diff --git a/README.ja.md b/README.ja.md index c2d1ad88a0..5d1c8f5ebb 100644 --- a/README.ja.md +++ b/README.ja.md @@ -53,6 +53,17 @@ - **クロスデバイス:** iOS、Android、デスクトップ、Web、CLI に対応。机で作業を始め、スマートフォンで確認し、ターミナルから自動化できます。 - **プライバシー優先:** Paseo にはテレメトリー・トラッキング・強制ログインは一切ありません。 +## スポンサー + + + + + + + + +
Hoplite
Cloud coding agents
Paseo のスポンサーになるPaseo のスポンサーになる
+ ## はじめかた Paseo はコーディングエージェントを管理するローカルサーバー(デーモン)を起動します。デスクトップアプリ・モバイルアプリ・Web アプリ・CLI などのクライアントがこのデーモンに接続します。 @@ -151,11 +162,9 @@ npm run build:server npm run typecheck ``` -## スポンサー +## Paseo を支援する -Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをここと paseo.sh のホームページに掲載します。 - - +Paseo は一人で開発しており、使ってくれる人たちの支援で成り立っています。[GitHub Sponsors](https://github.com/sponsors/boudra) で支援できます。企業として毎月[スポンサー](https://paseo.sh/sponsor#spot)になっていただくと、ロゴをこの README の冒頭と、paseo.sh のトップページおよびドキュメントに掲載します。 ## 関連プロジェクト diff --git a/README.ko.md b/README.ko.md index 60581767cd..7f2098221d 100644 --- a/README.ko.md +++ b/README.ko.md @@ -47,6 +47,17 @@ - **여러 기기 지원:** iOS, Android, 데스크톱, 웹, CLI를 지원합니다. 데스크톱에서 시작해 휴대폰으로 확인하고 터미널에서 자동화할 수 있습니다. - **개인정보 보호 우선:** Paseo는 텔레메트리, 추적, 강제 로그인을 사용하지 않습니다. +## 스폰서 + + + + + + + + +
Hoplite
Cloud coding agents
Paseo 스폰서 되기Paseo 스폰서 되기
+ ## 시작하기 Paseo는 코딩 에이전트를 관리하는 로컬 서버인 데몬을 실행합니다. 데스크톱 앱, 모바일 앱, 웹 앱, CLI 같은 클라이언트가 이 데몬에 연결합니다. @@ -161,11 +172,9 @@ npm run build:server npm run typecheck ``` -## 스폰서 +## Paseo 후원하기 -Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이곳과 paseo.sh 홈페이지에 게재할 수 있습니다. - - +Paseo는 한 사람이 개발하며, 사용하는 분들의 후원으로 운영됩니다. [GitHub Sponsors](https://github.com/sponsors/boudra)로 후원할 수 있습니다. 회사는 매월 [Paseo를 후원](https://paseo.sh/sponsor#spot)하고 로고를 이 README 상단과 paseo.sh 홈페이지 및 문서에 게재할 수 있습니다. ## 관련 프로젝트 diff --git a/README.md b/README.md index 2fc0fc8f3d..91310cd7c7 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,17 @@ Paseo is an open source agentic development environment for desktop, mobile, web [Run parallel tasks in Paseo](https://paseo.sh/docs/parallel-development): start agents in separate worktrees, review their diffs, run each app, and check it in the built-in browser. +## Sponsors + + + + + + + + +
Hoplite
Cloud coding agents
Become a sponsorBecome a sponsor
+ ## Plugins Plugins run on the daemon and show up in every client you connect, with the same UI on desktop, web, @@ -207,11 +218,9 @@ npm run build:server npm run typecheck ``` -## Sponsors +## Support Paseo -Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown here and on the paseo.sh homepage. - - +Paseo is an independent project used by tens of thousands of developers daily, built by one person and funded by the people who use it. Support the work on [GitHub Sponsors](https://github.com/sponsors/boudra). Companies can [sponsor Paseo](https://paseo.sh/sponsor#spot) monthly and have their logo shown at the top of this README and on the paseo.sh homepage and docs. ## Related projects diff --git a/README.zh-CN.md b/README.zh-CN.md index dfdf673b2e..745071f0fe 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -53,6 +53,17 @@ - **跨设备:** 支持 iOS、Android、桌面端、Web 和 CLI。在桌前开始工作,用手机查看进度,也可以从终端脚本化操作。 - **隐私优先:** Paseo 没有遥测、追踪,也不会强制登录。 +## 赞助商 + + + + + + + + +
Hoplite
Cloud coding agents
成为 Paseo 赞助商成为 Paseo 赞助商
+ ## 快速开始 Paseo 会运行一个名为 daemon 的本地服务,用来管理你的 coding agents。桌面 app、移动 app、Web app 和 CLI 等客户端都会连接到它。 @@ -151,11 +162,9 @@ npm run build:server npm run typecheck ``` -## 赞助 +## 支持 Paseo -Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在这里和 paseo.sh 首页上。 - - +Paseo 由一个人开发,靠使用它的人来资助。你可以通过 [GitHub Sponsors](https://github.com/sponsors/boudra) 支持这项工作。公司也可以按月[赞助 Paseo](https://paseo.sh/sponsor#spot),logo 会展示在本 README 顶部,以及 paseo.sh 的首页和文档中。 ## 相关项目 diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index f501538270..378a0070ea 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -212,7 +212,7 @@ parentAgentId === thisAgent.id AND !archivedAt - **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent. -Clicking either kind opens a workspace tab. On compact layouts it opens in the [workspace sheet](explorer-sidebar.md#workspace-sheet) instead; the sheet hides the composer, and a Paseo subagent's sheet offers **Open as tab** to get it back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical. +Clicking either kind opens a workspace tab. On compact layouts it opens in a [sheet](explorer-sidebar.md#sheets) over the subagents list instead; the sheet hides the composer and shows the agent's model and thinking as one line in its place (`PaneSubtitle`, the row provider subagents already use), and a Paseo subagent's sheet offers **Open as tab** (the full-screen glyph in the header) to get the composer back. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical. Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only. diff --git a/docs/custom-providers.md b/docs/custom-providers.md index efc5fdf24f..a6c6cefbdc 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -484,7 +484,7 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout. -Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below. +Paseo also ships an in-app ACP provider catalog for common agents, including CodeWhale, Cursor, DeepAgents, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Most catalog entries create the same `extends: "acp"` provider config shown below. Hoplite activates its [bundled provider](plugins.md#built-in-plugins) instead. ### Adding a generic ACP provider diff --git a/docs/explorer-sidebar.md b/docs/explorer-sidebar.md index 9415abd85a..c9a89f8a58 100644 --- a/docs/explorer-sidebar.md +++ b/docs/explorer-sidebar.md @@ -7,7 +7,7 @@ contracts. | ---------------- | ---------------------------- | ------------------------------------------ | | Explorer sidebar | Files and Changes navigation | Cmd+E shows or hides the dedicated dock | | Side pane | Ordinary workspace content | Created and closed like any workspace pane | -| Workspace sheet | Compact look-ups | Stacks tabs; each closes or becomes a tab | +| Sheets | Compact look-ups | One tab each, on the app-wide sheet stack | ## Panel host contract @@ -23,23 +23,30 @@ can move between Explorer and main panes. Keep panel implementations independent of either shell. `WorkspacePanelHost` owns mounting and retention, while each shell owns its tabs, focus, dragging, resizing, and shortcuts. -## Workspace sheet +## Sheets -On compact layouts, files and subagents open in a closable sheet over the workspace, so the tab -switcher keeps only agents and terminals. A panel kind opts in with `compactSheet` in its manifest +On compact layouts, files and subagents open in a sheet over the workspace, so the tab switcher +keeps only agents and terminals. A panel kind opts in with `compactSheet` in its manifest (`packages/app/src/panels/panel-manifest.ts`), and the layout store's `openTab` decides between -sheet and tab, so callers open targets the same way on every layout. Each sheet holds one ordinary -`WorkspaceTab`, and sheets stack: a target opened from a sheet covers it, and closing the top sheet -(drag, Close, backdrop, or Android Back) uncovers the one below. Revealing a target that already has -a sheet brings that sheet back to the top and closes the sheets above it, except ones holding unsaved -work. Opening a sheet as a tab dismisses the sheets above it, since the -tab would sit under them. The stack is not persisted. +sheet and tab, so callers open targets the same way on every layout. Each such sheet holds one +ordinary `WorkspaceTab` (`SheetTab` in the store) and is an `IsolatedBottomSheetModal` like every +other sheet in the app, so it sits on the one app-wide stack: a sheet opened from any sheet, a +subagent chosen from the subagents list included, lands on top of it, and closing the top sheet +(drag, Close, backdrop, or Android Back) uncovers the one below. The list stays open under the +sheet it opened; see [menus.md](menus.md#selecting-an-item-on-ios) for the row contract. + +The store keeps `sheetsByWorkspace` in opening order and never reorders it, because moving a sheet +remounts its panel. Revealing a target that already has a sheet uncovers that sheet by closing the +ones above it, except ones holding unsaved work, which stay where they are. Opening a sheet as a tab +dismisses the sheets above it, since the tab would sit under them. The stack is not persisted. Sheets are modal and cover the whole compact column, header included. A sheet's header is its drag -handle, because the panel scrolls on its own and content panning would fight it. Sheets render in -the workspace tree rather than through `IsolatedBottomSheetModal`: their panels need the navigator -and every workspace provider an ordinary pane has, and a portal drops them -([floating-panels.md Gotcha 7](floating-panels.md#gotcha-7--a-sheet-cannot-read-context-from-its-call-site)). +handle, because the panel scrolls on its own and content panning would fight it. Its trailing rail +holds icon buttons only (`SheetHeaderIconButton`): Open as tab is the full-screen glyph beside +Close, so the title keeps its width. The sheets present +from inside the compact column's `SheetHostScope`, so their panels read the navigator and every +workspace provider an ordinary pane has without a context bridge +([floating-panels.md Gotcha 6](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth)). A sheet holding unsaved work is never implicitly displaced; only its guarded close removes it. Moving its tab to another host remounts the panel, and the file editor keeps unsaved text in the diff --git a/docs/floating-panels.md b/docs/floating-panels.md index cb51c8f7dd..1af71ac4c9 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -217,7 +217,14 @@ sheet suspends pane focus, and the composer gives up input from the moment the user asks for the compact agent list or explorer, before the panel's motion settles. Pane focus itself stays put under those panels: the agent panel reads it as "the user is in this chat" for attention clearing, and opening the agent -list must not clear the attention it shows. +list must not clear the attention it shows. Focus restoration follows pane +focus, not the active composer. The editor keeps its focus intent only across +a blur that lands while its pane is not the user's (a retained tab hidden, a +sheet over it) and refocuses when the pane comes back. A blur while the pane is +theirs drops the intent in every layout mode, and the keyboard the compact +panels dismiss when they settle is such a blur, so closing the agent list or +explorer never reopens the keyboard. Keying restoration on the active composer +made that dismiss look like a hidden-tab blur and refocused on close. The band beside the inline editor where the fullscreen glyph sits is the input's own right padding, so a tap anywhere in it focuses the editor whether or not the glyph shows. On web the slot is a fixed box over the visible viewport, which is @@ -336,11 +343,20 @@ Do not treat `onChange(-1)` as a close by itself. In a stacked another pushed sheet. Close React state from `onDismiss`; use `onChange` only to track phase. -All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`. This -includes native centered dialogs: separate React Native Modals cannot present a root-owned -sibling dialog over an existing iOS presenter. The one exception is the -[workspace sheet](explorer-sidebar.md#workspace-sheet), an in-tree Gorhom `BottomSheet` that hosts a -whole workspace panel. +All sheets use the app-wide Gorhom modal provider through `IsolatedBottomSheetModal`, with no +exceptions: one stack means a sheet opened from any sheet lands on top of it and uncovers it on +close. This includes native centered dialogs: separate React Native Modals cannot present a +root-owned sibling dialog over an existing iOS presenter. A sheet that must draw inside a subtree +(the [sheet tabs](explorer-sidebar.md#sheets) draw inside the compact workspace column so their +panels read the workspace providers) presents from inside a `SheetHostScope`, which moves the +portal host and container measurement while the sheet still joins the same stack. That export is +what `patches/@gorhom+bottom-sheet+*.patch` adds. + +A sheet whose owner may veto the close (unsaved work) passes `enableDismissOnClose={false}`. Every +close path then parks the sheet at index -1 with its content mounted; the owner hears +`onChange(-1)`, runs its guard, and either snaps back to 0 or drops its state, which unmounts the +sheet and takes it off the stack. Calling `dismiss()` on such a sheet bypasses the guard and +unmounts it, so its backdrop and Back press call `close()`. The wrapper must give Gorhom an object ref. Gorhom accepts a callback ref at the React boundary but stores it in the modal queue and later reads `.current`; provider dismissal, replacement, @@ -350,9 +366,10 @@ forwards commands to the current Gorhom handle. Request a backdrop with `backdropOpacity`; do not install a caller-owned backdrop. On web, Gorhom's default backdrop closes its own sheet, even when another modal is above it. Its animated pointer-event changes can expose a lower backdrop during a transition. The shared web backdrop -keeps its press target until portal teardown and asks the provider to dismiss the top modal. -Gorhom's presentation queue remains the authority; backdrop mount order is irrelevant. Native -retains Gorhom's gesture backdrop and the same push/replace presentation semantics. +keeps its press target until portal teardown and asks the provider to dismiss the top modal +(a guarded sheet closes itself instead, see above). Gorhom's presentation queue remains the +authority; backdrop mount order is irrelevant. Native retains Gorhom's gesture backdrop and the +same push/replace presentation semantics. ## Gotcha 7 — A sheet cannot read context from its call site diff --git a/docs/glossary.md b/docs/glossary.md index dcf8178bc7..644c109d44 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -21,7 +21,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Repository / Remote** — Internal Git observations. They may produce mutable kind, branch, and project-grouping metadata but never the host-local project ID, root, display name, or workspace membership. No UI label. - **Sidebar items** — The configurable entries of the app sidebar, in two sections. Header: the rows at the head of the sidebar — New workspace, History, Search, Schedules, and plugin header items. Footer: the full-width rows between Add project and the footer icon row — Usage and plugin footer items. Add project and the icon row (Hosts, Help and support, Settings) are fixed and are not sidebar items. A plugin item may render several rows, such as one per bot; it is still one item that moves and hides as a block. Each section's order and visibility is one per-device preference (`AppSettings.sidebarNavItems`, `AppSettings.sidebarFooterItems`); every item may be off. UI: the Header and Footer cards on the Sidebar settings page. Code: `packages/app/src/sidebar-nav/model.ts` resolves a section, and the sidebar (`components/sidebar/sidebar-nav-rows.tsx`, the footer in `components/left-sidebar.tsx`) and the settings page (`screens/settings/sidebar/sidebar-nav-section.tsx`) read it through `useSidebarNavItems(section)`. Never hardcode one of these entries or add a second place that decides which ones show. Don't confuse with **Explorer sidebar**. - **Explorer sidebar** — The dedicated navigation dock containing the Files and Changes trees. UI: "Explorer sidebar". Code: `packages/app/src/workspace-tabs/explorer-sidebar.ts`; persisted pane identifiers retain the `explorer` spelling. See [explorer-sidebar.md](explorer-sidebar.md). -- **Workspace sheet** — A compact sheet that holds one file or subagent tab over the workspace without adding it to the tab switcher; opening another target from a sheet stacks a new one on top. UI action: "Open as tab". Code: `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `WorkspaceSheetHost` (`packages/app/src/screens/workspace/workspace-sheet-host.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#workspace-sheet). +- **Sheet** — Any bottom sheet; there is one kind and one app-wide stack, so a sheet opened from a sheet lands on top of it and closing it uncovers the one below. Code: `IsolatedBottomSheetModal` (`packages/app/src/components/ui/isolated-bottom-sheet-modal`). Forbidden: "Workspace sheet", "modal sheet" as a distinct kind. See [floating-panels.md](floating-panels.md). +- **Sheet tab** — A compact-layout sheet holding one file or subagent tab over the workspace without adding it to the tab switcher. UI action: "Open as tab". Code: `SheetTab` and `sheetsByWorkspace` in `packages/app/src/stores/workspace-layout-store.ts`, `SheetTabs` (`packages/app/src/screens/workspace/sheet-tabs.tsx`). See [explorer-sidebar.md](explorer-sidebar.md#sheets). - **Side pane** — An ordinary right-side workspace pane used for content opened beside the user's work. It closes and participates in workspace splits like every other main-host pane. See [explorer-sidebar.md](explorer-sidebar.md#side-pane). Forbidden: "Side panel". - **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). - **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned). diff --git a/docs/menus.md b/docs/menus.md index 50434b9e9d..9dc3e26cae 100644 --- a/docs/menus.md +++ b/docs/menus.md @@ -52,6 +52,11 @@ is another, with its own icons and hover-revealed actions — but both hand the and take `closeOnSelect` to say whether choosing them ends the surface. A row that owns its press outright leaves the surface open behind whatever it opened, and skips the iOS wait. +A row whose result opens a sheet passes `closeOnSelect={false}`: the sheet stacks over the menu +sheet and uncovers it on close, so the menu is where the user lands next. A row that opens a tab +closes, because the tab replaces the pane the menu belongs to. The subagents track asks the layout +store which one a row is (`useSubagentOpensInSheet`), since the answer changes with the layout. + ## Pages A submenu is a page, declared as data on the surface and reached by a `MenuSubTrigger` whose `id` @@ -193,6 +198,9 @@ its own. backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes. - **Sheet stacking and backdrop dismissal belong to `IsolatedBottomSheetModal`.** Menus only request a backdrop opacity. See [floating-panels.md](floating-panels.md#gotcha-6--bottom-sheet-refs-are-not-lifecycle-truth). +- **A menu closes when its panel leaves the screen.** `MenuSurface` closes the menu when its + retained panel goes inactive. Hiding the surface alone leaves the open state behind, and the + menu would reappear with the panel on the next tab switch. - **Retained panels own visibility.** The shared menu surface unmounts when its panel becomes inactive. An async action can navigate before its menu closes; a hidden panel must not leave a portal backdrop blocking the destination. On web the chat suspends one commit after it goes diff --git a/docs/plugins.md b/docs/plugins.md index 866c74128e..4e1985ea81 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -107,6 +107,12 @@ absent from the installed plugin list and source configuration; their client bun appear in the plugin catalog. Editing one in development requires a daemon restart. Directory, Git, and npm installs cannot use a built-in ID. +A provider registration with `requiresConfiguration: true` participates only while its ID exists +under `agents.providers`. Hoplite uses this for catalog opt-in. Add writes `{ "enabled": true }` +without `extends`, so it selects the bundled adapter. Remove deletes the config entry; setting +`enabled: false` keeps the entry. The bundled JavaScript still loads at startup, but an absent +provider does not enter executable resolution or catalog discovery. + Provider plugins use separate installation and provider IDs. `muse-provider` registers the selectable `muse` provider (Muse Code) and a usage source. Its `status({ launch })` reports availability and a diagnostic after the daemon resolves the executable. Configure command, diff --git a/packages/app/e2e/browser/acp-provider-catalog.spec.ts b/packages/app/e2e/browser/acp-provider-catalog.spec.ts index a0d94e5c02..ceca7489e3 100644 --- a/packages/app/e2e/browser/acp-provider-catalog.spec.ts +++ b/packages/app/e2e/browser/acp-provider-catalog.spec.ts @@ -1,13 +1,24 @@ -import { test } from "../support/fixtures"; +import { + closeModelPicker, + openModelPicker, + seedAgentProfiles, +} from "../support/helpers/agent-profiles"; +import { expectComposerVisible } from "../support/helpers/composer"; +import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { writeFile } from "node:fs/promises"; +import { expect, test } from "../support/fixtures"; import { gotoAppShell, openSettings } from "../support/helpers/app"; import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; import { getServerId } from "../support/helpers/server-id"; import { expectProviderInstalledInSettings, + findAcpCatalogProvider, installAcpCatalogProvider, openAddProviderArea, openSettingsHost, openSettingsHostSection, + seedSavedSettingsHosts, } from "../support/helpers/settings"; const ACP_PROVIDER = { @@ -16,8 +27,13 @@ const ACP_PROVIDER = { }; interface ProviderCatalogDaemonClient { + getLastServerInfoMessage(): { + features?: { hopliteProvider?: boolean; plugins?: boolean }; + } | null; connect(): Promise; close(): Promise; + getDaemonConfig(): Promise<{ config: { providers?: Record } }>; + getProvidersSnapshot(): Promise<{ entries: Array<{ provider: string }> }>; patchDaemonConfig(config: { removeProviders?: string[] }): Promise; } @@ -41,3 +57,124 @@ test.describe("ACP provider catalog", () => { } }); }); + +for (const theme of ["light", "dark"] as const) { + test(`adds Hoplite from the catalog in ${theme} theme`, async ({ page }, testInfo) => { + const client = await connectDaemonClient({ + clientIdPrefix: "hoplite-catalog-e2e", + }); + try { + expect(client.getLastServerInfoMessage()?.features?.hopliteProvider).toBe(true); + await client.patchDaemonConfig({ removeProviders: ["hoplite"] }); + expect( + (await client.getProvidersSnapshot()).entries.some((entry) => entry.provider === "hoplite"), + ).toBe(false); + await page.addInitScript((value) => { + localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: value })); + }, theme); + await gotoAppShell(page); + await openSettings(page); + await openSettingsHost(page, getServerId()); + await openSettingsHostSection(page, getServerId(), "providers"); + await expect( + page.getByRole("button", { name: "Hoplite provider details", exact: true }), + ).toHaveCount(0); + await openAddProviderArea(page); + await findAcpCatalogProvider(page, "Hoplite"); + await page.screenshot({ path: testInfo.outputPath(`hoplite-catalog-${theme}.png`) }); + await installAcpCatalogProvider(page, "Hoplite"); + await expectProviderInstalledInSettings(page, "Hoplite"); + await page.screenshot({ path: testInfo.outputPath(`hoplite-installed-${theme}.png`) }); + expect((await client.getDaemonConfig()).config.providers?.hoplite).toEqual({ enabled: true }); + await writeFile( + testInfo.outputPath(`hoplite-added-${theme}.json`), + JSON.stringify(await client.getProvidersSnapshot(), null, 2), + ); + await removeHoplite(page); + await expect( + page.getByRole("button", { name: "Hoplite provider details", exact: true }), + ).toHaveCount(0); + expect((await client.getDaemonConfig()).config.providers?.hoplite).toBeUndefined(); + expect( + (await client.getProvidersSnapshot()).entries.some((entry) => entry.provider === "hoplite"), + ).toBe(false); + await writeFile( + testInfo.outputPath(`hoplite-removed-${theme}.json`), + JSON.stringify(await client.getProvidersSnapshot(), null, 2), + ); + await openAddProviderArea(page); + await findAcpCatalogProvider(page, "Hoplite"); + await page.screenshot({ path: testInfo.outputPath(`hoplite-removed-${theme}.png`) }); + await installAcpCatalogProvider(page, "Hoplite"); + await expectProviderInstalledInSettings(page, "Hoplite"); + } finally { + await client.patchDaemonConfig({ removeProviders: ["hoplite"] }).catch(() => undefined); + await client.close().catch(() => undefined); + } + }); +} + +async function removeHoplite(page: import("@playwright/test").Page): Promise { + await page.getByTestId("provider-actions-hoplite").click(); + page.once("dialog", (dialog) => { + void dialog.accept(); + }); + await page.getByTestId("provider-remove-hoplite").click(); +} + +test("unsupported host asks for an update without writing Hoplite config", async ({ + page, + outdatedDaemon, +}, testInfo) => { + const client = await connectDaemonClient({ + clientIdPrefix: "hoplite-unsupported", + port: Number(new URL(`http://${outdatedDaemon.endpoint}`).port), + }); + try { + expect(client.getLastServerInfoMessage()?.features?.plugins).toBe(true); + expect(client.getLastServerInfoMessage()?.features?.hopliteProvider).toBe(false); + const before = await client.getDaemonConfig(); + await seedSavedSettingsHosts(page, [outdatedDaemon]); + await page.reload(); + await openSettings(page); + await openSettingsHost(page, outdatedDaemon.serverId); + await openSettingsHostSection(page, outdatedDaemon.serverId, "providers"); + await openAddProviderArea(page); + await page.getByRole("button", { name: "Add Hoplite", exact: true }).click(); + const errorToast = page.getByTestId("app-toast-message"); + await expect(errorToast).toHaveText("Update the host to add Hoplite."); + await expect(errorToast).toBeInViewport(); + expect((await client.getDaemonConfig()).config).toEqual(before.config); + await expect( + page.getByRole("button", { name: "Hoplite provider details", exact: true }), + ).toHaveCount(0); + await page.screenshot({ path: testInfo.outputPath("hoplite-update-host.png") }); + } finally { + await client.close(); + } +}); + +test("model selector excludes Hoplite before opt-in", async ({ page }, testInfo) => { + const client = await connectDaemonClient({ + clientIdPrefix: "hoplite-picker", + }); + const profiles = await seedAgentProfiles([ + { id: "hoplite-picker-anchor", name: "Picker anchor", provider: "mock" }, + ]); + const workspace = await seedWorkspace({ repoPrefix: "hoplite-picker-" }); + try { + await client.patchDaemonConfig({ removeProviders: ["hoplite"] }); + await gotoWorkspace(page, workspace.workspaceId); + await clickNewChat(page); + await expectComposerVisible(page); + await openModelPicker(page); + await expect(page.getByTestId("model-provider-mock")).toBeVisible(); + await expect(page.getByTestId("model-provider-hoplite")).toHaveCount(0); + await page.screenshot({ path: testInfo.outputPath("hoplite-model-selector-absent.png") }); + await closeModelPicker(page); + } finally { + await workspace.cleanup(); + await profiles.restore(); + await client.close(); + } +}); diff --git a/packages/app/e2e/browser/agent-tab-image-stability.spec.ts b/packages/app/e2e/browser/agent-tab-image-stability.spec.ts index 5f70bdafe3..841314343f 100644 --- a/packages/app/e2e/browser/agent-tab-image-stability.spec.ts +++ b/packages/app/e2e/browser/agent-tab-image-stability.spec.ts @@ -16,6 +16,7 @@ import { } from "../support/helpers/assistant-images"; import { expectAgentReadyToInterrupt } from "../support/helpers/agent-stream"; import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import path from "node:path"; const test = base.extend<{ imageWorkspace: SeededWorkspace }>({ imageWorkspace: async ({ page: _page }, provide) => { @@ -28,6 +29,42 @@ const test = base.extend<{ imageWorkspace: SeededWorkspace }>({ }, }); +test("assistant images at paths with spaces, Unicode and literal percent escapes render", async ({ + imageWorkspace: workspace, + page, +}) => { + test.setTimeout(120_000); + const image = await createSmallAssistantPng(workspace, { + alt: "Angle destination image", + fileName: "My image Мои.png", + }); + const agent = await createSettledMockAgent(workspace, "Encoded image paths"); + await emitSettledAssistantImage(workspace.client, agent, image, `<${image.relativePath}>`); + await openAssistantImageTimeline(page, agent); + await expectAssistantImageRendered(page, image); + + const encodedImage = { ...image, alt: "Encoded destination image" }; + await emitSettledAssistantImage( + workspace.client, + agent, + encodedImage, + encodeURI(path.join(workspace.repoPath, image.relativePath)), + ); + await expectAssistantImageRendered(page, encodedImage); + + const literalPercentImage = await createSmallAssistantPng(workspace, { + alt: "Literal percent filename image", + fileName: "literal%20name.png", + }); + await emitSettledAssistantImage( + workspace.client, + agent, + literalPercentImage, + encodeURI(literalPercentImage.relativePath), + ); + await expectAssistantImageRendered(page, literalPercentImage); +}); + test("switching between settled agent tabs keeps a real assistant PNG rendered", async ({ imageWorkspace: workspace, page, diff --git a/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts b/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts index feceed7869..73c662124f 100644 --- a/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts +++ b/packages/app/e2e/browser/agent-timeline-scroll-smoothness.spec.ts @@ -133,6 +133,58 @@ test("scroll detector accounts for delayed wheel input and growth below the new ).toHaveLength(1); }); +test("scroll detector follows the entered image through resize and virtual offset correction", () => { + for (const height of [225, 1632]) { + const before: ScrollFrame = { + at: 1000, + scrollTop: 2208, + scrollHeight: 5000, + viewportHeight: 516, + virtualized: true, + loading: false, + rows: [ + { id: "image", top: -704, height: 560 }, + { id: "caption", top: -144, height: 100 }, + { id: "old-reader", top: -44, height: 127 }, + ], + anchor: "old-reader", + wheelTotal: 0, + lastWheelAt: 0, + inputFinishedAt: null, + imageLoads: 0, + mounted: 3, + unmounted: 0, + }; + const resize = height - 560; + const after: ScrollFrame = { + ...before, + at: 1060, + // The virtualizer corrects 110px of estimates while the wheel moves 160px. + scrollTop: 1938, + scrollHeight: 5000 + resize - 110, + wheelTotal: 160, + lastWheelAt: 1020, + imageLoads: 1, + rows: [ + { id: "image", top: -544, height }, + { id: "caption", top: 16 + resize, height: 100 }, + { id: "old-reader", top: 116 + resize, height: 127 }, + ], + }; + expect(findScrollJumps([before, after])).toEqual([]); + // Resizing must not excuse additional viewport movement. + expect( + findScrollJumps([ + before, + { + ...after, + rows: after.rows.map((row) => ({ id: row.id, height: row.height, top: row.top + 100 })), + }, + ]), + ).toHaveLength(1); + } +}); + for (const cadence of scrollCadences) { test(`varied timeline preserves reading position during ${cadence.name} upward scrolling`, async ({ page, diff --git a/packages/app/e2e/browser/compact-workspace-sheet.spec.ts b/packages/app/e2e/browser/compact-workspace-sheet.spec.ts index 474a498353..77a2a0882b 100644 --- a/packages/app/e2e/browser/compact-workspace-sheet.spec.ts +++ b/packages/app/e2e/browser/compact-workspace-sheet.spec.ts @@ -4,18 +4,23 @@ import { expect, test } from "../support/fixtures"; import { expectComposerVisible } from "../support/helpers/composer"; import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; -import { openSubagentFromCompactTrack, seedSubagent } from "../support/helpers/subagents"; import { - closeWorkspaceSheet, + expectSubagentRowVisible, + openSubagentFromCompactTrack, + seedSubagent, +} from "../support/helpers/subagents"; +import { + closeSheet, expectOpenTabCount, - openWorkspaceSheetAsTab, + openSheetAsTab, expectDeclinedDiscardPrompt, - expectWorkspaceSheetAtSnap, - replaceWorkspaceSheetFileText, + expectSheetAtSnap, + replaceSheetFileText, selectTabFromSwitcher, - tapWorkspaceSheetBackdrop, - workspaceSheet, -} from "../support/helpers/workspace-sheet"; + sheet, + sheetHeader, + tapSheetBackdrop, +} from "../support/helpers/sheet"; const FILE_LINK = "linked.ts:3"; @@ -55,47 +60,90 @@ test("compact opens files and subagents in a sheet, leaving the tabs for agents" await expect(fileLink).toBeVisible({ timeout: 15_000 }); await fileLink.click(); - const sheet = workspaceSheet(page, "linked.ts"); - await expect(sheet).toBeVisible(); - await expect(sheet.getByText("three")).toBeVisible(); - await expectWorkspaceSheetAtSnap(page, "linked.ts"); - await expect(sheet.getByRole("button", { name: "Open as tab" })).toHaveCount(0); + const fileSheet = sheet(page, "linked.ts"); + await expect(fileSheet).toBeVisible(); + await expect(fileSheet.getByText("three")).toBeVisible(); + await expectSheetAtSnap(page, "linked.ts"); + await expect( + sheetHeader(page, "linked.ts").getByRole("button", { name: "Open as tab" }), + ).toHaveCount(0); await expectOpenTabCount(page, 1); - await closeWorkspaceSheet(page, "linked.ts"); + await closeSheet(page, "linked.ts"); }); - await test.step("a subagent opens in a sheet without its composer", async () => { + await test.step("a subagent opens in a sheet over the subagents list", async () => { await openSubagentFromCompactTrack(page, child.id); - const sheet = workspaceSheet(page, child.title); - await expect(sheet).toBeVisible(); - await expectWorkspaceSheetAtSnap(page, child.title); - await expect(sheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0); + const childSheet = sheet(page, child.title); + await expect(childSheet).toBeVisible(); + await expectSheetAtSnap(page, child.title); + await expect(childSheet.getByRole("textbox", { name: "Message agent..." })).toHaveCount(0); + // The composer's model, thinking, and mode stand in for it, resolved the same way. An idle + // seeded agent has no context usage yet. + await expect(childSheet.getByTestId("agent-pane-subtitle")).toHaveText( + "Ten second stream · Low · Load test", + ); await expectOpenTabCount(page, 1); }); - await test.step("Open as tab promotes the subagent to a full tab", async () => { - await openWorkspaceSheetAsTab(page, child.title); + await test.step("closing the subagent's sheet uncovers the list", async () => { + await closeSheet(page, child.title); + await expectSubagentRowVisible(page, child.id); + }); + + await test.step("Open as tab promotes the subagent to a full tab and ends the list", async () => { + await page.getByTestId(`subagents-track-row-${child.id}`).click(); + await expectSheetAtSnap(page, child.title); + await openSheetAsTab(page, child.title); + await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0); await expectOpenTabCount(page, 2); await expectComposerVisible(page); }); - await test.step("a sheet with unsaved edits closes only through the guarded close", async () => { + await test.step("the list the subagent was opened from closed with its tab", async () => { await selectTabFromSwitcher(page, "Sheet parent"); + await expect(page.getByTestId(`subagents-track-row-${child.id}`)).toHaveCount(0); + }); + + await test.step("once its tab closes, the subagent reopens in a sheet that keeps the list", async () => { + await page + .getByRole("button", { name: /Switch tabs/ }) + .filter({ visible: true }) + .click(); + await expect(page.getByText("Workspace tabs", { exact: true })).toBeVisible(); + const menuBase = `workspace-tab-menu-agent_${child.id}`; + await page.getByTestId(`${menuBase}-trigger`).click(); + await page.getByTestId(`${menuBase}-close`).click(); + await expectOpenTabCount(page, 1); + // Closing a tab keeps the switcher open, with the parent as the tab left. Its backdrop is + // tapped once the row menu's own sheet has gone. + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1); + await tapSheetBackdrop(page); + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0); + + await openSubagentFromCompactTrack(page, child.id); + await expectSheetAtSnap(page, child.title); + await closeSheet(page, child.title); + await expectSubagentRowVisible(page, child.id); + await tapSheetBackdrop(page); + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0); + }); + + await test.step("a sheet with unsaved edits closes only through the guarded close", async () => { await page.getByText(FILE_LINK, { exact: true }).click(); - const sheet = workspaceSheet(page, "linked.ts"); - await expect(sheet).toBeVisible(); + const fileSheet = sheet(page, "linked.ts"); + await expect(fileSheet).toBeVisible(); // Hold the autosave so the edit stays unsaved. gate.holdNextClientRequest("fs.file.write.request"); - await replaceWorkspaceSheetFileText(page, "linked.ts", "edited in the sheet"); + await replaceSheetFileText(page, "linked.ts", "edited in the sheet"); await gate.waitForHeldClientRequest(); - await expectDeclinedDiscardPrompt(page, () => tapWorkspaceSheetBackdrop(page)); - await expect(sheet.locator(".cm-content")).toHaveText("edited in the sheet"); + await expectDeclinedDiscardPrompt(page, () => tapSheetBackdrop(page)); + await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet"); await expectDeclinedDiscardPrompt(page, () => - sheet.getByRole("button", { name: "Close" }).click(), + sheetHeader(page, "linked.ts").getByRole("button", { name: "Close" }).click(), ); - await expect(sheet.locator(".cm-content")).toHaveText("edited in the sheet"); + await expect(fileSheet.locator(".cm-content")).toHaveText("edited in the sheet"); gate.releaseHeldClientRequest(); await expect.poll(() => readFile(linkedPath, "utf8")).toBe("edited in the sheet"); diff --git a/packages/app/e2e/browser/composer-focus.spec.ts b/packages/app/e2e/browser/composer-focus.spec.ts index b79cfd9255..cbc710a94a 100644 --- a/packages/app/e2e/browser/composer-focus.spec.ts +++ b/packages/app/e2e/browser/composer-focus.spec.ts @@ -1,13 +1,30 @@ import { expect, test, type Page } from "../support/fixtures"; import { + composerLocator, + blurComposerInput, + enterComposerFullscreen, + exitComposerFullscreen, expectComposerDraft, expectComposerFocused, + expectComposerFullscreenControls, expectComposerNotFocused, expectComposerVisible, + expectWorkspaceHeaderVisible, + resizeComposerViewport, submitMessage, typeIntoFocusedComposer, } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { + closeMobileAgentSidebar, + closeMobileExplorer, + expectMobileAgentSidebarHidden, + expectMobileAgentSidebarVisible, + expectMobileExplorerHidden, + expectMobileExplorerVisible, + swipeOpenMobileAgentSidebar, + swipeOpenMobileExplorer, +} from "../support/helpers/sidebar"; test("submitting a message leaves the composer ready for the next message", async ({ page }) => { const agent = await seedMockAgentWorkspace({ @@ -76,3 +93,76 @@ test.describe("touch screen wide enough for the desktop layout", () => { } }); }); + +const COMPACT_PANELS = [ + { + name: "agent list", + open: swipeOpenMobileAgentSidebar, + expectOpen: expectMobileAgentSidebarVisible, + close: closeMobileAgentSidebar, + expectClosed: expectMobileAgentSidebarHidden, + }, + { + name: "Explorer", + open: swipeOpenMobileExplorer, + expectOpen: expectMobileExplorerVisible, + close: closeMobileExplorer, + expectClosed: expectMobileExplorerHidden, + }, +] as const; + +for (const panel of COMPACT_PANELS) { + test(`closing the compact ${panel.name} does not restore composer focus`, async ({ page }) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "composer-focus-panel-", + title: `Composer focus under ${panel.name}`, + }); + const draft = "Line one\nLine two\nLine three"; + try { + await resizeComposerViewport(page, "portrait"); + await openAgentRoute(page, agent); + await expectComposerVisible(page); + + await test.step("an untouched editor stays untouched", async () => { + await expectComposerNotFocused(page); + await panel.open(page); + await panel.expectOpen(page); + await panel.close(page); + await panel.expectClosed(page); + await expectComposerNotFocused(page); + }); + + await test.step("an inline editor blurred under the panel stays blurred", async () => { + await composerLocator(page).fill(draft); + await composerLocator(page).click(); + await expectComposerFocused(page); + await panel.open(page); + await panel.expectOpen(page); + await blurComposerInput(page); + await panel.close(page); + await panel.expectClosed(page); + await expectComposerNotFocused(page); + await expectComposerDraft(page, draft); + }); + + await test.step("a fullscreen editor blurred under the panel stays blurred", async () => { + await composerLocator(page).click(); + await enterComposerFullscreen(page); + await expectComposerFullscreenControls(page); + await expectComposerFocused(page); + await panel.open(page); + await panel.expectOpen(page); + await blurComposerInput(page); + await panel.close(page); + await panel.expectClosed(page); + await expectComposerNotFocused(page); + await expectComposerFullscreenControls(page); + await expectComposerDraft(page, draft); + await exitComposerFullscreen(page); + await expectWorkspaceHeaderVisible(page); + }); + } finally { + await agent.cleanup(); + } + }); +} diff --git a/packages/app/e2e/browser/composer-whitespace.spec.ts b/packages/app/e2e/browser/composer-whitespace.spec.ts index 0c74fc7630..8e1a527f0f 100644 --- a/packages/app/e2e/browser/composer-whitespace.spec.ts +++ b/packages/app/e2e/browser/composer-whitespace.spec.ts @@ -37,6 +37,7 @@ import { closeMobileAgentSidebar, expectMobileAgentSidebarHidden, expectMobileAgentSidebarVisible, + swipeOpenMobileAgentSidebar, } from "../support/helpers/sidebar"; async function composerHeight(page: Page): Promise { @@ -165,22 +166,12 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page } } }); -async function swipeOpenMobileAgentSidebar(page: Page): Promise { - const viewport = page.viewportSize(); - if (!viewport) throw new Error("viewport size is required"); - const y = Math.round(viewport.height / 2); - await page.mouse.move(8, y); - await page.mouse.down(); - await page.mouse.move(40, y, { steps: 4 }); - await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 }); - await page.mouse.up(); -} - /** Freeze the page's timers and animation frames until `page.clock.resume()`. */ async function pausePageClock(page: Page): Promise { const now = Date.now(); await page.clock.install({ time: now }); - await page.clock.pauseAt(now); + // The installed clock advances between protocol calls; pause at a future time. + await page.clock.pauseAt(now + 60_000); } /** The send error shares the footer with Send, so whatever keeps Send visible keeps it visible. */ @@ -216,7 +207,7 @@ test("compact long drafts stop growing before the header", async ({ page }) => { } }); -test("compact fullscreen editing preserves the live draft and selection and stays open after send", async ({ +test("compact fullscreen editing preserves the live draft and selection and returns to chat after send", async ({ page, }) => { const agent = await seedMockAgentWorkspace({ @@ -270,7 +261,7 @@ test("compact fullscreen editing preserves the live draft and selection and stay await sendComposerWithButton(page); await expect(composerLocator(page)).toHaveValue(""); }); - await test.step("sending inside fullscreen clears the editor and keeps fullscreen open", async () => { + await test.step("sending inside fullscreen returns to chat and clears the editor", async () => { await writeCompactDraft(page, "First line\nSecond line\nThird line"); await enterComposerFullscreen(page); await writeCompactDraft(page, "Short draft"); @@ -284,10 +275,12 @@ test("compact fullscreen editing preserves the live draft and selection and stay await resizeComposerViewport(page, "portrait"); await sendComposerWithButton(page); await expect(composerLocator(page)).toHaveValue(""); - await expectComposerFullscreenControls(page); - await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled(); + await expectWorkspaceHeaderVisible(page); + await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0); }); await test.step("typing and sending keep working after leaving fullscreen again", async () => { + await writeCompactDraft(page, "First line\nSecond line\nThird line"); + await enterComposerFullscreen(page); await writeCompactDraft(page, "Typed inside fullscreen"); await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled(); await exitComposerFullscreen(page); @@ -349,16 +342,16 @@ test("fullscreen submit rejection retains the editable draft and supports retry" await writeCompactDraft(page, draft); await enterComposerFullscreen(page); await sendComposerWithButton(page); - await expect(page.getByTestId("composer-fullscreen").getByRole("alert")).toHaveText( - "Requested mock prompt rejection", - ); + await expectWorkspaceHeaderVisible(page); + await expect(page.getByRole("alert")).toHaveText("Requested mock prompt rejection"); + await enterComposerFullscreen(page); await expectFullscreenErrorAboveSend(page); await expect(composerLocator(page)).toHaveValue(draft); await expect(composerLocator(page)).toBeEditable(); await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled(); await sendComposerWithButton(page); await expect(composerLocator(page)).toHaveValue(""); - await expectComposerFullscreenControls(page); + await expectWorkspaceHeaderVisible(page); } finally { await agent.cleanup(); } @@ -450,8 +443,7 @@ test("fullscreen Send keeps the configured queue behavior while an agent runs", await expectComposerFullscreenControls(page); await queueComposerWithButton(page); await expect(composerLocator(page)).toHaveValue(""); - await expectComposerFullscreenControls(page); - await exitComposerFullscreen(page); + await expectWorkspaceHeaderVisible(page); await expectQueuedMessageButton(page); } finally { await agent.cleanup(); diff --git a/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts b/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts index e7dbde3bd9..906efcfb42 100644 --- a/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts +++ b/packages/app/e2e/browser/new-workspace-composer-draft.spec.ts @@ -1,7 +1,14 @@ +import { + composerLocator, + enterComposerFullscreen, + exitComposerFullscreen, + resizeComposerViewport, +} from "../support/helpers/composer"; import { expect, test } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { getE2EDaemonPort } from "../support/helpers/daemon-port"; import { + delayBrowserWorkspaceCreatedResponse, expectNewWorkspaceDraft, expectNewWorkspaceProjectSelected, fillNewWorkspaceDraft, @@ -134,3 +141,45 @@ test.describe("New workspace composer draft", () => { } }); }); + +test("fullscreen creation returns inline and keeps submission locked when reopened", async ({ + page, +}) => { + const project = await seedWorkspace({ repoPrefix: "fullscreen-submit-pending-" }); + const delay = await delayBrowserWorkspaceCreatedResponse(page); + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openNewWorkspaceComposer(page, project); + await resizeComposerViewport(page, "portrait"); + await fillNewWorkspaceDraft(page, DRAFT); + await enterComposerFullscreen(page); + await page.getByRole("button", { name: "Create", exact: true }).click(); + await delay.waitForCreateRequest(); + + await test.step("submission returns inline with the original draft and spinner", async () => { + await expect(page.getByTestId("composer-fullscreen")).toHaveCount(0); + await expect(composerLocator(page)).toHaveValue(DRAFT); + await expect(composerLocator(page)).not.toBeEditable(); + await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled(); + await expect(page.getByRole("progressbar")).toBeVisible(); + }); + await test.step("fullscreen can reopen while pending without unlocking or duplicating submission", async () => { + await enterComposerFullscreen(page); + await expect(page.getByTestId("composer-fullscreen")).toBeVisible(); + await expect(composerLocator(page)).toHaveValue(DRAFT); + await expect(composerLocator(page)).not.toBeEditable(); + await expect(page.getByRole("button", { name: "Create", exact: true })).toBeDisabled(); + await expect(page.getByRole("progressbar")).toBeVisible(); + await exitComposerFullscreen(page); + }); + delay.release(); + await page.waitForURL((url) => url.pathname.includes("/workspace/")); + await expect(composerLocator(page)).toHaveValue(""); + await expect(composerLocator(page)).toBeEditable(); + expect(delay.agentRequests).toHaveLength(1); + } finally { + delay.release(); + await project.cleanup(); + } +}); diff --git a/packages/app/e2e/support/helpers/assistant-images.ts b/packages/app/e2e/support/helpers/assistant-images.ts index 63bc182eaf..c4200c7c75 100644 --- a/packages/app/e2e/support/helpers/assistant-images.ts +++ b/packages/app/e2e/support/helpers/assistant-images.ts @@ -87,10 +87,11 @@ export async function emitSettledAssistantImage( client: SeedDaemonClient, agent: ArchiveTabAgent, image: AssistantImageFixture, + destination: string = image.relativePath, ): Promise { await client.sendAgentMessage( agent.id, - `Emit settled assistant image Markdown: ![${image.alt}](${image.relativePath})`, + `Emit settled assistant image Markdown: ![${image.alt}](${destination})`, ); const result = await client.waitForFinish(agent.id, 30_000); if (result.status !== "idle" || result.final?.lastError) { diff --git a/packages/app/e2e/support/helpers/composer.ts b/packages/app/e2e/support/helpers/composer.ts index efd9266657..d4ef8fc2f4 100644 --- a/packages/app/e2e/support/helpers/composer.ts +++ b/packages/app/e2e/support/helpers/composer.ts @@ -56,6 +56,12 @@ export async function expectComposerNotFocused(page: Page): Promise { await expect(composerInput(page)).not.toBeFocused(); } +/** Blur the editor without anything else taking focus. */ +export async function blurComposerInput(page: Page): Promise { + await composerInput(page).blur(); + await expectComposerNotFocused(page); +} + export async function submitMessage(page: Page, text: string): Promise { const input = composerInput(page); await expect(input).toBeEditable({ timeout: 30_000 }); diff --git a/packages/app/e2e/support/helpers/settings.ts b/packages/app/e2e/support/helpers/settings.ts index 1702f7fe44..6b810e6d13 100644 --- a/packages/app/e2e/support/helpers/settings.ts +++ b/packages/app/e2e/support/helpers/settings.ts @@ -370,7 +370,7 @@ export async function findAcpCatalogProvider(page: Page, providerName: string): export async function installAcpCatalogProvider(page: Page, providerName: string): Promise { await findAcpCatalogProvider(page, providerName); - await page.getByRole("button", { name: "Add", exact: true }).click(); + await page.getByRole("button", { name: `Add ${providerName}`, exact: true }).click(); } export async function expectProviderInstalledInSettings( diff --git a/packages/app/e2e/support/helpers/workspace-sheet.ts b/packages/app/e2e/support/helpers/sheet.ts similarity index 66% rename from packages/app/e2e/support/helpers/workspace-sheet.ts rename to packages/app/e2e/support/helpers/sheet.ts index b0544f7df6..6288c6f9c1 100644 --- a/packages/app/e2e/support/helpers/workspace-sheet.ts +++ b/packages/app/e2e/support/helpers/sheet.ts @@ -1,11 +1,16 @@ import { expect, type Locator, type Page } from "@playwright/test"; -/** The compact workspace sheet, named after the tab it hosts. */ -export function workspaceSheet(page: Page, label: string): Locator { +/** A sheet tab's body, named after the tab it shows. */ +export function sheet(page: Page, label: string): Locator { return page.getByRole("dialog", { name: label }); } -/** Sheet height as a fraction of the viewport; mirrors the host's 92% snap point. */ +/** A sheet tab's header, which carries its title, Close, and the Open as tab glyph. */ +export function sheetHeader(page: Page, label: string): Locator { + return page.getByTestId("sheet-tab-header").filter({ hasText: label }); +} + +/** Sheet height as a fraction of the viewport; mirrors the sheet tab's 92% snap point. */ const SHEET_SNAP_FRACTION = 0.92; /** The header starts below the sheet's drag indicator, inside the handle. */ const SHEET_INDICATOR_MAX_HEIGHT = 32; @@ -14,12 +19,11 @@ const SHEET_INDICATOR_MAX_HEIGHT = 32; * The sheet rests at its snap point, docked to the bottom edge. A sheet pushed up past it * covers the workspace header; a sheet sunk below it hides content. */ -export async function expectWorkspaceSheetAtSnap(page: Page, label: string): Promise { +export async function expectSheetAtSnap(page: Page, label: string): Promise { const viewportHeight = page.viewportSize()?.height ?? 0; const snapTop = Math.round(viewportHeight * (1 - SHEET_SNAP_FRACTION)); - const sheet = workspaceSheet(page, label); - const header = sheet.getByTestId("workspace-sheet-header"); - const body = sheet.getByTestId("workspace-sheet-body"); + const header = sheetHeader(page, label); + const body = sheet(page, label); await expect .poll(async () => { const [headerBox, bodyBox] = await Promise.all([header.boundingBox(), body.boundingBox()]); @@ -39,26 +43,22 @@ export async function expectOpenTabCount(page: Page, count: number): Promise { - const sheet = workspaceSheet(page, label); - await sheet.getByRole("button", { name: "Close" }).click(); - await expect(sheet).toHaveCount(0); +export async function closeSheet(page: Page, label: string): Promise { + await sheetHeader(page, label).getByRole("button", { name: "Close" }).click(); + await expect(sheet(page, label)).toHaveCount(0); } -export async function openWorkspaceSheetAsTab(page: Page, label: string): Promise { - const sheet = workspaceSheet(page, label); - await sheet.getByRole("button", { name: "Open as tab" }).click(); - await expect(sheet).toHaveCount(0); +export async function openSheetAsTab(page: Page, label: string): Promise { + await sheetHeader(page, label).getByRole("button", { name: "Open as tab" }).click(); + await expect(sheet(page, label)).toHaveCount(0); } -export async function replaceWorkspaceSheetFileText( +export async function replaceSheetFileText( page: Page, label: string, content: string, ): Promise { - const editor = workspaceSheet(page, label) - .getByTestId("file-source-editor") - .locator(".cm-content"); + const editor = sheet(page, label).getByTestId("file-source-editor").locator(".cm-content"); await editor.click(); await editor.press("ControlOrMeta+A"); await editor.pressSequentially(content); @@ -69,6 +69,7 @@ export async function selectTabFromSwitcher(page: Page, label: string): Promise< .getByRole("button", { name: /Switch tabs/ }) .filter({ visible: true }) .click(); + await expect(page.getByText("Workspace tabs", { exact: true })).toBeVisible(); const option = page.getByText(label, { exact: true }).filter({ visible: true }).last(); await expect(option).toBeVisible(); await option.click(); @@ -76,7 +77,7 @@ export async function selectTabFromSwitcher(page: Page, label: string): Promise< } /** Taps the top sheet's backdrop; each stacked sheet renders its own, top last. */ -export async function tapWorkspaceSheetBackdrop(page: Page): Promise { +export async function tapSheetBackdrop(page: Page): Promise { await page .getByRole("button", { name: "Bottom sheet backdrop" }) .last() diff --git a/packages/app/e2e/support/helpers/sidebar.ts b/packages/app/e2e/support/helpers/sidebar.ts index 1909b4f31b..500ce8bd41 100644 --- a/packages/app/e2e/support/helpers/sidebar.ts +++ b/packages/app/e2e/support/helpers/sidebar.ts @@ -250,3 +250,41 @@ export async function expectMobileAgentSidebarVisible(page: Page): Promise export async function expectMobileAgentSidebarHidden(page: Page): Promise { await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 }); } + +/** Drag from the left edge: the only way to open the list while fullscreen hides the header. */ +export async function swipeOpenMobileAgentSidebar(page: Page): Promise { + const viewport = page.viewportSize(); + if (!viewport) throw new Error("viewport size is required"); + const y = Math.round(viewport.height / 2); + await page.mouse.move(8, y); + await page.mouse.down(); + await page.mouse.move(40, y, { steps: 4 }); + await page.mouse.move(Math.round(viewport.width * 0.7), y, { steps: 12 }); + await page.mouse.up(); +} + +/** Drag from the right edge to open the compact Explorer over the agent. */ +export async function swipeOpenMobileExplorer(page: Page): Promise { + const viewport = page.viewportSize(); + if (!viewport) throw new Error("viewport size is required"); + const y = Math.round(viewport.height / 2); + await page.mouse.move(viewport.width - 8, y); + await page.mouse.down(); + await page.mouse.move(viewport.width - 40, y, { steps: 4 }); + await page.mouse.move(Math.round(viewport.width * 0.3), y, { steps: 12 }); + await page.mouse.up(); +} + +export async function closeMobileExplorer(page: Page): Promise { + const closeButton = page.getByTestId("explorer-close"); + await expect(closeButton).toBeInViewport({ ratio: 1, timeout: 5_000 }); + await closeButton.click(); +} + +export async function expectMobileExplorerVisible(page: Page): Promise { + await expect(page.getByTestId("explorer-header")).toBeInViewport({ ratio: 1, timeout: 5_000 }); +} + +export async function expectMobileExplorerHidden(page: Page): Promise { + await expect(page.getByTestId("explorer-header")).not.toBeInViewport({ timeout: 5_000 }); +} diff --git a/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts index 01055ce2c1..f7b62fd953 100644 --- a/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts +++ b/packages/app/e2e/support/helpers/timeline-scroll-smoothness.ts @@ -297,11 +297,7 @@ export function findScrollJumps(frames: ScrollFrame[]) { // Follow that intended row, not text now below the image. const recent = frames.findLast((frame) => frame.at <= previous.at - 100); const wheelBudget = current.wheelTotal - (recent?.wheelTotal ?? 0); - const frameScroll = Math.max(0, previous.scrollTop - current.scrollTop); - const availableScroll = Math.min(frameScroll, wheelBudget, previous.scrollTop); - const readingLine = 8 - availableScroll; const currentRows = new Map(current.rows.map((row) => [row.id, row])); - const intendedRow = previous.rows.find((row) => row.top + row.height > readingLine); const anchor = previous.anchor; const before = currentRows.has(anchor) ? previous.rows.find((row) => row.id === anchor)! @@ -316,25 +312,33 @@ export function findScrollJumps(frames: ScrollFrame[]) { // A busy main thread can deliver wheel movement hundreds of milliseconds // after its event. Only assert idle stability after the driver stops input. const idle = current.inputFinishedAt !== null && previous.at - current.inputFinishedAt > 250; - // Wheel events can precede their scroll update, or include input already - // applied in earlier frames. Locate the reader with this frame's scroll, - // bounded by recent input. Growth below it can move the old anchor without - // moving the reading line. - const readerFollowsInput = - intendedRow && - currentRows.has(intendedRow.id) && - Math.abs(currentRows.get(intendedRow.id)!.top - intendedRow.top - availableScroll) <= 32; - const enteredRowGrowth = readerFollowsInput + // scrollTop also includes virtualizer estimate corrections. Locate the row + // entered by wheel input from its viewport movement instead. Its old height + // identifies the reader before a simultaneous image resize takes effect. + const intendedRow = previous.rows.find((row) => { + const next = currentRows.get(row.id); + if (!next || idle || wheelBudget <= 0) return false; + const travel = next.top - row.top; + return ( + travel >= -8 && + travel <= Math.min(wheelBudget, previous.scrollTop) + 32 && + (next.top <= 8 || current.scrollTop === 0) && + next.top + row.height > 8 + ); + }); + const enteredRowResize = intendedRow ? previous.rows.reduce((growth, row) => { if (row.top < intendedRow.top || row.top >= before.top) return growth; const height = currentRows.get(row.id)?.height ?? row.height; - return growth + Math.max(0, height - row.height); + return growth + height - row.height; }, 0) : 0; - const excessForward = movement > wheelBudget + enteredRowGrowth + 32; + const readingMovement = movement - enteredRowResize; + const excessForward = readingMovement > wheelBudget + 32; // Upward wheel input moves the same text DOWN the viewport. A negative move // is a reversal, independent of legitimate scrollTop compensation on prepend. - if (movement >= -8 && (!idle || Math.abs(movement) <= 8) && !excessForward) return []; + if (readingMovement >= -8 && (!idle || Math.abs(readingMovement) <= 8) && !excessForward) + return []; return [ { frame: index, diff --git a/packages/app/src/assets/acp-provider-icons.ts b/packages/app/src/assets/acp-provider-icons.ts index 65f123cb3d..cebb4ca76d 100644 --- a/packages/app/src/assets/acp-provider-icons.ts +++ b/packages/app/src/assets/acp-provider-icons.ts @@ -48,6 +48,8 @@ export const ACP_PROVIDER_ICON_SVGS = { goose: '\n \n\n', grok: '\n \n \n\n', + hoplite: + '\n', junie: '\n \n \n \n\n', kilo: '\n \n\n', diff --git a/packages/app/src/assets/acp-provider-icons/hoplite.svg b/packages/app/src/assets/acp-provider-icons/hoplite.svg new file mode 100644 index 0000000000..4d9c443429 --- /dev/null +++ b/packages/app/src/assets/acp-provider-icons/hoplite.svg @@ -0,0 +1 @@ + diff --git a/packages/app/src/attachments/utils.test.ts b/packages/app/src/attachments/utils.test.ts index d6fce078f2..9bf982c6e8 100644 --- a/packages/app/src/attachments/utils.test.ts +++ b/packages/app/src/attachments/utils.test.ts @@ -42,6 +42,18 @@ describe("fileUriToPath", () => { }); describe("localFileSourceToPath", () => { + it.each([ + "https://example.com/image%20name.png", + "data:image/png;base64,abc%20", + "blob:https://example.com/image%20name", + ])("preserves a direct Markdown image URI: %s", (source) => { + expect(localFileSourceToPath(source, { decodeHref: true })).toBe(source); + }); + + it("preserves malformed percent escapes rather than throwing", () => { + expect(localFileSourceToPath("/tmp/100%.png", { decodeHref: true })).toBe("/tmp/100%.png"); + }); + it("decodes markdown-encoded Windows drive-letter paths", () => { expect(localFileSourceToPath("C:%5CUsers%5Cfile.txt")).toBe("C:/Users/file.txt"); }); diff --git a/packages/app/src/attachments/utils.ts b/packages/app/src/attachments/utils.ts index 5fa0334359..46483bf0e7 100644 --- a/packages/app/src/attachments/utils.ts +++ b/packages/app/src/attachments/utils.ts @@ -173,11 +173,18 @@ export function fileUriToPath(uri: string): string { return normalizeWindowsDrivePath(decodedPath.replace(/^\/([A-Za-z]:[\\/])/, "$1")); } -export function localFileSourceToPath(source: string): string { +export function localFileSourceToPath( + source: string, + // Markdown destinations are URI-encoded; plain filesystem paths are not. + { decodeHref = false }: { decodeHref?: boolean } = {}, +): string { + if (/^(https?:|data:|blob:)/i.test(source)) { + return source; + } let path = source; if (source.startsWith("file://")) { path = fileUriToPath(source); - } else if (isMarkdownEncodedWindowsDrivePath(source)) { + } else if (decodeHref || isMarkdownEncodedWindowsDrivePath(source)) { path = decodeFilePathSource(source); } return normalizeWindowsDrivePath(path); diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index f7b4d4056c..d4d833e3f1 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -14,7 +14,7 @@ import { } from "../lib/overlay-root"; import { BottomSheetHandle, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; import Animated from "react-native-reanimated"; -import { ArrowLeft, Search, X } from "lucide-react-native"; +import { ArrowLeft, Search, X, type LucideIcon } from "lucide-react-native"; import { IsolatedBottomSheetModal, SheetVisibleFrame, @@ -287,6 +287,37 @@ function SheetContent({ style, children }: { style: StyleProp; childr return {children}; } +/** + * An icon action in a sheet header's trailing rail, drawn like the Close button so a header's + * actions line up with it at one size. Pass it through `SheetHeader.actions`. + */ +export function SheetHeaderIconButton({ + icon: Icon, + accessibilityLabel, + onPress, + testID, +}: { + icon: LucideIcon; + accessibilityLabel: string; + onPress: () => void; + testID?: string; +}) { + const { theme } = useUnistyles(); + return ( + + {({ pressed }) => ( + + )} + + ); +} + export function SheetHeaderView({ header, onClose, @@ -343,19 +374,11 @@ export function SheetHeaderView({ {header.actions ? {header.actions} : null} {showCloseButton ? ( - - {({ pressed }) => ( - - )} - + /> ) : null} {search ? ( diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 4587556ebe..7915832470 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -95,6 +95,7 @@ import { } from "@/assistant-file-links"; import { getCompactionMarkerLabel } from "./message-compaction-label"; import { useAssistantImage } from "@/assistant-image/use-assistant-image"; +import { localFileSourceToPath } from "@/attachments/utils"; import { AttachmentFrame, AttachmentLabel, @@ -1945,7 +1946,7 @@ export const AssistantMessage = memo(function AssistantMessage({ return ( Promise | void; } -const SEARCH_ICON_SIZE = 16; const PROVIDER_FALLBACK_ICON_SIZE = 20; const PROVIDER_REMOTE_ICON_SIZE = 24; +const TILE_GAP = SPACING[3]; +/** Narrowest tile that keeps the icon, type badge, install link, and Add on one row. */ +const MIN_TILE_WIDTH = 300; +const MAX_COLUMNS = 4; const ThemedPackagePlus = withUnistyles(PackagePlus); const ThemedSvgXml = withUnistyles(SvgXml); -const ThemedSearch = withUnistyles(Search); -const ThemedExternalLink = withUnistyles(ExternalLink); -const ThemedTextInput = withUnistyles(TextInput, (theme) => ({ - placeholderTextColor: theme.colors.foregroundMuted, -})); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); -const foregroundMutedColorMapping = (theme: Theme) => ({ - color: theme.colors.foregroundMuted, -}); -function matchesSearch(entry: AcpProviderCatalogItem, query: string): boolean { - const normalized = query.trim().toLowerCase(); - if (!normalized) return true; - return [entry.title, entry.id, entry.description].some((value) => - value.toLowerCase().includes(normalized), - ); +function columnCountForWidth(width: number): number { + const fits = Math.floor((width + TILE_GAP) / (MIN_TILE_WIDTH + TILE_GAP)); + return Math.min(MAX_COLUMNS, Math.max(1, fits)); } -interface CatalogRowProps { +interface CatalogTileProps { entry: AcpProviderCatalogItem; + /** `undefined` until the grid is measured, or in a single column; the tile fills the row. */ + width: number | undefined; installing: boolean; onInstall: (entry: AcpProviderCatalogItem) => void; } -function CatalogRow({ entry, installing, onInstall }: CatalogRowProps) { +function CatalogTile({ entry, width, installing, onInstall }: CatalogTileProps) { const { t } = useTranslation(); - const actionLabel = installing - ? t("providerCatalog.actions.adding") - : t("providerCatalog.actions.add"); const handleInstall = useCallback(() => { onInstall(entry); }, [entry, onInstall]); - - const handleOpenInstallLink = useCallback(() => { - void openExternalUrl(entry.installLink); - }, [entry.installLink]); + const tileStyle = useMemo( + () => [settingsStyles.card, styles.tile, width === undefined ? styles.tileFull : { width }], + [width], + ); return ( - + {entry.iconSvg ? ( )} - - - - {entry.title} - - - {entry.version} - - - - {entry.description || entry.id} + + + {entry.title} - - - {t("providerCatalog.actions.installInstructions")} - - - + + {/* The catalog is the ACP registry, so every entry it offers is an ACP provider. */} + + + ); @@ -129,6 +113,7 @@ export function ProviderCatalogList({ const { entries: catalogEntries } = useAcpProviderCatalog(); const { entries: providerEntries } = useProvidersSnapshot(serverId); const [search, setSearch] = useState(""); + const [gridWidth, setGridWidth] = useState(null); const installedIds = useMemo( () => new Set(providerEntries?.map((entry) => entry.provider) ?? []), @@ -137,41 +122,48 @@ export function ProviderCatalogList({ const availableEntries = useMemo( () => - catalogEntries - .filter((entry) => !installedIds.has(entry.id)) - .filter((entry) => matchesSearch(entry, search)), + rankProviderCatalog( + catalogEntries.filter((entry) => !installedIds.has(entry.id)), + search, + ), [catalogEntries, installedIds, search], ); + const handleGridLayout = useCallback((event: LayoutChangeEvent) => { + setGridWidth(event.nativeEvent.layout.width); + }, []); + + const tileWidth = useMemo(() => { + if (gridWidth === null) return undefined; + const columns = columnCountForWidth(gridWidth); + if (columns === 1) return undefined; + return Math.floor((gridWidth - TILE_GAP * (columns - 1)) / columns); + }, [gridWidth]); + return ( - - - - - + {availableEntries.length === 0 ? ( - + {t("providerCatalog.noProviders")} ) : ( - + {availableEntries.map((entry) => ( - @@ -183,101 +175,56 @@ export function ProviderCatalogList({ } const styles = StyleSheet.create((theme) => ({ - searchField: { + searchRail: { flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - backgroundColor: theme.colors.surface2, - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.border, - paddingHorizontal: theme.spacing[3], marginBottom: theme.spacing[3], }, - searchIcon: { - width: 18, - alignItems: "center", + grid: { + flexDirection: "row", + flexWrap: "wrap", + gap: TILE_GAP, }, - searchInput: { - flex: 1, - minWidth: 0, - paddingVertical: theme.spacing[3], - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, - list: { - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.border, - overflow: "hidden", - }, - row: { + tile: { flexDirection: "row", alignItems: "center", gap: theme.spacing[3], padding: theme.spacing[3], - borderBottomWidth: 1, - borderBottomColor: theme.colors.border, + }, + tileFull: { + width: "100%", + }, + details: { + flex: 1, + minWidth: 0, + gap: theme.spacing[1], }, iconFrame: { + flexShrink: 0, width: 36, height: 36, borderRadius: theme.borderRadius.md, backgroundColor: theme.colors.surface2, alignItems: "center", justifyContent: "center", + }, + addButton: { flexShrink: 0, }, - textColumn: { - flex: 1, - minWidth: 0, - gap: theme.spacing[1], - }, - titleRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - minWidth: 0, - }, name: { color: theme.colors.foreground, fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.medium, - flexShrink: 1, + fontWeight: theme.fontWeight.normal, }, - version: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - flexShrink: 0, - }, - description: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, - installLink: { - alignSelf: "flex-start", + metaRow: { flexDirection: "row", + flexWrap: "wrap", alignItems: "center", - gap: theme.spacing[1], - maxWidth: "100%", - }, - installLinkText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, - actionButton: { - width: 92, - flexShrink: 0, + gap: theme.spacing[2], }, stateBox: { minHeight: 96, - borderRadius: theme.borderRadius.lg, - borderWidth: 1, - borderColor: theme.colors.border, - backgroundColor: theme.colors.surface1, alignItems: "center", justifyContent: "center", - gap: theme.spacing[3], padding: theme.spacing[4], }, stateText: { diff --git a/packages/app/src/components/provider-catalog-search.test.ts b/packages/app/src/components/provider-catalog-search.test.ts new file mode 100644 index 0000000000..4079d79602 --- /dev/null +++ b/packages/app/src/components/provider-catalog-search.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { rankProviderCatalog } from "./provider-catalog-search"; + +const entries = [ + { id: "kiro-cli", title: "Kiro CLI" }, + { id: "goose", title: "goose" }, + { id: "kimi", title: "Kimi Code CLI" }, + { id: "gemini", title: "Gemini CLI" }, + { id: "qwen-code", title: "Qwen Code" }, + { id: "codex-acp", title: "Codex" }, +]; + +function titles(list: { title: string }[]): string[] { + return list.map((entry) => entry.title); +} + +describe("rankProviderCatalog", () => { + it("lists every provider alphabetically by name without a query", () => { + expect(titles(rankProviderCatalog(entries, " "))).toEqual([ + "Codex", + "Gemini CLI", + "goose", + "Kimi Code CLI", + "Kiro CLI", + "Qwen Code", + ]); + }); + + it("ranks the strongest match first and drops providers that do not match", () => { + expect(titles(rankProviderCatalog(entries, "cod"))).toEqual([ + "Codex", + "Kimi Code CLI", + "Qwen Code", + ]); + }); + + it("matches in-word subsequences and multiple tokens like the other pickers", () => { + expect(titles(rankProviderCatalog(entries, "gmni"))).toEqual(["Gemini CLI"]); + expect(titles(rankProviderCatalog(entries, "qw cod"))).toEqual(["Qwen Code"]); + }); + + it("matches the provider id", () => { + expect(titles(rankProviderCatalog(entries, "kiro-cli"))).toEqual(["Kiro CLI"]); + }); +}); diff --git a/packages/app/src/components/provider-catalog-search.ts b/packages/app/src/components/provider-catalog-search.ts new file mode 100644 index 0000000000..23303c868f --- /dev/null +++ b/packages/app/src/components/provider-catalog-search.ts @@ -0,0 +1,34 @@ +import { + compareMatchScores, + scoreTextFields, + type MatchScore, +} from "@getpaseo/protocol/search/text-match"; + +export interface SearchableCatalogEntry { + id: string; + title: string; +} + +function compareTitles(a: SearchableCatalogEntry, b: SearchableCatalogEntry): number { + return a.title.localeCompare(b.title, undefined, { sensitivity: "base" }); +} + +/** + * Orders the Add provider catalog. Without a query the list is alphabetical by + * name. With a query it uses the pickers' ranked matcher over the fields the + * tile shows (name and id), best match first, ties alphabetical. + */ +export function rankProviderCatalog( + entries: readonly T[], + query: string, +): T[] { + if (!query.trim()) return [...entries].sort(compareTitles); + + const scored: { entry: T; score: MatchScore }[] = []; + for (const entry of entries) { + const score = scoreTextFields(query, [entry.title, entry.id]); + if (score) scored.push({ entry, score }); + } + scored.sort((a, b) => compareMatchScores(a.score, b.score) || compareTitles(a.entry, b.entry)); + return scored.map(({ entry }) => entry); +} diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx new file mode 100644 index 0000000000..d107547dcf --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/host-scope.tsx @@ -0,0 +1,91 @@ +import { + BottomSheetModalInternalProvider, + INITIAL_CONTAINER_LAYOUT, + useBottomSheetModalInternal, +} from "@gorhom/bottom-sheet"; +import { PortalHost } from "@gorhom/portal"; +import { useCallback, useId, useMemo, useRef, type ReactNode } from "react"; +import { + Dimensions, + StatusBar, + StyleSheet, + View, + type LayoutChangeEvent, + type StyleProp, + type ViewStyle, +} from "react-native"; +import { useSharedValue } from "react-native-reanimated"; + +/** + * Hosts the sheets presented from inside it. + * + * A sheet's content normally lands at the app root, on the far side of the portal, where every + * context between the root and the call site is gone (floating-panels.md Gotcha 7). Inside a + * scope, a sheet renders into the scope's own host instead, so its content reads the scope's + * contexts — the navigator, the workspace providers — without bridging each one by hand. The + * scope only moves where the sheet draws: it still joins the app-wide sheet stack, so stacking, + * Back and the backdrop treat it like every other sheet. + * + * The host fills the scope, and the sheets presented in it size and dock to the scope, not the + * window. Sheets presented outside the scope keep drawing at the root, above it. + */ +export function SheetHostScope({ + children, + style, +}: { + children: ReactNode; + style?: StyleProp; +}) { + const parent = useBottomSheetModalInternal(); + const hostName = `sheet-host-${useId()}`; + const containerLayoutState = useSharedValue({ ...INITIAL_CONTAINER_LAYOUT }); + const hostRef = useRef(null); + + // Mirrors Gorhom's own hosting container measurement, so a sheet in the scope lays out against + // the scope the way a root sheet lays out against the window. + const handleLayout = useCallback( + ({ nativeEvent }: LayoutChangeEvent) => { + const { height } = nativeEvent.layout; + hostRef.current?.measure((_x, _y, _width, _height, _pageX, pageY) => { + const windowHeight = Dimensions.get("window").height; + const top = pageY ?? 0; + containerLayoutState.set({ + height, + offset: { + top, + bottom: Math.max(0, windowHeight - (top + height + (StatusBar.currentHeight ?? 0))), + left: 0, + right: 0, + }, + }); + }); + }, + [containerLayoutState], + ); + + const value = useMemo( + () => ({ ...parent, hostName, containerLayoutState }), + [containerLayoutState, hostName, parent], + ); + + return ( + + + {children} + + + + + + ); +} + +const styles = StyleSheet.create({ + scope: { + flex: 1, + minHeight: 0, + }, + host: { + ...StyleSheet.absoluteFillObject, + }, +}); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx index 55e475b424..457181c53f 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx @@ -40,6 +40,13 @@ type IsolatedBottomSheetModalProps = Omit< BottomSheetModalProps, "enableDismissOnClose" | "stackBehavior" | "children" | "backdropComponent" > & { + /** + * Whether closing (drag, backdrop, Back) also dismisses, which unmounts the content. Default + * true: the owner hears `onDismiss` and drops its state. Pass `false` for a sheet whose close the + * owner may veto: it parks at index -1 with its content mounted, the owner hears `onChange(-1)`, + * and either snaps it back or unmounts it, which dismisses it from the stack. + */ + enableDismissOnClose?: boolean; /** * Nodes only. Gorhom also accepts a render function, but nothing here uses it and a bridge * would have to reach around it. @@ -84,19 +91,24 @@ export const IsolatedBottomSheetModal = forwardRef< presentation = "push", contextBridge, backdropOpacity, + enableDismissOnClose = true, ...bottomSheetProps } = props; const renderBackdrop = useCallback( (backdropProps: BottomSheetBackdropProps) => ( - + ), - [backdropOpacity], + [backdropOpacity, enableDismissOnClose], ); const modal = ( @@ -155,3 +167,4 @@ export function useIsolatedBottomSheetVisibility({ export { SheetVisibleFrame } from "./visible-frame"; export { systemBackPress } from "./back-press"; +export { SheetHostScope } from "./host-scope"; diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx index 1232566462..8c85ef77ca 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.tsx @@ -1,5 +1,15 @@ import { BottomSheetBackdrop, type BottomSheetBackdropProps } from "@gorhom/bottom-sheet"; -export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) { +/** + * What a backdrop tap does. `dismiss` takes the top modal off the stack and unmounts it; `close` + * only slides this sheet down, so a sheet that keeps its content through a close can veto it. + */ +export type SheetBackdropPress = "dismiss" | "close"; + +export function SheetBackdrop({ + pressBehavior: _pressBehavior, + ...props +}: BottomSheetBackdropProps & { opacity?: number; pressBehavior: SheetBackdropPress }) { + // Gorhom's native backdrop closes its own sheet, which here is always the top one. return ; } diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx index ba1e397a9e..749d89359c 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/sheet-backdrop.web.tsx @@ -1,16 +1,30 @@ import { BottomSheetBackdrop, + useBottomSheet, useBottomSheetModal, type BottomSheetBackdropProps, } from "@gorhom/bottom-sheet"; import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Pressable, StyleSheet, View } from "react-native"; +import type { SheetBackdropPress } from "./sheet-backdrop"; -export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: number }) { +export type { SheetBackdropPress }; + +export function SheetBackdrop({ + pressBehavior, + ...props +}: BottomSheetBackdropProps & { opacity?: number; pressBehavior: SheetBackdropPress }) { const { dismiss } = useBottomSheetModal(); + const { close } = useBottomSheet(); const { t } = useTranslation(); - const dismissTopSheet = useCallback(() => dismiss(), [dismiss]); + const handlePress = useCallback(() => { + // A sheet that may veto its close slides itself down and lets its owner decide. The other + // sheets ask the provider for the top modal, so a lower backdrop caught mid-transition still + // dismisses the sheet the user sees. + if (pressBehavior === "close") close(); + else dismiss(); + }, [close, dismiss, pressBehavior]); return ( @@ -28,7 +42,7 @@ export function SheetBackdrop(props: BottomSheetBackdropProps & { opacity?: numb accessibilityLabel={t("common.bottomSheetBackdrop")} // The provider owns presentation order, including when a lower backdrop remounts. // Keep this target until the portal unmounts: animation must not expose the sheet below. - onPress={dismissTopSheet} + onPress={handlePress} style={StyleSheet.absoluteFill} /> diff --git a/packages/app/src/components/ui/menu/menu-surface.tsx b/packages/app/src/components/ui/menu/menu-surface.tsx index 3b76d4cf2b..2f73149244 100644 --- a/packages/app/src/components/ui/menu/menu-surface.tsx +++ b/packages/app/src/components/ui/menu/menu-surface.tsx @@ -126,8 +126,14 @@ export interface MenuSurfaceProps { * popover (plus one flyout per open submenu) or a bottom sheet showing one page at a time. */ export function MenuSurface(props: MenuSurfaceProps): ReactElement | null { - const { presentation } = useMenuContext("MenuSurface"); + const { presentation, open, setOpen } = useMenuContext("MenuSurface"); const active = useRetainedPanelActive(); + // A menu belongs to the panel that opened it. When that panel leaves the screen the menu closes + // for good, rather than waiting hidden to reappear with the panel: a row that opened a sheet + // and promoted it to a tab, say, would otherwise bring the list back on the next tab switch. + useEffect(() => { + if (!active && open) setOpen(false); + }, [active, open, setOpen]); // Portals escape the hidden panel. Remove the surface in its inactive commit, // before freezing can suspend an async menu action's closing update. if (!active) return null; diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 21bf7f6679..10d670fcae 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1196,11 +1196,16 @@ export function Composer({ isPaneFocused, ...props }: ComposerProps) { // A compact overlay panel (agent list, explorer) covers the pane without taking its // focus. The composer gives up input from the moment the user asks for the panel, // not when its motion settles, so the exit action and autocomplete never race it. + // Pane focus stays put, so the keyboard the panel dismisses is a blur in the + // user's own pane: closing the panel does not bring focus back. const isCompact = useIsCompactFormFactor(); const overlayPanelRequested = usePanelStore((state) => state.mobilePanel.target !== "agent"); const coveredByOverlayPanel = isCompact && overlayPanelRequested; return ( - + diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index a1b8c0fdbb..7346095d5b 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -524,8 +524,8 @@ function MessageInputAutoFocus({ autoFocusKey: string | undefined; textInputRef: React.MutableRefObject; }) { - const { isActiveComposer } = useComposerKeyboardScope(); - useAutoFocusOnWebEffect(textInputRef, enabled && isActiveComposer, autoFocusKey); + const { isPaneFocused } = useComposerKeyboardScope(); + useAutoFocusOnWebEffect(textInputRef, enabled && isPaneFocused, autoFocusKey); return null; } @@ -942,6 +942,7 @@ interface SendMessageContext { onSubmit: (payload: MessagePayload) => void; onMinimizeHeight: () => void; preserveHeightOnSubmit: boolean; + exitFullscreen: () => void; } function sendMessageImpl(ctx: SendMessageContext): void { @@ -954,6 +955,7 @@ function sendMessageImpl(ctx: SendMessageContext): void { ) { return; } + ctx.exitFullscreen(); ctx.onSubmit({ text: trimmed, attachments: ctx.attachments, @@ -974,12 +976,14 @@ interface QueueMessageContext { onQueue: ((payload: MessagePayload) => void) | undefined; replaceText: (text: string) => void; onMinimizeHeight: () => void; + exitFullscreen: () => void; } function queueMessageImpl(ctx: QueueMessageContext): void { if (!ctx.onQueue) return; const trimmed = ctx.value.trim(); if (!trimmed && ctx.attachments.length === 0) return; + ctx.exitFullscreen(); ctx.onQueue({ text: trimmed, attachments: ctx.attachments, cwd: ctx.cwd }); ctx.replaceText(""); ctx.onMinimizeHeight(); @@ -1236,7 +1240,7 @@ export const MessageInput = forwardRef( textReplacement, submitLabel, } = resolveMessageInputProps(props); - const { isActiveComposer } = useComposerKeyboardScope(); + const { isPaneFocused } = useComposerKeyboardScope(); const mode = resolveComposerInputMode(inputMode); const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); @@ -1264,8 +1268,8 @@ export const MessageInput = forwardRef( const focusIntentRef = useRef(false); const valueRef = useRef(value); const selectionRef = useRef({ start: value.length, end: value.length }); - const activeComposerRef = useRef(isActiveComposer); - activeComposerRef.current = isActiveComposer; + const paneFocusedRef = useRef(isPaneFocused); + paneFocusedRef.current = isPaneFocused; const [webTextArea, setWebTextArea] = useState(null); const appliedTextReplacementKeyRef = useRef(textReplacement.key); const webTextareaRef = useRef(null); @@ -1285,9 +1289,13 @@ export const MessageInput = forwardRef( webTextareaRef.current = element; setWebTextArea(element); }, []); + // Focus intent outlives a blur only while the pane is not the user's pane (a retained + // tab hidden, a sheet over it); the pane coming back restores focus. A blur while the + // pane is theirs drops it, whatever the layout mode: the user dismissed the keyboard, + // or a compact overlay panel did on their behalf, and nothing later refocuses. useLayoutEffect(() => { - if (isActiveComposer && focusIntentRef.current) textInputRef.current?.focus(); - }, [isActiveComposer]); + if (isPaneFocused && focusIntentRef.current) textInputRef.current?.focus(); + }, [isPaneFocused]); // The compact and desktop screens mount the editor in different trees, so a // viewport that crosses the breakpoint remounts it. The draft owner brings the // text back; the session brings the caret and focus intent back. On web the @@ -1305,7 +1313,7 @@ export const MessageInput = forwardRef( textInputRef.current?.setSelection(selection); if (retained.focused) { focusIntentRef.current = true; - if (activeComposerRef.current) textInputRef.current?.focus(); + if (paneFocusedRef.current) textInputRef.current?.focus(); } } return () => { @@ -1636,6 +1644,7 @@ export const MessageInput = forwardRef( onSubmit, onMinimizeHeight: minimizeInputHeight, preserveHeightOnSubmit, + exitFullscreen, }); }, [ allowEmptySubmit, @@ -1647,6 +1656,7 @@ export const MessageInput = forwardRef( minimizeInputHeight, preserveHeightOnSubmit, updateLiveTextPresence, + exitFullscreen, ]); const handleQueueMessage = useCallback( @@ -1658,8 +1668,9 @@ export const MessageInput = forwardRef( onQueue, replaceText, onMinimizeHeight: minimizeInputHeight, + exitFullscreen, }), - [attachments, cwd, onQueue, replaceText, minimizeInputHeight], + [attachments, cwd, onQueue, replaceText, minimizeInputHeight, exitFullscreen], ); const handleDefaultSendAction = useCallback(() => { @@ -1807,13 +1818,11 @@ export const MessageInput = forwardRef( }, [onFocusChange]); const handleInputBlur = useCallback(() => { - // Fullscreen is nothing but the editor, so a blur there (a retained tab being - // hidden, the keyboard dismissed) keeps the intent: showing it again refocuses. - if (activeComposerRef.current && !isFullscreen) focusIntentRef.current = false; + if (paneFocusedRef.current) focusIntentRef.current = false; isInputFocusedRef.current = false; setIsInputFocused(false); onFocusChange?.(false); - }, [isFullscreen, onFocusChange]); + }, [onFocusChange]); const handlePasteError = useCallback( (message: string) => { diff --git a/packages/app/src/composer/keyboard-scope.tsx b/packages/app/src/composer/keyboard-scope.tsx index b254c53a51..20b57dde28 100644 --- a/packages/app/src/composer/keyboard-scope.tsx +++ b/packages/app/src/composer/keyboard-scope.tsx @@ -7,18 +7,35 @@ import { } from "react"; interface ComposerKeyboardScopeValue { + /** + * The pane this composer sits in is the user's pane. Focus restoration follows it: + * the editor keeps its focus intent across blurs that happen while the pane is not + * the user's (a retained tab hidden, a sheet over it) and brings focus back when + * the pane is theirs again. + */ + isPaneFocused: boolean; + /** + * The composer takes input right now: keyboard actions, shortcuts, autocomplete, + * and the fullscreen exit action. False while a compact overlay panel covers the + * pane, which does not take pane focus. + */ isActiveComposer: boolean; } const ComposerKeyboardScopeContext = createContext({ + isPaneFocused: false, isActiveComposer: false, }); export function ComposerKeyboardScopeProvider({ + isPaneFocused, isActiveComposer, children, }: PropsWithChildren): ReactElement { - const value = useMemo(() => ({ isActiveComposer }), [isActiveComposer]); + const value = useMemo( + () => ({ isPaneFocused, isActiveComposer }), + [isPaneFocused, isActiveComposer], + ); return ( {children} diff --git a/packages/app/src/composer/tracks-panel.browser.test.tsx b/packages/app/src/composer/tracks-panel.browser.test.tsx index 0b74c7e1da..9ebc92ed20 100644 --- a/packages/app/src/composer/tracks-panel.browser.test.tsx +++ b/packages/app/src/composer/tracks-panel.browser.test.tsx @@ -1,7 +1,8 @@ -import React, { act, type ReactNode } from "react"; +import React, { act, useCallback, useState, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { Pressable, Text } from "react-native"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RetainedPanelActivity } from "@/components/retained-panel"; import { ComposerTrackPill, ComposerTrackRow, type ComposerTrackPillSegment } from "./tracks"; const SUBAGENT_SEGMENTS: ComposerTrackPillSegment[] = [{ bucket: null, text: "3 subagents" }]; @@ -109,6 +110,43 @@ describe("composer track panel", () => { expect(row("row")).not.toBeNull(); }); + it("closes for good when the panel it belongs to leaves the screen", () => { + const onPress = vi.fn(); + function Harness() { + const [active, setActive] = useState(true); + const hide = useCallback(() => setActive(false), []); + const show = useCallback(() => setActive(true), []); + return ( + <> + + + + + + Subagent one + + + + + ); + } + mount(); + + openPanel(); + expect(isPanelOpen()).toBe(true); + + click(row("hide") as Element); + click(row("show") as Element); + + expect(isPanelOpen()).toBe(false); + expect(row("row")).toBeNull(); + }); + it("leaves the panel alone when a row's own action button is pressed", () => { const onPress = vi.fn(); const onAction = vi.fn(); diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts index 1fdb1c6f5d..1bd32db53d 100644 --- a/packages/app/src/data/acp-provider-catalog.ts +++ b/packages/app/src/data/acp-provider-catalog.ts @@ -8,6 +8,7 @@ export interface AcpProviderCatalogEntry { iconSvg: string | null; installLink: string; command: readonly [string, ...string[]]; + bundledProvider?: boolean; env?: Readonly>; params?: Readonly>; } @@ -238,6 +239,16 @@ const CATALOG_DATA = [ installLink: "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp", command: ["hermes", "acp"], }, + { + id: "hoplite", + bundledProvider: true, + title: "Hoplite", + description: "Cloud coding agent through the local hoplite acp bridge", + version: "manual", + iconId: "hoplite", + installLink: "https://hoplite.sh/docs/cli#install", + command: ["hoplite", "acp"], + }, { id: "junie", title: "Junie", @@ -389,6 +400,7 @@ export const ACP_PROVIDER_CATALOG: AcpProviderCatalogEntry[] = CATALOG_DATA.map( version: entry.version, installLink: entry.installLink, command: entry.command, + bundledProvider: "bundledProvider" in entry ? entry.bundledProvider : undefined, env: "env" in entry ? entry.env : undefined, params: "params" in entry ? entry.params : undefined, iconSvg: entry.iconId ? (ACP_PROVIDER_ICON_SVGS[entry.iconId] ?? null) : null, diff --git a/packages/app/src/hooks/use-acp-provider-catalog.test.ts b/packages/app/src/hooks/use-acp-provider-catalog.test.ts index 63e360aba0..201c17c022 100644 --- a/packages/app/src/hooks/use-acp-provider-catalog.test.ts +++ b/packages/app/src/hooks/use-acp-provider-catalog.test.ts @@ -44,6 +44,7 @@ describe("ACP provider catalog", () => { expect(findProvider("codewhale").command).toEqual(["codewhale", "serve", "--acp"]); expect(findProvider("devin").command).toEqual(["devin", "acp"]); expect(findProvider("goose").command).toEqual(["goose", "acp"]); + expect(findProvider("hoplite").command).toEqual(["hoplite", "acp"]); expect(findProvider("junie").command).toEqual(["junie", "--acp", "true"]); expect(findProvider("kiro").command).toEqual(["kiro-cli", "acp"]); expect(findProvider("poolside").command).toEqual(["pool", "acp"]); @@ -59,6 +60,32 @@ describe("ACP provider catalog", () => { expect(findProvider("minimax-code").iconSvg).toContain(" { + expect(findProvider("hoplite")).toMatchObject({ + title: "Hoplite", + command: ["hoplite", "acp"], + installLink: "https://hoplite.sh/docs/cli#install", + }); + expect(buildAcpProviderConfigPatch(findProvider("hoplite"), true)).toEqual({ + providers: { + hoplite: { enabled: true }, + }, + }); + const ids = getAcpProviderCatalog().map((entry) => entry.id); + expect(ids.indexOf("hermes")).toBeLessThan(ids.indexOf("hoplite")); + expect(ids.indexOf("hoplite")).toBeLessThan(ids.indexOf("junie")); + expect(findProvider("hoplite").iconSvg).toContain('fill="currentColor"'); + }); + + it.each([undefined, false])( + "rejects unsupported bundled Hoplite before producing a patch (%s)", + (supported) => { + expect(() => buildAcpProviderConfigPatch(findProvider("hoplite"), supported)).toThrow( + "Update the host to add Hoplite.", + ); + }, + ); + it("maps a catalog entry to the daemon provider config patch", () => { expect(buildAcpProviderConfigPatch(findProvider("amp-acp"))).toEqual({ providers: { diff --git a/packages/app/src/hooks/use-acp-provider-catalog.ts b/packages/app/src/hooks/use-acp-provider-catalog.ts index 92f9ca07e2..f20c31caf2 100644 --- a/packages/app/src/hooks/use-acp-provider-catalog.ts +++ b/packages/app/src/hooks/use-acp-provider-catalog.ts @@ -10,7 +10,13 @@ export function getAcpProviderCatalog(): AcpProviderCatalogItem[] { export function buildAcpProviderConfigPatch( entry: AcpProviderCatalogItem, + supportsHopliteProvider = false, ): MutableDaemonConfigPatch { + if (entry.bundledProvider) { + // COMPAT(hopliteProvider): added after v0.11.2, remove after 2027-04-15 once host floor includes the bundled adapter. + if (!supportsHopliteProvider) throw new Error("Update the host to add Hoplite."); + return { providers: { [entry.id]: { enabled: true } } }; + } return { providers: { [entry.id]: { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 5f00d8f70c..7434471ec6 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -631,7 +631,7 @@ export const ar: TranslationResources = { }, switcher: { trigger: "تبديل علامات التبويب (فتح{{count}})", - title: "علامة التبويب التبديل", + title: "علامات تبويب الـ Workspace", searchPlaceholder: "علامات تبويب البحث", }, menu: { @@ -1657,14 +1657,19 @@ export const ar: TranslationResources = { title: "إضافة مزود", search: "مقدمي البحث", noProviders: "لم يتم العثور على مقدمي الخدمات", + clearSearch: "مسح البحث", actions: { add: "يضيف", + addProvider: "إضافة {{provider}}", adding: "إضافة", installed: "تم التثبيت", cancel: "يلغي", installInstructions: "تعليمات التثبيت", installInstructionsFor: "تعليمات تثبيت{{provider}}", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "غير قادر على تثبيت الموفر", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 91e7e2c92f..97ced3f535 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -628,7 +628,7 @@ export const en = { }, switcher: { trigger: "Switch tabs ({{count}} open)", - title: "Switch tab", + title: "Workspace tabs", searchPlaceholder: "Search tabs", }, menu: { @@ -1682,14 +1682,19 @@ export const en = { title: "Add provider", search: "Search providers", noProviders: "No providers found", + clearSearch: "Clear search", actions: { add: "Add", + addProvider: "Add {{provider}}", adding: "Adding", installed: "Installed", cancel: "Cancel", installInstructions: "Install instructions", installInstructionsFor: "{{provider}} install instructions", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "Unable to install provider", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 6aff72fdd7..124e931497 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -637,7 +637,7 @@ export const es: TranslationResources = { }, switcher: { trigger: "Cambiar pestañas ({{count}}abierto)", - title: "Cambiar pestaña", + title: "Pestañas del workspace", searchPlaceholder: "Pestañas de búsqueda", }, menu: { @@ -1703,14 +1703,19 @@ export const es: TranslationResources = { title: "Agregar proveedor", search: "Proveedores de búsqueda", noProviders: "No se encontraron proveedores", + clearSearch: "Borrar búsqueda", actions: { add: "Agregar", + addProvider: "Añadir {{provider}}", adding: "Añadiendo", installed: "Instalado", cancel: "Cancelar", installInstructions: "Instrucciones de instalación", installInstructionsFor: "Instrucciones de instalación de{{provider}}", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "No se puede instalar el proveedor", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index a4471f594c..8a8e01786d 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -636,7 +636,7 @@ export const fr: TranslationResources = { }, switcher: { trigger: "Changer d’onglet ({{count}} ouverts)", - title: "Changer d’onglet", + title: "Onglets de l’espace de travail", searchPlaceholder: "Rechercher des onglets", }, menu: { @@ -1699,14 +1699,19 @@ export const fr: TranslationResources = { title: "Ajouter un fournisseur", search: "Rechercher des fournisseurs", noProviders: "Aucun fournisseur trouvé", + clearSearch: "Effacer la recherche", actions: { add: "Ajouter", + addProvider: "Ajouter {{provider}}", adding: "Ajout…", installed: "Installé", cancel: "Annuler", installInstructions: "Instructions d’installation", installInstructionsFor: "Instructions d’installation de {{provider}}", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "Impossible d’installer le fournisseur", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 9cbc630da2..9f6d4eced3 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -637,7 +637,7 @@ export const ja: TranslationResources = { }, switcher: { trigger: "タブを切り替え({{count}}件開いています)", - title: "タブを切り替え", + title: "ワークスペースのタブ", searchPlaceholder: "タブを検索", }, menu: { @@ -1674,14 +1674,19 @@ export const ja: TranslationResources = { title: "プロバイダーを追加", search: "プロバイダーを検索", noProviders: "プロバイダーが見つかりません", + clearSearch: "検索をクリア", actions: { add: "追加", + addProvider: "{{provider}} を追加", adding: "追加中", installed: "インストール済み", cancel: "キャンセル", installInstructions: "インストール手順", installInstructionsFor: "{{provider}}のインストール手順", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "プロバイダーをインストールできません", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 98598d39dd..a78d44f74b 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -632,7 +632,7 @@ export const ko: TranslationResources = { }, switcher: { trigger: "탭 전환 ({{count}}개 열림)", - title: "탭 전환", + title: "워크스페이스 탭", searchPlaceholder: "탭 검색", }, menu: { @@ -1667,14 +1667,19 @@ export const ko: TranslationResources = { title: "프로바이더 추가", search: "프로바이더 검색", noProviders: "프로바이더를 찾을 수 없습니다", + clearSearch: "검색 지우기", actions: { add: "추가", + addProvider: "{{provider}} 추가", adding: "추가 중", installed: "설치됨", cancel: "취소", installInstructions: "설치 안내", installInstructionsFor: "{{provider}} 설치 안내", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "프로바이더를 설치할 수 없습니다", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index c3ca4994d3..e5a2e3bfe4 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -636,7 +636,7 @@ export const ptBR: TranslationResources = { }, switcher: { trigger: "Alternar abas ({{count}} abertas)", - title: "Alternar aba", + title: "Abas do workspace", searchPlaceholder: "Buscar abas", }, menu: { @@ -1688,14 +1688,19 @@ export const ptBR: TranslationResources = { title: "Adicionar provedor", search: "Buscar provedores", noProviders: "Nenhum provedor encontrado", + clearSearch: "Limpar pesquisa", actions: { add: "Adicionar", + addProvider: "Adicionar {{provider}}", adding: "Adicionando", installed: "Instalado", cancel: "Cancelar", installInstructions: "Instruções de instalação", installInstructionsFor: "Instruções de instalação de {{provider}}", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "Não foi possível instalar provedor", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 57616a22a7..ad0bac05c6 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -638,7 +638,7 @@ export const ru: TranslationResources = { }, switcher: { trigger: "Переключить вкладки (открыто: {{count}})", - title: "Переключить вкладку", + title: "Вкладки рабочего пространства", searchPlaceholder: "Поиск вкладок", }, menu: { @@ -1686,14 +1686,19 @@ export const ru: TranslationResources = { title: "Добавить провайдера", search: "Поиск провайдеров", noProviders: "Провайдеры не найдены", + clearSearch: "Очистить поиск", actions: { add: "Добавить", + addProvider: "Добавить {{provider}}", adding: "Добавление", installed: "Установлено", cancel: "Отмена", installInstructions: "Инструкции по установке", installInstructionsFor: "Инструкции по установке {{provider}}", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "Не удалось установить провайдера", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 99088e068a..7b78bfbea6 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -631,7 +631,7 @@ export const zhCN: TranslationResources = { }, switcher: { trigger: "切换标签(已打开 {{count}} 个)", - title: "切换标签", + title: "Workspace 标签", searchPlaceholder: "搜索标签", }, menu: { @@ -1640,14 +1640,19 @@ export const zhCN: TranslationResources = { title: "添加 provider", search: "搜索 providers", noProviders: "未找到 providers", + clearSearch: "清除搜索", actions: { add: "添加", + addProvider: "添加 {{provider}}", adding: "正在添加", installed: "已安装", cancel: "取消", installInstructions: "安装说明", installInstructionsFor: "{{provider}} 安装说明", }, + types: { + acp: "ACP", + }, errors: { unableToInstall: "无法安装 provider", }, diff --git a/packages/app/src/panels/agent-facts-line.test.ts b/packages/app/src/panels/agent-facts-line.test.ts new file mode 100644 index 0000000000..182a5ff683 --- /dev/null +++ b/packages/app/src/panels/agent-facts-line.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { buildAgentFactsLine, type AgentFactsInput } from "./agent-facts-line"; + +const models = [ + { + provider: "claude", + id: "claude-fable-5-1", + label: "Fable 5.1", + thinkingOptions: [ + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + }, +]; + +const modes = [ + { id: "default", label: "Default" }, + { id: "bypassPermissions", label: "Bypass permissions" }, +]; + +const base: AgentFactsInput = { + models, + runtimeModelId: "claude-fable-5-1", + configuredModelId: null, + runtimeThinkingOptionId: "high", + explicitThinkingOptionId: null, + availableModes: modes, + currentModeId: "bypassPermissions", + contextWindowUsedTokens: 56_200, + contextWindowMaxTokens: 200_000, +}; + +describe("buildAgentFactsLine", () => { + it("joins model, thinking, mode, and context usage as the controls show them", () => { + expect(buildAgentFactsLine(base)).toBe( + "Fable 5.1 · High · Bypass permissions · 56k / 200k tokens (28%)", + ); + }); + + it("shows the raw model id before the catalog has loaded", () => { + expect( + buildAgentFactsLine({ + ...base, + models: null, + runtimeModelId: null, + configuredModelId: "gpt-6.1-sol", + runtimeThinkingOptionId: null, + explicitThinkingOptionId: "medium", + availableModes: [], + contextWindowUsedTokens: null, + contextWindowMaxTokens: null, + }), + ).toBe("gpt-6.1-sol · Medium"); + }); + + it("falls back to the provider's first mode until the agent reports one", () => { + expect( + buildAgentFactsLine({ + ...base, + currentModeId: null, + contextWindowUsedTokens: undefined, + contextWindowMaxTokens: undefined, + }), + ).toBe("Fable 5.1 · High · Default"); + }); + + it("leaves thinking and context out when the agent has none", () => { + expect( + buildAgentFactsLine({ + ...base, + models: [{ provider: "mock", id: "m", label: "Model M" }], + runtimeModelId: "m", + runtimeThinkingOptionId: null, + availableModes: [], + contextWindowUsedTokens: 10, + contextWindowMaxTokens: 0, + }), + ).toBe("Model M"); + }); + + it("is empty until the agent has a model", () => { + expect( + buildAgentFactsLine({ ...base, runtimeModelId: null, configuredModelId: " " }), + ).toBeNull(); + }); +}); diff --git a/packages/app/src/panels/agent-facts-line.ts b/packages/app/src/panels/agent-facts-line.ts new file mode 100644 index 0000000000..7d3cd80332 --- /dev/null +++ b/packages/app/src/panels/agent-facts-line.ts @@ -0,0 +1,59 @@ +import type { AgentMode, AgentModelDefinition } from "@getpaseo/protocol/agent-types"; +import { formatAgentModeLabel } from "@/agent-controls/labels"; +import { formatTokenCount } from "@/components/context-window-meter.utils"; +import { resolveAgentModelSelection } from "@/composer/agent-controls/utils"; + +export interface AgentFactsInput { + models: AgentModelDefinition[] | null; + runtimeModelId: string | null | undefined; + configuredModelId: string | null | undefined; + runtimeThinkingOptionId: string | null | undefined; + explicitThinkingOptionId: string | null | undefined; + availableModes: AgentMode[]; + currentModeId: string | null | undefined; + contextWindowUsedTokens: number | null | undefined; + contextWindowMaxTokens: number | null | undefined; +} + +/** + * What the composer's controls and context meter show about a Paseo agent, as one line: model, + * thinking, mode, and context usage, each resolved exactly as its control resolves it. Null until + * the agent has a model at all. + */ +export function buildAgentFactsLine(input: AgentFactsInput): string | null { + const hasModel = Boolean(input.runtimeModelId?.trim() || input.configuredModelId?.trim()); + if (!hasModel) return null; + const selection = resolveAgentModelSelection(input); + const parts = [selection.displayModel]; + if (selection.selectedThinkingId) parts.push(selection.displayThinking); + const mode = resolveModeLabel(input.availableModes, input.currentModeId); + if (mode) parts.push(mode); + const context = formatContextUsage(input.contextWindowUsedTokens, input.contextWindowMaxTokens); + if (context) parts.push(context); + return parts.join(" · "); +} + +/** The mode control shows the current mode, or the provider's first one until the agent reports. */ +function resolveModeLabel(modes: AgentMode[], currentModeId: string | null | undefined) { + const mode = modes.find((candidate) => candidate.id === currentModeId) ?? modes[0]; + return mode ? formatAgentModeLabel(mode) : null; +} + +/** The context meter's numbers: used of max, with the share it draws as the ring. */ +function formatContextUsage( + usedTokens: number | null | undefined, + maxTokens: number | null | undefined, +): string | null { + if ( + typeof usedTokens !== "number" || + typeof maxTokens !== "number" || + !Number.isFinite(usedTokens) || + !Number.isFinite(maxTokens) || + usedTokens < 0 || + maxTokens <= 0 + ) { + return null; + } + const percentage = Math.round(Math.min(100, (usedTokens / maxTokens) * 100)); + return `${formatTokenCount(usedTokens)} / ${formatTokenCount(maxTokens)} tokens (${percentage}%)`; +} diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index f857bbb349..a9e11df570 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -60,6 +60,9 @@ import { useContainerWidthBelow } from "@/hooks/use-container-width"; import { reconcileMissingAgentStateWithPresentAgent } from "@/panels/agent-panel-load-state"; import { TimelineSyncStatus } from "@/timeline/sync-status"; import { usePaneContext, usePaneFocus } from "@/panels/pane-context"; +import { buildAgentFactsLine } from "@/panels/agent-facts-line"; +import { PaneSubtitle } from "@/panels/pane-subtitle"; +import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { definePanel, type PanelDescriptor } from "@/panels/panel-registry"; import { RenderProfile } from "@/utils/render-profiler"; import { useHasPluginComposerPills } from "@/plugins"; @@ -1247,6 +1250,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ ) : null; const streamContent = ( + {showsComposer ? null : } { + const current = state.sessions[serverId]?.agents.get(agentId); + return current + ? { + provider: current.provider, + cwd: current.cwd, + runtimeModelId: current.runtimeInfo?.model ?? null, + configuredModelId: current.model ?? null, + runtimeThinkingOptionId: current.runtimeInfo?.thinkingOptionId ?? null, + explicitThinkingOptionId: current.thinkingOptionId ?? null, + availableModes: current.availableModes, + currentModeId: current.currentModeId, + contextWindowUsedTokens: current.lastUsage?.contextWindowUsedTokens ?? null, + contextWindowMaxTokens: current.lastUsage?.contextWindowMaxTokens ?? null, + } + : null; + }), + ); + const { entries } = useProvidersSnapshot(serverId, { cwd: agent?.cwd }); + const models = entries?.find((entry) => entry.provider === agent?.provider)?.models ?? null; + const text = agent ? buildAgentFactsLine({ ...agent, models }) : null; + return text ? : null; +} + const AgentStreamSection = memo(function AgentStreamSection({ streamViewRef, serverId, diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index 3e1634c113..86faad329c 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -12,6 +12,7 @@ import { type ArchiveFinishedStatus, useArchiveSubagent, useDetachSubagent, + useSubagentOpensInSheet, type SubagentRow, } from "@/subagents"; import { SubagentsTrack } from "@/subagents/track"; @@ -90,6 +91,7 @@ export const AgentTracks = memo(function AgentTracks({ }, [openSubagentTarget], ); + const opensInSheet = useSubagentOpensInSheet(workspaceKey); const handleOpenChanges = useCallback(() => { if (!workspaceKey) { return; @@ -122,6 +124,7 @@ export const AgentTracks = memo(function AgentTracks({ rows={subagentRows} onOpenSubagent={handleOpenSubagent} onOpenProviderSubagent={handleOpenProviderSubagent} + opensInSheet={opensInSheet} onArchiveSubagent={archiveSubagent} onArchiveFinished={onArchiveFinished} archiveFinishedStatus={archiveFinishedStatus} diff --git a/packages/app/src/panels/pane-subtitle.tsx b/packages/app/src/panels/pane-subtitle.tsx new file mode 100644 index 0000000000..eb5292d67b --- /dev/null +++ b/packages/app/src/panels/pane-subtitle.tsx @@ -0,0 +1,30 @@ +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; + +/** + * One muted line of facts about a pane's agent, under the pane's header and above its stream: + * model, thinking, tokens. Provider subagents get it from the daemon; a Paseo agent shown without + * its composer gets it from the same facts the composer's controls show. + */ +export function PaneSubtitle({ text, testID }: { text: string; testID?: string }) { + return ( + + + {text} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + header: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: theme.colors.border, + }, + text: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, +})); diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index 6c2d482403..ecd4a082bf 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -13,9 +13,11 @@ import { ComposerTrackBar } from "@/composer/tracks"; import { useIsCompactFormFactor } from "@/constants/layout"; import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine"; import { usePaneContext } from "@/panels/pane-context"; +import { PaneSubtitle } from "@/panels/pane-subtitle"; import { definePanel, type PanelDescriptor } from "@/panels/panel-registry"; import { useSessionStore } from "@/stores/session-store"; -import { useSubagentsForParent } from "@/subagents/select"; +import { type SubagentRow, useSubagentsForParent } from "@/subagents/select"; +import { useSubagentOpensInSheet } from "@/subagents/opens-in-sheet"; import { SubagentsTrack } from "@/subagents/track"; import { providerSubagentKey, @@ -30,6 +32,7 @@ import type { StreamItem } from "@/types/stream"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy"; import type { TurnPresentation } from "@/timeline/turn-liveness"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; const EMPTY_PERMISSIONS = new Map(); const EMPTY_STREAM_ITEMS: StreamItem[] = []; @@ -47,10 +50,12 @@ function ProviderSubagentChildTrack({ serverId, rows, onOpenProviderSubagent, + opensInSheet, }: { serverId: string; rows: ReturnType; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; + opensInSheet: (row: SubagentRow) => boolean; }) { if (rows.length === 0) return null; return ( @@ -60,6 +65,7 @@ function ProviderSubagentChildTrack({ rows={rows} onOpenSubagent={NOOP_SUBAGENT} onOpenProviderSubagent={onOpenProviderSubagent} + opensInSheet={opensInSheet} onArchiveSubagent={NOOP_SUBAGENT} /> @@ -109,7 +115,7 @@ function useProviderSubagentDescriptor( function ProviderSubagentPanel() { const { t } = useTranslation(); - const { serverId, target, openFileInWorkspace, openTab } = usePaneContext(); + const { serverId, workspaceId, target, openFileInWorkspace, openTab } = usePaneContext(); invariant(target.kind === "provider_subagent", "ProviderSubagentPanel requires provider target"); const key = providerSubagentKey(serverId, target.parentAgentId, target.subagentId); const streamId = `provider:${encodeURIComponent(target.parentAgentId)}:${encodeURIComponent(target.subagentId)}`; @@ -137,6 +143,9 @@ function ProviderSubagentPanel() { providerParentSubagentId: target.subagentId, }); const childTrackClearance = resolveChildTrackClearance(childRows.length, isCompact); + const opensInSheet = useSubagentOpensInSheet( + buildWorkspaceTabPersistenceKey({ serverId, workspaceId }), + ); const openProviderChild = useCallback( (parentAgentId: string, subagentId: string) => { openTab({ kind: "provider_subagent", parentAgentId, subagentId }); @@ -243,17 +252,7 @@ function ProviderSubagentPanel() { return ( - {subtitle ? ( - - - {subtitle} - - - ) : null} + {subtitle ? : null} ); @@ -280,16 +280,6 @@ function ProviderSubagentPanel() { const styles = StyleSheet.create((theme) => ({ container: { flex: 1, minHeight: 0 }, - subtitleHeader: { - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[1], - borderBottomWidth: theme.borderWidth[1], - borderBottomColor: theme.colors.border, - }, - subtitleText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, - }, unsupported: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 }, unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" }, })); diff --git a/packages/app/src/screens/settings/providers-section.test.tsx b/packages/app/src/screens/settings/providers-section.test.tsx index 77aaa4d8ec..0a702ae641 100644 --- a/packages/app/src/screens/settings/providers-section.test.tsx +++ b/packages/app/src/screens/settings/providers-section.test.tsx @@ -6,6 +6,7 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import type { MutableDaemonConfig } from "@getpaseo/protocol/messages"; +import { ToastApiProvider } from "@/contexts/toast-api-context"; const { theme, snapshotState, configState, patchConfigMock, openProviderSettingsMock } = vi.hoisted( () => ({ @@ -281,6 +282,12 @@ vi.mock("@/utils/confirm-dialog", () => ({ import { ProvidersSection } from "./providers-section"; +function unexpectedToast(): never { + throw new Error("Provider row interactions should not display a toast"); +} + +const toastApi = { show: unexpectedToast, copied: unexpectedToast, error: unexpectedToast }; + const claudeEntry: ProviderSnapshotEntry = { provider: "claude", status: "ready", @@ -366,7 +373,11 @@ describe("ProvidersSection", () => { function render(): void { act(() => { - root?.render(); + root?.render( + + + , + ); }); } diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index 4fb0a43278..72234b7602 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -11,6 +11,7 @@ import { } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; +import { useToast } from "@/contexts/toast-api-context"; import { settingsStyles } from "@/styles/settings"; import { useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostFeature } from "@/runtime/host-features"; @@ -19,6 +20,7 @@ import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { buildProviderDefinitions } from "@/utils/provider-definitions"; import { buildAcpProviderConfigPatch, + getAcpProviderCatalog, type AcpProviderCatalogItem, } from "@/hooks/use-acp-provider-catalog"; import { ProviderCatalogList } from "@/components/provider-catalog-list"; @@ -325,7 +327,9 @@ export interface ProvidersSectionProps { export function ProvidersSection({ serverId }: ProvidersSectionProps) { const { t } = useTranslation(); + const toast = useToast(); const isConnected = useHostRuntimeIsConnected(serverId); + const supportsHopliteProvider = useHostFeature(serverId, "hopliteProvider"); const supportsProviderRemoval = useHostFeature(serverId, "providerRemoval"); const { entries, isLoading, refresh } = useProvidersSnapshot(serverId); const { config, patchConfig } = useDaemonConfig(serverId); @@ -338,9 +342,16 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]); const hasServer = serverId.length > 0; - /** Custom providers are deletable only when config.json holds their `extends` definition. */ + /** Config owns derived providers and opt-in bundled catalog providers. */ const isConfigOwnedProvider = useCallback( - (providerId: string) => Boolean(config?.providers?.[providerId]?.extends), + (providerId: string) => + Boolean( + config?.providers?.[providerId] && + (config.providers[providerId].extends || + getAcpProviderCatalog().some( + (entry) => entry.id === providerId && entry.bundledProvider, + )), + ), [config], ); @@ -405,18 +416,15 @@ export function ProvidersSection({ serverId }: ProvidersSectionProps) { if (installingProviderId) return; setInstallingProviderId(entry.id); try { - await patchConfig(buildAcpProviderConfigPatch(entry)); + await patchConfig(buildAcpProviderConfigPatch(entry, supportsHopliteProvider)); await refresh([entry.id]); } catch (error) { - Alert.alert( - t("settings.providers.addErrorTitle"), - error instanceof Error ? error.message : String(error), - ); + toast.error(error instanceof Error ? error.message : String(error)); } finally { setInstallingProviderId((current) => (current === entry.id ? null : current)); } }, - [installingProviderId, patchConfig, refresh, t], + [installingProviderId, patchConfig, refresh, supportsHopliteProvider, toast], ); return ( diff --git a/packages/app/src/screens/workspace/sheet-tabs.tsx b/packages/app/src/screens/workspace/sheet-tabs.tsx new file mode 100644 index 0000000000..2b4e927a13 --- /dev/null +++ b/packages/app/src/screens/workspace/sheet-tabs.tsx @@ -0,0 +1,311 @@ +import type { BottomSheetHandleProps } from "@gorhom/bottom-sheet"; +import { useIsFocused } from "@react-navigation/native"; +import { + type ComponentType, + type FC, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { useStore } from "zustand"; +import { createStore } from "zustand/vanilla"; +import { Maximize2 } from "lucide-react-native"; +import { + SheetBackground, + SheetHandle, + SheetHeaderIconButton, + SheetHeaderView, + type SheetHeader, +} from "@/components/adaptive-modal-sheet"; +import { + IsolatedBottomSheetModal, + type IsolatedBottomSheetModalRef, + systemBackPress, +} from "@/components/ui/isolated-bottom-sheet-modal"; +import type { PanelIconProps } from "@/panels/panel-registry"; +import { getPanelRegistration } from "@/panels/panel-registry"; +import { ensurePanelsRegistered } from "@/panels/register-panels"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; +import { + WorkspacePaneContent, + type WorkspacePaneContentModel, +} from "@/screens/workspace/workspace-pane-content"; +import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store"; + +const SNAP_POINTS = ["92%"]; +const NO_SHEETS: readonly SheetTab[] = []; + +function PanelIcon({ + icon: Icon, + size, + color, +}: { icon: ComponentType } & PanelIconProps) { + return ; +} + +const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({ + color: theme.colors.foregroundMuted, +})); + +interface SheetTabHandleValue { + header: SheetHeader; + onClose: () => void; +} + +/** + * The sheet's handle, showing its header. Gorhom takes `handleComponent` as a component type and + * renders it on the far side of the portal: a new function per render would remount the header on + * every change, and no context from here reaches it. So each sheet gets one handle component for + * its lifetime, which reads the header through a store the sheet keeps current. + */ +function useSheetTabHandle(value: SheetTabHandleValue): FC { + const [store] = useState(() => createStore(() => value)); + useEffect(() => store.setState(value, true), [store, value]); + const [Handle] = useState( + () => + function SheetTabHandle(props: BottomSheetHandleProps) { + const { header, onClose } = useStore(store); + return ( + + + + ); + }, + ); + return Handle; +} + +export interface SheetTabsProps { + workspaceKey: string | null; + serverId: string; + workspaceId: string; + isWorkspaceFocused: boolean; + buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel; + /** The workspace's ordinary tab close, including its unsaved-changes guard. */ + onCloseTab: (tabId: string) => Promise; +} + +interface SheetTabSheetProps extends Omit { + sheet: SheetTab; + /** Only the top sheet's pane is focused and answers Android Back; the ones below wait their turn. */ + isTop: boolean; + /** Runs the guarded close and resolves to whether the tab is still open. */ + onClose: (tabId: string) => Promise; + onOpenAsTab: (tabId: string) => void; +} + +/** + * The workspace's sheet tabs, one sheet each, on the app-wide sheet stack. They present from inside + * the workspace's `SheetHostScope`, so each panel keeps every context an ordinary pane has, and a + * sheet opened from any other sheet (a subagent chosen from the subagents list, say) lands on top of + * it and uncovers it on close. The shared sheet parts (`SheetBackground`, `SheetHandle`, + * `SheetHeaderView`) keep them looking and dragging like every other sheet. + */ +export function SheetTabs({ workspaceKey, onCloseTab, ...rest }: SheetTabsProps) { + const sheets = useWorkspaceLayoutStore((state) => + workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS, + ); + const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab); + const handleOpenAsTab = useCallback( + (tabId: string) => { + if (workspaceKey) openSheetAsTab(workspaceKey, tabId); + }, + [openSheetAsTab, workspaceKey], + ); + const handleClose = useCallback( + async (tabId: string) => { + await onCloseTab(tabId); + // A declined unsaved-changes prompt leaves the tab in its sheet. + const open = workspaceKey + ? useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey] + : undefined; + return open?.some((sheet) => sheet.tab.tabId === tabId) ?? false; + }, + [onCloseTab, workspaceKey], + ); + return ( + <> + {sheets.map((sheet, index) => ( + + ))} + + ); +} + +function SheetTabSheet({ + sheet, + isTop, + serverId, + workspaceId, + isWorkspaceFocused, + buildContent, + onClose, + onOpenAsTab, +}: SheetTabSheetProps) { + const { t } = useTranslation(); + const { tab } = sheet; + const sheetRef = useRef(null); + const isScreenFocused = useIsFocused(); + ensurePanelsRegistered(); + const registration = getPanelRegistration(tab.target.kind); + if (!registration) { + throw new Error(`No panel registration for kind: ${tab.target.kind}`); + } + const descriptor = registration.useDescriptor(tab.target as never, { + serverId, + workspaceId, + tabId: tab.tabId, + }); + + // The sheet lives as long as its store entry: it presents on mount, and unmounting takes it off + // the stack, which uncovers the sheet below. Gorhom slides an unmounting sheet down and still + // reports that as `onChange(-1)`, which must not read as the user closing it: the store already + // moved on, and the same tab id may now be a pane tab (Open as tab). + const unmounted = useRef(false); + useEffect(() => { + sheetRef.current?.present(); + return () => { + unmounted.current = true; + }; + }, []); + + // Every close path (drag, backdrop, Close, Back) slides the sheet down first; `onChange(-1)` then + // runs the one guarded close. The sheet keeps its content through the close, so a declined + // unsaved-changes prompt only has to bring it back up. + const close = useCallback(() => sheetRef.current?.close(), []); + const handleChange = useCallback( + (index: number) => { + if (index !== -1 || unmounted.current) return; + void onClose(tab.tabId).then((stillOpen) => { + if (stillOpen) sheetRef.current?.snapToIndex(0); + return stillOpen; + }); + }, + [onClose, tab.tabId], + ); + // Hidden workspaces stay mounted in the deck and under Settings; only the visible one owns Back. + const ownsBackPress = isTop && isWorkspaceFocused && isScreenFocused; + useEffect(() => { + if (!ownsBackPress) return; + return systemBackPress.subscribe(() => { + close(); + return true; + }); + }, [close, ownsBackPress]); + + const handleOpenAsTab = useCallback(() => onOpenAsTab(tab.tabId), [onOpenAsTab, tab.tabId]); + const header = useMemo( + () => ({ + title: descriptor.label, + leading: , + // The sheet is for looking; the full-screen glyph takes the tab out of the sheet and into + // the switcher, where it gets its composer back. + actions: sheet.policy.openAsTab ? ( + + ) : undefined, + }), + [descriptor.icon, descriptor.label, handleOpenAsTab, sheet.policy.openAsTab, t], + ); + const handleValue = useMemo( + () => ({ header, onClose: close }), + [close, header], + ); + // The header is the sheet's handle: the panel scrolls on its own, so dragging the content would + // fight it, and the header is where people reach to pull the sheet down. + const SheetTabHandle = useSheetTabHandle(handleValue); + + return ( + + {/* A covered sheet stays as live as the pane under it; only focus follows the top sheet. */} + + + ); +} + +function SheetTabBody({ + tab, + label, + isPaneFocused, + isWorkspaceFocused, + buildContent, +}: { + tab: SheetTab["tab"]; + label: string; + /** Only the top sheet's pane is focused; the ones below are covered. */ + isPaneFocused: boolean; + isWorkspaceFocused: boolean; + buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel; +}) { + const tabDescriptor = useMemo( + () => ({ + key: tab.tabId, + tabId: tab.tabId, + kind: tab.target.kind, + target: tab.target, + state: tab.state, + }), + [tab], + ); + const content = useMemo(() => buildContent(tabDescriptor), [buildContent, tabDescriptor]); + // The sheet reaches the bottom of the screen, so it owns the system navigation inset. + const { bottom: bottomInset } = useSafeAreaInsets(); + const contentStyle = useMemo( + () => [styles.content, { paddingBottom: bottomInset }], + [bottomInset], + ); + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + content: { + flex: 1, + minHeight: 0, + }, +}); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index ef5b6aa779..bfe131d008 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -3,6 +3,7 @@ import { useComposerTabFullscreen, } from "@/composer/editing-session"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { SheetHostScope } from "@/components/ui/isolated-bottom-sheet-modal"; import type { JsonValue } from "@getpaseo/protocol/agent-types"; import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels"; import { @@ -78,7 +79,7 @@ import { selectExplorerSidebarPaneId, selectPaneFocusSuspended, type WorkspaceLayout, - type WorkspaceSheet, + type SheetTab, type WorkspaceTabPlacement, useWorkspaceLayoutStore, useWorkspaceLayoutStoreHydrated, @@ -165,7 +166,7 @@ import { type WorkspacePaneContentModel, } from "@/screens/workspace/workspace-pane-content"; import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set"; -import { WorkspaceSheetHost } from "@/screens/workspace/workspace-sheet-host"; +import { SheetTabs } from "@/screens/workspace/sheet-tabs"; import type { PaneHost } from "@/panels/panel-manifest"; import { WorkspaceFocusProvider } from "@/workspace/focus"; import { DiffDocumentWorkspaceCacheProvider } from "@/git/diff-document/workspace-cache"; @@ -208,7 +209,7 @@ import { RenderProfile } from "@/utils/render-profiler"; import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status"; import { useHasPullRequest, usePullRequestAutoAdd } from "@/panels/pull-request"; -const NO_SHEETS: readonly WorkspaceSheet[] = []; +const NO_SHEETS: readonly SheetTab[] = []; const WORKSPACE_FLOATING_PANEL_PORTAL_HOST_PREFIX = "workspace-floating-panels"; const EMPTY_UI_TABS: WorkspaceTab[] = []; const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = []; @@ -3552,14 +3553,15 @@ function WorkspaceScreenContent({ composerFullscreen: useComposerTabFullscreen(workspacePanelKey, activeTabId), }); - // The sheet is the column's last child, so it covers the compact header as a modal. + // Sheets opened from the compact column present inside it, so their panels read the workspace + // providers, and they cover the compact header as a modal. const workspaceCenterColumn = isMobile ? ( - + )} - - - - + + ) : ( diff --git a/packages/app/src/screens/workspace/workspace-sheet-host.tsx b/packages/app/src/screens/workspace/workspace-sheet-host.tsx deleted file mode 100644 index a46ea2c1c7..0000000000 --- a/packages/app/src/screens/workspace/workspace-sheet-host.tsx +++ /dev/null @@ -1,326 +0,0 @@ -import BottomSheet, { - BottomSheetBackdrop, - type BottomSheetBackdropProps, - type BottomSheetHandleProps, -} from "@gorhom/bottom-sheet"; -import { useIsFocused } from "@react-navigation/native"; -import { - type ComponentType, - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, -} from "react"; -import { View } from "react-native"; -import { useTranslation } from "react-i18next"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; -import { - SheetBackground, - SheetHandle, - SheetHeaderView, - type SheetHeader, -} from "@/components/adaptive-modal-sheet"; -import { systemBackPress } from "@/components/ui/isolated-bottom-sheet-modal"; -import { RetainedPanelActivity } from "@/components/retained-panel"; -import { Button } from "@/components/ui/button"; -import type { PanelIconProps } from "@/panels/panel-registry"; -import { getPanelRegistration } from "@/panels/panel-registry"; -import { ensurePanelsRegistered } from "@/panels/register-panels"; -import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; -import { - WorkspacePaneContent, - type WorkspacePaneContentModel, -} from "@/screens/workspace/workspace-pane-content"; -import { useWorkspaceLayoutStore, type WorkspaceSheet } from "@/stores/workspace-layout-store"; - -const SNAP_POINTS = ["92%"]; -const NO_SHEETS: readonly WorkspaceSheet[] = []; - -function PanelIcon({ - icon: Icon, - size, - color, -}: { icon: ComponentType } & PanelIconProps) { - return ; -} - -const ThemedPanelIcon = withUnistyles(PanelIcon, (theme) => ({ - color: theme.colors.foregroundMuted, -})); - -// The workspace sheet is a Gorhom sheet rendered in place rather than a portaled modal, so the -// backdrop closes the sheet itself instead of dismissing the top of the modal stack. -function renderBackdrop(props: BottomSheetBackdropProps) { - return ( - - ); -} - -// Stacked sheets share the bottom sheet's dim rather than darkening it again, but still catch the -// tap so it closes the top sheet. The backdrop is clear rather than transparent: iOS skips views -// under 0.01 opacity when hit testing, which would hand the tap to the sheet below. -function renderStackedBackdrop(props: BottomSheetBackdropProps) { - return ( - - ); -} - -interface WorkspaceSheetHandleValue { - header: SheetHeader; - onClose: () => void; -} - -const WorkspaceSheetHandleContext = createContext(null); - -// Gorhom renders `handleComponent` as a component type, so it stays one module-level component and -// reads the header from context; a per-render function would remount the header on every change. -function WorkspaceSheetHandle(props: BottomSheetHandleProps) { - const value = useContext(WorkspaceSheetHandleContext); - if (!value) throw new Error("WorkspaceSheetHandle must render inside a workspace sheet"); - return ( - - - - ); -} - -export interface WorkspaceSheetHostProps { - workspaceKey: string | null; - serverId: string; - workspaceId: string; - isWorkspaceFocused: boolean; - buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel; - /** The workspace's ordinary tab close, including its unsaved-changes guard. */ - onCloseTab: (tabId: string) => Promise; -} - -interface WorkspaceSheetPresentationProps extends Omit< - WorkspaceSheetHostProps, - "workspaceKey" | "onCloseTab" -> { - sheet: WorkspaceSheet; - /** Only the bottom sheet dims the workspace; the ones above share its dim. */ - isBottom: boolean; - /** Only the top sheet answers Android Back; the ones below wait their turn. */ - isTop: boolean; - onClose: (tabId: string) => Promise; - onOpenAsTab: (tabId: string) => void; -} - -/** - * Hosts the workspace's sheets over the compact tab strip, one Gorhom sheet per entry, top last. - * They render in the workspace tree, not through a portal, so each panel keeps every context an - * ordinary pane has. The shared sheet parts (`SheetBackground`, `SheetHandle`, `SheetHeaderView`) - * keep them looking and dragging like every other sheet. - */ -export function WorkspaceSheetHost({ workspaceKey, onCloseTab, ...rest }: WorkspaceSheetHostProps) { - const sheets = useWorkspaceLayoutStore((state) => - workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS, - ); - const openSheetAsTab = useWorkspaceLayoutStore((state) => state.openSheetAsTab); - const handleOpenAsTab = useCallback( - (tabId: string) => { - if (workspaceKey) openSheetAsTab(workspaceKey, tabId); - }, - [openSheetAsTab, workspaceKey], - ); - if (sheets.length === 0) return null; - return ( - - {sheets.map((sheet, index) => ( - - ))} - - ); -} - -function WorkspaceSheetPresentation({ - sheet, - isBottom, - isTop, - serverId, - workspaceId, - isWorkspaceFocused, - buildContent, - onClose, - onOpenAsTab, -}: WorkspaceSheetPresentationProps) { - const { t } = useTranslation(); - const { tab } = sheet; - const sheetRef = useRef(null); - const isScreenFocused = useIsFocused(); - ensurePanelsRegistered(); - const registration = getPanelRegistration(tab.target.kind); - if (!registration) { - throw new Error(`No panel registration for kind: ${tab.target.kind}`); - } - const descriptor = registration.useDescriptor(tab.target as never, { - serverId, - workspaceId, - tabId: tab.tabId, - }); - - const handleClose = useCallback(() => onClose(tab.tabId), [onClose, tab.tabId]); - const handleChange = useCallback( - (index: number) => { - if (index !== -1) return; - // A declined close (unsaved changes) leaves the tab in the sheet, so bring it back up. - void handleClose().then(() => sheetRef.current?.snapToIndex(0)); - }, - [handleClose], - ); - // Hidden workspaces stay mounted in the deck and under Settings; only the visible one owns back. - const ownsBackPress = isTop && isWorkspaceFocused && isScreenFocused; - useEffect(() => { - if (!ownsBackPress) return; - return systemBackPress.subscribe(() => { - void handleClose(); - return true; - }); - }, [handleClose, ownsBackPress]); - - const handleOpenAsTab = useCallback(() => onOpenAsTab(tab.tabId), [onOpenAsTab, tab.tabId]); - const header = useMemo( - () => ({ - title: descriptor.label, - leading: , - actions: sheet.policy.openAsTab ? ( - - ) : undefined, - }), - [descriptor.icon, descriptor.label, handleOpenAsTab, sheet.policy.openAsTab, t], - ); - const handleHeaderClose = useCallback(() => { - void handleClose(); - }, [handleClose]); - const handleValue = useMemo( - () => ({ header, onClose: handleHeaderClose }), - [handleHeaderClose, header], - ); - - return ( - // Gorhom renders the handle beside the content, so the dialog wraps the whole sheet. - - {/* The header is the sheet's handle: the panel scrolls on its own, so dragging the content - would fight it, and the header is where people reach to pull the sheet down. */} - - - {/* A covered sheet keeps its state but stops its live work until it is on top again. */} - - - - - - - ); -} - -function WorkspaceSheetBody({ - tab, - isPaneFocused, - isWorkspaceFocused, - buildContent, -}: { - tab: WorkspaceSheet["tab"]; - /** Only the top sheet's pane is focused; the ones below are covered. */ - isPaneFocused: boolean; - isWorkspaceFocused: boolean; - buildContent: (tab: WorkspaceTabDescriptor) => WorkspacePaneContentModel; -}) { - const tabDescriptor = useMemo( - () => ({ - key: tab.tabId, - tabId: tab.tabId, - kind: tab.target.kind, - target: tab.target, - state: tab.state, - }), - [tab], - ); - const content = useMemo(() => buildContent(tabDescriptor), [buildContent, tabDescriptor]); - // The sheet reaches the bottom of the screen, so it owns the system navigation inset. - const { bottom: bottomInset } = useSafeAreaInsets(); - const contentStyle = useMemo( - () => [styles.content, { paddingBottom: bottomInset }], - [bottomInset], - ); - - return ( - - - - ); -} - -const styles = StyleSheet.create({ - host: { - ...StyleSheet.absoluteFillObject, - }, - clearBackdrop: { - backgroundColor: "transparent", - }, - sheetLayer: { - ...StyleSheet.absoluteFillObject, - }, - content: { - flex: 1, - minHeight: 0, - }, -}); diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index fc973f442d..5f170bed7e 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -4592,7 +4592,7 @@ describe("compact workspace sheet", () => { expect(tabKinds(store)).toEqual(["terminal"]); }); - it("brings a stacked target back to the top by dropping the sheets above it", () => { + it("uncovers a covered sheet by dropping the sheets above it", () => { const store = createCompactStore(); const fileTabId = store .getState() @@ -4878,13 +4878,15 @@ describe("compact workspace sheet", () => { }); }); - it("brings a covered sheet back to the top and keeps a modified sheet above it beneath", () => { + it("keeps a covered sheet in place under a modified sheet instead of reordering", () => { const store = createCompactStore(); const otherTarget = { kind: "file" as const, path: "src/other.ts" }; const otherTabId = store .getState() .openTab({ workspaceKey, target: otherTarget, intent: "reveal" }); const modifiedSheetTabId = openModifiedFileSheet(store); + const unmodifiedTopTarget = { kind: "file" as const, path: "src/third.ts" }; + store.getState().openTab({ workspaceKey, target: unmodifiedTopTarget, intent: "reveal" }); const focusedBefore = focusedTabId(store); const tabId = store @@ -4892,10 +4894,10 @@ describe("compact workspace sheet", () => { .openTab({ workspaceKey, target: otherTarget, intent: "reveal" }); expect(tabId).toBe(otherTabId); - expect(sheetTargets(store)).toEqual([fileTarget, otherTarget]); - expect(store.getState().sheetsByWorkspace[workspaceKey]?.[0]?.tab.tabId).toBe( - modifiedSheetTabId, - ); + // Moving a sheet would remount its panel, so the stack keeps its order: the modified sheet + // stays on top of the one revealed, and only the unmodified sheet above them closes. + expect(sheetTargets(store)).toEqual([otherTarget, fileTarget]); + expect(sheetTabId(store)).toBe(modifiedSheetTabId); expect(stripTab(store, tabId ?? "")).toBeNull(); expect(focusedTabId(store)).toBe(focusedBefore); }); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index ba186b655a..2aea369df6 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -111,13 +111,13 @@ export interface OpenWorkspaceTabInput { } /** - * How the app shell lays out a workspace: compact (one tab at a time, plus the sheet), a single + * How the app shell lays out a workspace: compact (one tab at a time, plus sheets), a single * wide pane (touch tablets, which cannot render splits), or split panes. */ export type WorkspaceLayoutMode = "compact" | "single-pane" | "split-panes"; -/** A tab hosted by the compact workspace sheet instead of a pane. */ -export interface WorkspaceSheet { +/** A tab shown in a sheet over the compact workspace instead of in a pane. */ +export interface SheetTab { tab: WorkspaceTab; policy: CompactSheetPolicy; parentTabId: string | null; @@ -127,10 +127,12 @@ interface WorkspaceLayoutStore { /** Published once by the app shell; callers never pass a form factor to open a tab. */ layoutMode: WorkspaceLayoutMode; /** - * Each workspace's open sheets, bottom to top; only the top one is on screen. A workspace with - * no sheet has no entry. Not persisted: a reload returns to the tab strip. + * Each workspace's sheet tabs in the order they opened, so the last is the sheet on top. The + * list never reorders: the stack belongs to the sheet primitive, and moving a sheet would remount + * its panel. A workspace with no sheet has no entry. Not persisted: a reload returns to the tab + * strip. */ - sheetsByWorkspace: Record; + sheetsByWorkspace: Record; layoutByWorkspace: Record; splitSizesByWorkspace: Record>; explorerSidebarWidthByWorkspace: Record; @@ -143,10 +145,10 @@ interface WorkspaceLayoutStore { pullRequestTabAutoOpenedByWorkspace: Record; /** * The one entry point for showing a target. On compact layouts a target whose panel declares - * `compactSheet` opens in the workspace sheet; the returned tab id is the sheet tab's id. + * `compactSheet` opens in a sheet; the returned tab id is the sheet tab's id. */ openTab: (input: OpenWorkspaceTabInput) => string | null; - /** Whether `openTab` with this input would show the target in the sheet. */ + /** Whether `openTab` with this input would show the target in a sheet. */ opensInSheet: ( input: Pick, ) => boolean; @@ -684,12 +686,12 @@ function attachParentTab(input: { }); } -const NO_SHEETS: readonly WorkspaceSheet[] = []; +const NO_SHEETS: readonly SheetTab[] = []; function getSheets( state: Pick, workspaceKey: string, -): readonly WorkspaceSheet[] { +): readonly SheetTab[] { return state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS; } @@ -697,14 +699,14 @@ function findSheet( state: Pick, workspaceKey: string, tabId: string, -): WorkspaceSheet | undefined { +): SheetTab | undefined { return getSheets(state, workspaceKey).find((sheet) => sheet.tab.tabId === tabId); } function withSheets( state: Pick, workspaceKey: string, - sheets: readonly WorkspaceSheet[], + sheets: readonly SheetTab[], ): Pick { if (sheets.length > 0) { return { sheetsByWorkspace: { ...state.sheetsByWorkspace, [workspaceKey]: sheets } }; @@ -732,7 +734,7 @@ function withSheetTab( state: Pick, workspaceKey: string, tabId: string, - update: (sheet: WorkspaceSheet) => WorkspaceSheet, + update: (sheet: SheetTab) => SheetTab, ): Pick { return withSheets( state, @@ -777,13 +779,13 @@ function resolveOpenParentTabId( function placeTargetInSheet(input: { /** The sheet whose tab the target keeps, if any. */ - kept: WorkspaceSheet | undefined; + kept: SheetTab | undefined; target: WorkspaceTabTarget; intent: WorkspaceTabOpenIntent; policy: CompactSheetPolicy; parentTabId: string | null; state: JsonValue | undefined; -}): WorkspaceSheet { +}): SheetTab { if (input.kept) { return { ...input.kept, tab: { ...input.kept.tab, target: input.target } }; } @@ -857,14 +859,14 @@ function promoteSheetToTab( * A sheet holding unsaved work is never implicitly displaced: moving its tab would remount the * panel and drop the work. Only the explicit, guarded close removes it. */ -function sheetHoldsUnsavedWork(workspaceKey: string, sheet: WorkspaceSheet): boolean { +function sheetHoldsUnsavedWork(workspaceKey: string, sheet: SheetTab): boolean { return isSheetTabModified(workspaceKey, sheet.tab.tabId); } /** - * Where an open lands in the sheet stack, or null when it opens as a tab. A new target goes on - * top. A reveal of a resource a sheet already shows keeps that sheet's tab and brings it back to - * the top; the sheets above it close, except ones holding unsaved work, which stay beneath it. + * Where an open lands among the sheet tabs, or null when it opens as a tab. A new target goes on + * top. A reveal of a resource a sheet already shows keeps that sheet's tab and uncovers it by + * closing the sheets above it, except ones holding unsaved work, which stay where they are. */ function resolveSheetPlacement( state: WorkspaceLayoutStore, @@ -915,7 +917,7 @@ function openSheetTabInFront( */ function sheetAgentNoLongerFits( workspaceKey: string, - sheet: WorkspaceSheet, + sheet: SheetTab, snapshot: WorkspaceTabSnapshot, ): boolean { if (sheet.tab.target.kind !== "agent" || !snapshot.agentsHydrated) return false; @@ -1001,16 +1003,17 @@ export function createWorkspaceLayoutStore( parentTabId, state: input.state, }); - const below = + const next = keptIndex === null - ? sheets - : [ - ...sheets.slice(0, keptIndex), - ...sheets - .slice(keptIndex + 1) - .filter((above) => sheetHoldsUnsavedWork(normalizedWorkspaceKey, above)), - ]; - set((state) => withSheets(state, normalizedWorkspaceKey, [...below, sheet])); + ? [...sheets, sheet] + : sheets.flatMap((existing, index) => { + if (index === keptIndex) return [sheet]; + if (index < keptIndex) return [existing]; + return sheetHoldsUnsavedWork(normalizedWorkspaceKey, existing) + ? [existing] + : []; + }); + set((state) => withSheets(state, normalizedWorkspaceKey, next)); return sheet.tab.tabId; } const placement = getOpenTabPlacement( diff --git a/packages/app/src/subagents/index.ts b/packages/app/src/subagents/index.ts index e93d62eb2d..9c6227a34f 100644 --- a/packages/app/src/subagents/index.ts +++ b/packages/app/src/subagents/index.ts @@ -9,3 +9,4 @@ export { type ArchiveFinishedStatus, type UseArchiveFinishedSubagentsInput, } from "./use-archive-finished"; +export { useSubagentOpensInSheet } from "./opens-in-sheet"; diff --git a/packages/app/src/subagents/opens-in-sheet.test.tsx b/packages/app/src/subagents/opens-in-sheet.test.tsx new file mode 100644 index 0000000000..0792091135 --- /dev/null +++ b/packages/app/src/subagents/opens-in-sheet.test.tsx @@ -0,0 +1,133 @@ +/** + * @vitest-environment jsdom + */ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; +import { useSubagentOpensInSheet } from "./opens-in-sheet"; +import { selectSubagentsForParent, type SubagentRow } from "./select"; + +vi.mock("@react-native-async-storage/async-storage", () => { + const storage = new Map(); + return { + default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value); + }), + removeItem: vi.fn(async (key: string) => { + storage.delete(key); + }), + }, + }; +}); + +const SERVER_ID = "server-1"; +const WORKSPACE_ID = "ws-main"; +const AGENT_TIMESTAMP = new Date("2026-04-21T10:00:00.000Z"); + +const AGENT_DEFAULTS: Agent = { + serverId: SERVER_ID, + id: "agent", + provider: "codex", + status: "idle", + turn: { phase: "idle", cancellationRequestId: null }, + createdAt: AGENT_TIMESTAMP, + updatedAt: AGENT_TIMESTAMP, + lastUserMessageAt: null, + lastActivityAt: AGENT_TIMESTAMP, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + runtimeInfo: undefined, + lastUsage: undefined, + lastError: null, + title: "Agent", + cwd: "/repo/worktree", + workspaceId: WORKSPACE_ID, + model: null, + features: undefined, + thinkingOptionId: undefined, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + archivedAt: null, + parentAgentId: null, + labels: {}, + projectPlacement: null, +}; + +const workspaceKey = buildWorkspaceTabPersistenceKey({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, +}); +if (!workspaceKey) throw new Error("workspace key"); + +function seedParentWithChild(): SubagentRow { + const parent: Agent = { ...AGENT_DEFAULTS, id: "parent", title: "Parent" }; + const child: Agent = { ...AGENT_DEFAULTS, id: "child", title: "Child", parentAgentId: "parent" }; + useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); + useSessionStore.getState().setAgents( + SERVER_ID, + new Map([ + [parent.id, parent], + [child.id, child], + ]), + ); + const [row] = selectSubagentsForParent( + useSessionStore.getState(), + { serverId: SERVER_ID, parentAgentId: "parent" }, + new Set(), + ); + if (!row) throw new Error("child row"); + return row; +} + +afterEach(() => { + useSessionStore.getState().clearSession(SERVER_ID); + useWorkspaceLayoutStore.setState({ + layoutMode: "split-panes", + layoutByWorkspace: {}, + sheetsByWorkspace: {}, + hiddenAgentIdsByWorkspace: {}, + pinnedAgentIdsByWorkspace: {}, + }); +}); + +describe("useSubagentOpensInSheet", () => { + it("re-renders the row's answer as the child's tab opens and closes", () => { + const row = seedParentWithChild(); + const store = useWorkspaceLayoutStore.getState(); + store.setLayoutMode("compact"); + const target = { kind: "agent", agentId: "child" } as const; + + // The row renders its answer, as the track does, so a stale render is a stale answer. + const { result } = renderHook(() => useSubagentOpensInSheet(workspaceKey)(row)); + expect(result.current).toBe(true); + + let tabId: string | null = null; + act(() => { + const sheetTabId = store.openTab({ workspaceKey, target, intent: "reveal" }); + tabId = sheetTabId ? store.openSheetAsTab(workspaceKey, sheetTabId) : null; + }); + expect(tabId).not.toBeNull(); + expect(result.current).toBe(false); + + act(() => { + store.closeTab(workspaceKey, tabId ?? ""); + }); + expect(result.current).toBe(true); + }); +}); diff --git a/packages/app/src/subagents/opens-in-sheet.ts b/packages/app/src/subagents/opens-in-sheet.ts new file mode 100644 index 0000000000..26432ea039 --- /dev/null +++ b/packages/app/src/subagents/opens-in-sheet.ts @@ -0,0 +1,29 @@ +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; +import type { SubagentRow } from "./select"; + +/** The workspace target a subagent row opens. */ +export function subagentRowTarget(row: SubagentRow): WorkspaceTabTarget { + return row.kind === "provider" + ? { kind: "provider_subagent", parentAgentId: row.parentAgentId, subagentId: row.id } + : { kind: "agent", agentId: row.id }; +} + +/** Answers, per subagent row, whether opening it in this workspace shows a sheet rather than a tab. */ +export function useSubagentOpensInSheet( + workspaceKey: string | null, +): (row: SubagentRow) => boolean { + // The rows render the answer, so this subscribes to what the answer reads: the layout mode and + // the workspace's tabs. A subagent already open as a tab reveals that tab instead of a sheet, + // and a row rendered while that tab existed must re-render once it closes, or choosing the row + // would still close the list it sits in. + useWorkspaceLayoutStore((state) => state.layoutMode); + useWorkspaceLayoutStore((state) => + workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined, + ); + return (row) => + workspaceKey !== null && + useWorkspaceLayoutStore + .getState() + .opensInSheet({ workspaceKey, target: subagentRowTarget(row), intent: "reveal" }); +} diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index 601030562f..c1834587d2 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -35,6 +35,11 @@ export interface SubagentsTrackProps { rows: SubagentRow[]; onOpenSubagent: (id: string) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; + /** + * Whether opening this row shows it in a sheet. A sheet stacks over the list and uncovers it on + * close, so the list stays open; a tab replaces the list's pane, so choosing it ends the list. + */ + opensInSheet: (row: SubagentRow) => boolean; onArchiveSubagent: (id: string) => void; onArchiveFinished?: () => void; archiveFinishedStatus?: ArchiveFinishedStatus; @@ -63,6 +68,7 @@ export function SubagentsTrack({ rows, onOpenSubagent, onOpenProviderSubagent, + opensInSheet, onArchiveSubagent, onArchiveFinished, archiveFinishedStatus = IDLE_ARCHIVE_FINISHED_STATUS, @@ -103,6 +109,7 @@ export function SubagentsTrack({ serverId={serverId} onOpenSubagent={onOpenSubagent} onOpenProviderSubagent={onOpenProviderSubagent} + opensInSheet={opensInSheet(row)} onArchiveSubagent={onArchiveSubagent} onDetachSubagent={onDetachSubagent} /> @@ -174,6 +181,7 @@ interface SubagentsTrackRowProps { row: SubagentRow; onOpenSubagent: (id: string) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; + opensInSheet: boolean; onArchiveSubagent: (id: string) => void; onDetachSubagent?: (id: string) => void; } @@ -183,6 +191,7 @@ function SubagentsTrackRow({ row, onOpenSubagent, onOpenProviderSubagent, + opensInSheet, onArchiveSubagent, onDetachSubagent, }: SubagentsTrackRowProps): ReactElement { @@ -245,6 +254,7 @@ function SubagentsTrackRow({ {renderRow} diff --git a/packages/app/src/utils/assistant-markdown-image-source.test.ts b/packages/app/src/utils/assistant-markdown-image-source.test.ts new file mode 100644 index 0000000000..edc02a4eb7 --- /dev/null +++ b/packages/app/src/utils/assistant-markdown-image-source.test.ts @@ -0,0 +1,58 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { localFileSourceToPath } from "@/attachments/utils"; +import { createAssistantMarkdownParser } from "./assistant-markdown-parser"; +import { resolveAssistantImageSource } from "./assistant-image-source"; + +describe("local assistant Markdown images", () => { + let workspaceRoot: string; + const imageBytes = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==", + "base64", + ); + + beforeEach(async () => { + workspaceRoot = await mkdtemp(path.join(os.tmpdir(), "assistant-markdown-image-")); + await mkdir(path.join(workspaceRoot, "My Apps", "Мои"), { recursive: true }); + await writeFile(path.join(workspaceRoot, "My Apps", "Мои", "test.png"), imageBytes); + await writeFile(path.join(workspaceRoot, "literal%20name.png"), imageBytes); + }); + + afterEach(async () => { + await rm(workspaceRoot, { recursive: true, force: true }); + }); + + it.each([ + "absolute angle", + "absolute encoded", + "relative angle", + "relative encoded", + "file URI", + "literal percent", + ])("reads the actual image for the %s destination", async (form) => { + const absolute = path.join(workspaceRoot, "My Apps", "Мои", "test.png"); + const destinations: Record = { + "absolute angle": `<${absolute}>`, + "absolute encoded": encodeURI(absolute), + "relative angle": "", + "relative encoded": encodeURI("My Apps/Мои/test.png"), + "file URI": `file://${encodeURI(absolute)}`, + "literal percent": `${workspaceRoot}/literal%2520name.png`, + }; + const tokens = createAssistantMarkdownParser().parseInline( + `![test](${destinations[form]})`, + {}, + ); + const image = tokens + .flatMap((token) => token.children ?? []) + .find((token) => token.type === "image"); + expect(image).toBeDefined(); + const source = localFileSourceToPath(image!.attrGet("src")!, { decodeHref: true }); + const target = resolveAssistantImageSource({ source, workspaceRoot }); + expect(target?.kind).toBe("file_rpc"); + if (target?.kind !== "file_rpc") throw new Error("Expected a local image read target"); + await expect(readFile(path.resolve(target.cwd, target.path))).resolves.toEqual(imageBytes); + }); +}); diff --git a/packages/app/test-stubs/gorhom-bottom-sheet.ts b/packages/app/test-stubs/gorhom-bottom-sheet.ts index 5f3e8c7e8f..ee27421efd 100644 --- a/packages/app/test-stubs/gorhom-bottom-sheet.ts +++ b/packages/app/test-stubs/gorhom-bottom-sheet.ts @@ -3,6 +3,7 @@ // to keep the module graph importable. const Stub = () => null; const PassThrough = ({ children }: { children?: unknown }) => children; +const sharedValue = (value: unknown) => ({ get: () => value }); export default Stub; export const BottomSheetModal = Stub; @@ -17,10 +18,27 @@ export function useBottomSheetModal(): never { throw new Error("Modal stack interactions require the real Gorhom provider in browser E2E tests"); } +// A sheet host scope only redirects where sheets draw; with no sheet drawn there is nothing to host. +export function useBottomSheet(): never { + throw new Error("useBottomSheet is unavailable in tests"); +} +export const BottomSheetModalInternalProvider = PassThrough; +export const INITIAL_CONTAINER_LAYOUT = { + height: 0, + offset: { top: 0, bottom: 0, left: 0, right: 0 }, +}; +export function useBottomSheetModalInternal() { + return { + hostName: "stub", + containerLayoutState: sharedValue(INITIAL_CONTAINER_LAYOUT), + mountSheet: () => undefined, + unmountSheet: () => undefined, + willUnmountSheet: () => undefined, + }; +} + // The app imports the internal layout values to compute the visible sheet body. Browser unit tests // never render the native sheet, but Vite still validates this module's export surface. -const sharedValue = (value: unknown) => ({ get: () => value }); - export const KEYBOARD_STATUS = { SHOWN: "SHOWN" }; export function useBottomSheetInternal() { return { diff --git a/packages/plugin/src/server/acp-internal/connection.ts b/packages/plugin/src/server/acp-internal/connection.ts index fc78d2a8c4..09984a3d14 100644 --- a/packages/plugin/src/server/acp-internal/connection.ts +++ b/packages/plugin/src/server/acp-internal/connection.ts @@ -359,6 +359,8 @@ class AcpRuntime { private emit: (event: ProviderEvent) => void; private readonly messages = new Map(); private readonly toolCalls = new Map(); + // Running tool rows published by transformers; the turn's terminal closes any left open. + private readonly vendorToolCalls = new Map(); private readonly pendingCompactions = new Set(); private readonly permissions = new Map< string, @@ -595,10 +597,13 @@ class AcpRuntime { turnId, state: "started", }); + // The SDK dispatches notifications without awaiting them, so the prompt response can resolve + // while earlier session updates are still queued. Apply them before the turn ends. const settled = this.call( this.connection.prompt({ sessionId: this.nativeSessionId, prompt }), ).then( - (response): void => { + async (response): Promise => { + await this.drainNotifications(); const state = response.stopReason === "cancelled" ? "canceled" : "completed"; this.fallbackChunkIds.clear(); this.terminalizeTransientItems(state); @@ -610,7 +615,8 @@ class AcpRuntime { }); return undefined; }, - (error): void => { + async (error): Promise => { + await this.drainNotifications(); this.fallbackChunkIds.clear(); this.terminalizeTransientItems("failed"); this.emit({ @@ -1027,9 +1033,17 @@ class AcpRuntime { this.emit({ type: "timeline.item", sessionId: this.options.boundarySessionId, - item: toolTimelineItem(completed), + item: terminalToolItem(toolTimelineItem(snapshot), state), }); } + for (const item of this.vendorToolCalls.values()) { + this.emit({ + type: "timeline.item", + sessionId: this.options.boundarySessionId, + item: terminalToolItem(item, state), + }); + } + this.vendorToolCalls.clear(); for (const id of this.pendingCompactions) { this.emit({ type: "timeline.item", @@ -1076,6 +1090,10 @@ class AcpRuntime { if (this.configTransaction) this.stagedTransformerConfig = update.config; else this.emitConfigSnapshot(update.config); } else if (update.type === "timeline") { + if (update.item.type === "tool_call") { + if (update.item.status === "running") this.vendorToolCalls.set(update.item.id, update.item); + else this.vendorToolCalls.delete(update.item.id); + } this.emit({ type: "timeline.item", sessionId: this.options.boundarySessionId, @@ -1383,7 +1401,9 @@ function configValues( return Object.fromEntries(options.map((option) => [option.id, option.currentValue])); } -function toolTimelineItem(snapshot: AcpToolCallSnapshot): ProviderTimelineItem { +type ToolCallTimelineItem = Extract; + +function toolTimelineItem(snapshot: AcpToolCallSnapshot): ToolCallTimelineItem { const input = jsonRecord(snapshot.input); const isEdit = snapshot.kind === "edit" || snapshot.name?.toLowerCase().includes("edit") === true; const detail = isEdit @@ -1411,6 +1431,18 @@ function toolTimelineItem(snapshot: AcpToolCallSnapshot): ProviderTimelineItem { return { ...base, status, error: null }; } +// A row the turn left open ends with the turn. The wire contract requires a non-null error on a +// failed tool call, so a canceled turn cancels its rows instead of failing them. +function terminalToolItem( + item: ToolCallTimelineItem, + state: "completed" | "failed" | "canceled", +): ToolCallTimelineItem { + if (state === "failed") { + return { ...item, status: "failed", error: item.error ?? { message: "Turn failed" } }; + } + return { ...item, status: state, error: null }; +} + function jsonValue(value: unknown) { return z.json().parse(value); } diff --git a/packages/plugin/src/server/acp.test.ts b/packages/plugin/src/server/acp.test.ts index a62b2f2c48..13d69416bb 100644 --- a/packages/plugin/src/server/acp.test.ts +++ b/packages/plugin/src/server/acp.test.ts @@ -619,6 +619,146 @@ lines.on("line", (line) => { await connection.close(); }); + it.each([ + ["end_turn", "completed", "completed"], + ["cancelled", "canceled", "canceled"], + ] as const)( + "closes a transformer's running tool row when the turn ends with %s", + async (stopReason, turnState, rowStatus) => { + const harness = connectorHarness({ + handleMessage(instance, message) { + if (!("method" in message) || !("id" in message)) return false; + if (message.method !== "session/prompt") return false; + instance.notify("vendor/phase", { id: "phase-1", label: "Preparing" }); + instance.respond(message as AcpRequestMessage, { stopReason }); + return true; + }, + }); + const registration = runAcpProvider({ + id: "vendor-row-acp", + label: "Vendor row ACP", + connector: harness.connector, + transformers: [ + { + notification(notification) { + if (notification.method !== "vendor/phase") return null; + return { + type: "timeline", + item: { + type: "tool_call", + id: "phase-1", + callId: "phase-1", + name: "Vendor phase", + detail: { type: "plain_text", label: "Preparing" }, + status: "running", + error: null, + }, + }; + }, + }, + ], + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId: "first", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "first" }] }, + }, + }); + await waitForEvent( + events, + (event) => event.type === "session.turn" && event.state === turnState, + ); + + const rows = events.flatMap((event) => + event.type === "timeline.item" && event.item.id === "phase-1" ? [event.item] : [], + ); + expect(rows.map((row) => row.type === "tool_call" && row.status)).toEqual([ + "running", + rowStatus, + ]); + const closed = events.findIndex( + (event) => + event.type === "timeline.item" && + event.item.id === "phase-1" && + event.item.type === "tool_call" && + event.item.status === rowStatus, + ); + const terminal = events.findIndex( + (event) => event.type === "session.turn" && event.state === turnState, + ); + expect(closed).toBeLessThan(terminal); + // The wire contract only accepts a failed tool call with a non-null error. + expect(rows.at(-1)).toMatchObject({ error: null }); + await connection.close(); + }, + ); + + it("cancels an in-progress ACP tool call with the canceled turn instead of failing it without an error", async () => { + const harness = connectorHarness({ + handleMessage(instance, message) { + if (!("method" in message) || !("id" in message)) return false; + if (message.method !== "session/prompt") return false; + instance.notify("session/update", { + sessionId: "connector-session", + update: { + sessionUpdate: "tool_call", + toolCallId: "tool-1", + title: "Run tests", + kind: "execute", + status: "in_progress", + }, + }); + instance.respond(message as AcpRequestMessage, { stopReason: "cancelled" }); + return true; + }, + }); + const registration = runAcpProvider({ + id: "cancel-tool-acp", + label: "Cancel tool ACP", + connector: harness.connector, + }); + const connection = await registration.connect({ + versions: [1], + capabilities: ["prompt.message"], + }); + const events: ProviderEvent[] = []; + connection.onEvent((event) => events.push(event)); + await connection.send(openInput()); + await waitForEvent(events, (event) => event.type === "session.ready"); + + await connection.send({ + type: "session.prompt", + sessionId: "session-1", + prompt: { + clientMessageId: "first", + delivery: "auto", + input: { type: "message", content: [{ type: "text", text: "first" }] }, + }, + }); + await waitForEvent( + events, + (event) => event.type === "session.turn" && event.state === "canceled", + ); + + const rows = events.flatMap((event) => + event.type === "timeline.item" && event.item.id === "tool-1" ? [event.item] : [], + ); + expect(rows.at(-1)).toMatchObject({ type: "tool_call", status: "canceled", error: null }); + await connection.close(); + }); + it("serializes configuration mutations", async () => { let releaseMutation: (() => void) | null = null; const configOptions = [ diff --git a/packages/plugin/src/server/provider.ts b/packages/plugin/src/server/provider.ts index 33b49ab447..1646e8473b 100644 --- a/packages/plugin/src/server/provider.ts +++ b/packages/plugin/src/server/provider.ts @@ -26,6 +26,8 @@ export const PROVIDER_CAPABILITIES = [ export type ProviderCapability = (typeof PROVIDER_CAPABILITIES)[number]; export interface ProviderRegistration { + /** Activate only while agents.providers contains this provider ID, independently of enabled. */ + requiresConfiguration?: boolean; /** Equal keys share discovery within this provider. Include effective configuration and execution environment. */ getCatalogCacheKey?(options: ProviderCatalogOptions): Promise; /** Default executable and arguments; the daemon resolves overrides before status/connect. */ diff --git a/packages/protocol/src/messages.checkout-pr-schema.test.ts b/packages/protocol/src/messages.checkout-pr-schema.test.ts index cf15e47962..3966d6eb71 100644 --- a/packages/protocol/src/messages.checkout-pr-schema.test.ts +++ b/packages/protocol/src/messages.checkout-pr-schema.test.ts @@ -812,3 +812,14 @@ describe("checkout PR schemas", () => { }); }); }); + +test.each([undefined, false, true])( + "accepts optional Hoplite capability (%s)", + (hopliteProvider) => { + const features = hopliteProvider === undefined ? {} : { hopliteProvider }; + expect( + ServerInfoStatusPayloadSchema.parse({ status: "server_info", serverId: "test", features }) + .features, + ).toEqual(features); + }, +); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index fefc104279..c8fe60ff67 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -3710,6 +3710,8 @@ export const ServerInfoStatusPayloadSchema = z commitBaseClassification: z.boolean().optional(), // COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105. providerRemoval: z.boolean().optional(), + // COMPAT(hopliteProvider): added after v0.11.2, remove after 2027-04-15 once host floor includes the bundled adapter. + hopliteProvider: z.boolean().optional(), // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. importSessionWorkspaceTarget: z.boolean().optional(), // COMPAT(importSessionSearch): added in v0.8.0, remove gate after 2027-03-02. diff --git a/packages/protocol/src/provider-icon-names.ts b/packages/protocol/src/provider-icon-names.ts index 6265b631c3..4ca8ea44e3 100644 --- a/packages/protocol/src/provider-icon-names.ts +++ b/packages/protocol/src/provider-icon-names.ts @@ -31,6 +31,7 @@ export const ACP_PROVIDER_ICON_NAMES = [ "glm-acp-agent", "goose", "grok", + "hoplite", "junie", "kilo", "kimi", diff --git a/packages/server/package.json b/packages/server/package.json index beffa3b4ce..a69a2c3c2a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"", + "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 8b894a7765..ae8ed805d7 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -1121,3 +1121,50 @@ describe("plugin provider launch and status", () => { }, ); }); + +test("config-required plugin membership is independent of enablement and other providers", async () => { + const logger = createTestLogger(); + const plugins = new PluginAgentClientRegistry(logger); + const harness = createProviderHarness(); + let connections = 0; + let probes = 0; + plugins.replace([ + { + ...harness.registration, + requiresConfiguration: true, + async status() { + probes++; + return { available: true }; + }, + async connect(request) { + connections++; + return harness.registration.connect(request); + }, + }, + ]); + const build = (providerOverrides = {}) => + buildProviderRegistry(logger, { + pluginProviders: plugins.definitions(), + providerOverrides, + }); + try { + const before = build(); + expect(before["plugin-direct"]).toBeUndefined(); + expect({ probes, connections }).toEqual({ probes: 0, connections: 0 }); + const added = build({ "plugin-direct": {} }); + expect(added["plugin-direct"].enabled).toBe(true); + const client = added["plugin-direct"].createClient(logger); + expect(await client.isAvailable()).toBe(true); + const catalog = await client.fetchCatalog({ scope: "global" }); + expect(catalog.models.map((model) => model.id)).toEqual(["plugin-model"]); + expect({ probes, connections }).toEqual({ probes: 1, connections: 1 }); + const disabled = build({ "plugin-direct": { enabled: false } }); + expect(disabled["plugin-direct"].enabled).toBe(false); + expect(Object.keys(build())).toEqual(Object.keys(before)); + for (const id of Object.keys(before)) { + expect(added[id].enabled).toBe(before[id].enabled); + } + } finally { + await plugins.shutdown(); + } +}); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index 58bddd07b6..e575ff0d45 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -885,6 +885,7 @@ function createPluginProviderDefinition( label: registration.label, description: registration.description ?? `Plugin provider ${registration.label}`, iconSvg: registration.icon, + requiresConfiguration: registration.requiresConfiguration, defaultModeId: null, modes: [], supportsExactMcpPreapproval: true, diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 2698a3b2ac..9f85e4d37f 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -97,6 +97,7 @@ export interface ProviderDefinition extends AgentProviderDefinition { /** Provider metadata and an unconfigured factory; the registry owns override application. */ export interface RegisteredProviderDefinition extends AgentProviderDefinition { + requiresConfiguration?: boolean; iconSvg?: string; supportsExactMcpPreapproval: boolean; createClient: (logger: Logger, runtimeSettings?: ProviderRuntimeSettings) => AgentClient; @@ -925,6 +926,8 @@ export function buildProviderRegistry( throw new Error(`Plugin provider '${provider}' conflicts with a built-in provider`); } const override = providerOverrides[provider]; + if (definition.requiresConfiguration && !override) continue; + if (override?.extends) { logger.warn({ provider }, "Plugin provider shadowed by configured provider"); continue; diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 82acd13e1f..b10a4f8e02 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -348,6 +348,10 @@ export class ProviderSnapshotManager { return Object.prototype.hasOwnProperty.call(this.generation.definitions, provider); } + supportsConfigRequiredProvider(provider: AgentProvider): boolean { + return this.pluginProviders.definitions()[provider]?.requiresConfiguration === true; + } + getProviderLabel(provider: AgentProvider): string { return this.generation.definitions[provider]?.label ?? provider; } diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 169421fca9..3b61fcb863 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -3098,7 +3098,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.cachedCommands = update.availableCommands.map((command) => ({ name: command.name, description: command.description, - argumentHint: "", + argumentHint: command.input?.hint ?? "", kind: "command", })); this.settleCommandsReady(); diff --git a/packages/server/src/server/agent/providers/claude/agent.context-switch.real.e2e.test.ts b/packages/server/src/server/agent/providers/claude/agent.context-switch.real.e2e.test.ts new file mode 100644 index 0000000000..f05a6ee271 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.context-switch.real.e2e.test.ts @@ -0,0 +1,42 @@ +import pino from "pino"; +import { expect, test } from "vitest"; +import { ClaudeAgentClient } from "./agent.js"; +import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js"; + +// Explicit real-provider test: run separately from the default suite. +test("uses the active model context window after switching from a 1M model", async () => { + const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }) }); + const session = await client.createSession( + { provider: "claude", cwd: process.cwd(), model: "claude-haiku-4-5" }, + undefined, + { persistSession: false }, + ); + try { + for (const [model, maxTokens] of [ + ["claude-haiku-4-5", 200_000], + ["claude-opus-5-5", 1_000_000], + ["claude-haiku-4-5", 200_000], + ] as const) { + await session.setModel(model); + const events = await collectSessionTurnEvents( + session, + "Reply with exactly: OK. Do not use tools.", + { signal: AbortSignal.timeout(90_000) }, + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "usage_updated", + usage: expect.objectContaining({ contextWindowMaxTokens: maxTokens }), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "turn_completed", + usage: expect.objectContaining({ contextWindowMaxTokens: maxTokens }), + }), + ); + } + } finally { + await session.close(); + } +}, 300_000); diff --git a/packages/server/src/server/agent/providers/claude/agent.context-window.test.ts b/packages/server/src/server/agent/providers/claude/agent.context-window.test.ts index 1f0c5bd36c..84f31779e4 100644 --- a/packages/server/src/server/agent/providers/claude/agent.context-window.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.context-window.test.ts @@ -6,12 +6,28 @@ import type { ClaudeQueryFactory } from "./query.js"; // A query dependency that speaks the same stream interface as Claude Code. // Each request repeats init so a second turn exercises runtime-limit retention. -function contextQuery(runtimeModel: string, reportedLimit: number): ClaudeQueryFactory { +function contextQuery({ + runtimeModel, + reportedLimit, + modelWindowsByTurn, +}: { + runtimeModel: string; + reportedLimit: number; + modelWindowsByTurn?: Array>; +}): ClaudeQueryFactory { return ({ prompt }) => { let model = runtimeModel; + let turn = 0; const messages = (async function* () { if (typeof prompt === "string") throw new Error("Expected streaming input"); for await (const _message of prompt) { + const modelWindows = modelWindowsByTurn?.[turn] ?? { [model]: reportedLimit }; + turn += 1; + const usageEntries = Object.entries(modelWindows).map(([id, contextWindow]) => [ + id, + { contextWindow }, + ]); + const modelUsage = Object.fromEntries(usageEntries); yield { type: "system", subtype: "init", @@ -41,7 +57,7 @@ function contextQuery(runtimeModel: string, reportedLimit: number): ClaudeQueryF stop_reason: null, total_cost_usd: 0, usage: { input_tokens: 249_950, output_tokens: 50, cache_read_input_tokens: 0 }, - modelUsage: { [model]: { contextWindow: reportedLimit } }, + modelUsage, permission_denials: [], uuid: "context-result", }; @@ -71,7 +87,7 @@ test.each([ async ({ selected, runtime, expected }) => { const client = new ClaudeAgentClient({ logger: createTestLogger(), - queryFactory: contextQuery(runtime, 800_000), + queryFactory: contextQuery({ runtimeModel: runtime, reportedLimit: 800_000 }), resolveBinary: async () => process.execPath, }); const session = await client.createSession({ @@ -110,3 +126,93 @@ test.each([ } }, ); + +test("completed turns use the active model's window after switching both ways", async () => { + const haiku = "claude-haiku-4-5"; + const opus = "claude-opus-5-5"; + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveBinary: async () => process.execPath, + queryFactory: contextQuery({ + runtimeModel: haiku, + reportedLimit: 200_000, + modelWindowsByTurn: [ + { [haiku]: 200_000 }, + { [haiku]: 200_000, [opus]: 1_000_000 }, + { [haiku]: 200_000, [opus]: 1_000_000 }, + ], + }), + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + model: haiku, + }); + try { + for (const [model, limit] of [ + [haiku, 200_000], + [opus, 1_000_000], + [haiku, 200_000], + ] as const) { + await session.setModel(model); + const events = await collectSessionTurnEvents(session, "OK"); + expect(events).toContainEqual( + expect.objectContaining({ + type: "usage_updated", + usage: expect.objectContaining({ contextWindowMaxTokens: limit }), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "turn_completed", + usage: expect.objectContaining({ contextWindowMaxTokens: limit }), + }), + ); + } + } finally { + await session.close(); + } +}); + +test.each([ + { + name: "runtime-reported custom limit", + model: "custom-model", + windows: { "custom-model": 400_000, "prior-model": 1_000_000 }, + expected: 400_000, + }, + { + name: "missing active model entry", + model: "claude-haiku-4-5", + windows: { "prior-model": 1_000_000 }, + expected: 200_000, + }, + { + name: "invalid active model limit", + model: "claude-haiku-4-5", + windows: { "claude-haiku-4-5": -1, "prior-model": 1_000_000 }, + expected: 200_000, + }, +])("preserves $name without using another model's window", async ({ model, windows, expected }) => { + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveBinary: async () => process.execPath, + queryFactory: contextQuery({ + runtimeModel: model, + reportedLimit: expected, + modelWindowsByTurn: [windows], + }), + }); + const session = await client.createSession({ provider: "claude", cwd: process.cwd(), model }); + try { + const events = await collectSessionTurnEvents(session, "OK"); + expect(events).toContainEqual( + expect.objectContaining({ + type: "turn_completed", + usage: expect.objectContaining({ contextWindowMaxTokens: expected }), + }), + ); + } finally { + await session.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.import-titles.test.ts b/packages/server/src/server/agent/providers/claude/agent.import-titles.test.ts new file mode 100644 index 0000000000..e20b8d6faf --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.import-titles.test.ts @@ -0,0 +1,239 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; + +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import { ClaudeAgentClient } from "./agent.js"; +import { claudeProjectDirSync } from "./project-dir.js"; + +const SESSION_CWD = "/tmp/paseo-claude-renamed-import"; + +interface ClaudeTranscriptRecord { + [key: string]: unknown; +} + +let configDir: string; +let previousConfigDir: string | undefined; + +interface UserRecordInput { + sessionId: string; + content: string; +} + +interface WriteSessionInput { + sessionId: string; + records: ClaudeTranscriptRecord[]; +} + +function userRecord({ sessionId, content }: UserRecordInput): ClaudeTranscriptRecord { + return { + isSidechain: false, + type: "user", + message: { role: "user", content }, + cwd: SESSION_CWD, + sessionId, + }; +} + +async function writeSession({ sessionId, records }: WriteSessionInput): Promise { + const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir }); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeFile( + path.join(projectDir, `${sessionId}.jsonl`), + `${records.map((record) => JSON.stringify(record)).join("\n")}\n`, + ); +} + +// Importing replays the transcript from disk, so this drives the real resume +// path without starting Claude Code. +async function importSession(sessionId: string) { + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveBinary: async () => "/test/claude/bin", + }); + return client.importSession( + { providerHandleId: sessionId, cwd: SESSION_CWD }, + { + config: { provider: "claude", cwd: SESSION_CWD }, + storedConfig: { provider: "claude", cwd: SESSION_CWD }, + }, + ); +} + +beforeEach(async () => { + configDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-renamed-")); + previousConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = configDir; +}); + +afterEach(async () => { + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir; + } + await fs.rm(configDir, { recursive: true, force: true }); +}); + +describe("importing a renamed Claude session", () => { + test.each(["provider", "launch"])( + "reads the rename from the %s config directory used for history", + async (source) => { + const sessionId = "provider-directory-rename"; + const providerConfigDir = path.join(configDir, "provider"); + const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir: providerConfigDir }); + await fs.mkdir(projectDir, { recursive: true }); + await fs.writeFile( + path.join(projectDir, `${sessionId}.jsonl`), + [ + userRecord({ sessionId, content: "Review this project" }), + { type: "custom-title", customTitle: "My research session", sessionId }, + ] + .map((record) => JSON.stringify(record)) + .join("\n") + "\n", + ); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + runtimeSettings: + source === "provider" ? { env: { CLAUDE_CONFIG_DIR: providerConfigDir } } : undefined, + resolveBinary: async () => "/test/claude/bin", + }); + const imported = await client.importSession( + { providerHandleId: sessionId, cwd: SESSION_CWD }, + { + config: { provider: "claude", cwd: SESSION_CWD }, + storedConfig: { provider: "claude", cwd: SESSION_CWD }, + launchContext: + source === "launch" ? { env: { CLAUDE_CONFIG_DIR: providerConfigDir } } : undefined, + }, + ); + expect(imported.timeline.map((entry) => entry.item)).toContainEqual( + expect.objectContaining({ type: "user_message", text: "Review this project" }), + ); + expect(imported.config.title).toBe("My research session"); + }, + ); + + test("names the imported agent after a /rename", async () => { + const sessionId = "renamed-session"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "Review this project" }), + { type: "custom-title", customTitle: "My research session", sessionId }, + ], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBe("My research session"); + }); + + test("uses the last rename when the session was renamed more than once", async () => { + const sessionId = "renamed-twice"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "Move invoices to the new schema" }), + { type: "custom-title", customTitle: "invoices", sessionId }, + { type: "custom-title", customTitle: "billing rework", sessionId }, + ], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBe("billing rework"); + }); + + test("preserves a rename when the first prompt was /model", async () => { + const sessionId = "model-first"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "/model" }), + { type: "custom-title", customTitle: "My research session", sessionId }, + ], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBe("My research session"); + }); + + test("keeps the rename when a later record carries no usable name", async () => { + const sessionId = "malformed-later-record"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "Review this project" }), + { type: "custom-title", customTitle: "My research session", sessionId }, + { type: "custom-title", sessionId }, + ], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBe("My research session"); + }); + + test("ignores a rename recorded on a sidechain transcript", async () => { + const sessionId = "sidechain-rename"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "Review this project" }), + { type: "custom-title", customTitle: "Subagent title", sessionId, isSidechain: true }, + ], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBeUndefined(); + }); + + test("leaves the agent untitled when the session was never renamed", async () => { + const sessionId = "never-renamed"; + await writeSession({ + sessionId, + records: [userRecord({ sessionId, content: "Review this project" })], + }); + + const imported = await importSession(sessionId); + + expect(imported.config.title).toBeUndefined(); + }); + + test("does not name the imported agent after a generated ai-title", async () => { + const sessionId = "auto-titled"; + await writeSession({ + sessionId, + records: [ + userRecord({ sessionId, content: "Add pagination to the invoice list" }), + { type: "ai-title", aiTitle: "Invoice list pagination", sessionId }, + ], + }); + + const imported = await importSession(sessionId); + + // The Import Session list shows it, but agents are named from their first + // prompt line. + expect(imported.config.title).toBeUndefined(); + }); + + test("imports a session that has no transcript on disk", async () => { + const imported = await importSession("no-such-session"); + + expect(imported.config.title).toBeUndefined(); + }); + + test("surfaces an unreadable transcript instead of importing it as never renamed", async () => { + const sessionId = "unreadable-transcript"; + // A directory where the transcript belongs fails the read with EISDIR, the + // deterministic stand-in for a permissions or I/O fault. + const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir }); + await fs.mkdir(path.join(projectDir, `${sessionId}.jsonl`), { recursive: true }); + + await expect(importSession(sessionId)).rejects.toThrow(); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 8ea12610ea..8c5cc1a41c 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -203,24 +203,26 @@ function stripClaudeAskUserQuestionUiMetadata(input: AgentMetadata): AgentMetada }; } -export function normalizeClaudeAskUserQuestionUpdatedInput( +/** + * The answers this response will actually deliver, keyed by full question text, + * or null when there is no question list and answers map to work from. + * + * Shared by the normalizer and by the check that rejects an undeliverable + * answer, so the two can never disagree about what "deliverable" means. + */ +function resolveClaudeAskUserQuestionAnswers( updatedInput: AgentMetadata | undefined, fallbackInput: AgentMetadata | undefined, -): AgentMetadata { +): Record | null { const fallback = isMetadata(fallbackInput) ? fallbackInput : {}; const base = isMetadata(updatedInput) ? updatedInput : {}; - // Paseo's shared question UI serializes answers by question header, but Claude's - // AskUserQuestion tool expects answer keys to match the full question text. Merge - // the original request payload back in so provider callbacks that only return - // `{ answers }` still satisfy Claude's full tool input schema. - const merged = stripClaudeAskUserQuestionUiMetadata({ ...fallback, ...base }); const questions = (Array.isArray(base.questions) ? base.questions : null) ?? (Array.isArray(fallback.questions) ? fallback.questions : null); const answers = isMetadata(base.answers) ? base.answers : null; if (!questions || !answers) { - return merged; + return null; } const normalizedAnswers: Record = {}; @@ -244,7 +246,23 @@ export function normalizeClaudeAskUserQuestionUpdatedInput( } } - if (Object.keys(normalizedAnswers).length === 0) { + return normalizedAnswers; +} + +export function normalizeClaudeAskUserQuestionUpdatedInput( + updatedInput: AgentMetadata | undefined, + fallbackInput: AgentMetadata | undefined, +): AgentMetadata { + const fallback = isMetadata(fallbackInput) ? fallbackInput : {}; + const base = isMetadata(updatedInput) ? updatedInput : {}; + // Paseo's shared question UI serializes answers by question header, but Claude's + // AskUserQuestion tool expects answer keys to match the full question text. Merge + // the original request payload back in so provider callbacks that only return + // `{ answers }` still satisfy Claude's full tool input schema. + const merged = stripClaudeAskUserQuestionUiMetadata({ ...fallback, ...base }); + const normalizedAnswers = resolveClaudeAskUserQuestionAnswers(updatedInput, fallbackInput); + + if (!normalizedAnswers || Object.keys(normalizedAnswers).length === 0) { return merged; } @@ -254,6 +272,32 @@ export function normalizeClaudeAskUserQuestionUpdatedInput( }; } +class ClaudeQuestionAnswerRequiredError extends Error { + readonly code = "question_answer_required" as const; + + constructor(readonly requestId: string) { + super( + `Permission request '${requestId}' requires updatedInput.answers keyed by a ` + + `question's full text or header, with a non-empty string value. ` + + `The request is still pending; retry with that shape.`, + ); + this.name = "ClaudeQuestionAnswerRequiredError"; + } +} + +function assertClaudeQuestionAnswerDeliverable( + request: AgentPermissionRequest, + response: AgentPermissionResponse, +): void { + if (request.kind !== "question" || response.behavior !== "allow") { + return; + } + const answers = resolveClaudeAskUserQuestionAnswers(response.updatedInput, request.input); + if (!answers || Object.keys(answers).length === 0) { + throw new ClaudeQuestionAnswerRequiredError(request.id); + } +} + function isObjectRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } @@ -1693,11 +1737,21 @@ export class ClaudeAgentClient implements AgentClient { } async importSession(input: ImportProviderSessionInput, context: ImportProviderSessionContext) { + // Carry a `/rename` onto the imported agent so the name chosen in the CLI + // survives the import. Only an explicit rename counts: agents are named + // after their first prompt line since #1563, so adopting the generated + // `ai-title` here would put that LLM summary back. + const renamedTitle = await readClaudeRenamedSessionTitle({ + cwd: input.cwd, + sessionId: input.providerHandleId, + configDir: claudeConfigDir(this.buildProviderEnv(context.launchContext?.env)), + }); return importSessionFromPersistence({ provider: "claude", request: input, context, resumeSession: this.resumeSession.bind(this), + ...(renamedTitle ? { config: { title: renamedTitle } } : {}), }); } @@ -1829,30 +1883,22 @@ async function resolveClaudeAuth( } } -function extractContextWindowSize(modelUsage: unknown): number | undefined { +function extractContextWindowSize( + modelUsage: unknown, + modelId: string | null | undefined, +): number | undefined { const usageRecord = toObjectRecord(modelUsage); if (!usageRecord) { return undefined; } - let maxContextWindow: number | undefined; - for (const value of Object.values(usageRecord)) { - const valueRecord = toObjectRecord(value); - if (!valueRecord) { - continue; - } - const contextWindow = valueRecord.contextWindow; - if ( - typeof contextWindow !== "number" || - !Number.isFinite(contextWindow) || - contextWindow <= 0 - ) { - continue; - } - maxContextWindow = Math.max(maxContextWindow ?? 0, contextWindow); - } - - return maxContextWindow; + // modelUsage accumulates every model used in the session, including earlier turns + // and subagents. Only the active model describes this turn's context capacity. + const modelUsageEntry = modelId ? usageRecord[modelId] : undefined; + const contextWindow = toObjectRecord(modelUsageEntry)?.contextWindow; + return typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0 + ? contextWindow + : undefined; } interface StreamRequestInputUsage { @@ -2006,8 +2052,8 @@ class ClaudeContextUsageState { this.contextWindowMaxTokens = contextWindowMaxTokens; } - recordModelUsage(modelUsage: unknown): number | undefined { - const contextWindowMaxTokens = extractContextWindowSize(modelUsage); + recordModelUsage(modelUsage: unknown, modelId: string | null | undefined): number | undefined { + const contextWindowMaxTokens = extractContextWindowSize(modelUsage, modelId); if (contextWindowMaxTokens !== undefined) { this.contextWindowMaxTokens = contextWindowMaxTokens; } @@ -2050,7 +2096,10 @@ class ClaudeContextUsageState { return this.createUsageUpdatedEvent(usedTokens); } - buildResultUsage(message: SDKResultMessage, modelUsage: unknown): AgentUsage | undefined { + buildResultUsage( + message: SDKResultMessage, + modelId: string | null | undefined, + ): AgentUsage | undefined { try { if (!message.usage) { return undefined; @@ -2062,7 +2111,7 @@ class ClaudeContextUsageState { totalCostUsd: message.total_cost_usd, }; - const modelContextWindowMaxTokens = this.recordModelUsage(modelUsage ?? message.modelUsage); + const modelContextWindowMaxTokens = this.recordModelUsage(message.modelUsage, modelId); if (this.contextWindowMaxTokens !== undefined) { usage.contextWindowMaxTokens = this.contextWindowMaxTokens; } else if (modelContextWindowMaxTokens !== undefined) { @@ -2729,6 +2778,12 @@ class ClaudeAgentSession implements AgentSession { if (!pending) { throw new Error(`No pending permission request with id '${requestId}'`); } + + // Before the delete below, so a rejected answer leaves the request pending + // and answerable rather than consuming it for nothing. AgentManager only + // drops its own copy once this resolves, so both maps survive the throw. + assertClaudeQuestionAnswerDeliverable(pending.request, response); + this.pendingPermissions.delete(requestId); pending.cleanup?.(); @@ -4696,7 +4751,7 @@ class ClaudeAgentSession implements AgentSession { message: Extract, events: AgentStreamEvent[], ): void { - const usage = this.convertUsage(message, message.modelUsage); + const usage = this.convertUsage(message); if (message.subtype === "success") { events.push(...this.sidechainTracker.finishAll("completed")); // Built-in slash commands (e.g. /voice, /usage, "Unknown command: …") @@ -4878,8 +4933,8 @@ class ClaudeAgentSession implements AgentSession { return null; } - private convertUsage(message: SDKResultMessage, modelUsage?: unknown): AgentUsage | undefined { - return this.contextUsage.buildResultUsage(message, modelUsage); + private convertUsage(message: SDKResultMessage): AgentUsage | undefined { + return this.contextUsage.buildResultUsage(message, this.lastRuntimeModel ?? this.config.model); } private handlePermissionRequest: CanUseTool = async ( @@ -6608,6 +6663,66 @@ function normalizeClaudeSessionTitle(title: string | null): string | null { return normalized ? normalized : null; } +const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title"; + +/** + * The `custom-title` record is re-emitted every time the session is saved, so + * the current name is the last usable one in the file. Searching backwards finds it without + * walking the transcript, which matters because sessions reach tens of + * megabytes. + */ +function readLastClaudeCustomTitle(content: string): string | null { + const needle = `"${CLAUDE_CUSTOM_TITLE_TYPE}"`; + let at = content.lastIndexOf(needle); + while (at >= 0) { + const lineStart = content.lastIndexOf("\n", at) + 1; + const lineEnd = content.indexOf("\n", at); + const line = lineEnd === -1 ? content.slice(lineStart) : content.slice(lineStart, lineEnd); + let parsed: unknown; + try { + parsed = JSON.parse(line.trim()); + } catch { + parsed = undefined; + } + const entry = toObjectRecord(parsed); + if (entry && !entry.isSidechain && entry.type === CLAUDE_CUSTOM_TITLE_TYPE) { + const title = normalizeClaudeSessionTitle( + typeof entry.customTitle === "string" ? entry.customTitle : null, + ); + if (title) { + return title; + } + } + // The needle also matches a prompt that quotes it, and a record can carry no + // usable name, so keep looking backwards for one that does. + at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1); + } + return null; +} + +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + +async function readClaudeRenamedSessionTitle(input: { + cwd: string; + sessionId: string; + configDir: string; +}): Promise { + let content: string; + try { + content = await fsPromises.readFile(claudeTranscriptPathSync(input), "utf8"); + } catch (error) { + // A session with no transcript on disk has no rename to carry over. Every + // other read failure is a real fault and stays explicit. + if (isErrnoException(error) && error.code === "ENOENT") { + return null; + } + throw error; + } + return readLastClaudeCustomTitle(content); +} + function normalizeImportablePromptPreview(text: string): string | null { const normalized = text.trim().replace(/\s+/g, " "); if (!normalized) return null; diff --git a/packages/server/src/server/agent/providers/claude/question-response.test.ts b/packages/server/src/server/agent/providers/claude/question-response.test.ts new file mode 100644 index 0000000000..26909faab7 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/question-response.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from "vitest"; +import type { CanUseTool, Query, SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { ClaudeAgentClient } from "./agent.js"; +import type { ClaudeQueryFactory } from "./query.js"; +import type { AgentPermissionResponse } from "../../agent-sdk-types.js"; +import { createTestLogger } from "../../../../test-utils/test-logger.js"; + +// The SDK dependency receives the same permission callback as a real Claude process. +// Keep its stream open until close; permission state belongs entirely to the session. +function createSdkFake() { + let canUseTool: CanUseTool | undefined; + let finish!: () => void; + const finished = new Promise((resolve) => { + finish = resolve; + }); + const queryFactory: ClaudeQueryFactory = ({ options }) => { + canUseTool = options.canUseTool; + return { + close: finish, + return: async () => { + finish(); + return { value: undefined, done: true }; + }, + [Symbol.asyncIterator]() { + return { + next: async (): Promise> => { + await finished; + return { value: undefined, done: true }; + }, + }; + }, + } as Query; + }; + return { + queryFactory, + ask: (input: Record) => { + if (!canUseTool) throw new Error("SDK permission callback was not configured"); + return canUseTool("AskUserQuestion", input, { + signal: new AbortController().signal, + toolUseID: "question-tool-call", + }); + }, + }; +} + +const questionInput = { + questions: [{ question: "Which colour?", header: "Colour", options: [], multiSelect: false }], +}; + +const invalidResponses: Array<[string, AgentPermissionResponse]> = [ + ["selectedActionId", { behavior: "allow", selectedActionId: "Viridian" }], + ["missing answers", { behavior: "allow", updatedInput: {} }], + ["unknown question", { behavior: "allow", updatedInput: { answers: { Hue: "Viridian" } } }], + ["blank answer", { behavior: "allow", updatedInput: { answers: { Colour: " " } } }], + ["non-string answer", { behavior: "allow", updatedInput: { answers: { Colour: 1 } } }], + [ + "empty echoed questions", + { behavior: "allow", updatedInput: { questions: [], answers: { Colour: "Viridian" } } }, + ], +]; + +describe("Claude question permission responses", () => { + test.each(invalidResponses)( + "rejects %s and delivers a corrected retry", + async (_label, response) => { + const sdk = createSdkFake(); + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: sdk.queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" }); + try { + await session.startTurn("Ask which colour"); + const answer = sdk.ask(questionInput); + void answer.catch(() => undefined); + const request = session.getPendingPermissions()[0]!; + expect(request.kind).toBe("question"); + await expect(session.respondToPermission(request.id, response)).rejects.toMatchObject({ + code: "question_answer_required", + requestId: request.id, + }); + expect(session.getPendingPermissions()).toEqual([request]); + await session.respondToPermission(request.id, { + behavior: "allow", + updatedInput: { answers: { Colour: "Viridian" } }, + }); + await expect(answer).resolves.toMatchObject({ + behavior: "allow", + updatedInput: { answers: { "Which colour?": "Viridian" } }, + }); + expect(session.getPendingPermissions()).toEqual([]); + } finally { + await session.close(); + } + }, + ); + + test.each([ + ["header", questionInput, { Colour: "Viridian" }, { "Which colour?": "Viridian" }], + ["question text", questionInput, { "Which colour?": "Ochre" }, { "Which colour?": "Ochre" }], + [ + "padded question text", + { questions: [{ question: " Which colour? ", header: " Colour " }] }, + { " Which colour? ": "Ochre" }, + { " Which colour? ": "Ochre" }, + ], + [ + "padded header", + { questions: [{ question: " Which colour? ", header: " Colour " }] }, + { " Colour ": "Viridian" }, + { " Which colour? ": "Viridian" }, + ], + [ + "partial answer", + { questions: [...questionInput.questions, { question: "Ship it?", header: "Ship" }] }, + { Ship: "Yes" }, + { "Ship it?": "Yes" }, + ], + ])("delivers answers keyed by %s", async (_label, input, answers, expected) => { + const sdk = createSdkFake(); + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: sdk.queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" }); + try { + await session.startTurn("Ask which colour"); + const answer = sdk.ask(input); + void answer.catch(() => undefined); + const request = session.getPendingPermissions()[0]!; + await session.respondToPermission(request.id, { + behavior: "allow", + updatedInput: { answers }, + }); + await expect(answer).resolves.toMatchObject({ + behavior: "allow", + updatedInput: { answers: expected }, + }); + expect(session.getPendingPermissions()).toEqual([]); + } finally { + await session.close(); + } + }); + + test("allows denying a question without answers", async () => { + const sdk = createSdkFake(); + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: sdk.queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" }); + try { + await session.startTurn("Ask which colour"); + const answer = sdk.ask(questionInput); + void answer.catch(() => undefined); + const request = session.getPendingPermissions()[0]!; + await session.respondToPermission(request.id, { behavior: "deny", message: "No answer" }); + await expect(answer).resolves.toMatchObject({ behavior: "deny", message: "No answer" }); + expect(session.getPendingPermissions()).toEqual([]); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.commands.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.commands.test.ts index 2411773a7e..3655cccb5b 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.commands.test.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.commands.test.ts @@ -8,7 +8,7 @@ import { createTestLogger } from "../../../test-utils/test-logger.js"; import { GenericACPAgentClient } from "./generic-acp-agent.js"; describe("GenericACPAgentClient slash commands", () => { - test("lists commands an agent advertises right after session/new", async () => { + test("preserves argument hints in commands advertised right after session/new", async () => { await withFakeACPAgent("commands-after-session-new", async (command, cwd) => { const client = new GenericACPAgentClient({ logger: createTestLogger(), @@ -17,9 +17,17 @@ describe("GenericACPAgentClient slash commands", () => { }); const session = await client.createSession({ provider: "acp", cwd }); try { - await expect(session.listCommands?.()).resolves.toEqual([ - { name: "review", description: "Review the diff", argumentHint: "", kind: "command" }, - ]); + const commands = [ + { + name: "review", + description: "Review the diff", + argumentHint: "query", + kind: "command", + }, + { name: "status", description: "Show status", argumentHint: "", kind: "command" }, + ]; + await expect(session.listCommands?.()).resolves.toEqual(commands); + await expect(session.listCommands?.()).resolves.toEqual(commands); } finally { await session.close(); } @@ -91,7 +99,10 @@ rl.on("line", (line) => { sessionId: "session-1", update: { sessionUpdate: "available_commands_update", - availableCommands: [{ name: "review", description: "Review the diff" }], + availableCommands: [ + { name: "review", description: "Review the diff", input: { hint: "query" } }, + { name: "status", description: "Show status" }, + ], }, }, }, diff --git a/packages/server/src/server/agent/providers/omp/question-ui.ts b/packages/server/src/server/agent/providers/omp/question-ui.ts index 4fdb37f61c..2a90d7288e 100644 --- a/packages/server/src/server/agent/providers/omp/question-ui.ts +++ b/packages/server/src/server/agent/providers/omp/question-ui.ts @@ -3,6 +3,7 @@ import type { AgentPermissionResponse, AgentProvider, } from "../../agent-sdk-types.js"; +import { splitMultiSelectAnswer } from "../question-card-answer.js"; import type { OmpAgentMessage, OmpRuntimeEvent } from "./rpc-types.js"; type UiRequest = Extract; @@ -176,21 +177,7 @@ function selectedAnswer( return labels.includes(answer) ? { selected: [answer], custom: null } : { selected: [], custom: answer }; - - // The shared question UI sends a comma-joined answer in click order. Consume - // exact option labels from the front; any remaining text is the Other answer. - let remaining = answer; - const selected: string[] = []; - while (remaining.length > 0) { - const label = labels - .filter((candidate) => !selected.includes(candidate)) - .sort((left, right) => right.length - left.length) - .find((candidate) => remaining === candidate || remaining.startsWith(`${candidate}, `)); - if (!label) break; - selected.push(label); - remaining = remaining === label ? "" : remaining.slice(label.length + 2); - } - return { selected, custom: remaining || null }; + return splitMultiSelectAnswer(answer, labels); } /** Owns OMP question mapping and the ask tool's multi-RPC answer sequence. */ diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/v2-streamed-text-memory.ts b/packages/server/src/server/agent/providers/opencode/test-utils/v2-streamed-text-memory.ts new file mode 100644 index 0000000000..d9d254d9de --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/test-utils/v2-streamed-text-memory.ts @@ -0,0 +1,48 @@ +import { TimelineProjection } from "../../../timeline-projection.js"; +import { V2Timeline } from "../v2/timeline.js"; + +// Streams one reasoning part through the timeline into the daemon's projection and +// prints the bytes still held once the part is complete. +const mode = process.argv[2]; +const chunks = Number(process.argv[3]); +if (mode !== "delta" && mode !== "snapshot") throw new Error("Mode must be delta or snapshot"); +if (!global.gc) throw new Error("Run with --expose-gc"); + +global.gc(); +const before = process.memoryUsage().heapUsed; +const timeline = new V2Timeline(); +const projection = new TimelineProjection(); +const part = { assistantMessageID: "answer", type: "reasoning" as const, ordinal: 0 }; +timeline.startPart(part); +let text = ""; +for (let index = 0; index < chunks; index++) { + const delta = `chunk-${index}:`.padEnd(64, String.fromCharCode(65 + (index % 26))); + text += delta; + const event = + mode === "delta" + ? timeline.delta({ ...part, delta }) + : timeline.messages([ + { + id: "answer", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "model" }, + time: { created: 2 }, + content: [{ type: "reasoning", text }], + }, + ])[0]; + if (event?.type !== "timeline") throw new Error("Expected a timeline event"); + projection.append({ + seq: index + 1, + timestamp: "2026-10-01T00:00:00.000Z", + turnId: "turn", + item: event.item, + }); +} +global.gc(); +const retainedBytes = process.memoryUsage().heapUsed - before; +const [row] = projection.getRows(); +if (row?.item.type !== "reasoning" || row.item.text !== text) { + throw new Error("Projection lost the streamed text"); +} +process.stdout.write(String(retainedBytes)); diff --git a/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts b/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts index 50a6d98549..69da960c5c 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.test.ts @@ -109,7 +109,7 @@ describe("OpenCode v2 question tool", () => { { value: "Explore", label: "Explore", description: "Map the codebase" }, { value: "Fix a bug", label: "Fix a bug", description: "Track down a bug" }, ], - multiple: false, + multiSelect: false, allowOther: true, }, ], @@ -129,6 +129,99 @@ describe("OpenCode v2 question tool", () => { await session.close(); } }); + + test("answers a multi-select question with the checked options and a typed answer", async () => { + const harness = new V2Harness(); + harness.api.session.form.list = async () => [ + { + id: "question", + sessionID: "session", + title: "Questions", + fields: [ + { + key: "q0", + title: "Fixes", + description: "Which fixes should I make?", + type: "multiselect", + options: [ + { value: "delete", label: "Delete both" }, + { value: "delete-and-fix", label: "Delete both, fix code" }, + { value: "email", label: "Email the user" }, + ], + custom: true, + }, + ], + }, + ]; + const answers: Parameters[0][] = []; + harness.api.session.form.reply = async (input) => { + answers.push(input); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + const [request] = session.getPendingPermissions(); + expect(request.input).toMatchObject({ questions: [{ header: "Fixes", multiSelect: true }] }); + await session.respondToPermission("question", { + behavior: "allow", + updatedInput: { + answers: { Fixes: "Delete both, fix code, Email the user, Also check the logs" }, + }, + }); + expect(answers).toEqual([ + { + sessionID: "session", + formID: "question", + answer: { q0: ["delete-and-fix", "email", "Also check the logs"] }, + }, + ]); + expect(session.getPendingPermissions()).toHaveLength(0); + } finally { + await session.close(); + } + }); + + test("rejects typed text on a multi-select question that has no typed answer", async () => { + const harness = new V2Harness(); + harness.api.session.form.list = async () => [ + { + id: "question", + sessionID: "session", + title: "Questions", + fields: [ + { + key: "q0", + title: "Fixes", + type: "multiselect", + options: [{ value: "delete", label: "Delete both" }], + }, + ], + }, + ]; + const answers: Parameters[0][] = []; + harness.api.session.form.reply = async (input) => { + answers.push(input); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + await expect( + session.respondToPermission("question", { + behavior: "allow", + updatedInput: { answers: { Fixes: "Delete both, Something else" } }, + }), + ).rejects.toThrow("Invalid answer for OpenCode question q0"); + expect(answers).toEqual([]); + } finally { + await session.close(); + } + }); }); describe("OpenCode v2 permission routing", () => { diff --git a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts index cc13b99e4b..cb33e0652e 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts @@ -7,6 +7,7 @@ import type { AgentSessionConfig, AgentStreamEvent, } from "../../../agent-sdk-types.js"; +import { splitMultiSelectAnswer } from "../../question-card-answer.js"; export class SessionPermissions { private readonly pending = new Map(); @@ -109,7 +110,7 @@ export class SessionPermissions { header: field.title ?? field.key, question: field.description ?? field.title ?? field.key, options: "options" in field ? field.options : undefined, - multiple: field.type === "multiselect", + multiSelect: field.type === "multiselect", allowOther: "custom" in field && field.custom === true, })), }, @@ -143,8 +144,8 @@ function formAnswer(field: FormInfo["fields"][number], value: unknown): FormValu ); }; if (field.type === "multiselect") { - if (Array.isArray(value) && value.every((item: unknown) => typeof item === "string")) - return value.map(labelValue); + const selected = multiSelectAnswer(field, value); + if (selected) return selected.map(labelValue); } else if (field.type === "string" && typeof value === "string") { return labelValue(value); } else if (field.type === "boolean") { @@ -162,3 +163,17 @@ function formAnswer(field: FormInfo["fields"][number], value: unknown): FormValu } throw new Error(`Invalid answer for OpenCode question ${field.key}`); } + +type MultiSelectField = Extract; + +function multiSelectAnswer(field: MultiSelectField, value: unknown): string[] | undefined { + if (Array.isArray(value) && value.every((item: unknown) => typeof item === "string")) + return value; + if (typeof value !== "string") return undefined; + const { selected, custom } = splitMultiSelectAnswer( + value, + field.options.map((option) => option.label), + ); + if (custom === null) return selected; + return field.custom === true ? [...selected, custom] : undefined; +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts b/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts index 9189f5d3c5..82236de2b8 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/timeline.test.ts @@ -1,4 +1,6 @@ import type { SessionMessageAssistant } from "@opencode/client"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; import { expect, test } from "vitest"; import { V2Timeline } from "./timeline.js"; @@ -95,3 +97,38 @@ test("a tool's image output is shown as an image, not as base64 in the tool outp ]); expect(timeline.messages([screenshot])).toEqual([]); }); + +test("a surrogate pair split across deltas streams back intact", () => { + const timeline = new V2Timeline(); + const part = { assistantMessageID: "answer", type: "text" as const, ordinal: 0 }; + timeline.startPart(part); + const chunks = ["a\uD83D", "\uDE00b"].map((delta) => { + const event = timeline.delta({ ...part, delta }); + return event?.type === "timeline" && event.item.type === "assistant_message" + ? event.item.text + : null; + }); + expect(chunks).toEqual(["a\uD83D", "\uDE00b"]); + expect(chunks.join("")).toBe("a😀b"); +}); + +function retainedBytesAfterStreaming(mode: "delta" | "snapshot", chunks: number): number { + const fixture = fileURLToPath( + new URL("../test-utils/v2-streamed-text-memory.ts", import.meta.url), + ); + const output = execFileSync( + process.execPath, + ["--expose-gc", "--import", "tsx", fixture, mode, String(chunks)], + { encoding: "utf8" }, + ); + return Number(output); +} + +test.each(["delta", "snapshot"] as const)( + "a long %s-streamed part holds memory in proportion to its text", + (mode) => { + const chunks = 2000; + const textBytes = chunks * 64; + expect(retainedBytesAfterStreaming(mode, chunks)).toBeLessThan(textBytes * 32); + }, +); diff --git a/packages/server/src/server/agent/providers/opencode/v2/timeline.ts b/packages/server/src/server/agent/providers/opencode/v2/timeline.ts index c58316df9e..0fdd6f9f40 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/timeline.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/timeline.ts @@ -12,6 +12,13 @@ function textPartKey(messageID: string, part: "text" | "reasoning", ordinal: num return `${messageID}:${part}:${ordinal}`; } +// A `slice` can share its source's storage, so every streamed chunk kept by the timeline +// store would hold the whole text it was cut from. Copying the UTF-16 code units makes +// the chunk independent; UTF-8 would corrupt a surrogate pair split across chunks. +function detachedSuffix(text: string, start: number): string { + return Buffer.from(text.slice(start), "utf16le").toString("utf16le"); +} + // Text and reasoning each number parts from zero, independent of snapshot position. export class V2Timeline { private readonly content = new Map(); @@ -40,7 +47,7 @@ export class V2Timeline { if (!text.startsWith(emitted)) throw new Error("OpenCode changed previously emitted message content"); this.content.set(key, text); - const suffix = text.slice(emitted.length); + const suffix = detachedSuffix(text, emitted.length); const item: AgentTimelineItem = event.type === "text" ? { type: "assistant_message", text: suffix, messageId: event.assistantMessageID } @@ -143,7 +150,7 @@ export class V2Timeline { if (!part.text.startsWith(previous)) throw new Error("OpenCode changed previously emitted message content"); this.content.set(key, part.text); - const suffix = part.text.slice(previous.length); + const suffix = detachedSuffix(part.text, previous.length); if (part.type === "text") push({ type: "assistant_message", text: suffix, messageId: messageID }); else push({ type: "reasoning", text: suffix }); diff --git a/packages/server/src/server/agent/providers/question-card-answer.test.ts b/packages/server/src/server/agent/providers/question-card-answer.test.ts new file mode 100644 index 0000000000..c7049fa7dc --- /dev/null +++ b/packages/server/src/server/agent/providers/question-card-answer.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "vitest"; + +import { splitMultiSelectAnswer } from "./question-card-answer.js"; + +describe("splitMultiSelectAnswer", () => { + const labels = ["Delete both", "Delete both, fix code", "Email the user"]; + + test("splits checked labels in click order", () => { + expect(splitMultiSelectAnswer("Email the user, Delete both", labels)).toEqual({ + selected: ["Email the user", "Delete both"], + custom: null, + }); + }); + + test("keeps a label that contains a comma as one selection", () => { + expect(splitMultiSelectAnswer("Delete both, fix code, Email the user", labels)).toEqual({ + selected: ["Delete both, fix code", "Email the user"], + custom: null, + }); + }); + + test("returns trailing text as the typed answer", () => { + expect(splitMultiSelectAnswer("Delete both, Also check the logs", labels)).toEqual({ + selected: ["Delete both"], + custom: "Also check the logs", + }); + expect(splitMultiSelectAnswer("Only typed text", labels)).toEqual({ + selected: [], + custom: "Only typed text", + }); + }); +}); diff --git a/packages/server/src/server/agent/providers/question-card-answer.ts b/packages/server/src/server/agent/providers/question-card-answer.ts new file mode 100644 index 0000000000..01513869c9 --- /dev/null +++ b/packages/server/src/server/agent/providers/question-card-answer.ts @@ -0,0 +1,23 @@ +/** + * The shared question card sends a multi-select answer as one string: the checked option + * labels in click order, then any typed answer, joined with ", ". Consume exact option + * labels from the front, longest first so a label containing ", " wins over its prefix; + * any remaining text is the typed answer. + */ +export function splitMultiSelectAnswer( + answer: string, + labels: string[], +): { selected: string[]; custom: string | null } { + let remaining = answer; + const selected: string[] = []; + while (remaining.length > 0) { + const label = labels + .filter((candidate) => !selected.includes(candidate)) + .sort((left, right) => right.length - left.length) + .find((candidate) => remaining === candidate || remaining.startsWith(`${candidate}, `)); + if (!label) break; + selected.push(label); + remaining = remaining === label ? "" : remaining.slice(label.length + 2); + } + return { selected, custom: remaining || null }; +} diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index 0bc3310b41..b6518d773d 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -157,15 +157,19 @@ test("DaemonClient uploads file bytes to daemon temp storage", async () => { chunkSize: 5, }); + const uploadId = result.file?.id ?? ""; + expect(uploadId).toMatch( + /^upload_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); expect(result).toEqual({ requestId: "req-upload-e2e", file: { type: "uploaded_file", - id: "upload_req-upload-e2e", + id: uploadId, fileName: "notes.txt", mimeType: "text/plain", size: 11, - path: path.join(daemon.paseoHome, "uploads", "upload_req-upload-e2e", "notes.txt"), + path: path.join(daemon.paseoHome, "uploads", uploadId, "notes.txt"), }, error: null, }); diff --git a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts index f0cd4495e1..75302d4168 100644 --- a/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/checkout-diff-content.e2e.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "vitest"; @@ -85,3 +85,76 @@ test.each(["uncommitted", "base", "commit"] as const)( }, 60_000, ); + +test.each( + (["uncommitted", "base"] as const).flatMap((mode) => + (["single", "batch", "rename"] as const).map((shape) => ({ mode, shape })), + ), +)( + "$mode $shape diff includes hunks from a repository subdirectory", + async ({ mode, shape }) => { + const ctx = await createDaemonTestContext(); + const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-")); + + try { + git(cwd, "init", "-b", "main"); + git(cwd, "config", "user.name", "Test"); + git(cwd, "config", "user.email", "test@example.com"); + git(cwd, "config", "core.autocrlf", "false"); + const subdirectory = join(cwd, "sub"); + mkdirSync(subdirectory); + const unchangedLines = "unchanged\n".repeat(10); + writeFileSync(join(subdirectory, "normal.txt"), `before\n${unchangedLines}`); + if (shape === "batch") { + writeFileSync(join(cwd, "sibling.txt"), "old sibling\n"); + } + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial"); + if (mode === "base") { + git(cwd, "checkout", "-b", "feature"); + } + const filename = shape === "rename" ? "renamed.txt" : "normal.txt"; + if (shape === "rename") { + git(cwd, "mv", "sub/normal.txt", `sub/${filename}`); + } + writeFileSync(join(subdirectory, filename), `after\n${unchangedLines}`); + if (shape === "batch") { + writeFileSync(join(cwd, "sibling.txt"), "new sibling\n"); + } + if (mode === "base") { + git(cwd, "add", "."); + git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit"); + } + + const compare = { mode, baseRef: "main" }; + const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare); + const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare); + const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare); + const liveDiff = await subscription.ready; + await subscription.release(); + + expect(rootDiff.error).toBeNull(); + expect(subdirectoryDiff.error).toBeNull(); + expect(liveDiff.error).toBeNull(); + expect(liveDiff.files).toEqual(rootDiff.files); + expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1); + const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`); + expect(file).toMatchObject({ + additions: 1, + deletions: 1, + ...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}), + }); + expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "before" }), + expect.objectContaining({ type: "add", content: "after" }), + ]), + ); + expect(subdirectoryDiff.files).toEqual(rootDiff.files); + } finally { + await ctx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 60_000, +); diff --git a/packages/server/src/server/daemon-e2e/claude-history-other-project-dir.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-history-other-project-dir.e2e.test.ts index 00b365881f..ad703a3885 100644 --- a/packages/server/src/server/daemon-e2e/claude-history-other-project-dir.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-history-other-project-dir.e2e.test.ts @@ -67,6 +67,7 @@ describe("daemon E2E - Claude history stored under another project folder", () = content: "reply from the original folder", }, }, + { type: "custom-title", customTitle: "Renamed moved session", sessionId: SESSION_ID }, ] .map((entry) => `${JSON.stringify(entry)}\n`) .join(""), @@ -114,6 +115,7 @@ describe("daemon E2E - Claude history stored under another project folder", () = sessionId: SESSION_ID, cwd: agentCwd, }); + expect(agent.title).toBe("Renamed moved session"); await stopDaemon(); const restartedClient = await startDaemon(); diff --git a/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts index 29779fd1ef..1715fabec9 100644 --- a/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-provider-config-dir-history.e2e.test.ts @@ -55,6 +55,7 @@ describe("daemon E2E - Claude history lives in the provider's CLAUDE_CONFIG_DIR" cwd, message: { role: "assistant", content: "reply from the custom config dir" }, }, + { type: "custom-title", customTitle: "Renamed provider session", sessionId: SESSION_ID }, ] .map((entry) => `${JSON.stringify(entry)}\n`) .join(""), @@ -111,6 +112,7 @@ describe("daemon E2E - Claude history lives in the provider's CLAUDE_CONFIG_DIR" sessionId: SESSION_ID, cwd, }); + expect(agent.title).toBe("Renamed provider session"); await stopDaemon(); const restartedClient = await startDaemon(); diff --git a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts index 0c99744e62..da52c4dac4 100644 --- a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts @@ -8,6 +8,7 @@ import { readdirSync, readlinkSync, } from "fs"; +import { rm } from "node:fs/promises"; import { tmpdir } from "os"; import path from "path"; import { execFileSync } from "node:child_process"; @@ -100,7 +101,9 @@ describe("checkout discard", () => { } } finally { // afterEach shuts down the daemon and its repository observers first. - onTestFinished(() => rmSync(cwd, { recursive: true, force: true })); + onTestFinished(() => + rm(cwd, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }), + ); } }, ); diff --git a/packages/server/src/server/daemon-e2e/hoplite-fixture.cjs b/packages/server/src/server/daemon-e2e/hoplite-fixture.cjs new file mode 100644 index 0000000000..3650238ad7 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/hoplite-fixture.cjs @@ -0,0 +1,37 @@ +// Local ACP peer for the bundled Hoplite adapter; no Hoplite service or credentials. +const fs = require("node:fs"); +const readline = require("node:readline"); +fs.appendFileSync(process.env.HOPLITE_TEST_RECORD, "launch\n"); +const configOptions = [ + { + id: "model", + category: "model", + type: "select", + name: "Model", + currentValue: "fixture-model", + options: [{ value: "fixture-model", name: "Fixture model" }], + }, +]; +const send = (message) => + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\n"); +readline.createInterface({ input: process.stdin }).on("line", (line) => { + const request = JSON.parse(line); + if (request.id === undefined) return; + let result = {}; + if (request.method === "initialize") + result = { protocolVersion: 1, agentCapabilities: {}, authMethods: [] }; + if (request.method === "session/new") + result = { + sessionId: "hoplite-fixture", + configOptions, + }; + if (request.method === "session/set_config_option") result = { configOptions }; + if (request.method === "session/prompt") { + send({ + method: "_hoplite/run_phase", + params: { runId: "fixture-run", phase: "provisioning", label: "Preparing fixture workspace" }, + }); + result = { stopReason: "end_turn" }; + } + send({ id: request.id, result }); +}); diff --git a/packages/server/src/server/daemon-e2e/models.e2e.test.ts b/packages/server/src/server/daemon-e2e/models.e2e.test.ts index 1f2086c879..3447444358 100644 --- a/packages/server/src/server/daemon-e2e/models.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/models.e2e.test.ts @@ -1,7 +1,8 @@ import { describe, test, expect } from "vitest"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { loadConfig } from "../config.js"; import { findExecutable } from "../../executable-resolution/executable-resolution.js"; import { BuiltinPluginLoader } from "../plugins/builtin/index.js"; import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; @@ -124,15 +125,12 @@ test("Antigravity creates an agent and answers a prompt with real agy", async ({ try { await client.connect(); await client.fetchAgents({ subscribe: {} }); - await expect - .poll( - async () => { - const snapshot = await client.getProvidersSnapshot({ cwd }); - return snapshot.entries.find((entry) => entry.provider === "antigravity")?.status; - }, - { timeout: 30000 }, - ) - .toBe("ready"); + // Snapshot reads start discovery again; await one refresh before checking readiness. + await client.refreshProvidersSnapshot({ cwd, providers: ["antigravity"] }); + const snapshot = await client.getProvidersSnapshot({ cwd }); + expect(snapshot.entries.find((entry) => entry.provider === "antigravity")?.status).toBe( + "ready", + ); const agent = await client.createAgent({ provider: "antigravity", cwd, @@ -165,3 +163,125 @@ test("Antigravity creates an agent and answers a prompt with real agy", async ({ await rm(cwd, { recursive: true, force: true }); } }, 120000); + +test("bundled Hoplite requires a config entry and removal survives restart", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "paseo-hoplite-optin-")); + const record = path.join(root, "launches.txt"); + await writeFile(record, ""); + const options = { + paseoHomeRoot: root, + cleanup: false, + mcpEnabled: false, + builtinPlugins: new BuiltinPluginLoader(undefined, [ + "hoplite-provider", + "antigravity-provider", + ]), + }; + let daemon = await createTestPaseoDaemon(options); + const connect = async () => { + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.11.2", + }); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + return client; + }; + let client = await connect(); + try { + const before = await client.getProvidersSnapshot(); + expect(before.entries.some((entry) => entry.provider === "hoplite")).toBe(false); + expect(before.entries.some((entry) => entry.provider === "antigravity")).toBe(true); + expect(await readFile(record, "utf8")).toBe(""); + + await client.patchDaemonConfig({ + providers: { + hoplite: { + enabled: true, + command: [process.execPath, path.resolve(import.meta.dirname, "hoplite-fixture.cjs")], + env: { HOPLITE_TEST_RECORD: record }, + }, + }, + }); + await expect + .poll( + async () => + (await client.getProvidersSnapshot()).entries.find( + (entry) => entry.provider === "hoplite", + )?.status, + ) + .toBe("ready"); + expect((await client.listProviderModels("hoplite")).models.map((model) => model.id)).toEqual([ + "fixture-model", + ]); + const agent = await client.createAgent({ + provider: "hoplite", + cwd: root, + model: "fixture-model", + }); + await client.sendMessage(agent.id, "Exercise the contributor transformer"); + await client.waitForFinish(agent.id, 10000); + const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 }); + expect(timeline.entries.map((entry) => entry.item)).toContainEqual( + expect.objectContaining({ + type: "tool_call", + name: "Hoplite workspace", + status: "completed", + }), + ); + await client.archiveAgent(agent.id); + expect(await readFile(record, "utf8")).toContain("launch"); + + await client.patchDaemonConfig({ providers: { hoplite: { enabled: false } } }); + expect( + (await client.getProvidersSnapshot()).entries.find((entry) => entry.provider === "hoplite") + ?.enabled, + ).toBe(false); + await client.close(); + await daemon.close(); + daemon = await createTestPaseoDaemon({ + ...options, + staticDir: daemon.staticDir, + providerOverrides: loadConfig(daemon.paseoHome, { env: {} }).providerOverrides, + }); + client = await connect(); + expect( + (await client.getProvidersSnapshot()).entries.find((entry) => entry.provider === "hoplite") + ?.enabled, + ).toBe(false); + await client.patchDaemonConfig({ removeProviders: ["hoplite"] }); + expect( + (await client.getProvidersSnapshot()).entries.some((entry) => entry.provider === "hoplite"), + ).toBe(false); + expect((await client.getDaemonConfig()).config.providers?.hoplite).toBeUndefined(); + const persisted = JSON.parse( + await readFile(path.join(daemon.paseoHome, "config.json"), "utf8"), + ); + expect(persisted.agents?.providers?.hoplite).toBeUndefined(); + const launchesAfterRemoval = await readFile(record, "utf8"); + await client.close(); + await daemon.close(); + daemon = await createTestPaseoDaemon({ + ...options, + staticDir: daemon.staticDir, + providerOverrides: loadConfig(daemon.paseoHome, { env: {} }).providerOverrides, + }); + client = await connect(); + const restarted = await client.getProvidersSnapshot(); + expect(restarted.entries.some((entry) => entry.provider === "hoplite")).toBe(false); + expect(restarted.entries.map((entry) => entry.provider)).toEqual( + before.entries.map((entry) => entry.provider), + ); + expect(await readFile(record, "utf8")).toBe(launchesAfterRemoval); + await client.patchDaemonConfig({ providers: { hoplite: { enabled: false } } }); + expect( + (await client.getProvidersSnapshot()).entries.find((entry) => entry.provider === "hoplite") + ?.enabled, + ).toBe(false); + } finally { + await client.close(); + await daemon.close(); + await rm(root, { recursive: true, force: true }); + await rm(daemon.staticDir, { recursive: true, force: true }); + } +}, 60000); diff --git a/packages/server/src/server/git-filename-diffs.e2e.test.ts b/packages/server/src/server/git-filename-diffs.e2e.test.ts index 343e1c81c8..c32ee040b8 100644 --- a/packages/server/src/server/git-filename-diffs.e2e.test.ts +++ b/packages/server/src/server/git-filename-diffs.e2e.test.ts @@ -5,6 +5,44 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createTestPaseoDaemon, DaemonClient } from "./test-utils/index.js"; +test("a rename-only commit returns zero added or deleted lines through the client", async () => { + const cwd = mkdtempSync(join(tmpdir(), "commit-rename-diff-")); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + const daemon = await createTestPaseoDaemon({ agentClients: {} }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + git("init", "-b", "main"); + git("config", "user.name", "Test"); + git("config", "user.email", "test@example.com"); + writeFileSync(join(cwd, "old.txt"), "same\n"); + git("add", "."); + git("-c", "commit.gpgsign=false", "commit", "-m", "initial"); + git("mv", "old.txt", "new.txt"); + git("-c", "commit.gpgsign=false", "commit", "-m", "rename"); + const sha = git("rev-parse", "HEAD"); + + await client.connect(); + const history = await client.listCheckoutCommits(cwd); + expect(history.commits.find((commit) => commit.sha === sha)?.files).toEqual([ + { path: "new.txt", additions: 0, deletions: 0, status: "renamed" }, + ]); + expect(await client.getCommitFileDiff(cwd, sha, "new.txt")).toEqual({ + file: { + path: "new.txt", + isNew: false, + isDeleted: false, + additions: 0, + deletions: 0, + hunks: [], + }, + }); + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); + test.skipIf(process.platform === "win32")( "checkout and commit diffs preserve filenames that Git quotes", async () => { diff --git a/packages/server/src/server/plugins/builtin/index.ts b/packages/server/src/server/plugins/builtin/index.ts index c14c027853..cf6ffe9eee 100644 --- a/packages/server/src/server/plugins/builtin/index.ts +++ b/packages/server/src/server/plugins/builtin/index.ts @@ -9,6 +9,7 @@ export const builtinPlugins = [ "copilot-usage-source", "cursor-usage-source", "grok-usage-source", + "hoplite-provider", "kimi-usage-source", "minimax-usage-source", "muse-provider", diff --git a/packages/server/src/server/plugins/compiler.test.ts b/packages/server/src/server/plugins/compiler.test.ts index facba48fdb..fc86f85db3 100644 --- a/packages/server/src/server/plugins/compiler.test.ts +++ b/packages/server/src/server/plugins/compiler.test.ts @@ -138,6 +138,51 @@ async function createRootAlias(directory: string): Promise { } describe("plugin runtime entries", () => { + it.each([ + 'import type { AgentTimelineItem, ToolCallDetail } from "@getpaseo/protocol/agent-types"; export type Detail = ToolCallDetail; export type Item = AgentTimelineItem;', + 'import { type AgentTimelineItem } from "@getpaseo/protocol/agent-types"; export type Item = AgentTimelineItem;', + 'export type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";', + 'export type Item = import("@getpaseo/protocol/agent-types").AgentTimelineItem;', + 'export type { AgentAttachment } from "@getpaseo/protocol/messages";', + ])("compiles protocol types without local dependencies: %s", async (typeSource) => { + const entries = await createSplitPlugin(); + await writeFile( + path.join(entries.directory, "shared", "labels.ts"), + `${typeSource}\nexport const clientLabel = "Client contribution"; export const serverLabel = "Server contribution";`, + ); + + const result = await compilePlugin(entries); + + expect(result.clientBundle).toContain("Client contribution"); + expect(result.serverBundle).toContain("Server contribution"); + expect(result.clientBundle).not.toContain("@getpaseo/protocol"); + expect(result.serverBundle).not.toContain("@getpaseo/protocol"); + }); + + it("still requires local dependencies for protocol runtime imports", async () => { + const entries = await createSplitPlugin(); + await writeFile( + entries.server, + 'import { TOOL_CALL_ICON_NAMES } from "@getpaseo/protocol/agent-types"; export default () => TOOL_CALL_ICON_NAMES;', + ); + + await expect(compilePlugin(entries)).rejects.toThrow( + 'Could not resolve "@getpaseo/protocol/agent-types"', + ); + }); + + it("still rejects missing third-party type dependencies", async () => { + const entries = await createSplitPlugin(); + await writeFile( + entries.client, + 'import type { Item } from "missing-types"; export default (item: Item) => item;', + ); + + await expect(compilePlugin(entries)).rejects.toThrow( + 'Could not resolve type dependency "missing-types"', + ); + }); + it.each([ "react", "react/jsx-runtime", diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts index 15df395d06..b48449bb50 100644 --- a/packages/server/src/server/plugins/compiler.ts +++ b/packages/server/src/server/plugins/compiler.ts @@ -245,6 +245,10 @@ function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory: runtimeSpecifierError(packageSpecifier, owner, file) ?? lexicalBoundaryError(specifier, path.dirname(file), pluginDirectory, owner); if (error) return error; + // Protocol types are shared host contracts, also exposed by the SDK's peer + // dependency during development. Erased imports need no plugin-local install; + // runtime imports still go through normal dependency resolution below. + if (typeOnly && specifier.startsWith("@getpaseo/protocol/")) continue; // Host modules have separately enforced SDK boundaries and need no local installation. if ( (PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) || diff --git a/packages/server/src/server/plugins/index.ts b/packages/server/src/server/plugins/index.ts index 6c4af29e62..a5ecea8097 100644 --- a/packages/server/src/server/plugins/index.ts +++ b/packages/server/src/server/plugins/index.ts @@ -629,6 +629,7 @@ export class PluginService { label: provider.label, description: provider.description, command: provider.command, + requiresConfiguration: provider.requiresConfiguration, status: provider.hasStatus ? (request) => { if (!this.runtime.getProviderStatus) diff --git a/packages/server/src/server/plugins/plugin-process-protocol.ts b/packages/server/src/server/plugins/plugin-process-protocol.ts index e959780389..b3f363cb29 100644 --- a/packages/server/src/server/plugins/plugin-process-protocol.ts +++ b/packages/server/src/server/plugins/plugin-process-protocol.ts @@ -14,6 +14,7 @@ import { import { z } from "zod"; export interface PluginProviderMetadata { + requiresConfiguration?: boolean; hasCatalogCacheKey?: boolean; hasStatus?: boolean; command?: readonly [string, ...string[]]; @@ -112,6 +113,7 @@ const providerMetadataSchema = z label: z.string().min(1), description: z.string().optional(), iconPath: z.string().optional(), + requiresConfiguration: z.boolean().optional(), hasCatalogCacheKey: z.boolean().optional(), hasStatus: z.boolean().optional(), command: z.tuple([z.string().min(1)], z.string()).optional(), diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index 3f11926d7d..5e6efa1925 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -158,6 +158,7 @@ export function createPluginWorker(options: { label: provider.label, description: provider.description, iconPath: provider.icon, + requiresConfiguration: provider.requiresConfiguration, hasCatalogCacheKey: provider.getCatalogCacheKey !== undefined, hasStatus: provider.status !== undefined, command: provider.command, diff --git a/packages/server/src/server/resolve-worktree-creation-intent.test.ts b/packages/server/src/server/resolve-worktree-creation-intent.test.ts index 82895e302b..8d44b9ba80 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.test.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import type { ForgeService } from "../services/forge-service.js"; +import { buildForkLocalBranchName } from "../utils/change-request-checkout.js"; import { CheckoutSourceForgeMismatchError, MissingCheckoutTargetError, @@ -32,14 +33,8 @@ function createResolverHarness(overrides?: { defaultCheckoutRefs: ({ changeRequestNumber }) => [ { remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` }, ], - buildPrLocalBranchName: ({ headRef, checkoutTarget }) => { - const normalized = checkoutTarget.headOwnerLogin?.trim().toLowerCase() ?? ""; - const owner = - checkoutTarget.isCrossRepository && /^[a-z0-9-]+$/.test(normalized) - ? normalized - : null; - return owner ? `${owner}/${headRef}` : headRef; - }, + buildPrLocalBranchName: ({ headRef, checkoutTarget }) => + buildForkLocalBranchName({ headRef, ...checkoutTarget }), supportsCrossRepoCheckoutWithoutRefs: true, } : {}; @@ -182,6 +177,28 @@ describe("resolveWorktreeCreationIntent", () => { expect(deps.headRefLookups).toEqual([]); }); + test("does not track origin for a same-repo head with no origin branch", async () => { + const deps = createResolverHarness(); + deps.forgeService.getPullRequestCheckoutTarget = async () => ({ + number: 42, + baseRefName: "main", + headRefName: "ford/towel", + headOwnerLogin: null, + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: false, + headRefKind: "pull-ref", + }); + + const intent = await resolveWorktreeCreationIntent( + { action: "checkout", githubPrNumber: 42 }, + repoRoot, + deps, + ); + + expect(intent).not.toHaveProperty("trackOriginHead"); + }); + test("configures the contributor remote for fork PR targets", async () => { const deps = createResolverHarness(); deps.forgeService.getPullRequestCheckoutTarget = async () => ({ @@ -211,6 +228,29 @@ describe("resolveWorktreeCreationIntent", () => { expect(deps.headRefLookups).toEqual([]); }); + test("prefixes the pr number when the fork was deleted", async () => { + const deps = createResolverHarness(); + deps.forgeService.getPullRequestCheckoutTarget = async () => ({ + number: 42, + baseRefName: "main", + headRefName: "towel", + headOwnerLogin: null, + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: true, + }); + + const intent = await resolveWorktreeCreationIntent( + { action: "checkout", githubPrNumber: 42 }, + repoRoot, + deps, + ); + + expect(intent).toMatchObject({ headRef: "towel", localBranchName: "pr-42/towel" }); + expect(intent).not.toHaveProperty("pushRemoteUrl"); + expect(intent).not.toHaveProperty("trackOriginHead"); + }); + test("uses an explicit PR head ref without calling GitHub", async () => { const deps = createResolverHarness(); @@ -293,6 +333,105 @@ describe("resolveWorktreeCreationIntent", () => { expect(deps.headRefLookups).toEqual([]); }); + test("names the local branch pr- for a gitea pull-ref-only head", async () => { + const deps = createResolverHarness({ + forge: "gitea", + forgeService: { + defaultCheckoutRefs: ({ changeRequestNumber }) => [ + { remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` }, + { remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` }, + ], + buildPrLocalBranchName: ({ headRef, checkoutTarget }) => + buildForkLocalBranchName({ headRef, ...checkoutTarget }), + getPullRequestCheckoutTarget: async ({ number }) => ({ + number, + baseRefName: "main", + headRefName: `refs/pull/${number}/head`, + headOwnerLogin: null, + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: false, + headRefKind: "pull-ref", + }), + }, + }); + + const intent = await resolveWorktreeCreationIntent( + { action: "checkout", checkoutSource: { kind: "change_request", number: 3 } }, + repoRoot, + deps, + ); + + expect(intent).toMatchObject({ localBranchName: "pr-3" }); + expect(intent).not.toHaveProperty("trackOriginHead"); + }); + + test("names the local branch owner/pr- for a gitea fork pull-ref-only head", async () => { + const deps = createResolverHarness({ + forge: "gitea", + forgeService: { + defaultCheckoutRefs: ({ changeRequestNumber }) => [ + { remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` }, + { remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` }, + ], + buildPrLocalBranchName: ({ headRef, checkoutTarget }) => + buildForkLocalBranchName({ headRef, ...checkoutTarget }), + supportsCrossRepoCheckoutWithoutRefs: true, + getPullRequestCheckoutTarget: async ({ number }) => ({ + number, + baseRefName: "main", + headRefName: `refs/pull/${number}/head`, + headOwnerLogin: "contributor", + headRepositorySshUrl: "git@gitea.test:contributor/repo.git", + headRepositoryUrl: "https://gitea.test/contributor/repo", + isCrossRepository: true, + headRefKind: "pull-ref", + }), + }, + }); + + const intent = await resolveWorktreeCreationIntent( + { action: "checkout", checkoutSource: { kind: "change_request", number: 3 } }, + repoRoot, + deps, + ); + + expect(intent).toMatchObject({ localBranchName: "contributor/pr-3" }); + }); + + test("does not double the pr number for a gitea deleted fork's pull-ref-only head", async () => { + const deps = createResolverHarness({ + forge: "gitea", + forgeService: { + defaultCheckoutRefs: ({ changeRequestNumber }) => [ + { remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` }, + { remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` }, + ], + buildPrLocalBranchName: ({ headRef, checkoutTarget }) => + buildForkLocalBranchName({ headRef, ...checkoutTarget }), + supportsCrossRepoCheckoutWithoutRefs: true, + getPullRequestCheckoutTarget: async ({ number }) => ({ + number, + baseRefName: "main", + headRefName: `refs/pull/${number}/head`, + headOwnerLogin: null, + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: true, + headRefKind: "pull-ref", + }), + }, + }); + + const intent = await resolveWorktreeCreationIntent( + { action: "checkout", checkoutSource: { kind: "change_request", number: 3 } }, + repoRoot, + deps, + ); + + expect(intent).toMatchObject({ localBranchName: "pr-3" }); + }); + test("reports unsupported cross-repository checkout targets without a checkout ref", async () => { const deps = createResolverHarness({ forge: "gitea", diff --git a/packages/server/src/server/resolve-worktree-creation-intent.ts b/packages/server/src/server/resolve-worktree-creation-intent.ts index 73403f5908..5eddc0ef7b 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.ts @@ -191,7 +191,8 @@ async function resolvePrCheckoutIntent( }) ?? [{ remoteName: "origin", remoteRef: `refs/heads/${headRef}` }]; const localBranchName = service.buildPrLocalBranchName?.({ headRef, checkoutTarget }); const crossRepository = resolveCrossRepositoryFields(checkoutTarget); - const trackOriginHead = !checkoutTarget.isCrossRepository; + const trackOriginHead = + !checkoutTarget.isCrossRepository && checkoutTarget.headRefKind !== "pull-ref"; return { kind: "checkout-change-request", diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index 50c0ee3c76..c5cf271ad3 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -240,6 +240,7 @@ export function createProviderSnapshotManagerStub(): { const off = vi.fn(); const destroy = vi.fn(); const stub = { + supportsConfigRequiredProvider: () => false, getSnapshot, refreshSnapshotForCwd, refreshSettingsSnapshot, diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 25acb9946b..10694a6ebe 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -1141,6 +1141,7 @@ describe("relay external socket reconnect behavior", () => { expect(serverInfo.features?.stableProjectIdentity).toBe(true); expect(serverInfo.features?.canonicalSubmittedPrompts).toBe(true); expect(serverInfo.features?.providersSnapshotCwd).toBe(true); + expect(serverInfo.features?.hopliteProvider).toBe(false); expect(serverInfo.features?.pluginLogs).toBe(true); expect(serverInfo.features?.workspaceMarkUnread).toBe(true); expect(serverInfo.features?.["terminal-input-mode-replay"]).toBe(true); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 5e93681a66..e833a07ce3 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1915,6 +1915,7 @@ export class VoiceAssistantWebSocketServer { commitBaseClassification: true, // COMPAT(providerRemoval): added in v0.1.105, drop the gate when floor >= v0.1.105. providerRemoval: true, + hopliteProvider: this.providerSnapshotManager.supportsConfigRequiredProvider("hoplite"), // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. importSessionWorkspaceTarget: true, // COMPAT(importSessionSearch): added in v0.7.3, remove gate after 2027-03-02. diff --git a/packages/server/src/services/forge-service.ts b/packages/server/src/services/forge-service.ts index fba9833cd8..8499cc8756 100644 --- a/packages/server/src/services/forge-service.ts +++ b/packages/server/src/services/forge-service.ts @@ -39,6 +39,8 @@ export interface PullRequestCheckoutTarget { headRepositorySshUrl: string | null; headRepositoryUrl: string | null; isCrossRepository: boolean; + /** "pull-ref" when headRefName is a synthetic pull ref, not a real branch name (agit PRs, deleted branches); undefined means "branch" */ + headRefKind?: "branch" | "pull-ref"; } export interface PullRequestCheckoutRef { @@ -441,9 +443,9 @@ export interface ForgeService { getPullRequestCheckoutTarget(options: GetPullRequestOptions): Promise; /** * Refs to fetch for a change-request checkout when the resolved checkout - * target carries none. Adapters that expose a universal change-request head - * ref (GitHub's refs/pull/N/head) return it here; others let the shell fall - * back to the head branch. + * target carries none. Adapters with a universal change-request head ref + * (refs/pull/N/head) return it here; others let the shell fall back to the + * head branch. */ defaultCheckoutRefs?(params: { changeRequestNumber: number; @@ -451,7 +453,7 @@ export interface ForgeService { }): PullRequestCheckoutRef[]; /** * Local branch name for a checked-out change request when the adapter - * disambiguates cross-repository heads (GitHub prefixes the fork owner). + * disambiguates cross-repository heads (prefixes the fork owner). * Returns undefined to keep the head ref name as-is. */ buildPrLocalBranchName?(params: { diff --git a/packages/server/src/services/gitea-service.test.ts b/packages/server/src/services/gitea-service.test.ts index 768f1a7f05..9e3a3ecc62 100644 --- a/packages/server/src/services/gitea-service.test.ts +++ b/packages/server/src/services/gitea-service.test.ts @@ -2297,11 +2297,13 @@ describe("createGiteaService", () => { checkoutRefs: [ { remoteName: "origin", remoteRef: "refs/pull/5/head" }, { remoteName: "origin", remoteRef: "refs/heads/feat/sample-change" }, + { remoteName: "upstream", remoteRef: "refs/pull/5/head" }, ], headOwnerLogin: null, headRepositorySshUrl: null, headRepositoryUrl: null, isCrossRepository: false, + headRefKind: "branch", }); }); @@ -2332,17 +2334,215 @@ describe("createGiteaService", () => { headRefName: "feat/sample-change", checkoutRefs: [ { remoteName: "origin", remoteRef: "refs/pull/5/head" }, - { remoteName: "origin", remoteRef: "refs/heads/feat/sample-change" }, + { remoteName: "upstream", remoteRef: "refs/pull/5/head" }, ], headOwnerLogin: "contributor", headRepositorySshUrl: "git@gitea.com:contributor/sample-repo.git", headRepositoryUrl: "https://gitea.com/contributor/sample-repo", isCrossRepository: true, + headRefKind: "branch", }); expect(calls).toContainEqual(["api", "repos/example-user/sample-repo/pulls/5"]); }); + it("skips the origin branch fallback and tracking for an AGit pull request", async () => { + const { service } = makeService((args) => + args[0] === "api" + ? ok( + JSON.stringify({ + flow: 1, + head: { ref: "refs/pull/5/head", repo: { id: 1 } }, + base: { repo: { id: 1 } }, + }), + ) + : ok(JSON.stringify(STATUS_PR_VIEW)), + ); + + const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 }); + + expect(target).toMatchObject({ + checkoutRefs: [ + { remoteName: "origin", remoteRef: "refs/pull/5/head" }, + { remoteName: "upstream", remoteRef: "refs/pull/5/head" }, + ], + isCrossRepository: false, + headRefKind: "pull-ref", + }); + }); + + it("names the local branch pr- end to end for an agit pull request", async () => { + // the tea CLI reports head as the raw pull ref too when there's no real + // branch, same as the Forgejo API's head.ref + const { service } = makeService((args) => + args[0] === "api" + ? ok( + JSON.stringify({ + flow: 1, + head: { ref: "refs/pull/5/head", repo: { id: 1 } }, + base: { repo: { id: 1 } }, + }), + ) + : ok(JSON.stringify({ ...STATUS_PR_VIEW, head: "refs/pull/5/head" })), + ); + + const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 }); + const localBranchName = service.buildPrLocalBranchName?.({ + headRef: target.headRefName, + checkoutTarget: target, + }); + + expect(localBranchName).toBe("pr-5"); + }); + + it("skips the origin branch fallback when the head branch was deleted", async () => { + const { service } = makeService((args) => + args[0] === "api" + ? ok( + JSON.stringify({ + head: { ref: "refs/pull/5/head", repo: { id: 1 } }, + base: { repo: { id: 1 } }, + }), + ) + : ok(JSON.stringify(STATUS_PR_VIEW)), + ); + + const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 }); + + expect(target).toMatchObject({ + checkoutRefs: [ + { remoteName: "origin", remoteRef: "refs/pull/5/head" }, + { remoteName: "upstream", remoteRef: "refs/pull/5/head" }, + ], + isCrossRepository: false, + headRefKind: "pull-ref", + }); + }); + + it("keeps a pull request from a deleted fork cross-repository", async () => { + const { service } = makeService((args) => + args[0] === "api" + ? ok(JSON.stringify({ head: { repo: null }, base: { repo: { id: 1 } } })) + : ok(JSON.stringify(STATUS_PR_VIEW)), + ); + + const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 }); + + expect(target).toMatchObject({ + checkoutRefs: [ + { remoteName: "origin", remoteRef: "refs/pull/5/head" }, + { remoteName: "upstream", remoteRef: "refs/pull/5/head" }, + ], + headOwnerLogin: null, + isCrossRepository: true, + headRefKind: "branch", + }); + }); + + it("keeps the origin branch fallback when the server sends no head ref", async () => { + const { service } = makeService((args) => + args[0] === "api" + ? ok(JSON.stringify({ head: { repo: { id: 1 } }, base: { repo: { id: 1 } } })) + : ok(JSON.stringify(STATUS_PR_VIEW)), + ); + + const target = await service.getPullRequestCheckoutTarget({ cwd: "/repo", number: 5 }); + + expect(target.checkoutRefs).toContainEqual({ + remoteName: "origin", + remoteRef: "refs/heads/feat/sample-change", + }); + expect(target.headRefKind).toBe("branch"); + }); + + it("prefixes the local branch name with the fork owner for a cross-repository checkout", () => { + const { service } = makeService(() => ok("")); + + const localBranchName = service.buildPrLocalBranchName?.({ + headRef: "patch-1", + checkoutTarget: { + number: 5, + baseRefName: "main", + headRefName: "patch-1", + headOwnerLogin: "contributor", + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: true, + }, + }); + + expect(localBranchName).toBe("contributor/patch-1"); + }); + + it("names the local branch pr- for a same-repo pull-ref-only head", () => { + const { service } = makeService(() => ok("")); + + const localBranchName = service.buildPrLocalBranchName?.({ + headRef: "refs/pull/5/head", + checkoutTarget: { + number: 5, + baseRefName: "main", + headRefName: "refs/pull/5/head", + headOwnerLogin: null, + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: false, + headRefKind: "pull-ref", + }, + }); + + expect(localBranchName).toBe("pr-5"); + }); + + it("prefixes pr- with the fork owner for a pull-ref-only head", () => { + const { service } = makeService(() => ok("")); + + const localBranchName = service.buildPrLocalBranchName?.({ + headRef: "refs/pull/5/head", + checkoutTarget: { + number: 5, + baseRefName: "main", + headRefName: "refs/pull/5/head", + headOwnerLogin: "contributor", + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: true, + headRefKind: "pull-ref", + }, + }); + + expect(localBranchName).toBe("contributor/pr-5"); + }); + + it("does not double the pr number for a deleted fork's pull-ref-only head", () => { + const { service } = makeService(() => ok("")); + + const localBranchName = service.buildPrLocalBranchName?.({ + headRef: "refs/pull/5/head", + checkoutTarget: { + number: 5, + baseRefName: "main", + headRefName: "refs/pull/5/head", + headOwnerLogin: null, + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: true, + headRefKind: "pull-ref", + }, + }); + + expect(localBranchName).toBe("pr-5"); + }); + + it("returns the pull head ref for default checkout refs", () => { + const { service } = makeService(() => ok("")); + + expect(service.defaultCheckoutRefs?.({ changeRequestNumber: 5, headRef: "patch-1" })).toEqual([ + { remoteName: "origin", remoteRef: "refs/pull/5/head" }, + { remoteName: "upstream", remoteRef: "refs/pull/5/head" }, + ]); + }); + it("creates a pull request and parses the resulting URL and index", async () => { const { service, calls } = makeService((args) => { if (args[0] === "pr" && args[1] === "create") { diff --git a/packages/server/src/services/gitea-service.ts b/packages/server/src/services/gitea-service.ts index 88b5caa004..892f9cc33a 100644 --- a/packages/server/src/services/gitea-service.ts +++ b/packages/server/src/services/gitea-service.ts @@ -5,6 +5,10 @@ import pLimit from "p-limit"; import { parseGitHubRemoteIdentity, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; import { findExecutable } from "../executable-resolution/executable-resolution.js"; import { runGitCommand } from "../utils/run-git-command.js"; +import { + buildForkLocalBranchName, + buildPullHeadCheckoutRefs, +} from "../utils/change-request-checkout.js"; import { execCommand } from "../utils/spawn.js"; import { createCachedCliPathResolver, @@ -75,6 +79,8 @@ const CURRENT_PR_HEAD_SHA_CACHE_TTL_MS = 30_000; // the sweep to the 250 most recent Actions tasks. const GITEA_ACTION_PAGE_LIMIT = 50; const GITEA_ACTION_MAX_PAGES = 5; +// PullRequestFlowAGit in forgejo models/issues/pull.go +const GITEA_PULL_REQUEST_FLOW_AGIT = 1; /** * Fields requested from `tea pr list -o json`. tea's default field set omits the @@ -225,8 +231,12 @@ const GiteaPullRequestRepoSchema = z const GiteaPullRequestApiSchema = z .object({ + flow: z.number().optional(), head: z - .object({ repo: GiteaPullRequestRepoSchema.nullable().optional() }) + .object({ + ref: z.string().optional(), + repo: GiteaPullRequestRepoSchema.nullable().optional(), + }) .passthrough() .optional(), base: z @@ -1435,17 +1445,30 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For return runJsonParse(args, runOptions, stdout, schema); } + function resolveHeadRefKind( + pr: z.infer, + number: number, + ): "branch" | "pull-ref" { + // head.ref only becomes the branch name when the branch still exists, + // agit PRs and deleted branches keep the pull ref + const isPullRefOnly = + pr.flow === GITEA_PULL_REQUEST_FLOW_AGIT || pr.head?.ref === `refs/pull/${number}/head`; + return isPullRefOnly ? "pull-ref" : "branch"; + } + async function resolveForkCheckoutFacts( cwd: string, summary: PullRequestSummary, ): Promise<{ isCrossRepository: boolean; + headRefKind: "branch" | "pull-ref"; headOwnerLogin: string | null; headRepositorySshUrl: string | null; headRepositoryUrl: string | null; }> { const sameRepo = { isCrossRepository: false, + headRefKind: "branch" as const, headOwnerLogin: null, headRepositorySshUrl: null, headRepositoryUrl: null, @@ -1464,14 +1487,17 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For ); const headRepo = pr.head?.repo ?? null; const baseRepo = pr.base?.repo ?? null; - if (headRepo?.id == null || baseRepo?.id == null || headRepo.id === baseRepo.id) { - return sameRepo; + const headRefKind = resolveHeadRefKind(pr, summary.number); + if (baseRepo?.id == null || headRepo?.id === baseRepo.id) { + return { ...sameRepo, headRefKind }; } + // headRepo is null when the fork was deleted return { isCrossRepository: true, - headOwnerLogin: headRepo.owner?.login ?? null, - headRepositorySshUrl: headRepo.ssh_url ?? null, - headRepositoryUrl: headRepo.html_url ?? null, + headRefKind, + headOwnerLogin: headRepo?.owner?.login ?? null, + headRepositorySshUrl: headRepo?.ssh_url ?? null, + headRepositoryUrl: headRepo?.html_url ?? null, }; } @@ -1997,17 +2023,29 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For return summary.headRefName; }, + defaultCheckoutRefs({ changeRequestNumber }) { + return buildPullHeadCheckoutRefs(changeRequestNumber); + }, + + buildPrLocalBranchName({ headRef, checkoutTarget }) { + return buildForkLocalBranchName({ headRef, ...checkoutTarget }); + }, + async getPullRequestCheckoutTarget( input: GetPullRequestOptions, ): Promise { const summary = await this.getPullRequest(input); - const checkoutRefs = [ - { remoteName: "origin", remoteRef: `refs/pull/${summary.number}/head` }, - ]; - if (summary.headRefName) { + const fork = await resolveForkCheckoutFacts(input.cwd, summary); + const [originPullRef, upstreamPullRef] = buildPullHeadCheckoutRefs(summary.number); + const checkoutRefs = [originPullRef]; + // for a fork PR a branch with that name on origin belongs to the base repo + const headBranchOnOrigin = + !fork.isCrossRepository && fork.headRefKind === "branch" && Boolean(summary.headRefName); + if (headBranchOnOrigin) { checkoutRefs.push({ remoteName: "origin", remoteRef: `refs/heads/${summary.headRefName}` }); } - const fork = await resolveForkCheckoutFacts(input.cwd, summary); + // upstream can hold a different PR with the same number, so it goes last + checkoutRefs.push(upstreamPullRef); return { number: summary.number, baseRefName: summary.baseRefName, @@ -2017,6 +2055,7 @@ export function createGiteaService(options: CreateGiteaServiceOptions = {}): For headRepositorySshUrl: fork.headRepositorySshUrl, headRepositoryUrl: fork.headRepositoryUrl, isCrossRepository: fork.isCrossRepository, + headRefKind: fork.headRefKind, }; }, diff --git a/packages/server/src/services/github-service.test.ts b/packages/server/src/services/github-service.test.ts index ab71115f36..3109bcc63c 100644 --- a/packages/server/src/services/github-service.test.ts +++ b/packages/server/src/services/github-service.test.ts @@ -797,6 +797,7 @@ describe("ForgeService", () => { headRepositorySshUrl: "git@github.com:therainisme/paseo.git", headRepositoryUrl: "https://github.com/therainisme/paseo", isCrossRepository: true, + headRefKind: "branch", }); expect(runner.calls).toHaveLength(2); diff --git a/packages/server/src/services/github-service.ts b/packages/server/src/services/github-service.ts index b6de0cbd6e..2dd3fdc269 100644 --- a/packages/server/src/services/github-service.ts +++ b/packages/server/src/services/github-service.ts @@ -6,6 +6,10 @@ import { } from "@getpaseo/protocol/git-remote"; import { findExecutable } from "../executable-resolution/executable-resolution.js"; import { runGitCommand } from "../utils/run-git-command.js"; +import { + buildForkLocalBranchName, + buildPullHeadCheckoutRefs, +} from "../utils/change-request-checkout.js"; import { execCommand } from "../utils/spawn.js"; import { resolveSshHostname } from "../utils/ssh-hostname.js"; import { @@ -2104,17 +2108,11 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G }, defaultCheckoutRefs({ changeRequestNumber }) { - return [ - { remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` }, - { remoteName: "upstream", remoteRef: `refs/pull/${changeRequestNumber}/head` }, - ]; + return buildPullHeadCheckoutRefs(changeRequestNumber); }, buildPrLocalBranchName({ headRef, checkoutTarget }) { - const owner = checkoutTarget.isCrossRepository - ? normalizeGitHubOwnerForBranch(checkoutTarget.headOwnerLogin) - : null; - return owner ? `${owner}/${headRef}` : headRef; + return buildForkLocalBranchName({ headRef, ...checkoutTarget }); }, supportsCrossRepoCheckoutWithoutRefs: true, @@ -2663,11 +2661,6 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G return api; } -function normalizeGitHubOwnerForBranch(owner: string | null): string | null { - const normalized = owner?.trim().toLowerCase() ?? ""; - return /^[a-z0-9-]+$/.test(normalized) ? normalized : null; -} - function getGithubStatusFacts( status: PullRequestCommandStatus | null | undefined, ): GitHubPullRequestStatusFacts | null { @@ -3529,6 +3522,7 @@ function toPullRequestCheckoutTarget( headRepositorySshUrl: pullRequest.headRepository?.sshUrl || null, headRepositoryUrl: pullRequest.headRepository?.url || null, isCrossRepository: pullRequest.isCrossRepository, + headRefKind: "branch", }; } diff --git a/packages/server/src/services/gitlab-service.test.ts b/packages/server/src/services/gitlab-service.test.ts index 371198e447..b9b6d447d4 100644 --- a/packages/server/src/services/gitlab-service.test.ts +++ b/packages/server/src/services/gitlab-service.test.ts @@ -477,6 +477,7 @@ describe("createGitLabService", () => { headRepositorySshUrl: null, headRepositoryUrl: null, isCrossRepository: false, + headRefKind: "branch", }); expect(calls[0]).toEqual(["mr", "view", "14", "-F", "json"]); }); diff --git a/packages/server/src/services/gitlab-service.ts b/packages/server/src/services/gitlab-service.ts index 1fff0e2a02..c5f54be2cb 100644 --- a/packages/server/src/services/gitlab-service.ts +++ b/packages/server/src/services/gitlab-service.ts @@ -1204,6 +1204,7 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F mr.target_project_id !== undefined && mr.target_project_id !== null && mr.source_project_id !== mr.target_project_id, + headRefKind: "branch", }; }, diff --git a/packages/server/src/utils/change-request-checkout.test.ts b/packages/server/src/utils/change-request-checkout.test.ts new file mode 100644 index 0000000000..74a57c2445 --- /dev/null +++ b/packages/server/src/utils/change-request-checkout.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { + buildForkLocalBranchName, + normalizeForgeOwnerForBranch, +} from "./change-request-checkout.js"; + +describe("normalizeForgeOwnerForBranch", () => { + it("lowercases a valid login", () => { + expect(normalizeForgeOwnerForBranch("Contributor")).toBe("contributor"); + }); + + it("allows dots and underscores", () => { + expect(normalizeForgeOwnerForBranch("a.b_c")).toBe("a.b_c"); + }); + + it("rejects a login starting with a dash", () => { + expect(normalizeForgeOwnerForBranch("-abc")).toBeNull(); + }); + + it("rejects a login starting with a dot", () => { + expect(normalizeForgeOwnerForBranch(".hidden")).toBeNull(); + }); + + it("rejects a login containing '..'", () => { + expect(normalizeForgeOwnerForBranch("a..b")).toBeNull(); + }); + + it("rejects a login ending in .lock", () => { + expect(normalizeForgeOwnerForBranch("contributor.lock")).toBeNull(); + }); + + it("rejects a login with a path traversal segment", () => { + expect(normalizeForgeOwnerForBranch("../evil")).toBeNull(); + }); + + it("rejects an empty or null login", () => { + expect(normalizeForgeOwnerForBranch(null)).toBeNull(); + expect(normalizeForgeOwnerForBranch("")).toBeNull(); + expect(normalizeForgeOwnerForBranch(" ")).toBeNull(); + }); +}); + +describe("buildForkLocalBranchName", () => { + const fork = { headRef: "patch-1", number: 42, isCrossRepository: true }; + + it("keeps the head ref for a same-repository head", () => { + expect( + buildForkLocalBranchName({ ...fork, isCrossRepository: false, headOwnerLogin: "arthur" }), + ).toBe("patch-1"); + }); + + it("prefixes a fork head with the lowercased owner", () => { + expect(buildForkLocalBranchName({ ...fork, headOwnerLogin: "Arthur" })).toBe("arthur/patch-1"); + }); + + it("falls back to the pr number when the owner is unknown", () => { + expect(buildForkLocalBranchName({ ...fork, headOwnerLogin: null })).toBe("pr-42/patch-1"); + }); + + it("falls back to the pr number when the owner is not a safe ref segment", () => { + expect(buildForkLocalBranchName({ ...fork, headOwnerLogin: "-zaphod" })).toBe("pr-42/patch-1"); + }); + + // pull-ref-only heads (agit PRs, deleted branches) report headRef as the + // literal pull ref, e.g. refs/pull/42/head, not a real branch name + it("collapses a same-repository pull-ref-only head to pr-", () => { + expect( + buildForkLocalBranchName({ + ...fork, + isCrossRepository: false, + headOwnerLogin: "arthur", + headRefKind: "pull-ref", + }), + ).toBe("pr-42"); + }); + + it("prefixes pr- with the owner for a fork pull-ref-only head", () => { + expect( + buildForkLocalBranchName({ ...fork, headOwnerLogin: "Arthur", headRefKind: "pull-ref" }), + ).toBe("arthur/pr-42"); + }); + + it("does not double the pr number when the fork owner is unknown and there is no head branch", () => { + expect( + buildForkLocalBranchName({ ...fork, headOwnerLogin: null, headRefKind: "pull-ref" }), + ).toBe("pr-42"); + }); +}); diff --git a/packages/server/src/utils/change-request-checkout.ts b/packages/server/src/utils/change-request-checkout.ts new file mode 100644 index 0000000000..12c779571b --- /dev/null +++ b/packages/server/src/utils/change-request-checkout.ts @@ -0,0 +1,47 @@ +import type { PullRequestCheckoutRef } from "../services/forge-service.js"; + +// upstream for clones where origin is your fork +export function buildPullHeadCheckoutRefs(number: number): PullRequestCheckoutRef[] { + return [ + { remoteName: "origin", remoteRef: `refs/pull/${number}/head` }, + { remoteName: "upstream", remoteRef: `refs/pull/${number}/head` }, + ]; +} + +// forgejo username pattern, covers github logins too +export function normalizeForgeOwnerForBranch(owner: string | null): string | null { + const normalized = owner?.trim().toLowerCase() ?? ""; + if (!/^[a-z0-9][a-z0-9_.-]*$/.test(normalized)) { + return null; + } + // git refuses .. and .lock, trailing dot too + if (normalized.includes("..") || normalized.endsWith(".") || normalized.endsWith(".lock")) { + return null; + } + return normalized; +} + +export interface ForkLocalBranchNameInput { + headRef: string; + number: number; + isCrossRepository: boolean; + headOwnerLogin: string | null; + /** "pull-ref" when headRef is a synthetic pull ref, not a real branch name (agit PRs, deleted branches); undefined means "branch" */ + headRefKind?: "branch" | "pull-ref"; +} + +export function buildForkLocalBranchName(params: ForkLocalBranchNameInput): string { + const pullRefFallback = `pr-${params.number}`; + // headRef is the literal pull ref (e.g. refs/pull/42/head) when there is no + // real branch to name after, so use the pr number instead + const headSegment = params.headRefKind === "pull-ref" ? pullRefFallback : params.headRef; + if (!params.isCrossRepository) { + return headSegment; + } + const owner = normalizeForgeOwnerForBranch(params.headOwnerLogin); + if (!owner) { + // don't double up pr-/pr- when the owner is also unknown + return headSegment === pullRefFallback ? headSegment : `${pullRefFallback}/${headSegment}`; + } + return `${owner}/${headSegment}`; +} diff --git a/packages/server/src/utils/checkout-git.commit-file-diff.test.ts b/packages/server/src/utils/checkout-git.commit-file-diff.test.ts index fbb22fe3b9..576e9b0c80 100644 --- a/packages/server/src/utils/checkout-git.commit-file-diff.test.ts +++ b/packages/server/src/utils/checkout-git.commit-file-diff.test.ts @@ -3,7 +3,8 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs" import { tmpdir } from "os"; import { join } from "path"; import { afterEach, describe, expect, it } from "vitest"; -import { getCommitFileDiff } from "./checkout-git.js"; +import { getCommitFileDiff, listCheckoutCommits } from "./checkout-git.js"; +import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.js"; const tempDirs: string[] = []; @@ -44,6 +45,75 @@ function headSha(repoDir: string): string { } describe("getCommitFileDiff", () => { + it.each(["new.txt", "with spaces.txt"])("preserves a rename-only commit to %s", async (name) => { + const repoDir = initRepo(); + commitFile(repoDir, "old.txt", "same\n", "initial"); + git(["mv", "old.txt", name], repoDir); + git(["-c", "commit.gpgsign=false", "commit", "-m", "rename"], repoDir); + const sha = headSha(repoDir); + + const history = await listCheckoutCommits({ cwd: repoDir }); + expect(history.commits.find((commit) => commit.sha === sha)?.files).toEqual([ + { path: name, status: "renamed", additions: 0, deletions: 0 }, + ]); + expect(await getCommitFileDiff({ cwd: repoDir, sha, path: name })).toEqual({ + path: name, + isNew: false, + isDeleted: false, + additions: 0, + deletions: 0, + hunks: [], + }); + }); + + it.each(["new.txt", "目录.txt"])( + "preserves the edits in a committed rename to %s", + async (name) => { + const repoDir = initRepo(); + commitFile(repoDir, "old.txt", "same\nkeep\nlast\n", "initial"); + git(["mv", "old.txt", name], repoDir); + writeFileSync(join(repoDir, name), "same\nKEEP\nlast\n"); + git(["add", "."], repoDir); + git(["-c", "commit.gpgsign=false", "commit", "-m", "rename and edit"], repoDir); + const sha = headSha(repoDir); + + const history = await listCheckoutCommits({ cwd: repoDir }); + expect(history.commits.find((commit) => commit.sha === sha)?.files).toEqual([ + { path: name, status: "renamed", additions: 1, deletions: 1 }, + ]); + const file = await getCommitFileDiff({ cwd: repoDir, sha, path: name }); + expect(file).toMatchObject({ + path: name, + isNew: false, + isDeleted: false, + additions: 1, + deletions: 1, + }); + expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "remove", content: "keep" }), + expect.objectContaining({ type: "add", content: "KEEP" }), + ]), + ); + }, + ); + + it.skipIf(process.platform === "win32")("preserves a rename to a quoted filename", async () => { + const repoDir = initRepo(); + const name = 'new "draft".txt'; + commitFile(repoDir, "old.txt", "same\n", "initial"); + git(["mv", "old.txt", name], repoDir); + git(["-c", "commit.gpgsign=false", "commit", "-m", "rename"], repoDir); + expect(await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: name })).toEqual({ + path: name, + isNew: false, + isDeleted: false, + additions: 0, + deletions: 0, + hunks: [], + }); + }); + it("returns the parsed diff for a modified file in a commit", async () => { const repoDir = initRepo(); commitFile(repoDir, "foo.txt", "a\nb\nc\n", "initial"); @@ -78,6 +148,78 @@ describe("getCommitFileDiff", () => { expect(file?.deletions).toBe(0); }); + it.each([ + ["exact", "line1\nline2\nline3\nline4\nline5\nline6\n"], + ["edited", "line1\nline2\nline3\nline4\nline5\nCHANGED\n"], + ])("shows an added %s copy as a new file", async (_kind, addedContent) => { + const repoDir = initRepo(); + commitFile(repoDir, "source.txt", "line1\nline2\nline3\nline4\nline5\nline6\n", "initial"); + commitFile(repoDir, "added.txt", addedContent, "add similar file"); + const sha = headSha(repoDir); + + const history = await listCheckoutCommits({ cwd: repoDir }); + expect(history.commits.find((commit) => commit.sha === sha)?.files).toEqual([ + { path: "added.txt", status: "added", additions: 6, deletions: 0 }, + ]); + const file = await getCommitFileDiff({ cwd: repoDir, sha, path: "added.txt" }); + expect(file).toMatchObject({ + path: "added.txt", + isNew: true, + isDeleted: false, + additions: 6, + deletions: 0, + }); + expect( + file?.hunks + .flatMap((hunk) => hunk.lines) + .filter((line) => line.type !== "header") + .map(({ type, content }) => ({ type, content })), + ).toEqual( + addedContent + .trimEnd() + .split("\n") + .map((content) => ({ type: "add", content })), + ); + }); + + it("shares commit rename discovery across concurrent and later file requests", async () => { + const repoDir = initRepo(); + commitFile(repoDir, "old.txt", "same\n", "initial"); + git(["mv", "old.txt", "new.txt"], repoDir); + commitFile(repoDir, "added.txt", "added\n", "rename and add"); + const sha = headSha(repoDir); + + startGitCommandMetrics(); + try { + const [renamed, added] = await Promise.all([ + getCommitFileDiff({ cwd: repoDir, sha, path: "new.txt" }), + getCommitFileDiff({ cwd: repoDir, sha, path: "added.txt" }), + ]); + expect(renamed).toMatchObject({ isNew: false, additions: 0, deletions: 0 }); + expect(added).toMatchObject({ isNew: true, additions: 1, deletions: 0 }); + expect(await getCommitFileDiff({ cwd: repoDir, sha, path: "new.txt" })).toEqual(renamed); + } finally { + const metrics = stopGitCommandMetrics(); + expect(metrics.commands.filter(({ args }) => args.includes("--name-status"))).toHaveLength(1); + } + }); + + it("rediscovers renames when a symbolic commit ref moves", async () => { + const repoDir = initRepo(); + commitFile(repoDir, "old.txt", "same\n", "initial"); + expect(await getCommitFileDiff({ cwd: repoDir, sha: "HEAD", path: "old.txt" })).toMatchObject({ + isNew: true, + additions: 1, + }); + git(["mv", "old.txt", "new.txt"], repoDir); + git(["-c", "commit.gpgsign=false", "commit", "-m", "rename"], repoDir); + expect(await getCommitFileDiff({ cwd: repoDir, sha: "HEAD", path: "new.txt" })).toMatchObject({ + isNew: false, + additions: 0, + deletions: 0, + }); + }); + it("returns null for a path not changed in the commit", async () => { const repoDir = initRepo(); commitFile(repoDir, "foo.txt", "a\n", "initial"); @@ -89,6 +231,59 @@ describe("getCommitFileDiff", () => { expect(file).toBeNull(); }); + it("does not follow an unchanged file back to an earlier commit", async () => { + const repoDir = initRepo(); + commitFile(repoDir, "foo.txt", "same\n", "initial"); + commitFile(repoDir, "other.txt", "other\n", "unrelated change"); + expect( + await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: "foo.txt" }), + ).toBeNull(); + }); + + it("returns the added file in a root commit and the deleted file in its deletion commit", async () => { + const repoDir = initRepo(); + commitFile(repoDir, "foo.txt", "same\n", "initial"); + expect( + await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: "foo.txt" }), + ).toMatchObject({ + isNew: true, + isDeleted: false, + additions: 1, + deletions: 0, + }); + git(["rm", "foo.txt"], repoDir); + git(["-c", "commit.gpgsign=false", "commit", "-m", "delete"], repoDir); + expect( + await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: "foo.txt" }), + ).toMatchObject({ + isNew: false, + isDeleted: true, + additions: 0, + deletions: 1, + }); + }); + + it("preserves a rename introduced by a merge against its first parent", async () => { + const repoDir = initRepo(); + commitFile(repoDir, "old.txt", "same\n", "initial"); + git(["checkout", "-b", "feature"], repoDir); + git(["mv", "old.txt", "new.txt"], repoDir); + git(["-c", "commit.gpgsign=false", "commit", "-m", "rename"], repoDir); + git(["checkout", "main"], repoDir); + commitFile(repoDir, "main.txt", "main\n", "advance main"); + git(["merge", "--no-ff", "feature", "-m", "merge feature"], repoDir); + expect( + await getCommitFileDiff({ cwd: repoDir, sha: headSha(repoDir), path: "new.txt" }), + ).toMatchObject({ + path: "new.txt", + isNew: false, + isDeleted: false, + additions: 0, + deletions: 0, + hunks: [], + }); + }); + it("returns a merge commit diff against its first parent", async () => { const repoDir = initRepo(); commitFile(repoDir, "README.md", "base\n", "initial"); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index bda89506b5..ce00702908 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -2643,6 +2643,60 @@ export async function listCheckoutCommits({ return { baseRef: comparisonBaseRef, commits }; } +const commitRenamesCache = new TTLCache>>({ + max: 100, + ttl: 60_000, + checkAgeOnGet: true, +}); + +function getCommitRenames(cwd: string, sha: string): Promise> { + // Symbolic refs and abbreviated IDs can resolve to a different commit later. + if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) { + return readCommitRenames(cwd, sha); + } + const key = JSON.stringify([cwd, sha]); + const cached = commitRenamesCache.get(key); + if (cached) { + return cached; + } + const lookup = readCommitRenames(cwd, sha).catch((error: unknown) => { + if (commitRenamesCache.get(key) === lookup) { + commitRenamesCache.delete(key); + } + throw error; + }); + // Store the pending lookup too, so concurrent file requests share the scan. + commitRenamesCache.set(key, lookup); + return lookup; +} + +// Discover renames across the commit before narrowing the patch to a file. +// --follow also detects copies, which would disagree with history's -M-only list. +async function readCommitRenames(cwd: string, sha: string): Promise> { + const result = await runGitCommand( + [ + "show", + sha, + "--format=", + "--diff-merges=first-parent", + "--name-status", + "-z", + "-M", + "--diff-filter=R", + ], + { cwd, envOverlay: READ_ONLY_GIT_ENV }, + ); + if (result.truncated) { + throw new Error("Commit renames exceeded the git output limit"); + } + const renames = new Map(); + const fields = result.stdout.split("\0"); + for (let index = 0; index + 2 < fields.length; index += 3) { + renames.set(fields[index + 2]!, fields[index + 1]!); + } + return renames; +} + /** * Fetches the unified diff of a single file as introduced by one commit and * parses it into the same {@link ParsedDiffFile} shape the diff subscription @@ -2665,8 +2719,20 @@ export async function getCommitFileDiff({ path: string; }): Promise { const worktreeRoot = await requireGitWorktreeRoot(cwd); + const renames = await getCommitRenames(worktreeRoot, sha); + const oldPath = renames.get(path); + const paths = oldPath === undefined ? [path] : [oldPath, path]; const { stdout } = await runGitCommand( - ["show", sha, "--format=", "--diff-merges=first-parent", ...GIT_PATCH_FORMAT_ARGS, "--", path], + [ + "show", + sha, + "--format=", + "--diff-merges=first-parent", + "-M", + ...GIT_PATCH_FORMAT_ARGS, + "--", + ...paths.map((filePath) => `:(literal)${filePath}`), + ], { cwd: worktreeRoot, envOverlay: READ_ONLY_GIT_ENV, @@ -2682,8 +2748,8 @@ export async function getCommitFileDiff({ getNewFileContent: (file) => readGitFileContentAtRef(worktreeRoot, sha, file.path), }); - // `--` scopes the diff to a single pathspec, so there is at most one real - // entry. Pick by path to drop any stray header-only section the parser emits. + // Including the rename source lets Git pair it with the destination. Select + // the requested entry if another change also matches the source path. const file = parsedFiles.find((candidate) => candidate.path === path) ?? null; if (!file) { return null; @@ -3423,7 +3489,7 @@ export async function getCheckoutDiff( compare: CheckoutDiffCompare, context?: CheckoutContext, ): Promise { - await requireGitRepo(cwd); + cwd = await requireGitWorktreeRoot(cwd); const refsForDiff = await resolveCheckoutDiffRefs(cwd, compare, context); if (!refsForDiff) { diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 9b0cba2643..82505dc800 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -16,6 +16,7 @@ import { mkdirSync, rmSync, existsSync, + readFileSync, realpathSync, symlinkSync, writeFileSync, @@ -94,6 +95,64 @@ describe("paseo worktree manager", () => { await rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); }); + it.each([ + { existing: [], preferred: "main/patch-1", expected: "main-1/patch-1" }, + { existing: ["main-1"], preferred: "main/patch-1", expected: "main-2/patch-1" }, + { + existing: ["main-1/patch-1"], + preferred: "main/patch-1", + expected: "main-1/patch-1-2", + }, + { + existing: ["bob/patch-1/child"], + preferred: "bob/patch-1", + expected: "bob/patch-1-1", + }, + ])( + "checks out a fork when $preferred conflicts with existing branch paths", + async ({ existing, preferred, expected }) => { + const git = (...args: string[]) => + execFileSync("git", args, { cwd: repoDir, encoding: "utf8", stdio: "pipe" }).trim(); + git("config", "core.autocrlf", "false"); + const baseHead = git("rev-parse", "main"); + for (const branch of existing) git("branch", branch); + git("checkout", "-b", "fixture-pr"); + writeFileSync(join(repoDir, "file.txt"), "from-pr\n"); + git("add", "file.txt"); + git("-c", "commit.gpgsign=false", "commit", "-m", "fork patch"); + const prHead = git("rev-parse", "HEAD"); + git("checkout", "main"); + const remote = join(tempDir, "origin.git"); + git("clone", "--bare", repoDir, remote); + git("remote", "add", "origin", remote); + git(`--git-dir=${remote}`, "update-ref", "refs/pull/1/head", prHead); + git("pack-refs", "--all"); + + const result = await createWorktreePrimitive({ + cwd: repoDir, + paseoHome, + worktreeSlug: "fork-review", + runSetup: false, + source: { + kind: "checkout-change-request", + forge: "gitea", + changeRequestNumber: 1, + headRef: "patch-1", + localBranchName: preferred, + baseRefName: "main", + checkoutRefs: [{ remoteName: "origin", remoteRef: "refs/pull/1/head" }], + }, + }); + + expect(result.branchName).toBe(expected); + expect(git("rev-parse", result.branchName)).toBe(prHead); + expect(readFileSync(join(result.worktreePath, "file.txt"), "utf8")).toBe("from-pr\n"); + for (const branch of ["main", ...existing]) { + expect(git("rev-parse", branch)).toBe(baseHead); + } + }, + ); + it("treats a worktree as paseo-owned even when its .git admin is missing", async () => { const created = await createLegacyWorktreeForTest({ branchName: "orphan-admin-branch", diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index f454a7225a..39b64d8137 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1778,13 +1778,30 @@ async function localBranchExists(cwd: string, branchName: string): Promise { - let newBranchName = candidateBranch; + const { stdout } = await runGitCommand( + ["for-each-ref", "--format=%(refname:strip=2)", "refs/heads/"], + { cwd, envOverlay: READ_ONLY_GIT_ENV }, + ); + const existingBranches = new Set(stdout.trim().split("\n").filter(Boolean)); + const candidateSegments = candidateBranch.split("/"); + const segments = [...candidateSegments]; let suffix = 1; - while (await localBranchExists(cwd, newBranchName)) { - newBranchName = `${candidateBranch}-${suffix}`; - suffix++; + while (true) { + const branchName = segments.join("/"); + const parentConflict = segments.findIndex((_, index) => + existingBranches.has(segments.slice(0, index + 1).join("/")), + ); + const childConflict = [...existingBranches].some((branch) => + branch.startsWith(`${branchName}/`), + ); + if (parentConflict === -1 && !childConflict) { + return branchName; + } + // Git refs form a namespace: a branch cannot also be another branch's + // parent directory. Rename the conflicting segment, preserving the rest. + const conflictIndex = parentConflict === -1 ? segments.length - 1 : parentConflict; + segments[conflictIndex] = `${candidateSegments[conflictIndex]}-${suffix++}`; } - return newBranchName; } async function isBranchCheckedOut(cwd: string, branchName: string): Promise { diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts index 4b25764445..c80345c79e 100644 --- a/packages/server/vitest.config.ts +++ b/packages/server/vitest.config.ts @@ -19,9 +19,9 @@ export default defineConfig({ setupFiles: [path.resolve(__dirname, "./src/test-utils/vitest-setup.ts")], pool: "forks", fileParallelism: false, - // Windows runners intermittently starve subprocess-heavy Git tests at the - // default worker count, leaving child processes alive past their deadlines. - maxWorkers: process.platform === "win32" ? 2 : undefined, + // Even two concurrent Git-heavy suites can exhaust Windows test deadlines. + // Run files serially so their subprocesses do not compete across suites. + maxWorkers: process.platform === "win32" ? 1 : undefined, exclude: ["**/node_modules/**", "**/dist/**", "**/.claude/**", "**/.dev/**"], }, }); diff --git a/packages/website/e2e/sponsors.spec.ts b/packages/website/e2e/sponsors.spec.ts new file mode 100644 index 0000000000..7b42316d54 --- /dev/null +++ b/packages/website/e2e/sponsors.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from "playwright/test"; + +const PAGES = ["/", "/docs/connectivity", "/changelog"]; + +for (const path of PAGES) { + test(`${path} shows the sponsors and invites the next one`, async ({ page }) => { + await page.goto(path); + + const sponsors = page.getByRole("region", { name: "Sponsors", exact: true }); + await expect(sponsors.getByRole("link", { name: "Hoplite" })).toHaveAttribute( + "href", + "https://hoplite.sh/?utm_source=paseo&utm_medium=website&utm_campaign=sponsor", + ); + await expect(sponsors.getByText("Cloud coding agents", { exact: true })).toBeVisible(); + await expect(sponsors.getByRole("link", { name: "Become a sponsor" })).toHaveAttribute( + "href", + "/sponsor#spot", + ); + }); +} diff --git a/packages/website/public/sponsors/hoplite-on-dark.svg b/packages/website/public/sponsors/hoplite-on-dark.svg new file mode 100644 index 0000000000..55b1da0592 --- /dev/null +++ b/packages/website/public/sponsors/hoplite-on-dark.svg @@ -0,0 +1 @@ + diff --git a/packages/website/public/sponsors/hoplite-on-light.svg b/packages/website/public/sponsors/hoplite-on-light.svg new file mode 100644 index 0000000000..144d1a59c9 --- /dev/null +++ b/packages/website/public/sponsors/hoplite-on-light.svg @@ -0,0 +1 @@ + diff --git a/packages/website/src/components/docs-source-footer.tsx b/packages/website/src/components/docs-source-footer.tsx index 69a9711e80..2b5ea3f131 100644 --- a/packages/website/src/components/docs-source-footer.tsx +++ b/packages/website/src/components/docs-source-footer.tsx @@ -1,3 +1,4 @@ +import { ExternalLink } from "lucide-react"; import type { Doc } from "~/docs"; const GITHUB_BASE_URL = "https://github.com/getpaseo/paseo/blob/main"; @@ -6,9 +7,15 @@ export function DocsSourceFooter({ doc }: { doc: Doc }) { const sourceUrl = `${GITHUB_BASE_URL}/${doc.sourcePath}`; return ( -