mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 09:42:53 +00:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f660fcf2e5 | ||
|
|
50480d8c9d | ||
|
|
75b0eaf8a8 | ||
|
|
2caecdaae8 | ||
|
|
29883cb797 | ||
|
|
97bdbebd7f | ||
|
|
c527b203c4 | ||
|
|
602860625c | ||
|
|
c032df5b3d | ||
|
|
e1805ef542 | ||
|
|
a7f3bf7abb | ||
|
|
04624cb419 | ||
|
|
bc02cfd8cd | ||
|
|
32d6b18dbf | ||
|
|
cf4664ddf1 | ||
|
|
c3d37eba4b | ||
|
|
33224ace47 | ||
|
|
efa25ad3fe | ||
|
|
28a963795b | ||
|
|
3f7188bc1c | ||
|
|
b54c8e493a | ||
|
|
19a7d26349 | ||
|
|
2a0a0ff656 | ||
|
|
5d7afd59ae | ||
|
|
959da4acb8 | ||
|
|
9163aed3f1 | ||
|
|
c6b04f302a | ||
|
|
975dbd433b | ||
|
|
4dbd26c88c | ||
|
|
2e18620a28 | ||
|
|
dd8efba552 | ||
|
|
08556c2bda | ||
|
|
bed5d2b1be | ||
|
|
4391c47615 | ||
|
|
1fbe5cab39 | ||
|
|
ccd991b3e3 | ||
|
|
5fca5b7ccb | ||
|
|
9b7f12a6e9 | ||
|
|
1492bc659a | ||
|
|
345638dc7f | ||
|
|
929d2b717e | ||
|
|
ce905d9483 | ||
|
|
133c38c5c3 | ||
|
|
97a6deaa39 | ||
|
|
e12bdc2fc5 | ||
|
|
41298bd910 |
+1
-1
@@ -207,11 +207,11 @@
|
||||
"packages/app/src/desktop/browser/pane/index.tsx",
|
||||
"packages/app/src/desktop/browser/pane/index.web.tsx",
|
||||
"packages/app/src/components/command-center.tsx",
|
||||
"packages/app/src/components/compact-explorer-sidebar.tsx",
|
||||
"packages/app/src/components/context-window-meter.tsx",
|
||||
"packages/app/src/components/dictation-controls.tsx",
|
||||
"packages/app/src/components/download-toast.tsx",
|
||||
"packages/app/src/components/draggable-list.native.tsx",
|
||||
"packages/app/src/components/explorer-sidebar.tsx",
|
||||
"packages/app/src/components/headers/back-header.tsx",
|
||||
"packages/app/src/components/headers/menu-header.tsx",
|
||||
"packages/app/src/components/headers/screen-header.tsx",
|
||||
|
||||
@@ -1,5 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
## 0.5.0-beta.4 - 2026-08-21
|
||||
|
||||
### Added
|
||||
|
||||
- Added plugin-contributed application themes to Settings → Appearance ([#3602](https://github.com/getpaseo/paseo/pull/3602) by [@ragokan](https://github.com/ragokan))
|
||||
- Added MiniMax Code to the one-click ACP provider catalog ([#3457](https://github.com/getpaseo/paseo/pull/3457) by [@hetaoBackend](https://github.com/hetaoBackend))
|
||||
- Added project filtering to the sidebar display menu ([#3563](https://github.com/getpaseo/paseo/pull/3563) by [@cleiter](https://github.com/cleiter))
|
||||
- Added active-turn steering for OpenCode ([#3580](https://github.com/getpaseo/paseo/pull/3580) by [@mcowger](https://github.com/mcowger))
|
||||
- Added separate Content text sizing for chat, the composer, Markdown, and review prose ([#3637](https://github.com/getpaseo/paseo/pull/3637))
|
||||
- Added live sheets for summarized tool-call groups on compact layouts ([#3619](https://github.com/getpaseo/paseo/pull/3619))
|
||||
|
||||
### Improved
|
||||
|
||||
- Changed the Side panel into a user-directed surface with independent tabs and configurable supporting-tab routing ([#3605](https://github.com/getpaseo/paseo/pull/3605))
|
||||
- Changed Default send in Settings to a dropdown menu ([#3644](https://github.com/getpaseo/paseo/pull/3644))
|
||||
- Reduced Android workspace-switch stalls after using several long chats ([#3610](https://github.com/getpaseo/paseo/pull/3610))
|
||||
- Changed automatic Setup tabs to appear only after workspace setup fails ([#3634](https://github.com/getpaseo/paseo/pull/3634))
|
||||
- Corrected Russian UI translations and terminology ([#3586](https://github.com/getpaseo/paseo/pull/3586) by [@timz](https://github.com/timz))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed modified tab indicators overlapping truncated tab titles
|
||||
- Fixed composer steers remaining unread while Claude or Codex waited on a permission ([#3585](https://github.com/getpaseo/paseo/pull/3585) by [@cleiter](https://github.com/cleiter))
|
||||
- Fixed Changes opening an empty comparison after checkout state changes ([#3636](https://github.com/getpaseo/paseo/pull/3636))
|
||||
- Fixed Pi chats ending in an error state during a successful automatic retry ([#3639](https://github.com/getpaseo/paseo/pull/3639))
|
||||
- Fixed Claude usage appearing unavailable when macOS Keychain contains multiple credential items ([#3597](https://github.com/getpaseo/paseo/pull/3597) by [@t-benoit](https://github.com/t-benoit))
|
||||
- Fixed active Codex agents failing to open when their native thread was archived outside Paseo ([#3334](https://github.com/getpaseo/paseo/pull/3334))
|
||||
- Fixed OpenCode turns failing when plugin-heavy startup takes more than ten seconds ([#3578](https://github.com/getpaseo/paseo/pull/3578) by [@BrianAguilarWasco](https://github.com/BrianAguilarWasco))
|
||||
- Fixed worktree creation rejecting Git-valid branch names containing uppercase letters, underscores, or dots ([#3591](https://github.com/getpaseo/paseo/pull/3591))
|
||||
- Fixed Android Studio showing a generic terminal icon in the desktop open-target menu ([#3614](https://github.com/getpaseo/paseo/pull/3614) by [@caikovsky](https://github.com/caikovsky))
|
||||
|
||||
## 0.5.0-beta.3 - 2026-08-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the composer retaining sent text on iOS and Android ([#3564](https://github.com/getpaseo/paseo/pull/3564))
|
||||
- Fixed pull request checkout in fork workflows where the target repository is configured as `upstream` ([#2997](https://github.com/getpaseo/paseo/pull/2997) by [@mcowger](https://github.com/mcowger))
|
||||
|
||||
## 0.5.0-beta.2 - 2026-08-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added Android Studio to Open in editor ([#3531](https://github.com/getpaseo/paseo/pull/3531) by [@caikovsky](https://github.com/caikovsky))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `npm install @getpaseo/cli@beta` failing with a 404 because the plugin SDK it depends on was never published
|
||||
- Renamed the plugin SDK package to `@getpaseo/plugin`, with `@paseo/plugin` imports still resolving for existing plugins
|
||||
- Fixed the old docked file explorer sidebar staying open next to the explorer pane after upgrading to 0.5.0-beta.1, with no way to dismiss it on Windows and Linux
|
||||
- Fixed new agents, terminals, and browser tabs opening in a background pane instead of the focused one
|
||||
|
||||
## 0.5.0-beta.1 - 2026-08-18
|
||||
|
||||
### Added
|
||||
|
||||
@@ -52,6 +52,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
|
||||
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
||||
| [docs/side-panel.md](docs/side-panel.md) | The Side panel surface — its three renderings, tab placement intents, pane lifecycle, and the routing preference |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||
|
||||
@@ -23,7 +23,7 @@ RUN set -eux; \
|
||||
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@paseo/plugin --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/plugin --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
|
||||
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
|
||||
|
||||
|
||||
@@ -103,7 +103,9 @@ recoverable from History. Opening one pins its tab and renders the archived-agen
|
||||
timeline catch-up may load provider history with a runtime-only `history` resume purpose, which must
|
||||
leave both Paseo's `archivedAt` and the provider's native archive state unchanged. **Unarchive** remains
|
||||
the only transition back to an interactive runtime: it runs the provider's native unarchive hook
|
||||
(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow.
|
||||
(including Codex `thread/unarchive`) before the normal agent resume and timeline hydration flow. A
|
||||
provider session can be archived outside Paseo while its Paseo agent remains active. Interactive
|
||||
resume repairs that drift through the provider's native unarchive hook; history resume does not.
|
||||
|
||||
Provider session connection owns every process it spawns until the session is registered with
|
||||
`AgentManager`. If initialization, persisted-session resume, or initial history hydration fails,
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ Before adding a new component, read `components/ui/`. The primitive usually exis
|
||||
|
||||
Hierarchy is conveyed through weight and color, not size. Most interface text is `fontSize.base`; compact metadata and hints use `fontSize.sm`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
|
||||
|
||||
The authored ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Base size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. Code size remains independent.
|
||||
The authored interface ramp uses a 14px base. New native installs default to 15px; web and desktop default to 14px. The Appearance **Interface size** setting is the rendered `fontSize.base` value and scales the rest of the UI ramp proportionally. Primary readable content has its own `fontSize.content`, which defaults to 15px on every platform. It owns message bodies, composer input, Markdown, and PR prose. Controls, navigation, metadata, tool chrome, code, diffs, editors, and terminals stay on their interface or code tokens. **Code size** remains independent.
|
||||
|
||||
Weight has three tiers, applied by role:
|
||||
|
||||
|
||||
+2
-2
@@ -242,13 +242,13 @@ focused invalidation capture; React Native's generated stacks make that mode hig
|
||||
Set `PASEO_PROFILE_TRACE_FOCUS=1` to include focus targets, durations, and JavaScript call stacks in
|
||||
the scenario report. This mode wraps `HTMLElement.focus`, so use it only for diagnosis.
|
||||
|
||||
For the desktop explorer toggle, run the app against the root checkout's daemon and use:
|
||||
For the desktop Side panel toggle, run the app against the root checkout's daemon and use:
|
||||
|
||||
```bash
|
||||
npm run profile:explorer-toggle --workspace=@getpaseo/app
|
||||
```
|
||||
|
||||
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the explorer pane,
|
||||
The harness verifies port `6768`, opens the Paseo workspace, creates and warms the Side panel pane,
|
||||
records an idle control, then measures settled and 50 ms burst Cmd+E toggles. It reports
|
||||
input-to-DOM and input-to-paint latency, React commits, mounts, unmounts, and DOM mutations. Set
|
||||
`PASEO_PROFILE_TRACE_PATH=/tmp/explorer-toggle.trace.json` or
|
||||
|
||||
+2
-1
@@ -19,6 +19,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. User-facing creation treats it as the `worktree` workspace isolation choice. Code and `paseo.json` retain worktree terminology for git lifecycle implementation. Forbidden: "Checkout" as a product synonym.
|
||||
- **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.
|
||||
- **Side panel** — The companion surface beside the user's work, holding Changes, Files, the pull request, and tabs an agent opens alongside its output. UI: "Side panel". Code: `packages/app/src/workspace-tabs/side-panel.ts`; the persisted pane id and layout key keep the pre-rename `explorer` spelling. See [side-panel.md](side-panel.md). Forbidden: "Explorer" as the name of the surface — **File Explorer** is only the Files view inside it.
|
||||
- **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).
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
@@ -43,7 +44,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
|
||||
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
|
||||
- **Composer track** — A contextual lane at the foot of a pane, by the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**, **Tasks track**. The subagents and tasks tracks are pills floating over the transcript, each opening a panel with its rows; the queue track is a lane inside the composer itself. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), pill and panel frame in `packages/app/src/composer/tracks.tsx`, `SubagentsTrack` (`packages/app/src/subagents/track.tsx`), `AgentTaskList` (`packages/app/src/composer/task-list/index.tsx`).
|
||||
- **Steer** — A follow-up admitted into an agent's already-running turn instead of interrupting it. Interrupting the turn discards steers the provider has not read yet; there is no take-back short of that, and no provider offers one. The stop button and Escape both interrupt. A steer the provider never read keeps its transcript row, matching Codex. UI: "Send and steer". Code: `steerActiveTurn` (`packages/server/src/server/agent/agent-sdk-types.ts`). Don't confuse with **Queue track**, which holds messages client-side until the turn ends and never enters the running turn. Forbidden: "Delivery", "Queued prompt".
|
||||
- **Steer** — A follow-up admitted into an agent's already-running turn instead of interrupting it. Interrupting the turn discards steers the provider has not read yet; there is no take-back short of that, and no provider offers one. The stop button and Escape both interrupt. A steer the provider never read keeps its transcript row, matching Codex. Sending while the agent waits on a permission denies that request first, so the message reaches the same turn instead of stalling behind it. UI: "Send and steer". Code: `steerActiveTurn` (`packages/server/src/server/agent/agent-sdk-types.ts`). Don't confuse with **Queue track**, which holds messages client-side until the turn ends and never enters the running turn. Forbidden: "Delivery", "Queued prompt".
|
||||
- **Subagent** — User-facing term for an agent session related to a parent agent session. Use **subagents** in UI copy and docs. Internal daemon/provider plumbing may say "child agent" or `child_session`, especially for provider-managed imports; do not surface "child agent" as a product term.
|
||||
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
|
||||
- **Fork** — Starting a NEW agent session seeded with a copy of another session's curated chat history. The history rides along as a `chat_history` composer attachment on a draft; the fork does **not** resume or branch the provider-side session, and the source session keeps running untouched. Two targets, one surface, one RPC (`agent.fork_context`, gated on the `agentForkContext` host feature). UI: the fork menu lives in the assistant turn footer and offers "Fork in a new tab" / "Fork in a new workspace". On a **completed** turn it sits beside the copy button and pins a boundary to that turn, so the fork stops where the turn ended. On the **in-flight** turn it sits beside the progress loader and elapsed timer, and omits the boundary entirely — `selectForkContextRows` projects the whole timeline when no boundary is given, so the fork captures everything **up to now**, including the response still streaming. That omission is what makes mid-run forking work; pinning a boundary there would silently drop the live response. Forbidden on provider-subagent (read-only) panes. Code: `useForkAgent` (`packages/app/src/hooks/use-fork-agent.ts`), `selectForkContextRows` (`packages/server/src/server/agent/activity-curator.ts`). Don't confuse with: git fork (unrelated; Paseo has no such concept), **Worktree** (filesystem isolation, not history copying).
|
||||
|
||||
+35
-12
@@ -1,7 +1,7 @@
|
||||
# Local plugins
|
||||
|
||||
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
||||
and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
||||
app themes, and composer attachment sources from one `index.ts`. Paseo executes the server contribution in a
|
||||
subprocess and evaluates the client contribution in the app runtime. Plugin code is trusted code;
|
||||
this first slice does not sandbox it.
|
||||
|
||||
@@ -53,7 +53,7 @@ my-plugin/
|
||||
```
|
||||
|
||||
Paseo compiles TypeScript and TSX when loading the plugin, so these packages are development dependencies only.
|
||||
The generated declaration file supplies `@paseo/plugin` and `@paseo/plugin/server` types until the
|
||||
The generated declaration file supplies `@getpaseo/plugin` and `@getpaseo/plugin/server` types until the
|
||||
SDK is distributed as a public package. Regenerate new plugins with the matching Paseo CLI when the
|
||||
SDK contract changes.
|
||||
|
||||
@@ -96,15 +96,15 @@ code lives behind filename boundaries:
|
||||
| `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. |
|
||||
| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. |
|
||||
|
||||
Shared files import contracts from `@paseo/plugin/server`. Client files import hooks from
|
||||
`@paseo/plugin`. Plugin UI runs on desktop and mobile across multiple themes: color every
|
||||
Shared files import contracts from `@getpaseo/plugin/server`. Client files import hooks from
|
||||
`@getpaseo/plugin`. Plugin UI runs on desktop and mobile across multiple themes: color every
|
||||
`Text` from `theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from
|
||||
`layout.compact`. See `public-docs/plugins/reference.md`.
|
||||
|
||||
| Module | Use it for |
|
||||
| ---------------------- | -------------------------------------------------------- |
|
||||
| `@paseo/plugin` | hooks and UI types |
|
||||
| `@paseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
|
||||
| Module | Use it for |
|
||||
| ------------------------- | -------------------------------------------------------- |
|
||||
| `@getpaseo/plugin` | hooks and UI types |
|
||||
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
|
||||
|
||||
The compiler removes client registrations and imports from the server entry point, and server
|
||||
registrations and imports from the client entry point. Importing a `*.server` module from a client
|
||||
@@ -113,7 +113,7 @@ such as `StyleSheet.create` belong in `*.client.tsx`; placing them in `index.ts`
|
||||
server bundle.
|
||||
|
||||
```ts
|
||||
import type { PluginContext } from "@paseo/plugin";
|
||||
import type { PluginContext } from "@getpaseo/plugin";
|
||||
import { Greeting } from "./greeting.client";
|
||||
import { createGreeting } from "./greeting.server";
|
||||
import { greetRpc } from "./greeting.shared";
|
||||
@@ -178,7 +178,7 @@ search picker, drafts, selected pill, and submission. The plugin returns complet
|
||||
credentials and vendor API calls stay in the daemon handler.
|
||||
|
||||
```ts
|
||||
import type { PluginContext } from "@paseo/plugin";
|
||||
import type { PluginContext } from "@getpaseo/plugin";
|
||||
import { search } from "./issues.server";
|
||||
import { issues, searchIssues } from "./issues.shared";
|
||||
|
||||
@@ -194,5 +194,28 @@ on several hosts are not coalesced. The selected snapshot submits as a text atta
|
||||
external-resource presentation, so it remains readable if the plugin is removed or an older peer
|
||||
drops the optional presentation fields.
|
||||
|
||||
See `plugin-examples/local-plugin` for a native surface and `plugin-examples/linear` for a complete
|
||||
attachment-source example.
|
||||
## Contribute a theme
|
||||
|
||||
`addTheme` takes a small light or dark palette and a display name. Paseo expands it through the
|
||||
same semantic builders as the built-in themes, so plugins do not depend on the complete app token
|
||||
contract. Unistyles needs every theme name at `StyleSheet.configure` time, so
|
||||
`packages/app/src/styles/theme.ts` reserves one light and one dark plugin slot. The appearance
|
||||
provider rewrites the matching slot when the selection changes. See [unistyles.md](unistyles.md)
|
||||
for the runtime-patching rules the appearance settings share.
|
||||
|
||||
`addTheme` is a client registration, so the compiler strips it from the backend bundle. A daemon
|
||||
that predates it does not, and the plugin fails to start there. Daemons advertise
|
||||
`features.pluginThemes` in `server_info`; the plugin theme catalog is the one place the app reads it, and
|
||||
a host without it contributes no themes.
|
||||
|
||||
The selection persists as `theme: "plugin"` plus a `pluginThemeId` of `<pluginId>/theme/<themeId>`,
|
||||
so equal themes on several hosts coalesce the way sidebar contributions do. Two hosts can answer
|
||||
that id with different palettes, so picking a theme records its host through
|
||||
`rememberPluginContributionHost` and resolution prefers it; a peer connecting or dropping then does
|
||||
not repaint the app. Without a preference the sorted registry snapshot decides, so the result is
|
||||
stable rather than arrival-ordered. The app resolves that id
|
||||
against the installed catalog on every change; an id nothing contributes falls back to the default
|
||||
preference instead of painting the reserved slot's placeholder colors.
|
||||
|
||||
See `plugin-examples/local-plugin` for a native surface, `plugin-examples/linear` for a complete
|
||||
attachment-source example, and `plugin-examples/catppuccin` for a theme.
|
||||
|
||||
+3
-1
@@ -81,10 +81,12 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
|
||||
|
||||
`AgentManager` owns the one canonical timeline row for a foreground prompt carrying a Paseo `clientMessageId`. It records that row when `startTurn` accepts, with the wire `messageId` set to the same value. Provider adapters still emit their native user-message echo with the same `clientMessageId` when available; the manager records its provider identity on the internal row without changing or redispatching the wire item. If an adapter emits the echo before `startTurn` resolves, the manager records the provider identity with the row at acceptance. Provider adapters continue to own externally initiated user rows that have no Paseo client identity. Do not perform global transcript text dedupe.
|
||||
|
||||
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. The released OpenCode v1 adapter does not implement this operation.
|
||||
Active-turn steering is an optional `AgentSession.steerActiveTurn` operation. The manager owns admission against its exact foreground turn, canonical user-message creation, echo reconciliation, and falls back to the normal interrupt-and-replace path only when the adapter reports `unavailable`. An adapter error leaves the steer's fate ambiguous and must surface without an interrupt or retry. Codex calls `turn/steer` with the native expected turn and Paseo client user-message ID. Claude pushes an admitted steer into the exact active SDK query input; isolated control commands remain unavailable. OpenCode calls `session/prompt_async` with an OpenCode-generated message ID; the server queues the prompt while busy and the next LLM call in the same Paseo turn includes it. A missing session reports `unavailable` and uses the normal interrupt fallback.
|
||||
|
||||
A steering adapter also owes its interrupt: stopping a turn must discard the steers the provider has not read yet, or one of them resumes the turn the user just stopped. Codex clears pending input when it aborts a turn; Claude does not, so its adapter cancels the SDK messages it queued before calling `query.interrupt()`.
|
||||
|
||||
`SteerActiveTurnOptions.clearPendingPermissions` makes permission release part of the provider contract. A provider that accepts such a steer queues it first, denies permissions blocking its delivery, and stops once the steer is read. Steers without the flag leave permissions open. A denied plan remains in the timeline because the pending card was the only other copy of its text.
|
||||
|
||||
Rewind accepts the canonical wire `messageId` and resolves it to the provider identity before calling the adapter. A submitted prompt cannot be rewound until its provider echo supplies that identity.
|
||||
|
||||
Submitted user-message wire items carry the same Paseo ID in `messageId` and `clientMessageId`. Provider adapters attach `clientMessageId` only to the echo for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID.
|
||||
|
||||
+1
-1
@@ -120,7 +120,7 @@ republish the packages:
|
||||
|
||||
```bash
|
||||
PASEO_VERSION=$(node -p "require('./package.json').version")
|
||||
for package in highlight relay protocol client server cli; do
|
||||
for package in highlight relay protocol client plugin server cli; do
|
||||
npm dist-tag add "@getpaseo/$package@$PASEO_VERSION" beta
|
||||
done
|
||||
```
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Side panel
|
||||
|
||||
The companion surface beside the user's work: Changes, Files, the pull request, and
|
||||
anything an agent asks to show alongside its output.
|
||||
|
||||
Everything that opens, reveals, hides, or asks about it goes through
|
||||
`packages/app/src/workspace-tabs/side-panel.ts`. Nothing else branches on which
|
||||
surface the current layout uses.
|
||||
|
||||
## Three renderings, two facts
|
||||
|
||||
| Layout | Rendering |
|
||||
| ---------------------- | ----------------------------------------------- |
|
||||
| compact | slide-over overlay, owned by the panel store |
|
||||
| non-compact + splits | dedicated right pane, owned by the layout store |
|
||||
| non-compact, no splits | ordinary tabs in the focused pane |
|
||||
|
||||
The third case is the one that bites. A native tablet renders one pane at a time and
|
||||
the tab row only lists the focused pane's tabs, so anything routed into a separate
|
||||
side panel pane there disappears with no way back.
|
||||
|
||||
## Placement
|
||||
|
||||
Where a tab lands is an intent the caller declares, not something the store infers
|
||||
from a `paneId` that happens to be in scope. `WorkspaceTabPlacement`
|
||||
(`packages/app/src/stores/workspace-layout-actions.ts`) has four modes, and the
|
||||
difference between them only shows up when the tab is already open somewhere:
|
||||
|
||||
| Mode | New tab | Tab already open |
|
||||
| --------- | ----------------- | ---------------------------- |
|
||||
| `pane` | opens in the pane | **moves** to the pane |
|
||||
| `prefer` | opens in the pane | stays where the user left it |
|
||||
| `focused` | focused pane | focuses it in place |
|
||||
| `ambient` | focused pane | focuses it in place |
|
||||
|
||||
`pane` is a pane-local affordance — that pane's `+` menu, its empty-state launcher.
|
||||
The user is placing the tab and expects it to arrive.
|
||||
|
||||
`prefer` is an implicit supporting open — an agent's file link or a failed workspace
|
||||
setup. It has an opinion about new tabs only. Yanking a tab out of the pane the user
|
||||
deliberately moved it to is the failure this mode exists to prevent.
|
||||
|
||||
`ambient` differs from `focused` in one rule: reconciliation opens tabs with nobody
|
||||
behind the click, and the side panel can hold focus from an earlier reveal, so an
|
||||
ambient open of something the user works _in_ (an agent, a terminal, a browser) is
|
||||
pushed out of the side panel. A `focused` open honours the focused pane whatever it
|
||||
is, because the user is standing in it.
|
||||
|
||||
Deduplication lives in `openTabInLayoutFocused`, so it obeys the placement intent for
|
||||
every caller. Do not re-place a tab after opening it.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
The side panel lives in the ordinary split tree, so it is a pane like any other until
|
||||
it empties or the user dismisses it:
|
||||
|
||||
- **Close pane** on the side panel **hides** it; its tabs are waiting on the next reveal.
|
||||
- **Close pane** on any other pane **removes** it.
|
||||
- Closing the Side panel's final tab hides the pane. Revealing it returns the empty launcher.
|
||||
This is close-only: claiming or dragging that tab to another pane leaves the empty Side panel
|
||||
visible.
|
||||
- Nothing takes the last visible pane away. `closePane` will not remove it, and
|
||||
neither `closePane` nor `hideSidePanel` will hide it — including the header toggle
|
||||
reaching `hideSidePanel` after every other pane is gone. The gate is one check in
|
||||
`setPaneHiddenInLayout`; do not add a second one upstream.
|
||||
|
||||
`closePane` in the layout store owns that distinction. React renders the button and
|
||||
does not decide what it means.
|
||||
|
||||
Dragging a pane's last tab into another pane collapses the source pane — except the
|
||||
side panel, which the user summons and expects to find again, split size and all.
|
||||
|
||||
## What the side panel never does
|
||||
|
||||
It does not seed itself. Revealing it opens an empty pane with the launcher; the user
|
||||
picks what goes in. Detecting a pull request makes the launcher's card available and
|
||||
opens nothing. A failed workspace setup adds a background tab to the main pane.
|
||||
Running and successful setup never seed tabs.
|
||||
|
||||
## Routing preference
|
||||
|
||||
**Settings → General → Open supporting tabs in the Side panel**, on by default,
|
||||
persisted in client app settings as `openSupportingTabsInSidePanel`. Off means new
|
||||
supporting tabs land in the focused pane instead. Mobile ignores it. Explicit actions
|
||||
— the header toggle, a pane's `+` menu, the empty-state launcher — are never subject
|
||||
to it.
|
||||
|
||||
## The name
|
||||
|
||||
The product surface is the **Side panel**. **File Explorer** is still the name of the
|
||||
Files view inside it. Persisted identifiers keep their pre-rename spelling: the pane
|
||||
id is the literal string `"explorer"` (`SIDE_PANEL_PANE_ID`) and the persisted layout
|
||||
key is `explorerPaneIdByWorkspace`. The persisted state schema is strict, so renaming
|
||||
either would fail every saved blob and wipe the layout it describes.
|
||||
+3
-2
@@ -335,15 +335,16 @@ If we ever need to avoid the transition entirely, store at least the theme prefe
|
||||
|
||||
## Runtime Theme Patching For User Preferences
|
||||
|
||||
Appearance settings (UI/mono font family, font sizes, syntax-highlight theme) are applied by patching every registered theme at runtime with `UnistylesRuntime.updateTheme(name, updater)` — not by threading preference reads through components. `applyAppearance` in `packages/app/src/screens/settings/appearance/apply-appearance.ts` runs from a `ProvidersWrapper` effect on settings load/change and loops all six theme keys, returning `{ ...theme, fontFamily, fontSize, lineHeight, colors.syntax }`.
|
||||
Appearance settings (theme, UI/mono font family, font sizes, syntax-highlight theme) are owned by `packages/app/src/appearance`. Its provider subscribes once and synchronizes Unistyles when settings or plugin contributions change. `applyAppearance` patches every key in `REGISTERED_THEMES`, returning `{ ...theme, fontFamily, fontSize, lineHeight, colors.syntax }`.
|
||||
|
||||
This works without `useUnistyles()` because every consumer already reads these tokens through `StyleSheet.create((theme) => …)` (or the `withUnistyles`/`uniProps` path for the markdown renderer), so patching the theme repaints tracked views through the native ShadowRegistry with no React re-render.
|
||||
|
||||
Gotchas:
|
||||
|
||||
- **Patch all themes, not just the active one.** The active theme can change and adaptive mode can flip light/dark; patching every key keeps the active key current and makes ordering vs `setTheme`/`setAdaptiveThemes` irrelevant. The effect depends on the settings values (not on `theme`), so it cannot loop.
|
||||
- **The reserved plugin keys are the exception to that ordering.** A plugin-contributed theme is rebuilt from its palette, which discards the appearance patch, so the appearance provider writes the matching light or dark slot before applying font and syntax preferences. See [plugins.md](plugins.md).
|
||||
- **Narrow the discriminated union before spreading.** `updateTheme`'s updater returns the theme union; spreading the union widens `colorScheme` to `"light" | "dark"`, which is assignable to neither concrete member. Branch on `t.colorScheme` so each branch spreads a single narrowed theme type (no `as`).
|
||||
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with the UI ramp (`Math.round(theme.fontSize.base * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
|
||||
- **`lineHeight.diff` is the code/diff line-height axis** — it is coupled to the code-font-size control (≈ `codeFontSize * 1.5`). Do NOT use it for prose. Markdown body line-height scales with content size (`Math.round(theme.fontSize.content * 1.4)`); routing prose through `lineHeight.diff` clips text at small code sizes.
|
||||
- **High-churn draft values** (live-while-typing in the appearance preview) bypass the theme: apply them as inline styles marked with `inlineUnistylesStyle` so per-keystroke values don't grow the `#unistyles-web` CSS registry.
|
||||
- **The app shell uses one `AppearanceStyleBoundary`.** Runtime-patched numeric theme values are baked into Unistyles web classes rather than CSS variables, while parsed/memoized content also does not naturally re-run when appearance tokens change. The boundary sits below stable runtime providers in `app/_layout.tsx` and remounts the visual shell once. `applyAppearance` patches the active theme before inactive registry entries so its subscribers receive the committed values in the same update. Do not add local appearance keys or nested boundaries.
|
||||
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-yHpFbO9HYwuyioIOKzpSv/FZjMqne+LFUSq5eFgRBPI=
|
||||
sha256-FhcGrCB3+Nsr/RCEQClXwTZW+jyRptNpNjIzAmnb3pI=
|
||||
|
||||
Generated
+31
-31
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.5.0-beta.1",
|
||||
"version": "0.5.0-beta.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.5.0-beta.1",
|
||||
"version": "0.5.0-beta.4",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -6247,6 +6247,10 @@
|
||||
"resolved": "packages/highlight",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@getpaseo/plugin": {
|
||||
"resolved": "packages/plugin",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@getpaseo/protocol": {
|
||||
"resolved": "packages/protocol",
|
||||
"link": true
|
||||
@@ -9556,10 +9560,6 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@paseo/plugin": {
|
||||
"resolved": "packages/plugin",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
@@ -36140,7 +36140,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.5.0-beta.1",
|
||||
"version": "0.5.0-beta.4",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -36154,12 +36154,12 @@
|
||||
"@getpaseo/client": "*",
|
||||
"@getpaseo/expo-two-way-audio": "*",
|
||||
"@getpaseo/highlight": "*",
|
||||
"@getpaseo/plugin": "*",
|
||||
"@getpaseo/protocol": "*",
|
||||
"@gorhom/bottom-sheet": "^5.2.14",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@marijn/find-cluster-break": "^1.0.2",
|
||||
"@mattermost/react-native-paste-input": "2.0.1",
|
||||
"@paseo/plugin": "*",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native/normalize-colors": "^0.81.5",
|
||||
@@ -37192,12 +37192,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.5.0-beta.1",
|
||||
"version": "0.5.0-beta.4",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.5.0-beta.1",
|
||||
"@getpaseo/protocol": "0.5.0-beta.1",
|
||||
"@getpaseo/server": "0.5.0-beta.1",
|
||||
"@getpaseo/client": "0.5.0-beta.4",
|
||||
"@getpaseo/protocol": "0.5.0-beta.4",
|
||||
"@getpaseo/server": "0.5.0-beta.4",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37444,10 +37444,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.5.0-beta.1",
|
||||
"version": "0.5.0-beta.4",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.5.0-beta.1",
|
||||
"@getpaseo/relay": "0.5.0-beta.1",
|
||||
"@getpaseo/protocol": "0.5.0-beta.4",
|
||||
"@getpaseo/relay": "0.5.0-beta.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37458,7 +37458,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.5.0-beta.1",
|
||||
"version": "0.5.0-beta.4",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37701,7 +37701,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.5.0-beta.1",
|
||||
"version": "0.5.0-beta.4",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38597,7 +38597,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.5.0-beta.1",
|
||||
"version": "0.5.0-beta.4",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38625,13 +38625,13 @@
|
||||
}
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@paseo/plugin",
|
||||
"version": "0.5.0-beta.1",
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.5.0-beta.4",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "*",
|
||||
"@getpaseo/client": "0.5.0-beta.4",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38640,14 +38640,14 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "*",
|
||||
"@getpaseo/client": "0.5.0-beta.4",
|
||||
"react": "19.1.0",
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.5.0-beta.1",
|
||||
"version": "0.5.0-beta.4",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -38660,7 +38660,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.5.0-beta.1",
|
||||
"version": "0.5.0-beta.4",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38878,20 +38878,20 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.5.0-beta.1",
|
||||
"version": "0.5.0-beta.4",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@getpaseo/client": "0.5.0-beta.1",
|
||||
"@getpaseo/highlight": "0.5.0-beta.1",
|
||||
"@getpaseo/protocol": "0.5.0-beta.1",
|
||||
"@getpaseo/relay": "0.5.0-beta.1",
|
||||
"@getpaseo/client": "0.5.0-beta.4",
|
||||
"@getpaseo/highlight": "0.5.0-beta.4",
|
||||
"@getpaseo/plugin": "0.5.0-beta.4",
|
||||
"@getpaseo/protocol": "0.5.0-beta.4",
|
||||
"@getpaseo/relay": "0.5.0-beta.4",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
"@paseo/plugin": "*",
|
||||
"@xterm/headless": "^6.0.0",
|
||||
"ai": "5.0.78",
|
||||
"ajv": "^8.20.0",
|
||||
@@ -39940,7 +39940,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.5.0-beta.1",
|
||||
"version": "0.5.0-beta.4",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+8
-8
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.5.0-beta.1",
|
||||
"version": "0.5.0-beta.4",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -47,8 +47,8 @@
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
|
||||
"build:highlight:clean": "npm run build:clean --workspace=@getpaseo/highlight",
|
||||
"build:plugin": "npm run build --workspace=@paseo/plugin",
|
||||
"build:plugin:clean": "npm run build:clean --workspace=@paseo/plugin",
|
||||
"build:plugin": "npm run build --workspace=@getpaseo/plugin",
|
||||
"build:plugin:clean": "npm run build:clean --workspace=@getpaseo/plugin",
|
||||
"build:relay": "npm run build --workspace=@getpaseo/relay",
|
||||
"build:relay:clean": "npm run build:clean --workspace=@getpaseo/relay",
|
||||
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
|
||||
@@ -102,11 +102,11 @@
|
||||
"version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
|
||||
"version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
|
||||
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
|
||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||
"release:publish:beta:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/relay --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/protocol --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/client --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/server --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/cli --access public --tag beta",
|
||||
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||
"release:publish:beta": "npm publish --workspace=@getpaseo/highlight --access public --tag beta && npm publish --workspace=@getpaseo/relay --access public --tag beta && npm publish --workspace=@getpaseo/protocol --access public --tag beta && npm publish --workspace=@getpaseo/client --access public --tag beta && npm publish --workspace=@getpaseo/server --access public --tag beta && npm publish --workspace=@getpaseo/cli --access public --tag beta",
|
||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client:clean && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/plugin && npm run build:server:clean && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && npm pack --dry-run --workspace=@getpaseo/plugin && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/protocol --access public && npm publish --dry-run --workspace=@getpaseo/client --access public && npm publish --dry-run --workspace=@getpaseo/plugin --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||
"release:publish:beta:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/relay --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/protocol --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/client --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/plugin --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/server --access public --tag beta && npm publish --dry-run --workspace=@getpaseo/cli --access public --tag beta",
|
||||
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/protocol --access public && npm publish --workspace=@getpaseo/client --access public && npm publish --workspace=@getpaseo/plugin --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||
"release:publish:beta": "npm publish --workspace=@getpaseo/highlight --access public --tag beta && npm publish --workspace=@getpaseo/relay --access public --tag beta && npm publish --workspace=@getpaseo/protocol --access public --tag beta && npm publish --workspace=@getpaseo/client --access public --tag beta && npm publish --workspace=@getpaseo/plugin --access public --tag beta && npm publish --workspace=@getpaseo/server --access public --tag beta && npm publish --workspace=@getpaseo/cli --access public --tag beta",
|
||||
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:publish:beta && npm run release:push",
|
||||
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:publish:beta && npm run release:push",
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
} from "../support/helpers/settings";
|
||||
|
||||
const ACP_PROVIDER = {
|
||||
id: "hermes",
|
||||
name: "Hermes",
|
||||
id: "minimax-code",
|
||||
name: "MiniMax Code",
|
||||
};
|
||||
|
||||
interface ProviderCatalogDaemonClient {
|
||||
@@ -22,7 +22,7 @@ interface ProviderCatalogDaemonClient {
|
||||
}
|
||||
|
||||
test.describe("ACP provider catalog", () => {
|
||||
test("adds a catalog provider from settings", async ({ page }) => {
|
||||
test("adds MiniMax Code from settings", async ({ page }) => {
|
||||
const client = await connectDaemonClient<ProviderCatalogDaemonClient>({
|
||||
clientIdPrefix: "provider-catalog-e2e",
|
||||
});
|
||||
|
||||
@@ -726,7 +726,7 @@ async function recordDelayedRunningTransition(
|
||||
gate.holdNextAgentStreamEvent("turn_started");
|
||||
gate.holdNextAgentUpdate(agent.agentId, "running");
|
||||
gate.holdNextServerMessage("send_agent_message_response");
|
||||
gate.setAgentStreamItemSuppressed("assistant_message", true);
|
||||
gate.setAgentStreamSuppressed(true);
|
||||
await recordTurnFrames(page, prompt);
|
||||
await installActivityContinuityOracle(page, prompt);
|
||||
await submitMessage(page, prompt);
|
||||
@@ -779,13 +779,13 @@ async function recordDelayedRunningTransition(
|
||||
const checkpoints = await readActivityContinuityOracle(page);
|
||||
expectActivityContinuity(checkpoints);
|
||||
expect(elapsedBeforeAuthoritative).toBe(0);
|
||||
gate.setAgentStreamItemSuppressed("assistant_message", false);
|
||||
gate.setAgentStreamSuppressed(false);
|
||||
return { frames, checkpoints };
|
||||
} finally {
|
||||
if (!turnReleased) gate.releaseHeldAgentStreamEvent("turn_started");
|
||||
if (!snapshotReleased) gate.releaseHeldAgentUpdate(agent.agentId, "running");
|
||||
if (!responseReleased) gate.releaseHeldServerMessage("send_agent_message_response");
|
||||
gate.setAgentStreamItemSuppressed("assistant_message", false);
|
||||
gate.setAgentStreamSuppressed(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -814,6 +814,8 @@ test("keeps the first prompt of a new agent in place through authoritative hydra
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const prompt = "Delay synthetic user message by 300ms.";
|
||||
gate.holdNextServerMessage("fetch_agent_timeline_response");
|
||||
gate.setAgentStreamEventSuppressed("timeline", true);
|
||||
await attachImageFromMenu(page, FIRST_PROMPT_IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
await recordTurnFrames(page, prompt);
|
||||
@@ -821,7 +823,10 @@ test("keeps the first prompt of a new agent in place through authoritative hydra
|
||||
|
||||
const submittedRow = page.getByTestId("user-message").filter({ hasText: prompt }).first();
|
||||
await expect(submittedRow).toBeVisible();
|
||||
await gate.waitForServerMessage("fetch_agent_timeline_response");
|
||||
await gate.waitForHeldServerMessage("fetch_agent_timeline_response");
|
||||
gate.truncateHeldTimelineAfterLast("user_message");
|
||||
gate.releaseHeldServerMessage("fetch_agent_timeline_response");
|
||||
await expect(submittedRow.getByTestId("rewind-menu-trigger")).toBeVisible();
|
||||
await recordPaintsFor(page, 80);
|
||||
expectAtomicFirstPromptTransition(await stopTurnFrameRecording(page));
|
||||
} finally {
|
||||
|
||||
@@ -315,10 +315,11 @@ async function configureInterruptInSettings(page: Page): Promise<void> {
|
||||
|
||||
async function selectSendBehaviorInSettings(
|
||||
page: Page,
|
||||
buttonName: string,
|
||||
behaviorLabel: string,
|
||||
stored: string,
|
||||
): Promise<void> {
|
||||
await page.getByRole("button", { name: buttonName, exact: true }).click();
|
||||
await page.getByRole("button", { name: /^Default send: / }).click();
|
||||
await page.getByRole("menuitem", { name: behaviorLabel, exact: true }).click();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
|
||||
|
||||
@@ -91,14 +91,16 @@ test.describe("Agent timeline pagination", () => {
|
||||
await expectTimelinePromptVisible(page, agent.newestPrompt);
|
||||
await expectTimelinePromptCentered(page, agent.newestPrompt);
|
||||
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
|
||||
await expectTimelinePromptNotMounted(page, agent.initialTailOldestPrompt);
|
||||
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await expectTimelinePromptVisible(page, agent.initialTailOldestPrompt);
|
||||
await history.expectRequestedPages(1);
|
||||
history.releasePage(1);
|
||||
await history.expectSettledWithRequestedPages(1);
|
||||
await expectTimelinePromptCentered(page, agent.firstOlderPagePrompt);
|
||||
|
||||
await userScrollsTimelineToHistoryStart(page);
|
||||
await expectTimelinePromptCentered(page, agent.firstOlderPagePrompt);
|
||||
await history.expectRequestedPages(2);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
|
||||
@@ -45,7 +45,7 @@ test("keeps the selected workspace visible in Pure black", async ({ page }, test
|
||||
}
|
||||
});
|
||||
|
||||
test("applies the base font size to settings text", async ({ page }) => {
|
||||
test("applies the interface font size to settings text", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiBaseFontSize: 21 }));
|
||||
});
|
||||
@@ -56,11 +56,14 @@ test("applies the base font size to settings text", async ({ page }) => {
|
||||
const sectionTitle = page.getByText("Theme", { exact: true }).first();
|
||||
await expect(sectionTitle).toHaveCSS("font-size", "18px");
|
||||
|
||||
const fontSizeInput = page.getByLabel("Base font size");
|
||||
await expect(fontSizeInput).toHaveValue("21");
|
||||
await fontSizeInput.fill("12");
|
||||
await fontSizeInput.press("Tab");
|
||||
const interfaceSizeInput = page.getByLabel("Interface font size");
|
||||
const contentSizeInput = page.getByLabel("Content font size");
|
||||
await expect(interfaceSizeInput).toHaveValue("21");
|
||||
await expect(contentSizeInput).toHaveValue("21");
|
||||
await interfaceSizeInput.fill("12");
|
||||
await interfaceSizeInput.press("Tab");
|
||||
|
||||
await expect(fontSizeInput).toHaveValue("12");
|
||||
await expect(interfaceSizeInput).toHaveValue("12");
|
||||
await expect(contentSizeInput).toHaveValue("21");
|
||||
await expect(sectionTitle).toHaveCSS("font-size", "10px");
|
||||
});
|
||||
|
||||
@@ -258,6 +258,61 @@ test.afterEach(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("Changes opens the populated committed comparison for a clean checkout", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(panel.getByTestId("changes-diff-status-trigger")).toContainText("Committed");
|
||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Changes expires a manual comparison when checkout dirtiness changes", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const mode = panel.getByTestId("changes-diff-status-trigger");
|
||||
await expect(mode).toContainText("Uncommitted");
|
||||
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-committed").click();
|
||||
await expect(mode).toContainText("Committed");
|
||||
await expect(panel.getByRole("button", { name: "See uncommitted changes" })).toBeVisible();
|
||||
|
||||
execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath });
|
||||
execFileSync("git", ["commit", "-m", "Commit working changes"], { cwd: workspace.repoPath });
|
||||
await expect(mode).toContainText("Committed");
|
||||
await expect(panel.getByRole("button", { name: "See uncommitted changes" })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await writeFile(path.join(workspace.repoPath, "new-working-change.txt"), "uncommitted\n");
|
||||
await expect(mode).toContainText("Uncommitted", { timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("an empty Changes comparison links to the populated comparison", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithCommittedDiff();
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const mode = panel.getByTestId("changes-diff-status-trigger");
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-committed").click();
|
||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||
|
||||
await mode.click();
|
||||
await page.getByTestId("changes-diff-mode-uncommitted").click();
|
||||
|
||||
await expect(panel.getByText("No uncommitted changes", { exact: true })).toBeVisible();
|
||||
const seeCommitted = panel.getByRole("button", { name: "See committed changes" });
|
||||
await expect(seeCommitted).toBeVisible();
|
||||
await seeCommitted.click();
|
||||
await expect(mode).toContainText("Committed");
|
||||
await expect(panel.getByText("committed-only.ts", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("changes file actions open below the right-click without a reserved kebab", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -648,7 +703,7 @@ test("Changes keeps review navigation and controls inside its workspace tab", as
|
||||
|
||||
test("desktop Changes toggles a navigation tree beside the expanded diff document", async ({
|
||||
page,
|
||||
}) => {
|
||||
}, testInfo) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
@@ -658,6 +713,41 @@ test("desktop Changes toggles a navigation tree beside the expanded diff documen
|
||||
await expect(page.getByTestId("changes-layout-split")).toHaveCount(0);
|
||||
// The tree toggle is the one icon action that stays outside the overflow menu.
|
||||
await expect(page.getByTestId("changes-toggle-tree")).toBeVisible();
|
||||
const [changesToolbarBox, treeToggleBox, optionsBox, treeGlyphBox, optionsGlyphBox] =
|
||||
await Promise.all([
|
||||
page.getByTestId("changes-header").boundingBox(),
|
||||
page.getByTestId("changes-toggle-tree").boundingBox(),
|
||||
page.getByRole("button", { name: "Diff options" }).boundingBox(),
|
||||
page.getByTestId("changes-toggle-tree").locator("svg").boundingBox(),
|
||||
page.getByRole("button", { name: "Diff options" }).locator("svg").boundingBox(),
|
||||
]);
|
||||
if (!changesToolbarBox || !treeToggleBox || !optionsBox || !treeGlyphBox || !optionsGlyphBox) {
|
||||
throw new Error("Changes toolbar geometry could not be measured");
|
||||
}
|
||||
expect(changesToolbarBox.height).toBe(36);
|
||||
expect(treeToggleBox.width).toBe(24);
|
||||
expect(treeToggleBox.height).toBe(24);
|
||||
expect(optionsBox.width).toBe(24);
|
||||
expect(optionsBox.height).toBe(24);
|
||||
expect(treeGlyphBox.width).toBe(14);
|
||||
expect(treeGlyphBox.height).toBe(14);
|
||||
expect(optionsGlyphBox.width).toBe(14);
|
||||
expect(optionsGlyphBox.height).toBe(14);
|
||||
const diffOptions = page.getByRole("button", { name: "Diff options" });
|
||||
const diffOptionsRestBackground = await diffOptions.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
await diffOptions.hover();
|
||||
const diffOptionsHoverBackground = await diffOptions.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
expect(diffOptionsHoverBackground).not.toBe(diffOptionsRestBackground);
|
||||
await page.getByTestId("changes-toggle-tree").hover();
|
||||
expect(
|
||||
await page
|
||||
.getByTestId("changes-toggle-tree")
|
||||
.evaluate((element) => getComputedStyle(element).backgroundColor),
|
||||
).toBe(diffOptionsHoverBackground);
|
||||
await expect(page.getByTestId("changes-toggle-collapse-all")).toHaveCount(0);
|
||||
await expect(page.getByTestId("changes-toggle-layout")).toHaveCount(0);
|
||||
|
||||
@@ -675,6 +765,12 @@ test("desktop Changes toggles a navigation tree beside the expanded diff documen
|
||||
|
||||
await scrollToLowerUnwrappedDiffRows(page);
|
||||
await page.getByTestId("changes-toggle-tree").click();
|
||||
await expect(page.getByTestId("changes-toggle-tree")).toHaveAttribute("aria-selected", "true");
|
||||
expect(
|
||||
await page
|
||||
.getByTestId("changes-toggle-tree")
|
||||
.evaluate((element) => getComputedStyle(element).backgroundColor),
|
||||
).toBe(diffOptionsHoverBackground);
|
||||
const tree = changesTree(page);
|
||||
const content = changesContent(page);
|
||||
await expect(tree.getByTestId("diff-folder-src")).toBeVisible();
|
||||
@@ -735,7 +831,38 @@ test("desktop Changes toggles a navigation tree beside the expanded diff documen
|
||||
await expect(tree.getByTestId("diff-tree-file-0")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("changes-toggle-tree").click();
|
||||
await expect(page.getByTestId("changes-toggle-tree")).toHaveAttribute("aria-selected", "false");
|
||||
await expectFlatFileList(page);
|
||||
const screenshot = testInfo.outputPath("changes-toolbar.png");
|
||||
await page.screenshot({ path: screenshot });
|
||||
await testInfo.attach("changes-toolbar", { path: screenshot, contentType: "image/png" });
|
||||
});
|
||||
|
||||
test("compact Changes keeps its options control touch-sized", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await page.setViewportSize({ width: 480, height: 900 });
|
||||
|
||||
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true });
|
||||
const [optionsBox, glyphBox] = await Promise.all([
|
||||
options.boundingBox(),
|
||||
options.locator("svg").boundingBox(),
|
||||
]);
|
||||
if (!optionsBox || !glyphBox) {
|
||||
throw new Error("Compact Changes options geometry could not be measured");
|
||||
}
|
||||
expect(optionsBox.width).toBe(32);
|
||||
expect(optionsBox.height).toBe(32);
|
||||
expect(glyphBox.width).toBe(18);
|
||||
expect(glyphBox.height).toBe(18);
|
||||
|
||||
await options.click();
|
||||
const wrapLines = page.getByText("Wrap long lines", { exact: true });
|
||||
await expect(wrapLines).toBeVisible();
|
||||
await wrapLines.click();
|
||||
await options.click();
|
||||
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Scroll long lines");
|
||||
});
|
||||
|
||||
test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => {
|
||||
@@ -875,6 +1002,7 @@ test("split canvas creates a review on the changed side and keeps it in that col
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await configureDiffPresentation(page, { layout: "split", wrapLines: false });
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
await setOpenChangesPresentation(page, { layout: "split", wrapLines: false });
|
||||
await startReviewOnFirstChangedLine(page, "right");
|
||||
const [editor, body] = await Promise.all([
|
||||
page.getByTestId("inline-review-editor").boundingBox(),
|
||||
@@ -1004,6 +1132,7 @@ test("canvas diff copies only the selected split side", async ({ context, page }
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await configureDiffPresentation(page, { layout: "split", wrapLines: false });
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
await setOpenChangesPresentation(page, { layout: "split", wrapLines: false });
|
||||
const before = await readSelectionPaintSamples(page, "right");
|
||||
await dragAddedTextRange(page, {
|
||||
lines,
|
||||
@@ -1027,6 +1156,7 @@ test("canvas diff copies exact wrapped fragments", async ({ context, page }) =>
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await configureDiffPresentation(page, { layout: "unified", wrapLines: true });
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
await setOpenChangesPresentation(page, { layout: "unified", wrapLines: true });
|
||||
await dragAddedTextRange(page, {
|
||||
lines: [content],
|
||||
wrapped: true,
|
||||
@@ -1151,6 +1281,31 @@ async function configureDiffPresentation(
|
||||
);
|
||||
}
|
||||
|
||||
async function setOpenChangesPresentation(
|
||||
page: Page,
|
||||
requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean },
|
||||
): Promise<void> {
|
||||
const options = page.getByRole("button", { name: "Diff options" }).filter({ visible: true });
|
||||
|
||||
await options.click();
|
||||
const layoutItem = page.getByTestId("changes-toggle-layout");
|
||||
const currentLayout = (await layoutItem.locator("svg").count()) === 2 ? "split" : "unified";
|
||||
if (currentLayout !== requestedPresentation.layout) {
|
||||
await layoutItem.click();
|
||||
} else {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
await options.click();
|
||||
const wrapItem = page.getByTestId("changes-toggle-wrap-lines");
|
||||
const currentWrapLines = (await wrapItem.locator("svg").count()) === 2;
|
||||
if (currentWrapLines !== requestedPresentation.wrapLines) {
|
||||
await wrapItem.click();
|
||||
} else {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
}
|
||||
|
||||
async function holdBrowserFontLoads(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const fontSet = document.fonts;
|
||||
@@ -1265,6 +1420,28 @@ async function createWorkspaceWithMountedTabDiff(
|
||||
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithCommittedDiff(): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("changes-committed-", {
|
||||
files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }],
|
||||
});
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup().catch(() => undefined);
|
||||
},
|
||||
});
|
||||
|
||||
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repo.path });
|
||||
await writeFile(path.join(repo.path, "committed-only.ts"), "export const committed = true;\n");
|
||||
execFileSync("git", ["add", "committed-only.ts"], { cwd: repo.path });
|
||||
execFileSync("git", ["commit", "-m", "Add committed-only file"], { cwd: repo.path });
|
||||
|
||||
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
|
||||
if (!created.workspace) throw new Error(created.error ?? "Failed to create committed workspace");
|
||||
return { id: created.workspace.id, repoPath: repo.path };
|
||||
}
|
||||
|
||||
async function createWorkspaceWithExactSelectionDiff(content: string): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("changes-canvas-selection-", {
|
||||
files: [{ path: "src/selection.ts", content: "" }],
|
||||
@@ -1290,6 +1467,13 @@ async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Prom
|
||||
await expectExpandedMountedTabDiff(page);
|
||||
}
|
||||
|
||||
async function openWorkspaceChangesSurface(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await openChangesPanel(page);
|
||||
}
|
||||
|
||||
async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
|
||||
|
||||
@@ -119,6 +119,7 @@ test.describe("desktop chat outline", () => {
|
||||
test("previews and jumps to the focused prompt from the keyboard", async ({ page }) => {
|
||||
await focusChatOutlinePrompt(page, 1);
|
||||
await expectChatOutlinePreview(page, agent.prompts[0]);
|
||||
await expectTimelinePromptNotMounted(page, agent.oldestPrompt);
|
||||
|
||||
await pressEnterOnFocusedPrompt(page);
|
||||
await expectTimelinePromptLandedBelowTop(page, agent.oldestPrompt);
|
||||
|
||||
@@ -51,6 +51,19 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
||||
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
|
||||
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
|
||||
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
|
||||
const [commitToolbarBox, layoutToggleBox, layoutToggleGlyphBox] = await Promise.all([
|
||||
panel.getByTestId("commit-diff-header").boundingBox(),
|
||||
layoutToggle.boundingBox(),
|
||||
layoutToggle.locator("svg").boundingBox(),
|
||||
]);
|
||||
if (!commitToolbarBox || !layoutToggleBox || !layoutToggleGlyphBox) {
|
||||
throw new Error("Commit-diff toolbar geometry could not be measured");
|
||||
}
|
||||
expect(commitToolbarBox.height).toBe(36);
|
||||
expect(layoutToggleBox.width).toBe(24);
|
||||
expect(layoutToggleBox.height).toBe(24);
|
||||
expect(layoutToggleGlyphBox.width).toBe(14);
|
||||
expect(layoutToggleGlyphBox.height).toBe(14);
|
||||
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||
await expect(panel.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||
await expectCommitDiffHeaderGeometry(panel);
|
||||
|
||||
@@ -77,7 +77,7 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), created.workspace.id));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await openChangesPanel(page);
|
||||
await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expectSingleCanvasSurface(page);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoWorkspace, pressDirectNewTabShortcut } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
test.describe("Direct terminal shortcut pane placement", () => {
|
||||
test("opens a terminal in the focused pane", async ({ page }) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "direct-terminal-shortcut-pane-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
await openChangesPanel(page);
|
||||
const focusedPane = page
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.filter({ has: page.getByTestId("workspace-tab-working_diff") })
|
||||
.first();
|
||||
const primaryPane = page
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.filter({ hasNot: page.getByTestId("workspace-tab-working_diff") })
|
||||
.first();
|
||||
await pressDirectNewTabShortcut(page, "t");
|
||||
|
||||
await expect(focusedPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(primaryPane.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { ensureExplorerPane, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
function visible(page: Page, testId: string): Locator {
|
||||
return page.getByTestId(testId).filter({ visible: true });
|
||||
@@ -37,9 +37,8 @@ function mainTabRow(page: Page): Locator {
|
||||
}
|
||||
|
||||
async function focusExplorerPane(page: Page): Promise<void> {
|
||||
await ensureExplorerPane(page);
|
||||
await openChangesPanel(page);
|
||||
const changesTab = visible(page, "workspace-tab-working_diff").first();
|
||||
await changesTab.click({ position: { x: 12, y: 13 } });
|
||||
await expect(changesTab).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
@@ -54,11 +53,15 @@ async function closeSeededDraftInMainPane(page: Page): Promise<void> {
|
||||
await expect(draftTabChip(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
async function emptyPaneBox(page: Page) {
|
||||
const paneChild = page
|
||||
function emptyPaneChild(page: Page): Locator {
|
||||
return page
|
||||
.getByTestId("split-group-child")
|
||||
.filter({ hasText: "No tabs in this pane." })
|
||||
.filter({ has: page.getByTestId("workspace-pane-empty-state") })
|
||||
.first();
|
||||
}
|
||||
|
||||
async function emptyPaneBox(page: Page) {
|
||||
const paneChild = emptyPaneChild(page);
|
||||
await expect(paneChild).toBeVisible({ timeout: 15_000 });
|
||||
const box = await paneChild.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
@@ -170,7 +173,7 @@ test.describe("explorer pane tab placement", () => {
|
||||
});
|
||||
// The split must have taken: agent pane + empty main + explorer = 3 tab rows.
|
||||
await expect(visible(page, "workspace-tabs-row")).toHaveCount(3, { timeout: 10_000 });
|
||||
await expect(page.getByText("No tabs in this pane.")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-pane-empty-state")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("app must stay interactive after the split", async () => {
|
||||
@@ -183,12 +186,10 @@ test.describe("explorer pane tab placement", () => {
|
||||
await chip.click({ position: { x: 12, y: 13 }, timeout: 5_000 });
|
||||
await expect(chip).toHaveAttribute("aria-selected", "true", { timeout: 5_000 });
|
||||
|
||||
// Clicking inside the empty pane (focuses it) must not lock anything.
|
||||
const emptyPane = page
|
||||
.getByTestId("split-group-child")
|
||||
.filter({ hasText: "No tabs in this pane." })
|
||||
.first();
|
||||
await emptyPane.click({ timeout: 5_000 });
|
||||
// Clicking inside the empty pane (focuses it) must not lock anything. Aim
|
||||
// above the vertically centred launcher, which would open a tab instead.
|
||||
const emptyPane = emptyPaneChild(page);
|
||||
await emptyPane.click({ position: { x: 40, y: 120 }, timeout: 5_000 });
|
||||
|
||||
// The empty pane's + menu must open and dismiss cleanly.
|
||||
const emptyPaneTrigger = emptyPane.getByTestId("workspace-new-tab-menu-trigger").first();
|
||||
@@ -207,7 +208,9 @@ test.describe("explorer pane tab placement", () => {
|
||||
});
|
||||
|
||||
// The agent chip must have moved into the previously empty pane and stay clickable.
|
||||
await expect(page.getByText("No tabs in this pane.")).toHaveCount(0, { timeout: 10_000 });
|
||||
await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(0, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await agentTabChip(page, agentId)
|
||||
.first()
|
||||
.click({ position: { x: 12, y: 13 } });
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// Reported on 0.5.0-beta.1 desktop: after upgrading, the old docked explorer
|
||||
// sidebar and the new explorer pane were both visible, and on Windows the
|
||||
// sidebar had no close button because the window controls own that corner.
|
||||
//
|
||||
// The sidebar rendered off a persisted `desktop.fileExplorerOpen` flag that no
|
||||
// migration cleared and that the rerouted explorer toggle no longer wrote. The
|
||||
// explorer is now one surface per layout, so that flag must not resurrect it.
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
function visible(page: Page, testId: string): Locator {
|
||||
return page.getByTestId(testId).filter({ visible: true });
|
||||
}
|
||||
|
||||
function readPersistedPanelState(page: Page): Promise<string | null> {
|
||||
return page.evaluate(() => localStorage.getItem("panel-state"));
|
||||
}
|
||||
|
||||
/** Only the docked sidebar ever rendered `explorer-header` on a wide layout. */
|
||||
function dockedSidebar(page: Page): Locator {
|
||||
return visible(page, "explorer-header");
|
||||
}
|
||||
|
||||
/** The desktop companion surface is a hideable side panel. */
|
||||
function sidePanel(page: Page): Locator {
|
||||
return visible(page, "workspace-side-panel");
|
||||
}
|
||||
|
||||
/** Seed exactly what a pre-pane build wrote after the user opened the sidebar once. */
|
||||
async function seedRetiredSidebarState(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"panel-state",
|
||||
JSON.stringify({
|
||||
version: 14,
|
||||
state: {
|
||||
desktop: { agentListOpen: true, fileExplorerOpen: true, focusModeEnabled: false },
|
||||
explorerTab: "changes",
|
||||
explorerTabByCheckout: {},
|
||||
expandedPathsByWorkspace: {},
|
||||
diffCollapsedFoldersByWorkspace: {},
|
||||
collapsedFilePathsByWorkspace: {},
|
||||
sidebarWidth: 340,
|
||||
explorerWidth: 400,
|
||||
explorerSortOption: "name",
|
||||
explorerShowHiddenFiles: true,
|
||||
treeRailWidth: 320,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("explorer surface after upgrading from the docked sidebar", () => {
|
||||
test("a persisted fileExplorerOpen flag no longer renders a second explorer", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-surface-upgrade-" });
|
||||
|
||||
try {
|
||||
await seedRetiredSidebarState(page);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
await test.step("no docked sidebar, on load or after toggling", async () => {
|
||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
||||
|
||||
const toggle = visible(page, "workspace-explorer-toggle").first();
|
||||
await toggle.click();
|
||||
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
|
||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
||||
|
||||
await toggle.click();
|
||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(dockedSidebar(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("the retired keys are migrated out of storage", async () => {
|
||||
const persisted = await readPersistedPanelState(page);
|
||||
expect(persisted).not.toBeNull();
|
||||
expect(persisted).not.toContain("fileExplorerOpen");
|
||||
expect(persisted).not.toContain("explorerWidth");
|
||||
// Everything else the user had must survive the migration.
|
||||
expect(persisted).toContain('"sidebarWidth":340');
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("toggle-both-sidebars closes and restores the agent list and the explorer", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-surface-both-" });
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
const agentList = visible(page, "sidebar-sessions");
|
||||
const toggle = visible(page, "workspace-explorer-toggle").first();
|
||||
|
||||
await test.step("open the explorer so both sides are showing", async () => {
|
||||
await toggle.click();
|
||||
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
|
||||
await expect(agentList).toHaveCount(1);
|
||||
});
|
||||
|
||||
await test.step("the shortcut collapses both", async () => {
|
||||
await page.keyboard.press(`${modifier}+.`);
|
||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(agentList).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("the shortcut restores both", async () => {
|
||||
await page.keyboard.press(`${modifier}+.`);
|
||||
await expect(agentList).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(sidePanel(page)).toHaveCount(1, { timeout: 15_000 });
|
||||
await expect(sidePanel(page).getByTestId("workspace-pane-empty-state")).toBeVisible();
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -117,7 +117,7 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
|
||||
const sourceEditor = editor(page);
|
||||
await sourceEditor.click();
|
||||
await sourceEditor.press("Control+Home");
|
||||
await sourceEditor.press("ControlOrMeta+Home");
|
||||
await expect(page.getByLabel(/^Line 1, column \d+$/)).toBeVisible();
|
||||
|
||||
await page
|
||||
@@ -241,13 +241,13 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
const initialModeBox = await modeControl.boundingBox();
|
||||
expect(initialModeBox).not.toBeNull();
|
||||
const initialModeX = initialModeBox!.x;
|
||||
await content.press("Control+End");
|
||||
await content.press("ControlOrMeta+End");
|
||||
await expect(page.getByLabel(/Line 12, column \d+/)).toBeVisible();
|
||||
const movedModeBox = await modeControl.boundingBox();
|
||||
expect(movedModeBox).not.toBeNull();
|
||||
expect(movedModeBox!.x).toBe(initialModeX);
|
||||
|
||||
await content.press("Control+a");
|
||||
await content.press("ControlOrMeta+a");
|
||||
const selection = editorHost.locator(".cm-selectionBackground").first();
|
||||
await expect(selection).toBeVisible();
|
||||
await expect(selection).toHaveCSS("background-color", "rgba(255, 255, 255, 0.2)");
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { delayFileReadResponse } from "../support/helpers/file-read-gate";
|
||||
import {
|
||||
expandFolder,
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
} from "../support/helpers/file-explorer";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
workspace = await seedWorkspace({
|
||||
repoPrefix: "file-transition-",
|
||||
repo: {
|
||||
files: [
|
||||
{ path: "src/a.ts", content: "export const sourceA = true;\n" },
|
||||
{ path: "src/b.ts", content: "export const sourceB = true;\n" },
|
||||
{ path: "docs/b.md", content: "# Markdown B\n" },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => workspace?.cleanup());
|
||||
|
||||
async function openSourceA(page: import("@playwright/test").Page) {
|
||||
await openFileExplorer(page);
|
||||
await expandFolder(page, "src");
|
||||
await openFileFromExplorer(page, "a.ts");
|
||||
await expect(page.getByText("export const sourceA = true;")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function assertHeldFileTransition(
|
||||
page: import("@playwright/test").Page,
|
||||
targetPath: string,
|
||||
finalText: string,
|
||||
gate: Awaited<ReturnType<typeof delayFileReadResponse>>,
|
||||
) {
|
||||
await page.evaluate((tabTestId) => {
|
||||
const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = [];
|
||||
const sample = () => {
|
||||
const next = {
|
||||
chip: Boolean(document.querySelector(`[data-testid="${tabTestId}"]`)),
|
||||
loading: Boolean(document.querySelector('[data-testid="file-preview-loading"]')),
|
||||
unavailable: Boolean(document.querySelector('[data-testid="file-preview-unsupported"]')),
|
||||
};
|
||||
if (JSON.stringify(timeline.at(-1)) !== JSON.stringify(next)) timeline.push(next);
|
||||
};
|
||||
sample();
|
||||
const observer = new MutationObserver(sample);
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
|
||||
(
|
||||
window as typeof window & { __fileTransitionTimeline?: typeof timeline }
|
||||
).__fileTransitionTimeline = timeline;
|
||||
(
|
||||
window as typeof window & { __fileTransitionObserver?: MutationObserver }
|
||||
).__fileTransitionObserver = observer;
|
||||
}, `workspace-tab-file_${targetPath}`);
|
||||
await openFileFromExplorer(page, targetPath.split("/").pop() as string);
|
||||
await gate.waitUntilHeld();
|
||||
|
||||
const targetChip = page.getByTestId(`workspace-tab-file_${targetPath}`).first();
|
||||
await expect(targetChip).toBeVisible();
|
||||
await expect(targetChip).toContainText(targetPath.split("/").pop() as string);
|
||||
await expect(page.getByTestId("file-preview-loading")).toBeVisible();
|
||||
await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0);
|
||||
await expect(page.getByText("export const sourceA = true;")).toHaveCount(0);
|
||||
|
||||
const heldTimeline = await page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as typeof window & {
|
||||
__fileTransitionTimeline?: Array<{
|
||||
chip: boolean;
|
||||
loading: boolean;
|
||||
unavailable: boolean;
|
||||
}>;
|
||||
}
|
||||
).__fileTransitionTimeline ?? [],
|
||||
);
|
||||
expect(heldTimeline).toContainEqual(expect.objectContaining({ chip: true, loading: true }));
|
||||
expect(heldTimeline).not.toContainEqual(expect.objectContaining({ unavailable: true }));
|
||||
const descriptorIndex = heldTimeline.findIndex((sample) => sample.chip);
|
||||
expect(heldTimeline.slice(descriptorIndex)).not.toContainEqual(
|
||||
expect.objectContaining({ chip: false }),
|
||||
);
|
||||
|
||||
gate.release();
|
||||
await expect(page.getByText(finalText)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("file-preview-loading")).toHaveCount(0);
|
||||
const timeline = await page.evaluate(() => {
|
||||
const windowWithTimeline = window as typeof window & {
|
||||
__fileTransitionTimeline?: Array<{ chip: boolean; loading: boolean; unavailable: boolean }>;
|
||||
__fileTransitionObserver?: MutationObserver;
|
||||
};
|
||||
windowWithTimeline.__fileTransitionObserver?.disconnect();
|
||||
return windowWithTimeline.__fileTransitionTimeline ?? [];
|
||||
});
|
||||
expect(
|
||||
timeline.filter((sample, index) => sample.loading && !timeline[index - 1]?.loading),
|
||||
).toHaveLength(1);
|
||||
}
|
||||
|
||||
test.describe("File target transitions", () => {
|
||||
test("keeps the Markdown descriptor and loading state stable through a real delayed read", async ({
|
||||
page,
|
||||
}) => {
|
||||
const gate = await delayFileReadResponse(page, "docs/b.md");
|
||||
await page.reload();
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openSourceA(page);
|
||||
await expandFolder(page, "docs");
|
||||
await assertHeldFileTransition(page, "docs/b.md", "Markdown B", gate);
|
||||
});
|
||||
|
||||
test("keeps the source descriptor and loading state stable through a real delayed read", async ({
|
||||
page,
|
||||
}) => {
|
||||
const gate = await delayFileReadResponse(page, "src/b.ts");
|
||||
await page.reload();
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openSourceA(page);
|
||||
await assertHeldFileTransition(page, "src/b.ts", "export const sourceB = true;", gate);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
// The file panel's tree rail had no way to close: the changes panel's toolbar
|
||||
// carries a folder-tree toggle, the file toolbar carried nothing, and the rail
|
||||
// was permanent on desktop.
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { openFileExplorer, openFileFromExplorer } from "../support/helpers/file-explorer";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
workspace = await seedWorkspace({
|
||||
repoPrefix: "file-tree-rail-toggle-",
|
||||
repo: { files: [{ path: "docs/guide.md", content: "# Guide\n" }] },
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test.describe("File panel tree rail", () => {
|
||||
test("the file toolbar toggle closes and reopens the tree", async ({ page }, testInfo) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, "docs");
|
||||
await openFileFromExplorer(page, "guide.md");
|
||||
|
||||
const rail = page.getByTestId("file-tree-rail-tree").filter({ visible: true });
|
||||
const toggle = page.getByTestId("file-toggle-tree").filter({ visible: true }).first();
|
||||
const editorToolbar = page.getByTestId("file-panel-bar").filter({ visible: true });
|
||||
const filesToolbar = page.getByTestId("files-pane-header").filter({ visible: true });
|
||||
const refresh = page.getByRole("button", { name: "Refresh files" }).filter({ visible: true });
|
||||
const hiddenFiles = page
|
||||
.getByRole("button", { name: "Hide hidden files" })
|
||||
.filter({ visible: true });
|
||||
const shoot = async (name: string) => {
|
||||
const path = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path });
|
||||
await testInfo.attach(name, { path, contentType: "image/png" });
|
||||
};
|
||||
await expect(rail).toBeVisible({ timeout: 30_000 });
|
||||
await expect(toggle).toHaveAttribute("aria-selected", "true");
|
||||
const [
|
||||
editorToolbarBox,
|
||||
filesToolbarBox,
|
||||
treeToggleBox,
|
||||
refreshBox,
|
||||
treeGlyphBox,
|
||||
refreshGlyphBox,
|
||||
] = await Promise.all([
|
||||
editorToolbar.boundingBox(),
|
||||
filesToolbar.boundingBox(),
|
||||
toggle.boundingBox(),
|
||||
refresh.boundingBox(),
|
||||
toggle.locator("svg").boundingBox(),
|
||||
refresh.locator("svg").boundingBox(),
|
||||
]);
|
||||
if (
|
||||
!editorToolbarBox ||
|
||||
!filesToolbarBox ||
|
||||
!treeToggleBox ||
|
||||
!refreshBox ||
|
||||
!treeGlyphBox ||
|
||||
!refreshGlyphBox
|
||||
) {
|
||||
throw new Error("Pane-content toolbar geometry could not be measured");
|
||||
}
|
||||
expect(editorToolbarBox.height).toBe(36);
|
||||
expect(filesToolbarBox.height).toBe(36);
|
||||
expect(editorToolbarBox.y + editorToolbarBox.height).toBe(
|
||||
filesToolbarBox.y + filesToolbarBox.height,
|
||||
);
|
||||
expect(treeToggleBox.width).toBe(24);
|
||||
expect(treeToggleBox.height).toBe(24);
|
||||
expect(refreshBox.width).toBe(24);
|
||||
expect(refreshBox.height).toBe(24);
|
||||
expect(treeGlyphBox.width).toBe(14);
|
||||
expect(treeGlyphBox.height).toBe(14);
|
||||
expect(refreshGlyphBox.width).toBe(14);
|
||||
expect(refreshGlyphBox.height).toBe(14);
|
||||
|
||||
const restBackground = await refresh.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
const treeToggleSelectedBackground = await toggle.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
await refresh.hover();
|
||||
const refreshHoverBackground = await refresh.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
expect(refreshHoverBackground).not.toBe(restBackground);
|
||||
expect(refreshHoverBackground).toBe(treeToggleSelectedBackground);
|
||||
await hiddenFiles.click();
|
||||
const hiddenFilesSelected = page
|
||||
.getByRole("button", { name: "Show hidden files" })
|
||||
.filter({ visible: true });
|
||||
expect(
|
||||
await hiddenFilesSelected.evaluate((element) => getComputedStyle(element).backgroundColor),
|
||||
).toBe(refreshHoverBackground);
|
||||
await shoot("tree-open");
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-selected", "false");
|
||||
await expect(rail).toHaveCount(0);
|
||||
await shoot("tree-closed");
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-selected", "true");
|
||||
await expect(rail).toBeVisible();
|
||||
await shoot("tree-reopened");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { submitMessage } from "../support/helpers/composer";
|
||||
import { waitForPermissionPrompt } from "../support/helpers/permissions";
|
||||
import { cleanupRewindFlow, type AgentHandle, launchAgent } from "../support/helpers/rewind-flow";
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
provider: "claude" as const,
|
||||
providerConfig: { model: "haiku", modeId: "plan" },
|
||||
planPrompt:
|
||||
"Do not ask questions. Produce a concise plan to append the line 'Hello' to README.md, then present it for approval. Do not implement it.",
|
||||
planText: "README.md",
|
||||
steerPrompt: "Do not implement anything. Reply exactly CLAUDE_STEER_RECEIVED and stop.",
|
||||
reply: "CLAUDE_STEER_RECEIVED",
|
||||
},
|
||||
{
|
||||
provider: "codex" as const,
|
||||
providerConfig: { featureValues: { plan_mode: true } },
|
||||
planPrompt:
|
||||
"Produce a concise implementation plan with exactly these steps: Inspect permission steering; Implement permission steering; Verify permission steering. Do not implement it.",
|
||||
planText: "Inspect permission steering",
|
||||
steerPrompt: "Do not produce another plan. Reply exactly CODEX_STEER_RECEIVED and stop.",
|
||||
reply: "CODEX_STEER_RECEIVED",
|
||||
},
|
||||
];
|
||||
|
||||
test.describe("composer steer supersedes plan approval", () => {
|
||||
for (const scenario of scenarios) {
|
||||
test(`${scenario.provider} keeps the rejected plan in the timeline`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(420_000);
|
||||
const cwd = realpathSync(
|
||||
mkdtempSync(path.join(tmpdir(), `paseo-permission-steer-${scenario.provider}-`)),
|
||||
);
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: scenario.provider,
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: scenario.providerConfig,
|
||||
});
|
||||
await submitMessage(page, scenario.planPrompt);
|
||||
await waitForPermissionPrompt(page, 180_000);
|
||||
|
||||
const pendingPlan = page.getByTestId("permission-plan-card");
|
||||
await expect(pendingPlan).toContainText(scenario.planText);
|
||||
const pendingScreenshot = testInfo.outputPath(`${scenario.provider}-pending-plan.png`);
|
||||
await page.screenshot({ path: pendingScreenshot });
|
||||
await testInfo.attach(`${scenario.provider} pending plan`, {
|
||||
path: pendingScreenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await submitMessage(page, scenario.steerPrompt);
|
||||
await expect(pendingPlan).toHaveCount(0, { timeout: 30_000 });
|
||||
const rejectedPlan = page.getByTestId("timeline-plan-card");
|
||||
await expect(rejectedPlan).toBeVisible({ timeout: 30_000 });
|
||||
await expect(rejectedPlan).toContainText(scenario.planText);
|
||||
await expect(
|
||||
page.getByTestId("assistant-message").filter({ hasText: scenario.reply }),
|
||||
).toBeVisible({
|
||||
timeout: 180_000,
|
||||
});
|
||||
|
||||
const rejectedScreenshot = testInfo.outputPath(`${scenario.provider}-rejected-plan.png`);
|
||||
await page.screenshot({ path: rejectedScreenshot });
|
||||
await testInfo.attach(`${scenario.provider} rejected plan`, {
|
||||
path: rejectedScreenshot,
|
||||
contentType: "image/png",
|
||||
});
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspace";
|
||||
import { openSettingsSection } from "../support/helpers/settings";
|
||||
|
||||
const PLUGIN_ID = "plugin-theme-e2e";
|
||||
|
||||
// Catppuccin Mocha: base, text, surface0, surface1, mauve, subtext0, overlay0.
|
||||
const PLUGIN_SOURCE = `export default function contribute(plugin) {
|
||||
plugin.addTheme({
|
||||
id: "mocha",
|
||||
name: "Catppuccin Mocha",
|
||||
appearance: "dark",
|
||||
colors: {
|
||||
background: "#1e1e2e",
|
||||
foreground: "#cdd6f4",
|
||||
raised: "#313244",
|
||||
control: "#45475a",
|
||||
border: "#45475a",
|
||||
accent: "#cba6f7",
|
||||
mutedForeground: "#a6adc8",
|
||||
ring: "#6c7086",
|
||||
},
|
||||
});
|
||||
plugin.addTheme({
|
||||
id: "latte",
|
||||
name: "Catppuccin Latte",
|
||||
appearance: "light",
|
||||
colors: {
|
||||
background: "#eff1f5",
|
||||
foreground: "#4c4f69",
|
||||
raised: "#e6e9ef",
|
||||
control: "#dce0e8",
|
||||
border: "#ccd0da",
|
||||
accent: "#8839ef",
|
||||
mutedForeground: "#6c6f85",
|
||||
ring: "#9ca0b0",
|
||||
},
|
||||
});
|
||||
return () => {};
|
||||
}`;
|
||||
|
||||
// settingsStyles.sectionHeaderTitle paints from foregroundMuted, so the section heading proves the
|
||||
// contributed palette reached the semantic tokens rather than just the swatch.
|
||||
const MOCHA_MUTED_FOREGROUND = "rgb(166, 173, 200)";
|
||||
const LATTE_MUTED_FOREGROUND = "rgb(108, 111, 133)";
|
||||
|
||||
test("applies a contributed theme and falls back when its plugin is gone", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-theme-e2e-"));
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||
await writeFile(path.join(directory, "index.ts"), PLUGIN_SOURCE);
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory);
|
||||
await page.goto("/settings");
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await openSettingsSection(page, "appearance");
|
||||
|
||||
const sectionTitle = page.getByText("Theme", { exact: true }).first();
|
||||
await page.getByLabel("Theme: System", { exact: true }).click();
|
||||
const mochaItem = page.getByText("Catppuccin Mocha", { exact: true });
|
||||
await expect(mochaItem).toBeVisible({ timeout: 30_000 });
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("plugin-theme-picker.png"),
|
||||
animations: "disabled",
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await test.step("a contributed light theme uses the light palette", async () => {
|
||||
await page.getByText("Catppuccin Latte", { exact: true }).click();
|
||||
await expect(page.getByLabel("Theme: Catppuccin Latte", { exact: true })).toBeVisible();
|
||||
await expect(sectionTitle).toHaveCSS("color", LATTE_MUTED_FOREGROUND);
|
||||
await page.getByLabel("Theme: Catppuccin Latte", { exact: true }).click();
|
||||
});
|
||||
|
||||
await mochaItem.click();
|
||||
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible();
|
||||
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("plugin-theme-applied.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
|
||||
await test.step("the selection survives a reload", async () => {
|
||||
await page.reload();
|
||||
await expect(page.getByLabel("Theme: Catppuccin Mocha", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(sectionTitle).toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
||||
});
|
||||
|
||||
await test.step("removing the plugin falls back to the default theme", async () => {
|
||||
await client.removePlugin(PLUGIN_ID);
|
||||
await expect(page.getByLabel("Theme: System", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(sectionTitle).not.toHaveCSS("color", MOCHA_MUTED_FOREGROUND);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("plugin-theme-fallback.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await client.removePlugin(PLUGIN_ID).catch(() => undefined);
|
||||
await client
|
||||
.patchDaemonConfig({ pluginsEnabled: previousConfig.config.pluginsEnabled ?? false })
|
||||
.catch(() => undefined);
|
||||
await client.close().catch(() => undefined);
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -31,7 +31,7 @@ function isSettledWorkspaceUrl(url: URL): boolean {
|
||||
function pluginSource(): string {
|
||||
return `import React, { useRef } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useAgent, useWorkspace } from "@paseo/plugin";
|
||||
import { useAgent, useWorkspace } from "@getpaseo/plugin";
|
||||
|
||||
function WorkspacePanel({ workspaceId, host, layout }) {
|
||||
const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id }));
|
||||
|
||||
@@ -70,9 +70,28 @@ test.describe("Settings toggle tab regression", () => {
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
|
||||
await page.getByRole("button", { name: "Queue", exact: true }).click();
|
||||
const defaultSendTrigger = page.getByRole("button", {
|
||||
name: "Default send: Steer",
|
||||
exact: true,
|
||||
});
|
||||
await expect(defaultSendTrigger).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "Queue", exact: true })).toHaveCount(0);
|
||||
|
||||
await defaultSendTrigger.click();
|
||||
await expect(page.getByRole("menuitem", { name: "Steer", exact: true })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
await page.getByRole("menuitem", { name: "Queue", exact: true }).click();
|
||||
await expectSendBehavior(page, "queue");
|
||||
await page.getByRole("button", { name: "Interrupt", exact: true }).click();
|
||||
const queuedDefaultSendTrigger = page.getByRole("button", {
|
||||
name: "Default send: Queue",
|
||||
exact: true,
|
||||
});
|
||||
await expect(queuedDefaultSendTrigger).toBeVisible();
|
||||
|
||||
await queuedDefaultSendTrigger.click();
|
||||
await page.getByRole("menuitem", { name: "Interrupt", exact: true }).click();
|
||||
await expectSendBehavior(page, "interrupt");
|
||||
|
||||
await pressSettingsToggleShortcut(page);
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { expect, type Locator, type Page, type TestInfo } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { ensureSidePanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
const path = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path });
|
||||
await testInfo.attach(name, { path, contentType: "image/png" });
|
||||
}
|
||||
|
||||
function sidePanel(page: Page): Locator {
|
||||
return page.getByTestId("workspace-side-panel").filter({ visible: true });
|
||||
}
|
||||
|
||||
function mainPane(page: Page): Locator {
|
||||
return page.getByTestId("workspace-pane-main").filter({ visible: true });
|
||||
}
|
||||
|
||||
function emptyState(scope: Locator): Locator {
|
||||
return scope.getByTestId("workspace-pane-empty-state");
|
||||
}
|
||||
|
||||
function sidePanelToggle(page: Page): Locator {
|
||||
return page.getByTestId("workspace-explorer-toggle").first();
|
||||
}
|
||||
|
||||
function closePaneButton(page: Page): Locator {
|
||||
return page.getByRole("button", { name: "Close pane" });
|
||||
}
|
||||
|
||||
/** Drags the pane's only tab into the left edge of its own pane, splitting it out. */
|
||||
async function splitTabOutOfMainPane(page: Page, chip: Locator): Promise<void> {
|
||||
const paneBox = await mainPane(page).boundingBox();
|
||||
const chipBox = await chip.boundingBox();
|
||||
expect(paneBox).not.toBeNull();
|
||||
expect(chipBox).not.toBeNull();
|
||||
if (!paneBox || !chipBox) throw new Error("Unable to measure the main pane");
|
||||
|
||||
await page.mouse.move(chipBox.x + chipBox.width / 2, chipBox.y + chipBox.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(chipBox.x + chipBox.width / 2 + 12, chipBox.y + chipBox.height / 2 + 4);
|
||||
await page.mouse.move(paneBox.x + paneBox.width * 0.06, paneBox.y + paneBox.height * 0.6, {
|
||||
steps: 20,
|
||||
});
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
test.describe("Side panel", () => {
|
||||
test("reveals empty, launches a view, and hides back out of the way", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-reveal-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(sidePanel(page)).toHaveCount(0);
|
||||
|
||||
await test.step("revealing the Side panel seeds nothing", async () => {
|
||||
await sidePanelToggle(page).click();
|
||||
await expect(sidePanel(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expect(emptyState(sidePanel(page))).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(0);
|
||||
await capture(page, testInfo, "01-side-panel-reveals-empty");
|
||||
});
|
||||
|
||||
await test.step("Changes opens in the Side panel that offered it", async () => {
|
||||
await emptyState(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
|
||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await capture(page, testInfo, "02-changes-opens-in-side-panel");
|
||||
});
|
||||
|
||||
await test.step("a loaded pane offers no one-tap close", async () => {
|
||||
await expect(closePaneButton(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("closing its final tab hides the Side panel, and it reopens empty", async () => {
|
||||
const changesTab = sidePanel(page).getByTestId("workspace-tab-working_diff");
|
||||
await changesTab.hover();
|
||||
await page
|
||||
.locator('[data-testid^="workspace-working-diff-close-"]')
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
await capture(page, testInfo, "03-side-panel-hidden-after-final-tab-close");
|
||||
|
||||
await sidePanelToggle(page).click();
|
||||
await expect(sidePanel(page)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(emptyState(sidePanel(page))).toBeVisible();
|
||||
await capture(page, testInfo, "04-side-panel-revealed-empty");
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("an emptied ordinary pane is removed rather than hidden", async ({ page }, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-close-main-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureSidePanel(page);
|
||||
|
||||
// Dragging the pane's only tab into its own left edge splits that tab into a
|
||||
// new pane and leaves the one it came from empty.
|
||||
const draftTab = page
|
||||
.locator('[data-testid^="workspace-tab-draft_"]')
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(draftTab).toBeVisible({ timeout: 30_000 });
|
||||
await splitTabOutOfMainPane(page, draftTab);
|
||||
const draftPane = page
|
||||
.locator('[data-testid^="workspace-pane-pane_"]')
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(draftPane).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const emptyMain = emptyState(mainPane(page));
|
||||
await expect(emptyMain).toBeVisible({ timeout: 15_000 });
|
||||
await capture(page, testInfo, "06-empty-main-pane-launcher");
|
||||
|
||||
await emptyMain.getByRole("button", { name: "Close pane" }).click();
|
||||
|
||||
// Gone for good, unlike the Side panel beside it, which is still only hidden away.
|
||||
await expect(mainPane(page)).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(sidePanel(page)).toBeVisible();
|
||||
await expect(draftPane.getByTestId("workspace-pane-empty-state")).toHaveCount(0);
|
||||
await capture(page, testInfo, "07-empty-main-pane-removed");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("the last pane on screen offers no Close pane", async ({ page }, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-final-pane-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
// Park a tab in the Side panel, then put the panel away. The workspace still
|
||||
// holds that tab, so emptying Main does not look like an empty workspace and
|
||||
// nothing reseeds a draft into it.
|
||||
await ensureSidePanel(page);
|
||||
await emptyState(sidePanel(page)).getByRole("button", { name: "Files" }).click();
|
||||
await expect(sidePanel(page).getByTestId("workspace-tab-files")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await sidePanelToggle(page).click();
|
||||
await expect(sidePanel(page)).toHaveCount(0, { timeout: 15_000 });
|
||||
|
||||
const draftTab = page
|
||||
.locator('[data-testid^="workspace-tab-draft_"]')
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await draftTab.hover();
|
||||
await page
|
||||
.locator('[data-testid^="workspace-draft-close-"]')
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Main is now empty and the only pane the user can see. Taking it away would
|
||||
// leave nowhere to look, so the launcher appears without a Close pane.
|
||||
const lastPane = emptyState(mainPane(page));
|
||||
await expect(lastPane).toBeVisible({ timeout: 15_000 });
|
||||
await expect(lastPane.getByRole("button", { name: "Files" })).toBeVisible();
|
||||
await expect(lastPane.getByRole("button", { name: "Close pane" })).toHaveCount(0);
|
||||
await expect(closePaneButton(page)).toHaveCount(0);
|
||||
await capture(page, testInfo, "08-final-visible-pane-has-no-close");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a pane's own menu creates an independent view when one is open elsewhere", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "side-panel-claim-" });
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await ensureSidePanel(page);
|
||||
await emptyState(sidePanel(page)).getByRole("button", { name: "Changes" }).click();
|
||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await capture(page, testInfo, "08-changes-in-side-panel");
|
||||
|
||||
await mainPane(page).getByTestId("workspace-new-tab-menu-trigger").click();
|
||||
await page
|
||||
.getByTestId("workspace-new-tab-menu-changes")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(sidePanel(page).getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
||||
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
||||
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(2);
|
||||
await capture(page, testInfo, "09-independent-changes-in-both-panes");
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
closeSidebarDisplayPreferences,
|
||||
openSidebarProjectFilter,
|
||||
pinWorkspaceFromSidebar,
|
||||
selectAllProjectsFilter,
|
||||
selectSidebarStatusGrouping,
|
||||
toggleProjectFilter,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
test.describe("Sidebar project filter", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("pins the sidebar to one project across both grouping modes", async ({ page }) => {
|
||||
// Two temp repos means two projects, which is also what makes the `Project ›` row appear.
|
||||
const alpha = await seedWorkspace({ repoPrefix: "project-filter-alpha-", title: "Alpha work" });
|
||||
const beta = await seedWorkspace({ repoPrefix: "project-filter-beta-", title: "Beta work" });
|
||||
const serverId = getServerId();
|
||||
const alphaRow = page.getByTestId(`sidebar-workspace-row-${serverId}:${alpha.workspaceId}`);
|
||||
const betaRow = page.getByTestId(`sidebar-workspace-row-${serverId}:${beta.workspaceId}`);
|
||||
const filterTrigger = page.getByTestId("sidebar-display-project-filter");
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await expect(alphaRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(betaRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openSidebarProjectFilter(page);
|
||||
await expect(page.getByTestId("sidebar-project-filter-all")).toBeVisible();
|
||||
await toggleProjectFilter(page, alpha.projectKey);
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
|
||||
await expect(alphaRow).toBeVisible();
|
||||
await expect(betaRow).toHaveCount(0, { timeout: 10_000 });
|
||||
|
||||
// The indicator reads the filter as it is applied, so it must be on here.
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(filterTrigger).toBeVisible();
|
||||
await expect(filterTrigger.getByTestId("menu-sub-indicator")).toBeVisible();
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
|
||||
// Status grouping builds its rows from the workspace entries rather than the projects
|
||||
// array, so a filter applied in only one of the two places passes every check above and
|
||||
// silently fails right here.
|
||||
await selectSidebarStatusGrouping(page);
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(alphaRow).toBeVisible({ timeout: 15_000 });
|
||||
await expect(betaRow).toHaveCount(0);
|
||||
|
||||
// The filter is view state and survives a cold load.
|
||||
await page.reload();
|
||||
await expect(alphaRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(betaRow).toHaveCount(0);
|
||||
|
||||
await openSidebarProjectFilter(page);
|
||||
await selectAllProjectsFilter(page);
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(alphaRow).toBeVisible();
|
||||
await expect(betaRow).toBeVisible({ timeout: 15_000 });
|
||||
} finally {
|
||||
await beta.cleanup();
|
||||
await alpha.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the display menu reachable when the pinned section swallows the filtered project", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Pinning hoists a chat out of its project, and a project whose chats are ALL hoisted is
|
||||
// dropped from the project list entirely. Filter to that project and the list body has no
|
||||
// project rows left — so the header, which carries the only route back to the filter page,
|
||||
// has to survive on the strength of the filter alone.
|
||||
const alpha = await seedWorkspace({
|
||||
repoPrefix: "project-filter-pinned-",
|
||||
title: "Pinned work",
|
||||
});
|
||||
const beta = await seedWorkspace({ repoPrefix: "project-filter-other-", title: "Other work" });
|
||||
const serverId = getServerId();
|
||||
const alphaRow = page.getByTestId(`sidebar-workspace-row-${serverId}:${alpha.workspaceId}`);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await expect(alphaRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openSidebarProjectFilter(page);
|
||||
await toggleProjectFilter(page, alpha.projectKey);
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
|
||||
await pinWorkspaceFromSidebar(page, alpha.workspaceId);
|
||||
await expect(alphaRow).toBeVisible();
|
||||
|
||||
// The way out of the filter is still on screen.
|
||||
await expect(page.getByTestId("sidebar-display-preferences-menu")).toBeVisible();
|
||||
await openSidebarProjectFilter(page);
|
||||
await selectAllProjectsFilter(page);
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${serverId}:${beta.workspaceId}`),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
} finally {
|
||||
await beta.cleanup();
|
||||
await alpha.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("hides the filter row when there is only one project", async ({ page }) => {
|
||||
const only = await seedWorkspace({ repoPrefix: "project-filter-solo-", title: "Solo work" });
|
||||
const serverId = getServerId();
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${serverId}:${only.workspaceId}`),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-display-project-filter")).toHaveCount(0);
|
||||
} finally {
|
||||
await only.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -343,7 +343,7 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await openFilesPanel(page);
|
||||
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible();
|
||||
await expect(explorerToggle).toHaveAccessibleName("Close explorer");
|
||||
await expect(explorerToggle).toHaveAccessibleName("Close side panel");
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tabs-row").filter({ visible: true })).toHaveCount(2);
|
||||
|
||||
@@ -351,7 +351,7 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(explorerToggle).toHaveAccessibleName("Open explorer");
|
||||
await expect(explorerToggle).toHaveAccessibleName("Open side panel");
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
const COMPACT_VIEWPORT = { width: 390, height: 520 };
|
||||
|
||||
function parseSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
|
||||
return envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||
? (envelope.message as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getToolCallStatus(
|
||||
message: WebSocketMessage,
|
||||
agentId: string,
|
||||
): { callId: string; status: string } | null {
|
||||
const sessionMessage = parseSessionMessage(message);
|
||||
if (sessionMessage?.type !== "agent_stream") {
|
||||
return null;
|
||||
}
|
||||
const payload = sessionMessage.payload as Record<string, unknown> | undefined;
|
||||
if (payload?.agentId !== agentId) {
|
||||
return null;
|
||||
}
|
||||
const event = payload.event as Record<string, unknown> | undefined;
|
||||
const item = event?.item as Record<string, unknown> | undefined;
|
||||
return event?.type === "timeline" &&
|
||||
item?.type === "tool_call" &&
|
||||
typeof item.callId === "string" &&
|
||||
typeof item.status === "string"
|
||||
? { callId: item.callId, status: item.status }
|
||||
: null;
|
||||
}
|
||||
|
||||
function cloneToolCallWithId(
|
||||
message: WebSocketMessage,
|
||||
callId: string,
|
||||
sequenceOffset: number,
|
||||
): string {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
const envelope = JSON.parse(raw) as {
|
||||
message?: { payload?: { seq?: number; event?: { item?: { callId?: string } } } };
|
||||
};
|
||||
const payload = envelope.message?.payload;
|
||||
const item = payload?.event?.item;
|
||||
if (!payload || !item?.callId || typeof payload.seq !== "number") {
|
||||
throw new Error("Expected a tool-call session message");
|
||||
}
|
||||
item.callId = callId;
|
||||
payload.seq += sequenceOffset;
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
function shiftTimelineSequence(
|
||||
message: WebSocketMessage,
|
||||
sequenceOffset: number,
|
||||
): WebSocketMessage {
|
||||
if (sequenceOffset === 0) {
|
||||
return message;
|
||||
}
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(raw) as { message?: { payload?: { seq?: number } } };
|
||||
const payload = envelope.message?.payload;
|
||||
if (typeof payload?.seq !== "number") {
|
||||
return message;
|
||||
}
|
||||
payload.seq += sequenceOffset;
|
||||
return JSON.stringify(envelope);
|
||||
} catch {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
async function holdStreamAfterFirstCompletedToolCall(page: Page, agentId: string) {
|
||||
let firstCallId: string | null = null;
|
||||
let firstCompletedMessage: WebSocketMessage | null = null;
|
||||
let isHolding = false;
|
||||
let sequenceOffset = 0;
|
||||
const heldMessages: WebSocketMessage[] = [];
|
||||
let sendToPage: ((message: WebSocketMessage) => void) | null = null;
|
||||
let resolveFirstCompleted!: () => void;
|
||||
const firstCompleted = new Promise<void>((resolve) => {
|
||||
resolveFirstCompleted = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
sendToPage = (message) => ws.send(message);
|
||||
ws.onMessage((message) => server.send(message));
|
||||
server.onMessage((message) => {
|
||||
if (isHolding) {
|
||||
heldMessages.push(message);
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(shiftTimelineSequence(message, sequenceOffset));
|
||||
const toolCall = getToolCallStatus(message, agentId);
|
||||
firstCallId ??= toolCall?.callId ?? null;
|
||||
if (toolCall?.callId === firstCallId && toolCall.status === "completed") {
|
||||
firstCompletedMessage = message;
|
||||
isHolding = true;
|
||||
resolveFirstCompleted();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
waitForFirstCompleted: () => firstCompleted,
|
||||
release(extraCompletedCalls = 0) {
|
||||
isHolding = false;
|
||||
if (extraCompletedCalls > 0 && (!firstCallId || !firstCompletedMessage)) {
|
||||
throw new Error("Expected the first completed tool call before release");
|
||||
}
|
||||
for (let index = 0; index < extraCompletedCalls; index += 1) {
|
||||
sendToPage?.(
|
||||
cloneToolCallWithId(
|
||||
firstCompletedMessage as WebSocketMessage,
|
||||
`${firstCallId}:${index}`,
|
||||
index + 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
sequenceOffset = extraCompletedCalls;
|
||||
for (const message of heldMessages.splice(0)) {
|
||||
sendToPage?.(shiftTimelineSequence(message, sequenceOffset));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function configureOverviewToolCalls(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function createOverviewAgent(page: Page, title: string) {
|
||||
await configureOverviewToolCalls(page);
|
||||
return seedMockAgentWorkspace({
|
||||
repoPrefix: "tool-call-overview-sheet-",
|
||||
title,
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
}
|
||||
|
||||
async function openOverviewAgent(
|
||||
page: Page,
|
||||
agent: Awaited<ReturnType<typeof seedMockAgentWorkspace>>,
|
||||
): Promise<void> {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: agent.workspaceId,
|
||||
agentId: agent.agentId,
|
||||
});
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
function hasScrolledToLatest(root: HTMLElement): boolean {
|
||||
for (const node of root.querySelectorAll<HTMLElement>("*")) {
|
||||
if (node.scrollHeight <= node.clientHeight) {
|
||||
continue;
|
||||
}
|
||||
const maximumScrollTop = node.scrollHeight - node.clientHeight;
|
||||
return maximumScrollTop > 0 && Math.abs(node.scrollTop - maximumScrollTop) <= 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function readScrollMetrics(root: HTMLElement) {
|
||||
const badgeElements = root.querySelectorAll<HTMLElement>("[data-testid=tool-call-badge]");
|
||||
const latestBadge = badgeElements.item(badgeElements.length - 1);
|
||||
const scrollables = [];
|
||||
for (const node of root.querySelectorAll<HTMLElement>("*")) {
|
||||
if (node.scrollHeight <= node.clientHeight) {
|
||||
continue;
|
||||
}
|
||||
scrollables.push({
|
||||
className: node.className,
|
||||
clientHeight: node.clientHeight,
|
||||
rect: node.getBoundingClientRect().toJSON(),
|
||||
scrollHeight: node.scrollHeight,
|
||||
scrollTop: node.scrollTop,
|
||||
tagName: node.tagName,
|
||||
});
|
||||
}
|
||||
return {
|
||||
latestBadgeRect: latestBadge?.getBoundingClientRect().toJSON(),
|
||||
rootRect: root.getBoundingClientRect().toJSON(),
|
||||
scrollables,
|
||||
viewport: { height: window.innerHeight, width: window.innerWidth },
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("compact overview tool calls", () => {
|
||||
test.use({ viewport: COMPACT_VIEWPORT, hasTouch: true });
|
||||
|
||||
test("keeps a live group sheet current and stacks individual details above it", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await createOverviewAgent(page, "Compact overview tool calls");
|
||||
|
||||
try {
|
||||
const gate = await holdStreamAfterFirstCompletedToolCall(page, agent.agentId);
|
||||
await openOverviewAgent(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Exercise the overview tool-call sheet.");
|
||||
await gate.waitForFirstCompleted();
|
||||
|
||||
const group = page.getByTestId("tool-call-group").first();
|
||||
await expect(group).toBeVisible();
|
||||
await group.click();
|
||||
|
||||
const sheet = page.getByTestId("tool-call-group-sheet");
|
||||
const summary = page.getByTestId("tool-call-group-sheet-summary");
|
||||
await expect(sheet).toBeVisible();
|
||||
const initialSummary = await summary.innerText();
|
||||
|
||||
gate.release(10);
|
||||
const badges = sheet.getByTestId("tool-call-badge");
|
||||
await expect.poll(() => badges.count(), { timeout: 30_000 }).toBeGreaterThanOrEqual(11);
|
||||
await expect(summary).not.toHaveText(initialSummary);
|
||||
await expect.poll(() => sheet.evaluate(hasScrolledToLatest)).toBe(true);
|
||||
const scrollMetrics = await sheet.evaluate(readScrollMetrics);
|
||||
await testInfo.attach("compact-overview-scroll-metrics", {
|
||||
body: JSON.stringify(scrollMetrics, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
|
||||
await badges.last().click();
|
||||
const detailClose = page.getByTestId("tool-call-sheet-close");
|
||||
await expect(detailClose).toBeVisible();
|
||||
|
||||
await detailClose.click();
|
||||
await expect(detailClose).toBeHidden();
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
await testInfo.attach("compact-overview-tool-call-group", {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await page.getByTestId("tool-call-group-sheet-close").click();
|
||||
await expect(sheet).toBeHidden();
|
||||
await expect(group).toBeVisible();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps overview tool calls inline on desktop", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const agent = await createOverviewAgent(page, "Desktop overview tool calls");
|
||||
|
||||
try {
|
||||
await openOverviewAgent(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Exercise desktop overview tool calls.");
|
||||
const group = page.getByTestId("tool-call-group").first();
|
||||
await expect(group).toBeVisible();
|
||||
|
||||
await group.click();
|
||||
await expect(page.getByTestId("tool-call-group-sheet")).toHaveCount(0);
|
||||
await expect(group.getByTestId("tool-call-badge").first()).toBeVisible();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -266,7 +266,7 @@ test.describe("Workspace labels", () => {
|
||||
|
||||
// Clear restores the complete sidebar without leaving the filter page.
|
||||
await page.getByTestId("sidebar-label-filter-clear").click();
|
||||
await expect(page.getByTestId("sidebar-label-filter-empty-state")).toBeHidden();
|
||||
await expect(page.getByTestId("sidebar-filter-empty-state")).toBeHidden();
|
||||
await expect(page.getByTestId("sidebar-label-filter-clear")).toBeHidden();
|
||||
await expect(labelledRow).toBeVisible();
|
||||
await expect(unlabelledRow).toBeVisible();
|
||||
|
||||
@@ -65,12 +65,14 @@ test.describe("Workspace pane mounting", () => {
|
||||
.first()
|
||||
.elementHandle();
|
||||
expect(originalComposer).not.toBeNull();
|
||||
const emptyPanesBeforeSplit = await page.getByTestId("workspace-pane-empty-state").count();
|
||||
|
||||
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(
|
||||
2,
|
||||
await expect(page.getByTestId("workspace-pane-empty-state")).toHaveCount(
|
||||
emptyPanesBeforeSplit + 1,
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(page.getByTestId("message-input-root").filter({ visible: true })).toHaveCount(1);
|
||||
|
||||
const originalStillConnected = await originalComposer!.evaluate((node) => node.isConnected);
|
||||
expect(originalStillConnected).toBe(true);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import {
|
||||
closeSetupTab,
|
||||
waitForWorkspaceTabsVisible,
|
||||
expectFailedSetupTabSeededInMainPane,
|
||||
expectSetupTabNotSeeded,
|
||||
expectNoTerminalTabs,
|
||||
clickFirstTerminalTab,
|
||||
expectFirstTerminalTabContains,
|
||||
@@ -19,6 +22,7 @@ import {
|
||||
expectSetupPanel,
|
||||
openHomeWithProject,
|
||||
navigateToWorkspaceViaSidebar,
|
||||
returnHomeFromWorkspace,
|
||||
openWorkspaceScriptsMenu,
|
||||
startWorkspaceScriptFromMenu,
|
||||
closeWorkspaceScriptsMenu,
|
||||
@@ -39,7 +43,7 @@ interface WorkspaceScriptStarter {
|
||||
}
|
||||
|
||||
test.describe("Workspace setup streaming", () => {
|
||||
test("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => {
|
||||
test("does not seed the setup tab while workspace setup is running", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-open-", {
|
||||
paseoConfig: {
|
||||
@@ -60,7 +64,7 @@ test.describe("Workspace setup streaming", () => {
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
|
||||
await expectSetupPanel(page);
|
||||
await expectSetupTabNotSeeded(page, workspace.id);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
@@ -99,6 +103,8 @@ test.describe("Workspace setup streaming", () => {
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
|
||||
await expectSetupTabNotSeeded(page, workspace.id);
|
||||
await expectSetupPanel(page);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page, { timeout: 30_000 });
|
||||
@@ -156,7 +162,7 @@ test.describe("Workspace setup streaming", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("streams a failed setup snapshot when setup fails", async () => {
|
||||
test("seeds a failed setup tab once in the main pane", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-failure-", {
|
||||
paseoConfig: {
|
||||
@@ -173,7 +179,7 @@ test.describe("Workspace setup streaming", () => {
|
||||
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
|
||||
);
|
||||
|
||||
await createWorkspaceThroughDaemon(client, {
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "workspace-setup-failure",
|
||||
});
|
||||
@@ -182,6 +188,16 @@ test.describe("Workspace setup streaming", () => {
|
||||
expect(failedPayload.detail.log).toContain("starting setup");
|
||||
expect(failedPayload.detail.log).toContain("setup failed");
|
||||
expect(failedPayload.error).toMatch(/failed/i);
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectFailedSetupTabSeededInMainPane(page, workspace.id);
|
||||
|
||||
await closeSetupTab(page, workspace.id);
|
||||
await returnHomeFromWorkspace(page);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
await expectSetupTabNotSeeded(page, workspace.id);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function fileReadRequest(message: WebSocketMessage, path: string): boolean {
|
||||
const raw = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
const envelope = JSON.parse(raw) as { type?: unknown; message?: unknown };
|
||||
const session =
|
||||
envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||
? (envelope.message as { type?: unknown; mode?: unknown; path?: unknown })
|
||||
: (envelope as { type?: unknown; mode?: unknown; path?: unknown });
|
||||
return (
|
||||
session.type === "file_explorer_request" && session.mode === "file" && session.path === path
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function delayFileReadResponse(page: Page, path: string) {
|
||||
let delayed: (() => void) | null = null;
|
||||
let resolveDelayed: (() => void) | null = null;
|
||||
const delayedResponse = new Promise<void>((resolve) => {
|
||||
resolveDelayed = resolve;
|
||||
});
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
server.onMessage((message) => {
|
||||
ws.send(message);
|
||||
});
|
||||
// Hold the outgoing JSON request. The corresponding body response travels as
|
||||
// binary frames, so intercepting the response cannot identify this read.
|
||||
ws.onMessage((message) => {
|
||||
if (delayed || !fileReadRequest(message, path)) {
|
||||
server.send(message);
|
||||
return;
|
||||
}
|
||||
delayed = () => server.send(message);
|
||||
resolveDelayed?.();
|
||||
});
|
||||
});
|
||||
return {
|
||||
waitUntilHeld: () => delayedResponse,
|
||||
release: () => delayed?.(),
|
||||
};
|
||||
}
|
||||
@@ -174,6 +174,27 @@ export async function openSidebarDisplayPage(page: Page, branchTestID: string):
|
||||
await page.getByTestId(branchTestID).click();
|
||||
}
|
||||
|
||||
// Project filters live a page below the display-preferences root, like the host filters.
|
||||
export async function openSidebarProjectFilter(page: Page): Promise<void> {
|
||||
await openSidebarDisplayPage(page, "sidebar-display-project-filter");
|
||||
}
|
||||
|
||||
// The filter rows are keyed by the project's sidebar view key. A project seeded from its own temp
|
||||
// repo has a `projectKey` unique on the host, so the view key IS that key — see
|
||||
// `buildWorkspaceStructureProjects`.
|
||||
export async function toggleProjectFilter(page: Page, projectViewKey: string): Promise<void> {
|
||||
await page.getByTestId(`sidebar-project-filter-${projectViewKey}`).click();
|
||||
}
|
||||
|
||||
export async function selectAllProjectsFilter(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-project-filter-all").click();
|
||||
}
|
||||
|
||||
export async function closeSidebarDisplayPreferences(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("sidebar-display-preferences-content")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function selectSidebarStatusGrouping(page: Page): Promise<void> {
|
||||
await openSidebarDisplayPage(page, "sidebar-display-grouping");
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
|
||||
@@ -13,12 +13,12 @@ function splitSeparator(page: Page): Locator {
|
||||
}
|
||||
|
||||
export async function openExplorerSplit(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await page.getByRole("button", { name: "Open side panel" }).click();
|
||||
await expect(splitSeparator(page)).toHaveCount(1, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function hideExplorerSplit(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Close explorer" }).click();
|
||||
await page.getByRole("button", { name: "Close side panel" }).click();
|
||||
await expect(splitSeparator(page)).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -532,30 +532,42 @@ export async function scrollTimelineToOldestLoadedEdge(page: Page): Promise<void
|
||||
export async function userScrollsTimelineToHistoryStart(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await scroll.hover();
|
||||
for (let step = 0; step < 60; step += 1) {
|
||||
if ((await readTimelineViewport(page)).scrollTop <= HISTORY_START_THRESHOLD_PX) {
|
||||
break;
|
||||
}
|
||||
await page.mouse.wheel(0, -1_000);
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
}),
|
||||
);
|
||||
}
|
||||
await expect
|
||||
.poll(async () => (await readTimelineViewport(page)).scrollTop)
|
||||
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
|
||||
await returnTimelineToSettledHistoryStart(page, () => page.mouse.wheel(0, -1_000));
|
||||
}
|
||||
|
||||
export async function userNavigatesTimelineToHistoryStartWithKeyboard(page: Page): Promise<void> {
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
await scroll.focus();
|
||||
await page.keyboard.press("Home");
|
||||
await expect
|
||||
.poll(async () => (await readTimelineViewport(page)).scrollTop)
|
||||
.toBeLessThanOrEqual(HISTORY_START_THRESHOLD_PX);
|
||||
await returnTimelineToSettledHistoryStart(page, () => page.keyboard.press("Home"));
|
||||
}
|
||||
|
||||
async function returnTimelineToSettledHistoryStart(
|
||||
page: Page,
|
||||
moveUp: () => Promise<unknown>,
|
||||
): Promise<void> {
|
||||
const loadingSpinner = page.getByTestId("load-older-history-spinner");
|
||||
// A caller may only start a new history traversal after its prior page has settled.
|
||||
await expect(loadingSpinner).toBeHidden();
|
||||
let previous = await readTimelineViewport(page);
|
||||
while (true) {
|
||||
await moveUp();
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
let viewport = await readTimelineViewport(page);
|
||||
// Tests that hold the response need to observe the requested page before releasing it.
|
||||
if (await loadingSpinner.isVisible()) {
|
||||
return;
|
||||
}
|
||||
if (viewport.scrollTop <= HISTORY_START_THRESHOLD_PX) {
|
||||
return;
|
||||
}
|
||||
await expect(loadingSpinner).toBeHidden();
|
||||
await waitForTimelineGeometryToSettle(page);
|
||||
viewport = await readTimelineViewport(page);
|
||||
expect(
|
||||
viewport.scrollTop < previous.scrollTop || viewport.scrollHeight > previous.scrollHeight,
|
||||
).toBe(true);
|
||||
previous = viewport;
|
||||
}
|
||||
}
|
||||
|
||||
export async function scrollTimelineToNewestLoadedEdge(page: Page): Promise<void> {
|
||||
|
||||
@@ -276,6 +276,11 @@ export async function navigateToWorkspaceViaSidebar(
|
||||
});
|
||||
}
|
||||
|
||||
export async function returnHomeFromWorkspace(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Home" }).click();
|
||||
await expect(page).not.toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function openWorkspaceScriptsMenu(page: Page): Promise<void> {
|
||||
await page.getByTestId("workspace-scripts-button").click();
|
||||
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -13,14 +13,52 @@ export async function getWorkspaceTabTestIds(page: Page): Promise<string[]> {
|
||||
return ids;
|
||||
}
|
||||
|
||||
function setupTabTestId(workspaceId: string): string {
|
||||
return `workspace-tab-setup_${workspaceId}`;
|
||||
}
|
||||
|
||||
async function waitForSetupToReachWorkspace(page: Page): Promise<void> {
|
||||
const actionsButton = page.getByTestId("workspace-header-menu-trigger");
|
||||
await expect(actionsButton).toBeVisible({ timeout: 30_000 });
|
||||
await actionsButton.click();
|
||||
await expect(page.getByTestId("workspace-header-show-setup")).toBeVisible({ timeout: 30_000 });
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function expectSetupTabNotSeeded(page: Page, workspaceId: string): Promise<void> {
|
||||
await waitForSetupToReachWorkspace(page);
|
||||
const tab = page.getByTestId(setupTabTestId(workspaceId));
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
await expect(tab).toHaveCount(0);
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectFailedSetupTabSeededInMainPane(
|
||||
page: Page,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const tabId = setupTabTestId(workspaceId);
|
||||
await expect(page.getByTestId(tabId).filter({ visible: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const panel = await ensureSidePanel(page);
|
||||
await expect(panel.getByTestId(tabId)).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function closeSetupTab(page: Page, workspaceId: string): Promise<void> {
|
||||
const tabId = setupTabTestId(workspaceId);
|
||||
await page.getByTestId(tabId).filter({ visible: true }).first().click({ button: "right" });
|
||||
await page.getByTestId(`workspace-tab-context-setup_${workspaceId}-close`).click();
|
||||
await expect(page.getByTestId(tabId)).toHaveCount(0);
|
||||
}
|
||||
|
||||
function visibleTestId(page: Page, testId: string) {
|
||||
return page.getByTestId(testId).filter({ visible: true });
|
||||
}
|
||||
|
||||
function explorerPaneTabRow(page: Page) {
|
||||
return visibleTestId(page, "workspace-tabs-row")
|
||||
.filter({ has: page.getByTestId("workspace-tab-working_diff") })
|
||||
.first();
|
||||
function sidePanel(page: Page) {
|
||||
return visibleTestId(page, "workspace-side-panel").first();
|
||||
}
|
||||
|
||||
async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
||||
@@ -32,35 +70,51 @@ async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
||||
await expect(tab).toHaveAttribute("aria-selected", "true");
|
||||
}
|
||||
|
||||
export async function ensureExplorerPane(page: Page) {
|
||||
/** Reveal the Side panel. It opens empty; the caller decides what goes in it. */
|
||||
export async function ensureSidePanel(page: Page): Promise<Locator> {
|
||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
||||
const tabRow = explorerPaneTabRow(page);
|
||||
if ((await tabRow.count()) === 0) {
|
||||
const panel = sidePanel(page);
|
||||
if ((await panel.count()) === 0) {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(tabRow).toBeVisible({ timeout: 30_000 });
|
||||
return tabRow;
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
return panel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveals the Side panel and brings one of its views up in it. Goes through the
|
||||
* pane's own `+` menu, which is there whether the pane is empty or already loaded.
|
||||
*/
|
||||
async function openSidePanelView(
|
||||
page: Page,
|
||||
view: { tabTestId: string; menuTestId: string; contentTestId: string; timeout?: number },
|
||||
): Promise<void> {
|
||||
const panel = await ensureSidePanel(page);
|
||||
const tab = panel.getByTestId(view.tabTestId);
|
||||
if ((await tab.count()) === 0) {
|
||||
await panel.getByTestId("workspace-new-tab-menu-trigger").click();
|
||||
await visibleTestId(page, view.menuTestId).first().click();
|
||||
}
|
||||
await selectWorkspaceTab(tab);
|
||||
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
|
||||
timeout: view.timeout ?? 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function openChangesPanel(page: Page): Promise<void> {
|
||||
const tabRow = await ensureExplorerPane(page);
|
||||
await selectWorkspaceTab(tabRow.getByTestId("workspace-tab-working_diff"));
|
||||
await expect(visibleTestId(page, "working-diff-panel").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
await openSidePanelView(page, {
|
||||
tabTestId: "workspace-tab-working_diff",
|
||||
menuTestId: "workspace-new-tab-menu-changes",
|
||||
contentTestId: "working-diff-panel",
|
||||
});
|
||||
}
|
||||
|
||||
export async function openFilesPanel(page: Page): Promise<void> {
|
||||
const tabRow = await ensureExplorerPane(page);
|
||||
const filesTab = tabRow.getByTestId("workspace-tab-files");
|
||||
if ((await filesTab.count()) === 0) {
|
||||
await tabRow.getByTestId("workspace-new-tab-menu-trigger").click();
|
||||
await visibleTestId(page, "workspace-new-tab-menu-files").first().click();
|
||||
}
|
||||
await selectWorkspaceTab(filesTab);
|
||||
await expect(visibleTestId(page, "file-explorer-tree-scroll").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
await openSidePanelView(page, {
|
||||
tabTestId: "workspace-tab-files",
|
||||
menuTestId: "workspace-new-tab-menu-files",
|
||||
contentTestId: "file-explorer-tree-scroll",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,14 +125,12 @@ export async function openPullRequestPanel(page: Page): Promise<void> {
|
||||
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
||||
return;
|
||||
}
|
||||
const tabRow = await ensureExplorerPane(page);
|
||||
const pullRequestTab = tabRow.getByTestId("workspace-tab-pull_request");
|
||||
if ((await pullRequestTab.count()) === 0) {
|
||||
await tabRow.getByTestId("workspace-new-tab-menu-trigger").click();
|
||||
await visibleTestId(page, "workspace-new-tab-menu-pull-request").first().click();
|
||||
}
|
||||
await selectWorkspaceTab(pullRequestTab);
|
||||
await expect(visibleTestId(page, "pr-pane").first()).toBeVisible({ timeout: 15_000 });
|
||||
await openSidePanelView(page, {
|
||||
tabTestId: "workspace-tab-pull_request",
|
||||
menuTestId: "workspace-new-tab-menu-pull-request",
|
||||
contentTestId: "pr-pane",
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.5.0-beta.1",
|
||||
"version": "0.5.0-beta.4",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -49,12 +49,12 @@
|
||||
"@getpaseo/client": "*",
|
||||
"@getpaseo/expo-two-way-audio": "*",
|
||||
"@getpaseo/highlight": "*",
|
||||
"@getpaseo/plugin": "*",
|
||||
"@getpaseo/protocol": "*",
|
||||
"@gorhom/bottom-sheet": "^5.2.14",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@marijn/find-cluster-break": "^1.0.2",
|
||||
"@mattermost/react-native-paste-input": "2.0.1",
|
||||
"@paseo/plugin": "*",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native/normalize-colors": "^0.81.5",
|
||||
|
||||
@@ -174,4 +174,151 @@ describe("useChatOutline", () => {
|
||||
|
||||
await waitFor(() => expect(onJumpError).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it("reveals an already-loaded older prompt before scrolling to it", async () => {
|
||||
runtime.listAgentTimelinePrompts.mockResolvedValue({
|
||||
epoch: "epoch-1",
|
||||
prompts: [{ seq: 1, timestamp: new Date(1).toISOString(), preview: "prompt" }],
|
||||
});
|
||||
const scrollToMessage = vi.fn();
|
||||
const revealLoadedItem = vi.fn(() => true);
|
||||
const viewport: StreamViewportHandle = {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
scrollToMessage,
|
||||
};
|
||||
const viewportRef = { current: viewport };
|
||||
const tail = [
|
||||
{
|
||||
id: "older-prompt",
|
||||
kind: "user_message" as const,
|
||||
text: "prompt",
|
||||
timestamp: new Date(1),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1 },
|
||||
},
|
||||
];
|
||||
const { result, rerender } = renderHook(
|
||||
({ visibleItemIds }) =>
|
||||
useChatOutline({
|
||||
agentId: "agent-1",
|
||||
serverId: "server-1",
|
||||
timelineEpoch: "epoch-1",
|
||||
tail,
|
||||
head: [],
|
||||
enabled: true,
|
||||
viewportRef,
|
||||
onJumpError: vi.fn(),
|
||||
visibleItemIds,
|
||||
revealLoadedItem,
|
||||
}),
|
||||
{ initialProps: { visibleItemIds: new Set<string>() } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
|
||||
await act(async () => result.current.jumpToPrompt(1));
|
||||
expect(revealLoadedItem).toHaveBeenCalledWith("older-prompt");
|
||||
expect(scrollToMessage).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ visibleItemIds: new Set(["older-prompt"]) });
|
||||
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledWith("older-prompt"));
|
||||
});
|
||||
|
||||
it("reveals a fetched prompt that lands outside the mounted history window", async () => {
|
||||
runtime.listAgentTimelinePrompts.mockResolvedValue({
|
||||
epoch: "epoch-1",
|
||||
prompts: [{ seq: 1, timestamp: new Date(1).toISOString(), preview: "prompt" }],
|
||||
});
|
||||
const fetch = deferred<void>();
|
||||
runtime.fetchAgentTimeline.mockReturnValue(fetch.promise);
|
||||
const scrollToMessage = vi.fn();
|
||||
const revealLoadedItem = vi.fn(() => true);
|
||||
const viewportRef = {
|
||||
current: {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
scrollToMessage,
|
||||
},
|
||||
};
|
||||
const fetchedPrompt = {
|
||||
id: "fetched-prompt",
|
||||
kind: "user_message" as const,
|
||||
text: "prompt",
|
||||
timestamp: new Date(1),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 1 },
|
||||
};
|
||||
const { result, rerender } = renderHook(
|
||||
({ tail, visibleItemIds }) =>
|
||||
useChatOutline({
|
||||
agentId: "agent-1",
|
||||
serverId: "server-1",
|
||||
timelineEpoch: "epoch-1",
|
||||
tail,
|
||||
head: [],
|
||||
enabled: true,
|
||||
viewportRef,
|
||||
onJumpError: vi.fn(),
|
||||
visibleItemIds,
|
||||
revealLoadedItem,
|
||||
}),
|
||||
{
|
||||
initialProps: {
|
||||
tail: [] as (typeof fetchedPrompt)[],
|
||||
visibleItemIds: new Set<string>(),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
|
||||
act(() => result.current.jumpToPrompt(1));
|
||||
await waitFor(() => expect(runtime.fetchAgentTimeline).toHaveBeenCalledOnce());
|
||||
|
||||
rerender({ tail: [fetchedPrompt], visibleItemIds: new Set<string>() });
|
||||
await waitFor(() => expect(revealLoadedItem).toHaveBeenCalledWith(fetchedPrompt.id));
|
||||
expect(scrollToMessage).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ tail: [fetchedPrompt], visibleItemIds: new Set([fetchedPrompt.id]) });
|
||||
await waitFor(() => expect(scrollToMessage).toHaveBeenCalledOnce());
|
||||
expect(scrollToMessage).toHaveBeenCalledWith(fetchedPrompt.id);
|
||||
await act(async () => fetch.resolve());
|
||||
});
|
||||
|
||||
it("jumps directly to a loaded prompt in the live head", async () => {
|
||||
runtime.listAgentTimelinePrompts.mockResolvedValue({
|
||||
epoch: "epoch-1",
|
||||
prompts: [{ seq: 2, timestamp: new Date(2).toISOString(), preview: "prompt" }],
|
||||
});
|
||||
const scrollToMessage = vi.fn();
|
||||
const viewport: StreamViewportHandle = {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
scrollToMessage,
|
||||
};
|
||||
const livePrompt = {
|
||||
id: "live-prompt",
|
||||
kind: "user_message" as const,
|
||||
text: "prompt",
|
||||
timestamp: new Date(2),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 2 },
|
||||
};
|
||||
const revealLoadedItem = vi.fn();
|
||||
revealLoadedItem.mockReturnValue(false);
|
||||
const { result } = renderHook(() =>
|
||||
useChatOutline({
|
||||
agentId: "agent-1",
|
||||
serverId: "server-1",
|
||||
timelineEpoch: "epoch-1",
|
||||
tail: [],
|
||||
head: [livePrompt],
|
||||
enabled: true,
|
||||
viewportRef: { current: viewport },
|
||||
onJumpError: vi.fn(),
|
||||
visibleItemIds: new Set([livePrompt.id]),
|
||||
revealLoadedItem,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.prompts).toHaveLength(1));
|
||||
await act(async () => result.current.jumpToPrompt(2));
|
||||
expect(scrollToMessage).toHaveBeenCalledWith("live-prompt");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface UseChatOutlineInput {
|
||||
enabled: boolean;
|
||||
viewportRef: RefObject<StreamViewportHandle | null>;
|
||||
onJumpError: () => void;
|
||||
visibleItemIds?: ReadonlySet<string>;
|
||||
revealLoadedItem?: (itemId: string) => boolean;
|
||||
}
|
||||
|
||||
export interface ChatOutline {
|
||||
@@ -51,6 +53,8 @@ export function useChatOutline({
|
||||
enabled,
|
||||
viewportRef,
|
||||
onJumpError,
|
||||
visibleItemIds,
|
||||
revealLoadedItem,
|
||||
}: UseChatOutlineInput): ChatOutline {
|
||||
const [index, setIndex] = useState<AgentTimelinePromptIndexPayload | null>(null);
|
||||
const [pendingJump, setPendingJump] = useState<PendingPromptJump | null>(null);
|
||||
@@ -135,7 +139,12 @@ export function useChatOutline({
|
||||
useEffect(() => {
|
||||
if (pendingJump === null) return;
|
||||
const target = loadedItems.find((item) => item.timelineCursor?.seq === pendingJump.seq);
|
||||
if (target && !pendingJump.hasScrolled) {
|
||||
if (target) {
|
||||
if (pendingJump.hasScrolled) return;
|
||||
if (visibleItemIds?.has(target.id) === false) {
|
||||
revealLoadedItem?.(target.id);
|
||||
return;
|
||||
}
|
||||
viewportRef.current?.scrollToMessage?.(target.id);
|
||||
setPendingJump((current) => {
|
||||
if (current?.requestId !== pendingJump.requestId) return current;
|
||||
@@ -144,7 +153,7 @@ export function useChatOutline({
|
||||
return;
|
||||
}
|
||||
if (pendingJump.fetchSettled) setPendingJump(null);
|
||||
}, [loadedItems, pendingJump, viewportRef]);
|
||||
}, [loadedItems, pendingJump, revealLoadedItem, viewportRef, visibleItemIds]);
|
||||
|
||||
const jumpToPrompt = useCallback(
|
||||
(seq: number) => {
|
||||
@@ -152,6 +161,11 @@ export function useChatOutline({
|
||||
setPendingJump(null);
|
||||
const loaded = loadedItems.find((item) => item.timelineCursor?.seq === seq);
|
||||
if (loaded) {
|
||||
if (revealLoadedItem?.(loaded.id)) {
|
||||
const requestId = nextJumpRequestIdRef.current;
|
||||
setPendingJump({ requestId, seq, fetchSettled: true, hasScrolled: false });
|
||||
return;
|
||||
}
|
||||
viewportRef.current?.scrollToMessage?.(loaded.id);
|
||||
return;
|
||||
}
|
||||
@@ -171,7 +185,7 @@ export function useChatOutline({
|
||||
});
|
||||
});
|
||||
},
|
||||
[agentId, index, loadedItems, onJumpError, serverId, viewportRef],
|
||||
[agentId, index, loadedItems, onJumpError, revealLoadedItem, serverId, viewportRef],
|
||||
);
|
||||
|
||||
return { prompts, activePrompt, jumpToPrompt, reportReadingPosition };
|
||||
|
||||
@@ -22,6 +22,22 @@ const visibleHistoryStart: HistoryStartPaginationInput = {
|
||||
};
|
||||
|
||||
describe("history start pagination", () => {
|
||||
it("treats locally revealed history as progress before remote pagination", () => {
|
||||
const first = evaluateHistoryStartPagination(
|
||||
createArmedHistoryStartPaginationState(),
|
||||
visibleHistoryStart,
|
||||
);
|
||||
const localReveal = evaluateHistoryStartPagination(first.state, {
|
||||
...visibleHistoryStart,
|
||||
progressKey: "epoch-1:20:local-60",
|
||||
});
|
||||
|
||||
expect(first.shouldLoad).toBe(true);
|
||||
expect(localReveal.state).toEqual({
|
||||
status: "settling",
|
||||
loadedProgressKey: "epoch-1:20:local-60",
|
||||
});
|
||||
});
|
||||
it("waits for user scroll intent before loading", () => {
|
||||
const dormant = createHistoryStartPaginationState();
|
||||
const evaluated = evaluateHistoryStartPagination(dormant, visibleHistoryStart);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
|
||||
export const DEFAULT_MOUNTED_RECENT_STREAM_ITEMS = 20;
|
||||
|
||||
type MountedRecentStreamItemsE2ETestGlobals = typeof globalThis & {
|
||||
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
|
||||
};
|
||||
|
||||
function readPositiveIntegerOverride(value: unknown): number | null {
|
||||
if (!Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
const normalized = Math.trunc(value as number);
|
||||
return normalized > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function getMountedRecentStreamItems(): number {
|
||||
const override = readPositiveIntegerOverride(
|
||||
(globalThis as MountedRecentStreamItemsE2ETestGlobals)
|
||||
.__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS,
|
||||
);
|
||||
return override ?? DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
|
||||
}
|
||||
|
||||
export function findMountedWindowStart(input: {
|
||||
items: StreamItem[];
|
||||
minMountedCount: number;
|
||||
}): number {
|
||||
const { items, minMountedCount } = input;
|
||||
if (items.length <= minMountedCount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let startIndex = Math.max(items.length - minMountedCount, 0);
|
||||
while (startIndex > 0 && items[startIndex]?.kind !== "user_message") {
|
||||
startIndex -= 1;
|
||||
}
|
||||
return startIndex;
|
||||
}
|
||||
@@ -25,6 +25,56 @@ function assistantMessage(id: string, seed: number): StreamItem {
|
||||
}
|
||||
|
||||
describe("buildAgentStreamRenderModel", () => {
|
||||
it("projects a bounded recent turn-aligned history window on every platform", () => {
|
||||
const tail = [
|
||||
userMessage("u1", 1),
|
||||
assistantMessage("a1", 2),
|
||||
userMessage("u2", 3),
|
||||
assistantMessage("a2", 4),
|
||||
userMessage("u3", 5),
|
||||
assistantMessage("a3", 6),
|
||||
];
|
||||
|
||||
const model = buildAgentStreamRenderModel({
|
||||
isTurnActive: false,
|
||||
activeTurnStartedAt: null,
|
||||
tail,
|
||||
head: [],
|
||||
platform: "native",
|
||||
isMobileBreakpoint: false,
|
||||
historyStart: 2,
|
||||
});
|
||||
|
||||
expect(model.history.map((item) => item.id)).toEqual(["a3", "u3", "a2", "u2"]);
|
||||
expect(model.segments.historyVirtualized).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("derives timing only for the rendered history window", () => {
|
||||
const tail = [
|
||||
userMessage("hidden-u", 1),
|
||||
assistantMessage("hidden-a", 2),
|
||||
userMessage("visible-u", 3),
|
||||
assistantMessage("visible-a", 4),
|
||||
];
|
||||
|
||||
const model = buildAgentStreamRenderModel({
|
||||
isTurnActive: false,
|
||||
activeTurnStartedAt: null,
|
||||
tail,
|
||||
head: [],
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
historyStart: 2,
|
||||
});
|
||||
|
||||
expect(model.turnTiming.byAssistantId.has("hidden-a")).toBe(false);
|
||||
expect(model.turnTiming.byAssistantId.get("visible-a")).toEqual({
|
||||
startedAt: tail[2]?.timestamp,
|
||||
completedAt: tail[3]?.timestamp,
|
||||
durationMs: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps head separate from committed history on desktop web", () => {
|
||||
const tail: StreamItem[] = [];
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { deriveStreamTurnTiming, type StreamTurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
findMountedWindowStart,
|
||||
getWebMountedRecentStreamItems,
|
||||
getWebPartialVirtualizationThreshold,
|
||||
} from "./web-virtualization";
|
||||
import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
|
||||
import { getWebPartialVirtualizationThreshold } from "./web-virtualization";
|
||||
import { orderHeadForStreamRenderStrategy, orderTailForStreamRenderStrategy } from "./strategy";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
|
||||
@@ -41,6 +38,7 @@ export interface BuildAgentStreamRenderModelInput {
|
||||
head: StreamItem[];
|
||||
platform: "web" | "native";
|
||||
isMobileBreakpoint: boolean;
|
||||
historyStart?: number;
|
||||
}
|
||||
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
@@ -91,7 +89,7 @@ function splitOrderedTail(params: {
|
||||
platform === "web" &&
|
||||
!isMobileBreakpoint &&
|
||||
orderedTail.length > getWebPartialVirtualizationThreshold();
|
||||
const cacheKey = `${platform}:${isMobileBreakpoint}:${getWebMountedRecentStreamItems()}:${shouldSplitHistory}`;
|
||||
const cacheKey = `${platform}:${isMobileBreakpoint}:${getMountedRecentStreamItems()}:${shouldSplitHistory}`;
|
||||
let cachedByKey = splitHistoryCache.get(orderedTail);
|
||||
if (!cachedByKey) {
|
||||
cachedByKey = new Map();
|
||||
@@ -117,7 +115,7 @@ function splitOrderedTail(params: {
|
||||
|
||||
const mountedWindowStart = findMountedWindowStart({
|
||||
items: orderedTail,
|
||||
minMountedCount: getWebMountedRecentStreamItems(),
|
||||
minMountedCount: getMountedRecentStreamItems(),
|
||||
});
|
||||
const split = {
|
||||
history: orderedTail,
|
||||
@@ -165,9 +163,10 @@ export function buildAgentStreamRenderModel(
|
||||
isMobileBreakpoint: input.isMobileBreakpoint,
|
||||
});
|
||||
const orderingCacheKey = `${input.platform}:${input.isMobileBreakpoint}`;
|
||||
const renderedTail = input.historyStart ? input.tail.slice(input.historyStart) : input.tail;
|
||||
const orderedTail = getOrderedItems({
|
||||
cache: orderedTailCache,
|
||||
source: input.tail,
|
||||
source: renderedTail,
|
||||
cacheKey: orderingCacheKey,
|
||||
order: (items) =>
|
||||
orderTailForStreamRenderStrategy({
|
||||
@@ -193,7 +192,7 @@ export function buildAgentStreamRenderModel(
|
||||
const turnTiming = getTurnTiming({
|
||||
isTurnActive: input.isTurnActive,
|
||||
activeTurnStartedAt: input.activeTurnStartedAt,
|
||||
tail: input.tail,
|
||||
tail: renderedTail,
|
||||
head: input.head,
|
||||
});
|
||||
|
||||
|
||||
@@ -617,10 +617,8 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||
initialNumToRender={40}
|
||||
maxToRenderPerBatch={40}
|
||||
updateCellsBatchingPeriod={0}
|
||||
windowSize={21}
|
||||
initialNumToRender={12}
|
||||
windowSize={10}
|
||||
removeClippedSubviews={false}
|
||||
scrollEnabled={scrollEnabled}
|
||||
showsVerticalScrollIndicator
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { useStreamHistoryWindow } from "./use-stream-history-window";
|
||||
|
||||
function message(id: string, kind: "user_message" | "assistant_message"): StreamItem {
|
||||
return { id, kind, text: id, timestamp: new Date() };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, "__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS");
|
||||
});
|
||||
|
||||
describe("useStreamHistoryWindow", () => {
|
||||
it("mounts only the most recent 20 items by default", () => {
|
||||
const items = Array.from({ length: 11 }, (_, turn) => [
|
||||
message(`u${turn}`, "user_message"),
|
||||
message(`a${turn}`, "assistant_message"),
|
||||
]).flat();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useStreamHistoryWindow({ agentId: "agent-1", items, loadRemoteOlder: vi.fn() }),
|
||||
);
|
||||
|
||||
expect(result.current.start).toBe(2);
|
||||
});
|
||||
|
||||
it("reveals loaded history before requesting an older daemon page", async () => {
|
||||
Object.defineProperty(globalThis, "__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS", {
|
||||
value: 2,
|
||||
configurable: true,
|
||||
});
|
||||
const loadRemoteOlder = vi.fn(async () => true);
|
||||
const { result } = renderHook(() =>
|
||||
useStreamHistoryWindow({
|
||||
agentId: "agent-1",
|
||||
items: [
|
||||
message("u1", "user_message"),
|
||||
message("a1", "assistant_message"),
|
||||
message("u2", "user_message"),
|
||||
message("a2", "assistant_message"),
|
||||
],
|
||||
loadRemoteOlder,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.start).toBe(2);
|
||||
await act(async () => result.current.loadOlder());
|
||||
expect(result.current.start).toBe(0);
|
||||
expect(loadRemoteOlder).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => result.current.loadOlder());
|
||||
expect(loadRemoteOlder).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps its boundary at the same item when loaded history is prepended", () => {
|
||||
Object.defineProperty(globalThis, "__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS", {
|
||||
value: 2,
|
||||
configurable: true,
|
||||
});
|
||||
const items = [
|
||||
message("u1", "user_message"),
|
||||
message("a1", "assistant_message"),
|
||||
message("u2", "user_message"),
|
||||
message("a2", "assistant_message"),
|
||||
];
|
||||
const { result, rerender } = renderHook(
|
||||
({ history }) =>
|
||||
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
|
||||
{ initialProps: { history: items } },
|
||||
);
|
||||
|
||||
expect(result.current.start).toBe(2);
|
||||
rerender({
|
||||
history: [message("u0", "user_message"), message("a0", "assistant_message"), ...items],
|
||||
});
|
||||
expect(result.current.start).toBe(4);
|
||||
});
|
||||
|
||||
it("keeps live appends inside the rendered window", () => {
|
||||
Object.defineProperty(globalThis, "__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS", {
|
||||
value: 2,
|
||||
configurable: true,
|
||||
});
|
||||
const items = [
|
||||
message("u1", "user_message"),
|
||||
message("a1", "assistant_message"),
|
||||
message("u2", "user_message"),
|
||||
message("a2", "assistant_message"),
|
||||
];
|
||||
const { result, rerender } = renderHook(
|
||||
({ history }) =>
|
||||
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
|
||||
{ initialProps: { history: items } },
|
||||
);
|
||||
|
||||
rerender({
|
||||
history: [...items, message("u3", "user_message"), message("a3", "assistant_message")],
|
||||
});
|
||||
expect(result.current.start).toBe(2);
|
||||
});
|
||||
|
||||
it("restarts at a recent window when an item replacement removes the boundary", () => {
|
||||
Object.defineProperty(globalThis, "__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS", {
|
||||
value: 2,
|
||||
configurable: true,
|
||||
});
|
||||
const { result, rerender } = renderHook(
|
||||
({ history }) =>
|
||||
useStreamHistoryWindow({ agentId: "agent-1", items: history, loadRemoteOlder: vi.fn() }),
|
||||
{
|
||||
initialProps: {
|
||||
history: [
|
||||
message("u1", "user_message"),
|
||||
message("a1", "assistant_message"),
|
||||
message("u2", "user_message"),
|
||||
message("a2", "assistant_message"),
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
rerender({
|
||||
history: [
|
||||
message("new-u1", "user_message"),
|
||||
message("new-a1", "assistant_message"),
|
||||
message("new-u2", "user_message"),
|
||||
message("new-a2", "assistant_message"),
|
||||
message("new-u3", "user_message"),
|
||||
message("new-a3", "assistant_message"),
|
||||
],
|
||||
});
|
||||
expect(result.current.start).toBe(4);
|
||||
});
|
||||
|
||||
it("does not claim to reveal a missing or already-visible item", () => {
|
||||
Object.defineProperty(globalThis, "__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS", {
|
||||
value: 2,
|
||||
configurable: true,
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
useStreamHistoryWindow({
|
||||
agentId: "agent-1",
|
||||
items: [
|
||||
message("u1", "user_message"),
|
||||
message("a1", "assistant_message"),
|
||||
message("u2", "user_message"),
|
||||
message("a2", "assistant_message"),
|
||||
],
|
||||
loadRemoteOlder: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.revealLoadedHistory("missing")).toBe(false);
|
||||
expect(result.current.revealLoadedHistory("u2")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
|
||||
|
||||
function findEarlierHistoryWindowStart(input: {
|
||||
items: StreamItem[];
|
||||
start: number;
|
||||
itemId: string | undefined;
|
||||
}): number {
|
||||
const targetIndex = input.itemId ? input.items.findIndex((item) => item.id === input.itemId) : -1;
|
||||
const targetStart = targetIndex >= 0 && targetIndex < input.start ? targetIndex : input.start;
|
||||
if (!input.itemId) {
|
||||
return findMountedWindowStart({
|
||||
items: input.items.slice(0, targetStart),
|
||||
minMountedCount: getMountedRecentStreamItems(),
|
||||
});
|
||||
}
|
||||
return findMountedWindowStart({
|
||||
items: input.items.slice(0, targetStart + 1),
|
||||
minMountedCount: 1,
|
||||
});
|
||||
}
|
||||
|
||||
interface HistoryWindowBoundary {
|
||||
agentId: string;
|
||||
boundaryItemId: string | null;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
function reconcileHistoryWindow(input: {
|
||||
current: HistoryWindowBoundary;
|
||||
agentId: string;
|
||||
boundaryIndex: number;
|
||||
initialBoundaryItemId: string | null;
|
||||
hasItems: boolean;
|
||||
}): HistoryWindowBoundary {
|
||||
const { current, agentId, boundaryIndex, initialBoundaryItemId, hasItems } = input;
|
||||
if (current.agentId !== agentId) {
|
||||
return { agentId, boundaryItemId: initialBoundaryItemId, initialized: hasItems };
|
||||
}
|
||||
if (!current.initialized && hasItems) {
|
||||
return { ...current, boundaryItemId: initialBoundaryItemId, initialized: true };
|
||||
}
|
||||
if (current.boundaryItemId !== null && boundaryIndex < 0) {
|
||||
return { ...current, boundaryItemId: initialBoundaryItemId };
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function useStreamHistoryWindow(input: {
|
||||
agentId: string;
|
||||
items: StreamItem[];
|
||||
loadRemoteOlder: () => boolean | Promise<boolean>;
|
||||
}) {
|
||||
const { agentId, items, loadRemoteOlder } = input;
|
||||
const initialStart = useMemo(
|
||||
() => findMountedWindowStart({ items, minMountedCount: getMountedRecentStreamItems() }),
|
||||
[items],
|
||||
);
|
||||
const initialBoundaryItemId = initialStart === 0 ? null : (items[initialStart]?.id ?? null);
|
||||
const [window, setWindow] = useState(() => ({
|
||||
agentId,
|
||||
boundaryItemId: initialBoundaryItemId,
|
||||
initialized: items.length > 0,
|
||||
}));
|
||||
const boundaryIndex =
|
||||
window.agentId === agentId && window.boundaryItemId !== null
|
||||
? items.findIndex((item) => item.id === window.boundaryItemId)
|
||||
: -1;
|
||||
let start = initialStart;
|
||||
if (window.agentId === agentId && window.initialized) {
|
||||
if (window.boundaryItemId === null) {
|
||||
start = 0;
|
||||
} else if (boundaryIndex >= 0) {
|
||||
start = boundaryIndex;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setWindow((current) =>
|
||||
reconcileHistoryWindow({
|
||||
current,
|
||||
agentId,
|
||||
boundaryIndex,
|
||||
initialBoundaryItemId,
|
||||
hasItems: items.length > 0,
|
||||
}),
|
||||
);
|
||||
}, [agentId, boundaryIndex, initialBoundaryItemId, items.length]);
|
||||
|
||||
const revealLoadedHistory = useCallback(
|
||||
(itemId?: string): boolean => {
|
||||
const targetIndex = itemId ? items.findIndex((item) => item.id === itemId) : -1;
|
||||
if (start === 0 || (itemId !== undefined && (targetIndex < 0 || targetIndex >= start))) {
|
||||
return false;
|
||||
}
|
||||
const nextStart = findEarlierHistoryWindowStart({ items, start, itemId });
|
||||
if (nextStart === start) {
|
||||
return false;
|
||||
}
|
||||
setWindow({
|
||||
agentId,
|
||||
boundaryItemId: items[nextStart]?.id ?? null,
|
||||
initialized: true,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[agentId, items, start],
|
||||
);
|
||||
const loadOlder = useCallback(async (): Promise<boolean> => {
|
||||
return revealLoadedHistory() || (await loadRemoteOlder());
|
||||
}, [loadRemoteOlder, revealLoadedHistory]);
|
||||
|
||||
return { start, hasLocalHistory: start > 0, revealLoadedHistory, loadOlder };
|
||||
}
|
||||
@@ -26,7 +26,8 @@ import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { Check, ChevronDown, X } from "lucide-react-native";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { openSidePanelView } from "@/workspace-tabs/side-panel";
|
||||
import {
|
||||
AssistantMessage,
|
||||
SpeakMessage,
|
||||
@@ -104,6 +105,7 @@ import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { recordRenderProfileReasons } from "@/utils/render-profiler";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useStreamHistoryWindow } from "./use-stream-history-window";
|
||||
|
||||
function renderLiveAuxiliaryNode(input: {
|
||||
pendingPermissions: ReactNode;
|
||||
@@ -339,8 +341,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
const resolvedServerId = serverId ?? context.serverId ?? "";
|
||||
@@ -377,7 +377,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
agentId,
|
||||
toast,
|
||||
});
|
||||
const { isLoadingOlder, hasOlder, progressKey, loadOlder } = historyPagination
|
||||
const {
|
||||
isLoadingOlder: remoteIsLoadingOlder,
|
||||
hasOlder: remoteHasOlder,
|
||||
progressKey: remoteProgressKey,
|
||||
loadOlder: loadRemoteOlder,
|
||||
} = historyPagination
|
||||
? {
|
||||
isLoadingOlder: historyPagination.isLoadingOlder,
|
||||
hasOlder: historyPagination.hasOlder,
|
||||
@@ -445,15 +450,18 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
setCurrentPath: false,
|
||||
});
|
||||
|
||||
const checkout = {
|
||||
serverId: resolvedServerId,
|
||||
cwd: context.cwd,
|
||||
isGit: context.projectPlacement?.checkout?.isGit ?? true,
|
||||
};
|
||||
setExplorerTabForCheckout({ ...checkout, tab: "files" });
|
||||
openFileExplorerForCheckout({
|
||||
openSidePanelView({
|
||||
isCompact: isMobile,
|
||||
checkout,
|
||||
workspaceKey: buildWorkspaceTabPersistenceKey({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId: context.workspaceId ?? "",
|
||||
}),
|
||||
checkout: {
|
||||
serverId: resolvedServerId,
|
||||
cwd: context.cwd,
|
||||
isGit: context.projectPlacement?.checkout?.isGit ?? true,
|
||||
},
|
||||
view: "files",
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -518,6 +526,19 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
toolCallDetailLevel,
|
||||
],
|
||||
);
|
||||
const {
|
||||
start: historyWindowStart,
|
||||
hasLocalHistory,
|
||||
revealLoadedHistory,
|
||||
loadOlder,
|
||||
} = useStreamHistoryWindow({
|
||||
agentId,
|
||||
items: projectedToolCalls.tail,
|
||||
loadRemoteOlder,
|
||||
});
|
||||
const isLoadingOlder = remoteIsLoadingOlder;
|
||||
const hasOlder = hasLocalHistory || remoteHasOlder;
|
||||
const progressKey = `${remoteProgressKey ?? "local"}:${historyWindowStart}`;
|
||||
|
||||
const baseRenderModel = useMemo(() => {
|
||||
return buildAgentStreamRenderModel({
|
||||
@@ -527,6 +548,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
head: projectedToolCalls.head,
|
||||
platform: isWeb ? "web" : "native",
|
||||
isMobileBreakpoint: isMobile,
|
||||
historyStart: historyWindowStart,
|
||||
});
|
||||
}, [
|
||||
isMobile,
|
||||
@@ -534,6 +556,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
projectedToolCalls.head,
|
||||
projectedToolCalls.tail,
|
||||
effectiveTurnPresentation.startedAt,
|
||||
historyWindowStart,
|
||||
]);
|
||||
const streamLayout = useMemo(
|
||||
() =>
|
||||
@@ -555,6 +578,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const handleTimelineHistoryLoadError = useCallback(() => {
|
||||
toast?.error(t("agentStream.historyLoadFailed"));
|
||||
}, [t, toast]);
|
||||
const visibleHistoryItemIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
[...baseRenderModel.history, ...baseRenderModel.segments.liveHead].map((item) => item.id),
|
||||
),
|
||||
[baseRenderModel.history, baseRenderModel.segments.liveHead],
|
||||
);
|
||||
const chatOutline = useChatOutline({
|
||||
agentId,
|
||||
serverId: resolvedServerId,
|
||||
@@ -564,6 +594,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
enabled: supportsChatOutline && chatOutlineEnabled,
|
||||
viewportRef,
|
||||
onJumpError: handleTimelineHistoryLoadError,
|
||||
visibleItemIds: visibleHistoryItemIds,
|
||||
revealLoadedItem: revealLoadedHistory,
|
||||
});
|
||||
|
||||
useImperativeHandle(
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { estimateAssistantMessageHeightFromCache } from "@/utils/assistant-message-height-estimate";
|
||||
import {
|
||||
DEFAULT_MOUNTED_RECENT_STREAM_ITEMS,
|
||||
findMountedWindowStart,
|
||||
getMountedRecentStreamItems,
|
||||
} from "./history-window";
|
||||
|
||||
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
|
||||
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = 50;
|
||||
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
|
||||
const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40;
|
||||
|
||||
type BottomAnchorE2ETestGlobals = typeof globalThis & {
|
||||
__PASEO_E2E_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD?: unknown;
|
||||
__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS?: unknown;
|
||||
};
|
||||
|
||||
function readPositiveIntegerOverride(value: unknown): number | null {
|
||||
@@ -26,10 +30,7 @@ export function getWebPartialVirtualizationThreshold(): number {
|
||||
}
|
||||
|
||||
export function getWebMountedRecentStreamItems(): number {
|
||||
const override = readPositiveIntegerOverride(
|
||||
(globalThis as BottomAnchorE2ETestGlobals).__PASEO_E2E_WEB_MOUNTED_RECENT_STREAM_ITEMS,
|
||||
);
|
||||
return override ?? DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS;
|
||||
return getMountedRecentStreamItems();
|
||||
}
|
||||
|
||||
export interface IndexedStreamItem {
|
||||
@@ -63,21 +64,7 @@ export function estimateStreamItemHeight(item: StreamItem): number {
|
||||
}
|
||||
}
|
||||
|
||||
export function findMountedWindowStart(input: {
|
||||
items: StreamItem[];
|
||||
minMountedCount: number;
|
||||
}): number {
|
||||
const { items, minMountedCount } = input;
|
||||
if (items.length <= minMountedCount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let startIndex = Math.max(items.length - minMountedCount, 0);
|
||||
while (startIndex > 0 && items[startIndex]?.kind !== "user_message") {
|
||||
startIndex -= 1;
|
||||
}
|
||||
return startIndex;
|
||||
}
|
||||
export { findMountedWindowStart };
|
||||
|
||||
export function splitWebVirtualizedHistory(input: {
|
||||
entries: IndexedStreamItem[];
|
||||
|
||||
@@ -20,7 +20,8 @@ import { AppState, useWindowDimensions, View } from "react-native";
|
||||
import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AppearanceProvider } from "@/appearance/provider";
|
||||
import { CommandCenter } from "@/command-center/command-center";
|
||||
import { CommandCenterRootActions } from "@/command-center/root-registration";
|
||||
import { CommandCenterProvider } from "@/command-center/provider";
|
||||
@@ -110,14 +111,12 @@ import {
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import { applyAppearance } from "@/screens/settings/appearance/apply-appearance";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { flushDraftPersistStorage } from "@/stores/draft-store";
|
||||
import { getNextThemePreference, THEME_TO_UNISTYLES } from "@/styles/theme";
|
||||
import { getNextThemePreference } from "@/styles/theme";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
|
||||
import {
|
||||
useHasWindowChromeObstruction,
|
||||
WindowChromeProvider,
|
||||
@@ -463,15 +462,10 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const toggleMobileAgentList = usePanelStore((state) => state.toggleMobileAgentList);
|
||||
const toggleDesktopAgentList = usePanelStore((state) => state.toggleDesktopAgentList);
|
||||
const openDesktopAgentList = usePanelStore((state) => state.openDesktopAgentList);
|
||||
const closeDesktopAgentList = usePanelStore((state) => state.closeDesktopAgentList);
|
||||
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||
const exitFocusMode = usePanelStore((state) => state.exitFocusMode);
|
||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const isDesktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
const sidebarWidth = usePanelStore((state) => state.sidebarWidth);
|
||||
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
|
||||
const cycleTheme = useCallback(() => {
|
||||
@@ -487,25 +481,16 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
const hasMountedDesktopSidebar = useLatchedBoolean(chromeEnabled);
|
||||
const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList;
|
||||
const toggleDesktopSidebars = useCallback(() => {
|
||||
const { desktop } = usePanelStore.getState();
|
||||
toggleDesktopSidebarsWithCheckoutIntent({
|
||||
isAgentListOpen: desktop.agentListOpen,
|
||||
isFileExplorerOpen: desktop.fileExplorerOpen,
|
||||
openAgentList: openDesktopAgentList,
|
||||
closeAgentList: closeDesktopAgentList,
|
||||
closeFileExplorer: closeDesktopFileExplorer,
|
||||
toggleFocusedFileExplorer: () =>
|
||||
keyboardActionDispatcher.dispatch({
|
||||
id: "sidebar.toggle.right",
|
||||
scope: "sidebar",
|
||||
}),
|
||||
});
|
||||
}, [
|
||||
closeDesktopAgentList,
|
||||
closeDesktopFileExplorer,
|
||||
keyboardActionDispatcher,
|
||||
openDesktopAgentList,
|
||||
]);
|
||||
// The focused workspace owns its layout key, its checkout, and therefore the
|
||||
// only correct answer to "is the explorer open". Let it decide when there is
|
||||
// one: the pathname alone cannot identify the active workspace, because
|
||||
// desktop cold-starts at "/" and restores the workspace from route params.
|
||||
if (keyboardActionDispatcher.dispatch({ id: "sidebar.toggle.both", scope: "sidebar" })) {
|
||||
return;
|
||||
}
|
||||
// Off a workspace route there is no explorer — only the agent list.
|
||||
toggleAgentList();
|
||||
}, [keyboardActionDispatcher, toggleAgentList]);
|
||||
// TODO: stop matching pathname here as a branch. `chromeEnabled` should not
|
||||
// conflate workspace/project-specific chrome (sidebar, mobile gesture) with
|
||||
// global concerns like keyboard shortcuts. Split those out so settings (and
|
||||
@@ -527,9 +512,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
|
||||
const appContentMinimumWidth = resolveDesktopAppContentMinimum({
|
||||
isSettingsRoute: pathname.includes("/settings"),
|
||||
isWorkspaceExplorerOpen: isWorkspaceRoute && isDesktopFileExplorerOpen,
|
||||
requestedExplorerWidth: explorerWidth,
|
||||
viewportWidth,
|
||||
});
|
||||
const desktopSidebarMounted = hasMountedDesktopSidebar && !isWorkspaceFocusModeEnabled;
|
||||
const desktopSidebarVisible = resolveDesktopSidebarVisibility({
|
||||
@@ -664,59 +646,29 @@ function MobileGestureWrapper({
|
||||
}
|
||||
|
||||
function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
const { settings, isLoading: settingsLoading } = useAppSettings();
|
||||
const { upsertConnectionFromOfferUrl } = useHostMutations();
|
||||
|
||||
// Apply theme setting on mount and when it changes
|
||||
useEffect(() => {
|
||||
if (settingsLoading) return;
|
||||
if (settings.theme === "auto") {
|
||||
UnistylesRuntime.setAdaptiveThemes(true);
|
||||
} else {
|
||||
UnistylesRuntime.setAdaptiveThemes(false);
|
||||
UnistylesRuntime.setTheme(THEME_TO_UNISTYLES[settings.theme]);
|
||||
}
|
||||
}, [settingsLoading, settings.theme]);
|
||||
|
||||
// Apply font / size / syntax appearance settings on mount and when they change.
|
||||
// Sibling to the theme effect above; order is irrelevant because both patch all
|
||||
// registered theme keys, so the active key is always current.
|
||||
useEffect(() => {
|
||||
if (settingsLoading) return;
|
||||
applyAppearance({
|
||||
uiFontFamily: settings.uiFontFamily,
|
||||
monoFontFamily: settings.monoFontFamily,
|
||||
uiBaseFontSize: settings.uiBaseFontSize,
|
||||
codeFontSize: settings.codeFontSize,
|
||||
syntaxTheme: settings.syntaxTheme,
|
||||
});
|
||||
}, [
|
||||
settingsLoading,
|
||||
settings.uiFontFamily,
|
||||
settings.monoFontFamily,
|
||||
settings.uiBaseFontSize,
|
||||
settings.codeFontSize,
|
||||
settings.syntaxTheme,
|
||||
]);
|
||||
|
||||
return (
|
||||
<VoiceProvider>
|
||||
<DesktopWindowControlsSync enabled={!settingsLoading} />
|
||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||
<HostSessionManager />
|
||||
<FaviconStatusSync />
|
||||
<AppearanceStyleBoundary>{children}</AppearanceStyleBoundary>
|
||||
</VoiceProvider>
|
||||
<AppearanceProvider>
|
||||
<VoiceProvider>
|
||||
<DesktopWindowControlsSync />
|
||||
<OfferLinkListener upsertDaemonFromOfferUrl={upsertConnectionFromOfferUrl} />
|
||||
<HostSessionManager />
|
||||
<FaviconStatusSync />
|
||||
<AppearanceStyleBoundary>{children}</AppearanceStyleBoundary>
|
||||
</VoiceProvider>
|
||||
</AppearanceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopWindowControlsSync({ enabled }: { enabled: boolean }) {
|
||||
function DesktopWindowControlsSync() {
|
||||
const { isLoading } = useAppSettings();
|
||||
const { theme } = useUnistyles();
|
||||
const surface0 = theme.colors.surface0;
|
||||
const foreground = theme.colors.foreground;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || isNative) return;
|
||||
if (isLoading || isNative) return;
|
||||
void updateDesktopWindowControls({
|
||||
backgroundColor: surface0,
|
||||
foregroundColor: foreground,
|
||||
@@ -724,7 +676,7 @@ function DesktopWindowControlsSync({ enabled }: { enabled: boolean }) {
|
||||
}).catch((error) => {
|
||||
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
|
||||
});
|
||||
}, [enabled, surface0, foreground]);
|
||||
}, [isLoading, surface0, foreground]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getWorkspaceSelectionKey,
|
||||
orderWorkspaceSelectionsForStableRender,
|
||||
reconcileRetainedWorkspaceSelections,
|
||||
resolveWorkspaceDeckRetentionLimit,
|
||||
type RetainedWorkspaceSelection,
|
||||
resolveWorkspaceDeckEntries,
|
||||
shouldKeepWorkspaceDeckEntryMounted,
|
||||
@@ -33,7 +34,7 @@ import {
|
||||
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit,
|
||||
} from "@/utils/host-route-browser";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
|
||||
function getParamValue(value: string | string[] | undefined): string {
|
||||
@@ -217,6 +218,7 @@ function WorkspaceDeck({
|
||||
currentEntries: retainedSelections,
|
||||
activeSelection,
|
||||
now: reconciliationNow,
|
||||
maxMountedWorkspaces: resolveWorkspaceDeckRetentionLimit({ isNative }),
|
||||
}),
|
||||
[activeSelection, reconciliationNow, retainedSelections],
|
||||
);
|
||||
@@ -253,6 +255,7 @@ function WorkspaceDeck({
|
||||
currentEntries: current,
|
||||
activeSelection,
|
||||
now: Date.now(),
|
||||
maxMountedWorkspaces: resolveWorkspaceDeckRetentionLimit({ isNative }),
|
||||
}),
|
||||
);
|
||||
}, expirationDelay + 1);
|
||||
|
||||
+14
-1
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { darkHighlightColors, resolveSyntaxColors } from "@getpaseo/highlight";
|
||||
import { DEFAULT_UI_FONT_STACK, REGISTERED_THEMES } from "@/styles/theme";
|
||||
import { applyAppearance, type AppearanceInput } from "./apply-appearance";
|
||||
import { applyAppearance, type AppearanceInput } from "./apply";
|
||||
|
||||
// Override the global react-native-unistyles mock (vitest.setup.ts) so that
|
||||
// UnistylesRuntime.updateTheme is a spy that records (themeName, updater) calls.
|
||||
@@ -27,6 +27,7 @@ interface FakeTheme {
|
||||
fontFamily: { ui: string; mono: string };
|
||||
fontSize: {
|
||||
code: number;
|
||||
content: number;
|
||||
sm: number;
|
||||
base: number;
|
||||
lg: number;
|
||||
@@ -45,6 +46,7 @@ function makeFakeTheme(): FakeTheme {
|
||||
fontFamily: { ui: "seed-ui-stack", mono: "seed-mono-stack" },
|
||||
fontSize: {
|
||||
code: 12,
|
||||
content: 15,
|
||||
sm: 12,
|
||||
base: 14,
|
||||
lg: 16,
|
||||
@@ -63,6 +65,7 @@ function makeInput(overrides: Partial<AppearanceInput> = {}): AppearanceInput {
|
||||
uiFontFamily: "",
|
||||
monoFontFamily: "",
|
||||
uiBaseFontSize: 14,
|
||||
contentFontSize: 15,
|
||||
codeFontSize: 12,
|
||||
syntaxTheme: "one",
|
||||
...overrides,
|
||||
@@ -131,6 +134,7 @@ describe("applyAppearance", () => {
|
||||
const alreadyScaled = makeFakeTheme();
|
||||
alreadyScaled.fontSize = {
|
||||
code: 4,
|
||||
content: 4,
|
||||
sm: 4,
|
||||
base: 4,
|
||||
lg: 4,
|
||||
@@ -155,6 +159,15 @@ describe("applyAppearance", () => {
|
||||
expect(fontSize.code).toBe(10);
|
||||
});
|
||||
|
||||
it("sets content size independently of the interface and code sizes", () => {
|
||||
applyAppearance(makeInput({ uiBaseFontSize: 14, contentFontSize: 19, codeFontSize: 10 }));
|
||||
|
||||
const { fontSize } = runCapturedUpdater();
|
||||
expect(fontSize.base).toBe(14);
|
||||
expect(fontSize.content).toBe(19);
|
||||
expect(fontSize.code).toBe(10);
|
||||
});
|
||||
|
||||
it("sets fontSize.code to codeFontSize regardless of the UI font size", () => {
|
||||
applyAppearance(makeInput({ uiBaseFontSize: 14, codeFontSize: 18 }));
|
||||
|
||||
+12
-2
@@ -15,6 +15,7 @@ export interface AppearanceInput {
|
||||
uiFontFamily: string; // "" -> default stack
|
||||
monoFontFamily: string; // "" -> default stack
|
||||
uiBaseFontSize: number; // already clamped
|
||||
contentFontSize: number; // already clamped
|
||||
codeFontSize: number; // already clamped
|
||||
syntaxTheme: SyntaxThemeId;
|
||||
}
|
||||
@@ -28,7 +29,11 @@ export interface AppearanceInput {
|
||||
* `code` is set absolutely to `codeSize`, never scaled by the UI factor — a separate
|
||||
* control on a separate semantic axis (mono/diff text).
|
||||
*/
|
||||
function scaleFontSize(uiBaseSize: number, codeSize: number): Theme["fontSize"] {
|
||||
function scaleFontSize(
|
||||
uiBaseSize: number,
|
||||
contentSize: number,
|
||||
codeSize: number,
|
||||
): Theme["fontSize"] {
|
||||
const r = uiBaseSize / FONT_SIZE.base;
|
||||
return {
|
||||
sm: Math.round(FONT_SIZE.sm * r),
|
||||
@@ -38,6 +43,7 @@ function scaleFontSize(uiBaseSize: number, codeSize: number): Theme["fontSize"]
|
||||
"2xl": Math.round(FONT_SIZE["2xl"] * r),
|
||||
"3xl": Math.round(FONT_SIZE["3xl"] * r),
|
||||
"4xl": Math.round(FONT_SIZE["4xl"] * r),
|
||||
content: contentSize, // absolute, NOT scaled
|
||||
code: codeSize, // absolute, NOT scaled
|
||||
};
|
||||
}
|
||||
@@ -68,7 +74,11 @@ export function applyAppearance(input: AppearanceInput): void {
|
||||
for (const key of themeKeys) {
|
||||
UnistylesRuntime.updateTheme(key, (t) => {
|
||||
const fontFamily = { ui, mono };
|
||||
const fontSize = scaleFontSize(input.uiBaseFontSize, input.codeFontSize);
|
||||
const fontSize = scaleFontSize(
|
||||
input.uiBaseFontSize,
|
||||
input.contentFontSize,
|
||||
input.codeFontSize,
|
||||
);
|
||||
const lineHeight = { ...t.lineHeight, diff: diffLineHeight };
|
||||
if (t.colorScheme === "light") {
|
||||
return {
|
||||
@@ -0,0 +1,97 @@
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo } from "react";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { DEFAULT_THEME_PREFERENCE, useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
import {
|
||||
rememberPluginThemeHost,
|
||||
usePluginThemeCatalog,
|
||||
type PluginThemeOption,
|
||||
} from "@/plugins/themes";
|
||||
import { PLUGIN_THEME_NAMES, PLUGIN_THEME_PREFERENCE, THEME_TO_UNISTYLES } from "@/styles/theme";
|
||||
import { applyAppearance } from "./apply";
|
||||
|
||||
interface ContributedThemes {
|
||||
options: PluginThemeOption[];
|
||||
selected: PluginThemeOption | null;
|
||||
select: (option: PluginThemeOption) => void;
|
||||
}
|
||||
|
||||
interface ApplyThemeInput {
|
||||
preference: AppSettings["theme"];
|
||||
contributedTheme: PluginThemeOption | null;
|
||||
}
|
||||
|
||||
const ContributedThemesContext = createContext<ContributedThemes | null>(null);
|
||||
|
||||
function applyTheme({ preference, contributedTheme }: ApplyThemeInput): void {
|
||||
if (contributedTheme) {
|
||||
const themeName = PLUGIN_THEME_NAMES[contributedTheme.theme.colorScheme];
|
||||
UnistylesRuntime.updateTheme(themeName, () => contributedTheme.theme);
|
||||
UnistylesRuntime.setAdaptiveThemes(false);
|
||||
UnistylesRuntime.setTheme(themeName);
|
||||
return;
|
||||
}
|
||||
|
||||
const builtInPreference =
|
||||
preference === PLUGIN_THEME_PREFERENCE ? DEFAULT_THEME_PREFERENCE : preference;
|
||||
if (builtInPreference === "auto") {
|
||||
UnistylesRuntime.setAdaptiveThemes(true);
|
||||
return;
|
||||
}
|
||||
|
||||
UnistylesRuntime.setAdaptiveThemes(false);
|
||||
UnistylesRuntime.setTheme(THEME_TO_UNISTYLES[builtInPreference]);
|
||||
}
|
||||
|
||||
export function AppearanceProvider({ children }: { children: ReactNode }) {
|
||||
const { settings, updateSettings, isLoading } = useAppSettings();
|
||||
const options = usePluginThemeCatalog();
|
||||
const selected = useMemo(() => {
|
||||
if (settings.theme !== PLUGIN_THEME_PREFERENCE) return null;
|
||||
return options.find((option) => option.id === settings.pluginThemeId) ?? null;
|
||||
}, [options, settings.pluginThemeId, settings.theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
applyTheme({ preference: settings.theme, contributedTheme: selected });
|
||||
applyAppearance({
|
||||
uiFontFamily: settings.uiFontFamily,
|
||||
monoFontFamily: settings.monoFontFamily,
|
||||
uiBaseFontSize: settings.uiBaseFontSize,
|
||||
contentFontSize: settings.contentFontSize,
|
||||
codeFontSize: settings.codeFontSize,
|
||||
syntaxTheme: settings.syntaxTheme,
|
||||
});
|
||||
}, [
|
||||
isLoading,
|
||||
selected,
|
||||
settings.theme,
|
||||
settings.uiFontFamily,
|
||||
settings.monoFontFamily,
|
||||
settings.uiBaseFontSize,
|
||||
settings.contentFontSize,
|
||||
settings.codeFontSize,
|
||||
settings.syntaxTheme,
|
||||
]);
|
||||
|
||||
const select = useCallback(
|
||||
(option: PluginThemeOption) => {
|
||||
rememberPluginThemeHost(option);
|
||||
void updateSettings({
|
||||
theme: PLUGIN_THEME_PREFERENCE,
|
||||
pluginThemeId: option.id,
|
||||
});
|
||||
},
|
||||
[updateSettings],
|
||||
);
|
||||
const value = useMemo(() => ({ options, selected, select }), [options, selected, select]);
|
||||
|
||||
return (
|
||||
<ContributedThemesContext.Provider value={value}>{children}</ContributedThemesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useContributedThemes(): ContributedThemes {
|
||||
const themes = useContext(ContributedThemesContext);
|
||||
if (themes === null) throw new Error("useContributedThemes requires AppearanceProvider");
|
||||
return themes;
|
||||
}
|
||||
@@ -51,6 +51,8 @@ export const ACP_PROVIDER_ICON_SVGS = {
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 40 40" fill="none">\n <path d="M25 15H35V16.75C35 29 30.5001 35 16.5001 35H15V25H16.5001C22.6251 25 25 22.875 25 16.75V15Z" fill="currentColor"/>\n <rect x="5" y="15" width="10" height="10" fill="currentColor"/>\n <rect x="15" y="5" width="10" height="10" fill="currentColor"/>\n</svg>\n',
|
||||
kilo: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 40 40">\n <path fill="currentColor" d="M27.9998324,34.6666921h6.6666279v5.3333023h-8.3809037l-3.6190266-3.6190266v-8.3809037h5.3333023v6.6666279ZM39.9997627,26.2857884l-3.6190266-3.6190266h-8.3809037v5.3333023h6.6666279v6.6666279h5.3333023v-8.3809037ZM17.3332326,22.6667578h-5.3333023v5.3333023h5.3333023v-5.3333023ZM0,36.3809638l3.6190266,3.6190266h13.714206v-5.3333023H5.3333023v-11.9999302H0v13.714206ZM33.9600959,11.9999247V3.619021L30.3410693-.0000056h-7.6745392v5.3333023h5.9602634v6.6666279h-5.9602634v5.3333023h17.3332326v-5.3333023h-6.0396668ZM5.3333023,0H0v17.3332326h5.3333023v-5.9999651h6.6666279v5.9999651h5.3333023v-5.9999651l-5.3333023-5.3333023h-6.6666279V0ZM17.3332326,0h-5.3333023v5.9999651h5.3333023V0Z"/>\n</svg>\n',
|
||||
kimi: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">\n <path fill="currentColor" d="M19.514 7.342C19.711 7.862 19.814 8.413 19.816 8.969C19.816 12.494 15.998 14.697 12.946 12.934C11.529 12.116 10.657 10.605 10.657 8.969C10.657 5.646 14.085 3.431 17.115 4.793C15.26 3.373 12.99 2.607 10.655 2.612C4.772 2.614 0.005 7.381 0 13.265C0.002 19.148 4.772 23.918 10.655 23.92C16.538 23.916 21.306 19.147 21.31 13.265C21.312 11.155 20.687 9.095 19.514 7.342ZM14.841 4.666C14.841 8.191 18.657 10.395 21.709 8.632C23.127 7.814 24 6.302 24 4.666C24 1.14 20.184-1.061 17.13 0.699C15.714 1.519 14.841 3.03 14.841 4.666"/>\n</svg>\n',
|
||||
"minimax-code":
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">\n <path fill="currentColor" d="M2 3h3l3 5 3-5h3v10h-2V6.5l-4 6-4-6V13H2V3Z"/>\n</svg>\n',
|
||||
"minion-code":
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">\n <rect fill="none" stroke="currentColor" stroke-width="1.1" x="4.5" y="2.5" width="7" height="10" rx="3.5"/>\n <line stroke="currentColor" stroke-width="1" x1="3" y1="5.5" x2="13" y2="5.5"/>\n <circle fill="none" stroke="currentColor" stroke-width="1" cx="6.5" cy="5.5" r="1.6"/>\n <circle fill="none" stroke="currentColor" stroke-width="1" cx="9.5" cy="5.5" r="1.6"/>\n <circle fill="currentColor" cx="6.8" cy="5.6" r="0.55"/>\n <circle fill="currentColor" cx="9.8" cy="5.6" r="0.55"/>\n <path fill="none" stroke="currentColor" stroke-width="0.6" stroke-linecap="round" d="M7 8.3 Q8 9 9 8.3"/>\n <path fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" d="M4.5 8 L3 9.5"/>\n <path fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round" d="M11.5 8 L13 9.5"/>\n <line stroke="currentColor" stroke-width="0.7" x1="5" y1="9.5" x2="11" y2="9.5"/>\n <line stroke="currentColor" stroke-width="0.9" x1="6.5" y1="12.5" x2="6.5" y2="14"/>\n <line stroke="currentColor" stroke-width="0.9" x1="9.5" y1="12.5" x2="9.5" y2="14"/>\n <line stroke="currentColor" stroke-width="0.9" stroke-linecap="round" x1="6.5" y1="14" x2="5.5" y2="14"/>\n <line stroke="currentColor" stroke-width="0.9" stroke-linecap="round" x1="9.5" y1="14" x2="10.5" y2="14"/>\n <line stroke="currentColor" stroke-width="0.7" stroke-linecap="round" x1="8" y1="2.5" x2="7.3" y2="0.8"/>\n <line stroke="currentColor" stroke-width="0.7" stroke-linecap="round" x1="8" y1="2.5" x2="8" y2="0.5"/>\n <line stroke="currentColor" stroke-width="0.7" stroke-linecap="round" x1="8" y1="2.5" x2="8.7" y2="0.8"/>\n</svg>\n',
|
||||
"mistral-vibe":
|
||||
|
||||
@@ -132,7 +132,13 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
|
||||
filePath: path,
|
||||
persistenceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
|
||||
showMobileAgent: usePanelStore.getState().showMobileAgent,
|
||||
openWorkspaceTabFocused: useWorkspaceLayoutStore.getState().openTabFocused,
|
||||
openWorkspaceTabInFocusedPane: (workspaceKey, target, placement) =>
|
||||
useWorkspaceLayoutStore.getState().openTab({
|
||||
workspaceKey,
|
||||
target,
|
||||
intent: "reveal",
|
||||
placement,
|
||||
}),
|
||||
focusWorkspaceTab: useWorkspaceLayoutStore.getState().focusTab,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { darkTheme } from "@/styles/theme";
|
||||
import { appearanceStyleBoundaryKey } from "./appearance-style-boundary";
|
||||
|
||||
describe("appearanceStyleBoundaryKey", () => {
|
||||
it("changes when content size changes without any other appearance token changing", () => {
|
||||
const contentOnlyChange = {
|
||||
...darkTheme,
|
||||
fontSize: { ...darkTheme.fontSize, content: darkTheme.fontSize.content + 1 },
|
||||
};
|
||||
|
||||
expect(appearanceStyleBoundaryKey(contentOnlyChange)).not.toBe(
|
||||
appearanceStyleBoundaryKey(darkTheme),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -11,8 +11,8 @@ function AppearanceStyleBoundaryBase({ appearanceKey, children }: AppearanceStyl
|
||||
return <Fragment key={appearanceKey}>{children}</Fragment>;
|
||||
}
|
||||
|
||||
const appearanceStyleBoundaryMapping = (theme: Theme): Partial<AppearanceStyleBoundaryProps> => ({
|
||||
appearanceKey: [
|
||||
export function appearanceStyleBoundaryKey(theme: Theme): string {
|
||||
return [
|
||||
theme.fontFamily.ui,
|
||||
theme.fontFamily.mono,
|
||||
theme.fontSize.sm,
|
||||
@@ -22,6 +22,7 @@ const appearanceStyleBoundaryMapping = (theme: Theme): Partial<AppearanceStyleBo
|
||||
theme.fontSize["2xl"],
|
||||
theme.fontSize["3xl"],
|
||||
theme.fontSize["4xl"],
|
||||
theme.fontSize.content,
|
||||
theme.fontSize.code,
|
||||
theme.lineHeight.diff,
|
||||
theme.colors.foreground,
|
||||
@@ -51,8 +52,12 @@ const appearanceStyleBoundaryMapping = (theme: Theme): Partial<AppearanceStyleBo
|
||||
theme.colors.syntax.meta,
|
||||
theme.colors.syntax.heading,
|
||||
theme.colors.syntax.link,
|
||||
].join("\u0000"),
|
||||
});
|
||||
].join("\u0000");
|
||||
}
|
||||
|
||||
function appearanceStyleBoundaryMapping(theme: Theme): Partial<AppearanceStyleBoundaryProps> {
|
||||
return { appearanceKey: appearanceStyleBoundaryKey(theme) };
|
||||
}
|
||||
|
||||
const ThemedAppearanceStyleBoundary = withUnistyles(AppearanceStyleBoundaryBase);
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ import { View } from "react-native";
|
||||
import { GestureDetector } from "react-native-gesture-handler";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspace } from "@/stores/session-store-hooks";
|
||||
import { CompactExplorerSidebar } from "@/components/explorer-sidebar";
|
||||
import { CompactExplorerSidebar } from "@/components/compact-explorer-sidebar";
|
||||
import { useOpenFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { selectIsCompactFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import { useWorkspaceCheckoutStatus } from "@/screens/workspace/use-workspace-checkout-status";
|
||||
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -46,9 +47,7 @@ function useActiveCompactExplorerSidebarModel(
|
||||
): CompactExplorerSidebarHostModel | null {
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
const workspace = useWorkspace(selection?.serverId ?? null, selection?.workspaceId ?? null);
|
||||
const isExplorerOpen = usePanelStore((state) =>
|
||||
selectIsFileExplorerOpen(state, { isCompact: true }),
|
||||
);
|
||||
const isExplorerOpen = usePanelStore(selectIsCompactFileExplorerOpen);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const client = useHostRuntimeClient(selection?.serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(selection?.serverId ?? "");
|
||||
@@ -99,24 +98,26 @@ interface CompactExplorerSidebarHostProps {
|
||||
|
||||
export function CompactExplorerSidebarHost({ children, enabled }: CompactExplorerSidebarHostProps) {
|
||||
const model = useActiveCompactExplorerSidebarModel(enabled);
|
||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||
const openCompactFileExplorer = usePanelStore((state) => state.openCompactFileExplorer);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
|
||||
const openTab = useWorkspaceLayoutStore((state) => state.openTab);
|
||||
const openWorkspaceTabInFocusedPane = useCallback(
|
||||
(workspaceKey: string, target: WorkspaceTabTarget) =>
|
||||
openTab({ workspaceKey, target, intent: "reveal" }),
|
||||
[openTab],
|
||||
);
|
||||
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||
|
||||
const handleOpenExplorer = useCallback(() => {
|
||||
if (!model?.workspaceRoot) {
|
||||
return;
|
||||
}
|
||||
openFileExplorerForCheckout({
|
||||
isCompact: true,
|
||||
checkout: {
|
||||
serverId: model.serverId,
|
||||
cwd: model.workspaceRoot,
|
||||
isGit: model.isGit,
|
||||
},
|
||||
openCompactFileExplorer({
|
||||
serverId: model.serverId,
|
||||
cwd: model.workspaceRoot,
|
||||
isGit: model.isGit,
|
||||
});
|
||||
}, [model, openFileExplorerForCheckout]);
|
||||
}, [model, openCompactFileExplorer]);
|
||||
|
||||
const handleOpenFile = useCallback(
|
||||
(filePath: string) => {
|
||||
@@ -127,11 +128,11 @@ export function CompactExplorerSidebarHost({ children, enabled }: CompactExplore
|
||||
filePath,
|
||||
persistenceKey: model.persistenceKey,
|
||||
showMobileAgent,
|
||||
openWorkspaceTabFocused,
|
||||
openWorkspaceTabInFocusedPane,
|
||||
focusWorkspaceTab,
|
||||
});
|
||||
},
|
||||
[focusWorkspaceTab, model, openWorkspaceTabFocused, showMobileAgent],
|
||||
[focusWorkspaceTab, model, openWorkspaceTabInFocusedPane, showMobileAgent],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
+35
-175
@@ -1,20 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Pressable,
|
||||
useWindowDimensions,
|
||||
StyleSheet as RNStyleSheet,
|
||||
} from "react-native";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import Animated, { useAnimatedStyle, useSharedValue, runOnJS } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatPrTabLabel, PullRequestTabIcon } from "@/git/pull-request-panel";
|
||||
import { usePanelStore, selectIsFileExplorerOpen, type ExplorerTab } from "@/stores/panel-store";
|
||||
import {
|
||||
usePanelStore,
|
||||
selectIsCompactFileExplorerOpen,
|
||||
type ExplorerTab,
|
||||
} from "@/stores/panel-store";
|
||||
import { useCloseFileExplorerGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import {
|
||||
@@ -23,19 +18,14 @@ import {
|
||||
HEADER_TOP_PADDING_MOBILE,
|
||||
} from "@/constants/layout";
|
||||
import { ChangesSurface } from "@/git/diff-pane";
|
||||
import { changesStateSchema, defaultChangesState, type ChangesState } from "@/panels/changes/state";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { shouldUseCompactExplorerKeyboardPadding } from "@/hooks/keyboard-shift-policy";
|
||||
import { useHasOwnedWindowChromeObstruction, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { RetainedPanel, RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
|
||||
import {
|
||||
SIDEBAR_RESIZE_ACTIVATION_OFFSET,
|
||||
SIDEBAR_RESIZE_FAIL_OFFSET,
|
||||
} from "@/components/sidebar-resize-handle-layout";
|
||||
import { usePullRequestPanelAvailability } from "@/panels/pull-request-availability";
|
||||
import { PullRequestContent } from "@/panels/pull-request";
|
||||
import { useAddFileToChat } from "@/panels/use-add-file-to-chat";
|
||||
@@ -81,7 +71,7 @@ export function CompactExplorerSidebar({
|
||||
}: ExplorerSidebarProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: true }));
|
||||
const isOpen = usePanelStore(selectIsCompactFileExplorerOpen);
|
||||
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
||||
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
|
||||
serverId,
|
||||
@@ -149,123 +139,6 @@ export function CompactExplorerSidebar({
|
||||
);
|
||||
}
|
||||
|
||||
export function ExplorerSidebar({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
onOpenFile,
|
||||
}: ExplorerSidebarProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const explorerWidth = usePanelStore((state) => state.explorerWidth);
|
||||
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
|
||||
const isOpen = usePanelStore((state) => selectIsFileExplorerOpen(state, { isCompact: false }));
|
||||
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
|
||||
const { explorerTab, handleTabPress } = useExplorerSidebarSharedState({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
isGit,
|
||||
});
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const visibleExplorerWidth = resolveDesktopExplorerWidth({
|
||||
requestedWidth: explorerWidth,
|
||||
viewportWidth,
|
||||
});
|
||||
const startWidthRef = useRef(visibleExplorerWidth);
|
||||
const resizeWidth = useSharedValue(visibleExplorerWidth);
|
||||
const [resizePressed, setResizePressed] = useState(false);
|
||||
const showResizeGrip = useCallback(() => setResizePressed(true), []);
|
||||
const hideResizeGrip = useCallback(() => setResizePressed(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
resizeWidth.value = visibleExplorerWidth;
|
||||
}, [resizeWidth, visibleExplorerWidth]);
|
||||
|
||||
const handleDesktopClose = useCallback(() => {
|
||||
logExplorerSidebar("handleClose", {
|
||||
reason: "desktop-close-button",
|
||||
isOpen,
|
||||
});
|
||||
closeDesktopFileExplorer();
|
||||
}, [closeDesktopFileExplorer, isOpen]);
|
||||
|
||||
const resizeGesture = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.enabled(true)
|
||||
.hitSlop({ left: 8, right: 8, top: 0, bottom: 0 })
|
||||
.onBegin(() => {
|
||||
scheduleOnRN(showResizeGrip);
|
||||
})
|
||||
// See the left sidebar's gesture: horizontal intent only, with the start
|
||||
// width anchored to the activation translation so the threshold is free.
|
||||
.activeOffsetX([-SIDEBAR_RESIZE_ACTIVATION_OFFSET, SIDEBAR_RESIZE_ACTIVATION_OFFSET])
|
||||
.failOffsetY([-SIDEBAR_RESIZE_FAIL_OFFSET, SIDEBAR_RESIZE_FAIL_OFFSET])
|
||||
.onStart((event) => {
|
||||
startWidthRef.current = visibleExplorerWidth + event.translationX;
|
||||
resizeWidth.value = visibleExplorerWidth;
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
const newWidth = startWidthRef.current - event.translationX;
|
||||
resizeWidth.value = resolveDesktopExplorerWidth({
|
||||
requestedWidth: newWidth,
|
||||
viewportWidth,
|
||||
});
|
||||
})
|
||||
.onEnd(() => {
|
||||
runOnJS(setExplorerWidth)(resizeWidth.value);
|
||||
})
|
||||
.onFinalize(() => {
|
||||
scheduleOnRN(hideResizeGrip);
|
||||
}),
|
||||
[
|
||||
hideResizeGrip,
|
||||
resizeWidth,
|
||||
setExplorerWidth,
|
||||
showResizeGrip,
|
||||
viewportWidth,
|
||||
visibleExplorerWidth,
|
||||
],
|
||||
);
|
||||
|
||||
const resizeAnimatedStyle = useAnimatedStyle(() => ({
|
||||
width: resizeWidth.value,
|
||||
}));
|
||||
const desktopSidebarStyle = useMemo(
|
||||
() => [explorerStaticStyles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }],
|
||||
[resizeAnimatedStyle, insets.top],
|
||||
);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={desktopSidebarStyle}>
|
||||
<View style={[styles.desktopSidebarBorder, { flex: 1 }]}>
|
||||
<SidebarResizeHandle
|
||||
edge="left"
|
||||
gesture={resizeGesture}
|
||||
pressed={resizePressed}
|
||||
testID="explorer-sidebar-resize-handle"
|
||||
/>
|
||||
|
||||
<ExplorerSidebarContent
|
||||
activeTab={explorerTab}
|
||||
onTabPress={handleTabPress}
|
||||
onClose={handleDesktopClose}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isGit={isGit}
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExplorerTabButtonProps {
|
||||
tab: ExplorerTab;
|
||||
active: boolean;
|
||||
@@ -319,7 +192,6 @@ function ExplorerSidebarContent({
|
||||
}: SidebarContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const hasRightWindowControls = useHasOwnedWindowChromeObstruction("top-right");
|
||||
const { prPane, showPullRequest: showPrTab } = usePullRequestPanelAvailability({
|
||||
serverId,
|
||||
cwd: workspaceRoot,
|
||||
@@ -358,7 +230,7 @@ function ExplorerSidebarContent({
|
||||
<ExplorerTabButton
|
||||
tab="changes"
|
||||
active={resolvedTab === "changes"}
|
||||
label={t("workspace.tabs.explorer.changes")}
|
||||
label={t("workspace.tabs.sidePanel.changes")}
|
||||
onTabPress={onTabPress}
|
||||
testID="explorer-tab-changes"
|
||||
/>
|
||||
@@ -366,7 +238,7 @@ function ExplorerSidebarContent({
|
||||
<ExplorerTabButton
|
||||
tab="files"
|
||||
active={resolvedTab === "files"}
|
||||
label={t("workspace.tabs.explorer.files")}
|
||||
label={t("workspace.tabs.sidePanel.files")}
|
||||
onTabPress={onTabPress}
|
||||
testID="explorer-tab-files"
|
||||
/>
|
||||
@@ -389,27 +261,23 @@ function ExplorerSidebarContent({
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.headerRightSection}>
|
||||
{!hasRightWindowControls && (
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={styles.closeButton}
|
||||
testID="explorer-close"
|
||||
nativeID="explorer-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("workspace.tabs.explorer.close")}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<X
|
||||
size={18}
|
||||
color={
|
||||
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={styles.closeButton}
|
||||
testID="explorer-close"
|
||||
nativeID="explorer-close"
|
||||
accessible
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("workspace.tabs.sidePanel.close")}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<X
|
||||
size={18}
|
||||
color={hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</WindowChromeSafeArea>
|
||||
|
||||
@@ -462,6 +330,9 @@ function ChangedFilesPane({
|
||||
"serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile"
|
||||
>) {
|
||||
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
||||
const [changesState, setChangesState] = useState<ChangesState>(() =>
|
||||
changesStateSchema.parse(defaultChangesState),
|
||||
);
|
||||
return (
|
||||
<ChangesSurface
|
||||
host="explorer"
|
||||
@@ -469,8 +340,11 @@ function ChangedFilesPane({
|
||||
workspaceId={workspaceId}
|
||||
cwd={workspaceRoot}
|
||||
enabled={isOpen}
|
||||
modeScope="compact-explorer"
|
||||
onOpenFile={onOpenFile}
|
||||
onAddToChat={canAddToChat ? addFile : undefined}
|
||||
state={changesState}
|
||||
onStateChange={setChangesState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -495,21 +369,7 @@ function FilesPane({
|
||||
|
||||
const PrTabContent = PullRequestContent;
|
||||
|
||||
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
|
||||
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||
// tries to patch the native node that Reanimated also manages.
|
||||
const explorerStaticStyles = RNStyleSheet.create({
|
||||
desktopSidebar: {
|
||||
position: "relative" as const,
|
||||
},
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
desktopSidebarBorder: {
|
||||
borderLeftWidth: 1,
|
||||
borderLeftColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
sidebarContent: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
canDesktopAppSidebarShare,
|
||||
resolveDesktopAppChromeLayout,
|
||||
resolveDesktopAppContentMinimum,
|
||||
resolveDesktopExplorerWidth,
|
||||
resolveDesktopSidebarVisibility,
|
||||
resolveDesktopSidebarWidth,
|
||||
} from "@/components/desktop-sidebar-layout";
|
||||
@@ -79,18 +78,8 @@ describe("desktop sidebar layout", () => {
|
||||
expect(resolveDesktopSidebarWidth({ requestedWidth: 600, viewportWidth: 1440 })).toBe(600);
|
||||
});
|
||||
|
||||
it("keeps a temporarily narrow explorer render-only", () => {
|
||||
expect(resolveDesktopExplorerWidth({ requestedWidth: 400, viewportWidth: 751 })).toBe(351);
|
||||
expect(resolveDesktopExplorerWidth({ requestedWidth: 400, viewportWidth: 1440 })).toBe(400);
|
||||
});
|
||||
|
||||
it("yields app navigation when settings or Explorer need the shell width", () => {
|
||||
const settingsMinimum = resolveDesktopAppContentMinimum({
|
||||
isSettingsRoute: true,
|
||||
isWorkspaceExplorerOpen: false,
|
||||
requestedExplorerWidth: 400,
|
||||
viewportWidth: 751,
|
||||
});
|
||||
it("yields app navigation when settings needs the shell width", () => {
|
||||
const settingsMinimum = resolveDesktopAppContentMinimum({ isSettingsRoute: true });
|
||||
expect(settingsMinimum).toBe(720);
|
||||
expect(
|
||||
canDesktopAppSidebarShare({
|
||||
@@ -99,32 +88,16 @@ describe("desktop sidebar layout", () => {
|
||||
viewportWidth: 751,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
const explorerMinimum = resolveDesktopAppContentMinimum({
|
||||
isSettingsRoute: false,
|
||||
isWorkspaceExplorerOpen: true,
|
||||
requestedExplorerWidth: 400,
|
||||
viewportWidth: 751,
|
||||
});
|
||||
expect(explorerMinimum).toBe(751);
|
||||
it("imposes no content minimum outside settings", () => {
|
||||
expect(resolveDesktopAppContentMinimum({ isSettingsRoute: false })).toBe(0);
|
||||
expect(
|
||||
canDesktopAppSidebarShare({
|
||||
contentMinimumWidth: explorerMinimum,
|
||||
contentMinimumWidth: resolveDesktopAppContentMinimum({ isSettingsRoute: false }),
|
||||
requestedSidebarWidth: 320,
|
||||
viewportWidth: 751,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
canDesktopAppSidebarShare({
|
||||
contentMinimumWidth: resolveDesktopAppContentMinimum({
|
||||
isSettingsRoute: false,
|
||||
isWorkspaceExplorerOpen: true,
|
||||
requestedExplorerWidth: 400,
|
||||
viewportWidth: 1120,
|
||||
}),
|
||||
requestedSidebarWidth: 320,
|
||||
viewportWidth: 1120,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { SETTINGS_DESKTOP_SPLIT_MIN_WIDTH } from "@/constants/layout";
|
||||
import {
|
||||
MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||
MAX_SIDEBAR_WIDTH,
|
||||
MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
MIN_SIDEBAR_WIDTH,
|
||||
} from "@/stores/panel-store";
|
||||
import { MAX_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
|
||||
|
||||
export const MIN_DESKTOP_CENTER_WIDTH = 400;
|
||||
const MIN_DESKTOP_CENTER_WIDTH = 400;
|
||||
|
||||
export function resolveDesktopSidebarVisibility(input: {
|
||||
chromeEnabled: boolean;
|
||||
@@ -67,32 +62,8 @@ export function resolveDesktopSidebarWidth(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDesktopExplorerWidth(input: {
|
||||
requestedWidth: number;
|
||||
viewportWidth: number;
|
||||
}): number {
|
||||
"worklet";
|
||||
return resolveDesktopPanelWidth({
|
||||
...input,
|
||||
minimumWidth: MIN_EXPLORER_SIDEBAR_WIDTH,
|
||||
maximumWidth: MAX_EXPLORER_SIDEBAR_WIDTH,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDesktopAppContentMinimum(input: {
|
||||
isSettingsRoute: boolean;
|
||||
isWorkspaceExplorerOpen: boolean;
|
||||
requestedExplorerWidth: number;
|
||||
viewportWidth: number;
|
||||
}): number {
|
||||
const workspaceMinimum = input.isWorkspaceExplorerOpen
|
||||
? MIN_DESKTOP_CENTER_WIDTH +
|
||||
resolveDesktopExplorerWidth({
|
||||
requestedWidth: input.requestedExplorerWidth,
|
||||
viewportWidth: input.viewportWidth,
|
||||
})
|
||||
: 0;
|
||||
return Math.max(input.isSettingsRoute ? SETTINGS_DESKTOP_SPLIT_MIN_WIDTH : 0, workspaceMinimum);
|
||||
export function resolveDesktopAppContentMinimum(input: { isSettingsRoute: boolean }): number {
|
||||
return input.isSettingsRoute ? SETTINGS_DESKTOP_SPLIT_MIN_WIDTH : 0;
|
||||
}
|
||||
|
||||
export function canDesktopAppSidebarShare(input: {
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from "react-native";
|
||||
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { ChevronDown, Eye, EyeOff, FilePlus, FolderPlus, RotateCw } from "lucide-react-native";
|
||||
@@ -38,6 +38,11 @@ import {
|
||||
WORKSPACE_TREE_LOADING_ICON_SIZE,
|
||||
} from "@/components/tree-primitives";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
PaneContentToolbar,
|
||||
paneContentToolbarIconSize,
|
||||
paneContentToolbarIconButtonStyle,
|
||||
} from "@/components/ui/pane-content-toolbar";
|
||||
import {
|
||||
useOverlayFlatListScrollbar,
|
||||
type OverlayFlatListScrollbar,
|
||||
@@ -136,8 +141,11 @@ function sortTriggerStyle({
|
||||
return [styles.sortTrigger, (Boolean(hovered) || pressed) && styles.sortTriggerHovered];
|
||||
}
|
||||
|
||||
function iconButtonStyle({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.iconButton, (Boolean(hovered) || pressed) && styles.iconButtonHovered];
|
||||
function iconButtonStyle(
|
||||
isCompact: boolean,
|
||||
{ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean },
|
||||
) {
|
||||
return paneContentToolbarIconButtonStyle({ hovered, pressed }, false, isCompact);
|
||||
}
|
||||
|
||||
type ExplorerListRow =
|
||||
@@ -1041,6 +1049,11 @@ export function FileExplorerPane({
|
||||
});
|
||||
}, [requestDirectoryListing]);
|
||||
|
||||
const toolbarIconButtonStyle = useCallback(
|
||||
(state: PressableStateCallbackType) => iconButtonStyle(isCompact, state),
|
||||
[isCompact],
|
||||
);
|
||||
|
||||
if (!hasWorkspaceScope) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
@@ -1058,6 +1071,7 @@ export function FileExplorerPane({
|
||||
>
|
||||
<FileExplorerPaneContent
|
||||
error={error}
|
||||
isCompact={isCompact}
|
||||
showInitialLoading={showInitialLoading}
|
||||
showBackFromError={showBackFromError}
|
||||
listRows={listRows}
|
||||
@@ -1073,7 +1087,7 @@ export function FileExplorerPane({
|
||||
handleBackFromError={handleBackFromError}
|
||||
handleRetry={handleRetry}
|
||||
sortTriggerStyle={sortTriggerStyle}
|
||||
iconButtonStyle={iconButtonStyle}
|
||||
iconButtonStyle={toolbarIconButtonStyle}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -1132,6 +1146,7 @@ function RootCreationContextTarget({
|
||||
|
||||
interface FileExplorerPaneContentProps {
|
||||
error: string | null;
|
||||
isCompact: boolean;
|
||||
showInitialLoading: boolean;
|
||||
showBackFromError: boolean;
|
||||
listRows: ExplorerListRow[];
|
||||
@@ -1155,6 +1170,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
error,
|
||||
isCompact,
|
||||
showInitialLoading,
|
||||
showBackFromError,
|
||||
listRows,
|
||||
@@ -1229,7 +1245,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
|
||||
return (
|
||||
<View style={[styles.treePane, styles.treePaneFill]}>
|
||||
<View style={styles.paneHeader} testID="files-pane-header">
|
||||
<PaneContentToolbar style={styles.paneHeader} testID="files-pane-header">
|
||||
<Pressable
|
||||
onPress={handleSortCycle}
|
||||
style={sortTriggerStyleProp}
|
||||
@@ -1251,7 +1267,10 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
accessibilityLabel={t("workspace.fileActions.newFile")}
|
||||
testID="files-new-file"
|
||||
>
|
||||
<FilePlus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<FilePlus
|
||||
size={paneContentToolbarIconSize(isCompact)}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleNewFolderAtRoot}
|
||||
@@ -1261,7 +1280,10 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
accessibilityLabel={t("workspace.fileActions.newFolder")}
|
||||
testID="files-new-folder"
|
||||
>
|
||||
<FolderPlus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<FolderPlus
|
||||
size={paneContentToolbarIconSize(isCompact)}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
</>
|
||||
) : null}
|
||||
@@ -1275,9 +1297,15 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
testID="files-hidden-toggle"
|
||||
>
|
||||
{showHiddenFiles ? (
|
||||
<Eye size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Eye
|
||||
size={paneContentToolbarIconSize(isCompact)}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
) : (
|
||||
<EyeOff size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<EyeOff
|
||||
size={paneContentToolbarIconSize(isCompact)}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
@@ -1295,14 +1323,20 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
>
|
||||
<View style={styles.refreshIcon}>
|
||||
{isRefreshFetching ? (
|
||||
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<LoadingSpinner
|
||||
size={paneContentToolbarIconSize(isCompact)}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
) : (
|
||||
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<RotateCw
|
||||
size={paneContentToolbarIconSize(isCompact)}
|
||||
color={theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</PaneContentToolbar>
|
||||
<ContextMenu>
|
||||
<RootCreationContextTarget enabled={Boolean(onNewEntryAtRoot)}>
|
||||
{listRows.length === 0 ? (
|
||||
@@ -1662,13 +1696,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minWidth: 0,
|
||||
},
|
||||
paneHeader: {
|
||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingRight: theme.spacing[3],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
sortTrigger: {
|
||||
flexDirection: "row",
|
||||
@@ -1806,16 +1837,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
iconButton: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
iconButtonActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
|
||||
@@ -88,7 +88,8 @@ interface SidebarSharedProps {
|
||||
projectIconTargets: SidebarProjectIconTarget[];
|
||||
pinnedGroups: PinnedSidebarGroups;
|
||||
projects: SidebarProjectEntry[];
|
||||
hasProjectsBeforeLabelFilter: boolean;
|
||||
hasProjectsBeforeFilter: boolean;
|
||||
hasActiveProjectFilter: boolean;
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
isInitialLoad: boolean;
|
||||
isRevalidating: boolean;
|
||||
@@ -143,7 +144,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
|
||||
const {
|
||||
projects,
|
||||
hasProjectsBeforeLabelFilter,
|
||||
hasProjectsBeforeFilter,
|
||||
resolvedProjectFilters,
|
||||
workspaceEntriesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
@@ -251,7 +253,8 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
projectIconTargets,
|
||||
pinnedGroups,
|
||||
projects,
|
||||
hasProjectsBeforeLabelFilter,
|
||||
hasProjectsBeforeFilter,
|
||||
hasActiveProjectFilter: resolvedProjectFilters.length > 0,
|
||||
workspaceEntriesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
@@ -606,7 +609,8 @@ function MobileSidebar({
|
||||
projectIconTargets,
|
||||
pinnedGroups,
|
||||
projects,
|
||||
hasProjectsBeforeLabelFilter,
|
||||
hasProjectsBeforeFilter,
|
||||
hasActiveProjectFilter,
|
||||
workspaceEntriesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
@@ -725,7 +729,8 @@ function MobileSidebar({
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeLabelFilter={hasProjectsBeforeLabelFilter}
|
||||
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
@@ -757,7 +762,8 @@ function DesktopSidebar({
|
||||
projectIconTargets,
|
||||
pinnedGroups,
|
||||
projects,
|
||||
hasProjectsBeforeLabelFilter,
|
||||
hasProjectsBeforeFilter,
|
||||
hasActiveProjectFilter,
|
||||
workspaceEntriesByKey,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
@@ -930,7 +936,8 @@ function DesktopSidebar({
|
||||
projectIconTargets={projectIconTargets}
|
||||
pinnedGroups={pinnedGroups}
|
||||
projects={projects}
|
||||
hasProjectsBeforeLabelFilter={hasProjectsBeforeLabelFilter}
|
||||
hasProjectsBeforeFilter={hasProjectsBeforeFilter}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
workspaceEntriesByKey={workspaceEntriesByKey}
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
|
||||
@@ -354,9 +354,12 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
text: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: theme.fontSize.content,
|
||||
...(isWeb
|
||||
? { lineHeight: Math.round(theme.fontSize.base * 1.4), overflowWrap: "anywhere" as const }
|
||||
? {
|
||||
lineHeight: Math.round(theme.fontSize.content * 1.4),
|
||||
overflowWrap: "anywhere" as const,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
imagePreviewContainer: {
|
||||
@@ -1958,8 +1961,8 @@ const speakMessageStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
text: {
|
||||
fontFamily: theme.fontFamily.ui,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: Math.round(theme.fontSize.base * 1.4),
|
||||
fontSize: theme.fontSize.content,
|
||||
lineHeight: Math.round(theme.fontSize.content * 1.4),
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -118,7 +118,7 @@ import {
|
||||
} from "@/components/sidebar/sidebar-workspace-row-content";
|
||||
import { useOpenKebabMenuVisibility } from "@/components/sidebar/use-open-kebab-menu-visibility";
|
||||
import {
|
||||
SidebarLabelFilterEmptyState,
|
||||
SidebarFilterEmptyState,
|
||||
SidebarProjectEmptyState,
|
||||
} from "@/components/sidebar/empty-states";
|
||||
import { selectWorkspaceServiceSummary } from "@/components/sidebar/workspace-meta-row";
|
||||
@@ -245,7 +245,9 @@ interface SidebarWorkspaceListProps {
|
||||
projectIconTargets: SidebarProjectIconTarget[];
|
||||
pinnedGroups: PinnedSidebarGroups;
|
||||
projects: SidebarProjectEntry[];
|
||||
hasProjectsBeforeLabelFilter: boolean;
|
||||
hasProjectsBeforeFilter: boolean;
|
||||
/** Whether a project filter is actually being applied — the resolved list, not the stored one. */
|
||||
hasActiveProjectFilter: boolean;
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
onToggleProjectCollapsed: (projectViewKey: string) => void;
|
||||
@@ -1935,7 +1937,8 @@ export function SidebarWorkspaceList({
|
||||
projectIconTargets,
|
||||
pinnedGroups,
|
||||
projects,
|
||||
hasProjectsBeforeLabelFilter,
|
||||
hasProjectsBeforeFilter,
|
||||
hasActiveProjectFilter,
|
||||
workspaceEntriesByKey,
|
||||
collapsedProjectKeys,
|
||||
onToggleProjectCollapsed,
|
||||
@@ -1999,8 +2002,12 @@ export function SidebarWorkspaceList({
|
||||
// A filter that matches nothing swaps the list's body and nothing above it. It used to replace
|
||||
// this whole subtree, which unmounted the header — and the header is where the display menu's
|
||||
// trigger lives, so filtering the last row away closed the menu you were filtering from.
|
||||
const labelFilterEmpty =
|
||||
hasActiveLabelFilter && hasProjectsBeforeLabelFilter && projects.length === 0;
|
||||
//
|
||||
// Only the label filter can get here. The project filter resolves against the projects it can
|
||||
// see and falls back to "all projects" when nothing matches, so it either keeps at least one
|
||||
// project or is not applied at all — it can narrow this list but never empty it.
|
||||
const sidebarFilterEmpty =
|
||||
hasActiveLabelFilter && hasProjectsBeforeFilter && projects.length === 0;
|
||||
|
||||
// Project mode is the one that keeps its project headers; every other grouping mode is a flat
|
||||
// list of grouped rows, so a new mode lands in the grouped branch rather than silently in this
|
||||
@@ -2019,7 +2026,7 @@ export function SidebarWorkspaceList({
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onPinnedWorkspaceReorder={handlePinnedWorkspaceReorder}
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
labelFilterEmpty={labelFilterEmpty}
|
||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
/>
|
||||
@@ -2036,7 +2043,8 @@ export function SidebarWorkspaceList({
|
||||
onAddProject={onAddProject}
|
||||
listFooterComponent={listFooterComponent}
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
labelFilterEmpty={labelFilterEmpty}
|
||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
pathname={pathname}
|
||||
@@ -2069,7 +2077,7 @@ function SidebarGroupedModeList({
|
||||
onToggleWorkspacePin,
|
||||
onPinnedWorkspaceReorder,
|
||||
listHeaderComponent,
|
||||
labelFilterEmpty,
|
||||
sidebarFilterEmpty,
|
||||
parentGestureRef,
|
||||
dragGestureHostPresented,
|
||||
}: {
|
||||
@@ -2084,7 +2092,7 @@ function SidebarGroupedModeList({
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void;
|
||||
listHeaderComponent?: ReactElement | null;
|
||||
labelFilterEmpty: boolean;
|
||||
sidebarFilterEmpty: boolean;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
dragGestureHostPresented?: boolean;
|
||||
}) {
|
||||
@@ -2111,7 +2119,7 @@ function SidebarGroupedModeList({
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onPinnedWorkspaceReorder={onPinnedWorkspaceReorder}
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
labelFilterEmpty={labelFilterEmpty}
|
||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
/>
|
||||
@@ -2130,7 +2138,8 @@ function ProjectModeList({
|
||||
onAddProject,
|
||||
listFooterComponent,
|
||||
listHeaderComponent,
|
||||
labelFilterEmpty,
|
||||
sidebarFilterEmpty,
|
||||
hasActiveProjectFilter,
|
||||
parentGestureRef,
|
||||
dragGestureHostPresented,
|
||||
pathname,
|
||||
@@ -2144,12 +2153,12 @@ function ProjectModeList({
|
||||
| "workspaceGroups"
|
||||
| "projectIconTargets"
|
||||
| "groupMode"
|
||||
| "hasProjectsBeforeLabelFilter"
|
||||
| "hasProjectsBeforeFilter"
|
||||
| "isRefreshing"
|
||||
| "onRefresh"
|
||||
> & {
|
||||
/** Swaps the list body for the label filter's empty state. Never the header above it. */
|
||||
labelFilterEmpty: boolean;
|
||||
sidebarFilterEmpty: boolean;
|
||||
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
||||
pathname: string;
|
||||
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||
@@ -2487,11 +2496,17 @@ function ProjectModeList({
|
||||
) : null}
|
||||
{/* The header carries the display menu, which is the only way back out of a filter, so it
|
||||
stays for as long as a filter is what emptied the list. It is absent only when the
|
||||
sidebar is genuinely empty, where a section heading would sit over nothing. */}
|
||||
{unpinnedProjects.length > 0 || hasActiveHostFilter || labelFilterEmpty
|
||||
sidebar is genuinely empty, where a section heading would sit over nothing.
|
||||
Every filter that can empty this branch needs a term here: a project filter pinned to a
|
||||
project whose chats are all pinned leaves `unpinnedProjects` empty, and without its term
|
||||
the header would go with it, taking the only route back to the filter page. */}
|
||||
{unpinnedProjects.length > 0 ||
|
||||
hasActiveHostFilter ||
|
||||
hasActiveProjectFilter ||
|
||||
sidebarFilterEmpty
|
||||
? listHeaderComponent
|
||||
: null}
|
||||
{labelFilterEmpty ? <SidebarLabelFilterEmptyState /> : projectBody}
|
||||
{sidebarFilterEmpty ? <SidebarFilterEmptyState /> : projectBody}
|
||||
{listFooterComponent}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -38,6 +38,12 @@ import {
|
||||
import { HostStatusDot } from "@/components/host-status-dot";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
|
||||
import { ProjectIconView } from "@/components/project-icon-view";
|
||||
import { useProjectIcons } from "@/projects/icons";
|
||||
import { resolveSidebarProjectIconTargets } from "@/utils/sidebar-project-row-model";
|
||||
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
|
||||
import type { SidebarProjectEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
hasActiveSidebarLabelFilter,
|
||||
@@ -159,6 +165,9 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const preferences = useSidebarDisplayPreferences();
|
||||
const hosts = useHosts();
|
||||
// `allProjects`, never `projects`: the model's `projects` is already filtered, so a picker fed
|
||||
// from it would lose the row that undoes the filter as soon as the filter narrowed to one.
|
||||
const { allProjects, resolvedProjectFilters } = useSidebarModel();
|
||||
const { labels } = useWorkspaceLabelProjection();
|
||||
const [managerOpen, setManagerOpen] = useState(false);
|
||||
const openManager = useCallback(() => setManagerOpen(true), []);
|
||||
@@ -173,6 +182,8 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
);
|
||||
|
||||
const showHostFilter = hosts.length > 1;
|
||||
// One project is the whole sidebar, so filtering to it is a no-op with a menu row attached.
|
||||
const showProjectFilter = allProjects.length > 1;
|
||||
// Nothing to filter by means no row at all. The active-filter half is not redundant: the merged
|
||||
// catalog only counts hosts that are online, so a host dropping off would otherwise take away
|
||||
// the only way back to a filter that is still hiding workspaces.
|
||||
@@ -236,6 +247,19 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
content: <HostFilterPage preferences={preferences} hosts={hosts} />,
|
||||
});
|
||||
}
|
||||
if (showProjectFilter) {
|
||||
definitions.push({
|
||||
id: "projectFilter",
|
||||
title: t("sidebar.display.projectFilter.label"),
|
||||
content: (
|
||||
<ProjectFilterPage
|
||||
projects={allProjects}
|
||||
resolvedProjectFilters={resolvedProjectFilters}
|
||||
preferences={preferences}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (showLabelFilter) {
|
||||
definitions.push({
|
||||
id: "labelFilter",
|
||||
@@ -246,7 +270,18 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
});
|
||||
}
|
||||
return definitions;
|
||||
}, [t, preferences, hosts, showHostFilter, showLabelFilter, labels, openManager]);
|
||||
}, [
|
||||
t,
|
||||
preferences,
|
||||
hosts,
|
||||
showHostFilter,
|
||||
showProjectFilter,
|
||||
allProjects,
|
||||
resolvedProjectFilters,
|
||||
showLabelFilter,
|
||||
labels,
|
||||
openManager,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -297,6 +332,21 @@ export function SidebarDisplayPreferencesMenu(): ReactElement {
|
||||
</MenuSubTrigger>
|
||||
</>
|
||||
) : null}
|
||||
{showProjectFilter ? (
|
||||
<>
|
||||
{/* Host and Project narrow the same list, so they read as one block. The separator
|
||||
belongs above whichever of the two is showing first — with a single host there is
|
||||
no Host row and Project is what has to carry it. */}
|
||||
{showHostFilter ? null : <MenuSeparator />}
|
||||
<MenuSubTrigger
|
||||
id="projectFilter"
|
||||
indicator={resolvedProjectFilters.length > 0}
|
||||
testID="sidebar-display-project-filter"
|
||||
>
|
||||
{t("sidebar.display.projectFilter.label")}
|
||||
</MenuSubTrigger>
|
||||
</>
|
||||
) : null}
|
||||
{showLabelFilter ? (
|
||||
<>
|
||||
<MenuSeparator />
|
||||
@@ -550,6 +600,93 @@ function ChecksSubTrigger(): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every project the sidebar could show, one row each.
|
||||
*
|
||||
* A workspace belongs to exactly one project, so this is a plain allowlist — the same shape as the
|
||||
* host page, and deliberately not the label page's tri-state.
|
||||
*
|
||||
* Selection reads `resolvedProjectFilters`, not the stored list. A stored key whose project is not
|
||||
* currently visible filters nothing, so showing it as checked here would contradict the sidebar.
|
||||
*/
|
||||
function ProjectFilterPage({
|
||||
projects,
|
||||
resolvedProjectFilters,
|
||||
preferences,
|
||||
}: {
|
||||
projects: readonly SidebarProjectEntry[];
|
||||
resolvedProjectFilters: readonly string[];
|
||||
preferences: Preferences;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const iconTargets = useMemo(() => resolveSidebarProjectIconTargets(projects), [projects]);
|
||||
// Shares TanStack's cache with the sidebar's own call, so this subscribes rather than refetches.
|
||||
const iconByProjectViewKey = useProjectIcons({ projects: iconTargets });
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuItem
|
||||
selected={resolvedProjectFilters.length === 0}
|
||||
closeOnSelect={false}
|
||||
onSelect={preferences.clearProjectFilters}
|
||||
testID="sidebar-project-filter-all"
|
||||
>
|
||||
{t("sidebar.display.projectFilter.all")}
|
||||
</MenuItem>
|
||||
{projects.map((project) => (
|
||||
<ProjectFilterItem
|
||||
key={project.viewKey}
|
||||
viewKey={project.viewKey}
|
||||
label={project.projectName}
|
||||
iconDataUri={iconByProjectViewKey.get(project.viewKey) ?? null}
|
||||
selected={resolvedProjectFilters.includes(project.viewKey)}
|
||||
onToggle={preferences.toggleProjectFilter}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectFilterItem({
|
||||
viewKey,
|
||||
label,
|
||||
iconDataUri,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
viewKey: string;
|
||||
label: string;
|
||||
iconDataUri: string | null;
|
||||
selected: boolean;
|
||||
onToggle: (viewKey: string) => void;
|
||||
}): ReactElement {
|
||||
const handleSelect = useCallback(() => onToggle(viewKey), [viewKey, onToggle]);
|
||||
const leading = useMemo(
|
||||
() => (
|
||||
<ProjectIconView
|
||||
iconDataUri={iconDataUri}
|
||||
initial={projectIconPlaceholderLabelFromDisplayName(label).charAt(0).toUpperCase()}
|
||||
projectViewKey={viewKey}
|
||||
size={OPTION_ICON_SIZE}
|
||||
textStyle={styles.projectIconText}
|
||||
/>
|
||||
),
|
||||
[iconDataUri, label, viewKey],
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
selected={selected}
|
||||
leading={leading}
|
||||
closeOnSelect={false}
|
||||
onSelect={handleSelect}
|
||||
testID={`sidebar-project-filter-${viewKey}`}
|
||||
>
|
||||
{label}
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function HostFilterPage({
|
||||
preferences,
|
||||
hosts,
|
||||
@@ -627,4 +764,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
triggerHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
// The icon sits in a 14pt menu slot, so the fallback initial is sized down to match rather
|
||||
// than reusing the sidebar row's 16pt figure.
|
||||
projectIconText: {
|
||||
fontSize: 8,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -30,6 +30,10 @@ export interface SidebarDisplayPreferences {
|
||||
hostFilters: readonly string[];
|
||||
toggleHostFilter: (serverId: string) => void;
|
||||
clearHostFilters: () => void;
|
||||
/** Raw stored selection. For anything the user sees, use the model's resolved list instead. */
|
||||
projectFilters: readonly string[];
|
||||
toggleProjectFilter: (viewKey: string) => void;
|
||||
clearProjectFilters: () => void;
|
||||
labelFilter: SidebarLabelFilter;
|
||||
toggleLabelFilter: (name: string) => void;
|
||||
clearLabelFilter: () => void;
|
||||
@@ -49,6 +53,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
|
||||
const toggleHostFilter = useSidebarViewStore((state) => state.toggleHostFilter);
|
||||
const clearHostFilters = useSidebarViewStore((state) => state.clearHostFilters);
|
||||
const projectFilters = useSidebarViewStore((state) => state.projectFilters);
|
||||
const toggleProjectFilter = useSidebarViewStore((state) => state.toggleProjectFilter);
|
||||
const clearProjectFilters = useSidebarViewStore((state) => state.clearProjectFilters);
|
||||
const labelFilter = useSidebarViewStore((state) => state.labelFilter);
|
||||
const toggleLabelFilter = useSidebarViewStore((state) => state.toggleLabelFilter);
|
||||
const clearLabelFilter = useSidebarViewStore((state) => state.clearLabelFilter);
|
||||
@@ -110,6 +117,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
hostFilters,
|
||||
toggleHostFilter,
|
||||
clearHostFilters,
|
||||
projectFilters,
|
||||
toggleProjectFilter,
|
||||
clearProjectFilters,
|
||||
labelFilter,
|
||||
toggleLabelFilter,
|
||||
clearLabelFilter,
|
||||
@@ -128,6 +138,9 @@ export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
|
||||
hostFilters,
|
||||
toggleHostFilter,
|
||||
clearHostFilters,
|
||||
projectFilters,
|
||||
toggleProjectFilter,
|
||||
clearProjectFilters,
|
||||
labelFilter,
|
||||
toggleLabelFilter,
|
||||
clearLabelFilter,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { Plus } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
@@ -13,16 +14,24 @@ import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
* trigger, and an empty state that replaces the list rather than its body takes the header with
|
||||
* it — which is how filtering the last row away used to close the menu you were filtering from.
|
||||
*/
|
||||
export function SidebarLabelFilterEmptyState() {
|
||||
export function SidebarFilterEmptyState() {
|
||||
const { t } = useTranslation();
|
||||
const clearLabelFilter = useSidebarViewStore((state) => state.clearLabelFilter);
|
||||
const clearProjectFilters = useSidebarViewStore((state) => state.clearProjectFilters);
|
||||
// Clears every filter that can empty the list, not just the one that did. The card names no
|
||||
// filter, so a Clear that undid only one of two active filters would leave it on screen looking
|
||||
// like it had failed.
|
||||
const clearFilters = useCallback(() => {
|
||||
clearLabelFilter();
|
||||
clearProjectFilters();
|
||||
}, [clearLabelFilter, clearProjectFilters]);
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="sidebar-label-filter-empty-state">
|
||||
<Text style={styles.title}>{t("workspaceLabels.filter.noMatchesTitle")}</Text>
|
||||
<Text style={styles.description}>{t("workspaceLabels.filter.noMatchesDescription")}</Text>
|
||||
<Button variant="ghost" size="sm" onPress={clearLabelFilter}>
|
||||
{t("workspaceLabels.filter.clear")}
|
||||
<View style={styles.container} testID="sidebar-filter-empty-state">
|
||||
<Text style={styles.title}>{t("sidebar.filterEmpty.title")}</Text>
|
||||
<Text style={styles.description}>{t("sidebar.filterEmpty.description")}</Text>
|
||||
<Button variant="ghost" size="sm" onPress={clearFilters}>
|
||||
{t("sidebar.filterEmpty.clear")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { createContext, useContext, useEffect, useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
useSidebarWorkspacesList,
|
||||
type SidebarProjectEntry,
|
||||
type SidebarWorkspaceEntry,
|
||||
type SidebarWorkspacesListResult,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
@@ -17,6 +18,7 @@ import type { SidebarShortcutModel } from "@/utils/sidebar-shortcuts";
|
||||
import { buildSidebarProjection } from "./sidebar-projection";
|
||||
import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model";
|
||||
import { filterWorkspacesByLabels, type SidebarWorkspaceGroup } from "./sidebar-labels";
|
||||
import { filterWorkspacesByProjects, resolveActiveProjectFilters } from "./sidebar-project-filter";
|
||||
import {
|
||||
hasAuthoritativeWorkspaceLabelCatalog,
|
||||
useWorkspaceLabelProjection,
|
||||
@@ -24,7 +26,16 @@ import {
|
||||
|
||||
interface SidebarModel extends SidebarWorkspacesListResult {
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
hasProjectsBeforeLabelFilter: boolean;
|
||||
/**
|
||||
* Every project the sidebar could show, before any filter narrows it.
|
||||
*
|
||||
* `projects` is the FILTERED list. A surface that offers a filter picker must read this one, or
|
||||
* narrowing the filter deletes the rows that would undo it.
|
||||
*/
|
||||
allProjects: SidebarProjectEntry[];
|
||||
/** The project filter as it is actually being applied — see `resolveActiveProjectFilters`. */
|
||||
resolvedProjectFilters: readonly string[];
|
||||
hasProjectsBeforeFilter: boolean;
|
||||
groupMode: SidebarGroupMode;
|
||||
workspaceGroups: SidebarWorkspaceGroup[];
|
||||
projectIconTargets: SidebarProjectIconTarget[];
|
||||
@@ -46,6 +57,7 @@ export function SidebarModelProvider({
|
||||
const list = useSidebarWorkspacesList();
|
||||
const groupMode = useSidebarViewStore((state) => state.groupMode);
|
||||
const labelFilter = useSidebarViewStore((state) => state.labelFilter);
|
||||
const projectFilters = useSidebarViewStore((state) => state.projectFilters);
|
||||
const reconcileLabelFilter = useSidebarViewStore((state) => state.reconcileLabelFilter);
|
||||
const { hosts: labelHosts } = useWorkspaceLabelProjection();
|
||||
const collapsedProjectKeys = useSidebarCollapsedSectionsStore(
|
||||
@@ -69,31 +81,63 @@ export function SidebarModelProvider({
|
||||
reconcileLabelFilter(availableLabelNames);
|
||||
}, [availableLabelNames, hasAuthoritativeLabelCatalog, reconcileLabelFilter]);
|
||||
const hasActiveLabelFilter = hasActiveSidebarLabelFilter(labelFilter);
|
||||
const resolvedProjectFilters = useMemo(
|
||||
() =>
|
||||
resolveActiveProjectFilters(
|
||||
projectFilters,
|
||||
new Set(list.projects.map((project) => project.viewKey)),
|
||||
),
|
||||
[projectFilters, list.projects],
|
||||
);
|
||||
const hasActiveProjectFilter = resolvedProjectFilters.length > 0;
|
||||
// The project filter is deliberately absent from this gate. It reads `projectViewKey`, which
|
||||
// lives on the project and the placement, so it can narrow the project list without hydrating
|
||||
// anything; the label filter reads `labels`, which only exists on an entry. Hydration opens a
|
||||
// live session-store subscription over every workspace on every visible host, so widening this
|
||||
// for a filter that does not need it costs a retained-but-inactive sidebar real work.
|
||||
const needsWorkspaceEntries = groupMode !== "project" || hasActiveLabelFilter;
|
||||
const workspaceEntriesByKey = useSidebarWorkspaceEntries(
|
||||
list.workspacePlacements,
|
||||
active !== false || needsWorkspaceEntries,
|
||||
);
|
||||
const filteredWorkspaceEntriesByKey = useMemo(() => {
|
||||
const filtered = filterWorkspacesByLabels({
|
||||
const byProject = filterWorkspacesByProjects({
|
||||
workspaces: [...workspaceEntriesByKey.values()],
|
||||
...labelFilter,
|
||||
projectFilters: resolvedProjectFilters,
|
||||
});
|
||||
const filtered = filterWorkspacesByLabels({ workspaces: byProject, ...labelFilter });
|
||||
return new Map(filtered.map((workspace) => [workspace.workspaceKey, workspace]));
|
||||
}, [labelFilter, workspaceEntriesByKey]);
|
||||
}, [labelFilter, resolvedProjectFilters, workspaceEntriesByKey]);
|
||||
const visibleWorkspaceKeys = useMemo(
|
||||
() => new Set(filteredWorkspaceEntriesByKey.keys()),
|
||||
[filteredWorkspaceEntriesByKey],
|
||||
);
|
||||
// The two filters prune differently on purpose. The project filter is a membership test on the
|
||||
// project itself, so a project you filtered TO survives even with no workspaces — it still owns
|
||||
// a header row you can create your first workspace under. The label filter can only ask about
|
||||
// workspaces, so a project it empties has nothing left to show.
|
||||
const filteredProjects = useMemo(() => {
|
||||
if (!hasActiveLabelFilter) return list.projects;
|
||||
return list.projects.flatMap((project) => {
|
||||
const workspaces = project.workspaces.filter((workspace) =>
|
||||
visibleWorkspaceKeys.has(workspace.workspaceKey),
|
||||
);
|
||||
return workspaces.length > 0 ? [{ ...project, workspaces }] : [];
|
||||
});
|
||||
}, [hasActiveLabelFilter, list.projects, visibleWorkspaceKeys]);
|
||||
let projects = list.projects;
|
||||
if (hasActiveProjectFilter) {
|
||||
const included = new Set(resolvedProjectFilters);
|
||||
projects = projects.filter((project) => included.has(project.viewKey));
|
||||
}
|
||||
if (hasActiveLabelFilter) {
|
||||
projects = projects.flatMap((project) => {
|
||||
const workspaces = project.workspaces.filter((workspace) =>
|
||||
visibleWorkspaceKeys.has(workspace.workspaceKey),
|
||||
);
|
||||
return workspaces.length > 0 ? [{ ...project, workspaces }] : [];
|
||||
});
|
||||
}
|
||||
return projects;
|
||||
}, [
|
||||
hasActiveLabelFilter,
|
||||
hasActiveProjectFilter,
|
||||
resolvedProjectFilters,
|
||||
list.projects,
|
||||
visibleWorkspaceKeys,
|
||||
]);
|
||||
const pinnedKeys = usePinnedSidebarKeys(filteredProjects);
|
||||
const projectionInput = useMemo(
|
||||
() => ({
|
||||
@@ -124,7 +168,9 @@ export function SidebarModelProvider({
|
||||
() => ({
|
||||
...list,
|
||||
projects: filteredProjects,
|
||||
hasProjectsBeforeLabelFilter: list.projects.length > 0,
|
||||
allProjects: list.projects,
|
||||
resolvedProjectFilters,
|
||||
hasProjectsBeforeFilter: list.projects.length > 0,
|
||||
workspaceEntriesByKey: filteredWorkspaceEntriesByKey,
|
||||
groupMode,
|
||||
workspaceGroups: projection.workspaceGroups,
|
||||
@@ -135,6 +181,7 @@ export function SidebarModelProvider({
|
||||
shortcutModel: projection.shortcutModel,
|
||||
}),
|
||||
[
|
||||
resolvedProjectFilters,
|
||||
collapsedProjectKeys,
|
||||
groupMode,
|
||||
list,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { filterWorkspacesByProjects, resolveActiveProjectFilters } from "./sidebar-project-filter";
|
||||
|
||||
function workspace(workspaceId: string, projectViewKey: string): SidebarWorkspaceEntry {
|
||||
return {
|
||||
workspaceKey: `host:${workspaceId}`,
|
||||
serverId: "host",
|
||||
workspaceId,
|
||||
projectViewKey,
|
||||
projectName: projectViewKey,
|
||||
projectRootPath: `/repo/${projectViewKey}`,
|
||||
workspaceDirectory: `/repo/${projectViewKey}/${workspaceId}`,
|
||||
workspaceDirectoryLabel: workspaceId,
|
||||
projectKind: "git",
|
||||
workspaceKind: "worktree",
|
||||
name: workspaceId,
|
||||
title: null,
|
||||
pinnedAt: null,
|
||||
labels: [],
|
||||
currentBranch: "main",
|
||||
statusBucket: "done",
|
||||
statusEnteredAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
prHint: null,
|
||||
archiveHasUncommittedChanges: null,
|
||||
archiveUnpushedCommitCount: null,
|
||||
scripts: [],
|
||||
hasRunningScripts: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveActiveProjectFilters", () => {
|
||||
test("reads an empty stored filter as every project", () => {
|
||||
expect(resolveActiveProjectFilters([], new Set(["alpha", "beta"]))).toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps only the keys that still name a project it can see", () => {
|
||||
expect(resolveActiveProjectFilters(["alpha", "gone"], new Set(["alpha", "beta"]))).toEqual([
|
||||
"alpha",
|
||||
]);
|
||||
expect(resolveActiveProjectFilters(["alpha", "beta"], new Set(["alpha", "beta"]))).toEqual([
|
||||
"alpha",
|
||||
"beta",
|
||||
]);
|
||||
});
|
||||
|
||||
// Nothing deletes the stored keys, so a filter naming only projects on a host that has not
|
||||
// connected yet must go inert rather than blank the sidebar. It comes back with the host.
|
||||
test("goes inert instead of matching nothing", () => {
|
||||
expect(resolveActiveProjectFilters(["alpha"], new Set(["beta"]))).toEqual([]);
|
||||
expect(resolveActiveProjectFilters(["alpha"], new Set())).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterWorkspacesByProjects", () => {
|
||||
const workspaces = [
|
||||
workspace("one", "alpha"),
|
||||
workspace("two", "alpha"),
|
||||
workspace("three", "beta"),
|
||||
];
|
||||
|
||||
function filtered(projectFilters: string[]) {
|
||||
return filterWorkspacesByProjects({ workspaces, projectFilters }).map(
|
||||
(entry) => entry.workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
test("keeps every workspace when nothing is pinned", () => {
|
||||
expect(filtered([])).toEqual(["one", "two", "three"]);
|
||||
});
|
||||
|
||||
test("keeps the workspaces of the pinned projects", () => {
|
||||
expect(filtered(["alpha"])).toEqual(["one", "two"]);
|
||||
expect(filtered(["beta"])).toEqual(["three"]);
|
||||
expect(filtered(["alpha", "beta"])).toEqual(["one", "two", "three"]);
|
||||
});
|
||||
|
||||
test("keeps nothing for a project with no workspaces", () => {
|
||||
expect(filtered(["gamma"])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
|
||||
/**
|
||||
* The subset of a stored project filter that still names a project the sidebar can see.
|
||||
*
|
||||
* Empty means "all projects" — both when nothing is pinned and when everything pinned has gone
|
||||
* away. That second case is why nothing ever deletes a stored key: the project list is narrowed
|
||||
* by the host filter and is empty before any host connects, so absence does not mean the project
|
||||
* is gone. A filter goes inert while its host is away and comes back with it.
|
||||
*
|
||||
* This is the only definition of "is a project filter active". The menu's indicator, the
|
||||
* "All projects" check and the per-row selection all read it, so they cannot disagree with what
|
||||
* the list is actually doing.
|
||||
*/
|
||||
export function resolveActiveProjectFilters(
|
||||
projectFilters: readonly string[],
|
||||
availableViewKeys: ReadonlySet<string>,
|
||||
): readonly string[] {
|
||||
if (projectFilters.length === 0) return EMPTY_PROJECT_FILTERS;
|
||||
const matching = projectFilters.filter((viewKey) => availableViewKeys.has(viewKey));
|
||||
return matching.length > 0 ? matching : EMPTY_PROJECT_FILTERS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the Project page's selection to the sidebar's workspace entries.
|
||||
*
|
||||
* A workspace belongs to exactly one project, so this is a plain allowlist — no tri-state, and
|
||||
* no "unassigned" row of the kind the label filter needs.
|
||||
*/
|
||||
export function filterWorkspacesByProjects(input: {
|
||||
workspaces: readonly SidebarWorkspaceEntry[];
|
||||
projectFilters: readonly string[];
|
||||
}): SidebarWorkspaceEntry[] {
|
||||
const { workspaces, projectFilters } = input;
|
||||
if (projectFilters.length === 0) return [...workspaces];
|
||||
const included = new Set(projectFilters);
|
||||
return workspaces.filter((workspace) => included.has(workspace.projectViewKey));
|
||||
}
|
||||
|
||||
const EMPTY_PROJECT_FILTERS: readonly string[] = [];
|
||||
@@ -23,7 +23,7 @@ import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspac
|
||||
import { type SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { StatusBucket } from "@/hooks/sidebar-status-view-model";
|
||||
import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels";
|
||||
import { SidebarLabelFilterEmptyState } from "@/components/sidebar/empty-states";
|
||||
import { SidebarFilterEmptyState } from "@/components/sidebar/empty-states";
|
||||
import type { HostBadgeModel } from "@/hosts/appearance";
|
||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -127,7 +127,7 @@ interface StatusWorkspaceListProps {
|
||||
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspaceEntry[]) => void;
|
||||
listHeaderComponent?: ReactNode;
|
||||
/** Swaps the group list for the label filter's empty state. Never the header above it. */
|
||||
labelFilterEmpty?: boolean;
|
||||
sidebarFilterEmpty?: boolean;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
dragGestureHostPresented?: boolean;
|
||||
}
|
||||
@@ -144,7 +144,7 @@ export function SidebarStatusWorkspaceList({
|
||||
onToggleWorkspacePin,
|
||||
onPinnedWorkspaceReorder,
|
||||
listHeaderComponent,
|
||||
labelFilterEmpty = false,
|
||||
sidebarFilterEmpty = false,
|
||||
parentGestureRef,
|
||||
dragGestureHostPresented,
|
||||
}: StatusWorkspaceListProps) {
|
||||
@@ -231,8 +231,8 @@ export function SidebarStatusWorkspaceList({
|
||||
</View>
|
||||
) : null}
|
||||
{listHeaderComponent}
|
||||
{labelFilterEmpty ? (
|
||||
<SidebarLabelFilterEmptyState />
|
||||
{sidebarFilterEmpty ? (
|
||||
<SidebarFilterEmptyState />
|
||||
) : (
|
||||
<StatusGroupList
|
||||
groups={groups}
|
||||
|
||||
@@ -129,9 +129,14 @@ interface SplitContainerProps {
|
||||
position: "left" | "right" | "top" | "bottom";
|
||||
}) => void;
|
||||
onMoveTabToPane: (tabId: string, toPaneId: string) => void;
|
||||
onClosePane: (paneId: string) => void;
|
||||
onResizeSplit: (groupId: string, sizes: number[]) => void;
|
||||
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
|
||||
renderPaneEmptyState?: () => ReactNode;
|
||||
renderPaneEmptyState?: (input: {
|
||||
paneId: string;
|
||||
paneIsolated: boolean;
|
||||
onClosePane: () => void;
|
||||
}) => ReactNode;
|
||||
focusModeEnabled?: boolean;
|
||||
onExitFocusMode: () => void;
|
||||
}
|
||||
@@ -174,9 +179,10 @@ interface SplitNodeViewProps extends Omit<SplitContainerProps, "layout" | "onMov
|
||||
node: SplitNode;
|
||||
uiTabs: WorkspaceTab[];
|
||||
focusedPaneId: string | null;
|
||||
explorerPaneId: string | null;
|
||||
sidePanelPaneId: string | null;
|
||||
maximizedPaneId: string | null;
|
||||
onTogglePaneMaximized: (paneId: string) => void;
|
||||
paneIsolated: boolean;
|
||||
activeDragTabId: string | null;
|
||||
showDropZones: boolean;
|
||||
dropPreview: SplitDropZoneHover | null;
|
||||
@@ -263,6 +269,7 @@ function useStableTabDescriptorMap(tabDescriptors: WorkspaceTabDescriptor[]) {
|
||||
cachedDescriptor &&
|
||||
cachedDescriptor.key === tabDescriptor.key &&
|
||||
cachedDescriptor.kind === tabDescriptor.kind &&
|
||||
cachedDescriptor.state === tabDescriptor.state &&
|
||||
workspaceTabTargetsEqual(cachedDescriptor.target, tabDescriptor.target)
|
||||
) {
|
||||
next.set(tabDescriptor.tabId, cachedDescriptor);
|
||||
@@ -404,6 +411,7 @@ export function SplitContainer({
|
||||
onSplitPane,
|
||||
onSplitPaneEmpty,
|
||||
onMoveTabToPane,
|
||||
onClosePane,
|
||||
onResizeSplit,
|
||||
onReorderTabsInPane,
|
||||
renderPaneEmptyState = () => null,
|
||||
@@ -421,8 +429,8 @@ export function SplitContainer({
|
||||
} | null>(null);
|
||||
const maximizedPaneId =
|
||||
maximizedPane?.workspaceKey === workspaceKey ? maximizedPane.paneId : null;
|
||||
const explorerPaneId = useWorkspaceLayoutStore(
|
||||
(state) => state.explorerPaneIdByWorkspace[workspaceKey] ?? null,
|
||||
const sidePanelPaneId = useWorkspaceLayoutStore(
|
||||
(state) => state.sidePanelPaneIdByWorkspace[workspaceKey] ?? null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -451,7 +459,7 @@ export function SplitContainer({
|
||||
if (action.id !== "workspace.explorer.maximize.toggle") {
|
||||
return false;
|
||||
}
|
||||
const explorerPane = findPaneById(layout.root, explorerPaneId);
|
||||
const explorerPane = findPaneById(layout.root, sidePanelPaneId);
|
||||
if (focusModeEnabled || !explorerPane || explorerPane.hidden === true) {
|
||||
return true;
|
||||
}
|
||||
@@ -459,7 +467,7 @@ export function SplitContainer({
|
||||
handleTogglePaneMaximized(explorerPane.id);
|
||||
return true;
|
||||
},
|
||||
[explorerPaneId, focusModeEnabled, handleTogglePaneMaximized, layout.root, onFocusPane],
|
||||
[sidePanelPaneId, focusModeEnabled, handleTogglePaneMaximized, layout.root, onFocusPane],
|
||||
);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
@@ -494,6 +502,11 @@ export function SplitContainer({
|
||||
[focusModeEnabled, layout.focusedPaneId, layout.root, maximizedPaneId],
|
||||
);
|
||||
const renderRoot = useMemo(() => wrapRootPaneForStableMount(splitRoot.root), [splitRoot.root]);
|
||||
// Isolating a pane (focus mode, maximize) hides its siblings without removing
|
||||
// them, so the one pane on screen is not really the last one and closing it is not
|
||||
// what anyone means. That is all this container knows; whether the layout itself
|
||||
// allows the dismissal is `canDismissPaneInLayout`, which the caller reads.
|
||||
const paneIsolated = focusModeEnabled || Boolean(maximizedPaneId);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
const data = asWorkspaceTabDragData(event.active.data.current);
|
||||
@@ -654,7 +667,7 @@ export function SplitContainer({
|
||||
workspaceKey={workspaceKey}
|
||||
uiTabs={uiTabs}
|
||||
focusedPaneId={layout.focusedPaneId}
|
||||
explorerPaneId={explorerPaneId}
|
||||
sidePanelPaneId={sidePanelPaneId}
|
||||
maximizedPaneId={maximizedPaneId}
|
||||
onTogglePaneMaximized={handleTogglePaneMaximized}
|
||||
normalizedServerId={normalizedServerId}
|
||||
@@ -682,6 +695,8 @@ export function SplitContainer({
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
paneIsolated={paneIsolated}
|
||||
onClosePane={onClosePane}
|
||||
onResizeSplit={onResizeSplit}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
@@ -863,7 +878,7 @@ function SplitNodeView({
|
||||
workspaceKey,
|
||||
uiTabs,
|
||||
focusedPaneId,
|
||||
explorerPaneId,
|
||||
sidePanelPaneId,
|
||||
maximizedPaneId,
|
||||
onTogglePaneMaximized,
|
||||
normalizedServerId,
|
||||
@@ -891,6 +906,8 @@ function SplitNodeView({
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
onSplitPaneEmpty,
|
||||
paneIsolated,
|
||||
onClosePane,
|
||||
onResizeSplit,
|
||||
onReorderTabsInPane,
|
||||
renderPaneEmptyState,
|
||||
@@ -942,7 +959,7 @@ function SplitNodeView({
|
||||
pane={node.pane}
|
||||
uiTabs={uiTabs}
|
||||
isFocused={node.pane.id === focusedPaneId}
|
||||
explorerPaneId={explorerPaneId}
|
||||
sidePanelPaneId={sidePanelPaneId}
|
||||
maximizedPaneId={maximizedPaneId}
|
||||
onTogglePaneMaximized={onTogglePaneMaximized}
|
||||
normalizedServerId={normalizedServerId}
|
||||
@@ -970,6 +987,8 @@ function SplitNodeView({
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
paneIsolated={paneIsolated}
|
||||
onClosePane={onClosePane}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
activeDragTabId={activeDragTabId}
|
||||
@@ -994,7 +1013,7 @@ function SplitNodeView({
|
||||
workspaceKey={workspaceKey}
|
||||
uiTabs={uiTabs}
|
||||
focusedPaneId={focusedPaneId}
|
||||
explorerPaneId={explorerPaneId}
|
||||
sidePanelPaneId={sidePanelPaneId}
|
||||
maximizedPaneId={maximizedPaneId}
|
||||
onTogglePaneMaximized={onTogglePaneMaximized}
|
||||
normalizedServerId={normalizedServerId}
|
||||
@@ -1022,6 +1041,8 @@ function SplitNodeView({
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
onSplitPaneEmpty={onSplitPaneEmpty}
|
||||
paneIsolated={paneIsolated}
|
||||
onClosePane={onClosePane}
|
||||
onResizeSplit={onResizeSplit}
|
||||
onReorderTabsInPane={onReorderTabsInPane}
|
||||
renderPaneEmptyState={renderPaneEmptyState}
|
||||
@@ -1082,13 +1103,15 @@ function SplitPaneView({
|
||||
onFocusPane,
|
||||
onSplitPane: _onSplitPane,
|
||||
onSplitPaneEmpty: _onSplitPaneEmpty,
|
||||
paneIsolated,
|
||||
onClosePane,
|
||||
onReorderTabsInPane,
|
||||
renderPaneEmptyState,
|
||||
activeDragTabId,
|
||||
showDropZones,
|
||||
dropPreview,
|
||||
tabDropPreview,
|
||||
explorerPaneId,
|
||||
sidePanelPaneId,
|
||||
maximizedPaneId,
|
||||
onTogglePaneMaximized,
|
||||
focusModeEnabled,
|
||||
@@ -1195,9 +1218,15 @@ function SplitPaneView({
|
||||
() => onTogglePaneMaximized(paneId),
|
||||
[onTogglePaneMaximized, paneId],
|
||||
);
|
||||
const handleClosePane = useCallback(() => onClosePane(paneId), [onClosePane, paneId]);
|
||||
return (
|
||||
<RenderProfile id={`SplitPaneView:${pane.id}`}>
|
||||
<View ref={paneRef} collapsable={false} style={styles.pane}>
|
||||
<View
|
||||
ref={paneRef}
|
||||
collapsable={false}
|
||||
style={styles.pane}
|
||||
testID={pane.id === sidePanelPaneId ? "workspace-side-panel" : `workspace-pane-${pane.id}`}
|
||||
>
|
||||
<WindowChromeSafeArea placement="inline" style={styles.paneTabs}>
|
||||
<TitlebarDragRegion />
|
||||
<WorkspaceDesktopTabsRow
|
||||
@@ -1228,7 +1257,7 @@ function SplitPaneView({
|
||||
tabDropPreviewIndex={
|
||||
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
|
||||
}
|
||||
showPaneMaximizeAction={pane.id === explorerPaneId && !focusModeEnabled}
|
||||
showPaneMaximizeAction={pane.id === sidePanelPaneId && !focusModeEnabled}
|
||||
paneMaximized={pane.id === maximizedPaneId}
|
||||
onTogglePaneMaximized={handleToggleMaximized}
|
||||
focusModeEnabled={Boolean(focusModeEnabled)}
|
||||
@@ -1257,7 +1286,11 @@ function SplitPaneView({
|
||||
/>
|
||||
);
|
||||
})
|
||||
: (renderPaneEmptyState?.() ?? null)}
|
||||
: (renderPaneEmptyState?.({
|
||||
paneId,
|
||||
paneIsolated,
|
||||
onClosePane: handleClosePane,
|
||||
}) ?? null)}
|
||||
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import React, { createContext, useContext, useCallback, useMemo } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import Animated from "react-native-reanimated";
|
||||
import {
|
||||
BottomSheetScrollView,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BottomSheetScrollView, BottomSheetBackdrop } from "@gorhom/bottom-sheet";
|
||||
import { X } from "lucide-react-native";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
type ContextBridge,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import type { ToolCallIconComponent } from "@/utils/tool-call-icon";
|
||||
@@ -45,17 +42,43 @@ export function useToolCallSheet(): ToolCallSheetContextValue {
|
||||
return context;
|
||||
}
|
||||
|
||||
// ----- Custom Background Component -----
|
||||
|
||||
function CustomSheetBackground({ style }: BottomSheetBackgroundProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const containerStyle = useMemo(
|
||||
() => [style, { backgroundColor: theme.colors.surface2, borderRadius: 16 }],
|
||||
[style, theme.colors.surface2],
|
||||
export function useToolCallSheetContextBridge(): ContextBridge {
|
||||
const context = useToolCallSheet();
|
||||
return useCallback(
|
||||
(children) => (
|
||||
<ToolCallSheetContext.Provider value={context}>{children}</ToolCallSheetContext.Provider>
|
||||
),
|
||||
[context],
|
||||
);
|
||||
return <Animated.View pointerEvents="none" style={containerStyle} />;
|
||||
}
|
||||
|
||||
interface ToolCallHeaderIconProps {
|
||||
icon: ToolCallIconComponent;
|
||||
size: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
function ToolCallHeaderIcon({ icon: Icon, size, color }: ToolCallHeaderIconProps) {
|
||||
return <Icon size={size} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedToolCallHeaderIcon = withUnistyles(ToolCallHeaderIcon, (theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
}));
|
||||
const ThemedCloseIcon = withUnistyles(X, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
|
||||
export const ToolCallSheetModal = withUnistyles(IsolatedBottomSheetModal, (theme) => ({
|
||||
backgroundStyle: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderRadius: 16,
|
||||
},
|
||||
handleIndicatorStyle: {
|
||||
backgroundColor: theme.colors.palette.zinc[600],
|
||||
},
|
||||
}));
|
||||
|
||||
// ----- Provider Component -----
|
||||
|
||||
interface ToolCallSheetProviderProps {
|
||||
@@ -63,7 +86,6 @@ interface ToolCallSheetProviderProps {
|
||||
}
|
||||
|
||||
export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const [sheetData, setSheetData] = React.useState<ToolCallSheetData | null>(null);
|
||||
const [isSheetOpen, setIsSheetOpen] = React.useState(false);
|
||||
|
||||
@@ -104,15 +126,10 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
|
||||
[openToolCall, closeToolCall],
|
||||
);
|
||||
|
||||
const handleIndicatorStyle = useMemo(
|
||||
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
|
||||
[theme.colors.palette.zinc],
|
||||
);
|
||||
|
||||
return (
|
||||
<ToolCallSheetContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<IsolatedBottomSheetModal
|
||||
<ToolCallSheetModal
|
||||
ref={bottomSheetRef}
|
||||
contextBridge={null}
|
||||
snapPoints={snapPoints}
|
||||
@@ -122,11 +139,9 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
|
||||
onDismiss={handleToolCallSheetDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
backgroundComponent={CustomSheetBackground}
|
||||
handleIndicatorStyle={handleIndicatorStyle}
|
||||
>
|
||||
{sheetData && <ToolCallSheetContent data={sheetData} onClose={closeToolCall} />}
|
||||
</IsolatedBottomSheetModal>
|
||||
</ToolCallSheetModal>
|
||||
</ToolCallSheetContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -139,7 +154,7 @@ interface ToolCallSheetContentProps {
|
||||
}
|
||||
|
||||
function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const { displayName, detail, errorText, icon: IconComponent, showLoadingSkeleton } = data;
|
||||
|
||||
return (
|
||||
@@ -147,13 +162,19 @@ function ToolCallSheetContent({ data, onClose }: ToolCallSheetContentProps) {
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerLeft}>
|
||||
<IconComponent size={20} color={theme.colors.foreground} />
|
||||
<ThemedToolCallHeaderIcon icon={IconComponent} size={20} />
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable onPress={onClose} style={styles.closeButton}>
|
||||
<X size={20} color={theme.colors.foregroundMuted} />
|
||||
<Pressable
|
||||
onPress={onClose}
|
||||
style={styles.closeButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("common.actions.close")}
|
||||
testID="tool-call-sheet-close"
|
||||
>
|
||||
<ThemedCloseIcon size={20} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { MIN_TREE_RAIL_WIDTH, clampTreeRailWidth } from "@/stores/panel-store";
|
||||
import { MIN_TREE_RAIL_WIDTH } from "@/stores/panel-store";
|
||||
|
||||
const MIN_CONTENT_WIDTH = 240;
|
||||
|
||||
export function resolveVisibleTreeRailWidth(
|
||||
requestedWidth: number,
|
||||
containerWidth: number,
|
||||
minimumWidth = MIN_TREE_RAIL_WIDTH,
|
||||
): number {
|
||||
const maximumVisibleWidth = Math.max(MIN_TREE_RAIL_WIDTH, containerWidth - MIN_CONTENT_WIDTH);
|
||||
return Math.min(clampTreeRailWidth(requestedWidth), maximumVisibleWidth);
|
||||
const maximumVisibleWidth = Math.max(minimumWidth, containerWidth - MIN_CONTENT_WIDTH);
|
||||
return Math.min(Math.max(minimumWidth, Math.min(600, requestedWidth)), maximumVisibleWidth);
|
||||
}
|
||||
|
||||
export function acceptTreeRailContainerMeasurement(
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useMemo } from "react";
|
||||
import { Pressable, Text } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderTree } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
paneContentToolbarIconSize,
|
||||
paneContentToolbarIconButtonStyle,
|
||||
} from "@/components/ui/pane-content-toolbar";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedFolderTree = withUnistyles(FolderTree);
|
||||
const iconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
/**
|
||||
* Opens and closes a `TreeRail`. Every panel that owns a tree rail uses this one
|
||||
* button so the affordance reads the same wherever the rail appears; only the
|
||||
* flag it drives is per-panel.
|
||||
*/
|
||||
export function TreeRailToggle({
|
||||
visible,
|
||||
testID,
|
||||
onToggle,
|
||||
}: {
|
||||
visible: boolean;
|
||||
testID: string;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const label = t(visible ? "workspace.tree.hideFolderTree" : "workspace.tree.showFolderTree");
|
||||
const buttonStyle = useMemo(
|
||||
() => (state: { hovered?: boolean; pressed: boolean }) =>
|
||||
paneContentToolbarIconButtonStyle(state, visible),
|
||||
[visible],
|
||||
);
|
||||
const accessibilityState = useMemo(() => ({ selected: visible }), [visible]);
|
||||
return (
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-selected={visible}
|
||||
testID={testID}
|
||||
style={buttonStyle}
|
||||
onPress={onToggle}
|
||||
>
|
||||
<ThemedFolderTree size={paneContentToolbarIconSize(false)} uniProps={iconColorMapping} />
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<Text style={styles.tooltipText}>{label}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
}));
|
||||
@@ -27,16 +27,20 @@ export interface TreeRailProps {
|
||||
*/
|
||||
children: ReactNode;
|
||||
testID?: string;
|
||||
width?: number;
|
||||
onWidthChange?: (width: number) => void;
|
||||
minimumWidth?: number;
|
||||
}
|
||||
|
||||
/** Content-left/tree-right master-detail geometry with one app-wide tree width. */
|
||||
export function TreeRail({ children, testID }: TreeRailProps) {
|
||||
export function TreeRail({ children, testID, width, onWidthChange, minimumWidth }: TreeRailProps) {
|
||||
const [content, tree] = Children.toArray(children);
|
||||
const requestedWidth = usePanelStore((state) => state.treeRailWidth);
|
||||
const setTreeRailWidth = usePanelStore((state) => state.setTreeRailWidth);
|
||||
const sharedWidth = usePanelStore((state) => state.treeRailWidth);
|
||||
const setSharedWidth = usePanelStore((state) => state.setTreeRailWidth);
|
||||
const requestedWidth = width ?? sharedWidth;
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const [containerWidth, setContainerWidth] = useState(viewportWidth);
|
||||
const visibleWidth = resolveVisibleTreeRailWidth(requestedWidth, containerWidth);
|
||||
const visibleWidth = resolveVisibleTreeRailWidth(requestedWidth, containerWidth, minimumWidth);
|
||||
const startWidthRef = useRef(visibleWidth);
|
||||
const resizeWidth = useSharedValue(visibleWidth);
|
||||
const [resizePressed, setResizePressed] = useState(false);
|
||||
@@ -62,11 +66,21 @@ export function TreeRail({ children, testID }: TreeRailProps) {
|
||||
resizeWidth.value = resolveVisibleTreeRailWidth(
|
||||
startWidthRef.current - event.translationX,
|
||||
containerWidth,
|
||||
minimumWidth,
|
||||
);
|
||||
})
|
||||
.onEnd(() => runOnJS(setTreeRailWidth)(resizeWidth.value))
|
||||
.onEnd(() => runOnJS(onWidthChange ?? setSharedWidth)(resizeWidth.value))
|
||||
.onFinalize(() => scheduleOnRN(hideResizeGrip)),
|
||||
[containerWidth, hideResizeGrip, resizeWidth, setTreeRailWidth, showResizeGrip, visibleWidth],
|
||||
[
|
||||
containerWidth,
|
||||
hideResizeGrip,
|
||||
onWidthChange,
|
||||
minimumWidth,
|
||||
resizeWidth,
|
||||
setSharedWidth,
|
||||
showResizeGrip,
|
||||
visibleWidth,
|
||||
],
|
||||
);
|
||||
const railWidthStyle = useAnimatedStyle(() => ({ width: resizeWidth.value }));
|
||||
const handleLayout = useCallback(
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
|
||||
/** Shared chrome at the boundary between a workspace pane's tabs and content. */
|
||||
export function PaneContentToolbar({
|
||||
children,
|
||||
style,
|
||||
testID,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
}) {
|
||||
return (
|
||||
<View style={[styles.toolbar, style]} testID={testID}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function paneContentToolbarIconSize(isCompact: boolean): number {
|
||||
return isCompact ? 18 : 14;
|
||||
}
|
||||
|
||||
export function paneContentToolbarIconButtonStyle(
|
||||
{ hovered, pressed }: { hovered?: boolean; pressed: boolean },
|
||||
selected = false,
|
||||
isCompact = false,
|
||||
) {
|
||||
return [
|
||||
isCompact ? styles.iconButtonCompact : styles.iconButton,
|
||||
(selected || Boolean(hovered) || pressed) && styles.iconButtonSelected,
|
||||
];
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
toolbar: {
|
||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
flexShrink: 0,
|
||||
},
|
||||
iconButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
flexShrink: 0,
|
||||
},
|
||||
iconButtonCompact: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: theme.borderRadius.md,
|
||||
flexShrink: 0,
|
||||
},
|
||||
iconButtonSelected: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
@@ -39,6 +39,7 @@ export function Shortcut({
|
||||
if (displayChord.length === 1) {
|
||||
return (
|
||||
<View style={badgeStyle}>
|
||||
<View style={styles.badgeBackground} />
|
||||
<Text style={textCombinedStyle}>{formatShortcut(singleCombo, shortcutOs)}</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -49,6 +50,7 @@ export function Shortcut({
|
||||
{displayChord.map(function (combo) {
|
||||
return (
|
||||
<View key={combo.join("+")} style={styles.badge}>
|
||||
<View style={styles.badgeBackground} />
|
||||
<Text style={textCombinedStyle}>{formatShortcut(combo, shortcutOs)}</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -59,12 +61,18 @@ export function Shortcut({
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
badge: {
|
||||
position: "relative",
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
paddingVertical: 2,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
borderWidth: 0,
|
||||
},
|
||||
badgeBackground: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
backgroundColor: theme.colors.surface4,
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
sequence: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act, createRef } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { EditingTextInput } from "./text-input";
|
||||
import type { EditingTextInputHandle } from "./types";
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root && container) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
container.remove();
|
||||
}
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
function noop() {}
|
||||
|
||||
describe("EditingTextInputNative", () => {
|
||||
it("clears text via clear() when replaceText receives an empty string", () => {
|
||||
const handleRef = createRef<EditingTextInputHandle>();
|
||||
|
||||
act(() => {
|
||||
root?.render(<EditingTextInput ref={handleRef} initialValue="initial" onChangeText={noop} />);
|
||||
});
|
||||
|
||||
expect(handleRef.current?.getText()).toBe("initial");
|
||||
|
||||
act(() => {
|
||||
handleRef.current?.replaceText("");
|
||||
});
|
||||
|
||||
expect(handleRef.current?.getText()).toBe("");
|
||||
});
|
||||
|
||||
it("updates textRef and text when replaceText receives non-empty text", () => {
|
||||
const handleRef = createRef<EditingTextInputHandle>();
|
||||
|
||||
act(() => {
|
||||
root?.render(<EditingTextInput ref={handleRef} initialValue="hello" />);
|
||||
});
|
||||
|
||||
expect(handleRef.current?.getText()).toBe("hello");
|
||||
|
||||
act(() => {
|
||||
handleRef.current?.replaceText("world", { start: 0, end: 5 });
|
||||
});
|
||||
|
||||
expect(handleRef.current?.getText()).toBe("world");
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,9 @@ type NativeInput = (TextInput | PasteTextInputInstance) & {
|
||||
blur(): void;
|
||||
focus(): void;
|
||||
isFocused(): boolean;
|
||||
setNativeProps(props: { text: string; selection?: { start: number; end: number } }): void;
|
||||
clear?(): void;
|
||||
replaceText?(text: string, selection?: { start: number; end: number }): void;
|
||||
setNativeProps?(props: { text?: string; selection?: { start: number; end: number } }): void;
|
||||
setSelection?(start: number, end: number): void;
|
||||
getNativeRef?(): unknown;
|
||||
};
|
||||
@@ -39,7 +41,18 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
||||
getText: () => textRef.current,
|
||||
replaceText: (nextText, selection) => {
|
||||
textRef.current = nextText;
|
||||
inputRef.current?.setNativeProps({ text: nextText, ...(selection ? { selection } : {}) });
|
||||
if (inputRef.current?.replaceText) {
|
||||
inputRef.current.replaceText(nextText, selection);
|
||||
return;
|
||||
}
|
||||
if (nextText === "") {
|
||||
inputRef.current?.clear?.();
|
||||
} else {
|
||||
inputRef.current?.setNativeProps?.({
|
||||
text: nextText,
|
||||
...(selection ? { selection } : {}),
|
||||
});
|
||||
}
|
||||
if (selection) inputRef.current?.setSelection?.(selection.start, selection.end);
|
||||
},
|
||||
getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current,
|
||||
|
||||
@@ -20,7 +20,6 @@ import { resolveTurnPresentation, TURN_LIVENESS_IDLE } from "@/timeline/turn-liv
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
@@ -49,7 +48,11 @@ import {
|
||||
useIsCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
type WorkspaceDraftTabSetup,
|
||||
} from "@/workspace-tabs/model";
|
||||
import { openSidePanelView } from "@/workspace-tabs/side-panel";
|
||||
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||
const EMPTY_ONLINE_SERVER_IDS: string[] = [];
|
||||
@@ -443,28 +446,19 @@ export function WorkspaceDraftAgentTab({
|
||||
const clearWorkspaceAttachments = useWorkspaceAttachmentsStore(
|
||||
(state) => state.clearWorkspaceAttachments,
|
||||
);
|
||||
const openFileExplorerForCheckout = usePanelStore((state) => state.openFileExplorerForCheckout);
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const handleOpenWorkspaceAttachment = useCallback(
|
||||
(attachment: WorkspaceComposerAttachment) => {
|
||||
if (attachment.kind !== "review") {
|
||||
return;
|
||||
}
|
||||
const checkout = {
|
||||
serverId,
|
||||
cwd: attachment.attachment.cwd,
|
||||
isGit: true,
|
||||
};
|
||||
openFileExplorerForCheckout({
|
||||
checkout,
|
||||
openSidePanelView({
|
||||
isCompact: isCompactFormFactor,
|
||||
});
|
||||
setExplorerTabForCheckout({
|
||||
...checkout,
|
||||
tab: "changes",
|
||||
workspaceKey: buildWorkspaceTabPersistenceKey({ serverId, workspaceId: workspaceId ?? "" }),
|
||||
checkout: { serverId, cwd: attachment.attachment.cwd, isGit: true },
|
||||
view: "changes",
|
||||
});
|
||||
},
|
||||
[isCompactFormFactor, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout],
|
||||
[isCompactFormFactor, serverId, workspaceId],
|
||||
);
|
||||
|
||||
const {
|
||||
|
||||
@@ -69,6 +69,50 @@ describe("focused chat target", () => {
|
||||
parentTabIdByTabId: { "changes-tab": "agent-tab" },
|
||||
} as WorkspaceLayout;
|
||||
|
||||
expect(resolveFocusedChatTarget({ serverId: "server-1", layout })).toEqual({
|
||||
tabId: "agent-tab",
|
||||
draftKey: "agent:server-1:agent-1",
|
||||
});
|
||||
});
|
||||
it("targets a chat in a neighbouring pane when the focused pane holds none", () => {
|
||||
const layout = {
|
||||
root: {
|
||||
kind: "group",
|
||||
group: {
|
||||
id: "group-1",
|
||||
direction: "horizontal",
|
||||
sizes: [0.5, 0.5],
|
||||
children: [
|
||||
{
|
||||
kind: "pane",
|
||||
pane: {
|
||||
id: "main",
|
||||
tabIds: ["agent-tab"],
|
||||
focusedTabId: "agent-tab",
|
||||
tabs: [
|
||||
{
|
||||
tabId: "agent-tab",
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "pane",
|
||||
pane: {
|
||||
id: "explorer",
|
||||
tabIds: ["changes-tab"],
|
||||
focusedTabId: "changes-tab",
|
||||
tabs: [{ tabId: "changes-tab", target: { kind: "working_diff" }, createdAt: 2 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
focusedPaneId: "explorer",
|
||||
} as unknown as WorkspaceLayout;
|
||||
|
||||
expect(resolveFocusedChatTarget({ serverId: "server-1", layout })).toEqual({
|
||||
tabId: "agent-tab",
|
||||
draftKey: "agent:server-1:agent-1",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import {
|
||||
collectAllPanes,
|
||||
collectAllTabs,
|
||||
findPaneById,
|
||||
type WorkspaceLayout,
|
||||
@@ -33,6 +34,12 @@ function resolveChatTab(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chat a file should attach to. The focused pane answers first, then the tab it
|
||||
* came from — a file opened beside a chat belongs to that chat. Failing both, any
|
||||
* chat the user can see: standing in the Side panel looking at Changes, "add to
|
||||
* chat" plainly means the chat in the pane next to it.
|
||||
*/
|
||||
export function resolveFocusedChatTarget(input: {
|
||||
serverId: string;
|
||||
layout: WorkspaceLayout | undefined;
|
||||
@@ -40,20 +47,34 @@ export function resolveFocusedChatTarget(input: {
|
||||
if (!input.layout) {
|
||||
return null;
|
||||
}
|
||||
const pane = findPaneById(input.layout.root, input.layout.focusedPaneId);
|
||||
const focusedTabId = pane?.focusedTabId;
|
||||
if (!focusedTabId) {
|
||||
return null;
|
||||
}
|
||||
const tabs = collectAllTabs(input.layout.root);
|
||||
const tab = tabs.find((candidate) => candidate.tabId === focusedTabId);
|
||||
const focusedChat = resolveChatTab(input.serverId, tab);
|
||||
if (focusedChat) {
|
||||
return focusedChat;
|
||||
const focusedTabId = findPaneById(input.layout.root, input.layout.focusedPaneId)?.focusedTabId;
|
||||
if (focusedTabId) {
|
||||
const focusedChat = resolveChatTab(
|
||||
input.serverId,
|
||||
tabs.find((candidate) => candidate.tabId === focusedTabId),
|
||||
);
|
||||
if (focusedChat) {
|
||||
return focusedChat;
|
||||
}
|
||||
const parentTabId = input.layout.parentTabIdByTabId?.[focusedTabId];
|
||||
const parentChat = resolveChatTab(
|
||||
input.serverId,
|
||||
tabs.find((candidate) => candidate.tabId === parentTabId),
|
||||
);
|
||||
if (parentChat) {
|
||||
return parentChat;
|
||||
}
|
||||
}
|
||||
const parentTabId = input.layout.parentTabIdByTabId?.[focusedTabId];
|
||||
return resolveChatTab(
|
||||
input.serverId,
|
||||
tabs.find((candidate) => candidate.tabId === parentTabId),
|
||||
);
|
||||
|
||||
for (const pane of collectAllPanes(input.layout.root)) {
|
||||
const paneChat = resolveChatTab(
|
||||
input.serverId,
|
||||
tabs.find((candidate) => candidate.tabId === pane.focusedTabId),
|
||||
);
|
||||
if (paneChat) {
|
||||
return paneChat;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ import {
|
||||
} from "@/attachments/service";
|
||||
import { resolveAgentControlsMode } from "@/composer/agent-controls/mode";
|
||||
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
|
||||
import { resolveActiveSendBehavior } from "./input/state";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
@@ -1476,6 +1477,20 @@ function ComposerContentImpl({
|
||||
const beginAgentCancellation = useSessionStore((state) => state.beginAgentCancellation);
|
||||
const settleAgentCancellation = useSessionStore((state) => state.settleAgentCancellation);
|
||||
const isAgentRunning = hasActiveTurn;
|
||||
// Queueing behind a permission prompt would strand the message: the turn is
|
||||
// parked until the request is answered.
|
||||
const hasPendingPermission = useSessionStore((state) => {
|
||||
const pendingPermissions = state.sessions[serverId]?.pendingPermissions;
|
||||
if (!pendingPermissions) return false;
|
||||
for (const permission of pendingPermissions.values()) {
|
||||
if (permission.agentId === agentId) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const activeSendBehavior = resolveActiveSendBehavior(
|
||||
appSettings.sendBehavior,
|
||||
hasPendingPermission,
|
||||
);
|
||||
const hasAgent = agentState.status !== null;
|
||||
|
||||
const queueWriter = useMemo<QueueWriter>(
|
||||
@@ -2302,7 +2317,7 @@ function ComposerContentImpl({
|
||||
voiceServerId={serverId}
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
defaultSendBehavior={appSettings.sendBehavior}
|
||||
defaultSendBehavior={activeSendBehavior}
|
||||
onQueue={handleQueue}
|
||||
onSubmitLoadingPress={submitLoadingPressHandler}
|
||||
onKeyPress={handleCommandKeyPress}
|
||||
|
||||
@@ -1980,9 +1980,9 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
textInput: {
|
||||
width: "100%",
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: theme.fontSize.content,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: theme.fontSize.base * 1.4,
|
||||
lineHeight: theme.fontSize.content * 1.4,
|
||||
...(isWeb
|
||||
? ({
|
||||
outlineStyle: "none",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user