diff --git a/.gitignore b/.gitignore index cf735fd..e9a53be 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,4 @@ examples/VPS_SETUP.md examples/VPS_SETUP.md .commandcode/settings.json .commandcode/taste/taste.md +ecc-pre-bypass.md diff --git a/AGENTS.md b/AGENTS.md index 609f82f..55cdb00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,45 @@ Update corresponding Docs in [CrewCode Docs](/docs/), and [AGENTS.md](/AGENTS.md Git Workspace changed-file rows support stage/unstage controls and a context menu for stage, stage-all, unstage, and explicitly confirmed discard actions. +GitHub pull-request creation, review, and management stay inside Git Workspace/Git Sidebar through the typed CrewCode client. Preserve the single-PR Branches → Details → Review creation flow, measured base-relative evidence, the compact current-branch PR sidebar launcher, and the canonical repository PR Browser with real on-demand detail/diff/check evidence, PierreDiff per-file review, overall review submission, explicit draft-to-ready action, visible action-gate reasons, explicit merge confirmation, and merge/squash/rebase choices. The Browser must capture the exact selected PR/branches, lock selection/navigation during every mutation, and refresh authoritative evidence after the observed result; do not restore a second review shell or mutation state. PR conflict preparation must require explicit confirmation, the exact clean PR-head worktree, validated refs, observed fetch/merge outcomes, and must surface conflicts through the existing merge card without implicit checkout; retain `MERGE_HEAD` visibility after the last file is staged, then require explicit continue and push. `--auto` is not conflict resolution. Read methods remain registered-root `workspace:read`; mutations require `workspace:write`. Never expose GitHub credentials to the renderer/browser, fabricate inline comments, imply self-approval is available, or infer readiness/merge from silence. Crew integration remains a separate provenance-journal and behavioral-verification workflow. See `docs/github-pull-requests.md`. + +The Git Workspace repository PR browser loads a bounded all-state catalogue through the typed client, selects the current branch's head PR when present, and filters the observed catalogue locally by all/open/closed/assigned-to-viewer. Treat merged PRs as closed and assigned as observed assignee or requested-reviewer identity only. Load selected PR details on demand and identify the author with the observed GitHub username plus exact creation timestamp. Keep Overview, Timeline, Code changes, and Checks in the middle pane: Overview always exposes Description/Problem/What changed/Why it changed/Solution while marking absent author content as not provided rather than inventing it; Timeline chronologically combines the observed open event, commits, comments, and submitted reviews; Code changes loads GitHub's combined base-to-head diff on demand, never the `--patch` format-patch mail series, strips defensive mail boundaries, folds repeated paths under one canonical `diff --git` header, and sends one selected file patch to PierreDiff; Checks shows observed status and links only as an external fallback. Keep non-heading copy across every PR surface at the readable PR body scale rather than terminal-label sizes. Show real reviewers, assignees, labels, checks, branches, and change metrics. Keep mutation actions in the Browser inspector with exact target confirmation and mutation locking. Preserve the stacked mobile catalogue/detail and file-list/diff layouts and the explicit SSH-unavailable result. See `docs/github-pull-requests.md`. + +PR authorship avatars must be retrieved only through the trusted main/Brain boundary. Restrict every redirect and bounded raster response to GitHub-controlled hosts, return only a data URL to the renderer, cache successful images by username, and keep the GitHub-mark fallback when retrieval fails. Keep observed GitHub comments and submitted review summaries at the bottom of PR Browser Overview after the description sections, with author, state, exact timestamp, Markdown body, and an explicit empty state. + +PR Browser inline reviews remain bound to the exact selected PR, head commit, file path, diff side, and line. Keep pending inline comments local until one explicit review submission sends the summary and bounded comment batch; main must revalidate the current GitHub head before mutation and refuse stale drafts rather than retargeting them. Review-thread evidence and viewed-file state come from bounded GitHub GraphQL queries through the trusted main/Brain boundary. Show resolve/reopen only from observed viewer capability flags. When GitHub viewed-state evidence is unavailable, label any session-only fallback as local-only. Preserve previous/next/next-unviewed navigation and observed files/commits since the viewer's latest completed review. These reads require registered-root `workspace:read`; submissions, thread state, and file viewed state require `workspace:write`. See `docs/github-pull-requests.md`. + +PR Browser management stays in the canonical selected-PR inspector. Preserve exact-number mutation locking and authoritative catalogue/detail refresh for title/body edits, reviewer/assignee/label changes, draft/ready transitions, and close/reopen. Load bounded assignable-user, suggested-reviewer, and repository-label choices only when management opens through registered-root `workspace:read`; all management mutations require `workspace:write`. Keep author, label, base, head, review-request, and review-decision filtering local to the bounded catalogue. Preserve repository-scoped selected PR, filters/search, middle-pane tab, and selected file when returning where the evidence still exists. Copy PR identity and branch evidence without opening GitHub. Never expose GitHub credentials to the renderer or imply a mutation succeeded before refreshed GitHub evidence is observed. See `docs/github-pull-requests.md`. + +PR Browser checks and merging remain head-pinned to the exact selected PR. Load bounded check suites, jobs, steps, annotations, and logs on demand through the trusted main/Brain boundary; state explicit third-party provider limits instead of inventing unavailable evidence. Check context and logs require registered-root `workspace:read`; reruns and merge automation require `workspace:write`. Revalidate the GitHub head and job/run ownership before reruns, and require explicit confirmation for job/workflow reruns, direct merge, auto-merge, queue submission, and disabling automation. Keep merge/squash/rebase, auto-merge, and queue operations locked to the observed head commit, then refresh authoritative detail and check evidence and report only the resulting observed state. Treat branch-update permission reasons as update guidance, not fabricated merge blockers. See `docs/github-pull-requests.md`. + +Desktop system-tray behavior is opt-in. When enabled, closing the window hides +it while app-owned work continues; the tray must expose explicit Open and Quit +actions, and Quit must pass through normal cleanup. Disabling the preference +removes the tray immediately. Retain the macOS Dock icon and do not expose tray +behavior to web, Hub, or headless runtimes. See `docs/system-tray.md`. + +Chat sessions persist independent `createdAt`, `lastUsedAt`, and `archivedAt` +timestamps. Advance `lastUsedAt` only when work is sent through the chat; the +Archive page displays it as `MM/DD/YYYY`, while retention continues to use only +`archivedAt`. + +Global chat notifications are live-event-only. Never replay historical errors, +agent exits, or replies from the L1 cache, L2 transcript hydration, continuity +reconciliation, startup, or session switching. Preserve global delivery for +newly appended live errors and active-scope delivery for live agent-exit +warnings. See `docs/notifications.md`. + +Inactive standalone terminal tabs stay mounted to preserve their PTYs, but must +pass `active={false}` through `TermColumn` to `XTermPane`. Buffer their output +without `term.write()`, then refit and replay it with bounded frame work and +xterm callback backpressure when activated. Keep bridge activity phase changes +inside the existing 50 ms text/thinking stream flush. Idle App-owned pollers +must preserve state identity when data is unchanged, remain single-flight, and +use asynchronous filesystem/child-process APIs; never put sync I/O or +`spawnSync` in an automatic refresh path. See +`docs/terminal-stream-performance.md`. + ## What is CrewCode? CrewCode is a desktop ACE (Agent Coding Environment) GUI built with Electron + React + TypeScript. It lets developers run a *crew* of AI coding agents (Claude Code, Codex, OpenCode, etc.) in parallel across local git worktrees, each in its own workspace with a chat thread, embedded terminal panes, and a code/markdown editor — all in one frameless native-feeling window. @@ -148,6 +187,67 @@ Project-owned type declarations belong in `.ts` files. `.d.ts` is reserved for a The shared React renderer supports desktop and direct browser clients. New renderer code must obtain privileged operations through the typed CrewCode client boundary in `src/renderer/src/runtime/crewcode-client.ts`; do not introduce transport-specific HTTP/WebSocket calls in components. Electron installs `window.electronAPI`; the web adapter implements the same contract over authenticated, versioned HTTP/WebSocket RPC. Protocol envelopes live in `src/shared/remote-access-types.ts`; see `docs/web-remote-access.md`. +Optional desktop/web continuity attaches Electron to an enrolled, detached background +Brain through an owner-only loopback rendezvous. Once attached, the Brain store is +authoritative for routed workspaces, transcripts, replay/resume state, terminals, +agents, and the allowlisted workspace/chat catalogue; Electron retains native-only +integrations through the composite client. Seed only missing Brain state, preserve +provider-specific resume keys, and alias legacy `thread:` replay to `web:` without +overwriting existing Brain data. Newer desktop transcript shards merge into existing +Brain shards by message identity. Continuity catalogue patches include session +completion timestamps so web drawer rows can show the same elapsed labels. Normal desktop close must not stop the Brain; only an +explicit Stop Brain/Quit-and-stop action withdraws remote availability. Serialize +prompts FIFO within one conversation while allowing different conversations to run in +parallel, and merge divergent full transcript saves instead of letting stale clients +clobber observed turns. Keep Hub scopes/registered-root checks intact and never treat +this as file synchronization. Never hydrate the aggregate `transcripts.loadAll` result +over a Brain/Hub relay; load bounded per-scope tails for active/L1 conversations and +hydrate cold scopes on demand while keeping full shards authoritative on the Brain. +The 32-scope startup hydration must retain encryption and control-frame headroom under +the Hub's shared 8 MiB connection burst; keep remote scope tails at or below 96 KiB +unless the aggregate relay-budget test and transport budget are changed together. +Keep the pre-React startup surface present while Electron +probes, attaches to, and hydrates from an enabled Brain; startup status is observational +and must never imply attachment success before it is observed. Desktop & Web Settings +must probe and show the Hub's observed canonical browser/passkey origin without exposing +its machine credential; never substitute the enrollment address for an observed browser +origin or imply that enabling Brain starts or proves reachability of the separate Hub +service. Browser adapters must return real disposer functions for unconditional shared +subscriptions whose desktop event source is absent, and must keep optional desktop-only +capabilities genuinely absent; never let a generic unsupported-method Proxy turn an +optional capability probe into a throwing function. See +`docs/desktop-web-continuity.md`. + +Continuity catalogue hydration must merge desktop-only session/tab identities into a +Brain catalogue without replacing genuine identities the Brain already owns, once +desktop catalogue authority exists. Until that marker is present, or while the Brain +still contains transcript-derived recovered rows, Electron reseeds the exact desktop +names, active selection, tab-key order, and per-tab session order; drop unmatched +recovered navigation rows while preserving transcript shards, and retain genuine +web-created sessions. If catalogue records are missing, recover only +registered-workspace solo chats from bounded metadata-only transcript +scope/timestamp/provider/title hints; store those fallback rows oldest-first so the +drawer reverse matches desktop recency; retitle dated Recovered chat rows from the +four-word first-prompt hint; never transfer transcript bodies or provider-native +resume ids for catalogue recovery, and never materialize crew lane scopes as solo +sessions. Recovered rows stay browser-local and must not enter +Brain continuity patches unless a real user send promotes the row. Only owner-loopback +desktop control may establish the persisted desktop-catalogue authority marker; generic +browser continuity RPC must not forge it. The marker and exact desktop repair apply only +to optional Electron-to-Brain attachment. Do not change standalone Hub, +`hub --local-brain`, manually started Brain, or web-only behavior to require desktop +state. Browser provider availability/model discovery must +read the Brain registry even when desktop path overrides are unsupported. Automatic +headless registry probes must use asynchronous filesystem/child-process APIs and retain +login/interactive-shell plus common install-location parity. Browser `delegation.*` +RPC remains session-bound and requires the existing Brain-local `agent` scope. + +Source-checkout remote-access scripts are `npm run enroll -- --hub `, `npm run +brain`, and `npm run hub:mobile`. Keep mobile Hub fail-closed around an existing +Tailscale Serve configuration: replacement requires explicit `--tailscale-replace`. +Do not run the foreground `npm run brain` against the default Brain data directory +while Electron Background Brain owns it. + Remote-access credentials are authority boundaries. Pairing tokens must remain short-lived, memory-only, and single-use. Persist only device-session digests in owner-only atomic stores; enforce expiry and revocation. Browser HTTP/WebSocket origins must match exactly or be explicitly configured—never reflect arbitrary `Origin`/forwarded headers. Keep authentication limiters bounded, and do not hardcode CJ's `crewcode.logixhub.icu` deployment as a default Hub URL. Browser delegation keeps its agent-facing endpoint Brain-loopback and bearer-scoped; @@ -158,7 +258,7 @@ the trusted renderer plus manifest permission gate. Remote GitHub UI may drive t Brain's `gh` device login and registered-workspace publishing, but must never expose the Brain's GitHub credential or allow remote logout. -The self-hosted Hub is a separate `crewcode hub` process, not Electron renderer state. `crewcode hub --local-brain` may spawn a sibling `crewcode brain` on the Hub host after owner passkey setup; keep Hub SQLite and Brain credentials in separate data dirs, do not default-grant scopes, and still enroll extra machines with `crewcode enroll` then `crewcode brain`. Keep its SQLite store owner-only and server-side; persist WebAuthn public credentials and only digests of browser/CSRF secrets. Bootstrap credentials and WebAuthn challenges stay short-lived and memory-only. Require user verification, exact configured RP origin/id, one-use challenges, secure HttpOnly SameSite cookies, and CSRF checks for mutations. Machine enrollment tokens must also stay short-lived, memory-only, single-use, and rate-limited; persist only machine bearer digests at the Hub and keep the brain credential file owner-only. Presence and relay connections are outbound-only and revocation must fail closed. Hub connection tickets remain short-lived, memory-only, one-shot, browser-session/user/machine bound, and exact-origin protected. Relay application frames must stay end-to-end encrypted and ordered; the Hub may route metadata but must not receive RPC/source/terminal/agent plaintext. Do not let Hub identity, machine presence, or requested ticket scope imply Brain execution authority: `crewcode brain` defaults to no RPC grants, and every decrypted method must pass both explicit Brain-local scope and registered-workspace validation. Relay loss means pending outcomes are interrupted, never successful. +The self-hosted Hub is a separate `crewcode hub` process, not Electron renderer state. `crewcode hub --local-brain` may spawn a sibling `crewcode brain` on the Hub host after owner passkey setup; keep Hub SQLite and Brain credentials in separate data dirs, do not default-grant scopes, and still enroll extra machines with `crewcode enroll` then `crewcode brain`. Keep its SQLite store owner-only and server-side; persist WebAuthn public credentials and only digests of browser/CSRF secrets. Bootstrap credentials and WebAuthn challenges stay short-lived and memory-only. Require user verification, exact configured RP origin/id, one-use challenges, secure HttpOnly SameSite cookies, and CSRF checks for mutations. Machine enrollment tokens must also stay short-lived, memory-only, single-use, and rate-limited; persist only machine bearer digests at the Hub and keep the brain credential file owner-only. Presence and relay connections are outbound-only and revocation must fail closed. Hub connection tickets remain short-lived, memory-only, one-shot, browser-session/user/machine bound, and exact-origin protected. Relay application frames must stay end-to-end encrypted and ordered; the Hub may route metadata but must not receive RPC/source/terminal/agent plaintext. Do not let Hub identity, machine presence, or requested ticket scope imply Brain execution authority: `crewcode brain` defaults to no RPC grants, and every decrypted method must pass both explicit Brain-local scope and registered-workspace validation. Relay loss means pending outcomes are interrupted, never successful. Preserve the first observed encrypted-relay close reason through browser startup and record bounded close metadata (peer, WebSocket code, and reason) in the Hub audit store; never replace it with a later generic disconnected error or log relay payloads. Brain-to-browser encrypted frames must use bounded callback-backed ordering and advance their sequence/nonce only after the preceding WebSocket send is accepted; serialization, transport, or queue failure closes the affected tunnel rather than creating a sequence hole. Remote cross-thread conversation handoff stays Brain-local. Namespace browser replay shards under `web:`; never copy the replay store into browser persistence. Require an authenticated owner-held destination bridge and Brain-local `agent` scope, refuse handoff while the destination is running, perform bounded disposable summarization on the Brain, clear the destination native resume id, and replay the combined destination history exactly once on its next native-provider prompt. Missing history, lost ownership, or summary failure is an explicit failure, never inferred success. @@ -174,7 +274,7 @@ The design system lives in `.design/crewcode-design-system/`. The canonical CSS Renderer components may use Tailwind v4 utilities through the utilities-only integration in `src/renderer/src/styles/tailwind.css`. Preflight must stay disabled so incremental conversions do not reset unrelated app surfaces. Use the `cc-*` semantic Tailwind colors, which map to the canonical live CSS tokens; see `docs/tailwind-renderer.md`. -The Prompt/Skills Studio phone list is an edge-to-edge surface, not a centered percentage-width card. Keep the `.pb` → `.pb-left` → `.pb-inner` container chain at `width: 100%`, `max-width: 100%`, and `min-width: 0`. Do not render the category-chip scroller on phones; retain only its compact management/favorite/layout toolbar. Phone cards must be non-shrinking children of the scrollable flex list, grow to fit their wrapped title and description, and contain overflow without line clamps. The phone detail editor must not offer or render Split mode: resolve a stored desktop Split state to Source, retain explicit Source/Preview choices, and let `.pd-source` fill the remaining body height. Actionable controls remain at least 36px and text inputs remain at the iOS-safe 16px. +The Prompt/Skills Studio desktop rail keeps its header, filters, and footer fixed while `.pb-list` scrolls independently; preserve the `.pb-left` → `.pb-inner` → `.pb-list` flex-height chain and `min-height: 0` above 768px. The phone list is an edge-to-edge surface, not a centered percentage-width card. Keep the `.pb` → `.pb-left` → `.pb-inner` container chain at `width: 100%`, `max-width: 100%`, and `min-width: 0`. Do not render the category-chip scroller on phones; retain only its compact management/favorite/layout toolbar. Phone cards must be non-shrinking children of the scrollable flex list, grow to fit their wrapped title and description, and contain overflow without line clamps. The phone detail editor must not offer or render Split mode: resolve a stored desktop Split state to Source, retain explicit Source/Preview choices, and let `.pd-source` fill the remaining body height. Actionable controls remain at least 36px and text inputs remain at the iOS-safe 16px. The composer PromptPicker has separate Prompts and Skills tabs backed by the shared prompt library. Prompt selection inserts into the visible composer (using variable fill when required); Skill selection toggles only the resolved session's `enabledSkillIds`, remains open for multi-select, and never inserts the skill body or mutates a global enable flag. Keep enabled state visible and phone tabs/rows at least 44px/48px respectively. @@ -208,7 +308,7 @@ Three tsconfigs compose via project references: ## Current state -Read this file only when working on any of the features below and need the Current state of them `CrewCoder provider`, `ACP Grok Build`, `Sidebar Folder Creation`, `Crew Supervisor`, `Delegated Threads`,`Chat Archiving`, `Hide work Logs`, `Realtime Voice Orb`, `Notifcation Sound`, `Agent Messages`, `Agent Task Activity`, `Cusromization Panel`, `Queued Messages`, `Composer Execution Modes & reasoning`, `Claude SDK Global skills isolation`, `Provider Switch Handoff & Compact`, `Chat`, `Drawer session split`, `Markdown Editor`, `Code Editor`, `Workbench Mode`, `Git Workspace/Sidebar`, `Mobile-responsive Pages`, [Current State](docs/current-state.md) +Read this file only when working on any of the features below and need the Current state of them `CrewCoder provider`, `ACP Grok Build`, `Sidebar Folder Creation`, `Crew Supervisor`, `Delegated Threads`,`Chat Archiving`, `Hide work Logs`, `Realtime Voice Orb`, `Notifcation Sound`, `App updates`, `Agent Messages`, `Agent Task Activity`, `Cusromization Panel`, `Queued Messages`, `Composer Execution Modes & reasoning`, `Claude SDK Global skills isolation`, `Provider Switch Handoff & Compact`, `Chat`, `Drawer session split`, `Markdown Editor`, `Code Editor`, `Workbench Mode`, `Git Workspace/Sidebar`, `Mobile-responsive Pages`, [Current State](docs/current-state.md) Agent activity must not depend on prompt instructions or provider tool compliance. Every bridge-backed solo, crew-lane, or supervisor dispatch creates a dedicated CrewCode-owned `activity` transcript record for that turn; raw PTY agents are excluded because their terminal outcome is not observable. Advance it only from observed bridge events: `turn_start` begins work, tool categories may update its deterministic phase, and normal `turn_end` completes it. Prompt rejection, abort, stop, bridge error/closure, custody halt, or lost runtime becomes cancelled/interrupted, never success. Terminal activity is immutable, and a persisted running record from another app runtime projects as interrupted. Provider-native todo/plan/task snapshots may replace the generic row only while the CrewCode lifecycle is active; the CrewCode terminal outcome wins over stale native pending/in-progress state. @@ -216,7 +316,7 @@ CrewCoder `crew-tasks` activity remains provider-owned and optional. Preserve th YuHeard PTY integration must remain bundle-safe. `PtyService` receives the active YuHeard server through an injected accessor and statically imports its shell-wrapper helpers; do not use runtime relative `require('./yuheard-*')` calls from PTY code because electron-vite can move that code into a chunk without emitting the required sibling modules. CLI launch, initial TUI paint, and prompt submission are not completed turns. Codex must use only its exact `approval-requested` and `agent-turn-complete` hook events—never generic PTY idle/BEL heuristics—while output fallback detection remains available for agents without an exact hook. Suppress every YuHeard surface only when the exact completing terminal owns keyboard focus in the focused CrewCode window; a different pane must still alert. See `docs/yuheard.md`. -CrewCoder agent profiles are separate from CrewCode execution modes. Show the desktop model-row profile picker only when the installed CrewCoder provider is active; disable it during a running turn, persist the optional session-scoped `crewcoderMode`, omit `--mode` for Configured default, and pass only `general | crewcoder | plugin | extension` to `crewcoder acp --mode`. A concrete profile locks the underlying CrewCode permission policy to Build and disables Ask/Plan/Build/Full on desktop and phone; Configured default re-enables those controls. Never retain a hidden prior Ask, Plan, or Full Access policy under a concrete profile. A profile change is a launch-flag change, so drop only the idle CrewCoder bridge and native-resume it on the next prompt. Never route Ask/Plan/Build/Full into CrewCoder's `--mode`. The `crewcoder` profile's plan gate is CrewCoder-owned: project `crewcoder_clarify` / `crewcoder_propose_plan` into the activity overlay and send `/approve-plan` as a prompt, never as a tool-permission Allow/Deny. See `docs/crewcoder-provider.md`. +CrewCoder agent profiles are separate from CrewCode execution modes. Show the desktop model-row profile picker only when the installed CrewCoder provider is active; disable it during a running turn, persist the optional session-scoped `crewcoderMode`, omit `--mode` for Configured default, and pass only `general | crewcoder | plugin | extension` to `crewcoder acp --mode`. A concrete profile locks the underlying CrewCode permission policy to Build and disables Ask/Plan/Build/Full on desktop and phone; Configured default re-enables those controls. Never retain a hidden prior Ask, Plan, or Full Access policy under a concrete profile. When the concrete `crewcoder` profile is active, show the separate desktop approval picker and persist `crewcoderApprovalMode`; expose only CrewCoder's `review`, `always`, `never`, `full-access`, and `sandboxed` values, with `review` as the fail-closed default. Treat approval changes as immutable launch authority: disable them during a running turn, drop only the idle bridge, include the value in custody, and native-resume on the next prompt. Never suppresses prompts but continues to block dangerous calls; Sandboxed applies the native sandbox policy where supported; Full access bypasses CrewCoder approval requests and dangerous-command blocking, so label that risk truthfully and never imply CrewCode Build still interposes. Never route Ask/Plan/Build/Full into CrewCoder's `--mode`. The `crewcoder` profile's plan gate is CrewCoder-owned: project `crewcoder_clarify` / `crewcoder_propose_plan` into the activity overlay and send `/approve-plan` as a prompt, never as a tool-permission Allow/Deny. See `docs/crewcoder-provider.md`. Provider context handoff is initiated from the Solo Chat header or `/handoff`. Its Used chats tab mirrors the current workspace's live Sessions catalogue across chat tabs; starting either a new or used destination closes the card immediately and moves progress/failure feedback to the destination meter. Preserve each selected destination's owner tab/worktree, existing provider/model/effort locking, and disposable destination-provider summary flow documented in `docs/provider-context-handoff.md`. diff --git a/docs/chat-archiving.md b/docs/chat-archiving.md index 55618bc..e41bfc3 100644 --- a/docs/chat-archiving.md +++ b/docs/chat-archiving.md @@ -12,6 +12,26 @@ full on-disk transcript all survive. surface. The **Archive page** (App menu → Archive) is the one place to see them: a single cross-workspace list with search, a workspace filter, per-row Restore/Delete, and the retention control. +- Every archive row shows the chat's **last-used calendar date** as + `MM/DD/YYYY`. Archiving does not change that value: a chat last used on + `08/28/2026` and archived on `08/30/2026` still displays `08/28/2026`. + +## Chat timestamps + +Each newly created `Session` records two independent wall-clock values: + +- `createdAt` is stamped once when the chat is created. +- `lastUsedAt` starts at creation and advances only when work is sent through + that chat (including normal prompts, follow-ups, voice sends, delegated + prompts, browser-grab sends, handoffs, and `/compact`). Opening, renaming, + pinning, restoring, or archiving a chat does not advance it. + +The Archive page displays and sorts by `lastUsedAt`; `archivedAt` remains +separate and continues to drive retention. Sessions saved before these fields +existed are backfilled once from transcript modification times, which are the +best available evidence of actual activity because archive/restore does not +rewrite transcripts. If no transcript metadata exists, CrewCode uses the +archive timestamp or first-launch-after-upgrade time as a non-zero fallback. ## Retention @@ -71,8 +91,8 @@ of the session in `crewcode:sessionsByTab`. | File | Role | | --- | --- | -| `src/renderer/src/types/index.ts` | `Session.archived`, `Session.archivedAt`, `archive` tab kind | -| `src/renderer/src/hooks/useChatSessions.ts` | `setArchived`, `backfillArchivedAt`, live-only accessors, id allocation | +| `src/renderer/src/types/index.ts` | `Session.createdAt`, `Session.lastUsedAt`, `Session.archived`, `Session.archivedAt`, `archive` tab kind | +| `src/renderer/src/hooks/useChatSessions.ts` | session timestamp creation/backfill/touch, `setArchived`, `backfillArchivedAt`, live-only accessors, id allocation | | `src/renderer/src/hooks/archive-retention.ts` | pure expiry/age rules | | `src/renderer/src/hooks/useSettings.tsx` | `archiveRetentionDays` + its fail-safe normalization | | `src/renderer/src/App.tsx` | `archiveSession` / `restoreSession` / `renameSession`, live vs archived grouping, page wiring | diff --git a/docs/conversation-storage.md b/docs/conversation-storage.md index cde33f9..23029b7 100644 --- a/docs/conversation-storage.md +++ b/docs/conversation-storage.md @@ -34,7 +34,9 @@ If a sharded file is missing or unreadable, CrewCode can lazily recover that ses ### Browser/Brain conversation scopes -Remote browser replay history remains authoritative on the Brain, not in browser `localStorage`. The shared renderer supplies an opaque chat session id; the remote boundary namespaces it as `web:` before `AgentBridgeService` reads or writes the same per-session conversation shards described above. This keeps desktop `thread:` keys and browser keys from aliasing each other. +Remote browser and Brain-attached desktop replay history remains authoritative on the Brain, not in renderer `localStorage`. The shared renderer supplies an opaque chat session id; the remote boundary namespaces it as `web:` before `AgentBridgeService` reads or writes the same per-session conversation shards described above. First-time desktop attachment copies missing state and creates non-destructive `web:` aliases for existing `thread:` shards. Provider-native resume IDs continue to use the desktop-compatible `:` key, so switching clients does not lose resume state and switching providers cannot consume another provider's native id. + +The Brain also serializes prompt entry per conversation. Desktop and web may submit concurrently, but one conversation receives one provider turn at a time in FIFO order; different conversations remain concurrent. Stable bridge starts are coalesced so simultaneous first attachment does not create competing provider processes. Cross-thread browser handoff is a bounded Brain-side operation. The browser names a source chat and an already-owned destination bridge, but never downloads the source replay shard. The Brain summarizes the source with a disposable destination-provider bridge, appends only the resulting handoff packet to the destination shard, clears the destination's native resume id, and replays the combined destination history once on its next native-provider prompt. Stateless HTTP providers consume the updated shard directly. Missing source history, summary failure, a running destination, or lost destination ownership is an explicit failure and is never inferred as success. @@ -96,6 +98,13 @@ The rich UI thread (the full renderer `Message[]` — user/agent/thinking/toolca Messages are stored opaquely — the main process never inspects their shape, so the renderer `Message` type stays renderer-only. IPC surface: `transcripts:loadAll`, `transcripts:save`, `transcripts:remove`, and a **synchronous** `transcripts:saveSyncBatch` used only on window teardown (an async `invoke` can be dropped before the renderer dies, so the last turn is written synchronously). +In a Brain-attached runtime, `src/main/transcript-service.ts` owns the equivalent +Brain-side shards. Since desktop and browser can save full arrays based on different +snapshots, it merges by stable message identity (ignoring client-local display time +where no durable id exists) before writing. New divergent rows are appended in Brain +receipt order, known activity/tool/turn rows are replaced, and explicit +`transcripts.remove` remains the only whole-thread deletion path. + ### L1 — `crewcode:messagesByTab` localStorage (bounded fast-paint cache) `src/renderer/src/stores/chat-messages-store.ts` keeps a synchronous localStorage copy so the transcript paints instantly on launch. localStorage has a hard ~5MB per-origin quota; the cache therefore caps each scope's tail (`MAX_PERSISTED_MESSAGES_PER_SCOPE`) and, on `QuotaExceededError`, evicts the least-recently-touched scopes so the newest conversation always wins the remaining space. diff --git a/docs/crewcoder-provider.md b/docs/crewcoder-provider.md index fbe15fc..c8f9f2e 100644 --- a/docs/crewcoder-provider.md +++ b/docs/crewcoder-provider.md @@ -36,13 +36,30 @@ a turn is running so authority cannot change underneath live execution. It stays absent for unavailable or inactive providers and from the phone layout, where the desktop model-row reveal itself is intentionally hidden. +When the concrete **CrewCoder** profile is selected, the row also shows a +session-scoped approval picker with all native policies: + +- **Review** (`review`) lets safe calls proceed and asks for mutations and dangerous calls. +- **Always** (`always`) asks for every non-safe call. +- **Never** (`never`) shows no prompts while continuing to block dangerous calls. +- **Full access** (`full-access`) accepts calls without prompts. +- **Sandboxed** (`sandboxed`) shows no prompts and runs non-dangerous calls through the sandbox policy where supported. + +Older or invalid persisted values fail closed to Review. Changing the policy +drops only the idle CrewCoder bridge and native-resumes it on the next prompt; +both controls are disabled during a running turn. Full access is an explicit +authority escalation: CrewCoder stops emitting approval requests and permits +dangerous commands, so CrewCode's permission overlay and dangerous-command +tripwire cannot interpose on those provider-native calls. + A concrete CrewCoder profile also owns the agent's behavioral mode, so CrewCode locks its separate execution policy to **Build** and disables the -Ask/Plan/Build/Full control. Build remains active underneath as the approval -gate: writes still require CrewCode's permission overlay instead of becoming -implicitly Full Access. The phone model menu disables its Mode row for the same -session. Returning to **Configured default** re-enables the execution-mode -control; the session remains on Build until the user chooses another policy. +Ask/Plan/Build/Full control. Under the default Review policy, Build remains the +CrewCode permission gate. Explicit CrewCoder Full access bypasses that native +request path and must not be described as Build-protected. The phone model menu +disables its Mode row for the same session. Returning to **Configured default** +re-enables the execution-mode control; the session remains on Build until the +user chooses another policy. The `crewcoder` profile adds a runtime inspect → clarify → plan → approve sequence inside CrewCoder. CrewCode does not enforce that gate; it projects @@ -50,7 +67,8 @@ sequence inside CrewCoder. CrewCode does not enforce that gate; it projects and sends `/approve-plan` as a user prompt when the user clicks **approve plan** or picks the CrewCoder slash command. That prompt is not `/approve` and does not settle a `session/request_permission` card. After plan approval, CrewCode -Build permission prompts still apply to mutating tools. Revising a proposed +handles any permission requests emitted by the selected native approval policy; +Never, Full access, and Sandboxed may deliberately emit none. Revising a proposed plan is a normal composer message; CrewCoder treats that as a new `awaiting_plan` cycle rather than approval. @@ -182,8 +200,11 @@ CrewCoder's separate agent-profile `--mode` option (`general`, `crewcoder`, `plugin`, `extension`). The selected `Session.crewcoderMode` is the only value allowed onto that launch flag. It is process-scoped, whereas CrewCode execution mode remains the permission policy described above. A concrete CrewCoder -profile fixes that policy to Build; it must never inherit a hidden prior Ask, -Plan, or Full Access value. +profile fixes that CrewCode policy to Build; it must never inherit a hidden +prior Ask, Plan, or Full Access value. `Session.crewcoderApprovalMode` is a +separate native authority value. CrewCode passes only `review`, `always`, +`never`, `full-access`, or `sandboxed` to `--approval`, defaults +missing/invalid values to `review`, and records the value in execution custody. ## Filesystem and SSH behavior diff --git a/docs/current-state.md b/docs/current-state.md index 80f61e9..7d07fcb 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -1,16 +1,47 @@ # Current state +Desktop system-tray behavior is opt-in through **Settings → General → Keep running in background**. Enabling it creates an OS tray with explicit **Open CrewCode** and **Quit CrewCode** actions; closing the window hides it without stopping terminals or agents, while tray Quit follows the normal cleanup path. Windows and Linux also restore on tray click (Linux StatusNotifierItem Activate is typically a single click). Disabling the preference removes the tray immediately. macOS retains its Dock icon, and web/Hub/headless runtimes never emulate the tray. See `docs/system-tray.md`. + Real agent integration is wired through normalized bridges (pi, OpenCode, Claude, Codex, Hermes, CrewCoder, Ollama, and OpenRouter) with PTY panes remaining available for terminals. Workspaces, worktrees, git operations, terminals, settings, and crew sessions are all real and persisted (workspaces + tabs to disk, messages to localStorage). +## Desktop and web continuity + +An enrolled desktop can enable **Settings → Desktop & Web → Background Brain**. The +optional detached Brain becomes authoritative for Brain-authorized workspaces, rich +transcripts, replay/native-resume state, terminals, agents, and the bounded workspace/ +chat catalogue used by both Electron and Hub web. First enable seeds missing Electron +state without overwriting existing Brain data and aliases legacy `thread:` replay into +the shared `web:` scope. Normal desktop close leaves the Brain and remote availability +running; **Stop Brain** and **Quit and stop Brain** explicitly terminate it. + +Desktop and web may submit prompts concurrently. Stable bridge starts are coalesced, +and the Brain serializes one conversation FIFO while allowing different conversations +to execute in parallel. Transcript saves merge divergent client snapshots so one +client cannot erase a turn already observed from the other. Catalogue hydration occurs +before app mount and later local edits push bounded per-key patches; live navigation +pull between already-open clients remains a first-release limitation. Files stay on +the enrolled machine, Hub relay traffic remains end-to-end encrypted, and Hub identity +still cannot widen Brain-local roots/scopes. A pre-React startup surface reports Brain +probe, attachment, and hydration phases during the enable-triggered reload instead of +leaving the desktop window blank. Desktop & Web Settings probes the enrolled Hub, shows +its observed canonical browser/passkey origin with an **Open Hub** action, and states +that enabling Brain does not start the separate Hub service. The enrollment address is +never treated as proof of browser origin or reachability. See +`docs/desktop-web-continuity.md`. + Settings → General stores a workspace-scoped default branch. The selector detects local branches from the active repository. A new solo-chat session captures that setting once, reuses or creates the branch worktree, selects it for that chat surface, then clears the one-shot request so existing chats and later manual branch switches are never moved retroactively. Git Workspace and Git Sidebar also use the live setting as their comparison base without checking it out: committed branch differences remain reviewable alongside local status, while only true working-tree rows expose stage/unstage actions. Delegated threads retain their separate base/worktree contract. +Git Workspace and Git Sidebar keep pull-request creation, browsing, review, management, checks, and merge operations inside CrewCode. Creation is a single-PR **Branches → Details → Review** flow with measured base-relative ahead/behind/file/conflict evidence. The canonical PR Browser selects a current-branch PR when present, filters its bounded catalogue locally, and shows the observed GitHub author and exact creation time. Its middle pane provides **Overview** with explicit Description/Problem/What changed/Why it changed/Solution sections, **Timeline** with chronologically observed open/commit/comment/review evidence, **Code changes** with an on-demand real per-file `PierreDiff`, and **Checks** with on-demand suites, jobs, steps, bounded annotations and logs, rerun controls, and an observed merge-requirements ledger; absent authored rationale is labeled missing, never fabricated. Code changes uses GitHub's combined base-to-head diff rather than its format-patch mail series, removes mail envelopes defensively, and normalizes repeated paths to one canonical file patch before Pierre. It supports exact path/side/commit/line inline-comment drafts, atomic multi-comment review submission, observed review conversations with authorized resolve/reopen, GitHub-backed viewed files with an explicit local-only fallback, next/unreviewed navigation, and files/commits since the viewer's last completed review. The Browser inspector owns title/Markdown editing, reviewer/assignee/label management, overall review submission, update-branch, draft/ready transitions, conflict preparation, confirmed close/reopen, head-pinned merge commit/squash/rebase, auto-merge, and merge queues. Author, label, base, head, request, and decision filters operate on the one bounded catalogue; repository-scoped selection, filters, tab, and file restore when returning. Identity/branch copy controls and observed head-commit/update evidence remain in app. It locks PR selection and navigation during mutations, captures the exact target, revalidates the head before privileged review/check/merge actions, and reloads GitHub evidence before reporting the observed result. Git Sidebar's compact current-branch PR summary opens this same workspace; the redundant standalone review shell is retired. GitHub remains authoritative for repository rules and third-party check capabilities. Brain/web reads stay registered-root and `workspace:read` scoped while mutations require `workspace:write`. See `docs/github-pull-requests.md`. + +PR authorship now loads the author's public GitHub profile image through the trusted main/Brain boundary. The host restricts redirect destinations and bounded raster content to GitHub-controlled hosts, caches successful images by username, and gives the renderer a data URL; rejected or unavailable images retain the GitHub-mark fallback. The Overview ends with observed GitHub comments and submitted review summaries after the description sections, including author, state, exact timestamp, and Markdown body. + ## CrewCoder -When the active installed provider is CrewCoder, the desktop model-row reveal offers session-scoped Configured default/general/crewcoder/plugin/extension profiles. Configured default omits the launch flag; a concrete choice adds CrewCoder's distinct `--mode`, restarts only that bridge before native resume, locks CrewCode's underlying permission policy to Build, and disables Ask/Plan/Build/Full on desktop and phone until Configured default is restored. Never pass CrewCode's execution-mode value as the CrewCoder profile, and never let a concrete profile inherit a hidden prior Ask, Plan, or Full Access policy. The `crewcoder` profile's inspect/clarify/propose/approve-plan gate is CrewCoder-owned: CrewCode renders `crewcoder_clarify` and `crewcoder_propose_plan` on the activity overlay and sends `/approve-plan` as a prompt from the overlay button or slash command. Do not reuse tool-permission Allow/Deny for that card, and do not treat a clarification answer as plan approval. +When the active installed provider is CrewCoder, the desktop model-row reveal offers session-scoped Configured default/general/crewcoder/plugin/extension profiles. Configured default omits the launch flag; a concrete choice adds CrewCoder's distinct `--mode`, restarts only that bridge before native resume, locks CrewCode's underlying permission policy to Build, and disables Ask/Plan/Build/Full on desktop and phone until Configured default is restored. The concrete `crewcoder` profile also reveals a session-scoped approval picker for `review`, `always`, `never`, `full-access`, and `sandboxed`. Review is the fail-closed default; Always prompts for every non-safe call, Never suppresses prompts but blocks dangerous calls, Full access accepts every call without prompting, and Sandboxed suppresses prompts while applying CrewCoder's sandbox policy to non-dangerous calls where supported. Missing or invalid values fail closed to review, changes restart only an idle bridge, and execution custody includes the native approval value. Full access explicitly bypasses CrewCoder approval requests and dangerous-command blocking, so CrewCode's permission overlay/tripwire cannot interpose. Never pass CrewCode's execution-mode value as the CrewCoder profile, and never let a concrete profile inherit a hidden prior Ask, Plan, or Full Access policy. The `crewcoder` profile's inspect/clarify/propose/approve-plan gate is CrewCoder-owned: CrewCode renders `crewcoder_clarify` and `crewcoder_propose_plan` on the activity overlay and sends `/approve-plan` as a prompt from the overlay button or slash command. Do not reuse tool-permission Allow/Deny for that card, and do not treat a clarification answer as plan approval. -CrewCoder is a first-class ACP provider implemented separately in `crewcoder-bridge.ts`; CrewCode is the client and spawns `crewcoder acp --approval review`. Keep Hermes untouched. CrewCoder is native-resume, discovers `provider:model` choices through `session/new`, maps namespaced usage `lastInputTokens` to live context occupancy, reports authoritative background compaction lifecycle through `_crewcoder/compaction_update` (never duplicate it with usage-drop inference), clears stale context occupancy on successful compaction until the next measured usage while retaining the full CrewCode transcript as display history, and uses once-only permission choices so remembered agent decisions cannot bypass later composer-mode changes. Its prompt watchdog measures ACP inactivity, not total turn duration, and pauses while Build permission is awaiting user input; a genuine timeout must send `session/cancel` before CrewCode ends the turn so another prompt cannot overlap live CrewCoder work. A closed CrewCoder ACP child is removed from the bridge registry so the next composer submission follows the existing missing-bridge restart path instead of writing to dead stdin and reporting `process not writable`. CrewCoder ACP must respect CrewCoder's persisted `autoCompact` setting; CrewCode must not force compaction or retry context-window failures for CrewCoder, Pi, or other providers. ACP `Internal error` responses can carry the actionable CrewCoder failure in `error.data.message`, which the bridge must prefer over the generic envelope text. Local ACP file reads currently use saved disk bytes while SSH reads/writes route through SFTP; do not claim dirty editor-buffer support until a renderer-host route exists. Session-scoped `externalDirectories` are synchronized after ACP new/load through `session/set_external_directories`, including `[]` to revoke stale native-session grants; changing them must restart the bridge. CrewCoder validates and persists the roots, while CrewCode's picker remains unavailable for SSH roots. It is deliberately excluded from disposable editor completion. Every bridge-backed solo, crew-lane, or supervisor dispatch creates a CrewCode-owned activity record whose pending/running/tool-phase/terminal state comes only from observed bridge events; runtime loss becomes interrupted. Optional CrewCoder `crew-tasks` snapshots on ACP `rawOutput` enrich that row while active, and newer Task* mutations fold over the current turn's last snapshot. Project-wide `TaskList` results remain excluded because they mix unrelated CrewCoder sessions. CrewCode does not prompt for or fabricate native activity and does not enable disabled CrewCoder tools. See `docs/agent-activity-overlay.md`. See `docs/crewcoder-provider.md`. +CrewCoder is a first-class ACP provider implemented separately in `crewcoder-bridge.ts`; CrewCode is the client and spawns `crewcoder acp` with the session's normalized native approval value. Keep Hermes untouched. CrewCoder is native-resume, discovers `provider:model` choices through `session/new`, maps namespaced usage `lastInputTokens` to live context occupancy, reports authoritative background compaction lifecycle through `_crewcoder/compaction_update` (never duplicate it with usage-drop inference), clears stale context occupancy on successful compaction until the next measured usage while retaining the full CrewCode transcript as display history, and uses once-only permission choices so remembered agent decisions cannot bypass later composer-mode changes. Its prompt watchdog measures ACP inactivity, not total turn duration, and pauses while Build permission is awaiting user input; a genuine timeout must send `session/cancel` before CrewCode ends the turn so another prompt cannot overlap live CrewCoder work. A closed CrewCoder ACP child is removed from the bridge registry so the next composer submission follows the existing missing-bridge restart path instead of writing to dead stdin and reporting `process not writable`. CrewCoder ACP must respect CrewCoder's persisted `autoCompact` setting; CrewCode must not force compaction or retry context-window failures for CrewCoder, Pi, or other providers. ACP `Internal error` responses can carry the actionable CrewCoder failure in `error.data.message`, which the bridge must prefer over the generic envelope text. Local ACP file reads currently use saved disk bytes while SSH reads/writes route through SFTP; do not claim dirty editor-buffer support until a renderer-host route exists. Session-scoped `externalDirectories` are synchronized after ACP new/load through `session/set_external_directories`, including `[]` to revoke stale native-session grants; changing them must restart the bridge. CrewCoder validates and persists the roots, while CrewCode's picker remains unavailable for SSH roots. It is deliberately excluded from disposable editor completion. Every bridge-backed solo, crew-lane, or supervisor dispatch creates a CrewCode-owned activity record whose pending/running/tool-phase/terminal state comes only from observed bridge events; runtime loss becomes interrupted. Optional CrewCoder `crew-tasks` snapshots on ACP `rawOutput` enrich that row while active, and newer Task* mutations fold over the current turn's last snapshot. Project-wide `TaskList` results remain excluded because they mix unrelated CrewCoder sessions. CrewCode does not prompt for or fabricate native activity and does not enable disabled CrewCoder tools. See `docs/agent-activity-overlay.md`. See `docs/crewcoder-provider.md`. ## ACP Grok Build @@ -38,7 +69,7 @@ Delegated threads let a solo-chat agent spawn real, persistent chat sessions thr ## Chat Archiving -Chat archiving (`Session.archived`) is non-destructive: archiving releases the session's bridge but must never delete its transcript, and only explicit Delete calls `transcripts:remove`. Archived sessions are hidden from every live surface because `getSessions()` filters them — use `getAllSessions()` only for the archive list itself. Activation must never land on an archived session, and a tab whose sessions are all archived is treated as empty so `ensureTab` seeds a fresh thread (with an id that skips archived ones — reusing an id would alias two threads onto one transcript). The archive/rename right-click menu lives on live drawer session rows; archived chats are not shown in the drawer at all and surface only in the `archive` tab kind, a cross-workspace review page. Archive retention (`settings.archiveRetentionDays`, `0 | 30 | 60 | 90`, default `0`) is a **classifier, not a scheduler**: it flags expired chats for an explicit confirmed bulk delete and must never gain a background sweep. A session with no `archivedAt` is never expired, legacy archived sessions are backfilled to first-launch-after-upgrade rather than zero, and a malformed persisted retention value falls back to Never — enabling a window must not retroactively mark unknown-age history deletable. See `docs/chat-archiving.md`. +Chat archiving (`Session.archived`) is non-destructive: archiving releases the session's bridge but must never delete its transcript, and only explicit Delete calls `transcripts:remove`. Archived sessions are hidden from every live surface because `getSessions()` filters them — use `getAllSessions()` only for the archive list itself. Activation must never land on an archived session, and a tab whose sessions are all archived is treated as empty so `ensureTab` seeds a fresh thread (with an id that skips archived ones — reusing an id would alias two threads onto one transcript). The archive/rename right-click menu lives on live drawer session rows; archived chats are not shown in the drawer at all and surface only in the `archive` tab kind, a cross-workspace review page. `Session.createdAt` is stamped once, while `Session.lastUsedAt` advances only when work is sent through the chat; opening, metadata edits, restore, and archive do not touch it. The archive page displays `lastUsedAt` as `MM/DD/YYYY` and sorts by it, never by archive time; legacy values are recovered from transcript mtimes when available. Archive retention (`settings.archiveRetentionDays`, `0 | 30 | 60 | 90`, default `0`) is a **classifier, not a scheduler** and continues to use the independent `archivedAt`: it flags expired chats for an explicit confirmed bulk delete and must never gain a background sweep. A session with no `archivedAt` is never expired, legacy archived sessions are backfilled to first-launch-after-upgrade rather than zero, and a malformed persisted retention value falls back to Never — enabling a window must not retroactively mark unknown-age history deletable. See `docs/chat-archiving.md`. ## Hide work Logs @@ -52,10 +83,25 @@ Solo Chat selection speech is independent text-to-speech: capture a non-empty se Voice orb start/end shortcuts are component-local keybindings exposed in Settings and `keys.json`. In split layouts, start must target the focused composer rather than fan out; end must only stop the session that currently owns the voice microphone. +## App updates + +Packaged desktop CrewCode polls GitHub Releases through `electron-updater`. +Available and downloaded versions show on the global notification bar with the +new version so the user does not have to open Settings to find them; the card +stays until dismissed and opens **Settings → Updates** on click. Channel and +auto-download policy are pushed to main at App launch, not only when the +Settings Updates section mounts. Dev builds and browser sessions do not poll. +See `docs/releasing.md` and `docs/notifications.md`. + ## Notifcation Sound Completed-turn desktop notifications use the persisted `notificationSound` setting: `system` delegates audio to the OS, `bell`/`ding`/`knock` use the renderer's synthesized tones, and `none` stays silent. Custom tones must send the native toast with `silent: true` so users never hear both CrewCode and system audio. Coalesced crew completions play exactly one sound. On Linux, toasts go through a detached `notify-send` child process — never Electron `Notification` on the main-process hot path, whose synchronous DBus round-trips froze the app 0.5–1.1s per toast. See `docs/notifications.md`. +The global notification bar consumes chat errors and agent-exit warnings only +from newly appended live messages. L1/L2 transcript hydration, whole-map +continuity reconciliation, app startup, and session switching must never replay +historical chat records as fresh notifications. See `docs/notifications.md`. + ## YuHeard Terminal Alerts YuHeard is the terminal-only agent-done channel. It listens on a per-user Unix socket at `~/.crewcode/yuheard.sock` (overridable via `YUHEARD_SOCKET`) and accepts newline-delimited JSON reports from any client — a Claude Code hook, the `bin/yuheard` CLI, or an auto-wrap shell shim that CrewCode installs per pane. Chat bridge turn-end must not call YuHeard; Solo/Crew chat tabs and their sidecar PTYs are ineligible (`tabKindAllowsYuHeard`). Those chats keep `notificationSound` and the existing in-app toast only. For terminal panes the renderer plays a `knock` sound, shows an in-app toast, and (when unfocused) an OS notification titled "Terminal agent finished". CrewCode does not infer turn-complete from a still-running TUI: BEL / OSC 9 / OSC 777 complete immediately on eligible panes; idle-after-output (~2.8s after a large burst) is armed for agent-tagged panes, after a socket `running` report, or when the user submits a line whose first command is a known agent CLI. **Codex TUI never goes idle** and its built-in TUI notify defaults to unfocused-only, so spawn/wrap injects `-c notify=[python-or-node, per-pane hook]` plus `tui.notifications=true`, `tui.notification_method="bel"`, and `tui.notification_condition="always"` instead of editing `~/.codex/config.toml`. Fish panes redefine agent functions after rc so a `function codex` cannot skip the shim; bash drops matching aliases/functions via `PROMPT_COMMAND`. XTermPane reattach sends the same YuHeard spawn flags as the owner `ptyCreate`. See `docs/yuheard.md`. @@ -115,6 +161,10 @@ Visible chat transcripts persist in two layers (`src/renderer/src/stores/chat-me Renderer re-render isolation: state that changes at high frequency must NOT live in `App.tsx`, because `App` rebuilds the whole tab tree (and, on Workbench, every mounted `ChatPane`) on each render. Two such slices are now isolated into stores that only their consumers subscribe to — `stores/terminal-unread-store.ts` (background PTY output; a `claude`/`codex` agent in a hidden tab used to re-render the shell ~1.4×/s) and `stores/composer-draft-store.ts` (per-tab composer drafts; every keystroke re-rendered the shell). Do not move either back into `App`. Live agent state (running / status / queued follow-ups / pending user requests) lives in `stores/bridge-activity-store.ts`, not in `useBridgeRegistry`'s `useState`. It used to ride on the `bridges` prop, which meant ChatPane's Stop button, spinner, follow-up pills, and permission prompts only stayed correct because that bundle got a fresh object identity on every App render — an accident, not a design, and one no type or test protected. Consumers now subscribe to the slice they read: ChatPane uses `useIsBridgeRunning` / `useBridgeStatus` / `useQueuedFollowUps` / `useUserRequestsForTab`. `useBridgeRegistry` still subscribes to `runningByBridge` / `runningByScope` (Mission Control fans out over every session via `isBridgeRunning`), and the workspace drawer also subscribes to `runningByScope` so Running does not depend on App recomputing `workingChats`. Status and follow-up churn no longer re-render App. Do not reintroduce `getBridgeStatus` / `getQueuedFollowUps` / `userRequestsByTab` onto the `bridges` bundle. `clearBridges()` deliberately does NOT drop user requests — an idle-stopped bridge keeps its tab's requests, while `dropBridge`/`releaseTab`/`resetSession` clear them explicitly; `bridge-activity-store.test.ts` pins that. +Inactive standalone terminal tabs remain mounted for PTY/process continuity, but their full-size `visibility:hidden` keepalive containers must not keep xterm/WebGL on the renderer hot path. Pass the explicit active-tab signal through `TermColumn` to `XTermPane`; inactive panes buffer a bounded output tail without calling `term.write()`, then refit and replay with a per-frame budget plus xterm callback backpressure on activation. Visible panes use the same batching so PTY bursts do not become one renderer task per chunk. Bridge text/thinking activity phases are folded into the existing 50 ms delta flush rather than scanning the transcript for every raw provider token. See `docs/terminal-stream-performance.md`. + +Idle refreshes follow the same responsiveness contract. The five-second prompt/skill/command scan is App-owned, so unchanged content must preserve the prior library object and slow scans must stay single-flight; local scan reads use asynchronous filesystem APIs rather than blocking Electron main. The automatic GitHub poll must use asynchronous, bounded child processes and preserve the prior App state for an unchanged result. Never add `spawnSync`, `readdirSync`, `statSync`, or `readFileSync` to an interval-, focus-, or visibility-driven status path. See `docs/terminal-stream-performance.md`. + Structural message updates (tool calls and new thinking blocks) must stay isolated from the workspace shell. `Messages`' `areRowsEqual` compares a work-log anchor's actual source `ToolCallMessage` identities; never replace that with blanket live-turn invalidation, because a growing thinking/text sibling would rebuild every work log in the turn. Agent rows receive a precomputed `showTurnSummary` boolean instead of reading the whole transcript. Self-contained rows — `thinking` above all — re-render only when their own message changes. `chronologicalStreamSegments` caches splits per chunk string, so a growing block only re-splits its tail instead of every chunk on every flush (that was quadratic in turn length); it also appends units in a loop rather than `push(...units)`, which throws on very long blocks. Solo-chat auto-follow runs in the next animation frame, not `useLayoutEffect`: reading `scrollHeight` synchronously during every React commit forced shell-wide layout. Keep `.thread-shell` layout/paint-contained so transcript reflow cannot invalidate the workspace drawer. `ChatPane` is still not `React.memo`'d, but that is now a cost/benefit call rather than a correctness trap: `MessageRow` is already memoized and `Messages` pages at 50 rows, so an App-driven ChatPane re-render is cheap reconciliation. Memoize only against a profile, never against a theory. @@ -142,7 +192,7 @@ The standalone Git Workspace tab (`kind: 'git'`) is a full-page surface backed b PromptBuilder (`prompts` tab), Mission Control (`mission` tab), Canvas Mode (`canvas` tab), Code Editor, Git Workspace/Sidebar, and Changes by turn are usable on phones and tablets when CrewCode is reached through a browser context — both the standalone `crewcode serve` and the Hub-relayed `?machine=…` entry points share the same renderer. Layout decisions flow from `useMobileLayout()` (`isMobile = innerWidth ≤ 768`, `isTablet = 769–1024`) so JS branches and CSS breakpoints cannot drift. The Code Editor keeps its canvas primary with an off-canvas file tree, Git Sidebar is a dismissible right overlay from chat or editor, Git Workspace uses a bounded single-column overview/change/diff/tools flow, and Changes by turn is a full-screen stacked catalogue/diff surface. The browser context does not affect the Electron renderer; desktop behaviour is unchanged. See `docs/mobile-responsive-pages.md`. - **Hub mobile home and desktop overview**: an authenticated Hub visit at ≤768px redirects to `/app?hub=mobile` and mounts `MobileDashboard` before any Brain relay is opened. It reads the owner name and enrolled-machine presence from the Hub's cookie-authenticated, read-only session/machines endpoints; fake agent/worktree counts are forbidden. Both Hub mobile headers use the supplied theme-aware CrewCode logo assets. Selecting an online machine enters `/app?hub=mobile&machine=…` and opens a disposable end-to-end encrypted tunnel with only `workspace:read` and `agent` requested. `MobileMachineOverview` adapts Brain-visible transcript sessions and live executions into Mission agents, then uses the same `deriveMissionStats` aggregation as `mc-stats` for agents/running/done and distinct `projectId/worktree` totals; denied or incomplete inputs render as `—`. It lists at most five recent thread summaries. Current Brains return only each summary's opaque scope id, timestamp, and bounded first-user title seed—not transcript bodies. If an already-running older Brain rejects `transcripts.recent` as unsupported, the renderer falls back to `transcripts.mtimes`; saved rows still appear with timestamps and an untitled label, without downloading full histories. A recent row carries a bounded workspace/tab/session descriptor into `/app?machine=…`; after transcript hydration the App validates ownership, restores the exact session id when the browser has no local catalog entry, and focuses that thread. **Open CrewCode** enters the same renderer without a thread target. Navigation closes the overview relay before the full runtime opens. Offline machines remain disabled, and `/?hub-admin=1` is the explicit escape hatch to passkey/device administration. Desktop Hub visits, tablets above 768px, Electron, and direct `crewcode serve` startup do not mount either mobile surface. -- **PromptBuilder**: below 768px the 360px left rail and detail pane share a `list | detail` navigation state (driven by `data-view` on `.pb`). The list is edge-to-edge with `min-width: 0` enforced through its container chain; do not restore the old centered `85%` rail because it wastes phone width and lets intrinsic child widths push the page sideways. The Prompts/Skills tabs and compact New action share one bounded row. Category chips are omitted on phones; only the category-management, favorites, and layout tools remain in the compact toolbar. Prompt cards use phone-specific compact padding and type. Cards are non-shrinking children of the scrollable flex list, grow to their wrapped title and description height, and contain text overflow without line clamps. Detail mode removes the Split choice on phones: a stored desktop Split state resolves to Source, and the source textarea fills the remaining body height; users can still explicitly switch between Source and Preview. The composer PromptPicker has separate Prompts and Skills tabs: prompts insert or open variable fill, while skills toggle session-scoped activation in place without inserting their body or closing the picker. On phones the picker becomes a full-bleed bottom sheet with its side fill panel collapsed to a top section, 44px tabs, and 48px list rows. Actionable controls (icon buttons, mode toolbar, save/apply) retain ≥36px touch targets and gain `:active` states mirroring the existing `:hover` styles. Keyboard-hint chips (`⌘F`, `⌘P`, `⌘J`, `⌘S`) are hidden on phones, and text inputs stay at 16px to prevent iOS auto-zoom. +- **PromptBuilder**: above 768px the left rail uses a bounded `.pb-left` → `.pb-inner` → `.pb-list` flex-height chain so its header, filters, and footer stay fixed while prompt/skill cards scroll independently. Below 768px the 360px left rail and detail pane share a `list | detail` navigation state (driven by `data-view` on `.pb`). The list is edge-to-edge with `min-width: 0` enforced through its container chain; do not restore the old centered `85%` rail because it wastes phone width and lets intrinsic child widths push the page sideways. The Prompts/Skills tabs and compact New action share one bounded row. Category chips are omitted on phones; only the category-management, favorites, and layout tools remain in the compact toolbar. Prompt cards use phone-specific compact padding and type. Cards are non-shrinking children of the scrollable flex list, grow to their wrapped title and description height, and contain text overflow without line clamps. Detail mode removes the Split choice on phones: a stored desktop Split state resolves to Source, and the source textarea fills the remaining body height; users can still explicitly switch between Source and Preview. The composer PromptPicker has separate Prompts and Skills tabs: prompts insert or open variable fill, while skills toggle session-scoped activation in place without inserting their body or closing the picker. On phones the picker becomes a full-bleed bottom sheet with its side fill panel collapsed to a top section, 44px tabs, and 48px list rows. Actionable controls (icon buttons, mode toolbar, save/apply) retain ≥36px touch targets and gain `:active` states mirroring the existing `:hover` styles. Keyboard-hint chips (`⌘F`, `⌘P`, `⌘J`, `⌘S`) are hidden on phones, and text inputs stay at 16px to prevent iOS auto-zoom. - **Mission Control**: `deriveMissionStats` is the canonical aggregation for the desktop `mc-stats` strip, hero totals, and Hub mobile machine overview; status counts and distinct `projectId/worktree` identity must not be reimplemented per surface. Below 768px the activity feed moves out of the page into a new `MobileShell` sheet (`mission-activity`) driven by a header "Activity" pill in the toolbar. `StatStrip` collapses from 6 to 3 columns (and to 2 below 480px), the agent grid floor drops to 280px so cards fit at 360px viewport width, the `BlockingBanner` row reflows into two rows (body on top, reply input + jump button on the bottom), and the menulet already had a 768px full-bleed override. `pluginMissionWidgets` and the in-page side feed are hidden on phones and live behind the sheet. The toolbar is a two-row stack: filter segments alone on the top row, all action controls (Activity pill, group-by select, refresh, spawn) on a second row pushed to the right with the desktop spacer collapsed. - **Canvas Mode**: below 768px the pane grid `grid-auto-rows` drops to `minmax(min(50dvh, 480px), 1fr)` so two panes can share a phone screen. The pane-bar action cluster is replaced by a single `⋯` overflow button (`.canvas-mode-pane-more`) that opens a popover menu (`.canvas-mode-pane-menu-pop`) carrying the per-pane actions: mode prompt toggle (chat only), verbose-logs toggle (chat only), and close pane; Add chat / Add terminal live on the page-level FAB. The popover closes on outside click, touch, and Escape. The "Add" controls inside the desktop cluster were promoted from `
` to `
) })} diff --git a/src/renderer/src/components/archive/ArchivePage.tsx b/src/renderer/src/components/archive/ArchivePage.tsx index 199832a..27cd02b 100644 --- a/src/renderer/src/components/archive/ArchivePage.tsx +++ b/src/renderer/src/components/archive/ArchivePage.tsx @@ -10,7 +10,7 @@ import React, { useMemo, useState } from 'react' import type { Session, Workspace } from '../../types' import { ARCHIVE_RETENTION_CHOICES, type ArchiveRetentionDays } from '../../hooks/useSettings' -import { formatArchivedAgo, isExpired, retentionLabel } from '../../hooks/archive-retention' +import { formatLastUsedDate, isExpired, retentionLabel } from '../../hooks/archive-retention' import { PROVIDER_IMAGES, providerImageClass } from '../composer/provider-meta' import { ConfirmModal, type ConfirmModalRequest } from '../ui/ConfirmModal' import { Icon } from '../ui/Icon' @@ -50,8 +50,8 @@ export function ArchivePage({ return entries .filter(e => !wsFilter || e.wsId === wsFilter) .filter(e => !q || e.session.label.toLowerCase().includes(q) || e.wsName.toLowerCase().includes(q)) - // Most recently archived first; unknown timestamps sink to the bottom. - .sort((a, b) => (b.session.archivedAt ?? 0) - (a.session.archivedAt ?? 0)) + // Most recently used first; filing a chat away must not affect its order. + .sort((a, b) => (b.session.lastUsedAt ?? b.session.createdAt ?? 0) - (a.session.lastUsedAt ?? a.session.createdAt ?? 0)) }, [entries, query, wsFilter]) const confirmDeleteOne = (entry: ArchivedEntry) => { @@ -161,7 +161,7 @@ export function ArchivePage({ {entry.session.label} {entry.wsName} - archived {formatArchivedAgo(entry.session, now)} + {formatLastUsedDate(entry.session)} {gone && expired}
diff --git a/src/renderer/src/components/chat/ChatPane.tsx b/src/renderer/src/components/chat/ChatPane.tsx index 9f5f8bc..3fe6c5e 100644 --- a/src/renderer/src/components/chat/ChatPane.tsx +++ b/src/renderer/src/components/chat/ChatPane.tsx @@ -38,7 +38,7 @@ import { useVoiceSessionController } from '../../hooks/useVoiceSessionController import { getCrewCodeClient } from '../../runtime/crewcode-client' import { isCrewLaneSessionKey } from '../../../../shared/custody-types' import { isSessionDrag, readSessionDrag, type SessionDragPayload } from '../thread/session-drag' -import { crewCoderProfileLocksExecutionMode, type CrewCoderMode } from '../../../../shared/crewcoder-types' +import { crewCoderApprovalForProfile, crewCoderProfileLocksExecutionMode, type CrewCoderApprovalMode, type CrewCoderMode } from '../../../../shared/crewcoder-types' type CrewBranchWithMessagesProps = Omit, 'messagesByTab'> @@ -305,6 +305,9 @@ export function ChatPane({ const model = activeSession?.model ?? '' const effort = (activeSession?.effort ?? 'medium') as EffortLevel const crewcoderMode = activeSession?.crewcoderMode + // Full access is visible and effective only for the concrete CrewCoder + // profile; switching profiles must never leave hidden elevated authority. + const crewcoderApprovalMode = crewCoderApprovalForProfile(crewcoderMode, activeSession?.crewcoderApprovalMode) const crewCoderProfileActive = crewCoderProfileLocksExecutionMode(activeAgentId, crewcoderMode) const modeLevel = crewCoderProfileActive ? 'build' : normalizeModeLevel(activeSession?.mode ?? settingsDefaultMode) const composerMode: Mode = MODE_FROM_SETTINGS[modeLevel] ?? 'Build' @@ -379,9 +382,14 @@ export function ChatPane({ chatSessions.update(tabId, sessActive, { crewcoderMode: nextMode, ...(nextMode ? { mode: 'build' as const } : {}), + ...(nextMode === 'crewcoder' ? {} : { crewcoderApprovalMode: 'review' as const }), }) }, [tabId, sessActive, chatSessions]) + const setCrewCoderApprovalMode = useCallback((nextMode: CrewCoderApprovalMode) => { + chatSessions.update(tabId, sessActive, { crewcoderApprovalMode: nextMode }) + }, [tabId, sessActive, chatSessions]) + const setComposerMode = useCallback((m: Mode) => { if (crewCoderProfileActive) return chatSessions.update(tabId, sessActive, { mode: MODE_TO_LEVEL[m] }) @@ -455,6 +463,7 @@ export function ChatPane({ model, effort, crewcoderMode, + crewcoderApprovalMode, mode: modeLevel, effectivePath, bridges, @@ -478,6 +487,7 @@ export function ChatPane({ workspaceName: workspace.name, workspaceBranch: worktreeBranch ?? effectiveBranch, externalDirectories: activeSession?.externalDirectories ?? [], + onSessionUsed: () => chatSessions.touchLastUsed(tabId, sessActive), }) const sendWithSessionTitle = useCallback(async (overrideText?: string) => { @@ -621,6 +631,7 @@ export function ChatPane({ setHandoffError(null) setHandoffOpen(false) chatSessions.activate(target.tabId, target.id) + chatSessions.touchLastUsed(target.tabId, target.id) onHandoffDestinationActivate(target) const targetAgent = agents.find(agent => agent.id === target!.agentId) @@ -863,6 +874,8 @@ export function ChatPane({ setEffort={setEffort} crewcoderMode={crewcoderMode} setCrewCoderMode={setCrewCoderMode} + crewcoderApprovalMode={crewcoderApprovalMode} + setCrewCoderApprovalMode={setCrewCoderApprovalMode} delegationEnabled={delegation.enabled} onToggleDelegation={canSessionDelegate(activeSession) ? toggleDelegation : undefined} modePromptsEnabled={modePromptsEnabled} @@ -991,6 +1004,7 @@ export function ChatPane({ width={gitWidth} {...git.handlers} onOpenFileDiff={onOpenGitFileDiff} + onOpenConflictFile={onOpenFile} onOpenTerminal={openTerminalForPath} pluginGitLenses={pluginGitLenses} onPluginGitLens={onPluginGitLens} diff --git a/src/renderer/src/components/chat/SoloChatView.tsx b/src/renderer/src/components/chat/SoloChatView.tsx index 3d9c6d1..653e0ff 100644 --- a/src/renderer/src/components/chat/SoloChatView.tsx +++ b/src/renderer/src/components/chat/SoloChatView.tsx @@ -21,7 +21,7 @@ import type { EffortLevel } from '../composer/EffortPicker' import type { McpServerConfig } from '../../hooks/useSettings' import type { VoiceControlSurface } from '../../../../shared/voice-types' import type { TurnChangeTarget } from '../thread/turn-changes-data' -import type { CrewCoderMode } from '../../../../shared/crewcoder-types' +import type { CrewCoderApprovalMode, CrewCoderMode } from '../../../../shared/crewcoder-types' type ThreadView = 'chat' | 'code' | 'md' @@ -89,6 +89,8 @@ export interface SoloChatViewProps { setEffort: (e: EffortLevel) => void crewcoderMode?: CrewCoderMode setCrewCoderMode: (mode: CrewCoderMode | undefined) => void + crewcoderApprovalMode: CrewCoderApprovalMode + setCrewCoderApprovalMode: (mode: CrewCoderApprovalMode) => void // MCP — registry + this session's opt-in selection. Picker hidden when disabled. mcpEnabled?: boolean mcpServers?: McpServerConfig[] @@ -151,7 +153,7 @@ export function SoloChatView(props: SoloChatViewProps) { agentLabel, modelLabel, voiceControl, gitOpen, setGitOpen, github, dirtyCount = 0, changesOpen, changesCount, toggleChangesOpen, onStartCrew, onOpenCanvas, onOpenTerminal, onHandoff, composerMode, setComposerMode, composer, setComposer, onSend, onRunCommand, onQueueFollowUp, queuedFollowUps = [], onRemoveQueuedFollowUp, isRunning, loadingStatus = null, onStop, agentRequest, custodyHalt, onReauthorizeCustody, onAgentRequestResponse, - agents, activeAgentId, setActiveAgentId, model, setModel, effort, setEffort, crewcoderMode, setCrewCoderMode, + agents, activeAgentId, setActiveAgentId, model, setModel, effort, setEffort, crewcoderMode, setCrewCoderMode, crewcoderApprovalMode, setCrewCoderApprovalMode, mcpEnabled, mcpServers, selectedMcpIds, onToggleMcp, shortcutOverrides, onOpenFile, onOpenTurnChange, editorInitialFile, onThreadContextMenu, onOpenPrompts, onOpenBrowser, delegationEnabled, onToggleDelegation, @@ -269,6 +271,8 @@ export function SoloChatView(props: SoloChatViewProps) { onSelectEffort={setEffort} crewcoderMode={crewcoderMode} onSelectCrewCoderMode={setCrewCoderMode} + crewcoderApprovalMode={crewcoderApprovalMode} + onSelectCrewCoderApprovalMode={setCrewCoderApprovalMode} mcpEnabled={mcpEnabled} mcpServers={mcpServers} selectedMcpIds={selectedMcpIds} diff --git a/src/renderer/src/components/composer/Composer.tsx b/src/renderer/src/components/composer/Composer.tsx index b9c67a4..a9b3fc6 100644 --- a/src/renderer/src/components/composer/Composer.tsx +++ b/src/renderer/src/components/composer/Composer.tsx @@ -16,7 +16,7 @@ import { ComposerBranchPicker } from '../git/BranchPicker' import type { GitBranchRef } from '../git/git-state' import { VoiceOrb } from '../voice/VoiceOrb' import type { VoiceControlSurface } from '../../../../shared/voice-types' -import { crewCoderProfileLocksExecutionMode, type CrewCoderMode } from '../../../../shared/crewcoder-types' +import { crewCoderProfileLocksExecutionMode, type CrewCoderApprovalMode, type CrewCoderMode } from '../../../../shared/crewcoder-types' import { ComposerDictationButton } from './ComposerDictationButton' import { insertDictationText } from './composer-dictation-text' import { MobileComposerActionMenu, MobileComposerModelMenu } from './MobileComposerMenus' @@ -69,6 +69,8 @@ interface ComposerProps { onSelectEffort: (e: EffortLevel) => void crewcoderMode?: CrewCoderMode onSelectCrewCoderMode: (mode: CrewCoderMode | undefined) => void + crewcoderApprovalMode: CrewCoderApprovalMode + onSelectCrewCoderApprovalMode: (mode: CrewCoderApprovalMode) => void mcpEnabled?: boolean mcpServers?: McpServerConfig[] @@ -160,7 +162,7 @@ export function Composer({ sentMessageHistory = [], isRunning, onStop, voiceControl, dictationScopeId, agents, activeAgentId, onSelectAgent, - model, onSelectModel, effort, onSelectEffort, crewcoderMode, onSelectCrewCoderMode, + model, onSelectModel, effort, onSelectEffort, crewcoderMode, onSelectCrewCoderMode, crewcoderApprovalMode, onSelectCrewCoderApprovalMode, mcpEnabled, mcpServers, selectedMcpIds, onToggleMcp, shortcutOverrides, attachments: attachmentsProp, onAttachmentsChange, @@ -742,6 +744,8 @@ export function Composer({ effort={effort} crewcoderMode={crewcoderMode} onSelectCrewCoderMode={onSelectCrewCoderMode} + crewcoderApprovalMode={crewcoderApprovalMode} + onSelectCrewCoderApprovalMode={onSelectCrewCoderApprovalMode} crewcoderModeDisabled={isRunning} executionModeDisabled={executionModeDisabled} mode={mode} diff --git a/src/renderer/src/components/composer/CrewCoderApprovalPicker.test.ts b/src/renderer/src/components/composer/CrewCoderApprovalPicker.test.ts new file mode 100644 index 0000000..a065fa4 --- /dev/null +++ b/src/renderer/src/components/composer/CrewCoderApprovalPicker.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { crewCoderApprovalItems } from './CrewCoderApprovalPicker' + +describe('CrewCoder approval picker', () => { + it('exposes every CrewCoder approval policy in stable order', () => { + expect(crewCoderApprovalItems().map(item => item.id)).toEqual([ + 'review', 'always', 'never', 'full-access', 'sandboxed', + ]) + }) +}) diff --git a/src/renderer/src/components/composer/CrewCoderApprovalPicker.tsx b/src/renderer/src/components/composer/CrewCoderApprovalPicker.tsx new file mode 100644 index 0000000..c5564ec --- /dev/null +++ b/src/renderer/src/components/composer/CrewCoderApprovalPicker.tsx @@ -0,0 +1,38 @@ +import React from 'react' +import { Icon } from '../ui/Icon' +import { PickerSheet } from './PickerSheet' +import type { CrewCoderApprovalMode } from '../../../../shared/crewcoder-types' + +export function crewCoderApprovalItems() { + return [ + { id: 'review', label: 'Review', sub: 'Safe calls proceed; mutations and dangerous calls ask' }, + { id: 'always', label: 'Always', sub: 'Every non-safe call asks for permission' }, + { id: 'never', label: 'Never', sub: 'No prompts; dangerous calls remain blocked' }, + { id: 'full-access', label: 'Full access', sub: 'No prompts; all calls are accepted' }, + { id: 'sandboxed', label: 'Sandboxed', sub: 'No prompts; non-dangerous calls use the sandbox policy' }, + ] satisfies Array<{ id: CrewCoderApprovalMode; label: string; sub: string }> +} + +interface CrewCoderApprovalPickerProps { + open: boolean + anchor: HTMLElement | null + value: CrewCoderApprovalMode + onPick: (mode: CrewCoderApprovalMode) => void + onClose: () => void +} + +export function CrewCoderApprovalPicker({ open, anchor, value, onPick, onClose }: CrewCoderApprovalPickerProps) { + return ( + onPick(id as CrewCoderApprovalMode)} + defaultIcon={} + width={340} + /> + ) +} diff --git a/src/renderer/src/components/composer/ModelRow.test.ts b/src/renderer/src/components/composer/ModelRow.test.ts index cffc6a3..0d06a22 100644 --- a/src/renderer/src/components/composer/ModelRow.test.ts +++ b/src/renderer/src/components/composer/ModelRow.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { AgentInfo } from '../../types' -import { crewCoderModesAvailable } from './ModelRow' +import { crewCoderApprovalAvailable, crewCoderModesAvailable } from './ModelRow' const crewcoder = (available: boolean): AgentInfo => ({ id: 'crewcoder', name: 'CrewCoder', path: available ? '/usr/bin/crewcoder' : null, @@ -13,4 +13,11 @@ describe('CrewCoder model-row control', () => { expect(crewCoderModesAvailable([crewcoder(false)], 'crewcoder')).toBe(false) expect(crewCoderModesAvailable([crewcoder(true)], 'codex')).toBe(false) }) + + it('shows native approval only for the concrete CrewCoder profile', () => { + expect(crewCoderApprovalAvailable([crewcoder(true)], 'crewcoder', 'crewcoder')).toBe(true) + expect(crewCoderApprovalAvailable([crewcoder(true)], 'crewcoder', 'plugin')).toBe(false) + expect(crewCoderApprovalAvailable([crewcoder(true)], 'crewcoder', undefined)).toBe(false) + expect(crewCoderApprovalAvailable([crewcoder(true)], 'codex', 'crewcoder')).toBe(false) + }) }) diff --git a/src/renderer/src/components/composer/ModelRow.tsx b/src/renderer/src/components/composer/ModelRow.tsx index 5a18a52..c3cd10a 100644 --- a/src/renderer/src/components/composer/ModelRow.tsx +++ b/src/renderer/src/components/composer/ModelRow.tsx @@ -12,7 +12,8 @@ import type { McpServerConfig } from '../../hooks/useSettings' import type { Mode } from './ModeSegment' import { ModeSegment } from './ModeSegment' import { CrewCoderModePicker } from './CrewCoderModePicker' -import type { CrewCoderMode } from '../../../../shared/crewcoder-types' +import { CrewCoderApprovalPicker } from './CrewCoderApprovalPicker' +import type { CrewCoderApprovalMode, CrewCoderMode } from '../../../../shared/crewcoder-types' interface ModelRowProps { agents: AgentInfo[] @@ -28,6 +29,8 @@ interface ModelRowProps { setMode: (m: Mode) => void crewcoderMode?: CrewCoderMode onSelectCrewCoderMode: (mode: CrewCoderMode | undefined) => void + crewcoderApprovalMode: CrewCoderApprovalMode + onSelectCrewCoderApprovalMode: (mode: CrewCoderApprovalMode) => void crewcoderModeDisabled?: boolean executionModeDisabled?: boolean @@ -64,8 +67,12 @@ export function crewCoderModesAvailable(agents: AgentInfo[], activeAgentId: stri return activeAgentId === 'crewcoder' && agents.some(agent => agent.id === 'crewcoder' && agent.available) } +export function crewCoderApprovalAvailable(agents: AgentInfo[], activeAgentId: string, mode?: CrewCoderMode): boolean { + return crewCoderModesAvailable(agents, activeAgentId) && mode === 'crewcoder' +} + export const ModelRow = forwardRef(function ModelRow({ - agents, setMode, mode, crewcoderMode, onSelectCrewCoderMode, crewcoderModeDisabled = false, executionModeDisabled = false, activeAgentId, onSelectAgent, + agents, setMode, mode, crewcoderMode, onSelectCrewCoderMode, crewcoderApprovalMode, onSelectCrewCoderApprovalMode, crewcoderModeDisabled = false, executionModeDisabled = false, activeAgentId, onSelectAgent, model, onSelectModel, effort, onSelectEffort, mcpEnabled = false, mcpServers = [], selectedMcpIds = [], onToggleMcp, @@ -76,19 +83,24 @@ export const ModelRow = forwardRef(function Model const effortRef = useRef(null) const mcpRef = useRef(null) const crewCoderModeRef = useRef(null) + const crewCoderApprovalRef = useRef(null) const [provOpen, setProvOpen] = useState(false) const [modelOpen, setModelOpen] = useState(false) const [effortOpen, setEffortOpen] = useState(false) const [mcpOpen, setMcpOpen] = useState(false) const [crewCoderModeOpen, setCrewCoderModeOpen] = useState(false) + const [crewCoderApprovalOpen, setCrewCoderApprovalOpen] = useState(false) useEffect(() => { - onOpenChange?.(provOpen || modelOpen || effortOpen || mcpOpen || crewCoderModeOpen) - }, [provOpen, modelOpen, effortOpen, mcpOpen, crewCoderModeOpen, onOpenChange]) + onOpenChange?.(provOpen || modelOpen || effortOpen || mcpOpen || crewCoderModeOpen || crewCoderApprovalOpen) + }, [provOpen, modelOpen, effortOpen, mcpOpen, crewCoderModeOpen, crewCoderApprovalOpen, onOpenChange]) useEffect(() => { - if (crewcoderModeDisabled) setCrewCoderModeOpen(false) + if (crewcoderModeDisabled) { + setCrewCoderModeOpen(false) + setCrewCoderApprovalOpen(false) + } }, [crewcoderModeDisabled]) // Only count selections that still exist in the registry, so a removed server @@ -99,7 +111,7 @@ export const ModelRow = forwardRef(function Model const { list: models } = useProviderModels(activeAgentId) useImperativeHandle(ref, () => ({ - openModelPicker: () => { setModelOpen(true); setProvOpen(false); setEffortOpen(false); setCrewCoderModeOpen(false) }, + openModelPicker: () => { setModelOpen(true); setProvOpen(false); setEffortOpen(false); setCrewCoderModeOpen(false); setCrewCoderApprovalOpen(false) }, cycleModel: (dir: 1 | -1) => { if (models.length === 0) return const idx = models.findIndex(m => m.id === model) @@ -111,6 +123,7 @@ export const ModelRow = forwardRef(function Model const active = agents.find(a => a.id === activeAgentId) ?? agents.find(a => a.available) const showCrewCoderModes = crewCoderModesAvailable(agents, activeAgentId) + const showCrewCoderApproval = crewCoderApprovalAvailable(agents, activeAgentId, crewcoderMode) const handleSelectAgent = (id: string) => { // Start resolving the next provider immediately so the model picker is warm. @@ -125,7 +138,7 @@ export const ModelRow = forwardRef(function Model )} + {showCrewCoderApproval && ( + + )} +
+
{state.ahead || 0} {state.behind || 0} @@ -102,6 +108,34 @@ export function GitPage(props: GitSidebarProps) { onCreate={onCreateBranch} onClose={() => setCreateBranchOpen(false)} /> + branch.name.replace(/^origin\//, ''))} + defaultBase={state.defaultBase || state.comparisonRef || 'main'} + defaultTitle={state.history?.[0]?.msg || workspace.branch.replace(/[-_/]+/g, ' ')} + onCreate={async options => (await onCreatePR?.(options))?.ok ?? false} + onClose={() => setCreatePrOpen(false)} + /> + setPrBrowserOpen(false)} + /> {menu && createPortal( <> diff --git a/src/renderer/src/components/git/GitSidebar.tsx b/src/renderer/src/components/git/GitSidebar.tsx index 540b4b1..52d3a39 100644 --- a/src/renderer/src/components/git/GitSidebar.tsx +++ b/src/renderer/src/components/git/GitSidebar.tsx @@ -7,9 +7,11 @@ import React, { useState, useEffect, useRef } from 'react' import { createPortal } from 'react-dom' import { Icon } from '../ui/Icon' import { PublishModal } from './PublishModal' +import { PullRequestModal } from './PullRequestModal' +import { PullRequestBrowser } from './PullRequestBrowser' import type { GitState, GitChange, GitConflict, GitBranchRef, GitWorktreeRef, GitPrRef, - GitHistoryEntry, GitBanner, CheckState, GitSidebarWorkspace, GitSidebarHandlers, + GitHistoryEntry, GitBanner, GitSidebarWorkspace, GitSidebarHandlers, GitChatTarget, } from './git-state' import { NEW_CHAT_TARGET } from './git-state' @@ -404,9 +406,10 @@ interface ConflictsBodyProps { onResolve?: (opts: { file: string; strategy: 'ours' | 'theirs' | 'editor' | 'agent'; targetTabId?: string }) => void onAbort?: () => void onContinue?: () => void + onOpenFile?: (path: string) => void } -function ConflictsBody({ conflicts, branch, chatTargets = [], onResolve, onAbort, onContinue }: ConflictsBodyProps) { +function ConflictsBody({ conflicts, branch, chatTargets = [], onResolve, onAbort, onContinue, onOpenFile }: ConflictsBodyProps) { // Which conflict row's "ask agent" menu is open (by path). Only relevant when // there are chat tabs to choose between; otherwise the button resolves directly. const [agentMenuFor, setAgentMenuFor] = useState(null) @@ -436,7 +439,7 @@ function ConflictsBody({ conflicts, branch, chatTargets = [], onResolve, onAbort
Merge in progress on {branch}. - Resolve each file, then continue. + {conflicts.length > 0 ? ' Resolve each file, then continue.' : ' All conflicts are resolved; continue to create the merge commit.'}
{conflicts.map(c => ( @@ -447,7 +450,7 @@ function ConflictsBody({ conflicts, branch, chatTargets = [], onResolve, onAbort {c.hunks > 0 && {c.hunks} hunk{c.hunks === 1 ? '' : 's'}}
-
+ ) + } - {expandedPr && ( -
- {expandedPr.desc &&
{expandedPr.desc}
} -
from{expandedPr.head}
-
into{expandedPr.base}
- {expandedPr.runs && expandedPr.runs.length > 0 && ( - <> -
checks
-
- {expandedPr.runs.map((r, i) => ( -
- - {r.name} - {r.dur} -
- ))} -
- - )} -
- - - -
-
- )} - -
- - {hasUnpushed ? `${branch} has unpushed commits` : `no PR yet for ${branch}`} - - + return ( +
+
+
{branchPr.status}#{branchPr.num}
+

{branchPr.title}

+
{branchPr.head}{branchPr.base}
+
{failed ? `${failed} checks failing` : branchPr.checks?.length ? `${passed}/${branchPr.checks.length} checks passed` : 'No checks reported'}{(branchPr.mergeStateStatus ?? 'merge state unknown').toLowerCase().replaceAll('_', ' ')}
- +
+
{hasUnpushed ? `${branch} has unpushed commits` : `Working on ${branch}`}{prs.length} repository PR{prs.length === 1 ? '' : 's'}
+
) } @@ -756,6 +692,8 @@ export interface GitSidebarProps extends GitSidebarHandlers { onPluginGitLens?: (target: { pluginId: string; sidebarPanel?: string; tab?: string; command?: string }) => void /** Mobile overlay close action. Omitted for the persistent desktop/sidebar page variants. */ onClose?: () => void + /** Open a conflicted file in CrewCode's editable Code Editor. */ + onOpenConflictFile?: (path: string) => void } export function GitSidebar({ @@ -769,19 +707,24 @@ export function GitSidebar({ onStageFile, onUnstageFile, onStageAll, onUnstageAll, onDiscardFile, onOpenFileDiff, onCommit, onCreateWorktree, onSwitchWorktree, onMergeWorktree, onRemoveWorktree, onResolveConflict, onAbortMerge, onContinueMerge, - onCreatePR, onOpenPR, onMergePR, onApprovePR, + onCreatePR, onMergePR, + onUpdatePRBranch, onReadyPR, onDraftPR, onReopenPR, onEditPR, onMetadataPR, onRerunPRCheck, onMergeAutomationPR, onPreparePRConflicts, onClosePR, onReviewPR, onInitRepo, onPublish, onOpenTerminal, + onOpenConflictFile, pluginGitLenses = [], onPluginGitLens, onClose, }: GitSidebarProps) { const hasConflicts = (state.conflicts || []).length > 0 + const mergeInProgress = state.mergeInProgress === true // A remote can exist after a partial publish while the branch was never pushed. const needsPublish = state.hasRemote === false || state.hasUpstream === false const notRepo = state.isRepo === false const noCommits = (state.history || []).length === 0 const [publishOpen, setPublishOpen] = useState(false) + const [prCreateOpen, setPrCreateOpen] = useState(false) + const [prBrowserOpen, setPrBrowserOpen] = useState(false) // Open which cards by default — conflicts always; changes when dirty; others closed. const [open, setOpen] = useState({ @@ -838,7 +781,7 @@ export function GitSidebar({ onPull={onPull} onFetch={handleFetch} onSync={onSync} - onCreatePR={onCreatePR} + onCreatePR={() => setPrCreateOpen(true)} onOpenTerminal={onOpenTerminal} onCheckoutBranch={onCheckoutBranch} onCreateBranch={onCreateBranch} @@ -902,7 +845,7 @@ export function GitSidebar({ )} - {hasConflicts && ( + {(hasConflicts || mergeInProgress) && ( )} @@ -991,10 +935,8 @@ export function GitSidebar({ prs={state.prs || []} branch={workspace.branch} hasUnpushed={state.ahead > 0} - onCreate={onCreatePR} - onOpen={onOpenPR} - onMerge={onMergePR} - onApprove={onApprovePR} + onCreate={() => setPrCreateOpen(true)} + onBrowse={() => setPrBrowserOpen(true)} /> @@ -1022,6 +964,34 @@ export function GitSidebar({ onPublish={async opts => (await onPublish?.(opts)) ?? false} onClose={() => setPublishOpen(false)} /> + branch.name.replace(/^origin\//, ''))} + defaultBase={state.defaultBase || state.comparisonRef || 'main'} + defaultTitle={state.history?.[0]?.msg || workspace.branch.replace(/[-_/]+/g, ' ')} + onCreate={async options => (await onCreatePR?.(options))?.ok ?? false} + onClose={() => setPrCreateOpen(false)} + /> + setPrBrowserOpen(false)} + /> ) } diff --git a/src/renderer/src/components/git/PullRequestBrowser.tsx b/src/renderer/src/components/git/PullRequestBrowser.tsx new file mode 100644 index 0000000..274a5a4 --- /dev/null +++ b/src/renderer/src/components/git/PullRequestBrowser.tsx @@ -0,0 +1,973 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import type { + GitHubMergeMethod, + GitHubPullRequestCheckEvidence, + GitHubPullRequestCheckLogResult, + GitHubPullRequestCheckRerunOptions, + GitHubPullRequestChecksContext, + GitHubPullRequestCatalogue, + GitHubPullRequestCatalogueItem, + GitHubPullRequestConflictPreparationResult, + GitHubPullRequestDetail, + GitHubPullRequestInlineCommentDraft, + GitHubPullRequestEditOptions, + GitHubPullRequestManagementContext, + GitHubPullRequestMergeAutomationOptions, + GitHubPullRequestMetadataOptions, + GitHubPullRequestReviewContext, + GitHubPullRequestReviewOptions, +} from '../../../../shared/github-types' +import { getCrewCodeClient } from '../../runtime/crewcode-client' +import { PierreDiff } from '../diff/PierreDiff' +import { Markdown } from '../thread/Markdown' +import { Icon } from '../ui/Icon' +import { parsePullRequestBodySections } from './pull-request-body' +import { splitPullRequestPatch } from './pull-request-diff' +import type { GitActionOutcome } from './git-state' + +type PullRequestFilter = 'all' | 'open' | 'closed' | 'assigned' +type PullRequestBrowserTab = 'overview' | 'timeline' | 'changes' | 'checks' +type PullRequestReviewFilter = 'any' | 'requested' | 'requested-to-you' | 'approved' | 'changes-requested' | 'review-required' + +interface PullRequestBrowserMemory { + selectedNumber: number | null + selectedPath: string + tab: PullRequestBrowserTab + filter: PullRequestFilter + query: string + author: string + label: string + base: string + head: string + review: PullRequestReviewFilter +} + +const browserMemoryByRepo = new Map() + +interface PullRequestBrowserProps { + open: boolean + repoPath: string + currentBranch: string + onMerge?: (num: number, method: GitHubMergeMethod, headCommitId?: string) => Promise + onUpdateBranch?: (num: number) => Promise + onReady?: (num: number) => Promise + onDraft?: (num: number) => Promise + onReopen?: (num: number) => Promise + onEdit?: (num: number, options: GitHubPullRequestEditOptions) => Promise + onMetadata?: (num: number, options: GitHubPullRequestMetadataOptions) => Promise + onRerunCheck?: (num: number, options: GitHubPullRequestCheckRerunOptions) => Promise + onMergeAutomation?: (num: number, options: GitHubPullRequestMergeAutomationOptions) => Promise + onPrepareConflicts?: (head: string, base: string) => Promise + onClosePr?: (num: number) => Promise + onReview?: (num: number, options: GitHubPullRequestReviewOptions) => Promise + onClose: () => void +} + +function dateLabel(value: string): string { + const date = new Date(value) + return !value || Number.isNaN(date.getTime()) ? '' : new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', year: 'numeric' }).format(date) +} + +function exactDateLabel(value: string): string { + const date = new Date(value) + return !value || Number.isNaN(date.getTime()) ? 'Creation time unavailable' : new Intl.DateTimeFormat(undefined, { + month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short', + }).format(date) +} + +function prState(item: GitHubPullRequestCatalogueItem): string { + if (item.isDraft) return 'Draft' + return item.state === 'MERGED' ? 'Merged' : item.state === 'CLOSED' ? 'Closed' : 'Open' +} + +function githubProfile(login: string): string { + return `https://github.com/${encodeURIComponent(login)}` +} + +function checkState(status: string, conclusion: string | null): 'ok' | 'fail' | 'pending' | 'neutral' { + const value = (conclusion || status).toUpperCase() + if (['PENDING', 'QUEUED', 'IN_PROGRESS', 'WAITING', 'REQUESTED', 'EXPECTED'].includes(value)) return 'pending' + if (value === 'SUCCESS') return 'ok' + if (['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STALE', 'STARTUP_FAILURE'].includes(value)) return 'fail' + return 'neutral' +} + +export function PullRequestBrowser({ + open, + repoPath, + currentBranch, + onMerge, + onUpdateBranch, + onReady, + onDraft, + onReopen, + onEdit, + onMetadata, + onRerunCheck, + onMergeAutomation, + onPrepareConflicts, + onClosePr, + onReview, + onClose, +}: PullRequestBrowserProps) { + const [catalogue, setCatalogue] = useState(null) + const initialMemory = browserMemoryByRepo.get(repoPath) + const [selectedNumber, setSelectedNumber] = useState(initialMemory?.selectedNumber ?? null) + const [detailByNumber, setDetailByNumber] = useState>({}) + const [patchByNumber, setPatchByNumber] = useState>({}) + const [reviewContextByNumber, setReviewContextByNumber] = useState>({}) + const [reviewContextError, setReviewContextError] = useState('') + const [reviewContextLoading, setReviewContextLoading] = useState(false) + const [localViewedByNumber, setLocalViewedByNumber] = useState>({}) + const [pendingCommentsByNumber, setPendingCommentsByNumber] = useState>({}) + const [inlineTarget, setInlineTarget] = useState<{ path: string; side: 'LEFT' | 'RIGHT'; line: number } | null>(null) + const [inlineBody, setInlineBody] = useState('') + const [selectedPath, setSelectedPath] = useState(initialMemory?.selectedPath ?? '') + const [tab, setTab] = useState(initialMemory?.tab ?? 'overview') + const [filter, setFilter] = useState(initialMemory?.filter ?? 'all') + const [query, setQuery] = useState(initialMemory?.query ?? '') + const [authorFilter, setAuthorFilter] = useState(initialMemory?.author ?? '') + const [labelFilter, setLabelFilter] = useState(initialMemory?.label ?? '') + const [baseFilter, setBaseFilter] = useState(initialMemory?.base ?? '') + const [headFilter, setHeadFilter] = useState(initialMemory?.head ?? '') + const [reviewFilter, setReviewFilter] = useState(initialMemory?.review ?? 'any') + const [advancedFiltersOpen, setAdvancedFiltersOpen] = useState(false) + const [loading, setLoading] = useState(false) + const [detailLoading, setDetailLoading] = useState(false) + const [patchLoading, setPatchLoading] = useState(false) + const [error, setError] = useState('') + const [patchError, setPatchError] = useState('') + const [avatarByLogin, setAvatarByLogin] = useState>({}) + const [mutation, setMutation] = useState<{ kind: string; number: number } | null>(null) + const [notice, setNotice] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null) + const [reviewEvent, setReviewEvent] = useState('comment') + const [reviewBody, setReviewBody] = useState('') + const [mergeMethod, setMergeMethod] = useState('squash') + const [confirmAction, setConfirmAction] = useState<'merge' | 'auto' | 'disable-auto' | 'queue' | 'close' | 'reopen' | 'draft' | 'resolve' | null>(null) + const [conflictPreparation, setConflictPreparation] = useState(null) + const [editingDetails, setEditingDetails] = useState(false) + const [editTitle, setEditTitle] = useState('') + const [editBody, setEditBody] = useState('') + const [managementOpen, setManagementOpen] = useState(false) + const [managementContextByNumber, setManagementContextByNumber] = useState>({}) + const [managementLoading, setManagementLoading] = useState(false) + const [managementError, setManagementError] = useState('') + const [checksContextByNumber, setChecksContextByNumber] = useState>({}) + const [checksLoading, setChecksLoading] = useState(false) + const [checksError, setChecksError] = useState('') + const [expandedCheckId, setExpandedCheckId] = useState('') + const [checkLogByKey, setCheckLogByKey] = useState>({}) + const [checkLogLoadingKey, setCheckLogLoadingKey] = useState('') + const [checkRerunConfirm, setCheckRerunConfirm] = useState<{ check: GitHubPullRequestCheckEvidence; mode: 'all' | 'failed' | 'job' } | null>(null) + const [metadataValue, setMetadataValue] = useState>({ reviewer: '', assignee: '', label: '' }) + const previousRepoRef = useRef(repoPath) + const previousSelectedRef = useRef(selectedNumber) + const restoringRepoRef = useRef(false) + + const loadCatalogue = useCallback(async () => { + if (!open) return + setLoading(true) + setError('') + try { + const result = await getCrewCodeClient().githubPrCatalogue(repoPath) + if ('error' in result) throw new Error(result.error) + setCatalogue(result) + setSelectedNumber(current => { + if (current && result.items.some(item => item.number === current)) return current + return result.items.find(item => item.head === currentBranch)?.number ?? result.items[0]?.number ?? null + }) + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : String(loadError)) + } finally { + setLoading(false) + } + }, [currentBranch, open, repoPath]) + + useEffect(() => { + if (!open) return + setMutation(null) + setNotice(null) + setConfirmAction(null) + setConflictPreparation(null) + void loadCatalogue() + }, [open, repoPath, loadCatalogue]) + + useEffect(() => { + if (previousRepoRef.current === repoPath) return + previousRepoRef.current = repoPath + restoringRepoRef.current = true + const memory = browserMemoryByRepo.get(repoPath) + setSelectedNumber(memory?.selectedNumber ?? null) + setSelectedPath(memory?.selectedPath ?? '') + setTab(memory?.tab ?? 'overview') + setFilter(memory?.filter ?? 'all') + setQuery(memory?.query ?? '') + setAuthorFilter(memory?.author ?? '') + setLabelFilter(memory?.label ?? '') + setBaseFilter(memory?.base ?? '') + setHeadFilter(memory?.head ?? '') + setReviewFilter(memory?.review ?? 'any') + setCatalogue(null) + setDetailByNumber({}) + setPatchByNumber({}) + setReviewContextByNumber({}) + setManagementContextByNumber({}) + setChecksContextByNumber({}) + setCheckLogByKey({}) + }, [repoPath]) + + useEffect(() => { + if (!open) return + browserMemoryByRepo.set(repoPath, { selectedNumber, selectedPath, tab, filter, query, author: authorFilter, label: labelFilter, base: baseFilter, head: headFilter, review: reviewFilter }) + }, [authorFilter, baseFilter, filter, headFilter, labelFilter, open, query, repoPath, reviewFilter, selectedNumber, selectedPath, tab]) + + useEffect(() => { + if (previousSelectedRef.current === selectedNumber) return + previousSelectedRef.current = selectedNumber + if (restoringRepoRef.current) { restoringRepoRef.current = false; return } + setSelectedPath('') + setReviewContextError('') + setInlineTarget(null) + setInlineBody('') + setPatchError('') + setNotice(null) + setReviewEvent('comment') + setReviewBody('') + setConfirmAction(null) + setConflictPreparation(null) + setEditingDetails(false) + setManagementOpen(false) + setManagementError('') + setChecksError('') + setExpandedCheckId('') + setCheckRerunConfirm(null) + }, [selectedNumber]) + + useEffect(() => { + if (!open || selectedNumber == null || detailByNumber[selectedNumber]) return + let cancelled = false + setDetailLoading(true) + void getCrewCodeClient().githubPrDetail(repoPath, selectedNumber).then(result => { + if (cancelled) return + if ('error' in result) { setError(result.error); return } + setDetailByNumber(current => ({ ...current, [selectedNumber]: result })) + setSelectedPath(current => current && result.files.some(file => file.path === current) ? current : result.files[0]?.path ?? '') + }).catch(loadError => { + if (!cancelled) setError(loadError instanceof Error ? loadError.message : String(loadError)) + }).finally(() => { if (!cancelled) setDetailLoading(false) }) + return () => { cancelled = true } + }, [detailByNumber, open, repoPath, selectedNumber]) + + useEffect(() => { + if (!open || tab !== 'changes' || selectedNumber == null || Object.hasOwn(patchByNumber, selectedNumber)) return + let cancelled = false + setPatchLoading(true) + setPatchError('') + void getCrewCodeClient().githubPrDiff(repoPath, selectedNumber).then(result => { + if (cancelled) return + if (!result.ok) { setPatchError(result.error || `Could not load code changes for #${selectedNumber}`); return } + setPatchByNumber(current => ({ ...current, [selectedNumber]: result.patch })) + }).catch(loadError => { + if (!cancelled) setPatchError(loadError instanceof Error ? loadError.message : String(loadError)) + }).finally(() => { if (!cancelled) setPatchLoading(false) }) + return () => { cancelled = true } + }, [open, patchByNumber, repoPath, selectedNumber, tab]) + + const loadReviewContext = useCallback(async (number: number, force = false) => { + if (!force && reviewContextByNumber[number]) return reviewContextByNumber[number] + setReviewContextLoading(true) + setReviewContextError('') + try { + const result = await getCrewCodeClient().githubPrReviewContext(repoPath, number) + if ('error' in result) throw new Error(result.error) + setReviewContextByNumber(current => ({ ...current, [number]: result })) + return result + } catch (loadError) { + setReviewContextError(loadError instanceof Error ? loadError.message : String(loadError)) + return null + } finally { + setReviewContextLoading(false) + } + }, [repoPath, reviewContextByNumber]) + + useEffect(() => { + if (!open || tab !== 'changes' || selectedNumber == null) return + void loadReviewContext(selectedNumber) + }, [loadReviewContext, open, selectedNumber, tab]) + + useEffect(() => { + if (!open) return + const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape' && !mutation) onClose() } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [mutation, onClose, open]) + + const avatarSelected = catalogue?.items.find(item => item.number === selectedNumber) ?? null + const avatarDetail = selectedNumber == null ? null : detailByNumber[selectedNumber] ?? null + const avatarAuthor = avatarDetail?.author || avatarSelected?.author || '' + + useEffect(() => { + if (!open || !avatarAuthor || Object.hasOwn(avatarByLogin, avatarAuthor)) return + let cancelled = false + void getCrewCodeClient().githubAvatar(repoPath, avatarAuthor).then(result => { + if (cancelled) return + setAvatarByLogin(current => ({ ...current, [avatarAuthor]: result.ok && result.dataUrl ? result.dataUrl : null })) + }).catch(() => { + if (!cancelled) setAvatarByLogin(current => ({ ...current, [avatarAuthor]: null })) + }) + return () => { cancelled = true } + }, [avatarAuthor, avatarByLogin, open, repoPath]) + + useEffect(() => { + if (!open || !managementOpen || selectedNumber == null || managementContextByNumber[selectedNumber]) return + let cancelled = false + setManagementLoading(true) + setManagementError('') + void getCrewCodeClient().githubPrManagementContext(repoPath, selectedNumber).then(result => { + if (cancelled) return + if ('error' in result) { setManagementError(result.error); return } + setManagementContextByNumber(current => ({ ...current, [selectedNumber]: result })) + }).catch(loadError => { + if (!cancelled) setManagementError(loadError instanceof Error ? loadError.message : String(loadError)) + }).finally(() => { if (!cancelled) setManagementLoading(false) }) + return () => { cancelled = true } + }, [managementContextByNumber, managementOpen, open, repoPath, selectedNumber]) + + const loadChecksContext = useCallback(async (number: number, force = false) => { + if (!force && checksContextByNumber[number]) return checksContextByNumber[number] + setChecksLoading(true) + setChecksError('') + try { + const result = await getCrewCodeClient().githubPrChecksContext(repoPath, number) + if ('error' in result) throw new Error(result.error) + setChecksContextByNumber(current => ({ ...current, [number]: result })) + return result + } catch (loadError) { + setChecksError(loadError instanceof Error ? loadError.message : String(loadError)) + return null + } finally { + setChecksLoading(false) + } + }, [checksContextByNumber, repoPath]) + + useEffect(() => { + if (!open || tab !== 'checks' || selectedNumber == null) return + void loadChecksContext(selectedNumber) + }, [loadChecksContext, open, selectedNumber, tab]) + + const visibleItems = useMemo(() => { + const viewer = catalogue?.viewer?.toLowerCase() ?? '' + const search = query.trim().toLowerCase() + return (catalogue?.items ?? []).filter(item => { + if (filter === 'open' && item.state !== 'OPEN') return false + if (filter === 'closed' && item.state === 'OPEN') return false + if (filter === 'assigned' && (!viewer || ![...item.assignees, ...item.reviewers].some(login => login.toLowerCase() === viewer))) return false + if (authorFilter && item.author !== authorFilter) return false + if (labelFilter && !item.labels.includes(labelFilter)) return false + if (baseFilter && item.base !== baseFilter) return false + if (headFilter && item.head !== headFilter) return false + if (reviewFilter === 'requested' && item.reviewers.length === 0) return false + if (reviewFilter === 'requested-to-you' && (!viewer || !item.reviewers.some(login => login.toLowerCase() === viewer))) return false + if (reviewFilter === 'approved' && item.reviewDecision !== 'APPROVED') return false + if (reviewFilter === 'changes-requested' && item.reviewDecision !== 'CHANGES_REQUESTED') return false + if (reviewFilter === 'review-required' && item.reviewDecision !== 'REVIEW_REQUIRED') return false + return !search || `${item.title} ${item.number} ${item.author} ${item.head} ${item.base}`.toLowerCase().includes(search) + }) + }, [authorFilter, baseFilter, catalogue, filter, headFilter, labelFilter, query, reviewFilter]) + + useEffect(() => { + if (mutation) return + if (visibleItems.length === 0) return + if (!visibleItems.some(item => item.number === selectedNumber)) setSelectedNumber(visibleItems[0].number) + }, [mutation, selectedNumber, visibleItems]) + + if (!open) return null + const selected = catalogue?.items.find(item => item.number === selectedNumber) ?? null + const detail = selectedNumber == null ? null : detailByNumber[selectedNumber] ?? null + const author = detail?.author || selected?.author || 'unknown' + const authorAvatar = avatarByLogin[author] + const createdAt = detail?.createdAt || selected?.createdAt || '' + const sections = parsePullRequestBodySections(detail?.body ?? selected?.body ?? '') + const patch = selectedNumber == null ? '' : patchByNumber[selectedNumber] ?? '' + const patches = splitPullRequestPatch(patch) + const selectedPatch = patches.find(file => file.path === selectedPath)?.patch ?? '' + const reviewContext = selectedNumber == null ? null : reviewContextByNumber[selectedNumber] ?? null + const pendingComments = selectedNumber == null ? [] : pendingCommentsByNumber[selectedNumber] ?? [] + const localViewed = selectedNumber == null ? [] : localViewedByNumber[selectedNumber] ?? [] + const viewedPaths = new Set(reviewContext ? reviewContext.files.filter(file => file.viewed).map(file => file.path) : localViewed) + const selectedFileViewed = !!selectedPath && viewedPaths.has(selectedPath) + const selectedThreads = (reviewContext?.threads ?? []).filter(thread => thread.path === selectedPath) + const filesSinceLastReview = new Set(reviewContext?.filesSinceLastReview ?? []) + const selectedFileIndex = Math.max(0, (detail?.files ?? []).findIndex(file => file.path === selectedPath)) + const comments = detail?.comments ?? [] + const managementContext = selectedNumber == null ? null : managementContextByNumber[selectedNumber] ?? null + const checksContext = selectedNumber == null ? null : checksContextByNumber[selectedNumber] ?? null + const timeline = detail ? [ + { id: 'opened', at: detail.createdAt, kind: 'opened' as const, author: detail.author, title: 'Opened this pull request', body: '' }, + ...detail.commits.map(commit => ({ id: commit.oid, at: commit.committedAt, kind: 'commit' as const, author: commit.author, title: commit.message, body: commit.oid.slice(0, 7) })), + ...detail.comments.map(comment => ({ id: comment.id, at: comment.createdAt, kind: comment.kind, author: comment.author, title: comment.kind === 'review' ? comment.state?.toLowerCase().replaceAll('_', ' ') || 'Submitted a review' : 'Commented', body: comment.body })), + ].sort((a, b) => a.at.localeCompare(b.at)) : [] + const passedChecks = detail?.checks.filter(check => checkState(check.status, check.conclusion) === 'ok').length ?? 0 + const failedChecks = detail?.checks.filter(check => checkState(check.status, check.conclusion) === 'fail').length ?? 0 + const detailedPassedChecks = checksContext?.checks.filter(check => checkState(check.status, check.conclusion) === 'ok').length ?? passedChecks + const detailedFailedChecks = checksContext?.checks.filter(check => checkState(check.status, check.conclusion) === 'fail').length ?? failedChecks + const detailedPendingChecks = checksContext?.checks.filter(check => checkState(check.status, check.conclusion) === 'pending').length ?? 0 + const isSelfAuthored = !!catalogue?.viewer && catalogue.viewer.toLowerCase() === author.toLowerCase() + const isOpen = detail?.state === 'OPEN' + const actionLocked = mutation !== null + const showConflictFlow = detail?.mergeStateStatus === 'DIRTY' + || /not mergeable|conflict|cleanly created/i.test(notice?.text ?? '') + || conflictPreparation !== null + const filterChoices = { + authors: [...new Set((catalogue?.items ?? []).map(item => item.author).filter(Boolean))].sort(), + labels: [...new Set((catalogue?.items ?? []).flatMap(item => item.labels))].sort(), + bases: [...new Set((catalogue?.items ?? []).map(item => item.base).filter(Boolean))].sort(), + heads: [...new Set((catalogue?.items ?? []).map(item => item.head).filter(Boolean))].sort(), + } + const checkSuites = [...(checksContext?.checks ?? [])].reduce>((suites, check) => { + const suite = suites.find(candidate => candidate.name === check.suiteName) + if (suite) suite.checks.push(check) + else suites.push({ name: check.suiteName, checks: [check] }) + return suites + }, []) + const mergeBlockers: Array<{ kind: 'blocked' | 'waiting'; title: string; detail: string }> = [] + if (detail?.isDraft) mergeBlockers.push({ kind: 'blocked', title: 'Draft pull request', detail: 'Mark this pull request ready before merging.' }) + if (checksContext?.mergeable === 'CONFLICTING' || detail?.mergeStateStatus === 'DIRTY') mergeBlockers.push({ kind: 'blocked', title: 'Merge conflicts', detail: `Resolve conflicts between ${detail?.head ?? 'the head'} and ${detail?.base ?? 'the base'} before merging.` }) + if (checksContext?.reviewDecision === 'CHANGES_REQUESTED') mergeBlockers.push({ kind: 'blocked', title: 'Changes requested', detail: 'At least one active review requests changes.' }) + else if (checksContext?.reviewDecision === 'REVIEW_REQUIRED') mergeBlockers.push({ kind: 'waiting', title: 'Review required', detail: 'Required approving reviews have not been observed.' }) + const requiredFailed = checksContext?.checks.filter(check => check.isRequired && checkState(check.status, check.conclusion) === 'fail') ?? [] + const requiredPending = checksContext?.checks.filter(check => check.isRequired && checkState(check.status, check.conclusion) === 'pending') ?? [] + if (requiredFailed.length) mergeBlockers.push({ kind: 'blocked', title: 'Required checks failed', detail: requiredFailed.map(check => check.name).join(', ') }) + if (requiredPending.length) mergeBlockers.push({ kind: 'waiting', title: 'Required checks pending', detail: requiredPending.map(check => check.name).join(', ') }) + if (detail?.mergeStateStatus === 'BEHIND') mergeBlockers.push({ kind: 'waiting', title: 'Head branch is behind', detail: `${detail.head} needs the latest ${detail.base} changes.` }) + if (checksContext?.mergeStateStatus === 'BLOCKED' && !mergeBlockers.some(blocker => blocker.title.toLowerCase().includes('required') || blocker.title === 'Changes requested')) mergeBlockers.push({ kind: 'blocked', title: 'Repository rules', detail: 'GitHub reports this pull request as blocked but did not return a more specific rule.' }) + + const selectRelativeFile = (direction: -1 | 1) => { + const files = detail?.files ?? [] + if (!files.length) return + const nextIndex = (selectedFileIndex + direction + files.length) % files.length + setSelectedPath(files[nextIndex].path) + setInlineTarget(null) + setInlineBody('') + } + + const selectNextUnviewedFile = () => { + const files = detail?.files ?? [] + if (!files.length) return + for (let offset = 1; offset <= files.length; offset += 1) { + const candidate = files[(selectedFileIndex + offset) % files.length] + if (!viewedPaths.has(candidate.path)) { setSelectedPath(candidate.path); setInlineTarget(null); setInlineBody(''); return } + } + } + + const refreshSelectedEvidence = async (number: number): Promise => { + const client = getCrewCodeClient() + const [nextCatalogue, nextDetail] = await Promise.all([ + client.githubPrCatalogue(repoPath), + client.githubPrDetail(repoPath, number), + ]) + if ('error' in nextCatalogue) throw new Error(nextCatalogue.error) + if ('error' in nextDetail) throw new Error(nextDetail.error) + setCatalogue(nextCatalogue) + setDetailByNumber(current => ({ ...current, [number]: nextDetail })) + setPatchByNumber(current => { + const next = { ...current } + delete next[number] + return next + }) + setChecksContextByNumber(current => { + const next = { ...current } + delete next[number] + return next + }) + setSelectedPath(current => current && nextDetail.files.some(file => file.path === current) ? current : nextDetail.files[0]?.path ?? '') + return nextDetail + } + + const refreshBrowser = async () => { + setLoading(true) + setError('') + try { + if (selectedNumber == null) await loadCatalogue() + else await refreshSelectedEvidence(selectedNumber) + } catch (refreshError) { + setError(refreshError instanceof Error ? refreshError.message : String(refreshError)) + } finally { + setLoading(false) + } + } + + const runMutation = async ( + kind: string, + action: (number: number) => Promise | undefined, + success: (nextDetail: GitHubPullRequestDetail) => string, + ) => { + if (!selected || actionLocked) return + const targetNumber = selected.number + setMutation({ kind, number: targetNumber }) + setNotice(null) + try { + const result = await action(targetNumber) + if (!result?.ok) throw new Error(result?.error || `${kind} was not completed`) + try { + const nextDetail = await refreshSelectedEvidence(targetNumber) + setNotice({ kind: 'ok', text: success(nextDetail) }) + } catch (refreshError) { + setNotice({ kind: 'error', text: `${kind} completed, but CrewCode could not refresh GitHub evidence: ${refreshError instanceof Error ? refreshError.message : String(refreshError)}` }) + } + } catch (mutationError) { + setNotice({ kind: 'error', text: mutationError instanceof Error ? mutationError.message : String(mutationError) }) + } finally { + setMutation(null) + } + } + + const addPendingComment = () => { + if (!detail || !inlineTarget || !inlineBody.trim() || !selectedNumber || !reviewContext?.headCommitId) return + const comment: GitHubPullRequestInlineCommentDraft = { + id: globalThis.crypto?.randomUUID?.() ?? `review-${Date.now()}`, + ...inlineTarget, + body: inlineBody.trim(), + commitId: reviewContext.headCommitId, + } + setPendingCommentsByNumber(current => ({ ...current, [selectedNumber]: [...(current[selectedNumber] ?? []), comment] })) + setInlineTarget(null) + setInlineBody('') + } + + const removePendingComment = (id: string) => { + if (!selectedNumber || actionLocked) return + setPendingCommentsByNumber(current => ({ ...current, [selectedNumber]: (current[selectedNumber] ?? []).filter(comment => comment.id !== id) })) + } + + const toggleViewedFile = async () => { + if (!selectedNumber || !selectedPath || actionLocked) return + const nextViewed = !selectedFileViewed + if (!reviewContext) { + setLocalViewedByNumber(current => ({ + ...current, + [selectedNumber]: nextViewed + ? [...new Set([...(current[selectedNumber] ?? []), selectedPath])] + : (current[selectedNumber] ?? []).filter(path => path !== selectedPath), + })) + return + } + const targetNumber = selectedNumber + const targetPath = selectedPath + setMutation({ kind: nextViewed ? 'Mark file viewed' : 'Mark file unviewed', number: targetNumber }) + setNotice(null) + try { + const result = await getCrewCodeClient().ghPrViewedFile(repoPath, targetNumber, { pullRequestId: reviewContext.pullRequestId, path: targetPath, viewed: nextViewed }) + if (!result.ok) throw new Error(result.error || 'GitHub did not update the viewed-file state') + await loadReviewContext(targetNumber, true) + setNotice({ kind: 'ok', text: `${targetPath} marked ${nextViewed ? 'viewed' : 'unviewed'} on GitHub.` }) + } catch (mutationError) { + setNotice({ kind: 'error', text: mutationError instanceof Error ? mutationError.message : String(mutationError) }) + } finally { + setMutation(null) + } + } + + const toggleReviewThread = async (threadId: string, resolved: boolean) => { + if (!selectedNumber || actionLocked) return + const targetNumber = selectedNumber + setMutation({ kind: resolved ? 'Resolve conversation' : 'Reopen conversation', number: targetNumber }) + setNotice(null) + try { + const result = await getCrewCodeClient().ghPrReviewThread(repoPath, targetNumber, threadId, resolved) + if (!result.ok) throw new Error(result.error || 'GitHub did not update the review conversation') + await loadReviewContext(targetNumber, true) + setNotice({ kind: 'ok', text: `Review conversation ${resolved ? 'resolved' : 'reopened'} on GitHub.` }) + } catch (mutationError) { + setNotice({ kind: 'error', text: mutationError instanceof Error ? mutationError.message : String(mutationError) }) + } finally { + setMutation(null) + } + } + + const submitReview = async () => { + if (!onReview || !detail || (reviewEvent !== 'approve' && !reviewBody.trim()) || (reviewEvent === 'approve' && isSelfAuthored)) return + const targetNumber = detail.number + await runMutation('Submit review', number => onReview(number, { + event: reviewEvent, + body: reviewBody.trim() || undefined, + commitId: pendingComments.length ? reviewContext?.headCommitId : undefined, + comments: pendingComments.length ? pendingComments : undefined, + }), () => { + setReviewBody('') + setPendingCommentsByNumber(current => ({ ...current, [targetNumber]: [] })) + return 'Review submitted to GitHub and PR evidence refreshed.' + }) + await loadReviewContext(targetNumber, true) + } + + const beginEditingDetails = () => { + if (!detail || actionLocked) return + setEditTitle(detail.title) + setEditBody(detail.body) + setEditingDetails(true) + } + + const saveDetails = async () => { + if (!onEdit || !detail || !editTitle.trim()) return + await runMutation('Update pull request', number => onEdit(number, { title: editTitle, body: editBody }), next => { + setEditingDetails(false) + return next.title === editTitle.trim() && next.body === editBody + ? `GitHub confirms #${next.number} details were updated.` + : `Update completed, but GitHub returned different pull request details.` + }) + } + + const changeMetadata = async (kind: GitHubPullRequestMetadataOptions['kind'], operation: GitHubPullRequestMetadataOptions['operation'], value: string) => { + if (!onMetadata || !value.trim()) return + await runMutation(`${operation === 'add' ? 'Add' : 'Remove'} ${kind}`, number => onMetadata(number, { kind, operation, value }), next => { + if (operation === 'add') setMetadataValue(current => ({ ...current, [kind]: '' })) + return `GitHub confirms #${next.number} ${kind} metadata was refreshed.` + }) + } + + const copyEvidence = async (label: string, value: string) => { + if (!value || actionLocked) return + const result = await getCrewCodeClient().clipboardWriteText(value) + setNotice(result.ok ? { kind: 'ok', text: `${label} copied.` } : { kind: 'error', text: result.error || `Could not copy ${label.toLowerCase()}.` }) + } + + const loadCheckLog = async (check: GitHubPullRequestCheckEvidence) => { + if (!selectedNumber || !checksContext || !check.runId || !check.jobId || actionLocked) return + const key = `${selectedNumber}:${check.id}` + setCheckLogLoadingKey(key) + try { + const result = await getCrewCodeClient().githubPrCheckLog(repoPath, selectedNumber, checksContext.headCommitId, check.runId, check.jobId) + setCheckLogByKey(current => ({ ...current, [key]: result })) + } catch (loadError) { + setCheckLogByKey(current => ({ ...current, [key]: { ok: false, log: '', truncated: false, error: loadError instanceof Error ? loadError.message : String(loadError) } })) + } finally { + setCheckLogLoadingKey('') + } + } + + const rerunCheck = async () => { + if (!selectedNumber || !checksContext || !checkRerunConfirm || !onRerunCheck || actionLocked || !checkRerunConfirm.check.runId) return + const targetNumber = selectedNumber + const target = checkRerunConfirm + const runId = target.check.runId! + setMutation({ kind: 'Request check rerun', number: targetNumber }) + setNotice(null) + try { + const result = await onRerunCheck(targetNumber, { + headCommitId: checksContext.headCommitId, + runId, + mode: target.mode, + jobId: target.mode === 'job' ? target.check.jobId ?? undefined : undefined, + }) + if (!result.ok) throw new Error(result.error || 'GitHub did not accept the check rerun') + await refreshSelectedEvidence(targetNumber) + await loadChecksContext(targetNumber, true) + setNotice({ kind: 'ok', text: `GitHub accepted the ${target.mode === 'job' ? target.check.name : target.mode === 'failed' ? 'failed-job' : 'workflow'} rerun request for #${targetNumber}.` }) + } catch (rerunError) { + setNotice({ kind: 'error', text: rerunError instanceof Error ? rerunError.message : String(rerunError) }) + } finally { + setCheckRerunConfirm(null) + setMutation(null) + } + } + + const performMergeAutomation = async (action: GitHubPullRequestMergeAutomationOptions['action']) => { + if (!selectedNumber || !detail || !onMergeAutomation || actionLocked) return + const targetNumber = selectedNumber + const headCommitId = checksContext?.headCommitId ?? detail.headCommitId + setMutation({ kind: action === 'disable' ? 'Disable auto-merge' : action === 'queue' ? 'Submit to merge queue' : 'Enable auto-merge', number: targetNumber }) + setNotice(null) + try { + const result = await onMergeAutomation(targetNumber, { action, headCommitId, method: action === 'enable' ? mergeMethod : undefined }) + if (!result.ok) throw new Error(result.error || 'GitHub did not accept the merge automation action') + const nextDetail = await refreshSelectedEvidence(targetNumber) + const nextChecks = await loadChecksContext(targetNumber, true) + const confirmed = action === 'disable' ? !!nextChecks && !nextChecks.autoMerge && !nextChecks.isInMergeQueue + : action === 'queue' ? nextDetail.state === 'MERGED' || (!!nextChecks && (nextChecks.isInMergeQueue || !!nextChecks.autoMerge)) + : nextDetail.state === 'MERGED' || !!nextChecks?.autoMerge + if (!confirmed) throw new Error(`GitHub accepted the command but did not confirm that ${action === 'disable' ? 'auto-merge is disabled' : action === 'queue' ? 'the PR is queued or waiting automatically' : 'auto-merge is enabled'}.`) + if (nextDetail.state === 'MERGED') setFilter('all') + setNotice({ kind: 'ok', text: nextDetail.state === 'MERGED' ? `GitHub confirms #${targetNumber} is merged.` : action === 'disable' ? `GitHub confirms auto-merge is disabled for #${targetNumber}.` : action === 'queue' ? `GitHub confirms #${targetNumber} is queued or will enter the queue when requirements pass.` : `GitHub confirms auto-merge is enabled for #${targetNumber}.` }) + } catch (automationError) { + setNotice({ kind: 'error', text: automationError instanceof Error ? automationError.message : String(automationError) }) + } finally { + setConfirmAction(null) + setMutation(null) + } + } + + const prepareMergeConfirmation = async (action: 'merge' | 'auto' | 'queue') => { + if (!selectedNumber || actionLocked) return + const targetNumber = selectedNumber + const targetHead = detail?.headCommitId + setMutation({ kind: 'Load merge requirements', number: targetNumber }) + try { + const evidence = await loadChecksContext(targetNumber, true) + if (!evidence) return + if (evidence.headCommitId !== targetHead) { + await refreshSelectedEvidence(targetNumber) + setNotice({ kind: 'error', text: `The head commit for #${targetNumber} changed while merge requirements loaded. Review the refreshed evidence before continuing.` }) + return + } + setConfirmAction(action === 'merge' && evidence.isMergeQueueEnabled ? 'queue' : action) + } finally { + setMutation(null) + } + } + + const prepareConflicts = async () => { + if (!selected || !detail || !onPrepareConflicts || actionLocked) return + const targetNumber = selected.number + const targetHead = detail.head + const targetBase = detail.base + setMutation({ kind: 'Prepare conflict resolution', number: targetNumber }) + setNotice(null) + try { + const result = await onPrepareConflicts(targetHead, targetBase) + setConflictPreparation(result) + if (!result.ok) throw new Error(result.error || 'Could not start local conflict resolution') + if (result.status === 'conflicts') setNotice({ kind: 'ok', text: `Local merge for #${targetNumber} started with ${result.conflicts.length} conflict${result.conflicts.length === 1 ? '' : 's'}. Return to Git Workspace to resolve them.` }) + else if (result.status === 'ready-to-continue') setNotice({ kind: 'ok', text: `All conflicts for #${targetNumber} are resolved. Return to Git Workspace and continue the merge.` }) + else setNotice({ kind: 'ok', text: `Merged ${targetBase} into ${targetHead} locally. Push ${targetHead} to update #${targetNumber}.` }) + } catch (mutationError) { + setNotice({ kind: 'error', text: mutationError instanceof Error ? mutationError.message : String(mutationError) }) + } finally { + setConfirmAction(null) + setMutation(null) + } + } + + return createPortal( +
+
+ +
Pull requests

{repoPath.split(/[\\/]/).filter(Boolean).pop() || 'Repository'}

Current branch {currentBranch}

+
+ + +
+
+ +
+ + +
+ {error &&
{error}
} + {!selected ?
Select a pull requestRepository PR details will appear here.
: <> +
+
{prState(selected)}#{selected.number}
+

{detail?.title ?? selected.title}

+
+ + opened this pull request on +
+

Merge {detail?.head ?? selected.head} into {detail?.base ?? selected.base}

+
+ + + {tab === 'overview' &&
+ {detailLoading && !detail &&
Loading pull request evidence…
} +
PR description
+ {sections.map((section, index) =>

{section.title}

{section.provided ? void getCrewCodeClient().openExternal(url)} /> :

Not provided in this pull request description.

}
)} +
+

Comments

{comments.length}
+ {comments.map(comment =>
+ +
+
@{comment.author || 'unknown'}{comment.kind === 'review' ? comment.state?.toLowerCase().replaceAll('_', ' ') || 'reviewed' : 'commented'}
+ {comment.body ? void getCrewCodeClient().openExternal(url)} /> :

No written review summary.

} +
+
)} + {!detailLoading && detail && comments.length === 0 &&

No comments yet.

} +
+
} + + {tab === 'timeline' &&
+ {detailLoading && !detail &&
Loading timeline…
} + {timeline.map(event =>
+ +
{event.author || 'unknown'}{event.title}
{event.body && (event.kind === 'commit' ? {event.body} : void getCrewCodeClient().openExternal(url)} />)}
+
)} + {!detailLoading && timeline.length === 0 &&
No timeline evidence returned.
} +
} + + {tab === 'changes' &&
+ +
+
+ {selectedPath || 'No file selected'} +
+ + + + +
+
+ {reviewContextLoading && !reviewContext &&
Loading review conversations and viewed files…
} + {reviewContextError &&
Local-only viewed state{reviewContextError}
} + {reviewContext && !reviewContextError &&
Select a line number to add an inline comment.Viewed state synced with GitHub
} + {reviewContext?.lastReviewedAt &&
+ Since your review on {exactDateLabel(reviewContext.lastReviewedAt)}{reviewContext.filesSinceLastReview.length} files · {reviewContext.commitsSinceLastReview.length} commits +
{reviewContext.commitsSinceLastReview.map(commit => {commit.oid.slice(0, 7)}{commit.message || 'Untitled commit'})}{reviewContext.commitsSinceLastReview.length === 0 && No newer commits.}
+
} + {patchError ?
{patchError}
: patchLoading ?
Loading GitHub patch…
: selectedPath ? { if (!actionLocked && reviewContext) { setInlineTarget({ path: selectedPath, ...target }); setInlineBody('') } }} /> :
Select a changed file.
} + {inlineTarget &&
+
Add review comment{inlineTarget.path}:{inlineTarget.line} {inlineTarget.side}
+