mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2e8a44848 | ||
|
|
81bffbd01a | ||
|
|
9519c5ba99 | ||
|
|
194822b939 | ||
|
|
2505075beb | ||
|
|
0c3c61be94 | ||
|
|
7046b118e9 | ||
|
|
3fed2f2cd9 | ||
|
|
234f0d9b75 | ||
|
|
548e15c177 | ||
|
|
6240b2d17c | ||
|
|
4e99e4c18c | ||
|
|
2f07c5918f | ||
|
|
3e6061b169 | ||
|
|
4ed944db2f | ||
|
|
af244d0f02 | ||
|
|
f2124f8cf9 | ||
|
|
152cdcabad | ||
|
|
44da3cdf05 | ||
|
|
a8c6c2f4bc | ||
|
|
faccbaae59 | ||
|
|
e61c06706c | ||
|
|
c54ddfd07f | ||
|
|
ddaaaa27be | ||
|
|
fbd02437dc | ||
|
|
b447a650a7 | ||
|
|
4d2acfbeb5 | ||
|
|
d6bc201f46 | ||
|
|
5e75e463ec | ||
|
|
35d4ae201a | ||
|
|
b93d326fb2 | ||
|
|
9505e43925 | ||
|
|
2f0e3451de | ||
|
|
28cac5859a | ||
|
|
59d9a25a0d | ||
|
|
60462337b6 | ||
|
|
f44ef807ef | ||
|
|
f030ddefb1 | ||
|
|
f7a6548b68 | ||
|
|
ab9c9b96a7 | ||
|
|
8fe11e3bb0 |
@@ -200,6 +200,41 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
import-electron:
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies with retry
|
||||
run: node scripts/npm-retry.mjs ci
|
||||
|
||||
- name: Build app and server dependencies
|
||||
run: |
|
||||
npm run build:app-deps
|
||||
npm run build:server
|
||||
|
||||
- name: Build Desktop main
|
||||
run: npm run build:main --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Run product import behavior
|
||||
run: npm run test:import-electron --workspace=@getpaseo/desktop
|
||||
|
||||
- name: Upload import behavior diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: import-electron-macos
|
||||
path: |
|
||||
packages/app/test-results/
|
||||
packages/app/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
app-tests:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
|
||||
@@ -1,65 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0-beta.3 - 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
- Open the complete Changes view as a workspace tab ([#2298](https://github.com/getpaseo/paseo/pull/2298) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Add files to chat directly from Files and Changes ([#2275](https://github.com/getpaseo/paseo/pull/2275) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Open existing agents from Paseo links or the CLI ([#2324](https://github.com/getpaseo/paseo/pull/2324))
|
||||
- Use Oh My Pi's Write Approval mode to allow reads while requiring approval for changes ([#2228](https://github.com/getpaseo/paseo/pull/2228) by [@theslava](https://github.com/theslava))
|
||||
|
||||
### Improved
|
||||
|
||||
- Usage bars now warn as provider limits approach ([#2322](https://github.com/getpaseo/paseo/pull/2322) by [@cleiter](https://github.com/cleiter))
|
||||
- Oh My Pi advisor results now retain their structure and severity ([#2219](https://github.com/getpaseo/paseo/pull/2219) by [@ebg1223](https://github.com/ebg1223))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Notifications now open the correct workspace and agent ([#2331](https://github.com/getpaseo/paseo/pull/2331))
|
||||
- Archived agents can be restored directly from History ([#2316](https://github.com/getpaseo/paseo/pull/2316))
|
||||
- CLI agent runs stay in the current workspace unless a new workspace is requested ([#2315](https://github.com/getpaseo/paseo/pull/2315))
|
||||
- Oh My Pi slash commands now include commands from every supported source ([#2175](https://github.com/getpaseo/paseo/pull/2175) by [@bendavid](https://github.com/bendavid))
|
||||
- Oh My Pi chats no longer stay stuck as running after delayed or incomplete completion events ([#2261](https://github.com/getpaseo/paseo/pull/2261), [#2282](https://github.com/getpaseo/paseo/pull/2282) by [@isac322](https://github.com/isac322))
|
||||
|
||||
## 0.2.0-beta.2 - 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
- Edit files directly in the web and desktop apps ([#2270](https://github.com/getpaseo/paseo/pull/2270), [#2309](https://github.com/getpaseo/paseo/pull/2309))
|
||||
- Switch models from the Command Center for active agents and new drafts ([#2147](https://github.com/getpaseo/paseo/pull/2147) by [@kedrzu](https://github.com/kedrzu))
|
||||
- Configure workspace service ports with a fixed range or external allocator ([#2165](https://github.com/getpaseo/paseo/pull/2165) by [@mcowger](https://github.com/mcowger))
|
||||
- Search keyboard shortcuts by action, note, or key combination ([#2160](https://github.com/getpaseo/paseo/pull/2160))
|
||||
- Turn thinking off for supported Claude models ([#2257](https://github.com/getpaseo/paseo/pull/2257))
|
||||
- Use Pi's Max thinking level ([#2267](https://github.com/getpaseo/paseo/pull/2267) by [@ByteTrue](https://github.com/ByteTrue))
|
||||
|
||||
### Improved
|
||||
|
||||
- Improved parity of CLI and MCP tools for workspace, agent and schedule management ([#2186](https://github.com/getpaseo/paseo/pull/2186))
|
||||
- Pasted PR/MR links in the composer become auto-selected as a checkout option ([#2290](https://github.com/getpaseo/paseo/pull/2290))
|
||||
- Projects, workspaces and chat syncing is more efficient ([#2028](https://github.com/getpaseo/paseo/pull/2028), [#2185](https://github.com/getpaseo/paseo/pull/2185), [#2196](https://github.com/getpaseo/paseo/pull/2196), [#2206](https://github.com/getpaseo/paseo/pull/2206), [#2259](https://github.com/getpaseo/paseo/pull/2259), [#2263](https://github.com/getpaseo/paseo/pull/2263))
|
||||
- Make project creation more explicit ([#2098](https://github.com/getpaseo/paseo/pull/2098), [#2187](https://github.com/getpaseo/paseo/pull/2187))
|
||||
- Idle agents release processes automatically and resume when needed ([#2203](https://github.com/getpaseo/paseo/pull/2203), [#2209](https://github.com/getpaseo/paseo/pull/2209))
|
||||
- New Claude and Codex agents default to safer automatic approval modes when supported ([#2213](https://github.com/getpaseo/paseo/pull/2213))
|
||||
- Oh My Pi now supports Max thinking in imported and new sessions ([#2191](https://github.com/getpaseo/paseo/pull/2191) by [@mvanhorn](https://github.com/mvanhorn))
|
||||
- Commit history now includes recent pushed and base-branch commits ([#2312](https://github.com/getpaseo/paseo/pull/2312))
|
||||
- Permission and thinking changes made during a turn now show when they take effect ([#2201](https://github.com/getpaseo/paseo/pull/2201))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reused branches no longer attach an unrelated merged or closed pull request ([#2172](https://github.com/getpaseo/paseo/pull/2172) by [@nllptrx](https://github.com/nllptrx))
|
||||
- Pi compaction waits for long summaries instead of reporting a false timeout ([#2181](https://github.com/getpaseo/paseo/pull/2181) by [@jasonhnd](https://github.com/jasonhnd))
|
||||
- Pi chats keep new messages aligned with the correct history after an idle agent resumes ([#2313](https://github.com/getpaseo/paseo/pull/2313))
|
||||
- OpenCode follow-ups triggered by completed background work now remain visible ([#2258](https://github.com/getpaseo/paseo/pull/2258))
|
||||
- Codex no longer shows the parent agent as a phantom subagent ([#2214](https://github.com/getpaseo/paseo/pull/2214))
|
||||
- Local dictation now works in Nix-packaged installations ([#1587](https://github.com/getpaseo/paseo/pull/1587) by [@yhori991](https://github.com/yhori991))
|
||||
- The composer remains visible after submitting dictated text and returning to the app ([#2194](https://github.com/getpaseo/paseo/pull/2194))
|
||||
- Desktop's dictation shortcut remains responsive after finishing a recording ([#2268](https://github.com/getpaseo/paseo/pull/2268))
|
||||
- Projects can be renamed before their first workspace ([#2252](https://github.com/getpaseo/paseo/pull/2252) by [@albertodeago](https://github.com/albertodeago))
|
||||
- Settings keep showing a connected remote host when the local daemon is stopped ([#1749](https://github.com/getpaseo/paseo/pull/1749) by [@dwyanewang](https://github.com/dwyanewang))
|
||||
- Pinned workspaces no longer disappear briefly when reopening the compact sidebar ([#2210](https://github.com/getpaseo/paseo/pull/2210))
|
||||
- Session imports find older matches from the current workspace ([#2265](https://github.com/getpaseo/paseo/pull/2265) by [@nikuscs](https://github.com/nikuscs))
|
||||
|
||||
## 0.2.0-beta.1 - 2026-07-17
|
||||
|
||||
### Added
|
||||
|
||||
@@ -80,13 +80,6 @@ archived workspace. History navigation must not infer workspace lifecycle from `
|
||||
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
|
||||
only the route's explicit Unarchive or Restore action changes the archived workspace.
|
||||
|
||||
History navigation preserves the selected agent as an explicit recovery target. If both that agent
|
||||
and its workspace are archived, the workspace recovery action restores the workspace and unarchives
|
||||
the selected agent as one user action. Other archived agents in the restored workspace remain
|
||||
recoverable from History. Opening one pins its tab and renders the archived-agent callout before any
|
||||
provider timeline is loaded; **Unarchive** runs the provider's native unarchive hook (including Codex
|
||||
`thread/unarchive`) before the normal agent resume and timeline hydration flow.
|
||||
|
||||
## Tabs vs archive
|
||||
|
||||
These are two distinct concepts that used to be conflated:
|
||||
|
||||
+13
-1
@@ -91,7 +91,19 @@ it does not depend on the server.
|
||||
Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient`
|
||||
facade. App and CLI may import the low-level driver from
|
||||
`@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped
|
||||
code imports from `@getpaseo/client`.
|
||||
code imports from `@getpaseo/client`. Node automation uses
|
||||
`@getpaseo/client/node`, which owns daemon discovery, authentication, transport
|
||||
selection, and the capability-gated host automation facade shared by the CLI and
|
||||
published import tools.
|
||||
|
||||
### External project import
|
||||
|
||||
The private [`getpaseo/import`](https://github.com/getpaseo/import) repository publishes
|
||||
`@getpaseo/import`. It owns source discovery, parsing, mapping, fixtures, the CLI, and the
|
||||
programmatic import engine. Paseo Desktop pins an exact package version and calls its library
|
||||
entrypoint from Electron main, passing the public Node host-automation facade and forwarding typed
|
||||
progress events through a narrow IPC bridge. Paseo does not contain source databases, parsers, or
|
||||
import-package release machinery.
|
||||
|
||||
### `packages/app` — Mobile + web client (Expo)
|
||||
|
||||
|
||||
@@ -190,10 +190,6 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
},
|
||||
worktrees?: {
|
||||
root?: string // optional root for new worktrees; defaults to $PASEO_HOME/worktrees
|
||||
servicePorts?: { // optional dynamic service port allocation policy
|
||||
range?: string // inclusive range, e.g. "3000-4000"
|
||||
portScript?: string // executable that receives service/workspace context and prints one TCP port
|
||||
}
|
||||
},
|
||||
providers: {
|
||||
openai: {
|
||||
|
||||
@@ -64,8 +64,6 @@ The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has f
|
||||
|
||||
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
||||
|
||||
Sizes are a shared contract across control kinds, defined once in `control-geometry.ts`: `xs` = 28px tall with `fontSize.xs` labels, `sm` = 32px with `fontSize.sm`, `md`/`lg` = 44px with `fontSize.sm`. `<SegmentedControl>` (`packages/app/src/components/ui/segmented-control.tsx`) takes the same `xs`/`sm`/`md` sizes — a segmented control next to a `<Button>` of the same size always matches in height, label size, and horizontal padding. Thin chrome such as the file toolbar uses `xs`; settings rows use `sm`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use.
|
||||
|
||||
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
||||
|
||||
---
|
||||
|
||||
+1
-16
@@ -278,15 +278,6 @@ Service proxy hostnames use the double-dash shape: `web--feature-auth--project.l
|
||||
}
|
||||
```
|
||||
|
||||
Service ports use OS ephemeral allocation by default. Set `worktrees.servicePorts` in
|
||||
`$PASEO_HOME/config.json`, or replace it for one project with `worktree.servicePorts` in
|
||||
`paseo.json`. The block accepts an inclusive `range` such as `"3000-4000"` or a `portScript`
|
||||
executable. Since `portScript` is executed directly without a shell, it must point to a real executable (e.g., a binary or a script with a proper shebang like `#!/bin/sh`) rather than an inline shell command or shell pipeline. For inline shell commands or pipelines, wrap them in a small script. `portScript` runs in the workspace directory with four arguments: service name,
|
||||
workspace ID, branch name, and worktree path. A missing branch is passed as an empty string. The same
|
||||
values are available as `PASEO_SCRIPTNAME`, `PASEO_WORKSPACE_ID`, `PASEO_BRANCH_NAME`, and
|
||||
`PASEO_WORKTREE_PATH`. The script must print one valid TCP port. Paseo trusts the external allocator,
|
||||
so the port may already be bound. `portScript` takes precedence when both values are present.
|
||||
|
||||
## Bundled daemon web UI
|
||||
|
||||
> The user-facing guide for this feature (enabling it, reverse proxy, TLS, tunnels, security) lives at [public-docs/web-ui.md](../public-docs/web-ui.md). This section is the contributor/build reference: how the artifact is produced, bundled, and excluded from desktop packaging.
|
||||
@@ -383,14 +374,13 @@ install.
|
||||
|
||||
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The script wraps the CLI with `scripts/dev-home.sh`, so it automatically uses this checkout's `.dev/paseo-home` and dev daemon endpoint unless you pass an explicit override. The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
|
||||
|
||||
Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --new-workspace local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
|
||||
Canonical automation uses `paseo workspace create/ls/archive`, `paseo heartbeat create/update/delete`, and the full `paseo schedule` group. MCP heartbeat automation is intentionally smaller: create and delete only. Detach remains an explicit user lifecycle action rather than an agent tool. `paseo run --isolation local|worktree` composes workspace creation with agent creation. The old `paseo worktree` and `paseo run --worktree` forms are hidden compatibility aliases.
|
||||
|
||||
```bash
|
||||
npm run cli -- ls -a -g # List all agents globally
|
||||
npm run cli -- ls -a -g --json # Same, as JSON
|
||||
npm run cli -- inspect <id> # Show detailed agent info
|
||||
npm run cli -- logs <id> # View agent timeline
|
||||
npm run cli -- agent open <id> # Focus an existing agent in Paseo Desktop
|
||||
npm run cli -- daemon status # Check daemon status
|
||||
npm run cli -- clone owner/repo --dir ~/workspace # Clone GitHub repo and register project
|
||||
```
|
||||
@@ -401,11 +391,6 @@ Use `--host <host:port>` to point the CLI at a different daemon:
|
||||
npm run cli -- --host localhost:7777 ls -a
|
||||
```
|
||||
|
||||
Desktop integrations can focus an existing agent without creating one or
|
||||
sending a message. Use `paseo://h/<server-id>/agent/<agent-id>`, or run
|
||||
`paseo agent open <agent-id>`. The CLI reads the local daemon's server ID by
|
||||
default; pass `--server <server-id>` when targeting another server.
|
||||
|
||||
## Agent state
|
||||
|
||||
Agent data lives at:
|
||||
|
||||
@@ -73,20 +73,6 @@ 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.
|
||||
|
||||
## Agent Targets
|
||||
|
||||
Notifications and agent URLs enter the router with different authoritative
|
||||
targets.
|
||||
|
||||
- Notifications carry `serverId`, `workspaceId`, and `agentId`. Route them
|
||||
directly to the workspace with the agent open intent.
|
||||
- Agent URLs carry only `serverId` and `agentId`. Route them through
|
||||
`/h/[serverId]/agent/[agentId]`; that route waits for the named host, resolves
|
||||
the agent's workspace from the host, and then opens the agent there.
|
||||
|
||||
Both paths converge on `navigateToAgent()`. Do not make notification routing
|
||||
guess a workspace, and do not add a workspace to the stable agent URL format.
|
||||
|
||||
## Params
|
||||
|
||||
Required dynamic params belong to the matched route.
|
||||
|
||||
@@ -57,7 +57,7 @@ Android touches sailed straight through to the chat scroll view behind it.
|
||||
|
||||
Two escape hatches in the codebase:
|
||||
|
||||
- **`Modal`** (combobox, dropdown menu and tooltip on native) — opens a new Android window, so
|
||||
- **`Modal`** (combobox, tooltip on native) — opens a new Android window, so
|
||||
hit-testing starts fresh in that window. Side effect: a Modal opening on
|
||||
Android can detach the IME from an underlying TextInput. Fine for combobox
|
||||
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
|
||||
@@ -70,13 +70,7 @@ Two escape hatches in the codebase:
|
||||
overlays can use the current `FloatingPanelPortalHost` so sliding sidebars
|
||||
cover them.
|
||||
|
||||
Choose Modal vs Portal by whether the underlying input can lose its keyboard.
|
||||
|
||||
On web, dropdown menus render into the shared `overlay-root`, not React Native
|
||||
Web's `<Modal>`/`<dialog>`. A browser top-layer dialog always paints above
|
||||
ordinary portals regardless of `z-index`, which would hide app toasts and
|
||||
tooltips behind the menu. The shared overlay scale keeps menus below toasts and
|
||||
lets tooltip portals paint above both.
|
||||
Choose Modal vs Portal by whether the underlying input can lose its keyboard.
|
||||
|
||||
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
|
||||
|
||||
|
||||
+1
-3
@@ -24,7 +24,7 @@ Paseo tools are not implemented as MCP tools internally. They live in a shared t
|
||||
|
||||
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.
|
||||
|
||||
Paseo's per-agent and daemon-wide system prompts are appended by its generated Pi integration extension. Paseo deliberately does not pass `--append-system-prompt`, because that flag replaces Pi's automatic `APPEND_SYSTEM.md` discovery instead of composing with it.
|
||||
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
|
||||
|
||||
Pi model records expose input capabilities through `model.input`. Only send raw RPC `images` when the current model explicitly includes `"image"` in that list. Text-only Pi/OMP models reject image content and persist the rejected image in JSONL history, so image prompts for those models must be materialized to a local file and passed as a text path hint instead.
|
||||
|
||||
@@ -44,8 +44,6 @@ OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prom
|
||||
|
||||
Every provider adapter owns its canonical user-message timeline rows. When a foreground prompt is accepted, the adapter must emit exactly one `user_message` timeline item for that submitted prompt, using the same message ID it gives to or receives from the provider runtime. Optimistic client messages are UI-only and provider transcript echoes are optional; neither is allowed to be the only source of truth. If the provider later echoes the same submitted user message, dedupe it only within the active turn. Prefer provider-visible message IDs, but ACP runtimes may omit that ID or replace it with a provider-owned one; in that case suppress only echo chunks whose accumulated text is a prefix of the active submitted prompt. Do not perform global transcript text dedupe.
|
||||
|
||||
Submitted user-message rows preserve both identities: `messageId` is the provider-visible ID and the optional `clientMessageId` is the Paseo ID from `AgentRunOptions`. Attach `clientMessageId` only to the canonical row for that foreground submission; provider history and externally initiated user rows do not have a Paseo client ID.
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft feature and command listing must use the explicit draft model only; if no model is selected yet, return no metadata instead of resolving a default model through catalog discovery.
|
||||
|
||||
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
|
||||
|
||||
@@ -107,6 +107,6 @@ Codex also receives the Windows equivalent:
|
||||
if defined PASEO_TERMINAL_ID (if defined PASEO_HOOK_CLI ("%PASEO_HOOK_CLI%" hooks codex <event>) else (paseo hooks codex <event>))
|
||||
```
|
||||
|
||||
The daemon resolves the current CLI through `PASEO_CLI` when its launcher supplies one, or through the npm package shim for standalone installs. Terminal setup exposes that resolved executable to hooks as `PASEO_HOOK_CLI`; desktop and other daemon launchers do not know about the hook-specific variable. The generated command falls back to bare `paseo` if the hook env is missing and no-ops outside Paseo terminals because the `PASEO_TERMINAL_ID` gate remains first. Paseo also prepends the resolved CLI directory to each terminal `PATH` as a secondary fallback. All other behavior lives in `paseo hooks`: read the env, map the event, POST activity, and no-op/fail-open when anything is missing or unavailable.
|
||||
Paseo injects `PASEO_HOOK_CLI` so Codex's hook shell cannot pick up a stale global `paseo` before the current one. The command still falls back to bare `paseo` if the env is missing, and it still no-ops outside Paseo terminals because the `PASEO_TERMINAL_ID` gate remains first. Paseo also prepends the CLI binary directory to each terminal `PATH` as a secondary fallback. All other behavior lives in `paseo hooks`: read the env, map the event, POST activity, and no-op/fail-open when anything is missing or unavailable.
|
||||
|
||||
If config installation fails, daemon startup and terminal spawn continue without terminal activity hooks.
|
||||
|
||||
@@ -156,6 +156,15 @@ Vitest picks up tests by suffix. The suffix tells the runner which category it b
|
||||
|
||||
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
|
||||
|
||||
Desktop import coverage starts from the real product Integrations row and crosses the real Electron
|
||||
main process, compiled sandboxed preload, production import IPC, the installed import library, and a
|
||||
real Desktop-managed daemon. It covers confirmation, streamed output, success, failure, and
|
||||
ineligible-host UI:
|
||||
|
||||
```bash
|
||||
npm run test:import-electron --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI.
|
||||
|
||||
Codex MultiAgentV2 real tests use local Codex authentication rather than the OpenRouter-compatible test provider. OpenRouter does not accept Codex collaboration-history items on the parent follow-up request, so it cannot verify a complete native sub-agent turn.
|
||||
|
||||
+8
-21
@@ -51,27 +51,17 @@ When a client resumes with a known cursor, it catches up after that cursor to co
|
||||
|
||||
When a client resumes without a cursor, it fetches the latest tail page.
|
||||
|
||||
## Client replica lifetime
|
||||
|
||||
The host runtime owns each session replica for as long as the host remains registered. React
|
||||
providers attach message handlers and UI integrations to that replica, but mounting or unmounting a
|
||||
provider must not create or clear it. A provider can remount during Fast Refresh or ordinary UI
|
||||
recomposition while the runtime still owns the same directory snapshot and timeline cursors.
|
||||
|
||||
Removing the host from the registry is the destructive boundary: it stops the runtime and clears the
|
||||
session and host-scoped setup state together.
|
||||
|
||||
## Selective and legacy delivery
|
||||
|
||||
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
|
||||
|
||||
- Selective daemons receive the union of agents visible in every pane. Additions subscribe and
|
||||
catch up immediately. Every visibility-driven removal, including app backgrounding, stays
|
||||
subscribed for a 30-second grace period so brief tab, pane, route, and app switches do not repeatedly
|
||||
subscribed for a short grace period so brief tab, pane, route, and app switches do not repeatedly
|
||||
unsubscribe and catch up. Losing window keyboard focus does not make a selected pane invisible.
|
||||
Disconnecting and disposal clear pending grace because the subscription itself no longer exists.
|
||||
After grace has expired, revisiting a retained timeline displays its cached state immediately and
|
||||
authoritative catch-up advances it to the current tail.
|
||||
After grace has expired, a retained timeline stays covered when revisited until authoritative
|
||||
catch-up completes; cached partial output is never presented as current history.
|
||||
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
|
||||
authoritative catch-up, but the app does not issue selective-subscription RPCs.
|
||||
|
||||
@@ -87,14 +77,11 @@ its completion advances `seqEnd`, followed by a merged assistant message. The ap
|
||||
remaining page through the existing stream reducer. It must not append full projected text to a
|
||||
live prefix.
|
||||
|
||||
Optimistic user prompts occupy stable timeline slots. Catch-up never extracts, delays, or reinserts
|
||||
them. A canonical user row replaces its matching slot in place; an unmatched prompt stays exactly
|
||||
where the user submitted it. Other canonical rows are applied after the already-present timeline
|
||||
instead of relocating visible user messages around newly fetched history.
|
||||
|
||||
Canonical submitted user rows carry the provider's `messageId` and Paseo's optional
|
||||
`clientMessageId`. Clients reconcile optimistic prompts by `clientMessageId`. Content matching is
|
||||
limited to the dated compatibility path for daemon timelines created before that field existed.
|
||||
Optimistic user prompts are presentation state rather than canonical history. Incremental catch-up
|
||||
temporarily separates them, applies canonical entries, lets canonical user rows reconcile through
|
||||
the existing optimistic-message rules, then restores any unmatched prompts after the caught-up
|
||||
history. This keeps late history before a newly submitted prompt without duplicating an
|
||||
acknowledged prompt.
|
||||
|
||||
## Relevant code
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-Ys/R5+0O3SDYeCSd7hatTuCwV9S0kIO8vhmPKGZsvn0=
|
||||
sha256-MjAAaU+zU3m5fdE9rQrr4aA3eTDZztIXJQVXKbj73rY=
|
||||
|
||||
+5
-8
@@ -5,7 +5,6 @@
|
||||
nodejs_22,
|
||||
python3,
|
||||
makeWrapper,
|
||||
autoPatchelfHook,
|
||||
# node-pty needs libuv headers on Linux
|
||||
libuv,
|
||||
# Exposed so downstream flakes that follow a different nixpkgs revision
|
||||
@@ -60,13 +59,10 @@ buildNpmPackage rec {
|
||||
nativeBuildInputs = [
|
||||
python3 # for node-gyp (node-pty compilation)
|
||||
makeWrapper
|
||||
] ++ lib.optionals stdenv.hostPlatform.isLinux [
|
||||
autoPatchelfHook
|
||||
];
|
||||
|
||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
|
||||
libuv
|
||||
stdenv.cc.cc.lib # libstdc++ for sherpa-onnx prebuilt binaries
|
||||
];
|
||||
|
||||
# Don't use the default npm build hook — we need a custom build sequence
|
||||
@@ -75,9 +71,10 @@ buildNpmPackage rec {
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
# Rebuild only node-pty (native addon for terminal emulation). The sherpa
|
||||
# speech runtime ships prebuilt platform packages and is copied into the
|
||||
# daemon closure by scripts/trace-daemon.mjs.
|
||||
# Rebuild only node-pty (native addon for terminal emulation).
|
||||
# Speech-related native modules (sherpa-onnx, onnxruntime-node) are
|
||||
# intentionally left unbuilt — they're lazily loaded and gracefully
|
||||
# degrade when unavailable.
|
||||
npm rebuild node-pty
|
||||
|
||||
# Build all server packages in dependency order (defined in package.json)
|
||||
@@ -92,7 +89,7 @@ buildNpmPackage rec {
|
||||
|
||||
# Compute the daemon's runtime closure by static module-graph tracing
|
||||
# (@vercel/nft from supervisor-entrypoint.js, cli/dist/index.js, and the
|
||||
# forked terminal/speech worker processes) plus an explicit list of non-JS
|
||||
# forked terminal-worker-process.js) plus an explicit list of non-JS
|
||||
# assets read at runtime. The trace script is the single source of
|
||||
# truth for what the daemon needs at $out — auditable in plain JS, no
|
||||
# npm hoisting / .bin / workspace-symlink footguns.
|
||||
|
||||
Generated
+62
-88
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0-beta.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0-beta.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -2531,35 +2531,10 @@
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
||||
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
|
||||
"integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
|
||||
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||
"version": "6.12.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
||||
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
@@ -2579,33 +2554,22 @@
|
||||
"@codemirror/language": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
|
||||
"integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.37.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
|
||||
"integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
|
||||
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
|
||||
"integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
|
||||
"version": "6.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
|
||||
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
@@ -11091,19 +11055,6 @@
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@replit/codemirror-vim": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.3.0.tgz",
|
||||
"integrity": "sha512-aTx931ULAMuJx6xLf7KQDOL7CxD+Sa05FktTDrtLaSy53uj01ll3Zf17JdKsriER248oS55GBzg0CfCTjEneAQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@codemirror/commands": "6.x.x",
|
||||
"@codemirror/language": "6.x.x",
|
||||
"@codemirror/search": "6.x.x",
|
||||
"@codemirror/state": "6.x.x",
|
||||
"@codemirror/view": "6.x.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
|
||||
@@ -31775,7 +31726,6 @@
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.0.tgz",
|
||||
"integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
@@ -31940,6 +31890,12 @@
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/sql.js": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
|
||||
"integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/srvx": {
|
||||
"version": "0.11.15",
|
||||
"resolved": "https://registry.npmjs.org/srvx/-/srvx-0.11.15.tgz",
|
||||
@@ -35211,13 +35167,8 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0-beta.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -35232,7 +35183,6 @@
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native/normalize-colors": "^0.81.5",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@replit/codemirror-vim": "6.3.0",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-virtual": "^3.13.21",
|
||||
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
||||
@@ -36236,12 +36186,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0-beta.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.2.0-beta.3",
|
||||
"@getpaseo/protocol": "0.2.0-beta.3",
|
||||
"@getpaseo/server": "0.2.0-beta.3",
|
||||
"@getpaseo/client": "0.2.0-beta.1",
|
||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
||||
"@getpaseo/server": "0.2.0-beta.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -36487,24 +36437,27 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0-beta.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.2.0-beta.3",
|
||||
"@getpaseo/relay": "0.2.0-beta.3",
|
||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
||||
"@getpaseo/relay": "0.2.0-beta.1",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0-beta.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
"@getpaseo/import": "0.1.0-beta.2",
|
||||
"@getpaseo/server": "*",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
@@ -36512,6 +36465,7 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@types/node": "24.6.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"electron": "41.2.0",
|
||||
@@ -36522,6 +36476,26 @@
|
||||
"wait-on": "8.0.5"
|
||||
}
|
||||
},
|
||||
"packages/desktop/node_modules/@getpaseo/import": {
|
||||
"version": "0.1.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/@getpaseo/import/-/import-0.1.0-beta.2.tgz",
|
||||
"integrity": "sha512-1t+kw6gUQpaz30BLWIwnm252DpdyMANDGq7sRmb0fRz4Eetsz/QAefTRiYUkS/eKVvfDd3avH9HCIU/DzAXCgQ==",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
||||
"smol-toml": "^1.6.0",
|
||||
"sql.js": "^1.14.1"
|
||||
},
|
||||
"bin": {
|
||||
"paseo-import": "dist/bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@getpaseo/client": ">=0.2.0-beta.2"
|
||||
}
|
||||
},
|
||||
"packages/desktop/node_modules/@types/node": {
|
||||
"version": "24.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.0.tgz",
|
||||
@@ -36744,7 +36718,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0-beta.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -37640,9 +37614,9 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0-beta.1",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -37872,7 +37846,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0-beta.1",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
@@ -37885,7 +37859,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0-beta.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38103,15 +38077,15 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0-beta.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.214",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.2.0-beta.3",
|
||||
"@getpaseo/highlight": "0.2.0-beta.3",
|
||||
"@getpaseo/protocol": "0.2.0-beta.3",
|
||||
"@getpaseo/relay": "0.2.0-beta.3",
|
||||
"@getpaseo/client": "0.2.0-beta.1",
|
||||
"@getpaseo/highlight": "0.2.0-beta.1",
|
||||
"@getpaseo/protocol": "0.2.0-beta.1",
|
||||
"@getpaseo/relay": "0.2.0-beta.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -38648,7 +38622,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0-beta.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0-beta.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
@@ -132,8 +132,6 @@
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"lightningcss": "1.30.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { seedMockAgentWorkspace, openAgentRoute } from "./helpers/mock-agent";
|
||||
|
||||
function visibleComposer(page: Page) {
|
||||
return page.locator("textarea[data-composer-input]").filter({ visible: true }).first();
|
||||
}
|
||||
|
||||
test("adds a changed file to the focused chat without replacing its composer draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "add-file-to-chat-",
|
||||
title: "Target chat",
|
||||
});
|
||||
const relativePath = "src/changed file.ts";
|
||||
|
||||
try {
|
||||
await mkdir(path.join(workspace.cwd, "src"), { recursive: true });
|
||||
await writeFile(path.join(workspace.cwd, relativePath), "export const changed = true;\n");
|
||||
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||
|
||||
await page.setViewportSize({ width: 1400, height: 900 });
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
agentId: workspace.agentId,
|
||||
});
|
||||
|
||||
const agentComposer = visibleComposer(page);
|
||||
await expect(agentComposer).toBeEditable({ timeout: 30_000 });
|
||||
await agentComposer.fill("Preserve this thought");
|
||||
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await page.getByTestId("explorer-tab-changes").click();
|
||||
const changedFile = page.getByText("changed file.ts", { exact: true }).first();
|
||||
await expect(changedFile).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-file-0-add-to-chat").click();
|
||||
|
||||
const attachment = page.getByTestId("composer-workspace-file-attachment-pill");
|
||||
await expect(attachment).toContainText("changed file.ts");
|
||||
await expect(attachment).toContainText(relativePath);
|
||||
await expect(agentComposer).toHaveValue("Preserve this thought");
|
||||
await expect(agentComposer).toBeFocused();
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import { mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { openSessions } from "./helpers/archive-tab";
|
||||
import {
|
||||
assertChatTranscript,
|
||||
cleanupRewindFlow,
|
||||
launchAgent,
|
||||
sendMessage,
|
||||
type AgentHandle,
|
||||
} from "./helpers/rewind-flow";
|
||||
import type { SeedDaemonClient } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
interface TimelineClient extends SeedDaemonClient {
|
||||
fetchAgentTimeline(
|
||||
agentId: string,
|
||||
options: { direction: "tail"; projection: "projected"; limit: number },
|
||||
): Promise<unknown>;
|
||||
}
|
||||
|
||||
const INITIAL_PROMPT = "Reply with exactly CODEX_ARCHIVE_TIMELINE_SENTINEL and nothing else.";
|
||||
const INITIAL_REPLY = "CODEX_ARCHIVE_TIMELINE_SENTINEL";
|
||||
const FOLLOW_UP_PROMPT = "Reply with exactly CODEX_UNARCHIVED_SENTINEL and nothing else.";
|
||||
const FOLLOW_UP_REPLY = "CODEX_UNARCHIVED_SENTINEL";
|
||||
|
||||
async function historyContainsAgent(client: SeedDaemonClient, agentId: string): Promise<boolean> {
|
||||
const history = await client.fetchAgentHistory({ page: { limit: 200 } });
|
||||
return history.entries.some((entry) => entry.agent.id === agentId);
|
||||
}
|
||||
|
||||
test.describe("archived Codex agent recovery", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("cold-opens without provider history, then unarchives and restores the conversation", async ({
|
||||
page,
|
||||
}) => {
|
||||
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-archived-codex-")));
|
||||
let handle: AgentHandle | undefined;
|
||||
|
||||
try {
|
||||
handle = await launchAgent({
|
||||
page,
|
||||
provider: "codex",
|
||||
cwd,
|
||||
mode: "full-access",
|
||||
});
|
||||
await sendMessage(handle, INITIAL_PROMPT);
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: INITIAL_PROMPT },
|
||||
{ role: "assistant", text: INITIAL_REPLY },
|
||||
]);
|
||||
|
||||
await handle.client.archiveAgent(handle.agentId);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await handle?.client.fetchAgent({ agentId: handle.agentId }))?.agent.archivedAt ??
|
||||
null,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeNull();
|
||||
|
||||
const timelineClient = handle.client as TimelineClient;
|
||||
await expect(
|
||||
timelineClient.fetchAgentTimeline(handle.agentId, {
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
limit: 100,
|
||||
}),
|
||||
).rejects.toThrow(/archiv/i);
|
||||
await expect
|
||||
.poll(async () => (handle ? historyContainsAgent(handle.client, handle.agentId) : false), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await page.getByTestId(`agent-row-${getServerId()}-${handle.agentId}`).click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${handle.agentId}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("This agent is archived", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("agent-load-error")).toHaveCount(0);
|
||||
await expect(page.getByTestId("agent-timeline-sync-error")).toHaveCount(0);
|
||||
await expect(page.getByTestId("user-message")).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "Unarchive" }).click();
|
||||
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: INITIAL_PROMPT },
|
||||
{ role: "assistant", text: INITIAL_REPLY },
|
||||
]);
|
||||
|
||||
await sendMessage(handle, FOLLOW_UP_PROMPT);
|
||||
await assertChatTranscript(handle, [
|
||||
{ role: "user", text: INITIAL_PROMPT },
|
||||
{ role: "assistant", text: INITIAL_REPLY },
|
||||
{ role: "user", text: FOLLOW_UP_PROMPT },
|
||||
{ role: "assistant", text: FOLLOW_UP_REPLY },
|
||||
]);
|
||||
} finally {
|
||||
await cleanupRewindFlow({ handle, cwd });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -28,20 +28,26 @@ test("commit history shows dates and shares diff layout preferences", async ({
|
||||
|
||||
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
|
||||
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
|
||||
const layoutToggle = panel.getByTestId("commit-diff-toggle-layout");
|
||||
await expect(layoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||
await expect(panel.getByTestId("commit-diff-layout-unified")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await layoutToggle.click();
|
||||
await expect(layoutToggle).toHaveAccessibleName("Switch to unified diff");
|
||||
await panel.getByTestId("commit-diff-layout-split").click();
|
||||
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
|
||||
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
await page.getByTestId(/^workspace-commit-diff-close-/).click();
|
||||
await expect(panel).toHaveCount(0);
|
||||
await commitRow.click();
|
||||
await expect(panel.getByTestId("commit-diff-toggle-layout")).toHaveAccessibleName(
|
||||
"Switch to unified diff",
|
||||
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
cpSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
_electron as electron,
|
||||
expect,
|
||||
test,
|
||||
type ElectronApplication,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
|
||||
test.skip(process.env.E2E_DESKTOP_RUNTIME !== "1", "requires the real Electron product runtime");
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "../../..");
|
||||
const nodeRequire = createRequire(path.join(repoRoot, "packages", "desktop", "package.json"));
|
||||
const importPackageRoot = path.resolve(path.dirname(nodeRequire.resolve("@getpaseo/import")), "..");
|
||||
let installation: Awaited<ReturnType<typeof createConductorInstallation>>;
|
||||
let electronApp: ElectronApplication;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
installation = await createConductorInstallation();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await electronApp?.close();
|
||||
rmSync(installation.root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("imports from the product Integrations row and renders success, failure, and eligibility", async () => {
|
||||
electronApp = await launchProduct();
|
||||
const page = await electronApp.firstWindow();
|
||||
await openIntegrations(page);
|
||||
await expect(page.getByRole("button", { name: "Enable built-in daemon" })).toHaveCount(0);
|
||||
|
||||
await openImport(page);
|
||||
await expect(
|
||||
page.getByText("Paseo will register valid repositories", { exact: false }),
|
||||
).toBeVisible();
|
||||
await page.getByTestId("import-confirm").click();
|
||||
await expect(page.getByTestId("import-result")).toHaveText("Import complete.");
|
||||
await expect(page.getByTestId("import-output")).toContainText(
|
||||
`Registered project ${installation.repo}.`,
|
||||
);
|
||||
await expect(page.getByTestId("import-output")).toContainText("Import summary:");
|
||||
|
||||
await page.getByTestId("import-done").click();
|
||||
writeFileSync(installation.databasePath, "not a sqlite database");
|
||||
await openImport(page);
|
||||
await page.getByTestId("import-confirm").click();
|
||||
await expect(page.getByTestId("import-result")).toHaveText("Import failed.");
|
||||
await expect(page.getByTestId("import-output")).toContainText("ERROR:");
|
||||
|
||||
await page.getByTestId("import-done").click();
|
||||
await stopImportHost();
|
||||
await page.getByRole("button", { name: "General", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Integrations", exact: true }).click();
|
||||
const row = page.getByTestId("conductor-import-row");
|
||||
await expect(row).toContainText("Start the Desktop-managed host before importing.");
|
||||
await expect(row.getByRole("button", { name: "Import", exact: true })).toBeDisabled();
|
||||
});
|
||||
|
||||
async function launchProduct(): Promise<ElectronApplication> {
|
||||
const metroPort = requiredEnvironment("E2E_METRO_PORT");
|
||||
return electron.launch({
|
||||
args: [path.join(repoRoot, "packages", "desktop", "dist", "main.js")],
|
||||
env: {
|
||||
...process.env,
|
||||
EXPO_DEV_URL: `http://127.0.0.1:${metroPort}`,
|
||||
HOME: installation.home,
|
||||
PASEO_HOME: requiredEnvironment("E2E_PASEO_HOME"),
|
||||
PASEO_DISABLE_SINGLE_INSTANCE_LOCK: "1",
|
||||
PASEO_TEST_APP_NAME: "Paseo Import Behavior",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function openIntegrations(page: Page): Promise<void> {
|
||||
const settings = page.getByRole("button", { name: "Settings", exact: true });
|
||||
await expect(settings).toBeVisible({ timeout: 90_000 });
|
||||
await settings.click();
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Integrations", exact: true }).click();
|
||||
await expect(page.getByTestId("conductor-import-row")).toBeVisible();
|
||||
}
|
||||
|
||||
async function openImport(page: Page): Promise<void> {
|
||||
const button = page
|
||||
.getByTestId("conductor-import-row")
|
||||
.getByRole("button", { name: "Import", exact: true });
|
||||
await expect(button).toBeEnabled();
|
||||
await button.click();
|
||||
await expect(page.getByTestId("import-sheet")).toBeVisible();
|
||||
}
|
||||
|
||||
async function createConductorInstallation(): Promise<{
|
||||
root: string;
|
||||
home: string;
|
||||
repo: string;
|
||||
databasePath: string;
|
||||
}> {
|
||||
const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-product-import-")));
|
||||
const home = path.join(root, "home");
|
||||
const repo = path.join(root, "repo");
|
||||
mkdirSync(home, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main", repo]);
|
||||
execFileSync("git", ["config", "user.email", "fixture@example.com"], { cwd: repo });
|
||||
execFileSync("git", ["config", "user.name", "Fixture"], { cwd: repo });
|
||||
writeFileSync(path.join(repo, "README.md"), "fixture\n");
|
||||
execFileSync("git", ["add", "."], { cwd: repo });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "fixture"], {
|
||||
cwd: repo,
|
||||
});
|
||||
|
||||
const databaseDirectory = path.join(home, "Library", "Application Support", "com.conductor.app");
|
||||
mkdirSync(databaseDirectory, { recursive: true });
|
||||
const databasePath = path.join(databaseDirectory, "conductor.db");
|
||||
cpSync(path.join(importPackageRoot, "fixtures", "conductor", "conductor.db"), databasePath);
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
"-e",
|
||||
`const fs = require("node:fs");
|
||||
const initSqlJs = require("sql.js");
|
||||
(async () => {
|
||||
const SQL = await initSqlJs({ locateFile: () => require.resolve("sql.js/dist/sql-wasm.wasm") });
|
||||
const databasePath = process.argv[1];
|
||||
const repo = process.argv[2];
|
||||
const database = new SQL.Database(fs.readFileSync(databasePath));
|
||||
database.run("UPDATE repos SET root_path = ? WHERE id = 'repo-current'", [repo]);
|
||||
database.run("UPDATE repos SET is_hidden = 1 WHERE id <> 'repo-current'");
|
||||
database.run("DELETE FROM workspaces");
|
||||
fs.writeFileSync(databasePath, database.export());
|
||||
database.close();
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });`,
|
||||
databasePath,
|
||||
repo,
|
||||
],
|
||||
{ cwd: repoRoot },
|
||||
);
|
||||
return { root, home, repo: realpathSync(repo), databasePath };
|
||||
}
|
||||
|
||||
async function stopImportHost(): Promise<void> {
|
||||
const pidFile = path.join(requiredEnvironment("E2E_PASEO_HOME"), "paseo.pid");
|
||||
const pid = (JSON.parse(readFileSync(pidFile, "utf8")) as { pid: number }).pid;
|
||||
process.kill(pid, "SIGTERM");
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error(`Import host ${pid} did not stop.`);
|
||||
}
|
||||
|
||||
function requiredEnvironment(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`${name} is required.`);
|
||||
return value;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { unlink, writeFile } from "node:fs/promises";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes";
|
||||
@@ -10,11 +10,6 @@ import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
|
||||
interface DirtyWorkspace {
|
||||
id: string;
|
||||
repoPath: string;
|
||||
}
|
||||
|
||||
interface WorkspaceFixtureOptions {
|
||||
includeDeletedFile?: boolean;
|
||||
}
|
||||
|
||||
interface CleanupTask {
|
||||
@@ -223,99 +218,6 @@ test("changes diff keeps code rows aligned with the gutter", async ({ page }) =>
|
||||
});
|
||||
});
|
||||
|
||||
test("changes file actions open from the kebab and right-click", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
|
||||
await page.getByTestId("diff-file-1-actions").click();
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
|
||||
await page.getByTestId("diff-file-0-open-file").click();
|
||||
|
||||
await expect(page.getByTestId("workspace-file-pane")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Changes switches between inline and full-tab navigation", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const changesTabToggle = page.getByTestId("changes-open-tab");
|
||||
await expect(changesTabToggle).toHaveAccessibleName("Open Changes tab");
|
||||
await changesTabToggle.click();
|
||||
await expect(changesTabToggle).toHaveAccessibleName("Close Changes tab");
|
||||
|
||||
const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
await expect(visiblePanel).toBeVisible();
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible();
|
||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-file-pane")).toHaveCount(0);
|
||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toHaveCount(0);
|
||||
await visiblePanel.getByTestId("diff-file-0-toggle").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
const workingDiffLayoutToggle = visiblePanel.getByTestId("working-diff-toggle-layout");
|
||||
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to side-by-side diff");
|
||||
await workingDiffLayoutToggle.click();
|
||||
await expect(workingDiffLayoutToggle).toHaveAccessibleName("Switch to unified diff");
|
||||
await visiblePanel.getByTestId("working-diff-options-menu").click();
|
||||
await expect(page.getByTestId("working-diff-toggle-whitespace")).toContainText("Hide whitespace");
|
||||
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText("Wrap long lines");
|
||||
await expect(page.getByTestId("working-diff-refresh")).toContainText("Refresh");
|
||||
await page.getByTestId("working-diff-toggle-wrap-lines").click();
|
||||
await visiblePanel.getByTestId("working-diff-options-menu").click();
|
||||
await expect(page.getByTestId("working-diff-toggle-wrap-lines")).toContainText(
|
||||
"Scroll long lines",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
|
||||
await expect(visiblePanel.getByTestId(/^diff-file-\d+-body$/)).toHaveCount(0);
|
||||
await visiblePanel.getByTestId("working-diff-toggle-expand-all").click();
|
||||
await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
|
||||
await expect(
|
||||
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE);
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(visiblePanel).toContainText("zz-deleted.ts");
|
||||
await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER);
|
||||
await expect(visiblePanel.getByText("use-mounted-tab-set.ts", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("explorer-content-area").getByTestId("diff-file-1")).toContainText(
|
||||
"zz-deleted.ts",
|
||||
);
|
||||
await page.getByTestId("explorer-content-area").getByTestId("diff-file-1-toggle").click();
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
|
||||
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
|
||||
await expect(visiblePanel).toContainText("Deleted");
|
||||
|
||||
await changesTabToggle.click();
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||
).toBeVisible();
|
||||
await page.getByTestId("explorer-content-area").getByTestId("diff-file-0-toggle").click();
|
||||
await expect(
|
||||
page.getByTestId("explorer-content-area").getByTestId("diff-file-0-body"),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("changes diff switches between flat and tree file lists", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useUnwrappedDiffLines(page);
|
||||
@@ -341,11 +243,6 @@ test("changes diff switches between flat and tree file lists", async ({ page })
|
||||
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-0")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Collapse all" }).click();
|
||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Expand all" }).click();
|
||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
|
||||
|
||||
await page.getByTestId("diff-folder-src-toggle").click();
|
||||
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
|
||||
|
||||
@@ -353,53 +250,6 @@ test("changes diff switches between flat and tree file lists", async ({ page })
|
||||
await expectFlatFileList(page);
|
||||
});
|
||||
|
||||
test("workspace file panes keep their controls on shared alignment rails", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
||||
|
||||
const changesRightRail = await Promise.all([
|
||||
readSvgRight(page, "explorer-close"),
|
||||
readSvgRight(page, "changes-options-menu"),
|
||||
readSvgRight(page, "diff-file-0-actions"),
|
||||
]);
|
||||
expectAligned(changesRightRail);
|
||||
|
||||
const [folderStat, fileStat] = await Promise.all([
|
||||
page.getByTestId("diff-folder-src-stat").boundingBox(),
|
||||
page.getByTestId("diff-file-0-stat").boundingBox(),
|
||||
]);
|
||||
expect(folderStat).not.toBeNull();
|
||||
expect(fileStat).not.toBeNull();
|
||||
expect(folderStat!.x + folderStat!.width).toBeCloseTo(fileStat!.x + fileStat!.width, 0);
|
||||
|
||||
await page.getByTestId("explorer-tab-files").click();
|
||||
await expect(page.getByTestId("file-explorer-row-0")).toBeVisible();
|
||||
|
||||
const filesRightRail = await Promise.all([
|
||||
readSvgRight(page, "explorer-close"),
|
||||
readSvgRight(page, "files-refresh"),
|
||||
readSvgRight(page, "file-explorer-row-0-actions"),
|
||||
]);
|
||||
expectAligned(filesRightRail);
|
||||
|
||||
const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([
|
||||
page.getByTestId("files-sort-label").boundingBox(),
|
||||
page.getByTestId("file-explorer-row-0").locator("svg").first().boundingBox(),
|
||||
page.getByTestId("file-explorer-tree-scroll").boundingBox(),
|
||||
page.getByTestId("file-explorer-row-0").boundingBox(),
|
||||
]);
|
||||
expect(sortLabel).not.toBeNull();
|
||||
expect(firstRowIcon).not.toBeNull();
|
||||
expect(treeBounds).not.toBeNull();
|
||||
expect(rowBounds).not.toBeNull();
|
||||
expect(sortLabel!.x).toBeCloseTo(firstRowIcon!.x, 0);
|
||||
expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0);
|
||||
expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0);
|
||||
});
|
||||
|
||||
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -549,14 +399,10 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
|
||||
});
|
||||
}
|
||||
|
||||
async function createWorkspaceWithMountedTabDiff(
|
||||
options: WorkspaceFixtureOptions = {},
|
||||
): Promise<DirtyWorkspace> {
|
||||
const files = [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }];
|
||||
if (options.includeDeletedFile) {
|
||||
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
|
||||
}
|
||||
const repo = await createTempGitRepo("diff-row-alignment-", { files });
|
||||
async function createWorkspaceWithMountedTabDiff(): Promise<DirtyWorkspace> {
|
||||
const repo = await createTempGitRepo("diff-row-alignment-", {
|
||||
files: [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }],
|
||||
});
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
@@ -566,16 +412,13 @@ async function createWorkspaceWithMountedTabDiff(
|
||||
});
|
||||
|
||||
await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER);
|
||||
if (options.includeDeletedFile) {
|
||||
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
|
||||
}
|
||||
const createdWorkspace = await client.createWorkspace({
|
||||
source: { kind: "directory", path: repo.path },
|
||||
});
|
||||
if (!createdWorkspace.workspace) {
|
||||
throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`);
|
||||
}
|
||||
return { id: createdWorkspace.workspace.id, repoPath: repo.path };
|
||||
return { id: createdWorkspace.workspace.id };
|
||||
}
|
||||
|
||||
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
|
||||
@@ -593,21 +436,6 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
|
||||
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function readSvgRight(page: Page, testID: string): Promise<number> {
|
||||
const box = await page.getByTestId(testID).locator("svg").first().boundingBox();
|
||||
if (!box) {
|
||||
throw new Error(`Could not measure ${testID}`);
|
||||
}
|
||||
return box.x + box.width;
|
||||
}
|
||||
|
||||
function expectAligned(values: number[]): void {
|
||||
const [first, ...rest] = values;
|
||||
for (const value of rest) {
|
||||
expect(value).toBeCloseTo(first, 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { openFileExplorer, openFileFromExplorer, expectFileTabOpen } from "./helpers/file-explorer";
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
|
||||
const RED_PIXEL = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
const BLUE_PIXEL = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
|
||||
function editor(page: Page) {
|
||||
return page.getByTestId("file-source-editor").filter({ visible: true }).locator(".cm-content");
|
||||
}
|
||||
|
||||
async function replaceEditorText(page: Page, content: string): Promise<void> {
|
||||
const contentElement = editor(page);
|
||||
await contentElement.click();
|
||||
await contentElement.press("Control+A");
|
||||
await contentElement.type(content);
|
||||
}
|
||||
|
||||
async function openWorkspaceFile(page: Page, filename: string): Promise<void> {
|
||||
const tree = page.getByTestId("file-explorer-tree-scroll");
|
||||
if (!(await tree.isVisible())) await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, filename);
|
||||
await expectFileTabOpen(page, filename);
|
||||
}
|
||||
|
||||
async function seedAgentWithFileLink(target: string) {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "file-editing-chat-link-",
|
||||
title: "Chat file link e2e",
|
||||
initialPrompt: [
|
||||
"Generate a title and a git branch name for a coding agent from the user prompt and attachments.",
|
||||
"Return JSON only with fields 'title' and 'branch'.",
|
||||
"",
|
||||
"<user-prompt>",
|
||||
`Open \`${target}\` now`,
|
||||
"</user-prompt>",
|
||||
].join("\n"),
|
||||
});
|
||||
await writeFile(
|
||||
path.join(session.cwd, "target.ts"),
|
||||
Array.from({ length: 80 }, (_, index) => `export const line${index + 1} = ${index + 1};`).join(
|
||||
"\n",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
return session;
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("opens an assistant file link at its referenced line", async ({ page }) => {
|
||||
const target = "target.ts:42";
|
||||
const session = await seedAgentWithFileLink(target);
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
|
||||
const fileLink = page.getByText(target, { exact: true });
|
||||
await expect(fileLink).toBeVisible({ timeout: 15_000 });
|
||||
await fileLink.click();
|
||||
|
||||
await expectFileTabOpen(page, "target.ts");
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(page.getByLabel("Line 42, column 1")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("file-source-editor").locator(".cm-line", { hasText: "line42 = 42" }),
|
||||
).toBeVisible();
|
||||
|
||||
const sourceEditor = editor(page);
|
||||
await sourceEditor.click();
|
||||
await sourceEditor.press("Control+Home");
|
||||
await expect(page.getByLabel(/^Line 1, column \d+$/)).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByTestId(`workspace-tab-agent_${session.agentId}`)
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
await expect(fileLink).toBeVisible();
|
||||
await fileLink.click();
|
||||
|
||||
await expect(page.getByLabel("Line 42, column 1")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("file-source-editor").locator(".cm-line", { hasText: "line42 = 42" }),
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows the full file path and keeps editor controls stable", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-visuals-" });
|
||||
const relativePath = "src/deep/visuals.md";
|
||||
const sourcePath = path.join(workspace.repoPath, relativePath);
|
||||
await mkdir(path.dirname(sourcePath), { recursive: true });
|
||||
await writeFile(
|
||||
sourcePath,
|
||||
[...Array.from({ length: 11 }, (_, index) => `line ${index + 1}`), "abcdefghijklmnop"].join(
|
||||
"\n",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openFileExplorer(page);
|
||||
await page.getByTestId("file-explorer-tree-scroll").getByText("src", { exact: true }).click();
|
||||
await page.getByTestId("file-explorer-tree-scroll").getByText("deep", { exact: true }).click();
|
||||
await openFileFromExplorer(page, "visuals.md");
|
||||
await expectFileTabOpen(page, relativePath);
|
||||
|
||||
const fileTab = page.getByTestId(`workspace-tab-file_${relativePath}`).first();
|
||||
await fileTab.hover();
|
||||
await expect(page.getByTestId(`workspace-tab-tooltip-file_${relativePath}`)).toHaveText(
|
||||
relativePath,
|
||||
);
|
||||
await expect(page.getByTestId("file-panel-bar")).not.toContainText("visuals.md");
|
||||
const modeControl = page.getByTestId("file-markdown-mode");
|
||||
await expect(modeControl).toBeVisible();
|
||||
await page.getByTestId("file-mode-source").click();
|
||||
|
||||
const editorHost = page.getByTestId("file-source-editor");
|
||||
const content = editor(page);
|
||||
await expect(editorHost).toHaveAttribute("data-pmono", "");
|
||||
await expect(content).toHaveCSS("font-family", /SFMono-Regular/);
|
||||
|
||||
await content.click();
|
||||
const cursor = editorHost.locator(".cm-cursor-primary");
|
||||
await expect(cursor).toBeVisible();
|
||||
await expect(cursor).toHaveCSS("border-left-color", "rgb(250, 250, 250)");
|
||||
|
||||
const initialModeBox = await modeControl.boundingBox();
|
||||
expect(initialModeBox).not.toBeNull();
|
||||
const initialModeX = initialModeBox!.x;
|
||||
await content.press("Control+End");
|
||||
await expect(page.getByLabel(/Line 12, column \d+/)).toBeVisible();
|
||||
const movedModeBox = await modeControl.boundingBox();
|
||||
expect(movedModeBox).not.toBeNull();
|
||||
expect(movedModeBox!.x).toBe(initialModeX);
|
||||
|
||||
await content.press("Control+a");
|
||||
const selection = editorHost.locator(".cm-selectionBackground").first();
|
||||
await expect(selection).toBeVisible();
|
||||
await expect(selection).toHaveCSS("background-color", "rgba(255, 255, 255, 0.2)");
|
||||
});
|
||||
|
||||
test("applies the interface font to portaled tooltips", async ({ page, withWorkspace }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ uiFontFamily: "monospace" }));
|
||||
});
|
||||
const workspace = await withWorkspace({ prefix: "file-tooltip-font-" });
|
||||
const relativePath = "tooltip-font.txt";
|
||||
await writeFile(path.join(workspace.repoPath, relativePath), "tooltip font\n", "utf8");
|
||||
await workspace.navigateTo();
|
||||
await openFileExplorer(page);
|
||||
await openFileFromExplorer(page, relativePath);
|
||||
await expectFileTabOpen(page, relativePath);
|
||||
|
||||
await page.getByTestId(`workspace-tab-file_${relativePath}`).first().hover();
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByTestId(`workspace-tab-tooltip-file_${relativePath}`)
|
||||
.getByText(relativePath, { exact: true }),
|
||||
).toHaveCSS("font-family", "monospace");
|
||||
});
|
||||
|
||||
test("autosaves, saves immediately, resolves conflicts, and restores live updates after reconnect", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const gate = await installDaemonWebSocketGate(page);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-source-" });
|
||||
const sourcePath = path.join(workspace.repoPath, "source.ts");
|
||||
await writeFile(sourcePath, "const initial = 1;\n", "utf8");
|
||||
await Promise.all(
|
||||
["one.ts", "two.ts", "three.ts", "four.ts"].map((fileName) =>
|
||||
writeFile(path.join(workspace.repoPath, fileName), `// ${fileName}\n`, "utf8"),
|
||||
),
|
||||
);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "source.ts");
|
||||
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(page.getByLabel(/File size/)).toBeVisible();
|
||||
await expect(page.getByLabel(/lines/)).toBeVisible();
|
||||
|
||||
await replaceEditorText(page, "const autosaved = 2;\n");
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).toBeVisible();
|
||||
await expect(page.getByLabel("Editor status dirty")).toBeVisible();
|
||||
await expect(page.getByLabel("Editor status clean")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_source.ts")).not.toBeVisible();
|
||||
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const autosaved = 2;\n");
|
||||
|
||||
await replaceEditorText(page, "const immediate = 3;\n");
|
||||
await editor(page).press("Control+s");
|
||||
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const immediate = 3;\n");
|
||||
|
||||
await writeFile(sourcePath, "const external = 4;\nconst line = 2;\n", "utf8");
|
||||
await expect(editor(page)).toContainText("const external = 4;");
|
||||
await expect(page.getByLabel("3 lines")).toBeVisible();
|
||||
|
||||
await replaceEditorText(page, "const localWins = 5;\n");
|
||||
await writeFile(sourcePath, "const diskLoses = 6;\n", "utf8");
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
for (const fileName of ["one.ts", "two.ts", "three.ts", "four.ts"]) {
|
||||
await openWorkspaceFile(page, fileName);
|
||||
}
|
||||
await page.getByTestId("workspace-tab-file_source.ts").filter({ visible: true }).click();
|
||||
await expect(editor(page)).toContainText("const localWins = 5;");
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Overwrite", exact: true }).click();
|
||||
await expect.poll(() => readFile(sourcePath, "utf8")).toBe("const localWins = 5;\n");
|
||||
|
||||
await replaceEditorText(page, "const discarded = 7;\n");
|
||||
await writeFile(sourcePath, "const diskWins = 8;\n", "utf8");
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.getByRole("button", { name: "Reload", exact: true }).click();
|
||||
await expect(editor(page)).toContainText("const diskWins = 8;");
|
||||
|
||||
const subscriptionCount = gate.getClientRequestCount("fs.file.subscribe.request");
|
||||
await gate.drop();
|
||||
gate.restore();
|
||||
await expect
|
||||
.poll(() => gate.getClientRequestCount("fs.file.subscribe.request"), { timeout: 30_000 })
|
||||
.toBeGreaterThan(subscriptionCount);
|
||||
await writeFile(sourcePath, "const afterReconnect = 9;\n", "utf8");
|
||||
await expect(editor(page)).toContainText("const afterReconnect = 9;");
|
||||
});
|
||||
|
||||
test("warns before closing a panel with an unsaved draft", async ({ page, withWorkspace }) => {
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-draft-" });
|
||||
const sourcePath = path.join(workspace.repoPath, "draft.ts");
|
||||
await writeFile(sourcePath, "const initial = 1;\n", "utf8");
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "draft.ts");
|
||||
|
||||
await replaceEditorText(page, "const local = 2;\n");
|
||||
await writeFile(sourcePath, "const external = 3;\n", "utf8");
|
||||
await expect(page.getByTestId("file-conflict-alert")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
|
||||
|
||||
let closePrompt = "";
|
||||
page.once("dialog", async (dialog) => {
|
||||
closePrompt = dialog.message();
|
||||
await dialog.dismiss();
|
||||
});
|
||||
await page
|
||||
.getByTestId("workspace-tab-file_draft.ts")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click({ button: "right" });
|
||||
await page
|
||||
.getByTestId("workspace-tab-context-file_draft.ts-close")
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
expect(closePrompt).toContain("Closing it will discard the draft.");
|
||||
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await expect(page.getByTestId("workspace-tab-modified-file_draft.ts")).toBeVisible();
|
||||
});
|
||||
|
||||
test("refreshes Markdown and images while preserving Preview and Source behavior", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-preview-" });
|
||||
const markdownPath = path.join(workspace.repoPath, "notes.md");
|
||||
const imagePath = path.join(workspace.repoPath, "pixel.png");
|
||||
await writeFile(markdownPath, "# First heading\n", "utf8");
|
||||
await writeFile(imagePath, RED_PIXEL);
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "notes.md");
|
||||
|
||||
await expect(page.getByText("First heading", { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("file-markdown-mode")).toBeVisible();
|
||||
await writeFile(markdownPath, "# Updated heading\n", "utf8");
|
||||
await expect(page.getByText("Updated heading", { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByTestId("file-mode-source").click();
|
||||
await expect(page.getByTestId("file-source-editor")).toBeVisible();
|
||||
await replaceEditorText(page, "# Saved from source\n");
|
||||
await expect.poll(() => readFile(markdownPath, "utf8")).toBe("# Saved from source\n");
|
||||
await page.getByTestId("file-mode-preview").click();
|
||||
await expect(page.getByText("Saved from source", { exact: true })).toBeVisible();
|
||||
|
||||
await openWorkspaceFile(page, "pixel.png");
|
||||
const image = page.getByTestId("workspace-file-pane").locator("img");
|
||||
await expect(image).toBeVisible();
|
||||
const initialSource = await image.getAttribute("src");
|
||||
await writeFile(imagePath, BLUE_PIXEL);
|
||||
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
|
||||
});
|
||||
|
||||
test("persists Vim keybindings and reports Vim mode with cursor position", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "file-editing-vim-" });
|
||||
await writeFile(path.join(workspace.repoPath, "vim.ts"), "const vim = true;\n", "utf8");
|
||||
|
||||
await page.goto("/settings/editor");
|
||||
const toggle = page.getByRole("switch", { name: "Vim keybindings" });
|
||||
await expect(toggle).toBeVisible();
|
||||
await toggle.click();
|
||||
await expect(toggle).toBeChecked();
|
||||
await page.reload();
|
||||
await expect(page.getByRole("switch", { name: "Vim keybindings" })).toBeChecked();
|
||||
|
||||
await workspace.navigateTo();
|
||||
await openWorkspaceFile(page, "vim.ts");
|
||||
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
|
||||
await expect(page.getByLabel("Line 1, column 1")).toBeVisible();
|
||||
await editor(page).click();
|
||||
await editor(page).press("i");
|
||||
await expect(page.getByLabel("Vim mode INSERT")).toBeVisible();
|
||||
await editor(page).press("Escape");
|
||||
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -328,12 +328,6 @@ interface PairingDaemonClient {
|
||||
|
||||
async function createFakeEditorBin(): Promise<string> {
|
||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-editor-bin-"));
|
||||
let realGhPath = "";
|
||||
try {
|
||||
realGhPath = execSync("which gh").toString().trim();
|
||||
} catch {
|
||||
// The local PR fixture below remains usable without a system gh binary.
|
||||
}
|
||||
|
||||
const fakeEditorSource = `#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
@@ -355,70 +349,6 @@ if (recordPath) {
|
||||
await chmod(editorPath, 0o755);
|
||||
}
|
||||
|
||||
const fakeGhPath = path.join(binDir, "gh");
|
||||
const fakeGhSource = `#!/usr/bin/env node
|
||||
const { spawnSync } = require("child_process");
|
||||
const args = process.argv.slice(2);
|
||||
const fixtureRemote = "https://github.com/paseo-e2e/local-fixture.git";
|
||||
const origin = spawnSync("git", ["config", "--get", "remote.origin.url"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"]
|
||||
}).stdout?.trim();
|
||||
|
||||
if (origin === fixtureRemote) {
|
||||
const command = args.slice(0, 2).join(" ");
|
||||
if (command === "auth status") process.exit(0);
|
||||
if (command === "repo view") {
|
||||
process.stdout.write(JSON.stringify({ owner: { login: "paseo-e2e" }, name: "local-fixture", parent: null }));
|
||||
process.exit(0);
|
||||
}
|
||||
if (command === "issue list") {
|
||||
process.stdout.write("[]");
|
||||
process.exit(0);
|
||||
}
|
||||
if (command === "pr list" || command === "pr view") {
|
||||
const pr = {
|
||||
number: 1,
|
||||
title: "Use pasted PR as start ref",
|
||||
url: "https://github.com/paseo-e2e/local-fixture/pull/1",
|
||||
state: "OPEN",
|
||||
body: null,
|
||||
labels: [],
|
||||
baseRefName: "main",
|
||||
headRefName: "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"))) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
data: { repository: { pullRequest: {
|
||||
number: 1,
|
||||
baseRefName: "main",
|
||||
headRefName: "pr-branch-1",
|
||||
isCrossRepository: false,
|
||||
headRepositoryOwner: { login: "paseo-e2e" },
|
||||
headRepository: {
|
||||
sshUrl: "git@github.com:paseo-e2e/local-fixture.git",
|
||||
url: fixtureRemote
|
||||
}
|
||||
} } }
|
||||
}));
|
||||
process.exit(0);
|
||||
}
|
||||
process.stderr.write("Unsupported local GitHub fixture command: " + args.join(" ") + "\\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const realGhPath = ${JSON.stringify(realGhPath)};
|
||||
if (!realGhPath) process.exit(127);
|
||||
const result = spawnSync(realGhPath, args, { stdio: "inherit" });
|
||||
process.exit(result.status ?? 1);
|
||||
`;
|
||||
await writeFile(fakeGhPath, fakeGhSource);
|
||||
await chmod(fakeGhPath, 0o755);
|
||||
|
||||
return binDir;
|
||||
}
|
||||
|
||||
@@ -779,11 +709,13 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_LISTEN:
|
||||
process.env.E2E_DESKTOP_RUNTIME === "1" ? `127.0.0.1:${args.port}` : `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
...(process.env.E2E_DESKTOP_RUNTIME === "1" ? { PASEO_DESKTOP_MANAGED: "1" } : {}),
|
||||
});
|
||||
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
|
||||
@@ -65,8 +65,6 @@ export interface DesktopBridgeConfig {
|
||||
daemonListen?: string;
|
||||
/** Keep start_desktop_daemon pending to hold the desktop startup blocker open. */
|
||||
hangDaemonStart?: boolean;
|
||||
/** Delay the desktop settings IPC response to exercise startup ordering. */
|
||||
desktopSettingsDelayMs?: number;
|
||||
/**
|
||||
* Controls what dialog.ask returns when the daemon management confirm dialog
|
||||
* fires. True = confirm (proceed with the action), false = cancel. Defaults to
|
||||
@@ -103,7 +101,6 @@ declare global {
|
||||
__capturedDialogCall: ConfirmDialogCall | undefined;
|
||||
__capturedDialogOpenCalls: Array<Record<string, unknown> | undefined>;
|
||||
__recordDesktopEditorOpen?: (input: DesktopEditorOpenRecord) => Promise<void>;
|
||||
__desktopDaemonStartRequested?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +129,6 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
let daemonRunning = true;
|
||||
let currentPid: number | null = cfg.daemonPid ?? null;
|
||||
let startCount = 0;
|
||||
window.__desktopDaemonStartRequested = false;
|
||||
|
||||
function buildDaemonStatus() {
|
||||
return {
|
||||
@@ -149,7 +145,6 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
}
|
||||
|
||||
function startDesktopDaemon() {
|
||||
window.__desktopDaemonStartRequested = true;
|
||||
if (cfg.hangDaemonStart) {
|
||||
return new Promise(() => undefined);
|
||||
}
|
||||
@@ -161,13 +156,6 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
return buildDaemonStatus();
|
||||
}
|
||||
|
||||
async function waitForDesktopSettingsResponse() {
|
||||
const delayMs = cfg.desktopSettingsDelayMs ?? 0;
|
||||
if (delayMs > 0) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
|
||||
const desktopBridge: {
|
||||
platform: string;
|
||||
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
@@ -224,7 +212,6 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
}
|
||||
|
||||
if (command === "get_desktop_settings") {
|
||||
await waitForDesktopSettingsResponse();
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
|
||||
@@ -290,17 +277,6 @@ export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfi
|
||||
}, config);
|
||||
}
|
||||
|
||||
export async function waitForDesktopDaemonStartRequest(page: Page): Promise<void> {
|
||||
await page.waitForFunction(() => window.__desktopDaemonStartRequested === true);
|
||||
// Give the startup state two paints to expose any app → splash regression.
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForDirectoryDialog(
|
||||
page: Page,
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export function hasGithubAuth(): boolean {
|
||||
@@ -59,12 +59,6 @@ export interface GhDefaultBranchClone {
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface LocalGhPrFixture {
|
||||
pr: GhPrFixture;
|
||||
mainCheckout: GhDefaultBranchClone;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
function gh(args: string[], opts?: { cwd?: string }): string {
|
||||
return execFileSync("gh", args, {
|
||||
cwd: opts?.cwd,
|
||||
@@ -287,57 +281,3 @@ export async function cloneGithubRepoDefaultBranchOnly(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createLocalGithubPrFixture(): Promise<LocalGhPrFixture> {
|
||||
const fixtureRoot = await mkdtemp(path.join("/tmp", "paseo-e2e-local-github-pr-"));
|
||||
const basePath = path.join(fixtureRoot, "base");
|
||||
const remotePath = path.join(fixtureRoot, "remote.git");
|
||||
const checkoutPath = path.join(fixtureRoot, "main-only");
|
||||
const githubUrl = "https://github.com/paseo-e2e/local-fixture.git";
|
||||
await mkdir(basePath);
|
||||
|
||||
git(["init", "-b", "main"], basePath);
|
||||
git(["config", "user.email", "e2e@paseo.test"], basePath);
|
||||
git(["config", "user.name", "Paseo E2E"], basePath);
|
||||
git(["config", "commit.gpgsign", "false"], basePath);
|
||||
await writeFile(path.join(basePath, "README.md"), "# Local GitHub fixture\n");
|
||||
git(["add", "README.md"], basePath);
|
||||
git(["commit", "-m", "Initial commit"], basePath);
|
||||
git(["checkout", "-b", "pr-branch-1"], basePath);
|
||||
await writeFile(path.join(basePath, "pr-1.txt"), "PR 1\n");
|
||||
git(["add", "pr-1.txt"], basePath);
|
||||
git(["commit", "-m", "Add PR 1"], basePath);
|
||||
git(["checkout", "main"], basePath);
|
||||
|
||||
execFileSync("git", ["clone", "--quiet", "--bare", basePath, remotePath], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
git(["update-ref", "refs/pull/1/head", "refs/heads/pr-branch-1"], remotePath);
|
||||
execFileSync(
|
||||
"git",
|
||||
["clone", "--quiet", "--single-branch", "--branch", "main", remotePath, checkoutPath],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
git(["remote", "set-url", "origin", githubUrl], checkoutPath);
|
||||
git(["config", `url.${remotePath}.insteadOf`, githubUrl], checkoutPath);
|
||||
git(["config", "user.email", "e2e@paseo.test"], checkoutPath);
|
||||
git(["config", "user.name", "Paseo E2E"], checkoutPath);
|
||||
git(["config", "commit.gpgsign", "false"], checkoutPath);
|
||||
|
||||
return {
|
||||
pr: {
|
||||
number: 1,
|
||||
title: "Use pasted PR as start ref",
|
||||
url: "https://github.com/paseo-e2e/local-fixture/pull/1",
|
||||
branch: "pr-branch-1",
|
||||
localPath: basePath,
|
||||
},
|
||||
mainCheckout: {
|
||||
path: checkoutPath,
|
||||
cleanup: async () => {},
|
||||
},
|
||||
cleanup: async () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type BrowserContext, type Page } from "@playwright/test";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
@@ -290,13 +290,6 @@ export async function selectBranchInPicker(page: Page, name: string): Promise<vo
|
||||
await branchRow.click();
|
||||
}
|
||||
|
||||
export async function searchAndSelectBranchInPicker(page: Page, name: string): Promise<void> {
|
||||
const searchInput = page.getByPlaceholder("Search branches and PRs");
|
||||
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||
await searchInput.fill(name);
|
||||
await selectBranchInPicker(page, name);
|
||||
}
|
||||
|
||||
export async function selectGitHubPrInPicker(page: Page, number: number): Promise<void> {
|
||||
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
|
||||
await expect(prRow).toBeVisible({ timeout: 30_000 });
|
||||
@@ -358,18 +351,6 @@ export async function expectComposerGithubAttachmentPill(
|
||||
await expect(pills.first()).toContainText(input.title);
|
||||
}
|
||||
|
||||
export async function pasteGithubPrUrl(
|
||||
page: Page,
|
||||
context: BrowserContext,
|
||||
url: string,
|
||||
): Promise<void> {
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
await page.evaluate((value) => navigator.clipboard.writeText(value), url);
|
||||
await composer.focus();
|
||||
await page.keyboard.press("Control+V");
|
||||
}
|
||||
|
||||
export async function assertNewWorkspaceSidebarAndHeader(
|
||||
page: Page,
|
||||
input: {
|
||||
|
||||
@@ -144,7 +144,7 @@ export interface SeedDaemonClient {
|
||||
} | null;
|
||||
fetchAgentHistory(options?: {
|
||||
page?: { limit: number };
|
||||
}): Promise<{ entries: Array<{ agent: { id: string } }> }>;
|
||||
}): Promise<{ entries: Array<{ id: string }> }>;
|
||||
subscribeTerminal(
|
||||
terminalId: string,
|
||||
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||
|
||||
@@ -22,7 +22,6 @@ interface SavedSettingsHostInput {
|
||||
const SECTION_LABELS = {
|
||||
general: "General",
|
||||
appearance: "Appearance",
|
||||
editor: "Editor",
|
||||
shortcuts: "Shortcuts",
|
||||
integrations: "Integrations",
|
||||
permissions: "Permissions",
|
||||
|
||||
@@ -7,10 +7,6 @@ interface SessionMessage {
|
||||
payload?: unknown;
|
||||
}
|
||||
|
||||
interface TimelineSubscriptionWaitOptions {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
function readSessionMessage(message: WebSocketMessage): SessionMessage | null {
|
||||
if (typeof message !== "string") return null;
|
||||
try {
|
||||
@@ -40,16 +36,72 @@ export function observeTimelineSubscriptions(page: Page) {
|
||||
});
|
||||
|
||||
return {
|
||||
async waitForSubscribedAgents(
|
||||
agentIds: string[],
|
||||
options: TimelineSubscriptionWaitOptions = {},
|
||||
): Promise<void> {
|
||||
async waitForSubscribedAgents(agentIds: string[]): Promise<void> {
|
||||
const expected = [...new Set(agentIds)].sort();
|
||||
await expect
|
||||
.poll(() => acknowledgedAgentIds?.slice().sort() ?? null, {
|
||||
timeout: options.timeout ?? 15_000,
|
||||
})
|
||||
.poll(() => acknowledgedAgentIds?.slice().sort() ?? null, { timeout: 15_000 })
|
||||
.toEqual(expected);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface AssistantFrameState {
|
||||
active: boolean;
|
||||
lastText: string | null;
|
||||
snapshots: string[];
|
||||
}
|
||||
|
||||
export async function observeLastAssistantFrames(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const state: AssistantFrameState = {
|
||||
active: true,
|
||||
lastText: null,
|
||||
snapshots: [],
|
||||
};
|
||||
const sample = () => {
|
||||
const hasPaintedHistoryOverlay = Array.from(
|
||||
document.querySelectorAll('[data-testid="agent-history-overlay"]'),
|
||||
).some((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
});
|
||||
if (!hasPaintedHistoryOverlay) {
|
||||
const messages = Array.from(
|
||||
document.querySelectorAll('[data-testid="assistant-message"]'),
|
||||
).filter((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
});
|
||||
const text = messages.at(-1)?.textContent?.trim() ?? "";
|
||||
if (text && text !== state.lastText) {
|
||||
state.lastText = text;
|
||||
state.snapshots.push(text);
|
||||
}
|
||||
}
|
||||
if (state.active) requestAnimationFrame(sample);
|
||||
};
|
||||
Object.assign(window, { __assistantFrameState: state });
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
|
||||
return {
|
||||
async stop(): Promise<string[]> {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
new Promise<string[]>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
const state = (
|
||||
window as typeof window & { __assistantFrameState?: AssistantFrameState }
|
||||
).__assistantFrameState;
|
||||
if (!state) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
state.active = false;
|
||||
resolve([...state.snapshots]);
|
||||
});
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,14 +17,11 @@ import {
|
||||
expectPickerOpen,
|
||||
expectPickerSelected,
|
||||
expectStartingRefPickerTriggerPr,
|
||||
fillNewWorkspaceDraft,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
openBranchPicker,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
openStartingRefPicker,
|
||||
pasteGithubPrUrl,
|
||||
searchAndSelectBranchInPicker,
|
||||
selectBranchInPicker,
|
||||
selectGitHubPrInPicker,
|
||||
selectPickerOptionByKeyboard,
|
||||
@@ -33,11 +30,9 @@ import {
|
||||
} from "./helpers/new-workspace";
|
||||
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
|
||||
import {
|
||||
createLocalGithubPrFixture,
|
||||
cloneGithubRepoDefaultBranchOnly,
|
||||
createTempGithubRepo,
|
||||
hasGithubAuth,
|
||||
type LocalGhPrFixture,
|
||||
} from "./helpers/github-fixtures";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
@@ -196,7 +191,6 @@ test.describe("New workspace flow", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
const localWorkspaceIds = new Set<string>();
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
const localGithubFixtures = new Set<LocalGhPrFixture>();
|
||||
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
@@ -218,13 +212,6 @@ test.describe("New workspace flow", () => {
|
||||
await client?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
for (const fixture of localGithubFixtures) {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
localGithubFixtures.clear();
|
||||
});
|
||||
|
||||
test("adds a project from the selected empty host", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("new-workspace-project-picker-");
|
||||
const primaryServerId = getServerId();
|
||||
@@ -820,100 +807,6 @@ test.describe("New workspace flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("pasted GitHub PR replaces a selected branch and creates its worktree", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const fixture = await createLocalGithubPrFixture();
|
||||
localGithubFixtures.add(fixture);
|
||||
const { pr, mainCheckout } = fixture;
|
||||
|
||||
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, "main");
|
||||
|
||||
await pasteGithubPrUrl(page, context, pr.url);
|
||||
|
||||
await expect(page.getByTestId("workspace-create-submit")).toBeDisabled();
|
||||
await expectComposerGithubAttachmentPill(page, {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
});
|
||||
await expectStartingRefPickerTriggerPr(page, {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
headRef: pr.branch,
|
||||
});
|
||||
|
||||
await submitNewWorkspaceWithoutPrompt(page);
|
||||
|
||||
const worktree = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId: getServerId(),
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
|
||||
const branchInfo = await readWorktreeBranchInfo({
|
||||
worktreePath: worktree.workspaceDirectory,
|
||||
});
|
||||
expect(branchInfo.currentBranch).toBe(pr.branch);
|
||||
expect(existsSync(path.join(worktree.workspaceDirectory, "pr-1.txt"))).toBe(true);
|
||||
});
|
||||
|
||||
test("branches remain searchable after a pasted PR and determine the created worktree", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const fixture = await createLocalGithubPrFixture();
|
||||
localGithubFixtures.add(fixture);
|
||||
const { pr, mainCheckout } = fixture;
|
||||
|
||||
const openedProject = await openProjectViaDaemon(client, mainCheckout.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
await pasteGithubPrUrl(page, context, pr.url);
|
||||
await expectStartingRefPickerTriggerPr(page, {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
headRef: pr.branch,
|
||||
});
|
||||
|
||||
await openStartingRefPicker(page);
|
||||
await searchAndSelectBranchInPicker(page, "main");
|
||||
await expectPickerSelected(page, "main");
|
||||
await fillNewWorkspaceDraft(page, `${pr.url}\nKeep this checkout on main`);
|
||||
await expectPickerSelected(page, "main");
|
||||
await submitNewWorkspaceWithoutPrompt(page);
|
||||
|
||||
const worktree = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId: getServerId(),
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
|
||||
expect(existsSync(path.join(worktree.workspaceDirectory, "pr-1.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
test("selected GitHub PR creates the worktree from the PR head even when the head branch is not fetched", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -219,7 +219,6 @@ test.describe("Half-screen desktop layout", () => {
|
||||
}
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.getByTestId(`sidebar-project-show-more-${workspace.projectId}`).click();
|
||||
await waitForSidebarWorkspace(page, lastWorkspaceId);
|
||||
|
||||
const sidebarScroll = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||
|
||||
@@ -3,7 +3,10 @@ import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { test } from "./fixtures";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { observeTimelineSubscriptions } from "./helpers/timeline-delivery";
|
||||
import {
|
||||
observeLastAssistantFrames,
|
||||
observeTimelineSubscriptions,
|
||||
} from "./helpers/timeline-delivery";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
import {
|
||||
@@ -72,40 +75,47 @@ async function commitMessage(scenario: ViewedTimelineScenario, agentId: string,
|
||||
}
|
||||
|
||||
test.describe("Viewed agent timelines", () => {
|
||||
test("an unsubscribed hidden chat catches up when shown", async ({ page }) => {
|
||||
test("a focused turn streams live and resumes its hidden remainder atomically", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const subscriptions = observeTimelineSubscriptions(page);
|
||||
const scenario = await seedViewedTimelineScenario();
|
||||
try {
|
||||
await openAgent(page, scenario, scenario.firstAgentId);
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId], { timeout: 45_000 });
|
||||
await commitMessage(
|
||||
scenario,
|
||||
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId]);
|
||||
|
||||
await scenario.client.sendAgentMessage(
|
||||
scenario.firstAgentId,
|
||||
"Committed after the first chat unsubscribed.",
|
||||
"Stream while focused, then finish while hidden.",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await selectAgent(page, "First viewed chat");
|
||||
await expect(
|
||||
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
|
||||
page.getByTestId("assistant-message").filter({ hasText: "Cycle 1" }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0);
|
||||
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId]);
|
||||
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 30_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
|
||||
const assistantFrames = await observeLastAssistantFrames(page);
|
||||
await selectAgent(page, "First viewed chat");
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||
const snapshots = await assistantFrames.stop();
|
||||
|
||||
expect(snapshots[0]).toContain("(end of synthetic stream)");
|
||||
} finally {
|
||||
await scenario.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a hidden retained chat stays current during unsubscribe grace", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const subscriptions = observeTimelineSubscriptions(page);
|
||||
test("a hidden retained chat catches up when shown", async ({ page }) => {
|
||||
const scenario = await seedViewedTimelineScenario();
|
||||
try {
|
||||
await openAgent(page, scenario, scenario.firstAgentId);
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]);
|
||||
await commitMessage(
|
||||
scenario,
|
||||
scenario.firstAgentId,
|
||||
@@ -118,7 +128,6 @@ test.describe("Viewed agent timelines", () => {
|
||||
await expect(
|
||||
page.getByText("Committed while the first chat is hidden.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await scenario.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||
import { delayCreatedAgentInitialTailResponse } from "./helpers/agent-timeline-gate";
|
||||
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
@@ -44,49 +43,6 @@ async function fetchActiveAgentTitle(
|
||||
}
|
||||
|
||||
test.describe("Workspace agent title handoff", () => {
|
||||
test("does not cover the agent pane while the optimistic create becomes authoritative", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
const timelineGate = await delayCreatedAgentInitialTailResponse(page);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-create-handoff-flash-" });
|
||||
|
||||
try {
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-new-agent-tab-inline").click();
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const prompt = "Keep the optimistic agent pane visible during handoff";
|
||||
await submitMessage(page, prompt);
|
||||
const agentId = await timelineGate.waitForCreatedAgent();
|
||||
await timelineGate.waitForDelayedResponse();
|
||||
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByTestId("agent-history-overlay")).toHaveCount(0);
|
||||
|
||||
const overlayAppeared = page
|
||||
.getByTestId("agent-history-overlay")
|
||||
.waitFor({ state: "attached", timeout: 2_000 })
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
timelineGate.release();
|
||||
await timelineGate.waitForForwardedResponse();
|
||||
|
||||
expect(await overlayAppeared).toBe(false);
|
||||
} finally {
|
||||
timelineGate.release();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows the prompt tab title and replaces it when the daemon title updates", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostWorkspaceOpenRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import {
|
||||
@@ -35,53 +31,11 @@ import {
|
||||
} from "./helpers/workspace-ui";
|
||||
import { clickSettingsBackToWorkspace } from "./helpers/settings";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { injectDesktopBridge, waitForDesktopDaemonStartRequest } from "./helpers/desktop-updates";
|
||||
import { injectDesktopBridge } from "./helpers/desktop-updates";
|
||||
import { expectAppRoute } from "./helpers/route-assertions";
|
||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||
import { addOfflineHostAndReload } from "./helpers/hosts";
|
||||
|
||||
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
||||
type StartupPresentation = "splash" | "app";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__paseoStartupPresentationTrace?: StartupPresentation[];
|
||||
}
|
||||
}
|
||||
|
||||
async function observeStartupPresentation(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const trace: StartupPresentation[] = [];
|
||||
window.__paseoStartupPresentationTrace = trace;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const recordPresentation = () => {
|
||||
let presentation: StartupPresentation | null = null;
|
||||
if (document.querySelector('[data-testid="startup-splash"]')) {
|
||||
presentation = "splash";
|
||||
} else if (
|
||||
document.querySelector(
|
||||
'[data-testid="workspace-header-title"], [data-testid="sidebar-settings"]',
|
||||
)
|
||||
) {
|
||||
presentation = "app";
|
||||
}
|
||||
if (presentation && trace.at(-1) !== presentation) {
|
||||
trace.push(presentation);
|
||||
}
|
||||
};
|
||||
new MutationObserver(recordPresentation).observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
recordPresentation();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getStartupPresentation(page: Page): Promise<StartupPresentation[]> {
|
||||
return page.evaluate(() => window.__paseoStartupPresentationTrace?.slice() ?? []);
|
||||
}
|
||||
|
||||
async function expectNoLoadingWorkspacePane(
|
||||
page: Page,
|
||||
@@ -163,43 +117,6 @@ async function expectWorkspaceLocation(
|
||||
test.describe("Workspace navigation regression", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("opens a notification's workspace on a different offline host", async ({ page }) => {
|
||||
const target = {
|
||||
serverId: "notification-offline-host",
|
||||
workspaceId: "notification-workspace",
|
||||
agentId: "notification-agent",
|
||||
};
|
||||
|
||||
await gotoAppShell(page);
|
||||
await addOfflineHostAndReload(page, {
|
||||
serverId: target.serverId,
|
||||
label: "Notification Host",
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId("sidebar-settings").filter({ visible: true }).first(),
|
||||
).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await page.evaluate((data) => {
|
||||
globalThis.dispatchEvent(
|
||||
new CustomEvent("paseo:web-notification-click", {
|
||||
detail: { data: { ...data, reason: "finished" } },
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
}, target);
|
||||
|
||||
await expectAppRoute(
|
||||
page,
|
||||
buildHostWorkspaceOpenRoute(target.serverId, target.workspaceId, `agent:${target.agentId}`),
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await expect(page.getByText("Connecting", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Notification Host", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("keeps one replacement draft after returning from settings and closing the last tab", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
@@ -322,9 +239,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("refresh keeps one continuous splash before restoring the workspace route", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("refresh keeps the user on the same workspace route", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const daemonGate = await installDaemonWebSocketGate(page);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-refresh-route-" });
|
||||
@@ -339,7 +254,6 @@ test.describe("Workspace navigation regression", () => {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
hangDaemonStart: true,
|
||||
desktopSettingsDelayMs: 250,
|
||||
daemonListen: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
});
|
||||
await openWorkspaceThroughApp(page, { serverId, workspace });
|
||||
@@ -347,16 +261,14 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
await expectWorkspaceLocation(page, { serverId, workspace });
|
||||
|
||||
await observeStartupPresentation(page);
|
||||
await daemonGate.drop();
|
||||
await page.reload();
|
||||
await waitForDesktopDaemonStartRequest(page);
|
||||
await expect(page.getByTestId("startup-splash")).toBeVisible({ timeout: 30_000 });
|
||||
daemonGate.restore();
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await expectWorkspaceLocation(page, { serverId, workspace });
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
expect(await getStartupPresentation(page)).toEqual(["splash", "app"]);
|
||||
} finally {
|
||||
daemonGate.restore();
|
||||
await workspace.cleanup();
|
||||
@@ -462,13 +374,12 @@ test.describe("Workspace navigation regression", () => {
|
||||
await expectWorkspaceDeckEntryCount(page, 2);
|
||||
|
||||
await page.evaluate(
|
||||
({ agentId, serverId: targetServerId, workspaceId }) => {
|
||||
({ agentId, serverId: targetServerId }) => {
|
||||
globalThis.dispatchEvent(
|
||||
new CustomEvent("paseo:web-notification-click", {
|
||||
detail: {
|
||||
data: {
|
||||
serverId: targetServerId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
reason: "finished",
|
||||
},
|
||||
@@ -477,7 +388,7 @@ test.describe("Workspace navigation regression", () => {
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId },
|
||||
{ agentId: secondAgent.id, serverId },
|
||||
);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
||||
|
||||
@@ -44,10 +44,7 @@ test.describe("Worktree restore", () => {
|
||||
tempRepo = await createTempGitRepo("wt-restore-");
|
||||
});
|
||||
|
||||
async function createArchivedMissingWorktree(
|
||||
prefix: string,
|
||||
options: { agentCount?: number; keepAgentsArchived?: boolean } = {},
|
||||
) {
|
||||
async function createArchivedMissingWorktree(prefix: string) {
|
||||
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
createdProjectIds.add(project.projectKey);
|
||||
const worktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||
@@ -56,19 +53,11 @@ test.describe("Worktree restore", () => {
|
||||
});
|
||||
createdProjectIds.add(worktree.projectKey);
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
const agents = await Promise.all(
|
||||
Array.from({ length: options.agentCount ?? 1 }, () =>
|
||||
createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
workspaceId: worktree.workspaceId,
|
||||
title: `${prefix}-${randomUUID().slice(0, 8)}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const agent = agents[0];
|
||||
if (!agent) {
|
||||
throw new Error("Expected at least one archived-worktree agent");
|
||||
}
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
workspaceId: worktree.workspaceId,
|
||||
title: `${prefix}-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
|
||||
await expect
|
||||
@@ -78,21 +67,11 @@ test.describe("Worktree restore", () => {
|
||||
// Match the remote cloud-race record: workspace archived and absent, while
|
||||
// the surviving closed agent record is not agent-archived. Refresh now owns
|
||||
// only agent lifecycle, so its expected cwd failure cannot recover the workspace.
|
||||
if (options.keepAgentsArchived) {
|
||||
for (const archivedAgent of agents) {
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, archivedAgent.id), { timeout: 30_000 })
|
||||
.not.toBeNull();
|
||||
}
|
||||
} else {
|
||||
await client.refreshAgent(agent.id).catch(() => undefined);
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 })
|
||||
.toBeNull();
|
||||
}
|
||||
await client.refreshAgent(agent.id).catch(() => undefined);
|
||||
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
|
||||
|
||||
return { agent, agents, worktree };
|
||||
return { agent, worktree };
|
||||
}
|
||||
|
||||
async function openArchivedWorkspaceFromHistory(page: Page, prefix: string) {
|
||||
@@ -262,54 +241,6 @@ test.describe("Worktree restore", () => {
|
||||
).toHaveText(switchedBranch, { timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("recovers the selected agent with its workspace and later rescues another archived agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { agents, worktree } = await createArchivedMissingWorktree("restore-agents", {
|
||||
agentCount: 2,
|
||||
keepAgentsArchived: true,
|
||||
});
|
||||
const [firstAgent, secondAgent] = agents;
|
||||
if (!firstAgent || !secondAgent) {
|
||||
throw new Error("Expected two archived agents");
|
||||
}
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await page.getByTestId(`agent-row-${getServerId()}-${firstAgent.id}`).click();
|
||||
|
||||
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByTestId("workspace-recovery-action").click();
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(true);
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${firstAgent.id}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, firstAgent.id), { timeout: 30_000 })
|
||||
.toBeNull();
|
||||
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await openSessions(page);
|
||||
await page.getByTestId(`agent-row-${getServerId()}-${secondAgent.id}`).click();
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${secondAgent.id}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByRole("button", { name: "Unarchive" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByRole("button", { name: "Unarchive" }).click();
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, secondAgent.id), { timeout: 30_000 })
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
|
||||
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
|
||||
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.2.0-beta.3",
|
||||
"version": "0.2.0-beta.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -23,6 +23,7 @@
|
||||
"test:browser": "vitest run --project browser",
|
||||
"test:e2e": "playwright test --project='Desktop Chrome'",
|
||||
"test:e2e:desktop": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/project-picker-desktop.spec.ts",
|
||||
"test:e2e:import-electron": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --project='Desktop Chrome' e2e/conductor-import.electron.spec.ts",
|
||||
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"build": "npm run build:web",
|
||||
@@ -32,11 +33,6 @@
|
||||
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.7.1",
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -51,7 +47,6 @@
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native/normalize-colors": "^0.81.5",
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"@replit/codemirror-vim": "6.3.0",
|
||||
"@tanstack/react-query": "^5.90.11",
|
||||
"@tanstack/react-virtual": "^3.13.21",
|
||||
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
||||
|
||||
@@ -27,7 +27,10 @@ export default defineConfig({
|
||||
projects: [
|
||||
{
|
||||
name: "Desktop Chrome",
|
||||
testIgnore: ["**/*.real.spec.ts"],
|
||||
testIgnore:
|
||||
process.env.E2E_DESKTOP_RUNTIME === "1"
|
||||
? ["**/*.real.spec.ts"]
|
||||
: ["**/*.real.spec.ts", "**/*.electron.spec.ts"],
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
FlatList,
|
||||
ActivityIndicator,
|
||||
Keyboard,
|
||||
Platform,
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
type ListRenderItemInfo,
|
||||
@@ -190,12 +189,6 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
},
|
||||
scrollToBottom,
|
||||
});
|
||||
// Android's maintainVisibleContentPosition ignores the list inversion transform and
|
||||
// fights the controller's offset-zero correction while the live header grows.
|
||||
const maintainVisibleContentPosition =
|
||||
Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom"
|
||||
? undefined
|
||||
: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION;
|
||||
|
||||
useEffect(() => {
|
||||
streamViewportMetricsRef.current = {
|
||||
@@ -421,7 +414,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
maintainVisibleContentPosition={maintainVisibleContentPosition}
|
||||
maintainVisibleContentPosition={DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION}
|
||||
initialNumToRender={40}
|
||||
maxToRenderPerBatch={40}
|
||||
updateCellsBatchingPeriod={0}
|
||||
|
||||
@@ -99,7 +99,7 @@ import {
|
||||
useWorkspaceAttachmentsStore,
|
||||
} from "@/attachments/workspace-attachments-store";
|
||||
import type { WorkspaceComposerAttachment } from "@/attachments/types";
|
||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
|
||||
|
||||
@@ -60,13 +60,13 @@ import {
|
||||
resolveStartupBlocker,
|
||||
resolveStartupNavigationReady,
|
||||
shouldRunStartupGiveUpTimer,
|
||||
startDaemonIfGateAllows,
|
||||
startHostRuntimeBootstrap,
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||
import { ThemedStack } from "@/navigation/themed-stack";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
import { AgentNavigationListener } from "@/desktop/agent-navigation";
|
||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||
import { updateDesktopWindowControls } from "@/desktop/electron/window";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
@@ -147,10 +147,9 @@ function PushNotificationRouter() {
|
||||
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
|
||||
const target = resolveNotificationTarget(data);
|
||||
const serverId = target.serverId;
|
||||
const workspaceId = target.workspaceId;
|
||||
const agentId = target.agentId;
|
||||
if (serverId && workspaceId && agentId) {
|
||||
navigateToAgent({ serverId, workspaceId, agentId, pin: true });
|
||||
if (serverId && agentId) {
|
||||
navigateToAgent({ serverId, agentId, pin: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -338,6 +337,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
store,
|
||||
daemonStartService,
|
||||
shouldStartDaemon: shouldStartBuiltInDaemon,
|
||||
onGateError: (message) => daemonStartService.recordError(message),
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -376,7 +376,11 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const retry = useCallback(() => {
|
||||
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||
void daemonStartService.startIfEnabled({ shouldStart: shouldStartBuiltInDaemon });
|
||||
startDaemonIfGateAllows({
|
||||
daemonStartService,
|
||||
shouldStartDaemon: shouldStartBuiltInDaemon,
|
||||
onGateError: (message) => daemonStartService.recordError(message),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const splashError =
|
||||
@@ -910,7 +914,6 @@ function AppShell() {
|
||||
<MobilePanelsProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<OpenProjectListener />
|
||||
<AgentNavigationListener />
|
||||
<AppWithSidebar>
|
||||
<WorkspaceRouteNavigationBridge />
|
||||
<RootStack />
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
|
||||
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
|
||||
export default function HostAgentReadyRoute() {
|
||||
@@ -24,128 +21,97 @@ function HostAgentReadyRouteContent() {
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
}>();
|
||||
const handledNavigationRef = useRef<string | null>(null);
|
||||
const redirectedRef = useRef(false);
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||
const agentId = typeof params.agentId === "string" ? params.agentId : "";
|
||||
const hosts = useHosts();
|
||||
const runtimeSnapshot = useHostRuntimeSnapshot(serverId);
|
||||
const client = runtimeSnapshot?.client ?? null;
|
||||
const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting";
|
||||
const hostName = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const agentWorkspaceId = useSessionStore((state) => {
|
||||
if (!serverId || !agentId) {
|
||||
return null;
|
||||
}
|
||||
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
|
||||
});
|
||||
const shouldLookupAgent = Boolean(
|
||||
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
|
||||
const hasHydratedWorkspaces = useSessionStore((state) =>
|
||||
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
||||
);
|
||||
const lookupQuery = useFetchQuery({
|
||||
queryKey: ["agentRouteResolution", serverId, agentId, runtimeSnapshot?.clientGeneration ?? 0],
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error("Target host client is unavailable");
|
||||
}
|
||||
const result = await client.fetchAgent({ agentId });
|
||||
return result?.agent?.workspaceId ?? null;
|
||||
},
|
||||
enabled: shouldLookupAgent,
|
||||
retry: false,
|
||||
dataShape: "value",
|
||||
staleTimeMs: 0,
|
||||
});
|
||||
const lookup = useMemo<AgentRouteLookup>(() => {
|
||||
if (!shouldLookupAgent) {
|
||||
return { kind: "idle" };
|
||||
}
|
||||
if (lookupQuery.isFetching) {
|
||||
return { kind: "fetching" };
|
||||
}
|
||||
if (lookupQuery.isError) {
|
||||
return { kind: "failed", error: toErrorMessage(lookupQuery.error) };
|
||||
}
|
||||
if (lookupQuery.isSuccess) {
|
||||
return { kind: "found", workspaceId: lookupQuery.data };
|
||||
}
|
||||
return { kind: "fetching" };
|
||||
}, [
|
||||
lookupQuery.data,
|
||||
lookupQuery.error,
|
||||
lookupQuery.isError,
|
||||
lookupQuery.isFetching,
|
||||
lookupQuery.isSuccess,
|
||||
shouldLookupAgent,
|
||||
]);
|
||||
const resolution = resolveAgentRoute({
|
||||
serverId,
|
||||
agentId,
|
||||
cachedWorkspaceId: agentWorkspaceId,
|
||||
connectionStatus,
|
||||
lookup,
|
||||
});
|
||||
const resolvedWorkspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
|
||||
|
||||
useEffect(() => {
|
||||
let navigationKey: string | null = null;
|
||||
if (resolution.kind === "invalid") {
|
||||
navigationKey = "invalid";
|
||||
} else if (resolution.kind === "resolved") {
|
||||
navigationKey = `workspace:${resolution.workspaceId}`;
|
||||
} else if (resolution.kind === "notFound") {
|
||||
navigationKey = "not-found";
|
||||
if (redirectedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!navigationKey || handledNavigationRef.current === navigationKey) {
|
||||
if (!serverId || !agentId) {
|
||||
redirectedRef.current = true;
|
||||
router.replace("/" as Href);
|
||||
return;
|
||||
}
|
||||
handledNavigationRef.current = navigationKey;
|
||||
|
||||
if (resolution.kind === "resolved") {
|
||||
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
|
||||
return;
|
||||
if (resolvedWorkspaceId) {
|
||||
redirectedRef.current = true;
|
||||
navigateToAgent({
|
||||
serverId,
|
||||
agentId,
|
||||
});
|
||||
}
|
||||
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
|
||||
}, [agentId, resolution, router, serverId]);
|
||||
}, [agentId, resolvedWorkspaceId, router, serverId]);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
if (resolution.kind === "lookupError") {
|
||||
void lookupQuery.refetch();
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (serverId) {
|
||||
void getHostRuntimeStore().runProbeCycleNow(serverId);
|
||||
}
|
||||
}, [lookupQuery, resolution.kind, serverId]);
|
||||
const handleManageHost = useCallback(() => {
|
||||
if (serverId) {
|
||||
router.push(buildSettingsHostRoute(serverId));
|
||||
}
|
||||
}, [router, serverId]);
|
||||
const handleBack = useCallback(() => {
|
||||
if (router.canGoBack()) {
|
||||
router.back();
|
||||
if (!serverId || !agentId) {
|
||||
return;
|
||||
}
|
||||
router.replace(serverId ? buildHostRootRoute(serverId) : ("/" as Href));
|
||||
}, [router, serverId]);
|
||||
if (agentWorkspaceId && !hasHydratedWorkspaces) {
|
||||
return;
|
||||
}
|
||||
if (!client || !isConnected) {
|
||||
redirectedRef.current = true;
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
}
|
||||
}, [agentWorkspaceId, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
|
||||
|
||||
if (
|
||||
resolution.kind === "waitingForHost" ||
|
||||
resolution.kind === "fetchingAgent" ||
|
||||
resolution.kind === "lookupError"
|
||||
) {
|
||||
// Agent URLs intentionally omit workspaceId. Keep this route mounted while the target host
|
||||
// reconnects, then resolve the workspace from the authoritative agent record.
|
||||
return (
|
||||
<AgentRouteResolutionView
|
||||
resolution={resolution}
|
||||
hostName={hostName}
|
||||
lastHostError={runtimeSnapshot?.lastError ?? null}
|
||||
onRetry={handleRetry}
|
||||
onManageHost={handleManageHost}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!serverId || !agentId || !client || !isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
void client
|
||||
.fetchAgent({ agentId })
|
||||
.then((result) => {
|
||||
if (cancelled || redirectedRef.current) {
|
||||
return;
|
||||
}
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(result?.agent?.workspaceId);
|
||||
redirectedRef.current = true;
|
||||
if (workspaceId) {
|
||||
navigateToAgent({
|
||||
serverId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
return;
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled || redirectedRef.current) {
|
||||
return;
|
||||
}
|
||||
redirectedRef.current = true;
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentId, client, isConnected, router, serverId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { useHasHydratedWorkspaces, useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
import { useWorkspaceLayoutStoreHydrated } from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
@@ -110,9 +110,7 @@ function HostWorkspaceRouteContent() {
|
||||
const openValue = getParamValue(globalParams.open);
|
||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
||||
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
|
||||
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
|
||||
const recoveryAgentId = openIntent?.kind === "agent" ? openIntent.agentId : null;
|
||||
const isAgentOpenIntent = recoveryAgentId !== null;
|
||||
const isAgentOpenIntent = parseWorkspaceOpenIntent(openValue)?.kind === "agent";
|
||||
const isOpenIntentWaitingForWorkspace = Boolean(
|
||||
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
|
||||
);
|
||||
@@ -149,6 +147,7 @@ function HostWorkspaceRouteContent() {
|
||||
}
|
||||
consumedIntentRef.current = consumptionKey;
|
||||
|
||||
const openIntent = parseWorkspaceOpenIntent(openValue);
|
||||
if (openIntent) {
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
@@ -172,7 +171,6 @@ function HostWorkspaceRouteContent() {
|
||||
hasHydratedWorkspaceLayoutStore,
|
||||
isOpenIntentWaitingForWorkspace,
|
||||
navigation,
|
||||
openIntent,
|
||||
openValue,
|
||||
rootNavigationState?.key,
|
||||
serverId,
|
||||
@@ -187,16 +185,10 @@ function HostWorkspaceRouteContent() {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} recoveryAgentId={recoveryAgentId} />;
|
||||
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
|
||||
}
|
||||
|
||||
function WorkspaceDeck({
|
||||
recoveryRequested,
|
||||
recoveryAgentId,
|
||||
}: {
|
||||
recoveryRequested: boolean;
|
||||
recoveryAgentId: string | null;
|
||||
}) {
|
||||
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
|
||||
const activeSelection = useActiveWorkspaceSelection();
|
||||
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
|
||||
activeSelection ? [activeSelection] : [],
|
||||
@@ -242,7 +234,6 @@ function WorkspaceDeck({
|
||||
selection={selection}
|
||||
activeSelection={activeSelection}
|
||||
recoveryRequested={recoveryRequested}
|
||||
recoveryAgentId={recoveryAgentId}
|
||||
onUnmountInactive={unmountWorkspaceSelection}
|
||||
/>
|
||||
);
|
||||
@@ -255,13 +246,11 @@ function WorkspaceDeckEntry({
|
||||
selection,
|
||||
activeSelection,
|
||||
recoveryRequested,
|
||||
recoveryAgentId,
|
||||
onUnmountInactive,
|
||||
}: {
|
||||
selection: ActiveWorkspaceSelection;
|
||||
activeSelection: ActiveWorkspaceSelection;
|
||||
recoveryRequested: boolean;
|
||||
recoveryAgentId: string | null;
|
||||
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
|
||||
}) {
|
||||
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
|
||||
@@ -293,7 +282,6 @@ function WorkspaceDeckEntry({
|
||||
workspaceId={selection.workspaceId}
|
||||
isRouteFocused={isActive}
|
||||
recoveryRequested={isActive && recoveryRequested}
|
||||
recoveryAgentId={isActive ? recoveryAgentId : null}
|
||||
/>
|
||||
</RetainedPanel>
|
||||
);
|
||||
|
||||
@@ -92,20 +92,9 @@ export interface ChatHistoryContextAttachment {
|
||||
|
||||
export const NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER = "new-workspace-picker";
|
||||
|
||||
export type WorkspaceFileSelection =
|
||||
| { kind: "whole_file" }
|
||||
| { kind: "line_range"; startLine: number; endLine: number };
|
||||
|
||||
export interface WorkspaceFileComposerAttachment {
|
||||
kind: "workspace_file";
|
||||
path: string;
|
||||
selection: WorkspaceFileSelection;
|
||||
}
|
||||
|
||||
export type UserComposerAttachment =
|
||||
| { kind: "image"; metadata: AttachmentMetadata }
|
||||
| { kind: "file"; attachment: UploadedFileAttachment }
|
||||
| WorkspaceFileComposerAttachment
|
||||
| { kind: "forge_issue"; item: ForgeSearchItem }
|
||||
| { kind: "forge_change_request"; item: ForgeSearchItem }
|
||||
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { RefCallback } from "react";
|
||||
import type { View } from "react-native";
|
||||
import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types";
|
||||
|
||||
export function useWorkspaceFileDragSource(
|
||||
_input: WorkspaceFileDragSourceInput,
|
||||
): RefCallback<View> | undefined {
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { useCallback, useEffect, useState, type RefCallback } from "react";
|
||||
import type { View } from "react-native";
|
||||
import { createWorkspaceFileAttachment } from "./workspace-file";
|
||||
import { serializeWorkspaceFileDragPayload, WORKSPACE_FILE_DRAG_MIME } from "./workspace-file-drag";
|
||||
import type { WorkspaceFileDragSourceInput } from "./workspace-file-drag-source.types";
|
||||
|
||||
export function useWorkspaceFileDragSource({
|
||||
enabled,
|
||||
disabled = false,
|
||||
serverId,
|
||||
workspaceId,
|
||||
path,
|
||||
selection,
|
||||
}: WorkspaceFileDragSourceInput): RefCallback<View> {
|
||||
const [element, setElement] = useState<HTMLElement | null>(null);
|
||||
const dragSourceRef = useCallback((node: View | null) => {
|
||||
setElement(node as unknown as HTMLElement | null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!element || !enabled || disabled || !serverId || !workspaceId) {
|
||||
if (element) {
|
||||
element.draggable = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceServerId = serverId;
|
||||
const sourceWorkspaceId = workspaceId;
|
||||
element.draggable = true;
|
||||
function handleDragStart(event: DragEvent) {
|
||||
if (!event.dataTransfer) {
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.effectAllowed = "copy";
|
||||
event.dataTransfer.setData(
|
||||
WORKSPACE_FILE_DRAG_MIME,
|
||||
serializeWorkspaceFileDragPayload({
|
||||
version: 1,
|
||||
serverId: sourceServerId,
|
||||
workspaceId: sourceWorkspaceId,
|
||||
attachment: createWorkspaceFileAttachment({ path, selection }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
element.addEventListener("dragstart", handleDragStart);
|
||||
return () => {
|
||||
element.draggable = false;
|
||||
element.removeEventListener("dragstart", handleDragStart);
|
||||
};
|
||||
}, [disabled, element, enabled, path, selection, serverId, workspaceId]);
|
||||
|
||||
return dragSourceRef;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import type { WorkspaceFileSelection } from "./types";
|
||||
|
||||
export interface WorkspaceFileDragSourceInput {
|
||||
enabled: boolean;
|
||||
disabled?: boolean;
|
||||
serverId?: string;
|
||||
workspaceId: string | null | undefined;
|
||||
path: string;
|
||||
selection?: WorkspaceFileSelection;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createWorkspaceFileAttachment } from "./workspace-file";
|
||||
import {
|
||||
parseWorkspaceFileDragPayload,
|
||||
resolveWorkspaceFileDrop,
|
||||
serializeWorkspaceFileDragPayload,
|
||||
type WorkspaceFileDragPayload,
|
||||
} from "./workspace-file-drag";
|
||||
|
||||
function payload(): WorkspaceFileDragPayload {
|
||||
return {
|
||||
version: 1,
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-1",
|
||||
attachment: createWorkspaceFileAttachment({
|
||||
path: "src/app.ts",
|
||||
selection: { kind: "line_range", startLine: 12, endLine: 24 },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace file drag payload", () => {
|
||||
it("round-trips workspace identity and future line selections", () => {
|
||||
expect(parseWorkspaceFileDragPayload(serializeWorkspaceFileDragPayload(payload()))).toEqual(
|
||||
payload(),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed and invalid payloads", () => {
|
||||
expect(parseWorkspaceFileDragPayload("not json")).toBeNull();
|
||||
expect(
|
||||
parseWorkspaceFileDragPayload(
|
||||
JSON.stringify({
|
||||
...payload(),
|
||||
attachment: {
|
||||
kind: "workspace_file",
|
||||
path: "src/app.ts",
|
||||
selection: { kind: "line_range", startLine: 24, endLine: 12 },
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts drops only within the originating server and workspace", () => {
|
||||
const dragged = payload();
|
||||
expect(
|
||||
resolveWorkspaceFileDrop({
|
||||
payload: dragged,
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-1",
|
||||
}),
|
||||
).toEqual(dragged.attachment);
|
||||
expect(
|
||||
resolveWorkspaceFileDrop({
|
||||
payload: dragged,
|
||||
serverId: "server-1",
|
||||
workspaceId: "workspace-2",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import type { WorkspaceFileComposerAttachment } from "./types";
|
||||
import { isWorkspaceFileComposerAttachment } from "./workspace-file";
|
||||
|
||||
export const WORKSPACE_FILE_DRAG_MIME = "application/x-paseo-workspace-file+json";
|
||||
|
||||
export interface WorkspaceFileDragPayload {
|
||||
version: 1;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
attachment: WorkspaceFileComposerAttachment;
|
||||
}
|
||||
|
||||
export function serializeWorkspaceFileDragPayload(payload: WorkspaceFileDragPayload): string {
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
export function parseWorkspaceFileDragPayload(serialized: string): WorkspaceFileDragPayload | null {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(serialized);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return null;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
record.version !== 1 ||
|
||||
typeof record.serverId !== "string" ||
|
||||
record.serverId.length === 0 ||
|
||||
typeof record.workspaceId !== "string" ||
|
||||
record.workspaceId.length === 0 ||
|
||||
!isWorkspaceFileComposerAttachment(record.attachment)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
serverId: record.serverId,
|
||||
workspaceId: record.workspaceId,
|
||||
attachment: record.attachment,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWorkspaceFileDrop(input: {
|
||||
payload: WorkspaceFileDragPayload;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}): WorkspaceFileComposerAttachment | null {
|
||||
return input.payload.serverId === input.serverId &&
|
||||
input.payload.workspaceId === input.workspaceId
|
||||
? input.payload.attachment
|
||||
: null;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import {
|
||||
appendWorkspaceFileAttachment,
|
||||
createWorkspaceFileAttachment,
|
||||
getWorkspaceFileAttachmentKey,
|
||||
getWorkspaceFileAttachmentSubtitle,
|
||||
workspaceFileAttachmentToAgentAttachment,
|
||||
} from "./workspace-file";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
|
||||
|
||||
describe("workspace file attachments", () => {
|
||||
it("models whole files and line ranges as distinct selections", () => {
|
||||
const wholeFile = createWorkspaceFileAttachment({ path: "src/app.ts" });
|
||||
const lineRange = createWorkspaceFileAttachment({
|
||||
path: "src/app.ts",
|
||||
selection: { kind: "line_range", startLine: 12, endLine: 24 },
|
||||
});
|
||||
|
||||
expect(wholeFile).toEqual({
|
||||
kind: "workspace_file",
|
||||
path: "src/app.ts",
|
||||
selection: { kind: "whole_file" },
|
||||
});
|
||||
expect(getWorkspaceFileAttachmentKey(wholeFile)).not.toBe(
|
||||
getWorkspaceFileAttachmentKey(lineRange),
|
||||
);
|
||||
expect(getWorkspaceFileAttachmentSubtitle(wholeFile)).toBe("src/app.ts");
|
||||
expect(getWorkspaceFileAttachmentSubtitle(lineRange)).toBe("src/app.ts · 12-24");
|
||||
});
|
||||
|
||||
it("deduplicates only identical paths and selections", () => {
|
||||
const image = {
|
||||
kind: "image" as const,
|
||||
metadata: {
|
||||
id: "image-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb" as const,
|
||||
storageKey: "image-1",
|
||||
createdAt: 1,
|
||||
},
|
||||
};
|
||||
const wholeFile = createWorkspaceFileAttachment({ path: "src/app.ts" });
|
||||
const range = createWorkspaceFileAttachment({
|
||||
path: "src/app.ts",
|
||||
selection: { kind: "line_range", startLine: 1, endLine: 5 },
|
||||
});
|
||||
const current: UserComposerAttachment[] = [image, wholeFile];
|
||||
|
||||
expect(appendWorkspaceFileAttachment(current, wholeFile)).toBe(current);
|
||||
expect(appendWorkspaceFileAttachment(current, range)).toEqual([image, wholeFile, range]);
|
||||
});
|
||||
|
||||
it("submits a path reference without uploading or inserting prompt text", () => {
|
||||
const attachment = createWorkspaceFileAttachment({
|
||||
path: "src/app.ts",
|
||||
selection: { kind: "line_range", startLine: 12, endLine: 24 },
|
||||
});
|
||||
|
||||
expect(workspaceFileAttachmentToAgentAttachment(attachment)).toEqual({
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: "app.ts",
|
||||
text: "Workspace file: src/app.ts\nLines: 12-24",
|
||||
});
|
||||
expect(splitComposerAttachmentsForSubmit([attachment])).toEqual({
|
||||
images: [],
|
||||
attachments: [workspaceFileAttachmentToAgentAttachment(attachment)],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
UserComposerAttachment,
|
||||
WorkspaceFileComposerAttachment,
|
||||
WorkspaceFileSelection,
|
||||
} from "./types";
|
||||
|
||||
interface CreateWorkspaceFileAttachmentInput {
|
||||
path: string;
|
||||
selection?: WorkspaceFileSelection;
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.trim().replace(/^\.\//, "");
|
||||
}
|
||||
|
||||
export function isWorkspaceFileComposerAttachment(
|
||||
value: unknown,
|
||||
): value is WorkspaceFileComposerAttachment {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
record.kind !== "workspace_file" ||
|
||||
typeof record.path !== "string" ||
|
||||
record.path.trim().length === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const selection = record.selection;
|
||||
if (!selection || typeof selection !== "object") {
|
||||
return false;
|
||||
}
|
||||
const { kind, startLine, endLine } = selection as Record<string, unknown>;
|
||||
if (kind === "whole_file") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
kind === "line_range" &&
|
||||
typeof startLine === "number" &&
|
||||
Number.isInteger(startLine) &&
|
||||
typeof endLine === "number" &&
|
||||
Number.isInteger(endLine) &&
|
||||
startLine > 0 &&
|
||||
endLine >= startLine
|
||||
);
|
||||
}
|
||||
|
||||
export function createWorkspaceFileAttachment({
|
||||
path,
|
||||
selection = { kind: "whole_file" },
|
||||
}: CreateWorkspaceFileAttachmentInput): WorkspaceFileComposerAttachment {
|
||||
return {
|
||||
kind: "workspace_file",
|
||||
path: normalizePath(path),
|
||||
selection,
|
||||
};
|
||||
}
|
||||
|
||||
export function getWorkspaceFileAttachmentKey(attachment: WorkspaceFileComposerAttachment): string {
|
||||
const selection = attachment.selection;
|
||||
const selectionKey =
|
||||
selection.kind === "whole_file"
|
||||
? selection.kind
|
||||
: `${selection.kind}:${selection.startLine}-${selection.endLine}`;
|
||||
return `${normalizePath(attachment.path)}:${selectionKey}`;
|
||||
}
|
||||
|
||||
export function appendWorkspaceFileAttachment(
|
||||
current: UserComposerAttachment[],
|
||||
attachment: WorkspaceFileComposerAttachment,
|
||||
): UserComposerAttachment[] {
|
||||
const attachmentKey = getWorkspaceFileAttachmentKey(attachment);
|
||||
const alreadyAttached = current.some(
|
||||
(candidate) =>
|
||||
candidate.kind === "workspace_file" &&
|
||||
getWorkspaceFileAttachmentKey(candidate) === attachmentKey,
|
||||
);
|
||||
return alreadyAttached ? current : [...current, attachment];
|
||||
}
|
||||
|
||||
export function workspaceFileAttachmentToAgentAttachment(
|
||||
attachment: WorkspaceFileComposerAttachment,
|
||||
): Extract<AgentAttachment, { type: "text" }> {
|
||||
const fileName = attachment.path.split("/").pop() ?? attachment.path;
|
||||
const lines =
|
||||
attachment.selection.kind === "line_range"
|
||||
? `\nLines: ${attachment.selection.startLine}-${attachment.selection.endLine}`
|
||||
: "";
|
||||
return {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: fileName,
|
||||
text: `Workspace file: ${attachment.path}${lines}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function getWorkspaceFileAttachmentSubtitle(
|
||||
attachment: WorkspaceFileComposerAttachment,
|
||||
): string {
|
||||
if (attachment.selection.kind === "whole_file") {
|
||||
return attachment.path;
|
||||
}
|
||||
return `${attachment.path} · ${attachment.selection.startLine}-${attachment.selection.endLine}`;
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import { createJSONStorage, type StateStorage } from "zustand/middleware";
|
||||
import { mountBrowserAutomationHandler } from "./handler";
|
||||
import type { DesktopHostBridge } from "@/desktop/host";
|
||||
import { useBrowserStore } from "@/stores/browser-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
|
||||
type BrowserAutomationExecuteRequest = Extract<
|
||||
SessionOutboundMessage,
|
||||
|
||||
@@ -6,8 +6,11 @@ import {
|
||||
resizeResidentBrowserWebview,
|
||||
} from "@/components/browser-webview-resident";
|
||||
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store";
|
||||
import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
collectAllTabs,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
|
||||
type BrowserAutomationExecuteRequest = Extract<
|
||||
SessionOutboundMessage,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
|
||||
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
|
||||
|
||||
export type ClientSlashCommandKind = "archive-agent" | "replace-agent-with-draft";
|
||||
export type ClientSlashCommandExecution = "immediate" | "insert";
|
||||
|
||||
@@ -458,6 +458,8 @@ export interface AdaptiveModalSheetProps {
|
||||
desktopMaxWidth?: number;
|
||||
scrollable?: boolean;
|
||||
presentation?: "push" | "replace";
|
||||
/** Whether gestures may dismiss the compact bottom sheet. */
|
||||
dismissible?: boolean;
|
||||
}
|
||||
|
||||
export function AdaptiveModalSheet({
|
||||
@@ -472,6 +474,7 @@ export function AdaptiveModalSheet({
|
||||
desktopMaxWidth,
|
||||
scrollable = true,
|
||||
presentation,
|
||||
dismissible = true,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
@@ -608,7 +611,7 @@ export function AdaptiveModalSheet({
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
enablePanDownToClose={dismissible}
|
||||
backgroundComponent={SheetBackground}
|
||||
handleIndicatorStyle={handleIndicatorStyle}
|
||||
keyboardBehavior="extend"
|
||||
|
||||
@@ -394,7 +394,6 @@ export function AgentList({
|
||||
serverId,
|
||||
agentId,
|
||||
workspaceId: agent.workspaceId,
|
||||
pin: true,
|
||||
});
|
||||
},
|
||||
[isActionSheetVisible, onAgentSelect],
|
||||
|
||||
@@ -9,7 +9,6 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
|
||||
interface ArchivedAgentCalloutProps {
|
||||
serverId: string;
|
||||
@@ -22,7 +21,6 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const [isUnarchiving, setIsUnarchiving] = useState(false);
|
||||
const [unarchiveError, setUnarchiveError] = useState<string | null>(null);
|
||||
|
||||
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({ mode: "translate" });
|
||||
|
||||
@@ -34,11 +32,10 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
||||
const handleUnarchive = useCallback(async () => {
|
||||
if (!client || !isConnected || isUnarchiving) return;
|
||||
setIsUnarchiving(true);
|
||||
setUnarchiveError(null);
|
||||
try {
|
||||
await client.refreshAgent(agentId);
|
||||
} catch (error) {
|
||||
setUnarchiveError(toErrorMessage(error));
|
||||
console.error("[ArchivedAgentCallout] Failed to unarchive agent:", error);
|
||||
setIsUnarchiving(false);
|
||||
}
|
||||
}, [client, isConnected, isUnarchiving, agentId]);
|
||||
@@ -47,23 +44,16 @@ export function ArchivedAgentCallout({ serverId, agentId }: ArchivedAgentCallout
|
||||
<Animated.View style={containerStyle}>
|
||||
<View style={styles.inputAreaContainer}>
|
||||
<View style={styles.inputAreaContent}>
|
||||
<View style={styles.calloutStack}>
|
||||
<View style={styles.callout}>
|
||||
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={handleUnarchive}
|
||||
disabled={!isConnected || isUnarchiving}
|
||||
>
|
||||
{t("agentPanel.archived.unarchive")}
|
||||
</Button>
|
||||
</View>
|
||||
{unarchiveError ? (
|
||||
<Text style={styles.errorText} testID="agent-unarchive-error">
|
||||
{unarchiveError}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={styles.callout}>
|
||||
<Text style={styles.calloutText}>{t("agentPanel.archived.callout")}</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={handleUnarchive}
|
||||
disabled={!isConnected || isUnarchiving}
|
||||
>
|
||||
{t("agentPanel.archived.unarchive")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -107,16 +97,8 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
md: theme.spacing[6],
|
||||
},
|
||||
},
|
||||
calloutStack: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
calloutText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
errorText: {
|
||||
color: theme.colors.statusDanger,
|
||||
fontSize: theme.fontSize.sm,
|
||||
textAlign: "center",
|
||||
},
|
||||
})) as unknown as Record<string, object>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-layout-store";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
interface DiffStatProps {
|
||||
additions: number;
|
||||
deletions: number;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const compactFormatter = new Intl.NumberFormat("en-US", {
|
||||
@@ -16,9 +15,9 @@ export function formatDiffCount(value: number): string {
|
||||
return compactFormatter.format(value).toLowerCase();
|
||||
}
|
||||
|
||||
export function DiffStat({ additions, deletions, testID }: DiffStatProps) {
|
||||
export function DiffStat({ additions, deletions }: DiffStatProps) {
|
||||
return (
|
||||
<View style={styles.row} testID={testID}>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.additions}>+{formatDiffCount(additions)}</Text>
|
||||
<Text style={styles.deletions}>-{formatDiffCount(deletions)}</Text>
|
||||
</View>
|
||||
|
||||
@@ -36,11 +36,6 @@ import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
import { resolveDesktopExplorerWidth } from "@/components/desktop-sidebar-layout";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { resolveFocusedChatTarget } from "@/composer/focused-chat-target";
|
||||
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
|
||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||
|
||||
@@ -391,16 +386,15 @@ function ExplorerSidebarContent({
|
||||
{/* Content based on active tab */}
|
||||
<View style={styles.contentArea} testID="explorer-content-area">
|
||||
{resolvedTab === "changes" && (
|
||||
<ChangedFilesPane
|
||||
<GitDiffPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
isOpen={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
cwd={workspaceRoot}
|
||||
enabled={isOpen}
|
||||
/>
|
||||
)}
|
||||
{resolvedTab === "files" && (
|
||||
<FilesPane
|
||||
<FileExplorerPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
@@ -421,83 +415,6 @@ function ExplorerSidebarContent({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared add-to-chat state for the changes/files panes: both expose an "add file
|
||||
* to chat" action that attaches the file to the focused chat's composer.
|
||||
* Available only when a workspace with a focused chat is available.
|
||||
*/
|
||||
function useAddFileToChat({
|
||||
serverId,
|
||||
workspaceId,
|
||||
}: Pick<SidebarContentProps, "serverId" | "workspaceId">) {
|
||||
const workspaceKey = workspaceId
|
||||
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId })
|
||||
: null;
|
||||
const layout = useWorkspaceLayoutStore((state) =>
|
||||
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
|
||||
);
|
||||
const focusTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||
const focusedChat = useMemo(
|
||||
() => resolveFocusedChatTarget({ serverId, layout }),
|
||||
[serverId, layout],
|
||||
);
|
||||
const addFile = useCallback(
|
||||
(filePath: string) => {
|
||||
if (!focusedChat || !workspaceKey) {
|
||||
return;
|
||||
}
|
||||
void useDraftStore.getState().attachWorkspaceFile({
|
||||
draftKey: focusedChat.draftKey,
|
||||
attachment: createWorkspaceFileAttachment({ path: filePath }),
|
||||
});
|
||||
focusTab(workspaceKey, focusedChat.tabId);
|
||||
},
|
||||
[focusTab, focusedChat, workspaceKey],
|
||||
);
|
||||
return { addFile, canAddToChat: focusedChat !== null };
|
||||
}
|
||||
|
||||
function ChangedFilesPane({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
isOpen,
|
||||
onOpenFile,
|
||||
}: Pick<
|
||||
SidebarContentProps,
|
||||
"serverId" | "workspaceId" | "workspaceRoot" | "isOpen" | "onOpenFile"
|
||||
>) {
|
||||
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
||||
return (
|
||||
<GitDiffPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
cwd={workspaceRoot}
|
||||
enabled={isOpen}
|
||||
onOpenFile={onOpenFile}
|
||||
onAddToChat={canAddToChat ? addFile : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FilesPane({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
onOpenFile,
|
||||
}: Pick<SidebarContentProps, "serverId" | "workspaceId" | "workspaceRoot" | "onOpenFile">) {
|
||||
const { addFile, canAddToChat } = useAddFileToChat({ serverId, workspaceId });
|
||||
return (
|
||||
<FileExplorerPane
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
workspaceRoot={workspaceRoot}
|
||||
onOpenFile={onOpenFile}
|
||||
onAddToChat={canAddToChat ? addFile : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface PrTabContentProps {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import { useMemo, type ReactElement, type ReactNode } from "react";
|
||||
import { type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
Copy,
|
||||
Download,
|
||||
FileText,
|
||||
MessageSquarePlus,
|
||||
MoreVertical,
|
||||
type LucideIcon,
|
||||
} from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const ThemedMoreVertical = withUnistyles(MoreVertical);
|
||||
|
||||
/** Width occupied by a file action trigger, including its visual padding. */
|
||||
export const FILE_ACTIONS_MENU_WIDTH = ICON_SIZE.sm + 2 * SPACING[1];
|
||||
|
||||
interface FileAction {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
onSelect: () => void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
interface FileActionsMenuProps {
|
||||
fileKind: "file" | "directory";
|
||||
fileExists?: boolean;
|
||||
onOpenFile?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
onDownload?: () => void;
|
||||
onAddToChat?: () => void;
|
||||
/** Optional metadata block rendered above the actions (e.g. size/modified). */
|
||||
header?: ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
hitSlop?: number;
|
||||
accessibilityLabel: string;
|
||||
testIDPrefix?: string;
|
||||
}
|
||||
|
||||
// The menu lives inside pressable rows (diff header, explorer entry); stop the
|
||||
// press so opening it doesn't also trigger the row.
|
||||
function stopTriggerPropagation(event: { stopPropagation?: () => void }) {
|
||||
event.stopPropagation?.();
|
||||
}
|
||||
|
||||
function triggerStyle({
|
||||
hovered,
|
||||
pressed,
|
||||
open,
|
||||
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
|
||||
return [styles.trigger, (Boolean(hovered) || pressed || Boolean(open)) && styles.triggerActive];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared kebab (⋮) menu for per-file actions. Used by the file explorer tree and
|
||||
* git diff pane so both surfaces share action availability, ordering, and chrome.
|
||||
*/
|
||||
export function FileActionsMenu({
|
||||
fileKind,
|
||||
fileExists = true,
|
||||
onOpenFile,
|
||||
onCopyPath,
|
||||
onDownload,
|
||||
onAddToChat,
|
||||
header,
|
||||
open,
|
||||
onOpenChange,
|
||||
hitSlop = 12,
|
||||
accessibilityLabel,
|
||||
testIDPrefix,
|
||||
}: FileActionsMenuProps): ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const actions = useMemo<FileAction[]>(() => {
|
||||
const availableFile = fileKind === "file" && fileExists;
|
||||
const next: FileAction[] = [];
|
||||
if (availableFile && onOpenFile) {
|
||||
next.push({
|
||||
key: "open-file",
|
||||
label: t("workspace.fileActions.openFile"),
|
||||
icon: FileText,
|
||||
onSelect: onOpenFile,
|
||||
testID: testIDPrefix ? `${testIDPrefix}-open-file` : undefined,
|
||||
});
|
||||
}
|
||||
if (onCopyPath) {
|
||||
next.push({
|
||||
key: "copy-path",
|
||||
label: t("workspace.fileActions.copyPath"),
|
||||
icon: Copy,
|
||||
onSelect: onCopyPath,
|
||||
});
|
||||
}
|
||||
if (availableFile && onDownload) {
|
||||
next.push({
|
||||
key: "download",
|
||||
label: t("workspace.fileActions.download"),
|
||||
icon: Download,
|
||||
onSelect: onDownload,
|
||||
});
|
||||
}
|
||||
if (availableFile && onAddToChat) {
|
||||
next.push({
|
||||
key: "add-to-chat",
|
||||
label: t("workspace.fileActions.addToChat"),
|
||||
icon: MessageSquarePlus,
|
||||
onSelect: onAddToChat,
|
||||
testID: testIDPrefix ? `${testIDPrefix}-add-to-chat` : undefined,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}, [fileExists, fileKind, onAddToChat, onCopyPath, onDownload, onOpenFile, t, testIDPrefix]);
|
||||
|
||||
if (actions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={hitSlop}
|
||||
onPressIn={stopTriggerPropagation}
|
||||
style={triggerStyle}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testIDPrefix ? `${testIDPrefix}-actions` : undefined}
|
||||
>
|
||||
<ThemedMoreVertical size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
{header ? (
|
||||
<>
|
||||
{header}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
) : null}
|
||||
{actions.map((action) => (
|
||||
<FileActionMenuItem key={action.key} action={action} />
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function FileActionMenuItem({ action }: { action: FileAction }): ReactElement {
|
||||
const Icon = action.icon;
|
||||
const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]);
|
||||
const leading = useMemo(
|
||||
() => <ThemedIcon size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />,
|
||||
[ThemedIcon],
|
||||
);
|
||||
return (
|
||||
<DropdownMenuItem leading={leading} onSelect={action.onSelect} testID={action.testID}>
|
||||
{action.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: {
|
||||
// The hover box comes from padding, but an equal negative vertical margin
|
||||
// cancels its height contribution so the trigger overlaps the row's natural
|
||||
// line height instead of growing it. The comfortable tap target is `hitSlop`,
|
||||
// never padding.
|
||||
padding: theme.spacing[1],
|
||||
width: FILE_ACTIONS_MENU_WIDTH,
|
||||
marginVertical: -theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
triggerActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ImageAttachment } from "@/composer/types";
|
||||
import type { WorkspaceFileDragPayload } from "@/attachments/workspace-file-drag";
|
||||
|
||||
export interface DroppedFileItem {
|
||||
kind: "web-file";
|
||||
@@ -19,5 +18,4 @@ export type DroppedItem = DroppedFileItem | DroppedPathItem;
|
||||
export interface FileDropSink {
|
||||
onFiles: (images: ImageAttachment[]) => void;
|
||||
onGenericFiles?: (items: DroppedItem[]) => void;
|
||||
onWorkspaceFile?: (payload: WorkspaceFileDragPayload) => void;
|
||||
}
|
||||
|
||||
@@ -11,10 +11,6 @@ import {
|
||||
} from "@/attachments/file-types";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { DroppedItem, DroppedPathItem, FileDropSink } from "./types";
|
||||
import {
|
||||
parseWorkspaceFileDragPayload,
|
||||
WORKSPACE_FILE_DRAG_MIME,
|
||||
} from "@/attachments/workspace-file-drag";
|
||||
|
||||
type DesktopDragDropPayload =
|
||||
| { type: "enter"; paths: string[] }
|
||||
@@ -189,11 +185,7 @@ export function useDropListeners({
|
||||
if (disabledRef.current) return;
|
||||
|
||||
dragCounter.current++;
|
||||
if (suppressed.value || !hasSink.value) return;
|
||||
const types = new Set(e.dataTransfer?.types ?? []);
|
||||
const acceptsWorkspaceFile =
|
||||
types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile);
|
||||
if (types.has("Files") || acceptsWorkspaceFile) {
|
||||
if (e.dataTransfer?.types.includes("Files")) {
|
||||
isDragging.value = true;
|
||||
}
|
||||
}
|
||||
@@ -205,11 +197,7 @@ export function useDropListeners({
|
||||
if (!e.dataTransfer) return;
|
||||
// Only advertise "copy" when the drop would actually be accepted, so the cursor doesn't
|
||||
// promise a drop that the handler then discards (suppressed/archived/no consumer mounted).
|
||||
const types = new Set(e.dataTransfer.types);
|
||||
const acceptsWorkspaceFile =
|
||||
types.has(WORKSPACE_FILE_DRAG_MIME) && Boolean(getSink()?.onWorkspaceFile);
|
||||
const acceptsDrop = types.has("Files") || acceptsWorkspaceFile;
|
||||
const canAccept = acceptsDrop && !disabledRef.current && !suppressed.value && hasSink.value;
|
||||
const canAccept = !disabledRef.current && !suppressed.value && hasSink.value;
|
||||
e.dataTransfer.dropEffect = canAccept ? "copy" : "none";
|
||||
}
|
||||
|
||||
@@ -237,14 +225,6 @@ export function useDropListeners({
|
||||
const sink = getSink();
|
||||
if (!sink) return;
|
||||
|
||||
const serializedWorkspaceFile = e.dataTransfer?.getData(WORKSPACE_FILE_DRAG_MIME);
|
||||
if (serializedWorkspaceFile && sink.onWorkspaceFile) {
|
||||
const payload = parseWorkspaceFileDragPayload(serializedWorkspaceFile);
|
||||
if (payload) {
|
||||
sink.onWorkspaceFile(payload);
|
||||
}
|
||||
}
|
||||
|
||||
const files = Array.from(e.dataTransfer?.files ?? []);
|
||||
const genericItems: DroppedItem[] = files.map((file) => ({
|
||||
kind: "web-file",
|
||||
|
||||
@@ -15,26 +15,36 @@ import {
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native";
|
||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import {
|
||||
TreeChevron,
|
||||
TreeIndentGuides,
|
||||
treeRowPaddingLeft,
|
||||
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
} from "@/components/tree-primitives";
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
MoreVertical,
|
||||
RotateCw,
|
||||
} from "lucide-react-native";
|
||||
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||
import { TreeChevron, TreeIndentGuides, TREE_INDENT_PER_LEVEL } from "@/components/tree-primitives";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { AgentFileExplorerState, ExplorerEntry } from "@/stores/session-store";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { FileActionsMenu } from "@/components/file-actions-menu";
|
||||
import { useFileDownload } from "@/hooks/use-file-download";
|
||||
import { useDownloadStore } from "@/stores/download-store";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||
import { usePanelStore, type SortOption } from "@/stores/panel-store";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
|
||||
import { filterVisibleExplorerEntries, isHiddenExplorerPath } from "@/file-explorer/visibility";
|
||||
import { useWorkspaceFileDragSource } from "@/attachments/use-workspace-file-drag-source";
|
||||
|
||||
const SORT_OPTIONS: { value: SortOption }[] = [
|
||||
{ value: "name" },
|
||||
@@ -53,8 +63,6 @@ function formatFileSize({ size }: { size: number }): string {
|
||||
}
|
||||
|
||||
interface TreeRowItemProps {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
entry: ExplorerEntry;
|
||||
depth: number;
|
||||
isExpanded: boolean;
|
||||
@@ -63,8 +71,21 @@ interface TreeRowItemProps {
|
||||
onEntryPress: (entry: ExplorerEntry) => void;
|
||||
onCopyPath: (path: string) => void;
|
||||
onDownloadEntry: (entry: ExplorerEntry) => void;
|
||||
onAddToChat?: (path: string) => void;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
function stopPressInPropagation(event: { stopPropagation?: () => void }) {
|
||||
event.stopPropagation?.();
|
||||
}
|
||||
|
||||
function menuButtonStyle({
|
||||
hovered,
|
||||
pressed,
|
||||
open,
|
||||
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
|
||||
return [
|
||||
styles.menuButton,
|
||||
(Boolean(hovered) || pressed || Boolean(open)) && styles.menuButtonActive,
|
||||
];
|
||||
}
|
||||
|
||||
function sortTriggerStyle({
|
||||
@@ -83,8 +104,6 @@ function treeRowKeyExtractor(row: TreeRow) {
|
||||
}
|
||||
|
||||
function TreeRowItem({
|
||||
serverId,
|
||||
workspaceId,
|
||||
entry,
|
||||
depth,
|
||||
isExpanded,
|
||||
@@ -93,17 +112,10 @@ function TreeRowItem({
|
||||
onEntryPress,
|
||||
onCopyPath,
|
||||
onDownloadEntry,
|
||||
onAddToChat,
|
||||
testID,
|
||||
}: TreeRowItemProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const isDirectory = entry.kind === "directory";
|
||||
const dragSourceRef = useWorkspaceFileDragSource({
|
||||
enabled: !isDirectory,
|
||||
serverId,
|
||||
workspaceId,
|
||||
path: entry.path,
|
||||
});
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
onEntryPress(entry);
|
||||
@@ -112,10 +124,10 @@ function TreeRowItem({
|
||||
const pressableStyle = useCallback(
|
||||
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.entryRow,
|
||||
{ paddingLeft: treeRowPaddingLeft(depth) },
|
||||
{ paddingLeft: theme.spacing[2] + depth * TREE_INDENT_PER_LEVEL },
|
||||
(Boolean(hovered) || pressed || isSelected) && styles.entryRowActive,
|
||||
],
|
||||
[depth, isSelected],
|
||||
[depth, isSelected, theme.spacing],
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
@@ -126,42 +138,23 @@ function TreeRowItem({
|
||||
onDownloadEntry(entry);
|
||||
}, [onDownloadEntry, entry]);
|
||||
|
||||
const handleAddToChat = useCallback(() => {
|
||||
onAddToChat?.(entry.path);
|
||||
}, [onAddToChat, entry.path]);
|
||||
|
||||
const metaHeader = useMemo(
|
||||
() => (
|
||||
<View style={styles.contextMetaBlock}>
|
||||
<View style={styles.contextMetaRow}>
|
||||
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
||||
{t("workspace.fileExplorer.context.size")}
|
||||
</Text>
|
||||
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
||||
{formatFileSize({ size: entry.size })}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.contextMetaRow}>
|
||||
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
||||
{t("workspace.fileExplorer.context.modified")}
|
||||
</Text>
|
||||
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
||||
{formatTimeAgo(new Date(entry.modifiedAt))}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
),
|
||||
[entry.modifiedAt, entry.size, t],
|
||||
const copyLeading = useMemo(
|
||||
() => <Copy size={14} color={theme.colors.foregroundMuted} />,
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
const downloadLeading = useMemo(
|
||||
() => <Download size={14} color={theme.colors.foregroundMuted} />,
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable onPress={handlePress} style={pressableStyle} testID={testID}>
|
||||
<Pressable onPress={handlePress} style={pressableStyle}>
|
||||
<TreeIndentGuides depth={depth} />
|
||||
<View ref={dragSourceRef} style={styles.entryInfo}>
|
||||
<View style={styles.entryInfo}>
|
||||
<View style={styles.entryIcon}>
|
||||
{(() => {
|
||||
if (!isDirectory) {
|
||||
return <MaterialFileIcon fileName={entry.name} size={16} />;
|
||||
return <SvgXml xml={getFileIconSvg(entry.name)} width={16} height={16} />;
|
||||
}
|
||||
if (loading) return <ActivityIndicator size="small" />;
|
||||
return <TreeChevron expanded={isExpanded} />;
|
||||
@@ -171,15 +164,40 @@ function TreeRowItem({
|
||||
{entry.name}
|
||||
</Text>
|
||||
</View>
|
||||
<FileActionsMenu
|
||||
fileKind={entry.kind}
|
||||
onCopyPath={handleCopy}
|
||||
onDownload={handleDownload}
|
||||
onAddToChat={onAddToChat ? handleAddToChat : undefined}
|
||||
header={metaHeader}
|
||||
accessibilityLabel={t("workspace.fileActions.moreActions")}
|
||||
testIDPrefix={testID}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger hitSlop={8} onPressIn={stopPressInPropagation} style={menuButtonStyle}>
|
||||
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
<View style={styles.contextMetaBlock}>
|
||||
<View style={styles.contextMetaRow}>
|
||||
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
||||
{t("workspace.fileExplorer.context.size")}
|
||||
</Text>
|
||||
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
||||
{formatFileSize({ size: entry.size })}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.contextMetaRow}>
|
||||
<Text style={styles.contextMetaLabel} numberOfLines={1}>
|
||||
{t("workspace.fileExplorer.context.modified")}
|
||||
</Text>
|
||||
<Text style={styles.contextMetaValue} numberOfLines={1} ellipsizeMode="tail">
|
||||
{formatTimeAgo(new Date(entry.modifiedAt))}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem leading={copyLeading} onSelect={handleCopy}>
|
||||
{t("workspace.fileExplorer.context.copyPath")}
|
||||
</DropdownMenuItem>
|
||||
{entry.kind === "file" ? (
|
||||
<DropdownMenuItem leading={downloadLeading} onSelect={handleDownload}>
|
||||
{t("workspace.fileExplorer.context.download")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -189,7 +207,6 @@ interface FileExplorerPaneProps {
|
||||
workspaceId?: string | null;
|
||||
workspaceRoot: string;
|
||||
onOpenFile?: (filePath: string) => void;
|
||||
onAddToChat?: (path: string) => void;
|
||||
}
|
||||
|
||||
interface TreeRow {
|
||||
@@ -202,10 +219,14 @@ export function FileExplorerPane({
|
||||
workspaceId,
|
||||
workspaceRoot,
|
||||
onOpenFile,
|
||||
onAddToChat,
|
||||
}: FileExplorerPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const daemons = useHosts();
|
||||
const daemonProfile = useMemo(
|
||||
() => daemons.find((daemon) => daemon.serverId === serverId),
|
||||
[daemons, serverId],
|
||||
);
|
||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||
const workspaceStateKey = useMemo(
|
||||
() =>
|
||||
@@ -215,6 +236,10 @@ export function FileExplorerPane({
|
||||
}),
|
||||
[normalizedWorkspaceRoot, workspaceId],
|
||||
);
|
||||
const workspaceScopeId = useMemo(
|
||||
() => workspaceId?.trim() || normalizedWorkspaceRoot,
|
||||
[normalizedWorkspaceRoot, workspaceId],
|
||||
);
|
||||
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
|
||||
const explorerState = useSessionStore((state) =>
|
||||
workspaceStateKey && state.sessions[serverId]
|
||||
@@ -222,16 +247,12 @@ export function FileExplorerPane({
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const { requestDirectoryListing, selectExplorerEntry } = useFileExplorerActions({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
});
|
||||
const downloadFile = useFileDownload({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
});
|
||||
const { requestDirectoryListing, requestFileDownloadToken, selectExplorerEntry } =
|
||||
useFileExplorerActions({
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceRoot: normalizedWorkspaceRoot,
|
||||
});
|
||||
const sortOption = usePanelStore((state) => state.explorerSortOption);
|
||||
const showHiddenFiles = usePanelStore((state) => state.explorerShowHiddenFiles);
|
||||
const setSortOption = usePanelStore((state) => state.setExplorerSortOption);
|
||||
@@ -326,14 +347,18 @@ export function FileExplorerPane({
|
||||
[normalizedWorkspaceRoot],
|
||||
);
|
||||
|
||||
const startDownload = useDownloadStore((state) => state.startDownload);
|
||||
const handleDownloadEntry = useCallback(
|
||||
(entry: ExplorerEntry) => {
|
||||
if (entry.kind !== "file") {
|
||||
return;
|
||||
}
|
||||
downloadFile({ fileName: entry.name, path: entry.path });
|
||||
},
|
||||
[downloadFile],
|
||||
(entry: ExplorerEntry) =>
|
||||
downloadExplorerEntry({
|
||||
entry,
|
||||
workspaceScopeId,
|
||||
serverId,
|
||||
daemonProfile,
|
||||
startDownload,
|
||||
requestFileDownloadToken,
|
||||
}),
|
||||
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
|
||||
);
|
||||
|
||||
const handleSortCycle = useCallback(() => {
|
||||
@@ -395,8 +420,6 @@ export function FileExplorerPane({
|
||||
const renderTreeRow = useCallback(
|
||||
(info: ListRenderItemInfo<TreeRow>) => (
|
||||
<TreeRowDispatcher
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
info={info}
|
||||
expandedPaths={expandedPaths}
|
||||
selectedEntryPath={selectedEntryPath}
|
||||
@@ -404,7 +427,6 @@ export function FileExplorerPane({
|
||||
onEntryPress={handleEntryPress}
|
||||
onCopyPath={handleCopyPath}
|
||||
onDownloadEntry={handleDownloadEntry}
|
||||
onAddToChat={onAddToChat}
|
||||
/>
|
||||
),
|
||||
[
|
||||
@@ -414,9 +436,6 @@ export function FileExplorerPane({
|
||||
handleDownloadEntry,
|
||||
isDirectoryLoading,
|
||||
selectedEntryPath,
|
||||
onAddToChat,
|
||||
serverId,
|
||||
workspaceId,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -558,14 +577,8 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
return (
|
||||
<View style={[styles.treePane, styles.treePaneFill]}>
|
||||
<View style={styles.paneHeader} testID="files-pane-header">
|
||||
<Pressable
|
||||
onPress={handleSortCycle}
|
||||
style={sortTriggerStyleProp}
|
||||
testID="files-sort-trigger"
|
||||
>
|
||||
<Text style={styles.sortTriggerText} testID="files-sort-label">
|
||||
{currentSortLabel}
|
||||
</Text>
|
||||
<Pressable onPress={handleSortCycle} style={sortTriggerStyleProp}>
|
||||
<Text style={styles.sortTriggerText}>{currentSortLabel}</Text>
|
||||
<ChevronDown size={12} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<View style={styles.headerActions}>
|
||||
@@ -576,7 +589,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={hiddenFilesToggleAccessibilityLabel}
|
||||
accessibilityState={hiddenFilesToggleAccessibilityState}
|
||||
testID="files-hidden-toggle"
|
||||
>
|
||||
{showHiddenFiles ? (
|
||||
<Eye size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
@@ -595,7 +607,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
? t("workspace.fileExplorer.actions.refreshing")
|
||||
: t("workspace.fileExplorer.actions.refresh")
|
||||
}
|
||||
testID="files-refresh"
|
||||
>
|
||||
<View style={styles.refreshIcon}>
|
||||
{isRefreshFetching ? (
|
||||
@@ -768,6 +779,39 @@ function resolveTreeRows({
|
||||
});
|
||||
}
|
||||
|
||||
type StartDownloadFn = ReturnType<typeof useDownloadStore.getState>["startDownload"];
|
||||
type StartDownloadParams = Parameters<StartDownloadFn>[0];
|
||||
|
||||
function downloadExplorerEntry({
|
||||
entry,
|
||||
workspaceScopeId,
|
||||
serverId,
|
||||
daemonProfile,
|
||||
startDownload,
|
||||
requestFileDownloadToken,
|
||||
}: {
|
||||
entry: ExplorerEntry;
|
||||
workspaceScopeId: string | undefined;
|
||||
serverId: string;
|
||||
daemonProfile: StartDownloadParams["daemonProfile"];
|
||||
startDownload: StartDownloadFn;
|
||||
requestFileDownloadToken: (
|
||||
targetPath: string,
|
||||
) => ReturnType<StartDownloadParams["requestFileDownloadToken"]>;
|
||||
}): void {
|
||||
if (!workspaceScopeId || entry.kind !== "file") {
|
||||
return;
|
||||
}
|
||||
startDownload({
|
||||
serverId,
|
||||
scopeId: workspaceScopeId,
|
||||
fileName: entry.name,
|
||||
path: entry.path,
|
||||
daemonProfile,
|
||||
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDirectory({
|
||||
entry,
|
||||
workspaceStateKey,
|
||||
@@ -807,8 +851,6 @@ function toggleDirectory({
|
||||
}
|
||||
|
||||
function TreeRowDispatcher({
|
||||
serverId,
|
||||
workspaceId,
|
||||
info,
|
||||
expandedPaths,
|
||||
selectedEntryPath,
|
||||
@@ -816,10 +858,7 @@ function TreeRowDispatcher({
|
||||
onEntryPress,
|
||||
onCopyPath,
|
||||
onDownloadEntry,
|
||||
onAddToChat,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
info: ListRenderItemInfo<TreeRow>;
|
||||
expandedPaths: Set<string>;
|
||||
selectedEntryPath: string | null;
|
||||
@@ -827,7 +866,6 @@ function TreeRowDispatcher({
|
||||
onEntryPress: (entry: ExplorerEntry) => void;
|
||||
onCopyPath: (path: string) => void | Promise<void>;
|
||||
onDownloadEntry: (entry: ExplorerEntry) => void;
|
||||
onAddToChat?: (path: string) => void;
|
||||
}) {
|
||||
const entry = info.item.entry;
|
||||
const depth = info.item.depth;
|
||||
@@ -838,8 +876,6 @@ function TreeRowDispatcher({
|
||||
|
||||
return (
|
||||
<TreeRowItem
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
entry={entry}
|
||||
depth={depth}
|
||||
isExpanded={isExpanded}
|
||||
@@ -848,8 +884,6 @@ function TreeRowDispatcher({
|
||||
onEntryPress={onEntryPress}
|
||||
onCopyPath={onCopyPath}
|
||||
onDownloadEntry={onDownloadEntry}
|
||||
onAddToChat={onAddToChat}
|
||||
testID={`file-explorer-row-${info.index}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1028,6 +1062,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minHeight: 0,
|
||||
},
|
||||
entriesContent: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
@@ -1077,8 +1112,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingVertical: 2,
|
||||
paddingRight: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
entryRowActive: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
@@ -1098,6 +1134,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
menuButton: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
menuButtonActive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
contextMetaBlock: {
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { FileVersion } from "@getpaseo/protocol/messages";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image as RNImage,
|
||||
@@ -15,7 +8,7 @@ import {
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -36,14 +29,6 @@ import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useAppActivelyVisible } from "@/hooks/use-app-visible";
|
||||
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useLiveFile } from "./live-file";
|
||||
import { FilePanelBar } from "./bar";
|
||||
import { FileEditorModel, type FileEditorFile } from "./editor/model";
|
||||
import { FileEditorView } from "./editor/view";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { usePublishPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
|
||||
|
||||
interface CodeLineProps {
|
||||
tokens: HighlightToken[];
|
||||
@@ -57,12 +42,9 @@ interface FilePreviewBodyProps {
|
||||
isLoading: boolean;
|
||||
isMobile: boolean;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
imagePreviewUri: string | null;
|
||||
}
|
||||
|
||||
type TextExplorerFile = ExplorerFile & { kind: "text" };
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
@@ -208,10 +190,9 @@ function FilePreviewBody({
|
||||
isLoading,
|
||||
isMobile,
|
||||
location,
|
||||
navigationRevision,
|
||||
imagePreviewUri,
|
||||
}: FilePreviewBodyProps) {
|
||||
const theme = UnistylesRuntime.getTheme();
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const filePath = location.path;
|
||||
const isMarkdownFile =
|
||||
@@ -259,7 +240,7 @@ function FilePreviewBody({
|
||||
});
|
||||
}, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [lineHeight, lineSelection, navigationRevision]);
|
||||
}, [lineHeight, lineSelection]);
|
||||
|
||||
if (isLoading && !preview) {
|
||||
return (
|
||||
@@ -382,27 +363,15 @@ export function FilePane({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
location,
|
||||
navigationRevision,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceRoot: string;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const [markdownMode, setMarkdownMode] = useState<"preview" | "source">("preview");
|
||||
const [resolvedPreview, setResolvedPreview] = useState<{
|
||||
key: string | null;
|
||||
file: ExplorerFile | null;
|
||||
imageAttachment: AttachmentMetadata | null;
|
||||
}>({ key: null, file: null, imageAttachment: null });
|
||||
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
|
||||
const supportsEditing = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true,
|
||||
);
|
||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
|
||||
const readTarget = useMemo(
|
||||
@@ -421,359 +390,61 @@ export function FilePane({
|
||||
// from another window after an external edit. The gate lives in isFileQueryEnabled.
|
||||
const isActive = useRetainedPanelActive();
|
||||
const isAppVisible = useAppActivelyVisible();
|
||||
const enabled = isFileQueryEnabled({
|
||||
hasReadTarget: Boolean(client && readTarget),
|
||||
isTabActive: isActive,
|
||||
isAppVisible,
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["workspaceFile", serverId, readTarget?.cwd ?? null, readTarget?.path ?? null],
|
||||
enabled: isFileQueryEnabled({
|
||||
hasReadTarget: Boolean(client && readTarget),
|
||||
isTabActive: isActive,
|
||||
isAppVisible,
|
||||
}),
|
||||
queryFn: async () => {
|
||||
if (!client || !readTarget) {
|
||||
return {
|
||||
file: null as ExplorerFile | null,
|
||||
error: t("workspace.terminal.hostDisconnected"),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const file = await client.readFile(readTarget.cwd, readTarget.path);
|
||||
const preview = await createFilePanePreview(file);
|
||||
return {
|
||||
file: preview.file,
|
||||
imageAttachment: preview.imageAttachment,
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
file: null,
|
||||
imageAttachment: null,
|
||||
error: error instanceof Error ? error.message : t("panels.file.failedToLoad"),
|
||||
};
|
||||
}
|
||||
},
|
||||
staleTime: 5_000,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
const { query, version } = useLiveFile({
|
||||
client,
|
||||
serverId,
|
||||
cwd: readTarget?.cwd ?? null,
|
||||
path: readTarget?.path ?? null,
|
||||
enabled,
|
||||
liveUpdates: supportsEditing,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const key = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
|
||||
void (async () => {
|
||||
const nextPreview = await createFilePanePreview(query.data ?? null);
|
||||
if (active) setResolvedPreview({ key, ...nextPreview });
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [query.data, readTarget]);
|
||||
|
||||
useEffect(() => setMarkdownMode("preview"), [readTarget?.path]);
|
||||
|
||||
const previewKey = readTarget ? `${readTarget.cwd}:${readTarget.path}` : null;
|
||||
const preview = resolvedPreview.key === previewKey ? resolvedPreview.file : null;
|
||||
const imagePreviewUri = useAttachmentPreviewUrl(
|
||||
resolvedPreview.key === previewKey ? resolvedPreview.imageAttachment : null,
|
||||
);
|
||||
const isMarkdown = isMarkdownPreview(preview, location.path);
|
||||
const editable = isEditableTextFile({
|
||||
preview,
|
||||
supportsEditing,
|
||||
});
|
||||
const lineCount =
|
||||
preview?.kind === "text" ? (preview.content ?? "").split("\n").length : undefined;
|
||||
const errorMessage = getFileErrorMessage(query.error, t("panels.file.failedToLoad"));
|
||||
|
||||
return (
|
||||
<FilePanePresentation
|
||||
serverId={serverId}
|
||||
client={client}
|
||||
readTarget={readTarget}
|
||||
preview={preview}
|
||||
version={version}
|
||||
filename={getFileNameFromPath(location.path) ?? location.path}
|
||||
markdownMode={isMarkdown ? markdownMode : undefined}
|
||||
onMarkdownModeChange={isMarkdown ? setMarkdownMode : undefined}
|
||||
lineCount={lineCount}
|
||||
editable={editable}
|
||||
disconnectedMessage={t("workspace.terminal.hostDisconnected")}
|
||||
errorMessage={errorMessage}
|
||||
isLoading={query.isFetching}
|
||||
isMobile={isMobile}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
imagePreviewUri={imagePreviewUri}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function isMarkdownPreview(preview: ExplorerFile | null, path: string): boolean {
|
||||
return preview?.kind === "text" && isRenderedMarkdownFile(path);
|
||||
}
|
||||
|
||||
function getFileErrorMessage(error: unknown, fallback: string): string | null {
|
||||
if (!error) return null;
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
function isEditableTextFile(input: {
|
||||
preview: ExplorerFile | null;
|
||||
supportsEditing: boolean;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
isWeb &&
|
||||
input.supportsEditing &&
|
||||
input.preview?.kind === "text" &&
|
||||
input.preview.size <= 1024 * 1024,
|
||||
);
|
||||
}
|
||||
|
||||
function FilePanePresentation({
|
||||
serverId,
|
||||
client,
|
||||
readTarget,
|
||||
preview,
|
||||
version,
|
||||
filename,
|
||||
markdownMode,
|
||||
onMarkdownModeChange,
|
||||
lineCount,
|
||||
editable,
|
||||
disconnectedMessage,
|
||||
errorMessage,
|
||||
isLoading,
|
||||
isMobile,
|
||||
location,
|
||||
navigationRevision,
|
||||
imagePreviewUri,
|
||||
}: {
|
||||
serverId: string;
|
||||
client: DaemonClient | null;
|
||||
readTarget: { cwd: string; path: string } | null;
|
||||
preview: ExplorerFile | null;
|
||||
version: FileVersion | null;
|
||||
filename: string;
|
||||
markdownMode?: "preview" | "source";
|
||||
onMarkdownModeChange?: (mode: "preview" | "source") => void;
|
||||
lineCount?: number;
|
||||
editable: boolean;
|
||||
disconnectedMessage: string;
|
||||
errorMessage: string | null;
|
||||
isLoading: boolean;
|
||||
isMobile: boolean;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
imagePreviewUri: string | null;
|
||||
}) {
|
||||
if (!client && readTarget) {
|
||||
return (
|
||||
<View style={styles.container} testID="workspace-file-pane">
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>{disconnectedMessage}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (editable && client && readTarget && preview?.kind === "text") {
|
||||
return (
|
||||
<EditableFilePane
|
||||
key={`${serverId}:${readTarget.cwd}:${readTarget.path}`}
|
||||
client={client}
|
||||
cwd={readTarget.cwd}
|
||||
path={readTarget.path}
|
||||
preview={preview as TextExplorerFile}
|
||||
version={version}
|
||||
filename={filename}
|
||||
mode={markdownMode}
|
||||
onModeChange={onMarkdownModeChange}
|
||||
isLoading={isLoading}
|
||||
isMobile={isMobile}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const imagePreviewUri = useAttachmentPreviewUrl(query.data?.imageAttachment ?? null);
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="workspace-file-pane">
|
||||
{preview ? (
|
||||
<FilePanelBar
|
||||
size={preview.size}
|
||||
lineCount={lineCount}
|
||||
mode={markdownMode}
|
||||
onModeChange={onMarkdownModeChange}
|
||||
/>
|
||||
) : null}
|
||||
{errorMessage ? (
|
||||
{query.data?.error ? (
|
||||
<View style={styles.centerState}>
|
||||
<Text style={styles.errorText}>{errorMessage}</Text>
|
||||
<Text style={styles.errorText}>{query.data.error}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<FilePreviewBody
|
||||
preview={preview}
|
||||
isLoading={isLoading}
|
||||
preview={query.data?.file ?? null}
|
||||
isLoading={query.isFetching}
|
||||
isMobile={isMobile}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
imagePreviewUri={imagePreviewUri}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableFilePane({
|
||||
client,
|
||||
cwd,
|
||||
path,
|
||||
preview,
|
||||
version,
|
||||
filename,
|
||||
mode,
|
||||
onModeChange,
|
||||
isLoading,
|
||||
isMobile,
|
||||
location,
|
||||
navigationRevision,
|
||||
}: {
|
||||
client: DaemonClient;
|
||||
cwd: string;
|
||||
path: string;
|
||||
preview: TextExplorerFile;
|
||||
version: FileVersion | null;
|
||||
filename: string;
|
||||
mode?: "preview" | "source";
|
||||
onModeChange?: (mode: "preview" | "source") => void;
|
||||
isLoading: boolean;
|
||||
isMobile: boolean;
|
||||
location: WorkspaceFileLocation;
|
||||
navigationRevision: number;
|
||||
}) {
|
||||
const { settings } = useAppSettings();
|
||||
const { t } = useTranslation();
|
||||
const [cursor, setCursor] = useState({ line: 1, column: 1 });
|
||||
const [vimMode, setVimMode] = useState<string | null>(settings.vimKeybindings ? "NORMAL" : null);
|
||||
const session = useMemo(
|
||||
() => ({
|
||||
async read(): Promise<FileEditorFile> {
|
||||
const file = await client.readFile(cwd, path);
|
||||
if (file.kind !== "text") throw new Error("File is no longer text.");
|
||||
return {
|
||||
content: new TextDecoder().decode(file.bytes),
|
||||
version: {
|
||||
status: "ready",
|
||||
cwd,
|
||||
path,
|
||||
size: file.size,
|
||||
modifiedAt: file.modifiedAt,
|
||||
revision: file.revision,
|
||||
},
|
||||
};
|
||||
},
|
||||
write(input: { content: string; expectedModifiedAt: string; expectedRevision?: string }) {
|
||||
return client.writeFile({ cwd, path, ...input });
|
||||
},
|
||||
}),
|
||||
[client, cwd, path],
|
||||
);
|
||||
const [model] = useState(
|
||||
() =>
|
||||
new FileEditorModel({
|
||||
file: {
|
||||
content: preview.content ?? "",
|
||||
version: {
|
||||
status: "ready",
|
||||
cwd,
|
||||
path,
|
||||
size: preview.size,
|
||||
modifiedAt: preview.modifiedAt,
|
||||
},
|
||||
},
|
||||
session,
|
||||
}),
|
||||
);
|
||||
const snapshot = useSyncExternalStore(model.subscribe, model.getSnapshot, model.getSnapshot);
|
||||
const suspendPendingSave = useCallback(() => model.suspendAutosave(), [model]);
|
||||
usePublishPanelInstanceAttributes({ modified: snapshot.modified, suspendPendingSave });
|
||||
const theme = UnistylesRuntime.getTheme();
|
||||
const visualTheme = useMemo(
|
||||
() => ({
|
||||
colorScheme: theme.colorScheme,
|
||||
background: theme.colors.surface0,
|
||||
foreground: theme.colors.foreground,
|
||||
cursor: theme.colors.terminal.cursor,
|
||||
foregroundMuted: theme.colors.foregroundMuted,
|
||||
border: theme.colors.border,
|
||||
selection: theme.colors.terminal.selectionBackground,
|
||||
monoFont: theme.fontFamily.mono,
|
||||
codeFontSize: theme.fontSize.code,
|
||||
syntax: theme.colors.syntax,
|
||||
}),
|
||||
[
|
||||
theme.colors.border,
|
||||
theme.colors.foreground,
|
||||
theme.colors.foregroundMuted,
|
||||
theme.colors.surface0,
|
||||
theme.colors.syntax,
|
||||
theme.colors.terminal.cursor,
|
||||
theme.colors.terminal.selectionBackground,
|
||||
theme.colorScheme,
|
||||
theme.fontFamily.mono,
|
||||
theme.fontSize.code,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => () => model.dispose(), [model]);
|
||||
useEffect(() => {
|
||||
if (version) model.receiveFileVersion(version);
|
||||
}, [model, version]);
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
void (async () => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("panels.file.editor.reloadTitle"),
|
||||
message: t("panels.file.editor.reloadMessage"),
|
||||
confirmLabel: t("panels.file.editor.reload"),
|
||||
destructive: true,
|
||||
});
|
||||
if (confirmed) void model.reload();
|
||||
})();
|
||||
}, [model, t]);
|
||||
const handleOverwrite = useCallback(() => void model.overwrite(), [model]);
|
||||
const handleVimModeChange = useCallback((nextMode: string | null) => setVimMode(nextMode), []);
|
||||
const renderedPreview = useMemo<ExplorerFile>(
|
||||
() => ({
|
||||
...preview,
|
||||
content: snapshot.content,
|
||||
size: snapshot.version.status === "ready" ? snapshot.version.size : preview.size,
|
||||
modifiedAt:
|
||||
snapshot.version.status === "ready" ? snapshot.version.modifiedAt : preview.modifiedAt,
|
||||
}),
|
||||
[preview, snapshot.content, snapshot.version],
|
||||
);
|
||||
const showSource = mode !== "preview";
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="workspace-file-pane">
|
||||
<FilePanelBar
|
||||
size={
|
||||
snapshot.observedVersion.status === "ready" ? snapshot.observedVersion.size : preview.size
|
||||
}
|
||||
lineCount={snapshot.content.split("\n").length}
|
||||
editorStatus={snapshot.status}
|
||||
cursor={showSource ? cursor : undefined}
|
||||
vimMode={showSource ? vimMode : null}
|
||||
conflictUnavailable={snapshot.observedVersion.status !== "ready"}
|
||||
onOverwrite={handleOverwrite}
|
||||
onReload={handleReload}
|
||||
mode={mode}
|
||||
onModeChange={onModeChange}
|
||||
/>
|
||||
{showSource ? (
|
||||
<FileEditorView
|
||||
model={model}
|
||||
filename={filename}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
vimEnabled={settings.vimKeybindings}
|
||||
theme={visualTheme}
|
||||
onCursorChange={setCursor}
|
||||
onVimModeChange={handleVimModeChange}
|
||||
/>
|
||||
) : (
|
||||
<FilePreviewBody
|
||||
preview={renderedPreview}
|
||||
isLoading={isLoading}
|
||||
isMobile={isMobile}
|
||||
location={location}
|
||||
navigationRevision={navigationRevision}
|
||||
imagePreviewUri={null}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
@@ -341,64 +341,6 @@ function FooterIconButton({
|
||||
);
|
||||
}
|
||||
|
||||
function footerAddProjectButtonStyle({
|
||||
hovered,
|
||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.footerAddProjectButton, Boolean(hovered) && styles.footerAddProjectButtonHovered];
|
||||
}
|
||||
|
||||
function FooterAddProjectButton({
|
||||
onPress,
|
||||
label,
|
||||
shortcutKeys,
|
||||
theme,
|
||||
}: {
|
||||
onPress: () => void;
|
||||
label: string;
|
||||
shortcutKeys: ReturnType<typeof useShortcutKeys>;
|
||||
theme: SidebarTheme;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
style={footerAddProjectButtonStyle}
|
||||
testID="sidebar-add-project"
|
||||
nativeID="sidebar-add-project"
|
||||
accessible
|
||||
accessibilityLabel={label}
|
||||
accessibilityRole="button"
|
||||
onPress={onPress}
|
||||
>
|
||||
{({ hovered }) => {
|
||||
const isHovered = Boolean(hovered);
|
||||
return (
|
||||
<>
|
||||
<FolderPlus
|
||||
size={theme.iconSize.sm}
|
||||
color={isHovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
styles.footerAddProjectLabel,
|
||||
isHovered && styles.footerAddProjectLabelHovered,
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<IconTooltipContent label={label} shortcutKeys={shortcutKeys} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHostPicker({
|
||||
theme,
|
||||
label,
|
||||
@@ -552,12 +494,6 @@ function SidebarFooter({
|
||||
|
||||
return (
|
||||
<View style={styles.sidebarFooter}>
|
||||
<FooterAddProjectButton
|
||||
onPress={handleOpenProject}
|
||||
label={labels.addProject}
|
||||
shortcutKeys={newAgentKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
<View style={styles.footerIconRow}>
|
||||
<SidebarHostPicker
|
||||
theme={theme}
|
||||
@@ -565,6 +501,14 @@ function SidebarFooter({
|
||||
onAddHost={handleAddHost}
|
||||
onOpenHostSettings={handleOpenHostSettings}
|
||||
/>
|
||||
<FooterIconButton
|
||||
onPress={handleOpenProject}
|
||||
testID="sidebar-add-project"
|
||||
label={labels.addProject}
|
||||
icon={FolderPlus}
|
||||
shortcutKeys={newAgentKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
<FooterIconButton
|
||||
onPress={handleHome}
|
||||
testID="sidebar-home"
|
||||
@@ -980,6 +924,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
sidebarHeaderGroup: {
|
||||
paddingTop: theme.spacing[2],
|
||||
gap: 2,
|
||||
// Distance from History's bottom edge to the divider. WorkspacesSectionHeader
|
||||
// uses a slightly smaller paddingTop to balance the action buttons' centering
|
||||
// offset so the divider reads as visually centered between the two.
|
||||
paddingBottom: theme.spacing[1.5],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
@@ -1061,8 +1008,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
sidebarFooter: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
justifyContent: "flex-start",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[3],
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.colors.border,
|
||||
@@ -1073,30 +1020,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
flexShrink: 0,
|
||||
},
|
||||
footerAddProjectButton: {
|
||||
minWidth: 0,
|
||||
minHeight: 32,
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
footerAddProjectButtonHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
footerAddProjectLabel: {
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
footerAddProjectLabelHovered: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
footerIconButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
|
||||
@@ -569,26 +569,8 @@ export function createSharedMarkdownRules(): RenderRules {
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
hardbreak: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownTextSpan key={node.key} style={styles.hardbreak}>
|
||||
{"\n"}
|
||||
</MarkdownTextSpan>
|
||||
),
|
||||
softbreak: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownTextSpan key={node.key} style={styles.softbreak}>
|
||||
{"\n"}
|
||||
</MarkdownTextSpan>
|
||||
),
|
||||
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
code_block: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||
import type { PanelIconProps } from "@/panels/panel-registry";
|
||||
|
||||
export function MaterialFileIcon({ fileName, size }: { fileName: string; size: number }) {
|
||||
return <SvgXml xml={getFileIconSvg(fileName)} width={size} height={size} />;
|
||||
}
|
||||
|
||||
export function createMaterialFileIcon(fileName: string): ComponentType<PanelIconProps> {
|
||||
function BoundMaterialFileIcon({ size }: PanelIconProps) {
|
||||
return <MaterialFileIcon fileName={fileName} size={size} />;
|
||||
}
|
||||
return BoundMaterialFileIcon;
|
||||
}
|
||||
@@ -1689,29 +1689,10 @@ export const AssistantMessage = memo(function AssistantMessage({
|
||||
// plain <Text> is not hoisted into a UITextViewChild and is dropped (same
|
||||
// root cause as strong/em/s) — so on iOS a hard line break vanished, and
|
||||
// a softbreak between words jammed them together ("one\ntwo" -> "onetwo").
|
||||
// Emit the break through MarkdownTextSpan so it composes on iOS. Keep
|
||||
// the resolved break styles: hardbreak is a full-width flex-row child on
|
||||
// Android, and dropping that width joins the surrounding text spans.
|
||||
hardbreak: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownTextSpan key={node.key} style={styles.hardbreak}>
|
||||
{"\n"}
|
||||
</MarkdownTextSpan>
|
||||
),
|
||||
softbreak: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownTextSpan key={node.key} style={styles.softbreak}>
|
||||
{"\n"}
|
||||
</MarkdownTextSpan>
|
||||
),
|
||||
// Emit the break through MarkdownTextSpan so it composes on iOS; web and
|
||||
// Android keep the same "\n" they rendered before.
|
||||
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
code_block: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
|
||||
@@ -94,8 +94,6 @@ import type { StatusGroup } from "@/hooks/sidebar-status-view-model";
|
||||
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
||||
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
||||
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||
import {
|
||||
SidebarWorkspaceRowFrame,
|
||||
SidebarWorkspaceRowContent,
|
||||
@@ -1634,12 +1632,6 @@ function ProjectBlock({
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
}) {
|
||||
const {
|
||||
visibleItems: visibleWorkspaces,
|
||||
expanded: workspacesExpanded,
|
||||
canToggle: canToggleWorkspaces,
|
||||
toggleExpanded: toggleWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup(project.workspaces);
|
||||
const rowModel = useMemo(
|
||||
() =>
|
||||
buildSidebarProjectRowModel({
|
||||
@@ -1792,28 +1784,19 @@ function ProjectBlock({
|
||||
if (!collapsed) {
|
||||
if (project.workspaces.length > 0) {
|
||||
projectChildren = (
|
||||
<>
|
||||
<DraggableList
|
||||
testID={`sidebar-workspace-list-${project.projectKey}`}
|
||||
data={visibleWorkspaces}
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
{canToggleWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={workspacesExpanded}
|
||||
onPress={toggleWorkspacesExpanded}
|
||||
testID={`sidebar-project-show-more-${project.projectKey}`}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
<DraggableList
|
||||
testID={`sidebar-workspace-list-${project.projectKey}`}
|
||||
data={project.workspaces}
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
);
|
||||
} else if (rowModel.trailingAction.kind === "new_workspace") {
|
||||
projectChildren = (
|
||||
@@ -2090,12 +2073,6 @@ function ProjectModeList({
|
||||
const selectionEnabled = isWorkspaceRoute;
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const { pinnedChats, unpinnedProjects } = pinnedGroups;
|
||||
const {
|
||||
visibleItems: visiblePinnedChats,
|
||||
expanded: pinnedChatsExpanded,
|
||||
canToggle: canTogglePinnedChats,
|
||||
toggleExpanded: togglePinnedChatsExpanded,
|
||||
} = useLimitedSidebarGroup(pinnedChats);
|
||||
const projectIconTargets = useMemo(
|
||||
() =>
|
||||
projects.flatMap((project) => {
|
||||
@@ -2326,6 +2303,7 @@ function ProjectModeList({
|
||||
canCopyBranchName={workspace.projectKind === "git"}
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
reserveIdleStatusIndicatorSpace={false}
|
||||
isCreating={creatingWorkspaceIds.has(workspace.workspaceId)}
|
||||
selectionEnabled={selectionEnabled}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
@@ -2353,18 +2331,7 @@ function ProjectModeList({
|
||||
{pinnedChats.length > 0 ? (
|
||||
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
|
||||
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
||||
{pinnedCollapsed ? null : (
|
||||
<>
|
||||
{visiblePinnedChats.map(renderPinnedChat)}
|
||||
{canTogglePinnedChats ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={pinnedChatsExpanded}
|
||||
onPress={togglePinnedChatsExpanded}
|
||||
testID="sidebar-pinned-show-more"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{pinnedCollapsed ? null : pinnedChats.map(renderPinnedChat)}
|
||||
</View>
|
||||
) : null}
|
||||
{unpinnedProjects.length > 0 || hasActiveHostFilter ? listHeaderComponent : null}
|
||||
@@ -2432,9 +2399,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
// Optical inset: aligns the visible Pinned/Workspaces glyph edge with the
|
||||
// Schedules icon across the divider; their layout boxes have different insets.
|
||||
paddingTop: 2,
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
projectListContainer: {
|
||||
|
||||
@@ -47,7 +47,6 @@ export function PinnedSectionHeader({
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
header: {
|
||||
minHeight: 36,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignSelf: "flex-start",
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
const foregroundColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
});
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedChevronUp = withUnistyles(ChevronUp);
|
||||
|
||||
export function SidebarGroupToggleRow({
|
||||
expanded,
|
||||
onPress,
|
||||
testID,
|
||||
}: {
|
||||
expanded: boolean;
|
||||
onPress: () => void;
|
||||
testID: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const label = t(
|
||||
expanded ? "sidebar.workspace.actions.showLess" : "sidebar.workspace.actions.showMore",
|
||||
);
|
||||
const rowStyle = useCallback(
|
||||
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
styles.row,
|
||||
(Boolean(hovered) || pressed) && styles.rowHovered,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole={isWeb ? undefined : "button"}
|
||||
accessibilityLabel={label}
|
||||
onPress={onPress}
|
||||
style={rowStyle}
|
||||
testID={testID}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<>
|
||||
<View style={styles.iconSlot}>
|
||||
{expanded ? (
|
||||
<ThemedChevronUp
|
||||
size={14}
|
||||
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
) : (
|
||||
<ThemedChevronDown
|
||||
size={14}
|
||||
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<Text style={hovered || pressed ? styles.textHovered : styles.text} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
row: {
|
||||
minHeight: 32,
|
||||
marginLeft: theme.spacing[6],
|
||||
marginRight: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
userSelect: "none",
|
||||
},
|
||||
rowHovered: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
iconSlot: {
|
||||
width: theme.iconSize.sm,
|
||||
height: theme.iconSize.sm,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
text: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
textHovered: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
}));
|
||||
@@ -42,8 +42,6 @@ import {
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { SidebarWorkspaceMenu } from "@/components/sidebar/sidebar-workspace-menu";
|
||||
import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header";
|
||||
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
|
||||
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
|
||||
import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin";
|
||||
|
||||
// Themed icon wrappers
|
||||
@@ -95,12 +93,6 @@ export function SidebarStatusWorkspaceList({
|
||||
const togglePinnedCollapsed = useSidebarCollapsedSectionsStore(
|
||||
(state) => state.togglePinnedCollapsed,
|
||||
);
|
||||
const {
|
||||
visibleItems: visiblePinnedWorkspaces,
|
||||
expanded: pinnedWorkspacesExpanded,
|
||||
canToggle: canTogglePinnedWorkspaces,
|
||||
toggleExpanded: togglePinnedWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup(pinnedWorkspaces);
|
||||
|
||||
const statusShortcutIndex = showShortcutBadges ? shortcutIndexByWorkspaceKey : new Map();
|
||||
const content = (
|
||||
@@ -108,9 +100,9 @@ export function SidebarStatusWorkspaceList({
|
||||
{pinnedWorkspaces.length > 0 ? (
|
||||
<View style={styles.pinnedSection} testID="sidebar-pinned-section">
|
||||
<PinnedSectionHeader collapsed={pinnedCollapsed} onToggle={togglePinnedCollapsed} />
|
||||
{pinnedCollapsed ? null : (
|
||||
<>
|
||||
{visiblePinnedWorkspaces.map((workspace) => (
|
||||
{pinnedCollapsed
|
||||
? null
|
||||
: pinnedWorkspaces.map((workspace) => (
|
||||
<StatusWorkspaceRow
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
@@ -124,18 +116,10 @@ export function SidebarStatusWorkspaceList({
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
reserveIdleStatusIndicatorSpace={false}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
/>
|
||||
))}
|
||||
{canTogglePinnedWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={pinnedWorkspacesExpanded}
|
||||
onPress={togglePinnedWorkspacesExpanded}
|
||||
testID="sidebar-pinned-show-more"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
{listHeaderComponent}
|
||||
@@ -205,89 +189,35 @@ function StatusGroupList({
|
||||
return (
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
<StatusGroupRows
|
||||
key={group.bucket}
|
||||
group={group}
|
||||
collapsed={collapsedStatusGroupKeys.has(group.bucket)}
|
||||
projectNamesByKey={projectNamesByKey}
|
||||
shortcutIndex={shortcutIndex}
|
||||
showShortcutBadges={showShortcutBadges}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
hostLabelByServerId={hostLabelByServerId}
|
||||
showHostLabels={showHostLabels}
|
||||
supportsPinningByServerId={supportsPinningByServerId}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusGroupRows({
|
||||
group,
|
||||
collapsed,
|
||||
projectNamesByKey,
|
||||
shortcutIndex,
|
||||
showShortcutBadges,
|
||||
onWorkspacePress,
|
||||
hostLabelByServerId,
|
||||
showHostLabels,
|
||||
supportsPinningByServerId,
|
||||
onToggleWorkspacePin,
|
||||
}: {
|
||||
group: StatusGroup;
|
||||
collapsed: boolean;
|
||||
projectNamesByKey: Map<string, string>;
|
||||
shortcutIndex: Map<string, number>;
|
||||
showShortcutBadges: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
hostLabelByServerId: ReadonlyMap<string, string>;
|
||||
showHostLabels: boolean;
|
||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
}) {
|
||||
const {
|
||||
visibleItems: visibleWorkspaces,
|
||||
expanded: workspacesExpanded,
|
||||
canToggle: canToggleWorkspaces,
|
||||
toggleExpanded: toggleWorkspacesExpanded,
|
||||
} = useLimitedSidebarGroup(group.rows);
|
||||
|
||||
return (
|
||||
<View style={styles.statusGroupBlock}>
|
||||
<StatusGroupHeader group={group} collapsed={collapsed} />
|
||||
{!collapsed ? (
|
||||
<View
|
||||
style={styles.statusWorkspaceListContainer}
|
||||
testID={`sidebar-status-group-rows-${group.bucket}`}
|
||||
>
|
||||
{visibleWorkspaces.map((workspace) => (
|
||||
<StatusWorkspaceRow
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
subtitle={buildStatusRowSubtitle({
|
||||
projectName: projectNamesByKey.get(workspace.projectKey) ?? "",
|
||||
hostLabel: showHostLabels
|
||||
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
|
||||
: null,
|
||||
})}
|
||||
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
/>
|
||||
))}
|
||||
{canToggleWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
expanded={workspacesExpanded}
|
||||
onPress={toggleWorkspacesExpanded}
|
||||
testID={`sidebar-status-show-more-${group.bucket}`}
|
||||
/>
|
||||
<View key={group.bucket} style={styles.statusGroupBlock}>
|
||||
<StatusGroupHeader group={group} collapsed={collapsedStatusGroupKeys.has(group.bucket)} />
|
||||
{!collapsedStatusGroupKeys.has(group.bucket) ? (
|
||||
<View
|
||||
style={styles.statusWorkspaceListContainer}
|
||||
testID={`sidebar-status-group-rows-${group.bucket}`}
|
||||
>
|
||||
{group.rows.map((workspace) => (
|
||||
<StatusWorkspaceRow
|
||||
key={workspace.workspaceKey}
|
||||
workspace={workspace}
|
||||
subtitle={buildStatusRowSubtitle({
|
||||
projectName: projectNamesByKey.get(workspace.projectKey) ?? "",
|
||||
hostLabel: showHostLabels
|
||||
? (hostLabelByServerId.get(workspace.serverId) ?? workspace.serverId)
|
||||
: null,
|
||||
})}
|
||||
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -793,8 +723,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
// Keep status mode's Pinned/Workspaces boundary identical to project mode.
|
||||
paddingTop: 2,
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
pinnedSection: {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
const INITIAL_VISIBLE_ITEMS = 20;
|
||||
|
||||
export function useLimitedSidebarGroup<T>(items: readonly T[]) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const visibleItems = useMemo(
|
||||
() => (expanded ? items.slice() : items.slice(0, INITIAL_VISIBLE_ITEMS)),
|
||||
[expanded, items],
|
||||
);
|
||||
const canToggle = items.length > INITIAL_VISIBLE_ITEMS;
|
||||
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
|
||||
|
||||
return { visibleItems, expanded, canToggle, toggleExpanded };
|
||||
}
|
||||
@@ -55,7 +55,6 @@ import {
|
||||
getWorkspacePaneDescriptors,
|
||||
} from "@/screens/workspace/workspace-pane-state";
|
||||
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
||||
import { useModifiedPanelTabIds } from "@/panels/panel-instance-attributes";
|
||||
import {
|
||||
WorkspacePaneContent,
|
||||
type WorkspacePaneContentModel,
|
||||
@@ -76,7 +75,7 @@ import {
|
||||
type SplitPane,
|
||||
type WorkspaceLayout,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import type { WorkspaceTab } from "@/workspace-tabs/model";
|
||||
import type { WorkspaceTab } from "@/stores/workspace-tabs-store";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
||||
import { isNative } from "@/constants/platform";
|
||||
@@ -935,17 +934,11 @@ function SplitPaneView({
|
||||
);
|
||||
const paneTabs = useMemo(() => paneState.tabs.map((tab) => tab.descriptor), [paneState.tabs]);
|
||||
const paneTabIds = useMemo(() => paneTabs.map((tab) => tab.tabId), [paneTabs]);
|
||||
const modifiedPaneTabIds = useModifiedPanelTabIds({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
tabIds: paneTabIds,
|
||||
});
|
||||
const tabDescriptorMap = useStableTabDescriptorMap(paneTabs);
|
||||
const activeTabDescriptor = paneState.activeTab?.descriptor ?? null;
|
||||
const { mountedTabIds } = useMountedTabSet({
|
||||
activeTabId: activeTabDescriptor?.tabId ?? null,
|
||||
allTabIds: paneTabIds,
|
||||
retainedTabIds: modifiedPaneTabIds,
|
||||
cap: 3,
|
||||
});
|
||||
const mountedPaneTabIds = useMemo(
|
||||
|
||||
@@ -11,7 +11,6 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
// indentation, guide lines, and chevron. Keep those here so the two trees can't
|
||||
// drift apart.
|
||||
export const TREE_INDENT_PER_LEVEL = 16;
|
||||
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
|
||||
|
||||
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
|
||||
* in the Changes tree so their indentation can't drift apart. */
|
||||
|
||||
@@ -10,7 +10,6 @@ const theme = {
|
||||
md: 6,
|
||||
lg: 8,
|
||||
xl: 12,
|
||||
full: 9999,
|
||||
},
|
||||
borderWidth: {
|
||||
1: 1,
|
||||
@@ -103,41 +102,12 @@ describe("control geometry", () => {
|
||||
expect(geometry.formTextInputMd.paddingVertical).toBe(11);
|
||||
});
|
||||
|
||||
it("keeps segmented controls ghost with fully rounded segments in a button-sized track", () => {
|
||||
it("subtracts segmented control inset from the nested segment radius", () => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
|
||||
expect(geometry.segmentedContainerXs.padding).toBe(0);
|
||||
expect(geometry.segmentedContainerSm.padding).toBe(0);
|
||||
expect(geometry.segmentedContainerMd.padding).toBe(0);
|
||||
expect(geometry.segmentedSegmentXs.borderRadius).toBe(9999);
|
||||
expect(geometry.segmentedSegmentSm.borderRadius).toBe(9999);
|
||||
expect(geometry.segmentedSegmentMd.borderRadius).toBe(9999);
|
||||
expect(geometry.segmentedContainerXs.minHeight).toBe(geometry.buttonXs.minHeight);
|
||||
expect(geometry.segmentedContainerSm.minHeight).toBe(geometry.buttonSm.minHeight);
|
||||
expect(geometry.segmentedContainerMd.minHeight).toBe(geometry.buttonMd.minHeight);
|
||||
expect(geometry.segmentedSegmentXs.minHeight).toBe(24);
|
||||
expect(geometry.segmentedSegmentSm.minHeight).toBe(28);
|
||||
expect(geometry.segmentedSegmentMd.minHeight).toBe(38);
|
||||
});
|
||||
|
||||
it("keeps one size contract across buttons and segmented controls", () => {
|
||||
const geometry = createControlGeometry(theme);
|
||||
|
||||
// xs is a genuinely smaller tier, not sm with a different font.
|
||||
expect(geometry.buttonXs.minHeight).toBe(28);
|
||||
expect(geometry.buttonSm.minHeight).toBe(32);
|
||||
expect(geometry.buttonMd.minHeight).toBe(44);
|
||||
|
||||
// Same size name means the same label size on every control kind.
|
||||
expect(geometry.segmentedLabelXs.fontSize).toBe(12);
|
||||
expect(geometry.segmentedLabelXs.fontSize).toBe(geometry.buttonTextXs.fontSize);
|
||||
expect(geometry.segmentedLabelSm.fontSize).toBe(14);
|
||||
expect(geometry.segmentedLabelSm.fontSize).toBe(geometry.buttonText.fontSize);
|
||||
expect(geometry.segmentedLabelMd.fontSize).toBe(geometry.buttonText.fontSize);
|
||||
|
||||
// Same size name means the same horizontal padding on every control kind.
|
||||
expect(geometry.segmentedSegmentXs.paddingHorizontal).toBe(geometry.buttonXs.paddingHorizontal);
|
||||
expect(geometry.segmentedSegmentSm.paddingHorizontal).toBe(geometry.buttonSm.paddingHorizontal);
|
||||
expect(geometry.segmentedSegmentMd.paddingHorizontal).toBe(geometry.buttonMd.paddingHorizontal);
|
||||
expect(geometry.segmentedContainerSm.borderRadius).toBe(6);
|
||||
expect(geometry.segmentedSegmentSm.borderRadius).toBe(4);
|
||||
expect(geometry.segmentedContainerMd.borderRadius).toBe(8);
|
||||
expect(geometry.segmentedSegmentMd.borderRadius).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
|
||||
export type ButtonControlSize = "xs" | "sm" | "md" | "lg";
|
||||
export type FieldControlSize = "sm" | "md";
|
||||
export type SegmentedControlSize = "xs" | "sm" | "md";
|
||||
export type SegmentedControlSize = "sm" | "md";
|
||||
export type ControlInteractionPhase = "rest" | "hover" | "active";
|
||||
|
||||
export interface ControlInteractionState {
|
||||
@@ -22,10 +22,8 @@ export interface ControlInteractionStyleMap {
|
||||
controlDisabled?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
const TIGHT_CONTROL_HEIGHT = 28;
|
||||
const COMPACT_CONTROL_HEIGHT = 32;
|
||||
const FIELD_CONTROL_HEIGHT = 44;
|
||||
const SEGMENTED_TIGHT_INSET = 2;
|
||||
const SEGMENTED_COMPACT_INSET = 2;
|
||||
const SEGMENTED_FIELD_INSET = 3;
|
||||
const SWITCH_TRACK_WIDTH = 34;
|
||||
@@ -37,7 +35,6 @@ const CONTROL_CENTER_JUSTIFY_CONTENT = "center";
|
||||
const FIELD_TEXT_LINE_HEIGHT_RATIO = 1.4;
|
||||
|
||||
const controlHeights = {
|
||||
tight: TIGHT_CONTROL_HEIGHT,
|
||||
compact: COMPACT_CONTROL_HEIGHT,
|
||||
field: FIELD_CONTROL_HEIGHT,
|
||||
};
|
||||
@@ -50,7 +47,6 @@ export const buttonIconSize: Record<ButtonControlSize, number> = {
|
||||
};
|
||||
|
||||
export const segmentedIconSize: Record<SegmentedControlSize, number> = {
|
||||
xs: ICON_SIZE.xs,
|
||||
sm: ICON_SIZE.sm,
|
||||
md: ICON_SIZE.md,
|
||||
};
|
||||
@@ -62,6 +58,10 @@ export const switchGeometry = {
|
||||
thumbTravel: SWITCH_TRACK_WIDTH - SWITCH_THUMB_SIZE - (SWITCH_TRACK_HEIGHT - SWITCH_THUMB_SIZE),
|
||||
};
|
||||
|
||||
function nestedRadius(containerRadius: number, inset: number): number {
|
||||
return Math.max(0, containerRadius - inset);
|
||||
}
|
||||
|
||||
function fieldLineHeight(fontSize: number): number {
|
||||
return Math.round(fontSize * FIELD_TEXT_LINE_HEIGHT_RATIO);
|
||||
}
|
||||
@@ -121,6 +121,8 @@ export function createControlGeometry(theme: Theme) {
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: fieldTextMdLineHeight,
|
||||
};
|
||||
const segmentedContainerSmRadius = theme.borderRadius.md;
|
||||
const segmentedContainerMdRadius = theme.borderRadius.lg;
|
||||
const switchControl = {
|
||||
minHeight: controlHeights.compact,
|
||||
justifyContent: CONTROL_CENTER_JUSTIFY_CONTENT,
|
||||
@@ -128,7 +130,7 @@ export function createControlGeometry(theme: Theme) {
|
||||
|
||||
return {
|
||||
buttonXs: {
|
||||
minHeight: controlHeights.tight,
|
||||
minHeight: controlHeights.compact,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.md,
|
||||
},
|
||||
@@ -192,41 +194,31 @@ export function createControlGeometry(theme: Theme) {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
switchControl,
|
||||
segmentedContainerXs: {
|
||||
minHeight: controlHeights.tight,
|
||||
padding: 0,
|
||||
},
|
||||
segmentedContainerSm: {
|
||||
minHeight: controlHeights.compact,
|
||||
padding: 0,
|
||||
padding: SEGMENTED_COMPACT_INSET,
|
||||
borderRadius: segmentedContainerSmRadius,
|
||||
},
|
||||
segmentedContainerMd: {
|
||||
minHeight: controlHeights.field,
|
||||
padding: 0,
|
||||
},
|
||||
segmentedSegmentXs: {
|
||||
minHeight: controlHeights.tight - SEGMENTED_TIGHT_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
padding: SEGMENTED_FIELD_INSET,
|
||||
borderRadius: segmentedContainerMdRadius,
|
||||
},
|
||||
segmentedSegmentSm: {
|
||||
minHeight: controlHeights.compact - SEGMENTED_COMPACT_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
borderRadius: nestedRadius(segmentedContainerSmRadius, SEGMENTED_COMPACT_INSET),
|
||||
},
|
||||
segmentedSegmentMd: {
|
||||
minHeight: controlHeights.field - SEGMENTED_FIELD_INSET * 2,
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
segmentedLabelXs: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
borderRadius: nestedRadius(segmentedContainerMdRadius, SEGMENTED_FIELD_INSET),
|
||||
},
|
||||
segmentedLabelSm: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
segmentedLabelMd: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -34,7 +33,6 @@ import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
|
||||
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
|
||||
|
||||
// Action status for menu items with loading/success feedback
|
||||
export type ActionStatus = "idle" | "pending" | "success";
|
||||
@@ -218,16 +216,12 @@ function renderDropdownSurface(input: {
|
||||
style={surfaceStyle}
|
||||
frameStyle={frameStyle}
|
||||
entering={contentEntering}
|
||||
exiting={
|
||||
isWeb
|
||||
? undefined
|
||||
: contentExiting.withCallback((finished) => {
|
||||
"worklet";
|
||||
if (finished) {
|
||||
runOnJS(onExited)();
|
||||
}
|
||||
})
|
||||
}
|
||||
exiting={contentExiting.withCallback((finished) => {
|
||||
"worklet";
|
||||
if (finished) {
|
||||
runOnJS(onExited)();
|
||||
}
|
||||
})}
|
||||
>
|
||||
{body}
|
||||
</FloatingSurface>
|
||||
@@ -502,17 +496,6 @@ export function DropdownMenuContent({
|
||||
setOpen(false);
|
||||
}, [setOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || !modalVisible || typeof window === "undefined") return undefined;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.stopPropagation();
|
||||
handleClose();
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [handleClose, modalVisible]);
|
||||
|
||||
// Measure trigger when opening
|
||||
useEffect(() => {
|
||||
if (!open || !triggerRef.current) {
|
||||
@@ -630,34 +613,6 @@ export function DropdownMenuContent({
|
||||
</View>
|
||||
);
|
||||
|
||||
const overlay = (
|
||||
<View style={[styles.overlay, isWeb ? styles.overlayWeb : null]}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("menu.backdrop")}
|
||||
style={styles.backdrop}
|
||||
onPress={handleClose}
|
||||
testID={testID ? `${testID}-backdrop` : undefined}
|
||||
/>
|
||||
{!closing
|
||||
? renderDropdownSurface({
|
||||
frameStyle,
|
||||
testID,
|
||||
surfaceStyle,
|
||||
scrollable,
|
||||
scrollViewportStyle,
|
||||
content,
|
||||
surfaceNativeID,
|
||||
onExited: () => setModalVisible(false),
|
||||
})
|
||||
: null}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (isWeb && typeof document !== "undefined") {
|
||||
return createPortal(overlay, getOverlayRoot());
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={modalVisible}
|
||||
@@ -667,7 +622,27 @@ export function DropdownMenuContent({
|
||||
onDismiss={flushPendingSelect}
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
{overlay}
|
||||
<View style={styles.overlay}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("menu.backdrop")}
|
||||
style={styles.backdrop}
|
||||
onPress={handleClose}
|
||||
testID={testID ? `${testID}-backdrop` : undefined}
|
||||
/>
|
||||
{!closing
|
||||
? renderDropdownSurface({
|
||||
frameStyle,
|
||||
testID,
|
||||
surfaceStyle,
|
||||
scrollable,
|
||||
scrollViewportStyle,
|
||||
content,
|
||||
surfaceNativeID,
|
||||
onExited: () => setModalVisible(false),
|
||||
})
|
||||
: null}
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -903,11 +878,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
},
|
||||
overlayWeb: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
zIndex: OVERLAY_Z.modal,
|
||||
pointerEvents: "auto" as const,
|
||||
},
|
||||
backdrop: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
|
||||
@@ -41,7 +41,7 @@ function SegmentIcon({ icon, iconSize, iconColor }: SegmentIconProps) {
|
||||
|
||||
const ThemedSegmentIcon = withUnistyles(SegmentIcon);
|
||||
|
||||
const selectedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.surface0 });
|
||||
const selectedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foreground });
|
||||
const mutedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foregroundMuted });
|
||||
|
||||
export function SegmentedControl<T extends string>({
|
||||
@@ -53,14 +53,9 @@ export function SegmentedControl<T extends string>({
|
||||
style,
|
||||
testID,
|
||||
}: SegmentedControlProps<T>) {
|
||||
const sizeStyles = {
|
||||
xs: { container: styles.containerXs, segment: styles.segmentXs, label: styles.labelXs },
|
||||
sm: { container: styles.containerSm, segment: styles.segmentSm, label: styles.labelSm },
|
||||
md: { container: styles.containerMd, segment: styles.segmentMd, label: styles.labelMd },
|
||||
}[size];
|
||||
const containerSizeStyle = sizeStyles.container;
|
||||
const segmentSizeStyle = sizeStyles.segment;
|
||||
const labelSizeStyle = sizeStyles.label;
|
||||
const containerSizeStyle = size === "sm" ? styles.containerSm : styles.containerMd;
|
||||
const segmentSizeStyle = size === "sm" ? styles.segmentSm : styles.segmentMd;
|
||||
const labelSizeStyle = size === "sm" ? styles.labelSm : styles.labelMd;
|
||||
const iconSize = segmentedIconSize[size];
|
||||
|
||||
const containerStyle = useMemo(
|
||||
@@ -166,12 +161,9 @@ const styles = StyleSheet.create((theme) => {
|
||||
return {
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "transparent",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
containerXs: {
|
||||
...geometry.segmentedContainerXs,
|
||||
alignItems: "stretch",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
gap: 2,
|
||||
},
|
||||
containerSm: {
|
||||
...geometry.segmentedContainerSm,
|
||||
@@ -186,9 +178,6 @@ const styles = StyleSheet.create((theme) => {
|
||||
flexShrink: 0,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
segmentXs: {
|
||||
...geometry.segmentedSegmentXs,
|
||||
},
|
||||
segmentSm: {
|
||||
...geometry.segmentedSegmentSm,
|
||||
},
|
||||
@@ -196,13 +185,18 @@ const styles = StyleSheet.create((theme) => {
|
||||
...geometry.segmentedSegmentMd,
|
||||
},
|
||||
segmentSelected: {
|
||||
backgroundColor: theme.colors.foreground,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
},
|
||||
segmentHover: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
segmentPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
},
|
||||
segmentDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
@@ -215,9 +209,6 @@ const styles = StyleSheet.create((theme) => {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
labelXs: {
|
||||
...geometry.segmentedLabelXs,
|
||||
},
|
||||
labelSm: {
|
||||
...geometry.segmentedLabelSm,
|
||||
},
|
||||
@@ -225,7 +216,7 @@ const styles = StyleSheet.create((theme) => {
|
||||
...geometry.segmentedLabelMd,
|
||||
},
|
||||
labelSelected: {
|
||||
color: theme.colors.surface0,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Dimensions,
|
||||
Platform,
|
||||
@@ -24,12 +23,13 @@ import {
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { Portal } from "@gorhom/portal";
|
||||
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet";
|
||||
import { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { FloatingSurface } from "@/components/ui/floating";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { getOverlayRoot, OVERLAY_Z } from "@/lib/overlay-root";
|
||||
|
||||
type Side = "top" | "bottom" | "left" | "right";
|
||||
type Align = "start" | "center" | "end";
|
||||
@@ -446,6 +446,7 @@ export function TooltipContent({
|
||||
maxWidth?: number;
|
||||
}>): ReactElement | null {
|
||||
const ctx = useTooltipContext("TooltipContent");
|
||||
const bottomSheetInternal = useBottomSheetModalInternal(true);
|
||||
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
@@ -515,22 +516,23 @@ export function TooltipContent({
|
||||
// steal focus / disrupt hover). Rendering via Portal + position:fixed keeps the
|
||||
// exact same positioning math as DropdownMenu, without hover feedback loops.
|
||||
if (isWeb) {
|
||||
return createPortal(
|
||||
<View pointerEvents="none" style={styles.portalOverlay}>
|
||||
<FloatingSurface
|
||||
pointerEvents="none"
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
collapsable={false}
|
||||
testID={testID}
|
||||
onLayout={handleLayout}
|
||||
style={contentStyle}
|
||||
frameStyle={frameStyle}
|
||||
>
|
||||
{children}
|
||||
</FloatingSurface>
|
||||
</View>,
|
||||
getOverlayRoot(),
|
||||
return (
|
||||
<Portal hostName={bottomSheetInternal?.hostName}>
|
||||
<View pointerEvents="none" style={styles.portalOverlay}>
|
||||
<FloatingSurface
|
||||
pointerEvents="none"
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
collapsable={false}
|
||||
testID={testID}
|
||||
onLayout={handleLayout}
|
||||
style={contentStyle}
|
||||
frameStyle={frameStyle}
|
||||
>
|
||||
{children}
|
||||
</FloatingSurface>
|
||||
</View>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -568,7 +570,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
zIndex: OVERLAY_Z.tooltip,
|
||||
zIndex: 1000,
|
||||
},
|
||||
content: {
|
||||
paddingVertical: theme.spacing[1],
|
||||
|
||||
@@ -338,7 +338,7 @@ export function openComposerAttachment(input: OpenComposerAttachmentInput): void
|
||||
input.setLightboxMetadata(input.attachment.metadata);
|
||||
return;
|
||||
}
|
||||
if (input.attachment.kind === "file" || input.attachment.kind === "workspace_file") {
|
||||
if (input.attachment.kind === "file") {
|
||||
return;
|
||||
}
|
||||
if (isWorkspaceAttachment(input.attachment)) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
buildForgeAttachmentFromSearchItem,
|
||||
buildLegacyGitHubAttachmentFromSearchItem,
|
||||
} from "@/utils/review-attachments";
|
||||
import { workspaceFileAttachmentToAgentAttachment } from "@/attachments/workspace-file";
|
||||
|
||||
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
|
||||
|
||||
@@ -50,11 +49,6 @@ export function splitComposerAttachmentsForSubmit(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (attachment.kind === "workspace_file") {
|
||||
agentAttachments.push(workspaceFileAttachmentToAgentAttachment(attachment));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isWorkspaceAttachment(attachment)) {
|
||||
if (attachment.kind === "browser_element" && attachment.attachment.screenshot) {
|
||||
images.push(attachment.attachment.screenshot);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
||||
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||
|
||||
const { asyncStorage } = vi.hoisted(() => ({
|
||||
asyncStorage: new Map<string, string>(),
|
||||
@@ -160,11 +159,7 @@ describe("useAgentInputDraft live contract", () => {
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
useDraftStore.setState({
|
||||
drafts: {},
|
||||
createModalDraft: null,
|
||||
attachmentFocusRequestByDraftKey: {},
|
||||
});
|
||||
useDraftStore.setState({ drafts: {}, createModalDraft: null });
|
||||
});
|
||||
|
||||
it("hydrates persisted text and attachments and returns draft-mode composer state for a caller-provided key", async () => {
|
||||
@@ -429,39 +424,6 @@ describe("useAgentInputDraft live contract", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches to an unmounted legacy draft without losing its input", async () => {
|
||||
const image: AttachmentMetadata = {
|
||||
id: "legacy-image",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "attachments/legacy-image",
|
||||
createdAt: 10,
|
||||
};
|
||||
useDraftStore.setState({
|
||||
drafts: {
|
||||
"draft:legacy-workspace-file": {
|
||||
input: { text: "legacy text", images: [image] },
|
||||
lifecycle: "active",
|
||||
updatedAt: Date.now(),
|
||||
version: 1,
|
||||
} as unknown as DraftRecordForTest,
|
||||
},
|
||||
});
|
||||
|
||||
await useDraftStore.getState().attachWorkspaceFile({
|
||||
draftKey: "draft:legacy-workspace-file",
|
||||
attachment: createWorkspaceFileAttachment({ path: "src/app.ts" }),
|
||||
});
|
||||
|
||||
expect(useDraftStore.getState().getDraftInput("draft:legacy-workspace-file")).toEqual({
|
||||
text: "legacy text",
|
||||
attachments: [
|
||||
{ kind: "image", metadata: image },
|
||||
createWorkspaceFileAttachment({ path: "src/app.ts" }),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("clear resets text and attachments", async () => {
|
||||
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||
const image: AttachmentMetadata = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@/hooks/use-agent-form-state";
|
||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||
import {
|
||||
areAttachmentsEqual,
|
||||
buildDraftAgentControls,
|
||||
hasDraftContent,
|
||||
resolveDraftKey,
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
type ProviderSelectionState,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { toDraftInputIfReady } from "@/stores/draft-store/state";
|
||||
|
||||
type AttachmentUpdater =
|
||||
| UserComposerAttachment[]
|
||||
@@ -57,7 +57,6 @@ export interface AgentInputDraft {
|
||||
setAttachments: (updater: AttachmentUpdater) => void;
|
||||
clear: (lifecycle: "sent" | "abandoned") => void;
|
||||
isHydrated: boolean;
|
||||
attachmentFocusRequestId: number;
|
||||
composerState: DraftComposerState | null;
|
||||
}
|
||||
|
||||
@@ -78,66 +77,67 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
}),
|
||||
[formState.selectedServerId, input.draftKey],
|
||||
);
|
||||
const draftRecord = useDraftStore((state) => state.drafts[draftKey]);
|
||||
const draft = useMemo(() => toDraftInputIfReady(draftRecord), [draftRecord]);
|
||||
const attachmentFocusRequestId = useDraftStore(
|
||||
(state) => state.attachmentFocusRequestByDraftKey[draftKey] ?? 0,
|
||||
);
|
||||
const [hydratedDraftKey, setHydratedDraftKey] = useState<string | null>(null);
|
||||
const text = draft?.text ?? "";
|
||||
const attachments = draft?.attachments ?? [];
|
||||
const isHydrated = hydratedDraftKey === draftKey;
|
||||
const [text, setText] = useState("");
|
||||
const [attachments, setAttachmentsState] = useState<UserComposerAttachment[]>([]);
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
const draftGenerationRef = useRef(0);
|
||||
const hydratedGenerationRef = useRef(0);
|
||||
|
||||
const saveDraft = useCallback(
|
||||
(
|
||||
update: (draft: { text: string; attachments: UserComposerAttachment[] }) => {
|
||||
text: string;
|
||||
attachments: UserComposerAttachment[];
|
||||
},
|
||||
) => {
|
||||
const store = useDraftStore.getState();
|
||||
const current = store.getDraftInput(draftKey) ?? { text: "", attachments: [] };
|
||||
const next = update(current);
|
||||
if (!hasDraftContent(next)) {
|
||||
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
|
||||
return;
|
||||
const setAttachments = useCallback((updater: AttachmentUpdater) => {
|
||||
setAttachmentsState((previousAttachments) => {
|
||||
if (typeof updater === "function") {
|
||||
return updater(previousAttachments);
|
||||
}
|
||||
store.saveDraftInput({ draftKey, draft: next });
|
||||
},
|
||||
[draftKey],
|
||||
);
|
||||
|
||||
const setText = useCallback(
|
||||
(nextText: string) => {
|
||||
saveDraft((current) => ({ ...current, text: nextText }));
|
||||
},
|
||||
[saveDraft],
|
||||
);
|
||||
|
||||
const setAttachments = useCallback(
|
||||
(updater: AttachmentUpdater) => {
|
||||
saveDraft((current) => ({
|
||||
...current,
|
||||
attachments: typeof updater === "function" ? updater(current.attachments) : updater,
|
||||
}));
|
||||
},
|
||||
[saveDraft],
|
||||
);
|
||||
return updater;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(
|
||||
(lifecycle: "sent" | "abandoned") => {
|
||||
useDraftStore.getState().clearDraftInput({ draftKey, lifecycle });
|
||||
const store = useDraftStore.getState();
|
||||
store.clearDraftInput({ draftKey, lifecycle });
|
||||
|
||||
const generation = store.beginDraftGeneration(draftKey);
|
||||
draftGenerationRef.current = generation;
|
||||
hydratedGenerationRef.current = generation;
|
||||
|
||||
setText("");
|
||||
setAttachmentsState([]);
|
||||
setIsHydrated(true);
|
||||
},
|
||||
[draftKey],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const store = useDraftStore.getState();
|
||||
const generation = store.beginDraftGeneration(draftKey);
|
||||
draftGenerationRef.current = generation;
|
||||
hydratedGenerationRef.current = 0;
|
||||
|
||||
setText("");
|
||||
setAttachmentsState([]);
|
||||
setIsHydrated(false);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
await useDraftStore.getState().hydrateDraftInput({ draftKey });
|
||||
if (!cancelled) {
|
||||
setHydratedDraftKey(draftKey);
|
||||
const draft = await store.hydrateDraftInput({
|
||||
draftKey,
|
||||
});
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (!useDraftStore.getState().isDraftGenerationCurrent({ draftKey, generation })) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (draft) {
|
||||
setText(draft.text);
|
||||
setAttachmentsState(draft.attachments);
|
||||
}
|
||||
|
||||
hydratedGenerationRef.current = generation;
|
||||
setIsHydrated(true);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
@@ -145,6 +145,52 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
};
|
||||
}, [draftKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentGeneration = draftGenerationRef.current;
|
||||
if (currentGeneration <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = useDraftStore.getState();
|
||||
const isCurrentGeneration = store.isDraftGenerationCurrent({
|
||||
draftKey,
|
||||
generation: currentGeneration,
|
||||
});
|
||||
if (!isCurrentGeneration) {
|
||||
return;
|
||||
}
|
||||
if (hydratedGenerationRef.current !== currentGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = store.getDraftInput(draftKey);
|
||||
const isSameDraft =
|
||||
existing !== undefined &&
|
||||
existing.text === text &&
|
||||
areAttachmentsEqual({
|
||||
left: existing.attachments,
|
||||
right: attachments,
|
||||
});
|
||||
if (isSameDraft) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasDraftContent({ text, attachments })) {
|
||||
if (existing) {
|
||||
store.clearDraftInput({ draftKey, lifecycle: "abandoned" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
store.saveDraftInput({
|
||||
draftKey,
|
||||
draft: {
|
||||
text,
|
||||
attachments,
|
||||
},
|
||||
});
|
||||
}, [attachments, draftKey, text]);
|
||||
|
||||
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
|
||||
useEffect(() => {
|
||||
if (!composerOptions || !lockedWorkingDir) {
|
||||
@@ -258,7 +304,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
setAttachments,
|
||||
clear,
|
||||
isHydrated,
|
||||
attachmentFocusRequestId,
|
||||
composerState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
||||
import { Keyboard, ScrollView, Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ReanimatedAnimated from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
useIsCompactFormFactor,
|
||||
} from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { WorkspaceDraftTabSetup } from "@/workspace-tabs/model";
|
||||
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
|
||||
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||
const EMPTY_ONLINE_SERVER_IDS: string[] = [];
|
||||
@@ -665,11 +665,7 @@ export function WorkspaceDraftAgentTab({
|
||||
});
|
||||
|
||||
const inputAreaWrapperStyle = useMemo(
|
||||
() => [
|
||||
animatedStaticStyles.inputAreaWrapper,
|
||||
{ paddingBottom: insets.bottom },
|
||||
composerKeyboardStyle,
|
||||
],
|
||||
() => [styles.inputAreaWrapper, { paddingBottom: insets.bottom }, composerKeyboardStyle],
|
||||
[insets.bottom, composerKeyboardStyle],
|
||||
);
|
||||
|
||||
@@ -751,7 +747,6 @@ export function WorkspaceDraftAgentTab({
|
||||
<Composer
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
externalKeyboardShift
|
||||
isPaneFocused={isPaneFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
@@ -766,7 +761,6 @@ export function WorkspaceDraftAgentTab({
|
||||
cwd={composerState.workingDir}
|
||||
clearDraft={draftInput.clear}
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
autoFocusKey={String(draftInput.attachmentFocusRequestId)}
|
||||
onFocusInput={handleFocusInputCallback}
|
||||
commandDraftConfig={composerState.commandDraftConfig}
|
||||
agentControls={composerAgentControls}
|
||||
@@ -778,12 +772,6 @@ export function WorkspaceDraftAgentTab({
|
||||
);
|
||||
}
|
||||
|
||||
const animatedStaticStyles = RNStyleSheet.create({
|
||||
inputAreaWrapper: {
|
||||
width: "100%",
|
||||
},
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
@@ -807,6 +795,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
configSection: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
inputAreaWrapper: {
|
||||
width: "100%",
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
importPillRow: {
|
||||
width: "100%",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceLayout } from "@/stores/workspace-layout-store";
|
||||
import { resolveFocusedChatTarget } from "./focused-chat-target";
|
||||
|
||||
function layoutWithTarget(
|
||||
target: import("@/workspace-tabs/model").WorkspaceTab["target"],
|
||||
): WorkspaceLayout {
|
||||
return {
|
||||
root: {
|
||||
kind: "pane",
|
||||
pane: {
|
||||
id: "pane-1",
|
||||
tabIds: ["focused-tab"],
|
||||
focusedTabId: "focused-tab",
|
||||
tabs: [{ tabId: "focused-tab", target, createdAt: 1 }],
|
||||
},
|
||||
},
|
||||
focusedPaneId: "pane-1",
|
||||
} as WorkspaceLayout;
|
||||
}
|
||||
|
||||
describe("focused chat target", () => {
|
||||
it("targets the focused agent draft", () => {
|
||||
expect(
|
||||
resolveFocusedChatTarget({
|
||||
serverId: "server-1",
|
||||
layout: layoutWithTarget({ kind: "agent", agentId: "agent-1" }),
|
||||
}),
|
||||
).toEqual({ tabId: "focused-tab", draftKey: "agent:server-1:agent-1" });
|
||||
});
|
||||
|
||||
it("targets the focused unsent draft", () => {
|
||||
expect(
|
||||
resolveFocusedChatTarget({
|
||||
serverId: "server-1",
|
||||
layout: layoutWithTarget({ kind: "draft", draftId: "draft-1" }),
|
||||
}),
|
||||
).toEqual({ tabId: "focused-tab", draftKey: "draft:server-1:draft-1" });
|
||||
});
|
||||
|
||||
it("does not guess when the focused tab is not a chat", () => {
|
||||
expect(
|
||||
resolveFocusedChatTarget({
|
||||
serverId: "server-1",
|
||||
layout: layoutWithTarget({ kind: "terminal", terminalId: "terminal-1" }),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import {
|
||||
collectAllTabs,
|
||||
findPaneById,
|
||||
type WorkspaceLayout,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
|
||||
export interface FocusedChatTarget {
|
||||
tabId: string;
|
||||
draftKey: string;
|
||||
}
|
||||
|
||||
export function resolveFocusedChatTarget(input: {
|
||||
serverId: string;
|
||||
layout: WorkspaceLayout | undefined;
|
||||
}): FocusedChatTarget | null {
|
||||
if (!input.layout) {
|
||||
return null;
|
||||
}
|
||||
const pane = findPaneById(input.layout.root, input.layout.focusedPaneId);
|
||||
const focusedTabId = pane?.focusedTabId;
|
||||
if (!focusedTabId) {
|
||||
return null;
|
||||
}
|
||||
const tab = collectAllTabs(input.layout.root).find(
|
||||
(candidate) => candidate.tabId === focusedTabId,
|
||||
);
|
||||
if (!tab) {
|
||||
return null;
|
||||
}
|
||||
if (tab.target.kind === "agent") {
|
||||
return {
|
||||
tabId: tab.tabId,
|
||||
draftKey: buildDraftStoreKey({ serverId: input.serverId, agentId: tab.target.agentId }),
|
||||
};
|
||||
}
|
||||
if (tab.target.kind === "draft") {
|
||||
return {
|
||||
tabId: tab.tabId,
|
||||
draftKey: buildDraftStoreKey({
|
||||
serverId: input.serverId,
|
||||
agentId: tab.tabId,
|
||||
draftId: tab.target.draftId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -48,9 +48,7 @@ interface SearchCall {
|
||||
|
||||
interface HarnessInput {
|
||||
initialAttachments?: UserComposerAttachment[];
|
||||
initialCwd?: string;
|
||||
initialText?: string;
|
||||
onPullRequestDetected?: () => void;
|
||||
remote?: string | null;
|
||||
}
|
||||
|
||||
@@ -74,14 +72,6 @@ function createSearchClient(items: ForgeSearchItem[]): ForgeSearchClient & { cal
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -97,8 +87,6 @@ function createWrapper() {
|
||||
|
||||
function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
|
||||
const [text, setText] = useState(input.initialText ?? "");
|
||||
const [searchClient, setSearchClient] = useState(client);
|
||||
const [workingDirectory, setWorkingDirectory] = useState(input.initialCwd ?? cwd);
|
||||
const [attachments, setAttachments] = useState<UserComposerAttachment[]>(
|
||||
input.initialAttachments ?? [],
|
||||
);
|
||||
@@ -106,22 +94,18 @@ function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
|
||||
text,
|
||||
remoteUrl: input.remote ?? remoteUrl,
|
||||
attachments,
|
||||
client: searchClient,
|
||||
client,
|
||||
isConnected: true,
|
||||
serverId: "server-1",
|
||||
cwd: workingDirectory,
|
||||
cwd,
|
||||
setAttachments,
|
||||
onPullRequestDetected: input.onPullRequestDetected,
|
||||
});
|
||||
|
||||
return {
|
||||
text,
|
||||
setText,
|
||||
setSearchClient,
|
||||
setWorkingDirectory,
|
||||
attachments,
|
||||
setAttachments,
|
||||
isResolving: autoAttach.isResolving,
|
||||
markGithubAttachmentRemoved: autoAttach.markGithubAttachmentRemoved,
|
||||
};
|
||||
}
|
||||
@@ -137,20 +121,14 @@ describe("useComposerGithubAutoAttach", () => {
|
||||
it("adds a matching pasted GitHub PR URL as a composer attachment", async () => {
|
||||
vi.useFakeTimers();
|
||||
const client = createSearchClient([pr101]);
|
||||
const onPullRequestDetected = vi.fn();
|
||||
const { result } = renderHook(() => useHarness(client, { onPullRequestDetected }), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
|
||||
|
||||
act(() => {
|
||||
result.current.setText("Please review https://github.com/acme/paseo/pull/101");
|
||||
});
|
||||
expect(result.current.isResolving).toBe(true);
|
||||
expect(onPullRequestDetected).toHaveBeenCalledTimes(1);
|
||||
await flushDebounce();
|
||||
|
||||
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
|
||||
expect(result.current.isResolving).toBe(false);
|
||||
expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -230,115 +208,4 @@ describe("useComposerGithubAutoAttach", () => {
|
||||
]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("stays resolving while overlapping lookups share a ref", async () => {
|
||||
vi.useFakeTimers();
|
||||
const firstLookup = deferred<ForgeSearchPayload>();
|
||||
const secondLookup = deferred<ForgeSearchPayload>();
|
||||
const client: ForgeSearchClient = {
|
||||
searchForge: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(firstLookup.promise)
|
||||
.mockReturnValueOnce(secondLookup.promise),
|
||||
};
|
||||
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
|
||||
|
||||
act(() => {
|
||||
result.current.setText(
|
||||
"Refs https://github.com/acme/paseo/pull/101 and https://github.com/acme/paseo/pull/202",
|
||||
);
|
||||
});
|
||||
await flushDebounce();
|
||||
|
||||
act(() => {
|
||||
result.current.setText("Still https://github.com/acme/paseo/pull/202");
|
||||
});
|
||||
await flushDebounce();
|
||||
|
||||
await act(async () => {
|
||||
firstLookup.resolve(githubPayload([], "search-101"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(result.current.isResolving).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
secondLookup.resolve(githubPayload([], "search-202"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(result.current.isResolving).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("stops resolving when an in-flight PR URL is removed", async () => {
|
||||
vi.useFakeTimers();
|
||||
const lookup = deferred<ForgeSearchPayload>();
|
||||
const client: ForgeSearchClient = {
|
||||
searchForge: vi.fn().mockReturnValue(lookup.promise),
|
||||
};
|
||||
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
|
||||
|
||||
act(() => {
|
||||
result.current.setText("Review https://github.com/acme/paseo/pull/101");
|
||||
});
|
||||
await flushDebounce();
|
||||
act(() => {
|
||||
result.current.setText("");
|
||||
});
|
||||
|
||||
expect(result.current.isResolving).toBe(false);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("ignores a lookup that finishes after the target changes", async () => {
|
||||
vi.useFakeTimers();
|
||||
const lookup = deferred<ForgeSearchPayload>();
|
||||
const client: ForgeSearchClient = {
|
||||
searchForge: vi.fn().mockReturnValue(lookup.promise),
|
||||
};
|
||||
const { result } = renderHook(() => useHarness(client), { wrapper: createWrapper() });
|
||||
|
||||
act(() => {
|
||||
result.current.setText("Review https://github.com/acme/paseo/pull/101");
|
||||
});
|
||||
await flushDebounce();
|
||||
|
||||
act(() => {
|
||||
result.current.setWorkingDirectory("/other-repo");
|
||||
});
|
||||
await flushDebounce();
|
||||
await act(async () => {
|
||||
lookup.resolve(githubPayload([pr101], "search-101"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.attachments).toEqual([]);
|
||||
expect(client.searchForge).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("accepts a lookup after the transport client is replaced for the same target", async () => {
|
||||
vi.useFakeTimers();
|
||||
const lookup = deferred<ForgeSearchPayload>();
|
||||
const firstClient: ForgeSearchClient = {
|
||||
searchForge: vi.fn().mockReturnValue(lookup.promise),
|
||||
};
|
||||
const replacementClient = createSearchClient([pr101]);
|
||||
const { result } = renderHook(() => useHarness(firstClient), { wrapper: createWrapper() });
|
||||
|
||||
act(() => {
|
||||
result.current.setText("Review https://github.com/acme/paseo/pull/101");
|
||||
});
|
||||
await flushDebounce();
|
||||
act(() => {
|
||||
result.current.setSearchClient(replacementClient);
|
||||
});
|
||||
await act(async () => {
|
||||
lookup.resolve(githubPayload([pr101], "search-101"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
|
||||
expect(replacementClient.calls).toEqual([]);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
@@ -27,12 +26,9 @@ interface ComposerGithubAutoAttachInput {
|
||||
cwd: string;
|
||||
supportsForgeSearch?: boolean;
|
||||
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
|
||||
onPullRequestDetected?: () => void;
|
||||
onPullRequestAdded?: (item: ForgeSearchItem) => void;
|
||||
}
|
||||
|
||||
interface ComposerGithubAutoAttachResult {
|
||||
isResolving: boolean;
|
||||
markGithubAttachmentRemoved: (attachment: ComposerAttachment | undefined) => void;
|
||||
}
|
||||
|
||||
@@ -43,24 +39,10 @@ export function useComposerGithubAutoAttach(
|
||||
const latestRef = useRef(params);
|
||||
const removedRefKeysRef = useRef(new Set<string>());
|
||||
const pendingRefKeysRef = useRef(new Set<string>());
|
||||
const presentPullRequestKeysRef = useRef(new Set<string>());
|
||||
const previousTargetRef = useRef({ serverId: params.serverId, cwd: params.cwd });
|
||||
const [resolvingRefCounts, setResolvingRefCounts] = useState<ReadonlyMap<string, number>>(
|
||||
() => new Map(),
|
||||
);
|
||||
|
||||
latestRef.current = params;
|
||||
|
||||
useEffect(() => {
|
||||
suppressRefsCarriedAcrossTargets({
|
||||
params: latestRef.current,
|
||||
previousTargetRef,
|
||||
removedRefKeys: removedRefKeysRef.current,
|
||||
});
|
||||
notifyNewPullRequestRefs({
|
||||
params: latestRef.current,
|
||||
presentPullRequestKeysRef,
|
||||
});
|
||||
const refs = refsReadyForLookup({
|
||||
params: latestRef.current,
|
||||
removedRefKeys: removedRefKeysRef.current,
|
||||
@@ -70,15 +52,6 @@ export function useComposerGithubAutoAttach(
|
||||
return;
|
||||
}
|
||||
|
||||
const refKeys = refs.map(githubRefKey);
|
||||
setResolvingRefCounts((current) => addKeys(current, refKeys));
|
||||
let resolvingReleased = false;
|
||||
const releaseResolving = () => {
|
||||
if (resolvingReleased) return;
|
||||
resolvingReleased = true;
|
||||
clearResolvingKeys(setResolvingRefCounts, refKeys);
|
||||
};
|
||||
|
||||
const timerId = setTimeout(() => {
|
||||
void attachRefs({
|
||||
refs,
|
||||
@@ -86,12 +59,11 @@ export function useComposerGithubAutoAttach(
|
||||
latestRef,
|
||||
removedRefKeys: removedRefKeysRef.current,
|
||||
pendingRefKeys: pendingRefKeysRef.current,
|
||||
}).finally(releaseResolving);
|
||||
});
|
||||
}, AUTO_ATTACH_DEBOUNCE_MS);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timerId);
|
||||
releaseResolving();
|
||||
};
|
||||
}, [
|
||||
params.text,
|
||||
@@ -114,84 +86,12 @@ export function useComposerGithubAutoAttach(
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
isResolving: resolvingRefCounts.size > 0,
|
||||
markGithubAttachmentRemoved,
|
||||
}),
|
||||
[markGithubAttachmentRemoved, resolvingRefCounts.size],
|
||||
[markGithubAttachmentRemoved],
|
||||
);
|
||||
}
|
||||
|
||||
function suppressRefsCarriedAcrossTargets({
|
||||
params,
|
||||
previousTargetRef,
|
||||
removedRefKeys,
|
||||
}: {
|
||||
params: ComposerGithubAutoAttachInput;
|
||||
previousTargetRef: RefObject<{ serverId: string; cwd: string }>;
|
||||
removedRefKeys: Set<string>;
|
||||
}): void {
|
||||
const previous = previousTargetRef.current;
|
||||
const targetChanged =
|
||||
previous.cwd.trim().length > 0 &&
|
||||
params.cwd.trim().length > 0 &&
|
||||
(previous.serverId !== params.serverId || previous.cwd !== params.cwd);
|
||||
previousTargetRef.current = { serverId: params.serverId, cwd: params.cwd };
|
||||
if (!targetChanged) return;
|
||||
|
||||
for (const ref of extractGithubRefs(params.text, params.remoteUrl)) {
|
||||
removedRefKeys.add(githubRefKey(ref));
|
||||
}
|
||||
}
|
||||
|
||||
function notifyNewPullRequestRefs({
|
||||
params,
|
||||
presentPullRequestKeysRef,
|
||||
}: {
|
||||
params: ComposerGithubAutoAttachInput;
|
||||
presentPullRequestKeysRef: RefObject<Set<string>>;
|
||||
}): void {
|
||||
const currentKeys = new Set(
|
||||
extractGithubRefs(params.text, params.remoteUrl)
|
||||
.filter((ref) => ref.kind === "pull")
|
||||
.map(githubRefKey),
|
||||
);
|
||||
for (const key of currentKeys) {
|
||||
if (!presentPullRequestKeysRef.current.has(key)) {
|
||||
params.onPullRequestDetected?.();
|
||||
}
|
||||
}
|
||||
presentPullRequestKeysRef.current = currentKeys;
|
||||
}
|
||||
|
||||
function addKeys(
|
||||
current: ReadonlyMap<string, number>,
|
||||
keys: readonly string[],
|
||||
): ReadonlyMap<string, number> {
|
||||
const nextCounts = new Map(current);
|
||||
for (const key of keys) nextCounts.set(key, (nextCounts.get(key) ?? 0) + 1);
|
||||
return nextCounts;
|
||||
}
|
||||
|
||||
function removeKeys(
|
||||
current: ReadonlyMap<string, number>,
|
||||
keys: readonly string[],
|
||||
): ReadonlyMap<string, number> {
|
||||
const next = new Map(current);
|
||||
for (const key of keys) {
|
||||
const count = next.get(key) ?? 0;
|
||||
if (count <= 1) next.delete(key);
|
||||
else next.set(key, count - 1);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function clearResolvingKeys(
|
||||
setResolvingRefCounts: Dispatch<SetStateAction<ReadonlyMap<string, number>>>,
|
||||
keys: readonly string[],
|
||||
): void {
|
||||
setResolvingRefCounts((current) => removeKeys(current, keys));
|
||||
}
|
||||
|
||||
async function attachRefs({
|
||||
refs,
|
||||
queryClient,
|
||||
@@ -242,28 +142,16 @@ async function attachRef({
|
||||
return;
|
||||
}
|
||||
const item = search.items.find((candidate) => githubItemMatchesRef(candidate, ref));
|
||||
const current = latestRef.current;
|
||||
if (
|
||||
!item ||
|
||||
removedRefKeys.has(key) ||
|
||||
!isSameLookupTarget(snapshot, current) ||
|
||||
!isRefStillPresent(ref, current)
|
||||
) {
|
||||
if (!item || removedRefKeys.has(key) || !isRefStillPresent(ref, latestRef.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAttachmentSelectedForGithubItem(current.attachments, item)) {
|
||||
return;
|
||||
}
|
||||
current.setAttachments((attachments) => {
|
||||
if (removedRefKeys.has(key) || isAttachmentSelectedForGithubItem(attachments, item)) {
|
||||
return attachments;
|
||||
latestRef.current.setAttachments((current) => {
|
||||
if (removedRefKeys.has(key) || isAttachmentSelectedForGithubItem(current, item)) {
|
||||
return current;
|
||||
}
|
||||
return toggleGithubAttachment(attachments, item);
|
||||
return toggleGithubAttachment(current, item);
|
||||
});
|
||||
if (item.kind === "change_request") {
|
||||
current.onPullRequestAdded?.(item);
|
||||
}
|
||||
}
|
||||
|
||||
function refsReadyForLookup({
|
||||
@@ -324,17 +212,6 @@ function isRefStillPresent(ref: GithubRef, params: ComposerGithubAutoAttachInput
|
||||
);
|
||||
}
|
||||
|
||||
function isSameLookupTarget(
|
||||
initial: ComposerGithubAutoAttachInput,
|
||||
current: ComposerGithubAutoAttachInput,
|
||||
): boolean {
|
||||
return (
|
||||
initial.serverId === current.serverId &&
|
||||
initial.cwd === current.cwd &&
|
||||
initial.remoteUrl === current.remoteUrl
|
||||
);
|
||||
}
|
||||
|
||||
function hasGithubAttachment(attachments: UserComposerAttachment[], ref: GithubRef): boolean {
|
||||
return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Pressable,
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
StyleSheet as RNStyleSheet,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import type { TFunction } from "i18next";
|
||||
@@ -99,7 +98,6 @@ import type {
|
||||
AttachmentMetadata,
|
||||
ComposerAttachment,
|
||||
UserComposerAttachment,
|
||||
WorkspaceFileComposerAttachment,
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import type { PickedFile } from "@/attachments/picked-file";
|
||||
@@ -120,15 +118,6 @@ import { getForgePresentation } from "@/git/forge";
|
||||
import { ForgeBrandIcon } from "@/git/forge-icon";
|
||||
import { useComposerGithubAutoAttach } from "./github/auto-attach";
|
||||
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
|
||||
import {
|
||||
appendWorkspaceFileAttachment,
|
||||
getWorkspaceFileAttachmentKey,
|
||||
getWorkspaceFileAttachmentSubtitle,
|
||||
} from "@/attachments/workspace-file";
|
||||
import {
|
||||
resolveWorkspaceFileDrop,
|
||||
type WorkspaceFileDragPayload,
|
||||
} from "@/attachments/workspace-file-drag";
|
||||
|
||||
type QueuedMessage = QueuedComposerMessage;
|
||||
|
||||
@@ -409,18 +398,6 @@ function renderComposerAttachmentPill(args: RenderComposerAttachmentPillArgs): R
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (attachment.kind === "workspace_file") {
|
||||
return (
|
||||
<WorkspaceFileAttachmentPill
|
||||
key={`workspace-file:${getWorkspaceFileAttachmentKey(attachment)}`}
|
||||
attachment={attachment}
|
||||
index={index}
|
||||
disabled={disabled}
|
||||
onRemove={onRemove}
|
||||
removeLabel={labels.removeFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (composerWorkspaceAttachment.is(attachment)) {
|
||||
return composerWorkspaceAttachment.renderPill({
|
||||
attachment,
|
||||
@@ -733,43 +710,6 @@ function FileAttachmentPill({
|
||||
);
|
||||
}
|
||||
|
||||
interface WorkspaceFileAttachmentPillProps {
|
||||
attachment: WorkspaceFileComposerAttachment;
|
||||
index: number;
|
||||
disabled: boolean;
|
||||
onRemove: (index: number) => void;
|
||||
removeLabel: string;
|
||||
}
|
||||
|
||||
function WorkspaceFileAttachmentPill({
|
||||
attachment,
|
||||
index,
|
||||
disabled,
|
||||
onRemove,
|
||||
removeLabel,
|
||||
}: WorkspaceFileAttachmentPillProps) {
|
||||
const handleRemove = useCallback(() => {
|
||||
onRemove(index);
|
||||
}, [index, onRemove]);
|
||||
const fileName = attachment.path.split("/").pop() ?? attachment.path;
|
||||
return (
|
||||
<AttachmentPill
|
||||
testID="composer-workspace-file-attachment-pill"
|
||||
onOpen={noopCallback}
|
||||
onRemove={handleRemove}
|
||||
openAccessibilityLabel={fileName}
|
||||
removeAccessibilityLabel={removeLabel}
|
||||
disabled={disabled}
|
||||
>
|
||||
<AttachmentLabel
|
||||
icon={filePillIcon}
|
||||
title={fileName}
|
||||
subtitle={getWorkspaceFileAttachmentSubtitle(attachment)}
|
||||
/>
|
||||
</AttachmentPill>
|
||||
);
|
||||
}
|
||||
|
||||
interface GithubPickerOptionProps {
|
||||
label: string;
|
||||
testID: string;
|
||||
@@ -814,7 +754,6 @@ function GithubPickerOption({
|
||||
interface ComposerProps {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
isPaneFocused: boolean;
|
||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
||||
onClientSlashCommand?: (command: ClientSlashCommand) => Promise<void>;
|
||||
@@ -829,8 +768,6 @@ interface ComposerProps {
|
||||
submitIcon?: "arrow" | "return";
|
||||
/** Externally controlled loading state. When true, disables the submit button. */
|
||||
isSubmitLoading?: boolean;
|
||||
/** When true, waits for pasted GitHub links to resolve before enabling submit. */
|
||||
waitForGithubAutoAttachOnSubmit?: boolean;
|
||||
submitBehavior?: "clear" | "preserve-and-lock";
|
||||
/** When true, blurs the input immediately when submitting. */
|
||||
blurOnSubmit?: boolean;
|
||||
@@ -840,14 +777,10 @@ interface ComposerProps {
|
||||
attachmentScopeKeys?: readonly string[];
|
||||
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
|
||||
onChangeAttachments: (updater: AttachmentListUpdater) => void;
|
||||
onGithubPrDetected?: () => void;
|
||||
onGithubPrAutoAttach?: (item: ForgeSearchItem) => void;
|
||||
cwd: string;
|
||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||
/** When true, auto-focuses the text input on web. */
|
||||
autoFocus?: boolean;
|
||||
/** Changing this value requests focus again while autoFocus remains true. */
|
||||
autoFocusKey?: string;
|
||||
/** Callback to expose a focus function to parent components (desktop only). */
|
||||
onFocusInput?: (focus: () => void) => void;
|
||||
/** Optional draft context for listing commands before an agent exists. */
|
||||
@@ -1039,7 +972,6 @@ function ComposerVoiceModeButton({
|
||||
export function Composer({
|
||||
agentId,
|
||||
serverId,
|
||||
workspaceId,
|
||||
isPaneFocused,
|
||||
onSubmitMessage,
|
||||
onClientSlashCommand,
|
||||
@@ -1049,7 +981,6 @@ export function Composer({
|
||||
submitButtonTestID,
|
||||
submitIcon = "arrow",
|
||||
isSubmitLoading = false,
|
||||
waitForGithubAutoAttachOnSubmit = false,
|
||||
submitBehavior = "clear",
|
||||
blurOnSubmit = false,
|
||||
value,
|
||||
@@ -1058,12 +989,9 @@ export function Composer({
|
||||
attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
|
||||
onOpenWorkspaceAttachment,
|
||||
onChangeAttachments,
|
||||
onGithubPrDetected,
|
||||
onGithubPrAutoAttach,
|
||||
cwd,
|
||||
clearDraft,
|
||||
autoFocus = false,
|
||||
autoFocusKey,
|
||||
onFocusInput,
|
||||
commandDraftConfig,
|
||||
onMessageSent,
|
||||
@@ -1142,8 +1070,6 @@ export function Composer({
|
||||
cwd,
|
||||
supportsForgeSearch,
|
||||
setAttachments: setSelectedAttachments,
|
||||
onPullRequestDetected: onGithubPrDetected,
|
||||
onPullRequestAdded: onGithubPrAutoAttach,
|
||||
});
|
||||
const [cursorIndex, setCursorIndex] = useState(0);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
@@ -1259,21 +1185,6 @@ export function Composer({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleWorkspaceFileDropped = useCallback(
|
||||
(payload: WorkspaceFileDragPayload) => {
|
||||
if (!workspaceId) {
|
||||
return;
|
||||
}
|
||||
const attachment = resolveWorkspaceFileDrop({ payload, serverId, workspaceId });
|
||||
if (!attachment) {
|
||||
return;
|
||||
}
|
||||
setSelectedAttachments((current) => appendWorkspaceFileAttachment(current, attachment));
|
||||
focusInput();
|
||||
},
|
||||
[focusInput, serverId, setSelectedAttachments, workspaceId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onFocusInput?.(focusInput);
|
||||
}, [focusInput, onFocusInput]);
|
||||
@@ -1996,7 +1907,7 @@ export function Composer({
|
||||
);
|
||||
|
||||
const composerContainerStyle = useMemo(
|
||||
() => [animatedStaticStyles.container, keyboardAnimatedStyle],
|
||||
() => [styles.container, keyboardAnimatedStyle],
|
||||
[keyboardAnimatedStyle],
|
||||
);
|
||||
const inputAreaContainerStyle = useMemo(
|
||||
@@ -2038,21 +1949,13 @@ export function Composer({
|
||||
|
||||
const messageInputContainerRef = useRef<View>(null);
|
||||
|
||||
const isSubmitBusy =
|
||||
isProcessing ||
|
||||
isSubmitLoading ||
|
||||
isUploadingFile ||
|
||||
(waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving);
|
||||
const isSubmitBusy = isProcessing || isSubmitLoading || isUploadingFile;
|
||||
|
||||
// Disable drops while submitting/uploading: the submit path clears and restores attachments,
|
||||
// so a drop in that window would be lost or land on a locked draft. `disabled` hides the
|
||||
// backdrop and rejects the drop atomically, instead of accepting a drop with no feedback.
|
||||
useFileDrop(
|
||||
{
|
||||
onFiles: addImages,
|
||||
onGenericFiles: handleGenericFilesDropped,
|
||||
onWorkspaceFile: handleWorkspaceFileDropped,
|
||||
},
|
||||
{ onFiles: addImages, onGenericFiles: handleGenericFilesDropped },
|
||||
{ disabled: isSubmitBusy },
|
||||
);
|
||||
|
||||
@@ -2113,7 +2016,7 @@ export function Composer({
|
||||
isReadyForDictation={isDictationReady}
|
||||
placeholder={messagePlaceholder}
|
||||
autoFocus={messageInputAutoFocus}
|
||||
autoFocusKey={`${serverId}:${agentId}:${autoFocusKey ?? ""}`}
|
||||
autoFocusKey={`${serverId}:${agentId}`}
|
||||
disabled={isSubmitLoading}
|
||||
isPaneFocused={isPaneFocused}
|
||||
leftContent={leftContent}
|
||||
@@ -2161,14 +2064,11 @@ export function Composer({
|
||||
);
|
||||
}
|
||||
|
||||
const animatedStaticStyles = RNStyleSheet.create({
|
||||
const styles = StyleSheet.create((theme: Theme) => ({
|
||||
container: {
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
},
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create((theme: Theme) => ({
|
||||
borderSeparator: {
|
||||
height: theme.borderWidth[1],
|
||||
backgroundColor: theme.colors.border,
|
||||
|
||||
@@ -70,7 +70,6 @@ import {
|
||||
resolveComposerSurfacePresentation,
|
||||
runAlternateSendAction,
|
||||
runDefaultSendAction,
|
||||
runMessageInputKeyboardAction,
|
||||
stopRealtimeVoice,
|
||||
} from "./state";
|
||||
|
||||
@@ -497,6 +496,65 @@ function handleDesktopKeyPressImpl(
|
||||
ctx.handleDefaultSendAction();
|
||||
}
|
||||
|
||||
interface KeyboardActionHandlers {
|
||||
textInputRef: React.MutableRefObject<
|
||||
TextInput | (TextInput & { getNativeRef?: () => unknown }) | null
|
||||
>;
|
||||
isDictatingRef: React.MutableRefObject<boolean>;
|
||||
sendAfterTranscriptRef: React.MutableRefObject<boolean>;
|
||||
confirmDictation: () => void | Promise<void>;
|
||||
cancelDictation: () => void | Promise<void>;
|
||||
startDictationIfAvailable: () => Promise<void>;
|
||||
handleToggleRealtimeVoiceShortcut: () => void;
|
||||
isRealtimeVoiceForCurrentAgent: boolean;
|
||||
voice: { toggleMute: () => void } | null | undefined;
|
||||
}
|
||||
|
||||
function runKeyboardActionImpl(
|
||||
action: MessageInputKeyboardActionKind,
|
||||
h: KeyboardActionHandlers,
|
||||
): boolean {
|
||||
if (action === "focus") {
|
||||
h.textInputRef.current?.focus();
|
||||
return true;
|
||||
}
|
||||
if (action === "send" || action === "dictation-confirm") {
|
||||
if (h.isDictatingRef.current) {
|
||||
h.sendAfterTranscriptRef.current = true;
|
||||
void h.confirmDictation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (action === "voice-toggle") {
|
||||
h.handleToggleRealtimeVoiceShortcut();
|
||||
return true;
|
||||
}
|
||||
if (action === "voice-mute-toggle") {
|
||||
if (h.isRealtimeVoiceForCurrentAgent) {
|
||||
h.voice?.toggleMute();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (action === "dictation-cancel") {
|
||||
if (h.isDictatingRef.current) {
|
||||
void h.cancelDictation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (action === "dictation-toggle") {
|
||||
if (h.isDictatingRef.current) {
|
||||
h.sendAfterTranscriptRef.current = true;
|
||||
void h.confirmDictation();
|
||||
} else {
|
||||
void h.startDictationIfAvailable();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getTextInputNativeElement(
|
||||
current: TextInput | (TextInput & { getNativeRef?: () => unknown }) | null,
|
||||
): HTMLElement | null {
|
||||
@@ -858,18 +916,22 @@ function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
|
||||
interface StartDictationContext {
|
||||
dictationUnavailableMessage: string | null | undefined;
|
||||
canStartDictation: () => boolean;
|
||||
isDictatingRef: React.MutableRefObject<boolean>;
|
||||
toast: { error: (msg: string) => void };
|
||||
startDictation: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promise<void> {
|
||||
if (ctx.dictationUnavailableMessage) {
|
||||
ctx.isDictatingRef.current = false;
|
||||
ctx.toast.error(ctx.dictationUnavailableMessage);
|
||||
return;
|
||||
}
|
||||
if (!ctx.canStartDictation()) {
|
||||
ctx.isDictatingRef.current = false;
|
||||
return;
|
||||
}
|
||||
ctx.isDictatingRef.current = true;
|
||||
await ctx.startDictation();
|
||||
}
|
||||
|
||||
@@ -1214,18 +1276,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
textInputRef.current?.blur?.();
|
||||
},
|
||||
runKeyboardAction: (action) =>
|
||||
runMessageInputKeyboardAction(action, {
|
||||
focusInput: () => textInputRef.current?.focus(),
|
||||
isDictationRecording: isDictationActive,
|
||||
markTranscriptForSend: () => {
|
||||
sendAfterTranscriptRef.current = true;
|
||||
},
|
||||
runKeyboardActionImpl(action, {
|
||||
textInputRef,
|
||||
isDictatingRef,
|
||||
sendAfterTranscriptRef,
|
||||
confirmDictation,
|
||||
cancelDictation,
|
||||
startDictation: startDictationIfAvailable,
|
||||
toggleRealtimeVoice: handleToggleRealtimeVoiceShortcut,
|
||||
isRealtimeVoiceActive: isRealtimeVoiceForCurrentAgent,
|
||||
toggleRealtimeVoiceMute: () => voice?.toggleMute(),
|
||||
startDictationIfAvailable,
|
||||
handleToggleRealtimeVoiceShortcut,
|
||||
isRealtimeVoiceForCurrentAgent,
|
||||
voice,
|
||||
}),
|
||||
getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null),
|
||||
}));
|
||||
@@ -1309,7 +1369,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
|
||||
const {
|
||||
isRecording: isDictating,
|
||||
isRecordingActive: isDictationActive,
|
||||
isProcessing: isDictationProcessing,
|
||||
partialTranscript: _dictationPartialTranscript,
|
||||
volume: dictationVolume,
|
||||
@@ -1330,6 +1389,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
enableDuration: true,
|
||||
});
|
||||
|
||||
const isDictatingRef = useRef(isDictating);
|
||||
useEffect(() => {
|
||||
isDictatingRef.current = isDictating;
|
||||
}, [isDictating]);
|
||||
|
||||
const isRealtimeVoiceForCurrentAgent = computeIsRealtimeVoiceForAgent(
|
||||
voice,
|
||||
voiceServerId,
|
||||
@@ -1356,6 +1420,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
startDictationIfAvailableImpl({
|
||||
dictationUnavailableMessage,
|
||||
canStartDictation,
|
||||
isDictatingRef,
|
||||
toast,
|
||||
startDictation,
|
||||
}),
|
||||
|
||||
@@ -4,40 +4,12 @@ import {
|
||||
resolveComposerSurfacePresentation,
|
||||
runAlternateSendAction,
|
||||
runDefaultSendAction,
|
||||
runMessageInputKeyboardAction,
|
||||
stopRealtimeVoice,
|
||||
} from "./state";
|
||||
|
||||
const connected = { isConnected: true } as never;
|
||||
const disconnected = { isConnected: false } as never;
|
||||
|
||||
function createDictationKeyboard({ startsRecording }: { startsRecording: boolean }) {
|
||||
let isRecording = false;
|
||||
const actions: string[] = [];
|
||||
|
||||
return {
|
||||
actions,
|
||||
pressDictationShortcut: () =>
|
||||
runMessageInputKeyboardAction("dictation-toggle", {
|
||||
focusInput: () => undefined,
|
||||
isDictationRecording: () => isRecording,
|
||||
markTranscriptForSend: () => actions.push("send transcript"),
|
||||
startDictation: () => {
|
||||
actions.push("start");
|
||||
isRecording = startsRecording;
|
||||
},
|
||||
confirmDictation: () => {
|
||||
actions.push("confirm");
|
||||
isRecording = false;
|
||||
},
|
||||
cancelDictation: () => undefined,
|
||||
toggleRealtimeVoice: () => undefined,
|
||||
isRealtimeVoiceActive: false,
|
||||
toggleRealtimeVoiceMute: () => undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("composer surface presentation", () => {
|
||||
it("shows only the input when no voice overlay is active", () => {
|
||||
expect(resolveComposerSurfacePresentation(false)).toEqual({
|
||||
@@ -142,27 +114,6 @@ describe("computeCanStartDictation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("dictation keyboard behavior", () => {
|
||||
it("starts dictation again after the previous dictation finishes", () => {
|
||||
const keyboard = createDictationKeyboard({ startsRecording: true });
|
||||
|
||||
keyboard.pressDictationShortcut();
|
||||
keyboard.pressDictationShortcut();
|
||||
keyboard.pressDictationShortcut();
|
||||
|
||||
expect(keyboard.actions).toEqual(["start", "send transcript", "confirm", "start"]);
|
||||
});
|
||||
|
||||
it("can retry when starting dictation does not enter the recording state", () => {
|
||||
const keyboard = createDictationKeyboard({ startsRecording: false });
|
||||
|
||||
keyboard.pressDictationShortcut();
|
||||
keyboard.pressDictationShortcut();
|
||||
|
||||
expect(keyboard.actions).toEqual(["start", "start"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("composer send behavior", () => {
|
||||
function actions() {
|
||||
const calls: string[] = [];
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { MessagePayload } from "@/composer/types";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
|
||||
export type SendBehavior = "interrupt" | "queue";
|
||||
|
||||
@@ -46,18 +45,6 @@ interface SendActionContext {
|
||||
handleQueueMessage: () => void;
|
||||
}
|
||||
|
||||
interface MessageInputKeyboardActions {
|
||||
focusInput: () => void;
|
||||
isDictationRecording: () => boolean;
|
||||
markTranscriptForSend: () => void;
|
||||
confirmDictation: () => void | Promise<void>;
|
||||
cancelDictation: () => void | Promise<void>;
|
||||
startDictation: () => void | Promise<void>;
|
||||
toggleRealtimeVoice: () => void;
|
||||
isRealtimeVoiceActive: boolean;
|
||||
toggleRealtimeVoiceMute: () => void;
|
||||
}
|
||||
|
||||
export function computeCanStartDictation(input: {
|
||||
client: DaemonClient | null;
|
||||
isReadyForDictation: boolean | undefined;
|
||||
@@ -89,51 +76,6 @@ export function runAlternateSendAction(ctx: SendActionContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function runMessageInputKeyboardAction(
|
||||
action: MessageInputKeyboardActionKind,
|
||||
actions: MessageInputKeyboardActions,
|
||||
): boolean {
|
||||
if (action === "focus") {
|
||||
actions.focusInput();
|
||||
return true;
|
||||
}
|
||||
if (action === "send" || action === "dictation-confirm") {
|
||||
if (actions.isDictationRecording()) {
|
||||
actions.markTranscriptForSend();
|
||||
void actions.confirmDictation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (action === "voice-toggle") {
|
||||
actions.toggleRealtimeVoice();
|
||||
return true;
|
||||
}
|
||||
if (action === "voice-mute-toggle") {
|
||||
if (actions.isRealtimeVoiceActive) {
|
||||
actions.toggleRealtimeVoiceMute();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (action === "dictation-cancel") {
|
||||
if (actions.isDictationRecording()) {
|
||||
void actions.cancelDictation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (action === "dictation-toggle") {
|
||||
if (actions.isDictationRecording()) {
|
||||
actions.markTranscriptForSend();
|
||||
void actions.confirmDictation();
|
||||
} else {
|
||||
void actions.startDictation();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise<void> {
|
||||
if (!ctx.voice || !ctx.isRealtimeVoiceForCurrentAgent) return;
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol
|
||||
import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
buildAgentAttentionNotificationPayload,
|
||||
type AgentAttentionReason,
|
||||
type AgentAttentionNotificationPayload,
|
||||
type NotificationPermissionRequest,
|
||||
} from "@getpaseo/protocol/agent-attention-notification";
|
||||
@@ -155,35 +154,6 @@ const getLatestPermissionRequest = (
|
||||
return null;
|
||||
};
|
||||
|
||||
interface AgentAttentionNotificationInput {
|
||||
notification?: AgentAttentionNotificationPayload;
|
||||
reason: AgentAttentionReason;
|
||||
serverId: string;
|
||||
workspaceId: string | undefined;
|
||||
agentId: string;
|
||||
assistantMessage: string | null;
|
||||
permissionRequest: NotificationPermissionRequest | null;
|
||||
}
|
||||
|
||||
function resolveAgentAttentionNotification(
|
||||
input: AgentAttentionNotificationInput,
|
||||
): AgentAttentionNotificationPayload | null {
|
||||
if (input.notification) {
|
||||
return input.notification.data.workspaceId ? input.notification : null;
|
||||
}
|
||||
if (!input.workspaceId) {
|
||||
return null;
|
||||
}
|
||||
return buildAgentAttentionNotificationPayload({
|
||||
reason: input.reason,
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
agentId: input.agentId,
|
||||
assistantMessage: input.reason === "finished" ? input.assistantMessage : null,
|
||||
permissionRequest: input.reason === "permission" ? input.permissionRequest : null,
|
||||
});
|
||||
}
|
||||
|
||||
type WorkspaceSetupProgressPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace_setup_progress" }
|
||||
@@ -417,6 +387,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const toast = useToast();
|
||||
|
||||
// Zustand store actions
|
||||
const initializeSession = useSessionStore((state) => state.initializeSession);
|
||||
const clearSession = useSessionStore((state) => state.clearSession);
|
||||
const setIsPlayingAudio = useSessionStore((state) => state.setIsPlayingAudio);
|
||||
const setMessages = useSessionStore((state) => state.setMessages);
|
||||
const setCurrentAssistantMessage = useSessionStore((state) => state.setCurrentAssistantMessage);
|
||||
@@ -438,9 +410,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const setWorkspaces = useSessionStore((state) => state.setWorkspaces);
|
||||
const flushAgentLastActivity = useSessionStore((state) => state.flushAgentLastActivity);
|
||||
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
|
||||
const updateSessionClient = useSessionStore((state) => state.updateSessionClient);
|
||||
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
|
||||
const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync);
|
||||
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
|
||||
const clearWorkspaceSetupServer = useWorkspaceSetupStore((state) => state.clearServer);
|
||||
|
||||
// Track focused agent for heartbeat
|
||||
const focusedAgentId = useSessionStore(
|
||||
@@ -530,20 +504,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const assistantMessage =
|
||||
findLatestAssistantMessageText(head) ?? findLatestAssistantMessageText(tail);
|
||||
const permissionRequest = getLatestPermissionRequest(session, params.agentId);
|
||||
const workspaceId = session?.agents?.get(params.agentId)?.workspaceId;
|
||||
|
||||
const notification = resolveAgentAttentionNotification({
|
||||
notification: params.notification,
|
||||
reason: params.reason,
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId: params.agentId,
|
||||
assistantMessage,
|
||||
permissionRequest,
|
||||
});
|
||||
if (!notification) {
|
||||
return;
|
||||
}
|
||||
const notification =
|
||||
params.notification ??
|
||||
buildAgentAttentionNotificationPayload({
|
||||
reason: params.reason,
|
||||
serverId,
|
||||
agentId: params.agentId,
|
||||
assistantMessage: params.reason === "finished" ? assistantMessage : null,
|
||||
permissionRequest: params.reason === "permission" ? permissionRequest : null,
|
||||
});
|
||||
|
||||
void sendOsNotification({
|
||||
title: notification.title,
|
||||
@@ -554,6 +524,17 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
[serverId],
|
||||
);
|
||||
|
||||
// Initialize session in store
|
||||
useEffect(() => {
|
||||
const generation = getHostRuntimeStore().getSnapshot(serverId)?.clientGeneration ?? 0;
|
||||
initializeSession(serverId, client, generation);
|
||||
}, [serverId, client, initializeSession]);
|
||||
|
||||
useEffect(() => {
|
||||
const generation = getHostRuntimeStore().getSnapshot(serverId)?.clientGeneration ?? 0;
|
||||
updateSessionClient(serverId, client, generation);
|
||||
}, [serverId, client, updateSessionClient]);
|
||||
|
||||
useEffect(() => {
|
||||
const serverInfo = client.getLastServerInfoMessage();
|
||||
if (!serverInfo) {
|
||||
@@ -1346,5 +1327,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
[client],
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearWorkspaceSetupServer(serverId);
|
||||
clearSession(serverId);
|
||||
};
|
||||
}, [clearSession, clearWorkspaceSetupServer, serverId]);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -37,10 +37,10 @@ const CATALOG_DATA = [
|
||||
title: "Auggie CLI",
|
||||
description:
|
||||
"Augment Code's powerful software agent, backed by industry-leading context engine",
|
||||
version: "0.33.0",
|
||||
version: "0.32.0",
|
||||
iconId: "auggie",
|
||||
installLink: "https://www.augmentcode.com/",
|
||||
command: ["npx", "-y", "@augmentcode/auggie@0.33.0", "--acp"],
|
||||
command: ["npx", "-y", "@augmentcode/auggie@0.32.0", "--acp"],
|
||||
env: {
|
||||
AUGMENT_DISABLE_AUTO_UPDATE: "1",
|
||||
},
|
||||
@@ -59,10 +59,10 @@ const CATALOG_DATA = [
|
||||
title: "Cline",
|
||||
description:
|
||||
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
version: "3.0.46",
|
||||
version: "3.0.44",
|
||||
iconId: "cline",
|
||||
installLink: "https://cline.bot/cli",
|
||||
command: ["npx", "-y", "cline@3.0.46", "--acp"],
|
||||
command: ["npx", "-y", "cline@3.0.44", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "codebuddy-code",
|
||||
@@ -122,10 +122,10 @@ const CATALOG_DATA = [
|
||||
id: "deepagents",
|
||||
title: "DeepAgents",
|
||||
description: "Batteries-included AI coding and general purpose agent powered by LangChain.",
|
||||
version: "0.1.20",
|
||||
version: "0.1.19",
|
||||
iconId: "deepagents",
|
||||
installLink: "https://docs.langchain.com/oss/javascript/deepagents/overview",
|
||||
command: ["npx", "-y", "deepagents-acp@0.1.20"],
|
||||
command: ["npx", "-y", "deepagents-acp@0.1.19"],
|
||||
},
|
||||
{
|
||||
id: "devin",
|
||||
@@ -140,29 +140,29 @@ const CATALOG_DATA = [
|
||||
id: "dimcode",
|
||||
title: "DimCode",
|
||||
description: "A coding agent that puts leading models at your command.",
|
||||
version: "0.2.35",
|
||||
version: "0.2.31",
|
||||
iconId: "dimcode",
|
||||
installLink: "https://dimcode.dev/docs/acp.html",
|
||||
command: ["npx", "-y", "dimcode@0.2.35", "acp"],
|
||||
command: ["npx", "-y", "dimcode@0.2.31", "acp"],
|
||||
},
|
||||
{
|
||||
id: "dirac",
|
||||
title: "Dirac",
|
||||
description:
|
||||
"Reduces API costs by more than 50%, produces better and faster work. Uses Hash anchored parallel edits, AST manipulation and a whole lot of neat optimizations. Fully Open Source.",
|
||||
version: "0.4.22",
|
||||
version: "0.4.17",
|
||||
iconId: "dirac",
|
||||
installLink: "https://dirac.run",
|
||||
command: ["npx", "-y", "dirac-cli@0.4.22", "--acp"],
|
||||
command: ["npx", "-y", "dirac-cli@0.4.17", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "factory-droid",
|
||||
title: "Factory Droid",
|
||||
description: "Factory Droid - AI coding agent powered by Factory AI",
|
||||
version: "0.177.0",
|
||||
version: "0.174.0",
|
||||
iconId: "factory-droid",
|
||||
installLink: "https://factory.ai/product/cli",
|
||||
command: ["npx", "-y", "droid@0.177.0", "exec", "--output-format", "acp-daemon"],
|
||||
command: ["npx", "-y", "droid@0.174.0", "exec", "--output-format", "acp-daemon"],
|
||||
env: {
|
||||
DROID_DISABLE_AUTO_UPDATE: "true",
|
||||
FACTORY_DROID_AUTO_UPDATE_ENABLED: "false",
|
||||
@@ -173,10 +173,10 @@ const CATALOG_DATA = [
|
||||
id: "fast-agent",
|
||||
title: "fast-agent",
|
||||
description: "Code and build agents with comprehensive multi-provider support",
|
||||
version: "0.9.21",
|
||||
version: "0.9.14",
|
||||
iconId: "fast-agent",
|
||||
installLink: "https://fast-agent.ai/acp/",
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.9.21", "fast-agent-acp", "-x"],
|
||||
command: ["uvx", "--from", "fast-agent-acp==0.9.14", "fast-agent-acp", "-x"],
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
@@ -192,10 +192,10 @@ const CATALOG_DATA = [
|
||||
title: "GLM Agent",
|
||||
description:
|
||||
"ACP agent powered by Zhipu AI's GLM Coding Plan models (glm-5.1, glm-5-turbo, glm-4.7, glm-4.5-air). Supports streaming, tool calls, mid-session model switching, image input via Z.AI Coding Plan Vision MCP, and session load/fork/resume with on-disk persistence.",
|
||||
version: "1.3.0",
|
||||
version: "1.1.4",
|
||||
iconId: "glm-acp-agent",
|
||||
installLink: "https://github.com/stefandevo/glm-acp-agent",
|
||||
command: ["npx", "-y", "glm-acp-agent@1.3.0"],
|
||||
command: ["npx", "-y", "glm-acp-agent@1.1.4"],
|
||||
},
|
||||
{
|
||||
id: "goose",
|
||||
@@ -284,10 +284,10 @@ const CATALOG_DATA = [
|
||||
id: "nova",
|
||||
title: "Nova",
|
||||
description: "Nova by Compass AI - a fully-fledged software engineer at your command",
|
||||
version: "1.1.29",
|
||||
version: "1.1.27",
|
||||
iconId: "nova",
|
||||
installLink: "https://www.compassap.ai/portfolio/nova.html",
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.29", "acp"],
|
||||
command: ["npx", "-y", "@compass-ai/nova@1.1.27", "acp"],
|
||||
},
|
||||
{
|
||||
id: "poolside",
|
||||
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
|
||||
id: "qoder",
|
||||
title: "Qoder CLI",
|
||||
description: "AI coding assistant with agentic capabilities",
|
||||
version: "1.1.3",
|
||||
version: "1.0.48",
|
||||
iconId: "qoder",
|
||||
installLink: "https://qoder.com",
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.1.3", "--acp"],
|
||||
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.48", "--acp"],
|
||||
},
|
||||
{
|
||||
id: "qwen-code",
|
||||
title: "Qwen Code",
|
||||
description: "Alibaba's Qwen coding assistant",
|
||||
version: "0.20.1",
|
||||
version: "0.19.11",
|
||||
iconId: "qwen-code",
|
||||
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
|
||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.20.1", "--acp", "--experimental-skills"],
|
||||
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.11", "--acp", "--experimental-skills"],
|
||||
},
|
||||
{
|
||||
id: "sigit",
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { listenToDesktopEvent } from "@/desktop/electron/events";
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
|
||||
interface OpenAgentEventPayload {
|
||||
serverId?: unknown;
|
||||
agentId?: unknown;
|
||||
}
|
||||
|
||||
export function AgentNavigationListener() {
|
||||
const openAgent = useStableEvent((payload: OpenAgentEventPayload | null) => {
|
||||
const serverId = typeof payload?.serverId === "string" ? payload.serverId.trim() : "";
|
||||
const agentId = typeof payload?.agentId === "string" ? payload.agentId.trim() : "";
|
||||
if (!serverId || !agentId) {
|
||||
return;
|
||||
}
|
||||
navigateToAgent({ serverId, agentId });
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const host = getDesktopHost();
|
||||
const ready = host?.agentNavigation?.ready;
|
||||
if (typeof host?.events?.on !== "function" || typeof ready !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let unlisten: (() => void) | null = null;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const connect = async () => {
|
||||
let dispose: (() => void) | null = null;
|
||||
try {
|
||||
dispose = await listenToDesktopEvent<OpenAgentEventPayload>("open-agent", openAgent);
|
||||
if (disposed) {
|
||||
dispose();
|
||||
return;
|
||||
}
|
||||
unlisten = dispose;
|
||||
const pending = await ready();
|
||||
if (!disposed && pending) {
|
||||
openAgent(pending);
|
||||
}
|
||||
} catch {
|
||||
dispose?.();
|
||||
if (unlisten === dispose) {
|
||||
unlisten = null;
|
||||
}
|
||||
if (!disposed) {
|
||||
retryTimer = setTimeout(() => void connect(), 1_000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void connect();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer);
|
||||
}
|
||||
unlisten?.();
|
||||
};
|
||||
}, [openAgent]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
type SkillsStatus,
|
||||
} from "@/desktop/daemon/desktop-daemon";
|
||||
import { useCliInstall, useSkillsStatus } from "@/desktop/hooks/use-install-status";
|
||||
import { conductorImportSource } from "@/desktop/imports/conductor";
|
||||
import { ExternalImport } from "@/desktop/imports/external-import";
|
||||
|
||||
const CLI_DOCS_URL = "https://paseo.sh/docs/cli";
|
||||
const SKILLS_DOCS_URL = "https://paseo.sh/docs/skills";
|
||||
@@ -201,6 +203,7 @@ export function IntegrationsSection() {
|
||||
onUninstall={handleUninstallSkills}
|
||||
/>
|
||||
</View>
|
||||
<ExternalImport source={conductorImportSource} />
|
||||
</View>
|
||||
</SettingsSection>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,11 @@ interface DaemonStatusData {
|
||||
logs: DesktopDaemonLogs;
|
||||
}
|
||||
|
||||
export function useDaemonStatus() {
|
||||
interface UseDaemonStatusOptions {
|
||||
refetchOnMount?: boolean | "always";
|
||||
}
|
||||
|
||||
export function useDaemonStatus(options: UseDaemonStatusOptions = {}) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const enabled = shouldUseDesktopDaemon();
|
||||
@@ -26,7 +30,7 @@ export function useDaemonStatus() {
|
||||
queryKey: DAEMON_STATUS_QUERY_KEY,
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
refetchOnMount: "always",
|
||||
refetchOnMount: options.refetchOnMount ?? "always",
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
const [status, logs] = await Promise.all([getDesktopDaemonStatus(), getDesktopDaemonLogs()]);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import { router } from "expo-router";
|
||||
import { getIsElectron } from "@/constants/platform";
|
||||
import { useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { useDaemonStatus } from "@/desktop/hooks/use-daemon-status";
|
||||
import { useBuiltInDaemonManagement } from "@/desktop/hooks/use-built-in-daemon-management";
|
||||
@@ -15,8 +13,6 @@ export interface EnableBuiltInDaemonOption {
|
||||
|
||||
export function useEnableBuiltInDaemonOption(): EnableBuiltInDaemonOption {
|
||||
const isElectron = getIsElectron();
|
||||
const localDaemon = useLocalDaemonServerIdState();
|
||||
const hosts = useHosts();
|
||||
const { settings, updateSettings } = useDesktopSettings();
|
||||
const { data, setStatus, refetch } = useDaemonStatus();
|
||||
const { enable } = useBuiltInDaemonManagement({
|
||||
@@ -27,11 +23,7 @@ export function useEnableBuiltInDaemonOption(): EnableBuiltInDaemonOption {
|
||||
refreshStatus: refetch,
|
||||
});
|
||||
|
||||
const isLocalhostConfigured =
|
||||
localDaemon.status === "resolved" &&
|
||||
localDaemon.serverId !== null &&
|
||||
hosts.some((host) => host.serverId === localDaemon.serverId);
|
||||
const visible = isElectron && localDaemon.status === "resolved" && !isLocalhostConfigured;
|
||||
const visible = isElectron && data?.status.desktopManaged === false;
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
void (async () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user