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("