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
This commit is contained in:
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.<branch>.remote` fails for branches without configured upstream.
|
||||
- Missing remote branch graph: `rev-list --count <branch>..origin/<branch>` 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.
|
||||
+1
-2
@@ -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)
|
||||
|
||||
Generated
+102
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<AgentAttachment, { type: "review" }>;
|
||||
@@ -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 }
|
||||
|
||||
@@ -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> = {},
|
||||
): 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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<WorkspaceAttachmentsStore>()((set) => ({
|
||||
attachmentsByScope: {},
|
||||
setWorkspaceAttachments: ({ scopeKey, attachments }) => {
|
||||
@@ -84,6 +117,21 @@ export const useWorkspaceAttachmentsStore = create<WorkspaceAttachmentsStore>()(
|
||||
};
|
||||
});
|
||||
},
|
||||
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]) {
|
||||
|
||||
@@ -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<string, unknown>): 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"
|
||||
>
|
||||
<GitHubIcon
|
||||
<PullRequestTabIcon
|
||||
size={13}
|
||||
color={
|
||||
resolvedTab === "pr" ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
@@ -507,7 +515,14 @@ function SidebarContent({
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
)}
|
||||
{resolvedTab === "pr" && prPane.data && <PrPane data={prPane.data} />}
|
||||
{resolvedTab === "pr" && prPane.data && (
|
||||
<PullRequestPane
|
||||
serverId={serverId}
|
||||
cwd={workspaceRoot}
|
||||
data={prPane.data}
|
||||
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -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<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
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 (
|
||||
<MarkdownTextSpan
|
||||
monoSurface={monoSurface}
|
||||
onPress={onPress}
|
||||
accessibilityRole={accessibilityRole}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</MarkdownTextSpan>
|
||||
);
|
||||
}
|
||||
|
||||
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 <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
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 <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<MarkdownInheritedText
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={linkStyle}
|
||||
accessibilityRole="link"
|
||||
onPress={handlePress}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
);
|
||||
}
|
||||
|
||||
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 = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.text}
|
||||
>
|
||||
{node.content}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
textgroup: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.textgroup}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
strong: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.strong}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
em: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.em}>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
s: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.s}>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
code_block: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={null}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_block}
|
||||
/>
|
||||
),
|
||||
fence: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={node.sourceInfo}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.fence}
|
||||
/>
|
||||
),
|
||||
code_inline: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_inline}
|
||||
monoSurface
|
||||
>
|
||||
{node.content ?? ""}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
bullet_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.bullet_list}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
),
|
||||
ordered_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.ordered_list}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
),
|
||||
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 (
|
||||
<View key={node.key} style={styles.list_item}>
|
||||
<Text style={iconStyle}>{marker}</Text>
|
||||
<MarkdownListItemContent contentStyle={contentStyle}>{children}</MarkdownListItemContent>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
paragraph: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownParagraphView
|
||||
key={node.key}
|
||||
paragraphStyle={styles.paragraph}
|
||||
containsImage={markdownNodeContainsType(node, "image")}
|
||||
>
|
||||
{children}
|
||||
</MarkdownParagraphView>
|
||||
),
|
||||
link: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
onLinkPress?: (url: string) => boolean,
|
||||
) => (
|
||||
<FilePreviewMarkdownLink
|
||||
key={node.key}
|
||||
href={getMarkdownLinkHref(node)}
|
||||
inheritedStyles={EMPTY_TEXT_STYLE}
|
||||
linkStyle={styles.link}
|
||||
onLinkPress={onLinkPress}
|
||||
>
|
||||
{children}
|
||||
</FilePreviewMarkdownLink>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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}
|
||||
>
|
||||
<AppearanceStyleBoundary>
|
||||
<Markdown style={markdownStyles} rules={markdownRules} markdownit={markdownParser}>
|
||||
{preview.content ?? ""}
|
||||
</Markdown>
|
||||
</AppearanceStyleBoundary>
|
||||
<MarkdownRenderer text={preview.content ?? ""} />
|
||||
</RNScrollView>
|
||||
{scrollbar.overlay}
|
||||
</View>
|
||||
|
||||
@@ -39,7 +39,7 @@ interface MarkdownParagraphViewProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 };
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = {};
|
||||
|
||||
// Paragraph stays a <View>, not a <Text>, for layout fidelity. RN Android's
|
||||
// text engine *does* accept inline View children (TextInlineViewPlaceholderSpan
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
'<a href="#"><img alt="Priority" src="https://example.com/assets/priority.svg?v=9" align="top"></a> 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",
|
||||
"",
|
||||
"<details>",
|
||||
"<summary><h3>Important Files Changed</h3></summary>",
|
||||
"",
|
||||
"- `packages/server/src/server/session.ts`",
|
||||
"",
|
||||
"</details>",
|
||||
"",
|
||||
"<details>",
|
||||
"<summary><h3>Security Findings</h3></summary>",
|
||||
"",
|
||||
"No blocking findings.",
|
||||
"",
|
||||
"</details>",
|
||||
"",
|
||||
"<!-- bot_other_comments_section -->",
|
||||
'<sub>Reviews (8): Last reviewed commit: “revert: undo parser” | <a href="https://app.greptile.com">Re-trigger Greptile</a></sub>',
|
||||
].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 = [
|
||||
'<a href="#"><img alt="Priority" src="https://example.com/priority.svg" align="top"></a> <a href="#"><img alt="Security" src="https://example.com/security.svg" align="top"></a> **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(
|
||||
'<img alt="Small" src="https://example.com/small.svg" width="18" height="12">',
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "inlineImage",
|
||||
alt: "Small",
|
||||
src: "https://example.com/small.svg",
|
||||
width: 18,
|
||||
height: 12,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps safe non-empty image links", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
'<a href="https://example.com/details"><img alt="Small" src="https://example.com/small.svg"></a>',
|
||||
),
|
||||
).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(
|
||||
'<details><summary>Images</summary><a href="https://example.com/page"><img alt="Icon" src="https://example.com/icon.svg"></a> Inline text</details>',
|
||||
),
|
||||
).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('<img alt="Shot" src="https://example.com/shot.png">\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 <img alt="Icon" src="https://example.com/icon.png"> 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 = "";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("leaves unsafe HTML image sources inert", () => {
|
||||
const source = '<img alt="Bad" src="javascript:alert(1)">';
|
||||
|
||||
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("<a ");
|
||||
expect(text).not.toContain("<img ");
|
||||
expect(text).not.toContain("</a>");
|
||||
});
|
||||
|
||||
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("</details>");
|
||||
expect(renderedText).not.toContain("<!--");
|
||||
expect(splitHtmlishMarkdown(multiDetailsBody).slice(0, 4)).toMatchObject([
|
||||
{ kind: "markdown", text: "### Bot Review\n\n" },
|
||||
{ kind: "details", summary: "Important Files Changed" },
|
||||
{ kind: "markdown", text: "\n\n" },
|
||||
{ kind: "details", summary: "Security Findings" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves paragraph boundaries around title, prose, and code block", () => {
|
||||
const text = splitHtmlishMarkdown(inlineImageBody)
|
||||
.map((part) => (part.kind === "markdown" ? part.text : ""))
|
||||
.join("");
|
||||
|
||||
expect(text).toContain(
|
||||
"Spoofed browser User-Agent allows access control bypass\n\nThe middleware now trusts",
|
||||
);
|
||||
expect(text).toContain("unauthenticated requests.\n\n```ts\nconst isBrowser");
|
||||
});
|
||||
|
||||
it("keeps product-specific image language out of parser and renderer logic", () => {
|
||||
const parserSource = readFileSync(new URL("./html-ish.ts", import.meta.url), "utf8");
|
||||
const rendererSource = readFileSync(new URL("./renderer.tsx", import.meta.url), "utf8");
|
||||
const productionSource = `${parserSource}\n${rendererSource}`;
|
||||
|
||||
expect(productionSource).not.toMatch(/Greptile|badge|P0|P1|P2|greptile-static-assets/i);
|
||||
});
|
||||
|
||||
it("parses Greptile-style details blocks", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
"<details><summary><h3>Greptile Summary</h3></summary>Body markdown</details>",
|
||||
),
|
||||
).toEqual([{ kind: "details", summary: "Greptile Summary", body: "Body markdown" }]);
|
||||
});
|
||||
|
||||
it("keeps details inside fenced code as literal markdown", () => {
|
||||
const source = "```html\n<details><summary>Example</summary>Body</details>\n```";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("keeps details inside inline code as literal markdown", () => {
|
||||
const source = "Use `<details><summary>Example</summary>Body</details>` as an example.";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("still parses normal details outside code", () => {
|
||||
expect(splitHtmlishMarkdown("`code`\n<details><summary>Real</summary>Body</details>")).toEqual([
|
||||
{ kind: "markdown", text: "`code`\n" },
|
||||
{ kind: "details", summary: "Real", body: "Body" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves text before and after details blocks", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown("Before\n<details><summary>More</summary>Hidden</details>\nAfter"),
|
||||
).toEqual([
|
||||
{ kind: "markdown", text: "Before\n" },
|
||||
{ kind: "details", summary: "More", body: "Hidden" },
|
||||
{ kind: "markdown", text: "\nAfter" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses multiple details blocks", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
"<details><summary>One</summary>A</details><details><summary>Two</summary>B</details>",
|
||||
),
|
||||
).toEqual([
|
||||
{ kind: "details", summary: "One", body: "A" },
|
||||
{ kind: "details", summary: "Two", body: "B" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes br and simple code tags into markdown", () => {
|
||||
expect(normalizeHtmlishMarkdown("Line 1<br/>Line 2 <code>safe-value</code>")).toBe(
|
||||
"Line 1\nLine 2 `safe-value`",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves complex code tags inert instead of parsing HTML", () => {
|
||||
expect(normalizeHtmlishMarkdown('<code onclick="evil()"><script>x</script></code>')).toBe(
|
||||
'<code onclick="evil()"><script>x</script></code>',
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to inert markdown when details are unclosed", () => {
|
||||
const source = "<details><summary>Open</summary>Still open";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("falls back to inert markdown when summary is missing", () => {
|
||||
const source = "<details>Hidden</details>";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("does not execute or render unknown HTML as structured content", () => {
|
||||
const source =
|
||||
'<script>alert(1)</script><details onclick="evil()"><summary>Safe</summary><iframe src="x"></iframe></details>';
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{ kind: "markdown", text: "<script>alert(1)</script>" },
|
||||
{ kind: "details", summary: "Safe", body: '<iframe src="x"></iframe>' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,705 @@
|
||||
import { Parser } from "htmlparser2";
|
||||
|
||||
export interface MarkdownTextPart {
|
||||
kind: "markdown";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface MarkdownDetailsPart {
|
||||
kind: "details";
|
||||
summary: string;
|
||||
body: string;
|
||||
bodyParts?: MarkdownDisplayPart[];
|
||||
}
|
||||
|
||||
export interface MarkdownInlineImagePart {
|
||||
kind: "inlineImage";
|
||||
alt: string;
|
||||
src: string;
|
||||
href?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** The image starts a line and text continues on that same line. */
|
||||
flowsWithText?: boolean;
|
||||
}
|
||||
|
||||
export type MarkdownDisplayPart = MarkdownTextPart | MarkdownDetailsPart | MarkdownInlineImagePart;
|
||||
|
||||
const FENCE_LINE_RE = /^ {0,3}([`~]{3,})[^\n\r]*(?:\r?\n|$)/gm;
|
||||
const BACKTICK_RUN_RE = /`+/g;
|
||||
const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i;
|
||||
const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i;
|
||||
const VOID_HTML_TAGS = new Set(["br", "img"]);
|
||||
|
||||
interface ProtectedMarkdownRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface MarkdownImageDimensions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface HtmlTextToken {
|
||||
kind: "text";
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface HtmlCommentToken {
|
||||
kind: "comment";
|
||||
}
|
||||
|
||||
interface HtmlTagToken {
|
||||
kind: "tag";
|
||||
name: string;
|
||||
closing: boolean;
|
||||
selfClosing: boolean;
|
||||
attributes: Record<string, string>;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
type HtmlToken = HtmlTextToken | HtmlCommentToken | HtmlTagToken;
|
||||
|
||||
interface InlineImageParseResult {
|
||||
part: MarkdownInlineImagePart;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface MarkdownDelimiterMatch {
|
||||
index: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface HtmlishTokenParser {
|
||||
write(chunk: string): void;
|
||||
skip(length: number): void;
|
||||
end(): void;
|
||||
}
|
||||
|
||||
export function splitHtmlishMarkdown(source: string): MarkdownDisplayPart[] {
|
||||
return splitHtmlishTokens(tokenizeHtmlishMarkdown(source));
|
||||
}
|
||||
|
||||
function splitHtmlishTokens(tokens: HtmlToken[]): MarkdownDisplayPart[] {
|
||||
const parts: MarkdownDisplayPart[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < tokens.length) {
|
||||
const token = tokens[cursor];
|
||||
if (isOpenTag(token, "details")) {
|
||||
const closeIndex = findMatchingClose(tokens, cursor, "details");
|
||||
if (closeIndex !== null) {
|
||||
const details = parseDetailsTokens(tokens.slice(cursor + 1, closeIndex));
|
||||
if (details) {
|
||||
parts.push(details);
|
||||
cursor = closeIndex + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inlineImage = parseInlineImageAt(tokens, cursor);
|
||||
if (inlineImage) {
|
||||
parts.push(
|
||||
flowsWithFollowingText(tokens, cursor, inlineImage.end)
|
||||
? { ...inlineImage.part, flowsWithText: true }
|
||||
: inlineImage.part,
|
||||
);
|
||||
cursor = inlineImage.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextDetailsIndex = findNextOpenTag(tokens, cursor + 1, "details");
|
||||
const nextInlineImageIndex = findNextInlineImageIndex(tokens, cursor + 1);
|
||||
const end = Math.min(nextDetailsIndex ?? tokens.length, nextInlineImageIndex ?? tokens.length);
|
||||
appendMarkdownPart(parts, renderInlineTokens(tokens.slice(cursor, end)));
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function parseInlineImageAt(tokens: HtmlToken[], start: number): InlineImageParseResult | null {
|
||||
const token = tokens[start];
|
||||
if (token?.kind !== "tag" || token.closing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (token.name === "img") {
|
||||
const image = imageTokenToInlineImage(token, undefined);
|
||||
return image ? { part: image, end: start + 1 } : null;
|
||||
}
|
||||
|
||||
if (token.name !== "a") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const closeIndex = findMatchingClose(tokens, start, "a");
|
||||
if (closeIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const image = getSingleImageChild(tokens.slice(start + 1, closeIndex));
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inlineImage = imageTokenToInlineImage(image, safeHref(token.attributes.href));
|
||||
return inlineImage ? { part: inlineImage, end: closeIndex + 1 } : null;
|
||||
}
|
||||
|
||||
function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number): boolean {
|
||||
const previous = tokens[start - 1];
|
||||
// At line start if nothing precedes this image, or the preceding token ends with only
|
||||
// whitespace since the last newline (covers a bare space between two images on the same line).
|
||||
const atLineStart =
|
||||
previous === undefined ||
|
||||
(previous.kind === "text" && /(?:^|[\n\r])[ \t]*$/.test(previous.value));
|
||||
if (!atLineStart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Scan forward past same-line whitespace-only text tokens and inline images to find
|
||||
// the first substantive text token, without crossing a newline.
|
||||
let cursor = end;
|
||||
while (cursor < tokens.length) {
|
||||
const token = tokens[cursor];
|
||||
if (token === undefined) {
|
||||
break;
|
||||
}
|
||||
if (token.kind === "text") {
|
||||
const sameLine = token.value.split(/\r?\n/, 1)[0] ?? "";
|
||||
if (sameLine.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
// Whitespace-only on this line — keep scanning only if no newline was crossed.
|
||||
if (token.value.includes("\n") || token.value.includes("\r")) {
|
||||
return false;
|
||||
}
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
// An inline image tag — skip over it (the image itself and its possible wrapping close tag).
|
||||
if (token.kind === "tag") {
|
||||
const imageResult = parseInlineImageAt(tokens, cursor);
|
||||
if (imageResult) {
|
||||
cursor = imageResult.end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findNextInlineImageIndex(tokens: HtmlToken[], start: number): number | null {
|
||||
for (let index = start; index < tokens.length; index += 1) {
|
||||
if (parseInlineImageAt(tokens, index)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendMarkdownPart(parts: MarkdownDisplayPart[], text: string): void {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
const previous = parts.at(-1);
|
||||
if (previous?.kind === "markdown") {
|
||||
previous.text += text;
|
||||
return;
|
||||
}
|
||||
parts.push({ kind: "markdown", text });
|
||||
}
|
||||
|
||||
function parseDetailsTokens(tokens: HtmlToken[]): MarkdownDetailsPart | null {
|
||||
const summaryOpenIndex = findNextOpenTag(tokens, 0, "summary");
|
||||
if (summaryOpenIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summaryCloseIndex = findMatchingClose(tokens, summaryOpenIndex, "summary");
|
||||
if (summaryCloseIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summaryTokens = tokens.slice(summaryOpenIndex + 1, summaryCloseIndex);
|
||||
const bodyTokens = [...tokens.slice(0, summaryOpenIndex), ...tokens.slice(summaryCloseIndex + 1)];
|
||||
const summary = renderSummaryTokens(summaryTokens).trim();
|
||||
if (!summary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bodyParts = splitHtmlishTokens(bodyTokens);
|
||||
const body = renderBodyText(bodyParts);
|
||||
|
||||
return {
|
||||
kind: "details",
|
||||
summary,
|
||||
body: body.trim(),
|
||||
...(bodyParts.some((part) => part.kind !== "markdown")
|
||||
? { bodyParts: trimBodyParts(bodyParts) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function renderBodyText(parts: MarkdownDisplayPart[]): string {
|
||||
return parts.map((part) => (part.kind === "markdown" ? part.text : "")).join("");
|
||||
}
|
||||
|
||||
function trimBodyParts(parts: MarkdownDisplayPart[]): MarkdownDisplayPart[] {
|
||||
const trimmed = [...parts];
|
||||
const first = trimmed[0];
|
||||
if (first?.kind === "markdown") {
|
||||
first.text = first.text.trimStart();
|
||||
}
|
||||
const last = trimmed.at(-1);
|
||||
if (last?.kind === "markdown") {
|
||||
last.text = last.text.trimEnd();
|
||||
}
|
||||
return trimmed.filter((part) => part.kind !== "markdown" || part.text.length > 0);
|
||||
}
|
||||
|
||||
function renderSummaryTokens(tokens: HtmlToken[]): string {
|
||||
return stripSingleHeadingWrapper(renderInlineTokens(tokens));
|
||||
}
|
||||
|
||||
function stripSingleHeadingWrapper(text: string): string {
|
||||
const tokens = tokenizeHtmlishMarkdown(text.trim());
|
||||
if (tokens.length < 3) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const first = tokens[0];
|
||||
const last = tokens.at(-1);
|
||||
if (!isHeadingTag(first) || !isClosingTag(last, first.name)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return renderInlineTokens(tokens.slice(1, -1));
|
||||
}
|
||||
|
||||
function renderInlineTokens(tokens: HtmlToken[]): string {
|
||||
let output = "";
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
if (token.kind === "text") {
|
||||
output += token.value;
|
||||
continue;
|
||||
}
|
||||
if (token.kind === "comment" || token.closing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.name === "br") {
|
||||
output += "\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.name === "img") {
|
||||
output += renderImageToken(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
const closeIndex = token.selfClosing ? null : findMatchingClose(tokens, index, token.name);
|
||||
if (closeIndex === null) {
|
||||
output += renderUnknownTag(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
const children = tokens.slice(index + 1, closeIndex);
|
||||
if (token.name === "a") {
|
||||
output += renderLinkToken(token, children);
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
if (token.name === "sub") {
|
||||
output += renderInlineTokens(children);
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
if (token.name === "code" && children.every((child) => child.kind === "text")) {
|
||||
output += `\`${renderInlineTokens(children)}\``;
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
const rawTag = token.raw;
|
||||
const tagName = token.name;
|
||||
if (isHeadingTag(token)) {
|
||||
output += renderInlineTokens(children);
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
output += `${rawTag}${renderInlineTokens(children)}</${tagName}>`;
|
||||
index = closeIndex;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function renderImageToken(token: HtmlTagToken): string {
|
||||
const image = imageTokenToInlineImage(token, undefined);
|
||||
if (!image) {
|
||||
return token.raw;
|
||||
}
|
||||
|
||||
return ``;
|
||||
}
|
||||
|
||||
function renderLinkToken(token: HtmlTagToken, children: HtmlToken[]): string {
|
||||
const imageOnly = getSingleImageChild(children);
|
||||
if (imageOnly) {
|
||||
return renderImageToken(imageOnly);
|
||||
}
|
||||
|
||||
const label = renderInlineTokens(children);
|
||||
const href = token.attributes.href ?? "";
|
||||
if (!label || !SAFE_LINK_HREF_RE.test(href) || href === "#") {
|
||||
return label;
|
||||
}
|
||||
|
||||
return `[${label}](${href})`;
|
||||
}
|
||||
|
||||
function imageTokenToInlineImage(
|
||||
token: HtmlTagToken,
|
||||
href: string | undefined,
|
||||
): MarkdownInlineImagePart | null {
|
||||
const src = token.attributes.src ?? "";
|
||||
if (!SAFE_IMAGE_SRC_RE.test(src)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "inlineImage",
|
||||
alt: token.attributes.alt ?? "",
|
||||
src,
|
||||
...(href ? { href } : {}),
|
||||
...parseImageDimensions(token.attributes),
|
||||
};
|
||||
}
|
||||
|
||||
function parseImageDimensions(attributes: Record<string, string>): MarkdownImageDimensions {
|
||||
return {
|
||||
...parseImageDimension("width", attributes.width),
|
||||
...parseImageDimension("height", attributes.height),
|
||||
};
|
||||
}
|
||||
|
||||
function parseImageDimension(key: "width" | "height", value: string | undefined) {
|
||||
if (!value || !/^\d+(?:\.\d+)?$/.test(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? { [key]: parsed } : {};
|
||||
}
|
||||
|
||||
function safeHref(href: string | undefined): string | undefined {
|
||||
if (!href || href === "#" || !SAFE_LINK_HREF_RE.test(href)) {
|
||||
return undefined;
|
||||
}
|
||||
return href;
|
||||
}
|
||||
|
||||
function getSingleImageChild(tokens: HtmlToken[]): HtmlTagToken | null {
|
||||
const visible = tokens.filter((token) => token.kind !== "comment" && !isWhitespaceText(token));
|
||||
return visible.length === 1 && isOpenTag(visible[0], "img") ? visible[0] : null;
|
||||
}
|
||||
|
||||
function renderUnknownTag(token: HtmlTagToken): string {
|
||||
if (token.name === "script" || token.name === "style") {
|
||||
return "";
|
||||
}
|
||||
return token.raw;
|
||||
}
|
||||
|
||||
function escapeMarkdownImageAlt(value: string): string {
|
||||
return value.replace(/]/g, "\\]");
|
||||
}
|
||||
|
||||
function findNextOpenTag(tokens: HtmlToken[], start: number, name: string): number | null {
|
||||
for (let index = start; index < tokens.length; index += 1) {
|
||||
if (isOpenTag(tokens[index], name)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findMatchingClose(tokens: HtmlToken[], openIndex: number, name: string): number | null {
|
||||
let depth = 1;
|
||||
for (let index = openIndex + 1; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (isOpenTag(token, name) && !token.selfClosing) {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if (isClosingTag(token, name)) {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isOpenTag(token: HtmlToken | undefined, name: string): token is HtmlTagToken {
|
||||
return token?.kind === "tag" && token.name === name && !token.closing;
|
||||
}
|
||||
|
||||
function isClosingTag(token: HtmlToken | undefined, name: string): token is HtmlTagToken {
|
||||
return token?.kind === "tag" && token.name === name && token.closing;
|
||||
}
|
||||
|
||||
function isHeadingTag(token: HtmlToken | undefined): token is HtmlTagToken {
|
||||
return token?.kind === "tag" && isHeadingTagName(token.name) && !token.closing;
|
||||
}
|
||||
|
||||
function isHeadingTagName(name: string): boolean {
|
||||
return (
|
||||
name === "h1" ||
|
||||
name === "h2" ||
|
||||
name === "h3" ||
|
||||
name === "h4" ||
|
||||
name === "h5" ||
|
||||
name === "h6"
|
||||
);
|
||||
}
|
||||
|
||||
function isWhitespaceText(token: HtmlToken): boolean {
|
||||
return token.kind === "text" && token.value.trim() === "";
|
||||
}
|
||||
|
||||
function tokenizeHtmlishMarkdown(source: string): HtmlToken[] {
|
||||
const protectedRanges = getProtectedMarkdownRanges(source);
|
||||
const tokens: HtmlToken[] = [];
|
||||
const parser = createHtmlishTokenParser(source, tokens);
|
||||
let cursor = 0;
|
||||
|
||||
for (const range of protectedRanges) {
|
||||
parser.write(source.slice(cursor, range.start));
|
||||
tokens.push({ kind: "text", value: source.slice(range.start, range.end) });
|
||||
parser.skip(range.end - range.start);
|
||||
cursor = range.end;
|
||||
}
|
||||
|
||||
parser.write(source.slice(cursor));
|
||||
parser.end();
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishTokenParser {
|
||||
let stripNextLeadingLineBreak = false;
|
||||
let skippedLength = 0;
|
||||
let parser!: Parser;
|
||||
parser = new Parser(
|
||||
{
|
||||
onopentag(name, attributes, isImplied) {
|
||||
if (isImplied) {
|
||||
return;
|
||||
}
|
||||
const selfClosing = VOID_HTML_TAGS.has(name);
|
||||
tokens.push({
|
||||
kind: "tag",
|
||||
name,
|
||||
closing: false,
|
||||
selfClosing,
|
||||
attributes,
|
||||
raw: renderStartTag(name, attributes),
|
||||
});
|
||||
},
|
||||
onclosetag(name, isImplied) {
|
||||
if (isImplied || VOID_HTML_TAGS.has(name)) {
|
||||
return;
|
||||
}
|
||||
tokens.push({
|
||||
kind: "tag",
|
||||
name,
|
||||
closing: true,
|
||||
selfClosing: false,
|
||||
attributes: {},
|
||||
raw: `</${name}>`,
|
||||
});
|
||||
},
|
||||
ontext(value) {
|
||||
if (stripNextLeadingLineBreak) {
|
||||
stripNextLeadingLineBreak = false;
|
||||
value = stripLeadingLineBreak(value);
|
||||
}
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
tokens.push({ kind: "text", value });
|
||||
},
|
||||
oncomment() {
|
||||
stripNextLeadingLineBreak = isLineStart(source, parser.startIndex + skippedLength);
|
||||
tokens.push({ kind: "comment" });
|
||||
},
|
||||
},
|
||||
{
|
||||
decodeEntities: false,
|
||||
lowerCaseAttributeNames: true,
|
||||
lowerCaseTags: true,
|
||||
recognizeSelfClosing: true,
|
||||
},
|
||||
);
|
||||
return {
|
||||
write(chunk) {
|
||||
parser.write(chunk);
|
||||
},
|
||||
skip(length) {
|
||||
skippedLength += length;
|
||||
},
|
||||
end() {
|
||||
parser.end();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isLineStart(source: string, index: number): boolean {
|
||||
return index === 0 || source[index - 1] === "\n" || source[index - 1] === "\r";
|
||||
}
|
||||
|
||||
function stripLeadingLineBreak(value: string): string {
|
||||
if (value.startsWith("\r\n")) {
|
||||
return value.slice(2);
|
||||
}
|
||||
if (value.startsWith("\n") || value.startsWith("\r")) {
|
||||
return value.slice(1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function renderStartTag(name: string, attributes: Record<string, string>): string {
|
||||
const renderedAttributes = Object.entries(attributes)
|
||||
.map(([key, value]) => (value === "" ? ` ${key}` : ` ${key}="${escapeAttribute(value)}"`))
|
||||
.join("");
|
||||
return `<${name}${renderedAttributes}>`;
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
return value.replaceAll("&", "&").replaceAll('"', """);
|
||||
}
|
||||
|
||||
function getProtectedMarkdownRanges(source: string): ProtectedMarkdownRange[] {
|
||||
const fencedRanges = getFencedCodeRanges(source);
|
||||
return mergeProtectedRanges([...fencedRanges, ...getInlineCodeRanges(source, fencedRanges)]);
|
||||
}
|
||||
|
||||
function getFencedCodeRanges(source: string): ProtectedMarkdownRange[] {
|
||||
const ranges: ProtectedMarkdownRange[] = [];
|
||||
FENCE_LINE_RE.lastIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const open = FENCE_LINE_RE.exec(source);
|
||||
if (!open) {
|
||||
return ranges;
|
||||
}
|
||||
|
||||
const marker = open[1];
|
||||
if (!marker) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const close = findClosingFence(source, FENCE_LINE_RE.lastIndex, marker);
|
||||
if (!close) {
|
||||
ranges.push({ start: open.index, end: source.length });
|
||||
return ranges;
|
||||
}
|
||||
|
||||
ranges.push({ start: open.index, end: close.end });
|
||||
FENCE_LINE_RE.lastIndex = close.end;
|
||||
}
|
||||
}
|
||||
|
||||
function findClosingFence(
|
||||
source: string,
|
||||
start: number,
|
||||
marker: string,
|
||||
): MarkdownDelimiterMatch | null {
|
||||
const closeRe = new RegExp(
|
||||
`^ {0,3}[${marker[0]}]{${marker.length},}[^\\n\\r]*(?:\\r?\\n|$)`,
|
||||
"gm",
|
||||
);
|
||||
closeRe.lastIndex = start;
|
||||
const close = closeRe.exec(source);
|
||||
return close ? { index: close.index, end: closeRe.lastIndex } : null;
|
||||
}
|
||||
|
||||
function getInlineCodeRanges(
|
||||
source: string,
|
||||
fencedRanges: ProtectedMarkdownRange[],
|
||||
): ProtectedMarkdownRange[] {
|
||||
const ranges: ProtectedMarkdownRange[] = [];
|
||||
BACKTICK_RUN_RE.lastIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const open = BACKTICK_RUN_RE.exec(source);
|
||||
if (!open) {
|
||||
return ranges;
|
||||
}
|
||||
if (isProtectedIndex(open.index, fencedRanges)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const marker = open[0];
|
||||
const close = findClosingBacktickRun(source, BACKTICK_RUN_RE.lastIndex, marker, fencedRanges);
|
||||
if (!close) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ranges.push({ start: open.index, end: close.end });
|
||||
BACKTICK_RUN_RE.lastIndex = close.end;
|
||||
}
|
||||
}
|
||||
|
||||
function findClosingBacktickRun(
|
||||
source: string,
|
||||
start: number,
|
||||
marker: string,
|
||||
fencedRanges: ProtectedMarkdownRange[],
|
||||
): MarkdownDelimiterMatch | null {
|
||||
BACKTICK_RUN_RE.lastIndex = start;
|
||||
|
||||
while (true) {
|
||||
const close = BACKTICK_RUN_RE.exec(source);
|
||||
if (!close) {
|
||||
return null;
|
||||
}
|
||||
if (close[0] === marker && !isProtectedIndex(close.index, fencedRanges)) {
|
||||
return { index: close.index, end: BACKTICK_RUN_RE.lastIndex };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeProtectedRanges(ranges: ProtectedMarkdownRange[]): ProtectedMarkdownRange[] {
|
||||
const sorted = ranges.toSorted((a, b) => a.start - b.start);
|
||||
const merged: ProtectedMarkdownRange[] = [];
|
||||
|
||||
for (const range of sorted) {
|
||||
const previous = merged.at(-1);
|
||||
if (!previous || range.start > previous.end) {
|
||||
merged.push({ ...range });
|
||||
continue;
|
||||
}
|
||||
previous.end = Math.max(previous.end, range.end);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function isProtectedIndex(index: number, ranges: ProtectedMarkdownRange[]): boolean {
|
||||
return ranges.some((range) => index >= range.start && index < range.end);
|
||||
}
|
||||
|
||||
export function normalizeHtmlishMarkdown(source: string): string {
|
||||
return renderInlineTokens(tokenizeHtmlishMarkdown(source));
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export interface InlineImageDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface InlineImageExplicitDimensions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const INLINE_IMAGE_FALLBACK_SIZE = 16;
|
||||
const INLINE_IMAGE_MAX_WIDTH = 240;
|
||||
const INLINE_IMAGE_MAX_HEIGHT = 160;
|
||||
|
||||
export function resolveInlineImageSize(input: {
|
||||
explicit: InlineImageExplicitDimensions;
|
||||
natural: InlineImageDimensions | null;
|
||||
}): InlineImageDimensions {
|
||||
const dimensions = resolveInlineImageDimensions(input);
|
||||
const scale = Math.min(
|
||||
1,
|
||||
INLINE_IMAGE_MAX_WIDTH / dimensions.width,
|
||||
INLINE_IMAGE_MAX_HEIGHT / dimensions.height,
|
||||
);
|
||||
|
||||
return {
|
||||
width: Math.round(dimensions.width * scale),
|
||||
height: Math.round(dimensions.height * scale),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveInlineImageDimensions(input: {
|
||||
explicit: InlineImageExplicitDimensions;
|
||||
natural: InlineImageDimensions | null;
|
||||
}): InlineImageDimensions {
|
||||
if (input.explicit.width && input.explicit.height) {
|
||||
return { width: input.explicit.width, height: input.explicit.height };
|
||||
}
|
||||
|
||||
if (input.explicit.width && input.natural) {
|
||||
return {
|
||||
width: input.explicit.width,
|
||||
height: input.explicit.width * (input.natural.height / input.natural.width),
|
||||
};
|
||||
}
|
||||
|
||||
if (input.explicit.height && input.natural) {
|
||||
return {
|
||||
width: input.explicit.height * (input.natural.width / input.natural.height),
|
||||
height: input.explicit.height,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.explicit.width) {
|
||||
return { width: input.explicit.width, height: input.explicit.width };
|
||||
}
|
||||
|
||||
if (input.explicit.height) {
|
||||
return { width: input.explicit.height, height: input.explicit.height };
|
||||
}
|
||||
|
||||
return input.natural ?? { width: INLINE_IMAGE_FALLBACK_SIZE, height: INLINE_IMAGE_FALLBACK_SIZE };
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MarkdownDisplayPart } from "./html-ish";
|
||||
import { groupMarkdownParts } from "./part-groups";
|
||||
|
||||
describe("groupMarkdownParts", () => {
|
||||
const flowImage: MarkdownDisplayPart = {
|
||||
kind: "inlineImage",
|
||||
alt: "Priority",
|
||||
src: "https://example.com/priority.svg",
|
||||
flowsWithText: true,
|
||||
};
|
||||
|
||||
it("flows an image with the lead paragraph and keeps later paragraphs full width", () => {
|
||||
expect(
|
||||
groupMarkdownParts([
|
||||
flowImage,
|
||||
{ kind: "markdown", text: " **Title line**\n\nSecond paragraph." },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "imageText",
|
||||
images: [flowImage],
|
||||
lead: "**Title line**",
|
||||
rest: "Second paragraph.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("flows an image with a single-paragraph text", () => {
|
||||
expect(groupMarkdownParts([flowImage, { kind: "markdown", text: " Only line" }])).toEqual([
|
||||
{ kind: "imageText", images: [flowImage], lead: "Only line", rest: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("groups two consecutive flowing images with the lead paragraph", () => {
|
||||
const secondImage: MarkdownDisplayPart = {
|
||||
kind: "inlineImage",
|
||||
alt: "Severity",
|
||||
src: "https://example.com/severity.svg",
|
||||
flowsWithText: true,
|
||||
};
|
||||
|
||||
expect(
|
||||
groupMarkdownParts([
|
||||
flowImage,
|
||||
{ kind: "markdown", text: " " },
|
||||
secondImage,
|
||||
{ kind: "markdown", text: " **Title text here**\n\nBody paragraph." },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "imageText",
|
||||
images: [flowImage, secondImage],
|
||||
lead: "**Title text here**",
|
||||
rest: "Body paragraph.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps standalone images as plain parts", () => {
|
||||
const image: MarkdownDisplayPart = {
|
||||
kind: "inlineImage",
|
||||
alt: "Shot",
|
||||
src: "https://example.com/shot.png",
|
||||
};
|
||||
|
||||
expect(groupMarkdownParts([image, { kind: "markdown", text: "\n\nCaption" }])).toEqual([
|
||||
{ kind: "part", part: image },
|
||||
{ kind: "part", part: { kind: "markdown", text: "\n\nCaption" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a flowing image alone when no markdown follows", () => {
|
||||
expect(groupMarkdownParts([flowImage])).toEqual([{ kind: "part", part: flowImage }]);
|
||||
});
|
||||
|
||||
it("keeps a flowing image alone when the following markdown starts with a blank line", () => {
|
||||
expect(groupMarkdownParts([flowImage, { kind: "markdown", text: "\n\nBelow" }])).toEqual([
|
||||
{ kind: "part", part: flowImage },
|
||||
{ kind: "part", part: { kind: "markdown", text: "\n\nBelow" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes other part kinds through unchanged", () => {
|
||||
const details: MarkdownDisplayPart = { kind: "details", summary: "More", body: "Body" };
|
||||
|
||||
expect(groupMarkdownParts([{ kind: "markdown", text: "Intro" }, details])).toEqual([
|
||||
{ kind: "part", part: { kind: "markdown", text: "Intro" } },
|
||||
{ kind: "part", part: details },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { MarkdownDisplayPart, MarkdownInlineImagePart } from "./html-ish";
|
||||
|
||||
export type MarkdownPartGroup =
|
||||
| { kind: "part"; part: MarkdownDisplayPart }
|
||||
| { kind: "imageText"; images: MarkdownInlineImagePart[]; lead: string; rest: string };
|
||||
|
||||
/**
|
||||
* Pairs a run of inline images that flow with text (small status images at the
|
||||
* start of a line, GitHub-style) with the lead paragraph of the following
|
||||
* markdown part, so the renderer can lay them out side by side instead of
|
||||
* stacking the images as their own blocks.
|
||||
*
|
||||
* Between consecutive flowing images the parser may emit whitespace-only
|
||||
* markdown parts — these are consumed and discarded when building the group.
|
||||
*/
|
||||
export function groupMarkdownParts(parts: readonly MarkdownDisplayPart[]): MarkdownPartGroup[] {
|
||||
const groups: MarkdownPartGroup[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < parts.length) {
|
||||
const part = parts[index];
|
||||
|
||||
if (part.kind === "inlineImage" && part.flowsWithText) {
|
||||
// Collect the maximal run of flowing images, skipping whitespace-only markdown between them.
|
||||
const images: MarkdownInlineImagePart[] = [part];
|
||||
let lookahead = index + 1;
|
||||
|
||||
while (lookahead < parts.length) {
|
||||
const candidate = parts[lookahead];
|
||||
if (candidate === undefined) {
|
||||
break;
|
||||
}
|
||||
if (isWhitespaceOnlyMarkdown(candidate)) {
|
||||
lookahead += 1;
|
||||
continue;
|
||||
}
|
||||
if (candidate.kind === "inlineImage" && candidate.flowsWithText) {
|
||||
images.push(candidate);
|
||||
lookahead += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const trailing = parts[lookahead];
|
||||
if (trailing?.kind === "markdown") {
|
||||
const { lead, rest } = splitLeadParagraph(trailing.text);
|
||||
if (lead) {
|
||||
groups.push({ kind: "imageText", images, lead, rest });
|
||||
index = lookahead + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// No usable lead — emit the images (and any whitespace parts) as plain parts.
|
||||
for (let i = index; i < lookahead; i += 1) {
|
||||
const p = parts[i];
|
||||
if (p !== undefined) {
|
||||
groups.push({ kind: "part", part: p });
|
||||
}
|
||||
}
|
||||
index = lookahead;
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.push({ kind: "part", part });
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function isWhitespaceOnlyMarkdown(part: MarkdownDisplayPart): boolean {
|
||||
return part.kind === "markdown" && part.text.trim() === "";
|
||||
}
|
||||
|
||||
function splitLeadParagraph(text: string): { lead: string; rest: string } {
|
||||
if (/^[ \t]*\r?\n/.test(text)) {
|
||||
return { lead: "", rest: text };
|
||||
}
|
||||
const boundary = /\r?\n[ \t]*\r?\n/.exec(text);
|
||||
if (!boundary) {
|
||||
return { lead: text.trim(), rest: "" };
|
||||
}
|
||||
return {
|
||||
lead: text.slice(0, boundary.index).trim(),
|
||||
rest: text.slice(boundary.index + boundary[0].length),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveInlineImageSize } from "./inline-image-size";
|
||||
|
||||
describe("resolveInlineImageSize", () => {
|
||||
it("respects a one-sided explicit width using natural aspect ratio", () => {
|
||||
expect(
|
||||
resolveInlineImageSize({ explicit: { width: 18 }, natural: { width: 90, height: 45 } }),
|
||||
).toEqual({
|
||||
width: 18,
|
||||
height: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("respects a one-sided explicit height using natural aspect ratio", () => {
|
||||
expect(
|
||||
resolveInlineImageSize({ explicit: { height: 18 }, natural: { width: 90, height: 45 } }),
|
||||
).toEqual({
|
||||
width: 36,
|
||||
height: 18,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a generic small fallback when no dimensions are known", () => {
|
||||
expect(resolveInlineImageSize({ explicit: {}, natural: null })).toEqual({
|
||||
width: 16,
|
||||
height: 16,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,758 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
Image,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type TextProps,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react-native";
|
||||
import Markdown, {
|
||||
MarkdownIt,
|
||||
type ASTNode,
|
||||
type RenderRules,
|
||||
} from "react-native-markdown-display";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
|
||||
import { markdownNodeContainsType } from "@/utils/markdown-ast";
|
||||
import { createCompactMarkdownStyles, createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import {
|
||||
splitHtmlishMarkdown,
|
||||
type MarkdownDisplayPart,
|
||||
type MarkdownInlineImagePart,
|
||||
} from "./html-ish";
|
||||
import { resolveInlineImageSize, type InlineImageDimensions } from "./inline-image-size";
|
||||
import { groupMarkdownParts, type MarkdownPartGroup } from "./part-groups";
|
||||
|
||||
export type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
interface MarkdownWithStableRendererProps {
|
||||
children: ReactNode;
|
||||
style: ReturnType<typeof createMarkdownStyles> | ReturnType<typeof createCompactMarkdownStyles>;
|
||||
rules?: RenderRules;
|
||||
markdownit?: ReturnType<typeof MarkdownIt>;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
allowedImageHandlers?: readonly string[];
|
||||
topLevelMaxExceededItem?: ReactNode;
|
||||
}
|
||||
|
||||
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
|
||||
const ThemedMarkdown = withUnistyles(MarkdownWithStableRenderer);
|
||||
|
||||
function markdownStyleMapping(theme: Theme): Partial<MarkdownWithStableRendererProps> {
|
||||
return { style: createMarkdownStyles(theme) };
|
||||
}
|
||||
|
||||
function compactMarkdownStyleMapping(theme: Theme): Partial<MarkdownWithStableRendererProps> {
|
||||
return { style: createCompactMarkdownStyles(theme) };
|
||||
}
|
||||
|
||||
const defaultMarkdownParser = MarkdownIt({ typographer: true, linkify: true });
|
||||
const EMPTY_TEXT_STYLE: TextStyle = {};
|
||||
const MARKDOWN_LIST_ITEM_CONTENT_FLEX: ViewStyle = { flex: 1, flexShrink: 1, minWidth: 0 };
|
||||
export interface MarkdownRendererProps {
|
||||
text: string;
|
||||
compact?: boolean;
|
||||
rules?: RenderRules;
|
||||
markdownit?: ReturnType<typeof MarkdownIt>;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
allowedImageHandlers?: readonly string[];
|
||||
topLevelMaxExceededItem?: ReactNode;
|
||||
enableHtmlish?: boolean;
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({
|
||||
text,
|
||||
compact = false,
|
||||
rules,
|
||||
markdownit = defaultMarkdownParser,
|
||||
onLinkPress,
|
||||
allowedImageHandlers,
|
||||
topLevelMaxExceededItem,
|
||||
enableHtmlish = true,
|
||||
}: MarkdownRendererProps) {
|
||||
const markdownRules = useMemo(() => rules ?? createSharedMarkdownRules(), [rules]);
|
||||
const parts = useMemo(
|
||||
() => (enableHtmlish ? splitHtmlishMarkdown(text) : [{ kind: "markdown" as const, text }]),
|
||||
[enableHtmlish, text],
|
||||
);
|
||||
const rendererProps = useMemo(
|
||||
() => ({
|
||||
compact,
|
||||
rules: markdownRules,
|
||||
markdownit,
|
||||
onLinkPress,
|
||||
allowedImageHandlers,
|
||||
topLevelMaxExceededItem,
|
||||
}),
|
||||
[
|
||||
allowedImageHandlers,
|
||||
compact,
|
||||
markdownRules,
|
||||
markdownit,
|
||||
onLinkPress,
|
||||
topLevelMaxExceededItem,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<AppearanceStyleBoundary>
|
||||
<MarkdownPartList parts={parts} rendererProps={rendererProps} />
|
||||
</AppearanceStyleBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
type MarkdownPartRendererProps = Omit<MarkdownRendererProps, "text" | "enableHtmlish"> & {
|
||||
rules: RenderRules;
|
||||
};
|
||||
|
||||
function MarkdownPartList({
|
||||
parts,
|
||||
rendererProps,
|
||||
}: {
|
||||
parts: MarkdownDisplayPart[];
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
const keyedGroups = useMemo(() => keyMarkdownGroups(groupMarkdownParts(parts)), [parts]);
|
||||
return (
|
||||
<>
|
||||
{keyedGroups.map(({ key, group }) =>
|
||||
group.kind === "part" ? (
|
||||
<MarkdownPart key={key} part={group.part} rendererProps={rendererProps} />
|
||||
) : (
|
||||
<MarkdownImageTextGroup key={key} group={group} rendererProps={rendererProps} />
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function keyMarkdownGroups(
|
||||
groups: MarkdownPartGroup[],
|
||||
): { key: string; group: MarkdownPartGroup }[] {
|
||||
const seen = new Map<string, number>();
|
||||
return groups.map((group) => {
|
||||
const identity =
|
||||
group.kind === "part"
|
||||
? getMarkdownPartIdentity(group.part)
|
||||
: `imageText:${group.images.map((i) => i.src).join(",")}:${group.lead.slice(0, 80)}`;
|
||||
const seenCount = seen.get(identity) ?? 0;
|
||||
seen.set(identity, seenCount + 1);
|
||||
return { key: `${identity}:${seenCount}`, group };
|
||||
});
|
||||
}
|
||||
|
||||
function getMarkdownPartIdentity(part: MarkdownDisplayPart): string {
|
||||
if (part.kind === "markdown") {
|
||||
return `markdown:${part.text.slice(0, 80)}`;
|
||||
}
|
||||
if (part.kind === "inlineImage") {
|
||||
return `inlineImage:${part.src}:${part.alt}`;
|
||||
}
|
||||
return `details:${part.summary.slice(0, 80)}:${part.body.slice(0, 80)}`;
|
||||
}
|
||||
|
||||
function MarkdownPart({
|
||||
part,
|
||||
rendererProps,
|
||||
}: {
|
||||
part: MarkdownDisplayPart;
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
if (part.kind === "details") {
|
||||
return <MarkdownDetails part={part} rendererProps={rendererProps} />;
|
||||
}
|
||||
|
||||
if (part.kind === "inlineImage") {
|
||||
return <MarkdownInlineImage part={part} onLinkPress={rendererProps.onLinkPress} />;
|
||||
}
|
||||
|
||||
if (part.text.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <MarkdownFragment text={part.text} {...rendererProps} />;
|
||||
}
|
||||
|
||||
function MarkdownFragment({
|
||||
text,
|
||||
compact,
|
||||
rules,
|
||||
markdownit,
|
||||
onLinkPress,
|
||||
allowedImageHandlers,
|
||||
topLevelMaxExceededItem,
|
||||
}: MarkdownRendererProps & { rules: RenderRules }) {
|
||||
const uniProps = compact ? compactMarkdownStyleMapping : markdownStyleMapping;
|
||||
return (
|
||||
<ThemedMarkdown
|
||||
uniProps={uniProps}
|
||||
rules={rules}
|
||||
markdownit={markdownit}
|
||||
onLinkPress={onLinkPress}
|
||||
allowedImageHandlers={allowedImageHandlers}
|
||||
topLevelMaxExceededItem={topLevelMaxExceededItem}
|
||||
>
|
||||
{text}
|
||||
</ThemedMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
function useNaturalImageDimensions(part: MarkdownInlineImagePart): {
|
||||
natural: InlineImageDimensions | null;
|
||||
failed: boolean;
|
||||
setFailed: (failed: boolean) => void;
|
||||
} {
|
||||
const [natural, setNatural] = useState<InlineImageDimensions | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (part.width && part.height) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
Image.getSize(
|
||||
part.src,
|
||||
(width, height) => {
|
||||
if (!cancelled) {
|
||||
setNatural({ width, height });
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) {
|
||||
setFailed(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [part.height, part.src, part.width]);
|
||||
|
||||
return { natural, failed, setFailed };
|
||||
}
|
||||
|
||||
function MarkdownInlineImage({
|
||||
part,
|
||||
onLinkPress,
|
||||
}: {
|
||||
part: Extract<MarkdownDisplayPart, { kind: "inlineImage" }>;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
}) {
|
||||
const { natural: naturalDimensions } = useNaturalImageDimensions(part);
|
||||
const explicitDimensions = useMemo(
|
||||
() => ({ width: part.width, height: part.height }),
|
||||
[part.height, part.width],
|
||||
);
|
||||
const handlePress = useCallback(() => {
|
||||
if (!part.href) return;
|
||||
if (onLinkPress?.(part.href) === false) return;
|
||||
void openExternalUrl(part.href);
|
||||
}, [onLinkPress, part.href]);
|
||||
const source = useMemo(() => ({ uri: part.src }), [part.src]);
|
||||
const imageSize = useMemo(
|
||||
() => resolveInlineImageSize({ explicit: explicitDimensions, natural: naturalDimensions }),
|
||||
[explicitDimensions, naturalDimensions],
|
||||
);
|
||||
const imageStyle = useMemo(() => [detailsStyles.inlineImage, imageSize], [imageSize]);
|
||||
|
||||
const image = (
|
||||
<Image
|
||||
source={source}
|
||||
style={imageStyle}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel={part.alt || undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!part.href) {
|
||||
return <View style={detailsStyles.inlineImageWrap}>{image}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable style={detailsStyles.inlineImageWrap} onPress={handlePress} accessibilityRole="link">
|
||||
{image}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const FLOW_IMAGE_MAX_HEIGHT = 18;
|
||||
|
||||
function MarkdownFlowImage({
|
||||
part,
|
||||
onLinkPress,
|
||||
}: {
|
||||
part: MarkdownInlineImagePart;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
}) {
|
||||
const { natural, failed, setFailed } = useNaturalImageDimensions(part);
|
||||
const handlePress = useCallback(() => {
|
||||
if (!part.href) return;
|
||||
if (onLinkPress?.(part.href) === false) return;
|
||||
void openExternalUrl(part.href);
|
||||
}, [onLinkPress, part.href]);
|
||||
const handleError = useCallback(() => setFailed(true), [setFailed]);
|
||||
const source = useMemo(() => ({ uri: part.src }), [part.src]);
|
||||
const imageSize = useMemo(() => {
|
||||
const size = resolveInlineImageSize({
|
||||
explicit: { width: part.width, height: part.height },
|
||||
natural,
|
||||
});
|
||||
const scale = Math.min(1, FLOW_IMAGE_MAX_HEIGHT / size.height);
|
||||
return { width: Math.round(size.width * scale), height: Math.round(size.height * scale) };
|
||||
}, [natural, part.height, part.width]);
|
||||
const imageStyle = useMemo(() => [detailsStyles.flowImage, imageSize], [imageSize]);
|
||||
|
||||
if (failed) {
|
||||
if (!part.alt) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View style={detailsStyles.flowImageFallback}>
|
||||
<Text style={detailsStyles.flowImageFallbackText}>{part.alt}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const image = (
|
||||
<Image
|
||||
source={source}
|
||||
style={imageStyle}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel={part.alt || undefined}
|
||||
onError={handleError}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!part.href) {
|
||||
return image;
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable onPress={handlePress} accessibilityRole="link">
|
||||
{image}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownImageTextGroup({
|
||||
group,
|
||||
rendererProps,
|
||||
}: {
|
||||
group: Extract<MarkdownPartGroup, { kind: "imageText" }>;
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<View style={detailsStyles.imageTextRow}>
|
||||
{group.images.map((image) => (
|
||||
<MarkdownFlowImage key={image.src} part={image} onLinkPress={rendererProps.onLinkPress} />
|
||||
))}
|
||||
<View style={detailsStyles.imageTextRowContent}>
|
||||
<MarkdownFragment text={group.lead} {...rendererProps} />
|
||||
</View>
|
||||
</View>
|
||||
{group.rest ? <MarkdownFragment text={group.rest} {...rendererProps} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownDetails({
|
||||
part,
|
||||
rendererProps,
|
||||
}: {
|
||||
part: Extract<MarkdownDisplayPart, { kind: "details" }>;
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const toggle = useCallback(() => setOpen((current) => !current), []);
|
||||
const bodyParts = useMemo(
|
||||
() => part.bodyParts ?? [{ kind: "markdown" as const, text: part.body }],
|
||||
[part.body, part.bodyParts],
|
||||
);
|
||||
return (
|
||||
<View style={detailsStyles.container}>
|
||||
<Pressable style={detailsStyles.summaryRow} onPress={toggle} accessibilityRole="button">
|
||||
{open ? (
|
||||
<ChevronDown size={14} color={detailsStyles.summaryIcon.color} />
|
||||
) : (
|
||||
<ChevronRight size={14} color={detailsStyles.summaryIcon.color} />
|
||||
)}
|
||||
<Text style={detailsStyles.summaryText}>{part.summary}</Text>
|
||||
</Pressable>
|
||||
{open ? (
|
||||
<View style={detailsStyles.body}>
|
||||
<MarkdownPartList parts={bodyParts} rendererProps={rendererProps} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownInheritedTextProps {
|
||||
inheritedStyles: TextStyle;
|
||||
textStyle: TextStyle;
|
||||
style?: TextStyle;
|
||||
monoSurface?: boolean;
|
||||
onPress?: TextProps["onPress"];
|
||||
accessibilityRole?: TextProps["accessibilityRole"];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function MarkdownInheritedText({
|
||||
inheritedStyles,
|
||||
textStyle,
|
||||
style: overrideStyle,
|
||||
monoSurface,
|
||||
onPress,
|
||||
accessibilityRole,
|
||||
children,
|
||||
}: MarkdownInheritedTextProps) {
|
||||
const style = useMemo(
|
||||
() => [inheritedStyles, textStyle, overrideStyle],
|
||||
[inheritedStyles, textStyle, overrideStyle],
|
||||
);
|
||||
return (
|
||||
<MarkdownTextSpan
|
||||
monoSurface={monoSurface}
|
||||
onPress={onPress}
|
||||
accessibilityRole={accessibilityRole}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</MarkdownTextSpan>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownListItemContentProps {
|
||||
contentStyle: ViewStyle;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MarkdownListItemContent({ contentStyle, children }: MarkdownListItemContentProps) {
|
||||
const style = useMemo(() => [contentStyle, MARKDOWN_LIST_ITEM_CONTENT_FLEX], [contentStyle]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
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 <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
interface SharedMarkdownLinkProps {
|
||||
href: string;
|
||||
inheritedStyles: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function SharedMarkdownLink({
|
||||
href,
|
||||
inheritedStyles,
|
||||
linkStyle,
|
||||
onLinkPress,
|
||||
children,
|
||||
}: SharedMarkdownLinkProps) {
|
||||
const handlePress = useCallback(() => {
|
||||
if (!href) return;
|
||||
if (onLinkPress?.(href) === false) return;
|
||||
void openExternalUrl(href);
|
||||
}, [href, onLinkPress]);
|
||||
|
||||
return (
|
||||
<MarkdownInheritedText
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={linkStyle}
|
||||
accessibilityRole="link"
|
||||
onPress={handlePress}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
);
|
||||
}
|
||||
|
||||
function getMarkdownLinkHref(node: ASTNode): string {
|
||||
const href = node.attributes?.href;
|
||||
return typeof href === "string" ? href : "";
|
||||
}
|
||||
|
||||
export function createSharedMarkdownRules(): RenderRules {
|
||||
return {
|
||||
text: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.text}
|
||||
>
|
||||
{node.content}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
textgroup: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.textgroup}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
strong: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.strong}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
em: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.em}>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
s: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.s}>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
code_block: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={null}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_block}
|
||||
/>
|
||||
),
|
||||
fence: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={node.sourceInfo}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.fence}
|
||||
/>
|
||||
),
|
||||
code_inline: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_inline}
|
||||
monoSurface
|
||||
>
|
||||
{node.content ?? ""}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
bullet_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.bullet_list}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
),
|
||||
ordered_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.ordered_list}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
),
|
||||
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 (
|
||||
<View key={node.key} style={styles.list_item}>
|
||||
<Text style={iconStyle}>{marker}</Text>
|
||||
<MarkdownListItemContent contentStyle={contentStyle}>{children}</MarkdownListItemContent>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
paragraph: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownParagraphView
|
||||
key={node.key}
|
||||
paragraphStyle={styles.paragraph}
|
||||
containsImage={markdownNodeContainsType(node, "image")}
|
||||
>
|
||||
{children}
|
||||
</MarkdownParagraphView>
|
||||
),
|
||||
link: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
onLinkPress?: (url: string) => boolean,
|
||||
) => (
|
||||
<SharedMarkdownLink
|
||||
key={node.key}
|
||||
href={getMarkdownLinkHref(node)}
|
||||
inheritedStyles={EMPTY_TEXT_STYLE}
|
||||
linkStyle={styles.link}
|
||||
onLinkPress={onLinkPress}
|
||||
>
|
||||
{children}
|
||||
</SharedMarkdownLink>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const detailsStyles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
marginBottom: theme.spacing[2],
|
||||
overflow: "hidden",
|
||||
},
|
||||
summaryRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
summaryIcon: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
summaryText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 18,
|
||||
},
|
||||
body: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingTop: theme.spacing[2],
|
||||
},
|
||||
inlineImageWrap: {
|
||||
alignSelf: "flex-start",
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
inlineImage: {},
|
||||
imageTextRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
marginBottom: theme.spacing[3],
|
||||
},
|
||||
imageTextRowContent: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
flowImage: {
|
||||
marginTop: 2,
|
||||
},
|
||||
flowImageFallback: {
|
||||
marginTop: 2,
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
flowImageFallbackText: {
|
||||
fontSize: 10,
|
||||
lineHeight: 14,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import * as React from "react";
|
||||
import {
|
||||
useState,
|
||||
@@ -27,12 +26,8 @@ import {
|
||||
Children,
|
||||
cloneElement,
|
||||
} from "react";
|
||||
import type { ReactNode, ComponentType } from "react";
|
||||
import Markdown, {
|
||||
MarkdownIt,
|
||||
type ASTNode,
|
||||
type RenderRules,
|
||||
} from "react-native-markdown-display";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { MarkdownIt, type ASTNode, type RenderRules } from "react-native-markdown-display";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import MaskedView from "@react-native-masked-view/masked-view";
|
||||
import {
|
||||
@@ -52,7 +47,7 @@ import {
|
||||
FileSymlink,
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { type Theme } from "@/styles/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -63,8 +58,8 @@ import Animated, {
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { MarkdownRenderer, type MarkdownStyles } from "@/components/markdown/renderer";
|
||||
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
@@ -113,8 +108,6 @@ import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
|
||||
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
|
||||
export type { InlinePathTarget } from "@/assistant-file-links";
|
||||
|
||||
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
interface UserMessageProps {
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
@@ -162,22 +155,6 @@ const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [
|
||||
] as const;
|
||||
const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>;
|
||||
|
||||
interface MarkdownWithStableRendererProps {
|
||||
children: ReactNode;
|
||||
style: ReturnType<typeof createMarkdownStyles>;
|
||||
rules: RenderRules;
|
||||
markdownit: MarkdownIt;
|
||||
onLinkPress: (url: string) => boolean;
|
||||
allowedImageHandlers: readonly string[];
|
||||
topLevelMaxExceededItem: ReactNode;
|
||||
}
|
||||
|
||||
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
|
||||
const ThemedMarkdown = withUnistyles(MarkdownWithStableRenderer);
|
||||
const markdownStyleMapping = (theme: Theme): Partial<MarkdownWithStableRendererProps> => ({
|
||||
style: createMarkdownStyles(theme),
|
||||
});
|
||||
|
||||
const ThemedMicVocal = withUnistyles(MicVocal);
|
||||
const ThemedTodoCheckIcon = withUnistyles(Check);
|
||||
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
|
||||
@@ -1521,18 +1498,15 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
|
||||
onLinkPress,
|
||||
}: MemoizedMarkdownBlockProps) {
|
||||
return (
|
||||
<AppearanceStyleBoundary>
|
||||
<ThemedMarkdown
|
||||
uniProps={markdownStyleMapping}
|
||||
<MarkdownRenderer
|
||||
text={text}
|
||||
enableHtmlish={false}
|
||||
rules={rules}
|
||||
markdownit={parser}
|
||||
onLinkPress={onLinkPress}
|
||||
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
|
||||
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
|
||||
>
|
||||
{text}
|
||||
</ThemedMarkdown>
|
||||
</AppearanceStyleBoundary>
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -66,7 +66,9 @@ function imageWithId(id: string): AttachmentMetadata {
|
||||
return { ...imageMetadata, id, storageKey: id, fileName: `${id}.png` };
|
||||
}
|
||||
|
||||
function reviewWorkspaceAttachment(body: string): WorkspaceComposerAttachment {
|
||||
function reviewWorkspaceAttachment(
|
||||
body: string,
|
||||
): Extract<WorkspaceComposerAttachment, { kind: "review" }> {
|
||||
const attachment: Extract<AgentAttachment, { type: "review" }> = {
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState, type ReactElement } from "re
|
||||
import { Text, View } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { MessageSquareCode, MousePointer2 } from "lucide-react-native";
|
||||
import { FileText, MessageSquareCode, MousePointer2 } from "lucide-react-native";
|
||||
import type {
|
||||
ComposerAttachment,
|
||||
UserComposerAttachment,
|
||||
@@ -12,12 +12,15 @@ import { AttachmentPill } from "@/components/attachment-pill";
|
||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||
import {
|
||||
isWorkspaceAttachment,
|
||||
isPullRequestContextAttachment,
|
||||
userAttachmentsOnly,
|
||||
workspaceAttachmentToSubmitAttachment,
|
||||
} from "@/attachments/workspace-attachment-utils";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { useClearReviewDraft } from "@/review/store";
|
||||
|
||||
type TranslationFn = ReturnType<typeof useTranslation>["t"];
|
||||
|
||||
interface WorkspaceAttachmentBindingInput {
|
||||
normalAttachments: UserComposerAttachment[];
|
||||
workspaceAttachments: readonly WorkspaceComposerAttachment[];
|
||||
@@ -59,6 +62,12 @@ function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
|
||||
html: attachment.attachment.outerHTML,
|
||||
});
|
||||
}
|
||||
if (isPullRequestContextAttachment(attachment)) {
|
||||
return JSON.stringify({
|
||||
kind: attachment.kind,
|
||||
id: attachment.id,
|
||||
});
|
||||
}
|
||||
return JSON.stringify({
|
||||
type: "review",
|
||||
cwd: attachment.attachment.cwd,
|
||||
@@ -74,6 +83,83 @@ function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
|
||||
});
|
||||
}
|
||||
|
||||
function removeWorkspaceAttachmentsMatching(selectedKey: string): void {
|
||||
const { attachmentsByScope, setWorkspaceAttachments } = useWorkspaceAttachmentsStore.getState();
|
||||
for (const [scopeKey, attachments] of Object.entries(attachmentsByScope)) {
|
||||
const nextAttachments = attachments.filter(
|
||||
(attachment) => getAttachmentKey(attachment) !== selectedKey,
|
||||
);
|
||||
if (nextAttachments.length !== attachments.length) {
|
||||
setWorkspaceAttachments({ scopeKey, attachments: nextAttachments });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeSentContextAttachments(attachments: readonly ComposerAttachment[]): void {
|
||||
const sentContextKeys = attachments.filter(isPullRequestContextAttachment).map(getAttachmentKey);
|
||||
for (const key of sentContextKeys) {
|
||||
removeWorkspaceAttachmentsMatching(key);
|
||||
}
|
||||
}
|
||||
|
||||
function getContextSourceLabel(attachment: WorkspaceComposerAttachment): string {
|
||||
if (attachment.kind === "github.pull_request_check") {
|
||||
return "Check logs";
|
||||
}
|
||||
if (attachment.kind === "github.pull_request_comment") {
|
||||
return "Comment";
|
||||
}
|
||||
return "Review";
|
||||
}
|
||||
|
||||
function getPillLabel(attachment: WorkspaceComposerAttachment, t: TranslationFn): string {
|
||||
if (attachment.kind === "browser_element") {
|
||||
return t("composer.attachments.browserElement", { tag: attachment.attachment.tag });
|
||||
}
|
||||
if (isPullRequestContextAttachment(attachment)) {
|
||||
return `${getContextSourceLabel(attachment)} · ${attachment.title}`;
|
||||
}
|
||||
return attachment.commentCount === 1
|
||||
? t("message.attachments.reviewOne")
|
||||
: t("message.attachments.reviewMany", { count: attachment.commentCount });
|
||||
}
|
||||
|
||||
function getOpenAccessibilityLabel(
|
||||
attachment: WorkspaceComposerAttachment,
|
||||
t: TranslationFn,
|
||||
): string {
|
||||
if (attachment.kind === "browser_element") {
|
||||
return t("composer.attachments.openBrowserElement");
|
||||
}
|
||||
if (isPullRequestContextAttachment(attachment)) {
|
||||
return "Open context attachment";
|
||||
}
|
||||
return t("composer.attachments.openReview");
|
||||
}
|
||||
|
||||
function getRemoveAccessibilityLabel(
|
||||
attachment: WorkspaceComposerAttachment,
|
||||
t: TranslationFn,
|
||||
): string {
|
||||
if (attachment.kind === "browser_element") {
|
||||
return t("composer.attachments.removeBrowserElement");
|
||||
}
|
||||
if (isPullRequestContextAttachment(attachment)) {
|
||||
return "Remove context attachment";
|
||||
}
|
||||
return t("composer.attachments.removeReview");
|
||||
}
|
||||
|
||||
function renderPillIcon(attachment: WorkspaceComposerAttachment): ReactElement {
|
||||
if (attachment.kind === "browser_element") {
|
||||
return <ThemedMousePointer2 size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
|
||||
}
|
||||
if (isPullRequestContextAttachment(attachment)) {
|
||||
return <ThemedFileText size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
|
||||
}
|
||||
return <ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />;
|
||||
}
|
||||
|
||||
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
|
||||
return (
|
||||
<WorkspaceAttachmentPill
|
||||
@@ -90,9 +176,6 @@ function useWorkspaceAttachmentBinding({
|
||||
onOpenWorkspaceAttachment,
|
||||
}: WorkspaceAttachmentBindingInput): ComposerWorkspaceAttachmentBinding {
|
||||
const clearReviewDraft = useClearReviewDraft();
|
||||
const setWorkspaceAttachments = useWorkspaceAttachmentsStore(
|
||||
(state) => state.setWorkspaceAttachments,
|
||||
);
|
||||
const [suppressedKeys, setSuppressedKeys] = useState<readonly string[]>([]);
|
||||
const workspaceAttachmentKeys = useMemo(
|
||||
() => workspaceAttachments.map(getAttachmentKey),
|
||||
@@ -143,6 +226,7 @@ function useWorkspaceAttachmentBinding({
|
||||
clearReviewDraft({ key: attachment.reviewDraftKey });
|
||||
}
|
||||
}
|
||||
removeSentContextAttachments(attachments);
|
||||
},
|
||||
[clearReviewDraft],
|
||||
);
|
||||
@@ -151,17 +235,9 @@ function useWorkspaceAttachmentBinding({
|
||||
({ selectedAttachments: current, index }: RemoveWorkspaceAttachmentInput) => {
|
||||
const selected = current[index];
|
||||
if (isWorkspaceAttachment(selected)) {
|
||||
if (selected.kind === "browser_element") {
|
||||
if (selected.kind === "browser_element" || isPullRequestContextAttachment(selected)) {
|
||||
const selectedKey = getAttachmentKey(selected);
|
||||
const { attachmentsByScope } = useWorkspaceAttachmentsStore.getState();
|
||||
for (const [scopeKey, attachments] of Object.entries(attachmentsByScope)) {
|
||||
const nextAttachments = attachments.filter(
|
||||
(attachment) => getAttachmentKey(attachment) !== selectedKey,
|
||||
);
|
||||
if (nextAttachments.length !== attachments.length) {
|
||||
setWorkspaceAttachments({ scopeKey, attachments: nextAttachments });
|
||||
}
|
||||
}
|
||||
removeWorkspaceAttachmentsMatching(selectedKey);
|
||||
return true;
|
||||
}
|
||||
suppressWorkspaceAttachment(selected);
|
||||
@@ -169,7 +245,7 @@ function useWorkspaceAttachmentBinding({
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[setWorkspaceAttachments, suppressWorkspaceAttachment],
|
||||
[suppressWorkspaceAttachment],
|
||||
);
|
||||
|
||||
const openAttachment = useCallback(
|
||||
@@ -233,15 +309,7 @@ function WorkspaceAttachmentPill({
|
||||
onRemove,
|
||||
}: WorkspaceAttachmentPillProps) {
|
||||
const { t } = useTranslation();
|
||||
let label: string;
|
||||
if (attachment.kind === "browser_element") {
|
||||
label = t("composer.attachments.browserElement", { tag: attachment.attachment.tag });
|
||||
} else {
|
||||
label =
|
||||
attachment.commentCount === 1
|
||||
? t("message.attachments.reviewOne")
|
||||
: t("message.attachments.reviewMany", { count: attachment.commentCount });
|
||||
}
|
||||
const label = getPillLabel(attachment, t);
|
||||
const handleOpen = useCallback(() => {
|
||||
onOpen(attachment);
|
||||
}, [onOpen, attachment]);
|
||||
@@ -253,26 +321,12 @@ function WorkspaceAttachmentPill({
|
||||
testID="composer-review-attachment-pill"
|
||||
onOpen={handleOpen}
|
||||
onRemove={handleRemove}
|
||||
openAccessibilityLabel={
|
||||
attachment.kind === "browser_element"
|
||||
? t("composer.attachments.openBrowserElement")
|
||||
: t("composer.attachments.openReview")
|
||||
}
|
||||
removeAccessibilityLabel={
|
||||
attachment.kind === "browser_element"
|
||||
? t("composer.attachments.removeBrowserElement")
|
||||
: t("composer.attachments.removeReview")
|
||||
}
|
||||
openAccessibilityLabel={getOpenAccessibilityLabel(attachment, t)}
|
||||
removeAccessibilityLabel={getRemoveAccessibilityLabel(attachment, t)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<View style={styles.pillBody}>
|
||||
<View style={styles.pillIcon}>
|
||||
{attachment.kind === "browser_element" ? (
|
||||
<ThemedMousePointer2 size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
) : (
|
||||
<ThemedMessageSquareCode size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.pillIcon}>{renderPillIcon(attachment)}</View>
|
||||
<Text style={styles.pillText} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
@@ -315,4 +369,5 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
|
||||
const ThemedMousePointer2 = withUnistyles(MousePointer2);
|
||||
const ThemedMessageSquareCode = withUnistyles(MessageSquareCode);
|
||||
const ThemedFileText = withUnistyles(FileText);
|
||||
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
@@ -1,453 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleCheck,
|
||||
CircleDot,
|
||||
CircleSlash,
|
||||
CircleX,
|
||||
ExternalLink,
|
||||
GitMerge,
|
||||
GitPullRequest,
|
||||
GitPullRequestClosed,
|
||||
GitPullRequestDraft,
|
||||
MessageSquare,
|
||||
} from "lucide-react-native";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import type {
|
||||
CheckStatus,
|
||||
PrPaneActivity,
|
||||
PrPaneCheck,
|
||||
PrPaneData,
|
||||
PrState,
|
||||
} from "@/git/pr-pane-data";
|
||||
|
||||
function rowPressableStyle({ hovered }: { hovered?: boolean }) {
|
||||
return [styles.row, Boolean(hovered) && styles.hoverable];
|
||||
}
|
||||
|
||||
function activityPressableStyle({ hovered }: { hovered?: boolean }) {
|
||||
return [styles.activityRow, Boolean(hovered) && styles.hoverable];
|
||||
}
|
||||
|
||||
export function PrPane({ data }: { data: PrPaneData }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const [checksOpen, setChecksOpen] = useState(true);
|
||||
const [reviewsOpen, setReviewsOpen] = useState(true);
|
||||
|
||||
const handleOpenPrUrl = useCallback(() => {
|
||||
void openExternalUrl(data.url);
|
||||
}, [data.url]);
|
||||
|
||||
const handleToggleChecks = useCallback(() => {
|
||||
setChecksOpen((o) => !o);
|
||||
}, []);
|
||||
|
||||
const handleToggleReviews = useCallback(() => {
|
||||
setReviewsOpen((o) => !o);
|
||||
}, []);
|
||||
|
||||
const passed = data.checks.filter((c) => c.status === "success").length;
|
||||
const failed = data.checks.filter((c) => c.status === "failure").length;
|
||||
const pending = data.checks.filter((c) => c.status === "pending").length;
|
||||
|
||||
const approvals = data.activity.filter(
|
||||
(a) => a.kind === "review" && a.reviewState === "approved",
|
||||
).length;
|
||||
const changesRequested = data.activity.filter(
|
||||
(a) => a.kind === "review" && a.reviewState === "changes_requested",
|
||||
).length;
|
||||
const commentCount = data.activity.filter(
|
||||
(a) => a.kind === "comment" || (a.kind === "review" && a.reviewState === "commented"),
|
||||
).length;
|
||||
|
||||
const stateColor = getStateColor(data.state, theme);
|
||||
const StateIcon = getStateIcon(data.state);
|
||||
const stateLabel = t(`workspace.git.pr.states.${data.state}`);
|
||||
const stateLabelStyle = useMemo(() => [styles.stateLabel, { color: stateColor }], [stateColor]);
|
||||
const keyedActivity = useMemo(
|
||||
() => data.activity.map((item, idx) => ({ key: `${item.author}-${item.kind}-${idx}`, item })),
|
||||
[data.activity],
|
||||
);
|
||||
|
||||
const checkSuccessIcon = useMemo(
|
||||
() => <CircleCheck size={12} color={theme.colors.statusSuccess} />,
|
||||
[theme.colors.statusSuccess],
|
||||
);
|
||||
const checkDangerIcon = useMemo(
|
||||
() => <CircleX size={12} color={theme.colors.statusDanger} />,
|
||||
[theme.colors.statusDanger],
|
||||
);
|
||||
const checkWarningIcon = useMemo(
|
||||
() => <CircleDot size={12} color={theme.colors.statusWarning} />,
|
||||
[theme.colors.statusWarning],
|
||||
);
|
||||
const commentIcon = useMemo(
|
||||
() => <MessageSquare size={11} color={theme.colors.foregroundMuted} />,
|
||||
[theme.colors.foregroundMuted],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.root} testID="pr-pane">
|
||||
<Pressable onPress={handleOpenPrUrl} style={styles.header}>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
<View style={styles.stateLine}>
|
||||
<StateIcon size={14} color={stateColor} />
|
||||
<Text style={stateLabelStyle} testID="pr-pane-state">
|
||||
{stateLabel}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.title} numberOfLines={3} testID="pr-pane-title">
|
||||
{data.title}
|
||||
{hovered ? (
|
||||
<Text>
|
||||
{" "}
|
||||
<ExternalLink size={12} color={theme.colors.foregroundMuted} />
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<Section
|
||||
title={t("workspace.git.pr.sections.checks")}
|
||||
open={checksOpen}
|
||||
onToggle={handleToggleChecks}
|
||||
summary={
|
||||
<>
|
||||
<SummaryPill
|
||||
count={passed}
|
||||
color={theme.colors.statusSuccess}
|
||||
icon={checkSuccessIcon}
|
||||
testID="pr-pane-check-passed"
|
||||
/>
|
||||
<SummaryPill
|
||||
count={failed}
|
||||
color={theme.colors.statusDanger}
|
||||
icon={checkDangerIcon}
|
||||
testID="pr-pane-check-failed"
|
||||
/>
|
||||
<SummaryPill
|
||||
count={pending}
|
||||
color={theme.colors.statusWarning}
|
||||
icon={checkWarningIcon}
|
||||
testID="pr-pane-check-pending"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{data.checks.map((check) => (
|
||||
<CheckRow key={check.name} check={check} />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<Section
|
||||
title={t("workspace.git.pr.sections.reviews")}
|
||||
open={reviewsOpen}
|
||||
onToggle={handleToggleReviews}
|
||||
summary={
|
||||
<>
|
||||
<SummaryPill
|
||||
count={approvals}
|
||||
color={theme.colors.statusSuccess}
|
||||
icon={checkSuccessIcon}
|
||||
/>
|
||||
<SummaryPill
|
||||
count={changesRequested}
|
||||
color={theme.colors.statusDanger}
|
||||
icon={checkDangerIcon}
|
||||
/>
|
||||
<SummaryPill
|
||||
count={commentCount}
|
||||
color={theme.colors.foregroundMuted}
|
||||
icon={commentIcon}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{keyedActivity.map(({ key, item }) => (
|
||||
<ActivityRow key={key} item={item} />
|
||||
))}
|
||||
</Section>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionProps {
|
||||
title: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
summary: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Section({ title, open, onToggle, summary, children }: SectionProps) {
|
||||
const { theme } = useUnistyles();
|
||||
return (
|
||||
<View style={open ? styles.sectionOpen : undefined}>
|
||||
<Pressable style={styles.sectionHeader} onPress={onToggle}>
|
||||
{open ? (
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
) : (
|
||||
<ChevronRight size={14} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
<View style={styles.summaryWrap}>{summary}</View>
|
||||
</Pressable>
|
||||
{open && (
|
||||
<ScrollView
|
||||
style={styles.sectionBody}
|
||||
contentContainerStyle={styles.sectionBodyContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryPill({
|
||||
count,
|
||||
color,
|
||||
icon,
|
||||
testID,
|
||||
}: {
|
||||
count: number;
|
||||
color: string;
|
||||
icon: React.ReactNode;
|
||||
testID?: string;
|
||||
}) {
|
||||
const textStyle = useMemo(() => [styles.summaryPillText, { color }], [color]);
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<View style={styles.summaryPill} testID={testID}>
|
||||
{icon}
|
||||
<Text style={textStyle}>{count}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckRow({ check }: { check: PrPaneCheck }) {
|
||||
const handlePress = useCallback(() => {
|
||||
void openExternalUrl(check.url);
|
||||
}, [check.url]);
|
||||
return (
|
||||
<Pressable onPress={handlePress} style={rowPressableStyle}>
|
||||
<CheckStatusIcon status={check.status} />
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||
{check.name}
|
||||
</Text>
|
||||
{check.workflow && (
|
||||
<Text style={styles.rowMetaMid} numberOfLines={1}>
|
||||
{check.workflow}
|
||||
</Text>
|
||||
)}
|
||||
{check.duration && <Text style={styles.rowMeta}>{check.duration}</Text>}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckStatusIcon({ status }: { status: CheckStatus }) {
|
||||
const { theme } = useUnistyles();
|
||||
if (status === "success") return <CircleCheck size={14} color={theme.colors.statusSuccess} />;
|
||||
if (status === "failure") return <CircleX size={14} color={theme.colors.statusDanger} />;
|
||||
if (status === "pending") return <CircleDot size={14} color={theme.colors.statusWarning} />;
|
||||
return <CircleSlash size={14} color={theme.colors.foregroundMuted} />;
|
||||
}
|
||||
|
||||
function ActivityRow({ item }: { item: PrPaneActivity }) {
|
||||
const { t } = useTranslation();
|
||||
const verb = getTranslatedActivityVerb(item, t);
|
||||
const age = item.age === "just now" ? t("workspace.git.pr.time.justNow") : item.age;
|
||||
const handlePress = useCallback(() => {
|
||||
void openExternalUrl(item.url);
|
||||
}, [item.url]);
|
||||
const avatarStyle = useMemo(
|
||||
() => [styles.avatar, { backgroundColor: item.avatarColor }],
|
||||
[item.avatarColor],
|
||||
);
|
||||
return (
|
||||
<Pressable onPress={handlePress} style={activityPressableStyle} testID="pr-pane-activity-row">
|
||||
<View style={avatarStyle}>
|
||||
<Text style={styles.avatarText}>{item.author.slice(0, 1).toUpperCase()}</Text>
|
||||
</View>
|
||||
<View style={styles.activityMain}>
|
||||
<View style={styles.activityHeader}>
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||
{item.author}
|
||||
</Text>
|
||||
<Text style={styles.rowMetaMid}>{verb}</Text>
|
||||
<Text style={styles.rowMeta}>{age}</Text>
|
||||
</View>
|
||||
<Text style={styles.rowBody} numberOfLines={2}>
|
||||
{item.body}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function getTranslatedActivityVerb(
|
||||
item: Pick<PrPaneActivity, "kind" | "reviewState">,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
if (item.kind === "comment") return t("workspace.git.pr.activity.commented");
|
||||
if (item.reviewState === "approved") return t("workspace.git.pr.activity.approved");
|
||||
if (item.reviewState === "changes_requested") {
|
||||
return t("workspace.git.pr.activity.requestedChanges");
|
||||
}
|
||||
return t("workspace.git.pr.activity.reviewed");
|
||||
}
|
||||
|
||||
function getStateColor(state: PrState, theme: ReturnType<typeof useUnistyles>["theme"]): string {
|
||||
if (state === "open") return theme.colors.statusSuccess;
|
||||
if (state === "draft") return theme.colors.foregroundMuted;
|
||||
if (state === "merged") return theme.colors.statusMerged;
|
||||
return theme.colors.statusDanger;
|
||||
}
|
||||
|
||||
function getStateIcon(state: PrState) {
|
||||
if (state === "draft") return GitPullRequestDraft;
|
||||
if (state === "merged") return GitMerge;
|
||||
if (state === "closed") return GitPullRequestClosed;
|
||||
return GitPullRequest;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
hoverable: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "column",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[4],
|
||||
},
|
||||
stateLine: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
stateLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
title: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foreground,
|
||||
lineHeight: 19,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
sectionOpen: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
sectionBody: {
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
sectionBodyContent: {
|
||||
paddingBottom: theme.spacing[3],
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
summaryWrap: {
|
||||
marginLeft: "auto",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
summaryPill: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
},
|
||||
summaryPillText: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
activityRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
activityMain: { flex: 1, minWidth: 0, gap: 2 },
|
||||
activityHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
rowTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foreground,
|
||||
flexShrink: 1,
|
||||
},
|
||||
rowMeta: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
marginLeft: "auto",
|
||||
},
|
||||
rowMetaMid: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
flexShrink: 1,
|
||||
},
|
||||
rowBody: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
lineHeight: 16,
|
||||
},
|
||||
avatar: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginTop: 1,
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 10,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: "#fff",
|
||||
},
|
||||
}));
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PrPaneData } from "./pr-pane-data";
|
||||
import { formatPrTabLabel } from "./pr-tab-label";
|
||||
|
||||
function makePrPaneData(overrides: Partial<PrPaneData> = {}): PrPaneData {
|
||||
return {
|
||||
number: 42,
|
||||
title: "Sample PR",
|
||||
state: "open",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42",
|
||||
reviewDecision: "pending",
|
||||
awaitingReviewers: [],
|
||||
checks: [],
|
||||
activity: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("formatPrTabLabel", () => {
|
||||
it("returns #<number> when data is present", () => {
|
||||
expect(formatPrTabLabel(makePrPaneData({ number: 42 }))).toBe("#42");
|
||||
});
|
||||
|
||||
it("returns #— fallback when data is null", () => {
|
||||
expect(formatPrTabLabel(null)).toBe("#—");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { PrPaneData } from "./pr-pane-data";
|
||||
|
||||
export function formatPrTabLabel(data: PrPaneData | null): string {
|
||||
return data ? `#${data.number}` : "#—";
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatPullRequestActivityLocation } from "./activity-location";
|
||||
import type { PrPaneActivity } from "./data";
|
||||
|
||||
function activity(location: PrPaneActivity["location"]): PrPaneActivity {
|
||||
return {
|
||||
id: "comment-1",
|
||||
provider: "github",
|
||||
kind: "comment",
|
||||
author: "octocat",
|
||||
avatarColor: "#0ea5e9",
|
||||
body: "Looks good.",
|
||||
age: "3d ago",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#discussion_r1",
|
||||
location,
|
||||
};
|
||||
}
|
||||
|
||||
describe("pull request activity location labels", () => {
|
||||
it("includes inline thread state and concise thread IDs", () => {
|
||||
expect(
|
||||
formatPullRequestActivityLocation(
|
||||
activity({
|
||||
path: "packages/app/src/panel.tsx",
|
||||
startLine: 10,
|
||||
line: 12,
|
||||
threadId: "PRRT_1",
|
||||
isResolved: false,
|
||||
isOutdated: true,
|
||||
}),
|
||||
),
|
||||
).toBe("packages/app/src/panel.tsx:10-12 · unresolved · outdated · thread PRRT_1");
|
||||
});
|
||||
|
||||
it("includes current/resolved state without noisy long thread IDs", () => {
|
||||
expect(
|
||||
formatPullRequestActivityLocation(
|
||||
activity({
|
||||
path: "packages/app/src/panel.tsx",
|
||||
line: 12,
|
||||
threadId: "PRRT_kwDOAReallyLongOpaqueThreadIdentifier",
|
||||
isResolved: true,
|
||||
isOutdated: false,
|
||||
}),
|
||||
),
|
||||
).toBe("packages/app/src/panel.tsx:12 · resolved · current");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { PrPaneActivity } from "./data";
|
||||
|
||||
export function formatPullRequestActivityLocation(activity: PrPaneActivity): string | null {
|
||||
if (!activity.location) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = [formatPathAndLines(activity.location)];
|
||||
const threadState = formatThreadState(activity.location);
|
||||
if (threadState) {
|
||||
parts.push(...threadState);
|
||||
}
|
||||
const threadLabel = formatThreadLabel(activity.location.threadId);
|
||||
if (threadLabel) {
|
||||
parts.push(threadLabel);
|
||||
}
|
||||
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function formatPathAndLines(location: NonNullable<PrPaneActivity["location"]>): string {
|
||||
if (location.line !== undefined && location.startLine !== undefined) {
|
||||
return `${location.path}:${location.startLine}-${location.line}`;
|
||||
}
|
||||
if (location.line !== undefined) {
|
||||
return `${location.path}:${location.line}`;
|
||||
}
|
||||
return location.path;
|
||||
}
|
||||
|
||||
/** Path-and-line label for a review thread header, e.g. "src/foo.ts:12-14". */
|
||||
export function formatPullRequestThreadPath(
|
||||
location: NonNullable<PrPaneActivity["location"]>,
|
||||
): string {
|
||||
return formatPathAndLines(location);
|
||||
}
|
||||
|
||||
function formatThreadState(location: NonNullable<PrPaneActivity["location"]>): string[] | null {
|
||||
const state = [];
|
||||
if (location.isResolved !== undefined) {
|
||||
state.push(location.isResolved ? "resolved" : "unresolved");
|
||||
}
|
||||
if (location.isOutdated !== undefined) {
|
||||
state.push(location.isOutdated ? "outdated" : "current");
|
||||
}
|
||||
return state.length > 0 ? state : null;
|
||||
}
|
||||
|
||||
function formatThreadLabel(threadId: string | undefined): string | null {
|
||||
if (!threadId || threadId.length > 24) {
|
||||
return null;
|
||||
}
|
||||
return `thread ${threadId}`;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collapseActivity,
|
||||
expandActivity,
|
||||
getActivityState,
|
||||
getCollapsedEntryIds,
|
||||
getVisibleEntries,
|
||||
} from "./activity-state";
|
||||
import type { PrTimelineEntry } from "./timeline";
|
||||
import type { PrPaneActivity } from "./data";
|
||||
|
||||
function activity(id: string, overrides: Partial<PrPaneActivity> = {}): PrPaneActivity {
|
||||
return {
|
||||
id,
|
||||
provider: "github",
|
||||
kind: "comment",
|
||||
author: "octocat",
|
||||
avatarColor: "#0ea5e9",
|
||||
body: "Looks good.",
|
||||
age: "3d ago",
|
||||
url: `https://github.com/getpaseo/paseo/pull/42#${id}`,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function singleEntry(id: string, resolved = false, outdated = false): PrTimelineEntry {
|
||||
return {
|
||||
kind: "single",
|
||||
id,
|
||||
activity: activity(id, {
|
||||
location:
|
||||
resolved || outdated
|
||||
? { path: "a.ts", line: 1, isResolved: resolved, isOutdated: outdated }
|
||||
: undefined,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function threadEntry(id: string, resolved = false, outdated = false): PrTimelineEntry {
|
||||
return {
|
||||
kind: "thread",
|
||||
id: `thread:${id}`,
|
||||
location: { path: "a.ts", line: 1, isResolved: resolved, isOutdated: outdated },
|
||||
comments: [activity(id)],
|
||||
};
|
||||
}
|
||||
|
||||
function reviewEntry(id: string, threads: PrTimelineEntry[]): PrTimelineEntry {
|
||||
return {
|
||||
kind: "review",
|
||||
id,
|
||||
review: activity(id, { kind: "review", reviewState: "commented" }),
|
||||
threads: threads.filter((entry) => entry.kind === "thread"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("pull request activity state", () => {
|
||||
it("collapses and expands activity by PR-scoped stable key", () => {
|
||||
const collapsed = collapseActivity(getActivityState(), {
|
||||
prNumber: 42,
|
||||
activityId: "comment-1",
|
||||
});
|
||||
|
||||
expect(collapsed.collapsedKeys).toEqual(["42:comment-1"]);
|
||||
expect(expandActivity(collapsed, { prNumber: 42, activityId: "comment-1" })).toEqual({
|
||||
collapsedKeys: [],
|
||||
expandedKeys: ["42:comment-1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("collapses resolved and outdated entries by default", () => {
|
||||
const entries = [
|
||||
singleEntry("normal"),
|
||||
singleEntry("resolved", true),
|
||||
singleEntry("outdated", false, true),
|
||||
threadEntry("thread-normal"),
|
||||
threadEntry("thread-resolved", true),
|
||||
threadEntry("thread-outdated", false, true),
|
||||
];
|
||||
|
||||
const visible = getVisibleEntries(getActivityState(), { prNumber: 42, entries });
|
||||
|
||||
expect(visible.map((v) => ({ id: v.entry.id, collapsed: v.collapsed }))).toEqual([
|
||||
{ id: "normal", collapsed: false },
|
||||
{ id: "resolved", collapsed: true },
|
||||
{ id: "outdated", collapsed: true },
|
||||
{ id: "thread:thread-normal", collapsed: false },
|
||||
{ id: "thread:thread-resolved", collapsed: true },
|
||||
{ id: "thread:thread-outdated", collapsed: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("lets user expand a default-collapsed entry", () => {
|
||||
const entries = [threadEntry("resolved", true)];
|
||||
const expanded = expandActivity(getActivityState(), {
|
||||
prNumber: 42,
|
||||
activityId: "thread:resolved",
|
||||
});
|
||||
|
||||
const visible = getVisibleEntries(expanded, { prNumber: 42, entries });
|
||||
expect(visible[0].collapsed).toBe(false);
|
||||
});
|
||||
|
||||
it("lets user collapse a default-expanded entry", () => {
|
||||
const entries = [threadEntry("normal")];
|
||||
const collapsed = collapseActivity(getActivityState(), {
|
||||
prNumber: 42,
|
||||
activityId: "thread:normal",
|
||||
});
|
||||
|
||||
const visible = getVisibleEntries(collapsed, { prNumber: 42, entries });
|
||||
expect(visible[0].collapsed).toBe(true);
|
||||
});
|
||||
|
||||
it("includes default-collapsed nested review threads in collapsed IDs", () => {
|
||||
const entries = [
|
||||
reviewEntry("review", [
|
||||
threadEntry("thread-normal"),
|
||||
threadEntry("thread-resolved", true),
|
||||
threadEntry("thread-outdated", false, true),
|
||||
]),
|
||||
];
|
||||
|
||||
const collapsedIds = getCollapsedEntryIds(getActivityState(), { prNumber: 42, entries });
|
||||
|
||||
expect([...collapsedIds].sort()).toEqual(["thread:thread-outdated", "thread:thread-resolved"]);
|
||||
});
|
||||
|
||||
it("lets user expand a default-collapsed nested review thread", () => {
|
||||
const entries = [reviewEntry("review", [threadEntry("thread-resolved", true)])];
|
||||
const expanded = expandActivity(getActivityState(), {
|
||||
prNumber: 42,
|
||||
activityId: "thread:thread-resolved",
|
||||
});
|
||||
|
||||
const collapsedIds = getCollapsedEntryIds(expanded, { prNumber: 42, entries });
|
||||
|
||||
expect(collapsedIds.has("thread:thread-resolved")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { PrTimelineEntry } from "./timeline";
|
||||
|
||||
export interface PullRequestActivityIdentity {
|
||||
prNumber: number;
|
||||
activityId: string;
|
||||
}
|
||||
|
||||
export interface PullRequestActivityState {
|
||||
collapsedKeys: readonly string[];
|
||||
expandedKeys: readonly string[];
|
||||
}
|
||||
|
||||
export interface VisiblePullRequestEntry {
|
||||
entry: PrTimelineEntry;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export function getActivityState(): PullRequestActivityState {
|
||||
return { collapsedKeys: [], expandedKeys: [] };
|
||||
}
|
||||
|
||||
export function getActivityStateKey(identity: PullRequestActivityIdentity): string {
|
||||
return `${identity.prNumber}:${identity.activityId}`;
|
||||
}
|
||||
|
||||
function shouldCollapseByDefault(entry: PrTimelineEntry): boolean {
|
||||
if (entry.kind === "thread") {
|
||||
return entry.location.isResolved === true || entry.location.isOutdated === true;
|
||||
}
|
||||
if (entry.kind === "single") {
|
||||
return (
|
||||
entry.activity.location?.isResolved === true || entry.activity.location?.isOutdated === true
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function collapseActivity(
|
||||
state: PullRequestActivityState,
|
||||
identity: PullRequestActivityIdentity,
|
||||
): PullRequestActivityState {
|
||||
const key = getActivityStateKey(identity);
|
||||
if (state.collapsedKeys.includes(key)) {
|
||||
return { ...state, expandedKeys: state.expandedKeys.filter((item) => item !== key) };
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
collapsedKeys: [...state.collapsedKeys, key],
|
||||
expandedKeys: state.expandedKeys.filter((item) => item !== key),
|
||||
};
|
||||
}
|
||||
|
||||
export function expandActivity(
|
||||
state: PullRequestActivityState,
|
||||
identity: PullRequestActivityIdentity,
|
||||
): PullRequestActivityState {
|
||||
const key = getActivityStateKey(identity);
|
||||
const collapsedKeys = state.collapsedKeys.filter((item) => item !== key);
|
||||
const expandedKeys = state.expandedKeys.includes(key)
|
||||
? state.expandedKeys
|
||||
: [...state.expandedKeys, key];
|
||||
return { ...state, collapsedKeys, expandedKeys };
|
||||
}
|
||||
|
||||
export function getVisibleEntries(
|
||||
state: PullRequestActivityState,
|
||||
input: { prNumber: number; entries: readonly PrTimelineEntry[] },
|
||||
): VisiblePullRequestEntry[] {
|
||||
return input.entries.map((entry) => {
|
||||
const key = getActivityStateKey({ prNumber: input.prNumber, activityId: entry.id });
|
||||
const collapsedByDefault = shouldCollapseByDefault(entry);
|
||||
const isExplicitlyCollapsed = state.collapsedKeys.includes(key);
|
||||
const isExplicitlyExpanded = state.expandedKeys.includes(key);
|
||||
|
||||
const collapsed = isExplicitlyCollapsed || (collapsedByDefault && !isExplicitlyExpanded);
|
||||
|
||||
return { entry, collapsed };
|
||||
});
|
||||
}
|
||||
|
||||
export function getCollapsedEntryIds(
|
||||
state: PullRequestActivityState,
|
||||
input: { prNumber: number; entries?: readonly PrTimelineEntry[] },
|
||||
): ReadonlySet<string> {
|
||||
const prefix = `${input.prNumber}:`;
|
||||
const collapsedIds = new Set(
|
||||
state.collapsedKeys
|
||||
.filter((key) => key.startsWith(prefix))
|
||||
.map((key) => key.slice(prefix.length)),
|
||||
);
|
||||
|
||||
addDefaultCollapsedEntryIds(collapsedIds, state, input.prNumber, input.entries ?? []);
|
||||
|
||||
return collapsedIds;
|
||||
}
|
||||
|
||||
function addDefaultCollapsedEntryIds(
|
||||
collapsedIds: Set<string>,
|
||||
state: PullRequestActivityState,
|
||||
prNumber: number,
|
||||
entries: readonly PrTimelineEntry[],
|
||||
) {
|
||||
for (const entry of entries) {
|
||||
const key = getActivityStateKey({ prNumber, activityId: entry.id });
|
||||
if (shouldCollapseByDefault(entry) && !state.expandedKeys.includes(key)) {
|
||||
collapsedIds.add(entry.id);
|
||||
}
|
||||
if (state.expandedKeys.includes(key)) {
|
||||
collapsedIds.delete(entry.id);
|
||||
}
|
||||
if (entry.kind === "review") {
|
||||
addDefaultCollapsedEntryIds(collapsedIds, state, prNumber, entry.threads);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildPullRequestCheckContextAttachment,
|
||||
buildPullRequestCommentContextAttachment,
|
||||
buildPullRequestReviewContextAttachment,
|
||||
buildPullRequestThreadContextAttachment,
|
||||
canAddPullRequestActivityToChat,
|
||||
canAddPullRequestCheckLogsToChat,
|
||||
type PullRequestContextBuilderInput,
|
||||
} from "./context-attachment";
|
||||
import type { PrPaneActivity, PrPaneCheck } from "./data";
|
||||
import type { PrThreadEntry } from "./timeline";
|
||||
|
||||
const baseInput: Omit<PullRequestContextBuilderInput, "activity"> = {
|
||||
provider: { id: "github", label: "GitHub" },
|
||||
pullRequest: {
|
||||
number: 42,
|
||||
title: "Fix flaky build",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42",
|
||||
},
|
||||
};
|
||||
|
||||
function comment(overrides: Partial<PrPaneActivity> = {}): PrPaneActivity {
|
||||
return {
|
||||
id: "comment-1",
|
||||
provider: "github",
|
||||
kind: "comment",
|
||||
author: "octocat",
|
||||
avatarColor: "#0ea5e9",
|
||||
body: "Looks good.",
|
||||
age: "3d ago",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#issuecomment-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function review(overrides: Partial<PrPaneActivity> = {}): PrPaneActivity {
|
||||
return {
|
||||
id: "review-1",
|
||||
provider: "github",
|
||||
kind: "review",
|
||||
author: "reviewer",
|
||||
avatarColor: "#8b5cf6",
|
||||
reviewState: "commented",
|
||||
body: "Please simplify this.",
|
||||
age: "2d ago",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function check(overrides: Partial<PrPaneCheck> = {}): PrPaneCheck {
|
||||
return {
|
||||
name: "server-tests",
|
||||
provider: "github",
|
||||
status: "failure",
|
||||
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
github: { checkRunId: 12345, workflowRunId: 456 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("pull request context attachments", () => {
|
||||
it("formats a comment as a workspace context attachment", () => {
|
||||
expect(
|
||||
buildPullRequestCommentContextAttachment({
|
||||
...baseInput,
|
||||
activity: comment({
|
||||
location: {
|
||||
path: "packages/app/src/panel.tsx",
|
||||
line: 42,
|
||||
threadId: "PRRT_1",
|
||||
isResolved: false,
|
||||
isOutdated: true,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "github.pull_request_comment",
|
||||
id: "42:comment-1",
|
||||
title: "octocat",
|
||||
subtitle: "#42 Fix flaky build",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#issuecomment-1",
|
||||
text: [
|
||||
"GitHub pull request comment",
|
||||
"Pull request: #42 Fix flaky build",
|
||||
"Pull request URL: https://github.com/getpaseo/paseo/pull/42",
|
||||
"URL: https://github.com/getpaseo/paseo/pull/42#issuecomment-1",
|
||||
"Author: octocat",
|
||||
"Created: 3d ago",
|
||||
"Location: packages/app/src/panel.tsx:42 · unresolved · outdated · thread PRRT_1",
|
||||
"",
|
||||
"Looks good.",
|
||||
].join("\n"),
|
||||
});
|
||||
});
|
||||
|
||||
it("formats a review with state as a workspace context attachment", () => {
|
||||
expect(buildPullRequestReviewContextAttachment({ ...baseInput, activity: review() })).toEqual({
|
||||
kind: "github.pull_request_review",
|
||||
id: "42:review-1",
|
||||
title: "reviewer",
|
||||
subtitle: "#42 Fix flaky build",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
|
||||
text: [
|
||||
"GitHub pull request review",
|
||||
"Pull request: #42 Fix flaky build",
|
||||
"Pull request URL: https://github.com/getpaseo/paseo/pull/42",
|
||||
"URL: https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
|
||||
"Author: reviewer",
|
||||
"State: commented",
|
||||
"Created: 2d ago",
|
||||
"",
|
||||
"Please simplify this.",
|
||||
].join("\n"),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not add bodyless approvals to chat", () => {
|
||||
const activity = review({ reviewState: "approved", body: "" });
|
||||
|
||||
expect(canAddPullRequestActivityToChat(activity)).toBe(false);
|
||||
expect(buildPullRequestReviewContextAttachment({ ...baseInput, activity })).toBeNull();
|
||||
});
|
||||
|
||||
it("adds bodyless changes-requested reviews as metadata-only context", () => {
|
||||
const activity = review({ reviewState: "changes_requested", body: "" });
|
||||
|
||||
expect(canAddPullRequestActivityToChat(activity)).toBe(true);
|
||||
expect(buildPullRequestReviewContextAttachment({ ...baseInput, activity })?.text).toBe(
|
||||
[
|
||||
"GitHub pull request review",
|
||||
"Pull request: #42 Fix flaky build",
|
||||
"Pull request URL: https://github.com/getpaseo/paseo/pull/42",
|
||||
"URL: https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
|
||||
"Author: reviewer",
|
||||
"State: changes_requested",
|
||||
"Created: 2d ago",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows add-logs only for failed checks", () => {
|
||||
expect(canAddPullRequestCheckLogsToChat(check({ status: "failure" }))).toBe(true);
|
||||
expect(canAddPullRequestCheckLogsToChat(check({ status: "success" }))).toBe(false);
|
||||
expect(canAddPullRequestCheckLogsToChat(check({ status: "pending" }))).toBe(false);
|
||||
expect(canAddPullRequestCheckLogsToChat(check({ status: "skipped" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("formats failed check details as a workspace context attachment", () => {
|
||||
expect(
|
||||
buildPullRequestCheckContextAttachment({
|
||||
...baseInput,
|
||||
check: check(),
|
||||
githubDetails: {
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
name: "server-tests",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
detailsUrl: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
output: { title: "Tests failed", summary: "1 failure", text: "Assertion failed" },
|
||||
annotations: [
|
||||
{
|
||||
path: "packages/server/src/index.ts",
|
||||
startLine: 10,
|
||||
endLine: 12,
|
||||
annotationLevel: "failure",
|
||||
message: "Expected true",
|
||||
},
|
||||
],
|
||||
failedJobs: [
|
||||
{
|
||||
jobId: 789,
|
||||
name: "test",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
logTail: "last line",
|
||||
logTruncated: false,
|
||||
},
|
||||
],
|
||||
truncated: true,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "github.pull_request_check",
|
||||
id: "42:check-run:12345",
|
||||
title: "server-tests",
|
||||
subtitle: "#42 Fix flaky build",
|
||||
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
text: [
|
||||
"GitHub pull request check",
|
||||
"Pull request: #42 Fix flaky build",
|
||||
"Pull request URL: https://github.com/getpaseo/paseo/pull/42",
|
||||
"Check: server-tests",
|
||||
"Status: failure",
|
||||
"Conclusion: failure",
|
||||
"Check URL: https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
"Details URL: https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
"Output title: Tests failed",
|
||||
"Output summary: 1 failure",
|
||||
"Output text:",
|
||||
"Assertion failed",
|
||||
"",
|
||||
"Annotations:",
|
||||
"- packages/server/src/index.ts:10-12 failure: Expected true",
|
||||
"",
|
||||
"Failed jobs:",
|
||||
"- test: failure",
|
||||
" https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
" ```",
|
||||
" last line",
|
||||
" ```",
|
||||
"",
|
||||
"Note: Check details were truncated by GitHub/API or local caps.",
|
||||
].join("\n"),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps same-named GitHub checks distinct by check run", () => {
|
||||
const ubuntu = buildPullRequestCheckContextAttachment({
|
||||
...baseInput,
|
||||
check: check({ github: { checkRunId: 12345, workflowRunId: 456 } }),
|
||||
githubDetails: null,
|
||||
});
|
||||
const windows = buildPullRequestCheckContextAttachment({
|
||||
...baseInput,
|
||||
check: check({ github: { checkRunId: 67890, workflowRunId: 456 } }),
|
||||
githubDetails: null,
|
||||
});
|
||||
|
||||
expect(ubuntu.id).toBe("42:check-run:12345");
|
||||
expect(windows.id).toBe("42:check-run:67890");
|
||||
});
|
||||
|
||||
it("formats metadata-only failed check context when details are unavailable", () => {
|
||||
expect(
|
||||
buildPullRequestCheckContextAttachment({
|
||||
...baseInput,
|
||||
check: check({
|
||||
name: "status/context",
|
||||
url: "https://github.com/getpaseo/paseo/status/context",
|
||||
github: undefined,
|
||||
}),
|
||||
githubDetails: null,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "github.pull_request_check",
|
||||
id: "42:check:status/context",
|
||||
title: "status/context",
|
||||
subtitle: "#42 Fix flaky build",
|
||||
url: "https://github.com/getpaseo/paseo/status/context",
|
||||
text: [
|
||||
"GitHub pull request check",
|
||||
"Pull request: #42 Fix flaky build",
|
||||
"Pull request URL: https://github.com/getpaseo/paseo/pull/42",
|
||||
"Check: status/context",
|
||||
"Status: failure",
|
||||
"Check URL: https://github.com/getpaseo/paseo/status/context",
|
||||
].join("\n"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPullRequestThreadContextAttachment", () => {
|
||||
function thread(overrides: Partial<PrThreadEntry> = {}): PrThreadEntry {
|
||||
return {
|
||||
kind: "thread",
|
||||
id: "thread:PRRT_1",
|
||||
location: { path: "src/a.ts", line: 12, threadId: "PRRT_1", isResolved: false },
|
||||
comments: [
|
||||
comment({
|
||||
id: "t1",
|
||||
body: "This is frozen after initial registration.",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#discussion_r1",
|
||||
}),
|
||||
comment({ id: "t2", author: "mo", age: "1d ago", body: "Good catch, fixing." }),
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("bundles the whole thread conversation into one attachment", () => {
|
||||
expect(buildPullRequestThreadContextAttachment({ ...baseInput, thread: thread() })).toEqual({
|
||||
kind: "github.pull_request_comment",
|
||||
id: "42:thread:PRRT_1",
|
||||
title: "src/a.ts:12",
|
||||
subtitle: "#42 Fix flaky build",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#discussion_r1",
|
||||
text: [
|
||||
"GitHub pull request review thread",
|
||||
"Pull request: #42 Fix flaky build",
|
||||
"Pull request URL: https://github.com/getpaseo/paseo/pull/42",
|
||||
"URL: https://github.com/getpaseo/paseo/pull/42#discussion_r1",
|
||||
"Location: src/a.ts:12",
|
||||
"Thread state: unresolved",
|
||||
"",
|
||||
"octocat (3d ago):\nThis is frozen after initial registration.",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"mo (1d ago):\nGood catch, fixing.",
|
||||
].join("\n"),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when no comment has a body", () => {
|
||||
expect(
|
||||
buildPullRequestThreadContextAttachment({
|
||||
...baseInput,
|
||||
thread: thread({ comments: [comment({ body: " " })] }),
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { CheckoutGithubCheckDetails } from "@getpaseo/protocol/messages";
|
||||
import type { PullRequestContextAttachment } from "@/attachments/types";
|
||||
import {
|
||||
formatPullRequestActivityLocation,
|
||||
formatPullRequestThreadPath,
|
||||
} from "./activity-location";
|
||||
import type { PrPaneActivity, PrPaneCheck, PullRequestProviderMetadata, ReviewState } from "./data";
|
||||
import type { PrThreadEntry } from "./timeline";
|
||||
|
||||
export interface PullRequestContextMetadata {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PullRequestContextBuilderInput {
|
||||
provider: PullRequestProviderMetadata;
|
||||
pullRequest: PullRequestContextMetadata;
|
||||
activity: PrPaneActivity;
|
||||
}
|
||||
|
||||
export interface PullRequestThreadContextBuilderInput {
|
||||
provider: PullRequestProviderMetadata;
|
||||
pullRequest: PullRequestContextMetadata;
|
||||
thread: PrThreadEntry;
|
||||
}
|
||||
|
||||
export interface PullRequestGithubCheckContextBuilderInput {
|
||||
provider: PullRequestProviderMetadata & { id: "github" };
|
||||
pullRequest: PullRequestContextMetadata;
|
||||
check: PrPaneCheck & { provider: "github" };
|
||||
githubDetails?: CheckoutGithubCheckDetails | null;
|
||||
}
|
||||
|
||||
export function canAddPullRequestActivityToChat(activity: PrPaneActivity): boolean {
|
||||
if (activity.kind === "comment") {
|
||||
return activity.body.trim().length > 0;
|
||||
}
|
||||
return activity.body.trim().length > 0 || activity.reviewState === "changes_requested";
|
||||
}
|
||||
|
||||
export function canAddPullRequestCheckLogsToChat(check: PrPaneCheck): boolean {
|
||||
return check.status === "failure";
|
||||
}
|
||||
|
||||
export function buildPullRequestCommentContextAttachment(
|
||||
input: PullRequestContextBuilderInput,
|
||||
): PullRequestContextAttachment {
|
||||
return {
|
||||
kind: "github.pull_request_comment",
|
||||
id: `${input.pullRequest.number}:${input.activity.id}`,
|
||||
title: input.activity.author,
|
||||
subtitle: formatPullRequestSubtitle(input.pullRequest),
|
||||
text: formatActivityContextText({
|
||||
...input,
|
||||
heading: `${input.provider.label} pull request comment`,
|
||||
}),
|
||||
url: input.activity.url,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPullRequestReviewContextAttachment(
|
||||
input: PullRequestContextBuilderInput,
|
||||
): PullRequestContextAttachment | null {
|
||||
if (!canAddPullRequestActivityToChat(input.activity)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "github.pull_request_review",
|
||||
id: `${input.pullRequest.number}:${input.activity.id}`,
|
||||
title: input.activity.author,
|
||||
subtitle: formatPullRequestSubtitle(input.pullRequest),
|
||||
text: formatActivityContextText({
|
||||
...input,
|
||||
heading: `${input.provider.label} pull request review`,
|
||||
reviewState: input.activity.reviewState,
|
||||
}),
|
||||
url: input.activity.url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches a whole review thread (root comment plus replies) as one
|
||||
* attachment, so the agent gets the full conversation around a code location.
|
||||
*/
|
||||
export function buildPullRequestThreadContextAttachment(
|
||||
input: PullRequestThreadContextBuilderInput,
|
||||
): PullRequestContextAttachment | null {
|
||||
const comments = input.thread.comments.filter((comment) => comment.body.trim().length > 0);
|
||||
const root = input.thread.comments[0];
|
||||
if (comments.length === 0 || !root) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`${input.provider.label} pull request review thread`,
|
||||
`Pull request: #${input.pullRequest.number} ${input.pullRequest.title}`,
|
||||
`Pull request URL: ${input.pullRequest.url}`,
|
||||
`URL: ${root.url}`,
|
||||
`Location: ${formatPullRequestThreadPath(input.thread.location)}`,
|
||||
];
|
||||
if (input.thread.location.isResolved !== undefined) {
|
||||
lines.push(`Thread state: ${input.thread.location.isResolved ? "resolved" : "unresolved"}`);
|
||||
}
|
||||
if (input.thread.location.isOutdated) {
|
||||
lines.push("Note: this thread is outdated (the code it refers to has changed)");
|
||||
}
|
||||
|
||||
const conversation = comments.map(
|
||||
(comment) => `${comment.author} (${comment.age}):\n${comment.body.trim()}`,
|
||||
);
|
||||
|
||||
return {
|
||||
kind: "github.pull_request_comment",
|
||||
id: `${input.pullRequest.number}:${input.thread.id}`,
|
||||
title: formatPullRequestThreadPath(input.thread.location),
|
||||
subtitle: formatPullRequestSubtitle(input.pullRequest),
|
||||
text: [...lines, "", conversation.join("\n\n---\n\n")].join("\n"),
|
||||
url: root.url,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPullRequestCheckContextAttachment(
|
||||
input: PullRequestGithubCheckContextBuilderInput,
|
||||
): PullRequestContextAttachment {
|
||||
return {
|
||||
kind: "github.pull_request_check",
|
||||
id: formatPullRequestCheckContextId(input.pullRequest, input.check),
|
||||
title: input.check.name,
|
||||
subtitle: formatPullRequestSubtitle(input.pullRequest),
|
||||
text: formatGitHubCheckContextText(input),
|
||||
url: input.githubDetails?.detailsUrl ?? input.githubDetails?.url ?? input.check.url,
|
||||
};
|
||||
}
|
||||
|
||||
function formatPullRequestCheckContextId(
|
||||
pullRequest: PullRequestContextMetadata,
|
||||
check: PrPaneCheck,
|
||||
): string {
|
||||
if (check.github?.checkRunId !== undefined) {
|
||||
return `${pullRequest.number}:check-run:${check.github.checkRunId}`;
|
||||
}
|
||||
return `${pullRequest.number}:check:${check.name}`;
|
||||
}
|
||||
|
||||
function formatGitHubCheckContextText({
|
||||
provider,
|
||||
pullRequest,
|
||||
check,
|
||||
githubDetails,
|
||||
}: PullRequestGithubCheckContextBuilderInput): string {
|
||||
const lines = [
|
||||
`${provider.label} pull request check`,
|
||||
`Pull request: #${pullRequest.number} ${pullRequest.title}`,
|
||||
`Pull request URL: ${pullRequest.url}`,
|
||||
`Check: ${check.name}`,
|
||||
`Status: ${check.status}`,
|
||||
];
|
||||
|
||||
if (githubDetails?.conclusion) {
|
||||
lines.push(`Conclusion: ${githubDetails.conclusion}`);
|
||||
}
|
||||
lines.push(`Check URL: ${check.url}`);
|
||||
const detailsUrl = githubDetails?.detailsUrl ?? githubDetails?.url;
|
||||
if (detailsUrl) {
|
||||
lines.push(`Details URL: ${detailsUrl}`);
|
||||
}
|
||||
appendGitHubCheckOutput(lines, githubDetails);
|
||||
appendGitHubCheckAnnotations(lines, githubDetails);
|
||||
appendGitHubFailedJobs(lines, githubDetails);
|
||||
if (githubDetails?.truncated) {
|
||||
lines.push("", "Note: Check details were truncated by GitHub/API or local caps.");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function appendGitHubCheckOutput(
|
||||
lines: string[],
|
||||
details: CheckoutGithubCheckDetails | null | undefined,
|
||||
) {
|
||||
if (details?.output?.title) {
|
||||
lines.push(`Output title: ${details.output.title}`);
|
||||
}
|
||||
if (details?.output?.summary) {
|
||||
lines.push(`Output summary: ${details.output.summary}`);
|
||||
}
|
||||
if (details?.output?.text) {
|
||||
lines.push("Output text:", details.output.text);
|
||||
}
|
||||
}
|
||||
|
||||
function appendGitHubCheckAnnotations(
|
||||
lines: string[],
|
||||
details: CheckoutGithubCheckDetails | null | undefined,
|
||||
) {
|
||||
if (!details?.annotations?.length) {
|
||||
return;
|
||||
}
|
||||
lines.push("", "Annotations:");
|
||||
for (const annotation of details.annotations) {
|
||||
lines.push(`- ${formatAnnotation(annotation)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function appendGitHubFailedJobs(
|
||||
lines: string[],
|
||||
details: CheckoutGithubCheckDetails | null | undefined,
|
||||
) {
|
||||
if (!details?.failedJobs?.length) {
|
||||
return;
|
||||
}
|
||||
lines.push("", "Failed jobs:");
|
||||
for (const job of details.failedJobs) {
|
||||
lines.push(`- ${job.name}: ${job.conclusion ?? job.status ?? "unknown"}`);
|
||||
if (job.url) {
|
||||
lines.push(` ${job.url}`);
|
||||
}
|
||||
if (job.logTail) {
|
||||
lines.push(" ```", ...job.logTail.split("\n").map((line) => ` ${line}`), " ```");
|
||||
}
|
||||
if (job.logTruncated) {
|
||||
lines.push(" Log tail truncated to the latest capped lines.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatAnnotation(annotation: CheckoutGithubCheckDetails["annotations"][number]): string {
|
||||
const location = annotation.path
|
||||
? `${annotation.path}${formatAnnotationLines(annotation)}`
|
||||
: "unknown location";
|
||||
const level = annotation.annotationLevel ? ` ${annotation.annotationLevel}` : "";
|
||||
const message = annotation.message ? `: ${annotation.message}` : "";
|
||||
return `${location}${level}${message}`;
|
||||
}
|
||||
|
||||
function formatAnnotationLines(
|
||||
annotation: CheckoutGithubCheckDetails["annotations"][number],
|
||||
): string {
|
||||
if (annotation.startLine !== undefined && annotation.endLine !== undefined) {
|
||||
return `:${annotation.startLine}-${annotation.endLine}`;
|
||||
}
|
||||
if (annotation.startLine !== undefined) {
|
||||
return `:${annotation.startLine}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatActivityContextText({
|
||||
heading,
|
||||
pullRequest,
|
||||
activity,
|
||||
reviewState,
|
||||
}: PullRequestContextBuilderInput & { heading: string; reviewState?: ReviewState }): string {
|
||||
const lines = [
|
||||
heading,
|
||||
`Pull request: #${pullRequest.number} ${pullRequest.title}`,
|
||||
`Pull request URL: ${pullRequest.url}`,
|
||||
`URL: ${activity.url}`,
|
||||
`Author: ${activity.author}`,
|
||||
];
|
||||
|
||||
if (reviewState) {
|
||||
lines.push(`State: ${reviewState}`);
|
||||
}
|
||||
if (activity.age) {
|
||||
lines.push(`Created: ${activity.age}`);
|
||||
}
|
||||
if (activity.location) {
|
||||
lines.push(`Location: ${formatPullRequestActivityLocation(activity)}`);
|
||||
}
|
||||
|
||||
const body = activity.body.trim();
|
||||
if (body.length === 0) {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
return [...lines, "", body].join("\n");
|
||||
}
|
||||
|
||||
function formatPullRequestSubtitle(pullRequest: PullRequestContextMetadata): string {
|
||||
return `#${pullRequest.number} ${pullRequest.title}`;
|
||||
}
|
||||
+86
-5
@@ -9,7 +9,7 @@ import {
|
||||
getActivityVerb,
|
||||
getStateLabel,
|
||||
mapPrPaneData,
|
||||
} from "./pr-pane-data";
|
||||
} from "./data";
|
||||
|
||||
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
|
||||
type PullRequestTimeline = PullRequestTimelineResponse["payload"];
|
||||
@@ -118,6 +118,7 @@ describe("mapPrPaneData", () => {
|
||||
|
||||
expect(data?.checks).toEqual([
|
||||
{
|
||||
provider: "github",
|
||||
name: "typecheck",
|
||||
status: "success",
|
||||
url: "https://example.com/checks/1",
|
||||
@@ -147,16 +148,44 @@ describe("mapPrPaneData", () => {
|
||||
|
||||
expect(data?.checks).toEqual([
|
||||
{
|
||||
provider: "github",
|
||||
name: "success",
|
||||
workflow: "CI",
|
||||
status: "success",
|
||||
duration: "1m",
|
||||
url: "https://example.com/1",
|
||||
},
|
||||
{ name: "failure", status: "failure", url: "https://example.com/2" },
|
||||
{ name: "pending", status: "pending", url: "https://example.com/3" },
|
||||
{ name: "skipped", status: "skipped", url: "https://example.com/4" },
|
||||
{ name: "cancelled", status: "skipped", url: "https://example.com/5" },
|
||||
{ provider: "github", name: "failure", status: "failure", url: "https://example.com/2" },
|
||||
{ provider: "github", name: "pending", status: "pending", url: "https://example.com/3" },
|
||||
{ provider: "github", name: "skipped", status: "skipped", url: "https://example.com/4" },
|
||||
{ provider: "github", name: "cancelled", status: "skipped", url: "https://example.com/5" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps GitHub check identifiers into provider refs", () => {
|
||||
const data = mapPrPaneData(
|
||||
status({
|
||||
checks: [
|
||||
{
|
||||
name: "server-tests",
|
||||
status: "failure",
|
||||
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
},
|
||||
],
|
||||
}),
|
||||
baseTimeline,
|
||||
);
|
||||
|
||||
expect(data?.checks).toEqual([
|
||||
{
|
||||
provider: "github",
|
||||
name: "server-tests",
|
||||
status: "failure",
|
||||
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
github: { checkRunId: 12345, workflowRunId: 456 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -191,6 +220,58 @@ describe("mapPrPaneData", () => {
|
||||
expect(data?.activity.map((item) => item.author)).toEqual(["alice", "bob"]);
|
||||
});
|
||||
|
||||
it("maps timeline author avatar URLs and inline comment location metadata", () => {
|
||||
const data = mapPrPaneData(
|
||||
baseStatus,
|
||||
timeline({
|
||||
items: [
|
||||
{
|
||||
id: "thread-comment-1",
|
||||
kind: "comment",
|
||||
author: "inline-reviewer",
|
||||
authorUrl: "https://github.com/inline-reviewer",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/3?v=4",
|
||||
body: "This should include line context.",
|
||||
createdAt: Date.UTC(2026, 0, 1, 11, 0, 0),
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#discussion_r1",
|
||||
location: {
|
||||
path: "packages/app/src/git/pull-request-panel/data.ts",
|
||||
line: 24,
|
||||
startLine: 20,
|
||||
threadId: "PRRT_1",
|
||||
isResolved: true,
|
||||
isOutdated: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
Date.UTC(2026, 0, 1, 12, 0, 0),
|
||||
);
|
||||
|
||||
expect(data?.activity).toEqual([
|
||||
{
|
||||
id: "thread-comment-1",
|
||||
provider: "github",
|
||||
kind: "comment",
|
||||
author: "inline-reviewer",
|
||||
authorUrl: "https://github.com/inline-reviewer",
|
||||
avatarColor: deriveAvatarColor("inline-reviewer"),
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/3?v=4",
|
||||
body: "This should include line context.",
|
||||
age: "1h ago",
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#discussion_r1",
|
||||
location: {
|
||||
path: "packages/app/src/git/pull-request-panel/data.ts",
|
||||
line: 24,
|
||||
startLine: 20,
|
||||
threadId: "PRRT_1",
|
||||
isResolved: true,
|
||||
isOutdated: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters empty commented reviews but keeps blocking review states", () => {
|
||||
const data = mapPrPaneData(
|
||||
baseStatus,
|
||||
+55
-2
@@ -7,27 +7,58 @@ export type PrState = "open" | "draft" | "merged" | "closed";
|
||||
export type CheckStatus = "success" | "failure" | "pending" | "skipped";
|
||||
export type ReviewState = "approved" | "changes_requested" | "commented";
|
||||
export type ActivityKind = "review" | "comment";
|
||||
export type PullRequestProvider = "github";
|
||||
|
||||
export interface PullRequestProviderMetadata {
|
||||
id: PullRequestProvider;
|
||||
label: string;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
const GITHUB_PROVIDER: PullRequestProviderMetadata = { id: "github", label: "GitHub" };
|
||||
|
||||
export interface PrPaneCheck {
|
||||
provider: PullRequestProvider;
|
||||
name: string;
|
||||
workflow?: string;
|
||||
status: CheckStatus;
|
||||
duration?: string;
|
||||
url: string;
|
||||
github?: {
|
||||
checkRunId?: number;
|
||||
workflowRunId?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PrPaneActivity {
|
||||
provider: PullRequestProvider;
|
||||
id: string;
|
||||
kind: ActivityKind;
|
||||
author: string;
|
||||
authorUrl?: string | null;
|
||||
avatarColor: string;
|
||||
avatarUrl?: string | null;
|
||||
reviewState?: ReviewState;
|
||||
body: string;
|
||||
age: string;
|
||||
url: string;
|
||||
/** For inline review comments: the review this comment was submitted with. */
|
||||
reviewId?: string;
|
||||
location?: {
|
||||
path: string;
|
||||
line?: number;
|
||||
startLine?: number;
|
||||
threadId?: string;
|
||||
isResolved?: boolean;
|
||||
isOutdated?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PrPaneData {
|
||||
provider: PullRequestProviderMetadata;
|
||||
number: number;
|
||||
repoOwner?: string;
|
||||
repoName?: string;
|
||||
title: string;
|
||||
state: PrState;
|
||||
url: string;
|
||||
@@ -69,7 +100,10 @@ export function mapPrPaneData(
|
||||
const timelineMatchesStatus = timeline?.prNumber === number;
|
||||
|
||||
return {
|
||||
provider: GITHUB_PROVIDER,
|
||||
number,
|
||||
repoOwner: status.repoOwner,
|
||||
repoName: status.repoName,
|
||||
title: status.title,
|
||||
state: derivePrState(status),
|
||||
url: status.url,
|
||||
@@ -137,11 +171,20 @@ function mapCheck(check: NonNullable<CheckoutPrStatus>["checks"][number]): PrPan
|
||||
|
||||
return [
|
||||
{
|
||||
provider: "github",
|
||||
name: check.name,
|
||||
workflow: check.workflow,
|
||||
status: mapCheckStatus(check.status),
|
||||
duration: check.duration,
|
||||
url: check.url,
|
||||
...(check.workflow ? { workflow: check.workflow } : {}),
|
||||
...(check.duration ? { duration: check.duration } : {}),
|
||||
...(check.checkRunId !== undefined || check.workflowRunId !== undefined
|
||||
? {
|
||||
github: {
|
||||
...(check.checkRunId !== undefined ? { checkRunId: check.checkRunId } : {}),
|
||||
...(check.workflowRunId !== undefined ? { workflowRunId: check.workflowRunId } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -168,12 +211,18 @@ function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivi
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: item.id,
|
||||
provider: "github",
|
||||
kind: "comment",
|
||||
author: item.author,
|
||||
authorUrl: item.authorUrl,
|
||||
avatarColor: deriveAvatarColor(item.author),
|
||||
avatarUrl: item.avatarUrl,
|
||||
body: item.body,
|
||||
age: formatAge(item.createdAt, nowMs),
|
||||
url: item.url,
|
||||
reviewId: item.reviewId,
|
||||
location: item.location,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -184,9 +233,13 @@ function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivi
|
||||
|
||||
return [
|
||||
{
|
||||
id: item.id,
|
||||
provider: "github",
|
||||
kind: "review",
|
||||
author: item.author,
|
||||
authorUrl: item.authorUrl,
|
||||
avatarColor: deriveAvatarColor(item.author),
|
||||
avatarUrl: item.avatarUrl,
|
||||
reviewState: item.reviewState,
|
||||
body: item.body,
|
||||
age: formatAge(item.createdAt, nowMs),
|
||||
@@ -0,0 +1,5 @@
|
||||
export { PullRequestPane } from "./pane";
|
||||
export { PullRequestTabIcon } from "./tab-icon";
|
||||
export { formatPrTabLabel } from "./tab-label";
|
||||
export { prPaneTimelineQueryKind } from "./query-keys";
|
||||
export { usePrPaneData } from "./use-data";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
export const prPaneTimelineQueryKind = "prPaneTimeline";
|
||||
|
||||
export function prPaneTimelineQueryKey({
|
||||
serverId,
|
||||
cwd,
|
||||
prNumber,
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
prNumber: number | null;
|
||||
}) {
|
||||
return [prPaneTimelineQueryKind, serverId, cwd, prNumber] as const;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
|
||||
export function PullRequestTabIcon({ size, color }: { size: number; color: string }) {
|
||||
return <GitHubIcon size={size} color={color} />;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatPrTabLabel } from "./tab-label";
|
||||
|
||||
describe("formatPrTabLabel", () => {
|
||||
it("returns #<number> when a pull request number is present", () => {
|
||||
expect(formatPrTabLabel(42)).toBe("#42");
|
||||
});
|
||||
|
||||
it("returns #— fallback when the pull request number is null", () => {
|
||||
expect(formatPrTabLabel(null)).toBe("#—");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export function formatPrTabLabel(prNumber: number | null): string {
|
||||
return prNumber === null ? "#—" : `#${prNumber}`;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildPrTimeline } from "./timeline";
|
||||
import type { PrPaneActivity } from "./data";
|
||||
|
||||
function activity(overrides: Partial<PrPaneActivity> & { id: string }): PrPaneActivity {
|
||||
return {
|
||||
provider: "github",
|
||||
kind: "comment",
|
||||
author: "alice",
|
||||
avatarColor: "#8b5cf6",
|
||||
body: "body",
|
||||
age: "1h ago",
|
||||
url: "https://github.com/acme/app/pull/1#comment",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildPrTimeline", () => {
|
||||
it("keeps standalone comments and reviews as single entries in order", () => {
|
||||
const comment = activity({ id: "c1" });
|
||||
const review = activity({ id: "r1", kind: "review", reviewState: "approved" });
|
||||
|
||||
expect(buildPrTimeline([comment, review])).toEqual([
|
||||
{ kind: "single", id: "c1", activity: comment },
|
||||
{ kind: "single", id: "r1", activity: review },
|
||||
]);
|
||||
});
|
||||
|
||||
it("groups comments sharing a threadId into one thread at the first comment's position", () => {
|
||||
const before = activity({ id: "c1" });
|
||||
const root = activity({
|
||||
id: "t1-root",
|
||||
location: { path: "src/a.ts", line: 12, threadId: "PRRT_1", isResolved: false },
|
||||
});
|
||||
const between = activity({ id: "c2" });
|
||||
const reply = activity({
|
||||
id: "t1-reply",
|
||||
author: "bob",
|
||||
location: { path: "src/a.ts", line: 12, threadId: "PRRT_1", isResolved: false },
|
||||
});
|
||||
|
||||
expect(buildPrTimeline([before, root, between, reply])).toEqual([
|
||||
{ kind: "single", id: "c1", activity: before },
|
||||
{
|
||||
kind: "thread",
|
||||
id: "thread:PRRT_1",
|
||||
location: { path: "src/a.ts", line: 12, threadId: "PRRT_1", isResolved: false },
|
||||
comments: [root, reply],
|
||||
},
|
||||
{ kind: "single", id: "c2", activity: between },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps located comments without a threadId as single entries", () => {
|
||||
const located = activity({ id: "c1", location: { path: "src/a.ts", line: 3 } });
|
||||
|
||||
expect(buildPrTimeline([located])).toEqual([{ kind: "single", id: "c1", activity: located }]);
|
||||
});
|
||||
|
||||
it("separates distinct threads", () => {
|
||||
const a = activity({ id: "a", location: { path: "x.ts", threadId: "T1" } });
|
||||
const b = activity({ id: "b", location: { path: "y.ts", threadId: "T2" } });
|
||||
const a2 = activity({ id: "a2", location: { path: "x.ts", threadId: "T1" } });
|
||||
|
||||
const entries = buildPrTimeline([a, b, a2]);
|
||||
expect(entries.map((entry) => entry.id)).toEqual(["thread:T1", "thread:T2"]);
|
||||
expect(entries[0]).toMatchObject({ comments: [a, a2] });
|
||||
expect(entries[1]).toMatchObject({ comments: [b] });
|
||||
});
|
||||
|
||||
it("nests threads under the review they were submitted with", () => {
|
||||
const review = activity({
|
||||
id: "R1",
|
||||
kind: "review",
|
||||
reviewState: "changes_requested",
|
||||
body: "Please fix these.",
|
||||
});
|
||||
const thread = activity({
|
||||
id: "t1",
|
||||
reviewId: "R1",
|
||||
location: { path: "src/a.ts", line: 4, threadId: "T1" },
|
||||
});
|
||||
const reply = activity({
|
||||
id: "t1-reply",
|
||||
reviewId: "R2-later",
|
||||
location: { path: "src/a.ts", line: 4, threadId: "T1" },
|
||||
});
|
||||
const unrelated = activity({
|
||||
id: "t2",
|
||||
reviewId: "R-unknown",
|
||||
location: { path: "src/b.ts", threadId: "T2" },
|
||||
});
|
||||
|
||||
expect(buildPrTimeline([review, thread, reply, unrelated])).toEqual([
|
||||
{
|
||||
kind: "review",
|
||||
id: "R1",
|
||||
review,
|
||||
threads: [
|
||||
{
|
||||
kind: "thread",
|
||||
id: "thread:T1",
|
||||
location: { path: "src/a.ts", line: 4, threadId: "T1" },
|
||||
comments: [thread, reply],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "thread",
|
||||
id: "thread:T2",
|
||||
location: { path: "src/b.ts", threadId: "T2" },
|
||||
comments: [unrelated],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps reviews without threads as single entries", () => {
|
||||
const review = activity({ id: "R1", kind: "review", reviewState: "approved" });
|
||||
|
||||
expect(buildPrTimeline([review])).toEqual([{ kind: "single", id: "R1", activity: review }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { PrPaneActivity } from "./data";
|
||||
|
||||
export type PrThreadLocation = NonNullable<PrPaneActivity["location"]>;
|
||||
|
||||
export interface PrThreadEntry {
|
||||
kind: "thread";
|
||||
id: string;
|
||||
location: PrThreadLocation;
|
||||
comments: PrPaneActivity[];
|
||||
}
|
||||
|
||||
export interface PrReviewEntry {
|
||||
kind: "review";
|
||||
id: string;
|
||||
review: PrPaneActivity;
|
||||
threads: PrThreadEntry[];
|
||||
}
|
||||
|
||||
export type PrTimelineEntry =
|
||||
| { kind: "single"; id: string; activity: PrPaneActivity }
|
||||
| PrThreadEntry
|
||||
| PrReviewEntry;
|
||||
|
||||
/**
|
||||
* Builds the GitHub-style nested timeline:
|
||||
* - comments sharing a `location.threadId` collapse into one thread entry
|
||||
* (root comment + replies), placed at the first comment's position;
|
||||
* - threads whose root comment carries a `reviewId` nest under that review
|
||||
* (GitHub's explicit signal: PullRequestReviewComment.pullRequestReview);
|
||||
* - everything else stays a standalone entry in original order.
|
||||
*/
|
||||
export function buildPrTimeline(activities: readonly PrPaneActivity[]): PrTimelineEntry[] {
|
||||
const entries = groupThreads(activities);
|
||||
return nestThreadsUnderReviews(entries);
|
||||
}
|
||||
|
||||
function groupThreads(activities: readonly PrPaneActivity[]): PrTimelineEntry[] {
|
||||
const entries: PrTimelineEntry[] = [];
|
||||
const threadsById = new Map<string, PrThreadEntry>();
|
||||
|
||||
for (const activity of activities) {
|
||||
const threadId = activity.location?.threadId;
|
||||
if (!activity.location || !threadId) {
|
||||
entries.push({ kind: "single", id: activity.id, activity });
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = threadsById.get(threadId);
|
||||
if (existing) {
|
||||
existing.comments.push(activity);
|
||||
continue;
|
||||
}
|
||||
|
||||
const thread: PrThreadEntry = {
|
||||
kind: "thread",
|
||||
id: `thread:${threadId}`,
|
||||
location: activity.location,
|
||||
comments: [activity],
|
||||
};
|
||||
threadsById.set(threadId, thread);
|
||||
entries.push(thread);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function nestThreadsUnderReviews(entries: PrTimelineEntry[]): PrTimelineEntry[] {
|
||||
const reviewIds = new Set(
|
||||
entries.flatMap((entry) =>
|
||||
entry.kind === "single" && entry.activity.kind === "review" ? [entry.activity.id] : [],
|
||||
),
|
||||
);
|
||||
const threadsByReviewId = new Map<string, PrThreadEntry[]>();
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.kind !== "thread") {
|
||||
continue;
|
||||
}
|
||||
const reviewId = entry.comments[0]?.reviewId;
|
||||
if (!reviewId || !reviewIds.has(reviewId)) {
|
||||
continue;
|
||||
}
|
||||
const threads = threadsByReviewId.get(reviewId) ?? [];
|
||||
threads.push(entry);
|
||||
threadsByReviewId.set(reviewId, threads);
|
||||
}
|
||||
|
||||
if (threadsByReviewId.size === 0) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
const nestedThreads = new Set([...threadsByReviewId.values()].flat());
|
||||
return entries.flatMap((entry): PrTimelineEntry[] => {
|
||||
if (entry.kind === "thread") {
|
||||
return nestedThreads.has(entry) ? [] : [entry];
|
||||
}
|
||||
if (entry.kind === "single" && entry.activity.kind === "review") {
|
||||
const threads = threadsByReviewId.get(entry.activity.id);
|
||||
if (threads) {
|
||||
return [{ kind: "review" as const, id: entry.id, review: entry.activity, threads }];
|
||||
}
|
||||
}
|
||||
return [entry];
|
||||
});
|
||||
}
|
||||
+1
-1
@@ -12,7 +12,7 @@ import {
|
||||
selectPrPaneState,
|
||||
shouldFetchTimelineFrom,
|
||||
unsupportedTimelineKey,
|
||||
} from "./use-pr-pane-data";
|
||||
} from "./use-data";
|
||||
|
||||
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
|
||||
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
|
||||
+2
-2
@@ -7,10 +7,10 @@ import type {
|
||||
PullRequestTimelineResponse,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { mapPrPaneData, type PrPaneData } from "@/git/pr-pane-data";
|
||||
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
|
||||
import { prPaneTimelineQueryKey } from "@/git/query-keys";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { mapPrPaneData, type PrPaneData } from "./data";
|
||||
import { prPaneTimelineQueryKey } from "./query-keys";
|
||||
|
||||
type CheckoutPrStatus = CheckoutPrStatusResponse["payload"]["status"];
|
||||
type CheckoutPrStatusPayloadError = CheckoutPrStatusResponse["payload"]["error"];
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
checkoutPrStatusQueryKey,
|
||||
checkoutStatusQueryKey,
|
||||
invalidateCheckoutGitQueriesForClient,
|
||||
prPaneTimelineQueryKey,
|
||||
} from "@/git/query-keys";
|
||||
import { prPaneTimelineQueryKey } from "@/git/pull-request-panel/query-keys";
|
||||
|
||||
describe("checkout query keys", () => {
|
||||
const serverId = "server-1";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Query, QueryClient } from "@tanstack/react-query";
|
||||
import { prPaneTimelineQueryKind } from "./pull-request-panel/query-keys";
|
||||
|
||||
interface CheckoutQueryIdentity {
|
||||
serverId: string;
|
||||
@@ -25,18 +26,6 @@ export function checkoutPrStatusQueryKey(serverId: string, cwd: string) {
|
||||
return ["checkoutPrStatus", serverId, cwd] as const;
|
||||
}
|
||||
|
||||
export function prPaneTimelineQueryKey({
|
||||
serverId,
|
||||
cwd,
|
||||
prNumber,
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
prNumber: number | null;
|
||||
}) {
|
||||
return ["prPaneTimeline", serverId, cwd, prNumber] as const;
|
||||
}
|
||||
|
||||
export async function invalidateCheckoutGitQueriesForClient(
|
||||
queryClient: QueryClient,
|
||||
identity: CheckoutQueryIdentity,
|
||||
@@ -52,7 +41,7 @@ export async function invalidateCheckoutGitQueriesForClient(
|
||||
predicate: checkoutQueryPredicate("checkoutPrStatus", identity),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: checkoutQueryPredicate("prPaneTimeline", identity),
|
||||
predicate: checkoutQueryPredicate(prPaneTimelineQueryKind, identity),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -230,14 +230,15 @@ describe("workspace agent visibility", () => {
|
||||
"slash-agent",
|
||||
makeAgent({
|
||||
id: "slash-agent",
|
||||
cwd: "/Users/moboudra/.paseo/worktrees/1luy0po7/normal-squid/",
|
||||
cwd: "/Users/moboudra/dev/paseo/.dev/paseo-home/worktrees/1luy0po7/normal-squid/",
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
sessionAgents,
|
||||
workspaceDirectory: "/Users/moboudra/.paseo/worktrees/1luy0po7/normal-squid",
|
||||
workspaceDirectory:
|
||||
"/Users/moboudra/dev/paseo/.dev/paseo-home/worktrees/1luy0po7/normal-squid",
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set(["slash-agent"]));
|
||||
|
||||
@@ -1952,6 +1952,89 @@ test("requests GitHub auto-merge disable via namespaced RPC", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("requests GitHub check details via namespaced RPC", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const promise = client.checkoutGithubGetCheckDetails(
|
||||
{
|
||||
cwd: "/tmp/project",
|
||||
repoOwner: "getpaseo",
|
||||
repoName: "paseo",
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
},
|
||||
"req-check-details",
|
||||
);
|
||||
|
||||
expect(mock.sent).toHaveLength(1);
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request).toMatchObject({
|
||||
type: "checkout.github.get_check_details.request",
|
||||
cwd: "/tmp/project",
|
||||
repoOwner: "getpaseo",
|
||||
repoName: "paseo",
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
requestId: "req-check-details",
|
||||
});
|
||||
|
||||
mock.triggerMessage(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "checkout.github.get_check_details.response",
|
||||
payload: {
|
||||
cwd: "/tmp/project",
|
||||
requestId: "req-check-details",
|
||||
success: true,
|
||||
details: {
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
name: "server-tests",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
annotations: [],
|
||||
failedJobs: [],
|
||||
truncated: false,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
cwd: "/tmp/project",
|
||||
requestId: "req-check-details",
|
||||
success: true,
|
||||
details: {
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
name: "server-tests",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
annotations: [],
|
||||
failedJobs: [],
|
||||
truncated: false,
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("requests checkout pull via RPC", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -37,6 +37,7 @@ import type {
|
||||
CheckoutPrMergeResponse,
|
||||
CheckoutPrMergeMethod,
|
||||
CheckoutGithubSetAutoMergeResponse,
|
||||
CheckoutGithubGetCheckDetailsResponse,
|
||||
CheckoutPrStatusResponse,
|
||||
PullRequestTimelineResponse,
|
||||
CheckoutSwitchBranchResponse,
|
||||
@@ -292,6 +293,7 @@ type CheckoutRefreshPayload = CheckoutRefreshResponse["payload"];
|
||||
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
|
||||
type CheckoutPrMergePayload = CheckoutPrMergeResponse["payload"];
|
||||
type CheckoutGithubSetAutoMergePayload = CheckoutGithubSetAutoMergeResponse["payload"];
|
||||
type CheckoutGithubGetCheckDetailsPayload = CheckoutGithubGetCheckDetailsResponse["payload"];
|
||||
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
|
||||
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
|
||||
type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"];
|
||||
@@ -3023,6 +3025,32 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async checkoutGithubGetCheckDetails(
|
||||
input: {
|
||||
cwd: string;
|
||||
repoOwner: string;
|
||||
repoName: string;
|
||||
checkRunId: number;
|
||||
workflowRunId?: number;
|
||||
},
|
||||
requestId?: string,
|
||||
): Promise<CheckoutGithubGetCheckDetailsPayload> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest<"checkout.github.get_check_details.response">(
|
||||
{
|
||||
requestId,
|
||||
message: {
|
||||
type: "checkout.github.get_check_details.request",
|
||||
cwd: input.cwd,
|
||||
repoOwner: input.repoOwner,
|
||||
repoName: input.repoName,
|
||||
checkRunId: input.checkRunId,
|
||||
workflowRunId: input.workflowRunId,
|
||||
},
|
||||
timeout: 60000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async checkoutPrStatus(cwd: string, requestId?: string): Promise<CheckoutPrStatusPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
CheckoutGithubGetCheckDetailsRequestSchema,
|
||||
CheckoutGithubGetCheckDetailsResponseSchema,
|
||||
CheckoutGithubSetAutoMergeRequestSchema,
|
||||
CheckoutGithubSetAutoMergeResponseSchema,
|
||||
CheckoutPrMergeRequestSchema,
|
||||
@@ -87,6 +89,47 @@ describe("checkout PR schemas", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("parses optional GitHub check identifiers on PR checks", () => {
|
||||
expect(
|
||||
CheckoutPrStatusSchema.parse({
|
||||
number: 993,
|
||||
url: "https://github.com/getpaseo/paseo/pull/993",
|
||||
title: "Expose failed check logs",
|
||||
state: "open",
|
||||
baseRefName: "main",
|
||||
headRefName: "phase-6",
|
||||
isMerged: false,
|
||||
checks: [
|
||||
{
|
||||
name: "server tests",
|
||||
status: "failure",
|
||||
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
},
|
||||
{
|
||||
name: "legacy context",
|
||||
status: "success",
|
||||
url: "https://example.com/context",
|
||||
},
|
||||
],
|
||||
}).checks,
|
||||
).toEqual([
|
||||
{
|
||||
name: "server tests",
|
||||
status: "failure",
|
||||
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
},
|
||||
{
|
||||
name: "legacy context",
|
||||
status: "success",
|
||||
url: "https://example.com/context",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test.each(["merge", "squash", "rebase"] as const)(
|
||||
"accepts %s as a PR merge method",
|
||||
(mergeMethod) => {
|
||||
@@ -171,6 +214,103 @@ describe("checkout PR schemas", () => {
|
||||
).toEqual(payload);
|
||||
});
|
||||
|
||||
test("accepts GitHub check details requests and responses", () => {
|
||||
expect(
|
||||
CheckoutGithubGetCheckDetailsRequestSchema.parse({
|
||||
type: "checkout.github.get_check_details.request",
|
||||
cwd: "/tmp/repo",
|
||||
repoOwner: "getpaseo",
|
||||
repoName: "paseo",
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
requestId: "request-check-details",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "checkout.github.get_check_details.request",
|
||||
cwd: "/tmp/repo",
|
||||
repoOwner: "getpaseo",
|
||||
repoName: "paseo",
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
requestId: "request-check-details",
|
||||
});
|
||||
|
||||
expect(
|
||||
CheckoutGithubGetCheckDetailsResponseSchema.parse({
|
||||
type: "checkout.github.get_check_details.response",
|
||||
payload: {
|
||||
cwd: "/tmp/repo",
|
||||
success: true,
|
||||
details: {
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
name: "server tests",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
output: {
|
||||
title: "Tests failed",
|
||||
summary: "1 failure",
|
||||
text: "Assertion failed",
|
||||
},
|
||||
annotations: [
|
||||
{
|
||||
path: "packages/server/src/index.ts",
|
||||
startLine: 10,
|
||||
endLine: 12,
|
||||
annotationLevel: "failure",
|
||||
message: "Expected true",
|
||||
},
|
||||
],
|
||||
failedJobs: [
|
||||
{
|
||||
jobId: 789,
|
||||
name: "test",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
logTail: "last line",
|
||||
logTruncated: false,
|
||||
},
|
||||
],
|
||||
truncated: true,
|
||||
},
|
||||
error: null,
|
||||
requestId: "request-check-details",
|
||||
},
|
||||
}).payload.details,
|
||||
).toMatchObject({
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
failedJobs: [{ jobId: 789, logTail: "last line" }],
|
||||
truncated: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects invalid GitHub check details request identities", () => {
|
||||
const request = {
|
||||
type: "checkout.github.get_check_details.request",
|
||||
cwd: "/tmp/repo",
|
||||
repoOwner: "getpaseo",
|
||||
repoName: "paseo",
|
||||
checkRunId: 12345,
|
||||
requestId: "request-check-details",
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
CheckoutGithubGetCheckDetailsRequestSchema.parse({ ...request, repoOwner: "../owner" }),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
CheckoutGithubGetCheckDetailsRequestSchema.parse({ ...request, repoName: "" }),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
CheckoutGithubGetCheckDetailsRequestSchema.parse({ ...request, checkRunId: 0 }),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
CheckoutGithubGetCheckDetailsRequestSchema.parse({ ...request, workflowRunId: 1.5 }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("accepts the GitHub auto-merge server_info feature flag", () => {
|
||||
expect(
|
||||
ServerInfoStatusPayloadSchema.parse({
|
||||
@@ -186,4 +326,18 @@ describe("checkout PR schemas", () => {
|
||||
checkoutGithubSetAutoMerge: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts the GitHub check details server_info feature flag", () => {
|
||||
expect(
|
||||
ServerInfoStatusPayloadSchema.parse({
|
||||
status: "server_info",
|
||||
serverId: "srv_test",
|
||||
features: {
|
||||
githubCheckDetails: true,
|
||||
},
|
||||
}).features,
|
||||
).toEqual({
|
||||
githubCheckDetails: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,6 +126,77 @@ describe("pull request timeline message schemas", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses optional author avatar, author URL, and inline comment location fields", () => {
|
||||
const parsed = PullRequestTimelineResponseSchema.parse({
|
||||
type: "pull_request_timeline_response",
|
||||
payload: {
|
||||
items: [
|
||||
{
|
||||
id: "review-1",
|
||||
kind: "review",
|
||||
author: "octocat",
|
||||
authorUrl: "https://github.com/octocat",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/1?v=4",
|
||||
body: "Looks good",
|
||||
createdAt: 1710000000000,
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
|
||||
reviewState: "approved",
|
||||
},
|
||||
{
|
||||
id: "thread-comment-1",
|
||||
kind: "comment",
|
||||
author: "hubot",
|
||||
authorUrl: "https://github.com/hubot",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/2?v=4",
|
||||
body: "Inline note",
|
||||
createdAt: 1710000001000,
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#discussion_r1",
|
||||
location: {
|
||||
path: "packages/app/src/git/pull-request-panel/data.ts",
|
||||
line: 24,
|
||||
startLine: 20,
|
||||
threadId: "PRRT_1",
|
||||
isResolved: true,
|
||||
isOutdated: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.payload.items).toEqual([
|
||||
{
|
||||
id: "review-1",
|
||||
kind: "review",
|
||||
author: "octocat",
|
||||
authorUrl: "https://github.com/octocat",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/1?v=4",
|
||||
body: "Looks good",
|
||||
createdAt: 1710000000000,
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
|
||||
reviewState: "approved",
|
||||
},
|
||||
{
|
||||
id: "thread-comment-1",
|
||||
kind: "comment",
|
||||
author: "hubot",
|
||||
authorUrl: "https://github.com/hubot",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/2?v=4",
|
||||
body: "Inline note",
|
||||
createdAt: 1710000001000,
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#discussion_r1",
|
||||
location: {
|
||||
path: "packages/app/src/git/pull-request-panel/data.ts",
|
||||
line: 24,
|
||||
startLine: 20,
|
||||
threadId: "PRRT_1",
|
||||
isResolved: true,
|
||||
isOutdated: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses response through the outbound message union", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "pull_request_timeline_response",
|
||||
@@ -161,6 +232,91 @@ describe("pull request timeline message schemas", () => {
|
||||
expect(parsed.type).toBe("pull_request_timeline_response");
|
||||
});
|
||||
|
||||
test("an old client timeline schema parses new daemon avatar and inline location fields", () => {
|
||||
const oldClientPullRequestTimelineResponseSchema = z.object({
|
||||
type: z.literal("pull_request_timeline_response"),
|
||||
payload: z.object({
|
||||
cwd: z.string(),
|
||||
prNumber: z.number().nullable(),
|
||||
items: z.array(
|
||||
z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
id: z.string(),
|
||||
kind: z.literal("review"),
|
||||
author: z.string(),
|
||||
body: z.string(),
|
||||
createdAt: z.number(),
|
||||
url: z.string(),
|
||||
reviewState: z.enum(["approved", "changes_requested", "commented"]),
|
||||
}),
|
||||
z.object({
|
||||
id: z.string(),
|
||||
kind: z.literal("comment"),
|
||||
author: z.string(),
|
||||
body: z.string(),
|
||||
createdAt: z.number(),
|
||||
url: z.string(),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
truncated: z.boolean(),
|
||||
error: z.unknown().nullable(),
|
||||
requestId: z.string(),
|
||||
githubFeaturesEnabled: z.boolean(),
|
||||
}),
|
||||
});
|
||||
const newDaemonPayload = PullRequestTimelineResponseSchema.parse({
|
||||
type: "pull_request_timeline_response",
|
||||
payload: {
|
||||
cwd: "/tmp/repo",
|
||||
prNumber: 42,
|
||||
items: [
|
||||
{
|
||||
id: "comment-1",
|
||||
kind: "comment",
|
||||
author: "octocat",
|
||||
authorUrl: "https://github.com/octocat",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/1?v=4",
|
||||
body: "Inline note",
|
||||
createdAt: 1710000000000,
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#discussion_r1",
|
||||
location: {
|
||||
path: "src/file.ts",
|
||||
line: 12,
|
||||
threadId: "PRRT_1",
|
||||
},
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
error: null,
|
||||
requestId: "request-compat",
|
||||
githubFeaturesEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(oldClientPullRequestTimelineResponseSchema.parse(newDaemonPayload)).toEqual({
|
||||
type: "pull_request_timeline_response",
|
||||
payload: {
|
||||
cwd: "/tmp/repo",
|
||||
prNumber: 42,
|
||||
items: [
|
||||
{
|
||||
id: "comment-1",
|
||||
kind: "comment",
|
||||
author: "octocat",
|
||||
body: "Inline note",
|
||||
createdAt: 1710000000000,
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#discussion_r1",
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
error: null,
|
||||
requestId: "request-compat",
|
||||
githubFeaturesEnabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes future error kinds to unknown", () => {
|
||||
const parsed = PullRequestTimelineResponseSchema.parse({
|
||||
type: "pull_request_timeline_response",
|
||||
|
||||
@@ -1455,6 +1455,18 @@ export const CheckoutGithubSetAutoMergeRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
const GitHubRepoSegmentSchema = z.string().regex(/^[A-Za-z0-9._-]+$/);
|
||||
|
||||
export const CheckoutGithubGetCheckDetailsRequestSchema = z.object({
|
||||
type: z.literal("checkout.github.get_check_details.request"),
|
||||
cwd: z.string(),
|
||||
repoOwner: GitHubRepoSegmentSchema,
|
||||
repoName: GitHubRepoSegmentSchema,
|
||||
checkRunId: z.number().int().positive(),
|
||||
workflowRunId: z.number().int().positive().optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const CheckoutPrStatusRequestSchema = z.object({
|
||||
type: z.literal("checkout_pr_status_request"),
|
||||
cwd: z.string(),
|
||||
@@ -1924,6 +1936,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CheckoutPrCreateRequestSchema,
|
||||
CheckoutPrMergeRequestSchema,
|
||||
CheckoutGithubSetAutoMergeRequestSchema,
|
||||
CheckoutGithubGetCheckDetailsRequestSchema,
|
||||
CheckoutPrStatusRequestSchema,
|
||||
PullRequestTimelineRequestSchema,
|
||||
CheckoutSwitchBranchRequestSchema,
|
||||
@@ -2151,6 +2164,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
.object({
|
||||
providersSnapshot: z.boolean().optional(),
|
||||
checkoutGithubSetAutoMerge: z.boolean().optional(),
|
||||
// COMPAT(githubCheckDetails): added in v0.1.92, remove gate after 2026-12-08.
|
||||
githubCheckDetails: z.boolean().optional(),
|
||||
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
|
||||
daemonStatusRpc: z.boolean().optional(),
|
||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||
@@ -3045,6 +3060,8 @@ export const CheckoutPrStatusSchema = z.object({
|
||||
url: z.string().nullable(),
|
||||
workflow: z.string().optional(),
|
||||
duration: z.string().optional(),
|
||||
checkRunId: z.number().optional(),
|
||||
workflowRunId: z.number().optional(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
@@ -3190,6 +3207,58 @@ export const CheckoutGithubSetAutoMergeResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const CheckoutGithubCheckAnnotationSchema = z.object({
|
||||
path: z.string().optional(),
|
||||
startLine: z.number().optional(),
|
||||
endLine: z.number().optional(),
|
||||
annotationLevel: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
rawDetails: z.string().optional(),
|
||||
});
|
||||
|
||||
const CheckoutGithubCheckJobSchema = z.object({
|
||||
jobId: z.number(),
|
||||
name: z.string(),
|
||||
status: z.string().nullable().optional(),
|
||||
conclusion: z.string().nullable().optional(),
|
||||
url: z.string().nullable().optional(),
|
||||
logTail: z.string().optional(),
|
||||
logTruncated: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const CheckoutGithubCheckDetailsSchema = z.object({
|
||||
checkRunId: z.number(),
|
||||
workflowRunId: z.number().nullable().optional(),
|
||||
name: z.string(),
|
||||
status: z.string().nullable().optional(),
|
||||
conclusion: z.string().nullable().optional(),
|
||||
url: z.string().nullable().optional(),
|
||||
detailsUrl: z.string().nullable().optional(),
|
||||
output: z
|
||||
.object({
|
||||
title: z.string().nullable().optional(),
|
||||
summary: z.string().nullable().optional(),
|
||||
text: z.string().nullable().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
annotations: z.array(CheckoutGithubCheckAnnotationSchema).optional().default([]),
|
||||
failedJobs: z.array(CheckoutGithubCheckJobSchema).optional().default([]),
|
||||
truncated: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export const CheckoutGithubGetCheckDetailsResponseSchema = z.object({
|
||||
type: z.literal("checkout.github.get_check_details.response"),
|
||||
payload: z.object({
|
||||
cwd: z.string(),
|
||||
success: z.boolean(),
|
||||
details: CheckoutGithubCheckDetailsSchema.nullable().optional().default(null),
|
||||
error: CheckoutErrorSchema.nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const CheckoutPrStatusResponseSchema = z.object({
|
||||
type: z.literal("checkout_pr_status_response"),
|
||||
payload: CheckoutPrStatusPayloadSchema,
|
||||
@@ -3225,6 +3294,8 @@ const PullRequestTimelineReviewItemSchema = z.object({
|
||||
id: z.string().optional().default(""),
|
||||
kind: z.literal("review"),
|
||||
author: z.string().optional().default("unknown"),
|
||||
authorUrl: z.string().nullable().optional(),
|
||||
avatarUrl: z.string().nullable().optional(),
|
||||
body: z.string().optional().default(""),
|
||||
createdAt: z.number().optional().default(0),
|
||||
url: z.string().optional().default(""),
|
||||
@@ -3238,9 +3309,25 @@ const PullRequestTimelineCommentItemSchema = z.object({
|
||||
id: z.string().optional().default(""),
|
||||
kind: z.literal("comment"),
|
||||
author: z.string().optional().default("unknown"),
|
||||
authorUrl: z.string().nullable().optional(),
|
||||
avatarUrl: z.string().nullable().optional(),
|
||||
body: z.string().optional().default(""),
|
||||
createdAt: z.number().optional().default(0),
|
||||
url: z.string().optional().default(""),
|
||||
// GitHub review id this inline comment belongs to; lets clients nest review
|
||||
// threads under their parent review. Absent on issue comments and on
|
||||
// timelines from daemons that predate the field.
|
||||
reviewId: z.string().optional(),
|
||||
location: z
|
||||
.object({
|
||||
path: z.string(),
|
||||
line: z.number().optional(),
|
||||
startLine: z.number().optional(),
|
||||
threadId: z.string().optional(),
|
||||
isResolved: z.boolean().optional(),
|
||||
isOutdated: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const PullRequestTimelineItemSchema = z.preprocess(
|
||||
@@ -3786,6 +3873,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
CheckoutPrCreateResponseSchema,
|
||||
CheckoutPrMergeResponseSchema,
|
||||
CheckoutGithubSetAutoMergeResponseSchema,
|
||||
CheckoutGithubGetCheckDetailsResponseSchema,
|
||||
CheckoutPrStatusResponseSchema,
|
||||
PullRequestTimelineResponseSchema,
|
||||
CheckoutSwitchBranchResponseSchema,
|
||||
@@ -4057,6 +4145,13 @@ export type CheckoutGithubSetAutoMergeRequest = z.infer<
|
||||
export type CheckoutGithubSetAutoMergeResponse = z.infer<
|
||||
typeof CheckoutGithubSetAutoMergeResponseSchema
|
||||
>;
|
||||
export type CheckoutGithubGetCheckDetailsRequest = z.infer<
|
||||
typeof CheckoutGithubGetCheckDetailsRequestSchema
|
||||
>;
|
||||
export type CheckoutGithubCheckDetails = z.infer<typeof CheckoutGithubCheckDetailsSchema>;
|
||||
export type CheckoutGithubGetCheckDetailsResponse = z.infer<
|
||||
typeof CheckoutGithubGetCheckDetailsResponseSchema
|
||||
>;
|
||||
export type PullRequestMergeable = z.infer<typeof CheckoutPrStatusSchema>["mergeable"];
|
||||
export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>;
|
||||
export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>;
|
||||
|
||||
@@ -282,7 +282,8 @@ describe("opencode tool-call mapper", () => {
|
||||
callId: "opencode-write-success-text",
|
||||
status: "completed",
|
||||
input: {
|
||||
filePath: "/Users/moboudra/.paseo/worktrees/1luy0po7/cold-ladybug/dummy.txt",
|
||||
filePath:
|
||||
"/Users/moboudra/dev/paseo/.dev/paseo-home/worktrees/1luy0po7/cold-ladybug/dummy.txt",
|
||||
content: "hello world\n",
|
||||
},
|
||||
output: "Wrote file successfully.",
|
||||
@@ -291,7 +292,8 @@ describe("opencode tool-call mapper", () => {
|
||||
|
||||
expect(item.detail).toEqual({
|
||||
type: "write",
|
||||
filePath: "/Users/moboudra/.paseo/worktrees/1luy0po7/cold-ladybug/dummy.txt",
|
||||
filePath:
|
||||
"/Users/moboudra/dev/paseo/.dev/paseo-home/worktrees/1luy0po7/cold-ladybug/dummy.txt",
|
||||
content: "hello world\n",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,11 @@ import {
|
||||
createProviderSnapshotManagerStub,
|
||||
} from "./test-utils/session-stubs.js";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import type { GitHubPullRequestStatusFacts } from "../services/github-service.js";
|
||||
import type {
|
||||
GitHubCheckDetails,
|
||||
GitHubPullRequestStatusFacts,
|
||||
GitHubService,
|
||||
} from "../services/github-service.js";
|
||||
|
||||
interface SessionHandlerInternals {
|
||||
startVoiceTurnController(): Promise<void>;
|
||||
@@ -207,12 +211,7 @@ vi.mock("./worktree-bootstrap.js", async (importOriginal) => {
|
||||
});
|
||||
|
||||
interface SessionForTestOptions {
|
||||
github?: {
|
||||
invalidate: ReturnType<typeof vi.fn>;
|
||||
isAuthenticated?: ReturnType<typeof vi.fn>;
|
||||
getPullRequestTimeline?: ReturnType<typeof vi.fn>;
|
||||
searchIssuesAndPrs?: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
github?: Partial<GitHubService>;
|
||||
checkoutDiffManager?: { scheduleRefreshForCwd: ReturnType<typeof vi.fn> };
|
||||
workspaceGitService?: {
|
||||
getCheckoutDiff?: ReturnType<typeof vi.fn>;
|
||||
@@ -3669,6 +3668,7 @@ describe("session pull request timeline handling", () => {
|
||||
kind: "review",
|
||||
author: "octocat",
|
||||
authorUrl: "https://github.com/octocat",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/1?v=4",
|
||||
body: "Looks good",
|
||||
createdAt: 1710000000000,
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
|
||||
@@ -3706,6 +3706,8 @@ describe("session pull request timeline handling", () => {
|
||||
id: "review-1",
|
||||
kind: "review",
|
||||
author: "octocat",
|
||||
authorUrl: "https://github.com/octocat",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/1?v=4",
|
||||
body: "Looks good",
|
||||
createdAt: 1710000000000,
|
||||
url: "https://github.com/getpaseo/paseo/pull/42#pullrequestreview-1",
|
||||
@@ -3799,4 +3801,88 @@ describe("session pull request timeline handling", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("emits GitHub check details responses", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const checkDetailRequests: Array<{
|
||||
cwd: string;
|
||||
repoOwner: string;
|
||||
repoName: string;
|
||||
checkRunId: number;
|
||||
workflowRunId?: number;
|
||||
}> = [];
|
||||
const checkDetails: GitHubCheckDetails = {
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
name: "server-tests",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
detailsUrl: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
output: { title: "Tests failed", summary: "1 failure", text: "Assertion failed" },
|
||||
annotations: [],
|
||||
failedJobs: [],
|
||||
truncated: false,
|
||||
};
|
||||
const github: Partial<GitHubService> = {
|
||||
invalidate() {},
|
||||
async isAuthenticated() {
|
||||
return true;
|
||||
},
|
||||
async getGitHubCheckDetails(request) {
|
||||
checkDetailRequests.push({
|
||||
cwd: request.cwd,
|
||||
repoOwner: request.repoOwner,
|
||||
repoName: request.repoName,
|
||||
checkRunId: request.checkRunId,
|
||||
workflowRunId: request.workflowRunId,
|
||||
});
|
||||
return checkDetails;
|
||||
},
|
||||
};
|
||||
const session = createSessionForTest({ github, messages });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "checkout.github.get_check_details.request",
|
||||
cwd: "/tmp/repo",
|
||||
repoOwner: "getpaseo",
|
||||
repoName: "paseo",
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
requestId: "request-check-details",
|
||||
});
|
||||
|
||||
expect(checkDetailRequests).toEqual([
|
||||
{
|
||||
cwd: "/tmp/repo",
|
||||
repoOwner: "getpaseo",
|
||||
repoName: "paseo",
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toContainEqual({
|
||||
type: "checkout.github.get_check_details.response",
|
||||
payload: {
|
||||
cwd: "/tmp/repo",
|
||||
success: true,
|
||||
details: {
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
name: "server-tests",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
detailsUrl: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
|
||||
output: { title: "Tests failed", summary: "1 failure", text: "Assertion failed" },
|
||||
annotations: [],
|
||||
failedJobs: [],
|
||||
truncated: false,
|
||||
},
|
||||
error: null,
|
||||
requestId: "request-check-details",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2077,6 +2077,8 @@ export class Session {
|
||||
return this.handleCheckoutPrMergeRequest(msg);
|
||||
case "checkout.github.set_auto_merge.request":
|
||||
return this.handleCheckoutGithubSetAutoMergeRequest(msg);
|
||||
case "checkout.github.get_check_details.request":
|
||||
return this.handleCheckoutGithubGetCheckDetailsRequest(msg);
|
||||
case "checkout_pr_status_request":
|
||||
return this.handleCheckoutPrStatusRequest(msg);
|
||||
case "pull_request_timeline_request":
|
||||
@@ -5692,6 +5694,46 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCheckoutGithubGetCheckDetailsRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "checkout.github.get_check_details.request" }>,
|
||||
): Promise<void> {
|
||||
const { cwd, repoOwner, repoName, checkRunId, workflowRunId, requestId } = msg;
|
||||
|
||||
try {
|
||||
const details = await this.github.getGitHubCheckDetails({
|
||||
cwd,
|
||||
repoOwner,
|
||||
repoName,
|
||||
checkRunId,
|
||||
workflowRunId,
|
||||
});
|
||||
this.emit({
|
||||
type: "checkout.github.get_check_details.response",
|
||||
payload: {
|
||||
cwd,
|
||||
success: true,
|
||||
details,
|
||||
error: null,
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.emit({
|
||||
type: "checkout.github.get_check_details.response",
|
||||
payload: {
|
||||
cwd,
|
||||
success: false,
|
||||
details: null,
|
||||
error: {
|
||||
code: "UNKNOWN",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePaseoWorktreeListRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_list_request" }>,
|
||||
): Promise<void> {
|
||||
@@ -9252,6 +9294,5 @@ function isValidGitHubRepoSegment(value: string): boolean {
|
||||
function toPullRequestTimelinePayloadItem(
|
||||
item: PullRequestTimelineItem,
|
||||
): PullRequestTimelinePayloadItem {
|
||||
const { authorUrl: _authorUrl, ...payload } = item;
|
||||
return payload;
|
||||
return item;
|
||||
}
|
||||
|
||||
@@ -1055,6 +1055,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
providersSnapshot: true,
|
||||
// COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13.
|
||||
checkoutGithubSetAutoMerge: true,
|
||||
githubCheckDetails: true,
|
||||
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
|
||||
daemonStatusRpc: true,
|
||||
// COMPAT(terminalRestoreModes): added in v0.1.81, remove gate after 2026-11-23.
|
||||
|
||||
@@ -308,6 +308,7 @@ function pullRequestTimelineJson(overrides: Record<string, unknown> = {}): strin
|
||||
author: {
|
||||
login: "reviewer",
|
||||
url: "https://github.com/reviewer",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/1?v=4",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -331,6 +332,7 @@ function pullRequestTimelineJson(overrides: Record<string, unknown> = {}): strin
|
||||
author: {
|
||||
login: "commenter",
|
||||
url: "https://github.com/commenter",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/2?v=4",
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -873,6 +875,8 @@ describe("GitHubService", () => {
|
||||
});
|
||||
expect(runner.calls[0]?.args[3]).toContain("reviews(first: 100)");
|
||||
expect(runner.calls[0]?.args[3]).toContain("comments(first: 100)");
|
||||
expect(runner.calls[0]?.args[3]).toContain("avatarUrl");
|
||||
expect(runner.calls[0]?.args[3]).toContain("reviewThreads(first: 100)");
|
||||
expect(timeline).toEqual({
|
||||
prNumber: 42,
|
||||
repoOwner: "parentOwner",
|
||||
@@ -885,6 +889,7 @@ describe("GitHubService", () => {
|
||||
id: "PRR_empty_commented",
|
||||
author: "unknown",
|
||||
authorUrl: null,
|
||||
avatarUrl: null,
|
||||
body: "",
|
||||
createdAt: Date.parse("2026-04-02T13:50:00Z"),
|
||||
url: "https://github.com/parentOwner/parentRepo/pull/42#pullrequestreview-2",
|
||||
@@ -895,6 +900,7 @@ describe("GitHubService", () => {
|
||||
id: "PRR_approved",
|
||||
author: "reviewer",
|
||||
authorUrl: "https://github.com/reviewer",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/1?v=4",
|
||||
body: "Looks good to me.",
|
||||
createdAt: Date.parse("2026-04-02T13:52:14Z"),
|
||||
url: "https://github.com/parentOwner/parentRepo/pull/42#pullrequestreview-1",
|
||||
@@ -905,6 +911,7 @@ describe("GitHubService", () => {
|
||||
id: "IC_later",
|
||||
author: "commenter",
|
||||
authorUrl: "https://github.com/commenter",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/2?v=4",
|
||||
body: "Can we add a regression test?",
|
||||
createdAt: Date.parse("2026-04-02T13:55:00Z"),
|
||||
url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-3",
|
||||
@@ -913,6 +920,176 @@ describe("GitHubService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("maps inline review thread comments as chronological PR timeline comments with location", async () => {
|
||||
const runner = createRunner([
|
||||
pullRequestTimelineJson({
|
||||
reviewThreads: {
|
||||
nodes: [
|
||||
{
|
||||
id: "PRRT_1",
|
||||
path: "packages/app/src/git/pull-request-panel/data.ts",
|
||||
line: 24,
|
||||
startLine: 20,
|
||||
isResolved: true,
|
||||
isOutdated: false,
|
||||
comments: {
|
||||
nodes: [
|
||||
{
|
||||
id: "PRRC_1",
|
||||
body: "This should include line context.",
|
||||
url: "https://github.com/parentOwner/parentRepo/pull/42#discussion_r1",
|
||||
createdAt: "2026-04-02T13:51:00Z",
|
||||
author: {
|
||||
login: "inline-reviewer",
|
||||
url: "https://github.com/inline-reviewer",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/3?v=4",
|
||||
},
|
||||
pullRequestReview: { id: "PRR_empty_commented" },
|
||||
},
|
||||
],
|
||||
pageInfo: { hasNextPage: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: { hasNextPage: false },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
const timeline = await service.getPullRequestTimeline({
|
||||
cwd: "/repo",
|
||||
prNumber: 42,
|
||||
repoOwner: "parentOwner",
|
||||
repoName: "parentRepo",
|
||||
});
|
||||
|
||||
expect(timeline.truncated).toBe(true);
|
||||
expect(timeline.items.map((item) => item.id)).toEqual([
|
||||
"PRR_empty_commented",
|
||||
"PRRC_1",
|
||||
"PRR_approved",
|
||||
"IC_later",
|
||||
]);
|
||||
expect(timeline.items[1]).toEqual({
|
||||
kind: "comment",
|
||||
id: "PRRC_1",
|
||||
author: "inline-reviewer",
|
||||
authorUrl: "https://github.com/inline-reviewer",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/3?v=4",
|
||||
body: "This should include line context.",
|
||||
createdAt: Date.parse("2026-04-02T13:51:00Z"),
|
||||
url: "https://github.com/parentOwner/parentRepo/pull/42#discussion_r1",
|
||||
reviewId: "PRR_empty_commented",
|
||||
location: {
|
||||
path: "packages/app/src/git/pull-request-panel/data.ts",
|
||||
line: 24,
|
||||
startLine: 20,
|
||||
threadId: "PRRT_1",
|
||||
isResolved: true,
|
||||
isOutdated: false,
|
||||
},
|
||||
});
|
||||
expect(runner.calls[0]?.args[3]).toContain("pullRequestReview");
|
||||
});
|
||||
|
||||
it("keeps inline review thread comments once when they also appear in PR comments", async () => {
|
||||
const runner = createRunner([
|
||||
pullRequestTimelineJson({
|
||||
comments: {
|
||||
nodes: [
|
||||
{
|
||||
id: "PRRC_1",
|
||||
body: "This should include line context.",
|
||||
url: "https://github.com/parentOwner/parentRepo/pull/42#discussion_r1",
|
||||
createdAt: "2026-04-02T13:51:00Z",
|
||||
author: {
|
||||
login: "inline-reviewer",
|
||||
url: "https://github.com/inline-reviewer",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/3?v=4",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "IC_later",
|
||||
body: "Can we add a regression test?",
|
||||
url: "https://github.com/parentOwner/parentRepo/pull/42#issuecomment-3",
|
||||
createdAt: "2026-04-02T13:55:00Z",
|
||||
author: {
|
||||
login: "commenter",
|
||||
url: "https://github.com/commenter",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/2?v=4",
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: { hasNextPage: false },
|
||||
},
|
||||
reviewThreads: {
|
||||
nodes: [
|
||||
{
|
||||
id: "PRRT_1",
|
||||
path: "packages/app/src/git/pull-request-panel/data.ts",
|
||||
line: 24,
|
||||
startLine: 20,
|
||||
isResolved: false,
|
||||
isOutdated: false,
|
||||
comments: {
|
||||
nodes: [
|
||||
{
|
||||
id: "PRRC_1",
|
||||
body: "This should include line context.",
|
||||
url: "https://github.com/parentOwner/parentRepo/pull/42#discussion_r1",
|
||||
createdAt: "2026-04-02T13:51:00Z",
|
||||
author: {
|
||||
login: "inline-reviewer",
|
||||
url: "https://github.com/inline-reviewer",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/3?v=4",
|
||||
},
|
||||
pullRequestReview: { id: "PRR_empty_commented" },
|
||||
},
|
||||
],
|
||||
pageInfo: { hasNextPage: false },
|
||||
},
|
||||
},
|
||||
],
|
||||
pageInfo: { hasNextPage: false },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
const timeline = await service.getPullRequestTimeline({
|
||||
cwd: "/repo",
|
||||
prNumber: 42,
|
||||
repoOwner: "parentOwner",
|
||||
repoName: "parentRepo",
|
||||
});
|
||||
|
||||
expect(timeline.items.map((item) => item.id)).toEqual([
|
||||
"PRR_empty_commented",
|
||||
"PRRC_1",
|
||||
"PRR_approved",
|
||||
"IC_later",
|
||||
]);
|
||||
expect(timeline.items[1]).toMatchObject({
|
||||
id: "PRRC_1",
|
||||
reviewId: "PRR_empty_commented",
|
||||
location: {
|
||||
path: "packages/app/src/git/pull-request-panel/data.ts",
|
||||
line: 24,
|
||||
startLine: 20,
|
||||
threadId: "PRRT_1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the passed parent repository identity for fork PR timelines", async () => {
|
||||
const runner = createRunner([pullRequestTimelineJson()]);
|
||||
const service = createGitHubService({
|
||||
@@ -1336,6 +1513,7 @@ describe("GitHubService", () => {
|
||||
statusCheckRollup: [
|
||||
{
|
||||
__typename: "CheckRun",
|
||||
databaseId: 12345,
|
||||
completedAt: "2026-04-02T13:52:14Z",
|
||||
conclusion: "SUCCESS",
|
||||
detailsUrl: "https://github.com/acme/repo/actions/runs/123",
|
||||
@@ -1390,6 +1568,8 @@ describe("GitHubService", () => {
|
||||
url: "https://github.com/acme/repo/actions/runs/123",
|
||||
workflow: "Server CI",
|
||||
duration: "2m 14s",
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 123,
|
||||
},
|
||||
{
|
||||
name: "deploy/preview",
|
||||
@@ -1402,6 +1582,293 @@ describe("GitHubService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches GitHub check details with capped failed job log tails and reuses cached logs", async () => {
|
||||
const longLog = Array.from({ length: 220 }, (_, index) => `line ${index + 1}`).join("\n");
|
||||
const runner = createRunner([
|
||||
JSON.stringify({
|
||||
id: 12345,
|
||||
name: "server-tests",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
html_url: "https://github.com/acme/repo/actions/runs/456/job/789",
|
||||
details_url: "https://github.com/acme/repo/actions/runs/456/job/789",
|
||||
output: {
|
||||
title: "Tests failed",
|
||||
summary: "1 failure",
|
||||
text: "Assertion failed",
|
||||
},
|
||||
check_suite: {
|
||||
workflow_run: {
|
||||
id: 456,
|
||||
},
|
||||
},
|
||||
}),
|
||||
JSON.stringify([
|
||||
{
|
||||
path: "packages/server/src/index.ts",
|
||||
start_line: 10,
|
||||
end_line: 12,
|
||||
annotation_level: "failure",
|
||||
message: "Expected true",
|
||||
title: "server test failed",
|
||||
raw_details: "stack trace",
|
||||
},
|
||||
]),
|
||||
JSON.stringify({
|
||||
jobs: [
|
||||
{
|
||||
id: 789,
|
||||
name: "test",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
html_url: "https://github.com/acme/repo/actions/runs/456/job/789",
|
||||
completed_at: "2026-04-02T13:52:14Z",
|
||||
},
|
||||
{
|
||||
id: 790,
|
||||
name: "lint",
|
||||
status: "completed",
|
||||
conclusion: "success",
|
||||
html_url: "https://github.com/acme/repo/actions/runs/456/job/790",
|
||||
completed_at: "2026-04-02T13:52:15Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
longLog,
|
||||
JSON.stringify({
|
||||
id: 12345,
|
||||
name: "server-tests",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
html_url: "https://github.com/acme/repo/actions/runs/456/job/789",
|
||||
check_suite: { workflow_run: { id: 456 } },
|
||||
}),
|
||||
JSON.stringify([]),
|
||||
JSON.stringify({
|
||||
jobs: [
|
||||
{
|
||||
id: 789,
|
||||
name: "test",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
html_url: "https://github.com/acme/repo/actions/runs/456/job/789",
|
||||
completed_at: "2026-04-02T13:52:14Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
const first = await service.getGitHubCheckDetails({
|
||||
cwd: "/repo",
|
||||
repoOwner: "acme",
|
||||
repoName: "repo",
|
||||
checkRunId: 12345,
|
||||
});
|
||||
const second = await service.getGitHubCheckDetails({
|
||||
cwd: "/repo",
|
||||
repoOwner: "acme",
|
||||
repoName: "repo",
|
||||
checkRunId: 12345,
|
||||
force: true,
|
||||
reason: "verify-log-cache",
|
||||
});
|
||||
|
||||
expect(first).toEqual({
|
||||
checkRunId: 12345,
|
||||
workflowRunId: 456,
|
||||
name: "server-tests",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
url: "https://github.com/acme/repo/actions/runs/456/job/789",
|
||||
detailsUrl: "https://github.com/acme/repo/actions/runs/456/job/789",
|
||||
output: {
|
||||
title: "Tests failed",
|
||||
summary: "1 failure",
|
||||
text: "Assertion failed",
|
||||
},
|
||||
annotations: [
|
||||
{
|
||||
path: "packages/server/src/index.ts",
|
||||
startLine: 10,
|
||||
endLine: 12,
|
||||
annotationLevel: "failure",
|
||||
message: "Expected true",
|
||||
title: "server test failed",
|
||||
rawDetails: "stack trace",
|
||||
},
|
||||
],
|
||||
failedJobs: [
|
||||
{
|
||||
jobId: 789,
|
||||
name: "test",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
url: "https://github.com/acme/repo/actions/runs/456/job/789",
|
||||
logTail: Array.from({ length: 200 }, (_, index) => `line ${index + 21}`).join("\n"),
|
||||
logTruncated: true,
|
||||
},
|
||||
],
|
||||
truncated: true,
|
||||
});
|
||||
expect(second.failedJobs[0]?.logTail).toBe(first.failedJobs[0]?.logTail);
|
||||
expect(runner.calls.map((call) => call.args)).toEqual([
|
||||
["api", "repos/acme/repo/check-runs/12345"],
|
||||
["api", "repos/acme/repo/check-runs/12345/annotations", "-f", "per_page=20"],
|
||||
["api", "repos/acme/repo/actions/runs/456/jobs", "-f", "per_page=100"],
|
||||
["api", "repos/acme/repo/actions/jobs/789/logs"],
|
||||
["api", "repos/acme/repo/check-runs/12345"],
|
||||
["api", "repos/acme/repo/check-runs/12345/annotations", "-f", "per_page=20"],
|
||||
["api", "repos/acme/repo/actions/runs/456/jobs", "-f", "per_page=100"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("caps failed check jobs at five and caps each log tail to 16 KiB", async () => {
|
||||
const oversizedLog = "x".repeat(20 * 1024);
|
||||
const failedJobs = Array.from({ length: 6 }, (_, index) => ({
|
||||
id: 800 + index,
|
||||
name: `failed-${index + 1}`,
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
html_url: `https://github.com/acme/repo/actions/runs/456/job/${800 + index}`,
|
||||
completed_at: `2026-04-02T13:52:${10 + index}Z`,
|
||||
}));
|
||||
const runner = createRunner([
|
||||
JSON.stringify({
|
||||
id: 12345,
|
||||
name: "server-tests",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
html_url: "https://github.com/acme/repo/actions/runs/456/job/789",
|
||||
check_suite: { workflow_run: { id: 456 } },
|
||||
}),
|
||||
JSON.stringify([]),
|
||||
JSON.stringify({ jobs: failedJobs }),
|
||||
oversizedLog,
|
||||
oversizedLog,
|
||||
oversizedLog,
|
||||
oversizedLog,
|
||||
oversizedLog,
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
const details = await service.getGitHubCheckDetails({
|
||||
cwd: "/repo",
|
||||
repoOwner: "acme",
|
||||
repoName: "repo",
|
||||
checkRunId: 12345,
|
||||
});
|
||||
|
||||
expect(details.failedJobs.map((job) => job.jobId)).toEqual([800, 801, 802, 803, 804]);
|
||||
expect(details.failedJobs.map((job) => job.logTruncated)).toEqual([
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
expect(details.failedJobs.map((job) => Buffer.byteLength(job.logTail ?? "", "utf8"))).toEqual([
|
||||
16 * 1024,
|
||||
16 * 1024,
|
||||
16 * 1024,
|
||||
16 * 1024,
|
||||
16 * 1024,
|
||||
]);
|
||||
expect(runner.calls.map((call) => call.args)).toEqual([
|
||||
["api", "repos/acme/repo/check-runs/12345"],
|
||||
["api", "repos/acme/repo/check-runs/12345/annotations", "-f", "per_page=20"],
|
||||
["api", "repos/acme/repo/actions/runs/456/jobs", "-f", "per_page=100"],
|
||||
["api", "repos/acme/repo/actions/jobs/800/logs"],
|
||||
["api", "repos/acme/repo/actions/jobs/801/logs"],
|
||||
["api", "repos/acme/repo/actions/jobs/802/logs"],
|
||||
["api", "repos/acme/repo/actions/jobs/803/logs"],
|
||||
["api", "repos/acme/repo/actions/jobs/804/logs"],
|
||||
]);
|
||||
expect(details.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("marks check details truncated when annotations hit the page cap", async () => {
|
||||
const annotations = Array.from({ length: 20 }, (_, index) => ({
|
||||
path: `packages/server/src/file-${index}.ts`,
|
||||
start_line: index + 1,
|
||||
annotation_level: "failure",
|
||||
message: `Failure ${index + 1}`,
|
||||
}));
|
||||
const runner = createRunner([
|
||||
JSON.stringify({
|
||||
id: 12345,
|
||||
name: "server-tests",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
html_url: "https://github.com/acme/repo/actions/runs/456/job/789",
|
||||
check_suite: { workflow_run: { id: 456 } },
|
||||
}),
|
||||
JSON.stringify(annotations),
|
||||
JSON.stringify({ jobs: [] }),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
const details = await service.getGitHubCheckDetails({
|
||||
cwd: "/repo",
|
||||
repoOwner: "acme",
|
||||
repoName: "repo",
|
||||
checkRunId: 12345,
|
||||
});
|
||||
|
||||
expect(details.annotations).toHaveLength(20);
|
||||
expect(details.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("marks check details truncated when workflow jobs hit the page cap", async () => {
|
||||
const jobs = Array.from({ length: 100 }, (_, index) => ({
|
||||
id: 900 + index,
|
||||
name: `job-${index + 1}`,
|
||||
status: "completed",
|
||||
conclusion: "success",
|
||||
html_url: `https://github.com/acme/repo/actions/runs/456/job/${900 + index}`,
|
||||
completed_at: `2026-04-02T13:52:${String(index % 60).padStart(2, "0")}Z`,
|
||||
}));
|
||||
const runner = createRunner([
|
||||
JSON.stringify({
|
||||
id: 12345,
|
||||
name: "server-tests",
|
||||
status: "completed",
|
||||
conclusion: "failure",
|
||||
html_url: "https://github.com/acme/repo/actions/runs/456/job/789",
|
||||
check_suite: { workflow_run: { id: 456 } },
|
||||
}),
|
||||
JSON.stringify([]),
|
||||
JSON.stringify({ jobs }),
|
||||
]);
|
||||
const service = createGitHubService({
|
||||
runner: runner.runner,
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
const details = await service.getGitHubCheckDetails({
|
||||
cwd: "/repo",
|
||||
repoOwner: "acme",
|
||||
repoName: "repo",
|
||||
checkRunId: 12345,
|
||||
});
|
||||
|
||||
expect(details.failedJobs).toEqual([]);
|
||||
expect(details.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("retries current PR view without statusCheckRollup when token permissions are insufficient", async () => {
|
||||
const runner = createScriptedRunner([
|
||||
{
|
||||
|
||||
@@ -6,6 +6,12 @@ import { runGitCommand } from "../utils/run-git-command.js";
|
||||
import { execCommand } from "../utils/spawn.js";
|
||||
|
||||
const DEFAULT_GITHUB_CACHE_TTL_MS = 30_000;
|
||||
const CHECK_ANNOTATION_PAGE_MAX = 20;
|
||||
const CHECK_LOG_TAIL_MAX_LINES = 200;
|
||||
const CHECK_LOG_TAIL_MAX_BYTES = 16 * 1024;
|
||||
const CHECK_LOG_TAIL_CACHE_MAX_ENTRIES = 128;
|
||||
const ACTIONS_JOB_PAGE_MAX = 100;
|
||||
const FAILED_CHECK_JOB_LIMIT = 5;
|
||||
export const GITHUB_POLL_FAST_INTERVAL_MS = 20_000;
|
||||
export const GITHUB_POLL_SLOW_INTERVAL_MS = 120_000;
|
||||
export const GITHUB_POLL_ERROR_BACKOFF_CAP_MS = 300_000;
|
||||
@@ -41,6 +47,7 @@ const GitHubPullRequestSummarySchema = z.object({
|
||||
|
||||
const PullRequestCheckRunNodeSchema = z.object({
|
||||
__typename: z.literal("CheckRun"),
|
||||
databaseId: z.number().nullable().optional(),
|
||||
name: z.string(),
|
||||
workflowName: z.string().nullable().optional(),
|
||||
conclusion: z.string().nullable().optional(),
|
||||
@@ -79,6 +86,59 @@ const LegacyPullRequestStatusCheckRollupSchema = z.object({
|
||||
contexts: z.array(z.unknown()),
|
||||
});
|
||||
|
||||
const GitHubCheckRunDetailsSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string().catch(""),
|
||||
status: z.string().nullable().optional(),
|
||||
conclusion: z.string().nullable().optional(),
|
||||
html_url: z.string().nullable().optional(),
|
||||
details_url: z.string().nullable().optional(),
|
||||
output: z
|
||||
.object({
|
||||
title: z.string().nullable().optional(),
|
||||
summary: z.string().nullable().optional(),
|
||||
text: z.string().nullable().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
check_suite: z
|
||||
.object({
|
||||
workflow_run: z
|
||||
.object({
|
||||
id: z.number().nullable().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const GitHubCheckAnnotationSchema = z.object({
|
||||
path: z.string().optional(),
|
||||
start_line: z.number().optional(),
|
||||
end_line: z.number().optional(),
|
||||
annotation_level: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
raw_details: z.string().optional(),
|
||||
});
|
||||
|
||||
const GitHubCheckAnnotationsSchema = z.array(GitHubCheckAnnotationSchema).catch([]);
|
||||
|
||||
const GitHubActionsJobSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string().catch(""),
|
||||
status: z.string().nullable().optional(),
|
||||
conclusion: z.string().nullable().optional(),
|
||||
html_url: z.string().nullable().optional(),
|
||||
completed_at: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const GitHubActionsJobsSchema = z.object({
|
||||
jobs: z.array(GitHubActionsJobSchema).catch([]),
|
||||
});
|
||||
|
||||
const PullRequestReviewDecisionSchema = z
|
||||
.enum(["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"])
|
||||
.nullable()
|
||||
@@ -158,6 +218,7 @@ const TimelineAuthorSchema = z
|
||||
.object({
|
||||
login: z.string().optional(),
|
||||
url: z.string().nullable().optional(),
|
||||
avatarUrl: z.string().nullable().optional(),
|
||||
})
|
||||
.nullable()
|
||||
.optional();
|
||||
@@ -179,6 +240,29 @@ const PullRequestTimelineCommentNodeSchema = z.object({
|
||||
author: TimelineAuthorSchema,
|
||||
});
|
||||
|
||||
const PullRequestReviewThreadCommentNodeSchema = PullRequestTimelineCommentNodeSchema.extend({
|
||||
pullRequestReview: z
|
||||
.object({ id: z.string().catch("") })
|
||||
.nullable()
|
||||
.optional()
|
||||
.catch(null),
|
||||
});
|
||||
|
||||
const PullRequestReviewThreadNodeSchema = z.object({
|
||||
id: z.string().catch(""),
|
||||
path: z.string().catch(""),
|
||||
line: z.number().nullable().optional().catch(null),
|
||||
startLine: z.number().nullable().optional().catch(null),
|
||||
isResolved: z.boolean().catch(false),
|
||||
isOutdated: z.boolean().catch(false),
|
||||
comments: z
|
||||
.object({
|
||||
nodes: z.array(PullRequestReviewThreadCommentNodeSchema).catch([]),
|
||||
pageInfo: z.object({ hasNextPage: z.boolean().catch(false) }).catch({ hasNextPage: false }),
|
||||
})
|
||||
.catch({ nodes: [], pageInfo: { hasNextPage: false } }),
|
||||
});
|
||||
|
||||
const PullRequestTimelinePageInfoSchema = z.object({
|
||||
hasNextPage: z.boolean().catch(false),
|
||||
});
|
||||
@@ -203,6 +287,12 @@ const PullRequestTimelineGraphqlSchema = z.object({
|
||||
pageInfo: PullRequestTimelinePageInfoSchema.catch({ hasNextPage: false }),
|
||||
})
|
||||
.catch({ nodes: [], pageInfo: { hasNextPage: false } }),
|
||||
reviewThreads: z
|
||||
.object({
|
||||
nodes: z.array(PullRequestReviewThreadNodeSchema).catch([]),
|
||||
pageInfo: PullRequestTimelinePageInfoSchema.catch({ hasNextPage: false }),
|
||||
})
|
||||
.catch({ nodes: [], pageInfo: { hasNextPage: false } }),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
@@ -328,6 +418,7 @@ query PullRequestTimeline($owner: String!, $name: String!, $number: Int!) {
|
||||
author {
|
||||
login
|
||||
url
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
@@ -343,6 +434,39 @@ query PullRequestTimeline($owner: String!, $name: String!, $number: Int!) {
|
||||
author {
|
||||
login
|
||||
url
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
reviewThreads(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
path
|
||||
line
|
||||
startLine
|
||||
isResolved
|
||||
isOutdated
|
||||
comments(first: 100) {
|
||||
nodes {
|
||||
id
|
||||
body
|
||||
url
|
||||
createdAt
|
||||
author {
|
||||
login
|
||||
url
|
||||
avatarUrl
|
||||
}
|
||||
pullRequestReview {
|
||||
id
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
@@ -420,6 +544,8 @@ export interface PullRequestCheck {
|
||||
url: string | null;
|
||||
workflow?: string;
|
||||
duration?: string;
|
||||
checkRunId?: number;
|
||||
workflowRunId?: number;
|
||||
}
|
||||
|
||||
export type PullRequestChecksStatus = "none" | "pending" | "success" | "failure";
|
||||
@@ -472,6 +598,7 @@ interface PullRequestTimelineItemBase {
|
||||
id: string;
|
||||
author: string;
|
||||
authorUrl: string | null;
|
||||
avatarUrl: string | null;
|
||||
body: string;
|
||||
createdAt: number;
|
||||
url: string;
|
||||
@@ -484,8 +611,19 @@ export type PullRequestTimelineItem =
|
||||
})
|
||||
| (PullRequestTimelineItemBase & {
|
||||
kind: "comment";
|
||||
reviewId?: string;
|
||||
location?: PullRequestTimelineCommentLocation;
|
||||
});
|
||||
|
||||
export interface PullRequestTimelineCommentLocation {
|
||||
path: string;
|
||||
line?: number;
|
||||
startLine?: number;
|
||||
threadId?: string;
|
||||
isResolved?: boolean;
|
||||
isOutdated?: boolean;
|
||||
}
|
||||
|
||||
export type GitHubPullRequestTimelineErrorKind = "not_found" | "forbidden" | "unknown";
|
||||
|
||||
export interface GitHubPullRequestTimelineError {
|
||||
@@ -577,6 +715,53 @@ export type GetGitHubPullRequestTimelineOptions = {
|
||||
repoName: string;
|
||||
} & GitHubReadOptions;
|
||||
|
||||
export type GetGitHubCheckDetailsOptions = {
|
||||
cwd: string;
|
||||
repoOwner: string;
|
||||
repoName: string;
|
||||
checkRunId: number;
|
||||
workflowRunId?: number;
|
||||
} & GitHubReadOptions;
|
||||
|
||||
export interface GitHubCheckAnnotation {
|
||||
path?: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
annotationLevel?: string;
|
||||
message?: string;
|
||||
title?: string;
|
||||
rawDetails?: string;
|
||||
}
|
||||
|
||||
export interface GitHubCheckFailedJob {
|
||||
jobId: number;
|
||||
name: string;
|
||||
status?: string | null;
|
||||
conclusion?: string | null;
|
||||
url?: string | null;
|
||||
completedAt?: string;
|
||||
logTail?: string;
|
||||
logTruncated?: boolean;
|
||||
}
|
||||
|
||||
export interface GitHubCheckDetails {
|
||||
checkRunId: number;
|
||||
workflowRunId?: number | null;
|
||||
name: string;
|
||||
status?: string | null;
|
||||
conclusion?: string | null;
|
||||
url?: string | null;
|
||||
detailsUrl?: string | null;
|
||||
output?: {
|
||||
title?: string | null;
|
||||
summary?: string | null;
|
||||
text?: string | null;
|
||||
} | null;
|
||||
annotations: GitHubCheckAnnotation[];
|
||||
failedJobs: GitHubCheckFailedJob[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface GitHubSearchResult {
|
||||
items: Array<{
|
||||
kind: "issue" | "pr";
|
||||
@@ -627,6 +812,7 @@ export interface GitHubService {
|
||||
getPullRequestTimeline(
|
||||
options: GetGitHubPullRequestTimelineOptions,
|
||||
): Promise<GitHubPullRequestTimeline>;
|
||||
getGitHubCheckDetails(options: GetGitHubCheckDetailsOptions): Promise<GitHubCheckDetails>;
|
||||
searchIssuesAndPrs(options: SearchGitHubIssuesAndPrsOptions): Promise<GitHubSearchResult>;
|
||||
createPullRequest(
|
||||
options: CreateGitHubPullRequestOptions,
|
||||
@@ -745,6 +931,7 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
const inFlight = new Map<string, InFlightCacheEntry>();
|
||||
const pollTargets = new Map<string, GitHubPollTarget>();
|
||||
const checkLogTailCache = new Map<string, { logTail: string; logTruncated: boolean }>();
|
||||
let api!: GitHubService;
|
||||
|
||||
async function cached<T>(params: {
|
||||
@@ -1088,6 +1275,94 @@ export function createGitHubService(options: CreateGitHubServiceOptions = {}): G
|
||||
});
|
||||
},
|
||||
|
||||
getGitHubCheckDetails(input) {
|
||||
return cached({
|
||||
cwd: input.cwd,
|
||||
method: "getGitHubCheckDetails",
|
||||
args: {
|
||||
repoOwner: input.repoOwner,
|
||||
repoName: input.repoName,
|
||||
checkRunId: input.checkRunId,
|
||||
workflowRunId: input.workflowRunId,
|
||||
},
|
||||
readOptions: input,
|
||||
load: async () => {
|
||||
const repoPath = `repos/${input.repoOwner}/${input.repoName}`;
|
||||
const checkRun = parseGitHubCheckRunDetails(
|
||||
await run(["api", `${repoPath}/check-runs/${input.checkRunId}`], { cwd: input.cwd }),
|
||||
);
|
||||
const annotations = parseGitHubCheckAnnotations(
|
||||
await run(
|
||||
[
|
||||
"api",
|
||||
`${repoPath}/check-runs/${input.checkRunId}/annotations`,
|
||||
"-f",
|
||||
`per_page=${CHECK_ANNOTATION_PAGE_MAX}`,
|
||||
],
|
||||
{
|
||||
cwd: input.cwd,
|
||||
},
|
||||
),
|
||||
);
|
||||
const workflowRunId = input.workflowRunId ?? checkRun.workflowRunId ?? null;
|
||||
const failedJobs: GitHubCheckFailedJob[] = [];
|
||||
let truncated = annotations.length >= CHECK_ANNOTATION_PAGE_MAX;
|
||||
|
||||
if (typeof workflowRunId === "number") {
|
||||
const jobs = parseGitHubActionsJobs(
|
||||
await run(
|
||||
[
|
||||
"api",
|
||||
`${repoPath}/actions/runs/${workflowRunId}/jobs`,
|
||||
"-f",
|
||||
`per_page=${ACTIONS_JOB_PAGE_MAX}`,
|
||||
],
|
||||
{
|
||||
cwd: input.cwd,
|
||||
},
|
||||
),
|
||||
);
|
||||
const failed = jobs.filter(isFailedActionsJob);
|
||||
truncated ||= jobs.length >= ACTIONS_JOB_PAGE_MAX;
|
||||
truncated ||= failed.length > FAILED_CHECK_JOB_LIMIT;
|
||||
for (const job of failed.slice(0, FAILED_CHECK_JOB_LIMIT)) {
|
||||
const log = await getCachedCheckLogTail({
|
||||
cwd: input.cwd,
|
||||
repoPath,
|
||||
job,
|
||||
run,
|
||||
cache: checkLogTailCache,
|
||||
});
|
||||
truncated ||= log.logTruncated;
|
||||
failedJobs.push({
|
||||
jobId: job.jobId,
|
||||
name: job.name,
|
||||
status: job.status,
|
||||
conclusion: job.conclusion,
|
||||
url: job.url,
|
||||
logTail: log.logTail,
|
||||
logTruncated: log.logTruncated,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
checkRunId: checkRun.checkRunId,
|
||||
workflowRunId,
|
||||
name: checkRun.name,
|
||||
status: checkRun.status,
|
||||
conclusion: checkRun.conclusion,
|
||||
url: checkRun.url,
|
||||
detailsUrl: checkRun.detailsUrl,
|
||||
output: checkRun.output,
|
||||
annotations,
|
||||
failedJobs,
|
||||
truncated,
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async searchIssuesAndPrs(input) {
|
||||
if (input.force && !input.reason) {
|
||||
throw new Error("GitHubService forced read requires a reason");
|
||||
@@ -1945,10 +2220,19 @@ function parsePullRequestTimeline(
|
||||
): GitHubPullRequestTimeline {
|
||||
const parsed = PullRequestTimelineGraphqlSchema.parse(JSON.parse(stdout || "{}"));
|
||||
const pullRequest = parsed.data?.repository?.pullRequest;
|
||||
const reviewThreadItems = pullRequest
|
||||
? pullRequest.reviewThreads.nodes.flatMap(toPullRequestTimelineReviewThreadItems)
|
||||
: [];
|
||||
const reviewThreadItemIds = new Set(
|
||||
reviewThreadItems.map((item) => item.id).filter((id) => id.length > 0),
|
||||
);
|
||||
const items = pullRequest
|
||||
? [
|
||||
...pullRequest.reviews.nodes.flatMap(toPullRequestTimelineReviewItem),
|
||||
...pullRequest.comments.nodes.map(toPullRequestTimelineCommentItem),
|
||||
...pullRequest.comments.nodes
|
||||
.filter((comment) => !reviewThreadItemIds.has(comment.id))
|
||||
.map(toPullRequestTimelineCommentItem),
|
||||
...reviewThreadItems,
|
||||
].sort(compareTimelineItems)
|
||||
: [];
|
||||
return {
|
||||
@@ -1956,9 +2240,12 @@ function parsePullRequestTimeline(
|
||||
repoOwner: identity.repoOwner,
|
||||
repoName: identity.repoName,
|
||||
items,
|
||||
// S3 deliberately caps timeline fetches at the first 100 reviews and first 100 comments.
|
||||
// S3 deliberately caps timeline fetches at the first 100 reviews, comments, and review threads.
|
||||
truncated: Boolean(
|
||||
pullRequest?.reviews.pageInfo.hasNextPage || pullRequest?.comments.pageInfo.hasNextPage,
|
||||
pullRequest?.reviews.pageInfo.hasNextPage ||
|
||||
pullRequest?.comments.pageInfo.hasNextPage ||
|
||||
pullRequest?.reviewThreads.pageInfo.hasNextPage ||
|
||||
pullRequest?.reviewThreads.nodes.some((thread) => thread.comments.pageInfo.hasNextPage),
|
||||
),
|
||||
error: pullRequest ? null : { kind: "not_found", message: "Pull request not found" },
|
||||
};
|
||||
@@ -1977,6 +2264,7 @@ function toPullRequestTimelineReviewItem(
|
||||
id: review.id,
|
||||
author: review.author?.login ?? "unknown",
|
||||
authorUrl: review.author?.url ?? null,
|
||||
avatarUrl: review.author?.avatarUrl ?? null,
|
||||
body: review.body ?? "",
|
||||
createdAt: parseOptionalTime(review.submittedAt ?? null),
|
||||
url: review.url,
|
||||
@@ -1993,12 +2281,146 @@ function toPullRequestTimelineCommentItem(
|
||||
id: comment.id,
|
||||
author: comment.author?.login ?? "unknown",
|
||||
authorUrl: comment.author?.url ?? null,
|
||||
avatarUrl: comment.author?.avatarUrl ?? null,
|
||||
body: comment.body ?? "",
|
||||
createdAt: parseOptionalTime(comment.createdAt ?? null),
|
||||
url: comment.url,
|
||||
};
|
||||
}
|
||||
|
||||
function toPullRequestTimelineReviewThreadItems(
|
||||
thread: z.infer<typeof PullRequestReviewThreadNodeSchema>,
|
||||
): PullRequestTimelineItem[] {
|
||||
return thread.comments.nodes.map((comment) => ({
|
||||
...toPullRequestTimelineCommentItem(comment),
|
||||
...(comment.pullRequestReview?.id ? { reviewId: comment.pullRequestReview.id } : {}),
|
||||
location: {
|
||||
path: thread.path,
|
||||
...(thread.line !== null && thread.line !== undefined ? { line: thread.line } : {}),
|
||||
...(thread.startLine !== null && thread.startLine !== undefined
|
||||
? { startLine: thread.startLine }
|
||||
: {}),
|
||||
...(thread.id ? { threadId: thread.id } : {}),
|
||||
isResolved: thread.isResolved,
|
||||
isOutdated: thread.isOutdated,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function parseGitHubCheckRunDetails(stdout: string): GitHubCheckDetails {
|
||||
const parsed = GitHubCheckRunDetailsSchema.parse(JSON.parse(stdout || "{}"));
|
||||
return {
|
||||
checkRunId: parsed.id,
|
||||
workflowRunId: parsed.check_suite?.workflow_run?.id ?? null,
|
||||
name: parsed.name,
|
||||
status: parsed.status,
|
||||
conclusion: parsed.conclusion,
|
||||
url: parsed.html_url,
|
||||
detailsUrl: parsed.details_url,
|
||||
output: parsed.output,
|
||||
annotations: [],
|
||||
failedJobs: [],
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
function parseGitHubCheckAnnotations(stdout: string): GitHubCheckAnnotation[] {
|
||||
return GitHubCheckAnnotationsSchema.parse(JSON.parse(stdout || "[]")).map((annotation) => {
|
||||
const result: GitHubCheckAnnotation = {};
|
||||
if (annotation.path) result.path = annotation.path;
|
||||
if (annotation.start_line !== undefined) result.startLine = annotation.start_line;
|
||||
if (annotation.end_line !== undefined) result.endLine = annotation.end_line;
|
||||
if (annotation.annotation_level) result.annotationLevel = annotation.annotation_level;
|
||||
if (annotation.message) result.message = annotation.message;
|
||||
if (annotation.title) result.title = annotation.title;
|
||||
if (annotation.raw_details) result.rawDetails = annotation.raw_details;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function parseGitHubActionsJobs(stdout: string): GitHubCheckFailedJob[] {
|
||||
return GitHubActionsJobsSchema.parse(JSON.parse(stdout || "{}")).jobs.map((job) => {
|
||||
const result: GitHubCheckFailedJob = {
|
||||
jobId: job.id,
|
||||
name: job.name,
|
||||
status: job.status,
|
||||
conclusion: job.conclusion,
|
||||
url: job.html_url,
|
||||
};
|
||||
if (job.completed_at) result.completedAt = job.completed_at;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function isFailedActionsJob(job: GitHubCheckFailedJob): boolean {
|
||||
return (
|
||||
job.conclusion === "failure" ||
|
||||
job.conclusion === "cancelled" ||
|
||||
job.conclusion === "timed_out" ||
|
||||
job.conclusion === "action_required"
|
||||
);
|
||||
}
|
||||
|
||||
async function getCachedCheckLogTail(input: {
|
||||
cwd: string;
|
||||
repoPath: string;
|
||||
job: GitHubCheckFailedJob & { completedAt?: string };
|
||||
run: (args: string[], options: GitHubCommandRunnerOptions) => Promise<string>;
|
||||
cache: Map<string, { logTail: string; logTruncated: boolean }>;
|
||||
}): Promise<{ logTail: string; logTruncated: boolean }> {
|
||||
const key = `${input.job.jobId}:${input.job.completedAt ?? ""}`;
|
||||
const cached = input.cache.get(key);
|
||||
if (cached) {
|
||||
input.cache.delete(key);
|
||||
input.cache.set(key, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const capped = capCheckLogTail(
|
||||
await input.run(["api", `${input.repoPath}/actions/jobs/${input.job.jobId}/logs`], {
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
);
|
||||
input.cache.set(key, capped);
|
||||
while (input.cache.size > CHECK_LOG_TAIL_CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = input.cache.keys().next().value;
|
||||
if (!oldestKey) {
|
||||
break;
|
||||
}
|
||||
input.cache.delete(oldestKey);
|
||||
}
|
||||
return capped;
|
||||
}
|
||||
|
||||
function capCheckLogTail(log: string): { logTail: string; logTruncated: boolean } {
|
||||
const lines = log.split("\n");
|
||||
let truncated = lines.length > CHECK_LOG_TAIL_MAX_LINES;
|
||||
let tail = lines.slice(-CHECK_LOG_TAIL_MAX_LINES).join("\n");
|
||||
|
||||
if (Buffer.byteLength(tail, "utf8") > CHECK_LOG_TAIL_MAX_BYTES) {
|
||||
truncated = true;
|
||||
tail = utf8SuffixWithinBytes(tail, CHECK_LOG_TAIL_MAX_BYTES);
|
||||
}
|
||||
|
||||
return { logTail: tail, logTruncated: truncated };
|
||||
}
|
||||
|
||||
function utf8SuffixWithinBytes(value: string, maxBytes: number): string {
|
||||
let lowerBound = 0;
|
||||
let upperBound = value.length;
|
||||
|
||||
while (lowerBound < upperBound) {
|
||||
const midpoint = Math.floor((lowerBound + upperBound) / 2);
|
||||
if (Buffer.byteLength(value.slice(midpoint), "utf8") > maxBytes) {
|
||||
lowerBound = midpoint + 1;
|
||||
} else {
|
||||
upperBound = midpoint;
|
||||
}
|
||||
}
|
||||
|
||||
return value.slice(lowerBound);
|
||||
}
|
||||
|
||||
function mapTimelineReviewState(
|
||||
state: string,
|
||||
body: string,
|
||||
@@ -2161,6 +2583,10 @@ function buildPullRequestCheck(
|
||||
...(typeof context.workflowName === "string" && context.workflowName.trim().length > 0
|
||||
? { workflow: context.workflowName }
|
||||
: {}),
|
||||
...(typeof context.databaseId === "number" ? { checkRunId: context.databaseId } : {}),
|
||||
...(typeof context.checkSuite?.workflowRun?.databaseId === "number"
|
||||
? { workflowRunId: context.checkSuite.workflowRun.databaseId }
|
||||
: {}),
|
||||
...formatCheckRunDuration(context),
|
||||
recency: getCheckRunRecency(context),
|
||||
};
|
||||
|
||||
+6
-6
@@ -3,28 +3,28 @@
|
||||
"setup": [
|
||||
"npm ci",
|
||||
"node ./scripts/seed-ios-native-cache.mjs",
|
||||
"cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_SEED_HOME=\"$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home\" PASEO_HOME=\"$PASEO_WORKTREE_PATH/.dev/paseo-home\" ./scripts/dev-home.sh",
|
||||
"cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_SEED_HOME=\"$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home\" PASEO_HOME=\"$PWD/.dev/paseo-home\" ./scripts/dev-home.sh",
|
||||
"npm run build:server",
|
||||
"npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"cp \"$PASEO_SOURCE_CHECKOUT_PATH/packages/server/.env\" \"$PASEO_WORKTREE_PATH/packages/server/.env\""
|
||||
"cp \"$PASEO_SOURCE_CHECKOUT_PATH/packages/server/.env\" \"$PWD/packages/server/.env\""
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"daemon": {
|
||||
"type": "service",
|
||||
"command": "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"$PASEO_WORKTREE_PATH\" PASEO_HOME=\"$PASEO_WORKTREE_PATH/.dev/paseo-home\" PASEO_SKIP_DEV_SERVER_BUILD=1 PASEO_LISTEN=0.0.0.0:$PASEO_PORT ./scripts/dev-daemon.sh"
|
||||
"command": "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"$PWD\" PASEO_HOME=\"$PWD/.dev/paseo-home\" PASEO_SKIP_DEV_SERVER_BUILD=1 PASEO_LISTEN=0.0.0.0:$PASEO_PORT ./scripts/dev-daemon.sh"
|
||||
},
|
||||
"app": {
|
||||
"type": "service",
|
||||
"command": "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"$PASEO_WORKTREE_PATH\" PASEO_HOME=\"$PASEO_WORKTREE_PATH/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT} EXPO_PORT=$PASEO_PORT ./scripts/dev-app.sh"
|
||||
"command": "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"$PWD\" PASEO_HOME=\"$PWD/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT} EXPO_PORT=$PASEO_PORT ./scripts/dev-app.sh"
|
||||
},
|
||||
"desktop": {
|
||||
"type": "service",
|
||||
"command": "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"$PASEO_WORKTREE_PATH\" PASEO_HOME=\"$PASEO_WORKTREE_PATH/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT} EXPO_PORT=$PASEO_PORT npm run dev --workspace=@getpaseo/desktop"
|
||||
"command": "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"$PWD\" PASEO_HOME=\"$PWD/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT} EXPO_PORT=$PASEO_PORT npm run dev --workspace=@getpaseo/desktop"
|
||||
},
|
||||
"ios-simulator": {
|
||||
"type": "service",
|
||||
"command": "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"$PASEO_WORKTREE_PATH\" PASEO_HOME=\"$PASEO_WORKTREE_PATH/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT} ./scripts/paseo-ios-simulator-service.sh"
|
||||
"command": "cross-env PASEO_DEV_MANAGED_HOME=1 PASEO_DEV_ROOT=\"$PWD\" PASEO_HOME=\"$PWD/.dev/paseo-home\" PASEO_LISTEN=0.0.0.0:${PASEO_SERVICE_DAEMON_PORT} PASEO_DEV_DAEMON_ENDPOINT=localhost:${PASEO_SERVICE_DAEMON_PORT} ./scripts/paseo-ios-simulator-service.sh"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user