mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72b9ea1652 |
@@ -50,8 +50,6 @@ The daemon also supports an optional shared-secret password (set via `auth.passw
|
|||||||
|
|
||||||
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
|
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
|
||||||
|
|
||||||
When Paseo checks out a change request from a different repository, it does not run that workspace's `paseo.json` setup, automatic terminals, named scripts, or teardown until you explicitly run setup for that workspace. The decision lasts for the workspace and does not re-prompt after new commits. Same-repository changes, ordinary branches, local workspaces, agent launches, terminals, explicit shell commands, and metadata-generation instructions are outside this gate.
|
|
||||||
|
|
||||||
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
|
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
|
||||||
|
|
||||||
In Docker, the official image runs the daemon and agents as the non-root
|
In Docker, the official image runs the daemon and agents as the non-root
|
||||||
|
|||||||
+19
-20
@@ -439,26 +439,25 @@ workspace together with its owning project.
|
|||||||
|
|
||||||
Array of workspace records. A workspace is a specific working directory within a project.
|
Array of workspace records. A workspace is a specific working directory within a project.
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ------------------------------ | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
|
| `workspaceId` | `string` | Opaque stable identifier (`wks_<hex>`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. |
|
||||||
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
|
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
|
||||||
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
|
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
|
||||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
|
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
|
||||||
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
|
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
|
||||||
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
|
| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". |
|
||||||
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
|
| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. |
|
||||||
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
|
| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. |
|
||||||
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
|
| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees |
|
||||||
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
|
| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` |
|
||||||
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
|
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
| `updatedAt` | `string` (ISO 8601) | |
|
| `updatedAt` | `string` (ISO 8601) | |
|
||||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||||
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
|
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
|
||||||
| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. |
|
| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. |
|
||||||
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
||||||
| `untrustedSource` | `{ kind: "change_request", forge, number, headRepository }?` | Provenance captured when a cross-repository change request creates the workspace. Missing means repository automation is allowed; explicit setup removes the field. |
|
|
||||||
|
|
||||||
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
|
> **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity.
|
||||||
|
|
||||||
|
|||||||
+52
-89
@@ -1,8 +1,9 @@
|
|||||||
# Plugins
|
# Plugins
|
||||||
|
|
||||||
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
|
||||||
client slash commands, timeline items, composer pills, app themes, and composer attachment sources.
|
client slash commands, timeline items, composer pills, app themes, and composer attachment sources
|
||||||
Paseo executes `index.server.ts` in a subprocess and `index.client.tsx` in every connected app.
|
from one `index.ts`. Paseo executes the server contribution in a subprocess and evaluates the client
|
||||||
|
contribution in the app runtime.
|
||||||
|
|
||||||
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
|
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
|
||||||
|
|
||||||
@@ -41,19 +42,15 @@ runtime-safe: run `paseo reload` after editing `config.json`. Enabling starts ev
|
|||||||
enabled plugin; disabling tears them all down without restarting the daemon. Plugin source entries
|
enabled plugin; disabling tears them all down without restarting the daemon. Plugin source entries
|
||||||
remain lifecycle-owned and do not reload from manual config edits.
|
remain lifecycle-owned and do not reload from manual config edits.
|
||||||
|
|
||||||
The directory contains an identity-only manifest, one optional entry per runtime, runtime-owned
|
The directory contains an identity-only manifest, one entry point, and local typechecking support:
|
||||||
directories, and local typechecking support. At least one entry is required.
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
my-plugin/
|
my-plugin/
|
||||||
paseo-plugin.json
|
paseo-plugin.json
|
||||||
|
index.ts
|
||||||
|
main.client.tsx
|
||||||
package.json
|
package.json
|
||||||
tsconfig.json
|
tsconfig.json
|
||||||
index.client.tsx
|
|
||||||
index.server.ts
|
|
||||||
client/greeting.tsx
|
|
||||||
server/greeting.ts
|
|
||||||
shared/greeting.ts
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The generated `package.json` installs `@getpaseo/plugin` and the other host modules as development
|
The generated `package.json` installs `@getpaseo/plugin` and the other host modules as development
|
||||||
@@ -139,19 +136,16 @@ log credentials or tokens.
|
|||||||
|
|
||||||
## Contribute behavior and UI
|
## Contribute behavior and UI
|
||||||
|
|
||||||
Default export one contribution function from each runtime entry. Keep the entries to registration
|
Default export one contribution function from `index.ts`. Keep it to contribution wiring. Runtime
|
||||||
wiring. Runtime code lives behind directory boundaries:
|
code lives behind filename boundaries:
|
||||||
|
|
||||||
| Path | Owns |
|
| Suffix | Owns |
|
||||||
| -------------------------------- | -------------------------------------------------------------------- |
|
| -------------- | -------------------------------------------------------------------- |
|
||||||
| `index.client.tsx` and `client/` | React, React Native, hooks, styles, surfaces, panels, and callbacks. |
|
| `*.client.tsx` | React, React Native, hooks, styles, surfaces, panels, and callbacks. |
|
||||||
| `index.server.ts` and `server/` | Node APIs, filesystem and process access, credentials, and handlers. |
|
| `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. |
|
||||||
| `shared/` | Zod RPC contracts and plain values used by both runtimes. |
|
| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. |
|
||||||
|
|
||||||
Do not put any other code modules in the plugin root.
|
Shared files import contracts from `@getpaseo/plugin/server`. Client files import Paseo UI from
|
||||||
|
|
||||||
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
|
|
||||||
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
|
|
||||||
`@getpaseo/plugin/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
|
`@getpaseo/plugin/react-native`. Its `Icon` resolves a Lucide name using the client's installed icon
|
||||||
set; an unknown name renders nothing so it cannot break the plugin surface.
|
set; an unknown name renders nothing so it cannot break the plugin surface.
|
||||||
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
|
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
|
||||||
@@ -160,45 +154,28 @@ Plugin UI runs on desktop and mobile across multiple themes: color every `Text`
|
|||||||
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
|
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
|
||||||
See `public-docs/plugins/reference.md`.
|
See `public-docs/plugins/reference.md`.
|
||||||
|
|
||||||
| Module | Use it for |
|
| Module | Use it for |
|
||||||
| ------------------------------- | ----------------------------------------------------------------------------------------- |
|
| ------------------------------- | -------------------------------------------------------- |
|
||||||
| `@getpaseo/plugin` | contribution contracts, shared definitions, RPC input/output types, and client data hooks |
|
| `@getpaseo/plugin` | contribution contracts and client data hooks |
|
||||||
| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks |
|
| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks |
|
||||||
| `@getpaseo/plugin/server` | handler-only types such as `PluginHandlerContext` |
|
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
|
||||||
|
|
||||||
The compiler rejects a client import of `server/`, a server import of `client/`, and every `node:`
|
The compiler removes client registrations and imports from the server entry point, and server
|
||||||
import reachable from client code. Shared modules cannot import runtime-owned modules. A relative
|
registrations and imports from the client entry point. Importing a `*.server` module from a client
|
||||||
import to any other code file in the plugin root is also rejected; move it into `client/`, `server/`,
|
module, or a `*.client` module from a server module, fails compilation. Top-level React Native calls
|
||||||
or `shared/`. These are compile errors naming the importing file and boundary rule. Top-level React
|
such as `StyleSheet.create` belong in `*.client.tsx`; placing them in `index.ts` executes them in the
|
||||||
Native calls such as `StyleSheet.create` belong in `client/`.
|
server bundle.
|
||||||
|
|
||||||
The scaffold omits `"DOM"` from `tsconfig.json` and does not use `/// <reference lib="dom" />`, so
|
|
||||||
browser globals are not available across the plugin. Put sanctioned web-only APIs in
|
|
||||||
`client/web.ts`, declare only the globals that module uses, gate each export with
|
|
||||||
`Platform.OS === "web"`, and provide a native implementation or no-op. See the
|
|
||||||
[public plugin reference](../public-docs/plugins/reference.md#works-on-mobile) for the complete
|
|
||||||
pattern.
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// index.server.ts
|
import type { PluginContext } from "@getpaseo/plugin";
|
||||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
import { Greeting } from "./greeting.client";
|
||||||
import { createGreeting } from "./server/greeting";
|
import { createGreeting } from "./greeting.server";
|
||||||
import { greetRpc } from "./shared/greeting";
|
import { greetRpc } from "./greeting.shared";
|
||||||
|
|
||||||
export default function contribute(server: PluginServerContext) {
|
export default function contribute(plugin: PluginContext) {
|
||||||
server.handle(greetRpc, createGreeting);
|
plugin.handle(greetRpc, createGreeting);
|
||||||
return () => {};
|
plugin.addSurface("main", Greeting);
|
||||||
}
|
plugin.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" });
|
||||||
```
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// index.client.tsx
|
|
||||||
import type { PluginClientContext } from "@getpaseo/plugin";
|
|
||||||
import { Greeting } from "./client/greeting";
|
|
||||||
|
|
||||||
export default function contribute(client: PluginClientContext) {
|
|
||||||
client.addSurface("main", Greeting);
|
|
||||||
client.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" });
|
|
||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -255,10 +232,10 @@ Plugins do not receive Expo Router or workspace-layout store access.
|
|||||||
|
|
||||||
## Contribute composer pills
|
## Contribute composer pills
|
||||||
|
|
||||||
Add and remove targeted pills from the client entry lifecycle. `index.client.tsx` runs once per
|
Register a headless client entrypoint, then add and remove targeted pills from that client
|
||||||
plugin installation in each connected app and never runs in the daemon subprocess. It can subscribe
|
lifecycle. `addClientSide` runs once per plugin installation in each connected app and never runs
|
||||||
to the client API, call plugin RPCs, and own arbitrary client state without mounting a panel or
|
in the daemon subprocess. It can subscribe to the client API, call plugin RPCs, and own arbitrary
|
||||||
surface.
|
client state without mounting a panel or surface.
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
export function contributeClient(client: PluginClientContext) {
|
export function contributeClient(client: PluginClientContext) {
|
||||||
@@ -289,8 +266,8 @@ export function contributeClient(client: PluginClientContext) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Call `contributeClient(client)` from `index.client.tsx`, or move its body into that entry.
|
Wire it from `index.ts` with `plugin.addClientSide(contributeClient)`. `addComposerPill` exists only
|
||||||
`addComposerPill` returns an idempotent removal function. A pill appears only in the
|
on `PluginClientContext`; it returns an idempotent removal function. A pill appears only in the
|
||||||
matching workspace and agent track bar alongside Tasks and Subagents. Paseo owns the pressable,
|
matching workspace and agent track bar alongside Tasks and Subagents. Paseo owns the pressable,
|
||||||
shared chrome, pending state, error reporting, and placement. The component owns its icon and text;
|
shared chrome, pending state, error reporting, and placement. The component owns its icon and text;
|
||||||
the callback is client code by construction. Removing the pill, reloading the plugin, disconnecting
|
the callback is client code by construction. Removing the pill, reloading the plugin, disconnecting
|
||||||
@@ -329,22 +306,21 @@ await paseo.agents.ref(agentId).timeline.append({
|
|||||||
```
|
```
|
||||||
|
|
||||||
The daemon stamps the runtime `pluginId`; plugin code never supplies it. Reusing the same `id`
|
The daemon stamps the runtime `pluginId`; plugin code never supplies it. Reusing the same `id`
|
||||||
replaces the previous row from that plugin on live clients and fresh timeline fetches. Rows live in
|
replaces the previous row from that plugin on live clients and fresh timeline fetches. The row stays
|
||||||
the daemon's in-memory timeline and survive scroll, refetch, and reconnect, but not a daemon
|
in canonical history and renders an unavailable placeholder when its renderer is not installed.
|
||||||
restart. A row without an installed renderer shows an unavailable placeholder. Serialized `data`
|
Serialized `data` must be at most 64 KiB; the daemon rejects a larger append instead of storing a
|
||||||
must be at most 64 KiB; the daemon rejects a larger append instead of storing a payload that cannot
|
payload that cannot be rendered intact. The daemon advertises this RPC through
|
||||||
be rendered intact. The daemon advertises this RPC through
|
|
||||||
`server_info.features.pluginTimelineItems`.
|
`server_info.features.pluginTimelineItems`.
|
||||||
|
|
||||||
## Contribute client slash commands
|
## Contribute client slash commands
|
||||||
|
|
||||||
`addSlashCommand` registers an agent- or workspace-context command in the composer. The
|
`addClientSlashCommand` registers an agent- or workspace-context command in the composer. The
|
||||||
callback runs in the app, receives the trimmed text after the command name as `args`, and receives
|
callback runs in the app, receives the trimmed text after the command name as `args`, and receives
|
||||||
the same `paseo`, `rpc`, `openSurface`, workspace, agent, and `openPanel` capabilities as the matching
|
the same `paseo`, `rpc`, `openSurface`, workspace, agent, and `openPanel` capabilities as the matching
|
||||||
Command Center callback.
|
Command Center callback.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
client.addSlashCommand({
|
plugin.addClientSlashCommand({
|
||||||
name: "review",
|
name: "review",
|
||||||
description: "Run the review bot",
|
description: "Run the review bot",
|
||||||
argumentHint: "[scope]",
|
argumentHint: "[scope]",
|
||||||
@@ -367,24 +343,13 @@ search picker, drafts, selected pill, and submission. The plugin returns complet
|
|||||||
credentials and vendor API calls stay in the daemon handler.
|
credentials and vendor API calls stay in the daemon handler.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// index.server.ts
|
import type { PluginContext } from "@getpaseo/plugin";
|
||||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
import { search } from "./issues.server";
|
||||||
import { search } from "./server/issues";
|
import { issues, searchIssues } from "./issues.shared";
|
||||||
import { searchIssues } from "./shared/issues";
|
|
||||||
|
|
||||||
export default function contribute(server: PluginServerContext) {
|
export default function contribute(plugin: PluginContext) {
|
||||||
server.handle(searchIssues, search);
|
plugin.handle(searchIssues, search);
|
||||||
return () => {};
|
plugin.addAttachmentSource(issues);
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
// index.client.tsx
|
|
||||||
import type { PluginClientContext } from "@getpaseo/plugin";
|
|
||||||
import { issues } from "./shared/issues";
|
|
||||||
|
|
||||||
export default function contribute(client: PluginClientContext) {
|
|
||||||
client.addAttachmentSource(issues);
|
|
||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -403,8 +368,8 @@ contract. Unistyles needs every theme name at `StyleSheet.configure` time, so
|
|||||||
provider rewrites the matching slot when the selection changes. See [unistyles.md](unistyles.md)
|
provider rewrites the matching slot when the selection changes. See [unistyles.md](unistyles.md)
|
||||||
for the runtime-patching rules the appearance settings share.
|
for the runtime-patching rules the appearance settings share.
|
||||||
|
|
||||||
`addTheme` is a client registration and belongs in `index.client.tsx`. A client that predates it
|
`addTheme` is a client registration, so the compiler strips it from the backend bundle. A daemon
|
||||||
cannot evaluate that entry. Daemons advertise
|
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
|
`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.
|
a host without it contributes no themes.
|
||||||
|
|
||||||
@@ -417,8 +382,6 @@ 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
|
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.
|
preference instead of painting the reserved slot's placeholder colors.
|
||||||
|
|
||||||
Existing plugin authors should follow the standalone [runtime-entry migration guide](../public-docs/plugins/migration.md).
|
|
||||||
|
|
||||||
See `plugin-examples/local-plugin` for a native surface, `plugin-examples/linear` for a complete
|
See `plugin-examples/local-plugin` for a native surface, `plugin-examples/linear` for a complete
|
||||||
attachment-source example, `plugin-examples/timeline-items` for timeline projection, and
|
attachment-source example, `plugin-examples/timeline-items` for timeline projection, and
|
||||||
`plugin-examples/catppuccin` for a theme.
|
`plugin-examples/catppuccin` for a theme.
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-R5NuJTwMOYPo0i60qhF0lZU+OMHwjnIKf9gI1fCJ9/M=
|
sha256-0hOGev0HglOQmofzPQMfiWh1opg6cpiEgsfK22AKcGk=
|
||||||
|
|||||||
Generated
+47
@@ -39056,6 +39056,7 @@
|
|||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
|
"@babel/parser": "^7.29.8",
|
||||||
"@getpaseo/client": "0.7.2",
|
"@getpaseo/client": "0.7.2",
|
||||||
"@getpaseo/highlight": "0.7.2",
|
"@getpaseo/highlight": "0.7.2",
|
||||||
"@getpaseo/plugin": "0.7.2",
|
"@getpaseo/plugin": "0.7.2",
|
||||||
@@ -39105,6 +39106,52 @@
|
|||||||
"vitest": "^4.1.6"
|
"vitest": "^4.1.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/server/node_modules/@babel/helper-string-parser": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages/server/node_modules/@babel/helper-validator-identifier": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages/server/node_modules/@babel/parser": {
|
||||||
|
"version": "7.29.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
|
||||||
|
"integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/types": "^7.29.8"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"parser": "bin/babel-parser.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages/server/node_modules/@babel/types": {
|
||||||
|
"version": "7.29.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
|
||||||
|
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/helper-string-parser": "^7.29.7",
|
||||||
|
"@babel/helper-validator-identifier": "^7.29.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/server/node_modules/@esbuild/aix-ppc64": {
|
"packages/server/node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
|
|||||||
@@ -1019,16 +1019,6 @@ test("autofocusing an inline review keeps the Changes tab focused", async ({ pag
|
|||||||
.toBe(focusedBackground);
|
.toBe(focusedBackground);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("inline reviews keep the browser text context menu", async ({ page }) => {
|
|
||||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
|
||||||
await useUnwrappedDiffLines(page);
|
|
||||||
await openWorkspaceChanges(page, workspace);
|
|
||||||
|
|
||||||
await startReviewOnFirstChangedLine(page);
|
|
||||||
await page.getByTestId("inline-review-editor-input").click({ button: "right" });
|
|
||||||
await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("split canvas creates a review on the changed side and keeps it in that column", async ({
|
test("split canvas creates a review on the changed side and keeps it in that column", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -1152,33 +1142,6 @@ test("canvas diff copies a dragged character selection without opening a review"
|
|||||||
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("canvas diff context menu copies selections and source lines", async ({ context, page }) => {
|
|
||||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
|
||||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
|
||||||
await useUnwrappedDiffLines(page);
|
|
||||||
await openSelectionWorkspaceChanges(page, workspace);
|
|
||||||
|
|
||||||
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 });
|
|
||||||
await rightClickFirstChangedLine(page);
|
|
||||||
await page.getByTestId("diff-source-copy-selection").click();
|
|
||||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("CDEFGH");
|
|
||||||
|
|
||||||
await rightClickFirstChangedLine(page);
|
|
||||||
await page.getByTestId("diff-source-copy-line").click();
|
|
||||||
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("ABCDEFGHIJ");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("canvas diff clears a selection when collapsing an earlier file", async ({ page }) => {
|
|
||||||
const workspace = await createWorkspaceWithTwoSelectionDiffs();
|
|
||||||
await useUnwrappedDiffLines(page);
|
|
||||||
await openWorkspaceChanges(page, workspace);
|
|
||||||
|
|
||||||
await dragExactAddedText(page, { startOffset: 2, endOffset: 8 }, 1);
|
|
||||||
await page.getByTestId("diff-file-0-toggle").click();
|
|
||||||
await rightClickFirstChangedLine(page, 1);
|
|
||||||
await expect(page.getByTestId("diff-source-copy-selection")).toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => {
|
test("clicking the canvas dismisses a selection without opening a review", async ({ page }) => {
|
||||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
||||||
await useUnwrappedDiffLines(page);
|
await useUnwrappedDiffLines(page);
|
||||||
@@ -1576,29 +1539,6 @@ async function createWorkspaceWithExactSelectionDiff(content: string): Promise<D
|
|||||||
return { id: created.workspace.id, repoPath: repo.path };
|
return { id: created.workspace.id, repoPath: repo.path };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createWorkspaceWithTwoSelectionDiffs(): Promise<DirtyWorkspace> {
|
|
||||||
const repo = await createTempGitRepo("changes-canvas-selection-shift-", {
|
|
||||||
files: [
|
|
||||||
{ path: "src/first.ts", content: "" },
|
|
||||||
{ path: "src/second.ts", content: "" },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
await Promise.all([
|
|
||||||
writeFile(path.join(repo.path, "src/first.ts"), "FIRST\n"),
|
|
||||||
writeFile(path.join(repo.path, "src/second.ts"), "ABCDEFGHIJ\n"),
|
|
||||||
]);
|
|
||||||
const client = await connectSeedClient();
|
|
||||||
cleanupTasks.push({
|
|
||||||
run: async () => {
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
await repo.cleanup().catch(() => undefined);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
|
|
||||||
if (!created.workspace) throw new Error(created.error ?? "Failed to create selection workspace");
|
|
||||||
return { id: created.workspace.id, repoPath: repo.path };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise<DirtyWorkspace> {
|
async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise<DirtyWorkspace> {
|
||||||
const files = Array.from({ length: fileCount }, (_, index) => ({
|
const files = Array.from({ length: fileCount }, (_, index) => ({
|
||||||
path: `src/file-${String(index).padStart(4, "0")}.ts`,
|
path: `src/file-${String(index).padStart(4, "0")}.ts`,
|
||||||
@@ -1813,20 +1753,6 @@ async function clickFirstChangedLine(page: Page): Promise<void> {
|
|||||||
await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5);
|
await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rightClickFirstChangedLine(page: Page, fileIndex = 0): Promise<void> {
|
|
||||||
const body = page.getByTestId(`diff-file-${fileIndex}-body`);
|
|
||||||
const canvas = page.getByTestId("git-diff-canvas");
|
|
||||||
const [bodyBounds, fontSize] = await Promise.all([
|
|
||||||
body.boundingBox(),
|
|
||||||
canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)),
|
|
||||||
]);
|
|
||||||
if (!bodyBounds) throw new Error("Expanded diff body has no bounds");
|
|
||||||
const lineHeight = Math.round(fontSize * 1.5);
|
|
||||||
await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5, {
|
|
||||||
button: "right",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hoverFirstChangedGutter(page: Page): Promise<void> {
|
async function hoverFirstChangedGutter(page: Page): Promise<void> {
|
||||||
const body = page.getByTestId("diff-file-0-body");
|
const body = page.getByTestId("diff-file-0-body");
|
||||||
const canvas = page.getByTestId("git-diff-canvas");
|
const canvas = page.getByTestId("git-diff-canvas");
|
||||||
@@ -1867,9 +1793,8 @@ async function deleteInlineReview(page: Page): Promise<void> {
|
|||||||
async function dragExactAddedText(
|
async function dragExactAddedText(
|
||||||
page: Page,
|
page: Page,
|
||||||
offsets: { startOffset: number; endOffset: number },
|
offsets: { startOffset: number; endOffset: number },
|
||||||
fileIndex = 0,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const body = page.getByTestId(`diff-file-${fileIndex}-body`);
|
const body = page.getByTestId("diff-file-0-body");
|
||||||
const canvas = page.getByTestId("git-diff-canvas");
|
const canvas = page.getByTestId("git-diff-canvas");
|
||||||
const [bodyBounds, metrics] = await Promise.all([
|
const [bodyBounds, metrics] = await Promise.all([
|
||||||
body.boundingBox(),
|
body.boundingBox(),
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
|
||||||
import { mkdirSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { expect, test } from "../support/fixtures";
|
|
||||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
|
||||||
import {
|
|
||||||
connectWorkspaceSetupClient,
|
|
||||||
openHomeWithProject,
|
|
||||||
openWorkspaceScriptsMenu,
|
|
||||||
seedProjectForWorkspaceSetup,
|
|
||||||
startWorkspaceScriptFromMenu,
|
|
||||||
} from "../support/helpers/workspace-setup";
|
|
||||||
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
|
||||||
import { getServerId } from "../support/helpers/server-id";
|
|
||||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
|
||||||
|
|
||||||
const evidenceDir = "/tmp/fork-setup-gate";
|
|
||||||
|
|
||||||
test("fork workspace setup stays blocked until the user runs it", async ({ page }) => {
|
|
||||||
test.setTimeout(90_000);
|
|
||||||
mkdirSync(evidenceDir, { recursive: true });
|
|
||||||
const client = await connectWorkspaceSetupClient();
|
|
||||||
const repo = await createTempGitRepo("fork-setup-gate-", {
|
|
||||||
withRemote: true,
|
|
||||||
originUrl: "https://github.com/paseo-e2e/local-fixture.git",
|
|
||||||
branches: ["pr-branch-2"],
|
|
||||||
paseoConfig: {
|
|
||||||
worktree: {
|
|
||||||
setup: ["node -e \"setTimeout(() => console.log('setup complete'), 1500)\""],
|
|
||||||
},
|
|
||||||
scripts: { dev: { command: 'node -e "setTimeout(() => {}, 30000)"' } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const localRemote = path.join(repo.path, "remote.git");
|
|
||||||
execFileSync("git", ["update-ref", "refs/pull/2/head", "refs/heads/pr-branch-2"], {
|
|
||||||
cwd: localRemote,
|
|
||||||
});
|
|
||||||
execFileSync(
|
|
||||||
"git",
|
|
||||||
["config", `url.${localRemote}.insteadOf`, "git@github.com:fork-owner/local-fixture.git"],
|
|
||||||
{ cwd: repo.path },
|
|
||||||
);
|
|
||||||
execFileSync(
|
|
||||||
"git",
|
|
||||||
[
|
|
||||||
"config",
|
|
||||||
"--add",
|
|
||||||
`url.${localRemote}.insteadOf`,
|
|
||||||
"https://github.com/paseo-e2e/local-fixture.git",
|
|
||||||
],
|
|
||||||
{ cwd: repo.path },
|
|
||||||
);
|
|
||||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
|
||||||
const result = await client.createWorkspace({
|
|
||||||
source: {
|
|
||||||
kind: "worktree",
|
|
||||||
cwd: repo.path,
|
|
||||||
action: "checkout",
|
|
||||||
checkoutSource: { kind: "change_request", forge: "github", number: 2 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!result.workspace || result.error)
|
|
||||||
throw new Error(result.error ?? "Workspace creation failed");
|
|
||||||
expect(result.setupSkippedReason).toContain("Scripts are blocked for PR #2");
|
|
||||||
|
|
||||||
await openHomeWithProject(page, repo.path);
|
|
||||||
await page.goto(buildHostWorkspaceRoute(getServerId(), result.workspace.id));
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
|
|
||||||
await test.step("the blocked setup tab opens with the fork warning", async () => {
|
|
||||||
await expect(page.getByTestId("workspace-setup-panel")).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(page.getByRole("alert")).toContainText("fork-owner/local-fixture");
|
|
||||||
await expect(page.getByRole("button", { name: "Run setup" })).toBeVisible();
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(evidenceDir, "blocked-fork-setup.png"),
|
|
||||||
fullPage: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("starting a script explains why it is blocked", async () => {
|
|
||||||
await openWorkspaceScriptsMenu(page);
|
|
||||||
await startWorkspaceScriptFromMenu(page, "dev");
|
|
||||||
await expect(page.getByText(/Scripts are blocked for PR #2/)).toBeVisible();
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(evidenceDir, "blocked-script-error.png"),
|
|
||||||
fullPage: true,
|
|
||||||
});
|
|
||||||
await page.reload();
|
|
||||||
await waitForWorkspaceTabsVisible(page);
|
|
||||||
await expect(page.getByRole("button", { name: "Run setup" })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("running setup replaces the warning with normal progress", async () => {
|
|
||||||
await page.getByRole("button", { name: "Run setup" }).click();
|
|
||||||
await expect(page.getByTestId("workspace-setup-status")).toContainText(/Running|Completed/, {
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
await page.screenshot({
|
|
||||||
path: path.join(evidenceDir, "fork-setup-running.png"),
|
|
||||||
fullPage: true,
|
|
||||||
});
|
|
||||||
await expect(page.getByTestId("workspace-setup-status")).toContainText("Completed", {
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await client.close();
|
|
||||||
await repo.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
|
||||||
import { mkdtempSync } from "node:fs";
|
|
||||||
import { copyFile, mkdir, rm, utimes, writeFile } from "node:fs/promises";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import type { TestInfo } from "@playwright/test";
|
|
||||||
import { test, type Page } from "../support/fixtures";
|
|
||||||
import { ImportSessionFlow } from "../support/helpers/import-session";
|
|
||||||
import {
|
|
||||||
connectNewWorkspaceDaemonClient,
|
|
||||||
openProjectViaDaemon,
|
|
||||||
type OpenedProject,
|
|
||||||
} from "../support/helpers/new-workspace";
|
|
||||||
import { createTempDirectory, createTempGitRepo } from "../support/helpers/workspace";
|
|
||||||
|
|
||||||
const SCREENSHOT_DIRECTORY = path.join(
|
|
||||||
process.env.HOME ?? tmpdir(),
|
|
||||||
".paseo/plans/import-session-ux",
|
|
||||||
);
|
|
||||||
const claudeConfigDirectory = mkdtempSync(path.join(tmpdir(), "paseo-import-flow-claude-"));
|
|
||||||
const brokenProvider = "broken-acp";
|
|
||||||
|
|
||||||
test.use({
|
|
||||||
e2eDaemonConfig: {
|
|
||||||
version: 1,
|
|
||||||
agents: {
|
|
||||||
providers: {
|
|
||||||
codex: { enabled: false },
|
|
||||||
copilot: { enabled: false },
|
|
||||||
omp: { enabled: false },
|
|
||||||
opencode: { enabled: false },
|
|
||||||
pi: { enabled: false },
|
|
||||||
[brokenProvider]: {
|
|
||||||
extends: "acp",
|
|
||||||
label: "Broken ACP",
|
|
||||||
command: ["missing-agent-command", "acp"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
e2eDaemonEnvironment: { CLAUDE_CONFIG_DIR: claudeConfigDirectory },
|
|
||||||
});
|
|
||||||
|
|
||||||
interface ImportFlowScenario {
|
|
||||||
project: OpenedProject;
|
|
||||||
reuseTarget: OpenedProject;
|
|
||||||
projectName: string;
|
|
||||||
projectRoot: string;
|
|
||||||
worktreeDirectory: string;
|
|
||||||
unrelatedDirectory: string;
|
|
||||||
importSessionId: string;
|
|
||||||
repoCleanup(): Promise<void>;
|
|
||||||
unrelatedCleanup(): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
let scenario: ImportFlowScenario;
|
|
||||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
|
||||||
|
|
||||||
test.setTimeout(120_000);
|
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
await mkdir(SCREENSHOT_DIRECTORY, { recursive: true });
|
|
||||||
const repo = await createTempGitRepo("isf-", {
|
|
||||||
originUrl: "https://github.com/paseo-e2e/import-session-fixture.git",
|
|
||||||
});
|
|
||||||
const unrelated = await createTempDirectory("isf-other-");
|
|
||||||
const worktreeDirectory = path.join(repo.path, "worktrees", "review-fix");
|
|
||||||
await mkdir(path.dirname(worktreeDirectory), { recursive: true });
|
|
||||||
execFileSync("git", ["worktree", "add", "-b", "review-fix", worktreeDirectory], {
|
|
||||||
cwd: repo.path,
|
|
||||||
stdio: "ignore",
|
|
||||||
});
|
|
||||||
|
|
||||||
client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
|
||||||
const project = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
|
|
||||||
if (!project.workspace) {
|
|
||||||
throw new Error(project.error ?? "Failed to create the import-session fixture workspace");
|
|
||||||
}
|
|
||||||
const projects = await client.listProjects();
|
|
||||||
const projectDescriptor = projects.projects.find(
|
|
||||||
(candidate) => candidate.projectId === project.workspace?.projectId,
|
|
||||||
);
|
|
||||||
if (!projectDescriptor?.projectKey) {
|
|
||||||
throw new Error("Fixture workspace has no project key");
|
|
||||||
}
|
|
||||||
|
|
||||||
const openedProject: OpenedProject = {
|
|
||||||
workspaceId: project.workspace.id,
|
|
||||||
projectId: project.workspace.projectId,
|
|
||||||
projectKey: projectDescriptor.projectKey,
|
|
||||||
projectDisplayName: project.workspace.projectDisplayName,
|
|
||||||
workspaceName: project.workspace.name,
|
|
||||||
workspaceDirectory: project.workspace.workspaceDirectory,
|
|
||||||
};
|
|
||||||
const reuseTarget = await openProjectViaDaemon(client, unrelated.path);
|
|
||||||
const importSessionId = "fixture-custom-title";
|
|
||||||
await seedClaudeSessions({
|
|
||||||
projectRoot: repo.path,
|
|
||||||
worktreeDirectory,
|
|
||||||
unrelatedDirectory: unrelated.path,
|
|
||||||
importSessionId,
|
|
||||||
});
|
|
||||||
scenario = {
|
|
||||||
project: openedProject,
|
|
||||||
reuseTarget,
|
|
||||||
projectName: project.workspace.projectDisplayName,
|
|
||||||
projectRoot: repo.path,
|
|
||||||
worktreeDirectory,
|
|
||||||
unrelatedDirectory: unrelated.path,
|
|
||||||
importSessionId,
|
|
||||||
repoCleanup: repo.cleanup,
|
|
||||||
unrelatedCleanup: unrelated.cleanup,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
await client?.removeProject(scenario?.project.projectId).catch(() => undefined);
|
|
||||||
await client?.removeProject(scenario?.reuseTarget.projectId).catch(() => undefined);
|
|
||||||
await client?.close().catch(() => undefined);
|
|
||||||
await scenario?.repoCleanup().catch(() => undefined);
|
|
||||||
await scenario?.unrelatedCleanup().catch(() => undefined);
|
|
||||||
await rm(claudeConfigDirectory, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("captures the compact import-session journey", async ({ page }, testInfo) => {
|
|
||||||
const flow = new ImportSessionFlow(page);
|
|
||||||
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
|
|
||||||
|
|
||||||
await test.step("the mobile sidebar exposes import in its footer", async () => {
|
|
||||||
await flow.revealMobileEntryPoint();
|
|
||||||
await capture(page, testInfo, "01-mobile-sidebar-footer.png");
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("the host-wide sheet is newest first and fits its provider filter", async () => {
|
|
||||||
await flow.openGlobally();
|
|
||||||
await flow.expectScope("Sessions on", false);
|
|
||||||
await flow.expectRows({
|
|
||||||
first: [scenario.importSessionId, "fixture-worktree", "fixture-unrelated"],
|
|
||||||
folders: [
|
|
||||||
[scenario.importSessionId, scenario.projectName],
|
|
||||||
["fixture-worktree", `${scenario.projectName} · worktrees/review-fix`],
|
|
||||||
["fixture-unrelated", scenario.reuseTarget.projectDisplayName],
|
|
||||||
],
|
|
||||||
});
|
|
||||||
await flow.expectProviderError("Broken ACP");
|
|
||||||
await flow.expectProviderFilterFits(390);
|
|
||||||
await flow.revealSession("fixture-unrelated");
|
|
||||||
await capture(page, testInfo, "02-mobile-sheet-unscoped.png");
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("search narrows across the fixture corpus", async () => {
|
|
||||||
await flow.search("invoice");
|
|
||||||
await capture(page, testInfo, "03-mobile-search-narrowed.png");
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("load more grows the result set and then disappears", async () => {
|
|
||||||
await flow.resetSearch();
|
|
||||||
await capture(page, testInfo, "04-mobile-load-more-visible.png");
|
|
||||||
await flow.loadMore();
|
|
||||||
await capture(page, testInfo, "05-mobile-load-more-complete.png");
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("one provider fails inline and Retry settles", async () => {
|
|
||||||
await flow.retryProvider(brokenProvider, "Broken ACP");
|
|
||||||
await capture(page, testInfo, "06-mobile-provider-error-retry.png");
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("the selected row imports and opens the hydrated transcript", async () => {
|
|
||||||
await flow.importSession(scenario.importSessionId);
|
|
||||||
await flow.expectTranscript("Review the invoice migration", "The fixture transcript is ready.");
|
|
||||||
await capture(page, testInfo, "08-mobile-agent-after-import.png");
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("workspace actions start scoped and can widen to the host", async () => {
|
|
||||||
await flow.openFromWorkspaceHeader();
|
|
||||||
await capture(page, testInfo, "09-mobile-workspace-scoped.png");
|
|
||||||
await flow.showAll();
|
|
||||||
await flow.expectRows({
|
|
||||||
folders: [["fixture-unrelated", scenario.reuseTarget.projectDisplayName]],
|
|
||||||
});
|
|
||||||
await capture(page, testInfo, "10-mobile-workspace-show-all.png");
|
|
||||||
|
|
||||||
await flow.importSession("fixture-root-03");
|
|
||||||
await flow.expectImportedIntoWorkspace(
|
|
||||||
scenario.reuseTarget.workspaceId,
|
|
||||||
"Review fixture item 3",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("captures the desktop import sheet and command-center entry", async ({ page }, testInfo) => {
|
|
||||||
const flow = new ImportSessionFlow(page);
|
|
||||||
await flow.openWorkspace(scenario.project.workspaceId, {
|
|
||||||
width: 1280,
|
|
||||||
height: 800,
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("desktop shows the flat host-wide sheet", async () => {
|
|
||||||
await flow.openGlobally();
|
|
||||||
// The compact test may already have imported the newest fixture row, so this
|
|
||||||
// asserts recency as an ordering between two rows nothing imports.
|
|
||||||
await flow.expectRows({
|
|
||||||
before: ["fixture-worktree", "fixture-unrelated"],
|
|
||||||
folders: [["fixture-worktree", `${scenario.projectName} · worktrees/review-fix`]],
|
|
||||||
});
|
|
||||||
await flow.revealSession("fixture-unrelated");
|
|
||||||
await capture(page, testInfo, "11-desktop-sheet-unscoped.png");
|
|
||||||
await flow.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("import matches the command but not Home", async () => {
|
|
||||||
await flow.expectCommandCenterMatch();
|
|
||||||
await capture(page, testInfo, "12-desktop-command-center-import.png");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
|
||||||
const outputPath = testInfo.outputPath(name);
|
|
||||||
await page.screenshot({ path: outputPath, fullPage: true });
|
|
||||||
await copyFile(outputPath, path.join(SCREENSHOT_DIRECTORY, name));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedClaudeSessions(input: {
|
|
||||||
projectRoot: string;
|
|
||||||
worktreeDirectory: string;
|
|
||||||
unrelatedDirectory: string;
|
|
||||||
importSessionId: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
const sessions = [
|
|
||||||
{
|
|
||||||
cwd: input.projectRoot,
|
|
||||||
id: input.importSessionId,
|
|
||||||
title: "Invoice migration plan",
|
|
||||||
prompt: "Review the invoice migration",
|
|
||||||
answer: "The fixture transcript is ready.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
cwd: input.worktreeDirectory,
|
|
||||||
id: "fixture-worktree",
|
|
||||||
title: "Review worktree fix",
|
|
||||||
prompt: "Check the worktree import flow",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
cwd: input.unrelatedDirectory,
|
|
||||||
id: "fixture-unrelated",
|
|
||||||
title: "Unrelated directory notes",
|
|
||||||
prompt: "Review the unrelated directory",
|
|
||||||
},
|
|
||||||
...Array.from({ length: 20 }, (_, index) => ({
|
|
||||||
cwd: [input.projectRoot, input.worktreeDirectory, input.unrelatedDirectory][index % 3]!,
|
|
||||||
id: `fixture-root-${String(index + 1).padStart(2, "0")}`,
|
|
||||||
title: `Root session ${String(index + 1).padStart(2, "0")}`,
|
|
||||||
prompt: index === 7 ? "Investigate invoice rendering" : `Review fixture item ${index + 1}`,
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
const newest = Date.now() - 60_000;
|
|
||||||
for (const [index, session] of sessions.entries()) {
|
|
||||||
const projectDirectory = path.join(
|
|
||||||
claudeConfigDirectory,
|
|
||||||
"projects",
|
|
||||||
session.cwd.replace(/[^a-zA-Z0-9]/g, "-"),
|
|
||||||
);
|
|
||||||
await mkdir(projectDirectory, { recursive: true });
|
|
||||||
const sessionPath = path.join(projectDirectory, `${session.id}.jsonl`);
|
|
||||||
const records = [
|
|
||||||
{
|
|
||||||
type: "user",
|
|
||||||
uuid: `${session.id}-user`,
|
|
||||||
message: { role: "user", content: session.prompt },
|
|
||||||
cwd: session.cwd,
|
|
||||||
sessionId: session.id,
|
|
||||||
},
|
|
||||||
...(session.answer
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
type: "assistant",
|
|
||||||
uuid: `${session.id}-assistant`,
|
|
||||||
message: {
|
|
||||||
role: "assistant",
|
|
||||||
content: [{ type: "text", text: session.answer }],
|
|
||||||
},
|
|
||||||
cwd: session.cwd,
|
|
||||||
sessionId: session.id,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
{ type: "custom-title", customTitle: session.title, sessionId: session.id },
|
|
||||||
];
|
|
||||||
await writeFile(sessionPath, `${records.map((record) => JSON.stringify(record)).join("\n")}\n`);
|
|
||||||
const timestamp = new Date(newest - index * 60_000);
|
|
||||||
await utimes(sessionPath, timestamp, timestamp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { test } from "../support/fixtures";
|
|
||||||
import {
|
|
||||||
closeDiagramFullscreen,
|
|
||||||
closeDiagramFullscreenFromOutside,
|
|
||||||
expectCompletedDiagram,
|
|
||||||
expectFullscreenDiagram,
|
|
||||||
expectFullscreenDiagramClosed,
|
|
||||||
openDiagramFullscreen,
|
|
||||||
requestDiagram,
|
|
||||||
waitForDiagramTurnToComplete,
|
|
||||||
zoomAndPanFullscreenDiagram,
|
|
||||||
} from "../support/helpers/diagram";
|
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
|
||||||
|
|
||||||
const MERMAID_DIAGRAM = [
|
|
||||||
"```mermaid",
|
|
||||||
"flowchart LR",
|
|
||||||
" Start --> Middle",
|
|
||||||
" Middle --> Review",
|
|
||||||
" Review --> Ship",
|
|
||||||
"```",
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
test("opens a Mermaid diagram fullscreen and closes it again", async ({ page }) => {
|
|
||||||
test.setTimeout(120_000);
|
|
||||||
|
|
||||||
const agent = await seedMockAgentWorkspace({
|
|
||||||
repoPrefix: "mermaid-fullscreen-",
|
|
||||||
title: "Fullscreen Mermaid",
|
|
||||||
featureValues: {
|
|
||||||
mockStreamingAssistantResponse: MERMAID_DIAGRAM,
|
|
||||||
mockStreamingAssistantIntervalMs: 10,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await test.step("Open the conversation and wait for the rendered diagram", async () => {
|
|
||||||
await openAgentRoute(page, agent);
|
|
||||||
await requestDiagram(agent);
|
|
||||||
await waitForDiagramTurnToComplete(agent);
|
|
||||||
await expectCompletedDiagram(page, ["Start", "Ship"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("The toolbar opens the diagram fullscreen", async () => {
|
|
||||||
await openDiagramFullscreen(page);
|
|
||||||
await expectFullscreenDiagram(page, ["Start", "Review", "Ship"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("The fullscreen diagram zooms with the wheel and pans by dragging", async () => {
|
|
||||||
await zoomAndPanFullscreenDiagram(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("The close action returns to the conversation", async () => {
|
|
||||||
await closeDiagramFullscreen(page);
|
|
||||||
await expectFullscreenDiagramClosed(page);
|
|
||||||
await expectCompletedDiagram(page, ["Start", "Ship"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("Escape closes the fullscreen diagram", async () => {
|
|
||||||
await openDiagramFullscreen(page);
|
|
||||||
await expectFullscreenDiagram(page, ["Start", "Ship"]);
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
await expectFullscreenDiagramClosed(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
await test.step("Clicking outside the diagram closes the fullscreen viewer", async () => {
|
|
||||||
await openDiagramFullscreen(page);
|
|
||||||
await expectFullscreenDiagram(page, ["Start", "Ship"]);
|
|
||||||
await closeDiagramFullscreenFromOutside(page);
|
|
||||||
await expectFullscreenDiagramClosed(page);
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
await agent.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -119,7 +119,7 @@ test("plugin modal adapts its presentation and preserves host contexts", async (
|
|||||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||||
const previousConfig = await client.getDaemonConfig();
|
const previousConfig = await client.getDaemonConfig();
|
||||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||||
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
|
await writeFile(path.join(directory, "index.tsx"), PLUGIN_SOURCE);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
|
|||||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||||
const previous = await client.getDaemonConfig();
|
const previous = await client.getDaemonConfig();
|
||||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "e2e-plugin" }));
|
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "e2e-plugin" }));
|
||||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v1"));
|
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v1"));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const catalog = observePluginCatalog(page);
|
const catalog = observePluginCatalog(page);
|
||||||
@@ -143,14 +143,14 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
|
|||||||
await reloadPlugin(page);
|
await reloadPlugin(page);
|
||||||
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
|
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
|
||||||
|
|
||||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v2"));
|
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v2"));
|
||||||
await openPluginSettings(page);
|
await openPluginSettings(page);
|
||||||
await reloadPlugin(page);
|
await reloadPlugin(page);
|
||||||
await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2");
|
await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2");
|
||||||
await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible();
|
await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible();
|
||||||
|
|
||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(directory, "index.client.tsx"),
|
path.join(directory, "index.tsx"),
|
||||||
pluginSource("Broken surface", "render exploded"),
|
pluginSource("Broken surface", "render exploded"),
|
||||||
);
|
);
|
||||||
await openPluginSettings(page);
|
await openPluginSettings(page);
|
||||||
@@ -159,13 +159,13 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
|
|||||||
await openContribution(page, "Healthy surface", "Healthy contribution");
|
await openContribution(page, "Healthy surface", "Healthy contribution");
|
||||||
await openContribution(page, "Broken surface", "Plugin failed: render exploded");
|
await openContribution(page, "Broken surface", "Plugin failed: render exploded");
|
||||||
|
|
||||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Recovered surface"));
|
await writeFile(path.join(directory, "index.tsx"), pluginSource("Recovered surface"));
|
||||||
await reloadActiveContribution(page, client, "Recovered surface cleanup 4");
|
await reloadActiveContribution(page, client, "Recovered surface cleanup 4");
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("button", { name: "Broken surface", exact: true }),
|
page.getByRole("button", { name: "Broken surface", exact: true }),
|
||||||
).not.toBeVisible();
|
).not.toBeVisible();
|
||||||
|
|
||||||
await writeFile(path.join(directory, "index.client.tsx"), "export default broken syntax !!!");
|
await writeFile(path.join(directory, "index.tsx"), "export default broken syntax !!!");
|
||||||
await openPluginSettings(page);
|
await openPluginSettings(page);
|
||||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||||
await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed");
|
await expect(page.getByTestId("plugin-management-feedback")).toContainText("Request failed");
|
||||||
@@ -173,7 +173,7 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
|
|||||||
await leavePluginSettings(page);
|
await leavePluginSettings(page);
|
||||||
await expectContributionRemoved(page, "Recovered surface");
|
await expectContributionRemoved(page, "Recovered surface");
|
||||||
|
|
||||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v3"));
|
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v3"));
|
||||||
await openPluginSettings(page);
|
await openPluginSettings(page);
|
||||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||||
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
await expect(page.getByLabel("e2e-plugin running")).toBeVisible();
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ test("applies a contributed theme and falls back when its plugin is gone", async
|
|||||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||||
const previousConfig = await client.getDaemonConfig();
|
const previousConfig = await client.getDaemonConfig();
|
||||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||||
await writeFile(path.join(directory, "index.client.ts"), PLUGIN_SOURCE);
|
await writeFile(path.join(directory, "index.ts"), PLUGIN_SOURCE);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { TestInfo } from "@playwright/test";
|
import type { TestInfo } from "@playwright/test";
|
||||||
@@ -26,11 +26,18 @@ function isSettledWorkspaceUrl(url: URL): boolean {
|
|||||||
return url.pathname.includes("/workspace/") && !url.searchParams.has("open");
|
return url.pathname.includes("/workspace/") && !url.searchParams.has("open");
|
||||||
}
|
}
|
||||||
|
|
||||||
function pluginClientSource(input: { workspaceId: string; agentId: string }): string {
|
function pluginSource(input: { workspaceId: string; agentId: string }): string {
|
||||||
return `import React, { useRef } from "react";
|
return `import React, { useRef } from "react";
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from "react-native";
|
||||||
import { Icon, useAgent, useWorkspace } from "@getpaseo/plugin";
|
import { Icon, useAgent, useWorkspace } from "@getpaseo/plugin";
|
||||||
import { recordComposerOpen } from "./shared/rpc";
|
import { defineRpc } from "@getpaseo/plugin/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const recordComposerOpen = defineRpc({
|
||||||
|
name: "composer.open",
|
||||||
|
input: z.object({ workspaceId: z.string() }),
|
||||||
|
output: z.object({ opened: z.boolean() }),
|
||||||
|
});
|
||||||
|
|
||||||
function WorkspacePanel({ workspaceId, host, layout }) {
|
function WorkspacePanel({ workspaceId, host, layout }) {
|
||||||
const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id }));
|
const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id }));
|
||||||
@@ -104,49 +111,23 @@ function contributeClient(client) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function contribute(client) {
|
export default function contribute(plugin) {
|
||||||
client.addSurface("collision", DirectCollisionSurface);
|
plugin.handle(recordComposerOpen, async ({ workspaceId }, { paseo }) => {
|
||||||
client.addSurface("sidebar-destination", SidebarCollisionSurface);
|
|
||||||
client.addSidebarItem({ id: "collision", title: "Collision sidebar", icon: "Blocks", surface: "sidebar-destination" });
|
|
||||||
client.addWorkspacePanel({ id: "workspace", title: "Workspace inspector", icon: "PanelsTopLeft", context: "workspace", Component: WorkspacePanel });
|
|
||||||
client.addWorkspacePanel({ id: "agent", title: "Agent inspector", icon: "PanelTop", context: "agent", Component: AgentPanel });
|
|
||||||
client.addCommandCenterItem({ id: "global", title: "Plugin global action", icon: "Blocks", context: "global", onSelect() {} });
|
|
||||||
client.addCommandCenterItem({ id: "surface", title: "Open direct collision surface", icon: "Blocks", context: "workspace", onSelect({ openSurface }) { openSurface("collision"); } });
|
|
||||||
client.addCommandCenterItem({ id: "workspace", title: "Open plugin workspace", icon: "PanelsTopLeft", context: "workspace", onSelect({ openPanel }) { openPanel("workspace"); } });
|
|
||||||
client.addCommandCenterItem({ id: "agent", title: "Open plugin agent", icon: "PanelTop", context: "agent", onSelect({ openPanel }) { openPanel("agent"); } });
|
|
||||||
return contributeClient(client);
|
|
||||||
}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pluginSharedSource = `import { defineRpc } from "@getpaseo/plugin";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export const recordComposerOpen = defineRpc({
|
|
||||||
name: "composer.open",
|
|
||||||
input: z.object({ workspaceId: z.string() }),
|
|
||||||
output: z.object({ opened: z.boolean() }),
|
|
||||||
});`;
|
|
||||||
|
|
||||||
const pluginServerSource = `import { recordComposerOpen } from "./shared/rpc";
|
|
||||||
|
|
||||||
export default function contribute(server) {
|
|
||||||
server.handle(recordComposerOpen, async ({ workspaceId }, { paseo }) => {
|
|
||||||
await paseo.workspaces.ref(workspaceId).setTitle("Opened from composer pill");
|
await paseo.workspaces.ref(workspaceId).setTitle("Opened from composer pill");
|
||||||
return { opened: true };
|
return { opened: true };
|
||||||
});
|
});
|
||||||
|
plugin.addSurface("collision", DirectCollisionSurface);
|
||||||
|
plugin.addSurface("sidebar-destination", SidebarCollisionSurface);
|
||||||
|
plugin.addSidebarItem({ id: "collision", title: "Collision sidebar", icon: "Blocks", surface: "sidebar-destination" });
|
||||||
|
plugin.addWorkspacePanel({ id: "workspace", title: "Workspace inspector", icon: "PanelsTopLeft", context: "workspace", Component: WorkspacePanel });
|
||||||
|
plugin.addWorkspacePanel({ id: "agent", title: "Agent inspector", icon: "PanelTop", context: "agent", Component: AgentPanel });
|
||||||
|
plugin.addCommandCenterItem({ id: "global", title: "Plugin global action", icon: "Blocks", context: "global", onSelect() {} });
|
||||||
|
plugin.addCommandCenterItem({ id: "surface", title: "Open direct collision surface", icon: "Blocks", context: "workspace", onSelect({ openSurface }) { openSurface("collision"); } });
|
||||||
|
plugin.addCommandCenterItem({ id: "workspace", title: "Open plugin workspace", icon: "PanelsTopLeft", context: "workspace", onSelect({ openPanel }) { openPanel("workspace"); } });
|
||||||
|
plugin.addCommandCenterItem({ id: "agent", title: "Open plugin agent", icon: "PanelTop", context: "agent", onSelect({ openPanel }) { openPanel("agent"); } });
|
||||||
|
plugin.addClientSide(contributeClient);
|
||||||
return () => {};
|
return () => {};
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
async function writePluginSources(
|
|
||||||
directory: string,
|
|
||||||
input: { workspaceId: string; agentId: string },
|
|
||||||
): Promise<void> {
|
|
||||||
await mkdir(path.join(directory, "shared"), { recursive: true });
|
|
||||||
await Promise.all([
|
|
||||||
writeFile(path.join(directory, "index.client.tsx"), pluginClientSource(input)),
|
|
||||||
writeFile(path.join(directory, "index.server.ts"), pluginServerSource),
|
|
||||||
writeFile(path.join(directory, "shared", "rpc.ts"), pluginSharedSource),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function searchCommands(page: Page, query: string) {
|
async function searchCommands(page: Page, query: string) {
|
||||||
@@ -200,10 +181,10 @@ test.describe("plugin workspace panels and Command Center", () => {
|
|||||||
port: secondaryDaemon.port,
|
port: secondaryDaemon.port,
|
||||||
});
|
});
|
||||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||||
await writePluginSources(directory, {
|
await writeFile(
|
||||||
workspaceId: primary.workspaceId,
|
path.join(directory, "index.tsx"),
|
||||||
agentId: "missing-agent",
|
pluginSource({ workspaceId: primary.workspaceId, agentId: "missing-agent" }),
|
||||||
});
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await primaryClient.patchDaemonConfig({ pluginsEnabled: true });
|
await primaryClient.patchDaemonConfig({ pluginsEnabled: true });
|
||||||
@@ -279,10 +260,10 @@ test.describe("plugin workspace panels and Command Center", () => {
|
|||||||
modeId: "load-test",
|
modeId: "load-test",
|
||||||
});
|
});
|
||||||
const navigationAgentId = agent.id;
|
const navigationAgentId = agent.id;
|
||||||
await writePluginSources(directory, {
|
await writeFile(
|
||||||
workspaceId: primary.workspaceId,
|
path.join(directory, "index.tsx"),
|
||||||
agentId: navigationAgentId,
|
pluginSource({ workspaceId: primary.workspaceId, agentId: navigationAgentId }),
|
||||||
});
|
);
|
||||||
await primaryClient.reloadPlugin(PLUGIN_ID);
|
await primaryClient.reloadPlugin(PLUGIN_ID);
|
||||||
|
|
||||||
await runCommand(page, "Open direct collision surface");
|
await runCommand(page, "Open direct collision surface");
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
expectSetupPanel,
|
expectSetupPanel,
|
||||||
openHomeWithProject,
|
openHomeWithProject,
|
||||||
navigateToWorkspaceViaSidebar,
|
navigateToWorkspaceViaSidebar,
|
||||||
leaveWorkspaceViaHistory,
|
returnHomeFromWorkspace,
|
||||||
openWorkspaceScriptsMenu,
|
openWorkspaceScriptsMenu,
|
||||||
startWorkspaceScriptFromMenu,
|
startWorkspaceScriptFromMenu,
|
||||||
closeWorkspaceScriptsMenu,
|
closeWorkspaceScriptsMenu,
|
||||||
@@ -195,7 +195,7 @@ test.describe("Workspace setup streaming", () => {
|
|||||||
await expectFailedSetupTabSeededInMainPane(page, workspace.id);
|
await expectFailedSetupTabSeededInMainPane(page, workspace.id);
|
||||||
|
|
||||||
await closeSetupTab(page, workspace.id);
|
await closeSetupTab(page, workspace.id);
|
||||||
await leaveWorkspaceViaHistory(page);
|
await returnHomeFromWorkspace(page);
|
||||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||||
await expectSetupTabNotSeeded(page, workspace.id);
|
await expectSetupTabNotSeeded(page, workspace.id);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ const metroTest = base.extend({
|
|||||||
const daemonTest = metroTest.extend<
|
const daemonTest = metroTest.extend<
|
||||||
{ projectOwnership: void },
|
{ projectOwnership: void },
|
||||||
{
|
{
|
||||||
e2eDaemonConfig: Record<string, unknown> | undefined;
|
|
||||||
e2eDaemonEnvironment: Record<string, string>;
|
|
||||||
e2eForkProviders: string[];
|
e2eForkProviders: string[];
|
||||||
e2eInjectPaseoTools: boolean;
|
e2eInjectPaseoTools: boolean;
|
||||||
e2eWorker: void;
|
e2eWorker: void;
|
||||||
@@ -45,17 +43,9 @@ const daemonTest = metroTest.extend<
|
|||||||
>({
|
>({
|
||||||
e2eForkProviders: [[], { scope: "worker", option: true }],
|
e2eForkProviders: [[], { scope: "worker", option: true }],
|
||||||
e2eInjectPaseoTools: [false, { scope: "worker", option: true }],
|
e2eInjectPaseoTools: [false, { scope: "worker", option: true }],
|
||||||
e2eDaemonConfig: [undefined, { scope: "worker", option: true }],
|
|
||||||
e2eDaemonEnvironment: [{}, { scope: "worker", option: true }],
|
|
||||||
e2eWorker: [
|
e2eWorker: [
|
||||||
async (
|
async ({ e2eForkProviders, e2eInjectPaseoTools }, provide, workerInfo) => {
|
||||||
{ e2eDaemonConfig, e2eDaemonEnvironment, e2eForkProviders, e2eInjectPaseoTools },
|
|
||||||
provide,
|
|
||||||
workerInfo,
|
|
||||||
) => {
|
|
||||||
const worker = await startE2EWorker(workerInfo.workerIndex, {
|
const worker = await startE2EWorker(workerInfo.workerIndex, {
|
||||||
daemonConfig: e2eDaemonConfig,
|
|
||||||
environment: e2eDaemonEnvironment,
|
|
||||||
forkProviders: e2eForkProviders,
|
forkProviders: e2eForkProviders,
|
||||||
injectPaseoTools: e2eInjectPaseoTools,
|
injectPaseoTools: e2eInjectPaseoTools,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ export async function expectCommandCenterToRemainAtTheTop(page: Page): Promise<v
|
|||||||
export async function expectPrimaryCommandCenterActions(panel: Locator): Promise<void> {
|
export async function expectPrimaryCommandCenterActions(panel: Locator): Promise<void> {
|
||||||
const expectedActions = [
|
const expectedActions = [
|
||||||
{ title: "New workspace", shortcut: "⌘N" },
|
{ title: "New workspace", shortcut: "⌘N" },
|
||||||
{ title: "Import session" },
|
|
||||||
{ title: "History" },
|
{ title: "History" },
|
||||||
{ title: "Schedules" },
|
{ title: "Schedules" },
|
||||||
{ title: "Settings", shortcut: "⌘," },
|
{ title: "Settings", shortcut: "⌘," },
|
||||||
@@ -106,9 +105,6 @@ export async function expectPrimaryCommandCenterActions(panel: Locator): Promise
|
|||||||
await input.fill("home");
|
await input.fill("home");
|
||||||
await expect(panel.getByRole("button").filter({ hasText: "Home" })).toBeVisible();
|
await expect(panel.getByRole("button").filter({ hasText: "Home" })).toBeVisible();
|
||||||
await expect(panel.getByRole("button").filter({ hasText: "Add project" })).toHaveCount(0);
|
await expect(panel.getByRole("button").filter({ hasText: "Add project" })).toHaveCount(0);
|
||||||
await input.fill("import");
|
|
||||||
await expect(panel.getByRole("button").filter({ hasText: "Import session" })).toBeVisible();
|
|
||||||
await expect(panel.getByRole("button").filter({ hasText: "Home" })).toHaveCount(0);
|
|
||||||
await input.fill("");
|
await input.fill("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,21 +30,9 @@ export async function expectDiagramRemainsRenderedWhileStreaming(
|
|||||||
completion: Promise<void>,
|
completion: Promise<void>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const diagram = page.getByRole("img", { name: DIAGRAM_NAME }).last();
|
const diagram = page.getByRole("img", { name: DIAGRAM_NAME }).last();
|
||||||
let turnCompleted = false;
|
const completed = completion.then(() => true);
|
||||||
const completed = completion.then(() => {
|
|
||||||
turnCompleted = true;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
expect(await diagram.isVisible()).toBe(true);
|
expect(await diagram.isVisible()).toBe(true);
|
||||||
if (turnCompleted) return completion;
|
|
||||||
const box = await diagram.boundingBox();
|
|
||||||
if (turnCompleted) return completion;
|
|
||||||
expect(box, "the inline diagram should have measurable layout").not.toBeNull();
|
|
||||||
expect(
|
|
||||||
box?.height,
|
|
||||||
"the inline diagram should remain tall enough to read",
|
|
||||||
).toBeGreaterThanOrEqual(56);
|
|
||||||
const didComplete = await Promise.race([completed, page.waitForTimeout(16).then(() => false)]);
|
const didComplete = await Promise.race([completed, page.waitForTimeout(16).then(() => false)]);
|
||||||
if (didComplete) return completion;
|
if (didComplete) return completion;
|
||||||
}
|
}
|
||||||
@@ -61,90 +49,3 @@ export async function expectCompletedDiagram(page: Page, labels: readonly string
|
|||||||
export async function reloadConversation(page: Page): Promise<void> {
|
export async function reloadConversation(page: Page): Promise<void> {
|
||||||
await page.reload({ waitUntil: "domcontentloaded" });
|
await page.reload({ waitUntil: "domcontentloaded" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const FULLSCREEN_VIEWPORT = "mermaid-fullscreen-viewport";
|
|
||||||
|
|
||||||
function diagramSvg(page: Page, viewportTestId: string) {
|
|
||||||
return page
|
|
||||||
.getByTestId(`${viewportTestId}-canvas`)
|
|
||||||
.last()
|
|
||||||
.locator("iframe")
|
|
||||||
.contentFrame()
|
|
||||||
.locator("#diagram svg");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The viewport toolbar only reveals itself on hover, so the diagram is hovered first. The
|
|
||||||
* pointer is parked elsewhere beforehand so the hover transition fires even when a previous
|
|
||||||
* step left the cursor inside the viewport.
|
|
||||||
*/
|
|
||||||
export async function openDiagramFullscreen(page: Page): Promise<void> {
|
|
||||||
await page.mouse.move(0, 0);
|
|
||||||
await page.getByTestId("mermaid-viewport-canvas").last().hover();
|
|
||||||
await page.getByTestId("mermaid-fullscreen").last().click();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function closeDiagramFullscreen(page: Page): Promise<void> {
|
|
||||||
await page.getByTestId(`${FULLSCREEN_VIEWPORT}-canvas`).hover();
|
|
||||||
await page.getByTestId("mermaid-fullscreen-close").click();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function zoomAndPanFullscreenDiagram(page: Page): Promise<void> {
|
|
||||||
const canvas = page.getByTestId(`${FULLSCREEN_VIEWPORT}-canvas`);
|
|
||||||
const svg = diagramSvg(page, FULLSCREEN_VIEWPORT);
|
|
||||||
const initial = await svg.boundingBox();
|
|
||||||
expect(initial, "the fullscreen diagram should have measurable layout").not.toBeNull();
|
|
||||||
|
|
||||||
await page.mouse.move(initial!.x + initial!.width / 2, initial!.y + initial!.height / 2);
|
|
||||||
await page.mouse.wheel(0, -100);
|
|
||||||
|
|
||||||
const zoomed = await svg.boundingBox();
|
|
||||||
expect(zoomed, "the zoomed diagram should have measurable layout").not.toBeNull();
|
|
||||||
expect(zoomed!.width).toBeGreaterThan(initial!.width * 1.1);
|
|
||||||
|
|
||||||
const viewport = await canvas.boundingBox();
|
|
||||||
expect(viewport, "the fullscreen viewport should have measurable layout").not.toBeNull();
|
|
||||||
await page.mouse.move(viewport!.x + viewport!.width / 2, viewport!.y + viewport!.height / 2);
|
|
||||||
await page.mouse.down();
|
|
||||||
await page.mouse.move(
|
|
||||||
viewport!.x + viewport!.width / 2 - 120,
|
|
||||||
viewport!.y + viewport!.height / 2,
|
|
||||||
);
|
|
||||||
await page.mouse.up();
|
|
||||||
|
|
||||||
const panned = await svg.boundingBox();
|
|
||||||
expect(panned, "the panned diagram should have measurable layout").not.toBeNull();
|
|
||||||
expect(Math.abs(panned!.x - zoomed!.x)).toBeGreaterThan(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function closeDiagramFullscreenFromOutside(page: Page): Promise<void> {
|
|
||||||
const canvas = page.getByTestId(`${FULLSCREEN_VIEWPORT}-canvas`);
|
|
||||||
const viewport = await canvas.boundingBox();
|
|
||||||
const svg = await diagramSvg(page, FULLSCREEN_VIEWPORT).boundingBox();
|
|
||||||
expect(viewport, "the fullscreen viewport should have measurable layout").not.toBeNull();
|
|
||||||
expect(svg, "the fullscreen diagram should have measurable layout").not.toBeNull();
|
|
||||||
|
|
||||||
const x = viewport!.x + viewport!.width / 2;
|
|
||||||
const y =
|
|
||||||
svg!.y + svg!.height < viewport!.y + viewport!.height - 8
|
|
||||||
? viewport!.y + viewport!.height - 4
|
|
||||||
: viewport!.y + 4;
|
|
||||||
await page.mouse.click(x, y);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectFullscreenDiagram(
|
|
||||||
page: Page,
|
|
||||||
labels: readonly string[],
|
|
||||||
): Promise<void> {
|
|
||||||
const viewport = page.getByTestId(FULLSCREEN_VIEWPORT);
|
|
||||||
await expect(viewport).toBeVisible({ timeout: 30_000 });
|
|
||||||
const svg = diagramSvg(page, FULLSCREEN_VIEWPORT);
|
|
||||||
await expect(svg).toBeVisible({ timeout: 30_000 });
|
|
||||||
for (const label of labels) {
|
|
||||||
await expect(svg).toContainText(label);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectFullscreenDiagramClosed(page: Page): Promise<void> {
|
|
||||||
await expect(page.getByTestId(FULLSCREEN_VIEWPORT)).toHaveCount(0);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,13 +10,6 @@ export interface E2EWorker {
|
|||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface E2EWorkerOptions {
|
|
||||||
forkProviders?: string[];
|
|
||||||
injectPaseoTools?: boolean;
|
|
||||||
daemonConfig?: Record<string, unknown>;
|
|
||||||
environment?: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveOptionalHome(value: string | undefined): string | null {
|
function resolveOptionalHome(value: string | undefined): string | null {
|
||||||
const trimmed = value?.trim();
|
const trimmed = value?.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
@@ -84,33 +77,31 @@ if (origin === fixtureRemote) {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
if (command === "pr list" || command === "pr view") {
|
if (command === "pr list" || command === "pr view") {
|
||||||
const isFork = args.includes("2");
|
|
||||||
const pr = {
|
const pr = {
|
||||||
number: isFork ? 2 : 1,
|
number: 1,
|
||||||
title: "Use pasted PR as start ref",
|
title: "Use pasted PR as start ref",
|
||||||
url: "https://github.com/paseo-e2e/local-fixture/pull/" + (isFork ? 2 : 1),
|
url: "https://github.com/paseo-e2e/local-fixture/pull/1",
|
||||||
state: "OPEN",
|
state: "OPEN",
|
||||||
body: null,
|
body: null,
|
||||||
labels: [],
|
labels: [],
|
||||||
baseRefName: "main",
|
baseRefName: "main",
|
||||||
headRefName: isFork ? "pr-branch-2" : "pr-branch-1",
|
headRefName: "pr-branch-1",
|
||||||
updatedAt: "2026-01-01T00:00:00Z"
|
updatedAt: "2026-01-01T00:00:00Z"
|
||||||
};
|
};
|
||||||
process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr));
|
process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr));
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
if (command === "api graphql" && args.some((arg) => arg.includes("PullRequestCheckoutTarget"))) {
|
if (command === "api graphql" && args.some((arg) => arg.includes("PullRequestCheckoutTarget"))) {
|
||||||
const isFork = args.some((arg) => arg === "number=2");
|
|
||||||
process.stdout.write(JSON.stringify({
|
process.stdout.write(JSON.stringify({
|
||||||
data: { repository: { pullRequest: {
|
data: { repository: { pullRequest: {
|
||||||
number: isFork ? 2 : 1,
|
number: 1,
|
||||||
baseRefName: "main",
|
baseRefName: "main",
|
||||||
headRefName: isFork ? "pr-branch-2" : "pr-branch-1",
|
headRefName: "pr-branch-1",
|
||||||
isCrossRepository: isFork,
|
isCrossRepository: false,
|
||||||
headRepositoryOwner: { login: isFork ? "fork-owner" : "paseo-e2e" },
|
headRepositoryOwner: { login: "paseo-e2e" },
|
||||||
headRepository: {
|
headRepository: {
|
||||||
sshUrl: isFork ? "git@github.com:fork-owner/local-fixture.git" : "git@github.com:paseo-e2e/local-fixture.git",
|
sshUrl: "git@github.com:paseo-e2e/local-fixture.git",
|
||||||
url: isFork ? "https://github.com/fork-owner/local-fixture" : fixtureRemote
|
url: fixtureRemote
|
||||||
}
|
}
|
||||||
} } }
|
} } }
|
||||||
}));
|
}));
|
||||||
@@ -165,7 +156,7 @@ async function applyMetadataFork(targetHome: string, providerIds: string[]): Pro
|
|||||||
|
|
||||||
export async function startE2EWorker(
|
export async function startE2EWorker(
|
||||||
workerIndex: number,
|
workerIndex: number,
|
||||||
options: E2EWorkerOptions = {},
|
options: { forkProviders?: string[]; injectPaseoTools?: boolean } = {},
|
||||||
): Promise<E2EWorker> {
|
): Promise<E2EWorker> {
|
||||||
const requestedRoot = resolveOptionalHome(process.env.E2E_PASEO_HOME);
|
const requestedRoot = resolveOptionalHome(process.env.E2E_PASEO_HOME);
|
||||||
const paseoHome = requestedRoot
|
const paseoHome = requestedRoot
|
||||||
@@ -178,14 +169,6 @@ export async function startE2EWorker(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await applyMetadataFork(paseoHome, options.forkProviders ?? []);
|
await applyMetadataFork(paseoHome, options.forkProviders ?? []);
|
||||||
// Worker-scoped fixture config lets a spec exercise provider discovery without
|
|
||||||
// reading the developer's provider state or sharing configuration with other specs.
|
|
||||||
if (options.daemonConfig) {
|
|
||||||
await writeFile(
|
|
||||||
path.join(paseoHome, "config.json"),
|
|
||||||
`${JSON.stringify(options.daemonConfig, null, 2)}\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (options.injectPaseoTools) {
|
if (options.injectPaseoTools) {
|
||||||
await enablePaseoTools(paseoHome);
|
await enablePaseoTools(paseoHome);
|
||||||
}
|
}
|
||||||
@@ -196,7 +179,6 @@ export async function startE2EWorker(
|
|||||||
NODE_ENV: "development",
|
NODE_ENV: "development",
|
||||||
PATH: `${fakeEditorBin}${path.delimiter}${process.env.PATH ?? ""}`,
|
PATH: `${fakeEditorBin}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||||
PASEO_E2E_EDITOR_RECORD_PATH: editorRecordPath,
|
PASEO_E2E_EDITOR_RECORD_PATH: editorRecordPath,
|
||||||
...options.environment,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
|
||||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
|
||||||
import { gotoAppShell } from "./app";
|
|
||||||
import { openCommandCenter } from "./command-center";
|
|
||||||
import { getServerId } from "./server-id";
|
|
||||||
import { expectMobileAgentSidebarVisible, openMobileAgentSidebar } from "./sidebar";
|
|
||||||
|
|
||||||
const rowTestId = (id: string) => `import-session-session-claude-${id}`;
|
|
||||||
const rowFolderTestId = (id: string) => `import-session-row-folder-claude-${id}`;
|
|
||||||
|
|
||||||
export class ImportSessionFlow {
|
|
||||||
constructor(private readonly page: Page) {}
|
|
||||||
async openWorkspace(workspaceId: string, viewport: { width: number; height: number }) {
|
|
||||||
await this.page.setViewportSize(viewport);
|
|
||||||
await gotoAppShell(this.page);
|
|
||||||
await this.page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId));
|
|
||||||
await expect(this.page.getByRole("button", { name: "Workspace actions" })).toBeVisible({
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async revealMobileEntryPoint() {
|
|
||||||
await openMobileAgentSidebar(this.page);
|
|
||||||
await expectMobileAgentSidebarVisible(this.page);
|
|
||||||
const button = this.page.getByTestId("sidebar-import-session");
|
|
||||||
await expect(button).toHaveAccessibleName("Import session");
|
|
||||||
await button.hover();
|
|
||||||
await expect(this.page.getByText("Import session", { exact: true })).toBeVisible();
|
|
||||||
}
|
|
||||||
async openGlobally() {
|
|
||||||
await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible();
|
|
||||||
await this.page.getByTestId("sidebar-import-session").click();
|
|
||||||
await this.expectSheetReady();
|
|
||||||
}
|
|
||||||
async openFromWorkspaceHeader() {
|
|
||||||
await this.page.getByRole("button", { name: "Workspace actions" }).click();
|
|
||||||
await this.page.getByTestId("workspace-header-import-agent").click();
|
|
||||||
await this.expectScope("This workspace");
|
|
||||||
await expect(this.page.getByTestId("import-session-show-all")).toBeVisible();
|
|
||||||
await expect(this.page.getByText("Workspace actions", { exact: true })).not.toBeVisible();
|
|
||||||
}
|
|
||||||
async expectScope(text: string, exact = true) {
|
|
||||||
const scope = this.page.getByTestId("import-session-scope");
|
|
||||||
if (exact) await expect(scope).toHaveText(text);
|
|
||||||
else await expect(scope).toContainText(text);
|
|
||||||
await expect(scope).toBeVisible();
|
|
||||||
}
|
|
||||||
async expectRows(input: {
|
|
||||||
first?: string[];
|
|
||||||
before?: [string, string];
|
|
||||||
folders?: Array<[string, string]>;
|
|
||||||
}) {
|
|
||||||
const ids = await this.rowIds();
|
|
||||||
if (input.first) expect(ids.slice(0, input.first.length)).toEqual(input.first.map(rowTestId));
|
|
||||||
if (input.before) {
|
|
||||||
expect(ids.indexOf(rowTestId(input.before[0]))).toBeGreaterThanOrEqual(0);
|
|
||||||
expect(ids.indexOf(rowTestId(input.before[0]))).toBeLessThan(
|
|
||||||
ids.indexOf(rowTestId(input.before[1])),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const [id, folder] of input.folders ?? []) {
|
|
||||||
await expect(this.page.getByTestId(rowFolderTestId(id))).toHaveText(folder);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async expectProviderError(label: string) {
|
|
||||||
await expect(this.page.getByTestId("import-session-provider-errors")).toContainText(
|
|
||||||
`Could not load ${label} sessions`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
async expectProviderFilterFits(width: number) {
|
|
||||||
await expect(this.page.getByTestId("import-session-scope")).toBeInViewport();
|
|
||||||
const filter = this.page.getByRole("button", { name: "Filter: All" });
|
|
||||||
await expect(filter).toBeInViewport();
|
|
||||||
const bounds = await filter.boundingBox();
|
|
||||||
expect(bounds?.x ?? width).toBeGreaterThanOrEqual(0);
|
|
||||||
expect((bounds?.x ?? width) + (bounds?.width ?? 1)).toBeLessThanOrEqual(width);
|
|
||||||
}
|
|
||||||
async revealSession(id: string) {
|
|
||||||
await this.page.getByTestId(rowTestId(id)).scrollIntoViewIfNeeded();
|
|
||||||
}
|
|
||||||
async search(query: string) {
|
|
||||||
await this.page.getByTestId("import-session-search").fill(query);
|
|
||||||
await expect(this.page.getByText("Invoice migration plan", { exact: true })).toBeVisible();
|
|
||||||
await expect(this.page.getByText("Root session 01", { exact: true })).toHaveCount(0);
|
|
||||||
await expect(this.page.getByTestId("import-session-load-more")).toHaveCount(0);
|
|
||||||
}
|
|
||||||
async resetSearch() {
|
|
||||||
await this.page.getByTestId("import-session-search").fill("");
|
|
||||||
await expect(this.page.getByTestId("import-session-load-more")).toBeVisible();
|
|
||||||
}
|
|
||||||
async loadMore() {
|
|
||||||
await this.page.getByTestId("import-session-load-more").click();
|
|
||||||
await expect(this.page.getByText("Root session 20", { exact: true })).toBeVisible();
|
|
||||||
await expect(this.page.getByTestId("import-session-load-more")).toHaveCount(0);
|
|
||||||
}
|
|
||||||
async retryProvider(provider: string, label: string) {
|
|
||||||
await this.expectProviderError(label);
|
|
||||||
await this.page.getByTestId(`import-session-retry-${provider}`).click();
|
|
||||||
await expect(this.page.getByTestId(`import-session-retry-${provider}`)).toBeEnabled();
|
|
||||||
await this.expectProviderError(label);
|
|
||||||
await expect(this.page.getByRole("progressbar")).toHaveCount(0);
|
|
||||||
}
|
|
||||||
async importSession(id: string) {
|
|
||||||
await this.page.getByTestId(rowTestId(id)).click();
|
|
||||||
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0, {
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async expectTranscript(userText: string, assistantText: string) {
|
|
||||||
await expect(this.page.getByTestId("user-message")).toContainText(userText);
|
|
||||||
await expect(this.page.getByTestId("assistant-message")).toContainText(assistantText);
|
|
||||||
}
|
|
||||||
async showAll() {
|
|
||||||
await this.page.getByTestId("import-session-show-all").click();
|
|
||||||
await expect(this.page.getByTestId("import-session-scope")).toContainText("Sessions on");
|
|
||||||
}
|
|
||||||
async expectImportedIntoWorkspace(workspaceId: string, userText: string) {
|
|
||||||
await expect(this.page).toHaveURL(buildHostWorkspaceRoute(getServerId(), workspaceId), {
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
const workspace = this.page.getByTestId(`workspace-deck-entry-${getServerId()}:${workspaceId}`);
|
|
||||||
await expect(workspace.getByTestId("user-message")).toContainText(userText);
|
|
||||||
}
|
|
||||||
async close() {
|
|
||||||
await this.page.keyboard.press("Escape");
|
|
||||||
await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0);
|
|
||||||
}
|
|
||||||
async expectCommandCenterMatch() {
|
|
||||||
const panel = await openCommandCenter(this.page);
|
|
||||||
await panel.getByTestId("command-center-input").fill("import");
|
|
||||||
await expect(panel.getByText("Import session", { exact: true })).toBeVisible();
|
|
||||||
await expect(panel.getByText("Home", { exact: true })).toHaveCount(0);
|
|
||||||
}
|
|
||||||
private async expectSheetReady() {
|
|
||||||
const sheet = this.page.getByTestId("import-session-sheet");
|
|
||||||
await expect(sheet).toBeVisible({ timeout: 30_000 });
|
|
||||||
await expect(sheet.getByText("Loading sessions...", { exact: true })).toHaveCount(0, {
|
|
||||||
timeout: 30_000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
private async rowIds() {
|
|
||||||
return await this.page
|
|
||||||
.locator('[data-testid^="import-session-session-claude-"]')
|
|
||||||
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -136,13 +136,11 @@ async function expectVerticalOrder(
|
|||||||
const measured = await Promise.all(
|
const measured = await Promise.all(
|
||||||
keys.map(async (key) => ({ key, top: await rowTop(locate(key)) })),
|
keys.map(async (key) => ({ key, top: await rowTop(locate(key)) })),
|
||||||
);
|
);
|
||||||
if (
|
const laidOut = measured.filter(
|
||||||
!measured.every(
|
(entry): entry is { key: SidebarNavKey; top: number } => entry.top !== null,
|
||||||
(entry): entry is { key: SidebarNavKey; top: number } => entry.top !== null,
|
);
|
||||||
)
|
if (laidOut.length !== keys.length) return null;
|
||||||
)
|
return laidOut.sort((a, b) => a.top - b.top).map((entry) => entry.key);
|
||||||
return null;
|
|
||||||
return measured.sort((a, b) => a.top - b.top).map((entry) => entry.key);
|
|
||||||
},
|
},
|
||||||
{ message: `Expected ${subject} in order`, timeout: 15_000 },
|
{ message: `Expected ${subject} in order`, timeout: 15_000 },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ class StartupAssertions {
|
|||||||
await expect(hostRow).toBeVisible({ timeout: 15_000 });
|
await expect(hostRow).toBeVisible({ timeout: 15_000 });
|
||||||
await expect(hostRow).toContainText(input.label);
|
await expect(hostRow).toContainText(input.label);
|
||||||
await expect(this.page.getByTestId("sidebar-add-project")).toBeVisible();
|
await expect(this.page.getByTestId("sidebar-add-project")).toBeVisible();
|
||||||
await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible();
|
await expect(this.page.getByTestId("sidebar-home")).toBeVisible();
|
||||||
await expect(this.page.getByTestId("sidebar-settings")).toBeVisible();
|
await expect(this.page.getByTestId("sidebar-settings")).toBeVisible();
|
||||||
await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0);
|
await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0);
|
||||||
return this;
|
return this;
|
||||||
|
|||||||
@@ -276,9 +276,9 @@ export async function navigateToWorkspaceViaSidebar(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function leaveWorkspaceViaHistory(page: Page): Promise<void> {
|
export async function returnHomeFromWorkspace(page: Page): Promise<void> {
|
||||||
await page.getByRole("button", { name: "History", exact: true }).click();
|
await page.getByRole("button", { name: "Home" }).click();
|
||||||
await expect(page).toHaveURL(/\/sessions$/, { timeout: 30_000 });
|
await expect(page).not.toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openWorkspaceScriptsMenu(page: Page): Promise<void> {
|
export async function openWorkspaceScriptsMenu(page: Page): Promise<void> {
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ export const ACP_PROVIDER_ICON_SVGS = {
|
|||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">\n <path fill="currentColor" d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81"/>\n</svg>\n',
|
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">\n <path fill="currentColor" d="M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81"/>\n</svg>\n',
|
||||||
"github-copilot-cli":
|
"github-copilot-cli":
|
||||||
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">\n<path fill-rule="evenodd" clip-rule="evenodd" d="M7.99816 14.2779C12.0678 14.2779 14.9997 11.6274 14.9997 10.9574V9.3189C14.939 8.77306 14.4165 7.82349 13.6074 7.48636C13.5959 7.42498 13.5859 7.36153 13.5756 7.29542C13.5506 7.13548 13.5231 6.95977 13.4649 6.75954C13.6409 6.31508 13.6873 5.81187 13.6873 5.31135C13.6873 4.54947 13.5757 3.76321 13.0811 3.13712C12.5745 2.4959 11.7737 2.15372 10.6972 2.03411C9.64215 1.91688 8.71828 2.06409 8.12157 2.70342C8.07827 2.74981 8.0345 2.79812 7.99655 2.84746C7.95861 2.79812 7.91805 2.74981 7.87475 2.70342C7.27804 2.06409 6.35416 1.91688 5.29908 2.03411C4.22263 2.15372 3.42181 2.4959 2.91521 3.13712C2.42057 3.76321 2.30905 4.54947 2.30905 5.31135C2.30905 5.81187 2.35539 6.31508 2.53142 6.75954C2.47323 6.95977 2.44574 7.13542 2.42072 7.29536C2.41037 7.3615 2.40044 7.42496 2.38893 7.48636C1.57983 7.82349 1.05728 8.77306 0.996582 9.3189V10.9574C0.996582 11.6274 3.92849 14.2779 7.99816 14.2779ZM10.5523 3.33859C9.63863 3.23707 9.25001 3.41798 9.0811 3.59896C8.90273 3.79007 8.76738 4.19254 8.86971 5.0112C8.94909 5.64627 9.13423 6.08757 9.41018 6.36929C9.67188 6.63645 10.0966 6.8426 10.8435 6.8426C11.6506 6.8426 11.9631 6.66867 12.1051 6.51046C12.2619 6.33577 12.3748 6.00462 12.3748 5.31135C12.3748 4.64141 12.2675 4.22455 12.0513 3.95077C11.8469 3.69214 11.4446 3.43773 10.5523 3.33859ZM6.91639 3.59896C6.74748 3.41798 6.35886 3.23707 5.44518 3.33859C4.55289 3.43773 4.15058 3.69214 3.94624 3.95077C3.72994 4.22455 3.62271 4.64141 3.62271 5.31135C3.62271 6.00462 3.73563 6.33577 3.89239 6.51046C4.03436 6.66867 4.34693 6.8426 5.15396 6.8426C5.90092 6.8426 6.32561 6.63645 6.58731 6.36929C6.86326 6.08757 7.0484 5.64627 7.12778 5.0112C7.23011 4.19254 7.09476 3.79007 6.91639 3.59896ZM7.99818 12.9788C9.99307 12.9788 12.0088 12.0076 12.3748 11.7248V8.00133L12.3546 7.90026C11.9257 8.08369 11.4142 8.1551 10.8435 8.1551C9.84049 8.1551 9.04148 7.86851 8.47257 7.28773C8.28092 7.09208 8.12536 6.87308 7.99978 6.6378C7.8742 6.87308 7.71544 7.09208 7.52378 7.28773C6.95488 7.86851 6.15587 8.1551 5.15283 8.1551C4.58212 8.1551 4.07068 8.08369 3.6418 7.90026L3.62158 8.00133V11.7248C3.98755 12.0076 6.00329 12.9788 7.99818 12.9788Z" fill="currentColor"/>\n<path d="M6.46854 9.0301C6.83097 9.0301 7.12479 9.32391 7.12479 9.68635V10.9988C7.12479 11.3613 6.83097 11.6551 6.46854 11.6551C6.1061 11.6551 5.81229 11.3613 5.81229 10.9988V9.68635C5.81229 9.32391 6.1061 9.0301 6.46854 9.0301Z" fill="currentColor"/>\n<path d="M10.1873 9.68635C10.1873 9.32391 9.89347 9.0301 9.53104 9.0301C9.1686 9.0301 8.87479 9.32391 8.87479 9.68635V10.9988C8.87479 11.3613 9.1686 11.6551 9.53104 11.6551C9.89347 11.6551 10.1873 11.3613 10.1873 10.9988V9.68635Z" fill="currentColor"/>\n</svg>\n',
|
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">\n<path fill-rule="evenodd" clip-rule="evenodd" d="M7.99816 14.2779C12.0678 14.2779 14.9997 11.6274 14.9997 10.9574V9.3189C14.939 8.77306 14.4165 7.82349 13.6074 7.48636C13.5959 7.42498 13.5859 7.36153 13.5756 7.29542C13.5506 7.13548 13.5231 6.95977 13.4649 6.75954C13.6409 6.31508 13.6873 5.81187 13.6873 5.31135C13.6873 4.54947 13.5757 3.76321 13.0811 3.13712C12.5745 2.4959 11.7737 2.15372 10.6972 2.03411C9.64215 1.91688 8.71828 2.06409 8.12157 2.70342C8.07827 2.74981 8.0345 2.79812 7.99655 2.84746C7.95861 2.79812 7.91805 2.74981 7.87475 2.70342C7.27804 2.06409 6.35416 1.91688 5.29908 2.03411C4.22263 2.15372 3.42181 2.4959 2.91521 3.13712C2.42057 3.76321 2.30905 4.54947 2.30905 5.31135C2.30905 5.81187 2.35539 6.31508 2.53142 6.75954C2.47323 6.95977 2.44574 7.13542 2.42072 7.29536C2.41037 7.3615 2.40044 7.42496 2.38893 7.48636C1.57983 7.82349 1.05728 8.77306 0.996582 9.3189V10.9574C0.996582 11.6274 3.92849 14.2779 7.99816 14.2779ZM10.5523 3.33859C9.63863 3.23707 9.25001 3.41798 9.0811 3.59896C8.90273 3.79007 8.76738 4.19254 8.86971 5.0112C8.94909 5.64627 9.13423 6.08757 9.41018 6.36929C9.67188 6.63645 10.0966 6.8426 10.8435 6.8426C11.6506 6.8426 11.9631 6.66867 12.1051 6.51046C12.2619 6.33577 12.3748 6.00462 12.3748 5.31135C12.3748 4.64141 12.2675 4.22455 12.0513 3.95077C11.8469 3.69214 11.4446 3.43773 10.5523 3.33859ZM6.91639 3.59896C6.74748 3.41798 6.35886 3.23707 5.44518 3.33859C4.55289 3.43773 4.15058 3.69214 3.94624 3.95077C3.72994 4.22455 3.62271 4.64141 3.62271 5.31135C3.62271 6.00462 3.73563 6.33577 3.89239 6.51046C4.03436 6.66867 4.34693 6.8426 5.15396 6.8426C5.90092 6.8426 6.32561 6.63645 6.58731 6.36929C6.86326 6.08757 7.0484 5.64627 7.12778 5.0112C7.23011 4.19254 7.09476 3.79007 6.91639 3.59896ZM7.99818 12.9788C9.99307 12.9788 12.0088 12.0076 12.3748 11.7248V8.00133L12.3546 7.90026C11.9257 8.08369 11.4142 8.1551 10.8435 8.1551C9.84049 8.1551 9.04148 7.86851 8.47257 7.28773C8.28092 7.09208 8.12536 6.87308 7.99978 6.6378C7.8742 6.87308 7.71544 7.09208 7.52378 7.28773C6.95488 7.86851 6.15587 8.1551 5.15283 8.1551C4.58212 8.1551 4.07068 8.08369 3.6418 7.90026L3.62158 8.00133V11.7248C3.98755 12.0076 6.00329 12.9788 7.99818 12.9788Z" fill="currentColor"/>\n<path d="M6.46854 9.0301C6.83097 9.0301 7.12479 9.32391 7.12479 9.68635V10.9988C7.12479 11.3613 6.83097 11.6551 6.46854 11.6551C6.1061 11.6551 5.81229 11.3613 5.81229 10.9988V9.68635C5.81229 9.32391 6.1061 9.0301 6.46854 9.0301Z" fill="currentColor"/>\n<path d="M10.1873 9.68635C10.1873 9.32391 9.89347 9.0301 9.53104 9.0301C9.1686 9.0301 8.87479 9.32391 8.87479 9.68635V10.9988C8.87479 11.3613 9.1686 11.6551 9.53104 11.6551C9.89347 11.6551 10.1873 11.3613 10.1873 10.9988V9.68635Z" fill="currentColor"/>\n</svg>\n',
|
||||||
gjc: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="76 62 872 872">\n <path fill="currentColor" d="M284 493C307 423 337 375 377 349C416 318 463 303 512 303C561 303 608 318 647 349C687 375 717 423 740 493C615 454 409 454 284 493Z"/>\n <path fill="none" stroke="currentColor" stroke-width="38" stroke-linecap="round" d="M371 478C355 367 304 260 197 229M653 478C669 367 720 260 827 229"/>\n <path fill="currentColor" d="M98 613C109 510 295 451 512 451C729 451 915 510 926 613C935 681 850 744 759 765L763 717C833 698 883 655 881 613C875 554 711 513 512 513C313 513 149 554 143 613C141 655 191 698 261 717L265 765C174 744 89 681 98 613Z"/>\n <path fill="currentColor" fill-rule="evenodd" d="M356 560H668A94 94 0 0 1 762 654V692A94 94 0 0 1 668 786H356A94 94 0 0 1 262 692V654A94 94 0 0 1 356 560ZM364 586H660A71 71 0 0 1 731 657V689A71 71 0 0 1 660 760H364A71 71 0 0 1 293 689V657A71 71 0 0 1 364 586Z"/>\n <path fill="currentColor" d="M365.5 654H450.5A15.5 15.5 0 0 1 466 669.5A15.5 15.5 0 0 1 450.5 685H365.5A15.5 15.5 0 0 1 350 669.5A15.5 15.5 0 0 1 365.5 654ZM573.5 654H658.5A15.5 15.5 0 0 1 674 669.5A15.5 15.5 0 0 1 658.5 685H573.5A15.5 15.5 0 0 1 558 669.5A15.5 15.5 0 0 1 573.5 654Z"/>\n</svg>\n',
|
|
||||||
"glm-acp-agent":
|
"glm-acp-agent":
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" fill-rule="evenodd"><title>Z.ai</title><path d="M8.07 1.333L6.618 3.302H.435L1.887 1.333h6.184zM15.503 12.699L14.052 14.667h-6.161l1.449-1.968h6.163zM16 1.333L6.176 14.667H0L9.824 1.333H16z"/></svg>\n',
|
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" fill-rule="evenodd"><title>Z.ai</title><path d="M8.07 1.333L6.618 3.302H.435L1.887 1.333h6.184zM15.503 12.699L14.052 14.667h-6.161l1.449-1.968h6.163zM16 1.333L6.176 14.667H0L9.824 1.333H16z"/></svg>\n',
|
||||||
goose:
|
goose:
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="76 62 872 872">
|
|
||||||
<path fill="currentColor" d="M284 493C307 423 337 375 377 349C416 318 463 303 512 303C561 303 608 318 647 349C687 375 717 423 740 493C615 454 409 454 284 493Z"/>
|
|
||||||
<path fill="none" stroke="currentColor" stroke-width="38" stroke-linecap="round" d="M371 478C355 367 304 260 197 229M653 478C669 367 720 260 827 229"/>
|
|
||||||
<path fill="currentColor" d="M98 613C109 510 295 451 512 451C729 451 915 510 926 613C935 681 850 744 759 765L763 717C833 698 883 655 881 613C875 554 711 513 512 513C313 513 149 554 143 613C141 655 191 698 261 717L265 765C174 744 89 681 98 613Z"/>
|
|
||||||
<path fill="currentColor" fill-rule="evenodd" d="M356 560H668A94 94 0 0 1 762 654V692A94 94 0 0 1 668 786H356A94 94 0 0 1 262 692V654A94 94 0 0 1 356 560ZM364 586H660A71 71 0 0 1 731 657V689A71 71 0 0 1 660 760H364A71 71 0 0 1 293 689V657A71 71 0 0 1 364 586Z"/>
|
|
||||||
<path fill="currentColor" d="M365.5 654H450.5A15.5 15.5 0 0 1 466 669.5A15.5 15.5 0 0 1 450.5 685H365.5A15.5 15.5 0 0 1 350 669.5A15.5 15.5 0 0 1 365.5 654ZM573.5 654H658.5A15.5 15.5 0 0 1 674 669.5A15.5 15.5 0 0 1 658.5 685H573.5A15.5 15.5 0 0 1 558 669.5A15.5 15.5 0 0 1 573.5 654Z"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -8,7 +8,6 @@ import {
|
|||||||
FolderPlus,
|
FolderPlus,
|
||||||
History,
|
History,
|
||||||
Home,
|
Home,
|
||||||
Import,
|
|
||||||
Keyboard,
|
Keyboard,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -18,7 +17,6 @@ import { withUnistyles } from "react-native-unistyles";
|
|||||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||||
import { useImportSession } from "@/hooks/use-import-session";
|
|
||||||
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
|
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
|
||||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||||
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||||
@@ -54,7 +52,6 @@ const ThemedSettings = withUnistyles(Settings, (theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
}));
|
}));
|
||||||
const ThemedHome = withUnistyles(Home, (theme) => ({ color: theme.colors.foregroundMuted }));
|
const ThemedHome = withUnistyles(Home, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||||
const ThemedImport = withUnistyles(Import, (theme) => ({ color: theme.colors.foregroundMuted }));
|
|
||||||
const ThemedFolder = withUnistyles(Folder, (theme) => ({ color: theme.colors.foregroundMuted }));
|
const ThemedFolder = withUnistyles(Folder, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||||
const ThemedCircleDashed = withUnistyles(CircleDashed, (theme) => ({
|
const ThemedCircleDashed = withUnistyles(CircleDashed, (theme) => ({
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
@@ -91,10 +88,6 @@ function HomeIcon({ size }: CommandCenterIconProps) {
|
|||||||
return <ThemedHome size={size} strokeWidth={2.2} />;
|
return <ThemedHome size={size} strokeWidth={2.2} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImportIcon({ size }: CommandCenterIconProps) {
|
|
||||||
return <ThemedImport size={size} strokeWidth={2.2} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FolderIcon({ size }: CommandCenterIconProps) {
|
function FolderIcon({ size }: CommandCenterIconProps) {
|
||||||
return <ThemedFolder size={size} strokeWidth={2.2} />;
|
return <ThemedFolder size={size} strokeWidth={2.2} />;
|
||||||
}
|
}
|
||||||
@@ -113,7 +106,6 @@ export function CommandCenterRootActions() {
|
|||||||
const { overrides } = useKeyboardShortcutOverrides();
|
const { overrides } = useKeyboardShortcutOverrides();
|
||||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||||
const openAddProject = useOpenAddProject();
|
const openAddProject = useOpenAddProject();
|
||||||
const { open: openImportSession, sheet: importSessionSheet } = useImportSession();
|
|
||||||
const settingsRoute = useMemo<Href>(() => buildSettingsRoute(), []);
|
const settingsRoute = useMemo<Href>(() => buildSettingsRoute(), []);
|
||||||
const homeRoute = useMemo<Href>(() => buildOpenProjectRoute(), []);
|
const homeRoute = useMemo<Href>(() => buildOpenProjectRoute(), []);
|
||||||
const sessionsRoute = useMemo<Href>(() => buildSessionsRoute(), []);
|
const sessionsRoute = useMemo<Href>(() => buildSessionsRoute(), []);
|
||||||
@@ -173,30 +165,12 @@ export function CommandCenterRootActions() {
|
|||||||
resolveShortcutKeysForAction("new-workspace", overrides, shortcutPlatform) ?? undefined,
|
resolveShortcutKeysForAction("new-workspace", overrides, shortcutPlatform) ?? undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "import-session",
|
|
||||||
group: "actions",
|
|
||||||
groupRank: 0,
|
|
||||||
rank: 2,
|
|
||||||
keywords: ["import", "session", "terminal"],
|
|
||||||
visibility: "always",
|
|
||||||
run: () => {
|
|
||||||
clearCommandCenterFocusRestoreElement();
|
|
||||||
openImportSession();
|
|
||||||
},
|
|
||||||
presentation: {
|
|
||||||
kind: "action",
|
|
||||||
title: t("importSession.title"),
|
|
||||||
sectionTitle: t("shell.commandCenter.actions"),
|
|
||||||
icon: ImportIcon,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "home",
|
id: "home",
|
||||||
group: "actions",
|
group: "actions",
|
||||||
groupRank: 0,
|
groupRank: 0,
|
||||||
rank: 3,
|
rank: 2,
|
||||||
keywords: ["home", "start", "pair", "device", "providers"],
|
keywords: ["home", "start", "import", "session", "pair", "device", "providers"],
|
||||||
visibility: "query",
|
visibility: "query",
|
||||||
run: () => {
|
run: () => {
|
||||||
clearCommandCenterFocusRestoreElement();
|
clearCommandCenterFocusRestoreElement();
|
||||||
@@ -213,7 +187,7 @@ export function CommandCenterRootActions() {
|
|||||||
id: "history",
|
id: "history",
|
||||||
group: "actions",
|
group: "actions",
|
||||||
groupRank: 0,
|
groupRank: 0,
|
||||||
rank: 4,
|
rank: 3,
|
||||||
keywords: ["history", "sessions", "recent"],
|
keywords: ["history", "sessions", "recent"],
|
||||||
visibility: "always",
|
visibility: "always",
|
||||||
run: () => {
|
run: () => {
|
||||||
@@ -231,7 +205,7 @@ export function CommandCenterRootActions() {
|
|||||||
id: "schedules",
|
id: "schedules",
|
||||||
group: "actions",
|
group: "actions",
|
||||||
groupRank: 0,
|
groupRank: 0,
|
||||||
rank: 5,
|
rank: 4,
|
||||||
keywords: ["schedules", "scheduled", "automation", "recurring"],
|
keywords: ["schedules", "scheduled", "automation", "recurring"],
|
||||||
visibility: "always",
|
visibility: "always",
|
||||||
run: () => {
|
run: () => {
|
||||||
@@ -249,7 +223,7 @@ export function CommandCenterRootActions() {
|
|||||||
id: "settings",
|
id: "settings",
|
||||||
group: "actions",
|
group: "actions",
|
||||||
groupRank: 0,
|
groupRank: 0,
|
||||||
rank: 6,
|
rank: 5,
|
||||||
keywords: ["settings", "preferences", "config", "configuration"],
|
keywords: ["settings", "preferences", "config", "configuration"],
|
||||||
visibility: "always",
|
visibility: "always",
|
||||||
run: () => {
|
run: () => {
|
||||||
@@ -276,7 +250,7 @@ export function CommandCenterRootActions() {
|
|||||||
id: "toggle-left-sidebar",
|
id: "toggle-left-sidebar",
|
||||||
group: "actions",
|
group: "actions",
|
||||||
groupRank: 0,
|
groupRank: 0,
|
||||||
rank: 8,
|
rank: 7,
|
||||||
keywords: ["toggle", "sidebar", "left", "panel", "workspaces"],
|
keywords: ["toggle", "sidebar", "left", "panel", "workspaces"],
|
||||||
visibility: "query",
|
visibility: "query",
|
||||||
run: () => {
|
run: () => {
|
||||||
@@ -300,7 +274,7 @@ export function CommandCenterRootActions() {
|
|||||||
id: "keyboard-shortcuts",
|
id: "keyboard-shortcuts",
|
||||||
group: "actions",
|
group: "actions",
|
||||||
groupRank: 0,
|
groupRank: 0,
|
||||||
rank: 7,
|
rank: 6,
|
||||||
keywords: ["keyboard", "shortcuts", "keys", "hotkeys"],
|
keywords: ["keyboard", "shortcuts", "keys", "hotkeys"],
|
||||||
visibility: "always",
|
visibility: "always",
|
||||||
run: () => setShortcutsDialogOpen(true),
|
run: () => setShortcutsDialogOpen(true),
|
||||||
@@ -335,7 +309,6 @@ export function CommandCenterRootActions() {
|
|||||||
homeRoute,
|
homeRoute,
|
||||||
keyboardActionDispatcher,
|
keyboardActionDispatcher,
|
||||||
openAddProject,
|
openAddProject,
|
||||||
openImportSession,
|
|
||||||
overrides,
|
overrides,
|
||||||
schedulesRoute,
|
schedulesRoute,
|
||||||
sessionsRoute,
|
sessionsRoute,
|
||||||
@@ -349,5 +322,5 @@ export function CommandCenterRootActions() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useCommandCenterActions({ sourceId: "root", enabled: true, actions });
|
useCommandCenterActions({ sourceId: "root", enabled: true, actions });
|
||||||
return importSessionSheet;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,10 @@ import {
|
|||||||
aggregateSessionEntries,
|
aggregateSessionEntries,
|
||||||
ALL_FILTER_VALUE,
|
ALL_FILTER_VALUE,
|
||||||
buildProviderLabelMap,
|
buildProviderLabelMap,
|
||||||
collectProviderErrorRows,
|
collectErroredProviderLabels,
|
||||||
computeEmptyState,
|
computeEmptyState,
|
||||||
formatDirectoryLabel,
|
|
||||||
getPromptPreview,
|
getPromptPreview,
|
||||||
getSessionTitle,
|
getSessionTitle,
|
||||||
hasMoreSessions,
|
|
||||||
nextPageLimit,
|
|
||||||
PER_PROVIDER_LIMIT,
|
|
||||||
resolveDirectoryLabel,
|
|
||||||
resolveImportTarget,
|
|
||||||
resolveProvidersToFetch,
|
resolveProvidersToFetch,
|
||||||
requiresImportSessionsHostUpgrade,
|
requiresImportSessionsHostUpgrade,
|
||||||
type SessionsQueryResult,
|
type SessionsQueryResult,
|
||||||
@@ -173,176 +167,27 @@ describe("sumFilteredAlreadyImportedCount", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("collectProviderErrorRows", () => {
|
describe("collectErroredProviderLabels", () => {
|
||||||
it("returns no rows when no providers are being fetched", () => {
|
it("returns no labels when no providers are being fetched", () => {
|
||||||
expect(collectProviderErrorRows(null, [], new Map())).toEqual([]);
|
expect(collectErroredProviderLabels(null, [], new Map())).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns a row for each provider whose request failed", () => {
|
it("returns labels for each errored provider, falling back to provider id", () => {
|
||||||
const rows = collectProviderErrorRows(
|
const labels = collectErroredProviderLabels(
|
||||||
["claude", "codex"],
|
["claude", "codex"],
|
||||||
[settled(undefined, { isError: true }), settled({ entries: [] })],
|
[settled(undefined, { isError: true }), settled({ entries: [] })],
|
||||||
new Map([["claude", "Claude Code"]]),
|
new Map([["claude", "Claude Code"]]),
|
||||||
);
|
);
|
||||||
expect(rows).toEqual([{ provider: "claude", label: "Claude Code" }]);
|
expect(labels).toEqual(["Claude Code"]);
|
||||||
});
|
|
||||||
|
|
||||||
it("returns a row for a provider the daemon reported as failed in a good response", () => {
|
|
||||||
const rows = collectProviderErrorRows(
|
|
||||||
["codex"],
|
|
||||||
[
|
|
||||||
settled({
|
|
||||||
entries: [],
|
|
||||||
providerErrors: [{ provider: "codex", message: "timed out" }],
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
new Map([["codex", "Codex"]]),
|
|
||||||
);
|
|
||||||
expect(rows).toEqual([{ provider: "codex", label: "Codex" }]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses provider id when the label map has no entry", () => {
|
it("uses provider id when the label map has no entry", () => {
|
||||||
const rows = collectProviderErrorRows(
|
const labels = collectErroredProviderLabels(
|
||||||
["codex"],
|
["codex"],
|
||||||
[settled(undefined, { isError: true })],
|
[settled(undefined, { isError: true })],
|
||||||
new Map(),
|
new Map(),
|
||||||
);
|
);
|
||||||
expect(rows).toEqual([{ provider: "codex", label: "codex" }]);
|
expect(labels).toEqual(["codex"]);
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("nextPageLimit", () => {
|
|
||||||
it("grows the per-provider limit one step at a time", () => {
|
|
||||||
expect(nextPageLimit(PER_PROVIDER_LIMIT)).toBe(45);
|
|
||||||
expect(nextPageLimit(45)).toBe(90);
|
|
||||||
expect(nextPageLimit(90)).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stops at the protocol ceiling", () => {
|
|
||||||
expect(nextPageLimit(200)).toBe(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("hasMoreSessions", () => {
|
|
||||||
it("reports more when a provider filled the requested page", () => {
|
|
||||||
expect(hasMoreSessions([settled({ entries: [entry(), entry()] })], 2)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports no more once every provider returned a short page", () => {
|
|
||||||
expect(hasMoreSessions([settled({ entries: [entry()] })], 2)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports no more at the last page size, however full the page is", () => {
|
|
||||||
expect(hasMoreSessions([settled({ entries: [entry(), entry()] })], 200)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps reporting more while the next page replaces placeholder rows", () => {
|
|
||||||
expect(
|
|
||||||
hasMoreSessions([settled({ entries: [entry()] }, { isPlaceholderData: true })], 45),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resolveDirectoryLabel", () => {
|
|
||||||
const projects = [
|
|
||||||
{ rootPath: "/home/me/paseo", name: "paseo" },
|
|
||||||
{ rootPath: "/home/me/paseo/packages/app", name: "paseo app" },
|
|
||||||
];
|
|
||||||
|
|
||||||
it("names the project root after the project alone", () => {
|
|
||||||
expect(resolveDirectoryLabel("/home/me/paseo", projects)).toEqual({ name: "paseo" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("qualifies a worktree under the project with its path below the root", () => {
|
|
||||||
expect(
|
|
||||||
resolveDirectoryLabel("/home/me/paseo/.dev/worktrees/abc/zebra", [projects[0]!]),
|
|
||||||
).toEqual({ name: "paseo", detail: ".dev/worktrees/abc/zebra" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tells two worktrees of the same project apart", () => {
|
|
||||||
const zebra = resolveDirectoryLabel("/home/me/paseo/.dev/worktrees/abc/zebra", [projects[0]!]);
|
|
||||||
const otter = resolveDirectoryLabel("/home/me/paseo/.dev/worktrees/def/otter", [projects[0]!]);
|
|
||||||
expect(zebra).not.toEqual(otter);
|
|
||||||
expect([zebra.detail, otter.detail]).toEqual([
|
|
||||||
".dev/worktrees/abc/zebra",
|
|
||||||
".dev/worktrees/def/otter",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("picks the most specific project root containing the directory", () => {
|
|
||||||
expect(resolveDirectoryLabel("/home/me/paseo/packages/app/src", projects)).toEqual({
|
|
||||||
name: "paseo app",
|
|
||||||
detail: "src",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores trailing slashes on both sides", () => {
|
|
||||||
expect(
|
|
||||||
resolveDirectoryLabel("/home/me/paseo/", [{ rootPath: "/home/me/paseo/", name: "p" }]),
|
|
||||||
).toEqual({ name: "p" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not match a project root that is only a string prefix", () => {
|
|
||||||
expect(resolveDirectoryLabel("/home/me/paseo-fork", projects)).toEqual({
|
|
||||||
name: "/home/me/paseo-fork",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the path when no project owns the directory", () => {
|
|
||||||
expect(resolveDirectoryLabel("/tmp/scratch", projects)).toEqual({ name: "/tmp/scratch" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("formatDirectoryLabel", () => {
|
|
||||||
it("shows the project name alone at its root", () => {
|
|
||||||
expect(formatDirectoryLabel({ name: "paseo" })).toBe("paseo");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("appends the path under the root so worktrees of one project read apart", () => {
|
|
||||||
expect(formatDirectoryLabel({ name: "paseo", detail: ".dev/worktrees/zebra" })).toBe(
|
|
||||||
"paseo · .dev/worktrees/zebra",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("resolveImportTarget", () => {
|
|
||||||
it("trusts a scoped listing, whose rows the daemon already matched realpath-aware", () => {
|
|
||||||
expect(
|
|
||||||
resolveImportTarget({
|
|
||||||
entryCwd: "/private/repo/paseo",
|
|
||||||
workspaceCwd: "/repo/paseo",
|
|
||||||
workspaceId: "ws-1",
|
|
||||||
isScopedListing: true,
|
|
||||||
}),
|
|
||||||
).toEqual({ workspaceId: "ws-1", crossWorkspace: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the current workspace for a Show-all row in that workspace's directory", () => {
|
|
||||||
expect(
|
|
||||||
resolveImportTarget({
|
|
||||||
entryCwd: "/repo/paseo/",
|
|
||||||
workspaceCwd: "/repo/paseo",
|
|
||||||
workspaceId: "ws-1",
|
|
||||||
isScopedListing: false,
|
|
||||||
}),
|
|
||||||
).toEqual({ workspaceId: "ws-1", crossWorkspace: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops the workspace for a Show-all row from another directory, which the daemon rejects", () => {
|
|
||||||
expect(
|
|
||||||
resolveImportTarget({
|
|
||||||
entryCwd: "/repo/other",
|
|
||||||
workspaceCwd: "/repo/paseo",
|
|
||||||
workspaceId: "ws-1",
|
|
||||||
isScopedListing: false,
|
|
||||||
}),
|
|
||||||
).toEqual({ crossWorkspace: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats a sheet with no workspace as cross-workspace", () => {
|
|
||||||
expect(resolveImportTarget({ entryCwd: "/repo/paseo", isScopedListing: false })).toEqual({
|
|
||||||
crossWorkspace: true,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -391,7 +236,6 @@ describe("computeEmptyState", () => {
|
|||||||
isQueryingProviders: true,
|
isQueryingProviders: true,
|
||||||
allQueriesSettled: true,
|
allQueriesSettled: true,
|
||||||
selectedProvider: ALL_FILTER_VALUE,
|
selectedProvider: ALL_FILTER_VALUE,
|
||||||
hasQuery: false,
|
|
||||||
aggregatedCount: 0,
|
aggregatedCount: 0,
|
||||||
visibleCount: 0,
|
visibleCount: 0,
|
||||||
totalAlreadyImportedCount: 0,
|
totalAlreadyImportedCount: 0,
|
||||||
@@ -430,15 +274,6 @@ describe("computeEmptyState", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("says nothing matched when a query narrowed the list to nothing", () => {
|
|
||||||
const result = computeEmptyState({
|
|
||||||
...baseInputs,
|
|
||||||
hasQuery: true,
|
|
||||||
totalAlreadyImportedCount: 4,
|
|
||||||
});
|
|
||||||
expect(result.emptyStateTitle).toBe("No sessions match your search.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the already-imported message when imported entries were filtered out", () => {
|
it("shows the already-imported message when imported entries were filtered out", () => {
|
||||||
const result = computeEmptyState({
|
const result = computeEmptyState({
|
||||||
...baseInputs,
|
...baseInputs,
|
||||||
|
|||||||
@@ -5,18 +5,6 @@ import { i18n } from "@/i18n/i18next";
|
|||||||
export const PER_PROVIDER_LIMIT = 15;
|
export const PER_PROVIDER_LIMIT = 15;
|
||||||
export const ALL_FILTER_VALUE = "__all__";
|
export const ALL_FILTER_VALUE = "__all__";
|
||||||
|
|
||||||
/**
|
|
||||||
* Paging grows the per-provider limit instead of carrying an offset. The daemon
|
|
||||||
* ranks the whole candidate set on every request, so an offset into a previous
|
|
||||||
* ranking would drift as sessions move; refetching a larger page cannot. 200 is
|
|
||||||
* the protocol ceiling on `limit`.
|
|
||||||
*/
|
|
||||||
const PAGE_LIMITS: readonly number[] = [PER_PROVIDER_LIMIT, 45, 90, 200];
|
|
||||||
|
|
||||||
export function nextPageLimit(limit: number): number {
|
|
||||||
return PAGE_LIMITS.find((candidate) => candidate > limit) ?? limit;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function requiresImportSessionsHostUpgrade(input: {
|
export function requiresImportSessionsHostUpgrade(input: {
|
||||||
supportsSnapshot: boolean;
|
supportsSnapshot: boolean;
|
||||||
workspaceId?: string | null;
|
workspaceId?: string | null;
|
||||||
@@ -30,13 +18,11 @@ export interface SessionsQueryResult {
|
|||||||
| {
|
| {
|
||||||
entries: FetchRecentProviderSessionEntry[];
|
entries: FetchRecentProviderSessionEntry[];
|
||||||
filteredAlreadyImportedCount?: number;
|
filteredAlreadyImportedCount?: number;
|
||||||
providerErrors?: Array<{ provider: string; message: string }>;
|
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
isPlaceholderData?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveProvidersToFetch(
|
export function resolveProvidersToFetch(
|
||||||
@@ -95,132 +81,20 @@ export function sumFilteredAlreadyImportedCount(
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function collectErroredProviderLabels(
|
||||||
* A provider that returned a full page may have more behind it. The daemon
|
|
||||||
* slices to `limit` after dropping already-imported sessions, so a short page is
|
|
||||||
* the end of that provider's list. Placeholder rows belong to the previous page,
|
|
||||||
* so the button holds its place instead of blinking out while the next loads.
|
|
||||||
*/
|
|
||||||
export function hasMoreSessions(
|
|
||||||
queries: ReadonlyArray<SessionsQueryResult>,
|
|
||||||
limit: number,
|
|
||||||
): boolean {
|
|
||||||
if (nextPageLimit(limit) === limit) return false;
|
|
||||||
return queries.some((entryQuery) => {
|
|
||||||
const count = entryQuery.data?.entries.length ?? 0;
|
|
||||||
return entryQuery.isPlaceholderData ? count > 0 : count >= limit;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProviderErrorRow {
|
|
||||||
provider: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One row per provider that could not be listed, whether the request itself
|
|
||||||
* failed or the daemon reported the provider as failed in an otherwise good
|
|
||||||
* response. Both mean the same thing to the user, and both retry the same way.
|
|
||||||
*/
|
|
||||||
export function collectProviderErrorRows(
|
|
||||||
providersToFetch: AgentProvider[] | null,
|
providersToFetch: AgentProvider[] | null,
|
||||||
queries: ReadonlyArray<SessionsQueryResult>,
|
queries: ReadonlyArray<SessionsQueryResult>,
|
||||||
providerLabelById: ReadonlyMap<string, string>,
|
providerLabelById: ReadonlyMap<string, string>,
|
||||||
): ProviderErrorRow[] {
|
): string[] {
|
||||||
if (providersToFetch === null) return [];
|
if (providersToFetch === null) return [];
|
||||||
const rows: ProviderErrorRow[] = [];
|
const labels: string[] = [];
|
||||||
for (let index = 0; index < providersToFetch.length; index++) {
|
for (let index = 0; index < queries.length; index++) {
|
||||||
const query = queries[index];
|
if (queries[index]?.isError) {
|
||||||
const provider = providersToFetch[index];
|
const provider = providersToFetch[index];
|
||||||
if (!query || provider === undefined) continue;
|
labels.push(providerLabelById.get(provider) ?? provider);
|
||||||
const failed = query.isError || (query.data?.providerErrors?.length ?? 0) > 0;
|
}
|
||||||
if (!failed) continue;
|
|
||||||
rows.push({ provider, label: providerLabelById.get(provider) ?? provider });
|
|
||||||
}
|
}
|
||||||
return rows;
|
return labels;
|
||||||
}
|
|
||||||
|
|
||||||
export interface DirectoryProject {
|
|
||||||
rootPath: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DirectoryLabel {
|
|
||||||
/** The owning project's name, or the whole path when no project owns it. */
|
|
||||||
name: string;
|
|
||||||
/**
|
|
||||||
* The directory's path under its project root, absent at the root itself.
|
|
||||||
* Worktrees of one project all resolve to the same name, so without this the
|
|
||||||
* sheet would show several identical headings.
|
|
||||||
*/
|
|
||||||
detail?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function withoutTrailingSlash(path: string): string {
|
|
||||||
return path.length > 1 ? path.replace(/\/+$/, "") : path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The project's name when the app knows the directory, else the directory itself. */
|
|
||||||
export function resolveDirectoryLabel(
|
|
||||||
directory: string,
|
|
||||||
projects: ReadonlyArray<DirectoryProject>,
|
|
||||||
): DirectoryLabel {
|
|
||||||
const normalized = withoutTrailingSlash(directory);
|
|
||||||
let bestRoot: string | null = null;
|
|
||||||
let bestName: string | null = null;
|
|
||||||
for (const project of projects) {
|
|
||||||
const root = withoutTrailingSlash(project.rootPath);
|
|
||||||
if (!root) continue;
|
|
||||||
if (bestRoot !== null && root.length <= bestRoot.length) continue;
|
|
||||||
if (normalized !== root && !normalized.startsWith(`${root}/`)) continue;
|
|
||||||
bestRoot = root;
|
|
||||||
bestName = project.name;
|
|
||||||
}
|
|
||||||
if (bestRoot === null || bestName === null) {
|
|
||||||
return { name: normalized };
|
|
||||||
}
|
|
||||||
const relative = normalized.slice(bestRoot.length + 1);
|
|
||||||
return relative ? { name: bestName, detail: relative } : { name: bestName };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The one-line form a row shows under its prompt preview. */
|
|
||||||
export function formatDirectoryLabel(label: DirectoryLabel): string {
|
|
||||||
return label.detail ? `${label.name} · ${label.detail}` : label.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportTarget {
|
|
||||||
/**
|
|
||||||
* Omitted unless the row's directory is the scoped workspace's own: the daemon
|
|
||||||
* rejects an import whose cwd does not match the requested workspace.
|
|
||||||
*/
|
|
||||||
workspaceId?: string;
|
|
||||||
/**
|
|
||||||
* The agent lands in a workspace other than the one the sheet was opened from,
|
|
||||||
* so the caller has to open that workspace instead of adding a tab here.
|
|
||||||
*/
|
|
||||||
crossWorkspace: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveImportTarget(input: {
|
|
||||||
entryCwd: string;
|
|
||||||
/** The directory the sheet was opened for, absent when it was opened host-wide. */
|
|
||||||
workspaceCwd?: string | null;
|
|
||||||
workspaceId?: string | null;
|
|
||||||
/** The listed rows came from a fetch scoped to `workspaceCwd`. */
|
|
||||||
isScopedListing: boolean;
|
|
||||||
}): ImportTarget {
|
|
||||||
if (!input.workspaceId || !input.workspaceCwd) {
|
|
||||||
return { crossWorkspace: true };
|
|
||||||
}
|
|
||||||
// A scoped listing only holds rows the daemon already matched to this
|
|
||||||
// workspace's directory with realpaths resolved, which the client cannot do.
|
|
||||||
// "Show all" drops that guarantee, so each row is compared by path instead.
|
|
||||||
const belongsToWorkspace =
|
|
||||||
input.isScopedListing ||
|
|
||||||
withoutTrailingSlash(input.entryCwd) === withoutTrailingSlash(input.workspaceCwd);
|
|
||||||
return belongsToWorkspace
|
|
||||||
? { workspaceId: input.workspaceId, crossWorkspace: false }
|
|
||||||
: { crossWorkspace: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSessionTitle(entry: FetchRecentProviderSessionEntry): string {
|
export function getSessionTitle(entry: FetchRecentProviderSessionEntry): string {
|
||||||
@@ -249,7 +123,6 @@ export interface EmptyStateInputs {
|
|||||||
isQueryingProviders: boolean;
|
isQueryingProviders: boolean;
|
||||||
allQueriesSettled: boolean;
|
allQueriesSettled: boolean;
|
||||||
selectedProvider: string;
|
selectedProvider: string;
|
||||||
hasQuery: boolean;
|
|
||||||
aggregatedCount: number;
|
aggregatedCount: number;
|
||||||
visibleCount: number;
|
visibleCount: number;
|
||||||
totalAlreadyImportedCount: number;
|
totalAlreadyImportedCount: number;
|
||||||
@@ -269,11 +142,6 @@ export function computeEmptyState(input: EmptyStateInputs): {
|
|||||||
if (!showEmptyState) {
|
if (!showEmptyState) {
|
||||||
return { showEmptyState, emptyStateTitle: "" };
|
return { showEmptyState, emptyStateTitle: "" };
|
||||||
}
|
}
|
||||||
// A query narrowing the list to nothing is a different answer from a host with
|
|
||||||
// nothing to import, and the recovery is different too.
|
|
||||||
if (input.hasQuery) {
|
|
||||||
return { showEmptyState, emptyStateTitle: i18n.t("importSession.empty.noMatches") };
|
|
||||||
}
|
|
||||||
const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0;
|
const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0;
|
||||||
if (isFilteredEmpty) {
|
if (isFilteredEmpty) {
|
||||||
const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider;
|
const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider;
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ import { ImportSessionSheet } from "@/components/import-session-sheet";
|
|||||||
|
|
||||||
const { theme } = vi.hoisted(() => ({
|
const { theme } = vi.hoisted(() => ({
|
||||||
theme: {
|
theme: {
|
||||||
spacing: { 1: 4, 1.5: 6, 2: 8, 2.5: 10, 3: 12, 4: 16, 6: 24, 8: 32 },
|
spacing: { 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16, 6: 24 },
|
||||||
borderWidth: { 1: 1 },
|
borderWidth: { 1: 1 },
|
||||||
borderRadius: { md: 6, lg: 8, full: 9999 },
|
borderRadius: { md: 6, lg: 8, full: 9999 },
|
||||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
fontSize: { xs: 11, sm: 13, base: 15 },
|
||||||
fontWeight: { normal: "400", medium: "500", semibold: "600" },
|
fontWeight: { normal: "400", medium: "500", semibold: "600" },
|
||||||
iconSize: { sm: 14, md: 16, lg: 24 },
|
iconSize: { sm: 14, md: 16 },
|
||||||
opacity: { 50: 0.5 },
|
opacity: { 50: 0.5 },
|
||||||
colors: {
|
colors: {
|
||||||
foreground: "#fff",
|
foreground: "#fff",
|
||||||
@@ -30,8 +30,6 @@ const { theme } = vi.hoisted(() => ({
|
|||||||
surface3: "#333",
|
surface3: "#333",
|
||||||
border: "#444",
|
border: "#444",
|
||||||
borderAccent: "#555",
|
borderAccent: "#555",
|
||||||
interactionHighlight: "rgba(255,255,255,0.08)",
|
|
||||||
palette: { red: { 300: "#f87171" } },
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -77,8 +75,6 @@ vi.mock("lucide-react-native", () => {
|
|||||||
Inbox: icon("Inbox"),
|
Inbox: icon("Inbox"),
|
||||||
Layers: icon("Layers"),
|
Layers: icon("Layers"),
|
||||||
RotateCw: icon("RotateCw"),
|
RotateCw: icon("RotateCw"),
|
||||||
Search: icon("Search"),
|
|
||||||
X: icon("X"),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,22 +117,6 @@ vi.mock("@/components/ui/combobox", () => ({
|
|||||||
ComboboxItem: ({ label }: { label: string }) => React.createElement("span", null, label),
|
ComboboxItem: ({ label }: { label: string }) => React.createElement("span", null, label),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
interface SheetSearchProps {
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
testID?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetSearchInput({ search }: { search: SheetSearchProps }) {
|
|
||||||
const handleChange = React.useCallback(
|
|
||||||
(event: React.ChangeEvent<HTMLInputElement>) => search.onChange(event.target.value),
|
|
||||||
[search],
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<input data-testid={search.testID} placeholder={search.placeholder} onChange={handleChange} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
vi.mock("@/components/adaptive-modal-sheet", () => ({
|
vi.mock("@/components/adaptive-modal-sheet", () => ({
|
||||||
AdaptiveModalSheet: ({
|
AdaptiveModalSheet: ({
|
||||||
visible,
|
visible,
|
||||||
@@ -145,21 +125,14 @@ vi.mock("@/components/adaptive-modal-sheet", () => ({
|
|||||||
testID,
|
testID,
|
||||||
}: {
|
}: {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
header?: {
|
header?: { title: string; actions?: ReactNode };
|
||||||
title: string;
|
|
||||||
subtitle?: ReactNode;
|
|
||||||
actions?: ReactNode;
|
|
||||||
search?: { onChange: (value: string) => void; placeholder?: string; testID?: string };
|
|
||||||
};
|
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}) =>
|
}) =>
|
||||||
visible ? (
|
visible ? (
|
||||||
<section data-testid={testID}>
|
<section data-testid={testID}>
|
||||||
<h1>{header?.title}</h1>
|
<h1>{header?.title}</h1>
|
||||||
{header?.subtitle}
|
|
||||||
{header?.actions}
|
{header?.actions}
|
||||||
{header?.search ? <SheetSearchInput search={header.search} /> : null}
|
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
) : null,
|
) : null,
|
||||||
@@ -190,62 +163,26 @@ vi.mock("@/hooks/use-providers-snapshot", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockHostFeatures = vi.hoisted(() => ({
|
|
||||||
current: { importSessionSearch: true, importSessionWorkspaceTarget: true } as Record<
|
|
||||||
string,
|
|
||||||
boolean
|
|
||||||
>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/runtime/host-features", () => ({
|
|
||||||
useHostFeature: (_serverId: string | null, feature: string) =>
|
|
||||||
mockHostFeatures.current[feature] === true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/runtime/host-runtime", () => ({
|
|
||||||
useHosts: () => [{ serverId: "server-1", label: "workbench" }],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockHostProjects = vi.hoisted(() => ({
|
|
||||||
current: [] as Array<{ iconWorkingDir: string; projectName: string }>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/projects/host-projects", () => ({
|
|
||||||
useHostProjects: () => mockHostProjects.current,
|
|
||||||
}));
|
|
||||||
|
|
||||||
interface RenderOptions {
|
interface RenderOptions {
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onImportedAgent?: (agentId: string) => void;
|
onImportedAgent?: (agentId: string) => void;
|
||||||
onImported?: (agent: Awaited<ReturnType<DaemonClient["importAgent"]>>) => void;
|
onImported?: (agent: Awaited<ReturnType<DaemonClient["importAgent"]>>) => void;
|
||||||
cwd?: string | null;
|
cwd?: string | null;
|
||||||
workspaceId?: string;
|
|
||||||
supportsSearch?: boolean;
|
|
||||||
projects?: Array<{ iconWorkingDir: string; projectName: string }>;
|
|
||||||
snapshot?: {
|
snapshot?: {
|
||||||
entries?: ProviderSnapshotEntry[];
|
entries?: ProviderSnapshotEntry[];
|
||||||
supportsSnapshot?: boolean;
|
supportsSnapshot?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyHostMocks(options?: RenderOptions) {
|
|
||||||
mockSnapshot.current = {
|
|
||||||
entries: options?.snapshot?.entries,
|
|
||||||
supportsSnapshot: options?.snapshot?.supportsSnapshot ?? false,
|
|
||||||
};
|
|
||||||
mockHostFeatures.current = {
|
|
||||||
importSessionSearch: options?.supportsSearch ?? true,
|
|
||||||
importSessionWorkspaceTarget: true,
|
|
||||||
};
|
|
||||||
mockHostProjects.current = options?.projects ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSheet(
|
function renderSheet(
|
||||||
client: Pick<DaemonClient, "fetchRecentProviderSessions" | "importAgent">,
|
client: Pick<DaemonClient, "fetchRecentProviderSessions" | "importAgent">,
|
||||||
options?: RenderOptions,
|
options?: RenderOptions,
|
||||||
) {
|
) {
|
||||||
applyHostMocks(options);
|
mockSnapshot.current = {
|
||||||
|
entries: options?.snapshot?.entries,
|
||||||
|
supportsSnapshot: options?.snapshot?.supportsSnapshot ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -263,7 +200,6 @@ function renderSheet(
|
|||||||
client={client}
|
client={client}
|
||||||
serverId="server-1"
|
serverId="server-1"
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
workspaceId={options?.workspaceId}
|
|
||||||
onClose={options?.onClose ?? vi.fn()}
|
onClose={options?.onClose ?? vi.fn()}
|
||||||
onImportedAgent={options?.onImportedAgent ?? vi.fn()}
|
onImportedAgent={options?.onImportedAgent ?? vi.fn()}
|
||||||
onImported={options?.onImported}
|
onImported={options?.onImported}
|
||||||
@@ -309,16 +245,6 @@ function createImportedAgentSnapshot(id: string): Awaited<ReturnType<DaemonClien
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPageOfEntries(count: number): FetchRecentProviderSessionEntry[] {
|
|
||||||
return Array.from({ length: count }, (_unused, index) =>
|
|
||||||
createProviderSessionEntry({
|
|
||||||
providerId: "claude",
|
|
||||||
providerHandleId: `thread-${index}`,
|
|
||||||
title: `Session ${index}`,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createProviderSessionEntry(
|
function createProviderSessionEntry(
|
||||||
overrides?: Partial<FetchRecentProviderSessionEntry>,
|
overrides?: Partial<FetchRecentProviderSessionEntry>,
|
||||||
): FetchRecentProviderSessionEntry {
|
): FetchRecentProviderSessionEntry {
|
||||||
@@ -451,8 +377,7 @@ describe("ImportSessionSheet", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await screen.findByText("Could not load Claude Code sessions");
|
await screen.findByText("Could not load recent sessions.");
|
||||||
screen.getByTestId("import-session-retry-claude");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("loads recent provider sessions for the workspace and renders descriptor-owned labels", async () => {
|
it("loads recent provider sessions for the workspace and renders descriptor-owned labels", async () => {
|
||||||
@@ -553,34 +478,19 @@ describe("ImportSessionSheet", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("imports a selected session by provider handle and reports the imported agent", async () => {
|
it("imports a selected session by provider handle and reports the imported agent", async () => {
|
||||||
const events: string[] = [];
|
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||||
let fetchCount = 0;
|
requestId: "recent-provider-sessions",
|
||||||
const fetchRecentProviderSessions = vi.fn(async () => {
|
entries: [
|
||||||
fetchCount += 1;
|
createProviderSessionEntry({
|
||||||
events.push("fetch");
|
providerId: "claude",
|
||||||
if (fetchCount > 1) {
|
providerLabel: "Claude Code",
|
||||||
return await new Promise<never>(() => {});
|
cwd: "/repo/paseo-realpath",
|
||||||
}
|
|
||||||
return {
|
|
||||||
requestId: "recent-provider-sessions",
|
|
||||||
entries: [
|
|
||||||
createProviderSessionEntry({
|
|
||||||
providerId: "claude",
|
|
||||||
providerLabel: "Claude Code",
|
|
||||||
cwd: "/repo/paseo-realpath",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
let resolveImport!: (agent: ReturnType<typeof createImportedAgentSnapshot>) => void;
|
|
||||||
const importAgent = vi.fn(
|
|
||||||
() =>
|
|
||||||
new Promise<ReturnType<typeof createImportedAgentSnapshot>>((resolve) => {
|
|
||||||
resolveImport = resolve;
|
|
||||||
}),
|
}),
|
||||||
);
|
],
|
||||||
const onClose = vi.fn(() => events.push("close"));
|
}));
|
||||||
const onImportedAgent = vi.fn(() => events.push("navigate"));
|
const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported"));
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const onImportedAgent = vi.fn();
|
||||||
|
|
||||||
renderSheet(
|
renderSheet(
|
||||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||||
@@ -590,31 +500,21 @@ describe("ImportSessionSheet", () => {
|
|||||||
{
|
{
|
||||||
onClose,
|
onClose,
|
||||||
onImportedAgent,
|
onImportedAgent,
|
||||||
workspaceId: "ws-current",
|
|
||||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const row = await screen.findByTestId("import-session-session-claude-provider-thread-1");
|
fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1"));
|
||||||
expect(events).toEqual(["fetch"]);
|
|
||||||
fireEvent.click(row);
|
|
||||||
|
|
||||||
await screen.findByText("Importing...");
|
|
||||||
expect(events).toEqual(["fetch"]);
|
|
||||||
resolveImport(createImportedAgentSnapshot("agent-imported"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(importAgent).toHaveBeenCalledWith({
|
expect(importAgent).toHaveBeenCalledWith({
|
||||||
providerId: "claude",
|
providerId: "claude",
|
||||||
providerHandleId: "provider-thread-1",
|
providerHandleId: "provider-thread-1",
|
||||||
cwd: "/repo/paseo-realpath",
|
cwd: "/repo/paseo-realpath",
|
||||||
workspaceId: "ws-current",
|
|
||||||
});
|
});
|
||||||
expect(events).toEqual(["fetch", "close", "navigate"]);
|
|
||||||
});
|
});
|
||||||
expect(onImportedAgent).toHaveBeenCalledWith("agent-imported");
|
expect(onImportedAgent).toHaveBeenCalledWith("agent-imported");
|
||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows an import error state without closing when selected session import fails", async () => {
|
it("shows an import error state without closing when selected session import fails", async () => {
|
||||||
@@ -749,7 +649,7 @@ describe("ImportSessionSheet", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await screen.findByText("Session codex");
|
await screen.findByText("Session codex");
|
||||||
await screen.findByText("Could not load Claude Code sessions");
|
await screen.findByText("Could not load sessions for Claude Code.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters the merged list when a provider badge is selected and restores it on All", async () => {
|
it("filters the merged list when a provider badge is selected and restores it on All", async () => {
|
||||||
@@ -858,7 +758,7 @@ describe("ImportSessionSheet", () => {
|
|||||||
expect(fetchRecentProviderSessions).not.toHaveBeenCalled();
|
expect(fetchRecentProviderSessions).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits cwd from fetch and names each row's folder when cwd is unset", async () => {
|
it("omits cwd from fetch and renders the session cwd on each row when cwd is unset", async () => {
|
||||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||||
requestId: "recent-provider-sessions",
|
requestId: "recent-provider-sessions",
|
||||||
entries: [
|
entries: [
|
||||||
@@ -893,9 +793,6 @@ describe("ImportSessionSheet", () => {
|
|||||||
expect.objectContaining({ cwd: expect.anything() }),
|
expect.objectContaining({ cwd: expect.anything() }),
|
||||||
);
|
);
|
||||||
await screen.findByText("/home/me/work/other-project");
|
await screen.findByText("/home/me/work/other-project");
|
||||||
expect(
|
|
||||||
screen.getByTestId("import-session-row-folder-claude-provider-thread-1").textContent,
|
|
||||||
).toBe("/home/me/work/other-project");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the session's cwd when importing in cwd-less mode and fires onImported", async () => {
|
it("uses the session's cwd when importing in cwd-less mode and fires onImported", async () => {
|
||||||
@@ -939,344 +836,10 @@ describe("ImportSessionSheet", () => {
|
|||||||
});
|
});
|
||||||
expect(onImported).toHaveBeenCalledTimes(1);
|
expect(onImported).toHaveBeenCalledTimes(1);
|
||||||
expect(onImported).toHaveBeenCalledWith(expect.objectContaining({ id: "agent-imported" }));
|
expect(onImported).toHaveBeenCalledWith(expect.objectContaining({ id: "agent-imported" }));
|
||||||
expect(onImportedAgent).not.toHaveBeenCalled();
|
expect(onImportedAgent).toHaveBeenCalledWith("agent-imported");
|
||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("names the scope after the host when unscoped and after the workspace when scoped", async () => {
|
|
||||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
|
||||||
requestId: "recent-provider-sessions",
|
|
||||||
entries: [],
|
|
||||||
}));
|
|
||||||
const importAgent = vi.fn();
|
|
||||||
const client = createRecentSessionsClient(fetchRecentProviderSessions, importAgent);
|
|
||||||
|
|
||||||
renderSheet(client, {
|
|
||||||
cwd: null,
|
|
||||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
|
||||||
});
|
|
||||||
await screen.findByText("Sessions on workbench");
|
|
||||||
expect(screen.queryByTestId("import-session-show-all")).toBeNull();
|
|
||||||
cleanup();
|
|
||||||
|
|
||||||
renderSheet(client, {
|
|
||||||
workspaceId: "ws-current",
|
|
||||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
|
||||||
});
|
|
||||||
await screen.findByText("This workspace");
|
|
||||||
screen.getByTestId("import-session-show-all");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lists unscoped rows newest first and names each row's folder", async () => {
|
|
||||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
|
||||||
requestId: "recent-provider-sessions",
|
|
||||||
entries: [
|
|
||||||
createProviderSessionEntry({
|
|
||||||
providerId: "claude",
|
|
||||||
providerHandleId: "newest",
|
|
||||||
cwd: "/home/me/paseo",
|
|
||||||
title: "Newest in paseo",
|
|
||||||
lastActivityAt: "2026-04-30T12:00:00.000Z",
|
|
||||||
}),
|
|
||||||
createProviderSessionEntry({
|
|
||||||
providerId: "claude",
|
|
||||||
providerHandleId: "elsewhere",
|
|
||||||
cwd: "/tmp/scratch",
|
|
||||||
title: "Scratch session",
|
|
||||||
lastActivityAt: "2026-04-30T11:00:00.000Z",
|
|
||||||
}),
|
|
||||||
createProviderSessionEntry({
|
|
||||||
providerId: "claude",
|
|
||||||
providerHandleId: "older",
|
|
||||||
cwd: "/home/me/paseo",
|
|
||||||
title: "Older in paseo",
|
|
||||||
lastActivityAt: "2026-04-30T10:00:00.000Z",
|
|
||||||
}),
|
|
||||||
createProviderSessionEntry({
|
|
||||||
providerId: "claude",
|
|
||||||
providerHandleId: "worktree",
|
|
||||||
cwd: "/home/me/paseo/.dev/worktrees/zebra",
|
|
||||||
title: "Worktree session",
|
|
||||||
lastActivityAt: "2026-04-30T09:00:00.000Z",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
const importAgent = vi.fn();
|
|
||||||
|
|
||||||
renderSheet(
|
|
||||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
|
||||||
DaemonClient,
|
|
||||||
"fetchRecentProviderSessions" | "importAgent"
|
|
||||||
>,
|
|
||||||
{
|
|
||||||
cwd: null,
|
|
||||||
projects: [{ iconWorkingDir: "/home/me/paseo", projectName: "paseo" }],
|
|
||||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await screen.findByTestId("import-session-session-claude-newest");
|
|
||||||
expect(
|
|
||||||
screen
|
|
||||||
.getAllByTestId(/^import-session-session-/)
|
|
||||||
.map((row) => row.getAttribute("data-testid")),
|
|
||||||
).toEqual([
|
|
||||||
"import-session-session-claude-newest",
|
|
||||||
"import-session-session-claude-elsewhere",
|
|
||||||
"import-session-session-claude-older",
|
|
||||||
"import-session-session-claude-worktree",
|
|
||||||
]);
|
|
||||||
expect(
|
|
||||||
screen.getAllByTestId(/^import-session-row-folder-/).map((folder) => folder.textContent),
|
|
||||||
).toEqual(["paseo", "/tmp/scratch", "paseo", "paseo · .dev/worktrees/zebra"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves the folder off every row when the sheet is scoped to one workspace", async () => {
|
|
||||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
|
||||||
requestId: "recent-provider-sessions",
|
|
||||||
entries: [
|
|
||||||
createProviderSessionEntry({
|
|
||||||
providerId: "claude",
|
|
||||||
providerHandleId: "scoped",
|
|
||||||
cwd: "/repo/paseo",
|
|
||||||
title: "Scoped session",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
const importAgent = vi.fn();
|
|
||||||
|
|
||||||
renderSheet(
|
|
||||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
|
||||||
DaemonClient,
|
|
||||||
"fetchRecentProviderSessions" | "importAgent"
|
|
||||||
>,
|
|
||||||
{
|
|
||||||
workspaceId: "ws-current",
|
|
||||||
projects: [{ iconWorkingDir: "/repo/paseo", projectName: "paseo" }],
|
|
||||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await screen.findByText("Scoped session");
|
|
||||||
expect(screen.queryAllByTestId(/^import-session-row-folder-/)).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends the debounced search query to every provider", async () => {
|
|
||||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
|
||||||
requestId: "recent-provider-sessions",
|
|
||||||
entries: [],
|
|
||||||
}));
|
|
||||||
const importAgent = vi.fn();
|
|
||||||
|
|
||||||
renderSheet(
|
|
||||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
|
||||||
DaemonClient,
|
|
||||||
"fetchRecentProviderSessions" | "importAgent"
|
|
||||||
>,
|
|
||||||
{
|
|
||||||
cwd: null,
|
|
||||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(fetchRecentProviderSessions).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByTestId("import-session-search"), {
|
|
||||||
target: { value: "invoice" },
|
|
||||||
});
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
|
|
||||||
providers: ["claude"],
|
|
||||||
limit: 15,
|
|
||||||
query: "invoice",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("hides the search field when the host cannot search sessions", async () => {
|
|
||||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
|
||||||
requestId: "recent-provider-sessions",
|
|
||||||
entries: [],
|
|
||||||
}));
|
|
||||||
const importAgent = vi.fn();
|
|
||||||
|
|
||||||
renderSheet(
|
|
||||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
|
||||||
DaemonClient,
|
|
||||||
"fetchRecentProviderSessions" | "importAgent"
|
|
||||||
>,
|
|
||||||
{
|
|
||||||
cwd: null,
|
|
||||||
supportsSearch: false,
|
|
||||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(fetchRecentProviderSessions).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
expect(screen.queryByTestId("import-session-search")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("asks for a larger page when Load more is pressed, and stops offering it on a short page", async () => {
|
|
||||||
const fetchRecentProviderSessions = vi.fn(async (options: { limit?: number } | undefined) => ({
|
|
||||||
requestId: "recent-provider-sessions",
|
|
||||||
entries: createPageOfEntries(options?.limit === 15 ? 15 : 20),
|
|
||||||
}));
|
|
||||||
const importAgent = vi.fn();
|
|
||||||
|
|
||||||
renderSheet(
|
|
||||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
|
||||||
DaemonClient,
|
|
||||||
"fetchRecentProviderSessions" | "importAgent"
|
|
||||||
>,
|
|
||||||
{
|
|
||||||
cwd: null,
|
|
||||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(await screen.findByTestId("import-session-load-more"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
|
|
||||||
providers: ["claude"],
|
|
||||||
limit: 45,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByTestId("import-session-load-more")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stops the refresh spinner and retries only the failed provider", async () => {
|
|
||||||
const fetchRecentProviderSessions = vi.fn(
|
|
||||||
async (options: { providers?: string[] } | undefined) => {
|
|
||||||
const provider = options?.providers?.[0];
|
|
||||||
if (provider === "claude") {
|
|
||||||
throw new Error("claude offline");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
requestId: `recent-${provider}`,
|
|
||||||
entries: [
|
|
||||||
createProviderSessionEntry({
|
|
||||||
providerId: provider ?? "codex",
|
|
||||||
providerHandleId: `${provider}-thread`,
|
|
||||||
title: `Session ${provider}`,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const importAgent = vi.fn();
|
|
||||||
|
|
||||||
renderSheet(
|
|
||||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
|
||||||
DaemonClient,
|
|
||||||
"fetchRecentProviderSessions" | "importAgent"
|
|
||||||
>,
|
|
||||||
{
|
|
||||||
snapshot: {
|
|
||||||
supportsSnapshot: true,
|
|
||||||
entries: [createSnapshotEntry("claude"), createSnapshotEntry("codex")],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await screen.findByText("Session codex");
|
|
||||||
await screen.findByText("Could not load Claude Code sessions");
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByTestId("import-session-loading-spinner")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
fetchRecentProviderSessions.mockClear();
|
|
||||||
fireEvent.click(screen.getByTestId("import-session-retry-claude"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
|
|
||||||
cwd: "/repo/paseo",
|
|
||||||
providers: ["claude"],
|
|
||||||
limit: 15,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows an error row for a provider the daemon reported as failed", async () => {
|
|
||||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
|
||||||
requestId: "recent-provider-sessions",
|
|
||||||
entries: [createProviderSessionEntry({ providerId: "codex", providerLabel: "Codex" })],
|
|
||||||
providerErrors: [{ provider: "codex", message: "timed out" }],
|
|
||||||
}));
|
|
||||||
const importAgent = vi.fn();
|
|
||||||
|
|
||||||
renderSheet(
|
|
||||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
|
||||||
DaemonClient,
|
|
||||||
"fetchRecentProviderSessions" | "importAgent"
|
|
||||||
>,
|
|
||||||
{
|
|
||||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("codex")] },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await screen.findByText("Could not load Codex sessions");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("imports a foreign-directory row without the current workspace once Show all is on", async () => {
|
|
||||||
const fetchRecentProviderSessions = vi.fn(async (options: { cwd?: string } | undefined) => ({
|
|
||||||
requestId: "recent-provider-sessions",
|
|
||||||
entries: [
|
|
||||||
createProviderSessionEntry({
|
|
||||||
providerId: "claude",
|
|
||||||
providerHandleId: options?.cwd ? "scoped-thread" : "foreign-thread",
|
|
||||||
cwd: options?.cwd ?? "/home/me/work/other-project",
|
|
||||||
title: options?.cwd ? "Scoped session" : "Foreign session",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported"));
|
|
||||||
const onImported = vi.fn();
|
|
||||||
const onImportedAgent = vi.fn();
|
|
||||||
|
|
||||||
renderSheet(
|
|
||||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
|
||||||
DaemonClient,
|
|
||||||
"fetchRecentProviderSessions" | "importAgent"
|
|
||||||
>,
|
|
||||||
{
|
|
||||||
workspaceId: "ws-current",
|
|
||||||
onImported,
|
|
||||||
onImportedAgent,
|
|
||||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await screen.findByText("Scoped session");
|
|
||||||
fireEvent.click(screen.getByTestId("import-session-show-all"));
|
|
||||||
|
|
||||||
await screen.findByText("Foreign session");
|
|
||||||
expect(fetchRecentProviderSessions).toHaveBeenCalledWith({
|
|
||||||
providers: ["claude"],
|
|
||||||
limit: 15,
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("import-session-session-claude-foreign-thread"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(importAgent).toHaveBeenCalledWith({
|
|
||||||
providerId: "claude",
|
|
||||||
providerHandleId: "foreign-thread",
|
|
||||||
cwd: "/home/me/work/other-project",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
expect(onImported).toHaveBeenCalledWith(expect.objectContaining({ id: "agent-imported" }));
|
|
||||||
expect(onImportedAgent).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refetches sessions when the refresh button is clicked", async () => {
|
it("refetches sessions when the refresh button is clicked", async () => {
|
||||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||||
requestId: "recent-provider-sessions",
|
requestId: "recent-provider-sessions",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
|
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
|
||||||
import { keepPreviousData, useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type {
|
import type {
|
||||||
DaemonClient,
|
DaemonClient,
|
||||||
@@ -10,33 +10,22 @@ import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
|||||||
import { ChevronDown, Inbox, Layers, RotateCw } from "lucide-react-native";
|
import { ChevronDown, Inbox, Layers, RotateCw } from "lucide-react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { formatTimeAgo } from "@/utils/time";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
|
||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import { useHostProjects } from "@/projects/host-projects";
|
|
||||||
import { useHostFeature } from "@/runtime/host-features";
|
import { useHostFeature } from "@/runtime/host-features";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
|
||||||
import { i18n } from "@/i18n/i18next";
|
import { i18n } from "@/i18n/i18next";
|
||||||
import {
|
import {
|
||||||
aggregateSessionEntries,
|
aggregateSessionEntries,
|
||||||
ALL_FILTER_VALUE,
|
ALL_FILTER_VALUE,
|
||||||
buildProviderLabelMap,
|
buildProviderLabelMap,
|
||||||
collectProviderErrorRows,
|
collectErroredProviderLabels,
|
||||||
computeEmptyState,
|
computeEmptyState,
|
||||||
type DirectoryProject,
|
|
||||||
formatDirectoryLabel,
|
|
||||||
getPromptPreview,
|
getPromptPreview,
|
||||||
getSessionTitle,
|
getSessionTitle,
|
||||||
hasMoreSessions,
|
|
||||||
resolveDirectoryLabel,
|
|
||||||
nextPageLimit,
|
|
||||||
PER_PROVIDER_LIMIT,
|
PER_PROVIDER_LIMIT,
|
||||||
type ProviderErrorRow,
|
|
||||||
resolveImportTarget,
|
|
||||||
resolveProvidersToFetch,
|
resolveProvidersToFetch,
|
||||||
requiresImportSessionsHostUpgrade,
|
requiresImportSessionsHostUpgrade,
|
||||||
sumFilteredAlreadyImportedCount,
|
sumFilteredAlreadyImportedCount,
|
||||||
@@ -44,8 +33,6 @@ import {
|
|||||||
|
|
||||||
const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"];
|
const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"];
|
||||||
const DISABLED_ACCESSIBILITY_STATE = { disabled: true };
|
const DISABLED_ACCESSIBILITY_STATE = { disabled: true };
|
||||||
/** Long enough that a typed word is one request, short enough to feel live. */
|
|
||||||
const SEARCH_DEBOUNCE_MS = 200;
|
|
||||||
|
|
||||||
type RecentProviderSessionsClient = Pick<
|
type RecentProviderSessionsClient = Pick<
|
||||||
DaemonClient,
|
DaemonClient,
|
||||||
@@ -61,9 +48,7 @@ interface ImportSessionSheetProps {
|
|||||||
cwd?: string | null;
|
cwd?: string | null;
|
||||||
workspaceId?: string | null;
|
workspaceId?: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** The agent belongs to the workspace the sheet was opened from; open it here. */
|
|
||||||
onImportedAgent?: (agentId: string) => void;
|
onImportedAgent?: (agentId: string) => void;
|
||||||
/** The agent belongs to its own workspace; open that workspace and navigate. */
|
|
||||||
onImported?: (agent: ImportedAgent) => void;
|
onImported?: (agent: ImportedAgent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,51 +56,27 @@ type RecentSessionsResponse = Awaited<
|
|||||||
ReturnType<RecentProviderSessionsClient["fetchRecentProviderSessions"]>
|
ReturnType<RecentProviderSessionsClient["fetchRecentProviderSessions"]>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type SessionsQueryKey = ReadonlyArray<string | number | null>;
|
|
||||||
|
|
||||||
function buildSessionsQueryKey(input: {
|
|
||||||
cwd: string | null;
|
|
||||||
query: string;
|
|
||||||
limit: number;
|
|
||||||
provider?: string;
|
|
||||||
}): SessionsQueryKey {
|
|
||||||
return [
|
|
||||||
"recent-provider-sessions",
|
|
||||||
input.cwd,
|
|
||||||
input.query,
|
|
||||||
input.limit,
|
|
||||||
...(input.provider === undefined ? [] : [input.provider]),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionsQueryConfig {
|
interface SessionsQueryConfig {
|
||||||
queryKey: SessionsQueryKey;
|
queryKey: ReadonlyArray<string | null>;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
// A provider that cannot answer stays broken until the user asks again. React
|
|
||||||
// Query's default retry would keep the header spinner turning and pile up
|
|
||||||
// in-flight daemon requests behind a dead provider (#2512).
|
|
||||||
retry: false;
|
|
||||||
placeholderData: typeof keepPreviousData;
|
|
||||||
queryFn: () => Promise<RecentSessionsResponse>;
|
queryFn: () => Promise<RecentSessionsResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSessionsQueriesConfig(args: {
|
function buildSessionsQueriesConfig(args: {
|
||||||
providersToFetch: AgentProvider[] | null;
|
providersToFetch: AgentProvider[] | null;
|
||||||
|
sessionsQueryRoot: ReadonlyArray<string | null>;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
client: RecentProviderSessionsClient | null;
|
client: RecentProviderSessionsClient | null;
|
||||||
cwd: string | null;
|
cwd: string | null | undefined;
|
||||||
query: string;
|
|
||||||
limit: number;
|
|
||||||
hostDisconnectedMessage?: string;
|
hostDisconnectedMessage?: string;
|
||||||
}): SessionsQueryConfig[] {
|
}): SessionsQueryConfig[] {
|
||||||
const { providersToFetch, visible, client, cwd, query, limit, hostDisconnectedMessage } = args;
|
const { providersToFetch, sessionsQueryRoot, visible, client, cwd, hostDisconnectedMessage } =
|
||||||
|
args;
|
||||||
if (providersToFetch === null) return [];
|
if (providersToFetch === null) return [];
|
||||||
const enabled = visible && Boolean(client);
|
const enabled = visible && Boolean(client);
|
||||||
return providersToFetch.map((provider) => ({
|
return providersToFetch.map((provider) => ({
|
||||||
queryKey: buildSessionsQueryKey({ cwd, query, limit, provider }),
|
queryKey: [...sessionsQueryRoot, provider],
|
||||||
enabled,
|
enabled,
|
||||||
retry: false as const,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error(hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"));
|
throw new Error(hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"));
|
||||||
@@ -123,8 +84,7 @@ function buildSessionsQueriesConfig(args: {
|
|||||||
return await client.fetchRecentProviderSessions({
|
return await client.fetchRecentProviderSessions({
|
||||||
...(cwd ? { cwd } : {}),
|
...(cwd ? { cwd } : {}),
|
||||||
providers: [provider],
|
providers: [provider],
|
||||||
limit,
|
limit: PER_PROVIDER_LIMIT,
|
||||||
...(query ? { query } : {}),
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -136,6 +96,8 @@ interface SheetStatusMessagesProps {
|
|||||||
hasNoImportableProviders: boolean;
|
hasNoImportableProviders: boolean;
|
||||||
isLoadingSessions: boolean;
|
isLoadingSessions: boolean;
|
||||||
hasRows: boolean;
|
hasRows: boolean;
|
||||||
|
allQueriesErrored: boolean;
|
||||||
|
erroredProviderLabels: ReadonlyArray<string>;
|
||||||
importErrored: boolean;
|
importErrored: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +107,8 @@ function SheetStatusMessages({
|
|||||||
hasNoImportableProviders,
|
hasNoImportableProviders,
|
||||||
isLoadingSessions,
|
isLoadingSessions,
|
||||||
hasRows,
|
hasRows,
|
||||||
|
allQueriesErrored,
|
||||||
|
erroredProviderLabels,
|
||||||
importErrored,
|
importErrored,
|
||||||
}: SheetStatusMessagesProps) {
|
}: SheetStatusMessagesProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -166,6 +130,16 @@ function SheetStatusMessages({
|
|||||||
<Text style={styles.statusText}>{t("importSession.status.loading")}</Text>
|
<Text style={styles.statusText}>{t("importSession.status.loading")}</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
{allQueriesErrored ? (
|
||||||
|
<Text style={styles.statusText}>{t("importSession.status.failedAll")}</Text>
|
||||||
|
) : null}
|
||||||
|
{!allQueriesErrored && erroredProviderLabels.length > 0 ? (
|
||||||
|
<Text style={styles.statusText}>
|
||||||
|
{t("importSession.status.failedProviders", {
|
||||||
|
providers: erroredProviderLabels.join(", "),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
{importErrored ? (
|
{importErrored ? (
|
||||||
<Text style={styles.statusText}>{t("importSession.status.failedImport")}</Text>
|
<Text style={styles.statusText}>{t("importSession.status.failedImport")}</Text>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -173,48 +147,6 @@ function SheetStatusMessages({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProviderErrorBanner({
|
|
||||||
rows,
|
|
||||||
onRetry,
|
|
||||||
}: {
|
|
||||||
rows: ReadonlyArray<ProviderErrorRow>;
|
|
||||||
onRetry: (provider: string) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<View style={styles.errorBanner} testID="import-session-provider-errors">
|
|
||||||
{rows.map((row) => (
|
|
||||||
<ProviderErrorBannerRow key={row.provider} row={row} onRetry={onRetry} />
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProviderErrorBannerRow({
|
|
||||||
row,
|
|
||||||
onRetry,
|
|
||||||
}: {
|
|
||||||
row: ProviderErrorRow;
|
|
||||||
onRetry: (provider: string) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const handleRetry = useCallback(() => onRetry(row.provider), [onRetry, row.provider]);
|
|
||||||
return (
|
|
||||||
<View style={styles.errorRow}>
|
|
||||||
<Text style={styles.errorText}>
|
|
||||||
{t("importSession.status.failedProvider", { provider: row.label })}
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="xs"
|
|
||||||
onPress={handleRetry}
|
|
||||||
testID={`import-session-retry-${row.provider}`}
|
|
||||||
>
|
|
||||||
{t("common.actions.retry")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) {
|
function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -245,38 +177,6 @@ function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPre
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScopeSubtitle({
|
|
||||||
hostLabel,
|
|
||||||
isScoped,
|
|
||||||
onShowAll,
|
|
||||||
}: {
|
|
||||||
hostLabel: string;
|
|
||||||
isScoped: boolean;
|
|
||||||
onShowAll: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<View style={styles.subtitleRow}>
|
|
||||||
<Text style={styles.subtitleText} numberOfLines={1} testID="import-session-scope">
|
|
||||||
{isScoped
|
|
||||||
? t("importSession.scope.workspace")
|
|
||||||
: t("importSession.scope.host", { host: hostLabel })}
|
|
||||||
</Text>
|
|
||||||
{isScoped ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="xs"
|
|
||||||
onPress={onShowAll}
|
|
||||||
testID="import-session-show-all"
|
|
||||||
style={styles.subtitleAction}
|
|
||||||
>
|
|
||||||
{t("importSession.actions.showAll")}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetEmptyState({ title }: { title: string }) {
|
function SheetEmptyState({ title }: { title: string }) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
return (
|
return (
|
||||||
@@ -293,14 +193,13 @@ function ImportSessionSheetRow({
|
|||||||
entry,
|
entry,
|
||||||
disabled,
|
disabled,
|
||||||
importing,
|
importing,
|
||||||
folder,
|
showCwd,
|
||||||
onImportSession,
|
onImportSession,
|
||||||
}: {
|
}: {
|
||||||
entry: FetchRecentProviderSessionEntry;
|
entry: FetchRecentProviderSessionEntry;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
importing: boolean;
|
importing: boolean;
|
||||||
/** The row's directory, shown only when rows can come from more than one. */
|
showCwd: boolean;
|
||||||
folder: string | null;
|
|
||||||
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
|
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -349,13 +248,9 @@ function ImportSessionSheetRow({
|
|||||||
<Text style={styles.rowPreview} numberOfLines={2}>
|
<Text style={styles.rowPreview} numberOfLines={2}>
|
||||||
{promptPreview}
|
{promptPreview}
|
||||||
</Text>
|
</Text>
|
||||||
{folder ? (
|
{showCwd && entry.cwd ? (
|
||||||
<Text
|
<Text style={styles.rowCwd} numberOfLines={1}>
|
||||||
style={styles.rowFolder}
|
{entry.cwd}
|
||||||
numberOfLines={1}
|
|
||||||
testID={`import-session-row-folder-${entry.providerId}-${entry.providerHandleId}`}
|
|
||||||
>
|
|
||||||
{folder}
|
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -363,35 +258,6 @@ function ImportSessionSheetRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SessionRows({
|
|
||||||
entries,
|
|
||||||
disabled,
|
|
||||||
importingSessionKey,
|
|
||||||
resolveFolder,
|
|
||||||
onImportSession,
|
|
||||||
}: {
|
|
||||||
entries: ReadonlyArray<FetchRecentProviderSessionEntry>;
|
|
||||||
disabled: boolean;
|
|
||||||
importingSessionKey: string | null;
|
|
||||||
resolveFolder: (entry: FetchRecentProviderSessionEntry) => string | null;
|
|
||||||
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<View style={styles.list}>
|
|
||||||
{entries.map((entry) => (
|
|
||||||
<ImportSessionSheetRow
|
|
||||||
key={`${entry.providerId}:${entry.providerHandleId}`}
|
|
||||||
entry={entry}
|
|
||||||
disabled={disabled}
|
|
||||||
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
|
|
||||||
folder={resolveFolder(entry)}
|
|
||||||
onImportSession={onImportSession}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ImportSessionSheet({
|
export function ImportSessionSheet({
|
||||||
visible,
|
visible,
|
||||||
client,
|
client,
|
||||||
@@ -406,32 +272,8 @@ export function ImportSessionSheet({
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
|
|
||||||
// "Show all" widens a workspace-scoped sheet to the whole host. The sheet's own
|
|
||||||
// `cwd` stays the scope it was opened with, because that is what decides where
|
|
||||||
// an imported agent lands.
|
|
||||||
const [isShowingAllDirectories, setIsShowingAllDirectories] = useState(false);
|
|
||||||
const [searchInput, setSearchInput] = useState("");
|
|
||||||
const [pageLimit, setPageLimit] = useState(PER_PROVIDER_LIMIT);
|
|
||||||
const [selectedProvider, setSelectedProvider] = useState<string>(ALL_FILTER_VALUE);
|
|
||||||
|
|
||||||
const scopeCwd = isShowingAllDirectories ? null : (cwd ?? null);
|
|
||||||
const supportsSearch = useHostFeature(serverId, "importSessionSearch");
|
|
||||||
const query = useDebouncedValue(supportsSearch ? searchInput : "", SEARCH_DEBOUNCE_MS).trim();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) return;
|
|
||||||
setIsShowingAllDirectories(false);
|
|
||||||
setSearchInput("");
|
|
||||||
}, [visible]);
|
|
||||||
|
|
||||||
// A narrower or wider list starts at page one; keeping a grown limit would
|
|
||||||
// fetch 200 rows for a query that matches three.
|
|
||||||
useEffect(() => {
|
|
||||||
setPageLimit(PER_PROVIDER_LIMIT);
|
|
||||||
}, [query, scopeCwd]);
|
|
||||||
|
|
||||||
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
|
const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, {
|
||||||
cwd: scopeCwd,
|
cwd,
|
||||||
enabled: visible,
|
enabled: visible,
|
||||||
});
|
});
|
||||||
const supportsWorkspaceTarget = useHostFeature(serverId, "importSessionWorkspaceTarget");
|
const supportsWorkspaceTarget = useHostFeature(serverId, "importSessionWorkspaceTarget");
|
||||||
@@ -451,20 +293,22 @@ export function ImportSessionSheet({
|
|||||||
[snapshotEntries],
|
[snapshotEntries],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sessionsQueryRoot = useMemo(() => ["recent-provider-sessions", scopeCwd], [scopeCwd]);
|
const sessionsQueryRoot = useMemo(
|
||||||
|
() => ["recent-provider-sessions", cwd ?? null] as const,
|
||||||
|
[cwd],
|
||||||
|
);
|
||||||
|
|
||||||
const queriesConfig = useMemo(
|
const queriesConfig = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildSessionsQueriesConfig({
|
buildSessionsQueriesConfig({
|
||||||
providersToFetch,
|
providersToFetch,
|
||||||
|
sessionsQueryRoot,
|
||||||
visible,
|
visible,
|
||||||
client,
|
client,
|
||||||
cwd: scopeCwd,
|
cwd,
|
||||||
query,
|
|
||||||
limit: pageLimit,
|
|
||||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||||
}),
|
}),
|
||||||
[providersToFetch, visible, client, scopeCwd, query, pageLimit, t],
|
[providersToFetch, sessionsQueryRoot, visible, client, cwd, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const queries = useQueries({ queries: queriesConfig });
|
const queries = useQueries({ queries: queriesConfig });
|
||||||
@@ -476,6 +320,8 @@ export function ImportSessionSheet({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const filterProviders = useMemo(() => [...(providersToFetch ?? [])].sort(), [providersToFetch]);
|
const filterProviders = useMemo(() => [...(providersToFetch ?? [])].sort(), [providersToFetch]);
|
||||||
|
|
||||||
|
const [selectedProvider, setSelectedProvider] = useState<string>(ALL_FILTER_VALUE);
|
||||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||||
const filterAnchorRef = useRef<View>(null);
|
const filterAnchorRef = useRef<View>(null);
|
||||||
|
|
||||||
@@ -493,27 +339,6 @@ export function ImportSessionSheet({
|
|||||||
return aggregatedEntries.filter((entry) => entry.providerId === selectedProvider);
|
return aggregatedEntries.filter((entry) => entry.providerId === selectedProvider);
|
||||||
}, [aggregatedEntries, selectedProvider]);
|
}, [aggregatedEntries, selectedProvider]);
|
||||||
|
|
||||||
const projectServerIds = useMemo(() => (serverId ? [serverId] : []), [serverId]);
|
|
||||||
const hostProjects = useHostProjects(projectServerIds);
|
|
||||||
const directoryProjects = useMemo<DirectoryProject[]>(
|
|
||||||
() =>
|
|
||||||
hostProjects.map((project) => ({
|
|
||||||
rootPath: project.iconWorkingDir,
|
|
||||||
name: project.projectName,
|
|
||||||
})),
|
|
||||||
[hostProjects],
|
|
||||||
);
|
|
||||||
|
|
||||||
// A scoped sheet only lists one directory, so naming it on every row is noise.
|
|
||||||
const showRowFolders = scopeCwd === null;
|
|
||||||
const resolveFolder = useCallback(
|
|
||||||
(entry: FetchRecentProviderSessionEntry) =>
|
|
||||||
showRowFolders
|
|
||||||
? formatDirectoryLabel(resolveDirectoryLabel(entry.cwd, directoryProjects))
|
|
||||||
: null,
|
|
||||||
[showRowFolders, directoryProjects],
|
|
||||||
);
|
|
||||||
|
|
||||||
const filterComboboxOptions = useMemo<ComboboxOption[]>(
|
const filterComboboxOptions = useMemo<ComboboxOption[]>(
|
||||||
() => [
|
() => [
|
||||||
{ id: ALL_FILTER_VALUE, label: t("importSession.filters.all") },
|
{ id: ALL_FILTER_VALUE, label: t("importSession.filters.all") },
|
||||||
@@ -589,31 +414,19 @@ export function ImportSessionSheet({
|
|||||||
if (!entry.cwd) {
|
if (!entry.cwd) {
|
||||||
throw new Error("Session is missing a working directory");
|
throw new Error("Session is missing a working directory");
|
||||||
}
|
}
|
||||||
const target = resolveImportTarget({
|
|
||||||
entryCwd: entry.cwd,
|
|
||||||
workspaceCwd: cwd,
|
|
||||||
workspaceId,
|
|
||||||
isScopedListing: scopeCwd !== null,
|
|
||||||
});
|
|
||||||
const agent = await client.importAgent({
|
const agent = await client.importAgent({
|
||||||
providerId: entry.providerId,
|
providerId: entry.providerId,
|
||||||
providerHandleId: entry.providerHandleId,
|
providerHandleId: entry.providerHandleId,
|
||||||
cwd: entry.cwd,
|
cwd: entry.cwd,
|
||||||
...(target.workspaceId ? { workspaceId: target.workspaceId } : {}),
|
...(workspaceId ? { workspaceId } : {}),
|
||||||
});
|
});
|
||||||
return { agent, target };
|
return agent;
|
||||||
},
|
},
|
||||||
onSuccess: ({ agent, target }) => {
|
onSuccess: async (agent) => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
|
||||||
onClose();
|
onClose();
|
||||||
if (target.crossWorkspace) {
|
onImportedAgent?.(agent.id);
|
||||||
onImported?.(agent);
|
onImported?.(agent);
|
||||||
} else {
|
|
||||||
onImportedAgent?.(agent.id);
|
|
||||||
}
|
|
||||||
void queryClient.invalidateQueries({
|
|
||||||
queryKey: sessionsQueryRoot,
|
|
||||||
refetchType: "none",
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -629,61 +442,23 @@ export function ImportSessionSheet({
|
|||||||
[importMutation],
|
[importMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const providerErrorRows = useMemo(
|
const erroredProviderLabels = useMemo(
|
||||||
() => collectProviderErrorRows(providersToFetch, queries, providerLabelById),
|
() => collectErroredProviderLabels(providersToFetch, queries, providerLabelById),
|
||||||
[queries, providersToFetch, providerLabelById],
|
[queries, providersToFetch, providerLabelById],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Every query settles, errors included, so this stops turning (#2512).
|
const isRefreshing = queries.some((query) => query.isFetching);
|
||||||
const isRefreshing = queries.some((providerQuery) => providerQuery.isFetching);
|
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
void queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
|
void queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
|
||||||
}, [queryClient, sessionsQueryRoot]);
|
}, [queryClient, sessionsQueryRoot]);
|
||||||
|
|
||||||
const handleRetryProvider = useCallback(
|
|
||||||
(provider: string) => {
|
|
||||||
void queryClient.refetchQueries({
|
|
||||||
queryKey: buildSessionsQueryKey({ cwd: scopeCwd, query, limit: pageLimit, provider }),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[pageLimit, query, queryClient, scopeCwd],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleShowAll = useCallback(() => setIsShowingAllDirectories(true), []);
|
|
||||||
const handleLoadMore = useCallback(() => setPageLimit(nextPageLimit), []);
|
|
||||||
|
|
||||||
const hosts = useHosts();
|
|
||||||
const hostLabel = useMemo(
|
|
||||||
() => hosts.find((host) => host.serverId === serverId)?.label ?? serverId ?? "",
|
|
||||||
[hosts, serverId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const header = useMemo<SheetHeader>(
|
const header = useMemo<SheetHeader>(
|
||||||
() => ({
|
() => ({
|
||||||
title: t("importSession.title"),
|
title: t("importSession.title"),
|
||||||
subtitle: (
|
|
||||||
<ScopeSubtitle
|
|
||||||
hostLabel={hostLabel}
|
|
||||||
isScoped={scopeCwd !== null}
|
|
||||||
onShowAll={handleShowAll}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
...(supportsSearch
|
|
||||||
? {
|
|
||||||
search: {
|
|
||||||
onChange: setSearchInput,
|
|
||||||
placeholder: t("importSession.searchPlaceholder"),
|
|
||||||
// The compact sheet keeps its content mounted while hidden, so the
|
|
||||||
// field has to be told to drop the text the state already dropped.
|
|
||||||
resetKey: visible ? "open" : "closed",
|
|
||||||
testID: "import-session-search",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
actions: <RefreshAction isRefreshing={isRefreshing} onPress={handleRefresh} />,
|
actions: <RefreshAction isRefreshing={isRefreshing} onPress={handleRefresh} />,
|
||||||
}),
|
}),
|
||||||
[handleRefresh, handleShowAll, hostLabel, isRefreshing, scopeCwd, supportsSearch, t, visible],
|
[isRefreshing, handleRefresh, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isSnapshotUnsupported = requiresHostUpgrade;
|
const isSnapshotUnsupported = requiresHostUpgrade;
|
||||||
@@ -692,27 +467,22 @@ export function ImportSessionSheet({
|
|||||||
const isQueryingProviders = queries.length > 0;
|
const isQueryingProviders = queries.length > 0;
|
||||||
const isLoadingSessions =
|
const isLoadingSessions =
|
||||||
isWaitingForSnapshot ||
|
isWaitingForSnapshot ||
|
||||||
(isQueryingProviders &&
|
(isQueryingProviders && queries.some((query) => query.isLoading || query.isPending));
|
||||||
queries.some((providerQuery) => providerQuery.isLoading || providerQuery.isPending));
|
const allQueriesErrored = isQueryingProviders && queries.every((query) => query.isError);
|
||||||
const allQueriesErrored =
|
|
||||||
isQueryingProviders && queries.every((providerQuery) => providerQuery.isError);
|
|
||||||
const allQueriesSettled =
|
const allQueriesSettled =
|
||||||
isQueryingProviders &&
|
isQueryingProviders && queries.every((query) => !query.isLoading && !query.isPending);
|
||||||
queries.every((providerQuery) => !providerQuery.isLoading && !providerQuery.isPending);
|
|
||||||
const { showEmptyState, emptyStateTitle } = computeEmptyState({
|
const { showEmptyState, emptyStateTitle } = computeEmptyState({
|
||||||
isLoadingSessions,
|
isLoadingSessions,
|
||||||
allQueriesErrored,
|
allQueriesErrored,
|
||||||
isQueryingProviders,
|
isQueryingProviders,
|
||||||
allQueriesSettled,
|
allQueriesSettled,
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
hasQuery: query.length > 0,
|
|
||||||
aggregatedCount: aggregatedEntries.length,
|
aggregatedCount: aggregatedEntries.length,
|
||||||
visibleCount: visibleEntries.length,
|
visibleCount: visibleEntries.length,
|
||||||
totalAlreadyImportedCount,
|
totalAlreadyImportedCount,
|
||||||
providerLabelById,
|
providerLabelById,
|
||||||
});
|
});
|
||||||
const showFilter = filterProviders.length > 1;
|
const showFilter = filterProviders.length > 1;
|
||||||
const showLoadMore = hasMoreSessions(queries, pageLimit);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdaptiveModalSheet
|
<AdaptiveModalSheet
|
||||||
@@ -766,30 +536,22 @@ export function ImportSessionSheet({
|
|||||||
hasNoImportableProviders={hasNoImportableProviders}
|
hasNoImportableProviders={hasNoImportableProviders}
|
||||||
isLoadingSessions={isLoadingSessions}
|
isLoadingSessions={isLoadingSessions}
|
||||||
hasRows={visibleEntries.length > 0}
|
hasRows={visibleEntries.length > 0}
|
||||||
|
allQueriesErrored={allQueriesErrored}
|
||||||
|
erroredProviderLabels={erroredProviderLabels}
|
||||||
importErrored={importMutation.isError}
|
importErrored={importMutation.isError}
|
||||||
/>
|
/>
|
||||||
{providerErrorRows.length > 0 ? (
|
|
||||||
<ProviderErrorBanner rows={providerErrorRows} onRetry={handleRetryProvider} />
|
|
||||||
) : null}
|
|
||||||
{visibleEntries.length > 0 ? (
|
{visibleEntries.length > 0 ? (
|
||||||
<SessionRows
|
<View style={styles.list}>
|
||||||
entries={visibleEntries}
|
{visibleEntries.map((entry) => (
|
||||||
disabled={importMutation.isPending}
|
<ImportSessionSheetRow
|
||||||
importingSessionKey={importingSessionKey}
|
key={`${entry.providerId}:${entry.providerHandleId}`}
|
||||||
resolveFolder={resolveFolder}
|
entry={entry}
|
||||||
onImportSession={handleImportSession}
|
disabled={importMutation.isPending}
|
||||||
/>
|
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
|
||||||
) : null}
|
showCwd={!cwd}
|
||||||
{showLoadMore ? (
|
onImportSession={handleImportSession}
|
||||||
<View style={styles.footer}>
|
/>
|
||||||
<Button
|
))}
|
||||||
variant="ghost"
|
|
||||||
onPress={handleLoadMore}
|
|
||||||
disabled={isRefreshing}
|
|
||||||
testID="import-session-load-more"
|
|
||||||
>
|
|
||||||
{t("importSession.actions.loadMore")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null}
|
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null}
|
||||||
@@ -798,21 +560,6 @@ export function ImportSessionSheet({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
subtitleRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
minWidth: 0,
|
|
||||||
},
|
|
||||||
subtitleText: {
|
|
||||||
flexShrink: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
subtitleAction: {
|
|
||||||
marginHorizontal: -theme.spacing[2],
|
|
||||||
},
|
|
||||||
filterTriggerWrap: {
|
filterTriggerWrap: {
|
||||||
paddingBottom: theme.spacing[2],
|
paddingBottom: theme.spacing[2],
|
||||||
},
|
},
|
||||||
@@ -821,7 +568,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[1.5],
|
gap: theme.spacing[1.5],
|
||||||
alignSelf: "flex-start",
|
alignSelf: "flex-start",
|
||||||
maxWidth: "100%",
|
|
||||||
paddingVertical: theme.spacing[1.5],
|
paddingVertical: theme.spacing[1.5],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
@@ -836,8 +582,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: theme.colors.surface3,
|
backgroundColor: theme.colors.surface3,
|
||||||
},
|
},
|
||||||
filterTriggerText: {
|
filterTriggerText: {
|
||||||
flexShrink: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: theme.fontWeight.medium,
|
||||||
@@ -845,10 +589,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
list: {
|
list: {
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
},
|
},
|
||||||
footer: {
|
|
||||||
paddingTop: theme.spacing[2],
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
row: {
|
row: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "flex-start",
|
alignItems: "flex-start",
|
||||||
@@ -896,7 +636,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
},
|
},
|
||||||
rowFolder: {
|
rowCwd: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
@@ -910,26 +650,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
},
|
},
|
||||||
errorBanner: {
|
|
||||||
marginBottom: theme.spacing[2],
|
|
||||||
borderWidth: theme.borderWidth[1],
|
|
||||||
borderColor: theme.colors.border,
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
paddingVertical: theme.spacing[1],
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
},
|
|
||||||
errorRow: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
},
|
|
||||||
errorText: {
|
|
||||||
flexShrink: 1,
|
|
||||||
minWidth: 0,
|
|
||||||
color: theme.colors.palette.red[300],
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
emptyState: {
|
emptyState: {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { FolderPlus, GitBranch, Import, Server, Settings, X } from "lucide-react-native";
|
import { FolderPlus, GitBranch, Home, Server, Settings, X } from "lucide-react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -30,7 +30,6 @@ import { Shortcut } from "@/components/ui/shortcut";
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||||
import { useImportSession } from "@/hooks/use-import-session";
|
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import {
|
import {
|
||||||
type SidebarProjectEntry,
|
type SidebarProjectEntry,
|
||||||
@@ -47,7 +46,11 @@ import { usePanelStore } from "@/stores/panel-store";
|
|||||||
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||||
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
|
import {
|
||||||
|
buildOpenProjectRoute,
|
||||||
|
buildSettingsAddHostRoute,
|
||||||
|
buildSettingsRoute,
|
||||||
|
} from "@/utils/host-routes";
|
||||||
import { openHostOverview } from "@/navigation/settings-navigation";
|
import { openHostOverview } from "@/navigation/settings-navigation";
|
||||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||||
@@ -75,7 +78,7 @@ interface SidebarSharedProps {
|
|||||||
toggleProjectCollapsed: (projectViewKey: string) => void;
|
toggleProjectCollapsed: (projectViewKey: string) => void;
|
||||||
handleRefresh: () => void;
|
handleRefresh: () => void;
|
||||||
handleOpenProject: () => void;
|
handleOpenProject: () => void;
|
||||||
handleImportSession: () => void;
|
handleHome: () => void;
|
||||||
handleSettings: () => void;
|
handleSettings: () => void;
|
||||||
labels: SidebarLabels;
|
labels: SidebarLabels;
|
||||||
handleAddHost: () => void;
|
handleAddHost: () => void;
|
||||||
@@ -85,7 +88,7 @@ interface SidebarSharedProps {
|
|||||||
interface SidebarLabels {
|
interface SidebarLabels {
|
||||||
addProject: string;
|
addProject: string;
|
||||||
hosts: string;
|
hosts: string;
|
||||||
importSession: string;
|
home: string;
|
||||||
settings: string;
|
settings: string;
|
||||||
searchHosts: string;
|
searchHosts: string;
|
||||||
closeSidebar: string;
|
closeSidebar: string;
|
||||||
@@ -142,7 +145,6 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
|||||||
}, [isRevalidating, isManualRefresh]);
|
}, [isRevalidating, isManualRefresh]);
|
||||||
|
|
||||||
const openProjectPicker = useOpenAddProject();
|
const openProjectPicker = useOpenAddProject();
|
||||||
const { open: openImportSession, sheet: importSessionSheet } = useImportSession();
|
|
||||||
|
|
||||||
const handleOpenProjectMobile = useCallback(() => {
|
const handleOpenProjectMobile = useCallback(() => {
|
||||||
showMobileAgent();
|
showMobileAgent();
|
||||||
@@ -183,16 +185,20 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
|||||||
openHostOverview(serverId);
|
openHostOverview(serverId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleImportSessionMobile = useCallback(() => {
|
const handleHomeMobile = useCallback(() => {
|
||||||
showMobileAgent();
|
showMobileAgent();
|
||||||
openImportSession();
|
router.push(buildOpenProjectRoute());
|
||||||
}, [openImportSession, showMobileAgent]);
|
}, [showMobileAgent]);
|
||||||
|
|
||||||
|
const handleHomeDesktop = useCallback(() => {
|
||||||
|
router.push(buildOpenProjectRoute());
|
||||||
|
}, []);
|
||||||
|
|
||||||
const labels = useMemo(
|
const labels = useMemo(
|
||||||
(): SidebarLabels => ({
|
(): SidebarLabels => ({
|
||||||
addProject: t("sidebar.actions.addProject"),
|
addProject: t("sidebar.actions.addProject"),
|
||||||
hosts: t("sidebar.actions.hosts"),
|
hosts: t("sidebar.actions.hosts"),
|
||||||
importSession: t("importSession.title"),
|
home: t("sidebar.actions.home"),
|
||||||
settings: t("sidebar.actions.settings"),
|
settings: t("sidebar.actions.settings"),
|
||||||
searchHosts: t("sidebar.host.searchPlaceholder"),
|
searchHosts: t("sidebar.host.searchPlaceholder"),
|
||||||
closeSidebar: t("sidebar.actions.closeSidebar"),
|
closeSidebar: t("sidebar.actions.closeSidebar"),
|
||||||
@@ -222,42 +228,36 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
|||||||
|
|
||||||
if (isCompactLayout) {
|
if (isCompactLayout) {
|
||||||
return (
|
return (
|
||||||
<>
|
<RetainedPanelActivity active={active}>
|
||||||
<RetainedPanelActivity active={active}>
|
<MobileSidebar
|
||||||
<MobileSidebar
|
{...sharedProps}
|
||||||
{...sharedProps}
|
active={active}
|
||||||
active={active}
|
insetsTop={insets.top}
|
||||||
insetsTop={insets.top}
|
insetsBottom={insets.bottom}
|
||||||
insetsBottom={insets.bottom}
|
closeSidebar={showMobileAgent}
|
||||||
closeSidebar={showMobileAgent}
|
handleOpenProject={handleOpenProjectMobile}
|
||||||
handleOpenProject={handleOpenProjectMobile}
|
handleHome={handleHomeMobile}
|
||||||
handleImportSession={handleImportSessionMobile}
|
handleSettings={handleSettingsMobile}
|
||||||
handleSettings={handleSettingsMobile}
|
handleAddHost={handleAddHostMobile}
|
||||||
handleAddHost={handleAddHostMobile}
|
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
||||||
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
/>
|
||||||
/>
|
</RetainedPanelActivity>
|
||||||
</RetainedPanelActivity>
|
|
||||||
{importSessionSheet}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<RetainedPanelActivity active={active}>
|
||||||
<RetainedPanelActivity active={active}>
|
<DesktopSidebar
|
||||||
<DesktopSidebar
|
{...sharedProps}
|
||||||
{...sharedProps}
|
insetsTop={insets.top}
|
||||||
insetsTop={insets.top}
|
active={active}
|
||||||
active={active}
|
handleOpenProject={handleOpenProjectDesktop}
|
||||||
handleOpenProject={handleOpenProjectDesktop}
|
handleHome={handleHomeDesktop}
|
||||||
handleImportSession={openImportSession}
|
handleSettings={handleSettingsDesktop}
|
||||||
handleSettings={handleSettingsDesktop}
|
handleAddHost={handleAddHostDesktop}
|
||||||
handleAddHost={handleAddHostDesktop}
|
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
||||||
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
/>
|
||||||
/>
|
</RetainedPanelActivity>
|
||||||
</RetainedPanelActivity>
|
|
||||||
{importSessionSheet}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -444,7 +444,7 @@ function IconTooltipContent({
|
|||||||
function SidebarFooter({
|
function SidebarFooter({
|
||||||
theme,
|
theme,
|
||||||
handleOpenProject,
|
handleOpenProject,
|
||||||
handleImportSession,
|
handleHome,
|
||||||
handleSettings,
|
handleSettings,
|
||||||
labels,
|
labels,
|
||||||
handleAddHost,
|
handleAddHost,
|
||||||
@@ -452,12 +452,12 @@ function SidebarFooter({
|
|||||||
}: {
|
}: {
|
||||||
theme: SidebarTheme;
|
theme: SidebarTheme;
|
||||||
handleOpenProject: () => void;
|
handleOpenProject: () => void;
|
||||||
handleImportSession: () => void;
|
handleHome: () => void;
|
||||||
handleSettings: () => void;
|
handleSettings: () => void;
|
||||||
labels: {
|
labels: {
|
||||||
addProject: string;
|
addProject: string;
|
||||||
hosts: string;
|
hosts: string;
|
||||||
importSession: string;
|
home: string;
|
||||||
settings: string;
|
settings: string;
|
||||||
searchHosts: string;
|
searchHosts: string;
|
||||||
};
|
};
|
||||||
@@ -483,10 +483,10 @@ function SidebarFooter({
|
|||||||
onOpenHostSettings={handleOpenHostSettings}
|
onOpenHostSettings={handleOpenHostSettings}
|
||||||
/>
|
/>
|
||||||
<FooterIconButton
|
<FooterIconButton
|
||||||
onPress={handleImportSession}
|
onPress={handleHome}
|
||||||
testID="sidebar-import-session"
|
testID="sidebar-home"
|
||||||
label={labels.importSession}
|
label={labels.home}
|
||||||
icon={Import}
|
icon={Home}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
<SidebarHelpMenu />
|
<SidebarHelpMenu />
|
||||||
@@ -522,7 +522,7 @@ function MobileSidebar({
|
|||||||
toggleProjectCollapsed,
|
toggleProjectCollapsed,
|
||||||
handleRefresh,
|
handleRefresh,
|
||||||
handleOpenProject,
|
handleOpenProject,
|
||||||
handleImportSession,
|
handleHome,
|
||||||
handleSettings,
|
handleSettings,
|
||||||
labels,
|
labels,
|
||||||
handleAddHost,
|
handleAddHost,
|
||||||
@@ -595,7 +595,6 @@ function MobileSidebar({
|
|||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onWorkspacePress={handleWorkspacePress}
|
onWorkspacePress={handleWorkspacePress}
|
||||||
onAddProject={handleOpenProject}
|
onAddProject={handleOpenProject}
|
||||||
onImportSession={handleImportSession}
|
|
||||||
parentGestureRef={closeGestureRef}
|
parentGestureRef={closeGestureRef}
|
||||||
dragGestureHostActive={active}
|
dragGestureHostActive={active}
|
||||||
listHeaderComponent={workspacesSectionHeaderElement}
|
listHeaderComponent={workspacesSectionHeaderElement}
|
||||||
@@ -605,7 +604,7 @@ function MobileSidebar({
|
|||||||
<SidebarFooter
|
<SidebarFooter
|
||||||
theme={theme}
|
theme={theme}
|
||||||
handleOpenProject={handleOpenProject}
|
handleOpenProject={handleOpenProject}
|
||||||
handleImportSession={handleImportSession}
|
handleHome={handleHome}
|
||||||
handleSettings={handleSettings}
|
handleSettings={handleSettings}
|
||||||
labels={labels}
|
labels={labels}
|
||||||
handleAddHost={handleAddHost}
|
handleAddHost={handleAddHost}
|
||||||
@@ -634,7 +633,7 @@ function DesktopSidebar({
|
|||||||
toggleProjectCollapsed,
|
toggleProjectCollapsed,
|
||||||
handleRefresh,
|
handleRefresh,
|
||||||
handleOpenProject,
|
handleOpenProject,
|
||||||
handleImportSession,
|
handleHome,
|
||||||
handleSettings,
|
handleSettings,
|
||||||
labels,
|
labels,
|
||||||
handleAddHost,
|
handleAddHost,
|
||||||
@@ -772,7 +771,6 @@ function DesktopSidebar({
|
|||||||
isRefreshing={isManualRefresh && isRevalidating}
|
isRefreshing={isManualRefresh && isRevalidating}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onAddProject={handleOpenProject}
|
onAddProject={handleOpenProject}
|
||||||
onImportSession={handleImportSession}
|
|
||||||
listHeaderComponent={workspacesSectionHeaderElement}
|
listHeaderComponent={workspacesSectionHeaderElement}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -782,7 +780,7 @@ function DesktopSidebar({
|
|||||||
<SidebarFooter
|
<SidebarFooter
|
||||||
theme={theme}
|
theme={theme}
|
||||||
handleOpenProject={handleOpenProject}
|
handleOpenProject={handleOpenProject}
|
||||||
handleImportSession={handleImportSession}
|
handleHome={handleHome}
|
||||||
handleSettings={handleSettings}
|
handleSettings={handleSettings}
|
||||||
labels={labels}
|
labels={labels}
|
||||||
handleAddHost={handleAddHost}
|
handleAddHost={handleAddHost}
|
||||||
|
|||||||
@@ -54,11 +54,6 @@ const html = `<!doctype html>
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
/* The host sizes this frame to the diagram, so the diagram fills the frame. */
|
|
||||||
#diagram {
|
|
||||||
height: 100%;
|
|
||||||
}
|
}
|
||||||
#diagram svg {
|
#diagram svg {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -1,140 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
||||||
import { Modal, View } from "react-native";
|
|
||||||
import { X } from "lucide-react-native";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { ZoomableViewport } from "@/components/zoomable-viewport";
|
|
||||||
import { SPACING } from "@/styles/theme";
|
|
||||||
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
|
||||||
import { MermaidIframeRuntime, type MermaidRenderedMessage } from "./iframe-runtime.web";
|
|
||||||
import type { DiagramColorScheme } from "./render-model";
|
|
||||||
import { useMermaidRenderModel } from "./use-render-model";
|
|
||||||
|
|
||||||
interface MermaidFullscreenViewerProps {
|
|
||||||
source: string;
|
|
||||||
colorScheme: DiagramColorScheme;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The diagram is already in the render cache, so the viewport knows its size before the
|
|
||||||
// fullscreen iframe has re-rendered it.
|
|
||||||
const FALLBACK_SIZE = { width: 240, height: 240 };
|
|
||||||
const VIEWER_FIT = { padding: SPACING[6] };
|
|
||||||
|
|
||||||
export function MermaidFullscreenViewer({
|
|
||||||
source,
|
|
||||||
colorScheme,
|
|
||||||
onClose,
|
|
||||||
}: MermaidFullscreenViewerProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { state, request, rendered, renderFailed } = useMermaidRenderModel({
|
|
||||||
source,
|
|
||||||
phase: "complete",
|
|
||||||
colorScheme,
|
|
||||||
});
|
|
||||||
const handleRendered = useCallback(
|
|
||||||
(message: MermaidRenderedMessage) => {
|
|
||||||
rendered({
|
|
||||||
revision: message.revision,
|
|
||||||
source: message.source,
|
|
||||||
colorScheme: message.colorScheme,
|
|
||||||
dimensions: { height: message.height, width: message.width },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[rendered],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleKeydown(event: KeyboardEvent): void {
|
|
||||||
if (event.key === "Escape") onClose();
|
|
||||||
}
|
|
||||||
window.addEventListener("keydown", handleKeydown);
|
|
||||||
return () => window.removeEventListener("keydown", handleKeydown);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
// The Modal focus trap walks descendants and focuses the first one that takes it. Focus the
|
|
||||||
// content layer ourselves (this effect runs after the trap's) so keystrokes stay in this
|
|
||||||
// document instead of disappearing into a focused diagram iframe.
|
|
||||||
const contentLayerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
contentLayerRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const visible = state.visible;
|
|
||||||
const contentSize = useMemo(
|
|
||||||
() => (visible ? { width: visible.width, height: visible.height } : FALLBACK_SIZE),
|
|
||||||
[visible],
|
|
||||||
);
|
|
||||||
const actions = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
icon: X,
|
|
||||||
label: t("common.actions.close"),
|
|
||||||
onPress: onClose,
|
|
||||||
testID: "mermaid-fullscreen-close",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[onClose, t],
|
|
||||||
);
|
|
||||||
const contentLayerStyle = useMemo(
|
|
||||||
() => [
|
|
||||||
styles.contentLayer,
|
|
||||||
{
|
|
||||||
paddingTop: insets.top,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
paddingBottom: insets.bottom,
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[insets.bottom, insets.left, insets.right, insets.top],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
|
||||||
<WindowChromeRootRegion corners="both">
|
|
||||||
<View style={styles.root}>
|
|
||||||
<View style={styles.backdrop} />
|
|
||||||
<View style={contentLayerStyle}>
|
|
||||||
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: focus holder for the Escape shortcut */}
|
|
||||||
<div ref={contentLayerRef} style={focusLayerDomStyle} tabIndex={-1}>
|
|
||||||
<ZoomableViewport
|
|
||||||
accessibilityLabel={t("message.diagram.diagram")}
|
|
||||||
actions={actions}
|
|
||||||
contentSize={contentSize}
|
|
||||||
fit={VIEWER_FIT}
|
|
||||||
minScale={1}
|
|
||||||
onPressOutsideContent={onClose}
|
|
||||||
style={styles.viewport}
|
|
||||||
testID="mermaid-fullscreen-viewport"
|
|
||||||
wheelActivation="always"
|
|
||||||
>
|
|
||||||
<MermaidIframeRuntime
|
|
||||||
request={request}
|
|
||||||
onRendered={handleRendered}
|
|
||||||
onRenderFailed={renderFailed}
|
|
||||||
/>
|
|
||||||
</ZoomableViewport>
|
|
||||||
</div>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</WindowChromeRootRegion>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const focusLayerDomStyle: React.CSSProperties = {
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
height: "100%",
|
|
||||||
minHeight: 0,
|
|
||||||
outline: "none",
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
|
||||||
root: { flex: 1, minHeight: 0, minWidth: 0 },
|
|
||||||
backdrop: { position: "absolute", inset: 0, backgroundColor: theme.colors.surface0 },
|
|
||||||
contentLayer: { position: "absolute", inset: 0 },
|
|
||||||
viewport: { flex: 1 },
|
|
||||||
}));
|
|
||||||
@@ -1,17 +1,104 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Pressable, View, type TextStyle, type ViewStyle } from "react-native";
|
import { Pressable, View, type TextStyle, type ViewStyle } from "react-native";
|
||||||
import { Code, Maximize2, Workflow } from "lucide-react-native";
|
import { Code, Workflow } from "lucide-react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||||
import { ZoomableViewport } from "@/components/zoomable-viewport";
|
import { ZoomableViewport } from "@/components/zoomable-viewport";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import type { MarkdownFenceRendererProps } from "../types";
|
import type { MarkdownFenceRendererProps } from "../types";
|
||||||
import { MermaidFullscreenViewer } from "./fullscreen-viewer.web";
|
import type { MermaidRenderRequest } from "./render-model";
|
||||||
import { MermaidIframeRuntime, type MermaidRenderedMessage } from "./iframe-runtime.web";
|
import { mermaidRuntimeHtml } from "./runtime/html.gen";
|
||||||
|
import { parseMermaidRuntimeMessage, type MermaidRuntimeRenderMessage } from "./runtime/messages";
|
||||||
|
import { MermaidRuntimeRequestDriver } from "./runtime/request-driver";
|
||||||
import { useMermaidRenderModel } from "./use-render-model";
|
import { useMermaidRenderModel } from "./use-render-model";
|
||||||
import { getDiagramBoxStyle } from "./presentation";
|
import { getDiagramBoxStyle } from "./presentation";
|
||||||
|
|
||||||
|
interface MermaidIframeRuntimeProps {
|
||||||
|
request: MermaidRenderRequest | null;
|
||||||
|
height: React.CSSProperties["height"];
|
||||||
|
onRendered: (message: {
|
||||||
|
revision: number;
|
||||||
|
source: string;
|
||||||
|
colorScheme: "light" | "dark";
|
||||||
|
height: number;
|
||||||
|
width: number;
|
||||||
|
}) => void;
|
||||||
|
onRenderFailed: (revision: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MermaidIframeRuntime({
|
||||||
|
request,
|
||||||
|
height,
|
||||||
|
onRendered,
|
||||||
|
onRenderFailed,
|
||||||
|
}: MermaidIframeRuntimeProps) {
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||||
|
const driverRef = useRef<MermaidRuntimeRequestDriver | null>(null);
|
||||||
|
driverRef.current ??= new MermaidRuntimeRequestDriver();
|
||||||
|
const iframeStyle = useMemo<React.CSSProperties>(
|
||||||
|
() => ({
|
||||||
|
display: "block",
|
||||||
|
width: "100%",
|
||||||
|
height,
|
||||||
|
border: 0,
|
||||||
|
pointerEvents: "none",
|
||||||
|
background: "transparent",
|
||||||
|
}),
|
||||||
|
[height],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendRequest = useCallback((current: MermaidRenderRequest | null) => {
|
||||||
|
const target = iframeRef.current?.contentWindow;
|
||||||
|
if (!current || !target) return;
|
||||||
|
const message: MermaidRuntimeRenderMessage = {
|
||||||
|
type: "render",
|
||||||
|
revision: current.revision,
|
||||||
|
source: current.source,
|
||||||
|
colorScheme: current.colorScheme,
|
||||||
|
interactive: false,
|
||||||
|
};
|
||||||
|
target.postMessage(message, "*");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sendRequest(driverRef.current?.update(request) ?? null);
|
||||||
|
}, [request, sendRequest]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function receiveMessage(event: MessageEvent): void {
|
||||||
|
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||||
|
const message = parseMermaidRuntimeMessage(event.data);
|
||||||
|
if (!message) return;
|
||||||
|
if (message.type === "bridgeReady") {
|
||||||
|
sendRequest(driverRef.current?.ready() ?? null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.type === "renderError") {
|
||||||
|
onRenderFailed(message.revision);
|
||||||
|
sendRequest(driverRef.current?.settled(message.revision, false) ?? null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onRendered(message);
|
||||||
|
sendRequest(driverRef.current?.settled(message.revision, true) ?? null);
|
||||||
|
}
|
||||||
|
window.addEventListener("message", receiveMessage);
|
||||||
|
return () => window.removeEventListener("message", receiveMessage);
|
||||||
|
}, [onRenderFailed, onRendered, sendRequest]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
title=""
|
||||||
|
aria-hidden
|
||||||
|
sandbox="allow-scripts"
|
||||||
|
srcDoc={mermaidRuntimeHtml}
|
||||||
|
tabIndex={-1}
|
||||||
|
style={iframeStyle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface MermaidFenceHostImplProps extends MarkdownFenceRendererProps {
|
interface MermaidFenceHostImplProps extends MarkdownFenceRendererProps {
|
||||||
colorScheme?: "light" | "dark";
|
colorScheme?: "light" | "dark";
|
||||||
}
|
}
|
||||||
@@ -31,13 +118,16 @@ function MermaidFenceHostImpl({
|
|||||||
});
|
});
|
||||||
const [hasRuntimeContent, setHasRuntimeContent] = useState(false);
|
const [hasRuntimeContent, setHasRuntimeContent] = useState(false);
|
||||||
const [showSource, setShowSource] = useState(false);
|
const [showSource, setShowSource] = useState(false);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
||||||
const showSourcePress = useCallback(() => setShowSource(true), []);
|
const showSourcePress = useCallback(() => setShowSource(true), []);
|
||||||
const showDiagramPress = useCallback(() => setShowSource(false), []);
|
const showDiagramPress = useCallback(() => setShowSource(false), []);
|
||||||
const openFullscreen = useCallback(() => setIsFullscreen(true), []);
|
|
||||||
const closeFullscreen = useCallback(() => setIsFullscreen(false), []);
|
|
||||||
const handleRendered = useCallback(
|
const handleRendered = useCallback(
|
||||||
(message: MermaidRenderedMessage) => {
|
(message: {
|
||||||
|
revision: number;
|
||||||
|
source: string;
|
||||||
|
colorScheme: "light" | "dark";
|
||||||
|
height: number;
|
||||||
|
width: number;
|
||||||
|
}) => {
|
||||||
setHasRuntimeContent(true);
|
setHasRuntimeContent(true);
|
||||||
rendered({
|
rendered({
|
||||||
revision: message.revision,
|
revision: message.revision,
|
||||||
@@ -59,14 +149,8 @@ function MermaidFenceHostImpl({
|
|||||||
label: t("message.diagram.viewSource"),
|
label: t("message.diagram.viewSource"),
|
||||||
onPress: showSourcePress,
|
onPress: showSourcePress,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
icon: Maximize2,
|
|
||||||
label: t("message.diagram.fullscreen"),
|
|
||||||
onPress: openFullscreen,
|
|
||||||
testID: "mermaid-fullscreen",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
[openFullscreen, showSourcePress, t],
|
[showSourcePress, t],
|
||||||
);
|
);
|
||||||
const sourceView = useMemo(() => {
|
const sourceView = useMemo(() => {
|
||||||
const { marginTop, marginBottom, marginVertical, ...sourceTextStyle } = textStyle;
|
const { marginTop, marginBottom, marginVertical, ...sourceTextStyle } = textStyle;
|
||||||
@@ -78,11 +162,7 @@ function MermaidFenceHostImpl({
|
|||||||
return { container: [margins, sourceContainerStyle], text };
|
return { container: [margins, sourceContainerStyle], text };
|
||||||
}, [textStyle]);
|
}, [textStyle]);
|
||||||
const diagramStyle = useMemo(
|
const diagramStyle = useMemo(
|
||||||
() => [
|
() => [getDiagramBoxStyle(textStyle), containerStyle, { height: runtimeHeight }],
|
||||||
getDiagramBoxStyle(textStyle),
|
|
||||||
containerStyle,
|
|
||||||
{ height: runtimeHeight, minHeight: MIN_DIAGRAM_BOX_HEIGHT },
|
|
||||||
],
|
|
||||||
[runtimeHeight, textStyle],
|
[runtimeHeight, textStyle],
|
||||||
);
|
);
|
||||||
const diagramSize = useMemo(
|
const diagramSize = useMemo(
|
||||||
@@ -132,40 +212,18 @@ function MermaidFenceHostImpl({
|
|||||||
>
|
>
|
||||||
<MermaidIframeRuntime
|
<MermaidIframeRuntime
|
||||||
request={request}
|
request={request}
|
||||||
|
height="100%"
|
||||||
onRendered={handleRendered}
|
onRendered={handleRendered}
|
||||||
onRenderFailed={renderFailed}
|
onRenderFailed={renderFailed}
|
||||||
/>
|
/>
|
||||||
</ZoomableViewport>
|
</ZoomableViewport>
|
||||||
{isFullscreen && visible ? (
|
|
||||||
<MermaidFullscreenViewer
|
|
||||||
source={visible.source}
|
|
||||||
colorScheme={visible.colorScheme}
|
|
||||||
onClose={closeFullscreen}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const MEASURING_SIZE = { width: 240, height: 240 };
|
const MEASURING_SIZE = { width: 240, height: 240 };
|
||||||
/**
|
|
||||||
* The toolbar overlays the top of the box (8px offset + 32px compact buttons) and the box clips
|
|
||||||
* with `overflow: hidden`, so a shorter box leaves the buttons half-clipped and unclickable.
|
|
||||||
*/
|
|
||||||
const MIN_DIAGRAM_BOX_HEIGHT = 56;
|
|
||||||
const sourceContainerStyle: ViewStyle = { position: "relative" };
|
const sourceContainerStyle: ViewStyle = { position: "relative" };
|
||||||
/**
|
const containerStyle: ViewStyle = { overflow: "hidden", position: "relative" };
|
||||||
* The viewport's own root is `flex: 1`, so as a flex item it has `flex-basis: 0%` and that basis
|
|
||||||
* replaces the height below. In the markdown column there is no free space to grow into, so the
|
|
||||||
* box collapses and the diagram is scaled down to fit a couple of pixels. Size it from the height.
|
|
||||||
*/
|
|
||||||
const containerStyle: ViewStyle = {
|
|
||||||
flexBasis: "auto",
|
|
||||||
flexGrow: 0,
|
|
||||||
flexShrink: 0,
|
|
||||||
overflow: "hidden",
|
|
||||||
position: "relative",
|
|
||||||
};
|
|
||||||
const measuringStyle: ViewStyle = {
|
const measuringStyle: ViewStyle = {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: 0,
|
left: 0,
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
|
||||||
import type { DiagramColorScheme, MermaidRenderRequest } from "./render-model";
|
|
||||||
import { mermaidRuntimeHtml } from "./runtime/html.gen";
|
|
||||||
import { parseMermaidRuntimeMessage, type MermaidRuntimeRenderMessage } from "./runtime/messages";
|
|
||||||
import { MermaidRuntimeRequestDriver } from "./runtime/request-driver";
|
|
||||||
|
|
||||||
export interface MermaidRenderedMessage {
|
|
||||||
revision: number;
|
|
||||||
source: string;
|
|
||||||
colorScheme: DiagramColorScheme;
|
|
||||||
height: number;
|
|
||||||
width: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MermaidIframeRuntimeProps {
|
|
||||||
request: MermaidRenderRequest | null;
|
|
||||||
onRendered: (message: MermaidRenderedMessage) => void;
|
|
||||||
onRenderFailed: (revision: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sandboxed Mermaid renderer. Sizing and gestures belong to the surrounding viewport. */
|
|
||||||
export function MermaidIframeRuntime({
|
|
||||||
request,
|
|
||||||
onRendered,
|
|
||||||
onRenderFailed,
|
|
||||||
}: MermaidIframeRuntimeProps) {
|
|
||||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
||||||
const driverRef = useRef<MermaidRuntimeRequestDriver | null>(null);
|
|
||||||
driverRef.current ??= new MermaidRuntimeRequestDriver();
|
|
||||||
|
|
||||||
const sendRequest = useCallback((current: MermaidRenderRequest | null) => {
|
|
||||||
const target = iframeRef.current?.contentWindow;
|
|
||||||
if (!current || !target) return;
|
|
||||||
const message: MermaidRuntimeRenderMessage = {
|
|
||||||
type: "render",
|
|
||||||
revision: current.revision,
|
|
||||||
source: current.source,
|
|
||||||
colorScheme: current.colorScheme,
|
|
||||||
interactive: false,
|
|
||||||
};
|
|
||||||
target.postMessage(message, "*");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
sendRequest(driverRef.current?.update(request) ?? null);
|
|
||||||
}, [request, sendRequest]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function receiveMessage(event: MessageEvent): void {
|
|
||||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
|
||||||
const message = parseMermaidRuntimeMessage(event.data);
|
|
||||||
if (!message) return;
|
|
||||||
if (message.type === "bridgeReady") {
|
|
||||||
sendRequest(driverRef.current?.ready() ?? null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (message.type === "renderError") {
|
|
||||||
onRenderFailed(message.revision);
|
|
||||||
sendRequest(driverRef.current?.settled(message.revision, false) ?? null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onRendered(message);
|
|
||||||
sendRequest(driverRef.current?.settled(message.revision, true) ?? null);
|
|
||||||
}
|
|
||||||
window.addEventListener("message", receiveMessage);
|
|
||||||
return () => window.removeEventListener("message", receiveMessage);
|
|
||||||
}, [onRenderFailed, onRendered, sendRequest]);
|
|
||||||
|
|
||||||
// `inert` (not just tabIndex) because the Modal focus trap focuses descendants
|
|
||||||
// programmatically; a focused iframe swallows every keystroke, including Escape.
|
|
||||||
return (
|
|
||||||
<iframe
|
|
||||||
ref={iframeRef}
|
|
||||||
title=""
|
|
||||||
aria-hidden
|
|
||||||
inert
|
|
||||||
sandbox="allow-scripts"
|
|
||||||
srcDoc={mermaidRuntimeHtml}
|
|
||||||
tabIndex={-1}
|
|
||||||
style={iframeStyle}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const iframeStyle: React.CSSProperties = {
|
|
||||||
display: "block",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
border: 0,
|
|
||||||
pointerEvents: "none",
|
|
||||||
background: "transparent",
|
|
||||||
};
|
|
||||||
@@ -59,41 +59,6 @@ let pendingRender: MermaidRuntimeRenderMessage | null = null;
|
|||||||
let isRendering = false;
|
let isRendering = false;
|
||||||
let isDrainScheduled = false;
|
let isDrainScheduled = false;
|
||||||
|
|
||||||
interface DiagramSize {
|
|
||||||
height: number;
|
|
||||||
width: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mermaid emits `width="100%"` with an inline `max-width` in pixels, so the diagram renders at
|
|
||||||
* its natural width and never fills a larger box. The host sizes this frame, so let the diagram
|
|
||||||
* follow the frame in both directions instead.
|
|
||||||
*/
|
|
||||||
function stretchToFrame(svg: SVGSVGElement): void {
|
|
||||||
svg.style.maxWidth = "100%";
|
|
||||||
svg.style.maxHeight = "100%";
|
|
||||||
svg.style.width = "100%";
|
|
||||||
svg.style.height = "100%";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The reported size is the host's content size, and the host feeds it back as this frame's size.
|
|
||||||
* Measuring the rendered box would make that a loop: every re-render lands in a frame the last
|
|
||||||
* measurement shrank by the container's padding, so a streaming diagram ratchets down to nothing.
|
|
||||||
* The viewBox is the diagram's own size and doesn't move.
|
|
||||||
*/
|
|
||||||
function measureDiagram(host: HTMLElement, svg: SVGSVGElement | null): DiagramSize {
|
|
||||||
const viewBox = svg?.viewBox.baseVal;
|
|
||||||
if (viewBox && viewBox.width > 0 && viewBox.height > 0) {
|
|
||||||
return { height: Math.ceil(viewBox.height), width: Math.ceil(viewBox.width) };
|
|
||||||
}
|
|
||||||
const rect = svg?.getBoundingClientRect();
|
|
||||||
return {
|
|
||||||
height: Math.ceil(rect?.height ?? host.scrollHeight),
|
|
||||||
width: Math.ceil(rect?.width ?? host.scrollWidth),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function render(message: MermaidRuntimeRenderMessage): Promise<void> {
|
async function render(message: MermaidRuntimeRenderMessage): Promise<void> {
|
||||||
try {
|
try {
|
||||||
initializeMermaid(message.colorScheme);
|
initializeMermaid(message.colorScheme);
|
||||||
@@ -107,18 +72,14 @@ async function render(message: MermaidRuntimeRenderMessage): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
host.innerHTML = svg;
|
host.innerHTML = svg;
|
||||||
const element = host.querySelector("svg");
|
const rect = host.querySelector("svg")?.getBoundingClientRect();
|
||||||
if (element) {
|
|
||||||
stretchToFrame(element);
|
|
||||||
}
|
|
||||||
const size = measureDiagram(host, element);
|
|
||||||
sendToHost({
|
sendToHost({
|
||||||
type: "rendered",
|
type: "rendered",
|
||||||
revision: message.revision,
|
revision: message.revision,
|
||||||
source: message.source,
|
source: message.source,
|
||||||
colorScheme: message.colorScheme,
|
colorScheme: message.colorScheme,
|
||||||
height: size.height,
|
height: Math.ceil(rect?.height ?? host.scrollHeight),
|
||||||
width: size.width,
|
width: Math.ceil(rect?.width ?? host.scrollWidth),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
if (message.revision === latestRevision) {
|
if (message.revision === latestRevision) {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -29,13 +29,9 @@ function waitForRuntimeMessage(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mountRuntime(size?: { width: number; height: number }): Promise<HTMLIFrameElement> {
|
async function mountRuntime(): Promise<HTMLIFrameElement> {
|
||||||
const frame = document.createElement("iframe");
|
const frame = document.createElement("iframe");
|
||||||
frame.sandbox.add("allow-scripts");
|
frame.sandbox.add("allow-scripts");
|
||||||
if (size) {
|
|
||||||
frame.style.width = `${size.width}px`;
|
|
||||||
frame.style.height = `${size.height}px`;
|
|
||||||
}
|
|
||||||
const ready = waitForRuntimeMessage(frame, (message) => message.type === "bridgeReady");
|
const ready = waitForRuntimeMessage(frame, (message) => message.type === "bridgeReady");
|
||||||
frame.srcdoc = mermaidRuntimeHtml;
|
frame.srcdoc = mermaidRuntimeHtml;
|
||||||
document.body.append(frame);
|
document.body.append(frame);
|
||||||
@@ -44,13 +40,6 @@ async function mountRuntime(size?: { width: number; height: number }): Promise<H
|
|||||||
return frame;
|
return frame;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderedSize(message: MermaidRuntimeMessage): { height: number; width: number } {
|
|
||||||
if (message.type !== "rendered") {
|
|
||||||
throw new Error(`Expected a rendered diagram, got ${message.type}`);
|
|
||||||
}
|
|
||||||
return { height: message.height, width: message.width };
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(
|
function render(
|
||||||
frame: HTMLIFrameElement,
|
frame: HTMLIFrameElement,
|
||||||
input: { revision: number; source: string },
|
input: { revision: number; source: string },
|
||||||
@@ -96,22 +85,6 @@ describe("Mermaid sandbox runtime", () => {
|
|||||||
expect(second).toMatchObject({ type: "rendered", revision: 3, source: secondSource });
|
expect(second).toMatchObject({ type: "rendered", revision: 3, source: secondSource });
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* The host sizes this frame from the reported size, so a size that depends on the frame feeds
|
|
||||||
* back: every re-render measures inside a frame the previous measurement already shrank by the
|
|
||||||
* container's padding, and a streaming diagram ratchets down to a few pixels.
|
|
||||||
*/
|
|
||||||
it("reports the same size whatever frame the host gives it", async () => {
|
|
||||||
const source = "flowchart TD\nA[Start] --> B[Middle]\nB --> C[Ship]";
|
|
||||||
const narrowFrame = await mountRuntime({ height: 60, width: 60 });
|
|
||||||
const wideFrame = await mountRuntime({ height: 600, width: 900 });
|
|
||||||
|
|
||||||
const narrow = await render(narrowFrame, { revision: 1, source });
|
|
||||||
const wide = await render(wideFrame, { revision: 1, source });
|
|
||||||
|
|
||||||
expect(renderedSize(narrow)).toEqual(renderedSize(wide));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("coalesces queued input and never reports an obsolete result", async () => {
|
it("coalesces queued input and never reports an obsolete result", async () => {
|
||||||
const frame = await mountRuntime();
|
const frame = await mountRuntime();
|
||||||
const obsoleteSource = `flowchart TD\n${Array.from({ length: 250 }, (_, index) => `A${index} --> A${index + 1}`).join("\n")}`;
|
const obsoleteSource = `flowchart TD\n${Array.from({ length: 250 }, (_, index) => `A${index} --> A${index + 1}`).join("\n")}`;
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ describe("resolveProviderIconName", () => {
|
|||||||
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
|
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
|
||||||
expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" });
|
expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" });
|
||||||
expect(resolveProviderIconName("gemini")).toEqual({ kind: "catalog", id: "gemini" });
|
expect(resolveProviderIconName("gemini")).toEqual({ kind: "catalog", id: "gemini" });
|
||||||
expect(resolveProviderIconName("gjc")).toEqual({ kind: "catalog", id: "gjc" });
|
|
||||||
expect(resolveProviderIconName("traecli")).toEqual({ kind: "catalog", id: "traecli" });
|
expect(resolveProviderIconName("traecli")).toEqual({ kind: "catalog", id: "traecli" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -228,7 +228,6 @@ interface SidebarWorkspaceListProps {
|
|||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
onWorkspacePress?: () => void;
|
onWorkspacePress?: () => void;
|
||||||
onAddProject?: () => void;
|
onAddProject?: () => void;
|
||||||
onImportSession?: () => void;
|
|
||||||
listFooterComponent?: ReactElement | null;
|
listFooterComponent?: ReactElement | null;
|
||||||
// Rendered inside the scroll area, below the Pinned section and above the workspace
|
// Rendered inside the scroll area, below the Pinned section and above the workspace
|
||||||
// list. Holds the "Workspaces" section header so pinned items sit above it.
|
// list. Holds the "Workspaces" section header so pinned items sit above it.
|
||||||
@@ -1880,7 +1879,6 @@ export function SidebarWorkspaceList({
|
|||||||
onRefresh: _onRefresh,
|
onRefresh: _onRefresh,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
onAddProject,
|
onAddProject,
|
||||||
onImportSession,
|
|
||||||
listFooterComponent,
|
listFooterComponent,
|
||||||
listHeaderComponent,
|
listHeaderComponent,
|
||||||
parentGestureRef,
|
parentGestureRef,
|
||||||
@@ -1974,7 +1972,6 @@ export function SidebarWorkspaceList({
|
|||||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||||
onWorkspacePress={onWorkspacePress}
|
onWorkspacePress={onWorkspacePress}
|
||||||
onAddProject={onAddProject}
|
onAddProject={onAddProject}
|
||||||
onImportSession={onImportSession}
|
|
||||||
listFooterComponent={listFooterComponent}
|
listFooterComponent={listFooterComponent}
|
||||||
listHeaderComponent={listHeaderComponent}
|
listHeaderComponent={listHeaderComponent}
|
||||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||||
@@ -2070,7 +2067,6 @@ function ProjectModeList({
|
|||||||
shortcutIndexByWorkspaceKey,
|
shortcutIndexByWorkspaceKey,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
onAddProject,
|
onAddProject,
|
||||||
onImportSession,
|
|
||||||
listFooterComponent,
|
listFooterComponent,
|
||||||
listHeaderComponent,
|
listHeaderComponent,
|
||||||
sidebarFilterEmpty,
|
sidebarFilterEmpty,
|
||||||
@@ -2379,7 +2375,7 @@ function ProjectModeList({
|
|||||||
|
|
||||||
const projectBody =
|
const projectBody =
|
||||||
projects.length === 0 ? (
|
projects.length === 0 ? (
|
||||||
<SidebarProjectEmptyState onAddProject={onAddProject} onImportSession={onImportSession} />
|
<SidebarProjectEmptyState onAddProject={onAddProject} />
|
||||||
) : (
|
) : (
|
||||||
<DraggableList
|
<DraggableList
|
||||||
testID="sidebar-project-list"
|
testID="sidebar-project-list"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { Import, Plus } from "lucide-react-native";
|
import { Plus } from "lucide-react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
@@ -37,13 +37,7 @@ export function SidebarFilterEmptyState() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarProjectEmptyState({
|
export function SidebarProjectEmptyState({ onAddProject }: { onAddProject?: () => void }) {
|
||||||
onAddProject,
|
|
||||||
onImportSession,
|
|
||||||
}: {
|
|
||||||
onAddProject?: () => void;
|
|
||||||
onImportSession?: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -53,9 +47,6 @@ export function SidebarProjectEmptyState({
|
|||||||
<Button variant="ghost" size="sm" leftIcon={Plus} onPress={onAddProject}>
|
<Button variant="ghost" size="sm" leftIcon={Plus} onPress={onAddProject}>
|
||||||
{t("sidebar.actions.addProject")}
|
{t("sidebar.actions.addProject")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" leftIcon={Import} onPress={onImportSession}>
|
|
||||||
{t("importSession.title")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ export interface ToastApi {
|
|||||||
type ToastViewportPlacement = "app-shell" | "panel";
|
type ToastViewportPlacement = "app-shell" | "panel";
|
||||||
|
|
||||||
const DEFAULT_DURATION_MS = 2200;
|
const DEFAULT_DURATION_MS = 2200;
|
||||||
const TOAST_MAX_WIDTH = 480;
|
|
||||||
|
|
||||||
export function useToastHost(): {
|
export function useToastHost(): {
|
||||||
api: ToastApi;
|
api: ToastApi;
|
||||||
@@ -266,26 +265,24 @@ export function ToastViewport({
|
|||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<View style={styles.container} pointerEvents="box-none">
|
<View style={styles.container} pointerEvents="box-none">
|
||||||
<View style={styles.widthBoundary} pointerEvents="box-none">
|
<Animated.View
|
||||||
<Animated.View
|
testID={toast.testID ?? "app-toast"}
|
||||||
testID={toast.testID ?? "app-toast"}
|
onPointerEnter={isWeb ? pauseDismiss : undefined}
|
||||||
onPointerEnter={isWeb ? pauseDismiss : undefined}
|
onPointerLeave={isWeb ? resumeDismiss : undefined}
|
||||||
onPointerLeave={isWeb ? resumeDismiss : undefined}
|
style={toastAnimatedStyle}
|
||||||
style={toastAnimatedStyle}
|
accessibilityRole="alert"
|
||||||
accessibilityRole="alert"
|
>
|
||||||
>
|
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
|
||||||
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
|
{typeof toast.content === "string" ? (
|
||||||
{typeof toast.content === "string" ? (
|
<Text testID="app-toast-message" style={toastMessageStyle}>
|
||||||
<Text testID="app-toast-message" style={toastMessageStyle}>
|
{toast.content}
|
||||||
{toast.content}
|
</Text>
|
||||||
</Text>
|
) : (
|
||||||
) : (
|
<View testID="app-toast-message" style={styles.contentSlot}>
|
||||||
<View testID="app-toast-message" style={styles.contentSlot}>
|
{toast.content}
|
||||||
{toast.content}
|
</View>
|
||||||
</View>
|
)}
|
||||||
)}
|
</Animated.View>
|
||||||
</Animated.View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -305,14 +302,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
zIndex: OVERLAY_Z.toast,
|
zIndex: OVERLAY_Z.toast,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
widthBoundary: {
|
|
||||||
width: "92%",
|
|
||||||
maxWidth: TOAST_MAX_WIDTH,
|
|
||||||
alignItems: "center",
|
|
||||||
},
|
|
||||||
toast: {
|
toast: {
|
||||||
alignSelf: "center",
|
alignSelf: "center",
|
||||||
maxWidth: "100%",
|
maxWidth: "92%",
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
|
|||||||
@@ -187,19 +187,6 @@ const CATALOG_DATA = [
|
|||||||
installLink: "https://geminicli.com",
|
installLink: "https://geminicli.com",
|
||||||
command: ["npx", "-y", "@google/gemini-cli@0.52.0", "--acp"],
|
command: ["npx", "-y", "@google/gemini-cli@0.52.0", "--acp"],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "gjc",
|
|
||||||
title: "Gajae Code",
|
|
||||||
description:
|
|
||||||
"Runs on the Claude/Codex/Gemini subscription you already pay for. Plan-before-mutation workflows, evidence-gated execution, and approval prompts for shell and destructive edits.",
|
|
||||||
version: "manual",
|
|
||||||
iconId: "gjc",
|
|
||||||
installLink: "https://gajae-code.com",
|
|
||||||
command: ["gjc", "acp"],
|
|
||||||
env: {
|
|
||||||
GJC_ACP_PERMISSION_MODE: "prompt",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "glm-acp-agent",
|
id: "glm-acp-agent",
|
||||||
title: "GLM Agent",
|
title: "GLM Agent",
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import {
|
|||||||
characterOffsetAtPoint,
|
characterOffsetAtPoint,
|
||||||
hitTestDiffDocument,
|
hitTestDiffDocument,
|
||||||
orderedSelection,
|
orderedSelection,
|
||||||
selectAllSource,
|
|
||||||
selectCellSource,
|
|
||||||
selectedSourceText,
|
selectedSourceText,
|
||||||
} from "./hit-testing";
|
} from "./hit-testing";
|
||||||
import { buildDiffDocumentModel } from "./model";
|
import { buildDiffDocumentModel } from "./model";
|
||||||
@@ -106,64 +104,6 @@ describe("diff hit testing", () => {
|
|||||||
}),
|
}),
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("selects one source line for context-menu copy", () => {
|
|
||||||
const model = buildModel("copy this", { wrapLines: false });
|
|
||||||
const cell = changedCell(model);
|
|
||||||
|
|
||||||
expect(selectedSourceText(model, selectCellSource(model, position(model, cell, 4)))).toBe(
|
|
||||||
"copy this",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("selects all diff source in document order", () => {
|
|
||||||
const model = buildModel("first", { wrapLines: false });
|
|
||||||
|
|
||||||
expect(selectedSourceText(model, selectAllSource(model)!)).toBe("@@ -1,0 +1,2 @@\nfirst\ntail");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("selects all source from one split side without interleaving panes", () => {
|
|
||||||
const model = buildModel("unused", { files: [splitFile()], layout: "split" });
|
|
||||||
const changedRow = model.rows.find(
|
|
||||||
(row) => row.kind === "line" && row.cells.length === 2 && row.cells.every(Boolean),
|
|
||||||
);
|
|
||||||
expect(changedRow?.kind).toBe("line");
|
|
||||||
|
|
||||||
const left = changedRow!.kind === "line" ? changedRow!.cells[0]! : null;
|
|
||||||
const right = changedRow!.kind === "line" ? changedRow!.cells[1]! : null;
|
|
||||||
expect(selectedSourceText(model, selectAllSource(model, position(model, left!, 0))!)).toBe(
|
|
||||||
"@@ -1,2 +1,2 @@\nold-one\nold-two",
|
|
||||||
);
|
|
||||||
expect(selectedSourceText(model, selectAllSource(model, position(model, right!, 0))!)).toBe(
|
|
||||||
"new-one\nnew-two",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("defaults split hunk-header select-all to the new pane", () => {
|
|
||||||
const model = buildModel("unused", { files: [splitFile()], layout: "split" });
|
|
||||||
const header = model.rows
|
|
||||||
.filter((row) => row.kind === "line")
|
|
||||||
.flatMap((row) => row.cells)
|
|
||||||
.find((cell) => cell?.type === "header");
|
|
||||||
expect(header).toBeDefined();
|
|
||||||
|
|
||||||
expect(selectedSourceText(model, selectAllSource(model, position(model, header!, 0))!)).toBe(
|
|
||||||
"new-one\nnew-two",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the old pane for a deletion-only split hunk", () => {
|
|
||||||
const model = buildModel("unused", { files: [deletedSplitFile()], layout: "split" });
|
|
||||||
const header = model.rows
|
|
||||||
.filter((row) => row.kind === "line")
|
|
||||||
.flatMap((row) => row.cells)
|
|
||||||
.find((cell) => cell?.type === "header");
|
|
||||||
expect(header).toBeDefined();
|
|
||||||
|
|
||||||
expect(selectedSourceText(model, selectAllSource(model, position(model, header!, 0))!)).toBe(
|
|
||||||
"@@ -1,2 +1,0 @@\nold-one\nold-two",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildModel(changedContent: string, overrides: Partial<BuildDiffDocumentModelInput> = {}) {
|
function buildModel(changedContent: string, overrides: Partial<BuildDiffDocumentModelInput> = {}) {
|
||||||
@@ -272,29 +212,6 @@ function splitFile(): ParsedDiffFile {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function deletedSplitFile(): ParsedDiffFile {
|
|
||||||
return {
|
|
||||||
path: "src/deleted.ts",
|
|
||||||
isNew: false,
|
|
||||||
isDeleted: true,
|
|
||||||
additions: 0,
|
|
||||||
deletions: 2,
|
|
||||||
hunks: [
|
|
||||||
{
|
|
||||||
oldStart: 1,
|
|
||||||
oldCount: 2,
|
|
||||||
newStart: 1,
|
|
||||||
newCount: 0,
|
|
||||||
lines: [
|
|
||||||
{ type: "header", content: "@@ -1,2 +1,0 @@" },
|
|
||||||
{ type: "remove", content: "old-one" },
|
|
||||||
{ type: "remove", content: "old-two" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function reviewActionsForFirstAddition(): NonNullable<
|
function reviewActionsForFirstAddition(): NonNullable<
|
||||||
BuildDiffDocumentModelInput["reviewActions"]
|
BuildDiffDocumentModelInput["reviewActions"]
|
||||||
> {
|
> {
|
||||||
|
|||||||
@@ -105,64 +105,6 @@ export function selectedSourceText(model: DiffDocumentModel, selection: DiffSele
|
|||||||
.join("\n");
|
.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectCellSource(
|
|
||||||
model: DiffDocumentModel,
|
|
||||||
position: DiffCharacterPosition,
|
|
||||||
): DiffSelection {
|
|
||||||
const row = model.rows[position.rowIndex];
|
|
||||||
const cell = row?.kind === "line" ? row.cells[position.cellIndex] : null;
|
|
||||||
const end = cell?.content.length ?? position.sourceOffset;
|
|
||||||
return {
|
|
||||||
anchor: { ...position, sourceOffset: 0 },
|
|
||||||
focus: { ...position, sourceOffset: end },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectAllSource(
|
|
||||||
model: DiffDocumentModel,
|
|
||||||
preferredPosition?: DiffCharacterPosition,
|
|
||||||
): DiffSelection | null {
|
|
||||||
let first: DiffCharacterPosition | null = null;
|
|
||||||
let last: DiffCharacterPosition | null = null;
|
|
||||||
const preferredRow = preferredPosition ? model.rows[preferredPosition.rowIndex] : null;
|
|
||||||
const preferredCell =
|
|
||||||
preferredPosition && preferredRow?.kind === "line"
|
|
||||||
? preferredRow.cells[preferredPosition.cellIndex]
|
|
||||||
: null;
|
|
||||||
let splitCellIndex: number | null = null;
|
|
||||||
if (model.layout === "split") {
|
|
||||||
const hasNewSource = model.rows.some(
|
|
||||||
(row) => row.kind === "line" && row.cells[1] && row.cells[1].type !== "header",
|
|
||||||
);
|
|
||||||
splitCellIndex = hasNewSource ? 1 : 0;
|
|
||||||
if (preferredPosition && preferredCell && preferredCell.type !== "header") {
|
|
||||||
splitCellIndex = preferredPosition.cellIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const row of model.rows) {
|
|
||||||
if (row.kind !== "line") continue;
|
|
||||||
row.cells.forEach((cell, cellIndex) => {
|
|
||||||
if (!cell || (splitCellIndex !== null && cellIndex !== splitCellIndex)) return;
|
|
||||||
const side = cell.reviewTarget?.side ?? (cellIndex === 0 ? "old" : "new");
|
|
||||||
first ??= {
|
|
||||||
fileIndex: row.fileIndex,
|
|
||||||
rowIndex: row.index,
|
|
||||||
cellIndex,
|
|
||||||
side,
|
|
||||||
sourceOffset: 0,
|
|
||||||
};
|
|
||||||
last = {
|
|
||||||
fileIndex: row.fileIndex,
|
|
||||||
rowIndex: row.index,
|
|
||||||
cellIndex,
|
|
||||||
side,
|
|
||||||
sourceOffset: cell.content.length,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return first && last ? { anchor: first, focus: last } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function comparePosition(left: DiffCharacterPosition, right: DiffCharacterPosition): number {
|
function comparePosition(left: DiffCharacterPosition, right: DiffCharacterPosition): number {
|
||||||
return (
|
return (
|
||||||
left.rowIndex - right.rowIndex ||
|
left.rowIndex - right.rowIndex ||
|
||||||
|
|||||||
@@ -2,16 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ViewStyle } from "react-native";
|
import type { ViewStyle } from "react-native";
|
||||||
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
|
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
|
||||||
import {
|
|
||||||
ContextMenu,
|
|
||||||
ContextMenuContent,
|
|
||||||
ContextMenuItem,
|
|
||||||
ContextMenuSeparator,
|
|
||||||
ContextMenuTrigger,
|
|
||||||
} from "@/components/ui/context-menu";
|
|
||||||
import { useToast } from "@/contexts/toast-context";
|
|
||||||
import { InlineReviewAddButton, InlineReviewThread } from "@/review";
|
import { InlineReviewAddButton, InlineReviewThread } from "@/review";
|
||||||
import { copyToClipboard } from "@/utils/copy-to-clipboard";
|
|
||||||
import type { ReviewableDiffTarget } from "@/utils/diff-layout";
|
import type { ReviewableDiffTarget } from "@/utils/diff-layout";
|
||||||
import { DocumentFileHeader } from "./document-file-header";
|
import { DocumentFileHeader } from "./document-file-header";
|
||||||
import {
|
import {
|
||||||
@@ -20,12 +11,7 @@ import {
|
|||||||
diffMaterializationWindow,
|
diffMaterializationWindow,
|
||||||
resolveVisibleFileSections,
|
resolveVisibleFileSections,
|
||||||
} from "./header-layout";
|
} from "./header-layout";
|
||||||
import {
|
import { hitTestDiffDocument, selectedSourceText } from "./hit-testing";
|
||||||
hitTestDiffDocument,
|
|
||||||
selectAllSource,
|
|
||||||
selectCellSource,
|
|
||||||
selectedSourceText,
|
|
||||||
} from "./hit-testing";
|
|
||||||
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
|
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
|
||||||
import { HorizontalScroll } from "./horizontal-scroll.web";
|
import { HorizontalScroll } from "./horizontal-scroll.web";
|
||||||
import { buildDiffDocumentModel, FILE_HEADER_HEIGHT, resolveRelayoutScrollTop } from "./model";
|
import { buildDiffDocumentModel, FILE_HEADER_HEIGHT, resolveRelayoutScrollTop } from "./model";
|
||||||
@@ -47,7 +33,6 @@ const RESIZE_SETTLE_DELAY_MS = 120;
|
|||||||
|
|
||||||
export function DiffSurface(props: DiffSurfaceProps) {
|
export function DiffSurface(props: DiffSurfaceProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toast = useToast();
|
|
||||||
const workspaceCache = useDiffDocumentWorkspaceCache();
|
const workspaceCache = useDiffDocumentWorkspaceCache();
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
@@ -90,8 +75,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
const [interactionFiles, setInteractionFiles] = useState<
|
const [interactionFiles, setInteractionFiles] = useState<
|
||||||
ReturnType<typeof buildDiffDocumentModel>["files"]
|
ReturnType<typeof buildDiffDocumentModel>["files"]
|
||||||
>([]);
|
>([]);
|
||||||
const [hasSelection, setHasSelection] = useState(false);
|
|
||||||
const [contextHit, setContextHit] = useState<Extract<DiffHit, { kind: "cell" }> | null>(null);
|
|
||||||
const [hoveredAffordance, setHoveredAffordance] = useState<{
|
const [hoveredAffordance, setHoveredAffordance] = useState<{
|
||||||
hit: Extract<DiffHit, { kind: "cell" }>;
|
hit: Extract<DiffHit, { kind: "cell" }>;
|
||||||
left: number;
|
left: number;
|
||||||
@@ -505,13 +488,13 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
},
|
},
|
||||||
[schedulePaint],
|
[schedulePaint],
|
||||||
);
|
);
|
||||||
const pointHitAt = useCallback((clientX: number, clientY: number) => {
|
const pointHit = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
const currentModel = modelRef.current;
|
const currentModel = modelRef.current;
|
||||||
const root = rootRef.current;
|
const root = rootRef.current;
|
||||||
if (!currentModel || !root) return null;
|
if (!currentModel || !root) return null;
|
||||||
const bounds = root.getBoundingClientRect();
|
const bounds = root.getBoundingClientRect();
|
||||||
const x = clientX - bounds.left;
|
const x = event.clientX - bounds.left;
|
||||||
const documentY = clientY - bounds.top + scrollTopRef.current;
|
const documentY = event.clientY - bounds.top + scrollTopRef.current;
|
||||||
const file = currentModel.files.find(
|
const file = currentModel.files.find(
|
||||||
(entry) => entry.top <= documentY && documentY < entry.bottom,
|
(entry) => entry.top <= documentY && documentY < entry.bottom,
|
||||||
);
|
);
|
||||||
@@ -522,27 +505,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
horizontalOffset: file ? (horizontalOffsetsRef.current.get(file.path) ?? 0) : 0,
|
horizontalOffset: file ? (horizontalOffsetsRef.current.get(file.path) ?? 0) : 0,
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
const pointHit = useCallback(
|
|
||||||
(event: React.PointerEvent<HTMLDivElement>) => pointHitAt(event.clientX, event.clientY),
|
|
||||||
[pointHitAt],
|
|
||||||
);
|
|
||||||
const setSelection = useCallback(
|
|
||||||
(selection: DiffSelection | null) => {
|
|
||||||
selectionRef.current = selection;
|
|
||||||
const currentModel = modelRef.current;
|
|
||||||
setHasSelection(
|
|
||||||
Boolean(
|
|
||||||
selection && currentModel && selectedSourceText(currentModel, selection).length > 0,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
schedulePaint();
|
|
||||||
},
|
|
||||||
[schedulePaint],
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
dragRef.current = null;
|
|
||||||
setSelection(null);
|
|
||||||
}, [props.collapsedFilePaths, props.displayPreferences.layout, props.files, setSelection]);
|
|
||||||
const pointerDown = useCallback(
|
const pointerDown = useCallback(
|
||||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
if (event.button !== 0) return;
|
if (event.button !== 0) return;
|
||||||
@@ -556,7 +518,8 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
return;
|
return;
|
||||||
const dismissSelectionOnClick = selectionRef.current !== null;
|
const dismissSelectionOnClick = selectionRef.current !== null;
|
||||||
if (dismissSelectionOnClick) {
|
if (dismissSelectionOnClick) {
|
||||||
setSelection(null);
|
selectionRef.current = null;
|
||||||
|
schedulePaint();
|
||||||
}
|
}
|
||||||
const hit = pointHit(event);
|
const hit = pointHit(event);
|
||||||
if (hit?.kind !== "cell") return;
|
if (hit?.kind !== "cell") return;
|
||||||
@@ -567,11 +530,12 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
moved: false,
|
moved: false,
|
||||||
dismissSelectionOnClick,
|
dismissSelectionOnClick,
|
||||||
};
|
};
|
||||||
setSelection({ anchor: hit.position, focus: hit.position });
|
selectionRef.current = { anchor: hit.position, focus: hit.position };
|
||||||
event.currentTarget.focus();
|
event.currentTarget.focus();
|
||||||
event.currentTarget.setPointerCapture(event.pointerId);
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
schedulePaint();
|
||||||
},
|
},
|
||||||
[pointHit, setSelection],
|
[pointHit, schedulePaint],
|
||||||
);
|
);
|
||||||
const updateActiveHeader = useCallback(
|
const updateActiveHeader = useCallback(
|
||||||
(target: EventTarget | null) => {
|
(target: EventTarget | null) => {
|
||||||
@@ -625,9 +589,10 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
setHoveredAffordance(null);
|
setHoveredAffordance(null);
|
||||||
}
|
}
|
||||||
if (!drag || hit?.kind !== "cell") return;
|
if (!drag || hit?.kind !== "cell") return;
|
||||||
setSelection({ anchor: drag.anchor, focus: hit.position });
|
selectionRef.current = { anchor: drag.anchor, focus: hit.position };
|
||||||
|
schedulePaint();
|
||||||
},
|
},
|
||||||
[pointHit, setSelection, updateActiveHeader, viewport.width],
|
[pointHit, schedulePaint, updateActiveHeader, viewport.width],
|
||||||
);
|
);
|
||||||
const pointerUp = useCallback(
|
const pointerUp = useCallback(
|
||||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
@@ -647,7 +612,8 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
})
|
})
|
||||||
: false;
|
: false;
|
||||||
if (drag && !moved && drag.dismissSelectionOnClick) {
|
if (drag && !moved && drag.dismissSelectionOnClick) {
|
||||||
setSelection(null);
|
selectionRef.current = null;
|
||||||
|
schedulePaint();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -658,11 +624,12 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
hit.target &&
|
hit.target &&
|
||||||
reviewActions
|
reviewActions
|
||||||
) {
|
) {
|
||||||
setSelection(null);
|
selectionRef.current = null;
|
||||||
reviewActions.onStartComment(hit.target);
|
reviewActions.onStartComment(hit.target);
|
||||||
|
schedulePaint();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[pointHit, reviewActions, setSelection],
|
[pointHit, reviewActions, schedulePaint],
|
||||||
);
|
);
|
||||||
const cancelPointer = useCallback(() => {
|
const cancelPointer = useCallback(() => {
|
||||||
dragRef.current = null;
|
dragRef.current = null;
|
||||||
@@ -676,61 +643,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
},
|
},
|
||||||
[model],
|
[model],
|
||||||
);
|
);
|
||||||
const prepareContextMenu = useCallback(
|
|
||||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
const hit = pointHitAt(event.clientX, event.clientY);
|
|
||||||
setContextHit(hit?.kind === "cell" ? hit : null);
|
|
||||||
},
|
|
||||||
[pointHitAt],
|
|
||||||
);
|
|
||||||
const writeSourceText = useCallback(
|
|
||||||
(text: string) => {
|
|
||||||
void copyToClipboard(text)
|
|
||||||
.then(() => toast.copied(t("common.states.copied")))
|
|
||||||
.catch(() => toast.error(t("common.errors.unableToCopy")));
|
|
||||||
},
|
|
||||||
[t, toast],
|
|
||||||
);
|
|
||||||
const copySelectedSource = useCallback(() => {
|
|
||||||
const currentModel = modelRef.current;
|
|
||||||
const selection = selectionRef.current;
|
|
||||||
if (currentModel && selection) writeSourceText(selectedSourceText(currentModel, selection));
|
|
||||||
}, [writeSourceText]);
|
|
||||||
const copyContextLine = useCallback(() => {
|
|
||||||
const currentModel = modelRef.current;
|
|
||||||
if (!currentModel || !contextHit) return;
|
|
||||||
const selection = selectCellSource(currentModel, contextHit.position);
|
|
||||||
writeSourceText(selectedSourceText(currentModel, selection));
|
|
||||||
}, [contextHit, writeSourceText]);
|
|
||||||
const selectAll = useCallback(() => {
|
|
||||||
const currentModel = modelRef.current;
|
|
||||||
if (currentModel) {
|
|
||||||
setSelection(
|
|
||||||
selectAllSource(currentModel, contextHit?.position ?? selectionRef.current?.focus),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [contextHit?.position, setSelection]);
|
|
||||||
const keyDown = useCallback(
|
|
||||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
||||||
const target = event.target;
|
|
||||||
if (
|
|
||||||
target instanceof Element &&
|
|
||||||
target.closest('input, textarea, [contenteditable="true"]')
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "a") {
|
|
||||||
event.preventDefault();
|
|
||||||
selectAll();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key === "Escape" && selectionRef.current) {
|
|
||||||
event.preventDefault();
|
|
||||||
setSelection(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[selectAll, setSelection],
|
|
||||||
);
|
|
||||||
const rootStyle = useMemo<React.CSSProperties>(
|
const rootStyle = useMemo<React.CSSProperties>(
|
||||||
() => ({ ...ROOT_STYLE, background: props.palette.surface }),
|
() => ({ ...ROOT_STYLE, background: props.palette.surface }),
|
||||||
[props.palette.surface],
|
[props.palette.surface],
|
||||||
@@ -760,7 +672,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
[desiredTypography, loadedTypography],
|
[desiredTypography, loadedTypography],
|
||||||
);
|
);
|
||||||
|
|
||||||
const surface = (
|
return (
|
||||||
<div data-testid="git-diff-canvas-root" ref={rootRef} style={rootStyle}>
|
<div data-testid="git-diff-canvas-root" ref={rootRef} style={rootStyle}>
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
@@ -773,9 +685,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
onPointerCancel={cancelPointer}
|
onPointerCancel={cancelPointer}
|
||||||
onLostPointerCapture={cancelPointer}
|
onLostPointerCapture={cancelPointer}
|
||||||
onPointerLeave={pointerLeave}
|
onPointerLeave={pointerLeave}
|
||||||
onContextMenu={prepareContextMenu}
|
|
||||||
onCopy={copy}
|
onCopy={copy}
|
||||||
onKeyDown={keyDown}
|
|
||||||
style={SCROLL_STYLE}
|
style={SCROLL_STYLE}
|
||||||
>
|
>
|
||||||
<div style={contentStyle} onMouseDown={preventDocumentMouseSelection}>
|
<div style={contentStyle} onMouseDown={preventDocumentMouseSelection}>
|
||||||
@@ -842,34 +752,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
|
||||||
<ContextMenu>
|
|
||||||
<ContextMenuTrigger contextOnly style={CONTEXT_TRIGGER_STYLE}>
|
|
||||||
{surface}
|
|
||||||
</ContextMenuTrigger>
|
|
||||||
<ContextMenuContent align="start" minWidth={180} testID="diff-source-context-menu">
|
|
||||||
<ContextMenuItem
|
|
||||||
disabled={!hasSelection}
|
|
||||||
onSelect={copySelectedSource}
|
|
||||||
testID="diff-source-copy-selection"
|
|
||||||
>
|
|
||||||
{t("common.actions.copy")}
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
|
||||||
disabled={!contextHit}
|
|
||||||
onSelect={copyContextLine}
|
|
||||||
testID="diff-source-copy-line"
|
|
||||||
>
|
|
||||||
{t("common.actions.copyLine")}
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuSeparator />
|
|
||||||
<ContextMenuItem onSelect={selectAll} testID="diff-source-select-all">
|
|
||||||
{t("common.actions.selectAll")}
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function shiftCanvasPixels(input: {
|
function shiftCanvasPixels(input: {
|
||||||
@@ -938,7 +820,7 @@ function WebFileHeaderSection({
|
|||||||
[file.bottom, file.top],
|
[file.bottom, file.top],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div style={style} onContextMenu={stopContextMenuPropagation}>
|
<div style={style}>
|
||||||
<div
|
<div
|
||||||
data-diff-header="true"
|
data-diff-header="true"
|
||||||
data-diff-header-path={file.path}
|
data-diff-header-path={file.path}
|
||||||
@@ -982,7 +864,7 @@ function WebReviewThread({
|
|||||||
[height, left, top, width],
|
[height, left, top, width],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div style={style} data-diff-review="true" onContextMenu={stopContextMenuPropagation}>
|
<div style={style} data-diff-review="true">
|
||||||
<InlineReviewThread
|
<InlineReviewThread
|
||||||
reviewTarget={target}
|
reviewTarget={target}
|
||||||
reviewActions={actions}
|
reviewActions={actions}
|
||||||
@@ -994,17 +876,12 @@ function WebReviewThread({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopContextMenuPropagation(event: React.MouseEvent): void {
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROOT_STYLE: React.CSSProperties = {
|
const ROOT_STYLE: React.CSSProperties = {
|
||||||
position: "relative",
|
position: "relative",
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
};
|
};
|
||||||
const CONTEXT_TRIGGER_STYLE: ViewStyle = { flex: 1, minHeight: 0 };
|
|
||||||
const SCROLL_STYLE: React.CSSProperties = {
|
const SCROLL_STYLE: React.CSSProperties = {
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
inset: 0,
|
inset: 0,
|
||||||
|
|||||||
@@ -81,13 +81,6 @@ describe("ACP provider catalog", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("registers Gajae Code with its ACP command and permission-prompt env", () => {
|
|
||||||
const patch = buildAcpProviderConfigPatch(findProvider("gjc"));
|
|
||||||
|
|
||||||
expect(patch.providers?.gjc?.command).toEqual(["gjc", "acp"]);
|
|
||||||
expect(patch.providers?.gjc?.env).toEqual({ GJC_ACP_PERMISSION_MODE: "prompt" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves provider params in the daemon config patch", () => {
|
it("preserves provider params in the daemon config patch", () => {
|
||||||
const droidPatch = buildAcpProviderConfigPatch(findProvider("factory-droid"));
|
const droidPatch = buildAcpProviderConfigPatch(findProvider("factory-droid"));
|
||||||
|
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
import { useCallback, useState, type ReactNode } from "react";
|
|
||||||
import { type Href, useRouter } from "expo-router";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
|
||||||
import { useHostChooser } from "@/hosts/host-chooser";
|
|
||||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
|
||||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
|
||||||
import { useOpenProject } from "@/hooks/use-open-project";
|
|
||||||
|
|
||||||
interface UseImportSessionOptions {
|
|
||||||
serverId?: string;
|
|
||||||
cwd?: string;
|
|
||||||
workspaceId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseImportSessionResult {
|
|
||||||
open: () => void;
|
|
||||||
sheet: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImportedAgentTarget {
|
|
||||||
id: string;
|
|
||||||
cwd: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends the user to an agent that belongs to a workspace other than the one they
|
|
||||||
* are looking at. The imported session's own directory decides the destination,
|
|
||||||
* so the project has to exist before the route can resolve.
|
|
||||||
*/
|
|
||||||
export function useNavigateToImportedAgent(
|
|
||||||
serverId: string | null | undefined,
|
|
||||||
): (agent: ImportedAgentTarget) => Promise<void> {
|
|
||||||
const router = useRouter();
|
|
||||||
const normalizedServerId = serverId?.trim() || null;
|
|
||||||
const openProject = useOpenProject(normalizedServerId);
|
|
||||||
|
|
||||||
return useCallback(
|
|
||||||
async (agent: ImportedAgentTarget) => {
|
|
||||||
if (!normalizedServerId) return;
|
|
||||||
const project = await openProject(agent.cwd);
|
|
||||||
if (project.ok) {
|
|
||||||
router.push(buildHostAgentDetailRoute(normalizedServerId, agent.id) as Href);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[normalizedServerId, openProject, router],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useImportSession({
|
|
||||||
serverId,
|
|
||||||
cwd,
|
|
||||||
workspaceId,
|
|
||||||
}: UseImportSessionOptions = {}): UseImportSessionResult {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const chooseHost = useHostChooser();
|
|
||||||
const [importServerId, setImportServerId] = useState<string | null>(null);
|
|
||||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
|
||||||
const client = useHostRuntimeClient(importServerId ?? "");
|
|
||||||
const navigateToImportedAgent = useNavigateToImportedAgent(importServerId);
|
|
||||||
|
|
||||||
const openForHost = useCallback((chosenServerId: string) => {
|
|
||||||
setImportServerId(chosenServerId);
|
|
||||||
setIsSheetOpen(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const open = useCallback(() => {
|
|
||||||
if (serverId) {
|
|
||||||
openForHost(serverId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
chooseHost({
|
|
||||||
title: t("importSession.chooseHostTitle"),
|
|
||||||
onChooseHost: openForHost,
|
|
||||||
});
|
|
||||||
}, [chooseHost, openForHost, serverId, t]);
|
|
||||||
|
|
||||||
const close = useCallback(() => setIsSheetOpen(false), []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
open,
|
|
||||||
sheet: (
|
|
||||||
<ImportSessionSheet
|
|
||||||
visible={isSheetOpen}
|
|
||||||
client={client}
|
|
||||||
serverId={importServerId}
|
|
||||||
cwd={cwd}
|
|
||||||
workspaceId={workspaceId}
|
|
||||||
onClose={close}
|
|
||||||
onImported={navigateToImportedAgent}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -285,7 +285,6 @@ describe("translation resources", () => {
|
|||||||
|
|
||||||
it("includes workspace and panel keys for the Batch 4A migration", () => {
|
it("includes workspace and panel keys for the Batch 4A migration", () => {
|
||||||
expect(en.importSession.title).toBe("Import session");
|
expect(en.importSession.title).toBe("Import session");
|
||||||
expect(en.importSession.chooseHostTitle).toBe("Import from host");
|
|
||||||
expect(en.importSession.status.connectHost).toBe("Connect to a host to import sessions");
|
expect(en.importSession.status.connectHost).toBe("Connect to a host to import sessions");
|
||||||
expect(en.importSession.actions.refresh).toBe("Refresh sessions");
|
expect(en.importSession.actions.refresh).toBe("Refresh sessions");
|
||||||
expect(en.workspace.fileExplorer.sort.name).toBe("Name");
|
expect(en.workspace.fileExplorer.sort.name).toBe("Name");
|
||||||
@@ -299,9 +298,6 @@ describe("translation resources", () => {
|
|||||||
expect(en.workspace.browser.controls.enterUrl).toBe("Enter URL");
|
expect(en.workspace.browser.controls.enterUrl).toBe("Enter URL");
|
||||||
expect(en.workspace.terminal.hostDisconnected).toBe("Host is not connected");
|
expect(en.workspace.terminal.hostDisconnected).toBe("Host is not connected");
|
||||||
expect(en.panels.file.directoryMissing).toBe("Workspace directory not found.");
|
expect(en.panels.file.directoryMissing).toBe("Workspace directory not found.");
|
||||||
expect(en.openProject.tiles.importSession.description).toBe(
|
|
||||||
"Open a Claude Code, Codex or other session you started in a terminal",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes workspace Git and review keys for the Batch 4B migration", () => {
|
it("includes workspace Git and review keys for the Batch 4B migration", () => {
|
||||||
@@ -448,6 +444,7 @@ describe("translation resources", () => {
|
|||||||
expect(en.sidebar.host.searchPlaceholder).toBe("Search hosts...");
|
expect(en.sidebar.host.searchPlaceholder).toBe("Search hosts...");
|
||||||
expect(en.sidebar.actions.addProject).toBe("Add project");
|
expect(en.sidebar.actions.addProject).toBe("Add project");
|
||||||
expect(en.sidebar.actions.hosts).toBe("Hosts");
|
expect(en.sidebar.actions.hosts).toBe("Hosts");
|
||||||
|
expect(en.sidebar.actions.home).toBe("Home");
|
||||||
expect(en.sidebar.actions.settings).toBe("Settings");
|
expect(en.sidebar.actions.settings).toBe("Settings");
|
||||||
expect(en.sidebar.actions.closeSidebar).toBe("Close sidebar");
|
expect(en.sidebar.actions.closeSidebar).toBe("Close sidebar");
|
||||||
expect(en.sidebar.sections.sessions).toBe("History");
|
expect(en.sidebar.sections.sessions).toBe("History");
|
||||||
|
|||||||
@@ -10,12 +10,10 @@ export const ar: TranslationResources = {
|
|||||||
cancel: "يلغي",
|
cancel: "يلغي",
|
||||||
close: "يغلق",
|
close: "يغلق",
|
||||||
copy: "ينسخ",
|
copy: "ينسخ",
|
||||||
copyLine: "نسخ السطر",
|
|
||||||
dismiss: "رفض",
|
dismiss: "رفض",
|
||||||
retry: "أعد المحاولة",
|
retry: "أعد المحاولة",
|
||||||
search: "يبحث",
|
search: "يبحث",
|
||||||
select: "يختار",
|
select: "يختار",
|
||||||
selectAll: "تحديد الكل",
|
|
||||||
},
|
},
|
||||||
placeholders: {
|
placeholders: {
|
||||||
search: "يبحث...",
|
search: "يبحث...",
|
||||||
@@ -35,7 +33,6 @@ export const ar: TranslationResources = {
|
|||||||
errors: {
|
errors: {
|
||||||
error: "خطأ",
|
error: "خطأ",
|
||||||
unableToSave: "غير قادر على الحفظ",
|
unableToSave: "غير قادر على الحفظ",
|
||||||
unableToCopy: "تعذر النسخ",
|
|
||||||
nameRequired: "الاسم مطلوب",
|
nameRequired: "الاسم مطلوب",
|
||||||
daemonUnavailable: "Daemon غير متوفر",
|
daemonUnavailable: "Daemon غير متوفر",
|
||||||
daemonClientUnavailable: "عميل Daemon غير متوفر",
|
daemonClientUnavailable: "عميل Daemon غير متوفر",
|
||||||
@@ -289,7 +286,6 @@ export const ar: TranslationResources = {
|
|||||||
zoomIn: "تكبير",
|
zoomIn: "تكبير",
|
||||||
zoomOut: "تصغير",
|
zoomOut: "تصغير",
|
||||||
resetZoom: "إعادة تعيين العرض",
|
resetZoom: "إعادة تعيين العرض",
|
||||||
fullscreen: "ملء الشاشة",
|
|
||||||
viewSource: "عرض المصدر",
|
viewSource: "عرض المصدر",
|
||||||
viewDiagram: "عرض المخطط",
|
viewDiagram: "عرض المخطط",
|
||||||
},
|
},
|
||||||
@@ -361,13 +357,7 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
importSession: {
|
importSession: {
|
||||||
chooseHostTitle: en.importSession.chooseHostTitle,
|
|
||||||
title: "جلسة الاستيراد",
|
title: "جلسة الاستيراد",
|
||||||
searchPlaceholder: "البحث في الجلسات...",
|
|
||||||
scope: {
|
|
||||||
host: "الجلسات على {{host}}",
|
|
||||||
workspace: "مساحة العمل هذه",
|
|
||||||
},
|
|
||||||
filters: {
|
filters: {
|
||||||
all: "الجميع",
|
all: "الجميع",
|
||||||
},
|
},
|
||||||
@@ -376,13 +366,12 @@ export const ar: TranslationResources = {
|
|||||||
updateHost: "قم بتحديث المضيف لاستيراد الجلسات.",
|
updateHost: "قم بتحديث المضيف لاستيراد الجلسات.",
|
||||||
noProviders: "لم يتم تمكين أي موفري خدمات قابلين للاستيراد.",
|
noProviders: "لم يتم تمكين أي موفري خدمات قابلين للاستيراد.",
|
||||||
loading: "جارٍ تحميل الجلسات الأخيرة...",
|
loading: "جارٍ تحميل الجلسات الأخيرة...",
|
||||||
failedProvider: "تعذر تحميل جلسات {{provider}}",
|
failedAll: "تعذر تحميل الجلسات الأخيرة.",
|
||||||
|
failedProviders: "تعذر تحميل جلسات العمل لـ{{providers}}.",
|
||||||
failedImport: "تعذر استيراد الجلسة المحددة.",
|
failedImport: "تعذر استيراد الجلسة المحددة.",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
refresh: "تحديث الجلسات",
|
refresh: "تحديث الجلسات",
|
||||||
showAll: "عرض الكل",
|
|
||||||
loadMore: "تحميل المزيد",
|
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
untitledSession: "جلسة بلا عنوان",
|
untitledSession: "جلسة بلا عنوان",
|
||||||
@@ -390,7 +379,6 @@ export const ar: TranslationResources = {
|
|||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
noRecent: "لا توجد جلسات حديثة لاستيرادها.",
|
noRecent: "لا توجد جلسات حديثة لاستيرادها.",
|
||||||
noMatches: "لا توجد جلسات تطابق بحثك.",
|
|
||||||
alreadyImported: "تم بالفعل استيراد كافة الجلسات الأخيرة.",
|
alreadyImported: "تم بالفعل استيراد كافة الجلسات الأخيرة.",
|
||||||
noProviderSessions: "لم يتم العثور على جلسات{{provider}}.",
|
noProviderSessions: "لم يتم العثور على جلسات{{provider}}.",
|
||||||
},
|
},
|
||||||
@@ -503,23 +491,14 @@ export const ar: TranslationResources = {
|
|||||||
completed: "اكتمل الإعداد",
|
completed: "اكتمل الإعداد",
|
||||||
failed: "فشل الإعداد",
|
failed: "فشل الإعداد",
|
||||||
workspace: "إعداد Workspace",
|
workspace: "إعداد Workspace",
|
||||||
blocked: "تم حظر الإعداد",
|
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
running: "جري",
|
running: "جري",
|
||||||
completed: "مكتمل",
|
completed: "مكتمل",
|
||||||
failed: "فشل",
|
failed: "فشل",
|
||||||
waiting: "في انتظار إخراج الإعداد",
|
waiting: "في انتظار إخراج الإعداد",
|
||||||
blocked: "محظور",
|
|
||||||
},
|
},
|
||||||
waiting: "جارٍ إعداد مساحة العمل...",
|
waiting: "جارٍ إعداد مساحة العمل...",
|
||||||
blocked: {
|
|
||||||
title: "لم يتم تشغيل الإعداد",
|
|
||||||
description:
|
|
||||||
"يأتي طلب السحب هذا من {{repository}}، وهو مستودع مختلف. قد يشغّل الإعداد والبرامج النصية تعليمات برمجية لم تراجعها.",
|
|
||||||
run: "تشغيل الإعداد",
|
|
||||||
runFailed: "فشل تشغيل إعداد مساحة العمل",
|
|
||||||
},
|
|
||||||
empty: {
|
empty: {
|
||||||
noCommands: "لم يتم تشغيل أي أوامر إعداد لمساحة العمل هذه.",
|
noCommands: "لم يتم تشغيل أي أوامر إعداد لمساحة العمل هذه.",
|
||||||
},
|
},
|
||||||
@@ -1103,6 +1082,7 @@ export const ar: TranslationResources = {
|
|||||||
addProject: "إضافة مشروع",
|
addProject: "إضافة مشروع",
|
||||||
newWorkspace: "مساحة عمل جديدة",
|
newWorkspace: "مساحة عمل جديدة",
|
||||||
hosts: "المضيفون",
|
hosts: "المضيفون",
|
||||||
|
home: "بيت",
|
||||||
settings: "إعدادات",
|
settings: "إعدادات",
|
||||||
closeSidebar: "إغلاق الشريط الجانبي",
|
closeSidebar: "إغلاق الشريط الجانبي",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,12 +7,10 @@ export const en = {
|
|||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
close: "Close",
|
close: "Close",
|
||||||
copy: "Copy",
|
copy: "Copy",
|
||||||
copyLine: "Copy line",
|
|
||||||
dismiss: "Dismiss",
|
dismiss: "Dismiss",
|
||||||
retry: "Retry",
|
retry: "Retry",
|
||||||
search: "Search",
|
search: "Search",
|
||||||
select: "Select",
|
select: "Select",
|
||||||
selectAll: "Select all",
|
|
||||||
},
|
},
|
||||||
placeholders: {
|
placeholders: {
|
||||||
search: "Search...",
|
search: "Search...",
|
||||||
@@ -32,7 +30,6 @@ export const en = {
|
|||||||
errors: {
|
errors: {
|
||||||
error: "Error",
|
error: "Error",
|
||||||
unableToSave: "Unable to save",
|
unableToSave: "Unable to save",
|
||||||
unableToCopy: "Unable to copy",
|
|
||||||
nameRequired: "Name is required",
|
nameRequired: "Name is required",
|
||||||
daemonUnavailable: "Daemon unavailable",
|
daemonUnavailable: "Daemon unavailable",
|
||||||
daemonClientUnavailable: "Daemon client unavailable",
|
daemonClientUnavailable: "Daemon client unavailable",
|
||||||
@@ -287,7 +284,6 @@ export const en = {
|
|||||||
zoomIn: "Zoom in",
|
zoomIn: "Zoom in",
|
||||||
zoomOut: "Zoom out",
|
zoomOut: "Zoom out",
|
||||||
resetZoom: "Reset view",
|
resetZoom: "Reset view",
|
||||||
fullscreen: "Fullscreen",
|
|
||||||
viewSource: "View source",
|
viewSource: "View source",
|
||||||
viewDiagram: "View diagram",
|
viewDiagram: "View diagram",
|
||||||
},
|
},
|
||||||
@@ -360,12 +356,6 @@ export const en = {
|
|||||||
},
|
},
|
||||||
importSession: {
|
importSession: {
|
||||||
title: "Import session",
|
title: "Import session",
|
||||||
chooseHostTitle: "Import from host",
|
|
||||||
searchPlaceholder: "Search sessions...",
|
|
||||||
scope: {
|
|
||||||
host: "Sessions on {{host}}",
|
|
||||||
workspace: "This workspace",
|
|
||||||
},
|
|
||||||
filters: {
|
filters: {
|
||||||
all: "All",
|
all: "All",
|
||||||
},
|
},
|
||||||
@@ -374,13 +364,12 @@ export const en = {
|
|||||||
updateHost: "Update the host to import sessions.",
|
updateHost: "Update the host to import sessions.",
|
||||||
noProviders: "No importable providers are enabled.",
|
noProviders: "No importable providers are enabled.",
|
||||||
loading: "Loading recent sessions...",
|
loading: "Loading recent sessions...",
|
||||||
failedProvider: "Could not load {{provider}} sessions",
|
failedAll: "Could not load recent sessions.",
|
||||||
|
failedProviders: "Could not load sessions for {{providers}}.",
|
||||||
failedImport: "Could not import selected session.",
|
failedImport: "Could not import selected session.",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
refresh: "Refresh sessions",
|
refresh: "Refresh sessions",
|
||||||
showAll: "Show all",
|
|
||||||
loadMore: "Load more",
|
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
untitledSession: "Untitled session",
|
untitledSession: "Untitled session",
|
||||||
@@ -388,7 +377,6 @@ export const en = {
|
|||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
noRecent: "No recent sessions to import.",
|
noRecent: "No recent sessions to import.",
|
||||||
noMatches: "No sessions match your search.",
|
|
||||||
alreadyImported: "All recent sessions are already imported.",
|
alreadyImported: "All recent sessions are already imported.",
|
||||||
noProviderSessions: "No {{provider}} sessions found.",
|
noProviderSessions: "No {{provider}} sessions found.",
|
||||||
},
|
},
|
||||||
@@ -501,23 +489,14 @@ export const en = {
|
|||||||
completed: "Setup completed",
|
completed: "Setup completed",
|
||||||
failed: "Setup failed",
|
failed: "Setup failed",
|
||||||
workspace: "Workspace setup",
|
workspace: "Workspace setup",
|
||||||
blocked: "Setup blocked",
|
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
running: "Running",
|
running: "Running",
|
||||||
completed: "Completed",
|
completed: "Completed",
|
||||||
failed: "Failed",
|
failed: "Failed",
|
||||||
waiting: "Waiting for setup output",
|
waiting: "Waiting for setup output",
|
||||||
blocked: "Blocked",
|
|
||||||
},
|
},
|
||||||
waiting: "Setting up workspace...",
|
waiting: "Setting up workspace...",
|
||||||
blocked: {
|
|
||||||
title: "Setup was not run",
|
|
||||||
description:
|
|
||||||
"This PR comes from {{repository}}, a different repository. Its setup and scripts could run code you have not reviewed.",
|
|
||||||
run: "Run setup",
|
|
||||||
runFailed: "Failed to run workspace setup",
|
|
||||||
},
|
|
||||||
empty: {
|
empty: {
|
||||||
noCommands: "No setup commands ran for this workspace.",
|
noCommands: "No setup commands ran for this workspace.",
|
||||||
},
|
},
|
||||||
@@ -1112,6 +1091,7 @@ export const en = {
|
|||||||
addProject: "Add project",
|
addProject: "Add project",
|
||||||
newWorkspace: "New workspace",
|
newWorkspace: "New workspace",
|
||||||
hosts: "Hosts",
|
hosts: "Hosts",
|
||||||
|
home: "Home",
|
||||||
settings: "Settings",
|
settings: "Settings",
|
||||||
closeSidebar: "Close sidebar",
|
closeSidebar: "Close sidebar",
|
||||||
},
|
},
|
||||||
@@ -1443,7 +1423,7 @@ export const en = {
|
|||||||
},
|
},
|
||||||
importSession: {
|
importSession: {
|
||||||
title: "Import session",
|
title: "Import session",
|
||||||
description: "Open a Claude Code, Codex or other session you started in a terminal",
|
description: "Bring in recent external CLI sessions",
|
||||||
},
|
},
|
||||||
setupProviders: {
|
setupProviders: {
|
||||||
title: "Setup providers",
|
title: "Setup providers",
|
||||||
|
|||||||
@@ -10,12 +10,10 @@ export const es: TranslationResources = {
|
|||||||
cancel: "Cancelar",
|
cancel: "Cancelar",
|
||||||
close: "Cerrar",
|
close: "Cerrar",
|
||||||
copy: "Copiar",
|
copy: "Copiar",
|
||||||
copyLine: "Copiar línea",
|
|
||||||
dismiss: "Despedir",
|
dismiss: "Despedir",
|
||||||
retry: "Rever",
|
retry: "Rever",
|
||||||
search: "Buscar",
|
search: "Buscar",
|
||||||
select: "Seleccionar",
|
select: "Seleccionar",
|
||||||
selectAll: "Seleccionar todo",
|
|
||||||
},
|
},
|
||||||
placeholders: {
|
placeholders: {
|
||||||
search: "Buscar...",
|
search: "Buscar...",
|
||||||
@@ -35,7 +33,6 @@ export const es: TranslationResources = {
|
|||||||
errors: {
|
errors: {
|
||||||
error: "Error",
|
error: "Error",
|
||||||
unableToSave: "No se puede guardar",
|
unableToSave: "No se puede guardar",
|
||||||
unableToCopy: "No se pudo copiar",
|
|
||||||
nameRequired: "El nombre es obligatorio",
|
nameRequired: "El nombre es obligatorio",
|
||||||
daemonUnavailable: "Daemonno disponible",
|
daemonUnavailable: "Daemonno disponible",
|
||||||
daemonClientUnavailable: "ClienteDaemonno disponible",
|
daemonClientUnavailable: "ClienteDaemonno disponible",
|
||||||
@@ -292,7 +289,6 @@ export const es: TranslationResources = {
|
|||||||
zoomIn: "Acercar",
|
zoomIn: "Acercar",
|
||||||
zoomOut: "Alejar",
|
zoomOut: "Alejar",
|
||||||
resetZoom: "Restablecer vista",
|
resetZoom: "Restablecer vista",
|
||||||
fullscreen: "Pantalla completa",
|
|
||||||
viewSource: "Ver fuente",
|
viewSource: "Ver fuente",
|
||||||
viewDiagram: "Ver diagrama",
|
viewDiagram: "Ver diagrama",
|
||||||
},
|
},
|
||||||
@@ -364,13 +360,7 @@ export const es: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
importSession: {
|
importSession: {
|
||||||
chooseHostTitle: en.importSession.chooseHostTitle,
|
|
||||||
title: "Importar sesión",
|
title: "Importar sesión",
|
||||||
searchPlaceholder: "Buscar sesiones...",
|
|
||||||
scope: {
|
|
||||||
host: "Sesiones en {{host}}",
|
|
||||||
workspace: "Este espacio de trabajo",
|
|
||||||
},
|
|
||||||
filters: {
|
filters: {
|
||||||
all: "Todo",
|
all: "Todo",
|
||||||
},
|
},
|
||||||
@@ -379,13 +369,12 @@ export const es: TranslationResources = {
|
|||||||
updateHost: "Actualice el host para importar sesiones.",
|
updateHost: "Actualice el host para importar sesiones.",
|
||||||
noProviders: "No hay proveedores importables habilitados.",
|
noProviders: "No hay proveedores importables habilitados.",
|
||||||
loading: "Cargando sesiones recientes...",
|
loading: "Cargando sesiones recientes...",
|
||||||
failedProvider: "No se pudieron cargar las sesiones de {{provider}}",
|
failedAll: "No se pudieron cargar las sesiones recientes.",
|
||||||
|
failedProviders: "No se pudieron cargar sesiones para{{providers}}.",
|
||||||
failedImport: "No se pudo importar la sesión seleccionada.",
|
failedImport: "No se pudo importar la sesión seleccionada.",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
refresh: "Actualizar sesiones",
|
refresh: "Actualizar sesiones",
|
||||||
showAll: "Mostrar todo",
|
|
||||||
loadMore: "Cargar más",
|
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
untitledSession: "Sesión sin título",
|
untitledSession: "Sesión sin título",
|
||||||
@@ -393,7 +382,6 @@ export const es: TranslationResources = {
|
|||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
noRecent: "No hay sesiones recientes para importar.",
|
noRecent: "No hay sesiones recientes para importar.",
|
||||||
noMatches: "Ninguna sesión coincide con tu búsqueda.",
|
|
||||||
alreadyImported: "Todas las sesiones recientes ya están importadas.",
|
alreadyImported: "Todas las sesiones recientes ya están importadas.",
|
||||||
noProviderSessions: "No se encontraron sesiones{{provider}}.",
|
noProviderSessions: "No se encontraron sesiones{{provider}}.",
|
||||||
},
|
},
|
||||||
@@ -507,23 +495,14 @@ export const es: TranslationResources = {
|
|||||||
completed: "Configuración completada",
|
completed: "Configuración completada",
|
||||||
failed: "Error de configuración",
|
failed: "Error de configuración",
|
||||||
workspace: "Configuración deWorkspace",
|
workspace: "Configuración deWorkspace",
|
||||||
blocked: "Configuración bloqueada",
|
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
running: "Correr",
|
running: "Correr",
|
||||||
completed: "Terminado",
|
completed: "Terminado",
|
||||||
failed: "Fallido",
|
failed: "Fallido",
|
||||||
waiting: "Esperando el resultado de la configuración",
|
waiting: "Esperando el resultado de la configuración",
|
||||||
blocked: "Bloqueado",
|
|
||||||
},
|
},
|
||||||
waiting: "Configurando el espacio de trabajo...",
|
waiting: "Configurando el espacio de trabajo...",
|
||||||
blocked: {
|
|
||||||
title: "No se ejecutó la configuración",
|
|
||||||
description:
|
|
||||||
"Este PR proviene de {{repository}}, un repositorio diferente. Su configuración y scripts podrían ejecutar código que no has revisado.",
|
|
||||||
run: "Ejecutar configuración",
|
|
||||||
runFailed: "No se pudo ejecutar la configuración",
|
|
||||||
},
|
|
||||||
empty: {
|
empty: {
|
||||||
noCommands: "No se ejecutó ningún comando de configuración para este espacio de trabajo.",
|
noCommands: "No se ejecutó ningún comando de configuración para este espacio de trabajo.",
|
||||||
},
|
},
|
||||||
@@ -1137,6 +1116,7 @@ export const es: TranslationResources = {
|
|||||||
addProject: "Agregar proyecto",
|
addProject: "Agregar proyecto",
|
||||||
newWorkspace: "Nuevo espacio de trabajo",
|
newWorkspace: "Nuevo espacio de trabajo",
|
||||||
hosts: "Hosts",
|
hosts: "Hosts",
|
||||||
|
home: "Hogar",
|
||||||
settings: "Ajustes",
|
settings: "Ajustes",
|
||||||
closeSidebar: "Cerrar barra lateral",
|
closeSidebar: "Cerrar barra lateral",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,12 +10,10 @@ export const fr: TranslationResources = {
|
|||||||
cancel: "Annuler",
|
cancel: "Annuler",
|
||||||
close: "Fermer",
|
close: "Fermer",
|
||||||
copy: "Copie",
|
copy: "Copie",
|
||||||
copyLine: "Copier la ligne",
|
|
||||||
dismiss: "Rejeter",
|
dismiss: "Rejeter",
|
||||||
retry: "Réessayer",
|
retry: "Réessayer",
|
||||||
search: "Recherche",
|
search: "Recherche",
|
||||||
select: "Sélectionner",
|
select: "Sélectionner",
|
||||||
selectAll: "Tout sélectionner",
|
|
||||||
},
|
},
|
||||||
placeholders: {
|
placeholders: {
|
||||||
search: "Recherche...",
|
search: "Recherche...",
|
||||||
@@ -35,7 +33,6 @@ export const fr: TranslationResources = {
|
|||||||
errors: {
|
errors: {
|
||||||
error: "Erreur",
|
error: "Erreur",
|
||||||
unableToSave: "Impossible d'enregistrer",
|
unableToSave: "Impossible d'enregistrer",
|
||||||
unableToCopy: "Impossible de copier",
|
|
||||||
nameRequired: "Le nom est requis",
|
nameRequired: "Le nom est requis",
|
||||||
daemonUnavailable: "Daemonindisponible",
|
daemonUnavailable: "Daemonindisponible",
|
||||||
daemonClientUnavailable: "ClientDaemonindisponible",
|
daemonClientUnavailable: "ClientDaemonindisponible",
|
||||||
@@ -293,7 +290,6 @@ export const fr: TranslationResources = {
|
|||||||
zoomIn: "Zoomer",
|
zoomIn: "Zoomer",
|
||||||
zoomOut: "Dézoomer",
|
zoomOut: "Dézoomer",
|
||||||
resetZoom: "Réinitialiser la vue",
|
resetZoom: "Réinitialiser la vue",
|
||||||
fullscreen: "Plein écran",
|
|
||||||
viewSource: "Afficher la source",
|
viewSource: "Afficher la source",
|
||||||
viewDiagram: "Afficher le diagramme",
|
viewDiagram: "Afficher le diagramme",
|
||||||
},
|
},
|
||||||
@@ -365,13 +361,7 @@ export const fr: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
importSession: {
|
importSession: {
|
||||||
chooseHostTitle: en.importSession.chooseHostTitle,
|
|
||||||
title: "Session d'importation",
|
title: "Session d'importation",
|
||||||
searchPlaceholder: "Rechercher des sessions...",
|
|
||||||
scope: {
|
|
||||||
host: "Sessions sur {{host}}",
|
|
||||||
workspace: "Cet espace de travail",
|
|
||||||
},
|
|
||||||
filters: {
|
filters: {
|
||||||
all: "Tous",
|
all: "Tous",
|
||||||
},
|
},
|
||||||
@@ -380,13 +370,12 @@ export const fr: TranslationResources = {
|
|||||||
updateHost: "Mettez à jour l'hôte pour importer des sessions.",
|
updateHost: "Mettez à jour l'hôte pour importer des sessions.",
|
||||||
noProviders: "Aucun fournisseur importable n'est activé.",
|
noProviders: "Aucun fournisseur importable n'est activé.",
|
||||||
loading: "Chargement des sessions récentes...",
|
loading: "Chargement des sessions récentes...",
|
||||||
failedProvider: "Impossible de charger les sessions {{provider}}",
|
failedAll: "Impossible de charger les sessions récentes.",
|
||||||
|
failedProviders: "Impossible de charger les sessions pour{{providers}}.",
|
||||||
failedImport: "Impossible d'importer la session sélectionnée.",
|
failedImport: "Impossible d'importer la session sélectionnée.",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
refresh: "Sessions de rafraîchissement",
|
refresh: "Sessions de rafraîchissement",
|
||||||
showAll: "Tout afficher",
|
|
||||||
loadMore: "Charger plus",
|
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
untitledSession: "Séance sans titre",
|
untitledSession: "Séance sans titre",
|
||||||
@@ -394,7 +383,6 @@ export const fr: TranslationResources = {
|
|||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
noRecent: "Aucune session récente à importer.",
|
noRecent: "Aucune session récente à importer.",
|
||||||
noMatches: "Aucune session ne correspond à votre recherche.",
|
|
||||||
alreadyImported: "Toutes les sessions récentes sont déjà importées.",
|
alreadyImported: "Toutes les sessions récentes sont déjà importées.",
|
||||||
noProviderSessions: "Aucune session{{provider}}trouvée.",
|
noProviderSessions: "Aucune session{{provider}}trouvée.",
|
||||||
},
|
},
|
||||||
@@ -507,23 +495,14 @@ export const fr: TranslationResources = {
|
|||||||
completed: "Configuration terminée",
|
completed: "Configuration terminée",
|
||||||
failed: "Échec de l'installation",
|
failed: "Échec de l'installation",
|
||||||
workspace: "ConfigurationWorkspace",
|
workspace: "ConfigurationWorkspace",
|
||||||
blocked: "Configuration bloquée",
|
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
running: "En cours d'exécution",
|
running: "En cours d'exécution",
|
||||||
completed: "Complété",
|
completed: "Complété",
|
||||||
failed: "Échoué",
|
failed: "Échoué",
|
||||||
waiting: "En attente de la sortie de configuration",
|
waiting: "En attente de la sortie de configuration",
|
||||||
blocked: "Bloqué",
|
|
||||||
},
|
},
|
||||||
waiting: "Configuration de l'espace de travail...",
|
waiting: "Configuration de l'espace de travail...",
|
||||||
blocked: {
|
|
||||||
title: "La configuration n’a pas été exécutée",
|
|
||||||
description:
|
|
||||||
"Cette PR provient de {{repository}}, un dépôt différent. Sa configuration et ses scripts pourraient exécuter du code que vous n’avez pas examiné.",
|
|
||||||
run: "Exécuter la configuration",
|
|
||||||
runFailed: "Échec de la configuration de l’espace de travail",
|
|
||||||
},
|
|
||||||
empty: {
|
empty: {
|
||||||
noCommands: "Aucune commande de configuration n'a été exécutée pour cet espace de travail.",
|
noCommands: "Aucune commande de configuration n'a été exécutée pour cet espace de travail.",
|
||||||
},
|
},
|
||||||
@@ -1137,6 +1116,7 @@ export const fr: TranslationResources = {
|
|||||||
addProject: "Ajouter un projet",
|
addProject: "Ajouter un projet",
|
||||||
newWorkspace: "Nouvel espace de travail",
|
newWorkspace: "Nouvel espace de travail",
|
||||||
hosts: "Hôtes",
|
hosts: "Hôtes",
|
||||||
|
home: "Maison",
|
||||||
settings: "Paramètres",
|
settings: "Paramètres",
|
||||||
closeSidebar: "Fermer la barre latérale",
|
closeSidebar: "Fermer la barre latérale",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,12 +10,10 @@ export const ja: TranslationResources = {
|
|||||||
cancel: "キャンセル",
|
cancel: "キャンセル",
|
||||||
close: "閉じる",
|
close: "閉じる",
|
||||||
copy: "コピー",
|
copy: "コピー",
|
||||||
copyLine: "行をコピー",
|
|
||||||
dismiss: "閉じる",
|
dismiss: "閉じる",
|
||||||
retry: "再試行",
|
retry: "再試行",
|
||||||
search: "検索",
|
search: "検索",
|
||||||
select: "選択",
|
select: "選択",
|
||||||
selectAll: "すべて選択",
|
|
||||||
},
|
},
|
||||||
placeholders: {
|
placeholders: {
|
||||||
search: "検索...",
|
search: "検索...",
|
||||||
@@ -35,7 +33,6 @@ export const ja: TranslationResources = {
|
|||||||
errors: {
|
errors: {
|
||||||
error: "エラー",
|
error: "エラー",
|
||||||
unableToSave: "保存できません",
|
unableToSave: "保存できません",
|
||||||
unableToCopy: "コピーできません",
|
|
||||||
nameRequired: "名前は必須です",
|
nameRequired: "名前は必須です",
|
||||||
daemonUnavailable: "デーモンが利用できません",
|
daemonUnavailable: "デーモンが利用できません",
|
||||||
daemonClientUnavailable: "デーモンクライアントが利用できません",
|
daemonClientUnavailable: "デーモンクライアントが利用できません",
|
||||||
@@ -292,7 +289,6 @@ export const ja: TranslationResources = {
|
|||||||
zoomIn: "拡大",
|
zoomIn: "拡大",
|
||||||
zoomOut: "縮小",
|
zoomOut: "縮小",
|
||||||
resetZoom: "表示をリセット",
|
resetZoom: "表示をリセット",
|
||||||
fullscreen: "全画面表示",
|
|
||||||
viewSource: "ソースを表示",
|
viewSource: "ソースを表示",
|
||||||
viewDiagram: "図を表示",
|
viewDiagram: "図を表示",
|
||||||
},
|
},
|
||||||
@@ -364,13 +360,7 @@ export const ja: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
importSession: {
|
importSession: {
|
||||||
chooseHostTitle: en.importSession.chooseHostTitle,
|
|
||||||
title: "セッションをインポート",
|
title: "セッションをインポート",
|
||||||
searchPlaceholder: "セッションを検索...",
|
|
||||||
scope: {
|
|
||||||
host: "{{host}} のセッション",
|
|
||||||
workspace: "このワークスペース",
|
|
||||||
},
|
|
||||||
filters: {
|
filters: {
|
||||||
all: "すべて",
|
all: "すべて",
|
||||||
},
|
},
|
||||||
@@ -379,13 +369,12 @@ export const ja: TranslationResources = {
|
|||||||
updateHost: "セッションをインポートするにはホストを更新してください。",
|
updateHost: "セッションをインポートするにはホストを更新してください。",
|
||||||
noProviders: "インポート可能なプロバイダーが有効になっていません。",
|
noProviders: "インポート可能なプロバイダーが有効になっていません。",
|
||||||
loading: "最近のセッションを読み込み中...",
|
loading: "最近のセッションを読み込み中...",
|
||||||
failedProvider: "{{provider}} のセッションを読み込めませんでした",
|
failedAll: "最近のセッションを読み込めませんでした。",
|
||||||
|
failedProviders: "{{providers}}のセッションを読み込めませんでした。",
|
||||||
failedImport: "選択したセッションをインポートできませんでした。",
|
failedImport: "選択したセッションをインポートできませんでした。",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
refresh: "セッションを更新",
|
refresh: "セッションを更新",
|
||||||
showAll: "すべて表示",
|
|
||||||
loadMore: "さらに読み込む",
|
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
untitledSession: "無題のセッション",
|
untitledSession: "無題のセッション",
|
||||||
@@ -393,7 +382,6 @@ export const ja: TranslationResources = {
|
|||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
noRecent: "インポートする最近のセッションがありません。",
|
noRecent: "インポートする最近のセッションがありません。",
|
||||||
noMatches: "検索に一致するセッションがありません。",
|
|
||||||
alreadyImported: "最近のセッションはすでにすべてインポートされています。",
|
alreadyImported: "最近のセッションはすでにすべてインポートされています。",
|
||||||
noProviderSessions: "{{provider}}のセッションが見つかりません。",
|
noProviderSessions: "{{provider}}のセッションが見つかりません。",
|
||||||
},
|
},
|
||||||
@@ -507,23 +495,14 @@ export const ja: TranslationResources = {
|
|||||||
completed: "セットアップ完了",
|
completed: "セットアップ完了",
|
||||||
failed: "セットアップ失敗",
|
failed: "セットアップ失敗",
|
||||||
workspace: "ワークスペースセットアップ",
|
workspace: "ワークスペースセットアップ",
|
||||||
blocked: "セットアップはブロックされました",
|
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
running: "実行中",
|
running: "実行中",
|
||||||
completed: "完了",
|
completed: "完了",
|
||||||
failed: "失敗",
|
failed: "失敗",
|
||||||
waiting: "セットアップ出力を待機中",
|
waiting: "セットアップ出力を待機中",
|
||||||
blocked: "ブロック済み",
|
|
||||||
},
|
},
|
||||||
waiting: "ワークスペースをセットアップ中...",
|
waiting: "ワークスペースをセットアップ中...",
|
||||||
blocked: {
|
|
||||||
title: "セットアップは実行されませんでした",
|
|
||||||
description:
|
|
||||||
"このPRは別のリポジトリ {{repository}} からのものです。セットアップとスクリプトは未確認のコードを実行する可能性があります。",
|
|
||||||
run: "セットアップを実行",
|
|
||||||
runFailed: "ワークスペースのセットアップに失敗しました",
|
|
||||||
},
|
|
||||||
empty: {
|
empty: {
|
||||||
noCommands: "このワークスペースでセットアップコマンドは実行されませんでした。",
|
noCommands: "このワークスペースでセットアップコマンドは実行されませんでした。",
|
||||||
},
|
},
|
||||||
@@ -1115,6 +1094,7 @@ export const ja: TranslationResources = {
|
|||||||
addProject: "プロジェクトを追加",
|
addProject: "プロジェクトを追加",
|
||||||
newWorkspace: "新しいワークスペース",
|
newWorkspace: "新しいワークスペース",
|
||||||
hosts: "ホスト",
|
hosts: "ホスト",
|
||||||
|
home: "ホーム",
|
||||||
settings: "設定",
|
settings: "設定",
|
||||||
closeSidebar: "サイドバーを閉じる",
|
closeSidebar: "サイドバーを閉じる",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,12 +10,10 @@ export const ko: TranslationResources = {
|
|||||||
cancel: "취소",
|
cancel: "취소",
|
||||||
close: "닫기",
|
close: "닫기",
|
||||||
copy: "복사",
|
copy: "복사",
|
||||||
copyLine: "줄 복사",
|
|
||||||
dismiss: "닫기",
|
dismiss: "닫기",
|
||||||
retry: "다시 시도",
|
retry: "다시 시도",
|
||||||
search: "검색",
|
search: "검색",
|
||||||
select: "선택",
|
select: "선택",
|
||||||
selectAll: "모두 선택",
|
|
||||||
},
|
},
|
||||||
placeholders: {
|
placeholders: {
|
||||||
search: "검색...",
|
search: "검색...",
|
||||||
@@ -35,7 +33,6 @@ export const ko: TranslationResources = {
|
|||||||
errors: {
|
errors: {
|
||||||
error: "오류",
|
error: "오류",
|
||||||
unableToSave: "저장할 수 없습니다",
|
unableToSave: "저장할 수 없습니다",
|
||||||
unableToCopy: "복사할 수 없습니다",
|
|
||||||
nameRequired: "이름을 입력하세요",
|
nameRequired: "이름을 입력하세요",
|
||||||
daemonUnavailable: "데몬을 사용할 수 없습니다",
|
daemonUnavailable: "데몬을 사용할 수 없습니다",
|
||||||
daemonClientUnavailable: "데몬 클라이언트를 사용할 수 없습니다",
|
daemonClientUnavailable: "데몬 클라이언트를 사용할 수 없습니다",
|
||||||
@@ -290,7 +287,6 @@ export const ko: TranslationResources = {
|
|||||||
zoomIn: "확대",
|
zoomIn: "확대",
|
||||||
zoomOut: "축소",
|
zoomOut: "축소",
|
||||||
resetZoom: "보기 초기화",
|
resetZoom: "보기 초기화",
|
||||||
fullscreen: "전체 화면",
|
|
||||||
viewSource: "소스 보기",
|
viewSource: "소스 보기",
|
||||||
viewDiagram: "다이어그램 보기",
|
viewDiagram: "다이어그램 보기",
|
||||||
},
|
},
|
||||||
@@ -362,13 +358,7 @@ export const ko: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
importSession: {
|
importSession: {
|
||||||
chooseHostTitle: en.importSession.chooseHostTitle,
|
|
||||||
title: "세션 가져오기",
|
title: "세션 가져오기",
|
||||||
searchPlaceholder: "세션 검색...",
|
|
||||||
scope: {
|
|
||||||
host: "{{host}}의 세션",
|
|
||||||
workspace: "이 워크스페이스",
|
|
||||||
},
|
|
||||||
filters: {
|
filters: {
|
||||||
all: "전체",
|
all: "전체",
|
||||||
},
|
},
|
||||||
@@ -377,13 +367,12 @@ export const ko: TranslationResources = {
|
|||||||
updateHost: "세션을 가져오려면 호스트를 업데이트하세요.",
|
updateHost: "세션을 가져오려면 호스트를 업데이트하세요.",
|
||||||
noProviders: "가져올 수 있는 프로바이더가 활성화되어 있지 않습니다.",
|
noProviders: "가져올 수 있는 프로바이더가 활성화되어 있지 않습니다.",
|
||||||
loading: "최근 세션을 불러오는 중...",
|
loading: "최근 세션을 불러오는 중...",
|
||||||
failedProvider: "{{provider}} 세션을 불러올 수 없습니다",
|
failedAll: "최근 세션을 불러올 수 없습니다.",
|
||||||
|
failedProviders: "{{providers}}의 세션을 불러올 수 없습니다.",
|
||||||
failedImport: "선택한 세션을 가져올 수 없습니다.",
|
failedImport: "선택한 세션을 가져올 수 없습니다.",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
refresh: "세션 새로고침",
|
refresh: "세션 새로고침",
|
||||||
showAll: "전체 표시",
|
|
||||||
loadMore: "더 보기",
|
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
untitledSession: "제목 없는 세션",
|
untitledSession: "제목 없는 세션",
|
||||||
@@ -391,7 +380,6 @@ export const ko: TranslationResources = {
|
|||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
noRecent: "가져올 최근 세션이 없습니다.",
|
noRecent: "가져올 최근 세션이 없습니다.",
|
||||||
noMatches: "검색과 일치하는 세션이 없습니다.",
|
|
||||||
alreadyImported: "최근 세션이 모두 이미 가져와졌습니다.",
|
alreadyImported: "최근 세션이 모두 이미 가져와졌습니다.",
|
||||||
noProviderSessions: "{{provider}} 세션을 찾을 수 없습니다.",
|
noProviderSessions: "{{provider}} 세션을 찾을 수 없습니다.",
|
||||||
},
|
},
|
||||||
@@ -504,23 +492,14 @@ export const ko: TranslationResources = {
|
|||||||
completed: "설정 완료",
|
completed: "설정 완료",
|
||||||
failed: "설정 실패",
|
failed: "설정 실패",
|
||||||
workspace: "워크스페이스 설정",
|
workspace: "워크스페이스 설정",
|
||||||
blocked: "설정 차단됨",
|
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
running: "실행 중",
|
running: "실행 중",
|
||||||
completed: "완료됨",
|
completed: "완료됨",
|
||||||
failed: "실패함",
|
failed: "실패함",
|
||||||
waiting: "설정 출력을 기다리는 중",
|
waiting: "설정 출력을 기다리는 중",
|
||||||
blocked: "차단됨",
|
|
||||||
},
|
},
|
||||||
waiting: "워크스페이스 설정 중...",
|
waiting: "워크스페이스 설정 중...",
|
||||||
blocked: {
|
|
||||||
title: "설정이 실행되지 않았습니다",
|
|
||||||
description:
|
|
||||||
"이 PR은 다른 저장소인 {{repository}}에서 왔습니다. 설정과 스크립트가 검토하지 않은 코드를 실행할 수 있습니다.",
|
|
||||||
run: "설정 실행",
|
|
||||||
runFailed: "워크스페이스 설정 실행 실패",
|
|
||||||
},
|
|
||||||
empty: {
|
empty: {
|
||||||
noCommands: "이 워크스페이스에서 실행된 설정 명령이 없습니다.",
|
noCommands: "이 워크스페이스에서 실행된 설정 명령이 없습니다.",
|
||||||
},
|
},
|
||||||
@@ -1110,6 +1089,7 @@ export const ko: TranslationResources = {
|
|||||||
addProject: "프로젝트 추가",
|
addProject: "프로젝트 추가",
|
||||||
newWorkspace: "새 워크스페이스",
|
newWorkspace: "새 워크스페이스",
|
||||||
hosts: "호스트",
|
hosts: "호스트",
|
||||||
|
home: "홈",
|
||||||
settings: "설정",
|
settings: "설정",
|
||||||
closeSidebar: "사이드바 닫기",
|
closeSidebar: "사이드바 닫기",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,12 +10,10 @@ export const ptBR: TranslationResources = {
|
|||||||
cancel: "Cancelar",
|
cancel: "Cancelar",
|
||||||
close: "Fechar",
|
close: "Fechar",
|
||||||
copy: "Copiar",
|
copy: "Copiar",
|
||||||
copyLine: "Copiar linha",
|
|
||||||
dismiss: "Dispensar",
|
dismiss: "Dispensar",
|
||||||
retry: "Tentar novamente",
|
retry: "Tentar novamente",
|
||||||
search: "Buscar",
|
search: "Buscar",
|
||||||
select: "Selecionar",
|
select: "Selecionar",
|
||||||
selectAll: "Selecionar tudo",
|
|
||||||
},
|
},
|
||||||
placeholders: {
|
placeholders: {
|
||||||
search: "Buscar...",
|
search: "Buscar...",
|
||||||
@@ -35,7 +33,6 @@ export const ptBR: TranslationResources = {
|
|||||||
errors: {
|
errors: {
|
||||||
error: "Erro",
|
error: "Erro",
|
||||||
unableToSave: "Não foi possível salvar",
|
unableToSave: "Não foi possível salvar",
|
||||||
unableToCopy: "Não foi possível copiar",
|
|
||||||
nameRequired: "O nome é obrigatório",
|
nameRequired: "O nome é obrigatório",
|
||||||
daemonUnavailable: "Daemon indisponível",
|
daemonUnavailable: "Daemon indisponível",
|
||||||
daemonClientUnavailable: "Cliente do daemon indisponível",
|
daemonClientUnavailable: "Cliente do daemon indisponível",
|
||||||
@@ -292,7 +289,6 @@ export const ptBR: TranslationResources = {
|
|||||||
zoomIn: "Aproximar",
|
zoomIn: "Aproximar",
|
||||||
zoomOut: "Afastar",
|
zoomOut: "Afastar",
|
||||||
resetZoom: "Redefinir visualização",
|
resetZoom: "Redefinir visualização",
|
||||||
fullscreen: "Tela cheia",
|
|
||||||
viewSource: "Ver fonte",
|
viewSource: "Ver fonte",
|
||||||
viewDiagram: "Ver diagrama",
|
viewDiagram: "Ver diagrama",
|
||||||
},
|
},
|
||||||
@@ -364,13 +360,7 @@ export const ptBR: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
importSession: {
|
importSession: {
|
||||||
chooseHostTitle: en.importSession.chooseHostTitle,
|
|
||||||
title: "Importar sessão",
|
title: "Importar sessão",
|
||||||
searchPlaceholder: "Buscar sessões...",
|
|
||||||
scope: {
|
|
||||||
host: "Sessões em {{host}}",
|
|
||||||
workspace: "Este workspace",
|
|
||||||
},
|
|
||||||
filters: {
|
filters: {
|
||||||
all: "Tudo",
|
all: "Tudo",
|
||||||
},
|
},
|
||||||
@@ -379,13 +369,12 @@ export const ptBR: TranslationResources = {
|
|||||||
updateHost: "Atualize o host para importar sessões.",
|
updateHost: "Atualize o host para importar sessões.",
|
||||||
noProviders: "Nenhum provedor importável está ativado.",
|
noProviders: "Nenhum provedor importável está ativado.",
|
||||||
loading: "Carregando sessões recentes...",
|
loading: "Carregando sessões recentes...",
|
||||||
failedProvider: "Não foi possível carregar as sessões de {{provider}}",
|
failedAll: "Não foi possível carregar sessões recentes.",
|
||||||
|
failedProviders: "Não foi possível carregar sessões de {{providers}}.",
|
||||||
failedImport: "Não foi possível importar a sessão selecionada.",
|
failedImport: "Não foi possível importar a sessão selecionada.",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
refresh: "Atualizar sessões",
|
refresh: "Atualizar sessões",
|
||||||
showAll: "Mostrar tudo",
|
|
||||||
loadMore: "Carregar mais",
|
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
untitledSession: "Sessão sem título",
|
untitledSession: "Sessão sem título",
|
||||||
@@ -393,7 +382,6 @@ export const ptBR: TranslationResources = {
|
|||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
noRecent: "Nenhuma sessão recente para importar.",
|
noRecent: "Nenhuma sessão recente para importar.",
|
||||||
noMatches: "Nenhuma sessão corresponde à sua busca.",
|
|
||||||
alreadyImported: "Todas as sessões recentes já foram importadas.",
|
alreadyImported: "Todas as sessões recentes já foram importadas.",
|
||||||
noProviderSessions: "Nenhuma sessão de {{provider}} encontrada.",
|
noProviderSessions: "Nenhuma sessão de {{provider}} encontrada.",
|
||||||
},
|
},
|
||||||
@@ -507,23 +495,14 @@ export const ptBR: TranslationResources = {
|
|||||||
completed: "Configuração concluída",
|
completed: "Configuração concluída",
|
||||||
failed: "Falha na configuração",
|
failed: "Falha na configuração",
|
||||||
workspace: "Configuração do workspace",
|
workspace: "Configuração do workspace",
|
||||||
blocked: "Configuração bloqueada",
|
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
running: "Em execução",
|
running: "Em execução",
|
||||||
completed: "Concluído",
|
completed: "Concluído",
|
||||||
failed: "Falhou",
|
failed: "Falhou",
|
||||||
waiting: "Aguardando saída da configuração",
|
waiting: "Aguardando saída da configuração",
|
||||||
blocked: "Bloqueado",
|
|
||||||
},
|
},
|
||||||
waiting: "Configurando workspace...",
|
waiting: "Configurando workspace...",
|
||||||
blocked: {
|
|
||||||
title: "A configuração não foi executada",
|
|
||||||
description:
|
|
||||||
"Este PR vem de {{repository}}, um repositório diferente. A configuração e os scripts podem executar código que você não revisou.",
|
|
||||||
run: "Executar configuração",
|
|
||||||
runFailed: "Falha ao executar a configuração do workspace",
|
|
||||||
},
|
|
||||||
empty: {
|
empty: {
|
||||||
noCommands: "Nenhum comando de configuração foi executado para este workspace.",
|
noCommands: "Nenhum comando de configuração foi executado para este workspace.",
|
||||||
},
|
},
|
||||||
@@ -1128,6 +1107,7 @@ export const ptBR: TranslationResources = {
|
|||||||
addProject: "Adicionar projeto",
|
addProject: "Adicionar projeto",
|
||||||
newWorkspace: "Novo workspace",
|
newWorkspace: "Novo workspace",
|
||||||
hosts: "Hosts",
|
hosts: "Hosts",
|
||||||
|
home: "Início",
|
||||||
settings: "Configurações",
|
settings: "Configurações",
|
||||||
closeSidebar: "Fechar barra lateral",
|
closeSidebar: "Fechar barra lateral",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,12 +10,10 @@ export const ru: TranslationResources = {
|
|||||||
cancel: "Отмена",
|
cancel: "Отмена",
|
||||||
close: "Закрыть",
|
close: "Закрыть",
|
||||||
copy: "Копировать",
|
copy: "Копировать",
|
||||||
copyLine: "Копировать строку",
|
|
||||||
dismiss: "Отклонить",
|
dismiss: "Отклонить",
|
||||||
retry: "Повторить",
|
retry: "Повторить",
|
||||||
search: "Поиск",
|
search: "Поиск",
|
||||||
select: "Выбрать",
|
select: "Выбрать",
|
||||||
selectAll: "Выбрать все",
|
|
||||||
},
|
},
|
||||||
placeholders: {
|
placeholders: {
|
||||||
search: "Поиск...",
|
search: "Поиск...",
|
||||||
@@ -35,7 +33,6 @@ export const ru: TranslationResources = {
|
|||||||
errors: {
|
errors: {
|
||||||
error: "Ошибка",
|
error: "Ошибка",
|
||||||
unableToSave: "Не удалось сохранить",
|
unableToSave: "Не удалось сохранить",
|
||||||
unableToCopy: "Не удалось скопировать",
|
|
||||||
nameRequired: "Требуется имя",
|
nameRequired: "Требуется имя",
|
||||||
daemonUnavailable: "Daemon недоступен",
|
daemonUnavailable: "Daemon недоступен",
|
||||||
daemonClientUnavailable: "Daemon клиента недоступен",
|
daemonClientUnavailable: "Daemon клиента недоступен",
|
||||||
@@ -291,7 +288,6 @@ export const ru: TranslationResources = {
|
|||||||
zoomIn: "Увеличить масштаб",
|
zoomIn: "Увеличить масштаб",
|
||||||
zoomOut: "Уменьшить масштаб",
|
zoomOut: "Уменьшить масштаб",
|
||||||
resetZoom: "Сбросить масштаб",
|
resetZoom: "Сбросить масштаб",
|
||||||
fullscreen: "Полноэкранный режим",
|
|
||||||
viewSource: "Показать исходный код",
|
viewSource: "Показать исходный код",
|
||||||
viewDiagram: "Показать диаграмму",
|
viewDiagram: "Показать диаграмму",
|
||||||
},
|
},
|
||||||
@@ -363,13 +359,7 @@ export const ru: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
importSession: {
|
importSession: {
|
||||||
chooseHostTitle: en.importSession.chooseHostTitle,
|
|
||||||
title: "Импортировать сессию",
|
title: "Импортировать сессию",
|
||||||
searchPlaceholder: "Поиск сессий...",
|
|
||||||
scope: {
|
|
||||||
host: "Сессии на {{host}}",
|
|
||||||
workspace: "Это рабочее пространство",
|
|
||||||
},
|
|
||||||
filters: {
|
filters: {
|
||||||
all: "Все",
|
all: "Все",
|
||||||
},
|
},
|
||||||
@@ -378,13 +368,12 @@ export const ru: TranslationResources = {
|
|||||||
updateHost: "Обновите хост, чтобы импортировать сессии.",
|
updateHost: "Обновите хост, чтобы импортировать сессии.",
|
||||||
noProviders: "Нет включённых провайдеров с поддержкой импорта.",
|
noProviders: "Нет включённых провайдеров с поддержкой импорта.",
|
||||||
loading: "Загрузка недавних сессий...",
|
loading: "Загрузка недавних сессий...",
|
||||||
failedProvider: "Не удалось загрузить сессии провайдера {{provider}}",
|
failedAll: "Не удалось загрузить недавние сессии.",
|
||||||
|
failedProviders: "Не удалось загрузить сессии следующих провайдеров: {{providers}}.",
|
||||||
failedImport: "Не удалось импортировать выбранную сессию.",
|
failedImport: "Не удалось импортировать выбранную сессию.",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
refresh: "Обновить список сессий",
|
refresh: "Обновить список сессий",
|
||||||
showAll: "Показать все",
|
|
||||||
loadMore: "Загрузить ещё",
|
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
untitledSession: "Сессия без названия",
|
untitledSession: "Сессия без названия",
|
||||||
@@ -392,7 +381,6 @@ export const ru: TranslationResources = {
|
|||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
noRecent: "Нет недавних сессий для импорта.",
|
noRecent: "Нет недавних сессий для импорта.",
|
||||||
noMatches: "Нет сессий, соответствующих запросу.",
|
|
||||||
alreadyImported: "Все недавние сессии уже импортированы.",
|
alreadyImported: "Все недавние сессии уже импортированы.",
|
||||||
noProviderSessions: "Сессии провайдера {{provider}} не найдены.",
|
noProviderSessions: "Сессии провайдера {{provider}} не найдены.",
|
||||||
},
|
},
|
||||||
@@ -506,23 +494,14 @@ export const ru: TranslationResources = {
|
|||||||
completed: "Настройка завершена",
|
completed: "Настройка завершена",
|
||||||
failed: "Настройка не удалась",
|
failed: "Настройка не удалась",
|
||||||
workspace: "Настройка рабочего пространства",
|
workspace: "Настройка рабочего пространства",
|
||||||
blocked: "Настройка заблокирована",
|
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
running: "Выполняется",
|
running: "Выполняется",
|
||||||
completed: "Завершено",
|
completed: "Завершено",
|
||||||
failed: "Завершено с ошибкой",
|
failed: "Завершено с ошибкой",
|
||||||
waiting: "Ожидание вывода команд настройки",
|
waiting: "Ожидание вывода команд настройки",
|
||||||
blocked: "Заблокировано",
|
|
||||||
},
|
},
|
||||||
waiting: "Настройка рабочего пространства...",
|
waiting: "Настройка рабочего пространства...",
|
||||||
blocked: {
|
|
||||||
title: "Настройка не запускалась",
|
|
||||||
description:
|
|
||||||
"Этот PR поступил из другого репозитория — {{repository}}. Его настройка и скрипты могут выполнить код, который вы не проверяли.",
|
|
||||||
run: "Запустить настройку",
|
|
||||||
runFailed: "Не удалось запустить настройку рабочего пространства",
|
|
||||||
},
|
|
||||||
empty: {
|
empty: {
|
||||||
noCommands: "Для этого рабочего пространства не выполнялись команды настройки.",
|
noCommands: "Для этого рабочего пространства не выполнялись команды настройки.",
|
||||||
},
|
},
|
||||||
@@ -1119,6 +1098,7 @@ export const ru: TranslationResources = {
|
|||||||
addProject: "Добавить проект",
|
addProject: "Добавить проект",
|
||||||
newWorkspace: "Новое рабочее пространство",
|
newWorkspace: "Новое рабочее пространство",
|
||||||
hosts: "Хосты",
|
hosts: "Хосты",
|
||||||
|
home: "Главная",
|
||||||
settings: "Настройки",
|
settings: "Настройки",
|
||||||
closeSidebar: "Закрыть боковую панель",
|
closeSidebar: "Закрыть боковую панель",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,12 +10,10 @@ export const zhCN: TranslationResources = {
|
|||||||
cancel: "取消",
|
cancel: "取消",
|
||||||
close: "关闭",
|
close: "关闭",
|
||||||
copy: "复制",
|
copy: "复制",
|
||||||
copyLine: "复制行",
|
|
||||||
dismiss: "关闭",
|
dismiss: "关闭",
|
||||||
retry: "重试",
|
retry: "重试",
|
||||||
search: "搜索",
|
search: "搜索",
|
||||||
select: "选择",
|
select: "选择",
|
||||||
selectAll: "全选",
|
|
||||||
},
|
},
|
||||||
placeholders: {
|
placeholders: {
|
||||||
search: "搜索...",
|
search: "搜索...",
|
||||||
@@ -35,7 +33,6 @@ export const zhCN: TranslationResources = {
|
|||||||
errors: {
|
errors: {
|
||||||
error: "错误",
|
error: "错误",
|
||||||
unableToSave: "无法保存",
|
unableToSave: "无法保存",
|
||||||
unableToCopy: "无法复制",
|
|
||||||
nameRequired: "名称必填",
|
nameRequired: "名称必填",
|
||||||
daemonUnavailable: "Daemon 不可用",
|
daemonUnavailable: "Daemon 不可用",
|
||||||
daemonClientUnavailable: "Daemon client 不可用",
|
daemonClientUnavailable: "Daemon client 不可用",
|
||||||
@@ -289,7 +286,6 @@ export const zhCN: TranslationResources = {
|
|||||||
zoomIn: "放大",
|
zoomIn: "放大",
|
||||||
zoomOut: "缩小",
|
zoomOut: "缩小",
|
||||||
resetZoom: "重置视图",
|
resetZoom: "重置视图",
|
||||||
fullscreen: "全屏",
|
|
||||||
viewSource: "查看源码",
|
viewSource: "查看源码",
|
||||||
viewDiagram: "查看图表",
|
viewDiagram: "查看图表",
|
||||||
},
|
},
|
||||||
@@ -361,13 +357,7 @@ export const zhCN: TranslationResources = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
importSession: {
|
importSession: {
|
||||||
chooseHostTitle: en.importSession.chooseHostTitle,
|
|
||||||
title: "导入会话",
|
title: "导入会话",
|
||||||
searchPlaceholder: "搜索会话...",
|
|
||||||
scope: {
|
|
||||||
host: "{{host}} 上的会话",
|
|
||||||
workspace: "此 workspace",
|
|
||||||
},
|
|
||||||
filters: {
|
filters: {
|
||||||
all: "全部",
|
all: "全部",
|
||||||
},
|
},
|
||||||
@@ -376,13 +366,12 @@ export const zhCN: TranslationResources = {
|
|||||||
updateHost: "更新 Host 以导入会话。",
|
updateHost: "更新 Host 以导入会话。",
|
||||||
noProviders: "没有已启用的可导入 Provider。",
|
noProviders: "没有已启用的可导入 Provider。",
|
||||||
loading: "正在加载最近会话...",
|
loading: "正在加载最近会话...",
|
||||||
failedProvider: "无法加载 {{provider}} 的会话",
|
failedAll: "无法加载最近会话。",
|
||||||
|
failedProviders: "无法加载 {{providers}} 的会话。",
|
||||||
failedImport: "无法导入所选会话。",
|
failedImport: "无法导入所选会话。",
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
refresh: "刷新会话",
|
refresh: "刷新会话",
|
||||||
showAll: "显示全部",
|
|
||||||
loadMore: "加载更多",
|
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
untitledSession: "未命名会话",
|
untitledSession: "未命名会话",
|
||||||
@@ -390,7 +379,6 @@ export const zhCN: TranslationResources = {
|
|||||||
},
|
},
|
||||||
empty: {
|
empty: {
|
||||||
noRecent: "没有可导入的最近会话。",
|
noRecent: "没有可导入的最近会话。",
|
||||||
noMatches: "没有与搜索匹配的会话。",
|
|
||||||
alreadyImported: "所有最近会话都已导入。",
|
alreadyImported: "所有最近会话都已导入。",
|
||||||
noProviderSessions: "没有找到 {{provider}} 会话。",
|
noProviderSessions: "没有找到 {{provider}} 会话。",
|
||||||
},
|
},
|
||||||
@@ -503,23 +491,14 @@ export const zhCN: TranslationResources = {
|
|||||||
completed: "Setup 已完成",
|
completed: "Setup 已完成",
|
||||||
failed: "Setup 失败",
|
failed: "Setup 失败",
|
||||||
workspace: "Workspace setup",
|
workspace: "Workspace setup",
|
||||||
blocked: "设置已阻止",
|
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
running: "正在运行",
|
running: "正在运行",
|
||||||
completed: "已完成",
|
completed: "已完成",
|
||||||
failed: "失败",
|
failed: "失败",
|
||||||
waiting: "正在等待 setup 输出",
|
waiting: "正在等待 setup 输出",
|
||||||
blocked: "已阻止",
|
|
||||||
},
|
},
|
||||||
waiting: "正在 setup workspace...",
|
waiting: "正在 setup workspace...",
|
||||||
blocked: {
|
|
||||||
title: "未运行设置",
|
|
||||||
description:
|
|
||||||
"此 PR 来自另一个仓库 {{repository}}。其设置和脚本可能会运行你尚未审查的代码。",
|
|
||||||
run: "运行设置",
|
|
||||||
runFailed: "运行工作区设置失败",
|
|
||||||
},
|
|
||||||
empty: {
|
empty: {
|
||||||
noCommands: "此 workspace 没有运行 setup 命令。",
|
noCommands: "此 workspace 没有运行 setup 命令。",
|
||||||
},
|
},
|
||||||
@@ -1095,6 +1074,7 @@ export const zhCN: TranslationResources = {
|
|||||||
addProject: "添加 project",
|
addProject: "添加 project",
|
||||||
newWorkspace: "新建工作区",
|
newWorkspace: "新建工作区",
|
||||||
hosts: "Hosts",
|
hosts: "Hosts",
|
||||||
|
home: "首页",
|
||||||
settings: "设置",
|
settings: "设置",
|
||||||
closeSidebar: "关闭侧边栏",
|
closeSidebar: "关闭侧边栏",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { Alert } from "@/components/ui/alert";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native";
|
import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -17,7 +15,6 @@ import {
|
|||||||
type WorkspaceSetupSnapshot,
|
type WorkspaceSetupSnapshot,
|
||||||
} from "@/stores/workspace-setup-store";
|
} from "@/stores/workspace-setup-store";
|
||||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||||
import { useHostFeature } from "@/runtime/host-features";
|
|
||||||
|
|
||||||
function useSetupPanelDescriptor(
|
function useSetupPanelDescriptor(
|
||||||
target: { kind: "setup"; workspaceId: string },
|
target: { kind: "setup"; workspaceId: string },
|
||||||
@@ -52,17 +49,6 @@ function useSetupPanelDescriptor(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot?.status === "blocked") {
|
|
||||||
return {
|
|
||||||
label: t("workspace.setup.descriptor.label"),
|
|
||||||
subtitle: t("workspace.setup.descriptor.blocked"),
|
|
||||||
tooltip: t("workspace.setup.descriptor.blocked"),
|
|
||||||
titleState: "ready",
|
|
||||||
icon: CircleAlert,
|
|
||||||
statusBucket: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
label: t("workspace.setup.descriptor.label"),
|
label: t("workspace.setup.descriptor.label"),
|
||||||
subtitle: t("workspace.setup.descriptor.workspace"),
|
subtitle: t("workspace.setup.descriptor.workspace"),
|
||||||
@@ -114,55 +100,6 @@ type SetupCommand = WorkspaceSetupSnapshot["detail"]["commands"][number];
|
|||||||
|
|
||||||
const EMPTY_COMMANDS: SetupCommand[] = [];
|
const EMPTY_COMMANDS: SetupCommand[] = [];
|
||||||
|
|
||||||
function BlockedSetupNotice({
|
|
||||||
serverId,
|
|
||||||
workspaceId,
|
|
||||||
source,
|
|
||||||
}: {
|
|
||||||
serverId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
source: NonNullable<WorkspaceSetupSnapshot["blockedSource"]>;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const client = useHostRuntimeClient(serverId);
|
|
||||||
const supportsSetupRun = useHostFeature(serverId, "workspaceSetupRun");
|
|
||||||
const [runError, setRunError] = useState<string | null>(null);
|
|
||||||
const [isRunningSetup, setIsRunningSetup] = useState(false);
|
|
||||||
const runSetup = useCallback(async () => {
|
|
||||||
if (!client) return;
|
|
||||||
setRunError(null);
|
|
||||||
setIsRunningSetup(true);
|
|
||||||
try {
|
|
||||||
const response = await client.runWorkspaceSetup(workspaceId);
|
|
||||||
if (response.error) throw new Error(response.error);
|
|
||||||
} catch (error) {
|
|
||||||
setRunError(error instanceof Error ? error.message : t("workspace.setup.blocked.runFailed"));
|
|
||||||
} finally {
|
|
||||||
setIsRunningSetup(false);
|
|
||||||
}
|
|
||||||
}, [client, t, workspaceId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Alert
|
|
||||||
variant="warning"
|
|
||||||
title={t("workspace.setup.blocked.title")}
|
|
||||||
description={t("workspace.setup.blocked.description", {
|
|
||||||
repository: source.headRepository,
|
|
||||||
})}
|
|
||||||
testID="workspace-setup-blocked"
|
|
||||||
>
|
|
||||||
{supportsSetupRun ? (
|
|
||||||
<Button accessibilityRole="button" onPress={runSetup} loading={isRunningSetup} size="sm">
|
|
||||||
{t("workspace.setup.blocked.run")}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</Alert>
|
|
||||||
{runError ? <Alert variant="error" description={runError} /> : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAutoExpandIndex(commands: { index: number; status: string }[]): number | null {
|
function resolveAutoExpandIndex(commands: { index: number; status: string }[]): number | null {
|
||||||
const running = commands.find((c) => c.status === "running");
|
const running = commands.find((c) => c.status === "running");
|
||||||
if (running) return running.index;
|
if (running) return running.index;
|
||||||
@@ -172,12 +109,11 @@ function resolveAutoExpandIndex(commands: { index: number; status: string }[]):
|
|||||||
|
|
||||||
function resolveSetupStatusLabel(
|
function resolveSetupStatusLabel(
|
||||||
status: string | undefined,
|
status: string | undefined,
|
||||||
labels: Record<CommandStatus | "blocked" | "waiting", string>,
|
labels: Record<CommandStatus | "waiting", string>,
|
||||||
): string {
|
): string {
|
||||||
if (status === "running") return labels.running;
|
if (status === "running") return labels.running;
|
||||||
if (status === "completed") return labels.completed;
|
if (status === "completed") return labels.completed;
|
||||||
if (status === "failed") return labels.failed;
|
if (status === "failed") return labels.failed;
|
||||||
if (status === "blocked") return labels.blocked;
|
|
||||||
return labels.waiting;
|
return labels.waiting;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,18 +151,26 @@ function buildCommandRowState(args: BuildCommandRowPropsArgs) {
|
|||||||
return { hasError, hasLog, isExpandable, isAutoExpanded, showDetail, processedLog };
|
return { hasError, hasLog, isExpandable, isAutoExpanded, showDetail, processedLog };
|
||||||
}
|
}
|
||||||
|
|
||||||
function useWorkspaceSetupSnapshot(serverId: string, workspaceId: string) {
|
function SetupPanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { serverId, target } = usePaneContext();
|
||||||
|
invariant(target.kind === "setup", "SetupPanel requires setup target");
|
||||||
|
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
const key = buildWorkspaceTabPersistenceKey({
|
||||||
|
serverId,
|
||||||
|
workspaceId: target.workspaceId,
|
||||||
|
});
|
||||||
const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null));
|
const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null));
|
||||||
const upsertProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
|
const upsertProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
|
||||||
const requestedRef = useRef(false);
|
|
||||||
|
|
||||||
|
// On mount, if no snapshot in the store, request cached status from server
|
||||||
|
const requestedRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (snapshot || requestedRef.current || !client) return;
|
if (snapshot || requestedRef.current || !client) return;
|
||||||
requestedRef.current = true;
|
requestedRef.current = true;
|
||||||
client
|
client
|
||||||
.fetchWorkspaceSetupStatus(workspaceId)
|
.fetchWorkspaceSetupStatus(target.workspaceId)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response.snapshot) {
|
if (response.snapshot) {
|
||||||
upsertProgress({
|
upsertProgress({
|
||||||
@@ -237,42 +181,9 @@ function useWorkspaceSetupSnapshot(serverId: string, workspaceId: string) {
|
|||||||
return;
|
return;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Server may not support this yet — ignore.
|
// Server may not support this yet — ignore
|
||||||
});
|
});
|
||||||
}, [client, snapshot, serverId, upsertProgress, workspaceId]);
|
}, [client, snapshot, serverId, target.workspaceId, upsertProgress]);
|
||||||
|
|
||||||
return snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
function useExpandedSetupCommands() {
|
|
||||||
const [expandedIndices, setExpandedIndices] = useState<Set<number>>(new Set());
|
|
||||||
const [manuallyCollapsed, setManuallyCollapsed] = useState<Set<number>>(new Set());
|
|
||||||
const toggleExpanded = useCallback((index: number, isAutoExpanded: boolean) => {
|
|
||||||
setExpandedIndices((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(index) || isAutoExpanded) {
|
|
||||||
next.delete(index);
|
|
||||||
if (isAutoExpanded) setManuallyCollapsed((current) => new Set(current).add(index));
|
|
||||||
} else {
|
|
||||||
next.add(index);
|
|
||||||
setManuallyCollapsed((current) => {
|
|
||||||
const updated = new Set(current);
|
|
||||||
updated.delete(index);
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
return { expandedIndices, manuallyCollapsed, toggleExpanded };
|
|
||||||
}
|
|
||||||
|
|
||||||
function SetupPanel() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { serverId, target } = usePaneContext();
|
|
||||||
invariant(target.kind === "setup", "SetupPanel requires setup target");
|
|
||||||
|
|
||||||
const snapshot = useWorkspaceSetupSnapshot(serverId, target.workspaceId);
|
|
||||||
|
|
||||||
const commands = snapshot?.detail.commands ?? EMPTY_COMMANDS;
|
const commands = snapshot?.detail.commands ?? EMPTY_COMMANDS;
|
||||||
const log = snapshot?.detail.log ?? "";
|
const log = snapshot?.detail.log ?? "";
|
||||||
@@ -280,7 +191,30 @@ function SetupPanel() {
|
|||||||
snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0;
|
snapshot?.status === "completed" && commands.length === 0 && log.trim().length === 0;
|
||||||
const isWaiting = !snapshot || (snapshot.status === "running" && commands.length === 0);
|
const isWaiting = !snapshot || (snapshot.status === "running" && commands.length === 0);
|
||||||
|
|
||||||
const { expandedIndices, manuallyCollapsed, toggleExpanded } = useExpandedSetupCommands();
|
const [expandedIndices, setExpandedIndices] = useState<Set<number>>(new Set());
|
||||||
|
const [manuallyCollapsed, setManuallyCollapsed] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
const toggleExpanded = useCallback((index: number, isAutoExpanded: boolean) => {
|
||||||
|
setExpandedIndices((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(index) || isAutoExpanded) {
|
||||||
|
next.delete(index);
|
||||||
|
// If this was auto-expanded, record that the user manually collapsed it
|
||||||
|
if (isAutoExpanded) {
|
||||||
|
setManuallyCollapsed((mc) => new Set(mc).add(index));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next.add(index);
|
||||||
|
// If the user re-expands, remove from manually collapsed
|
||||||
|
setManuallyCollapsed((mc) => {
|
||||||
|
const updated = new Set(mc);
|
||||||
|
updated.delete(index);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const autoExpandIndex = resolveAutoExpandIndex(commands);
|
const autoExpandIndex = resolveAutoExpandIndex(commands);
|
||||||
const statusLabels = useMemo(
|
const statusLabels = useMemo(
|
||||||
@@ -288,7 +222,6 @@ function SetupPanel() {
|
|||||||
running: t("workspace.setup.status.running"),
|
running: t("workspace.setup.status.running"),
|
||||||
completed: t("workspace.setup.status.completed"),
|
completed: t("workspace.setup.status.completed"),
|
||||||
failed: t("workspace.setup.status.failed"),
|
failed: t("workspace.setup.status.failed"),
|
||||||
blocked: t("workspace.setup.status.blocked"),
|
|
||||||
waiting: t("workspace.setup.status.waiting"),
|
waiting: t("workspace.setup.status.waiting"),
|
||||||
}),
|
}),
|
||||||
[t],
|
[t],
|
||||||
@@ -305,97 +238,60 @@ function SetupPanel() {
|
|||||||
<Text style={styles.hiddenStatus} testID="workspace-setup-status">
|
<Text style={styles.hiddenStatus} testID="workspace-setup-status">
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
</Text>
|
</Text>
|
||||||
<SetupPanelBody
|
|
||||||
serverId={serverId}
|
{isWaiting ? (
|
||||||
workspaceId={target.workspaceId}
|
<View style={styles.waitingContainer}>
|
||||||
snapshot={snapshot}
|
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
||||||
commands={commands}
|
<Text style={styles.waitingText}>{t("workspace.setup.waiting")}</Text>
|
||||||
log={log}
|
</View>
|
||||||
isWaiting={isWaiting}
|
) : null}
|
||||||
hasNoSetupCommands={hasNoSetupCommands}
|
{!isWaiting && hasNoSetupCommands ? (
|
||||||
autoExpandIndex={autoExpandIndex}
|
<View style={styles.emptyContainer}>
|
||||||
expandedIndices={expandedIndices}
|
<Text
|
||||||
manuallyCollapsed={manuallyCollapsed}
|
style={styles.emptyText}
|
||||||
onToggle={toggleExpanded}
|
accessible
|
||||||
/>
|
accessibilityLabel={t("workspace.setup.accessibility.noCommands")}
|
||||||
|
>
|
||||||
|
{t("workspace.setup.empty.noCommands")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{!isWaiting && !hasNoSetupCommands ? (
|
||||||
|
<View style={styles.commandList}>
|
||||||
|
{commands.map((command) => {
|
||||||
|
const rowState = buildCommandRowState({
|
||||||
|
command,
|
||||||
|
autoExpandIndex,
|
||||||
|
log,
|
||||||
|
expandedIndices,
|
||||||
|
manuallyCollapsed,
|
||||||
|
snapshotError: snapshot?.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SetupCommandRow
|
||||||
|
key={`${command.index}:${command.command}`}
|
||||||
|
command={command}
|
||||||
|
showDetail={rowState.showDetail}
|
||||||
|
isAutoExpanded={rowState.isAutoExpanded}
|
||||||
|
isExpandable={rowState.isExpandable}
|
||||||
|
hasLog={rowState.hasLog}
|
||||||
|
hasError={rowState.hasError}
|
||||||
|
processedLog={rowState.processedLog}
|
||||||
|
errorMessage={snapshot?.error ?? null}
|
||||||
|
onToggle={toggleExpanded}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<StandaloneLogView commands={commands} log={log} />
|
||||||
|
<TopLevelSetupError snapshotError={snapshot?.error ?? null} commands={commands} />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SetupPanelBodyProps {
|
|
||||||
serverId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
snapshot: WorkspaceSetupSnapshot | null;
|
|
||||||
commands: SetupCommand[];
|
|
||||||
log: string;
|
|
||||||
isWaiting: boolean;
|
|
||||||
hasNoSetupCommands: boolean;
|
|
||||||
autoExpandIndex: number | null;
|
|
||||||
expandedIndices: Set<number>;
|
|
||||||
manuallyCollapsed: Set<number>;
|
|
||||||
onToggle: (index: number, isAutoExpanded: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SetupPanelBody(props: SetupPanelBodyProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { snapshot, commands, log } = props;
|
|
||||||
if (snapshot?.status === "blocked" && snapshot.blockedSource) {
|
|
||||||
return (
|
|
||||||
<BlockedSetupNotice
|
|
||||||
serverId={props.serverId}
|
|
||||||
workspaceId={props.workspaceId}
|
|
||||||
source={snapshot.blockedSource}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (props.isWaiting) {
|
|
||||||
return (
|
|
||||||
<View style={styles.waitingContainer}>
|
|
||||||
<ThemedLoadingSpinner size="large" uniProps={foregroundMutedColorMapping} />
|
|
||||||
<Text style={styles.waitingText}>{t("workspace.setup.waiting")}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (props.hasNoSetupCommands) {
|
|
||||||
return (
|
|
||||||
<View style={styles.emptyContainer}>
|
|
||||||
<Text
|
|
||||||
style={styles.emptyText}
|
|
||||||
accessible
|
|
||||||
accessibilityLabel={t("workspace.setup.accessibility.noCommands")}
|
|
||||||
>
|
|
||||||
{t("workspace.setup.empty.noCommands")}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<View style={styles.commandList}>
|
|
||||||
{commands.map((command) => {
|
|
||||||
const rowState = buildCommandRowState({
|
|
||||||
command,
|
|
||||||
autoExpandIndex: props.autoExpandIndex,
|
|
||||||
log,
|
|
||||||
expandedIndices: props.expandedIndices,
|
|
||||||
manuallyCollapsed: props.manuallyCollapsed,
|
|
||||||
snapshotError: snapshot?.error,
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<SetupCommandRow
|
|
||||||
key={`${command.index}:${command.command}`}
|
|
||||||
command={command}
|
|
||||||
{...rowState}
|
|
||||||
errorMessage={snapshot?.error ?? null}
|
|
||||||
onToggle={props.onToggle}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<StandaloneLogView commands={commands} log={log} />
|
|
||||||
<TopLevelSetupError snapshotError={snapshot?.error ?? null} commands={commands} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SetupCommandRowProps {
|
interface SetupCommandRowProps {
|
||||||
command: SetupCommand;
|
command: SetupCommand;
|
||||||
showDetail: boolean;
|
showDetail: boolean;
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
|
|||||||
onSelect: onAgentSelect,
|
onSelect: onAgentSelect,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
clientSlashCommands: [],
|
clientSide: null,
|
||||||
attachmentSources: [],
|
attachmentSources: [],
|
||||||
themes: [],
|
themes: [],
|
||||||
timelineTransformers: [],
|
timelineTransformers: [],
|
||||||
|
|||||||
+32
-10
@@ -1,16 +1,16 @@
|
|||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { PluginComposerPillProps } from "@getpaseo/plugin";
|
import type { PluginClientContext, PluginComposerPillProps } from "@getpaseo/plugin";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import type { InstalledPlugin } from "./types";
|
import type { InstalledPlugin } from "../types";
|
||||||
import { createPluginClientRuntime } from "./client-runtime";
|
import { startPluginClientSide } from "./lifecycle";
|
||||||
import { pluginComposerPillStore } from "./composer-pills/store";
|
import { pluginComposerPillStore } from "./store";
|
||||||
|
|
||||||
function Pill(_props: PluginComposerPillProps) {
|
function Pill(_props: PluginComposerPillProps) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function installation(): InstalledPlugin {
|
function installation(clientSide: NonNullable<InstalledPlugin["clientSide"]>): InstalledPlugin {
|
||||||
return {
|
return {
|
||||||
id: "review",
|
id: "review",
|
||||||
serverId: "host-a",
|
serverId: "host-a",
|
||||||
@@ -21,7 +21,7 @@ function installation(): InstalledPlugin {
|
|||||||
sidebarItems: [],
|
sidebarItems: [],
|
||||||
workspacePanels: [],
|
workspacePanels: [],
|
||||||
commandCenterItems: [],
|
commandCenterItems: [],
|
||||||
clientSlashCommands: [],
|
clientSide,
|
||||||
attachmentSources: [],
|
attachmentSources: [],
|
||||||
themes: [],
|
themes: [],
|
||||||
timelineTransformers: [],
|
timelineTransformers: [],
|
||||||
@@ -39,10 +39,19 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("plugin client-side lifecycle", () => {
|
describe("plugin client-side lifecycle", () => {
|
||||||
it("lets the client runtime add and remove a targeted composer pill", () => {
|
it("lets client code add and remove a targeted composer pill at runtime", async () => {
|
||||||
const plugin = installation();
|
const captured: { client?: PluginClientContext } = {};
|
||||||
|
let cleanupCount = 0;
|
||||||
|
const plugin = installation((context) => {
|
||||||
|
captured.client = context;
|
||||||
|
return () => {
|
||||||
|
cleanupCount += 1;
|
||||||
|
};
|
||||||
|
});
|
||||||
installations.push(plugin);
|
installations.push(plugin);
|
||||||
const client = createPluginClientRuntime(plugin, daemonClient);
|
const stop = startPluginClientSide(plugin, daemonClient);
|
||||||
|
const client = captured.client;
|
||||||
|
if (!client) throw new Error("Client lifecycle did not start");
|
||||||
|
|
||||||
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
|
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
|
||||||
const remove = client.addComposerPill({
|
const remove = client.addComposerPill({
|
||||||
@@ -64,10 +73,23 @@ describe("plugin client-side lifecycle", () => {
|
|||||||
|
|
||||||
remove();
|
remove();
|
||||||
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
|
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
|
||||||
|
|
||||||
|
client.addComposerPill({
|
||||||
|
id: "review-ready",
|
||||||
|
title: "Open review",
|
||||||
|
workspaceId: "workspace-a",
|
||||||
|
agentId: "agent-a",
|
||||||
|
Component: Pill,
|
||||||
|
onPress() {},
|
||||||
|
});
|
||||||
|
await stop();
|
||||||
|
|
||||||
|
expect(pluginComposerPillStore.getSnapshot()).toEqual([]);
|
||||||
|
expect(cleanupCount).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects duplicate pills only within the same plugin target", () => {
|
it("rejects duplicate pills only within the same plugin target", () => {
|
||||||
const plugin = installation();
|
const plugin = installation(() => () => undefined);
|
||||||
installations.push(plugin);
|
installations.push(plugin);
|
||||||
const contribution = {
|
const contribution = {
|
||||||
id: "review-ready",
|
id: "review-ready",
|
||||||
+34
-11
@@ -1,21 +1,25 @@
|
|||||||
|
import type {
|
||||||
|
PluginCleanup,
|
||||||
|
PluginClientContext,
|
||||||
|
PluginClientOpenPanelOptions,
|
||||||
|
} from "@getpaseo/plugin";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin";
|
|
||||||
import {
|
import {
|
||||||
createPluginAgentActionContext,
|
createPluginAgentActionContext,
|
||||||
createPluginCapabilities,
|
createPluginCapabilities,
|
||||||
createPluginWorkspaceActionContext,
|
createPluginWorkspaceActionContext,
|
||||||
} from "./actions";
|
} from "../actions";
|
||||||
import { createPluginClientStateSource } from "./client-state/source";
|
import { createPluginClientStateSource } from "../client-state/source";
|
||||||
import type { PluginClientRuntime } from "./evaluate";
|
import { createPluginNavigation } from "../navigation";
|
||||||
import { createPluginNavigation } from "./navigation";
|
import { createPluginSurfaceRuntime } from "../surface-runtime";
|
||||||
import { pluginComposerPillStore } from "./composer-pills/store";
|
import type { InstalledPlugin } from "../types";
|
||||||
import { createPluginSurfaceRuntime } from "./surface-runtime";
|
import { pluginComposerPillStore } from "./store";
|
||||||
import type { InstalledPlugin } from "./types";
|
|
||||||
|
|
||||||
export function createPluginClientRuntime(
|
export function startPluginClientSide(
|
||||||
installation: InstalledPlugin,
|
installation: InstalledPlugin,
|
||||||
daemonClient: DaemonClient,
|
daemonClient: DaemonClient,
|
||||||
): PluginClientRuntime {
|
): PluginCleanup {
|
||||||
|
if (!installation.clientSide) return () => undefined;
|
||||||
const runtime = createPluginSurfaceRuntime(daemonClient, installation.id);
|
const runtime = createPluginSurfaceRuntime(daemonClient, installation.id);
|
||||||
if (!runtime) throw new Error("Plugin host is offline");
|
if (!runtime) throw new Error("Plugin host is offline");
|
||||||
const state = createPluginClientStateSource(installation.serverId);
|
const state = createPluginClientStateSource(installation.serverId);
|
||||||
@@ -24,7 +28,7 @@ export function createPluginClientRuntime(
|
|||||||
runtime,
|
runtime,
|
||||||
createPluginNavigation({ serverId: installation.serverId, workspaceId: null }),
|
createPluginNavigation({ serverId: installation.serverId, workspaceId: null }),
|
||||||
);
|
);
|
||||||
return {
|
const context: PluginClientContext = {
|
||||||
...capabilities,
|
...capabilities,
|
||||||
addComposerPill(contribution) {
|
addComposerPill(contribution) {
|
||||||
return pluginComposerPillStore.add(installation, contribution);
|
return pluginComposerPillStore.add(installation, contribution);
|
||||||
@@ -33,6 +37,25 @@ export function createPluginClientRuntime(
|
|||||||
openClientPanel({ installation, runtime, state, panelId, options });
|
openClientPanel({ installation, runtime, state, panelId, options });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let cleanup: PluginCleanup;
|
||||||
|
try {
|
||||||
|
cleanup = installation.clientSide(context);
|
||||||
|
if (typeof cleanup !== "function") {
|
||||||
|
throw new Error("Plugin client-side entrypoint must return a cleanup function");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
pluginComposerPillStore.removeInstallation(installation);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
return async () => {
|
||||||
|
if (stopped) return;
|
||||||
|
stopped = true;
|
||||||
|
pluginComposerPillStore.removeInstallation(installation);
|
||||||
|
await cleanup();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function openClientPanel(input: {
|
function openClientPanel(input: {
|
||||||
@@ -1,19 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { runPluginClientBundle, type PluginClientRuntime } from "./evaluate";
|
import { evaluatePluginClientBundle } from "./evaluate";
|
||||||
|
|
||||||
const runtime = {
|
|
||||||
paseo: {},
|
|
||||||
async rpc() {},
|
|
||||||
openSurface() {},
|
|
||||||
openPanel() {},
|
|
||||||
addComposerPill() {
|
|
||||||
return () => undefined;
|
|
||||||
},
|
|
||||||
} as unknown as PluginClientRuntime;
|
|
||||||
|
|
||||||
function evaluatePluginClientBundle(id: string, source: string) {
|
|
||||||
return runPluginClientBundle(id, source, runtime);
|
|
||||||
}
|
|
||||||
|
|
||||||
function bundle(body: string): string {
|
function bundle(body: string): string {
|
||||||
return `(function() {
|
return `(function() {
|
||||||
@@ -24,73 +10,6 @@ function bundle(body: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("evaluatePluginClientBundle", () => {
|
describe("evaluatePluginClientBundle", () => {
|
||||||
it("returns idempotent removers for every client registration", () => {
|
|
||||||
let pillCount = 0;
|
|
||||||
const plugin = runPluginClientBundle(
|
|
||||||
"removals",
|
|
||||||
bundle(`
|
|
||||||
function Component() { return null; }
|
|
||||||
const schema = { safeParse(value) { return { success: true, data: value }; } };
|
|
||||||
globalThis.__pluginRemovals = [
|
|
||||||
plugin.addSurface("main", Component),
|
|
||||||
plugin.addSidebarItem({ id: "main", title: "Main", icon: "Blocks", surface: "main" }),
|
|
||||||
plugin.addWorkspacePanel({ id: "panel", title: "Panel", icon: "Blocks", context: "workspace", Component }),
|
|
||||||
plugin.addCommandCenterItem({ id: "command", title: "Command", icon: "Blocks", context: "global", onSelect() {} }),
|
|
||||||
plugin.addSlashCommand({ name: "review", description: "Review", argumentHint: "", context: "workspace", onSubmit() {} }),
|
|
||||||
plugin.addComposerPill({ id: "pill", title: "Pill", workspaceId: "workspace", agentId: "agent", Component, onPress() {} }),
|
|
||||||
plugin.addAttachmentSource({ id: "issues", title: "Issues", icon: "Blocks", pickerTitle: "Attach issue", searchPlaceholder: "Search", search: { name: "issues.search", input: {}, output: {} } }),
|
|
||||||
plugin.addTheme({ id: "night", name: "Night", appearance: "dark", colors: { background: "#000", foreground: "#fff", raised: "#111", control: "#222", border: "#333", mutedForeground: "#aaa", ring: "#555" } }),
|
|
||||||
plugin.addTimelineTransformer({ id: "transformer", query: { itemType: "tool_call" }, transform() { return { items: [] }; } }),
|
|
||||||
plugin.addTimelineRenderer({ kind: "card", version: 1, schema, Component }),
|
|
||||||
];
|
|
||||||
`),
|
|
||||||
{
|
|
||||||
...runtime,
|
|
||||||
addComposerPill() {
|
|
||||||
pillCount += 1;
|
|
||||||
return () => {
|
|
||||||
pillCount -= 1;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const removals = Reflect.get(globalThis, "__pluginRemovals") as Array<() => void>;
|
|
||||||
|
|
||||||
expect(
|
|
||||||
[
|
|
||||||
plugin.surfaces,
|
|
||||||
plugin.sidebarItems,
|
|
||||||
plugin.workspacePanels,
|
|
||||||
plugin.commandCenterItems,
|
|
||||||
plugin.clientSlashCommands,
|
|
||||||
plugin.attachmentSources,
|
|
||||||
plugin.themes,
|
|
||||||
plugin.timelineTransformers,
|
|
||||||
plugin.timelineRenderers,
|
|
||||||
].every((items) => items.length === 1),
|
|
||||||
).toBe(true);
|
|
||||||
expect(pillCount).toBe(1);
|
|
||||||
for (const remove of removals) {
|
|
||||||
remove();
|
|
||||||
remove();
|
|
||||||
}
|
|
||||||
expect(
|
|
||||||
[
|
|
||||||
plugin.surfaces,
|
|
||||||
plugin.sidebarItems,
|
|
||||||
plugin.workspacePanels,
|
|
||||||
plugin.commandCenterItems,
|
|
||||||
plugin.clientSlashCommands,
|
|
||||||
plugin.attachmentSources,
|
|
||||||
plugin.themes,
|
|
||||||
plugin.timelineTransformers,
|
|
||||||
plugin.timelineRenderers,
|
|
||||||
].every((items) => items.length === 0),
|
|
||||||
).toBe(true);
|
|
||||||
expect(pillCount).toBe(0);
|
|
||||||
Reflect.deleteProperty(globalThis, "__pluginRemovals");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("collects timeline transformers and renderers", () => {
|
it("collects timeline transformers and renderers", () => {
|
||||||
const plugin = evaluatePluginClientBundle(
|
const plugin = evaluatePluginClientBundle(
|
||||||
"reports",
|
"reports",
|
||||||
@@ -293,16 +212,26 @@ describe("evaluatePluginClientBundle", () => {
|
|||||||
).toThrow("Duplicate Command Center item: review");
|
).toThrow("Duplicate Command Center item: review");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runs the client entry with the full runtime context", () => {
|
it("collects one explicit client-side entrypoint", () => {
|
||||||
const plugin = evaluatePluginClientBundle(
|
const plugin = evaluatePluginClientBundle(
|
||||||
"review",
|
"review",
|
||||||
bundle(`
|
bundle(`
|
||||||
if (!plugin.paseo || !plugin.rpc || !plugin.openSurface || !plugin.openPanel || !plugin.addComposerPill) {
|
function contributeClient() { return function() {}; }
|
||||||
throw new Error("missing client runtime");
|
plugin.addClientSide(contributeClient);
|
||||||
}
|
|
||||||
`),
|
`),
|
||||||
);
|
);
|
||||||
expect(plugin.id).toBe("review");
|
expect(plugin.clientSide).toBeTypeOf("function");
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
evaluatePluginClientBundle(
|
||||||
|
"review",
|
||||||
|
bundle(`
|
||||||
|
function contributeClient() { return function() {}; }
|
||||||
|
plugin.addClientSide(contributeClient);
|
||||||
|
plugin.addClientSide(contributeClient);
|
||||||
|
`),
|
||||||
|
),
|
||||||
|
).toThrow("Plugin has more than one client-side entrypoint");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects duplicate attachment source ids", () => {
|
it("rejects duplicate attachment source ids", () => {
|
||||||
@@ -468,11 +397,11 @@ describe("evaluatePluginClientBundle", () => {
|
|||||||
expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]);
|
expect(plugin.surfaces.map((surface) => surface.id)).toEqual(["main"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves shared RPC helpers from @getpaseo/plugin", () => {
|
it("resolves @getpaseo/plugin/server for shared RPC contracts", () => {
|
||||||
const plugin = evaluatePluginClientBundle(
|
const plugin = evaluatePluginClientBundle(
|
||||||
"example",
|
"example",
|
||||||
`(function(require) {
|
`(function(require) {
|
||||||
const { defineRpc, defineAttachmentSource } = require("@getpaseo/plugin");
|
const { defineRpc, defineAttachmentSource } = require("@getpaseo/plugin/server");
|
||||||
const search = defineRpc({ name: "issues.search", input: {}, output: {} });
|
const search = defineRpc({ name: "issues.search", input: {}, output: {} });
|
||||||
const module = { exports: {} };
|
const module = { exports: {} };
|
||||||
module.exports.default = function(plugin) {
|
module.exports.default = function(plugin) {
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import {
|
|||||||
defineRpc,
|
defineRpc,
|
||||||
type PluginAttachmentSourceContribution,
|
type PluginAttachmentSourceContribution,
|
||||||
type PluginCommandCenterItemContribution,
|
type PluginCommandCenterItemContribution,
|
||||||
type PluginCleanup,
|
type PluginClientContribution,
|
||||||
type PluginClientContext,
|
|
||||||
type PluginClientSlashCommandContribution,
|
type PluginClientSlashCommandContribution,
|
||||||
type PluginSidebarContribution,
|
type PluginSidebarContribution,
|
||||||
type PluginSurfaceProps,
|
type PluginSurfaceProps,
|
||||||
@@ -24,6 +23,7 @@ import {
|
|||||||
useWorkspace,
|
useWorkspace,
|
||||||
useRpc,
|
useRpc,
|
||||||
} from "@getpaseo/plugin";
|
} from "@getpaseo/plugin";
|
||||||
|
import { createPluginContext, type PluginRegistrationCollector } from "@getpaseo/plugin/host";
|
||||||
import type { EvaluatedPlugin } from "./types";
|
import type { EvaluatedPlugin } from "./types";
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import { Icon, resolvePluginIcon } from "./icons";
|
import { Icon, resolvePluginIcon } from "./icons";
|
||||||
@@ -68,23 +68,14 @@ function requireId(value: string, label: string): string {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PluginClientRuntime = Pick<
|
export function evaluatePluginClientBundle(id: string, bundle: string): EvaluatedPlugin {
|
||||||
PluginClientContext,
|
const collector: PluginRegistrationCollector = {
|
||||||
"paseo" | "rpc" | "openSurface" | "openPanel" | "addComposerPill"
|
|
||||||
>;
|
|
||||||
|
|
||||||
export function runPluginClientBundle(
|
|
||||||
id: string,
|
|
||||||
bundle: string,
|
|
||||||
runtime: PluginClientRuntime,
|
|
||||||
onChange: () => void = () => undefined,
|
|
||||||
): EvaluatedPlugin {
|
|
||||||
const collector: Omit<EvaluatedPlugin, "id" | "cleanup"> = {
|
|
||||||
surfaces: [],
|
surfaces: [],
|
||||||
sidebarItems: [],
|
sidebarItems: [],
|
||||||
workspacePanels: [],
|
workspacePanels: [],
|
||||||
commandCenterItems: [],
|
commandCenterItems: [],
|
||||||
clientSlashCommands: [],
|
clientSlashCommands: [],
|
||||||
|
clientSide: null,
|
||||||
attachmentSources: [],
|
attachmentSources: [],
|
||||||
themes: [],
|
themes: [],
|
||||||
timelineTransformers: [],
|
timelineTransformers: [],
|
||||||
@@ -99,38 +90,14 @@ export function runPluginClientBundle(
|
|||||||
const themeIds = new Set<string>();
|
const themeIds = new Set<string>();
|
||||||
const timelineTransformerIds = new Set<string>();
|
const timelineTransformerIds = new Set<string>();
|
||||||
const timelineRendererIds = new Set<string>();
|
const timelineRendererIds = new Set<string>();
|
||||||
const removals = new Set<PluginCleanup>();
|
const pluginContext = createPluginContext({
|
||||||
let setupComplete = false;
|
|
||||||
const notifyChange = () => {
|
|
||||||
if (setupComplete) onChange();
|
|
||||||
};
|
|
||||||
function register<T>(items: T[], item: T, release: () => void): PluginCleanup {
|
|
||||||
items.push(item);
|
|
||||||
notifyChange();
|
|
||||||
let active = true;
|
|
||||||
const remove = () => {
|
|
||||||
if (!active) return;
|
|
||||||
active = false;
|
|
||||||
const index = items.indexOf(item);
|
|
||||||
if (index !== -1) items.splice(index, 1);
|
|
||||||
release();
|
|
||||||
removals.delete(remove);
|
|
||||||
notifyChange();
|
|
||||||
};
|
|
||||||
removals.add(remove);
|
|
||||||
return remove;
|
|
||||||
}
|
|
||||||
const pluginContext: PluginClientContext = {
|
|
||||||
...runtime,
|
|
||||||
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
|
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
|
||||||
const normalizedId = requireId(surfaceId, "surface id");
|
const normalizedId = requireId(surfaceId, "surface id");
|
||||||
if (surfaceIds.has(normalizedId)) throw new Error(`Duplicate surface: ${normalizedId}`);
|
if (surfaceIds.has(normalizedId)) throw new Error(`Duplicate surface: ${normalizedId}`);
|
||||||
if (typeof Component !== "function")
|
if (typeof Component !== "function")
|
||||||
throw new Error(`Surface ${normalizedId} is not a component`);
|
throw new Error(`Surface ${normalizedId} is not a component`);
|
||||||
surfaceIds.add(normalizedId);
|
surfaceIds.add(normalizedId);
|
||||||
return register(collector.surfaces, { id: normalizedId, Component }, () =>
|
collector.surfaces.push({ id: normalizedId, Component });
|
||||||
surfaceIds.delete(normalizedId),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
addSidebarItem(contribution: PluginSidebarContribution) {
|
addSidebarItem(contribution: PluginSidebarContribution) {
|
||||||
const normalizedId = requireId(contribution.id, "sidebar item id");
|
const normalizedId = requireId(contribution.id, "sidebar item id");
|
||||||
@@ -140,16 +107,12 @@ export function runPluginClientBundle(
|
|||||||
if (!contribution.icon.trim()) throw new Error(`Sidebar item ${normalizedId} has no icon`);
|
if (!contribution.icon.trim()) throw new Error(`Sidebar item ${normalizedId} has no icon`);
|
||||||
resolvePluginIcon(contribution.icon.trim());
|
resolvePluginIcon(contribution.icon.trim());
|
||||||
sidebarItemIds.add(normalizedId);
|
sidebarItemIds.add(normalizedId);
|
||||||
return register(
|
collector.sidebarItems.push({
|
||||||
collector.sidebarItems,
|
id: normalizedId,
|
||||||
{
|
title: contribution.title.trim(),
|
||||||
id: normalizedId,
|
icon: contribution.icon.trim(),
|
||||||
title: contribution.title.trim(),
|
surface: requireId(contribution.surface, "sidebar surface id"),
|
||||||
icon: contribution.icon.trim(),
|
});
|
||||||
surface: requireId(contribution.surface, "sidebar surface id"),
|
|
||||||
},
|
|
||||||
() => sidebarItemIds.delete(normalizedId),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
addWorkspacePanel(contribution: PluginWorkspacePanelContribution) {
|
addWorkspacePanel(contribution: PluginWorkspacePanelContribution) {
|
||||||
const normalizedId = requireId(contribution.id, "workspace panel id");
|
const normalizedId = requireId(contribution.id, "workspace panel id");
|
||||||
@@ -169,17 +132,13 @@ export function runPluginClientBundle(
|
|||||||
resolvePluginIcon(icon);
|
resolvePluginIcon(icon);
|
||||||
const locations = normalizePanelLocations(normalizedId, contribution.locations);
|
const locations = normalizePanelLocations(normalizedId, contribution.locations);
|
||||||
workspacePanelIds.add(normalizedId);
|
workspacePanelIds.add(normalizedId);
|
||||||
return register(
|
collector.workspacePanels.push({
|
||||||
collector.workspacePanels,
|
...contribution,
|
||||||
{
|
id: normalizedId,
|
||||||
...contribution,
|
title,
|
||||||
id: normalizedId,
|
icon,
|
||||||
title,
|
locations,
|
||||||
icon,
|
});
|
||||||
locations,
|
|
||||||
},
|
|
||||||
() => workspacePanelIds.delete(normalizedId),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
addCommandCenterItem(contribution: PluginCommandCenterItemContribution) {
|
addCommandCenterItem(contribution: PluginCommandCenterItemContribution) {
|
||||||
const normalizedId = requireId(contribution.id, "Command Center item id");
|
const normalizedId = requireId(contribution.id, "Command Center item id");
|
||||||
@@ -202,19 +161,15 @@ export function runPluginClientBundle(
|
|||||||
}
|
}
|
||||||
resolvePluginIcon(icon);
|
resolvePluginIcon(icon);
|
||||||
commandCenterItemIds.add(normalizedId);
|
commandCenterItemIds.add(normalizedId);
|
||||||
return register(
|
collector.commandCenterItems.push({
|
||||||
collector.commandCenterItems,
|
...contribution,
|
||||||
{
|
id: normalizedId,
|
||||||
...contribution,
|
title,
|
||||||
id: normalizedId,
|
icon,
|
||||||
title,
|
keywords: contribution.keywords?.map((keyword) => keyword.trim()).filter(Boolean),
|
||||||
icon,
|
});
|
||||||
keywords: contribution.keywords?.map((keyword) => keyword.trim()).filter(Boolean),
|
|
||||||
},
|
|
||||||
() => commandCenterItemIds.delete(normalizedId),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
addSlashCommand(contribution: PluginClientSlashCommandContribution) {
|
addClientSlashCommand(contribution: PluginClientSlashCommandContribution) {
|
||||||
const name = requireId(contribution.name, "client slash command name");
|
const name = requireId(contribution.name, "client slash command name");
|
||||||
if (clientSlashCommandNames.has(name)) {
|
if (clientSlashCommandNames.has(name)) {
|
||||||
throw new Error(`Duplicate client slash command: ${name}`);
|
throw new Error(`Duplicate client slash command: ${name}`);
|
||||||
@@ -228,16 +183,19 @@ export function runPluginClientBundle(
|
|||||||
throw new Error(`Client slash command ${name} has no callback`);
|
throw new Error(`Client slash command ${name} has no callback`);
|
||||||
}
|
}
|
||||||
clientSlashCommandNames.add(name);
|
clientSlashCommandNames.add(name);
|
||||||
return register(
|
collector.clientSlashCommands.push({
|
||||||
collector.clientSlashCommands,
|
...contribution,
|
||||||
{
|
name,
|
||||||
...contribution,
|
description,
|
||||||
name,
|
argumentHint: contribution.argumentHint.trim(),
|
||||||
description,
|
});
|
||||||
argumentHint: contribution.argumentHint.trim(),
|
},
|
||||||
},
|
addClientSide(contribution: PluginClientContribution) {
|
||||||
() => clientSlashCommandNames.delete(name),
|
if (collector.clientSide) throw new Error("Plugin has more than one client-side entrypoint");
|
||||||
);
|
if (typeof contribution !== "function") {
|
||||||
|
throw new Error("Plugin client-side entrypoint is not a function");
|
||||||
|
}
|
||||||
|
collector.clientSide = contribution;
|
||||||
},
|
},
|
||||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution) {
|
addAttachmentSource(contribution: PluginAttachmentSourceContribution) {
|
||||||
const normalizedId = requireId(contribution.id, "attachment source id");
|
const normalizedId = requireId(contribution.id, "attachment source id");
|
||||||
@@ -258,25 +216,21 @@ export function runPluginClientBundle(
|
|||||||
if (!method) throw new Error(`Attachment source ${normalizedId} has no search RPC`);
|
if (!method) throw new Error(`Attachment source ${normalizedId} has no search RPC`);
|
||||||
resolvePluginIcon(icon);
|
resolvePluginIcon(icon);
|
||||||
attachmentSourceIds.add(normalizedId);
|
attachmentSourceIds.add(normalizedId);
|
||||||
return register(
|
collector.attachmentSources.push({
|
||||||
collector.attachmentSources,
|
id: normalizedId,
|
||||||
{
|
title,
|
||||||
id: normalizedId,
|
icon,
|
||||||
title,
|
pickerTitle,
|
||||||
icon,
|
searchPlaceholder,
|
||||||
pickerTitle,
|
search: { ...contribution.search, name: method },
|
||||||
searchPlaceholder,
|
});
|
||||||
search: { ...contribution.search, name: method },
|
|
||||||
},
|
|
||||||
() => attachmentSourceIds.delete(normalizedId),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
addTheme(contribution: PluginThemeContribution) {
|
addTheme(contribution: PluginThemeContribution) {
|
||||||
const normalizedId = requireId(contribution.id, "theme id");
|
const normalizedId = requireId(contribution.id, "theme id");
|
||||||
if (themeIds.has(normalizedId)) throw new Error(`Duplicate theme: ${normalizedId}`);
|
if (themeIds.has(normalizedId)) throw new Error(`Duplicate theme: ${normalizedId}`);
|
||||||
const theme = parsePluginThemeContribution({ ...contribution, id: normalizedId });
|
const theme = parsePluginThemeContribution({ ...contribution, id: normalizedId });
|
||||||
themeIds.add(normalizedId);
|
themeIds.add(normalizedId);
|
||||||
return register(collector.themes, theme, () => themeIds.delete(normalizedId));
|
collector.themes.push(theme);
|
||||||
},
|
},
|
||||||
addTimelineTransformer(contribution: PluginTimelineTransformerContribution) {
|
addTimelineTransformer(contribution: PluginTimelineTransformerContribution) {
|
||||||
const normalizedId = requireId(contribution.id, "timeline transformer id");
|
const normalizedId = requireId(contribution.id, "timeline transformer id");
|
||||||
@@ -298,9 +252,7 @@ export function runPluginClientBundle(
|
|||||||
throw new Error(`Invalid timeline transformer id: ${contribution.id}`);
|
throw new Error(`Invalid timeline transformer id: ${contribution.id}`);
|
||||||
}
|
}
|
||||||
timelineTransformerIds.add(normalizedId);
|
timelineTransformerIds.add(normalizedId);
|
||||||
return register(collector.timelineTransformers, contribution, () =>
|
collector.timelineTransformers.push(contribution);
|
||||||
timelineTransformerIds.delete(normalizedId),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
addTimelineRenderer(contribution: PluginTimelineRendererContribution) {
|
addTimelineRenderer(contribution: PluginTimelineRendererContribution) {
|
||||||
const kind = requireId(contribution.kind, "timeline renderer kind");
|
const kind = requireId(contribution.kind, "timeline renderer kind");
|
||||||
@@ -318,23 +270,9 @@ export function runPluginClientBundle(
|
|||||||
throw new Error(`Timeline renderer ${rendererId} is not a component`);
|
throw new Error(`Timeline renderer ${rendererId} is not a component`);
|
||||||
}
|
}
|
||||||
timelineRendererIds.add(rendererId);
|
timelineRendererIds.add(rendererId);
|
||||||
return register(collector.timelineRenderers, { ...contribution, kind }, () =>
|
collector.timelineRenderers.push({ ...contribution, kind });
|
||||||
timelineRendererIds.delete(rendererId),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
addComposerPill(contribution) {
|
});
|
||||||
const removePill = runtime.addComposerPill(contribution);
|
|
||||||
let active = true;
|
|
||||||
const remove = () => {
|
|
||||||
if (!active) return;
|
|
||||||
active = false;
|
|
||||||
removePill();
|
|
||||||
removals.delete(remove);
|
|
||||||
};
|
|
||||||
removals.add(remove);
|
|
||||||
return remove;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const runtimeRequire = (name: string): unknown => {
|
const runtimeRequire = (name: string): unknown => {
|
||||||
if (name === "react") return React;
|
if (name === "react") return React;
|
||||||
if (name === "react/jsx-runtime") return ReactJsxRuntime;
|
if (name === "react/jsx-runtime") return ReactJsxRuntime;
|
||||||
@@ -353,8 +291,8 @@ export function runPluginClientBundle(
|
|||||||
if (name === "@getpaseo/plugin/react-native" || name === "@paseo/plugin/react-native") {
|
if (name === "@getpaseo/plugin/react-native" || name === "@paseo/plugin/react-native") {
|
||||||
return pluginReactNativeRuntime;
|
return pluginReactNativeRuntime;
|
||||||
}
|
}
|
||||||
if (name === "@getpaseo/plugin/server" || name === "@paseo/plugin/server") {
|
if (name === "@getpaseo/plugin/server") {
|
||||||
return {};
|
return { defineAttachmentSource, defineRpc };
|
||||||
}
|
}
|
||||||
if (name === "@tanstack/react-query") return ReactQuery;
|
if (name === "@tanstack/react-query") return ReactQuery;
|
||||||
if (name === "zod") return Zod;
|
if (name === "zod") return Zod;
|
||||||
@@ -370,8 +308,8 @@ export function runPluginClientBundle(
|
|||||||
if (typeof setup !== "function") {
|
if (typeof setup !== "function") {
|
||||||
throw new Error(`Plugin ${id} must default export a function`);
|
throw new Error(`Plugin ${id} must default export a function`);
|
||||||
}
|
}
|
||||||
const entryCleanup = setup(pluginContext);
|
const cleanup = setup(pluginContext);
|
||||||
if (typeof entryCleanup !== "function") {
|
if (typeof cleanup !== "function") {
|
||||||
throw new Error(`Plugin ${id} contribution must return a cleanup function`);
|
throw new Error(`Plugin ${id} contribution must return a cleanup function`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,7 +321,7 @@ export function runPluginClientBundle(
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try {
|
try {
|
||||||
void Promise.resolve(entryCleanup()).catch((cleanupError) => {
|
void Promise.resolve(cleanup()).catch((cleanupError) => {
|
||||||
console.warn(`[Plugins] Cleanup failed after setup error for ${id}`, cleanupError);
|
console.warn(`[Plugins] Cleanup failed after setup error for ${id}`, cleanupError);
|
||||||
});
|
});
|
||||||
} catch (cleanupError) {
|
} catch (cleanupError) {
|
||||||
@@ -391,17 +329,6 @@ export function runPluginClientBundle(
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
setupComplete = true;
|
|
||||||
let stopped = false;
|
|
||||||
const cleanup = async () => {
|
|
||||||
if (stopped) return;
|
|
||||||
stopped = true;
|
|
||||||
try {
|
|
||||||
await entryCleanup();
|
|
||||||
} finally {
|
|
||||||
for (const remove of removals) remove();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
cleanup,
|
cleanup,
|
||||||
@@ -410,6 +337,7 @@ export function runPluginClientBundle(
|
|||||||
workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"],
|
workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"],
|
||||||
commandCenterItems: collector.commandCenterItems,
|
commandCenterItems: collector.commandCenterItems,
|
||||||
clientSlashCommands: collector.clientSlashCommands,
|
clientSlashCommands: collector.clientSlashCommands,
|
||||||
|
clientSide: collector.clientSide,
|
||||||
attachmentSources: collector.attachmentSources,
|
attachmentSources: collector.attachmentSources,
|
||||||
themes: collector.themes,
|
themes: collector.themes,
|
||||||
timelineTransformers: collector.timelineTransformers,
|
timelineTransformers: collector.timelineTransformers,
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { renderHook } from "@testing-library/react";
|
|||||||
import { expect, it, vi } from "vitest";
|
import { expect, it, vi } from "vitest";
|
||||||
|
|
||||||
vi.mock("./evaluate", () => ({
|
vi.mock("./evaluate", () => ({
|
||||||
runPluginClientBundle: vi.fn(),
|
evaluatePluginClientBundle: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("./client-runtime", () => ({
|
vi.mock("./composer-pills/lifecycle", () => ({
|
||||||
createPluginClientRuntime: vi.fn(),
|
startPluginClientSide: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { usePluginInstallations } from "./registry";
|
import { usePluginInstallations } from "./registry";
|
||||||
|
|||||||
@@ -1,33 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import { pluginRegistry } from "./registry";
|
||||||
import { pluginRegistry as registry } from "./registry";
|
|
||||||
|
|
||||||
vi.mock("./navigation", () => ({
|
|
||||||
createPluginNavigation: () => ({}),
|
|
||||||
}));
|
|
||||||
vi.mock("./client-runtime", () => ({
|
|
||||||
createPluginClientRuntime: () => ({
|
|
||||||
paseo: {},
|
|
||||||
rpc: async () => undefined,
|
|
||||||
openSurface: () => undefined,
|
|
||||||
openPanel: () => undefined,
|
|
||||||
addComposerPill: () => () => undefined,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const daemonClient = {} as DaemonClient;
|
|
||||||
const pluginRegistry = {
|
|
||||||
getSnapshot: registry.getSnapshot,
|
|
||||||
subscribe: registry.subscribe,
|
|
||||||
removeHost: registry.removeHost.bind(registry),
|
|
||||||
installCatalog(
|
|
||||||
serverId: string,
|
|
||||||
catalog: Parameters<typeof registry.installCatalog>[1],
|
|
||||||
options: { replacePluginId?: string } = {},
|
|
||||||
) {
|
|
||||||
return registry.installCatalog(serverId, catalog, { ...options, client: daemonClient });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function bundle(marker: string): string {
|
function bundle(marker: string): string {
|
||||||
return `(function() {
|
return `(function() {
|
||||||
@@ -57,10 +29,6 @@ function timelineBundle(marker: string): string {
|
|||||||
})`;
|
})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function installedPluginIds(): string[] {
|
|
||||||
return pluginRegistry.getSnapshot().map(({ id }) => id);
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
pluginRegistry.removeHost("host-a");
|
pluginRegistry.removeHost("host-a");
|
||||||
pluginRegistry.removeHost("host-b");
|
pluginRegistry.removeHost("host-b");
|
||||||
@@ -68,18 +36,6 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("PluginRegistry", () => {
|
describe("PluginRegistry", () => {
|
||||||
it("publishes synchronous setup once after storing the installation", () => {
|
|
||||||
const snapshots: string[][] = [];
|
|
||||||
const unsubscribe = pluginRegistry.subscribe(() => {
|
|
||||||
snapshots.push(installedPluginIds());
|
|
||||||
});
|
|
||||||
|
|
||||||
pluginRegistry.installCatalog("host-a", [{ id: "example", clientBundle: bundle("one") }]);
|
|
||||||
unsubscribe();
|
|
||||||
|
|
||||||
expect(snapshots).toEqual([["example"]]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports timeline contribution changes", () => {
|
it("reports timeline contribution changes", () => {
|
||||||
const first = timelineBundle("one");
|
const first = timelineBundle("one");
|
||||||
expect(pluginRegistry.installCatalog("host-a", [{ id: "reports", clientBundle: first }])).toBe(
|
expect(pluginRegistry.installCatalog("host-a", [{ id: "reports", clientBundle: first }])).toBe(
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useMemo, useSyncExternalStore } from "react";
|
import { useMemo, useSyncExternalStore } from "react";
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { createPluginClientRuntime } from "./client-runtime";
|
import { startPluginClientSide } from "./composer-pills/lifecycle";
|
||||||
import { runPluginClientBundle } from "./evaluate";
|
import { evaluatePluginClientBundle } from "./evaluate";
|
||||||
import type { InstalledPlugin } from "./types";
|
import type { InstalledPlugin } from "./types";
|
||||||
|
|
||||||
interface CatalogPlugin {
|
interface CatalogPlugin {
|
||||||
@@ -33,8 +33,8 @@ class PluginRegistry {
|
|||||||
catalog: CatalogPlugin[],
|
catalog: CatalogPlugin[],
|
||||||
options: {
|
options: {
|
||||||
replacePluginId?: string;
|
replacePluginId?: string;
|
||||||
client: DaemonClient;
|
client?: DaemonClient;
|
||||||
},
|
} = {},
|
||||||
): boolean {
|
): boolean {
|
||||||
const previous = this.byHost.get(serverId) ?? [];
|
const previous = this.byHost.get(serverId) ?? [];
|
||||||
const previousTimelineBundles = previous
|
const previousTimelineBundles = previous
|
||||||
@@ -58,7 +58,6 @@ class PluginRegistry {
|
|||||||
const installed = catalog.flatMap((entry) => {
|
const installed = catalog.flatMap((entry) => {
|
||||||
const key = `${serverId}/${entry.id}`;
|
const key = `${serverId}/${entry.id}`;
|
||||||
try {
|
try {
|
||||||
if (!entry.clientBundle) return [];
|
|
||||||
const existing = preserved.find(
|
const existing = preserved.find(
|
||||||
(plugin) => plugin.id === entry.id && plugin.clientBundle === entry.clientBundle,
|
(plugin) => plugin.id === entry.id && plugin.clientBundle === entry.clientBundle,
|
||||||
);
|
);
|
||||||
@@ -66,29 +65,29 @@ class PluginRegistry {
|
|||||||
this.evaluationErrors.delete(key);
|
this.evaluationErrors.delete(key);
|
||||||
return [existing];
|
return [existing];
|
||||||
}
|
}
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
const evaluated = evaluatePluginClientBundle(entry.id, entry.clientBundle);
|
||||||
const installation: InstalledPlugin = {
|
const installation: InstalledPlugin = {
|
||||||
id: entry.id,
|
...evaluated,
|
||||||
serverId,
|
serverId,
|
||||||
clientBundle: entry.clientBundle,
|
clientBundle: entry.clientBundle,
|
||||||
queryClient: new QueryClient(),
|
queryClient,
|
||||||
cleanup: () => undefined,
|
|
||||||
surfaces: [],
|
|
||||||
sidebarItems: [],
|
|
||||||
workspacePanels: [],
|
|
||||||
commandCenterItems: [],
|
|
||||||
clientSlashCommands: [],
|
|
||||||
attachmentSources: [],
|
|
||||||
themes: [],
|
|
||||||
timelineTransformers: [],
|
|
||||||
timelineRenderers: [],
|
|
||||||
};
|
};
|
||||||
const evaluated = runPluginClientBundle(
|
if (installation.clientSide) {
|
||||||
entry.id,
|
if (!options.client) throw new Error("Plugin client runtime is unavailable");
|
||||||
entry.clientBundle,
|
let clientCleanup;
|
||||||
createPluginClientRuntime(installation, options.client),
|
try {
|
||||||
() => this.publish(),
|
clientCleanup = startPluginClientSide(installation, options.client);
|
||||||
);
|
} catch (error) {
|
||||||
Object.assign(installation, evaluated);
|
queryClient.clear();
|
||||||
|
void Promise.resolve(evaluated.cleanup()).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
installation.cleanup = async () => {
|
||||||
|
await clientCleanup();
|
||||||
|
await evaluated.cleanup();
|
||||||
|
};
|
||||||
|
}
|
||||||
this.evaluationErrors.delete(key);
|
this.evaluationErrors.delete(key);
|
||||||
return [installation];
|
return [installation];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function installed(serverId: string, contributionId = "main"): InstalledPlugin {
|
|||||||
],
|
],
|
||||||
workspacePanels: [],
|
workspacePanels: [],
|
||||||
commandCenterItems: [],
|
commandCenterItems: [],
|
||||||
clientSlashCommands: [],
|
clientSide: null,
|
||||||
attachmentSources: [],
|
attachmentSources: [],
|
||||||
themes: [],
|
themes: [],
|
||||||
timelineTransformers: [],
|
timelineTransformers: [],
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ function installation(
|
|||||||
})),
|
})),
|
||||||
workspacePanels: [],
|
workspacePanels: [],
|
||||||
commandCenterItems: [],
|
commandCenterItems: [],
|
||||||
clientSlashCommands: [],
|
clientSide: null,
|
||||||
attachmentSources: [],
|
attachmentSources: [],
|
||||||
themes: [],
|
themes: [],
|
||||||
timelineTransformers: [],
|
timelineTransformers: [],
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
|
|||||||
sidebarItems: [],
|
sidebarItems: [],
|
||||||
workspacePanels: [],
|
workspacePanels: [],
|
||||||
commandCenterItems: [],
|
commandCenterItems: [],
|
||||||
clientSlashCommands: [],
|
clientSide: null,
|
||||||
attachmentSources: [],
|
attachmentSources: [],
|
||||||
themes,
|
themes,
|
||||||
timelineTransformers: [],
|
timelineTransformers: [],
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function plugin(input: {
|
|||||||
sidebarItems: [],
|
sidebarItems: [],
|
||||||
workspacePanels: [],
|
workspacePanels: [],
|
||||||
commandCenterItems: [],
|
commandCenterItems: [],
|
||||||
clientSlashCommands: [],
|
clientSide: null,
|
||||||
attachmentSources: [],
|
attachmentSources: [],
|
||||||
themes: [],
|
themes: [],
|
||||||
timelineTransformers: [
|
timelineTransformers: [
|
||||||
|
|||||||
@@ -17,15 +17,6 @@ vi.mock("@/constants/layout", () => ({
|
|||||||
vi.mock("../navigation", () => ({
|
vi.mock("../navigation", () => ({
|
||||||
createPluginNavigation: () => ({}),
|
createPluginNavigation: () => ({}),
|
||||||
}));
|
}));
|
||||||
vi.mock("../client-runtime", () => ({
|
|
||||||
createPluginClientRuntime: () => ({
|
|
||||||
paseo: {},
|
|
||||||
rpc: async () => undefined,
|
|
||||||
openSurface: () => undefined,
|
|
||||||
openPanel: () => undefined,
|
|
||||||
addComposerPill: () => () => undefined,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
vi.mock("../icons", () => ({
|
vi.mock("../icons", () => ({
|
||||||
Icon: () => null,
|
Icon: () => null,
|
||||||
resolvePluginIcon: () => () => null,
|
resolvePluginIcon: () => () => null,
|
||||||
@@ -105,7 +96,6 @@ const recoveredTimelineItem = { ...timelineItem, data: { label: "Recovered" } };
|
|||||||
|
|
||||||
const roots: Array<ReturnType<typeof createRoot>> = [];
|
const roots: Array<ReturnType<typeof createRoot>> = [];
|
||||||
const containers: HTMLElement[] = [];
|
const containers: HTMLElement[] = [];
|
||||||
const daemonClient = {} as DaemonClient;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal("React", React);
|
vi.stubGlobal("React", React);
|
||||||
@@ -121,9 +111,7 @@ afterEach(() => {
|
|||||||
|
|
||||||
describe("PluginTimelineItemView", () => {
|
describe("PluginTimelineItemView", () => {
|
||||||
it("validates and renders the matching plugin component", () => {
|
it("validates and renders the matching plugin component", () => {
|
||||||
pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: bundle }], {
|
pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: bundle }]);
|
||||||
client: daemonClient,
|
|
||||||
});
|
|
||||||
|
|
||||||
const markup = renderToStaticMarkup(
|
const markup = renderToStaticMarkup(
|
||||||
<PluginTimelineItemView serverId="host-1" agentId="agent-1" item={timelineItem} />,
|
<PluginTimelineItemView serverId="host-1" agentId="agent-1" item={timelineItem} />,
|
||||||
@@ -135,9 +123,7 @@ describe("PluginTimelineItemView", () => {
|
|||||||
it("contains renderer crashes to one timeline item", async () => {
|
it("contains renderer crashes to one timeline item", async () => {
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: failingBundle }], {
|
pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: failingBundle }]);
|
||||||
client: daemonClient,
|
|
||||||
});
|
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
containers.push(container);
|
containers.push(container);
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
@@ -162,9 +148,7 @@ describe("PluginTimelineItemView", () => {
|
|||||||
it("recovers when a streaming item's data changes", async () => {
|
it("recovers when a streaming item's data changes", async () => {
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||||
pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: recoveringBundle }], {
|
pluginRegistry.installCatalog("host-1", [{ id: "reports", clientBundle: recoveringBundle }]);
|
||||||
client: daemonClient,
|
|
||||||
});
|
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
containers.push(container);
|
containers.push(container);
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { QueryClient } from "@tanstack/react-query";
|
|||||||
import type {
|
import type {
|
||||||
PluginAttachmentSourceContribution,
|
PluginAttachmentSourceContribution,
|
||||||
PluginCommandCenterItemContribution,
|
PluginCommandCenterItemContribution,
|
||||||
|
PluginClientContribution,
|
||||||
PluginClientSlashCommandContribution,
|
PluginClientSlashCommandContribution,
|
||||||
PluginCleanup,
|
PluginCleanup,
|
||||||
PluginComposerPillContribution,
|
PluginComposerPillContribution,
|
||||||
@@ -25,7 +26,8 @@ export interface EvaluatedPlugin {
|
|||||||
sidebarItems: PluginSidebarContribution[];
|
sidebarItems: PluginSidebarContribution[];
|
||||||
workspacePanels: EvaluatedPluginWorkspacePanelContribution[];
|
workspacePanels: EvaluatedPluginWorkspacePanelContribution[];
|
||||||
commandCenterItems: PluginCommandCenterItemContribution[];
|
commandCenterItems: PluginCommandCenterItemContribution[];
|
||||||
clientSlashCommands: PluginClientSlashCommandContribution[];
|
clientSlashCommands?: PluginClientSlashCommandContribution[];
|
||||||
|
clientSide: PluginClientContribution | null;
|
||||||
attachmentSources: PluginAttachmentSourceContribution[];
|
attachmentSources: PluginAttachmentSourceContribution[];
|
||||||
themes: PluginThemeContribution[];
|
themes: PluginThemeContribution[];
|
||||||
timelineTransformers: PluginTimelineTransformerContribution[];
|
timelineTransformers: PluginTimelineTransformerContribution[];
|
||||||
|
|||||||
@@ -1,21 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
|
||||||
import { pluginRegistry } from "../registry";
|
import { pluginRegistry } from "../registry";
|
||||||
import { panelTargetSupportsHost, resolvePluginPanelOpenLocation } from "./locations";
|
import { panelTargetSupportsHost, resolvePluginPanelOpenLocation } from "./locations";
|
||||||
|
|
||||||
vi.mock("../navigation", () => ({
|
|
||||||
createPluginNavigation: () => ({}),
|
|
||||||
}));
|
|
||||||
vi.mock("../client-runtime", () => ({
|
|
||||||
createPluginClientRuntime: () => ({
|
|
||||||
paseo: {},
|
|
||||||
rpc: async () => undefined,
|
|
||||||
openSurface: () => undefined,
|
|
||||||
openPanel: () => undefined,
|
|
||||||
addComposerPill: () => () => undefined,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function install(locations: readonly ("workspace" | "explorer")[]) {
|
function install(locations: readonly ("workspace" | "explorer")[]) {
|
||||||
const bundle = `(function() {
|
const bundle = `(function() {
|
||||||
return { default: function(plugin) {
|
return { default: function(plugin) {
|
||||||
@@ -30,9 +16,7 @@ function install(locations: readonly ("workspace" | "explorer")[]) {
|
|||||||
return function() {};
|
return function() {};
|
||||||
}};
|
}};
|
||||||
})`;
|
})`;
|
||||||
pluginRegistry.installCatalog("host-1", [{ id: "review", clientBundle: bundle }], {
|
pluginRegistry.installCatalog("host-1", [{ id: "review", clientBundle: bundle }]);
|
||||||
client: {} as DaemonClient,
|
|
||||||
});
|
|
||||||
return pluginRegistry.getSnapshot()[0]!;
|
return pluginRegistry.getSnapshot()[0]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function installed(): InstalledPlugin {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
commandCenterItems: [],
|
commandCenterItems: [],
|
||||||
clientSlashCommands: [],
|
clientSide: null,
|
||||||
attachmentSources: [],
|
attachmentSources: [],
|
||||||
themes: [],
|
themes: [],
|
||||||
timelineTransformers: [],
|
timelineTransformers: [],
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { PaseoLogo } from "@/components/icons/paseo-logo";
|
|||||||
import { CommunityLinks } from "@/components/community-links";
|
import { CommunityLinks } from "@/components/community-links";
|
||||||
import { MenuHeader } from "@/components/headers/menu-header";
|
import { MenuHeader } from "@/components/headers/menu-header";
|
||||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||||
import { useImportSession } from "@/hooks/use-import-session";
|
|
||||||
import { useHostChooser } from "@/hosts/host-chooser";
|
import { useHostChooser } from "@/hosts/host-chooser";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import {
|
import {
|
||||||
@@ -20,17 +19,24 @@ import {
|
|||||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||||
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
|
import { PairDeviceModal } from "@/desktop/components/pair-device-modal";
|
||||||
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
import { buildHostAgentDetailRoute, buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||||
|
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||||
|
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||||
|
import { useOpenProject } from "@/hooks/use-open-project";
|
||||||
|
import type { Href } from "expo-router";
|
||||||
|
|
||||||
export function OpenProjectScreen() {
|
export function OpenProjectScreen() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList);
|
const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList);
|
||||||
const openProjectPicker = useOpenAddProject();
|
const openProjectPicker = useOpenAddProject();
|
||||||
const importSession = useImportSession();
|
|
||||||
const chooseHost = useHostChooser();
|
const chooseHost = useHostChooser();
|
||||||
const localServerId = useLocalDaemonServerId();
|
const localServerId = useLocalDaemonServerId();
|
||||||
|
const [importServerId, setImportServerId] = useState<string | null>(null);
|
||||||
|
const importClient = useHostRuntimeClient(importServerId ?? "");
|
||||||
|
const openImportedProject = useOpenProject(importServerId);
|
||||||
const [isPairDeviceOpen, setIsPairDeviceOpen] = useState(false);
|
const [isPairDeviceOpen, setIsPairDeviceOpen] = useState(false);
|
||||||
|
const [isImportSheetOpen, setIsImportSheetOpen] = useState(false);
|
||||||
|
|
||||||
const isCompactLayout = useIsCompactFormFactor();
|
const isCompactLayout = useIsCompactFormFactor();
|
||||||
|
|
||||||
@@ -47,6 +53,30 @@ export function OpenProjectScreen() {
|
|||||||
const handleOpenPairDevice = useCallback(() => setIsPairDeviceOpen(true), []);
|
const handleOpenPairDevice = useCallback(() => setIsPairDeviceOpen(true), []);
|
||||||
const handleClosePairDevice = useCallback(() => setIsPairDeviceOpen(false), []);
|
const handleClosePairDevice = useCallback(() => setIsPairDeviceOpen(false), []);
|
||||||
|
|
||||||
|
const handleOpenImportSession = useCallback(() => {
|
||||||
|
chooseHost({
|
||||||
|
title: "Import from host",
|
||||||
|
onChooseHost: (serverId) => {
|
||||||
|
setImportServerId(serverId);
|
||||||
|
setIsImportSheetOpen(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [chooseHost]);
|
||||||
|
const handleCloseImportSession = useCallback(() => setIsImportSheetOpen(false), []);
|
||||||
|
|
||||||
|
const handleImported = useCallback(
|
||||||
|
(agent: { id: string; cwd: string }) => {
|
||||||
|
if (!importServerId) return;
|
||||||
|
void (async () => {
|
||||||
|
const result = await openImportedProject(agent.cwd);
|
||||||
|
if (result.ok) {
|
||||||
|
router.push(buildHostAgentDetailRoute(importServerId, agent.id) as Href);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
[importServerId, openImportedProject, router],
|
||||||
|
);
|
||||||
|
|
||||||
const handleOpenProviders = useCallback(() => {
|
const handleOpenProviders = useCallback(() => {
|
||||||
chooseHost({
|
chooseHost({
|
||||||
title: "Choose host",
|
title: "Choose host",
|
||||||
@@ -77,7 +107,7 @@ export function OpenProjectScreen() {
|
|||||||
icon={Inbox}
|
icon={Inbox}
|
||||||
title={t("openProject.tiles.importSession.title")}
|
title={t("openProject.tiles.importSession.title")}
|
||||||
description={t("openProject.tiles.importSession.description")}
|
description={t("openProject.tiles.importSession.description")}
|
||||||
onPress={importSession.open}
|
onPress={handleOpenImportSession}
|
||||||
testID="open-project-import-session"
|
testID="open-project-import-session"
|
||||||
/>
|
/>
|
||||||
<HomeTile
|
<HomeTile
|
||||||
@@ -107,7 +137,13 @@ export function OpenProjectScreen() {
|
|||||||
onClose={handleClosePairDevice}
|
onClose={handleClosePairDevice}
|
||||||
testID="open-project-pair-device-modal"
|
testID="open-project-pair-device-modal"
|
||||||
/>
|
/>
|
||||||
{importSession.sheet}
|
<ImportSessionSheet
|
||||||
|
visible={isImportSheetOpen}
|
||||||
|
client={importClient}
|
||||||
|
serverId={importServerId}
|
||||||
|
onClose={handleCloseImportSession}
|
||||||
|
onImported={handleImported}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { View, Text } from "react-native";
|
|||||||
import { useIsFocused } from "@react-navigation/native";
|
import { useIsFocused } from "@react-navigation/native";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { ChevronLeft, Import } from "lucide-react-native";
|
import { ChevronLeft } from "lucide-react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { MenuHeader } from "@/components/headers/menu-header";
|
import { MenuHeader } from "@/components/headers/menu-header";
|
||||||
@@ -15,7 +15,6 @@ import { HostFilter } from "@/components/hosts/host-filter";
|
|||||||
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker";
|
import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker";
|
||||||
import { type AgentHistoryHostError, useAgentHistory } from "@/hooks/use-agent-history";
|
import { type AgentHistoryHostError, useAgentHistory } from "@/hooks/use-agent-history";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
import { useImportSession } from "@/hooks/use-import-session";
|
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { buildOpenProjectRoute } from "@/utils/host-routes";
|
import { buildOpenProjectRoute } from "@/utils/host-routes";
|
||||||
|
|
||||||
@@ -73,7 +72,6 @@ export function SessionsScreen() {
|
|||||||
function SessionsScreenContent() {
|
function SessionsScreenContent() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const importSession = useImportSession();
|
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID);
|
const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID);
|
||||||
const [searchInput, setSearchInput] = useState("");
|
const [searchInput, setSearchInput] = useState("");
|
||||||
@@ -203,9 +201,6 @@ function SessionsScreenContent() {
|
|||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="ghost" leftIcon={Import} onPress={importSession.open}>
|
|
||||||
{t("importSession.title")}
|
|
||||||
</Button>
|
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{!isInitialLoad && !showLoadError && agents.length > 0 ? (
|
{!isInitialLoad && !showLoadError && agents.length > 0 ? (
|
||||||
@@ -221,7 +216,6 @@ function SessionsScreenContent() {
|
|||||||
flat={isSearching}
|
flat={isSearching}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{importSession.sheet}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ import { WorkspaceActions } from "@/git/workspace-actions";
|
|||||||
import { WorkspaceOpenInEditorButton } from "@/workspace/open-in-editor/button";
|
import { WorkspaceOpenInEditorButton } from "@/workspace/open-in-editor/button";
|
||||||
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
|
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
|
||||||
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||||
import { useNavigateToImportedAgent } from "@/hooks/use-import-session";
|
|
||||||
import { useToast } from "@/contexts/toast-context";
|
import { useToast } from "@/contexts/toast-context";
|
||||||
import { getOrCreateClientId } from "@/utils/client-id";
|
import { getOrCreateClientId } from "@/utils/client-id";
|
||||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
@@ -2084,9 +2083,6 @@ function WorkspaceScreenContent({
|
|||||||
},
|
},
|
||||||
[persistenceKey, selectWorkspaceTabInPane],
|
[persistenceKey, selectWorkspaceTabInPane],
|
||||||
);
|
);
|
||||||
// A "Show all" import can land in another workspace entirely; that
|
|
||||||
// agent has no tab here, so it opens its own workspace instead.
|
|
||||||
const navigateToImportedAgent = useNavigateToImportedAgent(normalizedServerId);
|
|
||||||
const handleImportedAgent = useCallback(
|
const handleImportedAgent = useCallback(
|
||||||
(agentId: string) => {
|
(agentId: string) => {
|
||||||
if (!persistenceKey) {
|
if (!persistenceKey) {
|
||||||
@@ -4097,7 +4093,6 @@ function WorkspaceScreenContent({
|
|||||||
workspaceId={normalizedWorkspaceId}
|
workspaceId={normalizedWorkspaceId}
|
||||||
onClose={closeImportSheet}
|
onClose={closeImportSheet}
|
||||||
onImportedAgent={handleImportedAgent}
|
onImportedAgent={handleImportedAgent}
|
||||||
onImported={navigateToImportedAgent}
|
|
||||||
/>
|
/>
|
||||||
<WorkspaceTabRenameModal
|
<WorkspaceTabRenameModal
|
||||||
renamingTab={isRouteFocused ? renamingTab : null}
|
renamingTab={isRouteFocused ? renamingTab : null}
|
||||||
|
|||||||
@@ -221,12 +221,11 @@ describe("workspace-setup-store", () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("seeds a setup tab for failed or blocked setup", () => {
|
it("seeds a setup tab only for failed setup", () => {
|
||||||
expect(shouldSeedWorkspaceSetupTab(null)).toBe(false);
|
expect(shouldSeedWorkspaceSetupTab(null)).toBe(false);
|
||||||
expect(shouldSeedWorkspaceSetupTab(makeSnapshot("running"))).toBe(false);
|
expect(shouldSeedWorkspaceSetupTab(makeSnapshot("running"))).toBe(false);
|
||||||
expect(shouldSeedWorkspaceSetupTab(makeSnapshot("completed"))).toBe(false);
|
expect(shouldSeedWorkspaceSetupTab(makeSnapshot("completed"))).toBe(false);
|
||||||
expect(shouldSeedWorkspaceSetupTab(makeSnapshot("failed", "Setup failed"))).toBe(true);
|
expect(shouldSeedWorkspaceSetupTab(makeSnapshot("failed", "Setup failed"))).toBe(true);
|
||||||
expect(shouldSeedWorkspaceSetupTab(makeSnapshot("blocked"))).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("claims one failed setup surface until a later setup lifecycle begins", () => {
|
it("claims one failed setup surface until a later setup lifecycle begins", () => {
|
||||||
|
|||||||
@@ -34,13 +34,11 @@ export function shouldShowWorkspaceSetup(snapshot: WorkspaceSetupSnapshot | null
|
|||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return (
|
return snapshot.error !== null || snapshot.detail.commands.length > 0;
|
||||||
snapshot.status === "blocked" || snapshot.error !== null || snapshot.detail.commands.length > 0
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldSeedWorkspaceSetupTab(snapshot: WorkspaceSetupSnapshot | null): boolean {
|
export function shouldSeedWorkspaceSetupTab(snapshot: WorkspaceSetupSnapshot | null): boolean {
|
||||||
return snapshot?.status === "failed" || snapshot?.status === "blocked";
|
return snapshot?.status === "failed";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkspaceSetupStoreState {
|
interface WorkspaceSetupStoreState {
|
||||||
@@ -84,7 +82,7 @@ export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set, g
|
|||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const surfacedFailedSetupKeys = new Set(state.surfacedFailedSetupKeys);
|
const surfacedFailedSetupKeys = new Set(state.surfacedFailedSetupKeys);
|
||||||
if (payload.status !== "failed" && payload.status !== "blocked") {
|
if (payload.status !== "failed") {
|
||||||
surfacedFailedSetupKeys.delete(key);
|
surfacedFailedSetupKeys.delete(key);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -107,10 +105,7 @@ export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set, g
|
|||||||
|
|
||||||
let claimed = false;
|
let claimed = false;
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (
|
if (state.snapshots[key]?.status !== "failed" || state.surfacedFailedSetupKeys.has(key)) {
|
||||||
!["failed", "blocked"].includes(state.snapshots[key]?.status ?? "") ||
|
|
||||||
state.surfacedFailedSetupKeys.has(key)
|
|
||||||
) {
|
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
claimed = true;
|
claimed = true;
|
||||||
|
|||||||
@@ -575,7 +575,6 @@ async function resolveRunWorkspace(
|
|||||||
const branch = result.workspace.gitRuntime?.currentBranch;
|
const branch = result.workspace.gitRuntime?.currentBranch;
|
||||||
const label = branch ? `${result.workspace.name} (${branch})` : result.workspace.name;
|
const label = branch ? `${result.workspace.name} (${branch})` : result.workspace.name;
|
||||||
console.error(`Created workspace ${result.workspace.id} - ${label}`);
|
console.error(`Created workspace ${result.workspace.id} - ${label}`);
|
||||||
if (result.setupSkippedReason) console.error(result.setupSkippedReason);
|
|
||||||
console.error(
|
console.error(
|
||||||
"Tip: pass --workspace <id> (or set PASEO_WORKSPACE_ID) to run in an existing workspace.",
|
"Tip: pass --workspace <id> (or set PASEO_WORKSPACE_ID) to run in an existing workspace.",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,27 +1,11 @@
|
|||||||
import { execFile } from "node:child_process";
|
|
||||||
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||||
import { createRequire } from "node:module";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { promisify } from "node:util";
|
import ts from "typescript";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { scaffoldPluginDirectory } from "./scaffold.js";
|
import { scaffoldPluginDirectory } from "./scaffold.js";
|
||||||
|
|
||||||
const directories: string[] = [];
|
const directories: string[] = [];
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
const tscPath = createRequire(import.meta.url).resolve("typescript/bin/tsc");
|
|
||||||
|
|
||||||
async function typecheckPlugin(directory: string): Promise<void> {
|
|
||||||
const configPath = path.join(directory, "tsconfig.json");
|
|
||||||
try {
|
|
||||||
await execFileAsync(process.execPath, [tscPath, "--noEmit", "-p", configPath], {
|
|
||||||
cwd: directory,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const failure = error as { stderr?: string; stdout?: string };
|
|
||||||
throw new Error(`${failure.stdout ?? ""}${failure.stderr ?? ""}`, { cause: error });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true })));
|
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true })));
|
||||||
@@ -36,12 +20,17 @@ describe("plugin scaffold", () => {
|
|||||||
await scaffoldPluginDirectory(directory);
|
await scaffoldPluginDirectory(directory);
|
||||||
|
|
||||||
const configPath = path.join(directory, "tsconfig.json");
|
const configPath = path.join(directory, "tsconfig.json");
|
||||||
const config = JSON.parse(await readFile(configPath, "utf8")) as {
|
const loaded = ts.readConfigFile(configPath, ts.sys.readFile);
|
||||||
compilerOptions: { lib: string[]; types: string[] };
|
expect(loaded.error).toBeUndefined();
|
||||||
};
|
const parsed = ts.parseJsonConfigFileContent(loaded.config, ts.sys, directory);
|
||||||
expect(config.compilerOptions.lib).toEqual(["ES2023"]);
|
const diagnostics = ts.getPreEmitDiagnostics(
|
||||||
expect(config.compilerOptions.types).toEqual(["react"]);
|
ts.createProgram(parsed.fileNames, parsed.options),
|
||||||
await expect(typecheckPlugin(directory)).resolves.toBeUndefined();
|
);
|
||||||
|
expect(
|
||||||
|
diagnostics.map((diagnostic) =>
|
||||||
|
ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual({
|
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual({
|
||||||
id: "hello-plugin",
|
id: "hello-plugin",
|
||||||
});
|
});
|
||||||
@@ -64,38 +53,12 @@ describe("plugin scaffold", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(await readdir(directory)).not.toContain("paseo-plugin.d.ts");
|
expect(await readdir(directory)).not.toContain("paseo-plugin.d.ts");
|
||||||
await expect(readFile(path.join(directory, "index.client.tsx"), "utf8")).resolves.toContain(
|
await expect(readFile(path.join(directory, "index.ts"), "utf8")).resolves.toContain(
|
||||||
'from "./client/greeting"',
|
'from "./main.client"',
|
||||||
);
|
);
|
||||||
await expect(readFile(path.join(directory, "index.server.ts"), "utf8")).resolves.toContain(
|
await expect(readFile(path.join(directory, "main.client.tsx"), "utf8")).resolves.toContain(
|
||||||
'from "./server/greeting"',
|
"Hello from my plugin",
|
||||||
);
|
);
|
||||||
await expect(readFile(path.join(directory, "client/greeting.tsx"), "utf8")).resolves.toContain(
|
|
||||||
"useRpc(greetingRpc)",
|
|
||||||
);
|
|
||||||
await expect(readFile(path.join(directory, "client/web.ts"), "utf8")).resolves.not.toContain(
|
|
||||||
`/// <reference lib="dom" />`,
|
|
||||||
);
|
|
||||||
await expect(readFile(path.join(directory, "client/web.ts"), "utf8")).resolves.toContain(
|
|
||||||
`declare const window: { open(url: string, target: string, features: string): unknown };`,
|
|
||||||
);
|
|
||||||
await expect(readFile(path.join(directory, "client/web.ts"), "utf8")).resolves.toContain(
|
|
||||||
`Platform.OS === "web"`,
|
|
||||||
);
|
|
||||||
await expect(readFile(path.join(directory, "client/greeting.tsx"), "utf8")).resolves.toContain(
|
|
||||||
`openExternal("https://paseo.sh")`,
|
|
||||||
);
|
|
||||||
await expect(readFile(path.join(directory, "server/greeting.ts"), "utf8")).resolves.toContain(
|
|
||||||
'"Hello, " + name + "!"',
|
|
||||||
);
|
|
||||||
await expect(readFile(path.join(directory, "shared/greeting.ts"), "utf8")).resolves.toContain(
|
|
||||||
'name: "greeting.create"',
|
|
||||||
);
|
|
||||||
|
|
||||||
await writeFile(path.join(directory, "client/greeting.tsx"), '\ndocument.title = "x";\n', {
|
|
||||||
flag: "a",
|
|
||||||
});
|
|
||||||
await expect(typecheckPlugin(directory)).rejects.toThrow("Cannot find name 'document'.");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("typechecks client and server Paseo API access", async () => {
|
it("typechecks client and server Paseo API access", async () => {
|
||||||
@@ -105,8 +68,8 @@ describe("plugin scaffold", () => {
|
|||||||
await scaffoldPluginDirectory(directory);
|
await scaffoldPluginDirectory(directory);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
writeFile(
|
writeFile(
|
||||||
path.join(directory, "shared", "inspect.ts"),
|
path.join(directory, "inspect.shared.ts"),
|
||||||
`import { defineRpc } from "@getpaseo/plugin";
|
`import { defineRpc } from "@getpaseo/plugin/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const inspect = defineRpc({
|
export const inspect = defineRpc({
|
||||||
@@ -117,13 +80,13 @@ export const inspect = defineRpc({
|
|||||||
`,
|
`,
|
||||||
),
|
),
|
||||||
writeFile(
|
writeFile(
|
||||||
path.join(directory, "server", "inspect.ts"),
|
path.join(directory, "inspect.server.ts"),
|
||||||
`import type { PluginHandlerContext } from "@getpaseo/plugin/server";
|
`import type { PluginHandlerContext } from "@getpaseo/plugin/server";
|
||||||
import type { RpcInput } from "@getpaseo/plugin";
|
import type { output as ZodOutput } from "zod";
|
||||||
import { inspect } from "../shared/inspect";
|
import { inspect } from "./inspect.shared";
|
||||||
|
|
||||||
export async function inspectConfig(
|
export async function inspectConfig(
|
||||||
_input: RpcInput<typeof inspect>,
|
_input: ZodOutput<typeof inspect.input>,
|
||||||
{ paseo }: PluginHandlerContext,
|
{ paseo }: PluginHandlerContext,
|
||||||
) {
|
) {
|
||||||
return { configured: Boolean((await paseo.config.get()).config) };
|
return { configured: Boolean((await paseo.config.get()).config) };
|
||||||
@@ -131,7 +94,7 @@ export async function inspectConfig(
|
|||||||
`,
|
`,
|
||||||
),
|
),
|
||||||
writeFile(
|
writeFile(
|
||||||
path.join(directory, "client", "main.tsx"),
|
path.join(directory, "main.client.tsx"),
|
||||||
`import React from "react";
|
`import React from "react";
|
||||||
import { Text } from "react-native";
|
import { Text } from "react-native";
|
||||||
import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native";
|
import { Icon, Modal, useToast } from "@getpaseo/plugin/react-native";
|
||||||
@@ -144,7 +107,7 @@ import {
|
|||||||
usePaseo,
|
usePaseo,
|
||||||
useWorkspace,
|
useWorkspace,
|
||||||
} from "@getpaseo/plugin";
|
} from "@getpaseo/plugin";
|
||||||
import { inspect } from "../shared/inspect";
|
import { inspect } from "./inspect.shared";
|
||||||
|
|
||||||
export function Surface({ navigation }: PluginSurfaceProps) {
|
export function Surface({ navigation }: PluginSurfaceProps) {
|
||||||
const paseo = usePaseo();
|
const paseo = usePaseo();
|
||||||
@@ -192,21 +155,23 @@ export function contributeClient(client: PluginClientContext) {
|
|||||||
`,
|
`,
|
||||||
),
|
),
|
||||||
writeFile(
|
writeFile(
|
||||||
path.join(directory, "index.client.tsx"),
|
path.join(directory, "index.ts"),
|
||||||
`import type { PluginClientContext } from "@getpaseo/plugin";
|
`import type { PluginContext } from "@getpaseo/plugin";
|
||||||
import { AgentPanel, contributeClient, Surface } from "./client/main";
|
import { AgentPanel, contributeClient, Surface } from "./main.client";
|
||||||
import { inspect } from "./shared/inspect";
|
import { inspectConfig } from "./inspect.server";
|
||||||
|
import { inspect } from "./inspect.shared";
|
||||||
|
|
||||||
export default function contribute(client: PluginClientContext) {
|
export default function contribute(plugin: PluginContext) {
|
||||||
client.addSurface("main", Surface);
|
plugin.handle(inspect, inspectConfig);
|
||||||
client.addWorkspacePanel({
|
plugin.addSurface("main", Surface);
|
||||||
|
plugin.addWorkspacePanel({
|
||||||
id: "review",
|
id: "review",
|
||||||
title: "Review",
|
title: "Review",
|
||||||
icon: "Scan",
|
icon: "Scan",
|
||||||
context: "agent",
|
context: "agent",
|
||||||
Component: AgentPanel,
|
Component: AgentPanel,
|
||||||
});
|
});
|
||||||
client.addCommandCenterItem({
|
plugin.addCommandCenterItem({
|
||||||
id: "open-review",
|
id: "open-review",
|
||||||
title: "Open review",
|
title: "Open review",
|
||||||
icon: "Scan",
|
icon: "Scan",
|
||||||
@@ -217,25 +182,25 @@ export default function contribute(client: PluginClientContext) {
|
|||||||
openPanel("review");
|
openPanel("review");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return contributeClient(client);
|
plugin.addClientSide(contributeClient);
|
||||||
}
|
|
||||||
`,
|
|
||||||
),
|
|
||||||
writeFile(
|
|
||||||
path.join(directory, "index.server.ts"),
|
|
||||||
`import type { PluginServerContext } from "@getpaseo/plugin";
|
|
||||||
import { inspectConfig } from "./server/inspect";
|
|
||||||
import { inspect } from "./shared/inspect";
|
|
||||||
|
|
||||||
export default function contribute(server: PluginServerContext) {
|
|
||||||
server.handle(inspect, inspectConfig);
|
|
||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await expect(typecheckPlugin(directory)).resolves.toBeUndefined();
|
const configPath = path.join(directory, "tsconfig.json");
|
||||||
|
const loaded = ts.readConfigFile(configPath, ts.sys.readFile);
|
||||||
|
const parsed = ts.parseJsonConfigFileContent(loaded.config, ts.sys, directory);
|
||||||
|
const diagnostics = ts.getPreEmitDiagnostics(
|
||||||
|
ts.createProgram(parsed.fileNames, parsed.options),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
diagnostics.map((diagnostic) =>
|
||||||
|
ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
it("refuses to write into a non-empty directory", async () => {
|
it("refuses to write into a non-empty directory", async () => {
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ const TSCONFIG = {
|
|||||||
target: "ES2020",
|
target: "ES2020",
|
||||||
module: "ESNext",
|
module: "ESNext",
|
||||||
moduleResolution: "Bundler",
|
moduleResolution: "Bundler",
|
||||||
lib: ["ES2023"],
|
lib: ["ES2023", "DOM"],
|
||||||
types: ["react"],
|
|
||||||
jsx: "react-jsx",
|
jsx: "react-jsx",
|
||||||
strict: true,
|
strict: true,
|
||||||
skipLibCheck: true,
|
skipLibCheck: true,
|
||||||
@@ -20,60 +19,20 @@ const TSCONFIG = {
|
|||||||
include: ["**/*.ts", "**/*.tsx"],
|
include: ["**/*.ts", "**/*.tsx"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const CLIENT_ENTRY = `import type { PluginClientContext } from "@getpaseo/plugin";
|
const ENTRY = `import type { PluginContext } from "@getpaseo/plugin";
|
||||||
import { GreetingSurface } from "./client/greeting";
|
import { MainSurface } from "./main.client";
|
||||||
|
|
||||||
export default function contribute(client: PluginClientContext) {
|
export default function contribute(plugin: PluginContext) {
|
||||||
client.addSurface("greeting", GreetingSurface);
|
plugin.addSurface("main", MainSurface);
|
||||||
client.addSidebarItem({
|
|
||||||
id: "greeting",
|
|
||||||
title: "Greeting",
|
|
||||||
icon: "MessageCircle",
|
|
||||||
surface: "greeting",
|
|
||||||
});
|
|
||||||
return () => {};
|
return () => {};
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const SERVER_ENTRY = `import type { PluginServerContext } from "@getpaseo/plugin";
|
const CLIENT_SURFACE = `import type { PluginSurfaceProps } from "@getpaseo/plugin";
|
||||||
import { createGreeting } from "./server/greeting";
|
import React, { useMemo } from "react";
|
||||||
import { greetingRpc } from "./shared/greeting";
|
import { Text, View } from "react-native";
|
||||||
|
|
||||||
export default function contribute(server: PluginServerContext) {
|
export function MainSurface({ theme, layout }: PluginSurfaceProps) {
|
||||||
server.handle(greetingRpc, createGreeting);
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SHARED_GREETING = `import { defineRpc } from "@getpaseo/plugin";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
export const greetingRpc = defineRpc({
|
|
||||||
name: "greeting.create",
|
|
||||||
input: z.object({ name: z.string() }),
|
|
||||||
output: z.object({ message: z.string() }),
|
|
||||||
});
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SERVER_GREETING = `import type { RpcInput } from "@getpaseo/plugin";
|
|
||||||
import { greetingRpc } from "../shared/greeting";
|
|
||||||
|
|
||||||
export function createGreeting({ name }: RpcInput<typeof greetingRpc>) {
|
|
||||||
return { message: "Hello, " + name + "!" };
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const CLIENT_GREETING = `import type { PluginSurfaceProps } from "@getpaseo/plugin";
|
|
||||||
import { useRpc } from "@getpaseo/plugin";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import { Pressable, Text, View } from "react-native";
|
|
||||||
import { greetingRpc } from "../shared/greeting";
|
|
||||||
import { openExternal } from "./web";
|
|
||||||
|
|
||||||
export function GreetingSurface({ theme, layout }: PluginSurfaceProps) {
|
|
||||||
const createGreeting = useRpc(greetingRpc);
|
|
||||||
const greeting = useMutation({ mutationFn: createGreeting });
|
|
||||||
const styles = useMemo(
|
const styles = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
screen: {
|
screen: {
|
||||||
@@ -82,49 +41,17 @@ export function GreetingSurface({ theme, layout }: PluginSurfaceProps) {
|
|||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: theme.colors.surface0,
|
||||||
},
|
},
|
||||||
text: { color: theme.colors.foreground },
|
text: { color: theme.colors.foreground },
|
||||||
button: { padding: 12, backgroundColor: theme.colors.accent },
|
|
||||||
buttonText: { color: theme.colors.accentForeground },
|
|
||||||
}),
|
}),
|
||||||
[theme, layout.compact],
|
[theme, layout.compact],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<View style={styles.screen}>
|
<View style={styles.screen}>
|
||||||
<Text style={styles.text}>{greeting.data?.message ?? "Ask the daemon for a greeting."}</Text>
|
<Text style={styles.text}>Hello from my plugin</Text>
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="Create greeting"
|
|
||||||
style={styles.button}
|
|
||||||
onPress={() => greeting.mutate({ name: "Paseo" })}
|
|
||||||
>
|
|
||||||
<Text style={styles.buttonText}>Create greeting</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="link"
|
|
||||||
accessibilityLabel="Open the Paseo website"
|
|
||||||
style={styles.button}
|
|
||||||
onPress={() => openExternal("https://paseo.sh")}
|
|
||||||
>
|
|
||||||
<Text style={styles.buttonText}>Open paseo.sh</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const CLIENT_WEB = `import { Linking, Platform } from "react-native";
|
|
||||||
|
|
||||||
// This plugin typechecks without the DOM library. Declare only what this module uses.
|
|
||||||
declare const window: { open(url: string, target: string, features: string): unknown };
|
|
||||||
|
|
||||||
export async function openExternal(url: string): Promise<void> {
|
|
||||||
if (Platform.OS === "web") {
|
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await Linking.openURL(url);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export interface PluginScaffold {
|
export interface PluginScaffold {
|
||||||
id: string;
|
id: string;
|
||||||
directory: string;
|
directory: string;
|
||||||
@@ -161,16 +88,9 @@ export async function scaffoldPluginDirectory(
|
|||||||
["paseo-plugin.json", `${JSON.stringify({ id }, null, 2)}\n`],
|
["paseo-plugin.json", `${JSON.stringify({ id }, null, 2)}\n`],
|
||||||
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
|
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
|
||||||
["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`],
|
["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`],
|
||||||
["index.client.tsx", CLIENT_ENTRY],
|
["index.ts", ENTRY],
|
||||||
["index.server.ts", SERVER_ENTRY],
|
["main.client.tsx", CLIENT_SURFACE],
|
||||||
["shared/greeting.ts", SHARED_GREETING],
|
|
||||||
["server/greeting.ts", SERVER_GREETING],
|
|
||||||
["client/greeting.tsx", CLIENT_GREETING],
|
|
||||||
["client/web.ts", CLIENT_WEB],
|
|
||||||
]);
|
]);
|
||||||
await Promise.all(
|
|
||||||
["shared", "server", "client"].map((name) => mkdir(path.join(directory, name))),
|
|
||||||
);
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
[...files].map(([filename, contents]) =>
|
[...files].map(([filename, contents]) =>
|
||||||
writeFile(path.join(directory, filename), contents, { flag: "wx" }),
|
writeFile(path.join(directory, filename), contents, { flag: "wx" }),
|
||||||
|
|||||||
@@ -5,18 +5,10 @@ import { runArchiveCommand } from "./archive.js";
|
|||||||
import { runCreateCommand } from "./create.js";
|
import { runCreateCommand } from "./create.js";
|
||||||
import { runLsCommand } from "./ls.js";
|
import { runLsCommand } from "./ls.js";
|
||||||
import { runRenameCommand } from "./rename.js";
|
import { runRenameCommand } from "./rename.js";
|
||||||
import { runSetupCommand } from "./setup.js";
|
|
||||||
|
|
||||||
export function createWorkspaceCommand(): Command {
|
export function createWorkspaceCommand(): Command {
|
||||||
const workspace = new Command("workspace").description("Manage workspaces");
|
const workspace = new Command("workspace").description("Manage workspaces");
|
||||||
|
|
||||||
addJsonAndDaemonHostOptions(
|
|
||||||
workspace
|
|
||||||
.command("setup")
|
|
||||||
.description("Allow and run setup for a workspace")
|
|
||||||
.argument("<workspace-id>", "Workspace id"),
|
|
||||||
).action(withOutput(runSetupCommand));
|
|
||||||
|
|
||||||
addJsonAndDaemonHostOptions(
|
addJsonAndDaemonHostOptions(
|
||||||
workspace
|
workspace
|
||||||
.command("create")
|
.command("create")
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import type { Command } from "commander";
|
|
||||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
|
||||||
import type { CommandError, OutputSchema, SingleResult } from "../../output/index.js";
|
|
||||||
|
|
||||||
interface WorkspaceSetupResult {
|
|
||||||
workspaceId: string;
|
|
||||||
status: "started" | "already_allowed";
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspaceSetupSchema: OutputSchema<WorkspaceSetupResult> = {
|
|
||||||
idField: "workspaceId",
|
|
||||||
columns: [
|
|
||||||
{ header: "WORKSPACE ID", field: "workspaceId", width: 20 },
|
|
||||||
{ header: "STATUS", field: "status", width: 18 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function runSetupCommand(
|
|
||||||
workspaceId: string,
|
|
||||||
options: { host?: string },
|
|
||||||
_command: Command,
|
|
||||||
): Promise<SingleResult<WorkspaceSetupResult>> {
|
|
||||||
const host = getDaemonHost({ host: options.host });
|
|
||||||
const client = await connectToDaemon({ host: options.host }).catch((error: unknown) => {
|
|
||||||
throw {
|
|
||||||
code: "DAEMON_NOT_RUNNING",
|
|
||||||
message: `Cannot connect to daemon at ${host}: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
} satisfies CommandError;
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
// COMPAT(workspaceSetupRun): added in v0.7.3, remove gate after 2027-09-02.
|
|
||||||
if (!client.getLastServerInfoMessage()?.features?.workspaceSetupRun) {
|
|
||||||
throw { code: "DAEMON_UPDATE_REQUIRED", message: "Update the host to run workspace setup." };
|
|
||||||
}
|
|
||||||
const payload = await client.runWorkspaceSetup(workspaceId);
|
|
||||||
if (payload.error) throw new Error(payload.error);
|
|
||||||
return {
|
|
||||||
type: "single",
|
|
||||||
data: { workspaceId, status: payload.started ? "started" : "already_allowed" },
|
|
||||||
schema: workspaceSetupSchema,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (error && typeof error === "object" && "code" in error) throw error;
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
throw { code: "WORKSPACE_SETUP_FAILED", message } satisfies CommandError;
|
|
||||||
} finally {
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,7 @@ async function main(): Promise<void> {
|
|||||||
const context = await createE2ETestContext({ timeout: 45_000 });
|
const context = await createE2ETestContext({ timeout: 45_000 });
|
||||||
try {
|
try {
|
||||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "cli-e2e" }));
|
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "cli-e2e" }));
|
||||||
await writeFile(path.join(directory, "index.server.ts"), pluginSource);
|
await writeFile(path.join(directory, "index.tsx"), pluginSource);
|
||||||
|
|
||||||
const install = await context.paseo(["plugin", "install", directory, "--json"]);
|
const install = await context.paseo(["plugin", "install", directory, "--json"]);
|
||||||
assert.equal(install.exitCode, 0, install.stderr);
|
assert.equal(install.exitCode, 0, install.stderr);
|
||||||
@@ -44,7 +44,7 @@ async function main(): Promise<void> {
|
|||||||
path.join(gitDirectory, "paseo-plugin.json"),
|
path.join(gitDirectory, "paseo-plugin.json"),
|
||||||
JSON.stringify({ id: "git-cli-e2e" }),
|
JSON.stringify({ id: "git-cli-e2e" }),
|
||||||
);
|
);
|
||||||
await writeFile(path.join(gitDirectory, "index.server.ts"), pluginSource);
|
await writeFile(path.join(gitDirectory, "index.ts"), pluginSource);
|
||||||
await git(gitDirectory, ["add", "-A"]);
|
await git(gitDirectory, ["add", "-A"]);
|
||||||
await git(gitDirectory, ["commit", "-m", "initial"]);
|
await git(gitDirectory, ["commit", "-m", "initial"]);
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ async function main(): Promise<void> {
|
|||||||
assert.equal(JSON.parse(gitInstall.stdout).source, "git");
|
assert.equal(JSON.parse(gitInstall.stdout).source, "git");
|
||||||
|
|
||||||
await writeFile(
|
await writeFile(
|
||||||
path.join(gitDirectory, "index.server.ts"),
|
path.join(gitDirectory, "index.ts"),
|
||||||
`${pluginSource}\nconst updated = true;\n`,
|
`${pluginSource}\nconst updated = true;\n`,
|
||||||
);
|
);
|
||||||
await git(gitDirectory, ["add", "-A"]);
|
await git(gitDirectory, ["add", "-A"]);
|
||||||
|
|||||||
@@ -4499,7 +4499,6 @@ test("fetches scoped recent provider sessions", async () => {
|
|||||||
providers: ["my-claude"],
|
providers: ["my-claude"],
|
||||||
since: "2026-04-30T00:00:00.000Z",
|
since: "2026-04-30T00:00:00.000Z",
|
||||||
limit: 25,
|
limit: 25,
|
||||||
query: "invoice",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mock.sent).toHaveLength(1);
|
expect(mock.sent).toHaveLength(1);
|
||||||
@@ -4512,7 +4511,6 @@ test("fetches scoped recent provider sessions", async () => {
|
|||||||
providers?: string[];
|
providers?: string[];
|
||||||
since?: string;
|
since?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
query?: string;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
expect(request.message).toMatchObject({
|
expect(request.message).toMatchObject({
|
||||||
@@ -4521,7 +4519,6 @@ test("fetches scoped recent provider sessions", async () => {
|
|||||||
providers: ["my-claude"],
|
providers: ["my-claude"],
|
||||||
since: "2026-04-30T00:00:00.000Z",
|
since: "2026-04-30T00:00:00.000Z",
|
||||||
limit: 25,
|
limit: 25,
|
||||||
query: "invoice",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
mock.triggerMessage(
|
mock.triggerMessage(
|
||||||
|
|||||||
@@ -2096,7 +2096,6 @@ export class DaemonClient {
|
|||||||
...(options?.providers ? { providers: options.providers } : {}),
|
...(options?.providers ? { providers: options.providers } : {}),
|
||||||
...(options?.since ? { since: options.since } : {}),
|
...(options?.since ? { since: options.since } : {}),
|
||||||
...(options?.limit ? { limit: options.limit } : {}),
|
...(options?.limit ? { limit: options.limit } : {}),
|
||||||
...(options?.query !== undefined ? { query: options.query } : {}),
|
|
||||||
});
|
});
|
||||||
return this.sendRequest({
|
return this.sendRequest({
|
||||||
requestId: resolvedRequestId,
|
requestId: resolvedRequestId,
|
||||||
@@ -2392,16 +2391,6 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async runWorkspaceSetup(
|
|
||||||
workspaceId: string,
|
|
||||||
requestId?: string,
|
|
||||||
): Promise<Extract<SessionOutboundMessage, { type: "workspace.setup.run.response" }>["payload"]> {
|
|
||||||
return this.sendNamespacedCorrelatedSessionRequest<"workspace.setup.run.response">({
|
|
||||||
requestId,
|
|
||||||
message: { type: "workspace.setup.run.request", workspaceId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async fetchAgent(options: FetchAgentOptions): Promise<FetchAgentResult | null>;
|
async fetchAgent(options: FetchAgentOptions): Promise<FetchAgentResult | null>;
|
||||||
async fetchAgent(agentId: string, requestId?: string): Promise<FetchAgentResult | null>;
|
async fetchAgent(agentId: string, requestId?: string): Promise<FetchAgentResult | null>;
|
||||||
async fetchAgent(
|
async fetchAgent(
|
||||||
|
|||||||
@@ -580,19 +580,7 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId,
|
|||||||
const responsiveViewport = await readViewport(client, browserId);
|
const responsiveViewport = await readViewport(client, browserId);
|
||||||
|
|
||||||
await originalDeck.getByTestId(`workspace-tab-agent_${callerAgentId}`).click();
|
await originalDeck.getByTestId(`workspace-tab-agent_${callerAgentId}`).click();
|
||||||
await page.waitForFunction(
|
await page.waitForTimeout(500);
|
||||||
({ id, webContentsId }) => {
|
|
||||||
const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`);
|
|
||||||
return (
|
|
||||||
webview?.parentElement?.getAttribute("data-paseo-browser-surface") === id &&
|
|
||||||
webview.parentElement.style.width === "1px" &&
|
|
||||||
webview.parentElement.style.pointerEvents === "none" &&
|
|
||||||
webview.getWebContentsId() === webContentsId
|
|
||||||
);
|
|
||||||
},
|
|
||||||
{ id: browserId, webContentsId: firstGuest.webContentsId },
|
|
||||||
{ timeout: timeoutMs },
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
await callBrowserToolUntilReady(client, "browser_screenshot", { browserId });
|
await callBrowserToolUntilReady(client, "browser_screenshot", { browserId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -127,20 +127,7 @@ export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginClientContext extends PluginCommandCapabilities {
|
export interface PluginClientContext extends PluginCommandCapabilities {
|
||||||
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): PluginCleanup;
|
|
||||||
addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup;
|
|
||||||
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup;
|
|
||||||
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): PluginCleanup;
|
|
||||||
addSlashCommand(contribution: PluginClientSlashCommandContribution): PluginCleanup;
|
|
||||||
addComposerPill(contribution: PluginComposerPillContribution): PluginCleanup;
|
addComposerPill(contribution: PluginComposerPillContribution): PluginCleanup;
|
||||||
addAttachmentSource(contribution: PluginAttachmentSourceContribution): PluginCleanup;
|
|
||||||
addTheme(contribution: PluginThemeContribution): PluginCleanup;
|
|
||||||
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
|
|
||||||
contribution: PluginTimelineTransformerContribution<ItemType>,
|
|
||||||
): PluginCleanup;
|
|
||||||
addTimelineRenderer<Schema extends ZodType>(
|
|
||||||
contribution: PluginTimelineRendererContribution<Schema>,
|
|
||||||
): PluginCleanup;
|
|
||||||
openPanel(id: string, options: PluginClientOpenPanelOptions): void;
|
openPanel(id: string, options: PluginClientOpenPanelOptions): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,7 +296,7 @@ export interface PluginHandlerContext {
|
|||||||
paseo: PaseoApi;
|
paseo: PaseoApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginServerContext {
|
export interface PluginContext {
|
||||||
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
||||||
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
||||||
handler: (
|
handler: (
|
||||||
@@ -317,8 +304,22 @@ export interface PluginServerContext {
|
|||||||
context: PluginHandlerContext,
|
context: PluginHandlerContext,
|
||||||
) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
|
) => ZodInput<OutputSchema> | Promise<ZodInput<OutputSchema>>,
|
||||||
): void;
|
): void;
|
||||||
|
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): void;
|
||||||
|
addSidebarItem(contribution: PluginSidebarContribution): void;
|
||||||
|
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
|
||||||
|
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
|
||||||
|
addClientSlashCommand(contribution: PluginClientSlashCommandContribution): void;
|
||||||
|
addClientSide(contribution: PluginClientContribution): void;
|
||||||
|
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
|
||||||
|
addTheme(contribution: PluginThemeContribution): void;
|
||||||
|
addTimelineTransformer<ItemType extends AgentTimelineItem["type"]>(
|
||||||
|
contribution: PluginTimelineTransformerContribution<ItemType>,
|
||||||
|
): void;
|
||||||
|
addTimelineRenderer<Schema extends ZodType>(
|
||||||
|
contribution: PluginTimelineRendererContribution<Schema>,
|
||||||
|
): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PluginCleanup = () => void | Promise<void>;
|
export type PluginCleanup = () => void | Promise<void>;
|
||||||
|
|
||||||
export type PluginServerContribution = (server: PluginServerContext) => PluginCleanup;
|
export type PluginContribution = (plugin: PluginContext) => PluginCleanup;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, expectTypeOf, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { defineAttachmentSource, defineRpc, type RpcInput, type RpcOutput } from "./index.js";
|
import { defineAttachmentSource, defineRpc } from "./index.js";
|
||||||
import { callPluginRpc, searchPluginAttachments } from "./host.js";
|
import { callPluginRpc, searchPluginAttachments } from "./host.js";
|
||||||
|
|
||||||
const contract = defineRpc({
|
const contract = defineRpc({
|
||||||
@@ -10,11 +10,6 @@ const contract = defineRpc({
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("plugin RPC contracts", () => {
|
describe("plugin RPC contracts", () => {
|
||||||
it("derives handler input and output types from a contract", () => {
|
|
||||||
expectTypeOf<RpcInput<typeof contract>>().toEqualTypeOf<{ value: number }>();
|
|
||||||
expectTypeOf<RpcOutput<typeof contract>>().toEqualTypeOf<{ value: number }>();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("validates input before invoking the daemon", async () => {
|
it("validates input before invoking the daemon", async () => {
|
||||||
let invocations = 0;
|
let invocations = 0;
|
||||||
const invoke = async () => {
|
const invoke = async () => {
|
||||||
|
|||||||
@@ -4,10 +4,99 @@ export {
|
|||||||
usePluginRuntimeContextBridge,
|
usePluginRuntimeContextBridge,
|
||||||
type PluginRuntimeContextBridge,
|
type PluginRuntimeContextBridge,
|
||||||
} from "./runtime-context-bridge.js";
|
} from "./runtime-context-bridge.js";
|
||||||
import type { PluginAttachmentSourceContribution } from "./contracts.js";
|
import type {
|
||||||
|
PluginAttachmentSourceContribution,
|
||||||
|
PluginCommandCenterItemContribution,
|
||||||
|
PluginClientContribution,
|
||||||
|
PluginClientSlashCommandContribution,
|
||||||
|
PluginContext,
|
||||||
|
PluginSidebarContribution,
|
||||||
|
PluginSurfaceContribution,
|
||||||
|
PluginSurfaceProps,
|
||||||
|
PluginThemeContribution,
|
||||||
|
PluginTimelineRendererContribution,
|
||||||
|
PluginTimelineTransformerContribution,
|
||||||
|
PluginWorkspacePanelContribution,
|
||||||
|
} from "./contracts.js";
|
||||||
import { PluginRpcProvider } from "./rpc-context.js";
|
import { PluginRpcProvider } from "./rpc-context.js";
|
||||||
import { PaseoApiProvider } from "./paseo-context.js";
|
import { PaseoApiProvider } from "./paseo-context.js";
|
||||||
import { callPluginRpc } from "./rpc.js";
|
import { callPluginRpc } from "./rpc.js";
|
||||||
|
import type { ComponentType } from "react";
|
||||||
|
|
||||||
|
interface PluginCollector {
|
||||||
|
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): void;
|
||||||
|
addSidebarItem(contribution: PluginSidebarContribution): void;
|
||||||
|
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): void;
|
||||||
|
addCommandCenterItem(contribution: PluginCommandCenterItemContribution): void;
|
||||||
|
addClientSlashCommand(contribution: PluginClientSlashCommandContribution): void;
|
||||||
|
addClientSide(contribution: PluginClientContribution): void;
|
||||||
|
addAttachmentSource(contribution: PluginAttachmentSourceContribution): void;
|
||||||
|
addTheme(contribution: PluginThemeContribution): void;
|
||||||
|
addTimelineTransformer(contribution: PluginTimelineTransformerContribution): void;
|
||||||
|
addTimelineRenderer(contribution: PluginTimelineRendererContribution): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginRegistrationCollector {
|
||||||
|
surfaces: PluginSurfaceContribution[];
|
||||||
|
sidebarItems: PluginSidebarContribution[];
|
||||||
|
workspacePanels: PluginWorkspacePanelContribution[];
|
||||||
|
commandCenterItems: PluginCommandCenterItemContribution[];
|
||||||
|
clientSlashCommands: PluginClientSlashCommandContribution[];
|
||||||
|
clientSide: PluginClientContribution | null;
|
||||||
|
attachmentSources: PluginAttachmentSourceContribution[];
|
||||||
|
themes: PluginThemeContribution[];
|
||||||
|
timelineTransformers: PluginTimelineTransformerContribution[];
|
||||||
|
timelineRenderers: PluginTimelineRendererContribution[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPluginContext(
|
||||||
|
collector: PluginCollector,
|
||||||
|
): Pick<
|
||||||
|
PluginContext,
|
||||||
|
| "addSurface"
|
||||||
|
| "addSidebarItem"
|
||||||
|
| "addWorkspacePanel"
|
||||||
|
| "addCommandCenterItem"
|
||||||
|
| "addClientSlashCommand"
|
||||||
|
| "addClientSide"
|
||||||
|
| "addAttachmentSource"
|
||||||
|
| "addTheme"
|
||||||
|
| "addTimelineTransformer"
|
||||||
|
| "addTimelineRenderer"
|
||||||
|
> {
|
||||||
|
return {
|
||||||
|
addSurface(id, Component) {
|
||||||
|
collector.addSurface(id, Component);
|
||||||
|
},
|
||||||
|
addSidebarItem(contribution) {
|
||||||
|
collector.addSidebarItem(contribution);
|
||||||
|
},
|
||||||
|
addWorkspacePanel(contribution) {
|
||||||
|
collector.addWorkspacePanel(contribution);
|
||||||
|
},
|
||||||
|
addCommandCenterItem(contribution) {
|
||||||
|
collector.addCommandCenterItem(contribution);
|
||||||
|
},
|
||||||
|
addClientSlashCommand(contribution) {
|
||||||
|
collector.addClientSlashCommand(contribution);
|
||||||
|
},
|
||||||
|
addClientSide(contribution) {
|
||||||
|
collector.addClientSide(contribution);
|
||||||
|
},
|
||||||
|
addAttachmentSource(contribution) {
|
||||||
|
collector.addAttachmentSource(contribution);
|
||||||
|
},
|
||||||
|
addTheme(contribution) {
|
||||||
|
collector.addTheme(contribution);
|
||||||
|
},
|
||||||
|
addTimelineTransformer(contribution) {
|
||||||
|
collector.addTimelineTransformer(contribution);
|
||||||
|
},
|
||||||
|
addTimelineRenderer(contribution) {
|
||||||
|
collector.addTimelineRenderer(contribution);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function searchPluginAttachments(
|
export async function searchPluginAttachments(
|
||||||
source: PluginAttachmentSourceContribution,
|
source: PluginAttachmentSourceContribution,
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import type { PluginAttachmentSourceContribution, PluginIconProps } from "./contracts.js";
|
import type { PluginIconProps } from "./contracts.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
PluginAttachmentItemSchema,
|
PluginAttachmentItemSchema,
|
||||||
PluginAttachmentSearchPayloadSchema,
|
PluginAttachmentSearchPayloadSchema,
|
||||||
|
defineAttachmentSource,
|
||||||
|
defineRpc,
|
||||||
type PluginAttachmentItem,
|
type PluginAttachmentItem,
|
||||||
type PluginAttachmentSearchPayload,
|
type PluginAttachmentSearchPayload,
|
||||||
} from "./attachments.js";
|
type PluginRpcContract,
|
||||||
export { defineRpc, type PluginRpcContract, type RpcInput, type RpcOutput } from "./rpc.js";
|
} from "./server.js";
|
||||||
|
|
||||||
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
|
|
||||||
definition: Definition,
|
|
||||||
): Definition {
|
|
||||||
return definition;
|
|
||||||
}
|
|
||||||
|
|
||||||
export declare const Icon: ComponentType<PluginIconProps>;
|
export declare const Icon: ComponentType<PluginIconProps>;
|
||||||
export type {
|
export type {
|
||||||
@@ -30,8 +26,8 @@ export type {
|
|||||||
PluginClientOpenPanelOptions,
|
PluginClientOpenPanelOptions,
|
||||||
PluginComposerPillContribution,
|
PluginComposerPillContribution,
|
||||||
PluginComposerPillProps,
|
PluginComposerPillProps,
|
||||||
PluginServerContribution,
|
PluginContribution,
|
||||||
PluginServerContext,
|
PluginContext,
|
||||||
PluginGlobalCommandContext,
|
PluginGlobalCommandContext,
|
||||||
PluginHandlerContext,
|
PluginHandlerContext,
|
||||||
PluginHostProps,
|
PluginHostProps,
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
describe("plugin runtime-entry migration guide", () => {
|
|
||||||
it("maps every client registration method", async () => {
|
|
||||||
const contracts = await readFile(new URL("./contracts.ts", import.meta.url), "utf8");
|
|
||||||
const context = /export interface PluginClientContext[^{]*{([\s\S]*?)\n}/.exec(contracts)?.[1];
|
|
||||||
if (!context) throw new Error("PluginClientContext was not found");
|
|
||||||
const methods = [...context.matchAll(/^\s+(add[A-Z]\w*)\(/gm)].map((match) => match[1]);
|
|
||||||
const migration = await readFile(
|
|
||||||
fileURLToPath(new URL("../../../public-docs/plugins/migration.md", import.meta.url)),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
const table = migration.slice(
|
|
||||||
migration.indexOf("| Old registration and location"),
|
|
||||||
migration.indexOf("## 4. Separate imports"),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(methods).not.toEqual([]);
|
|
||||||
for (const method of methods) expect(table).toContain(`client.${method}(`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -9,9 +9,6 @@ export interface PluginRpcContract<
|
|||||||
output: OutputSchema;
|
output: OutputSchema;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RpcInput<Contract extends PluginRpcContract> = ZodOutput<Contract["input"]>;
|
|
||||||
export type RpcOutput<Contract extends PluginRpcContract> = ZodInput<Contract["output"]>;
|
|
||||||
|
|
||||||
interface RpcDefinition<InputSchema extends ZodType, OutputSchema extends ZodType> {
|
interface RpcDefinition<InputSchema extends ZodType, OutputSchema extends ZodType> {
|
||||||
name: string;
|
name: string;
|
||||||
input: InputSchema;
|
input: InputSchema;
|
||||||
|
|||||||
@@ -1 +1,16 @@
|
|||||||
export type { PluginHandlerContext } from "./contracts.js";
|
import type { PluginAttachmentSourceContribution } from "./contracts.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
PluginAttachmentItemSchema,
|
||||||
|
PluginAttachmentSearchPayloadSchema,
|
||||||
|
type PluginAttachmentItem,
|
||||||
|
type PluginAttachmentSearchPayload,
|
||||||
|
} from "./attachments.js";
|
||||||
|
export type { PluginAttachmentSourceContribution, PluginHandlerContext } from "./contracts.js";
|
||||||
|
export { defineRpc, type PluginRpcContract } from "./rpc.js";
|
||||||
|
|
||||||
|
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
|
||||||
|
definition: Definition,
|
||||||
|
): Definition {
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"lib": ["ES2023"],
|
"lib": ["ES2023", "DOM"],
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|||||||
@@ -344,21 +344,6 @@ describe("agent detach RPC", () => {
|
|||||||
}
|
}
|
||||||
expect(parsed.features?.importSessionWorkspaceTarget).toBe(true);
|
expect(parsed.features?.importSessionWorkspaceTarget).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parses the session import search feature gate", () => {
|
|
||||||
const parsed = parseServerInfoStatusPayload({
|
|
||||||
status: "server_info",
|
|
||||||
serverId: "srv-test",
|
|
||||||
features: {
|
|
||||||
importSessionSearch: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!parsed) {
|
|
||||||
throw new Error("Expected server info payload to parse");
|
|
||||||
}
|
|
||||||
expect(parsed.features?.importSessionSearch).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("agent setting action responses", () => {
|
describe("agent setting action responses", () => {
|
||||||
|
|||||||
@@ -1371,7 +1371,6 @@ export const FetchRecentProviderSessionsRequestMessageSchema = z.object({
|
|||||||
providers: z.array(z.string()).optional(),
|
providers: z.array(z.string()).optional(),
|
||||||
since: z.string().optional(),
|
since: z.string().optional(),
|
||||||
limit: z.number().int().positive().max(200).optional(),
|
limit: z.number().int().positive().max(200).optional(),
|
||||||
query: z.string().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const FetchAgentRequestMessageSchema = z.object({
|
export const FetchAgentRequestMessageSchema = z.object({
|
||||||
@@ -2459,12 +2458,6 @@ export const WorkspaceSetupStatusRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const WorkspaceSetupRunRequestSchema = z.object({
|
|
||||||
type: z.literal("workspace.setup.run.request"),
|
|
||||||
workspaceId: z.string(),
|
|
||||||
requestId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs.
|
// COMPAT(desktopEditorBridge): added in v0.1.88, remove after 2026-12-03 once old clients no longer call daemon editor RPCs.
|
||||||
export const LegacyListAvailableEditorsRequestSchema = z.object({
|
export const LegacyListAvailableEditorsRequestSchema = z.object({
|
||||||
type: z.literal("list_available_editors_request"),
|
type: z.literal("list_available_editors_request"),
|
||||||
@@ -3159,7 +3152,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
PaseoWorktreeArchiveRequestSchema,
|
PaseoWorktreeArchiveRequestSchema,
|
||||||
CreatePaseoWorktreeRequestSchema,
|
CreatePaseoWorktreeRequestSchema,
|
||||||
WorkspaceSetupStatusRequestSchema,
|
WorkspaceSetupStatusRequestSchema,
|
||||||
WorkspaceSetupRunRequestSchema,
|
|
||||||
LegacyListAvailableEditorsRequestSchema,
|
LegacyListAvailableEditorsRequestSchema,
|
||||||
LegacyOpenInEditorRequestSchema,
|
LegacyOpenInEditorRequestSchema,
|
||||||
OpenProjectRequestSchema,
|
OpenProjectRequestSchema,
|
||||||
@@ -3398,8 +3390,6 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
directorySync: z.boolean().optional(),
|
directorySync: z.boolean().optional(),
|
||||||
// COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14.
|
// COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14.
|
||||||
workspaceLabels: z.boolean().optional(),
|
workspaceLabels: z.boolean().optional(),
|
||||||
// COMPAT(workspaceSetupRun): added in v0.7.3, remove gate after 2027-09-02.
|
|
||||||
workspaceSetupRun: z.boolean().optional(),
|
|
||||||
// COMPAT(checkoutForgeSetAutoMerge): added in v0.2.0-beta.1. Remove the
|
// COMPAT(checkoutForgeSetAutoMerge): added in v0.2.0-beta.1. Remove the
|
||||||
// feature gate and checkoutGithubSetAutoMerge fallback after 2027-01-17
|
// feature gate and checkoutGithubSetAutoMerge fallback after 2027-01-17
|
||||||
// once the supported daemon floor is >= v0.2.0.
|
// once the supported daemon floor is >= v0.2.0.
|
||||||
@@ -3505,8 +3495,6 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
providerRemoval: z.boolean().optional(),
|
providerRemoval: z.boolean().optional(),
|
||||||
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
|
// COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16.
|
||||||
importSessionWorkspaceTarget: z.boolean().optional(),
|
importSessionWorkspaceTarget: z.boolean().optional(),
|
||||||
// COMPAT(importSessionSearch): added in v0.7.3, remove gate after 2027-03-02.
|
|
||||||
importSessionSearch: z.boolean().optional(),
|
|
||||||
// COMPAT(forgeProviders): added in v0.2.0-beta.1. Drop the gate after
|
// COMPAT(forgeProviders): added in v0.2.0-beta.1. Drop the gate after
|
||||||
// 2027-01-17 once the supported daemon floor is >= v0.2.0.
|
// 2027-01-17 once the supported daemon floor is >= v0.2.0.
|
||||||
// Daemon advertises pluggable non-GitHub forge support (the forge registry);
|
// Daemon advertises pluggable non-GitHub forge support (the forge registry);
|
||||||
@@ -3983,14 +3971,6 @@ export const FetchRecentProviderSessionsResponseMessageSchema = z.object({
|
|||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
entries: z.array(RecentProviderSessionDescriptorPayloadSchema),
|
entries: z.array(RecentProviderSessionDescriptorPayloadSchema),
|
||||||
filteredAlreadyImportedCount: z.number().int().nonnegative().optional(),
|
filteredAlreadyImportedCount: z.number().int().nonnegative().optional(),
|
||||||
providerErrors: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
provider: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4165,42 +4145,16 @@ export const WorkspaceSetupProgressMessageSchema = z.object({
|
|||||||
type: z.literal("workspace_setup_progress"),
|
type: z.literal("workspace_setup_progress"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
workspaceId: z.string(),
|
workspaceId: z.string(),
|
||||||
status: z.enum(["running", "completed", "failed", "blocked"]),
|
status: z.enum(["running", "completed", "failed"]),
|
||||||
detail: WorktreeSetupDetailPayloadSchema,
|
detail: WorktreeSetupDetailPayloadSchema,
|
||||||
error: z.string().nullable(),
|
error: z.string().nullable(),
|
||||||
blockedSource: z
|
|
||||||
.object({
|
|
||||||
kind: z.literal("change_request"),
|
|
||||||
forge: z.string(),
|
|
||||||
number: z.number().int().positive(),
|
|
||||||
headRepository: z.string(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const WorkspaceSetupSnapshotSchema = z.object({
|
export const WorkspaceSetupSnapshotSchema = z.object({
|
||||||
status: z.enum(["running", "completed", "failed", "blocked"]),
|
status: z.enum(["running", "completed", "failed"]),
|
||||||
detail: WorktreeSetupDetailPayloadSchema,
|
detail: WorktreeSetupDetailPayloadSchema,
|
||||||
error: z.string().nullable(),
|
error: z.string().nullable(),
|
||||||
blockedSource: z
|
|
||||||
.object({
|
|
||||||
kind: z.literal("change_request"),
|
|
||||||
forge: z.string(),
|
|
||||||
number: z.number().int().positive(),
|
|
||||||
headRepository: z.string(),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const WorkspaceSetupRunResponseMessageSchema = z.object({
|
|
||||||
type: z.literal("workspace.setup.run.response"),
|
|
||||||
payload: z.object({
|
|
||||||
requestId: z.string(),
|
|
||||||
workspaceId: z.string(),
|
|
||||||
started: z.boolean(),
|
|
||||||
error: z.string().nullable(),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const WorkspaceSetupStatusResponseMessageSchema = z.object({
|
export const WorkspaceSetupStatusResponseMessageSchema = z.object({
|
||||||
@@ -4599,7 +4553,6 @@ export const WorkspaceCreateResponseSchema = z.object({
|
|||||||
payload: z.object({
|
payload: z.object({
|
||||||
workspace: WorkspaceDescriptorPayloadSchema.nullable(),
|
workspace: WorkspaceDescriptorPayloadSchema.nullable(),
|
||||||
setupTerminalId: z.string().nullable(),
|
setupTerminalId: z.string().nullable(),
|
||||||
setupSkippedReason: z.string().optional(),
|
|
||||||
error: z.string().nullable(),
|
error: z.string().nullable(),
|
||||||
errorCode: z.string().optional(),
|
errorCode: z.string().optional(),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
@@ -5632,7 +5585,6 @@ export const CreatePaseoWorktreeResponseSchema = z.object({
|
|||||||
error: z.string().nullable(),
|
error: z.string().nullable(),
|
||||||
errorCode: z.string().optional(),
|
errorCode: z.string().optional(),
|
||||||
setupTerminalId: z.string().nullable(),
|
setupTerminalId: z.string().nullable(),
|
||||||
setupSkippedReason: z.string().optional(),
|
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -6437,7 +6389,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ScriptStatusUpdateMessageSchema,
|
ScriptStatusUpdateMessageSchema,
|
||||||
WorkspaceSetupProgressMessageSchema,
|
WorkspaceSetupProgressMessageSchema,
|
||||||
WorkspaceSetupStatusResponseMessageSchema,
|
WorkspaceSetupStatusResponseMessageSchema,
|
||||||
WorkspaceSetupRunResponseMessageSchema,
|
|
||||||
AgentStreamMessageSchema,
|
AgentStreamMessageSchema,
|
||||||
AgentStatusMessageSchema,
|
AgentStatusMessageSchema,
|
||||||
FetchAgentsResponseMessageSchema,
|
FetchAgentsResponseMessageSchema,
|
||||||
@@ -6616,9 +6567,6 @@ export type WorkspaceSetupSnapshot = z.infer<typeof WorkspaceSetupSnapshotSchema
|
|||||||
export type WorkspaceSetupStatusResponseMessage = z.infer<
|
export type WorkspaceSetupStatusResponseMessage = z.infer<
|
||||||
typeof WorkspaceSetupStatusResponseMessageSchema
|
typeof WorkspaceSetupStatusResponseMessageSchema
|
||||||
>;
|
>;
|
||||||
export type WorkspaceSetupRunResponseMessage = z.infer<
|
|
||||||
typeof WorkspaceSetupRunResponseMessageSchema
|
|
||||||
>;
|
|
||||||
export type AgentStreamMessage = z.infer<typeof AgentStreamMessageSchema>;
|
export type AgentStreamMessage = z.infer<typeof AgentStreamMessageSchema>;
|
||||||
export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>;
|
export type AgentStatusMessage = z.infer<typeof AgentStatusMessageSchema>;
|
||||||
export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePayloadSchema>;
|
export type ProjectCheckoutLitePayload = z.infer<typeof ProjectCheckoutLitePayloadSchema>;
|
||||||
@@ -6954,7 +6902,6 @@ export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponse
|
|||||||
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
|
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
|
||||||
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
|
export type PaseoWorktreeArchiveResponse = z.infer<typeof PaseoWorktreeArchiveResponseSchema>;
|
||||||
export type WorkspaceSetupStatusRequest = z.infer<typeof WorkspaceSetupStatusRequestSchema>;
|
export type WorkspaceSetupStatusRequest = z.infer<typeof WorkspaceSetupStatusRequestSchema>;
|
||||||
export type WorkspaceSetupRunRequest = z.infer<typeof WorkspaceSetupRunRequestSchema>;
|
|
||||||
export type LegacyListAvailableEditorsRequest = z.infer<
|
export type LegacyListAvailableEditorsRequest = z.infer<
|
||||||
typeof LegacyListAvailableEditorsRequestSchema
|
typeof LegacyListAvailableEditorsRequestSchema
|
||||||
>;
|
>;
|
||||||
|
|||||||
@@ -10,52 +10,6 @@ import {
|
|||||||
} from "./messages.js";
|
} from "./messages.js";
|
||||||
|
|
||||||
describe("workspace message schemas", () => {
|
describe("workspace message schemas", () => {
|
||||||
test("parses blocked setup status and the explicit setup run RPC", () => {
|
|
||||||
expect(
|
|
||||||
SessionInboundMessageSchema.parse({
|
|
||||||
type: "workspace.setup.run.request",
|
|
||||||
workspaceId: "workspace-fork",
|
|
||||||
requestId: "run-setup",
|
|
||||||
}),
|
|
||||||
).toMatchObject({ type: "workspace.setup.run.request", workspaceId: "workspace-fork" });
|
|
||||||
|
|
||||||
expect(
|
|
||||||
SessionOutboundMessageSchema.parse({
|
|
||||||
type: "workspace_setup_progress",
|
|
||||||
payload: {
|
|
||||||
workspaceId: "workspace-fork",
|
|
||||||
status: "blocked",
|
|
||||||
detail: {
|
|
||||||
type: "worktree_setup",
|
|
||||||
worktreePath: "/repo/fork",
|
|
||||||
branchName: "fork",
|
|
||||||
log: "",
|
|
||||||
commands: [],
|
|
||||||
},
|
|
||||||
error: null,
|
|
||||||
blockedSource: {
|
|
||||||
kind: "change_request",
|
|
||||||
forge: "github",
|
|
||||||
number: 42,
|
|
||||||
headRepository: "contributor/paseo",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toMatchObject({ payload: { status: "blocked", blockedSource: { number: 42 } } });
|
|
||||||
|
|
||||||
expect(
|
|
||||||
SessionOutboundMessageSchema.parse({
|
|
||||||
type: "workspace.setup.run.response",
|
|
||||||
payload: {
|
|
||||||
requestId: "run-setup",
|
|
||||||
workspaceId: "workspace-fork",
|
|
||||||
started: true,
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toMatchObject({ type: "workspace.setup.run.response", payload: { started: true } });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parses fetch_workspaces_request", () => {
|
test("parses fetch_workspaces_request", () => {
|
||||||
const parsed = SessionInboundMessageSchema.parse({
|
const parsed = SessionInboundMessageSchema.parse({
|
||||||
type: "fetch_workspaces_request",
|
type: "fetch_workspaces_request",
|
||||||
@@ -310,7 +264,6 @@ describe("workspace message schemas", () => {
|
|||||||
|
|
||||||
expect(request.type).toBe("fetch_recent_provider_sessions_request");
|
expect(request.type).toBe("fetch_recent_provider_sessions_request");
|
||||||
expect(request.providers).toEqual(["my-claude"]);
|
expect(request.providers).toEqual(["my-claude"]);
|
||||||
expect(request.query).toBeUndefined();
|
|
||||||
expect(response.payload).toEqual({
|
expect(response.payload).toEqual({
|
||||||
requestId: "req-recent-provider-sessions",
|
requestId: "req-recent-provider-sessions",
|
||||||
entries: [
|
entries: [
|
||||||
@@ -328,30 +281,6 @@ describe("workspace message schemas", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parses session import search requests and per-provider errors", () => {
|
|
||||||
const request = SessionInboundMessageSchema.parse({
|
|
||||||
type: "fetch_recent_provider_sessions_request",
|
|
||||||
requestId: "req-search-provider-sessions",
|
|
||||||
query: "invoice",
|
|
||||||
});
|
|
||||||
const response = SessionOutboundMessageSchema.parse({
|
|
||||||
type: "fetch_recent_provider_sessions_response",
|
|
||||||
payload: {
|
|
||||||
requestId: "req-search-provider-sessions",
|
|
||||||
entries: [],
|
|
||||||
providerErrors: [{ provider: "codex", message: "Codex listing timed out" }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(request.query).toBe("invoice");
|
|
||||||
if (response.type !== "fetch_recent_provider_sessions_response") {
|
|
||||||
throw new Error("expected fetch_recent_provider_sessions_response");
|
|
||||||
}
|
|
||||||
expect(response.payload.providerErrors).toEqual([
|
|
||||||
{ provider: "codex", message: "Codex listing timed out" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("parses fetch_recent_provider_sessions response with filteredAlreadyImportedCount", () => {
|
test("parses fetch_recent_provider_sessions response with filteredAlreadyImportedCount", () => {
|
||||||
const response = SessionOutboundMessageSchema.parse({
|
const response = SessionOutboundMessageSchema.parse({
|
||||||
type: "fetch_recent_provider_sessions_response",
|
type: "fetch_recent_provider_sessions_response",
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export const ACP_PROVIDER_ICON_NAMES = [
|
|||||||
"factory-droid",
|
"factory-droid",
|
||||||
"fast-agent",
|
"fast-agent",
|
||||||
"gemini",
|
"gemini",
|
||||||
"gjc",
|
|
||||||
"glm-acp-agent",
|
"glm-acp-agent",
|
||||||
"goose",
|
"goose",
|
||||||
"grok",
|
"grok",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user