diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 605645186fb..1409ed1c371 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -472,7 +472,10 @@ jobs: # message and tag name explicitly (avoids npm's `v1.2.3` prefix # assumptions and any lifecycle scripts that would run on bump). npm version "$VERSION" --no-git-tag-version --allow-same-version - git add package.json + # Why: package version is embedded in generated skill provenance; full tag + # history preserves released snapshots while the current version is updated. + node config/scripts/generate-skill-bundle-manifest.mjs --write + git add package.json resources/skills commit_message="release: v$VERSION" if [[ "$EVENT_NAME" == "schedule" ]]; then commit_message="$commit_message [rc-slot:$SLOT]" diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 0514453128b..e6f6018e6b0 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -374,16 +374,28 @@ describe('Electron runtime package contract', () => { expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"') }) - it('lets release-cut tag a version that is already present on main', () => { + it('keeps release-cut version commits self-healing and taggable on retries', () => { const releaseWorkflow = readFileSync( join(projectDir, '.github/workflows/release-cut.yml'), 'utf8' ) const parsedWorkflow = parse(releaseWorkflow) + const checkoutStep = parsedWorkflow.jobs.cut.steps.find((step) => step.name === 'Checkout ref') const bumpStep = parsedWorkflow.jobs.cut.steps.find( (step) => step.name === 'Bump package.json and tag' ) + const bumpIndex = bumpStep.run.indexOf( + 'npm version "$VERSION" --no-git-tag-version --allow-same-version' + ) + const generateIndex = bumpStep.run.indexOf( + 'node config/scripts/generate-skill-bundle-manifest.mjs --write' + ) + const stageIndex = bumpStep.run.indexOf('git add package.json resources/skills') + expect(checkoutStep.with['fetch-depth']).toBe(0) + expect(bumpIndex).toBeGreaterThanOrEqual(0) + expect(generateIndex).toBeGreaterThan(bumpIndex) + expect(stageIndex).toBeGreaterThan(generateIndex) expect(bumpStep.run).toContain('git diff --cached --quiet') expect(bumpStep.run).toContain('git commit --allow-empty -m "$commit_message"') }) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 417924fc997..d96e1bddd40 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 5.8m + + downloads: 5.9m @@ -15,7 +15,7 @@ downloads downloads - 5.8m - 5.8m + 5.9m + 5.9m diff --git a/docs/reference/browser-local-https-certificate-trust.md b/docs/reference/browser-local-https-certificate-trust.md new file mode 100644 index 00000000000..d858e030cb9 --- /dev/null +++ b/docs/reference/browser-local-https-certificate-trust.md @@ -0,0 +1,926 @@ +# Built-in Browser Local HTTPS and Certificate Trust + +## Status + +Implemented reference design for +[issue #8454](https://github.com/stablyai/orca/issues/8454). + +The branch includes scheme-less local-dev URL classification, certificate-specific +failure copy, `Try HTTPS`, exact per-WebContents certificate grants, +`Proceed Anyway (Unsafe)`, local IPC, remote/headless runtime propagation, and +focused unit and real-Electron coverage. A session-level request gate contains +Chromium's cached certificate continuation so approval cannot leak to sibling +tabs, assets, fetches, iframes, or WebSockets. The release gates below remain +the acceptance contract for Electron upgrades and cross-platform validation. + +## Summary + +Orca's built-in browser cannot open a local HTTPS development server when the +server presents an untrusted certificate. A scheme-less local address also +defaults to HTTP, so entering `localhost:3000` does not discover an HTTPS-only +server. + +This design keeps HTTP as the default for the scheme-less local forms that +already use it, adds a visible `Try HTTPS` recovery action after a failed local +HTTP navigation, and adds an explicit `Proceed Anyway (Unsafe)` path for an +untrusted local HTTPS certificate. Certificate approval is narrow and +temporary: it is bound to one browser WebContents, one secure endpoint, the +SHA-256 digest of the exact leaf certificate, and the specific certificate +error. A secure endpoint treats `https` and its companion `wss` scheme as one +TLS endpoint but still includes the canonical hostname and effective port. No +approval state is persisted. + +The certificate decision runs in the process that owns the browser page. Local +desktop webviews use the desktop main process; SSH/headless pages use the remote +runtime process and expose the decision through runtime RPC. + +## Problem + +There are three independent behaviors behind the current failure. + +1. `normalizeBrowserNavigationUrl` and the tab-create entry classifier prepend + `http://` to scheme-less loopback input. Explicit `https://` input is already + preserved. +2. Electron rejects an untrusted server certificate by default. Orca has no + `certificate-error` decision flow, so a self-signed development certificate + fails with a Chromium certificate error such as + `ERR_CERT_AUTHORITY_INVALID (-202)`. +3. Browser load errors are presented as generic connectivity failures. For a + loopback URL, Orca advises the user to check that the server is running even + when the server responded and only certificate verification failed. + +The same trust limitation exists in the offscreen browser backend used by +headless and SSH-owned browser pages. A desktop-only handler would fix the +visible local webview while leaving remote browser ownership inconsistent. + +## Goals + +- Show certificate-specific failure copy for main-frame certificate errors. +- Let a user explicitly approve an untrusted local development certificate. +- Bind approval to the exact browser surface, secure endpoint, leaf certificate + digest, and certificate error. +- Keep approval in memory only and clear it with the owning browser surface. +- Support both desktop webviews and SSH/headless offscreen browser pages. +- Give scheme-less local input a discoverable path to HTTPS without silently + changing the existing HTTP default. +- Preserve the current browser sandbox, navigation allowlist, session profiles, + and mixed-content policy. +- Keep browser failure state accurate across event-order races, early webview + attachment, reload, stale user actions, and remote latency. + +## Non-goals + +- Do not automatically trust a self-signed certificate. +- Do not disable `webSecurity`, enable insecure mixed content, or add + `--ignore-certificate-errors`. +- Do not install a certificate into the operating-system trust store. +- Do not persist certificate exceptions across app restarts, browser surface + recreation, or session-profile changes. +- Do not default every scheme-less localhost URL to HTTPS. +- Do not automatically probe HTTPS and then downgrade to HTTP. +- Do not add a general-purpose exception flow for public websites in v1. +- Do not make every Chromium certificate error overridable in v1. +- Do not extend localhost worktree-label proxying to HTTPS targets. + +## Product Decisions + +### Scheme-less local input continues to use HTTP + +`localhost:3000` remains `http://localhost:3000/`. HTTP is still the common +development-server default, and changing the inference globally would turn +working HTTP servers into certificate or TLS failures. + +When an HTTP navigation to a loopback URL fails, the browser failure overlay +offers `Try HTTPS`. The action replaces only the URL scheme and preserves the +host, explicit non-default port, path, query, and fragment. An absent port, or +an explicit `:80` normalized away by the URL parser, becomes HTTPS's default +port 443. Orca does not automatically retry because an invisible +cross-protocol retry would add latency, send an unexpected second request, and +make downgrade behavior opaque. + +Explicit input remains authoritative: + +- `http://localhost:3000` stays HTTP. +- `https://localhost:3000` stays HTTPS. +- URLs advertised by a workspace process keep their advertised scheme. + +The shared browser URL domain becomes the source of truth for the existing +scheme-less local-dev classification. The tab-create entry classifier calls +that shared logic instead of keeping a second regular expression and scheme +rule. This consolidation must not reuse the stricter certificate-eligibility +predicate: doing so would change current behavior for inputs such as +`0.0.0.0`, bracketed non-loopback IPv6 addresses, and `.localhost` subdomains. + +### V1 approval covers untrusted local authorities only + +The first version offers `Proceed Anyway (Unsafe)` only when all of these are +true: + +- the failure is for the main frame; +- the attempted URL is HTTPS; +- Electron reports `net::ERR_CERT_AUTHORITY_INVALID`, corresponding to + Chromium load error `-202`; +- the host is an eligible loopback host; +- the WebContents is a browser surface managed by Orca; and +- Orca retained a live pending challenge for that browser surface. + +Other certificate errors receive accurate copy but no bypass action. In +particular, v1 does not bypass revoked certificates, malformed certificates, +weak keys, date errors, or hostname/SAN mismatches. Expanding the allowlist +requires a separate security decision and dedicated tests. + +### Eligible loopback hosts + +The shared predicate accepts: + +- `localhost`, case-insensitively, with an optional trailing dot; +- valid subdomains of `.localhost`, also with an optional trailing dot; +- valid IPv4 addresses in `127.0.0.0/8`; and +- IPv6 loopback `::1`. + +The predicate does not treat `0.0.0.0`, `::`, arbitrary bracketed IPv6 +addresses, private LAN addresses, or a DNS name that merely resolves to +loopback as certificate-bypass eligible. Wildcard bind addresses should already +be normalized to a connectable loopback address before navigation. + +SSH local forwards are eligible when their browser-facing URL uses one of these +loopback hosts. Forwarding does not fix certificate names: a URL using +`127.0.0.1` still needs an IP SAN for `127.0.0.1`; a certificate containing only +`localhost` will fail with `ERR_CERT_COMMON_NAME_INVALID`, which v1 does not +bypass. Custom advertised DNS names remain subject to normal system trust in +v1, even if the name maps to loopback. + +### Approval lifetime + +An accepted grant is scoped to: + +```text +browser WebContents ID + secure endpoint + leaf SHA-256 digest + error name +``` + +The secure endpoint canonicalizes `wss:` to its companion `https:` endpoint and +includes the normalized hostname and effective port. This lets an approved +local page load same-endpoint HTTPS assets and its WSS development socket. A +grant for `https://localhost:3000` does not apply to port 3001, to +`https://127.0.0.1:3000`, to another browser tab, or to a replacement +certificate. + +Construct the secure endpoint from the parsed request URL: map `https:` and +`wss:` to `https:`, lowercase the hostname, remove one optional trailing dot, +use the URL parser's canonical IP representation, and use port 443 when the +parsed port is empty. Exclude credentials, path, query, and fragment. Reject +every other scheme. + +Accepted grants survive reloads and same-surface navigation back to the exact +endpoint. They are cleared when the WebContents commits a main-frame navigation +whose canonical secure endpoint differs from the grant's endpoint (reusing the +per-WebContents navigation-sequence tracking), when the WebContents is destroyed +or replaced, the session profile changes, the browser-owning process exits, or +the app restarts. Binding grant lifetime to the granting main-frame document +prevents a later top-level document in the same tab — for example a navigation +to a public site — from silently reusing the loopback grant for a same-endpoint +iframe, fetch, or WSS subrequest. +Clearing a grant affects future requests; it cannot retroactively terminate a +document that already loaded. Grants are not written to settings, browser +history, workspace session state, or logs. + +Chromium caches an accepted `certificate-error` continuation across the +Electron session partition. Orca therefore retains every endpoint/certificate +identity accepted in that partition until the browser-owning process exits and +gates all later HTTPS/WSS requests with `webRequest.onBeforeRequest`. Only the +WebContents holding the matching grant may reach a cached endpoint. A sibling +main-frame navigation receives a synthetic challenge for the known identity; +sibling subresources, fetches, iframes, workers, and WSS requests fail silently. + +Because a preflight request gate cannot inspect the certificate that will be +presented after Chromium's cache is consulted, one endpoint is locked to the +first accepted untrusted leaf identity for that process lifetime. If that +endpoint later presents a different untrusted leaf, strict verification rejects +it and Orca does not offer another bypass until the browser-owning process +restarts. A replacement system-trusted certificate may load normally; the +conservative request gate still keeps sibling WebContents blocked until restart. + +## User Experience + +### Local HTTP failure + +For a failed `http://` loopback navigation, keep the current connectivity +title and recovery hint. Add `Try HTTPS` when an HTTPS form can be constructed. + +Example: + +```text +Can't reach localhost:3000 +We couldn't connect to your local server. + +[Try HTTPS] [Retry] [Copy Address] [Open Externally] +``` + +`Try HTTPS` is the default button because it is the targeted recovery action +for a local HTTP failure. Keep the existing `Copy Address` recovery action; +`Retry` uses `outline`, while `Copy Address` and `Open Externally` stay quiet +secondary actions. `Try HTTPS` does not appear for an already-HTTPS URL or a +non-eligible host. + +### Certificate failure + +For `ERR_CERT_AUTHORITY_INVALID (-202)` on an eligible local endpoint, replace +the generic connectivity content with: + +```text +Connection isn't secure +Orca doesn't trust the authority that issued the certificate for localhost:3000. + +For local development, use a trusted local certificate when possible. + +[Open Externally] [Retry] [Copy Address] [Proceed Anyway (Unsafe)] +``` + +- `Open Externally` is the default safe recovery action when the URL is + reachable from the desktop. +- When `Open Externally` is unavailable, `Retry` becomes the default action. +- `Proceed Anyway (Unsafe)` uses `outline`; `Copy Address` stays quiet. +- `Proceed Anyway (Unsafe)` is always visibly labeled; it is not hidden behind + a tooltip. +- The unsafe action does not use the destructive color. It does not delete or + irreversibly mutate user data, and the explicit label carries the warning. +- Use a muted `ShieldAlert` icon from `lucide-react`; do not add a warning color + or new token. + +For a remote-owned page whose URL is remote localhost, `Open Externally` is +hidden because the desktop system browser cannot reach the remote loopback +address. It remains available for a desktop-owned page or a locally forwarded +URL. + +For certificate errors that v1 does not permit bypassing, show the same +certificate-specific title with error-specific body text, `Retry`, +`Copy Address`, and an eligible `Open Externally` action. `Copy Address` remains +present as a quiet secondary action in every certificate-failure branch, +eligible or not, so only `Try HTTPS` and `Proceed Anyway (Unsafe)` toggle in and +out across cases. The same default-action rule applies as for `-202`: +`Open Externally` is the default when the URL is reachable from the desktop, +otherwise `Retry` becomes the default. Do not show the local-server-running +hint. + +Use this presentation mapping, inserting the display host into the copy: + +| Chromium code | Body copy | Proceed in v1 | +| ----------------------- | -------------------------------------------------------------------------- | ----------------------------- | +| `-200` | `The certificate doesn't match {host}.` | No | +| `-201` | `The certificate for {host} isn't valid at the current date and time.` | No | +| `-202` | `Orca doesn't trust the authority that issued the certificate for {host}.` | Eligible local endpoints only | +| Other certificate error | `Orca couldn't verify the certificate for {host}.` | No | + +Keep the raw error name/code available to diagnostics and optional details, but +do not make users interpret it to understand the primary failure. + +### Interaction behavior + +- The overlay is persistent inline UI because the user must read and act on the + failure; do not use a toast. +- The overlay does not steal focus when it appears. +- Actions are reachable by Tab and have visible focus rings. +- Use `aria-live="polite"`; repeated background failures must not repeatedly + announce the same message. +- Clicking `Proceed Anyway (Unsafe)` disables the action immediately, and also + disables the other overlay actions (`Retry`, `Try HTTPS`, `Copy Address`, + `Open Externally`) for the duration of the approval round-trip so a stale + click cannot race the controller's own `loadURL`. Re-enable them once a + success or typed-failure response returns. +- Map every `proceedCertificate` failure reason to an overlay outcome: + `expired`, `changed`, `ineligible`, and `missing` keep the overlay open and + show the inline recovery message below; `navigated` shows no message because + the overlay for that navigation is already cleared. In every non-success case + `Proceed Anyway (Unsafe)` stays disabled until a fresh pending challenge is + announced rather than re-enabling on its own. +- Delay the spinner by 200 ms, matching the style guide's remote-latency rule. + After that delay, show the canonical `Loader2` with `Connecting…` and use a + fixed button width so the label swap cannot move adjacent actions. +- If approval expired or the certificate changed, keep the overlay open and + show `The certificate changed or the approval expired. Retry the page.` +- A successful main-frame navigation clears the visible certificate failure. + +The UI must use existing `background`, `foreground`, `muted-foreground`, +`border`, and `ring` tokens plus the existing shadcn `Button` primitive. No new +color, radius, or shadow tier is required. Do not apply a blanket opacity to +the overlay's interactive content; use semantic muted text classes so button +and focus-ring contrast remains intact. Put every new user-visible string +through the existing localization catalog. + +## Architecture + +### Ownership + +The browser-owning main process is the only authority that can accept a +certificate challenge. + +- Desktop-owned `` pages are controlled by the desktop main process. +- Headless/SSH-owned pages are controlled by the remote `orca serve` main + process through `OffscreenBrowserBackend`. +- The renderer only presents a pending challenge and requests approval. It + never decides whether a certificate is trusted. + +### New main-process controller + +Add `src/main/browser/browser-certificate-trust-controller.ts` to manage pending +certificate challenges, and +`src/main/browser/browser-certificate-request-guard.ts` to manage grants plus +the session-level cached-certificate request boundary. + +The controller owns: + +```ts +type BrowserCertificateFailure = { + challengeId: string + browserPageId: string + errorCode: number | null + error: string + origin: string + displayHost: string + canProceed: boolean + observedAt: number +} + +type PendingBrowserCertificateChallenge = { + challengeId: string + guestWebContentsId: number + browserPageId: string | null + navigationSequence: number + navigationUrl: string + origin: string + secureEndpoint: string + leafCertificateSha256: string + errorCode: number | null + error: string + expiresAt: number +} + +type BrowserCertificateGrant = { + guestWebContentsId: number + secureEndpoint: string + leafCertificateSha256: string + error: string +} +``` + +Generate challenge IDs with `randomUUID()`. Compute the certificate identity in +the browser-owning main process as SHA-256 over the DER bytes of Electron's leaf +`certificate.data`; do not depend on the undocumented formatting or algorithm +of `certificate.fingerprint`. Renderer and runtime payloads expose no +certificate bytes or digest—only the challenge ID, display host, origin, error +code, error name, `canProceed`, and observation time. + +Pending challenges expire after five minutes and are bounded to 32 entries. +Accepted grants are bounded to 32 entries per browser-owning process and are +also removed with their WebContents. Bounds evict the oldest entry and notify +the affected UI when a live challenge is evicted. Ordinary cleanup should keep +both collections much smaller. Accepted endpoint identities are not evicted: +Chromium's corresponding cache cannot be cleared narrowly, so forgetting one +would reopen cross-WebContents access until process exit. + +### Certificate event handling + +Register exactly one app-level `certificate-error` listener after Electron is +ready but before either a desktop window or the offscreen backend can create a +browser page. Every branch must call Electron's callback exactly once; an +unexpected exception fails closed with `callback(false)`. + +Electron supplies the certificate error as a string on this event, while +`did-fail-load` supplies the Chromium number later. Normalize the string to one +canonical error name and map only known names to numbers for presentation. An +unknown name keeps `errorCode: null` and is never bypass-eligible. + +For every event: + +1. Resolve the WebContents through `BrowserManager` and reject unmanaged, + retired, or popup WebContents. +2. Parse the request URL, normalize its secure endpoint, and compute the leaf + certificate SHA-256 digest. Invalid data fails closed. +3. Compare the normalized error name, endpoint, and digest against an accepted + grant for this exact WebContents. On an exact match, first retain the accepted + identity in the owning Electron session, then call `event.preventDefault()` + and `callback(true)`. +4. If no grant matches, reject non-main-frame failures without creating a + challenge. Subresources and iframes can consume an existing grant but cannot + mint one. +5. For a main-frame failure, record a pending challenge only when the URL and + error are eligible. Notify the owning page when its page ID is known, then + call `callback(false)`. + +Track a monotonically increasing main-frame navigation sequence per +WebContents. A new main-frame navigation invalidates its pending challenge in +both main and renderer state before the next certificate decision. Repeated +identical events within one sequence reuse the challenge ID; a retry or a +different navigation gets a new ID. This prevents an old button or compromised +renderer from approving a page the user has already left. + +The first failure is always rejected. User approval records a grant and reloads +the page; the next certificate event is the one that succeeds. + +Do not leave Electron's callback pending while waiting for user input. Rejecting +first gives Orca a normal load-failure lifecycle, avoids an unbounded blocked +navigation, and makes stale approval cleanup deterministic. + +Do not use `session.setCertificateVerifyProc`. It applies to an entire session +partition, cannot bind a decision to one browser page, and verification results +can be cached by Chromium's network service. + +### Session request gate and Electron upgrade guard + +The pinned Electron 43 binary caches `callback(true)` broadly enough that a +second WebContents in the same partition can load the endpoint without another +`certificate-error` event. The certificate callback alone is therefore not an +isolation boundary. + +Install exactly one `webRequest.onBeforeRequest` listener on every Orca browser +session before its first page loads. Electron permits only one listener for this +event, so the browser session registry owns installation and removal. Once an +endpoint identity has been accepted, the listener blocks every HTTPS/WSS +request to that endpoint unless `details.webContentsId` has the matching grant. +Requests without a WebContents ID, including background/worker traffic, fail +closed. A blocked main-frame request recreates the challenge and certificate +load error; other resource types are canceled without replacing the current +page's overlay. + +The real-Electron integration suite must prove same-tab reload, same-endpoint +HTTPS assets and WSS, different-tab documents and subresources, and +different-port isolation against the exact Electron version in +`pnpm-lock.yaml`. Any Electron upgrade that breaks those tests blocks release +until the design is revised; unit mocks are insufficient. + +### BrowserManager integration + +Add a narrow BrowserManager query API instead of exposing its internal maps: + +```ts +getManagedBrowserGuestContext(webContentsId: number): { + browserPageId: string | null + worktreeId: string | null + sessionProfileId: string | null + owner: 'desktop-webview' | 'offscreen' +} | null +``` + +For guest lifecycle, follow the existing extracted-controller precedent +(`browser-grab-session-controller.ts`, `browser-guest-ui.ts`, +`browser-download-destination.ts`), all of which BrowserManager drives by +calling a plain method on the controller directly from `registerGuest` / +`unregisterGuest`. Have BrowserManager call +`certificateTrustController.onGuestRegistered({ browserPageId, webContentsId })` +and `.onGuestRetired(webContentsId)` from those same bodies, rather than adding a +new listener/emitter registration API to BrowserManager — there is no such +pub-sub surface in `src/main/browser` today and this single consumer does not +warrant introducing one. + +`getManagedBrowserGuestContext` must recognize an attached primary desktop +guest before renderer registration, returning a null page ID, while rejecting +popup descendants that merely inherited browser policies. It must also +recognize offscreen pages after `registerOffscreenGuest` and stop recognizing a +guest immediately when BrowserManager retires or unregisters it. The controller +re-checks this ownership on every certificate event even if it still has an +in-memory grant. + +An attached desktop webview can encounter a certificate error before its +document reaches `dom-ready`, which is when BrowserPane currently registers the +guest. Keep pending challenges keyed by WebContents ID until registration +supplies the page ID, then flush the failure to the renderer. + +BrowserPane should also register idempotently on the webview's `did-attach` +event, with `dom-ready` retained as a fallback. `registerGuest` should report +whether the main process accepted the registration so a rare attach-policy race +can retry at `dom-ready`; the renderer must not cache the WebContents ID as +registered until it receives `true`. + +Offscreen pages already register before `loadURL`, so their page ID is normally +available when the certificate event fires. Bring their main-frame navigation +and `did-fail-load` observation under the same BrowserManager lifecycle used by +desktop guests; the current offscreen loader only logs a rejected `loadURL`, so +it cannot by itself populate runtime failure snapshots. + +### Renderer state + +Certificate challenges are transient runtime state and must not be added to +the persisted `BrowserPage` schema. + +Add a transient map to the browser store: + +```ts +browserCertificateFailuresByPageId: Record +``` + +The workspace session serializer must omit this map. Clear an entry when: + +- the page completes a successful main-frame navigation; +- the user starts a different navigation; +- the page or workspace closes; +- the owning browser WebContents is replaced; or +- the main process reports that the challenge expired. + +The existing `BrowserLoadError` remains the persisted diagnostic source. Add a +pure Chromium-error classifier for Electron 43's certificate range (`-200` +through `-219`, excluding the unused values) so certificate failures still +render accurate copy after restore even when no live challenge exists. A live +certificate failure's error code takes presentation precedence over a +synthesized `-1` Chromium-error-page fallback. Independently, only a live, +matching challenge controls whether `Proceed Anyway (Unsafe)` is enabled. + +Use both the page ID and attempted origin when combining the live challenge +with `BrowserLoadError`. A stale challenge from a prior navigation must never +add a proceed action to a different failure. Main-process navigation-sequence +invalidation is the security boundary; renderer cleanup is defense in depth and +keeps the UI honest. + +### Local IPC + +Extend the browser preload API with: + +```ts +onCertificateFailureChanged( + callback: (event: { + browserPageId: string + failure: BrowserCertificateFailure | null + }) => void +): () => void + +proceedCertificate(args: { + browserPageId: string + challengeId: string +}): Promise< + | { ok: true } + | { + ok: false + reason: 'expired' | 'changed' | 'ineligible' | 'missing' | 'navigated' + } +> +``` + +Register the handler with the existing browser IPC group and reuse +`isTrustedBrowserRenderer`. The main process resolves the page to its current +WebContents and then consumes the exact pending challenge. Renderer-provided +origin, error code, or certificate identity are never trusted. + +On success, main consumes the challenge, records the grant, and calls +`loadURL` with the challenge's main-owned `navigationUrl`. It must not use +`webContents.getURL()`, which may be `chrome-error://chromewebdata/` or a +previously committed page. It also must not reload if the page's current +navigation sequence no longer matches the challenge. The renderer does not +assign `webview.src` as a second side effect. + +### Remote runtime RPC + +The remote browser owner exposes equivalent behavior through: + +```text +browser.certificate.proceed +``` + +with the runtime worktree selector, remote browser page ID, and challenge ID. +Advertise support through a new `browser.certificate-trust.v1` capability only +when the runtime has both a browser backend and the certificate controller. +Keep the method out of the mobile-scope RPC allowlist; it is for an +authenticated runtime-scope Orca client presenting browser chrome, not a guest +page or unauthenticated caller. + +Extend `RuntimeMobileSessionBrowserTab` compatibly with optional `loadError` +and `certificateFailure` fields. Both are emitted from live runtime state. +`loadError` keeps the existing `BrowserPage` last-known diagnostic semantics, +including workspace persistence; `certificateFailure` is transient and is +never written to workspace persistence. Controller changes, challenge expiry, +navigation, and offscreen `did-fail-load` must mark the runtime session snapshot +dirty so clients do not wait for unrelated state to change. Web clients and +desktop clients reconcile these fields through the existing remote page-handle +mapping. Each full snapshot replaces the prior transient certificate failure +for that page, including clearing it when the field is absent. An older runtime +without the capability always clears any previously mirrored proceed action. + +When the active page has a remote owner, BrowserPane routes approval to that +environment. It must not call local desktop IPC. If the runtime lacks the new +capability, the UI still classifies the certificate failure accurately but does +not render `Proceed Anyway (Unsafe)`. `Try HTTPS`, retry, and other navigation +actions continue through the existing owner-aware navigation path rather than +touching a local webview for a remote-owned page. + +### Scheme recovery + +Add pure functions in the shared browser URL domain: + +```ts +classifySchemeLessLocalDevAddress(rawInput: string): URL | null +isEligibleLocalCertificateHost(hostname: string): boolean +toHttpsRecoveryUrl(rawUrl: string): string | null +``` + +`classifySchemeLessLocalDevAddress` preserves the exact current HTTP-default +input set and is shared by address-bar normalization and tab-create entry. It +is deliberately broader than certificate eligibility. + +`isEligibleLocalCertificateHost` accepts a parsed/canonical hostname; normalize +case and an optional trailing dot and handle the brackets returned by +`URL.hostname` for IPv6. Validate IPv4 octets and DNS label boundaries instead +of using a substring or loose suffix check. + +`toHttpsRecoveryUrl` returns a value only for an HTTP URL with an eligible +loopback host. It changes `protocol` to `https:` on a parsed `URL` object and +preserves credentials, hostname, an explicit non-default port, path, query, and +fragment. When no port is present, including when the parsed HTTP URL normalized +`:80` away, the result uses HTTPS's default port 443. It never probes either +protocol. + +Both address-bar submission and tab-create entry use the shared local-address +classifier. Existing advertised workspace-port URLs remain authoritative and +require no new probing. + +## Security Model + +### Invariants + +- Default certificate verification remains strict. +- Only a trusted Orca renderer or authenticated runtime-scope Orca client can + request approval. +- The renderer cannot choose the origin, certificate digest, error, + WebContents, or eligibility result. +- A grant is exact-match only and never applies across tabs, ports, + certificates, or browser-owning processes. +- Session-cached accepted endpoints remain request-gated until process exit, + even after their original WebContents or bounded grant is removed. +- Certificate PEM/DER data and certificate digests do not enter workspace + state, history, telemetry, or ordinary logs. +- Subresource and iframe certificate failures cannot mint an approval action; + they can consume an already-approved exact-endpoint grant on the same + WebContents. +- The proceed affordance exists only in Orca chrome; guest content has no + direct approval channel. Main-process challenge validation remains the + security boundary and does not assume proof of a physical click. +- A remote client cannot approve a certificate in a different runtime + environment. +- Starting another main-frame navigation invalidates the pending approval in + the browser-owning process, not only in renderer state. + +### Threat cases + +| Threat | Required behavior | +| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| Compromised web page sends forged IPC | Guest WebContents has no preload API; trusted-renderer check rejects it. | +| Compromised renderer invents a challenge | Main consumes only a server-created pending challenge tied to the current page and WebContents. | +| Certificate changes after approval | Leaf-digest mismatch rejects; a second untrusted leaf for that endpoint cannot be bypassed until restart. | +| Same host uses a different port | Secure-endpoint mismatch rejects. | +| Another tab visits the approved endpoint | WebContents mismatch rejects. | +| Approved tab later loads a foreign top document | Grant is invalidated when the main frame navigates to a different endpoint; the new document cannot reuse it. | +| Challenge is replayed | Challenge ID is single-use and expires. | +| User leaves the failed page before clicking | Navigation-sequence mismatch rejects without reloading the stale URL. | +| Approved page opens same-endpoint WSS/HMR | Existing endpoint grant permits it; WSS cannot create a grant by itself. | +| Public site presents an untrusted certificate | Host eligibility fails; no proceed action is available. | +| Remote page approval is sent locally | Ownership routing and remote page handles select the browser-owning runtime. | +| App restarts after approval | No persisted grant exists. | + +## Failure Handling + +- If a certificate event arrives before guest registration, retain it by + WebContents ID and flush after registration. +- If guest registration never completes, expire the pending challenge without + notifying unrelated pages. +- If `Proceed` arrives after expiry, navigation, or WebContents replacement, + return a typed failure and leave strict verification in place. +- If reload fails for a different reason, clear the certificate challenge and + present the new load error. +- If the same rejected challenge fires repeatedly in one navigation, coalesce + it without rotating the challenge ID or stacking announcements. +- If the certificate-error listener cannot parse the URL or leaf certificate, + reject normally and do not offer approval. +- If the remote connection drops during approval, keep the local UI stable and + surface the runtime error inline after the latency threshold. +- If an unexpected controller error occurs, call Electron's callback once with + `false`; never leave the request suspended. +- If an accepted main-frame page later opens a new same-endpoint TLS connection, + apply the exact grant to that HTTPS/WSS subrequest. Reject unmatched + subrequests silently without replacing the main-frame overlay. +- If an accepted endpoint presents a different untrusted leaf, reject it without + replacing the process-lifetime accepted identity or offering a second bypass. + +## Data and Privacy + +No migration is required. + +Add no new product analytics in v1. Do not record URLs, certificate digests, +challenge IDs, certificate subjects, or approval results. If product analytics +becomes a requirement later, define coarse actions in the existing typed +feature-interaction catalog as a separate reviewed change. + +Existing diagnostics may retain the Chromium numerical error code, as they do +for other browser load failures. Debug logging must contain at most the error +name, page ID, owner kind, and normalized loopback host class; omit query +strings, full URLs, and certificate digests. + +## Test Plan + +### Shared URL tests + +- Explicit HTTP and HTTPS local URLs remain unchanged. +- Scheme-less `localhost`, IPv4 loopback, and IPv6 loopback still normalize to + HTTP. +- Tab-create and address-bar local-address classification stay in parity for + the full legacy set, including wildcard and bracketed IPv6 inputs. +- The broader scheme-less classifier is not accidentally substituted for the + stricter certificate-eligibility predicate. +- `toHttpsRecoveryUrl` preserves host, an explicit non-default port, path, + query, and fragment; a URL with no parsed port changes from HTTP's default 80 + to HTTPS's default 443. +- `Try HTTPS` is unavailable for HTTPS, file URLs, public hosts, wildcard bind + hosts, private LAN hosts, and invalid URLs. +- Loopback eligibility covers `localhost`, trailing-dot localhost, + `.localhost` subdomains with and without a trailing dot, the full valid IPv4 + 127/8 range, and bracketed or unbracketed `::1` after URL parsing. +- Reject malformed IPv4, arbitrary bracketed IPv6, `0.0.0.0`, `::`, and names + that only contain the word `localhost`. + +### Main-process trust-controller tests + +- Unmanaged, retired, and popup WebContents are rejected without pending + challenges. +- A managed `-202` loopback failure creates one pending challenge and calls + `callback(false)`. +- Every event path calls its callback exactly once; only an accepted grant calls + `preventDefault()` and `callback(true)`. +- The digest is SHA-256 over DER leaf bytes and is stable across PEM formatting. +- An exact accepted grant covers same-endpoint HTTPS assets, WSS, and iframes on + the same WebContents without allowing those requests to mint a challenge. +- Wrong page, WebContents, origin, port, certificate digest, error type, and + challenge ID fail closed. +- A token is single-use and expires after five minutes. +- Repeated identical failures in one navigation coalesce; a new navigation + invalidates the token and rotates the challenge ID. +- Approving after navigation returns `navigated` and never reloads the old URL. +- Approval loads the main-owned challenged URL, not the current Chromium error + URL or a renderer-supplied URL. +- Certificate rotation fails closed and cannot replace the process-lifetime + accepted endpoint identity. +- WebContents destruction removes pending challenges and grants. +- Bounds evict the oldest pending challenge/grant. +- Registering a page after an early certificate event flushes the event to the + correct renderer. +- Concurrent tabs and session profiles remain isolated. + +### IPC and runtime tests + +- Browser certificate IPC rejects untrusted senders and malformed arguments. +- Renderer-supplied origin or certificate-identity fields are neither accepted + nor used. +- Local approval reloads only the currently mapped guest. +- Remote approval calls the owning environment's RPC with the remote page ID. +- Local IPC is not called for a remote-owned page. +- Mobile-scope runtime clients cannot call the approval method. +- Older runtimes without `browser.certificate-trust.v1` never show an enabled + proceed action. +- Runtime snapshots carry live load errors and certificate failure state, + publish immediately on changes, and do not add certificate challenges to + persisted session schemas. +- A snapshot that omits or clears a challenge removes the mirrored proceed + action rather than leaving a stale one behind. +- Two runtime environments with identical page IDs cannot approve each other's + challenge. + +### Renderer tests + +- `-202` renders certificate-specific copy and no server-running hint. +- A live certificate challenge still wins when Chromium's error-page fallback + produced diagnostic code `-1`. +- A live eligible challenge renders `Proceed Anyway (Unsafe)`. +- A restored `-202` load error without a live challenge remains accurate but + cannot proceed. +- Other certificate errors render accurate copy without v1 bypass. +- Local HTTP failures render `Try HTTPS`; HTTPS and non-loopback failures do not. +- Starting a new navigation clears stale certificate state. +- Success clears the overlay. +- Proceed disables immediately, delays the spinner, and preserves its width. +- Expired/changed responses show the inline recovery message. +- Keyboard focus and `aria-live` behavior do not regress the address bar. +- `Copy Address` remains available, interactive content is not blanket-dimmed, + and all new strings are present in the localization catalog. + +### Electron integration + +Run a local HTTPS server with a generated certificate whose SAN includes +`localhost` but whose authority is not trusted. + +1. `https://localhost:` fails with `-202` and shows certificate copy. +2. `Proceed Anyway (Unsafe)` reloads and renders the page. +3. HTTPS assets and a WSS echo/HMR endpoint on the same host and port load after + approval; a different-port asset remains blocked. +4. Reload in the same tab remains allowed without broadening the grant. +5. A second tab using the same session profile, origin, and certificate remains + blocked. This is a release-blocking assertion against the pinned Electron + binary, not a mocked controller test. +6. Navigating away before clicking makes the old challenge unusable and does + not pull the tab back to the failed URL. +7. Replacing the certificate keeps the original tab blocked and offers no + second bypass until the browser-owning process restarts. +8. Closing and recreating the browser surface removes the grant. +9. Restarting Orca removes the grant. +10. A system-trusted local certificate loads without an interstitial. +11. A public/non-loopback untrusted origin has no proceed action. +12. A hostname/SAN mismatch shows certificate copy but no proceed action. +13. `localhost:` first attempts HTTP and its failure offers `Try HTTPS`. + +Repeat the ownership-sensitive cases for: + +- a local desktop webview; +- an SSH local port forward opened in the desktop browser, using a certificate + whose SAN matches the browser-facing forwarded hostname; and +- a headless/offscreen browser page owned by `orca serve` under Linux/Xvfb. + +## Implementation Plan + +1. Add the shared scheme-less local classifier, strict certificate-host + predicate, secure-endpoint canonicalizer, and HTTPS-recovery function with + focused tests. +2. Consolidate address-bar and tab-create local classification without changing + current scheme defaults. +3. Add BrowserManager ownership/registration/retirement hooks, early + `did-attach` registration with a `dom-ready` fallback, and offscreen load + failure observation. +4. Add shared certificate failure/result types and the main-process trust + controller, including navigation-sequence invalidation and the app-level + listener. Its certificate-event handling resolves ownership through the + BrowserManager hooks from step 3, so those must land first. +5. Add local IPC, preload APIs, and transient renderer state. +6. Add the certificate-specific classification and copy as new exported + functions/branches in the existing `browser-notices.ts` (which already owns + load-failure copy via `formatLoadFailureDescription` / + `formatLoadFailureRecoveryHint`), and extract only the overlay JSX into a + focused `browser-load-failure-overlay.tsx` module instead of growing the + already grandfathered `BrowserPane.tsx`; do not add or extend a max-lines + disable. +7. Add certificate-specific and `Try HTTPS` UI, preserve existing recovery + actions, and update the localization catalog. +8. Add the offscreen/runtime capability, live snapshot fields and publication, + and approval RPC. +9. Add unit, IPC, renderer, runtime, and real-Electron integration coverage. +10. Run formatting, typecheck, lint, localization verification, targeted tests, + and cross-platform manual validation. + +## Rollout + +No feature flag is required for accurate error copy or `Try HTTPS` because both +paths preserve strict certificate verification. + +The proceed path should ship only after desktop and offscreen ownership tests +pass. If remote runtime support cannot land in the same release, gate the +button on `browser.certificate-trust.v1`; local desktop support may ship while +older/remote owners retain accurate error copy and trusted-CA guidance. + +Do not close issue #8454 until a packaged build has been validated against a +real self-signed local HTTPS server. Unit mocks alone do not establish Electron +certificate-event ordering. + +## UI Quality Bar + +- Follow `docs/STYLEGUIDE.md` and the adjacent browser failure overlay. +- Use only existing semantic tokens, shadcn buttons, and lucide icons. +- Keep the overlay quiet and monochrome; certificate failure must not introduce + an amber warning treatment. +- Copy must distinguish connection, DNS, and certificate failures without + claiming more than the Chromium error proves. +- Buttons must remain on one line at supported pane widths or wrap as one + deliberate group without overlap. +- Validate light/dark mode, macOS/Windows/Linux font metrics, and 200 ms remote + latency. +- Capture review evidence for local HTTP failure, eligible certificate failure, + ineligible certificate failure, accepted certificate, and the same browser + chrome after recovery. + +## Release Gates and Open Risks + +- The pinned Electron binary must prove that the session request gate blocks a + second tab, sibling HTTPS subresources/WSS, and a second port even when + Chromium does not re-emit `certificate-error`. If any isolation assertion + fails, omit the proceed path or redesign it around a dedicated ephemeral + partition; do not silently widen the grant. +- Same-endpoint WSS and fresh HTTPS subresource connections must work after + approval. A main document that renders while HMR/API traffic remains blocked + does not fix the local-development use case. +- A remote/headless certificate failure must reach the client as both accurate + load-error copy and a live challenge. Logging a rejected offscreen `loadURL` + is not sufficient. +- Stale approval must be rejected in the browser-owning process after any new + main-frame navigation, including when renderer cleanup or a remote snapshot + is delayed. +- SSH-forward validation must use a certificate whose SAN matches the + browser-facing hostname. Authority bypass must not mask a hostname mismatch. +- The app-level listener and real-certificate tests must pass on macOS, Windows, + and Linux/Xvfb. Platform trust-store differences do not justify enabling a + broader error allowlist. + +## Review Decisions and Follow-ups + +### From 2026-07-12 review + +- The integrated browser is part of the development workflow (including + automation, annotations, and remote ownership), so `Open Externally` and trusted + local-certificate guidance remain safer alternatives but do not replace an + explicit in-browser decision. +- V1 intentionally limits approval to `ERR_CERT_AUTHORITY_INVALID (-202)`. + Hostname/SAN, date, revoked, and malformed-certificate failures remain blocked; + broadening that set requires a separate security review and real-certificate + tests. +- Raw error details remain available to existing diagnostics rather than adding a + new disclosure affordance to the quiet recovery overlay. diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 0251da70490..d6bf1187811 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -141,7 +141,6 @@ import { saveCustomKeys, type CustomKey } from '../../../../src/components/CustomKeyModal' -import { buildMobileDiffLines } from '../../../../src/session/mobile-diff-lines' import { addMobileDiffComment, formatDiffComments, @@ -177,7 +176,7 @@ import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-termi import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference' import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus' import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions' -import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind' +import { resolveMobileFileTabDoc } from '../../../../src/files/mobile-file-tab-doc' import { openMobileTerminalFileTap } from '../../../../src/session/mobile-terminal-file-tap-open' import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name' import { @@ -2043,93 +2042,12 @@ export default function SessionScreen() { } setFileDocs((prev) => new Map(prev).set(tab.id, { status: 'loading' })) try { - if (tab.diffSource === 'staged' || tab.diffSource === 'unstaged') { - const response = await client.sendRequest('git.diff', { - worktree: `id:${worktreeId}`, - filePath: tab.relativePath, - staged: tab.diffSource === 'staged' - }) - if (!response.ok) { - throw new Error((response as RpcFailure).error.message) - } - const result = (response as RpcSuccess).result as - | { - kind: 'text' - originalContent: string - modifiedContent: string - } - | { kind: 'binary' } - if (result.kind !== 'text') { - throw new Error('binary_file') - } - const diff = buildMobileDiffLines(result.originalContent, result.modifiedContent) - setFileDocs((prev) => - new Map(prev).set(tab.id, { - status: 'ready', - kind: 'diff', - lines: diff.lines, - truncated: diff.truncated - }) - ) - return - } - const artifactKind = classifyMobileArtifact(tab.relativePath) - if (artifactKind === 'image') { - const preview = await client.sendRequest('files.readPreview', { - worktree: `id:${worktreeId}`, - relativePath: tab.relativePath - }) - if (!preview.ok) { - throw new Error((preview as RpcFailure).error.message) - } - const result = (preview as RpcSuccess).result as { - content: string - isImage?: boolean - mimeType?: string - } - if (!result.isImage || !result.mimeType || result.content.length === 0) { - throw new Error('binary_file') - } - setFileDocs((prev) => - new Map(prev).set(tab.id, { - status: 'ready', - kind: 'image', - dataUri: `data:${result.mimeType};base64,${result.content}` - }) - ) - return - } - const response = await client.sendRequest('files.read', { - worktree: `id:${worktreeId}`, - relativePath: tab.relativePath + const doc = await resolveMobileFileTabDoc(client, { + worktreeId, + relativePath: tab.relativePath, + diffSource: tab.diffSource }) - if (!response.ok) { - throw new Error((response as RpcFailure).error.message) - } - const result = (response as RpcSuccess).result as { - content: string - truncated: boolean - byteLength: number - } - if (artifactKind === 'html') { - setFileDocs((prev) => - new Map(prev).set(tab.id, { - status: 'ready', - kind: 'html', - content: result.content - }) - ) - return - } - setFileDocs((prev) => - new Map(prev).set(tab.id, { - status: 'ready', - kind: 'file', - content: result.content, - truncated: result.truncated, - byteLength: result.byteLength - }) - ) + setFileDocs((prev) => new Map(prev).set(tab.id, doc)) } catch (err) { const message = err instanceof Error ? err.message : '' const previewMessage = diff --git a/mobile/src/files/mobile-diff-image-preview.test.ts b/mobile/src/files/mobile-diff-image-preview.test.ts new file mode 100644 index 00000000000..e63f532b28d --- /dev/null +++ b/mobile/src/files/mobile-diff-image-preview.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' +import { mobileDiffImageDataUri } from './mobile-diff-image-preview' + +describe('mobileDiffImageDataUri', () => { + it('renders a modified image diff from the post-change bytes', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: 'b2xk', + modifiedContent: 'bmV3', + isImage: true, + mimeType: 'image/png' + }) + ).toBe('data:image/png;base64,bmV3') + }) + + it('renders an added image diff (no original) from the modified bytes', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: '', + modifiedContent: 'bmV3', + isImage: true, + mimeType: 'image/png' + }) + ).toBe('data:image/png;base64,bmV3') + }) + + it('falls back to the original bytes for a proven deletion (modifiedDeleted)', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: 'b2xk', + originalIsBinary: true, + modifiedContent: '', + modifiedIsBinary: false, + modifiedDeleted: true, + isImage: true, + mimeType: 'image/jpeg' + }) + ).toBe('data:image/jpeg;base64,b2xk') + }) + + // The reviewer's read-failure case: a relay/SSH read returns an empty modified + // side with modifiedIsBinary false and no modifiedDeleted flag. Without a proven + // deletion, falling back to the original would show a stale pre-change image, so + // "unavailable" (null) is the honest result. + it('returns null on a read failure (empty modified, no modifiedDeleted flag)', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: 'b2xk', + originalIsBinary: true, + modifiedContent: '', + modifiedIsBinary: false, + isImage: true, + mimeType: 'image/png' + }) + ).toBeNull() + }) + + // Guards the >size-cap case: the modified side IS a binary image but its bytes + // arrive empty. Falling back to the original would render the stale pre-change + // image; "unavailable" is the honest result. + it('returns null for a modify whose binary modified side is empty (no stale fallback)', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: 'b2xk', + originalIsBinary: true, + modifiedContent: '', + modifiedIsBinary: true, + isImage: true, + mimeType: 'image/png' + }) + ).toBeNull() + }) + + it('returns null for a non-previewable binary (no isImage/mimeType)', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: '', + modifiedContent: 'AAAA' + }) + ).toBeNull() + }) + + it('returns null when flagged as image but carrying no bytes', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: '', + modifiedContent: '', + isImage: true, + mimeType: 'image/png' + }) + ).toBeNull() + }) + + it('returns null for application/pdf even when flagged as previewable', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: '', + modifiedContent: 'JVBER', + isImage: true, + mimeType: 'application/pdf' + }) + ).toBeNull() + }) + + it('returns null when flagged as image but mimeType is missing', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: '', + modifiedContent: 'bmV3', + isImage: true + }) + ).toBeNull() + }) +}) diff --git a/mobile/src/files/mobile-diff-image-preview.ts b/mobile/src/files/mobile-diff-image-preview.ts new file mode 100644 index 00000000000..09d7cd2ef36 --- /dev/null +++ b/mobile/src/files/mobile-diff-image-preview.ts @@ -0,0 +1,32 @@ +import { buildImageDataUri } from '../../../src/shared/image-data-uri' + +// modifiedDeleted marks a proven deletion (modified side genuinely absent); an +// empty modifiedContent alone can't, since a relay/SSH read failure also arrives +// empty with modifiedIsBinary false. +export type MobileBinaryDiffResult = { + kind: 'binary' + originalContent?: string + modifiedContent?: string + originalIsBinary?: boolean + modifiedIsBinary?: boolean + modifiedDeleted?: boolean + isImage?: boolean + mimeType?: string +} + +// Falls back to the original bytes only for a proven deletion; a read failure or +// size-capped modify — also empty on the modified side — returns null instead of +// the stale pre-change image. +export function mobileDiffImageDataUri(result: MobileBinaryDiffResult): string | null { + if (result.isImage !== true) { + return null + } + const modified = result.modifiedContent || '' + if (modified) { + return buildImageDataUri(result.mimeType, modified) + } + if (result.modifiedDeleted === true) { + return buildImageDataUri(result.mimeType, result.originalContent || '') + } + return null +} diff --git a/mobile/src/files/mobile-file-tab-doc.test.ts b/mobile/src/files/mobile-file-tab-doc.test.ts new file mode 100644 index 00000000000..a9570e6cf44 --- /dev/null +++ b/mobile/src/files/mobile-file-tab-doc.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import type { RpcResponse } from '../transport/types' +import { resolveMobileFileTabDoc } from './mobile-file-tab-doc' + +function ok(result: unknown): RpcResponse { + return { id: 'x', ok: true, result, _meta: { runtimeId: 'r' } } +} + +function fail(code: string, message: string): RpcResponse { + return { id: 'x', ok: false, error: { code, message }, _meta: { runtimeId: 'r' } } +} + +// Fake client that returns a canned response per RPC method. +function clientOf(byMethod: Record): { + sendRequest: (method: string) => Promise + calls: string[] +} { + const calls: string[] = [] + return { + calls, + sendRequest: (method: string) => { + calls.push(method) + const response = byMethod[method] + if (!response) { + throw new Error(`unexpected method ${method}`) + } + return Promise.resolve(response) + } + } +} + +const WT = { worktreeId: 'wt1' } + +describe('resolveMobileFileTabDoc', () => { + it('renders a staged text diff', async () => { + const client = clientOf({ + 'git.diff': ok({ kind: 'text', originalContent: 'a\n', modifiedContent: 'a\nb\n' }) + }) + const doc = await resolveMobileFileTabDoc(client, { + ...WT, + relativePath: 'a.ts', + diffSource: 'staged' + }) + expect(doc.kind).toBe('diff') + expect(client.calls).toEqual(['git.diff']) + }) + + it('renders an unstaged image diff from the modified bytes', async () => { + const client = clientOf({ + 'git.diff': ok({ + kind: 'binary', + originalContent: 'b2xk', + modifiedContent: 'bmV3', + modifiedIsBinary: true, + isImage: true, + mimeType: 'image/png' + }) + }) + const doc = await resolveMobileFileTabDoc(client, { + ...WT, + relativePath: 'm1.png', + diffSource: 'unstaged' + }) + expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' }) + }) + + it('throws binary_file for an image modify whose bytes are empty (no stale fallback)', async () => { + const client = clientOf({ + 'git.diff': ok({ + kind: 'binary', + originalContent: 'b2xk', + modifiedContent: '', + modifiedIsBinary: true, + isImage: true, + mimeType: 'image/png' + }) + }) + await expect( + resolveMobileFileTabDoc(client, { ...WT, relativePath: 'm1.png', diffSource: 'unstaged' }) + ).rejects.toThrow('binary_file') + }) + + it('throws binary_file for a non-image binary diff', async () => { + const client = clientOf({ 'git.diff': ok({ kind: 'binary', modifiedContent: 'AAAA' }) }) + await expect( + resolveMobileFileTabDoc(client, { ...WT, relativePath: 'a.bin', diffSource: 'unstaged' }) + ).rejects.toThrow('binary_file') + }) + + it('renders a live image preview via files.readPreview', async () => { + const client = clientOf({ + 'files.readPreview': ok({ content: 'bmV3', isImage: true, mimeType: 'image/png' }) + }) + const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'logo.png' }) + expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' }) + expect(client.calls).toEqual(['files.readPreview']) + }) + + it('throws binary_file when readPreview returns no image bytes', async () => { + const client = clientOf({ + 'files.readPreview': ok({ content: '', isImage: true, mimeType: 'image/png' }) + }) + await expect( + resolveMobileFileTabDoc(client, { ...WT, relativePath: 'logo.png' }) + ).rejects.toThrow('binary_file') + }) + + it('renders html source via files.read', async () => { + const client = clientOf({ + 'files.read': ok({ content: '

hi

', truncated: false, byteLength: 11 }) + }) + const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'page.html' }) + expect(doc).toEqual({ status: 'ready', kind: 'html', content: '

hi

' }) + }) + + it('renders a plain text file via files.read', async () => { + const client = clientOf({ + 'files.read': ok({ content: 'hello', truncated: true, byteLength: 5 }) + }) + const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'notes.txt' }) + expect(doc).toEqual({ + status: 'ready', + kind: 'file', + content: 'hello', + truncated: true, + byteLength: 5 + }) + }) + + it('propagates the RPC error message when a read fails', async () => { + const client = clientOf({ 'files.read': fail('EIO', 'file_too_large') }) + await expect( + resolveMobileFileTabDoc(client, { ...WT, relativePath: 'notes.txt' }) + ).rejects.toThrow('file_too_large') + }) +}) diff --git a/mobile/src/files/mobile-file-tab-doc.ts b/mobile/src/files/mobile-file-tab-doc.ts new file mode 100644 index 00000000000..78977b5fd30 --- /dev/null +++ b/mobile/src/files/mobile-file-tab-doc.ts @@ -0,0 +1,94 @@ +import { buildImageDataUri } from '../../../src/shared/image-data-uri' +import { classifyMobileArtifact } from '../session/mobile-artifact-kind' +import { buildMobileDiffLines, type MobileDiffLine } from '../session/mobile-diff-lines' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcFailure, RpcSuccess } from '../transport/types' +import { mobileDiffImageDataUri, type MobileBinaryDiffResult } from './mobile-diff-image-preview' + +type FileTabDocClient = Pick + +// The ready doc a session file tab renders. Mirrors the ready arm of the route's +// FileDocState; kept in src so the loader stays testable without the route. +export type MobileFileTabDoc = + | { status: 'ready'; kind: 'file'; content: string; truncated: boolean; byteLength: number } + | { status: 'ready'; kind: 'diff'; lines: MobileDiffLine[]; truncated: boolean } + | { status: 'ready'; kind: 'image'; dataUri: string } + | { status: 'ready'; kind: 'html'; content: string } + +export type MobileFileTabDocRequest = { + worktreeId: string + relativePath: string + diffSource?: 'staged' | 'unstaged' | 'branch' | 'commit' +} + +// Throws 'binary_file'/'file_too_large'/the RPC error message; callers map those +// to error docs. +export async function resolveMobileFileTabDoc( + client: FileTabDocClient, + request: MobileFileTabDocRequest +): Promise { + const worktree = `id:${request.worktreeId}` + const { relativePath } = request + if (request.diffSource === 'staged' || request.diffSource === 'unstaged') { + const response = await client.sendRequest('git.diff', { + worktree, + filePath: relativePath, + staged: request.diffSource === 'staged' + }) + if (!response.ok) { + throw new Error((response as RpcFailure).error.message) + } + const result = (response as RpcSuccess).result as + | { kind: 'text'; originalContent: string; modifiedContent: string } + | MobileBinaryDiffResult + if (result.kind !== 'text') { + // Render image diffs (add/modify/delete) from the base64 the host already + // sends; only non-previewable binaries stay unavailable. + const dataUri = mobileDiffImageDataUri(result) + if (!dataUri) { + throw new Error('binary_file') + } + return { status: 'ready', kind: 'image', dataUri } + } + const diff = buildMobileDiffLines(result.originalContent, result.modifiedContent) + return { status: 'ready', kind: 'diff', lines: diff.lines, truncated: diff.truncated } + } + + const artifactKind = classifyMobileArtifact(relativePath) + if (artifactKind === 'image') { + const preview = await client.sendRequest('files.readPreview', { worktree, relativePath }) + if (!preview.ok) { + throw new Error((preview as RpcFailure).error.message) + } + const result = (preview as RpcSuccess).result as { + content: string + isImage?: boolean + mimeType?: string + } + const dataUri = result.isImage ? buildImageDataUri(result.mimeType, result.content) : null + if (!dataUri) { + throw new Error('binary_file') + } + return { status: 'ready', kind: 'image', dataUri } + } + + const response = await client.sendRequest('files.read', { worktree, relativePath }) + if (!response.ok) { + throw new Error((response as RpcFailure).error.message) + } + const result = (response as RpcSuccess).result as { + content: string + truncated: boolean + byteLength: number + } + if (artifactKind === 'html') { + return { status: 'ready', kind: 'html', content: result.content } + } + return { + status: 'ready', + kind: 'file', + content: result.content, + truncated: result.truncated, + byteLength: result.byteLength + } +} diff --git a/mobile/src/source-control/mobile-git-status.test.ts b/mobile/src/source-control/mobile-git-status.test.ts index 9c3575a75b2..a9e2b554f91 100644 --- a/mobile/src/source-control/mobile-git-status.test.ts +++ b/mobile/src/source-control/mobile-git-status.test.ts @@ -2,6 +2,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import type { GitStatusResult } from '../../../src/shared/git-status-types' import { buildMobileSourceControlSections, + canOpenMobileGitStatusEntry, countStagedEntries, countUnstagedEntries, getStageablePaths, @@ -65,6 +66,31 @@ describe('mobile source control status helpers', () => { expect(isMobileGitDiscardableEntry(conflictedEntries[2])).toBe(false) }) + it('allows opening deleted files so pre-delete text/image diffs can load', () => { + expect( + canOpenMobileGitStatusEntry({ path: 'deleted.png', status: 'deleted', area: 'unstaged' }) + ).toBe(true) + expect( + canOpenMobileGitStatusEntry({ path: 'logo.png', status: 'modified', area: 'unstaged' }) + ).toBe(true) + expect( + canOpenMobileGitStatusEntry({ + path: 'conflict.ts', + status: 'modified', + area: 'unstaged', + conflictStatus: 'unresolved' + }) + ).toBe(false) + expect( + canOpenMobileGitStatusEntry({ + path: 'resolved.ts', + status: 'modified', + area: 'unstaged', + conflictStatus: 'resolved_locally' + }) + ).toBe(true) + }) + it('sorts entries by desktop-compatible conflict rank, then path', () => { const sections = buildMobileSourceControlSections([ { path: 'zeta.ts', status: 'modified', area: 'unstaged' }, diff --git a/mobile/src/source-control/mobile-git-status.ts b/mobile/src/source-control/mobile-git-status.ts index c6bfde9b8c2..65fcf61e211 100644 --- a/mobile/src/source-control/mobile-git-status.ts +++ b/mobile/src/source-control/mobile-git-status.ts @@ -90,6 +90,12 @@ export function isMobileGitDiscardableEntry(entry: MobileGitStatusEntry): boolea return entry.conflictStatus !== 'unresolved' && entry.conflictStatus !== 'resolved_locally' } +// Why: unresolved conflicts are not a stable file to open. Deletions are — +// git.diff still returns the pre-delete side (text or image via modifiedDeleted). +export function canOpenMobileGitStatusEntry(entry: MobileGitStatusEntry): boolean { + return entry.conflictStatus !== 'unresolved' +} + export function isMobileGitUnavailable(code: string | undefined, message: string | undefined) { return ( code === 'forbidden' || diff --git a/mobile/src/source-control/mobile-source-control-screen-state.ts b/mobile/src/source-control/mobile-source-control-screen-state.ts index eb20cdaa3ac..78a588d19ff 100644 --- a/mobile/src/source-control/mobile-source-control-screen-state.ts +++ b/mobile/src/source-control/mobile-source-control-screen-state.ts @@ -20,6 +20,7 @@ import type { MobileGitBranchCompareSummary } from './mobile-branch-compare' import { + canOpenMobileGitStatusEntry, isMobileGitDiscardableEntry, isMobileGitStageableEntry, type MobileGitFileStatus, @@ -58,15 +59,14 @@ export type MobileGitStatusEntryView = MobileGitStatusEntry & { } // Decorate raw status entries with the row-level capability/action-id fields the -// file list needs. Deleted/unresolved entries are not openable (matches the -// opener guards). +// file list needs. Opener guards must use the same canOpen rule. export function buildMobileGitStatusEntryViews( entries: readonly MobileGitStatusEntry[] ): MobileGitStatusEntryView[] { return entries.map((entry) => ({ ...entry, canDiscard: isMobileGitDiscardableEntry(entry), - canOpen: entry.status !== 'deleted' && entry.conflictStatus !== 'unresolved', + canOpen: canOpenMobileGitStatusEntry(entry), canStage: isMobileGitStageableEntry(entry), discardActionId: `discard:${entry.path}`, stageActionId: `stage:${entry.path}`, diff --git a/mobile/src/source-control/use-mobile-source-control-openers.ts b/mobile/src/source-control/use-mobile-source-control-openers.ts index be3febb4a80..bf0eff63cf6 100644 --- a/mobile/src/source-control/use-mobile-source-control-openers.ts +++ b/mobile/src/source-control/use-mobile-source-control-openers.ts @@ -12,7 +12,11 @@ import { canOpenMobileBranchCompareDiff, type MobileGitBranchChangeEntry } from './mobile-branch-compare' -import { isMobileGitUnavailable, type MobileGitStatusEntry } from './mobile-git-status' +import { + canOpenMobileGitStatusEntry, + isMobileGitUnavailable, + type MobileGitStatusEntry +} from './mobile-git-status' import { buildMobileReviewFileRoute } from './mobile-review-route' import type { GitDiffTextResult, @@ -70,7 +74,9 @@ export function useMobileSourceControlOpeners(params: Params) { const openFile = useCallback( async (entry: MobileGitStatusEntry) => { - if (entry.status === 'deleted' || entry.conflictStatus === 'unresolved') { + // Deletions are openable (pre-delete text/image via git.diff); only block + // unresolved conflicts, matching canOpenMobileGitStatusEntry / row UI. + if (!canOpenMobileGitStatusEntry(entry)) { return } if (openingPathRef.current || busyActionRef.current) { diff --git a/package.json b/package.json index 292873687f1..147a7c89226 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.144-rc.2", + "version": "1.4.144-rc.4", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/resources/skills/current-manifest.json b/resources/skills/current-manifest.json index fca1162fa5e..cea063aa334 100644 --- a/resources/skills/current-manifest.json +++ b/resources/skills/current-manifest.json @@ -1,11 +1,11 @@ { "schemaVersion": 1, - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.4", "skills": [ { "name": "computer-use", "sourcePath": "skills/computer-use", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.4", "releaseRevision": 5, "packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68", "gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344", @@ -24,7 +24,7 @@ { "name": "linear-tickets", "sourcePath": "skills/linear-tickets", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.4", "releaseRevision": 4, "packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b", "gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176", @@ -43,7 +43,7 @@ { "name": "orca-cli", "sourcePath": "skills/orca-cli", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.4", "releaseRevision": 32, "packageDigest": "51740ff13f379ac5743d3fd28a14b17168dcef40f7048c20182dce166098c45f", "gitTreeSha": "ded93000a5f654e2b4f324501282459bd56afe19", @@ -62,7 +62,7 @@ { "name": "orca-emulator", "sourcePath": "skills/orca-emulator", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.4", "releaseRevision": 4, "packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b", "gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656", @@ -81,7 +81,7 @@ { "name": "orca-emulator-android", "sourcePath": "skills/orca-emulator-android", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.4", "releaseRevision": 2, "packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5", "gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83", @@ -100,7 +100,7 @@ { "name": "orca-linear", "sourcePath": "skills/orca-linear", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.4", "releaseRevision": 2, "packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f", "gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8", @@ -119,7 +119,7 @@ { "name": "orca-per-workspace-env", "sourcePath": "skills/orca-per-workspace-env", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.4", "releaseRevision": 2, "packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d", "gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650", @@ -138,7 +138,7 @@ { "name": "orchestration", "sourcePath": "skills/orchestration", - "appVersion": "1.4.144-rc.1", + "appVersion": "1.4.144-rc.4", "releaseRevision": 24, "packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9", "gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982", diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index c1f46a602fb..1df5dbba930 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -140,7 +140,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { }) it('strips a prefix the model leaked into the slug from both branch and display name', async () => { - // Model ignored "no prefixes" and echoed `you/worktree-spinner`, which the + // Model echoed `you/worktree-spinner`, which the // sanitizer folds to `you-worktree-spinner`; without stripping it would // double-prefix the branch (`you/you-...`) and show "You worktree spinner". generateBranchNameMock.mockResolvedValue({ success: true, slug: 'you-worktree-spinner' }) diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index c6e7fb8d10e..d669192907f 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -283,9 +283,8 @@ async function runAutoRename( const username = provider ? (await getSshGitUsername(provider, repo.path)) || null : (await resolveLocalGitUsername(repo.path)) || null - // The model is told not to add a prefix, but sometimes echoes the configured - // one (e.g. `tmchow/...`); strip it so it doesn't double-prefix the branch or - // leak into the display name. + // The model sometimes echoes the configured prefix (e.g. `tmchow/...`); strip + // it so it doesn't double-prefix the branch or leak into the display name. const slug = stripConfiguredBranchPrefix( generated.slug, getConfiguredBranchPrefix(settings, username) diff --git a/src/main/browser/agent-browser-bridge.test.ts b/src/main/browser/agent-browser-bridge.test.ts index f55f726f356..4629c844f08 100644 --- a/src/main/browser/agent-browser-bridge.test.ts +++ b/src/main/browser/agent-browser-bridge.test.ts @@ -79,6 +79,8 @@ function mockBrowserManager( getWebContentsIdByTabId: () => tabs, getWorktreeIdForTab: (tabId: string) => worktrees.get(tabId), getGuestWebContentsId: vi.fn(() => null), + getBrowserPageLoadError: vi.fn(() => null), + getBrowserPageCertificateFailure: vi.fn(() => null), unregisterGuest: vi.fn(), ensureWebviewVisible: vi.fn(async () => () => {}), acquireAutomationVisibility: vi.fn(async () => () => {}), @@ -531,6 +533,43 @@ describe('AgentBrowserBridge', () => { expect(result.tabs[0].url).toBe('https://a.com') }) + it('surfaces the browser-manager load error on each listed tab', () => { + const tabs = new Map([['tab-a', 1]]) + const wc1 = mockWebContents(1, 'chrome-error://chromewebdata/', '') + webContentsFromIdMock.mockImplementation((id: number) => (id === 1 ? wc1 : null)) + const loadError = { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/' + } + const certificateFailure = { + challengeId: 'challenge-1', + browserPageId: 'tab-a', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + } + const b = new AgentBrowserBridge( + mockBrowserManager(tabs, new Map(), { + getBrowserPageLoadError: vi.fn((tabId: string) => (tabId === 'tab-a' ? loadError : null)), + getBrowserPageCertificateFailure: vi.fn((tabId: string) => + tabId === 'tab-a' ? certificateFailure : null + ) + }) + ) + + // Why: an agent driving the browser must see the structured cert failure, + // not just chrome-error:// from getURL(). + expect(b.tabList().tabs[0]).toMatchObject({ + url: 'https://localhost:3443/', + loadError, + certificateFailure + }) + }) + it('does not mutate active-tab routing when tab-list infers the first live tab', () => { const tabs = new Map([ ['tab-a', 1], diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts index 80817c81d58..0bec7bf68fa 100644 --- a/src/main/browser/agent-browser-bridge.ts +++ b/src/main/browser/agent-browser-bridge.ts @@ -698,12 +698,18 @@ export class AgentBrowserBridge { if (firstLiveWcId === null) { firstLiveWcId = wcId } + const loadError = this.browserManager.getBrowserPageLoadError(tabId) + const certificateFailure = this.browserManager.getBrowserPageCertificateFailure(tabId) result.push({ browserPageId: tabId, index: index++, - url: wc.getURL() ?? '', + // Why: failed WebContents report chrome-error://, which is neither + // actionable nor the address the user asked to load. + url: loadError?.validatedUrl ?? wc.getURL() ?? '', title: wc.getTitle() ?? '', - active: wcId === activeWcId + active: wcId === activeWcId, + loadError, + certificateFailure }) } // Why: if no tab has been explicitly activated yet, surface the first live diff --git a/src/main/browser/browser-certificate-challenge.ts b/src/main/browser/browser-certificate-challenge.ts new file mode 100644 index 00000000000..2e49685b5d3 --- /dev/null +++ b/src/main/browser/browser-certificate-challenge.ts @@ -0,0 +1,77 @@ +import type { BrowserCertificateFailure } from '../../shared/types' +import { SUPPORTED_CERTIFICATE_ERROR_CODE } from './browser-certificate-identity' + +export const CERTIFICATE_CHALLENGE_TTL_MS = 5 * 60_000 +export const MAX_PENDING_CERTIFICATE_CHALLENGES = 32 +export const MAX_CERTIFICATE_GRANTS = 32 + +export type ManagedBrowserGuestContext = { + browserPageId: string | null + worktreeId: string | null + sessionProfileId: string | null + owner: 'desktop-webview' | 'offscreen' +} + +export type BrowserCertificateTrustControllerDependencies = { + resolveManagedGuestContext: (webContentsId: number) => ManagedBrowserGuestContext | null + resolveWebContentsIdForPage: (browserPageId: string) => number | null + resolveWebContents: (webContentsId: number) => Electron.WebContents | null + onFailureChanged: ( + webContentsId: number, + failure: BrowserCertificateFailure | null, + navigationUrl?: string + ) => void + now?: () => number + createChallengeId?: () => string +} + +export type PendingCertificateChallenge = { + challengeId: string + guestWebContentsId: number + browserPageId: string | null + navigationSequence: number + navigationUrl: string + origin: string + displayHost: string + secureEndpoint: string + leafCertificateSha256: string + errorCode: number | null + error: string + observedAt: number + expiresAt: number +} + +export type CertificateTrustGrant = Pick< + PendingCertificateChallenge, + 'guestWebContentsId' | 'secureEndpoint' | 'leafCertificateSha256' | 'error' +> + +export function certificateChallengeIdentityMatches( + challenge: PendingCertificateChallenge, + candidate: Pick< + PendingCertificateChallenge, + 'navigationSequence' | 'secureEndpoint' | 'leafCertificateSha256' | 'error' + > +): boolean { + return ( + challenge.navigationSequence === candidate.navigationSequence && + challenge.secureEndpoint === candidate.secureEndpoint && + challenge.leafCertificateSha256 === candidate.leafCertificateSha256 && + challenge.error === candidate.error + ) +} + +export function toBrowserCertificateFailure( + challenge: PendingCertificateChallenge +): BrowserCertificateFailure { + return { + challengeId: challenge.challengeId, + browserPageId: challenge.browserPageId ?? '', + errorCode: challenge.errorCode, + error: challenge.error, + origin: challenge.origin, + displayHost: challenge.displayHost, + canProceed: challenge.errorCode === SUPPORTED_CERTIFICATE_ERROR_CODE, + observedAt: challenge.observedAt + } +} diff --git a/src/main/browser/browser-certificate-identity.ts b/src/main/browser/browser-certificate-identity.ts new file mode 100644 index 00000000000..9170f8edde4 --- /dev/null +++ b/src/main/browser/browser-certificate-identity.ts @@ -0,0 +1,32 @@ +import { createHash } from 'node:crypto' + +export const SUPPORTED_CERTIFICATE_ERROR = 'ERR_CERT_AUTHORITY_INVALID' +export const SUPPORTED_CERTIFICATE_ERROR_CODE = -202 + +export function normalizeCertificateError(error: string): string { + return error + .trim() + .replace(/^net::/i, '') + .toUpperCase() +} + +export function getSupportedCertificateErrorCode(error: string): number | null { + return normalizeCertificateError(error) === SUPPORTED_CERTIFICATE_ERROR + ? SUPPORTED_CERTIFICATE_ERROR_CODE + : null +} + +export function getLeafCertificateSha256(certificate: Electron.Certificate): string | null { + const match = certificate.data.match( + /-----BEGIN CERTIFICATE-----([\s\S]*?)-----END CERTIFICATE-----/ + ) + if (!match) { + return null + } + try { + const der = Buffer.from(match[1].replace(/\s+/g, ''), 'base64') + return der.length > 0 ? createHash('sha256').update(der).digest('hex') : null + } catch { + return null + } +} diff --git a/src/main/browser/browser-certificate-request-guard.ts b/src/main/browser/browser-certificate-request-guard.ts new file mode 100644 index 00000000000..0a4602804a9 --- /dev/null +++ b/src/main/browser/browser-certificate-request-guard.ts @@ -0,0 +1,203 @@ +import type { Session } from 'electron' + +import { toSecureCertificateEndpoint } from '../../shared/browser-url' +import { MAX_CERTIFICATE_GRANTS, type CertificateTrustGrant } from './browser-certificate-challenge' + +type CertificateIdentity = Pick< + CertificateTrustGrant, + 'secureEndpoint' | 'leafCertificateSha256' | 'error' +> + +type BlockedMainFrame = CertificateIdentity & { + webContentsId: number + navigationUrl: string + origin: string + displayHost: string +} + +type RequestGuardDependencies = { + onBlockedMainFrame: (blocked: BlockedMainFrame) => void +} + +function certificateIdentitiesMatch( + left: CertificateIdentity | undefined, + right: CertificateIdentity +): boolean { + return Boolean( + left && + left.secureEndpoint === right.secureEndpoint && + left.leafCertificateSha256 === right.leafCertificateSha256 && + left.error === right.error + ) +} + +export class BrowserCertificateRequestGuard { + private readonly grantsByGuestId = new Map() + private readonly grantSessionByGuestId = new Map() + private readonly guardedSessions = new Set() + private readonly acceptedIdentityBySession = new Map>() + + constructor(private readonly dependencies: RequestGuardDependencies) {} + + installSession(session: Session): void { + if (this.guardedSessions.has(session)) { + return + } + // Why: Chromium caches certificate continuations at session scope. This + // request gate restores the narrower per-WebContents approval boundary. + session.webRequest.onBeforeRequest((details, callback) => { + callback(this.shouldBlockRequest(session, details) ? { cancel: true } : {}) + }) + this.guardedSessions.add(session) + } + + removeSession(session: Session): void { + if (!this.guardedSessions.delete(session)) { + return + } + session.webRequest.onBeforeRequest(null) + this.acceptedIdentityBySession.delete(session) + for (const [webContentsId, grantSession] of this.grantSessionByGuestId) { + if (grantSession === session) { + this.revokeGuest(webContentsId) + } + } + } + + canOfferCertificate(session: Session, identity: CertificateIdentity): boolean { + if (!this.guardedSessions.has(session)) { + return false + } + const accepted = this.acceptedIdentityBySession.get(session)?.get(identity.secureEndpoint) + // Why: the request gate cannot inspect TLS after Chromium caches a decision. + // Never accept a second bad leaf for one endpoint in the same app process. + return !accepted || certificateIdentitiesMatch(accepted, identity) + } + + grant(session: Session, grant: CertificateTrustGrant): boolean { + if (!this.guardedSessions.has(session)) { + return false + } + let acceptedByEndpoint = this.acceptedIdentityBySession.get(session) + const accepted = acceptedByEndpoint?.get(grant.secureEndpoint) + // Why: once one leaf is accepted for an endpoint in this session, Chromium + // caches the TLS decision. Refuse a conflicting pending leaf so proceed() + // cannot report success when the next certificate-error will still fail. + if (accepted && !certificateIdentitiesMatch(accepted, grant)) { + return false + } + // Why: pin the identity at grant time, not only on the later certificate + // callback, so a concurrent sibling proceed cannot race in a second leaf. + if (!acceptedByEndpoint) { + acceptedByEndpoint = new Map() + this.acceptedIdentityBySession.set(session, acceptedByEndpoint) + } + acceptedByEndpoint.set(grant.secureEndpoint, { + secureEndpoint: grant.secureEndpoint, + leafCertificateSha256: grant.leafCertificateSha256, + error: grant.error + }) + this.grantsByGuestId.delete(grant.guestWebContentsId) + this.grantsByGuestId.set(grant.guestWebContentsId, grant) + this.grantSessionByGuestId.set(grant.guestWebContentsId, session) + this.enforceGrantBound() + return true + } + + shouldTrustCertificate( + session: Session, + webContentsId: number, + identity: CertificateIdentity + ): boolean { + if ( + !this.guardedSessions.has(session) || + this.grantSessionByGuestId.get(webContentsId) !== session || + !certificateIdentitiesMatch(this.grantsByGuestId.get(webContentsId), identity) + ) { + return false + } + let acceptedByEndpoint = this.acceptedIdentityBySession.get(session) + if (!acceptedByEndpoint) { + acceptedByEndpoint = new Map() + this.acceptedIdentityBySession.set(session, acceptedByEndpoint) + } + const accepted = acceptedByEndpoint.get(identity.secureEndpoint) + if (accepted && !certificateIdentitiesMatch(accepted, identity)) { + return false + } + acceptedByEndpoint.set(identity.secureEndpoint, identity) + return true + } + + revokeGuest(webContentsId: number): void { + this.grantsByGuestId.delete(webContentsId) + this.grantSessionByGuestId.delete(webContentsId) + } + + revokeForCommittedNavigation(webContentsId: number, url: string): void { + const grant = this.grantsByGuestId.get(webContentsId) + if (grant && toSecureCertificateEndpoint(url) !== grant.secureEndpoint) { + this.revokeGuest(webContentsId) + } + } + + private shouldBlockRequest( + session: Session, + details: Electron.OnBeforeRequestListenerDetails + ): boolean { + const secureEndpoint = toSecureCertificateEndpoint(details.url) + if (!secureEndpoint) { + return false + } + const accepted = this.acceptedIdentityBySession.get(session)?.get(secureEndpoint) + if (!accepted) { + return false + } + const webContentsId = details.webContentsId ?? details.webContents?.id + const grant = webContentsId === undefined ? undefined : this.grantsByGuestId.get(webContentsId) + if ( + webContentsId !== undefined && + grant && + this.grantSessionByGuestId.get(webContentsId) === session && + certificateIdentitiesMatch(grant, accepted) + ) { + return false + } + if (details.resourceType === 'mainFrame' && webContentsId !== undefined) { + this.reportBlockedMainFrame(webContentsId, details.url, accepted) + } + return true + } + + private reportBlockedMainFrame( + webContentsId: number, + navigationUrl: string, + identity: CertificateIdentity + ): void { + try { + const parsed = new URL(navigationUrl) + if (parsed.protocol !== 'https:') { + return + } + this.dependencies.onBlockedMainFrame({ + webContentsId, + navigationUrl, + origin: parsed.origin, + displayHost: parsed.host, + ...identity + }) + } catch { + // The request remains blocked even if Chromium supplies a malformed URL. + } + } + + private enforceGrantBound(): void { + while (this.grantsByGuestId.size > MAX_CERTIFICATE_GRANTS) { + const oldest = this.grantsByGuestId.keys().next().value as number | undefined + if (oldest === undefined) { + return + } + this.revokeGuest(oldest) + } + } +} diff --git a/src/main/browser/browser-certificate-trust-controller.test.ts b/src/main/browser/browser-certificate-trust-controller.test.ts new file mode 100644 index 00000000000..85153aef5a7 --- /dev/null +++ b/src/main/browser/browser-certificate-trust-controller.test.ts @@ -0,0 +1,336 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { BrowserCertificateTrustController } from './browser-certificate-trust-controller' + +const FIRST_CERTIFICATE = certificate('first certificate') +const SECOND_CERTIFICATE = certificate('replacement certificate') +let beforeRequestListener: + | (( + details: Electron.OnBeforeRequestListenerDetails, + callback: (response: Electron.CallbackResponse) => void + ) => void) + | null = null +const browserSession = { + webRequest: { + onBeforeRequest: vi.fn( + ( + listener: + | (( + details: Electron.OnBeforeRequestListenerDetails, + callback: (response: Electron.CallbackResponse) => void + ) => void) + | null + ) => { + beforeRequestListener = listener + } + ) + } +} as unknown as Electron.Session + +function certificate(contents: string): Electron.Certificate { + return { + data: `-----BEGIN CERTIFICATE-----\n${Buffer.from(contents).toString('base64')}\n-----END CERTIFICATE-----` + } as Electron.Certificate +} + +function createGuest(id: number): Electron.WebContents { + return { + id, + session: browserSession, + isDestroyed: vi.fn(() => false), + loadURL: vi.fn(() => Promise.resolve()) + } as unknown as Electron.WebContents +} + +function beforeRequest(args: { + url?: string + webContentsId?: number + resourceType?: Electron.OnBeforeRequestListenerDetails['resourceType'] +}) { + const callback = vi.fn() + beforeRequestListener?.( + { + id: 1, + url: args.url ?? 'https://localhost:3443/app', + method: 'GET', + webContentsId: args.webContentsId, + resourceType: args.resourceType ?? 'mainFrame', + referrer: '', + timestamp: 1, + uploadData: [] + }, + callback + ) + return callback +} + +function certificateEvent(args: { + controller: BrowserCertificateTrustController + guest: Electron.WebContents + url?: string + error?: string + certificate?: Electron.Certificate + isMainFrame?: boolean +}) { + const preventDefault = vi.fn() + const callback = vi.fn() + args.controller.handleCertificateError({ + event: { preventDefault }, + webContents: args.guest, + url: args.url ?? 'https://localhost:3443/app', + error: args.error ?? 'net::ERR_CERT_AUTHORITY_INVALID', + certificate: args.certificate ?? FIRST_CERTIFICATE, + callback, + isMainFrame: args.isMainFrame ?? true + }) + return { preventDefault, callback } +} + +describe('BrowserCertificateTrustController', () => { + const guest = createGuest(7) + const otherGuest = createGuest(8) + const onFailureChanged = vi.fn() + let now = 1_000 + let challengeNumber = 0 + let pageByGuestId: Map + let guestByPageId: Map + let guestById: Map + let controller: BrowserCertificateTrustController + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(guest.isDestroyed).mockReturnValue(false) + vi.mocked(otherGuest.isDestroyed).mockReturnValue(false) + vi.mocked(guest.loadURL).mockResolvedValue(undefined) + vi.mocked(otherGuest.loadURL).mockResolvedValue(undefined) + now = 1_000 + challengeNumber = 0 + pageByGuestId = new Map([ + [guest.id, 'page-1'], + [otherGuest.id, 'page-2'] + ]) + guestByPageId = new Map([ + ['page-1', guest.id], + ['page-2', otherGuest.id] + ]) + guestById = new Map([ + [guest.id, guest], + [otherGuest.id, otherGuest] + ]) + controller = new BrowserCertificateTrustController({ + resolveManagedGuestContext: (webContentsId) => { + if (!pageByGuestId.has(webContentsId)) { + return null + } + return { + browserPageId: pageByGuestId.get(webContentsId) ?? null, + worktreeId: 'worktree-1', + sessionProfileId: 'profile-1', + owner: 'desktop-webview' + } + }, + resolveWebContentsIdForPage: (browserPageId) => guestByPageId.get(browserPageId) ?? null, + resolveWebContents: (webContentsId) => guestById.get(webContentsId) ?? null, + onFailureChanged, + now: () => now, + createChallengeId: () => `challenge-${++challengeNumber}` + }) + controller.installSessionRequestGuard(browserSession) + }) + + it('rejects first, then trusts only the approved guest, endpoint, certificate, and error', () => { + controller.onMainFrameNavigationStarted(guest.id) + const first = certificateEvent({ controller, guest }) + + expect(first.callback).toHaveBeenCalledOnce() + expect(first.callback).toHaveBeenCalledWith(false) + expect(first.preventDefault).not.toHaveBeenCalled() + expect(controller.getFailure('page-1')).toMatchObject({ + challengeId: 'challenge-1', + browserPageId: 'page-1', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + errorCode: -202, + canProceed: true + }) + + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: true }) + expect(guest.loadURL).toHaveBeenCalledWith('https://localhost:3443/app') + expect(onFailureChanged).toHaveBeenLastCalledWith(guest.id, null) + + const sameEndpoint = certificateEvent({ + controller, + guest, + url: 'wss://localhost:3443/socket' + }) + expect(sameEndpoint.preventDefault).toHaveBeenCalledOnce() + expect(sameEndpoint.callback).toHaveBeenCalledOnce() + expect(sameEndpoint.callback).toHaveBeenCalledWith(true) + + const otherTab = certificateEvent({ controller, guest: otherGuest }) + expect(otherTab.callback).toHaveBeenCalledWith(false) + expect(otherTab.preventDefault).not.toHaveBeenCalled() + + const otherPort = certificateEvent({ + controller, + guest, + url: 'https://localhost:3444/' + }) + expect(otherPort.callback).toHaveBeenCalledWith(false) + expect(otherPort.preventDefault).not.toHaveBeenCalled() + + const otherCertificate = certificateEvent({ + controller, + guest, + certificate: SECOND_CERTIFICATE + }) + expect(otherCertificate.callback).toHaveBeenCalledWith(false) + expect(otherCertificate.preventDefault).not.toHaveBeenCalled() + }) + + it('blocks a session-cached certificate for sibling requests until that guest approves it', () => { + certificateEvent({ controller, guest }) + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: true }) + expect(certificateEvent({ controller, guest }).callback).toHaveBeenCalledWith(true) + + const siblingMainFrame = beforeRequest({ webContentsId: otherGuest.id }) + expect(siblingMainFrame).toHaveBeenCalledWith({ cancel: true }) + expect(controller.getFailure('page-2')).toMatchObject({ + challengeId: 'challenge-2', + browserPageId: 'page-2', + origin: 'https://localhost:3443', + canProceed: true + }) + + expect( + beforeRequest({ webContentsId: otherGuest.id, resourceType: 'webSocket' }) + ).toHaveBeenCalledWith({ cancel: true }) + expect(beforeRequest({ resourceType: 'webSocket' })).toHaveBeenCalledWith({ cancel: true }) + expect( + beforeRequest({ url: 'https://localhost:3444/app', webContentsId: otherGuest.id }) + ).toHaveBeenCalledWith({}) + expect(beforeRequest({ webContentsId: guest.id })).toHaveBeenCalledWith({}) + + expect(controller.proceed('page-2', 'challenge-2')).toEqual({ ok: true }) + expect(beforeRequest({ webContentsId: otherGuest.id })).toHaveBeenCalledWith({}) + }) + + it('fails closed when an accepted endpoint presents a replacement bad certificate', () => { + certificateEvent({ controller, guest }) + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: true }) + expect(certificateEvent({ controller, guest }).callback).toHaveBeenCalledWith(true) + + controller.onMainFrameNavigationStarted(guest.id) + const replacement = certificateEvent({ controller, guest, certificate: SECOND_CERTIFICATE }) + + expect(replacement.callback).toHaveBeenCalledWith(false) + expect(replacement.preventDefault).not.toHaveBeenCalled() + expect(controller.getFailure('page-1')).toBeNull() + }) + + it('rejects proceed for a second tab whose pending leaf conflicts with an accepted identity', () => { + // Both tabs observe different leaves before either is approved. + certificateEvent({ controller, guest }) + certificateEvent({ controller, guest: otherGuest, certificate: SECOND_CERTIFICATE }) + expect(controller.getFailure('page-1')?.challengeId).toBe('challenge-1') + expect(controller.getFailure('page-2')?.challengeId).toBe('challenge-2') + + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: true }) + // First proceed pins the session identity; the sibling's conflicting leaf + // must not report success (Chromium cannot honor a second bad leaf). + expect(controller.proceed('page-2', 'challenge-2')).toEqual({ + ok: false, + reason: 'ineligible' + }) + expect(otherGuest.loadURL).not.toHaveBeenCalled() + }) + + it('never offers approval for public hosts, non-authority failures, or subframes', () => { + for (const event of [ + certificateEvent({ controller, guest, url: 'https://example.com/' }), + certificateEvent({ controller, guest, error: 'ERR_CERT_DATE_INVALID' }), + certificateEvent({ controller, guest, isMainFrame: false }) + ]) { + expect(event.callback).toHaveBeenCalledOnce() + expect(event.callback).toHaveBeenCalledWith(false) + expect(event.preventDefault).not.toHaveBeenCalled() + } + expect(controller.getFailure('page-1')).toBeNull() + expect(onFailureChanged).not.toHaveBeenCalled() + }) + + it('queues an early challenge until the guest receives its browser page identity', () => { + pageByGuestId.set(guest.id, null) + certificateEvent({ controller, guest }) + + expect(onFailureChanged).not.toHaveBeenCalled() + expect(controller.getFailure('page-1')).toBeNull() + + pageByGuestId.set(guest.id, 'page-1') + controller.onGuestRegistered(guest.id, 'page-1') + + expect(onFailureChanged).toHaveBeenCalledWith( + guest.id, + expect.objectContaining({ challengeId: 'challenge-1', browserPageId: 'page-1' }), + 'https://localhost:3443/app' + ) + }) + + it('rejects stale, expired, destroyed, and mismatched approval attempts', () => { + certificateEvent({ controller, guest }) + + expect(controller.proceed('page-1', 'wrong-challenge')).toEqual({ + ok: false, + reason: 'changed' + }) + now += 5 * 60_000 + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ + ok: false, + reason: 'expired' + }) + + certificateEvent({ controller, guest }) + vi.mocked(guest.isDestroyed).mockReturnValue(true) + expect(controller.proceed('page-1', 'challenge-2')).toEqual({ + ok: false, + reason: 'missing' + }) + expect(guest.loadURL).not.toHaveBeenCalled() + }) + + it('clears pending approval and grants when navigation or guest ownership changes', () => { + certificateEvent({ controller, guest }) + controller.onMainFrameNavigationStarted(guest.id) + expect(controller.getFailure('page-1')).toBeNull() + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ + ok: false, + reason: 'missing' + }) + + certificateEvent({ controller, guest }) + expect(controller.proceed('page-1', 'challenge-2')).toEqual({ ok: true }) + controller.onMainFrameNavigationCommitted(guest.id, 'https://example.com/') + const afterCommit = certificateEvent({ controller, guest }) + expect(afterCommit.callback).toHaveBeenCalledWith(false) + + controller.onGuestRetired(guest.id) + expect(controller.getFailure('page-1')).toBeNull() + }) + + it('answers malformed and unmanaged events exactly once without overriding Electron', () => { + pageByGuestId.delete(guest.id) + const unmanaged = certificateEvent({ controller, guest }) + const malformedUrl = certificateEvent({ controller, guest: otherGuest, url: 'not a URL' }) + const malformedCertificate = certificateEvent({ + controller, + guest: otherGuest, + certificate: { data: 'not a PEM certificate' } as Electron.Certificate + }) + + for (const event of [unmanaged, malformedUrl, malformedCertificate]) { + expect(event.callback).toHaveBeenCalledOnce() + expect(event.callback).toHaveBeenCalledWith(false) + expect(event.preventDefault).not.toHaveBeenCalled() + } + }) +}) diff --git a/src/main/browser/browser-certificate-trust-controller.ts b/src/main/browser/browser-certificate-trust-controller.ts new file mode 100644 index 00000000000..e69d7ad2ca3 --- /dev/null +++ b/src/main/browser/browser-certificate-trust-controller.ts @@ -0,0 +1,300 @@ +import { randomUUID } from 'node:crypto' +import type { BrowserCertificateFailure, BrowserCertificateProceedResult } from '../../shared/types' +import { + isEligibleLocalCertificateHost, + toSecureCertificateEndpoint +} from '../../shared/browser-url' +import { + certificateChallengeIdentityMatches, + CERTIFICATE_CHALLENGE_TTL_MS, + MAX_PENDING_CERTIFICATE_CHALLENGES, + toBrowserCertificateFailure, + type BrowserCertificateTrustControllerDependencies, + type PendingCertificateChallenge +} from './browser-certificate-challenge' +import { + getLeafCertificateSha256, + getSupportedCertificateErrorCode, + normalizeCertificateError, + SUPPORTED_CERTIFICATE_ERROR, + SUPPORTED_CERTIFICATE_ERROR_CODE +} from './browser-certificate-identity' +import { BrowserCertificateRequestGuard } from './browser-certificate-request-guard' + +export type { ManagedBrowserGuestContext } from './browser-certificate-challenge' + +export class BrowserCertificateTrustController { + private readonly pendingByGuestId = new Map() + private readonly navigationSequenceByGuestId = new Map() + private readonly expiryTimerByGuestId = new Map>() + private readonly requestGuard: BrowserCertificateRequestGuard + + constructor(private readonly dependencies: BrowserCertificateTrustControllerDependencies) { + this.requestGuard = new BrowserCertificateRequestGuard({ + onBlockedMainFrame: (blocked) => { + const context = this.dependencies.resolveManagedGuestContext(blocked.webContentsId) + if (context) { + this.recordPendingChallenge({ + ...blocked, + browserPageId: context.browserPageId + }) + } + } + }) + } + + installSessionRequestGuard(session: Electron.Session): void { + this.requestGuard.installSession(session) + } + removeSessionRequestGuard(session: Electron.Session): void { + this.requestGuard.removeSession(session) + } + + handleCertificateError(args: { + event: Pick + webContents: Electron.WebContents + url: string + error: string + certificate: Electron.Certificate + callback: (isTrusted: boolean) => void + isMainFrame: boolean + }): void { + let answered = false + const answer = (trusted: boolean): void => { + if (!answered) { + answered = true + args.callback(trusted) + } + } + try { + const context = this.dependencies.resolveManagedGuestContext(args.webContents.id) + const parsed = new URL(args.url) + const endpoint = toSecureCertificateEndpoint(args.url) + const digest = getLeafCertificateSha256(args.certificate) + const error = normalizeCertificateError(args.error) + if (!context || !endpoint || !digest) { + answer(false) + return + } + const identity = { secureEndpoint: endpoint, leafCertificateSha256: digest, error } + if ( + this.requestGuard.shouldTrustCertificate( + args.webContents.session, + args.webContents.id, + identity + ) + ) { + args.event.preventDefault() + answer(true) + return + } + if ( + args.isMainFrame && + parsed.protocol === 'https:' && + error === SUPPORTED_CERTIFICATE_ERROR && + isEligibleLocalCertificateHost(parsed.hostname) && + this.requestGuard.canOfferCertificate(args.webContents.session, identity) + ) { + this.recordPendingChallenge({ + webContentsId: args.webContents.id, + browserPageId: context.browserPageId, + navigationUrl: args.url, + origin: parsed.origin, + displayHost: parsed.host, + secureEndpoint: endpoint, + leafCertificateSha256: digest, + error + }) + } + answer(false) + } catch { + answer(false) + } + } + + onGuestRegistered(webContentsId: number, browserPageId: string): void { + const pending = this.pendingByGuestId.get(webContentsId) + if (!pending) { + return + } + pending.browserPageId = browserPageId + this.emitFailure(pending) + } + + onGuestRetired(webContentsId: number): void { + this.clearPending(webContentsId, true) + this.requestGuard.revokeGuest(webContentsId) + this.navigationSequenceByGuestId.delete(webContentsId) + } + + onMainFrameNavigationStarted(webContentsId: number): void { + this.navigationSequenceByGuestId.set( + webContentsId, + (this.navigationSequenceByGuestId.get(webContentsId) ?? 0) + 1 + ) + this.clearPending(webContentsId, true) + } + + onMainFrameNavigationCommitted(webContentsId: number, url: string): void { + this.requestGuard.revokeForCommittedNavigation(webContentsId, url) + } + + getFailure(browserPageId: string): BrowserCertificateFailure | null { + this.pruneExpired() + for (const pending of this.pendingByGuestId.values()) { + if (pending.browserPageId === browserPageId) { + return toBrowserCertificateFailure(pending) + } + } + return null + } + + proceed(browserPageId: string, challengeId: string): BrowserCertificateProceedResult { + const webContentsId = this.dependencies.resolveWebContentsIdForPage(browserPageId) + if (webContentsId === null) { + return { ok: false, reason: 'missing' } + } + const pending = this.pendingByGuestId.get(webContentsId) + if (!pending) { + return { ok: false, reason: 'missing' } + } + if (pending.challengeId !== challengeId || pending.browserPageId !== browserPageId) { + return { ok: false, reason: 'changed' } + } + if (pending.expiresAt <= this.now()) { + this.clearPending(webContentsId, true) + return { ok: false, reason: 'expired' } + } + if (pending.errorCode !== SUPPORTED_CERTIFICATE_ERROR_CODE) { + return { ok: false, reason: 'ineligible' } + } + if (pending.navigationSequence !== (this.navigationSequenceByGuestId.get(webContentsId) ?? 0)) { + return { ok: false, reason: 'navigated' } + } + const context = this.dependencies.resolveManagedGuestContext(webContentsId) + const guest = this.dependencies.resolveWebContents(webContentsId) + if (!context || context.browserPageId !== browserPageId || !guest || guest.isDestroyed()) { + return { ok: false, reason: 'missing' } + } + const granted = this.requestGuard.grant(guest.session, { + guestWebContentsId: webContentsId, + secureEndpoint: pending.secureEndpoint, + leafCertificateSha256: pending.leafCertificateSha256, + error: pending.error + }) + if (!granted) { + return { ok: false, reason: 'ineligible' } + } + const navigationUrl = pending.navigationUrl + this.clearPending(webContentsId, true) + void guest.loadURL(navigationUrl).catch(() => {}) + return { ok: true } + } + + private recordPendingChallenge(args: { + webContentsId: number + browserPageId: string | null + navigationUrl: string + origin: string + displayHost: string + secureEndpoint: string + leafCertificateSha256: string + error: string + }): void { + const navigationSequence = this.navigationSequenceByGuestId.get(args.webContentsId) ?? 0 + const existing = this.pendingByGuestId.get(args.webContentsId) + if ( + existing && + certificateChallengeIdentityMatches(existing, { + navigationSequence, + secureEndpoint: args.secureEndpoint, + leafCertificateSha256: args.leafCertificateSha256, + error: args.error + }) + ) { + return + } + this.clearPending(args.webContentsId, true) + const observedAt = this.now() + const pending: PendingCertificateChallenge = { + challengeId: this.dependencies.createChallengeId?.() ?? randomUUID(), + guestWebContentsId: args.webContentsId, + browserPageId: args.browserPageId, + navigationSequence, + navigationUrl: args.navigationUrl, + origin: args.origin, + displayHost: args.displayHost, + secureEndpoint: args.secureEndpoint, + leafCertificateSha256: args.leafCertificateSha256, + errorCode: getSupportedCertificateErrorCode(args.error), + error: args.error, + observedAt, + expiresAt: observedAt + CERTIFICATE_CHALLENGE_TTL_MS + } + this.pendingByGuestId.set(args.webContentsId, pending) + this.scheduleExpiry(pending) + this.enforcePendingBound() + this.emitFailure(pending) + } + + private emitFailure(pending: PendingCertificateChallenge): void { + if (pending.browserPageId) { + this.dependencies.onFailureChanged( + pending.guestWebContentsId, + toBrowserCertificateFailure(pending), + pending.navigationUrl + ) + } + } + + private clearPending(webContentsId: number, notify: boolean): void { + const pending = this.pendingByGuestId.get(webContentsId) + this.pendingByGuestId.delete(webContentsId) + const timer = this.expiryTimerByGuestId.get(webContentsId) + if (timer) { + clearTimeout(timer) + this.expiryTimerByGuestId.delete(webContentsId) + } + if (notify && pending?.browserPageId) { + this.dependencies.onFailureChanged(webContentsId, null) + } + } + + private scheduleExpiry(pending: PendingCertificateChallenge): void { + const timer = setTimeout( + () => { + if ( + this.pendingByGuestId.get(pending.guestWebContentsId)?.challengeId === pending.challengeId + ) { + this.clearPending(pending.guestWebContentsId, true) + } + }, + Math.max(0, pending.expiresAt - this.now()) + ) + timer.unref?.() + this.expiryTimerByGuestId.set(pending.guestWebContentsId, timer) + } + + private pruneExpired(): void { + const now = this.now() + for (const pending of this.pendingByGuestId.values()) { + if (pending.expiresAt <= now) { + this.clearPending(pending.guestWebContentsId, true) + } + } + } + + private enforcePendingBound(): void { + while (this.pendingByGuestId.size > MAX_PENDING_CERTIFICATE_CHALLENGES) { + const oldest = this.pendingByGuestId.keys().next().value as number | undefined + if (oldest === undefined) { + return + } + this.clearPending(oldest, true) + } + } + + private now(): number { + return this.dependencies.now?.() ?? Date.now() + } +} diff --git a/src/main/browser/browser-manager-grab.test.ts b/src/main/browser/browser-manager-grab.test.ts index 0d10e915fa8..43652e6b9db 100644 --- a/src/main/browser/browser-manager-grab.test.ts +++ b/src/main/browser/browser-manager-grab.test.ts @@ -781,7 +781,7 @@ describe('browserManager grab operations', () => { const promise = browserManager.awaitGrabSelection('tab-1', 'op-1', guest) // Find the did-start-navigation handler and trigger it with isMainFrame=true - const navHandler = guestOnMock.mock.calls.find( + const navHandler = guestOnMock.mock.calls.findLast( ([event]) => event === 'did-start-navigation' )?.[1] as ((...args: unknown[]) => void) | undefined @@ -798,7 +798,7 @@ describe('browserManager grab operations', () => { void browserManager.awaitGrabSelection('tab-1', 'op-1', guest) // Trigger did-start-navigation with isMainFrame=false (subframe) - const navHandler = guestOnMock.mock.calls.find( + const navHandler = guestOnMock.mock.calls.findLast( ([event]) => event === 'did-start-navigation' )?.[1] as ((...args: unknown[]) => void) | undefined diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index 912923fc8f5..6a4d6400e44 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -104,6 +104,7 @@ describe('browserManager', () => { webContentsFromIdMock.mockReset() openPopupWithOriginBarMock.mockReset() browserManager.unregisterAll() + browserManager.setBrowserGuestStateChangedListener(null) browserManager.setDictationShortcutForwardingPredicate(null) browserManager.setSettingsResolver(() => ({})) }) @@ -583,6 +584,45 @@ describe('browserManager', () => { expect(browserManager.getSessionProfileIdForTab('browser-1')).toBe('work') }) + it('tracks offscreen load failures for the owning worktree snapshot', () => { + const stateChanged = vi.fn() + const offscreenGuest = { + id: 605, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'window'), + setBackgroundThrottling: vi.fn(), + setWindowOpenHandler: vi.fn(), + on: vi.fn(), + off: vi.fn(), + getURL: vi.fn(() => 'https://localhost:3443/') + } + webContentsFromIdMock.mockReturnValue(offscreenGuest) + browserManager.setBrowserGuestStateChangedListener(stateChanged) + + browserManager.registerOffscreenGuest({ + browserPageId: 'offscreen-page', + worktreeId: 'remote-worktree', + webContentsId: offscreenGuest.id + }) + const didFailLoad = offscreenGuest.on.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedUrl: string, + isMainFrame: boolean + ) => void + didFailLoad(null, -202, 'Certificate authority invalid', 'https://localhost:3443/', true) + + expect(browserManager.getBrowserPageLoadError('offscreen-page')).toEqual({ + code: -202, + description: 'Certificate authority invalid', + validatedUrl: 'https://localhost:3443/' + }) + expect(stateChanged).toHaveBeenCalledWith('remote-worktree') + }) + it('falls back to opening popup URLs externally before a guest is registered', () => { const guest = { id: 105, @@ -1588,6 +1628,163 @@ describe('browserManager', () => { validatedUrl: 'http://localhost:3000/' } }) + expect(browserManager.getBrowserPageLoadError('browser-1')).toEqual({ + code: -105, + description: 'Name not resolved', + validatedUrl: 'http://localhost:3000/' + }) + + const didStartNavigationHandler = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as + | ((event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void) + | undefined + didStartNavigationHandler?.(null, 'http://localhost:3000/retry', false, true) + expect(browserManager.getBrowserPageLoadError('browser-1')).toBeNull() + }) + + it('drops a queued failure when a replacement navigation starts before registration', () => { + const rendererSendMock = vi.fn() + const guest = { + id: 407, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + getURL: vi.fn(() => 'https://example.com/') + } + webContentsFromIdMock.mockImplementation((id: number) => + id === guest.id + ? guest + : id === rendererWebContentsId + ? { isDestroyed: vi.fn(() => false), send: rendererSendMock } + : null + ) + + browserManager.attachGuestPolicies(guest as never) + const didFailLoad = guestOnMock.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedUrl: string, + isMainFrame: boolean + ) => void + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + didFailLoad(null, -202, 'Certificate authority invalid', 'https://localhost:3443/', true) + didStartNavigation(null, 'https://example.com/', false, true) + expect( + browserManager.registerGuest({ + browserPageId: 'browser-late-registration', + webContentsId: guest.id, + rendererWebContentsId + }) + ).toBe(true) + + expect(rendererSendMock).not.toHaveBeenCalledWith( + 'browser:guest-load-failed', + expect.anything() + ) + expect(browserManager.getBrowserPageLoadError('browser-late-registration')).toBeNull() + }) + + it('keeps a certificate failure until a real main-frame navigation starts', () => { + const guest = { + id: 405, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + send: vi.fn(), + getURL: vi.fn(() => 'chrome-error://chromewebdata/') + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + const didFailLoad = guestOnMock.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedUrl: string, + isMainFrame: boolean + ) => void + + browserManager.registerGuest({ + browserPageId: 'browser-certificate-page', + webContentsId: guest.id, + rendererWebContentsId + }) + didFailLoad(null, -202, 'Certificate authority invalid', 'https://localhost:3443/', true) + didStartNavigation(null, 'chrome-error://chromewebdata/', false, true) + expect(browserManager.getBrowserPageLoadError('browser-certificate-page')?.code).toBe(-202) + + didStartNavigation(null, 'https://localhost:3443/', false, true) + expect(browserManager.getBrowserPageLoadError('browser-certificate-page')).toBeNull() + }) + + it('restores a certificate failure when a retry navigation aborts before committing', () => { + const guest = { + id: 406, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + send: vi.fn(), + getURL: vi.fn(() => 'chrome-error://chromewebdata/') + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + const didFailLoad = guestOnMock.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedUrl: string, + isMainFrame: boolean + ) => void + + browserManager.registerGuest({ + browserPageId: 'browser-retry-abort-page', + webContentsId: guest.id, + rendererWebContentsId + }) + didFailLoad(null, -202, 'Certificate authority invalid', 'https://localhost:3443/', true) + // Retry: the new navigation starts (overlay optimistically cleared)... + didStartNavigation(null, 'https://localhost:3443/', false, true) + expect(browserManager.getBrowserPageLoadError('browser-retry-abort-page')).toBeNull() + // ...then aborts (ERR_ABORTED) before committing, so the error is restored. + didFailLoad(null, -3, 'Aborted', 'https://localhost:3443/', true) + expect(browserManager.getBrowserPageLoadError('browser-retry-abort-page')?.code).toBe(-202) + + // A fresh navigation from a non-errored state drops the stash, so a later + // abort cannot resurrect the old error. + didStartNavigation(null, 'https://localhost:3443/', false, true) // stashes -202, clears active + didStartNavigation(null, 'https://example.com/', false, true) // active empty -> drops stash + didFailLoad(null, -3, 'Aborted', 'https://example.com/', true) + expect(browserManager.getBrowserPageLoadError('browser-retry-abort-page')).toBeNull() }) it('queues permission denials and download requests until the guest registers', () => { diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index b9232bcd8d6..60352732efa 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -9,7 +9,8 @@ import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants' import { normalizeBrowserNavigationUrl, normalizeExternalBrowserUrl, - redactKagiSessionToken + redactKagiSessionToken, + toSecureCertificateEndpoint } from '../../shared/browser-url' import type { BrowserDownloadFinishedEvent, @@ -52,9 +53,18 @@ import { buildBrowserAnnotationViewportBridgeScript } from '../../shared/browser-annotation-viewport-bridge' import type { KeybindingOverrides } from '../../shared/keybindings' +import type { BrowserCertificateFailure, BrowserLoadError } from '../../shared/types' +import { + BrowserCertificateTrustController, + type ManagedBrowserGuestContext +} from './browser-certificate-trust-controller' const AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS = 2_000 +function isChromiumInternalErrorUrl(url: string): boolean { + return url.startsWith('chrome-error://') +} + function resolveWithTimeout( promise: Promise, timeoutMs: number, @@ -238,23 +248,47 @@ export class BrowserManager { private readonly annotationViewportBridgeOpsByTabId = new Map>() private readonly worktreeIdByTabId = new Map() private readonly policyAttachedGuestIds = new Set() + private readonly offscreenGuestIds = new Set() private readonly policyCleanupByGuestId = new Map void>() private readonly clickedLinkFrameNameByGuestId = new Map() + private readonly loadErrorsByGuestId = new Map() + // Why: did-start-navigation optimistically hides the overlay, but an aborted + // nav never commits — stash the cleared error so did-fail-load(-3) can restore + // it instead of stranding the user on a blank surface. + private readonly clearedLoadErrorsByGuestId = new Map() + private browserGuestStateChangedListener: ((worktreeId: string) => void) | null = null + private certificateTrustController: BrowserCertificateTrustController | null = null private shouldForwardDictationShortcut: (() => boolean) | null = null private readonly pendingLoadFailuresByGuestId = new Map< number, { code: number; description: string; validatedUrl: string } >() - - setDictationShortcutForwardingPredicate(predicate: (() => boolean) | null): void { - this.shouldForwardDictationShortcut = predicate - } private readonly pendingPermissionEventsByGuestId = new Map() private readonly pendingPopupEventsByGuestId = new Map() private readonly pendingDownloadIdsByGuestId = new Map() private readonly downloadsById = new Map() private readonly grabSessionController = new BrowserGrabSessionController() + setDictationShortcutForwardingPredicate(predicate: (() => boolean) | null): void { + this.shouldForwardDictationShortcut = predicate + } + + setBrowserGuestStateChangedListener(listener: ((worktreeId: string) => void) | null): void { + this.browserGuestStateChangedListener = listener + } + + setCertificateTrustController(controller: BrowserCertificateTrustController): void { + this.certificateTrustController = controller + } + + installCertificateRequestGuard(session: Electron.Session): void { + this.certificateTrustController?.installSessionRequestGuard(session) + } + + removeCertificateRequestGuard(session: Electron.Session): void { + this.certificateTrustController?.removeSessionRequestGuard(session) + } + setSettingsResolver( resolver: () => { keybindings?: KeybindingOverrides @@ -827,18 +861,84 @@ export class BrowserManager { validatedURL: string, isMainFrame: boolean ): void => { - if (!isMainFrame || errorCode === -3) { + if (!isMainFrame) { return } - this.forwardOrQueueGuestLoadFailure(guest.id, { + const browserPageId = this.tabIdByWebContentsId.get(guest.id) + const certificateFailure = browserPageId + ? this.certificateTrustController?.getFailure(browserPageId) + : null + if ( + certificateFailure && + toSecureCertificateEndpoint(validatedURL || guest.getURL()) === + toSecureCertificateEndpoint(certificateFailure.origin) + ) { + // Why: a request-guard cancellation is the transport for the existing + // certificate warning; do not replace it with ERR_ABORTED/blocked copy. + return + } + if (errorCode === -3) { + // Why: an aborted main-frame nav never committed, so restore the error + // did-start-navigation optimistically cleared — otherwise a retry that + // aborts leaves the failed page with no overlay. + const clearedError = this.clearedLoadErrorsByGuestId.get(guest.id) + if (clearedError !== undefined) { + this.clearedLoadErrorsByGuestId.delete(guest.id) + this.loadErrorsByGuestId.set(guest.id, clearedError) + this.forwardOrQueueGuestLoadFailure(guest.id, clearedError) + this.notifyBrowserGuestStateChanged(guest.id) + } + return + } + this.clearedLoadErrorsByGuestId.delete(guest.id) + const loadError = { code: errorCode, description: errorDescription || 'This site could not be reached.', - validatedUrl: validatedURL || guest.getURL() || 'about:blank' - }) + validatedUrl: redactKagiSessionToken(validatedURL || guest.getURL() || 'about:blank') + } + this.loadErrorsByGuestId.set(guest.id, loadError) + this.forwardOrQueueGuestLoadFailure(guest.id, loadError) + this.notifyBrowserGuestStateChanged(guest.id) + } + + const didStartNavigationHandler = ( + _event: Electron.Event, + url: string, + _isInPlace: boolean, + isMainFrame: boolean + ): void => { + if (!isMainFrame || isChromiumInternalErrorUrl(url)) { + return + } + this.certificateTrustController?.onMainFrameNavigationStarted(guest.id) + // Why: a failure queued before renderer registration belongs only to the + // navigation that produced it. A replacement navigation must not replay + // that stale failure when its later dom-ready registers the guest. + this.pendingLoadFailuresByGuestId.delete(guest.id) + const activeError = this.loadErrorsByGuestId.get(guest.id) + if (activeError === undefined) { + // Why: no error to hide; drop any stale stash so a later abort cannot + // resurrect an error from a navigation that already succeeded. + this.clearedLoadErrorsByGuestId.delete(guest.id) + return + } + this.clearedLoadErrorsByGuestId.set(guest.id, activeError) + this.loadErrorsByGuestId.delete(guest.id) + this.notifyBrowserGuestStateChanged(guest.id) + } + + const didNavigateHandler = (_event: Electron.Event, url: string): void => { + // Why: a committed navigation means the optimistic stash from + // did-start-navigation is obsolete — drop it so a later ERR_ABORTED + // cannot restore a failure over the already-committed page. + this.clearedLoadErrorsByGuestId.delete(guest.id) + this.certificateTrustController?.onMainFrameNavigationCommitted(guest.id, url) } guest.on('will-navigate', navigationGuard) guest.on('will-redirect', navigationGuard) + guest.on('did-start-navigation', didStartNavigationHandler) + guest.on('did-navigate', didNavigateHandler) guest.on('did-fail-load', didFailLoadHandler) const handleDestroyed = (): void => { // Why: guests can be destroyed before renderer registration. Without @@ -874,6 +974,8 @@ export class BrowserManager { if (!guest.isDestroyed()) { guest.off('will-navigate', navigationGuard) guest.off('will-redirect', navigationGuard) + guest.off('did-start-navigation', didStartNavigationHandler) + guest.off('did-navigate', didNavigateHandler) guest.off('did-fail-load', didFailLoadHandler) } }) @@ -918,6 +1020,7 @@ export class BrowserManager { private cleanupGuestPolicyAttachment(guestWebContentsId: number): void { const isPrimaryGuest = this.tabIdByWebContentsId.has(guestWebContentsId) + this.certificateTrustController?.onGuestRetired(guestWebContentsId) const policyCleanup = this.policyCleanupByGuestId.get(guestWebContentsId) if (policyCleanup) { policyCleanup() @@ -925,6 +1028,7 @@ export class BrowserManager { } this.policyAttachedGuestIds.delete(guestWebContentsId) this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId) + this.offscreenGuestIds.delete(guestWebContentsId) this.popupOwnerContextByGuestId.delete(guestWebContentsId) // Why: a popup must stop inheriting authorization as soon as its primary // owner is retired, even if Chromium has not destroyed the child yet. @@ -936,6 +1040,8 @@ export class BrowserManager { } } this.pendingLoadFailuresByGuestId.delete(guestWebContentsId) + this.loadErrorsByGuestId.delete(guestWebContentsId) + this.clearedLoadErrorsByGuestId.delete(guestWebContentsId) this.pendingPermissionEventsByGuestId.delete(guestWebContentsId) this.pendingPopupEventsByGuestId.delete(guestWebContentsId) this.cancelPendingDownloadsForGuest(guestWebContentsId) @@ -949,10 +1055,10 @@ export class BrowserManager { sessionProfileId, webContentsId, rendererWebContentsId - }: BrowserGuestRegistration): void { + }: BrowserGuestRegistration): boolean { const browserTabId = browserPageId ?? legacyBrowserTabId if (!browserTabId) { - return + return false } // Why: re-registering the same browser tab can happen when Chromium swaps // or recreates the underlying guest surface. Any active grab is bound to @@ -968,7 +1074,7 @@ export class BrowserManager { const guest = webContents.fromId(webContentsId) if (!guest || guest.isDestroyed()) { - return + return false } // Why: the renderer sends webContentsId, which we must not blindly trust. @@ -976,21 +1082,20 @@ export class BrowserManager { // causing us to overwrite its setWindowOpenHandler or attach unintended // context menus. Only accept genuine webview guest surfaces. if (guest.getType() !== 'webview') { - return + return false } if (!this.policyAttachedGuestIds.has(webContentsId)) { // Why: renderer registration is only the second half of the guest setup. // Main must only trust guests that already passed attach-time policy // installation; otherwise a trusted renderer could point us at some other // arbitrary webview and bypass the intended host-window attach boundary. - return + return false } const previousWebContentsId = this.webContentsIdByTabId.get(browserTabId) if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { this.retireStaleGuestWebContents(previousWebContentsId) } - this.webContentsIdByTabId.set(browserTabId, webContentsId) this.tabIdByWebContentsId.set(webContentsId, browserTabId) if (workspaceId) { @@ -1001,6 +1106,7 @@ export class BrowserManager { if (worktreeId) { this.worktreeIdByTabId.set(browserTabId, worktreeId) } + this.certificateTrustController?.onGuestRegistered(webContentsId, browserTabId) this.setupContextMenu(browserTabId, guest) this.setupGrabShortcut(browserTabId, guest) @@ -1010,6 +1116,7 @@ export class BrowserManager { this.flushPendingPermissionEvents(browserTabId, webContentsId) this.flushPendingPopupEvents(browserTabId, webContentsId) this.flushPendingDownloadRequests(browserTabId, webContentsId) + return true } unregisterGuest(browserTabId: string): void { @@ -1088,6 +1195,10 @@ export class BrowserManager { if (!guest || guest.isDestroyed()) { return } + // Why: offscreen pages have no renderer webview listeners, so main owns + // their load-failure lifecycle for remote browser chrome. + this.offscreenGuestIds.add(webContentsId) + this.attachGuestPolicies(guest) const previousWebContentsId = this.webContentsIdByTabId.get(browserPageId) if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { this.retireStaleGuestWebContents(previousWebContentsId) @@ -1098,6 +1209,7 @@ export class BrowserManager { if (worktreeId) { this.worktreeIdByTabId.set(browserPageId, worktreeId) } + this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId) } unregisterAll(): void { @@ -1111,6 +1223,7 @@ export class BrowserManager { this.unregisterGuest(browserTabId) } this.policyAttachedGuestIds.clear() + this.offscreenGuestIds.clear() // Why: unregisterGuest only cleans up guests that were registered (have an // entry in webContentsIdByTabId). Guests that went through // attachGuestPolicies but were never registered still have cleanup closures @@ -1125,6 +1238,8 @@ export class BrowserManager { this.worktreeIdByTabId.clear() this.sessionProfileIdByPageId.clear() this.pendingLoadFailuresByGuestId.clear() + this.loadErrorsByGuestId.clear() + this.clearedLoadErrorsByGuestId.clear() this.pendingPermissionEventsByGuestId.clear() this.pendingPopupEventsByGuestId.clear() this.pendingDownloadIdsByGuestId.clear() @@ -1148,6 +1263,86 @@ export class BrowserManager { return this.sessionProfileIdByPageId.get(browserTabId) ?? null } + getBrowserPageLoadError(browserPageId: string): BrowserLoadError | null { + const webContentsId = this.webContentsIdByTabId.get(browserPageId) + return webContentsId === undefined + ? null + : (this.loadErrorsByGuestId.get(webContentsId) ?? null) + } + + getBrowserPageCertificateFailure(browserPageId: string): BrowserCertificateFailure | null { + return this.certificateTrustController?.getFailure(browserPageId) ?? null + } + + getManagedBrowserGuestContext(webContentsId: number): ManagedBrowserGuestContext | null { + if (this.popupOwnerContextByGuestId.has(webContentsId)) { + return null + } + const browserPageId = this.tabIdByWebContentsId.get(webContentsId) ?? null + const offscreen = this.offscreenGuestIds.has(webContentsId) + if (!offscreen && !this.policyAttachedGuestIds.has(webContentsId)) { + return null + } + if (!offscreen) { + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed() || guest.getType() !== 'webview') { + return null + } + } + return { + browserPageId, + worktreeId: browserPageId ? (this.worktreeIdByTabId.get(browserPageId) ?? null) : null, + sessionProfileId: browserPageId + ? (this.sessionProfileIdByPageId.get(browserPageId) ?? null) + : null, + owner: offscreen ? 'offscreen' : 'desktop-webview' + } + } + + notifyCertificateFailureChanged( + webContentsId: number, + failure: BrowserCertificateFailure | null, + navigationUrl?: string + ): void { + if (failure && navigationUrl) { + const loadError = { + code: failure.errorCode ?? -1, + description: failure.error, + validatedUrl: redactKagiSessionToken(navigationUrl) + } + this.loadErrorsByGuestId.set(webContentsId, loadError) + this.forwardOrQueueGuestLoadFailure(webContentsId, loadError) + } + const browserPageId = this.tabIdByWebContentsId.get(webContentsId) + if (!browserPageId) { + return + } + if (this.offscreenGuestIds.has(webContentsId)) { + this.notifyBrowserGuestStateChanged(webContentsId) + return + } + const renderer = this.resolveRendererForBrowserTab(browserPageId) + renderer?.send('browser:certificate-failure-changed', { browserPageId, failure }) + } + + private notifyBrowserGuestStateChanged(webContentsId: number): void { + if (!this.offscreenGuestIds.has(webContentsId)) { + return + } + const browserPageId = this.tabIdByWebContentsId.get(webContentsId) + const worktreeId = browserPageId ? this.worktreeIdByTabId.get(browserPageId) : null + if (worktreeId) { + // Why: this runs inside an Electron guest event dispatch; the listener + // synchronously reconciles mobile-session tabs, and an escaping throw would + // become a fatal uncaught exception (no catch-all main-process guard). + try { + this.browserGuestStateChangedListener?.(worktreeId) + } catch (error) { + console.error('[browser-manager] browserGuestStateChanged listener failed', error) + } + } + } + notifyPermissionDenied(args: { guestWebContentsId: number permission: string @@ -2033,3 +2228,13 @@ export class BrowserManager { } export const browserManager = new BrowserManager() +export const browserCertificateTrustController = new BrowserCertificateTrustController({ + resolveManagedGuestContext: (webContentsId) => + browserManager.getManagedBrowserGuestContext(webContentsId), + resolveWebContentsIdForPage: (browserPageId) => + browserManager.getGuestWebContentsId(browserPageId), + resolveWebContents: (webContentsId) => webContents.fromId(webContentsId) ?? null, + onFailureChanged: (webContentsId, failure, navigationUrl) => + browserManager.notifyCertificateFailureChanged(webContentsId, failure, navigationUrl) +}) +browserManager.setCertificateTrustController(browserCertificateTrustController) diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index 583afff0815..174b5aa7715 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -109,7 +109,9 @@ function installModuleMocks( vi.doMock('./browser-manager', () => ({ browserManager: { notifyPermissionDenied: browserManagerNotifyPermissionDeniedMock, - handleGuestWillDownload: browserManagerHandleGuestWillDownloadMock + handleGuestWillDownload: browserManagerHandleGuestWillDownloadMock, + installCertificateRequestGuard: vi.fn(), + removeCertificateRequestGuard: vi.fn() } })) vi.doMock('./browser-media-access', () => ({ diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 8d81590ae5c..f116e08c0da 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -21,7 +21,9 @@ vi.mock('electron', () => ({ vi.mock('./browser-manager', () => ({ browserManager: { notifyPermissionDenied: vi.fn(), - handleGuestWillDownload: vi.fn() + handleGuestWillDownload: vi.fn(), + installCertificateRequestGuard: vi.fn(), + removeCertificateRequestGuard: vi.fn() } })) diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index 3603de11568..a70aff4e6b2 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -551,6 +551,7 @@ class BrowserSessionRegistry { } const sess = session.fromPartition(partition) + browserManager.installCertificateRequestGuard(sess) if (typeof sess.getUserAgent === 'function') { const cleanUA = cleanElectronUserAgent(sess.getUserAgent()) sess.setUserAgent(cleanUA) @@ -622,6 +623,7 @@ class BrowserSessionRegistry { // Electron Session object survives; clear policy callbacks and listener // bookkeeping so removed profiles do not leave retained closures behind. this.configuredPartitions.delete(partition) + browserManager.removeCertificateRequestGuard(sess) sess.removeListener('will-download', this.handleWillDownload) clearBrowserWebAuthnAccessHandlers(sess) sess.setPermissionRequestHandler(null) diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index b4e5fc8e26c..ae8c834d894 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -2210,7 +2210,7 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'C:\\PortableGit\\bin\\bash.exe', - ['--login', '-i'], + ['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'], expect.objectContaining({ cwd: 'C:\\Users\\jin\\repo', env: expect.objectContaining({ CHERE_INVOKING: '1' }) diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index 121a8208754..c0789d1529c 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -280,6 +280,121 @@ branch refs/heads/main expect(getGitCalls()).toContain('git worktree remove --force /repo-feature') }) + it('force-retries removal when git refuses a clean worktree containing an initialised submodule', async () => { + mockGitCommands({ + 'git worktree list --porcelain': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }, + 'git worktree list --porcelain#2': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main +` + }, + 'git worktree remove /repo-feature': { + error: new Error('git worktree remove failed'), + stderr: 'fatal: working trees containing submodules cannot be moved or removed' + }, + 'git status --porcelain --untracked-files=all': { stdout: '' } + }) + + await removeWorktree('/repo', '/repo-feature') + + const calls = getGitCalls() + expectGitCallOrder( + calls, + 'git worktree remove /repo-feature', + 'git status --porcelain --untracked-files=all' + ) + expectGitCallOrder( + calls, + 'git status --porcelain --untracked-files=all', + 'git worktree remove --force /repo-feature' + ) + expect(calls).toContain('git branch -d -- feature/test') + }) + + it('surfaces uncommitted changes instead of force-removing a dirty submodule worktree', async () => { + mockGitCommands({ + 'git worktree list --porcelain': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }, + 'git worktree remove /repo-feature': { + error: new Error('git worktree remove failed'), + stderr: 'fatal: working trees containing submodules cannot be moved or removed' + }, + 'git status --porcelain --untracked-files=all': { stdout: ' M sub\n' } + }) + + await expect(removeWorktree('/repo', '/repo-feature')).rejects.toThrow( + 'Worktree has uncommitted or untracked changes.' + ) + expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature') + }) + + it('does not force-retry when the caller already forced removal', async () => { + mockGitCommands({ + 'git worktree list --porcelain': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }, + 'git worktree remove --force /repo-feature': { + error: new Error('git worktree remove failed'), + stderr: 'fatal: working trees containing submodules cannot be moved or removed' + } + }) + + await expect(removeWorktree('/repo', '/repo-feature', true)).rejects.toThrow() + expect( + getGitCalls().filter((call) => call === 'git worktree remove --force /repo-feature') + ).toHaveLength(1) + }) + + it('does not force-retry unrelated non-force remove failures', async () => { + mockGitCommands({ + 'git worktree list --porcelain': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }, + 'git worktree remove /repo-feature': { + error: new Error('git worktree remove failed'), + stderr: 'fatal: contains modified or untracked files, use --force to delete it' + } + }) + + await expect(removeWorktree('/repo', '/repo-feature')).rejects.toThrow( + 'git worktree remove failed' + ) + expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature') + expect(getGitCalls()).not.toContain('git status --porcelain --untracked-files=all') + }) + it('rejects a locked worktree with stable app-owned copy before invoking remove', async () => { mockGitCommands({ 'git worktree list --porcelain': { diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 8af536a31a6..fd5e16f91c5 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -476,6 +476,37 @@ describe('getDiff', () => { }) }) + it('flags a deleted image so previewers can fall back to the original bytes', async () => { + const pngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00]) + gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: pngBuffer }) + statMock.mockRejectedValueOnce(Object.assign(new Error('missing'), { code: 'ENOENT' })) + + const result = await getDiff('/repo', 'assets/deleted.png', false) + + expect(result.kind).toBe('binary') + if (result.kind !== 'binary') { + throw new Error('expected binary diff result') + } + expect(result.modifiedDeleted).toBe(true) + expect(result.originalContent).toBe(pngBuffer.toString('base64')) + expect(result.modifiedContent).toBe('') + }) + + it('does not treat an unreadable working-tree image as a deletion', async () => { + const pngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00]) + gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: pngBuffer }) + statMock.mockResolvedValueOnce({ isFile: () => true, size: 5 }) + readFileMock.mockRejectedValueOnce(new Error('EIO')) + + const result = await getDiff('/repo', 'assets/unreadable.png', false) + + expect(result.kind).toBe('binary') + if (result.kind !== 'binary') { + throw new Error('expected binary diff result') + } + expect(result.modifiedDeleted).toBeUndefined() + }) + it('coalesces concurrent identical staged diff reads while in flight', async () => { const leftBlob = deferredBuffer('head-content\n') const rightBlob = deferredBuffer('index-content\n') diff --git a/src/main/git/status.ts b/src/main/git/status.ts index a0c103b744c..ae818b52567 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -1329,6 +1329,7 @@ async function loadDiff( let modifiedContent = '' let originalIsBinary = false let modifiedIsBinary = false + let modifiedDeleted = false try { const leftBlob = staged @@ -1343,22 +1344,30 @@ async function loadDiff( const rightBlob = await readGitBlobAtIndexPath(worktreePath, filePath, options) modifiedContent = rightBlob.content modifiedIsBinary = rightBlob.isBinary + modifiedDeleted = !rightBlob.exists } else { const workingTreeBlob = await readWorkingTreeFile(path.join(worktreePath, filePath)) modifiedContent = workingTreeBlob.content modifiedIsBinary = workingTreeBlob.isBinary + modifiedDeleted = !workingTreeBlob.exists } } catch { // Fallback } - return buildDiffResult( + const result = buildDiffResult( originalContent, modifiedContent, originalIsBinary, modifiedIsBinary, filePath ) + // Why: mark a proven deletion so previewers can fall back to the original bytes + // without mistaking a read failure's empty modified side for a deletion. + if (result.kind === 'binary' && modifiedDeleted) { + return { ...result, modifiedDeleted: true } + } + return result } export async function getBranchCompare( @@ -1859,20 +1868,33 @@ async function readGitBlobAtOidPath( } async function readWorkingTreeFile(filePath: string): Promise { + let fileStat try { - const fileStat = await stat(filePath) - if (!fileStat.isFile()) { - return { content: '', isBinary: false, exists: false } - } - if (fileStat.size > MAX_GIT_SHOW_BYTES) { - // Why: git blob reads are capped through maxBuffer; mirror that bound for - // unstaged working-tree content before readFile can pull in huge assets. - return { content: '', isBinary: true, exists: true } + fileStat = await stat(filePath) + } catch (error) { + // Why: ENOENT means the working-tree file is genuinely gone (a deletion); + // any other stat error is a read failure, which must not be reported as an + // absence since callers fall back to the original bytes only for deletions. + return { + content: '', + isBinary: false, + exists: (error as NodeJS.ErrnoException)?.code !== 'ENOENT' } + } + if (!fileStat.isFile()) { + return { content: '', isBinary: false, exists: false } + } + if (fileStat.size > MAX_GIT_SHOW_BYTES) { + // Why: git blob reads are capped through maxBuffer; mirror that bound for + // unstaged working-tree content before readFile can pull in huge assets. + return { content: '', isBinary: true, exists: true } + } + try { const buffer = await readFile(filePath) return bufferToBlob(buffer, filePath) } catch { - return { content: '', isBinary: false, exists: false } + // Why: the file exists but could not be read — a read failure, not a deletion. + return { content: '', isBinary: false, exists: true } } } diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 8823c683b60..84ce3963192 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -14,6 +14,7 @@ import type { RemoveWorktreeResult } from '../../shared/types' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree-removal' +import { isSubmoduleWorktreeRemovalRefusal } from '../../shared/worktree-submodule-removal' import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path' import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' import { parseWslUncPath } from '../../shared/wsl-paths' @@ -1221,7 +1222,21 @@ async function performRemoveWorktree( args.push('--force') } args.push(worktreePath) - await gitExecFileAsync(args, gitExecOptions(repoPath, options)) + try { + await gitExecFileAsync(args, gitExecOptions(repoPath, options)) + } catch (error) { + if (force || !isSubmoduleWorktreeRemovalRefusal(error)) { + throw error + } + // Why: Git refuses non-force removal of any worktree with an initialised + // submodule even when everything is clean. Re-prove cleanliness (parent + // status reports dirty submodule content as ` M `), then --force. + await assertWorktreeCleanForRemoval(worktreePath, false, options) + await gitExecFileAsync( + ['worktree', 'remove', '--force', worktreePath], + gitExecOptions(repoPath, options) + ) + } if (!branchName) { return {} diff --git a/src/main/github/client.ts b/src/main/github/client.ts index aa137a971ea..26d629aadb1 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -485,16 +485,32 @@ function mapIssueWorkItem(item: Record): MainWorkItem { .filter(Boolean) : [], updatedAt: String(item.updated_at ?? item.updatedAt ?? ''), - author: - typeof item.user === 'object' && item.user !== null && 'login' in item.user - ? String((item.user as { login?: unknown }).login ?? '') - : typeof item.author === 'object' && item.author !== null && 'login' in item.author - ? String((item.author as { login?: unknown }).login ?? '') - : null, + ...authorFieldsFromUnknown(item), ...(item.assignees !== undefined ? { assignees: usersFromUnknown(item.assignees) } : {}) } } +/** + * Derive both the author login and its API avatar_url in one place so GHE + * avatars render (the login-only `github.com/{login}.png` URL 404s on GHE). + * + * REST exposes `user.avatar_url`; gh/GraphQL expose `author.avatarUrl`. `gh pr + * view` omits the avatar entirely, so authorAvatarUrl is left undefined and the + * UI falls back to the login URL then a placeholder. See #8784. + */ +function authorFieldsFromUnknown( + item: Record +): Pick { + const user = userFromUnknown(item.user ?? item.author) + if (!user) { + return { author: null } + } + return { + author: user.login, + ...(user.avatarUrl ? { authorAvatarUrl: user.avatarUrl } : {}) + } +} + function extractHeadOwnerLogin(item: Record): string | null { // gh CLI `pr list --json headRepositoryOwner` shape: { login } if (typeof item.headRepositoryOwner === 'object' && item.headRepositoryOwner !== null) { @@ -746,12 +762,7 @@ function mapPullRequestWorkItem( .filter(Boolean) : [], updatedAt: String(item.updated_at ?? item.updatedAt ?? ''), - author: - typeof item.user === 'object' && item.user !== null && 'login' in item.user - ? String((item.user as { login?: unknown }).login ?? '') - : typeof item.author === 'object' && item.author !== null && 'login' in item.author - ? String((item.author as { login?: unknown }).login ?? '') - : null, + ...authorFieldsFromUnknown(item), branchName: typeof item.head === 'object' && item.head !== null && 'ref' in item.head ? String((item.head as { ref?: unknown }).ref ?? '') diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts index 6d3d14c99b7..4fd21131c23 100644 --- a/src/main/github/work-item-details.test.ts +++ b/src/main/github/work-item-details.test.ts @@ -216,6 +216,56 @@ describe('getWorkItemDetails', () => { expect(details?.participants?.[0]?.login).toBe('issue-author') }) + it('enriches a non-participating assignee avatar from the GraphQL assignees connection', async () => { + // Why: a GHE assignee who never commented is absent from `participants`, so + // the enrichment must also draw avatars from the `assignees` connection or + // item.assignees keeps the blank avatar `gh` returns. + getWorkItemMock.mockResolvedValueOnce({ + id: 'issue:924', + type: 'issue', + number: 924, + title: 'Assignee avatar', + state: 'open', + url: 'https://github.com/acme/widgets/issues/924', + labels: [], + updatedAt: '2026-04-01T00:00:00Z', + author: 'issue-author', + assignees: [{ login: 'ghe-assignee', name: 'GHE Assignee', avatarUrl: '' }] + }) + getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + issue: { + body: 'Issue body', + assignees: { + nodes: [ + { + login: 'ghe-assignee', + name: 'GHE Assignee', + avatarUrl: 'https://ghe.example.com/avatars/ghe-assignee' + } + ] + }, + participants: { nodes: [{ login: 'issue-author', avatarUrl: 'https://x/y' }] }, + comments: { nodes: [] } + } + } + } + }) + }) + .mockResolvedValueOnce({ stdout: '' }) + + const details = await getWorkItemDetails('/repo-root', 924, 'issue') + + expect(details?.item.assignees?.[0]).toMatchObject({ + login: 'ghe-assignee', + avatarUrl: 'https://ghe.example.com/avatars/ghe-assignee' + }) + }) + it('caps issue timeline pagination by supported activity items', async () => { getWorkItemMock.mockResolvedValueOnce({ id: 'issue:923', @@ -568,4 +618,66 @@ describe('getWorkItemDetails', () => { expect(details?.filesUnavailable).toBe(false) expect(details?.files).toEqual([]) }) + + // Why: `gh pr view` omits avatar_url, so the login-based github.com URL 404s on + // GHE. getWorkItemDetails must resolve author/reviewer/assignee avatars via the + // GraphQL user(login:) batch and stamp them onto the returned item. See #8784. + it('enriches PR author, reviewer, and assignee avatars from the GraphQL user lookup', async () => { + getWorkItemMock.mockResolvedValueOnce({ + id: 'pr:1102', + type: 'pr', + number: 1102, + title: 'Enterprise PR', + state: 'open', + url: 'https://ghe.example.com/acme/widgets/pull/1102', + labels: [], + updatedAt: '2026-07-11T00:00:00Z', + author: 'seah', + reviewRequests: [{ login: 'ludi', name: null, avatarUrl: '' }], + latestReviews: [{ login: 'inho', state: 'APPROVED', avatarUrl: '' }], + // A default `u/0` placeholder must be replaced by the resolved avatar. + assignees: [{ login: 'seah', name: 'Seah', avatarUrl: 'https://avatars.example.com/u/0?v=4' }] + }) + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getPRCommentsMock.mockResolvedValue([]) + getPRChecksMock.mockResolvedValue([]) + const avatars: Record = { + seah: 'https://avatars.example.com/u/1?v=4', + ludi: 'https://avatars.example.com/u/2?v=4', + inho: 'https://avatars.example.com/u/3?v=4' + } + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const target = args.at(-1) + if (target === 'repos/acme/widgets/pulls/1102') { + return { + stdout: JSON.stringify({ body: 'PR body', head: { sha: 'h' }, base: { sha: 'b' } }) + } + } + if (target === 'repos/acme/widgets/pulls/1102/files?per_page=100') { + return { stdout: '[]' } + } + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + if (query.includes('user(login:')) { + // Return the aliased users the batch asked for, keyed by their login. + const data: Record = {} + let index = 0 + for (const login of Object.keys(avatars)) { + if (query.includes(`user(login: "${login}")`)) { + data[`u${index}`] = { login, name: null, avatarUrl: avatars[login] } + index += 1 + } + } + return { stdout: JSON.stringify({ data }) } + } + return { stdout: JSON.stringify({ data: {} }) } + }) + + const details = await getWorkItemDetails('/repo-root', 1102, 'pr') + + expect(details?.item.authorAvatarUrl).toBe(avatars.seah) + expect(details?.item.reviewRequests?.[0]?.avatarUrl).toBe(avatars.ludi) + expect(details?.item.latestReviews?.[0]?.avatarUrl).toBe(avatars.inho) + // The default u/0 placeholder is overridden by the resolved avatar. + expect(details?.item.assignees?.[0]?.avatarUrl).toBe(avatars.seah) + }) }) diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index 9aa2de2b26b..bd126641fcb 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -85,7 +85,7 @@ const ISSUE_DETAILS_QUERY = `query($owner: String!, $repo: String!, $number: Int repository(owner: $owner, name: $repo) { issue(number: $number) { body - assignees(first: 50) { nodes { login } } + assignees(first: 50) { nodes { login avatarUrl(size: 48) ... on User { name } } } participants(first: 100) { nodes { login avatarUrl(size: 48) ... on User { name } } } @@ -111,7 +111,7 @@ type GraphQLIssueDetailsResponse = { repository?: { issue?: { body?: string | null - assignees?: { nodes?: { login?: string }[] } + assignees?: { nodes?: { login?: string; avatarUrl?: string; name?: string | null }[] } participants?: { nodes?: GitHubAssignableUser[] } comments?: { nodes?: { @@ -323,6 +323,12 @@ async function getIssueTimelineItems( } } +/** + * Fetch an issue's body, comments, assignees, participants, and timeline in a + * single GraphQL round-trip. Returns null on any partial error so the caller + * falls back to the strict REST path. Assignee/participant avatars are resolved + * here so GitHub Enterprise users don't render blank. + */ async function getIssueDetailsViaGraphQL( repoPath: string, issueNumber: number, @@ -332,6 +338,10 @@ async function getIssueDetailsViaGraphQL( body: string comments: PRComment[] assignees: string[] + // Avatar-bearing assignees, kept separate from the `assignees` login list so + // callers that only need logins are unaffected; used to enrich assignee + // avatars that `gh` leaves blank (notably GitHub Enterprise users). + assigneeUsers: GitHubAssignableUser[] participants: GitHubAssignableUser[] timelineItems: GitHubIssueTimelineItem[] } | null> { @@ -386,9 +396,16 @@ async function getIssueDetailsViaGraphQL( url: c.url ?? '', isBot: c.author?.__typename === 'Bot' })) - const assignees = (issue.assignees?.nodes ?? []) - .map((a) => a.login) - .filter((login): login is string => Boolean(login)) + const assigneeUsers: GitHubAssignableUser[] = (issue.assignees?.nodes ?? []) + .filter((a): a is { login: string; avatarUrl?: string; name?: string | null } => + Boolean(a.login) + ) + .map((a) => ({ + login: a.login, + name: a.name ?? null, + avatarUrl: a.avatarUrl ?? '' + })) + const assignees = assigneeUsers.map((a) => a.login) const participants: GitHubAssignableUser[] = (issue.participants?.nodes ?? []) .filter((u) => Boolean(u.login)) .map((u) => ({ @@ -401,6 +418,7 @@ async function getIssueDetailsViaGraphQL( body: issue.body ?? '', comments, assignees, + assigneeUsers, participants, timelineItems } @@ -877,6 +895,11 @@ async function getGitHubUsersByLogin( return [] } if (rateLimitGuard('graphql').blocked) { + // Why: surface the skip. Callers degrade silently to login-based avatar URLs + // (blank on GHE), so without this log "why are avatars missing" is untraceable. + console.warn( + `getGitHubUsersByLogin skipped: GraphQL rate-limit budget exhausted (${uniqueLogins.length} logins unresolved)` + ) return [] } const fields = uniqueLogins @@ -921,19 +944,100 @@ async function getGitHubUsersByLogin( } } +/** + * Stamp GraphQL-resolved avatars onto a work item's author, reviewers, review + * requests, latest reviews, and assignees. + * + * The work item's users come from `gh pr view`, which omits avatar_url. On + * GitHub Enterprise the login-based `github.com/{login}.png` URL 404s, so those + * avatars render blank while comment avatars (fetched via GraphQL) work. + * `knownUsers` (participants + the mention-author batch, which already includes + * the item's reviewers/assignees) carries the resolved avatars, so we apply + * them without any extra round-trip. See #8784. + */ +function enrichItemDisplayAvatars( + item: Omit, + knownUsers: GitHubAssignableUser[] +): Omit { + const avatarByLogin = new Map() + for (const user of knownUsers) { + if (user.login && user.avatarUrl) { + avatarByLogin.set(user.login.toLowerCase(), user.avatarUrl) + } + } + if (avatarByLogin.size === 0) { + return item + } + // Why: prefer the GraphQL-resolved avatar over whatever `gh` returned. `gh pr + // view` often yields an empty avatar or the default `u/0` placeholder for + // enterprise users; the resolved avatar is the authoritative one for that + // login. Fall back to the original only when the lookup found nothing. + const avatarFor = (login: string): string | undefined => avatarByLogin.get(login.toLowerCase()) + const resolvedAvatar = (login: string, existing?: string | null): string | undefined => + avatarFor(login) || existing || undefined + // Callers coalesce a missing result to the field's "no avatar" sentinel ('' + // for GitHubAssignableUser, null for GitHubPRReviewSummary); GitHubUserAvatar + // treats both as falsy and falls back to the login URL then initials. + const authorAvatarUrl = (item.author ? avatarFor(item.author) : undefined) || item.authorAvatarUrl + return { + ...item, + ...(authorAvatarUrl ? { authorAvatarUrl } : {}), + ...(item.reviewRequests + ? { + reviewRequests: item.reviewRequests.map((user) => ({ + ...user, + avatarUrl: resolvedAvatar(user.login, user.avatarUrl) ?? '' + })) + } + : {}), + ...(item.latestReviews + ? { + latestReviews: item.latestReviews.map((review) => ({ + ...review, + avatarUrl: resolvedAvatar(review.login, review.avatarUrl) ?? null + })) + } + : {}), + ...(item.assignees + ? { + assignees: item.assignees.map((user) => ({ + ...user, + avatarUrl: resolvedAvatar(user.login, user.avatarUrl) ?? '' + })) + } + : {}) + } +} + async function getMentionParticipants( repoPath: string, - item: Pick, + item: Pick< + GitHubWorkItem, + 'author' | 'number' | 'type' | 'reviewRequests' | 'latestReviews' | 'assignees' + >, comments: PRComment[], participants: GitHubAssignableUser[], connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const visibleLogins = [item.author ?? '', ...comments.map((comment) => comment.author)] - // Why: one aliased GraphQL query returns login/name/avatarUrl for every - // mentioned author in a single round-trip. The previous REST fan-out - // (/users/) returned the same fields but cost one rate-limit point - // per user. + // Why: resolve mention authors AND the item's reviewers/assignees in ONE + // aliased GraphQL round-trip. Folding the display users in here means the + // caller can reuse this result to stamp avatars (see enrichItemDisplayAvatars) + // without a second, separately rate-limited lookup — which is what made GHE + // reviewer/assignee avatars resolve intermittently. See #8784. + // Why: order the always-visible display users (author, reviewers, assignees) + // before comment authors so the 40-login cap in getGitHubUsersByLogin never + // drops a reviewer/assignee avatar on a PR with many commenters. On a PR with + // 40+ distinct participants the trailing comment authors are dropped from this + // batch and lose only their @mention-suggestion avatar; rendered comment + // avatars are unaffected (they carry their own avatarUrl from the comment). + const visibleLogins = [ + item.author ?? '', + ...(item.reviewRequests ?? []).map((user) => user.login), + ...(item.latestReviews ?? []).map((review) => review.login), + ...(item.assignees ?? []).map((user) => user.login), + ...comments.map((comment) => comment.author) + ] const graphQlUsers = await getGitHubUsersByLogin( repoPath, visibleLogins, @@ -1007,7 +1111,13 @@ export async function getWorkItemDetails( ) if (collapsed) { return { - item, + // Include assigneeUsers: a non-participating assignee is absent from + // the participants connection, so without them item.assignees keeps a + // blank/placeholder avatar (common for GitHub Enterprise users). + item: enrichItemDisplayAvatars(item, [ + ...collapsed.participants, + ...collapsed.assigneeUsers + ]), body: collapsed.body, comments: collapsed.comments, assignees: collapsed.assignees, @@ -1030,7 +1140,7 @@ export async function getWorkItemDetails( localGitOptions ) return { - item, + item: enrichItemDisplayAvatars(item, mentionParticipants), body, comments, assignees, @@ -1064,7 +1174,7 @@ export async function getWorkItemDetails( ]) return { - item, + item: enrichItemDisplayAvatars(item, mentionParticipants), body, comments, headSha: shas?.headSha, diff --git a/src/main/index.ts b/src/main/index.ts index efdd7ee9a6e..cebff02a96f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -163,7 +163,7 @@ import { } from './ipc/pty' import { AgentBrowserBridge } from './browser/agent-browser-bridge' import { EmulatorBridge } from './emulator/emulator-bridge' -import { browserManager } from './browser/browser-manager' +import { browserCertificateTrustController, browserManager } from './browser/browser-manager' import { OffscreenBrowserBackend } from './browser/offscreen-browser-backend' import { initializeBrowserSessionsForApp } from './browser/browser-session-startup' import { setUnreadDockBadgeCount } from './dock/unread-badge' @@ -1710,6 +1710,22 @@ function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: { app.whenReady().then(async () => { logStartupMilestone('app-ready') + // Why: certificate decisions must be installed before either desktop + // webviews or headless browser windows can issue their first TLS request. + app.on( + 'certificate-error', + (event, webContents, url, error, certificate, callback, isMainFrame) => { + browserCertificateTrustController.handleCertificateError({ + event, + webContents, + url, + error, + certificate, + callback, + isMainFrame + }) + } + ) electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId) app.setName(devInstanceIdentity.name) @@ -1919,6 +1935,9 @@ app.whenReady().then(async () => { isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {} }) runtime = runtimeService + browserManager.setBrowserGuestStateChangedListener((worktreeId) => { + runtimeService.notifyMobileSessionTabsChanged(worktreeId) + }) automations = new AutomationService(store, { claudeUsage, codexUsage, @@ -2178,7 +2197,15 @@ app.whenReady().then(async () => { enableWebSocket: true, ...(isE2E ? { wsPort: 0 } : {}), ...(devWsPort !== undefined ? { wsPort: devWsPort } : {}), - ...(serveOptions?.wsPort !== undefined ? { wsPort: serveOptions.wsPort } : {}), + ...(serveOptions?.wsPort !== undefined + ? { + wsPort: serveOptions.wsPort, + // Why: only an explicit `orca serve --port` pin prefers the requested + // port over a stale STA-1511 fallback (issue #8535). Default 6768 / + // dev 6769 keep fallback-first so mobile pairings stay stable. + preferPinnedWsPort: true + } + : {}), webClientRoot: getBundledWebClientRoot() }) registerMobileHandlers(runtimeRpc, { getRelayStatus: () => desktopRelayStatus }) @@ -2393,6 +2420,7 @@ app.on('will-quit', (e) => { // Why: headless offscreen browser windows are main-process owned; tear them // down explicitly on quit alongside the other browser/session shutdowns. runtime?.getOffscreenBrowserBackend()?.destroyAll?.() + browserManager.setBrowserGuestStateChangedListener(null) const emulatorShutdown = runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve() killAllPty() const watcherShutdown = shutdownWatchersOnce() diff --git a/src/main/ipc/browser.test.ts b/src/main/ipc/browser.test.ts index 042140cd38b..7651d130651 100644 --- a/src/main/ipc/browser.test.ts +++ b/src/main/ipc/browser.test.ts @@ -11,6 +11,7 @@ const { openDevToolsMock, setAnnotationViewportBridgeMock, cancelDownloadMock, + proceedCertificateMock, browserWindowFromWebContentsMock, webContentsFromIdMock } = vi.hoisted(() => ({ @@ -24,6 +25,7 @@ const { openDevToolsMock: vi.fn().mockResolvedValue(true), setAnnotationViewportBridgeMock: vi.fn().mockResolvedValue(true), cancelDownloadMock: vi.fn(), + proceedCertificateMock: vi.fn(), browserWindowFromWebContentsMock: vi.fn(), webContentsFromIdMock: vi.fn() })) @@ -42,6 +44,9 @@ vi.mock('electron', () => ({ })) vi.mock('../browser/browser-manager', () => ({ + browserCertificateTrustController: { + proceed: proceedCertificateMock + }, browserManager: { registerGuest: registerGuestMock, unregisterGuest: unregisterGuestMock, @@ -68,6 +73,7 @@ describe('registerBrowserHandlers', () => { removeHandlerMock.mockReset() handleMock.mockReset() registerGuestMock.mockReset() + registerGuestMock.mockReturnValue(true) unregisterGuestMock.mockReset() getGuestWebContentsIdMock.mockReset() getWebContentsIdByTabIdMock.mockReset() @@ -76,6 +82,8 @@ describe('registerBrowserHandlers', () => { openDevToolsMock.mockReset() setAnnotationViewportBridgeMock.mockReset() cancelDownloadMock.mockReset() + proceedCertificateMock.mockReset() + proceedCertificateMock.mockReturnValue({ ok: true }) browserWindowFromWebContentsMock.mockReset() webContentsFromIdMock.mockReset() webContentsFromIdMock.mockReturnValue({ isDestroyed: () => false }) @@ -110,6 +118,41 @@ describe('registerBrowserHandlers', () => { expect(registerGuestMock).not.toHaveBeenCalled() }) + it('does not resolve registration waiters when BrowserManager rejects the guest', async () => { + vi.useFakeTimers() + try { + registerGuestMock.mockReturnValue(false) + const settled = Promise.allSettled([waitForTabRegistration('page-1', 1000)]) + registerBrowserHandlers() + const registerHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:registerGuest' + )?.[1] as (event: { sender: Electron.WebContents }, args: object) => boolean + + const result = registerHandler( + { + sender: { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + }, + { + browserPageId: 'page-1', + workspaceId: 'workspace-1', + worktreeId: 'worktree-1', + webContentsId: 123 + } + ) + + expect(result).toBe(false) + await vi.advanceTimersByTimeAsync(1001) + expect(await settled).toEqual([{ status: 'rejected', reason: expect.any(Error) }]) + } finally { + vi.useRealTimers() + } + }) + it('authorizes browser download cancellation through the owning renderer', () => { cancelDownloadMock.mockReturnValue(true) registerBrowserHandlers() @@ -157,6 +200,60 @@ describe('registerBrowserHandlers', () => { expect(cancelDownloadMock).not.toHaveBeenCalled() }) + it('allows only the trusted renderer to approve an exact certificate challenge', () => { + registerBrowserHandlers() + const proceedHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:proceedCertificate' + )?.[1] as (event: { sender: Electron.WebContents }, args: unknown) => unknown + const trustedSender = { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + + expect( + proceedHandler( + { sender: trustedSender }, + { browserPageId: 'page-1', challengeId: 'challenge-1' } + ) + ).toEqual({ ok: true }) + expect(proceedCertificateMock).toHaveBeenCalledWith('page-1', 'challenge-1') + + proceedCertificateMock.mockClear() + const untrustedSender = { + id: 92, + isDestroyed: () => false, + getType: () => 'webview', + getURL: () => 'https://localhost:3443/' + } as Electron.WebContents + expect( + proceedHandler( + { sender: untrustedSender }, + { browserPageId: 'page-1', challengeId: 'challenge-1' } + ) + ).toEqual({ ok: false, reason: 'missing' }) + expect(proceedCertificateMock).not.toHaveBeenCalled() + }) + + it('rejects malformed certificate approval IPC arguments', () => { + registerBrowserHandlers() + const proceedHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:proceedCertificate' + )?.[1] as (event: { sender: Electron.WebContents }, args: unknown) => unknown + const sender = { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + + for (const args of [null, {}, { browserPageId: 1, challengeId: 'challenge-1' }]) { + expect(proceedHandler({ sender }, args)).toEqual({ ok: false, reason: 'missing' }) + } + expect(proceedCertificateMock).not.toHaveBeenCalled() + }) + it('updates the bridge active tab for the owning worktree', async () => { const onTabChangedMock = vi.fn() getGuestWebContentsIdMock.mockReturnValue(4242) diff --git a/src/main/ipc/browser.ts b/src/main/ipc/browser.ts index 1bc405c9d0a..f5c1c8fb268 100644 --- a/src/main/ipc/browser.ts +++ b/src/main/ipc/browser.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: browser IPC handlers must be registered together so the trust boundary (isTrustedBrowserRenderer) and handler teardown stay consistent. */ import { BrowserWindow, ipcMain, webContents } from 'electron' -import { browserManager } from '../browser/browser-manager' +import { browserCertificateTrustController, browserManager } from '../browser/browser-manager' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import { browserSessionRegistry } from '../browser/browser-session-registry' import { @@ -24,6 +24,7 @@ import type { } from '../../shared/browser-grab-types' import type { BrowserCookieImportResult, + BrowserCertificateProceedResult, BrowserSessionProfile, BrowserSessionProfileScope, BrowserViewportOverride @@ -180,6 +181,7 @@ export function registerBrowserHandlers(): void { ipcMain.removeHandler('browser:captureSelectionScreenshot') ipcMain.removeHandler('browser:extractHoverPayload') ipcMain.removeHandler('browser:activeTabChanged') + ipcMain.removeHandler('browser:proceedCertificate') ipcMain.handle( 'browser:registerGuest', @@ -201,10 +203,13 @@ export function registerBrowserHandlers(): void { // with a new webContentsId. The bridge must destroy the old session's // proxy (its webContents is gone) and let the next command recreate it. const previousWcId = browserManager.getGuestWebContentsId(args.browserPageId) - browserManager.registerGuest({ + const registered = browserManager.registerGuest({ ...args, rendererWebContentsId: event.sender.id }) + if (!registered) { + return false + } if (agentBrowserBridgeRef && previousWcId !== null && previousWcId !== args.webContentsId) { agentBrowserBridgeRef.onProcessSwap(args.browserPageId, args.webContentsId, previousWcId) } @@ -235,6 +240,23 @@ export function registerBrowserHandlers(): void { return true }) + ipcMain.handle( + 'browser:proceedCertificate', + ( + event, + args: { browserPageId?: unknown; challengeId?: unknown } + ): BrowserCertificateProceedResult => { + if ( + !isTrustedBrowserRenderer(event.sender) || + typeof args?.browserPageId !== 'string' || + typeof args.challengeId !== 'string' + ) { + return { ok: false, reason: 'missing' } + } + return browserCertificateTrustController.proceed(args.browserPageId, args.challengeId) + } + ) + // Why: keeps the bridge's active tab in sync with the renderer's UI state. // Without this, a user switching tabs in the UI would leave the agent operating // on the previous tab, which is confusing. diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 4f08581282b..64047cb1c46 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -6497,7 +6497,7 @@ describe('registerPtyHandlers', () => { expect(spawnMock).toHaveBeenCalledWith( 'C:\\Program Files\\Git\\bin\\bash.exe', - ['--login', '-i'], + ['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'], expect.objectContaining({ env: expect.objectContaining({ CHERE_INVOKING: '1' }) }) diff --git a/src/main/ipc/worktree-git-common-watch.test.ts b/src/main/ipc/worktree-git-common-watch.test.ts new file mode 100644 index 00000000000..9cc70f1d7e0 --- /dev/null +++ b/src/main/ipc/worktree-git-common-watch.test.ts @@ -0,0 +1,189 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, mkdir, realpath, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { subscribeViaWatcherProcess } from './parcel-watcher-process' +import type { + WatcherProcessCallback, + WatcherProcessHooks +} from './parcel-watcher-process-subscription' +import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' +import type { WorktreeBasePollEvent } from './worktree-base-directory-poller' +import { startGitCommonWatch } from './worktree-git-common-watch' + +vi.mock('./parcel-watcher-process', () => ({ + subscribeViaWatcherProcess: vi.fn() +})) + +const POLL_MS = 25 + +type ChildSubscription = { + dir: string + callback: WatcherProcessCallback + hooks: WatcherProcessHooks + unsubscribe: ReturnType +} + +describe('worktree git-common narrow watch (darwin)', () => { + const cleanups: (() => Promise)[] = [] + const subscribeMock = vi.mocked(subscribeViaWatcherProcess) + let childSubscriptions: ChildSubscription[] = [] + + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) + childSubscriptions = [] + subscribeMock.mockReset() + }) + + function installSubscribeMock(): void { + subscribeMock.mockImplementation(async (dir, callback, _opts, hooks = {}) => { + const unsubscribe = vi.fn(async () => {}) + childSubscriptions.push({ dir, callback, hooks, unsubscribe }) + return { unsubscribe } + }) + } + + async function makeCommonDir(withWorktrees: boolean): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-git-common-watch-')) + cleanups.push(() => rm(root, { recursive: true, force: true })) + const commonDir = await realpath(root) + if (withWorktrees) { + await mkdir(join(commonDir, 'worktrees')) + } + return commonDir + } + + function makeTarget(path: string): WorktreeBaseWatchTarget { + return { + key: `git-common:local:${path}`, + kind: 'git-common', + path, + repos: new Map([['repo-1', { repoId: 'repo-1', repoName: 'project', nestWorkspaces: false }]]) + } + } + + async function startWatch(commonDir: string, received: WorktreeBasePollEvent[][]): Promise { + const watch = await startGitCommonWatch( + makeTarget(commonDir), + (events) => received.push(events), + POLL_MS, + 'darwin' + ) + cleanups.push(() => watch.unsubscribe()) + } + + it('hosts the narrow stream in the watcher child, not in-process', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + + expect(subscribeMock).toHaveBeenCalledTimes(1) + const [dir, , opts] = subscribeMock.mock.calls[0] + expect(dir).toBe(join(commonDir, 'worktrees')) + expect(opts).toEqual({}) + + const entryPath = join(commonDir, 'worktrees', 'wt-a') + childSubscriptions[0].callback(null, [{ type: 'create', path: entryPath }]) + expect(received.flat()).toContainEqual({ type: 'create', path: entryPath }) + }) + + it('tears down and re-arms when the watched root is deleted', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const worktreesDir = join(commonDir, 'worktrees') + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + + await rm(worktreesDir, { recursive: true, force: true }) + childSubscriptions[0].callback(null, [{ type: 'delete', path: worktreesDir }]) + await vi.waitFor(() => { + expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1) + }) + expect(received.flat()).toContainEqual({ type: 'delete', path: worktreesDir }) + + // The existence poll re-subscribes once a new first worktree recreates it. + await mkdir(worktreesDir) + await vi.waitFor(() => { + expect(subscribeMock).toHaveBeenCalledTimes(2) + }) + expect(received.flat()).toContainEqual({ type: 'create', path: worktreesDir }) + }) + + it('tears down and re-arms on watcher errors', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const worktreesDir = join(commonDir, 'worktrees') + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + + childSubscriptions[0].callback(new Error('watcher child reported failure'), []) + await vi.waitFor(() => { + expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1) + }) + // The error is surfaced as a structural change so worktrees re-sync. + expect(received.flat()).toContainEqual({ type: 'update', path: worktreesDir }) + + // The dir still exists, so the existence poll re-subscribes. + await vi.waitFor(() => { + expect(subscribeMock).toHaveBeenCalledTimes(2) + }) + + const receivedAfterRearm = received.length + childSubscriptions[0].callback(new Error('late error from replaced watcher'), []) + childSubscriptions[0].callback(null, [ + { type: 'create', path: join(worktreesDir, 'late-old-event') } + ]) + childSubscriptions[0].hooks.onInterruption?.() + + // A replaced watch cannot tear down its successor or report stale events. + expect(received).toHaveLength(receivedAfterRearm) + expect(childSubscriptions[1].unsubscribe).not.toHaveBeenCalled() + }) + + it('reports a structural change after a watcher-child interruption', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const worktreesDir = join(commonDir, 'worktrees') + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + + childSubscriptions[0].hooks.onInterruption?.() + expect(received.flat()).toContainEqual({ type: 'update', path: worktreesDir }) + // The supervisor resubscribed the same record; no teardown should happen. + expect(childSubscriptions[0].unsubscribe).not.toHaveBeenCalled() + }) + + it('arms via existence polling when the worktrees dir appears later', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(false) + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + expect(subscribeMock).not.toHaveBeenCalled() + + await mkdir(join(commonDir, 'worktrees')) + await vi.waitFor(() => { + expect(subscribeMock).toHaveBeenCalledTimes(1) + }) + }) + + it('stops forwarding events and unsubscribes the child on dispose', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const received: WorktreeBasePollEvent[][] = [] + const watch = await startGitCommonWatch( + makeTarget(commonDir), + (events) => received.push(events), + POLL_MS, + 'darwin' + ) + await watch.unsubscribe() + expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1) + + received.length = 0 + childSubscriptions[0].callback(null, [ + { type: 'create', path: join(commonDir, 'worktrees', 'late') } + ]) + expect(received).toHaveLength(0) + }) +}) diff --git a/src/main/ipc/worktree-git-common-watch.ts b/src/main/ipc/worktree-git-common-watch.ts index 39899b1e149..705963ed726 100644 --- a/src/main/ipc/worktree-git-common-watch.ts +++ b/src/main/ipc/worktree-git-common-watch.ts @@ -1,5 +1,6 @@ import { stat } from 'node:fs/promises' import { join } from 'node:path' +import { subscribeViaWatcherProcess } from './parcel-watcher-process' import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' import type { WorktreeBasePollEvent, @@ -20,6 +21,10 @@ import { // included). Other platforms: dir-listing poll (no fseventsd to protect, and // on Windows an open directory handle on `worktrees/` could interfere with // `git worktree prune` removing it). +// The native stream is hosted in the crash-isolated watcher child, never the +// Electron main process: watcher.node teardown races heap-corrupt the hosting +// process when unsubscribe overlaps in-flight callbacks (issue #8732), and +// root deletion via `git worktree prune` makes that overlap routine here. // Why: branch switches and commits made in the primary checkout rewrite these // top-level files (linked-worktree equivalents live under `worktrees/`). @@ -157,12 +162,14 @@ async function startGitCommonNarrowWatch( return false } let errored = false + let active = true // Why: parcel tears its native stream down when the watched root is // deleted (e.g. `git worktree prune` removing an empty worktrees dir) — // sometimes surfaced as an error, sometimes as a delete event for the // root. Either way: notify, drop the dead stream, and let the existence // poll re-arm when a future worktree add recreates the dir. const teardownAndRearm = (): void => { + active = false errored = true const current = subscription subscription = null @@ -172,26 +179,38 @@ async function startGitCommonNarrowWatch( armExistencePoll() } try { - const watcher = await import('@parcel/watcher') - const sub = await watcher.subscribe(worktreesDir, (error, events) => { - if (disposed) { - return - } - if (error) { - onEvents([{ type: 'update', path: worktreesDir }]) - teardownAndRearm() - return - } - if (events.length > 0) { - const rootGone = events.some( - (event) => event.type === 'delete' && event.path === worktreesDir - ) - onEvents(events.map((event) => ({ type: event.type, path: event.path }))) - if (rootGone) { + const sub = await subscribeViaWatcherProcess( + worktreesDir, + (error, events) => { + if (disposed || !active) { + return + } + if (error) { + onEvents([{ type: 'update', path: worktreesDir }]) teardownAndRearm() + return + } + if (events.length > 0) { + const rootGone = events.some( + (event) => event.type === 'delete' && event.path === worktreesDir + ) + onEvents(events.map((event) => ({ type: event.type, path: event.path }))) + if (rootGone) { + teardownAndRearm() + } + } + }, + {}, + { + // Why: a watcher-child crash drops events during the automatic + // resubscribe gap; report a structural change so worktrees re-sync. + onInterruption: () => { + if (!disposed && active) { + onEvents([{ type: 'update', path: worktreesDir }]) + } } } - }) + ) if (disposed || errored) { void sub.unsubscribe().catch(() => {}) return !errored diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index acde5dd02c3..39771208b1d 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -690,6 +690,31 @@ describe('Store', () => { expect(store.getSettings().minimizeToTrayOnClose).toBe(false) }) + it('persists native chat session options with per-model isolation', async () => { + const store = await createStore() + store.updateSettings({ + nativeChatSessionOptions: { + claude: { + model: 'opus', + valuesByModel: { + opus: { effort: 'xhigh', fastMode: true }, + sonnet: { effort: 'medium' } + } + } + } + }) + store.flush() + + const reloaded = await createStore() + expect(reloaded.getSettings().nativeChatSessionOptions?.claude).toEqual({ + model: 'opus', + valuesByModel: { + opus: { effort: 'xhigh', fastMode: true }, + sonnet: { effort: 'medium' } + } + }) + }) + it('coerces non-boolean minimizeToTrayOnClose payloads to a strict boolean', async () => { const store = await createStore() // Why: a renderer-supplied non-bool must never persist as a truthy non-bool @@ -2483,7 +2508,7 @@ describe('Store', () => { }) expect(persisted.settings.sourceControlAi.actions.branchName).toEqual({ agentId: 'claude', - commandInputTemplate: '{basePrompt}\n\nRollback commit prompt' + commandInputTemplate: 'Rollback commit prompt\n\n{basePrompt}' }) }) diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index f69505161f2..54f8c2a110c 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -936,7 +936,7 @@ describe('LocalPtyProvider', () => { expect(spawnMock).toHaveBeenCalledWith( 'C:\\Program Files\\Git\\bin\\bash.exe', - ['--login', '-i'], + ['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'], expect.objectContaining({ cwd: 'C:\\Users\\jin\\repo', env: expect.objectContaining({ diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 41e9b3b9488..273763f7017 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -196,14 +196,27 @@ describe('resolveWindowsShellLaunchArgs', () => { ]) }) - it('starts Git Bash as an interactive login shell without changing cwd', () => { + it('starts Git Bash as an interactive login shell with UTF-8 console setup', () => { const result = resolveWindowsShellLaunchArgs( 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Users\\alice\\code', 'C:\\Users\\alice' ) - expect(result.shellArgs).toEqual(['--login', '-i']) + // Why: Git Bash inherits the ConPTY OEM code page (CP437), so a byte-writing + // UTF-8 TUI mojibakes unless the console is switched to UTF-8 before it runs. + // Assert the contract behaviorally: chcp.com 65001 must precede the exec'd + // interactive login shell (which preserves `--login -i` and the shell's + // foreground identity), rather than pinning the exact command string. + expect(result.shellArgs[0]).toBe('-c') + const bashCommand = result.shellArgs[1] + expect(bashCommand).toContain('chcp.com 65001') + expect(bashCommand).toMatch(/exec "\$BASH" --login -i$/) + // The UTF-8 switch must run before the login shell is exec'd. + expect(bashCommand.indexOf('chcp.com')).toBeLessThan(bashCommand.indexOf('exec')) + // Must stay fail-open: `;` (not `&&`) so a missing chcp.com can't abort the + // exec and kill the terminal on startup. + expect(bashCommand).not.toContain('&&') expect(result.effectiveCwd).toBe('C:\\Users\\alice\\code') expect(result.validationCwd).toBe('C:\\Users\\alice\\code') }) diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 4977cc11158..523aee72a6a 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -16,6 +16,13 @@ const CMD_EXE_COMMAND_LINE_MAX_CHARS = 8191 const STARTUP_COMMAND_TEXT_MAX_CHARS = 6000 const POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS = 28_000 const CMD_UTF8_SETUP_COMMAND = 'chcp 65001 > nul' +// Why: Git for Windows' bash inherits the ConPTY console's OEM code page +// (CP437), so a TUI that writes UTF-8 bytes straight to the console — agents +// like Claude Code use WriteFile, not WriteConsoleW — renders as mojibake +// (`❯` -> `Γ¥»`). Switch the console to UTF-8, then exec the normal interactive +// login shell; cmd.exe and PowerShell already do the equivalent. The `;` (not +// `&&`) keeps startup working even if chcp.com is missing. +const GIT_BASH_UTF8_LOGIN_COMMAND = 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i' /** Result of resolving a Windows shell to its launch args + effective cwd. * @@ -176,7 +183,7 @@ export function resolveWindowsShellLaunchArgs( if (isWindowsGitBashShellPath(shellPath)) { return { - shellArgs: ['--login', '-i'], + shellArgs: ['-c', GIT_BASH_UTF8_LOGIN_COMMAND], effectiveCwd: nativeCwd, validationCwd: nativeCwd } diff --git a/src/main/runtime/orca-runtime-browser.ts b/src/main/runtime/orca-runtime-browser.ts index 053f614663c..c7da17da95d 100644 --- a/src/main/runtime/orca-runtime-browser.ts +++ b/src/main/runtime/orca-runtime-browser.ts @@ -49,9 +49,10 @@ import type { BrowserViewportResult, BrowserWaitResult } from '../../shared/runtime-types' +import type { BrowserCertificateProceedResult } from '../../shared/types' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import type { BrowserBackend } from '../browser/browser-backend' -import { browserManager } from '../browser/browser-manager' +import { browserCertificateTrustController, browserManager } from '../browser/browser-manager' import { BrowserError } from '../browser/cdp-bridge' import { startBrowserScreencast, @@ -633,6 +634,16 @@ export class RuntimeBrowserCommands { } } + async browserProceedCertificate( + params: { challengeId: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + if (!target.browserPageId) { + return { ok: false, reason: 'missing' } + } + return browserCertificateTrustController.proceed(target.browserPageId, params.challengeId) + } + async browserTabShow(params: { page: string; worktree?: string }): Promise { const target = await this.resolveBrowserCommandTarget(params) return { tab: this.describeBrowserTab(params.page, target.worktreeId) } diff --git a/src/main/runtime/orca-runtime-files.test.ts b/src/main/runtime/orca-runtime-files.test.ts index e38d0adf0d0..562dbb5602b 100644 --- a/src/main/runtime/orca-runtime-files.test.ts +++ b/src/main/runtime/orca-runtime-files.test.ts @@ -252,6 +252,8 @@ describe('RuntimeFileCommands', () => { it('opens text files through the renderer host (inheriting active runtime env)', async () => { const openFile = vi.fn() const { commands } = createRuntimeFileCommands({ openFile }) + resolveAuthorizedPathMock.mockResolvedValue('/repo/docs/readme.md') + statMock.mockResolvedValue({ isDirectory: () => false }) const result = await commands.openMobileFile('id:wt-1', 'docs/readme.md') @@ -272,6 +274,8 @@ describe('RuntimeFileCommands', () => { it('opens previewable images through the renderer host as an image tab', async () => { const openFile = vi.fn() const { commands } = createRuntimeFileCommands({ openFile }) + resolveAuthorizedPathMock.mockResolvedValue('/repo/assets/logo.png') + statMock.mockResolvedValue({ isDirectory: () => false }) const result = await commands.openMobileFile('id:wt-1', 'assets/logo.png') @@ -296,6 +300,7 @@ describe('RuntimeFileCommands', () => { const result = await commands.openMobileFile('id:wt-1', 'dist/bundle.zip') expect(openFile).not.toHaveBeenCalled() + expect(statMock).not.toHaveBeenCalled() expect(result).toEqual({ worktree: 'wt-1', relativePath: 'dist/bundle.zip', @@ -304,6 +309,43 @@ describe('RuntimeFileCommands', () => { }) }) + it('rejects missing local files without creating an editor tab', async () => { + const openFile = vi.fn() + const { commands } = createRuntimeFileCommands({ openFile }) + resolveAuthorizedPathMock.mockResolvedValue('/repo/docs/missing.md') + statMock.mockRejectedValue(enoent()) + + await expect(commands.openMobileFile('id:wt-1', 'docs/missing.md')).rejects.toThrow( + "ENOENT: no such file or directory, open '/repo/docs/missing.md'" + ) + expect(openFile).not.toHaveBeenCalled() + }) + + it('rejects missing remote files without creating an editor tab', async () => { + const openFile = vi.fn() + const resolveRuntimeFileTarget = vi.fn(async () => ({ + worktree: { + id: 'wt-1', + repoId: 'repo-1', + path: '/remote/repo' + }, + connectionId: 'ssh-1' + })) + const { commands } = createRuntimeFileCommands({ + openFile, + path: '/remote/repo', + resolveRuntimeFileTarget + }) + vi.mocked(getSshFilesystemProvider).mockReturnValue({ + stat: vi.fn().mockRejectedValue(new Error('ENOENT: no such file or directory')) + } as never) + + await expect(commands.openMobileFile('id:wt-1', 'docs/missing.md')).rejects.toThrow( + "ENOENT: no such file or directory, open '/remote/repo/docs/missing.md'" + ) + expect(openFile).not.toHaveBeenCalled() + }) + it('does not follow symlinks when reading runtime-local file explorer dirs', async () => { const { commands } = createRuntimeFileCommands() resolveAuthorizedPathMock.mockResolvedValue('/repo') diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 5af3d0d22a3..822006701fd 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -498,7 +498,7 @@ export class RuntimeFileCommands { worktreeSelector: string, relativePath: string ): Promise { - const { worktree } = await this.host.resolveRuntimeFileTarget(worktreeSelector) + const { worktree, connectionId } = await this.host.resolveRuntimeFileTarget(worktreeSelector) if (!isSafeMobileRelativePath(relativePath)) { throw new Error('invalid_relative_path') } @@ -515,6 +515,9 @@ export class RuntimeFileCommands { return { worktree: worktree.id, relativePath, kind, opened: false } } const filePath = joinWorktreeRelativePath(worktree.path, relativePath) + // Why: CLI/agents treat opened:true as success. Stat first so missing paths + // fail the RPC instead of creating a ghost editor tab that only errors on read. + await this.assertMobileOpenTargetExists(filePath, connectionId) // Why: the service's internal runtimeId is not a registered runtime env selector // (those live in orca-environments.json). Passing it caused Unknown environment // errors on content load for CLI-initiated opens (via files.open from orca cli @@ -525,6 +528,25 @@ export class RuntimeFileCommands { return { worktree: worktree.id, relativePath, kind, opened: true } } + private async assertMobileOpenTargetExists( + filePath: string, + connectionId?: string + ): Promise { + try { + await (connectionId + ? this.statRemoteTerminalPath(filePath, connectionId) + : stat(await resolveAuthorizedPath(filePath, this.host.requireStore()))) + } catch (error) { + if ( + isENOENT(error) || + (connectionId && RuntimeFileCommands.isRemoteNotFoundErrorMessage(error)) + ) { + throw new Error(`ENOENT: no such file or directory, open '${filePath}'`) + } + throw error + } + } + async openMobileDiff( worktreeSelector: string, relativePath: string, diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 15a59321409..055d1c2ac48 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1538,6 +1538,123 @@ describe('OrcaRuntimeService', () => { expect(capabilities).toContain('browser.screencast.v1') // ...and the headless marker tells clients not to fall back to a local tab. expect(capabilities).toContain('browser.headless.v1') + expect(capabilities).toContain('browser.certificate-trust.v1') + }) + + it('surfaces live offscreen load failures in headless browser snapshots', () => { + const runtime = createRuntime() + runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() }) + runtime.setAgentBrowserBridge({ + tabList: vi.fn(() => ({ + tabs: [ + { + browserPageId: 'page-certificate-error', + index: 0, + url: 'https://localhost:3443/', + title: 'Local HTTPS', + active: true, + loadError: { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/' + }, + certificateFailure: { + challengeId: 'challenge-1', + browserPageId: 'page-certificate-error', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + } + } + ] + })) + } as never) + const browserTabs = runtime['buildHeadlessMobileSessionBrowserTabs'](TEST_WORKTREE_ID) + expect(browserTabs).toContainEqual( + expect.objectContaining({ + type: 'browser', + browserPageId: 'page-certificate-error', + loadError: { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/' + }, + certificateFailure: { + challengeId: 'challenge-1', + browserPageId: 'page-certificate-error', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + } + }) + ) + }) + + it('detects headless browser-tab changes by field, treating absent and null loadError alike', () => { + const runtime = createRuntime() + const base = { + type: 'browser' as const, + id: 'page-1', + title: 'Local', + browserWorkspaceId: 'page-1', + browserPageId: 'page-1', + url: 'https://localhost:3443/', + loading: false, + canGoBack: false, + canGoForward: false, + isActive: true + } + const err = { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/' + } + const certificateFailure = { + challengeId: 'challenge-1', + browserPageId: 'page-1', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + } + const unchanged = (a: unknown[], b: unknown[]): boolean => + runtime['headlessBrowserTabsUnchanged'](a as never, b as never) + + // Absent vs explicit null loadError are equivalent (the JSON.stringify trap). + expect(unchanged([{ ...base }], [{ ...base, loadError: null }])).toBe(true) + expect(unchanged([{ ...base, loadError: err }], [{ ...base, loadError: { ...err } }])).toBe( + true + ) + // A load-error-only change (identical ids/order) must not be missed. + expect(unchanged([{ ...base }], [{ ...base, loadError: err }])).toBe(false) + expect( + unchanged([{ ...base, loadError: err }], [{ ...base, loadError: { ...err, code: -200 } }]) + ).toBe(false) + expect( + unchanged( + [{ ...base, certificateFailure }], + [{ ...base, certificateFailure: { ...certificateFailure } }] + ) + ).toBe(true) + expect(unchanged([{ ...base }], [{ ...base, certificateFailure }])).toBe(false) + expect( + unchanged( + [{ ...base, certificateFailure }], + [{ ...base, certificateFailure: { ...certificateFailure, challengeId: 'challenge-2' } }] + ) + ).toBe(false) + // Scalar and length changes are detected. + expect(unchanged([{ ...base }], [{ ...base, title: 'Changed' }])).toBe(false) + expect(unchanged([{ ...base }], [{ ...base, isActive: false }])).toBe(false) + expect(unchanged([{ ...base }], [{ ...base }, { ...base, id: 'page-2' }])).toBe(false) }) it('does not advertise headless browser capability when a renderer window exists', () => { @@ -1547,6 +1664,16 @@ describe('OrcaRuntimeService', () => { runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() }) expect(runtime.getStatus().capabilities).not.toContain('browser.headless.v1') + // Desktop webviews still host certificate trust, so the proceed capability + // remains advertised for remote clients controlling those pages. + expect(runtime.getStatus().capabilities).toContain('browser.certificate-trust.v1') + }) + + it('does not advertise certificate trust when no browser backend is available', () => { + const runtime = createRuntime() + + expect(runtime.getStatus().capabilities).not.toContain('browser.certificate-trust.v1') + expect(runtime.getStatus().capabilities).not.toContain('browser.screencast.v1') }) it('closes a worktree’s offscreen browser pages when its metadata is removed (leak fix)', () => { @@ -19722,6 +19849,134 @@ describe('OrcaRuntimeService', () => { expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined() }) + it('durably tears down a runtime-owned SSH headless tab when renderer cleanup fails', async () => { + // #8958 regression: the acknowledged renderer relay cannot see headless tabs; + // its advisory fallback must not prevent authoritative teardown or flushing. + const persistedPtyId = 'ssh:ssh-1@@relay-pty' + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: persistedPtyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Remote Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: persistedPtyId }) + } + }) + ) + const kill = vi.fn(() => true) + const flushOrThrow = vi.fn() + const rendererError = new Error('renderer unavailable') + const closeTerminal = vi.fn(() => { + throw rendererError + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const closeTerminalTab = vi.fn(async () => {}) + const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never) + runtime.setPtyController({ + write: () => true, + kill, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: persistedPtyId, cwd: TEST_WORKTREE_PATH, title: 'Remote' }] + }) + runtime.setNotifier({ closeTerminal, closeTerminalTab } as never) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab') + + expect(closeTerminalTab).not.toHaveBeenCalled() + expect(kill).toHaveBeenCalledWith(persistedPtyId) + expect(closeTerminal).toHaveBeenCalledWith('host-tab') + expect(flushOrThrow).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith( + '[runtime] failed to notify renderer after headless terminal close', + { parentTabId: 'host-tab', error: rendererError } + ) + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) + expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined() + }) + + it('keeps the renderer close transaction for an adopted runtime-owned tab', async () => { + // The renderer pin state can be newer than the debounced workspace session. + // Once adopted, its live close guard must win over stale persisted metadata. + const servePtyId = 'serve-adopted-1' + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: servePtyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Adopted Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + isPinned: false + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: servePtyId }) + } + }) + ) + const kill = vi.fn(() => true) + const closeTerminal = vi.fn() + const closeTerminalTab = vi.fn(async () => { + throw new Error('terminal_tab_pinned') + }) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + write: () => true, + kill, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: servePtyId, cwd: TEST_WORKTREE_PATH, title: 'Adopted' }] + }) + runtime.setNotifier({ closeTerminal, closeTerminalTab } as never) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Adopted Terminal', + activeLeafId: HEADLESS_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_LEAF_ID, + paneRuntimeId: 1, + ptyId: servePtyId + } + ] + }) + + await expect( + runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab') + ).rejects.toThrow('terminal_tab_pinned') + + expect(closeTerminalTab).toHaveBeenCalledWith('host-tab') + expect(closeTerminal).not.toHaveBeenCalled() + expect(kill).not.toHaveBeenCalled() + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1) + expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined() + }) + it('materializes hydrated pending headless terminals with the persisted session identity', async () => { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 40bfc0e5691..6e1bce48a5a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -264,6 +264,7 @@ import { } from '../../shared/worktree-ownership' import { BROWSER_HEADLESS_RUNTIME_CAPABILITY, + BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, @@ -3061,6 +3062,12 @@ export class OrcaRuntimeService { if (hasOffscreen) { capabilities.push(BROWSER_HEADLESS_RUNTIME_CAPABILITY) } + // Why: certificate proceed is owned by the browser-hosting process for both + // desktop webviews and offscreen pages. Advertise whenever either backend + // can host a page so remote clients can surface Proceed Anyway (Unsafe). + if (canBrowse) { + capabilities.push(BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY) + } return { runtimeId: this.runtimeId, rendererGraphEpoch: this.rendererGraphEpoch, @@ -3430,13 +3437,16 @@ export class OrcaRuntimeService { allowAttachedWindow?: boolean onlyServeOwnedTerminals?: boolean } = {} - ): void { + ): Set { + // Why: report which worktrees were reconciled in place so callers don't + // reconcile them a second time (see notifyMobileSessionTabsChanged). + const reconciledWorktreeIds = new Set() if (this.getAvailableAuthoritativeWindow() && options.allowAttachedWindow !== true) { - return + return reconciledWorktreeIds } const session = this.store?.getWorkspaceSession?.() if (!session) { - return + return reconciledWorktreeIds } const entries = worktreeId !== undefined @@ -3455,6 +3465,7 @@ export class OrcaRuntimeService { // Reconcile just the browser tabs against the live bridge instead of // leaving a stale snapshot that omits a freshly-opened browser tab. this.reconcileHeadlessMobileSessionBrowserTabs(entryWorktreeId, existing) + reconciledWorktreeIds.add(entryWorktreeId) continue } const terminalTabs = this.buildHeadlessMobileSessionTerminalTabs( @@ -3554,6 +3565,7 @@ export class OrcaRuntimeService { tabs: mergedTabs }) } + return reconciledWorktreeIds } // Why: keep an existing snapshot's browser tabs in sync with the live bridge @@ -3568,13 +3580,11 @@ export class OrcaRuntimeService { } const liveBrowserTabs = this.buildHeadlessMobileSessionBrowserTabs(worktreeId) const liveIds = liveBrowserTabs.map((tab) => tab.id) - const existingBrowserIds = existing.tabs - .filter((tab): tab is RuntimeMobileSessionBrowserTab => tab.type === 'browser') - .map((tab) => tab.id) - const unchanged = - liveIds.length === existingBrowserIds.length && - liveIds.every((id, index) => existingBrowserIds[index] === id) - if (unchanged) { + const existingBrowserTabs = existing.tabs.filter( + (tab): tab is RuntimeMobileSessionBrowserTab => tab.type === 'browser' + ) + const existingBrowserIds = existingBrowserTabs.map((tab) => tab.id) + if (this.headlessBrowserTabsUnchanged(liveBrowserTabs, existingBrowserTabs)) { return } const nonBrowserTabs = existing.tabs.filter((tab) => tab.type !== 'browser') @@ -4051,6 +4061,8 @@ export class OrcaRuntimeService { loading: false, canGoBack: false, canGoForward: false, + loadError: tab.loadError ?? undefined, + certificateFailure: tab.certificateFailure ?? undefined, ...(persistedProps ? { color: persistedProps.color } : {}), ...(persistedProps ? { isPinned: persistedProps.isPinned === true } : {}), isActive: tab.active === true @@ -4058,6 +4070,75 @@ export class OrcaRuntimeService { }) } + // Why: change detection for headless browser tabs. Compares the fields that + // actually vary (a JSON.stringify equality was order-sensitive and silently + // dropped `undefined` keys, so it only worked while both sides shared one + // construction path). + private headlessBrowserTabsUnchanged( + live: RuntimeMobileSessionBrowserTab[], + existing: RuntimeMobileSessionBrowserTab[] + ): boolean { + if (live.length !== existing.length) { + return false + } + return live.every((tab, index) => { + const prev = existing[index] + return ( + tab.id === prev.id && + tab.title === prev.title && + tab.url === prev.url && + tab.isActive === prev.isActive && + (tab.isPinned ?? false) === (prev.isPinned ?? false) && + (tab.color ?? null) === (prev.color ?? null) && + this.browserLoadErrorsEqual(tab.loadError, prev.loadError) && + this.browserCertificateFailuresEqual(tab.certificateFailure, prev.certificateFailure) + ) + }) + } + + private browserLoadErrorsEqual( + a: RuntimeMobileSessionBrowserTab['loadError'], + b: RuntimeMobileSessionBrowserTab['loadError'] + ): boolean { + const left = a ?? null + const right = b ?? null + if (left === right) { + return true + } + if (!left || !right) { + return false + } + return ( + left.code === right.code && + left.description === right.description && + left.validatedUrl === right.validatedUrl + ) + } + + private browserCertificateFailuresEqual( + a: RuntimeMobileSessionBrowserTab['certificateFailure'], + b: RuntimeMobileSessionBrowserTab['certificateFailure'] + ): boolean { + const left = a ?? null + const right = b ?? null + if (left === right) { + return true + } + if (!left || !right) { + return false + } + return ( + left.challengeId === right.challengeId && + left.browserPageId === right.browserPageId && + left.errorCode === right.errorCode && + left.error === right.error && + left.origin === right.origin && + left.displayHost === right.displayHost && + left.canProceed === right.canProceed && + left.observedAt === right.observedAt + ) + } + private getPersistedUnifiedSessionTabProps( worktreeId: string, tabId: string @@ -4685,26 +4766,33 @@ export class OrcaRuntimeService { (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId ).length const closingWholeParent = tab.id !== tabId || parentLeafCount <= 1 + // Why: a runtime-owned headless tab is absent from renderer state, so the + // closeTerminalTab relay below would ack success without killing its PTY, + // and syncMobileSessionTabs would republish the "closed" tab. Only bypass + // the relay when no renderer owns the parent: an adopted tab needs the + // renderer's live pin guard and durable close transaction. + if (closingWholeParent && !this.tabs.has(tab.parentTabId)) { + this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab) + this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) + this.store?.flushOrThrow?.() + return { closed: true } + } if (closingWholeParent && this.notifier?.closeTerminalTab) { // Why: whole-tab close is a lifecycle transaction. The renderer reply // arrives only after canonical retirement and a forced session flush. await this.notifier.closeTerminalTab(tab.parentTabId) return { closed: true } } - if (!this.notifier?.closeTerminal) { + // Why: notifier implementations without the acknowledged relay may expose + // only raw pane close. Runtime-owned parents still need de-persist + kill. + if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) { this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab) + this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) this.store?.flushOrThrow?.() return { closed: true } } - // Why: a runtime-owned headless tab whose whole parent is being closed must - // be torn down authoritatively even with a renderer attached — kill the - // PTY, drop the persisted binding, and prune+emit — or syncMobileSessionTabs - // keeps republishing the "closed" tab with a live PTY. Best-effort notify the - // renderer too so any adopted pane closes (no dead pane). A single split leaf - // (exact id, multi-leaf parent) keeps the per-leaf path so siblings survive. - if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) { + if (!this.notifier?.closeTerminal) { this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab) - this.notifier?.closeTerminal(tab.parentTabId) this.store?.flushOrThrow?.() return { closed: true } } @@ -4732,6 +4820,19 @@ export class OrcaRuntimeService { return { closed: true } } + private notifyRendererOfHeadlessTerminalClose(parentTabId: string): void { + // Why: this relay is advisory after main owns teardown; renderer failure must + // not prevent the authoritative session flush or turn the close into failure. + try { + this.notifier?.closeTerminal(parentTabId) + } catch (error) { + console.warn('[runtime] failed to notify renderer after headless terminal close', { + parentTabId, + error + }) + } + } + private async closeHeadlessMobileBrowserTab( worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot, @@ -21545,6 +21646,17 @@ export class OrcaRuntimeService { this.notifyMobileSessionTabSnapshots() return } + if (this.offscreenBrowserBackend) { + const reconciled = this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) + // Why: hydrate already reconciles an existing snapshot in place; only + // reconcile here when it didn't (fresh build or an early-returned hydrate). + if (!reconciled.has(worktreeId)) { + const existing = this.mobileSessionTabsByWorktree.get(worktreeId) + if (existing) { + this.reconcileHeadlessMobileSessionBrowserTabs(worktreeId, existing) + } + } + } // Why: structural changes (tab add/remove/activate) must propagate promptly, // so cancel any pending coalesced title/status notify — this immediate emit // already reflects the latest snapshot and supersedes it. @@ -25458,6 +25570,8 @@ export class OrcaRuntimeService { browserTabList: RuntimeBrowserCommands['browserTabList'] = this.browserCommands.browserTabList.bind(this.browserCommands) + browserProceedCertificate: RuntimeBrowserCommands['browserProceedCertificate'] = + this.browserCommands.browserProceedCertificate.bind(this.browserCommands) browserTabShow: RuntimeBrowserCommands['browserTabShow'] = this.browserCommands.browserTabShow.bind(this.browserCommands) diff --git a/src/main/runtime/rpc/methods/browser-core.ts b/src/main/runtime/rpc/methods/browser-core.ts index fc1b1277fce..41484d20768 100644 --- a/src/main/runtime/rpc/methods/browser-core.ts +++ b/src/main/runtime/rpc/methods/browser-core.ts @@ -1,5 +1,5 @@ import { defineMethod, type RpcMethod } from '../core' -import { BrowserTarget } from '../schemas' +import { BrowserTarget, requiredString } from '../schemas' import { Check, Drag, @@ -34,6 +34,10 @@ import { } from './browser-schemas' import { BROWSER_TEXT_METHODS } from './browser-text-rpc-methods' +const CertificateProceed = BrowserTarget.extend({ + challengeId: requiredString('Missing required challengeId') +}) + export const BROWSER_CORE_METHODS: RpcMethod[] = [ defineMethod({ name: 'browser.snapshot', @@ -50,6 +54,11 @@ export const BROWSER_CORE_METHODS: RpcMethod[] = [ params: Goto, handler: async (params, { runtime }) => runtime.browserGoto(params) }), + defineMethod({ + name: 'browser.certificate.proceed', + params: CertificateProceed, + handler: async (params, { runtime }) => runtime.browserProceedCertificate(params) + }), ...BROWSER_TEXT_METHODS, defineMethod({ name: 'browser.select', diff --git a/src/main/runtime/rpc/methods/browser-schemas.ts b/src/main/runtime/rpc/methods/browser-schemas.ts index 72a2893444e..9eff1456662 100644 --- a/src/main/runtime/rpc/methods/browser-schemas.ts +++ b/src/main/runtime/rpc/methods/browser-schemas.ts @@ -1,7 +1,5 @@ -// Why: the browser method surface area is large enough that keeping every -// schema in the same file as its handler registration pushes the file past -// the 300-line lint cap. Grouping all browser schemas here keeps each -// handler file focused on dispatch wiring. +// Why: browser schemas stay separate from handler registration so both sides +// remain under the line cap and dispatch wiring stays scannable. import { z } from 'zod' import { BrowserTarget, diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts index 391e5e18284..ca34340ccb1 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -472,6 +472,43 @@ describe('WebSocketTransport', () => { expect(transport.resolvedPort).toBe(fallbackPort) }) + it('binds the preferred port first when preferPinnedPort is set and both are free', async () => { + // Why: issue #8535 — `orca serve --port

` clients dial the pin. A + // free but stale mobile-ws-fallback-port.json must not pre-empt it. + const preferredPort = await reserveFreePort() + const fallbackPort = await reserveFreePort() + + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: preferredPort, + fallbackPort, + preferPinnedPort: true + }) + transports.push(transport) + await transport.start() + expect(transport.resolvedPort).toBe(preferredPort) + }) + + it('falls back when preferPinnedPort is set but the preferred port is taken', async () => { + // Why: explicit pins still degrade to the STA-1511 fallback on + // EADDRINUSE so previously-paired mobile devices remain reachable. + const preferredHolder = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) + transports.push(preferredHolder) + await preferredHolder.start() + const preferredPort = preferredHolder.resolvedPort + const fallbackPort = await reserveFreePort() + + const transport = new WebSocketTransport({ + host: '127.0.0.1', + port: preferredPort, + fallbackPort, + preferPinnedPort: true + }) + transports.push(transport) + await transport.start() + expect(transport.resolvedPort).toBe(fallbackPort) + }) + it('binds the preferred port when the persisted fallback is taken', async () => { const fallbackHolder = new WebSocketTransport({ host: '127.0.0.1', port: 0 }) transports.push(fallbackHolder) diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 8b88c7a820e..0d5403c5991 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -54,6 +54,11 @@ export type WebSocketTransportOptions = { // the (now free) preferred port instead would strand those pairings // (STA-1511). Callers pass the previously assigned fallback port here. fallbackPort?: number + // Why: `orca serve --port

` clients dial the pinned port. Prefer that port + // first (fallback second) so a stale mobile-ws-fallback-port.json cannot + // silently steal the pin (issue #8535). Default auto/desktop keeps + // fallback-first for STA-1511 pairing stability. + preferPinnedPort?: boolean } export class WebSocketTransport implements RpcTransport { @@ -65,6 +70,7 @@ export class WebSocketTransport implements RpcTransport { private readonly preAuthTimeoutMs: number private readonly staticRoot: string | undefined private readonly fallbackPort: number | undefined + private readonly preferPinnedPort: boolean private httpServer: HttpsServer | HttpServer | null = null private wss: WebSocketServer | null = null private heartbeatTimer: ReturnType | null = null @@ -89,7 +95,8 @@ export class WebSocketTransport implements RpcTransport { heartbeatIntervalMs, preAuthTimeoutMs, staticRoot, - fallbackPort + fallbackPort, + preferPinnedPort }: WebSocketTransportOptions) { this.host = host this.port = port @@ -99,6 +106,7 @@ export class WebSocketTransport implements RpcTransport { this.preAuthTimeoutMs = preAuthTimeoutMs ?? PRE_AUTH_TIMEOUT_MS this.staticRoot = staticRoot this.fallbackPort = fallbackPort + this.preferPinnedPort = preferPinnedPort === true } onMessage(handler: WebSocketMessageHandler): void { @@ -150,10 +158,12 @@ export class WebSocketTransport implements RpcTransport { return } - // Why: a persisted fallback port is bound FIRST — devices paired while it - // was active store ws://ip: and would be permanently stranded if - // a later launch grabbed the (now free) preferred port instead (STA-1511). - // Without a persisted fallback the preferred port is tried first. On + // Why: default order binds a persisted fallback FIRST — devices paired + // while it was active store ws://ip: and would be stranded if a + // later launch grabbed the (now free) preferred port instead (STA-1511). + // Explicit serve --port flips the order so the pinned port wins when free + // (issue #8535); fallback remains a secondary candidate for EADDRINUSE. + // Without a persisted fallback only the preferred port is tried. On // EADDRINUSE each candidate falls through to the next, ending at port 0 // (OS-assigned) so mobile pairing still works when everything is taken. // The QR code reads resolvedPort after start, so it always advertises the @@ -163,7 +173,11 @@ export class WebSocketTransport implements RpcTransport { ? this.fallbackPort : undefined const candidatePorts = - persistedFallbackPort !== undefined ? [persistedFallbackPort, this.port] : [this.port] + persistedFallbackPort === undefined + ? [this.port] + : this.preferPinnedPort + ? [this.port, persistedFallbackPort] + : [persistedFallbackPort, this.port] for (const port of candidatePorts) { try { await this.tryListen(port) diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 03c8225b44d..1b0a31dd2b0 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -1983,6 +1983,20 @@ describe('OrcaRuntimeRpcServer', () => { (response) => replies.push(JSON.parse(response) as Record), () => {} ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_browser_certificate_proceed', + method: 'browser.certificate.proceed', + deviceToken: mobile.token, + params: { + worktree: 'id:wt-1', + page: 'page-1', + challengeId: 'challenge-1' + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) await server['handleWebSocketMessage']( JSON.stringify({ id: 'req_browser_dialog_accept', @@ -2123,6 +2137,13 @@ describe('OrcaRuntimeRpcServer', () => { expect(replies).toContainEqual( expect.objectContaining({ id: 'req_browser_viewport', ok: true }) ) + expect(replies).toContainEqual( + expect.objectContaining({ + id: 'req_browser_certificate_proceed', + ok: false, + error: expect.objectContaining({ code: 'forbidden' }) + }) + ) expect(replies).toContainEqual( expect.objectContaining({ id: 'req_browser_dialog_accept', ok: true }) ) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index d7623065634..5c7c44574d4 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -53,6 +53,10 @@ type OrcaRuntimeRpcServerOptions = { platform?: NodeJS.Platform enableWebSocket?: boolean wsPort?: number + // Why: true when the caller set an explicit port (e.g. `orca serve --port`). + // Distinguishes that pin from the DEFAULT_WS_PORT default so transport bind + // order can prefer the pin over a stale STA-1511 fallback (issue #8535). + preferPinnedWsPort?: boolean webClientRoot?: string // Why: test-only overrides for the two time-bound constants below. // Production callers must not pass these — defaults are set by the design @@ -450,6 +454,7 @@ export class OrcaRuntimeRpcServer { private readonly platform: NodeJS.Platform private readonly enableWebSocket: boolean private readonly wsPort: number + private readonly preferPinnedWsPort: boolean private readonly webClientRoot: string | undefined private readonly authToken = randomBytes(24).toString('hex') private readonly keepaliveIntervalMs: number @@ -482,6 +487,7 @@ export class OrcaRuntimeRpcServer { platform = process.platform, enableWebSocket = false, wsPort = DEFAULT_WS_PORT, + preferPinnedWsPort = false, webClientRoot, keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS, longPollCap = LONG_POLL_CAP @@ -493,6 +499,7 @@ export class OrcaRuntimeRpcServer { this.platform = platform this.enableWebSocket = enableWebSocket this.wsPort = wsPort + this.preferPinnedWsPort = preferPinnedWsPort this.webClientRoot = webClientRoot this.keepaliveIntervalMs = keepaliveIntervalMs this.longPollCap = longPollCap @@ -863,10 +870,11 @@ export class OrcaRuntimeRpcServer { staticRoot: this.webClientRoot, // Why: keep the fallback port stable across restarts so paired // devices' stored endpoints stay valid (STA-1511) — the transport - // binds a persisted fallback before the preferred port. wsPort 0 - // means the caller explicitly wants a random port (E2E) — don't - // pin it. - ...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}) + // binds a persisted fallback before the preferred port unless the + // caller explicitly pinned a port (serve --port). wsPort 0 means + // the caller wants a random port (E2E) — don't pin it. + ...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}), + ...(this.preferPinnedWsPort ? { preferPinnedPort: true } : {}) }) const mobileSocketWiring = new MobileSocketWiring({ deviceRegistry: this.deviceRegistry, diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index 4464bb8f4ed..3b921daf65b 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -1853,7 +1853,7 @@ describe('generateBranchNameFromContext', () => { }) }) - it('includes the branch-name custom prompt in the generated prompt', async () => { + it('keeps branch-name guidance first without dropping the output contract', async () => { let prompt = '' await generateBranchNameFromContext( { firstPrompt: 'Fix login flow' }, @@ -1861,7 +1861,8 @@ describe('generateBranchNameFromContext', () => { agentId: 'custom', model: '', customAgentCommand: 'agent', - customPrompt: 'Prefer auth terminology.' + customPrompt: 'Prefer auth terminology.', + commandInputTemplate: 'Prefer auth terminology.\n\n{basePrompt}' }, { kind: 'remote', @@ -1879,8 +1880,11 @@ describe('generateBranchNameFromContext', () => { } ) - expect(prompt).toContain('Additional user prompt:') + expect(prompt.startsWith('Prefer auth terminology.')).toBe(true) expect(prompt).toContain('Prefer auth terminology.') + expect(prompt).not.toContain('Additional user prompt:') + expect(prompt).toContain('Generate a short git branch name') + expect(prompt).toContain('Output ONLY the branch name on a single line') }) }) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index a3db4bbbc55..7c03b6f8879 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -67,6 +67,8 @@ import type { BaseRefDefaultResult, BaseRefSearchResult, BrowserCookieImportResult, + BrowserCertificateFailure, + BrowserCertificateProceedResult, BrowserLoadError, BrowserSessionProfile, BrowserSessionProfileScope, @@ -479,7 +481,7 @@ export type BrowserApi = { worktreeId: string sessionProfileId?: string | null webContentsId: number - }) => Promise + }) => Promise unregisterGuest: (args: { browserPageId: string }) => Promise openDevTools: (args: { browserPageId: string }) => Promise setViewportOverride: (args: { @@ -490,6 +492,13 @@ export type BrowserApi = { onGuestLoadFailed: ( callback: (args: { browserPageId: string; loadError: BrowserLoadError }) => void ) => () => void + onCertificateFailureChanged: ( + callback: (event: { browserPageId: string; failure: BrowserCertificateFailure | null }) => void + ) => () => void + proceedCertificate: (args: { + browserPageId: string + challengeId: string + }) => Promise onPermissionDenied: (callback: (event: BrowserPermissionDeniedEvent) => void) => () => void onPopup: (callback: (event: BrowserPopupEvent) => void) => () => void onDownloadRequested: (callback: (event: BrowserDownloadRequestedEvent) => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 4aa685d7b56..9f0a30529bd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2238,7 +2238,7 @@ const api = { worktreeId: string sessionProfileId?: string | null webContentsId: number - }): Promise => ipcRenderer.invoke('browser:registerGuest', args), + }): Promise => ipcRenderer.invoke('browser:registerGuest', args), unregisterGuest: (args: { browserPageId: string }): Promise => ipcRenderer.invoke('browser:unregisterGuest', args), @@ -2271,6 +2271,17 @@ const api = { return () => ipcRenderer.removeListener('browser:guest-load-failed', listener) }, + onCertificateFailureChanged: (callback): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: Parameters[0] + ): void => callback(data) + ipcRenderer.on('browser:certificate-failure-changed', listener) + return () => ipcRenderer.removeListener('browser:certificate-failure-changed', listener) + }, + + proceedCertificate: (args) => ipcRenderer.invoke('browser:proceedCertificate', args), + onPermissionDenied: ( callback: (event: { browserPageId: string; permission: string; origin: string }) => void ): (() => void) => { diff --git a/src/relay/git-handler-blob-readers.test.ts b/src/relay/git-handler-blob-readers.test.ts index 3a32eccaed1..b2b9e87ecbb 100644 --- a/src/relay/git-handler-blob-readers.test.ts +++ b/src/relay/git-handler-blob-readers.test.ts @@ -44,6 +44,7 @@ describe('git blob readers', () => { const result = await readBlobAtIndex(gitBuffer, '/repo', 'large.log') - expect(result).toEqual({ content: '', isBinary: true }) + // Why: overflow is size-capped content, not a staged deletion (missing: false). + expect(result).toEqual({ content: '', isBinary: true, missing: false }) }) }) diff --git a/src/relay/git-handler-ops.ts b/src/relay/git-handler-ops.ts index 395e31badb3..afe75a2b582 100644 --- a/src/relay/git-handler-ops.ts +++ b/src/relay/git-handler-ops.ts @@ -52,17 +52,19 @@ export async function readBlobAtIndex( gitBuffer: GitBufferExec, cwd: string, filePath: string -): Promise<{ content: string; isBinary: boolean }> { +): Promise<{ content: string; isBinary: boolean; missing: boolean }> { // Why: Git's `:` syntax expects forward slashes even on Windows. const gitPath = filePath.replace(/\\/g, '/') try { const buf = await gitBuffer(['show', '--end-of-options', `:${gitPath}`], cwd) - return bufferToBlob(buf, filePath) + return { ...bufferToBlob(buf, filePath), missing: false } } catch (error) { if (isGitBufferOverflowError(error)) { - return { content: '', isBinary: true } + return { content: '', isBinary: true, missing: false } } - return { content: '', isBinary: false } + // Why: a non-overflow failure means the path is absent from the index (a + // staged deletion), distinct from the size-capped case handled above. + return { content: '', isBinary: false, missing: true } } } @@ -91,6 +93,7 @@ export async function computeDiff( let modifiedContent = '' let originalIsBinary = false let modifiedIsBinary = false + let modifiedDeleted = false try { if (staged) { @@ -101,6 +104,7 @@ export async function computeDiff( const right = await readBlobAtIndex(git, worktreePath, filePath) modifiedContent = right.content modifiedIsBinary = right.isBinary + modifiedDeleted = right.missing } else { const left = compareAgainstHead ? await readBlobAtOid(git, worktreePath, 'HEAD', filePath) @@ -111,18 +115,25 @@ export async function computeDiff( const right = await readWorkingDiffFile(path.join(worktreePath, filePath)) modifiedContent = right.content modifiedIsBinary = right.isBinary + modifiedDeleted = right.missing } } catch { // Fallback to empty } - return buildDiffResult( + const result = buildDiffResult( originalContent, modifiedContent, originalIsBinary, modifiedIsBinary, filePath ) + // Why: mark a proven deletion so previewers can fall back to the original bytes + // without mistaking a read failure's empty modified side for a deletion. + if (result.kind === 'binary' && modifiedDeleted) { + return { ...result, modifiedDeleted: true } + } + return result } // ─── Branch compare ────────────────────────────────────────────────── diff --git a/src/relay/git-handler-worktree-ops.test.ts b/src/relay/git-handler-worktree-ops.test.ts index ed252df6a46..cedcf8b797c 100644 --- a/src/relay/git-handler-worktree-ops.test.ts +++ b/src/relay/git-handler-worktree-ops.test.ts @@ -196,6 +196,117 @@ describe('removeWorktreeOp', () => { ]) }) + it('force-retries removal when git refuses a clean worktree containing an initialised submodule', async () => { + const calls: string[] = [] + let listCount = 0 + const git = vi.fn(async (args, cwd) => { + calls.push(`${cwd}$ ${args.join(' ')}`) + if (args[0] === 'rev-parse') { + return { stdout: '/repo/.git\n', stderr: '' } + } + if (args[0] === 'worktree' && args[1] === 'list') { + listCount += 1 + return { + stdout: + listCount === 1 + ? worktreeList( + { path: '/repo', branch: 'main' }, + { path: '/repo-feature', branch: 'feature/test' } + ) + : worktreeList({ path: '/repo', branch: 'main' }), + stderr: '' + } + } + if (args[0] === 'worktree' && args[1] === 'remove' && !args.includes('--force')) { + throw Object.assign(new Error('git worktree remove failed'), { + stderr: 'fatal: working trees containing submodules cannot be moved or removed' + }) + } + return { stdout: '', stderr: '' } + }) + + await removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) + + expect(calls).toEqual([ + '/repo-feature$ rev-parse --git-common-dir', + `${resolvedRepoPath()}$ worktree list --porcelain -z`, + `${resolvedRepoPath()}$ worktree remove /repo-feature`, + '/repo-feature$ status --porcelain --untracked-files=all', + `${resolvedRepoPath()}$ worktree remove --force /repo-feature`, + `${resolvedRepoPath()}$ branch -d -- feature/test` + ]) + }) + + it('surfaces uncommitted changes instead of force-removing a dirty submodule worktree', async () => { + const git = vi.fn(async (args) => { + if (args[0] === 'rev-parse') { + return { stdout: '/repo/.git\n', stderr: '' } + } + if (args[0] === 'worktree' && args[1] === 'list') { + return { + stdout: worktreeList( + { path: '/repo', branch: 'main' }, + { path: '/repo-feature', branch: 'feature/test' } + ), + stderr: '' + } + } + if (args[0] === 'worktree' && args[1] === 'remove') { + throw Object.assign(new Error('git worktree remove failed'), { + stderr: 'fatal: working trees containing submodules cannot be moved or removed' + }) + } + if (args[0] === 'status') { + return { stdout: ' M sub\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + await expect( + removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) + ).rejects.toThrow('Worktree has uncommitted or untracked changes.') + expect(git).not.toHaveBeenCalledWith( + ['worktree', 'remove', '--force', '/repo-feature'], + expect.any(String) + ) + }) + + it('does not force-retry when the caller already forced SSH removal', async () => { + const git = vi.fn(async (args) => { + if (args[0] === 'rev-parse') { + return { stdout: '/repo/.git\n', stderr: '' } + } + if (args[0] === 'worktree' && args[1] === 'list') { + return { + stdout: worktreeList( + { path: '/repo', branch: 'main' }, + { path: '/repo-feature', branch: 'feature/test' } + ), + stderr: '' + } + } + if (args[0] === 'worktree' && args[1] === 'remove') { + throw Object.assign(new Error('git worktree remove failed'), { + stderr: 'fatal: working trees containing submodules cannot be moved or removed' + }) + } + return { stdout: '', stderr: '' } + }) + + await expect( + removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature', force: true }) + ).rejects.toThrow('git worktree remove failed') + expect( + git.mock.calls.filter( + ([args]) => args[0] === 'worktree' && args[1] === 'remove' && args.includes('--force') + ) + ).toHaveLength(1) + expect(git).not.toHaveBeenCalledWith( + ['status', '--porcelain', '--untracked-files=all'], + expect.any(String) + ) + }) + it('preserves the branch (does not throw) when `branch -d` refuses an unmerged branch', async () => { let listCount = 0 const git = vi.fn(async (args) => { diff --git a/src/relay/git-handler-worktree-remove.ts b/src/relay/git-handler-worktree-remove.ts index 2c2ce54a8cc..46ebaf8a355 100644 --- a/src/relay/git-handler-worktree-remove.ts +++ b/src/relay/git-handler-worktree-remove.ts @@ -1,6 +1,7 @@ import * as path from 'node:path' import type { RemoveWorktreeResult } from '../shared/types' import { assertWorktreeUnlockedForRemoval } from '../shared/worktree-removal' +import { isSubmoduleWorktreeRemovalRefusal } from '../shared/worktree-submodule-removal' import { deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure } from './git-handler-branch-cleanup' import type { GitExec } from './git-handler-ops' import type { GitCapabilityCache } from '../shared/git-capability-cache' @@ -156,7 +157,23 @@ export async function removeWorktreeOp( args.push('--force') } args.push(worktreePath) - await git(args, repoPath) + try { + await git(args, repoPath) + } catch (error) { + if (force || !isSubmoduleWorktreeRemovalRefusal(error)) { + throw error + } + // Why: Git refuses non-force removal of any worktree with an initialised + // submodule even when everything is clean. Re-prove cleanliness (parent + // status reports dirty submodule content as ` M `), then --force. + const { stdout } = await git(['status', '--porcelain', '--untracked-files=all'], worktreePath) + if (stdout.trim()) { + const dirtyError = new Error('Worktree has uncommitted or untracked changes.') + ;(dirtyError as Error & { stdout?: string }).stdout = stdout + throw dirtyError + } + await git(['worktree', 'remove', '--force', worktreePath], repoPath) + } if (!branchName) { return {} diff --git a/src/relay/git-working-file-read.test.ts b/src/relay/git-working-file-read.test.ts index b4f8e3e6f7f..7f9824ead94 100644 --- a/src/relay/git-working-file-read.test.ts +++ b/src/relay/git-working-file-read.test.ts @@ -21,7 +21,8 @@ describe('readWorkingDiffFile', () => { await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ content: 'hello', - isBinary: false + isBinary: false, + missing: false }) }) @@ -32,7 +33,61 @@ describe('readWorkingDiffFile', () => { await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ content: '', - isBinary: true + isBinary: true, + missing: false + }) + }) + + it('base64-encodes a previewable image by its extension', async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-')) + const filePath = path.join(tmpDir, 'icon.png') + const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01]) + await writeFile(filePath, pngBytes) + + await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ + content: pngBytes.toString('base64'), + isBinary: true, + missing: false + }) + }) + + it('reports an absent working-tree file as a deletion', async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-')) + const filePath = path.join(tmpDir, 'deleted.png') + + await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ + content: '', + isBinary: false, + missing: true + }) + }) + + it('returns base64 content for previewable image working-tree files', async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-')) + const filePath = path.join(tmpDir, 'image.png') + // Why: PNG signature + trailing null byte forces the binary-detection heuristic to trip. + const pngBuffer = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.from([0x00]) + ]) + await writeFile(filePath, pngBuffer) + + await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ + content: pngBuffer.toString('base64'), + isBinary: true, + missing: false + }) + }) + + it('returns empty content for non-previewable binary working-tree files', async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-')) + const filePath = path.join(tmpDir, 'archive.bin') + await writeFile(filePath, Buffer.from([0x00, 0x01, 0x02, 0x00, 0x03])) + + await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ + content: '', + isBinary: true, + missing: false }) }) }) diff --git a/src/relay/git-working-file-read.ts b/src/relay/git-working-file-read.ts index 2c7db8e9778..05cf1ebcdce 100644 --- a/src/relay/git-working-file-read.ts +++ b/src/relay/git-working-file-read.ts @@ -5,19 +5,31 @@ const MAX_RELAY_DIFF_WORKING_FILE_BYTES = 10 * 1024 * 1024 export async function readWorkingDiffFile( absPath: string -): Promise<{ content: string; isBinary: boolean }> { +): Promise<{ content: string; isBinary: boolean; missing: boolean }> { + let fileStat + try { + fileStat = await stat(absPath) + } catch (error) { + // Why: ENOENT means the working-tree file is genuinely gone (a deletion); + // any other stat error is a read failure we must not mistake for one, since + // callers fall back to the original bytes only for a proven deletion. + const missing = (error as NodeJS.ErrnoException)?.code === 'ENOENT' + return { content: '', isBinary: false, missing } + } + if (!fileStat.isFile()) { + return { content: '', isBinary: false, missing: true } + } + if (fileStat.size > MAX_RELAY_DIFF_WORKING_FILE_BYTES) { + // Why: mirror local git diff reads, which cap blob transfer at 10MB. + return { content: '', isBinary: true, missing: false } + } try { - const fileStat = await stat(absPath) - if (!fileStat.isFile()) { - return { content: '', isBinary: false } - } - if (fileStat.size > MAX_RELAY_DIFF_WORKING_FILE_BYTES) { - // Why: mirror local git diff reads, which cap blob transfer at 10MB. - return { content: '', isBinary: true } - } const buffer = await readFile(absPath) - return bufferToBlob(buffer) + // Why: bufferToBlob needs the path's extension to know an image is + // previewable; omitting it made every relay-side binary diff empty. + return { ...bufferToBlob(buffer, absPath), missing: false } } catch { - return { content: '', isBinary: false } + // Why: the file exists but could not be read — a read failure, not a deletion. + return { content: '', isBinary: false, missing: false } } } diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index d076f0b325d..6f6b7fb2424 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -181,7 +181,7 @@ import { filterGitHubPRReviewerCandidates, getGitHubPRReviewerQueryState } from '@/components/github/github-pr-reviewer-candidate-filter' -import { githubAvatarUrl } from '@/components/github/github-issue-comment-helpers' +import { GitHubUserAvatar } from '@/components/github/github-user-avatar' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' import { GITHUB_PR_MERGE_METHOD_LABELS, @@ -421,16 +421,7 @@ function ReviewerAvatar({ login: string avatarUrl: string }): React.JSX.Element { - return ( - - ) + return } function mergeReviewerSuggestions( diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index d807dacf89f..b53ba354618 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -157,7 +157,7 @@ import { filterGitHubPRReviewerCandidates, getGitHubPRReviewerQueryState } from '@/components/github/github-pr-reviewer-candidate-filter' -import { githubAvatarUrl } from '@/components/github/github-issue-comment-helpers' +import { GitHubUserAvatar } from '@/components/github/github-user-avatar' import { filterGitHubMentionOptions } from '@/components/github/github-mention-option-filter' import { getCommentBodySubmitState, @@ -468,16 +468,7 @@ function ReviewerAvatar({ login: string avatarUrl: string }): React.JSX.Element { - return ( - - ) + return } function mergeReviewerSuggestions( @@ -7148,12 +7139,10 @@ export default function PullRequestPage({ {workItem.author ? ( - ) : null} diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 0762c02b7f1..6add53d1625 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -133,6 +133,7 @@ import { } from '../../../shared/linear-links' import PRFilterDropdowns, { type PRFilterChange } from '@/components/github/PRFilterDropdowns' import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' +import { GitHubUserAvatar } from '@/components/github/github-user-avatar' import { buildGitHubRepoUrl, parseGitHubIssueOrPRLink } from '@/lib/github-links' import { findGithubWorkItemWorkspaceAttachment, @@ -1520,17 +1521,16 @@ function ReviewChipAvatar({ reviewer: GitHubPRPrimaryReviewer | null }): React.JSX.Element { if (reviewer?.login) { - // Why: `gh pr list --json reviewRequests` can return only logins; GitHub's - // public avatar endpoint keeps the list visual aligned with assignee cells. - const avatarUrl = reviewer.avatarUrl || `https://github.com/${reviewer.login}.png?size=40` + // Why: `gh pr list --json reviewRequests` can return only logins. Prefer the + // API avatar_url so GHE renders; GitHubUserAvatar falls back to the login URL + // and then an initials placeholder when no avatar loads. See #8784. return ( - ) } diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 20cde69e56f..50b4dc45852 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -63,6 +63,7 @@ import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner import { ORCA_BROWSER_BLANK_URL, ORCA_BROWSER_PARTITION } from '../../../../shared/constants' import { getOrcaProfileBrowserDefaultPartition } from '../../../../shared/orca-profiles' import type { + BrowserCertificateProceedResult, BrowserLoadError, BrowserPage as BrowserPageState, BrowserWorkspace as BrowserWorkspaceState @@ -70,7 +71,9 @@ import type { import { normalizeBrowserNavigationUrl, normalizeExternalBrowserUrl, - redactKagiSessionToken + redactKagiSessionToken, + resolveRemoteFailureExternalUrl, + toHttpsRecoveryUrl } from '../../../../shared/browser-url' import { keybindingMatchesAction } from '../../../../shared/keybindings' import { getScreenSubmitModifierLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' @@ -165,13 +168,7 @@ import { type BrowserScreencastFrameMetadata } from '../../../../shared/browser-screencast-protocol' import { withBrowserPaneUiRuntimeRpcSource } from '../../../../shared/runtime-rpc-feature-interaction-source' -import { - formatByteCount, - formatLoadFailureDescription, - formatLoadFailureRecoveryHint, - formatPermissionNotice, - formatPopupNotice -} from './browser-notices' +import { formatByteCount, formatPermissionNotice, formatPopupNotice } from './browser-notices' import { getDriverForBrowserPage, onBrowserDriverChange, @@ -186,6 +183,7 @@ import { useMarkupMode, type MarkupCaptureContext } from './markup/useMarkupMode import { MarkupOverlay } from './markup/MarkupOverlay' import { MarkupDrawButton } from './markup/MarkupDrawButton' import { deliverMarkupToClipboard } from './markup/markup-clipboard-delivery' +import { BrowserLoadFailureOverlay } from './browser-load-failure-overlay' type BrowserTabPageState = Partial< Pick< @@ -700,28 +698,6 @@ function getRemoteBrowserDeviceScaleFactor(): number { return Math.min(2, Math.max(1, Number(scale.toFixed(2)))) } -function getLoadErrorMetadata(loadError: BrowserLoadError | null): { - displayUrl: string - host: string | null - isLocalhostLike: boolean -} { - const rawUrl = loadError?.validatedUrl ?? 'about:blank' - const displayUrl = toDisplayUrl(rawUrl) - try { - const parsed = new URL(rawUrl) - const host = parsed.host || null - const hostname = parsed.hostname - const isLocalhostLike = - hostname === 'localhost' || - hostname === '127.0.0.1' || - hostname === '0.0.0.0' || - hostname === '::1' - return { displayUrl, host, isLocalhostLike } - } catch { - return { displayUrl, host: null, isLocalhostLike: false } - } -} - function getOpenableExternalUrl( webview: Electron.WebviewTag | null, fallbackUrl: string @@ -960,6 +936,12 @@ function RemoteBrowserPagePane({ (token: RemoteBrowserOperationToken) => Promise >(async () => null) const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle) + const certificateFailure = useAppStore( + (s) => s.browserCertificateFailuresByPageId[browserTab.id] ?? null + ) + const remotePageHandle = useAppStore( + (s) => s.remoteBrowserPageHandlesByPageId[browserTab.id] ?? null + ) const createBrowserTab = useAppStore((s) => s.createBrowserTab) const closeBrowserPage = useAppStore((s) => s.closeBrowserPage) const closeBrowserTab = useAppStore((s) => s.closeBrowserTab) @@ -1985,7 +1967,13 @@ function RemoteBrowserPagePane({ setRemoteError(message) onUpdatePageState(browserTab.id, { loading: false, - loadError: { code: 0, description: message, validatedUrl: url ?? browserTab.url } + // Why: validatedUrl crosses process/persistence boundaries, so redact a + // Kagi session token the same way the main-process failure path does. + loadError: { + code: 0, + description: message, + validatedUrl: redactKagiSessionToken(url ?? browserTab.url) + } }) } finally { if (isCurrentRemoteOperationToken(pageToken)) { @@ -2398,6 +2386,12 @@ function RemoteBrowserPagePane({ }, [frameUrl, handleRemoteScreenshotWheel]) const remoteFrameStyle = useMemo(() => getRemoteBrowserFrameStyle(frameMetadata), [frameMetadata]) + const remoteFailureUrl = browserTab.loadError?.validatedUrl ?? browserTab.url + const remoteFailureExternalUrl = resolveRemoteFailureExternalUrl(remoteFailureUrl) + const showRemoteFailureOverlay = + Boolean(browserTab.loadError) && + remoteFailureUrl !== 'about:blank' && + remoteFailureUrl !== ORCA_BROWSER_BLANK_URL // Why: markup works on remote panes by snapshotting the already-displayed // screencast — no in-page injection needed, so it is enabled here even @@ -2692,6 +2686,44 @@ function RemoteBrowserPagePane({ )} + {showRemoteFailureOverlay && browserTab.loadError ? ( + void runRemoteNavigation('browser.reload')} + onTryHttps={(url) => void runRemoteNavigation('browser.goto', url)} + onCopy={(url) => void window.api.ui.writeClipboardText(url)} + onOpenExternal={(url) => void window.api.shell.openUrl(url)} + certificateFailure={certificateFailure} + expectedBrowserPageId={ + remotePageHandle?.environmentId === activeRuntimeEnvironmentId + ? remotePageHandle.remotePageId + : null + } + onProceedCertificate={async (challengeId) => { + const target = runtimeTarget() + if ( + !target || + remotePageHandle?.environmentId !== target.environmentId || + remotePageHandle.remotePageId !== certificateFailure?.browserPageId + ) { + return { ok: false, reason: 'missing' } + } + return callRuntimeRpc( + target, + 'browser.certificate.proceed', + { + worktree: runtimeWorktree, + page: remotePageHandle.remotePageId, + challengeId + }, + { timeoutMs: 15_000, suppressFeatureInteraction: true } + ) + }} + /> + ) : null} {remoteError ? (

{remoteError} @@ -2879,6 +2911,9 @@ function BrowserPagePane({ const browserAnnotations = useAppStore( (s) => s.browserAnnotationsByPageId[browserTab.id] ?? EMPTY_BROWSER_ANNOTATIONS ) + const certificateFailure = useAppStore( + (s) => s.browserCertificateFailuresByPageId[browserTab.id] ?? null + ) const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[worktreeId]) const browserAnnotationsRef = useRef(browserAnnotations) browserAnnotationsRef.current = browserAnnotations @@ -3731,21 +3766,53 @@ function BrowserPagePane({ dismissAddressBarSuggestionsRef.current?.() } - const handleDomReady = (): void => { + let registrationInFlight: { webContentsId: number; promise: Promise } | null = null + const registerGuest = (): Promise => { const webContentsId = webview.getWebContentsId() - let queuedAnnotationViewportBridgeSync = false - if (registeredWebContentsIds.get(browserTab.id) !== webContentsId) { - registeredWebContentsIds.set(browserTab.id, webContentsId) - queuedAnnotationViewportBridgeSync = true - void window.api.browser - .registerGuest({ - browserPageId: browserTab.id, - workspaceId, - worktreeId, - sessionProfileId, - webContentsId - }) - .finally(() => syncBrowserAnnotationViewportBridge()) + if (registeredWebContentsIds.get(browserTab.id) === webContentsId) { + return Promise.resolve(true) + } + if (registrationInFlight?.webContentsId === webContentsId) { + return registrationInFlight.promise + } + const promise = window.api.browser + .registerGuest({ + browserPageId: browserTab.id, + workspaceId, + worktreeId, + sessionProfileId, + webContentsId + }) + .then((registered) => { + if (registered) { + registeredWebContentsIds.set(browserTab.id, webContentsId) + } + return registered + }) + // Why: normalize IPC rejection to false so the dom-ready fallback can + // retry attach-policy races without an unhandled promise rejection. + .catch(() => false) + .finally(() => { + if (registrationInFlight?.promise === promise) { + registrationInFlight = null + } + }) + registrationInFlight = { webContentsId, promise } + return promise + } + + const handleDidAttach = (): void => { + // Why: certificate failures can happen before dom-ready. Register at + // attach so main can map the initial failure to this page; dom-ready below + // remains an idempotent fallback for attach-policy races. + void registerGuest().finally(() => syncBrowserAnnotationViewportBridge()) + } + + const handleDomReady = (): void => { + const queuedAnnotationViewportBridgeSync = + registeredWebContentsIds.get(browserTab.id) !== webview.getWebContentsId() + if (queuedAnnotationViewportBridgeSync) { + void registerGuest().finally(() => syncBrowserAnnotationViewportBridge()) } syncNavigationState(webview) if (keepAddressBarFocusRef.current) { @@ -3964,6 +4031,7 @@ function BrowserPagePane({ } } + webview.addEventListener('did-attach', handleDidAttach) webview.addEventListener('dom-ready', handleDomReady) webview.addEventListener('focus', dismissAddressBarSuggestions) webview.addEventListener('did-start-loading', handleDidStartLoading) @@ -3997,6 +4065,7 @@ function BrowserPagePane({ } return () => { + webview.removeEventListener('did-attach', handleDidAttach) webview.removeEventListener('dom-ready', handleDomReady) webview.removeEventListener('focus', dismissAddressBarSuggestions) webview.removeEventListener('did-start-loading', handleDidStartLoading) @@ -4672,8 +4741,8 @@ function BrowserPagePane({ const isBlankTab = browserTab.url === 'about:blank' || browserTab.url === ORCA_BROWSER_BLANK_URL const externalUrl = getOpenableExternalUrl(webviewRef.current, browserTab.url) const currentBrowserUrl = getCurrentBrowserUrl(webviewRef.current, browserTab.url) - const loadErrorMeta = getLoadErrorMetadata(browserTab.loadError) - const loadErrorHint = formatLoadFailureRecoveryHint(loadErrorMeta) + const failedNavigationUrl = browserTab.loadError?.validatedUrl ?? currentBrowserUrl + const failureExternalUrl = normalizeExternalBrowserUrl(failedNavigationUrl) const showFailureOverlay = Boolean(browserTab.loadError) && !isBlankTab const visibleDownloads = (() => { const active = downloadStates.filter((download) => download.status === 'downloading') @@ -5476,114 +5545,40 @@ function BrowserPagePane({ onClose={() => setFindOpen(false)} webviewRef={webviewRef} /> - {showFailureOverlay ? ( -
-
-
- -
-

- {loadErrorMeta.host - ? translate( - 'auto.components.browser.pane.BrowserPane.db325a7eeb', - "Can't reach {{value0}}", - { value0: loadErrorMeta.host } - ) - : translate( - 'auto.components.browser.pane.BrowserPane.b2856516e2', - "Can't load this page" - )} -

-

- {formatLoadFailureDescription(browserTab.loadError, loadErrorMeta)} -

- {loadErrorHint ? ( -

{loadErrorHint}

- ) : null} -
- - - {externalUrl ? ( - - ) : null} -
-
-
+ {showFailureOverlay && browserTab.loadError ? ( + { + const webview = webviewRef.current + if (!webview) { + return + } + onUpdatePageStateRef.current(browserTab.id, { loading: true }) + retryBrowserTabLoad(webview, browserTab, onUpdatePageStateRef.current) + }} + onTryHttps={navigateToUrl} + onCopy={(url) => { + void window.api.ui.writeClipboardText(url) + setResourceNotice( + translate( + 'browser.loadFailure.addressCopied', + 'Copied the current page address.' + ) + ) + }} + onOpenExternal={(url) => void window.api.shell.openUrl(url)} + certificateFailure={certificateFailure} + expectedBrowserPageId={browserTab.id} + onProceedCertificate={(challengeId) => + window.api.browser.proceedCertificate({ + browserPageId: browserTab.id, + challengeId + }) + } + /> ) : null} {isBlankTab ? (
diff --git a/src/renderer/src/components/browser-pane/browser-load-failure-overlay.test.tsx b/src/renderer/src/components/browser-pane/browser-load-failure-overlay.test.tsx new file mode 100644 index 00000000000..d49e8ccdc9f --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-load-failure-overlay.test.tsx @@ -0,0 +1,319 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { BrowserLoadFailureOverlay } from './browser-load-failure-overlay' + +const callbacks = { + onRetry: vi.fn(), + onTryHttps: vi.fn(), + onCopy: vi.fn(), + onOpenExternal: vi.fn(), + onProceedCertificate: vi.fn() +} + +const certificateFailure = { + challengeId: 'challenge-1', + browserPageId: 'page-1', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 +} + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.clearAllMocks() +}) + +describe('BrowserLoadFailureOverlay', () => { + it('uses certificate-specific copy while keeping strict verification', () => { + render( + + ) + + expect(screen.getByText("Connection isn't secure")).toBeInTheDocument() + expect( + screen.getByText( + "Orca doesn't trust the authority that issued the certificate for localhost:3443." + ) + ).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeNull() + expect(screen.queryByText(/make sure the server is running/i)).not.toBeInTheDocument() + }) + + it('keeps restored certificate errors accurate without allowing approval', () => { + render( + + ) + + expect(screen.getByText("The certificate doesn't match localhost:3443.")).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeNull() + expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Copy Address' })).toBeEnabled() + }) + + it('offers HTTPS recovery only for an eligible failed HTTP address', () => { + const { rerender } = render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Try HTTPS' })) + expect(callbacks.onTryHttps).toHaveBeenCalledWith('https://localhost:3000/app') + + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Try HTTPS' })).toBeNull() + }) + + it('hides Open Externally and works without an external handler', () => { + render( + + ) + + // externalUrl / onOpenExternal omitted (e.g. a remote loopback failure): + // the action is absent and the overlay still renders its other recovery UI. + expect(screen.queryByRole('button', { name: 'Open Externally' })).toBeNull() + expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Copy Address' })).toBeEnabled() + }) + + it('does not show local-certificate guidance for a public-host failure', () => { + render( + + ) + + expect(screen.queryByText(/use a trusted local certificate/i)).not.toBeInTheDocument() + }) + + it('offers approval only for a matching live challenge', () => { + const { rerender } = render( + + ) + + expect(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeEnabled() + + rerender( + + ) + expect(screen.getByText("Connection isn't secure")).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeEnabled() + + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeNull() + + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeNull() + }) + + it('disables every action immediately and delays connecting feedback', async () => { + vi.useFakeTimers() + let resolveProceed: ((result: { ok: true }) => void) | null = null + callbacks.onProceedCertificate.mockReturnValue( + new Promise((resolve) => { + resolveProceed = resolve + }) + ) + const { rerender } = render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })) + expect(callbacks.onProceedCertificate).toHaveBeenCalledWith('challenge-1') + for (const name of ['Proceed Anyway (Unsafe)', 'Open Externally', 'Retry', 'Copy Address']) { + expect(screen.getByRole('button', { name })).toBeDisabled() + } + expect(screen.queryByText('Connecting…')).toBeNull() + + act(() => vi.advanceTimersByTime(199)) + expect(screen.queryByText('Connecting…')).toBeNull() + act(() => vi.advanceTimersByTime(1)) + expect(screen.getByRole('button', { name: 'Connecting…' })).toBeDisabled() + + await act(async () => resolveProceed?.({ ok: true })) + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeNull() + expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled() + }) + + it('recovers from typed approval failures and resets for a new challenge', async () => { + callbacks.onProceedCertificate.mockResolvedValue({ ok: false, reason: 'expired' }) + const props = { + loadError: { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/app' + }, + externalUrl: null, + currentUrl: 'https://localhost:3443/app', + httpsRecoveryUrl: null, + expectedBrowserPageId: 'page-1', + ...callbacks + } + const { rerender } = render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })) + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent(/certificate approval expired/i) + ) + expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled() + + callbacks.onProceedCertificate.mockReturnValue(new Promise(() => {})) + fireEvent.click(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })) + expect(screen.getByRole('button', { name: 'Retry' })).toBeDisabled() + rerender( + + ) + expect(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeEnabled() + expect(screen.queryByRole('alert')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/browser-pane/browser-load-failure-overlay.tsx b/src/renderer/src/components/browser-pane/browser-load-failure-overlay.tsx new file mode 100644 index 00000000000..06fa7249108 --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-load-failure-overlay.tsx @@ -0,0 +1,340 @@ +import { useEffect, useRef, useState } from 'react' +import { Copy, ExternalLink, Globe, Loader2, RefreshCw, ShieldAlert } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import type { + BrowserCertificateFailure, + BrowserCertificateProceedFailureReason, + BrowserCertificateProceedResult, + BrowserLoadError +} from '../../../../shared/types' +import { isEligibleLocalCertificateHost } from '../../../../shared/browser-url' +import { + formatLoadFailureDescription, + formatLoadFailureRecoveryHint, + isCertificateLoadError, + type LoadFailureMeta +} from './browser-notices' + +type BrowserLoadFailureOverlayProps = { + loadError: BrowserLoadError + externalUrl?: string | null + currentUrl: string + httpsRecoveryUrl: string | null + onRetry: () => void + onTryHttps: (url: string) => void + onCopy: (url: string) => void + onOpenExternal?: (url: string) => void + certificateFailure?: BrowserCertificateFailure | null + expectedBrowserPageId?: string | null + onProceedCertificate?: (challengeId: string) => Promise +} + +type CertificateProceedAttempt = { + challengeId: string + state: 'submitting' | 'failed' + showConnecting: boolean + reason?: BrowserCertificateProceedFailureReason | 'request-failed' +} + +function getLoadErrorMetadata(loadError: BrowserLoadError): LoadFailureMeta { + try { + const parsed = new URL(loadError.validatedUrl) + return { + host: parsed.host || null, + isLocalhostLike: + parsed.hostname === '0.0.0.0' || isEligibleLocalCertificateHost(parsed.hostname) + } + } catch { + return { host: null, isLocalhostLike: false } + } +} + +function normalizeCertificateError(error: string): string { + return error + .trim() + .replace(/^net::/i, '') + .toUpperCase() +} + +function getMatchingCertificateFailure(args: { + loadError: BrowserLoadError + certificateFailure?: BrowserCertificateFailure | null + expectedBrowserPageId?: string | null + canProceed: boolean +}): BrowserCertificateFailure | null { + const { loadError, certificateFailure, expectedBrowserPageId, canProceed } = args + const errorMatchesChallenge = Boolean( + certificateFailure && + (loadError.code === -1 || + (loadError.code === certificateFailure.errorCode && + normalizeCertificateError(certificateFailure.error) === + normalizeCertificateError(loadError.description))) + ) + if ( + !canProceed || + !certificateFailure?.canProceed || + !expectedBrowserPageId || + certificateFailure.browserPageId !== expectedBrowserPageId || + certificateFailure.errorCode !== -202 || + !errorMatchesChallenge + ) { + return null + } + try { + return new URL(loadError.validatedUrl).origin === certificateFailure.origin + ? certificateFailure + : null + } catch { + return null + } +} + +function formatCertificateProceedFailure( + reason: BrowserCertificateProceedFailureReason | 'request-failed' +): string { + if (reason === 'expired') { + return translate( + 'browser.loadFailure.certificateChallengeExpired', + 'This certificate approval expired. Retry the page to request a new one.' + ) + } + if (reason === 'changed' || reason === 'navigated') { + return translate( + 'browser.loadFailure.certificateChallengeChanged', + 'The certificate request changed. Retry the page and review the new warning.' + ) + } + if (reason === 'request-failed') { + return translate( + 'browser.loadFailure.certificateProceedFailed', + 'Orca could not approve this certificate request. Retry the page and try again.' + ) + } + return translate( + 'browser.loadFailure.certificateChallengeUnavailable', + 'This certificate request is no longer available. Retry the page to request a new one.' + ) +} + +export function BrowserLoadFailureOverlay({ + loadError, + externalUrl, + currentUrl, + httpsRecoveryUrl, + onRetry, + onTryHttps, + onCopy, + onOpenExternal, + certificateFailure, + expectedBrowserPageId, + onProceedCertificate +}: BrowserLoadFailureOverlayProps): React.JSX.Element { + const connectingTimerRef = useRef<{ + challengeId: string + timer: ReturnType + } | null>(null) + const [proceedAttempt, setProceedAttempt] = useState(null) + const matchingCertificateFailure = getMatchingCertificateFailure({ + loadError, + certificateFailure, + expectedBrowserPageId, + canProceed: Boolean(onProceedCertificate) + }) + // Why: Chromium's error-page fallback can replace the diagnostic code with + // -1 after main has already captured an exact live certificate challenge. + const presentationLoadError = + matchingCertificateFailure && loadError.code === -1 + ? { + ...loadError, + code: matchingCertificateFailure.errorCode ?? loadError.code, + description: matchingCertificateFailure.error + } + : loadError + const meta = getLoadErrorMetadata(presentationLoadError) + const certificateError = isCertificateLoadError(presentationLoadError) + const recoveryHint = formatLoadFailureRecoveryHint(meta, presentationLoadError) + const activeProceedAttempt = + proceedAttempt?.challengeId === matchingCertificateFailure?.challengeId ? proceedAttempt : null + const actionsDisabled = activeProceedAttempt?.state === 'submitting' + + useEffect(() => { + return () => { + if (connectingTimerRef.current) { + clearTimeout(connectingTimerRef.current.timer) + connectingTimerRef.current = null + } + } + }, [matchingCertificateFailure?.challengeId]) + + const proceedCertificate = (): void => { + if (!matchingCertificateFailure || !onProceedCertificate || actionsDisabled) { + return + } + const challengeId = matchingCertificateFailure.challengeId + setProceedAttempt({ challengeId, state: 'submitting', showConnecting: false }) + connectingTimerRef.current = { + challengeId, + timer: setTimeout(() => { + setProceedAttempt((current) => + current?.challengeId === challengeId && current.state === 'submitting' + ? { ...current, showConnecting: true } + : current + ) + }, 200) + } + void onProceedCertificate(challengeId) + .then((result) => { + if (connectingTimerRef.current?.challengeId === challengeId) { + clearTimeout(connectingTimerRef.current.timer) + connectingTimerRef.current = null + } + if (!result.ok) { + setProceedAttempt((current) => + current?.challengeId === challengeId + ? { + challengeId, + state: 'failed', + showConnecting: false, + reason: result.reason + } + : current + ) + } + }) + .catch(() => { + if (connectingTimerRef.current?.challengeId === challengeId) { + clearTimeout(connectingTimerRef.current.timer) + connectingTimerRef.current = null + } + setProceedAttempt((current) => + current?.challengeId === challengeId + ? { + challengeId, + state: 'failed', + showConnecting: false, + reason: 'request-failed' + } + : current + ) + }) + } + const retryButton = ( + + ) + const copyButton = ( + + ) + const externalButton = + externalUrl && onOpenExternal ? ( + + ) : null + + return ( +
+
+
+ {certificateError ? : } +
+

+ {certificateError + ? translate('browser.loadFailure.connectionNotSecure', "Connection isn't secure") + : meta.host + ? translate('browser.loadFailure.cantReachHost', "Can't reach {{value0}}", { + value0: meta.host + }) + : translate('browser.loadFailure.cantLoadPage', "Can't load this page")} +

+

+ {formatLoadFailureDescription(presentationLoadError, meta)} +

+ {certificateError && meta.isLocalhostLike ? ( +

+ {translate( + 'browser.loadFailure.trustedCertificateGuidance', + 'For local development, use a trusted local certificate when possible.' + )} +

+ ) : null} + {recoveryHint ?

{recoveryHint}

: null} + {activeProceedAttempt?.state === 'failed' && activeProceedAttempt.reason ? ( +

+ {formatCertificateProceedFailure(activeProceedAttempt.reason)} +

+ ) : null} +
+ {certificateError ? ( + <> + {externalButton} + {retryButton} + {copyButton} + {matchingCertificateFailure ? ( + + ) : null} + + ) : ( + <> + {httpsRecoveryUrl ? ( + + ) : null} + {retryButton} + {copyButton} + {externalButton} + + )} +
+
+
+ ) +} diff --git a/src/renderer/src/components/browser-pane/browser-notices.test.ts b/src/renderer/src/components/browser-pane/browser-notices.test.ts index b08bca7ba3c..24f31d3866a 100644 --- a/src/renderer/src/components/browser-pane/browser-notices.test.ts +++ b/src/renderer/src/components/browser-pane/browser-notices.test.ts @@ -5,7 +5,8 @@ import { formatLoadFailureDescription, formatLoadFailureRecoveryHint, formatPermissionNotice, - formatPopupNotice + formatPopupNotice, + isCertificateLoadError } from './browser-notices' describe('browser notice formatting', () => { @@ -114,4 +115,29 @@ describe('browser notice formatting', () => { }) ).toBeNull() }) + + it('formats certificate failures without local-server recovery advice', () => { + const meta = { host: 'localhost:3443', isLocalhostLike: true } + const loadError = (code: number) => ({ + code, + description: 'certificate error', + validatedUrl: 'https://localhost:3443/' + }) + + expect(formatLoadFailureDescription(loadError(-200), meta)).toBe( + "The certificate doesn't match localhost:3443." + ) + expect(formatLoadFailureDescription(loadError(-201), meta)).toBe( + "The certificate for localhost:3443 isn't valid at the current date and time." + ) + expect(formatLoadFailureDescription(loadError(-202), meta)).toBe( + "Orca doesn't trust the authority that issued the certificate for localhost:3443." + ) + expect(formatLoadFailureDescription(loadError(-208), meta)).toBe( + "Orca couldn't verify the certificate for localhost:3443." + ) + expect(isCertificateLoadError(loadError(-219))).toBe(true) + expect(isCertificateLoadError(loadError(-215))).toBe(false) + expect(formatLoadFailureRecoveryHint(meta, loadError(-202))).toBeNull() + }) }) diff --git a/src/renderer/src/components/browser-pane/browser-notices.ts b/src/renderer/src/components/browser-pane/browser-notices.ts index 81e31c8d81b..8c7b22800e0 100644 --- a/src/renderer/src/components/browser-pane/browser-notices.ts +++ b/src/renderer/src/components/browser-pane/browser-notices.ts @@ -4,8 +4,10 @@ import type { BrowserPopupEvent } from '../../../../shared/browser-guest-events' import type { BrowserLoadError } from '../../../../shared/types' +import { isChromiumCertificateErrorCode } from '../../../../shared/browser-certificate-errors' +import { translate } from '@/i18n/i18n' -type LoadFailureMeta = { +export type LoadFailureMeta = { host: string | null isLocalhostLike: boolean } @@ -73,6 +75,35 @@ export function formatLoadFailureDescription( if (!loadError) { return 'The page did not respond.' } + if (isChromiumCertificateErrorCode(loadError.code)) { + const host = meta.host ?? 'this address' + if (loadError.code === -200) { + return translate( + 'browser.loadFailure.certificateNameMismatch', + "The certificate doesn't match {{value0}}.", + { value0: host } + ) + } + if (loadError.code === -201) { + return translate( + 'browser.loadFailure.certificateDateInvalid', + "The certificate for {{value0}} isn't valid at the current date and time.", + { value0: host } + ) + } + if (loadError.code === -202) { + return translate( + 'browser.loadFailure.certificateAuthorityInvalid', + "Orca doesn't trust the authority that issued the certificate for {{value0}}.", + { value0: host } + ) + } + return translate( + 'browser.loadFailure.certificateVerificationFailed', + "Orca couldn't verify the certificate for {{value0}}.", + { value0: host } + ) + } if (meta.isLocalhostLike) { return "We couldn't connect to your local server." } @@ -82,9 +113,16 @@ export function formatLoadFailureDescription( return "We couldn't connect to this page." } -export function formatLoadFailureRecoveryHint(meta: LoadFailureMeta): string | null { - if (!meta.isLocalhostLike) { +export function formatLoadFailureRecoveryHint( + meta: LoadFailureMeta, + loadError?: BrowserLoadErrorLike +): string | null { + if (!meta.isLocalhostLike || (loadError && isChromiumCertificateErrorCode(loadError.code))) { return null } return 'If this should be a local app, make sure the server is running and listening on the expected port.' } + +export function isCertificateLoadError(loadError: BrowserLoadErrorLike): boolean { + return Boolean(loadError && isChromiumCertificateErrorCode(loadError.code)) +} diff --git a/src/renderer/src/components/editor/ImageViewer.tsx b/src/renderer/src/components/editor/ImageViewer.tsx index 408afd795b3..88b57b092b6 100644 --- a/src/renderer/src/components/editor/ImageViewer.tsx +++ b/src/renderer/src/components/editor/ImageViewer.tsx @@ -20,6 +20,7 @@ import { getZoomedImageLayoutSize } from './image-viewer-zoom' import { translate } from '@/i18n/i18n' +import { buildImageDataUri } from '../../../../shared/image-data-uri' const FALLBACK_IMAGE_MIME_TYPE = 'image/png' @@ -59,8 +60,8 @@ export default function ImageViewer({ const isPdf = mimeType === 'application/pdf' const isIntrinsicLayout = layout === 'intrinsic' const previewSrc = useMemo( - () => (cleanedContent && !isPdf ? `data:${mimeType};base64,${cleanedContent}` : null), - [cleanedContent, isPdf, mimeType] + () => buildImageDataUri(mimeType, cleanedContent), + [cleanedContent, mimeType] ) const imageError = previewSrc !== null && failedPreviewSrc === previewSrc const estimatedSize = useMemo(() => { diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx index 03213e9820e..a0699332738 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalWindowControls.tsx @@ -17,6 +17,8 @@ import { } from '../../../../shared/tui-agent-launch-defaults' import { translate } from '@/i18n/i18n' import { useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' +import { resolveNativeChatSessionOptionDefaults } from '../../../../shared/native-chat-session-option-defaults' +import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' type FloatingTerminalWindowControlsProps = { maximized: boolean @@ -73,6 +75,10 @@ export function FloatingTerminalWindowControls({ cmdOverrides: state.settings?.agentCmdOverrides ?? {}, agentArgs: resolveTuiAgentLaunchArgs(defaultAgent, state.settings?.agentDefaultArgs), agentEnv: resolveTuiAgentLaunchEnv(defaultAgent, state.settings?.agentDefaultEnv), + sessionOptions: resolveNativeChatSessionOptionDefaults( + state.settings?.nativeChatSessionOptions, + defaultAgent + ), platform: CLIENT_PLATFORM, allowEmptyPromptLaunch: true }) @@ -87,6 +93,7 @@ export function FloatingTerminalWindowControls({ return } const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID, undefined, undefined, { activate: false }) + seedNativeChatAppliedSessionOptions(tab.id, defaultAgent, startupPlan.sessionOptions) state.queueTabStartupCommand(tab.id, { command: startupPlan.launchCommand, ...(startupPlan.env ? { env: startupPlan.env } : {}), diff --git a/src/renderer/src/components/github/github-user-avatar.test.tsx b/src/renderer/src/components/github/github-user-avatar.test.tsx new file mode 100644 index 00000000000..5eeed156426 --- /dev/null +++ b/src/renderer/src/components/github/github-user-avatar.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it } from 'vitest' +import { GitHubUserAvatar, resolveGitHubUserAvatarSrc } from './github-user-avatar' + +function render(node: React.JSX.Element): { root: Root; container: HTMLDivElement } { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => { + root.render(node) + }) + return { root, container } +} + +describe('GitHubUserAvatar', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + + afterEach(() => { + act(() => root?.unmount()) + container?.remove() + root = null + container = null + }) + + it('renders the API avatar_url when provided (works for github.com and GHE)', () => { + const url = 'https://avatars.example.com/u/42?u=hash&v=4' + ;({ root, container } = render()) + const img = container.querySelector('img') + expect(img?.getAttribute('src')).toBe(url) + }) + + it('falls back to the login URL when no avatar_url is available', () => { + ;({ root, container } = render()) + const img = container.querySelector('img') + expect(img?.getAttribute('src')).toBe('https://github.com/octocat.png?size=64') + }) + + it('degrades to an initials placeholder when the image fails to load (GHE 404)', () => { + ;({ root, container } = render( + + )) + const img = container.querySelector('img') + expect(img).not.toBeNull() + act(() => { + img?.dispatchEvent(new Event('error')) + }) + expect(container.querySelector('img')).toBeNull() + expect(container.textContent).toBe('AL') + }) + + it('skips leading non-BMP chars so initials never render a broken surrogate', () => { + ;({ root, container } = render()) + act(() => { + container?.querySelector('img')?.dispatchEvent(new Event('error')) + }) + // Emoji word is skipped entirely rather than split into a broken half. + expect(container.textContent).toBe('D') + }) + + it('derives initials from the login when no name is given', () => { + ;({ root, container } = render()) + act(() => { + container?.querySelector('img')?.dispatchEvent(new Event('error')) + }) + expect(container.textContent).toBe('OC') + }) + + it('retries when avatarUrl changes after an earlier src failed (late-arriving avatar)', () => { + // Why: PR detail enrichment supplies avatar_url after first paint. A row that + // 404s on the early login-based URL must recover once the real URL lands. + ;({ root, container } = render()) + // First src is the login fallback; simulate the GHE 404. + act(() => { + container?.querySelector('img')?.dispatchEvent(new Event('error')) + }) + expect(container.querySelector('img')).toBeNull() + // Enriched avatar_url arrives → component must render the new image, not the + // latched placeholder. + const url = 'https://avatars.example.com/u/7?v=4' + act(() => { + root?.render() + }) + expect(container.querySelector('img')?.getAttribute('src')).toBe(url) + }) + + it('shows initials immediately when login and avatarUrl are both empty', () => { + ;({ root, container } = render()) + expect(container.querySelector('img')).toBeNull() + expect(container.textContent).toBe('AD') + }) +}) + +describe('resolveGitHubUserAvatarSrc', () => { + it('prefers trimmed API avatar over login.png', () => { + expect(resolveGitHubUserAvatarSrc('u', ' https://avatars.example.com/u/1?v=4 ')).toBe( + 'https://avatars.example.com/u/1?v=4' + ) + }) + + it('skips empty login so no github.com/.png request is built', () => { + expect(resolveGitHubUserAvatarSrc('', null)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/github/github-user-avatar.tsx b/src/renderer/src/components/github/github-user-avatar.tsx new file mode 100644 index 00000000000..385c7feda93 --- /dev/null +++ b/src/renderer/src/components/github/github-user-avatar.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react' +import { cn } from '@/lib/utils' +import { githubAvatarUrl } from '@/components/github/github-issue-comment-helpers' + +/** + * Build a 1-2 character initials placeholder from a display name or login, + * used when no avatar image is available or the image fails to load. + */ +function initialsFor(login: string, name?: string | null): string { + const source = (name?.trim() || login).trim() + if (!source) { + return '?' + } + // Iterate by code point (not UTF-16 unit) and keep only letters/digits, so a + // display name that leads with an emoji or other non-BMP char yields clean + // initials instead of a broken surrogate half. Words that are all symbols are + // skipped; an all-symbol name falls back to '?'. + const alnum = (word: string): string[] => [...word].filter((ch) => /[\p{L}\p{N}]/u.test(ch)) + const parts = source.split(/\s+/).filter(Boolean) + const letters = + parts.length >= 2 + ? parts + .map((word) => alnum(word)[0]) + .filter(Boolean) + .slice(0, 2) + .join('') + : alnum(source).slice(0, 2).join('') + return letters.toUpperCase() || '?' +} + +/** + * Prefer the API `avatar_url`, then the public login.png URL. Empty/whitespace + * API values and empty logins never produce a bogus request. See #8784. + */ +export function resolveGitHubUserAvatarSrc( + login: string, + avatarUrl?: string | null +): string | null { + const fromApi = avatarUrl?.trim() + if (fromApi) { + return fromApi + } + const trimmedLogin = login.trim() + if (!trimmedLogin) { + return null + } + return githubAvatarUrl(trimmedLogin) +} + +/** + * Avatar for a GitHub user that renders correctly on github.com and GitHub + * Enterprise. GHE logins don't exist on github.com, so the login-based + * `github.com/{login}.png` URL 404s. We prefer the API-provided `avatarUrl` + * and, when it (or the login fallback) fails to load, degrade to an initials + * placeholder instead of a broken image. See #8784. + */ +export function GitHubUserAvatar({ + login, + name, + avatarUrl, + title, + className +}: { + login: string + name?: string | null + avatarUrl?: string | null + title?: string + className?: string +}): React.JSX.Element { + // Why: track the specific src that failed rather than a boolean. Avatar data + // arrives after the first paint (PR detail enrichment), so a row that 404s on + // the early login-based URL must retry once the real avatar_url lands — a + // latched boolean would keep showing the placeholder forever. See #8784. + const [failedSrc, setFailedSrc] = useState(null) + const src = resolveGitHubUserAvatarSrc(login, avatarUrl) + if (src && failedSrc !== src) { + return ( + setFailedSrc(src)} + className={cn( + 'shrink-0 rounded-full border border-border/50 bg-muted object-cover', + className + )} + /> + ) + } + return ( + + {initialsFor(login, name)} + + ) +} diff --git a/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts new file mode 100644 index 00000000000..7b831270910 --- /dev/null +++ b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts @@ -0,0 +1,55 @@ +/** + * Issue #8784 — GHE PR avatars must prefer API avatar_url over github.com/{login}.png. + * + * Regression guard (was a repro that documented the broken path). After the fix: + * - resolveGitHubUserAvatarSrc prefers API URLs + * - PullRequestPage author uses authorAvatarUrl via GitHubUserAvatar + * - TaskPage ReviewChipAvatar no longer hardcodes github.com login.png + * + * Re-run: + * pnpm exec vitest run --config config/vitest.config.ts \ + * src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts + */ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { githubAvatarUrl } from './github-issue-comment-helpers' +import { resolveGitHubUserAvatarSrc } from './github-user-avatar' + +describe('issue #8784 GHE avatar fallback (regression)', () => { + it('prefers API avatar_url over login.png (GHE healthy path)', () => { + const api = 'https://ghe.example.com/avatars/u/42?v=4' + expect(resolveGitHubUserAvatarSrc('enterprise-only-user', api)).toBe(api) + }) + + it('trims whitespace-only avatarUrl and falls back to login.png for github.com', () => { + expect(resolveGitHubUserAvatarSrc('octocat', ' ')).toBe( + 'https://github.com/octocat.png?size=64' + ) + expect(resolveGitHubUserAvatarSrc('octocat', null)).toBe(githubAvatarUrl('octocat')) + }) + + it('returns null when neither avatarUrl nor login is usable (no bogus request)', () => { + expect(resolveGitHubUserAvatarSrc('', '')).toBeNull() + expect(resolveGitHubUserAvatarSrc(' ', null)).toBeNull() + }) + + it('login-only fallback still hardcodes public github.com png (github.com path)', () => { + // Why: github.com users without avatar_url still use this; GHE relies on + // enrichment + image onError → initials when this 404s. + expect(githubAvatarUrl('corp-user')).toBe('https://github.com/corp-user.png?size=64') + }) + + it('source routes PR author/reviewer avatars through GitHubUserAvatar + authorAvatarUrl', () => { + const prPage = readFileSync(join(__dirname, '../PullRequestPage.tsx'), 'utf8') + expect(prPage).toMatch(/GitHubUserAvatar/) + expect(prPage).toMatch(/authorAvatarUrl/) + // Why: author chip must not ignore API avatar_url and only pass login. + expect(prPage).not.toMatch(/githubAvatarUrl\(workItem\.author\)/) + + const taskPage = readFileSync(join(__dirname, '../TaskPage.tsx'), 'utf8') + expect(taskPage).toMatch(/GitHubUserAvatar/) + // Why: list chip must not hardcode github.com/{login}.png. + expect(taskPage).not.toMatch(/github\.com\/\$\{reviewer\.login\}\.png/) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index c8be699f8f6..f1f1794cc3b 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -1,21 +1,48 @@ // @vitest-environment happy-dom -import { act, cleanup, render } from '@testing-library/react' +import { act, cleanup, render, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + SessionOptionDescriptor, + SessionOptionsSurface +} from '../../../../shared/native-chat-session-options' +import { clearNativeChatSessionOptionCacheForTests } from './native-chat-session-option-cache' const mocks = vi.hoisted(() => ({ cancelPendingSends: vi.fn(), - fieldProps: null as { onSend?: () => void; onStop?: () => void } | null, + fieldProps: null as { + onSend?: () => void + onStop?: () => void + sessionOptionsSurface?: SessionOptionsSurface | null + sessionOptionsSnapshot?: SessionOptionDescriptor[] + } | null, + modelSwitchOutcome: 'applied' as 'applied' | 'rejected' | 'interaction-required' | 'unknown', + confirmationObserver: null as { + ready: Promise + result: Promise<'applied' | 'rejected' | 'interaction-required' | 'unknown'> + arm: ReturnType + startDetection: ReturnType + dispose: ReturnType + } | null, + createClaudeModelSwitchConfirmationObserver: vi.fn(), + getMainBufferSnapshot: vi.fn(), sendHandle: { cancel: vi.fn(), settleAfterMs: 500 }, sendNativeChatMessage: vi.fn(), + sendNativeChatMessageVerified: vi.fn(), trackPendingSend: vi.fn(), setDraft: vi.fn() })) -vi.mock('../../store', () => ({ - useAppStore: (selector: (state: unknown) => unknown) => - selector({ dictationState: 'idle', settings: { voice: { enabled: false } } }) -})) +vi.mock('../../store', () => { + const state = { + dictationState: 'idle', + settings: { voice: { enabled: false }, nativeChatSessionOptions: {} }, + updateSettings: vi.fn() + } + const useAppStore = (selector: (value: typeof state) => unknown) => selector(state) + useAppStore.getState = () => state + return { useAppStore } +}) vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: () => false, @@ -26,15 +53,27 @@ vi.mock('@/lib/agent-paste-draft', () => ({ })) vi.mock('./native-chat-runtime-send', () => ({ sendNativeChatMessage: (...args: unknown[]) => mocks.sendNativeChatMessage(...args), + sendNativeChatMessageVerified: (...args: unknown[]) => + mocks.sendNativeChatMessageVerified(...args), sendNativeChatMessageWithImageAttachments: vi.fn(), submitNativeChatPrompt: vi.fn() })) -vi.mock('./native-chat-agent-commands', () => ({ getAgentSlashCommands: () => [] })) -vi.mock('@/lib/native-chat-telemetry', () => ({ emitNativeChatMessageSent: vi.fn() })) +vi.mock('./claude-model-switch-confirmation', () => ({ + createClaudeModelSwitchConfirmationObserver: (...args: unknown[]) => + mocks.createClaudeModelSwitchConfirmationObserver(...args) +})) +vi.mock('./native-chat-agent-commands', () => ({ + getAgentSlashCommands: () => [] +})) +vi.mock('@/lib/native-chat-telemetry', () => ({ + emitNativeChatMessageSent: vi.fn() +})) vi.mock('./use-native-chat-draft', () => ({ useNativeChatDraft: () => ({ draft: 'hello', setDraft: mocks.setDraft }) })) -vi.mock('./native-chat-draft-cache', () => ({ readNativeChatDraftCache: () => '' })) +vi.mock('./native-chat-draft-cache', () => ({ + readNativeChatDraftCache: () => '' +})) vi.mock('./NativeChatComposerField', () => ({ NativeChatComposerField: (props: { onSend?: () => void; onStop?: () => void }) => { mocks.fieldProps = props @@ -51,7 +90,10 @@ vi.mock('./use-native-chat-composer-attachments', () => ({ }) })) vi.mock('./use-native-chat-composer-paste', () => ({ - useNativeChatComposerPaste: () => ({ handlePaste: vi.fn(), pasteFromClipboard: vi.fn() }) + useNativeChatComposerPaste: () => ({ + handlePaste: vi.fn(), + pasteFromClipboard: vi.fn() + }) })) vi.mock('./use-native-chat-external-attachments', () => ({ useNativeChatExternalAttachments: () => ({ @@ -59,7 +101,9 @@ vi.mock('./use-native-chat-external-attachments', () => ({ resolveAttachmentOwner: vi.fn() }) })) -vi.mock('../dictation/dictation-control-events', () => ({ dispatchDictationControl: vi.fn() })) +vi.mock('../dictation/dictation-control-events', () => ({ + dispatchDictationControl: vi.fn() +})) vi.mock('./use-native-chat-composer-keydown', () => ({ useNativeChatComposerKeyDown: () => vi.fn() })) @@ -75,11 +119,31 @@ import { NativeChatComposer } from './NativeChatComposer' describe('NativeChatComposer', () => { beforeEach(() => { vi.clearAllMocks() + clearNativeChatSessionOptionCacheForTests() mocks.fieldProps = null + mocks.modelSwitchOutcome = 'applied' + mocks.confirmationObserver = null + mocks.createClaudeModelSwitchConfirmationObserver.mockImplementation(() => { + const observer = { + ready: Promise.resolve(), + result: Promise.resolve(mocks.modelSwitchOutcome), + arm: vi.fn(), + startDetection: vi.fn(), + dispose: vi.fn() + } + mocks.confirmationObserver = observer + return observer + }) + mocks.getMainBufferSnapshot.mockResolvedValue(null) mocks.sendNativeChatMessage.mockReturnValue(mocks.sendHandle) + mocks.sendNativeChatMessageVerified.mockResolvedValue(true) + mocks.sendHandle.settleAfterMs = 500 Object.defineProperty(window, 'api', { configurable: true, - value: { ui: { onFileDrop: () => vi.fn() } } + value: { + pty: { getMainBufferSnapshot: mocks.getMainBufferSnapshot }, + ui: { onFileDrop: () => vi.fn() } + } }) }) @@ -122,4 +186,201 @@ describe('NativeChatComposer', () => { expect(onOptimisticSend).toHaveBeenCalledWith('hello', []) expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1') }) + + it('shows the model already selected in the Claude TUI when chat opens', async () => { + mocks.getMainBufferSnapshot.mockResolvedValue({ + data: 'Claude Code v2.1.211\r\nOpus 4.8 with medium effort · API Usage Billing', + cols: 120, + rows: 40 + }) + render( + null} + /> + ) + + await waitFor(() => + expect(mocks.fieldProps?.sessionOptionsSnapshot).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'model', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'opus' }) + }), + expect.objectContaining({ + id: 'effort', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'medium' }) + }) + ]) + ) + ) + expect(mocks.getMainBufferSnapshot).toHaveBeenCalledWith('pty-1', { scrollbackRows: 0 }) + }) + + it('reads Claude state from mounted xterm while its alternate screen is active', async () => { + mocks.getMainBufferSnapshot.mockResolvedValue({ + data: 'Claude Code v2.1.211\r\nOpus 4.8 with high effort · stale main buffer', + cols: 120, + rows: 40, + alternateScreen: true + }) + render( + + '\u001b[?1049h\u001b[HClaude Codev2.1.211\r\n' + + 'Sonnet 5 with medium effort · API Usage Billing' + } + /> + ) + + await waitFor(() => + expect(mocks.fieldProps?.sessionOptionsSnapshot).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'model', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'sonnet' }) + }), + expect.objectContaining({ + id: 'effort', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'medium' }) + }) + ]) + ) + ) + }) + + it('observes a fresh Claude model choice and stays native on success', async () => { + mocks.sendHandle.settleAfterMs = 0 + const onSlashCommand = vi.fn() + const onOptimisticSend = vi.fn() + const onSwitchToTerminal = vi.fn() + render( + + ) + + await act(async () => { + await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'opus') + }) + + expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith( + {}, + 'pty-1', + '/model opus', + expect.any(AbortSignal) + ) + expect(onSlashCommand).toHaveBeenCalledWith('/model opus') + expect(onOptimisticSend).not.toHaveBeenCalled() + expect(mocks.createClaudeModelSwitchConfirmationObserver).toHaveBeenCalledWith({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Opus 4.8' + }) + expect(onSwitchToTerminal).not.toHaveBeenCalled() + }) + + it('keeps a successful Claude model change after a conversation in native chat', async () => { + mocks.sendHandle.settleAfterMs = 0 + const onSwitchToTerminal = vi.fn() + render( + + ) + + await act(async () => { + await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'fable') + }) + + expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith( + {}, + 'pty-1', + '/model fable', + expect.any(AbortSignal) + ) + expect(mocks.createClaudeModelSwitchConfirmationObserver).toHaveBeenCalledWith({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5' + }) + expect(mocks.confirmationObserver?.arm).toHaveBeenCalledOnce() + expect(mocks.confirmationObserver?.arm.mock.invocationCallOrder[0]).toBeLessThan( + mocks.sendNativeChatMessageVerified.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + expect(mocks.confirmationObserver?.startDetection).toHaveBeenCalledOnce() + expect(mocks.confirmationObserver?.startDetection.mock.invocationCallOrder[0]).toBeGreaterThan( + mocks.sendNativeChatMessageVerified.mock.invocationCallOrder[0] ?? Number.NEGATIVE_INFINITY + ) + expect(mocks.confirmationObserver?.dispose).toHaveBeenCalledOnce() + expect(onSwitchToTerminal).not.toHaveBeenCalled() + }) + + it('reveals Claude interaction only when the model switch needs user input', async () => { + mocks.sendHandle.settleAfterMs = 0 + mocks.modelSwitchOutcome = 'interaction-required' + const onSwitchToTerminal = vi.fn() + render( + + ) + + await act(async () => { + await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', 'fable') + }) + + expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith( + {}, + 'pty-1', + '/model fable', + expect.any(AbortSignal) + ) + expect(onSwitchToTerminal).toHaveBeenCalledOnce() + }) + + it('waits for the Codex picker command before switching to the terminal', async () => { + mocks.sendHandle.settleAfterMs = 0 + const onSwitchToTerminal = vi.fn() + render( + + ) + + await act(async () => { + await mocks.fieldProps?.sessionOptionsSurface?.setOption('model', '') + }) + + expect(mocks.sendNativeChatMessageVerified).toHaveBeenCalledWith( + {}, + 'pty-1', + '/model', + expect.any(AbortSignal) + ) + expect(onSwitchToTerminal).toHaveBeenCalledOnce() + }) }) diff --git a/src/renderer/src/components/native-chat/NativeChatComposer.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.tsx index 41b254639dc..dd624ba06ed 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.tsx @@ -1,15 +1,6 @@ -import { - forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState -} from 'react' +import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useAppStore } from '../../store' import type { AgentType } from '../../../../shared/agent-status-types' -import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop' import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import { @@ -43,9 +34,12 @@ import { useNativeChatSkills } from './use-native-chat-skills' import { useNativeChatComposerAttachments } from './use-native-chat-composer-attachments' import { useNativeChatComposerPaste } from './use-native-chat-composer-paste' import { useNativeChatExternalAttachments } from './use-native-chat-external-attachments' -import { dispatchDictationControl } from '../dictation/dictation-control-events' import { useNativeChatComposerKeyDown } from './use-native-chat-composer-keydown' import { useNativeChatSendLifecycle } from './use-native-chat-send-lifecycle' +import { useNativeChatSessionOptions } from './use-native-chat-session-options' +import { useNativeChatFileAttachmentActions } from './use-native-chat-file-attachment-actions' +import { useNativeChatDictationActions } from './use-native-chat-dictation-actions' +import { useNativeChatSessionOptionCommand } from './use-native-chat-session-option-command' // Why: a plain ESC byte is what the agent TUIs read as the interrupt key over a // PTY (matching how xterm forwards Escape). The richer interrupt-intent @@ -79,6 +73,10 @@ export type NativeChatComposerProps = { * a small "Ran /clear" system line — slash commands aren't chat turns and * otherwise leave no visible trace that anything happened. */ onSlashCommand?: (command: string) => void + /** Picker-only agent commands continue in the hosted TUI after dispatch. */ + onSwitchToTerminal?: () => void + /** Reads the hosted TUI's current rendered screen when chat is entered. */ + readTerminalScreen?: () => string | null } export type NativeChatComposerHandle = { @@ -116,7 +114,9 @@ export const NativeChatComposer = forwardRef { - return window.api.ui.onFileDrop((payload) => { - if (payload.target !== NATIVE_FILE_DROP_TARGET.composer) { - return - } - attachExternalPaths(payload.paths) + const { pickAttachment } = useNativeChatFileAttachmentActions(attachExternalPaths) + const { toggleDictation, startHoldDictation, stopHoldDictation } = + useNativeChatDictationActions({ textareaRef, setDictationPressed }) + const { dispatch: dispatchSessionOptionCommand, isDispatching: isDispatchingSessionOption } = + useNativeChatSessionOptionCommand({ + agent, + disabled, + onSlashCommand, + resolveTarget, + setHistory }) - }, [attachExternalPaths]) - const pickAttachment = useCallback(() => { - void (async () => { - const filePath = await window.api.shell.pickAttachment() - if (!filePath) { - return - } - attachExternalPaths([filePath]) - })() - }, [attachExternalPaths]) - - const focusForDictation = useCallback(() => { - textareaRef.current?.focus() - }, []) - - const toggleDictation = useCallback(() => { - focusForDictation() - dispatchDictationControl('toggle') - }, [focusForDictation]) - - const startHoldDictation = useCallback(() => { - setDictationPressed(true) - focusForDictation() - dispatchDictationControl('start') - }, [focusForDictation]) - - const stopHoldDictation = useCallback(() => { - setDictationPressed(false) - dispatchDictationControl('stop') - }, []) + const { surface: sessionOptionsSurface, snapshot: sessionOptionsSnapshot } = + useNativeChatSessionOptions({ + agent, + terminalTabId, + targetPtyId, + dispatchCommand: dispatchSessionOptionCommand, + onAgentPicker: onSwitchToTerminal, + readTerminalScreen + }) const send = useCallback(() => { const text = draft @@ -293,6 +276,12 @@ export const NativeChatComposer = forwardRef ) } diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx new file mode 100644 index 00000000000..2e1b02ac249 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + variant: _variant, + size: _size, + ...props + }: { + children: ReactNode + variant?: string + size?: string + } & React.ButtonHTMLAttributes) => +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) =>
{children}
+})) + +vi.mock('./NativeChatSessionOptionPickers', () => ({ + NativeChatSessionOptionPickers: () =>
+})) + +import { NativeChatComposerActions } from './NativeChatComposerActions' + +afterEach(() => cleanup()) + +describe('NativeChatComposerActions', () => { + it('places session option pickers immediately beside dictation', () => { + render( + + ) + + const pickers = screen.getByTestId('session-option-pickers') + const dictation = screen.getByRole('button', { name: 'Start dictation' }) + expect(pickers.nextElementSibling).toBe(dictation) + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx index fb67917b332..aee0f79c3c0 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposerActions.tsx @@ -2,6 +2,11 @@ import { ArrowUp, Mic, Plus, Square } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' +import type { + SessionOptionDescriptor, + SessionOptionsSurface +} from '../../../../shared/native-chat-session-options' +import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers' export type NativeChatComposerActionsProps = { attachDisabled: boolean @@ -16,6 +21,8 @@ export type NativeChatComposerActionsProps = { onDictationHoldEnd: () => void onSend: () => void onStop?: () => void + sessionOptionsSurface: SessionOptionsSurface | null + sessionOptionsSnapshot: SessionOptionDescriptor[] } export function NativeChatComposerActions({ @@ -30,32 +37,43 @@ export function NativeChatComposerActions({ onDictationHoldStart, onDictationHoldEnd, onSend, - onStop + onStop, + sessionOptionsSurface, + sessionOptionsSnapshot }: NativeChatComposerActionsProps): React.JSX.Element { const dictationLabel = isDictating ? translate('components.native-chat.composer.stopDictation', 'Stop dictation') : translate('components.native-chat.composer.startDictation', 'Start dictation') return (
- - - - - - {translate('components.native-chat.composer.attach', 'Attach file')} - - +
+ + + + + + {translate('components.native-chat.composer.attach', 'Attach file')} + + +
+ {/* Why: keep session controls beside the actions they affect; the + model trigger is ordered last so it sits directly next to dictation. */} +
diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx new file mode 100644 index 00000000000..8d40a791788 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.test.tsx @@ -0,0 +1,230 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + fallback.replace('{{value0}}', values?.value0 ?? '') +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ) +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) =>
{children}
+})) + +vi.mock('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuTrigger: ({ + children, + disabled + }: { + children: React.ReactNode + disabled?: boolean + }) =>
{children}
, + DropdownMenuContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuLabel: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuSeparator: () =>
, + DropdownMenuItem: ({ children, disabled }: React.ButtonHTMLAttributes) => ( + + ), + DropdownMenuCheckboxItem: ({ + children, + disabled + }: React.ButtonHTMLAttributes) => ( + + ), + DropdownMenuRadioGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, + DropdownMenuRadioItem: ({ + children, + disabled, + value + }: React.ButtonHTMLAttributes & { value: string }) => ( + + ) +})) + +import { NativeChatSessionOptionPickers } from './NativeChatSessionOptionPickers' + +const surface = { + getSnapshot: vi.fn(() => []), + setOption: vi.fn(), + subscribe: vi.fn(() => vi.fn()) +} + +function model(overrides: Partial = {}): SessionOptionDescriptor { + return { + id: 'model', + label: 'Model', + category: 'model', + kind: { + type: 'select', + currentValue: 'opus', + choices: [ + { value: 'opus', label: 'Opus 4.8' }, + { value: 'sonnet', label: 'Sonnet 5' } + ] + }, + valueSource: 'applied', + settable: true, + ...overrides + } +} + +const effort: SessionOptionDescriptor = { + id: 'effort', + label: 'Effort', + category: 'thought_level', + kind: { + type: 'select', + currentValue: 'high', + choices: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + }, + valueSource: 'applied', + settable: true +} + +const fast: SessionOptionDescriptor = { + id: 'fastMode', + label: 'Fast mode', + category: 'mode', + kind: { type: 'boolean', currentValue: true }, + valueSource: 'applied', + settable: true +} + +afterEach(() => cleanup()) + +describe('NativeChatSessionOptionPickers', () => { + it('renders model and joined option labels, and hides an empty options pill', () => { + const { rerender } = render( + + ) + expect(screen.getByRole('button', { name: 'Model' }).textContent).toContain('Model: Opus 4.8') + expect(screen.getByRole('button', { name: 'Effort' }).textContent).toContain( + 'Effort: High · Fast' + ) + expect( + screen + .getByRole('button', { name: 'Effort' }) + .compareDocumentPosition(screen.getByRole('button', { name: 'Model' })) & + Node.DOCUMENT_POSITION_FOLLOWING + ).not.toBe(0) + + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Effort' })).toBeNull() + }) + + it('names a lone unknown effort control explicitly', () => { + render( + + ) + + expect(screen.getByRole('button', { name: 'Effort' }).textContent).toContain('Effort') + }) + + it('disables both picker triggers while the agent is working', () => { + render( + + ) + expect( + screen.getByRole('button', { name: 'Model' }).parentElement?.getAttribute('data-disabled') + ).toBe('true') + expect( + screen.getByRole('button', { name: 'Effort' }).parentElement?.getAttribute('data-disabled') + ).toBe('true') + }) + + it('does not duplicate titles for unknown values or misname generic controls', () => { + const { rerender } = render( + + ) + expect(screen.getByRole('button', { name: 'Model' }).textContent).toContain('Model') + expect(screen.getByRole('button', { name: 'Model' }).textContent).not.toContain('Model: Model') + expect(screen.getByRole('button', { name: 'Effort' }).textContent).not.toContain( + 'Effort: Effort' + ) + + rerender( + + ) + expect(screen.getByRole('button', { name: 'Session options' }).textContent).toContain('Fast') + expect(screen.queryByRole('button', { name: 'Effort' })).toBeNull() + }) + + it('shows the unconfirmed hint for dispatched values', () => { + render( + + ) + expect(screen.getByText('Sent to the agent — not confirmed')).not.toBeNull() + }) + + it('renders agent-picker routes as one action instead of radio choices', () => { + render( + + ) + expect(screen.getByRole('button', { name: 'Choose in agent picker…' })).not.toBeNull() + expect(screen.queryByText('GPT-5.5')).toBeNull() + expect(screen.queryByText('GPT-5.2 Codex')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx new file mode 100644 index 00000000000..15852d27db5 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatSessionOptionPickers.tsx @@ -0,0 +1,276 @@ +import { memo, useState } from 'react' +import { ChevronDown } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import type { + SessionOptionDescriptor, + SessionOptionsSurface, + SessionOptionValue +} from '../../../../shared/native-chat-session-options' +import { + nativeChatModelPillLabel, + nativeChatOptionsPillLabel, + nativeChatOptionsPillTitle, + nativeChatSessionChoiceLabel, + nativeChatSessionOptionDisabledReason, + nativeChatSessionOptionLabel +} from './native-chat-session-option-labels' + +export type NativeChatSessionOptionPickersProps = { + surface: SessionOptionsSurface | null + snapshot: SessionOptionDescriptor[] + isWorking: boolean +} + +const CATEGORY_ORDER: Record = { + thought_level: 0, + model_config: 1, + mode: 2 +} + +function sortedOptions(snapshot: readonly SessionOptionDescriptor[]): SessionOptionDescriptor[] { + return snapshot + .filter((descriptor) => descriptor.category !== 'model') + .sort((left, right) => { + const leftOrder = CATEGORY_ORDER[left.category ?? ''] ?? 3 + const rightOrder = CATEGORY_ORDER[right.category ?? ''] ?? 3 + return leftOrder - rightOrder + }) +} + +function PickerTooltipContent(props: { + label: string + disabledReason?: string | null + dispatched: boolean +}): React.JSX.Element { + return ( +
+
{props.disabledReason ?? props.label}
+ {props.dispatched ? ( +
+ {translate( + 'components.native-chat.composer.sentNotConfirmed', + 'Sent to the agent — not confirmed' + )} +
+ ) : null} +
+ ) +} + +function PickerTrigger(props: { + label: string + tooltipLabel: string + disabled: boolean + disabledReason?: string | null + dispatched: boolean +}): React.JSX.Element { + return ( + + + + + + + + + + + ) +} + +function ChoiceBody(props: { label: string; description?: string }): React.JSX.Element { + return ( +
+
{props.label}
+ {props.description ? ( +
{props.description}
+ ) : null} +
+ ) +} + +function actionLabel(descriptor: SessionOptionDescriptor): string { + if (descriptor.action?.type === 'agent-picker') { + return translate( + 'components.native-chat.composer.chooseInAgentPicker', + 'Choose in agent picker…' + ) + } + return translate('components.native-chat.composer.toggleOption', 'Toggle {{value0}}', { + value0: nativeChatSessionOptionLabel(descriptor).toLowerCase() + }) +} + +function DescriptorMenuRows(props: { + descriptor: SessionOptionDescriptor + pending: boolean + setValue: (value: SessionOptionValue) => void +}): React.JSX.Element { + const { descriptor, pending, setValue } = props + if (descriptor.action) { + return ( + + setValue( + descriptor.kind.type === 'boolean' + ? !(descriptor.kind.currentValue ?? false) + : (descriptor.kind.currentValue ?? '') + ) + } + > + {actionLabel(descriptor)} + + ) + } + if (descriptor.kind.type === 'boolean') { + return ( + setValue(checked === true)} + > + {nativeChatSessionOptionLabel(descriptor)} + + ) + } + return ( + setValue(value)} + > + {descriptor.kind.choices.map((choice) => ( + + + + ))} + + ) +} + +function NativeChatSessionOptionPickersInner({ + surface, + snapshot, + isWorking +}: NativeChatSessionOptionPickersProps): React.JSX.Element | null { + const [pendingId, setPendingId] = useState(null) + const model = snapshot.find((descriptor) => descriptor.category === 'model') + const options = sortedOptions(snapshot) + if (!surface || !model) { + return null + } + + const setOption = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => { + setPendingId(descriptor.id) + void surface + .setOption(descriptor.id, value) + .catch((error) => { + toast.error( + translate( + 'components.native-chat.composer.optionUpdateFailed', + 'Could not update option' + ), + { description: error instanceof Error ? error.message : String(error) } + ) + }) + .finally(() => setPendingId(null)) + } + + const modelReason = nativeChatSessionOptionDisabledReason(model.disabledReason) + const modelTooltip = translate('components.native-chat.composer.model', 'Model') + const optionsTooltip = nativeChatOptionsPillTitle(options) + const optionsReason = + options.length > 0 && options.every((descriptor) => !descriptor.settable) + ? nativeChatSessionOptionDisabledReason(options[0]?.disabledReason) + : null + + return ( +
+ {options.length > 0 ? ( + + descriptor.valueSource === 'dispatched')} + /> + + {options.map((descriptor, index) => { + const reason = nativeChatSessionOptionDisabledReason(descriptor.disabledReason) + return ( +
+ {index > 0 ? : null} + {nativeChatSessionOptionLabel(descriptor)} + {reason && !descriptor.settable ? ( + {reason} + ) : null} + setOption(descriptor, value)} + /> +
+ ) + })} +
+
+ ) : null} + + + + {modelReason && !model.settable ? ( + {modelReason} + ) : null} + setOption(model, value)} + /> + + +
+ ) +} + +export const NativeChatSessionOptionPickers = memo(NativeChatSessionOptionPickersInner) diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index 8caf7d835c8..d2267281eca 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -48,14 +48,10 @@ import { useNativeChatContextMenu } from './use-native-chat-context-menu' import type { NativeChatContextMenuActions } from './use-native-chat-context-menu' -import { - resolveNativeChatFileLink, - resolveNativeChatFileLinkContext -} from './native-chat-file-link' +import { resolveNativeChatFileLinkContext } from './native-chat-file-link' import { selectNativeChatRuntimeEnvironmentId } from './native-chat-runtime-owner' import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge' -import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' -import { openDetectedFilePath } from '@/components/terminal-pane/terminal-file-open-routing' +import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click' export type NativeChatViewProps = { /** The terminal tab hosting the agent. paneKey is `${tabId}:${leafId}`. */ @@ -70,17 +66,12 @@ export type NativeChatViewProps = { resolvedAgent?: TuiAgent | null /** Return this pane to the hosted terminal surface. */ onSwitchToTerminal?: () => void + /** Current xterm screen reader used to recover agent-reported session state. */ + readTerminalScreen?: () => string | null contextMenuActions?: Omit } -/** - * Native chat surface for an agent terminal. Resolves the pane to its agent + - * session id, streams the assembled conversation via the U4 live-session hook, - * and renders the message list, live status, and all empty/loading/error - * states. When no session id is known yet the hook surfaces live hook state on - * an empty transcript; a true scrollback-scrape fallback (U6) is wired but only - * runs when scrollback is obtainable — it degrades to the empty state otherwise. - */ +/** Resolves an agent terminal into its native conversation and composer UI. */ export default function NativeChatView({ terminalTabId, paneKey: preferredPaneKey, @@ -88,6 +79,7 @@ export default function NativeChatView({ launchAgent, resolvedAgent, onSwitchToTerminal, + readTerminalScreen, contextMenuActions }: NativeChatViewProps): React.JSX.Element { // Select only this tab's status entry (shallow-compared) so an unrelated @@ -120,6 +112,7 @@ export default function NativeChatView({ targetPtyId={targetPtyId} terminalTabId={terminalTabId} onSwitchToTerminal={onSwitchToTerminal} + readTerminalScreen={readTerminalScreen} contextMenuActions={contextMenuActions} /> )} @@ -135,6 +128,7 @@ function NativeChatResolvedView({ targetPtyId, terminalTabId, onSwitchToTerminal, + readTerminalScreen, contextMenuActions }: { paneKey: string @@ -144,6 +138,7 @@ function NativeChatResolvedView({ targetPtyId: string | null terminalTabId: string onSwitchToTerminal?: () => void + readTerminalScreen?: () => string | null contextMenuActions?: Omit }): React.JSX.Element { // Primitive owner selection (no useShallow): routes the pane's read/subscribe to @@ -356,24 +351,7 @@ function NativeChatResolvedView({ setWorkingInterrupted(true) interactiveSend.cancel() }, [interactiveSend]) - const openNativeChatFileLink = useCallback( - (event, href) => { - const target = resolveNativeChatFileLink(href, fileLinkContext) - if (!target || !fileLinkContext) { - return - } - event.preventDefault() - event.stopPropagation() - openDetectedFilePath(target.absolutePath, target.line, target.column, { - worktreeId: fileLinkContext.worktreeId, - worktreePath: fileLinkContext.worktreePath, - runtimeEnvironmentId: fileLinkContext.runtimeEnvironmentId, - openWithSystemDefault: event.shiftKey - }) - }, - [fileLinkContext] - ) - const nativeChatFileLinkClick = fileLinkContext ? openNativeChatFileLink : undefined + const nativeChatFileLinkClick = useNativeChatFileLinkClick(fileLinkContext) // Chat-only font zoom via Cmd/Ctrl +/-/0, gated to the live conversation so // the chord is inert on the loading/empty/error states and elsewhere. @@ -460,6 +438,8 @@ function NativeChatResolvedView({ onOptimisticSend={onOptimisticSend} onOptimisticSendCanceled={onOptimisticSendCanceled} onSlashCommand={onSlashCommand} + onSwitchToTerminal={onSwitchToTerminal} + readTerminalScreen={readTerminalScreen} /> )} {contextMenu.menu} diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts new file mode 100644 index 00000000000..e57f42e2efe --- /dev/null +++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts @@ -0,0 +1,176 @@ +// @vitest-environment happy-dom + +import { describe, expect, it, vi } from 'vitest' +import { + createClaudeModelSwitchConfirmationObserver, + hasClaudeModelSwitchConfirmation +} from './claude-model-switch-confirmation' + +describe('Claude model switch confirmation detection', () => { + it('recognizes the rendered cache warning across ANSI and wrapped whitespace', () => { + expect( + hasClaudeModelSwitchConfirmation( + '\u001b[1mSwitch model?\u001b[0m\r\nThis\u001b[9Gconversation\u001b[22Gis\u001b[25Gcached\r\nfor the current model.' + ) + ).toBe(true) + expect(hasClaudeModelSwitchConfirmation('Set model to Haiku 4.5')).toBe(false) + }) + + it('reports matching success output split across PTY chunks', async () => { + const dataObserver = { current: (_data: string): void => {} } + const unsubscribe = vi.fn(() => {}) + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: (watcher) => { + dataObserver.current = watcher + return unsubscribe + }, + timeoutMs: 100 + }) + + await observer.ready + dataObserver.current('historical Set model to Fable 5') + observer.arm() + dataObserver.current('\u001b[2mSet\u001b[10Gmo') + dataObserver.current('del\u001b[16Gto\u001b[19GFable 5 and saved as your default\u001b[0m') + + await expect(observer.result).resolves.toBe('applied') + expect(unsubscribe).toHaveBeenCalledOnce() + }) + + it('accepts the exact cached-history confirmation once and keeps observing', async () => { + const dataObserver = { current: (_data: string): void => {} } + const submitConfirmation = vi.fn() + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: (watcher) => { + dataObserver.current = watcher + return vi.fn(() => {}) + }, + submitConfirmation, + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + dataObserver.current('Switch model? This\u001b[9Gconversation\u001b[22Gis\u001b[25Gcached ') + dataObserver.current('for\u001b[36Gthe\u001b[40Gcurrent\u001b[48Gmodel.') + dataObserver.current(' redraw of the same Switch model? prompt') + + expect(submitConfirmation).toHaveBeenCalledOnce() + dataObserver.current('Set model to Fable 5 and saved as your default for new sessions') + + await expect(observer.result).resolves.toBe('applied') + }) + + it('reports a canceled model switch without opening an interaction', async () => { + const dataObserver = { current: (_data: string): void => {} } + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Haiku', + subscribeToData: (watcher) => { + dataObserver.current = watcher + return vi.fn(() => {}) + }, + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + dataObserver.current('\u001b[2mKept\u001b[9Gmodel\u001b[16Gas ') + dataObserver.current('Fable 5\u001b[0m') + + await expect(observer.result).resolves.toBe('rejected') + }) + + it('requests interaction for Fable one-time usage-credit consent', async () => { + const dataObserver = { current: (_data: string): void => {} } + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: (watcher) => { + dataObserver.current = watcher + return vi.fn(() => {}) + }, + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + dataObserver.current('Fable 5 uses usage credits and needs a one-time consent — ') + dataObserver.current('pick Fable from /model in an interactive session to set it up') + + await expect(observer.result).resolves.toBe('interaction-required') + }) + + it('reports unknown when the PTY observer cannot be established', async () => { + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: () => Promise.reject(new Error('unavailable')), + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + await expect(observer.result).resolves.toBe('unknown') + }) + + it('reports unknown on timeout instead of requesting the terminal', async () => { + vi.useFakeTimers() + try { + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: () => vi.fn(() => {}), + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + observer.startDetection() + await vi.advanceTimersByTimeAsync(100) + await expect(observer.result).resolves.toBe('unknown') + } finally { + vi.useRealTimers() + } + }) + + it('does not start the detection timeout until startDetection() is called', async () => { + vi.useFakeTimers() + try { + let settled = false + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Fable 5', + subscribeToData: () => vi.fn(() => {}), + timeoutMs: 100 + }) + void observer.result.then(() => { + settled = true + }) + + await observer.ready + observer.arm() + // Simulates slow SSH send latency between arm() and command delivery: the + // clock must not run yet, or a successful switch would time out to unknown. + await vi.advanceTimersByTimeAsync(500) + expect(settled).toBe(false) + + observer.startDetection() + await vi.advanceTimersByTimeAsync(100) + await expect(observer.result).resolves.toBe('unknown') + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts new file mode 100644 index 00000000000..64828018181 --- /dev/null +++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts @@ -0,0 +1,199 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { subscribeToPtyData } from '../terminal-pane/pty-data-sidecar-subscriptions' +import { isRemoteRuntimePtyId, sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' +import { subscribeToRuntimeTerminalData } from '@/runtime/runtime-terminal-stream' +import { NATIVE_CHAT_SUBMIT } from './native-chat-send' +import { stripScrollbackAnsi } from './native-chat-scrape-fallback' + +const DETECTION_TIMEOUT_MS = 5_000 +const MAX_OBSERVED_BYTES = 64 * 1024 + +type SubscribeToData = (watcher: (data: string) => void) => Promise<() => void> | (() => void) + +export type ClaudeModelSwitchOutcome = 'applied' | 'rejected' | 'interaction-required' | 'unknown' + +export type ClaudeModelSwitchConfirmationObserver = { + ready: Promise + result: Promise + arm(): void + startDetection(): void + dispose(): void +} + +export function hasClaudeModelSwitchConfirmation(buffer: string): boolean { + const text = compactTerminalText(buffer) + return ( + text.includes('switchmodel?') && text.includes('thisconversationiscachedforthecurrentmodel') + ) +} + +function compactTerminalText(buffer: string): string { + // Why: Claude positions TUI words with cursor-column escapes instead of + // emitting literal spaces, so matching must not depend on rendered gaps. + return stripScrollbackAnsi(buffer).replace(/\s+/g, '').toLowerCase() +} + +function hasClaudeModelSwitchSuccess(buffer: string, modelLabel: string): boolean { + const text = compactTerminalText(buffer) + const marker = `setmodelto${modelLabel.replace(/\s+/g, '').toLowerCase()}` + return text.includes(marker) +} + +function hasClaudeModelSwitchRejection(buffer: string): boolean { + return compactTerminalText(buffer).includes('keptmodelas') +} + +function hasClaudeModelSwitchInteraction(buffer: string): boolean { + const text = compactTerminalText(buffer) + return ( + text.includes('fable5usesusagecreditsandneedsaone-timeconsent') || + text.includes('pickfablefrom/modelinaninteractivesessiontosetitup') || + (text.includes('switchtofable5?') && text.includes('usagecredits')) + ) +} + +function subscribeToClaudeModelSwitchData(args: { + ptyId: string + settings: Pick | null | undefined + subscribeToData?: SubscribeToData + watcher: (data: string) => void +}): Promise<() => void> | (() => void) { + if (args.subscribeToData) { + return args.subscribeToData(args.watcher) + } + if (isRemoteRuntimePtyId(args.ptyId)) { + return subscribeToRuntimeTerminalData( + args.settings, + args.ptyId, + `desktop:native-chat-model-switch:${args.ptyId}`, + args.watcher, + { startAtLiveTail: true } + ) + } + return subscribeToPtyData(args.ptyId, args.watcher) +} + +export function createClaudeModelSwitchConfirmationObserver(args: { + ptyId: string + settings: Pick | null | undefined + expectedModelLabel: string | null + subscribeToData?: SubscribeToData + submitConfirmation?: () => boolean | void + timeoutMs?: number +}): ClaudeModelSwitchConfirmationObserver { + let armed = false + let settled = false + let confirmationSubmitted = false + let observed = '' + let timeout: ReturnType | null = null + let unsubscribe: (() => void) | null = null + let resolveResult!: (outcome: ClaudeModelSwitchOutcome) => void + let resolveReady!: () => void + const result = new Promise((resolve) => { + resolveResult = resolve + }) + const ready = new Promise((resolve) => { + resolveReady = resolve + }) + + const finish = (outcome: ClaudeModelSwitchOutcome): void => { + if (settled) { + return + } + settled = true + if (timeout !== null) { + clearTimeout(timeout) + timeout = null + } + unsubscribe?.() + unsubscribe = null + resolveResult(outcome) + } + + const scheduleTimeout = (): void => { + if (timeout !== null) { + clearTimeout(timeout) + } + timeout = setTimeout(() => finish('unknown'), args.timeoutMs ?? DETECTION_TIMEOUT_MS) + } + + const observeData = (data: string): void => { + if (!armed || settled) { + return + } + observed = `${observed}${data}`.slice(-MAX_OBSERVED_BYTES) + if (args.expectedModelLabel && hasClaudeModelSwitchSuccess(observed, args.expectedModelLabel)) { + finish('applied') + return + } + if (hasClaudeModelSwitchRejection(observed)) { + finish('rejected') + return + } + if (hasClaudeModelSwitchInteraction(observed)) { + finish('interaction-required') + return + } + if (!confirmationSubmitted && hasClaudeModelSwitchConfirmation(observed)) { + confirmationSubmitted = true + try { + // Why: the picker selection already expresses consent to switch; this + // exact Claude warning defaults to “Yes” and needs only one Enter. + const accepted = args.submitConfirmation + ? args.submitConfirmation() !== false + : sendRuntimePtyInput(args.settings, args.ptyId, NATIVE_CHAT_SUBMIT) + if (!accepted) { + finish('unknown') + return + } + scheduleTimeout() + } catch { + finish('unknown') + } + } + } + + try { + const subscription = subscribeToClaudeModelSwitchData({ + ptyId: args.ptyId, + settings: args.settings, + subscribeToData: args.subscribeToData, + watcher: observeData + }) + void Promise.resolve(subscription) + .then((dispose) => { + if (settled) { + dispose() + } else { + unsubscribe = dispose + } + }) + .catch(() => finish('unknown')) + .finally(resolveReady) + } catch { + finish('unknown') + resolveReady() + } + + return { + ready, + result, + arm: () => { + if (settled || armed) { + return + } + armed = true + }, + startDetection: () => { + // Why: measure the detection window from when the command is actually + // delivered, not from arm(). On SSH/remote the body+Enter round-trips can + // otherwise burn the timeout before the agent has even responded, turning + // a successful switch into a false "could not verify the model change". + if (settled) { + return + } + scheduleTimeout() + }, + dispose: () => finish('unknown') + } +} diff --git a/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts b/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts new file mode 100644 index 00000000000..e095adf1132 --- /dev/null +++ b/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { readClaudeSessionOptionsFromTerminalScreen } from './claude-terminal-session-options' + +describe('Claude terminal session option detection', () => { + it('reads the current model and effort from Claude header chrome', () => { + const screen = + '\u001b[1mClaude Code\u001b[0m v2.1.211\r\n' + + '\u001b[38;2;102;102;102mOpus 4.8 with high effort · API Usage Billing\r\n' + + '~/Documents/projects/orca' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'opus', + effort: 'high' + }) + }) + + it('reads a Claude header whose xterm serialization joins the version to the title', () => { + const screen = + '\u001b[?1049h\u001b[H▐▛███▜▌Claude Codev2.1.211\r\n' + + '▝▜█████▛▘Sonnet 5 with medium effort · API Usage Billing' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'sonnet', + effort: 'medium' + }) + }) + + it('does not mistake old conversation output for the current model', () => { + const screen = + 'Set model to Opus 4.8 and saved as your default\r\n' + + 'Claude Code v2.1.211\r\n' + + 'Sonnet 5 with medium effort · API Usage Billing' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'sonnet', + effort: 'medium' + }) + }) + + it('reports an option-less Haiku model without inventing effort', () => { + expect( + readClaudeSessionOptionsFromTerminalScreen( + 'Claude Code v2.1.211\r\nHaiku · API Usage Billing\r\n~/repo' + ) + ).toEqual({ model: 'haiku' }) + }) + + it('ignores text without Claude header chrome', () => { + expect( + readClaudeSessionOptionsFromTerminalScreen('I recommend Opus 4.8 for this task.') + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/claude-terminal-session-options.ts b/src/renderer/src/components/native-chat/claude-terminal-session-options.ts new file mode 100644 index 00000000000..7a1e5b8fbd8 --- /dev/null +++ b/src/renderer/src/components/native-chat/claude-terminal-session-options.ts @@ -0,0 +1,54 @@ +import { getAgentSessionOptionCatalog } from '../../../../shared/agent-session-option-catalog' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' +import { stripScrollbackAnsi } from './native-chat-scrape-fallback' + +const EFFORT_ID_BY_LABEL: Record = { + low: 'low', + medium: 'medium', + high: 'high', + 'extra high': 'xhigh', + xhigh: 'xhigh', + max: 'max' +} + +function normalizedScreenLines(screen: string): string[] { + return stripScrollbackAnsi(screen) + .split('\n') + .map((line) => line.replace(/\s+/g, ' ').trim()) +} + +export function readClaudeSessionOptionsFromTerminalScreen( + screen: string | null | undefined +): Record | null { + if (!screen) { + return null + } + const lines = normalizedScreenLines(screen) + const headerIndex = lines.findIndex((line) => + // xterm serialization can remove cursor-positioning cells between the + // product name and version, producing `Claude Codev2.1.211`. + /\bClaude Code\s*v?\d+(?:\.\d+){1,2}\b/i.test(line) + ) + if (headerIndex < 0) { + return null + } + // Why: only Claude's fixed header rows describe live state; conversation + // text and old command confirmations elsewhere in the buffer can be stale. + const header = lines.slice(headerIndex, headerIndex + 3).join(' ') + const catalog = getAgentSessionOptionCatalog('claude') + const model = [...(catalog?.models ?? [])] + .sort((left, right) => right.label.length - left.label.length) + .find(({ label }) => header.toLowerCase().includes(label.toLowerCase())) + if (!model) { + return null + } + const result: Record = { model: model.id } + const effortLabel = header.match( + /\bwith\s+(low|medium|high|extra high|xhigh|max)\s+effort\b/i + )?.[1] + const effort = effortLabel ? EFFORT_ID_BY_LABEL[effortLabel.toLowerCase()] : undefined + if (effort && model.options.some((option) => option.id === 'effort')) { + result.effort = effort + } + return result +} diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts new file mode 100644 index 00000000000..771daacd23e --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts @@ -0,0 +1,340 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + clearNativeChatSessionOptionCacheForTests, + seedNativeChatAppliedSessionOptions +} from './native-chat-session-option-cache' +import { createNativeChatPtySessionOptions } from './native-chat-pty-session-options' + +describe('native chat PTY session options', () => { + beforeEach(() => clearNativeChatSessionOptionCacheForTests()) + + it('starts attached sessions unknown and hides model-scoped options', () => { + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn() + })! + expect(surface.getSnapshot()).toHaveLength(1) + expect(surface.getSnapshot()[0]).toMatchObject({ + id: 'model', + valueSource: 'unknown' + }) + }) + + it('uses model and effort reported by the live Claude terminal', () => { + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + reportedValues: { model: 'opus', effort: 'medium' }, + dispatchCommand: vi.fn() + })! + + expect(surface.getSnapshot()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'model', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'opus' }) + }), + expect.objectContaining({ + id: 'effort', + valueSource: 'reported', + kind: expect.objectContaining({ currentValue: 'medium' }) + }) + ]) + ) + }) + + it('restores launch-backed values through the tab-to-PTY cache handoff', () => { + seedNativeChatAppliedSessionOptions('tab-1', 'claude', { + model: 'opus', + effort: 'xhigh' + }) + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + fallbackScopeKey: 'tab-1', + mode: 'live', + dispatchCommand: vi.fn() + })! + expect(surface.getSnapshot()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'model', valueSource: 'applied' }), + expect.objectContaining({ id: 'effort', valueSource: 'applied' }), + expect.objectContaining({ id: 'fastMode', valueSource: 'unknown' }) + ]) + ) + }) + + it('dispatches a Claude effort setter and publishes the full snapshot', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'opus', + effort: 'xhigh' + }) + const dispatch = vi.fn() + const persist = vi.fn() + const listener = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch, + persistSelection: persist + })! + surface.subscribe(listener) + + const effortResult = await surface.setOption('effort', 'high') + expect(dispatch).toHaveBeenCalledWith('/effort high') + expect(effortResult.snapshot.map(({ id }) => id)).toEqual(['model', 'effort', 'fastMode']) + expect(effortResult.snapshot.find(({ id }) => id === 'effort')).toMatchObject({ + valueSource: 'dispatched', + kind: { currentValue: 'high' } + }) + expect(listener).toHaveBeenCalledOnce() + expect(listener.mock.calls.every(([snapshot]) => Array.isArray(snapshot))).toBe(true) + expect(persist).toHaveBeenCalledWith({ + modelId: 'opus', + optionId: 'effort', + value: 'high' + }) + }) + + it('keeps a normal Claude model choice native and dispatches the selected model', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'sonnet', + effort: 'high' + }) + const dispatch = vi.fn().mockResolvedValue({ outcome: 'applied' }) + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch, + onAgentPicker + })! + expect(surface.getSnapshot()[0]?.action).toBeUndefined() + + const result = await surface.setOption('model', 'fable') + + expect(dispatch).toHaveBeenCalledWith('/model fable', { + detectAgentInteraction: 'claude-model-switch-confirmation', + expectedChoiceLabel: 'Fable 5' + }) + expect(onAgentPicker).not.toHaveBeenCalled() + expect(result.snapshot[0]).toMatchObject({ + valueSource: 'dispatched', + kind: { currentValue: 'fable' } + }) + }) + + it('reveals the terminal only when Claude actually requires model-switch interaction', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { model: 'sonnet' }) + const dispatch = vi.fn().mockResolvedValue({ outcome: 'interaction-required' }) + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch, + onAgentPicker + })! + + const result = await surface.setOption('model', 'haiku') + + expect(dispatch).toHaveBeenCalledWith('/model haiku', { + detectAgentInteraction: 'claude-model-switch-confirmation', + expectedChoiceLabel: 'Haiku' + }) + expect(onAgentPicker).toHaveBeenCalledOnce() + expect(result.snapshot[0]).toMatchObject({ valueSource: 'unknown' }) + }) + + it('keeps the prior model and persistence when Claude rejects the switch', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'fable', + effort: 'high' + }) + const persist = vi.fn() + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn().mockResolvedValue({ outcome: 'rejected' }), + persistSelection: persist, + onAgentPicker + })! + + await expect(surface.setOption('model', 'haiku')).rejects.toThrow( + 'Claude kept the current model.' + ) + + expect(surface.getSnapshot()[0]).toMatchObject({ + valueSource: 'applied', + kind: { currentValue: 'fable' } + }) + expect(persist).not.toHaveBeenCalled() + expect(onAgentPicker).not.toHaveBeenCalled() + }) + + it('stays native and clears stale truth when the switch cannot be verified', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'fable', + effort: 'high' + }) + const persist = vi.fn() + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn().mockResolvedValue({ outcome: 'unknown' }), + persistSelection: persist, + onAgentPicker + })! + + await expect(surface.setOption('model', 'haiku')).rejects.toThrow( + 'Could not verify the model change; open the terminal to check.' + ) + + expect(surface.getSnapshot()).toHaveLength(1) + expect(surface.getSnapshot()[0]).toMatchObject({ valueSource: 'unknown' }) + expect(persist).not.toHaveBeenCalled() + expect(onAgentPicker).not.toHaveBeenCalled() + }) + + it('keeps an unknown toggle unknown after the one-shot action', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'opus', + effort: 'high' + }) + const dispatch = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch + })! + const fastBefore = surface.getSnapshot().find(({ id }) => id === 'fastMode') + expect(fastBefore?.action?.type).toBe('toggle-command') + + const result = await surface.setOption('fastMode', true) + expect(dispatch).toHaveBeenCalledWith('/fast') + expect(result.snapshot.find(({ id }) => id === 'fastMode')).toMatchObject({ + valueSource: 'unknown', + action: { type: 'toggle-command' } + }) + }) + + it('hands Codex model changes to the TUI picker and drops stale truth', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'codex', { + model: 'gpt-5.5', + effort: 'high' + }) + const dispatch = vi.fn() + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'codex', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch, + onAgentPicker + })! + expect(surface.getSnapshot().find(({ id }) => id === 'effort')?.action?.type).toBe( + 'agent-picker' + ) + + const result = await surface.setOption('effort', 'xhigh') + expect(dispatch).toHaveBeenCalledWith('/model') + expect(onAgentPicker).toHaveBeenCalledOnce() + expect(result.snapshot).toHaveLength(1) + expect(result.snapshot[0]).toMatchObject({ valueSource: 'unknown' }) + }) + + it('tracks typed effort commands and downgrades typed toggles', () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'opus', + effort: 'xhigh' + }) + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn() + })! + surface.recordOutgoingCommand('/effort high') + expect(surface.getSnapshot().find(({ id }) => id === 'effort')).toMatchObject({ + valueSource: 'dispatched', + kind: { currentValue: 'high' } + }) + surface.recordOutgoingCommand('/fast') + expect(surface.getSnapshot().find(({ id }) => id === 'fastMode')).toMatchObject({ + valueSource: 'unknown' + }) + }) + + it('switches to the terminal and drops stale truth for a typed picker command', () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'opus', + effort: 'xhigh' + }) + const onAgentPicker = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn(), + onAgentPicker + })! + + surface.recordOutgoingCommand('/model') + + expect(onAgentPicker).toHaveBeenCalledOnce() + expect(surface.getSnapshot()).toHaveLength(1) + expect(surface.getSnapshot()[0]).toMatchObject({ valueSource: 'unknown' }) + }) + + it('passes an unknown persisted model through as a literal choice', () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'future-model' + }) + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: vi.fn() + })! + const model = surface.getSnapshot()[0] + expect(model.kind).toMatchObject({ + currentValue: 'future-model', + choices: expect.arrayContaining([{ value: 'future-model', label: 'future-model' }]) + }) + }) + + it('recomposes Cursor model slugs for live option changes', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'cursor', { + model: 'gpt-5.3-codex', + effort: 'medium', + fastMode: true + }) + const dispatch = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'cursor', + scopeKey: 'pty-1', + mode: 'live', + dispatchCommand: dispatch + })! + expect(surface.getSnapshot().find(({ id }) => id === 'effort')?.settable).toBe(true) + + await surface.setOption('effort', 'high') + + expect(dispatch).toHaveBeenCalledWith('/model gpt-5.3-codex-high-fast') + expect(surface.getSnapshot().find(({ id }) => id === 'effort')).toMatchObject({ + valueSource: 'dispatched', + kind: { currentValue: 'high' } + }) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts new file mode 100644 index 00000000000..41ce304011c --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts @@ -0,0 +1,276 @@ +import { + findCatalogModel, + findCatalogOption, + getAgentSessionOptionCatalog, + type CatalogMidSessionApply, + type CatalogModel, + type CatalogOptionApply +} from '../../../../shared/agent-session-option-catalog' +import type { AgentType } from '../../../../shared/agent-status-types' +import type { + SessionOptionDescriptor, + SessionOptionsSurface, + SessionOptionValue +} from '../../../../shared/native-chat-session-options' +import { + createNativeChatSessionOptionRecord, + readNativeChatSessionOptionCache, + writeNativeChatSessionOptionCache +} from './native-chat-session-option-cache' +import { + buildNativeChatSessionOptionSnapshot, + flattenNativeChatSessionOptionRecord, + type NativeChatSessionOptionMode +} from './native-chat-session-option-snapshot' +import { buildNativeChatSessionOptionCommand } from './native-chat-session-option-command-builder' +import type { + NativeChatSessionOptionDispatchCommand, + NativeChatSessionOptionDispatchResult +} from './native-chat-session-option-command-dispatch' +import { applyNativeChatReportedSessionOptions } from './native-chat-session-option-reporting' +import { recordNativeChatSessionOptionCommand } from './native-chat-session-option-command-recording' + +type PersistSelection = (args: { + modelId: string + optionId: string + value: SessionOptionValue +}) => Promise | void + +export type NativeChatPtySessionOptionsSurface = SessionOptionsSurface & { + recordOutgoingCommand(command: string): void + reportSessionOptions(values: Record): void + replaceModels(models: CatalogModel[]): void +} + +export type CreateNativeChatPtySessionOptionsArgs = { + agent: AgentType + scopeKey: string + fallbackScopeKey?: string + initialModels?: readonly CatalogModel[] + mode: NativeChatSessionOptionMode + reportedValues?: Record | null + dispatchCommand: NativeChatSessionOptionDispatchCommand + onAgentPicker?: () => void + persistSelection?: PersistSelection + onDraftValuesChanged?: (values: Record) => void +} + +export function createNativeChatPtySessionOptions( + args: CreateNativeChatPtySessionOptionsArgs +): NativeChatPtySessionOptionsSurface | null { + const catalog = getAgentSessionOptionCatalog(args.agent) + if (!catalog) { + return null + } + let models = [...(args.initialModels ?? catalog.models)] + let record = + readNativeChatSessionOptionCache(args.scopeKey, args.fallbackScopeKey) ?? + createNativeChatSessionOptionRecord(args.agent) + if (record.agent !== args.agent) { + record = createNativeChatSessionOptionRecord(args.agent) + } + + if (args.reportedValues && applyNativeChatReportedSessionOptions(record, args.reportedValues)) { + writeNativeChatSessionOptionCache(args.scopeKey, record) + } + let snapshot = buildNativeChatSessionOptionSnapshot({ + catalog, + models, + record, + mode: args.mode + }) + const listeners = new Set<(value: SessionOptionDescriptor[]) => void>() + + const publish = (): SessionOptionDescriptor[] => { + writeNativeChatSessionOptionCache(args.scopeKey, record) + snapshot = buildNativeChatSessionOptionSnapshot({ + catalog, + models, + record, + mode: args.mode + }) + for (const listener of listeners) { + listener(snapshot) + } + return snapshot + } + + const clearModelTruth = (): void => { + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + record.model = undefined + if (modelId) { + delete record.valuesByModel[modelId] + } + } + + const setTrackedValue = ( + optionId: string, + value: SessionOptionValue, + source: 'applied' | 'dispatched' + ): string | null => { + if (optionId === 'model') { + record.model = { value, source } + return typeof value === 'string' ? value : null + } + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + if (!modelId) { + return null + } + record.valuesByModel[modelId] = { + ...record.valuesByModel[modelId], + [optionId]: { value, source } + } + return modelId + } + + const persist = (modelId: string | null, optionId: string, value: SessionOptionValue): void => { + if (modelId) { + void args.persistSelection?.({ modelId, optionId, value }) + } + } + + const currentApply = ( + optionId: string + ): { apply: CatalogOptionApply; modelId: string | null } | null => { + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + if (optionId === 'model') { + return { apply: catalog.modelApply, modelId } + } + const model = modelId ? findCatalogModel({ ...catalog, models }, modelId) : undefined + const option = findCatalogOption(model, optionId) + return option ? { apply: option.apply, modelId } : null + } + + const handleAgentPicker = async (midSession: CatalogMidSessionApply): Promise => { + if (midSession.kind !== 'agent-picker') { + return + } + await args.dispatchCommand(midSession.command) + clearModelTruth() + publish() + args.onAgentPicker?.() + } + + const setOption = async (id: string, value: SessionOptionValue) => { + const resolved = currentApply(id) + if (!resolved) { + throw new Error(`Unknown session option: ${id}`) + } + const { apply, modelId: previousModelId } = resolved + if (args.mode === 'live' && apply.midSession?.kind === 'agent-picker') { + await handleAgentPicker(apply.midSession) + return { snapshot } + } + const source = args.mode === 'live' ? 'dispatched' : 'applied' + let dispatchResult: NativeChatSessionOptionDispatchResult | void = undefined + const toggleWasKnown = + apply.midSession?.kind === 'toggle-command' && previousModelId + ? record.valuesByModel[previousModelId]?.[id] !== undefined + : false + if (args.mode === 'live') { + const command = buildNativeChatSessionOptionCommand({ + optionId: id, + value, + apply, + modelId: previousModelId, + catalog, + models, + record + }) + if (!command) { + throw new Error('This option can only be set when the session starts.') + } + const detectAgentInteraction = + apply.midSession?.kind === 'command' + ? apply.midSession.detectAgentInteraction + : apply.composedIntoModel && catalog.modelApply.midSession?.kind === 'command' + ? catalog.modelApply.midSession.detectAgentInteraction + : undefined + const expectedChoiceLabel = + id === 'model' && typeof value === 'string' + ? (findCatalogModel({ ...catalog, models }, value)?.label ?? value) + : undefined + dispatchResult = detectAgentInteraction + ? await args.dispatchCommand(command, { + detectAgentInteraction, + expectedChoiceLabel + }) + : await args.dispatchCommand(command) + } else if (!apply.launchArgs && !apply.composedIntoModel) { + throw new Error('This option is only available after the session starts.') + } + + if (dispatchResult?.outcome === 'rejected') { + throw new Error('Claude kept the current model.') + } + if (dispatchResult?.outcome === 'unknown') { + clearModelTruth() + publish() + throw new Error('Could not verify the model change; open the terminal to check.') + } + if (dispatchResult?.outcome === 'interaction-required') { + clearModelTruth() + publish() + args.onAgentPicker?.() + return { snapshot } + } + + if (apply.midSession?.kind === 'toggle-command' && !toggleWasKnown) { + return { snapshot: publish() } + } + if (id === 'model' && previousModelId !== value) { + record.model = undefined + if (args.mode === 'live' && typeof value === 'string') { + // Why: switching models can reset its effort/toggles. A value cached + // from an earlier visit to that model is no longer live evidence. + delete record.valuesByModel[value] + } + } + const modelId = setTrackedValue(id, value, source) + if (apply.midSession?.kind === 'toggle-command' && previousModelId && source === 'dispatched') { + record.valuesByModel[previousModelId] = { + ...record.valuesByModel[previousModelId], + [id]: { value, source } + } + } + persist(modelId ?? previousModelId, id, value) + const next = publish() + if (args.mode === 'draft' && typeof record.model?.value === 'string') { + args.onDraftValuesChanged?.(flattenNativeChatSessionOptionRecord(record, record.model.value)) + } + return { snapshot: next } + } + + return { + getSnapshot: () => snapshot, + setOption, + subscribe: (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) + }, + recordOutgoingCommand: (command) => { + const result = recordNativeChatSessionOptionCommand({ + catalog, + models, + record, + command, + persist + }) + if (result.changed) { + publish() + } + if (result.opensAgentPicker) { + args.onAgentPicker?.() + } + }, + reportSessionOptions: (values) => { + if (applyNativeChatReportedSessionOptions(record, values)) { + publish() + } + }, + replaceModels: (nextModels) => { + models = [...nextModels] + publish() + } + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index 626e126ffa5..730ad84f1a6 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -3,12 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' // Mock the IO seam so the test stays pure: we only assert the write order and // the inter-write delay, not the local-vs-remote pty branching. const sendRuntimePtyInput = vi.fn() +const sendRuntimePtyInputVerified = vi.fn() vi.mock('@/runtime/runtime-terminal-inspection', () => ({ - sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args) + sendRuntimePtyInput: (...args: unknown[]) => sendRuntimePtyInput(...args), + sendRuntimePtyInputVerified: (...args: unknown[]) => sendRuntimePtyInputVerified(...args) })) import { sendNativeChatMessage, + sendNativeChatMessageVerified, sendNativeChatMessageWithImageAttachments, submitNativeChatPrompt, sendNativeChatAskAnswer, @@ -75,6 +78,52 @@ describe('sendNativeChatMessage', () => { }) }) +describe('sendNativeChatMessageVerified', () => { + beforeEach(() => { + vi.useFakeTimers() + sendRuntimePtyInputVerified.mockReset().mockResolvedValue(true) + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('awaits body acceptance before the delayed Enter write', async () => { + const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet') + await Promise.resolve() + + expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith( + SETTINGS, + PTY, + buildNativeChatPasteBytes('/model sonnet') + ) + + await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS) + + expect(await result).toBe(true) + expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + }) + + it('does not send Enter when the body is rejected', async () => { + sendRuntimePtyInputVerified.mockResolvedValueOnce(false) + + await expect(sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet')).resolves.toBe(false) + await vi.runAllTimersAsync() + + expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) + }) + + it('cancels the pending Enter when its composer detaches', async () => { + const controller = new AbortController() + const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet', controller.signal) + await Promise.resolve() + controller.abort() + + expect(await result).toBe(false) + expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) + }) +}) + describe('sendNativeChatMessageWithImageAttachments', () => { beforeEach(() => { vi.useFakeTimers() diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index 59ff443fd24..a1dca86b65f 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -2,7 +2,10 @@ // as a SEPARATE delayed pty write. Kept apart from the pure byte builders in // native-chat-send.ts so those stay IO-free and unit-testable without aliases. -import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection' +import { + sendRuntimePtyInput, + sendRuntimePtyInputVerified +} from '@/runtime/runtime-terminal-inspection' import type { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import type { AskAnswerKeyGroup } from './native-chat-interactive-prompt' import { @@ -20,7 +23,6 @@ export { NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_QUESTION_STEP_MS, NATIVE_CHA export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300 - /** Cancels an in-flight send's pending pty writes (the delayed Enter, and any * later question bodies/Enters). Safe to call after the send completes. */ export type NativeChatSendHandle = { @@ -47,6 +49,46 @@ export function sendNativeChatMessage( return { cancel: () => clearTimeout(timer), settleAfterMs: NATIVE_CHAT_SUBMIT_DELAY_MS } } +function waitForNativeChatSubmit(signal?: AbortSignal): Promise { + if (signal?.aborted) { + return Promise.resolve(false) + } + return new Promise((resolve) => { + let timer: ReturnType | null = null + const finish = (completed: boolean): void => { + if (timer === null) { + return + } + clearTimeout(timer) + timer = null + signal?.removeEventListener('abort', onAbort) + resolve(completed) + } + const onAbort = (): void => finish(false) + timer = setTimeout(() => finish(true), NATIVE_CHAT_SUBMIT_DELAY_MS) + signal?.addEventListener('abort', onAbort, { once: true }) + }) +} + +export async function sendNativeChatMessageVerified( + settings: ReturnType, + ptyId: string, + text: string, + signal?: AbortSignal +): Promise { + // Why: option commands await remote/SSH acceptance so the Enter cannot race + // ahead of the body while a model-change observer is already armed. + const bodyAccepted = await sendRuntimePtyInputVerified( + settings, + ptyId, + buildNativeChatPasteBytes(text) + ) + if (!bodyAccepted || signal?.aborted || !(await waitForNativeChatSubmit(signal))) { + return false + } + return sendRuntimePtyInputVerified(settings, ptyId, NATIVE_CHAT_SUBMIT) +} + export function sendNativeChatMessageWithImageAttachments( settings: ReturnType, ptyId: string, diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-cache.ts b/src/renderer/src/components/native-chat/native-chat-session-option-cache.ts new file mode 100644 index 00000000000..ae9418cd650 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-cache.ts @@ -0,0 +1,84 @@ +import type { AgentType } from '../../../../shared/agent-status-types' +import type { + SessionOptionValue, + SessionOptionValueSource +} from '../../../../shared/native-chat-session-options' +import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' + +export type TrackedNativeChatSessionOption = { + value: SessionOptionValue + source: Exclude +} + +export type NativeChatSessionOptionRecord = { + agent: AgentType + model?: TrackedNativeChatSessionOption + valuesByModel: Record> +} + +const sessionOptionCache = new Map() + +export function createNativeChatSessionOptionRecord( + agent: AgentType +): NativeChatSessionOptionRecord { + return { agent, valuesByModel: {} } +} + +export function cloneNativeChatSessionOptionRecord( + record: NativeChatSessionOptionRecord +): NativeChatSessionOptionRecord { + return { + agent: record.agent, + ...(record.model ? { model: { ...record.model } } : {}), + valuesByModel: Object.fromEntries( + Object.entries(record.valuesByModel).map(([modelId, values]) => [ + modelId, + Object.fromEntries(Object.entries(values).map(([id, tracked]) => [id, { ...tracked }])) + ]) + ) + } +} + +export function readNativeChatSessionOptionCache( + scopeKey: string, + fallbackScopeKey?: string +): NativeChatSessionOptionRecord | null { + const record = sessionOptionCache.get(scopeKey) ?? sessionOptionCache.get(fallbackScopeKey ?? '') + return record ? cloneNativeChatSessionOptionRecord(record) : null +} + +export function writeNativeChatSessionOptionCache( + scopeKey: string, + record: NativeChatSessionOptionRecord +): void { + setBoundedScopeCacheEntry( + sessionOptionCache, + scopeKey, + cloneNativeChatSessionOptionRecord(record) + ) +} + +export function seedNativeChatAppliedSessionOptions( + scopeKey: string, + agent: AgentType, + values: Record | null | undefined +): void { + const modelId = typeof values?.model === 'string' ? values.model : null + if (!modelId) { + return + } + const record = createNativeChatSessionOptionRecord(agent) + record.model = { value: modelId, source: 'applied' } + const modelValues: Record = {} + for (const [id, value] of Object.entries(values ?? {})) { + if (id !== 'model') { + modelValues[id] = { value, source: 'applied' } + } + } + record.valuesByModel[modelId] = modelValues + writeNativeChatSessionOptionCache(scopeKey, record) +} + +export function clearNativeChatSessionOptionCacheForTests(): void { + sessionOptionCache.clear() +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-command-builder.ts b/src/renderer/src/components/native-chat/native-chat-session-option-command-builder.ts new file mode 100644 index 00000000000..94de397b0ef --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-command-builder.ts @@ -0,0 +1,40 @@ +import { + findCatalogModel, + type AgentSessionOptionCatalog, + type CatalogModel, + type CatalogOptionApply +} from '../../../../shared/agent-session-option-catalog' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' +import type { NativeChatSessionOptionRecord } from './native-chat-session-option-cache' +import { flattenNativeChatSessionOptionRecord } from './native-chat-session-option-snapshot' + +export function buildNativeChatSessionOptionCommand(args: { + optionId: string + value: SessionOptionValue + apply: CatalogOptionApply + modelId: string | null + catalog: AgentSessionOptionCatalog + models: CatalogModel[] + record: NativeChatSessionOptionRecord +}): string | null { + const midSession = args.apply.midSession + if (midSession?.kind === 'command') { + return midSession.build(args.value) + } + if (midSession?.kind === 'toggle-command' || midSession?.kind === 'agent-picker') { + return midSession.command + } + if (!args.apply.composedIntoModel || !args.modelId || !args.catalog.composeModelValue) { + return null + } + const model = findCatalogModel({ ...args.catalog, models: args.models }, args.modelId) + const values = flattenNativeChatSessionOptionRecord(args.record, args.modelId) + for (const option of model?.options ?? []) { + values[option.id] ??= option.kind.defaultValue + } + values[args.optionId] = args.value + const composed = args.catalog.composeModelValue(args.modelId, values) + return args.catalog.modelApply.midSession?.kind === 'command' + ? args.catalog.modelApply.midSession.build(composed) + : null +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts b/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts new file mode 100644 index 00000000000..c510990dc5e --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-command-dispatch.ts @@ -0,0 +1,17 @@ +import type { CatalogAgentInteractionDetection } from '../../../../shared/agent-session-option-catalog' +import type { ClaudeModelSwitchOutcome } from './claude-model-switch-confirmation' + +export type NativeChatSessionOptionDispatchResult = { + outcome?: ClaudeModelSwitchOutcome +} + +export type NativeChatSessionOptionDispatchCommand = ( + command: string, + options?: { + detectAgentInteraction?: CatalogAgentInteractionDetection + expectedChoiceLabel?: string + } +) => + | Promise + | NativeChatSessionOptionDispatchResult + | void diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-command-matching.ts b/src/renderer/src/components/native-chat/native-chat-session-option-command-matching.ts new file mode 100644 index 00000000000..08509c36935 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-command-matching.ts @@ -0,0 +1,31 @@ +import type { CatalogMidSessionApply } from '../../../../shared/agent-session-option-catalog' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' + +export function parseBuiltSessionOptionCommand( + build: (value: SessionOptionValue) => string, + command: string +): string | null { + const marker = '__orca_session_option_value__' + const template = build(marker) + const markerIndex = template.indexOf(marker) + if (markerIndex < 0) { + return null + } + const prefix = template.slice(0, markerIndex) + const suffix = template.slice(markerIndex + marker.length) + if (!command.startsWith(prefix) || !command.endsWith(suffix)) { + return null + } + const value = command.slice(prefix.length, command.length - suffix.length).trim() + return value || null +} + +export function isSessionOptionAgentPickerCommand( + midSession: CatalogMidSessionApply | undefined, + command: string +): boolean { + return ( + (midSession?.kind === 'agent-picker' && command === midSession.command) || + (midSession?.kind === 'command' && command === midSession.pickerCommand) + ) +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-command-recording.ts b/src/renderer/src/components/native-chat/native-chat-session-option-command-recording.ts new file mode 100644 index 00000000000..051e14b1c1d --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-command-recording.ts @@ -0,0 +1,105 @@ +import { + findCatalogModel, + type AgentSessionOptionCatalog, + type CatalogMidSessionApply, + type CatalogModel +} from '../../../../shared/agent-session-option-catalog' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' +import type { NativeChatSessionOptionRecord } from './native-chat-session-option-cache' +import { + isSessionOptionAgentPickerCommand, + parseBuiltSessionOptionCommand +} from './native-chat-session-option-command-matching' + +function clearModel(record: NativeChatSessionOptionRecord): void { + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + record.model = undefined + if (modelId) { + delete record.valuesByModel[modelId] + } +} + +function recordCommandApply(args: { + record: NativeChatSessionOptionRecord + optionId: string + midSession: CatalogMidSessionApply | undefined + command: string + persist: (modelId: string | null, optionId: string, value: SessionOptionValue) => void +}): boolean { + const { record, optionId, midSession, command, persist } = args + if (!midSession || midSession.kind === 'unsupported') { + return false + } + if (midSession.kind === 'toggle-command' && command === midSession.command) { + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + if (modelId) { + delete record.valuesByModel[modelId]?.[optionId] + } + return true + } + if (isSessionOptionAgentPickerCommand(midSession, command)) { + clearModel(record) + return true + } + if (midSession.kind !== 'command') { + return false + } + const value = parseBuiltSessionOptionCommand(midSession.build, command) + if (!value) { + return false + } + const previousModelId = typeof record.model?.value === 'string' ? record.model.value : null + if (optionId === 'model') { + if (previousModelId !== value) { + // Why: a model command can reset model-scoped state, so an older value + // from a prior visit is no longer evidence about this live session. + delete record.valuesByModel[value] + } + record.model = { value, source: 'dispatched' } + persist(value, optionId, value) + return true + } + if (!previousModelId) { + return true + } + record.valuesByModel[previousModelId] = { + ...record.valuesByModel[previousModelId], + [optionId]: { value, source: 'dispatched' } + } + persist(previousModelId, optionId, value) + return true +} + +export function recordNativeChatSessionOptionCommand(args: { + catalog: AgentSessionOptionCatalog + models: CatalogModel[] + record: NativeChatSessionOptionRecord + command: string + persist: (modelId: string | null, optionId: string, value: SessionOptionValue) => void +}): { changed: boolean; opensAgentPicker: boolean } { + const { catalog, models, record, persist } = args + const command = args.command.trim() + let opensAgentPicker = isSessionOptionAgentPickerCommand(catalog.modelApply.midSession, command) + let changed = recordCommandApply({ + record, + optionId: 'model', + midSession: catalog.modelApply.midSession, + command, + persist + }) + const modelId = typeof record.model?.value === 'string' ? record.model.value : null + const model = modelId ? findCatalogModel({ ...catalog, models }, modelId) : undefined + for (const option of model?.options ?? []) { + opensAgentPicker = + opensAgentPicker || isSessionOptionAgentPickerCommand(option.apply.midSession, command) + changed = + recordCommandApply({ + record, + optionId: option.id, + midSession: option.apply.midSession, + command, + persist + }) || changed + } + return { changed, opensAgentPicker } +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts new file mode 100644 index 00000000000..bdf8ee6d1a2 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts @@ -0,0 +1,57 @@ +import type { AgentType } from '../../../../shared/agent-status-types' +import type { CatalogModel } from '../../../../shared/agent-session-option-catalog' +import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../../shared/commit-message-host-key' +import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' +import { getConnectionIdFromState } from '@/lib/connection-context' +import { + discoverRuntimeCommitMessageModels, + getRuntimeGitScope, + type RuntimeGitContext +} from '@/runtime/runtime-git-client' +import { useAppStore } from '@/store' + +export type NativeChatModelDiscoveryContext = { + hostKey: string + runtime: RuntimeGitContext +} + +export function resolveNativeChatModelDiscoveryContext( + terminalTabId: string +): NativeChatModelDiscoveryContext | null { + const state = useAppStore.getState() + const worktreeId = + Object.entries(state.tabsByWorktree ?? {}).find(([, tabs]) => + tabs.some((tab) => tab.id === terminalTabId) + )?.[0] ?? null + const connectionId = getConnectionIdFromState(state, worktreeId) + if (worktreeId && connectionId === undefined) { + return null + } + const settings = getSettingsForAgentTabRuntimeOwner(terminalTabId) + const worktreePath = worktreeId ? (state.getKnownWorktreeById?.(worktreeId)?.path ?? '') : '' + const scope = getRuntimeGitScope(settings, connectionId) + return { + hostKey: getCommitMessageModelDiscoveryHostKeyForScope(scope), + runtime: { + settings, + worktreeId, + worktreePath, + ...(connectionId ? { connectionId } : {}) + } + } +} + +export async function discoverNativeChatCatalogModels( + agent: AgentType, + context: RuntimeGitContext +): Promise { + const result = await discoverRuntimeCommitMessageModels(context, agent) + if (!result.success || result.models.length === 0) { + return null + } + return result.models.map((model) => ({ + id: model.id, + label: model.label, + options: [] + })) +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts new file mode 100644 index 00000000000..008d857ec9c --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CatalogModel } from '../../../../shared/agent-session-option-catalog' +import { + clearNativeChatModelEnrichmentForTests, + ensureNativeChatModelEnrichment, + readNativeChatEnrichedModels, + subscribeNativeChatEnrichedModels +} from './native-chat-session-option-enrichment' + +describe('native chat session option enrichment', () => { + beforeEach(() => clearNativeChatModelEnrichmentForTests()) + + it('keeps reads synchronous while one host-scoped probe is in flight', async () => { + let resolveDiscovery: ((models: CatalogModel[]) => void) | undefined + const discover = vi.fn( + () => + new Promise((resolve) => { + resolveDiscovery = resolve + }) + ) + const listener = vi.fn() + subscribeNativeChatEnrichedModels('cursor', 'ssh:one', listener) + + ensureNativeChatModelEnrichment({ agent: 'cursor', hostKey: 'ssh:one', discover }) + ensureNativeChatModelEnrichment({ agent: 'cursor', hostKey: 'ssh:one', discover }) + + expect(readNativeChatEnrichedModels('cursor', 'ssh:one')).toBeNull() + expect(discover).toHaveBeenCalledOnce() + + resolveDiscovery?.([ + { id: 'gpt-5.3-codex', label: 'GPT 5.3 live', options: [] }, + { id: 'account-model', label: 'Account model', options: [] } + ]) + await vi.waitFor(() => expect(listener).toHaveBeenCalledOnce()) + + const models = readNativeChatEnrichedModels('cursor', 'ssh:one')! + expect(models.find((model) => model.id === 'gpt-5.3-codex')).toMatchObject({ + label: 'GPT 5.3 live', + options: expect.arrayContaining([expect.objectContaining({ id: 'effort' })]) + }) + expect(models.at(-1)).toMatchObject({ id: 'account-model' }) + expect(readNativeChatEnrichedModels('cursor', 'ssh:two')).toBeNull() + }) + + it('falls back permanently to the seed after a failed once-per-host probe', async () => { + const discover = vi.fn().mockRejectedValue(new Error('offline')) + ensureNativeChatModelEnrichment({ agent: 'cursor', hostKey: 'local', discover }) + await vi.waitFor(() => expect(discover).toHaveBeenCalledOnce()) + await Promise.resolve() + + ensureNativeChatModelEnrichment({ agent: 'cursor', hostKey: 'local', discover }) + expect(discover).toHaveBeenCalledOnce() + expect(readNativeChatEnrichedModels('cursor', 'local')).toBeNull() + }) + + it('does not probe agents whose catalogs have no discovery command', () => { + const discover = vi.fn() + ensureNativeChatModelEnrichment({ agent: 'claude', hostKey: 'local', discover }) + expect(discover).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts new file mode 100644 index 00000000000..5fde5625c52 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts @@ -0,0 +1,87 @@ +import type { AgentType } from '../../../../shared/agent-status-types' +import { + getAgentSessionOptionCatalog, + mergeCatalogModels, + type CatalogModel +} from '../../../../shared/agent-session-option-catalog' + +type CatalogEnrichmentEntry = { + state: 'idle' | 'pending' | 'settled' + models: CatalogModel[] | null + listeners: Set<(models: CatalogModel[]) => void> +} + +const enrichmentByAgentHost = new Map() + +function enrichmentKey(agent: AgentType, hostKey: string): string { + return JSON.stringify([agent, hostKey]) +} + +export function readNativeChatEnrichedModels( + agent: AgentType, + hostKey: string +): CatalogModel[] | null { + const models = enrichmentByAgentHost.get(enrichmentKey(agent, hostKey))?.models + return models ? [...models] : null +} + +export function subscribeNativeChatEnrichedModels( + agent: AgentType, + hostKey: string, + listener: (models: CatalogModel[]) => void +): () => void { + const key = enrichmentKey(agent, hostKey) + const entry = enrichmentByAgentHost.get(key) ?? { + state: 'idle' as const, + models: null, + listeners: new Set<(models: CatalogModel[]) => void>() + } + entry.listeners.add(listener) + enrichmentByAgentHost.set(key, entry) + return () => entry.listeners.delete(listener) +} + +export function ensureNativeChatModelEnrichment(args: { + agent: AgentType + hostKey: string + discover: () => Promise +}): void { + const catalog = getAgentSessionOptionCatalog(args.agent) + if (!catalog?.listModels) { + return + } + const key = enrichmentKey(args.agent, args.hostKey) + const existing = enrichmentByAgentHost.get(key) + if (existing?.state === 'pending' || existing?.state === 'settled') { + return + } + const entry: CatalogEnrichmentEntry = existing ?? { + state: 'idle', + models: null, + listeners: new Set() + } + entry.state = 'pending' + enrichmentByAgentHost.set(key, entry) + + // Why: model discovery must never delay rendering or launching; the seed is + // immediately usable while this once-per-host probe runs in the background. + void args + .discover() + .then((discovered) => { + entry.state = 'settled' + if (!discovered || discovered.length === 0) { + return + } + entry.models = mergeCatalogModels(catalog.models, discovered) + for (const listener of entry.listeners) { + listener([...entry.models]) + } + }) + .catch(() => { + entry.state = 'settled' + }) +} + +export function clearNativeChatModelEnrichmentForTests(): void { + enrichmentByAgentHost.clear() +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-labels.ts b/src/renderer/src/components/native-chat/native-chat-session-option-labels.ts new file mode 100644 index 00000000000..33b940d88f3 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-labels.ts @@ -0,0 +1,135 @@ +import type { + SessionOptionDescriptor, + SessionOptionDisabledReason, + SessionOptionSelectChoice +} from '../../../../shared/native-chat-session-options' +import { translate } from '@/i18n/i18n' + +export function nativeChatSessionOptionLabel(descriptor: SessionOptionDescriptor): string { + switch (descriptor.id) { + case 'model': + return translate('components.native-chat.composer.model', 'Model') + case 'effort': + return translate('components.native-chat.composer.effort', descriptor.label) + case 'fastMode': + return translate('components.native-chat.composer.fastMode', 'Fast mode') + case 'thinking': + return translate('components.native-chat.composer.thinking', 'Thinking') + default: + return descriptor.label + } +} + +export function nativeChatSessionChoiceLabel(choice: SessionOptionSelectChoice): string { + switch (choice.value) { + case 'minimal': + return translate('components.native-chat.composer.optionValue.minimal', 'Minimal') + case 'low': + return translate('components.native-chat.composer.optionValue.low', 'Low') + case 'medium': + return translate('components.native-chat.composer.optionValue.medium', 'Medium') + case 'high': + return translate('components.native-chat.composer.optionValue.high', 'High') + case 'xhigh': + return translate('components.native-chat.composer.optionValue.xhigh', 'Extra high') + case 'max': + return translate('components.native-chat.composer.optionValue.max', 'Max') + default: + return choice.label + } +} + +export function nativeChatSessionOptionDisabledReason( + reason: SessionOptionDisabledReason | undefined +): string | null { + // Exhaustive over SessionOptionDisabledReason: a new key is a compile error + // here, so the localized label can never silently drift from the producer. + switch (reason) { + case 'set-when-session-starts': + return translate( + 'components.native-chat.composer.setWhenSessionStarts', + 'Set when the session starts.' + ) + case 'available-after-session-start': + return translate( + 'components.native-chat.composer.availableAfterSessionStarts', + 'Available after the session starts.' + ) + default: + return null + } +} + +export function nativeChatModelPillLabel(descriptor: SessionOptionDescriptor): string { + const modelLabel = translate('components.native-chat.composer.model', 'Model') + if ( + descriptor.valueSource === 'unknown' || + descriptor.kind.type !== 'select' || + !descriptor.kind.currentValue + ) { + return modelLabel + } + const valueLabel = nativeChatSessionChoiceLabel( + descriptor.kind.choices.find((choice) => choice.value === descriptor.kind.currentValue) ?? { + value: descriptor.kind.currentValue, + label: descriptor.kind.currentValue + } + ) + return translate('components.native-chat.composer.modelWithValue', 'Model: {{value0}}', { + value0: valueLabel + }) +} + +export function nativeChatOptionsPillTitle( + descriptors: readonly SessionOptionDescriptor[] +): string { + const effort = descriptors.find((descriptor) => descriptor.id === 'effort') + // Why: an effort-backed group is primarily the effort picker, even when it also reports modes. + return effort + ? nativeChatSessionOptionLabel(effort) + : translate('components.native-chat.composer.sessionOptions', 'Session options') +} + +export function nativeChatOptionsPillLabel( + descriptors: readonly SessionOptionDescriptor[] +): string { + const effort = descriptors.find((descriptor) => descriptor.id === 'effort') + const labels: string[] = [] + for (const descriptor of descriptors) { + if (descriptor.valueSource === 'unknown') { + continue + } + if (descriptor.kind.type === 'select' && descriptor.kind.currentValue) { + const choice = descriptor.kind.choices.find( + (candidate) => candidate.value === descriptor.kind.currentValue + ) + labels.push( + nativeChatSessionChoiceLabel( + choice ?? { + value: descriptor.kind.currentValue, + label: descriptor.kind.currentValue + } + ) + ) + } else if (descriptor.kind.type === 'boolean' && descriptor.kind.currentValue === true) { + labels.push( + descriptor.id === 'fastMode' + ? translate('components.native-chat.composer.optionValue.fast', 'Fast') + : nativeChatSessionOptionLabel(descriptor) + ) + } + } + if (labels.length > 0) { + const joinedLabels = labels.join(' · ') + if (effort) { + return translate('components.native-chat.composer.effortWithValue', 'Effort: {{value0}}', { + value0: joinedLabels + }) + } + return joinedLabels + } + if (effort) { + return nativeChatSessionOptionLabel(effort) + } + return translate('components.native-chat.composer.options', 'Options') +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-reporting.ts b/src/renderer/src/components/native-chat/native-chat-session-option-reporting.ts new file mode 100644 index 00000000000..411a0f988d7 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-reporting.ts @@ -0,0 +1,28 @@ +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' +import type { NativeChatSessionOptionRecord } from './native-chat-session-option-cache' + +export function applyNativeChatReportedSessionOptions( + record: NativeChatSessionOptionRecord, + values: Record +): boolean { + const modelId = typeof values.model === 'string' ? values.model : null + if (!modelId) { + return false + } + const modelChanged = record.model?.value !== modelId + let changed = modelChanged || record.model?.source !== 'reported' + record.model = { value: modelId, source: 'reported' } + const modelValues = modelChanged ? {} : { ...record.valuesByModel[modelId] } + for (const [id, value] of Object.entries(values)) { + if (id === 'model') { + continue + } + const current = modelValues[id] + if (current?.value !== value || current.source !== 'reported') { + changed = true + } + modelValues[id] = { value, source: 'reported' } + } + record.valuesByModel[modelId] = modelValues + return changed +} diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts b/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts new file mode 100644 index 00000000000..987e66edf4b --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts @@ -0,0 +1,178 @@ +import { + findCatalogModel, + type AgentSessionOptionCatalog, + type CatalogMidSessionApply, + type CatalogModel, + type CatalogOption +} from '../../../../shared/agent-session-option-catalog' +import type { + SessionOptionDescriptor, + SessionOptionSelectChoice, + SessionOptionValue +} from '../../../../shared/native-chat-session-options' +import type { + NativeChatSessionOptionRecord, + TrackedNativeChatSessionOption +} from './native-chat-session-option-cache' +import { translate } from '@/i18n/i18n' + +export type NativeChatSessionOptionMode = 'draft' | 'live' + +function choiceWithCurrent( + choices: readonly SessionOptionSelectChoice[], + tracked: TrackedNativeChatSessionOption | undefined +): SessionOptionSelectChoice[] { + const result = [...choices] + const current = typeof tracked?.value === 'string' ? tracked.value : null + if (current && !result.some((choice) => choice.value === current)) { + result.push({ value: current, label: current }) + } + return result +} + +function settableState(args: { + mode: NativeChatSessionOptionMode + apply: { launchArgs?: unknown; composedIntoModel?: true; midSession?: CatalogMidSessionApply } + composedModelApply?: { midSession?: CatalogMidSessionApply } +}): Pick { + if (args.mode === 'draft') { + return args.apply.launchArgs || args.apply.composedIntoModel + ? { settable: true } + : { settable: false, disabledReason: 'available-after-session-start' } + } + const midSession = args.apply.midSession + if (args.apply.composedIntoModel && args.composedModelApply?.midSession?.kind === 'command') { + return { settable: true } + } + return midSession && midSession.kind !== 'unsupported' + ? { settable: true } + : { settable: false, disabledReason: 'set-when-session-starts' } +} + +function actionForApply( + apply: { midSession?: CatalogMidSessionApply }, + tracked: TrackedNativeChatSessionOption | undefined, + mode: NativeChatSessionOptionMode +): SessionOptionDescriptor['action'] { + if (mode !== 'live') { + return undefined + } + if (apply.midSession?.kind === 'agent-picker') { + return { type: 'agent-picker' } + } + if (apply.midSession?.kind === 'toggle-command' && !tracked) { + return { type: 'toggle-command' } + } + return undefined +} + +function optionDescriptor(args: { + option: CatalogOption + tracked: TrackedNativeChatSessionOption | undefined + mode: NativeChatSessionOptionMode + composedModelApply: AgentSessionOptionCatalog['modelApply'] +}): SessionOptionDescriptor | null { + const { option, tracked, mode, composedModelApply } = args + const action = actionForApply(option.apply, tracked, mode) + const settable = settableState({ mode, apply: option.apply, composedModelApply }) + if (option.kind.type === 'select') { + const choices = choiceWithCurrent(option.kind.choices, tracked) + if (choices.length <= 1) { + return null + } + return { + id: option.id, + label: option.label, + ...(option.description ? { description: option.description } : {}), + ...(option.category ? { category: option.category } : {}), + kind: { + type: 'select', + ...(typeof tracked?.value === 'string' ? { currentValue: tracked.value } : {}), + choices + }, + valueSource: tracked?.source ?? 'unknown', + ...settable, + ...(action ? { action } : {}) + } + } + return { + id: option.id, + label: option.label, + ...(option.description ? { description: option.description } : {}), + ...(option.category ? { category: option.category } : {}), + kind: { + type: 'boolean', + ...(typeof tracked?.value === 'boolean' ? { currentValue: tracked.value } : {}) + }, + valueSource: tracked?.source ?? 'unknown', + ...settable, + ...(action ? { action } : {}) + } +} + +export function buildNativeChatSessionOptionSnapshot(args: { + catalog: AgentSessionOptionCatalog + models: readonly CatalogModel[] + record: NativeChatSessionOptionRecord + mode: NativeChatSessionOptionMode +}): SessionOptionDescriptor[] { + const { catalog, models, record, mode } = args + const modelTracked = record.model + const modelChoices = choiceWithCurrent( + models.map(({ id, label, description }) => ({ + value: id, + label, + ...(description ? { description } : {}) + })), + modelTracked + ) + const modelSettable = settableState({ mode, apply: catalog.modelApply }) + const modelAction = actionForApply(catalog.modelApply, modelTracked, mode) + const snapshot: SessionOptionDescriptor[] = [ + { + id: 'model', + label: translate('components.native-chat.composer.model', 'Model'), + category: 'model', + kind: { + type: 'select', + ...(typeof modelTracked?.value === 'string' ? { currentValue: modelTracked.value } : {}), + choices: modelChoices + }, + valueSource: modelTracked?.source ?? 'unknown', + ...modelSettable, + ...(modelAction ? { action: modelAction } : {}) + } + ] + if (typeof modelTracked?.value !== 'string') { + return snapshot + } + const model = findCatalogModel({ ...catalog, models: [...models] }, modelTracked.value) + const trackedValues = record.valuesByModel[modelTracked.value] ?? {} + for (const option of model?.options ?? []) { + const descriptor = optionDescriptor({ + option, + tracked: trackedValues[option.id], + mode, + composedModelApply: catalog.modelApply + }) + if (descriptor) { + snapshot.push(descriptor) + } + } + return snapshot +} + +export function flattenNativeChatSessionOptionRecord( + record: NativeChatSessionOptionRecord, + modelId: string +): Record { + return { + model: modelId, + ...Object.fromEntries( + Object.entries(record.valuesByModel[modelId] ?? {}).map(([id, tracked]) => [ + id, + tracked.value + ]) + ) + } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-dictation-actions.ts b/src/renderer/src/components/native-chat/use-native-chat-dictation-actions.ts new file mode 100644 index 00000000000..7d4caf72b8b --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-dictation-actions.ts @@ -0,0 +1,28 @@ +import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react' +import { dispatchDictationControl } from '../dictation/dictation-control-events' + +export function useNativeChatDictationActions(args: { + textareaRef: RefObject + setDictationPressed: Dispatch> +}): { + toggleDictation: () => void + startHoldDictation: () => void + stopHoldDictation: () => void +} { + const { setDictationPressed, textareaRef } = args + const focusForDictation = useCallback(() => textareaRef.current?.focus(), [textareaRef]) + const toggleDictation = useCallback(() => { + focusForDictation() + dispatchDictationControl('toggle') + }, [focusForDictation]) + const startHoldDictation = useCallback(() => { + setDictationPressed(true) + focusForDictation() + dispatchDictationControl('start') + }, [focusForDictation, setDictationPressed]) + const stopHoldDictation = useCallback(() => { + setDictationPressed(false) + dispatchDictationControl('stop') + }, [setDictationPressed]) + return { toggleDictation, startHoldDictation, stopHoldDictation } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.ts b/src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.ts new file mode 100644 index 00000000000..5a6094cfea3 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-file-attachment-actions.ts @@ -0,0 +1,27 @@ +import { useCallback, useEffect } from 'react' +import { NATIVE_FILE_DROP_TARGET } from '../../../../shared/native-file-drop' + +export function useNativeChatFileAttachmentActions( + attachExternalPaths: (paths: string[]) => void +): { pickAttachment: () => void } { + useEffect( + () => + window.api.ui.onFileDrop((payload) => { + if (payload.target === NATIVE_FILE_DROP_TARGET.composer) { + attachExternalPaths(payload.paths) + } + }), + [attachExternalPaths] + ) + + const pickAttachment = useCallback(() => { + void (async () => { + const filePath = await window.api.shell.pickAttachment() + if (filePath) { + attachExternalPaths([filePath]) + } + })() + }, [attachExternalPaths]) + + return { pickAttachment } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-file-link-click.ts b/src/renderer/src/components/native-chat/use-native-chat-file-link-click.ts new file mode 100644 index 00000000000..3e745bcd2bc --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-file-link-click.ts @@ -0,0 +1,27 @@ +import { useCallback } from 'react' +import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' +import { openDetectedFilePath } from '@/components/terminal-pane/terminal-file-open-routing' +import { resolveNativeChatFileLink, type NativeChatFileLinkContext } from './native-chat-file-link' + +export function useNativeChatFileLinkClick( + context: NativeChatFileLinkContext | null +): CommentMarkdownLinkClickHandler | undefined { + const openFileLink = useCallback( + (event, href) => { + const target = resolveNativeChatFileLink(href, context) + if (!target || !context) { + return + } + event.preventDefault() + event.stopPropagation() + openDetectedFilePath(target.absolutePath, target.line, target.column, { + worktreeId: context.worktreeId, + worktreePath: context.worktreePath, + runtimeEnvironmentId: context.runtimeEnvironmentId, + openWithSystemDefault: event.shiftKey + }) + }, + [context] + ) + return context ? openFileLink : undefined +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts new file mode 100644 index 00000000000..9720ab9f950 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts @@ -0,0 +1,114 @@ +import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react' +import type { AgentType } from '../../../../shared/agent-status-types' +import { emitNativeChatMessageSent } from '@/lib/native-chat-telemetry' +import { + nativeChatComposerTargetIsRemote, + type NativeChatResolvedTarget +} from './native-chat-composer-target' +import { pushHistory, type HistoryState } from './native-chat-composer-state' +import { sendNativeChatMessageVerified } from './native-chat-runtime-send' +import { + createClaudeModelSwitchConfirmationObserver, + type ClaudeModelSwitchConfirmationObserver +} from './claude-model-switch-confirmation' +import type { NativeChatSessionOptionDispatchCommand } from './native-chat-session-option-command-dispatch' + +export function useNativeChatSessionOptionCommand(args: { + agent: AgentType + disabled: boolean + onSlashCommand?: (command: string) => void + resolveTarget: () => NativeChatResolvedTarget | null + setHistory: Dispatch> +}): { dispatch: NativeChatSessionOptionDispatchCommand; isDispatching: boolean } { + const { agent, disabled, onSlashCommand, resolveTarget, setHistory } = args + const mountedRef = useRef(true) + const activeObserversRef = useRef(new Set()) + const activeSendsRef = useRef(new Set()) + // Why: expose an in-flight flag so the composer can block a normal send while + // an option command's body+delayed-Enter (and its confirmation observer) is + // still writing to the same pty — otherwise the two write sequences interleave + // on one input line and corrupt both. + const [isDispatching, setIsDispatching] = useState(false) + + useEffect(() => { + mountedRef.current = true + const observers = activeObserversRef.current + const sends = activeSendsRef.current + return () => { + mountedRef.current = false + for (const send of sends) { + send.abort() + } + sends.clear() + for (const observer of observers) { + observer.dispose() + } + observers.clear() + } + }, []) + + const dispatch = useCallback( + async (command, options) => { + const target = resolveTarget() + if (!target || disabled) { + throw new Error('No live terminal is available.') + } + const detectClaudeConfirmation = + options?.detectAgentInteraction === 'claude-model-switch-confirmation' + let observer: ClaudeModelSwitchConfirmationObserver | null = null + if (detectClaudeConfirmation) { + observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: target.ptyId, + settings: target.settings, + expectedModelLabel: options?.expectedChoiceLabel ?? null + }) + activeObserversRef.current.add(observer) + await observer.ready + if (!mountedRef.current) { + activeObserversRef.current.delete(observer) + observer.dispose() + throw new Error('Native chat command was canceled because the composer closed.') + } + // Why: arm only after the observer reaches the live PTY tail, then + // submit immediately so historical output cannot satisfy the match. + observer.arm() + } + const sendController = new AbortController() + activeSendsRef.current.add(sendController) + setIsDispatching(true) + try { + const accepted = await sendNativeChatMessageVerified( + target.settings, + target.ptyId, + command, + sendController.signal + ) + if (!accepted) { + throw new Error('The terminal did not accept the command.') + } + // Why: start the model-switch detection clock only now that the command + // has been accepted, so SSH/remote send latency doesn't eat the window + // before the agent has responded. + observer?.startDetection() + onSlashCommand?.(command.trim()) + emitNativeChatMessageSent({ + agent, + runtime: nativeChatComposerTargetIsRemote(target.ptyId) ? 'remote' : 'local' + }) + setHistory((previous) => pushHistory(previous, command)) + const outcome = observer ? await observer.result : undefined + return { outcome } + } finally { + activeSendsRef.current.delete(sendController) + setIsDispatching(activeSendsRef.current.size > 0) + if (observer) { + activeObserversRef.current.delete(observer) + observer.dispose() + } + } + }, + [agent, disabled, onSlashCommand, resolveTarget, setHistory] + ) + + return { dispatch, isDispatching } +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-options.ts b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts new file mode 100644 index 00000000000..23c5f87a294 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts @@ -0,0 +1,155 @@ +import { useEffect, useMemo, useSyncExternalStore } from 'react' +import type { AgentType } from '../../../../shared/agent-status-types' +import { updateNativeChatSessionOptionDefaults } from '../../../../shared/native-chat-session-option-defaults' +import type { SessionOptionDescriptor } from '../../../../shared/native-chat-session-options' +import { useAppStore } from '../../store' +import { + createNativeChatPtySessionOptions, + type NativeChatPtySessionOptionsSurface +} from './native-chat-pty-session-options' +import type { NativeChatSessionOptionDispatchCommand } from './native-chat-session-option-command-dispatch' +import { + ensureNativeChatModelEnrichment, + readNativeChatEnrichedModels, + subscribeNativeChatEnrichedModels +} from './native-chat-session-option-enrichment' +import { + discoverNativeChatCatalogModels, + resolveNativeChatModelDiscoveryContext +} from './native-chat-session-option-discovery' +import { readClaudeSessionOptionsFromTerminalScreen } from './claude-terminal-session-options' + +const EMPTY_SNAPSHOT: SessionOptionDescriptor[] = [] +const subscribeEmpty = (): (() => void) => () => {} +const getEmptySnapshot = (): SessionOptionDescriptor[] => EMPTY_SNAPSHOT + +export function useNativeChatSessionOptions(args: { + agent: AgentType + terminalTabId: string + targetPtyId: string | null + dispatchCommand: NativeChatSessionOptionDispatchCommand + onAgentPicker?: () => void + readTerminalScreen?: () => string | null +}): { + surface: NativeChatPtySessionOptionsSurface | null + snapshot: SessionOptionDescriptor[] +} { + const { agent, terminalTabId, targetPtyId, dispatchCommand, onAgentPicker, readTerminalScreen } = + args + const discoveryContext = useMemo( + () => resolveNativeChatModelDiscoveryContext(terminalTabId), + [terminalTabId] + ) + const surface = useMemo(() => { + // Why: native chat currently attaches only after startup is already queued; + // exposing a draft picker here would claim it can still mutate that command. + if (!targetPtyId) { + return null + } + const scopeKey = targetPtyId ?? terminalTabId + const reportedValues = + agent === 'claude' ? readClaudeSessionOptionsFromTerminalScreen(readTerminalScreen?.()) : null + let settingsWrite = Promise.resolve() + return createNativeChatPtySessionOptions({ + agent, + scopeKey, + ...(targetPtyId ? { fallbackScopeKey: terminalTabId } : {}), + ...(discoveryContext + ? { + initialModels: + readNativeChatEnrichedModels(agent, discoveryContext.hostKey) ?? undefined + } + : {}), + mode: targetPtyId ? 'live' : 'draft', + reportedValues, + dispatchCommand, + onAgentPicker, + persistSelection: async ({ modelId, optionId, value }) => { + // Why: read the live persisted defaults at write time (after any prior + // write in this chain settles) and merge only this selection onto them, + // rather than a baseline captured once at surface creation. A frozen + // baseline would let a second same-agent pane's write be clobbered, + // since updateSettings shallow-merges nativeChatSessionOptions. Chaining + // still keeps rapid consecutive picks in selection order. + settingsWrite = settingsWrite + .catch(() => undefined) + .then(() => { + const base = useAppStore.getState().settings?.nativeChatSessionOptions + const next = updateNativeChatSessionOptionDefaults({ + persisted: base, + agent, + modelId, + optionId, + value + }) + return useAppStore.getState().updateSettings({ nativeChatSessionOptions: next }) + }) + await settingsWrite + } + }) + }, [ + agent, + dispatchCommand, + discoveryContext, + onAgentPicker, + readTerminalScreen, + targetPtyId, + terminalTabId + ]) + + useEffect(() => { + if (!surface || agent !== 'claude') { + return + } + let cancelled = false + const reportCurrentValues = async (): Promise => { + let authoritativeScreen: string | null = null + if (targetPtyId && window.api?.pty?.getMainBufferSnapshot) { + try { + const snapshot = await window.api.pty.getMainBufferSnapshot(targetPtyId, { + scrollbackRows: 0 + }) + // Why: the API snapshots the main buffer, which is stale while a TUI + // owns the alternate screen. The mounted xterm is authoritative then. + authoritativeScreen = snapshot?.alternateScreen ? null : (snapshot?.data ?? null) + } catch { + // The mounted renderer buffer remains a transport-neutral fallback. + } + } + const reportedValues = + readClaudeSessionOptionsFromTerminalScreen(authoritativeScreen) ?? + readClaudeSessionOptionsFromTerminalScreen(readTerminalScreen?.()) + if (!cancelled && reportedValues) { + surface.reportSessionOptions(reportedValues) + } + } + void reportCurrentValues() + return () => { + cancelled = true + } + }, [agent, readTerminalScreen, surface, targetPtyId]) + + useEffect(() => { + if (!surface || !discoveryContext) { + return + } + const unsubscribe = subscribeNativeChatEnrichedModels( + agent, + discoveryContext.hostKey, + (models) => surface.replaceModels(models) + ) + ensureNativeChatModelEnrichment({ + agent, + hostKey: discoveryContext.hostKey, + discover: () => discoverNativeChatCatalogModels(agent, discoveryContext.runtime) + }) + return unsubscribe + }, [agent, discoveryContext, surface]) + + const snapshot = useSyncExternalStore( + surface?.subscribe ?? subscribeEmpty, + surface?.getSnapshot ?? getEmptySnapshot, + surface?.getSnapshot ?? getEmptySnapshot + ) + return { surface, snapshot } +} diff --git a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx index 6beb281bbc5..a0b8e894b47 100644 --- a/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx +++ b/src/renderer/src/components/onboarding/OnboardingInlineCommandTerminal.tsx @@ -294,6 +294,7 @@ export function OnboardingInlineCommandTerminal({ cwd={cwd} isActive isVisible + showSplitButton={false} onPtyExit={() => { onTerminalExit?.() closeTab(tabId, { recordInteraction: false, reason: 'pty-exit' }) diff --git a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts index 73571a2f330..1e57102600f 100644 --- a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts +++ b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts @@ -14,7 +14,8 @@ describe('buildOnboardingFolderAgentStartup', () => { }) expect(startup).toEqual({ - command: "codex '--dangerously-bypass-approvals-and-sandbox'", + command: + "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--dangerously-bypass-approvals-and-sandbox'", env: {}, launchAgent: 'codex', launchConfig: { @@ -22,6 +23,7 @@ describe('buildOnboardingFolderAgentStartup', () => { agentArgs: '--dangerously-bypass-approvals-and-sandbox', agentEnv: {} }, + sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' }, telemetry: { agent_kind: 'codex', launch_source: 'onboarding', @@ -97,7 +99,8 @@ describe('buildOnboardingFolderAgentStartup', () => { false ) ).toEqual({ - command: "echo onboarding-folder-agent '--dangerously-bypass-approvals-and-sandbox'", + command: + "echo onboarding-folder-agent '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--dangerously-bypass-approvals-and-sandbox'", env: {}, launchAgent: 'codex', launchConfig: { @@ -105,6 +108,7 @@ describe('buildOnboardingFolderAgentStartup', () => { agentArgs: '--dangerously-bypass-approvals-and-sandbox', agentEnv: {} }, + sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' }, telemetry: { agent_kind: 'codex', launch_source: 'onboarding', diff --git a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts index 336a3882b3d..d3afd06e139 100644 --- a/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts +++ b/src/renderer/src/components/right-sidebar/buildSourceControlAgentDeliveryPlan.ts @@ -3,6 +3,7 @@ import { useAppStore } from '@/store' import type { TuiAgent } from '../../../../shared/types' import type { SourceControlAgentActionDeliveryPlanState } from './SourceControlAgentActionDialogForm' import { buildSourceControlAgentConnectionErrorPlan } from './source-control-agent-action-dialog-support' +import { resolveNativeChatSessionOptionDefaults } from '../../../../shared/native-chat-session-option-defaults' type BuildSourceControlAgentDeliveryPlanArgs = { selectedAgent: TuiAgent | null @@ -34,6 +35,12 @@ export function buildSourceControlAgentDeliveryPlan({ agent: selectedAgent, commandInput, agentArgs, + sessionOptions: selectedAgent + ? resolveNativeChatSessionOptionDefaults( + useAppStore.getState().settings?.nativeChatSessionOptions, + selectedAgent + ) + : undefined, promptDelivery, detectedAgents, disabledAgents: useAppStore.getState().settings?.disabledTuiAgents, diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 0d4c2d5d537..ef50a504a3a 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -12,6 +12,7 @@ import { import { toast } from 'sonner' import type { GlobalSettings, OrcaHooks, ProjectHostSetup, Repo } from '../../../../shared/types' import type { SpeechModelState } from '../../../../shared/speech-types' +import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness' import type { SourceControlAiSettings, SourceControlAiSettingsPatch @@ -101,6 +102,7 @@ import { } from '@/hooks/useInstalledAgentSkills' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { useSkillFreshness } from '@/hooks/useSkillFreshness' +import { getSkillFreshnessDisplayStatus } from '@/lib/skill-freshness-display-status' import { deriveNeededSectionIds, getInitialMountedSectionIds } from './settings-load-performance' import { translate } from '@/i18n/i18n' import { getProjectHostSetupProjectionFromState } from '../../store/selectors' @@ -206,9 +208,10 @@ function getSettingsNavGroupDefinitionsForSearch( } function getSkillNavInstallStatus(skill: { + name: string installed: boolean loading: boolean - updateAvailable?: boolean + inventory: SkillFreshnessInventory | null }): SettingsNavInstallStatus { if (skill.loading) { return 'checking' @@ -216,7 +219,7 @@ function getSkillNavInstallStatus(skill: { if (!skill.installed) { return 'install' } - return skill.updateAvailable ? 'update-available' : 'installed' + return getSkillFreshnessDisplayStatus(skill.inventory, skill.name) } function hasReadyVoiceModel( @@ -733,16 +736,16 @@ function Settings(): React.JSX.Element { orchestrationSkill const { installed: computerUseSkillInstalled, loading: computerUseSkillLoading } = computerUseSkill - const eligibleUpdateSkillNames = skillFreshnessInventory?.eligibleUpdateNames const capabilityInstallStatusBySectionId = useMemo(() => { - const eligibleUpdates = new Set(skillFreshnessApplies ? (eligibleUpdateSkillNames ?? []) : []) + const applicableFreshnessInventory = skillFreshnessApplies ? skillFreshnessInventory : null const next = new Map([ [ 'orchestration', getSkillNavInstallStatus({ + name: ORCHESTRATION_SKILL_NAME, installed: orchestrationSkillInstalled, loading: orchestrationSkillLoading, - updateAvailable: eligibleUpdates.has(ORCHESTRATION_SKILL_NAME) + inventory: applicableFreshnessInventory }) ] ]) @@ -750,9 +753,10 @@ function Settings(): React.JSX.Element { next.set( 'computer-use', getSkillNavInstallStatus({ + name: COMPUTER_USE_SKILL_NAME, installed: computerUseSkillInstalled, loading: computerUseSkillLoading, - updateAvailable: eligibleUpdates.has(COMPUTER_USE_SKILL_NAME) + inventory: applicableFreshnessInventory }) ) if (settings) { @@ -770,13 +774,13 @@ function Settings(): React.JSX.Element { }, [ computerUseSkillInstalled, computerUseSkillLoading, - eligibleUpdateSkillNames, modelStates, orchestrationSkillInstalled, orchestrationSkillLoading, settings, showDesktopOnlySettings, skillFreshnessApplies, + skillFreshnessInventory, voiceModelStatesLoading ]) const navSections = useMemo( diff --git a/src/renderer/src/components/settings/SettingsSidebar.test.tsx b/src/renderer/src/components/settings/SettingsSidebar.test.tsx index 11eb49163a0..6fec899efea 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.test.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.test.tsx @@ -62,6 +62,12 @@ function renderSidebar( title: 'Voice', icon: Mic, installStatus: 'installed' + }, + { + id: 'computer-use', + title: 'Computer Use', + icon: Bot, + installStatus: 'up-to-date' } ] }, @@ -114,6 +120,7 @@ describe('SettingsSidebar', () => { expect(markup).toContain('Not installed') expect(markup).toContain('Installed') + expect(markup).toContain('Up to date') expect(markup).toContain('Optional') }) diff --git a/src/renderer/src/components/settings/SettingsSidebar.tsx b/src/renderer/src/components/settings/SettingsSidebar.tsx index 8029eabda1a..54908713bc6 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.tsx @@ -155,6 +155,8 @@ export function SettingsSidebar({ ) case 'installed': return translate('auto.components.settings.AgentSkillSetupPanel.9fcebceb2a', 'Installed') + case 'up-to-date': + return translate('auto.components.skills.SkillFreshnessStatusPill.upToDate', 'Up to date') case 'update-available': return translate( 'auto.components.skills.SkillFreshnessStatusPill.updateAvailable', @@ -167,7 +169,7 @@ export function SettingsSidebar({ const installStatusClassName = (status: SettingsNavInstallStatus): string => cn( 'ml-auto shrink-0 rounded-full border px-1.5 py-0.5 text-[10px] font-medium leading-none', - status === 'installed' + status === 'installed' || status === 'up-to-date' ? 'border-status-success-border bg-status-success-background text-status-success' : status === 'update-available' ? 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300' diff --git a/src/renderer/src/components/settings/ShortcutCommandBlock.tsx b/src/renderer/src/components/settings/ShortcutCommandBlock.tsx index 853e6ae3c62..c4eaa9cbe84 100644 --- a/src/renderer/src/components/settings/ShortcutCommandBlock.tsx +++ b/src/renderer/src/components/settings/ShortcutCommandBlock.tsx @@ -106,6 +106,8 @@ export function ShortcutCommandBlock({ /> ) + // Why: ShortcutsPane already filters rows against the global query; + // forceVisible skips the re-match here that would hide rows it kept. return (
diff --git a/src/renderer/src/components/settings/ShortcutFilterRail.test.ts b/src/renderer/src/components/settings/ShortcutFilterRail.test.ts index d979415798a..8dd6a039010 100644 --- a/src/renderer/src/components/settings/ShortcutFilterRail.test.ts +++ b/src/renderer/src/components/settings/ShortcutFilterRail.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { KeybindingActionId } from '../../../../shared/keybindings' import { SHORTCUT_LOCAL_SEARCH_QUERY_MAX_BYTES, + buildShortcutGlobalSearchMatcher, isShortcutLocalSearchQueryTooLarge, matchesShortcutLocalSearch, normalizeShortcutLocalSearchQuery, @@ -52,6 +53,40 @@ describe('ShortcutFilterRail search helpers', () => { expect(normalizeShortcutLocalSearchQuery(query)).toBeNull() }) + it('narrows rows when the global settings query matches specific rows', () => { + const settingsRow = createShortcutRow() + const worktreeRow: ShortcutRowModel = { + ...createShortcutRow(), + item: { + ...createShortcutRow().item, + id: 'worktree.create' as KeybindingActionId, + title: 'Create worktree', + searchKeywords: ['worktree', 'create'] + } + } + + const matcher = buildShortcutGlobalSearchMatcher([settingsRow, worktreeRow], 'worktree') + + expect(matcher(settingsRow)).toBe(false) + expect(matcher(worktreeRow)).toBe(true) + }) + + it('keeps every row visible on a pane-title-only global query', () => { + const rows = [createShortcutRow()] + + // Mirrors sidebar search selecting the pane with a query that matches only + // pane-level metadata (e.g. a localized pane title) and no row metadata. + const matcher = buildShortcutGlobalSearchMatcher(rows, '단축키') + + expect(matcher(rows[0])).toBe(true) + }) + + it('keeps every row visible when the global query is empty', () => { + const rows = [createShortcutRow()] + + expect(buildShortcutGlobalSearchMatcher(rows, '')(rows[0])).toBe(true) + }) + it('rejects oversized pasted shortcut searches before reading row metadata', () => { const oversizedQuery = 'secret-shortcut-search'.repeat(SHORTCUT_LOCAL_SEARCH_QUERY_MAX_BYTES) const row = { diff --git a/src/renderer/src/components/settings/ShortcutFilterRail.tsx b/src/renderer/src/components/settings/ShortcutFilterRail.tsx index 76445a67e9a..222127f88ed 100644 --- a/src/renderer/src/components/settings/ShortcutFilterRail.tsx +++ b/src/renderer/src/components/settings/ShortcutFilterRail.tsx @@ -6,7 +6,7 @@ import { cn } from '../../lib/utils' import { Button } from '../ui/button' import { Input } from '../ui/input' import type { ShortcutTerminalStatus } from './shortcut-terminal-status' -import type { SettingsSearchEntry } from './settings-search' +import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search' import { translate } from '@/i18n/i18n' export type ShortcutFilter = 'all' | 'modified' | 'unassigned' | 'conflicts' @@ -60,6 +60,17 @@ export function getShortcutSearchEntry(row: ShortcutRowModel): SettingsSearchEnt } } +// Why: a sidebar query can select this pane by title alone while matching zero +// rows; that would blank the list, so zero-row queries keep every row visible. +export function buildShortcutGlobalSearchMatcher( + rows: readonly ShortcutRowModel[], + searchQuery: string +): (row: ShortcutRowModel) => boolean { + const rowMatches = (row: ShortcutRowModel): boolean => + matchesSettingsSearch(searchQuery, getShortcutSearchEntry(row)) + return rows.some(rowMatches) ? rowMatches : () => true +} + export function matchesShortcutFilter(row: ShortcutRowModel, filter: ShortcutFilter): boolean { switch (filter) { case 'modified': diff --git a/src/renderer/src/components/settings/ShortcutsPane.tsx b/src/renderer/src/components/settings/ShortcutsPane.tsx index b6fe5b3556c..29f9661d196 100644 --- a/src/renderer/src/components/settings/ShortcutsPane.tsx +++ b/src/renderer/src/components/settings/ShortcutsPane.tsx @@ -25,7 +25,7 @@ import { sameBindings } from './keybinding-override-edits' import { - getShortcutSearchEntry, + buildShortcutGlobalSearchMatcher, matchesShortcutFilter, matchesShortcutLocalSearch, normalizeShortcutLocalSearchQuery, @@ -138,9 +138,10 @@ export function ShortcutsPane(): React.JSX.Element { ) const shortcutSearchQuery = normalizeShortcutLocalSearchQuery(shortcutQuery) const shortcutRows = shortcutGroups.flatMap((group) => group.rows) + const matchesShortcutGlobalSearch = buildShortcutGlobalSearchMatcher(shortcutRows, searchQuery) const matchesShortcutSearch = (row: ShortcutRowsByGroup['rows'][number]): boolean => shortcutSearchQuery !== null && - matchesSettingsSearch(searchQuery, getShortcutSearchEntry(row)) && + matchesShortcutGlobalSearch(row) && matchesShortcutLocalSearch(row, shortcutSearchQuery, platform) const baseVisibleRows = shortcutRows.filter((row) => matchesShortcutSearch(row)) const filterCounts: Record = { diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 81671c20616..948228984d8 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -5711,9 +5711,10 @@ const WorktreeList = React.memo(function WorktreeList({ groupBy, repos: visibleReposForRows, worktreesByRepo, + visibleWorktrees, filterRepoIds }) - }, [filterRepoIds, groupBy, visibleReposForRows, worktreesByRepo]) + }, [filterRepoIds, groupBy, visibleReposForRows, visibleWorktrees, worktreesByRepo]) const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos]) // Why: buildRows only needs which creates exist and their repo. Subscribe on a diff --git a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts index aaa3be238e9..8a33151f73a 100644 --- a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts +++ b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts @@ -38,6 +38,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'repo', repos: [repo], worktreesByRepo: { [repo.id]: [] }, + visibleWorktrees: [], filterRepoIds: [] }) ) @@ -51,6 +52,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'repo', repos: [repo], worktreesByRepo: {}, + visibleWorktrees: [], filterRepoIds: [] }) ) @@ -67,6 +69,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'repo', repos: [selectedRepo, hiddenRepo], worktreesByRepo: { [selectedRepo.id]: [], [hiddenRepo.id]: [] }, + visibleWorktrees: [], filterRepoIds: [selectedRepo.id] }) ) @@ -79,6 +82,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'none', repos: [repo], worktreesByRepo: { [repo.id]: [] }, + visibleWorktrees: [], filterRepoIds: [] }).size ).toBe(0) @@ -90,8 +94,109 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'repo', repos: [repo], worktreesByRepo: { [repo.id]: [worktree] }, + visibleWorktrees: [], filterRepoIds: [] }).size ).toBe(0) }) + + it('keeps grouped repos visible when workspace filters hide all of their rows', () => { + const groupedRepo: Repo = { ...repo, projectGroupId: 'group-1' } + const groupedWorktree: Worktree = { ...worktree, repoId: groupedRepo.id } + + expect( + Array.from( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [groupedRepo], + worktreesByRepo: { [groupedRepo.id]: [groupedWorktree] }, + visibleWorktrees: [], + filterRepoIds: [] + }) + ) + ).toEqual([groupedRepo.id]) + }) + + it('does not create a grouped repo placeholder when one of its workspaces is visible', () => { + const groupedRepo: Repo = { ...repo, projectGroupId: 'group-1' } + const groupedWorktree: Worktree = { ...worktree, repoId: groupedRepo.id } + + expect( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [groupedRepo], + worktreesByRepo: { [groupedRepo.id]: [groupedWorktree] }, + visibleWorktrees: [groupedWorktree], + filterRepoIds: [] + }).size + ).toBe(0) + }) + + it('still respects explicit project filters for sleep-filtered grouped members', () => { + const selected: Repo = { ...repo, id: 'repo-selected', projectGroupId: 'group-1' } + const filteredOut: Repo = { ...repo, id: 'repo-hidden', projectGroupId: 'group-1' } + const selectedWt: Worktree = { ...worktree, id: 'wt-selected', repoId: selected.id } + const hiddenWt: Worktree = { ...worktree, id: 'wt-hidden', repoId: filteredOut.id } + + expect( + Array.from( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [selected, filteredOut], + worktreesByRepo: { + [selected.id]: [selectedWt], + [filteredOut.id]: [hiddenWt] + }, + // Why: simulate Hide sleeping removing every card while the project + // filter still intentionally excludes `filteredOut`. + visibleWorktrees: [], + filterRepoIds: [selected.id] + }) + ) + ).toEqual([selected.id]) + }) + + it('placeholders only the fully-filtered members of a multi-project group', () => { + const sleeping: Repo = { ...repo, id: 'repo-sleeping', projectGroupId: 'group-1' } + const awake: Repo = { ...repo, id: 'repo-awake', projectGroupId: 'group-1' } + const sleepingWt: Worktree = { ...worktree, id: 'wt-sleeping', repoId: sleeping.id } + const awakeWt: Worktree = { ...worktree, id: 'wt-awake', repoId: awake.id } + + expect( + Array.from( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [sleeping, awake], + worktreesByRepo: { + [sleeping.id]: [sleepingWt], + [awake.id]: [awakeWt] + }, + visibleWorktrees: [awakeWt], + filterRepoIds: [] + }) + ) + ).toEqual([sleeping.id]) + }) + + it('does not placeholder ungrouped neighbors of a filtered grouped member', () => { + const grouped: Repo = { ...repo, id: 'repo-grouped', projectGroupId: 'group-1' } + const ungrouped: Repo = { ...repo, id: 'repo-ungrouped' } + const groupedWt: Worktree = { ...worktree, id: 'wt-grouped', repoId: grouped.id } + const ungroupedWt: Worktree = { ...worktree, id: 'wt-ungrouped', repoId: ungrouped.id } + + expect( + Array.from( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [grouped, ungrouped], + worktreesByRepo: { + [grouped.id]: [groupedWt], + [ungrouped.id]: [ungroupedWt] + }, + visibleWorktrees: [], + filterRepoIds: [] + }) + ) + ).toEqual([grouped.id]) + }) }) diff --git a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts index 26c248a2a65..57aee59c1bd 100644 --- a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts +++ b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts @@ -5,6 +5,7 @@ export function getEmptyProjectPlaceholderRepoIds(args: { groupBy: WorktreeGroupBy repos: readonly Repo[] worktreesByRepo: Readonly> + visibleWorktrees: readonly Worktree[] filterRepoIds: readonly string[] }): Set { if (args.groupBy !== 'repo') { @@ -12,12 +13,17 @@ export function getEmptyProjectPlaceholderRepoIds(args: { } const filterSet = args.filterRepoIds.length > 0 ? new Set(args.filterRepoIds) : null + const visibleRepoIds = new Set(args.visibleWorktrees.map((worktree) => worktree.repoId)) const placeholderRepoIds = new Set() for (const repo of args.repos) { if (filterSet && !filterSet.has(repo.id)) { continue } - if ((args.worktreesByRepo[repo.id]?.length ?? 0) === 0) { + const hasNoWorktrees = (args.worktreesByRepo[repo.id]?.length ?? 0) === 0 + // Why: workspace filters hide cards, but must not rewrite the visible + // membership of a persisted Project Group. #8865 + const isFilteredProjectGroupMember = repo.projectGroupId != null && !visibleRepoIds.has(repo.id) + if (hasNoWorktrees || isFilteredProjectGroupMember) { placeholderRepoIds.add(repo.id) } } diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index ee48eac66f2..faf2b6ede75 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -20,6 +20,7 @@ import { isWslUncPath } from '../../../../shared/wsl-paths' import { resolveLocalWindowsAgentStartupShell } from '../../../../shared/windows-terminal-shell' import type { AgentStartupShell } from '../../../../shared/tui-agent-startup-shell' import type { LaunchSource } from '../../../../shared/telemetry-events' +import type { SessionOptionValue } from '../../../../shared/native-chat-session-options' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import { getLinkedItemDisplayName, @@ -46,6 +47,7 @@ type SubmitFolderWorkspaceCreateParams = { agentCmdOverrides: Record | undefined agentArgs?: string | null agentEnv?: Record + sessionOptions?: Record terminalWindowsShell?: string | null isRemote?: boolean launchSource?: LaunchSource @@ -71,6 +73,7 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: { agentCmdOverrides: Record | undefined agentArgs?: string | null agentEnv?: Record + sessionOptions?: Record platform: NodeJS.Platform shell?: AgentStartupShell isRemote: boolean @@ -87,6 +90,7 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: { cmdOverrides: args.agentCmdOverrides ?? {}, agentArgs: args.agentArgs, agentEnv: args.agentEnv, + sessionOptions: args.sessionOptions, platform: args.platform, shell: args.shell, isRemote: args.isRemote @@ -99,6 +103,7 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: { expectedProcess: draftLaunchPlan.expectedProcess, followupPrompt: null, launchConfig: draftLaunchPlan.launchConfig, + ...(draftLaunchPlan.sessionOptions ? { sessionOptions: draftLaunchPlan.sessionOptions } : {}), ...(draftLaunchPlan.startupCommandDelivery ? { startupCommandDelivery: draftLaunchPlan.startupCommandDelivery } : {}), @@ -114,6 +119,7 @@ export function buildFolderWorkspaceLinkedStartupPlan(args: { cmdOverrides: args.agentCmdOverrides ?? {}, agentArgs: args.agentArgs, agentEnv: args.agentEnv, + sessionOptions: args.sessionOptions, platform: args.platform, shell: args.shell, isRemote: args.isRemote, @@ -159,6 +165,7 @@ export async function submitFolderWorkspaceCreate({ agentCmdOverrides, agentArgs, agentEnv, + sessionOptions, terminalWindowsShell, launchSource = 'sidebar', runtimeEnvironmentId = null, @@ -189,6 +196,7 @@ export async function submitFolderWorkspaceCreate({ agentCmdOverrides, agentArgs, agentEnv, + sessionOptions, platform: launchPlatform, shell: launchShell, isRemote: launchIsRemote @@ -200,6 +208,7 @@ export async function submitFolderWorkspaceCreate({ cmdOverrides: agentCmdOverrides ?? {}, agentArgs, agentEnv, + sessionOptions, platform: launchPlatform, shell: launchShell, isRemote: launchIsRemote, @@ -247,6 +256,7 @@ export async function submitFolderWorkspaceCreate({ launchConfig: startupPlan.launchConfig, ...(startupPlan.launchToken ? { launchToken: startupPlan.launchToken } : {}), launchAgent: quickAgent, + ...(startupPlan.sessionOptions ? { sessionOptions: startupPlan.sessionOptions } : {}), ...(startupPlan.draftPrompt ? { draftPrompt: startupPlan.draftPrompt } : {}), ...(startupPlan.startupCommandDelivery ? { startupCommandDelivery: startupPlan.startupCommandDelivery } diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index b967c4cd356..4b4012f8c7c 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -1933,6 +1933,73 @@ describe('project groups', () => { expect(rows[0]).toMatchObject({ label: 'Platform' }) }) + it('keeps sleep-filtered Project Group members as empty project headers', () => { + // Why: #8865 — Hide sleeping removes workspace cards; membership placeholders + // must still project the grouped project header so the group count stays honest. + const group: ProjectGroup = { + id: 'group-1', + name: 'Platform', + parentPath: '/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const sleepingRepo: Repo = { + ...repo, + id: 'repo-sleeping', + displayName: 'sleeping-project', + projectGroupId: group.id + } + const awakeRepo: Repo = { + ...repo, + id: 'repo-awake', + displayName: 'awake-project', + projectGroupId: group.id + } + const awakeWorktree: Worktree = { + ...worktree, + id: 'wt-awake', + repoId: awakeRepo.id, + path: '/tmp/awake' + } + + const rows = buildRows( + 'repo', + [awakeWorktree], + new Map([ + [sleepingRepo.id, sleepingRepo], + [awakeRepo.id, awakeRepo] + ]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([[awakeWorktree.id, awakeWorktree]]), + false, + undefined, + [group], + new Set([sleepingRepo.id]) + ) + + expect(rows[0]).toMatchObject({ + type: 'header', + key: 'project-group:group-1', + count: 2 + }) + // Why: empty/placeholder projects sort after projects with visible activity. + expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ + 'project-group:group-1', + `repo:${awakeRepo.id}`, + `repo:${sleepingRepo.id}` + ]) + }) + it('renders ungrouped repos as top-level repo rows when Project Groups exist', () => { const group: ProjectGroup = { id: 'group-1', diff --git a/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx index 3c858686591..d71bf0d8ba7 100644 --- a/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx @@ -1,6 +1,7 @@ import { useSkillFreshness } from '@/hooks/useSkillFreshness' import { translate } from '@/i18n/i18n' import { IntegrationStatusPill } from '@/components/integration-status-pill' +import { getSkillFreshnessDisplayStatus } from '@/lib/skill-freshness-display-status' // Why: the setup rails' Installed pill is presence-only; when freshness knows a // safe update exists (or that every copy is current) the pill should say so. @@ -8,7 +9,8 @@ import { IntegrationStatusPill } from '@/components/integration-status-pill' // placement is never advertised as updatable here. export function SkillFreshnessStatusPill({ skillName }: { skillName: string }): React.JSX.Element { const { inventory } = useSkillFreshness() - if (inventory?.eligibleUpdateNames.includes(skillName)) { + const status = getSkillFreshnessDisplayStatus(inventory, skillName) + if (status === 'update-available') { return ( {translate( @@ -18,14 +20,7 @@ export function SkillFreshnessStatusPill({ skillName }: { skillName: string }): ) } - const placements = inventory?.installations.filter( - (installation) => installation.name === skillName - ) - if ( - placements && - placements.length > 0 && - placements.every((installation) => installation.status === 'current') - ) { + if (status === 'up-to-date') { return ( {translate('auto.components.skills.SkillFreshnessStatusPill.upToDate', 'Up to date')} diff --git a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx index a810ae06cc6..fc9926f6ed6 100644 --- a/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessUpdateDialog.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { act, useState, type ReactNode } from 'react' +import { act, useLayoutEffect, useRef, useState, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { @@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({ onInteracted?: (method: 'keyboard' | 'pointer', event?: { key?: string }) => void onTerminalExit?: () => void }[], + terminalCommits: [] as [command: string, inert: boolean][], notifyChanged: vi.fn() })) @@ -42,8 +43,20 @@ vi.mock('@/hooks/useInstalledAgentSkills', () => ({ vi.mock('@/components/onboarding/OnboardingInlineCommandTerminal', () => ({ OnboardingInlineCommandTerminal: (props: (typeof mocks.terminalProps)[number]) => { + const terminalRef = useRef(null) mocks.terminalProps.push(props) - return
{props.command}
+ useLayoutEffect(() => { + mocks.terminalCommits.push([ + props.command, + terminalRef.current?.parentElement?.inert ?? false + ]) + }) + return ( +
+ {props.command} +