From 3163e212fbcd1ab43a286b28ad80359779970449 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 12 Jun 2026 13:43:01 +0800 Subject: [PATCH] Attach pull request feedback to chat (#1400) * feat(app): add pull request panel context actions Attach pull request comments, reviews, and failed check details to chat context. The PR panel now lives in its own module so the activity, query, and context policies have one home. * fix(server): mark capped check details as truncated * fix(app): preserve concurrent PR panel attachments * fix(server): preserve annotation truncation state * fix(app): keep matrix check attachments distinct * fix(app): streamline pull request context attachments * fix(app): simplify pull request feedback rendering * feat(app): group pull request review threads * fix(app): tidy pull request panel boundaries * fix: use worktree cwd for dev services * fix(app): keep git query keys UI-free * feat(app): collapse resolved comments and add chat actions to headers * fix: address PR feedback and Playwright flakes * fix(app): hide check chat action without attachment scope * fix(server): dedupe inline review timeline comments --- ...t-snapshot-startup-reshaping-2026-05-27.md | 211 --- ...de-provider-snapshot-timeout-2026-05-27.md | 389 ----- .../startup-sequence-analysis-2026-05-27.md | 381 ----- docs/unistyles.md | 3 +- package-lock.json | 102 ++ packages/app/e2e/helpers/composer.ts | 4 +- packages/app/e2e/helpers/pr-pane.ts | 2 +- packages/app/package.json | 1 + packages/app/src/attachments/types.ts | 35 +- .../workspace-attachment-utils.test.ts | 57 + .../attachments/workspace-attachment-utils.ts | 29 +- .../workspace-attachments-store.test.ts | 52 + .../workspace-attachments-store.ts | 48 + .../app/src/components/explorer-sidebar.tsx | 27 +- packages/app/src/components/file-pane.tsx | 322 +--- .../src/components/markdown-text.android.tsx | 2 +- .../app/src/components/markdown-text.ios.tsx | 2 +- .../app/src/components/markdown-text.web.tsx | 2 +- .../src/components/markdown/html-ish.test.ts | 312 ++++ .../app/src/components/markdown/html-ish.ts | 705 ++++++++ .../components/markdown/inline-image-size.ts | 63 + .../components/markdown/part-groups.test.ts | 92 + .../src/components/markdown/part-groups.ts | 89 + .../src/components/markdown/renderer.test.ts | 29 + .../app/src/components/markdown/renderer.tsx | 758 +++++++++ packages/app/src/components/message.tsx | 52 +- packages/app/src/composer/actions.test.ts | 4 +- .../src/composer/attachments/workspace.tsx | 137 +- packages/app/src/git/pr-pane.tsx | 453 ----- packages/app/src/git/pr-tab-label.test.ts | 27 - packages/app/src/git/pr-tab-label.ts | 5 - .../activity-location.test.ts | 48 + .../pull-request-panel/activity-location.ts | 54 + .../pull-request-panel/activity-state.test.ts | 140 ++ .../git/pull-request-panel/activity-state.ts | 115 ++ .../context-attachment.test.ts | 317 ++++ .../pull-request-panel/context-attachment.ts | 284 ++++ .../data.test.ts} | 91 +- .../data.ts} | 57 +- .../app/src/git/pull-request-panel/index.ts | 5 + .../app/src/git/pull-request-panel/pane.tsx | 1500 +++++++++++++++++ .../src/git/pull-request-panel/query-keys.ts | 13 + .../src/git/pull-request-panel/tab-icon.tsx | 5 + .../git/pull-request-panel/tab-label.test.ts | 12 + .../src/git/pull-request-panel/tab-label.ts | 3 + .../git/pull-request-panel/timeline.test.ts | 122 ++ .../src/git/pull-request-panel/timeline.ts | 105 ++ .../pull-request-panel/use-data.test.ts} | 2 +- .../pull-request-panel/use-data.ts} | 4 +- packages/app/src/git/query-keys.test.ts | 2 +- packages/app/src/git/query-keys.ts | 15 +- .../workspace-tabs/agent-visibility.test.ts | 5 +- packages/client/src/daemon-client.test.ts | 83 + packages/client/src/daemon-client.ts | 28 + .../src/messages.checkout-pr-schema.test.ts | 154 ++ .../messages.pull-request-timeline.test.ts | 156 ++ packages/protocol/src/messages.ts | 95 ++ .../opencode/tool-call-mapper.test.ts | 6 +- packages/server/src/server/session.test.ts | 100 +- packages/server/src/server/session.ts | 45 +- .../server/src/server/websocket-server.ts | 1 + .../src/services/github-service.test.ts | 467 +++++ .../server/src/services/github-service.ts | 432 ++++- paseo.json | 12 +- 64 files changed, 6934 insertions(+), 1939 deletions(-) delete mode 100644 docs/diagnostics/git-snapshot-startup-reshaping-2026-05-27.md delete mode 100644 docs/diagnostics/opencode-provider-snapshot-timeout-2026-05-27.md delete mode 100644 docs/diagnostics/startup-sequence-analysis-2026-05-27.md create mode 100644 packages/app/src/attachments/workspace-attachment-utils.test.ts create mode 100644 packages/app/src/components/markdown/html-ish.test.ts create mode 100644 packages/app/src/components/markdown/html-ish.ts create mode 100644 packages/app/src/components/markdown/inline-image-size.ts create mode 100644 packages/app/src/components/markdown/part-groups.test.ts create mode 100644 packages/app/src/components/markdown/part-groups.ts create mode 100644 packages/app/src/components/markdown/renderer.test.ts create mode 100644 packages/app/src/components/markdown/renderer.tsx delete mode 100644 packages/app/src/git/pr-pane.tsx delete mode 100644 packages/app/src/git/pr-tab-label.test.ts delete mode 100644 packages/app/src/git/pr-tab-label.ts create mode 100644 packages/app/src/git/pull-request-panel/activity-location.test.ts create mode 100644 packages/app/src/git/pull-request-panel/activity-location.ts create mode 100644 packages/app/src/git/pull-request-panel/activity-state.test.ts create mode 100644 packages/app/src/git/pull-request-panel/activity-state.ts create mode 100644 packages/app/src/git/pull-request-panel/context-attachment.test.ts create mode 100644 packages/app/src/git/pull-request-panel/context-attachment.ts rename packages/app/src/git/{pr-pane-data.test.ts => pull-request-panel/data.test.ts} (80%) rename packages/app/src/git/{pr-pane-data.ts => pull-request-panel/data.ts} (77%) create mode 100644 packages/app/src/git/pull-request-panel/index.ts create mode 100644 packages/app/src/git/pull-request-panel/pane.tsx create mode 100644 packages/app/src/git/pull-request-panel/query-keys.ts create mode 100644 packages/app/src/git/pull-request-panel/tab-icon.tsx create mode 100644 packages/app/src/git/pull-request-panel/tab-label.test.ts create mode 100644 packages/app/src/git/pull-request-panel/tab-label.ts create mode 100644 packages/app/src/git/pull-request-panel/timeline.test.ts create mode 100644 packages/app/src/git/pull-request-panel/timeline.ts rename packages/app/src/{hooks/use-pr-pane-data.test.ts => git/pull-request-panel/use-data.test.ts} (99%) rename packages/app/src/{hooks/use-pr-pane-data.ts => git/pull-request-panel/use-data.ts} (98%) diff --git a/docs/diagnostics/git-snapshot-startup-reshaping-2026-05-27.md b/docs/diagnostics/git-snapshot-startup-reshaping-2026-05-27.md deleted file mode 100644 index 87571bc2e..000000000 --- a/docs/diagnostics/git-snapshot-startup-reshaping-2026-05-27.md +++ /dev/null @@ -1,211 +0,0 @@ -# Git Snapshot Startup Reshaping - 2026-05-27 - -## What changed - -The sidebar PR badge no longer has a special per-row fetch path. It is derived from the workspace snapshot, the same way the sidebar already gets branch/diff metadata. - -```text -daemon startup / workspace subscription - -> WorkspaceGitService.refreshSnapshot(cwd) - -> getCheckoutSnapshotFacts(cwd) - -> getCheckoutStatus(cwd, { facts }) - -> getCheckoutShortstat(cwd, { facts }) - -> getPullRequestStatus(cwd, github, ..., { facts }) - -> WorkspaceGitRuntimeSnapshot - -> session workspace descriptor githubRuntime.pullRequest - -> app useSidebarWorkspacesList() - -> SidebarWorkspaceEntry.prHint - -> Sidebar row badge + hover card checks -``` - -The remaining `checkout_pr_status_request` path is still present for explicit PR surfaces and compatibility, but the sidebar row badge no longer calls `useWorkspacePrHint()` and therefore no longer generates ad hoc checkout PR status requests per visible row. - -## Shared Git Facts - -`getCheckoutSnapshotFacts()` is now the first git read in the workspace snapshot builder. It gathers facts that were previously rediscovered by separate functions: - -- worktree root: `rev-parse --show-toplevel` -- current branch: `rev-parse --abbrev-ref HEAD` -- origin remote URL -- Paseo worktree ownership and stored base ref -- resolved base ref and best comparison base -- main repo root -- branch remote/merge config -- tracked origin branch -- pull request lookup target for fork/PR worktrees - -Those facts are then passed through `CheckoutContext` so status, shortstat, and PR status reuse the same answers instead of independently re-reading them. - -## Current Data Flow - -```text -Workspace subscription / fetch_workspaces - -> session workspace registry - -> workspaceGitService.getSnapshot(cwd, includeGitHub) - -> refresh queue/throttle/dedupe per normalized cwd - -> refreshGitSnapshot() - -> getCheckoutSnapshotFacts() - -> getCheckoutStatus({ facts }) - -> getCheckoutShortstat({ facts }) - -> refreshGitHubSnapshot() - -> getPullRequestStatus({ facts }) - -> cached WorkspaceGitRuntimeSnapshot - -> WorkspaceDescriptorPayload.gitRuntime - -> WorkspaceDescriptorPayload.githubRuntime - -> app session store - -> useSidebarWorkspacesList() - -> diffStat from descriptor - -> prHint from descriptor.githubRuntime.pullRequest -``` - -## Startup Benchmark - -Added deterministic real-home benchmark: - -`packages/server/scripts/benchmark-startup-git-real-home.ts` - -The script freezes the current Paseo home using the same metadata-copy shape as `scripts/dev-home.sh`: JSON under `agents`, JSON under `projects`, and `config.json`. It then starts an isolated in-process daemon against that frozen home, subscribes to workspaces/agents, records git invocations through `runGitCommand`, and reports elapsed time, git count, max concurrency, CPU, and memory deltas. - -The frozen home used for the comparison contained 22 workspaces. - -### Before/After - -| run | code shape | client shape | git commands | failures | elapsed | -| ----------- | -------------------------- | ----------------------------------------- | -----------: | -------: | ------: | -| baseline | before change | legacy sidebar PR fanout | 529 | 20 | 39039ms | -| split check | after change | legacy sidebar PR fanout | 375 | 15 | 39039ms | -| after | after change | snapshot-only sidebar, no PR badge fanout | 372 | 15 | 31273ms | -| after 2 | after service fact sharing | snapshot-only sidebar, no PR badge fanout | 308 | 15 | 31334ms | - -The server-side fact reuse accounts for nearly all measured git command reduction: `529 -> 375` (`-154`, `-29.1%`) even when the old PR fanout is still forced. Removing the sidebar fanout removes the ad hoc request path, but in this run it only changed command count by `3` because the refreshed workspace snapshots already carried the PR data by the time the fanout ran. - -The second pass shares checkout facts between workspace observation setup and snapshot refresh. That removes another `64` git commands from the same frozen-home run: `372 -> 308` (`-17.2%` from the previous after, `-41.8%` from baseline). - -### Baseline: before change + legacy PR fanout - -```json -{ - "scenario": "legacyPrFanout", - "workspaceCount": 22, - "elapsedMs": 39039, - "git": { - "total": 529, - "failed": 20, - "maxConcurrent": 8, - "byCommand": [ - { "key": "show-ref --verify --quiet refs/heads/main", "count": 66 }, - { "key": "rev-parse --git-common-dir", "count": 58 }, - { "key": "rev-parse --abbrev-ref HEAD", "count": 50 }, - { "key": "rev-parse --git-dir", "count": 36 }, - { "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 35 }, - { "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 35 }, - { "key": "config --get remote.origin.url", "count": 32 }, - { "key": "ls-files --others --exclude-standard", "count": 18 }, - { "key": "rev-parse --absolute-git-dir", "count": 18 }, - { "key": "merge-base HEAD origin/main", "count": 17 }, - { "key": "rev-parse --show-toplevel", "count": 14 }, - { "key": "status --porcelain", "count": 14 } - ] - }, - "process": { - "cpuUserMs": 2009, - "cpuSystemMs": 2428, - "rssDeltaMb": -1.5, - "heapUsedDeltaMb": 16.9 - } -} -``` - -### After: after change + snapshot-only sidebar - -```json -{ - "scenario": "snapshotOnly", - "workspaceCount": 22, - "elapsedMs": 31273, - "git": { - "total": 372, - "failed": 15, - "maxConcurrent": 8, - "byCommand": [ - { "key": "config --get remote.origin.url", "count": 35 }, - { "key": "show-ref --verify --quiet refs/heads/main", "count": 34 }, - { "key": "rev-parse --git-common-dir", "count": 31 }, - { "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 22 }, - { "key": "status --porcelain", "count": 22 }, - { "key": "ls-files --others --exclude-standard", "count": 18 }, - { "key": "rev-parse --absolute-git-dir", "count": 18 }, - { "key": "merge-base HEAD origin/main", "count": 17 }, - { "key": "rev-parse --abbrev-ref HEAD", "count": 17 }, - { "key": "rev-parse --show-toplevel", "count": 17 }, - { "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 17 }, - { "key": "rev-list --count main..origin/main", "count": 7 } - ] - }, - "process": { - "cpuUserMs": 1871, - "cpuSystemMs": 2152, - "rssDeltaMb": 4.4, - "heapUsedDeltaMb": 8.8 - } -} -``` - -### After 2: shared service-level facts - -```json -{ - "scenario": "snapshotOnly", - "workspaceCount": 22, - "elapsedMs": 31334, - "git": { - "total": 308, - "failed": 15, - "maxConcurrent": 8, - "byCommand": [ - { "key": "show-ref --verify --quiet refs/heads/main", "count": 31 }, - { "key": "rev-parse --git-common-dir", "count": 26 }, - { "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 22 }, - { "key": "ls-files --others --exclude-standard", "count": 18 }, - { "key": "status --porcelain", "count": 18 }, - { "key": "merge-base HEAD origin/main", "count": 17 }, - { "key": "config --get remote.origin.url", "count": 13 }, - { "key": "rev-parse --abbrev-ref HEAD", "count": 13 }, - { "key": "rev-parse --absolute-git-dir", "count": 13 }, - { "key": "rev-parse --show-toplevel", "count": 13 }, - { "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 13 }, - { "key": "fetch origin --prune", "count": 5 } - ] - }, - "process": { - "cpuUserMs": 1817, - "cpuSystemMs": 1869, - "rssDeltaMb": 16.7, - "heapUsedDeltaMb": 10.4 - } -} -``` - -## Snapshot Equivalence Guard - -Added a focused utility test proving that status, shortstat, and PR status return the same data when run from shared snapshot facts. The same test records git calls and asserts the facts-backed path does not re-run: - -- `rev-parse --show-toplevel` -- `rev-parse --abbrev-ref HEAD` - -Test: - -`packages/server/src/utils/checkout-git.test.ts` -> `reuses checkout snapshot facts across status, shortstat, and PR status reads` - -## Remaining Waste Visible In Baseline - -This pass reshaped the data flow and removed the sidebar PR badge special path. It did not try to optimize every command. - -The benchmark still shows repeated per-workspace reads that are candidates for the next pass: - -- base ref existence checks still repeat as `show-ref` probes. -- default branch resolution still repeats `symbolic-ref refs/remotes/origin/HEAD`. -- repo common-dir lookup is lower, but still above the apparent git workspace count. -- shortstat still runs its own merge-base/diff/untracked scan per workspace. - -The important invariant now is clearer: sidebar-visible git data should flow from `WorkspaceGitService` snapshots, and snapshot builders should receive reusable git facts through `CheckoutContext`. diff --git a/docs/diagnostics/opencode-provider-snapshot-timeout-2026-05-27.md b/docs/diagnostics/opencode-provider-snapshot-timeout-2026-05-27.md deleted file mode 100644 index 3b823e670..000000000 --- a/docs/diagnostics/opencode-provider-snapshot-timeout-2026-05-27.md +++ /dev/null @@ -1,389 +0,0 @@ -# OpenCode Provider Snapshot Startup Timeout Diagnosis - 2026-05-27 - -## Answer - -The startup timeout is real OpenCode provider snapshot work, not an agent resume path. - -In the dev-style copied-home reproduction, the OpenCode snapshot misses the 30s budget because several expensive things stack: - -1. Paseo starts from a copied `PASEO_HOME` containing 4,851 agent records. -2. Clients ask for provider snapshots for three cwd scopes at almost the same time: - - `/Users/moboudra` - - `/Users/moboudra/dev/paseo` - - `/Users/moboudra/dev/blankpage/editor` -3. Each OpenCode snapshot runs two OpenCode SDK calls: - - `GET /provider?directory=...` through `client.provider.list()` - - `GET /agent?directory=...` through `client.app.agents()` -4. One cold `opencode serve` process is shared by the three cwd scopes. It took 8.562s to become ready. -5. After OpenCode was listening, Paseo issued six OpenCode HTTP calls concurrently. -6. The OpenCode `/provider` responses are large: about 3,549,620 decompressed bytes per cwd. -7. During the same window, the daemon was still doing heavy startup workspace git work. In the exact 18:14:19-18:14:43 window, the daemon log has 292 git spawn/close events. -8. The `/provider` calls eventually succeeded, but too late: they completed about 32.2s-32.5s after the snapshot fetch started, while the snapshot timeout is 30s. - -So the root cause is: - -```text -Cold OpenCode server startup + three concurrent cwd snapshots + large OpenCode /provider responses + daemon startup git contention causes client.provider.list() to complete after Paseo's 30s snapshot budget. -``` - -More precise wording: the contention is machine-level process/CPU/filesystem contention created by daemon startup work, especially git work. It is not proven to be an OpenCode internal lock or a Paseo-only event-loop issue. A daemon-free repro with only OpenCode plus an external git storm slowed the same six OpenCode calls from about 1s to about 30s total. - -Manual settings refresh works because it runs after startup contention is gone and uses `force: true`, which creates fresh OpenCode runtime/server state. The same OpenCode provider refreshes then complete in about 1.7s-2.2s. - -The daemon does not auto-retry error snapshots. A failed provider snapshot is cached as `status: "error"` until an explicit refresh resets it to loading. - -## Follow-up: Normal Copied-Home Startup Check - -I later reran a normal dev-daemon startup against a fresh copy of the same Paseo home metadata and drove the app startup request path: - -```text -fetchWorkspaces -fetchAgents -getProvidersSnapshot(home scope) -getProvidersSnapshot(first workspace scope) -``` - -That run did not reproduce the 30s OpenCode timeout. - -```text -home scope: - OpenCode ready at ~8s - availability: 1.6s - fetch total: 5.2s - -first workspace scope: - OpenCode ready at ~26s - availability: 2.0s - fetch total: 15.4s -``` - -The slowest OpenCode operation in that successful run was the workspace-scoped `/provider` response body read: `13.6s`. The daemon log had no `Timed out refreshing OpenCode` entry and no OpenCode provider snapshot failure. - -This means the timeout is reproducible under the heavier multi-scope startup contention captured below, but it is not guaranteed on every copied-home dev startup. - -## Reproduction Used - -The user's correction was right: the useful reproduction is not a random isolated home. It must match `dev.sh` worktree behavior. - -Relevant scripts: - -- `scripts/dev.sh` -- `scripts/dev-daemon.sh` -- `scripts/dev-home.sh` - -`dev-home.sh` only seeds this metadata into the dev home: - -```text -agents/**/*.json -projects/**/*.json -config.json -``` - -It does not copy `chat`, `loops`, `schedules`, sockets, pid files, logs, or worktree contents. - -I ran a separate daemon, not the main daemon: - -```text -PASEO_HOME=/var/folders/xl/kkk9drfd3ms_t8x7rmy4z6900000gn/T/paseo-devseed.Wms6pi -PASEO_LISTEN=127.0.0.1:51116 -PASEO_LOG_LEVEL=trace -``` - -Startup facts: - -```text -18:13:39.552 Agent storage initialized: 712ms -18:13:39.559 Workspace registries bootstrapped: 719ms -18:13:39.961 Agent registry loaded: 4851 records -18:13:39.972 Server listening: http://127.0.0.1:51116 -``` - -The probe then connected four client sessions and requested: - -- workspaces -- active agents -- provider snapshots for home, paseo, and blankpage/editor - -Client-visible result: - -```text -18:14:30.263 /Users/moboudra/dev/blankpage/editor opencode error: - OpenCode app.agents timed out after 10s - -18:14:41.687 /Users/moboudra/dev/paseo opencode error: - Timed out refreshing OpenCode after 30000ms - -18:14:41.688 /Users/moboudra opencode error: - Timed out refreshing OpenCode after 30000ms -``` - -## Exact OpenCode Timeline - -OpenCode snapshot requests began at `18:14:10`. - -Availability checks: - -```text -18:14:10.780 opencode availability start for /Users/moboudra -18:14:10.787 opencode availability start for /Users/moboudra/dev/paseo -18:14:10.800 opencode availability start for /Users/moboudra/dev/blankpage/editor - -18:14:11.363 paseo availability complete: 576ms -18:14:11.376 home availability complete: 597ms -18:14:11.391 blankpage availability complete: 591ms -``` - -OpenCode server acquisition: - -```text -18:14:11.364 OpenCode server spawn start: opencode serve --port 56376 -18:14:19.926 OpenCode server listening after 8562ms -``` - -Six SDK calls were then issued: - -```text -18:14:19.931 GET /provider directory=/Users/moboudra/dev/paseo -18:14:19.931 GET /agent directory=/Users/moboudra/dev/paseo -18:14:19.931 GET /provider directory=/Users/moboudra -18:14:19.931 GET /agent directory=/Users/moboudra -18:14:19.931 GET /provider directory=/Users/moboudra/dev/blankpage/editor -18:14:19.936 GET /agent directory=/Users/moboudra/dev/blankpage/editor -``` - -Why six: - -| Cwd | Why that scope exists | Model call | Mode call | -| -------------------------------------- | ---------------------------------------------------------- | --------------------------------------- | --------------------------------- | -| `/Users/moboudra` | home/settings provider snapshot | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` | -| `/Users/moboudra/dev/paseo` | workspace-scoped provider snapshot for the Paseo workspace | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` | -| `/Users/moboudra/dev/blankpage/editor` | workspace/agent cwd snapshot for blankpage/editor | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` | - -Multiple clients can request the same snapshot scope during startup, but non-forced provider loads are deduped by `(cwd, provider)`. Different cwd scopes are separate loads. Three cwd scopes times two OpenCode SDK calls each is the six OpenCode calls in this repro. - -Headers arrived before the 30s timeout: - -| Call | Cwd | Headers after request | -| ----------- | ------------------------------- | --------------------- | -| `/provider` | `/Users/moboudra` | 6.462s | -| `/agent` | `/Users/moboudra` | 6.681s | -| `/agent` | `/Users/moboudra/dev/paseo` | 6.681s | -| `/provider` | `/Users/moboudra/dev/paseo` | 8.192s | -| `/provider` | `/Users/moboudra/dev/blankpage` | 8.654s | -| `/agent` | `/Users/moboudra/dev/blankpage` | 8.649s | - -But body consumption and completion lagged: - -```text -18:14:29.380 /agent home complete, total app.agents duration 9450ms -18:14:29.813 /agent paseo complete, total app.agents duration 9883ms -18:14:30.263 /agent blankpage timed out at 10s -18:14:31.332 /agent blankpage body finally finished, after the 10s app.agents timeout - -18:14:41.687 paseo snapshot outer 30s timeout fires -18:14:41.688 home snapshot outer 30s timeout fires - -18:14:43.593 /provider home completes, provider.list duration 23664ms, total listModels 32218ms -18:14:43.798 /provider blankpage completes, provider.list duration 23868ms, total listModels 32411ms -18:14:43.839 /provider paseo completes, provider.list duration 23911ms, total listModels 32476ms -``` - -The useful `/provider` results arrived about 1.9s-2.2s after the snapshot manager had already marked home and paseo as failed. - -## Why Settings Refresh Works - -After the daemon settled, I ran the same refresh path through the daemon on port `51116`, using `refreshProvidersSnapshot({ providers: ["opencode"] })`. - -Results: - -```text -home refresh: - total: 2165ms - status: ready - models: 409 - modes: 5 - -/Users/moboudra/dev/paseo refresh: - total: 1675ms - status: ready - models: 409 - modes: 5 - -/Users/moboudra/dev/blankpage/editor refresh: - total: 1794ms - status: ready - models: 409 - modes: 5 -``` - -Trace details for the manual-style refresh: - -```text -OpenCode server acquisition: 708ms-1291ms -/agent completion: 433ms-592ms after request start -/provider completion: 524ms-618ms after request start -``` - -That proves the startup failure is not bad credentials, not a permanently wedged OpenCode install, and not OpenCode generally taking more than 30s. It is startup timing and contention. - -## Minimal OpenCode-Only Repros - -### OpenCode Only, No Daemon, No Artificial Load - -I started a fresh `opencode serve`, waited for stdout `listening on`, then issued the same six HTTP calls concurrently: - -```text -GET /provider?directory=/Users/moboudra -GET /agent?directory=/Users/moboudra -GET /provider?directory=/Users/moboudra/dev/paseo -GET /agent?directory=/Users/moboudra/dev/paseo -GET /provider?directory=/Users/moboudra/dev/blankpage/editor -GET /agent?directory=/Users/moboudra/dev/blankpage/editor -``` - -Three runs: - -| Run | `opencode serve` ready | All six calls complete | -| --- | ---------------------- | ---------------------- | -| 1 | 1376ms | 1295ms | -| 2 | 906ms | 1050ms | -| 3 | 939ms | 898ms | - -Slowest individual call in those runs: - -```text -/provider /Users/moboudra/dev/paseo: 1270ms total -/agent /Users/moboudra/dev/blankpage/editor: 1251ms total -``` - -So six concurrent OpenCode calls alone are not the bug. - -### OpenCode Only Plus External Git Storm, No Daemon - -I then ran the same OpenCode-only six-call test while an external shell spawned repeated git commands across the same real workspaces/worktrees. This did not use the Paseo daemon. - -Result: - -```text -opencode serve ready: 15479ms -all six OpenCode calls complete: 15176ms after server ready -combined cold-start + calls: about 30655ms -``` - -Individual calls under the external git storm: - -| Call | Cwd | Total | -| ----------- | -------------------------------------- | ------: | -| `/provider` | `/Users/moboudra` | 10684ms | -| `/agent` | `/Users/moboudra` | 10767ms | -| `/provider` | `/Users/moboudra/dev/paseo` | 13220ms | -| `/agent` | `/Users/moboudra/dev/paseo` | 13147ms | -| `/provider` | `/Users/moboudra/dev/blankpage/editor` | 14675ms | -| `/agent` | `/Users/moboudra/dev/blankpage/editor` | 15038ms | - -This is the daemon-free minimal evidence that process/filesystem contention can push the same OpenCode cold-start + six-call workload to the same 30s boundary. - -## Why It Does Not Retry - -`ProviderSnapshotManager.getSnapshot()` only starts background warmup for: - -- no existing snapshot -- missing providers -- entries still in `loading` with no active load - -When refresh fails, `refreshProvider()` stores: - -```text -status: "error" -error: "Timed out refreshing OpenCode after 30000ms" -``` - -An `error` entry is not treated as stale/loading by `getSnapshot()`, so normal reads keep returning the cached error. - -Settings refresh calls `refresh_providers_snapshot_request`, which routes to: - -```text -refreshSettingsSnapshot() -clearCachedProviders() -resetSnapshotToLoading() -refreshProviders(... force: true) -``` - -That is why you have to force a manual refresh. - -## Git Work During The Repro - -This is not the final optimization report, but it matters for the timeout because it overlaps exactly with OpenCode response handling. - -Total git commands in the dev-style copied-home daemon log: - -```text -632 spawned -632 closed -``` - -Top cwd counts: - -| Count | Cwd | -| ----: | ------------------------------------------------------------------------------------- | -| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/merry-ladybug` | -| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/hopeful-eel` | -| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-compaction-cancel-loading` | -| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-archive-worktree-session-history` | -| 44 | `/Users/moboudra/.paseo/worktrees/0vpo9h4b/breezy-toad` | -| 36 | `/Users/moboudra/.paseo/worktrees/steering-policy-refactor-detached` | -| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/integration-session-mcp-command-stack` | -| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-provider-diagnostic-binary-resolution` | -| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/feat-voice-runtime-on-demand` | -| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/feat-find-in-pane` | -| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/epic-paseo-client-sdk` | -| 24 | `/Users/moboudra/dev/paseo` | -| 24 | `/Users/moboudra/dev/blankpage/editor` | -| 24 | `/Users/moboudra/dev/faro/main` | -| 24 | `/Users/moboudra/dev/konbert/web` | -| 24 | `/Users/moboudra/dev/paseo-cloud` | - -In the exact OpenCode pressure window, `18:14:19` through `18:14:43`, there were: - -```text -142 git command spawns -150 git command closes -``` - -The main repeated command shapes were: - -```text -76 git rev-parse --show-toplevel -72 git status --porcelain -72 git show-ref --verify --quiet refs/remotes/origin/main -72 git show-ref --verify --quiet refs/heads/main -16 git config --get branch.main.remote -16 git config --get branch.main.merge -16 git rev-list --count main..origin/main -16 git rev-list --count origin/main..main -``` - -## Original `log.txt` Alignment - -The original startup showed the same home and paseo outer timeout shape: - -```text -16:04:22.466 /Users/moboudra/dev/paseo: - Timed out refreshing OpenCode after 30000ms - -16:04:22.482 /Users/moboudra: - Timed out refreshing OpenCode after 30000ms -``` - -The original logs did not include SDK fetch/header/body timing, so they could only show the wrapper-level timeout. The dev-style copied-home reproduction with instrumentation now shows the missing link: the `/provider` calls completed just after the 30s snapshot budget. - -## Files Instrumented For Diagnosis - -Temporary trace instrumentation was added to: - -- `packages/server/src/server/agent/provider-snapshot-manager.ts` -- `packages/server/src/server/agent/providers/opencode-agent.ts` -- `packages/server/src/server/agent/providers/opencode/runtime.ts` -- `packages/server/src/server/agent/providers/opencode/server-manager.ts` - -The instrumentation is behavior-neutral and only emits trace logs. diff --git a/docs/diagnostics/startup-sequence-analysis-2026-05-27.md b/docs/diagnostics/startup-sequence-analysis-2026-05-27.md deleted file mode 100644 index 97e4cd3ce..000000000 --- a/docs/diagnostics/startup-sequence-analysis-2026-05-27.md +++ /dev/null @@ -1,381 +0,0 @@ -# Daemon Startup Sequence Analysis - 2026-05-27 - -Source log: `log.txt` at repository root. - -Scope: current sliced startup log, starting at daemon worker startup and ending after workspace registry reconciliation and the first OpenCode heartbeat. - -This report is descriptive only. It does not propose optimizations. - -## Executive Summary - -The daemon becomes ready quickly, then does a heavy post-listen startup pass driven by reconnecting clients and workspace/app hydration. - -- Worker start: `16:03:46.678`, line 1. -- Server listening: `16:03:48.285`, line 47, elapsed `602ms`. -- First client hello: `16:03:50.285`, line 66. -- Workspace registries reconciled: `16:04:33.666`, line 1777, elapsed `45983ms`. - -The startup shape is therefore: - -- Daemon listen readiness: about `0.6s`. -- Client reconnect plus workspace/app/provider hydration: about `45s`. -- No git commands after workspace registry reconciliation in this slice. - -## Method - -I parsed structured trace lines from `log.txt`, especially: - -- `Git command closed` -- `agent.session.inbound` -- `agent.session.outbound` -- `ws_slow_request` -- provider snapshot warnings -- provider resume events - -Important limitation: git command logs do not carry a websocket request id, so per-request attribution is inferred from timing and server code paths. Per-workspace git counts, command shapes, durations, and failures are exact for this log. - -Relevant code paths checked: - -- `packages/server/src/server/session.ts` - - `fetch_workspaces_request` calls `syncWorkspaceGitObservers(payload.entries)`. - - `checkout_status_request` calls `workspaceGitService.getSnapshot(resolvedCwd)`. - - `checkout_pr_status_request` calls `workspaceGitService.getSnapshot(cwd)`. -- `packages/server/src/server/workspace-git-service.ts` - - checkout snapshot/root resolution uses `git rev-parse --show-toplevel`. - - snapshot refresh collects dirty state, upstream/ahead/behind, ref existence, and base divergence. -- `packages/app/src/contexts/session-context.tsx` - - initial workspace hydration calls `client.fetchWorkspaces({ sort: activity_at desc, subscribe, page limit 200 })`. -- `packages/app/src/hooks/use-sidebar-workspaces-list.ts` - - sidebar workspace refresh also calls `client.fetchWorkspaces({ sort: activity_at desc, page limit 200 })`. - -## Startup Timeline - -| time | line | event | -| -------------- | ---: | ------------------------------------------------------------------ | -| `16:03:46.678` | 1 | `DaemonRunner` starts daemon worker | -| `16:03:47.683` | 4 | worker spawned | -| `16:03:47.684` | 6 | daemon keypair loaded | -| `16:03:48.281` | 44 | bootstrap complete, ready to listen | -| `16:03:48.285` | 47 | server listening on `0.0.0.0:6767` | -| `16:03:50.274` | 60 | first websocket awaiting hello | -| `16:03:50.285` | 66 | first client connected via hello | -| `16:04:22.466` | 987 | OpenCode provider snapshot timeout for `/Users/moboudra/dev/paseo` | -| `16:04:22.482` | 1002 | OpenCode provider snapshot timeout for `/Users/moboudra` | -| `16:04:24.183` | 1201 | OpenCode provider subscribe starts | -| `16:04:24.183` | 1202 | OpenCode provider subscribe ready | -| `16:04:24.306` | 1214 | OpenCode server connected event | -| `16:04:25.933` | 1321 | OpenCode agent resumed from persistence | -| `16:04:33.666` | 1777 | workspace registries reconciled | -| `16:04:34.197` | 1783 | OpenCode heartbeat | -| `16:04:44.200` | 1789 | OpenCode heartbeat | - -## Git Command Totals - -Total git commands in the sliced startup: `444`. - -| phase | commands | failures | summed process time | -| ------------------------------- | -------: | -------: | ------------------: | -| daemon bootstrap before listen | 13 | 4 | 445ms | -| post-listen before first client | 1 | 0 | 2020ms | -| client reconnect + reconcile | 430 | 71 | 120813ms | -| after reconcile | 0 | 0 | 0ms | -| total | 444 | 75 | 123278ms | - -Summed process time is not wall-clock time. Many commands overlap. - -## Git Command Categories - -| category | commands | failures | summed process time | max duration | -| ---------------------------------------------------- | -------: | -------: | ------------------: | -----------: | -| ahead/behind: `rev-list --count ...` | 115 | 30 | 35815ms | 1557ms | -| refs: `show-ref --verify --quiet ...` | 86 | 2 | 14680ms | 1303ms | -| upstream config: `config --get branch.*` | 85 | 13 | 26437ms | 1624ms | -| root detection: `rev-parse --show-toplevel` | 80 | 30 | 24164ms | 1426ms | -| dirty status: `status --porcelain` | 50 | 0 | 12670ms | 2020ms | -| base divergence: `rev-list --left-right --count ...` | 28 | 0 | 9512ms | 1085ms | - -What those categories mean in the app: - -- Root detection: determine whether a cwd is inside a git repo and find its checkout root. -- Dirty status: show dirty/clean workspace state. -- Upstream config and ahead/behind: show branch tracking and sync state. -- Ref existence and base divergence: compare checkout branch against candidate base refs for checkout/PR status. - -## Per-Workspace Git Work - -Columns: - -- `phase`: `pre/warm/reconnect/after` -- `cats`: `root/dirty/upstream/ahead/refs/base/other` -- `total_ms`: summed process time for that workspace - -| workspace | cmds | fail | phase | cats | total_ms | max_ms | window | failing command shapes | -| ----------------------------------------------------------------------- | ---: | ---: | ---------- | ----------------- | -------: | -----: | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `~/.paseo/worktrees/1luy0po7/fix-compaction-cancel-loading` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 8255 | 1460 | `16:03:52.908-16:04:24.019` | `3x config --get branch.fix-compaction-cancel-loading.remote`; `3x rev-list --count fix-compaction-cancel-loading..origin/fix-compaction-cancel-loading`; `3x rev-list --count origin/fix-compaction-cancel-loading..fix-compaction-cancel-loading` | -| `~/.paseo/worktrees/1luy0po7/hopeful-eel` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 8468 | 1544 | `16:03:53.245-16:04:27.334` | `3x config --get branch.feat/markdown-annotations.remote`; `3x rev-list --count feat/markdown-annotations..origin/feat/markdown-annotations`; `3x rev-list --count origin/feat/markdown-annotations..feat/markdown-annotations` | -| `~/.paseo/worktrees/1luy0po7/merry-ladybug` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 7154 | 1099 | `16:03:53.696-16:04:29.644` | `3x config --get branch.feat/mcp-configuration.remote`; `3x rev-list --count feat/mcp-configuration..origin/feat/mcp-configuration`; `3x rev-list --count origin/feat/mcp-configuration..feat/mcp-configuration` | -| `~/dev/paseo` | 30 | 0 | `2/0/28/0` | `5/5/10/10/0/0/0` | 7457 | 1624 | `16:03:48.171-16:04:27.284` | | -| `~/.paseo/worktrees/0vpo9h4b/dazzling-duck` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7617 | 1269 | `16:03:51.918-16:04:23.971` | | -| `~/.paseo/worktrees/1luy0po7/epic-paseo-client-sdk` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7428 | 1426 | `16:03:52.445-16:04:23.991` | | -| `~/.paseo/worktrees/1luy0po7/fix-provider-diagnostic-binary-resolution` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7764 | 1091 | `16:03:52.681-16:04:23.971` | | -| `~/dev/emdash` | 22 | 6 | `0/0/22/0` | `2/2/2/6/8/2/0` | 3031 | 453 | `16:04:27.351-16:04:29.583` | `2x config --get branch.heads/main.remote`; `2x rev-list --count heads/main..origin/heads/main`; `2x rev-list --count origin/heads/main..heads/main` | -| `~/dev/opencode` | 22 | 4 | `0/0/22/0` | `2/2/2/6/8/2/0` | 2279 | 313 | `16:04:24.058-16:04:24.970` | `2x rev-list --count ecosystem-paseo..origin/ecosystem-paseo`; `2x rev-list --count origin/ecosystem-paseo..ecosystem-paseo` | -| `~/.paseo/worktrees/1luy0po7/integration-session-mcp-command-stack` | 18 | 0 | `0/0/18/0` | `3/3/6/6/0/0/0` | 7467 | 1242 | `16:03:53.781-16:04:23.971` | | -| `~/dev/blankpage/editor` | 18 | 0 | `2/0/16/0` | `3/3/6/6/0/0/0` | 2418 | 520 | `16:03:48.174-16:04:26.411` | | -| `~/dev/konbert/web` | 18 | 0 | `1/1/16/0` | `3/3/6/6/0/0/0` | 7324 | 2020 | `16:03:48.190-16:04:23.685` | | -| `~/dev/openchamber` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 1554 | 336 | `16:04:27.399-16:04:29.616` | | -| `~/dev/superset` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 1019 | 215 | `16:04:27.341-16:04:29.617` | | -| `~/dev/t3code` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 2761 | 588 | `16:04:27.356-16:04:29.603` | | -| `~/.paseo/worktrees/0vpo9h4b/breezy-toad` | 11 | 5 | `0/0/11/0` | `1/1/1/3/4/1/0` | 6465 | 1290 | `16:03:51.307-16:04:18.112` | `1x config --get branch.fix/user-delete-dark-mode.remote`; `1x rev-list --count fix/user-delete-dark-mode..origin/fix/user-delete-dark-mode`; `1x rev-list --count origin/fix/user-delete-dark-mode..fix/user-delete-dark-mode`; `2x show-ref --verify --quiet refs/remotes/origin/my-branch` | -| `~/.paseo/worktrees/1luy0po7/fix-archive-worktree-session-history` | 11 | 3 | `0/0/11/0` | `1/1/1/3/4/1/0` | 4303 | 757 | `16:03:52.539-16:04:19.011` | `1x config --get branch.fix-archive-worktree-session-history.remote`; `1x rev-list --count fix-archive-worktree-session-history..origin/fix-archive-worktree-session-history`; `1x rev-list --count origin/fix-archive-worktree-session-history..fix-archive-worktree-session-history` | -| `~/.paseo/worktrees/0vpo9h4b/codex-github-mention-implement-db-garbage` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 4986 | 1005 | `16:03:51.261-16:04:16.878` | | -| `~/.paseo/worktrees/1luy0po7/feat-find-in-pane` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5273 | 1130 | `16:03:52.391-16:04:17.682` | | -| `~/.paseo/worktrees/1luy0po7/feat-voice-runtime-on-demand` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5176 | 839 | `16:03:52.110-16:04:18.254` | | -| `~/.paseo/worktrees/steering-policy-refactor-detached` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5993 | 1243 | `16:03:53.984-16:04:17.673` | | -| `~/dev/faro/main` | 6 | 0 | `2/0/4/0` | `1/1/2/2/0/0/0` | 4964 | 1603 | `16:03:48.168-16:04:03.748` | | -| `~/dev/paseo-cloud` | 6 | 0 | `2/0/4/0` | `1/1/2/2/0/0/0` | 2123 | 1154 | `16:03:48.159-16:03:56.377` | | -| `~/dev/assistant` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 85 | 29 | `16:03:48.165-16:04:24.048` | `3x rev-parse --show-toplevel` | -| `~/dev/benchmark/dashboard-2026-05-25/review` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 224 | 105 | `16:03:48.197-16:04:26.560` | `3x rev-parse --show-toplevel` | -| `~/dev/research/orchestrator-worker` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 285 | 144 | `16:03:48.194-16:04:26.575` | `3x rev-parse --show-toplevel` | -| `/tmp` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 113 | 77 | `16:04:27.388-16:04:27.471` | `2x rev-parse --show-toplevel` | -| `~/dev` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 86 | 58 | `16:04:27.384-16:04:27.457` | `2x rev-parse --show-toplevel` | -| `~/dev/benchmark/dashboard-2026-05-25/01-claude-opus` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 216 | 185 | `16:04:26.525-16:04:26.543` | `2x rev-parse --show-toplevel` | -| `~/dev/benchmark/dashboard-2026-05-25/02-codex-gpt55` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 98 | 68 | `16:04:26.353-16:04:26.554` | `2x rev-parse --show-toplevel` | -| `~/dev/benchmark/dashboard-2026-05-25/03-opencode-zai-glm51` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 209 | 158 | `16:04:26.512-16:04:26.576` | `2x rev-parse --show-toplevel` | -| `~/dev/benchmark/dashboard-2026-05-25/04-opencode-zen-minimax27` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 148 | 101 | `16:04:26.431-16:04:26.549` | `2x rev-parse --show-toplevel` | -| `~/dev/benchmark/dashboard-2026-05-25/05-opencode-zen-kimi26` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 231 | 172 | `16:04:26.517-16:04:26.577` | `2x rev-parse --show-toplevel` | -| `~/dev/benchmark/dashboard-2026-05-25/06-opencode-or-deepseek4pro` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 93 | 73 | `16:04:27.365-16:04:27.380` | `2x rev-parse --show-toplevel` | -| `~/dev/benchmark/dashboard-2026-05-25/07-opencode-zen-gemini35flash` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 78 | 66 | `16:04:27.363-16:04:27.375` | `2x rev-parse --show-toplevel` | -| `~/dev/benchmark/dashboard-2026-05-25/08-opencode-zen-gpt55` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 120 | 65 | `16:04:27.359-16:04:27.430` | `2x rev-parse --show-toplevel` | -| `~/dev/assistant/game` | 1 | 1 | `1/0/0/0` | `1/0/0/0/0/0/0` | 13 | 13 | `16:03:48.155-16:03:48.155` | `1x rev-parse --show-toplevel` | - -## Git Failure Shape - -There were 75 nonzero git exits. - -Most failures were not timeouts. They were expected probe failures: - -- Non-repo checks: `rev-parse --show-toplevel` fails for paths that are not git repositories. -- Missing upstream config: `config --get branch..remote` fails for branches without configured upstream. -- Missing remote branch graph: `rev-list --count ..origin/` fails when the remote branch/ref does not exist. -- Missing ref checks: `show-ref --verify --quiet refs/remotes/origin/my-branch` fails when a candidate ref does not exist. - -The `~/dev/opencode` git failures are branch graph probes for `ecosystem-paseo` versus `origin/ecosystem-paseo`, not OpenCode provider startup failures. - -## Inbound Client Work - -Inbound session messages during the startup window: - -| request | count | -| --------------------------------- | ----: | -| `client_heartbeat` | 19 | -| `checkout_pr_status_request` | 18 | -| `fetch_agents_request` | 11 | -| `fetch_workspaces_request` | 9 | -| `get_providers_snapshot_request` | 9 | -| `project_icon_request` | 9 | -| `fetch_agent_timeline_request` | 7 | -| `clear_agent_attention` | 6 | -| `list_terminals_request` | 5 | -| `subscribe_terminals_request` | 5 | -| `list_available_editors_request` | 2 | -| `subscribe_checkout_diff_request` | 2 | -| `checkout_status_request` | 1 | -| `fetch_agent_request` | 1 | -| `file_explorer_request` | 1 | -| `read_project_config_request` | 1 | -| `workspace_setup_status_request` | 1 | - -Inbound by client: - -| client | count | top work | -| ----------------------------------------------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Electron `cid_d555...`, origin `http://localhost:8082` | 68 | `checkout_pr_status_request:18`, `project_icon_request:9`, `clear_agent_attention:6`, `fetch_agent_timeline_request:4`, `fetch_workspaces_request:3`, `fetch_agents_request:3`, `get_providers_snapshot_request:3` | -| HeadlessChrome `cid_d39...`, origin `http://localhost:8081` | 13 | `client_heartbeat:4`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` | -| local web `cid_a2b...`, origin `http://localhost:6767` | 13 | `client_heartbeat:4`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` | -| Android `cid_24c...`, origin `http://10.0.2.2:6767` | 11 | `client_heartbeat:2`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` | -| `cid_70d...`, host `0.0.0.0:6767` | 2 | `fetch_agents_request:2` | - -## Outbound Client Work - -Outbound session messages during the startup window: - -| message | count | -| ---------------------------------- | ----: | -| `providers_snapshot_update` | 129 | -| `workspace_update` | 81 | -| `checkout_status_update` | 76 | -| `agent_update` | 47 | -| `checkout_pr_status_response` | 18 | -| `fetch_agents_response` | 11 | -| `fetch_workspaces_response` | 9 | -| `get_providers_snapshot_response` | 9 | -| `project_icon_response` | 9 | -| `fetch_agent_timeline_response` | 7 | -| `list_terminals_response` | 5 | -| `terminals_changed` | 5 | -| `list_available_editors_response` | 2 | -| `subscribe_checkout_diff_response` | 2 | -| `checkout_status_response` | 1 | -| `fetch_agent_response` | 1 | -| `file_explorer_response` | 1 | -| `read_project_config_response` | 1 | -| `workspace_setup_status_response` | 1 | - -Provider snapshot updates were large and repeated: - -- Around lines 982-986: five `providers_snapshot_update` messages, each `215932` bytes. -- Around lines 997-1001: five `providers_snapshot_update` messages, each `215898` bytes. -- Around lines 1250-1254: five `providers_snapshot_update` messages, each `414735` bytes. - -## Slow Requests - -Slow requests logged during startup: - -| time | request | duration | client | line | -| -------------- | --------------------------------: | -------: | --------------------- | ---: | -| `16:04:29.702` | `fetch_agent_timeline_request` | 39372ms | HeadlessChrome | 1767 | -| `16:04:29.702` | `fetch_agent_timeline_request` | 39212ms | Electron | 1768 | -| `16:04:29.702` | `fetch_agent_timeline_request` | 38914ms | local web | 1769 | -| `16:04:33.665` | `checkout_pr_status_request` | 20181ms | Electron | 1776 | -| `16:04:08.109` | `subscribe_checkout_diff_request` | 17618ms | Electron | 565 | -| `16:04:29.702` | `fetch_agent_timeline_request` | 16216ms | Electron | 1770 | -| `16:04:06.624` | `fetch_agent_timeline_request` | 16134ms | Electron | 524 | -| `16:04:29.396` | `checkout_pr_status_request` | 15911ms | Electron | 1671 | -| `16:04:29.256` | `checkout_pr_status_request` | 15772ms | Electron | 1651 | -| `16:04:29.149` | `checkout_pr_status_request` | 15665ms | Electron | 1638 | -| `16:04:29.054` | `checkout_pr_status_request` | 15569ms | Electron | 1628 | -| `16:04:28.932` | `checkout_pr_status_request` | 15448ms | Electron | 1611 | -| `16:04:28.809` | `checkout_pr_status_request` | 15324ms | Electron | 1601 | -| `16:04:28.672` | `checkout_pr_status_request` | 15188ms | Electron | 1582 | -| `16:04:28.555` | `checkout_pr_status_request` | 15071ms | Electron | 1567 | -| `16:04:28.421` | `checkout_pr_status_request` | 14936ms | Electron | 1556 | -| `16:04:28.323` | `checkout_pr_status_request` | 14839ms | Electron | 1549 | -| `16:04:28.324` | `checkout_status_request` | 14839ms | Electron | 1550 | -| `16:04:28.189` | `checkout_pr_status_request` | 14705ms | Electron | 1536 | -| `16:04:29.634` | `fetch_agents_request` | 14590ms | `0.0.0.0:6767` client | 1759 | -| `16:04:28.006` | `checkout_pr_status_request` | 14522ms | Electron | 1526 | -| `16:04:27.628` | `checkout_pr_status_request` | 14143ms | Electron | 1496 | -| `16:04:27.061` | `checkout_pr_status_request` | 13576ms | Electron | 1405 | -| `16:04:29.645` | `fetch_agents_request` | 13384ms | `0.0.0.0:6767` client | 1762 | -| `16:04:02.812` | `fetch_agent_timeline_request` | 12321ms | Electron | 440 | -| `16:04:25.740` | `checkout_pr_status_request` | 12256ms | Electron | 1309 | -| `16:04:25.352` | `checkout_pr_status_request` | 11867ms | Electron | 1296 | -| `16:04:04.217` | `fetch_agent_timeline_request` | 11751ms | Android | 462 | -| `16:04:25.155` | `checkout_pr_status_request` | 11671ms | Electron | 1284 | -| `16:04:23.196` | `fetch_agent_request` | 9711ms | Electron | 1070 | -| `16:04:17.563` | `project_icon_request` | 4079ms | Electron | 877 | -| `16:03:53.022` | `list_available_editors_request` | 2533ms | Electron | 254 | -| `16:04:15.703` | `project_icon_request` | 2218ms | Electron | 824 | -| `16:04:15.696` | `project_icon_request` | 2211ms | Electron | 822 | -| `16:04:15.694` | `project_icon_request` | 2209ms | Electron | 820 | -| `16:04:15.103` | `file_explorer_request` | 1618ms | Electron | 806 | -| `16:04:14.107` | `list_terminals_request` | 621ms | Electron | 764 | -| `16:03:50.945` | `list_terminals_request` | 614ms | HeadlessChrome | 156 | - -The checkout PR requests are especially clustered: 18 Electron `checkout_pr_status_request` messages arrive together at `16:04:13.484`, lines 699-716. Their slow-request completions drain over the next ~20s, with `inflightRequests` dropping from 20 to 0. - -## Provider Findings - -### OpenCode - -OpenCode provider snapshot refresh had two timeouts: - -| time | line | cwd | error | -| -------------- | ---: | --------------------------- | --------------------------------------------- | -| `16:04:22.466` | 987 | `/Users/moboudra/dev/paseo` | `Timed out refreshing OpenCode after 30000ms` | -| `16:04:22.482` | 1002 | `/Users/moboudra` | `Timed out refreshing OpenCode after 30000ms` | - -These are provider snapshot failures, not OpenCode agent resume failures. - -The persisted OpenCode agent did resume: - -| time | line | event | -| -------------- | ---: | ----------------------------------------------------- | -| `16:04:24.183` | 1201 | `provider.opencode.subscribe.start` | -| `16:04:24.183` | 1202 | `provider.opencode.subscribe.ready` | -| `16:04:24.306` | 1214 | raw event `server.connected` | -| `16:04:25.933` | 1321 | `Agent resumed from persistence`, provider `opencode` | -| `16:04:34.197` | 1783 | raw event `server.heartbeat` | -| `16:04:44.200` | 1789 | raw event `server.heartbeat` | - -There are no `provider.opencode.subscribe.error` or OpenCode agent fatal errors in this slice. - -OpenCode-related git: - -- `~/dev/opencode` had 22 git commands. -- Four failed. -- The failed commands were branch graph probes for `ecosystem-paseo` versus `origin/ecosystem-paseo`. -- Those failures are git state/probe failures, not OpenCode provider process failures. - -### Codex - -Codex provider startup observations: - -- `provider.codex.spawn` appears multiple times for provider snapshot/config discovery. -- A persisted Codex agent resumes successfully at `16:04:06.357`, line 518. -- Debug logs show failed reads of Codex saved config defaults, but these are debug-level and do not become provider startup warnings/errors in this slice. -- There are unhandled Codex trace event types such as remote-control/status and thread/goal status, but no Codex timeout or fatal provider startup failure in this slice. - -### Claude - -Claude agents resume successfully: - -| time | line | client | agent | -| -------------- | ---: | -------- | -------------------------------------- | -| `16:04:02.540` | 434 | Electron | `f884552a-1383-4dba-8583-7ae0b6a62353` | -| `16:04:03.772` | 456 | Android | `0c89a057-05f2-4e23-9895-84c8e1952310` | - -## What Work The App Asked For - -The startup work visible in the app/server protocol is: - -- Workspace list/sidebar hydration: - - `fetch_workspaces_request`, 9 total. - - This asks for the workspace list sorted by `activity_at desc`, usually page limit 200. - - On the server this triggers workspace git observer sync and workspace update flushing. - -- Agent list and agent detail hydration: - - `fetch_agents_request`, 11 total. - - `fetch_agent_request`, 1 total. - - `fetch_agent_timeline_request`, 7 total. - - Timeline requests are among the slowest requests in this slice. - -- Checkout/PR status UI: - - `checkout_pr_status_request`, 18 total, all Electron. - - `checkout_status_request`, 1 total. - - `subscribe_checkout_diff_request`, 2 total. - - These correspond to git snapshot consumers and are clustered during Electron reconnect. - -- Provider/model/mode UI: - - `get_providers_snapshot_request`, 9 total. - - `providers_snapshot_update`, 129 outbound updates. - - OpenCode provider snapshot refresh times out twice during this flow. - -- Workspace chrome: - - `project_icon_request`, 9 total. - - `file_explorer_request`, 1 total. - -- Terminal panel: - - `list_terminals_request`, 5 total. - - `subscribe_terminals_request`, 5 total. - - `terminals_changed`, 5 outbound updates. - -- Attention state: - - `clear_agent_attention`, 6 total. - - Some failures appear while clearing attention for persisted agents, but these are not provider startup failures. - -## Concrete Waste-Looking Work, Without Optimizing Yet - -The log shows repeated work in these exact forms: - -- 444 git commands total, but only 14 complete before the first client hello. The rest are post-listen startup/client hydration work. -- Several workspaces get repeated full checkout snapshot patterns: - - three 33-command worktrees each get `3` root checks, `3` dirty checks, `3` upstream config probes, `9` ahead/behind probes, `12` ref checks, and `3` base divergence checks. - - three 27-command worktrees each get `3` root checks, `3` dirty checks, `6` upstream config probes, `6` ahead/behind probes, `6` ref checks, and `3` base divergence checks. - - `~/dev/paseo` gets `5` root checks, `5` dirty checks, `10` upstream config probes, and `10` ahead/behind probes. -- Electron sends 18 `checkout_pr_status_request` messages at the same timestamp, then they drain slowly over ~20s. -- Provider snapshot updates are broadcast very frequently: 129 outbound `providers_snapshot_update` messages, including large repeated payloads around 216KB and 415KB. -- OpenCode snapshot refresh times out twice after 30s, but the actual OpenCode agent connection/resume succeeds. - -Again, this section names repeated work observed in the startup. It does not claim which repetition should be removed. diff --git a/docs/unistyles.md b/docs/unistyles.md index 0c780dc57..d1d6a34d8 100644 --- a/docs/unistyles.md +++ b/docs/unistyles.md @@ -335,7 +335,7 @@ For paint-layer bugs, use high-contrast probes: 3. Screenshot the simulator and sample pixels to see which color fills the area. 4. Remove the probes before committing. -The welcome-screen investigation used this approach to prove the white layer was the `ScrollView` content container. Deep-dive evidence is in [welcome-theme-split-research.md](/Users/moboudra/.paseo/notes/welcome-theme-split-research.md). +The welcome-screen investigation used this approach to prove the white layer was the `ScrollView` content container. ## References @@ -349,4 +349,3 @@ The welcome-screen investigation used this approach to prove the white layer was - [GitHub issue #550: ScrollView sticky-header theme updates](https://github.com/jpudysz/react-native-unistyles/issues/550) - [GitHub issue #817: `UnistylesRuntime.themeName` does not re-render](https://github.com/jpudysz/react-native-unistyles/issues/817) - [GitHub issue #1030: `Image.tintColor` and native style update edge case](https://github.com/jpudysz/react-native-unistyles/issues/1030) -- [Local research note: welcome theme split](/Users/moboudra/.paseo/notes/welcome-theme-split-research.md) diff --git a/package-lock.json b/package-lock.json index 2967260f5..1f4b8ead0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37126,6 +37126,7 @@ "expo-system-ui": "~6.0.7", "expo-updates": "~29.0.12", "fast-deep-equal": "^3.1.3", + "htmlparser2": "^12.0.0", "i18next": "^26.3.0", "lucide-react-native": "^0.546.0", "markdown-it": "^10.0.0", @@ -37281,6 +37282,85 @@ "addons/*" ] }, + "packages/app/node_modules/dom-serializer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "packages/app/node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "packages/app/node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "packages/app/node_modules/domutils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "packages/app/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "packages/app/node_modules/expo-clipboard": { "version": "8.0.7", "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.7.tgz", @@ -37292,6 +37372,28 @@ "react-native": "*" } }, + "packages/app/node_modules/htmlparser2": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz", + "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "domutils": "^4.0.2", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, "packages/app/node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/packages/app/e2e/helpers/composer.ts b/packages/app/e2e/helpers/composer.ts index 01c3228bb..31f65cb86 100644 --- a/packages/app/e2e/helpers/composer.ts +++ b/packages/app/e2e/helpers/composer.ts @@ -165,14 +165,14 @@ export async function startRunningMockAgent( provider: "mock", cwd: repo.path, model: opts.model, - initialPrompt: opts.prompt, }); const agentUrl = `${buildHostWorkspaceRoute(serverId, repo.path)}?open=${encodeURIComponent(`agent:${agent.id}`)}`; await page.goto(agentUrl); + await expectComposerVisible(page); + await client.sendAgentMessage(agent.id, opts.prompt); await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({ timeout: 30_000, }); - await expectComposerVisible(page); return { client, repo }; } diff --git a/packages/app/e2e/helpers/pr-pane.ts b/packages/app/e2e/helpers/pr-pane.ts index ce93beeae..570480165 100644 --- a/packages/app/e2e/helpers/pr-pane.ts +++ b/packages/app/e2e/helpers/pr-pane.ts @@ -1,5 +1,5 @@ import { expect, type Page } from "@playwright/test"; -import { getStateLabel } from "@/git/pr-pane-data"; +import { getStateLabel } from "@/git/pull-request-panel/data"; export async function openPrPane(page: Page): Promise { await page.getByRole("button", { name: "Open explorer" }).click(); diff --git a/packages/app/package.json b/packages/app/package.json index 1b13eb18b..f2b36e402 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -81,6 +81,7 @@ "expo-system-ui": "~6.0.7", "expo-updates": "~29.0.12", "fast-deep-equal": "^3.1.3", + "htmlparser2": "^12.0.0", "i18next": "^26.3.0", "lucide-react-native": "^0.546.0", "markdown-it": "^10.0.0", diff --git a/packages/app/src/attachments/types.ts b/packages/app/src/attachments/types.ts index 3d373bd0f..2306caa47 100644 --- a/packages/app/src/attachments/types.ts +++ b/packages/app/src/attachments/types.ts @@ -41,14 +41,35 @@ export interface BrowserElementAttachment { formatted: string; } -export type ComposerAttachment = +export type PullRequestContextAttachmentKind = + | "github.pull_request_comment" + | "github.pull_request_review" + | "github.pull_request_check"; + +interface PullRequestContextAttachmentFields { + id: string; + title: string; + subtitle?: string; + text: string; + url?: string | null; +} + +export type PullRequestContextAttachment = + | ({ kind: "github.pull_request_comment" } & PullRequestContextAttachmentFields) + | ({ kind: "github.pull_request_review" } & PullRequestContextAttachmentFields) + | ({ kind: "github.pull_request_check" } & PullRequestContextAttachmentFields); + +export type UserComposerAttachment = | { kind: "image"; metadata: AttachmentMetadata } | { kind: "github_issue"; item: GitHubSearchItem } - | { kind: "github_pr"; item: GitHubSearchItem } + | { kind: "github_pr"; item: GitHubSearchItem }; + +export type WorkspaceComposerAttachment = | { kind: "browser_element"; attachment: BrowserElementAttachment; } + | PullRequestContextAttachment | { kind: "review"; attachment: Extract; @@ -56,15 +77,7 @@ export type ComposerAttachment = commentCount: number; }; -export type UserComposerAttachment = Exclude< - ComposerAttachment, - { kind: "review" } | { kind: "browser_element" } ->; - -export type WorkspaceComposerAttachment = Extract< - ComposerAttachment, - { kind: "review" } | { kind: "browser_element" } ->; +export type ComposerAttachment = UserComposerAttachment | WorkspaceComposerAttachment; export type AttachmentDataSource = | { kind: "bytes"; bytes: Uint8Array } diff --git a/packages/app/src/attachments/workspace-attachment-utils.test.ts b/packages/app/src/attachments/workspace-attachment-utils.test.ts new file mode 100644 index 000000000..5fd12cd34 --- /dev/null +++ b/packages/app/src/attachments/workspace-attachment-utils.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import type { ComposerAttachment, PullRequestContextAttachment } from "./types"; +import { + isWorkspaceAttachment, + userAttachmentsOnly, + workspaceAttachmentToSubmitAttachment, +} from "./workspace-attachment-utils"; + +function contextAttachment( + overrides: Partial = {}, +): PullRequestContextAttachment { + return { + kind: "github.pull_request_comment", + id: "comment-1", + title: "Comment · octocat", + subtitle: "Fix flaky build", + text: "GitHub pull request comment\n\nLooks good.", + url: "https://github.com/getpaseo/paseo/pull/42#issuecomment-1", + ...overrides, + }; +} + +describe("workspace attachment utilities", () => { + it("treats pull request context as a workspace attachment", () => { + expect(isWorkspaceAttachment(contextAttachment())).toBe(true); + }); + + it("strips context attachments from user draft attachments", () => { + const normalAttachment: ComposerAttachment = { + kind: "github_issue", + item: { + kind: "issue", + number: 12, + title: "Bug", + url: "https://github.com/getpaseo/paseo/issues/12", + state: "open", + body: "Bug report", + labels: [], + baseRefName: null, + headRefName: null, + }, + }; + + expect(userAttachmentsOnly([normalAttachment, contextAttachment()])).toEqual([ + normalAttachment, + ]); + }); + + it("serializes context attachments as protocol text attachments", () => { + expect(workspaceAttachmentToSubmitAttachment(contextAttachment())).toEqual({ + type: "text", + mimeType: "text/plain", + title: "Comment · octocat", + text: "GitHub pull request comment\n\nLooks good.", + }); + }); +}); diff --git a/packages/app/src/attachments/workspace-attachment-utils.ts b/packages/app/src/attachments/workspace-attachment-utils.ts index 1d7df81fe..8284be001 100644 --- a/packages/app/src/attachments/workspace-attachment-utils.ts +++ b/packages/app/src/attachments/workspace-attachment-utils.ts @@ -1,14 +1,29 @@ import type { ComposerAttachment, + PullRequestContextAttachment, UserComposerAttachment, WorkspaceComposerAttachment, } from "@/attachments/types"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; +export function isPullRequestContextAttachment( + attachment: ComposerAttachment | undefined, +): attachment is PullRequestContextAttachment { + return ( + attachment?.kind === "github.pull_request_comment" || + attachment?.kind === "github.pull_request_review" || + attachment?.kind === "github.pull_request_check" + ); +} + export function isWorkspaceAttachment( attachment: ComposerAttachment | undefined, ): attachment is WorkspaceComposerAttachment { - return attachment?.kind === "review" || attachment?.kind === "browser_element"; + return ( + attachment?.kind === "review" || + attachment?.kind === "browser_element" || + isPullRequestContextAttachment(attachment) + ); } export function userAttachmentsOnly( @@ -16,7 +31,9 @@ export function userAttachmentsOnly( ): UserComposerAttachment[] { return attachments.filter( (attachment): attachment is UserComposerAttachment => - attachment.kind !== "review" && attachment.kind !== "browser_element", + attachment.kind !== "review" && + attachment.kind !== "browser_element" && + !isPullRequestContextAttachment(attachment), ); } @@ -31,5 +48,13 @@ export function workspaceAttachmentToSubmitAttachment( text: attachment.attachment.formatted, }; } + if (isPullRequestContextAttachment(attachment)) { + return { + type: "text", + mimeType: "text/plain", + title: attachment.title, + text: attachment.text, + }; + } return attachment.kind === "review" ? attachment.attachment : null; } diff --git a/packages/app/src/attachments/workspace-attachments-store.test.ts b/packages/app/src/attachments/workspace-attachments-store.test.ts index 07b3a4d15..888a21d49 100644 --- a/packages/app/src/attachments/workspace-attachments-store.test.ts +++ b/packages/app/src/attachments/workspace-attachments-store.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { WorkspaceComposerAttachment } from "./types"; import { + appendWorkspaceAttachment, buildWorkspaceAttachmentScopeKey, resetWorkspaceAttachmentsStore, useWorkspaceAttachmentsStore, @@ -46,6 +47,16 @@ function reviewAttachment(body: string): WorkspaceComposerAttachment { }; } +function contextAttachment(id: string): WorkspaceComposerAttachment { + return { + kind: "github.pull_request_comment", + id, + title: "Comment · octocat", + text: "GitHub pull request comment\n\nLooks good.", + url: `https://github.com/getpaseo/paseo/pull/42#${id}`, + }; +} + describe("workspace attachments store", () => { it("scopes workspace attachments by server and workspace before cwd fallback", () => { expect( @@ -86,4 +97,45 @@ describe("workspace attachments store", () => { expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]).toBeUndefined(); }); + + it("appends unique context attachments without dropping other workspace attachments", () => { + const review = reviewAttachment("Please simplify this."); + const context = contextAttachment("comment-1"); + + expect(appendWorkspaceAttachment([review], context)).toEqual([review, context]); + }); + + it("dedupes repeated context attachments by provider, source, and id", () => { + const original = contextAttachment("comment-1"); + const replacement = { + ...contextAttachment("comment-1"), + title: "Comment · octocat updated", + text: "Updated text", + }; + + expect(appendWorkspaceAttachment([original], replacement)).toEqual([replacement]); + }); + + it("adds a workspace attachment against the current scope state", () => { + resetWorkspaceAttachmentsStore(); + const scopeKey = buildWorkspaceAttachmentScopeKey({ + serverId: "local", + workspaceId: "workspace-1", + cwd: "/repo", + }); + const review = reviewAttachment("Please simplify this."); + const context = contextAttachment("comment-1"); + + const addWorkspaceAttachment = useWorkspaceAttachmentsStore.getState().addWorkspaceAttachment; + useWorkspaceAttachmentsStore + .getState() + .setWorkspaceAttachments({ scopeKey, attachments: [review] }); + + addWorkspaceAttachment({ scopeKey, attachment: context }); + + expect(useWorkspaceAttachmentsStore.getState().attachmentsByScope[scopeKey]).toEqual([ + review, + context, + ]); + }); }); diff --git a/packages/app/src/attachments/workspace-attachments-store.ts b/packages/app/src/attachments/workspace-attachments-store.ts index faa8dbca7..3562bb087 100644 --- a/packages/app/src/attachments/workspace-attachments-store.ts +++ b/packages/app/src/attachments/workspace-attachments-store.ts @@ -19,6 +19,10 @@ interface WorkspaceAttachmentsStoreActions { scopeKey: string; attachments: readonly WorkspaceComposerAttachment[]; }) => void; + addWorkspaceAttachment: (input: { + scopeKey: string; + attachment: WorkspaceComposerAttachment; + }) => void; clearWorkspaceAttachments: (input: { scopeKey: string }) => void; } @@ -60,6 +64,35 @@ function areWorkspaceAttachmentsEqual( return left.every((attachment, index) => attachment === right[index]); } +function getContextAttachmentKey(attachment: WorkspaceComposerAttachment): string | null { + if ( + attachment.kind !== "github.pull_request_comment" && + attachment.kind !== "github.pull_request_review" && + attachment.kind !== "github.pull_request_check" + ) { + return null; + } + return JSON.stringify({ + kind: attachment.kind, + id: attachment.id, + }); +} + +export function appendWorkspaceAttachment( + current: readonly WorkspaceComposerAttachment[], + attachment: WorkspaceComposerAttachment, +): WorkspaceComposerAttachment[] { + const contextKey = getContextAttachmentKey(attachment); + if (contextKey === null) { + return [...current, attachment]; + } + + const next = current.filter( + (currentAttachment) => getContextAttachmentKey(currentAttachment) !== contextKey, + ); + return [...next, attachment]; +} + export const useWorkspaceAttachmentsStore = create()((set) => ({ attachmentsByScope: {}, setWorkspaceAttachments: ({ scopeKey, attachments }) => { @@ -84,6 +117,21 @@ export const useWorkspaceAttachmentsStore = create()( }; }); }, + addWorkspaceAttachment: ({ scopeKey, attachment }) => { + set((state) => { + const current = state.attachmentsByScope[scopeKey] ?? EMPTY_WORKSPACE_ATTACHMENTS; + const attachments = appendWorkspaceAttachment(current, attachment); + if (areWorkspaceAttachmentsEqual(current, attachments)) { + return state; + } + return { + attachmentsByScope: { + ...state.attachmentsByScope, + [scopeKey]: attachments, + }, + }; + }); + }, clearWorkspaceAttachments: ({ scopeKey }) => { set((state) => { if (!state.attachmentsByScope[scopeKey]) { diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 97ffe159d..4f566ecd3 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -13,9 +13,12 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { X } from "lucide-react-native"; import { useTranslation } from "react-i18next"; -import { GitHubIcon } from "@/components/icons/github-icon"; -import { PrPane } from "@/git/pr-pane"; -import { usePrPaneData } from "@/hooks/use-pr-pane-data"; +import { + formatPrTabLabel, + PullRequestPane, + PullRequestTabIcon, + usePrPaneData, +} from "@/git/pull-request-panel"; import { usePanelStore, selectIsFileExplorerOpen, @@ -33,6 +36,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useWindowControlsPadding } from "@/utils/desktop-window"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { isWeb } from "@/constants/platform"; +import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; const MIN_CHAT_WIDTH = 400; function logExplorerSidebar(_event: string, _details: Record): void {} @@ -433,7 +437,11 @@ function SidebarContent({ !isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab; const resolvedTab: ExplorerTab = requestedTab === "pr" && !hasPullRequest ? "changes" : requestedTab; - const prTabLabel = prPane.prNumber === null ? "" : `#${prPane.prNumber}`; + const prTabLabel = formatPrTabLabel(prPane.prNumber); + const workspaceAttachmentScopeKey = useMemo( + () => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }), + [serverId, workspaceId, workspaceRoot], + ); const headerStyle = useMemo( () => [styles.header, { paddingRight: padding.right }], @@ -470,7 +478,7 @@ function SidebarContent({ onTabPress={onTabPress} testID="explorer-tab-pr" > - )} - {resolvedTab === "pr" && prPane.data && } + {resolvedTab === "pr" && prPane.data && ( + + )} ); diff --git a/packages/app/src/components/file-pane.tsx b/packages/app/src/components/file-pane.tsx index e4bbf28e0..af2630083 100644 --- a/packages/app/src/components/file-pane.tsx +++ b/packages/app/src/components/file-pane.tsx @@ -1,31 +1,20 @@ -import React, { useCallback, useEffect, useMemo, useRef, type ReactNode } from "react"; +import React, { useEffect, useMemo, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; import type { FileReadResult } from "@getpaseo/client/internal/daemon-client"; -import Markdown, { - type ASTNode, - MarkdownIt, - type RenderRules, -} from "react-native-markdown-display"; import { ActivityIndicator, Image as RNImage, ScrollView as RNScrollView, Text, - type TextProps, - type TextStyle, View, - type ViewStyle, } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; -import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; -import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text"; +import { MarkdownRenderer } from "@/components/markdown/renderer"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; -import { openExternalUrl } from "@/utils/open-external-url"; import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; @@ -33,9 +22,6 @@ import { lineNumberGutterWidth } from "@/components/code-insets"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode"; import { isWeb } from "@/constants/platform"; -import { createMarkdownStyles } from "@/styles/markdown-styles"; -import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list"; -import { markdownNodeContainsType } from "@/utils/markdown-ast"; import type { AttachmentMetadata } from "@/attachments/types"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { persistAttachmentFromBytes } from "@/attachments/service"; @@ -60,8 +46,6 @@ interface FilePreviewBodyProps { imagePreviewUri: string | null; } -type MarkdownStyles = Record; - function trimNonEmpty(value: string | null | undefined): string | null { if (typeof value !== "string") { return null; @@ -132,299 +116,6 @@ function clampLineSelection(input: { return { lineStart, lineEnd: Math.max(lineStart, lineEnd) }; } -interface MarkdownInheritedTextProps { - inheritedStyles: TextStyle; - textStyle: TextStyle; - style?: TextStyle; - monoSurface?: boolean; - onPress?: TextProps["onPress"]; - accessibilityRole?: TextProps["accessibilityRole"]; - children: ReactNode; -} - -function MarkdownInheritedText({ - inheritedStyles, - textStyle, - style: overrideStyle, - monoSurface, - onPress, - accessibilityRole, - children, -}: MarkdownInheritedTextProps) { - const style = useMemo( - () => [inheritedStyles, textStyle, overrideStyle], - [inheritedStyles, textStyle, overrideStyle], - ); - return ( - - {children} - - ); -} - -interface MarkdownListItemContentProps { - contentStyle: ViewStyle; - children: ReactNode; -} - -const MARKDOWN_LIST_ITEM_CONTENT_FLEX: ViewStyle = { flex: 1, flexShrink: 1, minWidth: 0 }; -const EMPTY_TEXT_STYLE: TextStyle = {}; - -function MarkdownListItemContent({ contentStyle, children }: MarkdownListItemContentProps) { - const style = useMemo(() => [contentStyle, MARKDOWN_LIST_ITEM_CONTENT_FLEX], [contentStyle]); - return {children}; -} - -interface MarkdownListViewProps { - baseStyle: ViewStyle; - spacing: { marginTop: number; marginBottom: number }; - children: ReactNode; -} - -function MarkdownListView({ baseStyle, spacing, children }: MarkdownListViewProps) { - const style = useMemo(() => [baseStyle, spacing], [baseStyle, spacing]); - return {children}; -} - -interface FilePreviewMarkdownLinkProps { - href: string; - inheritedStyles: TextStyle; - linkStyle: TextStyle; - onLinkPress?: (url: string) => boolean; - children: ReactNode; -} - -function FilePreviewMarkdownLink({ - href, - inheritedStyles, - linkStyle, - onLinkPress, - children, -}: FilePreviewMarkdownLinkProps) { - const handlePress = useCallback(() => { - if (!href) return; - if (onLinkPress?.(href) === false) return; - void openExternalUrl(href); - }, [href, onLinkPress]); - - return ( - - {children} - - ); -} - -function getMarkdownLinkHref(node: ASTNode): string { - const href = node.attributes?.href; - return typeof href === "string" ? href : ""; -} - -function createFilePreviewMarkdownRules(): RenderRules { - return { - text: ( - node: ASTNode, - _children: ReactNode[], - _parent: ASTNode[], - styles: MarkdownStyles, - inheritedStyles: TextStyle = {}, - ) => ( - - {node.content} - - ), - textgroup: ( - node: ASTNode, - children: ReactNode[], - _parent: ASTNode[], - styles: MarkdownStyles, - inheritedStyles: TextStyle = {}, - ) => ( - - {children} - - ), - strong: ( - node: ASTNode, - children: ReactNode[], - _parent: ASTNode[], - styles: MarkdownStyles, - inheritedStyles: TextStyle = {}, - ) => ( - - {children} - - ), - em: ( - node: ASTNode, - children: ReactNode[], - _parent: ASTNode[], - styles: MarkdownStyles, - inheritedStyles: TextStyle = {}, - ) => ( - - {children} - - ), - s: ( - node: ASTNode, - children: ReactNode[], - _parent: ASTNode[], - styles: MarkdownStyles, - inheritedStyles: TextStyle = {}, - ) => ( - - {children} - - ), - hardbreak: (node: ASTNode) => {"\n"}, - softbreak: (node: ASTNode) => {"\n"}, - code_block: ( - node: ASTNode, - _children: ReactNode[], - _parent: ASTNode[], - styles: MarkdownStyles, - inheritedStyles: TextStyle = {}, - ) => ( - - ), - fence: ( - node: ASTNode, - _children: ReactNode[], - _parent: ASTNode[], - styles: MarkdownStyles, - inheritedStyles: TextStyle = {}, - ) => ( - - ), - code_inline: ( - node: ASTNode, - _children: ReactNode[], - _parent: ASTNode[], - styles: MarkdownStyles, - inheritedStyles: TextStyle = {}, - ) => ( - - {node.content ?? ""} - - ), - bullet_list: ( - node: ASTNode, - children: ReactNode[], - parent: ASTNode[], - styles: MarkdownStyles, - ) => ( - - {children} - - ), - ordered_list: ( - node: ASTNode, - children: ReactNode[], - parent: ASTNode[], - styles: MarkdownStyles, - ) => ( - - {children} - - ), - list_item: ( - node: ASTNode, - children: ReactNode[], - parent: ASTNode[], - styles: MarkdownStyles, - ) => { - const { isOrdered, marker } = getMarkdownListMarker(node, parent); - const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon; - const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content; - - return ( - - {marker} - {children} - - ); - }, - paragraph: ( - node: ASTNode, - children: ReactNode[], - _parent: ASTNode[], - styles: MarkdownStyles, - ) => ( - - {children} - - ), - link: ( - node: ASTNode, - children: ReactNode[], - _parent: ASTNode[], - styles: MarkdownStyles, - onLinkPress?: (url: string) => boolean, - ) => ( - - {children} - - ), - }; -} - const CodeLine = React.memo(function CodeLine({ tokens, lineNumber, @@ -506,9 +197,6 @@ function FilePreviewBody({ const { theme } = useUnistyles(); const { t } = useTranslation(); const filePath = location.path; - const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]); - const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []); - const markdownRules = useMemo(() => createFilePreviewMarkdownRules(), []); const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart; @@ -591,11 +279,7 @@ function FilePreviewBody({ scrollEventThrottle={16} showsVerticalScrollIndicator={!showDesktopWebScrollbar} > - - - {preview.content ?? ""} - - + {scrollbar.overlay} diff --git a/packages/app/src/components/markdown-text.android.tsx b/packages/app/src/components/markdown-text.android.tsx index dbaabd948..d2cb0864e 100644 --- a/packages/app/src/components/markdown-text.android.tsx +++ b/packages/app/src/components/markdown-text.android.tsx @@ -39,7 +39,7 @@ interface MarkdownParagraphViewProps { children: ReactNode; } -const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 }; +const MARKDOWN_PARAGRAPH_RESET: ViewStyle = {}; // Paragraph stays a , not a , for layout fidelity. RN Android's // text engine *does* accept inline View children (TextInlineViewPlaceholderSpan diff --git a/packages/app/src/components/markdown-text.ios.tsx b/packages/app/src/components/markdown-text.ios.tsx index 1e8d67da1..7fc07ae09 100644 --- a/packages/app/src/components/markdown-text.ios.tsx +++ b/packages/app/src/components/markdown-text.ios.tsx @@ -49,7 +49,7 @@ interface MarkdownParagraphViewProps { children: ReactNode; } -const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 }; +const MARKDOWN_PARAGRAPH_RESET: ViewStyle = {}; // iOS-only: paragraph wraps in UITextView so the entire paragraph is one // native text view. That's what unlocks cross-inline drag selection — handles diff --git a/packages/app/src/components/markdown-text.web.tsx b/packages/app/src/components/markdown-text.web.tsx index 0c9e4ceaa..8d5f9fa95 100644 --- a/packages/app/src/components/markdown-text.web.tsx +++ b/packages/app/src/components/markdown-text.web.tsx @@ -50,7 +50,7 @@ interface MarkdownParagraphViewProps { children: ReactNode; } -const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 }; +const MARKDOWN_PARAGRAPH_RESET: ViewStyle = {}; // Same shape as Android — paragraph is a View so block-level children (images) // keep their natural layout. Web text selection already spans nested inline diff --git a/packages/app/src/components/markdown/html-ish.test.ts b/packages/app/src/components/markdown/html-ish.test.ts new file mode 100644 index 000000000..15faec407 --- /dev/null +++ b/packages/app/src/components/markdown/html-ish.test.ts @@ -0,0 +1,312 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { normalizeHtmlishMarkdown, splitHtmlishMarkdown } from "./html-ish"; + +describe("splitHtmlishMarkdown", () => { + const inlineImageBody = [ + 'Priority Spoofed browser User-Agent allows access control bypass', + "", + "The middleware now trusts any browser-like User-Agent for unauthenticated requests.", + "", + "```ts", + 'const isBrowser = userAgent.includes("Mozilla");', + "```", + ].join("\n"); + + const multiDetailsBody = [ + "### Bot Review", + "", + "
", + "

Important Files Changed

", + "", + "- `packages/server/src/server/session.ts`", + "", + "
", + "", + "
", + "

Security Findings

", + "", + "No blocking findings.", + "", + "
", + "", + "", + 'Reviews (8): Last reviewed commit: “revert: undo parser” | Re-trigger Greptile', + ].join("\n"); + + it("classifies linked HTML images as generic inline images, not block markdown images", () => { + const [image, text] = splitHtmlishMarkdown(inlineImageBody); + + expect(image).toEqual({ + kind: "inlineImage", + alt: "Priority", + src: "https://example.com/assets/priority.svg?v=9", + flowsWithText: true, + }); + expect(text).toEqual({ + kind: "markdown", + text: [ + " Spoofed browser User-Agent allows access control bypass", + "", + "The middleware now trusts any browser-like User-Agent for unauthenticated requests.", + "", + "```ts", + 'const isBrowser = userAgent.includes("Mozilla");', + "```", + ].join("\n"), + }); + }); + + it("flags both images as flowsWithText when two inline images precede title text on the same line", () => { + const twoImageBody = [ + 'Priority Security **Title text here**', + "", + "Body paragraph.", + ].join("\n"); + + const parts = splitHtmlishMarkdown(twoImageBody); + const [first, second, third] = parts; + + expect(first).toEqual({ + kind: "inlineImage", + alt: "Priority", + src: "https://example.com/priority.svg", + flowsWithText: true, + }); + expect(second).toEqual({ kind: "markdown", text: " " }); + expect(third).toEqual({ + kind: "inlineImage", + alt: "Security", + src: "https://example.com/security.svg", + flowsWithText: true, + }); + }); + + it("keeps safe width and height attributes on inline images", () => { + expect( + splitHtmlishMarkdown( + 'Small', + ), + ).toEqual([ + { + kind: "inlineImage", + alt: "Small", + src: "https://example.com/small.svg", + width: 18, + height: 12, + }, + ]); + }); + + it("keeps safe non-empty image links", () => { + expect( + splitHtmlishMarkdown( + 'Small', + ), + ).toEqual([ + { + kind: "inlineImage", + alt: "Small", + src: "https://example.com/small.svg", + href: "https://example.com/details", + }, + ]); + }); + + it("preserves inline image parts inside details bodies", () => { + expect( + splitHtmlishMarkdown( + '
ImagesIcon Inline text
', + ), + ).toEqual([ + { + kind: "details", + summary: "Images", + body: "Inline text", + bodyParts: [ + { + kind: "inlineImage", + alt: "Icon", + src: "https://example.com/icon.svg", + href: "https://example.com/page", + flowsWithText: true, + }, + { kind: "markdown", text: " Inline text" }, + ], + }, + ]); + }); + + it("does not flag standalone inline images as flowing with text", () => { + expect( + splitHtmlishMarkdown('Shot\n\nCaption below'), + ).toEqual([ + { kind: "inlineImage", alt: "Shot", src: "https://example.com/shot.png" }, + { kind: "markdown", text: "\n\nCaption below" }, + ]); + }); + + it("does not flag mid-line inline images as flowing with text", () => { + const [, image] = splitHtmlishMarkdown( + 'Before Icon after', + ); + + expect(image).toEqual({ + kind: "inlineImage", + alt: "Icon", + src: "https://example.com/icon.png", + }); + }); + + it("leaves ordinary markdown images on the markdown path", () => { + const source = "![Ordinary](https://example.com/full-size.png)"; + + expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]); + }); + + it("leaves unsafe HTML image sources inert", () => { + const source = 'Bad'; + + expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]); + }); + + it("removes raw image anchor and image tags from rendered markdown text", () => { + const text = splitHtmlishMarkdown(inlineImageBody) + .map((part) => (part.kind === "markdown" ? part.text : "")) + .join(""); + + expect(text).not.toContain(""); + }); + + it("unwraps sub text and strips HTML comments", () => { + const parts = splitHtmlishMarkdown(multiDetailsBody); + const tail = parts.at(-1); + + expect(tail).toEqual({ + kind: "markdown", + text: "\n\nReviews (8): Last reviewed commit: “revert: undo parser” | [Re-trigger Greptile](https://app.greptile.com)", + }); + }); + + it("does not leak stray closing details tags across multiple details blocks", () => { + const renderedText = splitHtmlishMarkdown(multiDetailsBody) + .map((part) => { + if (part.kind === "markdown") return part.text; + if (part.kind === "details") return `${part.summary}\n${part.body}`; + return part.alt; + }) + .join("\n"); + + expect(renderedText).not.toContain(""); + expect(renderedText).not.toContain("