mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 03:03:04 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
244f957b8f | ||
|
|
bc5bc969c8 | ||
|
|
8186585201 | ||
|
|
eb665a55a8 | ||
|
|
d22737c77a | ||
|
|
aeb98f813a | ||
|
|
c8a7667a03 | ||
|
|
4c051388e1 | ||
|
|
91d9cf1dbd | ||
|
|
7f7e60bcbb | ||
|
|
8b6e9447f4 | ||
|
|
83f9fba209 | ||
|
|
b2ce2bcb83 | ||
|
|
135a3b4c9e | ||
|
|
2c8e8a8268 | ||
|
|
ee7949ae25 | ||
|
|
d636abd7a4 |
@@ -90,3 +90,6 @@ packages/protocol/src/generated/validation/*.aot.ts
|
||||
packages/desktop/.cache/
|
||||
packages/desktop/src-tauri/resources/managed-runtime/
|
||||
app.json
|
||||
|
||||
# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging.
|
||||
*.heapsnapshot
|
||||
|
||||
+11
-5
@@ -1,14 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## 0.9.0-beta.2 - 2026-09-18
|
||||
## 0.9.1 - 2026-09-22
|
||||
|
||||
### Added
|
||||
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer ([#5200](https://github.com/getpaseo/paseo/pull/5200) by [@leonardourci](https://github.com/leonardourci), [@sebgalind0](https://github.com/sebgalind0), [@rp4ri](https://github.com/rp4ri))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed every workspace showing the restart time as its last activity after relaunching ([#5040](https://github.com/getpaseo/paseo/pull/5040))
|
||||
- Fixed finished chats dropping out of Ready to review after a restart ([#5040](https://github.com/getpaseo/paseo/pull/5040))
|
||||
- Fixed launch resuming a provider session for every chat ever opened on the host ([#5040](https://github.com/getpaseo/paseo/pull/5040))
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon ([#5189](https://github.com/getpaseo/paseo/pull/5189) by [@bagutzu](https://github.com/bagutzu))
|
||||
- Fixed Import session offering only the newest 100 Codex conversations ([#5174](https://github.com/getpaseo/paseo/pull/5174) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed a Claude model whose ID carries a minor version, such as Opus 5.5, reverting to its major version in the composer once a turn finished ([#5200](https://github.com/getpaseo/paseo/pull/5200))
|
||||
|
||||
## 0.9.0-beta.1 - 2026-09-17
|
||||
## 0.9.0 - 2026-09-22
|
||||
|
||||
### Added
|
||||
|
||||
@@ -80,6 +84,8 @@
|
||||
- Fixed an updated app rejecting daemons that lack independent subscriptions ([#4737](https://github.com/getpaseo/paseo/pull/4737))
|
||||
- Fixed workspace and agent creation failing or leaving agent titles at "Loading…" on 0.8.0 and older daemons ([#4895](https://github.com/getpaseo/paseo/pull/4895))
|
||||
- Fixed Cursor models showing another model's thinking options ([#4180](https://github.com/getpaseo/paseo/pull/4180) by [@fidelix](https://github.com/fidelix))
|
||||
- Fixed Pi model pickers offering thinking levels the model does not support ([#4413](https://github.com/getpaseo/paseo/pull/4413) by [@mcowger](https://github.com/mcowger), [@therainisme](https://github.com/therainisme))
|
||||
- Fixed Pi sessions reporting the requested thinking level instead of the one Pi applied ([#4413](https://github.com/getpaseo/paseo/pull/4413))
|
||||
- Fixed voice-chat user messages showing the internal prompt wrapper instead of the transcript ([#4927](https://github.com/getpaseo/paseo/pull/4927))
|
||||
- Fixed History and Command Center search matching a query assembled from letters in separate words ([#4945](https://github.com/getpaseo/paseo/pull/4945))
|
||||
- Fixed workspace titles truncating early on touch layouts behind hidden diff stats ([#4698](https://github.com/getpaseo/paseo/pull/4698))
|
||||
|
||||
@@ -29,9 +29,23 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
|
||||
- **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`.
|
||||
- **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not.
|
||||
- **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal.
|
||||
- **Markdown blocks belong to presentation.** `agent-stream/presentation.ts` retains completed native
|
||||
blocks and parses only the growing last block during append. Splitting in the reducer discards
|
||||
paragraph separators and exposes fragments to plugin callbacks, which need the whole source text.
|
||||
- **Markdown blocks belong to presentation, and every assistant message is a block group.**
|
||||
`agent-stream/presentation.ts` splits a message into one display row per Markdown block, the same
|
||||
way whether it arrived streaming or as fetched history. Splitting in the reducer instead would
|
||||
discard paragraph separators and expose fragments to plugin callbacks, which need the whole source
|
||||
text. Live-head work stays cheap by parsing only the growing last block during append, retaining
|
||||
completed blocks by object identity, and caching the split per source item so a tail change does
|
||||
not re-split history. History was once left whole to keep cross-block Markdown context; that gave
|
||||
one message two shapes depending on how it arrived, and message-addressed features only worked on
|
||||
one of them.
|
||||
- **A row id is never a message id.** Block rows are `${messageId}:block:${n}` with
|
||||
`blockGroupId = messageId`, including single-block messages, so nothing can come to rely on the two
|
||||
being equal. `getStreamItemMessageId` in `presentation.ts` is the only way to go from a row to its
|
||||
message, and web rows carry it as `data-message-id` alongside `data-history-row-id`. Anything
|
||||
addressing a message — chat find, scroll-to-message, history reveal, the find expansion that lifts
|
||||
the render cap — uses the message id and must expect several rows to answer to it. Row ids stay for
|
||||
React keys, virtualizer measurement, scroll anchors, and per-row caches like assistant image
|
||||
occurrence keys.
|
||||
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
|
||||
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
|
||||
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
|
||||
|
||||
@@ -86,6 +86,13 @@ offline, then Updating messages until authoritative catch-up completes. Socket c
|
||||
certify that the displayed conversation is current. The timeline owner publishes freshness; the
|
||||
view renders it without a toast timer or a separate resume workflow.
|
||||
|
||||
The draft-create handoff has the same lifetime: the viewed-timeline owner releases it when the sync
|
||||
stops owing that chat a catch-up, not when the first authoritative page lands. Releasing it at the
|
||||
first page leaves a chat this client just created looking hydrated but still catching up, which
|
||||
shows Updating messages for a conversation that cannot be out of date. Closing the tab ends the
|
||||
obligation too, so a reopened chat starts from authoritative state instead of a stale optimistic
|
||||
one; disconnect and backgrounding keep it.
|
||||
|
||||
Foregrounding probes a nominally connected session immediately. A healthy response preserves the
|
||||
socket; a failed three-second probe starts reconnecting without waiting for the background heartbeat
|
||||
or retry backoff. This cannot keep a mobile socket alive after the operating system suspends it.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
Added
|
||||
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
|
||||
- Added Cmd/Ctrl+F Find to terminal scrollback
|
||||
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
|
||||
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
|
||||
- Added automatic Pull request tab opening once per workspace when a PR is detected
|
||||
- Added expandable plan cards, with rejected plans collapsed by default
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,9 @@
|
||||
Added
|
||||
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
|
||||
- Added Cmd/Ctrl+F Find to terminal scrollback
|
||||
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
|
||||
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
|
||||
- Added automatic Pull request tab opening once per workspace when a PR is detected
|
||||
- Added expandable plan cards, with rejected plans collapsed by default
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,9 @@
|
||||
Added
|
||||
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
|
||||
- Added Cmd/Ctrl+F Find to terminal scrollback
|
||||
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
|
||||
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
|
||||
- Added automatic Pull request tab opening once per workspace when a PR is detected
|
||||
- Added expandable plan cards, with rejected plans collapsed by default
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,9 @@
|
||||
Added
|
||||
- Added Cmd/Ctrl+F Find to file panes, with replacement in editable files
|
||||
- Added Cmd/Ctrl+F Find to terminal scrollback
|
||||
- Added Cmd/Ctrl+F Find to chat, including messages outside the loaded history window
|
||||
- Added Jump to file to mobile Changes, opening the changed-files tree in a sheet
|
||||
- Added automatic Pull request tab opening once per workspace when a PR is detected
|
||||
- Added expandable plan cards, with rejected plans collapsed by default
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-4z83Lg/QGO5amZfxfYjQL5yXjU0GB9jnFjzyZ5pyCPI=
|
||||
sha256-9UWtpZrCdyYyGq3HGNgSpU1+2Imu3oYqtSumq2DtANc=
|
||||
|
||||
Generated
+27
-27
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36196,7 +36196,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37262,12 +37262,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.9.0-beta.2",
|
||||
"@getpaseo/protocol": "0.9.0-beta.2",
|
||||
"@getpaseo/server": "0.9.0-beta.2",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/server": "0.9.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37514,10 +37514,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.9.0-beta.2",
|
||||
"@getpaseo/relay": "0.9.0-beta.2",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/relay": "0.9.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37528,7 +37528,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37772,7 +37772,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38668,7 +38668,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38697,14 +38697,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.9.0-beta.2",
|
||||
"@getpaseo/protocol": "0.9.0-beta.2",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38714,8 +38714,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.9.0-beta.2",
|
||||
"@getpaseo/protocol": "0.9.0-beta.2",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38737,7 +38737,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38764,7 +38764,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38982,16 +38982,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.9.0-beta.2",
|
||||
"@getpaseo/highlight": "0.9.0-beta.2",
|
||||
"@getpaseo/plugin": "0.9.0-beta.2",
|
||||
"@getpaseo/protocol": "0.9.0-beta.2",
|
||||
"@getpaseo/relay": "0.9.0-beta.2",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/highlight": "0.9.1",
|
||||
"@getpaseo/plugin": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/relay": "0.9.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -39895,7 +39895,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -38,6 +38,7 @@ import { gotoAppShell, openSettings, selectModel } from "../support/helpers/app"
|
||||
import { observeTimelineSubscriptions } from "../support/helpers/timeline-delivery";
|
||||
import { rememberTimelineRequestCounts } from "../support/helpers/timeline-resume";
|
||||
import {
|
||||
recordPanelToasts,
|
||||
switchWorkspaceViaSidebar,
|
||||
waitForWorkspaceInSidebar,
|
||||
workspaceDeckEntryLocator,
|
||||
@@ -1165,8 +1166,11 @@ test.describe("Agent message submission", () => {
|
||||
draftCreateScenario,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const toasts = await recordPanelToasts(page);
|
||||
const pending = await beginDraftCreateSubmission(page, draftCreateScenario);
|
||||
await completeDraftCreateSubmission(page, draftCreateScenario, pending);
|
||||
// A chat this client just created is current by construction; it is never out of date.
|
||||
await toasts.expectNeverShown("agent-updating-toast");
|
||||
});
|
||||
|
||||
test("restores a rejected submission and accepts its retry", async ({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BrowserContext } from "@playwright/test";
|
||||
import type { BrowserContext, Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
@@ -97,14 +97,20 @@ async function allowRichClipboard(context: BrowserContext): Promise<void> {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows of one assistant message. A message renders one row per Markdown block, so
|
||||
* every locator and every text walk below covers the group, not a single block.
|
||||
*/
|
||||
function assistantMessageBlocks(page: Page): Locator {
|
||||
return page.locator("[data-message-id]").filter({ has: page.getByTestId("assistant-message") });
|
||||
}
|
||||
|
||||
async function selectStructuredClipboardFixture(page: Page): Promise<void> {
|
||||
await selectAssistantTextRange(page, "Direct matches:", "ready");
|
||||
}
|
||||
|
||||
async function selectAssistantElement(page: Page, selector: string): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
await assistantMessage.locator(selector).evaluate((element) => {
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
@@ -124,9 +130,7 @@ async function selectAssistantListItemFromMarker(
|
||||
itemText: string,
|
||||
endText: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
const item = assistantMessage
|
||||
.locator(`[data-paseo-markdown-tag="${listTag}"]`)
|
||||
.filter({ hasText: itemText })
|
||||
@@ -170,9 +174,7 @@ async function doubleClickAssistantMarkdownText(
|
||||
tag: "code" | "strong",
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
await assistantMessage
|
||||
.locator(`[data-paseo-markdown-tag="${tag}"]`)
|
||||
.filter({ hasText: text })
|
||||
@@ -184,12 +186,20 @@ async function selectAssistantTextRange(
|
||||
startText: string,
|
||||
endText: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
await assistantMessage.evaluate(
|
||||
(element, selectedRange) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
await assistantMessageBlocks(page).evaluateAll(
|
||||
(blocks, selectedRange) => {
|
||||
// The message's text nodes in reading order, across every block row.
|
||||
const nodes: Text[] = [];
|
||||
for (const block of blocks) {
|
||||
const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
||||
let found = blockWalker.nextNode();
|
||||
while (found) {
|
||||
nodes.push(found as Text);
|
||||
found = blockWalker.nextNode();
|
||||
}
|
||||
}
|
||||
let cursor = 0;
|
||||
const walker = { nextNode: () => nodes[cursor++] ?? null };
|
||||
let startNode: Node | null = null;
|
||||
let startOffset = -1;
|
||||
let endNode: Node | null = null;
|
||||
@@ -241,12 +251,20 @@ async function selectAssistantAcrossCodeLines(
|
||||
startText: string,
|
||||
endText: string,
|
||||
): Promise<void> {
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
await assistantMessage.evaluate(
|
||||
(element, selectedRange) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
await assistantMessageBlocks(page).evaluateAll(
|
||||
(blocks, selectedRange) => {
|
||||
// The message's text nodes in reading order, across every block row.
|
||||
const nodes: Text[] = [];
|
||||
for (const block of blocks) {
|
||||
const blockWalker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
||||
let found = blockWalker.nextNode();
|
||||
while (found) {
|
||||
nodes.push(found as Text);
|
||||
found = blockWalker.nextNode();
|
||||
}
|
||||
}
|
||||
let cursor = 0;
|
||||
const walker = { nextNode: () => nodes[cursor++] ?? null };
|
||||
let startNode: Node | null = null;
|
||||
let startOffset = -1;
|
||||
let textNode = walker.nextNode();
|
||||
@@ -326,9 +344,7 @@ test("copying an assistant selection preserves Markdown structure and links", as
|
||||
);
|
||||
await openAgentRoute(page, agent);
|
||||
|
||||
const assistantMessage = page.getByTestId("assistant-message").filter({
|
||||
hasText: "Direct matches:",
|
||||
});
|
||||
const assistantMessage = assistantMessageBlocks(page);
|
||||
for (const [tag, text] of [
|
||||
["strong", "strong prose"],
|
||||
["em", "emphasized prose"],
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { TestInfo } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
type MockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
|
||||
import {
|
||||
expectReconnectingToastGone,
|
||||
@@ -15,19 +20,41 @@ import {
|
||||
|
||||
import { openCommandCenter, closeCommandCenter } from "../support/helpers/command-center";
|
||||
|
||||
const MAC_USER_AGENT =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
|
||||
|
||||
const FILLER = Array.from({ length: 400 }, (_, index) => `filler${index}`).join(" ");
|
||||
const STREAMED_RESPONSE = [
|
||||
"First paragraph with alpha-needle and pair-needle inside.",
|
||||
"Second paragraph with beta-needle and pair-needle inside.",
|
||||
`Third paragraph ${FILLER}`,
|
||||
].join("\n\n");
|
||||
|
||||
const RESPONSE =
|
||||
'İ😀hello **world**; hello *world*.\n\n[hello & world](https://example.com/hidden-destination)\n\n```ts\nconst a = "a.b";\n```\n\n- first cell\n- second cell';
|
||||
|
||||
// One message far taller than the viewport, so its first and last paragraph rows are
|
||||
// never in the virtualizer's window at the same time.
|
||||
const PARAGRAPH_PADDING = Array.from({ length: 40 }, (_, index) => `padding${index}`).join(" ");
|
||||
const TALL_RESPONSE = Array.from({ length: 30 }, (_, index) => {
|
||||
if (index === 0) return `Opening paragraph with span-needle. ${PARAGRAPH_PADDING}`;
|
||||
if (index === 29) return `Closing paragraph with span-needle. ${PARAGRAPH_PADDING}`;
|
||||
return `Paragraph ${index}. ${PARAGRAPH_PADDING}`;
|
||||
}).join("\n\n");
|
||||
|
||||
function query(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Find in pane", exact: true });
|
||||
}
|
||||
function status(page: Page) {
|
||||
return page.getByRole("status", { name: "Find matches" });
|
||||
}
|
||||
async function searchChat(page: Page, text: string) {
|
||||
async function openChatFind(page: Page) {
|
||||
await page.getByTestId("assistant-message").filter({ visible: true }).last().click();
|
||||
await page.keyboard.press("ControlOrMeta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
}
|
||||
async function searchChat(page: Page, text: string) {
|
||||
await openChatFind(page);
|
||||
await query(page).fill(text);
|
||||
}
|
||||
async function expectHighlight(page: Page, text: string) {
|
||||
@@ -71,6 +98,15 @@ async function nextMatch(page: Page) {
|
||||
async function previousMatch(page: Page) {
|
||||
await query(page).press("Shift+Enter");
|
||||
}
|
||||
async function expectFindStatus(page: Page, search: string, matches: string) {
|
||||
await query(page).fill(search);
|
||||
await expect(status(page)).toHaveText(matches, { timeout: 15_000 });
|
||||
}
|
||||
async function expectNextMatch(page: Page, matches: string, highlighted: string) {
|
||||
await nextMatch(page);
|
||||
await expect(status(page)).toHaveText(matches);
|
||||
await expectHighlight(page, highlighted);
|
||||
}
|
||||
async function closeFind(page: Page) {
|
||||
await query(page).press("Escape");
|
||||
await expect(query(page)).toHaveCount(0);
|
||||
@@ -103,14 +139,14 @@ test("loads older history through prompt navigation and finds repeated rendered
|
||||
await expect(page.getByText("Prompt number 0", { exact: true })).toHaveCount(0);
|
||||
await searchChat(page, "hello world");
|
||||
await expectHighlight(page, "hello world");
|
||||
await expect(status(page)).toHaveText("1 of 2 in message");
|
||||
await expect(status(page)).toHaveText("1 of 90");
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-find-highlight.png") });
|
||||
expect(jumps.requests()[0]).toMatchObject({ limit: 40, mergeWindow: true });
|
||||
await nextMatch(page);
|
||||
await expect(status(page)).toHaveText("2 of 2 in message");
|
||||
await expect(status(page)).toHaveText("2 of 90");
|
||||
await expectHighlight(page, "hello world");
|
||||
await previousMatch(page);
|
||||
await expect(status(page)).toHaveText("1 of 2 in message");
|
||||
await expect(status(page)).toHaveText("1 of 90");
|
||||
await query(page).fill("hello & world");
|
||||
await expectHighlight(page, "hello & world");
|
||||
await query(page).fill("a.b");
|
||||
@@ -123,6 +159,65 @@ test("loads older history through prompt navigation and finds repeated rendered
|
||||
}
|
||||
});
|
||||
|
||||
// The scope of the search is the whole chat, so the counter is the position across
|
||||
// every message that holds a hit, and Next walks off the end of one into the next.
|
||||
const ACROSS_PROMPT = "Find scope-needle in this chat";
|
||||
const ACROSS_RESPONSE = "Reply with scope-needle here.\n\nAnd scope-needle again there.";
|
||||
|
||||
/** The test id of the message that holds the current match, so it can be watched crossing one. */
|
||||
async function highlightedMessage(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const entry = Array.from(CSS.highlights.entries()).find(([name]) =>
|
||||
name.startsWith("paseo-chat-find-"),
|
||||
);
|
||||
const range = entry && Array.from(entry[1])[0];
|
||||
if (!(range instanceof Range)) return null;
|
||||
const message = range.startContainer.parentElement?.closest<HTMLElement>(
|
||||
'[data-testid="user-message"], [data-testid="assistant-message"]',
|
||||
);
|
||||
return message?.dataset.testid ?? null;
|
||||
});
|
||||
}
|
||||
async function expectHighlightedMessage(page: Page, testId: string) {
|
||||
await expect.poll(() => highlightedMessage(page)).toBe(testId);
|
||||
}
|
||||
|
||||
test("counts and steps through every match in the chat, not just the selected message", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-across-",
|
||||
title: "Chat Find across messages",
|
||||
initialPrompt: ACROSS_PROMPT,
|
||||
featureValues: { mockAssistantResponse: ACROSS_RESPONSE },
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
|
||||
// One hit in the prompt and two in the reply: three across the chat.
|
||||
await searchChat(page, "scope-needle");
|
||||
await expect(status(page)).toHaveText("1 of 3");
|
||||
await expectHighlight(page, "scope-needle");
|
||||
await expectHighlightedMessage(page, "user-message");
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-find-across-messages.png") });
|
||||
await expectNextMatch(page, "2 of 3", "scope-needle");
|
||||
await expectHighlightedMessage(page, "assistant-message");
|
||||
await expectNextMatch(page, "3 of 3", "scope-needle");
|
||||
await expectHighlightedMessage(page, "assistant-message");
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-find-last-match.png") });
|
||||
await expectNextMatch(page, "1 of 3", "scope-needle");
|
||||
await expectHighlightedMessage(page, "user-message");
|
||||
await previousMatch(page);
|
||||
await expect(status(page)).toHaveText("3 of 3");
|
||||
await expectHighlight(page, "scope-needle");
|
||||
await expectHighlightedMessage(page, "assistant-message");
|
||||
await closeFind(page);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects invisible source matches and ignores a late reply after closing", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -136,7 +231,7 @@ test("rejects invisible source matches and ignores a late reply after closing",
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByTestId("assistant-message")).toBeVisible();
|
||||
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
|
||||
await searchChat(page, "hidden-destination");
|
||||
await expect(status(page)).toHaveText("No matches");
|
||||
await query(page).fill("first cell second cell");
|
||||
@@ -166,7 +261,7 @@ test("shows a disconnected search failure and recovers with Retry", async ({ pag
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByTestId("assistant-message")).toBeVisible();
|
||||
await expect(page.getByTestId("assistant-message").first()).toBeVisible();
|
||||
await gate.drop();
|
||||
await expectReconnectingToastVisible(page);
|
||||
await searchChat(page, "hello world");
|
||||
@@ -186,6 +281,104 @@ test("shows a disconnected search failure and recovers with Retry", async ({ pag
|
||||
}
|
||||
});
|
||||
|
||||
interface StreamedReplyChat extends MockAgentWorkspace {
|
||||
/** Resolves once the streamed reply has finished arriving. */
|
||||
replyFinished(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the chat first, then streams a multi-block reply into it and waits for a
|
||||
* later block, so the block holding the early needles is no longer the growing row.
|
||||
*/
|
||||
async function openChatStreamingMultiBlockReply(page: Page): Promise<StreamedReplyChat> {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-live-blocks-",
|
||||
title: "Chat Find live blocks",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: STREAMED_RESPONSE,
|
||||
mockStreamingAssistantIntervalMs: 60,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Stream a multi block answer");
|
||||
await expect(page.getByText("beta-needle", { exact: false }).last()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
return {
|
||||
...agent,
|
||||
replyFinished: async () => {
|
||||
await agent.client.waitForFinish(agent.agentId, 60_000);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await agent.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
test("finds a hit in a message that streamed while the chat was open", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const chat = await openChatStreamingMultiBlockReply(page);
|
||||
try {
|
||||
await openChatFind(page);
|
||||
// The only hit is in the first paragraph, which is no longer the row that grows.
|
||||
await expectFindStatus(page, "alpha-needle", "1 of 1");
|
||||
await chat.replyFinished();
|
||||
await expectFindStatus(page, "beta-needle", "1 of 1");
|
||||
// One message, two Markdown blocks, one count across both.
|
||||
await expectFindStatus(page, "pair-needle", "1 of 2");
|
||||
await expectNextMatch(page, "2 of 2", "pair-needle");
|
||||
await closeFind(page);
|
||||
} finally {
|
||||
await chat.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Opens a chat whose newest reply is a single message far taller than the viewport,
|
||||
* behind enough older turns that the oldest one is not hydrated yet.
|
||||
*/
|
||||
async function openChatWithVirtualizedTallMessage(page: Page): Promise<MockAgentWorkspace> {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-virtualized-",
|
||||
title: "Chat Find virtualized message",
|
||||
featureValues: { mockAssistantResponse: TALL_RESPONSE },
|
||||
});
|
||||
try {
|
||||
for (let index = 0; index < 6; index++) {
|
||||
await agent.client.sendAgentMessage(agent.agentId, `Prompt number ${index}`);
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
}
|
||||
await openAgentRoute(page, agent);
|
||||
await expect(page.getByText("Prompt number 5", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Prompt number 0", { exact: true })).toHaveCount(0);
|
||||
return agent;
|
||||
} catch (error) {
|
||||
await agent.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Every block row of the selected message must be mounted for Find to see it, whatever
|
||||
// the virtualizer's scroll window holds. Otherwise a hit in a far paragraph is missing
|
||||
// from the count and Next walks off to the next message instead.
|
||||
test("counts every occurrence of a virtualized message and steps between them", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const chat = await openChatWithVirtualizedTallMessage(page);
|
||||
try {
|
||||
await openChatFind(page);
|
||||
await expectFindStatus(page, "span-needle", "1 of 12");
|
||||
await expectHighlight(page, "span-needle");
|
||||
await expectNextMatch(page, "2 of 12", "span-needle");
|
||||
await closeFind(page);
|
||||
} finally {
|
||||
await chat.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("finds text beyond the normal render cap and restores the cap when Find closes", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -194,7 +387,8 @@ test("finds text beyond the normal render cap and restores the cap when Find clo
|
||||
repoPrefix: "chat-find-cap-",
|
||||
title: "Chat Find long message",
|
||||
initialPrompt: "Render a long response",
|
||||
featureValues: { mockAssistantResponse: `${"background ".repeat(3100)}\n\n${tail}` },
|
||||
// One paragraph, so the needle is past the render cap of the row that holds it.
|
||||
featureValues: { mockAssistantResponse: `${"background ".repeat(3100)}${tail}` },
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
@@ -212,30 +406,94 @@ test("finds text beyond the normal render cap and restores the cap when Find clo
|
||||
}
|
||||
});
|
||||
|
||||
for (const shortcut of ["Control+f", "Meta+f"]) {
|
||||
test(`opens and refocuses Find with ${shortcut} from the composer`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
async function findFromComposer(page: Page, shortcut: string, testInfo: TestInfo) {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-composer-",
|
||||
title: "Chat Find from composer",
|
||||
initialPrompt: "Render the response",
|
||||
featureValues: { mockAssistantResponse: RESPONSE },
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentRoute(page, agent);
|
||||
await searchFromComposerWithoutLosingDraft(page, shortcut);
|
||||
await refocusFindAndReplaceQuery(page, shortcut);
|
||||
await page.screenshot({ path: testInfo.outputPath("find-from-composer.png") });
|
||||
await closeFind(page);
|
||||
await expectCommandCenterOwnsFindShortcut(page, shortcut);
|
||||
await fillComposerDraft(page, "Draft after closing Find");
|
||||
await expectComposerDraft(page, "Draft after closing Find");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
test("opens and refocuses Find with Control+f from the composer", async ({ page }, testInfo) => {
|
||||
await findFromComposer(page, "Control+f", testInfo);
|
||||
});
|
||||
|
||||
test.describe("macOS", () => {
|
||||
test.use({ userAgent: MAC_USER_AGENT });
|
||||
|
||||
test("opens and refocuses Find with Meta+f from the composer", async ({ page }, testInfo) => {
|
||||
await findFromComposer(page, "Meta+f", testInfo);
|
||||
});
|
||||
|
||||
// Control+F moves the caret forward on macOS, so chat Find must leave it alone.
|
||||
test("leaves Control+f to the composer", async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-composer-",
|
||||
title: "Chat Find from composer",
|
||||
repoPrefix: "chat-find-mac-control-f-",
|
||||
title: "Chat Find macOS Control+f",
|
||||
initialPrompt: "Render the response",
|
||||
featureValues: { mockAssistantResponse: RESPONSE },
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await openAgentRoute(page, agent);
|
||||
await searchFromComposerWithoutLosingDraft(page, shortcut);
|
||||
await refocusFindAndReplaceQuery(page, shortcut);
|
||||
await page.screenshot({ path: testInfo.outputPath("find-from-composer.png") });
|
||||
await closeFind(page);
|
||||
await expectCommandCenterOwnsFindShortcut(page, shortcut);
|
||||
await fillComposerDraft(page, "Draft after closing Find");
|
||||
await expectComposerDraft(page, "Draft after closing Find");
|
||||
await fillComposerDraft(page, "Keep this draft");
|
||||
await expectComposerFocused(page);
|
||||
await recordComposerKeydown(page);
|
||||
await page.keyboard.press("Control+f");
|
||||
await expect
|
||||
.poll(() => recordedComposerFindKeydown(page))
|
||||
.toEqual([{ key: "f", defaultPrevented: false }]);
|
||||
await expect(query(page)).toHaveCount(0);
|
||||
await expectComposerFocused(page);
|
||||
await expectComposerDraft(page, "Keep this draft");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
interface RecordedKeydown {
|
||||
key: string;
|
||||
defaultPrevented: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat Find listens on `document` in the capture phase, so the composer only ever
|
||||
* sees a Find keystroke after Find decided about it. That makes `defaultPrevented`
|
||||
* on the composer the evidence of who owns the key.
|
||||
*/
|
||||
async function recordComposerKeydown(page: Page) {
|
||||
await composerLocator(page).evaluate((element) => {
|
||||
const store = window as unknown as { __findKeydown: RecordedKeydown[] };
|
||||
store.__findKeydown = [];
|
||||
element.addEventListener("keydown", (event) => {
|
||||
store.__findKeydown.push({
|
||||
key: (event as KeyboardEvent).key,
|
||||
defaultPrevented: event.defaultPrevented,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function recordedComposerFindKeydown(page: Page): Promise<RecordedKeydown[]> {
|
||||
const recorded = await page.evaluate(
|
||||
() => (window as unknown as { __findKeydown: RecordedKeydown[] }).__findKeydown,
|
||||
);
|
||||
return recorded.filter((entry) => entry.key.toLowerCase() === "f");
|
||||
}
|
||||
|
||||
async function openFindFromComposer(page: Page, shortcut: string) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import type { FormPreferences } from "@/create-agent-preferences/preferences";
|
||||
import { closeCommandCenter, openCommandCenter } from "../support/helpers/command-center";
|
||||
import {
|
||||
@@ -79,6 +79,27 @@ test.describe("Command Center agent controls", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("shows an existing agent's only supported thinking level", async ({ page }) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "agent-controls-single-thinking-",
|
||||
title: "Single thinking level",
|
||||
model: "max-only-thinking-stream",
|
||||
thinkingOptionId: "max",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByTestId("agent-thinking-selector").filter({ visible: true }),
|
||||
).toHaveAccessibleName("Select thinking option (Max)");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("activates the best-matching row, not the highest-ranked group", async ({ page }) => {
|
||||
// "Mode › Load Test" matches "load" at offset 0; every "Model › Mock Load Test › …" row
|
||||
// matches at offset 5. Models used to outrank modes by fixed group rank, so Enter picked a
|
||||
|
||||
@@ -25,14 +25,21 @@ async function findInSource(page: Page, text: string) {
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill(text);
|
||||
}
|
||||
function querySelection(page: Page) {
|
||||
return query(page).evaluate((input: HTMLInputElement) => [
|
||||
input.selectionStart,
|
||||
input.selectionEnd,
|
||||
]);
|
||||
}
|
||||
/** The query box keeps the caret where the user left it, with nothing reselected. */
|
||||
async function expectQueryCaretKept(page: Page, text: string) {
|
||||
await expect.poll(() => querySelection(page)).toEqual([text.length, text.length]);
|
||||
await expect(query(page)).toHaveValue(text);
|
||||
}
|
||||
async function expectQuerySelected(page: Page, text: string) {
|
||||
await expect(query(page)).toBeFocused();
|
||||
await expect(query(page)).toHaveValue(text);
|
||||
await expect
|
||||
.poll(() =>
|
||||
query(page).evaluate((input: HTMLInputElement) => [input.selectionStart, input.selectionEnd]),
|
||||
)
|
||||
.toEqual([0, text.length]);
|
||||
await expect.poll(() => querySelection(page)).toEqual([0, text.length]);
|
||||
}
|
||||
async function closeFind(page: Page) {
|
||||
await query(page).press("Escape");
|
||||
@@ -428,3 +435,41 @@ test("Go to line keeps its dialog with Find closed and open", async ({
|
||||
await expect(status(page)).toHaveText("11 matches");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("macOS", () => {
|
||||
test.use({
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||
});
|
||||
|
||||
// Control+F moves the caret forward on macOS, so Find must only claim Command+F.
|
||||
test("opens Find with Meta+f and leaves Control+f to text editing", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}, testInfo) => {
|
||||
const workspace = await withWorkspace({ prefix: "pane-find-mac-" });
|
||||
await writeFile(path.join(workspace.repoPath, "source.txt"), "needle first\nneedle second\n");
|
||||
await workspace.navigateTo();
|
||||
await openSource(page, "source.txt");
|
||||
|
||||
await source(page).focus();
|
||||
await source(page).press("Control+f");
|
||||
await expect(query(page)).toHaveCount(0);
|
||||
|
||||
await source(page).press("Meta+f");
|
||||
await expect(query(page)).toBeFocused();
|
||||
await query(page).fill("needle");
|
||||
await expect(status(page)).toHaveText("1 of 2");
|
||||
|
||||
await test.step("Control+f inside the query box does not reclaim the field", async () => {
|
||||
await query(page).press("End");
|
||||
await query(page).press("Control+f");
|
||||
await expectQueryCaretKept(page, "needle");
|
||||
});
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath("mac-find.png") });
|
||||
await query(page).press("Meta+f");
|
||||
await expectQuerySelected(page, "needle");
|
||||
await closeFind(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface MockAgentOptions {
|
||||
port?: number;
|
||||
initialPrompt?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
modeId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
}
|
||||
@@ -43,6 +44,7 @@ export async function seedMockAgentWorkspace(
|
||||
title: options.title,
|
||||
modeId: options.modeId ?? "load-test",
|
||||
model: options.model ?? "e2e-fast-stream",
|
||||
thinkingOptionId: options.thinkingOptionId,
|
||||
initialPrompt: options.initialPrompt,
|
||||
featureValues: options.featureValues,
|
||||
});
|
||||
|
||||
@@ -111,6 +111,50 @@ export async function expectReconnectingToastGone(
|
||||
});
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__shownPanelToasts?: string[];
|
||||
}
|
||||
}
|
||||
|
||||
export interface PanelToastLog {
|
||||
/** Test ids of every toast that entered the DOM since recording started, in order. */
|
||||
shown(): Promise<string[]>;
|
||||
expectNeverShown(testId: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record every toast that enters the DOM from the next navigation onward. A toast can appear
|
||||
* and disappear within a few frames, so a settled `toHaveCount(0)` cannot prove one never showed.
|
||||
*/
|
||||
export async function recordPanelToasts(page: Page): Promise<PanelToastLog> {
|
||||
await page.addInitScript(() => {
|
||||
const shown: string[] = [];
|
||||
window.__shownPanelToasts = shown;
|
||||
const record = (node: Element) => {
|
||||
if (node.getAttribute("role") !== "alert") return;
|
||||
shown.push(node.getAttribute("data-testid") ?? "");
|
||||
};
|
||||
new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (!(node instanceof Element)) continue;
|
||||
record(node);
|
||||
for (const descendant of node.querySelectorAll('[role="alert"]')) record(descendant);
|
||||
}
|
||||
}
|
||||
}).observe(document, { childList: true, subtree: true });
|
||||
});
|
||||
|
||||
const shown = () => page.evaluate(() => window.__shownPanelToasts ?? []);
|
||||
return {
|
||||
shown,
|
||||
async expectNeverShown(testId: string): Promise<void> {
|
||||
expect(await shown()).not.toContain(testId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function expectHostConnectingOrOffline(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -6,3 +6,7 @@ export function ChatFind({ children }: ChatFindProps) {
|
||||
export function ChatFindExpansion({ children }: ChatFindExpansionProps) {
|
||||
return children(false);
|
||||
}
|
||||
/** Native has no chat find, so no message is ever selected by one. */
|
||||
export function useChatFindSelectedMessageId(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { View, Text } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PaneFind, type PaneFindHandle } from "@/pane-find";
|
||||
import { PaneFind, findShortcutPlatform, isFindShortcut, type PaneFindHandle } from "@/pane-find";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { usePaneFocus } from "@/panels/pane-context";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
@@ -19,11 +19,16 @@ import { hasActiveWebOverlay } from "@/lib/overlay-root";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { planTimelinePromptJump } from "@/timeline/timeline-sync-plan";
|
||||
import { ChatFindModel } from "./model";
|
||||
import { ChatFindModel, type ChatFindFailure } from "./model";
|
||||
import { createFindViewport } from "./viewport.web";
|
||||
import type { ChatFindProps, ChatFindExpansionProps } from "./types";
|
||||
|
||||
const Expansion = createContext<string | null>(null);
|
||||
const SelectedMessage = createContext<string | null>(null);
|
||||
const FAILURE_TEXT: Record<ChatFindFailure, string> = {
|
||||
connection: "paneFind.connectionFailure",
|
||||
historyChanged: "paneFind.historyChangedFailure",
|
||||
reveal: "paneFind.revealFailure",
|
||||
};
|
||||
const WIDGET_DATASET = { chatFindWidget: "true" };
|
||||
const ROOT_STYLE = {
|
||||
display: "flex",
|
||||
@@ -34,8 +39,17 @@ const ROOT_STYLE = {
|
||||
} as const;
|
||||
let nextHighlightId = 0;
|
||||
|
||||
export function ChatFindExpansion({ itemId, children }: ChatFindExpansionProps) {
|
||||
return children(useContext(Expansion) === itemId);
|
||||
/**
|
||||
* The message chat find has landed on, for surfaces that must keep it addressable:
|
||||
* the row that lifts its render cap, and the web virtualizer that has to mount every
|
||||
* block row of it so the whole message can be counted and highlighted.
|
||||
*/
|
||||
export function useChatFindSelectedMessageId(): string | null {
|
||||
return useContext(SelectedMessage);
|
||||
}
|
||||
|
||||
export function ChatFindExpansion({ messageId, children }: ChatFindExpansionProps) {
|
||||
return children(useChatFindSelectedMessageId() === messageId);
|
||||
}
|
||||
|
||||
export function ChatFind({
|
||||
@@ -44,15 +58,15 @@ export function ChatFind({
|
||||
epoch,
|
||||
items,
|
||||
viewportRef,
|
||||
revealLoadedItem,
|
||||
visibleItemIds,
|
||||
revealLoadedMessage,
|
||||
visibleMessageIds,
|
||||
children,
|
||||
}: ChatFindProps) {
|
||||
const { t } = useTranslation();
|
||||
const widget = useRef<PaneFindHandle>(null);
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const bindings = useRef({ viewportRef, revealLoadedItem, visibleItemIds });
|
||||
bindings.current = { viewportRef, revealLoadedItem, visibleItemIds };
|
||||
const bindings = useRef({ viewportRef, revealLoadedMessage, visibleMessageIds });
|
||||
bindings.current = { viewportRef, revealLoadedMessage, visibleMessageIds };
|
||||
const [highlightName] = useState(() => `paseo-chat-find-${++nextHighlightId}`);
|
||||
const { isInteractive } = usePaneFocus();
|
||||
const active = useRetainedPanelActive();
|
||||
@@ -92,12 +106,7 @@ export function ChatFind({
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
|
||||
return;
|
||||
if (
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
event.key.toLowerCase() === "f"
|
||||
) {
|
||||
if (isFindShortcut(event, findShortcutPlatform())) {
|
||||
event.preventDefault();
|
||||
model.open();
|
||||
widget.current?.focus();
|
||||
@@ -117,12 +126,12 @@ export function ChatFind({
|
||||
else if (state.phase === "error") status = t("paneFind.failed");
|
||||
else if (state.query.trim())
|
||||
status = state.count
|
||||
? t("paneFind.chatPosition", { current: state.occurrence + 1, total: state.count })
|
||||
? t("paneFind.position", { current: state.occurrence + 1, total: state.count })
|
||||
: t("paneFind.noMatches");
|
||||
return (
|
||||
<div ref={root} tabIndex={-1} style={ROOT_STYLE}>
|
||||
<style>{`::highlight(${highlightName}) { background-color: ${styles.highlight.backgroundColor}; color: ${styles.highlight.color}; }`}</style>
|
||||
<Expansion.Provider value={state.selectedItemId}>{children}</Expansion.Provider>
|
||||
<SelectedMessage.Provider value={state.selectedItemId}>{children}</SelectedMessage.Provider>
|
||||
{state.open && (
|
||||
<View style={styles.overlay} dataSet={WIDGET_DATASET}>
|
||||
<PaneFind
|
||||
@@ -135,9 +144,9 @@ export function ChatFind({
|
||||
onPrevious={model.previous}
|
||||
onClose={close}
|
||||
/>
|
||||
{state.error && (
|
||||
{state.failure && (
|
||||
<View style={styles.error}>
|
||||
<Text style={styles.errorText}>{t("paneFind.searchFailed")}</Text>
|
||||
<Text style={styles.errorText}>{t(FAILURE_TEXT[state.failure])}</Text>
|
||||
<Button size="xs" variant="ghost" onPress={model.retry}>
|
||||
{t("paneFind.retry")}
|
||||
</Button>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect, it } from "vitest";
|
||||
import { hydrateStreamState, type StreamItem } from "@/types/stream";
|
||||
import { ChatFindModel, type ChatFindOperations } from "./model";
|
||||
|
||||
function response(locations: Array<{ seq: number; role: "user" | "assistant" }>) {
|
||||
function response(locations: Array<{ seq: number; role: "user" | "assistant"; count?: number }>) {
|
||||
return {
|
||||
agentId: "agent",
|
||||
requestId: "request",
|
||||
@@ -159,11 +159,60 @@ it("retains a failed search for retry and distinguishes failure from zero matche
|
||||
model.open();
|
||||
model.setQuery("target");
|
||||
await expect.poll(() => model.getSnapshot().phase).toBe("error");
|
||||
expect(model.getSnapshot().error).toBe("Host disconnected");
|
||||
expect(model.getSnapshot().failure).toBe("connection");
|
||||
disconnected = false;
|
||||
model.retry();
|
||||
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
|
||||
expect(model.getSnapshot()).toMatchObject({ count: 0, error: null });
|
||||
expect(model.getSnapshot()).toMatchObject({ count: 0, failure: null });
|
||||
} finally {
|
||||
model.close();
|
||||
}
|
||||
});
|
||||
|
||||
// `load` is a host RPC like `search`, so its failure is a connection failure and the
|
||||
// widget must say so rather than blaming the reveal.
|
||||
it("reports a failed timeline load as a connection failure", async () => {
|
||||
const model = new ChatFindModel({
|
||||
async search() {
|
||||
return response([{ seq: 1, role: "assistant" }]);
|
||||
},
|
||||
async load() {
|
||||
throw new Error("Host disconnected");
|
||||
},
|
||||
async reveal() {
|
||||
return { count: 1, occurrence: 0 };
|
||||
},
|
||||
clear() {},
|
||||
});
|
||||
try {
|
||||
model.updateHistory("epoch", []);
|
||||
model.open();
|
||||
model.setQuery("target");
|
||||
await expect.poll(() => model.getSnapshot().phase).toBe("error");
|
||||
expect(model.getSnapshot().failure).toBe("connection");
|
||||
} finally {
|
||||
model.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a search result that outlived its epoch as a changed history", async () => {
|
||||
const model = new ChatFindModel({
|
||||
async search() {
|
||||
return response([{ seq: 1, role: "assistant" }]);
|
||||
},
|
||||
async load() {},
|
||||
async reveal() {
|
||||
return { count: 1, occurrence: 0 };
|
||||
},
|
||||
clear() {},
|
||||
});
|
||||
try {
|
||||
model.updateHistory("epoch", [message(1)]);
|
||||
model.open();
|
||||
model.setQuery("target");
|
||||
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
|
||||
model.updateHistory("epoch-2", [message(1)]);
|
||||
expect(model.getSnapshot()).toMatchObject({ phase: "error", failure: "historyChanged" });
|
||||
} finally {
|
||||
model.close();
|
||||
}
|
||||
@@ -187,7 +236,77 @@ it("can load a search location before the initial chat history has arrived", asy
|
||||
model.open();
|
||||
model.setQuery("target");
|
||||
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
|
||||
expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-1", count: 1, error: null });
|
||||
expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-1", count: 1, failure: null });
|
||||
} finally {
|
||||
model.close();
|
||||
}
|
||||
});
|
||||
|
||||
// The scope of the search is the whole chat, so the position spans every located
|
||||
// message: the host's estimate until a message is on screen, the rendered count after.
|
||||
it("reports the position across the whole chat and corrects it with rendered counts", async () => {
|
||||
const model = new ChatFindModel({
|
||||
async search() {
|
||||
return response([
|
||||
{ seq: 1, role: "assistant", count: 2 },
|
||||
{ seq: 2, role: "assistant", count: 3 },
|
||||
]);
|
||||
},
|
||||
async load() {},
|
||||
async reveal(id, _query, occurrence) {
|
||||
const count = id === "row-1" ? 2 : 1;
|
||||
return { count, occurrence: occurrence < 0 ? count - 1 : occurrence };
|
||||
},
|
||||
clear() {},
|
||||
});
|
||||
try {
|
||||
model.updateHistory("epoch", [message(1), message(2)]);
|
||||
model.open();
|
||||
model.setQuery("target");
|
||||
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
|
||||
expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-1", occurrence: 0, count: 5 });
|
||||
model.next();
|
||||
await expect.poll(() => model.getSnapshot().occurrence).toBe(1);
|
||||
expect(model.getSnapshot().count).toBe(5);
|
||||
model.next();
|
||||
await expect.poll(() => model.getSnapshot().selectedItemId).toBe("row-2");
|
||||
expect(model.getSnapshot()).toMatchObject({ occurrence: 2, count: 3 });
|
||||
model.previous();
|
||||
await expect.poll(() => model.getSnapshot().selectedItemId).toBe("row-1");
|
||||
expect(model.getSnapshot()).toMatchObject({ occurrence: 1, count: 3 });
|
||||
} finally {
|
||||
model.close();
|
||||
}
|
||||
});
|
||||
|
||||
// COMPAT(timelineSearchCount): a host that reports no counts is worth one match per
|
||||
// located message until a reveal replaces the estimate with the rendered count.
|
||||
it("counts one match per message from a host that reports no counts", async () => {
|
||||
const model = new ChatFindModel({
|
||||
async search() {
|
||||
return response([
|
||||
{ seq: 1, role: "assistant" },
|
||||
{ seq: 2, role: "assistant" },
|
||||
]);
|
||||
},
|
||||
async load() {},
|
||||
async reveal(id, _query, occurrence) {
|
||||
const count = id === "row-1" ? 2 : 1;
|
||||
return { count, occurrence: occurrence < 0 ? count - 1 : occurrence };
|
||||
},
|
||||
clear() {},
|
||||
});
|
||||
try {
|
||||
model.updateHistory("epoch", [message(1), message(2)]);
|
||||
model.open();
|
||||
model.setQuery("target");
|
||||
await expect.poll(() => model.getSnapshot().phase).toBe("ready");
|
||||
expect(model.getSnapshot()).toMatchObject({ selectedItemId: "row-1", occurrence: 0, count: 3 });
|
||||
model.next();
|
||||
await expect.poll(() => model.getSnapshot().occurrence).toBe(1);
|
||||
model.next();
|
||||
await expect.poll(() => model.getSnapshot().selectedItemId).toBe("row-2");
|
||||
expect(model.getSnapshot()).toMatchObject({ occurrence: 2, count: 3 });
|
||||
} finally {
|
||||
model.close();
|
||||
}
|
||||
|
||||
@@ -1,26 +1,59 @@
|
||||
import type { AgentTimelineSearchPayload } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
|
||||
type Location = AgentTimelineSearchPayload["locations"][number];
|
||||
/**
|
||||
* A message the host says contains the query. `count` is the host's estimate of its
|
||||
* occurrences until a reveal replaces it with the rendered count, so the whole-chat
|
||||
* position the widget shows is exact for every message that has been on screen. The
|
||||
* wire field is optional; the search boundary fills it in, so the interior trusts it.
|
||||
*/
|
||||
type Location = Omit<AgentTimelineSearchPayload["locations"][number], "count"> & {
|
||||
count: number;
|
||||
};
|
||||
interface Target {
|
||||
id: string;
|
||||
seq: number;
|
||||
before: number;
|
||||
}
|
||||
/**
|
||||
* Why a search stopped, so the widget can say something true. `connection` is the
|
||||
* host call itself failing, `historyChanged` is the timeline moving under a result
|
||||
* set, and `reveal` is a located match that never made it onto the screen.
|
||||
*/
|
||||
export type ChatFindFailure = "connection" | "historyChanged" | "reveal";
|
||||
|
||||
class ChatFindFailureError extends Error {
|
||||
constructor(
|
||||
readonly failure: ChatFindFailure,
|
||||
options?: { cause: unknown },
|
||||
) {
|
||||
super(`Chat find failed: ${failure}`, options);
|
||||
}
|
||||
}
|
||||
|
||||
function tagged<T>(failure: ChatFindFailure, operation: Promise<T>): Promise<T> {
|
||||
return operation.catch((cause: unknown) => {
|
||||
throw cause instanceof ChatFindFailureError
|
||||
? cause
|
||||
: new ChatFindFailureError(failure, { cause });
|
||||
});
|
||||
}
|
||||
|
||||
interface Snapshot {
|
||||
open: boolean;
|
||||
query: string;
|
||||
phase: "idle" | "searching" | "loading" | "ready" | "error";
|
||||
selectedItemId: string | null;
|
||||
/** Position across the whole chat: the search scope, not the selected message. */
|
||||
occurrence: number;
|
||||
count: number;
|
||||
error: string | null;
|
||||
failure: ChatFindFailure | null;
|
||||
}
|
||||
export interface ChatFindOperations {
|
||||
search(query: string, cursor?: number): Promise<AgentTimelineSearchPayload>;
|
||||
load(epoch: string, seq: number): Promise<unknown>;
|
||||
reveal(
|
||||
itemId: string,
|
||||
messageId: string,
|
||||
query: string,
|
||||
occurrence: number,
|
||||
signal: AbortSignal,
|
||||
@@ -36,7 +69,7 @@ export class ChatFindModel {
|
||||
selectedItemId: null,
|
||||
occurrence: 0,
|
||||
count: 0,
|
||||
error: null,
|
||||
failure: null,
|
||||
};
|
||||
private listeners = new Set<() => void>();
|
||||
private historyListeners = new Set<() => void>();
|
||||
@@ -46,6 +79,7 @@ export class ChatFindModel {
|
||||
private locations: Location[] = [];
|
||||
private resolved = new Map<Location, Target>();
|
||||
private current = -1;
|
||||
private inMessage = { occurrence: 0, count: 0 };
|
||||
private abort = new AbortController();
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
constructor(private operations: ChatFindOperations) {}
|
||||
@@ -73,7 +107,7 @@ export class ChatFindModel {
|
||||
};
|
||||
readonly close = () => {
|
||||
this.cancel();
|
||||
this.publish({ open: false, phase: "idle", selectedItemId: null, count: 0, error: null });
|
||||
this.publish({ open: false, phase: "idle", selectedItemId: null, count: 0, failure: null });
|
||||
};
|
||||
readonly setQuery = (query: string) => {
|
||||
this.cancel();
|
||||
@@ -81,13 +115,14 @@ export class ChatFindModel {
|
||||
this.resultEpoch = null;
|
||||
this.resolved.clear();
|
||||
this.current = -1;
|
||||
this.inMessage = { occurrence: 0, count: 0 };
|
||||
this.publish({
|
||||
query,
|
||||
phase: query.trim() ? "searching" : "idle",
|
||||
selectedItemId: null,
|
||||
occurrence: 0,
|
||||
count: 0,
|
||||
error: null,
|
||||
failure: null,
|
||||
});
|
||||
if (!query.trim()) return;
|
||||
const signal = this.abort.signal;
|
||||
@@ -102,20 +137,30 @@ export class ChatFindModel {
|
||||
this.publish({
|
||||
phase: "error",
|
||||
selectedItemId: null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
// Every operation call site is tagged, so an untagged failure is a bug here
|
||||
// rather than a known outcome; `connection` is the least misleading thing to
|
||||
// say about one, and its copy already asks the reader to check and retry.
|
||||
failure: error instanceof ChatFindFailureError ? error.failure : "connection",
|
||||
});
|
||||
}
|
||||
private async search(signal: AbortSignal) {
|
||||
try {
|
||||
let cursor: number | undefined;
|
||||
do {
|
||||
const result = await this.operations.search(this.state.query, cursor);
|
||||
const result = await tagged("connection", this.operations.search(this.state.query, cursor));
|
||||
if (signal.aborted) return;
|
||||
const historyChanged = this.epoch !== null && result.epoch !== this.epoch;
|
||||
const searchChanged = this.resultEpoch !== null && result.epoch !== this.resultEpoch;
|
||||
if (historyChanged || searchChanged) throw new Error("History changed; search again");
|
||||
if (historyChanged || searchChanged) throw new ChatFindFailureError("historyChanged");
|
||||
this.resultEpoch = result.epoch;
|
||||
this.locations.push(...result.locations);
|
||||
this.locations.push(
|
||||
...result.locations.map((location) => ({
|
||||
seq: location.seq,
|
||||
role: location.role,
|
||||
// COMPAT(timelineSearchCount): hosts before v0.9.0 send no count; remove after 2027-09-22.
|
||||
count: location.count ?? 1,
|
||||
})),
|
||||
);
|
||||
cursor = result.nextCursor ?? undefined;
|
||||
} while (cursor !== undefined);
|
||||
await this.navigate(0, 1, signal);
|
||||
@@ -130,7 +175,7 @@ export class ChatFindModel {
|
||||
epoch !== this.resultEpoch &&
|
||||
this.state.open
|
||||
) {
|
||||
this.fail(new Error("History changed; search again"), this.abort.signal);
|
||||
this.fail(new ChatFindFailureError("historyChanged"), this.abort.signal);
|
||||
}
|
||||
this.epoch = epoch;
|
||||
this.items = items;
|
||||
@@ -159,7 +204,7 @@ export class ChatFindModel {
|
||||
this.historyListeners.delete(check);
|
||||
signal.removeEventListener("abort", cancelled);
|
||||
if (target) resolve(target);
|
||||
else reject(new Error("Could not load this search location; retry"));
|
||||
else reject(new ChatFindFailureError("reveal"));
|
||||
};
|
||||
const check = () => {
|
||||
const target = this.target(location, false);
|
||||
@@ -176,7 +221,7 @@ export class ChatFindModel {
|
||||
private async navigate(index: number, direction: 1 | -1, signal: AbortSignal) {
|
||||
const epoch = this.resultEpoch;
|
||||
if (epoch === null) return;
|
||||
this.publish({ phase: "loading", error: null });
|
||||
this.publish({ phase: "loading", failure: null });
|
||||
let remaining = this.locations.length;
|
||||
while (remaining-- > 0 && this.locations.length) {
|
||||
index = (index + this.locations.length) % this.locations.length;
|
||||
@@ -187,7 +232,7 @@ export class ChatFindModel {
|
||||
this.items.some((item) => item.id === cached.id && item.timelineCursor?.seq === cached.seq);
|
||||
let target = stillLoaded ? cached : this.target(location, true);
|
||||
if (!target) {
|
||||
await this.operations.load(epoch, location.seq);
|
||||
await tagged("connection", this.operations.load(epoch, location.seq));
|
||||
if (signal.aborted) return;
|
||||
target = await this.waitForTarget(location, signal);
|
||||
}
|
||||
@@ -204,16 +249,14 @@ export class ChatFindModel {
|
||||
);
|
||||
index = this.locations.indexOf(location);
|
||||
this.publish({ selectedItemId: target.id });
|
||||
const result = await this.operations.reveal(
|
||||
target.id,
|
||||
this.state.query,
|
||||
direction === 1 ? 0 : -1,
|
||||
signal,
|
||||
const result = await tagged(
|
||||
"reveal",
|
||||
this.operations.reveal(target.id, this.state.query, direction === 1 ? 0 : -1, signal),
|
||||
);
|
||||
if (signal.aborted) return;
|
||||
if (result.count) {
|
||||
this.current = index;
|
||||
this.publish({ phase: "ready", ...result });
|
||||
this.publish({ phase: "ready", ...this.position(location, result) });
|
||||
return;
|
||||
}
|
||||
this.locations.splice(index, 1);
|
||||
@@ -222,23 +265,32 @@ export class ChatFindModel {
|
||||
this.operations.clear();
|
||||
this.publish({ phase: "ready", selectedItemId: null, count: 0, occurrence: 0 });
|
||||
}
|
||||
private position(location: Location, revealed: { occurrence: number; count: number }) {
|
||||
this.inMessage = revealed;
|
||||
location.count = revealed.count;
|
||||
let before = 0;
|
||||
let total = 0;
|
||||
for (const candidate of this.locations) {
|
||||
if (candidate === location) before = total;
|
||||
total += candidate.count;
|
||||
}
|
||||
return { occurrence: before + revealed.occurrence, count: total };
|
||||
}
|
||||
private async move(direction: 1 | -1) {
|
||||
if (this.state.phase !== "ready" || !this.state.count) return;
|
||||
this.cancel();
|
||||
const signal = this.abort.signal;
|
||||
try {
|
||||
const occurrence = this.state.occurrence + direction;
|
||||
if (occurrence >= 0 && occurrence < this.state.count && this.state.selectedItemId) {
|
||||
const occurrence = this.inMessage.occurrence + direction;
|
||||
if (occurrence >= 0 && occurrence < this.inMessage.count && this.state.selectedItemId) {
|
||||
this.publish({ phase: "loading" });
|
||||
const result = await this.operations.reveal(
|
||||
this.state.selectedItemId,
|
||||
this.state.query,
|
||||
occurrence,
|
||||
signal,
|
||||
const result = await tagged(
|
||||
"reveal",
|
||||
this.operations.reveal(this.state.selectedItemId, this.state.query, occurrence, signal),
|
||||
);
|
||||
if (signal.aborted) return;
|
||||
if (result.count) {
|
||||
this.publish({ phase: "ready", ...result });
|
||||
this.publish({ phase: "ready", ...this.position(this.locations[this.current]!, result) });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { findRenderedMatches } from "./ranges.web";
|
||||
import { findMessageMatches, findRenderedMatches } from "./ranges.web";
|
||||
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
@@ -10,6 +10,36 @@ function content(html: string) {
|
||||
document.body.append(row);
|
||||
return row;
|
||||
}
|
||||
function message(messageId: string, ...blocks: string[]) {
|
||||
const root = document.createElement("div");
|
||||
for (const block of blocks) {
|
||||
const row = document.createElement("div");
|
||||
row.dataset.messageId = messageId;
|
||||
row.innerHTML = `<div data-message-text="true"><div data-paseo-markdown-tag="p">${block}</div></div>`;
|
||||
root.append(row);
|
||||
}
|
||||
document.body.append(root);
|
||||
return root;
|
||||
}
|
||||
it("collects a message's occurrences across its block rows in row order", () => {
|
||||
const root = message("message-1", "alpha one", "beta", "alpha two");
|
||||
const other = document.createElement("div");
|
||||
other.dataset.messageId = "message-2";
|
||||
other.innerHTML =
|
||||
'<div data-message-text="true"><div data-paseo-markdown-tag="p">alpha three</div></div>';
|
||||
root.append(other);
|
||||
expect(findMessageMatches(root, "message-1", "alpha").map((range) => range.toString())).toEqual([
|
||||
"alpha",
|
||||
"alpha",
|
||||
]);
|
||||
expect(
|
||||
findMessageMatches(root, "message-1", "alpha").map(
|
||||
(range) => range.startContainer.parentElement?.textContent,
|
||||
),
|
||||
).toEqual(["alpha one", "alpha two"]);
|
||||
expect(findMessageMatches(root, "message-2", "alpha")).toHaveLength(1);
|
||||
expect(findMessageMatches(null, "message-1", "alpha")).toEqual([]);
|
||||
});
|
||||
it("finds each actual occurrence across inline formatting with original Unicode offsets", () => {
|
||||
const row = content(
|
||||
'<div data-paseo-markdown-tag="p">İ😀hello <strong>world</strong> then <em>hello</em> world</div>',
|
||||
|
||||
@@ -4,6 +4,23 @@ const BLOCKS = ["p", "pre", "li", "td", "th", "h1", "h2", "h3", "h4", "h5", "h6"
|
||||
const IGNORED =
|
||||
'[data-paseo-markdown-ignore="true"], [aria-hidden="true"], button, [role="button"], svg, script, style';
|
||||
|
||||
/** The rows of one message, in reading order. Each is one Markdown block. */
|
||||
export function findMessageRows(root: HTMLElement | null, messageId: string): HTMLElement[] {
|
||||
if (!root) return [];
|
||||
return Array.from(
|
||||
root.querySelectorAll<HTMLElement>(`[data-message-id="${CSS.escape(messageId)}"]`),
|
||||
);
|
||||
}
|
||||
|
||||
/** Occurrences in one message, ordered by row and then by position inside the row. */
|
||||
export function findMessageMatches(
|
||||
root: HTMLElement | null,
|
||||
messageId: string,
|
||||
query: string,
|
||||
): Range[] {
|
||||
return findMessageRows(root, messageId).flatMap((row) => findRenderedMatches(row, query));
|
||||
}
|
||||
|
||||
/** Local offsets belong to the DOM that supplied the text, never to a host parser. */
|
||||
export function findRenderedMatches(row: HTMLElement, query: string): Range[] {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
@@ -8,11 +8,11 @@ export interface ChatFindProps {
|
||||
epoch: string | null;
|
||||
items: StreamItem[];
|
||||
viewportRef: RefObject<StreamViewportHandle | null>;
|
||||
revealLoadedItem(itemId: string): boolean;
|
||||
visibleItemIds: ReadonlySet<string>;
|
||||
revealLoadedMessage(messageId: string): boolean;
|
||||
visibleMessageIds: ReadonlySet<string>;
|
||||
children: ReactNode;
|
||||
}
|
||||
export interface ChatFindExpansionProps {
|
||||
itemId: string;
|
||||
messageId: string;
|
||||
children(renderFullContent: boolean): ReactNode;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { createFindViewport } from "./viewport.web";
|
||||
import type { ChatFindProps } from "./types";
|
||||
import type { StreamViewportHandle } from "../strategy";
|
||||
|
||||
interface Capture {
|
||||
occurrence?: { signal: AbortSignal; targetTop(): number | null };
|
||||
}
|
||||
|
||||
function transcript(messageId: string, blocks: string[]): HTMLElement {
|
||||
const root = document.createElement("div");
|
||||
for (const [index, text] of blocks.entries()) {
|
||||
const row = document.createElement("div");
|
||||
row.setAttribute("data-history-row-id", `${messageId}:block:${index}`);
|
||||
row.setAttribute("data-message-id", messageId);
|
||||
row.innerHTML = `<div data-message-text="true"><div data-paseo-markdown-tag="p">${text}</div></div>`;
|
||||
root.append(row);
|
||||
}
|
||||
document.body.append(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function viewport(root: HTMLElement, capture: Capture) {
|
||||
const handle: StreamViewportHandle = {
|
||||
scrollToBottom() {},
|
||||
prepareForViewportChange() {},
|
||||
scrollToMessage(_messageId, occurrence) {
|
||||
if (occurrence) capture.occurrence = occurrence;
|
||||
},
|
||||
};
|
||||
const bindings: Pick<ChatFindProps, "viewportRef" | "revealLoadedMessage" | "visibleMessageIds"> =
|
||||
{
|
||||
viewportRef: { current: handle },
|
||||
revealLoadedMessage: () => false,
|
||||
visibleMessageIds: new Set(["message-1"]),
|
||||
};
|
||||
return createFindViewport({
|
||||
getBindings: () => bindings,
|
||||
getRoot: () => root,
|
||||
highlightName: "paseo-chat-find-test",
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
CSS.highlights.delete("paseo-chat-find-test");
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it("counts occurrences across every block row of the message", async () => {
|
||||
const root = transcript("message-1", ["needle one needle", "no hits", "needle three"]);
|
||||
const capture: Capture = {};
|
||||
const found = await viewport(root, capture).reveal(
|
||||
"message-1",
|
||||
"needle",
|
||||
2,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(found).toEqual({ occurrence: 2, count: 3 });
|
||||
});
|
||||
|
||||
// The settle loop asks for the target position every frame while the viewport moves,
|
||||
// and rows mount and unmount as it does. A flat index into the message would name a
|
||||
// different occurrence each time the row set changed; the chosen row owns it instead.
|
||||
it("keeps the chosen occurrence when an earlier row unmounts mid-scroll", async () => {
|
||||
const root = transcript("message-1", ["needle one needle", "needle three"]);
|
||||
const capture: Capture = {};
|
||||
const found = await viewport(root, capture).reveal(
|
||||
"message-1",
|
||||
"needle",
|
||||
2,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(found).toEqual({ occurrence: 2, count: 3 });
|
||||
|
||||
const settled = capture.occurrence?.targetTop();
|
||||
expect(settled).toBeTypeOf("number");
|
||||
expect(highlightedText()).toEqual(["needle"]);
|
||||
expect(highlightedRow()).toBe("message-1:block:1");
|
||||
|
||||
root.firstElementChild?.remove();
|
||||
expect(capture.occurrence?.targetTop()).toBeTypeOf("number");
|
||||
expect(highlightedRow()).toBe("message-1:block:1");
|
||||
});
|
||||
|
||||
function highlightedRanges(): Range[] {
|
||||
const highlight = CSS.highlights.get("paseo-chat-find-test");
|
||||
return highlight ? Array.from(highlight).filter((range) => range instanceof Range) : [];
|
||||
}
|
||||
|
||||
function highlightedText(): string[] {
|
||||
return highlightedRanges().map((range) => range.toString());
|
||||
}
|
||||
|
||||
function highlightedRow(): string | undefined {
|
||||
const row =
|
||||
highlightedRanges()[0]?.startContainer.parentElement?.closest<HTMLElement>(
|
||||
"[data-history-row-id]",
|
||||
);
|
||||
return row?.dataset.historyRowId;
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import type { ChatFindOperations } from "./model";
|
||||
import type { ChatFindProps } from "./types";
|
||||
import { findRenderedMatches } from "./ranges.web";
|
||||
import { findMessageMatches, findMessageRows, findRenderedMatches } from "./ranges.web";
|
||||
|
||||
interface ViewportInput {
|
||||
getBindings(): Pick<ChatFindProps, "viewportRef" | "revealLoadedItem" | "visibleItemIds">;
|
||||
getBindings(): Pick<ChatFindProps, "viewportRef" | "revealLoadedMessage" | "visibleMessageIds">;
|
||||
getRoot(): HTMLElement | null;
|
||||
highlightName: string;
|
||||
}
|
||||
|
||||
function rowOf(range: Range): HTMLElement | null {
|
||||
return range.startContainer.parentElement?.closest<HTMLElement>("[data-history-row-id]") ?? null;
|
||||
}
|
||||
|
||||
export function createFindViewport({
|
||||
getBindings,
|
||||
getRoot,
|
||||
@@ -16,7 +21,9 @@ export function createFindViewport({
|
||||
clear() {
|
||||
CSS.highlights.delete(highlightName);
|
||||
},
|
||||
reveal(itemId, query, occurrence, signal) {
|
||||
// A message renders as one row per Markdown block, so an occurrence can sit in
|
||||
// any of them and the chosen one decides where the viewport lands.
|
||||
reveal(messageId, query, occurrence, signal) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let frame = 0;
|
||||
let started = false;
|
||||
@@ -40,23 +47,21 @@ export function createFindViewport({
|
||||
return;
|
||||
}
|
||||
const current = getBindings();
|
||||
if (!current.visibleItemIds.has(itemId)) {
|
||||
current.revealLoadedItem(itemId);
|
||||
if (!current.visibleMessageIds.has(messageId)) {
|
||||
current.revealLoadedMessage(messageId);
|
||||
frame = requestAnimationFrame(poll);
|
||||
return;
|
||||
}
|
||||
if (!started) {
|
||||
current.viewportRef.current?.scrollToMessage?.(itemId);
|
||||
current.viewportRef.current?.scrollToMessage?.(messageId);
|
||||
started = true;
|
||||
}
|
||||
const row = getRoot()?.querySelector<HTMLElement>(
|
||||
`[data-history-row-id="${CSS.escape(itemId)}"]`,
|
||||
);
|
||||
if (!row || !row.getBoundingClientRect().height) {
|
||||
const rows = findMessageRows(getRoot(), messageId);
|
||||
if (!rows.some((row) => row.getBoundingClientRect().height)) {
|
||||
frame = requestAnimationFrame(poll);
|
||||
return;
|
||||
}
|
||||
const ranges = findRenderedMatches(row, query);
|
||||
const ranges = findMessageMatches(getRoot(), messageId, query);
|
||||
const index =
|
||||
occurrence < 0 ? ranges.length - 1 : Math.min(occurrence, ranges.length - 1);
|
||||
const range = ranges[index];
|
||||
@@ -65,10 +70,18 @@ export function createFindViewport({
|
||||
resolve({ occurrence: 0, count: 0 });
|
||||
return;
|
||||
}
|
||||
current.viewportRef.current?.scrollToMessage?.(itemId, {
|
||||
// The settle loop re-resolves the range every frame because scrolling
|
||||
// invalidates it. Re-counting the whole message would let a row mounting or
|
||||
// unmounting mid-scroll shift which occurrence a fixed index names, so the
|
||||
// occurrence is pinned to its own row for the rest of this reveal.
|
||||
const row = rowOf(range);
|
||||
const indexInRow = ranges
|
||||
.slice(0, index)
|
||||
.filter((earlier) => rowOf(earlier) === row).length;
|
||||
current.viewportRef.current?.scrollToMessage?.(messageId, {
|
||||
signal,
|
||||
targetTop(currentRow) {
|
||||
const selected = findRenderedMatches(currentRow, query)[index];
|
||||
targetTop() {
|
||||
const selected = row ? findRenderedMatches(row, query)[indexInRow] : undefined;
|
||||
if (!selected) return null;
|
||||
CSS.highlights.set(highlightName, new Highlight(selected));
|
||||
const widget = getRoot()?.querySelector<HTMLElement>(
|
||||
|
||||
@@ -153,7 +153,7 @@ describe("useChatOutline", () => {
|
||||
timelineEpoch,
|
||||
enabled,
|
||||
viewportRef,
|
||||
visibleItemIds: new Set(),
|
||||
visibleMessageIds: new Set(),
|
||||
onJumpError: vi.fn(),
|
||||
}),
|
||||
{
|
||||
@@ -236,7 +236,7 @@ describe("useChatOutline", () => {
|
||||
prompts: [{ seq: 1, timestamp: new Date(1).toISOString(), preview: "prompt" }],
|
||||
});
|
||||
const scrollToMessage = vi.fn();
|
||||
const revealLoadedItem = vi.fn(() => true);
|
||||
const revealLoadedMessage = vi.fn(() => true);
|
||||
const viewport: StreamViewportHandle = {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
@@ -253,7 +253,7 @@ describe("useChatOutline", () => {
|
||||
},
|
||||
];
|
||||
const { result, rerender } = renderHook(
|
||||
({ visibleItemIds }) =>
|
||||
({ visibleMessageIds }) =>
|
||||
useChatOutline({
|
||||
agentId: "agent-1",
|
||||
serverId: "server-1",
|
||||
@@ -263,18 +263,18 @@ describe("useChatOutline", () => {
|
||||
enabled: true,
|
||||
viewportRef,
|
||||
onJumpError: vi.fn(),
|
||||
visibleItemIds,
|
||||
revealLoadedItem,
|
||||
visibleMessageIds,
|
||||
revealLoadedMessage,
|
||||
}),
|
||||
{ initialProps: { visibleItemIds: new Set<string>() } },
|
||||
{ initialProps: { visibleMessageIds: new Set<string>() } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
|
||||
await act(async () => result.current.jumpToPrompt(1));
|
||||
expect(revealLoadedItem).toHaveBeenCalledWith("older-prompt");
|
||||
expect(revealLoadedMessage).toHaveBeenCalledWith("older-prompt");
|
||||
expect(scrollToMessage).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ visibleItemIds: new Set(["older-prompt"]) });
|
||||
rerender({ visibleMessageIds: new Set(["older-prompt"]) });
|
||||
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledWith("older-prompt"));
|
||||
});
|
||||
|
||||
@@ -286,7 +286,7 @@ describe("useChatOutline", () => {
|
||||
const fetch = deferred<void>();
|
||||
runtime.fetchAgentTimeline.mockReturnValue(fetch.promise);
|
||||
const scrollToMessage = vi.fn();
|
||||
const revealLoadedItem = vi.fn(() => true);
|
||||
const revealLoadedMessage = vi.fn(() => true);
|
||||
const viewportRef = {
|
||||
current: {
|
||||
scrollToBottom: vi.fn(),
|
||||
@@ -302,7 +302,7 @@ describe("useChatOutline", () => {
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1 },
|
||||
};
|
||||
const { result, rerender } = renderHook(
|
||||
({ tail, visibleItemIds }) =>
|
||||
({ tail, visibleMessageIds }) =>
|
||||
useChatOutline({
|
||||
agentId: "agent-1",
|
||||
serverId: "server-1",
|
||||
@@ -312,13 +312,13 @@ describe("useChatOutline", () => {
|
||||
enabled: true,
|
||||
viewportRef,
|
||||
onJumpError: vi.fn(),
|
||||
visibleItemIds,
|
||||
revealLoadedItem,
|
||||
visibleMessageIds,
|
||||
revealLoadedMessage,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
tail: [] as (typeof fetchedPrompt)[],
|
||||
visibleItemIds: new Set<string>(),
|
||||
visibleMessageIds: new Set<string>(),
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -327,11 +327,11 @@ describe("useChatOutline", () => {
|
||||
act(() => result.current.jumpToPrompt(1));
|
||||
await waitFor(() => expect(runtime.fetchAgentTimeline).toHaveBeenCalledOnce());
|
||||
|
||||
rerender({ tail: [fetchedPrompt], visibleItemIds: new Set<string>() });
|
||||
await waitFor(() => expect(revealLoadedItem).toHaveBeenCalledWith(fetchedPrompt.id));
|
||||
rerender({ tail: [fetchedPrompt], visibleMessageIds: new Set<string>() });
|
||||
await waitFor(() => expect(revealLoadedMessage).toHaveBeenCalledWith(fetchedPrompt.id));
|
||||
expect(scrollToMessage).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ tail: [fetchedPrompt], visibleItemIds: new Set([fetchedPrompt.id]) });
|
||||
rerender({ tail: [fetchedPrompt], visibleMessageIds: new Set([fetchedPrompt.id]) });
|
||||
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledOnce());
|
||||
expect(scrollToMessage).toHaveBeenCalledWith(fetchedPrompt.id);
|
||||
await act(async () => fetch.resolve());
|
||||
@@ -355,8 +355,8 @@ describe("useChatOutline", () => {
|
||||
timestamp: new Date(2),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2 },
|
||||
};
|
||||
const revealLoadedItem = vi.fn();
|
||||
revealLoadedItem.mockReturnValue(false);
|
||||
const revealLoadedMessage = vi.fn();
|
||||
revealLoadedMessage.mockReturnValue(false);
|
||||
const { result } = renderHook(() =>
|
||||
useChatOutline({
|
||||
agentId: "agent-1",
|
||||
@@ -367,8 +367,8 @@ describe("useChatOutline", () => {
|
||||
enabled: true,
|
||||
viewportRef: { current: viewport },
|
||||
onJumpError: vi.fn(),
|
||||
visibleItemIds: new Set([livePrompt.id]),
|
||||
revealLoadedItem,
|
||||
visibleMessageIds: new Set([livePrompt.id]),
|
||||
revealLoadedMessage,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ export interface UseChatOutlineInput {
|
||||
enabled: boolean;
|
||||
viewportRef: RefObject<StreamViewportHandle | null>;
|
||||
onJumpError: () => void;
|
||||
visibleItemIds?: ReadonlySet<string>;
|
||||
revealLoadedItem?: (itemId: string) => boolean;
|
||||
visibleMessageIds?: ReadonlySet<string>;
|
||||
revealLoadedMessage?: (messageId: string) => boolean;
|
||||
}
|
||||
|
||||
export interface ChatOutline {
|
||||
@@ -53,8 +53,8 @@ export function useChatOutline({
|
||||
enabled,
|
||||
viewportRef,
|
||||
onJumpError,
|
||||
visibleItemIds,
|
||||
revealLoadedItem,
|
||||
visibleMessageIds,
|
||||
revealLoadedMessage,
|
||||
}: UseChatOutlineInput): ChatOutline {
|
||||
const [index, setIndex] = useState<AgentTimelinePromptIndexPayload | null>(null);
|
||||
const [pendingJump, setPendingJump] = useState<PendingPromptJump | null>(null);
|
||||
@@ -136,8 +136,8 @@ export function useChatOutline({
|
||||
const target = loadedItems.find((item) => item.timelineCursor?.seq === pendingJump.seq);
|
||||
if (target) {
|
||||
if (pendingJump.hasScrolled) return;
|
||||
if (visibleItemIds?.has(target.id) === false) {
|
||||
revealLoadedItem?.(target.id);
|
||||
if (visibleMessageIds?.has(target.id) === false) {
|
||||
revealLoadedMessage?.(target.id);
|
||||
return;
|
||||
}
|
||||
viewportRef.current?.scrollToMessage?.(target.id);
|
||||
@@ -148,7 +148,7 @@ export function useChatOutline({
|
||||
return;
|
||||
}
|
||||
if (pendingJump.fetchSettled) setPendingJump(null);
|
||||
}, [loadedItems, pendingJump, revealLoadedItem, viewportRef, visibleItemIds]);
|
||||
}, [loadedItems, pendingJump, revealLoadedMessage, viewportRef, visibleMessageIds]);
|
||||
|
||||
const jumpToPrompt = useCallback(
|
||||
(seq: number) => {
|
||||
@@ -156,7 +156,7 @@ export function useChatOutline({
|
||||
setPendingJump(null);
|
||||
const loaded = loadedItems.find((item) => item.timelineCursor?.seq === seq);
|
||||
if (loaded) {
|
||||
if (revealLoadedItem?.(loaded.id)) {
|
||||
if (revealLoadedMessage?.(loaded.id)) {
|
||||
const requestId = nextJumpRequestIdRef.current;
|
||||
setPendingJump({ requestId, seq, fetchSettled: true, hasScrolled: false });
|
||||
return;
|
||||
@@ -180,7 +180,7 @@ export function useChatOutline({
|
||||
});
|
||||
});
|
||||
},
|
||||
[agentId, index, loadedItems, onJumpError, revealLoadedItem, serverId, viewportRef],
|
||||
[agentId, index, loadedItems, onJumpError, revealLoadedMessage, serverId, viewportRef],
|
||||
);
|
||||
|
||||
return { prompts, activePrompt, jumpToPrompt, reportReadingPosition };
|
||||
|
||||
@@ -210,21 +210,113 @@ describe("stream presentation through installed plugins", () => {
|
||||
expect(result.head).toMatchObject([{ text: "```ts\nconst a = 1;\n\nconst b = 2;" }]);
|
||||
});
|
||||
|
||||
it("leaves fetched native Markdown intact, including cross-paragraph references", () => {
|
||||
// One rendering path: a fetched message is the same block group as a streamed one,
|
||||
// which is what lets find, scroll-to-message and history reveal address it by id.
|
||||
// A link reference definition stays with the paragraph that uses it, so the split
|
||||
// never leaves an empty row behind and the reference still resolves.
|
||||
it("splits an assistant message into the same blocks through history and through the live head", () => {
|
||||
const source = hydrateStreamState([
|
||||
{
|
||||
event: assistant("[Link][docs]\n\n[docs]: https://example.com"),
|
||||
event: assistant("[Link][docs]\n\n[docs]: https://example.com\n\nClosing paragraph."),
|
||||
timestamp: new Date(1000),
|
||||
},
|
||||
]);
|
||||
const messageId = source[0]!.id;
|
||||
const present = (placement: "tail" | "head") =>
|
||||
rows(
|
||||
createStreamPresentation()({
|
||||
...presentationOptions,
|
||||
tail: placement === "tail" ? source : [],
|
||||
head: placement === "head" ? source : [],
|
||||
transform: undefined,
|
||||
}),
|
||||
);
|
||||
expect(present("tail")).toMatchObject([
|
||||
{
|
||||
id: `${messageId}:block:0`,
|
||||
blockGroupId: messageId,
|
||||
blockIndex: 0,
|
||||
text: "[Link][docs]\n\n[docs]: https://example.com",
|
||||
},
|
||||
{
|
||||
id: `${messageId}:block:1`,
|
||||
blockGroupId: messageId,
|
||||
blockIndex: 1,
|
||||
text: "Closing paragraph.",
|
||||
},
|
||||
]);
|
||||
const blocks = (items: StreamItem[]) =>
|
||||
items.map((item) => [item.id, item.kind === "assistant_message" ? item.text : null]);
|
||||
expect(blocks(present("head"))).toEqual(blocks(present("tail")));
|
||||
});
|
||||
|
||||
// Rows are addressed by id from outside presentation, so the same text has to land on
|
||||
// the same ids whether the reader watched it arrive or opened the chat afterwards.
|
||||
it("gives a streamed message the block ids its hydrated text would have had", () => {
|
||||
const text = "Intro paragraph.\n\n# Heading\n\n- first\n- second\n\nClosing paragraph.";
|
||||
const harness = streamHarness();
|
||||
for (let end = 8; end < text.length; end += 8) {
|
||||
harness.send(assistant(text.slice(end - 8, end)));
|
||||
}
|
||||
harness.send(assistant(text.slice(text.length - (text.length % 8 || 8))));
|
||||
const streamed = rows(harness.send({ type: "turn_completed", provider: "claude" }));
|
||||
const hydrated = rows(
|
||||
createStreamPresentation()({
|
||||
...presentationOptions,
|
||||
tail: hydrateStreamState([{ event: assistant(text), timestamp: new Date(1000) }]),
|
||||
head: [],
|
||||
transform: undefined,
|
||||
}),
|
||||
);
|
||||
expect(streamed.map((item) => item.id)).toEqual(hydrated.map((item) => item.id));
|
||||
expect(streamed.map((item) => item.kind === "assistant_message" && item.text)).toEqual(
|
||||
hydrated.map((item) => item.kind === "assistant_message" && item.text),
|
||||
);
|
||||
});
|
||||
|
||||
it("repoints a block at the canonical source that replaced the streamed one", () => {
|
||||
const harness = streamHarness();
|
||||
harness.send(assistant("Streamed answer"));
|
||||
const streamed = rows(harness.render());
|
||||
expect(streamed[0]).not.toHaveProperty("timelineCursor");
|
||||
const source = harness.source();
|
||||
const live = source.head[0];
|
||||
if (live?.kind !== "assistant_message") throw new Error("Expected a live assistant message");
|
||||
const canonical = { ...live, timelineCursor: { epoch: "epoch-1", seq: 42 } };
|
||||
const present = createStreamPresentation();
|
||||
present({ ...presentationOptions, tail: [], head: [live], transform: undefined });
|
||||
const repointed = rows(
|
||||
present({ ...presentationOptions, tail: [], head: [canonical], transform: undefined }),
|
||||
);
|
||||
expect(repointed).toMatchObject([
|
||||
{
|
||||
id: streamed[0]!.id,
|
||||
text: "Streamed answer",
|
||||
timelineCursor: { epoch: "epoch-1", seq: 42 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a single-paragraph message a one-block group", () => {
|
||||
const source = hydrateStreamState([
|
||||
{ event: assistant("Only one paragraph."), timestamp: new Date(1000) },
|
||||
]);
|
||||
const messageId = source[0]!.id;
|
||||
const result = createStreamPresentation()({
|
||||
...presentationOptions,
|
||||
tail: source,
|
||||
head: [],
|
||||
transform: undefined,
|
||||
});
|
||||
expect(result.tail).toEqual(source);
|
||||
expect(result.tail[0]).toBe(source[0]);
|
||||
expect(result.tail).toMatchObject([
|
||||
{
|
||||
id: `${messageId}:block:0`,
|
||||
blockGroupId: messageId,
|
||||
blockIndex: 0,
|
||||
text: "Only one paragraph.",
|
||||
},
|
||||
]);
|
||||
expect(result.tail[0]!.id).not.toBe(messageId);
|
||||
});
|
||||
|
||||
it("continues to stream inline reasoning with a stable plugin row", () => {
|
||||
@@ -441,7 +533,13 @@ describe("timeline presentation", () => {
|
||||
text: "<spoken-input>Example</spoken-input><instruction>Explain this XML.</instruction>",
|
||||
},
|
||||
];
|
||||
expect(projectTimelineItems(items)).toEqual(items);
|
||||
expect(projectTimelineItems(items)).toEqual(
|
||||
items.map((item) =>
|
||||
item.kind === "assistant_message"
|
||||
? { ...item, id: `${item.id}:block:0`, blockGroupId: item.id, blockIndex: 0 }
|
||||
: item,
|
||||
),
|
||||
);
|
||||
expect(projectTimelineItems(items)[0]).toBe(items[0]);
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,31 @@ function retainItems(previous: StreamItem[], next: StreamItem[]): StreamItem[] {
|
||||
: next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The source message a display row belongs to. Every assistant message is split into
|
||||
* Markdown blocks, so an assistant row id never equals its message id; anything that
|
||||
* addresses a message — find, scroll-to-message, history reveal — asks for this.
|
||||
*/
|
||||
export function getStreamItemMessageId(item: StreamItem): string {
|
||||
return item.kind === "assistant_message" ? (item.blockGroupId ?? item.id) : item.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* A block is reusable only when it still stands for the same source state. Text and
|
||||
* turn are what the reader sees; cursor and timestamp are what the timeline reads back
|
||||
* through the row — a reused block carrying a pre-canonical cursor reports a stale
|
||||
* reading position and weakens fork-boundary resolution.
|
||||
*/
|
||||
function isReusableBlock(block: AssistantMessageItem, source: AssistantMessageItem, id: string) {
|
||||
return (
|
||||
block.id === id &&
|
||||
block.turnId === source.turnId &&
|
||||
block.timestamp.getTime() === source.timestamp.getTime() &&
|
||||
block.timelineCursor?.epoch === source.timelineCursor?.epoch &&
|
||||
block.timelineCursor?.seq === source.timelineCursor?.seq
|
||||
);
|
||||
}
|
||||
|
||||
/** Source messages reach plugins before any Markdown splitting or Overview grouping. */
|
||||
export function createStreamPresentation() {
|
||||
const userMessageCache = new WeakMap<UserMessageItem, UserMessageItem>();
|
||||
@@ -54,6 +79,13 @@ export function createStreamPresentation() {
|
||||
let preparedLevel: ToolCallDetailLevel | undefined;
|
||||
let preparedHistory: PreparedToolCallHistory | null = null;
|
||||
|
||||
/**
|
||||
* One display row per Markdown block. Each block renders on its own, so a construct
|
||||
* that needs context from another block does not resolve: `splitMarkdownBlocks` keeps
|
||||
* link reference definitions with the block that uses them, but a reference pointing
|
||||
* at a definition several blocks away renders as literal text. Streamed messages
|
||||
* always behaved this way; history now matches them.
|
||||
*/
|
||||
function nativeBlocks(item: StreamItem): StreamItem[] {
|
||||
if (item.kind === "user_message") return [presentUserMessage(item)];
|
||||
if (item.kind !== "assistant_message") return [item];
|
||||
@@ -71,11 +103,9 @@ export function createStreamPresentation() {
|
||||
growingText =
|
||||
previous[previous.length - 1]!.text + item.text.slice(previousSource.text.length);
|
||||
}
|
||||
const textBlocks = splitMarkdownBlocks(growingText);
|
||||
if (prefix.length + textBlocks.length < 2) {
|
||||
blocksBySource.set(item, [item]);
|
||||
return [item];
|
||||
}
|
||||
const parsed = splitMarkdownBlocks(growingText);
|
||||
// Whitespace-only text has no block, and a message still owns exactly one row.
|
||||
const textBlocks = parsed.length > 0 || prefix.length > 0 ? parsed : [""];
|
||||
|
||||
const blocks = [...prefix];
|
||||
for (const [offset, text] of textBlocks.entries()) {
|
||||
@@ -87,9 +117,7 @@ export function createStreamPresentation() {
|
||||
}
|
||||
const existing = previous?.[index];
|
||||
const id = `${item.id}:block:${index}`;
|
||||
// Completed display blocks keep their first cursor and object identity while
|
||||
// the source message grows. The source itself always retains the latest cursor.
|
||||
if (existing?.id === id && existing.text === blockText && existing.turnId === item.turnId) {
|
||||
if (existing?.text === blockText && isReusableBlock(existing, item, id)) {
|
||||
blocks.push(existing);
|
||||
continue;
|
||||
}
|
||||
@@ -108,13 +136,11 @@ export function createStreamPresentation() {
|
||||
return (input: PresentationInput) => {
|
||||
// Retained history is not reprojected or regrouped on each live text update.
|
||||
if (historySource !== input.tail || historyTransform !== input.transform) {
|
||||
// One rendering path: history splits the same way the live head does, and
|
||||
// `blocksBySource` keeps both the live block identities and the split cost
|
||||
// from being paid again when only the tail's array identity changed.
|
||||
historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap<StreamItem>(
|
||||
(item) => {
|
||||
// Preserve live block identities at completion; fetched native Markdown
|
||||
// stays whole so links and other cross-block constructs keep their context.
|
||||
if (item.kind === "assistant_message") return blocksBySource.get(item) ?? [item];
|
||||
return [item.kind === "user_message" ? presentUserMessage(item) : item];
|
||||
},
|
||||
nativeBlocks,
|
||||
);
|
||||
historySource = input.tail;
|
||||
historyTransform = input.transform;
|
||||
|
||||
@@ -5,7 +5,7 @@ interface PromptJumpScrollMetrics {
|
||||
}
|
||||
|
||||
export interface PromptJumpSettleViewport {
|
||||
findTargetTop(itemId: string): number | null;
|
||||
findTargetTop(messageId: string): number | null;
|
||||
getContainerTop(): number;
|
||||
getScrollMetrics(): PromptJumpScrollMetrics;
|
||||
setScrollTop(scrollTop: number): void;
|
||||
@@ -13,7 +13,7 @@ export interface PromptJumpSettleViewport {
|
||||
}
|
||||
|
||||
export interface PromptJumpSettleController {
|
||||
start(itemId: string): void;
|
||||
start(messageId: string): void;
|
||||
cancel(): void;
|
||||
isActive(): boolean;
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export function createPromptJumpSettleController(input: {
|
||||
requestFrame: (callback: () => void) => number;
|
||||
cancelFrame: (frameId: number) => void;
|
||||
}): PromptJumpSettleController {
|
||||
let activeItemId: string | null = null;
|
||||
let activeMessageId: string | null = null;
|
||||
let frameId: number | null = null;
|
||||
let sampledFrames = 0;
|
||||
let stableFrames = 0;
|
||||
@@ -41,7 +41,7 @@ export function createPromptJumpSettleController(input: {
|
||||
function finish(): void {
|
||||
if (frameId !== null) input.cancelFrame(frameId);
|
||||
unsubscribeFromUserIntent?.();
|
||||
activeItemId = null;
|
||||
activeMessageId = null;
|
||||
frameId = null;
|
||||
sampledFrames = 0;
|
||||
stableFrames = 0;
|
||||
@@ -54,11 +54,11 @@ export function createPromptJumpSettleController(input: {
|
||||
|
||||
function tick(): void {
|
||||
frameId = null;
|
||||
const itemId = activeItemId;
|
||||
if (itemId === null) return;
|
||||
const messageId = activeMessageId;
|
||||
if (messageId === null) return;
|
||||
|
||||
sampledFrames += 1;
|
||||
const targetTop = input.viewport.findTargetTop(itemId);
|
||||
const targetTop = input.viewport.findTargetTop(messageId);
|
||||
if (targetTop !== null) {
|
||||
const delta = distanceFromLandingPosition(targetTop, input.viewport.getContainerTop());
|
||||
if (Math.abs(delta) <= POSITION_TOLERANCE_PX) {
|
||||
@@ -72,7 +72,7 @@ export function createPromptJumpSettleController(input: {
|
||||
const before = input.viewport.getScrollMetrics();
|
||||
input.viewport.setScrollTop(before.scrollTop + delta);
|
||||
const after = input.viewport.getScrollMetrics();
|
||||
const adjustedTargetTop = input.viewport.findTargetTop(itemId);
|
||||
const adjustedTargetTop = input.viewport.findTargetTop(messageId);
|
||||
let remainingDelta = 0;
|
||||
if (adjustedTargetTop !== null) {
|
||||
remainingDelta = distanceFromLandingPosition(
|
||||
@@ -97,15 +97,15 @@ export function createPromptJumpSettleController(input: {
|
||||
}
|
||||
|
||||
return {
|
||||
start(itemId) {
|
||||
start(messageId) {
|
||||
finish();
|
||||
activeItemId = itemId;
|
||||
activeMessageId = messageId;
|
||||
unsubscribeFromUserIntent = input.viewport.subscribeToUserIntent(finish);
|
||||
scheduleNextFrame();
|
||||
},
|
||||
cancel: finish,
|
||||
isActive() {
|
||||
return activeItemId !== null;
|
||||
return activeMessageId !== null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,12 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual";
|
||||
import {
|
||||
defaultRangeExtractor,
|
||||
measureElement as measureVirtualElement,
|
||||
useVirtualizer,
|
||||
type Range as VirtualRange,
|
||||
} from "@tanstack/react-virtual";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
@@ -36,6 +41,8 @@ import {
|
||||
createHistoryStartSettleScheduler,
|
||||
type HistoryStartSettleScheduler,
|
||||
} from "./history-start-settle-scheduler";
|
||||
import { useChatFindSelectedMessageId } from "@/agent-stream/chat-find";
|
||||
import { getStreamItemMessageId } from "./presentation";
|
||||
import { useScrollToMessage } from "./use-scroll-to-message.web";
|
||||
|
||||
interface CreateWebStreamStrategyInput {
|
||||
@@ -369,6 +376,26 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const activationKey = routeBottomAnchorRequest?.requestKey ?? props.agentId;
|
||||
const isActivationReady = !hasRouteBottomAnchorRequest || isAuthoritativeHistoryReady;
|
||||
|
||||
// Chat find counts and highlights occurrences from the DOM, so every block row of
|
||||
// the message it selected has to be mounted even when the scroll window is nowhere
|
||||
// near it. Without this a hit in a far paragraph is invisible to the count and Next
|
||||
// walks off to the following message.
|
||||
const chatFindMessageId = useChatFindSelectedMessageId();
|
||||
const chatFindRowIndexes = useMemo(() => {
|
||||
if (!chatFindMessageId) return null;
|
||||
const indexes = segments.historyVirtualized.flatMap((item, index) =>
|
||||
getStreamItemMessageId(item) === chatFindMessageId ? [index] : [],
|
||||
);
|
||||
return indexes.length > 0 ? indexes : null;
|
||||
}, [chatFindMessageId, segments.historyVirtualized]);
|
||||
const rangeExtractor = useCallback(
|
||||
(range: VirtualRange) => {
|
||||
const visible = defaultRangeExtractor(range);
|
||||
if (!chatFindRowIndexes) return visible;
|
||||
return [...new Set([...visible, ...chatFindRowIndexes])].sort((left, right) => left - right);
|
||||
},
|
||||
[chatFindRowIndexes],
|
||||
);
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: segments.historyVirtualized.length,
|
||||
enabled: shouldUseVirtualizer,
|
||||
@@ -379,6 +406,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return row ? estimateStreamItemHeight(row) : 120;
|
||||
},
|
||||
measureElement: measureVirtualElement,
|
||||
rangeExtractor,
|
||||
scrollMargin: VIRTUALIZER_SCROLL_MARGIN_PX,
|
||||
useAnimationFrameWithResizeObserver: true,
|
||||
overscan: 8,
|
||||
@@ -1192,7 +1220,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
);
|
||||
const mountedHistoryRows = useMemo(() => {
|
||||
return segments.historyMounted.map((item, index) => (
|
||||
<div key={item.id} data-history-row-id={item.id} style={streamRowStyle}>
|
||||
<div
|
||||
key={item.id}
|
||||
data-history-row-id={item.id}
|
||||
data-message-id={getStreamItemMessageId(item)}
|
||||
style={streamRowStyle}
|
||||
>
|
||||
{renderHistoryMountedRow(item, index, segments.historyMounted)}
|
||||
</div>
|
||||
));
|
||||
@@ -1200,7 +1233,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const liveHeadRows = useMemo(() => {
|
||||
void liveHeadRowRevision;
|
||||
return segments.liveHead.map((item, index) => (
|
||||
<div key={item.id} data-history-row-id={item.id} style={streamRowStyle}>
|
||||
<div
|
||||
key={item.id}
|
||||
data-history-row-id={item.id}
|
||||
data-message-id={getStreamItemMessageId(item)}
|
||||
style={streamRowStyle}
|
||||
>
|
||||
{renderLiveHeadRow(item, index, segments.liveHead)}
|
||||
</div>
|
||||
));
|
||||
@@ -1253,6 +1291,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
data-history-row-id={item.id}
|
||||
data-message-id={getStreamItemMessageId(item)}
|
||||
ref={measureVirtualizedRowElement}
|
||||
style={renderVirtualRowStyle(virtualRow.start)}
|
||||
>
|
||||
|
||||
@@ -40,13 +40,20 @@ export interface StreamEdgeSlotProps {
|
||||
ListFooterComponentStyle?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A caller that landed on one occurrence inside the message owns where the viewport
|
||||
* settles. It resolved the occurrence against a specific row already, so it takes no
|
||||
* row here and returns null once that row is gone.
|
||||
*/
|
||||
export interface ScrollToMessageOccurrence {
|
||||
signal: AbortSignal;
|
||||
targetTop(): number | null;
|
||||
}
|
||||
|
||||
export interface StreamViewportHandle {
|
||||
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
|
||||
prepareForViewportChange: () => void;
|
||||
scrollToMessage?: (
|
||||
itemId: string,
|
||||
occurrence?: { signal: AbortSignal; targetTop(row: HTMLElement): number | null },
|
||||
) => void;
|
||||
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
|
||||
}
|
||||
|
||||
export interface StreamSegmentRenderers {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import type { Virtualizer } from "@tanstack/react-virtual";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getStreamItemMessageId } from "./presentation";
|
||||
import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle";
|
||||
import type { ScrollToMessageOccurrence } from "./strategy";
|
||||
|
||||
interface UseScrollToMessageInput {
|
||||
active: boolean;
|
||||
scrollContainerRef: React.RefObject<HTMLElement | null>;
|
||||
rowVirtualizer: Virtualizer<HTMLElement, Element>;
|
||||
historyVirtualized: readonly { id: string }[];
|
||||
historyVirtualized: readonly StreamItem[];
|
||||
cancelPendingStickToBottom: () => void;
|
||||
setFollowOutput: (value: boolean) => boolean;
|
||||
onNearBottomChange: (value: boolean) => void;
|
||||
@@ -32,24 +35,22 @@ export function useScrollToMessage({
|
||||
setFollowOutput,
|
||||
onNearBottomChange,
|
||||
}: UseScrollToMessageInput) {
|
||||
const occurrenceRef = useRef<
|
||||
{ signal: AbortSignal; targetTop(row: HTMLElement): number | null } | undefined
|
||||
>(undefined);
|
||||
const occurrenceRef = useRef<ScrollToMessageOccurrence | undefined>(undefined);
|
||||
const removeAbortListener = useRef<(() => void) | null>(null);
|
||||
const settleController = useMemo(
|
||||
() =>
|
||||
createPromptJumpSettleController({
|
||||
viewport: {
|
||||
findTargetTop(itemId) {
|
||||
const container = scrollContainerRef.current;
|
||||
const target = container?.querySelector<HTMLElement>(
|
||||
`[data-history-row-id="${CSS.escape(itemId)}"]`,
|
||||
);
|
||||
findTargetTop(messageId) {
|
||||
const occurrence = occurrenceRef.current;
|
||||
if (occurrence?.signal.aborted) return null;
|
||||
if (!target) return null;
|
||||
if (occurrence) return occurrence.targetTop(target);
|
||||
return target.getBoundingClientRect().top;
|
||||
if (occurrence) return occurrence.targetTop();
|
||||
// Every row of an assistant message carries the message id; the first one
|
||||
// is the message's top.
|
||||
const target = scrollContainerRef.current?.querySelector<HTMLElement>(
|
||||
`[data-message-id="${CSS.escape(messageId)}"]`,
|
||||
);
|
||||
return target ? target.getBoundingClientRect().top : null;
|
||||
},
|
||||
getContainerTop() {
|
||||
return scrollContainerRef.current?.getBoundingClientRect().top ?? 0;
|
||||
@@ -102,10 +103,7 @@ export function useScrollToMessage({
|
||||
}, [active, settleController]);
|
||||
|
||||
const scrollToMessage = useCallback(
|
||||
(
|
||||
itemId: string,
|
||||
occurrence?: { signal: AbortSignal; targetTop(row: HTMLElement): number | null },
|
||||
) => {
|
||||
(messageId: string, occurrence?: ScrollToMessageOccurrence) => {
|
||||
occurrenceRef.current = occurrence;
|
||||
removeAbortListener.current?.();
|
||||
const cancel = () => settleController.cancel();
|
||||
@@ -118,7 +116,7 @@ export function useScrollToMessage({
|
||||
setFollowOutput(false);
|
||||
|
||||
const mounted = container.querySelector<HTMLElement>(
|
||||
`[data-history-row-id="${CSS.escape(itemId)}"]`,
|
||||
`[data-message-id="${CSS.escape(messageId)}"]`,
|
||||
);
|
||||
if (mounted) {
|
||||
const delta =
|
||||
@@ -126,15 +124,17 @@ export function useScrollToMessage({
|
||||
container.getBoundingClientRect().top -
|
||||
PROMPT_JUMP_TOP_INSET_PX;
|
||||
container.scrollTop += delta;
|
||||
settleController.start(itemId);
|
||||
settleController.start(messageId);
|
||||
onNearBottomChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const index = historyVirtualized.findIndex((item) => item.id === itemId);
|
||||
const index = historyVirtualized.findIndex(
|
||||
(item) => getStreamItemMessageId(item) === messageId,
|
||||
);
|
||||
if (index >= 0) {
|
||||
rowVirtualizer.scrollToIndex(index, { align: "start" });
|
||||
settleController.start(itemId);
|
||||
settleController.start(messageId);
|
||||
onNearBottomChange(false);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
|
||||
import { getStreamItemMessageId } from "./presentation";
|
||||
|
||||
// Callers reveal a message, not a row: an assistant message is several block rows and
|
||||
// the window has to open at the first of them.
|
||||
function findMessageRowIndex(items: StreamItem[], messageId: string): number {
|
||||
return items.findIndex((item) => getStreamItemMessageId(item) === messageId);
|
||||
}
|
||||
|
||||
function findEarlierHistoryWindowStart(input: {
|
||||
items: StreamItem[];
|
||||
start: number;
|
||||
itemId: string | undefined;
|
||||
}): number {
|
||||
const targetIndex = input.itemId ? input.items.findIndex((item) => item.id === input.itemId) : -1;
|
||||
const targetIndex = input.itemId ? findMessageRowIndex(input.items, input.itemId) : -1;
|
||||
const targetStart = targetIndex >= 0 && targetIndex < input.start ? targetIndex : input.start;
|
||||
if (!input.itemId) {
|
||||
return findMountedWindowStart({
|
||||
@@ -90,7 +97,7 @@ export function useStreamHistoryWindow(input: {
|
||||
|
||||
const revealLoadedHistory = useCallback(
|
||||
(itemId?: string): boolean => {
|
||||
const targetIndex = itemId ? items.findIndex((item) => item.id === itemId) : -1;
|
||||
const targetIndex = itemId ? findMessageRowIndex(items, itemId) : -1;
|
||||
if (start === 0 || (itemId !== undefined && (targetIndex < 0 || targetIndex >= start))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { ToolCallDetailsContent } from "@/components/tool-call-details";
|
||||
import { QuestionFormCard } from "@/components/question-form-card";
|
||||
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
|
||||
import { createStreamPresentation } from "./presentation";
|
||||
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
|
||||
import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view";
|
||||
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
@@ -605,10 +605,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const handleTimelineHistoryLoadError = useCallback(() => {
|
||||
toast?.error(t("agentStream.historyLoadFailed"));
|
||||
}, [t, toast]);
|
||||
const visibleHistoryItemIds = useMemo(
|
||||
// Chat find and the chat outline address messages, and an assistant message is a
|
||||
// group of block rows, so this is a set of message ids and never of row ids.
|
||||
const visibleMessageIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
[...baseRenderModel.history, ...baseRenderModel.segments.liveHead].map((item) => item.id),
|
||||
[...baseRenderModel.history, ...baseRenderModel.segments.liveHead].map(
|
||||
getStreamItemMessageId,
|
||||
),
|
||||
),
|
||||
[baseRenderModel.history, baseRenderModel.segments.liveHead],
|
||||
);
|
||||
@@ -621,8 +625,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
enabled: supportsChatOutline && chatOutlineEnabled,
|
||||
viewportRef,
|
||||
onJumpError: handleTimelineHistoryLoadError,
|
||||
visibleItemIds: visibleHistoryItemIds,
|
||||
revealLoadedItem: revealLoadedHistory,
|
||||
visibleMessageIds,
|
||||
revealLoadedMessage: revealLoadedHistory,
|
||||
});
|
||||
|
||||
useImperativeHandle(
|
||||
@@ -718,7 +722,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
onOpenWorkspaceFile={handleInlinePathPress}
|
||||
toast={toast}
|
||||
>
|
||||
<ChatFindExpansion itemId={item.id}>
|
||||
<ChatFindExpansion messageId={getStreamItemMessageId(item)}>
|
||||
{(renderFullContent) => (
|
||||
<AssistantMessage
|
||||
renderFullContent={renderFullContent}
|
||||
@@ -1098,8 +1102,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
epoch={timelineEpoch}
|
||||
items={findItems}
|
||||
viewportRef={viewportRef}
|
||||
revealLoadedItem={revealLoadedHistory}
|
||||
visibleItemIds={visibleHistoryItemIds}
|
||||
revealLoadedMessage={revealLoadedHistory}
|
||||
visibleMessageIds={visibleMessageIds}
|
||||
>
|
||||
<ToolCallSheetProvider>
|
||||
<AssistantSelectionCopySurface style={stylesheet.container}>
|
||||
|
||||
@@ -87,6 +87,31 @@ function selectNodeContents(element: Element): Selection {
|
||||
return selection;
|
||||
}
|
||||
|
||||
interface TranscriptMessage {
|
||||
messageId: string;
|
||||
blocks: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The web transcript's shape: a scroll container holding one row per Markdown block,
|
||||
* each row carrying its message id and wrapping its own assistant message element.
|
||||
*/
|
||||
function mountTranscript(messages: TranscriptMessage[]): HTMLElement {
|
||||
const transcript = document.createElement("div");
|
||||
transcript.setAttribute("data-testid", "agent-chat-scroll");
|
||||
for (const { messageId, blocks } of messages) {
|
||||
for (const [index, text] of blocks.entries()) {
|
||||
const row = document.createElement("div");
|
||||
row.setAttribute("data-history-row-id", `${messageId}:block:${index}`);
|
||||
row.setAttribute("data-message-id", messageId);
|
||||
row.innerHTML = `<div data-testid="assistant-message"><div data-paseo-markdown-tag="p">${text}</div></div>`;
|
||||
transcript.append(row);
|
||||
}
|
||||
}
|
||||
document.body.append(transcript);
|
||||
return transcript;
|
||||
}
|
||||
|
||||
function copiedMarkdown(selection: Selection): string | null {
|
||||
return createAssistantSelectionClipboardContent(selection)?.plainText ?? null;
|
||||
}
|
||||
@@ -110,6 +135,36 @@ describe("assistant selection copy ranges", () => {
|
||||
expect(copiedMarkdown(selectText(outside, 0, 7))).toBeNull();
|
||||
});
|
||||
|
||||
it("copies a selection that runs across the block rows of one message", () => {
|
||||
const transcript = mountTranscript([
|
||||
{ messageId: "message-1", blocks: ["First paragraph.", "Second paragraph."] },
|
||||
]);
|
||||
const blocks = transcript.querySelectorAll('[data-paseo-markdown-tag="p"]');
|
||||
expect(copiedMarkdown(selectRange(blocks[0]!, 6, blocks[1]!, 6))).toBe("paragraph.\n\nSecond");
|
||||
});
|
||||
|
||||
it("does not replace the browser clipboard for a selection reaching a second message", () => {
|
||||
const transcript = mountTranscript([
|
||||
{ messageId: "message-1", blocks: ["First paragraph.", "Second paragraph."] },
|
||||
{ messageId: "message-2", blocks: ["Reply paragraph."] },
|
||||
]);
|
||||
const blocks = transcript.querySelectorAll('[data-paseo-markdown-tag="p"]');
|
||||
expect(copiedMarkdown(selectRange(blocks[1]!, 0, blocks[2]!, 5))).toBeNull();
|
||||
});
|
||||
|
||||
// A retained inactive panel renders the same agent, so the same message id can exist
|
||||
// twice in the document. A group is only ever read out of one transcript, and rows
|
||||
// that belong to no transcript are not a group at all.
|
||||
it("does not assemble a message group from rows outside a transcript", () => {
|
||||
const transcript = mountTranscript([
|
||||
{ messageId: "message-1", blocks: ["First paragraph.", "Second paragraph."] },
|
||||
]);
|
||||
document.body.append(...transcript.children);
|
||||
transcript.remove();
|
||||
const blocks = document.querySelectorAll('[data-paseo-markdown-tag="p"]');
|
||||
expect(copiedMarkdown(selectRange(blocks[0]!, 0, blocks[1]!, 17))).toBeNull();
|
||||
});
|
||||
|
||||
it("does not replace the browser clipboard for a range spanning assistant messages", () => {
|
||||
const firstMessage = mountFixture();
|
||||
const secondMessage = mountFixture();
|
||||
|
||||
@@ -17,6 +17,9 @@ import {
|
||||
} from "./markup";
|
||||
|
||||
const ASSISTANT_MESSAGE_SELECTOR = '[data-testid="assistant-message"]';
|
||||
const MESSAGE_ROW_SELECTOR = "[data-message-id]";
|
||||
const CHAT_SCROLL_SELECTOR = '[data-testid="agent-chat-scroll"]';
|
||||
const messageRowSelector = (messageId: string) => `[data-message-id="${CSS.escape(messageId)}"]`;
|
||||
const CODE_BLOCK_SELECTOR = `[${MARKDOWN_COPY_TAG_ATTRIBUTE}="pre"]`;
|
||||
const CODE_REGION_SELECTOR = `${CODE_BLOCK_SELECTOR}, [${MARKDOWN_COPY_TAG_ATTRIBUTE}="code"]`;
|
||||
|
||||
@@ -64,20 +67,22 @@ export function createAssistantSelectionClipboardContent(
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const startMessage = closestAssistantMessage(range.startContainer);
|
||||
const endMessage = closestAssistantMessage(range.endContainer);
|
||||
if (!startMessage || startMessage !== endMessage) {
|
||||
const parts = selectedMessageParts(range);
|
||||
if (!parts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const partialCode = createPartialCodeContent(range, startMessage);
|
||||
if (partialCode) {
|
||||
return partialCode;
|
||||
if (parts.length === 1) {
|
||||
const partialCode = createPartialCodeContent(range, parts[0]!.message);
|
||||
if (partialCode) {
|
||||
return partialCode;
|
||||
}
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
const selected = cloneMarkdownSelection(range, startMessage);
|
||||
container.append(selected);
|
||||
for (const part of parts) {
|
||||
container.append(cloneMarkdownSelection(part.range, part.message));
|
||||
}
|
||||
restoreMarkdownElements(container);
|
||||
|
||||
const markdown = turndown.turndown(container.innerHTML).trim();
|
||||
@@ -407,6 +412,70 @@ function closestAssistantMessage(node: Node): Element | null {
|
||||
return element?.closest(ASSISTANT_MESSAGE_SELECTOR) ?? null;
|
||||
}
|
||||
|
||||
interface SelectedMessagePart {
|
||||
message: Element;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
function messageIdOf(node: Element): string | undefined {
|
||||
return node.closest<HTMLElement>(MESSAGE_ROW_SELECTOR)?.dataset.messageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* A retained inactive panel keeps rendering its chat, so the same agent — and the same
|
||||
* message id — can exist twice in the document. The selection's own transcript is the
|
||||
* only one that can answer for it.
|
||||
*/
|
||||
function assistantMessagesOfGroup(node: Element, messageId: string): Element[] {
|
||||
const transcript = node.closest(CHAT_SCROLL_SELECTOR);
|
||||
if (!transcript) {
|
||||
return [];
|
||||
}
|
||||
const rows = transcript.querySelectorAll<HTMLElement>(messageRowSelector(messageId));
|
||||
return Array.from(rows).flatMap((row) => {
|
||||
const message = row.querySelector(ASSISTANT_MESSAGE_SELECTOR);
|
||||
return message ? [message] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The selection cut into one range per assistant message element it covers.
|
||||
*
|
||||
* An assistant message renders one row per Markdown block, so a selection that runs
|
||||
* past a paragraph ends in a different element of the same message. Each block is
|
||||
* serialized against its own element, exactly as a selection inside one block is, and
|
||||
* the results are concatenated — Turndown never sees the row wrappers between them.
|
||||
* A selection reaching a second message, or anything that is not assistant Markdown,
|
||||
* copies as plain text.
|
||||
*/
|
||||
function selectedMessageParts(range: Range): SelectedMessagePart[] | null {
|
||||
const start = closestAssistantMessage(range.startContainer);
|
||||
const end = closestAssistantMessage(range.endContainer);
|
||||
if (!start || !end) {
|
||||
return null;
|
||||
}
|
||||
if (start === end) {
|
||||
return [{ message: start, range }];
|
||||
}
|
||||
const messageId = messageIdOf(start);
|
||||
if (!messageId || messageId !== messageIdOf(end)) {
|
||||
return null;
|
||||
}
|
||||
const messages = assistantMessagesOfGroup(start, messageId);
|
||||
const first = messages.indexOf(start);
|
||||
const last = messages.indexOf(end);
|
||||
if (first < 0 || last < first) {
|
||||
return null;
|
||||
}
|
||||
return messages.slice(first, last + 1).map((message) => {
|
||||
const part = message.ownerDocument.createRange();
|
||||
part.selectNodeContents(message);
|
||||
if (message === start) part.setStart(range.startContainer, range.startOffset);
|
||||
if (message === end) part.setEnd(range.endContainer, range.endOffset);
|
||||
return { message, range: part };
|
||||
});
|
||||
}
|
||||
|
||||
function restoreMarkdownElements(container: HTMLElement): void {
|
||||
for (const ignored of container.querySelectorAll(`[${MARKDOWN_COPY_IGNORE_ATTRIBUTE}]`)) {
|
||||
ignored.remove();
|
||||
|
||||
@@ -1788,7 +1788,7 @@ export const AgentControls = memo(function AgentControls({
|
||||
onEditAgentProfiles={handleEditAgentProfiles}
|
||||
onCreateAgentProfile={profileActions.create}
|
||||
onEditAgentProfile={profileActions.edit}
|
||||
thinkingOptions={thinkingOptions.length > 1 ? thinkingOptions : undefined}
|
||||
thinkingOptions={thinkingOptions.length > 0 ? thinkingOptions : undefined}
|
||||
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
|
||||
onSelectThinkingOption={handleSelectThinkingOption}
|
||||
features={agent.features}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { EditorView } from "@codemirror/view";
|
||||
import { View, type View as ViewInstance } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PaneFind, type PaneFindHandle } from "@/pane-find";
|
||||
import { PaneFind, findShortcutPlatform, isFindShortcut, type PaneFindHandle } from "@/pane-find";
|
||||
import { usePaneFocus } from "@/panels/pane-context";
|
||||
import { hasActiveWebOverlay } from "@/lib/overlay-root";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
@@ -41,12 +41,7 @@ export function FileFind({
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
|
||||
return;
|
||||
if (
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
event.key.toLowerCase() === "f"
|
||||
) {
|
||||
if (isFindShortcut(event, findShortcutPlatform())) {
|
||||
event.preventDefault();
|
||||
model.open(editor.current);
|
||||
widget.current?.focus();
|
||||
|
||||
@@ -3,12 +3,13 @@ import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ar: TranslationResources = {
|
||||
paneFind: {
|
||||
searchFailed: "تعذر البحث في هذه المحادثة. تحقق من الاتصال بالمضيف وأعد المحاولة.",
|
||||
connectionFailure: "تعذر البحث في هذه المحادثة. تحقق من الاتصال بالمضيف وأعد المحاولة.",
|
||||
historyChangedFailure: "تغيّرت المحادثة أثناء البحث. أعد البحث.",
|
||||
revealFailure: "تعذر عرض هذا التطابق. أعد المحاولة.",
|
||||
searching: "جارٍ البحث…",
|
||||
loading: "جارٍ التحميل…",
|
||||
failed: "فشل",
|
||||
retry: "إعادة المحاولة",
|
||||
chatPosition: "{{current}} من {{total}} في الرسالة",
|
||||
|
||||
title: "بحث",
|
||||
placeholder: "بحث في اللوحة",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
export const en = {
|
||||
paneFind: {
|
||||
searchFailed: "Could not search this chat. Check the host connection and retry.",
|
||||
connectionFailure: "Could not search this chat. Check the host connection and retry.",
|
||||
historyChangedFailure: "The chat changed while searching. Search again.",
|
||||
revealFailure: "Could not show this match. Retry.",
|
||||
searching: "Searching…",
|
||||
loading: "Loading…",
|
||||
failed: "Failed",
|
||||
retry: "Retry",
|
||||
chatPosition: "{{current}} of {{total}} in message",
|
||||
title: "Find",
|
||||
placeholder: "Find in pane",
|
||||
close: "Close Find",
|
||||
|
||||
@@ -3,13 +3,14 @@ import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const es: TranslationResources = {
|
||||
paneFind: {
|
||||
searchFailed:
|
||||
connectionFailure:
|
||||
"No se pudo buscar en este chat. Comprueba la conexión con el host y vuelve a intentarlo.",
|
||||
historyChangedFailure: "El chat cambió durante la búsqueda. Vuelve a buscar.",
|
||||
revealFailure: "No se pudo mostrar esta coincidencia. Vuelve a intentarlo.",
|
||||
searching: "Buscando…",
|
||||
loading: "Cargando…",
|
||||
failed: "Error",
|
||||
retry: "Reintentar",
|
||||
chatPosition: "{{current}} de {{total}} en el mensaje",
|
||||
|
||||
title: "Buscar",
|
||||
placeholder: "Buscar en el panel",
|
||||
|
||||
@@ -3,13 +3,14 @@ import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const fr: TranslationResources = {
|
||||
paneFind: {
|
||||
searchFailed:
|
||||
connectionFailure:
|
||||
"Impossible de rechercher dans cette conversation. Vérifiez la connexion à l’hôte et réessayez.",
|
||||
historyChangedFailure: "La conversation a changé pendant la recherche. Relancez la recherche.",
|
||||
revealFailure: "Impossible d’afficher ce résultat. Réessayez.",
|
||||
searching: "Recherche…",
|
||||
loading: "Chargement…",
|
||||
failed: "Échec",
|
||||
retry: "Réessayer",
|
||||
chatPosition: "{{current}} sur {{total}} dans le message",
|
||||
|
||||
title: "Rechercher",
|
||||
placeholder: "Rechercher dans le panneau",
|
||||
|
||||
@@ -3,13 +3,14 @@ import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ja: TranslationResources = {
|
||||
paneFind: {
|
||||
searchFailed:
|
||||
connectionFailure:
|
||||
"このチャットを検索できませんでした。ホストへの接続を確認して再試行してください。",
|
||||
historyChangedFailure: "検索中にチャットが変更されました。もう一度検索してください。",
|
||||
revealFailure: "この一致を表示できませんでした。再試行してください。",
|
||||
searching: "検索中…",
|
||||
loading: "読み込み中…",
|
||||
failed: "失敗",
|
||||
retry: "再試行",
|
||||
chatPosition: "メッセージ内 {{current}} / {{total}}",
|
||||
|
||||
title: "検索",
|
||||
placeholder: "ペイン内を検索",
|
||||
|
||||
@@ -3,12 +3,13 @@ import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ko: TranslationResources = {
|
||||
paneFind: {
|
||||
searchFailed: "이 채팅을 검색할 수 없습니다. 호스트 연결을 확인하고 다시 시도하세요.",
|
||||
connectionFailure: "이 채팅을 검색할 수 없습니다. 호스트 연결을 확인하고 다시 시도하세요.",
|
||||
historyChangedFailure: "검색 중에 채팅이 변경되었습니다. 다시 검색하세요.",
|
||||
revealFailure: "이 일치 항목을 표시할 수 없습니다. 다시 시도하세요.",
|
||||
searching: "검색 중…",
|
||||
loading: "불러오는 중…",
|
||||
failed: "실패",
|
||||
retry: "다시 시도",
|
||||
chatPosition: "메시지 내 {{current}} / {{total}}",
|
||||
|
||||
title: "찾기",
|
||||
placeholder: "패널에서 찾기",
|
||||
|
||||
@@ -3,13 +3,14 @@ import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ptBR: TranslationResources = {
|
||||
paneFind: {
|
||||
searchFailed:
|
||||
connectionFailure:
|
||||
"Não foi possível pesquisar nesta conversa. Verifique a conexão com o host e tente novamente.",
|
||||
historyChangedFailure: "A conversa mudou durante a busca. Busque novamente.",
|
||||
revealFailure: "Não foi possível mostrar esta correspondência. Tente novamente.",
|
||||
searching: "Buscando…",
|
||||
loading: "Carregando…",
|
||||
failed: "Falhou",
|
||||
retry: "Tentar novamente",
|
||||
chatPosition: "{{current}} de {{total}} na mensagem",
|
||||
|
||||
title: "Buscar",
|
||||
placeholder: "Buscar no painel",
|
||||
|
||||
@@ -3,13 +3,14 @@ import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const ru: TranslationResources = {
|
||||
paneFind: {
|
||||
searchFailed:
|
||||
connectionFailure:
|
||||
"Не удалось выполнить поиск в чате. Проверьте подключение к хосту и повторите попытку.",
|
||||
historyChangedFailure: "Чат изменился во время поиска. Выполните поиск снова.",
|
||||
revealFailure: "Не удалось показать это совпадение. Повторите попытку.",
|
||||
searching: "Поиск…",
|
||||
loading: "Загрузка…",
|
||||
failed: "Ошибка",
|
||||
retry: "Повторить",
|
||||
chatPosition: "{{current}} из {{total}} в сообщении",
|
||||
|
||||
title: "Найти",
|
||||
placeholder: "Найти в панели",
|
||||
|
||||
@@ -3,12 +3,13 @@ import { pluginSettings } from "./plugin-settings";
|
||||
|
||||
export const zhCN: TranslationResources = {
|
||||
paneFind: {
|
||||
searchFailed: "无法搜索此聊天。请检查主机连接后重试。",
|
||||
connectionFailure: "无法搜索此聊天。请检查主机连接后重试。",
|
||||
historyChangedFailure: "搜索期间聊天已更改。请重新搜索。",
|
||||
revealFailure: "无法显示此匹配项。请重试。",
|
||||
searching: "搜索中…",
|
||||
loading: "加载中…",
|
||||
failed: "失败",
|
||||
retry: "重试",
|
||||
chatPosition: "消息内第 {{current}} 项,共 {{total}} 项",
|
||||
|
||||
title: "查找",
|
||||
placeholder: "在窗格中查找",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isFindShortcut } from "./find-shortcut";
|
||||
|
||||
const MAC = { isMac: true };
|
||||
const NON_MAC = { isMac: false };
|
||||
|
||||
function keydown(
|
||||
overrides: Partial<Pick<KeyboardEvent, "key" | "metaKey" | "ctrlKey" | "shiftKey" | "altKey">>,
|
||||
) {
|
||||
return {
|
||||
key: "f",
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("on macOS", () => {
|
||||
it("opens Find with Command+F", () => {
|
||||
expect(isFindShortcut(keydown({ metaKey: true }), MAC)).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves Control+F to text editing", () => {
|
||||
expect(isFindShortcut(keydown({ ctrlKey: true }), MAC)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores Control+Command+F", () => {
|
||||
expect(isFindShortcut(keydown({ metaKey: true, ctrlKey: true }), MAC)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores Shift and Alt variants", () => {
|
||||
expect(isFindShortcut(keydown({ metaKey: true, shiftKey: true }), MAC)).toBe(false);
|
||||
expect(isFindShortcut(keydown({ metaKey: true, altKey: true }), MAC)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("off macOS", () => {
|
||||
it("opens Find with Control+F", () => {
|
||||
expect(isFindShortcut(keydown({ ctrlKey: true }), NON_MAC)).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores Meta+F", () => {
|
||||
expect(isFindShortcut(keydown({ metaKey: true }), NON_MAC)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores Control+Meta+F", () => {
|
||||
expect(isFindShortcut(keydown({ ctrlKey: true, metaKey: true }), NON_MAC)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores Shift and Alt variants", () => {
|
||||
expect(isFindShortcut(keydown({ ctrlKey: true, shiftKey: true }), NON_MAC)).toBe(false);
|
||||
expect(isFindShortcut(keydown({ ctrlKey: true, altKey: true }), NON_MAC)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("matches an uppercase F from a shifted keyboard layout", () => {
|
||||
expect(isFindShortcut(keydown({ key: "F", metaKey: true }), MAC)).toBe(true);
|
||||
expect(isFindShortcut(keydown({ key: "F", ctrlKey: true }), NON_MAC)).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores other keys", () => {
|
||||
expect(isFindShortcut(keydown({ key: "g", metaKey: true }), MAC)).toBe(false);
|
||||
expect(isFindShortcut(keydown({ key: "g", ctrlKey: true }), NON_MAC)).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
export interface FindShortcutPlatform {
|
||||
isMac: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one Find keystroke policy: Command+F on macOS, Control+F everywhere else.
|
||||
*
|
||||
* Control+F is a macOS text-editing binding that moves the caret forward, so a
|
||||
* surface that also claims it there eats a keystroke the text editor owns.
|
||||
*
|
||||
* The platform is a parameter rather than a lookup because this module is
|
||||
* imported by the terminal emulator runtime, which is bundled on its own into
|
||||
* the terminal WebView and cannot load React Native. App surfaces pass
|
||||
* `findShortcutPlatform()` from `@/pane-find`.
|
||||
*/
|
||||
export function isFindShortcut(
|
||||
event: Pick<KeyboardEvent, "key" | "metaKey" | "ctrlKey" | "shiftKey" | "altKey">,
|
||||
platform: FindShortcutPlatform,
|
||||
): boolean {
|
||||
const primaryModifier = platform.isMac
|
||||
? event.metaKey && !event.ctrlKey
|
||||
: event.ctrlKey && !event.metaKey;
|
||||
return primaryModifier && !event.shiftKey && !event.altKey && event.key.toLowerCase() === "f";
|
||||
}
|
||||
@@ -33,6 +33,15 @@ import { paneContentToolbarIconSize, ToolbarButton } from "@/components/ui/pane-
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { isFindShortcut, type FindShortcutPlatform } from "./find-shortcut";
|
||||
|
||||
export { isFindShortcut, type FindShortcutPlatform } from "./find-shortcut";
|
||||
|
||||
/** The platform every Find surface judges the shortcut against. */
|
||||
export function findShortcutPlatform(): FindShortcutPlatform {
|
||||
return { isMac: getShortcutOs() === "mac" };
|
||||
}
|
||||
|
||||
const TextInput = withUnistyles(EditingTextInput, (theme) => ({
|
||||
placeholderTextColor: theme.colors.foregroundMuted,
|
||||
@@ -166,12 +175,14 @@ export const PaneFind = forwardRef<PaneFindHandle, PaneFindProps>(function PaneF
|
||||
keyCode?: number;
|
||||
};
|
||||
if (isImeComposingKeyboardEvent(key)) return;
|
||||
if (
|
||||
(key.metaKey || key.ctrlKey) &&
|
||||
!key.altKey &&
|
||||
!key.shiftKey &&
|
||||
key.key.toLowerCase() === "f"
|
||||
) {
|
||||
const shortcut = {
|
||||
key: key.key,
|
||||
metaKey: key.metaKey === true,
|
||||
ctrlKey: key.ctrlKey === true,
|
||||
shiftKey: key.shiftKey === true,
|
||||
altKey: key.altKey === true,
|
||||
};
|
||||
if (isFindShortcut(shortcut, findShortcutPlatform())) {
|
||||
// RN Web inputs stop keydown before the pane's document listener.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -369,4 +369,25 @@ describe("AgentDirectoryReplica", () => {
|
||||
).toEqual(directoryPlacement);
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
|
||||
it("keeps a timeline-hydrated agent when a sequenced catch-up carries no entries", () => {
|
||||
const serverId = "agent-replica-timeline-membership";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const replica = new AgentDirectoryReplica(
|
||||
serverId,
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
// A conversation opened from history arrives over its timeline, without the
|
||||
// project placement the directory carries, and is a member from then on.
|
||||
replica.submitTimelineAgent(replica.captureTimeline("agent"), payload("timeline only"));
|
||||
expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true);
|
||||
|
||||
replica.commitChanges([], [], []);
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true);
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,15 +6,21 @@ import type { Agent } from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { type AgentDirectoryDelta } from "@/utils/agent-directory-sync";
|
||||
import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation";
|
||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
||||
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
|
||||
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
|
||||
import type { TurnLivenessTransition } from "@/timeline/turn-liveness";
|
||||
import { AgentStoreProjection } from "./internal/agent-store";
|
||||
|
||||
function projectAgentDirectoryEntry(agent: Agent): FetchAgentsEntry | null {
|
||||
return agent.projectPlacement
|
||||
? { agent: projectAgentSnapshot(agent), project: agent.projectPlacement }
|
||||
: null;
|
||||
// A member hydrated from its timeline or restored from the replica cache carries
|
||||
// no placement. Every other path in this flow derives one from the working
|
||||
// directory, so this one does too; returning nothing here would drop the member
|
||||
// from the merged directory and delete the conversation on the next catch-up.
|
||||
function projectAgentDirectoryEntry(agent: Agent): FetchAgentsEntry {
|
||||
return {
|
||||
agent: projectAgentSnapshot(agent),
|
||||
project: resolveProjectPlacement({ projectPlacement: agent.projectPlacement, cwd: agent.cwd }),
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentLifecycleToken {
|
||||
@@ -164,8 +170,7 @@ export class AgentDirectoryReplica {
|
||||
const previous = this.storeProjection.snapshot();
|
||||
const merged = new Map<string, FetchAgentsEntry>();
|
||||
for (const agent of previous.values()) {
|
||||
const entry = projectAgentDirectoryEntry(agent);
|
||||
if (entry) merged.set(agent.id, entry);
|
||||
merged.set(agent.id, projectAgentDirectoryEntry(agent));
|
||||
}
|
||||
for (const entry of entries) merged.set(entry.agent.id, entry);
|
||||
const removalsAsDeltas: AgentDirectoryDelta[] = removals.map(({ id }) => ({
|
||||
|
||||
@@ -640,6 +640,63 @@ describe("DirectorySync session readiness", () => {
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("reconciles agent changes on top of the accepted cached baseline", async () => {
|
||||
const serverId = "cached-agent-changes";
|
||||
serverIds.add(serverId);
|
||||
const client = new FakeDirectoryClient();
|
||||
const cachedAgent = createAgent(serverId, "cached-agent");
|
||||
const releaseNetwork = client.holdAgentFetch();
|
||||
const directory = new DirectorySync(
|
||||
serverId,
|
||||
{
|
||||
onAgentStoppedRunning: () => undefined,
|
||||
markAgentLoading: () => undefined,
|
||||
markAgentReady: () => undefined,
|
||||
markAgentError: () => undefined,
|
||||
},
|
||||
{
|
||||
readAgent: async () => undefined,
|
||||
readWorkspace: async () => undefined,
|
||||
readDirectory: async () => ({
|
||||
agents: new Map([[cachedAgent.id, cachedAgent]]),
|
||||
workspaces: new Map(),
|
||||
projects: new Map(),
|
||||
checkpoint: { agents: { generation: "g", afterSeq: 7 } },
|
||||
}),
|
||||
commitDirectoryMutations: () => undefined,
|
||||
},
|
||||
);
|
||||
directory.connectionChanged({
|
||||
client: client as unknown as DaemonClient,
|
||||
status: "online",
|
||||
source: { clientGeneration: 1, connectionEpoch: 1 },
|
||||
});
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
|
||||
store.updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: "test",
|
||||
features: { workspaceMultiplicity: true, directorySync: true },
|
||||
});
|
||||
|
||||
const refresh = directory.refreshAgents();
|
||||
await expect.poll(() => client.fetchAgentsCalls).toBe(1);
|
||||
releaseNetwork({
|
||||
requestId: "agents",
|
||||
entries: [],
|
||||
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
|
||||
sync: { generation: "g", headSeq: 7, mode: "changes", removals: [] },
|
||||
});
|
||||
await refresh;
|
||||
|
||||
expect(client.lastAgentOptions).toMatchObject({
|
||||
sync: { generation: "g", afterSeq: 7 },
|
||||
});
|
||||
expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(true);
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("does not use a cached checkpoint when the corresponding cache read loses its race", async () => {
|
||||
const serverId = "late-directory-cache";
|
||||
serverIds.add(serverId);
|
||||
|
||||
@@ -134,6 +134,7 @@ it("feeds restored layout to timeline sync as a release signal, not a subscripti
|
||||
readCursor: () => undefined,
|
||||
fetchPage: async () => ({ hasNewer: false, endCursor: null }),
|
||||
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
|
||||
onCatchUpEnded: () => undefined,
|
||||
reportError: () => undefined,
|
||||
schedule: () => () => undefined,
|
||||
});
|
||||
|
||||
@@ -10,12 +10,11 @@ import {
|
||||
import { View, type LayoutChangeEvent } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PaneFind, type PaneFindHandle } from "@/pane-find";
|
||||
import { PaneFind, findShortcutPlatform, isFindShortcut, type PaneFindHandle } from "@/pane-find";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { hasActiveWebOverlay } from "@/lib/overlay-root";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import type { TerminalEmulatorHandle } from "@/components/terminal-emulator-contract";
|
||||
import { isFindShortcut } from "../runtime/terminal-find-shortcut";
|
||||
import type { TerminalFindResult } from "../runtime/terminal-emulator-runtime";
|
||||
|
||||
export interface TerminalPaneFindHandle {
|
||||
@@ -65,7 +64,7 @@ export const TerminalFind = forwardRef<
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.defaultPrevented || hasActiveWebOverlay() || isImeComposingKeyboardEvent(event))
|
||||
return;
|
||||
if (isFindShortcut(event) && terminal.current?.find) {
|
||||
if (isFindShortcut(event, findShortcutPlatform()) && terminal.current?.find) {
|
||||
event.preventDefault();
|
||||
show();
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ import {
|
||||
type TerminalLocalFileLinkSource,
|
||||
type TerminalLocalFileLinkTarget,
|
||||
} from "../local-links/terminal-local-link-provider";
|
||||
import { isMac, isFindShortcut } from "./terminal-find-shortcut";
|
||||
import { isFindShortcut, type FindShortcutPlatform } from "@/pane-find/find-shortcut";
|
||||
import { isMacUserAgent } from "@/utils/mac-user-agent";
|
||||
import { resolveTerminalFontFamily, resolveTerminalFontSize } from "./terminal-font";
|
||||
|
||||
export type TerminalOutputData = Uint8Array;
|
||||
@@ -178,7 +179,7 @@ function withOverviewRulerBorderHidden(theme: ITheme): ITheme {
|
||||
}
|
||||
|
||||
export class TerminalEmulatorRuntime {
|
||||
constructor(private readonly options: { isMac: boolean } = { isMac }) {}
|
||||
constructor(private readonly options: FindShortcutPlatform = { isMac: isMacUserAgent() }) {}
|
||||
|
||||
private callbacks: TerminalEmulatorRuntimeCallbacks = {};
|
||||
private pendingModifiers: PendingTerminalModifiers = {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
export const isMac =
|
||||
typeof navigator !== "undefined" &&
|
||||
(/Macintosh|Mac OS/i.test(navigator.userAgent ?? "") ||
|
||||
/Mac/i.test((navigator as Navigator & { platform?: string }).platform ?? ""));
|
||||
|
||||
export function isFindShortcut(
|
||||
event: Pick<KeyboardEvent, "key" | "metaKey" | "ctrlKey" | "shiftKey" | "altKey">,
|
||||
platform = { isMac },
|
||||
): boolean {
|
||||
const primaryModifier = platform.isMac
|
||||
? event.metaKey && !event.ctrlKey
|
||||
: event.ctrlKey && !event.metaKey;
|
||||
return primaryModifier && !event.shiftKey && !event.altKey && event.key.toLowerCase() === "f";
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -9,6 +9,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import type { CachedTimeline } from "@/runtime/replica-cache";
|
||||
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
|
||||
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
createTimelineReplica,
|
||||
@@ -443,6 +444,119 @@ describe("viewed timeline persistence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("create handoff lifetime", () => {
|
||||
const DRAFT_ID = "draft-1";
|
||||
|
||||
afterEach(() => useCreateFlowStore.getState().clearAll());
|
||||
|
||||
function sentCreateHandoff(): void {
|
||||
useCreateFlowStore.getState().setPending({
|
||||
draftId: DRAFT_ID,
|
||||
serverId: SERVER_ID,
|
||||
agentId: AGENT_ID,
|
||||
clientMessageId: "client-message-1",
|
||||
text: "Create this chat.",
|
||||
timestamp: 0,
|
||||
});
|
||||
useCreateFlowStore.getState().markLifecycle({ draftId: DRAFT_ID, lifecycle: "sent" });
|
||||
}
|
||||
|
||||
function createHandoff(): PendingCreateAttempt | undefined {
|
||||
return useCreateFlowStore.getState().pendingByDraftId[DRAFT_ID];
|
||||
}
|
||||
|
||||
function syncedTailPage(): Parameters<ViewedTimelineOwner["applyTimelineResponse"]>[0] {
|
||||
return {
|
||||
requestId: "tail-1",
|
||||
agentId: AGENT_ID,
|
||||
agent: null,
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
window: { minSeq: 1, maxSeq: 0, nextSeq: 1 },
|
||||
startCursor: null,
|
||||
endCursor: null,
|
||||
entries: [],
|
||||
error: null,
|
||||
hasNewer: false,
|
||||
hasOlder: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
};
|
||||
}
|
||||
|
||||
interface HeldCatchUpOwner {
|
||||
owner: ViewedTimelineOwner;
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
async function ownerWithHeldCatchUp(): Promise<HeldCatchUpOwner> {
|
||||
let releaseTailFetch!: () => void;
|
||||
let tailFetchStarted = false;
|
||||
const tailFetched = new Promise<void>((resolve) => {
|
||||
releaseTailFetch = resolve;
|
||||
});
|
||||
const replica = createTimelineReplica({
|
||||
serverId: SERVER_ID,
|
||||
storage: { readTimeline: async () => undefined, commitTimeline: () => undefined },
|
||||
prepareAgent: async () => undefined,
|
||||
});
|
||||
const owner = createViewedTimelineOwner({
|
||||
serverId: SERVER_ID,
|
||||
replica,
|
||||
replaceDemandedAgentIds: () => undefined,
|
||||
drainQueuedAgentMessage: () => undefined,
|
||||
ports: {
|
||||
observe: () => ({ ready: Promise.resolve(), release: async () => undefined }),
|
||||
readCursor: () => undefined,
|
||||
fetchPage: async () => {
|
||||
tailFetchStarted = true;
|
||||
await tailFetched;
|
||||
return { hasNewer: false, endCursor: null };
|
||||
},
|
||||
fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }),
|
||||
reportError: () => undefined,
|
||||
schedule: () => () => undefined,
|
||||
},
|
||||
});
|
||||
|
||||
owner.setConnected(true);
|
||||
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
|
||||
await expect.poll(() => tailFetchStarted).toBe(true);
|
||||
return { owner, release: releaseTailFetch };
|
||||
}
|
||||
|
||||
it("keeps the handoff through the first synchronized page and releases it when the chat is current", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
sentCreateHandoff();
|
||||
const { owner, release } = await ownerWithHeldCatchUp();
|
||||
|
||||
owner.applyTimelineResponse(syncedTailPage());
|
||||
expect(
|
||||
selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID),
|
||||
).toMatchObject({ status: "synced" });
|
||||
expect(createHandoff()).toMatchObject({ agentId: AGENT_ID, lifecycle: "sent" });
|
||||
|
||||
release();
|
||||
await expect.poll(() => createHandoff()).toBeUndefined();
|
||||
owner.dispose();
|
||||
});
|
||||
|
||||
it("releases the handoff when the chat's tab closes before catch-up completes", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
sentCreateHandoff();
|
||||
const { owner, release } = await ownerWithHeldCatchUp();
|
||||
|
||||
owner.replaceVisibleAgentIds("test", []);
|
||||
owner.replaceOpenTabAgentIds([]);
|
||||
|
||||
expect(createHandoff()).toBeUndefined();
|
||||
release();
|
||||
owner.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
function createSqliteCache() {
|
||||
const database = new DatabaseSync(":memory:");
|
||||
let beforeRead = async () => {};
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
consumeForcedTimelineTailReplacement,
|
||||
createViewedTimelineSync,
|
||||
type TimelineResponsePayload,
|
||||
type ViewedTimelineStatus,
|
||||
type ViewedTimelineSyncPorts,
|
||||
} from "./viewed-timeline-sync";
|
||||
|
||||
@@ -31,6 +32,11 @@ interface MembershipRequest {
|
||||
fail(message: string): void;
|
||||
}
|
||||
|
||||
interface TimelineCurrentReport {
|
||||
agentId: string;
|
||||
status: ViewedTimelineStatus;
|
||||
}
|
||||
|
||||
interface TimelineFetch {
|
||||
agentId: string;
|
||||
request: ProjectedTimelineForwardFetchPlan;
|
||||
@@ -126,9 +132,16 @@ class TimelineWorld {
|
||||
readonly cursors = new Map<string, { epoch: string; endSeq: number }>();
|
||||
readonly cacheRequests: string[] = [];
|
||||
readonly forcedTimelineTailReplacements = new Set<string>();
|
||||
readonly reportedCurrent: TimelineCurrentReport[] = [];
|
||||
cacheGate: Deferred<void> | null = null;
|
||||
readonly sync = createViewedTimelineSync({
|
||||
replaceDemandedAgentIds: () => undefined,
|
||||
onCatchUpEnded: (agentId) => {
|
||||
this.reportedCurrent.push({
|
||||
agentId,
|
||||
status: this.sync.getAgentTimelineStatus(agentId),
|
||||
});
|
||||
},
|
||||
prepare: async (agentId) => {
|
||||
this.cacheRequests.push(agentId);
|
||||
this.cacheRequestWaiters.shift()?.(agentId);
|
||||
@@ -436,6 +449,60 @@ test("a gap absorbed by a running tail is recovered after the tail completes", a
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
});
|
||||
|
||||
test("reports a chat current only once its parked follow-up page completes", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
(await world.nextMembership()).succeed();
|
||||
const tail = await world.nextFetch("agent-a");
|
||||
|
||||
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 9 });
|
||||
tail.respond({ hasNewer: false });
|
||||
|
||||
const recovery = await world.nextFetch("agent-a");
|
||||
expect(world.reportedCurrent).toEqual([]);
|
||||
expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("pending");
|
||||
|
||||
recovery.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
expect(world.reportedCurrent).toEqual([{ agentId: "agent-a", status: "ready" }]);
|
||||
world.sync.dispose();
|
||||
});
|
||||
|
||||
test("reports a chat current once the latest-tail fallback replaces an overflowing catch-up", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 });
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
(await world.nextMembership()).succeed();
|
||||
|
||||
const probe = await world.nextFetch("agent-a");
|
||||
probe.respond({ hasNewer: true, seq: 82 });
|
||||
|
||||
const fallback = await world.nextFetch("agent-a");
|
||||
expect(world.reportedCurrent).toEqual([]);
|
||||
|
||||
fallback.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
expect(world.reportedCurrent).toEqual([{ agentId: "agent-a", status: "ready" }]);
|
||||
world.sync.dispose();
|
||||
});
|
||||
|
||||
test("stops owing a chat a catch-up when its tab closes mid-fetch", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("pane", ["agent-a"]);
|
||||
(await world.nextMembership()).succeed();
|
||||
await world.nextFetch("agent-a");
|
||||
expect(world.reportedCurrent).toEqual([]);
|
||||
|
||||
world.sync.replaceVisibleAgentIds("pane", []);
|
||||
world.sync.replaceOpenTabAgentIds([]);
|
||||
|
||||
expect(world.reportedCurrent).toEqual([{ agentId: "agent-a", status: "pending" }]);
|
||||
world.sync.dispose();
|
||||
});
|
||||
|
||||
test("unchanged visible-set publication does not cancel paged catch-up", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
|
||||
@@ -247,9 +247,6 @@ function finalizeProcessedTimeline(input: {
|
||||
clearAgentInitializingFlag(input.serverId, input.agentId);
|
||||
}
|
||||
if (input.synchronized) {
|
||||
useCreateFlowStore
|
||||
.getState()
|
||||
.clearByAgent({ serverId: input.serverId, agentId: input.agentId });
|
||||
const session = useSessionStore.getState().sessions[input.serverId];
|
||||
const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId);
|
||||
if (agent && agent.turn.phase === "idle") input.drainQueuedAgentMessage(input.agentId);
|
||||
@@ -317,6 +314,11 @@ export interface ViewedTimelineSyncPorts {
|
||||
request: ProjectedTimelineForwardFetchPlan,
|
||||
): Promise<TimelinePageResult>;
|
||||
fetchLatestTail(agentId: string): Promise<TimelinePageResult>;
|
||||
/**
|
||||
* The sync no longer owes this chat a catch-up: it reached current, or it left the
|
||||
* demanded set. Disconnect and backgrounding keep the obligation and do not report here.
|
||||
*/
|
||||
onCatchUpEnded(agentId: string): void;
|
||||
reportError(error: unknown): void;
|
||||
schedule(task: () => void, delayMs: number): () => void;
|
||||
}
|
||||
@@ -346,7 +348,7 @@ export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
|
||||
|
||||
export type ViewedTimelineOwnerPorts = Omit<
|
||||
ViewedTimelineSyncPorts,
|
||||
"prepare" | "replaceDemandedAgentIds"
|
||||
"prepare" | "replaceDemandedAgentIds" | "onCatchUpEnded"
|
||||
>;
|
||||
|
||||
export interface ViewedTimelineOwner extends ViewedTimelineSync {
|
||||
@@ -376,6 +378,8 @@ export function createViewedTimelineOwner(input: {
|
||||
prepare: (agentId) => input.replica.prepare(agentId),
|
||||
readCursor: (agentId) => input.replica.readCursor(agentId) ?? input.ports.readCursor(agentId),
|
||||
replaceDemandedAgentIds: input.replaceDemandedAgentIds,
|
||||
onCatchUpEnded: (agentId) =>
|
||||
useCreateFlowStore.getState().clearByAgent({ serverId: input.serverId, agentId }),
|
||||
});
|
||||
const streamQueue = createSessionAgentStreamReducerQueue({
|
||||
serverId: input.serverId,
|
||||
@@ -530,6 +534,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
const wasPending = visibilityCatchUpPending.delete(agentId);
|
||||
const hadError = visibilityCatchUpErrors.delete(agentId);
|
||||
const wasRetrying = manualRetries.delete(agentId);
|
||||
ports.onCatchUpEnded(agentId);
|
||||
if (wasPending || hadError || wasRetrying) notifyListeners();
|
||||
};
|
||||
|
||||
@@ -785,12 +790,14 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
return;
|
||||
}
|
||||
|
||||
const released: string[] = [];
|
||||
for (const agentId of desired) {
|
||||
if (!nextDesired.includes(agentId)) {
|
||||
cancelCatchUp(agentId);
|
||||
visibilityCatchUpPending.delete(agentId);
|
||||
visibilityCatchUpErrors.delete(agentId);
|
||||
manualRetries.delete(agentId);
|
||||
released.push(agentId);
|
||||
}
|
||||
}
|
||||
for (const agentId of nextDesired) {
|
||||
@@ -806,6 +813,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
desired = nextDesired;
|
||||
ports.replaceDemandedAgentIds(desired);
|
||||
membershipGeneration += 1;
|
||||
for (const agentId of released) ports.onCatchUpEnded(agentId);
|
||||
notifyListeners();
|
||||
void reconcileMembership();
|
||||
};
|
||||
|
||||
@@ -75,6 +75,49 @@ describe("splitMarkdownBlocks", () => {
|
||||
).toEqual(["Before", "3. Outer three\n\n 7. Inner seven\n 8. Inner eight", "After"]);
|
||||
});
|
||||
|
||||
it("keeps a link reference definition with the paragraph that uses it", () => {
|
||||
expect(splitMarkdownBlocks("See the [docs][d].\n\n[d]: https://example.com")).toEqual([
|
||||
"See the [docs][d].\n\n[d]: https://example.com",
|
||||
]);
|
||||
});
|
||||
|
||||
it("folds a leading definition-only block into the block below it", () => {
|
||||
expect(splitMarkdownBlocks('[d]: https://example.com "Docs"\n\nSee the [docs][d].')).toEqual([
|
||||
'[d]: https://example.com "Docs"\n\nSee the [docs][d].',
|
||||
]);
|
||||
});
|
||||
|
||||
it("folds several definition lines and several definition blocks into one block", () => {
|
||||
expect(
|
||||
splitMarkdownBlocks(
|
||||
"See [one][a] and [two][b].\n\n[a]: https://example.com/a\n[b]: <https://example.com/b>\n\n[c]: https://example.com/c 'Third'\n\nAfter",
|
||||
),
|
||||
).toEqual([
|
||||
"See [one][a] and [two][b].\n\n[a]: https://example.com/a\n[b]: <https://example.com/b>\n\n[c]: https://example.com/c 'Third'",
|
||||
"After",
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognizes every destination the renderer accepts, including escaped spaces", () => {
|
||||
expect(splitMarkdownBlocks("See [docs].\n\n[docs]: docs\\ folder/readme")).toEqual([
|
||||
"See [docs].\n\n[docs]: docs\\ folder/readme",
|
||||
]);
|
||||
expect(splitMarkdownBlocks("See [docs].\n\n[docs]: <docs folder/readme> 'Title'")).toEqual([
|
||||
"See [docs].\n\n[docs]: <docs folder/readme> 'Title'",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves a definition-only message as its own block", () => {
|
||||
expect(splitMarkdownBlocks("[d]: https://example.com")).toEqual(["[d]: https://example.com"]);
|
||||
});
|
||||
|
||||
it("does not fold a paragraph that merely starts with a bracketed link", () => {
|
||||
expect(splitMarkdownBlocks("Intro\n\n[Link](https://example.com) and more prose")).toEqual([
|
||||
"Intro",
|
||||
"[Link](https://example.com) and more prose",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats triple newlines as a split point and filters empty blocks", () => {
|
||||
expect(splitMarkdownBlocks("First paragraph\n\n\nSecond paragraph")).toEqual([
|
||||
"First paragraph",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* What the browser says about the platform. `getShortcutOs()` is the app's
|
||||
* answer for keyboard policy and consults native and Electron first; this is the
|
||||
* browser half of it, kept free of React Native imports so the terminal WebView
|
||||
* bundle can use it too.
|
||||
*/
|
||||
export function isMacUserAgent(): boolean {
|
||||
if (typeof navigator === "undefined") return false;
|
||||
const userAgent = navigator.userAgent ?? "";
|
||||
const platform = (navigator as Navigator & { platform?: string }).platform ?? "";
|
||||
return (
|
||||
/Macintosh|Mac OS|iPhone|iPad|iPod/i.test(userAgent) || /Mac|iPhone|iPad|iPod/i.test(platform)
|
||||
);
|
||||
}
|
||||
@@ -2,16 +2,12 @@ import { Platform } from "react-native";
|
||||
import { getIsElectronRuntimeMac } from "@/constants/layout";
|
||||
import type { ShortcutOs } from "@/utils/format-shortcut";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { isMacUserAgent } from "@/utils/mac-user-agent";
|
||||
|
||||
export function getShortcutOs(): ShortcutOs {
|
||||
if (isNative) {
|
||||
return Platform.OS === "ios" ? "mac" : "non-mac";
|
||||
}
|
||||
if (getIsElectronRuntimeMac()) return "mac";
|
||||
if (typeof navigator === "undefined") return "non-mac";
|
||||
const ua = navigator.userAgent ?? "";
|
||||
const platform = (navigator as Navigator & { platform?: string }).platform ?? "";
|
||||
const isApple =
|
||||
/Macintosh|Mac OS|iPhone|iPad|iPod/i.test(ua) || /Mac|iPhone|iPad|iPod/i.test(platform);
|
||||
return isApple ? "mac" : "non-mac";
|
||||
return isMacUserAgent() ? "mac" : "non-mac";
|
||||
}
|
||||
|
||||
@@ -4,6 +4,35 @@ import MarkdownIt from "markdown-it";
|
||||
const markdownBlockParser = new MarkdownIt();
|
||||
markdownBlockParser.core.ruler.disable("inline");
|
||||
|
||||
// The renderer decides what counts as a definition, so ask the same parser: a block
|
||||
// that produces no tokens but registers references is nothing but definitions.
|
||||
function isLinkReferenceDefinitionBlock(block: string): boolean {
|
||||
const env: { references?: Record<string, unknown> } = {};
|
||||
const tokens = markdownBlockParser.parse(block, env);
|
||||
return tokens.length === 0 && Object.keys(env.references ?? {}).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Definitions render nothing and resolve nothing on their own, so a block made only of
|
||||
* them would paint an empty row and break every reference that pointed at it. Fold it
|
||||
* into the block it belongs to: the one above, or the one below when it leads.
|
||||
*/
|
||||
function foldLinkReferenceDefinitions(blocks: string[]): string[] {
|
||||
const folded: string[] = [];
|
||||
let leading: string[] = [];
|
||||
for (const block of blocks) {
|
||||
if (isLinkReferenceDefinitionBlock(block)) {
|
||||
if (folded.length > 0) folded[folded.length - 1] += `\n\n${block}`;
|
||||
else leading.push(block);
|
||||
continue;
|
||||
}
|
||||
folded.push([...leading, block].join("\n\n"));
|
||||
leading = [];
|
||||
}
|
||||
if (leading.length > 0) folded.push(leading.join("\n\n"));
|
||||
return folded;
|
||||
}
|
||||
|
||||
export function splitMarkdownBlocks(text: string): string[] {
|
||||
if (text.length === 0) {
|
||||
return [];
|
||||
@@ -43,7 +72,7 @@ export function splitMarkdownBlocks(text: string): string[] {
|
||||
blocks.push(currentLines.join("\n"));
|
||||
}
|
||||
|
||||
return blocks.filter((block) => block.length > 0);
|
||||
return foldLinkReferenceDefinitions(blocks.filter((block) => block.length > 0));
|
||||
}
|
||||
|
||||
function getStructuralBlankLines(text: string, lines: string[]): Set<number> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -28,9 +28,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.9.0-beta.2",
|
||||
"@getpaseo/protocol": "0.9.0-beta.2",
|
||||
"@getpaseo/server": "0.9.0-beta.2",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/server": "0.9.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -52,10 +52,15 @@ interface ProviderDiagnostic {
|
||||
}
|
||||
|
||||
const EXPECTED_CLAUDE_MODELS = [
|
||||
{
|
||||
id: "claude-opus-5-5",
|
||||
model: "Opus 5.5",
|
||||
descriptionFragment: "Latest release",
|
||||
},
|
||||
{
|
||||
id: "claude-opus-5",
|
||||
model: "Opus 5",
|
||||
descriptionFragment: "Latest release",
|
||||
descriptionFragment: "Previous release",
|
||||
},
|
||||
{
|
||||
id: "claude-fable-5-1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -39,8 +39,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.9.0-beta.2",
|
||||
"@getpaseo/relay": "0.9.0-beta.2",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/relay": "0.9.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"description": "Paseo plugin SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -65,8 +65,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.9.0-beta.2",
|
||||
"@getpaseo/protocol": "0.9.0-beta.2",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -76,8 +76,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.9.0-beta.2",
|
||||
"@getpaseo/protocol": "0.9.0-beta.2",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -4608,7 +4608,13 @@ export const AgentTimelineSearchResponseMessageSchema = z.object({
|
||||
agentId: z.string(),
|
||||
epoch: z.string(),
|
||||
locations: z.array(
|
||||
z.object({ seq: z.number().int().nonnegative(), role: z.enum(["user", "assistant"]) }),
|
||||
z.object({
|
||||
seq: z.number().int().nonnegative(),
|
||||
role: z.enum(["user", "assistant"]),
|
||||
// Estimated occurrences in the message; the client verifies against rendered text.
|
||||
// COMPAT(timelineSearchCount): added in v0.9.0, remove optional after 2027-09-22.
|
||||
count: z.number().int().positive().optional(),
|
||||
}),
|
||||
),
|
||||
nextCursor: z.number().int().nonnegative().nullable(),
|
||||
error: z.string().nullable(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -106,11 +106,11 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.9.0-beta.2",
|
||||
"@getpaseo/highlight": "0.9.0-beta.2",
|
||||
"@getpaseo/plugin": "0.9.0-beta.2",
|
||||
"@getpaseo/protocol": "0.9.0-beta.2",
|
||||
"@getpaseo/relay": "0.9.0-beta.2",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/highlight": "0.9.1",
|
||||
"@getpaseo/plugin": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/relay": "0.9.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -4767,6 +4767,93 @@ test("later explicit config mutations win over events emitted by earlier mutatio
|
||||
expect(manager.getAgent(snapshot.id)?.config.thinkingOptionId).toBe("high");
|
||||
});
|
||||
|
||||
test("setAgentThinkingOption adopts the session's effective clamped level", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-thinking-clamp-"));
|
||||
class ClampingSession extends TestAgentSession {
|
||||
async setThinkingOption(): Promise<void> {}
|
||||
|
||||
override async getRuntimeInfo() {
|
||||
return {
|
||||
...(await super.getRuntimeInfo()),
|
||||
thinkingOptionId: "high",
|
||||
};
|
||||
}
|
||||
}
|
||||
class ClampingClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new ClampingSession(config);
|
||||
}
|
||||
}
|
||||
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new ClampingClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000135",
|
||||
});
|
||||
const snapshot = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
thinkingOptionId: "low",
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
await manager.setAgentThinkingOption(snapshot.id, "medium");
|
||||
await manager.flush();
|
||||
|
||||
const agent = manager.getAgent(snapshot.id);
|
||||
expect(agent?.config.thinkingOptionId).toBe("high");
|
||||
expect(agent?.runtimeInfo?.thinkingOptionId).toBe("high");
|
||||
const persisted = await storage.get(snapshot.id);
|
||||
expect(persisted?.config?.thinkingOptionId).toBe("high");
|
||||
});
|
||||
|
||||
test("setAgentThinkingOption surfaces a failed state read and keeps the previous level", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-thinking-read-failure-"));
|
||||
class UnreadableStateSession extends TestAgentSession {
|
||||
async setThinkingOption(): Promise<void> {}
|
||||
|
||||
override async getRuntimeInfo(): Promise<never> {
|
||||
throw new Error("state unavailable");
|
||||
}
|
||||
}
|
||||
class UnreadableStateClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new UnreadableStateSession(config);
|
||||
}
|
||||
}
|
||||
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new UnreadableStateClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000136",
|
||||
});
|
||||
const snapshot = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
thinkingOptionId: "low",
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
await expect(manager.setAgentThinkingOption(snapshot.id, "medium")).rejects.toThrow(
|
||||
"state unavailable",
|
||||
);
|
||||
await manager.flush();
|
||||
|
||||
expect(manager.getAgent(snapshot.id)?.config.thinkingOptionId).toBe("low");
|
||||
const persisted = await storage.get(snapshot.id);
|
||||
expect(persisted?.config?.thinkingOptionId).toBe("low");
|
||||
});
|
||||
|
||||
test("session config drift events update state through the stream channel", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-session-config-events-"));
|
||||
let capturedSession: TestAgentSession | null = null;
|
||||
|
||||
@@ -1934,11 +1934,17 @@ export class AgentManager {
|
||||
}
|
||||
await this.drainSessionEvents(agentId);
|
||||
|
||||
agent.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
|
||||
let effectiveThinkingOptionId = normalizedThinkingOptionId;
|
||||
const runtimeInfo = await agent.session.getRuntimeInfo();
|
||||
if (runtimeInfo.thinkingOptionId !== undefined) {
|
||||
effectiveThinkingOptionId = runtimeInfo.thinkingOptionId;
|
||||
}
|
||||
|
||||
agent.config.thinkingOptionId = effectiveThinkingOptionId ?? undefined;
|
||||
if (agent.runtimeInfo) {
|
||||
agent.runtimeInfo = {
|
||||
...agent.runtimeInfo,
|
||||
thinkingOptionId: normalizedThinkingOptionId,
|
||||
thinkingOptionId: effectiveThinkingOptionId,
|
||||
};
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
|
||||
@@ -19,7 +19,10 @@ it("returns source locations for formatted assistant text and literal user text"
|
||||
]),
|
||||
query: "hello world",
|
||||
});
|
||||
expect(result).toEqual({ locations: [{ seq: 2, role: "assistant" }], nextCursor: null });
|
||||
expect(result).toEqual({
|
||||
locations: [{ seq: 2, role: "assistant", count: 2 }],
|
||||
nextCursor: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("finds code and literal punctuation without searching tools, reasoning, or task state", async () => {
|
||||
@@ -34,8 +37,8 @@ it("finds code and literal punctuation without searching tools, reasoning, or ta
|
||||
query: "a.b",
|
||||
});
|
||||
expect(result.locations).toEqual([
|
||||
{ seq: 1, role: "assistant" },
|
||||
{ seq: 5, role: "user" },
|
||||
{ seq: 1, role: "assistant", count: 1 },
|
||||
{ seq: 5, role: "user", count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -49,8 +52,8 @@ it("bounds results and resumes without losing locations", async () => {
|
||||
const second = await searchTimeline({ rows: source, query: "target", cursor: first.nextCursor! });
|
||||
expect(second).toEqual({
|
||||
locations: [
|
||||
{ seq: 201, role: "user" },
|
||||
{ seq: 202, role: "user" },
|
||||
{ seq: 201, role: "user", count: 1 },
|
||||
{ seq: 202, role: "user", count: 1 },
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
@@ -59,9 +62,27 @@ it("bounds results and resumes without losing locations", async () => {
|
||||
it("finds ordinary Markdown entities and line breaks without requiring identical parsers", async () => {
|
||||
const source = rows([{ type: "assistant_message", text: "hello & **world**\nnext line" }]);
|
||||
expect((await searchTimeline({ rows: source, query: "hello & world" })).locations).toEqual([
|
||||
{ seq: 1, role: "assistant" },
|
||||
{ seq: 1, role: "assistant", count: 1 },
|
||||
]);
|
||||
expect((await searchTimeline({ rows: source, query: "world next" })).locations).toEqual([
|
||||
{ seq: 1, role: "assistant" },
|
||||
{ seq: 1, role: "assistant", count: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts occurrences per rendered block rather than across block boundaries", async () => {
|
||||
const source = rows([
|
||||
{ type: "assistant_message", text: "target one\n\ntarget two\n\n- target three\n- end target" },
|
||||
{ type: "user_message", text: "TARGET target" },
|
||||
]);
|
||||
expect((await searchTimeline({ rows: source, query: "target" })).locations).toEqual([
|
||||
{ seq: 1, role: "assistant", count: 4 },
|
||||
{ seq: 2, role: "user", count: 2 },
|
||||
]);
|
||||
expect((await searchTimeline({ rows: source, query: "end target" })).locations).toEqual([
|
||||
{ seq: 1, role: "assistant", count: 1 },
|
||||
]);
|
||||
expect((await searchTimeline({ rows: source, query: "two target" })).locations).toEqual([]);
|
||||
// Rendered text never runs from the end of one block into the start of the next, so
|
||||
// neither does a match, however contiguous the Markdown source looks.
|
||||
expect((await searchTimeline({ rows: source, query: "one target" })).locations).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ const PAGE_SIZE = 200;
|
||||
interface SearchLocation {
|
||||
seq: number;
|
||||
role: "user" | "assistant";
|
||||
count: number;
|
||||
}
|
||||
interface SearchInput {
|
||||
rows: readonly AgentTimelineRow[];
|
||||
@@ -15,8 +16,13 @@ interface SearchInput {
|
||||
cursor?: number;
|
||||
}
|
||||
|
||||
// Discovery is deliberately approximate. Only the client can verify displayed text.
|
||||
function searchableMarkdown(text: string): string {
|
||||
// Discovery is deliberately approximate. Only the client can verify displayed text, so
|
||||
// `count` estimates rendered occurrences and the client replaces it with the rendered
|
||||
// count once the message is on screen. Blocks are counted separately, and the Markdown
|
||||
// source is never searched whole, because a match the client cannot highlight — across
|
||||
// a block boundary, or on syntax the reader never sees — still inflates the total until
|
||||
// navigation reaches that message and drops it.
|
||||
function searchableBlocks(text: string): string[] {
|
||||
return markdown
|
||||
.parse(text, {})
|
||||
.map((token) => {
|
||||
@@ -32,28 +38,44 @@ function searchableMarkdown(text: string): string {
|
||||
if (token.type === "fence" || token.type === "code_block") return token.content;
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function searchPattern(query: string): RegExp {
|
||||
const source = query
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join("\\s+");
|
||||
return new RegExp(source, "giu");
|
||||
}
|
||||
|
||||
function countMatches(text: string, pattern: RegExp): number {
|
||||
let count = 0;
|
||||
for (const _ of text.matchAll(pattern)) count += 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
export async function searchTimeline({ rows, query, cursor = 0 }: SearchInput) {
|
||||
const locations: SearchLocation[] = [];
|
||||
if (!query.trim()) return { locations, nextCursor: null };
|
||||
const needle = normalizeSearchText(query);
|
||||
const pattern = searchPattern(query);
|
||||
let lastYield = performance.now();
|
||||
for (const entry of projectTimelineRows({ rows, mode: "projected" })) {
|
||||
if (entry.seqEnd <= cursor) continue;
|
||||
const item = entry.item;
|
||||
if (item.type !== "user_message" && item.type !== "assistant_message") continue;
|
||||
const raw = item.text.replace(/\r/g, "");
|
||||
const text = item.type === "assistant_message" ? searchableMarkdown(raw) : raw;
|
||||
if (normalizeSearchText(raw).includes(needle) || normalizeSearchText(text).includes(needle)) {
|
||||
const blocks = item.type === "assistant_message" ? searchableBlocks(raw) : [raw];
|
||||
const count = blocks.reduce((sum, block) => sum + countMatches(block, pattern), 0);
|
||||
if (count) {
|
||||
if (locations.length === PAGE_SIZE) {
|
||||
return { locations, nextCursor: locations[PAGE_SIZE - 1]!.seq };
|
||||
}
|
||||
locations.push({
|
||||
seq: entry.seqEnd,
|
||||
role: item.type === "user_message" ? "user" : "assistant",
|
||||
count,
|
||||
});
|
||||
}
|
||||
if (performance.now() - lastYield >= 8) {
|
||||
@@ -63,7 +85,3 @@ export async function searchTimeline({ rows, query, cursor = 0 }: SearchInput) {
|
||||
}
|
||||
return { locations, nextCursor: null };
|
||||
}
|
||||
|
||||
function normalizeSearchText(text: string): string {
|
||||
return text.replace(/\s+/g, " ").trim().toLowerCase();
|
||||
}
|
||||
|
||||
@@ -476,7 +476,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5");
|
||||
expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5-5");
|
||||
expect(models.map((model) => model.id)).toContain("claude-fable-5");
|
||||
} finally {
|
||||
await fs.rm(emptyConfigDir, { recursive: true, force: true });
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
import pino from "pino";
|
||||
|
||||
import type { AgentStreamEvent, AgentSession } from "../../agent-sdk-types.js";
|
||||
import {
|
||||
canRunRealProvider,
|
||||
createRealProviderClient,
|
||||
} from "../../../daemon-e2e/real-provider-test-config.js";
|
||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
||||
|
||||
// Adaptive thinking is the model's choice, so the prompt has to be hard enough
|
||||
// that it always reasons. A trivial sum does not think at all.
|
||||
const THINKING_PROMPT =
|
||||
"You have 12 identical-looking balls; exactly one has a different weight (you do not " +
|
||||
"know if heavier or lighter). Using a balance scale only 3 times, give the complete " +
|
||||
"decision procedure that always identifies the odd ball AND whether it is heavy or " +
|
||||
"light. Work out every branch.";
|
||||
|
||||
function isTerminalEvent(event: AgentStreamEvent): boolean {
|
||||
return (
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed" ||
|
||||
event.type === "turn_canceled"
|
||||
);
|
||||
}
|
||||
|
||||
async function collectUntilTerminal(session: AgentSession): Promise<AgentStreamEvent[]> {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of streamSession(session, THINKING_PROMPT)) {
|
||||
events.push(event);
|
||||
if (isTerminalEvent(event)) {
|
||||
return events;
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function reasoningTexts(events: AgentStreamEvent[]): string[] {
|
||||
return events.flatMap((event) =>
|
||||
event.type === "timeline" && event.item.type === "reasoning" ? [event.item.text] : [],
|
||||
);
|
||||
}
|
||||
|
||||
describe("Claude thinking display (real)", () => {
|
||||
let canRun = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
canRun = await canRunRealProvider("claude");
|
||||
});
|
||||
|
||||
beforeEach((context) => {
|
||||
if (!canRun) {
|
||||
context.skip();
|
||||
}
|
||||
});
|
||||
|
||||
test("emits reasoning timeline items when thinking is enabled", async () => {
|
||||
const client = createRealProviderClient("claude", pino({ level: "silent" }));
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
modeId: "bypassPermissions",
|
||||
model: "claude-sonnet-5",
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
|
||||
try {
|
||||
const events = await collectUntilTerminal(session);
|
||||
const failure = events.find((event) => event.type === "turn_failed");
|
||||
expect(failure, JSON.stringify(failure)).toBeUndefined();
|
||||
|
||||
const reasoning = reasoningTexts(events);
|
||||
expect(reasoning.length).toBeGreaterThan(0);
|
||||
expect(reasoning.join("").trim().length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await session.close().catch(() => undefined);
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -11,6 +11,7 @@ interface ClaudeModelManifestEntry {
|
||||
minimumClaudeCodeVersion?: string;
|
||||
contextWindowMaxTokens?: number;
|
||||
effortLevels?: readonly ClaudeEffortLevel[];
|
||||
defaultThinkingOptionId?: ClaudeEffortLevel;
|
||||
supportsThinkingDisabled?: boolean;
|
||||
supportsFastMode?: boolean;
|
||||
}
|
||||
@@ -34,10 +35,21 @@ export const CLAUDE_DISABLED_THINKING_OPTION_ID = "off";
|
||||
export const CLAUDE_ULTRACODE_THINKING_OPTION_ID = "ultracode";
|
||||
|
||||
export const CLAUDE_MODEL_MANIFEST = [
|
||||
{
|
||||
id: "claude-opus-5-5",
|
||||
label: "Opus 5.5",
|
||||
description: "Opus 5.5 · Latest release",
|
||||
defaultPriority: 3,
|
||||
minimumClaudeCodeVersion: "2.1.280",
|
||||
defaultThinkingOptionId: "medium",
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
||||
supportsFastMode: true,
|
||||
},
|
||||
{
|
||||
id: "claude-opus-5",
|
||||
label: "Opus 5",
|
||||
description: "Opus 5 · Latest release",
|
||||
description: "Opus 5 · Previous release",
|
||||
defaultPriority: 2,
|
||||
minimumClaudeCodeVersion: "2.1.219",
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
@@ -157,9 +169,14 @@ export const CLAUDE_MODEL_MANIFEST = [
|
||||
},
|
||||
] as const satisfies readonly ClaudeModelManifestEntry[];
|
||||
|
||||
function getDefaultThinkingOptionId(model: ClaudeModelManifestEntry): ClaudeEffortLevel {
|
||||
return model.defaultThinkingOptionId ?? CLAUDE_DEFAULT_THINKING_OPTION_ID;
|
||||
}
|
||||
|
||||
function buildThinkingOptions(
|
||||
effortLevels: readonly ClaudeEffortLevel[] | undefined,
|
||||
supportsThinkingDisabled: boolean,
|
||||
defaultThinkingOptionId: ClaudeEffortLevel,
|
||||
): AgentSelectOption[] | undefined {
|
||||
if (!effortLevels) {
|
||||
return undefined;
|
||||
@@ -170,7 +187,7 @@ function buildThinkingOptions(
|
||||
...effortLevels.map((id) => ({
|
||||
id,
|
||||
label: CLAUDE_EFFORT_LABELS[id],
|
||||
...(id === CLAUDE_DEFAULT_THINKING_OPTION_ID ? { isDefault: true } : {}),
|
||||
...(id === defaultThinkingOptionId ? { isDefault: true } : {}),
|
||||
})),
|
||||
];
|
||||
|
||||
@@ -196,6 +213,7 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD
|
||||
const thinkingOptions = buildThinkingOptions(
|
||||
model.effortLevels,
|
||||
model.supportsThinkingDisabled === true,
|
||||
getDefaultThinkingOptionId(model),
|
||||
);
|
||||
const definition: AgentModelDefinition = {
|
||||
provider: "claude",
|
||||
@@ -214,7 +232,7 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD
|
||||
}
|
||||
if (thinkingOptions) {
|
||||
definition.thinkingOptions = thinkingOptions;
|
||||
definition.defaultThinkingOptionId = CLAUDE_DEFAULT_THINKING_OPTION_ID;
|
||||
definition.defaultThinkingOptionId = getDefaultThinkingOptionId(model);
|
||||
}
|
||||
definitions.push(definition);
|
||||
if (!("aliases" in model) || !model.aliases) {
|
||||
@@ -286,7 +304,7 @@ export function resolveClaudeDisabledThinkingForModel(
|
||||
supported:
|
||||
!!model && "supportsThinkingDisabled" in model && model.supportsThinkingDisabled === true,
|
||||
fallbackThinkingOptionId:
|
||||
model && "effortLevels" in model ? CLAUDE_DEFAULT_THINKING_OPTION_ID : undefined,
|
||||
model && "effortLevels" in model ? getDefaultThinkingOptionId(model) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -350,7 +368,9 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined)
|
||||
/**
|
||||
* Normalize a Claude Code runtime/config model string to a known manifest ID.
|
||||
* Runtime metadata may include provider prefixes such as Bedrock model IDs; feature
|
||||
* gates should use normalizeClaudeManifestModelId instead.
|
||||
* gates should use normalizeClaudeManifestModelId instead. The prefixed matches are
|
||||
* unanchored, so major-minor runs first: "claude-opus-5-5" would otherwise stop at the
|
||||
* "claude-opus-5" entry.
|
||||
*/
|
||||
export function normalizeClaudeRuntimeModelId(value: string | null | undefined): string | null {
|
||||
const normalizedManifestModelId = normalizeClaudeManifestModelId(value);
|
||||
@@ -363,13 +383,14 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
|
||||
return null;
|
||||
}
|
||||
|
||||
const singleSegmentMatch = trimmed.match(
|
||||
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i,
|
||||
const runtimeMatch = trimmed.match(
|
||||
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
|
||||
);
|
||||
if (singleSegmentMatch) {
|
||||
const normalizedModelId = normalizeSingleSegmentClaudeModelId(
|
||||
singleSegmentMatch[1],
|
||||
singleSegmentMatch[2],
|
||||
if (runtimeMatch) {
|
||||
const normalizedModelId = normalizeMajorMinorClaudeModelId(
|
||||
runtimeMatch[1],
|
||||
runtimeMatch[2],
|
||||
runtimeMatch[3],
|
||||
trimmed.toLowerCase().includes("[1m]"),
|
||||
);
|
||||
if (normalizedModelId) {
|
||||
@@ -377,17 +398,16 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeMatch = trimmed.match(
|
||||
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
|
||||
const singleSegmentMatch = trimmed.match(
|
||||
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i,
|
||||
);
|
||||
if (!runtimeMatch) {
|
||||
if (!singleSegmentMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeMajorMinorClaudeModelId(
|
||||
runtimeMatch[1],
|
||||
runtimeMatch[2],
|
||||
runtimeMatch[3],
|
||||
return normalizeSingleSegmentClaudeModelId(
|
||||
singleSegmentMatch[1],
|
||||
singleSegmentMatch[2],
|
||||
trimmed.toLowerCase().includes("[1m]"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ async function createClaudeConfigDirWithRawSettings(settings: string): Promise<s
|
||||
return configDir;
|
||||
}
|
||||
|
||||
function createCatalogClient(claudeCodeVersion = "2.1.219"): ClaudeAgentClient {
|
||||
function createCatalogClient(claudeCodeVersion = "2.1.280"): ClaudeAgentClient {
|
||||
return new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveVersion: async () => claudeCodeVersion,
|
||||
@@ -50,6 +50,7 @@ describe("getClaudeModels", () => {
|
||||
it("returns all claude models", () => {
|
||||
const models = getClaudeModels();
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
"claude-opus-5-5",
|
||||
"claude-opus-5",
|
||||
"claude-fable-5-1",
|
||||
"claude-fable-5",
|
||||
@@ -72,7 +73,7 @@ describe("getClaudeModels", () => {
|
||||
const models = getClaudeModels();
|
||||
const defaults = models.filter((m) => m.isDefault);
|
||||
expect(defaults).toHaveLength(1);
|
||||
expect(defaults[0].id).toBe("claude-opus-5");
|
||||
expect(defaults[0].id).toBe("claude-opus-5-5");
|
||||
});
|
||||
|
||||
it("defines context window sizes in the catalog", () => {
|
||||
@@ -82,6 +83,7 @@ describe("getClaudeModels", () => {
|
||||
|
||||
expect(contextWindows).toEqual(
|
||||
new Map([
|
||||
["claude-opus-5-5", 1_000_000],
|
||||
["claude-opus-5", 1_000_000],
|
||||
["claude-fable-5-1", 1_000_000],
|
||||
["claude-fable-5", 1_000_000],
|
||||
@@ -109,6 +111,11 @@ describe("getClaudeModels", () => {
|
||||
|
||||
expect(getClaudeModels("2.1.168").map((model) => model.id)).not.toContain("claude-fable-5");
|
||||
expect(getClaudeModels("2.1.169").map((model) => model.id)).toContain("claude-fable-5");
|
||||
|
||||
expect(getClaudeModels("2.1.279").map((model) => model.id)).not.toContain("claude-opus-5-5");
|
||||
expect(getClaudeModels("2.1.279").find((model) => model.isDefault)?.id).toBe("claude-opus-5");
|
||||
expect(getClaudeModels("2.1.280").map((model) => model.id)).toContain("claude-opus-5-5");
|
||||
expect(getClaudeModels("2.1.280").find((model) => model.isDefault)?.id).toBe("claude-opus-5-5");
|
||||
});
|
||||
|
||||
it("derives thinking options from model effort capabilities", () => {
|
||||
@@ -169,6 +176,8 @@ describe("getClaudeModels", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
["claude-opus-5-5", false, "medium"],
|
||||
["claude-opus-5-5-20260401", false, "medium"],
|
||||
["claude-opus-5", true, "high"],
|
||||
["claude-opus-5-20260724", true, "high"],
|
||||
["claude-sonnet-5", true, "high"],
|
||||
@@ -417,6 +426,19 @@ describe("normalizeClaudeRuntimeModelId", () => {
|
||||
"claude-opus-4-8",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not collapse a prefixed minor release onto the major it extends", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("anthropic/claude-opus-5-5")).toBe("claude-opus-5-5");
|
||||
expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-opus-5-5-20260401-v1:0")).toBe(
|
||||
"claude-opus-5-5",
|
||||
);
|
||||
expect(normalizeClaudeRuntimeModelId("openrouter/anthropic/claude-fable-5-1")).toBe(
|
||||
"claude-fable-5-1",
|
||||
);
|
||||
expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-opus-5-20260724-v1:0")).toBe(
|
||||
"claude-opus-5",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseClaudeCodeVersion", () => {
|
||||
@@ -438,7 +460,7 @@ describe("findClaudeModel", () => {
|
||||
describe("Claude Opus 5 catalog", () => {
|
||||
it("offers a single Opus 5 entry with a 1M context window", () => {
|
||||
const opus5Models = getClaudeModels()
|
||||
.filter((model) => model.id.startsWith("claude-opus-5"))
|
||||
.filter((model) => /^claude-opus-5(\[1m\])?$/.test(model.id))
|
||||
.map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens }));
|
||||
|
||||
expect(opus5Models).toEqual([
|
||||
@@ -515,6 +537,41 @@ describe("Claude Fable 5.1 catalog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude Opus 5.5 catalog", () => {
|
||||
it("offers one Opus 5.5 entry with a 1M context window", () => {
|
||||
const opus55Models = getClaudeModels()
|
||||
.filter((model) => model.id.startsWith("claude-opus-5-5"))
|
||||
.map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens }));
|
||||
|
||||
expect(opus55Models).toEqual([
|
||||
{ id: "claude-opus-5-5", label: "Opus 5.5", contextWindowMaxTokens: 1_000_000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers every effort level except off, because Opus 5.5 cannot disable thinking", () => {
|
||||
const opus55 = getClaudeModels().find((model) => model.id === "claude-opus-5-5");
|
||||
|
||||
expect(opus55?.thinkingOptions?.map((option) => option.id)).toEqual([
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
|
||||
]);
|
||||
expect(opus55?.defaultThinkingOptionId).toBe("medium");
|
||||
expect(
|
||||
opus55?.thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id),
|
||||
).toEqual(["medium"]);
|
||||
});
|
||||
|
||||
it("resolves suffixed and dated Opus 5.5 IDs to the catalog entry", () => {
|
||||
expect(findClaudeModel("claude-opus-5-5[1m]")?.id).toBe("claude-opus-5-5");
|
||||
expect(findClaudeModel("claude-opus-5-5-20260401")?.id).toBe("claude-opus-5-5");
|
||||
expect(findClaudeModel("claude-opus-5-5-20260401[1m]")?.id).toBe("claude-opus-5-5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("claudeManifestModelSupportsFastMode", () => {
|
||||
it("keeps fast mode strict to first-party manifest model IDs", () => {
|
||||
expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull();
|
||||
@@ -523,6 +580,7 @@ describe("claudeManifestModelSupportsFastMode", () => {
|
||||
});
|
||||
|
||||
it("supports fast mode on Opus 5 but not on other Claude 5 models", () => {
|
||||
expect(claudeManifestModelSupportsFastMode("claude-opus-5-5")).toBe(true);
|
||||
expect(claudeManifestModelSupportsFastMode("claude-opus-5")).toBe(true);
|
||||
expect(claudeManifestModelSupportsFastMode("claude-sonnet-5")).toBe(false);
|
||||
expect(claudeManifestModelSupportsFastMode("claude-fable-5")).toBe(false);
|
||||
|
||||
@@ -5991,6 +5991,138 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
|
||||
describe("Codex importable sessions", () => {
|
||||
const CODEX_THREAD_PAGE_CAP = 100;
|
||||
|
||||
// Codex answers thread/list with at most 100 rows per response whatever limit
|
||||
// the caller asks for, and hands back a cursor for the remaining threads.
|
||||
function cappedThreadListHandler(threads: Array<Record<string, unknown>>) {
|
||||
return (input: unknown) => {
|
||||
const params = (input ?? {}) as {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
cwd?: string;
|
||||
sortKey?: string;
|
||||
};
|
||||
const recencyKey = params.sortKey === "updated_at" ? "updatedAt" : "createdAt";
|
||||
const matching = threads
|
||||
.filter((thread) => !params.cwd || thread.cwd === params.cwd)
|
||||
.sort((a, b) => Number(b[recencyKey]) - Number(a[recencyKey]));
|
||||
const offset = Number(params.cursor ?? 0);
|
||||
const page = matching.slice(
|
||||
offset,
|
||||
offset + Math.min(params.limit ?? CODEX_THREAD_PAGE_CAP, CODEX_THREAD_PAGE_CAP),
|
||||
);
|
||||
const nextOffset = offset + page.length;
|
||||
return {
|
||||
data: page,
|
||||
nextCursor: nextOffset < matching.length ? String(nextOffset) : null,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function threadsUsedInReverseCreationOrder(count: number) {
|
||||
// The most recently used thread is also the oldest one created, so a scan
|
||||
// that follows Codex's creation-time page order misses it.
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `thread-${index}`,
|
||||
cwd: "/workspace/project-a",
|
||||
preview: `Session ${index}`,
|
||||
createdAt: index,
|
||||
updatedAt: count - index,
|
||||
}));
|
||||
}
|
||||
|
||||
test("fills the requested window across the pages Codex caps at 100 rows", async () => {
|
||||
const threads = threadsUsedInReverseCreationOrder(250);
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"thread/list": cappedThreadListHandler([
|
||||
...threads,
|
||||
{ id: "other-project", cwd: "/workspace/project-b", createdAt: 9000, updatedAt: 9000 },
|
||||
]),
|
||||
});
|
||||
const provider = createProviderWithFakeAppServer(appServer);
|
||||
|
||||
const sessions = await provider.listImportableSessions({
|
||||
limit: 240,
|
||||
cwd: "/workspace/project-a",
|
||||
});
|
||||
|
||||
expect(sessions.map((session) => session.providerHandleId)).toEqual(
|
||||
threads.slice(0, 240).map((thread) => thread.id),
|
||||
);
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("keeps the scanned window on the most recently used threads", async () => {
|
||||
const threads = threadsUsedInReverseCreationOrder(550);
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"thread/list": cappedThreadListHandler(threads),
|
||||
});
|
||||
const provider = createProviderWithFakeAppServer(appServer);
|
||||
|
||||
const sessions = await provider.listImportableSessions({ limit: 500, scanLimit: 700 });
|
||||
|
||||
expect(sessions.map((session) => session.providerHandleId)).toEqual(
|
||||
threads.slice(0, 500).map((thread) => thread.id),
|
||||
);
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("keeps a thread once when it moves onto a later page mid-scan", async () => {
|
||||
// A thread used while the scan is paging sorts to the front and comes back
|
||||
// on the next page.
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"thread/list": (input) => {
|
||||
const { cursor } = (input ?? {}) as { cursor?: string };
|
||||
return cursor
|
||||
? {
|
||||
data: [
|
||||
{ id: "thread-1", cwd: "/workspace/project-a", updatedAt: 9000 },
|
||||
{ id: "thread-3", cwd: "/workspace/project-a", updatedAt: 1000 },
|
||||
],
|
||||
nextCursor: null,
|
||||
}
|
||||
: {
|
||||
data: [
|
||||
{ id: "thread-1", cwd: "/workspace/project-a", updatedAt: 3000 },
|
||||
{ id: "thread-2", cwd: "/workspace/project-a", updatedAt: 2000 },
|
||||
],
|
||||
nextCursor: "page-2",
|
||||
};
|
||||
},
|
||||
});
|
||||
const provider = createProviderWithFakeAppServer(appServer);
|
||||
|
||||
const sessions = await provider.listImportableSessions({ limit: 500, scanLimit: 500 });
|
||||
|
||||
expect(sessions.map((session) => session.providerHandleId)).toEqual([
|
||||
"thread-1",
|
||||
"thread-2",
|
||||
"thread-3",
|
||||
]);
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("stops scanning when Codex pages without handing back a new thread", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
// A cursor cycle: the two pages after the first serve threads the scan
|
||||
// already holds and point at each other, so it can never fill its window.
|
||||
"thread/list": (input) => {
|
||||
const { cursor } = (input ?? {}) as { cursor?: string };
|
||||
const thread = { id: "thread-1", cwd: "/workspace/project-a" };
|
||||
if (cursor === "page-a") return { data: [thread], nextCursor: "page-b" };
|
||||
if (cursor === "page-b") return { data: [thread], nextCursor: "page-a" };
|
||||
return { data: [thread], nextCursor: "page-a" };
|
||||
},
|
||||
});
|
||||
const provider = createProviderWithFakeAppServer(appServer);
|
||||
|
||||
const sessions = await provider.listImportableSessions({ limit: 500, scanLimit: 500 });
|
||||
|
||||
expect(sessions.map((session) => session.providerHandleId)).toEqual(["thread-1"]);
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("listImportableSessions uses thread list metadata without hydrating thread history", async () => {
|
||||
const allThreads = [
|
||||
{
|
||||
@@ -6071,7 +6203,10 @@ describe("Codex importable sessions", () => {
|
||||
capabilities: { experimentalApi: true, mcpServerOpenaiFormElicitation: true },
|
||||
},
|
||||
},
|
||||
{ method: "thread/list", params: { limit: 50, cwd: "/workspace/project-a" } },
|
||||
{
|
||||
method: "thread/list",
|
||||
params: { limit: 50, sortKey: "updated_at", cwd: "/workspace/project-a" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -914,6 +914,60 @@ const CodexModelListResponseSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Read the newest `window.limit` threads Codex knows about.
|
||||
*
|
||||
* Codex caps every `thread/list` response at 100 rows whatever limit it is
|
||||
* given (codex-cli 0.153 and 0.155), so one request only ever sees the newest
|
||||
* page and hands back a cursor for the rest. Follow that cursor until the
|
||||
* caller's window is full or Codex runs out of threads.
|
||||
*/
|
||||
async function readCodexThreadWindow(
|
||||
client: Pick<CodexAppServerClientLike, "request">,
|
||||
logger: Logger,
|
||||
window: { limit: number; cwd?: string },
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
// A thread updated while the scan is paging moves under `updated_at` order
|
||||
// and can come back on a later page, so identify each one and keep it once.
|
||||
// A row Codex sends without an id stands for itself.
|
||||
const threads = new Map<unknown, Record<string, unknown>>();
|
||||
let cursor: string | undefined;
|
||||
while (threads.size < window.limit) {
|
||||
const response = toObjectRecord(
|
||||
await client.request("thread/list", {
|
||||
limit: window.limit - threads.size,
|
||||
// Rank the window by last use. Codex pages by creation time by default,
|
||||
// which drops an old conversation that is still being worked in.
|
||||
// Older Codex builds ignore the unknown key and keep that order.
|
||||
sortKey: "updated_at",
|
||||
...(window.cwd ? { cwd: window.cwd } : {}),
|
||||
...(cursor ? { cursor } : {}),
|
||||
}),
|
||||
);
|
||||
const page = Array.isArray(response?.data) ? response.data.filter(isRecord) : [];
|
||||
const sizeBeforePage = threads.size;
|
||||
for (const thread of page) {
|
||||
const identity = typeof thread.id === "string" ? thread.id : thread;
|
||||
if (!threads.has(identity)) threads.set(identity, thread);
|
||||
}
|
||||
const nextCursor = typeof response?.nextCursor === "string" ? response.nextCursor : undefined;
|
||||
if (!nextCursor) break;
|
||||
if (threads.size === sizeBeforePage) {
|
||||
// Every page that continues the scan brings a thread the scan has not
|
||||
// seen, so the loop is bounded by the window. A page that brings none
|
||||
// would let a Codex-side cursor bug (a stuck cursor, or a cycle) page for
|
||||
// ever behind a caller that has already timed out.
|
||||
logger.warn(
|
||||
{ cursor: nextCursor },
|
||||
"codex thread/list returned no new threads mid-scan, stopping the session scan",
|
||||
);
|
||||
break;
|
||||
}
|
||||
cursor = nextCursor;
|
||||
}
|
||||
return [...threads.values()];
|
||||
}
|
||||
|
||||
function filterCodexThreadsByCwd(
|
||||
threads: Array<Record<string, unknown>>,
|
||||
cwd: string | undefined,
|
||||
@@ -7143,13 +7197,10 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
// filtering since most threads will be from other cwds, then keep the
|
||||
// local realpath-aware filter for symlink-equivalent workspace paths.
|
||||
const listLimit = options?.cwd ? Math.max(scanLimit, 50) : scanLimit;
|
||||
const response = toObjectRecord(
|
||||
await client.request("thread/list", {
|
||||
limit: listLimit,
|
||||
...(options?.cwd ? { cwd: options.cwd } : {}),
|
||||
}),
|
||||
);
|
||||
const allThreads = Array.isArray(response?.data) ? response.data.filter(isRecord) : [];
|
||||
const allThreads = await readCodexThreadWindow(client, this.logger, {
|
||||
limit: listLimit,
|
||||
cwd: options?.cwd,
|
||||
});
|
||||
const threads = filterCodexThreadsByCwd(allThreads, options?.cwd);
|
||||
return threads.slice(0, limit).map((thread) => {
|
||||
const threadId = typeof thread.id === "string" ? thread.id : "";
|
||||
|
||||
@@ -145,6 +145,18 @@ const MODELS: AgentModelDefinition[] = [
|
||||
intervalMs: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: MOCK_LOAD_TEST_PROVIDER_ID,
|
||||
id: "max-only-thinking-stream",
|
||||
label: "Max-only thinking stream",
|
||||
description: "Fast realistic stream with one supported thinking level.",
|
||||
thinkingOptions: [{ id: "max", label: "Max", isDefault: true }],
|
||||
defaultThinkingOptionId: "max",
|
||||
metadata: {
|
||||
durationMs: 10_000,
|
||||
intervalMs: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: MOCK_LOAD_TEST_PROVIDER_ID,
|
||||
id: "e2e-fast-stream",
|
||||
|
||||
@@ -24,11 +24,35 @@ import {
|
||||
transformPiModels,
|
||||
} from "./agent.js";
|
||||
import { FakePi } from "./test-utils/fake-pi.js";
|
||||
import type { PiModel, PiThinkingLevel } from "./rpc-types.js";
|
||||
import type { PiUsagePollScheduler } from "./usage-poller.js";
|
||||
|
||||
const ONE_BY_ONE_PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
|
||||
|
||||
const RESTRICTED_THINKING_MODEL: PiModel = {
|
||||
provider: "kimi-coding",
|
||||
id: "kimi-k3",
|
||||
name: "Kimi K3",
|
||||
reasoning: true,
|
||||
thinkingLevelMap: {
|
||||
off: null,
|
||||
minimal: null,
|
||||
low: "low",
|
||||
medium: null,
|
||||
high: "high",
|
||||
xhigh: null,
|
||||
max: "max",
|
||||
},
|
||||
};
|
||||
|
||||
interface PiThinkingCatalogCase {
|
||||
name: string;
|
||||
model: PiModel;
|
||||
optionIds: PiThinkingLevel[] | undefined;
|
||||
defaultThinkingOptionId: PiThinkingLevel | undefined;
|
||||
}
|
||||
|
||||
test("Pi RPC timeout defaults to 60 seconds and accepts an override", () => {
|
||||
expect(PiProviderParamsSchema.parse({}).rpcTimeoutMs).toBe(60_000);
|
||||
expect(PiProviderParamsSchema.parse({ rpcTimeoutMs: 90_000 }).rpcTimeoutMs).toBe(90_000);
|
||||
@@ -179,6 +203,51 @@ test("keeps normal Pi agent sessions persisted", async () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("lets Pi choose the thinking level when none is requested", async () => {
|
||||
const pi = new FakePi();
|
||||
pi.queueSessionSetup((session) => {
|
||||
session.state = {
|
||||
...session.state,
|
||||
model: RESTRICTED_THINKING_MODEL,
|
||||
thinkingLevel: "max",
|
||||
};
|
||||
});
|
||||
const session = await createClient(pi).createSession(
|
||||
createConfig({ model: "kimi-coding/kimi-k3" }),
|
||||
);
|
||||
onTestFinished(() => session.close());
|
||||
|
||||
expect(pi.recordedLaunches[0]?.thinkingOptionId).toBeUndefined();
|
||||
expect(pi.recordedLaunches[0]?.argv).not.toContain("--thinking");
|
||||
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe("max");
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ thinkingOptionId: "max" });
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ requested: "medium", effective: "high" },
|
||||
{ requested: "xhigh", effective: "max" },
|
||||
] as const)(
|
||||
"adopts Pi's $effective level when creating with $requested",
|
||||
async ({ requested, effective }) => {
|
||||
const pi = new FakePi();
|
||||
pi.queueSessionSetup((session) => {
|
||||
session.state = {
|
||||
...session.state,
|
||||
model: RESTRICTED_THINKING_MODEL,
|
||||
thinkingLevel: effective,
|
||||
};
|
||||
});
|
||||
const session = await createClient(pi).createSession(
|
||||
createConfig({ model: "kimi-coding/kimi-k3", thinkingOptionId: requested }),
|
||||
);
|
||||
onTestFinished(() => session.close());
|
||||
|
||||
expect(pi.recordedLaunches[0]?.thinkingOptionId).toBe(requested);
|
||||
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(effective);
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ thinkingOptionId: effective });
|
||||
},
|
||||
);
|
||||
|
||||
class SessionEvents {
|
||||
private readonly events: AgentStreamEvent[] = [];
|
||||
private readonly waiters: Array<{
|
||||
@@ -1370,6 +1439,32 @@ describe("PiRpcAgentSession", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("adopts Pi's clamped thinking level when resuming a session", async () => {
|
||||
const pi = new FakePi();
|
||||
pi.queueSessionSetup((session) => {
|
||||
session.state = {
|
||||
...session.state,
|
||||
model: RESTRICTED_THINKING_MODEL,
|
||||
thinkingLevel: "high",
|
||||
};
|
||||
});
|
||||
const session = await createClient(pi).resumeSession({
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
nativeHandle: "/tmp/native-pi-session",
|
||||
metadata: {
|
||||
cwd: "/workspace/project",
|
||||
model: "kimi-coding/kimi-k3",
|
||||
thinkingOptionId: "medium",
|
||||
},
|
||||
});
|
||||
onTestFinished(() => session.close());
|
||||
|
||||
expect(pi.recordedLaunches[0]?.thinkingOptionId).toBe("medium");
|
||||
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe("high");
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ thinkingOptionId: "high" });
|
||||
});
|
||||
|
||||
test("reports the persisted Pi entry attached to the submitted message", async () => {
|
||||
const pi = new FakePi();
|
||||
const client = createClient(pi);
|
||||
@@ -1436,8 +1531,6 @@ describe("PiRpcAgentSession", () => {
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--thinking",
|
||||
"medium",
|
||||
"--extension",
|
||||
actualLaunch.extensionPaths[0],
|
||||
]);
|
||||
@@ -1507,6 +1600,91 @@ describe("PiRpcAgentSession", () => {
|
||||
expect(fakeSession.setThinkingLevelRequests).toEqual(["high"]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ requested: "medium", effective: "high", rpcRequest: "medium" },
|
||||
{ requested: "xhigh", effective: "max", rpcRequest: "xhigh" },
|
||||
{ requested: null, effective: "high", rpcRequest: "medium" },
|
||||
] as const)(
|
||||
"stores Pi's $effective thinking level after requesting $requested",
|
||||
async ({ requested, effective, rpcRequest }) => {
|
||||
const pi = new FakePi();
|
||||
pi.queueSessionSetup((session) => {
|
||||
session.state = {
|
||||
...session.state,
|
||||
model: RESTRICTED_THINKING_MODEL,
|
||||
thinkingLevel: "low",
|
||||
};
|
||||
});
|
||||
const { session } = await createSession(pi);
|
||||
onTestFinished(() => session.close());
|
||||
const fakeSession = pi.latestSession();
|
||||
fakeSession.state = { ...fakeSession.state, thinkingLevel: effective };
|
||||
|
||||
await session.setThinkingOption(requested);
|
||||
|
||||
expect(fakeSession.setThinkingLevelRequests).toEqual([rpcRequest]);
|
||||
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(effective);
|
||||
await expect(session.getRuntimeInfo()).resolves.toEqual({
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
model: "kimi-coding/kimi-k3",
|
||||
thinkingOptionId: effective,
|
||||
modeId: null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test("refreshes Pi's thinking level after changing models", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
onTestFinished(() => session.close());
|
||||
const fakeSession = pi.latestSession();
|
||||
fakeSession.setModelResult = RESTRICTED_THINKING_MODEL;
|
||||
fakeSession.state = { ...fakeSession.state, thinkingLevel: "high" };
|
||||
|
||||
await session.setModel("kimi-coding/kimi-k3");
|
||||
|
||||
expect(fakeSession.setModelRequests).toEqual([{ provider: "kimi-coding", modelId: "kimi-k3" }]);
|
||||
expect(session.describePersistence()?.metadata).toEqual({
|
||||
cwd: "/tmp/paseo-pi-rpc-test",
|
||||
model: "kimi-coding/kimi-k3",
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
|
||||
model: "kimi-coding/kimi-k3",
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
});
|
||||
|
||||
test("reports updated Pi thinking state instead of a cached selection", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
onTestFinished(() => session.close());
|
||||
const fakeSession = pi.latestSession();
|
||||
fakeSession.state = { ...fakeSession.state, thinkingLevel: "off" };
|
||||
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ thinkingOptionId: "off" });
|
||||
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe("off");
|
||||
});
|
||||
|
||||
test("surfaces state refresh failures after a Pi thinking update", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
onTestFinished(() => session.close());
|
||||
pi.latestSession().getStateError = new Error("Pi state unavailable");
|
||||
|
||||
await expect(session.setThinkingOption("high")).rejects.toThrow("Pi state unavailable");
|
||||
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe("medium");
|
||||
});
|
||||
|
||||
test("surfaces state refresh failures after a Pi model update", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
onTestFinished(() => session.close());
|
||||
const fakeSession = pi.latestSession();
|
||||
fakeSession.setModelResult = RESTRICTED_THINKING_MODEL;
|
||||
fakeSession.getStateError = new Error("Pi state unavailable");
|
||||
|
||||
await expect(session.setModel("kimi-coding/kimi-k3")).rejects.toThrow("Pi state unavailable");
|
||||
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe("medium");
|
||||
});
|
||||
|
||||
test("materializes image prompts as text hints for text-only Pi models", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
@@ -2253,6 +2431,9 @@ describe("PiRpcAgentClient", () => {
|
||||
"utf8",
|
||||
);
|
||||
const pi = new FakePi();
|
||||
pi.queueSessionSetup((session) => {
|
||||
session.state.thinkingLevel = "high";
|
||||
});
|
||||
const client = new PiRpcAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
runtime: pi,
|
||||
@@ -2324,6 +2505,113 @@ describe("PiRpcAgentClient", () => {
|
||||
expect(pi.recordedLaunches[0]).toMatchObject({ cwd: "/workspace/with-extension" });
|
||||
});
|
||||
|
||||
test("honors per-model Pi thinking maps and clamps the catalog default upward", async () => {
|
||||
const pi = new FakePi();
|
||||
pi.queueSessionSetup((session) => {
|
||||
session.models = [RESTRICTED_THINKING_MODEL];
|
||||
});
|
||||
const client = createClient(pi);
|
||||
|
||||
const catalog = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/workspace/project",
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(catalog.models).toEqual([
|
||||
{
|
||||
provider: "pi",
|
||||
id: "kimi-coding/kimi-k3",
|
||||
label: "Kimi K3",
|
||||
description: "kimi-coding/kimi-k3",
|
||||
metadata: { provider: "kimi-coding", modelId: "kimi-k3" },
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low", description: "Faster reasoning" },
|
||||
{ id: "high", label: "High", description: "Deeper reasoning", isDefault: true },
|
||||
{ id: "max", label: "Max", description: "Extreme reasoning" },
|
||||
],
|
||||
defaultThinkingOptionId: "high",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test.each<PiThinkingCatalogCase>([
|
||||
{
|
||||
name: "no thinking map",
|
||||
model: { ...RESTRICTED_THINKING_MODEL, thinkingLevelMap: undefined },
|
||||
optionIds: ["off", "minimal", "low", "medium", "high"],
|
||||
defaultThinkingOptionId: "medium",
|
||||
},
|
||||
{
|
||||
name: "a partial thinking map",
|
||||
model: {
|
||||
...RESTRICTED_THINKING_MODEL,
|
||||
thinkingLevelMap: { minimal: null, medium: null, xhigh: "extra-high", max: null },
|
||||
},
|
||||
optionIds: ["off", "low", "high", "xhigh"],
|
||||
defaultThinkingOptionId: "high",
|
||||
},
|
||||
{
|
||||
name: "explicitly mapped extended levels",
|
||||
model: {
|
||||
...RESTRICTED_THINKING_MODEL,
|
||||
thinkingLevelMap: { xhigh: "extra-high", max: "maximum" },
|
||||
},
|
||||
optionIds: ["off", "minimal", "low", "medium", "high", "xhigh", "max"],
|
||||
defaultThinkingOptionId: "medium",
|
||||
},
|
||||
{
|
||||
name: "only lower thinking levels",
|
||||
model: {
|
||||
...RESTRICTED_THINKING_MODEL,
|
||||
thinkingLevelMap: { medium: null, high: null },
|
||||
},
|
||||
optionIds: ["off", "minimal", "low"],
|
||||
defaultThinkingOptionId: "low",
|
||||
},
|
||||
{
|
||||
name: "a non-reasoning model",
|
||||
model: { ...RESTRICTED_THINKING_MODEL, reasoning: false },
|
||||
optionIds: undefined,
|
||||
defaultThinkingOptionId: undefined,
|
||||
},
|
||||
{
|
||||
name: "no supported thinking levels",
|
||||
model: {
|
||||
...RESTRICTED_THINKING_MODEL,
|
||||
thinkingLevelMap: {
|
||||
...RESTRICTED_THINKING_MODEL.thinkingLevelMap,
|
||||
low: null,
|
||||
high: null,
|
||||
max: null,
|
||||
},
|
||||
},
|
||||
optionIds: [],
|
||||
defaultThinkingOptionId: "off",
|
||||
},
|
||||
])(
|
||||
"resolves Pi thinking options for $name",
|
||||
async ({ model, optionIds, defaultThinkingOptionId }) => {
|
||||
const pi = new FakePi();
|
||||
pi.queueSessionSetup((session) => {
|
||||
session.models = [model];
|
||||
});
|
||||
const catalog = await createClient(pi).fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/workspace/project",
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(catalog.models).toHaveLength(1);
|
||||
const thinkingOptions = catalog.models[0]?.thinkingOptions;
|
||||
expect(thinkingOptions?.map((option) => option.id)).toEqual(optionIds);
|
||||
expect(catalog.models[0]?.defaultThinkingOptionId).toBe(defaultThinkingOptionId);
|
||||
expect(
|
||||
thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id),
|
||||
).toEqual(optionIds?.filter((id) => id === defaultThinkingOptionId));
|
||||
},
|
||||
);
|
||||
|
||||
test("lists no draft features without starting a Pi session", async () => {
|
||||
const pi = new FakePi();
|
||||
const client = createClient(pi);
|
||||
@@ -2622,8 +2910,6 @@ describe("PiRpcAgentClient", () => {
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--thinking",
|
||||
"medium",
|
||||
"--mcp-config",
|
||||
actualLaunch.mcpConfigPath,
|
||||
"--extension",
|
||||
@@ -2704,8 +2990,6 @@ describe("PiRpcAgentClient", () => {
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--thinking",
|
||||
"medium",
|
||||
"--extension",
|
||||
actualLaunch.extensionPaths[0],
|
||||
]);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
type AgentRuntimeInfo,
|
||||
type AgentSelectOption,
|
||||
type AgentSession,
|
||||
type AgentSessionConfig,
|
||||
type AgentSlashCommand,
|
||||
@@ -171,12 +172,11 @@ const PI_THINKING_OPTIONS: ReadonlyArray<{
|
||||
id: PiThinkingLevel;
|
||||
label: string;
|
||||
description: string;
|
||||
isDefault?: boolean;
|
||||
}> = [
|
||||
{ id: "off", label: "Off", description: "No extra reasoning" },
|
||||
{ id: "minimal", label: "Minimal", description: "Light reasoning" },
|
||||
{ id: "low", label: "Low", description: "Faster reasoning" },
|
||||
{ id: "medium", label: "Medium", description: "Balanced reasoning", isDefault: true },
|
||||
{ id: "medium", label: "Medium", description: "Balanced reasoning" },
|
||||
{ id: "high", label: "High", description: "Deeper reasoning" },
|
||||
{ id: "xhigh", label: "XHigh", description: "Very deep reasoning" },
|
||||
{ id: "max", label: "Max", description: "Extreme reasoning" },
|
||||
@@ -373,21 +373,6 @@ function parseAutoCompactMode(value: string | undefined): AutoCompactMode {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function mapThinkingOption(option: (typeof PI_THINKING_OPTIONS)[number]) {
|
||||
const mappedOption = {
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
};
|
||||
if (option.isDefault) {
|
||||
return {
|
||||
...mappedOption,
|
||||
isDefault: true,
|
||||
};
|
||||
}
|
||||
return mappedOption;
|
||||
}
|
||||
|
||||
function piModelSupportsImageInput(model: PiModel | null | undefined): boolean {
|
||||
return model?.input?.includes("image") === true;
|
||||
}
|
||||
@@ -791,14 +776,6 @@ function isPiRequestAbortError(error: unknown): boolean {
|
||||
return /\brequest was aborted\b|\babort(ed)?\b/i.test(toDiagnosticErrorMessage(error));
|
||||
}
|
||||
|
||||
function resolveThinkingOptionId(
|
||||
cachedThinkingOptionId: string | null,
|
||||
sessionThinkingLevel: PiThinkingLevel,
|
||||
): PiThinkingLevel | null {
|
||||
const currentThinking = cachedThinkingOptionId ?? sessionThinkingLevel;
|
||||
return normalizePiThinkingOption(currentThinking);
|
||||
}
|
||||
|
||||
function modelToId(model: PiModel | null | undefined): string | null {
|
||||
return model?.provider && model.id ? `${model.provider}/${model.id}` : null;
|
||||
}
|
||||
@@ -1195,7 +1172,50 @@ function buildExtensionUiResponse(
|
||||
return { value: answer };
|
||||
}
|
||||
|
||||
function resolvePiThinkingConfig(
|
||||
model: PiModel,
|
||||
): Pick<AgentModelDefinition, "thinkingOptions" | "defaultThinkingOptionId"> {
|
||||
if (!model.reasoning) {
|
||||
return { thinkingOptions: undefined, defaultThinkingOptionId: undefined };
|
||||
}
|
||||
|
||||
const supportedOptions = PI_THINKING_OPTIONS.filter((option) => {
|
||||
const mapped = model.thinkingLevelMap?.[option.id];
|
||||
if (mapped === null) {
|
||||
return false;
|
||||
}
|
||||
if (option.id === "xhigh" || option.id === "max") {
|
||||
return mapped !== undefined;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const defaultIndex = PI_THINKING_OPTIONS.findIndex(
|
||||
(option) => option.id === DEFAULT_PI_THINKING_LEVEL,
|
||||
);
|
||||
// Pi clamps upward first, then falls back to the highest remaining lower level.
|
||||
const higherDefault = supportedOptions.find(
|
||||
(option) => PI_THINKING_OPTIONS.indexOf(option) >= defaultIndex,
|
||||
);
|
||||
const defaultOption = higherDefault ?? supportedOptions.at(-1);
|
||||
const defaultThinkingOptionId = defaultOption?.id ?? "off";
|
||||
return {
|
||||
thinkingOptions: supportedOptions.map((option) => {
|
||||
const mappedOption: AgentSelectOption = {
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
};
|
||||
if (option.id === defaultThinkingOptionId) {
|
||||
mappedOption.isDefault = true;
|
||||
}
|
||||
return mappedOption;
|
||||
}),
|
||||
defaultThinkingOptionId,
|
||||
};
|
||||
}
|
||||
|
||||
function mapPiModel(model: PiModel, provider: AgentProvider): AgentModelDefinition {
|
||||
const { thinkingOptions, defaultThinkingOptionId } = resolvePiThinkingConfig(model);
|
||||
return {
|
||||
provider,
|
||||
id: `${model.provider}/${model.id}`,
|
||||
@@ -1205,8 +1225,8 @@ function mapPiModel(model: PiModel, provider: AgentProvider): AgentModelDefiniti
|
||||
provider: model.provider,
|
||||
modelId: model.id,
|
||||
},
|
||||
thinkingOptions: model.reasoning ? PI_THINKING_OPTIONS.map(mapThinkingOption) : undefined,
|
||||
defaultThinkingOptionId: model.reasoning ? DEFAULT_PI_THINKING_LEVEL : undefined,
|
||||
thinkingOptions,
|
||||
defaultThinkingOptionId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1244,7 +1264,6 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
private activePromptRequestId: string | null = null;
|
||||
private readonly pendingPromptResults = new Map<string, boolean>();
|
||||
private readonly pendingSteerSubmissions: PiPendingSteerSubmission[] = [];
|
||||
private lastKnownThinkingOptionId: string | null;
|
||||
currentLeafOverrideId: string | null | undefined;
|
||||
private readonly capturedUserEntries: PiCapturedEntry[] = [];
|
||||
private readonly capturedUserEntriesById = new Map<string, PiCapturedEntry>();
|
||||
@@ -1266,14 +1285,11 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
this.runtimeSession = options.runtimeSession;
|
||||
this.config = options.config;
|
||||
this.state = options.initialState;
|
||||
this.config.thinkingOptionId = this.state.thinkingLevel;
|
||||
this.capabilities = options.capabilities;
|
||||
this.provider = PI_PROVIDER;
|
||||
this.currentModeId = options.currentModeId ?? null;
|
||||
this.cleanup = options.cleanup;
|
||||
this.lastKnownThinkingOptionId =
|
||||
normalizePiThinkingOption(options.config.thinkingOptionId) ??
|
||||
this.state.thinkingLevel ??
|
||||
null;
|
||||
this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS;
|
||||
this.logger = options.logger;
|
||||
this.usagePoller = new PiUsagePoller({
|
||||
@@ -1469,10 +1485,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
provider: this.provider,
|
||||
sessionId: this.state.sessionId,
|
||||
model: modelToId(this.state.model),
|
||||
thinkingOptionId: resolveThinkingOptionId(
|
||||
this.lastKnownThinkingOptionId,
|
||||
this.state.thinkingLevel,
|
||||
),
|
||||
thinkingOptionId: this.state.thinkingLevel,
|
||||
modeId: this.currentModeId,
|
||||
};
|
||||
}
|
||||
@@ -1689,22 +1702,14 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
const model = await this.runtimeSession.setModel(parsedReference.provider, parsedReference.id);
|
||||
this.state = {
|
||||
...this.state,
|
||||
model,
|
||||
};
|
||||
await this.refreshState();
|
||||
this.config.model = `${model.provider}/${model.id}`;
|
||||
}
|
||||
|
||||
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
|
||||
const thinkingLevel = normalizePiThinkingOption(thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL;
|
||||
await this.runtimeSession.setThinkingLevel(thinkingLevel);
|
||||
this.lastKnownThinkingOptionId = thinkingLevel;
|
||||
this.config.thinkingOptionId = thinkingLevel;
|
||||
this.state = {
|
||||
...this.state,
|
||||
thinkingLevel,
|
||||
};
|
||||
await this.refreshState();
|
||||
}
|
||||
|
||||
private emit(event: AgentStreamEvent): void {
|
||||
@@ -2496,6 +2501,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
|
||||
private async refreshState(): Promise<void> {
|
||||
this.state = await this.runtimeSession.getState();
|
||||
this.config.thinkingOptionId = this.state.thinkingLevel;
|
||||
}
|
||||
|
||||
private async refreshAfterTurn(finalUsage: Promise<void>): Promise<void> {
|
||||
@@ -2542,8 +2548,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
runtimeSession = await this.runtime.startSession({
|
||||
cwd: config.cwd,
|
||||
model: config.model,
|
||||
thinkingOptionId:
|
||||
normalizePiThinkingOption(config.thinkingOptionId) ?? DEFAULT_PI_THINKING_LEVEL,
|
||||
thinkingOptionId: normalizePiThinkingOption(config.thinkingOptionId) ?? undefined,
|
||||
noSession: config.internal === true,
|
||||
env: launchContext?.env,
|
||||
mcpConfigPath: mcpConfig?.path,
|
||||
|
||||
@@ -74,6 +74,7 @@ export interface PiModel {
|
||||
id: string;
|
||||
name?: string;
|
||||
reasoning?: boolean;
|
||||
thinkingLevelMap?: Record<string, string | null>;
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
api?: string;
|
||||
|
||||
@@ -265,6 +265,7 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
if (!this.setModelResult) {
|
||||
throw new Error("FakePi setModel requires setModelResult to be scripted");
|
||||
}
|
||||
this.state = { ...this.state, model: this.setModelResult };
|
||||
return this.setModelResult;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.9.0-beta.2",
|
||||
"version": "0.9.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user