mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1bbb7a102 | ||
|
|
20353ca208 | ||
|
|
16e8de01e3 | ||
|
|
02e611f3dd | ||
|
|
f4b209be4d | ||
|
|
c610676203 | ||
|
|
9e90b86afa | ||
|
|
35d2457d83 | ||
|
|
140b0bb716 | ||
|
|
92442e7435 | ||
|
|
75eb9d44a3 | ||
|
|
bb676ef086 | ||
|
|
53c9607479 | ||
|
|
25defbafb4 | ||
|
|
eb753813a1 | ||
|
|
20ecdee72d | ||
|
|
ce07732d62 | ||
|
|
a835136854 | ||
|
|
7178583612 | ||
|
|
f75dff2322 | ||
|
|
2d8ff6f1cf | ||
|
|
53d824a320 | ||
|
|
fa1f01ebbc | ||
|
|
d4841b68ff | ||
|
|
ac085d50c6 | ||
|
|
1c960261ac |
@@ -59,8 +59,6 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.format != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -80,8 +78,6 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -107,8 +103,6 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.quality != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -137,8 +131,6 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps: &server_test_steps
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -180,8 +172,6 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.server != 'false' || needs.changes.outputs.hub != 'false') }}
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps: *server_test_steps
|
||||
|
||||
desktop-tests-ubuntu:
|
||||
@@ -268,8 +258,6 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.app != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -296,8 +284,6 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.sdk != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -330,7 +316,6 @@ jobs:
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
PLAYWRIGHT_SHARD: "1/4"
|
||||
PLAYWRIGHT_ARTIFACT: "1"
|
||||
steps: &playwright_test_steps
|
||||
@@ -378,7 +363,6 @@ jobs:
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
PLAYWRIGHT_SHARD: "2/4"
|
||||
PLAYWRIGHT_ARTIFACT: "2"
|
||||
steps: *playwright_test_steps
|
||||
@@ -389,7 +373,6 @@ jobs:
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
PLAYWRIGHT_SHARD: "3/4"
|
||||
PLAYWRIGHT_ARTIFACT: "3"
|
||||
steps: *playwright_test_steps
|
||||
@@ -400,7 +383,6 @@ jobs:
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.browser != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
PLAYWRIGHT_SHARD: "4/4"
|
||||
PLAYWRIGHT_ARTIFACT: "4"
|
||||
steps: *playwright_test_steps
|
||||
@@ -410,8 +392,6 @@ jobs:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.relay != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -435,7 +415,6 @@ jobs:
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
||||
PASEO_DICTATION_ENABLED: "0"
|
||||
PASEO_VOICE_MODE_ENABLED: "0"
|
||||
@@ -467,7 +446,6 @@ jobs:
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
||||
PASEO_DICTATION_ENABLED: "0"
|
||||
PASEO_VOICE_MODE_ENABLED: "0"
|
||||
@@ -481,7 +459,6 @@ jobs:
|
||||
if: ${{ !cancelled() && (needs.changes.outputs.full != 'false' || needs.changes.outputs.cli != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: "1"
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
||||
PASEO_DICTATION_ENABLED: "0"
|
||||
PASEO_VOICE_MODE_ENABLED: "0"
|
||||
|
||||
@@ -547,28 +547,10 @@ jobs:
|
||||
fi
|
||||
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
|
||||
node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}"
|
||||
# shellcheck disable=SC2016
|
||||
ROLLOUT_HOURS_EXPECTED="$ROLLOUT_HOURS" RELEASE_DATE_EXPECTED="$timestamp" node -e '
|
||||
const yaml = require("js-yaml");
|
||||
const fs = require("fs");
|
||||
const expectedHours = Number(process.env.ROLLOUT_HOURS_EXPECTED);
|
||||
const expectedDate = process.env.RELEASE_DATE_EXPECTED;
|
||||
if (!Number.isFinite(expectedHours) || expectedHours < 0) {
|
||||
throw new Error(`expected non-negative rolloutHours, got ${process.env.ROLLOUT_HOURS_EXPECTED}`);
|
||||
}
|
||||
for (const f of process.argv.slice(1)) {
|
||||
const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {};
|
||||
if (m.rolloutHours !== expectedHours) {
|
||||
throw new Error(`${f}: rolloutHours=${m.rolloutHours}, expected ${expectedHours}`);
|
||||
}
|
||||
if (m.releaseDate !== expectedDate) {
|
||||
throw new Error(`${f}: releaseDate=${m.releaseDate}, expected ${expectedDate}`);
|
||||
}
|
||||
if (typeof m.version !== "string" || m.version.length === 0) {
|
||||
throw new Error(`${f}: missing or invalid version`);
|
||||
}
|
||||
}
|
||||
' "${files[@]}"
|
||||
node ../scripts/validate-desktop-manifests.mjs \
|
||||
--release-date "$timestamp" \
|
||||
--rollout-hours "$ROLLOUT_HOURS" \
|
||||
"${files[@]}"
|
||||
gh release upload "$release_lookup" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
failed_builds=()
|
||||
|
||||
@@ -50,6 +50,8 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
In Docker, the official image runs the daemon and agents as the non-root
|
||||
|
||||
@@ -156,9 +156,9 @@ The rows combine two kinds of children:
|
||||
parentAgentId === thisAgent.id AND !archivedAt
|
||||
```
|
||||
|
||||
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track.
|
||||
- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
|
||||
|
||||
Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. It shows its own direct children in a subagents track. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical.
|
||||
|
||||
Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only.
|
||||
|
||||
@@ -174,7 +174,8 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta
|
||||
- **A resumed task can be announced again with a new `tool_use_id`.** The first Task tool id remains the canonical descriptor and later ids are routing aliases for the same session-scoped task. The resumed prompt is added to that child timeline.
|
||||
- **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`.
|
||||
- **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them.
|
||||
- **On replay, `<session>/subagents/` holds every descendant, not just this session's children.** `agent-<id>.meta.json` carries `spawnDepth`: `1` is a direct child, `2+` was spawned by another subagent and its `toolUseId` names a Task call made inside its parent's session, which nothing in this transcript can resolve. Replaying those adds rows the live stream never showed, each with no Task card and no recoverable outcome, so they render as running forever. One recorded session showed 10 subagents live and would have replayed 22.
|
||||
- **Nested ownership comes from the launching sidechain, not `spawn_depth` alone.** A sidechain's Agent or Bash tool call records the direct owner of that `tool_use_id`; the following `task_started` inherits it. This routes a grandchild descriptor and child-owned background notifications without relying on labels or flattening them into the managed parent.
|
||||
- **On replay, `<session>/subagents/` holds every descendant beside the root.** Resolve the tree one proven generation at a time: the root transcript admits direct children, then each admitted sidechain transcript admits its children by `toolUseId`. `spawnDepth` orders candidates but does not establish ownership. Unresolved sidecars remain excluded as ambient or unrelated work.
|
||||
- **Replay `totalTokens` is a context-size reading, not cumulative spend.** Claude Code finalizes a subagent by summing the _last_ assistant message's usage block and shipping that as `usage.total_tokens`. Summing per-entry usage instead multiplies the cached prefix by the turn count and reports a number several times larger than the live path.
|
||||
|
||||
Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls.
|
||||
|
||||
+24
-2
@@ -75,6 +75,8 @@ not retain non-Git directories.
|
||||
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
|
||||
| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation |
|
||||
| `server/agent/mcp-server.ts` | Thin MCP adapter that registers the Paseo tool catalog with the MCP SDK |
|
||||
| `server/voice-chat/` | App-level voice calls, provider orchestration, and semantic call events |
|
||||
| `server/session/dictation/` | Composer dictation stream lifetime and speech-readiness gating |
|
||||
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
|
||||
| `server/orchestration-skills/` | Bundled catalog, host selection, convergence, and skill-directory transactions |
|
||||
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
|
||||
@@ -111,7 +113,26 @@ Cross-platform React Native app that connects to one or more daemons.
|
||||
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
|
||||
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
|
||||
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
|
||||
- Voice features: dictation (STT) and voice agent (realtime)
|
||||
- App `VoiceRuntime` owns the app-level call UX, current route context, and semantic transcript. It
|
||||
prepares installed client transports and accepts the server's selected answer without inspecting
|
||||
transport kind. Each transport owns capture, playback, its data plane, mute, volume, and media
|
||||
state; `transports/daemon-audio.ts` is the production transport in this slice. A WebRTC transport
|
||||
adds another factory at app composition and does not change `VoiceRuntime`.
|
||||
- Server `VoiceChat` serializes start/stop, cancels startup, routes provider-neutral state/events,
|
||||
forwards opaque transport data, and reacts to the provider's terminal signal. It knows only the
|
||||
`VoiceCallProvider` contract; concrete data-plane validation and serialization stay in the
|
||||
provider and matching client transport. Providers own prompts, credentials, hidden resources,
|
||||
tools, media integration, and cleanup. The manual provider creates a hidden orchestrator through
|
||||
the atomic required-Paseo-tools capability with an authoritative provider prompt, and owns a
|
||||
separate speech runtime configured from the top-level `manualVoice` block. That runtime is created
|
||||
on the first valid manual call and reused; ordinary daemon startup does not load or download voice
|
||||
models. Dictation keeps its own speech runtime. A complete manual orchestrator provider and mode
|
||||
selection is required; partial configuration does not fall back to another provider or mode.
|
||||
Cleanup detaches work delegated by the hidden orchestrator before archiving it.
|
||||
- Voice calls end as soon as the final client socket disconnects. The containing client session may
|
||||
remain reconnectable, but calls are not replayed or rehydrated in this protocol slice. The sidebar
|
||||
footer starts the call and hosts its controls; routes and visible agents do not own its lifetime.
|
||||
Dictation remains a separate composer feature.
|
||||
|
||||
Consumers request directory or timeline data without choosing memory, cache, or network. The owner
|
||||
publishes an accepted cache hit and then reconciles it over the existing network path. A miss or an
|
||||
@@ -264,7 +285,8 @@ optionally carry the same `turnId`. New clients use these fields when present an
|
||||
status once at the directory boundary rather than maintaining a second activity model.
|
||||
|
||||
- Terminal subscribe/input/capture commands
|
||||
- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`)
|
||||
- App-level voice call commands and events (`voice.call.*`) and independent dictation streams
|
||||
(`dictation_stream_*`)
|
||||
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
|
||||
|
||||
`directory_suggestions_request` is one daemon-owned filesystem search capability. The daemon
|
||||
|
||||
+59
-19
@@ -275,6 +275,45 @@ rather than storing something it cannot describe. That is why the client gates t
|
||||
UI on `server_info.features.agentProfiles` instead of letting a save appear to succeed against an
|
||||
older daemon.
|
||||
|
||||
### Agent provider Paseo tools
|
||||
|
||||
`agents.providers` is keyed by the exact provider ID used to launch the agent. The built-in IDs are
|
||||
`claude`, `codex`, `copilot`, `opencode`, `pi`, and `omp`. Custom provider IDs are their literal
|
||||
configuration keys, such as `my-claude` or `zai`, not the provider named by `extends`.
|
||||
|
||||
Each entry may include a Paseo-tool policy:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-claude": {
|
||||
"extends": "claude",
|
||||
"label": "My Claude",
|
||||
"paseoTools": {
|
||||
"enabled": true,
|
||||
"disabledTools": ["browser_evaluate"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Absent `paseoTools`, or absent fields within it, means Paseo tools are enabled and all tools are
|
||||
allowed. `enabled: false` disables the provider's Paseo catalog; `disabledTools` lists exact tool
|
||||
IDs to omit. The policy covers the core and browser catalog, not the voice-only `speak` tool.
|
||||
Browser tools also require `daemon.browserTools.enabled` and a connected browser host.
|
||||
This policy controls the catalog presented to an agent. It is not an authorization boundary for
|
||||
agents that can access the host through a shell.
|
||||
|
||||
`daemon.mcp.injectIntoAgents` is the global override. When it is `false`, no provider receives
|
||||
Paseo tools; otherwise the provider policy applies. Provider and global policy are resolved when a
|
||||
session is created, resumed, imported, or reloaded, so configuration changes affect the next
|
||||
session rather than an already-running one.
|
||||
|
||||
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
|
||||
|
||||
### Git process limits
|
||||
|
||||
Git process limits are global to one daemon. The start-rate limit defaults to `64` processes per
|
||||
@@ -439,25 +478,26 @@ workspace together with its owning project.
|
||||
|
||||
Array of workspace records. A workspace is a specific working directory within a project.
|
||||
|
||||
| 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. |
|
||||
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
|
||||
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
|
||||
| `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`". |
|
||||
| `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. |
|
||||
| `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` |
|
||||
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `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. |
|
||||
| `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" |
|
||||
| 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. |
|
||||
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
|
||||
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
|
||||
| `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`". |
|
||||
| `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. |
|
||||
| `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` |
|
||||
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `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. |
|
||||
| `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" |
|
||||
| `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.
|
||||
|
||||
|
||||
+6
-3
@@ -556,13 +556,16 @@ npm run cli -- daemon status # Check daemon status
|
||||
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
|
||||
```
|
||||
|
||||
Use `--host` to point the CLI at a different daemon:
|
||||
Use the global `--host` option to point the CLI at a different daemon:
|
||||
|
||||
```bash
|
||||
npm run cli -- ls -a --host localhost:7777
|
||||
npm run cli -- ls -a --host ssh://user@host
|
||||
npm run cli -- --host localhost:7777 ls -a
|
||||
npm run cli -- --host ssh://user@host ls -a
|
||||
```
|
||||
|
||||
Set `PASEO_HOST` to use the same target across invocations. An explicit
|
||||
`--host` overrides the environment variable.
|
||||
|
||||
In an SSH URI, the URL port is the SSH server port. The remote daemon defaults to `127.0.0.1:6767`; use `?daemonPort=7777` to override it. The transport runs non-interactively through the local OpenSSH client and never installs, starts, or configures the remote daemon. User-facing setup and troubleshooting live in [public-docs/connectivity.md](../public-docs/connectivity.md#ssh).
|
||||
|
||||
Desktop integrations can focus an existing agent without creating one or
|
||||
|
||||
@@ -77,6 +77,16 @@ only use local param fallback during cold mount (`/` or empty pathname), or a
|
||||
hidden workspace can overwrite the remembered workspace before Settings or
|
||||
History returns.
|
||||
|
||||
Settings detail routes are separate siblings on purpose. Keep
|
||||
`settings/[section]`, the host routes, the projects index, and project detail as
|
||||
distinct route names. `router.dismissTo()` ultimately matches stack entries by
|
||||
route name. A single catch-all Settings route would make project detail and the
|
||||
projects index the same route; Back would update params in place and leave a
|
||||
phantom detail entry underneath. The host routes also stay outside the
|
||||
store-ready protected group so a cold host deep link can survive daemon startup.
|
||||
Do not collapse this topology with a catch-all, `getId`, or
|
||||
`dangerouslySingular` workaround.
|
||||
|
||||
## Agent Targets
|
||||
|
||||
Notifications and agent URLs enter the router with different authoritative
|
||||
|
||||
+31
-15
@@ -134,19 +134,39 @@ The fix for transforms is Gotcha 3. The fix for context is Gotcha 7.
|
||||
|
||||
## Gotcha 3 — Keyboard layout and portal anchors
|
||||
|
||||
Move the chat surface with worklet-driven bottom padding from
|
||||
`KeyboardShiftProvider`. Padding keeps the stream, composer, visual position,
|
||||
and native hit testing in the same layout. Do not translate the stream and
|
||||
composer independently.
|
||||
`KeyboardTranslateView` owns visual keyboard motion. Android uses the
|
||||
controller's Reanimated signal. iOS uses its native-driver `Animated.event`
|
||||
signal because the stock iOS Reanimated value changes at move start, while
|
||||
writing a replacement Reanimated value every frame interrupts UIKit's hide
|
||||
animation. Keep this platform choice inside `KeyboardTranslateView`.
|
||||
|
||||
Do not use the controller's raw keyboard progress for the padding. It can retain
|
||||
a nonzero value after the keyboard closes. The shared provider normalizes that
|
||||
state and reconciles native animation-end events.
|
||||
`KeyboardDock` always keeps the chat surface at full height and wraps it in
|
||||
`KeyboardTranslateView`. The panel root clips it at the header edge, so rows
|
||||
slide under the header on open and out from under it on close. Do not swap the
|
||||
dock to keyboard padding at rest. Changing its height creates either a blank
|
||||
band or a paused transition when the inverted list updates its viewport.
|
||||
|
||||
`measureInWindow` already includes the dock's current padding layout. When a
|
||||
portal opens, snapshot the current shift and apply only the subsequent shift
|
||||
delta to its animated `bottom`. Adding the full shift moves the portal twice and
|
||||
can place it over the composer controls.
|
||||
Do not animate a layout prop through the keyboard transition. On Android
|
||||
Fabric, each animated layout update clones the shadow tree and runs Yoga over
|
||||
the dock subtree. This measured about 10 ms per frame on the emulator and the
|
||||
mounted layouts arrived in bursts every 2–4 frames. A transform does not dirty
|
||||
layout. Preserve the translated dock's history scroll range with a far-end
|
||||
content inset on the inverted stream list. Update that inset only when keyboard
|
||||
motion settles; never drive it per frame.
|
||||
|
||||
Move the stream and composer together through `KeyboardDock`. Do not translate
|
||||
them independently.
|
||||
|
||||
`KeyboardShiftProvider` owns the normalized shift used for settled insets and
|
||||
portal geometry, plus the `isMoving` worklet value. It reconciles native
|
||||
animation-end events because the controller can retain a nonzero value after
|
||||
the keyboard closes.
|
||||
|
||||
Measure portal anchors while the dock is at rest. If a popover opens during
|
||||
motion, re-measure when `isMoving` settles. `measureInWindow` includes the
|
||||
dock's current layout and transform. Snapshot the shift at measurement time and
|
||||
apply only the subsequent shift delta to the animated `bottom`. Adding the full
|
||||
shift moves the portal twice and can place it over the composer controls.
|
||||
|
||||
The provider also reconciles iOS from the controller's native `onEnd` event.
|
||||
The controller's stock iOS shared values update at move start and during an
|
||||
@@ -155,10 +175,6 @@ leave the last height/progress pair stuck in either the open or closed state.
|
||||
Keep that terminal reconciliation on the UI thread; a later focus or blur must
|
||||
not be required to repair the offset.
|
||||
|
||||
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
|
||||
to refresh the snapshot if the keyboard was mid-transition when the popover
|
||||
opened.
|
||||
|
||||
## Gotcha 4 — Host-relative positioning before platform offsets
|
||||
|
||||
The generic anchored-overlay rule is:
|
||||
|
||||
+139
-56
@@ -1,9 +1,8 @@
|
||||
# Plugins
|
||||
|
||||
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
|
||||
from one `index.ts`. Paseo executes the server contribution in a subprocess and evaluates the client
|
||||
contribution in the app runtime.
|
||||
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.
|
||||
|
||||
> **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.
|
||||
|
||||
@@ -42,15 +41,19 @@ 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
|
||||
remain lifecycle-owned and do not reload from manual config edits.
|
||||
|
||||
The directory contains an identity-only manifest, one entry point, and local typechecking support:
|
||||
The directory contains an identity-only manifest, one optional entry per runtime, runtime-owned
|
||||
directories, and local typechecking support. At least one entry is required.
|
||||
|
||||
```text
|
||||
my-plugin/
|
||||
paseo-plugin.json
|
||||
index.ts
|
||||
main.client.tsx
|
||||
package.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
|
||||
@@ -88,7 +91,7 @@ paseo plugin add https://gitlab.com/group/repository.git
|
||||
paseo plugin add https://git.example.com/owner/repository.git
|
||||
paseo plugin add owner/monorepo:plugins/review
|
||||
paseo plugin add owner/repository --ref main
|
||||
paseo plugin status
|
||||
paseo plugin ls
|
||||
paseo plugin update review
|
||||
paseo plugin update --all
|
||||
```
|
||||
@@ -96,8 +99,8 @@ paseo plugin update --all
|
||||
Append `:relative/path` to the source when the plugin lives below the repository root.
|
||||
|
||||
Omitting `--ref` tracks the remote's default branch. A branch passed with `--ref` also tracks;
|
||||
tags and commits stay pinned. `status` fetches tracked refs and reports the installed and available
|
||||
commits. Removing a Git source deletes Paseo's managed checkout.
|
||||
tags and commits stay pinned. `ls` reports the installed commit without contacting the remote.
|
||||
Removing a Git source deletes Paseo's managed checkout.
|
||||
|
||||
### Declare Git preparation
|
||||
|
||||
@@ -136,46 +139,68 @@ log credentials or tokens.
|
||||
|
||||
## Contribute behavior and UI
|
||||
|
||||
Default export one contribution function from `index.ts`. Keep it to contribution wiring. Runtime
|
||||
code lives behind filename boundaries:
|
||||
Default export one contribution function from each runtime entry. Keep the entries to registration
|
||||
wiring. Runtime code lives behind directory boundaries:
|
||||
|
||||
| Suffix | Owns |
|
||||
| -------------- | -------------------------------------------------------------------- |
|
||||
| `*.client.tsx` | React, React Native, hooks, styles, surfaces, panels, and callbacks. |
|
||||
| `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. |
|
||||
| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. |
|
||||
| Path | Owns |
|
||||
| -------------------------------- | -------------------------------------------------------------------- |
|
||||
| `index.client.tsx` and `client/` | React, React Native, hooks, styles, surfaces, panels, and callbacks. |
|
||||
| `index.server.ts` and `server/` | Node APIs, filesystem and process access, credentials, and handlers. |
|
||||
| `shared/` | Zod RPC contracts and plain values used by both runtimes. |
|
||||
|
||||
Shared files import contracts from `@getpaseo/plugin/server`. Client files import Paseo UI from
|
||||
Do not put any other code modules in the plugin root.
|
||||
|
||||
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
|
||||
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
|
||||
`<Modal.Content>`.
|
||||
Plugin UI runs on desktop and mobile across multiple themes: color every `Text` from
|
||||
`theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`.
|
||||
See `public-docs/plugins/reference.md`.
|
||||
See `public-docs/plugins/v0.8/reference.md`.
|
||||
|
||||
| Module | Use it for |
|
||||
| ------------------------------- | -------------------------------------------------------- |
|
||||
| `@getpaseo/plugin` | contribution contracts and client data hooks |
|
||||
| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks |
|
||||
| `@getpaseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
|
||||
| Module | Use it for |
|
||||
| ------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `@getpaseo/plugin` | contribution contracts, shared definitions, RPC input/output types, and client data hooks |
|
||||
| `@getpaseo/plugin/react-native` | Paseo React Native components and UI hooks |
|
||||
| `@getpaseo/plugin/server` | handler-only types such as `PluginHandlerContext` |
|
||||
| `@getpaseo/plugin/provider` | provider registration, connection, session, input, event, and timeline contracts |
|
||||
| `@getpaseo/plugin/acp` | command-backed ACP adapter and focused transformer hooks |
|
||||
|
||||
The compiler removes client registrations and imports from the server entry point, and server
|
||||
registrations and imports from the client entry point. Importing a `*.server` module from a client
|
||||
module, or a `*.client` module from a server module, fails compilation. Top-level React Native calls
|
||||
such as `StyleSheet.create` belong in `*.client.tsx`; placing them in `index.ts` executes them in the
|
||||
server bundle.
|
||||
The compiler rejects a client import of `server/`, a server import of `client/`, and every `node:`
|
||||
import reachable from client code. Shared modules cannot import runtime-owned modules. A relative
|
||||
import to any other code file in the plugin root is also rejected; move it into `client/`, `server/`,
|
||||
or `shared/`. These are compile errors naming the importing file and boundary rule. Top-level React
|
||||
Native calls such as `StyleSheet.create` belong in `client/`.
|
||||
|
||||
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/v0.8/reference.md#works-on-mobile) for the complete
|
||||
pattern.
|
||||
|
||||
```ts
|
||||
import type { PluginContext } from "@getpaseo/plugin";
|
||||
import { Greeting } from "./greeting.client";
|
||||
import { createGreeting } from "./greeting.server";
|
||||
import { greetRpc } from "./greeting.shared";
|
||||
// index.server.ts
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import { createGreeting } from "./server/greeting";
|
||||
import { greetRpc } from "./shared/greeting";
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(greetRpc, createGreeting);
|
||||
plugin.addSurface("main", Greeting);
|
||||
plugin.addSidebarItem({ id: "main", title: "Greeting", icon: "MessageCircle", surface: "main" });
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.handle(greetRpc, createGreeting);
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
```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 () => {};
|
||||
}
|
||||
```
|
||||
@@ -230,12 +255,56 @@ optional client-owned agent and workspace navigation; its absence is the compati
|
||||
older clients. Other navigation remains limited to registered global surfaces and workspace panels.
|
||||
Plugins do not receive Expo Router or workspace-layout store access.
|
||||
|
||||
## Contribute a provider
|
||||
|
||||
Register a provider from `index.server.ts`. The provider connection is callback-based and owns all
|
||||
of its sessions; plugin RPC is not part of the provider data path.
|
||||
|
||||
```ts
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import type { ProviderRegistration } from "@getpaseo/plugin/provider";
|
||||
import { createProvider } from "./server/provider";
|
||||
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.registerProvider(createProvider() satisfies ProviderRegistration);
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
`send()` resolves after acceptance. Publish operation completion, prompt disposition, turn state,
|
||||
configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`.
|
||||
Route messages, structured commands, steering, and command side effects through `session.prompt`.
|
||||
Provider settings are toggle/select data that Paseo renders in the composer. Keep private options in
|
||||
the opaque `providerOptions` config object.
|
||||
|
||||
Agent refresh closes the current provider session and opens it again with current configuration and
|
||||
persistence. Providers re-read credentials, environment, global configuration, and MCP servers on
|
||||
`session.open`; there is no provider reload input.
|
||||
|
||||
For an ACP command, register `runAcpProvider({ id, label, command })` from
|
||||
`@getpaseo/plugin/acp`. Its transformer hooks cover narrow vendor differences; do not translate the
|
||||
whole provider event stream. The direct and ACP examples live in `plugin-examples/provider-direct`
|
||||
and `plugin-examples/provider-acp-transformer`.
|
||||
|
||||
Provider-emitted plugin timeline items use the same renderer registration as transformed and
|
||||
daemon-appended plugin items. The direct example includes both sides. The renderer-only
|
||||
`plugin-examples/inline-thinking` example shows that timeline presentation remains independent of a
|
||||
provider implementation. The public [provider plugin guide](../public-docs/plugins/v0.8/providers.md)
|
||||
owns author workflow, lifecycle, testing, and distribution guidance.
|
||||
|
||||
`ProviderRegistration.icon` is a file path relative to the plugin directory, such as `icon.svg`.
|
||||
It must resolve inside that directory to a regular SVG file no larger than 64 KiB. The SVG must be
|
||||
self-contained: scripts, styles, `foreignObject`, event-handler attributes, JavaScript URLs, and
|
||||
external `href` or `xlink:href` references are rejected. Fragment references such as `#mark` are
|
||||
allowed. Paseo reads and sanitizes the file when the plugin starts; the string is never an inline
|
||||
SVG or URL.
|
||||
|
||||
## Contribute composer pills
|
||||
|
||||
Register a headless client entrypoint, then add and remove targeted pills from that client
|
||||
lifecycle. `addClientSide` runs once per plugin installation in each connected app and never runs
|
||||
in the daemon subprocess. It can subscribe to the client API, call plugin RPCs, and own arbitrary
|
||||
client state without mounting a panel or surface.
|
||||
Add and remove targeted pills from the client entry lifecycle. `index.client.tsx` runs once per
|
||||
plugin installation in each connected app and never runs in the daemon subprocess. It can subscribe
|
||||
to the client API, call plugin RPCs, and own arbitrary client state without mounting a panel or
|
||||
surface.
|
||||
|
||||
```tsx
|
||||
export function contributeClient(client: PluginClientContext) {
|
||||
@@ -266,8 +335,8 @@ export function contributeClient(client: PluginClientContext) {
|
||||
}
|
||||
```
|
||||
|
||||
Wire it from `index.ts` with `plugin.addClientSide(contributeClient)`. `addComposerPill` exists only
|
||||
on `PluginClientContext`; it returns an idempotent removal function. A pill appears only in the
|
||||
Call `contributeClient(client)` from `index.client.tsx`, or move its body into that entry.
|
||||
`addComposerPill` 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,
|
||||
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
|
||||
@@ -306,21 +375,22 @@ await paseo.agents.ref(agentId).timeline.append({
|
||||
```
|
||||
|
||||
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. The row stays
|
||||
in canonical history and renders an unavailable placeholder when its renderer is not installed.
|
||||
Serialized `data` must be at most 64 KiB; the daemon rejects a larger append instead of storing a
|
||||
payload that cannot be rendered intact. The daemon advertises this RPC through
|
||||
replaces the previous row from that plugin on live clients and fresh timeline fetches. Rows live in
|
||||
the daemon's in-memory timeline and survive scroll, refetch, and reconnect, but not a daemon
|
||||
restart. A row without an installed renderer shows an unavailable placeholder. Serialized `data`
|
||||
must be at most 64 KiB; the daemon rejects a larger append instead of storing a payload that cannot
|
||||
be rendered intact. The daemon advertises this RPC through
|
||||
`server_info.features.pluginTimelineItems`.
|
||||
|
||||
## Contribute client slash commands
|
||||
|
||||
`addClientSlashCommand` registers an agent- or workspace-context command in the composer. The
|
||||
`addSlashCommand` 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
|
||||
the same `paseo`, `rpc`, `openSurface`, workspace, agent, and `openPanel` capabilities as the matching
|
||||
Command Center callback.
|
||||
|
||||
```ts
|
||||
plugin.addClientSlashCommand({
|
||||
client.addSlashCommand({
|
||||
name: "review",
|
||||
description: "Run the review bot",
|
||||
argumentHint: "[scope]",
|
||||
@@ -343,13 +413,24 @@ search picker, drafts, selected pill, and submission. The plugin returns complet
|
||||
credentials and vendor API calls stay in the daemon handler.
|
||||
|
||||
```ts
|
||||
import type { PluginContext } from "@getpaseo/plugin";
|
||||
import { search } from "./issues.server";
|
||||
import { issues, searchIssues } from "./issues.shared";
|
||||
// index.server.ts
|
||||
import type { PluginServerContext } from "@getpaseo/plugin";
|
||||
import { search } from "./server/issues";
|
||||
import { searchIssues } from "./shared/issues";
|
||||
|
||||
export default function contribute(plugin: PluginContext) {
|
||||
plugin.handle(searchIssues, search);
|
||||
plugin.addAttachmentSource(issues);
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.handle(searchIssues, search);
|
||||
return () => {};
|
||||
}
|
||||
```
|
||||
|
||||
```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 () => {};
|
||||
}
|
||||
```
|
||||
@@ -368,8 +449,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)
|
||||
for the runtime-patching rules the appearance settings share.
|
||||
|
||||
`addTheme` is a client registration, so the compiler strips it from the backend bundle. A daemon
|
||||
that predates it does not, and the plugin fails to start there. Daemons advertise
|
||||
`addTheme` is a client registration and belongs in `index.client.tsx`. A client that predates it
|
||||
cannot evaluate that entry. Daemons advertise
|
||||
`features.pluginThemes` in `server_info`; the plugin theme catalog is the one place the app reads it, and
|
||||
a host without it contributes no themes.
|
||||
|
||||
@@ -382,6 +463,8 @@ stable rather than arrival-ordered. The app resolves that id
|
||||
against the installed catalog on every change; an id nothing contributes falls back to the default
|
||||
preference instead of painting the reserved slot's placeholder colors.
|
||||
|
||||
Existing plugin authors should follow the standalone [v0.8 runtime-entry migration guide](../public-docs/plugins/v0.8/migration.md).
|
||||
|
||||
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
|
||||
`plugin-examples/catppuccin` for a theme.
|
||||
|
||||
+3
-1
@@ -57,7 +57,9 @@ Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`
|
||||
|
||||
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
|
||||
|
||||
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`.
|
||||
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. The daemon resolves `agents.providers.<provider>.paseoTools` by the exact provider ID. The catalog policy belongs to the caller: it filters the tools exposed to the current agent. When that agent calls `create_agent`, the child receives the policy for the child provider ID; the caller's policy is not inherited.
|
||||
|
||||
A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume the already-filtered `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`; the MCP server builds the same policy-filtered catalog for that caller. Filtering is enforced at catalog registration in both paths. Browser tools remain subject to the daemon browser-tools setting and browser-host availability.
|
||||
|
||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||
|
||||
|
||||
@@ -253,6 +253,15 @@ If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its ow
|
||||
|
||||
If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+1> -f rollout_hours=0` after N+1 publishes so the users who already got N reach the fix fast.
|
||||
|
||||
### macOS system floor
|
||||
|
||||
The desktop app requires macOS 13 or newer. Keep both release guards when the floor changes:
|
||||
|
||||
- `packages/desktop/electron-builder.yml` writes the macOS version to `LSMinimumSystemVersion` for new installs.
|
||||
- `scripts/merge-mac-manifest.mjs` writes the matching Darwin kernel version to `minimumSystemVersion` in the update manifest. Existing clients check this before downloading an update.
|
||||
|
||||
macOS 13 maps to Darwin 22. The two values use different version domains; do not copy the macOS version into the update manifest.
|
||||
|
||||
### Limitations
|
||||
|
||||
- **No pause / kill switch.** To stop new admissions, ship a superseding release. Clients revalidate on quit and will not install the superseded download, but a client that already completed installation cannot be recalled; ship a hotfix `+1` patch.
|
||||
@@ -634,6 +643,7 @@ Each beta entry records what its testers receive. Promotion produces the single
|
||||
- [ ] The GitHub Release was published only after the three stable manifests were uploaded, and it has the changelog body and every expected macOS, Linux, Windows, and Android APK asset
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||
- [ ] The GitHub Release contains `latest-mac.yml`, `latest-linux.yml`, and `latest.yml`
|
||||
- [ ] `latest-mac.yml` contains the current `minimumSystemVersion` guard
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
- [ ] GitHub `Docker` workflow is green and both the versioned and `latest` images are published
|
||||
- [ ] GitHub `Release Notes Sync` is green and the release body matches the stable changelog entry
|
||||
|
||||
@@ -349,6 +349,18 @@ Gotchas:
|
||||
- **The app shell uses one `AppearanceStyleBoundary`.** Runtime-patched numeric theme values are baked into Unistyles web classes rather than CSS variables, while parsed/memoized content also does not naturally re-run when appearance tokens change. The boundary sits below stable runtime providers in `app/_layout.tsx` and remounts the visual shell once. `applyAppearance` patches the active theme before inactive registry entries so its subscribers receive the committed values in the same update. Do not add local appearance keys or nested boundaries.
|
||||
- **Dynamic font tokens stay widened.** `fontFamily`, `fontSize`, and `lineHeight` on `commonTheme` are annotated `string`/`number` (not narrowed by `as const`) so the updater's return assigns; the platform default stacks live in `DEFAULT_UI_FONT_STACK` / `DEFAULT_MONO_FONT_STACK`.
|
||||
|
||||
## Patching The Web Runtime
|
||||
|
||||
When backporting a Unistyles web fix, patch the TypeScript source and both
|
||||
shipped JavaScript builds. Native Metro resolves the package's `react-native`
|
||||
export to `src`, but browser and Electron Metro resolve its `browser` export to
|
||||
`lib/module`; CommonJS consumers use `lib/commonjs`. A source-only patch leaves
|
||||
Electron running the old code.
|
||||
|
||||
Register every dependency patch in `scripts/postinstall-patches.mjs`. A file in
|
||||
`patches/` is inert unless that script knows which installed package activates
|
||||
it.
|
||||
|
||||
## Debugging
|
||||
|
||||
To inspect what the Babel plugin sees, temporarily enable [`debug: true`](https://www.unistyl.es/v3/other/babel-plugin#debug) in `packages/app/babel.config.js`:
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-0hOGev0HglOQmofzPQMfiWh1opg6cpiEgsfK22AKcGk=
|
||||
sha256-EpfSUhkssURxoUgE9+odbFg3sroXH5bH5S9xClMG2nE=
|
||||
|
||||
Generated
+54
-199
@@ -28,6 +28,7 @@
|
||||
"@vercel/nft": "^1.5.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "44.2.0",
|
||||
"get-port-cli": "^3.0.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"knip": "^5.82.1",
|
||||
@@ -2883,6 +2884,16 @@
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron-internal/extract-zip": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz",
|
||||
"integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/asar": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz",
|
||||
@@ -2989,70 +3000,37 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
|
||||
"integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz",
|
||||
"integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"env-paths": "^2.2.0",
|
||||
"fs-extra": "^8.1.0",
|
||||
"got": "^11.8.5",
|
||||
"env-paths": "^3.0.0",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"progress": "^2.0.3",
|
||||
"semver": "^6.2.0",
|
||||
"semver": "^7.6.3",
|
||||
"sumchecker": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"global-agent": "^3.0.0"
|
||||
"undici": "^7.24.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get/node_modules/fs-extra": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
||||
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^4.0.0",
|
||||
"universalify": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6 <7 || >=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get/node_modules/jsonfile": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
|
||||
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get/node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/get/node_modules/universalify": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
||||
"node_modules/@electron/get/node_modules/env-paths": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
|
||||
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/notarize": {
|
||||
@@ -13187,17 +13165,6 @@
|
||||
"integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "2.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
|
||||
"integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.56.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
|
||||
@@ -15813,16 +15780,6 @@
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-crc32": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
@@ -18675,22 +18632,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "41.2.0",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-41.2.0.tgz",
|
||||
"integrity": "sha512-0OKLiymqfV0WK68RBXqAm3Myad2TpI5wwxLCBEUcH5Nugo3YfSk7p1Js/AL9266qTz5xZioUnxt9hG8FFwax0g==",
|
||||
"version": "44.2.0",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-44.2.0.tgz",
|
||||
"integrity": "sha512-oK1icjhapp3xsUZycO9WZaLmd3hJnA7ieobC4mpdtO1pEN+PPGWpA3m1Mgzzuc1wzSD2Wai4zcH4yfpGJqxFMA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@electron/get": "^2.0.0",
|
||||
"@types/node": "^24.9.0",
|
||||
"extract-zip": "^2.0.1"
|
||||
"@electron-internal/extract-zip": "^1.0.1",
|
||||
"@electron/get": "^5.0.0",
|
||||
"@types/node": "^24.9.0"
|
||||
},
|
||||
"bin": {
|
||||
"electron": "cli.js"
|
||||
"electron": "cli.js",
|
||||
"install-electron": "install.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.20.55"
|
||||
"node": ">= 22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-builder": {
|
||||
@@ -18797,19 +18754,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron/node_modules/@types/node": {
|
||||
"version": "24.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
|
||||
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron/node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -21881,43 +21838,6 @@
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/extract-zip": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
||||
"integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.1",
|
||||
"get-stream": "^5.1.0",
|
||||
"yauzl": "^2.10.0"
|
||||
},
|
||||
"bin": {
|
||||
"extract-zip": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.17.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@types/yauzl": "^2.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/extract-zip/node_modules/get-stream": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
|
||||
"integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pump": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/extsprintf": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz",
|
||||
@@ -22082,16 +22002,6 @@
|
||||
"walk-up-path": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fd-slicer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pend": "~1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -29636,13 +29546,6 @@
|
||||
"url": "https://github.com/sponsors/jet2jet"
|
||||
}
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -36137,17 +36040,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yauzl": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
|
||||
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-crc32": "~0.2.3",
|
||||
"fd-slicer": "~1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
@@ -37642,7 +37534,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "24.6.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"electron": "41.2.0",
|
||||
"electron": "44.2.0",
|
||||
"electron-builder": "26.8.1",
|
||||
"typescript": "5.9.3",
|
||||
"unzip-crx-3": "^0.2.0",
|
||||
@@ -38799,6 +38691,7 @@
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.7.2",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -38818,6 +38711,15 @@
|
||||
"zod": "^4.4.3"
|
||||
}
|
||||
},
|
||||
"packages/plugin/node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.4.0.tgz",
|
||||
"integrity": "sha512-/eufudw+aFY1LKLolT6yFE6UMmYRl7fMJ/DEONSIyR6wI3slHWITBsANRGqXEY8FRzqUxwh7QEaGiZHcJPVThg==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.7.2",
|
||||
@@ -39056,7 +38958,6 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@babel/parser": "^7.29.8",
|
||||
"@getpaseo/client": "0.7.2",
|
||||
"@getpaseo/highlight": "0.7.2",
|
||||
"@getpaseo/plugin": "0.7.2",
|
||||
@@ -39106,52 +39007,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": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
"@vercel/nft": "^1.5.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "44.2.0",
|
||||
"get-port-cli": "^3.0.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"knip": "^5.82.1",
|
||||
|
||||
@@ -397,23 +397,24 @@ test("every interactive file header has the same hover feedback", async ({ page
|
||||
|
||||
const first = page.getByTestId("diff-file-0-toggle");
|
||||
const second = page.getByTestId("diff-file-1-toggle");
|
||||
const canvas = page.getByTestId("git-diff-header-canvas");
|
||||
const normalBackground = await headerCanvasPixel(canvas, first, 10);
|
||||
const firstCanvas = page.getByTestId("git-diff-sticky-header-0");
|
||||
const secondCanvas = page.getByTestId("git-diff-sticky-header-1");
|
||||
const normalBackground = await headerCanvasPixel(firstCanvas, first, 10);
|
||||
|
||||
await first.hover();
|
||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).not.toBe(normalBackground);
|
||||
const hoverBackground = await headerCanvasPixel(canvas, first, 10);
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).not.toBe(normalBackground);
|
||||
const hoverBackground = await headerCanvasPixel(firstCanvas, first, 10);
|
||||
|
||||
await first.click();
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(first).toHaveAttribute("aria-expanded", "false");
|
||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(normalBackground);
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground);
|
||||
|
||||
await first.hover();
|
||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(hoverBackground);
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(hoverBackground);
|
||||
await second.hover();
|
||||
await expect.poll(() => headerCanvasPixel(canvas, first, 10)).toBe(normalBackground);
|
||||
await expect.poll(() => headerCanvasPixel(canvas, second, 10)).toBe(hoverBackground);
|
||||
await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground);
|
||||
await expect.poll(() => headerCanvasPixel(secondCanvas, second, 10)).toBe(hoverBackground);
|
||||
});
|
||||
|
||||
test("horizontal body scrolling never moves or repaints the canvas header", async ({ page }) => {
|
||||
@@ -422,22 +423,28 @@ test("horizontal body scrolling never moves or repaints the canvas header", asyn
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openSelectionWorkspaceChanges(page, workspace);
|
||||
|
||||
const headerCanvas = page.getByTestId("git-diff-header-canvas");
|
||||
const headerCanvas = page.getByTestId("git-diff-canvas");
|
||||
const header = page.getByTestId("diff-file-0-toggle");
|
||||
const beforeImage = await headerCanvas.evaluate((element) =>
|
||||
(element as HTMLCanvasElement).toDataURL(),
|
||||
);
|
||||
const beforePixel = await headerCanvasPixel(headerCanvas, header, 10);
|
||||
const beforeBounds = await header.boundingBox();
|
||||
|
||||
await horizontallyScrollFirstFile(page, 320);
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
expect(await headerCanvas.evaluate((element) => (element as HTMLCanvasElement).toDataURL())).toBe(
|
||||
beforeImage,
|
||||
);
|
||||
expect(await headerCanvasPixel(headerCanvas, header, 10)).toBe(beforePixel);
|
||||
expect(await header.boundingBox()).toEqual(beforeBounds);
|
||||
});
|
||||
|
||||
test("in-flow file headers move with the diff document", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithStickyTransitionDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChangesSurface(page, workspace);
|
||||
|
||||
const motion = await measureInFlowHeaderMotion(page);
|
||||
|
||||
expect(motion).toEqual({ headerSurface: -120, shell: -120 });
|
||||
});
|
||||
|
||||
test("the outgoing sticky header hands off without a gap or overlap", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
@@ -1019,6 +1026,16 @@ test("autofocusing an inline review keeps the Changes tab focused", async ({ pag
|
||||
.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 ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -1086,7 +1103,7 @@ test("canvas headers keep a many-file diff bounded while scrolling end to end",
|
||||
|
||||
const root = page.getByTestId("git-diff-canvas-root");
|
||||
const scroller = page.getByTestId("git-diff-scroll");
|
||||
await expect(page.getByTestId("git-diff-header-canvas")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2);
|
||||
await expect.poll(() => root.locator('[data-diff-header="true"]').count()).toBeLessThan(120);
|
||||
|
||||
await scroller.evaluate((element) => {
|
||||
@@ -1096,7 +1113,7 @@ test("canvas headers keep a many-file diff bounded while scrolling end to end",
|
||||
|
||||
await expect(page.getByTestId("diff-file-1999")).toBeVisible();
|
||||
await expect.poll(() => root.locator('[data-diff-header="true"]').count()).toBeLessThan(120);
|
||||
await expect(page.getByTestId("git-diff-header-canvas")).toBeVisible();
|
||||
await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test("the whole reviewable row reveals the gutter affordance and uses a text cursor", async ({
|
||||
@@ -1142,6 +1159,33 @@ test("canvas diff copies a dragged character selection without opening a review"
|
||||
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 }) => {
|
||||
const workspace = await createWorkspaceWithExactSelectionDiff("ABCDEFGHIJ");
|
||||
await useUnwrappedDiffLines(page);
|
||||
@@ -1539,6 +1583,29 @@ async function createWorkspaceWithExactSelectionDiff(content: string): Promise<D
|
||||
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> {
|
||||
const files = Array.from({ length: fileCount }, (_, index) => ({
|
||||
path: `src/file-${String(index).padStart(4, "0")}.ts`,
|
||||
@@ -1753,6 +1820,20 @@ async function clickFirstChangedLine(page: Page): Promise<void> {
|
||||
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> {
|
||||
const body = page.getByTestId("diff-file-0-body");
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
@@ -1793,8 +1874,9 @@ async function deleteInlineReview(page: Page): Promise<void> {
|
||||
async function dragExactAddedText(
|
||||
page: Page,
|
||||
offsets: { startOffset: number; endOffset: number },
|
||||
fileIndex = 0,
|
||||
): Promise<void> {
|
||||
const body = page.getByTestId("diff-file-0-body");
|
||||
const body = page.getByTestId(`diff-file-${fileIndex}-body`);
|
||||
const canvas = page.getByTestId("git-diff-canvas");
|
||||
const [bodyBounds, metrics] = await Promise.all([
|
||||
body.boundingBox(),
|
||||
@@ -1955,6 +2037,39 @@ async function horizontallyScrollFirstFile(page: Page, requestedOffset: number):
|
||||
return retainedOffset;
|
||||
}
|
||||
|
||||
async function measureInFlowHeaderMotion(
|
||||
page: Page,
|
||||
): Promise<{ headerSurface: number; shell: number }> {
|
||||
return page.getByTestId("git-diff-scroll").evaluate(async (element) => {
|
||||
const scroll = element as HTMLElement;
|
||||
const headerSurface = document.querySelector<HTMLElement>('[data-testid="git-diff-canvas"]');
|
||||
const shell = document.querySelector<HTMLElement>('[data-diff-header-path="src/second.ts"]');
|
||||
if (!headerSurface || !shell) throw new Error("Diff header motion surfaces are unavailable");
|
||||
|
||||
const shellDocumentTop =
|
||||
scroll.scrollTop + shell.getBoundingClientRect().top - scroll.getBoundingClientRect().top;
|
||||
scroll.scrollTop = Math.max(0, shellDocumentTop - scroll.clientHeight + 80);
|
||||
scroll.dispatchEvent(new Event("scroll", { bubbles: false }));
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
|
||||
const before = {
|
||||
headerSurface: headerSurface.getBoundingClientRect().top,
|
||||
shell: shell.getBoundingClientRect().top,
|
||||
};
|
||||
scroll.scrollTop += 120;
|
||||
const after = {
|
||||
headerSurface: headerSurface.getBoundingClientRect().top,
|
||||
shell: shell.getBoundingClientRect().top,
|
||||
};
|
||||
return {
|
||||
headerSurface: after.headerSurface - before.headerSurface,
|
||||
shell: after.shell - before.shell,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function longPressFileHeader(page: Page, header: Locator): Promise<void> {
|
||||
const bounds = await header.boundingBox();
|
||||
if (!bounds) throw new Error("File header has no bounds");
|
||||
|
||||
@@ -105,7 +105,7 @@ async function createFeatureCommit(repoPath: string): Promise<void> {
|
||||
async function expectCommitDiffHeaderGeometry(panel: Locator): Promise<void> {
|
||||
const [header, canvas] = await Promise.all([
|
||||
panel.getByTestId("diff-file-0").boundingBox(),
|
||||
panel.getByTestId("git-diff-header-canvas").boundingBox(),
|
||||
panel.getByTestId("git-diff-sticky-header-0").boundingBox(),
|
||||
]);
|
||||
expect(header).not.toBeNull();
|
||||
expect(canvas).not.toBeNull();
|
||||
|
||||
@@ -93,7 +93,10 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
[
|
||||
root.querySelectorAll('[data-diff-header="true"]').length,
|
||||
root.querySelectorAll("*").length,
|
||||
root.querySelector('[data-testid="git-diff-header-canvas"]') ? "canvas" : "dom",
|
||||
root.querySelector('[data-testid="git-diff-canvas"]') &&
|
||||
root.querySelectorAll('[data-testid^="git-diff-sticky-header-"]').length === 2
|
||||
? "canvas-pool"
|
||||
: "dom",
|
||||
] as const,
|
||||
);
|
||||
const headerDomMetrics = await page.getByTestId("diff-file-0-toggle").evaluate((header) => {
|
||||
@@ -125,7 +128,7 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
};
|
||||
});
|
||||
const headerCanvasMetrics = await page
|
||||
.getByTestId("git-diff-header-canvas")
|
||||
.getByTestId("git-diff-sticky-header-0")
|
||||
.evaluate((canvas: HTMLCanvasElement) => {
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Header canvas has no 2D context");
|
||||
@@ -168,6 +171,7 @@ diffPerfDescribe("Diff canvas performance", () => {
|
||||
);
|
||||
if (!RUN_HEADER_COMPARISON) {
|
||||
expect(benchmark.maximumShells).toBeLessThan(120);
|
||||
expect(benchmark.maximumStickyCanvases).toBe(2);
|
||||
expect(benchmark.blankHeaderFrames).toBe(0);
|
||||
expect(benchmark.medianFrameMs).toBeLessThanOrEqual(MANY_FILE_MAX_MEDIAN_FRAME_MS);
|
||||
expect(benchmark.p95FrameMs).toBeLessThanOrEqual(MANY_FILE_MAX_P95_FRAME_MS);
|
||||
@@ -435,7 +439,8 @@ async function openFirstReviewEditor(page: Page): Promise<number> {
|
||||
}
|
||||
|
||||
async function expectBoundedCanvasSurfaces(page: Page): Promise<void> {
|
||||
await expect(page.locator("canvas")).toHaveCount(2);
|
||||
await expect(page.locator("canvas")).toHaveCount(3);
|
||||
await expect(page.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2);
|
||||
await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0);
|
||||
const elementCount = await page
|
||||
.getByTestId("git-diff-canvas-root")
|
||||
@@ -527,40 +532,57 @@ async function benchmarkManyFileHeaderScrolling(page: Page): Promise<{
|
||||
p95FrameMs: number;
|
||||
maximumFrameMs: number;
|
||||
maximumShells: number;
|
||||
maximumStickyCanvases: number;
|
||||
blankHeaderFrames: number;
|
||||
endScrollTop: number;
|
||||
maximumScrollTop: number;
|
||||
}> {
|
||||
return page.getByTestId("git-diff-scroll").evaluate(async (element) => {
|
||||
const scroll = element as HTMLElement;
|
||||
const canvas = document.querySelector<HTMLCanvasElement>(
|
||||
'[data-testid="git-diff-header-canvas"]',
|
||||
);
|
||||
const maximumScrollTop = scroll.scrollHeight - scroll.clientHeight;
|
||||
const frameDurations: number[] = [];
|
||||
let maximumShells = 0;
|
||||
let maximumStickyCanvases = 0;
|
||||
let blankHeaderFrames = 0;
|
||||
const sampleHeader = () => {
|
||||
maximumShells = Math.max(
|
||||
maximumShells,
|
||||
document.querySelectorAll('[data-diff-header="true"]').length,
|
||||
);
|
||||
const canvases = Array.from(
|
||||
document.querySelectorAll<HTMLCanvasElement>('[data-testid^="git-diff-sticky-header-"]'),
|
||||
);
|
||||
maximumStickyCanvases = Math.max(maximumStickyCanvases, canvases.length);
|
||||
const scrollBounds = scroll.getBoundingClientRect();
|
||||
const sampleX = scrollBounds.left + 4;
|
||||
const sampleY = scrollBounds.top + 10;
|
||||
const canvas = canvases.find((candidate) => {
|
||||
const bounds = candidate.getBoundingClientRect();
|
||||
return (
|
||||
bounds.width > 0 &&
|
||||
sampleX >= bounds.left &&
|
||||
sampleX < bounds.right &&
|
||||
sampleY >= bounds.top &&
|
||||
sampleY < bounds.bottom
|
||||
);
|
||||
});
|
||||
if (canvas) {
|
||||
const context = canvas.getContext("2d")!;
|
||||
const ratio = canvas.width / canvas.getBoundingClientRect().width;
|
||||
const bounds = canvas.getBoundingClientRect();
|
||||
const ratio = canvas.width / bounds.width;
|
||||
if (
|
||||
context.getImageData(Math.round(4 * ratio), Math.round(10 * ratio), 1, 1).data[3] === 0
|
||||
context.getImageData(
|
||||
Math.round((sampleX - bounds.left) * ratio),
|
||||
Math.round((sampleY - bounds.top) * ratio),
|
||||
1,
|
||||
1,
|
||||
).data[3] === 0
|
||||
) {
|
||||
blankHeaderFrames += 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const bounds = scroll.getBoundingClientRect();
|
||||
if (
|
||||
!document.elementFromPoint(bounds.left + 4, bounds.top + 10)?.closest("[data-diff-header]")
|
||||
) {
|
||||
blankHeaderFrames += 1;
|
||||
}
|
||||
blankHeaderFrames += 1;
|
||||
};
|
||||
|
||||
for (const progress of [0, 0.25, 0.5, 0.75, 1]) {
|
||||
@@ -601,6 +623,7 @@ async function benchmarkManyFileHeaderScrolling(page: Page): Promise<{
|
||||
p95FrameMs: percentile(0.95),
|
||||
maximumFrameMs: Math.max(...frameDurations),
|
||||
maximumShells,
|
||||
maximumStickyCanvases,
|
||||
blankHeaderFrames,
|
||||
endScrollTop: scroll.scrollTop,
|
||||
maximumScrollTop,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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 previousConfig = await client.getDaemonConfig();
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||
await writeFile(path.join(directory, "index.tsx"), PLUGIN_SOURCE);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), PLUGIN_SOURCE);
|
||||
|
||||
try {
|
||||
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 previous = await client.getDaemonConfig();
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: "e2e-plugin" }));
|
||||
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v1"));
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v1"));
|
||||
|
||||
try {
|
||||
const catalog = observePluginCatalog(page);
|
||||
@@ -143,14 +143,14 @@ test("installs, reloads, recovers, disables, and removes a trusted local plugin"
|
||||
await reloadPlugin(page);
|
||||
await openContributionFromSettings(page, "Plugin v1", "Plugin v1 cleanup 1");
|
||||
|
||||
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v2"));
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v2"));
|
||||
await openPluginSettings(page);
|
||||
await reloadPlugin(page);
|
||||
await openContributionFromSettings(page, "Plugin v2", "Plugin v2 cleanup 2");
|
||||
await expect(page.getByRole("button", { name: "Plugin v1", exact: true })).not.toBeVisible();
|
||||
|
||||
await writeFile(
|
||||
path.join(directory, "index.tsx"),
|
||||
path.join(directory, "index.client.tsx"),
|
||||
pluginSource("Broken surface", "render exploded"),
|
||||
);
|
||||
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, "Broken surface", "Plugin failed: render exploded");
|
||||
|
||||
await writeFile(path.join(directory, "index.tsx"), pluginSource("Recovered surface"));
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Recovered surface"));
|
||||
await reloadActiveContribution(page, client, "Recovered surface cleanup 4");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Broken surface", exact: true }),
|
||||
).not.toBeVisible();
|
||||
|
||||
await writeFile(path.join(directory, "index.tsx"), "export default broken syntax !!!");
|
||||
await writeFile(path.join(directory, "index.client.tsx"), "export default broken syntax !!!");
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
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 expectContributionRemoved(page, "Recovered surface");
|
||||
|
||||
await writeFile(path.join(directory, "index.tsx"), pluginSource("Plugin v3"));
|
||||
await writeFile(path.join(directory, "index.client.tsx"), pluginSource("Plugin v3"));
|
||||
await openPluginSettings(page);
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
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 previousConfig = await client.getDaemonConfig();
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||
await writeFile(path.join(directory, "index.ts"), PLUGIN_SOURCE);
|
||||
await writeFile(path.join(directory, "index.client.ts"), PLUGIN_SOURCE);
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { TestInfo } from "@playwright/test";
|
||||
@@ -26,18 +26,11 @@ function isSettledWorkspaceUrl(url: URL): boolean {
|
||||
return url.pathname.includes("/workspace/") && !url.searchParams.has("open");
|
||||
}
|
||||
|
||||
function pluginSource(input: { workspaceId: string; agentId: string }): string {
|
||||
function pluginClientSource(input: { workspaceId: string; agentId: string }): string {
|
||||
return `import React, { useRef } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { Icon, useAgent, useWorkspace } from "@getpaseo/plugin";
|
||||
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() }),
|
||||
});
|
||||
import { recordComposerOpen } from "./shared/rpc";
|
||||
|
||||
function WorkspacePanel({ workspaceId, host, layout }) {
|
||||
const workspace = useWorkspace(workspaceId, (value) => ({ id: value.id }));
|
||||
@@ -111,23 +104,49 @@ function contributeClient(client) {
|
||||
};
|
||||
}
|
||||
|
||||
export default function contribute(plugin) {
|
||||
plugin.handle(recordComposerOpen, async ({ workspaceId }, { paseo }) => {
|
||||
export default function contribute(client) {
|
||||
client.addSurface("collision", DirectCollisionSurface);
|
||||
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");
|
||||
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 () => {};
|
||||
}`;
|
||||
|
||||
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) {
|
||||
@@ -181,10 +200,10 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
port: secondaryDaemon.port,
|
||||
});
|
||||
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify({ id: PLUGIN_ID }));
|
||||
await writeFile(
|
||||
path.join(directory, "index.tsx"),
|
||||
pluginSource({ workspaceId: primary.workspaceId, agentId: "missing-agent" }),
|
||||
);
|
||||
await writePluginSources(directory, {
|
||||
workspaceId: primary.workspaceId,
|
||||
agentId: "missing-agent",
|
||||
});
|
||||
|
||||
try {
|
||||
await primaryClient.patchDaemonConfig({ pluginsEnabled: true });
|
||||
@@ -260,10 +279,10 @@ test.describe("plugin workspace panels and Command Center", () => {
|
||||
modeId: "load-test",
|
||||
});
|
||||
const navigationAgentId = agent.id;
|
||||
await writeFile(
|
||||
path.join(directory, "index.tsx"),
|
||||
pluginSource({ workspaceId: primary.workspaceId, agentId: navigationAgentId }),
|
||||
);
|
||||
await writePluginSources(directory, {
|
||||
workspaceId: primary.workspaceId,
|
||||
agentId: navigationAgentId,
|
||||
});
|
||||
await primaryClient.reloadPlugin(PLUGIN_ID);
|
||||
|
||||
await runCommand(page, "Open direct collision surface");
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
type AgentHandle,
|
||||
type RewindFlowProvider,
|
||||
} from "../support/helpers/rewind-flow";
|
||||
import {
|
||||
expectNestedProviderSubagentOwnership,
|
||||
launchNestedProviderSubagentOwnershipScenario,
|
||||
reopenNestedProviderSession,
|
||||
} from "../support/helpers/provider-subagents";
|
||||
import { openSubagentsTrack } from "../support/helpers/subagents";
|
||||
|
||||
interface ProviderSubagentCase {
|
||||
@@ -26,7 +31,7 @@ const cases: ProviderSubagentCase[] = [
|
||||
provider: "claude",
|
||||
sentinel: "CLAUDE_CHILD_SENTINEL",
|
||||
expectedName: "sentinel_child",
|
||||
providerConfig: { model: "opus" },
|
||||
providerConfig: { model: "claude-sonnet-5" },
|
||||
prompt:
|
||||
'Use Claude Code\'s native Task tool exactly once. Set its subagent_type input to "Explore" and its name input to "sentinel_child". Ask it to reply with exactly CLAUDE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE. Do not use Paseo tools.',
|
||||
},
|
||||
@@ -147,3 +152,23 @@ test.describe("real provider subagent timelines", () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("real Claude nested subagent ownership", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("keeps a grandchild and its background notification with their direct owners", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-claude-nested-ui-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchNestedProviderSubagentOwnershipScenario(page, cwd);
|
||||
await expectNestedProviderSubagentOwnership(page, testInfo, "live");
|
||||
await reopenNestedProviderSession(handle);
|
||||
await expectNestedProviderSubagentOwnership(page, testInfo, "reopened");
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
expectSetupPanel,
|
||||
openHomeWithProject,
|
||||
navigateToWorkspaceViaSidebar,
|
||||
returnHomeFromWorkspace,
|
||||
leaveWorkspaceViaHistory,
|
||||
openWorkspaceScriptsMenu,
|
||||
startWorkspaceScriptFromMenu,
|
||||
closeWorkspaceScriptsMenu,
|
||||
@@ -195,7 +195,7 @@ test.describe("Workspace setup streaming", () => {
|
||||
await expectFailedSetupTabSeededInMainPane(page, workspace.id);
|
||||
|
||||
await closeSetupTab(page, workspace.id);
|
||||
await returnHomeFromWorkspace(page);
|
||||
await leaveWorkspaceViaHistory(page);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
await expectSetupTabNotSeeded(page, workspace.id);
|
||||
} finally {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { expect, type Page } from "@playwright/test";
|
||||
import { metroTest as test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
createIdleAgent,
|
||||
createMockIdleAgent,
|
||||
expectSessionRowArchived,
|
||||
openSessions,
|
||||
} from "../support/helpers/archive-tab";
|
||||
@@ -97,7 +97,7 @@ test.describe("Worktree restore after daemon restart", () => {
|
||||
createdProjectIds.add(worktree.projectKey);
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
|
||||
const agent = await createIdleAgent(client, {
|
||||
const agent = await createMockIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
workspaceId: worktree.workspaceId,
|
||||
title: `restart-restore-${randomUUID().slice(0, 8)}`,
|
||||
|
||||
@@ -35,6 +35,8 @@ const metroTest = base.extend({
|
||||
const daemonTest = metroTest.extend<
|
||||
{ projectOwnership: void },
|
||||
{
|
||||
e2eDaemonConfig: Record<string, unknown> | undefined;
|
||||
e2eDaemonEnvironment: Record<string, string>;
|
||||
e2eForkProviders: string[];
|
||||
e2eInjectPaseoTools: boolean;
|
||||
e2eWorker: void;
|
||||
@@ -43,9 +45,17 @@ const daemonTest = metroTest.extend<
|
||||
>({
|
||||
e2eForkProviders: [[], { scope: "worker", option: true }],
|
||||
e2eInjectPaseoTools: [false, { scope: "worker", option: true }],
|
||||
e2eDaemonConfig: [undefined, { scope: "worker", option: true }],
|
||||
e2eDaemonEnvironment: [{}, { scope: "worker", option: true }],
|
||||
e2eWorker: [
|
||||
async ({ e2eForkProviders, e2eInjectPaseoTools }, provide, workerInfo) => {
|
||||
async (
|
||||
{ e2eDaemonConfig, e2eDaemonEnvironment, e2eForkProviders, e2eInjectPaseoTools },
|
||||
provide,
|
||||
workerInfo,
|
||||
) => {
|
||||
const worker = await startE2EWorker(workerInfo.workerIndex, {
|
||||
daemonConfig: e2eDaemonConfig,
|
||||
environment: e2eDaemonEnvironment,
|
||||
forkProviders: e2eForkProviders,
|
||||
injectPaseoTools: e2eInjectPaseoTools,
|
||||
});
|
||||
|
||||
@@ -83,6 +83,7 @@ export async function expectCommandCenterToRemainAtTheTop(page: Page): Promise<v
|
||||
export async function expectPrimaryCommandCenterActions(panel: Locator): Promise<void> {
|
||||
const expectedActions = [
|
||||
{ title: "New workspace", shortcut: "⌘N" },
|
||||
{ title: "Import session" },
|
||||
{ title: "History" },
|
||||
{ title: "Schedules" },
|
||||
{ title: "Settings", shortcut: "⌘," },
|
||||
@@ -105,6 +106,9 @@ export async function expectPrimaryCommandCenterActions(panel: Locator): Promise
|
||||
await input.fill("home");
|
||||
await expect(panel.getByRole("button").filter({ hasText: "Home" })).toBeVisible();
|
||||
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("");
|
||||
}
|
||||
|
||||
|
||||
@@ -30,9 +30,21 @@ export async function expectDiagramRemainsRenderedWhileStreaming(
|
||||
completion: Promise<void>,
|
||||
): Promise<void> {
|
||||
const diagram = page.getByRole("img", { name: DIAGRAM_NAME }).last();
|
||||
const completed = completion.then(() => true);
|
||||
let turnCompleted = false;
|
||||
const completed = completion.then(() => {
|
||||
turnCompleted = true;
|
||||
return true;
|
||||
});
|
||||
for (;;) {
|
||||
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)]);
|
||||
if (didComplete) return completion;
|
||||
}
|
||||
@@ -49,3 +61,90 @@ export async function expectCompletedDiagram(page: Page, labels: readonly string
|
||||
export async function reloadConversation(page: Page): Promise<void> {
|
||||
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,6 +10,13 @@ export interface E2EWorker {
|
||||
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 {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return null;
|
||||
@@ -77,31 +84,33 @@ if (origin === fixtureRemote) {
|
||||
process.exit(0);
|
||||
}
|
||||
if (command === "pr list" || command === "pr view") {
|
||||
const isFork = args.includes("2");
|
||||
const pr = {
|
||||
number: 1,
|
||||
number: isFork ? 2 : 1,
|
||||
title: "Use pasted PR as start ref",
|
||||
url: "https://github.com/paseo-e2e/local-fixture/pull/1",
|
||||
url: "https://github.com/paseo-e2e/local-fixture/pull/" + (isFork ? 2 : 1),
|
||||
state: "OPEN",
|
||||
body: null,
|
||||
labels: [],
|
||||
baseRefName: "main",
|
||||
headRefName: "pr-branch-1",
|
||||
headRefName: isFork ? "pr-branch-2" : "pr-branch-1",
|
||||
updatedAt: "2026-01-01T00:00:00Z"
|
||||
};
|
||||
process.stdout.write(JSON.stringify(command === "pr list" ? [pr] : pr));
|
||||
process.exit(0);
|
||||
}
|
||||
if (command === "api graphql" && args.some((arg) => arg.includes("PullRequestCheckoutTarget"))) {
|
||||
const isFork = args.some((arg) => arg === "number=2");
|
||||
process.stdout.write(JSON.stringify({
|
||||
data: { repository: { pullRequest: {
|
||||
number: 1,
|
||||
number: isFork ? 2 : 1,
|
||||
baseRefName: "main",
|
||||
headRefName: "pr-branch-1",
|
||||
isCrossRepository: false,
|
||||
headRepositoryOwner: { login: "paseo-e2e" },
|
||||
headRefName: isFork ? "pr-branch-2" : "pr-branch-1",
|
||||
isCrossRepository: isFork,
|
||||
headRepositoryOwner: { login: isFork ? "fork-owner" : "paseo-e2e" },
|
||||
headRepository: {
|
||||
sshUrl: "git@github.com:paseo-e2e/local-fixture.git",
|
||||
url: fixtureRemote
|
||||
sshUrl: isFork ? "git@github.com:fork-owner/local-fixture.git" : "git@github.com:paseo-e2e/local-fixture.git",
|
||||
url: isFork ? "https://github.com/fork-owner/local-fixture" : fixtureRemote
|
||||
}
|
||||
} } }
|
||||
}));
|
||||
@@ -156,7 +165,7 @@ async function applyMetadataFork(targetHome: string, providerIds: string[]): Pro
|
||||
|
||||
export async function startE2EWorker(
|
||||
workerIndex: number,
|
||||
options: { forkProviders?: string[]; injectPaseoTools?: boolean } = {},
|
||||
options: E2EWorkerOptions = {},
|
||||
): Promise<E2EWorker> {
|
||||
const requestedRoot = resolveOptionalHome(process.env.E2E_PASEO_HOME);
|
||||
const paseoHome = requestedRoot
|
||||
@@ -169,6 +178,14 @@ export async function startE2EWorker(
|
||||
|
||||
try {
|
||||
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) {
|
||||
await enablePaseoTools(paseoHome);
|
||||
}
|
||||
@@ -179,6 +196,7 @@ export async function startE2EWorker(
|
||||
NODE_ENV: "development",
|
||||
PATH: `${fakeEditorBin}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: editorRecordPath,
|
||||
...options.environment,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
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")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { expect, type Locator, type Page, type TestInfo } from "@playwright/test";
|
||||
|
||||
import { launchAgent, sendMessage, type AgentHandle } from "./rewind-flow";
|
||||
import { openSubagentsTrack } from "./subagents";
|
||||
|
||||
const NESTED_OWNERSHIP_PROMPT =
|
||||
"You are ROOT_OWNER. Use Claude Code's native Agent tool exactly once, never Paseo tools. " +
|
||||
"Name the agent direct_owner and give it this complete task: You are DIRECT_OWNER. Use " +
|
||||
"Claude Code's native Agent tool exactly once. Name that agent nested_owner and give it " +
|
||||
"this complete task: You are NESTED_OWNER. Use Bash exactly once to run `sleep 2; printf " +
|
||||
"'NESTED_BACKGROUND_SENTINEL\\n'` with run_in_background true. Wait for the background " +
|
||||
"command's completion notification. Use TaskOutput with block true to wait for the Bash task and verify exit code 0 and the sentinel output before replying exactly NESTED_DONE. Do not finish while it is running. Wait for nested_owner " +
|
||||
"to finish, then reply exactly DIRECT_DONE. Wait for direct_owner to finish, then reply " +
|
||||
"exactly ROOT_DONE.";
|
||||
|
||||
export async function launchNestedProviderSubagentOwnershipScenario(
|
||||
page: Page,
|
||||
cwd: string,
|
||||
): Promise<AgentHandle> {
|
||||
const handle = await launchAgent({
|
||||
page,
|
||||
provider: "claude",
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
providerConfig: { model: "claude-sonnet-5" },
|
||||
});
|
||||
await sendMessage(handle, NESTED_OWNERSHIP_PROMPT);
|
||||
await expect(page.getByText("ROOT_DONE", { exact: true }).last()).toBeVisible({
|
||||
timeout: 120_000,
|
||||
});
|
||||
return handle;
|
||||
}
|
||||
|
||||
async function attachScreenshot(page: Page, testInfo: TestInfo, name: string): Promise<void> {
|
||||
const path = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path });
|
||||
await testInfo.attach(name, { path, contentType: "image/png" });
|
||||
}
|
||||
|
||||
async function expectOnlySubagent(page: Page, label: RegExp): Promise<Locator> {
|
||||
const rows = page.locator('[data-testid^="subagents-track-row-"]');
|
||||
await expect(rows).toHaveCount(1);
|
||||
const row = page.getByTestId("subagents-track-header-panel").getByRole("button", { name: label });
|
||||
await expect(row).toBeVisible();
|
||||
return row;
|
||||
}
|
||||
|
||||
async function selectOnlyRootSubagent(page: Page, label: RegExp): Promise<void> {
|
||||
await openSubagentsTrack(page);
|
||||
const row = await expectOnlySubagent(page, label);
|
||||
await row.click();
|
||||
}
|
||||
|
||||
async function openOwnedSubagents(page: Page, ownerPanel: Locator): Promise<void> {
|
||||
await ownerPanel.getByTestId("subagents-track-header").click();
|
||||
await expect(page.getByTestId("subagents-track-header-panel")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectNestedProviderSubagentOwnership(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
phase: string,
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page.getByText("Task notification", { exact: true }).filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
await selectOnlyRootSubagent(page, /^direct_owner\b/);
|
||||
const directPanel = page.getByTestId("provider-subagent-panel").filter({ visible: true });
|
||||
await expect(directPanel).toBeVisible();
|
||||
|
||||
await openOwnedSubagents(page, directPanel);
|
||||
const nestedRow = await expectOnlySubagent(page, /^nested_owner\b/);
|
||||
await attachScreenshot(page, testInfo, `nested-ownership-${phase}-tree`);
|
||||
await nestedRow.click();
|
||||
|
||||
const nestedPanel = page.locator('[data-testid="provider-subagent-panel"]:visible');
|
||||
await expect(
|
||||
nestedPanel
|
||||
.getByRole("button", {
|
||||
name: /^Task notification Background command .*completed \(exit code 0\)/,
|
||||
})
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await expect(nestedPanel).toContainText("NESTED_BACKGROUND_SENTINEL");
|
||||
await expect(nestedPanel).toContainText("NESTED_DONE");
|
||||
await attachScreenshot(page, testInfo, `nested-ownership-${phase}-timeline`);
|
||||
}
|
||||
|
||||
export async function reopenNestedProviderSession(handle: AgentHandle): Promise<void> {
|
||||
await handle.client.refreshAgent(handle.agentId);
|
||||
await handle.page.reload();
|
||||
await handle.page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click();
|
||||
await expect(handle.page.getByText("ROOT_DONE", { exact: true }).last()).toBeVisible();
|
||||
}
|
||||
@@ -318,9 +318,9 @@ export async function assertFileContains(filePath: string, text: string): Promis
|
||||
|
||||
export async function assertComposerIdle(handle: Pick<AgentHandle, "page">): Promise<void> {
|
||||
await expectComposerEditable(handle.page);
|
||||
await expect(handle.page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(
|
||||
handle.page.getByRole("button", { name: /^(?:Stop agent|Canceling agent)$/ }),
|
||||
).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(handle.page.getByTestId("turn-working-indicator")).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
@@ -38,12 +38,9 @@ function itemLabel(key: SidebarNavKey): string {
|
||||
}[key];
|
||||
}
|
||||
|
||||
async function rowTop(locator: Locator): Promise<number> {
|
||||
async function rowTop(locator: Locator): Promise<number | null> {
|
||||
const box = await locator.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("Expected a laid-out sidebar row to measure");
|
||||
}
|
||||
return box.y;
|
||||
return box?.y ?? null;
|
||||
}
|
||||
|
||||
export async function seedSidebarNavPreferences(
|
||||
@@ -139,6 +136,12 @@ async function expectVerticalOrder(
|
||||
const measured = await Promise.all(
|
||||
keys.map(async (key) => ({ key, top: await rowTop(locate(key)) })),
|
||||
);
|
||||
if (
|
||||
!measured.every(
|
||||
(entry): entry is { key: SidebarNavKey; top: number } => entry.top !== null,
|
||||
)
|
||||
)
|
||||
return null;
|
||||
return measured.sort((a, b) => a.top - b.top).map((entry) => entry.key);
|
||||
},
|
||||
{ message: `Expected ${subject} in order`, timeout: 15_000 },
|
||||
|
||||
@@ -163,7 +163,7 @@ class StartupAssertions {
|
||||
await expect(hostRow).toBeVisible({ timeout: 15_000 });
|
||||
await expect(hostRow).toContainText(input.label);
|
||||
await expect(this.page.getByTestId("sidebar-add-project")).toBeVisible();
|
||||
await expect(this.page.getByTestId("sidebar-home")).toBeVisible();
|
||||
await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible();
|
||||
await expect(this.page.getByTestId("sidebar-settings")).toBeVisible();
|
||||
await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0);
|
||||
return this;
|
||||
|
||||
@@ -276,9 +276,9 @@ export async function navigateToWorkspaceViaSidebar(
|
||||
});
|
||||
}
|
||||
|
||||
export async function returnHomeFromWorkspace(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Home" }).click();
|
||||
await expect(page).not.toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
export async function leaveWorkspaceViaHistory(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "History", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/sessions$/, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function openWorkspaceScriptsMenu(page: Page): Promise<void> {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from "react";
|
||||
import {
|
||||
FlatList,
|
||||
Keyboard,
|
||||
Platform,
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
@@ -23,6 +22,8 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useSettledKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
import { resolveStreamKeyboardInset } from "@/hooks/keyboard-shift-policy";
|
||||
import { useRevisedHistoryRows } from "./history-row-revision";
|
||||
import { useBottomAnchorController } from "./bottom-anchor-controller";
|
||||
import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss";
|
||||
@@ -104,6 +105,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
const scrollOffsetYRef = useRef(0);
|
||||
const isUserScrollActiveRef = useRef(false);
|
||||
const scrollKeyboardDismiss = useScrollKeyboardDismiss();
|
||||
const settledKeyboardShift = useSettledKeyboardShift();
|
||||
const userScrollEndFrameIdRef = useRef<number | null>(null);
|
||||
const programmaticScrollEventBudgetRef = useRef(0);
|
||||
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
|
||||
@@ -273,6 +275,34 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom"
|
||||
? undefined
|
||||
: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION;
|
||||
const streamKeyboardInset = useMemo(
|
||||
() =>
|
||||
resolveStreamKeyboardInset({
|
||||
platform: Platform.OS === "ios" ? "ios" : "android",
|
||||
settledShift: settledKeyboardShift,
|
||||
}),
|
||||
[settledKeyboardShift],
|
||||
);
|
||||
const listContentContainerStyle = useMemo(
|
||||
() => [
|
||||
baseListContentContainerStyle,
|
||||
{ paddingBottom: streamKeyboardInset.contentContainerPaddingBottom },
|
||||
],
|
||||
[baseListContentContainerStyle, streamKeyboardInset.contentContainerPaddingBottom],
|
||||
);
|
||||
const listInsetProps = useMemo(
|
||||
() =>
|
||||
streamKeyboardInset.contentInset
|
||||
? {
|
||||
automaticallyAdjustContentInsets: false,
|
||||
automaticallyAdjustsScrollIndicatorInsets: false,
|
||||
contentInsetAdjustmentBehavior: "never" as const,
|
||||
contentInset: streamKeyboardInset.contentInset,
|
||||
scrollIndicatorInsets: streamKeyboardInset.contentInset,
|
||||
}
|
||||
: {},
|
||||
[streamKeyboardInset.contentInset],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
streamViewportMetricsRef.current = {
|
||||
@@ -305,27 +335,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
};
|
||||
}, [agentId, clearNativeViewportSettling, clearPendingUserScrollEnd, evaluateHistoryStart]);
|
||||
|
||||
useEffect(() => {
|
||||
const keyboardEvents = [
|
||||
"keyboardWillShow",
|
||||
"keyboardWillHide",
|
||||
"keyboardDidShow",
|
||||
"keyboardDidHide",
|
||||
"keyboardWillChangeFrame",
|
||||
"keyboardDidChangeFrame",
|
||||
] as const;
|
||||
const subscriptions = keyboardEvents.map((eventName) =>
|
||||
Keyboard.addListener(eventName, () => {
|
||||
markNativeViewportSettling();
|
||||
}),
|
||||
);
|
||||
return () => {
|
||||
for (const subscription of subscriptions) {
|
||||
subscription.remove();
|
||||
}
|
||||
clearNativeViewportSettling();
|
||||
};
|
||||
}, [clearNativeViewportSettling, markNativeViewportSettling]);
|
||||
useEffect(() => () => clearNativeViewportSettling(), [clearNativeViewportSettling]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomAnchorController.prepareForStickyContentChange();
|
||||
@@ -560,6 +570,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
// data or the live header changes, preserving the row identities above.
|
||||
return (
|
||||
<FlatList
|
||||
{...listInsetProps}
|
||||
ref={flatListRef}
|
||||
data={historyRows}
|
||||
renderItem={renderItem}
|
||||
@@ -569,7 +580,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
nativeID="agent-chat-scroll-native-virtualized"
|
||||
ListHeaderComponent={liveHeaderContent ?? undefined}
|
||||
ListFooterComponent={historyFooterContent ?? undefined}
|
||||
contentContainerStyle={baseListContentContainerStyle}
|
||||
contentContainerStyle={listContentContainerStyle}
|
||||
style={listStyle}
|
||||
onLayout={handleListLayout}
|
||||
onScroll={handleScroll}
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
AssistantMessage,
|
||||
SpeakMessage,
|
||||
UserMessage,
|
||||
ActivityLog,
|
||||
Notification,
|
||||
ToolCall,
|
||||
TodoListCard,
|
||||
CompactionMarker,
|
||||
@@ -877,15 +877,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
case "tool_call":
|
||||
return renderToolCallItem(layoutItem, item);
|
||||
|
||||
case "activity_log":
|
||||
return (
|
||||
<ActivityLog
|
||||
type={item.activityType}
|
||||
message={item.message}
|
||||
timestamp={item.timestamp.getTime()}
|
||||
metadata={item.metadata}
|
||||
/>
|
||||
);
|
||||
case "notification":
|
||||
return <Notification level={item.level} message={item.message} />;
|
||||
|
||||
case "todo_list":
|
||||
return <TodoListCard items={item.items} activity={item.activity} />;
|
||||
|
||||
@@ -70,7 +70,7 @@ export function estimateStreamItemHeight(item: StreamItem): number {
|
||||
return COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE;
|
||||
case "todo_list":
|
||||
return 144;
|
||||
case "activity_log":
|
||||
case "notification":
|
||||
return 88;
|
||||
case "compaction":
|
||||
return 72;
|
||||
|
||||
@@ -4,7 +4,13 @@ import { PortalProvider } from "@gorhom/portal";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as Linking from "expo-linking";
|
||||
import * as Notifications from "expo-notifications";
|
||||
import { Stack, useNavigationContainerRef, usePathname, useRouter } from "expo-router";
|
||||
import {
|
||||
Stack,
|
||||
useGlobalSearchParams,
|
||||
useNavigationContainerRef,
|
||||
usePathname,
|
||||
useRouter,
|
||||
} from "expo-router";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
@@ -63,7 +69,7 @@ import { HorizontalScrollProvider } from "@/contexts/horizontal-scroll-context";
|
||||
import { SessionProvider } from "@/contexts/session-context";
|
||||
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
||||
import { ToastProvider } from "@/contexts/toast-context";
|
||||
import { VoiceProvider } from "@/contexts/voice-context";
|
||||
import { useVoiceOptional, VoiceProvider } from "@/contexts/voice-context";
|
||||
import {
|
||||
resolveStartupBlocker,
|
||||
resolveStartupNavigationReady,
|
||||
@@ -667,11 +673,35 @@ function ProvidersWrapper({ children }: { children: ReactNode }) {
|
||||
<HostSessionManager />
|
||||
<FaviconStatusSync />
|
||||
<AppearanceStyleBoundary>{children}</AppearanceStyleBoundary>
|
||||
<VoiceCallContextSync />
|
||||
</VoiceProvider>
|
||||
</AppearanceProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function VoiceCallContextSync() {
|
||||
const voice = useVoiceOptional();
|
||||
const params = useGlobalSearchParams<{
|
||||
serverId?: string | string[];
|
||||
workspaceId?: string | string[];
|
||||
agentId?: string | string[];
|
||||
}>();
|
||||
const serverId = Array.isArray(params.serverId) ? params.serverId[0] : params.serverId;
|
||||
const workspaceId = Array.isArray(params.workspaceId)
|
||||
? params.workspaceId[0]
|
||||
: params.workspaceId;
|
||||
const agentId = Array.isArray(params.agentId) ? params.agentId[0] : params.agentId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!voice?.isVoiceMode || voice.activeServerId !== serverId) return;
|
||||
void voice
|
||||
.updateCallContext({ workspaceId: workspaceId ?? null, agentId: agentId ?? null })
|
||||
.catch((error) => console.warn("[VoiceCall] Failed to update app context", error));
|
||||
}, [agentId, serverId, voice, workspaceId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function DesktopWindowControlsSync() {
|
||||
const { isLoading } = useAppSettings();
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
@@ -42,6 +42,7 @@ 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',
|
||||
"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',
|
||||
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":
|
||||
'<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:
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -96,7 +96,8 @@ export function useAgentControlCommandCenterActions(input: {
|
||||
provider: controls.provider ?? null,
|
||||
labels: buildAgentControlContributionLabels(t),
|
||||
icons: {
|
||||
provider: (provider) => getCommandCenterIcon(getProviderIcon(provider)),
|
||||
provider: (provider) =>
|
||||
getCommandCenterIcon(getProviderIcon(provider, controls.serverId)),
|
||||
thinking: getCommandCenterIcon(ThinkingIcon),
|
||||
planMode: getCommandCenterIcon(PlanModeIcon),
|
||||
mode: (modeId) =>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FolderPlus,
|
||||
History,
|
||||
Home,
|
||||
Import,
|
||||
Keyboard,
|
||||
PanelLeft,
|
||||
Plus,
|
||||
@@ -17,6 +18,7 @@ import { withUnistyles } from "react-native-unistyles";
|
||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useImportSession } from "@/hooks/use-import-session";
|
||||
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
@@ -52,6 +54,7 @@ const ThemedSettings = withUnistyles(Settings, (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 ThemedCircleDashed = withUnistyles(CircleDashed, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
@@ -88,6 +91,10 @@ function HomeIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedHome size={size} strokeWidth={2.2} />;
|
||||
}
|
||||
|
||||
function ImportIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedImport size={size} strokeWidth={2.2} />;
|
||||
}
|
||||
|
||||
function FolderIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedFolder size={size} strokeWidth={2.2} />;
|
||||
}
|
||||
@@ -106,6 +113,7 @@ export function CommandCenterRootActions() {
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const openAddProject = useOpenAddProject();
|
||||
const { open: openImportSession, sheet: importSessionSheet } = useImportSession();
|
||||
const settingsRoute = useMemo<Href>(() => buildSettingsRoute(), []);
|
||||
const homeRoute = useMemo<Href>(() => buildOpenProjectRoute(), []);
|
||||
const sessionsRoute = useMemo<Href>(() => buildSessionsRoute(), []);
|
||||
@@ -166,11 +174,29 @@ export function CommandCenterRootActions() {
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "home",
|
||||
id: "import-session",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 2,
|
||||
keywords: ["home", "start", "import", "session", "pair", "device", "providers"],
|
||||
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",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 3,
|
||||
keywords: ["home", "start", "pair", "device", "providers"],
|
||||
visibility: "query",
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
@@ -187,7 +213,7 @@ export function CommandCenterRootActions() {
|
||||
id: "history",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 3,
|
||||
rank: 4,
|
||||
keywords: ["history", "sessions", "recent"],
|
||||
visibility: "always",
|
||||
run: () => {
|
||||
@@ -205,7 +231,7 @@ export function CommandCenterRootActions() {
|
||||
id: "schedules",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 4,
|
||||
rank: 5,
|
||||
keywords: ["schedules", "scheduled", "automation", "recurring"],
|
||||
visibility: "always",
|
||||
run: () => {
|
||||
@@ -223,7 +249,7 @@ export function CommandCenterRootActions() {
|
||||
id: "settings",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 5,
|
||||
rank: 6,
|
||||
keywords: ["settings", "preferences", "config", "configuration"],
|
||||
visibility: "always",
|
||||
run: () => {
|
||||
@@ -250,7 +276,7 @@ export function CommandCenterRootActions() {
|
||||
id: "toggle-left-sidebar",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 7,
|
||||
rank: 8,
|
||||
keywords: ["toggle", "sidebar", "left", "panel", "workspaces"],
|
||||
visibility: "query",
|
||||
run: () => {
|
||||
@@ -274,7 +300,7 @@ export function CommandCenterRootActions() {
|
||||
id: "keyboard-shortcuts",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 6,
|
||||
rank: 7,
|
||||
keywords: ["keyboard", "shortcuts", "keys", "hotkeys"],
|
||||
visibility: "always",
|
||||
run: () => setShortcutsDialogOpen(true),
|
||||
@@ -309,6 +335,7 @@ export function CommandCenterRootActions() {
|
||||
homeRoute,
|
||||
keyboardActionDispatcher,
|
||||
openAddProject,
|
||||
openImportSession,
|
||||
overrides,
|
||||
schedulesRoute,
|
||||
sessionsRoute,
|
||||
@@ -322,5 +349,5 @@ export function CommandCenterRootActions() {
|
||||
]);
|
||||
|
||||
useCommandCenterActions({ sourceId: "root", enabled: true, actions });
|
||||
return null;
|
||||
return importSessionSheet;
|
||||
}
|
||||
|
||||
@@ -476,7 +476,7 @@ export function AdaptiveModalSheet({
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isKeyboardVisible = useKeyboardVisibility();
|
||||
const isKeyboardVisible = useKeyboardVisibility(visible);
|
||||
const resolvedSnapPoints = useMemo(() => snapPoints ?? ["65%", "90%"], [snapPoints]);
|
||||
const compactSafeAreaPadding = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -232,7 +232,7 @@ function SessionRow({
|
||||
const projectName = agent.projectPlacement?.projectName ?? "";
|
||||
const branch = agent.projectPlacement?.checkout.currentBranch ?? "";
|
||||
const workspaceName = agent.projectPlacement?.workspaceName ?? "";
|
||||
const ProviderIcon = getProviderIcon(agent.provider);
|
||||
const ProviderIcon = getProviderIcon(agent.provider, agent.serverId);
|
||||
const pendingPermissionCount = agent.pendingPermissionCount ?? 0;
|
||||
const rangesFor = useCallback(
|
||||
(field: AgentSearchMatch["field"]) =>
|
||||
|
||||
@@ -2,11 +2,10 @@ import { useCallback, useMemo, useState } from "react";
|
||||
import { View, Text } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { KeyboardTranslateView } from "@/components/keyboard-translate-view";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
@@ -24,11 +23,9 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
||||
const [isUnarchiving, setIsUnarchiving] = useState(false);
|
||||
const [unarchiveError, setUnarchiveError] = useState<string | null>(null);
|
||||
|
||||
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" });
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => [styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle],
|
||||
[insets.bottom, keyboardAnimatedStyle],
|
||||
() => [styles.container, { paddingBottom: insets.bottom }],
|
||||
[insets.bottom],
|
||||
);
|
||||
|
||||
const handleUnarchive = useCallback(async () => {
|
||||
@@ -44,7 +41,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
||||
}, [client, isConnected, isUnarchiving, agentId]);
|
||||
|
||||
return (
|
||||
<Animated.View style={containerStyle}>
|
||||
<KeyboardTranslateView style={containerStyle}>
|
||||
<View style={styles.inputAreaContainer}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
<View style={styles.calloutStack}>
|
||||
@@ -67,7 +64,7 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</KeyboardTranslateView>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,16 @@ import {
|
||||
aggregateSessionEntries,
|
||||
ALL_FILTER_VALUE,
|
||||
buildProviderLabelMap,
|
||||
collectErroredProviderLabels,
|
||||
collectProviderErrorRows,
|
||||
computeEmptyState,
|
||||
formatDirectoryLabel,
|
||||
getPromptPreview,
|
||||
getSessionTitle,
|
||||
hasMoreSessions,
|
||||
nextPageLimit,
|
||||
PER_PROVIDER_LIMIT,
|
||||
resolveDirectoryLabel,
|
||||
resolveImportTarget,
|
||||
resolveProvidersToFetch,
|
||||
requiresImportSessionsHostUpgrade,
|
||||
type SessionsQueryResult,
|
||||
@@ -167,27 +173,176 @@ describe("sumFilteredAlreadyImportedCount", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectErroredProviderLabels", () => {
|
||||
it("returns no labels when no providers are being fetched", () => {
|
||||
expect(collectErroredProviderLabels(null, [], new Map())).toEqual([]);
|
||||
describe("collectProviderErrorRows", () => {
|
||||
it("returns no rows when no providers are being fetched", () => {
|
||||
expect(collectProviderErrorRows(null, [], new Map())).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns labels for each errored provider, falling back to provider id", () => {
|
||||
const labels = collectErroredProviderLabels(
|
||||
it("returns a row for each provider whose request failed", () => {
|
||||
const rows = collectProviderErrorRows(
|
||||
["claude", "codex"],
|
||||
[settled(undefined, { isError: true }), settled({ entries: [] })],
|
||||
new Map([["claude", "Claude Code"]]),
|
||||
);
|
||||
expect(labels).toEqual(["Claude Code"]);
|
||||
expect(rows).toEqual([{ provider: "claude", label: "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", () => {
|
||||
const labels = collectErroredProviderLabels(
|
||||
const rows = collectProviderErrorRows(
|
||||
["codex"],
|
||||
[settled(undefined, { isError: true })],
|
||||
new Map(),
|
||||
);
|
||||
expect(labels).toEqual(["codex"]);
|
||||
expect(rows).toEqual([{ provider: "codex", label: "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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -236,6 +391,7 @@ describe("computeEmptyState", () => {
|
||||
isQueryingProviders: true,
|
||||
allQueriesSettled: true,
|
||||
selectedProvider: ALL_FILTER_VALUE,
|
||||
hasQuery: false,
|
||||
aggregatedCount: 0,
|
||||
visibleCount: 0,
|
||||
totalAlreadyImportedCount: 0,
|
||||
@@ -274,6 +430,15 @@ 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", () => {
|
||||
const result = computeEmptyState({
|
||||
...baseInputs,
|
||||
|
||||
@@ -5,6 +5,18 @@ import { i18n } from "@/i18n/i18next";
|
||||
export const PER_PROVIDER_LIMIT = 15;
|
||||
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: {
|
||||
supportsSnapshot: boolean;
|
||||
workspaceId?: string | null;
|
||||
@@ -18,11 +30,13 @@ export interface SessionsQueryResult {
|
||||
| {
|
||||
entries: FetchRecentProviderSessionEntry[];
|
||||
filteredAlreadyImportedCount?: number;
|
||||
providerErrors?: Array<{ provider: string; message: string }>;
|
||||
}
|
||||
| undefined;
|
||||
isError: boolean;
|
||||
isLoading: boolean;
|
||||
isPending: boolean;
|
||||
isPlaceholderData?: boolean;
|
||||
}
|
||||
|
||||
export function resolveProvidersToFetch(
|
||||
@@ -81,20 +95,132 @@ export function sumFilteredAlreadyImportedCount(
|
||||
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,
|
||||
queries: ReadonlyArray<SessionsQueryResult>,
|
||||
providerLabelById: ReadonlyMap<string, string>,
|
||||
): string[] {
|
||||
): ProviderErrorRow[] {
|
||||
if (providersToFetch === null) return [];
|
||||
const labels: string[] = [];
|
||||
for (let index = 0; index < queries.length; index++) {
|
||||
if (queries[index]?.isError) {
|
||||
const provider = providersToFetch[index];
|
||||
labels.push(providerLabelById.get(provider) ?? provider);
|
||||
}
|
||||
const rows: ProviderErrorRow[] = [];
|
||||
for (let index = 0; index < providersToFetch.length; index++) {
|
||||
const query = queries[index];
|
||||
const provider = providersToFetch[index];
|
||||
if (!query || provider === undefined) continue;
|
||||
const failed = query.isError || (query.data?.providerErrors?.length ?? 0) > 0;
|
||||
if (!failed) continue;
|
||||
rows.push({ provider, label: providerLabelById.get(provider) ?? provider });
|
||||
}
|
||||
return labels;
|
||||
return rows;
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -123,6 +249,7 @@ export interface EmptyStateInputs {
|
||||
isQueryingProviders: boolean;
|
||||
allQueriesSettled: boolean;
|
||||
selectedProvider: string;
|
||||
hasQuery: boolean;
|
||||
aggregatedCount: number;
|
||||
visibleCount: number;
|
||||
totalAlreadyImportedCount: number;
|
||||
@@ -142,6 +269,11 @@ export function computeEmptyState(input: EmptyStateInputs): {
|
||||
if (!showEmptyState) {
|
||||
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;
|
||||
if (isFilteredEmpty) {
|
||||
const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider;
|
||||
|
||||
@@ -14,12 +14,12 @@ import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||
|
||||
const { theme } = vi.hoisted(() => ({
|
||||
theme: {
|
||||
spacing: { 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16, 6: 24 },
|
||||
spacing: { 1: 4, 1.5: 6, 2: 8, 2.5: 10, 3: 12, 4: 16, 6: 24, 8: 32 },
|
||||
borderWidth: { 1: 1 },
|
||||
borderRadius: { md: 6, lg: 8, full: 9999 },
|
||||
fontSize: { xs: 11, sm: 13, base: 15 },
|
||||
fontWeight: { normal: "400", medium: "500", semibold: "600" },
|
||||
iconSize: { sm: 14, md: 16 },
|
||||
iconSize: { sm: 14, md: 16, lg: 24 },
|
||||
opacity: { 50: 0.5 },
|
||||
colors: {
|
||||
foreground: "#fff",
|
||||
@@ -30,6 +30,8 @@ const { theme } = vi.hoisted(() => ({
|
||||
surface3: "#333",
|
||||
border: "#444",
|
||||
borderAccent: "#555",
|
||||
interactionHighlight: "rgba(255,255,255,0.08)",
|
||||
palette: { red: { 300: "#f87171" } },
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -75,6 +77,8 @@ vi.mock("lucide-react-native", () => {
|
||||
Inbox: icon("Inbox"),
|
||||
Layers: icon("Layers"),
|
||||
RotateCw: icon("RotateCw"),
|
||||
Search: icon("Search"),
|
||||
X: icon("X"),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -117,6 +121,22 @@ vi.mock("@/components/ui/combobox", () => ({
|
||||
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", () => ({
|
||||
AdaptiveModalSheet: ({
|
||||
visible,
|
||||
@@ -125,14 +145,21 @@ vi.mock("@/components/adaptive-modal-sheet", () => ({
|
||||
testID,
|
||||
}: {
|
||||
visible: boolean;
|
||||
header?: { title: string; actions?: ReactNode };
|
||||
header?: {
|
||||
title: string;
|
||||
subtitle?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
search?: { onChange: (value: string) => void; placeholder?: string; testID?: string };
|
||||
};
|
||||
children: ReactNode;
|
||||
testID?: string;
|
||||
}) =>
|
||||
visible ? (
|
||||
<section data-testid={testID}>
|
||||
<h1>{header?.title}</h1>
|
||||
{header?.subtitle}
|
||||
{header?.actions}
|
||||
{header?.search ? <SheetSearchInput search={header.search} /> : null}
|
||||
{children}
|
||||
</section>
|
||||
) : null,
|
||||
@@ -163,26 +190,62 @@ 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 {
|
||||
visible?: boolean;
|
||||
onClose?: () => void;
|
||||
onImportedAgent?: (agentId: string) => void;
|
||||
onImported?: (agent: Awaited<ReturnType<DaemonClient["importAgent"]>>) => void;
|
||||
cwd?: string | null;
|
||||
workspaceId?: string;
|
||||
supportsSearch?: boolean;
|
||||
projects?: Array<{ iconWorkingDir: string; projectName: string }>;
|
||||
snapshot?: {
|
||||
entries?: ProviderSnapshotEntry[];
|
||||
supportsSnapshot?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function renderSheet(
|
||||
client: Pick<DaemonClient, "fetchRecentProviderSessions" | "importAgent">,
|
||||
options?: RenderOptions,
|
||||
) {
|
||||
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(
|
||||
client: Pick<DaemonClient, "fetchRecentProviderSessions" | "importAgent">,
|
||||
options?: RenderOptions,
|
||||
) {
|
||||
applyHostMocks(options);
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -200,6 +263,7 @@ function renderSheet(
|
||||
client={client}
|
||||
serverId="server-1"
|
||||
cwd={cwd}
|
||||
workspaceId={options?.workspaceId}
|
||||
onClose={options?.onClose ?? vi.fn()}
|
||||
onImportedAgent={options?.onImportedAgent ?? vi.fn()}
|
||||
onImported={options?.onImported}
|
||||
@@ -245,6 +309,16 @@ 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(
|
||||
overrides?: Partial<FetchRecentProviderSessionEntry>,
|
||||
): FetchRecentProviderSessionEntry {
|
||||
@@ -377,7 +451,8 @@ describe("ImportSessionSheet", () => {
|
||||
},
|
||||
);
|
||||
|
||||
await screen.findByText("Could not load recent sessions.");
|
||||
await screen.findByText("Could not load Claude Code sessions");
|
||||
screen.getByTestId("import-session-retry-claude");
|
||||
});
|
||||
|
||||
it("loads recent provider sessions for the workspace and renders descriptor-owned labels", async () => {
|
||||
@@ -478,19 +553,34 @@ describe("ImportSessionSheet", () => {
|
||||
});
|
||||
|
||||
it("imports a selected session by provider handle and reports the imported agent", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: [
|
||||
createProviderSessionEntry({
|
||||
providerId: "claude",
|
||||
providerLabel: "Claude Code",
|
||||
cwd: "/repo/paseo-realpath",
|
||||
const events: string[] = [];
|
||||
let fetchCount = 0;
|
||||
const fetchRecentProviderSessions = vi.fn(async () => {
|
||||
fetchCount += 1;
|
||||
events.push("fetch");
|
||||
if (fetchCount > 1) {
|
||||
return await new Promise<never>(() => {});
|
||||
}
|
||||
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 importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported"));
|
||||
const onClose = vi.fn();
|
||||
const onImportedAgent = vi.fn();
|
||||
);
|
||||
const onClose = vi.fn(() => events.push("close"));
|
||||
const onImportedAgent = vi.fn(() => events.push("navigate"));
|
||||
|
||||
renderSheet(
|
||||
{ fetchRecentProviderSessions, importAgent } as Pick<
|
||||
@@ -500,21 +590,31 @@ describe("ImportSessionSheet", () => {
|
||||
{
|
||||
onClose,
|
||||
onImportedAgent,
|
||||
workspaceId: "ws-current",
|
||||
snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] },
|
||||
},
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1"));
|
||||
const row = 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(() => {
|
||||
expect(importAgent).toHaveBeenCalledWith({
|
||||
providerId: "claude",
|
||||
providerHandleId: "provider-thread-1",
|
||||
cwd: "/repo/paseo-realpath",
|
||||
workspaceId: "ws-current",
|
||||
});
|
||||
expect(events).toEqual(["fetch", "close", "navigate"]);
|
||||
});
|
||||
expect(onImportedAgent).toHaveBeenCalledWith("agent-imported");
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows an import error state without closing when selected session import fails", async () => {
|
||||
@@ -649,7 +749,7 @@ describe("ImportSessionSheet", () => {
|
||||
);
|
||||
|
||||
await screen.findByText("Session codex");
|
||||
await screen.findByText("Could not load sessions for Claude Code.");
|
||||
await screen.findByText("Could not load Claude Code sessions");
|
||||
});
|
||||
|
||||
it("filters the merged list when a provider badge is selected and restores it on All", async () => {
|
||||
@@ -758,7 +858,7 @@ describe("ImportSessionSheet", () => {
|
||||
expect(fetchRecentProviderSessions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("omits cwd from fetch and renders the session cwd on each row when cwd is unset", async () => {
|
||||
it("omits cwd from fetch and names each row's folder when cwd is unset", async () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
entries: [
|
||||
@@ -793,6 +893,9 @@ describe("ImportSessionSheet", () => {
|
||||
expect.objectContaining({ cwd: expect.anything() }),
|
||||
);
|
||||
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 () => {
|
||||
@@ -836,10 +939,344 @@ describe("ImportSessionSheet", () => {
|
||||
});
|
||||
expect(onImported).toHaveBeenCalledTimes(1);
|
||||
expect(onImported).toHaveBeenCalledWith(expect.objectContaining({ id: "agent-imported" }));
|
||||
expect(onImportedAgent).toHaveBeenCalledWith("agent-imported");
|
||||
expect(onImportedAgent).not.toHaveBeenCalled();
|
||||
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 () => {
|
||||
const fetchRecentProviderSessions = vi.fn(async () => ({
|
||||
requestId: "recent-provider-sessions",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pressable, type PressableStateCallbackType, Text, View } from "react-native";
|
||||
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
DaemonClient,
|
||||
@@ -10,22 +10,33 @@ import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { ChevronDown, Inbox, Layers, RotateCw } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useHostProjects } from "@/projects/host-projects";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import {
|
||||
aggregateSessionEntries,
|
||||
ALL_FILTER_VALUE,
|
||||
buildProviderLabelMap,
|
||||
collectErroredProviderLabels,
|
||||
collectProviderErrorRows,
|
||||
computeEmptyState,
|
||||
type DirectoryProject,
|
||||
formatDirectoryLabel,
|
||||
getPromptPreview,
|
||||
getSessionTitle,
|
||||
hasMoreSessions,
|
||||
resolveDirectoryLabel,
|
||||
nextPageLimit,
|
||||
PER_PROVIDER_LIMIT,
|
||||
type ProviderErrorRow,
|
||||
resolveImportTarget,
|
||||
resolveProvidersToFetch,
|
||||
requiresImportSessionsHostUpgrade,
|
||||
sumFilteredAlreadyImportedCount,
|
||||
@@ -33,6 +44,8 @@ import {
|
||||
|
||||
const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"];
|
||||
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<
|
||||
DaemonClient,
|
||||
@@ -48,7 +61,9 @@ interface ImportSessionSheetProps {
|
||||
cwd?: string | null;
|
||||
workspaceId?: string | null;
|
||||
onClose: () => void;
|
||||
/** The agent belongs to the workspace the sheet was opened from; open it here. */
|
||||
onImportedAgent?: (agentId: string) => void;
|
||||
/** The agent belongs to its own workspace; open that workspace and navigate. */
|
||||
onImported?: (agent: ImportedAgent) => void;
|
||||
}
|
||||
|
||||
@@ -56,27 +71,51 @@ type RecentSessionsResponse = Awaited<
|
||||
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 {
|
||||
queryKey: ReadonlyArray<string | null>;
|
||||
queryKey: SessionsQueryKey;
|
||||
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>;
|
||||
}
|
||||
|
||||
function buildSessionsQueriesConfig(args: {
|
||||
providersToFetch: AgentProvider[] | null;
|
||||
sessionsQueryRoot: ReadonlyArray<string | null>;
|
||||
visible: boolean;
|
||||
client: RecentProviderSessionsClient | null;
|
||||
cwd: string | null | undefined;
|
||||
cwd: string | null;
|
||||
query: string;
|
||||
limit: number;
|
||||
hostDisconnectedMessage?: string;
|
||||
}): SessionsQueryConfig[] {
|
||||
const { providersToFetch, sessionsQueryRoot, visible, client, cwd, hostDisconnectedMessage } =
|
||||
args;
|
||||
const { providersToFetch, visible, client, cwd, query, limit, hostDisconnectedMessage } = args;
|
||||
if (providersToFetch === null) return [];
|
||||
const enabled = visible && Boolean(client);
|
||||
return providersToFetch.map((provider) => ({
|
||||
queryKey: [...sessionsQueryRoot, provider],
|
||||
queryKey: buildSessionsQueryKey({ cwd, query, limit, provider }),
|
||||
enabled,
|
||||
retry: false as const,
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error(hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected"));
|
||||
@@ -84,7 +123,8 @@ function buildSessionsQueriesConfig(args: {
|
||||
return await client.fetchRecentProviderSessions({
|
||||
...(cwd ? { cwd } : {}),
|
||||
providers: [provider],
|
||||
limit: PER_PROVIDER_LIMIT,
|
||||
limit,
|
||||
...(query ? { query } : {}),
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -96,8 +136,6 @@ interface SheetStatusMessagesProps {
|
||||
hasNoImportableProviders: boolean;
|
||||
isLoadingSessions: boolean;
|
||||
hasRows: boolean;
|
||||
allQueriesErrored: boolean;
|
||||
erroredProviderLabels: ReadonlyArray<string>;
|
||||
importErrored: boolean;
|
||||
}
|
||||
|
||||
@@ -107,8 +145,6 @@ function SheetStatusMessages({
|
||||
hasNoImportableProviders,
|
||||
isLoadingSessions,
|
||||
hasRows,
|
||||
allQueriesErrored,
|
||||
erroredProviderLabels,
|
||||
importErrored,
|
||||
}: SheetStatusMessagesProps) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -130,16 +166,6 @@ function SheetStatusMessages({
|
||||
<Text style={styles.statusText}>{t("importSession.status.loading")}</Text>
|
||||
</View>
|
||||
) : 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 ? (
|
||||
<Text style={styles.statusText}>{t("importSession.status.failedImport")}</Text>
|
||||
) : null}
|
||||
@@ -147,6 +173,48 @@ 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 }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
@@ -177,6 +245,38 @@ 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 }) {
|
||||
const { theme } = useUnistyles();
|
||||
return (
|
||||
@@ -190,16 +290,19 @@ function SheetEmptyState({ title }: { title: string }) {
|
||||
}
|
||||
|
||||
function ImportSessionSheetRow({
|
||||
serverId,
|
||||
entry,
|
||||
disabled,
|
||||
importing,
|
||||
showCwd,
|
||||
folder,
|
||||
onImportSession,
|
||||
}: {
|
||||
serverId: string | null;
|
||||
entry: FetchRecentProviderSessionEntry;
|
||||
disabled: boolean;
|
||||
importing: boolean;
|
||||
showCwd: boolean;
|
||||
/** The row's directory, shown only when rows can come from more than one. */
|
||||
folder: string | null;
|
||||
onImportSession: (entry: FetchRecentProviderSessionEntry) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -207,7 +310,7 @@ function ImportSessionSheetRow({
|
||||
const title = getSessionTitle(entry);
|
||||
const promptPreview = getPromptPreview(entry);
|
||||
const lastActivity = formatTimeAgo(new Date(entry.lastActivityAt));
|
||||
const ProviderIcon = getProviderIcon(entry.providerId);
|
||||
const ProviderIcon = getProviderIcon(entry.providerId, serverId);
|
||||
const accessibilityState = useMemo(
|
||||
() => (disabled ? DISABLED_ACCESSIBILITY_STATE : undefined),
|
||||
[disabled],
|
||||
@@ -248,9 +351,13 @@ function ImportSessionSheetRow({
|
||||
<Text style={styles.rowPreview} numberOfLines={2}>
|
||||
{promptPreview}
|
||||
</Text>
|
||||
{showCwd && entry.cwd ? (
|
||||
<Text style={styles.rowCwd} numberOfLines={1}>
|
||||
{entry.cwd}
|
||||
{folder ? (
|
||||
<Text
|
||||
style={styles.rowFolder}
|
||||
numberOfLines={1}
|
||||
testID={`import-session-row-folder-${entry.providerId}-${entry.providerHandleId}`}
|
||||
>
|
||||
{folder}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -258,6 +365,38 @@ function ImportSessionSheetRow({
|
||||
);
|
||||
}
|
||||
|
||||
function SessionRows({
|
||||
serverId,
|
||||
entries,
|
||||
disabled,
|
||||
importingSessionKey,
|
||||
resolveFolder,
|
||||
onImportSession,
|
||||
}: {
|
||||
serverId: string | null;
|
||||
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}`}
|
||||
serverId={serverId}
|
||||
entry={entry}
|
||||
disabled={disabled}
|
||||
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
|
||||
folder={resolveFolder(entry)}
|
||||
onImportSession={onImportSession}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImportSessionSheet({
|
||||
visible,
|
||||
client,
|
||||
@@ -272,8 +411,32 @@ export function ImportSessionSheet({
|
||||
const queryClient = useQueryClient();
|
||||
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, {
|
||||
cwd,
|
||||
cwd: scopeCwd,
|
||||
enabled: visible,
|
||||
});
|
||||
const supportsWorkspaceTarget = useHostFeature(serverId, "importSessionWorkspaceTarget");
|
||||
@@ -293,22 +456,20 @@ export function ImportSessionSheet({
|
||||
[snapshotEntries],
|
||||
);
|
||||
|
||||
const sessionsQueryRoot = useMemo(
|
||||
() => ["recent-provider-sessions", cwd ?? null] as const,
|
||||
[cwd],
|
||||
);
|
||||
const sessionsQueryRoot = useMemo(() => ["recent-provider-sessions", scopeCwd], [scopeCwd]);
|
||||
|
||||
const queriesConfig = useMemo(
|
||||
() =>
|
||||
buildSessionsQueriesConfig({
|
||||
providersToFetch,
|
||||
sessionsQueryRoot,
|
||||
visible,
|
||||
client,
|
||||
cwd,
|
||||
cwd: scopeCwd,
|
||||
query,
|
||||
limit: pageLimit,
|
||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||
}),
|
||||
[providersToFetch, sessionsQueryRoot, visible, client, cwd, t],
|
||||
[providersToFetch, visible, client, scopeCwd, query, pageLimit, t],
|
||||
);
|
||||
|
||||
const queries = useQueries({ queries: queriesConfig });
|
||||
@@ -320,8 +481,6 @@ export function ImportSessionSheet({
|
||||
);
|
||||
|
||||
const filterProviders = useMemo(() => [...(providersToFetch ?? [])].sort(), [providersToFetch]);
|
||||
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>(ALL_FILTER_VALUE);
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
const filterAnchorRef = useRef<View>(null);
|
||||
|
||||
@@ -339,6 +498,27 @@ export function ImportSessionSheet({
|
||||
return aggregatedEntries.filter((entry) => entry.providerId === 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[]>(
|
||||
() => [
|
||||
{ id: ALL_FILTER_VALUE, label: t("importSession.filters.all") },
|
||||
@@ -377,11 +557,11 @@ export function ImportSessionSheet({
|
||||
const map = new Map<string, React.ReactNode>();
|
||||
map.set(ALL_FILTER_VALUE, <Layers size={14} color={theme.colors.foregroundMuted} />);
|
||||
for (const provider of filterProviders) {
|
||||
const ProviderIcon = getProviderIcon(provider);
|
||||
const ProviderIcon = getProviderIcon(provider, serverId);
|
||||
map.set(provider, <ProviderIcon size={14} color={theme.colors.foregroundMuted} />);
|
||||
}
|
||||
return map;
|
||||
}, [filterProviders, theme.colors.foregroundMuted]);
|
||||
}, [filterProviders, serverId, theme.colors.foregroundMuted]);
|
||||
|
||||
const renderFilterOption = useCallback(
|
||||
({
|
||||
@@ -414,19 +594,31 @@ export function ImportSessionSheet({
|
||||
if (!entry.cwd) {
|
||||
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({
|
||||
providerId: entry.providerId,
|
||||
providerHandleId: entry.providerHandleId,
|
||||
cwd: entry.cwd,
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(target.workspaceId ? { workspaceId: target.workspaceId } : {}),
|
||||
});
|
||||
return agent;
|
||||
return { agent, target };
|
||||
},
|
||||
onSuccess: async (agent) => {
|
||||
await queryClient.invalidateQueries({ queryKey: sessionsQueryRoot });
|
||||
onSuccess: ({ agent, target }) => {
|
||||
onClose();
|
||||
onImportedAgent?.(agent.id);
|
||||
onImported?.(agent);
|
||||
if (target.crossWorkspace) {
|
||||
onImported?.(agent);
|
||||
} else {
|
||||
onImportedAgent?.(agent.id);
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: sessionsQueryRoot,
|
||||
refetchType: "none",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -442,23 +634,61 @@ export function ImportSessionSheet({
|
||||
[importMutation],
|
||||
);
|
||||
|
||||
const erroredProviderLabels = useMemo(
|
||||
() => collectErroredProviderLabels(providersToFetch, queries, providerLabelById),
|
||||
const providerErrorRows = useMemo(
|
||||
() => collectProviderErrorRows(providersToFetch, queries, providerLabelById),
|
||||
[queries, providersToFetch, providerLabelById],
|
||||
);
|
||||
|
||||
const isRefreshing = queries.some((query) => query.isFetching);
|
||||
// Every query settles, errors included, so this stops turning (#2512).
|
||||
const isRefreshing = queries.some((providerQuery) => providerQuery.isFetching);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: 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>(
|
||||
() => ({
|
||||
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} />,
|
||||
}),
|
||||
[isRefreshing, handleRefresh, t],
|
||||
[handleRefresh, handleShowAll, hostLabel, isRefreshing, scopeCwd, supportsSearch, t, visible],
|
||||
);
|
||||
|
||||
const isSnapshotUnsupported = requiresHostUpgrade;
|
||||
@@ -467,22 +697,27 @@ export function ImportSessionSheet({
|
||||
const isQueryingProviders = queries.length > 0;
|
||||
const isLoadingSessions =
|
||||
isWaitingForSnapshot ||
|
||||
(isQueryingProviders && queries.some((query) => query.isLoading || query.isPending));
|
||||
const allQueriesErrored = isQueryingProviders && queries.every((query) => query.isError);
|
||||
(isQueryingProviders &&
|
||||
queries.some((providerQuery) => providerQuery.isLoading || providerQuery.isPending));
|
||||
const allQueriesErrored =
|
||||
isQueryingProviders && queries.every((providerQuery) => providerQuery.isError);
|
||||
const allQueriesSettled =
|
||||
isQueryingProviders && queries.every((query) => !query.isLoading && !query.isPending);
|
||||
isQueryingProviders &&
|
||||
queries.every((providerQuery) => !providerQuery.isLoading && !providerQuery.isPending);
|
||||
const { showEmptyState, emptyStateTitle } = computeEmptyState({
|
||||
isLoadingSessions,
|
||||
allQueriesErrored,
|
||||
isQueryingProviders,
|
||||
allQueriesSettled,
|
||||
selectedProvider,
|
||||
hasQuery: query.length > 0,
|
||||
aggregatedCount: aggregatedEntries.length,
|
||||
visibleCount: visibleEntries.length,
|
||||
totalAlreadyImportedCount,
|
||||
providerLabelById,
|
||||
});
|
||||
const showFilter = filterProviders.length > 1;
|
||||
const showLoadMore = hasMoreSessions(queries, pageLimit);
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
@@ -506,7 +741,7 @@ export function ImportSessionSheet({
|
||||
<Layers size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
(() => {
|
||||
const ProviderIcon = getProviderIcon(selectedProvider);
|
||||
const ProviderIcon = getProviderIcon(selectedProvider, serverId);
|
||||
return <ProviderIcon size={14} color={theme.colors.foregroundMuted} />;
|
||||
})()
|
||||
)}
|
||||
@@ -536,22 +771,31 @@ export function ImportSessionSheet({
|
||||
hasNoImportableProviders={hasNoImportableProviders}
|
||||
isLoadingSessions={isLoadingSessions}
|
||||
hasRows={visibleEntries.length > 0}
|
||||
allQueriesErrored={allQueriesErrored}
|
||||
erroredProviderLabels={erroredProviderLabels}
|
||||
importErrored={importMutation.isError}
|
||||
/>
|
||||
{providerErrorRows.length > 0 ? (
|
||||
<ProviderErrorBanner rows={providerErrorRows} onRetry={handleRetryProvider} />
|
||||
) : null}
|
||||
{visibleEntries.length > 0 ? (
|
||||
<View style={styles.list}>
|
||||
{visibleEntries.map((entry) => (
|
||||
<ImportSessionSheetRow
|
||||
key={`${entry.providerId}:${entry.providerHandleId}`}
|
||||
entry={entry}
|
||||
disabled={importMutation.isPending}
|
||||
importing={importingSessionKey === `${entry.providerId}:${entry.providerHandleId}`}
|
||||
showCwd={!cwd}
|
||||
onImportSession={handleImportSession}
|
||||
/>
|
||||
))}
|
||||
<SessionRows
|
||||
serverId={serverId}
|
||||
entries={visibleEntries}
|
||||
disabled={importMutation.isPending}
|
||||
importingSessionKey={importingSessionKey}
|
||||
resolveFolder={resolveFolder}
|
||||
onImportSession={handleImportSession}
|
||||
/>
|
||||
) : null}
|
||||
{showLoadMore ? (
|
||||
<View style={styles.footer}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onPress={handleLoadMore}
|
||||
disabled={isRefreshing}
|
||||
testID="import-session-load-more"
|
||||
>
|
||||
{t("importSession.actions.loadMore")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{showEmptyState ? <SheetEmptyState title={emptyStateTitle} /> : null}
|
||||
@@ -560,6 +804,21 @@ export function ImportSessionSheet({
|
||||
}
|
||||
|
||||
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: {
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
@@ -568,6 +827,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1.5],
|
||||
alignSelf: "flex-start",
|
||||
maxWidth: "100%",
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
@@ -582,6 +842,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
filterTriggerText: {
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
@@ -589,6 +851,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
list: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
footer: {
|
||||
paddingTop: theme.spacing[2],
|
||||
alignItems: "center",
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
@@ -636,7 +902,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 20,
|
||||
},
|
||||
rowCwd: {
|
||||
rowFolder: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
@@ -650,6 +916,26 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
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: {
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { ViewProps } from "react-native";
|
||||
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
import { KeyboardTranslateView } from "@/components/keyboard-translate-view";
|
||||
|
||||
interface KeyboardDockProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function KeyboardDock({ children, style, ...props }: KeyboardDockProps) {
|
||||
const { shift } = useKeyboardShift();
|
||||
const keyboardPaddingStyle = useAnimatedStyle(() => ({
|
||||
paddingBottom: shift.value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={[style, keyboardPaddingStyle]} {...props}>
|
||||
<KeyboardTranslateView style={style} {...props}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</KeyboardTranslateView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { ViewProps } from "react-native";
|
||||
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||
|
||||
interface KeyboardTranslateViewProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function KeyboardTranslateView({
|
||||
children,
|
||||
enabled = true,
|
||||
style,
|
||||
...props
|
||||
}: KeyboardTranslateViewProps) {
|
||||
const { shift } = useKeyboardShift();
|
||||
const keyboardStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: enabled ? -shift.value : 0 }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={[style, keyboardStyle]} {...props}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Animated, type ViewProps } from "react-native";
|
||||
import { useKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
interface KeyboardTranslateViewProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function KeyboardTranslateView({
|
||||
children,
|
||||
enabled = true,
|
||||
style,
|
||||
...props
|
||||
}: KeyboardTranslateViewProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { height, progress } = useKeyboardAnimation();
|
||||
const translateY = useMemo(
|
||||
() => Animated.add(height, Animated.multiply(progress, insets.bottom)),
|
||||
[height, insets.bottom, progress],
|
||||
);
|
||||
const keyboardStyle = useMemo(
|
||||
() => ({ transform: [{ translateY: enabled ? translateY : 0 }] }),
|
||||
[enabled, translateY],
|
||||
);
|
||||
|
||||
return (
|
||||
<Animated.View style={[style, keyboardStyle]} {...props}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { View, type ViewProps } from "react-native";
|
||||
|
||||
interface KeyboardTranslateViewProps extends ViewProps {
|
||||
children: ReactNode;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function KeyboardTranslateView({
|
||||
children,
|
||||
enabled: _enabled,
|
||||
...props
|
||||
}: KeyboardTranslateViewProps) {
|
||||
return <View {...props}>{children}</View>;
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import { router } from "expo-router";
|
||||
import { FolderPlus, GitBranch, Home, Server, Settings, X } from "lucide-react-native";
|
||||
import { router, useGlobalSearchParams } from "expo-router";
|
||||
import {
|
||||
AudioLines,
|
||||
FolderPlus,
|
||||
GitBranch,
|
||||
Import,
|
||||
Server,
|
||||
Settings,
|
||||
X,
|
||||
} from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
@@ -30,6 +38,7 @@ import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useImportSession } from "@/hooks/use-import-session";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import {
|
||||
type SidebarProjectEntry,
|
||||
@@ -41,20 +50,23 @@ import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import type { SidebarWorkspaceGroup } from "@/components/sidebar/sidebar-labels";
|
||||
import type { SidebarProjectIconTarget } from "@/utils/sidebar-project-row-model";
|
||||
import { type SidebarGroupMode, useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window";
|
||||
import { useCloseAgentListGesture } from "@/mobile-panels/gestures";
|
||||
import { MobilePanelOverlay } from "@/mobile-panels/presentation";
|
||||
import {
|
||||
buildOpenProjectRoute,
|
||||
buildSettingsAddHostRoute,
|
||||
buildSettingsRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { openHostOverview } from "@/navigation/settings-navigation";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
import { VoiceCallSurface } from "./voice-call-surface";
|
||||
import {
|
||||
useActiveWorkspaceSelection,
|
||||
useLastWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
type SidebarTheme = ReturnType<typeof useUnistyles>["theme"];
|
||||
|
||||
@@ -78,7 +90,7 @@ interface SidebarSharedProps {
|
||||
toggleProjectCollapsed: (projectViewKey: string) => void;
|
||||
handleRefresh: () => void;
|
||||
handleOpenProject: () => void;
|
||||
handleHome: () => void;
|
||||
handleImportSession: () => void;
|
||||
handleSettings: () => void;
|
||||
labels: SidebarLabels;
|
||||
handleAddHost: () => void;
|
||||
@@ -88,7 +100,7 @@ interface SidebarSharedProps {
|
||||
interface SidebarLabels {
|
||||
addProject: string;
|
||||
hosts: string;
|
||||
home: string;
|
||||
importSession: string;
|
||||
settings: string;
|
||||
searchHosts: string;
|
||||
closeSidebar: string;
|
||||
@@ -145,6 +157,7 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
}, [isRevalidating, isManualRefresh]);
|
||||
|
||||
const openProjectPicker = useOpenAddProject();
|
||||
const { open: openImportSession, sheet: importSessionSheet } = useImportSession();
|
||||
|
||||
const handleOpenProjectMobile = useCallback(() => {
|
||||
showMobileAgent();
|
||||
@@ -185,20 +198,16 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
openHostOverview(serverId);
|
||||
}, []);
|
||||
|
||||
const handleHomeMobile = useCallback(() => {
|
||||
const handleImportSessionMobile = useCallback(() => {
|
||||
showMobileAgent();
|
||||
router.push(buildOpenProjectRoute());
|
||||
}, [showMobileAgent]);
|
||||
|
||||
const handleHomeDesktop = useCallback(() => {
|
||||
router.push(buildOpenProjectRoute());
|
||||
}, []);
|
||||
openImportSession();
|
||||
}, [openImportSession, showMobileAgent]);
|
||||
|
||||
const labels = useMemo(
|
||||
(): SidebarLabels => ({
|
||||
addProject: t("sidebar.actions.addProject"),
|
||||
hosts: t("sidebar.actions.hosts"),
|
||||
home: t("sidebar.actions.home"),
|
||||
importSession: t("importSession.title"),
|
||||
settings: t("sidebar.actions.settings"),
|
||||
searchHosts: t("sidebar.host.searchPlaceholder"),
|
||||
closeSidebar: t("sidebar.actions.closeSidebar"),
|
||||
@@ -228,36 +237,42 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole
|
||||
|
||||
if (isCompactLayout) {
|
||||
return (
|
||||
<RetainedPanelActivity active={active}>
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
active={active}
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
closeSidebar={showMobileAgent}
|
||||
handleOpenProject={handleOpenProjectMobile}
|
||||
handleHome={handleHomeMobile}
|
||||
handleSettings={handleSettingsMobile}
|
||||
handleAddHost={handleAddHostMobile}
|
||||
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
||||
/>
|
||||
</RetainedPanelActivity>
|
||||
<>
|
||||
<RetainedPanelActivity active={active}>
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
active={active}
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
closeSidebar={showMobileAgent}
|
||||
handleOpenProject={handleOpenProjectMobile}
|
||||
handleImportSession={handleImportSessionMobile}
|
||||
handleSettings={handleSettingsMobile}
|
||||
handleAddHost={handleAddHostMobile}
|
||||
handleOpenHostSettings={handleOpenHostSettingsMobile}
|
||||
/>
|
||||
</RetainedPanelActivity>
|
||||
{importSessionSheet}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RetainedPanelActivity active={active}>
|
||||
<DesktopSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
active={active}
|
||||
handleOpenProject={handleOpenProjectDesktop}
|
||||
handleHome={handleHomeDesktop}
|
||||
handleSettings={handleSettingsDesktop}
|
||||
handleAddHost={handleAddHostDesktop}
|
||||
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
||||
/>
|
||||
</RetainedPanelActivity>
|
||||
<>
|
||||
<RetainedPanelActivity active={active}>
|
||||
<DesktopSidebar
|
||||
{...sharedProps}
|
||||
insetsTop={insets.top}
|
||||
active={active}
|
||||
handleOpenProject={handleOpenProjectDesktop}
|
||||
handleImportSession={openImportSession}
|
||||
handleSettings={handleSettingsDesktop}
|
||||
handleAddHost={handleAddHostDesktop}
|
||||
handleOpenHostSettings={handleOpenHostSettingsDesktop}
|
||||
/>
|
||||
</RetainedPanelActivity>
|
||||
{importSessionSheet}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -268,6 +283,7 @@ function sidebarHostOptionTestID(serverId: string): string {
|
||||
function FooterIconButton({
|
||||
buttonRef,
|
||||
onPress,
|
||||
disabled = false,
|
||||
testID,
|
||||
label,
|
||||
icon: Icon,
|
||||
@@ -276,6 +292,7 @@ function FooterIconButton({
|
||||
theme,
|
||||
}: {
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
testID: string;
|
||||
label: string;
|
||||
icon: typeof FolderPlus;
|
||||
@@ -289,19 +306,20 @@ function FooterIconButton({
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
ref={buttonRef}
|
||||
style={styles.footerIconButton}
|
||||
testID={testID}
|
||||
nativeID={testID}
|
||||
collapsable={false}
|
||||
accessible
|
||||
accessibilityLabel={label}
|
||||
accessibilityRole="button"
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={[styles.footerIconButton, disabled && styles.footerIconButtonDisabled]}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Icon
|
||||
size={iconSize ?? theme.iconSize.md}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
color={hovered && !disabled ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -313,6 +331,68 @@ function FooterIconButton({
|
||||
);
|
||||
}
|
||||
|
||||
function firstSearchParam(value: string | string[] | undefined): string | null {
|
||||
return Array.isArray(value) ? (value[0] ?? null) : (value ?? null);
|
||||
}
|
||||
|
||||
function SidebarVoiceChatButton({ theme }: { theme: SidebarTheme }) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const voice = useVoiceOptional();
|
||||
const activeWorkspace = useActiveWorkspaceSelection();
|
||||
const lastWorkspace = useLastWorkspaceSelection();
|
||||
const workspace = activeWorkspace ?? lastWorkspace;
|
||||
const params = useGlobalSearchParams<{
|
||||
serverId?: string | string[];
|
||||
workspaceId?: string | string[];
|
||||
agentId?: string | string[];
|
||||
}>();
|
||||
const isConnected = useHostRuntimeIsConnected(workspace?.serverId ?? "");
|
||||
const isActiveRoute =
|
||||
workspace?.serverId === firstSearchParam(params.serverId) &&
|
||||
workspace?.workspaceId === firstSearchParam(params.workspaceId);
|
||||
const agentId = isActiveRoute ? firstSearchParam(params.agentId) : null;
|
||||
const disabled = !voice || voice.isVoiceSwitching;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (!voice || disabled) return;
|
||||
if (!workspace) {
|
||||
toast.error("Open a workspace before starting voice chat.");
|
||||
return;
|
||||
}
|
||||
if (!isConnected) {
|
||||
toast.error("Connect to the host before starting voice chat.");
|
||||
return;
|
||||
}
|
||||
void voice
|
||||
.startCall(workspace.serverId, { workspaceId: workspace.workspaceId, agentId })
|
||||
.catch((error) => {
|
||||
console.error("[Sidebar] Failed to start voice chat", error);
|
||||
toast.error(error instanceof Error ? error.message : "Unable to start voice chat");
|
||||
});
|
||||
}, [agentId, disabled, isConnected, toast, voice, workspace]);
|
||||
|
||||
if (voice?.isVoiceMode) return null;
|
||||
|
||||
return (
|
||||
<FooterIconButton
|
||||
onPress={handlePress}
|
||||
disabled={disabled}
|
||||
testID="sidebar-voice-chat"
|
||||
label={t("composer.voice.voiceMode")}
|
||||
icon={AudioLines}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function VoiceWorkspaceListClearance() {
|
||||
const voice = useVoiceOptional();
|
||||
return voice?.isVoiceMode ? <View style={styles.voiceWorkspaceListClearance} /> : null;
|
||||
}
|
||||
|
||||
const voiceWorkspaceListClearance = <VoiceWorkspaceListClearance />;
|
||||
|
||||
function footerAddProjectButtonStyle({
|
||||
hovered,
|
||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
@@ -444,7 +524,7 @@ function IconTooltipContent({
|
||||
function SidebarFooter({
|
||||
theme,
|
||||
handleOpenProject,
|
||||
handleHome,
|
||||
handleImportSession,
|
||||
handleSettings,
|
||||
labels,
|
||||
handleAddHost,
|
||||
@@ -452,12 +532,12 @@ function SidebarFooter({
|
||||
}: {
|
||||
theme: SidebarTheme;
|
||||
handleOpenProject: () => void;
|
||||
handleHome: () => void;
|
||||
handleImportSession: () => void;
|
||||
handleSettings: () => void;
|
||||
labels: {
|
||||
addProject: string;
|
||||
hosts: string;
|
||||
home: string;
|
||||
importSession: string;
|
||||
settings: string;
|
||||
searchHosts: string;
|
||||
};
|
||||
@@ -483,12 +563,13 @@ function SidebarFooter({
|
||||
onOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
<FooterIconButton
|
||||
onPress={handleHome}
|
||||
testID="sidebar-home"
|
||||
label={labels.home}
|
||||
icon={Home}
|
||||
onPress={handleImportSession}
|
||||
testID="sidebar-import-session"
|
||||
label={labels.importSession}
|
||||
icon={Import}
|
||||
theme={theme}
|
||||
/>
|
||||
<SidebarVoiceChatButton theme={theme} />
|
||||
<SidebarHelpMenu />
|
||||
<FooterIconButton
|
||||
onPress={handleSettings}
|
||||
@@ -522,7 +603,7 @@ function MobileSidebar({
|
||||
toggleProjectCollapsed,
|
||||
handleRefresh,
|
||||
handleOpenProject,
|
||||
handleHome,
|
||||
handleImportSession,
|
||||
handleSettings,
|
||||
labels,
|
||||
handleAddHost,
|
||||
@@ -595,16 +676,19 @@ function MobileSidebar({
|
||||
onRefresh={handleRefresh}
|
||||
onWorkspacePress={handleWorkspacePress}
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
listFooterComponent={voiceWorkspaceListClearance}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
|
||||
<VoiceCallSurface />
|
||||
<SidebarFooter
|
||||
theme={theme}
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleHome={handleHome}
|
||||
handleImportSession={handleImportSession}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
handleAddHost={handleAddHost}
|
||||
@@ -633,7 +717,7 @@ function DesktopSidebar({
|
||||
toggleProjectCollapsed,
|
||||
handleRefresh,
|
||||
handleOpenProject,
|
||||
handleHome,
|
||||
handleImportSession,
|
||||
handleSettings,
|
||||
labels,
|
||||
handleAddHost,
|
||||
@@ -771,16 +855,19 @@ function DesktopSidebar({
|
||||
isRefreshing={isManualRefresh && isRevalidating}
|
||||
onRefresh={handleRefresh}
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
listFooterComponent={voiceWorkspaceListClearance}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarCalloutSlot />
|
||||
|
||||
<VoiceCallSurface />
|
||||
<SidebarFooter
|
||||
theme={theme}
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleHome={handleHome}
|
||||
handleImportSession={handleImportSession}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
handleAddHost={handleAddHost}
|
||||
@@ -974,6 +1061,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
},
|
||||
footerIconButtonDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
voiceWorkspaceListClearance: {
|
||||
height: theme.spacing[12],
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -54,6 +54,11 @@ const html = `<!doctype html>
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
}
|
||||
/* The host sizes this frame to the diagram, so the diagram fills the frame. */
|
||||
#diagram {
|
||||
height: 100%;
|
||||
}
|
||||
#diagram svg {
|
||||
display: block;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
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,104 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Pressable, View, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { Code, Workflow } from "lucide-react-native";
|
||||
import { Code, Maximize2, Workflow } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { ZoomableViewport } from "@/components/zoomable-viewport";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { MarkdownFenceRendererProps } from "../types";
|
||||
import type { MermaidRenderRequest } from "./render-model";
|
||||
import { mermaidRuntimeHtml } from "./runtime/html.gen";
|
||||
import { parseMermaidRuntimeMessage, type MermaidRuntimeRenderMessage } from "./runtime/messages";
|
||||
import { MermaidRuntimeRequestDriver } from "./runtime/request-driver";
|
||||
import { MermaidFullscreenViewer } from "./fullscreen-viewer.web";
|
||||
import { MermaidIframeRuntime, type MermaidRenderedMessage } from "./iframe-runtime.web";
|
||||
import { useMermaidRenderModel } from "./use-render-model";
|
||||
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 {
|
||||
colorScheme?: "light" | "dark";
|
||||
}
|
||||
@@ -118,16 +31,13 @@ function MermaidFenceHostImpl({
|
||||
});
|
||||
const [hasRuntimeContent, setHasRuntimeContent] = useState(false);
|
||||
const [showSource, setShowSource] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const showSourcePress = useCallback(() => setShowSource(true), []);
|
||||
const showDiagramPress = useCallback(() => setShowSource(false), []);
|
||||
const openFullscreen = useCallback(() => setIsFullscreen(true), []);
|
||||
const closeFullscreen = useCallback(() => setIsFullscreen(false), []);
|
||||
const handleRendered = useCallback(
|
||||
(message: {
|
||||
revision: number;
|
||||
source: string;
|
||||
colorScheme: "light" | "dark";
|
||||
height: number;
|
||||
width: number;
|
||||
}) => {
|
||||
(message: MermaidRenderedMessage) => {
|
||||
setHasRuntimeContent(true);
|
||||
rendered({
|
||||
revision: message.revision,
|
||||
@@ -149,8 +59,14 @@ function MermaidFenceHostImpl({
|
||||
label: t("message.diagram.viewSource"),
|
||||
onPress: showSourcePress,
|
||||
},
|
||||
{
|
||||
icon: Maximize2,
|
||||
label: t("message.diagram.fullscreen"),
|
||||
onPress: openFullscreen,
|
||||
testID: "mermaid-fullscreen",
|
||||
},
|
||||
],
|
||||
[showSourcePress, t],
|
||||
[openFullscreen, showSourcePress, t],
|
||||
);
|
||||
const sourceView = useMemo(() => {
|
||||
const { marginTop, marginBottom, marginVertical, ...sourceTextStyle } = textStyle;
|
||||
@@ -162,7 +78,11 @@ function MermaidFenceHostImpl({
|
||||
return { container: [margins, sourceContainerStyle], text };
|
||||
}, [textStyle]);
|
||||
const diagramStyle = useMemo(
|
||||
() => [getDiagramBoxStyle(textStyle), containerStyle, { height: runtimeHeight }],
|
||||
() => [
|
||||
getDiagramBoxStyle(textStyle),
|
||||
containerStyle,
|
||||
{ height: runtimeHeight, minHeight: MIN_DIAGRAM_BOX_HEIGHT },
|
||||
],
|
||||
[runtimeHeight, textStyle],
|
||||
);
|
||||
const diagramSize = useMemo(
|
||||
@@ -212,18 +132,40 @@ function MermaidFenceHostImpl({
|
||||
>
|
||||
<MermaidIframeRuntime
|
||||
request={request}
|
||||
height="100%"
|
||||
onRendered={handleRendered}
|
||||
onRenderFailed={renderFailed}
|
||||
/>
|
||||
</ZoomableViewport>
|
||||
{isFullscreen && visible ? (
|
||||
<MermaidFullscreenViewer
|
||||
source={visible.source}
|
||||
colorScheme={visible.colorScheme}
|
||||
onClose={closeFullscreen}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 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 = {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
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,6 +59,41 @@ let pendingRender: MermaidRuntimeRenderMessage | null = null;
|
||||
let isRendering = 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> {
|
||||
try {
|
||||
initializeMermaid(message.colorScheme);
|
||||
@@ -72,14 +107,18 @@ async function render(message: MermaidRuntimeRenderMessage): Promise<void> {
|
||||
return;
|
||||
}
|
||||
host.innerHTML = svg;
|
||||
const rect = host.querySelector("svg")?.getBoundingClientRect();
|
||||
const element = host.querySelector("svg");
|
||||
if (element) {
|
||||
stretchToFrame(element);
|
||||
}
|
||||
const size = measureDiagram(host, element);
|
||||
sendToHost({
|
||||
type: "rendered",
|
||||
revision: message.revision,
|
||||
source: message.source,
|
||||
colorScheme: message.colorScheme,
|
||||
height: Math.ceil(rect?.height ?? host.scrollHeight),
|
||||
width: Math.ceil(rect?.width ?? host.scrollWidth),
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
});
|
||||
} catch {
|
||||
if (message.revision === latestRevision) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -29,9 +29,13 @@ function waitForRuntimeMessage(
|
||||
});
|
||||
}
|
||||
|
||||
async function mountRuntime(): Promise<HTMLIFrameElement> {
|
||||
async function mountRuntime(size?: { width: number; height: number }): Promise<HTMLIFrameElement> {
|
||||
const frame = document.createElement("iframe");
|
||||
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");
|
||||
frame.srcdoc = mermaidRuntimeHtml;
|
||||
document.body.append(frame);
|
||||
@@ -40,6 +44,13 @@ async function mountRuntime(): Promise<HTMLIFrameElement> {
|
||||
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(
|
||||
frame: HTMLIFrameElement,
|
||||
input: { revision: number; source: string },
|
||||
@@ -85,6 +96,22 @@ describe("Mermaid sandbox runtime", () => {
|
||||
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 () => {
|
||||
const frame = await mountRuntime();
|
||||
const obsoleteSource = `flowchart TD\n${Array.from({ length: 250 }, (_, index) => `A${index} --> A${index + 1}`).join("\n")}`;
|
||||
|
||||
@@ -30,13 +30,9 @@ import type MarkdownIt from "markdown-it";
|
||||
import { type ASTNode, type RenderRules } from "react-native-markdown-display";
|
||||
import MaskedView from "@react-native-masked-view/masked-view";
|
||||
import {
|
||||
Circle,
|
||||
Info,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
FileText,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Check,
|
||||
CheckSquare,
|
||||
CircleDot,
|
||||
@@ -59,7 +55,6 @@ import Animated, {
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { MarkdownRenderer, type MarkdownStyles } from "@/components/markdown/renderer";
|
||||
import type { TaskActivity, TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
||||
@@ -173,6 +168,9 @@ const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
|
||||
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
|
||||
const ThemedChevronRightIcon = withUnistyles(ChevronRight);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const ThemedNotificationInfo = withUnistyles(Info);
|
||||
const ThemedNotificationWarning = withUnistyles(TriangleAlertIcon);
|
||||
const ThemedNotificationError = withUnistyles(XCircle);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -182,6 +180,8 @@ const mutedForegroundColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.mutedForeground,
|
||||
});
|
||||
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
|
||||
const infoColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[300] });
|
||||
const warningColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
|
||||
const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
|
||||
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
|
||||
0% {
|
||||
@@ -2058,41 +2058,28 @@ export const SpeakMessage = memo(function SpeakMessage({
|
||||
);
|
||||
});
|
||||
|
||||
interface ActivityLogProps {
|
||||
type: "system" | "info" | "success" | "error" | "artifact";
|
||||
interface NotificationProps {
|
||||
level: "info" | "warning" | "error";
|
||||
message: string;
|
||||
timestamp: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
artifactId?: string;
|
||||
artifactType?: string;
|
||||
title?: string;
|
||||
onArtifactClick?: (artifactId: string) => void;
|
||||
disableOuterSpacing?: boolean;
|
||||
}
|
||||
|
||||
const activityLogStylesheet = StyleSheet.create((theme) => ({
|
||||
pressable: {
|
||||
const notificationStylesheet = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
borderRadius: theme.borderRadius.md,
|
||||
overflow: "hidden",
|
||||
},
|
||||
pressableSpacing: {
|
||||
containerSpacing: {
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
pressableActive: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
systemBg: {
|
||||
backgroundColor: "rgba(39, 39, 42, 0.5)",
|
||||
},
|
||||
infoBg: {
|
||||
backgroundColor: "rgba(30, 58, 138, 0.3)",
|
||||
backgroundColor: "rgba(147, 197, 253, 0.1)",
|
||||
},
|
||||
successBg: {
|
||||
backgroundColor: "rgba(20, 83, 45, 0.3)",
|
||||
warningBg: {
|
||||
backgroundColor: "rgba(245, 158, 11, 0.1)",
|
||||
},
|
||||
errorBg: {},
|
||||
artifactBg: {
|
||||
backgroundColor: "rgba(30, 58, 138, 0.4)",
|
||||
errorBg: {
|
||||
backgroundColor: "rgba(239, 68, 68, 0.1)",
|
||||
},
|
||||
content: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
@@ -2112,137 +2099,63 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
},
|
||||
messageText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: 20,
|
||||
},
|
||||
detailsRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginTop: theme.spacing[1],
|
||||
},
|
||||
detailsText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
marginRight: theme.spacing[1],
|
||||
},
|
||||
metadataContainer: {
|
||||
marginTop: theme.spacing[2],
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
borderRadius: theme.borderRadius.base,
|
||||
padding: theme.spacing[2],
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
metadataText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.code,
|
||||
fontFamily: theme.fontFamily.mono,
|
||||
lineHeight: 16,
|
||||
},
|
||||
}));
|
||||
|
||||
export const ActivityLog = memo(function ActivityLog({
|
||||
type,
|
||||
export const Notification = memo(function Notification({
|
||||
level,
|
||||
message,
|
||||
timestamp: _timestamp,
|
||||
metadata,
|
||||
artifactId,
|
||||
artifactType,
|
||||
title,
|
||||
onArtifactClick,
|
||||
disableOuterSpacing,
|
||||
}: ActivityLogProps) {
|
||||
const { t } = useTranslation();
|
||||
}: NotificationProps) {
|
||||
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const typeConfig = {
|
||||
system: {
|
||||
bg: activityLogStylesheet.systemBg,
|
||||
color: "#a1a1aa",
|
||||
Icon: Circle,
|
||||
info: {
|
||||
bg: notificationStylesheet.infoBg,
|
||||
iconColorMapping: infoColorMapping,
|
||||
Icon: ThemedNotificationInfo,
|
||||
},
|
||||
info: { bg: activityLogStylesheet.infoBg, color: "#60a5fa", Icon: Info },
|
||||
success: {
|
||||
bg: activityLogStylesheet.successBg,
|
||||
color: "#4ade80",
|
||||
Icon: CheckCircle,
|
||||
warning: {
|
||||
bg: notificationStylesheet.warningBg,
|
||||
iconColorMapping: warningColorMapping,
|
||||
Icon: ThemedNotificationWarning,
|
||||
},
|
||||
error: {
|
||||
bg: activityLogStylesheet.errorBg,
|
||||
color: "#f87171",
|
||||
Icon: XCircle,
|
||||
},
|
||||
artifact: {
|
||||
bg: activityLogStylesheet.artifactBg,
|
||||
color: "#93c5fd",
|
||||
Icon: FileText,
|
||||
bg: notificationStylesheet.errorBg,
|
||||
iconColorMapping: destructiveColorMapping,
|
||||
Icon: ThemedNotificationError,
|
||||
},
|
||||
};
|
||||
|
||||
const config = typeConfig[type];
|
||||
const config = typeConfig[level];
|
||||
const IconComponent = config.Icon;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (type === "artifact" && artifactId && onArtifactClick) {
|
||||
onArtifactClick(artifactId);
|
||||
} else if (metadata) {
|
||||
setIsExpanded((prev) => !prev);
|
||||
}
|
||||
}, [type, artifactId, onArtifactClick, metadata]);
|
||||
|
||||
const displayMessage =
|
||||
type === "artifact" && artifactType && title ? `${artifactType}: ${title}` : message;
|
||||
|
||||
const isInteractive = type === "artifact" || metadata;
|
||||
const pressableStyle = useMemo(
|
||||
const containerStyle = useMemo(
|
||||
() => [
|
||||
activityLogStylesheet.pressable,
|
||||
!resolvedDisableOuterSpacing && activityLogStylesheet.pressableSpacing,
|
||||
notificationStylesheet.container,
|
||||
!resolvedDisableOuterSpacing && notificationStylesheet.containerSpacing,
|
||||
config.bg,
|
||||
isInteractive && activityLogStylesheet.pressableActive,
|
||||
],
|
||||
[resolvedDisableOuterSpacing, config.bg, isInteractive],
|
||||
[resolvedDisableOuterSpacing, config.bg],
|
||||
);
|
||||
const messageTextStyle = useMemo(
|
||||
() => [activityLogStylesheet.messageText, { color: config.color }],
|
||||
[config.color],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable onPress={handlePress} disabled={!isInteractive} style={pressableStyle}>
|
||||
<View style={activityLogStylesheet.content}>
|
||||
<View style={activityLogStylesheet.row}>
|
||||
<View style={activityLogStylesheet.iconContainer}>
|
||||
<IconComponent size={16} color={config.color} />
|
||||
<View style={containerStyle}>
|
||||
<View style={notificationStylesheet.content}>
|
||||
<View style={notificationStylesheet.row}>
|
||||
<View style={notificationStylesheet.iconContainer}>
|
||||
<IconComponent size={16} uniProps={config.iconColorMapping} />
|
||||
</View>
|
||||
<View style={activityLogStylesheet.textContainer}>
|
||||
<Text style={messageTextStyle} selectable>
|
||||
{displayMessage}
|
||||
<View style={notificationStylesheet.textContainer}>
|
||||
<Text style={notificationStylesheet.messageText} selectable>
|
||||
{message}
|
||||
</Text>
|
||||
{metadata && (
|
||||
<View style={activityLogStylesheet.detailsRow}>
|
||||
<Text style={activityLogStylesheet.detailsText}>
|
||||
{t("message.activity.details")}
|
||||
</Text>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={12} color="#71717a" />
|
||||
) : (
|
||||
<ChevronRight size={12} color="#71717a" />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{isExpanded && metadata && (
|
||||
<View style={activityLogStylesheet.metadataContainer} dataSet={CODE_SURFACE_DATASET}>
|
||||
<Text style={activityLogStylesheet.metadataText}>
|
||||
{JSON.stringify(metadata, null, 2)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ interface ModelBrowserInput {
|
||||
}
|
||||
|
||||
export interface ModelBrowserState {
|
||||
serverId: string | null;
|
||||
providers: ProviderSelectorProvider[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
@@ -192,6 +193,7 @@ interface ModelBrowserProps {
|
||||
}
|
||||
|
||||
interface ModelBrowserContentProps extends Omit<ModelBrowserProps, "state" | "scrolling"> {
|
||||
serverId: string | null;
|
||||
view: ModelBrowserView;
|
||||
providers: ProviderSelectorProvider[];
|
||||
selectedProvider: string;
|
||||
@@ -209,14 +211,16 @@ type ProviderGlyphTone = "muted" | "foreground";
|
||||
|
||||
export function ModelProviderGlyph({
|
||||
provider,
|
||||
serverId,
|
||||
size,
|
||||
tone = "muted",
|
||||
}: {
|
||||
provider: string;
|
||||
serverId?: string | null;
|
||||
size: number;
|
||||
tone?: ProviderGlyphTone;
|
||||
}) {
|
||||
const Icon = getProviderIcon(provider);
|
||||
const Icon = getProviderIcon(provider, serverId);
|
||||
const color =
|
||||
tone === "foreground" ? styles.providerIconForeground.color : styles.providerIconMuted.color;
|
||||
return <Icon size={size} color={color} />;
|
||||
@@ -330,7 +334,12 @@ export function useModelBrowser({
|
||||
return {
|
||||
title: view.providerLabel,
|
||||
leading: (
|
||||
<ModelProviderGlyph provider={view.providerId} size={ICON_SIZE.md} tone="foreground" />
|
||||
<ModelProviderGlyph
|
||||
provider={view.providerId}
|
||||
serverId={serverId}
|
||||
size={ICON_SIZE.md}
|
||||
tone="foreground"
|
||||
/>
|
||||
),
|
||||
back: singleProviderView ? undefined : { onPress: showAll },
|
||||
actions: (
|
||||
@@ -389,6 +398,7 @@ export function useModelBrowser({
|
||||
);
|
||||
|
||||
return {
|
||||
serverId,
|
||||
providers,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
@@ -652,6 +662,7 @@ function ModelRowProfileAction({
|
||||
|
||||
function ModelRow({
|
||||
row,
|
||||
serverId,
|
||||
isSelected,
|
||||
showProviderLabel = false,
|
||||
onPress,
|
||||
@@ -661,6 +672,7 @@ function ModelRow({
|
||||
onEditProfiles,
|
||||
}: {
|
||||
row: ProviderSelectionModelRow;
|
||||
serverId: string | null;
|
||||
isSelected: boolean;
|
||||
showProviderLabel?: boolean;
|
||||
onPress: () => void;
|
||||
@@ -672,8 +684,8 @@ function ModelRow({
|
||||
const { t } = useTranslation();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const leadingSlot = useMemo(
|
||||
() => <ModelProviderGlyph provider={row.provider} size={ICON_SIZE.sm} />,
|
||||
[row.provider],
|
||||
() => <ModelProviderGlyph provider={row.provider} serverId={serverId} size={ICON_SIZE.sm} />,
|
||||
[row.provider, serverId],
|
||||
);
|
||||
|
||||
const description = showProviderLabel ? buildProviderQualifiedDescription(row) : row.description;
|
||||
@@ -810,6 +822,7 @@ function ModelRow({
|
||||
|
||||
function SelectableModelRow({
|
||||
row,
|
||||
serverId,
|
||||
isSelected,
|
||||
showProviderLabel,
|
||||
onSelect,
|
||||
@@ -819,6 +832,7 @@ function SelectableModelRow({
|
||||
onEditProfiles,
|
||||
}: {
|
||||
row: ProviderSelectionModelRow;
|
||||
serverId: string | null;
|
||||
isSelected: boolean;
|
||||
showProviderLabel?: boolean;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
@@ -833,6 +847,7 @@ function SelectableModelRow({
|
||||
return (
|
||||
<ModelRow
|
||||
row={row}
|
||||
serverId={serverId}
|
||||
isSelected={isSelected}
|
||||
showProviderLabel={showProviderLabel}
|
||||
onPress={handlePress}
|
||||
@@ -948,9 +963,11 @@ function AgentProfilesPickerContent({
|
||||
|
||||
function GroupProviderButton({
|
||||
provider,
|
||||
serverId,
|
||||
onDrillDown,
|
||||
}: {
|
||||
provider: ProviderSelectorProvider;
|
||||
serverId: string | null;
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -988,8 +1005,8 @@ function GroupProviderButton({
|
||||
);
|
||||
}, [selection, t]);
|
||||
const leadingSlot = useMemo(
|
||||
() => <ModelProviderGlyph provider={provider.id} size={ICON_SIZE.sm} />,
|
||||
[provider.id],
|
||||
() => <ModelProviderGlyph provider={provider.id} serverId={serverId} size={ICON_SIZE.sm} />,
|
||||
[provider.id, serverId],
|
||||
);
|
||||
const trailingSlot = useMemo(
|
||||
() => (
|
||||
@@ -1016,9 +1033,11 @@ function GroupProviderButton({
|
||||
|
||||
function GroupedProviderRows({
|
||||
providers,
|
||||
serverId,
|
||||
onDrillDown,
|
||||
}: {
|
||||
providers: ProviderSelectorProvider[];
|
||||
serverId: string | null;
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -1026,7 +1045,7 @@ function GroupedProviderRows({
|
||||
{providers.map((provider, index) => (
|
||||
<View key={provider.id}>
|
||||
{index > 0 ? <View style={styles.separator} /> : null}
|
||||
<GroupProviderButton provider={provider} onDrillDown={onDrillDown} />
|
||||
<GroupProviderButton provider={provider} serverId={serverId} onDrillDown={onDrillDown} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
@@ -1111,6 +1130,7 @@ function IndependentProviderList({ children }: { children: React.ReactNode }) {
|
||||
|
||||
function ModelRowList({
|
||||
rows,
|
||||
serverId,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
onSelect,
|
||||
@@ -1123,6 +1143,7 @@ function ModelRowList({
|
||||
onEditProfiles,
|
||||
}: {
|
||||
rows: ProviderSelectionModelRow[];
|
||||
serverId: string | null;
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
onSelect: (provider: string, modelId: string) => void;
|
||||
@@ -1139,6 +1160,7 @@ function ModelRowList({
|
||||
({ item }: { item: ProviderSelectionModelRow }) => (
|
||||
<SelectableModelRow
|
||||
row={item}
|
||||
serverId={serverId}
|
||||
isSelected={item.provider === selectedProvider && item.modelId === selectedModel}
|
||||
showProviderLabel={showProviderLabel}
|
||||
onSelect={onSelect}
|
||||
@@ -1156,6 +1178,7 @@ function ModelRowList({
|
||||
profiledLookup,
|
||||
selectedModel,
|
||||
selectedProvider,
|
||||
serverId,
|
||||
showProviderLabel,
|
||||
],
|
||||
);
|
||||
@@ -1229,6 +1252,7 @@ function ModelSearchEmptyState() {
|
||||
}
|
||||
|
||||
function ProviderModelBrowserContent({
|
||||
serverId,
|
||||
view,
|
||||
provider,
|
||||
profiles,
|
||||
@@ -1246,6 +1270,7 @@ function ProviderModelBrowserContent({
|
||||
isRetryingProvider,
|
||||
scrolling,
|
||||
}: {
|
||||
serverId: string | null;
|
||||
view: Extract<ModelBrowserView, { kind: "provider" }>;
|
||||
provider: ProviderSelectorProvider | null;
|
||||
profiles: AgentProfilePicker | null;
|
||||
@@ -1318,6 +1343,7 @@ function ProviderModelBrowserContent({
|
||||
}
|
||||
return (
|
||||
<ModelRowList
|
||||
serverId={serverId}
|
||||
rows={visibleRows}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
@@ -1333,6 +1359,7 @@ function ProviderModelBrowserContent({
|
||||
}
|
||||
|
||||
function ModelBrowserContent({
|
||||
serverId,
|
||||
view,
|
||||
providers,
|
||||
selectedProvider,
|
||||
@@ -1380,6 +1407,7 @@ function ModelBrowserContent({
|
||||
if (view.kind === "provider") {
|
||||
return (
|
||||
<ProviderModelBrowserContent
|
||||
serverId={serverId}
|
||||
view={view}
|
||||
provider={selectedViewProvider}
|
||||
profiles={profiles}
|
||||
@@ -1414,6 +1442,7 @@ function ModelBrowserContent({
|
||||
if (allView.kind === "searchResults") {
|
||||
return (
|
||||
<ModelRowList
|
||||
serverId={serverId}
|
||||
rows={allView.rows}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedModel={selectedModel}
|
||||
@@ -1445,7 +1474,11 @@ function ModelBrowserContent({
|
||||
<Text style={styles.sectionHeadingText}>{t("modelSelector.providers")}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<GroupedProviderRows providers={providers} onDrillDown={onDrillDown} />
|
||||
<GroupedProviderRows
|
||||
providers={providers}
|
||||
serverId={serverId}
|
||||
onDrillDown={onDrillDown}
|
||||
/>
|
||||
</View>
|
||||
) : null)}
|
||||
{!hasResults ? <ModelSearchEmptyState /> : null}
|
||||
@@ -1487,6 +1520,7 @@ export function ModelBrowser({
|
||||
}: ModelBrowserProps) {
|
||||
return (
|
||||
<ModelBrowserContent
|
||||
serverId={state.serverId}
|
||||
view={state.view}
|
||||
providers={state.providers}
|
||||
selectedProvider={state.selectedProvider}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
TERMINAL_PROFILE_ICON_NAMES,
|
||||
} from "@getpaseo/protocol/provider-icon-names";
|
||||
import { ACP_PROVIDER_CATALOG } from "@/data/acp-provider-catalog";
|
||||
import { resolveProviderIconName } from "./provider-icon-name";
|
||||
import { replaceProviderSnapshotIcons, resolveProviderIconName } from "./provider-icon-name";
|
||||
|
||||
describe("resolveProviderIconName", () => {
|
||||
it("returns the built-in identifier for known provider ids", () => {
|
||||
@@ -18,12 +18,55 @@ describe("resolveProviderIconName", () => {
|
||||
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("gemini")).toEqual({ kind: "catalog", id: "gemini" });
|
||||
expect(resolveProviderIconName("gjc")).toEqual({ kind: "catalog", id: "gjc" });
|
||||
expect(resolveProviderIconName("traecli")).toEqual({ kind: "catalog", id: "traecli" });
|
||||
});
|
||||
|
||||
it("falls back to the bot icon for unknown custom providers", () => {
|
||||
expect(resolveProviderIconName("custom-claude-profile")).toEqual({ kind: "bot" });
|
||||
});
|
||||
|
||||
it("resolves a snapshot SVG for a custom provider", () => {
|
||||
const svg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
|
||||
|
||||
replaceProviderSnapshotIcons("server-1", [{ provider: "snapshot-provider", iconSvg: svg }]);
|
||||
|
||||
expect(resolveProviderIconName("snapshot-provider", "server-1")).toEqual({ kind: "svg", svg });
|
||||
});
|
||||
|
||||
it("replaces each host snapshot without leaking icons across hosts", () => {
|
||||
const secondSvg = "<svg id='second' />";
|
||||
replaceProviderSnapshotIcons("server-1", [
|
||||
{ provider: "removed-provider", iconSvg: "<svg />" },
|
||||
{ provider: "snapshot-provider", iconSvg: "<svg />" },
|
||||
{ provider: "claude", iconSvg: "<svg />" },
|
||||
]);
|
||||
replaceProviderSnapshotIcons("server-2", [
|
||||
{ provider: "snapshot-provider", iconSvg: secondSvg },
|
||||
]);
|
||||
|
||||
expect(resolveProviderIconName("snapshot-provider", "server-1")).toEqual({
|
||||
kind: "svg",
|
||||
svg: "<svg />",
|
||||
});
|
||||
expect(resolveProviderIconName("snapshot-provider", "server-2")).toEqual({
|
||||
kind: "svg",
|
||||
svg: secondSvg,
|
||||
});
|
||||
expect(resolveProviderIconName("claude", "server-1")).toEqual({
|
||||
kind: "builtin",
|
||||
id: "claude",
|
||||
});
|
||||
|
||||
replaceProviderSnapshotIcons("server-1", [{ provider: "snapshot-provider" }]);
|
||||
|
||||
expect(resolveProviderIconName("removed-provider", "server-1")).toEqual({ kind: "bot" });
|
||||
expect(resolveProviderIconName("snapshot-provider", "server-1")).toEqual({ kind: "bot" });
|
||||
expect(resolveProviderIconName("snapshot-provider", "server-2")).toEqual({
|
||||
kind: "svg",
|
||||
svg: secondSvg,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("known provider icon names", () => {
|
||||
|
||||
@@ -2,19 +2,44 @@ import {
|
||||
BUILTIN_PROVIDER_ICON_NAMES,
|
||||
KNOWN_PROVIDER_ICON_NAMES,
|
||||
} from "@getpaseo/protocol/provider-icon-names";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export type ProviderIconName =
|
||||
| { kind: "builtin"; id: string }
|
||||
| { kind: "catalog"; id: string }
|
||||
| { kind: "svg"; svg: string }
|
||||
| { kind: "bot" };
|
||||
|
||||
const BUILTIN_PROVIDER_IDS = new Set(BUILTIN_PROVIDER_ICON_NAMES);
|
||||
const KNOWN_PROVIDER_IDS = new Set(KNOWN_PROVIDER_ICON_NAMES);
|
||||
const providerSnapshotIconSvgsByServer = new Map<string, ReadonlyMap<string, string>>();
|
||||
|
||||
export function resolveProviderIconName(provider: string): ProviderIconName {
|
||||
export function replaceProviderSnapshotIcons(
|
||||
serverId: string,
|
||||
entries: readonly Pick<ProviderSnapshotEntry, "provider" | "iconSvg">[],
|
||||
): void {
|
||||
const icons = new Map<string, string>();
|
||||
for (const entry of entries) {
|
||||
if (entry.iconSvg) {
|
||||
icons.set(entry.provider, entry.iconSvg);
|
||||
}
|
||||
}
|
||||
providerSnapshotIconSvgsByServer.set(serverId, icons);
|
||||
}
|
||||
|
||||
export function resolveProviderIconName(
|
||||
provider: string,
|
||||
serverId?: string | null,
|
||||
): ProviderIconName {
|
||||
if (BUILTIN_PROVIDER_IDS.has(provider)) {
|
||||
return { kind: "builtin", id: provider };
|
||||
}
|
||||
const iconSvg = serverId
|
||||
? providerSnapshotIconSvgsByServer.get(serverId)?.get(provider)
|
||||
: undefined;
|
||||
if (iconSvg) {
|
||||
return { kind: "svg", svg: iconSvg };
|
||||
}
|
||||
if (KNOWN_PROVIDER_IDS.has(provider)) {
|
||||
return { kind: "catalog", id: provider };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Bot } from "lucide-react-native";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { replaceProviderSnapshotIcons } from "./provider-icon-name";
|
||||
import { getProviderIcon, type ProviderIconComponent } from "./provider-icons";
|
||||
|
||||
function renderIcon(Component: ProviderIconComponent) {
|
||||
if (typeof Component !== "function") throw new Error("Expected a function component");
|
||||
return (Component as (props: { size: number; color: string }) => unknown)({
|
||||
size: 18,
|
||||
color: "#123456",
|
||||
});
|
||||
}
|
||||
|
||||
describe("getProviderIcon", () => {
|
||||
it("renders registered snapshot SVG metadata with the requested size and color", () => {
|
||||
const svg = '<svg viewBox="0 0 24 24"><path d="M4 4h16v16H4z" /></svg>';
|
||||
replaceProviderSnapshotIcons("server-1", [{ provider: "rendered-provider", iconSvg: svg }]);
|
||||
|
||||
const rendered = renderIcon(getProviderIcon("rendered-provider", "server-1"));
|
||||
|
||||
expect(rendered).toMatchObject({
|
||||
type: SvgXml,
|
||||
props: { xml: svg, width: 18, height: 18, color: "#123456" },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the normal Bot fallback without snapshot SVG metadata", () => {
|
||||
replaceProviderSnapshotIcons("server-1", [{ provider: "plain-provider" }]);
|
||||
|
||||
expect(getProviderIcon("plain-provider", "server-1")).toBe(Bot);
|
||||
});
|
||||
});
|
||||
@@ -34,17 +34,18 @@ const CATALOG_ICON_SVGS = new Map(
|
||||
);
|
||||
|
||||
const catalogIconComponents = new Map<string, ProviderIconComponent>();
|
||||
const snapshotIconComponents = new Map<string, { svg: string; component: ProviderIconComponent }>();
|
||||
|
||||
function createCatalogIcon(provider: string, iconSvg: string): ProviderIconComponent {
|
||||
const CatalogProviderIcon: ProviderIconComponent = ({ size, color }) =>
|
||||
function createSvgIcon(provider: string, iconSvg: string): ProviderIconComponent {
|
||||
const SvgProviderIcon: ProviderIconComponent = ({ size, color }) =>
|
||||
createElement(SvgXml, {
|
||||
xml: iconSvg,
|
||||
width: size,
|
||||
height: size,
|
||||
color,
|
||||
});
|
||||
CatalogProviderIcon.displayName = `CatalogProviderIcon(${provider})`;
|
||||
return CatalogProviderIcon;
|
||||
SvgProviderIcon.displayName = `SvgProviderIcon(${provider})`;
|
||||
return SvgProviderIcon;
|
||||
}
|
||||
|
||||
function getCatalogProviderIcon(provider: string): ProviderIconComponent {
|
||||
@@ -56,18 +57,29 @@ function getCatalogProviderIcon(provider: string): ProviderIconComponent {
|
||||
if (!iconSvg) {
|
||||
return Bot;
|
||||
}
|
||||
const icon = createCatalogIcon(provider, iconSvg);
|
||||
const icon = createSvgIcon(provider, iconSvg);
|
||||
catalogIconComponents.set(provider, icon);
|
||||
return icon;
|
||||
}
|
||||
|
||||
export function getProviderIcon(provider: string): ProviderIconComponent {
|
||||
const name = resolveProviderIconName(provider);
|
||||
function getSnapshotProviderIcon(provider: string, svg: string): ProviderIconComponent {
|
||||
const cached = snapshotIconComponents.get(provider);
|
||||
if (cached?.svg === svg) return cached.component;
|
||||
const component = createSvgIcon(provider, svg);
|
||||
snapshotIconComponents.set(provider, { svg, component });
|
||||
return component;
|
||||
}
|
||||
|
||||
export function getProviderIcon(provider: string, serverId?: string | null): ProviderIconComponent {
|
||||
const name = resolveProviderIconName(provider, serverId);
|
||||
if (name.kind === "builtin") {
|
||||
return BUILTIN_PROVIDER_ICONS[name.id];
|
||||
}
|
||||
if (name.kind === "catalog") {
|
||||
return getCatalogProviderIcon(name.id);
|
||||
}
|
||||
if (name.kind === "svg") {
|
||||
return getSnapshotProviderIcon(`${serverId}:${provider}`, name.svg);
|
||||
}
|
||||
return Bot;
|
||||
}
|
||||
|
||||
@@ -3,24 +3,26 @@ import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { Mic, MicOff, Square } from "lucide-react-native";
|
||||
import { FOOTER_HEIGHT } from "@/constants/layout";
|
||||
import { MessageCircle, Mic, MicOff, Square } from "lucide-react-native";
|
||||
import { useVoiceTelemetry } from "@/contexts/voice-context";
|
||||
import { VolumeMeter } from "./volume-meter";
|
||||
|
||||
interface RealtimeVoiceOverlayProps {
|
||||
isMuted: boolean;
|
||||
isSwitching: boolean;
|
||||
isTranscriptOpen: boolean;
|
||||
onToggleTranscript: () => void;
|
||||
onToggleMute: () => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
const OVERLAY_BUTTON_SIZE = 44;
|
||||
const OVERLAY_VERTICAL_PADDING = (FOOTER_HEIGHT - OVERLAY_BUTTON_SIZE) / 2;
|
||||
const OVERLAY_BUTTON_SIZE = 28;
|
||||
|
||||
export function RealtimeVoiceOverlay({
|
||||
isMuted,
|
||||
isSwitching,
|
||||
isTranscriptOpen,
|
||||
onToggleTranscript,
|
||||
onToggleMute,
|
||||
onStop,
|
||||
}: RealtimeVoiceOverlayProps) {
|
||||
@@ -42,12 +44,26 @@ export function RealtimeVoiceOverlay({
|
||||
);
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Pressable
|
||||
onPress={onToggleTranscript}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={isTranscriptOpen ? "Hide voice transcript" : "Show voice transcript"}
|
||||
style={[
|
||||
styles.actionButton,
|
||||
styles.transcriptButton,
|
||||
isTranscriptOpen && styles.transcriptButtonOpen,
|
||||
]}
|
||||
>
|
||||
<MessageCircle size={theme.iconSize.md} color={theme.colors.foreground} strokeWidth={2.5} />
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.meterContainer}>
|
||||
<VolumeMeter
|
||||
volume={volume}
|
||||
isMuted={isMuted}
|
||||
isSpeaking={isSpeaking}
|
||||
orientation="horizontal"
|
||||
variant="compact"
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -62,9 +78,9 @@ export function RealtimeVoiceOverlay({
|
||||
style={muteButtonStyle}
|
||||
>
|
||||
{isMuted ? (
|
||||
<MicOff size={theme.iconSize.lg} color={theme.colors.palette.white} strokeWidth={2.5} />
|
||||
<MicOff size={theme.iconSize.md} color={theme.colors.palette.white} strokeWidth={2.5} />
|
||||
) : (
|
||||
<Mic size={theme.iconSize.lg} color={theme.colors.foreground} strokeWidth={2.5} />
|
||||
<Mic size={theme.iconSize.md} color={theme.colors.foreground} strokeWidth={2.5} />
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
@@ -79,7 +95,7 @@ export function RealtimeVoiceOverlay({
|
||||
<LoadingSpinner size="small" color={theme.colors.palette.white} />
|
||||
) : (
|
||||
<Square
|
||||
size={theme.iconSize.lg}
|
||||
size={theme.iconSize.md}
|
||||
color={theme.colors.palette.white}
|
||||
fill={theme.colors.palette.white}
|
||||
strokeWidth={2.5}
|
||||
@@ -96,24 +112,25 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
height: FOOTER_HEIGHT,
|
||||
borderRadius: theme.borderRadius["2xl"],
|
||||
minHeight: 40,
|
||||
borderRadius: theme.borderRadius.xl,
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: OVERLAY_VERTICAL_PADDING,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
meterContainer: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
actionsContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
actionButton: {
|
||||
width: OVERLAY_BUTTON_SIZE,
|
||||
@@ -127,6 +144,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
transcriptButton: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
transcriptButtonOpen: {
|
||||
backgroundColor: theme.colors.interactionHighlight,
|
||||
},
|
||||
muteButtonMuted: {
|
||||
backgroundColor: theme.colors.palette.red[600],
|
||||
borderColor: theme.colors.palette.red[800],
|
||||
|
||||
@@ -661,8 +661,8 @@ function ScheduleTargetFields({
|
||||
[],
|
||||
);
|
||||
const modelTriggerLeading = useMemo(
|
||||
() => <ProviderGlyph provider={state.selectedProvider} />,
|
||||
[state.selectedProvider],
|
||||
() => <ProviderGlyph provider={state.selectedProvider} serverId={state.selectedServerId} />,
|
||||
[state.selectedProvider, state.selectedServerId],
|
||||
);
|
||||
const renderModelTrigger = useCallback(
|
||||
({
|
||||
@@ -1019,11 +1019,17 @@ function ThinkingOptionItem({
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null {
|
||||
function ProviderGlyph({
|
||||
provider,
|
||||
serverId,
|
||||
}: {
|
||||
provider: string | null;
|
||||
serverId: string | null;
|
||||
}): ReactElement | null {
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
const Icon = getProviderIcon(provider);
|
||||
const Icon = getProviderIcon(provider, serverId);
|
||||
return <Icon size={16} color={styles.providerIcon.color} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface ScheduleRowActions {
|
||||
}
|
||||
|
||||
interface ScheduleRowProps extends ScheduleRowActions {
|
||||
serverId: string;
|
||||
schedule: ScheduleSummary;
|
||||
/** Client-derived target line (agent title / project / shortened path). */
|
||||
targetLabel: string;
|
||||
@@ -120,11 +121,17 @@ function buildMeta(
|
||||
|
||||
/** Small provider glyph. Reads the icon color off a StyleSheet object so the
|
||||
* dynamic component (getProviderIcon) stays compliant without useUnistyles. */
|
||||
function ProviderGlyph({ provider }: { provider: string | null }): ReactElement | null {
|
||||
function ProviderGlyph({
|
||||
provider,
|
||||
serverId,
|
||||
}: {
|
||||
provider: string | null;
|
||||
serverId: string;
|
||||
}): ReactElement | null {
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
const Icon = getProviderIcon(provider);
|
||||
const Icon = getProviderIcon(provider, serverId);
|
||||
return <Icon size={PROVIDER_ICON_SIZE} color={styles.providerIcon.color} />;
|
||||
}
|
||||
|
||||
@@ -138,6 +145,7 @@ function ProviderGlyph({ provider }: { provider: string | null }): ReactElement
|
||||
* highlights without reflow.
|
||||
*/
|
||||
export function ScheduleRow({
|
||||
serverId,
|
||||
schedule,
|
||||
targetLabel,
|
||||
provider,
|
||||
@@ -189,7 +197,7 @@ export function ScheduleRow({
|
||||
>
|
||||
<View style={styles.main}>
|
||||
<View style={styles.leading}>
|
||||
<ProviderGlyph provider={provider} />
|
||||
<ProviderGlyph provider={provider} serverId={serverId} />
|
||||
</View>
|
||||
<View style={styles.textGroup}>
|
||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||
|
||||
@@ -129,6 +129,7 @@ function SchedulesTableRow({
|
||||
|
||||
return (
|
||||
<ScheduleRow
|
||||
serverId={row.schedule.serverId}
|
||||
schedule={schedule}
|
||||
targetLabel={row.targetLabel}
|
||||
provider={row.provider}
|
||||
|
||||
@@ -228,6 +228,7 @@ interface SidebarWorkspaceListProps {
|
||||
onRefresh?: () => void;
|
||||
onWorkspacePress?: () => void;
|
||||
onAddProject?: () => void;
|
||||
onImportSession?: () => void;
|
||||
listFooterComponent?: ReactElement | null;
|
||||
// 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.
|
||||
@@ -1879,6 +1880,7 @@ export function SidebarWorkspaceList({
|
||||
onRefresh: _onRefresh,
|
||||
onWorkspacePress,
|
||||
onAddProject,
|
||||
onImportSession,
|
||||
listFooterComponent,
|
||||
listHeaderComponent,
|
||||
parentGestureRef,
|
||||
@@ -1972,6 +1974,7 @@ export function SidebarWorkspaceList({
|
||||
shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onAddProject={onAddProject}
|
||||
onImportSession={onImportSession}
|
||||
listFooterComponent={listFooterComponent}
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||
@@ -2067,6 +2070,7 @@ function ProjectModeList({
|
||||
shortcutIndexByWorkspaceKey,
|
||||
onWorkspacePress,
|
||||
onAddProject,
|
||||
onImportSession,
|
||||
listFooterComponent,
|
||||
listHeaderComponent,
|
||||
sidebarFilterEmpty,
|
||||
@@ -2375,7 +2379,7 @@ function ProjectModeList({
|
||||
|
||||
const projectBody =
|
||||
projects.length === 0 ? (
|
||||
<SidebarProjectEmptyState onAddProject={onAddProject} />
|
||||
<SidebarProjectEmptyState onAddProject={onAddProject} onImportSession={onImportSession} />
|
||||
) : (
|
||||
<DraggableList
|
||||
testID="sidebar-project-list"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import { Plus } from "lucide-react-native";
|
||||
import { Import, Plus } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
@@ -37,7 +37,13 @@ export function SidebarFilterEmptyState() {
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarProjectEmptyState({ onAddProject }: { onAddProject?: () => void }) {
|
||||
export function SidebarProjectEmptyState({
|
||||
onAddProject,
|
||||
onImportSession,
|
||||
}: {
|
||||
onAddProject?: () => void;
|
||||
onImportSession?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -47,6 +53,9 @@ export function SidebarProjectEmptyState({ onAddProject }: { onAddProject?: () =
|
||||
<Button variant="ghost" size="sm" leftIcon={Plus} onPress={onAddProject}>
|
||||
{t("sidebar.actions.addProject")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" leftIcon={Import} onPress={onImportSession}>
|
||||
{t("importSession.title")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface ToastApi {
|
||||
type ToastViewportPlacement = "app-shell" | "panel";
|
||||
|
||||
const DEFAULT_DURATION_MS = 2200;
|
||||
const TOAST_MAX_WIDTH = 480;
|
||||
|
||||
export function useToastHost(): {
|
||||
api: ToastApi;
|
||||
@@ -265,24 +266,26 @@ export function ToastViewport({
|
||||
|
||||
const content = (
|
||||
<View style={styles.container} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
testID={toast.testID ?? "app-toast"}
|
||||
onPointerEnter={isWeb ? pauseDismiss : undefined}
|
||||
onPointerLeave={isWeb ? resumeDismiss : undefined}
|
||||
style={toastAnimatedStyle}
|
||||
accessibilityRole="alert"
|
||||
>
|
||||
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
|
||||
{typeof toast.content === "string" ? (
|
||||
<Text testID="app-toast-message" style={toastMessageStyle}>
|
||||
{toast.content}
|
||||
</Text>
|
||||
) : (
|
||||
<View testID="app-toast-message" style={styles.contentSlot}>
|
||||
{toast.content}
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
<View style={styles.widthBoundary} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
testID={toast.testID ?? "app-toast"}
|
||||
onPointerEnter={isWeb ? pauseDismiss : undefined}
|
||||
onPointerLeave={isWeb ? resumeDismiss : undefined}
|
||||
style={toastAnimatedStyle}
|
||||
accessibilityRole="alert"
|
||||
>
|
||||
{icon ? <View style={styles.iconSlot}>{icon}</View> : null}
|
||||
{typeof toast.content === "string" ? (
|
||||
<Text testID="app-toast-message" style={toastMessageStyle}>
|
||||
{toast.content}
|
||||
</Text>
|
||||
) : (
|
||||
<View testID="app-toast-message" style={styles.contentSlot}>
|
||||
{toast.content}
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -302,9 +305,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
zIndex: OVERLAY_Z.toast,
|
||||
alignItems: "center",
|
||||
},
|
||||
widthBoundary: {
|
||||
width: "92%",
|
||||
maxWidth: TOAST_MAX_WIDTH,
|
||||
alignItems: "center",
|
||||
},
|
||||
toast: {
|
||||
alignSelf: "center",
|
||||
maxWidth: "92%",
|
||||
maxWidth: "100%",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import { useEffect, useMemo, useState, type ReactElement, type RefObject } from "react";
|
||||
import { Keyboard, View, useWindowDimensions } from "react-native";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { View, useWindowDimensions } from "react-native";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import Animated, {
|
||||
useAnimatedReaction,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Autocomplete, type AutocompleteOption } from "@/components/ui/autocomplete";
|
||||
@@ -66,57 +79,57 @@ export function AutocompletePopover({
|
||||
const windowDimensions = useWindowDimensions();
|
||||
const safeAreaInsets = useSafeAreaInsets();
|
||||
const portalHostName = useFloatingPanelPortalHostName();
|
||||
const { shift } = useKeyboardShift();
|
||||
const { shift, isMoving } = useKeyboardShift();
|
||||
const measuredShift = useSharedValue(0);
|
||||
const measurementGeneration = useRef(0);
|
||||
const canMeasure = visible && (options.length === 0 || selectedIndex >= 0);
|
||||
|
||||
const remeasure = useCallback(() => {
|
||||
if (!canMeasure) return;
|
||||
const anchorElement = anchorRef.current;
|
||||
if (!anchorElement) return;
|
||||
const generation = measurementGeneration.current;
|
||||
void Promise.all([
|
||||
measureElement(anchorElement),
|
||||
measureFloatingPanelPortalHost(portalHostName),
|
||||
]).then(([anchorRect, hostRect]) => {
|
||||
if (generation !== measurementGeneration.current || !hostRect) return undefined;
|
||||
setRelativeAnchorRect({
|
||||
x: anchorRect.x - hostRect.x,
|
||||
y: anchorRect.y - hostRect.y,
|
||||
width: anchorRect.width,
|
||||
hostHeight: hostRect.height,
|
||||
});
|
||||
measuredShift.value = shift.value;
|
||||
return undefined;
|
||||
});
|
||||
}, [anchorRef, canMeasure, measuredShift, portalHostName, shift]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || (options.length > 0 && selectedIndex < 0)) {
|
||||
measurementGeneration.current += 1;
|
||||
if (!canMeasure) {
|
||||
setRelativeAnchorRect(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const remeasure = () => {
|
||||
const anchorElement = anchorRef.current;
|
||||
if (!anchorElement) return;
|
||||
void Promise.all([
|
||||
measureElement(anchorElement),
|
||||
measureFloatingPanelPortalHost(portalHostName),
|
||||
]).then(([anchorRect, hostRect]) => {
|
||||
if (cancelled || !hostRect) return undefined;
|
||||
setRelativeAnchorRect({
|
||||
x: anchorRect.x - hostRect.x,
|
||||
y: anchorRect.y - hostRect.y,
|
||||
width: anchorRect.width,
|
||||
hostHeight: hostRect.height,
|
||||
});
|
||||
measuredShift.value = shift.value;
|
||||
return undefined;
|
||||
});
|
||||
};
|
||||
|
||||
remeasure();
|
||||
const raf = requestAnimationFrame(remeasure);
|
||||
const subscriptions = (["keyboardDidShow", "keyboardDidHide"] as const).map((event) =>
|
||||
Keyboard.addListener(event, () => requestAnimationFrame(remeasure)),
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
measurementGeneration.current += 1;
|
||||
cancelAnimationFrame(raf);
|
||||
for (const sub of subscriptions) sub.remove();
|
||||
};
|
||||
}, [
|
||||
visible,
|
||||
options.length,
|
||||
selectedIndex,
|
||||
anchorRef,
|
||||
portalHostName,
|
||||
measuredShift,
|
||||
shift,
|
||||
windowDimensions.width,
|
||||
windowDimensions.height,
|
||||
]);
|
||||
}, [canMeasure, remeasure, windowDimensions.width, windowDimensions.height]);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => isMoving.value,
|
||||
(moving, wasMoving) => {
|
||||
if (wasMoving && !moving) {
|
||||
scheduleOnRN(remeasure);
|
||||
}
|
||||
},
|
||||
[isMoving, remeasure],
|
||||
);
|
||||
|
||||
const baseStyle = useMemo(() => {
|
||||
if (!relativeAnchorRect) return null;
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay";
|
||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
||||
import type { VoiceCallEvent } from "@getpaseo/protocol/messages";
|
||||
|
||||
function VoiceCallEventRow({ event }: { event: VoiceCallEvent }) {
|
||||
switch (event.type) {
|
||||
case "transcript":
|
||||
return (
|
||||
<Text style={styles.eventText}>
|
||||
<Text style={styles.eventLabel}>{event.speaker === "user" ? "You:" : "Paseo:"} </Text>
|
||||
{event.text}
|
||||
</Text>
|
||||
);
|
||||
case "activity":
|
||||
return (
|
||||
<View style={styles.activityRow}>
|
||||
<Text style={styles.activityLabel}>{event.label}</Text>
|
||||
<Text style={styles.activityState}>{event.state}</Text>
|
||||
</View>
|
||||
);
|
||||
case "notice":
|
||||
return <Text style={styles.notice}>{event.text}</Text>;
|
||||
case "error":
|
||||
return <Text style={styles.error}>{event.message}</Text>;
|
||||
}
|
||||
}
|
||||
|
||||
export function VoiceCallSurface() {
|
||||
const voice = useVoiceOptional();
|
||||
const [transcriptOpen, setTranscriptOpen] = useState(false);
|
||||
const toggleTranscript = useCallback(() => setTranscriptOpen((open) => !open), []);
|
||||
|
||||
if (!voice || (!voice.isVoiceMode && voice.events.length === 0)) return null;
|
||||
|
||||
const showTranscript = transcriptOpen || !voice.isVoiceMode;
|
||||
|
||||
return (
|
||||
<View pointerEvents="box-none" style={styles.host}>
|
||||
<View style={styles.surface}>
|
||||
{showTranscript ? (
|
||||
<ScrollView style={styles.transcript} contentContainerStyle={styles.transcriptContent}>
|
||||
{voice.events.length === 0 ? (
|
||||
<Text style={styles.empty}>Conversation will appear here.</Text>
|
||||
) : (
|
||||
voice.events.map((event) => <VoiceCallEventRow key={event.id} event={event} />)
|
||||
)}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
{voice.isVoiceMode ? (
|
||||
<RealtimeVoiceOverlay
|
||||
isMuted={voice.isMuted}
|
||||
isSwitching={voice.isVoiceSwitching}
|
||||
isTranscriptOpen={transcriptOpen}
|
||||
onToggleTranscript={toggleTranscript}
|
||||
onToggleMute={voice.toggleMute}
|
||||
onStop={voice.stopVoice}
|
||||
/>
|
||||
) : (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss voice transcript"
|
||||
onPress={voice.dismissTranscript}
|
||||
style={styles.transcriptButton}
|
||||
>
|
||||
<Text style={styles.transcriptButtonText}>Dismiss</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
host: {
|
||||
width: "100%",
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[1.5],
|
||||
},
|
||||
surface: {
|
||||
width: "100%",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
transcript: {
|
||||
maxHeight: 200,
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
transcriptContent: {
|
||||
gap: theme.spacing[1.5],
|
||||
},
|
||||
empty: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
eventText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
eventLabel: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
activityRow: {
|
||||
minWidth: 0,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
activityLabel: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
activityState: {
|
||||
color: theme.colors.foregroundExtraMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
notice: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
error: {
|
||||
color: theme.colors.palette.red[500],
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
transcriptButton: {
|
||||
alignSelf: "flex-end",
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
transcriptButtonText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: "600",
|
||||
},
|
||||
}));
|
||||
@@ -131,7 +131,7 @@ export function CompactModelSheet({
|
||||
serverId,
|
||||
});
|
||||
const ProviderIcon =
|
||||
selectedProvider.trim().length > 0 ? getProviderIcon(selectedProvider) : null;
|
||||
selectedProvider.trim().length > 0 ? getProviderIcon(selectedProvider, serverId) : null;
|
||||
const ModelIcon = ProviderIcon ?? Bot;
|
||||
const rootHeader = useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -88,6 +88,110 @@ describe("useDraftAgentCreateFlow", () => {
|
||||
expect(onCreateSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the submitted preview when the pending provider selection disappears", () => {
|
||||
const attempt: DraftCreateAttempt = {
|
||||
clientMessageId: "msg-provider-handoff",
|
||||
text: "build this",
|
||||
timestamp: new Date("2026-05-25T00:00:00.000Z"),
|
||||
};
|
||||
const { result, rerender } = renderHook(
|
||||
({ provider }: { provider: string | null }) =>
|
||||
useDraftAgentCreateFlow({
|
||||
draftId: "draft-handoff",
|
||||
getPendingServerId: () => "server-1",
|
||||
initialAttempt: attempt,
|
||||
buildDraftAgent: () => {
|
||||
if (!provider) throw new Error("Select a model");
|
||||
return { provider, model: "selected-model" };
|
||||
},
|
||||
createRequest: async () => ({ agentId: "agent-1", result: { id: "agent-1" } }),
|
||||
onCreateSuccess: () => undefined,
|
||||
}),
|
||||
{ initialProps: { provider: "codex" } as { provider: string | null } },
|
||||
);
|
||||
expect(result.current.draftAgent).toEqual({ provider: "codex", model: "selected-model" });
|
||||
rerender({ provider: null });
|
||||
expect(result.current.isSubmitting).toBe(true);
|
||||
expect(result.current.draftAgent).toEqual({ provider: "codex", model: "selected-model" });
|
||||
});
|
||||
|
||||
it("shows an invalid prepared selection as a form error instead of throwing in render", () => {
|
||||
const createRequest = vi.fn(async () => ({ agentId: "agent-1", result: { id: "agent-1" } }));
|
||||
const { result } = renderHook(() =>
|
||||
useDraftAgentCreateFlow({
|
||||
draftId: "draft-invalid",
|
||||
getPendingServerId: () => "server-1",
|
||||
initialAttempt: {
|
||||
clientMessageId: "msg-invalid",
|
||||
text: "build this",
|
||||
timestamp: new Date(0),
|
||||
},
|
||||
buildDraftAgent: () => {
|
||||
throw new Error("Select a model");
|
||||
},
|
||||
createRequest,
|
||||
onCreateSuccess: () => undefined,
|
||||
}),
|
||||
);
|
||||
expect(result.current.isSubmitting).toBe(false);
|
||||
expect(result.current.draftAgent).toBeNull();
|
||||
expect(result.current.formErrorMessage).toBe("Select a model");
|
||||
expect(createRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a manual submission stable and lets a failed request retry with a new selection", async () => {
|
||||
let failRequest!: (error: Error) => void;
|
||||
const request = new Promise<never>((_resolve, reject) => {
|
||||
failRequest = reject;
|
||||
});
|
||||
let requestCount = 0;
|
||||
const { result, rerender } = renderHook(
|
||||
({ provider }: { provider: string | null }) =>
|
||||
useDraftAgentCreateFlow({
|
||||
draftId: "draft-manual",
|
||||
getPendingServerId: () => "server-1",
|
||||
buildDraftAgent: () => {
|
||||
if (!provider) throw new Error("Select a model");
|
||||
return { provider };
|
||||
},
|
||||
createRequest: async () => {
|
||||
requestCount++;
|
||||
if (requestCount === 1) return await request;
|
||||
return { agentId: "agent-1", result: { id: "agent-1" } };
|
||||
},
|
||||
onCreateSuccess: () => undefined,
|
||||
}),
|
||||
{ initialProps: { provider: "codex" } as { provider: string | null } },
|
||||
);
|
||||
let submission!: Promise<unknown>;
|
||||
const captureError = (error: unknown) => error;
|
||||
await act(async () => {
|
||||
submission = result.current
|
||||
.handleCreateFromInput({ text: "build this", attachments: [], cwd: "/repo" })
|
||||
.catch(captureError);
|
||||
});
|
||||
rerender({ provider: null });
|
||||
expect(result.current.draftAgent).toEqual({ provider: "codex" });
|
||||
await act(async () => {
|
||||
failRequest(new Error("Provider unavailable"));
|
||||
await submission;
|
||||
});
|
||||
expect(result.current.isSubmitting).toBe(false);
|
||||
expect(result.current.draftAgent).toBeNull();
|
||||
expect(result.current.formErrorMessage).toBe("Provider unavailable");
|
||||
rerender({ provider: "claude" });
|
||||
await act(async () => {
|
||||
await result.current.handleCreateFromInput({
|
||||
text: "try again",
|
||||
attachments: [],
|
||||
cwd: "/repo",
|
||||
});
|
||||
});
|
||||
expect(result.current.draftAgent).toEqual({ provider: "claude" });
|
||||
expect(result.current.formErrorMessage).toBe("");
|
||||
expect(requestCount).toBe(2);
|
||||
});
|
||||
|
||||
it("allows retrying an empty prompt when the draft still has context attachments", async () => {
|
||||
const attachment = {
|
||||
kind: "chat_history",
|
||||
|
||||
@@ -27,23 +27,23 @@ interface CreateAttempt {
|
||||
attachments?: AgentAttachment[];
|
||||
}
|
||||
|
||||
type DraftAgentMachineState =
|
||||
type DraftAgentMachineState<TDraftAgent> =
|
||||
| { tag: "draft"; errorMessage: string }
|
||||
| { tag: "creating"; attempt: CreateAttempt };
|
||||
| { tag: "creating"; attempt: CreateAttempt; draftAgent: TDraftAgent };
|
||||
|
||||
type DraftAgentMachineEvent =
|
||||
type DraftAgentMachineEvent<TDraftAgent> =
|
||||
| { type: "DRAFT_SET_ERROR"; message: string }
|
||||
| { type: "SUBMIT"; attempt: CreateAttempt }
|
||||
| { type: "SUBMIT"; attempt: CreateAttempt; draftAgent: TDraftAgent }
|
||||
| { type: "CREATE_FAILED"; message: string };
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
|
||||
}
|
||||
|
||||
function reducer(
|
||||
state: DraftAgentMachineState,
|
||||
event: DraftAgentMachineEvent,
|
||||
): DraftAgentMachineState {
|
||||
function reducer<TDraftAgent>(
|
||||
state: DraftAgentMachineState<TDraftAgent>,
|
||||
event: DraftAgentMachineEvent<TDraftAgent>,
|
||||
): DraftAgentMachineState<TDraftAgent> {
|
||||
switch (event.type) {
|
||||
case "DRAFT_SET_ERROR": {
|
||||
if (state.tag !== "draft") {
|
||||
@@ -52,7 +52,7 @@ function reducer(
|
||||
return { ...state, errorMessage: event.message };
|
||||
}
|
||||
case "SUBMIT": {
|
||||
return { tag: "creating", attempt: event.attempt };
|
||||
return { tag: "creating", attempt: event.attempt, draftAgent: event.draftAgent };
|
||||
}
|
||||
case "CREATE_FAILED": {
|
||||
if (state.tag !== "creating") {
|
||||
@@ -65,6 +65,17 @@ function reducer(
|
||||
}
|
||||
}
|
||||
|
||||
function prepareCreateAttempt<TDraftAgent>(
|
||||
attempt: CreateAttempt,
|
||||
buildDraftAgent: (attempt: CreateAttempt) => TDraftAgent,
|
||||
): DraftAgentMachineState<TDraftAgent> {
|
||||
try {
|
||||
return { tag: "creating", attempt, draftAgent: buildDraftAgent(attempt) };
|
||||
} catch (error) {
|
||||
return { tag: "draft", errorMessage: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
interface CreateRequestResult<TCreateResult> {
|
||||
agentId: string | null;
|
||||
result: TCreateResult;
|
||||
@@ -113,11 +124,11 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
|
||||
const { t } = useTranslation();
|
||||
const [machine, dispatch] = useReducer(
|
||||
reducer,
|
||||
reducer<TDraftAgent>,
|
||||
initialAttempt,
|
||||
(attempt): DraftAgentMachineState =>
|
||||
(attempt): DraftAgentMachineState<TDraftAgent> =>
|
||||
attempt
|
||||
? { tag: "creating", attempt }
|
||||
? prepareCreateAttempt(attempt, buildDraftAgent)
|
||||
: {
|
||||
tag: "draft",
|
||||
errorMessage: "",
|
||||
@@ -163,12 +174,18 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
];
|
||||
}, [machine]);
|
||||
|
||||
const draftAgent = useMemo<TDraftAgent | null>(() => {
|
||||
if (machine.tag !== "creating") {
|
||||
return null;
|
||||
}
|
||||
return buildDraftAgent(machine.attempt);
|
||||
}, [buildDraftAgent, machine]);
|
||||
const draftAgent = machine.tag === "creating" ? machine.draftAgent : null;
|
||||
const startCreateAttempt = useCallback(
|
||||
(attempt: CreateAttempt) => {
|
||||
const prepared = prepareCreateAttempt(attempt, buildDraftAgent);
|
||||
if (prepared.tag === "draft") {
|
||||
dispatch({ type: "DRAFT_SET_ERROR", message: prepared.errorMessage });
|
||||
throw new Error(prepared.errorMessage);
|
||||
}
|
||||
dispatch({ type: "SUBMIT", attempt, draftAgent: prepared.draftAgent });
|
||||
},
|
||||
[buildDraftAgent],
|
||||
);
|
||||
|
||||
const runCreateAttempt = useCallback(
|
||||
async ({ attempt, cwd }: { attempt: CreateAttempt; cwd: string }) => {
|
||||
@@ -179,15 +196,14 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
throw error;
|
||||
}
|
||||
|
||||
await onBeforeSubmit?.({
|
||||
attempt,
|
||||
text: attempt.text,
|
||||
images: attempt.images,
|
||||
attachments: attempt.attachments,
|
||||
cwd,
|
||||
});
|
||||
|
||||
try {
|
||||
await onBeforeSubmit?.({
|
||||
attempt,
|
||||
text: attempt.text,
|
||||
images: attempt.images,
|
||||
attachments: attempt.attachments,
|
||||
cwd,
|
||||
});
|
||||
const createResult = await createRequest({
|
||||
attempt,
|
||||
text: attempt.text,
|
||||
@@ -287,6 +303,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
|
||||
};
|
||||
|
||||
startCreateAttempt(attempt);
|
||||
setPendingCreateAttempt({
|
||||
draftId,
|
||||
serverId: pendingServerId,
|
||||
@@ -300,7 +317,6 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
: {}),
|
||||
});
|
||||
|
||||
dispatch({ type: "SUBMIT", attempt });
|
||||
onCreateStart?.();
|
||||
await runCreateAttempt({ attempt, cwd });
|
||||
},
|
||||
@@ -312,6 +328,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
onCreateStart,
|
||||
runCreateAttempt,
|
||||
setPendingCreateAttempt,
|
||||
startCreateAttempt,
|
||||
t,
|
||||
validateBeforeSubmit,
|
||||
],
|
||||
@@ -320,11 +337,11 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
const continueCreateFromAttempt = useCallback(
|
||||
async ({ attempt, cwd }: { attempt: CreateAttempt; cwd: string }) => {
|
||||
if (!isSubmitting) {
|
||||
dispatch({ type: "SUBMIT", attempt });
|
||||
startCreateAttempt(attempt);
|
||||
}
|
||||
await runCreateAttempt({ attempt, cwd });
|
||||
},
|
||||
[isSubmitting, runCreateAttempt],
|
||||
[isSubmitting, runCreateAttempt, startCreateAttempt],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ReanimatedAnimated from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { KeyboardTranslateView } from "@/components/keyboard-translate-view";
|
||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Composer } from "@/composer";
|
||||
@@ -624,17 +623,9 @@ export function WorkspaceDraftAgentTab({
|
||||
focusInputRef.current = focus;
|
||||
}, []);
|
||||
|
||||
const { style: composerKeyboardStyle } = useKeyboardShiftStyle({
|
||||
mode: "translate",
|
||||
});
|
||||
|
||||
const inputAreaWrapperStyle = useMemo(
|
||||
() => [
|
||||
animatedStaticStyles.inputAreaWrapper,
|
||||
{ paddingBottom: insets.bottom },
|
||||
composerKeyboardStyle,
|
||||
],
|
||||
[insets.bottom, composerKeyboardStyle],
|
||||
() => [animatedStaticStyles.inputAreaWrapper, { paddingBottom: insets.bottom }],
|
||||
[insets.bottom],
|
||||
);
|
||||
|
||||
const handleDropdownCloseFocus = useCallback(() => {
|
||||
@@ -678,7 +669,7 @@ export function WorkspaceDraftAgentTab({
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ReanimatedAnimated.View style={inputAreaWrapperStyle} onLayout={onInputAreaLayout}>
|
||||
<KeyboardTranslateView style={inputAreaWrapperStyle} onLayout={onInputAreaLayout}>
|
||||
{importPillPress ? (
|
||||
<View style={styles.importPillRow}>
|
||||
<View style={styles.importPillContent}>
|
||||
@@ -711,7 +702,7 @@ export function WorkspaceDraftAgentTab({
|
||||
agentControls={composerAgentControls}
|
||||
isCompactLayout={isCompactComposerLayout}
|
||||
/>
|
||||
</ReanimatedAnimated.View>
|
||||
</KeyboardTranslateView>
|
||||
</FileDropZone>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
View,
|
||||
Pressable,
|
||||
Text,
|
||||
StyleSheet as RNStyleSheet,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { View, Pressable, Text, StyleSheet as RNStyleSheet } from "react-native";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
useState,
|
||||
@@ -25,7 +19,6 @@ import {
|
||||
ArrowUp,
|
||||
Square,
|
||||
Pencil,
|
||||
AudioLines,
|
||||
CircleDot,
|
||||
FileText,
|
||||
GitPullRequest,
|
||||
@@ -34,7 +27,6 @@ import {
|
||||
Paperclip,
|
||||
} from "lucide-react-native";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import {
|
||||
AgentControls,
|
||||
@@ -42,6 +34,7 @@ import {
|
||||
type DraftAgentControlsProps,
|
||||
} from "@/composer/agent-controls";
|
||||
import { ContextWindowMeter } from "@/components/context-window-meter";
|
||||
import { KeyboardTranslateView } from "@/components/keyboard-translate-view";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
|
||||
import { useFilePicker } from "@/hooks/use-file-picker";
|
||||
@@ -75,7 +68,6 @@ import {
|
||||
type QueueWriter,
|
||||
type QueuedComposerMessage,
|
||||
} from "@/composer/actions";
|
||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
@@ -102,7 +94,6 @@ import {
|
||||
import { resolveAgentControlsMode } from "@/composer/agent-controls/mode";
|
||||
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
|
||||
import { resolveActiveSendBehavior } from "./input/state";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
@@ -183,14 +174,6 @@ function resolveIsComposerLocked(
|
||||
return submitBehavior === "preserve-and-lock" && isSubmitLoading;
|
||||
}
|
||||
|
||||
function resolveIsVoiceModeForAgent(
|
||||
voice: ReturnType<typeof useVoiceOptional>,
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
): boolean {
|
||||
return voice?.isVoiceModeForAgent(serverId, agentId) ?? false;
|
||||
}
|
||||
|
||||
function resolveKeyboardPriority(isMessageInputFocused: boolean): number {
|
||||
return isMessageInputFocused ? 200 : 100;
|
||||
}
|
||||
@@ -242,19 +225,6 @@ function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean
|
||||
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
|
||||
}
|
||||
|
||||
function buildRealtimeVoiceButtonStyle(
|
||||
hovered: boolean | undefined,
|
||||
voiceButtonDisabled: boolean,
|
||||
reserveLeadingSpace: boolean,
|
||||
): object[] {
|
||||
const hoveredStyle = hovered ? styles.iconButtonHovered : undefined;
|
||||
const disabledStyle = voiceButtonDisabled ? styles.buttonDisabled : undefined;
|
||||
const reserveStyle = reserveLeadingSpace ? styles.realtimeVoiceButtonCompactReserve : undefined;
|
||||
return [styles.realtimeVoiceButton, reserveStyle, hoveredStyle, disabledStyle].filter(
|
||||
(value): value is object => Boolean(value),
|
||||
);
|
||||
}
|
||||
|
||||
function buildAgentStateSelector(serverId: string, agentId: string) {
|
||||
return (state: ReturnType<typeof useSessionStore.getState>) => {
|
||||
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
||||
@@ -488,29 +458,6 @@ function resolveErrorMessage(error: unknown): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
interface AttemptStartRealtimeVoiceArgs {
|
||||
voice: ReturnType<typeof useVoiceOptional>;
|
||||
isConnected: boolean;
|
||||
hasAgent: boolean;
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
toastErrorRef: { current: (message: string) => void };
|
||||
}
|
||||
|
||||
function attemptStartRealtimeVoice(args: AttemptStartRealtimeVoiceArgs): void {
|
||||
const { voice, isConnected, hasAgent, serverId, agentId, toastErrorRef } = args;
|
||||
if (!voice || !isConnected || !hasAgent) return;
|
||||
if (voice.isVoiceSwitching) return;
|
||||
if (voice.isVoiceModeForAgent(serverId, agentId)) return;
|
||||
void voice.startVoice(serverId, agentId).catch((error) => {
|
||||
console.error("[Composer] Failed to start voice mode", error);
|
||||
const message = resolveErrorMessage(error);
|
||||
if (message && message.trim().length > 0) {
|
||||
toastErrorRef.current(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function focusMessageInputWithPlatformStrategy(messageInputRef: {
|
||||
current: MessageInputRef | null;
|
||||
}): void {
|
||||
@@ -1044,88 +991,6 @@ function ComposerCancelButton({
|
||||
);
|
||||
}
|
||||
|
||||
interface ComposerVoiceModeButtonProps {
|
||||
buttonIconSize: number;
|
||||
handleToggleRealtimeVoice: () => void;
|
||||
isConnected: boolean;
|
||||
isVoiceSwitching: boolean;
|
||||
realtimeVoiceButtonStyle: (
|
||||
state: PressableStateCallbackType & { hovered?: boolean },
|
||||
) => (object | undefined)[];
|
||||
voiceToggleKeys: ReturnType<typeof useShortcutKeys>;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps {
|
||||
isVoiceModeForAgent: boolean;
|
||||
hasAgent: boolean;
|
||||
isAgentRunning: boolean;
|
||||
hasSendableContent: boolean;
|
||||
isCompact: boolean;
|
||||
showVoice: boolean;
|
||||
}
|
||||
|
||||
function ComposerRightControlsSlot({
|
||||
isVoiceModeForAgent,
|
||||
hasAgent,
|
||||
isAgentRunning,
|
||||
hasSendableContent,
|
||||
isCompact,
|
||||
showVoice,
|
||||
...voiceProps
|
||||
}: ComposerRightControlsSlotProps) {
|
||||
const hideVoiceForCompactInput = isCompact && hasSendableContent;
|
||||
const showVoiceModeButton =
|
||||
showVoice && !isVoiceModeForAgent && hasAgent && !isAgentRunning && !hideVoiceForCompactInput;
|
||||
if (!showVoiceModeButton) return null;
|
||||
return (
|
||||
<View style={styles.rightControls}>
|
||||
<ComposerVoiceModeButton {...voiceProps} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ComposerVoiceModeButton({
|
||||
buttonIconSize,
|
||||
handleToggleRealtimeVoice,
|
||||
isConnected,
|
||||
isVoiceSwitching,
|
||||
realtimeVoiceButtonStyle,
|
||||
voiceToggleKeys,
|
||||
t,
|
||||
}: ComposerVoiceModeButtonProps) {
|
||||
const shortcutNode = voiceToggleKeys ? <Shortcut chord={voiceToggleKeys} /> : null;
|
||||
const renderTriggerContent = useCallback(
|
||||
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
|
||||
if (isVoiceSwitching) {
|
||||
return <LoadingSpinner size="small" color="white" />;
|
||||
}
|
||||
const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping;
|
||||
return <ThemedAudioLines size={buttonIconSize} uniProps={colorMapping} />;
|
||||
},
|
||||
[buttonIconSize, isVoiceSwitching],
|
||||
);
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={handleToggleRealtimeVoice}
|
||||
disabled={!isConnected || isVoiceSwitching}
|
||||
accessibilityLabel={t("composer.voice.enableVoiceMode")}
|
||||
accessibilityRole="button"
|
||||
style={realtimeVoiceButtonStyle}
|
||||
>
|
||||
{renderTriggerContent}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<View style={styles.tooltipRow}>
|
||||
<Text style={styles.tooltipText}>{t("composer.voice.voiceMode")}</Text>
|
||||
{shortcutNode}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function Composer({ isPaneFocused, ...props }: ComposerProps) {
|
||||
return (
|
||||
<ComposerKeyboardScopeProvider isActiveComposer={isPaneFocused}>
|
||||
@@ -1193,8 +1058,6 @@ function ComposerContentImpl({
|
||||
const toast = useToast();
|
||||
const toastErrorRef = useRef(toast.error);
|
||||
toastErrorRef.current = toast.error;
|
||||
const voice = useVoiceOptional();
|
||||
const voiceToggleKeys = useShortcutKeys("voice-toggle");
|
||||
const agentInterruptKeys = useShortcutKeys("agent-interrupt");
|
||||
const isDictationReady = useIsDictationReady({
|
||||
serverId,
|
||||
@@ -1839,24 +1702,6 @@ function ComposerContentImpl({
|
||||
focusMessageInputWithPlatformStrategy(messageInputRef);
|
||||
}, []);
|
||||
|
||||
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({
|
||||
mode: "translate",
|
||||
enabled: !externalKeyboardShift,
|
||||
});
|
||||
|
||||
const isVoiceModeForAgent = resolveIsVoiceModeForAgent(voice, serverId, agentId);
|
||||
|
||||
const handleToggleRealtimeVoice = useCallback(() => {
|
||||
attemptStartRealtimeVoice({
|
||||
voice,
|
||||
isConnected,
|
||||
hasAgent,
|
||||
serverId,
|
||||
agentId,
|
||||
toastErrorRef,
|
||||
});
|
||||
}, [agentId, hasAgent, isConnected, serverId, voice]);
|
||||
|
||||
const handleEditQueuedMessage = useCallback(
|
||||
(id: string) => {
|
||||
const result = editQueuedComposerMessage({
|
||||
@@ -1918,8 +1763,6 @@ function ComposerContentImpl({
|
||||
],
|
||||
);
|
||||
|
||||
const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
|
||||
|
||||
// Handle keyboard navigation for command autocomplete.
|
||||
const handleCommandKeyPress = useCallback(
|
||||
(event: ComposerKeyPressEvent) => autocompleteOnKeyPressRef.current(event),
|
||||
@@ -1931,14 +1774,6 @@ function ComposerContentImpl({
|
||||
[isConnected, isCancellingAgent],
|
||||
);
|
||||
|
||||
const isVoiceSwitching = voice?.isVoiceSwitching ?? false;
|
||||
const voiceButtonDisabled = !isConnected || isVoiceSwitching;
|
||||
const realtimeVoiceButtonStyle = useCallback(
|
||||
(state: PressableStateCallbackType & { hovered?: boolean }) =>
|
||||
buildRealtimeVoiceButtonStyle(state.hovered, voiceButtonDisabled, isCompactLayout),
|
||||
[isCompactLayout, voiceButtonDisabled],
|
||||
);
|
||||
|
||||
const activeActionContent = useMemo(
|
||||
() => (
|
||||
<ComposerCancelButton
|
||||
@@ -1962,41 +1797,6 @@ function ComposerContentImpl({
|
||||
],
|
||||
);
|
||||
|
||||
const rightContent = useMemo(
|
||||
() => (
|
||||
<ComposerRightControlsSlot
|
||||
isVoiceModeForAgent={isVoiceModeForAgent}
|
||||
hasAgent={hasAgent}
|
||||
isAgentRunning={isAgentRunning}
|
||||
hasSendableContent={hasSendableContent}
|
||||
isCompact={isCompactLayout}
|
||||
showVoice={mode.showVoice}
|
||||
buttonIconSize={buttonIconSize}
|
||||
handleToggleRealtimeVoice={handleToggleRealtimeVoice}
|
||||
isConnected={isConnected}
|
||||
isVoiceSwitching={isVoiceSwitching}
|
||||
realtimeVoiceButtonStyle={realtimeVoiceButtonStyle}
|
||||
voiceToggleKeys={voiceToggleKeys}
|
||||
t={t}
|
||||
/>
|
||||
),
|
||||
[
|
||||
buttonIconSize,
|
||||
handleToggleRealtimeVoice,
|
||||
hasAgent,
|
||||
hasSendableContent,
|
||||
isAgentRunning,
|
||||
isConnected,
|
||||
isCompactLayout,
|
||||
isVoiceModeForAgent,
|
||||
isVoiceSwitching,
|
||||
mode.showVoice,
|
||||
realtimeVoiceButtonStyle,
|
||||
t,
|
||||
voiceToggleKeys,
|
||||
],
|
||||
);
|
||||
|
||||
const { contextWindowMaxTokens, contextWindowUsedTokens } = resolveContextWindowValues(
|
||||
agentState.contextWindowMaxTokens,
|
||||
agentState.contextWindowUsedTokens,
|
||||
@@ -2231,10 +2031,6 @@ function ComposerContentImpl({
|
||||
[githubSearchItems, selectedAttachments, handleToggleGithubItem],
|
||||
);
|
||||
|
||||
const composerContainerStyle = useMemo(
|
||||
() => [animatedStaticStyles.container, keyboardAnimatedStyle],
|
||||
[keyboardAnimatedStyle],
|
||||
);
|
||||
const inputAreaContainerStyle = useMemo(
|
||||
() => [styles.inputAreaContainer, isComposerLocked && styles.inputAreaLocked],
|
||||
[isComposerLocked],
|
||||
@@ -2319,7 +2115,10 @@ function ComposerContentImpl({
|
||||
focusMessageInputForKeyboardAction={focusMessageInputForKeyboardAction}
|
||||
isMessageInputFocused={isMessageInputFocused}
|
||||
/>
|
||||
<Animated.View style={composerContainerStyle}>
|
||||
<KeyboardTranslateView
|
||||
style={animatedStaticStyles.container}
|
||||
enabled={!externalKeyboardShift}
|
||||
>
|
||||
<AttachmentLightbox source={lightboxSource} onClose={handleLightboxClose} />
|
||||
{/* Input area */}
|
||||
<View style={inputAreaContainerStyle}>
|
||||
@@ -2369,9 +2168,9 @@ function ComposerContentImpl({
|
||||
disabled={isSubmitLoading}
|
||||
leftContent={leftContent}
|
||||
beforeVoiceContent={beforeVoiceContent}
|
||||
rightContent={rightContent}
|
||||
activeActionContent={activeActionContent}
|
||||
voiceServerId={serverId}
|
||||
voiceWorkspaceId={workspaceId ?? undefined}
|
||||
voiceAgentId={agentId}
|
||||
isAgentRunning={isAgentRunning}
|
||||
defaultSendBehavior={activeSendBehavior}
|
||||
@@ -2413,7 +2212,7 @@ function ComposerContentImpl({
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</KeyboardTranslateView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2468,11 +2267,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
justifyContent: "center",
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
rightControls: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
contextWindowMeterSlot: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
@@ -2480,20 +2274,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
realtimeVoiceButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
realtimeVoiceButtonCompactReserve: {
|
||||
marginLeft: theme.spacing[1],
|
||||
},
|
||||
realtimeVoiceButtonActive: {
|
||||
backgroundColor: theme.colors.palette.green[600],
|
||||
borderColor: theme.colors.palette.green[800],
|
||||
},
|
||||
iconButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
@@ -2561,7 +2341,6 @@ const ThemedPencil = withUnistyles(Pencil);
|
||||
const ThemedArrowUp = withUnistyles(ArrowUp);
|
||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
const ThemedCircleDot = withUnistyles(CircleDot);
|
||||
const ThemedAudioLines = withUnistyles(AudioLines);
|
||||
const ThemedPaperclip = withUnistyles(Paperclip);
|
||||
const ThemedImageIcon = withUnistyles(ImageIcon);
|
||||
const ThemedClipboardPaste = withUnistyles(ClipboardPaste);
|
||||
|
||||
@@ -24,7 +24,6 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native";
|
||||
import { useDictation } from "@/hooks/use-dictation";
|
||||
import { DictationOverlay } from "@/components/dictation-controls";
|
||||
import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
||||
@@ -144,6 +143,7 @@ export interface MessageInputProps {
|
||||
/** Primary action to render when the agent is active and the composer has no sendable content. */
|
||||
activeActionContent?: React.ReactNode;
|
||||
voiceServerId?: string;
|
||||
voiceWorkspaceId?: string;
|
||||
voiceAgentId?: string;
|
||||
/** When true and there's sendable content, calls onQueue instead of onSubmit */
|
||||
isAgentRunning?: boolean;
|
||||
@@ -594,16 +594,9 @@ function MessageInputOverlay({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (showRealtimeOverlay && voice) {
|
||||
return (
|
||||
<RealtimeVoiceOverlay
|
||||
isMuted={voice.isMuted}
|
||||
isSwitching={voice.isVoiceSwitching}
|
||||
onToggleMute={voice.toggleMute}
|
||||
onStop={onRealtimeVoiceStop}
|
||||
/>
|
||||
);
|
||||
}
|
||||
void showRealtimeOverlay;
|
||||
void voice;
|
||||
void onRealtimeVoiceStop;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -830,41 +823,50 @@ interface ToggleRealtimeVoiceContext {
|
||||
voice:
|
||||
| {
|
||||
isVoiceSwitching: boolean;
|
||||
isVoiceModeForAgent: (serverId: string, agentId: string) => boolean;
|
||||
startVoice: (serverId: string, agentId: string) => Promise<unknown>;
|
||||
isCallActiveOnServer: (serverId: string) => boolean;
|
||||
startCall: (
|
||||
serverId: string,
|
||||
context: { workspaceId: string | null; agentId: string | null },
|
||||
) => Promise<unknown>;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
voiceServerId: string | undefined;
|
||||
voiceWorkspaceId: string | undefined;
|
||||
voiceAgentId: string | undefined;
|
||||
isConnected: boolean;
|
||||
disabled: boolean;
|
||||
isAgentRunning: boolean;
|
||||
handleStopRealtimeVoice: () => Promise<unknown> | void;
|
||||
toast: { error: (msg: string) => void };
|
||||
interruptBeforeVoiceMessage: string;
|
||||
}
|
||||
|
||||
function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
|
||||
if (!ctx.voice || !ctx.voiceServerId || !ctx.voiceAgentId || !ctx.isConnected || ctx.disabled) {
|
||||
if (
|
||||
!ctx.voice ||
|
||||
!ctx.voiceServerId ||
|
||||
!ctx.voiceWorkspaceId ||
|
||||
!ctx.isConnected ||
|
||||
ctx.disabled
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (ctx.voice.isVoiceSwitching) return;
|
||||
if (ctx.voice.isVoiceModeForAgent(ctx.voiceServerId, ctx.voiceAgentId)) {
|
||||
if (ctx.voice.isCallActiveOnServer(ctx.voiceServerId)) {
|
||||
void ctx.handleStopRealtimeVoice();
|
||||
return;
|
||||
}
|
||||
if (ctx.isAgentRunning) {
|
||||
ctx.toast.error(ctx.interruptBeforeVoiceMessage);
|
||||
return;
|
||||
}
|
||||
void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => {
|
||||
console.error("[MessageInput] Failed to start realtime voice", error);
|
||||
const message = extractErrorMessage(error);
|
||||
if (message && message.trim().length > 0) {
|
||||
ctx.toast.error(message);
|
||||
}
|
||||
});
|
||||
void ctx.voice
|
||||
.startCall(ctx.voiceServerId, {
|
||||
workspaceId: ctx.voiceWorkspaceId,
|
||||
agentId: ctx.voiceAgentId ?? null,
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[MessageInput] Failed to start realtime voice", error);
|
||||
const message = extractErrorMessage(error);
|
||||
if (message && message.trim().length > 0) {
|
||||
ctx.toast.error(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface StartDictationContext {
|
||||
@@ -959,12 +961,11 @@ function queueMessageImpl(ctx: QueueMessageContext): void {
|
||||
}
|
||||
|
||||
function computeIsRealtimeVoiceForAgent(
|
||||
voice: { isVoiceModeForAgent: (serverId: string, agentId: string) => boolean } | null | undefined,
|
||||
voice: { isCallActiveOnServer: (serverId: string) => boolean } | null | undefined,
|
||||
voiceServerId: string | undefined,
|
||||
voiceAgentId: string | undefined,
|
||||
): boolean {
|
||||
if (!voice || !voiceServerId || !voiceAgentId) return false;
|
||||
return voice.isVoiceModeForAgent(voiceServerId, voiceAgentId);
|
||||
if (!voice || !voiceServerId) return false;
|
||||
return voice.isCallActiveOnServer(voiceServerId);
|
||||
}
|
||||
|
||||
function computeShouldShowDictationOverlay(
|
||||
@@ -1068,6 +1069,7 @@ interface ResolvedMessageInputProps {
|
||||
rightContent: React.ReactNode;
|
||||
activeActionContent: React.ReactNode;
|
||||
voiceServerId: string | undefined;
|
||||
voiceWorkspaceId: string | undefined;
|
||||
voiceAgentId: string | undefined;
|
||||
isAgentRunning: boolean;
|
||||
defaultSendBehavior: "interrupt" | "steer" | "queue";
|
||||
@@ -1115,6 +1117,7 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu
|
||||
rightContent: props.rightContent,
|
||||
activeActionContent: props.activeActionContent,
|
||||
voiceServerId: props.voiceServerId,
|
||||
voiceWorkspaceId: props.voiceWorkspaceId,
|
||||
voiceAgentId: props.voiceAgentId,
|
||||
isAgentRunning: props.isAgentRunning ?? false,
|
||||
defaultSendBehavior: props.defaultSendBehavior,
|
||||
@@ -1170,6 +1173,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
rightContent,
|
||||
activeActionContent,
|
||||
voiceServerId,
|
||||
voiceWorkspaceId,
|
||||
voiceAgentId,
|
||||
isAgentRunning,
|
||||
defaultSendBehavior,
|
||||
@@ -1375,11 +1379,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
enableDuration: true,
|
||||
});
|
||||
|
||||
const isRealtimeVoiceForCurrentAgent = computeIsRealtimeVoiceForAgent(
|
||||
voice,
|
||||
voiceServerId,
|
||||
voiceAgentId,
|
||||
);
|
||||
const isRealtimeVoiceForCurrentAgent = computeIsRealtimeVoiceForAgent(voice, voiceServerId);
|
||||
const showDictationOverlay = computeShouldShowDictationOverlay(
|
||||
isDictating,
|
||||
isDictationProcessing,
|
||||
@@ -1452,9 +1452,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
await stopRealtimeVoice({
|
||||
voice,
|
||||
isRealtimeVoiceForCurrentAgent,
|
||||
isAgentRunning,
|
||||
client,
|
||||
voiceAgentId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[MessageInput] Failed to stop realtime voice", error);
|
||||
@@ -1463,30 +1460,28 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
toast.error(message);
|
||||
}
|
||||
}
|
||||
}, [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, toast, voice, voiceAgentId]);
|
||||
}, [isRealtimeVoiceForCurrentAgent, toast, voice]);
|
||||
|
||||
const handleToggleRealtimeVoiceShortcut = useCallback(() => {
|
||||
toggleRealtimeVoiceImpl({
|
||||
voice,
|
||||
voiceServerId,
|
||||
voiceWorkspaceId,
|
||||
voiceAgentId,
|
||||
isConnected,
|
||||
disabled,
|
||||
isAgentRunning,
|
||||
handleStopRealtimeVoice,
|
||||
toast,
|
||||
interruptBeforeVoiceMessage: t("composer.voice.interruptBeforeVoice"),
|
||||
});
|
||||
}, [
|
||||
disabled,
|
||||
handleStopRealtimeVoice,
|
||||
isAgentRunning,
|
||||
isConnected,
|
||||
t,
|
||||
toast,
|
||||
voice,
|
||||
voiceAgentId,
|
||||
voiceServerId,
|
||||
voiceWorkspaceId,
|
||||
]);
|
||||
|
||||
const minimizeInputHeight = useCallback(() => {
|
||||
|
||||
@@ -276,43 +276,14 @@ describe("composer send behavior", () => {
|
||||
});
|
||||
|
||||
describe("stopRealtimeVoice", () => {
|
||||
it("keeps voice mode active when the running agent refuses cancellation", async () => {
|
||||
const cancellationError = new Error("active run cancellation was not acknowledged");
|
||||
const cancelAgent = vi.fn().mockRejectedValue(cancellationError);
|
||||
it("stops the app-level call without touching the visible agent", async () => {
|
||||
const stopVoice = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
stopRealtimeVoice({
|
||||
voice: { stopVoice },
|
||||
isRealtimeVoiceForCurrentAgent: true,
|
||||
isAgentRunning: true,
|
||||
client: { cancelAgent },
|
||||
voiceAgentId: "agent-1",
|
||||
}),
|
||||
).rejects.toBe(cancellationError);
|
||||
|
||||
expect(stopVoice).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops voice mode after the running agent acknowledges cancellation", async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await stopRealtimeVoice({
|
||||
voice: {
|
||||
stopVoice: async () => {
|
||||
calls.push("stop voice");
|
||||
},
|
||||
},
|
||||
voice: { stopVoice },
|
||||
isRealtimeVoiceForCurrentAgent: true,
|
||||
isAgentRunning: true,
|
||||
client: {
|
||||
cancelAgent: async () => {
|
||||
calls.push("cancel agent");
|
||||
},
|
||||
},
|
||||
voiceAgentId: "agent-1",
|
||||
});
|
||||
|
||||
expect(calls).toEqual(["cancel agent", "stop voice"]);
|
||||
expect(stopVoice).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,9 +41,6 @@ export function resolveComposerSurfacePresentation(
|
||||
interface StopRealtimeVoiceContext {
|
||||
voice: { stopVoice: () => Promise<unknown> } | null | undefined;
|
||||
isRealtimeVoiceForCurrentAgent: boolean;
|
||||
isAgentRunning: boolean;
|
||||
client: { cancelAgent: (agentId: string) => Promise<unknown> } | null;
|
||||
voiceAgentId: string | undefined;
|
||||
}
|
||||
|
||||
interface SendActionContext {
|
||||
@@ -183,12 +180,5 @@ export function runMessageInputKeyboardAction(
|
||||
export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise<void> {
|
||||
if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return;
|
||||
|
||||
if (ctx.isAgentRunning) {
|
||||
if (!ctx.client || !ctx.voiceAgentId) {
|
||||
throw new Error("Cannot stop the running voice agent while the host is unavailable");
|
||||
}
|
||||
await ctx.client.cancelAgent(ctx.voiceAgentId);
|
||||
}
|
||||
|
||||
await ctx.voice.stopVoice();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useRef, ReactNode, useCallback, useEffect } from "react";
|
||||
import { Buffer } from "buffer";
|
||||
import { AppState } from "react-native";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -34,8 +33,7 @@ import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types";
|
||||
import type { GitSetupOptions } from "@getpaseo/protocol/messages";
|
||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context";
|
||||
import type { AudioPlaybackSource } from "@/voice/audio-engine-types";
|
||||
import { useVoiceRuntimeOptional } from "@/contexts/voice-context";
|
||||
import {
|
||||
selectAgentTimelineState,
|
||||
useSessionStore,
|
||||
@@ -72,50 +70,11 @@ export type {
|
||||
AgentFileExplorerState,
|
||||
} from "@/stores/session-store";
|
||||
|
||||
type AudioOutputPayload = Extract<SessionOutboundMessage, { type: "audio_output" }>["payload"];
|
||||
|
||||
interface BufferedAudioChunk {
|
||||
chunkIndex: number;
|
||||
audio: string;
|
||||
format: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
// COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12.
|
||||
function getTimelineDeliveryMode(selectiveAgentTimeline?: boolean): TimelineDeliveryMode {
|
||||
return selectiveAgentTimeline ? "selective" : "legacy";
|
||||
}
|
||||
|
||||
function decodeBase64Chunk(base64: string): Uint8Array {
|
||||
return Buffer.from(base64, "base64");
|
||||
}
|
||||
|
||||
function buildAudioPlaybackSource(chunks: BufferedAudioChunk[]): AudioPlaybackSource {
|
||||
const decodedChunks = chunks.map((chunk) => decodeBase64Chunk(chunk.audio));
|
||||
const totalSize = decodedChunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const output = new Uint8Array(totalSize);
|
||||
let offset = 0;
|
||||
for (const chunk of decodedChunks) {
|
||||
output.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
const format = chunks[0]?.format ?? "pcm";
|
||||
let mimeType: string;
|
||||
if (format === "pcm") mimeType = "audio/pcm;rate=24000;bits=16";
|
||||
else if (format === "mp3") mimeType = "audio/mpeg";
|
||||
else mimeType = `audio/${format}`;
|
||||
|
||||
const bytes = output.slice();
|
||||
return {
|
||||
size: bytes.byteLength,
|
||||
type: mimeType,
|
||||
async arrayBuffer() {
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const findLatestAssistantMessageText = (items: StreamItem[]): string | null => {
|
||||
for (let i = items.length - 1; i >= 0; i -= 1) {
|
||||
const item = items[i];
|
||||
@@ -186,15 +145,6 @@ type WorkspaceSetupProgressPayload = Extract<
|
||||
{ type: "workspace_setup_progress" }
|
||||
>["payload"];
|
||||
|
||||
function notifyVoiceAbortFailure(
|
||||
data: Extract<SessionOutboundMessage, { type: "activity_log" }>["payload"],
|
||||
notifyError: (message: string) => void,
|
||||
): void {
|
||||
if (data.type === "error" && data.metadata?.voiceAbortFailed === true) {
|
||||
notifyError(data.content);
|
||||
}
|
||||
}
|
||||
|
||||
interface SessionProviderSharedProps {
|
||||
children: ReactNode;
|
||||
serverId: string;
|
||||
@@ -222,7 +172,6 @@ export function SessionProvider(props: SessionProviderProps) {
|
||||
function SessionProviderInternal({ children, serverId, client }: SessionProviderClientProps) {
|
||||
const { t } = useTranslation();
|
||||
const voiceRuntime = useVoiceRuntimeOptional();
|
||||
const voiceAudioEngine = useVoiceAudioEngineOptional();
|
||||
const queryClient = useQueryClient();
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const toast = useToast();
|
||||
@@ -255,8 +204,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const appStateRef = useRef(AppState.currentState);
|
||||
const forcedTimelineTailReplacements = useRef(new Set<string>());
|
||||
const viewedTimelineSyncRef = useRef<ViewedTimelineOwner | null>(null);
|
||||
const audioOutputBuffersRef = useRef<Map<string, BufferedAudioChunk[]>>(new Map());
|
||||
const activeAudioGroupsRef = useRef<Set<string>>(new Set());
|
||||
const isAppVisible = useAppVisible();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -369,29 +316,29 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
useEffect(() => {
|
||||
const unregister = voiceRuntime?.registerSession({
|
||||
serverId,
|
||||
setVoiceMode: async (enabled, agentId) => {
|
||||
startCall: async (context, transports) => {
|
||||
if (!client) {
|
||||
throw new Error(t("common.errors.daemonUnavailable"));
|
||||
}
|
||||
await client.setVoiceMode(enabled, agentId);
|
||||
return client.startVoiceCall(context, transports);
|
||||
},
|
||||
sendVoiceAudioChunk: async (audioData, mimeType) => {
|
||||
stopCall: async (callId) => {
|
||||
if (!client) {
|
||||
throw new Error(t("common.errors.daemonUnavailable"));
|
||||
}
|
||||
await client.sendVoiceAudioChunk(audioData, mimeType);
|
||||
await client.stopVoiceCall(callId);
|
||||
},
|
||||
audioPlayed: async (chunkId) => {
|
||||
updateCallContext: async (callId, context) => {
|
||||
if (!client) {
|
||||
throw new Error(t("common.errors.daemonUnavailable"));
|
||||
}
|
||||
await client.audioPlayed(chunkId);
|
||||
await client.updateVoiceCallContext(callId, context);
|
||||
},
|
||||
abortRequest: async () => {
|
||||
sendTransportMessage: (callId, data) => {
|
||||
if (!client) {
|
||||
throw new Error(t("common.errors.daemonUnavailable"));
|
||||
}
|
||||
await client.abortRequest();
|
||||
client.sendVoiceCallTransportMessage(callId, data);
|
||||
},
|
||||
setAssistantAudioPlaying: (isPlaying) => {
|
||||
setIsPlayingAudio(serverId, isPlaying);
|
||||
@@ -532,14 +479,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const { agentId, event, timestamp, seq, epoch } = message.payload;
|
||||
const parsedTimestamp = new Date(timestamp);
|
||||
const streamEvent = event;
|
||||
if (
|
||||
event.type === "turn_started" ||
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed" ||
|
||||
event.type === "turn_canceled"
|
||||
) {
|
||||
voiceRuntime?.onTurnEvent(serverId, agentId, event.type);
|
||||
}
|
||||
const turnLiveness = deriveAgentStreamTurnLiveness([
|
||||
{ event: streamEvent, seq, epoch, timestamp: parsedTimestamp },
|
||||
]);
|
||||
@@ -661,94 +600,19 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
});
|
||||
});
|
||||
|
||||
const unsubAudioOutput = client.on("audio_output", async (message) => {
|
||||
if (message.type !== "audio_output") return;
|
||||
if (!voiceAudioEngine) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: AudioOutputPayload = message.payload;
|
||||
if (payload.isVoiceMode && voiceRuntime) {
|
||||
voiceRuntime.handleAudioOutput(serverId, payload);
|
||||
return;
|
||||
}
|
||||
|
||||
const playbackGroupId = payload.groupId ?? payload.id;
|
||||
const chunkIndex = payload.chunkIndex ?? 0;
|
||||
const isFinalChunk = payload.isLastChunk ?? true;
|
||||
|
||||
if (!audioOutputBuffersRef.current.has(playbackGroupId)) {
|
||||
audioOutputBuffersRef.current.set(playbackGroupId, []);
|
||||
}
|
||||
|
||||
const bufferedChunks = audioOutputBuffersRef.current.get(playbackGroupId)!;
|
||||
bufferedChunks.push({
|
||||
chunkIndex,
|
||||
audio: payload.audio,
|
||||
format: payload.format,
|
||||
id: payload.id,
|
||||
});
|
||||
|
||||
activeAudioGroupsRef.current.add(playbackGroupId);
|
||||
setIsPlayingAudio(serverId, true);
|
||||
|
||||
if (!isFinalChunk) {
|
||||
return;
|
||||
}
|
||||
|
||||
bufferedChunks.sort((left, right) => left.chunkIndex - right.chunkIndex);
|
||||
const chunkIds = bufferedChunks.map((chunk) => chunk.id);
|
||||
const shouldPlay =
|
||||
!payload.isVoiceMode || (voiceRuntime?.shouldPlayVoiceAudio(serverId) ?? false);
|
||||
const audioBlob = buildAudioPlaybackSource(bufferedChunks);
|
||||
function logAudioPlayedError(error: unknown): void {
|
||||
console.warn("[Session] Failed to confirm audio playback:", error);
|
||||
}
|
||||
const confirmAudioPlayed = async () => {
|
||||
await Promise.all(
|
||||
chunkIds.map((chunkId) => client.audioPlayed(chunkId).catch(logAudioPlayedError)),
|
||||
);
|
||||
};
|
||||
|
||||
let startedVoicePlayback = false;
|
||||
try {
|
||||
if (shouldPlay) {
|
||||
if (payload.isVoiceMode) {
|
||||
startedVoicePlayback = true;
|
||||
voiceRuntime?.onAssistantAudioStarted(serverId);
|
||||
}
|
||||
await voiceAudioEngine.play(audioBlob);
|
||||
}
|
||||
await confirmAudioPlayed();
|
||||
} catch (error) {
|
||||
console.error("[Session] Audio playback error:", error);
|
||||
await confirmAudioPlayed();
|
||||
} finally {
|
||||
audioOutputBuffersRef.current.delete(playbackGroupId);
|
||||
activeAudioGroupsRef.current.delete(playbackGroupId);
|
||||
setIsPlayingAudio(serverId, activeAudioGroupsRef.current.size > 0);
|
||||
|
||||
if (startedVoicePlayback) {
|
||||
voiceRuntime?.onAssistantAudioFinished(serverId);
|
||||
}
|
||||
}
|
||||
const unsubVoiceCallTransport = client.on("voice.call.transport.server", (message) => {
|
||||
if (message.type !== "voice.call.transport.server" || !voiceRuntime) return;
|
||||
voiceRuntime.handleTransportMessage(serverId, message.payload.callId, message.payload.data);
|
||||
});
|
||||
|
||||
const unsubActivity = client.on("activity_log", (message) => {
|
||||
if (message.type !== "activity_log") return;
|
||||
notifyVoiceAbortFailure(message.payload, toast.error);
|
||||
const unsubVoiceCallState = client.on("voice.call.state", (message) => {
|
||||
if (message.type !== "voice.call.state") return;
|
||||
voiceRuntime?.handleCallState(serverId, message.payload);
|
||||
});
|
||||
|
||||
const unsubTranscription = client.on("transcription_result", (message) => {
|
||||
if (message.type !== "transcription_result") return;
|
||||
|
||||
const transcriptText = message.payload.text.trim();
|
||||
voiceRuntime?.onTranscriptionResult(serverId, transcriptText);
|
||||
});
|
||||
|
||||
const unsubVoiceInputState = client.on("voice_input_state", (message) => {
|
||||
if (message.type !== "voice_input_state") return;
|
||||
voiceRuntime?.onServerSpeechStateChanged(serverId, message.payload.isSpeaking);
|
||||
const unsubVoiceCallEvent = client.on("voice.call.event", (message) => {
|
||||
if (message.type !== "voice.call.event") return;
|
||||
voiceRuntime?.handleCallEvent(serverId, message.payload.callId, message.payload.event);
|
||||
});
|
||||
|
||||
const unsubTerminalAttention = client.on("terminal_attention_required", (message) => {
|
||||
@@ -784,10 +648,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
unsubStatus();
|
||||
unsubPermissionRequest();
|
||||
unsubPermissionResolved();
|
||||
unsubAudioOutput();
|
||||
unsubActivity();
|
||||
unsubTranscription();
|
||||
unsubVoiceInputState();
|
||||
unsubVoiceCallTransport();
|
||||
unsubVoiceCallState();
|
||||
unsubVoiceCallEvent();
|
||||
unsubTerminalAttention();
|
||||
};
|
||||
}, [
|
||||
@@ -808,7 +671,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
updateSessionServerInfo,
|
||||
toast,
|
||||
voiceRuntime,
|
||||
voiceAudioEngine,
|
||||
]);
|
||||
|
||||
const _cancelAgentRun = useCallback(
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||
import type { VoiceAppContext } from "@getpaseo/protocol/messages";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { createAudioEngine } from "@/voice/audio-engine";
|
||||
import type { AudioEngine } from "@/voice/audio-engine-types";
|
||||
import {
|
||||
@@ -16,12 +18,18 @@ import {
|
||||
type VoiceRuntime,
|
||||
type VoiceRuntimeSnapshot,
|
||||
type VoiceRuntimeTelemetrySnapshot,
|
||||
} from "@/voice/voice-runtime";
|
||||
} from "@/voice-chat/runtime";
|
||||
import {
|
||||
createDaemonAudioTransportFactory,
|
||||
type DaemonAudioTransportFactory,
|
||||
} from "@/voice-chat/transports/daemon-audio";
|
||||
|
||||
interface VoiceContextValue extends VoiceRuntimeSnapshot {
|
||||
startVoice: (serverId: string, agentId: string) => Promise<void>;
|
||||
startCall: (serverId: string, context: VoiceAppContext) => Promise<void>;
|
||||
stopVoice: () => Promise<void>;
|
||||
isVoiceModeForAgent: (serverId: string, agentId: string) => boolean;
|
||||
updateCallContext: (context: VoiceAppContext) => Promise<void>;
|
||||
dismissTranscript: () => void;
|
||||
isCallActiveOnServer: (serverId: string) => boolean;
|
||||
toggleMute: () => void;
|
||||
}
|
||||
|
||||
@@ -31,7 +39,8 @@ const EMPTY_SNAPSHOT: VoiceRuntimeSnapshot = {
|
||||
isVoiceSwitching: false,
|
||||
isMuted: false,
|
||||
activeServerId: null,
|
||||
activeAgentId: null,
|
||||
activeCallId: null,
|
||||
events: [],
|
||||
};
|
||||
|
||||
const EMPTY_TELEMETRY: VoiceRuntimeTelemetrySnapshot = {
|
||||
@@ -73,9 +82,11 @@ export function useVoiceOptional(): VoiceContextValue | null {
|
||||
}
|
||||
return {
|
||||
...snapshot,
|
||||
startVoice: runtime.startVoice,
|
||||
startCall: runtime.startCall,
|
||||
stopVoice: runtime.stopVoice,
|
||||
isVoiceModeForAgent: runtime.isVoiceModeForAgent,
|
||||
updateCallContext: runtime.updateCallContext,
|
||||
dismissTranscript: runtime.dismissTranscript,
|
||||
isCallActiveOnServer: runtime.isCallActiveOnServer,
|
||||
toggleMute: runtime.toggleMute,
|
||||
};
|
||||
}, [snapshot, runtime]);
|
||||
@@ -113,17 +124,24 @@ interface VoiceProviderProps {
|
||||
}
|
||||
|
||||
export function VoiceProvider({ children }: VoiceProviderProps) {
|
||||
const toast = useToast();
|
||||
const engineRef = useRef<AudioEngine | null>(null);
|
||||
const runtimeRef = useRef<VoiceRuntime | null>(null);
|
||||
|
||||
if (!engineRef.current) {
|
||||
let runtime: VoiceRuntime | null = null;
|
||||
let daemonAudio: DaemonAudioTransportFactory | null = null;
|
||||
const reportMediaError = (error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : "Voice media failed";
|
||||
toast.error(message);
|
||||
void runtime?.reportError(message);
|
||||
};
|
||||
const engine = createAudioEngine({
|
||||
onCaptureData: (pcm) => {
|
||||
runtime?.handleCapturePcm(pcm);
|
||||
daemonAudio?.handleCapturePcm(pcm);
|
||||
},
|
||||
onVolumeLevel: (level) => {
|
||||
runtime?.handleCaptureVolume(level);
|
||||
daemonAudio?.handleCaptureVolume(level);
|
||||
},
|
||||
onInterruption: () => {
|
||||
void runtime?.stopVoice().catch((error) => {
|
||||
@@ -131,12 +149,13 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("[VoiceEngine] Capture error:", error);
|
||||
reportMediaError(error);
|
||||
},
|
||||
});
|
||||
daemonAudio = createDaemonAudioTransportFactory({ engine, onError: reportMediaError });
|
||||
|
||||
runtime = createVoiceRuntime({
|
||||
engine,
|
||||
transports: [daemonAudio],
|
||||
getServerInfo: (serverId) =>
|
||||
useSessionStore.getState().getSession(serverId)?.serverInfo ?? null,
|
||||
activateKeepAwake: async (tag) => {
|
||||
@@ -156,11 +175,14 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
void runtime.destroy().catch((error) => {
|
||||
console.error("[VoiceProvider] Failed to destroy voice runtime", error);
|
||||
});
|
||||
void runtime
|
||||
.destroy()
|
||||
.then(() => engine.destroy())
|
||||
.catch((error) => {
|
||||
console.error("[VoiceProvider] Failed to destroy voice runtime", error);
|
||||
});
|
||||
};
|
||||
}, [runtime]);
|
||||
}, [engine, runtime]);
|
||||
|
||||
return (
|
||||
<VoiceAudioEngineContext.Provider value={engine}>
|
||||
|
||||
@@ -187,6 +187,19 @@ const CATALOG_DATA = [
|
||||
installLink: "https://geminicli.com",
|
||||
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",
|
||||
title: "GLM Agent",
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
SessionOutboundMessage,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
|
||||
import { replaceProviderSnapshotIcons } from "@/components/provider-icon-name";
|
||||
import { orderCheckoutDiffFiles } from "@/git/diff-order";
|
||||
import { daemonConfigQueryKey } from "@/data/daemon-config";
|
||||
import { daemonPairingOfferQueryKey } from "@/data/daemon-pairing";
|
||||
@@ -194,6 +195,7 @@ export function applyProvidersSnapshotUpdate(input: {
|
||||
if (input.message.type !== "providers_snapshot_update") {
|
||||
return;
|
||||
}
|
||||
replaceProviderSnapshotIcons(input.serverId, input.message.payload.entries);
|
||||
const queryKey = providersSnapshotQueryKey(input.serverId, input.message.payload.cwd);
|
||||
input.queryClient.setQueryData(queryKey, {
|
||||
entries: input.message.payload.entries,
|
||||
|
||||
@@ -34,6 +34,25 @@ export interface DesktopAppLogs {
|
||||
contents: string;
|
||||
}
|
||||
|
||||
export interface DesktopUpdaterDiagnosticFile {
|
||||
path: string;
|
||||
exists: boolean;
|
||||
modifiedAt: string | null;
|
||||
contents: string;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface DesktopUpdaterDiagnostics {
|
||||
platform: string;
|
||||
currentVersion: string;
|
||||
targetVersion: string | null;
|
||||
targetVersionError: string | null;
|
||||
shipItDirectory: string | null;
|
||||
state: DesktopUpdaterDiagnosticFile | null;
|
||||
stdout: DesktopUpdaterDiagnosticFile | null;
|
||||
stderr: DesktopUpdaterDiagnosticFile | null;
|
||||
}
|
||||
|
||||
export interface LocalTransportTarget {
|
||||
[key: string]: unknown;
|
||||
transportType: "socket" | "pipe";
|
||||
@@ -123,6 +142,36 @@ function parseDesktopDaemonLogs(raw: unknown): DesktopDaemonLogs {
|
||||
};
|
||||
}
|
||||
|
||||
function parseDesktopUpdaterDiagnosticFile(raw: unknown): DesktopUpdaterDiagnosticFile | null {
|
||||
if (raw === null) return null;
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected desktop updater diagnostic file response.");
|
||||
}
|
||||
return {
|
||||
path: toStringOrNull(raw.path) ?? "",
|
||||
exists: raw.exists === true,
|
||||
modifiedAt: toStringOrNull(raw.modifiedAt),
|
||||
contents: typeof raw.contents === "string" ? raw.contents : "",
|
||||
error: toStringOrNull(raw.error),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDesktopUpdaterDiagnostics(raw: unknown): DesktopUpdaterDiagnostics {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Unexpected desktop updater diagnostics response.");
|
||||
}
|
||||
return {
|
||||
platform: toStringOrNull(raw.platform) ?? "unknown",
|
||||
currentVersion: toStringOrNull(raw.currentVersion) ?? "unknown",
|
||||
targetVersion: toStringOrNull(raw.targetVersion),
|
||||
targetVersionError: toStringOrNull(raw.targetVersionError),
|
||||
shipItDirectory: toStringOrNull(raw.shipItDirectory),
|
||||
state: parseDesktopUpdaterDiagnosticFile(raw.state),
|
||||
stdout: parseDesktopUpdaterDiagnosticFile(raw.stdout),
|
||||
stderr: parseDesktopUpdaterDiagnosticFile(raw.stderr),
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldUseDesktopDaemon(): boolean {
|
||||
return isElectronRuntime();
|
||||
}
|
||||
@@ -160,6 +209,10 @@ export async function getDesktopAppLogs(): Promise<DesktopAppLogs> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getDesktopUpdaterDiagnostics(): Promise<DesktopUpdaterDiagnostics> {
|
||||
return parseDesktopUpdaterDiagnostics(await invokeDesktopCommand("desktop_update_diagnostics"));
|
||||
}
|
||||
|
||||
export async function getCliDaemonStatus(): Promise<string> {
|
||||
const raw = await invokeDesktopCommand<unknown>("cli_daemon_status");
|
||||
if (typeof raw !== "string") {
|
||||
|
||||
@@ -25,6 +25,34 @@ function makeSources(): DesktopDiagnosticSources {
|
||||
logPath: "/logs/Paseo/main.log",
|
||||
contents: "[login-shell-env] start\n[login-shell-env] failed",
|
||||
}),
|
||||
getUpdaterDiagnostics: async () => ({
|
||||
platform: "darwin",
|
||||
currentVersion: "0.7.0",
|
||||
targetVersion: "0.7.2",
|
||||
targetVersionError: null,
|
||||
shipItDirectory: "/cache/sh.paseo.desktop.ShipIt",
|
||||
state: {
|
||||
path: "/cache/sh.paseo.desktop.ShipIt/ShipItState.plist",
|
||||
exists: true,
|
||||
modifiedAt: "2026-09-04T11:22:19.000Z",
|
||||
contents: '{"launchAfterInstallation":true}',
|
||||
error: null,
|
||||
},
|
||||
stdout: {
|
||||
path: "/cache/sh.paseo.desktop.ShipIt/ShipIt_stdout.log",
|
||||
exists: false,
|
||||
modifiedAt: null,
|
||||
contents: "",
|
||||
error: null,
|
||||
},
|
||||
stderr: {
|
||||
path: "/cache/sh.paseo.desktop.ShipIt/ShipIt_stderr.log",
|
||||
exists: true,
|
||||
modifiedAt: "2026-09-01T08:39:20.000Z",
|
||||
contents: "Installation completed successfully",
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,15 +78,33 @@ describe("desktop diagnostic report", () => {
|
||||
await appLogGate;
|
||||
return makeSources().getAppLogs();
|
||||
},
|
||||
getUpdaterDiagnostics: async () => {
|
||||
calls.push("updater");
|
||||
return makeSources().getUpdaterDiagnostics();
|
||||
},
|
||||
};
|
||||
|
||||
const resultPromise = collectDesktopDiagnosticSections(sources);
|
||||
|
||||
expect(calls).toEqual(["status", "daemonLogs", "appLogs"]);
|
||||
expect(calls).toEqual(["status", "daemonLogs", "appLogs", "updater"]);
|
||||
releaseAppLogs();
|
||||
await expect(resultPromise).resolves.toMatchObject({ status: "done" });
|
||||
});
|
||||
|
||||
test("includes target version lookup errors", async () => {
|
||||
const sources: DesktopDiagnosticSources = {
|
||||
...makeSources(),
|
||||
getUpdaterDiagnostics: async () => ({
|
||||
...(await makeSources().getUpdaterDiagnostics()),
|
||||
targetVersion: null,
|
||||
targetVersionError: "plutil failed",
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await collectDesktopDiagnosticSections(sources);
|
||||
expect(result.sections.join("\n\n")).toContain("Target version error: plutil failed");
|
||||
});
|
||||
|
||||
test("includes the Electron main-process log after the daemon log", async () => {
|
||||
const result = await collectDesktopDiagnosticSections(makeSources());
|
||||
const report = result.sections.join("\n\n");
|
||||
@@ -73,6 +119,14 @@ describe("desktop diagnostic report", () => {
|
||||
expect(report.indexOf("Desktop app log tail")).toBeGreaterThan(
|
||||
report.indexOf("Desktop daemon log tail"),
|
||||
);
|
||||
expect(report).toContain("Desktop updater\n Platform: darwin");
|
||||
expect(report).toContain(" Current version: 0.7.0");
|
||||
expect(report).toContain(" Target version: 0.7.2");
|
||||
expect(report).toContain("ShipItState.plist");
|
||||
expect(report).toContain('{"launchAfterInstallation":true}');
|
||||
expect(report).toContain("ShipIt stdout log tail");
|
||||
expect(report).toContain(" File not found");
|
||||
expect(report).toContain("Installation completed successfully");
|
||||
});
|
||||
|
||||
test("keeps daemon diagnostics when the Electron app log fails", async () => {
|
||||
|
||||
@@ -2,9 +2,12 @@ import {
|
||||
getDesktopAppLogs,
|
||||
getDesktopDaemonLogs,
|
||||
getDesktopDaemonStatus,
|
||||
getDesktopUpdaterDiagnostics,
|
||||
type DesktopAppLogs,
|
||||
type DesktopDaemonLogs,
|
||||
type DesktopDaemonStatus,
|
||||
type DesktopUpdaterDiagnosticFile,
|
||||
type DesktopUpdaterDiagnostics,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { formatDiagnosticSection } from "./app-diagnostic-report";
|
||||
|
||||
@@ -19,12 +22,14 @@ export interface DesktopDiagnosticSources {
|
||||
getStatus: () => Promise<DesktopDaemonStatus>;
|
||||
getDaemonLogs: () => Promise<DesktopDaemonLogs>;
|
||||
getAppLogs: () => Promise<DesktopAppLogs>;
|
||||
getUpdaterDiagnostics: () => Promise<DesktopUpdaterDiagnostics>;
|
||||
}
|
||||
|
||||
const DEFAULT_DESKTOP_DIAGNOSTIC_SOURCES: DesktopDiagnosticSources = {
|
||||
getStatus: getDesktopDaemonStatus,
|
||||
getDaemonLogs: getDesktopDaemonLogs,
|
||||
getAppLogs: getDesktopAppLogs,
|
||||
getUpdaterDiagnostics: getDesktopUpdaterDiagnostics,
|
||||
};
|
||||
|
||||
export async function collectDesktopDiagnosticSections(
|
||||
@@ -33,9 +38,10 @@ export async function collectDesktopDiagnosticSections(
|
||||
const sections: string[] = [];
|
||||
let failed = false;
|
||||
|
||||
const [daemonResult, appLogsResult] = await Promise.allSettled([
|
||||
const [daemonResult, appLogsResult, updaterResult] = await Promise.allSettled([
|
||||
Promise.all([sources.getStatus(), sources.getDaemonLogs()]),
|
||||
sources.getAppLogs(),
|
||||
sources.getUpdaterDiagnostics(),
|
||||
]);
|
||||
|
||||
if (daemonResult.status === "fulfilled") {
|
||||
@@ -62,12 +68,63 @@ export async function collectDesktopDiagnosticSections(
|
||||
);
|
||||
}
|
||||
|
||||
if (updaterResult.status === "fulfilled") {
|
||||
sections.push(...formatDesktopUpdaterSections(updaterResult.value));
|
||||
} else {
|
||||
failed = true;
|
||||
sections.push(
|
||||
formatDiagnosticSection("Desktop updater", [
|
||||
{ label: "Error", value: toMessage(updaterResult.reason) },
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
status: failed ? "failed" : "done",
|
||||
sections,
|
||||
};
|
||||
}
|
||||
|
||||
function formatDesktopUpdaterSections(diagnostics: DesktopUpdaterDiagnostics): string[] {
|
||||
const updaterDetails = [
|
||||
{ label: "Platform", value: diagnostics.platform },
|
||||
{ label: "Current version", value: diagnostics.currentVersion },
|
||||
{ label: "Target version", value: diagnostics.targetVersion ?? "unknown" },
|
||||
{ label: "ShipIt directory", value: diagnostics.shipItDirectory ?? "not applicable" },
|
||||
];
|
||||
if (diagnostics.targetVersionError) {
|
||||
updaterDetails.push({ label: "Target version error", value: diagnostics.targetVersionError });
|
||||
}
|
||||
const sections = [formatDiagnosticSection("Desktop updater", updaterDetails)];
|
||||
|
||||
if (diagnostics.platform !== "darwin") return sections;
|
||||
|
||||
sections.push(
|
||||
formatUpdaterFileSection("ShipItState.plist", diagnostics.state),
|
||||
formatUpdaterFileSection("ShipIt stdout log tail", diagnostics.stdout),
|
||||
formatUpdaterFileSection("ShipIt stderr log tail", diagnostics.stderr),
|
||||
);
|
||||
return sections;
|
||||
}
|
||||
|
||||
function formatUpdaterFileSection(
|
||||
title: string,
|
||||
file: DesktopUpdaterDiagnosticFile | null,
|
||||
): string {
|
||||
if (!file) {
|
||||
return formatDiagnosticSection(title, [{ label: "Status", value: "unavailable" }]);
|
||||
}
|
||||
|
||||
const header = formatDiagnosticSection(title, [
|
||||
{ label: "Path", value: file.path || "unknown" },
|
||||
{ label: "Modified", value: file.modifiedAt ?? "unknown" },
|
||||
]);
|
||||
if (file.error) return `${header}\n Error: ${file.error}`;
|
||||
if (!file.exists) return `${header}\n File not found`;
|
||||
if (!file.contents) return `${header}\n No contents found`;
|
||||
return `${header}\n${indentBlock(file.contents)}`;
|
||||
}
|
||||
|
||||
function formatDesktopDaemonSections(input: {
|
||||
status: DesktopDaemonStatus;
|
||||
daemonLogs: DesktopDaemonLogs;
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
diffHeaderViewportKey,
|
||||
diffInteractionWindowTop,
|
||||
resolveVisibleFileSections,
|
||||
} from "./header-layout";
|
||||
import { diffInteractionWindowTop, resolveVisibleFileSections } from "./header-layout";
|
||||
import type { DiffFileSection } from "./types";
|
||||
|
||||
describe("diff header viewport", () => {
|
||||
@@ -48,15 +44,6 @@ describe("diff header viewport", () => {
|
||||
?.y,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("reuses a fixed sticky header until its visible layout changes", () => {
|
||||
const files = [section(0, 0, 4_000, false), section(1, 4_000, 4_030, true)];
|
||||
const keyAt = (scrollTop: number) =>
|
||||
diffHeaderViewportKey({ files, scrollTop, viewportHeight: 600, activePath: null });
|
||||
|
||||
expect(keyAt(100)).toBe(keyAt(2_000));
|
||||
expect(keyAt(3_985)).not.toBe(keyAt(2_000));
|
||||
});
|
||||
});
|
||||
|
||||
describe("diff interaction window", () => {
|
||||
|
||||
@@ -21,20 +21,6 @@ export function diffMaterializationWindow(
|
||||
};
|
||||
}
|
||||
|
||||
export function diffHeaderViewportKey(input: {
|
||||
files: readonly DiffFileSection[];
|
||||
scrollTop: number;
|
||||
viewportHeight: number;
|
||||
activePath: string | null;
|
||||
}): string {
|
||||
const window = resolveVisibleFileSections({ ...input, overscan: 0 });
|
||||
const headers = window.files
|
||||
.filter((file) => file !== window.sticky?.file)
|
||||
.map((file) => `${file.fileIndex}:${file.top - input.scrollTop}`);
|
||||
if (window.sticky) headers.push(`${window.sticky.file.fileIndex}:${window.sticky.y}`);
|
||||
return `${input.activePath ?? ""}|${headers.join(",")}`;
|
||||
}
|
||||
|
||||
export function resolveVisibleFileSections(input: {
|
||||
files: readonly DiffFileSection[];
|
||||
scrollTop: number;
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
characterOffsetAtPoint,
|
||||
hitTestDiffDocument,
|
||||
orderedSelection,
|
||||
selectAllSource,
|
||||
selectCellSource,
|
||||
selectedSourceText,
|
||||
} from "./hit-testing";
|
||||
import { buildDiffDocumentModel } from "./model";
|
||||
@@ -104,6 +106,64 @@ describe("diff hit testing", () => {
|
||||
}),
|
||||
).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> = {}) {
|
||||
@@ -212,6 +272,29 @@ 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<
|
||||
BuildDiffDocumentModelInput["reviewActions"]
|
||||
> {
|
||||
|
||||
@@ -105,6 +105,64 @@ export function selectedSourceText(model: DiffDocumentModel, selection: DiffSele
|
||||
.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 {
|
||||
return (
|
||||
left.rowIndex - right.rowIndex ||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user