mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 18:52:39 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4092c29265 | ||
|
|
5cedc210a8 | ||
|
|
4724d1d9b7 | ||
|
|
8fdca94ead | ||
|
|
88f410567d | ||
|
|
f998ee1691 | ||
|
|
7a47ec0ee9 | ||
|
|
4f796181bf | ||
|
|
f517493591 | ||
|
|
fb74f766c8 | ||
|
|
e268d1a894 | ||
|
|
b5f5832214 | ||
|
|
b8a31034ca | ||
|
|
68275f1e0f | ||
|
|
3979371901 | ||
|
|
71e375c2bc | ||
|
|
35cde075ae | ||
|
|
2124115c8e | ||
|
|
4094eeb566 | ||
|
|
8d92e1cc17 | ||
|
|
5123b23f41 | ||
|
|
c3594b2079 | ||
|
|
dc9fb9aa1b | ||
|
|
7c430777bf | ||
|
|
acad9f6881 | ||
|
|
fc214b20f1 | ||
|
|
c998b24c5f | ||
|
|
28df3f259a | ||
|
|
cc2012be7c | ||
|
|
b139046a3e | ||
|
|
482bbb2910 | ||
|
|
4a5a8d469c | ||
|
|
c60fa098ac | ||
|
|
33cf6cf337 | ||
|
|
65fbe38dd3 | ||
|
|
a69dca2907 | ||
|
|
3029b5991a | ||
|
|
b1455fb4b3 | ||
|
|
b4e0ff7685 | ||
|
|
4c6b79dc44 | ||
|
|
a687c42f33 | ||
|
|
7beff9cb0b | ||
|
|
c763b80824 | ||
|
|
8905ad4160 | ||
|
|
60a5344cc6 | ||
|
|
8f0c35e924 |
@@ -52,7 +52,7 @@ jobs:
|
|||||||
filters: .github/ci-paths.yml
|
filters: .github/ci-paths.yml
|
||||||
|
|
||||||
- name: Validate CI contracts
|
- name: Validate CI contracts
|
||||||
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs scripts/sync-fdroid-changelogs.test.mjs
|
run: node --test scripts/ci-workflow.test.mjs scripts/daemon-launch-contract.test.mjs
|
||||||
|
|
||||||
format:
|
format:
|
||||||
name: format
|
name: format
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.5.2 - 2026-08-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed workspaces crashing on Android tablets and foldables in non-compact layouts ([#3760](https://github.com/getpaseo/paseo/pull/3760) by [@jeid64](https://github.com/jeid64))
|
||||||
|
- Fixed app settings resetting when builds sharing a profile recognize different settings ([#3787](https://github.com/getpaseo/paseo/pull/3787))
|
||||||
|
|
||||||
## 0.5.1 - 2026-08-23
|
## 0.5.1 - 2026-08-23
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -21,47 +21,46 @@ This is an npm workspace monorepo:
|
|||||||
|
|
||||||
At the start of non-trivial work, list `docs/` and skim anything relevant to the task.
|
At the start of non-trivial work, list `docs/` and skim anything relevant to the task.
|
||||||
|
|
||||||
| Doc | What's in it |
|
| Doc | What's in it |
|
||||||
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
||||||
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||||
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
|
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
|
||||||
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
|
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
|
||||||
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
||||||
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||||
| [docs/design.md](docs/design.md) | Design system — tokens, buttons, hierarchy, density, alignment rails, states, what's forbidden |
|
| [docs/design.md](docs/design.md) | Design system — tokens, buttons, hierarchy, density, alignment rails, states, what's forbidden |
|
||||||
| [docs/forms.md](docs/forms.md) | Form architecture — non-React form model, form kit, load-state gating; the schedule form is the golden example |
|
| [docs/forms.md](docs/forms.md) | Form architecture — non-React form model, form kit, load-state gating; the schedule form is the golden example |
|
||||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
||||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||||
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
||||||
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
|
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
|
||||||
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
|
||||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||||
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
|
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
|
||||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||||
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
|
| [docs/plugins.md](docs/plugins.md) | Local plugin manifest, directory source config, RPCs, native surfaces, and attachment sources |
|
||||||
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
|
||||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||||
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
|
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
|
||||||
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
|
||||||
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
|
||||||
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
|
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
|
||||||
| [docs/file-observation.md](docs/file-observation.md) | Recursive watcher ownership, Linux constraints, teardown invariants, and Parcel comparison |
|
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
||||||
| [docs/qa.md](docs/qa.md) | QA evidence bar for pull requests — platform matrix, version drift, performance, UI proof |
|
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
||||||
| [docs/mobile-panels.md](docs/mobile-panels.md) | Compact left/center/right panel ownership, worklet motion, gesture revisions, and Fabric constraints |
|
| [docs/side-panel.md](docs/side-panel.md) | The Side panel surface — its three renderings, tab placement intents, pane lifecycle, and the routing preference |
|
||||||
| [docs/side-panel.md](docs/side-panel.md) | The Side panel surface — its three renderings, tab placement intents, pane lifecycle, and the routing preference |
|
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
||||||
| [docs/browser-capture-harness.md](docs/browser-capture-harness.md) | Real-Electron browser screenshot harness and compositor-surface gotcha |
|
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows, version codes, F-Droid source builds and store metadata |
|
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
||||||
| [docs/docker.md](docs/docker.md) | Running the daemon and bundled web UI in Docker, volumes, agent images, security |
|
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
||||||
| [docs/terminal-activity.md](docs/terminal-activity.md) | Terminal activity indicators — source-agnostic tracker, agent hook reporting, adding a new hook provider |
|
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
| [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority |
|
||||||
| [public-docs/hub/security.md](public-docs/hub/security.md) | Public Hub guide — trust boundaries, untrusted triggers, provider controls, and output authority |
|
|
||||||
|
|
||||||
### Writing docs
|
### Writing docs
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
# Agent stream performance
|
|
||||||
|
|
||||||
How assistant text gets from a provider to the screen, and why it is paced on the way. Read this before changing `packages/server/src/server/agent/agent-stream-coalescer.ts`, the reducer queue in `packages/app/src/timeline/session-stream-reducers.ts`, or the reveal in `packages/app/src/hooks/use-revealed-text.ts`.
|
|
||||||
|
|
||||||
For terminal output, which is a separate pipeline with separate budgets, see [terminal-performance.md](terminal-performance.md).
|
|
||||||
|
|
||||||
## The pipeline
|
|
||||||
|
|
||||||
```
|
|
||||||
provider deltas (every provider streams incrementally)
|
|
||||||
→ AgentStreamCoalescer (daemon, leading + trailing, ≤1 message per 60ms per agent)
|
|
||||||
→ recordTimeline: one canonical row per flushed item
|
|
||||||
→ agent_stream ws message
|
|
||||||
→ reducer queue (app, one commit per frame) → session store
|
|
||||||
→ paced reveal (app, per assistant/reasoning item) → markdown blocks → paint
|
|
||||||
```
|
|
||||||
|
|
||||||
Every provider delivers incremental text, so there is no provider that needs special handling: Claude via `includePartialMessages`, Codex via `agent_message_delta`, ACP agents via `agent_message_chunk`, Pi and OMP via `text_delta`.
|
|
||||||
|
|
||||||
## Why the reveal is paced
|
|
||||||
|
|
||||||
Arrival is lumpy and there is no fixing that at the source. A 60ms coalescing window carries however many characters the model produced in those 60ms, which swings by an order of magnitude within a single turn. Painting each delta as it lands makes the size of those lumps visible, and that is what reads as jagged.
|
|
||||||
|
|
||||||
So arrival sets a _target_ and the reveal rate is derived from the backlog instead. A burst makes the text catch up faster; it does not make the text jump. Shrinking the coalescing window does not fix this — it makes the lumps smaller and more frequent, at the cost of message rate on a daemon loop that already contends with terminal frames and per-message relay encryption.
|
|
||||||
|
|
||||||
## Invariants
|
|
||||||
|
|
||||||
- **The coalescer is leading + trailing.** The first delta after an idle window flushes synchronously; only the rest of the burst waits for the trailing timer. Reverting to trailing-only adds a full window to the first character of every turn. Same shape and the same reason as `TerminalOutputCoalescer`.
|
|
||||||
- **The leading flush adds a canonical row, and that is fine.** A burst's first chunk lands as its own timeline row. `mergeAssistantChunks` / `mergeReasoningChunks` in `timeline-projection.ts` join contiguous same-turn rows, and clients read the projected timeline, so history is unaffected. Tests that assert on raw rows have to account for the extra row; tests that assert on what a client sees do not.
|
|
||||||
- **The store holds the full text; only the rendered slice is paced.** Copy, selection, the chat outline, and scroll geometry all read the same string the user can see. Pacing the store instead would leave the bottom anchor chasing a content height that is ahead of the reveal.
|
|
||||||
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
|
|
||||||
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
|
|
||||||
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
|
|
||||||
|
|
||||||
## Measuring
|
|
||||||
|
|
||||||
- **Smoothness (user-perceived):** `packages/app/e2e/browser/agent-stream-smoothness.spec.ts`, gated behind `PASEO_AGENT_STREAM_PERF_E2E=1`. Drives the mock provider's `bursty-stream` model and reports coefficient of variation of characters painted per frame (smoothness) plus p95 gap between visible updates (stalls). Both numbers are needed: a stalled stream is perfectly smooth.
|
|
||||||
- **Reproducing bursty arrival:** the `bursty-stream` model in `mock-load-test-agent.ts` emits uneven runs of tokens separated by idle gaps. Burst sizes come from a seeded generator, so a run repeats exactly.
|
|
||||||
- **Rate policy in isolation:** `computeRevealStep` in `packages/app/src/agent-stream/text-reveal.ts` is pure; `text-reveal.test.ts` covers convergence and burst flattening without a renderer.
|
|
||||||
|
|
||||||
Healthy numbers (2026-08, Expo web against a local dev daemon, real Claude Haiku agent, ~8.5s samples during active streaming). Setting `TEXT_REVEAL_HORIZON_MS` to 0 makes the reveal paint on arrival, which is how the baseline column was taken:
|
|
||||||
|
|
||||||
| | paint on arrival | paced |
|
|
||||||
| -------------------------------- | ---------------- | ----- |
|
|
||||||
| frames that advanced the text | 6% | 87% |
|
|
||||||
| chars-per-frame CV | 4.11 | 1.86 |
|
|
||||||
| gap between visible updates, p50 | 317ms | 17ms |
|
|
||||||
| gap between visible updates, p95 | 383ms | 17ms |
|
|
||||||
|
|
||||||
Total characters painted is roughly the same either way — the reveal changes when they land, not how many arrive.
|
|
||||||
|
|
||||||
Measure the **total** length across every `assistant-message` element, not the last one. A turn emits many assistant messages, so the tail element keeps changing identity and its length is not monotonic; sampling only the tail reads those handovers as resets and reports almost no growth. `sampleStreamFrames` does this correctly.
|
|
||||||
+1
-34
@@ -15,9 +15,7 @@ EAS profiles: `development`, `production`, and `production-apk` in `packages/app
|
|||||||
|
|
||||||
## Version codes
|
## Version codes
|
||||||
|
|
||||||
`packages/app/native-release-version.js` is the single definition of native and F-Droid version-code math. Do not re-derive these numbers anywhere else — a drifted copy produces changelog files that match no published APK, and nothing fails loudly.
|
`packages/app/app.config.js` derives Android `versionCode` from the package version with:
|
||||||
|
|
||||||
The base version code comes from the package version:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
major * 1_000_000 + minor * 1_000 + patch
|
major * 1_000_000 + minor * 1_000 + patch
|
||||||
@@ -147,37 +145,6 @@ Keep the excluded npm packages installed. Normal builds use them, while the F-Dr
|
|||||||
|
|
||||||
The EAS `production-apk` profile uses the large Android resource class. Release builds compile the native ABIs and run Hermes bundling in the same Gradle invocation; the default worker can exhaust its remaining memory and kill Hermes with exit code 137 even when Gradle's own heap is correctly sized.
|
The EAS `production-apk` profile uses the large Android resource class. Release builds compile the native ABIs and run Hermes bundling in the same Gradle invocation; the default worker can exhaust its remaining memory and kill Hermes with exit code 137 even when Gradle's own heap is correctly sized.
|
||||||
|
|
||||||
### F-Droid store metadata
|
|
||||||
|
|
||||||
F-Droid reads the store listing from `fastlane/metadata/android/<locale>/` **at the repo root**. This location provides the best compatibility with the F-Droid release process.
|
|
||||||
|
|
||||||
```text
|
|
||||||
fastlane/metadata/android/
|
|
||||||
├── en-US/ (F-Droid fallback locale, mandatory)
|
|
||||||
│ ├── title.txt (<=50 chars)
|
|
||||||
│ ├── short_description.txt (<=80 chars)
|
|
||||||
│ ├── full_description.txt (<=4000 chars, limited HTML)
|
|
||||||
│ ├── images/
|
|
||||||
│ │ ├── icon.png (512x512)
|
|
||||||
│ │ ├── featureGraphic.png (1024x500)
|
|
||||||
│ │ └── phoneScreenshots/ (1.png, 2.png, ...)
|
|
||||||
│ └── changelogs/ (generated — see below)
|
|
||||||
├── ja/
|
|
||||||
└── zh-CN/
|
|
||||||
```
|
|
||||||
|
|
||||||
Locale directories generally match `packages/app/src/i18n/locales.ts`, but note that `en` becomes `en-US`.
|
|
||||||
|
|
||||||
F-Droid changelogs are generated from `CHANGELOG.md`. Run `npm run fdroid:changelogs`; `npm run fdroid:changelogs:check` verifies without writing. It is wired into the npm `version` lifecycle, so a release picks it up automatically and `git add -A` stages the result.
|
|
||||||
|
|
||||||
One changelog must be generated per-ABI-split, so each version will create **four** identical version-coded entries. F-Droid caps changelogs at 500 characters, so the generator strips some content and adds a link to the full notes.
|
|
||||||
|
|
||||||
Stable sync fails loudly if `CHANGELOG.md` has no entry for the version being cut. That is intentional — the release checklist requires the entry to be committed first, so an abort here means the checklist was skipped.
|
|
||||||
|
|
||||||
Because the generator runs off the version in `package.json`, it must run **before** the tag is created: fdroidserver only reads metadata from the tag it builds, so the file for version N has to exist in the commit N points at.
|
|
||||||
|
|
||||||
Beta releases are an explicit no-op: they do not create or rewrite F-Droid changelog files. Stable releases and promotions generate the four ABI entries from their final changelog.
|
|
||||||
|
|
||||||
### React version lockstep
|
### React version lockstep
|
||||||
|
|
||||||
Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer` `19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen.
|
Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer` `19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen.
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ Cross-platform React Native app that connects to one or more daemons.
|
|||||||
|
|
||||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
|
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
|
||||||
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
|
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
|
||||||
- `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in a row store. It restores before navigation becomes ready and leaves remote hydration flags false.
|
- `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in AsyncStorage. It restores before navigation becomes ready and leaves remote hydration flags false.
|
||||||
- `runtime/directory-sync` owns directory reconciliation. On reconnect it passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
|
- `runtime/directory-sync` owns directory reconciliation. On reconnect it passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
|
||||||
- `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.
|
- `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.
|
||||||
- `SessionContext` wraps the daemon client for the active session
|
- `SessionContext` wraps the daemon client for the active session
|
||||||
@@ -115,15 +115,12 @@ Cross-platform React Native app that connects to one or more daemons.
|
|||||||
|
|
||||||
The replica cache paints stale data immediately while the host connects. Directory cursors are
|
The replica cache paints stale data immediately while the host connects. Directory cursors are
|
||||||
reconciliation checkpoints; cached entities remain non-authoritative until the daemon answers.
|
reconciliation checkpoints; cached entities remain non-authoritative until the daemon answers.
|
||||||
Pending permission requests are not restored from it. The row store is not encrypted, so the cached
|
Pending permission requests are not restored from it. AsyncStorage is not encrypted, so the cached
|
||||||
timeline tail may contain source code, prompts, and tool output; encrypted-at-rest storage is a
|
timeline tail may contain source code, prompts, and tool output; encrypted-at-rest storage is a
|
||||||
separate product/security decision. Agents, workspaces, and projects have individual rows; the
|
separate product/security decision. Its serialized payload has a 32 MiB byte budget and evicts whole
|
||||||
focused timeline tail and directory checkpoint each have one row per host. Writes serialize only
|
host snapshots in least-recently-written order; a single oversized host is omitted rather than
|
||||||
rows whose in-memory identities changed. Restore validates every row before hydration; one invalid
|
partially restored. Browser and Electron builds store it in IndexedDB. Native builds use
|
||||||
row clears the cache and starts cold. The 32 MiB byte budget is tracked incrementally from row
|
AsyncStorage, and Android reserves 64 MiB for that database.
|
||||||
payload lengths and evicts whole hosts in least-recently-written order. After restart, that order is
|
|
||||||
an approximation rebuilt from the row store's `readAll()` order. Browser and Electron builds use
|
|
||||||
IndexedDB. Native builds use expo-sqlite.
|
|
||||||
|
|
||||||
The three directory entity types have independent monotonic sequences and share one daemon
|
The three directory entity types have independent monotonic sequences and share one daemon
|
||||||
generation. The daemon retains only the latest projection per entity and bounded tombstones, not an
|
generation. The daemon retains only the latest projection per entity and bounded tombstones, not an
|
||||||
|
|||||||
+3
-33
@@ -26,9 +26,8 @@ For forge `acme`, the expected end state is:
|
|||||||
3. **App modules** - a forge splits into a pure logic half and a view half so
|
3. **App modules** - a forge splits into a pure logic half and a view half so
|
||||||
logic consumers (URL builders, merge-capability, native checks, and the
|
logic consumers (URL builders, merge-capability, native checks, and the
|
||||||
Node-based e2e harness) never pull the client rendering stack:
|
Node-based e2e harness) never pull the client rendering stack:
|
||||||
- `packages/app/src/git/forges/acme.ts` - logic: `id`, optional `urlGrammar`
|
- `packages/app/src/git/forges/acme.ts` - logic: `id`, optional `urlGrammar`,
|
||||||
(source links and pasted issue/change-request reference paths), optional
|
optional `facts` (schema, merge-capability, native-check fallbacks). No
|
||||||
`facts` (schema, merge-capability, native-check fallbacks). No
|
|
||||||
React/React-Native imports. Register in `CLIENT_FORGE_LOGIC_MODULES` in
|
React/React-Native imports. Register in `CLIENT_FORGE_LOGIC_MODULES` in
|
||||||
`packages/app/src/git/forges/index.ts`.
|
`packages/app/src/git/forges/index.ts`.
|
||||||
- `packages/app/src/git/forges/acme.view.tsx` - view: `icon` (SVG component
|
- `packages/app/src/git/forges/acme.view.tsx` - view: `icon` (SVG component
|
||||||
@@ -58,20 +57,8 @@ schema-mismatched facts render neutrally instead of failing the whole message
|
|||||||
parse. This is the version-skew win: an old client can receive facts from a
|
parse. This is the version-skew win: an old client can receive facts from a
|
||||||
newer forge and still show the PR/MR in a neutral state.
|
newer forge and still show the PR/MR in a neutral state.
|
||||||
|
|
||||||
Check results keep a stable normalized `status` for aggregation and an optional
|
|
||||||
open `traits: string[]` for composable forge-neutral refinements such as
|
|
||||||
`manual`, `action_required`, and `warning`. Consumers ignore unknown traits and
|
|
||||||
fall back to `status`; `rawStatus` preserves provider-native detail. Do not add
|
|
||||||
provider vocabulary to the normalized status union. Producers and the
|
|
||||||
presentation layer share the trait constants in
|
|
||||||
`packages/protocol/src/check-traits.ts` — use those instead of string literals
|
|
||||||
so classification and emission cannot drift apart.
|
|
||||||
|
|
||||||
Shipped GitHub compatibility stays separate:
|
Shipped GitHub compatibility stays separate:
|
||||||
|
|
||||||
- The forge compatibility layer first shipped in `v0.2.0-beta.1`. Its initial
|
|
||||||
six-month cleanup target is `2027-01-17`, after supported client and daemon
|
|
||||||
floors reach stable `v0.2.0`.
|
|
||||||
- `status.github` remains accepted for released peers.
|
- `status.github` remains accepted for released peers.
|
||||||
- The server keeps the `COMPAT(forgeSpecific)` mirror that copies GitHub facts
|
- The server keeps the `COMPAT(forgeSpecific)` mirror that copies GitHub facts
|
||||||
into `status.github` for older clients.
|
into `status.github` for older clients.
|
||||||
@@ -125,7 +112,7 @@ rendering stack:
|
|||||||
`acme.ts` exports a `ClientForgeLogicModule`:
|
`acme.ts` exports a `ClientForgeLogicModule`:
|
||||||
|
|
||||||
- `id`
|
- `id`
|
||||||
- optional `urlGrammar` (including `referencePaths` for composer auto-attachment)
|
- optional `urlGrammar`
|
||||||
- optional `facts` registration (schema, merge-capability, native-check fallbacks)
|
- optional `facts` registration (schema, merge-capability, native-check fallbacks)
|
||||||
|
|
||||||
`acme.view.tsx` exports a `ClientForgeViewModule`:
|
`acme.view.tsx` exports a `ClientForgeViewModule`:
|
||||||
@@ -194,27 +181,10 @@ are stale, run `npm run build:server`.
|
|||||||
real shims tagged with `COMPAT(name)`.
|
real shims tagged with `COMPAT(name)`.
|
||||||
- Gitea-family facts use `forgeSpecific.forge === "gitea"` even when the
|
- Gitea-family facts use `forgeSpecific.forge === "gitea"` even when the
|
||||||
top-level brand is Forgejo or Codeberg.
|
top-level brand is Forgejo or Codeberg.
|
||||||
- Gitea's legacy Actions task endpoint cannot filter by commit, may enforce a
|
|
||||||
page size below the requested limit, and omits rerun-attempt numbers. Walk a
|
|
||||||
bounded number of pages using `total_count` and filter locally. Preserve
|
|
||||||
ambiguous same-run tasks: display names are not stable job identities, so
|
|
||||||
name-based deduplication can hide a real failure.
|
|
||||||
- A blocked Gitea Actions task is not necessarily manual: only add the
|
|
||||||
`action_required` trait when the matching run metadata reports
|
|
||||||
`need_approval`.
|
|
||||||
- Brand icons are bundled React components, so they cannot come from protocol
|
- Brand icons are bundled React components, so they cannot come from protocol
|
||||||
manifest data.
|
manifest data.
|
||||||
- Source URL grammars are app-side because blob/tree path syntax is
|
- Source URL grammars are app-side because blob/tree path syntax is
|
||||||
forge-specific. If a forge has no grammar, omit the "Open on ..." source link
|
forge-specific. If a forge has no grammar, omit the "Open on ..." source link
|
||||||
rather than constructing a wrong URL.
|
rather than constructing a wrong URL.
|
||||||
- Pasted issue/change-request URL recognition is also app-side and registry
|
|
||||||
driven. Put the forge's route infixes in `urlGrammar.referencePaths`; the
|
|
||||||
neutral extractor matches the pasted host and full repository path against
|
|
||||||
the current remote before it invokes cwd-routed forge search. Do not add a
|
|
||||||
central forge switch or guess a self-hosted Gitea-family brand from URL shape.
|
|
||||||
- GitLab pipeline status constants belong to the GitLab adapter/client module,
|
- GitLab pipeline status constants belong to the GitLab adapter/client module,
|
||||||
not protocol.
|
not protocol.
|
||||||
- GitLab `manual` jobs need `allow_failure` to distinguish optional gray jobs
|
|
||||||
(`manual`) from blocking jobs (`manual` plus `action_required`). A failed
|
|
||||||
allowed-failure job carries the `warning` trait, and `canceling` remains active
|
|
||||||
until GitLab reports a terminal status.
|
|
||||||
|
|||||||
@@ -436,7 +436,6 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
||||||
- The npm `version` lifecycle regenerates F-Droid changelog files from `CHANGELOG.md` for stable releases only (`npm run fdroid:changelogs`) and stages them, so the release tag carries them. Betas are a no-op. A stable run **aborts the release** if `CHANGELOG.md` has no entry for the version being cut — commit the changelog entry first. See [docs/android.md](android.md) for why these files are generated per ABI.
|
|
||||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||||
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||||
|
|||||||
@@ -43,6 +43,6 @@ Terminal frames share the daemon main event loop with all agent traffic. The `ev
|
|||||||
|
|
||||||
## Known remaining contention (follow-up candidates)
|
## Known remaining contention (follow-up candidates)
|
||||||
|
|
||||||
- A single large `agent_stream` message (e.g. a 250KB diff payload) measurably delays terminal echo (~100ms-class dips) — cost is split between daemon serialization and app-side parse/render on the shared browser main thread. See [agent-stream-performance.md](agent-stream-performance.md) for that pipeline's own budgets.
|
- A single large `agent_stream` message (e.g. a 250KB diff payload) measurably delays terminal echo (~100ms-class dips) — cost is split between daemon serialization and app-side parse/render on the shared browser main thread.
|
||||||
- Relay-attached clients pay pure-JS tweetnacl encryption on the daemon main loop (`packages/relay/src/encrypted-channel.ts`). Negotiated binary application frames stay binary ciphertext and avoid base64 encode/decode; text and mixed-version traffic remain base64 WebSocket text frames.
|
- Relay-attached clients pay pure-JS tweetnacl encryption on the daemon main loop (`packages/relay/src/encrypted-channel.ts`). Negotiated binary application frames stay binary ciphertext and avoid base64 encode/decode; text and mixed-version traffic remain base64 WebSocket text frames.
|
||||||
- `sendToClient` re-stringifies session messages per socket; only matters for multi-socket connections.
|
- `sendToClient` re-stringifies session messages per socket; only matters for multi-socket connections.
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
Paseo is a client for AI coding agents that run on your own machine. Your dev environment, in your pocket.
|
|
||||||
|
|
||||||
Paseo runs a local server called the daemon on your computer. It manages your coding agents and streams their output in real time. This app connects to that daemon, so your code never leaves your machine and there is no cloud service in between.
|
|
||||||
|
|
||||||
<b>Supported agents</b>
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li>Claude Code</li>
|
|
||||||
<li>Codex</li>
|
|
||||||
<li>GitHub Copilot</li>
|
|
||||||
<li>OpenCode</li>
|
|
||||||
<li>Pi</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
You bring your own provider credentials and use your existing subscriptions. Paseo adds no markup and no inference costs of its own.
|
|
||||||
|
|
||||||
<b>What you can do</b>
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li>Start agents, watch them work, and answer their questions from your phone</li>
|
|
||||||
<li>Run several agents in parallel across projects and git worktrees</li>
|
|
||||||
<li>Review diffs, browse commit history, and edit files</li>
|
|
||||||
<li>Work with pull requests from GitHub, GitLab, Gitea, Forgejo, and Codeberg</li>
|
|
||||||
<li>Open a terminal in any workspace</li>
|
|
||||||
<li>Dictate prompts or talk through a problem in voice mode</li>
|
|
||||||
<li>Schedule agents to run on a recurring basis</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<b>Privacy</b>
|
|
||||||
|
|
||||||
No telemetry, no tracking, no account required. The app talks only to daemons you point it at. Remote access goes through an end-to-end encrypted relay that cannot read your traffic, or over your own network or VPN if you prefer.
|
|
||||||
|
|
||||||
<b>You need a daemon to connect to</b>
|
|
||||||
|
|
||||||
This app is not useful on its own. Install the Paseo daemon on the computer where your code and agent CLIs live, then pair this app with it from Settings. The desktop app bundles the daemon, or you can run it from npm, Docker, or Nix.
|
|
||||||
|
|
||||||
Setup instructions: https://paseo.sh
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB |
@@ -1 +0,0 @@
|
|||||||
../../../../../../packages/website/public/phone-1.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../../../../../packages/website/public/phone-2.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../../../../../packages/website/public/phone-3.png
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Monitor and control your local AI coding agents from anywhere
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Paseo
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
Paseo は、自分のマシン上で動作する AI コーディングエージェントのクライアントです。あなたの開発環境を、ポケットの中に。
|
|
||||||
|
|
||||||
Paseo はデーモンと呼ばれるローカルサーバーをあなたのコンピューター上で実行します。デーモンがコーディングエージェントを管理し、その出力をリアルタイムに配信します。このアプリはそのデーモンに接続するため、コードがマシンの外に出ることはなく、間にクラウドサービスも入りません。
|
|
||||||
|
|
||||||
<b>対応エージェント</b>
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li>Claude Code</li>
|
|
||||||
<li>Codex</li>
|
|
||||||
<li>GitHub Copilot</li>
|
|
||||||
<li>OpenCode</li>
|
|
||||||
<li>Pi</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
プロバイダーの認証情報はご自身のものを使用し、既存のサブスクリプションをそのまま活用できます。Paseo が独自の手数料や推論コストを追加することはありません。
|
|
||||||
|
|
||||||
<b>できること</b>
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li>スマートフォンからエージェントを起動し、作業を確認し、質問に回答する</li>
|
|
||||||
<li>プロジェクトや git worktree をまたいで複数のエージェントを並列実行する</li>
|
|
||||||
<li>差分のレビュー、コミット履歴の閲覧、ファイルの編集</li>
|
|
||||||
<li>GitHub、GitLab、Gitea、Forgejo、Codeberg のプルリクエストを扱う</li>
|
|
||||||
<li>任意のワークスペースでターミナルを開く</li>
|
|
||||||
<li>音声でプロンプトを入力したり、音声モードで問題を相談する</li>
|
|
||||||
<li>エージェントを定期実行するようスケジュールする</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<b>プライバシー</b>
|
|
||||||
|
|
||||||
テレメトリー、トラッキング、アカウント登録は一切ありません。アプリは、あなたが指定したデーモンにのみ通信します。リモートアクセスは通信内容を読み取れないエンドツーエンド暗号化リレーを経由するか、ご自身のネットワークや VPN を利用できます。
|
|
||||||
|
|
||||||
<b>接続先のデーモンが必要です</b>
|
|
||||||
|
|
||||||
このアプリは単体では機能しません。コードとエージェント CLI があるコンピューターに Paseo デーモンをインストールし、設定画面からこのアプリをペアリングしてください。デスクトップアプリにはデーモンが同梱されています。npm、Docker、Nix から実行することもできます。
|
|
||||||
|
|
||||||
セットアップ手順: https://paseo.sh
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ローカルのAIコーディングエージェントをどこからでも操作
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
Paseo 是运行在你自己机器上的 AI 编程 agents 的客户端。把你的开发环境装进口袋。
|
|
||||||
|
|
||||||
Paseo 会在你的电脑上运行一个名为 daemon 的本地服务,由它管理编程 agents 并实时推送输出。本应用连接到该 daemon,因此你的代码不会离开你的机器,中间也没有任何云服务。
|
|
||||||
|
|
||||||
<b>支持的 agents</b>
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li>Claude Code</li>
|
|
||||||
<li>Codex</li>
|
|
||||||
<li>GitHub Copilot</li>
|
|
||||||
<li>OpenCode</li>
|
|
||||||
<li>Pi</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
你使用自己的提供商凭据和现有订阅。Paseo 不加价,也不产生自己的推理费用。
|
|
||||||
|
|
||||||
<b>你可以做什么</b>
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li>在手机上启动 agents、查看进展并回答它们的提问</li>
|
|
||||||
<li>跨项目和 git worktree 并行运行多个 agents</li>
|
|
||||||
<li>审查差异、浏览提交历史、编辑文件</li>
|
|
||||||
<li>处理来自 GitHub、GitLab、Gitea、Forgejo 和 Codeberg 的合并请求</li>
|
|
||||||
<li>在任意工作区打开终端</li>
|
|
||||||
<li>用语音输入提示,或在语音模式下讨论问题</li>
|
|
||||||
<li>将 agents 设置为定期运行</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<b>隐私</b>
|
|
||||||
|
|
||||||
没有遥测、没有追踪、无需账号。应用只与你指定的 daemon 通信。远程访问通过无法读取你流量的端到端加密中继,或者你也可以使用自己的网络或 VPN。
|
|
||||||
|
|
||||||
<b>你需要一个可连接的 daemon</b>
|
|
||||||
|
|
||||||
本应用无法独立使用。请在存放代码和 agent CLI 的电脑上安装 Paseo daemon,然后在设置中将本应用与其配对。桌面应用已内置 daemon,你也可以通过 npm、Docker 或 Nix 运行。
|
|
||||||
|
|
||||||
安装说明:https://paseo.sh
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
随时随地监控和控制本地的 AI 编程 agents
|
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-13Q44pEU+V2M/wnitVAlBarYxAqWkGkXrE+w5qUCf7o=
|
sha256-o8d/nX0FdM6QB7c/RWpo1USsz8UNmwohSqkS7tflayc=
|
||||||
|
|||||||
Generated
+25
-68
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -15070,12 +15070,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/await-lock": {
|
|
||||||
"version": "2.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz",
|
|
||||||
"integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.13.6",
|
"version": "1.13.6",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||||
@@ -21342,20 +21336,6 @@
|
|||||||
"node": ">=8.0"
|
"node": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-sqlite": {
|
|
||||||
"version": "16.0.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/expo-sqlite/-/expo-sqlite-16.0.10.tgz",
|
|
||||||
"integrity": "sha512-tUOKxE9TpfneRG3eOfbNfhN9236SJ7IiUnP8gCqU7umd9DtgDGB/5PhYVVfl+U7KskgolgNoB9v9OZ9iwXN8Eg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"await-lock": "^2.2.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"expo": "*",
|
|
||||||
"react": "*",
|
|
||||||
"react-native": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/expo-system-ui": {
|
"node_modules/expo-system-ui": {
|
||||||
"version": "6.0.9",
|
"version": "6.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-6.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/expo-system-ui/-/expo-system-ui-6.0.9.tgz",
|
||||||
@@ -21801,16 +21781,6 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/fake-indexeddb": {
|
|
||||||
"version": "6.2.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
|
|
||||||
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fast-copy": {
|
"node_modules/fast-copy": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
|
||||||
@@ -36170,7 +36140,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "6.10.4",
|
"@codemirror/commands": "6.10.4",
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
@@ -36230,7 +36200,6 @@
|
|||||||
"expo-router": "~6.0.13",
|
"expo-router": "~6.0.13",
|
||||||
"expo-sharing": "^14.0.8",
|
"expo-sharing": "^14.0.8",
|
||||||
"expo-splash-screen": "~31.0.10",
|
"expo-splash-screen": "~31.0.10",
|
||||||
"expo-sqlite": "~16.0.10",
|
|
||||||
"expo-system-ui": "~6.0.7",
|
"expo-system-ui": "~6.0.7",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"htmlparser2": "^12.0.0",
|
"htmlparser2": "^12.0.0",
|
||||||
@@ -36272,7 +36241,6 @@
|
|||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
"@types/node": "~22.19.0",
|
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"@types/turndown": "^5.0.6",
|
"@types/turndown": "^5.0.6",
|
||||||
@@ -36286,7 +36254,6 @@
|
|||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
"expo-gradle-jvmargs": "^1.1.2",
|
"expo-gradle-jvmargs": "^1.1.2",
|
||||||
"fake-indexeddb": "^6.2.5",
|
|
||||||
"jsdom": "^20.0.3",
|
"jsdom": "^20.0.3",
|
||||||
"material-icon-theme": "^5.32.0",
|
"material-icon-theme": "^5.32.0",
|
||||||
"playwright": "^1.56.1",
|
"playwright": "^1.56.1",
|
||||||
@@ -36824,16 +36791,6 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/app/node_modules/@types/node": {
|
|
||||||
"version": "22.19.21",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz",
|
|
||||||
"integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"undici-types": "~6.21.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packages/app/node_modules/@xterm/addon-clipboard": {
|
"packages/app/node_modules/@xterm/addon-clipboard": {
|
||||||
"version": "0.3.0-beta.213",
|
"version": "0.3.0-beta.213",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.213.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.213.tgz",
|
||||||
@@ -37235,12 +37192,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.5.1",
|
"@getpaseo/client": "0.5.2",
|
||||||
"@getpaseo/protocol": "0.5.1",
|
"@getpaseo/protocol": "0.5.2",
|
||||||
"@getpaseo/server": "0.5.1",
|
"@getpaseo/server": "0.5.2",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -37487,10 +37444,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.5.1",
|
"@getpaseo/protocol": "0.5.2",
|
||||||
"@getpaseo/relay": "0.5.1",
|
"@getpaseo/relay": "0.5.2",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -37501,7 +37458,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -37744,7 +37701,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -38640,7 +38597,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
@@ -38669,12 +38626,12 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@getpaseo/plugin",
|
"name": "@getpaseo/plugin",
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@getpaseo/client": "0.5.1",
|
"@getpaseo/client": "0.5.2",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
@@ -38683,14 +38640,14 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@getpaseo/client": "0.5.1",
|
"@getpaseo/client": "0.5.2",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
@@ -38703,7 +38660,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"tweetnacl": "^1.0.3",
|
"tweetnacl": "^1.0.3",
|
||||||
@@ -38921,17 +38878,17 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.3.220",
|
"@anthropic-ai/claude-agent-sdk": "0.3.220",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@babel/parser": "^7.29.8",
|
"@babel/parser": "^7.29.8",
|
||||||
"@getpaseo/client": "0.5.1",
|
"@getpaseo/client": "0.5.2",
|
||||||
"@getpaseo/highlight": "0.5.1",
|
"@getpaseo/highlight": "0.5.2",
|
||||||
"@getpaseo/plugin": "0.5.1",
|
"@getpaseo/plugin": "0.5.2",
|
||||||
"@getpaseo/protocol": "0.5.1",
|
"@getpaseo/protocol": "0.5.2",
|
||||||
"@getpaseo/relay": "0.5.1",
|
"@getpaseo/relay": "0.5.2",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
@@ -39983,7 +39940,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.29.1",
|
"@cloudflare/vite-plugin": "^1.29.1",
|
||||||
"@cloudflare/workers-types": "^4.20260317.1",
|
"@cloudflare/workers-types": "^4.20260317.1",
|
||||||
|
|||||||
+2
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
@@ -84,8 +84,6 @@
|
|||||||
"android:release": "npm run android:production --workspace=@getpaseo/app",
|
"android:release": "npm run android:production --workspace=@getpaseo/app",
|
||||||
"android:clear": "npm run android:clear --workspace=@getpaseo/app",
|
"android:clear": "npm run android:clear --workspace=@getpaseo/app",
|
||||||
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
|
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
|
||||||
"fdroid:changelogs": "node scripts/sync-fdroid-changelogs.mjs",
|
|
||||||
"fdroid:changelogs:check": "node scripts/sync-fdroid-changelogs.mjs --check",
|
|
||||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||||
"web": "npm run web --workspace=@getpaseo/app",
|
"web": "npm run web --workspace=@getpaseo/app",
|
||||||
"dev:desktop": "cross-env PASEO_LISTEN=127.0.0.1:6768 npm run dev --workspace=@getpaseo/desktop",
|
"dev:desktop": "cross-env PASEO_LISTEN=127.0.0.1:6768 npm run dev --workspace=@getpaseo/desktop",
|
||||||
@@ -93,7 +91,7 @@
|
|||||||
"build:desktop": "npm run build:app-deps:clean && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
|
"build:desktop": "npm run build:app-deps:clean && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
|
||||||
"db:query": "npm run db:query --workspace=@getpaseo/server --",
|
"db:query": "npm run db:query --workspace=@getpaseo/server --",
|
||||||
"cli": "./scripts/dev-home.sh npx tsx packages/cli/src/index.js",
|
"cli": "./scripts/dev-home.sh npx tsx packages/cli/src/index.js",
|
||||||
"version": "npm run version:sync-internal && npm run fdroid:changelogs && npm run release:prepare && git add -A",
|
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||||
"release:prepare": "npm install --workspaces --include-workspace-root",
|
"release:prepare": "npm install --workspaces --include-workspace-root",
|
||||||
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
|
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
|
||||||
|
|||||||
@@ -3,13 +3,10 @@ import { expect, test, type Page } from "../support/fixtures";
|
|||||||
import { expectAgentIdle, expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
|
import { expectAgentIdle, expectAgentReadyToInterrupt } from "../support/helpers/agent-stream";
|
||||||
import {
|
import {
|
||||||
cancelAgent,
|
cancelAgent,
|
||||||
fillComposerDraft,
|
|
||||||
expectComposerDraft,
|
expectComposerDraft,
|
||||||
expectComposerVisible,
|
expectComposerVisible,
|
||||||
submitMessage,
|
submitMessage,
|
||||||
} from "../support/helpers/composer";
|
} from "../support/helpers/composer";
|
||||||
import { expectNearBottom, scrollChatAwayFromBottom } from "../support/helpers/agent-bottom-anchor";
|
|
||||||
import { holdRewindCompletion } from "../support/helpers/agent-timeline-gate";
|
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
|
|
||||||
async function completeSubmittedTurn(
|
async function completeSubmittedTurn(
|
||||||
@@ -45,13 +42,9 @@ async function expectTurnCompletesNormally(
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Agent message rewind", () => {
|
test.describe("Agent message rewind", () => {
|
||||||
test("rewinds a submitted prompt without replaying history and preserves a human draft", async ({
|
test("rewinds a submitted prompt and restores it to the composer", async ({ page }) => {
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
const retainedPrompt = "Keep this completed turn after rewind.";
|
const prompt = "Restore this submitted prompt after rewind.";
|
||||||
const rewoundPrompt = "Remove this completed turn after rewind.";
|
|
||||||
const preservedDraft = "Keep this human draft after rewind.";
|
|
||||||
const agent = await seedMockAgentWorkspace({
|
const agent = await seedMockAgentWorkspace({
|
||||||
repoPrefix: "message-rewind-roundtrip-",
|
repoPrefix: "message-rewind-roundtrip-",
|
||||||
title: "Message rewind roundtrip",
|
title: "Message rewind roundtrip",
|
||||||
@@ -59,30 +52,14 @@ test.describe("Agent message rewind", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const rewindCompletion = await holdRewindCompletion(page, agent.agentId);
|
|
||||||
await openAgentRoute(page, agent);
|
await openAgentRoute(page, agent);
|
||||||
await expectComposerVisible(page);
|
await expectComposerVisible(page);
|
||||||
await completeSubmittedTurn(page, agent, retainedPrompt);
|
const userMessage = await completeSubmittedTurn(page, agent, prompt);
|
||||||
const userMessage = await completeSubmittedTurn(page, agent, rewoundPrompt);
|
|
||||||
await scrollChatAwayFromBottom(page, {
|
|
||||||
deltaY: -900,
|
|
||||||
minDistanceFromBottom: 300,
|
|
||||||
});
|
|
||||||
await fillComposerDraft(page, preservedDraft);
|
|
||||||
rewindCompletion.clearTimelineStreamCount();
|
|
||||||
|
|
||||||
const rewind = rewindConversation(page, userMessage, rewoundPrompt);
|
await rewindConversation(page, userMessage, prompt);
|
||||||
await rewindCompletion.waitForDelayedResponse();
|
|
||||||
await expect(page.getByTestId("rewind-menu-conversation")).toBeDisabled();
|
|
||||||
expect(rewindCompletion.timelineStreamCount()).toBe(0);
|
|
||||||
rewindCompletion.release();
|
|
||||||
await rewind;
|
|
||||||
|
|
||||||
await expect(page.getByTestId("user-message").filter({ hasText: rewoundPrompt })).toHaveCount(
|
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toHaveCount(0);
|
||||||
0,
|
await expectComposerDraft(page, prompt);
|
||||||
);
|
|
||||||
await expectComposerDraft(page, preservedDraft);
|
|
||||||
await expectNearBottom(page);
|
|
||||||
} finally {
|
} finally {
|
||||||
await agent.cleanup();
|
await agent.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
import { test, expect } from "../support/fixtures";
|
|
||||||
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
|
|
||||||
import { startRunningMockAgent } from "../support/helpers/composer";
|
|
||||||
import {
|
|
||||||
sampleStreamFrames,
|
|
||||||
summarizeStreamSmoothness,
|
|
||||||
} from "../support/helpers/stream-smoothness";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Streaming smoothness, measured on the path the user actually sees.
|
|
||||||
*
|
|
||||||
* The mock provider's "bursty-stream" model emits tokens in uneven runs separated
|
|
||||||
* by idle gaps, which is how real models arrive. Sampling and the statistics live
|
|
||||||
* in ../support/helpers/stream-smoothness.ts, which also documents what the two
|
|
||||||
* numbers mean.
|
|
||||||
*
|
|
||||||
* Budgets are deliberately loose. This spec exists to catch a regression in the
|
|
||||||
* shape of the stream, not to pin exact numbers on shared CI hardware. For
|
|
||||||
* reference, on a local dev daemon the paced reveal measures a CV around 1.3–1.5
|
|
||||||
* against 2.7 when painting deltas as they arrive.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const SAMPLE_WINDOW_MS = 6_000;
|
|
||||||
const CHARS_PER_FRAME_CV_BUDGET = 2;
|
|
||||||
const UPDATE_GAP_P95_BUDGET_MS = 250;
|
|
||||||
const REACT_COMMIT_P95_BUDGET_MS = 12;
|
|
||||||
const REACT_COMMIT_COUNT_BUDGET = 600;
|
|
||||||
const RUN_AGENT_STREAM_PERF = process.env.PASEO_AGENT_STREAM_PERF_E2E === "1";
|
|
||||||
const agentStreamPerfDescribe = RUN_AGENT_STREAM_PERF ? test.describe : test.describe.skip;
|
|
||||||
|
|
||||||
interface ReactCommit {
|
|
||||||
id: string;
|
|
||||||
phase: "mount" | "update" | "nested-update";
|
|
||||||
actualDuration: number;
|
|
||||||
baseDuration: number;
|
|
||||||
startTime: number;
|
|
||||||
commitTime: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
agentStreamPerfDescribe("Agent stream smoothness", () => {
|
|
||||||
test("reveals bursty model output at a steady rate", async ({ page }, testInfo) => {
|
|
||||||
test.setTimeout(120_000);
|
|
||||||
await page.addInitScript(() => {
|
|
||||||
Reflect.set(globalThis, "__PASEO_RENDER_PROFILE_ENABLED__", true);
|
|
||||||
});
|
|
||||||
|
|
||||||
const agent = await startRunningMockAgent(page, {
|
|
||||||
prefix: "stream-smoothness-",
|
|
||||||
model: "bursty-stream",
|
|
||||||
prompt: "Stream bursty output for smoothness measurement.",
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await awaitAssistantMessage(page);
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const reset = Reflect.get(globalThis, "__PASEO_RESET_RENDER_PROFILE__");
|
|
||||||
if (typeof reset !== "function") {
|
|
||||||
throw new Error("Render profiler did not initialize");
|
|
||||||
}
|
|
||||||
reset();
|
|
||||||
});
|
|
||||||
|
|
||||||
const samples = await sampleStreamFrames(page, { sampleWindowMs: SAMPLE_WINDOW_MS });
|
|
||||||
const report = summarizeStreamSmoothness(samples, SAMPLE_WINDOW_MS);
|
|
||||||
const reactCommits = await readStreamReactCommits(page);
|
|
||||||
const reactReport = summarizeReactCommits(reactCommits);
|
|
||||||
|
|
||||||
await testInfo.attach("agent-stream-smoothness-report", {
|
|
||||||
body: JSON.stringify(report, null, 2),
|
|
||||||
contentType: "application/json",
|
|
||||||
});
|
|
||||||
await testInfo.attach("agent-stream-react-profile", {
|
|
||||||
body: JSON.stringify(reactReport, null, 2),
|
|
||||||
contentType: "application/json",
|
|
||||||
});
|
|
||||||
console.log(`[perf] Agent stream React: ${JSON.stringify(reactReport)}`);
|
|
||||||
console.log(`[perf] Agent stream smoothness: ${JSON.stringify(report)}`);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
report.growthFrames,
|
|
||||||
"the stream must actually produce text during the sample window",
|
|
||||||
).toBeGreaterThan(20);
|
|
||||||
expect(
|
|
||||||
report.charsPerFrameCv,
|
|
||||||
`characters painted per frame should be steady (cv < ${CHARS_PER_FRAME_CV_BUDGET})`,
|
|
||||||
).toBeLessThan(CHARS_PER_FRAME_CV_BUDGET);
|
|
||||||
expect(
|
|
||||||
report.updateGapP95Ms,
|
|
||||||
`the text should not stall (p95 gap < ${UPDATE_GAP_P95_BUDGET_MS}ms)`,
|
|
||||||
).toBeLessThan(UPDATE_GAP_P95_BUDGET_MS);
|
|
||||||
expect(reactReport.count, "streaming should produce measured React commits").toBeGreaterThan(
|
|
||||||
20,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
reactReport.count,
|
|
||||||
`the reveal should cap commits below ${REACT_COMMIT_COUNT_BUDGET} per sample window`,
|
|
||||||
).toBeLessThan(REACT_COMMIT_COUNT_BUDGET);
|
|
||||||
expect(
|
|
||||||
reactReport.actualDurationP95Ms,
|
|
||||||
`React commits should stay below ${REACT_COMMIT_P95_BUDGET_MS}ms at p95`,
|
|
||||||
).toBeLessThan(REACT_COMMIT_P95_BUDGET_MS);
|
|
||||||
} finally {
|
|
||||||
await agent.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
async function readStreamReactCommits(
|
|
||||||
page: Parameters<typeof sampleStreamFrames>[0],
|
|
||||||
): Promise<ReactCommit[]> {
|
|
||||||
return await page.evaluate(() => {
|
|
||||||
const samples = Reflect.get(globalThis, "__PASEO_RENDER_PROFILE__");
|
|
||||||
if (!Array.isArray(samples)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return samples.filter(
|
|
||||||
(sample): sample is ReactCommit =>
|
|
||||||
typeof sample === "object" &&
|
|
||||||
sample !== null &&
|
|
||||||
typeof Reflect.get(sample, "id") === "string" &&
|
|
||||||
Reflect.get(sample, "id").startsWith("AgentStreamSection:"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeReactCommits(commits: readonly ReactCommit[]) {
|
|
||||||
const durations = commits
|
|
||||||
.map((commit) => commit.actualDuration)
|
|
||||||
.sort((left, right) => left - right);
|
|
||||||
const total = durations.reduce((sum, duration) => sum + duration, 0);
|
|
||||||
return {
|
|
||||||
count: commits.length,
|
|
||||||
actualDurationTotalMs: round2(total),
|
|
||||||
actualDurationMeanMs: round2(commits.length > 0 ? total / commits.length : 0),
|
|
||||||
actualDurationP50Ms: percentile(durations, 50),
|
|
||||||
actualDurationP95Ms: percentile(durations, 95),
|
|
||||||
actualDurationMaxMs: durations.at(-1) ?? 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function round2(value: number): number {
|
|
||||||
return Math.round(value * 100) / 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
function percentile(sorted: readonly number[], percentileValue: number): number {
|
|
||||||
if (sorted.length === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return sorted[
|
|
||||||
Math.min(sorted.length - 1, Math.floor((sorted.length - 1) * (percentileValue / 100)))
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -102,11 +102,11 @@ test.describe("PR pane", () => {
|
|||||||
await expectPrPaneTitle(page, "Work in progress");
|
await expectPrPaneTitle(page, "Work in progress");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders check pills with correct success/failure/pending counts", async ({ page }) => {
|
test("renders check pills with correct passed/failed/pending counts", async ({ page }) => {
|
||||||
await gotoWorkspace(page, workspaceByTitle.get("PR with mixed checks")!);
|
await gotoWorkspace(page, workspaceByTitle.get("PR with mixed checks")!);
|
||||||
await openPrPane(page);
|
await openPrPane(page);
|
||||||
|
|
||||||
await expectPrPaneCheckSummary(page, { success: 2, failure: 1, pending: 1 });
|
await expectPrPaneCheckSummary(page, { passed: 2, failed: 1, pending: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders activity rows with correct count", async ({ page }) => {
|
test("renders activity rows with correct count", async ({ page }) => {
|
||||||
@@ -120,7 +120,7 @@ test.describe("PR pane", () => {
|
|||||||
await gotoWorkspace(page, workspaceByTitle.get("PR with no checks")!);
|
await gotoWorkspace(page, workspaceByTitle.get("PR with no checks")!);
|
||||||
await openPrPane(page);
|
await openPrPane(page);
|
||||||
|
|
||||||
await expectPrPaneCheckSummary(page, { success: 0, failure: 0, pending: 0 });
|
await expectPrPaneCheckSummary(page, { passed: 0, failed: 0, pending: 0 });
|
||||||
await expectPrPaneTitle(page, "PR with no checks");
|
await expectPrPaneTitle(page, "PR with no checks");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
import { mkdir, writeFile } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import { expect, test } from "@playwright/test";
|
|
||||||
import {
|
|
||||||
beginReplicaCacheObservation,
|
|
||||||
finishReplicaCacheObservation,
|
|
||||||
installReplicaCacheMeasurement,
|
|
||||||
measureReplicaCacheRestore,
|
|
||||||
type ReplicaCacheMeasurementReport,
|
|
||||||
} from "../support/helpers/replica-cache-measurement";
|
|
||||||
|
|
||||||
const RUN_MEASUREMENT = process.env.PASEO_REPLICA_CACHE_MEASUREMENT === "1";
|
|
||||||
const TARGET_URL = process.env.PASEO_REPLICA_CACHE_MEASUREMENT_URL;
|
|
||||||
const REPORT_PATH = process.env.PASEO_REPLICA_CACHE_MEASUREMENT_REPORT;
|
|
||||||
const OBSERVATION_MS = Number(process.env.PASEO_REPLICA_CACHE_MEASUREMENT_MS ?? 60_000);
|
|
||||||
const HYDRATED_SELECTOR =
|
|
||||||
process.env.PASEO_REPLICA_CACHE_HYDRATED_SELECTOR ??
|
|
||||||
'[data-testid="agent-chat-scroll"], [data-testid^="sidebar-workspace-row-"]';
|
|
||||||
|
|
||||||
const measurementDescribe = RUN_MEASUREMENT ? test.describe : test.describe.skip;
|
|
||||||
|
|
||||||
measurementDescribe("Replica cache live measurement", () => {
|
|
||||||
test("observes persistence and cached restore without mutating the daemon", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
test.setTimeout(OBSERVATION_MS + 90_000);
|
|
||||||
expect(TARGET_URL, "PASEO_REPLICA_CACHE_MEASUREMENT_URL must be a full app URL").toBeTruthy();
|
|
||||||
expect(REPORT_PATH, "PASEO_REPLICA_CACHE_MEASUREMENT_REPORT must be set").toBeTruthy();
|
|
||||||
|
|
||||||
await installReplicaCacheMeasurement(page);
|
|
||||||
await page.goto(TARGET_URL!, { waitUntil: "domcontentloaded" });
|
|
||||||
await page.waitForLoadState("networkidle").catch(() => undefined);
|
|
||||||
|
|
||||||
const observation = await beginReplicaCacheObservation(page);
|
|
||||||
await page.waitForTimeout(OBSERVATION_MS);
|
|
||||||
const observed = await finishReplicaCacheObservation(page, observation, TARGET_URL!);
|
|
||||||
const restoreMs = await measureReplicaCacheRestore(page, TARGET_URL!, HYDRATED_SELECTOR);
|
|
||||||
const report: ReplicaCacheMeasurementReport = { ...observed, restoreMs };
|
|
||||||
|
|
||||||
await mkdir(path.dirname(REPORT_PATH!), { recursive: true });
|
|
||||||
await writeFile(REPORT_PATH!, `${JSON.stringify(report, null, 2)}\n`);
|
|
||||||
await testInfo.attach("replica-cache-measurement", {
|
|
||||||
body: JSON.stringify(report, null, 2),
|
|
||||||
contentType: "application/json",
|
|
||||||
});
|
|
||||||
console.log(`[measurement] Replica cache: ${JSON.stringify(report)}`);
|
|
||||||
|
|
||||||
expect(report.observationMs).toBeGreaterThanOrEqual(OBSERVATION_MS);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
|
|
||||||
const RUN_REPLICA_CACHE_PERF = process.env.PASEO_REPLICA_CACHE_PERF_E2E === "1";
|
const RUN_REPLICA_CACHE_PERF = process.env.PASEO_REPLICA_CACHE_PERF_E2E === "1";
|
||||||
const MAX_STREAM_WRITES = 12;
|
const MAX_STREAM_WRITES = 12;
|
||||||
const MAX_SERIALIZED_CHARS = 45_000;
|
const MAX_SERIALIZED_CHARS = 190_000;
|
||||||
|
|
||||||
const test = base.extend<{ replicaCacheAgent: MockAgentWorkspace }>({
|
const test = base.extend<{ replicaCacheAgent: MockAgentWorkspace }>({
|
||||||
replicaCacheAgent: async ({ page: _page }, provide) => {
|
replicaCacheAgent: async ({ page: _page }, provide) => {
|
||||||
|
|||||||
@@ -196,12 +196,6 @@ async function loadHarnessEnvironment(repoRoot: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function globalSetup() {
|
export default async function globalSetup() {
|
||||||
if (process.env.PASEO_REPLICA_CACHE_MEASUREMENT === "1") {
|
|
||||||
if (!process.env.PASEO_REPLICA_CACHE_MEASUREMENT_URL) {
|
|
||||||
throw new Error("PASEO_REPLICA_CACHE_MEASUREMENT_URL must be set for live measurement");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const repoRoot = path.resolve(__dirname, "../../../..");
|
const repoRoot = path.resolve(__dirname, "../../../..");
|
||||||
await loadHarnessEnvironment(repoRoot);
|
await loadHarnessEnvironment(repoRoot);
|
||||||
|
|
||||||
|
|||||||
@@ -27,13 +27,6 @@ export interface DaemonHydrationGate {
|
|||||||
release(): void;
|
release(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RewindCompletionGate {
|
|
||||||
clearTimelineStreamCount(): void;
|
|
||||||
release(): void;
|
|
||||||
timelineStreamCount(): number;
|
|
||||||
waitForDelayedResponse(): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PromptJumpRequestTracker {
|
export interface PromptJumpRequestTracker {
|
||||||
requests(): Array<{ cursorSeq: number | null; limit: number | null; mergeWindow: boolean }>;
|
requests(): Array<{ cursorSeq: number | null; limit: number | null; mergeWindow: boolean }>;
|
||||||
}
|
}
|
||||||
@@ -262,62 +255,6 @@ export async function holdDaemonHydration(page: Page): Promise<DaemonHydrationGa
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function holdRewindCompletion(
|
|
||||||
page: Page,
|
|
||||||
agentId: string,
|
|
||||||
): Promise<RewindCompletionGate> {
|
|
||||||
let released = false;
|
|
||||||
let timelineStreamCount = 0;
|
|
||||||
const delayedForwards: Array<() => void> = [];
|
|
||||||
let resolveDelayedResponse: (() => void) | null = null;
|
|
||||||
const delayedResponse = new Promise<void>((resolve) => {
|
|
||||||
resolveDelayedResponse = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
|
||||||
const server = ws.connectToServer();
|
|
||||||
ws.onMessage((message) => server.send(message));
|
|
||||||
server.onMessage((message) => {
|
|
||||||
const sessionMessage = getSessionMessage(message);
|
|
||||||
const payload = sessionMessage ? getPayload(sessionMessage) : null;
|
|
||||||
const streamEvent = payload?.event;
|
|
||||||
if (
|
|
||||||
sessionMessage?.type === "agent_stream" &&
|
|
||||||
payload?.agentId === agentId &&
|
|
||||||
streamEvent &&
|
|
||||||
typeof streamEvent === "object" &&
|
|
||||||
(streamEvent as { type?: unknown }).type === "timeline"
|
|
||||||
) {
|
|
||||||
timelineStreamCount += 1;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!released &&
|
|
||||||
sessionMessage?.type === "agent.rewind.response" &&
|
|
||||||
payload?.agentId === agentId
|
|
||||||
) {
|
|
||||||
delayedForwards.push(() => ws.send(message));
|
|
||||||
resolveDelayedResponse?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
ws.send(message);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
clearTimelineStreamCount() {
|
|
||||||
timelineStreamCount = 0;
|
|
||||||
},
|
|
||||||
release() {
|
|
||||||
released = true;
|
|
||||||
for (const forward of delayedForwards.splice(0)) {
|
|
||||||
forward();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
timelineStreamCount: () => timelineStreamCount,
|
|
||||||
waitForDelayedResponse: () => delayedResponse,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function delayCreatedAgentInitialTailResponse(
|
export async function delayCreatedAgentInitialTailResponse(
|
||||||
page: Page,
|
page: Page,
|
||||||
): Promise<CreatedAgentTimelineGate> {
|
): Promise<CreatedAgentTimelineGate> {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { expect, type Page } from "@playwright/test";
|
import { expect, type Page } from "@playwright/test";
|
||||||
import type { CheckPresentationCounts } from "@/git/check-presentation";
|
|
||||||
import { getStateLabel } from "@/git/pull-request-panel/data";
|
import { getStateLabel } from "@/git/pull-request-panel/data";
|
||||||
import { openPullRequestPanel } from "./workspace-tabs";
|
import { openPullRequestPanel } from "./workspace-tabs";
|
||||||
|
|
||||||
@@ -30,10 +29,10 @@ async function assertCheckPill(page: Page, testId: string, count: number): Promi
|
|||||||
|
|
||||||
export async function expectPrPaneCheckSummary(
|
export async function expectPrPaneCheckSummary(
|
||||||
page: Page,
|
page: Page,
|
||||||
counts: Pick<CheckPresentationCounts, "success" | "failure" | "pending">,
|
counts: { passed: number; failed: number; pending: number },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await assertCheckPill(page, "pr-pane-check-success", counts.success);
|
await assertCheckPill(page, "pr-pane-check-passed", counts.passed);
|
||||||
await assertCheckPill(page, "pr-pane-check-failure", counts.failure);
|
await assertCheckPill(page, "pr-pane-check-failed", counts.failed);
|
||||||
await assertCheckPill(page, "pr-pane-check-pending", counts.pending);
|
await assertCheckPill(page, "pr-pane-check-pending", counts.pending);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,312 +0,0 @@
|
|||||||
import type { CDPSession, Page } from "@playwright/test";
|
|
||||||
|
|
||||||
const REPLICA_CACHE_DATABASE = "paseo-replica-row-store";
|
|
||||||
const REPLICA_CACHE_ROWS_STORE = "rows";
|
|
||||||
const PERSIST_FRAME_NAMES = [
|
|
||||||
"captureSessions",
|
|
||||||
"captureHost",
|
|
||||||
"queueReplicaChanges",
|
|
||||||
"apply",
|
|
||||||
"safeParse",
|
|
||||||
"stringify",
|
|
||||||
"setItem",
|
|
||||||
"measuredPut",
|
|
||||||
] as const;
|
|
||||||
const PERSIST_STACK_ANCHORS = new Set([
|
|
||||||
"captureSessions",
|
|
||||||
"captureHost",
|
|
||||||
"queueReplicaChanges",
|
|
||||||
"persist",
|
|
||||||
"flushPending",
|
|
||||||
"measuredPut",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export interface ReplicaCacheMeasurementReport {
|
|
||||||
targetUrl: string;
|
|
||||||
observationMs: number;
|
|
||||||
persistCpuMs: number;
|
|
||||||
persistCpuFrames: Record<string, number>;
|
|
||||||
flushes: number;
|
|
||||||
bytesWritten: {
|
|
||||||
total: number;
|
|
||||||
perFlush: number[];
|
|
||||||
};
|
|
||||||
redundantWrites: number;
|
|
||||||
storageDurationMs: number;
|
|
||||||
longTasks: {
|
|
||||||
count: number;
|
|
||||||
totalDurationMs: number;
|
|
||||||
maximumDurationMs: number;
|
|
||||||
durationsMs: number[];
|
|
||||||
};
|
|
||||||
restoreMs: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BrowserMeasurementState {
|
|
||||||
flushes: Array<{ bytes: number; durationMs: number }>;
|
|
||||||
longTasks: number[];
|
|
||||||
redundantWrites: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProfileNode {
|
|
||||||
id: number;
|
|
||||||
callFrame: { functionName: string; url: string };
|
|
||||||
parent?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SamplingProfile {
|
|
||||||
nodes: ProfileNode[];
|
|
||||||
samples?: number[];
|
|
||||||
timeDeltas?: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
__replicaCacheMeasurement?: BrowserMeasurementState;
|
|
||||||
__replicaCacheRestoreReadStartedAt?: number;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function installReplicaCacheMeasurement(page: Page): Promise<void> {
|
|
||||||
await page.addInitScript(
|
|
||||||
({ databaseName, storeName }) => {
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const transactionWrites = new WeakMap<
|
|
||||||
IDBTransaction,
|
|
||||||
Array<
|
|
||||||
| { type: "put"; key: string; value: string; bytes: number; startedAt: number }
|
|
||||||
| { type: "delete"; key: string; bytes: 0; startedAt: number }
|
|
||||||
>
|
|
||||||
>();
|
|
||||||
const lastValues = new Map<string, string>();
|
|
||||||
|
|
||||||
const resolveStorageKey = (
|
|
||||||
store: IDBObjectStore,
|
|
||||||
value: unknown,
|
|
||||||
explicitKey: IDBValidKey | undefined,
|
|
||||||
): unknown => {
|
|
||||||
if (explicitKey !== undefined) return explicitKey;
|
|
||||||
if (value === null || typeof value !== "object") return null;
|
|
||||||
if (Array.isArray(store.keyPath)) {
|
|
||||||
return store.keyPath.map((part) => Reflect.get(value, part));
|
|
||||||
}
|
|
||||||
return typeof store.keyPath === "string" ? Reflect.get(value, store.keyPath) : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
window.__replicaCacheMeasurement = {
|
|
||||||
flushes: [],
|
|
||||||
longTasks: [],
|
|
||||||
redundantWrites: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const observeTransaction = (transaction: IDBTransaction) => {
|
|
||||||
let writes = transactionWrites.get(transaction);
|
|
||||||
if (writes) return writes;
|
|
||||||
writes = [];
|
|
||||||
transactionWrites.set(transaction, writes);
|
|
||||||
transaction.addEventListener("complete", () => {
|
|
||||||
const state = window.__replicaCacheMeasurement;
|
|
||||||
if (!state || writes!.length === 0) return;
|
|
||||||
let bytes = 0;
|
|
||||||
let startedAt = Number.POSITIVE_INFINITY;
|
|
||||||
for (const write of writes!) {
|
|
||||||
bytes += write.bytes;
|
|
||||||
startedAt = Math.min(startedAt, write.startedAt);
|
|
||||||
if (write.type === "put") {
|
|
||||||
if (lastValues.get(write.key) === write.value) state.redundantWrites += 1;
|
|
||||||
lastValues.set(write.key, write.value);
|
|
||||||
} else {
|
|
||||||
lastValues.delete(write.key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.flushes.push({ bytes, durationMs: performance.now() - startedAt });
|
|
||||||
});
|
|
||||||
return writes;
|
|
||||||
};
|
|
||||||
|
|
||||||
const originalPut = IDBObjectStore.prototype.put;
|
|
||||||
IDBObjectStore.prototype.put = function measuredPut(value: unknown, key?: IDBValidKey) {
|
|
||||||
const request =
|
|
||||||
key === undefined ? originalPut.call(this, value) : originalPut.call(this, value, key);
|
|
||||||
if (this.transaction.db.name !== databaseName || this.name !== storeName) return request;
|
|
||||||
const rowPayload =
|
|
||||||
value && typeof value === "object" ? Reflect.get(value, "payload") : undefined;
|
|
||||||
let serialized = JSON.stringify(value);
|
|
||||||
if (typeof value === "string") serialized = value;
|
|
||||||
if (typeof rowPayload === "string") serialized = rowPayload;
|
|
||||||
const storageKey = JSON.stringify(resolveStorageKey(this, value, key));
|
|
||||||
observeTransaction(this.transaction).push({
|
|
||||||
type: "put",
|
|
||||||
key: `${this.name}:${storageKey}`,
|
|
||||||
value: serialized,
|
|
||||||
bytes: encoder.encode(serialized).byteLength,
|
|
||||||
startedAt: performance.now(),
|
|
||||||
});
|
|
||||||
return request;
|
|
||||||
};
|
|
||||||
const originalDelete = IDBObjectStore.prototype.delete;
|
|
||||||
IDBObjectStore.prototype.delete = function measuredDelete(query: IDBValidKey | IDBKeyRange) {
|
|
||||||
const request = originalDelete.call(this, query);
|
|
||||||
if (this.transaction.db.name !== databaseName || this.name !== storeName) return request;
|
|
||||||
observeTransaction(this.transaction).push({
|
|
||||||
type: "delete",
|
|
||||||
key: `${this.name}:${JSON.stringify(query)}`,
|
|
||||||
bytes: 0,
|
|
||||||
startedAt: performance.now(),
|
|
||||||
});
|
|
||||||
return request;
|
|
||||||
};
|
|
||||||
|
|
||||||
const markRestoreRead = (store: IDBObjectStore) => {
|
|
||||||
if (
|
|
||||||
store.transaction.db.name === databaseName &&
|
|
||||||
store.name === storeName &&
|
|
||||||
store.transaction.mode === "readonly" &&
|
|
||||||
window.__replicaCacheRestoreReadStartedAt === undefined
|
|
||||||
) {
|
|
||||||
window.__replicaCacheRestoreReadStartedAt = performance.now();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const originalGet = IDBObjectStore.prototype.get;
|
|
||||||
IDBObjectStore.prototype.get = function measuredGet(query: IDBValidKey | IDBKeyRange) {
|
|
||||||
markRestoreRead(this);
|
|
||||||
return originalGet.call(this, query);
|
|
||||||
};
|
|
||||||
const originalGetAll = IDBObjectStore.prototype.getAll;
|
|
||||||
IDBObjectStore.prototype.getAll = function measuredGetAll(
|
|
||||||
query?: IDBValidKey | IDBKeyRange | null,
|
|
||||||
count?: number,
|
|
||||||
) {
|
|
||||||
markRestoreRead(this);
|
|
||||||
return originalGetAll.call(this, query, count);
|
|
||||||
};
|
|
||||||
const originalOpenCursor = IDBObjectStore.prototype.openCursor;
|
|
||||||
IDBObjectStore.prototype.openCursor = function measuredOpenCursor(
|
|
||||||
query?: IDBValidKey | IDBKeyRange | null,
|
|
||||||
direction?: IDBCursorDirection,
|
|
||||||
) {
|
|
||||||
markRestoreRead(this);
|
|
||||||
return originalOpenCursor.call(this, query, direction);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof PerformanceObserver !== "undefined") {
|
|
||||||
const observer = new PerformanceObserver((entries) => {
|
|
||||||
const state = window.__replicaCacheMeasurement;
|
|
||||||
if (!state) return;
|
|
||||||
for (const entry of entries.getEntries()) state.longTasks.push(entry.duration);
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
observer.observe({ type: "longtask", buffered: true });
|
|
||||||
} catch {
|
|
||||||
// Chromium exposes Long Tasks; leave an empty list on runtimes that do not.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ databaseName: REPLICA_CACHE_DATABASE, storeName: REPLICA_CACHE_ROWS_STORE },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function beginReplicaCacheObservation(
|
|
||||||
page: Page,
|
|
||||||
): Promise<{ cdp: CDPSession; startedAt: number }> {
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const state = window.__replicaCacheMeasurement;
|
|
||||||
if (!state) throw new Error("Replica cache measurement is not installed");
|
|
||||||
state.flushes = [];
|
|
||||||
state.longTasks = [];
|
|
||||||
state.redundantWrites = 0;
|
|
||||||
});
|
|
||||||
const cdp = await page.context().newCDPSession(page);
|
|
||||||
await cdp.send("Profiler.enable");
|
|
||||||
await cdp.send("Profiler.setSamplingInterval", { interval: 100 });
|
|
||||||
await cdp.send("Profiler.start");
|
|
||||||
return { cdp, startedAt: Date.now() };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function finishReplicaCacheObservation(
|
|
||||||
page: Page,
|
|
||||||
observation: { cdp: CDPSession; startedAt: number },
|
|
||||||
targetUrl: string,
|
|
||||||
): Promise<Omit<ReplicaCacheMeasurementReport, "restoreMs">> {
|
|
||||||
const result = (await observation.cdp.send("Profiler.stop")) as { profile: SamplingProfile };
|
|
||||||
await observation.cdp.detach();
|
|
||||||
const browser = await page.evaluate(() => {
|
|
||||||
const state = window.__replicaCacheMeasurement;
|
|
||||||
if (!state) throw new Error("Replica cache measurement is not installed");
|
|
||||||
return state;
|
|
||||||
});
|
|
||||||
const cpu = summarizePersistCpu(result.profile);
|
|
||||||
const bytesPerFlush = browser.flushes.map((flush) => flush.bytes);
|
|
||||||
const longTaskDurations = browser.longTasks;
|
|
||||||
return {
|
|
||||||
targetUrl,
|
|
||||||
observationMs: Date.now() - observation.startedAt,
|
|
||||||
persistCpuMs: cpu.totalMs,
|
|
||||||
persistCpuFrames: cpu.byFrame,
|
|
||||||
flushes: browser.flushes.length,
|
|
||||||
bytesWritten: {
|
|
||||||
total: bytesPerFlush.reduce((sum, bytes) => sum + bytes, 0),
|
|
||||||
perFlush: bytesPerFlush,
|
|
||||||
},
|
|
||||||
redundantWrites: browser.redundantWrites,
|
|
||||||
storageDurationMs: browser.flushes.reduce((sum, flush) => sum + flush.durationMs, 0),
|
|
||||||
longTasks: {
|
|
||||||
count: longTaskDurations.length,
|
|
||||||
totalDurationMs: longTaskDurations.reduce((sum, duration) => sum + duration, 0),
|
|
||||||
maximumDurationMs: Math.max(0, ...longTaskDurations),
|
|
||||||
durationsMs: longTaskDurations,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function measureReplicaCacheRestore(
|
|
||||||
page: Page,
|
|
||||||
targetUrl: string,
|
|
||||||
hydratedSelector: string,
|
|
||||||
): Promise<number | null> {
|
|
||||||
await page.routeWebSocket(/\/ws(?:\?|$)/, async (socket) => {
|
|
||||||
await socket.close({ code: 1000, reason: "Hold reconnect until cached replica paints" });
|
|
||||||
});
|
|
||||||
await page.goto(targetUrl, { waitUntil: "domcontentloaded" });
|
|
||||||
const restoredAt = await page.waitForFunction(
|
|
||||||
(selector) => {
|
|
||||||
const startedAt = window.__replicaCacheRestoreReadStartedAt;
|
|
||||||
if (startedAt === undefined) return null;
|
|
||||||
const element = document.querySelector(selector);
|
|
||||||
if (!element || !(element instanceof HTMLElement) || element.offsetParent === null)
|
|
||||||
return null;
|
|
||||||
return performance.now();
|
|
||||||
},
|
|
||||||
hydratedSelector,
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
);
|
|
||||||
const restoredAtMs = await restoredAt.jsonValue();
|
|
||||||
const startedAtMs = await page.evaluate(() => window.__replicaCacheRestoreReadStartedAt ?? null);
|
|
||||||
return typeof restoredAtMs === "number" && startedAtMs !== null
|
|
||||||
? restoredAtMs - startedAtMs
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizePersistCpu(profile: SamplingProfile): {
|
|
||||||
totalMs: number;
|
|
||||||
byFrame: Record<string, number>;
|
|
||||||
} {
|
|
||||||
const nodes = new Map(profile.nodes.map((node) => [node.id, node]));
|
|
||||||
const byFrame: Record<string, number> = {};
|
|
||||||
let totalMicroseconds = 0;
|
|
||||||
for (const [index, sampleId] of (profile.samples ?? []).entries()) {
|
|
||||||
const delta = profile.timeDeltas?.[index] ?? 0;
|
|
||||||
const stackFunctions = new Set<string>();
|
|
||||||
let node = nodes.get(sampleId);
|
|
||||||
while (node) {
|
|
||||||
stackFunctions.add(node.callFrame.functionName);
|
|
||||||
node = node.parent === undefined ? undefined : nodes.get(node.parent);
|
|
||||||
}
|
|
||||||
if (![...stackFunctions].some((name) => PERSIST_STACK_ANCHORS.has(name))) continue;
|
|
||||||
const matched = PERSIST_FRAME_NAMES.filter((name) => stackFunctions.has(name));
|
|
||||||
if (matched.length === 0) continue;
|
|
||||||
totalMicroseconds += delta;
|
|
||||||
for (const pattern of matched) byFrame[pattern] = (byFrame[pattern] ?? 0) + delta / 1_000;
|
|
||||||
}
|
|
||||||
return { totalMs: totalMicroseconds / 1_000, byFrame };
|
|
||||||
}
|
|
||||||
@@ -1,49 +1,26 @@
|
|||||||
import type { Page } from "@playwright/test";
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
const DATABASE_NAME = "paseo-replica-row-store";
|
const DATABASE_NAME = "paseo-replica-cache";
|
||||||
const STORE_NAME = "rows";
|
const STORE_NAME = "key-value";
|
||||||
const SINGLETON_ID = "singleton";
|
const STORAGE_KEY = "@paseo:replica-cache";
|
||||||
|
|
||||||
type ReplicaRowKind = "agent" | "workspace" | "project" | "timeline" | "checkpoint";
|
|
||||||
|
|
||||||
interface ReplicaRowRecord {
|
|
||||||
serverId: string;
|
|
||||||
kind: ReplicaRowKind;
|
|
||||||
id: string;
|
|
||||||
payload: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReplicaCacheHostRecord {
|
|
||||||
serverId: string;
|
|
||||||
agents: Array<Record<string, unknown>>;
|
|
||||||
workspaces: Array<Record<string, unknown>>;
|
|
||||||
projects: Array<Record<string, unknown>>;
|
|
||||||
timeline?: {
|
|
||||||
agentId?: string;
|
|
||||||
items?: Array<Record<string, unknown>>;
|
|
||||||
range?: {
|
|
||||||
endSeq?: number;
|
|
||||||
epoch?: string;
|
|
||||||
startSeq?: number;
|
|
||||||
} | null;
|
|
||||||
} | null;
|
|
||||||
directorySync?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReplicaCacheRecord {
|
export interface ReplicaCacheRecord {
|
||||||
version?: number;
|
version?: number;
|
||||||
hosts?: ReplicaCacheHostRecord[];
|
hosts?: Array<{
|
||||||
|
timeline?: {
|
||||||
|
agentId?: string;
|
||||||
|
items?: Array<Record<string, unknown>>;
|
||||||
|
range?: {
|
||||||
|
endSeq?: number;
|
||||||
|
epoch?: string;
|
||||||
|
startSeq?: number;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReplicaCacheWriteObserverState {
|
interface ReplicaCacheWriteObserverState {
|
||||||
lastValues: Record<string, string>;
|
lastValue: string | null;
|
||||||
redundantWrites: number;
|
|
||||||
serializedChars: number;
|
|
||||||
storageDurationMs: number;
|
|
||||||
writes: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReplicaCacheWriteObserverReport {
|
|
||||||
redundantWrites: number;
|
redundantWrites: number;
|
||||||
serializedChars: number;
|
serializedChars: number;
|
||||||
storageDurationMs: number;
|
storageDurationMs: number;
|
||||||
@@ -56,10 +33,11 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readRows(input: {
|
async function readStoredValue(input: {
|
||||||
databaseName: string;
|
databaseName: string;
|
||||||
storeName: string;
|
storeName: string;
|
||||||
}): Promise<ReplicaRowRecord[]> {
|
storageKey: string;
|
||||||
|
}): Promise<string | null> {
|
||||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||||
const request = indexedDB.open(input.databaseName, 1);
|
const request = indexedDB.open(input.databaseName, 1);
|
||||||
request.addEventListener("success", () => resolve(request.result));
|
request.addEventListener("success", () => resolve(request.result));
|
||||||
@@ -69,103 +47,26 @@ async function readRows(input: {
|
|||||||
const request = database
|
const request = database
|
||||||
.transaction(input.storeName, "readonly")
|
.transaction(input.storeName, "readonly")
|
||||||
.objectStore(input.storeName)
|
.objectStore(input.storeName)
|
||||||
.getAll();
|
.get(input.storageKey);
|
||||||
request.addEventListener("success", () => resolve(request.result));
|
request.addEventListener("success", () =>
|
||||||
|
resolve(typeof request.result === "string" ? request.result : null),
|
||||||
|
);
|
||||||
request.addEventListener("error", () => reject(request.error));
|
request.addEventListener("error", () => reject(request.error));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function assembleCache(rows: ReplicaRowRecord[]): ReplicaCacheRecord {
|
|
||||||
const hosts = new Map<string, ReplicaCacheHostRecord>();
|
|
||||||
for (const row of rows) {
|
|
||||||
const host = hosts.get(row.serverId) ?? {
|
|
||||||
serverId: row.serverId,
|
|
||||||
agents: [],
|
|
||||||
workspaces: [],
|
|
||||||
projects: [],
|
|
||||||
timeline: null,
|
|
||||||
};
|
|
||||||
const payload = JSON.parse(row.payload) as Record<string, unknown>;
|
|
||||||
switch (row.kind) {
|
|
||||||
case "agent":
|
|
||||||
host.agents.push(payload);
|
|
||||||
break;
|
|
||||||
case "workspace":
|
|
||||||
host.workspaces.push(payload);
|
|
||||||
break;
|
|
||||||
case "project":
|
|
||||||
host.projects.push(payload);
|
|
||||||
break;
|
|
||||||
case "timeline":
|
|
||||||
host.timeline = payload;
|
|
||||||
break;
|
|
||||||
case "checkpoint":
|
|
||||||
host.directorySync = payload;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
hosts.set(row.serverId, host);
|
|
||||||
}
|
|
||||||
return { version: 6, hosts: [...hosts.values()] };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readReplicaCache(page: Page): Promise<ReplicaCacheRecord | null> {
|
export async function readReplicaCache(page: Page): Promise<ReplicaCacheRecord | null> {
|
||||||
const rows = await page.evaluate(readRows, {
|
const raw = await page.evaluate(readStoredValue, {
|
||||||
databaseName: DATABASE_NAME,
|
databaseName: DATABASE_NAME,
|
||||||
storeName: STORE_NAME,
|
storeName: STORE_NAME,
|
||||||
|
storageKey: STORAGE_KEY,
|
||||||
});
|
});
|
||||||
return rows.length > 0 ? assembleCache(rows) : null;
|
return raw ? (JSON.parse(raw) as ReplicaCacheRecord) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord): Promise<void> {
|
export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord): Promise<void> {
|
||||||
const rows: ReplicaRowRecord[] = [];
|
|
||||||
for (const host of value.hosts ?? []) {
|
|
||||||
for (const agent of host.agents) {
|
|
||||||
const snapshot = agent.snapshot;
|
|
||||||
if (!snapshot || typeof snapshot !== "object") continue;
|
|
||||||
const id = Reflect.get(snapshot, "id");
|
|
||||||
if (typeof id === "string") {
|
|
||||||
rows.push({ serverId: host.serverId, kind: "agent", id, payload: JSON.stringify(agent) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const workspace of host.workspaces) {
|
|
||||||
if (typeof workspace.id === "string") {
|
|
||||||
rows.push({
|
|
||||||
serverId: host.serverId,
|
|
||||||
kind: "workspace",
|
|
||||||
id: workspace.id,
|
|
||||||
payload: JSON.stringify(workspace),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const project of host.projects) {
|
|
||||||
if (typeof project.projectId === "string") {
|
|
||||||
rows.push({
|
|
||||||
serverId: host.serverId,
|
|
||||||
kind: "project",
|
|
||||||
id: project.projectId,
|
|
||||||
payload: JSON.stringify(project),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (host.timeline) {
|
|
||||||
rows.push({
|
|
||||||
serverId: host.serverId,
|
|
||||||
kind: "timeline",
|
|
||||||
id: SINGLETON_ID,
|
|
||||||
payload: JSON.stringify(host.timeline),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (host.directorySync !== undefined) {
|
|
||||||
rows.push({
|
|
||||||
serverId: host.serverId,
|
|
||||||
kind: "checkpoint",
|
|
||||||
id: SINGLETON_ID,
|
|
||||||
payload: JSON.stringify(host.directorySync),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
async ({ databaseName, storeName, nextRows }) => {
|
async ({ databaseName, storeName, storageKey, raw }) => {
|
||||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||||
const request = indexedDB.open(databaseName, 1);
|
const request = indexedDB.open(databaseName, 1);
|
||||||
request.addEventListener("success", () => resolve(request.result));
|
request.addEventListener("success", () => resolve(request.result));
|
||||||
@@ -173,23 +74,26 @@ export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord):
|
|||||||
});
|
});
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const transaction = database.transaction(storeName, "readwrite");
|
const transaction = database.transaction(storeName, "readwrite");
|
||||||
const store = transaction.objectStore(storeName);
|
transaction.objectStore(storeName).put(raw, storageKey);
|
||||||
store.clear();
|
|
||||||
for (const row of nextRows) store.put(row);
|
|
||||||
transaction.addEventListener("complete", () => resolve());
|
transaction.addEventListener("complete", () => resolve());
|
||||||
transaction.addEventListener("error", () => reject(transaction.error));
|
transaction.addEventListener("error", () => reject(transaction.error));
|
||||||
transaction.addEventListener("abort", () => reject(transaction.error));
|
transaction.addEventListener("abort", () => reject(transaction.error));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ databaseName: DATABASE_NAME, storeName: STORE_NAME, nextRows: rows },
|
{
|
||||||
|
databaseName: DATABASE_NAME,
|
||||||
|
storeName: STORE_NAME,
|
||||||
|
storageKey: STORAGE_KEY,
|
||||||
|
raw: JSON.stringify(value),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function observeReplicaCacheStorageWrites(page: Page): Promise<void> {
|
export async function observeReplicaCacheStorageWrites(page: Page): Promise<void> {
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ databaseName, storeName }) => {
|
({ databaseName, storeName, storageKey }) => {
|
||||||
window.__replicaCacheWriteObserver = {
|
window.__replicaCacheWriteObserver = {
|
||||||
lastValues: {},
|
lastValue: null,
|
||||||
redundantWrites: 0,
|
redundantWrites: 0,
|
||||||
serializedChars: 0,
|
serializedChars: 0,
|
||||||
storageDurationMs: 0,
|
storageDurationMs: 0,
|
||||||
@@ -202,71 +106,51 @@ export async function observeReplicaCacheStorageWrites(page: Page): Promise<void
|
|||||||
if (
|
if (
|
||||||
this.transaction.db.name !== databaseName ||
|
this.transaction.db.name !== databaseName ||
|
||||||
this.name !== storeName ||
|
this.name !== storeName ||
|
||||||
!value ||
|
key !== storageKey ||
|
||||||
typeof value !== "object"
|
typeof value !== "string"
|
||||||
) {
|
) {
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
const serverId = Reflect.get(value, "serverId");
|
|
||||||
const kind = Reflect.get(value, "kind");
|
|
||||||
const id = Reflect.get(value, "id");
|
|
||||||
const payload = Reflect.get(value, "payload");
|
|
||||||
if (
|
|
||||||
typeof serverId !== "string" ||
|
|
||||||
typeof kind !== "string" ||
|
|
||||||
typeof id !== "string" ||
|
|
||||||
typeof payload !== "string"
|
|
||||||
) {
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
const rowKey = `${serverId}:${kind}:${id}`;
|
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
request.addEventListener("success", () => {
|
request.addEventListener("success", () => {
|
||||||
const state = window.__replicaCacheWriteObserver;
|
const state = window.__replicaCacheWriteObserver;
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
if (state.lastValues[rowKey] === payload) state.redundantWrites += 1;
|
if (state.lastValue === value) state.redundantWrites += 1;
|
||||||
state.lastValues[rowKey] = payload;
|
state.lastValue = value;
|
||||||
state.writes += 1;
|
state.writes += 1;
|
||||||
state.serializedChars += payload.length;
|
state.serializedChars += value.length;
|
||||||
state.storageDurationMs += performance.now() - startedAt;
|
state.storageDurationMs += performance.now() - startedAt;
|
||||||
});
|
});
|
||||||
return request;
|
return request;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
{ databaseName: DATABASE_NAME, storeName: STORE_NAME },
|
{ databaseName: DATABASE_NAME, storeName: STORE_NAME, storageKey: STORAGE_KEY },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resetReplicaCacheStorageWriteObserver(page: Page): Promise<void> {
|
export async function resetReplicaCacheStorageWriteObserver(page: Page): Promise<void> {
|
||||||
const rows = await page.evaluate(readRows, {
|
const lastValue = await page.evaluate(readStoredValue, {
|
||||||
databaseName: DATABASE_NAME,
|
databaseName: DATABASE_NAME,
|
||||||
storeName: STORE_NAME,
|
storeName: STORE_NAME,
|
||||||
|
storageKey: STORAGE_KEY,
|
||||||
});
|
});
|
||||||
const lastValues = Object.fromEntries(
|
await page.evaluate((value) => {
|
||||||
rows.map((row) => [`${row.serverId}:${row.kind}:${row.id}`, row.payload]),
|
|
||||||
);
|
|
||||||
await page.evaluate((values) => {
|
|
||||||
window.__replicaCacheWriteObserver = {
|
window.__replicaCacheWriteObserver = {
|
||||||
lastValues: values,
|
lastValue: value,
|
||||||
redundantWrites: 0,
|
redundantWrites: 0,
|
||||||
serializedChars: 0,
|
serializedChars: 0,
|
||||||
storageDurationMs: 0,
|
storageDurationMs: 0,
|
||||||
writes: 0,
|
writes: 0,
|
||||||
};
|
};
|
||||||
}, lastValues);
|
}, lastValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readReplicaCacheStorageWriteObserver(
|
export async function readReplicaCacheStorageWriteObserver(
|
||||||
page: Page,
|
page: Page,
|
||||||
): Promise<ReplicaCacheWriteObserverReport> {
|
): Promise<ReplicaCacheWriteObserverState> {
|
||||||
return page.evaluate(() => {
|
return page.evaluate(() => {
|
||||||
const state = window.__replicaCacheWriteObserver;
|
const state = window.__replicaCacheWriteObserver;
|
||||||
if (!state) throw new Error("Replica cache write observer is not installed");
|
if (!state) throw new Error("Replica cache write observer is not installed");
|
||||||
return {
|
return state;
|
||||||
redundantWrites: state.redundantWrites,
|
|
||||||
serializedChars: state.serializedChars,
|
|
||||||
storageDurationMs: state.storageDurationMs,
|
|
||||||
writes: state.writes,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { summarizeStreamSmoothness } from "./stream-smoothness";
|
|
||||||
|
|
||||||
describe("summarizeStreamSmoothness", () => {
|
|
||||||
it("reports canonical handover snaps separately from paced growth", () => {
|
|
||||||
const report = summarizeStreamSmoothness(
|
|
||||||
[
|
|
||||||
{ timestampMs: 0, growthChars: 0, firstPaintChars: 0 },
|
|
||||||
{ timestampMs: 10, growthChars: 4, firstPaintChars: 0 },
|
|
||||||
{ timestampMs: 20, growthChars: 100, firstPaintChars: 30 },
|
|
||||||
{ timestampMs: 30, growthChars: 4, firstPaintChars: 0 },
|
|
||||||
{ timestampMs: 40, growthChars: 0, firstPaintChars: 0 },
|
|
||||||
],
|
|
||||||
40,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(report).toMatchObject({
|
|
||||||
frames: 3,
|
|
||||||
growthFrames: 2,
|
|
||||||
charsPainted: 8,
|
|
||||||
maxCharsInOneFrame: 4,
|
|
||||||
firstPaintFrames: 1,
|
|
||||||
firstPaintChars: 30,
|
|
||||||
handoverGrowthFrames: 1,
|
|
||||||
handoverGrowthChars: 100,
|
|
||||||
maxHandoverGrowthCharsInOneFrame: 100,
|
|
||||||
updateGapP95Ms: 10,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
import type { Page } from "@playwright/test";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Frame-level sampling of the streaming assistant message, and the two numbers
|
|
||||||
* that describe how it reads:
|
|
||||||
*
|
|
||||||
* - `charsPerFrameCv` — coefficient of variation of characters painted per frame.
|
|
||||||
* This is the smoothness metric. Painting deltas as they land tracks the
|
|
||||||
* arrival lumps, so it is high; pacing the reveal flattens it.
|
|
||||||
* - `updateGapP95Ms` — how long the text can sit unchanged mid-stream. This is
|
|
||||||
* the stall metric, and it is what stops smoothness being bought with lag.
|
|
||||||
*
|
|
||||||
* Both are needed. A stream that never updates is perfectly smooth.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface StreamFrameSample {
|
|
||||||
timestampMs: number;
|
|
||||||
growthChars: number;
|
|
||||||
firstPaintChars: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StreamSmoothnessReport {
|
|
||||||
sampleWindowMs: number;
|
|
||||||
frames: number;
|
|
||||||
growthFrames: number;
|
|
||||||
charsPainted: number;
|
|
||||||
meanCharsPerFrame: number;
|
|
||||||
maxCharsInOneFrame: number;
|
|
||||||
charsPerFrameCv: number;
|
|
||||||
firstPaintFrames: number;
|
|
||||||
firstPaintChars: number;
|
|
||||||
maxFirstPaintCharsInOneFrame: number;
|
|
||||||
handoverGrowthFrames: number;
|
|
||||||
handoverGrowthChars: number;
|
|
||||||
maxHandoverGrowthCharsInOneFrame: number;
|
|
||||||
updateGapP50Ms: number;
|
|
||||||
updateGapP95Ms: number;
|
|
||||||
updateGapMaxMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Record text growth on every mounted assistant message once per frame.
|
|
||||||
*
|
|
||||||
* Message occurrence identity separates paced growth from first paint. Completed markdown
|
|
||||||
* blocks move into canonical timeline rows while a turn streams, remounting text
|
|
||||||
* that was already visible. Counting the new node's full length as growth would
|
|
||||||
* report that handover as a large reveal even though no text appeared on screen.
|
|
||||||
* First paints stay in the report as a separate diagnostic.
|
|
||||||
*
|
|
||||||
* Runs entirely in the page so the sampling clock is the same clock the reveal
|
|
||||||
* runs on. Waits for the text to start moving first, so a slow model's
|
|
||||||
* time-to-first-token is not counted as a stall.
|
|
||||||
*/
|
|
||||||
export async function sampleStreamFrames(
|
|
||||||
page: Page,
|
|
||||||
options: { sampleWindowMs: number; startTimeoutMs?: number },
|
|
||||||
): Promise<StreamFrameSample[]> {
|
|
||||||
const startTimeoutMs = options.startTimeoutMs ?? 60_000;
|
|
||||||
return await page.evaluate(
|
|
||||||
async ({ sampleWindowMs, startTimeoutMs: waitMs }) => {
|
|
||||||
const readLength = () => {
|
|
||||||
let total = 0;
|
|
||||||
for (const node of document.querySelectorAll('[data-testid="assistant-message"]')) {
|
|
||||||
total += node.textContent?.length ?? 0;
|
|
||||||
}
|
|
||||||
return total;
|
|
||||||
};
|
|
||||||
|
|
||||||
const waitStart = performance.now();
|
|
||||||
const base = readLength();
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
const poll = () => {
|
|
||||||
if (readLength() > base + 20 || performance.now() - waitStart > waitMs) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
requestAnimationFrame(poll);
|
|
||||||
};
|
|
||||||
requestAnimationFrame(poll);
|
|
||||||
});
|
|
||||||
|
|
||||||
let previousLengths = new Map<string, number>();
|
|
||||||
for (const node of document.querySelectorAll('[data-testid="assistant-message"]')) {
|
|
||||||
const key = node.getAttribute("data-reveal-key");
|
|
||||||
const length = Number(node.getAttribute("data-reveal-length"));
|
|
||||||
if (key !== null && Number.isFinite(length)) {
|
|
||||||
previousLengths.set(key, length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const samples: Array<{
|
|
||||||
timestampMs: number;
|
|
||||||
growthChars: number;
|
|
||||||
firstPaintChars: number;
|
|
||||||
}> = [];
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
const start = performance.now();
|
|
||||||
const tick = (now: number) => {
|
|
||||||
const currentLengths = new Map<string, number>();
|
|
||||||
let growthChars = 0;
|
|
||||||
let firstPaintChars = 0;
|
|
||||||
for (const node of document.querySelectorAll('[data-testid="assistant-message"]')) {
|
|
||||||
const key = node.getAttribute("data-reveal-key");
|
|
||||||
const currentLength = Number(node.getAttribute("data-reveal-length"));
|
|
||||||
if (key === null || !Number.isFinite(currentLength)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const previousLength = previousLengths.get(key);
|
|
||||||
if (previousLength === undefined || currentLength < previousLength) {
|
|
||||||
firstPaintChars += currentLength;
|
|
||||||
} else {
|
|
||||||
const nodeGrowth = currentLength - previousLength;
|
|
||||||
growthChars += nodeGrowth;
|
|
||||||
}
|
|
||||||
currentLengths.set(key, currentLength);
|
|
||||||
}
|
|
||||||
samples.push({
|
|
||||||
timestampMs: now,
|
|
||||||
growthChars,
|
|
||||||
firstPaintChars,
|
|
||||||
});
|
|
||||||
previousLengths = currentLengths;
|
|
||||||
if (now - start >= sampleWindowMs) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
requestAnimationFrame(tick);
|
|
||||||
};
|
|
||||||
requestAnimationFrame(tick);
|
|
||||||
});
|
|
||||||
return samples;
|
|
||||||
},
|
|
||||||
{ sampleWindowMs: options.sampleWindowMs, startTimeoutMs },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function computePercentile(samples: readonly number[], percentile: number): number {
|
|
||||||
if (samples.length === 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const sorted = [...samples].sort((a, b) => a - b);
|
|
||||||
return sorted[Math.max(0, Math.ceil((percentile / 100) * sorted.length) - 1)];
|
|
||||||
}
|
|
||||||
|
|
||||||
function round2(value: number): number {
|
|
||||||
return Math.round(value * 100) / 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reduce frame samples to the report. Frames outside the streaming window are
|
|
||||||
* dropped: leading idle is time-to-first-token, and trailing idle means the turn
|
|
||||||
* ended mid-sample. First-paint frames are structural handovers. Existing blocks
|
|
||||||
* may also snap complete on those frames, so their growth is reported separately
|
|
||||||
* from the paced-growth CV.
|
|
||||||
*/
|
|
||||||
export function summarizeStreamSmoothness(
|
|
||||||
samples: readonly StreamFrameSample[],
|
|
||||||
sampleWindowMs: number,
|
|
||||||
): StreamSmoothnessReport {
|
|
||||||
const deltas: number[] = [];
|
|
||||||
const gapsMs: number[] = [];
|
|
||||||
let lastUpdateAtMs: number | null = null;
|
|
||||||
let firstGrowth = -1;
|
|
||||||
let lastGrowth = -1;
|
|
||||||
const handoverGrowths: number[] = [];
|
|
||||||
|
|
||||||
for (let index = 0; index < samples.length; index += 1) {
|
|
||||||
const sample = samples[index];
|
|
||||||
const isHandover = sample.firstPaintChars > 0;
|
|
||||||
const growth = isHandover ? 0 : sample.growthChars;
|
|
||||||
if (isHandover && sample.growthChars > 0) {
|
|
||||||
handoverGrowths.push(sample.growthChars);
|
|
||||||
lastUpdateAtMs = sample.timestampMs;
|
|
||||||
}
|
|
||||||
deltas.push(growth);
|
|
||||||
if (growth > 0) {
|
|
||||||
if (firstGrowth === -1) {
|
|
||||||
firstGrowth = deltas.length - 1;
|
|
||||||
}
|
|
||||||
lastGrowth = deltas.length - 1;
|
|
||||||
if (lastUpdateAtMs !== null) {
|
|
||||||
gapsMs.push(samples[index].timestampMs - lastUpdateAtMs);
|
|
||||||
}
|
|
||||||
lastUpdateAtMs = samples[index].timestampMs;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const active = firstGrowth === -1 ? [] : deltas.slice(firstGrowth, lastGrowth + 1);
|
|
||||||
const charsPainted = active.reduce((sum, delta) => sum + delta, 0);
|
|
||||||
const mean = active.length > 0 ? charsPainted / active.length : 0;
|
|
||||||
const variance =
|
|
||||||
active.length > 0
|
|
||||||
? active.reduce((sum, delta) => sum + (delta - mean) ** 2, 0) / active.length
|
|
||||||
: 0;
|
|
||||||
const firstPaints = samples.map((sample) => sample.firstPaintChars).filter((chars) => chars > 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sampleWindowMs,
|
|
||||||
frames: active.length,
|
|
||||||
growthFrames: active.filter((delta) => delta > 0).length,
|
|
||||||
charsPainted,
|
|
||||||
meanCharsPerFrame: round2(mean),
|
|
||||||
maxCharsInOneFrame: active.length > 0 ? Math.max(...active) : 0,
|
|
||||||
charsPerFrameCv: mean === 0 ? Number.POSITIVE_INFINITY : round2(Math.sqrt(variance) / mean),
|
|
||||||
firstPaintFrames: firstPaints.length,
|
|
||||||
firstPaintChars: firstPaints.reduce((sum, chars) => sum + chars, 0),
|
|
||||||
maxFirstPaintCharsInOneFrame: firstPaints.length > 0 ? Math.max(...firstPaints) : 0,
|
|
||||||
handoverGrowthFrames: handoverGrowths.length,
|
|
||||||
handoverGrowthChars: handoverGrowths.reduce((sum, chars) => sum + chars, 0),
|
|
||||||
maxHandoverGrowthCharsInOneFrame: handoverGrowths.length > 0 ? Math.max(...handoverGrowths) : 0,
|
|
||||||
updateGapP50Ms: round2(computePercentile(gapsMs, 50)),
|
|
||||||
updateGapP95Ms: round2(computePercentile(gapsMs, 95)),
|
|
||||||
updateGapMaxMs: gapsMs.length > 0 ? round2(Math.max(...gapsMs)) : 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
const versionPattern = /^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/;
|
const versionPattern = /^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/;
|
||||||
const stableIosBuildSlot = 999;
|
const stableIosBuildSlot = 999;
|
||||||
const FDROID_ABI_VERSION_CODE_SUFFIXES = {
|
|
||||||
"armeabi-v7a": 1,
|
|
||||||
"arm64-v8a": 2,
|
|
||||||
x86: 3,
|
|
||||||
x86_64: 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
function getNativeReleaseVersion(version) {
|
function getNativeReleaseVersion(version) {
|
||||||
const match = versionPattern.exec(version);
|
const match = versionPattern.exec(version);
|
||||||
@@ -27,11 +21,7 @@ function getNativeReleaseVersion(version) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const versionCode = major * 1_000_000 + minor * 1_000 + patch;
|
const versionCode = major * 1_000_000 + minor * 1_000 + patch;
|
||||||
if (
|
if (!Number.isSafeInteger(versionCode) || versionCode <= 0 || versionCode > 2_100_000_000) {
|
||||||
!Number.isSafeInteger(versionCode) ||
|
|
||||||
versionCode <= 0 ||
|
|
||||||
versionCode * 10 + 9 > 2_100_000_000
|
|
||||||
) {
|
|
||||||
throw new Error(`Derived Android versionCode is out of range: ${versionCode}`);
|
throw new Error(`Derived Android versionCode is out of range: ${versionCode}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,16 +38,4 @@ function getNativeReleaseVersion(version) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFdroidVersionCodes(version) {
|
module.exports = { getNativeReleaseVersion };
|
||||||
const { androidVersionCode } = getNativeReleaseVersion(version);
|
|
||||||
return Object.entries(FDROID_ABI_VERSION_CODE_SUFFIXES).map(([abi, suffix]) => ({
|
|
||||||
abi,
|
|
||||||
versionCode: androidVersionCode * 10 + suffix,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
FDROID_ABI_VERSION_CODE_SUFFIXES,
|
|
||||||
getFdroidVersionCodes,
|
|
||||||
getNativeReleaseVersion,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const {
|
const { getNativeReleaseVersion } = require("./native-release-version");
|
||||||
FDROID_ABI_VERSION_CODE_SUFFIXES,
|
|
||||||
getFdroidVersionCodes,
|
|
||||||
getNativeReleaseVersion,
|
|
||||||
} = require("./native-release-version");
|
|
||||||
|
|
||||||
describe("native release version", () => {
|
describe("native release version", () => {
|
||||||
it("reserves the final iOS build slot for a stable release", () => {
|
it("reserves the final iOS build slot for a stable release", () => {
|
||||||
@@ -28,19 +24,4 @@ describe("native release version", () => {
|
|||||||
"iOS beta number must be between 1 and 998",
|
"iOS beta number must be between 1 and 998",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("derives one F-Droid version code per published ABI", () => {
|
|
||||||
expect(FDROID_ABI_VERSION_CODE_SUFFIXES).toEqual({
|
|
||||||
"armeabi-v7a": 1,
|
|
||||||
"arm64-v8a": 2,
|
|
||||||
x86: 3,
|
|
||||||
x86_64: 4,
|
|
||||||
});
|
|
||||||
expect(getFdroidVersionCodes("0.5.0")).toEqual([
|
|
||||||
{ abi: "armeabi-v7a", versionCode: 50001 },
|
|
||||||
{ abi: "arm64-v8a", versionCode: 50002 },
|
|
||||||
{ abi: "x86", versionCode: 50003 },
|
|
||||||
{ abi: "x86_64", versionCode: 50004 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -23,7 +23,6 @@
|
|||||||
"test:browser": "vitest run --project browser",
|
"test:browser": "vitest run --project browser",
|
||||||
"test:e2e": "playwright test --project=browser",
|
"test:e2e": "playwright test --project=browser",
|
||||||
"test:e2e:diff-performance": "cross-env PASEO_DIFF_PERF_E2E=1 playwright test --project=browser e2e/browser/diff-performance.spec.ts",
|
"test:e2e:diff-performance": "cross-env PASEO_DIFF_PERF_E2E=1 playwright test --project=browser e2e/browser/diff-performance.spec.ts",
|
||||||
"test:e2e:stream-smoothness": "cross-env PASEO_AGENT_STREAM_PERF_E2E=1 playwright test --project=browser e2e/browser/agent-stream-smoothness.spec.ts",
|
|
||||||
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
|
"test:e2e:real": "cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=real-provider",
|
||||||
"test:e2e:relay-deployment": "npm --prefix ../.. run build:server && npm --prefix ../.. run build:daemon-web-ui && cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=relay-deployment e2e/browser/relay-deployment-reconnect.real.spec.ts",
|
"test:e2e:relay-deployment": "npm --prefix ../.. run build:server && npm --prefix ../.. run build:daemon-web-ui && cross-env E2E_FORK_PASEO_HOME_FROM=../../.dev/paseo-home playwright test --project=relay-deployment e2e/browser/relay-deployment-reconnect.real.spec.ts",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
@@ -96,7 +95,6 @@
|
|||||||
"expo-router": "~6.0.13",
|
"expo-router": "~6.0.13",
|
||||||
"expo-sharing": "^14.0.8",
|
"expo-sharing": "^14.0.8",
|
||||||
"expo-splash-screen": "~31.0.10",
|
"expo-splash-screen": "~31.0.10",
|
||||||
"expo-sqlite": "~16.0.10",
|
|
||||||
"expo-system-ui": "~6.0.7",
|
"expo-system-ui": "~6.0.7",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"htmlparser2": "^12.0.0",
|
"htmlparser2": "^12.0.0",
|
||||||
@@ -138,7 +136,6 @@
|
|||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
"@types/node": "~22.19.0",
|
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"@types/turndown": "^5.0.6",
|
"@types/turndown": "^5.0.6",
|
||||||
@@ -152,7 +149,6 @@
|
|||||||
"eslint": "^9.25.0",
|
"eslint": "^9.25.0",
|
||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
"expo-gradle-jvmargs": "^1.1.2",
|
"expo-gradle-jvmargs": "^1.1.2",
|
||||||
"fake-indexeddb": "^6.2.5",
|
|
||||||
"jsdom": "^20.0.3",
|
"jsdom": "^20.0.3",
|
||||||
"material-icon-theme": "^5.32.0",
|
"material-icon-theme": "^5.32.0",
|
||||||
"playwright": "^1.56.1",
|
"playwright": "^1.56.1",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
const fs = require("node:fs/promises");
|
const fs = require("node:fs/promises");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const { withAppBuildGradle, withDangerousMod, withSettingsGradle } = require("expo/config-plugins");
|
const { withAppBuildGradle, withDangerousMod, withSettingsGradle } = require("expo/config-plugins");
|
||||||
const { FDROID_ABI_VERSION_CODE_SUFFIXES } = require("../native-release-version");
|
|
||||||
|
|
||||||
const EXCLUDED_ANDROID_MODULES = [
|
const EXCLUDED_ANDROID_MODULES = [
|
||||||
"expo-camera",
|
"expo-camera",
|
||||||
@@ -12,15 +11,12 @@ const EXCLUDED_ANDROID_MODULES = [
|
|||||||
"expo-dev-menu-interface",
|
"expo-dev-menu-interface",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Generated from the shared suffix table so the Groovy literal can never drift
|
|
||||||
// from the version codes the F-Droid changelog filenames are derived from.
|
|
||||||
const FDROID_ABI_VERSION_CODE_ENTRIES = Object.entries(FDROID_ABI_VERSION_CODE_SUFFIXES)
|
|
||||||
.map(([abi, suffix]) => ` "${abi}": ${suffix},`)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
const FDROID_ABI_VERSION_CODE_BLOCK = `// Paseo F-Droid single-ABI version codes
|
const FDROID_ABI_VERSION_CODE_BLOCK = `// Paseo F-Droid single-ABI version codes
|
||||||
def paseoAbiVersionCodes = [
|
def paseoAbiVersionCodes = [
|
||||||
${FDROID_ABI_VERSION_CODE_ENTRIES}
|
"armeabi-v7a": 1,
|
||||||
|
"arm64-v8a": 2,
|
||||||
|
"x86": 3,
|
||||||
|
"x86_64": 4,
|
||||||
]
|
]
|
||||||
def paseoArchitectures = (findProperty("reactNativeArchitectures") ?: "")
|
def paseoArchitectures = (findProperty("reactNativeArchitectures") ?: "")
|
||||||
.toString()
|
.toString()
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface BottomAnchorRouteRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BottomAnchorLocalRequest {
|
export interface BottomAnchorLocalRequest {
|
||||||
reason: "jump-to-bottom" | "message-sent" | "rewind";
|
reason: "jump-to-bottom" | "message-sent";
|
||||||
agentId: string;
|
agentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,277 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import {
|
|
||||||
advanceTextReveal,
|
|
||||||
beginTextReveal,
|
|
||||||
clampToSafeRevealBoundary,
|
|
||||||
completeTextReveal,
|
|
||||||
computeRevealStep,
|
|
||||||
nextTextRevealFrame,
|
|
||||||
isTextRevealSettled,
|
|
||||||
retargetTextReveal,
|
|
||||||
TEXT_REVEAL_FRAME_INTERVAL_MS,
|
|
||||||
TEXT_REVEAL_HORIZON_MS,
|
|
||||||
visibleRevealedText,
|
|
||||||
} from "./text-reveal";
|
|
||||||
|
|
||||||
describe("computeRevealStep", () => {
|
|
||||||
it("reveals nothing when there is no backlog", () => {
|
|
||||||
expect(computeRevealStep({ backlog: 0, elapsedMs: 16 })).toBe(0);
|
|
||||||
expect(computeRevealStep({ backlog: -5, elapsedMs: 16 })).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drains the backlog over the horizon", () => {
|
|
||||||
const step = computeRevealStep({ backlog: 300, elapsedMs: TEXT_REVEAL_HORIZON_MS / 2 });
|
|
||||||
expect(step).toBe(150);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reveals the whole backlog once a frame covers the horizon", () => {
|
|
||||||
expect(computeRevealStep({ backlog: 42, elapsedMs: TEXT_REVEAL_HORIZON_MS })).toBe(42);
|
|
||||||
expect(computeRevealStep({ backlog: 42, elapsedMs: TEXT_REVEAL_HORIZON_MS * 10 })).toBe(42);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("scales the rate with the backlog so a faster model reveals faster", () => {
|
|
||||||
const slow = computeRevealStep({ backlog: 20, elapsedMs: 16 });
|
|
||||||
const fast = computeRevealStep({ backlog: 200, elapsedMs: 16 });
|
|
||||||
expect(fast).toBeGreaterThan(slow);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("always advances at least one character so the tail finishes", () => {
|
|
||||||
expect(computeRevealStep({ backlog: 1, elapsedMs: 1 })).toBe(1);
|
|
||||||
expect(computeRevealStep({ backlog: 2, elapsedMs: 0.01 })).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never overshoots the backlog", () => {
|
|
||||||
for (const backlog of [1, 3, 7, 50]) {
|
|
||||||
expect(computeRevealStep({ backlog, elapsedMs: 120 })).toBeLessThanOrEqual(backlog);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reveals nothing when no time has passed", () => {
|
|
||||||
expect(computeRevealStep({ backlog: 100, elapsedMs: 0 })).toBe(0);
|
|
||||||
expect(computeRevealStep({ backlog: 100, elapsedMs: -16 })).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("converges within a bounded number of frames at 60Hz", () => {
|
|
||||||
let remaining = 500;
|
|
||||||
let frames = 0;
|
|
||||||
while (remaining > 0) {
|
|
||||||
remaining -= computeRevealStep({ backlog: remaining, elapsedMs: 16 });
|
|
||||||
frames += 1;
|
|
||||||
expect(frames).toBeLessThan(120);
|
|
||||||
}
|
|
||||||
expect(remaining).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps per-frame steps smooth for a bursty arrival pattern", () => {
|
|
||||||
// A lumpy arrival sequence: the daemon's coalescing window carries wildly
|
|
||||||
// different character counts depending on model throughput.
|
|
||||||
const arrivals = [4, 180, 6, 240, 2, 90, 300, 8];
|
|
||||||
const steps: number[] = [];
|
|
||||||
let backlog = 0;
|
|
||||||
|
|
||||||
for (const arrival of arrivals) {
|
|
||||||
backlog += arrival;
|
|
||||||
// ~60ms of frames between coalesced flushes.
|
|
||||||
for (let frame = 0; frame < 4; frame += 1) {
|
|
||||||
const step = computeRevealStep({ backlog, elapsedMs: 16 });
|
|
||||||
backlog -= step;
|
|
||||||
steps.push(step);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Painting arrivals directly would swing from 2 to 300 characters in one
|
|
||||||
// paint. Pacing keeps the largest single paint far below the largest arrival.
|
|
||||||
expect(Math.max(...steps)).toBeLessThan(Math.max(...arrivals) / 2);
|
|
||||||
expect(Math.min(...steps)).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("clampToSafeRevealBoundary", () => {
|
|
||||||
it("leaves boundaries in plain text alone", () => {
|
|
||||||
expect(clampToSafeRevealBoundary("hello world", 5)).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clamps the ends", () => {
|
|
||||||
expect(clampToSafeRevealBoundary("hello", -3)).toBe(0);
|
|
||||||
expect(clampToSafeRevealBoundary("hello", 99)).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never splits a surrogate pair", () => {
|
|
||||||
// "a😀b" — the emoji occupies indices 1 and 2.
|
|
||||||
const text = "a😀b";
|
|
||||||
expect(clampToSafeRevealBoundary(text, 2)).toBe(1);
|
|
||||||
expect(clampToSafeRevealBoundary(text, 3)).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not strand a combining mark", () => {
|
|
||||||
// "e" + combining acute; cutting at 1 would show a bare "e" then reflow.
|
|
||||||
const text = `éx`;
|
|
||||||
expect(clampToSafeRevealBoundary(text, 1)).toBe(0);
|
|
||||||
expect(clampToSafeRevealBoundary(text, 2)).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not split an emoji ZWJ sequence", () => {
|
|
||||||
// Family emoji: person ZWJ person ZWJ child.
|
|
||||||
const family = "\u{1F468}\u{1F469}\u{1F467}";
|
|
||||||
const text = `${family}!`;
|
|
||||||
for (let index = 1; index < family.length; index += 1) {
|
|
||||||
expect(clampToSafeRevealBoundary(text, index)).toBe(0);
|
|
||||||
}
|
|
||||||
expect(clampToSafeRevealBoundary(text, family.length)).toBe(family.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not split a skin tone modifier from its base", () => {
|
|
||||||
const text = "\u{1F44D}\u{1F3FD} nice";
|
|
||||||
expect(clampToSafeRevealBoundary(text, 2)).toBe(0);
|
|
||||||
expect(clampToSafeRevealBoundary(text, 4)).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not strand a variation selector", () => {
|
|
||||||
const text = "❤️ done";
|
|
||||||
expect(clampToSafeRevealBoundary(text, 1)).toBe(0);
|
|
||||||
expect(clampToSafeRevealBoundary(text, 2)).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not split extended grapheme clusters", () => {
|
|
||||||
for (const { text, boundaries } of [
|
|
||||||
{ text: "a🇺🇸b", boundaries: [0, 1, 5, 6] },
|
|
||||||
{ text: "a가b", boundaries: [0, 1, 3, 4] },
|
|
||||||
{ text: "aकःb", boundaries: [0, 1, 3, 4] },
|
|
||||||
]) {
|
|
||||||
for (let index = 1; index < text.length; index += 1) {
|
|
||||||
const expected = boundaries.toReversed().find((boundary) => boundary <= index);
|
|
||||||
expect(clampToSafeRevealBoundary(text, index)).toBe(expected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("nextTextRevealFrame", () => {
|
|
||||||
it("caps a high-refresh frame clock at 60Hz", () => {
|
|
||||||
expect(nextTextRevealFrame(null, 0)).toEqual({
|
|
||||||
elapsedMs: TEXT_REVEAL_FRAME_INTERVAL_MS,
|
|
||||||
frameAtMs: 0,
|
|
||||||
});
|
|
||||||
expect(nextTextRevealFrame(0, 8)).toBeNull();
|
|
||||||
expect(nextTextRevealFrame(0, 17)).toEqual({
|
|
||||||
elapsedMs: 17,
|
|
||||||
frameAtMs: expect.closeTo(TEXT_REVEAL_FRAME_INTERVAL_MS, 5),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("text reveal state", () => {
|
|
||||||
it("reveals the first text it sees in full", () => {
|
|
||||||
const state = beginTextReveal("already here");
|
|
||||||
expect(visibleRevealedText(state)).toBe("already here");
|
|
||||||
expect(isTextRevealSettled(state)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("paces growth instead of painting it whole", () => {
|
|
||||||
let state = beginTextReveal("a");
|
|
||||||
state = retargetTextReveal(state, `a${"b".repeat(300)}`);
|
|
||||||
expect(visibleRevealedText(state)).toBe("a");
|
|
||||||
|
|
||||||
state = advanceTextReveal(state, 16);
|
|
||||||
const shown = visibleRevealedText(state);
|
|
||||||
expect(shown.length).toBeGreaterThan(1);
|
|
||||||
expect(shown.length).toBeLessThan(301);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("catches up to the full text across frames", () => {
|
|
||||||
const full = `a${"b".repeat(200)}`;
|
|
||||||
let state = retargetTextReveal(beginTextReveal("a"), full);
|
|
||||||
for (let frame = 0; frame < 60 && !isTextRevealSettled(state); frame += 1) {
|
|
||||||
state = advanceTextReveal(state, 16);
|
|
||||||
}
|
|
||||||
expect(isTextRevealSettled(state)).toBe(true);
|
|
||||||
expect(visibleRevealedText(state)).toBe(full);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("releases everything when the turn completes", () => {
|
|
||||||
const full = `a${"b".repeat(500)}`;
|
|
||||||
let state = advanceTextReveal(retargetTextReveal(beginTextReveal("a"), full), 16);
|
|
||||||
expect(visibleRevealedText(state)).not.toBe(full);
|
|
||||||
|
|
||||||
state = completeTextReveal(state);
|
|
||||||
expect(visibleRevealedText(state)).toBe(full);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("only ever paints a prefix of the real text", () => {
|
|
||||||
const full = "the quick brown fox jumps over the lazy dog";
|
|
||||||
let state = retargetTextReveal(beginTextReveal("the "), full);
|
|
||||||
for (let frame = 0; frame < 20; frame += 1) {
|
|
||||||
expect(full.startsWith(visibleRevealedText(state))).toBe(true);
|
|
||||||
state = advanceTextReveal(state, 16);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clamps when the slot switches to a shorter message", () => {
|
|
||||||
const state = retargetTextReveal(beginTextReveal("a long streaming message"), "short");
|
|
||||||
expect(visibleRevealedText(state)).toBe("short");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never paints a lone surrogate while streaming emoji", () => {
|
|
||||||
const full = `progress ${"\u{1F680}".repeat(40)} done`;
|
|
||||||
let state = retargetTextReveal(beginTextReveal("progress "), full);
|
|
||||||
for (let frame = 0; frame < 200 && !isTextRevealSettled(state); frame += 1) {
|
|
||||||
const shown = visibleRevealedText(state);
|
|
||||||
// A stranded surrogate would make the painted prefix un-decodable.
|
|
||||||
expect(shown).toBe(Array.from(shown).join(""));
|
|
||||||
expect(full.startsWith(shown)).toBe(true);
|
|
||||||
state = advanceTextReveal(state, 16);
|
|
||||||
}
|
|
||||||
expect(visibleRevealedText(completeTextReveal(state))).toBe(full);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a long cluster cannot stall the reveal", () => {
|
|
||||||
// The rendered boundary backs up over this cluster, but the counter does not,
|
|
||||||
// so the reveal still finishes.
|
|
||||||
const full = `x${"́".repeat(40)}y`;
|
|
||||||
let state = retargetTextReveal(beginTextReveal("x"), full);
|
|
||||||
for (let frame = 0; frame < 200 && !isTextRevealSettled(state); frame += 1) {
|
|
||||||
state = advanceTextReveal(state, 16);
|
|
||||||
}
|
|
||||||
expect(isTextRevealSettled(state)).toBe(true);
|
|
||||||
expect(visibleRevealedText(state)).toBe(full);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("trailing fragments from arrival boundaries", () => {
|
|
||||||
it("holds back half a flag while more text is coming", () => {
|
|
||||||
// The daemon's coalescing window can end a delta mid-cluster, so a caught-up
|
|
||||||
// reveal can be handed a single regional indicator.
|
|
||||||
const halfFlag = "flags: \u{1F1EC}";
|
|
||||||
const state = beginTextReveal(halfFlag);
|
|
||||||
expect(visibleRevealedText(state, { streaming: true })).toBe("flags: ");
|
|
||||||
// Once the turn ends nothing more is coming, so paint what there is.
|
|
||||||
expect(visibleRevealedText(state, { streaming: false })).toBe(halfFlag);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("paints the flag once its second half arrives", () => {
|
|
||||||
const whole = "flags: \u{1F1EC}\u{1F1E7}";
|
|
||||||
expect(visibleRevealedText(beginTextReveal(whole), { streaming: true })).toBe(whole);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps an even run of regional indicators intact", () => {
|
|
||||||
const twoFlags = "\u{1F1EC}\u{1F1E7}\u{1F1EB}\u{1F1F7}";
|
|
||||||
expect(visibleRevealedText(beginTextReveal(twoFlags), { streaming: true })).toBe(twoFlags);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("holds back a dangling joiner", () => {
|
|
||||||
expect(
|
|
||||||
visibleRevealedText(beginTextReveal("family: \u{1F468}\u200D"), { streaming: true }),
|
|
||||||
).toBe("family: \u{1F468}");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("holds back a dangling high surrogate", () => {
|
|
||||||
const loneHigh = `broken: ${String.fromCharCode(0xd83d)}`;
|
|
||||||
expect(visibleRevealedText(beginTextReveal(loneHigh), { streaming: true })).toBe("broken: ");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves ordinary trailing text alone", () => {
|
|
||||||
const prose = "just some prose";
|
|
||||||
expect(visibleRevealedText(beginTextReveal(prose), { streaming: true })).toBe(prose);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
/**
|
|
||||||
* Paced reveal for streaming assistant text.
|
|
||||||
*
|
|
||||||
* Deltas arrive lumpy: the daemon coalesces one message per 60ms window, carrying
|
|
||||||
* however many characters the model happened to produce in that window. Painting
|
|
||||||
* each delta as it lands makes the size of those lumps visible, which is what
|
|
||||||
* reads as jagged. So arrival changes the *target* and the reveal rate is derived
|
|
||||||
* from the backlog instead — a burst makes the text catch up faster, it doesn't
|
|
||||||
* make it jump.
|
|
||||||
*
|
|
||||||
* The store keeps the full text. Only the rendered slice is paced, so copy,
|
|
||||||
* selection, the chat outline, and scroll geometry all stay consistent with what
|
|
||||||
* is on screen.
|
|
||||||
*
|
|
||||||
* Everything here is pure. The only thing the React hook adds is a frame clock,
|
|
||||||
* which keeps the policy testable without a renderer.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Backlog is drained over this horizon. Shorter feels more like the raw arrival
|
|
||||||
// pattern; longer adds lag the user can notice at the end of a turn.
|
|
||||||
export const TEXT_REVEAL_HORIZON_MS = 150;
|
|
||||||
|
|
||||||
/** Reveal at most once per 60Hz frame, even on high-refresh displays. */
|
|
||||||
export const TEXT_REVEAL_FRAME_INTERVAL_MS = 1000 / 60;
|
|
||||||
|
|
||||||
// A frame's elapsed time is clamped to this before it is used, so a long stall
|
|
||||||
// (backgrounded tab, blocked main thread) doesn't produce a wild step from one
|
|
||||||
// enormous delta.
|
|
||||||
const MAX_ELAPSED_MS = 250;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Characters to reveal on this frame. Proportional to the backlog, so the reveal
|
|
||||||
* accelerates when the model runs ahead and settles when it doesn't, with a
|
|
||||||
* one-character floor so the tail always finishes.
|
|
||||||
*/
|
|
||||||
export function computeRevealStep(input: {
|
|
||||||
backlog: number;
|
|
||||||
elapsedMs: number;
|
|
||||||
horizonMs?: number;
|
|
||||||
}): number {
|
|
||||||
const { backlog } = input;
|
|
||||||
if (backlog <= 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const horizonMs = input.horizonMs ?? TEXT_REVEAL_HORIZON_MS;
|
|
||||||
if (horizonMs <= 0) {
|
|
||||||
return backlog;
|
|
||||||
}
|
|
||||||
|
|
||||||
const elapsedMs = Math.min(Math.max(input.elapsedMs, 0), MAX_ELAPSED_MS);
|
|
||||||
if (elapsedMs <= 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (elapsedMs >= horizonMs) {
|
|
||||||
return backlog;
|
|
||||||
}
|
|
||||||
|
|
||||||
const step = Math.ceil((backlog * elapsedMs) / horizonMs);
|
|
||||||
return Math.min(backlog, Math.max(1, step));
|
|
||||||
}
|
|
||||||
|
|
||||||
const ZERO_WIDTH_JOINER = 0x200d;
|
|
||||||
|
|
||||||
const graphemeSegmenter =
|
|
||||||
typeof Intl.Segmenter === "function"
|
|
||||||
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
|
||||||
: null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pacing needs a conformant grapheme segmenter. Older runtimes paint each
|
|
||||||
* arrival whole instead of risking a transient broken glyph.
|
|
||||||
*/
|
|
||||||
export function isTextRevealPacingSupported(): boolean {
|
|
||||||
return graphemeSegmenter !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pull a cut index back to somewhere it is safe to slice.
|
|
||||||
*
|
|
||||||
* A raw `slice` at an arbitrary index can split a grapheme cluster, which makes
|
|
||||||
* one visible glyph briefly render as separate parts while the text is streaming.
|
|
||||||
*
|
|
||||||
* This only moves the *rendered* boundary. The reveal counter stays monotonic, so
|
|
||||||
* a long cluster can never stall the reveal — the next frame steps past it.
|
|
||||||
*/
|
|
||||||
export function clampToSafeRevealBoundary(text: string, index: number): number {
|
|
||||||
if (index <= 0) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (index >= text.length) {
|
|
||||||
return text.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
const segment = graphemeSegmenter?.segment(text).containing(index);
|
|
||||||
return segment?.index ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TextRevealFrame {
|
|
||||||
elapsedMs: number;
|
|
||||||
frameAtMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keep reveal commits at 60Hz while carrying timing remainder forward so a
|
|
||||||
* high-refresh display does not make the reveal render at hardware frame rate.
|
|
||||||
*/
|
|
||||||
export function nextTextRevealFrame(
|
|
||||||
previousFrameAtMs: number | null,
|
|
||||||
timestampMs: number,
|
|
||||||
): TextRevealFrame | null {
|
|
||||||
const elapsedMs =
|
|
||||||
previousFrameAtMs === null ? TEXT_REVEAL_FRAME_INTERVAL_MS : timestampMs - previousFrameAtMs;
|
|
||||||
if (elapsedMs < TEXT_REVEAL_FRAME_INTERVAL_MS) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
elapsedMs,
|
|
||||||
frameAtMs: timestampMs - (elapsedMs % TEXT_REVEAL_FRAME_INTERVAL_MS),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TextRevealState {
|
|
||||||
/** The full text as the store knows it. */
|
|
||||||
readonly target: string;
|
|
||||||
/** How much of it has been released. Monotonic within a message. */
|
|
||||||
readonly revealed: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* First sight of a text is revealed whole. Only growth is paced, which is what
|
|
||||||
* makes history hydration, timeline replay, a virtualized row remounting on
|
|
||||||
* scroll, and an already-finished message all render complete on first paint
|
|
||||||
* without a special case for each.
|
|
||||||
*/
|
|
||||||
export function beginTextReveal(text: string): TextRevealState {
|
|
||||||
return { target: text, revealed: text.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Point the reveal at newly arrived text without changing how much is shown. */
|
|
||||||
export function retargetTextReveal(state: TextRevealState, text: string): TextRevealState {
|
|
||||||
if (state.target === text) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
// A shorter string means this slot is showing a different message than the one
|
|
||||||
// the reveal position belongs to.
|
|
||||||
return { target: text, revealed: Math.min(state.revealed, text.length) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Release one frame's worth of characters. */
|
|
||||||
export function advanceTextReveal(
|
|
||||||
state: TextRevealState,
|
|
||||||
elapsedMs: number,
|
|
||||||
horizonMs?: number,
|
|
||||||
): TextRevealState {
|
|
||||||
const step = computeRevealStep({
|
|
||||||
backlog: state.target.length - state.revealed,
|
|
||||||
elapsedMs,
|
|
||||||
...(horizonMs !== undefined ? { horizonMs } : {}),
|
|
||||||
});
|
|
||||||
if (step <= 0) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
return { target: state.target, revealed: Math.min(state.target.length, state.revealed + step) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Release everything, for when the turn ends and nothing may be held back. */
|
|
||||||
export function completeTextReveal(state: TextRevealState): TextRevealState {
|
|
||||||
if (state.revealed >= state.target.length) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
return { target: state.target, revealed: state.target.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isTextRevealSettled(state: TextRevealState): boolean {
|
|
||||||
return state.revealed >= state.target.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hold back a trailing fragment that cannot stand on its own.
|
|
||||||
*
|
|
||||||
* The reveal boundary is not the only place a cluster gets cut. The daemon's
|
|
||||||
* coalescing window ends a delta wherever the window closes, so a caught-up
|
|
||||||
* reveal can be handed half a flag and paint it — measured on a mixed-script
|
|
||||||
* stream as frames ending in a single regional indicator. Only fragments that
|
|
||||||
* are certainly waiting on more input are withheld: a dangling high surrogate, a
|
|
||||||
* trailing joiner, or an odd regional indicator.
|
|
||||||
*/
|
|
||||||
function trimIncompleteTrailingCluster(text: string): string {
|
|
||||||
if (text.length === 0) {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastUnit = text.charCodeAt(text.length - 1);
|
|
||||||
if (lastUnit >= 0xd800 && lastUnit <= 0xdbff) {
|
|
||||||
return text.slice(0, -1);
|
|
||||||
}
|
|
||||||
if (lastUnit === ZERO_WIDTH_JOINER) {
|
|
||||||
return text.slice(0, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const trailingRegionalIndicators = countTrailingRegionalIndicators(text);
|
|
||||||
if (trailingRegionalIndicators % 2 === 1) {
|
|
||||||
return text.slice(0, -2);
|
|
||||||
}
|
|
||||||
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Number of regional indicators at the end of `text`. A flag is a pair. */
|
|
||||||
function countTrailingRegionalIndicators(text: string): number {
|
|
||||||
let count = 0;
|
|
||||||
let cursor = text.length;
|
|
||||||
while (cursor >= 2) {
|
|
||||||
const codePoint = text.codePointAt(cursor - 2);
|
|
||||||
if (codePoint === undefined || codePoint < 0x1f1e6 || codePoint > 0x1f1ff) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
count += 1;
|
|
||||||
cursor -= 2;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What should actually be painted this frame.
|
|
||||||
*
|
|
||||||
* `streaming` says whether more text is still expected. While it is, a trailing
|
|
||||||
* fragment waits for the rest of its cluster; once the turn ends everything
|
|
||||||
* received is painted, because nothing more is coming.
|
|
||||||
*/
|
|
||||||
export function visibleRevealedText(
|
|
||||||
state: TextRevealState,
|
|
||||||
options?: { streaming?: boolean },
|
|
||||||
): string {
|
|
||||||
if (state.revealed >= state.target.length) {
|
|
||||||
return options?.streaming ? trimIncompleteTrailingCluster(state.target) : state.target;
|
|
||||||
}
|
|
||||||
return state.target.slice(0, clampToSafeRevealBoundary(state.target, state.revealed));
|
|
||||||
}
|
|
||||||
@@ -51,7 +51,6 @@ import type {
|
|||||||
} from "@getpaseo/protocol/agent-types";
|
} from "@getpaseo/protocol/agent-types";
|
||||||
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useRevealedText } from "@/hooks/use-revealed-text";
|
|
||||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||||
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
|
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
|
||||||
import { useSettings } from "@/hooks/use-settings";
|
import { useSettings } from "@/hooks/use-settings";
|
||||||
@@ -718,13 +717,15 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
const renderThoughtItem = useCallback(
|
const renderThoughtItem = useCallback(
|
||||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "thought" }>) => {
|
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "thought" }>) => {
|
||||||
return (
|
return (
|
||||||
<ThoughtSlot
|
<ToolCallSlot
|
||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
|
onInlineDetailsExpandedChangeByItemId={setInlineDetailsExpanded}
|
||||||
text={item.text}
|
toolName="thinking"
|
||||||
status={item.status}
|
args={item.text}
|
||||||
|
status={item.status === "ready" ? "completed" : "executing"}
|
||||||
isLastInSequence={layoutItem.isLastInToolSequence}
|
isLastInSequence={layoutItem.isLastInToolSequence}
|
||||||
defaultExpanded={autoExpandReasoning}
|
defaultExpanded={autoExpandReasoning}
|
||||||
|
forceInline={autoExpandReasoning}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1229,39 +1230,6 @@ interface ToolCallSlotProps extends Omit<
|
|||||||
onInlineDetailsExpandedChangeByItemId: (itemId: string, expanded: boolean) => void;
|
onInlineDetailsExpandedChangeByItemId: (itemId: string, expanded: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ThoughtSlotProps {
|
|
||||||
itemId: string;
|
|
||||||
onInlineDetailsExpandedChangeByItemId: (itemId: string, expanded: boolean) => void;
|
|
||||||
text: string;
|
|
||||||
status: Extract<StreamItem, { kind: "thought" }>["status"];
|
|
||||||
isLastInSequence: boolean;
|
|
||||||
defaultExpanded: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reasoning text is paced the same way assistant text is; see @/hooks/use-revealed-text.
|
|
||||||
function ThoughtSlot({
|
|
||||||
itemId,
|
|
||||||
onInlineDetailsExpandedChangeByItemId,
|
|
||||||
text,
|
|
||||||
status,
|
|
||||||
isLastInSequence,
|
|
||||||
defaultExpanded,
|
|
||||||
}: ThoughtSlotProps) {
|
|
||||||
const revealedText = useRevealedText(text, status === "ready" ? "complete" : "streaming");
|
|
||||||
return (
|
|
||||||
<ToolCallSlot
|
|
||||||
itemId={itemId}
|
|
||||||
onInlineDetailsExpandedChangeByItemId={onInlineDetailsExpandedChangeByItemId}
|
|
||||||
toolName="thinking"
|
|
||||||
args={revealedText}
|
|
||||||
status={status === "ready" ? "completed" : "executing"}
|
|
||||||
isLastInSequence={isLastInSequence}
|
|
||||||
defaultExpanded={defaultExpanded}
|
|
||||||
forceInline={defaultExpanded}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolCallSlot({
|
function ToolCallSlot({
|
||||||
itemId,
|
itemId,
|
||||||
onInlineDetailsExpandedChangeByItemId,
|
onInlineDetailsExpandedChangeByItemId,
|
||||||
|
|||||||
@@ -110,9 +110,7 @@ export type UserComposerAttachment =
|
|||||||
| PluginResourceComposerAttachment
|
| PluginResourceComposerAttachment
|
||||||
| { kind: "forge_issue"; item: ForgeSearchItem }
|
| { kind: "forge_issue"; item: ForgeSearchItem }
|
||||||
| { kind: "forge_change_request"; item: ForgeSearchItem }
|
| { kind: "forge_change_request"; item: ForgeSearchItem }
|
||||||
// COMPAT(githubAttachmentKinds): legacy persisted attachment kinds retained
|
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
|
||||||
// when forge-neutral kinds shipped in v0.2.0-beta.1. Remove after 2027-01-17
|
|
||||||
// once supported floors are >= v0.2.0 and old drafts no longer need them.
|
|
||||||
| { kind: "github_issue"; item: ForgeSearchItem }
|
| { kind: "github_issue"; item: ForgeSearchItem }
|
||||||
| {
|
| {
|
||||||
kind: "github_pr";
|
kind: "github_pr";
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import Svg, { Circle, Path } from "react-native-svg";
|
|
||||||
import type { SvgPathIconProps } from "./svg-path-icon";
|
|
||||||
|
|
||||||
// Forge-neutral rendering of GitLab's manual-status gear, aligned with the
|
|
||||||
// proportions of the other pipeline status icons.
|
|
||||||
const MANUAL_STATUS_GEAR_PATH =
|
|
||||||
"M10.5 7.63V6.37l-.787-.13c-.044-.175-.132-.349-.263-.61l.481-.652-.918-.913-.657.478a2.346 2.346 0 0 0-.612-.26L7.656 3.5H6.388l-.132.783c-.219.043-.394.13-.612.26l-.657-.478-.918.913.437.652c-.131.218-.175.392-.262.61l-.744.086v1.261l.787.13c.044.218.132.392.263.61l-.438.651.92.913.655-.434c.175.086.394.173.613.26l.131.783h1.313l.131-.783c.219-.043.394-.13.613-.26l.656.478.918-.913-.48-.652c.13-.218.218-.435.262-.61l.656-.13zM7 8.283a1.285 1.285 0 0 1-1.313-1.305c0-.739.57-1.304 1.313-1.304.744 0 1.313.565 1.313 1.304 0 .74-.57 1.305-1.313 1.305z";
|
|
||||||
|
|
||||||
export function ManualStatusIcon({ size = 16, color = "currentColor" }: SvgPathIconProps) {
|
|
||||||
return (
|
|
||||||
<Svg width={size} height={size} viewBox="-0.5 -0.5 15 15">
|
|
||||||
<Circle cx={7} cy={7} r={6.25} fill="none" stroke={color} strokeWidth={1.25} />
|
|
||||||
<Path d={MANUAL_STATUS_GEAR_PATH} fill={color} />
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -75,14 +75,12 @@ import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
|||||||
import { MarkdownFenceBlock } from "@/components/markdown/fence";
|
import { MarkdownFenceBlock } from "@/components/markdown/fence";
|
||||||
import type { MarkdownPhase } from "@/components/markdown/fence/types";
|
import type { MarkdownPhase } from "@/components/markdown/fence/types";
|
||||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||||
import { useRevealedText } from "@/hooks/use-revealed-text";
|
|
||||||
import { colorMarkdownLinkChildren } from "@/components/markdown/link-children";
|
import { colorMarkdownLinkChildren } from "@/components/markdown/link-children";
|
||||||
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
|
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
|
||||||
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
|
||||||
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
|
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
|
||||||
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
|
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
|
||||||
import { setAssistantMarkdownBlockHeight } from "@/utils/assistant-message-height-estimate";
|
import { setAssistantMarkdownBlockHeight } from "@/utils/assistant-message-height-estimate";
|
||||||
import { isRenderProfileEnabled } from "@/utils/render-profiler";
|
|
||||||
import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
|
import { getAgentAttachmentPillContent } from "@/attachments/attachment-pill-content";
|
||||||
import { PlanCard } from "./plan-card";
|
import { PlanCard } from "./plan-card";
|
||||||
import { useToolCallSheet } from "./tool-call-sheet";
|
import { useToolCallSheet } from "./tool-call-sheet";
|
||||||
@@ -1461,9 +1459,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
phase,
|
phase,
|
||||||
}: AssistantMessageProps) {
|
}: AssistantMessageProps) {
|
||||||
const markdownParser = useMemo(createAssistantMarkdownParser, []);
|
const markdownParser = useMemo(createAssistantMarkdownParser, []);
|
||||||
// Paint a paced prefix while the turn is streaming so text arrives at a steady
|
|
||||||
// rate instead of in whatever lumps the daemon's coalescing window produced.
|
|
||||||
const revealedMessage = useRevealedText(message, phase);
|
|
||||||
|
|
||||||
const fileLinkActions = useAssistantFileLinkActions();
|
const fileLinkActions = useAssistantFileLinkActions();
|
||||||
const handleMarkdownLinkPress = useStableEvent((url: string) => {
|
const handleMarkdownLinkPress = useStableEvent((url: string) => {
|
||||||
@@ -1902,7 +1897,7 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
};
|
};
|
||||||
}, [client, fileLinkActions, markdownParser, occurrenceKey, phase, serverId, workspaceRoot]);
|
}, [client, fileLinkActions, markdownParser, occurrenceKey, phase, serverId, workspaceRoot]);
|
||||||
|
|
||||||
const blocks = useMemo(() => splitMarkdownBlocks(revealedMessage), [revealedMessage]);
|
const blocks = useMemo(() => splitMarkdownBlocks(message), [message]);
|
||||||
const keyedBlocks = useMemo(
|
const keyedBlocks = useMemo(
|
||||||
() => blocks.map((block, index) => ({ key: `block:${index}`, block })),
|
() => blocks.map((block, index) => ({ key: `block:${index}`, block })),
|
||||||
[blocks],
|
[blocks],
|
||||||
@@ -1918,16 +1913,9 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
],
|
],
|
||||||
[spacing],
|
[spacing],
|
||||||
);
|
);
|
||||||
const revealDataSet = useMemo(
|
|
||||||
() =>
|
|
||||||
isRenderProfileEnabled()
|
|
||||||
? { revealKey: occurrenceKey, revealLength: String(revealedMessage.length) }
|
|
||||||
: undefined,
|
|
||||||
[occurrenceKey, revealedMessage.length],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View testID="assistant-message" dataSet={revealDataSet} style={assistantContainerStyle}>
|
<View testID="assistant-message" style={assistantContainerStyle}>
|
||||||
{keyedBlocks.map(({ key, block }, index) => (
|
{keyedBlocks.map(({ key, block }, index) => (
|
||||||
<AssistantMessageBlockContainer
|
<AssistantMessageBlockContainer
|
||||||
key={key}
|
key={key}
|
||||||
|
|||||||
@@ -9,13 +9,12 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
interface RewindComposerRestoreContextValue {
|
interface RewindComposerRestoreContextValue {
|
||||||
completeRewind: (text: string) => void;
|
restoreTextIfComposerEmpty: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RewindComposerRestoreProviderProps {
|
interface RewindComposerRestoreProviderProps {
|
||||||
text: string;
|
text: string;
|
||||||
setText: (text: string) => void;
|
setText: (text: string) => void;
|
||||||
onRewindComplete: () => void;
|
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +33,6 @@ export function restoreComposerTextIfEmpty(input: {
|
|||||||
export function RewindComposerRestoreProvider({
|
export function RewindComposerRestoreProvider({
|
||||||
text,
|
text,
|
||||||
setText,
|
setText,
|
||||||
onRewindComplete,
|
|
||||||
children,
|
children,
|
||||||
}: RewindComposerRestoreProviderProps) {
|
}: RewindComposerRestoreProviderProps) {
|
||||||
const textRef = useRef(text);
|
const textRef = useRef(text);
|
||||||
@@ -43,7 +41,7 @@ export function RewindComposerRestoreProvider({
|
|||||||
textRef.current = text;
|
textRef.current = text;
|
||||||
}, [text]);
|
}, [text]);
|
||||||
|
|
||||||
const completeRewind = useCallback(
|
const restoreTextIfComposerEmpty = useCallback(
|
||||||
(rewoundText: string) => {
|
(rewoundText: string) => {
|
||||||
const nextText = restoreComposerTextIfEmpty({
|
const nextText = restoreComposerTextIfEmpty({
|
||||||
currentText: textRef.current,
|
currentText: textRef.current,
|
||||||
@@ -52,12 +50,11 @@ export function RewindComposerRestoreProvider({
|
|||||||
if (nextText !== textRef.current) {
|
if (nextText !== textRef.current) {
|
||||||
setText(nextText);
|
setText(nextText);
|
||||||
}
|
}
|
||||||
onRewindComplete();
|
|
||||||
},
|
},
|
||||||
[onRewindComplete, setText],
|
[setText],
|
||||||
);
|
);
|
||||||
|
|
||||||
const value = useMemo(() => ({ completeRewind }), [completeRewind]);
|
const value = useMemo(() => ({ restoreTextIfComposerEmpty }), [restoreTextIfComposerEmpty]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RewindComposerRestoreContext.Provider value={value}>
|
<RewindComposerRestoreContext.Provider value={value}>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
|
|||||||
if (!shouldRestoreComposerForRewindMode(variables.mode)) {
|
if (!shouldRestoreComposerForRewindMode(variables.mode)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
composerRestore?.completeRewind(variables.rewoundText);
|
composerRestore?.restoreTextIfComposerEmpty(variables.rewoundText);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error instanceof Error ? error.message : t("rewind.errors.failed"));
|
toast.error(error instanceof Error ? error.message : t("rewind.errors.failed"));
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import { FadeIn, FadeOut } from "react-native-reanimated";
|
|||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
|
CircleCheck,
|
||||||
|
CircleDot,
|
||||||
|
CircleX,
|
||||||
Copy,
|
Copy,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileDiff,
|
FileDiff,
|
||||||
@@ -39,14 +42,6 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
|||||||
import { FloatingSurface } from "@/components/ui/floating";
|
import { FloatingSurface } from "@/components/ui/floating";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import {
|
|
||||||
COUNTED_CHECK_PRESENTATIONS,
|
|
||||||
countCheckPresentations,
|
|
||||||
type CountedCheckPresentation,
|
|
||||||
} from "@/git/check-presentation";
|
|
||||||
import { formatCheckPresentationCountsLabel } from "@/git/check-presentation-copy";
|
|
||||||
import { CheckPresentationIcon, getCheckPresentationTone } from "@/git/check-presentation.view";
|
|
||||||
import { buildForgeChecksUrl } from "@/git/forge-url";
|
|
||||||
|
|
||||||
interface Rect {
|
interface Rect {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -360,11 +355,17 @@ function HostRow({ serverId }: { serverId: string }): ReactElement | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||||
|
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||||
|
const ThemedCircleDot = withUnistyles(CircleDot);
|
||||||
|
const ThemedCircleX = withUnistyles(CircleX);
|
||||||
const ThemedCopy = withUnistyles(Copy);
|
const ThemedCopy = withUnistyles(Copy);
|
||||||
const ThemedCheck = withUnistyles(Check);
|
const ThemedCheck = withUnistyles(Check);
|
||||||
|
|
||||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||||
|
const successColorMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
|
||||||
|
const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
|
||||||
|
const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
||||||
|
|
||||||
function InfoRow({
|
function InfoRow({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
@@ -454,28 +455,51 @@ function CopyableInfoRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChecksSummaryPill({
|
function getChecksSummaryCounts(checks: NonNullable<PrHint["checks"]>) {
|
||||||
count,
|
return checks.reduce(
|
||||||
presentation,
|
(counts, check) => {
|
||||||
}: {
|
if (check.status === "success") counts.passed += 1;
|
||||||
count: number;
|
else if (check.status === "failure") counts.failed += 1;
|
||||||
presentation: CountedCheckPresentation;
|
else if (check.status !== "skipped" && check.status !== "cancelled") counts.pending += 1;
|
||||||
}) {
|
return counts;
|
||||||
if (count === 0) return null;
|
},
|
||||||
return (
|
{ passed: 0, failed: 0, pending: 0 },
|
||||||
<View style={styles.checksSummaryPill}>
|
|
||||||
<CheckPresentationIcon presentation={presentation} size={12} />
|
|
||||||
<Text style={checksSummaryTextStyle(presentation)}>{count}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function checksSummaryTextStyle(presentation: CountedCheckPresentation) {
|
function ChecksSummaryPill({
|
||||||
const tone = getCheckPresentationTone(presentation);
|
count,
|
||||||
if (tone === "success") return styles.checksStatusTextPassed;
|
kind,
|
||||||
if (tone === "danger") return styles.checksStatusTextFailed;
|
}: {
|
||||||
if (tone === "warning") return styles.checksStatusTextPending;
|
count: number;
|
||||||
return styles.checksStatusTextMuted;
|
kind: "passed" | "failed" | "pending";
|
||||||
|
}) {
|
||||||
|
if (count === 0) return null;
|
||||||
|
|
||||||
|
if (kind === "passed") {
|
||||||
|
return (
|
||||||
|
<View style={styles.checksSummaryPill}>
|
||||||
|
<ThemedCircleCheck size={12} uniProps={successColorMapping} />
|
||||||
|
<Text style={styles.checksStatusTextPassed}>{count}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "failed") {
|
||||||
|
return (
|
||||||
|
<View style={styles.checksSummaryPill}>
|
||||||
|
<ThemedCircleX size={12} uniProps={dangerColorMapping} />
|
||||||
|
<Text style={styles.checksStatusTextFailed}>{count}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.checksSummaryPill}>
|
||||||
|
<ThemedCircleDot size={12} uniProps={warningColorMapping} />
|
||||||
|
<Text style={styles.checksStatusTextPending}>{count}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChecksSummaryContent({
|
function ChecksSummaryContent({
|
||||||
@@ -488,7 +512,7 @@ function ChecksSummaryContent({
|
|||||||
hovered: boolean;
|
hovered: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const counts = countCheckPresentations(checks);
|
const { passed, failed, pending } = getChecksSummaryCounts(checks);
|
||||||
|
|
||||||
const labelStyle = hovered
|
const labelStyle = hovered
|
||||||
? [styles.checksSummaryLabel, styles.checksSummaryLabelHovered]
|
? [styles.checksSummaryLabel, styles.checksSummaryLabelHovered]
|
||||||
@@ -505,13 +529,9 @@ function ChecksSummaryContent({
|
|||||||
)}
|
)}
|
||||||
<Text style={labelStyle}>{t("workspace.git.pr.sections.checks")}</Text>
|
<Text style={labelStyle}>{t("workspace.git.pr.sections.checks")}</Text>
|
||||||
<View style={styles.checksSummaryCounts}>
|
<View style={styles.checksSummaryCounts}>
|
||||||
{COUNTED_CHECK_PRESENTATIONS.map((presentation) => (
|
<ChecksSummaryPill count={passed} kind="passed" />
|
||||||
<ChecksSummaryPill
|
<ChecksSummaryPill count={failed} kind="failed" />
|
||||||
key={presentation}
|
<ChecksSummaryPill count={pending} kind="pending" />
|
||||||
count={counts[presentation]}
|
|
||||||
presentation={presentation}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -526,16 +546,9 @@ function ChecksSummaryPressable({
|
|||||||
forge: PrHint["forge"];
|
forge: PrHint["forge"];
|
||||||
url: string;
|
url: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const counts = countCheckPresentations(checks);
|
|
||||||
const accessibilityLabel = formatCheckPresentationCountsLabel(
|
|
||||||
counts,
|
|
||||||
t("workspace.git.pr.sections.checks"),
|
|
||||||
t,
|
|
||||||
);
|
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
void openExternalUrl(buildForgeChecksUrl(forge, url) ?? url);
|
void openExternalUrl(`${url}/checks`);
|
||||||
}, [forge, url]);
|
}, [url]);
|
||||||
|
|
||||||
const renderChildren = useCallback(
|
const renderChildren = useCallback(
|
||||||
({ hovered }: { pressed: boolean; hovered?: boolean }) => (
|
({ hovered }: { pressed: boolean; hovered?: boolean }) => (
|
||||||
@@ -545,12 +558,7 @@ function ChecksSummaryPressable({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable style={checksSummaryPressableStyle} onPress={handlePress}>
|
||||||
accessibilityLabel={accessibilityLabel}
|
|
||||||
accessibilityRole="link"
|
|
||||||
style={checksSummaryPressableStyle}
|
|
||||||
onPress={handlePress}
|
|
||||||
>
|
|
||||||
{renderChildren}
|
{renderChildren}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
@@ -664,9 +672,4 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.statusSuccess,
|
color: theme.colors.statusSuccess,
|
||||||
},
|
},
|
||||||
checksStatusTextMuted: {
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
fontWeight: theme.fontWeight.normal,
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -21,15 +21,15 @@ import {
|
|||||||
cancelComposerAgent,
|
cancelComposerAgent,
|
||||||
dispatchComposerAgentMessage,
|
dispatchComposerAgentMessage,
|
||||||
editQueuedComposerMessage,
|
editQueuedComposerMessage,
|
||||||
findForgeItemByOption,
|
findGithubItemByOption,
|
||||||
isAttachmentSelectedForForgeItem,
|
isAttachmentSelectedForGithubItem,
|
||||||
openComposerAttachment,
|
openComposerAttachment,
|
||||||
pickAndPersistImages,
|
pickAndPersistImages,
|
||||||
queueComposerMessage,
|
queueComposerMessage,
|
||||||
removeComposerAttachmentAtIndex,
|
removeComposerAttachmentAtIndex,
|
||||||
sendQueuedComposerMessageNow,
|
sendQueuedComposerMessageNow,
|
||||||
toggleForgeAttachment,
|
toggleGithubAttachment,
|
||||||
toggleForgeAttachmentFromPicker,
|
toggleGithubAttachmentFromPicker,
|
||||||
type MessageSubmissionWriter,
|
type MessageSubmissionWriter,
|
||||||
type AttachmentPersister,
|
type AttachmentPersister,
|
||||||
type ComposerCancelClient,
|
type ComposerCancelClient,
|
||||||
@@ -961,19 +961,19 @@ describe("openComposerAttachment", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("toggleForgeAttachment", () => {
|
describe("toggleGithubAttachment", () => {
|
||||||
it("appends a GitHub issue when not already attached", () => {
|
it("appends a GitHub issue when not already attached", () => {
|
||||||
const next = toggleForgeAttachment([], issueItem);
|
const next = toggleGithubAttachment([], issueItem);
|
||||||
expect(next).toEqual([{ kind: "forge_issue", item: issueItem }]);
|
expect(next).toEqual([{ kind: "forge_issue", item: issueItem }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("appends a GitHub PR when not already attached", () => {
|
it("appends a GitHub PR when not already attached", () => {
|
||||||
const next = toggleForgeAttachment([], prItem);
|
const next = toggleGithubAttachment([], prItem);
|
||||||
expect(next).toEqual([{ kind: "forge_change_request", item: prItem }]);
|
expect(next).toEqual([{ kind: "forge_change_request", item: prItem }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes an existing GitHub item with the same kind+number", () => {
|
it("removes an existing GitHub item with the same kind+number", () => {
|
||||||
const next = toggleForgeAttachment([{ kind: "github_issue", item: issueItem }], issueItem);
|
const next = toggleGithubAttachment([{ kind: "github_issue", item: issueItem }], issueItem);
|
||||||
expect(next).toEqual([]);
|
expect(next).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -983,7 +983,7 @@ describe("toggleForgeAttachment", () => {
|
|||||||
{ kind: "github_pr", item: prItem },
|
{ kind: "github_pr", item: prItem },
|
||||||
];
|
];
|
||||||
const otherIssue: ForgeSearchItem = { ...issueItem, number: 999 };
|
const otherIssue: ForgeSearchItem = { ...issueItem, number: 999 };
|
||||||
const next = toggleForgeAttachment(start, otherIssue);
|
const next = toggleGithubAttachment(start, otherIssue);
|
||||||
expect(next).toEqual([
|
expect(next).toEqual([
|
||||||
{ kind: "github_issue", item: issueItem },
|
{ kind: "github_issue", item: issueItem },
|
||||||
{ kind: "github_pr", item: prItem },
|
{ kind: "github_pr", item: prItem },
|
||||||
@@ -992,41 +992,41 @@ describe("toggleForgeAttachment", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("toggleForgeAttachmentFromPicker", () => {
|
describe("toggleGithubAttachmentFromPicker", () => {
|
||||||
it("marks an existing GitHub item as removed when picker toggle removes it", () => {
|
it("marks an existing GitHub item as removed when picker toggle removes it", () => {
|
||||||
const markForgeAttachmentRemoved = vi.fn();
|
const markGithubAttachmentRemoved = vi.fn();
|
||||||
const attachment: UserComposerAttachment = { kind: "github_pr", item: prItem };
|
const attachment: UserComposerAttachment = { kind: "github_pr", item: prItem };
|
||||||
|
|
||||||
const next = toggleForgeAttachmentFromPicker({
|
const next = toggleGithubAttachmentFromPicker({
|
||||||
current: [attachment],
|
current: [attachment],
|
||||||
item: prItem,
|
item: prItem,
|
||||||
markForgeAttachmentRemoved,
|
markGithubAttachmentRemoved,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(next).toEqual([]);
|
expect(next).toEqual([]);
|
||||||
expect(markForgeAttachmentRemoved).toHaveBeenCalledTimes(1);
|
expect(markGithubAttachmentRemoved).toHaveBeenCalledTimes(1);
|
||||||
expect(markForgeAttachmentRemoved).toHaveBeenCalledWith(attachment);
|
expect(markGithubAttachmentRemoved).toHaveBeenCalledWith(attachment);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not mark a GitHub item removed when picker toggle adds it", () => {
|
it("does not mark a GitHub item removed when picker toggle adds it", () => {
|
||||||
const markForgeAttachmentRemoved = vi.fn();
|
const markGithubAttachmentRemoved = vi.fn();
|
||||||
|
|
||||||
const next = toggleForgeAttachmentFromPicker({
|
const next = toggleGithubAttachmentFromPicker({
|
||||||
current: [],
|
current: [],
|
||||||
item: issueItem,
|
item: issueItem,
|
||||||
markForgeAttachmentRemoved,
|
markGithubAttachmentRemoved,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(next).toEqual([{ kind: "forge_issue", item: issueItem }]);
|
expect(next).toEqual([{ kind: "forge_issue", item: issueItem }]);
|
||||||
expect(markForgeAttachmentRemoved).not.toHaveBeenCalled();
|
expect(markGithubAttachmentRemoved).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("findForgeItemByOption / isAttachmentSelectedForForgeItem", () => {
|
describe("findGithubItemByOption / isAttachmentSelectedForGithubItem", () => {
|
||||||
it("locates items via their composite kind:number id", () => {
|
it("locates items via their composite kind:number id", () => {
|
||||||
expect(findForgeItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem);
|
expect(findGithubItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem);
|
||||||
expect(findForgeItemByOption([issueItem, prItem], "change_request:202")).toBe(prItem);
|
expect(findGithubItemByOption([issueItem, prItem], "change_request:202")).toBe(prItem);
|
||||||
expect(findForgeItemByOption([issueItem], "change_request:404")).toBeUndefined();
|
expect(findGithubItemByOption([issueItem], "change_request:404")).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recognizes when an attachment list already contains a matching GitHub item", () => {
|
it("recognizes when an attachment list already contains a matching GitHub item", () => {
|
||||||
@@ -1035,7 +1035,7 @@ describe("findForgeItemByOption / isAttachmentSelectedForForgeItem", () => {
|
|||||||
{ kind: "github_issue", item: issueItem },
|
{ kind: "github_issue", item: issueItem },
|
||||||
reviewWorkspaceAttachment("ignored"),
|
reviewWorkspaceAttachment("ignored"),
|
||||||
];
|
];
|
||||||
expect(isAttachmentSelectedForForgeItem(attachments, issueItem)).toBe(true);
|
expect(isAttachmentSelectedForGithubItem(attachments, issueItem)).toBe(true);
|
||||||
expect(isAttachmentSelectedForForgeItem(attachments, prItem)).toBe(false);
|
expect(isAttachmentSelectedForGithubItem(attachments, prItem)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -356,9 +356,7 @@ function isForgeAttachment(
|
|||||||
return (
|
return (
|
||||||
attachment.kind === "forge_issue" ||
|
attachment.kind === "forge_issue" ||
|
||||||
attachment.kind === "forge_change_request" ||
|
attachment.kind === "forge_change_request" ||
|
||||||
// COMPAT(githubAttachmentKinds): accept legacy persisted attachment kinds
|
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
|
||||||
// until 2027-01-17, when supported floors are >= v0.2.0 and old drafts no
|
|
||||||
// longer require them.
|
|
||||||
attachment.kind === "github_issue" ||
|
attachment.kind === "github_issue" ||
|
||||||
attachment.kind === "github_pr"
|
attachment.kind === "github_pr"
|
||||||
);
|
);
|
||||||
@@ -378,17 +376,17 @@ export function toggleForgeAttachment(
|
|||||||
return [...current, buildForgeAttachment(item)];
|
return [...current, buildForgeAttachment(item)];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToggleForgeAttachmentFromPickerInput {
|
interface ToggleGithubAttachmentFromPickerInput {
|
||||||
current: UserComposerAttachment[];
|
current: UserComposerAttachment[];
|
||||||
item: ForgeSearchItem;
|
item: ForgeSearchItem;
|
||||||
markForgeAttachmentRemoved: (attachment: UserComposerAttachment) => void;
|
markGithubAttachmentRemoved: (attachment: UserComposerAttachment) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toggleForgeAttachmentFromPicker({
|
export function toggleGithubAttachmentFromPicker({
|
||||||
current,
|
current,
|
||||||
item,
|
item,
|
||||||
markForgeAttachmentRemoved,
|
markGithubAttachmentRemoved,
|
||||||
}: ToggleForgeAttachmentFromPickerInput): UserComposerAttachment[] {
|
}: ToggleGithubAttachmentFromPickerInput): UserComposerAttachment[] {
|
||||||
const existingAttachment = current.find(
|
const existingAttachment = current.find(
|
||||||
(attachment) =>
|
(attachment) =>
|
||||||
isForgeAttachment(attachment) &&
|
isForgeAttachment(attachment) &&
|
||||||
@@ -396,19 +394,19 @@ export function toggleForgeAttachmentFromPicker({
|
|||||||
attachment.item.number === item.number,
|
attachment.item.number === item.number,
|
||||||
);
|
);
|
||||||
if (existingAttachment) {
|
if (existingAttachment) {
|
||||||
markForgeAttachmentRemoved(existingAttachment);
|
markGithubAttachmentRemoved(existingAttachment);
|
||||||
}
|
}
|
||||||
return toggleForgeAttachment(current, item);
|
return toggleForgeAttachment(current, item);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findForgeItemByOption(
|
export function findGithubItemByOption(
|
||||||
items: readonly ForgeSearchItem[],
|
items: readonly ForgeSearchItem[],
|
||||||
optionId: string,
|
optionId: string,
|
||||||
): ForgeSearchItem | undefined {
|
): ForgeSearchItem | undefined {
|
||||||
return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId);
|
return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAttachmentSelectedForForgeItem(
|
export function isAttachmentSelectedForGithubItem(
|
||||||
current: readonly ComposerAttachment[],
|
current: readonly ComposerAttachment[],
|
||||||
item: ForgeSearchItem,
|
item: ForgeSearchItem,
|
||||||
): boolean {
|
): boolean {
|
||||||
@@ -419,3 +417,5 @@ export function isAttachmentSelectedForForgeItem(
|
|||||||
attachment.item.number === item.number,
|
attachment.item.number === item.number,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const toggleGithubAttachment = toggleForgeAttachment;
|
||||||
|
|||||||
@@ -21,9 +21,7 @@ interface SplitComposerAttachmentsOptions {
|
|||||||
export function resolveComposerAttachmentSubmitFormat(input: {
|
export function resolveComposerAttachmentSubmitFormat(input: {
|
||||||
supportsForgeAttachments?: boolean;
|
supportsForgeAttachments?: boolean;
|
||||||
}): ComposerAttachmentSubmitFormat {
|
}): ComposerAttachmentSubmitFormat {
|
||||||
// COMPAT(githubAttachmentKinds): emit legacy GitHub attachments for daemons
|
// COMPAT(forgeSearch): added in v0.1.106, remove github_search fallback after 2026-12-28.
|
||||||
// predating forge-neutral attachments. Remove after 2027-01-17 once the
|
|
||||||
// supported daemon floor is >= v0.2.0.
|
|
||||||
return input.supportsForgeAttachments === false ? "legacy-github" : "forge";
|
return input.supportsForgeAttachments === false ? "legacy-github" : "forge";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,9 +34,7 @@ export function splitComposerAttachmentsForSubmit(
|
|||||||
} {
|
} {
|
||||||
const images: ImageAttachment[] = [];
|
const images: ImageAttachment[] = [];
|
||||||
const agentAttachments: AgentAttachment[] = [];
|
const agentAttachments: AgentAttachment[] = [];
|
||||||
// COMPAT(githubAttachmentKinds): emit legacy GitHub attachments for daemons
|
// COMPAT(forgeSearch): added in v0.1.106, remove github_search fallback after 2026-12-28.
|
||||||
// predating forge-neutral attachments. Remove after 2027-01-17 once the
|
|
||||||
// supported daemon floor is >= v0.2.0.
|
|
||||||
const buildSearchAttachment =
|
const buildSearchAttachment =
|
||||||
options.format === "legacy-github"
|
options.format === "legacy-github"
|
||||||
? buildLegacyGitHubAttachmentFromSearchItem
|
? buildLegacyGitHubAttachmentFromSearchItem
|
||||||
|
|||||||
+37
-112
@@ -9,7 +9,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import type { UserComposerAttachment } from "@/attachments/types";
|
import type { UserComposerAttachment } from "@/attachments/types";
|
||||||
import type { ForgeSearchClient } from "@/git/use-forge-search-query";
|
import type { ForgeSearchClient } from "@/git/use-forge-search-query";
|
||||||
import type { ForgeSearchItem, ForgeSearchResponse } from "@getpaseo/protocol/messages";
|
import type { ForgeSearchItem, ForgeSearchResponse } from "@getpaseo/protocol/messages";
|
||||||
import { useComposerForgeAutoAttach } from "./forge-auto-attach";
|
import { useComposerGithubAutoAttach } from "./auto-attach";
|
||||||
|
|
||||||
type ForgeSearchPayload = ForgeSearchResponse["payload"];
|
type ForgeSearchPayload = ForgeSearchResponse["payload"];
|
||||||
|
|
||||||
@@ -48,31 +48,6 @@ const issue202: ForgeSearchItem = {
|
|||||||
headRefName: null,
|
headRefName: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const gitlabMr73: ForgeSearchItem = {
|
|
||||||
forge: "gitlab",
|
|
||||||
kind: "change_request",
|
|
||||||
number: 73,
|
|
||||||
title: "Attach MR",
|
|
||||||
url: "https://gitlab.com/acme/paseo/-/merge_requests/73",
|
|
||||||
state: "opened",
|
|
||||||
body: null,
|
|
||||||
labels: [],
|
|
||||||
projectPath: "acme/paseo",
|
|
||||||
baseRefName: "main",
|
|
||||||
headRefName: "feature",
|
|
||||||
};
|
|
||||||
|
|
||||||
const giteaIssue27: ForgeSearchItem = {
|
|
||||||
forge: "gitea",
|
|
||||||
kind: "issue",
|
|
||||||
number: 27,
|
|
||||||
title: "Attach Gitea issue",
|
|
||||||
url: "https://gitea.example.com/acme/paseo/issues/27",
|
|
||||||
state: "open",
|
|
||||||
body: null,
|
|
||||||
labels: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SearchCall {
|
interface SearchCall {
|
||||||
cwd: string;
|
cwd: string;
|
||||||
query: string;
|
query: string;
|
||||||
@@ -83,8 +58,8 @@ interface HarnessInput {
|
|||||||
initialAttachments?: UserComposerAttachment[];
|
initialAttachments?: UserComposerAttachment[];
|
||||||
initialCwd?: string;
|
initialCwd?: string;
|
||||||
initialText?: string;
|
initialText?: string;
|
||||||
onChangeRequestDetected?: () => void;
|
onPullRequestDetected?: () => void;
|
||||||
onChangeRequestAdded?: (item: ForgeSearchItem) => void;
|
onPullRequestAdded?: (item: ForgeSearchItem) => void;
|
||||||
remote?: string | null;
|
remote?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +111,7 @@ function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
|
|||||||
const [attachments, setAttachments] = useState<UserComposerAttachment[]>(
|
const [attachments, setAttachments] = useState<UserComposerAttachment[]>(
|
||||||
input.initialAttachments ?? [],
|
input.initialAttachments ?? [],
|
||||||
);
|
);
|
||||||
const autoAttach = useComposerForgeAutoAttach({
|
const autoAttach = useComposerGithubAutoAttach({
|
||||||
text,
|
text,
|
||||||
remoteUrl: input.remote ?? remoteUrl,
|
remoteUrl: input.remote ?? remoteUrl,
|
||||||
attachments,
|
attachments,
|
||||||
@@ -145,8 +120,8 @@ function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
|
|||||||
serverId: "server-1",
|
serverId: "server-1",
|
||||||
cwd: workingDirectory,
|
cwd: workingDirectory,
|
||||||
setAttachments,
|
setAttachments,
|
||||||
onChangeRequestDetected: input.onChangeRequestDetected,
|
onPullRequestDetected: input.onPullRequestDetected,
|
||||||
onChangeRequestAdded: input.onChangeRequestAdded,
|
onPullRequestAdded: input.onPullRequestAdded,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -157,7 +132,7 @@ function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
|
|||||||
attachments,
|
attachments,
|
||||||
setAttachments,
|
setAttachments,
|
||||||
isResolving: autoAttach.isResolving,
|
isResolving: autoAttach.isResolving,
|
||||||
markForgeAttachmentRemoved: autoAttach.markForgeAttachmentRemoved,
|
markGithubAttachmentRemoved: autoAttach.markGithubAttachmentRemoved,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,12 +143,12 @@ async function flushDebounce() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("useComposerForgeAutoAttach", () => {
|
describe("useComposerGithubAutoAttach", () => {
|
||||||
it("adds a matching pasted GitHub PR URL as a composer attachment", async () => {
|
it("adds a matching pasted GitHub PR URL as a composer attachment", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const client = createSearchClient([pr101]);
|
const client = createSearchClient([pr101]);
|
||||||
const onChangeRequestDetected = vi.fn();
|
const onPullRequestDetected = vi.fn();
|
||||||
const { result } = renderHook(() => useHarness(client, { onChangeRequestDetected }), {
|
const { result } = renderHook(() => useHarness(client, { onPullRequestDetected }), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -181,7 +156,7 @@ describe("useComposerForgeAutoAttach", () => {
|
|||||||
result.current.setText("Please review https://github.com/acme/paseo/pull/101");
|
result.current.setText("Please review https://github.com/acme/paseo/pull/101");
|
||||||
});
|
});
|
||||||
expect(result.current.isResolving).toBe(true);
|
expect(result.current.isResolving).toBe(true);
|
||||||
expect(onChangeRequestDetected).toHaveBeenCalledTimes(1);
|
expect(onPullRequestDetected).toHaveBeenCalledTimes(1);
|
||||||
await flushDebounce();
|
await flushDebounce();
|
||||||
|
|
||||||
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
|
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
|
||||||
@@ -190,56 +165,6 @@ describe("useComposerForgeAutoAttach", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds a matching pasted GitLab MR URL as a composer attachment", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const client = createSearchClient([gitlabMr73]);
|
|
||||||
const onChangeRequestDetected = vi.fn();
|
|
||||||
const onChangeRequestAdded = vi.fn();
|
|
||||||
const { result } = renderHook(
|
|
||||||
() =>
|
|
||||||
useHarness(client, {
|
|
||||||
onChangeRequestDetected,
|
|
||||||
onChangeRequestAdded,
|
|
||||||
remote: "git@gitlab.com:acme/paseo.git",
|
|
||||||
}),
|
|
||||||
{ wrapper: createWrapper() },
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setText("Review https://gitlab.com/acme/paseo/-/merge_requests/73/diffs");
|
|
||||||
});
|
|
||||||
await flushDebounce();
|
|
||||||
|
|
||||||
expect(result.current.attachments).toEqual([
|
|
||||||
{ kind: "forge_change_request", item: gitlabMr73 },
|
|
||||||
]);
|
|
||||||
expect(onChangeRequestDetected).toHaveBeenCalledTimes(1);
|
|
||||||
expect(onChangeRequestAdded).toHaveBeenCalledWith(gitlabMr73);
|
|
||||||
expect(client.calls).toEqual([{ cwd, query: "73", limit: 20 }]);
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds a matching pasted self-hosted Gitea issue URL", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const client = createSearchClient([giteaIssue27]);
|
|
||||||
const { result } = renderHook(
|
|
||||||
() =>
|
|
||||||
useHarness(client, {
|
|
||||||
remote: "git@gitea.example.com:acme/paseo.git",
|
|
||||||
}),
|
|
||||||
{ wrapper: createWrapper() },
|
|
||||||
);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.setText("See https://gitea.example.com/acme/paseo/issues/27");
|
|
||||||
});
|
|
||||||
await flushDebounce();
|
|
||||||
|
|
||||||
expect(result.current.attachments).toEqual([{ kind: "forge_issue", item: giteaIssue27 }]);
|
|
||||||
expect(client.calls).toEqual([{ cwd, query: "27", limit: 20 }]);
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores URLs that do not match the current remote", async () => {
|
it("ignores URLs that do not match the current remote", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const client = createSearchClient([pr101]);
|
const client = createSearchClient([pr101]);
|
||||||
@@ -282,7 +207,7 @@ describe("useComposerForgeAutoAttach", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.markForgeAttachmentRemoved(initialAttachments[0]);
|
result.current.markGithubAttachmentRemoved(initialAttachments[0]);
|
||||||
result.current.setAttachments([]);
|
result.current.setAttachments([]);
|
||||||
result.current.setText("Re-pasted https://github.com/acme/paseo/pull/101");
|
result.current.setText("Re-pasted https://github.com/acme/paseo/pull/101");
|
||||||
});
|
});
|
||||||
@@ -326,8 +251,8 @@ describe("useComposerForgeAutoAttach", () => {
|
|||||||
.mockReturnValueOnce(firstLookup.promise)
|
.mockReturnValueOnce(firstLookup.promise)
|
||||||
.mockReturnValueOnce(secondLookup.promise),
|
.mockReturnValueOnce(secondLookup.promise),
|
||||||
};
|
};
|
||||||
const onChangeRequestAdded = vi.fn();
|
const onPullRequestAdded = vi.fn();
|
||||||
const { result } = renderHook(() => useHarness(client, { onChangeRequestAdded }), {
|
const { result } = renderHook(() => useHarness(client, { onPullRequestAdded }), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -342,7 +267,7 @@ describe("useComposerForgeAutoAttach", () => {
|
|||||||
secondLookup.resolve(githubPayload([pr202], "search-202"));
|
secondLookup.resolve(githubPayload([pr202], "search-202"));
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
expect(onChangeRequestAdded).not.toHaveBeenCalled();
|
expect(onPullRequestAdded).not.toHaveBeenCalled();
|
||||||
expect(result.current.attachments).toEqual([]);
|
expect(result.current.attachments).toEqual([]);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -350,7 +275,7 @@ describe("useComposerForgeAutoAttach", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(onChangeRequestAdded.mock.calls).toEqual([[pr101], [pr202]]);
|
expect(onPullRequestAdded.mock.calls).toEqual([[pr101], [pr202]]);
|
||||||
expect(result.current.attachments).toEqual([
|
expect(result.current.attachments).toEqual([
|
||||||
{ kind: "forge_change_request", item: pr101 },
|
{ kind: "forge_change_request", item: pr101 },
|
||||||
{ kind: "forge_change_request", item: pr202 },
|
{ kind: "forge_change_request", item: pr202 },
|
||||||
@@ -369,8 +294,8 @@ describe("useComposerForgeAutoAttach", () => {
|
|||||||
.mockReturnValueOnce(firstLookup.promise)
|
.mockReturnValueOnce(firstLookup.promise)
|
||||||
.mockReturnValueOnce(secondLookup.promise),
|
.mockReturnValueOnce(secondLookup.promise),
|
||||||
};
|
};
|
||||||
const onChangeRequestAdded = vi.fn();
|
const onPullRequestAdded = vi.fn();
|
||||||
const { result } = renderHook(() => useHarness(client, { onChangeRequestAdded }), {
|
const { result } = renderHook(() => useHarness(client, { onPullRequestAdded }), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -390,7 +315,7 @@ describe("useComposerForgeAutoAttach", () => {
|
|||||||
secondLookup.resolve(githubPayload([pr202], "search-202"));
|
secondLookup.resolve(githubPayload([pr202], "search-202"));
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
expect(onChangeRequestAdded).not.toHaveBeenCalled();
|
expect(onPullRequestAdded).not.toHaveBeenCalled();
|
||||||
expect(result.current.attachments).toEqual([]);
|
expect(result.current.attachments).toEqual([]);
|
||||||
expect(result.current.isResolving).toBe(true);
|
expect(result.current.isResolving).toBe(true);
|
||||||
|
|
||||||
@@ -399,7 +324,7 @@ describe("useComposerForgeAutoAttach", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(onChangeRequestAdded.mock.calls).toEqual([[pr101], [pr202]]);
|
expect(onPullRequestAdded.mock.calls).toEqual([[pr101], [pr202]]);
|
||||||
expect(result.current.attachments).toEqual([
|
expect(result.current.attachments).toEqual([
|
||||||
{ kind: "forge_change_request", item: pr101 },
|
{ kind: "forge_change_request", item: pr101 },
|
||||||
{ kind: "forge_change_request", item: pr202 },
|
{ kind: "forge_change_request", item: pr202 },
|
||||||
@@ -413,8 +338,8 @@ describe("useComposerForgeAutoAttach", () => {
|
|||||||
it("uses the latest pull request order when URLs are reordered during debounce", async () => {
|
it("uses the latest pull request order when URLs are reordered during debounce", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const client = createSearchClient([pr101, pr202]);
|
const client = createSearchClient([pr101, pr202]);
|
||||||
const onChangeRequestAdded = vi.fn();
|
const onPullRequestAdded = vi.fn();
|
||||||
const { result } = renderHook(() => useHarness(client, { onChangeRequestAdded }), {
|
const { result } = renderHook(() => useHarness(client, { onPullRequestAdded }), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -430,7 +355,7 @@ describe("useComposerForgeAutoAttach", () => {
|
|||||||
});
|
});
|
||||||
await flushDebounce();
|
await flushDebounce();
|
||||||
|
|
||||||
expect(onChangeRequestAdded.mock.calls).toEqual([[pr202], [pr101]]);
|
expect(onPullRequestAdded.mock.calls).toEqual([[pr202], [pr101]]);
|
||||||
expect(client.calls).toEqual([
|
expect(client.calls).toEqual([
|
||||||
{ cwd, query: "202", limit: 20 },
|
{ cwd, query: "202", limit: 20 },
|
||||||
{ cwd, query: "101", limit: 20 },
|
{ cwd, query: "101", limit: 20 },
|
||||||
@@ -448,8 +373,8 @@ describe("useComposerForgeAutoAttach", () => {
|
|||||||
.mockReturnValueOnce(firstLookup.promise)
|
.mockReturnValueOnce(firstLookup.promise)
|
||||||
.mockReturnValueOnce(secondLookup.promise),
|
.mockReturnValueOnce(secondLookup.promise),
|
||||||
};
|
};
|
||||||
const onChangeRequestAdded = vi.fn();
|
const onPullRequestAdded = vi.fn();
|
||||||
const { result } = renderHook(() => useHarness(client, { onChangeRequestAdded }), {
|
const { result } = renderHook(() => useHarness(client, { onPullRequestAdded }), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -470,7 +395,7 @@ describe("useComposerForgeAutoAttach", () => {
|
|||||||
firstLookup.resolve(githubPayload([pr101], "search-101"));
|
firstLookup.resolve(githubPayload([pr101], "search-101"));
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
expect(onChangeRequestAdded).not.toHaveBeenCalled();
|
expect(onPullRequestAdded).not.toHaveBeenCalled();
|
||||||
expect(result.current.attachments).toEqual([]);
|
expect(result.current.attachments).toEqual([]);
|
||||||
expect(result.current.isResolving).toBe(true);
|
expect(result.current.isResolving).toBe(true);
|
||||||
|
|
||||||
@@ -479,7 +404,7 @@ describe("useComposerForgeAutoAttach", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(onChangeRequestAdded.mock.calls).toEqual([[pr202], [pr101]]);
|
expect(onPullRequestAdded.mock.calls).toEqual([[pr202], [pr101]]);
|
||||||
expect(result.current.attachments).toEqual([
|
expect(result.current.attachments).toEqual([
|
||||||
{ kind: "forge_change_request", item: pr202 },
|
{ kind: "forge_change_request", item: pr202 },
|
||||||
{ kind: "forge_change_request", item: pr101 },
|
{ kind: "forge_change_request", item: pr101 },
|
||||||
@@ -500,8 +425,8 @@ describe("useComposerForgeAutoAttach", () => {
|
|||||||
.mockReturnValueOnce(firstLookup.promise)
|
.mockReturnValueOnce(firstLookup.promise)
|
||||||
.mockReturnValueOnce(secondLookup.promise),
|
.mockReturnValueOnce(secondLookup.promise),
|
||||||
};
|
};
|
||||||
const onChangeRequestAdded = vi.fn();
|
const onPullRequestAdded = vi.fn();
|
||||||
const { result } = renderHook(() => useHarness(client, { onChangeRequestAdded }), {
|
const { result } = renderHook(() => useHarness(client, { onPullRequestAdded }), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -526,7 +451,7 @@ describe("useComposerForgeAutoAttach", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(onChangeRequestAdded.mock.calls).toEqual([[pr101]]);
|
expect(onPullRequestAdded.mock.calls).toEqual([[pr101]]);
|
||||||
});
|
});
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
@@ -537,8 +462,8 @@ describe("useComposerForgeAutoAttach", () => {
|
|||||||
const client: ForgeSearchClient = {
|
const client: ForgeSearchClient = {
|
||||||
searchForge: vi.fn().mockReturnValue(lookup.promise),
|
searchForge: vi.fn().mockReturnValue(lookup.promise),
|
||||||
};
|
};
|
||||||
const onChangeRequestAdded = vi.fn();
|
const onPullRequestAdded = vi.fn();
|
||||||
const { result } = renderHook(() => useHarness(client, { onChangeRequestAdded }), {
|
const { result } = renderHook(() => useHarness(client, { onPullRequestAdded }), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -566,7 +491,7 @@ describe("useComposerForgeAutoAttach", () => {
|
|||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
|
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
|
||||||
expect(result.current.isResolving).toBe(false);
|
expect(result.current.isResolving).toBe(false);
|
||||||
expect(onChangeRequestAdded.mock.calls).toEqual([[pr101]]);
|
expect(onPullRequestAdded.mock.calls).toEqual([[pr101]]);
|
||||||
});
|
});
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
@@ -581,8 +506,8 @@ describe("useComposerForgeAutoAttach", () => {
|
|||||||
.mockReturnValueOnce(firstLookup.promise)
|
.mockReturnValueOnce(firstLookup.promise)
|
||||||
.mockReturnValueOnce(secondLookup.promise),
|
.mockReturnValueOnce(secondLookup.promise),
|
||||||
};
|
};
|
||||||
const onChangeRequestAdded = vi.fn();
|
const onPullRequestAdded = vi.fn();
|
||||||
const { result } = renderHook(() => useHarness(client, { onChangeRequestAdded }), {
|
const { result } = renderHook(() => useHarness(client, { onPullRequestAdded }), {
|
||||||
wrapper: createWrapper(),
|
wrapper: createWrapper(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -597,7 +522,7 @@ describe("useComposerForgeAutoAttach", () => {
|
|||||||
secondLookup.resolve(githubPayload([pr202], "search-202"));
|
secondLookup.resolve(githubPayload([pr202], "search-202"));
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
expect(onChangeRequestAdded).not.toHaveBeenCalled();
|
expect(onPullRequestAdded).not.toHaveBeenCalled();
|
||||||
expect(result.current.isResolving).toBe(true);
|
expect(result.current.isResolving).toBe(true);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -606,7 +531,7 @@ describe("useComposerForgeAutoAttach", () => {
|
|||||||
await flushDebounce();
|
await flushDebounce();
|
||||||
|
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(onChangeRequestAdded.mock.calls).toEqual([[pr202]]);
|
expect(onPullRequestAdded.mock.calls).toEqual([[pr202]]);
|
||||||
expect(result.current.isResolving).toBe(false);
|
expect(result.current.isResolving).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
+104
-102
@@ -11,13 +11,13 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
|
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
|
||||||
import { buildForgeSearchQueryOptions, type ForgeSearchClient } from "@/git/use-forge-search-query";
|
import { buildForgeSearchQueryOptions, type ForgeSearchClient } from "@/git/use-forge-search-query";
|
||||||
import { extractForgeRefs, type ForgeRef } from "@/git/forge-refs";
|
import { extractGithubRefs, type GithubRef } from "@/utils/github-refs";
|
||||||
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
|
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||||
import { isAttachmentSelectedForForgeItem, toggleForgeAttachment } from "./actions";
|
import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "../actions";
|
||||||
|
|
||||||
const AUTO_ATTACH_DEBOUNCE_MS = 300;
|
const AUTO_ATTACH_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
interface ComposerForgeAutoAttachInput {
|
interface ComposerGithubAutoAttachInput {
|
||||||
text: string;
|
text: string;
|
||||||
remoteUrl: string | null | undefined;
|
remoteUrl: string | null | undefined;
|
||||||
attachments: UserComposerAttachment[];
|
attachments: UserComposerAttachment[];
|
||||||
@@ -27,29 +27,29 @@ interface ComposerForgeAutoAttachInput {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
supportsForgeSearch?: boolean;
|
supportsForgeSearch?: boolean;
|
||||||
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
|
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
|
||||||
onChangeRequestDetected?: () => void;
|
onPullRequestDetected?: () => void;
|
||||||
onChangeRequestAdded?: (item: ForgeSearchItem) => void;
|
onPullRequestAdded?: (item: ForgeSearchItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ComposerForgeAutoAttachResult {
|
interface ComposerGithubAutoAttachResult {
|
||||||
isResolving: boolean;
|
isResolving: boolean;
|
||||||
markForgeAttachmentRemoved: (attachment: ComposerAttachment | undefined) => void;
|
markGithubAttachmentRemoved: (attachment: ComposerAttachment | undefined) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActiveForgeLookup {
|
interface ActiveGithubLookup {
|
||||||
invalidate: (keys: readonly string[]) => void;
|
invalidate: (keys: readonly string[]) => void;
|
||||||
invalidateIrrelevant: (current: ComposerForgeAutoAttachInput) => void;
|
invalidateIrrelevant: (current: ComposerGithubAutoAttachInput) => void;
|
||||||
hasPending: (key: string) => boolean;
|
hasPending: (key: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useComposerForgeAutoAttach(
|
export function useComposerGithubAutoAttach(
|
||||||
params: ComposerForgeAutoAttachInput,
|
params: ComposerGithubAutoAttachInput,
|
||||||
): ComposerForgeAutoAttachResult {
|
): ComposerGithubAutoAttachResult {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const latestRef = useRef(params);
|
const latestRef = useRef(params);
|
||||||
const removedRefKeysRef = useRef(new Set<string>());
|
const removedRefKeysRef = useRef(new Set<string>());
|
||||||
const presentChangeRequestKeysRef = useRef(new Set<string>());
|
const presentPullRequestKeysRef = useRef(new Set<string>());
|
||||||
const activeLookupsRef = useRef(new Set<ActiveForgeLookup>());
|
const activeLookupsRef = useRef(new Set<ActiveGithubLookup>());
|
||||||
const previousTargetRef = useRef({ serverId: params.serverId, cwd: params.cwd });
|
const previousTargetRef = useRef({ serverId: params.serverId, cwd: params.cwd });
|
||||||
const [resolvingRefCounts, setResolvingRefCounts] = useState<ReadonlyMap<string, number>>(
|
const [resolvingRefCounts, setResolvingRefCounts] = useState<ReadonlyMap<string, number>>(
|
||||||
() => new Map(),
|
() => new Map(),
|
||||||
@@ -58,15 +58,15 @@ export function useComposerForgeAutoAttach(
|
|||||||
latestRef.current = params;
|
latestRef.current = params;
|
||||||
const lookupCandidateKey = getLookupCandidateKey(params, removedRefKeysRef.current);
|
const lookupCandidateKey = getLookupCandidateKey(params, removedRefKeysRef.current);
|
||||||
const lookupRelevanceKey = getLookupRelevanceKey(params, removedRefKeysRef.current);
|
const lookupRelevanceKey = getLookupRelevanceKey(params, removedRefKeysRef.current);
|
||||||
const presentChangeRequestKey = getPresentChangeRequestKey(params);
|
const presentPullRequestKey = getPresentPullRequestKey(params);
|
||||||
const hasClient = params.client !== null;
|
const hasClient = params.client !== null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
notifyNewChangeRequestRefs({
|
notifyNewPullRequestRefs({
|
||||||
params: latestRef.current,
|
params: latestRef.current,
|
||||||
presentChangeRequestKeysRef,
|
presentPullRequestKeysRef,
|
||||||
});
|
});
|
||||||
}, [presentChangeRequestKey]);
|
}, [presentPullRequestKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const current = latestRef.current;
|
const current = latestRef.current;
|
||||||
@@ -99,11 +99,11 @@ export function useComposerForgeAutoAttach(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const refKeys = refs.map(forgeRefKey);
|
const refKeys = refs.map(githubRefKey);
|
||||||
setResolvingRefCounts((current) => addKeys(current, refKeys));
|
setResolvingRefCounts((current) => addKeys(current, refKeys));
|
||||||
const unreleasedRefKeys = new Set(refKeys);
|
const unreleasedRefKeys = new Set(refKeys);
|
||||||
let lookupStarted = false;
|
let lookupStarted = false;
|
||||||
let lookup: ActiveForgeLookup | null = null;
|
let lookup: ActiveGithubLookup | null = null;
|
||||||
const releaseResolving = (keys: readonly string[]) => {
|
const releaseResolving = (keys: readonly string[]) => {
|
||||||
const keysToRelease = keys.filter((key) => unreleasedRefKeys.delete(key));
|
const keysToRelease = keys.filter((key) => unreleasedRefKeys.delete(key));
|
||||||
if (keysToRelease.length === 0) return;
|
if (keysToRelease.length === 0) return;
|
||||||
@@ -147,7 +147,7 @@ export function useComposerForgeAutoAttach(
|
|||||||
queryClient,
|
queryClient,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const markForgeAttachmentRemoved = useCallback((attachment: ComposerAttachment | undefined) => {
|
const markGithubAttachmentRemoved = useCallback((attachment: ComposerAttachment | undefined) => {
|
||||||
const key = attachmentKey(attachment);
|
const key = attachmentKey(attachment);
|
||||||
if (key) {
|
if (key) {
|
||||||
removedRefKeysRef.current.add(key);
|
removedRefKeysRef.current.add(key);
|
||||||
@@ -157,55 +157,55 @@ export function useComposerForgeAutoAttach(
|
|||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
isResolving: resolvingRefCounts.size > 0,
|
isResolving: resolvingRefCounts.size > 0,
|
||||||
markForgeAttachmentRemoved,
|
markGithubAttachmentRemoved,
|
||||||
}),
|
}),
|
||||||
[markForgeAttachmentRemoved, resolvingRefCounts.size],
|
[markGithubAttachmentRemoved, resolvingRefCounts.size],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLookupCandidateKey(
|
function getLookupCandidateKey(
|
||||||
params: ComposerForgeAutoAttachInput,
|
params: ComposerGithubAutoAttachInput,
|
||||||
removedRefKeys: ReadonlySet<string>,
|
removedRefKeys: ReadonlySet<string>,
|
||||||
): string {
|
): string {
|
||||||
return getLookupCandidateRefs(params, removedRefKeys).map(forgeRefKey).join("|");
|
return getLookupCandidateRefs(params, removedRefKeys).map(githubRefKey).join("|");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLookupCandidateRefs(
|
function getLookupCandidateRefs(
|
||||||
params: ComposerForgeAutoAttachInput,
|
params: ComposerGithubAutoAttachInput,
|
||||||
removedRefKeys: ReadonlySet<string>,
|
removedRefKeys: ReadonlySet<string>,
|
||||||
): ForgeRef[] {
|
): GithubRef[] {
|
||||||
return extractForgeRefs(params.text, params.remoteUrl).filter((ref) => {
|
return extractGithubRefs(params.text, params.remoteUrl).filter((ref) => {
|
||||||
const key = forgeRefKey(ref);
|
const key = githubRefKey(ref);
|
||||||
return !removedRefKeys.has(key) && !hasForgeAttachment(params.attachments, ref);
|
return !removedRefKeys.has(key) && !hasGithubAttachment(params.attachments, ref);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function didLookupSourceOrderChange(
|
function didLookupSourceOrderChange(
|
||||||
originalKeys: readonly string[],
|
originalKeys: readonly string[],
|
||||||
current: ComposerForgeAutoAttachInput,
|
current: ComposerGithubAutoAttachInput,
|
||||||
removedRefKeys: ReadonlySet<string>,
|
removedRefKeys: ReadonlySet<string>,
|
||||||
): boolean {
|
): boolean {
|
||||||
const currentKeys = getLookupCandidateRefs(current, removedRefKeys).map(forgeRefKey);
|
const currentKeys = getLookupCandidateRefs(current, removedRefKeys).map(githubRefKey);
|
||||||
const currentKeySet = new Set(currentKeys);
|
const currentKeySet = new Set(currentKeys);
|
||||||
const retainedOriginalKeys = originalKeys.filter((key) => currentKeySet.has(key));
|
const retainedOriginalKeys = originalKeys.filter((key) => currentKeySet.has(key));
|
||||||
return currentKeys.join("|") !== retainedOriginalKeys.join("|");
|
return currentKeys.join("|") !== retainedOriginalKeys.join("|");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLookupRelevanceKey(
|
function getLookupRelevanceKey(
|
||||||
params: ComposerForgeAutoAttachInput,
|
params: ComposerGithubAutoAttachInput,
|
||||||
removedRefKeys: ReadonlySet<string>,
|
removedRefKeys: ReadonlySet<string>,
|
||||||
): string {
|
): string {
|
||||||
return extractForgeRefs(params.text, params.remoteUrl)
|
return extractGithubRefs(params.text, params.remoteUrl)
|
||||||
.map(forgeRefKey)
|
.map(githubRefKey)
|
||||||
.filter((key) => !removedRefKeys.has(key))
|
.filter((key) => !removedRefKeys.has(key))
|
||||||
.sort()
|
.sort()
|
||||||
.join("|");
|
.join("|");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPresentChangeRequestKey(params: ComposerForgeAutoAttachInput): string {
|
function getPresentPullRequestKey(params: ComposerGithubAutoAttachInput): string {
|
||||||
return extractForgeRefs(params.text, params.remoteUrl)
|
return extractGithubRefs(params.text, params.remoteUrl)
|
||||||
.filter((ref) => ref.kind === "change_request")
|
.filter((ref) => ref.kind === "pull")
|
||||||
.map(forgeRefKey)
|
.map(githubRefKey)
|
||||||
.sort()
|
.sort()
|
||||||
.join("|");
|
.join("|");
|
||||||
}
|
}
|
||||||
@@ -216,15 +216,15 @@ function isLookupContextStillRelevant({
|
|||||||
current,
|
current,
|
||||||
removedRefKeys,
|
removedRefKeys,
|
||||||
}: {
|
}: {
|
||||||
ref: ForgeRef;
|
ref: GithubRef;
|
||||||
initial: ComposerForgeAutoAttachInput;
|
initial: ComposerGithubAutoAttachInput;
|
||||||
current: ComposerForgeAutoAttachInput;
|
current: ComposerGithubAutoAttachInput;
|
||||||
removedRefKeys: ReadonlySet<string>;
|
removedRefKeys: ReadonlySet<string>;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
return (
|
return (
|
||||||
current.client !== null &&
|
current.client !== null &&
|
||||||
current.isConnected &&
|
current.isConnected &&
|
||||||
!removedRefKeys.has(forgeRefKey(ref)) &&
|
!removedRefKeys.has(githubRefKey(ref)) &&
|
||||||
isSameLookupTarget(initial, current) &&
|
isSameLookupTarget(initial, current) &&
|
||||||
isRefStillPresent(ref, current)
|
isRefStillPresent(ref, current)
|
||||||
);
|
);
|
||||||
@@ -235,7 +235,7 @@ function suppressRefsCarriedAcrossTargets({
|
|||||||
previousTargetRef,
|
previousTargetRef,
|
||||||
removedRefKeys,
|
removedRefKeys,
|
||||||
}: {
|
}: {
|
||||||
params: ComposerForgeAutoAttachInput;
|
params: ComposerGithubAutoAttachInput;
|
||||||
previousTargetRef: RefObject<{ serverId: string; cwd: string }>;
|
previousTargetRef: RefObject<{ serverId: string; cwd: string }>;
|
||||||
removedRefKeys: Set<string>;
|
removedRefKeys: Set<string>;
|
||||||
}): void {
|
}): void {
|
||||||
@@ -247,29 +247,29 @@ function suppressRefsCarriedAcrossTargets({
|
|||||||
previousTargetRef.current = { serverId: params.serverId, cwd: params.cwd };
|
previousTargetRef.current = { serverId: params.serverId, cwd: params.cwd };
|
||||||
if (!targetChanged) return;
|
if (!targetChanged) return;
|
||||||
|
|
||||||
for (const ref of extractForgeRefs(params.text, params.remoteUrl)) {
|
for (const ref of extractGithubRefs(params.text, params.remoteUrl)) {
|
||||||
removedRefKeys.add(forgeRefKey(ref));
|
removedRefKeys.add(githubRefKey(ref));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function notifyNewChangeRequestRefs({
|
function notifyNewPullRequestRefs({
|
||||||
params,
|
params,
|
||||||
presentChangeRequestKeysRef,
|
presentPullRequestKeysRef,
|
||||||
}: {
|
}: {
|
||||||
params: ComposerForgeAutoAttachInput;
|
params: ComposerGithubAutoAttachInput;
|
||||||
presentChangeRequestKeysRef: RefObject<Set<string>>;
|
presentPullRequestKeysRef: RefObject<Set<string>>;
|
||||||
}): void {
|
}): void {
|
||||||
const currentKeys = new Set(
|
const currentKeys = new Set(
|
||||||
extractForgeRefs(params.text, params.remoteUrl)
|
extractGithubRefs(params.text, params.remoteUrl)
|
||||||
.filter((ref) => ref.kind === "change_request")
|
.filter((ref) => ref.kind === "pull")
|
||||||
.map(forgeRefKey),
|
.map(githubRefKey),
|
||||||
);
|
);
|
||||||
for (const key of currentKeys) {
|
for (const key of currentKeys) {
|
||||||
if (!presentChangeRequestKeysRef.current.has(key)) {
|
if (!presentPullRequestKeysRef.current.has(key)) {
|
||||||
params.onChangeRequestDetected?.();
|
params.onPullRequestDetected?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
presentChangeRequestKeysRef.current = currentKeys;
|
presentPullRequestKeysRef.current = currentKeys;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addKeys(
|
function addKeys(
|
||||||
@@ -310,14 +310,14 @@ function attachRefs({
|
|||||||
onSettled,
|
onSettled,
|
||||||
onComplete,
|
onComplete,
|
||||||
}: {
|
}: {
|
||||||
refs: ForgeRef[];
|
refs: GithubRef[];
|
||||||
initial: ComposerForgeAutoAttachInput;
|
initial: ComposerGithubAutoAttachInput;
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
latestRef: RefObject<ComposerForgeAutoAttachInput>;
|
latestRef: RefObject<ComposerGithubAutoAttachInput>;
|
||||||
removedRefKeys: Set<string>;
|
removedRefKeys: Set<string>;
|
||||||
onSettled: (key: string) => void;
|
onSettled: (key: string) => void;
|
||||||
onComplete: (lookup: ActiveForgeLookup) => void;
|
onComplete: (lookup: ActiveGithubLookup) => void;
|
||||||
}): ActiveForgeLookup {
|
}): ActiveGithubLookup {
|
||||||
const outcomes = refs.map(() => ({
|
const outcomes = refs.map(() => ({
|
||||||
settled: false,
|
settled: false,
|
||||||
item: null as ForgeSearchItem | null,
|
item: null as ForgeSearchItem | null,
|
||||||
@@ -332,16 +332,16 @@ function attachRefs({
|
|||||||
if (item) {
|
if (item) {
|
||||||
latestRef.current.setAttachments((attachments) => {
|
latestRef.current.setAttachments((attachments) => {
|
||||||
if (
|
if (
|
||||||
removedRefKeys.has(forgeRefKey(ref)) ||
|
removedRefKeys.has(githubRefKey(ref)) ||
|
||||||
isAttachmentSelectedForForgeItem(attachments, item)
|
isAttachmentSelectedForGithubItem(attachments, item)
|
||||||
) {
|
) {
|
||||||
return attachments;
|
return attachments;
|
||||||
}
|
}
|
||||||
return toggleForgeAttachment(attachments, item);
|
return toggleGithubAttachment(attachments, item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (item?.kind === "change_request") {
|
if (item?.kind === "change_request") {
|
||||||
latestRef.current.onChangeRequestAdded?.(item);
|
latestRef.current.onPullRequestAdded?.(item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!didComplete && nextOutcomeIndex === outcomes.length) {
|
if (!didComplete && nextOutcomeIndex === outcomes.length) {
|
||||||
@@ -357,7 +357,7 @@ function attachRefs({
|
|||||||
};
|
};
|
||||||
|
|
||||||
refs.forEach((ref, index) => {
|
refs.forEach((ref, index) => {
|
||||||
const key = forgeRefKey(ref);
|
const key = githubRefKey(ref);
|
||||||
void attachRef({ ref, key, queryClient, latestRef, removedRefKeys })
|
void attachRef({ ref, key, queryClient, latestRef, removedRefKeys })
|
||||||
.then((item) => settleOutcome(index, item))
|
.then((item) => settleOutcome(index, item))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -365,12 +365,12 @@ function attachRefs({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const lookup: ActiveForgeLookup = {
|
const lookup: ActiveGithubLookup = {
|
||||||
invalidate(keys) {
|
invalidate(keys) {
|
||||||
const invalidKeys = new Set(keys);
|
const invalidKeys = new Set(keys);
|
||||||
const newlySettledKeys: string[] = [];
|
const newlySettledKeys: string[] = [];
|
||||||
refs.forEach((ref, index) => {
|
refs.forEach((ref, index) => {
|
||||||
const key = forgeRefKey(ref);
|
const key = githubRefKey(ref);
|
||||||
if (!invalidKeys.has(key) || index < nextOutcomeIndex) return;
|
if (!invalidKeys.has(key) || index < nextOutcomeIndex) return;
|
||||||
if (!outcomes[index].settled) newlySettledKeys.push(key);
|
if (!outcomes[index].settled) newlySettledKeys.push(key);
|
||||||
outcomes[index] = { settled: true, item: null };
|
outcomes[index] = { settled: true, item: null };
|
||||||
@@ -382,14 +382,14 @@ function attachRefs({
|
|||||||
lookup.invalidate(
|
lookup.invalidate(
|
||||||
refs.flatMap((ref, index) =>
|
refs.flatMap((ref, index) =>
|
||||||
isLookupContextStillRelevant({ ref, initial, current, removedRefKeys }) &&
|
isLookupContextStillRelevant({ ref, initial, current, removedRefKeys }) &&
|
||||||
(outcomes[index].settled || !hasForgeAttachment(current.attachments, ref))
|
(outcomes[index].settled || !hasGithubAttachment(current.attachments, ref))
|
||||||
? []
|
? []
|
||||||
: [forgeRefKey(ref)],
|
: [githubRefKey(ref)],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
hasPending(key) {
|
hasPending(key) {
|
||||||
const index = refs.findIndex((ref) => forgeRefKey(ref) === key);
|
const index = refs.findIndex((ref) => githubRefKey(ref) === key);
|
||||||
return index >= 0 && !outcomes[index].settled;
|
return index >= 0 && !outcomes[index].settled;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -403,10 +403,10 @@ async function attachRef({
|
|||||||
latestRef,
|
latestRef,
|
||||||
removedRefKeys,
|
removedRefKeys,
|
||||||
}: {
|
}: {
|
||||||
ref: ForgeRef;
|
ref: GithubRef;
|
||||||
key: string;
|
key: string;
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
latestRef: RefObject<ComposerForgeAutoAttachInput>;
|
latestRef: RefObject<ComposerGithubAutoAttachInput>;
|
||||||
removedRefKeys: Set<string>;
|
removedRefKeys: Set<string>;
|
||||||
}): Promise<ForgeSearchItem | null> {
|
}): Promise<ForgeSearchItem | null> {
|
||||||
const snapshot = latestRef.current;
|
const snapshot = latestRef.current;
|
||||||
@@ -414,11 +414,11 @@ async function attachRef({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const search = await fetchForgeRefSearch({ ref, snapshot, queryClient });
|
const search = await fetchGithubRefSearch({ ref, snapshot, queryClient });
|
||||||
if (!search) {
|
if (!search) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const item = search.items.find((candidate) => forgeItemMatchesRef(candidate, ref));
|
const item = search.items.find((candidate) => githubItemMatchesRef(candidate, ref));
|
||||||
const current = latestRef.current;
|
const current = latestRef.current;
|
||||||
if (
|
if (
|
||||||
!item ||
|
!item ||
|
||||||
@@ -429,7 +429,7 @@ async function attachRef({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAttachmentSelectedForForgeItem(current.attachments, item)) {
|
if (isAttachmentSelectedForGithubItem(current.attachments, item)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
@@ -440,31 +440,31 @@ function refsReadyForLookup({
|
|||||||
removedRefKeys,
|
removedRefKeys,
|
||||||
activeLookups,
|
activeLookups,
|
||||||
}: {
|
}: {
|
||||||
params: ComposerForgeAutoAttachInput;
|
params: ComposerGithubAutoAttachInput;
|
||||||
removedRefKeys: Set<string>;
|
removedRefKeys: Set<string>;
|
||||||
activeLookups: ReadonlySet<ActiveForgeLookup>;
|
activeLookups: ReadonlySet<ActiveGithubLookup>;
|
||||||
}): ForgeRef[] {
|
}): GithubRef[] {
|
||||||
if (!params.client || !params.isConnected || params.cwd.trim().length === 0) {
|
if (!params.client || !params.isConnected || params.cwd.trim().length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return extractForgeRefs(params.text, params.remoteUrl).filter((ref) => {
|
return extractGithubRefs(params.text, params.remoteUrl).filter((ref) => {
|
||||||
const key = forgeRefKey(ref);
|
const key = githubRefKey(ref);
|
||||||
return (
|
return (
|
||||||
!removedRefKeys.has(key) &&
|
!removedRefKeys.has(key) &&
|
||||||
![...activeLookups].some((lookup) => lookup.hasPending(key)) &&
|
![...activeLookups].some((lookup) => lookup.hasPending(key)) &&
|
||||||
!hasForgeAttachment(params.attachments, ref)
|
!hasGithubAttachment(params.attachments, ref)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchForgeRefSearch({
|
async function fetchGithubRefSearch({
|
||||||
ref,
|
ref,
|
||||||
snapshot,
|
snapshot,
|
||||||
queryClient,
|
queryClient,
|
||||||
}: {
|
}: {
|
||||||
ref: ForgeRef;
|
ref: GithubRef;
|
||||||
snapshot: ComposerForgeAutoAttachInput;
|
snapshot: ComposerGithubAutoAttachInput;
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
}) {
|
}) {
|
||||||
if (!snapshot.client) {
|
if (!snapshot.client) {
|
||||||
@@ -487,15 +487,15 @@ async function fetchForgeRefSearch({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRefStillPresent(ref: ForgeRef, params: ComposerForgeAutoAttachInput): boolean {
|
function isRefStillPresent(ref: GithubRef, params: ComposerGithubAutoAttachInput): boolean {
|
||||||
return extractForgeRefs(params.text, params.remoteUrl).some(
|
return extractGithubRefs(params.text, params.remoteUrl).some(
|
||||||
(candidate) => forgeRefKey(candidate) === forgeRefKey(ref),
|
(candidate) => githubRefKey(candidate) === githubRefKey(ref),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSameLookupTarget(
|
function isSameLookupTarget(
|
||||||
initial: ComposerForgeAutoAttachInput,
|
initial: ComposerGithubAutoAttachInput,
|
||||||
current: ComposerForgeAutoAttachInput,
|
current: ComposerGithubAutoAttachInput,
|
||||||
): boolean {
|
): boolean {
|
||||||
return (
|
return (
|
||||||
initial.serverId === current.serverId &&
|
initial.serverId === current.serverId &&
|
||||||
@@ -504,28 +504,30 @@ function isSameLookupTarget(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasForgeAttachment(attachments: UserComposerAttachment[], ref: ForgeRef): boolean {
|
function hasGithubAttachment(attachments: UserComposerAttachment[], ref: GithubRef): boolean {
|
||||||
return attachments.some((attachment) => attachmentKey(attachment) === forgeRefKey(ref));
|
return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref));
|
||||||
}
|
}
|
||||||
|
|
||||||
function forgeItemMatchesRef(item: ForgeSearchItem, ref: ForgeRef): boolean {
|
function githubItemMatchesRef(item: ForgeSearchItem, ref: GithubRef): boolean {
|
||||||
return item.kind === forgeItemKind(ref) && item.number === ref.number;
|
return item.kind === githubItemKind(ref) && item.number === ref.number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function forgeItemKind(ref: ForgeRef): ForgeSearchItem["kind"] {
|
function githubItemKind(ref: GithubRef): ForgeSearchItem["kind"] {
|
||||||
return ref.kind === "change_request" ? "change_request" : "issue";
|
return ref.kind === "pull" ? "change_request" : "issue";
|
||||||
}
|
}
|
||||||
|
|
||||||
function forgeRefKey(ref: ForgeRef): string {
|
function githubRefKey(ref: GithubRef): string {
|
||||||
return `${forgeItemKind(ref)}:${ref.number}`;
|
return `${githubItemKind(ref)}:${ref.number}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachmentKey(attachment: ComposerAttachment | undefined): string | null {
|
function attachmentKey(attachment: ComposerAttachment | undefined): string | null {
|
||||||
if (
|
if (
|
||||||
attachment?.kind !== "forge_change_request" &&
|
!attachment ||
|
||||||
attachment?.kind !== "forge_issue" &&
|
attachment.kind === "image" ||
|
||||||
attachment?.kind !== "github_pr" &&
|
(attachment.kind !== "forge_change_request" &&
|
||||||
attachment?.kind !== "github_issue"
|
attachment.kind !== "forge_issue" &&
|
||||||
|
attachment.kind !== "github_pr" &&
|
||||||
|
attachment.kind !== "github_issue")
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -62,14 +62,14 @@ import {
|
|||||||
cancelComposerAgent,
|
cancelComposerAgent,
|
||||||
dispatchComposerAgentMessage,
|
dispatchComposerAgentMessage,
|
||||||
editQueuedComposerMessage,
|
editQueuedComposerMessage,
|
||||||
findForgeItemByOption,
|
findGithubItemByOption,
|
||||||
isAttachmentSelectedForForgeItem,
|
isAttachmentSelectedForGithubItem,
|
||||||
openComposerAttachment,
|
openComposerAttachment,
|
||||||
pickAndPersistImages,
|
pickAndPersistImages,
|
||||||
queueComposerMessage,
|
queueComposerMessage,
|
||||||
removeComposerAttachmentAtIndex,
|
removeComposerAttachmentAtIndex,
|
||||||
sendQueuedComposerMessageNow,
|
sendQueuedComposerMessageNow,
|
||||||
toggleForgeAttachmentFromPicker,
|
toggleGithubAttachmentFromPicker,
|
||||||
uploadFileAttachments,
|
uploadFileAttachments,
|
||||||
type AttachmentPersister,
|
type AttachmentPersister,
|
||||||
type QueueWriter,
|
type QueueWriter,
|
||||||
@@ -132,7 +132,7 @@ import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
|||||||
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||||
import { getForgePresentation } from "@/git/forge";
|
import { getForgePresentation } from "@/git/forge";
|
||||||
import { ForgeBrandIcon } from "@/git/forge-icon";
|
import { ForgeBrandIcon } from "@/git/forge-icon";
|
||||||
import { useComposerForgeAutoAttach } from "./forge-auto-attach";
|
import { useComposerGithubAutoAttach } from "./github/auto-attach";
|
||||||
import { readClipboardImage } from "./clipboard-image";
|
import { readClipboardImage } from "./clipboard-image";
|
||||||
import { normalizeNativePastedImages, type NativePastedFile } from "./native-pasted-image";
|
import { normalizeNativePastedImages, type NativePastedFile } from "./native-pasted-image";
|
||||||
import { PluginResourceAttachmentPill, usePluginAttachmentPicker } from "@/plugins";
|
import { PluginResourceAttachmentPill, usePluginAttachmentPicker } from "@/plugins";
|
||||||
@@ -924,8 +924,8 @@ interface ComposerProps {
|
|||||||
submitIcon?: "arrow" | "return";
|
submitIcon?: "arrow" | "return";
|
||||||
/** Externally controlled loading state. When true, disables the submit button. */
|
/** Externally controlled loading state. When true, disables the submit button. */
|
||||||
isSubmitLoading?: boolean;
|
isSubmitLoading?: boolean;
|
||||||
/** When true, waits for pasted forge links to resolve before enabling submit. */
|
/** When true, waits for pasted GitHub links to resolve before enabling submit. */
|
||||||
waitForForgeAutoAttachOnSubmit?: boolean;
|
waitForGithubAutoAttachOnSubmit?: boolean;
|
||||||
submitBehavior?: "clear" | "preserve-and-lock";
|
submitBehavior?: "clear" | "preserve-and-lock";
|
||||||
/** When true, blurs the input immediately when submitting. */
|
/** When true, blurs the input immediately when submitting. */
|
||||||
blurOnSubmit?: boolean;
|
blurOnSubmit?: boolean;
|
||||||
@@ -936,8 +936,8 @@ interface ComposerProps {
|
|||||||
attachmentScopeKeys?: readonly string[];
|
attachmentScopeKeys?: readonly string[];
|
||||||
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
|
onOpenWorkspaceAttachment?: (attachment: WorkspaceComposerAttachment) => void;
|
||||||
onChangeAttachments: (updater: AttachmentListUpdater) => void;
|
onChangeAttachments: (updater: AttachmentListUpdater) => void;
|
||||||
onForgeChangeRequestDetected?: () => void;
|
onGithubPrDetected?: () => void;
|
||||||
onForgeChangeRequestAutoAttach?: (item: ForgeSearchItem) => void;
|
onGithubPrAutoAttach?: (item: ForgeSearchItem) => void;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
clearDraft: (lifecycle: "sent" | "abandoned") => void;
|
||||||
/** When true, auto-focuses the text input on web. */
|
/** When true, auto-focuses the text input on web. */
|
||||||
@@ -1148,7 +1148,7 @@ function ComposerContentImpl({
|
|||||||
submitButtonTestID,
|
submitButtonTestID,
|
||||||
submitIcon = "arrow",
|
submitIcon = "arrow",
|
||||||
isSubmitLoading = false,
|
isSubmitLoading = false,
|
||||||
waitForForgeAutoAttachOnSubmit = false,
|
waitForGithubAutoAttachOnSubmit = false,
|
||||||
submitBehavior = "clear",
|
submitBehavior = "clear",
|
||||||
blurOnSubmit = false,
|
blurOnSubmit = false,
|
||||||
value,
|
value,
|
||||||
@@ -1158,8 +1158,8 @@ function ComposerContentImpl({
|
|||||||
attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
|
attachmentScopeKeys = EMPTY_ATTACHMENT_SCOPE_KEYS,
|
||||||
onOpenWorkspaceAttachment,
|
onOpenWorkspaceAttachment,
|
||||||
onChangeAttachments,
|
onChangeAttachments,
|
||||||
onForgeChangeRequestDetected,
|
onGithubPrDetected,
|
||||||
onForgeChangeRequestAutoAttach,
|
onGithubPrAutoAttach,
|
||||||
cwd,
|
cwd,
|
||||||
clearDraft,
|
clearDraft,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
@@ -1235,7 +1235,7 @@ function ComposerContentImpl({
|
|||||||
const supportsForgeSearch = useSessionStore(
|
const supportsForgeSearch = useSessionStore(
|
||||||
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true,
|
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true,
|
||||||
);
|
);
|
||||||
const forgeAutoAttach = useComposerForgeAutoAttach({
|
const githubAutoAttach = useComposerGithubAutoAttach({
|
||||||
text: userInput,
|
text: userInput,
|
||||||
remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status),
|
remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status),
|
||||||
attachments,
|
attachments,
|
||||||
@@ -1245,8 +1245,8 @@ function ComposerContentImpl({
|
|||||||
cwd,
|
cwd,
|
||||||
supportsForgeSearch,
|
supportsForgeSearch,
|
||||||
setAttachments: setSelectedAttachments,
|
setAttachments: setSelectedAttachments,
|
||||||
onChangeRequestDetected: onForgeChangeRequestDetected,
|
onPullRequestDetected: onGithubPrDetected,
|
||||||
onChangeRequestAdded: onForgeChangeRequestAutoAttach,
|
onPullRequestAdded: onGithubPrAutoAttach,
|
||||||
});
|
});
|
||||||
const [cursorIndex, setCursorIndex] = useState(0);
|
const [cursorIndex, setCursorIndex] = useState(0);
|
||||||
const cursorPublication = useMemo(() => new AfterPaintPublication<number>(setCursorIndex), []);
|
const cursorPublication = useMemo(() => new AfterPaintPublication<number>(setCursorIndex), []);
|
||||||
@@ -1734,7 +1734,7 @@ function ComposerContentImpl({
|
|||||||
|
|
||||||
const handleRemoveAttachment = useCallback(
|
const handleRemoveAttachment = useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
forgeAutoAttach.markForgeAttachmentRemoved(selectedAttachments[index]);
|
githubAutoAttach.markGithubAttachmentRemoved(selectedAttachments[index]);
|
||||||
const didRemoveWorkspaceAttachment = removeAttachment({
|
const didRemoveWorkspaceAttachment = removeAttachment({
|
||||||
selectedAttachments,
|
selectedAttachments,
|
||||||
index,
|
index,
|
||||||
@@ -1746,7 +1746,7 @@ function ComposerContentImpl({
|
|||||||
removeComposerAttachmentAtIndex({ attachments: prev, index, deleteAttachments }),
|
removeComposerAttachmentAtIndex({ attachments: prev, index, deleteAttachments }),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[forgeAutoAttach, removeAttachment, selectedAttachments, setSelectedAttachments],
|
[githubAutoAttach, removeAttachment, selectedAttachments, setSelectedAttachments],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenAttachment = useCallback(
|
const handleOpenAttachment = useCallback(
|
||||||
@@ -2082,10 +2082,10 @@ function ComposerContentImpl({
|
|||||||
|
|
||||||
const handleToggleGithubItem = useCallback(
|
const handleToggleGithubItem = useCallback(
|
||||||
(item: ForgeSearchItem) => {
|
(item: ForgeSearchItem) => {
|
||||||
const nextAttachments = toggleForgeAttachmentFromPicker({
|
const nextAttachments = toggleGithubAttachmentFromPicker({
|
||||||
current: attachments,
|
current: attachments,
|
||||||
item,
|
item,
|
||||||
markForgeAttachmentRemoved: forgeAutoAttach.markForgeAttachmentRemoved,
|
markGithubAttachmentRemoved: githubAutoAttach.markGithubAttachmentRemoved,
|
||||||
});
|
});
|
||||||
setSelectedAttachments(nextAttachments);
|
setSelectedAttachments(nextAttachments);
|
||||||
setIsGithubPickerOpen(false);
|
setIsGithubPickerOpen(false);
|
||||||
@@ -2093,7 +2093,7 @@ function ComposerContentImpl({
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
attachments,
|
attachments,
|
||||||
forgeAutoAttach,
|
githubAutoAttach,
|
||||||
setSelectedAttachments,
|
setSelectedAttachments,
|
||||||
setGithubSearchQuery,
|
setGithubSearchQuery,
|
||||||
setIsGithubPickerOpen,
|
setIsGithubPickerOpen,
|
||||||
@@ -2154,11 +2154,11 @@ function ComposerContentImpl({
|
|||||||
|
|
||||||
const renderGithubPickerOption = useCallback(
|
const renderGithubPickerOption = useCallback(
|
||||||
({ option, active }: { option: ComboboxOption; selected: boolean; active: boolean }) => {
|
({ option, active }: { option: ComboboxOption; selected: boolean; active: boolean }) => {
|
||||||
const item = findForgeItemByOption(githubSearchItems, option.id);
|
const item = findGithubItemByOption(githubSearchItems, option.id);
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return <View key={option.id} />;
|
return <View key={option.id} />;
|
||||||
}
|
}
|
||||||
const selected = isAttachmentSelectedForForgeItem(selectedAttachments, item);
|
const selected = isAttachmentSelectedForGithubItem(selectedAttachments, item);
|
||||||
return (
|
return (
|
||||||
<GithubPickerOption
|
<GithubPickerOption
|
||||||
key={option.id}
|
key={option.id}
|
||||||
@@ -2220,7 +2220,7 @@ function ComposerContentImpl({
|
|||||||
const isSubmitLoadingVisible =
|
const isSubmitLoadingVisible =
|
||||||
isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0;
|
isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0;
|
||||||
const isSubmitDisabled =
|
const isSubmitDisabled =
|
||||||
isSubmitLoadingVisible || (waitForForgeAutoAttachOnSubmit && forgeAutoAttach.isResolving);
|
isSubmitLoadingVisible || (waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving);
|
||||||
|
|
||||||
// Disable drops while submitting/uploading: the submit path clears and restores attachments,
|
// 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
|
// so a drop in that window would be lost or land on a locked draft. `disabled` hides the
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
isTimelineResumeSnapshotAuthoritative,
|
isTimelineResumeSnapshotAuthoritative,
|
||||||
planTimelineTailFetch,
|
planTimelineTailFetch,
|
||||||
} from "@/timeline/timeline-sync-plan";
|
} from "@/timeline/timeline-sync-plan";
|
||||||
import { requestTimelineReplacement } from "@/timeline/timeline-replacement";
|
|
||||||
import {
|
import {
|
||||||
createViewedTimelineSync,
|
createViewedTimelineSync,
|
||||||
type TimelineDeliveryMode,
|
type TimelineDeliveryMode,
|
||||||
@@ -795,19 +794,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
applyTimelineResponse(message.payload);
|
applyTimelineResponse(message.payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubTimelineReplacement = client.on("agent.timeline.replacement", (message) => {
|
|
||||||
if (message.type !== "agent.timeline.replacement") return;
|
|
||||||
void requestTimelineReplacement(
|
|
||||||
{
|
|
||||||
fetchAgentTimeline: (agentId, request) =>
|
|
||||||
getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request),
|
|
||||||
},
|
|
||||||
message.payload.agentId,
|
|
||||||
).catch((error: unknown) => {
|
|
||||||
console.warn("[Session] timeline replacement refresh failed", { serverId, error });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const unsubProviderSubagentUpdate = client.on("agent.provider_subagents.update", (message) => {
|
const unsubProviderSubagentUpdate = client.on("agent.provider_subagents.update", (message) => {
|
||||||
if (message.type !== "agent.provider_subagents.update") return;
|
if (message.type !== "agent.provider_subagents.update") return;
|
||||||
useProviderSubagentStore.getState().applyUpdate(serverId, message.payload);
|
useProviderSubagentStore.getState().applyUpdate(serverId, message.payload);
|
||||||
@@ -1003,7 +989,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubTimelineReplacement();
|
|
||||||
unsubAgentStream();
|
unsubAgentStream();
|
||||||
unsubAgentTimeline();
|
unsubAgentTimeline();
|
||||||
unsubProviderSubagentUpdate();
|
unsubProviderSubagentUpdate();
|
||||||
|
|||||||
@@ -50,9 +50,8 @@ function resolveAutoMergeActionsRpc(serverId: string): AutoMergeActionsRpc {
|
|||||||
if (session?.serverInfo?.features?.checkoutForgeSetAutoMerge === true) {
|
if (session?.serverInfo?.features?.checkoutForgeSetAutoMerge === true) {
|
||||||
return "forge";
|
return "forge";
|
||||||
}
|
}
|
||||||
// COMPAT(githubAutoMergeRpc): use the legacy GitHub RPC with daemons that
|
// COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once
|
||||||
// predate checkout.forge.set_auto_merge.*. Remove after 2027-01-17 once the
|
// all supported clients use checkout.forge.set_auto_merge.*.
|
||||||
// supported daemon floor is >= v0.2.0.
|
|
||||||
if (session?.serverInfo?.features?.checkoutGithubSetAutoMerge === true) {
|
if (session?.serverInfo?.features?.checkoutGithubSetAutoMerge === true) {
|
||||||
return "github";
|
return "github";
|
||||||
}
|
}
|
||||||
@@ -299,9 +298,8 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
|||||||
actionId: `enable-pr-auto-merge-${method}`,
|
actionId: `enable-pr-auto-merge-${method}`,
|
||||||
run: async () => {
|
run: async () => {
|
||||||
const client = resolveClient(serverId);
|
const client = resolveClient(serverId);
|
||||||
// COMPAT(githubAutoMergeRpc): use the legacy GitHub RPC with daemons
|
// COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once
|
||||||
// that predate checkout.forge.set_auto_merge.*. Remove after 2027-01-17
|
// all supported clients use checkout.forge.set_auto_merge.*.
|
||||||
// once the supported daemon floor is >= v0.2.0.
|
|
||||||
const payload =
|
const payload =
|
||||||
rpc === "forge"
|
rpc === "forge"
|
||||||
? await client.checkoutForgeSetAutoMerge(cwd, { enabled: true, method })
|
? await client.checkoutForgeSetAutoMerge(cwd, { enabled: true, method })
|
||||||
@@ -321,9 +319,8 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
|||||||
actionId: "disable-pr-auto-merge",
|
actionId: "disable-pr-auto-merge",
|
||||||
run: async () => {
|
run: async () => {
|
||||||
const client = resolveClient(serverId);
|
const client = resolveClient(serverId);
|
||||||
// COMPAT(githubAutoMergeRpc): use the legacy GitHub RPC with daemons
|
// COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once
|
||||||
// that predate checkout.forge.set_auto_merge.*. Remove after 2027-01-17
|
// all supported clients use checkout.forge.set_auto_merge.*.
|
||||||
// once the supported daemon floor is >= v0.2.0.
|
|
||||||
const payload =
|
const payload =
|
||||||
rpc === "forge"
|
rpc === "forge"
|
||||||
? await client.checkoutForgeSetAutoMerge(cwd, { enabled: false })
|
? await client.checkoutForgeSetAutoMerge(cwd, { enabled: false })
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { beforeAll, describe, expect, it } from "vitest";
|
|
||||||
import { i18n } from "@/i18n/i18next";
|
|
||||||
import {
|
|
||||||
formatCheckPresentationCountsLabel,
|
|
||||||
getCheckPresentationStatusLabel,
|
|
||||||
} from "./check-presentation-copy";
|
|
||||||
|
|
||||||
describe("check presentation copy", () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
await i18n.changeLanguage("en");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats counts in canonical presentation order and omits empty categories", () => {
|
|
||||||
expect(
|
|
||||||
formatCheckPresentationCountsLabel(
|
|
||||||
{
|
|
||||||
success: 2,
|
|
||||||
failure: 0,
|
|
||||||
warning: 1,
|
|
||||||
actionRequired: 1,
|
|
||||||
manual: 1,
|
|
||||||
pending: 0,
|
|
||||||
},
|
|
||||||
"Checks",
|
|
||||||
i18n.t,
|
|
||||||
),
|
|
||||||
).toBe("Checks, Passed: 2, Warnings: 1, Action required: 1, Manual: 1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses presentation policy for row status labels", () => {
|
|
||||||
expect(
|
|
||||||
getCheckPresentationStatusLabel({ status: "success", traits: ["warning"] }, i18n.t),
|
|
||||||
).toBe("Warning");
|
|
||||||
expect(getCheckPresentationStatusLabel({ status: "cancelled" }, i18n.t)).toBe("Cancelled");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import type { TFunction } from "i18next";
|
|
||||||
import {
|
|
||||||
COUNTED_CHECK_PRESENTATIONS,
|
|
||||||
classifyCheck,
|
|
||||||
type CheckPresentationCounts,
|
|
||||||
type CountedCheckPresentation,
|
|
||||||
type PresentableCheck,
|
|
||||||
} from "./check-presentation";
|
|
||||||
|
|
||||||
const CHECK_PRESENTATION_COUNT_KEYS = {
|
|
||||||
success: "sidebar.workspace.checks.passed",
|
|
||||||
failure: "sidebar.workspace.checks.failed",
|
|
||||||
warning: "sidebar.workspace.checks.warning",
|
|
||||||
actionRequired: "sidebar.workspace.checks.actionRequired",
|
|
||||||
manual: "sidebar.workspace.checks.manual",
|
|
||||||
pending: "sidebar.workspace.checks.pending",
|
|
||||||
} as const satisfies Record<CountedCheckPresentation, string>;
|
|
||||||
|
|
||||||
const CHECK_PRESENTATION_STATUS_KEYS = {
|
|
||||||
success: "workspace.git.pr.accessibility.checkStatus.passed",
|
|
||||||
failure: "workspace.git.pr.accessibility.checkStatus.failed",
|
|
||||||
warning: "workspace.git.pr.accessibility.checkStatus.warning",
|
|
||||||
actionRequired: "workspace.git.pr.accessibility.checkStatus.actionRequired",
|
|
||||||
manual: "workspace.git.pr.accessibility.checkStatus.manual",
|
|
||||||
pending: "workspace.git.pr.accessibility.checkStatus.pending",
|
|
||||||
} as const satisfies Record<CountedCheckPresentation, string>;
|
|
||||||
|
|
||||||
export function getCheckPresentationCountLabel(
|
|
||||||
presentation: CountedCheckPresentation,
|
|
||||||
count: number,
|
|
||||||
t: TFunction,
|
|
||||||
): string {
|
|
||||||
return t(CHECK_PRESENTATION_COUNT_KEYS[presentation], { count });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatCheckPresentationCountsLabel(
|
|
||||||
counts: CheckPresentationCounts,
|
|
||||||
heading: string,
|
|
||||||
t: TFunction,
|
|
||||||
): string {
|
|
||||||
const labels = COUNTED_CHECK_PRESENTATIONS.flatMap((presentation) => {
|
|
||||||
const count = counts[presentation];
|
|
||||||
return count > 0 ? [getCheckPresentationCountLabel(presentation, count, t)] : [];
|
|
||||||
});
|
|
||||||
return [heading, ...labels].join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCheckPresentationStatusLabel(check: PresentableCheck, t: TFunction): string {
|
|
||||||
const presentation = classifyCheck(check);
|
|
||||||
if (presentation !== "ignored") return t(CHECK_PRESENTATION_STATUS_KEYS[presentation]);
|
|
||||||
if (check.status === "cancelled") {
|
|
||||||
return t("workspace.git.pr.accessibility.checkStatus.cancelled");
|
|
||||||
}
|
|
||||||
return t("workspace.git.pr.accessibility.checkStatus.skipped");
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { classifyCheck, countCheckPresentations } from "./check-presentation";
|
|
||||||
|
|
||||||
describe("check presentation", () => {
|
|
||||||
it.each([
|
|
||||||
[{ status: "failure", traits: ["warning", "action_required"] }, "actionRequired"],
|
|
||||||
[{ status: "pending", traits: ["manual", "action_required"] }, "actionRequired"],
|
|
||||||
[{ status: "success", traits: ["action_required"] }, "actionRequired"],
|
|
||||||
[{ status: "success", traits: ["warning"] }, "warning"],
|
|
||||||
[{ status: "skipped", traits: ["manual"] }, "manual"],
|
|
||||||
[{ status: "success" }, "success"],
|
|
||||||
[{ status: "failure" }, "failure"],
|
|
||||||
[{ status: "skipped" }, "ignored"],
|
|
||||||
[{ status: "cancelled" }, "ignored"],
|
|
||||||
[{ status: "pending" }, "pending"],
|
|
||||||
[{ status: "success", traits: ["future-forge-trait"] }, "success"],
|
|
||||||
[{ status: "future-provider-state" }, "pending"],
|
|
||||||
] as const)("classifies %o as %s", (check, expected) => {
|
|
||||||
expect(classifyCheck(check)).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("counts mutually exclusive presentation categories", () => {
|
|
||||||
expect(
|
|
||||||
countCheckPresentations([
|
|
||||||
{ status: "success" },
|
|
||||||
{ status: "failure" },
|
|
||||||
{ status: "success", traits: ["warning"] },
|
|
||||||
{ status: "pending", traits: ["action_required"] },
|
|
||||||
{ status: "pending" },
|
|
||||||
{ status: "skipped", traits: ["manual"] },
|
|
||||||
{ status: "skipped" },
|
|
||||||
]),
|
|
||||||
).toEqual({
|
|
||||||
success: 1,
|
|
||||||
failure: 1,
|
|
||||||
warning: 1,
|
|
||||||
actionRequired: 1,
|
|
||||||
manual: 1,
|
|
||||||
pending: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import {
|
|
||||||
CHECK_TRAIT_ACTION_REQUIRED,
|
|
||||||
CHECK_TRAIT_MANUAL,
|
|
||||||
CHECK_TRAIT_WARNING,
|
|
||||||
} from "@getpaseo/protocol/check-traits";
|
|
||||||
|
|
||||||
export interface PresentableCheck {
|
|
||||||
status: string;
|
|
||||||
traits?: readonly string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const COUNTED_CHECK_PRESENTATIONS = [
|
|
||||||
"success",
|
|
||||||
"failure",
|
|
||||||
"warning",
|
|
||||||
"actionRequired",
|
|
||||||
"manual",
|
|
||||||
"pending",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type CountedCheckPresentation = (typeof COUNTED_CHECK_PRESENTATIONS)[number];
|
|
||||||
export type CheckPresentation = CountedCheckPresentation | "ignored";
|
|
||||||
|
|
||||||
export const ATTENTION_CHECK_PRESENTATIONS = [
|
|
||||||
"failure",
|
|
||||||
"warning",
|
|
||||||
"actionRequired",
|
|
||||||
] as const satisfies readonly CountedCheckPresentation[];
|
|
||||||
|
|
||||||
export type CheckPresentationCounts = Record<CountedCheckPresentation, number>;
|
|
||||||
|
|
||||||
export function classifyCheck(check: PresentableCheck): CheckPresentation {
|
|
||||||
const traits = check.traits ?? [];
|
|
||||||
if (traits.includes(CHECK_TRAIT_ACTION_REQUIRED)) return "actionRequired";
|
|
||||||
if (traits.includes(CHECK_TRAIT_WARNING)) return "warning";
|
|
||||||
if (traits.includes(CHECK_TRAIT_MANUAL)) return "manual";
|
|
||||||
if (check.status === "success") return "success";
|
|
||||||
if (check.status === "failure") return "failure";
|
|
||||||
if (check.status === "skipped" || check.status === "cancelled") return "ignored";
|
|
||||||
return "pending";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function countCheckPresentations(
|
|
||||||
checks: readonly PresentableCheck[],
|
|
||||||
): CheckPresentationCounts {
|
|
||||||
const counts: CheckPresentationCounts = {
|
|
||||||
success: 0,
|
|
||||||
failure: 0,
|
|
||||||
warning: 0,
|
|
||||||
actionRequired: 0,
|
|
||||||
manual: 0,
|
|
||||||
pending: 0,
|
|
||||||
};
|
|
||||||
for (const check of checks) {
|
|
||||||
const presentation = classifyCheck(check);
|
|
||||||
if (presentation !== "ignored") counts[presentation] += 1;
|
|
||||||
}
|
|
||||||
return counts;
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import { withUnistyles } from "react-native-unistyles";
|
|
||||||
import { CircleCheck, CircleDot, CircleSlash, CircleX } from "lucide-react-native";
|
|
||||||
import { ManualStatusIcon } from "@/components/icons/manual-status-icon";
|
|
||||||
import type { Theme } from "@/styles/theme";
|
|
||||||
import type { CheckPresentation } from "./check-presentation";
|
|
||||||
|
|
||||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
|
||||||
const ThemedCircleDot = withUnistyles(CircleDot);
|
|
||||||
const ThemedCircleSlash = withUnistyles(CircleSlash);
|
|
||||||
const ThemedCircleX = withUnistyles(CircleX);
|
|
||||||
const ThemedManualStatusIcon = withUnistyles(ManualStatusIcon);
|
|
||||||
|
|
||||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
|
||||||
const successColorMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
|
|
||||||
const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
|
||||||
const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
|
|
||||||
|
|
||||||
export type CheckPresentationTone = "success" | "danger" | "warning" | "muted";
|
|
||||||
|
|
||||||
interface CheckPresentationViewPolicy {
|
|
||||||
tone: CheckPresentationTone;
|
|
||||||
renderIcon: (size: number) => ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CHECK_PRESENTATION_VIEW_POLICY: Record<CheckPresentation, CheckPresentationViewPolicy> = {
|
|
||||||
success: {
|
|
||||||
tone: "success",
|
|
||||||
renderIcon(size) {
|
|
||||||
return <ThemedCircleCheck size={size} uniProps={successColorMapping} />;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
failure: {
|
|
||||||
tone: "danger",
|
|
||||||
renderIcon(size) {
|
|
||||||
return <ThemedCircleX size={size} uniProps={dangerColorMapping} />;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
tone: "warning",
|
|
||||||
renderIcon(size) {
|
|
||||||
return <ThemedCircleX size={size} uniProps={warningColorMapping} />;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
actionRequired: {
|
|
||||||
tone: "warning",
|
|
||||||
renderIcon(size) {
|
|
||||||
return <ThemedManualStatusIcon size={size} uniProps={warningColorMapping} />;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
manual: {
|
|
||||||
tone: "muted",
|
|
||||||
renderIcon(size) {
|
|
||||||
return <ThemedManualStatusIcon size={size} uniProps={foregroundMutedColorMapping} />;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pending: {
|
|
||||||
tone: "warning",
|
|
||||||
renderIcon(size) {
|
|
||||||
return <ThemedCircleDot size={size} uniProps={warningColorMapping} />;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ignored: {
|
|
||||||
tone: "muted",
|
|
||||||
renderIcon(size) {
|
|
||||||
return <ThemedCircleSlash size={size} uniProps={foregroundMutedColorMapping} />;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getCheckPresentationTone(presentation: CheckPresentation): CheckPresentationTone {
|
|
||||||
return CHECK_PRESENTATION_VIEW_POLICY[presentation].tone;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CheckPresentationIcon({
|
|
||||||
presentation,
|
|
||||||
size,
|
|
||||||
}: {
|
|
||||||
presentation: CheckPresentation;
|
|
||||||
size: number;
|
|
||||||
}): ReactNode {
|
|
||||||
return CHECK_PRESENTATION_VIEW_POLICY[presentation].renderIcon(size);
|
|
||||||
}
|
|
||||||
@@ -27,20 +27,6 @@ export interface ForgeUrlGrammar {
|
|||||||
blobInfix: string;
|
blobInfix: string;
|
||||||
/** Line/range anchor, e.g. github "#L1-L5" vs gitlab "#L1-5". */
|
/** Line/range anchor, e.g. github "#L1-L5" vs gitlab "#L1-5". */
|
||||||
lineAnchor: (start: number, end?: number) => string;
|
lineAnchor: (start: number, end?: number) => string;
|
||||||
/** Suffix for the change request's checks/pipelines overview. */
|
|
||||||
changeRequestChecksSuffix?: string;
|
|
||||||
/**
|
|
||||||
* Web-path infixes used when recognizing pasted links for this forge. The
|
|
||||||
* parser anchors each infix after the current remote's full repository path,
|
|
||||||
* so links for another repository are never auto-attached.
|
|
||||||
*/
|
|
||||||
referencePaths?: readonly ForgeReferencePath[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ForgeReferencePath {
|
|
||||||
kind: "change_request" | "issue";
|
|
||||||
/** Path between the repository path and the numeric local id. */
|
|
||||||
infix: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Line/range anchor shared by every GitHub-family forge (github, gitea, forgejo, codeberg). */
|
/** Line/range anchor shared by every GitHub-family forge (github, gitea, forgejo, codeberg). */
|
||||||
@@ -53,10 +39,6 @@ export const GITEA_FAMILY_URL_GRAMMAR: ForgeUrlGrammar = {
|
|||||||
treeInfix: "/src/branch/",
|
treeInfix: "/src/branch/",
|
||||||
blobInfix: "/src/branch/",
|
blobInfix: "/src/branch/",
|
||||||
lineAnchor: GITHUB_LINE_ANCHOR,
|
lineAnchor: GITHUB_LINE_ANCHOR,
|
||||||
referencePaths: [
|
|
||||||
{ kind: "change_request", infix: "/pulls/" },
|
|
||||||
{ kind: "issue", infix: "/issues/" },
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ForgeSpecificEnvelope = { forge: string } & Record<string, unknown>;
|
export type ForgeSpecificEnvelope = { forge: string } & Record<string, unknown>;
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { extractForgeRefs, parseForgeRef } from "./forge-refs";
|
|
||||||
|
|
||||||
describe("parseForgeRef", () => {
|
|
||||||
it.each([
|
|
||||||
[
|
|
||||||
"GitHub pull request",
|
|
||||||
"git@github.com:getpaseo/paseo.git",
|
|
||||||
"https://github.com/getpaseo/paseo/pull/994/files?diff=split#discussion_r123",
|
|
||||||
{ kind: "change_request", number: 994 },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"GitHub issue",
|
|
||||||
"https://github.com/getpaseo/paseo.git",
|
|
||||||
"https://github.com/getpaseo/paseo/issues/456",
|
|
||||||
{ kind: "issue", number: 456 },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"nested GitLab merge request",
|
|
||||||
"git@gitlab.com:group/subgroup/project.git",
|
|
||||||
"https://gitlab.com/group/subgroup/project/-/merge_requests/73/diffs",
|
|
||||||
{ kind: "change_request", number: 73 },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"GitLab issue",
|
|
||||||
"https://gitlab.com/group/project.git",
|
|
||||||
"https://gitlab.com/group/project/-/issues/19#note_1",
|
|
||||||
{ kind: "issue", number: 19 },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"self-hosted Gitea pull request",
|
|
||||||
"ssh://git@git.example.com/acme/project.git",
|
|
||||||
"https://git.example.com/acme/project/pulls/31/files",
|
|
||||||
{ kind: "change_request", number: 31 },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"Forgejo issue",
|
|
||||||
"git@forgejo.example.com:acme/project.git",
|
|
||||||
"https://forgejo.example.com/acme/project/issues/27",
|
|
||||||
{ kind: "issue", number: 27 },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"Codeberg pull request",
|
|
||||||
"git@codeberg.org:acme/project.git",
|
|
||||||
"https://codeberg.org/acme/project/pulls/8",
|
|
||||||
{ kind: "change_request", number: 8 },
|
|
||||||
],
|
|
||||||
])("parses a matching %s URL", (_label, remote, text, expected) => {
|
|
||||||
expect(parseForgeRef(text, remote)).toEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("canonicalizes a cloud SSH alias to its web host", () => {
|
|
||||||
expect(
|
|
||||||
parseForgeRef(
|
|
||||||
"https://github.com/getpaseo/paseo/pull/994",
|
|
||||||
"ssh://git@ssh.github.com/getpaseo/paseo.git",
|
|
||||||
),
|
|
||||||
).toEqual({ kind: "change_request", number: 994 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores another host, repository, and malformed local id", () => {
|
|
||||||
const remote = "git@gitlab.com:group/project.git";
|
|
||||||
expect(
|
|
||||||
parseForgeRef("https://other.example/group/project/-/merge_requests/1", remote),
|
|
||||||
).toBeNull();
|
|
||||||
expect(parseForgeRef("https://gitlab.com/group/other/-/merge_requests/1", remote)).toBeNull();
|
|
||||||
expect(
|
|
||||||
parseForgeRef("https://gitlab.com/group/project/-/merge_requests/1oops", remote),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not apply another forge's route grammar to a known cloud host", () => {
|
|
||||||
expect(
|
|
||||||
parseForgeRef(
|
|
||||||
"https://github.com/getpaseo/paseo/pulls/31",
|
|
||||||
"git@github.com:getpaseo/paseo.git",
|
|
||||||
),
|
|
||||||
).toBeNull();
|
|
||||||
expect(
|
|
||||||
parseForgeRef("https://gitlab.com/group/project/pull/31", "git@gitlab.com:group/project.git"),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("extractForgeRefs", () => {
|
|
||||||
it("extracts matching references in text order and deduplicates them", () => {
|
|
||||||
const text = [
|
|
||||||
"[MR](https://gitlab.com/group/project/-/merge_requests/12/diffs).",
|
|
||||||
"https://gitlab.com/group/project/-/issues/34#note_1,",
|
|
||||||
"https://gitlab.com/group/project/-/merge_requests/12",
|
|
||||||
"https://gitlab.com/group/other/-/issues/99",
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
expect(extractForgeRefs(text, "git@gitlab.com:group/project.git")).toEqual([
|
|
||||||
{ kind: "change_request", number: 12 },
|
|
||||||
{ kind: "issue", number: 34 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns no references without text or a valid remote", () => {
|
|
||||||
expect(extractForgeRefs("", "git@github.com:getpaseo/paseo.git")).toEqual([]);
|
|
||||||
expect(extractForgeRefs("https://github.com/getpaseo/paseo/pull/1", null)).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
import { FORGE_DEFINITIONS } from "@getpaseo/protocol/forge-manifest";
|
|
||||||
import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
|
||||||
import type { ForgeReferencePath } from "@/git/client-forge-module";
|
|
||||||
import { CLIENT_FORGE_LOGIC_MODULES } from "@/git/forges";
|
|
||||||
|
|
||||||
export interface ForgeRef {
|
|
||||||
kind: ForgeReferencePath["kind"];
|
|
||||||
number: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const WEB_URL_PATTERN = /https?:\/\/[^\s<>"'`)\]]+/giu;
|
|
||||||
const TRAILING_SENTENCE_PUNCTUATION = /[.,;:!]+$/u;
|
|
||||||
|
|
||||||
interface RemoteReferenceTarget {
|
|
||||||
webHosts: ReadonlySet<string>;
|
|
||||||
repoPath: string;
|
|
||||||
/** Known cloud forge ids, or null when a self-hosted domain is unresolved. */
|
|
||||||
forgeIds: ReadonlySet<string> | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds issue and change-request URLs belonging to the current repository.
|
|
||||||
* Route syntax comes entirely from the app forge registry: adding a forge's
|
|
||||||
* `urlGrammar.referencePaths` makes composer auto-attachment work without a
|
|
||||||
* central forge switch.
|
|
||||||
*/
|
|
||||||
export function extractForgeRefs(
|
|
||||||
text: string | null | undefined,
|
|
||||||
remoteUrl: string | null | undefined,
|
|
||||||
): ForgeRef[] {
|
|
||||||
const target = resolveRemoteReferenceTarget(remoteUrl);
|
|
||||||
const body = text?.trim();
|
|
||||||
if (!target || !body) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const refs: ForgeRef[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
for (const match of body.matchAll(WEB_URL_PATTERN)) {
|
|
||||||
const candidate = parsePastedWebUrl(match[0]);
|
|
||||||
if (!candidate || !target.webHosts.has(normalizeHost(candidate.hostname))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const pathname = decodePathname(candidate.pathname);
|
|
||||||
if (!pathname) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const referencePath of registeredReferencePaths(target.forgeIds)) {
|
|
||||||
const ref = parseReferencePath(pathname, target.repoPath, referencePath);
|
|
||||||
if (!ref) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const key = forgeRefKey(ref);
|
|
||||||
if (!seen.has(key)) {
|
|
||||||
seen.add(key);
|
|
||||||
refs.push(ref);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return refs;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseForgeRef(
|
|
||||||
text: string | null | undefined,
|
|
||||||
remoteUrl: string | null | undefined,
|
|
||||||
): ForgeRef | null {
|
|
||||||
return extractForgeRefs(text, remoteUrl)[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveRemoteReferenceTarget(
|
|
||||||
remoteUrl: string | null | undefined,
|
|
||||||
): RemoteReferenceTarget | null {
|
|
||||||
if (!remoteUrl) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const remote = parseGitRemoteLocation(remoteUrl);
|
|
||||||
if (!remote) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const webHosts = new Set([remote.host]);
|
|
||||||
const forgeIds = new Set<string>();
|
|
||||||
for (const definition of FORGE_DEFINITIONS) {
|
|
||||||
const cloudHosts = definition.cloudHosts?.map(normalizeHost) ?? [];
|
|
||||||
if (cloudHosts.includes(remote.host)) {
|
|
||||||
forgeIds.add(definition.id);
|
|
||||||
for (const host of cloudHosts) {
|
|
||||||
webHosts.add(host);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
webHosts,
|
|
||||||
repoPath: remote.path,
|
|
||||||
forgeIds: forgeIds.size > 0 ? forgeIds : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePastedWebUrl(raw: string): URL | null {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(raw.replace(TRAILING_SENTENCE_PUNCTUATION, ""));
|
|
||||||
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodePathname(pathname: string): string | null {
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(pathname);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function registeredReferencePaths(
|
|
||||||
forgeIds: ReadonlySet<string> | null,
|
|
||||||
): readonly ForgeReferencePath[] {
|
|
||||||
return CLIENT_FORGE_LOGIC_MODULES.flatMap((module) =>
|
|
||||||
!forgeIds || forgeIds.has(module.id) ? (module.urlGrammar?.referencePaths ?? []) : [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseReferencePath(
|
|
||||||
pathname: string,
|
|
||||||
repoPath: string,
|
|
||||||
referencePath: ForgeReferencePath,
|
|
||||||
): ForgeRef | null {
|
|
||||||
const prefix = `/${repoPath}${referencePath.infix}`;
|
|
||||||
if (!pathname.toLowerCase().startsWith(prefix.toLowerCase())) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const suffix = pathname.slice(prefix.length);
|
|
||||||
const numberText = suffix.match(/^(\d+)(?:\/|$)/u)?.[1];
|
|
||||||
if (!numberText) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const number = Number.parseInt(numberText, 10);
|
|
||||||
return Number.isSafeInteger(number) && number > 0 ? { kind: referencePath.kind, number } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function forgeRefKey(ref: ForgeRef): string {
|
|
||||||
return `${ref.kind}:${ref.number}`;
|
|
||||||
}
|
|
||||||
@@ -1,30 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import { buildForgeBlobUrl, buildForgeBranchTreeUrl, hasForgeWebUrls } from "./forge-url";
|
||||||
buildForgeBlobUrl,
|
|
||||||
buildForgeBranchTreeUrl,
|
|
||||||
buildForgeChecksUrl,
|
|
||||||
hasForgeWebUrls,
|
|
||||||
} from "./forge-url";
|
|
||||||
|
|
||||||
describe("buildForgeChecksUrl", () => {
|
|
||||||
it.each([
|
|
||||||
[
|
|
||||||
"github",
|
|
||||||
"https://github.com/acme/repo/pull/12",
|
|
||||||
"https://github.com/acme/repo/pull/12/checks",
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"gitlab",
|
|
||||||
"https://gitlab.com/acme/repo/-/merge_requests/12",
|
|
||||||
"https://gitlab.com/acme/repo/-/merge_requests/12/pipelines",
|
|
||||||
],
|
|
||||||
["codeberg", "https://codeberg.org/acme/repo/pulls/12", null],
|
|
||||||
["bitbucket", "https://bitbucket.org/acme/repo/pull-requests/12", null],
|
|
||||||
["gitlab", "not a url", null],
|
|
||||||
] as const)("maps the %s checks URL", (forge, url, expected) => {
|
|
||||||
expect(buildForgeChecksUrl(forge, url)).toBe(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buildForgeBranchTreeUrl", () => {
|
describe("buildForgeBranchTreeUrl", () => {
|
||||||
it("builds a branch-specific GitHub tree URL", () => {
|
it("builds a branch-specific GitHub tree URL", () => {
|
||||||
|
|||||||
@@ -27,23 +27,6 @@ export interface ForgeBranchTreeUrlInput {
|
|||||||
branch: string | null | undefined;
|
branch: string | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildForgeChecksUrl(forge: string, changeRequestUrl: string): string | null {
|
|
||||||
const suffix = getClientForgeLogicModule(forge)?.urlGrammar?.changeRequestChecksSuffix;
|
|
||||||
if (!suffix) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const url = new URL(changeRequestUrl);
|
|
||||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
url.pathname = `${url.pathname.replace(/\/$/, "")}${suffix}`;
|
|
||||||
return url.toString();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ForgeWebLocation {
|
interface ForgeWebLocation {
|
||||||
host: string;
|
host: string;
|
||||||
/** Non-default port for a self-hosted http(s) origin, or undefined. */
|
/** Non-default port for a self-hosted http(s) origin, or undefined. */
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import {
|
|||||||
type MergeCapability,
|
type MergeCapability,
|
||||||
} from "@/git/client-forge-module";
|
} from "@/git/client-forge-module";
|
||||||
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
||||||
import { CHECK_TRAIT_WARNING } from "@getpaseo/protocol/check-traits";
|
import { mapCheckStatus, type CheckStatus } from "@/git/pull-request-panel/check-status";
|
||||||
import { mapGiteaCommitState } from "@getpaseo/protocol/gitea-status";
|
|
||||||
|
|
||||||
const GiteaMergeFactsSchema = z
|
const GiteaMergeFactsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -21,6 +20,18 @@ const GiteaMergeFactsSchema = z
|
|||||||
|
|
||||||
type GiteaMergeFacts = z.infer<typeof GiteaMergeFactsSchema>;
|
type GiteaMergeFacts = z.infer<typeof GiteaMergeFactsSchema>;
|
||||||
|
|
||||||
|
// forgeSpecific.ciStatus carries Gitea's raw aggregate CI string. Server twin:
|
||||||
|
// packages/server/src/services/gitea-service.ts (mapGiteaCommitStatus) — "warning"
|
||||||
|
// and "error" are terminal, non-passing states, but the generic mapCheckStatus
|
||||||
|
// would show them as pending, so interpret them here where the module owns Gitea
|
||||||
|
// facts.
|
||||||
|
function mapGiteaCiStatus(ciStatus: string): CheckStatus {
|
||||||
|
if (ciStatus === "warning" || ciStatus === "error") {
|
||||||
|
return "failure";
|
||||||
|
}
|
||||||
|
return mapCheckStatus(ciStatus);
|
||||||
|
}
|
||||||
|
|
||||||
const GITEA_MERGE_METHODS: CheckoutPrMergeMethod[] = ["merge", "squash", "rebase"];
|
const GITEA_MERGE_METHODS: CheckoutPrMergeMethod[] = ["merge", "squash", "rebase"];
|
||||||
|
|
||||||
function deriveGiteaMergeCapability(gitea: GiteaMergeFacts): MergeCapability {
|
function deriveGiteaMergeCapability(gitea: GiteaMergeFacts): MergeCapability {
|
||||||
@@ -51,8 +62,7 @@ export const giteaForgeLogic = {
|
|||||||
return {
|
return {
|
||||||
provider: forge,
|
provider: forge,
|
||||||
name: "CI",
|
name: "CI",
|
||||||
status: mapGiteaCommitState(facts.ciStatus),
|
status: mapGiteaCiStatus(facts.ciStatus),
|
||||||
...(facts.ciStatus === "warning" ? { traits: [CHECK_TRAIT_WARNING] } : {}),
|
|
||||||
url: status.url,
|
url: status.url,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -86,11 +86,6 @@ export const githubForgeLogic = {
|
|||||||
treeInfix: "/tree/",
|
treeInfix: "/tree/",
|
||||||
blobInfix: "/blob/",
|
blobInfix: "/blob/",
|
||||||
lineAnchor: GITHUB_LINE_ANCHOR,
|
lineAnchor: GITHUB_LINE_ANCHOR,
|
||||||
changeRequestChecksSuffix: "/checks",
|
|
||||||
referencePaths: [
|
|
||||||
{ kind: "change_request", infix: "/pull/" },
|
|
||||||
{ kind: "issue", infix: "/issues/" },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
facts: defineForgeFacts({
|
facts: defineForgeFacts({
|
||||||
family: "github",
|
family: "github",
|
||||||
|
|||||||
@@ -4,25 +4,29 @@ import {
|
|||||||
type ClientForgeLogicModule,
|
type ClientForgeLogicModule,
|
||||||
type MergeCapability,
|
type MergeCapability,
|
||||||
} from "@/git/client-forge-module";
|
} from "@/git/client-forge-module";
|
||||||
import type { CheckoutPipelineJob, CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
||||||
import {
|
|
||||||
CHECK_TRAIT_ACTION_REQUIRED,
|
|
||||||
CHECK_TRAIT_MANUAL,
|
|
||||||
CHECK_TRAIT_WARNING,
|
|
||||||
} from "@getpaseo/protocol/check-traits";
|
|
||||||
import { GITLAB_ACTIVE_PIPELINE_STATUS_SET } from "@getpaseo/protocol/gitlab-pipeline";
|
|
||||||
import type { CheckStatus } from "@/git/pull-request-panel/check-status";
|
import type { CheckStatus } from "@/git/pull-request-panel/check-status";
|
||||||
import {
|
|
||||||
classifyCheck,
|
|
||||||
countCheckPresentations,
|
|
||||||
type CheckPresentation,
|
|
||||||
type CheckPresentationCounts,
|
|
||||||
type PresentableCheck,
|
|
||||||
} from "@/git/check-presentation";
|
|
||||||
|
|
||||||
const gitlabLineAnchor = (start: number, end?: number): string =>
|
const gitlabLineAnchor = (start: number, end?: number): string =>
|
||||||
end && end > start ? `#L${start}-${end}` : `#L${start}`;
|
end && end > start ? `#L${start}-${end}` : `#L${start}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical set of GitLab pipeline statuses that count as "still active" (a
|
||||||
|
* pipeline that has not reached a terminal state). Server-side twin:
|
||||||
|
* packages/server/src/services/gitlab-facts.ts (GITLAB_ACTIVE_PIPELINE_STATUSES),
|
||||||
|
* duplicated because the app can't depend on the server package.
|
||||||
|
*/
|
||||||
|
const GITLAB_ACTIVE_PIPELINE_STATUSES = [
|
||||||
|
"created",
|
||||||
|
"waiting_for_resource",
|
||||||
|
"preparing",
|
||||||
|
"pending",
|
||||||
|
"running",
|
||||||
|
"scheduled",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const GITLAB_ACTIVE_PIPELINE_STATUS_SET = new Set<string>(GITLAB_ACTIVE_PIPELINE_STATUSES);
|
||||||
|
|
||||||
export function isPipelineActiveStatus(status: string): boolean {
|
export function isPipelineActiveStatus(status: string): boolean {
|
||||||
return GITLAB_ACTIVE_PIPELINE_STATUS_SET.has(status);
|
return GITLAB_ACTIVE_PIPELINE_STATUS_SET.has(status);
|
||||||
}
|
}
|
||||||
@@ -34,45 +38,23 @@ export function mapPipelineStatus(status: string): CheckStatus {
|
|||||||
return "success";
|
return "success";
|
||||||
case "failed":
|
case "failed":
|
||||||
return "failure";
|
return "failure";
|
||||||
|
case "running":
|
||||||
|
case "pending":
|
||||||
|
case "created":
|
||||||
|
case "waiting_for_resource":
|
||||||
|
case "preparing":
|
||||||
|
case "scheduled":
|
||||||
|
case "manual":
|
||||||
|
return "pending";
|
||||||
case "canceled":
|
case "canceled":
|
||||||
case "cancelled":
|
case "cancelled":
|
||||||
case "skipped":
|
case "skipped":
|
||||||
return "skipped";
|
return "skipped";
|
||||||
default:
|
default:
|
||||||
// "manual" and every active transitional status (see
|
|
||||||
// GITLAB_ACTIVE_PIPELINE_STATUSES) render as pending.
|
|
||||||
return "pending";
|
return "pending";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type GitlabPipelineJobPresentationInput = Pick<CheckoutPipelineJob, "status" | "allowFailure">;
|
|
||||||
|
|
||||||
function toPresentableGitlabPipelineJob(job: GitlabPipelineJobPresentationInput): PresentableCheck {
|
|
||||||
const status = mapPipelineStatus(job.status);
|
|
||||||
if (job.status === "manual") {
|
|
||||||
return {
|
|
||||||
status,
|
|
||||||
traits: job.allowFailure
|
|
||||||
? [CHECK_TRAIT_MANUAL]
|
|
||||||
: [CHECK_TRAIT_MANUAL, CHECK_TRAIT_ACTION_REQUIRED],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (status === "failure" && job.allowFailure) return { status, traits: [CHECK_TRAIT_WARNING] };
|
|
||||||
return { status };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function classifyGitlabPipelineJob(
|
|
||||||
job: GitlabPipelineJobPresentationInput,
|
|
||||||
): CheckPresentation {
|
|
||||||
return classifyCheck(toPresentableGitlabPipelineJob(job));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function countGitlabPipelineJobs(
|
|
||||||
jobs: readonly CheckoutPipelineJob[],
|
|
||||||
): CheckPresentationCounts {
|
|
||||||
return countCheckPresentations(jobs.map(toPresentableGitlabPipelineJob));
|
|
||||||
}
|
|
||||||
|
|
||||||
const GitlabMergeFactsSchema = z
|
const GitlabMergeFactsSchema = z
|
||||||
.object({
|
.object({
|
||||||
forge: z.literal("gitlab"),
|
forge: z.literal("gitlab"),
|
||||||
@@ -164,11 +146,6 @@ export const gitlabForgeLogic = {
|
|||||||
treeInfix: "/-/tree/",
|
treeInfix: "/-/tree/",
|
||||||
blobInfix: "/-/blob/",
|
blobInfix: "/-/blob/",
|
||||||
lineAnchor: gitlabLineAnchor,
|
lineAnchor: gitlabLineAnchor,
|
||||||
changeRequestChecksSuffix: "/pipelines",
|
|
||||||
referencePaths: [
|
|
||||||
{ kind: "change_request", infix: "/-/merge_requests/" },
|
|
||||||
{ kind: "issue", infix: "/-/issues/" },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
facts: defineForgeFacts({
|
facts: defineForgeFacts({
|
||||||
family: "gitlab",
|
family: "gitlab",
|
||||||
|
|||||||
@@ -12,23 +12,24 @@ import {
|
|||||||
} from "@/git/client-forge-module";
|
} from "@/git/client-forge-module";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { formatDuration } from "@/utils/time";
|
import { formatDuration } from "@/utils/time";
|
||||||
import { COUNTED_CHECK_PRESENTATIONS } from "@/git/check-presentation";
|
|
||||||
import { CheckPresentationIcon } from "@/git/check-presentation.view";
|
|
||||||
import {
|
import {
|
||||||
CheckPresentationSummaryPill,
|
CheckStatusIcon,
|
||||||
Section,
|
Section,
|
||||||
|
SUMMARY_DANGER_ICON,
|
||||||
|
SUMMARY_SUCCESS_ICON,
|
||||||
|
SUMMARY_WARNING_ICON,
|
||||||
|
SummaryPill,
|
||||||
foregroundMutedColorMapping,
|
foregroundMutedColorMapping,
|
||||||
sectionKitStyles,
|
sectionKitStyles,
|
||||||
successColorMapping,
|
successColorMapping,
|
||||||
} from "@/git/pull-request-panel/section-kit";
|
} from "@/git/pull-request-panel/section-kit";
|
||||||
import { useGitLabPipeline } from "@/git/pull-request-panel/use-pipeline";
|
import { useGitLabPipeline } from "@/git/pull-request-panel/use-pipeline";
|
||||||
import {
|
import {
|
||||||
classifyGitlabPipelineJob,
|
|
||||||
deriveGitlabApprovals,
|
deriveGitlabApprovals,
|
||||||
deriveGitlabPipelineSummary,
|
deriveGitlabPipelineSummary,
|
||||||
countGitlabPipelineJobs,
|
|
||||||
GitlabMergeFactsSchema,
|
GitlabMergeFactsSchema,
|
||||||
isPipelineActiveStatus,
|
isPipelineActiveStatus,
|
||||||
|
mapPipelineStatus,
|
||||||
type GitlabApprovals,
|
type GitlabApprovals,
|
||||||
type GitlabMergeFacts,
|
type GitlabMergeFacts,
|
||||||
type GitlabPipelineSummary,
|
type GitlabPipelineSummary,
|
||||||
@@ -98,6 +99,23 @@ function formatPipelineDuration(seconds: number | null): string {
|
|||||||
return formatDuration(seconds * 1000);
|
return formatDuration(seconds * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PipelineJobCounts {
|
||||||
|
passed: number;
|
||||||
|
failed: number;
|
||||||
|
pending: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countPipelineJobs(jobs: CheckoutPipelineJob[]): PipelineJobCounts {
|
||||||
|
const counts: PipelineJobCounts = { passed: 0, failed: 0, pending: 0 };
|
||||||
|
for (const job of jobs) {
|
||||||
|
const status = mapPipelineStatus(job.status);
|
||||||
|
if (status === "success") counts.passed += 1;
|
||||||
|
else if (status === "failure") counts.failed += 1;
|
||||||
|
else if (status === "pending") counts.pending += 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
function GitLabPipelineSection({
|
function GitLabPipelineSection({
|
||||||
serverId,
|
serverId,
|
||||||
cwd,
|
cwd,
|
||||||
@@ -126,13 +144,13 @@ function GitLabPipelineSection({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const counts = useMemo(
|
const counts = useMemo(
|
||||||
() => countGitlabPipelineJobs((pipeline?.stages ?? []).flatMap((stage) => stage.jobs)),
|
() => countPipelineJobs((pipeline?.stages ?? []).flatMap((stage) => stage.jobs)),
|
||||||
[pipeline],
|
[pipeline],
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalCounted = Object.values(counts).reduce((total, count) => total + count, 0);
|
const totalCounted = counts.passed + counts.failed + counts.pending;
|
||||||
const showBreakdown = !isPlaceholderData && totalCounted > 0;
|
const showBreakdown = !isPlaceholderData && totalCounted > 0;
|
||||||
const displayCounts = showBreakdown ? counts : countGitlabPipelineJobs([]);
|
const displayCounts = showBreakdown ? counts : { passed: 0, failed: 0, pending: 0 };
|
||||||
|
|
||||||
const handleOpenPipeline = useCallback(() => {
|
const handleOpenPipeline = useCallback(() => {
|
||||||
if (summary.url) {
|
if (summary.url) {
|
||||||
@@ -142,15 +160,25 @@ function GitLabPipelineSection({
|
|||||||
|
|
||||||
const sectionSummary = (
|
const sectionSummary = (
|
||||||
<>
|
<>
|
||||||
{COUNTED_CHECK_PRESENTATIONS.map((presentation) => (
|
<SummaryPill
|
||||||
<CheckPresentationSummaryPill
|
count={displayCounts.passed}
|
||||||
key={presentation}
|
icon={SUMMARY_SUCCESS_ICON}
|
||||||
count={displayCounts[presentation]}
|
variant="success"
|
||||||
presentation={presentation}
|
testID="pr-pane-pipeline-passed"
|
||||||
testID={`pr-pane-pipeline-${presentation}`}
|
/>
|
||||||
/>
|
<SummaryPill
|
||||||
))}
|
count={displayCounts.failed}
|
||||||
{showBreakdown ? null : <GitLabPipelineStatusIcon status={summary.rawStatus} />}
|
icon={SUMMARY_DANGER_ICON}
|
||||||
|
variant="danger"
|
||||||
|
testID="pr-pane-pipeline-failed"
|
||||||
|
/>
|
||||||
|
<SummaryPill
|
||||||
|
count={displayCounts.pending}
|
||||||
|
icon={SUMMARY_WARNING_ICON}
|
||||||
|
variant="warning"
|
||||||
|
testID="pr-pane-pipeline-pending"
|
||||||
|
/>
|
||||||
|
{showBreakdown ? null : <CheckStatusIcon status={summary.status} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -167,7 +195,7 @@ function GitLabPipelineSection({
|
|||||||
disabled={!summary.url}
|
disabled={!summary.url}
|
||||||
testID="pr-pane-pipeline-link"
|
testID="pr-pane-pipeline-link"
|
||||||
>
|
>
|
||||||
<GitLabPipelineStatusIcon status={summary.rawStatus} />
|
<CheckStatusIcon status={summary.status} />
|
||||||
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
|
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
|
||||||
{`Pipeline #${summary.id}`}
|
{`Pipeline #${summary.id}`}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -206,7 +234,7 @@ function PipelineStageGroup({ stage }: { stage: CheckoutPipelineStage }) {
|
|||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<View style={styles.pipelineStageHeader}>
|
<View style={styles.pipelineStageHeader}>
|
||||||
<GitLabPipelineStatusIcon status={stage.status} />
|
<CheckStatusIcon status={mapPipelineStatus(stage.status)} />
|
||||||
<Text style={styles.pipelineStageName} numberOfLines={1}>
|
<Text style={styles.pipelineStageName} numberOfLines={1}>
|
||||||
{stage.name}
|
{stage.name}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -228,7 +256,7 @@ function PipelineJobRow({ job }: { job: CheckoutPipelineJob }) {
|
|||||||
const duration = formatPipelineDuration(job.durationSeconds);
|
const duration = formatPipelineDuration(job.durationSeconds);
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={handlePress} style={jobRowPressableStyle} disabled={!job.url}>
|
<Pressable onPress={handlePress} style={jobRowPressableStyle} disabled={!job.url}>
|
||||||
<GitLabPipelineStatusIcon status={job.status} allowFailure={job.allowFailure} />
|
<CheckStatusIcon status={mapPipelineStatus(job.status)} />
|
||||||
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
|
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
|
||||||
{job.name}
|
{job.name}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -244,17 +272,6 @@ function PipelineJobRow({ job }: { job: CheckoutPipelineJob }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GitLabPipelineStatusIcon({
|
|
||||||
status,
|
|
||||||
allowFailure = false,
|
|
||||||
}: {
|
|
||||||
status: string;
|
|
||||||
allowFailure?: boolean;
|
|
||||||
}) {
|
|
||||||
const presentation = classifyGitlabPipelineJob({ status, allowFailure });
|
|
||||||
return <CheckPresentationIcon presentation={presentation} size={14} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const gitlabForgeView = {
|
export const gitlabForgeView = {
|
||||||
id: "gitlab",
|
id: "gitlab",
|
||||||
icon: GitLabIcon,
|
icon: GitLabIcon,
|
||||||
|
|||||||
@@ -31,10 +31,11 @@ function deriveForgeMergeCapability(facts: unknown): MergeCapability | null {
|
|||||||
* Per-forge derivation lives on client forge modules; this stays the single
|
* Per-forge derivation lives on client forge modules; this stays the single
|
||||||
* neutral entry point the action policy reads.
|
* neutral entry point the action policy reads.
|
||||||
*
|
*
|
||||||
* COMPAT(forgeSpecific): forgeSpecific shipped in v0.2.0-beta.1. A daemon
|
* COMPAT(forgeSpecific): added in v0.1.106, remove after 2026-12-27. A daemon
|
||||||
* predating it emits only the legacy `status.github` field. When forgeSpecific
|
* predating forgeSpecific emits only the legacy `status.github` field. When
|
||||||
* is absent we synthesize the GitHub arm from those legacy facts. Remove after
|
* forgeSpecific is absent we synthesize the github arm from those legacy facts
|
||||||
* 2027-01-17 once the supported daemon floor is >= v0.2.0.
|
* so GitHub merge-method restrictions and auto-merge state are not lost against
|
||||||
|
* an old daemon.
|
||||||
*/
|
*/
|
||||||
export function deriveMergeCapability(
|
export function deriveMergeCapability(
|
||||||
forgeSpecific: unknown,
|
forgeSpecific: unknown,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { normalizeForge, type Forge } from "@/git/forge";
|
import { normalizeForge, type Forge } from "@/git/forge";
|
||||||
import type { PresentableCheck } from "@/git/check-presentation";
|
|
||||||
|
|
||||||
export interface PrHint {
|
export interface PrHint {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -7,21 +6,16 @@ export interface PrHint {
|
|||||||
state: "open" | "merged" | "closed";
|
state: "open" | "merged" | "closed";
|
||||||
/** Forge backing this change request, so badges render the right brand mark. */
|
/** Forge backing this change request, so badges render the right brand mark. */
|
||||||
forge: Forge;
|
forge: Forge;
|
||||||
checks?: PrHintCheck[];
|
checks?: Array<{ name: string; status: string; url: string | null }>;
|
||||||
checksStatus?: "none" | "pending" | "success" | "failure";
|
checksStatus?: "none" | "pending" | "success" | "failure";
|
||||||
reviewDecision?: "approved" | "changes_requested" | "pending" | null;
|
reviewDecision?: "approved" | "changes_requested" | "pending" | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrHintCheck extends PresentableCheck {
|
|
||||||
name: string;
|
|
||||||
url: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PrStatusLike {
|
interface PrStatusLike {
|
||||||
url: string;
|
url: string;
|
||||||
state: string;
|
state: string;
|
||||||
isMerged: boolean;
|
isMerged: boolean;
|
||||||
checks?: PrHintCheck[];
|
checks?: Array<{ name: string; status: string; url: string | null }>;
|
||||||
checksStatus?: string;
|
checksStatus?: string;
|
||||||
reviewDecision?: string | null;
|
reviewDecision?: string | null;
|
||||||
forge?: string;
|
forge?: string;
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ export function normalizeCheckoutPrStatusPayload(
|
|||||||
): CheckoutPrStatusPayload {
|
): CheckoutPrStatusPayload {
|
||||||
return {
|
return {
|
||||||
...payload,
|
...payload,
|
||||||
// COMPAT(forgeAuthState): normalize the legacy GitHub boolean for daemons
|
// COMPAT(forgeAuthState): added in v0.1.106, remove after 2026-12-27 once
|
||||||
// predating v0.2.0-beta.1. Remove after 2027-01-17 once the supported daemon
|
// all supported daemons send authState.
|
||||||
// floor is >= v0.2.0.
|
|
||||||
authState:
|
authState:
|
||||||
parseForgeAuthState(payload.authState) ??
|
parseForgeAuthState(payload.authState) ??
|
||||||
(payload.githubFeaturesEnabled ? "authenticated" : "unauthenticated"),
|
(payload.githubFeaturesEnabled ? "authenticated" : "unauthenticated"),
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import type { CheckPresentation } from "@/git/check-presentation";
|
import type { CheckStatus } from "./check-status";
|
||||||
|
|
||||||
/** Per-mounted PR panel state; never shared with another tab instance. */
|
/** Per-mounted PR panel state; never shared with another tab instance. */
|
||||||
export function useCheckGroupState() {
|
export function useCheckGroupState() {
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState<ReadonlySet<CheckPresentation>>(
|
const [collapsedGroups, setCollapsedGroups] = useState<ReadonlySet<CheckStatus>>(() => new Set());
|
||||||
() => new Set(),
|
const toggle = useCallback((status: CheckStatus) => {
|
||||||
);
|
|
||||||
const toggle = useCallback((status: CheckPresentation) => {
|
|
||||||
setCollapsedGroups((current) => {
|
setCollapsedGroups((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
if (!next.delete(status)) next.add(status);
|
if (!next.delete(status)) next.add(status);
|
||||||
|
|||||||
@@ -4,17 +4,19 @@
|
|||||||
* maps its raw CI strings onto this frozen union. Forge-specific vocabularies
|
* maps its raw CI strings onto this frozen union. Forge-specific vocabularies
|
||||||
* (e.g. GitLab pipeline statuses) live in the owning forge module.
|
* (e.g. GitLab pipeline statuses) live in the owning forge module.
|
||||||
*/
|
*/
|
||||||
export type CheckStatus = "success" | "failure" | "pending" | "skipped" | "cancelled";
|
export type CheckStatus = "success" | "failure" | "pending" | "skipped";
|
||||||
|
|
||||||
export function mapCheckStatus(status: string): CheckStatus {
|
export function mapCheckStatus(status: string): CheckStatus {
|
||||||
if (
|
if (
|
||||||
status === "success" ||
|
status === "success" ||
|
||||||
status === "failure" ||
|
status === "failure" ||
|
||||||
status === "pending" ||
|
status === "pending" ||
|
||||||
status === "skipped" ||
|
status === "skipped"
|
||||||
status === "cancelled"
|
|
||||||
) {
|
) {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
if (status === "cancelled") {
|
||||||
|
return "skipped";
|
||||||
|
}
|
||||||
return "pending";
|
return "pending";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Svg, { Circle } from "react-native-svg";
|
import Svg, { Circle } from "react-native-svg";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import type { CheckPresentation } from "@/git/check-presentation";
|
import type { CheckStatus } from "./check-status";
|
||||||
import type { ChecksSummary } from "./checks-summary";
|
import type { ChecksSummary } from "./checks-summary";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,23 +18,20 @@ const SEGMENT_GAP = 2;
|
|||||||
/** Floor for a segment, so one failure among twenty checks is still a visible mark. */
|
/** Floor for a segment, so one failure among twenty checks is still a visible mark. */
|
||||||
const MIN_SEGMENT = 1.5;
|
const MIN_SEGMENT = 1.5;
|
||||||
|
|
||||||
type ChecksPalette = Record<CheckPresentation, string> & { track: string };
|
type ChecksPalette = Record<CheckStatus, string> & { track: string };
|
||||||
|
|
||||||
const paletteMapping = (theme: Theme): { palette: ChecksPalette } => ({
|
const paletteMapping = (theme: Theme): { palette: ChecksPalette } => ({
|
||||||
palette: {
|
palette: {
|
||||||
actionRequired: theme.colors.statusWarning,
|
|
||||||
warning: theme.colors.statusWarning,
|
|
||||||
failure: theme.colors.statusDanger,
|
failure: theme.colors.statusDanger,
|
||||||
pending: theme.colors.statusWarning,
|
pending: theme.colors.statusWarning,
|
||||||
manual: theme.colors.foregroundMuted,
|
|
||||||
success: theme.colors.statusSuccess,
|
success: theme.colors.statusSuccess,
|
||||||
ignored: theme.colors.foregroundExtraMuted,
|
skipped: theme.colors.foregroundExtraMuted,
|
||||||
track: theme.colors.surface3,
|
track: theme.colors.surface3,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
interface RingSegment {
|
interface RingSegment {
|
||||||
status: CheckPresentation;
|
status: CheckStatus;
|
||||||
/** Painted arc length. */
|
/** Painted arc length. */
|
||||||
dash: number;
|
dash: number;
|
||||||
/** Distance from twelve o'clock to where this segment starts. */
|
/** Distance from twelve o'clock to where this segment starts. */
|
||||||
|
|||||||
@@ -5,13 +5,12 @@ import { ChevronDown, ChevronRight, MessageSquarePlus } from "lucide-react-nativ
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { ICON_SIZE } from "@/styles/theme";
|
import { ICON_SIZE } from "@/styles/theme";
|
||||||
import { classifyCheck, type CheckPresentation } from "@/git/check-presentation";
|
import type { CheckStatus } from "./check-status";
|
||||||
import { CheckPresentationIcon } from "@/git/check-presentation.view";
|
|
||||||
import { ChecksRing } from "./checks-ring";
|
import { ChecksRing } from "./checks-ring";
|
||||||
import { summarizeChecks, type ChecksGroup } from "./checks-summary";
|
import { summarizeChecks, type ChecksGroup } from "./checks-summary";
|
||||||
import { canAddPullRequestCheckLogsToChat } from "./context-attachment";
|
import { canAddPullRequestCheckLogsToChat } from "./context-attachment";
|
||||||
import type { PrPaneCheck } from "./data";
|
import type { PrPaneCheck } from "./data";
|
||||||
import { foregroundMutedColorMapping, sectionKitStyles } from "./section-kit";
|
import { CheckStatusIcon, foregroundMutedColorMapping, sectionKitStyles } from "./section-kit";
|
||||||
import { useCheckGroupState } from "./check-group-state";
|
import { useCheckGroupState } from "./check-group-state";
|
||||||
|
|
||||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||||
@@ -27,13 +26,10 @@ const LIST_MAX_HEIGHT = 268;
|
|||||||
* The three statuses the pane has always labelled for tests. Skipped has no id because
|
* The three statuses the pane has always labelled for tests. Skipped has no id because
|
||||||
* nothing asserts on it.
|
* nothing asserts on it.
|
||||||
*/
|
*/
|
||||||
const PART_TEST_ID: Partial<Record<CheckPresentation, string>> = {
|
const PART_TEST_ID: Partial<Record<CheckStatus, string>> = {
|
||||||
actionRequired: "pr-pane-check-action-required",
|
|
||||||
warning: "pr-pane-check-warning",
|
|
||||||
success: "pr-pane-check-passed",
|
success: "pr-pane-check-passed",
|
||||||
failure: "pr-pane-check-failed",
|
failure: "pr-pane-check-failed",
|
||||||
pending: "pr-pane-check-pending",
|
pending: "pr-pane-check-pending",
|
||||||
manual: "pr-pane-check-manual",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -141,7 +137,7 @@ function CheckGroup({
|
|||||||
}: {
|
}: {
|
||||||
group: ChecksGroup;
|
group: ChecksGroup;
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
onToggle: (status: CheckPresentation) => void;
|
onToggle: (status: CheckStatus) => void;
|
||||||
attachEnabled: boolean;
|
attachEnabled: boolean;
|
||||||
loadingCheckKeys: ReadonlySet<string>;
|
loadingCheckKeys: ReadonlySet<string>;
|
||||||
onAddLogsToChat: (check: PrPaneCheck) => void;
|
onAddLogsToChat: (check: PrPaneCheck) => void;
|
||||||
@@ -198,7 +194,7 @@ function CheckRow({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={handlePress} style={rowPressableStyle} testID="pr-pane-check-row">
|
<Pressable onPress={handlePress} style={rowPressableStyle} testID="pr-pane-check-row">
|
||||||
<CheckPresentationIcon presentation={classifyCheck(check)} size={14} />
|
<CheckStatusIcon status={check.status} />
|
||||||
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
|
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
|
||||||
{check.name}
|
{check.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { classifyCheck, type CheckPresentation } from "@/git/check-presentation";
|
import type { CheckStatus } from "./check-status";
|
||||||
import type { PrPaneCheck } from "./data";
|
import type { PrPaneCheck } from "./data";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -6,32 +6,25 @@ import type { PrPaneCheck } from "./data";
|
|||||||
* waiting on, then what needs no attention. Skipped trails everything because it is the
|
* waiting on, then what needs no attention. Skipped trails everything because it is the
|
||||||
* only status that says nothing about whether the run is going well.
|
* only status that says nothing about whether the run is going well.
|
||||||
*/
|
*/
|
||||||
const PRESENTATION_ORDER = [
|
const STATUS_ORDER = [
|
||||||
"actionRequired",
|
|
||||||
"warning",
|
|
||||||
"failure",
|
"failure",
|
||||||
"pending",
|
"pending",
|
||||||
"manual",
|
|
||||||
"success",
|
"success",
|
||||||
"ignored",
|
"skipped",
|
||||||
] as const satisfies readonly CheckPresentation[];
|
] as const satisfies readonly CheckStatus[];
|
||||||
|
|
||||||
/** How each status reads inside a count phrase: "2 failing", "4 in progress". */
|
/** How each status reads inside a count phrase: "2 failing", "4 in progress". */
|
||||||
const PRESENTATION_NOUN: Record<CheckPresentation, string> = {
|
const STATUS_NOUN: Record<CheckStatus, string> = {
|
||||||
actionRequired: "needs action",
|
|
||||||
warning: "warning",
|
|
||||||
failure: "failing",
|
failure: "failing",
|
||||||
pending: "in progress",
|
pending: "in progress",
|
||||||
manual: "manual",
|
|
||||||
success: "successful",
|
success: "successful",
|
||||||
ignored: "skipped",
|
skipped: "skipped",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The worst thing happening in the run, which is what the headline and the ring report. */
|
/** The worst thing happening in the run, which is what the headline and the ring report. */
|
||||||
export type ChecksOutcome = "actionRequired" | "failure" | "pending" | "success" | "none";
|
export type ChecksOutcome = "failure" | "pending" | "success" | "none";
|
||||||
|
|
||||||
const OUTCOME_HEADLINE: Record<ChecksOutcome, string> = {
|
const OUTCOME_HEADLINE: Record<ChecksOutcome, string> = {
|
||||||
actionRequired: "Some checks need your attention",
|
|
||||||
failure: "Some checks were not successful",
|
failure: "Some checks were not successful",
|
||||||
pending: "Some checks haven't completed yet",
|
pending: "Some checks haven't completed yet",
|
||||||
success: "All checks have passed",
|
success: "All checks have passed",
|
||||||
@@ -39,14 +32,14 @@ const OUTCOME_HEADLINE: Record<ChecksOutcome, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface ChecksCountPart {
|
export interface ChecksCountPart {
|
||||||
status: CheckPresentation;
|
status: CheckStatus;
|
||||||
count: number;
|
count: number;
|
||||||
/** e.g. "2 failing" */
|
/** e.g. "2 failing" */
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChecksGroup {
|
export interface ChecksGroup {
|
||||||
status: CheckPresentation;
|
status: CheckStatus;
|
||||||
/** e.g. "2 failing checks" */
|
/** e.g. "2 failing checks" */
|
||||||
label: string;
|
label: string;
|
||||||
checks: readonly PrPaneCheck[];
|
checks: readonly PrPaneCheck[];
|
||||||
@@ -76,20 +69,20 @@ export function summarizeChecks(checks: readonly PrPaneCheck[]): ChecksSummary {
|
|||||||
const groups: ChecksGroup[] = [];
|
const groups: ChecksGroup[] = [];
|
||||||
const parts: ChecksCountPart[] = [];
|
const parts: ChecksCountPart[] = [];
|
||||||
|
|
||||||
for (const status of PRESENTATION_ORDER) {
|
for (const status of STATUS_ORDER) {
|
||||||
const matching = checks.filter((check) => classifyCheck(check) === status);
|
const matching = checks.filter((check) => check.status === status);
|
||||||
if (matching.length === 0) {
|
if (matching.length === 0) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
groups.push({
|
groups.push({
|
||||||
status,
|
status,
|
||||||
label: `${matching.length} ${PRESENTATION_NOUN[status]} ${countNoun(matching.length)}`,
|
label: `${matching.length} ${STATUS_NOUN[status]} ${countNoun(matching.length)}`,
|
||||||
checks: matching,
|
checks: matching,
|
||||||
});
|
});
|
||||||
parts.push({
|
parts.push({
|
||||||
status,
|
status,
|
||||||
count: matching.length,
|
count: matching.length,
|
||||||
text: `${matching.length} ${PRESENTATION_NOUN[status]}`,
|
text: `${matching.length} ${STATUS_NOUN[status]}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,15 +107,7 @@ function selectOutcome(checks: readonly PrPaneCheck[]): ChecksOutcome {
|
|||||||
if (checks.length === 0) {
|
if (checks.length === 0) {
|
||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
if (checks.some((check) => classifyCheck(check) === "actionRequired")) {
|
if (checks.some((check) => check.status === "failure")) {
|
||||||
return "actionRequired";
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
checks.some((check) => {
|
|
||||||
const presentation = classifyCheck(check);
|
|
||||||
return presentation === "failure" || presentation === "warning";
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
return "failure";
|
return "failure";
|
||||||
}
|
}
|
||||||
if (checks.some((check) => check.status === "pending")) {
|
if (checks.some((check) => check.status === "pending")) {
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type {
|
import type {
|
||||||
CheckoutPipelineJob,
|
|
||||||
CheckoutPrStatusResponse,
|
CheckoutPrStatusResponse,
|
||||||
PullRequestTimelineResponse,
|
PullRequestTimelineResponse,
|
||||||
} from "@getpaseo/protocol/messages";
|
} from "@getpaseo/protocol/messages";
|
||||||
import {
|
import { isPipelineActiveStatus, mapPipelineStatus } from "@/git/forges/gitlab";
|
||||||
countGitlabPipelineJobs,
|
|
||||||
isPipelineActiveStatus,
|
|
||||||
mapPipelineStatus,
|
|
||||||
} from "@/git/forges/gitlab";
|
|
||||||
import { IDENTITY_COLOR_NAMES, identityColor } from "@/styles/identity-colors";
|
import { IDENTITY_COLOR_NAMES, identityColor } from "@/styles/identity-colors";
|
||||||
import {
|
import {
|
||||||
deriveAvatarColor,
|
deriveAvatarColor,
|
||||||
@@ -113,7 +108,7 @@ describe("mapPrPaneData", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps checks with null URLs by linking them to the pull request", () => {
|
it("drops checks with null URLs to preserve the pressable check contract", () => {
|
||||||
const data = mapPrPaneData(
|
const data = mapPrPaneData(
|
||||||
status({
|
status({
|
||||||
checks: [
|
checks: [
|
||||||
@@ -131,12 +126,6 @@ describe("mapPrPaneData", () => {
|
|||||||
status: "success",
|
status: "success",
|
||||||
url: "https://example.com/checks/1",
|
url: "https://example.com/checks/1",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
provider: "github",
|
|
||||||
name: "legacy status",
|
|
||||||
status: "pending",
|
|
||||||
url: "https://github.com/getpaseo/paseo/pull/42",
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,20 +141,9 @@ describe("mapPrPaneData", () => {
|
|||||||
duration: "1m",
|
duration: "1m",
|
||||||
},
|
},
|
||||||
{ name: "failure", status: "failure", url: "https://example.com/2" },
|
{ name: "failure", status: "failure", url: "https://example.com/2" },
|
||||||
{
|
{ name: "pending", status: "pending", url: "https://example.com/3" },
|
||||||
name: "approval",
|
|
||||||
status: "pending",
|
|
||||||
traits: ["action_required"],
|
|
||||||
url: "https://example.com/3",
|
|
||||||
},
|
|
||||||
{ name: "skipped", status: "skipped", url: "https://example.com/4" },
|
{ name: "skipped", status: "skipped", url: "https://example.com/4" },
|
||||||
{ name: "cancelled", status: "cancelled", url: "https://example.com/5" },
|
{ name: "cancelled", status: "cancelled", url: "https://example.com/5" },
|
||||||
{
|
|
||||||
name: "manual",
|
|
||||||
status: "skipped",
|
|
||||||
traits: ["manual"],
|
|
||||||
url: "https://example.com/6",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
baseTimeline,
|
baseTimeline,
|
||||||
@@ -181,27 +159,9 @@ describe("mapPrPaneData", () => {
|
|||||||
url: "https://example.com/1",
|
url: "https://example.com/1",
|
||||||
},
|
},
|
||||||
{ provider: "github", name: "failure", status: "failure", url: "https://example.com/2" },
|
{ provider: "github", name: "failure", status: "failure", url: "https://example.com/2" },
|
||||||
{
|
{ provider: "github", name: "pending", status: "pending", url: "https://example.com/3" },
|
||||||
provider: "github",
|
|
||||||
name: "approval",
|
|
||||||
status: "pending",
|
|
||||||
traits: ["action_required"],
|
|
||||||
url: "https://example.com/3",
|
|
||||||
},
|
|
||||||
{ provider: "github", name: "skipped", status: "skipped", url: "https://example.com/4" },
|
{ provider: "github", name: "skipped", status: "skipped", url: "https://example.com/4" },
|
||||||
{
|
{ provider: "github", name: "cancelled", status: "skipped", url: "https://example.com/5" },
|
||||||
provider: "github",
|
|
||||||
name: "cancelled",
|
|
||||||
status: "cancelled",
|
|
||||||
url: "https://example.com/5",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provider: "github",
|
|
||||||
name: "manual",
|
|
||||||
status: "skipped",
|
|
||||||
traits: ["manual"],
|
|
||||||
url: "https://example.com/6",
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -628,7 +588,7 @@ describe("mapPrPaneData", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps Forgejo branding and warning presentation for aggregate CI status", () => {
|
it("keeps Forgejo branding for aggregate Gitea-family CI status", () => {
|
||||||
const data = mapPrPaneData(
|
const data = mapPrPaneData(
|
||||||
status({
|
status({
|
||||||
forge: "forgejo",
|
forge: "forgejo",
|
||||||
@@ -638,7 +598,7 @@ describe("mapPrPaneData", () => {
|
|||||||
forge: "gitea",
|
forge: "gitea",
|
||||||
mergeable: true,
|
mergeable: true,
|
||||||
hasMerged: false,
|
hasMerged: false,
|
||||||
ciStatus: "warning",
|
ciStatus: "failure",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
baseTimeline,
|
baseTimeline,
|
||||||
@@ -651,7 +611,6 @@ describe("mapPrPaneData", () => {
|
|||||||
provider: "forgejo",
|
provider: "forgejo",
|
||||||
name: "CI",
|
name: "CI",
|
||||||
status: "failure",
|
status: "failure",
|
||||||
traits: ["warning"],
|
|
||||||
url: "https://forgejo.example.com/group/repo/pulls/7",
|
url: "https://forgejo.example.com/group/repo/pulls/7",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -708,48 +667,16 @@ describe("mapPipelineStatus", () => {
|
|||||||
expect(mapPipelineStatus("created")).toBe("pending");
|
expect(mapPipelineStatus("created")).toBe("pending");
|
||||||
expect(mapPipelineStatus("waiting_for_resource")).toBe("pending");
|
expect(mapPipelineStatus("waiting_for_resource")).toBe("pending");
|
||||||
expect(mapPipelineStatus("preparing")).toBe("pending");
|
expect(mapPipelineStatus("preparing")).toBe("pending");
|
||||||
expect(mapPipelineStatus("canceling")).toBe("pending");
|
|
||||||
expect(mapPipelineStatus("scheduled")).toBe("pending");
|
expect(mapPipelineStatus("scheduled")).toBe("pending");
|
||||||
expect(mapPipelineStatus("anything-else")).toBe("pending");
|
expect(mapPipelineStatus("anything-else")).toBe("pending");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("separates blocking outcomes from allowed failures and optional manual jobs", () => {
|
|
||||||
const job = (id: number, jobStatus: string, allowFailure: boolean): CheckoutPipelineJob => ({
|
|
||||||
id,
|
|
||||||
name: `job-${id}`,
|
|
||||||
stage: "test",
|
|
||||||
status: jobStatus,
|
|
||||||
rawStatus: jobStatus,
|
|
||||||
url: null,
|
|
||||||
allowFailure,
|
|
||||||
durationSeconds: null,
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
countGitlabPipelineJobs([
|
|
||||||
job(1, "success", false),
|
|
||||||
job(2, "failed", false),
|
|
||||||
job(3, "failed", true),
|
|
||||||
job(4, "pending", false),
|
|
||||||
job(5, "manual", true),
|
|
||||||
job(6, "manual", false),
|
|
||||||
]),
|
|
||||||
).toEqual({
|
|
||||||
success: 1,
|
|
||||||
failure: 1,
|
|
||||||
warning: 1,
|
|
||||||
actionRequired: 1,
|
|
||||||
manual: 1,
|
|
||||||
pending: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("marks running and queued pipeline statuses as live for polling", () => {
|
it("marks running and queued pipeline statuses as live for polling", () => {
|
||||||
expect(isPipelineActiveStatus("running")).toBe(true);
|
expect(isPipelineActiveStatus("running")).toBe(true);
|
||||||
expect(isPipelineActiveStatus("pending")).toBe(true);
|
expect(isPipelineActiveStatus("pending")).toBe(true);
|
||||||
expect(isPipelineActiveStatus("created")).toBe(true);
|
expect(isPipelineActiveStatus("created")).toBe(true);
|
||||||
expect(isPipelineActiveStatus("waiting_for_resource")).toBe(true);
|
expect(isPipelineActiveStatus("waiting_for_resource")).toBe(true);
|
||||||
expect(isPipelineActiveStatus("preparing")).toBe(true);
|
expect(isPipelineActiveStatus("preparing")).toBe(true);
|
||||||
expect(isPipelineActiveStatus("canceling")).toBe(true);
|
|
||||||
expect(isPipelineActiveStatus("scheduled")).toBe(true);
|
expect(isPipelineActiveStatus("scheduled")).toBe(true);
|
||||||
expect(isPipelineActiveStatus("success")).toBe(false);
|
expect(isPipelineActiveStatus("success")).toBe(false);
|
||||||
expect(isPipelineActiveStatus("failed")).toBe(false);
|
expect(isPipelineActiveStatus("failed")).toBe(false);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type {
|
|||||||
PullRequestTimelineResponse,
|
PullRequestTimelineResponse,
|
||||||
} from "@getpaseo/protocol/messages";
|
} from "@getpaseo/protocol/messages";
|
||||||
import { type Forge, getForgePresentation } from "@/git/forge";
|
import { type Forge, getForgePresentation } from "@/git/forge";
|
||||||
import type { PresentableCheck } from "@/git/check-presentation";
|
|
||||||
import { parseClientForgeFacts } from "@/git/forges";
|
import { parseClientForgeFacts } from "@/git/forges";
|
||||||
import type { ForgeSpecificStatusFacts } from "@/git/merge-capability";
|
import type { ForgeSpecificStatusFacts } from "@/git/merge-capability";
|
||||||
import { deriveIdentityColorName, identityColor } from "@/styles/identity-colors";
|
import { deriveIdentityColorName, identityColor } from "@/styles/identity-colors";
|
||||||
@@ -23,7 +22,7 @@ export interface PullRequestProviderMetadata {
|
|||||||
url?: string | null;
|
url?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrPaneCheck extends PresentableCheck {
|
export interface PrPaneCheck {
|
||||||
provider: PullRequestProvider;
|
provider: PullRequestProvider;
|
||||||
name: string;
|
name: string;
|
||||||
workflow?: string;
|
workflow?: string;
|
||||||
@@ -33,8 +32,6 @@ export interface PrPaneCheck extends PresentableCheck {
|
|||||||
* running one has been going ("running 2m"). Absent when the forge reports neither.
|
* running one has been going ("running 2m"). Absent when the forge reports neither.
|
||||||
*/
|
*/
|
||||||
timing?: string;
|
timing?: string;
|
||||||
isManual?: boolean;
|
|
||||||
requiresAction?: boolean;
|
|
||||||
url: string;
|
url: string;
|
||||||
/**
|
/**
|
||||||
* Forge-neutral reference for fetching this check's detail/logs on demand. Any
|
* Forge-neutral reference for fetching this check's detail/logs on demand. Any
|
||||||
@@ -204,7 +201,7 @@ function derivePrState(status: NonNullable<CheckoutPrStatus>): PrState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapChecks(status: NonNullable<CheckoutPrStatus>, forge: Forge): PrPaneCheck[] {
|
function mapChecks(status: NonNullable<CheckoutPrStatus>, forge: Forge): PrPaneCheck[] {
|
||||||
const checks = (status.checks ?? []).map((check) => mapCheck(check, forge, status.url));
|
const checks = (status.checks ?? []).flatMap((check) => mapCheck(check, forge));
|
||||||
if (checks.length > 0) {
|
if (checks.length > 0) {
|
||||||
return checks;
|
return checks;
|
||||||
}
|
}
|
||||||
@@ -214,27 +211,31 @@ function mapChecks(status: NonNullable<CheckoutPrStatus>, forge: Forge): PrPaneC
|
|||||||
function mapCheck(
|
function mapCheck(
|
||||||
check: NonNullable<CheckoutPrStatus>["checks"][number],
|
check: NonNullable<CheckoutPrStatus>["checks"][number],
|
||||||
forge: Forge,
|
forge: Forge,
|
||||||
fallbackUrl: string,
|
): PrPaneCheck[] {
|
||||||
): PrPaneCheck {
|
if (check.url === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const status = mapCheckStatus(check.status);
|
const status = mapCheckStatus(check.status);
|
||||||
const timing = formatCheckTiming(check.duration, status);
|
const timing = formatCheckTiming(check.duration, status);
|
||||||
return {
|
return [
|
||||||
provider: forge,
|
{
|
||||||
name: check.name,
|
provider: forge,
|
||||||
status,
|
name: check.name,
|
||||||
...(check.traits ? { traits: check.traits } : {}),
|
status,
|
||||||
url: check.url ?? fallbackUrl,
|
url: check.url,
|
||||||
...(check.workflow ? { workflow: check.workflow } : {}),
|
...(check.workflow ? { workflow: check.workflow } : {}),
|
||||||
...(timing ? { timing } : {}),
|
...(timing ? { timing } : {}),
|
||||||
...(check.checkRunId !== undefined || check.workflowRunId !== undefined
|
...(check.checkRunId !== undefined || check.workflowRunId !== undefined
|
||||||
? {
|
? {
|
||||||
detailRef: {
|
detailRef: {
|
||||||
...(check.checkRunId !== undefined ? { checkRunId: check.checkRunId } : {}),
|
...(check.checkRunId !== undefined ? { checkRunId: check.checkRunId } : {}),
|
||||||
...(check.workflowRunId !== undefined ? { workflowRunId: check.workflowRunId } : {}),
|
...(check.workflowRunId !== undefined ? { workflowRunId: check.workflowRunId } : {}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
};
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -135,7 +135,6 @@ describe("getNativeFallbackChecks", () => {
|
|||||||
provider: "gitea",
|
provider: "gitea",
|
||||||
name: "CI",
|
name: "CI",
|
||||||
status: "failure",
|
status: "failure",
|
||||||
traits: ["warning"],
|
|
||||||
url: "https://gitea.com/group/repo/pulls/7",
|
url: "https://gitea.com/group/repo/pulls/7",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ import { ChecksSection, getCheckIdentity } from "./checks-section";
|
|||||||
import { getActivityVerb, getStateLabel } from "./data";
|
import { getActivityVerb, getStateLabel } from "./data";
|
||||||
import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data";
|
import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data";
|
||||||
import type { ForgeSpecificStatusFacts } from "@/git/merge-capability";
|
import type { ForgeSpecificStatusFacts } from "@/git/merge-capability";
|
||||||
import { CheckPresentationIcon } from "@/git/check-presentation.view";
|
|
||||||
import {
|
import {
|
||||||
buildPrTimeline,
|
buildPrTimeline,
|
||||||
type PrReviewEntry,
|
type PrReviewEntry,
|
||||||
@@ -75,6 +74,8 @@ import {
|
|||||||
} from "./timeline";
|
} from "./timeline";
|
||||||
import {
|
import {
|
||||||
Section,
|
Section,
|
||||||
|
SUMMARY_DANGER_ICON,
|
||||||
|
SUMMARY_SUCCESS_ICON,
|
||||||
SummaryPill,
|
SummaryPill,
|
||||||
dangerColorMapping,
|
dangerColorMapping,
|
||||||
foregroundMutedColorMapping,
|
foregroundMutedColorMapping,
|
||||||
@@ -128,8 +129,6 @@ const PR_STATE_PRESENTATION: Record<PrState, PrStatePresentation> = {
|
|||||||
const SUMMARY_COMMENT_ICON = (
|
const SUMMARY_COMMENT_ICON = (
|
||||||
<ThemedMessageSquare size={11} uniProps={foregroundMutedColorMapping} />
|
<ThemedMessageSquare size={11} uniProps={foregroundMutedColorMapping} />
|
||||||
);
|
);
|
||||||
const SUMMARY_APPROVAL_ICON = <CheckPresentationIcon presentation="success" size={12} />;
|
|
||||||
const SUMMARY_CHANGES_REQUESTED_ICON = <CheckPresentationIcon presentation="failure" size={12} />;
|
|
||||||
const ADD_TO_CHAT_MENU_ICON = (
|
const ADD_TO_CHAT_MENU_ICON = (
|
||||||
<ThemedMessageSquarePlus size={14} uniProps={foregroundMutedColorMapping} />
|
<ThemedMessageSquarePlus size={14} uniProps={foregroundMutedColorMapping} />
|
||||||
);
|
);
|
||||||
@@ -206,9 +205,8 @@ export function PullRequestPane({
|
|||||||
);
|
);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const daemonClient = useHostRuntimeClient(serverId);
|
const daemonClient = useHostRuntimeClient(serverId);
|
||||||
// COMPAT(githubCheckDetailsRpc): recognize the legacy capability on daemons
|
// COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once
|
||||||
// predating checkout.forge.get_check_details.*. Remove after 2027-01-17 once
|
// all supported clients use checkout.forge.get_check_details.*.
|
||||||
// the supported daemon floor is >= v0.2.0.
|
|
||||||
const canFetchGitHubCheckDetails = useSessionStore(
|
const canFetchGitHubCheckDetails = useSessionStore(
|
||||||
(state) => state.sessions[serverId]?.serverInfo?.features?.githubCheckDetails === true,
|
(state) => state.sessions[serverId]?.serverInfo?.features?.githubCheckDetails === true,
|
||||||
);
|
);
|
||||||
@@ -397,9 +395,8 @@ export function PullRequestPane({
|
|||||||
workflowRunId: ref.workflowRunId,
|
workflowRunId: ref.workflowRunId,
|
||||||
changeRequestNumber: data.number,
|
changeRequestNumber: data.number,
|
||||||
};
|
};
|
||||||
// COMPAT(githubCheckDetailsRpc): use the legacy GitHub RPC with
|
// COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once
|
||||||
// daemons predating checkout.forge.get_check_details.*. Remove after
|
// all supported clients use checkout.forge.get_check_details.*.
|
||||||
// 2027-01-17 once the supported daemon floor is >= v0.2.0.
|
|
||||||
const payload = canFetchForgeCheckDetails
|
const payload = canFetchForgeCheckDetails
|
||||||
? await daemonClient.checkoutForgeGetCheckDetails(request)
|
? await daemonClient.checkoutForgeGetCheckDetails(request)
|
||||||
: await daemonClient.checkoutGithubGetCheckDetails(request);
|
: await daemonClient.checkoutGithubGetCheckDetails(request);
|
||||||
@@ -573,12 +570,8 @@ export function PullRequestPane({
|
|||||||
onToggle={handleToggleActivity}
|
onToggle={handleToggleActivity}
|
||||||
summary={
|
summary={
|
||||||
<>
|
<>
|
||||||
<SummaryPill count={approvals} icon={SUMMARY_APPROVAL_ICON} variant="success" />
|
<SummaryPill count={approvals} icon={SUMMARY_SUCCESS_ICON} variant="success" />
|
||||||
<SummaryPill
|
<SummaryPill count={changesRequested} icon={SUMMARY_DANGER_ICON} variant="danger" />
|
||||||
count={changesRequested}
|
|
||||||
icon={SUMMARY_CHANGES_REQUESTED_ICON}
|
|
||||||
variant="danger"
|
|
||||||
/>
|
|
||||||
<SummaryPill count={commentCount} icon={SUMMARY_COMMENT_ICON} variant="muted" />
|
<SummaryPill count={commentCount} icon={SUMMARY_COMMENT_ICON} variant="muted" />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import React, { type ReactNode } from "react";
|
import React, { type ReactNode } from "react";
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import type { CountedCheckPresentation } from "@/git/check-presentation";
|
|
||||||
import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry";
|
|
||||||
import {
|
import {
|
||||||
CheckPresentationIcon,
|
ChevronDown,
|
||||||
getCheckPresentationTone,
|
ChevronRight,
|
||||||
type CheckPresentationTone,
|
CircleCheck,
|
||||||
} from "@/git/check-presentation.view";
|
CircleDot,
|
||||||
import { ChevronDown, ChevronRight } from "lucide-react-native";
|
CircleSlash,
|
||||||
|
CircleX,
|
||||||
|
} from "lucide-react-native";
|
||||||
|
import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
|
import type { CheckStatus } from "./check-status";
|
||||||
|
|
||||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||||
|
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||||
|
const ThemedCircleDot = withUnistyles(CircleDot);
|
||||||
|
const ThemedCircleSlash = withUnistyles(CircleSlash);
|
||||||
|
const ThemedCircleX = withUnistyles(CircleX);
|
||||||
|
|
||||||
export const foregroundMutedColorMapping = (theme: Theme) => ({
|
export const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
@@ -21,30 +27,22 @@ export const successColorMapping = (theme: Theme) => ({ color: theme.colors.stat
|
|||||||
export const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
export const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
||||||
export const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
|
export const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
|
||||||
|
|
||||||
|
export const SUMMARY_SUCCESS_ICON = <ThemedCircleCheck size={12} uniProps={successColorMapping} />;
|
||||||
|
export const SUMMARY_DANGER_ICON = <ThemedCircleX size={12} uniProps={dangerColorMapping} />;
|
||||||
|
export const SUMMARY_WARNING_ICON = <ThemedCircleDot size={12} uniProps={warningColorMapping} />;
|
||||||
|
|
||||||
interface SectionProps {
|
interface SectionProps {
|
||||||
title: string;
|
title: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
summary: ReactNode;
|
summary: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
accessibilityLabel?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Section({
|
export function Section({ title, open, onToggle, summary, children }: SectionProps) {
|
||||||
title,
|
|
||||||
open,
|
|
||||||
onToggle,
|
|
||||||
summary,
|
|
||||||
children,
|
|
||||||
accessibilityLabel,
|
|
||||||
}: SectionProps) {
|
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
<Pressable
|
<Pressable style={sectionKitStyles.sectionHeader} onPress={onToggle}>
|
||||||
accessibilityLabel={accessibilityLabel}
|
|
||||||
style={sectionKitStyles.sectionHeader}
|
|
||||||
onPress={onToggle}
|
|
||||||
>
|
|
||||||
{open ? (
|
{open ? (
|
||||||
<ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />
|
<ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />
|
||||||
) : (
|
) : (
|
||||||
@@ -58,7 +56,7 @@ export function Section({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SummaryPillVariant = CheckPresentationTone;
|
export type SummaryPillVariant = "success" | "danger" | "warning" | "muted";
|
||||||
|
|
||||||
export function SummaryPill({
|
export function SummaryPill({
|
||||||
count,
|
count,
|
||||||
@@ -87,22 +85,11 @@ function summaryPillTextStyle(variant: SummaryPillVariant) {
|
|||||||
return sectionKitStyles.summaryPillMutedText;
|
return sectionKitStyles.summaryPillMutedText;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CheckPresentationSummaryPill({
|
export function CheckStatusIcon({ status }: { status: CheckStatus }) {
|
||||||
count,
|
if (status === "success") return <ThemedCircleCheck size={14} uniProps={successColorMapping} />;
|
||||||
presentation,
|
if (status === "failure") return <ThemedCircleX size={14} uniProps={dangerColorMapping} />;
|
||||||
testID,
|
if (status === "pending") return <ThemedCircleDot size={14} uniProps={warningColorMapping} />;
|
||||||
}: {
|
return <ThemedCircleSlash size={14} uniProps={foregroundMutedColorMapping} />;
|
||||||
count: number;
|
|
||||||
presentation: CountedCheckPresentation;
|
|
||||||
testID?: string;
|
|
||||||
}) {
|
|
||||||
if (count === 0) return null;
|
|
||||||
return (
|
|
||||||
<View style={sectionKitStyles.summaryPill} testID={testID}>
|
|
||||||
<CheckPresentationIcon presentation={presentation} size={12} />
|
|
||||||
<Text style={summaryPillTextStyle(getCheckPresentationTone(presentation))}>{count}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sectionKitStyles = StyleSheet.create((theme) => ({
|
export const sectionKitStyles = StyleSheet.create((theme) => ({
|
||||||
|
|||||||
@@ -95,9 +95,8 @@ export function buildForgeSearchQueryOptions(input: ForgeSearchQueryInput) {
|
|||||||
const request = input.kinds
|
const request = input.kinds
|
||||||
? { cwd: input.cwd, query, limit: 20, kinds: input.kinds }
|
? { cwd: input.cwd, query, limit: 20, kinds: input.kinds }
|
||||||
: { cwd: input.cwd, query, limit: 20 };
|
: { cwd: input.cwd, query, limit: 20 };
|
||||||
// COMPAT(githubSearchRpc): use the legacy GitHub RPC with daemons that
|
// COMPAT(githubSearchRpc): added in v0.1.106, remove after 2026-12-28 once
|
||||||
// predate forge.search.*. Remove after 2027-01-17 once the supported
|
// clients use forge.search.*.
|
||||||
// daemon floor is >= v0.2.0.
|
|
||||||
if (transport === "github" && input.client.searchGitHub) {
|
if (transport === "github" && input.client.searchGitHub) {
|
||||||
return normalizeLegacyGitHubSearchPayload(
|
return normalizeLegacyGitHubSearchPayload(
|
||||||
await input.client.searchGitHub(toLegacyGitHubSearchRequest(request)),
|
await input.client.searchGitHub(toLegacyGitHubSearchRequest(request)),
|
||||||
@@ -126,8 +125,7 @@ function normalizeForgeSearchPayload(payload: ForgeSearchResponse["payload"]): F
|
|||||||
function normalizeLegacyGitHubSearchPayload(
|
function normalizeLegacyGitHubSearchPayload(
|
||||||
payload: GitHubSearchResponse["payload"],
|
payload: GitHubSearchResponse["payload"],
|
||||||
): ForgeSearchPayload {
|
): ForgeSearchPayload {
|
||||||
// COMPAT(githubSearchAuthState): normalize the legacy GitHub search response
|
// COMPAT(githubSearchAuthState): added in v0.1.106, remove after 2026-12-28.
|
||||||
// until 2027-01-17, when the supported daemon floor is >= v0.2.0.
|
|
||||||
const featuresEnabled = payload.featuresEnabled ?? payload.githubFeaturesEnabled ?? true;
|
const featuresEnabled = payload.featuresEnabled ?? payload.githubFeaturesEnabled ?? true;
|
||||||
return {
|
return {
|
||||||
items: payload.items.flatMap((item) => {
|
items: payload.items.flatMap((item) => {
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
advanceTextReveal,
|
|
||||||
beginTextReveal,
|
|
||||||
completeTextReveal,
|
|
||||||
isTextRevealPacingSupported,
|
|
||||||
isTextRevealSettled,
|
|
||||||
nextTextRevealFrame,
|
|
||||||
retargetTextReveal,
|
|
||||||
type TextRevealState,
|
|
||||||
visibleRevealedText,
|
|
||||||
} from "@/agent-stream/text-reveal";
|
|
||||||
import type { MarkdownPhase } from "@/components/markdown/fence/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Binds the paced reveal in @/agent-stream/text-reveal to a frame clock.
|
|
||||||
*
|
|
||||||
* All of the policy — what is revealed when, and where it is safe to cut — lives
|
|
||||||
* in that module and is tested there. This hook only owns the requestAnimationFrame
|
|
||||||
* wiring, so the rendered behavior is covered end to end by
|
|
||||||
* `packages/app/e2e/browser/agent-stream-smoothness.spec.ts`.
|
|
||||||
*/
|
|
||||||
export function useRevealedText(text: string, phase: MarkdownPhase): string {
|
|
||||||
const pacingSupported = isTextRevealPacingSupported();
|
|
||||||
const stateRef = useRef<TextRevealState>(beginTextReveal(text));
|
|
||||||
const [, forceRender] = useState(0);
|
|
||||||
const frameRef = useRef<number | null>(null);
|
|
||||||
const lastFrameAtRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
stateRef.current = retargetTextReveal(stateRef.current, text);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const settle = () => {
|
|
||||||
lastFrameAtRef.current = null;
|
|
||||||
const next = completeTextReveal(stateRef.current);
|
|
||||||
if (next !== stateRef.current) {
|
|
||||||
stateRef.current = next;
|
|
||||||
forceRender((tick) => tick + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!pacingSupported || phase !== "streaming") {
|
|
||||||
settle();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isTextRevealSettled(stateRef.current)) {
|
|
||||||
lastFrameAtRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof requestAnimationFrame !== "function") {
|
|
||||||
settle();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tick = (timestamp: number) => {
|
|
||||||
frameRef.current = null;
|
|
||||||
const frame = nextTextRevealFrame(lastFrameAtRef.current, timestamp);
|
|
||||||
if (!frame) {
|
|
||||||
frameRef.current = requestAnimationFrame(tick);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastFrameAtRef.current = frame.frameAtMs;
|
|
||||||
|
|
||||||
const next = advanceTextReveal(stateRef.current, frame.elapsedMs);
|
|
||||||
if (next !== stateRef.current) {
|
|
||||||
stateRef.current = next;
|
|
||||||
forceRender((count) => count + 1);
|
|
||||||
}
|
|
||||||
if (!isTextRevealSettled(stateRef.current)) {
|
|
||||||
frameRef.current = requestAnimationFrame(tick);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
frameRef.current = requestAnimationFrame(tick);
|
|
||||||
return () => {
|
|
||||||
if (frameRef.current !== null) {
|
|
||||||
cancelAnimationFrame(frameRef.current);
|
|
||||||
frameRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [text, pacingSupported, phase]);
|
|
||||||
|
|
||||||
return pacingSupported
|
|
||||||
? visibleRevealedText(stateRef.current, { streaming: phase === "streaming" })
|
|
||||||
: text;
|
|
||||||
}
|
|
||||||
@@ -5,9 +5,9 @@ export const LEGACY_SETTINGS_KEY = "@paseo:settings";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The applied-migration marker deliberately sits outside the settings blob:
|
* The applied-migration marker deliberately sits outside the settings blob:
|
||||||
* `StoredAppSettingsSchema` is a strict object and `readValidatedJson` clears the value when
|
* App settings preserve fields newer builds understand, but this marker remains separate so
|
||||||
* parsing fails, so a new field there would wipe every setting for anyone who downgrades to a
|
* applying a migration never becomes an app-settings field that older builds must interpret.
|
||||||
* client that predates it. Keeping the marker as a list of ids also means the next migration is
|
* Keeping the marker as a list of ids also means the next migration is a new entry rather than
|
||||||
* a new entry rather than a schema change.
|
* a schema change.
|
||||||
*/
|
*/
|
||||||
export const SETTINGS_MIGRATIONS_KEY = "@paseo:settings-migrations";
|
export const SETTINGS_MIGRATIONS_KEY = "@paseo:settings-migrations";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { readValidatedJson } from "@/storage/validated-storage";
|
import { readValidatedJson } from "@/storage/validated-storage";
|
||||||
import { APP_SETTINGS_KEY, SETTINGS_MIGRATIONS_KEY } from "./keys";
|
import { APP_SETTINGS_KEY, SETTINGS_MIGRATIONS_KEY } from "./keys";
|
||||||
import type { AppSettings, KeyValueStorage } from "./storage";
|
import type { AppSettings, KeyValueStorage, PersistedAppSettings } from "./storage";
|
||||||
|
|
||||||
const AppliedMigrationsSchema = z.strictObject({ applied: z.array(z.string()) });
|
const AppliedMigrationsSchema = z.strictObject({ applied: z.array(z.string()) });
|
||||||
|
|
||||||
@@ -22,9 +22,14 @@ const STEER_DEFAULT_MIGRATION = "steer-default";
|
|||||||
export async function migrateAppSettings(
|
export async function migrateAppSettings(
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
storage: KeyValueStorage,
|
storage: KeyValueStorage,
|
||||||
|
stored?: PersistedAppSettings,
|
||||||
): Promise<AppSettings> {
|
): Promise<AppSettings> {
|
||||||
const stored = await readValidatedJson(storage, SETTINGS_MIGRATIONS_KEY, AppliedMigrationsSchema);
|
const migrationMarker = await readValidatedJson(
|
||||||
const applied = new Set(stored?.applied ?? []);
|
storage,
|
||||||
|
SETTINGS_MIGRATIONS_KEY,
|
||||||
|
AppliedMigrationsSchema,
|
||||||
|
);
|
||||||
|
const applied = new Set(migrationMarker?.applied ?? []);
|
||||||
if (applied.has(STEER_DEFAULT_MIGRATION)) {
|
if (applied.has(STEER_DEFAULT_MIGRATION)) {
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
@@ -32,7 +37,15 @@ export async function migrateAppSettings(
|
|||||||
const migrated: AppSettings =
|
const migrated: AppSettings =
|
||||||
settings.sendBehavior === "interrupt" ? { ...settings, sendBehavior: "steer" } : settings;
|
settings.sendBehavior === "interrupt" ? { ...settings, sendBehavior: "steer" } : settings;
|
||||||
if (migrated !== settings) {
|
if (migrated !== settings) {
|
||||||
await storage.setItem(APP_SETTINGS_KEY, JSON.stringify(migrated));
|
const storedSidebarRowItems = stored?.sidebarRowItems ?? {};
|
||||||
|
await storage.setItem(
|
||||||
|
APP_SETTINGS_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
...stored,
|
||||||
|
...migrated,
|
||||||
|
sidebarRowItems: { ...storedSidebarRowItems, ...migrated.sidebarRowItems },
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
applied.add(STEER_DEFAULT_MIGRATION);
|
applied.add(STEER_DEFAULT_MIGRATION);
|
||||||
|
|||||||
@@ -50,6 +50,28 @@ describe("loadAppSettingsFromStorage", () => {
|
|||||||
});
|
});
|
||||||
expect((await loadAppSettingsFromStorage(deps)).sendBehavior).toBe("steer");
|
expect((await loadAppSettingsFromStorage(deps)).sendBehavior).toBe("steer");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps valid settings when another build wrote unknown fields or enum values", async () => {
|
||||||
|
const stored = {
|
||||||
|
theme: "dark",
|
||||||
|
contentFontSize: 16,
|
||||||
|
sendBehavior: "future-mode",
|
||||||
|
futureSetting: { enabled: true },
|
||||||
|
sidebarRowItems: { host: false, futureRowItem: true },
|
||||||
|
};
|
||||||
|
const deps = makeDeps({
|
||||||
|
storage: createInMemoryKeyValueStorage({
|
||||||
|
[APP_SETTINGS_KEY]: JSON.stringify(stored),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await loadAppSettingsFromStorage(deps);
|
||||||
|
|
||||||
|
expect(result.theme).toBe("dark");
|
||||||
|
expect(result.sendBehavior).toBe(DEFAULT_CLIENT_SETTINGS.sendBehavior);
|
||||||
|
expect(result.sidebarRowItems.host).toBe(false);
|
||||||
|
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual(stored);
|
||||||
|
});
|
||||||
it("migrates a stored interrupt to steer and persists it", async () => {
|
it("migrates a stored interrupt to steer and persists it", async () => {
|
||||||
const deps = makeDeps({
|
const deps = makeDeps({
|
||||||
storage: createInMemoryKeyValueStorage({
|
storage: createInMemoryKeyValueStorage({
|
||||||
@@ -63,6 +85,32 @@ describe("loadAppSettingsFromStorage", () => {
|
|||||||
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "{}").sendBehavior).toBe(
|
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "{}").sendBehavior).toBe(
|
||||||
"steer",
|
"steer",
|
||||||
);
|
);
|
||||||
|
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "{}")).not.toHaveProperty(
|
||||||
|
"needsWrite",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an explicit services choice over the legacy scripts fallback", async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
storage: createInMemoryKeyValueStorage({
|
||||||
|
[APP_SETTINGS_KEY]: JSON.stringify({
|
||||||
|
contentFontSize: 16,
|
||||||
|
sidebarRowItems: { scripts: false, services: true },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await loadAppSettingsFromStorage(deps)).sidebarRowItems.services).toBe(true);
|
||||||
|
|
||||||
|
await saveAppSettings({
|
||||||
|
queryClient: new QueryClient(),
|
||||||
|
updates: {
|
||||||
|
sidebarRowItems: { ...DEFAULT_SIDEBAR_ROW_ITEMS, services: true },
|
||||||
|
},
|
||||||
|
deps,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await loadAppSettingsFromStorage(deps)).sidebarRowItems.services).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps an interrupt the user picked after the migration ran", async () => {
|
it("keeps an interrupt the user picked after the migration ran", async () => {
|
||||||
@@ -108,8 +156,8 @@ describe("loadAppSettingsFromStorage", () => {
|
|||||||
|
|
||||||
expect(result).toEqual(DEFAULT_CLIENT_SETTINGS);
|
expect(result).toEqual(DEFAULT_CLIENT_SETTINGS);
|
||||||
expect(DEFAULT_CLIENT_SETTINGS.language).toBe("system");
|
expect(DEFAULT_CLIENT_SETTINGS.language).toBe("system");
|
||||||
expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe(
|
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual(
|
||||||
JSON.stringify(DEFAULT_CLIENT_SETTINGS),
|
DEFAULT_CLIENT_SETTINGS,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -235,7 +283,11 @@ describe("loadAppSettingsFromStorage", () => {
|
|||||||
theme: "dark",
|
theme: "dark",
|
||||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||||
});
|
});
|
||||||
expect(deps.storage.entries.get(APP_SETTINGS_KEY)).toBe(JSON.stringify(result));
|
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual({
|
||||||
|
manageBuiltInDaemon: false,
|
||||||
|
releaseChannel: "beta",
|
||||||
|
...result,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves the legacy key's explicit interface size as content size", async () => {
|
it("preserves the legacy key's explicit interface size as content size", async () => {
|
||||||
@@ -400,6 +452,36 @@ describe("loadSettingsFromStorage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("saveAppSettings", () => {
|
describe("saveAppSettings", () => {
|
||||||
|
it("round-trips fields written by a newer build", async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
storage: createInMemoryKeyValueStorage({
|
||||||
|
[APP_SETTINGS_KEY]: JSON.stringify({
|
||||||
|
theme: "dark",
|
||||||
|
contentFontSize: 16,
|
||||||
|
sendBehavior: "future-mode",
|
||||||
|
futureSetting: { enabled: true },
|
||||||
|
sidebarRowItems: { host: false, futureRowItem: true },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadAppSettingsFromStorage(deps);
|
||||||
|
await saveAppSettings({
|
||||||
|
queryClient: new QueryClient(),
|
||||||
|
updates: { theme: "light" },
|
||||||
|
deps,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toMatchObject({
|
||||||
|
theme: "light",
|
||||||
|
futureSetting: { enabled: true },
|
||||||
|
sidebarRowItems: {
|
||||||
|
host: false,
|
||||||
|
futureRowItem: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("saves terminal scrollback through app settings persistence", async () => {
|
it("saves terminal scrollback through app settings persistence", async () => {
|
||||||
const deps = makeDeps({
|
const deps = makeDeps({
|
||||||
storage: createInMemoryKeyValueStorage({
|
storage: createInMemoryKeyValueStorage({
|
||||||
@@ -609,7 +691,7 @@ describe("appearance settings", () => {
|
|||||||
|
|
||||||
expect(result.uiBaseFontSize).toBe(baseSize);
|
expect(result.uiBaseFontSize).toBe(baseSize);
|
||||||
expect(persisted).toMatchObject({ uiBaseFontSize: baseSize });
|
expect(persisted).toMatchObject({ uiBaseFontSize: baseSize });
|
||||||
expect(persisted).not.toHaveProperty("uiFontSize");
|
expect(persisted.uiFontSize).toBe(legacySize);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -623,6 +705,20 @@ describe("appearance settings", () => {
|
|||||||
expect((await loadAppSettingsFromStorage(deps)).uiBaseFontSize).toBe(16);
|
expect((await loadAppSettingsFromStorage(deps)).uiBaseFontSize).toBe(16);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to a valid legacy interface scale when the explicit base size is invalid", async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
storage: createInMemoryKeyValueStorage({
|
||||||
|
[APP_SETTINGS_KEY]: JSON.stringify({ uiBaseFontSize: "abc", uiFontSize: 17 }),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await loadAppSettingsFromStorage(deps);
|
||||||
|
const persisted = JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null");
|
||||||
|
|
||||||
|
expect(result.uiBaseFontSize).toBe(15);
|
||||||
|
expect(persisted).toMatchObject({ uiBaseFontSize: 15, uiFontSize: 17 });
|
||||||
|
});
|
||||||
|
|
||||||
it("clamps the UI base font size into range and rejects non-numeric values", async () => {
|
it("clamps the UI base font size into range and rejects non-numeric values", async () => {
|
||||||
const deps = makeDeps({
|
const deps = makeDeps({
|
||||||
storage: createInMemoryKeyValueStorage({
|
storage: createInMemoryKeyValueStorage({
|
||||||
|
|||||||
@@ -2,16 +2,14 @@ import { isSyntaxThemeId, type SyntaxThemeId } from "@getpaseo/highlight";
|
|||||||
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
|
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
|
||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
|
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||||
import { parseAppLanguage, type AppLanguage } from "@/i18n/locales";
|
import type { AppLanguage } from "@/i18n/locales";
|
||||||
import {
|
import {
|
||||||
DEFAULT_SIDEBAR_CHECKS_DISPLAY,
|
DEFAULT_SIDEBAR_CHECKS_DISPLAY,
|
||||||
parseSidebarChecksDisplay,
|
|
||||||
type SidebarChecksDisplay,
|
type SidebarChecksDisplay,
|
||||||
} from "@/components/sidebar/display-preferences/checks-display";
|
} from "@/components/sidebar/display-preferences/checks-display";
|
||||||
import {
|
import {
|
||||||
DEFAULT_SIDEBAR_ROW_ITEMS,
|
DEFAULT_SIDEBAR_ROW_ITEMS,
|
||||||
isChecksHiddenByLegacyRowItem,
|
isChecksHiddenByLegacyRowItem,
|
||||||
parseSidebarRowItems,
|
|
||||||
type SidebarRowItems,
|
type SidebarRowItems,
|
||||||
} from "@/components/sidebar/display-preferences/row-items";
|
} from "@/components/sidebar/display-preferences/row-items";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
@@ -22,7 +20,6 @@ import {
|
|||||||
type ThemePreference,
|
type ThemePreference,
|
||||||
} from "@/styles/theme";
|
} from "@/styles/theme";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { readValidatedJson } from "@/storage/validated-storage";
|
|
||||||
import { APP_SETTINGS_KEY, LEGACY_SETTINGS_KEY } from "./keys";
|
import { APP_SETTINGS_KEY, LEGACY_SETTINGS_KEY } from "./keys";
|
||||||
import { migrateAppSettings } from "./migrations";
|
import { migrateAppSettings } from "./migrations";
|
||||||
|
|
||||||
@@ -41,17 +38,8 @@ const ThemePreferenceSchema = z.enum([
|
|||||||
...THEME_OPTIONS.map((option) => option.name),
|
...THEME_OPTIONS.map((option) => option.name),
|
||||||
PLUGIN_THEME_PREFERENCE,
|
PLUGIN_THEME_PREFERENCE,
|
||||||
]);
|
]);
|
||||||
const VALID_THEMES = new Set<string>(ThemePreferenceSchema.options);
|
|
||||||
/** Where the theme picker lands when the persisted preference cannot be honoured. */
|
/** Where the theme picker lands when the persisted preference cannot be honoured. */
|
||||||
export const DEFAULT_THEME_PREFERENCE = "auto" satisfies ThemePreference;
|
export const DEFAULT_THEME_PREFERENCE = "auto" satisfies ThemePreference;
|
||||||
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
|
|
||||||
const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]);
|
|
||||||
const VALID_SIDEBAR_WORKSPACE_TRAILINGS = new Set<SidebarWorkspaceTrailing>([
|
|
||||||
"diff",
|
|
||||||
"timestamp",
|
|
||||||
"none",
|
|
||||||
]);
|
|
||||||
const VALID_TOOL_CALL_DETAIL_LEVELS = new Set<ToolCallDetailLevel>(["overview", "detailed"]);
|
|
||||||
export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000;
|
export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000;
|
||||||
export const MIN_TERMINAL_SCROLLBACK_LINES = 0;
|
export const MIN_TERMINAL_SCROLLBACK_LINES = 0;
|
||||||
export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000;
|
export const MAX_TERMINAL_SCROLLBACK_LINES = 1_000_000;
|
||||||
@@ -106,58 +94,6 @@ export interface Settings extends AppSettings {
|
|||||||
releaseChannel: ReleaseChannel;
|
releaseChannel: ReleaseChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strict, so every item in SIDEBAR_ROW_ITEMS needs a key here the day it is added: the whole
|
|
||||||
// settings write is one validation, and one unknown key silently loses every other toggle in it.
|
|
||||||
// `checks` and `scripts` are gone from the item list and stay here for the COMPAT reads in
|
|
||||||
// row-items.ts.
|
|
||||||
const SidebarRowItemsSchema = z.strictObject({
|
|
||||||
branch: z.boolean().optional(),
|
|
||||||
project: z.boolean().optional(),
|
|
||||||
host: z.boolean().optional(),
|
|
||||||
changeRequest: z.boolean().optional(),
|
|
||||||
services: z.boolean().optional(),
|
|
||||||
labels: z.boolean().optional(),
|
|
||||||
checks: z.boolean().optional(),
|
|
||||||
scripts: z.boolean().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const StoredAppSettingsSchema = z.strictObject({
|
|
||||||
theme: ThemePreferenceSchema.optional(),
|
|
||||||
pluginThemeId: z.string().nullish(),
|
|
||||||
language: z
|
|
||||||
.enum(["system", "ar", "en", "es", "fr", "ja", "ko", "pt-BR", "ru", "zh-CN"])
|
|
||||||
.optional(),
|
|
||||||
sendBehavior: z.enum(["interrupt", "steer", "queue"]).optional(),
|
|
||||||
serviceUrlBehavior: z.enum(["ask", "in-app", "external"]).optional(),
|
|
||||||
terminalScrollbackLines: z.union([z.number(), z.string()]).optional(),
|
|
||||||
useLegacyTerminalRenderer: z.boolean().optional(),
|
|
||||||
uiFontFamily: z.string().optional(),
|
|
||||||
monoFontFamily: z.string().optional(),
|
|
||||||
uiBaseFontSize: z.union([z.number(), z.string()]).optional(),
|
|
||||||
contentFontSize: z.union([z.number(), z.string()]).optional(),
|
|
||||||
// COMPAT(uiFontSizeScale): replaced by the literal base size in v0.4, remove after 2027-08-17.
|
|
||||||
uiFontSize: z.union([z.number(), z.string()]).optional(),
|
|
||||||
codeFontSize: z.union([z.number(), z.string()]).optional(),
|
|
||||||
syntaxTheme: z.string().refine(isSyntaxThemeId).optional(),
|
|
||||||
workspaceTitleSource: z.enum(["title", "branch"]).optional(),
|
|
||||||
sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).optional(),
|
|
||||||
sidebarRowItems: SidebarRowItemsSchema.optional(),
|
|
||||||
sidebarChecksDisplay: z.enum(["iconAndText", "icon", "none"]).optional(),
|
|
||||||
autoExpandReasoning: z.boolean().optional(),
|
|
||||||
toolCallDetailLevel: z.enum(["overview", "detailed"]).optional(),
|
|
||||||
compactToolCalls: z.boolean().optional(),
|
|
||||||
chatOutlineEnabled: z.boolean().optional(),
|
|
||||||
vimKeybindings: z.boolean().optional(),
|
|
||||||
openSupportingTabsInSidePanel: z.boolean().optional(),
|
|
||||||
// COMPAT(rendererDesktopSettings): these fields used to share this renderer-owned key.
|
|
||||||
manageBuiltInDaemon: z.boolean().optional(),
|
|
||||||
releaseChannel: z.enum(["stable", "beta"]).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const LegacyRendererSettingsSchema = StoredAppSettingsSchema;
|
|
||||||
|
|
||||||
type StoredAppSettings = z.infer<typeof StoredAppSettingsSchema>;
|
|
||||||
|
|
||||||
export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||||
theme: DEFAULT_THEME_PREFERENCE,
|
theme: DEFAULT_THEME_PREFERENCE,
|
||||||
pluginThemeId: null,
|
pluginThemeId: null,
|
||||||
@@ -189,6 +125,131 @@ export const DEFAULT_APP_SETTINGS: Settings = {
|
|||||||
releaseChannel: "stable",
|
releaseChannel: "stable",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function clampedNumber(min: number, max: number) {
|
||||||
|
return z
|
||||||
|
.unknown()
|
||||||
|
.transform((value) => parseClampedFontSize(value, { min, max }))
|
||||||
|
.pipe(z.number());
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizedFontFamily() {
|
||||||
|
return z.unknown().transform(sanitizeFontFamily).pipe(z.string());
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarRowItemsSchema = z
|
||||||
|
.looseObject({
|
||||||
|
branch: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.branch),
|
||||||
|
project: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.project),
|
||||||
|
host: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.host),
|
||||||
|
changeRequest: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.changeRequest),
|
||||||
|
services: z.boolean().optional().catch(undefined),
|
||||||
|
labels: z.boolean().catch(DEFAULT_SIDEBAR_ROW_ITEMS.labels),
|
||||||
|
// COMPAT(sidebarRowItemsChecks): migrated in v0.3.0, remove after 2027-08-05.
|
||||||
|
checks: z.boolean().optional().catch(undefined),
|
||||||
|
// COMPAT(sidebarRowItemsScripts): migrated in v0.3.0, remove after 2027-08-05.
|
||||||
|
scripts: z.boolean().optional().catch(undefined),
|
||||||
|
})
|
||||||
|
.catch(DEFAULT_SIDEBAR_ROW_ITEMS);
|
||||||
|
|
||||||
|
type StoredAppSettingsFallback = AppSettings & {
|
||||||
|
uiFontSize?: number;
|
||||||
|
compactToolCalls?: boolean;
|
||||||
|
manageBuiltInDaemon?: boolean;
|
||||||
|
releaseChannel?: ReleaseChannel;
|
||||||
|
needsWrite: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_STORED_APP_SETTINGS = {
|
||||||
|
...DEFAULT_CLIENT_SETTINGS,
|
||||||
|
needsWrite: false,
|
||||||
|
} satisfies StoredAppSettingsFallback;
|
||||||
|
|
||||||
|
const StoredAppSettingsSchema = z
|
||||||
|
.looseObject({
|
||||||
|
theme: ThemePreferenceSchema.catch(DEFAULT_THEME_PREFERENCE),
|
||||||
|
pluginThemeId: z.string().nullable().catch(null),
|
||||||
|
language: z
|
||||||
|
.enum(["system", "ar", "en", "es", "fr", "ja", "ko", "pt-BR", "ru", "zh-CN"])
|
||||||
|
.catch("system"),
|
||||||
|
sendBehavior: z.enum(["interrupt", "steer", "queue"]).catch("steer"),
|
||||||
|
serviceUrlBehavior: z.enum(["ask", "in-app", "external"]).catch("ask"),
|
||||||
|
terminalScrollbackLines: clampedNumber(
|
||||||
|
MIN_TERMINAL_SCROLLBACK_LINES,
|
||||||
|
MAX_TERMINAL_SCROLLBACK_LINES,
|
||||||
|
).catch(DEFAULT_TERMINAL_SCROLLBACK_LINES),
|
||||||
|
useLegacyTerminalRenderer: z.boolean().catch(false),
|
||||||
|
uiFontFamily: sanitizedFontFamily().catch(""),
|
||||||
|
monoFontFamily: sanitizedFontFamily().catch(""),
|
||||||
|
uiBaseFontSize: clampedNumber(MIN_UI_BASE_FONT_SIZE, MAX_UI_BASE_FONT_SIZE)
|
||||||
|
.optional()
|
||||||
|
.catch(undefined),
|
||||||
|
contentFontSize: clampedNumber(MIN_CONTENT_FONT_SIZE, MAX_CONTENT_FONT_SIZE)
|
||||||
|
.optional()
|
||||||
|
.catch(DEFAULT_CONTENT_FONT_SIZE),
|
||||||
|
// COMPAT(uiFontSizeScale): replaced by the literal base size in v0.4, remove after 2027-08-17.
|
||||||
|
uiFontSize: clampedNumber(11, 24).optional().catch(undefined),
|
||||||
|
codeFontSize: clampedNumber(MIN_CODE_FONT_SIZE, MAX_CODE_FONT_SIZE).catch(
|
||||||
|
DEFAULT_CODE_FONT_SIZE,
|
||||||
|
),
|
||||||
|
syntaxTheme: z.string().refine(isSyntaxThemeId).catch("one"),
|
||||||
|
workspaceTitleSource: z.enum(["title", "branch"]).catch("title"),
|
||||||
|
sidebarWorkspaceTrailing: z.enum(["diff", "timestamp", "none"]).catch("diff"),
|
||||||
|
sidebarRowItems: SidebarRowItemsSchema,
|
||||||
|
sidebarChecksDisplay: z
|
||||||
|
.enum(["iconAndText", "icon", "none"])
|
||||||
|
.optional()
|
||||||
|
.catch(DEFAULT_SIDEBAR_CHECKS_DISPLAY),
|
||||||
|
autoExpandReasoning: z.boolean().catch(false),
|
||||||
|
toolCallDetailLevel: z
|
||||||
|
.enum(["overview", "detailed"])
|
||||||
|
.or(z.literal("concise").transform(() => "overview" as const))
|
||||||
|
.optional()
|
||||||
|
.catch("detailed"),
|
||||||
|
// COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12.
|
||||||
|
compactToolCalls: z.boolean().optional().catch(undefined),
|
||||||
|
chatOutlineEnabled: z.boolean().catch(true),
|
||||||
|
vimKeybindings: z.boolean().catch(false),
|
||||||
|
openSupportingTabsInSidePanel: z.boolean().catch(true),
|
||||||
|
// COMPAT(rendererDesktopSettings): these fields used to share this renderer-owned key.
|
||||||
|
manageBuiltInDaemon: z.boolean().optional().catch(undefined),
|
||||||
|
releaseChannel: z.enum(["stable", "beta"]).optional().catch(undefined),
|
||||||
|
})
|
||||||
|
.transform((stored) => {
|
||||||
|
const needsWrite =
|
||||||
|
(stored.uiBaseFontSize === undefined && stored.uiFontSize !== undefined) ||
|
||||||
|
stored.contentFontSize === undefined;
|
||||||
|
const uiBaseFontSize =
|
||||||
|
stored.uiBaseFontSize ??
|
||||||
|
(stored.uiFontSize === undefined
|
||||||
|
? DEFAULT_UI_BASE_FONT_SIZE
|
||||||
|
: Math.round((FONT_SIZE.base * stored.uiFontSize) / 16));
|
||||||
|
const sidebarChecksDisplay =
|
||||||
|
stored.sidebarChecksDisplay ??
|
||||||
|
(isChecksHiddenByLegacyRowItem(stored.sidebarRowItems)
|
||||||
|
? "none"
|
||||||
|
: DEFAULT_SIDEBAR_CHECKS_DISPLAY);
|
||||||
|
const toolCallDetailLevel =
|
||||||
|
stored.toolCallDetailLevel ?? (stored.compactToolCalls ? "overview" : "detailed");
|
||||||
|
return {
|
||||||
|
...stored,
|
||||||
|
uiBaseFontSize,
|
||||||
|
contentFontSize: stored.contentFontSize ?? uiBaseFontSize,
|
||||||
|
sidebarChecksDisplay,
|
||||||
|
sidebarRowItems: {
|
||||||
|
...stored.sidebarRowItems,
|
||||||
|
services:
|
||||||
|
stored.sidebarRowItems.services ??
|
||||||
|
(stored.sidebarRowItems.scripts === false ? false : DEFAULT_SIDEBAR_ROW_ITEMS.services),
|
||||||
|
},
|
||||||
|
toolCallDetailLevel,
|
||||||
|
needsWrite,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch(DEFAULT_STORED_APP_SETTINGS);
|
||||||
|
|
||||||
|
type StoredAppSettings = z.output<typeof StoredAppSettingsSchema>;
|
||||||
|
export type PersistedAppSettings = Omit<StoredAppSettings, "needsWrite">;
|
||||||
|
|
||||||
export interface KeyValueStorage {
|
export interface KeyValueStorage {
|
||||||
getItem(key: string): Promise<string | null>;
|
getItem(key: string): Promise<string | null>;
|
||||||
setItem(key: string, value: string): Promise<void>;
|
setItem(key: string, value: string): Promise<void>;
|
||||||
@@ -220,16 +281,22 @@ export async function saveAppSettings(input: {
|
|||||||
const current = normalizeAppSettings(storedCurrent);
|
const current = normalizeAppSettings(storedCurrent);
|
||||||
const next = { ...current, ...input.updates };
|
const next = { ...current, ...input.updates };
|
||||||
input.queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
input.queryClient.setQueryData<AppSettings>(APP_SETTINGS_QUERY_KEY, next);
|
||||||
await input.deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(next));
|
await writeAppSettings(
|
||||||
|
input.deps.storage,
|
||||||
|
(await readSettingsObject(input.deps.storage, APP_SETTINGS_KEY)) ??
|
||||||
|
StoredAppSettingsSchema.parse({}),
|
||||||
|
next,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise<AppSettings> {
|
export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise<AppSettings> {
|
||||||
try {
|
try {
|
||||||
const read = await readAppSettings(deps);
|
const read = await readAppSettings(deps);
|
||||||
if (read.needsWrite) {
|
if (read.needsWrite) {
|
||||||
await deps.storage.setItem(APP_SETTINGS_KEY, JSON.stringify(read.settings));
|
await writeAppSettings(deps.storage, read.stored, read.settings);
|
||||||
}
|
}
|
||||||
return await migrateAppSettings(read.settings, deps.storage);
|
const { needsWrite: _needsWrite, ...stored } = read.stored;
|
||||||
|
return await migrateAppSettings(read.settings, deps.storage, stored);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AppSettings] Failed to load settings:", error);
|
console.error("[AppSettings] Failed to load settings:", error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -242,23 +309,18 @@ export async function loadAppSettingsFromStorage(deps: SettingsDeps): Promise<Ap
|
|||||||
*/
|
*/
|
||||||
async function readAppSettings(
|
async function readAppSettings(
|
||||||
deps: SettingsDeps,
|
deps: SettingsDeps,
|
||||||
): Promise<{ settings: AppSettings; needsWrite: boolean }> {
|
): Promise<{ settings: AppSettings; needsWrite: boolean; stored: StoredAppSettings }> {
|
||||||
const stored = await readValidatedJson(deps.storage, APP_SETTINGS_KEY, StoredAppSettingsSchema);
|
const stored = await readSettingsObject(deps.storage, APP_SETTINGS_KEY);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
return {
|
return {
|
||||||
settings: normalizeAppSettings(stored),
|
settings: normalizeAppSettings(stored),
|
||||||
// COMPAT(uiFontSizeScale): persist the converted base size, remove after 2027-08-17.
|
// COMPAT(uiFontSizeScale): persist the converted base size, remove after 2027-08-17.
|
||||||
needsWrite:
|
needsWrite: stored.needsWrite,
|
||||||
(stored.uiBaseFontSize === undefined && stored.uiFontSize !== undefined) ||
|
stored,
|
||||||
stored.contentFontSize === undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const legacyStored = await readValidatedJson(
|
const legacyStored = await readSettingsObject(deps.storage, LEGACY_SETTINGS_KEY);
|
||||||
deps.storage,
|
|
||||||
LEGACY_SETTINGS_KEY,
|
|
||||||
LegacyRendererSettingsSchema,
|
|
||||||
);
|
|
||||||
if (legacyStored) {
|
if (legacyStored) {
|
||||||
return {
|
return {
|
||||||
settings: {
|
settings: {
|
||||||
@@ -266,10 +328,12 @@ async function readAppSettings(
|
|||||||
...pickAppSettingsFromLegacy(legacyStored),
|
...pickAppSettingsFromLegacy(legacyStored),
|
||||||
} satisfies AppSettings,
|
} satisfies AppSettings,
|
||||||
needsWrite: true,
|
needsWrite: true,
|
||||||
|
stored: legacyStored,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { settings: DEFAULT_CLIENT_SETTINGS, needsWrite: true };
|
const defaultStored = StoredAppSettingsSchema.parse({});
|
||||||
|
return { settings: DEFAULT_CLIENT_SETTINGS, needsWrite: true, stored: defaultStored };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSettingsFromStorage(deps: SettingsDeps): Promise<Settings> {
|
export async function loadSettingsFromStorage(deps: SettingsDeps): Promise<Settings> {
|
||||||
@@ -299,189 +363,27 @@ export async function loadSettingsFromStorage(deps: SettingsDeps): Promise<Setti
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeAppSettings(value: unknown): AppSettings {
|
export function normalizeAppSettings(value: unknown): AppSettings {
|
||||||
const result = StoredAppSettingsSchema.safeParse(value);
|
const {
|
||||||
|
needsWrite: _needsWrite,
|
||||||
|
manageBuiltInDaemon: _manageBuiltInDaemon,
|
||||||
|
releaseChannel: _releaseChannel,
|
||||||
|
compactToolCalls: _compactToolCalls,
|
||||||
|
uiFontSize: _uiFontSize,
|
||||||
|
...settings
|
||||||
|
} = StoredAppSettingsSchema.parse(value);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickAppSettingsFromLegacy(legacy: StoredAppSettings): AppSettings {
|
||||||
|
const settings = normalizeAppSettings(legacy);
|
||||||
return {
|
return {
|
||||||
...DEFAULT_CLIENT_SETTINGS,
|
...settings,
|
||||||
...pickAppSettings(result.success ? result.data : {}),
|
// The legacy key rendered content on the interface ramp. Freeze that
|
||||||
|
// rendered value into the new independent preference during migration.
|
||||||
|
contentFontSize: legacy.uiBaseFontSize,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseToolCallDetailLevel(stored: StoredAppSettings): ToolCallDetailLevel | null {
|
|
||||||
if (stored.toolCallDetailLevel !== undefined) {
|
|
||||||
if (
|
|
||||||
typeof stored.toolCallDetailLevel === "string" &&
|
|
||||||
VALID_TOOL_CALL_DETAIL_LEVELS.has(stored.toolCallDetailLevel)
|
|
||||||
) {
|
|
||||||
return stored.toolCallDetailLevel;
|
|
||||||
}
|
|
||||||
// COMPAT(toolCallDetailLevelConcise): removed in v0.1.107; legacy "concise" values
|
|
||||||
// deliberately follow the unknown-value fallback. Remove after 2027-01-14.
|
|
||||||
return "overview";
|
|
||||||
}
|
|
||||||
if (typeof stored.compactToolCalls === "boolean") {
|
|
||||||
// COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12.
|
|
||||||
return stored.compactToolCalls ? "overview" : "detailed";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseStoredSidebarChecksDisplay(stored: StoredAppSettings): SidebarChecksDisplay | null {
|
|
||||||
const display = parseSidebarChecksDisplay(stored.sidebarChecksDisplay);
|
|
||||||
if (display !== null) {
|
|
||||||
return display;
|
|
||||||
}
|
|
||||||
// COMPAT(sidebarRowItemsChecks): migrated in v0.3.0, remove after 2027-08-05.
|
|
||||||
return isChecksHiddenByLegacyRowItem(stored.sidebarRowItems) ? "none" : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickBooleanAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
|
|
||||||
const result: Partial<AppSettings> = {};
|
|
||||||
if (typeof stored.useLegacyTerminalRenderer === "boolean") {
|
|
||||||
result.useLegacyTerminalRenderer = stored.useLegacyTerminalRenderer;
|
|
||||||
}
|
|
||||||
if (typeof stored.vimKeybindings === "boolean") {
|
|
||||||
result.vimKeybindings = stored.vimKeybindings;
|
|
||||||
}
|
|
||||||
if (typeof stored.chatOutlineEnabled === "boolean") {
|
|
||||||
result.chatOutlineEnabled = stored.chatOutlineEnabled;
|
|
||||||
}
|
|
||||||
if (typeof stored.openSupportingTabsInSidePanel === "boolean") {
|
|
||||||
result.openSupportingTabsInSidePanel = stored.openSupportingTabsInSidePanel;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The settings whose stored value only has to be a member of a fixed set. Grouped like the
|
|
||||||
* boolean settings are: the numeric and font settings need real parsing and clamping, these
|
|
||||||
* need a membership check and nothing else.
|
|
||||||
*/
|
|
||||||
function pickEnumAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
|
|
||||||
const result: Partial<AppSettings> = {};
|
|
||||||
if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) {
|
|
||||||
result.theme = stored.theme;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
stored.sendBehavior === "interrupt" ||
|
|
||||||
stored.sendBehavior === "steer" ||
|
|
||||||
stored.sendBehavior === "queue"
|
|
||||||
) {
|
|
||||||
result.sendBehavior = stored.sendBehavior;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
typeof stored.serviceUrlBehavior === "string" &&
|
|
||||||
VALID_SERVICE_URL_BEHAVIORS.has(stored.serviceUrlBehavior)
|
|
||||||
) {
|
|
||||||
result.serviceUrlBehavior = stored.serviceUrlBehavior;
|
|
||||||
}
|
|
||||||
if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) {
|
|
||||||
result.syntaxTheme = stored.syntaxTheme;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
typeof stored.workspaceTitleSource === "string" &&
|
|
||||||
VALID_WORKSPACE_TITLE_SOURCES.has(stored.workspaceTitleSource)
|
|
||||||
) {
|
|
||||||
result.workspaceTitleSource = stored.workspaceTitleSource;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
typeof stored.sidebarWorkspaceTrailing === "string" &&
|
|
||||||
VALID_SIDEBAR_WORKSPACE_TRAILINGS.has(stored.sidebarWorkspaceTrailing)
|
|
||||||
) {
|
|
||||||
result.sidebarWorkspaceTrailing = stored.sidebarWorkspaceTrailing;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
|
|
||||||
const result: Partial<AppSettings> = {};
|
|
||||||
Object.assign(result, pickEnumAppSettings(stored));
|
|
||||||
if (typeof stored.pluginThemeId === "string") {
|
|
||||||
result.pluginThemeId = stored.pluginThemeId;
|
|
||||||
}
|
|
||||||
if (stored.sidebarRowItems !== undefined) {
|
|
||||||
result.sidebarRowItems = parseSidebarRowItems(stored.sidebarRowItems);
|
|
||||||
}
|
|
||||||
const sidebarChecksDisplay = parseStoredSidebarChecksDisplay(stored);
|
|
||||||
if (sidebarChecksDisplay !== null) {
|
|
||||||
result.sidebarChecksDisplay = sidebarChecksDisplay;
|
|
||||||
}
|
|
||||||
const language = parseAppLanguage(stored.language);
|
|
||||||
if (language !== null) {
|
|
||||||
result.language = language;
|
|
||||||
}
|
|
||||||
const terminalScrollbackLines = parseTerminalScrollbackLines(stored.terminalScrollbackLines);
|
|
||||||
if (terminalScrollbackLines !== null) {
|
|
||||||
result.terminalScrollbackLines = terminalScrollbackLines;
|
|
||||||
}
|
|
||||||
const uiFontFamily = sanitizeFontFamily(stored.uiFontFamily);
|
|
||||||
if (uiFontFamily !== null) {
|
|
||||||
result.uiFontFamily = uiFontFamily;
|
|
||||||
}
|
|
||||||
const monoFontFamily = sanitizeFontFamily(stored.monoFontFamily);
|
|
||||||
if (monoFontFamily !== null) {
|
|
||||||
result.monoFontFamily = monoFontFamily;
|
|
||||||
}
|
|
||||||
const uiBaseFontSize = parseClampedFontSize(stored.uiBaseFontSize, {
|
|
||||||
min: MIN_UI_BASE_FONT_SIZE,
|
|
||||||
max: MAX_UI_BASE_FONT_SIZE,
|
|
||||||
});
|
|
||||||
if (uiBaseFontSize !== null) {
|
|
||||||
result.uiBaseFontSize = uiBaseFontSize;
|
|
||||||
} else {
|
|
||||||
const legacyUiFontSize = parseClampedFontSize(stored.uiFontSize, {
|
|
||||||
min: 11,
|
|
||||||
max: 24,
|
|
||||||
});
|
|
||||||
if (legacyUiFontSize !== null) {
|
|
||||||
result.uiBaseFontSize = Math.round((FONT_SIZE.base * legacyUiFontSize) / 16);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const contentFontSize = parseClampedFontSize(stored.contentFontSize, {
|
|
||||||
min: MIN_CONTENT_FONT_SIZE,
|
|
||||||
max: MAX_CONTENT_FONT_SIZE,
|
|
||||||
});
|
|
||||||
if (contentFontSize !== null) {
|
|
||||||
result.contentFontSize = contentFontSize;
|
|
||||||
} else if (stored.contentFontSize === undefined) {
|
|
||||||
// Existing content followed the interface ramp. Preserve that rendered size
|
|
||||||
// once, then persist the independent setting during the read migration.
|
|
||||||
result.contentFontSize = result.uiBaseFontSize ?? DEFAULT_UI_BASE_FONT_SIZE;
|
|
||||||
}
|
|
||||||
const codeFontSize = parseClampedFontSize(stored.codeFontSize, {
|
|
||||||
min: MIN_CODE_FONT_SIZE,
|
|
||||||
max: MAX_CODE_FONT_SIZE,
|
|
||||||
});
|
|
||||||
if (codeFontSize !== null) {
|
|
||||||
result.codeFontSize = codeFontSize;
|
|
||||||
}
|
|
||||||
Object.assign(result, pickBooleanAppSettings(stored));
|
|
||||||
if (typeof stored.autoExpandReasoning === "boolean") {
|
|
||||||
result.autoExpandReasoning = stored.autoExpandReasoning;
|
|
||||||
}
|
|
||||||
const toolCallDetailLevel = parseToolCallDetailLevel(stored);
|
|
||||||
if (toolCallDetailLevel !== null) {
|
|
||||||
result.toolCallDetailLevel = toolCallDetailLevel;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickAppSettingsFromLegacy(
|
|
||||||
legacy: z.infer<typeof LegacyRendererSettingsSchema>,
|
|
||||||
): Partial<AppSettings> {
|
|
||||||
const result: Partial<AppSettings> = {};
|
|
||||||
if (legacy.theme === "dark" || legacy.theme === "light" || legacy.theme === "auto") {
|
|
||||||
result.theme = legacy.theme;
|
|
||||||
}
|
|
||||||
const legacyInterfaceSize = pickAppSettings(legacy).uiBaseFontSize;
|
|
||||||
if (legacyInterfaceSize !== undefined) {
|
|
||||||
result.uiBaseFontSize = legacyInterfaceSize;
|
|
||||||
}
|
|
||||||
// The legacy key rendered content on the interface ramp. Freeze that
|
|
||||||
// rendered value into the new independent preference during migration.
|
|
||||||
result.contentFontSize = legacyInterfaceSize ?? DEFAULT_UI_BASE_FONT_SIZE;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseTerminalScrollbackLines(value: unknown): number | null {
|
export function parseTerminalScrollbackLines(value: unknown): number | null {
|
||||||
let numericValue = NaN;
|
let numericValue = NaN;
|
||||||
if (typeof value === "number") {
|
if (typeof value === "number") {
|
||||||
@@ -548,10 +450,10 @@ async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): P
|
|||||||
releaseChannel?: ReleaseChannel;
|
releaseChannel?: ReleaseChannel;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
if (typeof stored.manageBuiltInDaemon === "boolean") {
|
if (stored.manageBuiltInDaemon !== undefined) {
|
||||||
result.manageBuiltInDaemon = stored.manageBuiltInDaemon;
|
result.manageBuiltInDaemon = stored.manageBuiltInDaemon;
|
||||||
}
|
}
|
||||||
if (stored.releaseChannel === "stable" || stored.releaseChannel === "beta") {
|
if (stored.releaseChannel !== undefined) {
|
||||||
result.releaseChannel = stored.releaseChannel;
|
result.releaseChannel = stored.releaseChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,11 +462,48 @@ async function loadLegacyDesktopSettingsFromStorage(storage: KeyValueStorage): P
|
|||||||
|
|
||||||
async function loadRendererSettingsPayload(
|
async function loadRendererSettingsPayload(
|
||||||
storage: KeyValueStorage,
|
storage: KeyValueStorage,
|
||||||
): Promise<z.infer<typeof LegacyRendererSettingsSchema> | null> {
|
): Promise<StoredAppSettings | null> {
|
||||||
const current = await readValidatedJson(storage, APP_SETTINGS_KEY, LegacyRendererSettingsSchema);
|
const current = await readSettingsObject(storage, APP_SETTINGS_KEY);
|
||||||
if (current) {
|
if (current) {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
||||||
return readValidatedJson(storage, LEGACY_SETTINGS_KEY, LegacyRendererSettingsSchema);
|
return readSettingsObject(storage, LEGACY_SETTINGS_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSettingsObject(
|
||||||
|
storage: KeyValueStorage,
|
||||||
|
key: string,
|
||||||
|
): Promise<StoredAppSettings | null> {
|
||||||
|
const raw = await storage.getItem(key);
|
||||||
|
if (raw === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded: unknown;
|
||||||
|
try {
|
||||||
|
decoded = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
console.warn(`[AppSettings] Removing corrupt ${key}: invalid JSON.`);
|
||||||
|
await storage.removeItem(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return StoredAppSettingsSchema.parse(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeAppSettings(
|
||||||
|
storage: KeyValueStorage,
|
||||||
|
stored: StoredAppSettings,
|
||||||
|
settings: AppSettings,
|
||||||
|
): Promise<void> {
|
||||||
|
const { needsWrite: _needsWrite, ...persistedStored } = stored;
|
||||||
|
const storedSidebarRowItems = persistedStored.sidebarRowItems;
|
||||||
|
await storage.setItem(
|
||||||
|
APP_SETTINGS_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
...persistedStored,
|
||||||
|
...settings,
|
||||||
|
sidebarRowItems: { ...storedSidebarRowItems, ...settings.sidebarRowItems },
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -310,16 +310,6 @@ describe("translation resources", () => {
|
|||||||
expect(en.workspace.git.pr.empty.pipelineJobsLoadFailed).toBe("Could not load pipeline jobs");
|
expect(en.workspace.git.pr.empty.pipelineJobsLoadFailed).toBe("Could not load pipeline jobs");
|
||||||
expect(en.workspace.git.pr.empty.allowedToFail).toBe("allowed to fail");
|
expect(en.workspace.git.pr.empty.allowedToFail).toBe("allowed to fail");
|
||||||
expect(en.workspace.git.pr.approvals).toBe("{{given}} of {{required}} approvals");
|
expect(en.workspace.git.pr.approvals).toBe("{{given}} of {{required}} approvals");
|
||||||
expect(en.workspace.git.pr.accessibility.checkStatus).toEqual({
|
|
||||||
passed: "Passed",
|
|
||||||
failed: "Failed",
|
|
||||||
warning: "Warning",
|
|
||||||
actionRequired: "Action required",
|
|
||||||
manual: "Manual",
|
|
||||||
pending: "Pending",
|
|
||||||
skipped: "Skipped",
|
|
||||||
cancelled: "Cancelled",
|
|
||||||
});
|
|
||||||
expect(en.review.comment.placeholder).toBe("Leave a comment");
|
expect(en.review.comment.placeholder).toBe("Leave a comment");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -448,14 +438,6 @@ describe("translation resources", () => {
|
|||||||
expect(en.sidebar.workspace.actions.createWorkspaceFor).toBe(
|
expect(en.sidebar.workspace.actions.createWorkspaceFor).toBe(
|
||||||
"Create a new workspace for {{projectName}}",
|
"Create a new workspace for {{projectName}}",
|
||||||
);
|
);
|
||||||
expect(en.sidebar.workspace.checks).toEqual({
|
|
||||||
passed: "Passed: {{count}}",
|
|
||||||
failed: "Failed: {{count}}",
|
|
||||||
warning: "Warnings: {{count}}",
|
|
||||||
actionRequired: "Action required: {{count}}",
|
|
||||||
manual: "Manual: {{count}}",
|
|
||||||
pending: "Pending: {{count}}",
|
|
||||||
});
|
|
||||||
expect(en.sidebar.project.empty.title).toBe("No projects yet");
|
expect(en.sidebar.project.empty.title).toBe("No projects yet");
|
||||||
expect(en.sidebar.project.empty.description).toBe("Add a project to get started");
|
expect(en.sidebar.project.empty.description).toBe("Add a project to get started");
|
||||||
expect(en.settings.projectList.hostLoadFailed).toBe(
|
expect(en.settings.projectList.hostLoadFailed).toBe(
|
||||||
|
|||||||
@@ -936,16 +936,6 @@ export const ar: TranslationResources = {
|
|||||||
accessibility: {
|
accessibility: {
|
||||||
pullRequest: "سحب الطلب #{{number}}",
|
pullRequest: "سحب الطلب #{{number}}",
|
||||||
pullRequest_mr: "طلب دمج !{{number}}",
|
pullRequest_mr: "طلب دمج !{{number}}",
|
||||||
checkStatus: {
|
|
||||||
passed: "ناجح",
|
|
||||||
failed: "فاشل",
|
|
||||||
warning: "تحذير",
|
|
||||||
actionRequired: "إجراء مطلوب",
|
|
||||||
manual: "يدوي",
|
|
||||||
pending: "قيد الانتظار",
|
|
||||||
skipped: "تم التخطي",
|
|
||||||
cancelled: "تم الإلغاء",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
states: {
|
states: {
|
||||||
draft: "مسودة",
|
draft: "مسودة",
|
||||||
@@ -1136,14 +1126,6 @@ export const ar: TranslationResources = {
|
|||||||
serviceUnhealthy: "الخدمة {{name}} غير سليمة",
|
serviceUnhealthy: "الخدمة {{name}} غير سليمة",
|
||||||
creating: "جارٍ الإنشاء...",
|
creating: "جارٍ الإنشاء...",
|
||||||
},
|
},
|
||||||
checks: {
|
|
||||||
passed: "ناجحة: {{count}}",
|
|
||||||
failed: "فاشلة: {{count}}",
|
|
||||||
warning: "تحذيرات: {{count}}",
|
|
||||||
actionRequired: "إجراء مطلوب: {{count}}",
|
|
||||||
manual: "يدوية: {{count}}",
|
|
||||||
pending: "قيد الانتظار: {{count}}",
|
|
||||||
},
|
|
||||||
actions: {
|
actions: {
|
||||||
menu: "إجراءات Workspace",
|
menu: "إجراءات Workspace",
|
||||||
newWorkspace: "مساحة عمل جديدة",
|
newWorkspace: "مساحة عمل جديدة",
|
||||||
|
|||||||
@@ -945,16 +945,6 @@ export const en = {
|
|||||||
accessibility: {
|
accessibility: {
|
||||||
pullRequest: "Pull request #{{number}}",
|
pullRequest: "Pull request #{{number}}",
|
||||||
pullRequest_mr: "Merge request !{{number}}",
|
pullRequest_mr: "Merge request !{{number}}",
|
||||||
checkStatus: {
|
|
||||||
passed: "Passed",
|
|
||||||
failed: "Failed",
|
|
||||||
warning: "Warning",
|
|
||||||
actionRequired: "Action required",
|
|
||||||
manual: "Manual",
|
|
||||||
pending: "Pending",
|
|
||||||
skipped: "Skipped",
|
|
||||||
cancelled: "Cancelled",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
states: {
|
states: {
|
||||||
draft: "Draft",
|
draft: "Draft",
|
||||||
@@ -1145,14 +1135,6 @@ export const en = {
|
|||||||
serviceUnhealthy: "Service {{name}} unhealthy",
|
serviceUnhealthy: "Service {{name}} unhealthy",
|
||||||
creating: "Creating...",
|
creating: "Creating...",
|
||||||
},
|
},
|
||||||
checks: {
|
|
||||||
passed: "Passed: {{count}}",
|
|
||||||
failed: "Failed: {{count}}",
|
|
||||||
warning: "Warnings: {{count}}",
|
|
||||||
actionRequired: "Action required: {{count}}",
|
|
||||||
manual: "Manual: {{count}}",
|
|
||||||
pending: "Pending: {{count}}",
|
|
||||||
},
|
|
||||||
actions: {
|
actions: {
|
||||||
menu: "Workspace actions",
|
menu: "Workspace actions",
|
||||||
newWorkspace: "New workspace",
|
newWorkspace: "New workspace",
|
||||||
|
|||||||
@@ -967,16 +967,6 @@ export const es: TranslationResources = {
|
|||||||
accessibility: {
|
accessibility: {
|
||||||
pullRequest: "Solicitud de extracción n.°{{number}}",
|
pullRequest: "Solicitud de extracción n.°{{number}}",
|
||||||
pullRequest_mr: "Solicitud de fusión !{{number}}",
|
pullRequest_mr: "Solicitud de fusión !{{number}}",
|
||||||
checkStatus: {
|
|
||||||
passed: "Superado",
|
|
||||||
failed: "Fallido",
|
|
||||||
warning: "Advertencia",
|
|
||||||
actionRequired: "Acción requerida",
|
|
||||||
manual: "Manual",
|
|
||||||
pending: "Pendiente",
|
|
||||||
skipped: "Omitido",
|
|
||||||
cancelled: "Cancelado",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
states: {
|
states: {
|
||||||
draft: "Borrador",
|
draft: "Borrador",
|
||||||
@@ -1170,14 +1160,6 @@ export const es: TranslationResources = {
|
|||||||
serviceUnhealthy: "Servicio {{name}} con fallos",
|
serviceUnhealthy: "Servicio {{name}} con fallos",
|
||||||
creating: "Creando...",
|
creating: "Creando...",
|
||||||
},
|
},
|
||||||
checks: {
|
|
||||||
passed: "Superados: {{count}}",
|
|
||||||
failed: "Fallidos: {{count}}",
|
|
||||||
warning: "Advertencias: {{count}}",
|
|
||||||
actionRequired: "Acción requerida: {{count}}",
|
|
||||||
manual: "Manuales: {{count}}",
|
|
||||||
pending: "Pendientes: {{count}}",
|
|
||||||
},
|
|
||||||
actions: {
|
actions: {
|
||||||
menu: "AccionesWorkspace",
|
menu: "AccionesWorkspace",
|
||||||
newWorkspace: "Nuevo espacio de trabajo",
|
newWorkspace: "Nuevo espacio de trabajo",
|
||||||
|
|||||||
@@ -966,16 +966,6 @@ export const fr: TranslationResources = {
|
|||||||
accessibility: {
|
accessibility: {
|
||||||
pullRequest: "Demande de tirage #{{number}}",
|
pullRequest: "Demande de tirage #{{number}}",
|
||||||
pullRequest_mr: "Demande de fusion !{{number}}",
|
pullRequest_mr: "Demande de fusion !{{number}}",
|
||||||
checkStatus: {
|
|
||||||
passed: "Réussi",
|
|
||||||
failed: "Échec",
|
|
||||||
warning: "Avertissement",
|
|
||||||
actionRequired: "Action requise",
|
|
||||||
manual: "Manuel",
|
|
||||||
pending: "En attente",
|
|
||||||
skipped: "Ignoré",
|
|
||||||
cancelled: "Annulé",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
states: {
|
states: {
|
||||||
draft: "Brouillon",
|
draft: "Brouillon",
|
||||||
@@ -1170,14 +1160,6 @@ export const fr: TranslationResources = {
|
|||||||
serviceUnhealthy: "Service {{name}} en échec",
|
serviceUnhealthy: "Service {{name}} en échec",
|
||||||
creating: "Création...",
|
creating: "Création...",
|
||||||
},
|
},
|
||||||
checks: {
|
|
||||||
passed: "Réussis : {{count}}",
|
|
||||||
failed: "Échecs : {{count}}",
|
|
||||||
warning: "Avertissements : {{count}}",
|
|
||||||
actionRequired: "Action requise : {{count}}",
|
|
||||||
manual: "Manuels : {{count}}",
|
|
||||||
pending: "En attente : {{count}}",
|
|
||||||
},
|
|
||||||
actions: {
|
actions: {
|
||||||
menu: "ActionsWorkspace",
|
menu: "ActionsWorkspace",
|
||||||
newWorkspace: "Nouvel espace de travail",
|
newWorkspace: "Nouvel espace de travail",
|
||||||
|
|||||||
@@ -947,16 +947,6 @@ export const ja: TranslationResources = {
|
|||||||
accessibility: {
|
accessibility: {
|
||||||
pullRequest: "プルリクエスト#{{number}}",
|
pullRequest: "プルリクエスト#{{number}}",
|
||||||
pullRequest_mr: "マージリクエスト !{{number}}",
|
pullRequest_mr: "マージリクエスト !{{number}}",
|
||||||
checkStatus: {
|
|
||||||
passed: "成功",
|
|
||||||
failed: "失敗",
|
|
||||||
warning: "警告",
|
|
||||||
actionRequired: "操作が必要",
|
|
||||||
manual: "手動",
|
|
||||||
pending: "保留中",
|
|
||||||
skipped: "スキップ済み",
|
|
||||||
cancelled: "キャンセル済み",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
states: {
|
states: {
|
||||||
draft: "ドラフト",
|
draft: "ドラフト",
|
||||||
@@ -1148,14 +1138,6 @@ export const ja: TranslationResources = {
|
|||||||
serviceUnhealthy: "サービス {{name}} 異常",
|
serviceUnhealthy: "サービス {{name}} 異常",
|
||||||
creating: "作成中...",
|
creating: "作成中...",
|
||||||
},
|
},
|
||||||
checks: {
|
|
||||||
passed: "成功: {{count}}",
|
|
||||||
failed: "失敗: {{count}}",
|
|
||||||
warning: "警告: {{count}}",
|
|
||||||
actionRequired: "操作が必要: {{count}}",
|
|
||||||
manual: "手動: {{count}}",
|
|
||||||
pending: "保留中: {{count}}",
|
|
||||||
},
|
|
||||||
actions: {
|
actions: {
|
||||||
menu: "ワークスペースアクション",
|
menu: "ワークスペースアクション",
|
||||||
newWorkspace: "新しいワークスペース",
|
newWorkspace: "新しいワークスペース",
|
||||||
|
|||||||
@@ -943,16 +943,6 @@ export const ko: TranslationResources = {
|
|||||||
accessibility: {
|
accessibility: {
|
||||||
pullRequest: "풀 리퀘스트 #{{number}}",
|
pullRequest: "풀 리퀘스트 #{{number}}",
|
||||||
pullRequest_mr: "병합 요청 !{{number}}",
|
pullRequest_mr: "병합 요청 !{{number}}",
|
||||||
checkStatus: {
|
|
||||||
passed: "통과",
|
|
||||||
failed: "실패",
|
|
||||||
warning: "경고",
|
|
||||||
actionRequired: "조치 필요",
|
|
||||||
manual: "수동",
|
|
||||||
pending: "대기 중",
|
|
||||||
skipped: "건너뜀",
|
|
||||||
cancelled: "취소됨",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
states: {
|
states: {
|
||||||
draft: "초안",
|
draft: "초안",
|
||||||
@@ -1143,14 +1133,6 @@ export const ko: TranslationResources = {
|
|||||||
serviceUnhealthy: "서비스 {{name}} 비정상",
|
serviceUnhealthy: "서비스 {{name}} 비정상",
|
||||||
creating: "생성하는 중...",
|
creating: "생성하는 중...",
|
||||||
},
|
},
|
||||||
checks: {
|
|
||||||
passed: "통과: {{count}}개",
|
|
||||||
failed: "실패: {{count}}개",
|
|
||||||
warning: "경고: {{count}}개",
|
|
||||||
actionRequired: "조치 필요: {{count}}개",
|
|
||||||
manual: "수동: {{count}}개",
|
|
||||||
pending: "대기 중: {{count}}개",
|
|
||||||
},
|
|
||||||
actions: {
|
actions: {
|
||||||
menu: "워크스페이스 작업",
|
menu: "워크스페이스 작업",
|
||||||
newWorkspace: "새 워크스페이스",
|
newWorkspace: "새 워크스페이스",
|
||||||
|
|||||||
@@ -958,16 +958,6 @@ export const ptBR: TranslationResources = {
|
|||||||
accessibility: {
|
accessibility: {
|
||||||
pullRequest: "Pull request #{{number}}",
|
pullRequest: "Pull request #{{number}}",
|
||||||
pullRequest_mr: "Merge request !{{number}}",
|
pullRequest_mr: "Merge request !{{number}}",
|
||||||
checkStatus: {
|
|
||||||
passed: "Aprovado",
|
|
||||||
failed: "Falhou",
|
|
||||||
warning: "Aviso",
|
|
||||||
actionRequired: "Ação necessária",
|
|
||||||
manual: "Manual",
|
|
||||||
pending: "Pendente",
|
|
||||||
skipped: "Ignorado",
|
|
||||||
cancelled: "Cancelado",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
states: {
|
states: {
|
||||||
draft: "Rascunho",
|
draft: "Rascunho",
|
||||||
@@ -1161,14 +1151,6 @@ export const ptBR: TranslationResources = {
|
|||||||
serviceUnhealthy: "Serviço {{name}} com falha",
|
serviceUnhealthy: "Serviço {{name}} com falha",
|
||||||
creating: "Criando...",
|
creating: "Criando...",
|
||||||
},
|
},
|
||||||
checks: {
|
|
||||||
passed: "Aprovados: {{count}}",
|
|
||||||
failed: "Falhas: {{count}}",
|
|
||||||
warning: "Avisos: {{count}}",
|
|
||||||
actionRequired: "Ação necessária: {{count}}",
|
|
||||||
manual: "Manuais: {{count}}",
|
|
||||||
pending: "Pendentes: {{count}}",
|
|
||||||
},
|
|
||||||
actions: {
|
actions: {
|
||||||
menu: "Ações do workspace",
|
menu: "Ações do workspace",
|
||||||
newWorkspace: "Novo workspace",
|
newWorkspace: "Novo workspace",
|
||||||
|
|||||||
@@ -951,16 +951,6 @@ export const ru: TranslationResources = {
|
|||||||
accessibility: {
|
accessibility: {
|
||||||
pullRequest: "PR #{{number}}",
|
pullRequest: "PR #{{number}}",
|
||||||
pullRequest_mr: "MR !{{number}}",
|
pullRequest_mr: "MR !{{number}}",
|
||||||
checkStatus: {
|
|
||||||
passed: "Успешно",
|
|
||||||
failed: "Ошибка",
|
|
||||||
warning: "Предупреждение",
|
|
||||||
actionRequired: "Требуется действие",
|
|
||||||
manual: "Вручную",
|
|
||||||
pending: "Ожидание",
|
|
||||||
skipped: "Пропущено",
|
|
||||||
cancelled: "Отменено",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
states: {
|
states: {
|
||||||
draft: "Черновик",
|
draft: "Черновик",
|
||||||
@@ -1152,14 +1142,6 @@ export const ru: TranslationResources = {
|
|||||||
serviceUnhealthy: "Сервис {{name}} работает некорректно",
|
serviceUnhealthy: "Сервис {{name}} работает некорректно",
|
||||||
creating: "Создание...",
|
creating: "Создание...",
|
||||||
},
|
},
|
||||||
checks: {
|
|
||||||
passed: "Успешные: {{count}}",
|
|
||||||
failed: "Ошибки: {{count}}",
|
|
||||||
warning: "Предупреждения: {{count}}",
|
|
||||||
actionRequired: "Требуется действие: {{count}}",
|
|
||||||
manual: "Вручную: {{count}}",
|
|
||||||
pending: "Ожидают: {{count}}",
|
|
||||||
},
|
|
||||||
actions: {
|
actions: {
|
||||||
menu: "Действия рабочего пространства",
|
menu: "Действия рабочего пространства",
|
||||||
newWorkspace: "Новое рабочее пространство",
|
newWorkspace: "Новое рабочее пространство",
|
||||||
|
|||||||
@@ -928,16 +928,6 @@ export const zhCN: TranslationResources = {
|
|||||||
accessibility: {
|
accessibility: {
|
||||||
pullRequest: "Pull request #{{number}}",
|
pullRequest: "Pull request #{{number}}",
|
||||||
pullRequest_mr: "合并请求 !{{number}}",
|
pullRequest_mr: "合并请求 !{{number}}",
|
||||||
checkStatus: {
|
|
||||||
passed: "成功",
|
|
||||||
failed: "失败",
|
|
||||||
warning: "警告",
|
|
||||||
actionRequired: "需要操作",
|
|
||||||
manual: "手动",
|
|
||||||
pending: "等待中",
|
|
||||||
skipped: "已跳过",
|
|
||||||
cancelled: "已取消",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
states: {
|
states: {
|
||||||
draft: "Draft",
|
draft: "Draft",
|
||||||
@@ -1126,14 +1116,6 @@ export const zhCN: TranslationResources = {
|
|||||||
serviceUnhealthy: "服务 {{name}} 异常",
|
serviceUnhealthy: "服务 {{name}} 异常",
|
||||||
creating: "正在创建...",
|
creating: "正在创建...",
|
||||||
},
|
},
|
||||||
checks: {
|
|
||||||
passed: "成功: {{count}}",
|
|
||||||
failed: "失败: {{count}}",
|
|
||||||
warning: "警告: {{count}}",
|
|
||||||
actionRequired: "需要操作: {{count}}",
|
|
||||||
manual: "手动: {{count}}",
|
|
||||||
pending: "等待中: {{count}}",
|
|
||||||
},
|
|
||||||
actions: {
|
actions: {
|
||||||
menu: "Workspace 操作",
|
menu: "Workspace 操作",
|
||||||
newWorkspace: "新建 workspace",
|
newWorkspace: "新建 workspace",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user