Merge origin/main into project-git-detection

This commit is contained in:
Mohamed Boudra
2026-07-17 07:17:08 +00:00
335 changed files with 38736 additions and 4389 deletions

View File

@@ -1,5 +1,11 @@
# Changelog
## 0.1.110 - 2026-07-16
### Fixed
- Kimi and other ACP agents now stay marked as running while a response is actively streaming ([#2148](https://github.com/getpaseo/paseo/pull/2148))
## 0.1.109 - 2026-07-16
> **Important update notice**

View File

@@ -37,6 +37,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
| [docs/forge-providers.md](docs/forge-providers.md) | Adding a git forge: registry/manifest, drop-in checklist, self-host/GHES, the two facts tiers |
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |

View File

@@ -68,6 +68,10 @@ Paseo validates the `Host` header on every HTTP request and every WebSocket upgr
Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their authentication. Each agent provider handles its own credentials. Paseo never stores or transmits provider API keys. Agents run in your user context with your existing credentials.
## Forge host trust
Paseo only talks to a forge host that is either a known cloud host or one the forge CLI is already authenticated to. It never probes or routes credentials to an unauthenticated, remote-derived host.
## Reporting vulnerabilities
If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.

View File

@@ -36,6 +36,12 @@ Users can also detach an existing subagent from the subagents track. Detach remo
`notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts.
## Provider-managed child agents
Some providers can create their own child sessions inside one provider runtime. OMP's task tool reports these with `child_session` events; `AgentManager` imports the live provider handle, stamps `paseo.parent-agent-id`, and surfaces the result as a normal subagent in the parent's subagents track.
The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle.
## Archive
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.

View File

@@ -149,8 +149,22 @@ Electron wrapper for macOS, Linux, and Windows.
> **In-app browser profile.** Every browser guest uses one stable persistent Electron session, so cookies, authentication, cache, and site storage are shared across tabs, workspaces, and desktop windows and survive tab or app closure. Browser identity is independent of that storage partition: after `did-attach`, the renderer explicitly registers its browser id, workspace id, and guest `WebContents` id, and main accepts the registration only when that guest belongs to the calling renderer and the shared profile. Settings > General > Clear browser data is the sole profile-deletion path; it clears the shared session and reloads live guests without deleting saved tabs or URLs.
>
> **In-app browser window opens.** Ordinary link opens, including Shift-clicked links, become Paseo workspace tabs. Script-created opens with popup features or a named window target and POST-backed opens remain secured Electron child windows in the shared browser profile, preserving `window.opener`, `postMessage`, named-window reuse, request bodies, and `window.close()` for OAuth, payment, and similar popup protocols. Unsupported URL schemes are denied before either path.
>
> **In-app browser ownership.** Each registered guest records its owning host window. The active browser is keyed by `(host window, workspace)`, and application-menu Reload / Force Reload resolve only within the window Electron supplies to the menu callback. A non-null active update must name a browser owned by that host; a null update clears only that host/workspace. Browser automation continues to target explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`.
>
> **Browser keyboard boundary.** Guest pages receive renderer-published shortcuts first. `Cmd/Ctrl+L` and `Cmd/Ctrl+R` are explicit guest-shell reservations; ordinary Paseo shortcuts run only after the page declines them. The sandboxed guest preload runs in every frame so focused iframes use the same boundary, while Node integration remains disabled. Human guest input disables Electron's menu fallback for plain keys. Agent-generated keys use guest `sendInputEvent` with `skipIfUnhandled`, so an unhandled Enter stops at the guest instead of reaching the host composer. Main selects the preload; it exposes no APIs to guest pages.
> **In-app browser targets are not yet per-window.** Browser webviews are still tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus records the workspace-active browser for UI state and `list_tabs` reporting, while agent automation targets explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`. Explicit attached-guest registration prevents concurrent windows from swapping different browser ids, but rendering the same saved browser tab in multiple windows can still make menu actions target the most recently registered guest. Making the registry window-scoped remains a follow-up.
```text
Human key -> guest WebContents
|-- Cmd/Ctrl+T/L/R ----------> reserved browser-shell action
`-- page keydown
|-- page prevents ------> page owns it
`-- published shortcut -> guest preload -> IPC(browserId) -> Paseo resolver
Agent browser_keypress -> guest sendInputEvent(skipIfUnhandled)
|-- guest handles ------------> page owns it
`-- guest does not handle ----> stop; never redispatch to the host window
```
### `packages/website` — Marketing site
@@ -183,7 +197,7 @@ Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a sess
Client session RPC waits default to 60s so slow relay or mobile networks do not turn a live but delayed daemon response into a false operation failure. Keep connect timeouts, app-level grace windows, explicit diagnostic latency probes, liveness ping timers, and genuinely long-running RPCs separate from this default.
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.forge.set_auto_merge.request` and `checkout.forge.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
**Notable session message types:**
@@ -265,14 +279,14 @@ Two workspaces can share the same `cwd` (e.g. a `directory` workspace and a `loc
**Directory-backed (shared by same-`cwd` workspaces) — keyed by `(serverId, cwd)`, never by `workspaceId`:**
| Surface | Key | Source |
| ---------------------- | -------------------------------------------------------- | ------------------------------------------------------- |
| Git status | `checkoutStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
| Git diff | `checkoutDiffQueryKey(serverId, cwd, mode, baseRef, ws)` | `packages/app/src/git/query-keys.ts` |
| GitHub PR status | `checkoutPrStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
| PR pane timeline | `prPaneTimelineQueryKey({ serverId, cwd, prNumber })` | `packages/app/src/git/pull-request-panel/query-keys.ts` |
| File preview content | `["workspaceFile", serverId, cwd, path]` | `packages/app/src/components/file-pane.tsx` |
| File explorer listings | fetched via `listDirectory(workspaceRoot, path)` | `packages/app/src/hooks/use-file-explorer-actions.ts` |
| Surface | Key | Source |
| ----------------------- | -------------------------------------------------------- | ------------------------------------------------------- |
| Git status | `checkoutStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
| Git diff | `checkoutDiffQueryKey(serverId, cwd, mode, baseRef, ws)` | `packages/app/src/git/query-keys.ts` |
| Forge change request | `checkoutPrStatusQueryKey(serverId, cwd)` | `packages/app/src/git/query-keys.ts` |
| Change request timeline | `prPaneTimelineQueryKey({ serverId, cwd, prNumber })` | `packages/app/src/git/pull-request-panel/query-keys.ts` |
| File preview content | `["workspaceFile", serverId, cwd, path]` | `packages/app/src/components/file-pane.tsx` |
| File explorer listings | fetched via `listDirectory(workspaceRoot, path)` | `packages/app/src/hooks/use-file-explorer-actions.ts` |
**Workspace-owned (independent per workspace) — keyed by `workspaceId` (falling back to `cwd` only when no `workspaceId` exists):**

View File

@@ -13,7 +13,16 @@ It validates the compositor behavior that unit tests cannot see:
- both viewport `capturePage` and full-page CDP screenshots return real pixels from
the permanent production parking state;
- guest background throttling can be disabled once at attach without per-capture
renderer coordination.
renderer coordination;
- the real-Electron host-composer sentinel proves guest Enter cannot submit a focused
host composer;
- the automation group loads the compiled production keyboard boundary and guest
preload, then proves that initial page window handlers get first refusal, unhandled
shortcuts synchronously suppress editable browser defaults before crossing the host
boundary, shortcuts marked unavailable in editable targets retain the browser field's
native behavior, handlers registered after preload still get first refusal, focused
iframes share the same boundary, digit wildcard shortcuts cross, and background automation
stays in the guest.
Run it with the repo Electron:
@@ -21,9 +30,11 @@ Run it with the repo Electron:
npm run capture-harness --workspace=@getpaseo/desktop
```
Run the browser automation fixture with:
Build the desktop main process before the automation group so its production guest
preload is available:
```bash
npm run build:main --workspace=@getpaseo/desktop
PASEO_CAPTURE_HARNESS_GROUP=automation npm run capture-harness --workspace=@getpaseo/desktop
```
@@ -43,6 +54,9 @@ ARIA-like snapshot text includes headings, static text, and controls; refs survi
`pushState` when the element still matches; same-URL rerenders stale old refs; and a
file-input ref can be resolved to a CDP backend node id for upload. It also verifies
page-context evaluation, including passing a resolved ref element as the function argument.
Keyboard containment runs last because the host-composer sentinel intentionally leaves
native focus in the host. It reuses an existing fixture button: adding a test-only control
changes the inline fixture geometry exercised by the earlier actionability checks.
On macOS the harness process must set `app.setActivationPolicy("accessory")` and
hide the Dock icon before creating any window. `showInactive()` only prevents window

View File

@@ -347,9 +347,9 @@ Override the command used to launch any provider with the `command` field. This
The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
### Pi-compatible forks with their own session directory
### OMP profiles and Pi-compatible forks
OMP already ships as a built-in provider option. It is disabled by default; enable it with:
OMP ships as a first-class built-in provider option. It is disabled by default; enable it with:
```json
{
@@ -361,6 +361,34 @@ OMP already ships as a built-in provider option. It is disabled by default; enab
}
```
Custom OMP profiles should extend `omp`. They inherit the OMP adapter's `rpc-ui` approvals, native Paseo host tools, provider-managed subagents, and import behavior:
```json
{
"agents": {
"providers": {
"omp-work": {
"extends": "omp",
"label": "Oh My Pi (Work)",
"command": ["omp"],
"env": {
"XDG_CONFIG_HOME": "~/.config/omp-work",
"XDG_STATE_HOME": "~/.local/state/omp-work"
},
"params": {
"sessionDir": "~/.local/state/omp-work/omp/agent/sessions",
"smolModel": "openai/gpt-5-mini",
"slowModel": "anthropic/claude-opus-4-1",
"planModel": "openai/o3"
}
}
}
}
}
```
`params.sessionDir` is used only for importing sessions that were started outside Paseo. If `command` or XDG env vars move OMP's state directory, set `params.sessionDir` to the resulting OMP JSONL session directory; launching and resuming still go through the configured command.
For other providers that keep Pi's `--mode rpc` API but write sessions somewhere else, extend `pi`, replace the command, and provide the JSONL session directory:
```json
@@ -380,7 +408,7 @@ For other providers that keep Pi's `--mode rpc` API but write sessions somewhere
}
```
The session directory is used only for importing sessions that were started outside Paseo. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
This session directory is also import-only. Launching and resuming still go through the configured command, so this example resumes with `my-pi-fork --mode rpc --session <session-file>`.
---

View File

@@ -40,6 +40,8 @@ The rule, condensed: text that _names_ a surface or a group is `medium`. Text th
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
`foregroundExtraMuted` is reserved for passive chrome that must sit behind muted text, such as an always-visible window control. Use the solid token instead of lowering SVG opacity; per-path opacity makes overlapping icon strokes render unevenly. Interactive hover and pressed states return to `foreground`.
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
Destructive is a color, not a click. Restart-daemon and remove-host are `<Button variant="outline">` in the row trailing slot; the destructive surface only appears inside the `confirmDialog` (`packages/app/src/screens/settings/host-page.tsx:541-547`). Workspace archive opens a confirm dialog before any red appears (`packages/app/src/components/sidebar-workspace-list.tsx`). Red appears after the user has indicated intent.

176
docs/forge-providers.md Normal file
View File

@@ -0,0 +1,176 @@
# Adding a Git Forge to Paseo
Paseo's forge layer is a registry/manifest system. A forge is a runtime concern:
shared protocol messages carry neutral/open facts, the server adapter owns
behavior, and the app owns bundled presentation/runtime interpretation.
The maintainer litmus test is the rule of thumb:
> Adding a new forge means adding files in a new directory/module that implement
> an interface, plus one entry in the centralized registry/manifest for that
> package.
## The Three Registrations
For forge `acme`, the expected end state is:
1. **Protocol manifest** - optional, only when the forge should be presented by
shared manifest data. Add one `ForgeDefinition` to
`packages/protocol/src/forge-manifest.ts`.
2. **Server adapter** - add `packages/server/src/services/acme-service.ts`
implementing `ForgeService`, any adapter-owned fact types/guards/constants
beside it, and one `defaultForgeRegistry` entry in
`packages/server/src/services/forge-registry.ts`.
3. **App modules** - a forge splits into a pure logic half and a view half so
logic consumers (URL builders, merge-capability, native checks, and the
Node-based e2e harness) never pull the client rendering stack:
- `packages/app/src/git/forges/acme.ts` - logic: `id`, optional `urlGrammar`,
optional `facts` (schema, merge-capability, native-check fallbacks). No
React/React-Native imports. Register in `CLIENT_FORGE_LOGIC_MODULES` in
`packages/app/src/git/forges/index.ts`.
- `packages/app/src/git/forges/acme.view.tsx` - view: `icon` (SVG component
under `packages/app/src/components/icons/`), optional `brandColor`, optional
`paneContributions`. Register in `CLIENT_FORGE_VIEW_MODULES` in
`packages/app/src/git/forges/view.ts`.
There should be no protocol typed-union arm, no central app icon/color/url/facts
map, and no central server union of known forge facts.
## Protocol
`forgeSpecific` on PR status is an open envelope:
```ts
z.object({ forge: z.string() }).passthrough();
```
The `forgeSpecific.forge` field is a **facts-family tag**, not the workspace
brand id. Gitea, Forgejo, and Codeberg can all emit `forgeSpecific.forge ===
"gitea"` when they share the same facts shape, while top-level `status.forge`
keeps the brand id (`"gitea"`, `"forgejo"`, `"codeberg"`).
Protocol does not validate per-forge fact fields. Consumers that understand a
facts family validate at runtime with their own schema/guard. Unknown or
schema-mismatched facts render neutrally instead of failing the whole message
parse. This is the version-skew win: an old client can receive facts from a
newer forge and still show the PR/MR in a neutral state.
Shipped GitHub compatibility stays separate:
- `status.github` remains accepted for released peers.
- The server keeps the `COMPAT(forgeSpecific)` mirror that copies GitHub facts
into `status.github` for older clients.
- Do not add a compatibility shim unless a released peer (<= 0.1.102) can
actually produce the state.
## Server
The server-wide status type only promises:
```ts
type ForgeSpecificStatusFacts = { forge: string } & Record<string, unknown>;
```
Adapter-owned files define the typed shapes and guards, for example
`github-facts.ts`, `gitlab-facts.ts`, and `gitea-facts.ts`. The adapter can keep
strong internal types for construction and command guards, but shared server
code must not grow a central list of forge fact arms.
Register the adapter in `defaultForgeRegistry` with:
- `createService`
- `matchesHost` from manifest `cloudHosts`
- `probeHost` when self-hosted/Enterprise detection is supported
Cloud hosts in the manifest are a bounded public-host list, not a self-host
allowlist. Self-hosted detection is a trust gate: Paseo only talks to a forge
host that is either a known cloud host or one the CLI is already authenticated
to. Adapter probes must not make anonymous HTTP requests to remote-derived
hosts, and adapters must not route credentials to an unauthenticated host.
## App
Each app forge splits into two modules so pure logic never imports the client
rendering stack:
`acme.ts` exports a `ClientForgeLogicModule`:
- `id`
- optional `urlGrammar`
- optional `facts` registration (schema, merge-capability, native-check fallbacks)
`acme.view.tsx` exports a `ClientForgeViewModule`:
- `id`
- `icon`
- `brandColor` (`null` for neutral; GitHub intentionally uses `null`)
- optional `paneContributions`
Two registries live under `packages/app/src/git/forges/`:
`CLIENT_FORGE_LOGIC_MODULES` (`index.ts`) drives URL grammar, merge-capability
derivation, and native fallback checks; `CLIENT_FORGE_VIEW_MODULES` (`view.ts`)
drives icon/color lookup and PR-pane contributions. Logic consumers must import
the logic registry only — importing the view registry (or a `.view.tsx` module)
from a logic path pulls react-native and breaks the Node-based e2e harness.
Per-forge brand colors live on the module, not in `styles/theme.ts`. Use the
Unistyles-safe pattern from `docs/unistyles.md`: no `useUnistyles()`. Brand icon
call sites use `withUnistyles` and a `uniProps` mapping such as:
```ts
(theme) => ({ color: theme.colorScheme === "light" ? colors.light : colors.dark });
```
Facts modules use one source of truth: a Zod schema. Helpers like
`defineForgeFacts`, `defineNativeFallbackCheck`, and `definePaneContribution`
derive guards from `schema.safeParse` and re-parse before invoking typed
derivers/renderers. That keeps typed derivers away from the open wire envelope.
## Checklist
To add `acme`:
1. Add `acme` to `FORGE_DEFINITIONS` if the shared manifest should know its
label, nouns, icon kind, sign-in CLI, or cloud hosts.
2. Add `acme-service.ts` implementing `ForgeService`.
3. Add `acme-facts.ts` beside the adapter if it reports native facts.
4. Add one `defaultForgeRegistry` entry.
5. Add `packages/app/src/git/forges/acme.ts` (logic) and
`packages/app/src/git/forges/acme.view.tsx` (view).
6. Add one `CLIENT_FORGE_LOGIC_MODULES` entry (`index.ts`) and one
`CLIENT_FORGE_VIEW_MODULES` entry (`view.ts`).
7. Add/update the icon component only if the client bundle should show a brand
mark.
8. If the forge's CI/data model does not fit an existing required
`ForgeService` field, widen the shared interface (plus the protocol schema
and its guards) instead of faking a value — e.g. Gitea Actions runs carry no
check-run id, so `GetCheckDetailsOptions.checkRunId` became optional with
`workflowRunId` as the alternative address. Expect this step to touch
`forge-service.ts`, `messages.ts`, and the call-site guards of the other
adapters. Widening a shared field is not forge-local: it also affects the
already-shipped forges/GitHub call sites and the capability-gated RPC (e.g.
`forgeCheckDetails`), so verify every consumer rather than assuming the change
only reaches the new adapter.
9. Run targeted tests: manifest/registry/resolver, the adapter test, protocol
checkout PR schema, app forge URL/presentation tests, app merge capability,
and any PR-pane native data tests touched.
Run `npm run typecheck` after each implementation slice. If protocol or client
declarations are stale, run `npm run build:client`; if server/CLI declarations
are stale, run `npm run build:server`.
## Gotchas
- GitHub is a normal registry entry plus released compatibility shims. Keep all
real shims tagged with `COMPAT(name)`.
- Gitea-family facts use `forgeSpecific.forge === "gitea"` even when the
top-level brand is Forgejo or Codeberg.
- Brand icons are bundled React components, so they cannot come from protocol
manifest data.
- Source URL grammars are app-side because blob/tree path syntax is
forge-specific. If a forge has no grammar, omit the "Open on ..." source link
rather than constructing a wrong URL.
- GitLab pipeline status constants belong to the GitLab adapter/client module,
not protocol.

View File

@@ -13,28 +13,32 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
- **Placement** — One workspace's stable foreign-key relationship to its project plus its git checkout snapshot. Internal. An explicit creation `projectId` is authoritative when active.
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
- **Forge** — Git hosting service behind Paseo's change-request features: GitHub, GitLab, Gitea, Forgejo, or a future registered adapter. Code: `ForgeService`, `forge-registry`, `forge-resolver`. Use `forge` for internal abstraction and registry IDs; use concrete forge names only when a behavior or RPC is forge-specific.
- **Change request** — Forge-neutral term for a proposed branch-to-branch code change. UI normally renders the forge noun instead: GitHub/Gitea/Forgejo "PR", GitLab "MR". Code: `forge_change_request` attachments, `checkoutSource: { kind: "change_request" }`, and PR/MR status payloads.
- **MR** — GitLab merge request. UI label for GitLab change requests only; do not use MR for GitHub/Gitea/Forgejo.
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
- **Repository / Remote** — Internal Git observations. They may affect mutable kind/branch metadata but never project identity, root, display name, or workspace membership. No UI label.
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, GitHub PR info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Repository / Remote** — Internal Git observations (`remoteUrl`, `mainRepoRoot`). They may affect mutable kind/branch metadata but never project identity, root, display name, or workspace membership. No UI label.
- **Directory-backed surface** — A right-sidebar surface whose content is determined by the workspace's `cwd`, so two workspaces on the same directory see identical content: git diff/status, forge change-request info, file preview/explorer contents. Keyed by `(serverId, cwd)`, never `workspaceId`. See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, OMP). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
- **Heartbeat** — Cron-style prompt sent back into the same agent/conversation. MCP: `create_heartbeat`. Use for reminders and babysitting where the status should return inline.
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
- **Attachment** — External or local context bound to an agent prompt: forge issue/change request, review context, uploaded file, text, or image. UI: "Attach issue or PR/MR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.
- **Composer input** — The text-entry surface inside the composer. Code: `MessageInput` (`packages/app/src/composer/input/input.tsx`).
- **Composer toolbar** — The bottom control row inside the composer input. Contains agent controls, attachment button, voice controls, and stop/send controls. Code: `leftContent`, `beforeVoiceContent`, and `rightContent` slots in `MessageInput` (`packages/app/src/composer/input/input.tsx`). Forbidden: "Status bar".
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
- **Composer track** — A contextual lane above the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`).
- **Subagent** — User-facing term for an agent session related to a parent agent session. Use **subagents** in UI copy and docs. Internal daemon/provider plumbing may say "child agent" or `child_session`, especially for provider-managed imports; do not surface "child agent" as a product term.
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:593`); (b) **git merge conflict** (no current UI string).

View File

@@ -37,6 +37,15 @@ npx vitest run packages/app/src/i18n/resources.test.ts --bail=1
The parity test catches missing keys across English and every supported locale resource.
## Forge-Variant Copy
Strings that vary by git forge follow a two-tier rule:
- **Indeclinable tokens** — brand names ("GitHub", "GitLab"), the PR/MR initialism, number prefixes (`#`/`!`) — are interpolated into a single key (`"Refresh git and {{brand}} state"`). These tokens stay latin and uninflected in every supported locale, so one string per locale suffices. The value comes from the forge manifest via `getForgePresentation`.
- **Sentences containing the full change-request noun** ("pull request" / "merge request" inflects and takes gender/case in translation) use the i18next `context` mechanism: the base key carries the pull-request wording and an `_mr` sibling carries the merge-request wording (`pullRequest` / `pullRequest_mr`). Call sites pass `t(key, { context: getForgePresentation(forge).changeRequestContext })`; an undefined or unknown context falls back to the base key.
Keys scale per vocabulary family (PR vs MR), not per forge: a new forge picks an existing family in its manifest entry and needs zero locale edits.
## Migration Order
Client UI translation is staged so each pass can migrate complete local copy clusters and keep reviews focused.

View File

@@ -16,7 +16,7 @@ Copilot custom agents are exposed through ACP session config, not the slash-comm
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (`providers/omp/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
@@ -32,9 +32,9 @@ Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loade
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
OMP is a built-in Pi-compatible provider, disabled by default. It uses the `omp` command and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled. Other Pi-compatible forks can still be custom providers that extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
OMP is a first-class built-in provider, disabled by default. Its launch contract, typed runtime, agent/session behavior, history, permissions, imports, and test fake live under `providers/omp/`; only the provider-neutral JSONL child-process transport is shared with Pi. It launches `omp --mode rpc-ui`, uses OMP's `get_available_commands` RPC for slash-command discovery, bridges OMP `rpc-ui` approval dialogs into Paseo permissions, and imports terminal-started sessions from `~/.omp/agent/sessions` when enabled.
Pi and OMP currently use different RPC names for slash-command discovery. The Pi package accepts `get_commands`; OMP accepts `get_available_commands`. Keep this as an explicit adapter setting for the built-in provider instead of probing with a fallback, because both packages return unknown-command errors without the request `id`, which otherwise turns a fast mismatch into the normal RPC timeout.
OMP supports native Paseo host tools. The adapter registers the caller-scoped Paseo tool catalog directly with OMP, so `create_agent`, `send_agent_prompt`, `wait_for_agent`, and related tools do not need the internal MCP fallback. OMP's provider-managed task subagents are surfaced as Paseo subagents through `child_session` imports; the parent keeps the subagents track while the child runtime stays owned by OMP. Custom OMP profiles should extend `omp`; other Pi-compatible forks can still extend `pi`, override `command`, and set `params.sessionDir` to their JSONL session directory.
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.

View File

@@ -3,14 +3,14 @@
New WebSocket session RPCs use dotted names with the direction as the final segment:
```ts
checkout.github.set_auto_merge.request;
checkout.github.set_auto_merge.response;
checkout.forge.set_auto_merge.request;
checkout.forge.set_auto_merge.response;
```
The namespace reads left to right:
- Domain: `checkout`
- Provider or subsystem: `github`
- Namespace segment: `forge`
- Operation: `set_auto_merge`; this segment is a verb, not a noun. If you would name an RPC `noun.request`, name it `get_noun.request` instead.
- Direction: `request` or `response`
@@ -21,8 +21,8 @@ Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or tran
For ordinary correlated RPCs, a `.request` has a matching `.response` with the same prefix. The daemon client may derive the response type mechanically:
```ts
checkout.github.set_auto_merge.request;
// -> checkout.github.set_auto_merge.response
checkout.forge.set_auto_merge.request;
// -> checkout.forge.set_auto_merge.response
```
Most new RPCs should follow this shape. If a request does not have a one-to-one response, call that out in the code near the schema.
@@ -33,7 +33,7 @@ Requests keep their parameters at the top level:
```ts
{
type: "checkout.github.set_auto_merge.request",
type: "checkout.forge.set_auto_merge.request",
cwd: "/repo",
enabled: true,
mergeMethod: "squash",
@@ -45,7 +45,7 @@ Responses put correlated result data under `payload`:
```ts
{
type: "checkout.github.set_auto_merge.response",
type: "checkout.forge.set_auto_merge.response",
payload: {
cwd: "/repo",
enabled: true,
@@ -58,14 +58,16 @@ Responses put correlated result data under `payload`:
Keep `requestId` in both request and response payloads. It is the correlation key.
## Provider Namespacing
## Forge Namespacing
Provider-specific behavior belongs under the provider segment:
Forge-neutral behavior currently uses `checkout.forge.*` for checkout-scoped operations and `forge.search.*` for forge search; forge-specific names belong here only after schema and session handlers exist:
- `checkout.github.*` for GitHub-specific checkout operations
- `checkout.gitlab.*` for future GitLab-specific checkout operations
- `checkout.forge.*` for operations whose request/response shape is genuinely
forge-neutral and whose implementation dispatches through the forge resolver.
- `checkout.github.*` for existing GitHub-specific compatibility RPCs while
callers migrate to the neutral `checkout.forge.*` shape
Do not put GitHub-specific enums or semantics into generic checkout RPC names. A generic RPC should only exist when the behavior is genuinely provider-neutral.
Do not put GitHub-specific enums or semantics into `checkout.forge.*` RPC names. A generic forge RPC should only exist when the behavior is genuinely forge-neutral.
## Compatibility

View File

@@ -1 +1 @@
sha256-gD1zheuLINSpkqLoW0Kxmjbey5+MhjyogDjc3aHStVQ=
sha256-DL1LamUyFzJOkPYR7eeIefGhzP/mcWGO5oxld/Bt8n0=

42
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.109",
"version": "0.1.110",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.109",
"version": "0.1.110",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -35162,7 +35162,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.109",
"version": "0.1.110",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -36181,12 +36181,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.109",
"version": "0.1.110",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.109",
"@getpaseo/protocol": "0.1.109",
"@getpaseo/server": "0.1.109",
"@getpaseo/client": "0.1.110",
"@getpaseo/protocol": "0.1.110",
"@getpaseo/server": "0.1.110",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36432,10 +36432,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.109",
"version": "0.1.110",
"dependencies": {
"@getpaseo/protocol": "0.1.109",
"@getpaseo/relay": "0.1.109",
"@getpaseo/protocol": "0.1.110",
"@getpaseo/relay": "0.1.110",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36446,7 +36446,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.109",
"version": "0.1.110",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36689,7 +36689,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.109",
"version": "0.1.110",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37585,7 +37585,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.109",
"version": "0.1.110",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37817,7 +37817,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.109",
"version": "0.1.110",
"dependencies": {
"zod": "^4.4.3"
},
@@ -37830,7 +37830,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.109",
"version": "0.1.110",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38048,15 +38048,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.109",
"version": "0.1.110",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.109",
"@getpaseo/highlight": "0.1.109",
"@getpaseo/protocol": "0.1.109",
"@getpaseo/relay": "0.1.109",
"@getpaseo/client": "0.1.110",
"@getpaseo/highlight": "0.1.110",
"@getpaseo/protocol": "0.1.110",
"@getpaseo/relay": "0.1.110",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38593,7 +38593,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.109",
"version": "0.1.110",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.109",
"version": "0.1.110",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [

View File

@@ -0,0 +1,72 @@
import { execFileSync } from "node:child_process";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { test, expect } from "./fixtures";
const COMMIT_SUBJECT = "Show commit timestamps";
test("commit history shows dates and shares diff layout preferences", async ({
page,
withWorkspace,
}) => {
const workspace = await withWorkspace({ prefix: "commit-diff-panel-" });
await createFeatureCommit(workspace.repoPath);
await page.setViewportSize({ width: 1400, height: 900 });
await workspace.navigateTo();
await page.getByRole("button", { name: "Open explorer" }).click();
const commitsSection = page.getByRole("button", { name: /Commits/i });
await expect(commitsSection).toBeVisible({ timeout: 30_000 });
await commitsSection.click();
const commitRow = page.locator('[data-testid^="commit-row-"]').filter({
hasText: COMMIT_SUBJECT,
});
await expect(commitRow).toContainText(COMMIT_SUBJECT, { timeout: 30_000 });
await expect(commitRow).toContainText("Jan 15");
await commitRow.click();
const panel = page.getByTestId("commit-diff-panel").filter({ visible: true });
await expect(panel.getByTestId("commit-diff-toolbar")).toBeVisible({ timeout: 30_000 });
await expect(panel.getByTestId("commit-diff-layout-unified")).toHaveAttribute(
"aria-selected",
"true",
);
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible({ timeout: 30_000 });
await panel.getByTestId("commit-diff-layout-split").click();
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
"aria-selected",
"true",
);
await expect(panel.getByTestId("diff-code-row-0")).toHaveCount(0);
await expect(panel.getByTestId("diff-file-0-body")).toBeVisible();
await page.getByTestId(/^workspace-commit-diff-close-/).click();
await expect(panel).toHaveCount(0);
await commitRow.click();
await expect(panel.getByTestId("commit-diff-layout-split")).toHaveAttribute(
"aria-selected",
"true",
{ timeout: 30_000 },
);
await page.setViewportSize({ width: 480, height: 900 });
await expect(panel.getByTestId("commit-diff-toolbar")).toHaveCount(0);
await expect(panel.getByTestId("diff-code-row-0")).toBeVisible();
});
async function createFeatureCommit(repoPath: string): Promise<void> {
execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoPath, stdio: "ignore" });
await writeFile(path.join(repoPath, "feature.txt"), "before\nafter\n");
execFileSync("git", ["add", "feature.txt"], { cwd: repoPath, stdio: "ignore" });
execFileSync("git", ["commit", "-m", COMMIT_SUBJECT], {
cwd: repoPath,
stdio: "ignore",
env: {
...process.env,
GIT_AUTHOR_DATE: "2020-01-15T12:00:00Z",
GIT_COMMITTER_DATE: "2020-01-15T12:00:00Z",
},
});
}

View File

@@ -59,7 +59,7 @@ async function closeTopSheet(page: Page) {
async function closeSheetByHeaderButton(page: Page, testId: string) {
const sheet = page.getByTestId(testId);
await sheet.getByLabel("Close", { exact: true }).click({ force: true });
await sheet.getByLabel("Close", { exact: true }).click();
await expect(sheet).not.toBeVisible({ timeout: 10_000 });
}

View File

@@ -0,0 +1,310 @@
import { test, expect, type Page } from "./fixtures";
import { TerminalE2EHarness } from "./helpers/terminal-dsl";
import { buildHostWorkspaceRoute } from "../src/utils/host-routes";
import { getServerId } from "./helpers/server-id";
/**
* Regression: a terminal created while the window is unfocused must still claim its PTY size
* once focus returns.
*
* The PTY is only ever resized by an explicit client claim (`terminal_input` resize). A freshly
* mounted terminal produces exactly one claim, from terminal-pane's pane-focus reflow effect. If
* that claim is emitted while the app is not actively visible (window blurred / document hidden),
* `handleTerminalResize` drops it — and nothing used to re-send it, leaving the PTY at 80x24
* while xterm renders the real pane size: vim/top squeezed into a corner until the user resizes
* the window.
*
* The repro is the mundane user flow: with a workspace already showing a terminal, open another
* one and switch to a different app while it spawns. We stage the blur deterministically by
* stubbing `document.hasFocus()` (which `useAppVisible` consults on window focus/blur events)
* instead of racing real OS focus. The first terminal matters: `useAppVisible` only observes
* focus events while a consumer is mounted, so it is what makes the blur visible to the app —
* exactly as in the real flow.
*
* The assertion reads the PTY's own opinion of its size (`stty size`) with input written
* daemon-side — clicking or typing in the pane would fire the focus-claim path and mask the bug.
*/
/**
* Simulates the window losing/regaining OS focus, which is what `useAppVisible` reads through
* `document.hasFocus()` plus the window focus/blur events.
*
* This has to be stubbed: headless Chromium never actually blurs. Opening a second page and
* calling bringToFront() leaves the first page at `hasFocus() === true`, `visibilityState
* === "visible"`, and fires no focus/blur events at all — so there is no way to produce a real
* blur from Playwright. This stubs the environment signal, not the app: the daemon, the
* WebSocket, the terminal, and every code path under test stay real.
*/
async function setWindowFocused(page: Page, focused: boolean): Promise<void> {
await page.evaluate((isFocused) => {
Object.defineProperty(document, "hasFocus", {
configurable: true,
value: () => isFocused,
});
window.dispatchEvent(new Event(isFocused ? "focus" : "blur"));
}, focused);
}
interface RenderedTerminalSize {
rows: number;
cols: number;
}
async function readRenderedTerminalSize(page: Page): Promise<RenderedTerminalSize | null> {
return await page.evaluate(() => {
const terminal = (window as Window & { __paseoTerminal?: { rows: number; cols: number } })
.__paseoTerminal;
return terminal ? { rows: terminal.rows, cols: terminal.cols } : null;
});
}
async function readTerminalBufferText(page: Page): Promise<string> {
return await page.evaluate(() => {
const terminal = (
window as Window & {
__paseoTerminal?: {
buffer: {
active: {
length: number;
getLine(index: number): { translateToString(trim: boolean): string } | undefined;
};
};
};
}
).__paseoTerminal;
if (!terminal) {
return "";
}
const lines: string[] = [];
for (let index = 0; index < terminal.buffer.active.length; index += 1) {
lines.push(terminal.buffer.active.getLine(index)?.translateToString(true) ?? "");
}
return lines.join("\n");
});
}
async function createTerminalViaMenu(page: Page): Promise<void> {
await page.getByTestId("workspace-new-tab-menu-trigger").click();
await page.getByTestId("workspace-new-tab-menu-terminal").click();
}
async function listTerminalIds(harness: TerminalE2EHarness): Promise<string[]> {
const listed = await harness.client.listTerminals(harness.tempRepo.path, undefined, {
workspaceId: harness.workspaceId,
});
return listed.terminals.map((terminal) => terminal.id);
}
// These narrow instead of asserting: an `expect(...).toBeTruthy()` plus an early return would let
// the test pass without ever reaching the assertions that prove the fix.
function exactlyOne<T>(values: T[], what: string): T {
const [value] = values;
if (values.length !== 1 || value === undefined) {
throw new Error(`Expected exactly one ${what}, got ${values.length}`);
}
return value;
}
function requireTerminalSize(size: RenderedTerminalSize | null): RenderedTerminalSize {
if (!size) {
throw new Error("No xterm instance rendered on the page");
}
return size;
}
function claimsFor(frames: ResizeClaim[], terminalId: string): ResizeClaim[] {
return frames.filter((frame) => frame.terminalId === terminalId);
}
function parseSttySize(bufferText: string): RenderedTerminalSize {
const match = /S=(\d+) (\d+)=/.exec(bufferText);
if (!match?.[1] || !match[2]) {
throw new Error(`stty size did not print in the terminal buffer:\n${bufferText}`);
}
return { rows: Number(match[1]), cols: Number(match[2]) };
}
interface ResizeClaim {
terminalId: string;
rows: number;
cols: number;
}
/**
* Records every resize claim the app puts on the wire. Claims travel two ways: as a JSON
* `terminal_input` before the stream has a binary slot, and as a binary frame
* ([opcode 0x03][slot][JSON {rows, cols}]) once subscribed. The slot -> terminal mapping comes
* from subscribe_terminal_response on the receive side.
*/
function captureResizeClaims(page: Page): ResizeClaim[] {
const resizeFrames: ResizeClaim[] = [];
const slotTerminals = new Map<number, string>();
const unmappedBinaryResizes: Array<{ slot: number; rows: number; cols: number }> = [];
const recordBinaryResize = (slot: number, size: { rows: number; cols: number }) => {
const terminalId = slotTerminals.get(slot);
if (terminalId) {
resizeFrames.push({ terminalId, ...size });
} else {
unmappedBinaryResizes.push({ slot, ...size });
}
};
const handleSentFrame = (frame: { payload: string | Buffer }) => {
const payload = frame.payload;
if (typeof payload !== "string") {
if (payload.length >= 2 && payload[0] === 0x03) {
try {
const parsed = JSON.parse(payload.subarray(2).toString("utf8")) as {
rows?: number;
cols?: number;
};
if (parsed.rows !== undefined && parsed.cols !== undefined) {
recordBinaryResize(payload[1] ?? -1, { rows: parsed.rows, cols: parsed.cols });
}
} catch {
// not a terminal resize frame — ignore
}
}
return;
}
if (!payload.includes("resize")) {
return;
}
try {
const outer = JSON.parse(payload) as { message?: Record<string, unknown> };
const message = outer.message ?? {};
if (message.type !== "terminal_input") {
return;
}
const inner = message.message as { type?: string; rows?: number; cols?: number };
if (inner?.type === "resize" && inner.rows !== undefined && inner.cols !== undefined) {
resizeFrames.push({
terminalId: String(message.terminalId ?? ""),
rows: inner.rows,
cols: inner.cols,
});
}
} catch {
// not JSON — ignore
}
};
const handleReceivedFrame = (frame: { payload: string | Buffer }) => {
const payload = frame.payload;
if (typeof payload !== "string" || !payload.includes("subscribe_terminal_response")) {
return;
}
try {
const outer = JSON.parse(payload) as { message?: Record<string, unknown> };
const message = outer.message ?? {};
if (message.type !== "subscribe_terminal_response") {
return;
}
const responsePayload = (message.payload ?? {}) as { terminalId?: string; slot?: number };
if (
typeof responsePayload.terminalId === "string" &&
typeof responsePayload.slot === "number"
) {
slotTerminals.set(responsePayload.slot, responsePayload.terminalId);
for (const entry of unmappedBinaryResizes.splice(0)) {
recordBinaryResize(entry.slot, { rows: entry.rows, cols: entry.cols });
}
}
} catch {
// not JSON — ignore
}
};
page.on("websocket", (ws) => {
ws.on("framesent", handleSentFrame);
ws.on("framereceived", handleReceivedFrame);
});
return resizeFrames;
}
test.describe("terminal PTY size claim under lost window focus", () => {
let harness: TerminalE2EHarness;
test.beforeEach(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-stuck-size" });
});
test.afterEach(async () => {
await harness.cleanup();
});
test("a terminal created while the window is blurred claims its size when focus returns", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 900 });
const resizeFrames = captureResizeClaims(page);
await page.goto(buildHostWorkspaceRoute(getServerId(), harness.workspaceId));
await expect(page.getByTestId("workspace-new-tab-menu-trigger")).toBeVisible({
timeout: 30_000,
});
// A terminal is already open — this also keeps useAppVisible's listeners mounted so the
// upcoming blur is actually observed by the app. Wait for the daemon to know about it rather
// than sleeping: if it were missing from this snapshot it would be misread as "new" below.
await createTerminalViaMenu(page);
await expect(harness.terminalSurface(page).first()).toBeVisible({ timeout: 30_000 });
const countTerminals = async () => (await listTerminalIds(harness)).length;
await expect.poll(countTerminals, { timeout: 15_000 }).toBe(1);
const knownIds = new Set(await listTerminalIds(harness));
// The user clicks away...
await setWindowFocused(page, false);
// ...opens another terminal while the window is unfocused...
await createTerminalViaMenu(page);
const findNewTerminalIds = async () =>
(await listTerminalIds(harness)).filter((id) => !knownIds.has(id));
await expect.poll(async () => (await findNewTerminalIds()).length, { timeout: 15_000 }).toBe(1);
const newTerminalId = exactlyOne(await findNewTerminalIds(), "new terminal");
// Let every mount-time refit run (the ladder goes out to 2s) while the window stays blurred.
// This one has to be a wait, not a poll: the assertion is that nothing happens.
await page.waitForTimeout(2_500);
// Nothing may reach the daemon while the app is hidden — handleTerminalResize drops claims
// that fire while !isAppVisible. Without this, deleting that gate would still leave the test
// green: the claim would simply arrive early instead of after focus returns.
expect(claimsFor(resizeFrames, newTerminalId)).toEqual([]);
// ...and comes back.
await setWindowFocused(page, true);
// __paseoTerminal points at the most recently mounted xterm — the new terminal.
const rendered = requireTerminalSize(await readRenderedTerminalSize(page));
// Sanity: the pane really rendered at a desktop size, not the PTY default.
expect(rendered.cols).toBeGreaterThan(80);
// The claim must reach the wire once focus is back. Poll for it rather than sleeping: this
// is the behavior under test, so waiting a fixed duration would trade a real assertion for
// a timing bet.
const claimsForNewTerminal = () => claimsFor(resizeFrames, newTerminalId);
await expect
.poll(claimsForNewTerminal, { timeout: 15_000 })
.toContainEqual({ terminalId: newTerminalId, rows: rendered.rows, cols: rendered.cols });
// And the PTY itself must agree. Ask it via the daemon, never via the page: focusing or
// typing in the pane triggers the focus-claim path and would mask the bug.
await harness.client.subscribeTerminal(newTerminalId);
harness.client.sendTerminalInput(newTerminalId, {
type: "input",
data: 'echo "S=$(stty size)="\n',
});
await expect
.poll(async () => readTerminalBufferText(page), { timeout: 15_000 })
.toMatch(/S=\d+ \d+=/);
const ptySize = parseSttySize(await readTerminalBufferText(page));
// The PTY must match what xterm rendered — not the daemon's 80x24 spawn default.
expect(ptySize).toEqual({ rows: rendered.rows, cols: rendered.cols });
});
});

View File

@@ -0,0 +1,51 @@
import { expect, test } from "./fixtures";
const modifier = process.platform === "darwin" ? "Meta" : "Control";
async function pressFocusModeShortcut(page: import("@playwright/test").Page) {
await page.keyboard.press(`${modifier}+Shift+F`);
}
async function pressSettingsShortcut(page: import("@playwright/test").Page) {
await page.keyboard.press(`${modifier}+Comma`);
}
async function blurActiveElement(page: import("@playwright/test").Page) {
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
}
test("focus mode only applies to the active workspace screen", async ({ page, withWorkspace }) => {
const workspace = await withWorkspace({ prefix: "focus-mode-boundary-" });
await workspace.navigateTo();
const exitFocusMode = page.getByRole("button", { name: "Exit focus mode" });
const settingsButton = page.getByRole("button", { name: "Settings", exact: true });
const settingsSidebar = page.getByRole("navigation", { name: "Settings" });
await expect(settingsButton).toBeVisible();
await expect(exitFocusMode).toHaveCount(0);
await blurActiveElement(page);
await pressFocusModeShortcut(page);
await expect(exitFocusMode).toBeVisible();
await expect(settingsButton).toHaveCount(0);
const workspaceUrl = page.url();
await pressSettingsShortcut(page);
await expect(settingsSidebar).toBeVisible();
await expect(exitFocusMode).toHaveCount(0);
await page.reload();
await expect(settingsSidebar).toBeVisible();
await expect(exitFocusMode).toHaveCount(0);
await pressFocusModeShortcut(page);
await page.goto(workspaceUrl);
await expect(exitFocusMode).toBeVisible();
await exitFocusMode.click();
await expect(exitFocusMode).toHaveCount(0);
await expect(settingsButton).toBeVisible();
});

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.109",
"version": "0.1.110",
"private": true,
"main": "index.ts",
"scripts": {

View File

@@ -40,6 +40,7 @@ import { FloatingPanelPortalHost } from "@/components/ui/floating-panel-portal";
import { HostChooserModal, useHostChooser } from "@/hosts/host-chooser";
import {
getIsElectronRuntime,
getIsElectronRuntimeMac,
HEADER_INNER_HEIGHT,
useIsCompactFormFactor,
} from "@/constants/layout";
@@ -108,7 +109,11 @@ import {
WindowChromeRegion,
WindowChromeSafeArea,
} from "@/utils/desktop-window";
import { buildOpenProjectRoute, parseServerIdFromPathname } from "@/utils/host-routes";
import {
buildOpenProjectRoute,
parseHostWorkspaceRouteFromPathname,
parseServerIdFromPathname,
} from "@/utils/host-routes";
import { buildNotificationRoute, resolveNotificationTarget } from "@/utils/notification-routing";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import {
@@ -425,7 +430,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const openDesktopAgentList = usePanelStore((state) => state.openDesktopAgentList);
const closeDesktopAgentList = usePanelStore((state) => state.closeDesktopAgentList);
const closeDesktopFileExplorer = usePanelStore((state) => state.closeDesktopFileExplorer);
const toggleFocusMode = usePanelStore((state) => state.toggleFocusMode);
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const isDesktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const isDesktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
@@ -442,6 +446,8 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const isCompactLayout = useIsCompactFormFactor();
useCompactWebViewportZoomLock(isCompactLayout);
const pathname = usePathname();
const isWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname) !== null;
const isWorkspaceFocusModeEnabled = isWorkspaceRoute && isFocusModeEnabled;
const chromeEnabled = chromeEnabledOverride ?? daemons.length > 0;
const toggleAgentList = isCompactLayout ? toggleMobileAgentList : toggleDesktopAgentList;
const toggleDesktopSidebars = useCallback(() => {
@@ -470,7 +476,6 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
isMobile: isCompactLayout,
toggleAgentList,
toggleBothSidebars: toggleDesktopSidebars,
toggleFocusMode,
cycleTheme,
});
@@ -479,11 +484,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const appContentMinimumWidth = resolveDesktopAppContentMinimum({
isSettingsRoute: pathname.includes("/settings"),
isWorkspaceExplorerOpen: pathname.includes("/workspace/") && isDesktopFileExplorerOpen,
isWorkspaceExplorerOpen: isWorkspaceRoute && isDesktopFileExplorerOpen,
requestedExplorerWidth: explorerWidth,
viewportWidth,
});
const desktopSidebarMounted = chromeEnabled && !isFocusModeEnabled;
const desktopSidebarMounted = chromeEnabled && !isWorkspaceFocusModeEnabled;
const desktopSidebarVisible =
!isCompactLayout &&
desktopSidebarMounted &&
@@ -497,7 +502,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
const appChromeLayout = resolveDesktopAppChromeLayout({
desktopSidebarRendered: desktopSidebarVisible,
hasTopLeftWindowControls,
sidebarControlsEnabled: chromeEnabled && !isFocusModeEnabled,
sidebarControlsEnabled: chromeEnabled && !isWorkspaceFocusModeEnabled,
});
const sidebarChrome = (
<SidebarChrome
@@ -659,16 +664,23 @@ function DesktopWindowControlsSync({ enabled }: { enabled: boolean }) {
const { theme } = useUnistyles();
const surface0 = theme.colors.surface0;
const foreground = theme.colors.foreground;
const pathname = usePathname();
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const liftTrafficLights =
getIsElectronRuntimeMac() &&
isFocusModeEnabled &&
parseHostWorkspaceRouteFromPathname(pathname) !== null;
useEffect(() => {
if (!enabled || isNative) return;
void updateDesktopWindowControls({
backgroundColor: surface0,
foregroundColor: foreground,
trafficLightOffsetY: liftTrafficLights ? -5 : 0.5,
}).catch((error) => {
console.warn("[DesktopWindow] Failed to update window controls overlay", error);
});
}, [enabled, surface0, foreground]);
}, [enabled, surface0, foreground, liftTrafficLights]);
return null;
}
@@ -987,7 +999,7 @@ const layoutStyles = StyleSheet.create((theme) => ({
},
windowSidebarToggle: {
position: "absolute",
top: 0,
top: 1,
left: 0,
zIndex: 20,
height: HEADER_INNER_HEIGHT,

View File

@@ -12,6 +12,7 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { WorkspaceComposerAttachment } from "@/attachments/types";
import { getFileTypeLabel } from "@/attachments/file-types";
import { isPullRequestContextAttachment } from "@/attachments/workspace-attachment-utils";
import { getForgePresentation } from "@/git/forge";
import { ICON_SIZE, type Theme } from "@/styles/theme";
export interface AttachmentPillContent {
@@ -27,10 +28,16 @@ function getReviewSubtitle(count: number, t: TFunction): string {
}
function getPullRequestContextSubtitle(attachment: WorkspaceComposerAttachment): string {
if (attachment.kind === "github.pull_request_check") {
if (
attachment.kind === "forge.change_request_check" ||
attachment.kind === "github.pull_request_check"
) {
return "Check logs";
}
if (attachment.kind === "github.pull_request_comment") {
if (
attachment.kind === "forge.change_request_comment" ||
attachment.kind === "github.pull_request_comment"
) {
return "Comment";
}
return "Review";
@@ -57,12 +64,21 @@ export function getAgentAttachmentPillContent(
title: t("message.attachments.review"),
subtitle: getReviewSubtitle(attachment.comments.length, t),
};
case "forge_change_request": {
const presentation = getForgePresentation(attachment.forge ?? "github");
return {
icon: attachmentGithubPrIcon,
title: attachment.title,
subtitle: `${presentation.changeRequestAbbrev} ${presentation.numberPrefix}${attachment.number}`,
};
}
case "github_pr":
return {
icon: attachmentGithubPrIcon,
title: attachment.title,
subtitle: `PR #${attachment.number}`,
};
case "forge_issue":
case "github_issue":
return {
icon: attachmentGithubIssueIcon,

View File

@@ -1,6 +1,6 @@
import type {
AgentAttachment,
GitHubSearchItem,
ForgeSearchItem,
UploadedFileAttachment,
} from "@getpaseo/protocol/messages";
@@ -54,6 +54,9 @@ export interface BrowserElementAttachment {
}
export type PullRequestContextAttachmentKind =
| "forge.change_request_comment"
| "forge.change_request_review"
| "forge.change_request_check"
| "github.pull_request_comment"
| "github.pull_request_review"
| "github.pull_request_check";
@@ -67,6 +70,9 @@ interface PullRequestContextAttachmentFields {
}
export type PullRequestContextAttachment =
| ({ kind: "forge.change_request_comment" } & PullRequestContextAttachmentFields)
| ({ kind: "forge.change_request_review" } & PullRequestContextAttachmentFields)
| ({ kind: "forge.change_request_check" } & PullRequestContextAttachmentFields)
| ({ kind: "github.pull_request_comment" } & PullRequestContextAttachmentFields)
| ({ kind: "github.pull_request_review" } & PullRequestContextAttachmentFields)
| ({ kind: "github.pull_request_check" } & PullRequestContextAttachmentFields);
@@ -89,10 +95,13 @@ export const NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER = "new-workspace-picker";
export type UserComposerAttachment =
| { kind: "image"; metadata: AttachmentMetadata }
| { kind: "file"; attachment: UploadedFileAttachment }
| { kind: "github_issue"; item: GitHubSearchItem }
| { kind: "forge_issue"; item: ForgeSearchItem }
| { kind: "forge_change_request"; item: ForgeSearchItem }
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
| { kind: "github_issue"; item: ForgeSearchItem }
| {
kind: "github_pr";
item: GitHubSearchItem;
item: ForgeSearchItem;
owner?: typeof NEW_WORKSPACE_PICKER_ATTACHMENT_OWNER;
};

View File

@@ -10,6 +10,9 @@ export function isPullRequestContextAttachment(
attachment: ComposerAttachment | undefined,
): attachment is PullRequestContextAttachment {
return (
attachment?.kind === "forge.change_request_comment" ||
attachment?.kind === "forge.change_request_review" ||
attachment?.kind === "forge.change_request_check" ||
attachment?.kind === "github.pull_request_comment" ||
attachment?.kind === "github.pull_request_review" ||
attachment?.kind === "github.pull_request_check"

View File

@@ -81,12 +81,15 @@ function areWorkspaceAttachmentsEqual(
}
function getContextAttachmentKey(attachment: WorkspaceComposerAttachment): string | null {
const isContextAttachment =
attachment.kind === "chat_history" ||
attachment.kind === "github.pull_request_comment" ||
attachment.kind === "github.pull_request_review" ||
attachment.kind === "github.pull_request_check";
if (!isContextAttachment) {
if (
attachment.kind !== "chat_history" &&
attachment.kind !== "forge.change_request_comment" &&
attachment.kind !== "forge.change_request_review" &&
attachment.kind !== "forge.change_request_check" &&
attachment.kind !== "github.pull_request_comment" &&
attachment.kind !== "github.pull_request_review" &&
attachment.kind !== "github.pull_request_check"
) {
return null;
}
return JSON.stringify({

View File

@@ -3,11 +3,16 @@ import {
type BrowserWebviewProfileHost,
clearResidentBrowserWebviewsForTests,
ensureResidentBrowserWebview,
getResidentBrowserWebview,
prepareBrowserWebview,
releaseResidentBrowserWebview,
removeResidentBrowserWebview,
takeResidentBrowserWebview,
} from "./browser-webview-resident";
import {
setCommandCenterFocusRestoreElement,
takeCommandCenterFocusRestoreElement,
} from "../utils/command-center-focus-restore";
const RESIDENT_HOST_ID = "paseo-browser-resident-webviews";
const attachedBrowsers: Array<{
@@ -254,6 +259,20 @@ describe("resident browser webviews", () => {
expect(document.getElementById(RESIDENT_HOST_ID)).toBeNull();
});
it("finds the originating browser webview for focus restoration", () => {
const webview = ensureTestBrowser({
browserId: "browser-focus",
workspaceId: "workspace-focus",
url: "https://example.com",
});
setCommandCenterFocusRestoreElement(getResidentBrowserWebview("browser-focus"));
expect(takeCommandCenterFocusRestoreElement()).toBe(webview);
expect(getResidentBrowserWebview("browser-missing")).toBeNull();
});
it("removes a resident webview when its browser tab closes", () => {
const webview = ensureTestBrowser({
browserId: "browser-closed",

View File

@@ -225,6 +225,19 @@ export function ensureResidentBrowserWebview(input: {
return webview;
}
export function getResidentBrowserWebview(browserId: string): HTMLElement | null {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
return null;
}
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
if (resident?.isConnected) {
return resident;
}
const ownerDocument = readDocument();
return ownerDocument ? findBrowserWebview(normalizedBrowserId, ownerDocument) : null;
}
export function takeResidentBrowserWebview(browserId: string): HTMLElement | null {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {

View File

@@ -347,6 +347,7 @@ function ExplorerSidebarContent({
testID="explorer-tab-pr"
>
<PullRequestTabIcon
forge={prPane.forge}
size={13}
color={
resolvedTab === "pr" ? theme.colors.foreground : theme.colors.foregroundMuted

View File

@@ -45,12 +45,14 @@ function MobileMenuIcon({ color }: { color: string }) {
function SidebarMenuToggleButton({
isMobile,
extraMutedIdleIcon = false,
resolvedStyle,
tooltipSide = "right",
testID = "menu-button",
nativeID = "menu-button",
}: Omit<SidebarMenuToggleProps, "style"> & {
isMobile: boolean;
extraMutedIdleIcon?: boolean;
resolvedStyle: StyleProp<ViewStyle>;
}) {
const { theme } = useUnistyles();
@@ -83,7 +85,12 @@ function SidebarMenuToggleButton({
accessibilityState={accessibilityState}
>
{({ hovered, pressed }) => {
const color = hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted;
let color = extraMutedIdleIcon
? theme.colors.foregroundExtraMuted
: theme.colors.foregroundMuted;
if (hovered || pressed) {
color = theme.colors.foreground;
}
return isMobile ? (
<MobileMenuIcon color={color} />
) : (
@@ -121,7 +128,14 @@ export function SidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps =
export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
return <SidebarMenuToggleButton {...props} isMobile={false} resolvedStyle={resolvedStyle} />;
return (
<SidebarMenuToggleButton
{...props}
isMobile={false}
extraMutedIdleIcon
resolvedStyle={resolvedStyle}
/>
);
}
export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {

View File

@@ -0,0 +1,8 @@
import { SvgPathIcon, type SvgPathIconProps } from "./svg-path-icon";
const CODEBERG_ICON_PATH =
"M11.999.747A11.974 11.974 0 0 0 0 12.75c0 2.254.635 4.465 1.833 6.376L11.837 6.19c.072-.092.251-.092.323 0l4.178 5.402h-2.992l.065.239h3.113l.882 1.138h-3.674l.103.374h3.86l.777 1.003h-4.358l.135.483h4.593l.695.894h-5.038l.165.589h5.326l.609.785h-5.717l.182.65h6.038l.562.727h-6.397l.183.65h6.717A12.003 12.003 0 0 0 24 12.75 11.977 11.977 0 0 0 11.999.747zm3.654 19.104.182.65h5.326c.173-.204.353-.433.513-.65zm.385 1.377.18.65h3.563c.233-.198.485-.428.712-.65zm.383 1.377.182.648h1.203c.356-.204.685-.412 1.042-.648z";
export function CodebergIcon(props: SvgPathIconProps) {
return <SvgPathIcon {...props} viewBox="0 0 24 24" path={CODEBERG_ICON_PATH} />;
}

View File

@@ -0,0 +1,8 @@
import { SvgPathIcon, type SvgPathIconProps } from "./svg-path-icon";
const FORGEJO_ICON_PATH =
"M16.7773 0c1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.1175a7.0759 7.0759 0 0 1 4.148-1.4205l.1176-.001 1.3385.0002c.4973-.8827 1.4434-1.4788 2.5288-1.4788 1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.319c.8827.4973 1.4788 1.4434 1.4788 2.5287 0 1.602-1.2986 2.9005-2.9005 2.9005-1.6018 0-2.9004-1.2986-2.9004-2.9005 0-1.0853.596-2.0314 1.4788-2.5287l-.0002-9.9831c0-3.887 3.1195-7.0453 6.9915-7.108l.1176-.001h1.3385C14.7458.5962 15.692 0 16.7773 0ZM7.2227 19.9052c-.6596 0-1.1943.5347-1.1943 1.1943s.5347 1.1943 1.1943 1.1943 1.1944-.5347 1.1944-1.1943-.5348-1.1943-1.1944-1.1943Zm9.5546-10.4644c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Zm0-7.7346c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Z";
export function ForgejoIcon(props: SvgPathIconProps) {
return <SvgPathIcon {...props} viewBox="0 0 24 24" path={FORGEJO_ICON_PATH} />;
}

View File

@@ -0,0 +1,8 @@
import { SvgPathIcon, type SvgPathIconProps } from "./svg-path-icon";
const GITEA_ICON_PATH =
"M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z";
export function GiteaIcon(props: SvgPathIconProps) {
return <SvgPathIcon {...props} viewBox="0 0 24 24" path={GITEA_ICON_PATH} />;
}

View File

@@ -0,0 +1,8 @@
import { SvgPathIcon, type SvgPathIconProps } from "./svg-path-icon";
const GITLAB_ICON_PATH =
"M23.6004 9.5927l-.0337-.0862L20.3 .9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.462-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z";
export function GitLabIcon(props: SvgPathIconProps) {
return <SvgPathIcon {...props} viewBox="0 0 24 24" path={GITLAB_ICON_PATH} />;
}

View File

@@ -1,4 +1,4 @@
import Svg, { Path } from "react-native-svg";
import Svg, { Circle, Rect } from "react-native-svg";
interface OmpIconProps {
size?: number;
@@ -6,9 +6,17 @@ interface OmpIconProps {
}
export function OmpIcon({ size = 16, color = "currentColor" }: OmpIconProps) {
// Ported from the Oh My Pi MIT-licensed assets/icon.svg mark.
return (
<Svg width={size} height={size} viewBox="0 0 64 64" fill={color}>
<Path d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" fill={color} />
<Svg width={size} height={size} viewBox="0 0 120 90" fill="none">
<Rect x={10} y={8} width={100} height={12} rx={2} fill={color} />
<Rect x={25} y={20} width={12} height={62} rx={2} fill={color} />
<Rect x={75} y={20} width={12} height={45} rx={2} fill={color} />
<Rect x={71} y={55} width={20} height={16} rx={3} fill="#f97316" />
<Rect x={76} y={59} width={3} height={8} rx={1} fill="#0d0d0d" />
<Rect x={82} y={59} width={3} height={8} rx={1} fill="#0d0d0d" />
<Circle cx={18} cy={14} r={2} fill="#f97316" opacity={0.8} />
<Circle cx={102} cy={14} r={2} fill="#f97316" opacity={0.8} />
</Svg>
);
}

View File

@@ -0,0 +1,24 @@
import Svg, { Path } from "react-native-svg";
export interface SvgPathIconProps {
size?: number;
color?: string;
}
interface SvgPathIconInput extends SvgPathIconProps {
path: string;
viewBox: string;
}
export function SvgPathIcon({
size = 16,
color = "currentColor",
path,
viewBox,
}: SvgPathIconInput) {
return (
<Svg width={size} height={size} viewBox={viewBox} fill={color}>
<Path d={path} />
</Svg>
);
}

View File

@@ -84,6 +84,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { SyncedLoader } from "@/components/synced-loader";
import { useToast } from "@/contexts/toast-context";
import { getForgePresentation, normalizeForge } from "@/git/forge";
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
@@ -319,12 +320,14 @@ export function PrBadge({ hint }: { hint: PrHint }) {
const textStyle = isHovered ? prBadgeTextHoveredCombined : prBadgeStyles.text;
const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state);
const presentation = getForgePresentation(normalizeForge(hint.forge));
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={t("workspace.git.pr.accessibility.pullRequest", {
number: hint.number,
context: presentation.changeRequestContext,
})}
hitSlop={4}
onPressIn={handlePressIn}
@@ -339,6 +342,7 @@ export function PrBadge({ hint }: { hint: PrHint }) {
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
)}
<Text style={textStyle} numberOfLines={1}>
{presentation.numberPrefix}
{hint.number}
</Text>
</Pressable>

View File

@@ -1,4 +1,5 @@
import { memo, useCallback, useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
ActivityIndicator,
Pressable,
@@ -18,13 +19,14 @@ import {
Monitor,
SquareTerminal,
} from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
import { SyncedLoader } from "@/components/synced-loader";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useAppSettings } from "@/hooks/use-settings";
import type { Theme } from "@/styles/theme";
import type { PrHint } from "@/git/use-pr-status-query";
import { getForgePresentation, normalizeForge } from "@/git/forge";
import { ForgeBrandIcon } from "@/git/forge-icon";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
@@ -52,7 +54,6 @@ const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purp
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedSyncedLoader = withUnistyles(SyncedLoader);
@@ -62,6 +63,10 @@ const ThemedFolderGit2 = withUnistyles(FolderGit2);
const ThemedGlobe = withUnistyles(Globe);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
function renderChecksBadgeForgeIcon(icon: string) {
return <ForgeBrandIcon iconKind={icon} size={10} uniProps={redColorMapping} />;
}
type SidebarWorkspaceScriptIconKind = "service" | "command";
export function SidebarWorkspaceRowFrame({
@@ -156,7 +161,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
{workspace.prHint ? (
<View style={styles.workspacePrBadgeRow}>
<PrBadge hint={workspace.prHint} />
<ChecksBadge checks={workspace.prHint.checks} />
<ChecksBadge checks={workspace.prHint.checks} forge={workspace.prHint.forge} />
</View>
) : null}
</View>
@@ -290,6 +295,7 @@ function StatusDotOverlay({
}
function PrBadge({ hint }: { hint: PrHint }) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const handlePress = useCallback(
(event: GestureResponderEvent) => {
@@ -303,6 +309,7 @@ function PrBadge({ hint }: { hint: PrHint }) {
[isHovered],
);
const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state);
const presentation = getForgePresentation(normalizeForge(hint.forge));
const handlePressIn = useCallback((event: GestureResponderEvent) => event.stopPropagation(), []);
const handleHoverIn = useCallback(() => setIsHovered(true), []);
@@ -316,7 +323,10 @@ function PrBadge({ hint }: { hint: PrHint }) {
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={`Pull request #${hint.number}`}
accessibilityLabel={t("workspace.git.pr.accessibility.pullRequest", {
number: hint.number,
context: presentation.changeRequestContext,
})}
hitSlop={4}
onPressIn={handlePressIn}
onPress={handlePress}
@@ -330,19 +340,21 @@ function PrBadge({ hint }: { hint: PrHint }) {
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
)}
<Text style={textStyle} numberOfLines={1}>
{presentation.numberPrefix}
{hint.number}
</Text>
</Pressable>
);
}
function ChecksBadge({ checks }: { checks: PrHint["checks"] }) {
function ChecksBadge({ checks, forge }: { checks: PrHint["checks"]; forge: PrHint["forge"] }) {
if (!checks || checks.length === 0) return null;
const failed = checks.filter((check) => check.status === "failure").length;
if (failed === 0) return null;
const icon = getForgePresentation(normalizeForge(forge)).icon;
return (
<View style={checksBadgeStyles.badge}>
<ThemedGitHubIcon size={10} uniProps={redColorMapping} />
{renderChecksBadgeForgeIcon(icon)}
<Text style={checksBadgeStyles.text}>{failed} failed</Text>
</View>
);

View File

@@ -123,6 +123,7 @@ interface SplitContainerProps {
onReorderTabsInPane: (paneId: string, tabIds: string[]) => void;
renderPaneEmptyState?: () => ReactNode;
focusModeEnabled?: boolean;
onExitFocusMode: () => void;
}
interface WorkspaceTabDragData {
@@ -390,6 +391,7 @@ export function SplitContainer({
onReorderTabsInPane,
renderPaneEmptyState = () => null,
focusModeEnabled,
onExitFocusMode,
}: SplitContainerProps) {
const inheritedWindowChromeCorners = useWindowChromeCorners();
const windowChromeCorners = focusModeEnabled ? inheritedWindowChromeCorners : "none";
@@ -611,6 +613,8 @@ export function SplitContainer({
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
windowChromeCorners={splitRoot.usesFallbackStrip ? "none" : windowChromeCorners}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
/>
<DragOverlay dropAnimation={null}>
{activeDragTabId ? (
@@ -755,6 +759,8 @@ function SplitNodeView({
dropPreview,
tabDropPreview,
windowChromeCorners,
focusModeEnabled,
onExitFocusMode,
}: SplitNodeViewProps) {
const groupId = node.kind === "group" ? node.group.id : null;
const groupDirection = node.kind === "group" ? node.group.direction : null;
@@ -808,6 +814,8 @@ function SplitNodeView({
showDropZones={showDropZones}
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
/>
</WindowChromeRegion>
);
@@ -857,6 +865,8 @@ function SplitNodeView({
dropPreview={dropPreview}
tabDropPreview={tabDropPreview}
windowChromeCorners={windowChromeCorners}
focusModeEnabled={focusModeEnabled}
onExitFocusMode={onExitFocusMode}
/>
</SplitGroupChild>
{index < node.group.children.length - 1 ? (
@@ -908,6 +918,8 @@ function SplitPaneView({
showDropZones,
dropPreview,
tabDropPreview,
focusModeEnabled,
onExitFocusMode,
}: SplitPaneViewProps) {
const { theme: _theme } = useUnistyles();
const paneRef = useRef<View | null>(null);
@@ -1043,6 +1055,8 @@ function SplitPaneView({
tabDropPreviewIndex={
tabDropPreview?.paneId === pane.id ? tabDropPreview.indicatorIndex : null
}
focusModeEnabled={Boolean(focusModeEnabled)}
onExitFocusMode={onExitFocusMode}
/>
</WindowChromeSafeArea>

View File

@@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import { resolveFocusLatchStep, type FocusLatchStep } from "./terminal-pane-focus-latch";
/**
* Pins the ordering that caused "terminal stuck at 80x24": the latch must record that the action
* RAN, not that the effect ran. A pane that mounts before its workspace has focus (or while the
* app is hidden) must still fire once readiness arrives — burning the latch on the attempt
* permanently disarms the only resize claim a fresh terminal ever sends.
*/
function run(steps: Array<{ key: string | null; canFire: boolean }>): FocusLatchStep[] {
const results: FocusLatchStep[] = [];
let latchedKey: string | null = null;
for (const step of steps) {
const result = resolveFocusLatchStep({ ...step, latchedKey });
latchedKey = result.latchedKey;
results.push(result);
}
return results;
}
describe("resolveFocusLatchStep", () => {
it("fires immediately when the pane is focused and ready at mount", () => {
const [step] = run([{ key: "ws:term-1", canFire: true }]);
expect(step).toEqual({ latchedKey: "ws:term-1", fire: true });
});
it("defers instead of burning the latch when readiness arrives late", () => {
// The bug: pane focused while the workspace is not (or the app is hidden). The old code
// latched on the first pass and never fired again.
const steps = run([
{ key: "ws:term-1", canFire: false }, // mount: pane focused, workspace not focused yet
{ key: "ws:term-1", canFire: true }, // workspace focus / visibility arrives
]);
expect(steps[0]).toEqual({ latchedKey: null, fire: false });
expect(steps[1]).toEqual({ latchedKey: "ws:term-1", fire: true });
});
it("fires exactly once per continuous pane-focus period", () => {
const steps = run([
{ key: "ws:term-1", canFire: true },
{ key: "ws:term-1", canFire: true }, // unrelated re-render
{ key: "ws:term-1", canFire: false }, // workspace blurs...
{ key: "ws:term-1", canFire: true }, // ...and refocuses: not a new pane-focus period
]);
expect(steps).toEqual([
{ latchedKey: "ws:term-1", fire: true },
{ latchedKey: "ws:term-1", fire: false },
{ latchedKey: "ws:term-1", fire: false },
{ latchedKey: "ws:term-1", fire: false },
]);
});
it("re-arms when the pane blurs and fires again on refocus", () => {
const steps = run([
{ key: "ws:term-1", canFire: true },
{ key: null, canFire: true }, // pane blurred / terminal gone
{ key: "ws:term-1", canFire: true },
]);
expect(steps).toEqual([
{ latchedKey: "ws:term-1", fire: true },
{ latchedKey: null, fire: false },
{ latchedKey: "ws:term-1", fire: true },
]);
});
it("fires for a different terminal in the same pane", () => {
const steps = run([
{ key: "ws:term-1", canFire: true },
{ key: "ws:term-2", canFire: true },
]);
expect(steps).toEqual([
{ latchedKey: "ws:term-1", fire: true },
{ latchedKey: "ws:term-2", fire: true },
]);
});
it("stays deferred across repeated not-ready passes, then fires once", () => {
const steps = run([
{ key: "ws:term-1", canFire: false },
{ key: "ws:term-1", canFire: false },
{ key: "ws:term-1", canFire: true },
{ key: "ws:term-1", canFire: true },
]);
expect(steps).toEqual([
{ latchedKey: null, fire: false },
{ latchedKey: null, fire: false },
{ latchedKey: "ws:term-1", fire: true },
{ latchedKey: "ws:term-1", fire: false },
]);
});
});

View File

@@ -0,0 +1,33 @@
/**
* Once-per-pane-focus latch for terminal-pane's focus/reflow effects.
*
* Each effect wants to fire exactly once per continuous pane-focus period per terminal, but only
* while the action can actually land (workspace focused, and for resize claims the app visible —
* `handleTerminalResize` drops claims emitted while hidden and nothing re-sends them). The latch
* must therefore record "the action ran", never "we tried": latching before the readiness gate
* permanently disarms the effect when a pane mounts before its workspace has focus, which is how
* a terminal ends up rendering full-size while its PTY stays at the daemon's 80x24 default.
*/
export interface FocusLatchStep {
latchedKey: string | null;
fire: boolean;
}
export function resolveFocusLatchStep(input: {
/** Identity of the pane-focus period; null resets the latch (pane blurred, no terminal). */
key: string | null;
latchedKey: string | null;
/** Whether the action can land right now; while false the latch stays untouched. */
canFire: boolean;
}): FocusLatchStep {
if (input.key === null) {
return { latchedKey: null, fire: false };
}
if (input.latchedKey === input.key) {
return { latchedKey: input.latchedKey, fire: false };
}
if (!input.canFire) {
return { latchedKey: input.latchedKey, fire: false };
}
return { latchedKey: input.key, fire: true };
}

View File

@@ -21,7 +21,7 @@ import {
resolvePendingModifierDataInput,
} from "@/utils/terminal-keys";
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
import { rememberTerminalViewportSize } from "@/terminal/runtime/terminal-size-cache";
import { resolveFocusLatchStep } from "./terminal-pane-focus-latch";
import {
TerminalStreamController,
type TerminalStreamControllerStatus,
@@ -261,42 +261,41 @@ export function TerminalPane({
);
useEffect(() => {
if (isMobile || !isPaneFocused || !terminalId) {
lastAutoFocusKeyRef.current = null;
return;
// Latch only when the focus actually ran: latching before the workspace-focus gate would
// permanently disarm this effect for panes that mount before their workspace has focus.
const step = resolveFocusLatchStep({
key: isMobile || !isPaneFocused || !terminalId ? null : `${scopeKey}:${terminalId}`,
latchedKey: lastAutoFocusKeyRef.current,
canFire: isWorkspaceFocused,
});
lastAutoFocusKeyRef.current = step.latchedKey;
if (step.fire) {
requestTerminalFocus();
}
const nextFocusKey = `${scopeKey}:${terminalId}`;
if (lastAutoFocusKeyRef.current === nextFocusKey) {
return;
}
lastAutoFocusKeyRef.current = nextFocusKey;
if (!isWorkspaceFocused) {
return;
}
requestTerminalFocus();
}, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]);
useEffect(() => {
if (!isPaneFocused || !terminalId) {
lastPaneFocusResizeKeyRef.current = null;
return;
// This reflow produces the one resize claim a freshly mounted terminal ever sends; defer it
// (without burning the latch) until the claim can land — handleTerminalResize drops claims
// while the workspace is unfocused or the app is hidden, and nothing re-sends a dropped one.
const step = resolveFocusLatchStep({
key: !isPaneFocused || !terminalId ? null : `${scopeKey}:${terminalId}`,
latchedKey: lastPaneFocusResizeKeyRef.current,
canFire: isWorkspaceFocused && isAppVisible,
});
lastPaneFocusResizeKeyRef.current = step.latchedKey;
if (step.fire) {
lastSentTerminalSizeRef.current = null;
requestTerminalReflow();
}
const focusResizeKey = `${scopeKey}:${terminalId}`;
if (lastPaneFocusResizeKeyRef.current === focusResizeKey) {
return;
}
lastPaneFocusResizeKeyRef.current = focusResizeKey;
if (!isWorkspaceFocused) {
return;
}
lastSentTerminalSizeRef.current = null;
requestTerminalReflow();
}, [isPaneFocused, isWorkspaceFocused, requestTerminalReflow, scopeKey, terminalId]);
}, [
isAppVisible,
isPaneFocused,
isWorkspaceFocused,
requestTerminalReflow,
scopeKey,
terminalId,
]);
const handleTerminalFocus = useCallback(() => {
if (isWorkspaceFocused && isPaneFocused) {
@@ -636,9 +635,6 @@ export function TerminalPane({
const normalizedCols = Math.floor(cols);
const nextSize = { rows: normalizedRows, cols: normalizedCols };
measuredTerminalSizeRef.current = nextSize;
// Seed future terminals in this workspace with the current pane size so they are born at
// the right size instead of the daemon's 80x24 default (see terminal-size-cache).
rememberTerminalViewportSize({ serverId, cwd, size: nextSize });
if (!input.shouldClaim || !client || !terminalId || !isWorkspaceFocused || !isAppVisible) {
return;
}

View File

@@ -23,7 +23,8 @@ import {
GitBranch,
Server,
} from "lucide-react-native";
import { GitHubIcon } from "@/components/icons/github-icon";
import { getForgePresentation, normalizeForge } from "@/git/forge";
import { ForgeBrandIcon } from "@/git/forge-icon";
import type { Theme } from "@/styles/theme";
import { DiffStat } from "@/components/diff-stat";
import { Pressable } from "react-native";
@@ -319,7 +320,11 @@ function WorkspaceHoverCardContent({
{prHint?.checks && prHint.checks.length > 0 ? (
<>
<View style={styles.separator} />
<ChecksSummaryPressable checks={prHint.checks} url={prHint.url} />
<ChecksSummaryPressable
checks={prHint.checks}
url={prHint.url}
forge={prHint.forge}
/>
</>
) : null}
</FloatingSurface>
@@ -343,7 +348,6 @@ function HostRow({ serverId }: { serverId: string }): ReactElement | null {
}
const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleX = withUnistyles(CircleX);
@@ -375,6 +379,10 @@ function InfoRow({
);
}
function renderChecksSummaryForgeIcon(icon: string, iconUniProps: typeof foregroundColorMapping) {
return <ForgeBrandIcon iconKind={icon} size={12} uniProps={iconUniProps} />;
}
function CopyableInfoRow({
icon: Icon,
value,
@@ -488,9 +496,11 @@ function ChecksSummaryPill({
function ChecksSummaryContent({
checks,
forge,
hovered,
}: {
checks: NonNullable<PrHint["checks"]>;
forge: PrHint["forge"];
hovered: boolean;
}) {
const { t } = useTranslation();
@@ -498,13 +508,14 @@ function ChecksSummaryContent({
const labelStyle = hovered ? checksSummaryLabelHoveredCombined : styles.checksSummaryLabel;
const iconUniProps = hovered ? foregroundColorMapping : foregroundMutedColorMapping;
const icon = getForgePresentation(normalizeForge(forge)).icon;
return (
<>
{hovered ? (
<ThemedExternalLink size={12} uniProps={iconUniProps} />
) : (
<ThemedGitHubIcon size={12} uniProps={iconUniProps} />
renderChecksSummaryForgeIcon(icon, iconUniProps)
)}
<Text style={labelStyle}>{t("workspace.git.pr.sections.checks")}</Text>
<View style={styles.checksSummaryCounts}>
@@ -518,9 +529,11 @@ function ChecksSummaryContent({
function ChecksSummaryPressable({
checks,
forge,
url,
}: {
checks: NonNullable<PrHint["checks"]>;
forge: PrHint["forge"];
url: string;
}) {
const handlePress = useCallback(() => {
@@ -529,9 +542,9 @@ function ChecksSummaryPressable({
const renderChildren = useCallback(
({ hovered }: { pressed: boolean; hovered?: boolean }) => (
<ChecksSummaryContent checks={checks} hovered={Boolean(hovered)} />
<ChecksSummaryContent checks={checks} forge={forge} hovered={Boolean(hovered)} />
),
[checks],
[checks, forge],
);
return (

View File

@@ -18,7 +18,10 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
import { encodeImages } from "@/utils/encode-images";
import { toErrorMessage } from "@/utils/error-messages";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import {
resolveComposerAttachmentSubmitFormat,
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
import type {
CreateAgentRequestOptions,
DaemonClient,
@@ -171,6 +174,9 @@ export function WorkspaceSetupDialog() {
const [pendingAction, setPendingAction] = useState<"chat" | null>(null);
const serverId = pendingWorkspaceSetup?.serverId ?? "";
const supportsForgeSearch = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true,
);
const sourceDirectory = pendingWorkspaceSetup?.sourceDirectory ?? "";
const displayName = pendingWorkspaceSetup?.displayName?.trim() ?? "";
const workspace = createdWorkspace;
@@ -309,7 +315,11 @@ export function WorkspaceSetupDialog() {
throw new Error(t("workspaceSetup.errors.selectModel"));
}
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
format: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: supportsForgeSearch,
}),
});
const encodedImages = await encodeImages(wirePayload.images);
const workspaceDirectory = requireWorkspaceDirectory({
workspaceId: ensuredWorkspace.id,
@@ -363,6 +373,7 @@ export function WorkspaceSetupDialog() {
t,
toast,
withConnectedClient,
supportsForgeSearch,
],
);

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { AgentAttachment, GitHubSearchItem } from "@getpaseo/protocol/messages";
import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
@@ -38,7 +38,7 @@ const imageMetadata: AttachmentMetadata = {
createdAt: 1,
};
const issueItem: GitHubSearchItem = {
const issueItem: ForgeSearchItem = {
kind: "issue",
number: 101,
title: "Fix composer attachments",
@@ -50,8 +50,8 @@ const issueItem: GitHubSearchItem = {
headRefName: null,
};
const prItem: GitHubSearchItem = {
kind: "pr",
const prItem: ForgeSearchItem = {
kind: "change_request",
number: 202,
title: "Refactor composer attachments",
url: "https://github.com/acme/paseo/pull/202",
@@ -357,8 +357,9 @@ describe("dispatchComposerAgentMessage", () => {
expect(call.options.images).toEqual([{ data: image.id, mimeType: image.mimeType }]);
expect(call.options.attachments).toEqual([
{
type: "github_pr",
mimeType: "application/github-pr",
type: "forge_change_request",
mimeType: "application/paseo-forge-change-request",
forge: "github",
number: 202,
title: "Refactor composer attachments",
url: "https://github.com/acme/paseo/pull/202",
@@ -380,6 +381,34 @@ describe("dispatchComposerAgentMessage", () => {
expect(userMessage.optimistic).toBe(true);
});
it("can send legacy GitHub attachment payloads for old daemons", async () => {
const client = createFakeSendClient();
const stream = createFakeStream();
await dispatchComposerAgentMessage({
client,
agentId: "agent",
text: "send old attachment",
attachments: [{ kind: "forge_change_request", item: prItem }],
attachmentSubmitFormat: "legacy-github",
encodeImages: passthroughEncodeImages,
stream,
});
expect(client.calls[0].options.attachments).toEqual([
{
type: "github_pr",
mimeType: "application/github-pr",
number: 202,
title: "Refactor composer attachments",
url: "https://github.com/acme/paseo/pull/202",
body: "PR body",
baseRefName: "main",
headRefName: "composer-attachments",
},
]);
});
it("appends to the existing head when one is present", async () => {
const existingItem: StreamItem = {
kind: "user_message",
@@ -694,12 +723,12 @@ describe("openComposerAttachment", () => {
describe("toggleGithubAttachment", () => {
it("appends a GitHub issue when not already attached", () => {
const next = toggleGithubAttachment([], issueItem);
expect(next).toEqual([{ kind: "github_issue", item: issueItem }]);
expect(next).toEqual([{ kind: "forge_issue", item: issueItem }]);
});
it("appends a GitHub PR when not already attached", () => {
const next = toggleGithubAttachment([], prItem);
expect(next).toEqual([{ kind: "github_pr", item: prItem }]);
expect(next).toEqual([{ kind: "forge_change_request", item: prItem }]);
});
it("removes an existing GitHub item with the same kind+number", () => {
@@ -712,12 +741,12 @@ describe("toggleGithubAttachment", () => {
{ kind: "github_issue", item: issueItem },
{ kind: "github_pr", item: prItem },
];
const otherIssue: GitHubSearchItem = { ...issueItem, number: 999 };
const otherIssue: ForgeSearchItem = { ...issueItem, number: 999 };
const next = toggleGithubAttachment(start, otherIssue);
expect(next).toEqual([
{ kind: "github_issue", item: issueItem },
{ kind: "github_pr", item: prItem },
{ kind: "github_issue", item: otherIssue },
{ kind: "forge_issue", item: otherIssue },
]);
});
});
@@ -747,7 +776,7 @@ describe("toggleGithubAttachmentFromPicker", () => {
markGithubAttachmentRemoved,
});
expect(next).toEqual([{ kind: "github_issue", item: issueItem }]);
expect(next).toEqual([{ kind: "forge_issue", item: issueItem }]);
expect(markGithubAttachmentRemoved).not.toHaveBeenCalled();
});
});
@@ -755,8 +784,8 @@ describe("toggleGithubAttachmentFromPicker", () => {
describe("findGithubItemByOption / isAttachmentSelectedForGithubItem", () => {
it("locates items via their composite kind:number id", () => {
expect(findGithubItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem);
expect(findGithubItemByOption([issueItem, prItem], "pr:202")).toBe(prItem);
expect(findGithubItemByOption([issueItem], "pr:404")).toBeUndefined();
expect(findGithubItemByOption([issueItem, prItem], "change_request:202")).toBe(prItem);
expect(findGithubItemByOption([issueItem], "change_request:404")).toBeUndefined();
});
it("recognizes when an attachment list already contains a matching GitHub item", () => {

View File

@@ -1,4 +1,4 @@
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
@@ -8,7 +8,10 @@ import {
isWorkspaceAttachment,
userAttachmentsOnly,
} from "@/attachments/workspace-attachment-utils";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import {
splitComposerAttachmentsForSubmit,
type ComposerAttachmentSubmitFormat,
} from "@/composer/attachments/submit";
import {
appendOptimisticUserMessageToStream,
buildOptimisticUserMessage,
@@ -162,6 +165,7 @@ export interface DispatchComposerAgentMessageInput {
agentId: string;
text: string;
attachments: ComposerAttachment[];
attachmentSubmitFormat?: ComposerAttachmentSubmitFormat;
encodeImages: (
images: AttachmentMetadata[],
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
@@ -171,7 +175,9 @@ export interface DispatchComposerAgentMessageInput {
export async function dispatchComposerAgentMessage(
input: DispatchComposerAgentMessageInput,
): Promise<void> {
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments);
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, {
format: input.attachmentSubmitFormat,
});
const messageId = generateMessageId();
const userMessage = buildOptimisticUserMessage({
id: messageId,
@@ -342,33 +348,44 @@ export function openComposerAttachment(input: OpenComposerAttachmentInput): void
input.openExternalUrl(input.attachment.item.url);
}
export function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment {
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
export function buildForgeAttachment(item: ForgeSearchItem): UserComposerAttachment {
return item.kind === "change_request"
? { kind: "forge_change_request", item }
: { kind: "forge_issue", item };
}
function isGithubAttachment(
function isForgeAttachment(
attachment: UserComposerAttachment,
): attachment is Extract<UserComposerAttachment, { kind: "github_issue" } | { kind: "github_pr" }> {
return attachment.kind === "github_issue" || attachment.kind === "github_pr";
): attachment is Extract<
UserComposerAttachment,
{ kind: "forge_issue" | "forge_change_request" | "github_issue" | "github_pr" }
> {
return (
attachment.kind === "forge_issue" ||
attachment.kind === "forge_change_request" ||
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
attachment.kind === "github_issue" ||
attachment.kind === "github_pr"
);
}
export function toggleGithubAttachment(
export function toggleForgeAttachment(
current: UserComposerAttachment[],
item: GitHubSearchItem,
item: ForgeSearchItem,
): UserComposerAttachment[] {
const matches = (attachment: UserComposerAttachment) =>
isGithubAttachment(attachment) &&
isForgeAttachment(attachment) &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number;
if (current.some(matches)) {
return current.filter((attachment) => !matches(attachment));
}
return [...current, buildGithubAttachment(item)];
return [...current, buildForgeAttachment(item)];
}
interface ToggleGithubAttachmentFromPickerInput {
current: UserComposerAttachment[];
item: GitHubSearchItem;
item: ForgeSearchItem;
markGithubAttachmentRemoved: (attachment: UserComposerAttachment) => void;
}
@@ -379,31 +396,33 @@ export function toggleGithubAttachmentFromPicker({
}: ToggleGithubAttachmentFromPickerInput): UserComposerAttachment[] {
const existingAttachment = current.find(
(attachment) =>
isGithubAttachment(attachment) &&
isForgeAttachment(attachment) &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
if (existingAttachment) {
markGithubAttachmentRemoved(existingAttachment);
}
return toggleGithubAttachment(current, item);
return toggleForgeAttachment(current, item);
}
export function findGithubItemByOption(
items: readonly GitHubSearchItem[],
items: readonly ForgeSearchItem[],
optionId: string,
): GitHubSearchItem | undefined {
): ForgeSearchItem | undefined {
return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId);
}
export function isAttachmentSelectedForGithubItem(
current: readonly ComposerAttachment[],
item: GitHubSearchItem,
item: ForgeSearchItem,
): boolean {
return userAttachmentsOnly(current).some(
(attachment) =>
isGithubAttachment(attachment) &&
isForgeAttachment(attachment) &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
}
export const toggleGithubAttachment = toggleForgeAttachment;

View File

@@ -5,14 +5,38 @@ import {
workspaceAttachmentToSubmitAttachment,
} from "@/attachments/workspace-attachment-utils";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
import {
buildForgeAttachmentFromSearchItem,
buildLegacyGitHubAttachmentFromSearchItem,
} from "@/utils/review-attachments";
export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachment[]): {
export type ComposerAttachmentSubmitFormat = "forge" | "legacy-github";
interface SplitComposerAttachmentsOptions {
format?: ComposerAttachmentSubmitFormat;
}
export function resolveComposerAttachmentSubmitFormat(input: {
supportsForgeAttachments?: boolean;
}): ComposerAttachmentSubmitFormat {
// COMPAT(forgeSearch): added in v0.1.106, remove github_search fallback after 2026-12-28.
return input.supportsForgeAttachments === false ? "legacy-github" : "forge";
}
export function splitComposerAttachmentsForSubmit(
attachments: ComposerAttachment[],
options: SplitComposerAttachmentsOptions = {},
): {
images: ImageAttachment[];
attachments: AgentAttachment[];
} {
const images: ImageAttachment[] = [];
const agentAttachments: AgentAttachment[] = [];
// COMPAT(forgeSearch): added in v0.1.106, remove github_search fallback after 2026-12-28.
const buildSearchAttachment =
options.format === "legacy-github"
? buildLegacyGitHubAttachmentFromSearchItem
: buildForgeAttachmentFromSearchItem;
for (const attachment of attachments) {
if (attachment.kind === "image") {
@@ -36,7 +60,7 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
continue;
}
const reviewAttachment = buildGitHubAttachmentFromSearchItem(attachment.item);
const reviewAttachment = buildSearchAttachment(attachment.item);
if (reviewAttachment) {
agentAttachments.push(reviewAttachment);
}

View File

@@ -1,7 +1,10 @@
import { useCallback, useMemo, useReducer } from "react";
import { useTranslation } from "react-i18next";
import type { ComposerAttachment } from "@/attachments/types";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import {
resolveComposerAttachmentSubmitFormat,
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore } from "@/stores/session-store";
import {
@@ -236,10 +239,23 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
}
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
const wirePayload = splitComposerAttachmentsForSubmit(attachments);
const trimmedPrompt = text.trim();
const pendingServerId = getPendingServerId();
if (!pendingServerId) {
const error = new Error(t("composer.errors.noHostSelected"));
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
const supportsForgeSearch =
useSessionStore.getState().sessions[pendingServerId]?.serverInfo?.features?.forgeSearch ===
true;
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
format: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: supportsForgeSearch,
}),
});
const images = wirePayload.images;
const trimmedPrompt = text.trim();
const hasAttachmentContent = images.length > 0 || wirePayload.attachments.length > 0;
if (!trimmedPrompt && !hasAttachmentContent && !allowEmptyText) {
const error = new Error(t("composer.errors.initialPromptRequired"));
@@ -258,13 +274,6 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
throw error;
}
const pendingServerId = getPendingServerId();
if (!pendingServerId) {
const error = new Error(t("composer.errors.noHostSelected"));
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
const attempt: CreateAttempt = {
clientMessageId: generateMessageId(),
text: trimmedPrompt,

View File

@@ -7,17 +7,17 @@ import React, { type ReactNode } from "react";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import type { UserComposerAttachment } from "@/attachments/types";
import type { GitHubSearchClient } from "@/git/use-github-search-query";
import type { GitHubSearchItem, GitHubSearchResponse } from "@getpaseo/protocol/messages";
import type { ForgeSearchClient } from "@/git/use-forge-search-query";
import type { ForgeSearchItem, ForgeSearchResponse } from "@getpaseo/protocol/messages";
import { useComposerGithubAutoAttach } from "./auto-attach";
type GitHubSearchPayload = GitHubSearchResponse["payload"];
type ForgeSearchPayload = ForgeSearchResponse["payload"];
const remoteUrl = "git@github.com:acme/paseo.git";
const cwd = "/repo";
const pr101: GitHubSearchItem = {
kind: "pr",
const pr101: ForgeSearchItem = {
kind: "change_request",
number: 101,
title: "Attach PR",
url: "https://github.com/acme/paseo/pull/101",
@@ -28,7 +28,7 @@ const pr101: GitHubSearchItem = {
headRefName: "feature",
};
const issue202: GitHubSearchItem = {
const issue202: ForgeSearchItem = {
kind: "issue",
number: 202,
title: "Attach issue",
@@ -52,22 +52,20 @@ interface HarnessInput {
remote?: string | null;
}
function githubPayload(items: GitHubSearchItem[], requestId: string): GitHubSearchPayload {
function githubPayload(items: ForgeSearchItem[], requestId: string): ForgeSearchPayload {
return {
items,
githubFeaturesEnabled: true,
authState: "authenticated",
error: null,
requestId,
};
}
function createSearchClient(
items: GitHubSearchItem[],
): GitHubSearchClient & { calls: SearchCall[] } {
function createSearchClient(items: ForgeSearchItem[]): ForgeSearchClient & { calls: SearchCall[] } {
const calls: SearchCall[] = [];
return {
calls,
async searchGitHub(options) {
async searchForge(options) {
calls.push(options);
return githubPayload(items, `search-${options.query}`);
},
@@ -87,7 +85,7 @@ function createWrapper() {
};
}
function useHarness(client: GitHubSearchClient, input: HarnessInput = {}) {
function useHarness(client: ForgeSearchClient, input: HarnessInput = {}) {
const [text, setText] = useState(input.initialText ?? "");
const [attachments, setAttachments] = useState<UserComposerAttachment[]>(
input.initialAttachments ?? [],
@@ -130,7 +128,7 @@ describe("useComposerGithubAutoAttach", () => {
});
await flushDebounce();
expect(result.current.attachments).toEqual([{ kind: "github_pr", item: pr101 }]);
expect(result.current.attachments).toEqual([{ kind: "forge_change_request", item: pr101 }]);
expect(client.calls).toEqual([{ cwd, query: "101", limit: 20 }]);
vi.useRealTimers();
});
@@ -201,8 +199,8 @@ describe("useComposerGithubAutoAttach", () => {
await flushDebounce();
expect(result.current.attachments).toEqual([
{ kind: "github_pr", item: pr101 },
{ kind: "github_issue", item: issue202 },
{ kind: "forge_change_request", item: pr101 },
{ kind: "forge_issue", item: issue202 },
]);
expect(client.calls).toEqual([
{ cwd, query: "101", limit: 20 },

View File

@@ -9,12 +9,9 @@ import {
type SetStateAction,
} from "react";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import {
buildGithubSearchQueryOptions,
type GitHubSearchClient,
} from "@/git/use-github-search-query";
import { buildForgeSearchQueryOptions, type ForgeSearchClient } from "@/git/use-forge-search-query";
import { extractGithubRefs, type GithubRef } from "@/utils/github-refs";
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import { isAttachmentSelectedForGithubItem, toggleGithubAttachment } from "../actions";
const AUTO_ATTACH_DEBOUNCE_MS = 300;
@@ -23,10 +20,11 @@ interface ComposerGithubAutoAttachInput {
text: string;
remoteUrl: string | null | undefined;
attachments: UserComposerAttachment[];
client: GitHubSearchClient | null;
client: ForgeSearchClient | null;
isConnected: boolean;
serverId: string;
cwd: string;
supportsForgeSearch?: boolean;
setAttachments: Dispatch<SetStateAction<UserComposerAttachment[]>>;
}
@@ -75,6 +73,7 @@ export function useComposerGithubAutoAttach(
params.isConnected,
params.serverId,
params.cwd,
params.supportsForgeSearch,
queryClient,
]);
@@ -193,11 +192,12 @@ async function fetchGithubRefSearch({
try {
return await queryClient.fetchQuery(
buildGithubSearchQueryOptions({
buildForgeSearchQueryOptions({
client: snapshot.client,
serverId: snapshot.serverId,
cwd: snapshot.cwd,
query: String(ref.number),
supportsForgeSearch: snapshot.supportsForgeSearch,
enabled: true,
}),
);
@@ -216,12 +216,12 @@ function hasGithubAttachment(attachments: UserComposerAttachment[], ref: GithubR
return attachments.some((attachment) => attachmentKey(attachment) === githubRefKey(ref));
}
function githubItemMatchesRef(item: GitHubSearchItem, ref: GithubRef): boolean {
function githubItemMatchesRef(item: ForgeSearchItem, ref: GithubRef): boolean {
return item.kind === githubItemKind(ref) && item.number === ref.number;
}
function githubItemKind(ref: GithubRef): GitHubSearchItem["kind"] {
return ref.kind === "pull" ? "pr" : "issue";
function githubItemKind(ref: GithubRef): ForgeSearchItem["kind"] {
return ref.kind === "pull" ? "change_request" : "issue";
}
function githubRefKey(ref: GithubRef): string {
@@ -232,7 +232,10 @@ function attachmentKey(attachment: ComposerAttachment | undefined): string | nul
if (
!attachment ||
attachment.kind === "image" ||
(attachment.kind !== "github_pr" && attachment.kind !== "github_issue")
(attachment.kind !== "forge_change_request" &&
attachment.kind !== "forge_issue" &&
attachment.kind !== "github_pr" &&
attachment.kind !== "github_issue")
) {
return null;
}

View File

@@ -28,7 +28,6 @@ import {
CircleDot,
FileText,
GitPullRequest,
Github,
Image as ImageIcon,
Paperclip,
} from "lucide-react-native";
@@ -94,7 +93,7 @@ import { submitAgentInput } from "@/composer/submit";
import { ComposerKeyboardScopeProvider } from "@/composer/keyboard-scope";
import { useAppSettings } from "@/hooks/use-settings";
import { isWeb, isNative } from "@/constants/platform";
import type { GitHubSearchItem } from "@getpaseo/protocol/messages";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
@@ -102,6 +101,7 @@ import type {
WorkspaceComposerAttachment,
} from "@/attachments/types";
import type { PickedFile } from "@/attachments/picked-file";
import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit";
import { composerWorkspaceAttachment } from "@/composer/attachments/workspace";
import { useWorkspaceAttachmentsForScopes } from "@/attachments/workspace-attachments-store";
import { droppedItemsToPickedFiles } from "@/composer/attachments/drop";
@@ -111,8 +111,11 @@ import { AttachmentLabel, AttachmentPill, AttachmentThumbnail } from "@/componen
import { AttachmentLightbox } from "@/components/attachment-lightbox";
import { openExternalUrl } from "@/utils/open-external-url";
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
import { useGithubSearchQuery } from "@/git/use-github-search-query";
import { useForgeSearchQuery } from "@/git/use-forge-search-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { getForgePresentation } from "@/git/forge";
import { ForgeBrandIcon } from "@/git/forge-icon";
import { useComposerGithubAutoAttach } from "./github/auto-attach";
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
@@ -281,8 +284,8 @@ interface RenderAttachmentTrayArgs {
openImage: string;
removeImage: string;
removeFile: string;
openGithub: (kind: string, number: number) => string;
removeGithub: (kind: string, number: number) => string;
openGithub: (kind: string, numberLabel: string) => string;
removeGithub: (kind: string, numberLabel: string) => string;
};
}
@@ -616,13 +619,16 @@ function ImageAttachmentPill({
}
interface GithubAttachmentPillProps {
attachment: Extract<ComposerAttachment, { kind: "github_pr" | "github_issue" }>;
attachment: Extract<
ComposerAttachment,
{ kind: "forge_change_request" | "forge_issue" | "github_pr" | "github_issue" }
>;
index: number;
disabled: boolean;
onOpen: (attachment: ComposerAttachment) => void;
onRemove: (index: number) => void;
openLabel: (kind: string, number: number) => string;
removeLabel: (kind: string, number: number) => string;
openLabel: (kind: string, numberLabel: string) => string;
removeLabel: (kind: string, numberLabel: string) => string;
}
function GithubAttachmentPill({
@@ -635,7 +641,11 @@ function GithubAttachmentPill({
removeLabel,
}: GithubAttachmentPillProps) {
const item = attachment.item;
const kindLabel = item.kind === "pr" ? "PR" : "issue";
const presentation = getForgePresentation(item.forge ?? "github");
const isChangeRequest = item.kind === "change_request";
const kindLabel = isChangeRequest ? presentation.changeRequestAbbrev : "issue";
const subtitleKind = isChangeRequest ? presentation.changeRequestAbbrev : "Issue";
const numberPrefix = isChangeRequest ? presentation.numberPrefix : presentation.issueNumberPrefix;
const handleOpen = useCallback(() => {
onOpen(attachment);
}, [onOpen, attachment]);
@@ -647,14 +657,14 @@ function GithubAttachmentPill({
testID="composer-github-attachment-pill"
onOpen={handleOpen}
onRemove={handleRemove}
openAccessibilityLabel={openLabel(kindLabel, item.number)}
removeAccessibilityLabel={removeLabel(kindLabel, item.number)}
openAccessibilityLabel={openLabel(kindLabel, `${numberPrefix}${item.number}`)}
removeAccessibilityLabel={removeLabel(kindLabel, `${numberPrefix}${item.number}`)}
disabled={disabled}
>
<AttachmentLabel
icon={item.kind === "pr" ? githubPrPillIcon : githubIssuePillIcon}
icon={isChangeRequest ? githubPrPillIcon : githubIssuePillIcon}
title={item.title}
subtitle={`${item.kind === "pr" ? "PR" : "Issue"} #${item.number}`}
subtitle={`${subtitleKind} ${numberPrefix}${item.number}`}
/>
</AttachmentPill>
);
@@ -703,8 +713,8 @@ interface GithubPickerOptionProps {
testID: string;
active: boolean;
selected: boolean;
item: GitHubSearchItem;
onToggle: (item: GitHubSearchItem) => void;
item: ForgeSearchItem;
onToggle: (item: ForgeSearchItem) => void;
}
function GithubPickerOption({
@@ -720,7 +730,7 @@ function GithubPickerOption({
}, [onToggle, item]);
const leadingSlot = useMemo(
() =>
item.kind === "pr" ? (
item.kind === "change_request" ? (
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
) : (
<ThemedCircleDot size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
@@ -1045,6 +1055,9 @@ export function Composer({
});
const setSelectedAttachments = onChangeAttachments;
const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd });
const supportsForgeSearch = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true,
);
const githubAutoAttach = useComposerGithubAutoAttach({
text: userInput,
remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status),
@@ -1053,6 +1066,7 @@ export function Composer({
isConnected,
serverId,
cwd,
supportsForgeSearch,
setAttachments: setSelectedAttachments,
});
const [cursorIndex, setCursorIndex] = useState(0);
@@ -1212,12 +1226,23 @@ export function Composer({
agentId: targetAgentId,
text,
attachments: sendAttachments,
attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: supportsForgeSearch,
}),
encodeImages,
stream,
});
onAttentionPromptSend?.();
};
}, [client, onAttentionPromptSend, serverId, setAgentStreamTail, setAgentStreamHead, t]);
}, [
client,
onAttentionPromptSend,
serverId,
setAgentStreamTail,
setAgentStreamHead,
supportsForgeSearch,
t,
]);
useEffect(() => {
onSubmitMessageRef.current = onSubmitMessage;
@@ -1711,12 +1736,33 @@ export function Composer({
[contextWindowMeter, isCompactLayout],
);
const hasGithubAttachment = useMemo(
() =>
selectedAttachments.some(
(attachment) =>
attachment.kind === "forge_change_request" ||
attachment.kind === "forge_issue" ||
attachment.kind === "github_pr" ||
attachment.kind === "github_issue",
),
[selectedAttachments],
);
// Composer stays mounted for each focused agent, so avoid a forge CLI call
// until the forge-specific picker or attachment presentation is visible.
const { forge } = useCheckoutPrStatusQuery({
serverId,
cwd,
enabled: isConnected && cwd.trim().length > 0 && (isGithubPickerOpen || hasGithubAttachment),
});
const forgePresentation = useMemo(() => getForgePresentation(forge), [forge]);
const githubSearchQueryTrimmed = githubSearchQuery.trim();
const githubSearchResultsQuery = useGithubSearchQuery({
const githubSearchResultsQuery = useForgeSearchQuery({
client,
serverId,
cwd,
query: githubSearchQueryTrimmed,
supportsForgeSearch,
enabled: resolveGithubSearchEnabled(isGithubPickerOpen, isConnected, cwd),
});
@@ -1724,11 +1770,18 @@ export function Composer({
const githubSearchItems = useMemo(() => githubSearchItemsRaw ?? [], [githubSearchItemsRaw]);
const githubSearchOptions: ComboboxOption[] = useMemo(
() =>
githubSearchItems.map((item) => ({
id: `${item.kind}:${item.number}`,
label: `#${item.number} ${item.title}`,
description: githubSearchQueryTrimmed,
})),
githubSearchItems.map((item) => {
const presentation = getForgePresentation(item.forge ?? "github");
const numberPrefix =
item.kind === "change_request"
? presentation.numberPrefix
: presentation.issueNumberPrefix;
return {
id: `${item.kind}:${item.number}`,
label: `${numberPrefix}${item.number} ${item.title}`,
description: githubSearchQueryTrimmed,
};
}),
[githubSearchItems, githubSearchQueryTrimmed],
);
@@ -1744,8 +1797,10 @@ export function Composer({
},
{
id: "github",
label: t("composer.attachments.addIssueOrPr"),
icon: <ThemedGithub size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />,
label: t("composer.attachments.addIssueOrPr", {
context: forgePresentation.changeRequestContext,
}),
icon: renderForgeAttachmentIcon(forgePresentation.icon),
onSelect: () => {
setIsGithubPickerOpen(true);
},
@@ -1759,11 +1814,11 @@ export function Composer({
},
},
],
[handlePickImage, handlePickFile, t],
[handlePickImage, handlePickFile, t, forgePresentation],
);
const handleToggleGithubItem = useCallback(
(item: GitHubSearchItem) => {
(item: ForgeSearchItem) => {
const nextAttachments = toggleGithubAttachmentFromPicker({
current: attachments,
item,
@@ -1868,10 +1923,10 @@ export function Composer({
openImage: t("composer.attachments.openImage"),
removeImage: t("composer.attachments.removeImage"),
removeFile: t("composer.attachments.removeFile"),
openGithub: (kind: string, number: number) =>
t("composer.attachments.openGithub", { kind, number }),
removeGithub: (kind: string, number: number) =>
t("composer.attachments.removeGithub", { kind, number }),
openGithub: (kind: string, numberLabel: string) =>
t("composer.attachments.openGithub", { kind, number: numberLabel }),
removeGithub: (kind: string, numberLabel: string) =>
t("composer.attachments.removeGithub", { kind, number: numberLabel }),
},
}),
[handleOpenAttachment, handleRemoveAttachment, isComposerLocked, selectedAttachments, t],
@@ -1983,8 +2038,12 @@ export function Composer({
onSelect={noop}
keepOpenOnSelect
searchable
searchPlaceholder={t("composer.github.searchPlaceholder")}
title={t("composer.github.title")}
searchPlaceholder={t("composer.github.searchPlaceholder", {
context: forgePresentation.changeRequestContext,
})}
title={t("composer.github.title", {
context: forgePresentation.changeRequestContext,
})}
open={isGithubPickerOpen}
onOpenChange={handleGithubPickerOpenChange}
onSearchQueryChange={setGithubSearchQuery}
@@ -2185,12 +2244,16 @@ const ThemedAudioLines = withUnistyles(AudioLines);
const ThemedPaperclip = withUnistyles(Paperclip);
const ThemedImageIcon = withUnistyles(ImageIcon);
const ThemedFileText = withUnistyles(FileText);
const ThemedGithub = withUnistyles(Github);
const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const iconAccentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
function renderForgeAttachmentIcon(icon: string): ReactElement {
return (
<ForgeBrandIcon iconKind={icon} size={ICON_SIZE.md} uniProps={iconForegroundMutedMapping} />
);
}
const githubPrPillIcon = (
<ThemedGitPullRequest size={ICON_SIZE.sm} uniProps={iconForegroundMutedMapping} />
);

View File

@@ -62,7 +62,10 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen
import { resolveProjectPlacement } from "@/utils/project-placement";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import type { AttachmentMetadata } from "@/attachments/types";
import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit";
import {
resolveComposerAttachmentSubmitFormat,
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts";
import { createProjectHydrationBuffer } from "@/contexts/session-workspace-hydration";
@@ -768,7 +771,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
if (queue && queue.length > 0) {
const [next, ...rest] = queue;
if (sendAgentMessageRef.current) {
const wirePayload = splitComposerAttachmentsForSubmit(next.attachments);
const supportsForgeSearch =
useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch ===
true;
const wirePayload = splitComposerAttachmentsForSubmit(next.attachments, {
format: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: supportsForgeSearch,
}),
});
void sendAgentMessageRef.current(
agent.id,
next.text,

View File

@@ -1,5 +1,6 @@
import { Platform } from "react-native";
import { getElectronHost } from "@/desktop/electron/host";
import type { BrowserKeyboardPolicy } from "@/keyboard/browser-shortcuts";
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
type BrowserAutomationExecuteRequest = Extract<
@@ -95,6 +96,7 @@ export interface DesktopWindowControlsOverlayUpdate {
height?: number;
backgroundColor?: string;
foregroundColor?: string;
trafficLightOffsetY?: number;
}
export interface DesktopWindowBridge {
@@ -121,10 +123,9 @@ export interface DesktopEventsBridge {
on?: (event: string, handler: (payload: unknown) => void) => Promise<() => void> | (() => void);
}
export interface DesktopBrowserShortcutEvent {
browserId?: string;
action: "focus-url";
}
export type DesktopBrowserShortcutEvent =
| { browserId?: string; action: "focus-url" }
| { browserId: string; action: "new-tab" };
export interface DesktopBrowserNewTabRequestEvent {
sourceBrowserId: string;
@@ -138,6 +139,7 @@ export interface DesktopAttachedBrowserRegistration {
}
export interface DesktopBrowserBridge {
setShortcutPolicy?: (input: BrowserKeyboardPolicy) => Promise<void>;
readonly profilePartition?: string;
registerAttachedBrowser?: (input: DesktopAttachedBrowserRegistration) => Promise<void>;
unregisterWorkspaceBrowser?: (browserId: string) => Promise<void>;

View File

@@ -183,66 +183,77 @@ describe("checkout-git-actions-store", () => {
).toBe("idle");
});
it("enables PR auto-merge when the daemon advertises auto-merge actions", async () => {
const client = {
checkoutGithubSetAutoMerge: vi.fn(async () => ({
for (const rpc of [
{
label: "forge",
method: "checkoutForgeSetAutoMerge",
feature: "checkoutForgeSetAutoMerge",
},
{
label: "legacy GitHub",
method: "checkoutGithubSetAutoMerge",
feature: "checkoutGithubSetAutoMerge",
},
] as const) {
it(`enables PR auto-merge through the ${rpc.label} RPC`, async () => {
const setAutoMerge = vi.fn(async () => ({
enabled: true,
success: true,
error: null,
})),
};
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: null,
features: { checkoutGithubSetAutoMerge: true },
});
}));
const client = { [rpc.method]: setAutoMerge };
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: null,
features: { [rpc.feature]: true },
});
await useCheckoutGitActionsStore
.getState()
.enablePrAutoMerge({ serverId, cwd, method: "squash" });
expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, {
enabled: true,
method: "squash",
});
expect(
useCheckoutGitActionsStore
await useCheckoutGitActionsStore
.getState()
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }),
).toBe("success");
});
.enablePrAutoMerge({ serverId, cwd, method: "squash" });
it("disables PR auto-merge when the daemon advertises auto-merge actions", async () => {
const client = {
checkoutGithubSetAutoMerge: vi.fn(async () => ({
expect(setAutoMerge).toHaveBeenCalledWith(cwd, {
enabled: true,
method: "squash",
});
expect(
useCheckoutGitActionsStore
.getState()
.getStatus({ serverId, cwd, actionId: "enable-pr-auto-merge-squash" }),
).toBe("success");
});
it(`disables PR auto-merge through the ${rpc.label} RPC`, async () => {
const setAutoMerge = vi.fn(async () => ({
enabled: false,
success: true,
error: null,
})),
};
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: null,
features: { checkoutGithubSetAutoMerge: true },
}));
const client = { [rpc.method]: setAutoMerge };
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
useSessionStore.getState().updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: null,
features: { [rpc.feature]: true },
});
await useCheckoutGitActionsStore.getState().disablePrAutoMerge({ serverId, cwd });
expect(setAutoMerge).toHaveBeenCalledWith(cwd, { enabled: false });
expect(
useCheckoutGitActionsStore
.getState()
.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }),
).toBe("success");
});
await useCheckoutGitActionsStore.getState().disablePrAutoMerge({ serverId, cwd });
expect(client.checkoutGithubSetAutoMerge).toHaveBeenCalledWith(cwd, { enabled: false });
expect(
useCheckoutGitActionsStore
.getState()
.getStatus({ serverId, cwd, actionId: "disable-pr-auto-merge" }),
).toBe("success");
});
}
it("does not call PR auto-merge RPCs when the daemon lacks the feature flag", async () => {
const client = {
checkoutGithubSetAutoMerge: vi.fn(async () => ({
checkoutForgeSetAutoMerge: vi.fn(async () => ({
enabled: true,
success: true,
error: null,
@@ -258,9 +269,9 @@ describe("checkout-git-actions-store", () => {
await expect(
useCheckoutGitActionsStore.getState().enablePrAutoMerge({ serverId, cwd, method: "merge" }),
).rejects.toThrow("Update the host to use GitHub auto-merge actions.");
).rejects.toThrow("Update the host to use auto-merge actions.");
expect(client.checkoutGithubSetAutoMerge).not.toHaveBeenCalled();
expect(client.checkoutForgeSetAutoMerge).not.toHaveBeenCalled();
expect(
useCheckoutGitActionsStore
.getState()

View File

@@ -42,11 +42,19 @@ function resolveClient(serverId: string) {
return client;
}
function assertGitHubAutoMergeActionsSupported(serverId: string) {
type AutoMergeActionsRpc = "forge" | "github";
function resolveAutoMergeActionsRpc(serverId: string): AutoMergeActionsRpc {
const session = useSessionStore.getState().sessions[serverId];
if (session?.serverInfo?.features?.checkoutGithubSetAutoMerge !== true) {
throw new Error("Update the host to use GitHub auto-merge actions.");
if (session?.serverInfo?.features?.checkoutForgeSetAutoMerge === true) {
return "forge";
}
// COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once
// all supported clients use checkout.forge.set_auto_merge.*.
if (session?.serverInfo?.features?.checkoutGithubSetAutoMerge === true) {
return "github";
}
throw new Error("Update the host to use auto-merge actions.");
}
function setStatus(
@@ -281,14 +289,19 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
},
enablePrAutoMerge: async ({ serverId, cwd, method }) => {
assertGitHubAutoMergeActionsSupported(serverId);
const rpc = resolveAutoMergeActionsRpc(serverId);
await runCheckoutAction({
serverId,
cwd,
actionId: `enable-pr-auto-merge-${method}`,
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: true, method });
// COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once
// all supported clients use checkout.forge.set_auto_merge.*.
const payload =
rpc === "forge"
? await client.checkoutForgeSetAutoMerge(cwd, { enabled: true, method })
: await client.checkoutGithubSetAutoMerge(cwd, { enabled: true, method });
if (payload.error) {
throw new Error(payload.error.message);
}
@@ -297,14 +310,19 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
},
disablePrAutoMerge: async ({ serverId, cwd }) => {
assertGitHubAutoMergeActionsSupported(serverId);
const rpc = resolveAutoMergeActionsRpc(serverId);
await runCheckoutAction({
serverId,
cwd,
actionId: "disable-pr-auto-merge",
run: async () => {
const client = resolveClient(serverId);
const payload = await client.checkoutGithubSetAutoMerge(cwd, { enabled: false });
// COMPAT(githubAutoMergeRpc): added in v0.1.106, remove after 2026-12-28 once
// all supported clients use checkout.forge.set_auto_merge.*.
const payload =
rpc === "forge"
? await client.checkoutForgeSetAutoMerge(cwd, { enabled: false })
: await client.checkoutGithubSetAutoMerge(cwd, { enabled: false });
if (payload.error) {
throw new Error(payload.error.message);
}

View File

@@ -1,11 +1,12 @@
// @vitest-environment jsdom
// The review draft store persists through AsyncStorage's web shim, which needs window.
import "@/test/window-local-storage";
import { QueryClient } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CheckoutStatusUpdate } from "@getpaseo/protocol/messages";
import { checkoutPrStatusQueryKey, checkoutStatusQueryKey } from "@/git/query-keys";
import { prPaneTimelineQueryKey } from "@/git/pull-request-panel/query-keys";
import {
prPanePipelineQueryKey,
prPaneTimelineQueryKey,
} from "@/git/pull-request-panel/query-keys";
import { resetReviewDraftStore, useReviewDraftStore } from "@/review/store";
import {
applyCheckoutStatusUpdateFromEvent,
@@ -14,6 +15,14 @@ import {
fetchCheckoutStatus,
} from "./checkout-status-cache";
vi.mock("@react-native-async-storage/async-storage", () => ({
default: {
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined),
removeItem: vi.fn(async () => undefined),
},
}));
const serverId = "server-1";
const cwd = "/repo";
@@ -41,6 +50,7 @@ function prStatus(overrides: Partial<CheckoutPrStatusPayload> = {}): CheckoutPrS
return {
cwd,
status: {
forge: "github",
url: "https://github.com/getpaseo/paseo/pull/42",
title: "My PR",
state: "open",
@@ -54,6 +64,8 @@ function prStatus(overrides: Partial<CheckoutPrStatusPayload> = {}): CheckoutPrS
reviewDecision: null,
},
githubFeaturesEnabled: true,
authState: "authenticated",
forge: "github",
error: null,
requestId: "pr-status-1",
...overrides,
@@ -62,7 +74,7 @@ function prStatus(overrides: Partial<CheckoutPrStatusPayload> = {}): CheckoutPrS
function checkoutStatusUpdate(
payload: CheckoutStatusPayload,
extraPrStatus?: CheckoutPrStatusPayload,
extraPrStatus?: NonNullable<CheckoutStatusUpdate["payload"]["prStatus"]>,
): CheckoutStatusUpdate {
return {
type: "checkout_status_update",
@@ -140,6 +152,29 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
expect(queryClient.getQueryData(checkoutPrStatusQueryKey(serverId, otherCwd))).toBeUndefined();
});
it("normalizes legacy PR auth state at the pushed-cache boundary", () => {
const queryClient = createQueryClient();
const { authState: _authState, ...legacyPrStatus } = prStatus({
githubFeaturesEnabled: false,
});
applyCheckoutStatusUpdateFromEvent({
queryClient,
serverId,
message: checkoutStatusUpdate(checkoutStatus(), legacyPrStatus),
});
expect(
queryClient.getQueryData<CheckoutPrStatusPayload>(checkoutPrStatusQueryKey(serverId, cwd))
?.authState,
).toBe("unauthenticated");
expect(
queryClient.getQueryData<CheckoutStatusUpdate["payload"]>(
checkoutStatusQueryKey(serverId, cwd),
)?.prStatus?.authState,
).toBe("unauthenticated");
});
it("expires a manual diff-mode override when the pushed dirty state flipped", () => {
const queryClient = createQueryClient();
setDiffModeOverride(false);
@@ -166,14 +201,21 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
expect(useReviewDraftStore.getState().diffModeOverrides["review:scope"]).toBeDefined();
});
it("invalidates the PR timeline when the prStatus changes, ignoring the volatile requestId", () => {
it("invalidates PR detail queries when the prStatus changes, ignoring the volatile requestId", () => {
const queryClient = createQueryClient();
queryClient.setQueryData(
checkoutPrStatusQueryKey(serverId, cwd),
prStatus({ requestId: "pr-v1" }),
);
const timelineKey = prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 });
const pipelineKey = prPanePipelineQueryKey({
serverId,
cwd,
pipelineId: 9001,
changeRequestNumber: 1,
});
queryClient.setQueryData(timelineKey, { items: [] });
queryClient.setQueryData(pipelineKey, { stages: [] });
applyCheckoutStatusUpdateFromEvent({
queryClient,
@@ -181,6 +223,7 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
message: checkoutStatusUpdate(checkoutStatus(), prStatus({ requestId: "pr-v2" })),
});
expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(false);
applyCheckoutStatusUpdateFromEvent({
queryClient,
@@ -194,14 +237,29 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
),
});
expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(true);
});
it("invalidates the PR timeline on the first prStatus emission, scoped to its cwd", () => {
it("invalidates PR detail queries on the first prStatus emission, scoped to its cwd", () => {
const queryClient = createQueryClient();
const timelineKey = prPaneTimelineQueryKey({ serverId, cwd, prNumber: 42 });
const otherTimelineKey = prPaneTimelineQueryKey({ serverId, cwd: "/repo2", prNumber: 42 });
const pipelineKey = prPanePipelineQueryKey({
serverId,
cwd,
pipelineId: 9001,
changeRequestNumber: 1,
});
const otherPipelineKey = prPanePipelineQueryKey({
serverId,
cwd: "/repo2",
pipelineId: 9001,
changeRequestNumber: 1,
});
queryClient.setQueryData(timelineKey, { items: [] });
queryClient.setQueryData(otherTimelineKey, { items: [] });
queryClient.setQueryData(pipelineKey, { stages: [] });
queryClient.setQueryData(otherPipelineKey, { stages: [] });
applyCheckoutStatusUpdateFromEvent({
queryClient,
@@ -211,5 +269,7 @@ describe("applyCheckoutStatusUpdateFromEvent", () => {
expect(queryClient.getQueryState(timelineKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(otherTimelineKey)?.isInvalidated).toBe(false);
expect(queryClient.getQueryState(pipelineKey)?.isInvalidated).toBe(true);
expect(queryClient.getQueryState(otherPipelineKey)?.isInvalidated).toBe(false);
});
});

View File

@@ -6,10 +6,11 @@ import {
checkoutStatusQueryKey,
invalidatePrPaneTimelineForCheckout,
} from "@/git/query-keys";
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
import { expireStaleDiffModeOverrides } from "@/review/store";
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
export type CheckoutPrStatusPayload = NonNullable<CheckoutStatusUpdate["payload"]["prStatus"]>;
export type { CheckoutPrStatusPayload } from "@/git/pr-status";
export interface CheckoutStatusClient {
getCheckoutStatus: (cwd: string) => Promise<CheckoutStatusPayload>;
@@ -43,14 +44,17 @@ export function applyCheckoutStatusUpdateFromEvent({
message: CheckoutStatusUpdate;
}): void {
const { payload } = message;
queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), payload);
const prStatus = payload.prStatus
? normalizeCheckoutPrStatusPayload(payload.prStatus)
: undefined;
const cachePayload = prStatus ? { ...payload, prStatus } : payload;
queryClient.setQueryData(checkoutStatusQueryKey(serverId, payload.cwd), cachePayload);
expireStaleDiffModeOverrides({
serverId,
cwd: payload.cwd,
isDirty: payload.isGit && payload.isDirty,
});
const prStatus = payload.prStatus;
if (!prStatus) {
return;
}

View File

@@ -0,0 +1,199 @@
import type { ComponentType } from "react";
import type { ReactNode } from "react";
import type { z } from "zod";
import type { CheckoutPrMergeMethod, CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
import type { Theme } from "@/styles/theme";
import type { Forge } from "@/git/forge";
import type { PrPaneCheck } from "@/git/pull-request-panel/data";
export interface ForgeIconProps {
size?: number;
color?: string;
}
export type ForgeIconComponent = ComponentType<ForgeIconProps>;
export interface ForgeBrandColor {
light: string;
dark: string;
}
export type ForgeIconColorMapping = (theme: Theme) => { color: string };
export interface ForgeUrlGrammar {
/** Path infix before the branch in a tree URL, e.g. "/tree/", "/-/tree/". */
treeInfix: string;
/** Path infix before the branch in a blob URL, e.g. "/blob/", "/-/blob/". */
blobInfix: string;
/** Line/range anchor, e.g. github "#L1-L5" vs gitlab "#L1-5". */
lineAnchor: (start: number, end?: number) => string;
}
/** Line/range anchor shared by every GitHub-family forge (github, gitea, forgejo, codeberg). */
export function GITHUB_LINE_ANCHOR(start: number, end?: number): string {
return end && end > start ? `#L${start}-L${end}` : `#L${start}`;
}
/** URL grammar shared by the gitea-family forges (gitea, forgejo, codeberg). */
export const GITEA_FAMILY_URL_GRAMMAR: ForgeUrlGrammar = {
treeInfix: "/src/branch/",
blobInfix: "/src/branch/",
lineAnchor: GITHUB_LINE_ANCHOR,
};
export type ForgeSpecificEnvelope = { forge: string } & Record<string, unknown>;
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
export type LegacyGithubMergeFacts = NonNullable<CheckoutPrStatus["github"]>;
export interface MergeCapability {
/** The change request can be merged directly right now. */
directMergeReady: boolean;
/** Auto-merge can be enabled right now. */
canEnableAutoMerge: boolean;
/** Auto-merge is already enabled on the change request. */
autoMergeEnabled: boolean;
/** The viewer is allowed to disable the active auto-merge. */
canDisableAutoMerge: boolean;
/** A merge queue is blocking both direct merge and auto-merge. */
mergeBlockedByQueue: boolean;
/** Merge methods the forge permits for this change request. */
allowedMethods: CheckoutPrMergeMethod[];
/** The forge's preferred/default merge method, if it reports one. */
preferredMethod: CheckoutPrMergeMethod | null;
}
export interface PaneChecksSlotContext {
serverId: string;
cwd: string;
/** Change request (PR/MR) number, so a section can address its head pipeline. */
changeRequestNumber: number;
open: boolean;
onToggle: () => void;
/** Whether the daemon advertises pluggable forge support (gates rich sections). */
enabled: boolean;
/**
* Whether the daemon can serve forge check/pipeline details over the
* forge-routed RPC. Single source of truth for gating on-demand detail
* fetches so a section never reaches an RPC the daemon lacks.
*/
canFetchCheckDetails: boolean;
}
export interface PaneNativeContribution {
guard: (facts: unknown) => boolean;
renderHeaderMeta: (facts: unknown) => ReactNode;
renderChecksSection: (facts: unknown, ctx: PaneChecksSlotContext) => ReactNode;
}
export interface NativeFallbackCheckEntry {
contribute: (facts: unknown, status: CheckoutPrStatus, forge: Forge) => PrPaneCheck | null;
}
interface TypedPaneContribution<TFacts extends ForgeSpecificEnvelope> {
renderHeaderMeta?: (facts: TFacts) => ReactNode;
renderChecksSection?: (facts: TFacts, ctx: PaneChecksSlotContext) => ReactNode;
}
interface TypedNativeFallbackCheck<TFacts extends ForgeSpecificEnvelope> {
contribute: (facts: TFacts, status: CheckoutPrStatus, forge: Forge) => PrPaneCheck | null;
}
export interface ClientForgeFactsEntry<TFacts extends ForgeSpecificEnvelope> {
readonly family: TFacts["forge"];
parse: (facts: unknown) => TFacts | null;
deriveMergeCapability: (facts: unknown) => MergeCapability | null;
readonly nativeFallbackChecks: readonly NativeFallbackCheckEntry[];
}
export interface ClientForgeFactsRegistration<TFacts extends ForgeSpecificEnvelope> {
readonly family: TFacts["forge"];
readonly schema: z.ZodType<TFacts>;
readonly deriveMergeCapability?: (facts: TFacts) => MergeCapability;
readonly nativeFallbackChecks?: readonly NativeFallbackCheckEntry[];
}
function parseFacts<TFacts extends ForgeSpecificEnvelope>(
schema: z.ZodType<TFacts>,
facts: unknown,
): TFacts | null {
if (!facts) {
return null;
}
const result = schema.safeParse(facts);
return result.success ? result.data : null;
}
export function defineNativeFallbackCheck<TFacts extends ForgeSpecificEnvelope>(
schema: z.ZodType<TFacts>,
contribution: TypedNativeFallbackCheck<TFacts>,
): NativeFallbackCheckEntry {
return {
contribute: (facts, status, forge) => {
const parsed = parseFacts(schema, facts);
return parsed ? contribution.contribute(parsed, status, forge) : null;
},
};
}
export function definePaneContribution<TFacts extends ForgeSpecificEnvelope>(
schema: z.ZodType<TFacts>,
contribution: TypedPaneContribution<TFacts>,
): PaneNativeContribution {
return {
guard: (facts) => parseFacts(schema, facts) !== null,
renderHeaderMeta: (facts) => {
const parsed = parseFacts(schema, facts);
return parsed && contribution.renderHeaderMeta ? contribution.renderHeaderMeta(parsed) : null;
},
renderChecksSection: (facts, ctx) => {
const parsed = parseFacts(schema, facts);
return parsed && contribution.renderChecksSection
? contribution.renderChecksSection(parsed, ctx)
: null;
},
};
}
export function defineForgeFacts<TFacts extends ForgeSpecificEnvelope>(
registration: ClientForgeFactsRegistration<TFacts>,
): ClientForgeFactsEntry<TFacts> {
return {
family: registration.family,
parse: (facts) => parseFacts(registration.schema, facts),
deriveMergeCapability: (facts) => {
if (!registration.deriveMergeCapability) {
return null;
}
const parsed = parseFacts(registration.schema, facts);
return parsed ? registration.deriveMergeCapability(parsed) : null;
},
nativeFallbackChecks: registration.nativeFallbackChecks ?? [],
};
}
/**
* Pure logic half of a forge: URL grammar and runtime-facts derivations. Kept
* free of any React/React-Native imports so logic consumers (URL builders,
* merge-capability, native-check fallbacks) — and the Node-based e2e harness
* that transitively imports them — never pull the client's rendering stack.
*/
export interface ClientForgeLogicModule<
TFacts extends ForgeSpecificEnvelope = ForgeSpecificEnvelope,
> {
readonly id: string;
readonly urlGrammar?: ForgeUrlGrammar;
readonly facts?: ClientForgeFactsEntry<TFacts>;
}
/**
* View half of a forge: the brand mark, brand color, and PR-pane render
* contributions. Imported only by rendering code (icon lookup, the PR pane).
*/
export interface ClientForgeViewModule {
readonly id: string;
readonly icon: ForgeIconComponent;
readonly brandColor?: ForgeBrandColor | null;
readonly paneContributions?: readonly PaneNativeContribution[];
}

View File

@@ -3,14 +3,16 @@ import { Pressable, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { CheckoutCommit } from "@getpaseo/protocol/messages";
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
import { formatTimeAgo } from "@/utils/time";
import { dotStyles } from "./shared";
interface CommitRowProps {
commit: CheckoutCommit;
now: Date;
onCommitPress: (sha: string) => void;
}
export const CommitRow = memo(function CommitRow({ commit, onCommitPress }: CommitRowProps) {
export const CommitRow = memo(function CommitRow({ commit, now, onCommitPress }: CommitRowProps) {
const handlePress = useCallback(() => {
onCommitPress(commit.sha);
}, [commit.sha, onCommitPress]);
@@ -30,6 +32,7 @@ export const CommitRow = memo(function CommitRow({ commit, onCommitPress }: Comm
<Text style={styles.subject} numberOfLines={1}>
{commit.subject}
</Text>
<Text style={styles.timestamp}>{formatTimeAgo(new Date(commit.authorDate), now)}</Text>
<View style={styles.caret}>
<ThemedChevron size={14} uniProps={chevronColorMapping} />
</View>
@@ -58,6 +61,11 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
timestamp: {
flexShrink: 0,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
caret: {
width: 16,
height: 16,

View File

@@ -1,7 +1,8 @@
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
import { useCheckoutCommitsQuery, type CheckoutCommitsQueryResult } from "@/git/use-commits-query";
import { ThemedChevron, chevronColorMapping } from "@/git/themed-chevron";
@@ -30,6 +31,8 @@ function CommitsSectionSkeleton() {
<View style={styles.skeletonDot} />
<View style={styles.skeletonSha} />
<View style={styles.skeletonSubject} />
<View style={styles.skeletonTimestamp} />
<View style={styles.skeletonCaret} />
</View>
))}
</View>
@@ -38,9 +41,11 @@ function CommitsSectionSkeleton() {
function CommitsSectionContent({
query,
now,
onCommitPress,
}: {
query: Exclude<CheckoutCommitsQueryResult, { status: "unsupported" }>;
now: Date;
onCommitPress: (sha: string) => void;
}) {
const { t } = useTranslation();
@@ -64,7 +69,7 @@ function CommitsSectionContent({
return (
<View style={styles.list}>
{query.data.commits.map((commit) => (
<CommitRow key={commit.sha} commit={commit} onCommitPress={onCommitPress} />
<CommitRow key={commit.sha} commit={commit} now={now} onCommitPress={onCommitPress} />
))}
</View>
);
@@ -73,7 +78,10 @@ function CommitsSectionContent({
export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionProps) {
const { t } = useTranslation();
const { preferences, updatePreferences } = useChangesPreferences();
const isPanelActive = useRetainedPanelActive();
const collapsed = preferences.commitsCollapsed;
const [now, setNow] = useState(() => new Date());
const displayNow = useMemo(() => (isPanelActive ? new Date() : now), [isPanelActive, now]);
const query = useCheckoutCommitsQuery({
serverId,
cwd,
@@ -81,9 +89,20 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP
});
const handleToggleSection = useCallback(() => {
if (collapsed) {
setNow(new Date());
}
void updatePreferences({ commitsCollapsed: !collapsed });
}, [collapsed, updatePreferences]);
useEffect(() => {
if (collapsed || !isPanelActive) {
return;
}
const interval = setInterval(() => setNow(new Date()), 10_000);
return () => clearInterval(interval);
}, [collapsed, isPanelActive]);
const headerChevronStyle = useMemo(
() => [styles.headerChevron, !collapsed && styles.headerChevronExpanded],
[collapsed],
@@ -125,7 +144,9 @@ export function CommitsSection({ serverId, cwd, onCommitPress }: CommitsSectionP
<Text style={styles.legendText}>{t("workspace.git.diff.commits.legendRemote")}</Text>
</View>
</Pressable>
{collapsed ? null : <CommitsSectionContent query={query} onCommitPress={onCommitPress} />}
{collapsed ? null : (
<CommitsSectionContent query={query} now={displayNow} onCommitPress={onCommitPress} />
)}
</View>
);
}
@@ -195,14 +216,15 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[2],
},
skeleton: {
paddingHorizontal: theme.spacing[2],
paddingBottom: theme.spacing[2],
paddingBottom: theme.spacing[1],
gap: theme.spacing[2],
},
skeletonRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
minHeight: 20,
},
skeletonDot: {
@@ -218,9 +240,22 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
},
skeletonSubject: {
width: "55%",
flex: 1,
minWidth: 0,
height: 12,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
},
skeletonTimestamp: {
width: 40,
height: 10,
borderRadius: theme.borderRadius.sm,
backgroundColor: theme.colors.surface2,
flexShrink: 0,
},
skeletonCaret: {
width: 16,
height: 16,
flexShrink: 0,
},
}));

View File

@@ -9,6 +9,7 @@ import {
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { DiffStat } from "@/components/diff-stat";
import {
View,
@@ -83,11 +84,13 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { GitHubIcon } from "@/components/icons/github-icon";
import { lineNumberGutterWidth } from "@/components/code-insets";
import { GitActionsSplitButton } from "@/git/actions-split-button";
import { BranchSwitcher } from "@/components/branch-switcher";
import { useGitActions } from "@/git/use-actions";
import { buildForgeSignInCommand, getForgePresentation, type Forge } from "@/git/forge";
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import type { ForgeAuthState } from "@getpaseo/protocol/messages";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { useToast } from "@/contexts/toast-context";
import { useSessionStore } from "@/stores/session-store";
@@ -1256,7 +1259,6 @@ const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
const ThemedDownload = withUnistyles(Download);
const ThemedUpload = withUnistyles(Upload);
const ThemedArrowDownUp = withUnistyles(ArrowDownUp);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const ThemedGitMerge = withUnistyles(GitMerge);
const ThemedRefreshCcw = withUnistyles(RefreshCcw);
const ThemedArchive = withUnistyles(Archive);
@@ -1403,6 +1405,7 @@ function DiffFilesToolbar({
}
interface DiffOptionsMenuProps {
brand: string;
hideWhitespace: boolean;
isMobile: boolean;
isRefreshing: boolean;
@@ -1415,6 +1418,7 @@ interface DiffOptionsMenuProps {
}
function DiffOptionsMenu({
brand,
hideWhitespace,
isMobile,
isRefreshing,
@@ -1489,7 +1493,11 @@ function DiffOptionsMenu({
testID="changes-refresh"
onSelect={onRefresh}
>
{isRefreshing ? t("workspace.git.diff.refreshing") : t("workspace.git.diff.refresh")}
{isRefreshing
? t("workspace.git.diff.refreshing")
: t("workspace.git.diff.refreshState", {
brand,
})}
</DropdownMenuItem>
</>
) : null}
@@ -2078,6 +2086,62 @@ function computePrErrorMessage(
return prPayloadError?.message ?? null;
}
// The precise setup step a workspace needs before its forge features work, or
// null when nothing is actionable (authenticated, or no forge remote at all).
type ForgeSetupAction = "install_cli" | "sign_in" | null;
// Drive the onboarding callout from the forge's auth state so the message names
// the exact next step (install the CLI vs sign in) for whichever forge backs the
// workspace — GitHub included. GitLab additionally requires the host to advertise
// GitLab support, matching the rest of the GitLab UI.
function computeForgeSetupAction(input: {
forge: Forge;
forgeProvidersSupported: boolean;
authState: ForgeAuthState | undefined;
}): ForgeSetupAction {
// A daemon without pluggable forge support can't operate any non-GitHub forge,
// so don't offer a setup action for one it can't drive.
if (input.forge !== "github" && !input.forgeProvidersSupported) {
return null;
}
switch (input.authState) {
case "cli_missing":
return "install_cli";
case "unauthenticated":
return "sign_in";
case "authenticated":
case "no_remote":
case "error":
return null;
default:
return null;
}
}
function parseForgeHost(url: string | null | undefined): string | null {
return url ? (parseGitRemoteLocation(url)?.host ?? null) : null;
}
function buildForgeSetupMessage(input: {
action: Exclude<ForgeSetupAction, null>;
forge: Forge;
host: string | null;
t: TFunction;
}): string {
const { brandLabel, signInCli } = getForgePresentation(input.forge);
// A forge with no known CLI (an unknown/third-party forge rendered neutrally)
// has no install/sign-in command to interpolate — show neutral guidance
// rather than the GitLab-specific callout or a null command.
if (signInCli === null) {
return input.t("workspace.git.forgeSetup.generic", { brand: brandLabel });
}
if (input.action === "install_cli") {
return input.t("workspace.git.forgeSetup.installCli", { cli: signInCli, brand: brandLabel });
}
const command = buildForgeSignInCommand(input.forge, input.host);
return input.t("workspace.git.forgeSetup.signIn", { command, brand: brandLabel });
}
function buildDiffModeTriggerStyle(): PressableStyleFn {
return ({ hovered, pressed, open }) => [
styles.diffModeTrigger,
@@ -2285,11 +2349,36 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
setWorkspaceAttachments,
workspaceAttachmentScopeKey,
]);
const { githubFeaturesEnabled, payloadError: prPayloadError } = useCheckoutPrStatusQuery({
const {
githubFeaturesEnabled,
forge,
authState,
payloadError: prPayloadError,
} = useCheckoutPrStatusQuery({
serverId,
cwd,
enabled: isGit,
});
const forgeProvidersSupported = useSessionStore(
(s) => s.sessions[serverId]?.serverInfo?.features?.forgeProviders === true,
);
const forgeSetupAction = computeForgeSetupAction({
forge,
forgeProvidersSupported,
authState,
});
const forgeSetupMessage = useMemo(
() =>
forgeSetupAction
? buildForgeSetupMessage({
action: forgeSetupAction,
forge,
host: parseForgeHost(status?.remoteUrl),
t,
})
: null,
[forgeSetupAction, forge, status?.remoteUrl, t],
);
const normalizedWorkspaceRoot = useMemo(() => cwd.trim(), [cwd]);
const workspaceStateKey = useMemo(
() =>
@@ -2401,11 +2490,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
pull: <ThemedDownload size={16} uniProps={foregroundMutedIconColorMapping} />,
push: <ThemedUpload size={16} uniProps={foregroundMutedIconColorMapping} />,
pullAndPush: <ThemedArrowDownUp size={16} uniProps={foregroundMutedIconColorMapping} />,
viewPr: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
createPr: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
mergePrSquash: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
mergePrMerge: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
mergePrRebase: <ThemedGitHubIcon size={16} uniProps={foregroundMutedIconColorMapping} />,
merge: <ThemedGitMerge size={16} uniProps={foregroundMutedIconColorMapping} />,
mergeFromBase: <ThemedRefreshCcw size={16} uniProps={foregroundMutedIconColorMapping} />,
archive: <ThemedArchive size={16} uniProps={foregroundMutedIconColorMapping} />,
@@ -2531,6 +2615,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
/>
) : null}
<DiffOptionsMenu
brand={getForgePresentation(forge).brandLabel}
hideWhitespace={changesPreferences.hideWhitespace}
isMobile={isMobile}
isRefreshing={isRefreshing}
@@ -2546,6 +2631,12 @@ export function GitDiffPane({ serverId, workspaceId, cwd, enabled }: GitDiffPane
</View>
) : null}
{forgeSetupMessage ? (
<View style={styles.forgeSetupCallout} testID="forge-setup-callout">
<Text style={styles.forgeSetupCalloutText}>{forgeSetupMessage}</Text>
</View>
) : null}
{prErrorMessage ? <Text style={styles.actionErrorText}>{prErrorMessage}</Text> : null}
<View style={styles.diffContainer}>{bodyContent}</View>
@@ -2653,6 +2744,20 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.xs,
color: theme.colors.destructive,
},
forgeSetupCallout: {
marginHorizontal: theme.spacing[3],
marginBottom: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.md,
backgroundColor: theme.colors.surface1,
},
forgeSetupCalloutText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
diffContainer: {
flex: 1,
minHeight: 0,

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { GitPullRequest } from "lucide-react-native";
import { getForgeBrandColorMapping, getForgeIconComponent } from "./forge-icon";
import { CLIENT_FORGE_VIEW_MODULES } from "./forges/view";
describe("getForgeIconComponent", () => {
it("resolves a dedicated brand icon for every registered forge module", () => {
for (const module of CLIENT_FORGE_VIEW_MODULES) {
expect(getForgeIconComponent(module.id)).toBe(module.icon);
}
});
it("falls back to the generic pull-request glyph for unknown icon kinds", () => {
expect(getForgeIconComponent("some-unknown-forge")).toBe(GitPullRequest);
expect(getForgeIconComponent("")).toBe(GitPullRequest);
});
});
describe("getForgeBrandColorMapping", () => {
it("returns a theme mapping only for forges that declare a brand color", () => {
for (const module of CLIENT_FORGE_VIEW_MODULES) {
const mapping = getForgeBrandColorMapping(module.id);
if (module.brandColor) {
expect(mapping).toBeTypeOf("function");
} else {
expect(mapping).toBeNull();
}
}
});
it("resolves the light or dark brand color from the theme color scheme", () => {
const branded = CLIENT_FORGE_VIEW_MODULES.find((module) => module.brandColor);
expect(branded).toBeDefined();
if (!branded?.brandColor) {
return;
}
const mapping = getForgeBrandColorMapping(branded.id);
expect(mapping?.({ colorScheme: "light" } as never)).toEqual({
color: branded.brandColor.light,
});
expect(mapping?.({ colorScheme: "dark" } as never)).toEqual({
color: branded.brandColor.dark,
});
});
it("returns null for unknown icon kinds", () => {
expect(getForgeBrandColorMapping("some-unknown-forge")).toBeNull();
});
});

View File

@@ -0,0 +1,72 @@
/**
* Brand marks are SVG React components, so they cannot travel over the wire as
* a string. This file derives icon and brand-color lookup tables from
* CLIENT_FORGE_VIEW_MODULES; a new forge ships an icon component in the client
* bundle and references it from its view module. Unknown icon kinds fall back to
* a generic pull-request glyph.
*/
import { withUnistyles } from "react-native-unistyles";
import { GitPullRequest } from "lucide-react-native";
import {
type ForgeBrandColor,
type ForgeIconColorMapping,
type ForgeIconComponent,
} from "@/git/client-forge-module";
import { CLIENT_FORGE_VIEW_MODULES } from "@/git/forges/view";
const FORGE_ICON_BY_KIND = new Map(
CLIENT_FORGE_VIEW_MODULES.map((module) => [module.id, module.icon]),
);
/**
* Raw brand icon component for an `iconKind`, for call sites that style with a
* plain `color` prop. Falls back to a generic pull-request glyph.
*/
export function getForgeIconComponent(iconKind: string): ForgeIconComponent {
return FORGE_ICON_BY_KIND.get(iconKind) ?? GitPullRequest;
}
const THEMED_ICON_BY_KIND = Object.fromEntries(
CLIENT_FORGE_VIEW_MODULES.map((module) => [module.id, withUnistyles(module.icon)]),
);
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
/** Theme-driven color mapping, e.g. `(theme) => ({ color: theme.colors.foregroundMuted })`. */
export type { ForgeIconColorMapping };
function brandColorMapping(colors: ForgeBrandColor): ForgeIconColorMapping {
return (theme) => ({ color: theme.colorScheme === "light" ? colors.light : colors.dark });
}
const BRAND_COLOR_MAPPING_BY_KIND = new Map(
CLIENT_FORGE_VIEW_MODULES.flatMap((module) =>
module.brandColor ? [[module.id, brandColorMapping(module.brandColor)] as const] : [],
),
);
/**
* The forge's brand color mapping, or null for forges with no dedicated brand
* color (github renders in a neutral tone; an unknown forge has none). Call
* sites pair this with {@link ForgeBrandIcon}: use it to tint the icon, or to
* decide whether to show a brand badge at all.
*/
export function getForgeBrandColorMapping(iconKind: string): ForgeIconColorMapping | null {
return BRAND_COLOR_MAPPING_BY_KIND.get(iconKind) ?? null;
}
/**
* Themed brand icon for an `iconKind`, for call sites that color via a unistyles
* mapping. Falls back to a generic pull-request glyph for unknown forges.
*/
export function ForgeBrandIcon({
iconKind,
size,
uniProps,
}: {
iconKind: string;
size: number;
uniProps: ForgeIconColorMapping;
}) {
const ThemedIcon = THEMED_ICON_BY_KIND[iconKind] ?? ThemedGitPullRequest;
return <ThemedIcon size={size} uniProps={uniProps} />;
}

View File

@@ -0,0 +1,205 @@
import { describe, expect, it } from "vitest";
import { buildForgeBlobUrl, buildForgeBranchTreeUrl, hasForgeWebUrls } from "./forge-url";
describe("buildForgeBranchTreeUrl", () => {
it("builds a branch-specific GitHub tree URL", () => {
expect(
buildForgeBranchTreeUrl("github", {
remoteUrl: "git@github.com:acme/repo.git",
branch: "feature/workspace-button",
}),
).toBe("https://github.com/acme/repo/tree/feature/workspace-button");
});
it("encodes reserved branch characters while preserving slash-separated branch names", () => {
expect(
buildForgeBranchTreeUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "feature/ship #42",
}),
).toBe("https://github.com/acme/repo/tree/feature/ship%20%2342");
});
it("uses the GitLab /-/tree/ infix and supports subgroups", () => {
expect(
buildForgeBranchTreeUrl("gitlab", {
remoteUrl: "https://gitlab.com/group/sub/repo.git",
branch: "main",
}),
).toBe("https://gitlab.com/group/sub/repo/-/tree/main");
});
it("uses the Gitea-family /src/branch/ infix", () => {
expect(
buildForgeBranchTreeUrl("gitea", {
remoteUrl: "https://gitea.com/acme/repo.git",
branch: "main",
}),
).toBe("https://gitea.com/acme/repo/src/branch/main");
expect(
buildForgeBranchTreeUrl("codeberg", {
remoteUrl: "https://codeberg.org/acme/repo.git",
branch: "main",
}),
).toBe("https://codeberg.org/acme/repo/src/branch/main");
});
it("returns null when the current branch is unavailable", () => {
expect(
buildForgeBranchTreeUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "HEAD",
}),
).toBeNull();
});
it("returns null for a forge with no known URL grammar", () => {
expect(
buildForgeBranchTreeUrl("bitbucket", {
remoteUrl: "https://bitbucket.org/acme/repo.git",
branch: "main",
}),
).toBeNull();
});
});
describe("buildForgeBlobUrl", () => {
it("builds a blob URL for a file path", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "git@github.com:acme/repo.git",
branch: "main",
path: "src/index.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts");
});
it("appends a single-line anchor", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12");
});
it("appends a GitHub line range anchor (#L12-L20)", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
lineEnd: 20,
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12-L20");
});
it("uses the GitLab /-/blob/ infix and #L12-20 range anchor", () => {
expect(
buildForgeBlobUrl("gitlab", {
remoteUrl: "https://gitlab.com/group/sub/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
lineEnd: 20,
}),
).toBe("https://gitlab.com/group/sub/repo/-/blob/main/src/index.ts#L12-20");
});
it("uses the Gitea-family /src/branch/ blob path with a #L12-L20 anchor", () => {
expect(
buildForgeBlobUrl("forgejo", {
remoteUrl: "https://codeberg.org/acme/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
lineEnd: 20,
}),
).toBe("https://codeberg.org/acme/repo/src/branch/main/src/index.ts#L12-L20");
});
it("derives the web host from a self-hosted remote (GitHub Enterprise)", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "git@github.acme.internal:team/repo.git",
branch: "main",
path: "src/index.ts",
}),
).toBe("https://github.acme.internal/team/repo/blob/main/src/index.ts");
});
it("canonicalizes the github.com SSH-alias host to the web host", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "ssh://git@ssh.github.com/acme/repo.git",
branch: "main",
path: "src/index.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts");
});
it("strips leading slashes and encodes path segments", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "/src/a b/c#d.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/src/a%20b/c%23d.ts");
});
it("normalizes harmless dot segments in the blob path", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "./src/../index.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/index.ts");
});
it("returns null for blob paths that escape above the repo root", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "../outside.ts",
}),
).toBeNull();
});
it("returns null when the path is missing", () => {
expect(
buildForgeBlobUrl("github", {
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "",
}),
).toBeNull();
});
it("returns null for a forge with no known URL grammar", () => {
expect(
buildForgeBlobUrl("bitbucket", {
remoteUrl: "https://bitbucket.org/acme/repo.git",
branch: "main",
path: "src/index.ts",
}),
).toBeNull();
});
});
describe("hasForgeWebUrls", () => {
it("is true for forges with a known URL grammar", () => {
for (const forge of ["github", "gitlab", "gitea", "forgejo", "codeberg"]) {
expect(hasForgeWebUrls(forge)).toBe(true);
}
});
it("is false for an unknown forge", () => {
expect(hasForgeWebUrls("bitbucket")).toBe(false);
});
});

View File

@@ -0,0 +1,127 @@
/**
* Forge-neutral web URL builders for "Open on <forge>" actions (a file blob or a
* branch tree). The host comes from the workspace remote — not a hardcoded
* cloud host — so self-hosted and Enterprise instances link correctly. Each
* forge contributes a small URL grammar (the path infixes and line-anchor
* format); an unknown forge has no grammar and yields null, so the action is
* simply absent rather than wrong.
*
* URL grammar lives on each client forge module. The repo identity and host
* both ride the manifest's `cloudHosts` only to canonicalize a cloud SSH alias
* (e.g. ssh.github.com -> github.com); a self-hosted host is used as-is.
*/
import { getForgeDefinition } from "@getpaseo/protocol/forge-manifest";
import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import { getClientForgeLogicModule } from "@/git/forges";
export interface ForgeBlobUrlInput {
remoteUrl: string | null | undefined;
branch: string | null | undefined;
path: string | null | undefined;
lineStart?: number;
lineEnd?: number;
}
export interface ForgeBranchTreeUrlInput {
remoteUrl: string | null | undefined;
branch: string | null | undefined;
}
interface ForgeWebLocation {
host: string;
repo: string;
}
/**
* Web host + repo path from a remote. The host is the remote's host, except a
* cloud SSH alias (a non-first entry in the forge's `cloudHosts`, e.g.
* ssh.github.com) is canonicalized to the forge's web host (`cloudHosts[0]`).
* Self-hosted hosts are returned untouched. The repo path supports nested
* groups (e.g. GitLab subgroups) since it is the full remote path.
*/
function isValidRepoPath(path: string): boolean {
const segments = path.split("/").filter((segment) => segment.length > 0);
if (segments.length === 0) {
return false;
}
return !segments.includes("..");
}
function resolveForgeWebLocation(
forge: string,
remoteUrl: string | null | undefined,
): ForgeWebLocation | null {
if (!remoteUrl) {
return null;
}
const location = parseGitRemoteLocation(remoteUrl);
if (!location || !isValidRepoPath(location.path)) {
return null;
}
const cloudHosts = getForgeDefinition(forge)?.cloudHosts;
const webHost =
cloudHosts && cloudHosts.length > 0 && cloudHosts.map(normalizeHost).includes(location.host)
? normalizeHost(cloudHosts[0])
: location.host;
return { host: webHost, repo: location.path };
}
function encodeBranch(branch: string): string {
return branch.split("/").map(encodeURIComponent).join("/");
}
function normalizeBlobPath(path: string | null | undefined): string | null {
const segments: string[] = [];
const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, "");
if (!trimmed) {
return null;
}
for (const segment of trimmed.split("/")) {
if (!segment || segment === ".") {
continue;
}
if (segment === "..") {
if (segments.length === 0) {
return null;
}
segments.pop();
continue;
}
segments.push(segment);
}
return segments.join("/") || null;
}
export function buildForgeBranchTreeUrl(
forge: string,
input: ForgeBranchTreeUrlInput,
): string | null {
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
const location = resolveForgeWebLocation(forge, input.remoteUrl);
const branch = input.branch?.trim();
if (!grammar || !location || !branch || branch === "HEAD") {
return null;
}
return `https://${location.host}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`;
}
export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null {
const grammar = getClientForgeLogicModule(forge)?.urlGrammar;
const location = resolveForgeWebLocation(forge, input.remoteUrl);
const branch = input.branch?.trim();
const filePath = normalizeBlobPath(input.path);
if (!grammar || !location || !branch || branch === "HEAD" || !filePath) {
return null;
}
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
let url = `https://${location.host}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`;
if (input.lineStart && input.lineStart > 0) {
url += grammar.lineAnchor(input.lineStart, input.lineEnd);
}
return url;
}
/** Whether the forge has web URL builders (i.e. a known URL grammar). */
export function hasForgeWebUrls(forge: string): boolean {
return getClientForgeLogicModule(forge)?.urlGrammar !== undefined;
}

View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import {
buildForgeSignInCommand,
forgeFromRemoteUrl,
getForgePresentation,
normalizeForge,
} from "./forge";
describe("normalizeForge", () => {
it("maps the gitlab discriminant to gitlab", () => {
expect(normalizeForge("gitlab")).toBe("gitlab");
});
it("keeps any non-empty forge id and defaults only absent values to github", () => {
expect(normalizeForge("github")).toBe("github");
expect(normalizeForge("gitea")).toBe("gitea");
expect(normalizeForge("forgejo")).toBe("forgejo");
expect(normalizeForge(undefined)).toBe("github");
expect(normalizeForge(null)).toBe("github");
// An unknown forge id is preserved (rendered neutrally), not collapsed to
// GitHub, so a newer daemon's forge degrades gracefully on an older client.
expect(normalizeForge("bitbucket")).toBe("bitbucket");
});
});
describe("getForgePresentation", () => {
it("keeps GitHub on the pull-request noun and the # prefix", () => {
const github = getForgePresentation("github");
expect(github.brandLabel).toBe("GitHub");
expect(github.changeRequestAbbrev).toBe("PR");
expect(github.numberPrefix).toBe("#");
expect(github.issueNumberPrefix).toBe("#");
expect(github.changeRequestContext).toBeUndefined();
});
it("relabels GitLab to the merge-request noun and the ! prefix", () => {
const gitlab = getForgePresentation("gitlab");
expect(gitlab.brandLabel).toBe("GitLab");
expect(gitlab.changeRequestAbbrev).toBe("MR");
expect(gitlab.numberPrefix).toBe("!");
expect(gitlab.issueNumberPrefix).toBe("#");
expect(gitlab.changeRequestContext).toBe("mr");
});
it("presents Gitea and Forgejo with GitHub nouns and the tea CLI", () => {
expect(getForgePresentation("gitea")).toMatchObject({
forge: "gitea",
icon: "gitea",
brandLabel: "Gitea",
changeRequestAbbrev: "PR",
numberPrefix: "#",
issueNumberPrefix: "#",
signInCli: "tea",
});
expect(getForgePresentation("forgejo")).toMatchObject({
forge: "forgejo",
icon: "forgejo",
brandLabel: "Forgejo",
changeRequestAbbrev: "PR",
signInCli: "tea",
});
expect(getForgePresentation("codeberg")).toMatchObject({
forge: "codeberg",
icon: "codeberg",
brandLabel: "Codeberg",
changeRequestAbbrev: "PR",
signInCli: "tea",
});
});
});
describe("forgeFromRemoteUrl", () => {
it("detects only public forge hosts that are safe without daemon probing", () => {
expect(forgeFromRemoteUrl("https://codeberg.org/example/repo.git")).toBe("codeberg");
expect(forgeFromRemoteUrl("https://gitlab.com/example/repo.git")).toBe("gitlab");
expect(forgeFromRemoteUrl("https://gitea.com/example/repo.git")).toBe("gitea");
});
it("does not classify self-managed hosts by substring", () => {
expect(forgeFromRemoteUrl("git@gitlab.example.org:example/repo.git")).toBeNull();
expect(forgeFromRemoteUrl("git@forgejo.example.org:example/repo.git")).toBeNull();
expect(forgeFromRemoteUrl("https://notgitlab.example.org/example/repo.git")).toBeNull();
});
});
describe("buildForgeSignInCommand", () => {
it("uses tea login add for both Gitea-family presentations", () => {
expect(buildForgeSignInCommand("gitea", "gitea.com")).toBe("tea login add");
expect(buildForgeSignInCommand("forgejo", "forgejo.example.org")).toBe("tea login add");
expect(buildForgeSignInCommand("codeberg", "codeberg.org")).toBe("tea login add");
});
it("uses plain gh auth login for GitHub (incl. the ssh.github.com endpoint)", () => {
expect(buildForgeSignInCommand("github", "github.com")).toBe("gh auth login");
expect(buildForgeSignInCommand("github", "ssh.github.com")).toBe("gh auth login");
});
it("targets the workspace host for self-hosted GitLab", () => {
expect(buildForgeSignInCommand("gitlab", "gitlab.acme.com")).toBe(
"glab auth login --hostname gitlab.acme.com",
);
});
it("returns no sign-in command for an unknown forge with no known CLI", () => {
expect(buildForgeSignInCommand("bitbucket", "bitbucket.org")).toBeNull();
});
});

View File

@@ -0,0 +1,126 @@
/**
* Forge-neutral presentation layer for the git-hosting UI, derived from the
* shared forge manifest.
*
* The daemon resolves which forge backs a workspace and reports it on the wire
* (`CheckoutPrStatusResponse.payload.forge`). The model keeps the `PullRequest`
* noun in code and types; the PR↔MR relabel, the number prefix, the brand mark,
* and the icon are a UI concern driven by the manifest entry for that id. An
* unknown/absent forge (e.g. a self-hosted forge a newer daemon reports to an
* older client) renders neutrally — never GitHub-branded. A null/empty forge
* still maps to GitHub so old daemons (which never send a forge) render exactly
* as before.
*/
import { FORGE_DEFINITIONS, getForgeDefinitionOrNeutral } from "@getpaseo/protocol/forge-manifest";
import { normalizeHost, parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import type { ForgeAuthState } from "@getpaseo/protocol/messages";
import {
buildForgeBlobUrl,
buildForgeBranchTreeUrl,
hasForgeWebUrls,
type ForgeBlobUrlInput,
type ForgeBranchTreeUrlInput,
} from "@/git/forge-url";
/**
* A forge id. Open by design: any id the daemon reports is valid, and the
* manifest drives presentation with a neutral fallback for unknown ids. Kept as
* a named alias to document intent at call sites.
*/
export type Forge = string;
export function normalizeForge(raw: string | null | undefined): string {
return raw && raw.length > 0 ? raw : "github";
}
export function parseForgeAuthState(value: unknown): ForgeAuthState | undefined {
switch (value) {
case "authenticated":
case "unauthenticated":
case "cli_missing":
case "no_remote":
case "error":
return value;
default:
return undefined;
}
}
export function forgeFromRemoteUrl(remoteUrl: string | null | undefined): string | null {
if (!remoteUrl) {
return null;
}
const host = parseGitRemoteLocation(remoteUrl)?.host;
if (!host) {
return null;
}
const normalized = normalizeHost(host);
for (const definition of FORGE_DEFINITIONS) {
if (definition.cloudHosts?.some((cloudHost) => normalizeHost(cloudHost) === normalized)) {
return definition.id;
}
}
return null;
}
export interface ForgePresentation {
forge: string;
/** Icon key; render sites fall back to a generic git icon for unknown values. */
icon: string;
/** Human brand name, e.g. for "Open on GitLab". */
brandLabel: string;
/** Short change-request noun: "PR" for GitHub, "MR" for GitLab. */
changeRequestAbbrev: string;
/** Full change-request noun: "pull request" for GitHub, "merge request" for GitLab. */
changeRequestNoun: string;
/** Prefix the forge puts before a change-request number: "#" vs "!". */
numberPrefix: string;
/** Prefix the forge puts before an issue number ("#" on every forge so far). */
issueNumberPrefix: string;
/** Auth CLI binary for the install hint, or null for a forge with no Paseo-driven sign-in. */
signInCli: string | null;
/**
* i18next context selecting the change-request vocabulary family for any key
* that carries an `_mr` variant: `t(key, { context: changeRequestContext })`
* resolves `key_mr` for the merge-request family and falls back to the base
* (pull-request) string for undefined or unknown families.
*/
changeRequestContext: "mr" | undefined;
buildBlobUrl: ((input: ForgeBlobUrlInput) => string | null) | null;
buildBranchTreeUrl: ((input: ForgeBranchTreeUrlInput) => string | null) | null;
}
export function getForgePresentation(forge: string): ForgePresentation {
const definition = getForgeDefinitionOrNeutral(forge);
const isMergeRequest = definition.changeRequestAbbrev === "MR";
const hasWebUrls = hasForgeWebUrls(definition.id);
return {
forge: definition.id,
icon: definition.iconKind,
brandLabel: definition.displayName,
changeRequestAbbrev: definition.changeRequestAbbrev,
changeRequestNoun: definition.changeRequestNoun,
numberPrefix: definition.changeRequestNumberPrefix,
issueNumberPrefix: definition.issueNumberPrefix,
signInCli: definition.signIn?.cli ?? null,
changeRequestContext: isMergeRequest ? "mr" : undefined,
buildBlobUrl: hasWebUrls ? (input) => buildForgeBlobUrl(definition.id, input) : null,
buildBranchTreeUrl: hasWebUrls
? (input) => buildForgeBranchTreeUrl(definition.id, input)
: null,
};
}
export function buildForgeSignInCommand(forge: string, host: string | null): string | null {
const signIn = getForgeDefinitionOrNeutral(forge).signIn;
if (!signIn) {
return null;
}
// A forge that needs a --hostname only gets it when a host is known; forges
// whose command already targets the right API host (e.g. github's plain
// `gh auth login`, which must NOT receive ssh.github.com) omit hostnameFlag.
if (signIn.hostnameFlag && host) {
return `${signIn.command} ${signIn.hostnameFlag} ${host}`;
}
return signIn.command;
}

View File

@@ -0,0 +1,6 @@
import { GITEA_FAMILY_URL_GRAMMAR, type ClientForgeLogicModule } from "@/git/client-forge-module";
export const codebergForgeLogic = {
id: "codeberg",
urlGrammar: GITEA_FAMILY_URL_GRAMMAR,
} satisfies ClientForgeLogicModule;

View File

@@ -0,0 +1,11 @@
import { CodebergIcon } from "@/components/icons/codeberg-icon";
import type { ClientForgeViewModule } from "@/git/client-forge-module";
export const codebergForgeView = {
id: "codeberg",
icon: CodebergIcon,
brandColor: {
light: "#2185D0",
dark: "#2185D0",
},
} satisfies ClientForgeViewModule;

View File

@@ -0,0 +1,6 @@
import { GITEA_FAMILY_URL_GRAMMAR, type ClientForgeLogicModule } from "@/git/client-forge-module";
export const forgejoForgeLogic = {
id: "forgejo",
urlGrammar: GITEA_FAMILY_URL_GRAMMAR,
} satisfies ClientForgeLogicModule;

View File

@@ -0,0 +1,11 @@
import { ForgejoIcon } from "@/components/icons/forgejo-icon";
import type { ClientForgeViewModule } from "@/git/client-forge-module";
export const forgejoForgeView = {
id: "forgejo",
icon: ForgejoIcon,
brandColor: {
light: "#FB923C",
dark: "#FB923C",
},
} satisfies ClientForgeViewModule;

View File

@@ -0,0 +1,72 @@
import { z } from "zod";
import {
defineForgeFacts,
defineNativeFallbackCheck,
GITEA_FAMILY_URL_GRAMMAR,
type ClientForgeLogicModule,
type MergeCapability,
} from "@/git/client-forge-module";
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import { mapCheckStatus, type CheckStatus } from "@/git/pull-request-panel/check-status";
const GiteaMergeFactsSchema = z
.object({
forge: z.literal("gitea"),
mergeable: z.boolean().optional().default(false),
hasMerged: z.boolean().optional().default(false),
ciStatus: z.string().nullable().optional().default(null),
})
.passthrough();
type GiteaMergeFacts = z.infer<typeof GiteaMergeFactsSchema>;
// forgeSpecific.ciStatus carries Gitea's raw aggregate CI string. Server twin:
// packages/server/src/services/gitea-service.ts (mapGiteaCommitStatus) — "warning"
// and "error" are terminal, non-passing states, but the generic mapCheckStatus
// would show them as pending, so interpret them here where the module owns Gitea
// facts.
function mapGiteaCiStatus(ciStatus: string): CheckStatus {
if (ciStatus === "warning" || ciStatus === "error") {
return "failure";
}
return mapCheckStatus(ciStatus);
}
const GITEA_MERGE_METHODS: CheckoutPrMergeMethod[] = ["merge", "squash", "rebase"];
function deriveGiteaMergeCapability(gitea: GiteaMergeFacts): MergeCapability {
return {
directMergeReady: gitea.mergeable && !gitea.hasMerged,
canEnableAutoMerge: false,
autoMergeEnabled: false,
canDisableAutoMerge: false,
mergeBlockedByQueue: false,
allowedMethods: GITEA_MERGE_METHODS,
preferredMethod: null,
};
}
export const giteaForgeLogic = {
id: "gitea",
urlGrammar: GITEA_FAMILY_URL_GRAMMAR,
facts: defineForgeFacts({
family: "gitea",
schema: GiteaMergeFactsSchema,
deriveMergeCapability: deriveGiteaMergeCapability,
nativeFallbackChecks: [
defineNativeFallbackCheck(GiteaMergeFactsSchema, {
contribute: (facts, status, forge) => {
if (!facts.ciStatus) {
return null;
}
return {
provider: forge,
name: "CI",
status: mapGiteaCiStatus(facts.ciStatus),
url: status.url,
};
},
}),
],
}),
} satisfies ClientForgeLogicModule<GiteaMergeFacts>;

View File

@@ -0,0 +1,11 @@
import { GiteaIcon } from "@/components/icons/gitea-icon";
import type { ClientForgeViewModule } from "@/git/client-forge-module";
export const giteaForgeView = {
id: "gitea",
icon: GiteaIcon,
brandColor: {
light: "#609926",
dark: "#609926",
},
} satisfies ClientForgeViewModule;

View File

@@ -0,0 +1,95 @@
import { z } from "zod";
import {
defineForgeFacts,
GITHUB_LINE_ANCHOR,
type ClientForgeLogicModule,
type MergeCapability,
} from "@/git/client-forge-module";
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
const GithubAutoMergeRequestSchema = z
.object({
enabledAt: z.string().nullable().optional().default(null),
mergeMethod: z.string().nullable().optional().default(null),
enabledBy: z.string().nullable().optional().default(null),
})
.nullable()
.optional()
.default(null);
const GithubRepositoryPolicySchema = z
.object({
autoMergeAllowed: z.boolean().optional().default(false),
mergeCommitAllowed: z.boolean().optional().default(false),
squashMergeAllowed: z.boolean().optional().default(false),
rebaseMergeAllowed: z.boolean().optional().default(false),
viewerDefaultMergeMethod: z.string().nullable().optional().default(null),
})
.optional()
.default({
autoMergeAllowed: false,
mergeCommitAllowed: false,
squashMergeAllowed: false,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: null,
});
const GithubMergeFactsSchema = z
.object({
forge: z.literal("github"),
mergeStateStatus: z.string().nullable().optional().default(null),
autoMergeRequest: GithubAutoMergeRequestSchema,
viewerCanEnableAutoMerge: z.boolean().optional().default(false),
viewerCanDisableAutoMerge: z.boolean().optional().default(false),
viewerCanMergeAsAdmin: z.boolean().optional().default(false),
viewerCanUpdateBranch: z.boolean().optional().default(false),
repository: GithubRepositoryPolicySchema,
isMergeQueueEnabled: z.boolean().optional().default(false),
isInMergeQueue: z.boolean().optional().default(false),
})
.passthrough();
type GithubMergeFacts = z.infer<typeof GithubMergeFactsSchema>;
const GITHUB_DIRECT_MERGE_STATE_ALLOWLIST = new Set(["CLEAN", "HAS_HOOKS"]);
function normalizeGithubMergeMethod(value: string | null): CheckoutPrMergeMethod | null {
if (value === "SQUASH") return "squash";
if (value === "MERGE") return "merge";
if (value === "REBASE") return "rebase";
return null;
}
function deriveGithubMergeCapability(github: GithubMergeFacts): MergeCapability {
const repository = github.repository;
const allowedMethods: CheckoutPrMergeMethod[] = [];
if (repository.mergeCommitAllowed) allowedMethods.push("merge");
if (repository.squashMergeAllowed) allowedMethods.push("squash");
if (repository.rebaseMergeAllowed) allowedMethods.push("rebase");
return {
directMergeReady: GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(github.mergeStateStatus ?? ""),
canEnableAutoMerge:
github.mergeStateStatus === "BLOCKED" &&
repository.autoMergeAllowed &&
github.viewerCanEnableAutoMerge,
autoMergeEnabled: github.autoMergeRequest !== null,
canDisableAutoMerge: github.viewerCanDisableAutoMerge === true,
mergeBlockedByQueue: github.isMergeQueueEnabled || github.isInMergeQueue,
allowedMethods,
preferredMethod: normalizeGithubMergeMethod(repository.viewerDefaultMergeMethod ?? null),
};
}
export const githubForgeLogic = {
id: "github",
urlGrammar: {
treeInfix: "/tree/",
blobInfix: "/blob/",
lineAnchor: GITHUB_LINE_ANCHOR,
},
facts: defineForgeFacts({
family: "github",
schema: GithubMergeFactsSchema,
deriveMergeCapability: deriveGithubMergeCapability,
}),
} satisfies ClientForgeLogicModule<GithubMergeFacts>;

View File

@@ -0,0 +1,8 @@
import { GitHubIcon } from "@/components/icons/github-icon";
import type { ClientForgeViewModule } from "@/git/client-forge-module";
export const githubForgeView = {
id: "github",
icon: GitHubIcon,
brandColor: null,
} satisfies ClientForgeViewModule;

View File

@@ -0,0 +1,155 @@
import { z } from "zod";
import {
defineForgeFacts,
type ClientForgeLogicModule,
type MergeCapability,
} from "@/git/client-forge-module";
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import type { CheckStatus } from "@/git/pull-request-panel/check-status";
const gitlabLineAnchor = (start: number, end?: number): string =>
end && end > start ? `#L${start}-${end}` : `#L${start}`;
/**
* Canonical set of GitLab pipeline statuses that count as "still active" (a
* pipeline that has not reached a terminal state). Server-side twin:
* packages/server/src/services/gitlab-facts.ts (GITLAB_ACTIVE_PIPELINE_STATUSES),
* duplicated because the app can't depend on the server package.
*/
const GITLAB_ACTIVE_PIPELINE_STATUSES = [
"created",
"waiting_for_resource",
"preparing",
"pending",
"running",
"scheduled",
] as const;
const GITLAB_ACTIVE_PIPELINE_STATUS_SET = new Set<string>(GITLAB_ACTIVE_PIPELINE_STATUSES);
export function isPipelineActiveStatus(status: string): boolean {
return GITLAB_ACTIVE_PIPELINE_STATUS_SET.has(status);
}
export function mapPipelineStatus(status: string): CheckStatus {
switch (status) {
case "success":
case "passed":
return "success";
case "failed":
return "failure";
case "running":
case "pending":
case "created":
case "waiting_for_resource":
case "preparing":
case "scheduled":
case "manual":
return "pending";
case "canceled":
case "cancelled":
case "skipped":
return "skipped";
default:
return "pending";
}
}
const GitlabMergeFactsSchema = z
.object({
forge: z.literal("gitlab"),
detailedMergeStatus: z.string().nullable().optional().default(null),
mergeStatus: z.string().nullable().optional().default(null),
hasConflicts: z.boolean().optional().default(false),
blockingDiscussionsResolved: z.boolean().optional().default(true),
approvalsRequired: z.number().optional().default(0),
approvalsGiven: z.number().optional().default(0),
pipelineStatus: z.string().nullable().optional().default(null),
pipelineId: z.number().nullable().optional().default(null),
pipelineUrl: z.string().nullable().optional().default(null),
mergeWhenPipelineSucceeds: z.boolean().optional().default(false),
})
.passthrough();
export type GitlabMergeFacts = z.infer<typeof GitlabMergeFactsSchema>;
export { GitlabMergeFactsSchema };
const GITLAB_MERGEABLE_STATUS = "mergeable";
const GITLAB_LEGACY_MERGEABLE_STATUS = "can_be_merged";
const GITLAB_MERGE_METHODS: CheckoutPrMergeMethod[] = ["merge", "squash", "rebase"];
/**
* Direct-merge readiness from GitLab's merge signals. `detailedMergeStatus` is
* GitLab 15.6+ only; when it is absent (older self-managed instances) fall back
* to the legacy `mergeStatus === "can_be_merged"` with no conflicts so merge is
* not silently refused everywhere.
*/
function isGitlabDirectMergeReady(gitlab: GitlabMergeFacts): boolean {
if (gitlab.detailedMergeStatus != null) {
return gitlab.detailedMergeStatus === GITLAB_MERGEABLE_STATUS;
}
return gitlab.mergeStatus === GITLAB_LEGACY_MERGEABLE_STATUS && gitlab.hasConflicts !== true;
}
function deriveGitlabMergeCapability(gitlab: GitlabMergeFacts): MergeCapability {
const autoMergeEnabled = gitlab.mergeWhenPipelineSucceeds === true;
const hasActivePipeline =
gitlab.pipelineStatus !== null && isPipelineActiveStatus(gitlab.pipelineStatus);
return {
directMergeReady: isGitlabDirectMergeReady(gitlab),
canEnableAutoMerge: !autoMergeEnabled && hasActivePipeline,
autoMergeEnabled,
canDisableAutoMerge: autoMergeEnabled,
mergeBlockedByQueue: false,
allowedMethods: GITLAB_MERGE_METHODS,
preferredMethod: null,
};
}
export interface GitlabPipelineSummary {
id: number;
status: CheckStatus;
rawStatus: string;
url: string | null;
}
export interface GitlabApprovals {
given: number;
required: number;
}
export function deriveGitlabPipelineSummary(facts: GitlabMergeFacts): GitlabPipelineSummary | null {
if (facts.pipelineId == null) {
return null;
}
const rawStatus = facts.pipelineStatus ?? "";
return {
id: facts.pipelineId,
status: mapPipelineStatus(rawStatus),
rawStatus,
url: facts.pipelineUrl ?? null,
};
}
export function deriveGitlabApprovals(facts: GitlabMergeFacts): GitlabApprovals | null {
const required = facts.approvalsRequired ?? 0;
if (required <= 0) {
return null;
}
return { given: facts.approvalsGiven ?? 0, required };
}
export const gitlabForgeLogic = {
id: "gitlab",
urlGrammar: {
treeInfix: "/-/tree/",
blobInfix: "/-/blob/",
lineAnchor: gitlabLineAnchor,
},
facts: defineForgeFacts({
family: "gitlab",
schema: GitlabMergeFactsSchema,
deriveMergeCapability: deriveGitlabMergeCapability,
}),
} satisfies ClientForgeLogicModule<GitlabMergeFacts>;

View File

@@ -0,0 +1,328 @@
import React, { useCallback, useMemo, type ReactNode } from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { CircleCheck, ExternalLink } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { CheckoutPipelineJob, CheckoutPipelineStage } from "@getpaseo/protocol/messages";
import { GitLabIcon } from "@/components/icons/gitlab-icon";
import {
definePaneContribution,
type ClientForgeViewModule,
type PaneChecksSlotContext,
} from "@/git/client-forge-module";
import { openExternalUrl } from "@/utils/open-external-url";
import { formatDuration } from "@/utils/time";
import {
CheckStatusIcon,
Section,
SUMMARY_DANGER_ICON,
SUMMARY_SUCCESS_ICON,
SUMMARY_WARNING_ICON,
SummaryPill,
foregroundMutedColorMapping,
sectionKitStyles,
successColorMapping,
} from "@/git/pull-request-panel/section-kit";
import { useGitLabPipeline } from "@/git/pull-request-panel/use-pipeline";
import {
deriveGitlabApprovals,
deriveGitlabPipelineSummary,
GitlabMergeFactsSchema,
isPipelineActiveStatus,
mapPipelineStatus,
type GitlabApprovals,
type GitlabMergeFacts,
type GitlabPipelineSummary,
} from "./gitlab";
function renderGitlabHeaderMeta(facts: GitlabMergeFacts): ReactNode {
const approvals = deriveGitlabApprovals(facts);
if (!approvals) {
return null;
}
return <GitlabApprovalsBadge approvals={approvals} />;
}
function renderGitlabChecksSection(facts: GitlabMergeFacts, ctx: PaneChecksSlotContext): ReactNode {
if (!ctx.enabled) {
return null;
}
const summary = deriveGitlabPipelineSummary(facts);
if (!summary) {
return null;
}
return (
<GitLabPipelineSection
serverId={ctx.serverId}
cwd={ctx.cwd}
changeRequestNumber={ctx.changeRequestNumber}
summary={summary}
open={ctx.open}
onToggle={ctx.onToggle}
canFetchCheckDetails={ctx.canFetchCheckDetails}
/>
);
}
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedExternalLink = withUnistyles(ExternalLink);
function GitlabApprovalsBadge({ approvals }: { approvals: GitlabApprovals }) {
const { t } = useTranslation();
return (
<View style={styles.approvalsBadge} testID="pr-pane-approvals">
<ThemedCircleCheck
size={11}
uniProps={
approvals.given >= approvals.required ? successColorMapping : foregroundMutedColorMapping
}
/>
<Text style={styles.approvalsText}>
{t("workspace.git.pr.approvals", { given: approvals.given, required: approvals.required })}
</Text>
</View>
);
}
function rowPressableStyle({ hovered }: { hovered?: boolean }) {
return [sectionKitStyles.checkRow, Boolean(hovered) && styles.hoverable];
}
function jobRowPressableStyle({ hovered }: { hovered?: boolean }) {
return [styles.pipelineJobRow, Boolean(hovered) && styles.hoverable];
}
function formatPipelineDuration(seconds: number | null): string {
if (seconds === null || seconds <= 0) {
return "";
}
return formatDuration(seconds * 1000);
}
interface PipelineJobCounts {
passed: number;
failed: number;
pending: number;
}
function countPipelineJobs(jobs: CheckoutPipelineJob[]): PipelineJobCounts {
const counts: PipelineJobCounts = { passed: 0, failed: 0, pending: 0 };
for (const job of jobs) {
const status = mapPipelineStatus(job.status);
if (status === "success") counts.passed += 1;
else if (status === "failure") counts.failed += 1;
else if (status === "pending") counts.pending += 1;
}
return counts;
}
function GitLabPipelineSection({
serverId,
cwd,
changeRequestNumber,
summary,
open,
onToggle,
canFetchCheckDetails,
}: {
serverId: string;
cwd: string;
changeRequestNumber: number;
summary: GitlabPipelineSummary;
open: boolean;
onToggle: () => void;
canFetchCheckDetails: boolean;
}) {
const { t } = useTranslation();
const { pipeline, isLoading, isPlaceholderData, error } = useGitLabPipeline({
serverId,
cwd,
pipelineId: summary.id,
changeRequestNumber,
enabled: open && canFetchCheckDetails,
live: isPipelineActiveStatus(summary.rawStatus),
});
const counts = useMemo(
() => countPipelineJobs((pipeline?.stages ?? []).flatMap((stage) => stage.jobs)),
[pipeline],
);
const totalCounted = counts.passed + counts.failed + counts.pending;
const showBreakdown = !isPlaceholderData && totalCounted > 0;
const displayCounts = showBreakdown ? counts : { passed: 0, failed: 0, pending: 0 };
const handleOpenPipeline = useCallback(() => {
if (summary.url) {
void openExternalUrl(summary.url);
}
}, [summary.url]);
const sectionSummary = (
<>
<SummaryPill
count={displayCounts.passed}
icon={SUMMARY_SUCCESS_ICON}
variant="success"
testID="pr-pane-pipeline-passed"
/>
<SummaryPill
count={displayCounts.failed}
icon={SUMMARY_DANGER_ICON}
variant="danger"
testID="pr-pane-pipeline-failed"
/>
<SummaryPill
count={displayCounts.pending}
icon={SUMMARY_WARNING_ICON}
variant="warning"
testID="pr-pane-pipeline-pending"
/>
{showBreakdown ? null : <CheckStatusIcon status={summary.status} />}
</>
);
return (
<Section
title={t("workspace.git.pr.sections.pipeline")}
open={open}
onToggle={onToggle}
summary={sectionSummary}
>
<Pressable
onPress={handleOpenPipeline}
style={rowPressableStyle}
disabled={!summary.url}
testID="pr-pane-pipeline-link"
>
<CheckStatusIcon status={summary.status} />
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
{`Pipeline #${summary.id}`}
</Text>
{summary.rawStatus ? (
<Text style={sectionKitStyles.checkWorkflow} numberOfLines={1}>
{summary.rawStatus}
</Text>
) : null}
{summary.url ? (
<View style={sectionKitStyles.checkTrailing}>
<ThemedExternalLink size={12} uniProps={foregroundMutedColorMapping} />
</View>
) : null}
</Pressable>
{isLoading ? (
<Text style={sectionKitStyles.emptyText}>
{t("workspace.git.pr.empty.loadingPipeline")}
</Text>
) : null}
{!isLoading && pipeline && pipeline.stages.length === 0 ? (
<Text style={sectionKitStyles.emptyText}>{t("workspace.git.pr.empty.noJobs")}</Text>
) : null}
{!isLoading && pipeline && pipeline.stages.length > 0
? pipeline.stages.map((stage) => <PipelineStageGroup key={stage.name} stage={stage} />)
: null}
{!isLoading && !pipeline && error ? (
<Text style={sectionKitStyles.emptyText}>
{t("workspace.git.pr.empty.pipelineJobsLoadFailed")}
</Text>
) : null}
</Section>
);
}
function PipelineStageGroup({ stage }: { stage: CheckoutPipelineStage }) {
return (
<View>
<View style={styles.pipelineStageHeader}>
<CheckStatusIcon status={mapPipelineStatus(stage.status)} />
<Text style={styles.pipelineStageName} numberOfLines={1}>
{stage.name}
</Text>
</View>
{stage.jobs.map((job) => (
<PipelineJobRow key={job.id} job={job} />
))}
</View>
);
}
function PipelineJobRow({ job }: { job: CheckoutPipelineJob }) {
const { t } = useTranslation();
const handlePress = useCallback(() => {
if (job.url) {
void openExternalUrl(job.url);
}
}, [job.url]);
const duration = formatPipelineDuration(job.durationSeconds);
return (
<Pressable onPress={handlePress} style={jobRowPressableStyle} disabled={!job.url}>
<CheckStatusIcon status={mapPipelineStatus(job.status)} />
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
{job.name}
</Text>
{job.allowFailure ? (
<Text style={sectionKitStyles.checkWorkflow} numberOfLines={1}>
{t("workspace.git.pr.empty.allowedToFail")}
</Text>
) : null}
<View style={sectionKitStyles.checkTrailing}>
{duration ? <Text style={sectionKitStyles.checkDuration}>{duration}</Text> : null}
</View>
</Pressable>
);
}
export const gitlabForgeView = {
id: "gitlab",
icon: GitLabIcon,
brandColor: {
light: "#FC6D26",
dark: "#FC6D26",
},
paneContributions: [
definePaneContribution(GitlabMergeFactsSchema, {
renderHeaderMeta: renderGitlabHeaderMeta,
renderChecksSection: renderGitlabChecksSection,
}),
],
} satisfies ClientForgeViewModule;
const styles = StyleSheet.create((theme) => ({
approvalsBadge: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
},
approvalsText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
hoverable: {
backgroundColor: theme.colors.surface1,
},
pipelineStageHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
},
pipelineStageName: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
textTransform: "uppercase",
letterSpacing: 0.5,
flexShrink: 1,
},
pipelineJobRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[2],
paddingRight: theme.spacing[3],
paddingLeft: theme.spacing[6],
minHeight: 32,
},
}));

View File

@@ -0,0 +1,59 @@
import { readdirSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { CLIENT_FORGE_LOGIC_MODULES } from "./index";
import { CLIENT_FORGE_VIEW_MODULES } from "./view";
/**
* Adding a forge is "create forges/<id>.ts (logic) + forges/<id>.view.tsx (view)
* + register one line in each registry". This test removes the only failure mode
* of the explicit registries — forgetting a line — without resorting to
* bundler-specific auto-discovery that Metro, Vite, and tsgo would each need to
* understand differently. The logic/view split keeps logic consumers (URL
* builders, merge-capability, native checks, and the Node e2e harness) free of
* the client rendering stack.
*/
const forgesDir = path.dirname(new URL(import.meta.url).pathname);
const dirEntries = readdirSync(forgesDir);
const logicModuleIds = dirEntries
.filter((file) => /^[a-z0-9-]+\.ts$/.test(file) && file !== "index.ts" && file !== "view.ts")
.map((file) => file.replace(/\.ts$/, ""))
.sort();
const viewModuleIds = dirEntries
.filter((file) => /^[a-z0-9-]+\.view\.tsx$/.test(file))
.map((file) => file.replace(/\.view\.tsx$/, ""))
.sort();
describe("CLIENT_FORGE_LOGIC_MODULES completeness", () => {
it("registers every forge logic module file in the directory", () => {
const registeredIds = CLIENT_FORGE_LOGIC_MODULES.map((module) => module.id).sort();
expect(registeredIds).toEqual(logicModuleIds);
});
it("has no duplicate forge ids", () => {
const ids = CLIENT_FORGE_LOGIC_MODULES.map((module) => module.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
describe("CLIENT_FORGE_VIEW_MODULES completeness", () => {
it("registers every forge view module file in the directory", () => {
const registeredIds = CLIENT_FORGE_VIEW_MODULES.map((module) => module.id).sort();
expect(registeredIds).toEqual(viewModuleIds);
});
it("has no duplicate forge ids", () => {
const ids = CLIENT_FORGE_VIEW_MODULES.map((module) => module.id);
expect(new Set(ids).size).toBe(ids.length);
});
});
describe("forge logic and view registries stay paired", () => {
it("has a view module for every logic module and vice versa", () => {
const logicIds = CLIENT_FORGE_LOGIC_MODULES.map((module) => module.id).sort();
const viewIds = CLIENT_FORGE_VIEW_MODULES.map((module) => module.id).sort();
expect(logicIds).toEqual(viewIds);
});
});

View File

@@ -0,0 +1,34 @@
import type { ClientForgeLogicModule, ForgeSpecificEnvelope } from "@/git/client-forge-module";
import { codebergForgeLogic } from "./codeberg";
import { forgejoForgeLogic } from "./forgejo";
import { giteaForgeLogic } from "./gitea";
import { githubForgeLogic } from "./github";
import { gitlabForgeLogic } from "./gitlab";
/**
* Pure logic registry. Import this (never the view registry) from URL builders,
* merge-capability, and native-check derivations so those paths — and the
* Node-based e2e harness that transitively imports them — stay free of the
* client rendering stack (react-native, react-native-svg, unistyles).
*/
export const CLIENT_FORGE_LOGIC_MODULES: readonly ClientForgeLogicModule[] = [
githubForgeLogic,
gitlabForgeLogic,
giteaForgeLogic,
forgejoForgeLogic,
codebergForgeLogic,
];
export function getClientForgeLogicModule(id: string): ClientForgeLogicModule | null {
return CLIENT_FORGE_LOGIC_MODULES.find((module) => module.id === id) ?? null;
}
export function parseClientForgeFacts(facts: unknown): ForgeSpecificEnvelope | null {
for (const module of CLIENT_FORGE_LOGIC_MODULES) {
const parsed = module.facts?.parse(facts);
if (parsed) {
return parsed;
}
}
return null;
}

View File

@@ -0,0 +1,23 @@
import type { ClientForgeViewModule } from "@/git/client-forge-module";
import { codebergForgeView } from "./codeberg.view";
import { forgejoForgeView } from "./forgejo.view";
import { giteaForgeView } from "./gitea.view";
import { githubForgeView } from "./github.view";
import { gitlabForgeView } from "./gitlab.view";
/**
* View registry: brand marks, brand colors, and PR-pane render contributions.
* Import this only from rendering code (icon lookup, the PR pane); it pulls the
* client rendering stack, so logic paths must use the logic registry instead.
*/
export const CLIENT_FORGE_VIEW_MODULES: readonly ClientForgeViewModule[] = [
githubForgeView,
gitlabForgeView,
giteaForgeView,
forgejoForgeView,
codebergForgeView,
];
export function getClientForgeViewModule(id: string): ClientForgeViewModule | null {
return CLIENT_FORGE_VIEW_MODULES.find((module) => module.id === id) ?? null;
}

View File

@@ -1,132 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildGitHubBlobUrl,
buildGitHubBranchTreeUrl,
parseGitHubRepoFromRemote,
} from "./github-url";
describe("parseGitHubRepoFromRemote", () => {
it.each([
["https://github.com/acme/repo.git", "acme/repo"],
["https://github.com/acme/repo", "acme/repo"],
["http://github.com/acme/repo.git", "acme/repo"],
["git@github.com:acme/repo.git", "acme/repo"],
["ssh://git@github.com/acme/repo.git", "acme/repo"],
["ssh://git@ssh.github.com/acme/repo.git", "acme/repo"],
["https://github.com/acme/repo/", "acme/repo"],
])("extracts the repo from %s", (remoteUrl, expected) => {
expect(parseGitHubRepoFromRemote(remoteUrl)).toBe(expected);
});
it("returns null for non-GitHub remotes", () => {
expect(parseGitHubRepoFromRemote("git@gitlab.com:acme/repo.git")).toBeNull();
});
it("returns null for invalid URLs", () => {
expect(parseGitHubRepoFromRemote("not a url")).toBeNull();
});
});
describe("buildGitHubBranchTreeUrl", () => {
it("builds a branch-specific GitHub tree URL", () => {
expect(
buildGitHubBranchTreeUrl({
remoteUrl: "git@github.com:acme/repo.git",
branch: "feature/workspace-button",
}),
).toBe("https://github.com/acme/repo/tree/feature/workspace-button");
});
it("encodes reserved branch characters while preserving slash-separated branch names", () => {
expect(
buildGitHubBranchTreeUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "feature/ship #42",
}),
).toBe("https://github.com/acme/repo/tree/feature/ship%20%2342");
});
it("returns null when the current branch is unavailable", () => {
expect(
buildGitHubBranchTreeUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "HEAD",
}),
).toBeNull();
});
});
describe("buildGitHubBlobUrl", () => {
it("builds a blob URL for a file path", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "git@github.com:acme/repo.git",
branch: "main",
path: "src/index.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts");
});
it("appends a single-line anchor", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12");
});
it("appends a line range anchor", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "src/index.ts",
lineStart: 12,
lineEnd: 20,
}),
).toBe("https://github.com/acme/repo/blob/main/src/index.ts#L12-L20");
});
it("strips leading slashes and encodes path segments", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "/src/a b/c#d.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/src/a%20b/c%23d.ts");
});
it("normalizes harmless dot segments in the blob path", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "./src/../index.ts",
}),
).toBe("https://github.com/acme/repo/blob/main/index.ts");
});
it("returns null for blob paths that escape above the repo root", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "../outside.ts",
}),
).toBeNull();
});
it("returns null when the path is missing", () => {
expect(
buildGitHubBlobUrl({
remoteUrl: "https://github.com/acme/repo.git",
branch: "main",
path: "",
}),
).toBeNull();
});
});

View File

@@ -1,69 +0,0 @@
import { parseGitHubRemoteUrl } from "@getpaseo/protocol/git-remote";
export function parseGitHubRepoFromRemote(remoteUrl: string | null | undefined): string | null {
const trimmed = remoteUrl?.trim();
if (!trimmed) {
return null;
}
return parseGitHubRemoteUrl(trimmed)?.repo ?? null;
}
export function buildGitHubBranchTreeUrl(input: {
remoteUrl: string | null | undefined;
branch: string | null | undefined;
}): string | null {
const repo = parseGitHubRepoFromRemote(input.remoteUrl);
const branch = input.branch?.trim();
if (!repo || !branch || branch === "HEAD") {
return null;
}
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
return `https://github.com/${repo}/tree/${encodedBranch}`;
}
function normalizeGitHubBlobPath(path: string | null | undefined): string | null {
const segments: string[] = [];
const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, "");
if (!trimmed) {
return null;
}
for (const segment of trimmed.split("/")) {
if (!segment || segment === ".") {
continue;
}
if (segment === "..") {
if (segments.length === 0) {
return null;
}
segments.pop();
continue;
}
segments.push(segment);
}
return segments.join("/") || null;
}
export function buildGitHubBlobUrl(input: {
remoteUrl: string | null | undefined;
branch: string | null | undefined;
path: string | null | undefined;
lineStart?: number;
lineEnd?: number;
}): string | null {
const repo = parseGitHubRepoFromRemote(input.remoteUrl);
const branch = input.branch?.trim();
const filePath = normalizeGitHubBlobPath(input.path);
if (!repo || !branch || branch === "HEAD" || !filePath) {
return null;
}
const encodedBranch = branch.split("/").map(encodeURIComponent).join("/");
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
let url = `https://github.com/${repo}/blob/${encodedBranch}/${encodedPath}`;
if (input.lineStart && input.lineStart > 0) {
url += `#L${input.lineStart}`;
if (input.lineEnd && input.lineEnd > input.lineStart) {
url += `-L${input.lineEnd}`;
}
}
return url;
}

View File

@@ -0,0 +1,300 @@
import { describe, expect, it } from "vitest";
import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability";
type GithubMergeFactsFixture = ForgeSpecificStatusFacts & {
forge: "github";
mergeStateStatus: string | null;
autoMergeRequest: {
enabledAt: string | null;
mergeMethod: string | null;
enabledBy: string | null;
} | null;
viewerCanEnableAutoMerge: boolean;
viewerCanDisableAutoMerge: boolean;
viewerCanMergeAsAdmin: boolean;
viewerCanUpdateBranch: boolean;
repository: {
autoMergeAllowed: boolean;
mergeCommitAllowed: boolean;
squashMergeAllowed: boolean;
rebaseMergeAllowed: boolean;
viewerDefaultMergeMethod: string | null;
};
isMergeQueueEnabled: boolean;
isInMergeQueue: boolean;
};
type GitlabMergeFactsFixture = ForgeSpecificStatusFacts & {
forge: "gitlab";
detailedMergeStatus: string | null;
hasConflicts: boolean;
blockingDiscussionsResolved: boolean;
approvalsRequired: number;
approvalsGiven: number;
pipelineStatus: string | null;
pipelineId: number | null;
pipelineUrl: string | null;
mergeWhenPipelineSucceeds: boolean;
};
type GiteaMergeFactsFixture = ForgeSpecificStatusFacts & {
forge: "gitea";
mergeable: boolean;
hasMerged: boolean;
ciStatus: string | null;
};
function facts(overrides: Partial<GithubMergeFactsFixture> = {}): GithubMergeFactsFixture {
return {
forge: "github",
mergeStateStatus: "CLEAN",
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
viewerCanDisableAutoMerge: false,
viewerCanMergeAsAdmin: false,
viewerCanUpdateBranch: false,
repository: {
autoMergeAllowed: false,
mergeCommitAllowed: true,
squashMergeAllowed: true,
rebaseMergeAllowed: true,
viewerDefaultMergeMethod: "SQUASH",
},
isMergeQueueEnabled: false,
isInMergeQueue: false,
...overrides,
};
}
function gitlabFacts(overrides: Partial<GitlabMergeFactsFixture> = {}): GitlabMergeFactsFixture {
return {
forge: "gitlab",
detailedMergeStatus: "mergeable",
hasConflicts: false,
blockingDiscussionsResolved: true,
approvalsRequired: 0,
approvalsGiven: 0,
pipelineStatus: "success",
pipelineId: null,
pipelineUrl: null,
mergeWhenPipelineSucceeds: false,
...overrides,
};
}
function giteaFacts(overrides: Partial<GiteaMergeFactsFixture> = {}): GiteaMergeFactsFixture {
return {
forge: "gitea",
mergeable: true,
hasMerged: false,
ciStatus: "success",
...overrides,
};
}
describe("deriveMergeCapability", () => {
it("returns null when the forge supplied no merge facts", () => {
expect(deriveMergeCapability(null)).toBeNull();
expect(deriveMergeCapability(undefined)).toBeNull();
});
it("rejects untagged and schema-mismatched forge facts at the registry boundary", () => {
expect(deriveMergeCapability({ approvalsRequired: 2 })).toBeNull();
expect(
deriveMergeCapability({
...gitlabFacts(),
approvalsRequired: "two",
}),
).toBeNull();
});
it("marks direct merge ready only for the GitHub clean states", () => {
expect(deriveMergeCapability(facts({ mergeStateStatus: "CLEAN" }))?.directMergeReady).toBe(
true,
);
expect(deriveMergeCapability(facts({ mergeStateStatus: "HAS_HOOKS" }))?.directMergeReady).toBe(
true,
);
expect(deriveMergeCapability(facts({ mergeStateStatus: "BLOCKED" }))?.directMergeReady).toBe(
false,
);
expect(deriveMergeCapability(facts({ mergeStateStatus: null }))?.directMergeReady).toBe(false);
});
it("can enable auto-merge only when blocked, allowed, and the viewer may enable it", () => {
const ready = facts({
mergeStateStatus: "BLOCKED",
viewerCanEnableAutoMerge: true,
repository: {
autoMergeAllowed: true,
mergeCommitAllowed: true,
squashMergeAllowed: true,
rebaseMergeAllowed: true,
viewerDefaultMergeMethod: "SQUASH",
},
});
expect(deriveMergeCapability(ready)?.canEnableAutoMerge).toBe(true);
expect(
deriveMergeCapability(facts({ ...ready, viewerCanEnableAutoMerge: false }))
?.canEnableAutoMerge,
).toBe(false);
expect(
deriveMergeCapability(facts({ ...ready, mergeStateStatus: "CLEAN" }))?.canEnableAutoMerge,
).toBe(false);
});
it("reports whether auto-merge is already enabled and can be disabled", () => {
const cap = deriveMergeCapability(
facts({
autoMergeRequest: { enabledAt: "now", mergeMethod: "SQUASH", enabledBy: "octocat" },
viewerCanDisableAutoMerge: true,
}),
);
expect(cap?.autoMergeEnabled).toBe(true);
expect(cap?.canDisableAutoMerge).toBe(true);
expect(deriveMergeCapability(facts())?.autoMergeEnabled).toBe(false);
});
it("treats an enabled or in-progress merge queue as blocking", () => {
expect(deriveMergeCapability(facts({ isMergeQueueEnabled: true }))?.mergeBlockedByQueue).toBe(
true,
);
expect(deriveMergeCapability(facts({ isInMergeQueue: true }))?.mergeBlockedByQueue).toBe(true);
expect(deriveMergeCapability(facts())?.mergeBlockedByQueue).toBe(false);
});
it("derives allowed methods and the preferred method from the repository policy", () => {
const cap = deriveMergeCapability(
facts({
repository: {
autoMergeAllowed: false,
mergeCommitAllowed: false,
squashMergeAllowed: true,
rebaseMergeAllowed: true,
viewerDefaultMergeMethod: "REBASE",
},
}),
);
expect(cap?.allowedMethods).toEqual(["squash", "rebase"]);
expect(cap?.preferredMethod).toBe("rebase");
});
it("returns a null preferred method when the forge reports an unknown default", () => {
expect(
deriveMergeCapability(
facts({ repository: { ...facts().repository, viewerDefaultMergeMethod: null } }),
)?.preferredMethod,
).toBeNull();
});
});
describe("deriveMergeCapability (legacy github fallback)", () => {
it("synthesizes full GitHub capability from legacy status.github when forgeSpecific is absent", () => {
const { forge: _forge, ...legacy } = facts({
mergeStateStatus: "CLEAN",
repository: {
autoMergeAllowed: false,
mergeCommitAllowed: false,
squashMergeAllowed: true,
rebaseMergeAllowed: false,
viewerDefaultMergeMethod: "SQUASH",
},
});
const cap = deriveMergeCapability(undefined, legacy);
expect(cap).not.toBeNull();
expect(cap?.directMergeReady).toBe(true);
expect(cap?.allowedMethods).toEqual(["squash"]);
expect(cap?.preferredMethod).toBe("squash");
});
it("returns null when both forgeSpecific and legacy github facts are absent", () => {
expect(deriveMergeCapability(undefined, null)).toBeNull();
expect(deriveMergeCapability(undefined, undefined)).toBeNull();
});
});
describe("deriveMergeCapability (gitlab)", () => {
it("produces a non-null capability for the gitlab arm", () => {
expect(deriveMergeCapability(gitlabFacts())).not.toBeNull();
});
it("marks direct merge ready only when GitLab reports the mergeable status", () => {
expect(
deriveMergeCapability(gitlabFacts({ detailedMergeStatus: "mergeable" }))?.directMergeReady,
).toBe(true);
expect(
deriveMergeCapability(gitlabFacts({ detailedMergeStatus: "ci_still_running" }))
?.directMergeReady,
).toBe(false);
expect(
deriveMergeCapability(gitlabFacts({ detailedMergeStatus: "discussions_not_resolved" }))
?.directMergeReady,
).toBe(false);
expect(
deriveMergeCapability(gitlabFacts({ detailedMergeStatus: null }))?.directMergeReady,
).toBe(false);
});
it("reflects merge-when-pipeline-succeeds as an enabled auto-merge", () => {
const enabled = deriveMergeCapability(
gitlabFacts({ mergeWhenPipelineSucceeds: true, pipelineStatus: "running" }),
);
expect(enabled?.autoMergeEnabled).toBe(true);
expect(enabled?.canDisableAutoMerge).toBe(true);
expect(enabled?.canEnableAutoMerge).toBe(false);
expect(deriveMergeCapability(gitlabFacts())?.autoMergeEnabled).toBe(false);
});
it("can enable auto-merge only while a pipeline is still in flight", () => {
expect(
deriveMergeCapability(gitlabFacts({ pipelineStatus: "created" }))?.canEnableAutoMerge,
).toBe(true);
expect(
deriveMergeCapability(gitlabFacts({ pipelineStatus: "waiting_for_resource" }))
?.canEnableAutoMerge,
).toBe(true);
expect(
deriveMergeCapability(gitlabFacts({ pipelineStatus: "preparing" }))?.canEnableAutoMerge,
).toBe(true);
expect(
deriveMergeCapability(gitlabFacts({ pipelineStatus: "pending" }))?.canEnableAutoMerge,
).toBe(true);
expect(
deriveMergeCapability(gitlabFacts({ pipelineStatus: "running" }))?.canEnableAutoMerge,
).toBe(true);
expect(
deriveMergeCapability(gitlabFacts({ pipelineStatus: "scheduled" }))?.canEnableAutoMerge,
).toBe(true);
expect(
deriveMergeCapability(gitlabFacts({ pipelineStatus: "success" }))?.canEnableAutoMerge,
).toBe(false);
expect(deriveMergeCapability(gitlabFacts({ pipelineStatus: null }))?.canEnableAutoMerge).toBe(
false,
);
});
it("offers GitLab merge methods and never reports a merge queue", () => {
const cap = deriveMergeCapability(gitlabFacts());
expect(cap?.allowedMethods).toEqual(["merge", "squash", "rebase"]);
expect(cap?.mergeBlockedByQueue).toBe(false);
expect(cap?.canEnableAutoMerge).toBe(false);
});
});
describe("deriveMergeCapability (gitea)", () => {
it("allows direct merge only when the pull request is mergeable and unmerged", () => {
expect(deriveMergeCapability(giteaFacts())?.directMergeReady).toBe(true);
expect(deriveMergeCapability(giteaFacts({ mergeable: false }))?.directMergeReady).toBe(false);
expect(deriveMergeCapability(giteaFacts({ hasMerged: true }))?.directMergeReady).toBe(false);
});
it("offers direct merge styles without auto-merge", () => {
const capability = deriveMergeCapability(giteaFacts());
expect(capability?.allowedMethods).toEqual(["merge", "squash", "rebase"]);
expect(capability?.canEnableAutoMerge).toBe(false);
expect(capability?.autoMergeEnabled).toBe(false);
expect(capability?.canDisableAutoMerge).toBe(false);
});
});

View File

@@ -0,0 +1,51 @@
import {
type ForgeSpecificEnvelope,
type LegacyGithubMergeFacts,
type MergeCapability,
} from "@/git/client-forge-module";
import { CLIENT_FORGE_LOGIC_MODULES } from "@/git/forges";
export type ForgeSpecificStatusFacts = ForgeSpecificEnvelope;
export type { LegacyGithubMergeFacts, MergeCapability };
/**
* Resolve the neutral merge capability from a registered forge's runtime facts.
* Returns null for an unregistered, unknown, or schema-mismatched facts family;
* callers then fall back to raw git state.
*/
function deriveForgeMergeCapability(facts: unknown): MergeCapability | null {
for (const module of CLIENT_FORGE_LOGIC_MODULES) {
const capability = module.facts?.deriveMergeCapability(facts);
if (capability) {
return capability;
}
}
return null;
}
/**
* Build the neutral merge capability from a forge's PR status facts. Returns
* null when the forge supplied no merge facts (e.g. a host that exposes none, or
* an unknown forge), in which case the caller falls back to raw git state.
* Per-forge derivation lives on client forge modules; this stays the single
* neutral entry point the action policy reads.
*
* COMPAT(forgeSpecific): added in v0.1.106, remove after 2026-12-27. A daemon
* predating forgeSpecific emits only the legacy `status.github` field. When
* forgeSpecific is absent we synthesize the github arm from those legacy facts
* so GitHub merge-method restrictions and auto-merge state are not lost against
* an old daemon.
*/
export function deriveMergeCapability(
forgeSpecific: unknown,
legacyGithubFacts?: LegacyGithubMergeFacts | null,
): MergeCapability | null {
if (forgeSpecific === null || forgeSpecific === undefined) {
if (legacyGithubFacts) {
return deriveForgeMergeCapability({ forge: "github", ...legacyGithubFacts });
}
return null;
}
return deriveForgeMergeCapability(forgeSpecific);
}

View File

@@ -3,11 +3,34 @@ import { CheckoutPrStatusSchema } from "@getpaseo/protocol/messages";
import { i18n } from "@/i18n/i18next";
import { buildGitActions, type BuildGitActionsInput } from "./policy";
import { deriveMergeCapability, type ForgeSpecificStatusFacts } from "./merge-capability";
function githubStatus(
overrides: Partial<NonNullable<BuildGitActionsInput["pullRequestGithub"]>> = {},
): NonNullable<BuildGitActionsInput["pullRequestGithub"]> {
type GithubMergeFactsFixture = ForgeSpecificStatusFacts & {
forge: "github";
mergeStateStatus: string | null;
autoMergeRequest: {
enabledAt: string | null;
mergeMethod: string | null;
enabledBy: string | null;
} | null;
viewerCanEnableAutoMerge: boolean;
viewerCanDisableAutoMerge: boolean;
viewerCanMergeAsAdmin: boolean;
viewerCanUpdateBranch: boolean;
repository: {
autoMergeAllowed: boolean;
mergeCommitAllowed: boolean;
squashMergeAllowed: boolean;
rebaseMergeAllowed: boolean;
viewerDefaultMergeMethod: string | null;
};
isMergeQueueEnabled: boolean;
isInMergeQueue: boolean;
};
function githubStatus(overrides: Partial<GithubMergeFactsFixture> = {}): GithubMergeFactsFixture {
return {
forge: "github",
mergeStateStatus: "CLEAN",
autoMergeRequest: null,
viewerCanEnableAutoMerge: false,
@@ -27,10 +50,17 @@ function githubStatus(
};
}
function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitActionsInput {
function createInput(
overrides: Partial<Omit<BuildGitActionsInput, "mergeCapability">> & {
pullRequestGithub?: unknown;
} = {},
): BuildGitActionsInput {
const { pullRequestGithub = null, ...rest } = overrides;
return {
isGit: true,
githubFeaturesEnabled: true,
forgeBrandLabel: "GitHub",
forgeChangeRequestNoun: "PR",
githubAutoMergeActionsEnabled: true,
hasPullRequest: false,
pullRequestUrl: null,
@@ -38,7 +68,7 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
pullRequestIsDraft: false,
pullRequestIsMerged: false,
pullRequestMergeable: "UNKNOWN",
pullRequestGithub: null,
mergeCapability: deriveMergeCapability(pullRequestGithub),
hasRemote: false,
isPaseoOwnedWorktree: false,
isOnBaseBranch: true,
@@ -128,7 +158,7 @@ function createInput(overrides: Partial<BuildGitActionsInput> = {}): BuildGitAct
handler: () => undefined,
},
},
...overrides,
...rest,
};
}
@@ -344,6 +374,28 @@ describe("git-actions-policy", () => {
});
});
it("explains why pull-and-push is unavailable when there is nothing to pull first", () => {
const actions = buildGitActions(
createInput({ hasRemote: true, aheadOfOrigin: 1, behindOfOrigin: 0 }),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action?.unavailableMessage).toBe(
"Pull and push isn't available because there are no incoming changes to pull first",
);
});
it("explains why pull-and-push is unavailable when there is nothing to push after pulling", () => {
const actions = buildGitActions(
createInput({ hasRemote: true, aheadOfOrigin: 0, behindOfOrigin: 1 }),
);
const action = actions.secondary.find((entry) => entry.id === "pull-and-push");
expect(action?.unavailableMessage).toBe(
"Pull and push isn't available because there is nothing new to send after pulling",
);
});
it("explains why pull-and-push is unavailable when there are uncommitted changes", () => {
const actions = buildGitActions(
createInput({
@@ -507,6 +559,44 @@ describe("git-actions-policy", () => {
});
});
it("uses the forge change-request noun in unavailable no-forge copy", () => {
const createActions = buildGitActions(
createInput({
githubFeaturesEnabled: false,
forgeBrandLabel: "GitLab",
forgeChangeRequestNoun: "MR",
hasRemote: true,
isOnBaseBranch: false,
aheadCount: 1,
}),
);
const viewActions = buildGitActions(
createInput({
githubFeaturesEnabled: false,
forgeBrandLabel: "GitLab",
forgeChangeRequestNoun: "MR",
hasRemote: true,
isOnBaseBranch: false,
hasPullRequest: true,
pullRequestUrl: "https://gitlab.com/example/repo/-/merge_requests/1",
}),
);
const createPrAction = [...createActions.secondary, ...createActions.menu].find(
(action) => action.id === "pr",
);
const viewPrAction = [viewActions.primary, ...viewActions.secondary, ...viewActions.menu].find(
(action) => action?.id === "pr",
);
expect(createPrAction?.unavailableMessage).toBe(
"Create MR isn't available right now because GitLab isn't connected",
);
expect(viewPrAction?.unavailableMessage).toBe(
"View MR isn't available right now because GitLab isn't connected",
);
});
it("uses local merge when merge is the stored ship default", () => {
const actions = buildGitActions(
createInput({
@@ -726,12 +816,12 @@ describe("git-actions-policy", () => {
pullRequestIsDraft: oldDaemonStatus.isDraft,
pullRequestIsMerged: oldDaemonStatus.isMerged,
pullRequestMergeable: oldDaemonStatus.mergeable,
pullRequestGithub: oldDaemonStatus.github,
pullRequestGithub: oldDaemonStatus.forgeSpecific,
shipDefault: "pr",
}),
);
expect(oldDaemonStatus.github).toBeUndefined();
expect(oldDaemonStatus.forgeSpecific).toBeUndefined();
expect(actions.primary).toMatchObject({
id: "merge-pr-squash",
label: "Merge PR (squash)",

View File

@@ -2,11 +2,9 @@ import type { ReactElement } from "react";
import type { ActionStatus } from "@/components/ui/dropdown-menu";
import { i18n } from "@/i18n/i18next";
import type {
CheckoutPrMergeMethod,
CheckoutPrStatusResponse,
PullRequestMergeable,
} from "@getpaseo/protocol/messages";
import type { CheckoutPrMergeMethod, PullRequestMergeable } from "@getpaseo/protocol/messages";
import type { MergeCapability } from "./merge-capability";
export type GitActionId =
| "commit"
@@ -55,6 +53,10 @@ interface GitActionRuntimeState {
export interface BuildGitActionsInput {
isGit: boolean;
githubFeaturesEnabled: boolean;
/** Forge brand label (e.g. "GitHub", "GitLab") for forge-neutral unavailable copy. */
forgeBrandLabel: string;
/** Short change-request noun label (e.g. "PR", "MR") for forge-neutral unavailable copy. */
forgeChangeRequestNoun: string;
githubAutoMergeActionsEnabled: boolean;
hasPullRequest: boolean;
pullRequestUrl: string | null;
@@ -62,7 +64,7 @@ export interface BuildGitActionsInput {
pullRequestIsDraft: boolean;
pullRequestIsMerged: boolean;
pullRequestMergeable: PullRequestMergeable;
pullRequestGithub: PullRequestGithubStatus | null;
mergeCapability: MergeCapability | null;
hasRemote: boolean;
isPaseoOwnedWorktree: boolean;
isOnBaseBranch: boolean;
@@ -98,7 +100,6 @@ type PullRequestAutoMergeEnableActionId = Extract<
"enable-pr-auto-merge-squash" | "enable-pr-auto-merge-merge" | "enable-pr-auto-merge-rebase"
>;
type PullRequestActionRole = "status" | "direct" | "auto";
type PullRequestGithubStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>["github"];
interface PullRequestActionModel {
readonly id: PullRequestActionId;
@@ -180,7 +181,6 @@ const PULL_REQUEST_ACTION_MODELS: readonly PullRequestActionModel[] = [
];
const REMOTE_ACTION_IDS: GitActionId[] = ["pull", "push", "pull-and-push"];
const GITHUB_DIRECT_MERGE_STATE_ALLOWLIST = new Set(["CLEAN", "HAS_HOOKS"]);
export function narrowPullRequestState(state: string | null | undefined): "open" | "closed" | null {
if (state === "open") return "open";
@@ -403,7 +403,10 @@ function buildPrAction(input: BuildGitActionsInput): GitAction {
unavailableMessage:
input.runtime.pr.disabled || input.githubFeaturesEnabled
? undefined
: i18n.t("workspace.git.actions.unavailable.viewPrNoGithub"),
: i18n.t("workspace.git.actions.unavailable.viewPrNoForge", {
brand: input.forgeBrandLabel,
noun: input.forgeChangeRequestNoun,
}),
icon: input.runtime.pr.icon,
startsGroup: false,
handler: input.runtime.pr.handler,
@@ -467,7 +470,7 @@ function buildEnablePullRequestAutoMergeAction(
function buildDisablePullRequestAutoMergeAction(input: BuildGitActionsInput): GitAction {
const runtime = input.runtime["disable-pr-auto-merge"];
const unavailableMessage =
input.pullRequestGithub?.viewerCanDisableAutoMerge === true
input.mergeCapability?.canDisableAutoMerge === true
? undefined
: i18n.t("workspace.git.actions.unavailable.autoMergeCannotDisable");
return {
@@ -475,7 +478,7 @@ function buildDisablePullRequestAutoMergeAction(input: BuildGitActionsInput): Gi
label: i18n.t("workspace.git.actions.autoMerge.enabled"),
pendingLabel: i18n.t("workspace.git.actions.autoMerge.disabling"),
successLabel: i18n.t("workspace.git.actions.autoMerge.disabled"),
disabled: runtime.disabled || input.pullRequestGithub?.viewerCanDisableAutoMerge !== true,
disabled: runtime.disabled || input.mergeCapability?.canDisableAutoMerge !== true,
status: runtime.status,
unavailableMessage: runtime.disabled ? undefined : unavailableMessage,
icon: runtime.icon,
@@ -543,7 +546,7 @@ function canUsePullRequestActionAsShipDefault(input: BuildGitActionsInput): bool
}
function canMergePr(input: BuildGitActionsInput): boolean {
const github = input.pullRequestGithub;
const capability = input.mergeCapability;
const canMergeFromPullRequestStatus =
input.githubFeaturesEnabled &&
input.hasPullRequest &&
@@ -558,7 +561,7 @@ function canMergePr(input: BuildGitActionsInput): boolean {
return false;
}
if (!hasPullRequestGithubFacts(github)) {
if (capability === null) {
return (
input.pullRequestMergeable === "MERGEABLE" &&
input.behindOfOrigin === 0 &&
@@ -568,16 +571,15 @@ function canMergePr(input: BuildGitActionsInput): boolean {
}
return (
GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(github.mergeStateStatus ?? "") &&
github.autoMergeRequest === null &&
!github.isMergeQueueEnabled &&
!github.isInMergeQueue &&
capability.directMergeReady &&
!capability.autoMergeEnabled &&
!capability.mergeBlockedByQueue &&
getAllowedDirectPullRequestMergeActionModels(input).length > 0
);
}
function canEnablePrAutoMerge(input: BuildGitActionsInput): boolean {
const github = input.pullRequestGithub;
const capability = input.mergeCapability;
return (
input.githubFeaturesEnabled &&
input.githubAutoMergeActionsEnabled &&
@@ -586,13 +588,10 @@ function canEnablePrAutoMerge(input: BuildGitActionsInput): boolean {
!input.pullRequestIsDraft &&
!input.pullRequestIsMerged &&
input.pullRequestMergeable !== "CONFLICTING" &&
hasPullRequestGithubFacts(github) &&
github.autoMergeRequest === null &&
github.mergeStateStatus === "BLOCKED" &&
github.repository.autoMergeAllowed &&
github.viewerCanEnableAutoMerge &&
!github.isMergeQueueEnabled &&
!github.isInMergeQueue &&
capability !== null &&
!capability.autoMergeEnabled &&
capability.canEnableAutoMerge &&
!capability.mergeBlockedByQueue &&
getAllowedAutoMergeEnableActionModels(input).length > 0
);
}
@@ -602,8 +601,7 @@ function hasEnabledPrAutoMerge(input: BuildGitActionsInput): boolean {
input.githubFeaturesEnabled &&
input.hasPullRequest &&
input.pullRequestUrl !== null &&
hasPullRequestGithubFacts(input.pullRequestGithub) &&
input.pullRequestGithub.autoMergeRequest !== null
input.mergeCapability?.autoMergeEnabled === true
);
}
@@ -615,7 +613,7 @@ function getPullUnavailableMessage(input: BuildGitActionsInput): string | undefi
return i18n.t("workspace.git.actions.unavailable.pullDirty");
}
if (input.behindOfOrigin === null) {
return "Pull isn't available here because this branch is not connected to a remote yet";
return i18n.t("workspace.git.actions.unavailable.pullNoRemote");
}
if (input.behindOfOrigin === 0) {
return i18n.t("workspace.git.actions.unavailable.pullUpToDate");
@@ -644,23 +642,26 @@ function getPullAndPushUnavailableMessage(input: BuildGitActionsInput): string |
return i18n.t("workspace.git.actions.unavailable.pullAndPushDirty");
}
if (input.behindOfOrigin === null) {
return "Pull and push isn't available because there are no incoming changes to pull first";
return i18n.t("workspace.git.actions.unavailable.pullAndPushNoIncoming");
}
if (input.behindOfOrigin === 0 && input.aheadOfOrigin === 0) {
return i18n.t("workspace.git.actions.unavailable.pullAndPushInSync");
}
if (input.behindOfOrigin === 0) {
return "Pull and push isn't available because there are no incoming changes to pull first";
return i18n.t("workspace.git.actions.unavailable.pullAndPushNoIncoming");
}
if ((input.aheadOfOrigin ?? 0) === 0) {
return "Pull and push isn't available because there is nothing new to send after pulling";
return i18n.t("workspace.git.actions.unavailable.pullAndPushNothingToPush");
}
return undefined;
}
function getCreatePrUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.githubFeaturesEnabled) {
return i18n.t("workspace.git.actions.unavailable.createPrNoGithub");
return i18n.t("workspace.git.actions.unavailable.createPrNoForge", {
brand: input.forgeBrandLabel,
noun: input.forgeChangeRequestNoun,
});
}
if (input.aheadCount === 0) {
return i18n.t("workspace.git.actions.unavailable.createPrNoCommits");
@@ -698,7 +699,10 @@ function getMergeFromBaseUnavailableMessage(input: BuildGitActionsInput): string
function getMergePrUnavailableMessage(input: BuildGitActionsInput): string | undefined {
if (!input.githubFeaturesEnabled) {
return i18n.t("workspace.git.actions.unavailable.mergePrNoGithub");
return i18n.t("workspace.git.actions.unavailable.mergePrNoForge", {
brand: input.forgeBrandLabel,
noun: input.forgeChangeRequestNoun,
});
}
if (!input.hasPullRequest) {
return i18n.t("workspace.git.actions.unavailable.mergePrMissing");
@@ -715,14 +719,17 @@ function getMergePrUnavailableMessage(input: BuildGitActionsInput): string | und
if (input.pullRequestMergeable === "CONFLICTING") {
return i18n.t("workspace.git.actions.unavailable.mergePrConflicts");
}
if (!hasPullRequestGithubFacts(input.pullRequestGithub)) {
if (input.mergeCapability === null) {
return undefined;
}
if (input.pullRequestGithub?.isMergeQueueEnabled || input.pullRequestGithub?.isInMergeQueue) {
if (input.mergeCapability.mergeBlockedByQueue) {
return i18n.t("workspace.git.actions.unavailable.mergePrQueue");
}
if (!GITHUB_DIRECT_MERGE_STATE_ALLOWLIST.has(input.pullRequestGithub?.mergeStateStatus ?? "")) {
return i18n.t("workspace.git.actions.unavailable.mergePrNotReady");
if (!input.mergeCapability.directMergeReady) {
return i18n.t("workspace.git.actions.unavailable.mergePrNotReady", {
brand: input.forgeBrandLabel,
noun: input.forgeChangeRequestNoun,
});
}
return undefined;
}
@@ -739,11 +746,7 @@ function shouldShowPullRequestAction(
return true;
}
if (id === "disable-pr-auto-merge") {
return (
input.githubAutoMergeActionsEnabled &&
hasPullRequestGithubFacts(input.pullRequestGithub) &&
input.pullRequestGithub.autoMergeRequest !== null
);
return input.githubAutoMergeActionsEnabled && input.mergeCapability?.autoMergeEnabled === true;
}
if (isDirectPullRequestMergeActionId(id)) {
return canMergePr(input) && getAllowedDirectPullRequestMergeActionIds(input).includes(id);
@@ -798,9 +801,7 @@ function getPreferredDirectPullRequestMergeActionModel(
input: BuildGitActionsInput,
): PullRequestDirectMergeActionModel | null {
const allowed = getAllowedDirectPullRequestMergeActionModels(input);
const preferred = normalizeGithubMergeMethod(
input.pullRequestGithub?.repository.viewerDefaultMergeMethod ?? null,
);
const preferred = input.mergeCapability?.preferredMethod ?? null;
return allowed.find((model) => model.method === preferred) ?? allowed[0] ?? null;
}
@@ -808,9 +809,7 @@ function getPreferredEnablePullRequestAutoMergeActionModel(
input: BuildGitActionsInput,
): PullRequestAutoMergeEnableActionModel | null {
const allowed = getAllowedAutoMergeEnableActionModels(input);
const preferred = normalizeGithubMergeMethod(
input.pullRequestGithub?.repository.viewerDefaultMergeMethod ?? null,
);
const preferred = input.mergeCapability?.preferredMethod ?? null;
return allowed.find((model) => model.method === preferred) ?? allowed[0] ?? null;
}
@@ -818,28 +817,9 @@ function isPullRequestMergeMethodAllowed(
input: BuildGitActionsInput,
method: CheckoutPrMergeMethod,
): boolean {
const repository = input.pullRequestGithub?.repository;
if (!repository) {
const capability = input.mergeCapability;
if (capability === null) {
return true;
}
if (method === "squash") {
return repository.squashMergeAllowed;
}
if (method === "merge") {
return repository.mergeCommitAllowed;
}
return repository.rebaseMergeAllowed;
}
function hasPullRequestGithubFacts(
github: PullRequestGithubStatus | null,
): github is NonNullable<PullRequestGithubStatus> {
return github !== null && github !== undefined;
}
function normalizeGithubMergeMethod(value: string | null): CheckoutPrMergeMethod | null {
if (value === "SQUASH") return "squash";
if (value === "MERGE") return "merge";
if (value === "REBASE") return "rebase";
return null;
return capability.allowedMethods.includes(method);
}

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { selectPrHintFromStatus } from "./pr-hint";
const githubStatus = {
url: "https://github.com/acme/repo/pull/42",
state: "open",
isMerged: false,
};
const gitlabStatus = {
url: "https://gitlab.com/group/proj/-/merge_requests/7",
state: "open",
isMerged: false,
};
describe("selectPrHintFromStatus", () => {
it("defaults the forge to github when none is supplied (old daemon)", () => {
const hint = selectPrHintFromStatus(githubStatus);
expect(hint).toMatchObject({ number: 42, forge: "github" });
});
it("carries the resolved forge onto the hint", () => {
const hint = selectPrHintFromStatus(githubStatus, "github");
expect(hint?.forge).toBe("github");
});
it("parses a GitLab merge-request URL and carries the gitlab forge", () => {
const hint = selectPrHintFromStatus(gitlabStatus, "gitlab");
expect(hint).toMatchObject({ number: 7, forge: "gitlab" });
});
it("passes an unknown forge id through untouched", () => {
const hint = selectPrHintFromStatus(githubStatus, "bitbucket");
expect(hint?.forge).toBe("bitbucket");
});
it("returns null when the url has no parseable change-request number", () => {
expect(
selectPrHintFromStatus({ url: "https://example.com/x", state: "open", isMerged: false }),
).toBeNull();
});
});

View File

@@ -1,7 +1,11 @@
import { normalizeForge, type Forge } from "@/git/forge";
export interface PrHint {
url: string;
number: number;
state: "open" | "merged" | "closed";
/** Forge backing this change request, so badges render the right brand mark. */
forge: Forge;
checks?: Array<{ name: string; status: string; url: string | null }>;
checksStatus?: "none" | "pending" | "success" | "failure";
reviewDecision?: "approved" | "changes_requested" | "pending" | null;
@@ -14,12 +18,15 @@ interface PrStatusLike {
checks?: Array<{ name: string; status: string; url: string | null }>;
checksStatus?: string;
reviewDecision?: string | null;
forge?: string;
}
function parsePullRequestNumber(url: string): number | null {
try {
const pathname = new URL(url).pathname;
const match = pathname.match(/\/pull\/(\d+)(?:\/|$)/);
// GitHub uses /pull/N, Gitea/Forgejo /pulls/N, GitLab /-/merge_requests/N.
// Match any so a non-GitHub change-request summary yields a hint (and brand mark).
const match = pathname.match(/\/(?:pull|pulls|merge_requests)\/(\d+)(?:\/|$)/);
if (!match) {
return null;
}
@@ -31,7 +38,10 @@ function parsePullRequestNumber(url: string): number | null {
}
}
export function selectPrHintFromStatus(status: PrStatusLike | null | undefined): PrHint | null {
export function selectPrHintFromStatus(
status: PrStatusLike | null | undefined,
forge?: string | null,
): PrHint | null {
if (!status?.url) {
return null;
}
@@ -50,6 +60,7 @@ export function selectPrHintFromStatus(status: PrStatusLike | null | undefined):
url: status.url,
number,
state,
forge: normalizeForge(forge ?? status.forge),
checks: status.checks,
checksStatus: status.checksStatus as PrHint["checksStatus"],
reviewDecision: status.reviewDecision as PrHint["reviewDecision"],

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
import { normalizeCheckoutPrStatusPayload } from "./pr-status";
function payload(
overrides: Partial<CheckoutPrStatusResponse["payload"]> = {},
): CheckoutPrStatusResponse["payload"] {
return {
cwd: "/repo",
status: null,
githubFeaturesEnabled: true,
forge: "github",
error: null,
requestId: "pr-status-1",
...overrides,
};
}
describe("normalizeCheckoutPrStatusPayload", () => {
it("preserves known auth states", () => {
expect(normalizeCheckoutPrStatusPayload(payload({ authState: "cli_missing" })).authState).toBe(
"cli_missing",
);
});
it("derives auth from the legacy feature flag when authState is absent", () => {
expect(normalizeCheckoutPrStatusPayload(payload()).authState).toBe("authenticated");
expect(
normalizeCheckoutPrStatusPayload(payload({ githubFeaturesEnabled: false })).authState,
).toBe("unauthenticated");
});
it("does not expose an unknown wire auth state to feature code", () => {
expect(
normalizeCheckoutPrStatusPayload(
payload({ authState: "future_auth_state", githubFeaturesEnabled: false }),
).authState,
).toBe("unauthenticated");
});
});

View File

@@ -0,0 +1,21 @@
import type { CheckoutPrStatusResponse, ForgeAuthState } from "@getpaseo/protocol/messages";
import { parseForgeAuthState } from "@/git/forge";
type WireCheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
export type CheckoutPrStatusPayload = Omit<WireCheckoutPrStatusPayload, "authState"> & {
authState: ForgeAuthState;
};
export function normalizeCheckoutPrStatusPayload(
payload: WireCheckoutPrStatusPayload,
): CheckoutPrStatusPayload {
return {
...payload,
// COMPAT(forgeAuthState): added in v0.1.106, remove after 2026-12-27 once
// all supported daemons send authState.
authState:
parseForgeAuthState(payload.authState) ??
(payload.githubFeaturesEnabled ? "authenticated" : "unauthenticated"),
};
}

View File

@@ -41,6 +41,7 @@ function threadEntry(id: string, resolved = false, outdated = false): PrTimeline
kind: "thread",
id: `thread:${id}`,
location: { path: "a.ts", line: 1, isResolved: resolved, isOutdated: outdated },
isResolved: resolved,
comments: [activity(id)],
};
}
@@ -90,6 +91,18 @@ describe("pull request activity state", () => {
]);
});
it("collapses a resolved general thread that carries no location", () => {
const entries: PrTimelineEntry[] = [
{ kind: "thread", id: "thread:general", isResolved: true, comments: [activity("g1")] },
];
const visible = getVisibleEntries(getActivityState(), { prNumber: 42, entries });
expect(visible.map((v) => ({ id: v.entry.id, collapsed: v.collapsed }))).toEqual([
{ id: "thread:general", collapsed: true },
]);
});
it("lets user expand a default-collapsed entry", () => {
const entries = [threadEntry("resolved", true)];
const expanded = expandActivity(getActivityState(), {

View File

@@ -25,7 +25,7 @@ export function getActivityStateKey(identity: PullRequestActivityIdentity): stri
function shouldCollapseByDefault(entry: PrTimelineEntry): boolean {
if (entry.kind === "thread") {
return entry.location.isResolved === true || entry.location.isOutdated === true;
return entry.isResolved === true || entry.location?.isOutdated === true;
}
if (entry.kind === "single") {
return (

View File

@@ -0,0 +1,22 @@
/**
* Neutral check status mapping shared by the PR-pane data builder, the forge
* native-data contributions, and the pane render. Kept forge-agnostic: a forge
* maps its raw CI strings onto this frozen union. Forge-specific vocabularies
* (e.g. GitLab pipeline statuses) live in the owning forge module.
*/
export type CheckStatus = "success" | "failure" | "pending" | "skipped";
export function mapCheckStatus(status: string): CheckStatus {
if (
status === "success" ||
status === "failure" ||
status === "pending" ||
status === "skipped"
) {
return status;
}
if (status === "cancelled") {
return "skipped";
}
return "pending";
}

View File

@@ -11,14 +11,15 @@ import {
import type { PrPaneActivity, PrPaneCheck } from "./data";
import type { PrThreadEntry } from "./timeline";
const baseInput: Omit<PullRequestContextBuilderInput, "activity"> = {
const baseInput = {
provider: { id: "github", label: "GitHub" },
forge: "github",
pullRequest: {
number: 42,
title: "Fix flaky build",
url: "https://github.com/getpaseo/paseo/pull/42",
},
};
} satisfies Omit<PullRequestContextBuilderInput, "activity">;
function comment(overrides: Partial<PrPaneActivity> = {}): PrPaneActivity {
return {
@@ -55,7 +56,7 @@ function check(overrides: Partial<PrPaneCheck> = {}): PrPaneCheck {
provider: "github",
status: "failure",
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
github: { checkRunId: 12345, workflowRunId: 456 },
detailRef: { checkRunId: 12345, workflowRunId: 456 },
...overrides,
};
}
@@ -76,7 +77,7 @@ describe("pull request context attachments", () => {
}),
}),
).toEqual({
kind: "github.pull_request_comment",
kind: "forge.change_request_comment",
id: "42:comment-1",
title: "octocat",
subtitle: "#42 Fix flaky build",
@@ -97,7 +98,7 @@ describe("pull request context attachments", () => {
it("formats a review with state as a workspace context attachment", () => {
expect(buildPullRequestReviewContextAttachment({ ...baseInput, activity: review() })).toEqual({
kind: "github.pull_request_review",
kind: "forge.change_request_review",
id: "42:review-1",
title: "reviewer",
subtitle: "#42 Fix flaky build",
@@ -116,6 +117,41 @@ describe("pull request context attachments", () => {
});
});
it("labels a GitLab comment as a merge request with the ! prefix", () => {
expect(
buildPullRequestCommentContextAttachment({
...baseInput,
provider: { id: "gitlab", label: "GitLab" },
forge: "gitlab",
pullRequest: {
number: 14,
title: "Wire up the timeline",
url: "https://gitlab.com/acme/app/-/merge_requests/14",
},
activity: comment({
provider: "gitlab",
url: "https://gitlab.com/acme/app/-/merge_requests/14#note_401",
}),
}),
).toEqual({
kind: "forge.change_request_comment",
id: "14:comment-1",
title: "octocat",
subtitle: "!14 Wire up the timeline",
url: "https://gitlab.com/acme/app/-/merge_requests/14#note_401",
text: [
"GitLab merge request comment",
"Merge request: !14 Wire up the timeline",
"Merge request URL: https://gitlab.com/acme/app/-/merge_requests/14",
"URL: https://gitlab.com/acme/app/-/merge_requests/14#note_401",
"Author: octocat",
"Created: 3d ago",
"",
"Looks good.",
].join("\n"),
});
});
it("does not add bodyless approvals to chat", () => {
const activity = review({ reviewState: "approved", body: "" });
@@ -185,7 +221,7 @@ describe("pull request context attachments", () => {
},
}),
).toEqual({
kind: "github.pull_request_check",
kind: "forge.change_request_check",
id: "42:check-run:12345",
title: "server-tests",
subtitle: "#42 Fix flaky build",
@@ -222,12 +258,12 @@ describe("pull request context attachments", () => {
it("keeps same-named GitHub checks distinct by check run", () => {
const ubuntu = buildPullRequestCheckContextAttachment({
...baseInput,
check: check({ github: { checkRunId: 12345, workflowRunId: 456 } }),
check: check({ detailRef: { checkRunId: 12345, workflowRunId: 456 } }),
githubDetails: null,
});
const windows = buildPullRequestCheckContextAttachment({
...baseInput,
check: check({ github: { checkRunId: 67890, workflowRunId: 456 } }),
check: check({ detailRef: { checkRunId: 67890, workflowRunId: 456 } }),
githubDetails: null,
});
@@ -242,12 +278,12 @@ describe("pull request context attachments", () => {
check: check({
name: "status/context",
url: "https://github.com/getpaseo/paseo/status/context",
github: undefined,
detailRef: undefined,
}),
githubDetails: null,
}),
).toEqual({
kind: "github.pull_request_check",
kind: "forge.change_request_check",
id: "42:check:status/context",
title: "status/context",
subtitle: "#42 Fix flaky build",
@@ -262,6 +298,42 @@ describe("pull request context attachments", () => {
].join("\n"),
});
});
it("formats GitLab failed check context as a merge request", () => {
expect(
buildPullRequestCheckContextAttachment({
...baseInput,
provider: { id: "gitlab", label: "GitLab" },
forge: "gitlab",
pullRequest: {
number: 14,
title: "Wire up pipelines",
url: "https://gitlab.com/acme/app/-/merge_requests/14",
},
check: check({
name: "pipeline",
provider: "gitlab",
url: "https://gitlab.com/acme/app/-/pipelines/99",
detailRef: undefined,
}),
githubDetails: null,
}),
).toEqual({
kind: "forge.change_request_check",
id: "14:check:pipeline",
title: "pipeline",
subtitle: "!14 Wire up pipelines",
url: "https://gitlab.com/acme/app/-/pipelines/99",
text: [
"GitLab merge request check",
"Merge request: !14 Wire up pipelines",
"Merge request URL: https://gitlab.com/acme/app/-/merge_requests/14",
"Check: pipeline",
"Status: failure",
"Check URL: https://gitlab.com/acme/app/-/pipelines/99",
].join("\n"),
});
});
});
describe("buildPullRequestThreadContextAttachment", () => {
@@ -284,7 +356,7 @@ describe("buildPullRequestThreadContextAttachment", () => {
it("bundles the whole thread conversation into one attachment", () => {
expect(buildPullRequestThreadContextAttachment({ ...baseInput, thread: thread() })).toEqual({
kind: "github.pull_request_comment",
kind: "forge.change_request_comment",
id: "42:thread:PRRT_1",
title: "src/a.ts:12",
subtitle: "#42 Fix flaky build",

View File

@@ -1,5 +1,6 @@
import type { CheckoutGithubCheckDetails } from "@getpaseo/protocol/messages";
import type { CheckoutCheckDetails } from "@getpaseo/protocol/messages";
import type { PullRequestContextAttachment } from "@/attachments/types";
import { type Forge, getForgePresentation } from "@/git/forge";
import {
formatPullRequestActivityLocation,
formatPullRequestThreadPath,
@@ -15,21 +16,24 @@ export interface PullRequestContextMetadata {
export interface PullRequestContextBuilderInput {
provider: PullRequestProviderMetadata;
forge: Forge;
pullRequest: PullRequestContextMetadata;
activity: PrPaneActivity;
}
export interface PullRequestThreadContextBuilderInput {
provider: PullRequestProviderMetadata;
forge: Forge;
pullRequest: PullRequestContextMetadata;
thread: PrThreadEntry;
}
export interface PullRequestGithubCheckContextBuilderInput {
provider: PullRequestProviderMetadata & { id: "github" };
provider: PullRequestProviderMetadata;
forge: Forge;
pullRequest: PullRequestContextMetadata;
check: PrPaneCheck & { provider: "github" };
githubDetails?: CheckoutGithubCheckDetails | null;
check: PrPaneCheck;
githubDetails?: CheckoutCheckDetails | null;
}
export function canAddPullRequestActivityToChat(activity: PrPaneActivity): boolean {
@@ -46,14 +50,15 @@ export function canAddPullRequestCheckLogsToChat(check: PrPaneCheck): boolean {
export function buildPullRequestCommentContextAttachment(
input: PullRequestContextBuilderInput,
): PullRequestContextAttachment {
const presentation = getForgePresentation(input.forge);
return {
kind: "github.pull_request_comment",
kind: "forge.change_request_comment",
id: `${input.pullRequest.number}:${input.activity.id}`,
title: input.activity.author,
subtitle: formatPullRequestSubtitle(input.pullRequest),
subtitle: formatPullRequestSubtitle(input.pullRequest, input.forge),
text: formatActivityContextText({
...input,
heading: `${input.provider.label} pull request comment`,
heading: `${presentation.brandLabel} ${presentation.changeRequestNoun} comment`,
}),
url: input.activity.url,
};
@@ -66,14 +71,15 @@ export function buildPullRequestReviewContextAttachment(
return null;
}
const presentation = getForgePresentation(input.forge);
return {
kind: "github.pull_request_review",
kind: "forge.change_request_review",
id: `${input.pullRequest.number}:${input.activity.id}`,
title: input.activity.author,
subtitle: formatPullRequestSubtitle(input.pullRequest),
subtitle: formatPullRequestSubtitle(input.pullRequest, input.forge),
text: formatActivityContextText({
...input,
heading: `${input.provider.label} pull request review`,
heading: `${presentation.brandLabel} ${presentation.changeRequestNoun} review`,
reviewState: input.activity.reviewState,
}),
url: input.activity.url,
@@ -93,17 +99,23 @@ export function buildPullRequestThreadContextAttachment(
return null;
}
const presentation = getForgePresentation(input.forge);
const noun = capitalizeFirst(presentation.changeRequestNoun);
const location = input.thread.location;
const threadTitle = location ? formatPullRequestThreadPath(location) : "Discussion thread";
const lines = [
`${input.provider.label} pull request review thread`,
`Pull request: #${input.pullRequest.number} ${input.pullRequest.title}`,
`Pull request URL: ${input.pullRequest.url}`,
`${presentation.brandLabel} ${presentation.changeRequestNoun} review thread`,
`${noun}: ${presentation.numberPrefix}${input.pullRequest.number} ${input.pullRequest.title}`,
`${noun} 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 (location) {
lines.push(`Location: ${formatPullRequestThreadPath(location)}`);
}
if (input.thread.location.isOutdated) {
if (location?.isResolved !== undefined) {
lines.push(`Thread state: ${location.isResolved ? "resolved" : "unresolved"}`);
}
if (location?.isOutdated) {
lines.push("Note: this thread is outdated (the code it refers to has changed)");
}
@@ -112,10 +124,10 @@ export function buildPullRequestThreadContextAttachment(
);
return {
kind: "github.pull_request_comment",
kind: "forge.change_request_comment",
id: `${input.pullRequest.number}:${input.thread.id}`,
title: formatPullRequestThreadPath(input.thread.location),
subtitle: formatPullRequestSubtitle(input.pullRequest),
title: threadTitle,
subtitle: formatPullRequestSubtitle(input.pullRequest, input.forge),
text: [...lines, "", conversation.join("\n\n---\n\n")].join("\n"),
url: root.url,
};
@@ -125,11 +137,11 @@ export function buildPullRequestCheckContextAttachment(
input: PullRequestGithubCheckContextBuilderInput,
): PullRequestContextAttachment {
return {
kind: "github.pull_request_check",
kind: "forge.change_request_check",
id: formatPullRequestCheckContextId(input.pullRequest, input.check),
title: input.check.name,
subtitle: formatPullRequestSubtitle(input.pullRequest),
text: formatGitHubCheckContextText(input),
subtitle: formatPullRequestSubtitle(input.pullRequest, input.forge),
text: formatCheckContextText(input),
url: input.githubDetails?.detailsUrl ?? input.githubDetails?.url ?? input.check.url,
};
}
@@ -138,22 +150,25 @@ function formatPullRequestCheckContextId(
pullRequest: PullRequestContextMetadata,
check: PrPaneCheck,
): string {
if (check.github?.checkRunId !== undefined) {
return `${pullRequest.number}:check-run:${check.github.checkRunId}`;
if (check.detailRef?.checkRunId !== undefined) {
return `${pullRequest.number}:check-run:${check.detailRef.checkRunId}`;
}
return `${pullRequest.number}:check:${check.name}`;
}
function formatGitHubCheckContextText({
function formatCheckContextText({
provider,
forge,
pullRequest,
check,
githubDetails,
}: PullRequestGithubCheckContextBuilderInput): string {
const presentation = getForgePresentation(forge);
const noun = capitalizeFirst(presentation.changeRequestNoun);
const lines = [
`${provider.label} pull request check`,
`Pull request: #${pullRequest.number} ${pullRequest.title}`,
`Pull request URL: ${pullRequest.url}`,
`${provider.label} ${presentation.changeRequestNoun} check`,
`${noun}: ${presentation.numberPrefix}${pullRequest.number} ${pullRequest.title}`,
`${noun} URL: ${pullRequest.url}`,
`Check: ${check.name}`,
`Status: ${check.status}`,
];
@@ -170,7 +185,7 @@ function formatGitHubCheckContextText({
appendGitHubCheckAnnotations(lines, githubDetails);
appendGitHubFailedJobs(lines, githubDetails);
if (githubDetails?.truncated) {
lines.push("", "Note: Check details were truncated by GitHub/API or local caps.");
lines.push("", `Note: Check details were truncated by ${provider.label}/API or local caps.`);
}
return lines.join("\n");
@@ -178,7 +193,7 @@ function formatGitHubCheckContextText({
function appendGitHubCheckOutput(
lines: string[],
details: CheckoutGithubCheckDetails | null | undefined,
details: CheckoutCheckDetails | null | undefined,
) {
if (details?.output?.title) {
lines.push(`Output title: ${details.output.title}`);
@@ -193,7 +208,7 @@ function appendGitHubCheckOutput(
function appendGitHubCheckAnnotations(
lines: string[],
details: CheckoutGithubCheckDetails | null | undefined,
details: CheckoutCheckDetails | null | undefined,
) {
if (!details?.annotations?.length) {
return;
@@ -204,10 +219,7 @@ function appendGitHubCheckAnnotations(
}
}
function appendGitHubFailedJobs(
lines: string[],
details: CheckoutGithubCheckDetails | null | undefined,
) {
function appendGitHubFailedJobs(lines: string[], details: CheckoutCheckDetails | null | undefined) {
if (!details?.failedJobs?.length) {
return;
}
@@ -226,7 +238,7 @@ function appendGitHubFailedJobs(
}
}
function formatAnnotation(annotation: CheckoutGithubCheckDetails["annotations"][number]): string {
function formatAnnotation(annotation: CheckoutCheckDetails["annotations"][number]): string {
const location = annotation.path
? `${annotation.path}${formatAnnotationLines(annotation)}`
: "unknown location";
@@ -235,9 +247,7 @@ function formatAnnotation(annotation: CheckoutGithubCheckDetails["annotations"][
return `${location}${level}${message}`;
}
function formatAnnotationLines(
annotation: CheckoutGithubCheckDetails["annotations"][number],
): string {
function formatAnnotationLines(annotation: CheckoutCheckDetails["annotations"][number]): string {
if (annotation.startLine !== undefined && annotation.endLine !== undefined) {
return `:${annotation.startLine}-${annotation.endLine}`;
}
@@ -249,14 +259,17 @@ function formatAnnotationLines(
function formatActivityContextText({
heading,
forge,
pullRequest,
activity,
reviewState,
}: PullRequestContextBuilderInput & { heading: string; reviewState?: ReviewState }): string {
const presentation = getForgePresentation(forge);
const noun = capitalizeFirst(presentation.changeRequestNoun);
const lines = [
heading,
`Pull request: #${pullRequest.number} ${pullRequest.title}`,
`Pull request URL: ${pullRequest.url}`,
`${noun}: ${presentation.numberPrefix}${pullRequest.number} ${pullRequest.title}`,
`${noun} URL: ${pullRequest.url}`,
`URL: ${activity.url}`,
`Author: ${activity.author}`,
];
@@ -279,6 +292,10 @@ function formatActivityContextText({
return [...lines, "", body].join("\n");
}
function formatPullRequestSubtitle(pullRequest: PullRequestContextMetadata): string {
return `#${pullRequest.number} ${pullRequest.title}`;
function formatPullRequestSubtitle(pullRequest: PullRequestContextMetadata, forge: Forge): string {
return `${getForgePresentation(forge).numberPrefix}${pullRequest.number} ${pullRequest.title}`;
}
function capitalizeFirst(value: string): string {
return value.length === 0 ? value : `${value[0].toUpperCase()}${value.slice(1)}`;
}

View File

@@ -3,6 +3,7 @@ import type {
CheckoutPrStatusResponse,
PullRequestTimelineResponse,
} from "@getpaseo/protocol/messages";
import { isPipelineActiveStatus, mapPipelineStatus } from "@/git/forges/gitlab";
import {
deriveAvatarColor,
formatAge,
@@ -33,6 +34,7 @@ const githubStatus: CheckoutPrStatus["github"] = {
};
const baseStatus: CheckoutPrStatus = {
forge: "github",
number: 42,
url: "https://github.com/getpaseo/paseo/pull/42",
title: "Wire PR pane data",
@@ -184,11 +186,30 @@ describe("mapPrPaneData", () => {
name: "server-tests",
status: "failure",
url: "https://github.com/getpaseo/paseo/actions/runs/456/job/789",
github: { checkRunId: 12345, workflowRunId: 456 },
detailRef: { checkRunId: 12345, workflowRunId: 456 },
},
]);
});
it("keeps a workflowRunId-only detail ref so Gitea Actions rows stay fetchable", () => {
const data = mapPrPaneData(
status({
forge: "gitea",
checks: [
{
name: "e2e",
status: "failure",
url: "https://gitea.com/acme/repo/actions/runs/7001",
workflowRunId: 7001,
},
],
}),
baseTimeline,
);
expect(data?.checks[0]?.detailRef).toEqual({ workflowRunId: 7001 });
});
it("preserves timeline item order while mapping mixed reviews and comments", () => {
const data = mapPrPaneData(
baseStatus,
@@ -272,6 +293,56 @@ describe("mapPrPaneData", () => {
]);
});
it("maps a top-level threadId onto general (non-file) discussion comments", () => {
const data = mapPrPaneData(
baseStatus,
timeline({
items: [
{
id: "note-1",
kind: "comment",
author: "reviewer-a",
body: "Can you clarify the rollout?",
createdAt: Date.UTC(2026, 0, 1, 11, 0, 0),
url: "https://gitlab.example.com/group/project/-/merge_requests/42#note_1",
threadId: "disc-1",
},
],
}),
Date.UTC(2026, 0, 1, 12, 0, 0),
"gitlab",
);
expect(data?.activity).toHaveLength(1);
expect(data?.activity[0]).toMatchObject({ id: "note-1", threadId: "disc-1" });
expect(data?.activity[0].location).toBeUndefined();
});
it("maps thread-level resolution onto general (non-file) discussion comments", () => {
const data = mapPrPaneData(
baseStatus,
timeline({
items: [
{
id: "note-2",
kind: "comment",
author: "reviewer-a",
body: "Resolved general discussion.",
createdAt: Date.UTC(2026, 0, 1, 11, 0, 0),
url: "https://gitlab.example.com/group/project/-/merge_requests/42#note_2",
threadId: "disc-2",
threadIsResolved: true,
},
],
}),
Date.UTC(2026, 0, 1, 12, 0, 0),
"gitlab",
);
expect(data?.activity[0]).toMatchObject({ id: "note-2", threadIsResolved: true });
expect(data?.activity[0].location).toBeUndefined();
});
it("filters empty commented reviews but keeps blocking review states", () => {
const data = mapPrPaneData(
baseStatus,
@@ -388,6 +459,23 @@ describe("mapPrPaneData", () => {
expect(mapPrPaneData(baseStatus, baseTimeline)?.awaitingReviewers).toEqual([]);
});
it("defaults the forge to github and omits the project path when neither is supplied", () => {
const data = mapPrPaneData(baseStatus, baseTimeline);
expect(data?.forge).toBe("github");
expect(data?.projectPath).toBeUndefined();
});
it("carries the resolved forge and the nested project path for GitLab", () => {
const data = mapPrPaneData(
status({ projectPath: "group/subgroup/repo" }),
baseTimeline,
undefined,
"gitlab",
);
expect(data?.forge).toBe("gitlab");
expect(data?.projectPath).toBe("group/subgroup/repo");
});
it("rejects stale timeline activity when the timeline PR number differs from status", () => {
const data = mapPrPaneData(
baseStatus,
@@ -409,6 +497,169 @@ describe("mapPrPaneData", () => {
expect(data?.activity).toEqual([]);
});
it("passes the forge native facts through for pane contributions to derive surfaces", () => {
const gitlabFacts = {
forge: "gitlab" as const,
detailedMergeStatus: "mergeable",
hasConflicts: false,
blockingDiscussionsResolved: true,
approvalsRequired: 2,
approvalsGiven: 1,
pipelineStatus: "running",
pipelineId: 306,
pipelineUrl: "https://gitlab.com/group/repo/-/pipelines/306",
mergeWhenPipelineSucceeds: false,
};
const data = mapPrPaneData(
status({
url: "https://gitlab.com/group/repo/-/merge_requests/7",
github: undefined,
forgeSpecific: gitlabFacts,
}),
baseTimeline,
undefined,
"gitlab",
);
expect(data?.forgeSpecific).toEqual({ ...gitlabFacts, mergeStatus: null });
});
it("omits forgeSpecific when the status carries no native facts", () => {
expect(mapPrPaneData(baseStatus, baseTimeline)?.forgeSpecific).toBeUndefined();
});
it("omits forgeSpecific when no registered forge schema accepts it", () => {
const data = mapPrPaneData(
status({ forgeSpecific: { forge: "gitlab", approvalsRequired: "two" } }),
baseTimeline,
);
expect(data?.forgeSpecific).toBeUndefined();
});
it("surfaces Gitea aggregate CI status as a check row", () => {
const data = mapPrPaneData(
status({
forge: "gitea",
url: "https://gitea.com/group/repo/pulls/7",
github: undefined,
forgeSpecific: {
forge: "gitea",
mergeable: true,
hasMerged: false,
ciStatus: "success",
},
}),
baseTimeline,
undefined,
"gitea",
);
expect(data?.checks).toEqual([
{
provider: "gitea",
name: "CI",
status: "success",
url: "https://gitea.com/group/repo/pulls/7",
},
]);
});
it("keeps Forgejo branding for aggregate Gitea-family CI status", () => {
const data = mapPrPaneData(
status({
forge: "forgejo",
url: "https://forgejo.example.com/group/repo/pulls/7",
github: undefined,
forgeSpecific: {
forge: "gitea",
mergeable: true,
hasMerged: false,
ciStatus: "failure",
},
}),
baseTimeline,
undefined,
"forgejo",
);
expect(data?.checks).toEqual([
{
provider: "forgejo",
name: "CI",
status: "failure",
url: "https://forgejo.example.com/group/repo/pulls/7",
},
]);
});
it("carries the resolved forge brand and activity provider for GitLab", () => {
const data = mapPrPaneData(
status({
url: "https://gitlab.com/group/repo/-/merge_requests/7",
github: undefined,
forgeSpecific: {
forge: "gitlab",
detailedMergeStatus: "mergeable",
hasConflicts: false,
blockingDiscussionsResolved: true,
approvalsRequired: 2,
approvalsGiven: 1,
pipelineStatus: null,
pipelineId: null,
pipelineUrl: null,
mergeWhenPipelineSucceeds: false,
},
}),
timeline({
items: [
{
id: "note-1",
kind: "comment",
author: "reviewer",
body: "Looks good",
createdAt: 1000,
url: "https://gitlab.com/group/repo/-/merge_requests/7#note_1",
},
],
}),
2000,
"gitlab",
);
expect(data?.provider).toEqual({ id: "gitlab", label: "GitLab" });
expect(data?.activity[0]?.provider).toBe("gitlab");
});
});
describe("mapPipelineStatus", () => {
it("maps GitLab and neutral pipeline statuses onto check statuses", () => {
expect(mapPipelineStatus("success")).toBe("success");
expect(mapPipelineStatus("passed")).toBe("success");
expect(mapPipelineStatus("failed")).toBe("failure");
expect(mapPipelineStatus("canceled")).toBe("skipped");
expect(mapPipelineStatus("skipped")).toBe("skipped");
expect(mapPipelineStatus("manual")).toBe("pending");
expect(mapPipelineStatus("running")).toBe("pending");
expect(mapPipelineStatus("pending")).toBe("pending");
expect(mapPipelineStatus("created")).toBe("pending");
expect(mapPipelineStatus("waiting_for_resource")).toBe("pending");
expect(mapPipelineStatus("preparing")).toBe("pending");
expect(mapPipelineStatus("scheduled")).toBe("pending");
expect(mapPipelineStatus("anything-else")).toBe("pending");
});
it("marks running and queued pipeline statuses as live for polling", () => {
expect(isPipelineActiveStatus("running")).toBe(true);
expect(isPipelineActiveStatus("pending")).toBe(true);
expect(isPipelineActiveStatus("created")).toBe(true);
expect(isPipelineActiveStatus("waiting_for_resource")).toBe(true);
expect(isPipelineActiveStatus("preparing")).toBe(true);
expect(isPipelineActiveStatus("scheduled")).toBe(true);
expect(isPipelineActiveStatus("success")).toBe(false);
expect(isPipelineActiveStatus("failed")).toBe(false);
expect(isPipelineActiveStatus("canceled")).toBe(false);
});
});
describe("deriveAvatarColor", () => {

View File

@@ -2,12 +2,18 @@ import type {
CheckoutPrStatusResponse,
PullRequestTimelineResponse,
} from "@getpaseo/protocol/messages";
import { type Forge, getForgePresentation } from "@/git/forge";
import { parseClientForgeFacts } from "@/git/forges";
import type { ForgeSpecificStatusFacts } from "@/git/merge-capability";
import { type CheckStatus, mapCheckStatus } from "./check-status";
import { getNativeFallbackChecks } from "./native-data";
export type { CheckStatus } from "./check-status";
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 type PullRequestProvider = Forge;
export interface PullRequestProviderMetadata {
id: PullRequestProvider;
@@ -15,8 +21,6 @@ export interface PullRequestProviderMetadata {
url?: string | null;
}
const GITHUB_PROVIDER: PullRequestProviderMetadata = { id: "github", label: "GitHub" };
export interface PrPaneCheck {
provider: PullRequestProvider;
name: string;
@@ -24,7 +28,12 @@ export interface PrPaneCheck {
status: CheckStatus;
duration?: string;
url: string;
github?: {
/**
* Forge-neutral reference for fetching this check's detail/logs on demand. Any
* forge that exposes a check-run id populates it; the daemon resolves the logs
* through the neutral check-details RPC.
*/
detailRef?: {
checkRunId?: number;
workflowRunId?: number;
};
@@ -44,6 +53,16 @@ export interface PrPaneActivity {
url: string;
/** For inline review comments: the review this comment was submitted with. */
reviewId?: string;
/**
* Forge-neutral discussion id, independent of a file position. Groups general
* (non-file) reply chains into one thread; file threads also carry it.
*/
threadId?: string;
/**
* Resolution state for a thread with no file position (e.g. a GitLab general
* discussion). File threads carry resolution under `location.isResolved`.
*/
threadIsResolved?: boolean;
location?: {
path: string;
line?: number;
@@ -56,15 +75,25 @@ export interface PrPaneActivity {
export interface PrPaneData {
provider: PullRequestProviderMetadata;
/** The forge hosting this change request. */
forge: Forge;
number: number;
repoOwner?: string;
repoName?: string;
/** Neutral project identity (GitLab namespaces nest beyond owner/name). */
projectPath?: string;
title: string;
state: PrState;
url: string;
reviewDecision: "approved" | "changes_requested" | "pending";
awaitingReviewers: string[];
checks: PrPaneCheck[];
/**
* The forge's already-validated native facts, passed through so pane native
* contributions (e.g. GitLab pipeline/approvals) derive their surfaces without
* the neutral data type carrying forge-specific fields.
*/
forgeSpecific?: ForgeSpecificStatusFacts;
activity: PrPaneActivity[];
}
@@ -87,6 +116,7 @@ export function mapPrPaneData(
status: CheckoutPrStatus,
timeline: PullRequestTimeline | null | undefined,
nowMs = Date.now(),
forge: Forge = "github",
): PrPaneData | null {
if (!status) {
return null;
@@ -98,25 +128,34 @@ export function mapPrPaneData(
}
const timelineMatchesStatus = timeline?.prNumber === number;
const provider = toProviderMetadata(forge);
const forgeSpecific = parseClientForgeFacts(status.forgeSpecific);
return {
provider: GITHUB_PROVIDER,
provider,
forge,
number,
repoOwner: status.repoOwner,
repoName: status.repoName,
projectPath: status.projectPath,
title: status.title,
state: derivePrState(status),
url: status.url,
reviewDecision: mapReviewDecision(status.reviewDecision),
// Requested reviewers are intentionally unwired until the server exposes them.
awaitingReviewers: [],
checks: (status.checks ?? []).flatMap(mapCheck),
checks: mapChecks(status, forge),
...(forgeSpecific ? { forgeSpecific } : {}),
activity: timelineMatchesStatus
? timeline.items.flatMap((item) => mapActivity(item, nowMs))
? timeline.items.flatMap((item) => mapActivity(item, nowMs, forge))
: [],
};
}
function toProviderMetadata(forge: Forge): PullRequestProviderMetadata {
return { id: forge, label: getForgePresentation(forge).brandLabel };
}
export function deriveAvatarColor(login: string): string {
return AVATAR_COLORS[hashLogin(login) % AVATAR_COLORS.length];
}
@@ -164,14 +203,25 @@ function derivePrState(status: NonNullable<CheckoutPrStatus>): PrState {
return "open";
}
function mapCheck(check: NonNullable<CheckoutPrStatus>["checks"][number]): PrPaneCheck[] {
function mapChecks(status: NonNullable<CheckoutPrStatus>, forge: Forge): PrPaneCheck[] {
const checks = (status.checks ?? []).flatMap((check) => mapCheck(check, forge));
if (checks.length > 0) {
return checks;
}
return getNativeFallbackChecks(status, forge);
}
function mapCheck(
check: NonNullable<CheckoutPrStatus>["checks"][number],
forge: Forge,
): PrPaneCheck[] {
if (check.url === null) {
return [];
}
return [
{
provider: "github",
provider: forge,
name: check.name,
status: mapCheckStatus(check.status),
url: check.url,
@@ -179,7 +229,7 @@ function mapCheck(check: NonNullable<CheckoutPrStatus>["checks"][number]): PrPan
...(check.duration ? { duration: check.duration } : {}),
...(check.checkRunId !== undefined || check.workflowRunId !== undefined
? {
github: {
detailRef: {
...(check.checkRunId !== undefined ? { checkRunId: check.checkRunId } : {}),
...(check.workflowRunId !== undefined ? { workflowRunId: check.workflowRunId } : {}),
},
@@ -189,22 +239,7 @@ function mapCheck(check: NonNullable<CheckoutPrStatus>["checks"][number]): PrPan
];
}
function mapCheckStatus(status: string): CheckStatus {
if (
status === "success" ||
status === "failure" ||
status === "pending" ||
status === "skipped"
) {
return status;
}
if (status === "cancelled") {
return "skipped";
}
return "pending";
}
function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivity[] {
function mapActivity(item: PullRequestTimelineItem, nowMs: number, forge: Forge): PrPaneActivity[] {
if (item.kind === "comment") {
if (item.body.trim() === "") {
return [];
@@ -212,7 +247,7 @@ function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivi
return [
{
id: item.id,
provider: "github",
provider: forge,
kind: "comment",
author: item.author,
authorUrl: item.authorUrl,
@@ -222,6 +257,8 @@ function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivi
age: formatAge(item.createdAt, nowMs),
url: item.url,
reviewId: item.reviewId,
threadId: item.threadId,
threadIsResolved: item.threadIsResolved,
location: item.location,
},
];
@@ -234,7 +271,7 @@ function mapActivity(item: PullRequestTimelineItem, nowMs: number): PrPaneActivi
return [
{
id: item.id,
provider: "github",
provider: forge,
kind: "review",
author: item.author,
authorUrl: item.authorUrl,

View File

@@ -0,0 +1,160 @@
import { describe, expect, it } from "vitest";
import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
import {
deriveGitlabApprovals,
deriveGitlabPipelineSummary,
type GitlabMergeFacts,
} from "@/git/forges/gitlab";
import { getNativeFallbackChecks } from "./native-data";
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
function gitlabFacts(overrides: Partial<GitlabMergeFacts> = {}): GitlabMergeFacts {
return {
forge: "gitlab",
detailedMergeStatus: "mergeable",
mergeStatus: null,
hasConflicts: false,
blockingDiscussionsResolved: true,
approvalsRequired: 0,
approvalsGiven: 0,
pipelineStatus: null,
pipelineId: null,
pipelineUrl: null,
mergeWhenPipelineSucceeds: false,
...overrides,
};
}
function status(forgeSpecific: CheckoutPrStatus["forgeSpecific"], url: string): CheckoutPrStatus {
return {
forge: "gitea",
number: 7,
url,
title: "Native data",
state: "open",
baseRefName: "main",
headRefName: "feature",
isMerged: false,
isDraft: false,
mergeable: "UNKNOWN",
checks: [],
reviewDecision: null,
...(forgeSpecific ? { forgeSpecific } : {}),
};
}
describe("deriveGitlabPipelineSummary", () => {
it("summarizes a head pipeline, mapping the raw status onto a check status", () => {
expect(
deriveGitlabPipelineSummary(
gitlabFacts({
pipelineStatus: "running",
pipelineId: 306,
pipelineUrl: "https://gitlab.com/group/repo/-/pipelines/306",
}),
),
).toEqual({
id: 306,
status: "pending",
rawStatus: "running",
url: "https://gitlab.com/group/repo/-/pipelines/306",
});
});
it("returns null when the MR has no head pipeline", () => {
expect(deriveGitlabPipelineSummary(gitlabFacts({ pipelineId: null }))).toBeNull();
});
});
describe("deriveGitlabApprovals", () => {
it("surfaces N of M approvals", () => {
expect(deriveGitlabApprovals(gitlabFacts({ approvalsRequired: 2, approvalsGiven: 1 }))).toEqual(
{
given: 1,
required: 2,
},
);
});
it("returns null when no approvals are required", () => {
expect(deriveGitlabApprovals(gitlabFacts({ approvalsRequired: 0 }))).toBeNull();
});
});
describe("getNativeFallbackChecks", () => {
it("surfaces the Gitea aggregate CI status branded with the top-level forge", () => {
expect(
getNativeFallbackChecks(
status(
{ forge: "gitea", mergeable: true, hasMerged: false, ciStatus: "success" },
"https://gitea.com/group/repo/pulls/7",
),
"gitea",
),
).toEqual([
{
provider: "gitea",
name: "CI",
status: "success",
url: "https://gitea.com/group/repo/pulls/7",
},
]);
});
it("keeps Forgejo branding for the Gitea-family aggregate check", () => {
expect(
getNativeFallbackChecks(
status(
{ forge: "gitea", mergeable: true, hasMerged: false, ciStatus: "failure" },
"https://forgejo.example.com/group/repo/pulls/7",
),
"forgejo",
),
).toEqual([
{
provider: "forgejo",
name: "CI",
status: "failure",
url: "https://forgejo.example.com/group/repo/pulls/7",
},
]);
});
it("maps a Gitea 'warning' aggregate to a failing fallback check, not pending", () => {
expect(
getNativeFallbackChecks(
status(
{ forge: "gitea", mergeable: true, hasMerged: false, ciStatus: "warning" },
"https://gitea.com/group/repo/pulls/7",
),
"gitea",
),
).toEqual([
{
provider: "gitea",
name: "CI",
status: "failure",
url: "https://gitea.com/group/repo/pulls/7",
},
]);
});
it("returns no fallback checks for a forge that reports none", () => {
expect(
getNativeFallbackChecks(
status(
{ forge: "gitea", mergeable: true, hasMerged: false, ciStatus: null },
"https://gitea.com/group/repo/pulls/7",
),
"gitea",
),
).toEqual([]);
expect(
getNativeFallbackChecks(
status(gitlabFacts(), "https://gitlab.com/group/repo/-/merge_requests/7"),
"gitlab",
),
).toEqual([]);
});
});

View File

@@ -0,0 +1,34 @@
/**
* Compatibility exports for tests and non-visual derivations. Rich PR-pane
* contributions now come from client forge modules and validate the open
* `forgeSpecific` envelope at runtime.
*/
import type { CheckoutPrStatusResponse } from "@getpaseo/protocol/messages";
import type { Forge } from "@/git/forge";
import { CLIENT_FORGE_LOGIC_MODULES } from "@/git/forges";
import type { PrPaneCheck } from "./data";
type CheckoutPrStatus = NonNullable<CheckoutPrStatusResponse["payload"]["status"]>;
const CLIENT_NATIVE_FALLBACK_CONTRIBUTIONS = CLIENT_FORGE_LOGIC_MODULES.flatMap(
(module) => module.facts?.nativeFallbackChecks ?? [],
);
/**
* Fallback check rows for a status whose forge reports no individual checks.
* Returns [] for forges that don't synthesize one.
*/
export function getNativeFallbackChecks(status: CheckoutPrStatus, forge: Forge): PrPaneCheck[] {
const facts = status.forgeSpecific;
if (!facts) {
return [];
}
const checks: PrPaneCheck[] = [];
for (const contribute of CLIENT_NATIVE_FALLBACK_CONTRIBUTIONS) {
const check = contribute.contribute(facts, status, forge);
if (check) {
checks.push(check);
}
}
return checks;
}

View File

@@ -10,11 +10,7 @@ import {
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
ChevronDown,
ChevronRight,
CircleCheck,
CircleDot,
CircleSlash,
CircleX,
Copy,
ExternalLink,
@@ -50,6 +46,9 @@ import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { isNative } from "@/constants/platform";
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { getForgePresentation } from "@/git/forge";
import { CLIENT_FORGE_VIEW_MODULES } from "@/git/forges/view";
import type { PaneNativeContribution } from "@/git/client-forge-module";
import { PrActivitySkeleton } from "./activity-skeleton";
import {
collapseActivity,
@@ -68,19 +67,28 @@ import {
canAddPullRequestCheckLogsToChat,
} from "./context-attachment";
import { getActivityVerb, getStateLabel } from "./data";
import type { CheckStatus, PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data";
import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data";
import type { ForgeSpecificStatusFacts } from "@/git/merge-capability";
import {
buildPrTimeline,
type PrReviewEntry,
type PrThreadEntry,
type PrTimelineEntry,
} from "./timeline";
import {
CheckStatusIcon,
Section,
SUMMARY_DANGER_ICON,
SUMMARY_SUCCESS_ICON,
SUMMARY_WARNING_ICON,
SummaryPill,
dangerColorMapping,
foregroundMutedColorMapping,
sectionKitStyles,
successColorMapping,
} from "./section-kit";
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const ThemedCircleSlash = withUnistyles(CircleSlash);
const ThemedCircleX = withUnistyles(CircleX);
const ThemedCopy = withUnistyles(Copy);
const ThemedExternalLink = withUnistyles(ExternalLink);
@@ -95,12 +103,20 @@ const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const successColorMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
const mergedColorMapping = (theme: Theme) => ({ color: theme.colors.statusMerged });
const CLIENT_PANE_CONTRIBUTIONS: readonly PaneNativeContribution[] =
CLIENT_FORGE_VIEW_MODULES.flatMap((module) => module.paneContributions ?? []);
function resolvePaneContribution(
facts: ForgeSpecificStatusFacts | undefined,
): PaneNativeContribution | null {
if (!facts) {
return null;
}
return CLIENT_PANE_CONTRIBUTIONS.find((contribution) => contribution.guard(facts)) ?? null;
}
type IconColorMapping = typeof foregroundColorMapping;
interface PrStatePresentation {
@@ -115,9 +131,6 @@ const PR_STATE_PRESENTATION: Record<PrState, PrStatePresentation> = {
closed: { Icon: ThemedGitPullRequestClosed, iconColor: dangerColorMapping },
};
const SUMMARY_SUCCESS_ICON = <ThemedCircleCheck size={12} uniProps={successColorMapping} />;
const SUMMARY_DANGER_ICON = <ThemedCircleX size={12} uniProps={dangerColorMapping} />;
const SUMMARY_WARNING_ICON = <ThemedCircleDot size={12} uniProps={warningColorMapping} />;
const SUMMARY_COMMENT_ICON = (
<ThemedMessageSquare size={11} uniProps={foregroundMutedColorMapping} />
);
@@ -133,7 +146,7 @@ function handleMarkdownLinkPress(url: string): boolean {
}
function rowPressableStyle({ hovered }: { hovered?: boolean }) {
return [styles.checkRow, Boolean(hovered) && styles.hoverable];
return [sectionKitStyles.checkRow, Boolean(hovered) && styles.hoverable];
}
function entryHeaderPressableStyle({ hovered }: { hovered?: boolean }) {
@@ -163,8 +176,11 @@ function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
}
function getCheckIdentity(check: PrPaneCheck): string {
if (check.github?.checkRunId !== undefined) {
return `${check.provider}:check-run:${check.github.checkRunId}`;
if (check.detailRef?.checkRunId !== undefined) {
return `${check.provider}:check-run:${check.detailRef.checkRunId}`;
}
if (check.detailRef?.workflowRunId !== undefined) {
return `${check.provider}:workflow-run:${check.detailRef.workflowRunId}`;
}
return `${check.provider}:${check.name}:${check.url}`;
}
@@ -203,9 +219,17 @@ export function PullRequestPane({
const { t } = useTranslation();
const toast = useToast();
const daemonClient = useHostRuntimeClient(serverId);
// COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once
// all supported clients use checkout.forge.get_check_details.*.
const canFetchGitHubCheckDetails = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.githubCheckDetails === true,
);
const canFetchForgeCheckDetails = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeCheckDetails === true,
);
const forgeProvidersEnabled = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeProviders === true,
);
const addWorkspaceAttachment = useWorkspaceAttachmentsStore(
(state) => state.addWorkspaceAttachment,
);
@@ -281,6 +305,7 @@ export function PullRequestPane({
}
const input = {
provider: data.provider,
forge: data.forge,
pullRequest: { number: data.number, title: data.title, url: data.url },
activity,
};
@@ -298,6 +323,7 @@ export function PullRequestPane({
},
[
addWorkspaceAttachment,
data.forge,
data.number,
data.provider,
data.title,
@@ -313,6 +339,7 @@ export function PullRequestPane({
}
const attachment = buildPullRequestThreadContextAttachment({
provider: data.provider,
forge: data.forge,
pullRequest: { number: data.number, title: data.title, url: data.url },
thread,
});
@@ -326,6 +353,7 @@ export function PullRequestPane({
},
[
addWorkspaceAttachment,
data.forge,
data.number,
data.provider,
data.title,
@@ -345,7 +373,7 @@ export function PullRequestPane({
}
const threads = entry.kind === "thread" ? [entry] : entry.threads;
for (const thread of threads) {
if (thread.location.isResolved === true) {
if (thread.isResolved === true) {
continue;
}
handleAddThreadToChat(thread);
@@ -363,23 +391,32 @@ export function PullRequestPane({
let details = null;
try {
const ref = check.github;
const ref = check.detailRef;
// The neutral forge RPC fetches detail for any forge; fall back to the
// legacy github-only RPC only for GitHub against a daemon that predates
// it. A non-GitHub forge therefore needs the neutral capability present.
const canFetchDetail =
canFetchForgeCheckDetails || (check.provider === "github" && canFetchGitHubCheckDetails);
if (
canFetchGitHubCheckDetails &&
canFetchDetail &&
daemonClient &&
check.provider === "github" &&
ref?.checkRunId !== undefined &&
(ref?.checkRunId !== undefined || ref?.workflowRunId !== undefined) &&
data.repoOwner &&
data.repoName
) {
try {
const payload = await daemonClient.checkoutGithubGetCheckDetails({
const request = {
cwd,
repoOwner: data.repoOwner,
repoName: data.repoName,
checkRunId: ref.checkRunId,
workflowRunId: ref.workflowRunId,
});
};
// COMPAT(githubCheckDetailsRpc): added in v0.1.106, remove after 2026-12-28 once
// all supported clients use checkout.forge.get_check_details.*.
const payload = canFetchForgeCheckDetails
? await daemonClient.checkoutForgeGetCheckDetails(request)
: await daemonClient.checkoutGithubGetCheckDetails(request);
details = payload.success ? payload.details : null;
} catch {
details = null;
@@ -387,6 +424,7 @@ export function PullRequestPane({
}
const attachment = buildPullRequestCheckContextAttachment({
provider: data.provider,
forge: data.forge,
pullRequest: { number: data.number, title: data.title, url: data.url },
check,
githubDetails: details,
@@ -403,9 +441,11 @@ export function PullRequestPane({
},
[
addWorkspaceAttachment,
canFetchForgeCheckDetails,
canFetchGitHubCheckDetails,
cwd,
daemonClient,
data.forge,
data.number,
data.provider,
data.repoName,
@@ -428,6 +468,28 @@ export function PullRequestPane({
const statePresentation = PR_STATE_PRESENTATION[data.state];
const StateIcon = statePresentation.Icon;
const forgePresentation = getForgePresentation(data.forge);
const repoIdentity =
data.projectPath ??
(data.repoOwner && data.repoName ? `${data.repoOwner}/${data.repoName}` : null);
// Native forge surfaces (e.g. GitLab approvals/pipeline) come from a registry
// keyed by the facts-family, so the central render has no per-forge branch.
const nativeContribution = resolvePaneContribution(data.forgeSpecific);
const nativeHeaderMeta = data.forgeSpecific
? nativeContribution?.renderHeaderMeta(data.forgeSpecific)
: null;
const nativeChecksSection = data.forgeSpecific
? nativeContribution?.renderChecksSection(data.forgeSpecific, {
serverId,
cwd,
changeRequestNumber: data.number,
open: checksOpen,
onToggle: handleToggleChecks,
enabled: forgeProvidersEnabled,
canFetchCheckDetails: canFetchForgeCheckDetails,
})
: null;
return (
<View style={styles.root} testID="pr-pane">
@@ -451,7 +513,7 @@ export function PullRequestPane({
accessibilityLabel={
isRefreshing
? t("workspace.git.diff.refreshing")
: t("workspace.git.diff.refreshState")
: t("workspace.git.diff.refreshState", { brand: forgePresentation.brandLabel })
}
testID="pr-pane-refresh"
style={refreshButtonStyle}
@@ -478,16 +540,21 @@ export function PullRequestPane({
<>
<Text style={styles.title} testID="pr-pane-title">
{data.title}
<Text style={styles.titleNumber}> #{data.number}</Text>
<Text style={styles.titleNumber}>
{" "}
{forgePresentation.numberPrefix}
{data.number}
</Text>
</Text>
<View style={styles.metaLine}>
<StateIcon size={14} uniProps={statePresentation.iconColor} />
<Text style={stateLabelStyle(data.state)} testID="pr-pane-state">
{getStateLabel(data.state)}
</Text>
{data.repoOwner && data.repoName ? (
{nativeHeaderMeta}
{repoIdentity ? (
<Text style={styles.repoRef} numberOfLines={1}>
{data.repoOwner}/{data.repoName}
{repoIdentity}
</Text>
) : null}
<View style={hovered ? styles.headerLinkIcon : styles.headerLinkIconHidden}>
@@ -498,50 +565,52 @@ export function PullRequestPane({
)}
</Pressable>
<Section
title="Checks"
open={checksOpen}
onToggle={handleToggleChecks}
summary={
<>
<SummaryPill
count={passed}
icon={SUMMARY_SUCCESS_ICON}
variant="success"
testID="pr-pane-check-passed"
/>
<SummaryPill
count={failed}
icon={SUMMARY_DANGER_ICON}
variant="danger"
testID="pr-pane-check-failed"
/>
<SummaryPill
count={pending}
icon={SUMMARY_WARNING_ICON}
variant="warning"
testID="pr-pane-check-pending"
/>
</>
}
>
{data.checks.length === 0 ? (
<Text style={styles.emptyText}>No checks</Text>
) : (
data.checks.map((check) => {
const checkKey = getCheckIdentity(check);
return (
<CheckRow
key={checkKey}
check={check}
attachEnabled={attachEnabled}
isAddingLogsToChat={loadingCheckKeys.has(checkKey)}
onAddLogsToChat={handleAddCheckLogsToChat}
{nativeChecksSection ?? (
<Section
title="Checks"
open={checksOpen}
onToggle={handleToggleChecks}
summary={
<>
<SummaryPill
count={passed}
icon={SUMMARY_SUCCESS_ICON}
variant="success"
testID="pr-pane-check-passed"
/>
);
})
)}
</Section>
<SummaryPill
count={failed}
icon={SUMMARY_DANGER_ICON}
variant="danger"
testID="pr-pane-check-failed"
/>
<SummaryPill
count={pending}
icon={SUMMARY_WARNING_ICON}
variant="warning"
testID="pr-pane-check-pending"
/>
</>
}
>
{data.checks.length === 0 ? (
<Text style={sectionKitStyles.emptyText}>No checks</Text>
) : (
data.checks.map((check) => {
const checkKey = getCheckIdentity(check);
return (
<CheckRow
key={checkKey}
check={check}
attachEnabled={attachEnabled}
isAddingLogsToChat={loadingCheckKeys.has(checkKey)}
onAddLogsToChat={handleAddCheckLogsToChat}
/>
);
})
)}
</Section>
)}
<View style={styles.divider} />
@@ -572,7 +641,7 @@ export function PullRequestPane({
) : null}
{activityLoading ? <PrActivitySkeleton /> : null}
{!activityLoading && visibleEntries.length === 0 ? (
<Text style={styles.emptyText}>No activity yet</Text>
<Text style={sectionKitStyles.emptyText}>No activity yet</Text>
) : null}
{!activityLoading
? visibleEntries.map(({ entry, collapsed }) => (
@@ -582,6 +651,7 @@ export function PullRequestPane({
collapsed={collapsed}
collapsedEntryIds={collapsedEntryIds}
attachEnabled={attachEnabled}
brandLabel={forgePresentation.brandLabel}
onAddToChat={handleAddActivityToChat}
onAddThreadToChat={handleAddThreadToChat}
onToggleCollapsed={handleToggleEntryCollapsed}
@@ -601,58 +671,6 @@ function stateLabelStyle(state: PrState) {
return styles.stateLabelClosed;
}
interface SectionProps {
title: string;
open: boolean;
onToggle: () => void;
summary: React.ReactNode;
children: React.ReactNode;
}
function Section({ title, open, onToggle, summary, children }: SectionProps) {
return (
<View>
<Pressable style={styles.sectionHeader} onPress={onToggle}>
{open ? (
<ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />
)}
<Text style={styles.sectionTitle}>{title}</Text>
<View style={styles.summaryWrap}>{summary}</View>
</Pressable>
{open ? <View style={styles.sectionBody}>{children}</View> : null}
</View>
);
}
function SummaryPill({
count,
icon,
variant,
testID,
}: {
count: number;
icon: React.ReactNode;
variant: "success" | "danger" | "warning" | "muted";
testID?: string;
}) {
if (count === 0) return null;
return (
<View style={styles.summaryPill} testID={testID}>
{icon}
<Text style={summaryPillTextStyle(variant)}>{count}</Text>
</View>
);
}
function summaryPillTextStyle(variant: "success" | "danger" | "warning" | "muted") {
if (variant === "success") return styles.summaryPillSuccessText;
if (variant === "danger") return styles.summaryPillDangerText;
if (variant === "warning") return styles.summaryPillWarningText;
return styles.summaryPillMutedText;
}
function CheckRow({
check,
attachEnabled,
@@ -677,15 +695,15 @@ function CheckRow({
return (
<Pressable onPress={handlePress} style={rowPressableStyle}>
<CheckStatusIcon status={check.status} />
<Text style={styles.checkName} numberOfLines={1}>
<Text style={sectionKitStyles.checkName} numberOfLines={1}>
{check.name}
</Text>
{check.workflow && (
<Text style={styles.checkWorkflow} numberOfLines={1}>
<Text style={sectionKitStyles.checkWorkflow} numberOfLines={1}>
{check.workflow}
</Text>
)}
<View style={styles.checkTrailing}>
<View style={sectionKitStyles.checkTrailing}>
{attachEnabled && canAddPullRequestCheckLogsToChat(check) ? (
<Button
variant="ghost"
@@ -698,21 +716,15 @@ function CheckRow({
{isAddingLogsToChat ? "Adding..." : "Add to chat"}
</Button>
) : null}
{check.duration && <Text style={styles.checkDuration}>{check.duration}</Text>}
{check.duration && <Text style={sectionKitStyles.checkDuration}>{check.duration}</Text>}
</View>
</Pressable>
);
}
function CheckStatusIcon({ status }: { status: CheckStatus }) {
if (status === "success") return <ThemedCircleCheck size={14} uniProps={successColorMapping} />;
if (status === "failure") return <ThemedCircleX size={14} uniProps={dangerColorMapping} />;
if (status === "pending") return <ThemedCircleDot size={14} uniProps={warningColorMapping} />;
return <ThemedCircleSlash size={14} uniProps={foregroundMutedColorMapping} />;
}
interface TimelineEntryCallbacks {
attachEnabled: boolean;
brandLabel: string;
onAddToChat: (activity: PrPaneActivity) => void;
onAddThreadToChat: (thread: PrThreadEntry) => void;
onToggleCollapsed: (entryId: string, collapsed: boolean) => void;
@@ -758,15 +770,18 @@ function ActivityKebab({
activity,
visible,
attachEnabled,
brandLabel,
onMenuOpenChange,
onAddToChat,
}: {
activity: PrPaneActivity;
visible: boolean;
attachEnabled: boolean;
brandLabel: string;
onMenuOpenChange: (open: boolean) => void;
onAddToChat: (activity: PrPaneActivity) => void;
}) {
const { t } = useTranslation();
const handleAddToChat = useCallback(() => onAddToChat(activity), [activity, onAddToChat]);
const handleCopy = useCallback(() => {
void writeMarkdownToRichClipboard(activity.body, getDefaultMarkdownClipboardEnvironment());
@@ -797,7 +812,7 @@ function ActivityKebab({
</DropdownMenuItem>
) : null}
<DropdownMenuItem leading={OPEN_MENU_ICON} onSelect={handleOpen}>
Open on GitHub
{t("workspace.git.pr.actions.openOn", { brand: brandLabel })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -887,6 +902,7 @@ function SingleActivityCard({
entry,
collapsed,
attachEnabled,
brandLabel,
onAddToChat,
onToggleCollapsed,
}: TimelineEntryCallbacks & {
@@ -920,6 +936,7 @@ function SingleActivityCard({
activity={activity}
visible={actionsVisible}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onMenuOpenChange={setMenuOpen}
onAddToChat={onAddToChat}
/>
@@ -942,6 +959,7 @@ function SingleActivityCard({
activity={activity}
visible={actionsVisible}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onMenuOpenChange={setMenuOpen}
onAddToChat={onAddToChat}
/>
@@ -974,6 +992,7 @@ function ThreadCard({
entry,
collapsed,
attachEnabled,
brandLabel,
onAddToChat,
onAddThreadToChat,
onToggleCollapsed,
@@ -987,6 +1006,7 @@ function ThreadCard({
thread={entry}
collapsed={collapsed}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onAddToChat={onAddToChat}
onAddThreadToChat={onAddThreadToChat}
onToggleCollapsed={onToggleCollapsed}
@@ -1000,6 +1020,7 @@ function ReviewCard({
collapsed,
collapsedEntryIds,
attachEnabled,
brandLabel,
onAddToChat,
onAddThreadToChat,
onToggleCollapsed,
@@ -1047,6 +1068,7 @@ function ReviewCard({
activity={review}
visible={actionsVisible}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onMenuOpenChange={setMenuOpen}
onAddToChat={onAddToChat}
/>
@@ -1079,6 +1101,7 @@ function ReviewCard({
thread={thread}
collapsed={collapsedEntryIds.has(thread.id)}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onAddToChat={onAddToChat}
onAddThreadToChat={onAddThreadToChat}
onToggleCollapsed={onToggleCollapsed}
@@ -1097,6 +1120,7 @@ function ThreadBlock({
thread,
collapsed,
attachEnabled,
brandLabel,
onAddToChat,
onAddThreadToChat,
onToggleCollapsed,
@@ -1104,10 +1128,12 @@ function ThreadBlock({
thread: PrThreadEntry;
collapsed: boolean;
attachEnabled: boolean;
brandLabel: string;
onAddToChat: (activity: PrPaneActivity) => void;
onAddThreadToChat: (thread: PrThreadEntry) => void;
onToggleCollapsed: (entryId: string, collapsed: boolean) => void;
}) {
const { t } = useTranslation();
const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } =
useRevealOnHover();
const handleHeaderPress = useCallback(() => {
@@ -1127,10 +1153,12 @@ function ThreadBlock({
<View onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
<Pressable onPress={handleHeaderPress} style={threadHeaderPressableStyle}>
<Text style={styles.threadPath} numberOfLines={1}>
{formatPullRequestThreadPath(thread.location)}
{thread.location
? formatPullRequestThreadPath(thread.location)
: t("workspace.git.pr.thread.discussion")}
</Text>
{thread.location.isResolved ? <StatusBadge label="Resolved" variant="success" /> : null}
{thread.location.isOutdated ? <StatusBadge label="Outdated" /> : null}
{thread.isResolved ? <StatusBadge label="Resolved" variant="success" /> : null}
{thread.location?.isOutdated ? <StatusBadge label="Outdated" /> : null}
<View style={styles.headerTrailing}>
{collapsed ? (
<View style={styles.threadCount}>
@@ -1152,7 +1180,7 @@ function ThreadBlock({
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={200}>
<DropdownMenuItem leading={OPEN_MENU_ICON} onSelect={handleOpenThread}>
Open on GitHub
{t("workspace.git.pr.actions.openOn", { brand: brandLabel })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -1161,7 +1189,12 @@ function ThreadBlock({
</Pressable>
{collapsed ? null : (
<>
<ThreadComment comment={root} attachEnabled={attachEnabled} onAddToChat={onAddToChat} />
<ThreadComment
comment={root}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onAddToChat={onAddToChat}
/>
{replies.length > 0 ? (
<View style={styles.replyRail}>
{replies.map((reply) => (
@@ -1169,6 +1202,7 @@ function ThreadBlock({
<ThreadComment
comment={reply}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onAddToChat={onAddToChat}
contentStyle={styles.replyThreadComment}
/>
@@ -1205,11 +1239,13 @@ function threadCommentStyle(contentStyle?: ViewStyle) {
function ThreadComment({
comment,
attachEnabled,
brandLabel,
onAddToChat,
contentStyle,
}: {
comment: PrPaneActivity;
attachEnabled: boolean;
brandLabel: string;
onAddToChat: (activity: PrPaneActivity) => void;
contentStyle?: ViewStyle;
}) {
@@ -1227,6 +1263,7 @@ function ThreadComment({
activity={comment}
visible={actionsVisible}
attachEnabled={attachEnabled}
brandLabel={brandLabel}
onMenuOpenChange={setMenuOpen}
onAddToChat={onAddToChat}
/>
@@ -1357,87 +1394,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
},
sectionHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
sectionTitle: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
sectionBody: {
paddingBottom: theme.spacing[3],
},
summaryWrap: {
marginLeft: "auto",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
summaryPill: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
summaryPillSuccessText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusSuccess,
},
summaryPillDangerText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusDanger,
},
summaryPillWarningText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusWarning,
},
summaryPillMutedText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
emptyText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
checkRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
minHeight: 32,
},
checkName: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
flexShrink: 1,
},
checkWorkflow: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
checkTrailing: {
marginLeft: "auto",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
checkDuration: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
checkAddButton: {
paddingVertical: 0,
},

Some files were not shown because too many files have changed in this diff Show More