mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-17 23:02:36 +00:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5d9307cf9 | ||
|
|
90c6de0580 | ||
|
|
9bd58f66b2 | ||
|
|
e2ad26d238 | ||
|
|
142cdac8d2 | ||
|
|
c517d07d6f | ||
|
|
6388c466b1 | ||
|
|
3c48ac3ad2 | ||
|
|
a4eb0adcf6 | ||
|
|
6ff071a487 | ||
|
|
ecd42015b9 | ||
|
|
86817a24fc | ||
|
|
86ab49153a | ||
|
|
6e5a4682ee | ||
|
|
f22775a8e1 | ||
|
|
a0aeca967c | ||
|
|
5384abdbaa | ||
|
|
06cdc5a0b8 | ||
|
|
4a32f95848 | ||
|
|
1b450e487e |
+16
-1
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 0.12.0-beta.1 - 2026-10-16
|
||||
## 0.12.0-beta.2 - 2026-10-17
|
||||
|
||||
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and close to 100 fixes.
|
||||
|
||||
@@ -27,8 +27,11 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
|
||||
- Changed phones to open files and subagents in a closable sheet, with Open as tab for subagents ([#6827](https://github.com/getpaseo/paseo/pull/6827), [#6885](https://github.com/getpaseo/paseo/pull/6885), [#6958](https://github.com/getpaseo/paseo/pull/6958))
|
||||
- Changed diff comments on phones to open in a sheet instead of an editor inside the code ([#6455](https://github.com/getpaseo/paseo/pull/6455))
|
||||
- Changed Settings on phones to large tiles grouped into App and Host ([#7129](https://github.com/getpaseo/paseo/pull/7129))
|
||||
- Changed the New workspace form on phones to full-width fields, with Import session in the header ([#7239](https://github.com/getpaseo/paseo/pull/7239))
|
||||
- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Made reopening the sidebar about 45% faster ([#7157](https://github.com/getpaseo/paseo/pull/7157))
|
||||
- Made projects and workspaces load faster after the app reconnects ([#7242](https://github.com/getpaseo/paseo/pull/7242))
|
||||
- Changed messages one agent sends another through the CLI or MCP to steer its running turn instead of interrupting it ([#5164](https://github.com/getpaseo/paseo/pull/5164) by [@derwells](https://github.com/derwells))
|
||||
- Changed Gitea and Forgejo fork PR checkouts to include the fork owner in the branch name, such as `bob/patch-1` ([#6829](https://github.com/getpaseo/paseo/pull/6829) by [@alexanderadam](https://github.com/alexanderadam))
|
||||
|
||||
### Plugins
|
||||
@@ -37,6 +40,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
|
||||
- Fixed `providers.waitForReady` throwing in plugins on the mobile app ([#6760](https://github.com/getpaseo/paseo/pull/6760) by [@GeorgeR](https://github.com/GeorgeR))
|
||||
- Fixed the chat outline not jumping to prompts rendered by a timeline plugin ([#6690](https://github.com/getpaseo/paseo/pull/6690) by [@lukeRRD](https://github.com/lukeRRD))
|
||||
- Fixed Escape stopping the running agent instead of first closing an open plugin preview ([#7110](https://github.com/getpaseo/paseo/pull/7110) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
|
||||
- Fixed `paseo plugin reload` failing with a 60-second timeout while the reload was still running ([#6306](https://github.com/getpaseo/paseo/pull/6306) by [@Mtuchel](https://github.com/Mtuchel))
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -51,6 +55,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
|
||||
- Fixed the Linux desktop window never appearing when the hidden window renders no frames ([#6726](https://github.com/getpaseo/paseo/pull/6726) by [@EhEo](https://github.com/EhEo))
|
||||
- Fixed opening another agent or workspace stalling while a different agent on the same host is streaming ([#7074](https://github.com/getpaseo/paseo/pull/7074) by [@colonelpanic8](https://github.com/colonelpanic8))
|
||||
- Fixed agent creation showing an error and a retry draft when the provider rejected the first prompt ([#6379](https://github.com/getpaseo/paseo/pull/6379))
|
||||
- Fixed question cards spinning forever when an answer fails to send; they now show the error and keep your answers ([#7098](https://github.com/getpaseo/paseo/pull/7098) by [@xzthemeofsss](https://github.com/xzthemeofsss))
|
||||
- Fixed parent agents missing the finish notification when a child finished its first turn very quickly ([#6008](https://github.com/getpaseo/paseo/pull/6008) by [@ahmet-izgi](https://github.com/ahmet-izgi))
|
||||
- Fixed parent agents being told a child finished when its task was stopped or reloaded ([#6660](https://github.com/getpaseo/paseo/pull/6660) by [@Mar-garet](https://github.com/Mar-garet))
|
||||
- Fixed Codex chats containing U+2028 or U+2029 failing to reopen on Node 24 ([#5999](https://github.com/getpaseo/paseo/pull/5999) by [@grahameger](https://github.com/grahameger), [@jtsang4](https://github.com/jtsang4), [@kotusenka](https://github.com/kotusenka))
|
||||
@@ -59,7 +64,12 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
|
||||
- Fixed answering multi-select OpenCode v2 questions failing with "Invalid answer" ([#6149](https://github.com/getpaseo/paseo/pull/6149) by [@gammons](https://github.com/gammons))
|
||||
- Fixed browser screenshots failing the turn on OpenCode v2 agents ([#6330](https://github.com/getpaseo/paseo/pull/6330))
|
||||
- Fixed OpenCode v2 agents receiving "HTTP 500" instead of the Paseo tool error ([#6519](https://github.com/getpaseo/paseo/pull/6519))
|
||||
- Fixed OpenCode agents receiving "HTTP 500" instead of a readable error when they call a Paseo tool with invalid arguments or an unknown name ([#7183](https://github.com/getpaseo/paseo/pull/7183))
|
||||
- Fixed rewinding a Claude conversation failing with "Session not found" when the provider sets `CLAUDE_CONFIG_DIR` ([#6325](https://github.com/getpaseo/paseo/pull/6325))
|
||||
- Fixed changing a running Claude agent's thinking level and then its mode killing its running command ([#7179](https://github.com/getpaseo/paseo/pull/7179))
|
||||
- Fixed Claude models that need a recent Claude Code being hidden when the provider command is a wrapper script ([#7209](https://github.com/getpaseo/paseo/pull/7209) by [@SulthanZahran1](https://github.com/SulthanZahran1))
|
||||
- Fixed background Claude subagent cards staying empty while the subagent works ([#6242](https://github.com/getpaseo/paseo/pull/6242))
|
||||
- Fixed Copilot staying in Plan mode and refusing edits after you select Allow All ([#7234](https://github.com/getpaseo/paseo/pull/7234))
|
||||
- Fixed imported Claude Code sessions taking their first prompt as the name instead of their `/rename` title ([#6930](https://github.com/getpaseo/paseo/pull/6930) by [@platanus-kr](https://github.com/platanus-kr))
|
||||
- Fixed a wrongly shaped answer to a Claude question reporting success and discarding the question ([#6936](https://github.com/getpaseo/paseo/pull/6936) by [@thomasvan](https://github.com/thomasvan))
|
||||
- Fixed Cursor agents showing no plan usage in their context details ([#6353](https://github.com/getpaseo/paseo/pull/6353) by [@gengjiawen](https://github.com/gengjiawen))
|
||||
@@ -85,9 +95,13 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
|
||||
- Fixed assistant file links with spaces or non-ASCII characters failing with ENOENT ([#6520](https://github.com/getpaseo/paseo/pull/6520) by [@apple-ouyang](https://github.com/apple-ouyang), [@sunshinehomeyu-blip](https://github.com/sunshinehomeyu-blip))
|
||||
- Fixed local images in agent replies failing to load when their path contains spaces or non-ASCII characters ([#6877](https://github.com/getpaseo/paseo/pull/6877))
|
||||
- Fixed Markdown file previews not showing images referenced by relative paths ([#6750](https://github.com/getpaseo/paseo/pull/6750) by [@apple-ouyang](https://github.com/apple-ouyang))
|
||||
- Fixed folder links in chats and Markdown previews opening an error tab or doing nothing; they now open Files with the folder's contents visible ([#7205](https://github.com/getpaseo/paseo/pull/7205), [#7210](https://github.com/getpaseo/paseo/pull/7210), [#7229](https://github.com/getpaseo/paseo/pull/7229))
|
||||
- Fixed Markdown links to files such as `[Capture](capture.png)` doing nothing for some file types ([#7233](https://github.com/getpaseo/paseo/pull/7233))
|
||||
- Fixed iOS sidebar rows staying highlighted and swallowing the next tap after a brief hold ([#7159](https://github.com/getpaseo/paseo/pull/7159) by [@kassamkhoja](https://github.com/kassamkhoja))
|
||||
- Fixed Android chats scrolling when you long-press to select text ([#7131](https://github.com/getpaseo/paseo/pull/7131) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
|
||||
- Fixed sidebar rows, footer buttons, and Settings items showing no feedback when tapped on touch devices ([#7150](https://github.com/getpaseo/paseo/pull/7150))
|
||||
- Fixed home screen actions being cut off on short phone screens ([#7167](https://github.com/getpaseo/paseo/pull/7167))
|
||||
- Fixed Android dictation stopping with "Dictation was interrupted by another audio source" when another app plays audio ([#7200](https://github.com/getpaseo/paseo/pull/7200))
|
||||
- Fixed Page Up and Page Down not scrolling the chat after clicking a message ([#7033](https://github.com/getpaseo/paseo/pull/7033) by [@panta82](https://github.com/panta82))
|
||||
- Fixed Add Project keyboard selection moving to directories scrolled out of view ([#7081](https://github.com/getpaseo/paseo/pull/7081) by [@superheher](https://github.com/superheher))
|
||||
- Fixed Enter replacing an email address in the composer with a file mention ([#6471](https://github.com/getpaseo/paseo/pull/6471) by [@jasonhnd](https://github.com/jasonhnd))
|
||||
@@ -108,6 +122,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
|
||||
- Fixed the Pi thinking level not updating when a Pi extension changes it ([#6741](https://github.com/getpaseo/paseo/pull/6741))
|
||||
- Fixed Pi subagents from `@tintinweb/pi-subagents` staying running after their turn limit ([#6594](https://github.com/getpaseo/paseo/pull/6594) by [@Chen-DaYi](https://github.com/Chen-DaYi))
|
||||
- Fixed manual `/compact` on OMP agents showing no progress and leaving the context meter stale ([#6106](https://github.com/getpaseo/paseo/pull/6106))
|
||||
- Fixed follow-ups to idle OMP agents staying queued instead of starting the agent ([#7169](https://github.com/getpaseo/paseo/pull/7169) by [@nhthung](https://github.com/nhthung))
|
||||
- Fixed voice mode TTS rejecting model and voice names from OpenAI-compatible servers ([#6060](https://github.com/getpaseo/paseo/pull/6060) by [@khimaros](https://github.com/khimaros))
|
||||
- Fixed the xhigh thinking option showing "Extra high" instead of its configured label ([#6005](https://github.com/getpaseo/paseo/pull/6005) by [@EliorFureraj](https://github.com/EliorFureraj))
|
||||
- Fixed the Remove button going off screen for long custom model names in provider settings ([#6507](https://github.com/getpaseo/paseo/pull/6507))
|
||||
|
||||
@@ -8,7 +8,7 @@ Native selection belongs to the library's text view. Keep an assistant message i
|
||||
|
||||
Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact.
|
||||
|
||||
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory.
|
||||
Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. An image alone on its line becomes its own paragraph before the library sees it, so it renders as a block on every platform; the library only treats sole-child paragraph images as blocks, and with hard soft breaks every newline is a break node, which left own-line images inline on iOS and web. Images that share a line with text stay inline.
|
||||
|
||||
## Engine constraints
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-1WmrHvE68Lzyil4On7xtsPMQTODXZ3Jb3/juUg3nHi4=
|
||||
sha256-O8sNh6oXFIddUljYiC6RbVZF5/7a7A7W9UWrOd0tIV4=
|
||||
|
||||
Generated
+28
-28
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36349,7 +36349,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37469,12 +37469,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/server": "0.12.0-beta.1",
|
||||
"@getpaseo/client": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@getpaseo/server": "0.12.0-beta.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37721,10 +37721,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/relay": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@getpaseo/relay": "0.12.0-beta.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37735,7 +37735,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37979,7 +37979,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38875,7 +38875,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38904,14 +38904,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/client": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38921,8 +38921,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/client": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38944,7 +38944,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38971,7 +38971,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -39189,16 +39189,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.12.0-beta.1",
|
||||
"@getpaseo/highlight": "0.12.0-beta.1",
|
||||
"@getpaseo/plugin": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/relay": "0.12.0-beta.1",
|
||||
"@getpaseo/client": "0.12.0-beta.2",
|
||||
"@getpaseo/highlight": "0.12.0-beta.2",
|
||||
"@getpaseo/plugin": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@getpaseo/relay": "0.12.0-beta.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -40119,7 +40119,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
@@ -41727,7 +41727,7 @@
|
||||
},
|
||||
"plugins": {
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"dependencies": {
|
||||
"@getpaseo/plugin": "*",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.8)
|
||||
abbrev (0.1.2)
|
||||
addressable (2.9.0)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1296.0)
|
||||
aws-sdk-core (3.257.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
rexml (~> 3.4, >= 3.4.2)
|
||||
aws-sdk-kms (1.132.0)
|
||||
aws-sdk-core (~> 3, >= 3.256.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.233.2)
|
||||
aws-sdk-core (~> 3, >= 3.256.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.3.0)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.1.3)
|
||||
cgi (0.5.2)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
csv (3.3.6)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.7.20261007)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
erb (6.0.7)
|
||||
excon (1.7.3)
|
||||
logger
|
||||
faraday (2.15.0)
|
||||
faraday-net_http (>= 2.0, < 3.6)
|
||||
json
|
||||
logger
|
||||
faraday-cookie_jar (0.0.8)
|
||||
faraday (>= 0.8.0)
|
||||
http-cookie (>= 1.0.0)
|
||||
faraday-follow_redirects (0.5.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-multipart (1.2.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (3.5.0)
|
||||
net-http (~> 0.5)
|
||||
faraday-retry (2.4.0)
|
||||
faraday (~> 2.0)
|
||||
fastimage (2.4.1)
|
||||
fastlane (2.241.1)
|
||||
CFPropertyList (>= 2.3, < 5.0.0)
|
||||
abbrev (~> 0.1)
|
||||
addressable (>= 2.9.0, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.197)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
base64 (~> 0.2)
|
||||
benchmark (>= 0.1.0)
|
||||
bundler (>= 2.4.0, < 5.0.0)
|
||||
cgi (~> 0.4)
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
csv (~> 3.3)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
erb (>= 4.0, < 7.0)
|
||||
excon (>= 1.0.0, < 2.0.0)
|
||||
faraday (~> 2.7)
|
||||
faraday-cookie_jar (~> 0.0.8)
|
||||
faraday-follow_redirects (~> 0.3)
|
||||
faraday-multipart (~> 1.0)
|
||||
faraday-retry (~> 2.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.1.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.99)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.5.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
irb (>= 1.8)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.10.3, < 4)
|
||||
logger (>= 1.6, < 2.0)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multi_json (~> 1.12)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
mutex_m (~> 0.3)
|
||||
naturally (~> 2.2)
|
||||
nkf (~> 0.2)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
ostruct (>= 0.1.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 3.4.0, < 4.0.0)
|
||||
security (~> 0.3)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 3.1.0, < 4.0.0)
|
||||
terminal-table (~> 4)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.4.1)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.109.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-core (1.3.1)
|
||||
addressable (~> 2.9)
|
||||
faraday (~> 2.13)
|
||||
faraday-follow_redirects (~> 0.3)
|
||||
googleauth (~> 1.14)
|
||||
mini_mime (~> 1.1)
|
||||
multi_json (~> 1.11)
|
||||
representable (~> 3.0)
|
||||
retriable (>= 3.1, < 5.0)
|
||||
google-apis-iamcredentials_v1 (0.28.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.18.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-apis-storage_v1 (0.69.0)
|
||||
google-apis-core (>= 0.15.0, < 2.a)
|
||||
google-cloud-core (1.9.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (2.4.1)
|
||||
base64 (~> 0.2)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-errors (1.7.0)
|
||||
google-cloud-storage (1.62.1)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-core (>= 0.18, < 2)
|
||||
google-apis-iamcredentials_v1 (~> 0.18)
|
||||
google-apis-storage_v1 (>= 0.42)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (~> 1.9)
|
||||
mini_mime (~> 1.0)
|
||||
google-logging-utils (0.2.1)
|
||||
googleauth (1.17.5)
|
||||
faraday (>= 1.0, < 3.a)
|
||||
google-cloud-env (~> 2.2)
|
||||
google-logging-utils (~> 0.1)
|
||||
jwt (>= 1.4, < 4.0)
|
||||
os (>= 0.9, < 2.0)
|
||||
pstore (~> 0.1)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.1.6)
|
||||
domain_name (~> 0.5)
|
||||
io-console (0.9.4)
|
||||
irb (1.18.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.21.2)
|
||||
jwt (3.3.0)
|
||||
base64
|
||||
logger (1.7.0)
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.21.2)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.3.0)
|
||||
net-http (0.10.0)
|
||||
net-protocol (>= 0.3.0)
|
||||
uri (>= 0.11.1)
|
||||
net-protocol (0.4.0)
|
||||
timeout
|
||||
nkf (0.3.0)
|
||||
optparse (0.8.1)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.3)
|
||||
plist (3.7.2)
|
||||
pp (0.6.4)
|
||||
prettyprint
|
||||
prettyprint (0.2.1)
|
||||
prism (1.9.0)
|
||||
pstore (0.2.1)
|
||||
public_suffix (7.0.5)
|
||||
rake (13.4.2)
|
||||
rbs (4.2.0)
|
||||
logger
|
||||
prism (>= 1.6.0)
|
||||
tsort
|
||||
rdoc (8.1.0)
|
||||
erb
|
||||
prism (>= 1.6.0)
|
||||
rbs (>= 4.0.0)
|
||||
tsort
|
||||
reline (0.7.0)
|
||||
io-console (~> 0.5)
|
||||
representable (3.3.1)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (4.2.0)
|
||||
rexml (3.4.4)
|
||||
rouge (3.28.0)
|
||||
rubyzip (3.7.0)
|
||||
security (0.3.0)
|
||||
signet (0.22.1)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 4.0)
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
terminal-notifier (3.1.0)
|
||||
terminal-table (4.0.0)
|
||||
unicode-display_width (>= 1.1.1, < 4)
|
||||
timeout (0.6.1)
|
||||
trailblazer-option (0.1.2)
|
||||
tsort (0.2.0)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unicode-display_width (3.3.0)
|
||||
unicode-emoji (~> 4.3)
|
||||
unicode-emoji (4.3.0)
|
||||
uri (1.1.2)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.28.1)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
base64
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.4.0)
|
||||
nkf
|
||||
rexml (>= 3.3.6, < 4.0)
|
||||
xcpretty (0.4.1)
|
||||
rouge (~> 3.28.0)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
x86_64-linux-gnu
|
||||
|
||||
DEPENDENCIES
|
||||
fastlane (~> 2.234)
|
||||
multi_json
|
||||
|
||||
CHECKSUMS
|
||||
CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
|
||||
abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
|
||||
addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
|
||||
artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
|
||||
atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
|
||||
aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
|
||||
aws-partitions (1.1296.0) sha256=70cc7a44fa4824a9df5652b853dc96aaaf09ec20642cd9d42ed7335a36cc3567
|
||||
aws-sdk-core (3.257.0) sha256=a92ad19c7ff668cec6688ee3200ee562da80647a3bbeed9fda02e8b0ca6492c6
|
||||
aws-sdk-kms (1.132.0) sha256=094b0097bb3be9d5c1ac87e971ca7f5aebb746801dfb5f0f576480bf98dca69c
|
||||
aws-sdk-s3 (1.233.2) sha256=b138737a0de8b02f163ae47a9fc184ac98fc9b90926237c7d7af716a14ea1221
|
||||
aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
|
||||
babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
|
||||
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
||||
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
|
||||
bigdecimal (4.1.3) sha256=61ebe1e5e559bdc3cc6f2c0ee7f427321fc838f59611c294356eb04d6e21cf66
|
||||
bundler (4.0.23) sha256=407ba8a1d928f412847f819aa5f8b6aef7c59fb51feb517112af8a165487287b
|
||||
cgi (0.5.2) sha256=61ca30298171190fd4fa0d8018e57ada456eae9b7a2b78526debf7f0a0e6f8bb
|
||||
claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
|
||||
colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
|
||||
colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
|
||||
commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
|
||||
csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
|
||||
declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
|
||||
digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
|
||||
domain_name (0.7.20261007) sha256=0976c6ab837dbde991b433d03acc38fdb91fa8839df31a1ed75ce0318dc0441c
|
||||
dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
|
||||
emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
|
||||
erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
|
||||
excon (1.7.3) sha256=ea13fabcab2b5f5bb64e2952a0ac018bf9882832fe7a58206630be18ba888fa9
|
||||
faraday (2.15.0) sha256=3be0eff0187ed913a1b4f679e9fcfe845b542cdd4f75b76cd6181a4ae1335fff
|
||||
faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
|
||||
faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad
|
||||
faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
|
||||
faraday-net_http (3.5.0) sha256=2e7094c34e38754ee048cc3df47376f8657005d9375f8473bce1c3b526a22e36
|
||||
faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe
|
||||
fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
|
||||
fastlane (2.241.1) sha256=e8e97f3274d678c74d9e473b98d202621262c40dfce2b43b7a05afde7caa2ea5
|
||||
fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847
|
||||
gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
|
||||
google-apis-androidpublisher_v3 (0.109.0) sha256=b4fdbb8af4a0d235e86bd173bd51eccd10effdc5de067ab10c1c0d22048a2a56
|
||||
google-apis-core (1.3.1) sha256=20ce9ee1fde0be87fe30a135dfdbd4b9ee33ba32172794c1f2770e7a45361b2f
|
||||
google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106
|
||||
google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254
|
||||
google-apis-storage_v1 (0.69.0) sha256=ca5faad4a244c609bb434f81884432c449e34b31a87623f7c32b582e464ca7c8
|
||||
google-cloud-core (1.9.0) sha256=ab55409f51488e8deefb6edcc1ce4771dfb5da2fe7b3bc075709a030c2b682a4
|
||||
google-cloud-env (2.4.1) sha256=987cc58afc50ce2903128ab4dcd442159be4457246b8137e41d49d5c3fc590dd
|
||||
google-cloud-errors (1.7.0) sha256=6e682f42d89aae08689f36f28495de629d756da076bafff0003bac7f8042ebb3
|
||||
google-cloud-storage (1.62.1) sha256=10dbf2ebda3af1b4b841335364cb889827c6cb17bf6c9f8bc5313b6bef7a4d3c
|
||||
google-logging-utils (0.2.1) sha256=73945c3ac6800eba802c26b1744765d5057cc4d89182c7255ee1347b0ca96dc7
|
||||
googleauth (1.17.5) sha256=92498efc6883630a7e011ce4e667f887e314e588d584410ba18df49f1303f3d7
|
||||
highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
|
||||
http-cookie (1.1.6) sha256=ba4b82be64de61dc281243dac70e3c382c45142f20268ed9276a3670c93feaa9
|
||||
io-console (0.9.4) sha256=a403304f32928ee85e56d457b4612ca43f2703b561a3258844992bd24085fbb1
|
||||
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
|
||||
jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
|
||||
json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
|
||||
jwt (3.3.0) sha256=44cc34fbc341c148233cf66641522b4bce0dfb1ce55e8073f05c56f44f4ee546
|
||||
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
||||
mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
|
||||
mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
|
||||
multi_json (1.21.2) sha256=245531eaf54bdba57aba515ff08b4503505c5e09911daa89c5a561e2f85bea8e
|
||||
multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
|
||||
mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
|
||||
nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723
|
||||
naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
|
||||
net-http (0.10.0) sha256=1a2ff6e37a05724973c43f2bf7bf105f59c36a4cd0a7e9b1c021a0a566987c27
|
||||
net-protocol (0.4.0) sha256=f9f90c8c6f64794a45160bf422177a336db12809de3beeeaa26b88fcd2e8c853
|
||||
nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895
|
||||
optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
|
||||
os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
|
||||
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
|
||||
plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
|
||||
pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
|
||||
prettyprint (0.2.1) sha256=d555d57ea6d7cf001d4774b693546a6bd88bec1e778ddffb5c60b68ceec4b7d4
|
||||
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
|
||||
pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a
|
||||
public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
|
||||
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
|
||||
rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674
|
||||
rdoc (8.1.0) sha256=befc37880a5cfd36dcb176c748b5fd5ee39dd3536a75e81824e4972ce230f60a
|
||||
reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
|
||||
representable (3.3.1) sha256=8a5fc8eef99f598147eee0390f606386268b429a840419fcfd3f7a8ccc40f566
|
||||
retriable (4.2.0) sha256=90c3b257472a1c02f2a3dfa64dd35a420f7a624cef1a5981e20fdac5730f8cdc
|
||||
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
|
||||
rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
|
||||
rubyzip (3.7.0) sha256=65c19294da75297a939006f3516deacc33185fbd721ff1954f7a231db6d3e121
|
||||
security (0.3.0) sha256=bc9d06718258a96c8552ce4044bfe69144491b10d3403d74c4753a03a720ecf0
|
||||
signet (0.22.1) sha256=6e73e9f2bdc2f038a5fdc8a89d248792b1b55e9ca50ee1ba458d8f1d9f0a2151
|
||||
simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
|
||||
terminal-notifier (3.1.0) sha256=073609e9da7bb60176ffadda903c8d9231fcdf18ffa7074dc9478b63fe25c69c
|
||||
terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2
|
||||
timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb
|
||||
trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
|
||||
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
|
||||
tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
|
||||
tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
|
||||
tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
|
||||
uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
|
||||
unicode-display_width (3.3.0) sha256=4b7aa66a4b11db50f6f7e98411215cd0dd10eaecb665d36eec5bcacb3fa0b613
|
||||
unicode-emoji (4.3.0) sha256=11c02fa73290378c066bb0562cd4c87d8e1b706fbbe0059ca12746a4244de8ce
|
||||
uri (1.1.2) sha256=89292e98eabbef6248b0a0ed5e7e398e152f83f17560023a6d94a7f492b169fb
|
||||
word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
|
||||
xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc
|
||||
xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
|
||||
xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.23
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
} from "../support/helpers/composer";
|
||||
|
||||
import { openCommandCenter, closeCommandCenter } from "../support/helpers/command-center";
|
||||
import {
|
||||
expectPromptsDrawnByPlugin,
|
||||
withPromptTimelinePlugin,
|
||||
} from "../support/helpers/plugin-timeline";
|
||||
|
||||
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";
|
||||
@@ -174,7 +178,7 @@ async function highlightedMessage(page: Page) {
|
||||
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"]',
|
||||
'[data-testid="user-message"], [data-testid="plugin-prompt"], [data-testid="assistant-message"]',
|
||||
);
|
||||
return message?.dataset.testid ?? null;
|
||||
});
|
||||
@@ -219,6 +223,34 @@ test("counts and steps through every match in the chat, not just the selected me
|
||||
}
|
||||
});
|
||||
|
||||
test("finds text in a prompt a timeline plugin draws", async ({ page }, testInfo) => {
|
||||
testInfo.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "chat-find-plugin-prompt-",
|
||||
title: "Chat Find plugin prompt",
|
||||
initialPrompt: ACROSS_PROMPT,
|
||||
featureValues: { mockAssistantResponse: ACROSS_RESPONSE },
|
||||
});
|
||||
try {
|
||||
await agent.client.waitForFinish(agent.agentId, 15_000);
|
||||
await withPromptTimelinePlugin(async () => {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectPromptsDrawnByPlugin(page);
|
||||
await openChatFind(page);
|
||||
await query(page).fill("scope-needle");
|
||||
await expect(status(page)).toHaveText("1 of 3");
|
||||
await expectHighlight(page, "scope-needle");
|
||||
await expectHighlightedMessage(page, "plugin-prompt");
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-find-plugin-prompt.png") });
|
||||
await expectNextMatch(page, "2 of 3", "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,
|
||||
}) => {
|
||||
|
||||
@@ -105,6 +105,37 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("opens explicit screenshot links and explains missing files", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "screenshot-markdown-link-",
|
||||
title: "Screenshot file links",
|
||||
initialPrompt: "Show screenshots",
|
||||
featureValues: {
|
||||
mockAssistantResponse:
|
||||
"[Capture](.dev/tablet-review/capture.png) [Missing capture](.dev/tablet-review/missing.png)",
|
||||
},
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, ".dev/tablet-review"), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, ".dev/tablet-review/capture.png"), RED_PIXEL);
|
||||
await openAgentRoute(page, session);
|
||||
await test.step("Open the linked screenshot", async () => {
|
||||
await openAssistantMarkdownLink(page, "Capture");
|
||||
await expectFileTabOpen(page, ".dev/tablet-review/capture.png");
|
||||
await expect(page.getByTestId("image-file-preview-canvas")).toBeVisible();
|
||||
});
|
||||
await test.step("Explain why the missing screenshot cannot open", async () => {
|
||||
await returnToAssistant(page, session.agentId);
|
||||
await openAssistantMarkdownLink(page, "Missing capture");
|
||||
await expectFileTabOpen(page, ".dev/tablet-review/missing.png");
|
||||
await expect(page.getByText(/ENOENT/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "encoded-markdown-link-",
|
||||
|
||||
@@ -2,6 +2,63 @@ import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
expectFileTabOpen,
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
} from "../support/helpers/file-explorer";
|
||||
import { ensureWorkspaceAgentPaneVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
async function openMarkdownPreview(page: Page) {
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, "README.md");
|
||||
await expectFileTabOpen(page, "README.md");
|
||||
await page
|
||||
.getByTestId("file-panel-bar")
|
||||
.getByRole("button", { name: "Preview", exact: true })
|
||||
.click();
|
||||
await ensureWorkspaceAgentPaneVisible(page);
|
||||
}
|
||||
|
||||
async function followPreviewLink(page: Page, name: string) {
|
||||
await page
|
||||
.getByTestId("workspace-file-pane")
|
||||
.filter({ visible: true })
|
||||
.getByRole("link", { name, exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
test("Markdown preview folder links reveal Files and file links still open a file", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "preview-folder-links-" });
|
||||
await mkdir(path.join(workspace.repoPath, "docs/guides"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "docs/guides/guide.md"),
|
||||
"# Folder link destination\n",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(workspace.repoPath, "README.md"),
|
||||
"[Dot](./docs/guides)\n\n[Bare](docs/guides)\n\n[Guide](docs/guides/guide.md)\n",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
|
||||
await openMarkdownPreview(page);
|
||||
await followPreviewLink(page, "Guide");
|
||||
await expect(page.getByText("Folder link destination", { exact: true })).toBeVisible();
|
||||
|
||||
await openMarkdownPreview(page);
|
||||
await followPreviewLink(page, "Dot");
|
||||
await expectFolderContents(page);
|
||||
await expect(page.getByTestId("workspace-tab-file_docs/guides")).toHaveCount(0);
|
||||
|
||||
await openMarkdownPreview(page);
|
||||
await followPreviewLink(page, "Bare");
|
||||
await expectFolderContents(page);
|
||||
await expect(page.getByTestId("workspace-tab-file_docs/guides")).toHaveCount(0);
|
||||
await page.screenshot({ path: test.info().outputPath("preview-folder-links.png") });
|
||||
});
|
||||
|
||||
async function openReportedFolderLink(page: Page, name: string) {
|
||||
const link = page.getByTestId("assistant-message").getByRole("link", { name, exact: true });
|
||||
@@ -16,13 +73,21 @@ async function expectFolderContents(page: Page) {
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
for (const folder of ["docs/guides", "docs/guides/", "docs/reports.v1", ".github/workflows"]) {
|
||||
for (const folder of [
|
||||
"./docs/guides",
|
||||
"./docs/guides/",
|
||||
"./docs/reports.v1",
|
||||
"./.github/workflows",
|
||||
"docs/guides",
|
||||
"docs/guides/",
|
||||
"docs/指南",
|
||||
]) {
|
||||
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "folder-link-repro-",
|
||||
title: "Folder link reproduction",
|
||||
initialPrompt: "Show the folder link",
|
||||
featureValues: { mockAssistantResponse: `[Open folder](./${folder})` },
|
||||
featureValues: { mockAssistantResponse: `[Open folder](${folder})` },
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, folder), { recursive: true });
|
||||
|
||||
@@ -2,9 +2,7 @@ import { test, expect } from "../support/fixtures";
|
||||
import type { Locator } from "@playwright/test";
|
||||
import {
|
||||
gotoWorkspace,
|
||||
assertNewChatTileVisible,
|
||||
assertNewTabMenuTriggerVisible,
|
||||
assertSingleNewTabButton,
|
||||
openNewTabMenuWithShortcut,
|
||||
clickNewChat,
|
||||
clickNewTerminal,
|
||||
@@ -126,6 +124,7 @@ test.describe("Tab creation", () => {
|
||||
page,
|
||||
}) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await assertNewTabMenuTriggerVisible(page);
|
||||
await pressNewTabShortcut(page);
|
||||
const newTabs = page
|
||||
.locator('[data-testid^="workspace-tab-tab_"]')
|
||||
@@ -190,11 +189,18 @@ test.describe("Tab creation", () => {
|
||||
await expect(agent).toBeFocused();
|
||||
});
|
||||
|
||||
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
||||
test("the Agent menu creates a draft and shows its composer within the visual budget", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await clickNewChat(page);
|
||||
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickNewChat(page),
|
||||
composerLocator(page),
|
||||
10_000,
|
||||
);
|
||||
expect(elapsed).toBeLessThan(3_000);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
@@ -202,12 +208,17 @@ test.describe("Tab creation", () => {
|
||||
expect(draftCountAfter).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("clicking terminal button creates a standalone terminal", async ({ page }) => {
|
||||
test("the Terminal menu creates a terminal within the visual budget", async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await clickNewTerminal(page);
|
||||
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickNewTerminal(page),
|
||||
terminalSurfaceLocator(page),
|
||||
20_000,
|
||||
);
|
||||
expect(elapsed).toBeLessThan(5_000);
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
@@ -295,13 +306,6 @@ test.describe("Tab creation", () => {
|
||||
await profileSeed.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("tab bar shows action buttons per pane", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await assertSingleNewTabButton(page);
|
||||
await assertNewChatTileVisible(page);
|
||||
await assertNewTabMenuTriggerVisible(page);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -339,37 +343,4 @@ test.describe("Tab transitions (no flash)", () => {
|
||||
expect(new Set(snapshots.map(tabIdentityKey)).size).toBeLessThanOrEqual(2);
|
||||
await expectTabTitleFits(page, "New Agent", { min: 64, max: 160 });
|
||||
});
|
||||
|
||||
test("Terminal transition completes within visual budget", async ({ page }) => {
|
||||
test.setTimeout(30_000);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickNewTerminal(page),
|
||||
terminalSurfaceLocator(page),
|
||||
20_000,
|
||||
);
|
||||
|
||||
// Terminal surface should appear within a reasonable budget.
|
||||
// Note: terminal creation involves a server round-trip, so we allow more time
|
||||
// than a pure in-memory transition, but it should still be well under 5 seconds.
|
||||
expect(elapsed).toBeLessThan(5_000);
|
||||
});
|
||||
|
||||
test("New agent tab click shows composer without flash", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickNewChat(page),
|
||||
composerLocator(page),
|
||||
10_000,
|
||||
);
|
||||
|
||||
// Draft creation is fully in-memory — should be fast
|
||||
// We use a generous budget here because CI can be slow, but the key assertion
|
||||
// is that no blank/flash frame appears (tested above).
|
||||
expect(elapsed).toBeLessThan(3_000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -188,3 +188,74 @@ async function measureInlineCode(message: Locator) {
|
||||
return { codes, right: paragraph.getBoundingClientRect().right };
|
||||
});
|
||||
}
|
||||
|
||||
test("inline-code paths preserve stable recognition and file navigation", async ({
|
||||
page,
|
||||
context,
|
||||
}, testInfo) => {
|
||||
const { writeFile, mkdir } = await import("node:fs/promises");
|
||||
const paths = ["src/app/[id].tsx", "src/app/(tabs)/index.tsx", "src/a$b.ts"];
|
||||
const source =
|
||||
paths.map((path) => `Open \`${path}\`.`).join("\n\n") +
|
||||
"\n\nOpen `.gitignore` and `src/index.ts#L10-L20`. Compare before/after and `before/after`. Keep `word.word` plain.";
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "inline-path-regression-",
|
||||
title: "Inline path regression",
|
||||
initialPrompt: "Show file paths",
|
||||
featureValues: { mockAssistantResponse: source },
|
||||
});
|
||||
try {
|
||||
await mkdir(`${session.cwd}/src/app/(tabs)`, { recursive: true });
|
||||
for (const path of paths) await writeFile(`${session.cwd}/${path}`, `Opened ${path}`);
|
||||
await writeFile(
|
||||
`${session.cwd}/src/index.ts`,
|
||||
Array.from({ length: 30 }, (_, i) => `export const line${i + 1} = ${i + 1};`).join("\n"),
|
||||
);
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await openAgentRoute(page, session);
|
||||
const message = page.getByTestId("assistant-message");
|
||||
await expect(message.getByRole("link", { name: ".gitignore", exact: true })).toBeVisible();
|
||||
await expect(message.getByRole("link", { name: "before/after", exact: true })).toHaveCount(0);
|
||||
await expect(message.getByRole("link", { name: "word.word", exact: true })).toHaveCount(0);
|
||||
for (const path of paths) {
|
||||
await openInlineRegressionFile(page, path);
|
||||
await expect(page.getByTestId("file-source-editor").filter({ visible: true })).toContainText(
|
||||
`Opened ${path}`,
|
||||
);
|
||||
}
|
||||
await openInlineRegressionFile(page, "src/index.ts#L10-L20");
|
||||
await expect(page.getByLabel("Line 20, column 26")).toBeVisible();
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await page
|
||||
.getByTestId("file-source-editor")
|
||||
.filter({ visible: true })
|
||||
.locator(".cm-content")
|
||||
.focus();
|
||||
await page.keyboard.press("ControlOrMeta+c");
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
|
||||
.toBe(
|
||||
Array.from({ length: 11 }, (_, i) => `export const line${i + 10} = ${i + 10};`).join("\n"),
|
||||
);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("file-source-editor")
|
||||
.filter({ visible: true })
|
||||
.locator(".cm-selectionBackground"),
|
||||
).not.toHaveCount(0);
|
||||
await testInfo.attach("inline-path-line-selection", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function openInlineRegressionFile(page: Page, path: string) {
|
||||
await page.getByRole("button", { name: "Inline path regression", exact: true }).click();
|
||||
await page
|
||||
.getByTestId("assistant-message")
|
||||
.getByRole("link", { name: path, exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
@@ -223,7 +223,10 @@ test.describe("Projects settings", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
|
||||
test("edits worktree setup, navigates within the host, and removes a script", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
@@ -236,15 +239,22 @@ test.describe("Projects settings", () => {
|
||||
await returnToProjectsList(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
});
|
||||
|
||||
test("project navigation stays inside the selected host", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectProjectHostContextHidden(page);
|
||||
await returnToProjectsList(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectProjectSettingsHistoryRoundTrip(page, editableProject.name);
|
||||
await test.step("navigation stays inside the selected host", async () => {
|
||||
await expectProjectHostContextHidden(page);
|
||||
await returnToProjectsList(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await expectProjectSettingsHistoryRoundTrip(page, editableProject.name);
|
||||
});
|
||||
|
||||
await test.step("removing a script leaves the committed setup unchanged", async () => {
|
||||
await expectScriptRowCount(page, 1);
|
||||
await removeProjectScript(page, "dev");
|
||||
await expectScriptRowCount(page, 0);
|
||||
await expectEmptyScriptList(page);
|
||||
await clickSaveProjectSettings(page);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("user edits worktree setup on a non-GitHub remote project", async ({
|
||||
@@ -259,18 +269,7 @@ test.describe("Projects settings", () => {
|
||||
await expectProjectConfigSaved(gitlabRemoteProject);
|
||||
});
|
||||
|
||||
test("user renames a project from the edit sheet", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await openProjectEditSheet(page);
|
||||
await fillProjectName(page, "Renamed project");
|
||||
await saveProjectEdits(page);
|
||||
|
||||
await expectProjectEditSaved(page);
|
||||
await expectProjectTitle(page, "Renamed project");
|
||||
});
|
||||
|
||||
test("reopening the edit sheet seeds from the saved project", async ({
|
||||
test("renames a project, reopens the saved edit sheet, and adds an icon", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
@@ -279,26 +278,26 @@ test.describe("Projects settings", () => {
|
||||
await openProjectEditSheet(page);
|
||||
await fillProjectName(page, "Renamed project");
|
||||
await saveProjectEdits(page);
|
||||
|
||||
await expectProjectEditSaved(page);
|
||||
await expectProjectTitle(page, "Renamed project");
|
||||
|
||||
await openProjectEditSheet(page);
|
||||
|
||||
await expectProjectEditName(page, "Renamed project");
|
||||
await expectProjectEditsSaveDisabled(page);
|
||||
});
|
||||
|
||||
test("user picks a custom project icon from a file", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await openProjectEditSheet(page);
|
||||
await chooseProjectIconImage(page, {
|
||||
name: "logo.png",
|
||||
mimeType: "image/png",
|
||||
buffer: PNG_1X1,
|
||||
await test.step("reopening seeds the saved name with no unsaved changes", async () => {
|
||||
await openProjectEditSheet(page);
|
||||
await expectProjectEditName(page, "Renamed project");
|
||||
await expectProjectEditsSaveDisabled(page);
|
||||
});
|
||||
await saveProjectEdits(page);
|
||||
|
||||
await expectProjectEditSaved(page);
|
||||
await test.step("an icon can be saved without another name change", async () => {
|
||||
await chooseProjectIconImage(page, {
|
||||
name: "logo.png",
|
||||
mimeType: "image/png",
|
||||
buffer: PNG_1X1,
|
||||
});
|
||||
await saveProjectEdits(page);
|
||||
await expectProjectEditSaved(page);
|
||||
await expectProjectTitle(page, "Renamed project");
|
||||
});
|
||||
});
|
||||
|
||||
test("user sets a project name and icon in one save", async ({ page, editableProject }) => {
|
||||
@@ -459,31 +458,4 @@ test.describe("Projects settings — error UX", () => {
|
||||
|
||||
await expectNoEditableTarget(page);
|
||||
});
|
||||
|
||||
test("project detail does not render a second host selector", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
await expectProjectHostContextHidden(page);
|
||||
});
|
||||
|
||||
test("script removal via kebab menu removes the row from the form", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
await expectScriptRowCount(page, 1);
|
||||
|
||||
await removeProjectScript(page, "dev");
|
||||
|
||||
await expectScriptRowCount(page, 0);
|
||||
await expectEmptyScriptList(page);
|
||||
await clickSaveProjectSettings(page);
|
||||
await expectNoUncommittedSetupWarning(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,28 +116,12 @@ async function exerciseProviderSettingsStack(page: Page) {
|
||||
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await expectProviderSettingsVisible(page);
|
||||
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("provider settings overlay stack", () => {
|
||||
test("provider model update age says just now", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "provider-relative-time-",
|
||||
title: "Provider relative time",
|
||||
});
|
||||
try {
|
||||
await page.clock.install({ time: Date.now() });
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await openProviderSettingsFromModelSelector(page);
|
||||
await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("provider settings covers the desktop model selector without closing it", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -504,6 +504,17 @@ test.describe("Mobile sidebar panelState transition", () => {
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
});
|
||||
|
||||
test("places the close button beside the first nav row instead of over it", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
|
||||
const firstRow = await page.getByTestId("sidebar-global-new-workspace").boundingBox();
|
||||
const closeButton = await page.getByTestId("sidebar-close").boundingBox();
|
||||
if (!firstRow || !closeButton) throw new Error("first nav row and close button must render");
|
||||
expect(closeButton.x).toBeGreaterThanOrEqual(firstRow.x + firstRow.width);
|
||||
});
|
||||
|
||||
test("keeps a pinned workspace rendered while the retained sidebar is closed", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { clickNewChat, clickNewTerminal, gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectTerminalSurfaceVisible,
|
||||
focusTerminalSurface,
|
||||
typeInTerminal,
|
||||
setupDeterministicPrompt,
|
||||
waitForTerminalContent,
|
||||
} from "../support/helpers/terminal-perf";
|
||||
|
||||
interface CreatedAgentCwdAssertion {
|
||||
workspaceId: string;
|
||||
cwd: string | null;
|
||||
}
|
||||
|
||||
async function fetchSingleAgentForWorkspace(
|
||||
workspace: SeededWorkspace,
|
||||
): Promise<CreatedAgentCwdAssertion | null> {
|
||||
const agents = (await workspace.client.fetchAgents({ scope: "active" })).entries
|
||||
.map((entry) => entry.agent)
|
||||
.filter((agent) => agent.workspaceId === workspace.workspaceId);
|
||||
if (agents.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
const [agent] = agents;
|
||||
return {
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: agent.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("Workspace cwd correctness", () => {
|
||||
test("main checkout workspace opens terminals in the project root", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const workspace = await withWorkspace({ prefix: "workspace-cwd-main-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewTerminal(page);
|
||||
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await focusTerminalSurface(page);
|
||||
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
||||
await typeInTerminal(page, "pwd\n");
|
||||
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
|
||||
});
|
||||
|
||||
test("draft tab creates an agent in the workspace cwd", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-cwd-draft-agent-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
await clickNewChat(page);
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
const message = `cwd draft create ${Date.now()}`;
|
||||
await expect(composer).toBeEditable({ timeout: 15_000 });
|
||||
await composer.fill(message);
|
||||
await composer.press("Enter");
|
||||
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => fetchSingleAgentForWorkspace(workspace), { timeout: 30_000 })
|
||||
.toEqual({
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: workspace.repoPath,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("worktree workspace opens terminals in the worktree directory", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const workspace = await withWorkspace({ worktree: true, prefix: "workspace-cwd-worktree-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewTerminal(page);
|
||||
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await focusTerminalSurface(page);
|
||||
await setupDeterministicPrompt(page, `PWD_READY_${Date.now()}`);
|
||||
await typeInTerminal(page, "pwd\n");
|
||||
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createAgentChatFromLauncher,
|
||||
createStandaloneTerminalFromLauncher,
|
||||
expectTerminalCwd,
|
||||
submitAgentChatAndExpectCwd,
|
||||
} from "../support/helpers/workspace-lifecycle";
|
||||
|
||||
test.describe("Workspace lifecycle", () => {
|
||||
@@ -19,8 +20,9 @@ test.describe("Workspace lifecycle", () => {
|
||||
const workspace = await withWorkspace({ prefix: "lifecycle-main-" });
|
||||
await workspace.navigateTo();
|
||||
|
||||
await test.step("creates an agent chat", async () => {
|
||||
await test.step("submitting a draft creates an agent in the project root", async () => {
|
||||
await createAgentChatFromLauncher(page);
|
||||
await submitAgentChatAndExpectCwd(page, workspace);
|
||||
});
|
||||
|
||||
await test.step("creates a terminal in the project root", async () => {
|
||||
|
||||
@@ -80,13 +80,6 @@ export async function pressDirectNewTabShortcut(page: Page, key: string): Promis
|
||||
|
||||
// ─── Tab bar assertions ───────────────────────────────────────────────────
|
||||
|
||||
/** Assert the inline plus button is visible in the tab bar. */
|
||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
/** Assert the New tab button is visible in the tab bar. */
|
||||
export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void> {
|
||||
await expect(
|
||||
@@ -132,13 +125,6 @@ export async function waitForTabWithTitle(
|
||||
).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
/** Assert the inline plus button is visible in the tab bar. */
|
||||
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
||||
const buttons = page.getByTestId("workspace-new-tab-button").filter({ visible: true });
|
||||
const count = await buttons.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
// ─── No-flash measurement ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { clickNewChat, clickNewTerminal } from "./launcher";
|
||||
import { connectSeedClient } from "./seed-client";
|
||||
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
|
||||
|
||||
function terminalSurface(page: Page) {
|
||||
@@ -30,3 +31,35 @@ export async function createAgentChatFromLauncher(page: Page): Promise<void> {
|
||||
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
|
||||
await expect(page.getByTestId("agent-loading")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function submitAgentChatAndExpectCwd(
|
||||
page: Page,
|
||||
workspace: { workspaceId: string; repoPath: string },
|
||||
): Promise<void> {
|
||||
const client = await connectSeedClient();
|
||||
try {
|
||||
const message = "cwd draft create";
|
||||
await composerInput(page).fill(message);
|
||||
await composerInput(page).press("Enter");
|
||||
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const agents = await client.fetchAgents({ scope: "active" });
|
||||
return agents.entries
|
||||
.map((entry) => entry.agent)
|
||||
.filter((agent) => agent.workspaceId === workspace.workspaceId)
|
||||
.map((agent) => ({ workspaceId: agent.workspaceId, cwd: agent.cwd }));
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toEqual([{ workspaceId: workspace.workspaceId, cwd: workspace.repoPath }]);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -76,3 +76,49 @@ it("keeps semantic Markdown paragraphs separate while finding inline matches", (
|
||||
]);
|
||||
expect(findRenderedMatches(row, ".another")).toEqual([]);
|
||||
});
|
||||
|
||||
function pluginRow(html: string) {
|
||||
const row = document.createElement("div");
|
||||
row.dataset.pluginRow = "true";
|
||||
row.dataset.messageId = "message-1";
|
||||
row.innerHTML = html;
|
||||
document.body.append(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
it("searches a plugin row that sets no text marker, bounding matches by view", () => {
|
||||
const row = pluginRow(
|
||||
"<div><div>Prompt</div><div>find <span>this</span> needle</div><div>needle</div><button>needle</button></div>",
|
||||
);
|
||||
expect(findRenderedMatches(row, "needle").map((range) => range.toString())).toEqual([
|
||||
"needle",
|
||||
"needle",
|
||||
]);
|
||||
expect(findRenderedMatches(row, "Promptfind")).toEqual([]);
|
||||
expect(findRenderedMatches(row, "find this needle")).toHaveLength(1);
|
||||
expect(findMessageMatches(row.parentElement, "message-1", "needle")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("leaves rows without the plugin marker limited to marked message text", () => {
|
||||
const row = document.createElement("div");
|
||||
row.innerHTML = "<div>needle</div>";
|
||||
document.body.append(row);
|
||||
expect(findRenderedMatches(row, "needle")).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts only drawn text in a plugin row, including an opened popover", () => {
|
||||
const row = pluginRow(
|
||||
'<div><span>visible needle</span><div hidden>hidden needle</div><div style="display:none">display needle</div><span style="visibility:hidden">visibility needle</span><span style="opacity:0">transparent needle</span><div popover="manual">popover needle</div></div>',
|
||||
);
|
||||
expect(findRenderedMatches(row, "needle").map((range) => range.toString())).toEqual(["needle"]);
|
||||
row.querySelector<HTMLElement>("[popover]")!.showPopover();
|
||||
expect(findRenderedMatches(row, "needle")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps text around nested plugin blocks in separate occurrences", () => {
|
||||
const row = pluginRow("<div>alpha <div>details</div>beta</div><div>gamma <div></div>delta</div>");
|
||||
expect(findRenderedMatches(row, "alpha beta")).toEqual([]);
|
||||
expect(findRenderedMatches(row, "gamma delta")).toEqual([]);
|
||||
expect(findRenderedMatches(row, "details").map((range) => range.toString())).toEqual(["details"]);
|
||||
expect(findRenderedMatches(row, "beta").map((range) => range.toString())).toEqual(["beta"]);
|
||||
});
|
||||
|
||||
@@ -21,28 +21,55 @@ export function findMessageMatches(
|
||||
return findMessageRows(root, messageId).flatMap((row) => findRenderedMatches(row, query));
|
||||
}
|
||||
|
||||
function renderedTextGroups(row: HTMLElement): Text[][] {
|
||||
const groups: Text[][] = [];
|
||||
// A plugin renderer owns the DOM of the message it replaced and sets no markers, so the
|
||||
// whole row is searched and each div (a React Native View or Text) bounds a match.
|
||||
const pluginRow = row.dataset.pluginRow === "true";
|
||||
const blocks = pluginRow ? `${BLOCKS},div` : BLOCKS;
|
||||
const contents = pluginRow
|
||||
? [row]
|
||||
: row.querySelectorAll<HTMLElement>('[data-message-text="true"]');
|
||||
for (const content of contents) {
|
||||
let group: { block: Element; texts: Text[] } | undefined;
|
||||
const walker = document.createTreeWalker(
|
||||
content,
|
||||
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
|
||||
);
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node instanceof Element) {
|
||||
// Even an empty nested block separates the text before and after it.
|
||||
if (node.matches(blocks)) group = undefined;
|
||||
continue;
|
||||
}
|
||||
if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue;
|
||||
if (
|
||||
pluginRow &&
|
||||
!node.parentElement?.checkVisibility({ opacityProperty: true, visibilityProperty: true })
|
||||
)
|
||||
continue;
|
||||
const block = node.parentElement?.closest(blocks) ?? content;
|
||||
if (group?.block !== block) {
|
||||
group = { block, texts: [] };
|
||||
groups.push(group.texts);
|
||||
}
|
||||
group.texts.push(node);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** 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 [];
|
||||
const groups = new Map<Element, Text[]>();
|
||||
for (const content of row.querySelectorAll<HTMLElement>('[data-message-text="true"]')) {
|
||||
const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT);
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue;
|
||||
const block = node.parentElement?.closest(BLOCKS) ?? content;
|
||||
const texts = groups.get(block) ?? [];
|
||||
texts.push(node);
|
||||
groups.set(block, texts);
|
||||
}
|
||||
}
|
||||
const pattern = query
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join("\\s+");
|
||||
const ranges: Range[] = [];
|
||||
for (const texts of groups.values()) {
|
||||
for (const texts of renderedTextGroups(row)) {
|
||||
const points = texts.map((node) => ({ node, start: 0, end: 0 }));
|
||||
let text = "";
|
||||
for (const point of points) {
|
||||
|
||||
@@ -1250,6 +1250,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
key={item.id}
|
||||
data-history-row-id={item.id}
|
||||
data-message-id={getStreamItemMessageId(item)}
|
||||
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
|
||||
style={streamRowStyle}
|
||||
>
|
||||
<StreamRowContent
|
||||
@@ -1267,6 +1268,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
key={item.id}
|
||||
data-history-row-id={item.id}
|
||||
data-message-id={getStreamItemMessageId(item)}
|
||||
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
|
||||
style={streamRowStyle}
|
||||
>
|
||||
<StreamRowContent
|
||||
@@ -1329,6 +1331,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
data-index={virtualRow.index}
|
||||
data-history-row-id={item.id}
|
||||
data-message-id={getStreamItemMessageId(item)}
|
||||
data-plugin-row={item.kind === "plugin" ? "true" : undefined}
|
||||
ref={measureVirtualizedRowElement}
|
||||
style={renderVirtualRowStyle(virtualRow.start)}
|
||||
>
|
||||
|
||||
@@ -23,6 +23,8 @@ const INLINE_PAREN_LINE_SUFFIX = /^(.+?)\(([0-9]+)(?:,[0-9]+)?(?:-([0-9]+)(?:,[0
|
||||
const INLINE_WORD_LINE_SUFFIX = /^(.+?)\s+lines?\s+([0-9]+)(?:-([0-9]+))?$/i;
|
||||
export interface AssistantHrefParseOptions {
|
||||
workspaceRoot?: string;
|
||||
/** Authored links identify a file; only inferred text needs filename heuristics. */
|
||||
explicitLink?: boolean;
|
||||
/**
|
||||
* True only when `value` is a markdown href — the renderer percent-encodes
|
||||
* non-ASCII and space characters, so the path must be decoded. Raw path
|
||||
@@ -234,7 +236,7 @@ export function classifyAssistantFileLink(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
|
||||
if (!options.explicitLink && isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
|
||||
return {
|
||||
kind: "ambiguousFileCandidate",
|
||||
target,
|
||||
@@ -278,6 +280,7 @@ export function parseAssistantFileLink(
|
||||
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
decodeHref: options.decodeHref,
|
||||
explicitLink: options.explicitLink,
|
||||
});
|
||||
if (relativeTarget) {
|
||||
return relativeTarget;
|
||||
@@ -411,7 +414,7 @@ function parseWorkspaceRelativeFileLink(
|
||||
|
||||
function parseLocalPathParts(
|
||||
value: string,
|
||||
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
|
||||
options: Pick<AssistantHrefParseOptions, "decodeHref" | "explicitLink"> = {},
|
||||
): {
|
||||
path: string;
|
||||
lines: Pick<InlinePathTarget, "lineStart" | "lineEnd">;
|
||||
@@ -437,7 +440,7 @@ function parseLocalPathParts(
|
||||
if (options.decodeHref) {
|
||||
pathPart = safeDecodeURIComponent(pathPart);
|
||||
}
|
||||
if (!isPlausibleAssistantLocalPath(pathPart)) {
|
||||
if (!options.explicitLink && !isPlausibleAssistantLocalPath(pathPart)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
@@ -458,7 +461,7 @@ function parseLocalPathParts(
|
||||
decodedBeforeHash = safeDecodeURIComponent(beforeHash);
|
||||
}
|
||||
const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash;
|
||||
if (!isPlausibleAssistantLocalPath(plausiblePath)) {
|
||||
if (!options.explicitLink && !isPlausibleAssistantLocalPath(plausiblePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,63 @@ const CONTEXT: AssistantFileLinkContext = {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
};
|
||||
|
||||
it.each(["docs/guides", "docs/guides/", "docs/%E6%8C%87%E5%8D%97"])(
|
||||
"resolves an explicit extensionless folder href: %s",
|
||||
(href) => {
|
||||
expect(classifyForResolution({ href }, CONTEXT)).toMatchObject({
|
||||
kind: "resolved",
|
||||
value: {
|
||||
kind: "file",
|
||||
target: { path: `${CONTEXT.workspaceRoot}/${decodeURIComponent(href).replace(/\/$/, "")}` },
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["docs/guides", "origin/main"])("leaves extensionless inline code alone: %s", (text) => {
|
||||
expect(classifyForResolution({ href: text, text, sourceType: "inline-code" }, CONTEXT)).toEqual({
|
||||
kind: "resolved",
|
||||
value: { kind: "ignored" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([{ markup: "linkify" }, { sourceInfo: "auto" }])(
|
||||
"keeps auto-recognized extensionless text ignored: %j",
|
||||
(origin) => {
|
||||
expect(
|
||||
classifyForResolution({ href: "docs/guides", text: "docs/guides", ...origin }, CONTEXT),
|
||||
).toEqual({
|
||||
kind: "resolved",
|
||||
value: { kind: "ignored" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["docs/guides?view=all"])(
|
||||
"does not turn URL-like hrefs into workspace paths: %s",
|
||||
(href) => {
|
||||
expect(classifyForResolution({ href }, CONTEXT)).toEqual({
|
||||
kind: "resolved",
|
||||
value: { kind: "ignored" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
".dev/tablet-review/tablet-agent-draft.png",
|
||||
"images/capture.jpg",
|
||||
"downloads/archive.custom",
|
||||
"assets/LICENSE",
|
||||
"example.com/guides",
|
||||
"capture.png",
|
||||
"missing.png",
|
||||
])("opens explicit file link %s without filename guessing", (href) => {
|
||||
expect(classifyForResolution({ href }, CONTEXT)).toMatchObject({
|
||||
kind: "resolved",
|
||||
value: { kind: "file", target: { path: `${CONTEXT.workspaceRoot}/${href}` } },
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the existing non-ASCII file named by a rendered markdown link", async () => {
|
||||
const workspaceRoot = await mkdtemp(path.join(tmpdir(), "assistant-file-link-"));
|
||||
try {
|
||||
|
||||
@@ -117,6 +117,7 @@ export function classifyForResolution(
|
||||
const classification = classifyAssistantFileLink(token, {
|
||||
workspaceRoot: context.workspaceRoot,
|
||||
decodeHref: tokenInfo.fromHref,
|
||||
explicitLink: source.sourceType !== "inline-code" && !isLinkifiedSource(source),
|
||||
});
|
||||
if (!classification) {
|
||||
return { kind: "resolved", value: { kind: "ignored" } };
|
||||
|
||||
@@ -21,8 +21,14 @@ import { Gesture } from "react-native-gesture-handler";
|
||||
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||
import {
|
||||
IconButtonChromeProvider,
|
||||
iconButtonChromeGlyphSize,
|
||||
mutedIconColorMapping,
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
|
||||
import {
|
||||
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
||||
@@ -62,6 +68,7 @@ import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { openHostOverview } from "@/navigation/settings-navigation";
|
||||
import {
|
||||
@@ -74,6 +81,8 @@ import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
|
||||
const ThemedX = withUnistyles(X);
|
||||
|
||||
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
||||
|
||||
const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null;
|
||||
@@ -545,6 +554,11 @@ function MobileSidebar({
|
||||
closeSidebar();
|
||||
}, [closeSidebar]);
|
||||
|
||||
const closeButton = useMemo(
|
||||
() => <SidebarCloseButton label={labels.closeSidebar} onPress={closeSidebar} />,
|
||||
[labels.closeSidebar, closeSidebar],
|
||||
);
|
||||
|
||||
const mobileSidebarInsetStyle = useMemo(
|
||||
() => ({
|
||||
paddingTop: insetsTop,
|
||||
@@ -562,30 +576,11 @@ function MobileSidebar({
|
||||
>
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<WindowChromeSafeArea placement="below" />
|
||||
<SidebarNavRows style={styles.sidebarHeaderGroup} onBeforeNavigate={closeSidebar} />
|
||||
<WindowChromeSafeArea
|
||||
placement="inline"
|
||||
pointerEvents="box-none"
|
||||
style={styles.mobileCloseButtonRow}
|
||||
>
|
||||
<Pressable
|
||||
style={styles.mobileCloseButton}
|
||||
onPress={closeSidebar}
|
||||
testID="sidebar-close"
|
||||
nativeID="sidebar-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={labels.closeSidebar}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<X
|
||||
size={theme.iconSize.md}
|
||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</WindowChromeSafeArea>
|
||||
<SidebarNavRows
|
||||
style={styles.sidebarHeaderGroup}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
firstRowTrailing={closeButton}
|
||||
/>
|
||||
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
@@ -626,6 +621,34 @@ function MobileSidebar({
|
||||
);
|
||||
}
|
||||
|
||||
const NO_SHORTCUT: ShortcutKey[] = [];
|
||||
|
||||
/** The compact header's icon button, so the sidebar's close reads like the header it covers. */
|
||||
function SidebarCloseButton({ label, onPress }: { label: string; onPress: () => void }) {
|
||||
return (
|
||||
<IconButtonChromeProvider value="header-island">
|
||||
<HeaderToggleButton
|
||||
onPress={onPress}
|
||||
tooltipLabel={label}
|
||||
tooltipKeys={NO_SHORTCUT}
|
||||
tooltipSide="bottom"
|
||||
style={styles.mobileCloseButton}
|
||||
testID="sidebar-close"
|
||||
nativeID="sidebar-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
>
|
||||
<ThemedX
|
||||
size={iconButtonChromeGlyphSize("large")}
|
||||
strokeWidth={1.5}
|
||||
uniProps={mutedIconColorMapping}
|
||||
/>
|
||||
</HeaderToggleButton>
|
||||
</IconButtonChromeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopSidebar({
|
||||
theme,
|
||||
workspaceGroups,
|
||||
@@ -856,24 +879,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
mobileCloseButtonRow: {
|
||||
position: "absolute",
|
||||
top: theme.spacing[3],
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 2,
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
mobileCloseButton: {
|
||||
// The 16px X paints farther inside its 32px hit target than the 14px Settings2 glyph.
|
||||
// This optical inset puts their painted right edges on the same sidebar rail.
|
||||
marginRight: theme.spacing[2] + 1.5,
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
marginRight: theme.spacing[2],
|
||||
},
|
||||
desktopSidebarBorder: {
|
||||
borderRightWidth: 1,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser";
|
||||
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
|
||||
import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks";
|
||||
import { classifyAssistantFileLink } from "@/assistant-file-links";
|
||||
import { markInlineImages } from "react-native-enriched-markdown/src/web/utils";
|
||||
import type { ASTNode } from "react-native-enriched-markdown/src/web/types";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { decodeMarkdownImage, getMarkdownImageStatus } from "./image-decoding";
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -31,6 +36,56 @@ describe("shared Markdown source adaptation", () => {
|
||||
"> - \n\n[picture]: ../a.png",
|
||||
);
|
||||
});
|
||||
it("gives an image alone on its line its own paragraph", () => {
|
||||
expect(
|
||||
prepareMarkdownSource("Before the shot:\n\nAfter it.").render(),
|
||||
).toBe("Before the shot:\n\n\n\nAfter it.");
|
||||
expect(prepareMarkdownSource("\n").render()).toBe(
|
||||
"\n\n",
|
||||
);
|
||||
expect(
|
||||
prepareMarkdownSource(
|
||||
"Line one\\\n[](https://x)\nLine three",
|
||||
).render(),
|
||||
).toBe("Line one\n\n[](https://x)\n\nLine three");
|
||||
});
|
||||
it("keeps images that share a line with text inline", () => {
|
||||
const source = "Icon  in text\n caption\nNext line";
|
||||
expect(prepareMarkdownSource(source).render()).toBe(source);
|
||||
});
|
||||
it("splits own-line images inside quotes and list items without ending the container", () => {
|
||||
expect(prepareMarkdownSource("> Quote text\n> \n> More").render()).toBe(
|
||||
"> Quote text\n>\n> \n>\n> More",
|
||||
);
|
||||
expect(
|
||||
prepareMarkdownSource("- Item text\n \n More\n- Next").render(),
|
||||
).toBe("- Item text\n\n \n\n More\n- Next");
|
||||
expect(prepareMarkdownSource("- \n Caption line").render()).toBe(
|
||||
"- \n\n Caption line",
|
||||
);
|
||||
});
|
||||
it("renders own-line images as blocks through the library parser", async () => {
|
||||
const source =
|
||||
"Before:\n\nIcon  in text\n> Quoted\n> ";
|
||||
const tree = await parseMarkdown(prepareMarkdownSource(source).render(), {
|
||||
hardSoftBreaks: true,
|
||||
latexMath: false,
|
||||
});
|
||||
markInlineImages(tree);
|
||||
const images: ASTNode[] = [];
|
||||
const collect = (node: ASTNode) => {
|
||||
if (node.type === "Image") images.push(node);
|
||||
node.children?.forEach(collect);
|
||||
};
|
||||
collect(tree);
|
||||
expect(
|
||||
images.map((image) => [image.attributes?.url, image.attributes?.isInline === true]),
|
||||
).toEqual([
|
||||
["https://x/a.png", false],
|
||||
["https://x/i.png", true],
|
||||
["https://x/q.png", false],
|
||||
]);
|
||||
});
|
||||
it("escapes spoiler delimiters only in prose", () => {
|
||||
expect(prepareMarkdownSource("Show ||words|| and `||code||`.").render()).toBe(
|
||||
"Show \\|\\|words\\|\\| and `||code||`.",
|
||||
@@ -59,7 +114,7 @@ it.each([
|
||||
"src/file.ts:10-20",
|
||||
"src/file.ts(10,2)",
|
||||
])("recognizes inline paths through the file resolver: %s", (path) => {
|
||||
const prepared = prepareMarkdownSource(`Open \`${path}\`.`);
|
||||
const prepared = prepareMarkdownSource(`Open \`${path}\`.`, "/repo");
|
||||
expect(prepared.linkRecognition.inlineCode?.test(path)).toBe(true);
|
||||
expect(prepared.linkSource(path, "inline-code")).toEqual({
|
||||
href: path,
|
||||
@@ -68,6 +123,106 @@ it.each([
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves stable inline-code file classification through the library parser", async () => {
|
||||
const paths = [
|
||||
"src/app/[id].tsx",
|
||||
"src/app/(tabs)/index.tsx",
|
||||
"src/a$b.ts",
|
||||
".gitignore",
|
||||
"src/index.ts#L10-L20",
|
||||
"src/index.ts:10-20",
|
||||
"src/index.ts:20-10",
|
||||
"src/index.ts#L0",
|
||||
"src/index.ts?raw",
|
||||
"before/after",
|
||||
"word.word",
|
||||
"README.md",
|
||||
"./scripts/run",
|
||||
"src/file.ts(10,2)",
|
||||
"src/file.ts lines 10-20",
|
||||
"/tmp/[route].tsx",
|
||||
"file:///tmp/file.ts#L10-L20",
|
||||
];
|
||||
const source = paths.map((path) => `\`${path}\``).join(" and ");
|
||||
const prepared = prepareMarkdownSource(source, "/repo");
|
||||
const tree = recognizeTextLinks(
|
||||
await parseMarkdown(prepared.render(), { latexMath: false }),
|
||||
prepared.linkRecognition.text,
|
||||
prepared.linkRecognition.inlineCode,
|
||||
);
|
||||
const links: string[] = [];
|
||||
function collect(node: ASTNode) {
|
||||
if (node.type === "Link") links.push(String(node.attributes?.url));
|
||||
node.children?.forEach(collect);
|
||||
}
|
||||
collect(tree);
|
||||
expect(links).toEqual([
|
||||
"src/app/[id].tsx",
|
||||
"src/app/(tabs)/index.tsx",
|
||||
"src/a$b.ts",
|
||||
".gitignore",
|
||||
"src/index.ts#L10-L20",
|
||||
"src/index.ts:10-20",
|
||||
"README.md",
|
||||
"./scripts/run",
|
||||
"src/file.ts(10,2)",
|
||||
"/tmp/[route].tsx",
|
||||
"file:///tmp/file.ts#L10-L20",
|
||||
]);
|
||||
expect(
|
||||
classifyAssistantFileLink("src/index.ts#L10-L20", { workspaceRoot: "/repo" }),
|
||||
).toMatchObject({ target: { path: "/repo/src/index.ts", lineStart: 10, lineEnd: 20 } });
|
||||
});
|
||||
|
||||
it("lets the engine own code boundaries while classifying candidate values", async () => {
|
||||
const source = [
|
||||
"```ts\n`src/fenced.ts`\n```",
|
||||
"`` src/app/[id].tsx `` and ` src/spaced.ts `",
|
||||
"``src/a`b.ts`` and `src/after.ts`",
|
||||
"\\`src/escaped.ts` and `src/final.ts`",
|
||||
"`\nsrc/newline.ts\n`",
|
||||
"[`src/explicit.ts`](https://example.com)",
|
||||
].join("\n\n");
|
||||
const prepared = prepareMarkdownSource(source, "/repo");
|
||||
const tree = recognizeTextLinks(
|
||||
await parseMarkdown(prepared.render(), { latexMath: false }),
|
||||
prepared.linkRecognition.text,
|
||||
prepared.linkRecognition.inlineCode,
|
||||
);
|
||||
const recognized: string[] = [];
|
||||
function collect(node: ASTNode) {
|
||||
if (node.type === "Link" && node.attributes?.recognizedLink === "true") {
|
||||
recognized.push(String(node.attributes.url));
|
||||
}
|
||||
node.children?.forEach(collect);
|
||||
}
|
||||
collect(tree);
|
||||
expect(recognized).toEqual([
|
||||
"src/app/[id].tsx",
|
||||
" src/spaced.ts ",
|
||||
"src/a`b.ts",
|
||||
"src/after.ts",
|
||||
"src/escaped.ts",
|
||||
"src/final.ts",
|
||||
"src/newline.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires workspace context for relative inline paths and escapes literal regex characters", () => {
|
||||
const source = "`src/app/[id].tsx` and `src/a+b.ts` and `README.md` and `/tmp/file.ts`";
|
||||
const withoutWorkspace = prepareMarkdownSource(source).linkRecognition.inlineCode;
|
||||
expect(withoutWorkspace.test("src/app/[id].tsx")).toBe(false);
|
||||
expect(withoutWorkspace.test("README.md")).toBe(false);
|
||||
expect(withoutWorkspace.test("/tmp/file.ts")).toBe(true);
|
||||
const prepared = prepareMarkdownSource(source, "/repo");
|
||||
const native = toNativeLinkRecognition(prepared.linkRecognition);
|
||||
const pattern = new RegExp(native.inlineCode.pattern);
|
||||
expect(pattern.test("src/app/[id].tsx")).toBe(true);
|
||||
expect(pattern.test("src/a+b.ts")).toBe(true);
|
||||
expect(pattern.test("src/app/i.tsx")).toBe(false);
|
||||
expect(pattern.test("src/ab.ts")).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes bare Unicode filenames and preserves inline-code URLs", () => {
|
||||
const prepared = prepareMarkdownSource("Read 说明.md, or `https://example.com`.");
|
||||
expect([..."说明.md,".matchAll(prepared.linkRecognition.text)].map(([text]) => text)).toEqual([
|
||||
@@ -436,7 +591,7 @@ it.each([
|
||||
"×/÷",
|
||||
"2024/10/16",
|
||||
])("leaves non-path tokens unlinked in prose and inline code: %s", (token) => {
|
||||
const { linkRecognition } = prepareMarkdownSource(`Value ${token}, then continue.`);
|
||||
const { linkRecognition } = prepareMarkdownSource(`Value ${token}, then \`${token}\`.`, "/repo");
|
||||
expect([...`Value ${token}, then continue.`.matchAll(linkRecognition.text)]).toEqual([]);
|
||||
expect(linkRecognition.inlineCode.test(token)).toBe(false);
|
||||
});
|
||||
@@ -461,12 +616,11 @@ it.each([
|
||||
"123.ts",
|
||||
"FILE.TS",
|
||||
"https://example.com/path",
|
||||
])("recognizes complete paths and URLs in prose and inline code: %s", (token) => {
|
||||
const { linkRecognition } = prepareMarkdownSource(`Open ${token} then continue.`);
|
||||
])("recognizes complete paths and URLs in prose: %s", (token) => {
|
||||
const { linkRecognition } = prepareMarkdownSource(`Open ${token} then \`${token}\`.`, "/repo");
|
||||
expect(
|
||||
[...`Open ${token} then continue.`.matchAll(linkRecognition.text)].map(([text]) => text),
|
||||
).toEqual([token]);
|
||||
expect(linkRecognition.inlineCode.test(token)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -477,10 +631,13 @@ it.each([
|
||||
"packages/app",
|
||||
"123/file",
|
||||
"src/123",
|
||||
])("requires stronger path intent in prose while preserving inline code: %s", (token) => {
|
||||
const { linkRecognition } = prepareMarkdownSource(`Compare ${token}, then continue.`);
|
||||
])("leaves extensionless relative paths unlinked in prose and inline code: %s", (token) => {
|
||||
const { linkRecognition } = prepareMarkdownSource(
|
||||
`Compare ${token}, then \`${token}\`.`,
|
||||
"/repo",
|
||||
);
|
||||
expect([...`Compare ${token}, then continue.`.matchAll(linkRecognition.text)]).toEqual([]);
|
||||
expect(linkRecognition.inlineCode.test(token)).toBe(true);
|
||||
expect(linkRecognition.inlineCode.test(token)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps prose and inline-code recognition enabled through the native regex adapter", () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { classifyAssistantFileLink } from "@/assistant-file-links";
|
||||
import { ASSISTANT_FILE_EXTENSIONS } from "@/assistant-file-links/extensions";
|
||||
import { normalizeMarkdownHtml } from "./html";
|
||||
import { gfmTable } from "micromark-extension-gfm-table";
|
||||
@@ -5,7 +6,7 @@ import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
|
||||
import type { AssistantFileLinkSource } from "@/assistant-file-links";
|
||||
import { decodeString } from "micromark-util-decode-string";
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
import type { Link, Nodes, Root } from "mdast";
|
||||
import type { Link, Nodes, Paragraph, PhrasingContent, Root } from "mdast";
|
||||
|
||||
// Keep this regex in the syntax shared by JavaScript, ICU and java.util.regex.
|
||||
// The Unicode flag makes Metro expand it to escapes rejected by native engines.
|
||||
@@ -22,7 +23,6 @@ const SEPARATED_PATH = String.raw`${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})+`;
|
||||
const FILE_NAME = String.raw`${PATH_SEGMENT}\.(?:${FILE_EXTENSIONS})`;
|
||||
// Bare prose paths need a known extension, a line suffix, or at least two separators.
|
||||
const PROSE_PATH = String.raw`(?:${PATH_PREFIX}${HAS_PATH_LETTER}${SEPARATED_PATH}|${HAS_PATH_LETTER}(?:${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})*[\\/]${FILE_NAME}|${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT}){2,}|${SEPARATED_PATH}(?=:)))`;
|
||||
const INLINE_CODE_PATH = String.raw`${PATH_PREFIX}?${HAS_PATH_LETTER}${SEPARATED_PATH}`;
|
||||
|
||||
function createFilePattern(path: string): RegExp {
|
||||
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
|
||||
@@ -33,20 +33,46 @@ function createFilePattern(path: string): RegExp {
|
||||
}
|
||||
|
||||
const FILE_PATTERN = createFilePattern(PROSE_PATH);
|
||||
const INLINE_CODE_FILE_PATTERN = createFilePattern(INLINE_CODE_PATH);
|
||||
|
||||
function linkMetadata() {
|
||||
function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
|
||||
// The engine accepts regexes on both platforms. Classify complete code spans
|
||||
// first, then pass literal matches rather than maintaining a second path grammar.
|
||||
const alternatives = [...inlineCodePaths].map((path) =>
|
||||
path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
||||
);
|
||||
return {
|
||||
linkRecognition: {
|
||||
text: FILE_PATTERN,
|
||||
inlineCode: new RegExp(
|
||||
String.raw`^(?:${INLINE_CODE_FILE_PATTERN.source})(?:\(\d+(?:,\d+)?(?:-\d+(?:,\d+)?)?\)| lines? \d+(?:-\d+)?)?$`,
|
||||
String.raw`^(?:https?://[^\s<>]+${alternatives.length ? `|${alternatives.join("|")}` : ""})$`,
|
||||
),
|
||||
},
|
||||
linkSource: markdownLinkSource,
|
||||
};
|
||||
}
|
||||
|
||||
function collectInlineCodePaths(source: string, workspaceRoot?: string): ReadonlySet<string> {
|
||||
const paths = new Set<string>();
|
||||
const previousEnds = new Map<number, number>();
|
||||
// Collect candidates between consecutive runs of equal length. The engine owns
|
||||
// Markdown context (escapes, fences, authored links); extra candidates are harmless
|
||||
// because recognition only applies to complete Code nodes. This avoids an AST
|
||||
// parse on every streaming reveal, including updates inside long code fences.
|
||||
for (const match of source.matchAll(/`+/g)) {
|
||||
const length = match[0].length;
|
||||
const previousEnd = previousEnds.get(length);
|
||||
previousEnds.set(length, match.index + length);
|
||||
if (previousEnd === undefined) continue;
|
||||
let value = source.slice(previousEnd, match.index).replace(/\r\n?|\n/g, " ");
|
||||
// CommonMark code spans replace line endings and strip one surrounding space.
|
||||
if (value.startsWith(" ") && value.endsWith(" ") && /[^ ]/.test(value)) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
const classification = classifyAssistantFileLink(value, { workspaceRoot });
|
||||
if (classification && classification.kind !== "external") paths.add(value);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function markdownLinkSource(
|
||||
url: string,
|
||||
sourceType?: "markdown" | "text" | "inline-code",
|
||||
@@ -90,6 +116,7 @@ export function prepareMarkdownSource(
|
||||
extensions: [gfmTable()],
|
||||
mdastExtensions: [gfmTableFromMarkdown()],
|
||||
}));
|
||||
const metadata = linkMetadata(collectInlineCodePaths(source, workspaceRoot));
|
||||
const edits: { start: number; end: number; text: string }[] = [];
|
||||
if (
|
||||
!source.includes(")
|
||||
) {
|
||||
return { images, ...linkMetadata(), render: () => source };
|
||||
return { images, ...metadata, render: () => source };
|
||||
}
|
||||
|
||||
const definitions = new Map<string, { url: string; title?: string | null }>();
|
||||
@@ -130,10 +157,50 @@ export function prepareMarkdownSource(
|
||||
end,
|
||||
});
|
||||
}
|
||||
// Line boundaries already turned into paragraph breaks, by offset of their newline.
|
||||
const splitBoundaries = new Set<number>();
|
||||
function isImageLike(node: PhrasingContent): boolean {
|
||||
if (node.type === "image" || node.type === "imageReference") return true;
|
||||
return (
|
||||
(node.type === "link" || node.type === "linkReference") &&
|
||||
node.children.length === 1 &&
|
||||
isImageLike(node.children[0])
|
||||
);
|
||||
}
|
||||
// An image alone on its line becomes its own paragraph, so every platform renders
|
||||
// it as a block. The library only treats sole-child paragraph images as blocks;
|
||||
// with hardSoftBreaks every newline is a break node, which made these inline.
|
||||
function splitStandaloneImages(paragraph: Paragraph, start: number, end: number) {
|
||||
for (const child of paragraph.children) {
|
||||
const childStart = child.position?.start.offset;
|
||||
const childEnd = child.position?.end.offset;
|
||||
if (childStart === undefined || childEnd === undefined || !isImageLike(child)) continue;
|
||||
const lineStart = source.lastIndexOf("\n", childStart - 1) + 1;
|
||||
const newline = source.indexOf("\n", childEnd);
|
||||
const lineEnd = newline === -1 ? source.length : newline;
|
||||
const prefix = source.slice(lineStart, childStart);
|
||||
const suffix = source.slice(childEnd, lineEnd);
|
||||
const aloneBefore = childStart === start || /^[\s>]*$/.test(prefix);
|
||||
const aloneAfter = /^\s*\\?$/.test(suffix);
|
||||
if (!aloneBefore || !aloneAfter) continue;
|
||||
// Continuation lines inside quotes and lists keep their quote markers on the blank line.
|
||||
const blank = prefix.replace(/[^>]/g, " ").trimEnd();
|
||||
const split = (boundary: number) => {
|
||||
if (splitBoundaries.has(boundary)) return;
|
||||
splitBoundaries.add(boundary);
|
||||
// A trailing backslash was a hard break; at a paragraph end it would render literally.
|
||||
const cut = source[boundary - 1] === "\\" ? boundary - 1 : boundary;
|
||||
edits.push({ start: cut, end: boundary + 1, text: `\n${blank}\n` });
|
||||
};
|
||||
if (lineStart > start) split(lineStart - 1);
|
||||
if (lineEnd < end) split(lineEnd);
|
||||
}
|
||||
}
|
||||
function visit(node: Nodes) {
|
||||
const start = node.position?.start.offset;
|
||||
const end = node.position?.end.offset;
|
||||
if (start !== undefined && end !== undefined) {
|
||||
if (node.type === "paragraph") splitStandaloneImages(node, start, end);
|
||||
if (node.type === "link") {
|
||||
const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot);
|
||||
if (title !== null)
|
||||
@@ -166,7 +233,7 @@ export function prepareMarkdownSource(
|
||||
visit(getTree());
|
||||
return {
|
||||
images,
|
||||
...linkMetadata(),
|
||||
...metadata,
|
||||
render(
|
||||
urls: ReadonlyMap<string, string> = new Map(),
|
||||
unavailableImages: ReadonlySet<string> = new Set(),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { router, usePathname } from "expo-router";
|
||||
import { CalendarClock, History, Plus, Search } from "lucide-react-native";
|
||||
import { memo, useCallback, useMemo, useRef, type ComponentType } from "react";
|
||||
import { memo, useCallback, useMemo, useRef, type ComponentType, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { PluginSidebarItem } from "@/plugins/sidebar-items";
|
||||
@@ -30,37 +31,49 @@ interface SidebarNavRowProps {
|
||||
interface SidebarNavRowsProps extends SidebarNavRowProps {
|
||||
/** Style for the group wrapper, which the sidebar owns. */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** Sits to the right of the first row, which shrinks to make room. */
|
||||
firstRowTrailing?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level sidebar navigation, ordered and filtered by the user's
|
||||
* `sidebarNavItems` preference. Renders nothing — not even the bordered group
|
||||
* wrapper — when every item is hidden.
|
||||
* wrapper — when every item is hidden and there is no `firstRowTrailing`.
|
||||
*/
|
||||
export function SidebarNavRows({ style, onBeforeNavigate }: SidebarNavRowsProps) {
|
||||
export function SidebarNavRows({ style, onBeforeNavigate, firstRowTrailing }: SidebarNavRowsProps) {
|
||||
const { items } = useSidebarNavItems("header");
|
||||
const visibleItems = useMemo(() => items.filter((item) => item.visible), [items]);
|
||||
const groupRef = useRef<View | null>(null);
|
||||
|
||||
if (visibleItems.length === 0) return null;
|
||||
if (visibleItems.length === 0 && !firstRowTrailing) return null;
|
||||
|
||||
const [firstRow, ...restRows] = visibleItems.map((item) => {
|
||||
if (item.kind === "plugin") {
|
||||
return (
|
||||
<PluginSidebarItem
|
||||
key={item.key}
|
||||
group={item.group}
|
||||
section="header"
|
||||
fallbackAnchorRef={groupRef}
|
||||
onBeforeNavigate={onBeforeNavigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const Row = BUILTIN_ROWS[item.id];
|
||||
return <Row key={item.key} onBeforeNavigate={onBeforeNavigate} />;
|
||||
});
|
||||
|
||||
return (
|
||||
<View ref={groupRef} collapsable={false} style={style}>
|
||||
{visibleItems.map((item) => {
|
||||
if (item.kind === "plugin") {
|
||||
return (
|
||||
<PluginSidebarItem
|
||||
key={item.key}
|
||||
group={item.group}
|
||||
section="header"
|
||||
fallbackAnchorRef={groupRef}
|
||||
onBeforeNavigate={onBeforeNavigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const Row = BUILTIN_ROWS[item.id];
|
||||
return <Row key={item.key} onBeforeNavigate={onBeforeNavigate} />;
|
||||
})}
|
||||
{firstRowTrailing ? (
|
||||
<View style={styles.firstRow}>
|
||||
<View style={styles.firstRowItem}>{firstRow}</View>
|
||||
{firstRowTrailing}
|
||||
</View>
|
||||
) : (
|
||||
firstRow
|
||||
)}
|
||||
{restRows}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -179,3 +192,14 @@ const BUILTIN_ROWS: Record<BuiltinSidebarNavId, ComponentType<SidebarNavRowProps
|
||||
search: SidebarSearchRow,
|
||||
schedules: SidebarSchedulesRow,
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
firstRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
firstRowItem: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2226,7 +2226,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
},
|
||||
// Lifts the composer off the chat it sits under on phones.
|
||||
inputWrapperCompact: {
|
||||
boxShadow: "0px 4px 16px rgba(0, 0, 0, 0.2)",
|
||||
...theme.shadow.sm,
|
||||
},
|
||||
// Dotted says "this surface is the same box, but there is nothing to type
|
||||
// into it" without swapping the border colour, which reads as an error state.
|
||||
|
||||
@@ -1334,8 +1334,9 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
fields: {
|
||||
project: "المشروع",
|
||||
base: "الأساس",
|
||||
branch: "الفرع",
|
||||
baseNotApplicable: "غير قابل للتطبيق",
|
||||
host: "المضيف",
|
||||
},
|
||||
titlePlaceholder: "العنوان (اختياري)",
|
||||
errors: {
|
||||
|
||||
@@ -1342,8 +1342,9 @@ export const en = {
|
||||
},
|
||||
fields: {
|
||||
project: "Project",
|
||||
base: "Base",
|
||||
branch: "Branch",
|
||||
baseNotApplicable: "Not applicable",
|
||||
host: "Host",
|
||||
},
|
||||
titlePlaceholder: "Title (optional)",
|
||||
errors: {
|
||||
|
||||
@@ -1370,8 +1370,9 @@ export const es: TranslationResources = {
|
||||
},
|
||||
fields: {
|
||||
project: "Proyecto",
|
||||
base: "Base",
|
||||
branch: "Rama",
|
||||
baseNotApplicable: "No aplicable",
|
||||
host: "Host",
|
||||
},
|
||||
titlePlaceholder: "Título (opcional)",
|
||||
errors: {
|
||||
|
||||
@@ -1363,8 +1363,9 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
fields: {
|
||||
project: "Projet",
|
||||
base: "Base",
|
||||
branch: "Branche",
|
||||
baseNotApplicable: "Non applicable",
|
||||
host: "Hôte",
|
||||
},
|
||||
titlePlaceholder: "Titre (facultatif)",
|
||||
errors: {
|
||||
|
||||
@@ -1348,8 +1348,9 @@ export const ja: TranslationResources = {
|
||||
},
|
||||
fields: {
|
||||
project: "プロジェクト",
|
||||
base: "ベース",
|
||||
branch: "ブランチ",
|
||||
baseNotApplicable: "該当なし",
|
||||
host: "ホスト",
|
||||
},
|
||||
titlePlaceholder: "タイトル(任意)",
|
||||
errors: {
|
||||
|
||||
@@ -1341,8 +1341,9 @@ export const ko: TranslationResources = {
|
||||
},
|
||||
fields: {
|
||||
project: "프로젝트",
|
||||
base: "기준",
|
||||
branch: "브랜치",
|
||||
baseNotApplicable: "해당 없음",
|
||||
host: "호스트",
|
||||
},
|
||||
titlePlaceholder: "제목(선택 사항)",
|
||||
errors: {
|
||||
|
||||
@@ -1360,8 +1360,9 @@ export const ptBR: TranslationResources = {
|
||||
},
|
||||
fields: {
|
||||
project: "Projeto",
|
||||
base: "Base",
|
||||
branch: "Branch",
|
||||
baseNotApplicable: "Não aplicável",
|
||||
host: "Host",
|
||||
},
|
||||
titlePlaceholder: "Título (opcional)",
|
||||
errors: {
|
||||
|
||||
@@ -1352,8 +1352,9 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
fields: {
|
||||
project: "Проект",
|
||||
base: "Базовая ветка",
|
||||
branch: "Ветка",
|
||||
baseNotApplicable: "Неприменимо",
|
||||
host: "Хост",
|
||||
},
|
||||
titlePlaceholder: "Заголовок (необязательно)",
|
||||
errors: {
|
||||
|
||||
@@ -1323,8 +1323,9 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
fields: {
|
||||
project: "项目",
|
||||
base: "基线",
|
||||
branch: "分支",
|
||||
baseNotApplicable: "不适用",
|
||||
host: "主机",
|
||||
},
|
||||
titlePlaceholder: "标题(可选)",
|
||||
errors: {
|
||||
|
||||
@@ -1207,6 +1207,60 @@ describe("DirectorySync session readiness", () => {
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it.each(["projects", "workspaces"] as const)(
|
||||
"requests projects and workspaces together and commits once when %s responds first",
|
||||
async (firstResponse) => {
|
||||
const serverId = `parallel-directory-${firstResponse}`;
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
|
||||
store.updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: "test",
|
||||
features: { workspaceMultiplicity: true, projectList: true },
|
||||
});
|
||||
const releaseProjects = client.holdProjectFetch();
|
||||
const releaseWorkspaces = client.holdWorkspaceFetch();
|
||||
const project = {
|
||||
projectId: "parallel-project",
|
||||
projectDisplayName: "Project baseline",
|
||||
projectRootPath: "/repo/parallel",
|
||||
projectKind: "git" as const,
|
||||
};
|
||||
const projects: ProjectListResult = { requestId: "projects", projects: [project] };
|
||||
const workspaces: WorkspaceFetchResult = {
|
||||
requestId: "workspaces",
|
||||
entries: [createWorkspaceEntry("parallel-workspace")],
|
||||
emptyProjects: [{ ...project, projectDisplayName: "Newer workspace overlay" }],
|
||||
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
|
||||
};
|
||||
const refresh = directory.refreshWorkspaces();
|
||||
try {
|
||||
await expect.poll(() => client.listProjectsCalls).toBe(1);
|
||||
await expect.poll(() => client.fetchWorkspacesCalls).toBe(1);
|
||||
if (firstResponse === "projects") releaseProjects(projects);
|
||||
else releaseWorkspaces(workspaces);
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
expect(useSessionStore.getState().sessions[serverId].workspaces.size).toBe(0);
|
||||
expect(useSessionStore.getState().sessions[serverId].projects.size).toBe(0);
|
||||
releaseProjects(projects);
|
||||
releaseWorkspaces(workspaces);
|
||||
await refresh;
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
expect(session.workspaces.has("parallel-workspace")).toBe(true);
|
||||
expect(session.projects.get(project.projectId)?.projectDisplayName).toBe(
|
||||
"Newer workspace overlay",
|
||||
);
|
||||
} finally {
|
||||
releaseProjects(projects);
|
||||
releaseWorkspaces(workspaces);
|
||||
await refresh;
|
||||
directory.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the accepted baseline when an old connection finishes its workspace request", async () => {
|
||||
const serverId = "superseded-workspace-baseline";
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
|
||||
@@ -563,16 +563,20 @@ export class DirectorySync {
|
||||
const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo;
|
||||
const supportsProjectList = serverInfo?.features?.projectList === true;
|
||||
const supportsDirectorySync = serverInfo?.features?.directorySync === true;
|
||||
if (supportsProjectList) {
|
||||
await this.fetchProjectSnapshot(client, source, transaction, supportsDirectorySync);
|
||||
}
|
||||
await this.fetchWorkspaceSnapshot(
|
||||
client,
|
||||
source,
|
||||
transaction,
|
||||
input?.subscribe === true,
|
||||
supportsDirectorySync,
|
||||
);
|
||||
const projectsReady = supportsProjectList
|
||||
? this.fetchProjectSnapshot(client, source, transaction, supportsDirectorySync)
|
||||
: Promise.resolve();
|
||||
await Promise.all([
|
||||
projectsReady,
|
||||
this.fetchWorkspaceSnapshot(
|
||||
client,
|
||||
source,
|
||||
transaction,
|
||||
input?.subscribe === true,
|
||||
supportsDirectorySync,
|
||||
projectsReady,
|
||||
),
|
||||
]);
|
||||
if (!supportsProjectList) {
|
||||
this.buildLegacyProjectSnapshot(transaction.snapshot);
|
||||
(transaction.snapshot.syncModes ??= {}).projects =
|
||||
@@ -592,6 +596,7 @@ export class DirectorySync {
|
||||
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
|
||||
initialSubscribe: boolean,
|
||||
supportsDirectorySync: boolean,
|
||||
projectsReady: Promise<void>,
|
||||
): Promise<void> {
|
||||
let cursor: string | null = null;
|
||||
let subscribe = initialSubscribe;
|
||||
@@ -618,6 +623,9 @@ export class DirectorySync {
|
||||
});
|
||||
payload = await subscription.ready;
|
||||
} else payload = await client.fetchWorkspaces(query);
|
||||
// Fetch both directories together, but apply the project baseline before the
|
||||
// workspace page's empty-project overlays, regardless of response order.
|
||||
await projectsReady;
|
||||
this.assertWorkspaceTransactionCurrent(client, source, transaction);
|
||||
applyWorkspaceSnapshotPage(transaction.snapshot, payload, cursor === null);
|
||||
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,217 @@
|
||||
import { forwardRef, useCallback, type ReactElement, type ReactNode } from "react";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown } from "lucide-react-native";
|
||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
|
||||
/**
|
||||
* `badge` is the desktop meta row: a borderless pill that names its field in a tooltip.
|
||||
* `field` is the compact form: a full-width filled box with the field's label inside, above the
|
||||
* value, so the whole field is one tap target.
|
||||
*/
|
||||
export type FieldTriggerVariant = "badge" | "field";
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const extraMutedColor = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted });
|
||||
|
||||
// Extra-muted: the chevron is an affordance, not information, and sits behind its label.
|
||||
function Chevron(): ReactElement {
|
||||
return (
|
||||
<View style={styles.chevron}>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={extraMutedColor} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const chevron = <Chevron />;
|
||||
|
||||
export interface FieldTriggerContentProps {
|
||||
variant: FieldTriggerVariant;
|
||||
/** The field's name, shown inside a `field`. */
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
value: string;
|
||||
/** Secondary text after the value, such as a terminal profile's name. */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export function FieldTriggerContent({
|
||||
variant,
|
||||
label,
|
||||
icon,
|
||||
value,
|
||||
detail,
|
||||
}: FieldTriggerContentProps): ReactElement {
|
||||
const valueLine = (
|
||||
<>
|
||||
<View style={styles.iconBox}>{icon}</View>
|
||||
<Text style={variant === "field" ? styles.fieldValue : styles.value} numberOfLines={1}>
|
||||
{value}
|
||||
{detail ? <Text style={styles.detail}> {detail}</Text> : null}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
if (variant === "badge") {
|
||||
return (
|
||||
<>
|
||||
{valueLine}
|
||||
{chevron}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<View style={styles.fieldBody}>
|
||||
<Text style={styles.fieldLabel} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
<View style={styles.fieldValueLine}>{valueLine}</View>
|
||||
</View>
|
||||
{chevron}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pressable style for a field trigger, for triggers that bring their own Pressable. */
|
||||
export function useFieldTriggerStyle(variant: FieldTriggerVariant, disabled: boolean) {
|
||||
return useCallback(
|
||||
(state: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
variant === "field" ? styles.field : styles.badge,
|
||||
!disabled && Boolean(state.hovered) && styles.highlighted,
|
||||
!disabled &&
|
||||
state.pressed &&
|
||||
(variant === "field" ? styles.highlighted : styles.badgePressed),
|
||||
disabled && styles.disabled,
|
||||
],
|
||||
[disabled, variant],
|
||||
);
|
||||
}
|
||||
|
||||
export interface FieldTriggerProps extends FieldTriggerContentProps {
|
||||
/** What a screen reader announces; defaults to `label`. */
|
||||
accessibilityLabel?: string;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
/** Desktop hover hint; badges only, since a field shows its label. */
|
||||
tooltip?: string;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/** A field trigger that anchors a Combobox. */
|
||||
export const FieldTrigger = forwardRef<View, FieldTriggerProps>(function FieldTrigger(
|
||||
{ accessibilityLabel, onPress, disabled = false, tooltip, testID, ...content },
|
||||
ref,
|
||||
): ReactElement {
|
||||
const style = useFieldTriggerStyle(content.variant, disabled);
|
||||
const trigger = (
|
||||
<ComboboxTrigger
|
||||
ref={ref}
|
||||
chevron={null}
|
||||
block={content.variant === "field"}
|
||||
testID={testID}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
style={style}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel ?? content.label}
|
||||
>
|
||||
<FieldTriggerContent {...content} />
|
||||
</ComboboxTrigger>
|
||||
);
|
||||
if (content.variant === "field" || !tooltip) return trigger;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
{trigger}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{tooltip}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
// Height of a badge. The desktop row sizes from it.
|
||||
export const FIELD_BADGE_HEIGHT = CONTROL_HEIGHTS.tight;
|
||||
// Height of a compact field: label line plus value line. The compact stack reserves rows from it.
|
||||
export const FIELD_HEIGHT = 56;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
badge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
height: FIELD_BADGE_HEIGHT,
|
||||
maxWidth: 240,
|
||||
overflow: "hidden",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
field: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
height: FIELD_HEIGHT,
|
||||
paddingLeft: theme.spacing[3],
|
||||
paddingRight: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
highlighted: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
badgePressed: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
// Optical: the chevron's ink sits high in its box.
|
||||
chevron: {
|
||||
flexShrink: 0,
|
||||
transform: [{ translateY: 1 }],
|
||||
},
|
||||
iconBox: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
value: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
fieldBody: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
gap: theme.spacing[0.5],
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
fieldValueLine: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
},
|
||||
fieldValue: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
detail: {
|
||||
color: theme.colors.foregroundExtraMuted,
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native";
|
||||
import { ProjectIconView } from "@/components/project-icon-view";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
|
||||
const ThemedFolder = withUnistyles(Folder);
|
||||
const ThemedFolderPlus = withUnistyles(FolderPlus);
|
||||
const ThemedGitBranch = withUnistyles(GitBranch);
|
||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const mutedColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
export const folderIcon = <ThemedFolder size={ICON_SIZE.sm} uniProps={mutedColor} />;
|
||||
export const addProjectIcon = <ThemedFolderPlus size={ICON_SIZE.sm} uniProps={mutedColor} />;
|
||||
export const branchIcon = <ThemedGitBranch size={ICON_SIZE.sm} uniProps={mutedColor} />;
|
||||
export const pullRequestIcon = <ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={mutedColor} />;
|
||||
|
||||
// Single uppercase initial inside an iconSize.md (16px) square — below the smallest font-size
|
||||
// token, so it stays a literal sized to the box.
|
||||
const PROJECT_ICON_FALLBACK_FONT_SIZE = 10;
|
||||
|
||||
export function ProjectIcon({
|
||||
projectViewKey,
|
||||
projectName,
|
||||
iconDataUri,
|
||||
}: {
|
||||
projectViewKey: string;
|
||||
projectName: string;
|
||||
iconDataUri: string | null;
|
||||
}): ReactElement {
|
||||
const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(projectName);
|
||||
return (
|
||||
<ProjectIconView
|
||||
iconDataUri={iconDataUri}
|
||||
initial={placeholderLabel.charAt(0).toUpperCase() || "?"}
|
||||
projectViewKey={projectViewKey}
|
||||
size={ICON_SIZE.md}
|
||||
textStyle={styles.fallbackText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
fallbackText: {
|
||||
fontSize: PROJECT_ICON_FALLBACK_FONT_SIZE,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,392 @@
|
||||
import { useCallback, useMemo, type ReactElement, type ReactNode, type RefObject } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { TerminalProfile } from "@getpaseo/protocol/messages";
|
||||
import { Combobox, type ComboboxOption, type ComboboxProps } from "@/components/ui/combobox";
|
||||
import { HostStatusDot } from "@/components/host-status-dot";
|
||||
import { HostPicker } from "@/components/hosts/host-picker";
|
||||
import type { LaunchTarget } from "@/new-workspace-launch/target";
|
||||
import type { HostProjectListItem } from "@/projects/host-projects";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import type { PickerItem } from "../../new-workspace-picker-item";
|
||||
import {
|
||||
FIELD_BADGE_HEIGHT,
|
||||
FIELD_HEIGHT,
|
||||
FieldTrigger,
|
||||
type FieldTriggerVariant,
|
||||
} from "./field-trigger";
|
||||
import { branchIcon, folderIcon, ProjectIcon, pullRequestIcon } from "./icons";
|
||||
import { LaunchControl } from "./launch-control";
|
||||
import { AddProjectOption, IsolationOption, ProjectOption, RefOption } from "./options";
|
||||
|
||||
type Isolation = "local" | "worktree";
|
||||
type RenderOption = NonNullable<ComboboxProps["renderOption"]>;
|
||||
|
||||
interface PickerControl {
|
||||
anchorRef: RefObject<View | null>;
|
||||
open: () => void;
|
||||
openState: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export interface NewWorkspaceFormProps {
|
||||
isCompact: boolean;
|
||||
isPending: boolean;
|
||||
project: PickerControl & {
|
||||
options: ComboboxOption[];
|
||||
projectByOptionId: Map<string, HostProjectListItem>;
|
||||
iconDataByProjectViewKey: Map<string, string | null>;
|
||||
selectedProject: HostProjectListItem | null;
|
||||
selectedOptionId: string;
|
||||
triggerLabel: string;
|
||||
selectedServerId: string;
|
||||
/** COMPAT(workspaceMultiplicity): without it only worktree-capable projects are selectable. */
|
||||
supportsWorkspaceMultiplicity: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onAddProject: () => void;
|
||||
};
|
||||
host: PickerControl & {
|
||||
allHosts: HostProfile[];
|
||||
selectedServerId: string;
|
||||
onSelect: (id: string) => void;
|
||||
onAddHost: () => void;
|
||||
};
|
||||
isolation: PickerControl & {
|
||||
effectiveIsolation: Isolation;
|
||||
canCreateWorktree: boolean;
|
||||
onSelect: (isolation: Isolation) => void;
|
||||
};
|
||||
base: PickerControl & {
|
||||
selectedSourceDirectory: string | null;
|
||||
selectedItem: PickerItem | null;
|
||||
triggerLabel: string;
|
||||
options: ComboboxOption[];
|
||||
itemById: Map<string, PickerItem>;
|
||||
selectedOptionId: string;
|
||||
onSelect: (id: string) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
emptyText: string;
|
||||
showRefPicker: boolean;
|
||||
};
|
||||
launch: {
|
||||
serverId: string;
|
||||
target: LaunchTarget;
|
||||
onChange: (target: LaunchTarget) => void;
|
||||
profiles: readonly TerminalProfile[];
|
||||
disabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function hostOptionTestID(serverId: string): string {
|
||||
return `new-workspace-host-picker-option-${serverId}`;
|
||||
}
|
||||
|
||||
function isProjectSelectable(
|
||||
project: HostProjectListItem,
|
||||
supportsWorkspaceMultiplicity: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
supportsWorkspaceMultiplicity ||
|
||||
project.hosts.some((host) => host.worktreeSupport !== "unsupported")
|
||||
);
|
||||
}
|
||||
|
||||
function useProjectPicker(
|
||||
project: NewWorkspaceFormProps["project"],
|
||||
isPending: boolean,
|
||||
): { renderOption: RenderOption; footer: ReactNode } {
|
||||
const { projectByOptionId, iconDataByProjectViewKey, selectedServerId } = project;
|
||||
const { supportsWorkspaceMultiplicity, onAddProject } = project;
|
||||
const renderOption = useCallback<RenderOption>(
|
||||
(row) => {
|
||||
const item = projectByOptionId.get(row.option.id);
|
||||
return (
|
||||
<ProjectOption
|
||||
{...row}
|
||||
project={item}
|
||||
iconDataUri={item ? (iconDataByProjectViewKey.get(item.viewKey) ?? null) : null}
|
||||
selectedServerId={selectedServerId}
|
||||
disabled={isPending || !item || !isProjectSelectable(item, supportsWorkspaceMultiplicity)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
iconDataByProjectViewKey,
|
||||
isPending,
|
||||
projectByOptionId,
|
||||
selectedServerId,
|
||||
supportsWorkspaceMultiplicity,
|
||||
],
|
||||
);
|
||||
const footer = useMemo(() => <AddProjectOption onPress={onAddProject} />, [onAddProject]);
|
||||
return { renderOption, footer };
|
||||
}
|
||||
|
||||
function useIsolationPicker(
|
||||
isolation: NewWorkspaceFormProps["isolation"],
|
||||
isPending: boolean,
|
||||
): { options: ComboboxOption[]; onSelect: (id: string) => void; renderOption: RenderOption } {
|
||||
const { t } = useTranslation();
|
||||
const { canCreateWorktree, onSelect, onOpenChange } = isolation;
|
||||
// "New worktree" is omitted entirely (not disabled) when the project isn't a
|
||||
// git checkout, since worktree isolation is impossible there.
|
||||
const options = useMemo<ComboboxOption[]>(() => {
|
||||
const local = { id: "local", label: t("newWorkspace.isolation.local") };
|
||||
if (!canCreateWorktree) return [local];
|
||||
return [local, { id: "worktree", label: t("newWorkspace.isolation.worktree") }];
|
||||
}, [canCreateWorktree, t]);
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
onSelect(id === "worktree" ? "worktree" : "local");
|
||||
onOpenChange(false);
|
||||
},
|
||||
[onOpenChange, onSelect],
|
||||
);
|
||||
const renderOption = useCallback<RenderOption>(
|
||||
(row) => <IsolationOption {...row} disabled={isPending} />,
|
||||
[isPending],
|
||||
);
|
||||
return { options, onSelect: handleSelect, renderOption };
|
||||
}
|
||||
|
||||
function useRefRenderOption(itemById: Map<string, PickerItem>, isPending: boolean): RenderOption {
|
||||
return useCallback<RenderOption>(
|
||||
(row) => <RefOption {...row} item={itemById.get(row.option.id)} disabled={isPending} />,
|
||||
[isPending, itemById],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The New workspace setup fields, one trigger per field, each opening its picker. Compact: a stack
|
||||
* of full-width fields with the label inside. Desktop: one meta row of badges. Either way, a field
|
||||
* that does not apply to the selected project is left out, and Host only shows with a second host.
|
||||
*/
|
||||
export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { isCompact, isPending, project, host, isolation, base, launch } = props;
|
||||
const variant: FieldTriggerVariant = isCompact ? "field" : "badge";
|
||||
const projectPicker = useProjectPicker(project, isPending);
|
||||
const isolationPicker = useIsolationPicker(isolation, isPending);
|
||||
const renderRefOption = useRefRenderOption(base.itemById, isPending);
|
||||
|
||||
const showHost = host.allHosts.length > 1;
|
||||
const selectedHostLabel =
|
||||
host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host";
|
||||
const selectedProject = project.selectedProject;
|
||||
const selectedProjectIconUri = selectedProject
|
||||
? (project.iconDataByProjectViewKey.get(selectedProject.viewKey) ?? null)
|
||||
: null;
|
||||
const projectIcon = useMemo(
|
||||
() =>
|
||||
selectedProject ? (
|
||||
<ProjectIcon
|
||||
projectViewKey={selectedProject.viewKey}
|
||||
projectName={selectedProject.projectName}
|
||||
iconDataUri={selectedProjectIconUri}
|
||||
/>
|
||||
) : (
|
||||
folderIcon
|
||||
),
|
||||
[selectedProject, selectedProjectIconUri],
|
||||
);
|
||||
const hostIcon = useMemo(
|
||||
() => <HostStatusDot serverId={host.selectedServerId} />,
|
||||
[host.selectedServerId],
|
||||
);
|
||||
const isWorktree = isolation.effectiveIsolation === "worktree";
|
||||
const isolationValue = isWorktree
|
||||
? t("newWorkspace.isolation.worktree")
|
||||
: t("newWorkspace.isolation.local");
|
||||
const refIcon = base.selectedItem?.kind === "github-pr" ? pullRequestIcon : branchIcon;
|
||||
|
||||
const projectField = (
|
||||
<FieldTrigger
|
||||
ref={project.anchorRef}
|
||||
variant={variant}
|
||||
label={t("newWorkspace.fields.project")}
|
||||
accessibilityLabel="Workspace project"
|
||||
tooltip={t("newWorkspace.tooltips.project")}
|
||||
icon={projectIcon}
|
||||
value={project.triggerLabel}
|
||||
onPress={project.open}
|
||||
disabled={isPending}
|
||||
testID="new-workspace-project-picker-trigger"
|
||||
/>
|
||||
);
|
||||
const hostField = showHost ? (
|
||||
<HostPicker
|
||||
hosts={host.allHosts}
|
||||
value={host.selectedServerId}
|
||||
onSelect={host.onSelect}
|
||||
open={host.openState}
|
||||
onOpenChange={host.onOpenChange}
|
||||
anchorRef={host.anchorRef}
|
||||
includeAddHost={isCompact}
|
||||
onAddHost={host.onAddHost}
|
||||
searchable={false}
|
||||
title={t("newWorkspace.fields.host")}
|
||||
desktopPlacement="bottom-start"
|
||||
desktopMinWidth={200}
|
||||
hostOptionTestID={hostOptionTestID}
|
||||
>
|
||||
<FieldTrigger
|
||||
ref={host.anchorRef}
|
||||
variant={variant}
|
||||
label={t("newWorkspace.fields.host")}
|
||||
tooltip={t("newWorkspace.tooltips.host")}
|
||||
icon={hostIcon}
|
||||
value={selectedHostLabel}
|
||||
onPress={host.open}
|
||||
disabled={isPending}
|
||||
testID="host-picker-trigger"
|
||||
/>
|
||||
</HostPicker>
|
||||
) : null;
|
||||
const isolationField = isolation.canCreateWorktree ? (
|
||||
<FieldTrigger
|
||||
ref={isolation.anchorRef}
|
||||
variant={variant}
|
||||
label={t("newWorkspace.isolation.label")}
|
||||
accessibilityLabel="Workspace isolation"
|
||||
tooltip={t("newWorkspace.tooltips.isolation")}
|
||||
icon={isWorktree ? branchIcon : folderIcon}
|
||||
value={isolationValue}
|
||||
onPress={isolation.open}
|
||||
disabled={isPending}
|
||||
testID="workspace-create-isolation-trigger"
|
||||
/>
|
||||
) : null;
|
||||
const baseField = base.showRefPicker ? (
|
||||
<FieldTrigger
|
||||
ref={base.anchorRef}
|
||||
variant={variant}
|
||||
label={t("newWorkspace.fields.branch")}
|
||||
accessibilityLabel={t("newWorkspace.refPicker.startingRef")}
|
||||
tooltip={t("newWorkspace.tooltips.startingRef")}
|
||||
icon={refIcon}
|
||||
value={base.triggerLabel}
|
||||
onPress={base.open}
|
||||
disabled={isPending || !base.selectedSourceDirectory}
|
||||
testID="new-workspace-ref-picker-trigger"
|
||||
/>
|
||||
) : null;
|
||||
const launchField = (
|
||||
<LaunchControl
|
||||
variant={variant}
|
||||
serverId={launch.serverId}
|
||||
target={launch.target}
|
||||
onChange={launch.onChange}
|
||||
profiles={launch.profiles}
|
||||
disabled={launch.disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
const pickers = (
|
||||
<>
|
||||
<Combobox
|
||||
options={project.options}
|
||||
value={project.selectedOptionId}
|
||||
onSelect={project.onSelect}
|
||||
searchable
|
||||
searchPlaceholder="Search projects"
|
||||
title={t("newWorkspace.fields.project")}
|
||||
open={project.openState}
|
||||
onOpenChange={project.onOpenChange}
|
||||
desktopPlacement="bottom-start"
|
||||
desktopMinWidth={360}
|
||||
anchorRef={project.anchorRef}
|
||||
emptyText="No projects available."
|
||||
renderOption={projectPicker.renderOption}
|
||||
footer={projectPicker.footer}
|
||||
/>
|
||||
<Combobox
|
||||
options={isolationPicker.options}
|
||||
value={isolation.effectiveIsolation}
|
||||
onSelect={isolationPicker.onSelect}
|
||||
title={t("newWorkspace.isolation.label")}
|
||||
open={isolation.openState}
|
||||
onOpenChange={isolation.onOpenChange}
|
||||
desktopPlacement="bottom-start"
|
||||
anchorRef={isolation.anchorRef}
|
||||
renderOption={isolationPicker.renderOption}
|
||||
/>
|
||||
<Combobox
|
||||
options={base.options}
|
||||
value={base.selectedOptionId}
|
||||
onSelect={base.onSelect}
|
||||
searchable
|
||||
searchPlaceholder={t("newWorkspace.refPicker.searchPlaceholder")}
|
||||
title={t("newWorkspace.refPicker.title")}
|
||||
open={base.openState}
|
||||
onOpenChange={base.onOpenChange}
|
||||
onSearchQueryChange={base.setSearchQuery}
|
||||
desktopPlacement="bottom-start"
|
||||
anchorRef={base.anchorRef}
|
||||
emptyText={base.emptyText}
|
||||
renderOption={renderRefOption}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isCompact) {
|
||||
return (
|
||||
<View testID="new-workspace-ref-picker-row" style={styles.compactStack}>
|
||||
{projectField}
|
||||
{hostField}
|
||||
{isolationField}
|
||||
{baseField}
|
||||
{launchField}
|
||||
{/* Isolation and Base come and go with the project; their rows stay reserved so the
|
||||
stack keeps one height and nothing above the composer jumps. */}
|
||||
{isolationField ? null : <View style={styles.reservedField} pointerEvents="none" />}
|
||||
{baseField ? null : <View style={styles.reservedField} pointerEvents="none" />}
|
||||
{pickers}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View testID="new-workspace-ref-picker-row" style={styles.desktopRow} pointerEvents="box-none">
|
||||
<View style={styles.desktopControl}>{projectField}</View>
|
||||
{hostField ? <View style={styles.desktopControl}>{hostField}</View> : null}
|
||||
{isolationField ? <View style={styles.desktopControl}>{isolationField}</View> : null}
|
||||
{baseField ? <View style={styles.desktopControl}>{baseField}</View> : null}
|
||||
<View style={styles.launchSpacer} pointerEvents="none" />
|
||||
{launchField}
|
||||
{pickers}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
compactStack: {
|
||||
marginBottom: theme.spacing[4],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
reservedField: {
|
||||
height: FIELD_HEIGHT,
|
||||
},
|
||||
desktopRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
minHeight: FIELD_BADGE_HEIGHT,
|
||||
// Matches the gap between the composer and the Import session pill below it.
|
||||
marginBottom: theme.spacing[4],
|
||||
// The badge adds its own left padding; offset it so the project icon's left
|
||||
// edge lands exactly on the "New workspace" title's left edge. The trailing
|
||||
// inset mirrors it so the launch chip stops on the composer's inner content
|
||||
// rather than running out to the composer's border.
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
desktopControl: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
// Pushes the launch control to the trailing edge of the desktop meta row.
|
||||
launchSpacer: {
|
||||
flex: 1,
|
||||
},
|
||||
}));
|
||||
+26
-46
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "expo-router";
|
||||
import { ChevronDown, MessageCircle, SquareTerminal } from "lucide-react-native";
|
||||
import { MessageCircle, SquareTerminal } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -30,14 +30,17 @@ import {
|
||||
resolveLaunchProfile,
|
||||
terminalLaunchTarget,
|
||||
type LaunchTarget,
|
||||
} from "./target";
|
||||
} from "@/new-workspace-launch/target";
|
||||
import {
|
||||
FieldTriggerContent,
|
||||
useFieldTriggerStyle,
|
||||
type FieldTriggerVariant,
|
||||
} from "./field-trigger";
|
||||
|
||||
const ThemedMessageCircle = withUnistyles(MessageCircle);
|
||||
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const extraMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted });
|
||||
|
||||
const chatIcon = <ThemedMessageCircle size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
|
||||
const blankTerminalIcon = <ThemedSquareTerminal size={ICON_SIZE.sm} uniProps={mutedColorMapping} />;
|
||||
@@ -83,12 +86,7 @@ export interface LaunchControlProps {
|
||||
onChange: (target: LaunchTarget) => void;
|
||||
profiles: readonly TerminalProfile[];
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* The meta row's shared badge style. Passed in rather than redeclared so this
|
||||
* trigger stays pixel-identical to the project, host, and branch triggers it
|
||||
* sits beside; a local copy drifts the moment one of them changes.
|
||||
*/
|
||||
badgePressableStyle: React.ComponentProps<typeof Pressable>["style"];
|
||||
variant: FieldTriggerVariant;
|
||||
}
|
||||
|
||||
function TriggerIcon({
|
||||
@@ -108,8 +106,8 @@ function TriggerIcon({
|
||||
}
|
||||
|
||||
/**
|
||||
* The meta-row control choosing what New workspace submits to: the chat agent,
|
||||
* or a terminal. One trigger shaped like every other chip on that row, opening
|
||||
* The form control choosing what New workspace submits to: the chat agent,
|
||||
* or a terminal. One trigger shaped like every other field trigger, opening
|
||||
* a menu whose Chat and Terminal groups are what keep a terminal profile named
|
||||
* "Codex" from reading as a chat Codex agent.
|
||||
*/
|
||||
@@ -119,13 +117,18 @@ export function LaunchControl({
|
||||
onChange,
|
||||
profiles,
|
||||
disabled = false,
|
||||
badgePressableStyle,
|
||||
variant,
|
||||
}: LaunchControlProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedProfile = useMemo(() => resolveLaunchProfile(target, profiles), [target, profiles]);
|
||||
const triggerStyle = useFieldTriggerStyle(variant, disabled);
|
||||
const triggerIcon = useMemo(
|
||||
() => <TriggerIcon target={target} profile={selectedProfile} />,
|
||||
[selectedProfile, target],
|
||||
);
|
||||
|
||||
// A profile's own name and logo say nothing about where it runs: a row
|
||||
// reading "(openai logo) Codex" is indistinguishable from a chat Codex agent.
|
||||
@@ -149,32 +152,26 @@ export function LaunchControl({
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen} compactMode="sheet">
|
||||
{/* The trigger is the Pressable itself, styled by the meta row's shared
|
||||
badge style, so it lands pixel-identical to the project, host, and
|
||||
branch chips it sits beside. */}
|
||||
{/* MenuTrigger is not a forwardRef component and pins its own ref, so the
|
||||
tooltip anchors to a wrapping View instead. Suppressed while the menu
|
||||
is open so the two surfaces never overlap. */}
|
||||
<Tooltip delayDuration={300} enabledOnDesktop={!open}>
|
||||
<Tooltip delayDuration={300} enabledOnDesktop={!open && variant === "badge"}>
|
||||
<TooltipTrigger asChild>
|
||||
<View>
|
||||
<DropdownMenuTrigger
|
||||
testID="new-workspace-launch-trigger"
|
||||
disabled={disabled}
|
||||
style={badgePressableStyle}
|
||||
style={triggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("newWorkspace.tooltips.launch")}
|
||||
>
|
||||
<View style={styles.iconBox}>
|
||||
<TriggerIcon target={target} profile={selectedProfile} />
|
||||
</View>
|
||||
<Text style={styles.label} numberOfLines={1}>
|
||||
{triggerLabel}
|
||||
{selectedProfile ? (
|
||||
<Text style={styles.profileName}> {selectedProfile.name}</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={extraMutedColorMapping} />
|
||||
<FieldTriggerContent
|
||||
variant={variant}
|
||||
label={t("newWorkspace.launch.title")}
|
||||
icon={triggerIcon}
|
||||
value={triggerLabel}
|
||||
detail={selectedProfile?.name}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
</View>
|
||||
</TooltipTrigger>
|
||||
@@ -232,27 +229,10 @@ export function LaunchControl({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
iconBox: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
label: {
|
||||
minWidth: 0,
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.popoverForeground,
|
||||
},
|
||||
profileName: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foregroundExtraMuted,
|
||||
},
|
||||
command: {
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
fontSize: theme.fontSize.sm,
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { getHostProjectSourceDirectory, type HostProjectListItem } from "@/projects/host-projects";
|
||||
import { pickerItemLabel, type PickerItem } from "../../new-workspace-picker-item";
|
||||
import { addProjectIcon, branchIcon, folderIcon, ProjectIcon, pullRequestIcon } from "./icons";
|
||||
|
||||
interface OptionRowState {
|
||||
option: ComboboxOption;
|
||||
selected: boolean;
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
function IconBox({ children }: { children: ReactElement }): ReactElement {
|
||||
return <View style={styles.iconBox}>{children}</View>;
|
||||
}
|
||||
|
||||
export function ProjectOption({
|
||||
option,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
project,
|
||||
iconDataUri,
|
||||
selectedServerId,
|
||||
disabled,
|
||||
}: OptionRowState & {
|
||||
project: HostProjectListItem | undefined;
|
||||
iconDataUri: string | null;
|
||||
selectedServerId: string;
|
||||
disabled: boolean;
|
||||
}): ReactElement {
|
||||
const leadingSlot = useMemo(
|
||||
() =>
|
||||
project ? (
|
||||
<IconBox>
|
||||
<ProjectIcon
|
||||
projectViewKey={project.viewKey}
|
||||
projectName={project.projectName}
|
||||
iconDataUri={iconDataUri}
|
||||
/>
|
||||
</IconBox>
|
||||
) : undefined,
|
||||
[iconDataUri, project],
|
||||
);
|
||||
if (!project) return <View key={option.id} />;
|
||||
return (
|
||||
<ComboboxItem
|
||||
testID={`new-workspace-project-picker-option-${project.viewKey}`}
|
||||
label={project.projectName}
|
||||
description={
|
||||
getHostProjectSourceDirectory(project, selectedServerId) ?? project.iconWorkingDir
|
||||
}
|
||||
selected={selected}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
leadingSlot={leadingSlot}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddProjectOption({ onPress }: { onPress: () => void }): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const openProjectKeys = useShortcutKeys("new-agent");
|
||||
const shortcut = useMemo(
|
||||
() => (openProjectKeys ? <Shortcut chord={openProjectKeys} /> : null),
|
||||
[openProjectKeys],
|
||||
);
|
||||
return (
|
||||
<ComboboxItem
|
||||
testID="new-workspace-project-picker-add-project"
|
||||
label={t("sidebar.actions.addProject")}
|
||||
onPress={onPress}
|
||||
leadingSlot={addProjectIcon}
|
||||
trailingSlot={shortcut}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const worktreeLeading = <IconBox>{branchIcon}</IconBox>;
|
||||
const localLeading = <IconBox>{folderIcon}</IconBox>;
|
||||
|
||||
export function IsolationOption({
|
||||
option,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
disabled,
|
||||
}: OptionRowState & { disabled: boolean }): ReactElement {
|
||||
return (
|
||||
<ComboboxItem
|
||||
testID={`workspace-create-isolation-${option.id}`}
|
||||
label={option.label}
|
||||
selected={selected}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
leadingSlot={option.id === "worktree" ? worktreeLeading : localLeading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const branchLeading = <IconBox>{branchIcon}</IconBox>;
|
||||
const pullRequestLeading = <IconBox>{pullRequestIcon}</IconBox>;
|
||||
|
||||
export function RefOption({
|
||||
option,
|
||||
selected,
|
||||
active,
|
||||
onPress,
|
||||
item,
|
||||
disabled,
|
||||
}: OptionRowState & { item: PickerItem | undefined; disabled: boolean }): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const divergenceLabel = item?.kind === "branch" ? item.divergenceLabel : undefined;
|
||||
const trailingSlot = useMemo(
|
||||
() =>
|
||||
divergenceLabel ? <Text style={styles.divergenceLabel}>{divergenceLabel}</Text> : undefined,
|
||||
[divergenceLabel],
|
||||
);
|
||||
if (!item) return <View key={option.id} />;
|
||||
|
||||
const isBranch = item.kind === "branch";
|
||||
return (
|
||||
<ComboboxItem
|
||||
testID={
|
||||
isBranch
|
||||
? `new-workspace-ref-picker-branch-${item.name}`
|
||||
: `new-workspace-ref-picker-pr-${item.item.number}`
|
||||
}
|
||||
label={pickerItemLabel(item)}
|
||||
description={
|
||||
!isBranch && item.item.baseRefName
|
||||
? t("newWorkspace.refPicker.intoBase", { baseRef: item.item.baseRefName })
|
||||
: undefined
|
||||
}
|
||||
selected={selected}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
leadingSlot={isBranch ? branchLeading : pullRequestLeading}
|
||||
trailingSlot={trailingSlot}
|
||||
accessibilityLabel={isBranch ? item.accessibilityLabel : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
iconBox: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
divergenceLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontVariant: ["tabular-nums"],
|
||||
},
|
||||
}));
|
||||
@@ -5,7 +5,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* The new workspace screen's way into Import session: a ghost button at the top on compact
|
||||
* The new workspace screen's way into Import session: a ghost button in the header on compact
|
||||
* layouts, an outlined pill under the composer otherwise. Both are muted until hovered.
|
||||
*/
|
||||
export function ImportSessionButton({
|
||||
@@ -17,7 +17,7 @@ export function ImportSessionButton({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={compact ? styles.compact : styles.wide}>
|
||||
<View style={compact ? null : styles.wide}>
|
||||
<Button
|
||||
// Ghost for its muted text that turns foreground on hover; the pill adds the outline.
|
||||
variant="ghost"
|
||||
@@ -34,9 +34,6 @@ export function ImportSessionButton({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
compact: {
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
// The composer sits spacing[4] in from the dock and leaves spacing[4] below itself; the pill's
|
||||
// border lines up with the composer's border.
|
||||
wide: {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { AssistantFileLinkResolverProvider, type InlinePathTarget } from "@/assistant-file-links";
|
||||
import {
|
||||
AssistantFileLinkResolverProvider,
|
||||
createInlinePathOpener,
|
||||
type InlinePathTarget,
|
||||
} from "@/assistant-file-links";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
import {
|
||||
WorkspaceComposerEditingSessionsProvider,
|
||||
useComposerTabFullscreen,
|
||||
@@ -203,6 +208,7 @@ import { PluginHeaderButtons } from "@/plugins";
|
||||
import {
|
||||
createWorkspaceFileTabTarget,
|
||||
normalizeWorkspaceFileLocation,
|
||||
type OpenFileDisposition,
|
||||
type WorkspaceFileLocation,
|
||||
type WorkspaceFileOpenRequest,
|
||||
} from "@/workspace/file-open";
|
||||
@@ -3658,9 +3664,42 @@ function WorkspaceScreenContent({
|
||||
</View>
|
||||
);
|
||||
|
||||
const openMarkdownFile = useStableEvent((target: InlinePathTarget) => {
|
||||
handleOpenPreferredAssistantFile({ location: target, parentTabId: activeTabId });
|
||||
const { revealDirectory } = useFileExplorerActions({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
workspaceRoot: workspaceDirectory,
|
||||
});
|
||||
const openMarkdownFile = useStableEvent((location: WorkspaceFileLocation) => {
|
||||
handleOpenPreferredAssistantFile({ location, parentTabId: activeTabId });
|
||||
});
|
||||
const openMarkdownDirectory = useStableEvent((directoryPath: string) => {
|
||||
void revealDirectory(directoryPath);
|
||||
openExplorerSidebarView({
|
||||
isCompact: isMobile,
|
||||
workspaceKey: persistenceKey,
|
||||
checkout: activeExplorerCheckout,
|
||||
view: "files",
|
||||
});
|
||||
});
|
||||
const markdownPathOpener = useMemo(
|
||||
() =>
|
||||
createInlinePathOpener({
|
||||
workspaceRoot: workspaceDirectory ?? "",
|
||||
client,
|
||||
openFile: openMarkdownFile,
|
||||
openDirectory: openMarkdownDirectory,
|
||||
}),
|
||||
[workspaceDirectory, client, openMarkdownFile, openMarkdownDirectory],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isRouteFocused) markdownPathOpener.cancel();
|
||||
return () => markdownPathOpener.cancel();
|
||||
}, [markdownPathOpener, isRouteFocused, activeTabId]);
|
||||
const openMarkdownPath = useStableEvent(
|
||||
(target: InlinePathTarget, disposition: OpenFileDisposition) => {
|
||||
if (isRouteFocused) void markdownPathOpener.open(target, disposition);
|
||||
},
|
||||
);
|
||||
|
||||
const renderedWorkspaceScreen = (
|
||||
<RenderProfile id="WorkspaceScreenContent">
|
||||
@@ -3705,7 +3744,7 @@ function WorkspaceScreenContent({
|
||||
client={client}
|
||||
serverId={normalizedServerId}
|
||||
workspaceRoot={workspaceDirectory ?? undefined}
|
||||
onOpenWorkspaceFile={openMarkdownFile}
|
||||
onOpenWorkspaceFile={openMarkdownPath}
|
||||
toast={toast}
|
||||
>
|
||||
{renderedWorkspaceScreen}
|
||||
|
||||
@@ -741,7 +741,7 @@ export const darkPureBlackTheme = buildDarkTheme(pureBlackDarkColors);
|
||||
|
||||
const lightShadow = {
|
||||
sm: {
|
||||
shadowColor: "rgba(0, 0, 0, 0.02)",
|
||||
shadowColor: "rgba(0, 0, 0, 0.08)",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
|
||||
@@ -41,6 +41,93 @@ function rowWithText(
|
||||
}
|
||||
|
||||
describe("native terminal selection", () => {
|
||||
it.each([
|
||||
{ startCol: 1, endCol: 8, expected: "한글 문장" },
|
||||
{ startCol: 3, endCol: 8, expected: "글 문장" },
|
||||
{ startCol: 1, endCol: 1, expected: "한" },
|
||||
{ startCol: 0, endCol: 0, expected: "한" },
|
||||
{ startCol: 0, endCol: 1, expected: "한" },
|
||||
{ startCol: 4, endCol: 6, expected: " 문" },
|
||||
])(
|
||||
"copies complete wide characters for selected columns $startCol through $endCol",
|
||||
async ({ startCol, endCol, expected }) => {
|
||||
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
|
||||
const copied: string[] = [];
|
||||
try {
|
||||
await terminal.write("한글 문장");
|
||||
const bounds = terminal.getBufferBounds();
|
||||
const selection = createTerminalSelectionModel();
|
||||
selection.begin({
|
||||
coordinate: { row: bounds.cursorRow, col: endCol },
|
||||
bounds,
|
||||
});
|
||||
selection.update({
|
||||
coordinate: { row: bounds.cursorRow, col: startCol },
|
||||
bounds,
|
||||
});
|
||||
|
||||
await copyTerminalSelection({
|
||||
terminal,
|
||||
selection: selection.getSnapshot().range,
|
||||
clipboard: {
|
||||
writeText: async (text) => {
|
||||
copied.push(text);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(copied).toEqual([expected]);
|
||||
} finally {
|
||||
terminal.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("copies Korean output without adding spaces for continuation cells", async () => {
|
||||
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
|
||||
const copied: string[] = [];
|
||||
try {
|
||||
await terminal.write("한글 문장");
|
||||
const bounds = terminal.getBufferBounds();
|
||||
await copyTerminalSelection({
|
||||
terminal,
|
||||
selection: {
|
||||
start: { row: bounds.cursorRow, col: 0 },
|
||||
end: { row: bounds.cursorRow, col: bounds.cursorCol - 1 },
|
||||
coordinateEpoch: bounds.coordinateEpoch,
|
||||
},
|
||||
clipboard: {
|
||||
writeText: async (text) => {
|
||||
copied.push(text);
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(copied).toEqual(["한글 문장"]);
|
||||
} finally {
|
||||
terminal.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("copies Korean text and real spaces without wide-character continuation spaces", async () => {
|
||||
const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 });
|
||||
try {
|
||||
await terminal.write("한글 문장 | 성공");
|
||||
const bounds = terminal.getBufferBounds();
|
||||
expect(
|
||||
extractTerminalSelectedText({
|
||||
terminal,
|
||||
selection: {
|
||||
start: { row: bounds.cursorRow, col: 0 },
|
||||
end: { row: bounds.cursorRow, col: bounds.cursorCol - 1 },
|
||||
coordinateEpoch: bounds.coordinateEpoch,
|
||||
},
|
||||
}),
|
||||
).toBe("한글 문장 | 성공");
|
||||
} finally {
|
||||
terminal.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("expands a long-press coordinate to the whole terminal word", async () => {
|
||||
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 32, scrollbackLines: 20 });
|
||||
await terminal.write("before PASEO_TARGET after\r\n");
|
||||
|
||||
@@ -324,10 +324,17 @@ export function extractTerminalSelectedText(input: TerminalSelectedTextInput): s
|
||||
const cells = window.rows[offset] ?? [];
|
||||
const startsOnThisRow = row === selection.start.row;
|
||||
const endsOnThisRow = row === selection.end.row;
|
||||
const startCol = startsOnThisRow ? selection.start.col : 0;
|
||||
let startCol = Math.max(0, startsOnThisRow ? selection.start.col : 0);
|
||||
// Selecting either half highlights the whole wide character, so copy its leading cell too.
|
||||
if (startCol > 0 && cells[startCol]?.width === 0) {
|
||||
startCol -= 1;
|
||||
}
|
||||
const endCol = endsOnThisRow ? selection.end.col : cells.length - 1;
|
||||
const selectedCells = cells.slice(Math.max(0, startCol), Math.max(0, endCol) + 1);
|
||||
const line = selectedCells.map((cell) => cell.char || " ").join("");
|
||||
const selectedCells = cells.slice(startCol, Math.max(0, endCol) + 1);
|
||||
const line = selectedCells
|
||||
.filter((cell) => cell.width !== 0)
|
||||
.map((cell) => cell.char)
|
||||
.join("");
|
||||
if (offset > 0 && window.wrappedRows[offset] !== true) {
|
||||
text += "\n";
|
||||
}
|
||||
|
||||
@@ -77,6 +77,90 @@ function applySynced(agentId: string, seq: number): void {
|
||||
afterEach(() => useSessionStore.getState().clearSession(SERVER_ID));
|
||||
|
||||
describe("viewed timeline persistence", () => {
|
||||
it("keeps one complete reply when live chunks arrive before a cached chat catches up", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
const partial = "Paragraph 1. Paragraph 2.";
|
||||
const complete = `${partial} Paragraph 3. END`;
|
||||
const owner = createOwner({
|
||||
readTimeline: async () => ({
|
||||
agentId: AGENT_ID,
|
||||
items: [{ ...item("reply-1", partial, 4), messageId: "reply-1" }],
|
||||
range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 },
|
||||
hasOlder: false,
|
||||
}),
|
||||
commitTimeline: () => undefined,
|
||||
});
|
||||
try {
|
||||
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID)
|
||||
.status,
|
||||
)
|
||||
.toBe("painted");
|
||||
owner.enqueueStreamEvent(AGENT_ID, {
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: " Paragraph 3.", messageId: "reply-1" },
|
||||
} as AgentStreamEventPayload,
|
||||
seq: 5,
|
||||
epoch: "epoch-1",
|
||||
timestamp: new Date("2026-08-26T10:00:01.000Z"),
|
||||
});
|
||||
owner.flushStreamAgent(AGENT_ID);
|
||||
owner.applyTimelineResponse({
|
||||
requestId: "resume",
|
||||
agentId: AGENT_ID,
|
||||
agent: null,
|
||||
direction: "after",
|
||||
projection: "projected",
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
window: { minSeq: 1, maxSeq: 6, nextSeq: 7 },
|
||||
startCursor: { epoch: "epoch-1", seq: 5 },
|
||||
endCursor: { epoch: "epoch-1", seq: 6 },
|
||||
entries: [
|
||||
{
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: complete, messageId: "reply-1" },
|
||||
timestamp: "2026-08-26T10:00:02.000Z",
|
||||
seqStart: 2,
|
||||
seqEnd: 6,
|
||||
sourceSeqRanges: [{ startSeq: 2, endSeq: 6 }],
|
||||
collapsed: ["assistant_merge"],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
hasNewer: false,
|
||||
hasOlder: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
});
|
||||
const replyTexts = () => {
|
||||
const session = useSessionStore.getState().sessions[SERVER_ID];
|
||||
return [
|
||||
...(session?.agentStreamTail.get(AGENT_ID) ?? []),
|
||||
...(session?.agentStreamHead.get(AGENT_ID) ?? []),
|
||||
]
|
||||
.filter((entry) => entry.kind === "assistant_message")
|
||||
.map((entry) => entry.text);
|
||||
};
|
||||
expect.soft(replyTexts()).toEqual([complete]);
|
||||
owner.enqueueStreamEvent(AGENT_ID, {
|
||||
event: { type: "turn_completed", provider: "codex" } as AgentStreamEventPayload,
|
||||
seq: 7,
|
||||
epoch: "epoch-1",
|
||||
timestamp: new Date("2026-08-26T10:00:03.000Z"),
|
||||
});
|
||||
owner.flushStreamAgent(AGENT_ID);
|
||||
expect.soft(replyTexts()).toEqual([complete]);
|
||||
} finally {
|
||||
owner.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("shares an in-flight cache preparation with the viewed owner", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
let release!: (value: CachedTimeline) => void;
|
||||
@@ -595,84 +679,6 @@ function createSqliteCache() {
|
||||
}
|
||||
|
||||
describe("SQLite baseline restoration", () => {
|
||||
it("does not duplicate a conversation restored from display-only SQLite history", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
const { cache, database } = createSqliteCache();
|
||||
cache.setHosts([SERVER_ID]);
|
||||
const timestamp = new Date("2026-08-26T10:00:00.000Z");
|
||||
cache.commitTimeline(SERVER_ID, AGENT_ID, {
|
||||
agentId: AGENT_ID,
|
||||
items: [
|
||||
{ kind: "user_message", id: "prompt", messageId: "prompt", text: "Say BASS_OK", timestamp },
|
||||
{ kind: "assistant_message", id: "reply", text: "BASS_OK", timestamp },
|
||||
],
|
||||
range: null,
|
||||
hasOlder: false,
|
||||
});
|
||||
await cache.flush();
|
||||
const owner = createOwner(cache);
|
||||
try {
|
||||
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID)
|
||||
.status,
|
||||
)
|
||||
.toBe("painted");
|
||||
owner.applyTimelineResponse({
|
||||
requestId: "reopened-tail",
|
||||
agentId: AGENT_ID,
|
||||
agent: null,
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
reset: false,
|
||||
epoch: "new-epoch",
|
||||
window: { minSeq: 1, maxSeq: 2, nextSeq: 3 },
|
||||
startCursor: { epoch: "new-epoch", seq: 1 },
|
||||
endCursor: { epoch: "new-epoch", seq: 2 },
|
||||
entries: [
|
||||
{
|
||||
provider: "test",
|
||||
item: { type: "user_message", messageId: "prompt", text: "Say BASS_OK" },
|
||||
timestamp: timestamp.toISOString(),
|
||||
seqStart: 1,
|
||||
seqEnd: 1,
|
||||
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
|
||||
collapsed: [],
|
||||
},
|
||||
{
|
||||
provider: "test",
|
||||
item: { type: "assistant_message", messageId: "reply", text: "BASS_OK" },
|
||||
timestamp: timestamp.toISOString(),
|
||||
seqStart: 2,
|
||||
seqEnd: 2,
|
||||
sourceSeqRanges: [{ startSeq: 2, endSeq: 2 }],
|
||||
collapsed: [],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
hasNewer: false,
|
||||
hasOlder: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
});
|
||||
const timeline = selectAgentTimelineState(
|
||||
useSessionStore.getState().sessions[SERVER_ID],
|
||||
AGENT_ID,
|
||||
);
|
||||
expect(timeline).toMatchObject({
|
||||
status: "synced",
|
||||
items: [
|
||||
{ kind: "user_message", text: "Say BASS_OK" },
|
||||
{ kind: "assistant_message", text: "BASS_OK" },
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
owner.dispose();
|
||||
database.close();
|
||||
}
|
||||
});
|
||||
it.each([false, true])(
|
||||
"keeps display-only cached history under a live event (synced=%s)",
|
||||
async (synced) => {
|
||||
|
||||
@@ -811,7 +811,7 @@ function reconcileOverlappingProjectedAssistant(params: {
|
||||
};
|
||||
|
||||
const headMatch = findMatch(params.head);
|
||||
const tailMatch = headMatch ? null : findMatch(params.tail);
|
||||
const tailMatch = findMatch(params.tail);
|
||||
const match = headMatch ?? tailMatch;
|
||||
if (!match) {
|
||||
return { tail: params.tail, head: params.head, reconciled: false };
|
||||
@@ -833,8 +833,20 @@ function reconcileOverlappingProjectedAssistant(params: {
|
||||
};
|
||||
|
||||
if (headMatch) {
|
||||
const tailCursor = tailMatch?.current.timelineCursor;
|
||||
const replacesCachedPrefix =
|
||||
tailMatch !== null &&
|
||||
tailCursor?.epoch === params.epoch &&
|
||||
unit.sourceSeqRanges.some(
|
||||
(range) => range.startSeq <= tailCursor.seq && tailCursor.seq <= range.endSeq,
|
||||
);
|
||||
return {
|
||||
tail: params.tail,
|
||||
// Live chunks can arrive before catch-up while their cached prefix is still painted.
|
||||
// The projected reply owns both copies; keeping the prefix would duplicate it and
|
||||
// cause completion to discard the updated head as an already-committed item.
|
||||
tail: replacesCachedPrefix
|
||||
? [...params.tail.slice(0, tailMatch.index), ...params.tail.slice(tailMatch.index + 1)]
|
||||
: params.tail,
|
||||
head: replaceMatch(params.head, headMatch.index),
|
||||
reconciled: true,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"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.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/server": "0.12.0-beta.1",
|
||||
"@getpaseo/client": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@getpaseo/server": "0.12.0-beta.2",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -2,6 +2,22 @@ import { describe, expect, it } from "vitest";
|
||||
import { createCli } from "./cli.js";
|
||||
|
||||
describe("canonical CLI surface", () => {
|
||||
it.each(["send", "agent send"])("%s accepts an explicit behavior override", async (path) => {
|
||||
const cli = createCli();
|
||||
const owner =
|
||||
path === "send" ? cli : cli.commands.find((command) => command.name() === "agent")!;
|
||||
const send = owner.commands.find((command) => command.name() === "send")!;
|
||||
send.action(() => {});
|
||||
await send.parseAsync(["target", "message", "--behavior", "interrupt"], { from: "user" });
|
||||
expect(send.opts().behavior).toBe("interrupt");
|
||||
await send.parseAsync(["target", "message", "--behavior", "steer"], { from: "user" });
|
||||
expect(send.opts().behavior).toBe("steer");
|
||||
send.exitOverride().configureOutput({ writeErr: () => {} });
|
||||
await expect(
|
||||
send.parseAsync(["target", "message", "--behavior", "queue"], { from: "user" }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("offers daemon host selection as a global option", () => {
|
||||
expect(createCli().helpInformation()).toContain("--host <host>");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type Command } from "commander";
|
||||
import { type Command, Option } from "commander";
|
||||
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
|
||||
import { collectMultiple } from "../../utils/command-options.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type {
|
||||
@@ -28,6 +29,7 @@ export const agentSendSchema: OutputSchema<AgentSendResult> = {
|
||||
};
|
||||
|
||||
export interface AgentSendOptions extends CommandOptions {
|
||||
behavior?: ActiveTurnBehavior;
|
||||
wait?: boolean;
|
||||
image?: string[];
|
||||
prompt?: string;
|
||||
@@ -42,6 +44,12 @@ export function addSendOptions(cmd: Command): Command {
|
||||
.option("--prompt <text>", "Provide the message inline as a flag")
|
||||
.option("--prompt-file <path>", "Read the message from a UTF-8 text file")
|
||||
.option("--image <path>", "Attach image(s) to the message", collectMultiple, [])
|
||||
.addOption(
|
||||
new Option(
|
||||
"--behavior <behavior>",
|
||||
"Active-turn behavior (agent default: steer; otherwise: interrupt)",
|
||||
).choices(["interrupt", "steer"]),
|
||||
)
|
||||
.option("--no-wait", "Return immediately without waiting for completion");
|
||||
}
|
||||
|
||||
@@ -191,10 +199,12 @@ export async function runSendCommand(
|
||||
const images =
|
||||
options.image && options.image.length > 0 ? await readImageFiles(options.image) : undefined;
|
||||
|
||||
const sourceAgentId = process.env.PASEO_AGENT_ID?.trim() || undefined;
|
||||
// Send the message
|
||||
await client.sendAgentMessage(agentIdArg, promptInput, {
|
||||
images,
|
||||
sourceAgentId: process.env.PASEO_AGENT_ID?.trim() || undefined,
|
||||
sourceAgentId,
|
||||
activeTurnBehavior: options.behavior ?? (sourceAgentId ? "steer" : "interrupt"),
|
||||
});
|
||||
|
||||
// If --no-wait, return immediately
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -39,8 +39,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/relay": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@getpaseo/relay": "0.12.0-beta.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"description": "Paseo plugin SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -70,8 +70,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/client": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -81,8 +81,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/client": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -107,11 +107,11 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.12.0-beta.1",
|
||||
"@getpaseo/highlight": "0.12.0-beta.1",
|
||||
"@getpaseo/plugin": "0.12.0-beta.1",
|
||||
"@getpaseo/protocol": "0.12.0-beta.1",
|
||||
"@getpaseo/relay": "0.12.0-beta.1",
|
||||
"@getpaseo/client": "0.12.0-beta.2",
|
||||
"@getpaseo/highlight": "0.12.0-beta.2",
|
||||
"@getpaseo/plugin": "0.12.0-beta.2",
|
||||
"@getpaseo/protocol": "0.12.0-beta.2",
|
||||
"@getpaseo/relay": "0.12.0-beta.2",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -402,6 +402,8 @@ export interface SetupFinishNotificationParams {
|
||||
childAgentId: string;
|
||||
callerAgentId: string;
|
||||
requireParentOwnership?: boolean;
|
||||
/** Retain pre-dispatch outcomes only when dispatch keeps the existing turn. */
|
||||
dispatchResult?: Promise<PromptDispatchDisposition | false>;
|
||||
/** A replacement must observe its new start, not the displaced running snapshot. */
|
||||
waitForTurnStart?: boolean;
|
||||
logger: Logger;
|
||||
@@ -466,10 +468,17 @@ interface NotifySafelyOptions {
|
||||
permissionRequest?: AgentPermissionRequest;
|
||||
}
|
||||
|
||||
// A caller waits on a child through one armed notification. Arming again, such as a
|
||||
// follow-up prompt while the child still runs, replaces the earlier one so the child's
|
||||
// next finish reaches the caller once.
|
||||
const armedFinishNotifications = new WeakMap<AgentManager, Map<string, () => void>>();
|
||||
// Pending follow-ups coexist until acknowledgement. A successful dispatch replaces
|
||||
// every earlier observation for the caller/child pair; a failure removes only itself.
|
||||
interface ArmedFinishNotification {
|
||||
stop(): void;
|
||||
supersedeWhen(accepted: Promise<boolean>): void;
|
||||
}
|
||||
|
||||
const armedFinishNotifications = new WeakMap<
|
||||
AgentManager,
|
||||
Map<string, Set<ArmedFinishNotification>>
|
||||
>();
|
||||
|
||||
export function setupFinishNotification(params: SetupFinishNotificationParams): () => void {
|
||||
const {
|
||||
@@ -491,15 +500,49 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
||||
const armedByManager = armedFinishNotifications.get(agentManager) ?? new Map();
|
||||
armedFinishNotifications.set(agentManager, armedByManager);
|
||||
const armedKey = JSON.stringify([childAgentId, callerAgentId]);
|
||||
armedByManager.get(armedKey)?.();
|
||||
armedByManager.set(armedKey, stop);
|
||||
const registrations = armedByManager.get(armedKey) ?? new Set<ArmedFinishNotification>();
|
||||
armedByManager.set(armedKey, registrations);
|
||||
const previous = [...registrations];
|
||||
let superseded = Promise.resolve(false);
|
||||
let dispatchPending = Boolean(params.dispatchResult);
|
||||
const registration: ArmedFinishNotification = {
|
||||
stop,
|
||||
supersedeWhen(accepted) {
|
||||
superseded = superseded.then(
|
||||
async (alreadySuperseded) => alreadySuperseded || (await accepted),
|
||||
);
|
||||
},
|
||||
};
|
||||
registrations.add(registration);
|
||||
if (params.dispatchResult) {
|
||||
const dispatchAccepted = params.dispatchResult.then((result) => result !== false);
|
||||
for (const observer of previous) observer.supersedeWhen(dispatchAccepted);
|
||||
void params.dispatchResult.then((accepted) => {
|
||||
dispatchPending = false;
|
||||
if (accepted) {
|
||||
for (const observer of previous) observer.stop();
|
||||
if (stopped || accepted === "turn_started") stop();
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
} else {
|
||||
for (const observer of previous) {
|
||||
observer.supersedeWhen(Promise.resolve(true));
|
||||
observer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
unsubscribe?.();
|
||||
if (armedByManager.get(armedKey) === stop) {
|
||||
armedByManager.delete(armedKey);
|
||||
// Pending observations remain visible to overlapping sends until acknowledgement.
|
||||
if (!dispatchPending) {
|
||||
registrations.delete(registration);
|
||||
if (registrations.size === 0 && armedByManager.get(armedKey) === registrations) {
|
||||
armedByManager.delete(armedKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,6 +550,11 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
|
||||
reason: FinishNotificationReason,
|
||||
permissionRequest?: AgentPermissionRequest,
|
||||
): Promise<void> {
|
||||
if (params.dispatchResult) {
|
||||
const result = await params.dispatchResult;
|
||||
if (result === false || result === "turn_started") return;
|
||||
}
|
||||
if (await superseded) return;
|
||||
const callerRecord = await agentStorage.get(callerAgentId);
|
||||
if (callerRecord?.archivedAt) {
|
||||
return;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { setupFinishNotification, type PromptDispatchDisposition } from "./agent-prompt.js";
|
||||
import { parseAgentMessage } from "./agent-messages/index.js";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -242,6 +243,7 @@ function buildAgentManagerSpies() {
|
||||
appendTimelineItem: vi.fn().mockResolvedValue(undefined),
|
||||
emitLiveTimelineItem: vi.fn().mockResolvedValue(undefined),
|
||||
hasInFlightRun: vi.fn().mockReturnValue(false),
|
||||
steerOrReplaceActiveTurn: async () => ({ status: "inactive" as const }),
|
||||
tryRunOutOfBand: vi.fn().mockReturnValue(false),
|
||||
subscribe: vi.fn().mockReturnValue(() => {}),
|
||||
streamAgent: vi.fn(() => (async function* noop() {})()),
|
||||
@@ -3860,6 +3862,9 @@ const HELD_TURN_CAPABILITIES = {
|
||||
} as const;
|
||||
|
||||
interface HeldTurnOptions {
|
||||
finishDuringSteer?: boolean;
|
||||
rejectSteer?: boolean;
|
||||
steerResult?: "accepted" | "unavailable";
|
||||
/** Run the whole turn before the turn-start request returns. */
|
||||
finishTurnsDuringStart?: boolean;
|
||||
}
|
||||
@@ -3872,6 +3877,7 @@ class HeldTurnAgentSession implements AgentSession {
|
||||
readonly capabilities = HELD_TURN_CAPABILITIES;
|
||||
readonly id = randomUUID();
|
||||
readonly prompts: string[] = [];
|
||||
readonly steeredPrompts: AgentPromptInput[] = [];
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
private activeTurnId: string | null = null;
|
||||
private turnStartGate: Promise<void> | null = null;
|
||||
@@ -3904,6 +3910,14 @@ class HeldTurnAgentSession implements AgentSession {
|
||||
return prompt === "/side-command" ? { run: async () => {} } : null;
|
||||
}
|
||||
|
||||
async steerActiveTurn(prompt: AgentPromptInput) {
|
||||
const status = this.options.steerResult ?? "unavailable";
|
||||
if (status === "accepted") this.steeredPrompts.push(prompt);
|
||||
if (status === "accepted" && this.options.finishDuringSteer) this.finishTurn();
|
||||
if (this.options.rejectSteer) throw new Error("Steer rejected");
|
||||
return { status };
|
||||
}
|
||||
|
||||
async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||
this.prompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt));
|
||||
await this.turnStartGate;
|
||||
@@ -4050,6 +4064,295 @@ class HeldTurnAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
describe("send_agent_prompt MCP tool", () => {
|
||||
it.each([
|
||||
{ agentScoped: true, behavior: undefined, available: true, steered: true },
|
||||
{ agentScoped: true, behavior: "interrupt", available: true, steered: false },
|
||||
{ agentScoped: true, behavior: "steer", available: true, steered: true },
|
||||
{ agentScoped: false, behavior: undefined, available: true, steered: false },
|
||||
{ agentScoped: false, behavior: "steer", available: true, steered: true },
|
||||
{ agentScoped: true, behavior: undefined, available: false, steered: false },
|
||||
] as const)(
|
||||
"dispatches behavior $behavior for agent-scoped=$agentScoped, steering available=$available",
|
||||
async ({ agentScoped, behavior, available, steered }) => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new HeldTurnAgentClient("codex", true, {
|
||||
steerResult: available ? "accepted" : "unavailable",
|
||||
});
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
try {
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: existingCwd },
|
||||
undefined,
|
||||
{ workspaceId: "wks_parent" },
|
||||
);
|
||||
const child = await manager.createAgent(
|
||||
{ provider: "codex", cwd: existingCwd },
|
||||
undefined,
|
||||
{ workspaceId: "wks_parent" },
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
callerAgentId: agentScoped ? parent.id : undefined,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "send_agent_prompt");
|
||||
await invokeToolWithParsedInput(tool, {
|
||||
agentId: child.id,
|
||||
prompt: "Original work",
|
||||
notifyOnFinish: false,
|
||||
});
|
||||
const originalTurn = manager.getAgent(child.id)!.activeTurnId;
|
||||
const response = await invokeToolWithParsedInput(tool, {
|
||||
agentId: child.id,
|
||||
prompt: "New message",
|
||||
notifyOnFinish: false,
|
||||
...(behavior ? { activeTurnBehavior: behavior } : {}),
|
||||
});
|
||||
expect(response.structuredContent).toMatchObject({
|
||||
success: true,
|
||||
disposition: steered ? "steered" : "turn_started",
|
||||
});
|
||||
expect(client.sessions[1]!.prompts).toHaveLength(steered ? 1 : 2);
|
||||
expect(client.sessions[1]!.steeredPrompts).toHaveLength(steered ? 1 : 0);
|
||||
expect(manager.getAgent(child.id)!.activeTurnId === originalTurn).toBe(steered);
|
||||
} finally {
|
||||
await removeAgentStateDir(manager, storage, workdir);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ rejected: false, priorNotification: false },
|
||||
{ rejected: true, priorNotification: false },
|
||||
{ rejected: false, priorNotification: true },
|
||||
])(
|
||||
"buffers completion until steer acknowledgement (rejected: $rejected, prior notification: $priorNotification)",
|
||||
async ({ rejected, priorNotification }) => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new HeldTurnAgentClient("codex", true, {
|
||||
steerResult: "accepted",
|
||||
finishDuringSteer: true,
|
||||
rejectSteer: rejected,
|
||||
});
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
let mcpClient: Client | undefined;
|
||||
try {
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: existingCwd },
|
||||
undefined,
|
||||
{ workspaceId: "wks_parent" },
|
||||
);
|
||||
const child = await manager.createAgent(
|
||||
{ provider: "codex", cwd: existingCwd },
|
||||
undefined,
|
||||
{ workspaceId: "wks_parent" },
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
callerAgentId: parent.id,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
mcpClient = await connectInMemoryMcpClient(server);
|
||||
const send = (input: Record<string, unknown>) =>
|
||||
mcpClient!.callTool({ name: "send_agent_prompt", arguments: input });
|
||||
await send({
|
||||
agentId: child.id,
|
||||
prompt: "Original work",
|
||||
notifyOnFinish: priorNotification,
|
||||
});
|
||||
const sending = send({
|
||||
agentId: child.id,
|
||||
prompt: "New message",
|
||||
notifyOnFinish: true,
|
||||
});
|
||||
if (rejected) {
|
||||
expect(await sending).toMatchObject({
|
||||
isError: true,
|
||||
content: [{ type: "text", text: expect.stringContaining("Steer rejected") }],
|
||||
});
|
||||
// Flush asynchronous notification work before checking that a rejected
|
||||
// dispatch did not notify. Then prove a later successful send still can.
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(client.sessions[0]!.prompts).toHaveLength(0);
|
||||
await send({
|
||||
agentId: child.id,
|
||||
prompt: "Retry after rejection",
|
||||
});
|
||||
client.sessions[1]!.finishTurn();
|
||||
} else {
|
||||
const response = await sending;
|
||||
expect(response.structuredContent).toMatchObject({
|
||||
success: true,
|
||||
disposition: "steered",
|
||||
});
|
||||
}
|
||||
await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1));
|
||||
expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({
|
||||
source: { kind: "agent-notification", agentId: child.id, event: "finished" },
|
||||
});
|
||||
} finally {
|
||||
await mcpClient?.close();
|
||||
await removeAgentStateDir(manager, storage, workdir);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
"preserves the original notification after rejection (completion during send: %s)",
|
||||
async (finishDuringSteer) => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new HeldTurnAgentClient("codex", true, {
|
||||
steerResult: "accepted",
|
||||
finishDuringSteer,
|
||||
rejectSteer: true,
|
||||
});
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
let mcpClient: Client | undefined;
|
||||
try {
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: existingCwd },
|
||||
undefined,
|
||||
{
|
||||
workspaceId: "wks_parent",
|
||||
},
|
||||
);
|
||||
const child = await manager.createAgent(
|
||||
{ provider: "codex", cwd: existingCwd },
|
||||
undefined,
|
||||
{
|
||||
workspaceId: "wks_parent",
|
||||
},
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
callerAgentId: parent.id,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
mcpClient = await connectInMemoryMcpClient(server);
|
||||
const send = (input: Record<string, unknown>) =>
|
||||
mcpClient!.callTool({ name: "send_agent_prompt", arguments: input });
|
||||
await send({
|
||||
agentId: child.id,
|
||||
prompt: "Original work",
|
||||
notifyOnFinish: true,
|
||||
});
|
||||
const sending = send({
|
||||
agentId: child.id,
|
||||
prompt: "New message",
|
||||
notifyOnFinish: true,
|
||||
});
|
||||
expect(await sending).toMatchObject({
|
||||
isError: true,
|
||||
content: [{ type: "text", text: expect.stringContaining("Steer rejected") }],
|
||||
});
|
||||
if (!finishDuringSteer) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(client.sessions[0]!.prompts).toHaveLength(0);
|
||||
client.sessions[1]!.finishTurn();
|
||||
}
|
||||
await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1));
|
||||
expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({
|
||||
source: { kind: "agent-notification", agentId: child.id, event: "finished" },
|
||||
});
|
||||
} finally {
|
||||
await mcpClient?.close();
|
||||
await removeAgentStateDir(manager, storage, workdir);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[false, true],
|
||||
[true, false],
|
||||
[false, false],
|
||||
[true, true],
|
||||
])(
|
||||
"overlapping follow-ups notify once (first accepted: %s, second accepted: %s)",
|
||||
async (firstAccepted, secondAccepted) => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new HeldTurnAgentClient("codex", true, {
|
||||
steerResult: "accepted",
|
||||
finishDuringSteer: false,
|
||||
rejectSteer: true,
|
||||
});
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
let mcpClient: Client | undefined;
|
||||
try {
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: existingCwd },
|
||||
undefined,
|
||||
{
|
||||
workspaceId: "wks_parent",
|
||||
},
|
||||
);
|
||||
const child = await manager.createAgent(
|
||||
{ provider: "codex", cwd: existingCwd },
|
||||
undefined,
|
||||
{
|
||||
workspaceId: "wks_parent",
|
||||
},
|
||||
);
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
callerAgentId: parent.id,
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
logger,
|
||||
});
|
||||
mcpClient = await connectInMemoryMcpClient(server);
|
||||
const send = (input: Record<string, unknown>) =>
|
||||
mcpClient!.callTool({ name: "send_agent_prompt", arguments: input });
|
||||
await send({
|
||||
agentId: child.id,
|
||||
prompt: "Original work",
|
||||
notifyOnFinish: true,
|
||||
});
|
||||
let firstResolve!: (result: PromptDispatchDisposition | false) => void;
|
||||
let secondResolve!: (result: PromptDispatchDisposition | false) => void;
|
||||
const first = new Promise<PromptDispatchDisposition | false>((resolve) => {
|
||||
firstResolve = resolve;
|
||||
});
|
||||
const second = new Promise<PromptDispatchDisposition | false>((resolve) => {
|
||||
secondResolve = resolve;
|
||||
});
|
||||
for (const dispatchResult of [first, second]) {
|
||||
setupFinishNotification({
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
childAgentId: child.id,
|
||||
callerAgentId: parent.id,
|
||||
dispatchResult,
|
||||
logger,
|
||||
});
|
||||
}
|
||||
firstResolve(firstAccepted ? "steered" : false);
|
||||
await first;
|
||||
secondResolve(secondAccepted ? "steered" : false);
|
||||
await second;
|
||||
client.sessions[1]!.finishTurn();
|
||||
await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1));
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
expect(client.sessions[0]!.prompts).toHaveLength(1);
|
||||
expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({
|
||||
source: { kind: "agent-notification", agentId: child.id, event: "finished" },
|
||||
});
|
||||
} finally {
|
||||
await mcpClient?.close();
|
||||
await removeAgentStateDir(manager, storage, workdir);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const logger = createTestLogger();
|
||||
const existingCwd = process.cwd();
|
||||
|
||||
@@ -4100,7 +4403,7 @@ describe("send_agent_prompt MCP tool", () => {
|
||||
|
||||
const response = await tool.handler(parsed.data as Record<string, unknown>);
|
||||
|
||||
expect(spies.agentManager.subscribe).toHaveBeenCalledTimes(1);
|
||||
expect(spies.agentManager.subscribe).toHaveBeenCalled();
|
||||
expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled();
|
||||
expect(response.structuredContent.guidance).toBe(
|
||||
"You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.",
|
||||
@@ -4270,7 +4573,7 @@ describe("send_agent_prompt MCP tool", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["out_of_band", "startup_failure"] as const)(
|
||||
it.each(["out_of_band", "out_of_band_interrupt", "startup_failure"] as const)(
|
||||
"preserves %s dispatch notification semantics",
|
||||
async (scenario) => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "mcp-dispatch-notification-"));
|
||||
@@ -4306,10 +4609,11 @@ describe("send_agent_prompt MCP tool", () => {
|
||||
prompt: "Ongoing task",
|
||||
notifyOnFinish: false,
|
||||
});
|
||||
if (scenario === "out_of_band") {
|
||||
if (scenario !== "startup_failure") {
|
||||
const response = await invokeToolWithParsedInput(tool, {
|
||||
agentId: child.id,
|
||||
prompt: "/side-command",
|
||||
activeTurnBehavior: scenario === "out_of_band_interrupt" ? "interrupt" : "steer",
|
||||
});
|
||||
expect(response.structuredContent).toMatchObject({
|
||||
success: true,
|
||||
|
||||
@@ -1617,7 +1617,12 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
configId: "allow_all",
|
||||
value: "on",
|
||||
});
|
||||
expect(setSessionMode).not.toHaveBeenCalled();
|
||||
// Allow All is agent mode with permissions pre-granted, so it has to leave
|
||||
// whatever mode the session is in, not just flip the flag.
|
||||
expect(setSessionMode).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
modeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
||||
});
|
||||
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
|
||||
expect(events.some((event) => event.type === "permission_requested")).toBe(false);
|
||||
});
|
||||
@@ -1648,7 +1653,12 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
configId: "allow_all",
|
||||
value: "on",
|
||||
});
|
||||
expect(setSessionMode).not.toHaveBeenCalled();
|
||||
// Allow All is agent mode with permissions pre-granted, so it has to leave
|
||||
// whatever mode the session is in, not just flip the flag.
|
||||
expect(setSessionMode).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
modeId: "https://agentclientprotocol.com/protocol/session-modes#agent",
|
||||
});
|
||||
await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID);
|
||||
});
|
||||
|
||||
|
||||
@@ -575,6 +575,36 @@ describe("ClaudeAgentClient binary resolution", () => {
|
||||
await expect(resolveClaudeCodeVersion()).resolves.toMatch(/^\d+\.\d+\.\d+$/);
|
||||
});
|
||||
|
||||
test("rejects a wrapper command's own version output instead of reading it as Claude Code's", async () => {
|
||||
const wrapperDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-version-"));
|
||||
const wrapperPath = path.join(wrapperDir, "wrapper");
|
||||
await fs.writeFile(wrapperPath, 'console.log("cux 0.4.1");\n');
|
||||
try {
|
||||
await expect(
|
||||
resolveClaudeCodeVersion({
|
||||
command: { mode: "replace", argv: [process.execPath, wrapperPath] },
|
||||
}),
|
||||
).rejects.toThrow("Unable to parse Claude Code version from --version output");
|
||||
} finally {
|
||||
await fs.rm(wrapperDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves the version when the command forwards the Claude Code banner", async () => {
|
||||
const wrapperDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-version-"));
|
||||
const wrapperPath = path.join(wrapperDir, "wrapper");
|
||||
await fs.writeFile(wrapperPath, 'console.log("2.1.302 (Claude Code)");\n');
|
||||
try {
|
||||
await expect(
|
||||
resolveClaudeCodeVersion({
|
||||
command: { mode: "replace", argv: [process.execPath, wrapperPath] },
|
||||
}),
|
||||
).resolves.toBe("2.1.302");
|
||||
} finally {
|
||||
await fs.rm(wrapperDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("loads user, project, and local Claude settings", async () => {
|
||||
const queryReturn = vi.fn();
|
||||
queryReturn.mockResolvedValue(undefined);
|
||||
|
||||
@@ -1839,7 +1839,13 @@ export async function resolveClaudeCodeVersion(
|
||||
timeout: 5_000,
|
||||
signal,
|
||||
});
|
||||
const version = parseClaudeCodeVersion(`${stdout}\n${stderr}`);
|
||||
// A wrapper command (the `command` provider setting) prints its own version here
|
||||
// instead of Claude Code's. Only trust output that identifies itself as Claude Code,
|
||||
// so a wrapper's version can't mis-gate models behind a minimumClaudeCodeVersion.
|
||||
const rawVersionOutput = `${stdout}\n${stderr}`;
|
||||
const version = /\d+\.\d+\.\d+\s+\(Claude Code\)/i.test(rawVersionOutput)
|
||||
? parseClaudeCodeVersion(rawVersionOutput)
|
||||
: null;
|
||||
if (!version) {
|
||||
throw new Error("Unable to parse Claude Code version from --version output");
|
||||
}
|
||||
|
||||
@@ -559,6 +559,33 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
expect(models.map((model) => model.id)).not.toContain("claude-opus-5[1m]");
|
||||
expect(models.map((model) => model.id)).not.toContain("claude-opus-5");
|
||||
});
|
||||
|
||||
it("keeps version-gated models when the probe command cannot identify Claude Code", async () => {
|
||||
const configDir = await createClaudeConfigDir({});
|
||||
const wrapperDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-wrapper-"));
|
||||
const wrapperPath = path.join(wrapperDir, "wrapper");
|
||||
await fs.writeFile(wrapperPath, 'console.log("cux 0.4.1");\n');
|
||||
try {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtimeSettings: {
|
||||
command: { mode: "replace", argv: [process.execPath, wrapperPath] },
|
||||
env: { CLAUDE_CONFIG_DIR: configDir },
|
||||
},
|
||||
discoverModels: async () => [],
|
||||
});
|
||||
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: configDir,
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(models.map((model) => model.id)).toContain("claude-opus-5-5");
|
||||
} finally {
|
||||
await fs.rm(wrapperDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeClaudeRuntimeModelId", () => {
|
||||
|
||||
@@ -209,6 +209,10 @@ export async function writeCopilotProviderMode(
|
||||
if (!requestsAllowAll) {
|
||||
return { handled: false };
|
||||
}
|
||||
await context.connection.setSessionMode({
|
||||
sessionId: context.sessionId,
|
||||
modeId: COPILOT_AGENT_MODE_ID,
|
||||
});
|
||||
const response = await context.connection.setSessionConfigOption({
|
||||
sessionId: context.sessionId,
|
||||
configId: COPILOT_ALLOW_ALL_CONFIG_ID,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { beforeAll, describe, expect, test } from "vitest";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
|
||||
|
||||
import { isCommandAvailable } from "../../../executable-resolution/executable-resolution.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
|
||||
import { resolveACPModeSelection } from "./acp-agent.js";
|
||||
import {
|
||||
COPILOT_ALLOW_ALL_MODE_ID,
|
||||
COPILOT_MODES,
|
||||
writeCopilotProviderMode,
|
||||
} from "./copilot-acp-agent.js";
|
||||
|
||||
const AGENT_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#agent";
|
||||
const PLAN_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#plan";
|
||||
|
||||
describe("Copilot ACP provider (real) Allow All mode", () => {
|
||||
let canRun = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
canRun = await isCommandAvailable("copilot");
|
||||
});
|
||||
|
||||
test("selecting Allow All from Plan leaves the real provider in Agent mode", async (context) => {
|
||||
if (!canRun) {
|
||||
context.skip();
|
||||
}
|
||||
const cwd = await mkdtemp(join(tmpdir(), "copilot-allow-all-real-"));
|
||||
const child = spawn("copilot", ["--acp"], { cwd, stdio: ["pipe", "pipe", "ignore"] });
|
||||
const connection = new ClientSideConnection(
|
||||
() => ({
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
}),
|
||||
ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout)),
|
||||
);
|
||||
|
||||
try {
|
||||
await connection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} });
|
||||
const session = await connection.newSession({ cwd, mcpServers: [] });
|
||||
const plan = await connection.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "mode",
|
||||
value: PLAN_MODE_ID,
|
||||
});
|
||||
expect(plan.configOptions).toContainEqual(
|
||||
expect.objectContaining({ id: "mode", currentValue: PLAN_MODE_ID }),
|
||||
);
|
||||
|
||||
// This is the same provider writer called by ACPAgentSession. Assert its
|
||||
// untransformed ACP response, since Paseo's displayed mode hides this bug.
|
||||
const result = await writeCopilotProviderMode({
|
||||
connection,
|
||||
sessionId: session.sessionId,
|
||||
requestedModeId: COPILOT_ALLOW_ALL_MODE_ID,
|
||||
currentModeId: PLAN_MODE_ID,
|
||||
selection: resolveACPModeSelection({
|
||||
modeId: COPILOT_ALLOW_ALL_MODE_ID,
|
||||
availableModes: COPILOT_MODES,
|
||||
configOptions: plan.configOptions,
|
||||
}),
|
||||
configOptions: plan.configOptions,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
expect(result.configOptions).toContainEqual(
|
||||
expect.objectContaining({ id: "mode", currentValue: AGENT_MODE_ID }),
|
||||
);
|
||||
expect(result.configOptions).toContainEqual(
|
||||
expect.objectContaining({ id: "allow_all", currentValue: "on" }),
|
||||
);
|
||||
} finally {
|
||||
await terminateWithTreeKill(child, { gracefulTimeoutMs: 1000, forceTimeoutMs: 1000 });
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import type { Logger } from "pino";
|
||||
|
||||
import type { AgentMode, AgentProvider, AgentSessionConfig } from "../agent-sdk-types.js";
|
||||
import type { AgentManager } from "../agent-manager.js";
|
||||
import { AgentProfileSchema } from "@getpaseo/protocol/messages";
|
||||
import { ActiveTurnBehaviorSchema, AgentProfileSchema } from "@getpaseo/protocol/messages";
|
||||
import type { DaemonConfigStore } from "../../daemon-config-store.js";
|
||||
import {
|
||||
AgentFeatureSchema,
|
||||
@@ -1209,6 +1209,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
.object(legacyTopLevelCreateAgentInputSchema)
|
||||
.strict();
|
||||
const commonSendAgentPromptInputSchema = {
|
||||
activeTurnBehavior: ActiveTurnBehaviorSchema.optional().describe(
|
||||
'How to send into a running turn: "steer" joins it, falling back to interrupt when unavailable; "interrupt" replaces it. Defaults to steer for agent callers and interrupt otherwise.',
|
||||
),
|
||||
agentId: z.string(),
|
||||
prompt: z.string(),
|
||||
sessionMode: z.string().optional().describe("Optional mode to set before running the prompt."),
|
||||
@@ -1955,28 +1958,55 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
inputSchema: sendAgentPromptInputSchema,
|
||||
outputSchema: {
|
||||
success: z.boolean(),
|
||||
disposition: z.enum(["turn_started", "out_of_band"]).optional(),
|
||||
disposition: z.enum(["turn_started", "out_of_band", "steered"]).optional(),
|
||||
status: AgentStatusEnum,
|
||||
guidance: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async ({ agentId, prompt, sessionMode, notifyOnFinish = Boolean(callerAgentId) }) => {
|
||||
async ({
|
||||
agentId,
|
||||
prompt,
|
||||
sessionMode,
|
||||
notifyOnFinish = Boolean(callerAgentId),
|
||||
activeTurnBehavior = callerAgentId ? "steer" : "interrupt",
|
||||
}) => {
|
||||
let stopNotification: (() => void) | undefined;
|
||||
function armFinishNotification(disposition: PromptDispatchDisposition): void {
|
||||
if (!callerAgentId || !notifyOnFinish) {
|
||||
return;
|
||||
}
|
||||
let acceptDispatch!: (result: PromptDispatchDisposition | false) => void;
|
||||
const dispatchResult = new Promise<PromptDispatchDisposition | false>((resolve) => {
|
||||
acceptDispatch = resolve;
|
||||
});
|
||||
const notifying = Boolean(callerAgentId && notifyOnFinish);
|
||||
if (callerAgentId && notifyOnFinish) {
|
||||
stopNotification = setupFinishNotification({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
childAgentId: agentId,
|
||||
callerAgentId,
|
||||
waitForTurnStart: disposition === "turn_started",
|
||||
waitForTurnStart: false,
|
||||
dispatchResult,
|
||||
logger: childLogger,
|
||||
});
|
||||
}
|
||||
function confirmDispatch(disposition: PromptDispatchDisposition): void {
|
||||
// A new turn must ignore outcomes from the displaced turn. Its iterator
|
||||
// has not started yet, so rearm here; steering keeps the early observer.
|
||||
if (disposition !== "turn_started") {
|
||||
acceptDispatch(disposition);
|
||||
return;
|
||||
}
|
||||
acceptDispatch(disposition);
|
||||
if (!callerAgentId || !notifyOnFinish) return;
|
||||
stopNotification?.();
|
||||
stopNotification = setupFinishNotification({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
childAgentId: agentId,
|
||||
callerAgentId,
|
||||
waitForTurnStart: true,
|
||||
logger: childLogger,
|
||||
});
|
||||
}
|
||||
|
||||
const notifying = Boolean(callerAgentId && notifyOnFinish);
|
||||
const { disposition } = await sendPromptToAgent({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
@@ -1984,9 +2014,11 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
prompt,
|
||||
source: callerAgentId ? { kind: "agent-message", agentId: callerAgentId } : undefined,
|
||||
sessionMode,
|
||||
onDispatch: armFinishNotification,
|
||||
activeTurnBehavior,
|
||||
onDispatch: confirmDispatch,
|
||||
logger: childLogger,
|
||||
}).catch((error) => {
|
||||
acceptDispatch(false);
|
||||
stopNotification?.();
|
||||
if (error instanceof AgentNotFoundError) {
|
||||
throw new PaseoToolInputError(error.message);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/builtin-plugins",
|
||||
"version": "0.12.0-beta.1",
|
||||
"version": "0.12.0-beta.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+4
-1
@@ -289,9 +289,12 @@ Use the recipient's agent ID from `paseo ls`, or [copy it from the agent's tab](
|
||||
```bash
|
||||
paseo send <id> "now run the tests"
|
||||
paseo send <id> --image screenshot.png "what's wrong here?"
|
||||
paseo send <id> --no-wait "queue this task"
|
||||
paseo send <id> --no-wait "run this task"
|
||||
paseo send <id> --behavior interrupt "stop and do this instead"
|
||||
```
|
||||
|
||||
Agent-originated sends (`PASEO_AGENT_ID` set) default to steering the running turn. Other sends default to interrupting it. Use `--behavior steer` or `--behavior interrupt` to override the default. Steering uses the same fallback as the client: if steering is unavailable, the running turn is interrupted and replaced.
|
||||
|
||||
## Viewing logs
|
||||
|
||||
```bash
|
||||
|
||||
@@ -51,7 +51,7 @@ Agents can prompt each other by **agent ID**, the identifier Paseo uses to addre
|
||||
|
||||
> Use Paseo to send this prompt to agent [paste agent ID here]: “Review the parser changes in your workspace and report any missing test cases.”
|
||||
|
||||
The receiving agent gets the prompt in its existing conversation. To have it send a separate message back, give it the sender's agent ID too.
|
||||
The receiving agent gets the prompt in its existing conversation. Agent-originated sends default to steering a running turn, with interruption fallback when steering is unavailable. Set MCP `activeTurnBehavior` or CLI `--behavior` to `steer` or `interrupt` to override that default. To have it send a separate message back, give it the sender's agent ID too.
|
||||
|
||||
Underneath, the agent calls `send_agent_prompt` with the recipient's `agentId` and a `prompt`. Agents can also discover IDs with `list_agents`; the CLI uses [`paseo send <id>`](/docs/cli#sending-messages). For another host, use the [remote CLI workflow](#work-on-another-machine).
|
||||
|
||||
|
||||
@@ -1318,6 +1318,13 @@ the source row, so updates to one streaming item do not remount its renderer. Us
|
||||
`useRevealedText(text, phase)` hook when a renderer should pace streaming text like Paseo's built-in
|
||||
assistant rows.
|
||||
|
||||
Chat find (Ctrl or Cmd+F) finds and highlights text in replacement rows when the query also
|
||||
matches the original message. The host searches the original prompt text or the assistant reply
|
||||
with Markdown syntax removed, then the app counts matches in the text your renderer draws.
|
||||
Text added only by your renderer is not independently indexed. Matches stay within a rendered
|
||||
block, and find skips buttons, icons, and anything with `aria-hidden="true"`. Find only runs on
|
||||
the web and desktop apps.
|
||||
|
||||
### Append a timeline row from the daemon
|
||||
|
||||
A server handler can add a plugin-owned row to canonical history:
|
||||
|
||||
@@ -63,7 +63,7 @@ Detach is an explicit user action in the subagents track, not an agent tool. A c
|
||||
|
||||
Agent-scoped `create_agent` defaults `notifyOnFinish` to true. Set it to `false` only for truly fire-and-forget agents.
|
||||
|
||||
**`send_agent_prompt`** — `{ agentId, prompt }`. Use for follow-ups to an existing agent. Returns after dispatch acknowledgement. Agent-scoped calls default `notifyOnFinish` to true; top-level calls have no parent callback.
|
||||
**`send_agent_prompt`** — `{ agentId, prompt, activeTurnBehavior? }`. Use for follow-ups to an existing agent. Agent-scoped calls default to `steer`; top-level calls default to `interrupt`. Set `activeTurnBehavior` to `steer` or `interrupt` to override the default. Steering falls back to interruption when unavailable. CLI agent sends use the same defaults and accept `--behavior steer|interrupt`. Returns after dispatch acknowledgement. Agent-scoped calls default `notifyOnFinish` to true; top-level calls have no parent callback.
|
||||
|
||||
**`update_agent`** — `{ agentId, name?, labels?, settings? }`. Use `settings` for runtime changes on an existing agent: `modeId`, `model`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user