diff --git a/config/scripts/orchestration-skill-guidance.test.mjs b/config/scripts/orchestration-skill-guidance.test.mjs new file mode 100644 index 00000000000..53a94ff9c7b --- /dev/null +++ b/config/scripts/orchestration-skill-guidance.test.mjs @@ -0,0 +1,20 @@ +import { readFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const projectDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const skillPath = join(projectDir, 'skills', 'orchestration', 'SKILL.md') + +describe('orchestration skill guidance', () => { + it('treats long-running worker waits as liveness checkpoints, not failures', () => { + const skill = readFileSync(skillPath, 'utf8') + + expect(skill).toContain('Treat a `check --wait` timeout or `{count:0}` as a checkpoint') + expect(skill).toContain('Do not stop, close, kill, or restart a worker') + expect(skill).toContain('keep waiting instead of retrying the task') + expect(skill).not.toContain( + 'If `check --wait` times out with no `worker_done` or `escalation`, fall back to `terminal wait --for tui-idle`, then `terminal read`.' + ) + }) +}) diff --git a/docs/reference/agent-session-resume-cli-evidence.md b/docs/reference/agent-session-resume-cli-evidence.md new file mode 100644 index 00000000000..5c26f9ecbc6 --- /dev/null +++ b/docs/reference/agent-session-resume-cli-evidence.md @@ -0,0 +1,341 @@ +# Agent Session Resume Evidence for Sleep/Wake + +Date: June 5, 2026 + +Scope: issue [stablyai/orca#1796](https://github.com/stablyai/orca/issues/1796), current worktree code, installed agent CLIs on this machine, cloned open-source CLI repos under `/tmp/orca-agent-resume-research`, and official provider docs where available. + +## Executive conclusion + +Orca can implement one-click agent resume on wake, but it cannot use terminal PTY IDs, pane keys, tab IDs, or worktree IDs as the agent session ID. Those are Orca terminal identifiers. The required value is the provider's conversation/session/thread ID. + +For several first-tier providers, the exact provider ID is available in hook payloads today, but Orca drops it during normalization: + +- Claude: hook payloads include `session_id`; resume command is `claude --resume `. +- Codex: hook runtime sends `session_id`; resume command is `codex resume `. +- Gemini: hook payloads include `session_id`; resume command is `gemini --resume `. +- Antigravity: hook payloads include `conversationId`; resume command is `agy --conversation `. +- OpenCode: Orca's managed plugin already reads OpenCode `sessionID`; resume command is `opencode --session `. +- Droid: hook payloads include `session_id`; resume command is `droid --resume `. +- Grok: hook payloads include `sessionId`/`session_id`; resume command is `grok --resume `. + +The current blocker is not primarily "how do we resume?" The exact resume action is to spawn a new agent process on wake in the same workspace/pane context using the provider-specific resume command. The blocker is that Orca's current hook event and status types have no durable provider-session field, so the ID is discarded before sleep. + +## Current Orca behavior + +Issue #1796 is accurate about current behavior. The issue says sleep loses agent type, session ID, last prompt, and resume command because `dropAgentStatusByWorktree` wipes rows before wake can offer resume. + +Verified current code path: + +- Sleep calls terminal shutdown with identifiers preserved, not provider conversation metadata. See `src/renderer/src/components/sidebar/sleep-worktree-flow.ts`. +- `shutdownWorktreeTerminals(worktreeId, { keepIdentifiers: true })` preserves terminal IDs/layout wake hints, then calls `dropAgentStatusByWorktree(worktreeId)`. See `src/renderer/src/store/slices/terminals.ts`. +- `agentStatusByPaneKey` is explicitly documented as "Real-time only - lives in renderer memory, not persisted to disk." See `src/renderer/src/store/slices/agent-status.ts`. +- `WorkspaceSessionState` persists terminal/browser/editor/SSH state, including terminal layouts and remote relay PTY IDs, but no sleeping-agent records. See `src/shared/types.ts`, `src/renderer/src/lib/workspace-session.ts`, and `src/shared/workspace-session-schema.ts`. +- `AgentStatusEntry` has `state`, `prompt`, timestamps, `agentType`, pane/tab/worktree attribution, tool previews, assistant preview, and orchestration context. It has no provider session ID or resume command field. See `src/shared/agent-status-types.ts`. + +The existing sleep persistence is terminal-resume metadata. The feature needs provider-conversation-resume metadata. + +## Hook pipeline gap + +The current local and SSH hook wire shapes drop provider IDs: + +- `AgentHookEventPayload` in `src/shared/agent-hook-listener.ts` includes pane/tab/worktree, prompt cache fields, hook event name, Claude tool/subagent IDs, and normalized `payload`. It has no provider session ID field. +- `AgentHookRelayEnvelope` in `src/shared/agent-hook-relay.ts` mirrors that normalized shape for SSH. Its comment states the relay normalizes before sending the envelope. Therefore remote/SSH events lose provider IDs at the relay boundary too unless the new field is added to the shared envelope. +- `ParsedAgentStatusPayload` in `src/shared/agent-status-types.ts` includes only status fields: `state`, `prompt`, `agentType`, `toolName`, `toolInput`, `lastAssistantMessage`, `interrupted`. + +Implementation implication: provider-session metadata must be extracted before normalization completes and must be carried through both the local HTTP path and the SSH relay path. + +## Installed CLI versions checked + +These exact binaries were available on this machine: + +| Agent | Version checked | +| --- | --- | +| Claude Code | `2.1.165` | +| Codex | `codex-cli 0.137.0` | +| Gemini | `0.44.0` | +| OpenCode | `1.15.13` | +| Cursor Agent | `2025.09.18-7ae6800` | +| Antigravity | `1.0.5` | +| Droid | `0.122.0` | +| Grok | `grok 0.2.22 (967574cb117) [stable]` | +| Pi | `0.72.1` | +| Amp, Copilot, Command Code, Hermes | Not installed locally | + +## Provider matrix + +Legend: + +- `Exact supported`: exact resume command and exact ID source are both verified. +- `Resume verified, ID capture missing`: CLI can resume, but current Orca hook path does not expose a durable exact ID. +- `ID seen, resume not verified`: Orca/source exposes an ID, but no exact resume CLI command was verified. +- `Out of current hook scope`: Orca has no current hook source for this TUI agent. + +| Agent | Resume command evidence | Exact ID source evidence | Current Orca support status | +| --- | --- | --- | --- | +| Claude | `claude --resume ` verified by local `claude --help` and Anthropic CLI docs. | Claude hook docs show hook input includes `session_id` and `transcript_path`; current Orca normalizer drops `session_id`. | Exact supported after adding provider-session metadata extraction. | +| Codex | `codex resume ` verified by local help and cloned Codex source `codex-rs/utils/cli/src/resume_command.rs`. | Cloned Codex source `codex-rs/core/src/hook_runtime.rs` sends `session_id` on `SessionStart`, `PreToolUse`, `PermissionRequest`, and `PostToolUse` hook requests. | Exact supported after extraction. | +| Gemini | `gemini --resume ` verified by local help and Gemini docs. | Gemini hooks reference says all hooks receive `session_id` and `transcript_path`. | Exact supported after extraction. | +| Antigravity | `agy --conversation ` verified by local help and Antigravity docs. | Antigravity hook docs list `conversationId` as the active conversation UUID. | Exact supported after extraction. | +| OpenCode | `opencode --session ` verified by local help, official docs, and cloned OpenCode TUI source. | Orca's OpenCode plugin already reads `event.properties?.sessionID` before posting. | Exact supported after preserving `sessionID`. | +| Droid | `droid --resume ` verified by local help and Factory CLI docs. | Factory hooks reference includes `session_id`; Orca installs Droid hooks and already subscribes to `SessionStart`. | Exact supported after extraction. | +| Grok | `grok --resume ` verified by local help. | Orca already reads `sessionId`/`session_id` in `getGrokChatHistoryPath`, but only to locate chat history. | Exact supported after preserving that same ID. | +| Cursor | `cursor-agent --resume ` and `cursor-agent resume` verified by local help and Cursor docs. | Current Orca Cursor hook install deliberately omits `sessionStart`/`sessionEnd`; the subscribed event set does not currently prove a chat ID source. `cursor-agent create-chat` was locally verified to return a UUID, but preallocation for Orca launches still needs an end-to-end test before relying on it. | Resume verified, ID capture missing. | +| Pi | `pi --session `, `pi --resume`, and `pi --continue` verified by local help and Pi docs. | Orca's bundled Pi/OMP extension posts status events but does not include session file path, session UUID, or session name in the status payload. | Resume verified, ID capture missing. | +| OMP | Same launch family as Pi in Orca. | Orca reuses Pi extension API shape and does not include exact session pointer. | Resume verified by inheritance only from Pi-like CLI contract; exact OMP ID capture missing. | +| Amp | Orca's managed plugin posts `threadId: event.thread.id`. | Amp is not installed locally, and no exact Amp CLI resume command was verified in this investigation. | ID seen, resume not verified. | +| Hermes | Orca's managed Hermes plugin selects `session_id` for session/LLM/tool events. | Hermes is not installed locally, and no exact Hermes CLI resume command was verified in this investigation. | ID seen, resume not verified. | +| Copilot | Orca installs broad Copilot hooks. | Local Copilot CLI not installed; no exact resume command or session ID contract was verified. | Not supported for exact resume yet. | +| Command Code | Orca installs hooks for `PreToolUse`, `PostToolUse`, and `Stop`. | Local Command Code CLI not installed; no exact resume command or session ID contract was verified. | Not supported for exact resume yet. | +| openclaude, autohand, aider, goose, kilo, kiro, crush, aug, cline, codebuff, continue, kimi, mistral-vibe, qwen-code, rovo, openclaw | Not investigated to exact-resume standard. | Not in `HOOK_SOURCE_BY_PATHNAME` today. | Out of current hook scope. | + +## Exact resume behavior by provider + +### Claude + +Verified: + +- Local `claude --help` shows `--continue`, `--resume [value]`, `--session-id `, `--fork-session`, and `--no-session-persistence`. +- Anthropic CLI docs show `claude -r "" "query"` and document `--resume` as resuming a specific session by ID. +- Claude hooks docs show hook input includes `session_id` and `transcript_path`. + +Resume command: + +```sh +claude --resume +``` + +If Orca stores `lastPromptSummary` separately, it should not pass that summary as a new user prompt by default. The one-click resume action should reopen the prior conversation. Auto-submitting a summary would create a new turn. + +### Codex + +Verified: + +- Local `codex resume --help` accepts `[SESSION_ID] [PROMPT]`, where the session target is a UUID or session name. +- Cloned Codex source `codex-rs/utils/cli/src/resume_command.rs` formats `codex resume `. +- Cloned Codex source `codex-rs/core/src/hook_runtime.rs` sends `session_id` on hook requests. +- Cloned Codex source has tests asserting resumed root sessions use the thread ID as session ID. + +Resume command: + +```sh +codex resume +``` + +Avoid `codex resume --last` for this feature when an exact ID is available. `--last` can resume the wrong conversation if another Codex session ran after the slept workspace. + +### Gemini + +Verified: + +- Local `gemini --help` shows `--resume`, `--session-id`, `--session-file`, and `--list-sessions`. +- Gemini official hooks reference states all hook input includes `session_id` and `transcript_path`. +- Gemini official configuration docs describe `--resume [session_id]` and state that a UUID can be supplied. + +Resume command: + +```sh +gemini --resume +``` + +### Antigravity + +Verified: + +- Local `agy --help` shows `--continue` and `--conversation`. +- Antigravity conversation docs show `agy --continue` for most recent and `agy --conversation ` for a specific session. +- Antigravity hooks docs define common hook input `conversationId`. + +Resume command: + +```sh +agy --conversation +``` + +### OpenCode + +Verified: + +- Local `opencode --help` shows `--continue` and `--session`. +- Official OpenCode docs list `--session` as the session ID to continue. +- Cloned OpenCode source `packages/opencode/src/cli/cmd/tui/thread.ts` defines `--session` and passes it into TUI args as `sessionID`. +- Cloned OpenCode source `validate-session.ts` validates the supplied session ID through the OpenCode client. +- Orca's managed OpenCode plugin reads `event.properties?.sessionID`. + +Resume command: + +```sh +opencode --session +``` + +### Droid + +Verified: + +- Local `droid --help` shows `--resume [sessionId]`, `--fork `, and `--cwd`. +- Factory CLI docs list resume behavior and `droid exec -s ` for exec mode. +- Factory hooks reference says `SessionStart` has a `resume` matcher and hook input includes `session_id`. + +Resume command: + +```sh +droid --resume +``` + +### Grok + +Verified: + +- Local `grok --help` shows `--resume []`, `--continue`, `--cwd`, and `--restore-code`. +- Orca already reads `sessionId`/`session_id` in `getGrokChatHistoryPath`. + +Resume command: + +```sh +grok --resume +``` + +### Cursor + +Verified: + +- Local `cursor-agent --help` shows `--resume [chatId]`. +- Cursor docs show listing prior chats with `cursor-agent ls`, resuming latest with `cursor-agent resume`, and resuming a specific conversation with `cursor-agent --resume="chat-id-here"`. +- Local help shows `cursor-agent create-chat` creates an empty chat and returns its ID. +- Running `cursor-agent create-chat` locally returned UUID `1fff2e62-9dd0-4cf3-9d8c-3569fea0aff7`, proving the command can preallocate a chat ID without an initial prompt. + +Not verified: + +- Current Orca Cursor hook install subscribes to `beforeSubmitPrompt`, `stop`, tool events, approval events, and `afterAgentResponse`, but intentionally does not subscribe to `sessionStart`/`sessionEnd`. +- I did not verify a current subscribed Cursor hook payload containing `chatId`. +- I did not run `cursor-agent --resume ` because that would start a real agent turn and may make workspace changes. + +Conclusion: exact Cursor resume is CLI-supported, but Orca currently needs either a verified hook payload chat ID or a launch preallocation strategy that is tested end to end. + +### Pi and OMP + +Verified: + +- Local `pi --help` shows `--continue`, `--resume`, `--session `, `--fork `, `--session-dir`, and `--no-session`. +- Pi docs state `/resume` opens a picker and `pi -r` opens the same picker at startup. +- Orca's Pi/OMP extension posts status events from Pi's extension API, but the status events do not include session path, UUID, or session name. + +Resume command: + +```sh +pi --session +``` + +Conclusion: exact Pi resume is CLI-supported, but current Orca capture is missing the exact pointer. + +## What Orca should store + +Store a durable record per sleeping agent pane, not per worktree only: + +```ts +type SleepingAgentSessionRecord = { + id: string + worktreeId: string + tabId?: string + paneKey: string + connectionId: string | null + agentType: TuiAgent + providerSession: { + kind: 'session_id' | 'conversation_id' | 'thread_id' | 'chat_id' | 'path' + value: string + } + resumeCommand: { + argv: string[] + cwd?: string + } + lastPrompt: string + lastAssistantMessage?: string + capturedAt: number + retentionUntil?: number | null +} +``` + +Important constraints: + +- `resumeCommand.argv` should be structured argv, not a shell string. This avoids cross-platform quoting bugs. +- Do not persist a terminal PTY ID as `providerSession.value`. +- For SSH, `cwd` and the command execute on the remote host. The provider session ID also belongs to the remote CLI's session store. +- For local workspaces, execute locally in the worktree path. +- For auto-resume, launch exactly one process per sleeping agent record and guard against double-click/duplicate wake races. + +## Provider extraction map + +This is the extraction map that is supported by the evidence above: + +| Hook source | Provider session field(s) to extract | Resume argv | +| --- | --- | --- | +| `claude` | `session_id` | `['claude', '--resume', id]` | +| `codex` | `session_id` | `['codex', 'resume', id]` | +| `gemini` | `session_id` | `['gemini', '--resume', id]` | +| `antigravity` | `conversationId` | `['agy', '--conversation', id]` | +| `opencode` | `sessionID` | `['opencode', '--session', id]` | +| `droid` | `session_id` | `['droid', '--resume', id]` | +| `grok` | `sessionId`, fallback `session_id` | `['grok', '--resume', id]` | +| `amp` | `threadId` | not enabled until Amp resume command is verified | +| `hermes` | `session_id` | not enabled until Hermes resume command is verified | + +## Required implementation shape + +1. Add a provider-session metadata type to shared hook types. +2. Extract provider session evidence in `parseAgentHookEvent` from raw `hookPayloadRecord` before normalizing to `ParsedAgentStatusPayload`. +3. Carry it in `AgentHookEventPayload`. +4. Carry it in `AgentHookRelayEnvelope` so SSH does not lose it. +5. Store latest provider-session evidence by pane key alongside live agent status. +6. On sleep, snapshot the live agent status plus latest provider-session evidence before `dropAgentStatusByWorktree`. +7. Persist sleeping-agent records in `WorkspaceSessionState` and schema. +8. On wake, render sleeping resumable rows separately from active rows and launch the provider-specific structured argv when the user resumes. +9. Clear records on explicit worktree removal, not on sleep. +10. If the provider session no longer exists, let the provider CLI fail and surface that failure without deleting the record until the user dismisses it. + +## Open questions that remain genuinely unverified + +- Cursor: whether any currently subscribed Cursor hook payload carries `chatId`. If not, the only plausible exact strategy is preallocating a chat with `cursor-agent create-chat` at launch and starting/resuming that known chat. That needs an end-to-end test before productizing. +- Pi/OMP: whether the extension API can expose the current session file/path/UUID. Current Orca extension does not forward it. +- Amp: whether the Amp CLI has an exact thread resume command matching `threadId`. +- Hermes: whether Hermes has an exact session resume CLI command matching `session_id`. +- Copilot and Command Code: exact resume command and exact hook session field were not verified because the CLIs were not installed locally and no sufficient primary docs/source were found during this pass. +- Non-hook TUI agents: no exact support should be promised until Orca adds a hook/metadata capture path for each. + +## Sources + +Local Orca source: + +- `src/shared/agent-hook-listener.ts` +- `src/shared/agent-hook-relay.ts` +- `src/shared/agent-status-types.ts` +- `src/shared/types.ts` +- `src/shared/tui-agent-config.ts` +- `src/renderer/src/store/slices/terminals.ts` +- `src/renderer/src/store/slices/agent-status.ts` +- `src/renderer/src/lib/workspace-session.ts` +- `src/shared/workspace-session-schema.ts` +- `src/main/opencode/hook-service.ts` +- `src/main/amp/hook-service.ts` +- `src/main/hermes/hook-service.ts` +- `src/main/pi/agent-status-extension-source.ts` +- `src/main/cursor/hook-service.ts` + +Cloned source: + +- `/tmp/orca-agent-resume-research/codex` +- `/tmp/orca-agent-resume-research/gemini-cli` +- `/tmp/orca-agent-resume-research/opencode` + +Official docs: + +- GitHub issue: https://github.com/stablyai/orca/issues/1796 +- Claude CLI reference: https://docs.anthropic.com/en/docs/claude-code/cli-usage +- Claude hooks reference: https://code.claude.com/docs/en/hooks +- Gemini hooks reference: https://geminicli.com/docs/hooks/reference/ +- Cursor CLI parameters: https://docs.cursor.com/en/cli/reference/parameters +- Cursor CLI usage: https://docs.cursor.com/en/cli/using +- OpenCode CLI docs: https://opencode.ai/docs/cli/ +- Antigravity conversation docs: https://antigravity.google/docs/cli-conversations +- Antigravity hooks docs: https://www.antigravity.google/docs/hooks +- Factory Droid hooks reference: https://docs.factory.ai/cli/configuration/hooks-reference +- Factory Droid CLI reference: https://docs.factory.ai/cli/configuration/cli-reference +- Pi docs: https://pi.dev/docs/latest/tree diff --git a/docs/reference/opencode-commit-message-stdin.md b/docs/reference/opencode-commit-message-stdin.md new file mode 100644 index 00000000000..c7141fb4469 --- /dev/null +++ b/docs/reference/opencode-commit-message-stdin.md @@ -0,0 +1,105 @@ +# OpenCode Commit Message Stdin Delivery + +## Problem + +OpenCode's preset Source Control AI path passes the full generated prompt as the final `opencode run` argv positional. Commit-message prompts include branch, staged file summary, user instructions, and the staged patch. The patch is capped by `STAGED_DIFF_BYTE_BUDGET = 200_000`, but the implementation uses JavaScript string length, not encoded byte length, so argv can still be much larger than command-line limits on Windows and some POSIX/SSH wrapper paths before OpenCode starts. + +Verified current code: + +- `src/shared/commit-message-agent-spec.ts:24-32` defines `promptDelivery` as the argv/stdin contract for large diffs. +- `src/shared/commit-message-agent-spec.ts:322-337` sets OpenCode to `promptDelivery: 'argv'` and appends `prompt`. +- `src/shared/commit-message-plan.ts:108-123` already routes `promptDelivery === 'stdin'` into `stdinPayload`. +- `src/main/text-generation/commit-message-text-generation.ts:470-601` pipes local `stdinPayload` to child stdin. +- `src/main/providers/ssh-git-provider.ts:122-140` forwards `stdinPayload` as relay `stdin`; `src/relay/agent-exec-handler.ts:160-303` writes it to the remote child. +- `src/shared/commit-message-plan.test.ts:36` currently locks in the broken OpenCode argv behavior. + +## Feasibility Gate + +`opencode --version` on the local machine reports `1.15.13`. `opencode run --help` confirms the required args exist: `--model`, `--agent`, `--format`, and `--variant`, with optional `[message..]` positionals. It does not document stdin prompt input. + +Shipping gate: before changing the preset, run a controlled smoke test using the exact no-message shape, and verify stdout clearly reflects the stdin prompt rather than an empty task: + +```sh +printf '%s\n' 'Write: test commit' | opencode run --model --agent build --format default +``` + +Also smoke `--variant ` if the selected model exposes thinking levels. If credentials, model access, or an unsupported OpenCode version block validation, do not treat `--help` alone as proof. If stdin fails, do not add an argv fallback; stop and choose a different no-argv transport or version-gated behavior. + +## Root Cause + +The planner and both local/SSH execution paths already know how to carry stdin payloads. The mismatch is the OpenCode preset args. + +OpenCode is not the only argv-delivered path in the registry: Cursor and custom commands that explicitly use `{prompt}` still put prompts in argv. They are out of scope here and should not be described as fixed by this change. + +## Non-Goals + +- Do not change prompt wording, diff truncation, output cleanup, model discovery, or model selection. +- Do not change interactive OpenCode terminal launch behavior. +- Do not change GitHub/GitLab/provider review flows. +- Do not change Cursor argv delivery or custom command `{prompt}` semantics. +- Do not add a second OpenCode execution path. + +## Design + +1. Change the OpenCode preset spec to `promptDelivery: 'stdin'`. +2. Add a short why-comment near that setting: OpenCode prompt input can contain large staged diffs and must stay out of argv. +3. Remove `prompt` from OpenCode `buildArgs`. The preset args must be exactly `run --model --agent build --format default`, plus `--variant ` when present. +4. Keep `modelSource`, `modelDiscovery`, default model, and dynamic-model fallback behavior unchanged. +5. Keep preset `agentCommandOverride` behavior unchanged: an override such as `npx opencode` prefixes the same OpenCode args, while the prompt remains `stdinPayload`. +6. Accept the shared-spec blast radius: OpenCode prompts for commit-message, PR-field, and branch-name generation all move to stdin because they all call `planCommitMessageGeneration(...)`. If the requirement is commit-message-only, this design is wrong and needs operation-aware delivery instead. + +## Data Flow + +- Source Control AI receives a staged context and builds the prompt. The context is not atomic: branch, staged summary, and staged patch are read by separate git commands before generation. +- `planCommitMessageGeneration(...)` validates the agent, model, and thinking level against the existing dynamic/static model rules. +- Because OpenCode becomes stdin-delivered, the planner passes an empty prompt into `buildArgs` and stores the real prompt in `plan.stdinPayload`. +- Local generation writes `stdinPayload` to the child process stdin. +- SSH generation serializes the same plan to the relay as `{ stdin: plan.stdinPayload }`, and the relay writes it to the remote child process stdin after spawn. + +This fix removes the prompt from process argv only. It does not reduce prompt memory, model latency, token use, or diff truncation pressure. Remote execution still sends the prompt in one JSON-RPC frame; the relay frame cap is 16 MB, and today's prompt budget is under that, but future budget increases must account for it. + +## Edge Cases + +- Large staged diff: the prompt must be absent from `args`, including no empty-string placeholder. +- Thinking level selected: preserve `--variant `. +- No thinking level selected: omit `--variant`. +- Dynamic OpenCode model not present in the seed catalog: preserve the existing dynamic fallback and still pass the selected model id. +- Agent command override: `npx opencode` becomes `binary: 'npx'`, `args: ['opencode', 'run', ...]`, with prompt in `stdinPayload`. +- Windows batch shims: removing the prompt from argv also removes prompt metacharacters from the batch command line; do not route the prompt through `{prompt}` for this preset. +- SSH: the prompt is JSON-serialized as `stdin`, not shell-quoted, and is written by the relay after spawn. +- SSH payload size: this is safe for the current prompt budget, not an unbounded transport. Anything near 16 MB needs streaming or another transport. +- OpenCode ignores stdin: the smoke test must catch this before shipping because `--help` does not document stdin. +- Output cleanup remains unchanged; OpenCode stderr/status chatter should be handled by existing failure and cleanup logic. + +## Consistency And Concurrency + +- No cache invalidation is introduced. Each generation plans from the request's current settings and the staged context supplied to that request. +- The staged context is not a true snapshot. If the index changes between the summary and patch reads, or after the context is read, the generated message can describe a mixed or stale view. That is existing behavior and not fixed here. +- Multiple windows can request generation for the same worktree. Local execution does not serialize same-`cwd` requests; the cancel map is keyed by `operation:local:` and the newest request overwrites the reachable cancel token. Do not claim this change fixes local multi-window races. +- SSH execution queues matching `operation + cwd` lanes inside a `SshGitProvider` instance. The relay itself tracks only one active child per lane and cancels a prior active child if another request for the same lane reaches it, which matters if separate provider instances/windows bypass the same queue. +- Settings or command overrides changed while a generation is in flight affect the next request only; the current child keeps its immutable plan. + +## Test Plan + +- `src/shared/commit-message-agent-spec.test.ts`: add OpenCode coverage asserting `promptDelivery === 'stdin'`, no prompt or empty placeholder in `buildArgs`, variant preservation, and no variant when absent. +- `src/shared/commit-message-plan.test.ts`: update OpenCode expectations so `stdinPayload` receives the prompt and argv excludes it; include an OpenCode command-override case. +- Because `planCommitMessageGeneration(...)` is reused by commit-message, PR-field, and branch-name generation, the plan tests are the main regression coverage for the shared blast radius. +- Keep existing Codex stdin, custom-command stdin, local child stdin, SSH provider, and relay stdin tests passing. +- Run targeted Vitest for the touched shared tests, plus `pnpm typecheck` and `pnpm lint` for the implementation branch. +- Run the OpenCode stdin smoke test from the feasibility gate. If it cannot run, do not mark implementation risk as resolved. + +## Lightweight Eng Review + +- Scope: shared preset-agent plumbing and shared tests, with an explicit shared-operation blast radius for OpenCode commit-message, PR-field, and branch-name prompts. No renderer redesign, settings migration, hosted review flow, or provider-specific review behavior. +- Architecture/data flow: use the existing `promptDelivery` contract; local and SSH execution already honor `stdinPayload`. +- Failure modes covered: prompt re-enters argv, `--variant` is dropped, unknown dynamic model ids regress, command overrides change stdin behavior, remote JSON-RPC frame limits are ignored, or OpenCode accepts the flags but ignores stdin. +- Performance/blast radius: no renderer, watcher, IPC subscription, or startup cost. Local argv shrinks; remote payload size is unchanged because the prompt still crosses JSON-RPC as stdin data in one frame. +- UI quality bar: no visual change; skip visual-design review. +- Screenshots: none required for this headless invocation change. If PR evidence is needed, prefer unit-test output and the controlled OpenCode stdin smoke result. +- Residual risk: OpenCode stdin prompt input is not documented by `opencode run --help`; remote hosts may have different OpenCode versions; existing local multi-window cancellation races and staged-context non-atomicity remain. + +## Rollout + +1. Update OpenCode spec delivery and args. +2. Update shared spec and planner tests. +3. Run targeted tests, typecheck, lint, and the OpenCode smoke test before shipping. diff --git a/docs/reference/project-ordering-mode.md b/docs/reference/project-ordering-mode.md new file mode 100644 index 00000000000..4cdc159551b --- /dev/null +++ b/docs/reference/project-ordering-mode.md @@ -0,0 +1,108 @@ +# Project Ordering Mode + +## Problem + +The sidebar can group workspaces by project, but project header order is currently coupled to workspace sorting instead of having its own user choice. + +- `src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx:59` defines one `Sort by` control for workspaces; it has no separate project-order setting. +- `src/renderer/src/store/slices/ui.ts:723` stores only `sortBy`, and `src/shared/types.ts:2543` persists the same workspace sort field. +- `src/renderer/src/components/sidebar/worktree-list-groups.ts:36` derives `ProjectGroupOrdering` from `sortBy`, so project headers follow the highest-ranked visible workspace in Recent/Smart and fall back to manual order otherwise. +- `src/renderer/src/components/sidebar/WorktreeList.tsx:3765` computes the ordered workspace ids, and `src/renderer/src/components/sidebar/WorktreeList.tsx:4060` feeds the derived project ordering into `buildRows(...)`. +- `src/renderer/src/components/sidebar/WorktreeList.tsx:826` only enables project-header drag when project grouping is manual and there are no Project Groups. +- `src/renderer/src/components/sidebar/project-header-drag.ts:1` already implements pointer-based project header dragging for virtualized rows, and `src/renderer/src/store/slices/repos.ts:699`, `src/main/ipc/repos.ts:1029`, and `src/main/persistence.ts:2497` already persist whole-repo manual reorders. + +Result: users cannot choose Manual vs Recent ordering specifically for projects, the default is not Manual, and changing workspace sort can unexpectedly reorder projects. + +## Goal + +Add a project-only ordering preference with two modes: + +- `Manual`, the default. Project headers render in the persisted manual project order, and users can drag project headers to reorder them. +- `Recent`. Project headers render by the most recent visible workspace activity in each project. + +This must affect only project header order in `groupBy: 'repo'`. Worktree/workspace rows inside each project must continue to use the existing workspace `sortBy`, filtering, pinning, lineage, status, and manual-order behavior. + +## Non-goals + +- Do not change `sortBy` semantics or the workspace `Sort by` control. +- Do not change worktree `manualOrder`, `sortOrder`, `lastActivityAt`, or `buildWorktreeComparator(...)`. +- Do not reorder worktrees inside a project when the project ordering mode changes. +- Do not change Project Group header order; groups continue to use `ProjectGroup.tabOrder`. +- Do not add GitHub-specific behavior. Repo/project ordering must remain provider-neutral and work for GitLab, folder projects, local repos, SSH repos, and runtime environments. +- Do not add telemetry in this pass unless a later product requirement asks for it. + +## Design + +1. Add a persisted project-order preference. + - Introduce `ProjectOrderBy = 'manual' | 'recent'` in `src/shared/types.ts` or a concrete sidebar ordering module. + - Add `projectOrderBy: ProjectOrderBy` to `PersistedUIState`, defaulting to `'manual'` in `getDefaultUIState()` (`src/shared/constants.ts:387`). + - Add `projectOrderBy` and `setProjectOrderBy` to the UI slice next to `sortBy` (`src/renderer/src/store/slices/ui.ts:723`). Like `setSortBy`, this setter is a bare `set({...})` — it does not persist on its own. + - Persist through the debounced UI writer, not the setter. `sortBy`/`groupBy` reach disk only via the explicit field list in `App.tsx`'s `window.api.ui.set({...})` effect (`src/renderer/src/App.tsx:1019`) plus its dependency array (`src/renderer/src/App.tsx:1042`). Add `projectOrderBy` to both, or the value lives only in memory and silently resets to `'manual'` every restart. + - Normalize persisted values in `src/main/persistence.ts` the same way `sortBy` is normalized at `src/main/persistence.ts:323`, and wire the normalizer into both `getUI()` (`src/main/persistence.ts:3116`) and `updateUI()` (`src/main/persistence.ts:3147`); invalid or missing values resolve to `'manual'`. + - Hydration in `src/renderer/src/store/slices/ui.ts:1862` should read the normalized value without migrating existing `sortBy`. + +2. Expose the choice in the sidebar options menu. + - In `SidebarWorkspaceOptionsMenu`, keep the existing `Sort by` submenu as workspace sorting. + - Add a second radio submenu labeled `Project order` with `Manual` and `Recent`. + - Show it only when `groupBy === 'repo'`, because project ordering has no visible effect in `none`, `workspace-status`, or `pr-status`. + - Use existing `DropdownMenuRadioGroup` and sidebar/menu styling from `docs/STYLEGUIDE.md`; no new tokens or custom colors. + - Copy should make the scope clear: Manual description `Drag projects to arrange them`; Recent description `Most recent workspace activity`. + +3. Decouple project header ordering from workspace sorting. + - Replace `getProjectGroupOrdering(groupBy, sortBy)` with a project-order resolver that depends on `groupBy` and `projectOrderBy`, not workspace `sortBy`. + - Update `buildRows(...)` so `groupBy !== 'repo'` ignores project order, `projectOrderBy === 'manual'` uses persisted manual ranks, and `projectOrderBy === 'recent'` sorts project header entries by a per-repo recent timestamp. + - Recent must be timestamp-based, not first-encounter. Today's `'visible-worktree-order'` path (`src/renderer/src/components/sidebar/worktree-list-groups.ts:548`) works only because the caller pre-sorts the worktree array by recency when `sortBy` is `recent`/`smart`. Decoupling from `sortBy` removes that guarantee — the incoming array may be name- or manual-sorted — so the Recent resolver must explicitly compute `max(lastActivityAt)` per repo rather than relying on encounter order. + - Recent project rank should be the maximum `lastActivityAt` among that repo's visible, unpinned worktrees passed into `buildRows(...)`. Empty placeholder projects and imported-worktree-card-only projects fall back to `Repo.addedAt`, then manual rank, then label. + - Do not reorder `group.items`; only reorder the project header entries before `appendOrderedGroups(...)`. This preserves the existing `orderMainWorktreeFirst(...)` and workspace row ordering at `src/renderer/src/components/sidebar/worktree-list-groups.ts:632`. + - With Project Groups, keep group headers ordered by `tabOrder`; apply Manual/Recent only to project entries within each group or the ungrouped bucket. + +4. Make Manual drag/drop project-scoped (no-Project-Groups case only in v1). + - Enable project header dragging when `groupBy === 'repo' && projectOrderBy === 'manual'`, regardless of workspace `sortBy`. Today `canReorderRepoHeaders` is additionally gated on `!hasProjectGroups` (`src/renderer/src/components/sidebar/WorktreeList.tsx:827`); the only change here is dropping the `sortBy`-derived `projectGroupOrdering` input in favor of `projectOrderBy === 'manual'`. The `!hasProjectGroups` gate stays. + - Keep using pointer events from `project-header-drag.ts` unchanged; the virtualized-row reasoning at `src/renderer/src/components/sidebar/project-header-drag.ts:3` still applies. For the no-Project-Groups case it already does exactly what we need: a flat whole-list permutation committed through `reorderRepos(...)`, preserving current behavior and IPC rejection semantics. + - Continue to suppress click-to-collapse only after a promoted drag, matching `src/renderer/src/components/sidebar/project-header-drag.ts:142`. + - Defer grouped (within-Project-Group) drag to a follow-up. `useRepoHeaderDrag` is built for a single flat `orderedRepoIds` permutation: `endDrag` (`src/renderer/src/components/sidebar/project-header-drag.ts:130`) computes `fromIndex`/`insertAt` over one array and calls `onCommit` → `reorderRepos`, and `onHandlePointerDown` (`src/renderer/src/components/sidebar/project-header-drag.ts:300`) snapshots every on-screen `[data-repo-header-id]` with no notion of group boundaries. Supporting in-group reorder needs new hook logic — bucket-aware drop targets, rejecting drops outside the source sibling bucket, computing a midpoint between neighbor `projectGroupOrder` values, and a second commit mode that calls `moveProjectToGroup(projectId, sameGroupId, order)` (`src/renderer/src/store/slices/repos.ts:138`) instead of `reorderRepos`. This is its own change and is out of scope for v1. + - In v1, Project-Groups users keep the project actions menu as the move surface (it already calls `moveProjectToGroup`). Manual project order still applies to their headers via the row-builder sort (step 3); only drag-to-reorder is unavailable inside groups. + - Do not turn a project reorder drag into a cross-group move in any version. The existing project actions menu remains the cross-group move surface. + +5. Keep persistence and runtime parity intact. + - Local no-group manual reorder continues through `repos:reorder` (`src/main/ipc/repos.ts:1029`) and `Store.reorderRepos(...)` (`src/main/persistence.ts:2497`). + - Remote no-group manual reorder continues through `repo.reorder` (`src/renderer/src/store/slices/repos.ts:723`). + - Grouped manual reorder via drag is deferred (see step 4); the follow-up would route through `projectGroup.moveProject` / `moveProjectToGroup(...)`, which already handles local and runtime-environment calls. In v1, grouped projects move only through the existing actions menu. + - The project-order preference is renderer UI state, not repo metadata. It persists through the `App.tsx` debounced `window.api.ui.set({...})` writer (step 1), not through any repo record. + +6. Tests. + - Add row-builder tests proving `projectOrderBy: 'manual'` orders project headers by `repoOrder` without changing workspace row order. + - Add row-builder tests proving `projectOrderBy: 'recent'` orders project headers by max visible `worktree.lastActivityAt` while preserving each project's child row order. + - Add Project Group tests for Manual and Recent within groups, plus unchanged `ProjectGroup.tabOrder`. + - Rewrite, do not extend, the existing Recent/`getProjectGroupOrdering` tests. The current cases at `src/renderer/src/components/sidebar/worktree-list-groups.test.ts:639` ("orders repo headers by first encounter…"), `:663` ("…highest-ranked visible child"), and the `getProjectGroupOrdering` block at `:752` all assert the old first-encounter coupling and are semantically incompatible with timestamp-based Recent. Replace them with tests for the new resolver and the `max(lastActivityAt)` ordering. + - Add UI slice/persistence normalization tests for default Manual, invalid value fallback, and hydration. Include a persistence-writer test (or note) that `projectOrderBy` is part of the `App.tsx` `ui.set` payload so it actually round-trips across restart. + - Grouped manual drag persistence tests are deferred with the grouped-drag feature (step 4). + +## Edge cases + +- Existing users with no `projectOrderBy` get Manual, even if their workspace `sortBy` is Recent. +- Changing workspace `sortBy` must not change project header order unless `projectOrderBy === 'recent'` and the visible worktree set/activity data changes. +- Changing `projectOrderBy` must not mutate any worktree `manualOrder` or repo order by itself. +- Manual drag while a project is added or removed can race. Whole-repo reorder (the only v1 drag path) keeps the existing permutation rejection and refetch behavior. (Grouped midpoint writes and their refetch-on-failure handling come with the deferred grouped-drag follow-up.) +- In Recent mode, projects with no visible worktrees should still render when they are placeholders or imported-worktree-card candidates; they sort after projects with activity. +- Pinned worktrees remain in the Pinned section. They should not make their project jump in Recent ordering unless an unpinned visible workspace in that project is also recent. +- Filters and hidden sleeping/default-branch workspaces affect the visible worktree set. Recent project order should reflect the rows the user can currently see. +- Project Group collapse state must not change when the order mode changes. +- Dragging a project inside a collapsed Project Group is impossible because its repo headers are not mounted; no special handling is needed. +- Dragging across Project Group boundaries should not silently move the project; cross-group moves stay on the actions menu. (In v1 there is no in-group drag at all — see step 4 — so this only constrains the deferred grouped-drag follow-up.) +- SSH/runtime projects must use the same store actions as local projects. No local filesystem path assumptions are needed. +- Folder projects have synthetic worktrees and should participate through the same `lastActivityAt` and manual repo order paths. +- Multi-window or external mutations are last-writer-wins through existing persistence. A rejected whole-repo permutation refetches repos (existing behavior); a failed grouped move would do the same once grouped drag lands. +- Behavior change on upgrade: existing users on `sortBy: recent`/`smart` currently see project headers bubble to follow workspace activity. After this change they default to Manual project order, so headers stop bubbling until they pick Recent in the new submenu. This is intended (matches the "no `projectOrderBy` → Manual" edge case) but is a visible change worth calling out in release notes. + +## Rollout + +1. Add `ProjectOrderBy` types/defaults/normalization, the UI slice setter, the `App.tsx` debounced-writer field, and persistence `getUI()`/`updateUI()` wiring. +2. Add the `Project order` submenu in `SidebarWorkspaceOptionsMenu`. +3. Update `WorktreeList` to read `projectOrderBy`, pass it to row construction, and enable project drag only in Manual project order (no-Project-Groups case, keeping the `!hasProjectGroups` gate). +4. Update `worktree-list-groups.ts` to order project headers by Manual or Recent independently from workspace `sortBy`, with Recent using `max(lastActivityAt)` per repo. +5. Add focused row-builder and UI persistence/round-trip tests; rewrite the incompatible first-encounter/`getProjectGroupOrdering` tests. +6. Run targeted Vitest for sidebar row ordering and repo slice tests, then `pnpm typecheck` and `pnpm lint`. +7. Validate in Electron: default startup shows Manual project order, the choice survives restart, project drag/drop reorders project headers only (ungrouped), Recent project order follows workspace activity, and worktree rows inside each project do not change when toggling project order. + +Deferred follow-up (separate change): extend `useRepoHeaderDrag` for grouped sibling buckets — bucket-aware drop targets and a second commit mode using `moveProjectToGroup(...)` midpoint ordering inside Project Groups — plus its grouped-drag persistence tests. diff --git a/mobile/src/tasks/mobile-workspace-name.test.ts b/mobile/src/tasks/mobile-workspace-name.test.ts new file mode 100644 index 00000000000..e4d804435c3 --- /dev/null +++ b/mobile/src/tasks/mobile-workspace-name.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { getLinkedWorkItemSuggestedName } from './mobile-workspace-name' + +describe('mobile workspace names', () => { + it('removes apostrophes inside words instead of splitting them', () => { + expect( + getLinkedWorkItemSuggestedName({ + title: "Can't enable browser notifications" + }) + ).toBe('cant-enable-browser-notifications') + + expect( + getLinkedWorkItemSuggestedName({ + title: 'Can’t enable browser notifications' + }) + ).toBe('cant-enable-browser-notifications') + }) +}) diff --git a/mobile/src/tasks/mobile-workspace-name.ts b/mobile/src/tasks/mobile-workspace-name.ts index 0cf116155f6..52330e834d7 100644 --- a/mobile/src/tasks/mobile-workspace-name.ts +++ b/mobile/src/tasks/mobile-workspace-name.ts @@ -5,8 +5,14 @@ export function resolveMobileWorkspaceCreateName(args: { return args.draft?.trim() || args.fallback } +// Why: mirrors the desktop issue-name slugger so contractions do not create +// branch/path names like `can-t-enable` in mobile-created workspaces. +function removeIntraWordApostrophes(input: string): string { + return input.replace(/[‘’]/g, "'").replace(/([\p{L}\p{N}])'(?=[\p{L}\p{N}])/gu, '$1') +} + function slugifyForWorkspaceName(input: string): string { - return input + return removeIntraWordApostrophes(input) .trim() .toLowerCase() .replace(/[\\/]+/g, '-') diff --git a/skills/orchestration/SKILL.md b/skills/orchestration/SKILL.md index 33cbe53bf18..fd5b22e454a 100644 --- a/skills/orchestration/SKILL.md +++ b/skills/orchestration/SKILL.md @@ -63,6 +63,8 @@ Rules: - Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal. - While supervising workers manually, use `check --wait --types worker_done,escalation,decision_gate --timeout-ms ` instead of sleep/poll loops. Reply to `decision_gate` messages with `orca orchestration reply --id --body --json`, then keep waiting. +- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop. +- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet. - Use `ask` when a worker needs a blocking answer from the coordinator; it waits for the reply and returns the answer directly. - `check --wait` returns one message at a time. If N workers may finish together, loop N times and dispatch newly ready tasks after each completion. - Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, and `@worktree:`. @@ -130,7 +132,7 @@ orca terminal send --terminal --text --enter --json If an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command "codex" --json` or `--command "claude"`. -Wait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. If `check --wait` times out with no `worker_done` or `escalation`, fall back to `terminal wait --for tui-idle`, then `terminal read`. +Wait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task. ## Agent Guidance @@ -151,7 +153,7 @@ orca terminal list --worktree id: --json orca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json orca orchestration task-create --spec "Fix the login button CSS" --json orca orchestration dispatch --task --to --inject --json -orca orchestration check --wait --types worker_done,escalation,decision_gate --timeout-ms 300000 --json +orca orchestration check --wait --types worker_done,escalation,decision_gate --timeout-ms 900000 --json ``` ## Next Action diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 313971bbc4d..c87c864b373 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -89,6 +89,7 @@ describe('AgentHookServer listener replay', () => { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', + providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' }, payload: { state: 'working', prompt: 'long task', agentType: 'codex' } }, 'conn-1' @@ -112,6 +113,7 @@ describe('AgentHookServer listener replay', () => { state: 'done', prompt: 'long task', agentType: 'codex', + providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' }, interrupted: true, receivedAt: 1_500, stateStartedAt: 1_500 @@ -120,6 +122,7 @@ describe('AgentHookServer listener replay', () => { expect(listener).toHaveBeenLastCalledWith( expect.objectContaining({ paneKey: PANE, + providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' }, payload: expect.objectContaining({ state: 'done', interrupted: true }) }) ) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 1ecbeb61d2e..f9cb59fec81 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -55,6 +55,7 @@ import { } from '../../shared/agent-interrupt-intent' import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id' import type { LegacyPaneKeyAliasEntry } from '../../shared/types' +import { normalizeAgentProviderSession } from '../../shared/agent-session-resume' export type { AgentHookSource } @@ -222,6 +223,7 @@ function sanitizeHydratedEntry( toolUseId: typeof record.toolUseId === 'string' ? record.toolUseId : undefined, toolAgentId: typeof record.toolAgentId === 'string' ? record.toolAgentId : undefined, toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined, + providerSession: normalizeAgentProviderSession(record.providerSession) ?? undefined, payload, receivedAt, stateStartedAt @@ -236,6 +238,7 @@ function toAgentStatusIpcPayload(entry: EnrichedAgentHookEventPayload): AgentSta connectionId: entry.connectionId, receivedAt: entry.receivedAt, stateStartedAt: entry.stateStartedAt, + ...(entry.providerSession ? { providerSession: entry.providerSession } : {}), ...entry.payload } } @@ -525,6 +528,7 @@ export class AgentHookServer { tabId: existing.tabId, worktreeId: existing.worktreeId, connectionId: existing.connectionId, + providerSession: existing.providerSession, payload: { state: 'done', prompt: payload.prompt, @@ -982,6 +986,7 @@ export class AgentHookServer { toolUseId?: string toolAgentId?: string toolAgentType?: string + providerSession?: unknown isReplay?: boolean payload: unknown }, @@ -1056,6 +1061,7 @@ export class AgentHookServer { typeof envelope.toolAgentType === 'string' && envelope.toolAgentType.trim().length > 0 ? envelope.toolAgentType.trim() : undefined + const providerSession = normalizeAgentProviderSession(envelope.providerSession) ?? undefined // Why: the relay is across a trust boundary; re-run the canonical // normalizer on the inner payload so prompt/agentType/toolName/toolInput // length caps, embedded-newline collapse, and the `interrupted`-only-on- @@ -1085,6 +1091,7 @@ export class AgentHookServer { toolUseId, toolAgentId, toolAgentType, + providerSession, isReplay: envelope.isReplay === true ? true : undefined, payload: normalizedPayload } diff --git a/src/main/browser/anti-detection.test.ts b/src/main/browser/anti-detection.test.ts new file mode 100644 index 00000000000..c937e3342c6 --- /dev/null +++ b/src/main/browser/anti-detection.test.ts @@ -0,0 +1,100 @@ +import { runInNewContext } from 'node:vm' +import { describe, expect, it } from 'vitest' + +import { ANTI_DETECTION_SCRIPT } from './anti-detection' + +type PermissionQueryResult = { + state: string + onchange: null +} + +type AntiDetectionContext = { + Notification: { + permission: string + requestPermission: (callback?: (permission: string) => void) => Promise + } + navigator: { + permissions: { + query: (descriptor: { name: string }) => Promise + } + } +} + +function createContext(args: { + nativeNotificationPermission: string + requestedNotificationPermission: string +}): AntiDetectionContext & Record { + class Permissions { + query(): Promise { + return Promise.resolve({ state: 'denied', onchange: null }) + } + } + + const Notification = { + permission: args.nativeNotificationPermission, + requestPermission(callback?: (permission: string) => void): Promise { + callback?.(args.requestedNotificationPermission) + return Promise.resolve(args.requestedNotificationPermission) + } + } + Object.defineProperty(Notification, 'permission', { + configurable: true, + get: () => args.nativeNotificationPermission + }) + + return { + Date, + Object, + Promise, + Set, + performance: { now: () => 0 }, + window: {}, + navigator: { + plugins: [], + languages: [], + permissions: new Permissions() + }, + Permissions, + Notification + } as AntiDetectionContext & Record +} + +describe('ANTI_DETECTION_SCRIPT', () => { + it('reports notification permission as granted after a site permission request succeeds', async () => { + const context = createContext({ + nativeNotificationPermission: 'denied', + requestedNotificationPermission: 'granted' + }) + + runInNewContext(ANTI_DETECTION_SCRIPT, context) + + expect(context.Notification.permission).toBe('default') + await expect(context.navigator.permissions.query({ name: 'notifications' })).resolves.toEqual({ + state: 'prompt', + onchange: null + }) + + await expect(context.Notification.requestPermission()).resolves.toBe('granted') + + expect(context.Notification.permission).toBe('granted') + await expect(context.navigator.permissions.query({ name: 'notifications' })).resolves.toEqual({ + state: 'granted', + onchange: null + }) + }) + + it('preserves notification permission when Electron already reports a grant', async () => { + const context = createContext({ + nativeNotificationPermission: 'granted', + requestedNotificationPermission: 'granted' + }) + + runInNewContext(ANTI_DETECTION_SCRIPT, context) + + expect(context.Notification.permission).toBe('granted') + await expect(context.navigator.permissions.query({ name: 'notifications' })).resolves.toEqual({ + state: 'granted', + onchange: null + }) + }) +}) diff --git a/src/main/browser/anti-detection.ts b/src/main/browser/anti-detection.ts index f523fc05303..4c62850ddc6 100644 --- a/src/main/browser/anti-detection.ts +++ b/src/main/browser/anti-detection.ts @@ -59,12 +59,32 @@ export const ANTI_DETECTION_SCRIPT = `(function() { // but real Chrome returns 'prompt' for ungranted permissions. Returning // 'denied' is a strong bot signal. Override the query result for common // permissions that Turnstile and similar detectors probe. + var notificationPermission = 'default'; + var setNotificationPermission = function(permission) { + if (permission === 'granted' || permission === 'denied') { + notificationPermission = permission; + return permission; + } + notificationPermission = 'default'; + return 'default'; + }; + var notificationPermissionState = function() { + return notificationPermission === 'default' ? 'prompt' : notificationPermission; + }; + try { + if (Notification.permission === 'granted') { + notificationPermission = 'granted'; + } + } catch {} const promptPerms = new Set([ - 'notifications', 'geolocation', 'camera', 'microphone', + 'geolocation', 'camera', 'microphone', 'midi', 'idle-detection', 'storage-access' ]); const origQuery = Permissions.prototype.query; Permissions.prototype.query = function(desc) { + if (desc.name === 'notifications') { + return Promise.resolve({ state: notificationPermissionState(), onchange: null }); + } if (promptPerms.has(desc.name)) { return Promise.resolve({ state: 'prompt', onchange: null }); } @@ -75,8 +95,25 @@ export const ANTI_DETECTION_SCRIPT = `(function() { // or blocked. Turnstile cross-references this with the Permissions API. try { Object.defineProperty(Notification, 'permission', { - get: () => 'default' + get: () => notificationPermission }); + const origRequestPermission = Notification.requestPermission; + if (typeof origRequestPermission === 'function') { + Notification.requestPermission = function(callback) { + var wrappedCallback = typeof callback === 'function' + ? function(permission) { + callback(setNotificationPermission(permission)); + } + : undefined; + var result = origRequestPermission.call(Notification, wrappedCallback); + if (result && typeof result.then === 'function') { + return result.then(function(permission) { + return setNotificationPermission(permission); + }); + } + return result; + }; + } } catch {} // Why: Electron webviews may have an empty languages array. Real Chrome // always has at least one entry. An empty array is an automation signal. diff --git a/src/main/browser/browser-guest-ui.test.ts b/src/main/browser/browser-guest-ui.test.ts index 718620273e3..ce7871e7d98 100644 --- a/src/main/browser/browser-guest-ui.test.ts +++ b/src/main/browser/browser-guest-ui.test.ts @@ -9,7 +9,12 @@ vi.mock('electron', () => ({ webContents: { fromId: vi.fn() } })) -import { setupGuestContextMenu, setupGuestShortcutForwarding } from './browser-guest-ui' +import { + resolveGuestMouseWheelZoomDirection, + setupGuestContextMenu, + setupGuestMouseWheelZoomForwarding, + setupGuestShortcutForwarding +} from './browser-guest-ui' describe('setupGuestContextMenu', () => { const browserTabId = 'tab-1' @@ -253,6 +258,125 @@ describe('setupGuestContextMenu', () => { }) }) +describe('guest mouse wheel browser zoom', () => { + const browserTabId = 'tab-1' + let rendererSendMock: ReturnType + let guestOnMock: ReturnType + let guestOffMock: ReturnType + + function makeGuest() { + return { + on: guestOnMock, + off: guestOffMock + } as unknown as Electron.WebContents + } + + function makeRenderer() { + return { send: rendererSendMock } as unknown as Electron.WebContents + } + + function mouseWheel( + overrides: Partial = {} + ): Electron.MouseWheelInputEvent { + return { + type: 'mouseWheel', + x: 0, + y: 0, + deltaY: -120, + modifiers: ['ctrl'], + ...overrides + } + } + + function triggerBeforeMouse(mouse: Electron.MouseInputEvent): ReturnType { + const handler = guestOnMock.mock.calls.find((call) => call[0] === 'before-mouse-event')?.[1] as + | ((event: Electron.Event, mouse: Electron.MouseInputEvent) => void) + | undefined + expect(handler).toBeTypeOf('function') + const preventDefault = vi.fn() + handler!({ preventDefault } as unknown as Electron.Event, mouse) + return preventDefault + } + + beforeEach(() => { + rendererSendMock = vi.fn() + guestOnMock = vi.fn() + guestOffMock = vi.fn() + }) + + it('resolves ctrl wheel direction from guest mouse input', () => { + expect(resolveGuestMouseWheelZoomDirection(mouseWheel({ deltaY: -120 }), 'win32')).toBe('in') + expect(resolveGuestMouseWheelZoomDirection(mouseWheel({ deltaY: 120 }), 'linux')).toBe('out') + }) + + it('allows command wheel only on macOS', () => { + const commandWheel = mouseWheel({ modifiers: ['cmd'], deltaY: -120 }) + + expect(resolveGuestMouseWheelZoomDirection(commandWheel, 'darwin')).toBe('in') + expect(resolveGuestMouseWheelZoomDirection(commandWheel, 'win32')).toBeNull() + }) + + it('ignores non-zoom wheel input', () => { + expect( + resolveGuestMouseWheelZoomDirection(mouseWheel({ modifiers: [], deltaY: -120 }), 'linux') + ).toBeNull() + expect( + resolveGuestMouseWheelZoomDirection(mouseWheel({ modifiers: ['ctrl', 'alt'] }), 'linux') + ).toBeNull() + expect( + resolveGuestMouseWheelZoomDirection(mouseWheel({ modifiers: ['ctrl', 'shift'] }), 'linux') + ).toBeNull() + expect(resolveGuestMouseWheelZoomDirection(mouseWheel({ deltaY: 0 }), 'linux')).toBeNull() + expect( + resolveGuestMouseWheelZoomDirection( + { type: 'mouseMove', x: 0, y: 0, modifiers: ['ctrl'] }, + 'linux' + ) + ).toBeNull() + }) + + it('forwards ctrl wheel to browser page zoom and consumes the guest wheel event', () => { + setupGuestMouseWheelZoomForwarding({ + browserTabId, + guest: makeGuest(), + resolveRenderer: () => makeRenderer() + }) + + const preventDefault = triggerBeforeMouse(mouseWheel({ deltaY: -120 })) + const outPreventDefault = triggerBeforeMouse(mouseWheel({ deltaY: 120 })) + + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(outPreventDefault).toHaveBeenCalledTimes(1) + expect(rendererSendMock).toHaveBeenNthCalledWith(1, 'ui:zoomBrowserPage', 'in') + expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:zoomBrowserPage', 'out') + }) + + it('consumes guest ctrl wheel even when the renderer is unavailable', () => { + setupGuestMouseWheelZoomForwarding({ + browserTabId, + guest: makeGuest(), + resolveRenderer: () => null + }) + + const preventDefault = triggerBeforeMouse(mouseWheel()) + + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(rendererSendMock).not.toHaveBeenCalled() + }) + + it('cleans up the mouse wheel listener on teardown', () => { + const cleanup = setupGuestMouseWheelZoomForwarding({ + browserTabId, + guest: makeGuest(), + resolveRenderer: () => makeRenderer() + }) + + cleanup() + + expect(guestOffMock).toHaveBeenCalledWith('before-mouse-event', expect.any(Function)) + }) +}) + describe('setupGuestShortcutForwarding', () => { const browserTabId = 'tab-1' let rendererSendMock: ReturnType diff --git a/src/main/browser/browser-guest-ui.ts b/src/main/browser/browser-guest-ui.ts index 6bbf9b16f0b..6d2e253dcb7 100644 --- a/src/main/browser/browser-guest-ui.ts +++ b/src/main/browser/browser-guest-ui.ts @@ -14,11 +14,43 @@ import { } from '../../shared/window-shortcut-policy' import { readGuestNavigationState } from './browser-guest-navigation-state' import { keybindingMatchesAction, type KeybindingOverrides } from '../../shared/keybindings' +import type { BrowserPageZoomDirection } from '../../shared/browser-page-zoom' type ResolveRenderer = (browserTabId: string) => Electron.WebContents | null type ShouldForwardDictationShortcut = () => boolean type IsMobileEmulatorEnabled = () => boolean +const CONTROL_MODIFIERS = new Set(['control', 'ctrl']) +const MAC_COMMAND_MODIFIERS = new Set(['meta', 'command', 'cmd']) +const WHEEL_ZOOM_BLOCKING_MODIFIERS = new Set(['alt', 'shift']) + +function hasModifier(mouse: Electron.MouseInputEvent, modifiers: ReadonlySet): boolean { + return mouse.modifiers?.some((modifier) => modifiers.has(modifier)) ?? false +} + +export function resolveGuestMouseWheelZoomDirection( + mouse: Electron.MouseInputEvent, + platform: NodeJS.Platform = process.platform +): BrowserPageZoomDirection | null { + if (mouse.type !== 'mouseWheel') { + return null + } + if (hasModifier(mouse, WHEEL_ZOOM_BLOCKING_MODIFIERS)) { + return null + } + const hasZoomModifier = + hasModifier(mouse, CONTROL_MODIFIERS) || + (platform === 'darwin' && hasModifier(mouse, MAC_COMMAND_MODIFIERS)) + if (!hasZoomModifier) { + return null + } + const deltaY = (mouse as Electron.MouseWheelInputEvent).deltaY + if (typeof deltaY !== 'number' || deltaY === 0) { + return null + } + return deltaY < 0 ? 'in' : 'out' +} + function isControlKeyRelease(input: Electron.Input): boolean { return input.type === 'keyUp' && (input.code === 'ControlLeft' || input.code === 'ControlRight') } @@ -438,6 +470,33 @@ export function setupGuestShortcutForwarding(args: { } } +export function setupGuestMouseWheelZoomForwarding(args: { + browserTabId: string + guest: Electron.WebContents + resolveRenderer: ResolveRenderer +}): () => void { + const { browserTabId, guest, resolveRenderer } = args + const handler = (event: Electron.Event, mouse: Electron.MouseInputEvent): void => { + const direction = resolveGuestMouseWheelZoomDirection(mouse) + if (!direction) { + return + } + // Why: wheel input over a focused webview does not reach renderer DOM + // handlers, so consume it here and forward to the existing page-zoom path. + event.preventDefault() + resolveRenderer(browserTabId)?.send('ui:zoomBrowserPage', direction) + } + + guest.on('before-mouse-event', handler) + return () => { + try { + guest.off('before-mouse-event', handler) + } catch { + // Why: best-effort — guest may already be destroyed during teardown. + } + } +} + export function resolveRendererWebContents( rendererWebContentsIdByTabId: ReadonlyMap, browserTabId: string diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index f31234c4d7f..1a10542cbbe 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -32,6 +32,7 @@ import { resolveRendererWebContents, setupGrabShortcutForwarding, setupGuestContextMenu, + setupGuestMouseWheelZoomForwarding, setupGuestShortcutForwarding } from './browser-guest-ui' import { ANTI_DETECTION_SCRIPT } from './anti-detection' @@ -189,6 +190,7 @@ export class BrowserManager { private readonly contextMenuCleanupByTabId = new Map void>() private readonly grabShortcutCleanupByTabId = new Map void>() private readonly shortcutForwardingCleanupByTabId = new Map void>() + private readonly mouseWheelZoomCleanupByTabId = new Map void>() private readonly annotationViewportBridgeOpsByTabId = new Map>() private readonly worktreeIdByTabId = new Map() private readonly policyAttachedGuestIds = new Set() @@ -761,6 +763,7 @@ export class BrowserManager { this.setupContextMenu(browserTabId, guest) this.setupGrabShortcut(browserTabId, guest) this.setupShortcutForwarding(browserTabId, guest) + this.setupMouseWheelZoomForwarding(browserTabId, guest) this.flushPendingLoadFailure(browserTabId, webContentsId) this.flushPendingPermissionEvents(browserTabId, webContentsId) this.flushPendingPopupEvents(browserTabId, webContentsId) @@ -796,6 +799,11 @@ export class BrowserManager { fwdCleanup() this.shortcutForwardingCleanupByTabId.delete(browserTabId) } + const mouseWheelZoomCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId) + if (mouseWheelZoomCleanup) { + mouseWheelZoomCleanup() + this.mouseWheelZoomCleanupByTabId.delete(browserTabId) + } // Why: paused downloads wait for explicit product approval. If the owning // browser tab disappears first, cancel the request so the app does not // retain orphaned download items or write files after context is gone. @@ -844,6 +852,7 @@ export class BrowserManager { this.pendingPermissionEventsByGuestId.clear() this.pendingPopupEventsByGuestId.clear() this.pendingDownloadIdsByGuestId.clear() + this.mouseWheelZoomCleanupByTabId.clear() this.annotationViewportBridgeOpsByTabId.clear() } @@ -1428,6 +1437,24 @@ export class BrowserManager { ) } + private setupMouseWheelZoomForwarding(browserTabId: string, guest: Electron.WebContents): void { + const previousCleanup = this.mouseWheelZoomCleanupByTabId.get(browserTabId) + if (previousCleanup) { + previousCleanup() + this.mouseWheelZoomCleanupByTabId.delete(browserTabId) + } + + this.mouseWheelZoomCleanupByTabId.set( + browserTabId, + setupGuestMouseWheelZoomForwarding({ + browserTabId, + guest, + resolveRenderer: (tabId) => + resolveRendererWebContents(this.rendererWebContentsIdByTabId, tabId) + }) + ) + } + private forwardOrQueueGuestLoadFailure( guestWebContentsId: number, loadError: { code: number; description: string; validatedUrl: string } diff --git a/src/main/browser/browser-session-permission-policy.ts b/src/main/browser/browser-session-permission-policy.ts new file mode 100644 index 00000000000..a66e27980e3 --- /dev/null +++ b/src/main/browser/browser-session-permission-policy.ts @@ -0,0 +1,17 @@ +const AUTO_GRANTED_BROWSER_PERMISSIONS = new Set([ + 'fullscreen', + // Agent-browser clipboard commands execute via CDP in this session; denying + // them breaks trusted runtime commands even when invoked with a user gesture. + 'clipboard-read', + 'clipboard-sanitized-write', + // User-opened browser pages need these profile-scoped grants to complete + // normal site flows like web push setup and durable app storage. + 'notifications', + // Chromium can request this at runtime even though Electron's TS union does + // not list it; chatgpt.com uses it to keep browser storage from eviction. + 'persistent-storage' +]) + +export function isAutoGrantedBrowserSessionPermission(permission: string): boolean { + return AUTO_GRANTED_BROWSER_PERMISSIONS.has(permission) +} diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index 2b511e53adb..d6de343e6f4 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -257,23 +257,35 @@ describe('BrowserSessionRegistry persistence', () => { requestHandler(guestWc, 'clipboard-read', permissionCallback) requestHandler(guestWc, 'clipboard-sanitized-write', permissionCallback) requestHandler(guestWc, 'notifications', permissionCallback) + requestHandler(guestWc, 'persistent-storage', permissionCallback) + requestHandler(guestWc, 'geolocation', permissionCallback) requestHandler(guestWc, 'media', permissionCallback, { mediaTypes: ['video'] }) await vi.waitFor(() => - expect(permissionCallback.mock.calls).toEqual([[true], [true], [true], [false], [true]]) + expect(permissionCallback.mock.calls).toEqual([ + [true], + [true], + [true], + [true], + [true], + [false], + [true] + ]) ) expect(browserManagerNotifyPermissionDeniedMock).toHaveBeenCalledWith({ guestWebContentsId: 401, - permission: 'notifications', + permission: 'geolocation', rawUrl: 'https://example.com/account' }) expect( browserManagerNotifyPermissionDeniedMock.mock.calls.map(([args]) => args.permission) - ).toEqual(['notifications']) + ).toEqual(['geolocation']) expect(checkHandler(null, 'fullscreen', '')).toBe(true) expect(checkHandler(null, 'clipboard-read', '')).toBe(true) expect(checkHandler(null, 'clipboard-sanitized-write', '')).toBe(true) - expect(checkHandler(null, 'notifications', '')).toBe(false) + expect(checkHandler(null, 'notifications', '')).toBe(true) + expect(checkHandler(null, 'persistent-storage', '')).toBe(true) + expect(checkHandler(null, 'geolocation', '')).toBe(false) expect(checkHandler(null, 'media', '', { mediaType: 'video' })).toBe(true) expect(defaultSession.setDisplayMediaRequestHandler).toHaveBeenCalled() const displayMediaHandler = defaultSession.setDisplayMediaRequestHandler.mock.calls[0][0] diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 81123befc84..914a48578ab 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -206,7 +206,9 @@ describe('BrowserSessionRegistry', () => { await vi.waitFor(() => expect(cb).toHaveBeenCalledWith(true)) expect(checkHandler(null, 'media', '', { mediaType: 'video' })).toBe(true) - expect(checkHandler(null, 'notifications', '', {})).toBe(false) + expect(checkHandler(null, 'notifications', '', {})).toBe(true) + expect(checkHandler(null, 'persistent-storage', '', {})).toBe(true) + expect(checkHandler(null, 'geolocation', '', {})).toBe(false) }) it('wires WebAuthn device selection for isolated partitions', () => { diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index 13afae49b35..af5170d1cd9 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -20,6 +20,7 @@ import type { BrowserSessionProfile, BrowserSessionProfileScope } from '../../sh import { browserManager } from './browser-manager' import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access' import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua' +import { isAutoGrantedBrowserSessionPermission } from './browser-session-permission-policy' import { allowsBrowserWebAuthnPermission, clearBrowserWebAuthnAccessHandlers, @@ -493,10 +494,6 @@ class BrowserSessionRegistry { sess.setUserAgent(cleanUA) setupClientHintsOverride(sess, cleanUA) } - // Why: agent-browser clipboard commands execute via CDP in this session. - // Until there is a separate trusted bridge, denying clipboard-read breaks - // those runtime commands even when invoked with a user gesture. - const autoGranted = new Set(['fullscreen', 'clipboard-read', 'clipboard-sanitized-write']) sess.setPermissionRequestHandler((webContents, permission, callback, details) => { // Why: `media` (camera/mic) must defer to macOS TCC instead of being // denied outright. Denying at the session layer would make pages inside @@ -530,7 +527,7 @@ class BrowserSessionRegistry { ) return } - const allowed = autoGranted.has(permission) + const allowed = isAutoGrantedBrowserSessionPermission(permission) if (!allowed) { browserManager.notifyPermissionDenied({ guestWebContentsId: webContents.id, @@ -547,7 +544,7 @@ class BrowserSessionRegistry { if (allowsBrowserWebAuthnPermission(permission, details)) { return true } - return autoGranted.has(permission) + return isAutoGrantedBrowserSessionPermission(permission) }) installBrowserWebAuthnAccessHandlers(sess) sess.setDisplayMediaRequestHandler((_request, callback) => { diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 5266aa80ee5..a8aee0423b7 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -724,6 +724,328 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(getRuntimeCodexHomePath())).toBe(true) }) + it('uses the same host CODEX_HOME after switching managed Codex accounts', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one') + const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two') + const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) + const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'one@example.com', + managedHomePath: managedHomePath1, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'two@example.com', + managedHomePath: managedHomePath2, + providerAccountId: 'acct-2', + workspaceLabel: null, + workspaceAccountId: 'acct-2', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + const account1Home = service.prepareForCodexLaunch() + settings.activeCodexManagedAccountId = 'account-2' + settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} } + const account2Home = service.prepareForCodexLaunch() + + expect(account1Home).toBe(getRuntimeCodexHomePath()) + expect(account2Home).toBe(getRuntimeCodexHomePath()) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth) + }) + + it('new host Codex launches get the selected account after preserving outgoing refreshes', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one', 1) + const account1RefreshedAuth = createCodexAuthJson( + 'one@example.com', + 'acct-1', + 'one-refreshed', + 2 + ) + const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two', 1) + const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) + const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'one@example.com', + managedHomePath: managedHomePath1, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'two@example.com', + managedHomePath: managedHomePath2, + providerAccountId: 'acct-2', + workspaceLabel: null, + workspaceAccountId: 'acct-2', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') + settings.activeCodexManagedAccountId = 'account-2' + settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} } + + expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) + expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth) + }) + + it('reads back selected-account refreshes without ambiguity from duplicate identities', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const account1Auth = createCodexAuthJson('same@example.com', 'acct-same', 'one', 1) + const account1RefreshedAuth = createCodexAuthJson( + 'same@example.com', + 'acct-same', + 'one-refreshed', + 2 + ) + const account2Auth = createCodexAuthJson('same@example.com', 'acct-same', 'two', 1) + const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) + const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'same@example.com', + managedHomePath: managedHomePath1, + providerAccountId: 'acct-same', + workspaceLabel: null, + workspaceAccountId: 'acct-same', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'same@example.com', + managedHomePath: managedHomePath2, + providerAccountId: 'acct-same', + workspaceLabel: null, + workspaceAccountId: 'acct-same', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') + service.syncForCurrentSelection() + + expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth) + expect(readFileSync(join(managedHomePath2, 'auth.json'), 'utf-8')).toBe(account2Auth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1RefreshedAuth) + }) + + it('keeps fresher selected-account startup refreshes when duplicate identities exist', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const account1Auth = createCodexAuthJson('same@example.com', 'acct-same', 'one', 1) + const account1RefreshedAuth = createCodexAuthJson( + 'same@example.com', + 'acct-same', + 'one-refreshed', + 2 + ) + const account2Auth = createCodexAuthJson('same@example.com', 'acct-same', 'two', 1) + const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) + const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'same@example.com', + managedHomePath: managedHomePath1, + providerAccountId: 'acct-same', + workspaceLabel: null, + workspaceAccountId: 'acct-same', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'same@example.com', + managedHomePath: managedHomePath2, + providerAccountId: 'acct-same', + workspaceLabel: null, + workspaceAccountId: 'acct-same', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth) + expect(readFileSync(join(managedHomePath2, 'auth.json'), 'utf-8')).toBe(account2Auth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1RefreshedAuth) + }) + + it('routes stale live-pane startup refreshes to the matching account before restoring selected auth', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one', 1) + const account1RefreshedAuth = createCodexAuthJson( + 'one@example.com', + 'acct-1', + 'one-refreshed', + 2 + ) + const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two', 1) + const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) + const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'one@example.com', + managedHomePath: managedHomePath1, + providerAccountId: 'acct-1', + workspaceLabel: null, + workspaceAccountId: 'acct-1', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'two@example.com', + managedHomePath: managedHomePath2, + providerAccountId: 'acct-2', + workspaceLabel: null, + workspaceAccountId: 'acct-2', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + } + ], + activeCodexManagedAccountId: 'account-2', + activeCodexManagedAccountIdsByRuntime: { host: 'account-2', wsl: {} } + }) + writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') + const store = createStore(settings) + + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + new CodexRuntimeHomeService(store as never) + + expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth) + expect(readFileSync(join(managedHomePath2, 'auth.json'), 'utf-8')).toBe(account2Auth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth) + }) + + it('preserves duplicate-identity outgoing refreshes before switching to another account', async () => { + const runtimeAuthPath = getRuntimeCodexAuthPath() + const account1Auth = createCodexAuthJson('same@example.com', 'acct-same', 'one', 1) + const account1RefreshedAuth = createCodexAuthJson( + 'same@example.com', + 'acct-same', + 'one-refreshed', + 2 + ) + const account2Auth = createCodexAuthJson('same@example.com', 'acct-same', 'two', 1) + const account3Auth = createCodexAuthJson('three@example.com', 'acct-3', 'three', 1) + const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) + const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) + const managedHomePath3 = createManagedAuth(testState.userDataDir, 'account-3', account3Auth) + const settings = createSettings({ + codexManagedAccounts: [ + { + id: 'account-1', + email: 'same@example.com', + managedHomePath: managedHomePath1, + providerAccountId: 'acct-same', + workspaceLabel: null, + workspaceAccountId: 'acct-same', + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + }, + { + id: 'account-2', + email: 'same@example.com', + managedHomePath: managedHomePath2, + providerAccountId: 'acct-same', + workspaceLabel: null, + workspaceAccountId: 'acct-same', + createdAt: 2, + updatedAt: 2, + lastAuthenticatedAt: 2 + }, + { + id: 'account-3', + email: 'three@example.com', + managedHomePath: managedHomePath3, + providerAccountId: 'acct-3', + workspaceLabel: null, + workspaceAccountId: 'acct-3', + createdAt: 3, + updatedAt: 3, + lastAuthenticatedAt: 3 + } + ], + activeCodexManagedAccountId: 'account-1', + activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } + }) + const store = createStore(settings) + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') + settings.activeCodexManagedAccountId = 'account-3' + settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-3', wsl: {} } + service.syncForCurrentSelection() + + expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth) + expect(readFileSync(join(managedHomePath2, 'auth.json'), 'utf-8')).toBe(account2Auth) + expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account3Auth) + }) + it('mirrors later system Codex config changes before launch', async () => { const systemCodexHome = getSystemCodexHomePath() mkdirSync(systemCodexHome, { recursive: true }) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 6d4c9e9fa44..013ae41d5c2 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -313,6 +313,20 @@ export class CodexRuntimeHomeService { private readBackRefreshedTokens(options: { updateLastWrittenAuthJson: boolean }): CodexReadBackResult { + const selectedAccountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host + if (selectedAccountId) { + const selectedAccountResult = this.readBackRefreshedTokensFromPath( + this.getRuntimeAuthPath(), + { + ...options, + expectedAccountId: selectedAccountId + } + ) + if (selectedAccountResult !== 'rejected') { + return selectedAccountResult + } + } + return this.readBackRefreshedTokensFromPath(this.getRuntimeAuthPath(), options) } diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 84eb40c4cd6..d41f0efe80e 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -1384,9 +1384,70 @@ describe('getPRForBranch', () => { cwd: '/repo-root' }) expect(target).toEqual({ - remoteName: 'pr-prateek-orca', - branchName: 'prateek/fix-sidebar-agents-toggle', - remoteUrl: 'git@github.com:prateek/orca.git' + pushTarget: { + remoteName: 'pr-prateek-orca', + branchName: 'prateek/fix-sidebar-agents-toggle', + remoteUrl: 'git@github.com:prateek/orca.git' + } + }) + }) + + it('surfaces maintainer_can_modify=false alongside a fork PR push target', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + maintainer_can_modify: false, + head: { + ref: 'prateek/fix-sidebar-agents-toggle', + repo: { + full_name: 'prateek/orca', + name: 'orca', + clone_url: 'https://github.com/prateek/orca.git', + ssh_url: 'git@github.com:prateek/orca.git', + owner: { login: 'prateek' } + } + } + }) + }) + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: 'git@github.com:stablyai/orca.git\n', + stderr: '' + }) + + await expect(getPullRequestPushTarget('/repo-root', 1738)).resolves.toEqual({ + pushTarget: { + remoteName: 'pr-prateek-orca', + branchName: 'prateek/fix-sidebar-agents-toggle', + remoteUrl: 'git@github.com:prateek/orca.git' + }, + maintainerCanModify: false + }) + }) + + it('omits maintainerCanModify when the API does not report the flag', async () => { + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + head: { + ref: 'fix-sidebar', + repo: { + full_name: 'stablyai/orca', + name: 'orca', + clone_url: 'https://github.com/stablyai/orca.git', + ssh_url: 'git@github.com:stablyai/orca.git', + owner: { login: 'stablyai' } + } + } + }) + }) + + await expect(getPullRequestPushTarget('/repo-root', 1738)).resolves.toEqual({ + pushTarget: { + remoteName: 'origin', + branchName: 'fix-sidebar' + } }) }) @@ -1409,8 +1470,10 @@ describe('getPRForBranch', () => { }) await expect(getPullRequestPushTarget('/repo-root', 1738)).resolves.toEqual({ - remoteName: 'origin', - branchName: 'fix-sidebar' + pushTarget: { + remoteName: 'origin', + branchName: 'fix-sidebar' + } }) expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) @@ -1494,8 +1557,10 @@ describe('getPRForBranch', () => { }) await expect(getPullRequestPushTarget('/repo-root', 1849)).resolves.toEqual({ - remoteName: 'origin', - branchName: 'feature/test' + pushTarget: { + remoteName: 'origin', + branchName: 'feature/test' + } }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['api', 'repos/fork/orca/pulls/1849'], { cwd: '/repo-root' diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 9254157e094..c63edfeaf72 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -257,11 +257,22 @@ function sanitizeRemoteName(owner: string, repo: string): string { return slug ? `pr-${slug}` : 'pr-head' } +/** + * A fork push target plus the PR's `maintainer_can_modify` flag. The flag rides + * alongside the target (rather than inside {@link GitPushTarget}) so it never + * leaks into the persisted, validated push-target shape. + */ +export type PullRequestPushTarget = { + pushTarget: GitPushTarget + /** false when the PR has "Allow edits from maintainers" off; a push may be rejected. */ + maintainerCanModify?: boolean +} + export async function getPullRequestPushTarget( repoPath: string, prNumber: number, connectionId?: string | null -): Promise { +): Promise { const context = githubRepoContext(repoPath, connectionId) const ghOptions = ghRepoExecOptions(context) const { candidates } = await resolvePRRepositoryCandidates(repoPath, connectionId) @@ -297,6 +308,7 @@ export async function getPullRequestPushTarget( } const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId) const pr = JSON.parse(prStdout) as { + maintainer_can_modify?: boolean head?: { ref?: string repo?: { @@ -314,6 +326,8 @@ export async function getPullRequestPushTarget( const repo = headRepo?.name?.trim() ?? headRepo?.full_name?.split('/')[1]?.trim() const cloneUrl = headRepo?.clone_url?.trim() const sshUrl = headRepo?.ssh_url?.trim() + const maintainerCanModify = + typeof pr.maintainer_can_modify === 'boolean' ? pr.maintainer_can_modify : undefined if (!owner || !repo || !branchName || !cloneUrl || !sshUrl) { return null } @@ -322,7 +336,10 @@ export async function getPullRequestPushTarget( origin.owner.toLowerCase() === owner.toLowerCase() && origin.repo.toLowerCase() === repo.toLowerCase() ) { - return { remoteName: 'origin', branchName } + return { + pushTarget: { remoteName: 'origin', branchName }, + ...(maintainerCanModify !== undefined ? { maintainerCanModify } : {}) + } } let originUrl: string | null = null @@ -335,9 +352,12 @@ export async function getPullRequestPushTarget( originUrl = null } return { - remoteName: sanitizeRemoteName(owner, repo), - branchName, - remoteUrl: pickPushRemoteUrl({ originUrl, cloneUrl, sshUrl }) + pushTarget: { + remoteName: sanitizeRemoteName(owner, repo), + branchName, + remoteUrl: pickPushRemoteUrl({ originUrl, cloneUrl, sshUrl }) + }, + ...(maintainerCanModify !== undefined ? { maintainerCanModify } : {}) } } finally { release() diff --git a/src/main/github/pr-start-point.test.ts b/src/main/github/pr-start-point.test.ts index f0f81b152f3..db8d15d625e 100644 --- a/src/main/github/pr-start-point.test.ts +++ b/src/main/github/pr-start-point.test.ts @@ -20,9 +20,11 @@ describe('resolveGitHubPrStartPoint', () => { it('falls back to the GitHub PR head ref when a direct branch fetch fails', async () => { getPullRequestPushTargetMock.mockResolvedValue({ - remoteName: 'pr-contributor-orca', - branchName: 'feat/onboarding-model-choice-782', - remoteUrl: 'git@github.com:contributor/orca.git' + pushTarget: { + remoteName: 'pr-contributor-orca', + branchName: 'feat/onboarding-model-choice-782', + remoteUrl: 'git@github.com:contributor/orca.git' + } }) const gitExec = vi.fn(async (args: string[]) => { if (args[0] === 'fetch' && String(args[2]).startsWith('+refs/heads/')) { @@ -50,6 +52,8 @@ describe('resolveGitHubPrStartPoint', () => { expect(gitExec).toHaveBeenCalledWith(['fetch', 'origin', 'refs/pull/1849/head']) expect(result).toEqual({ baseBranch: 'def456', + headSha: 'def456', + branchNameOverride: 'feat/onboarding-model-choice-782', pushTarget: { remoteName: 'pr-contributor-orca', branchName: 'feat/onboarding-model-choice-782', @@ -79,7 +83,11 @@ describe('resolveGitHubPrStartPoint', () => { }) expect(getPullRequestPushTargetMock).toHaveBeenCalledWith('/repo-root', 1849, null) - expect(result).toEqual({ baseBranch: 'def456' }) + expect(result).toEqual({ + baseBranch: 'def456', + headSha: 'def456', + branchNameOverride: 'feat/onboarding-model-choice-782' + }) }) it('resolves an inaccessible fork PR even when push-target discovery fails', async () => { @@ -102,7 +110,11 @@ describe('resolveGitHubPrStartPoint', () => { expect(getPullRequestPushTargetMock).toHaveBeenCalledWith('/repo-root', 1849, null) expect(gitExec).toHaveBeenCalledWith(['fetch', 'origin', 'refs/pull/1849/head']) - expect(result).toEqual({ baseBranch: 'abc123' }) + expect(result).toEqual({ + baseBranch: 'abc123', + headSha: 'abc123', + branchNameOverride: 'feat/onboarding-model-choice-782' + }) }) it('uses PR metadata when the caller did not pass a head ref', async () => { @@ -112,9 +124,11 @@ describe('resolveGitHubPrStartPoint', () => { isCrossRepository: true }) getPullRequestPushTargetMock.mockResolvedValue({ - remoteName: 'pr-contributor-orca', - branchName: 'contributor/fix', - remoteUrl: 'git@github.com:contributor/orca.git' + pushTarget: { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/fix', + remoteUrl: 'git@github.com:contributor/orca.git' + } }) const gitExec = vi.fn(async (args: string[]) => { if (args[0] === 'rev-parse') { @@ -133,6 +147,8 @@ describe('resolveGitHubPrStartPoint', () => { expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 1738, 'pr', null) expect(result).toEqual({ baseBranch: 'abc123', + headSha: 'abc123', + branchNameOverride: 'contributor/fix', pushTarget: { remoteName: 'pr-contributor-orca', branchName: 'contributor/fix', @@ -141,6 +157,44 @@ describe('resolveGitHubPrStartPoint', () => { }) }) + it('surfaces maintainerCanModify=false for a fork PR so the caller can warn', async () => { + getPullRequestPushTargetMock.mockResolvedValue({ + pushTarget: { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/fix', + remoteUrl: 'git@github.com:contributor/orca.git' + }, + maintainerCanModify: false + }) + const gitExec = vi.fn(async (args: string[]) => { + if (args[0] === 'rev-parse') { + return { stdout: 'abc123\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + const result = await resolveGitHubPrStartPoint({ + repoPath: '/repo-root', + prNumber: 1849, + headRefName: 'contributor/fix', + isCrossRepository: true, + gitExec, + resolveRemote: async () => 'origin' + }) + + expect(result).toEqual({ + baseBranch: 'abc123', + headSha: 'abc123', + branchNameOverride: 'contributor/fix', + pushTarget: { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/fix', + remoteUrl: 'git@github.com:contributor/orca.git' + }, + maintainerCanModify: false + }) + }) + it('returns the verified head SHA, branch override, and push target when same-repo branch fetch succeeds', async () => { const gitExec = vi.fn(async (args: string[]) => { if (args[0] === 'rev-parse') { diff --git a/src/main/github/pr-start-point.ts b/src/main/github/pr-start-point.ts index c53807bc0f8..61c5510a1a1 100644 --- a/src/main/github/pr-start-point.ts +++ b/src/main/github/pr-start-point.ts @@ -22,15 +22,20 @@ export async function resolveGitHubPrStartPoint( let headRefName = args.headRefName?.trim() ?? '' let isCrossRepository = args.isCrossRepository === true let pushTarget: GitPushTarget | undefined + let maintainerCanModify: boolean | undefined const resolvePushTarget = async (): Promise => { if (pushTarget) { return } try { - pushTarget = - (await getPullRequestPushTarget(args.repoPath, args.prNumber, args.connectionId ?? null)) ?? - undefined + const resolved = await getPullRequestPushTarget( + args.repoPath, + args.prNumber, + args.connectionId ?? null + ) + pushTarget = resolved?.pushTarget + maintainerCanModify = resolved?.maintainerCanModify } catch { // Why: deleted/inaccessible fork metadata can prevent push-target // discovery, but GitHub still exposes the PR head ref for checkout. @@ -94,7 +99,16 @@ export async function resolveGitHubPrStartPoint( if ('error' in result) { return result } - return { ...result, ...(pushTarget ? { pushTarget } : {}) } + // Why: adopt the contributor's branch name locally (mirroring the same-repo + // return below) so fork-PR worktrees aren't renamed with the maintainer's + // branch prefix (e.g. `me/866`). The push refspec still targets the fork. + return { + ...result, + headSha: result.baseBranch, + branchNameOverride: headRefName, + ...(pushTarget ? { pushTarget } : {}), + ...(maintainerCanModify !== undefined ? { maintainerCanModify } : {}) + } } try { @@ -111,7 +125,13 @@ export async function resolveGitHubPrStartPoint( const result = await fetchPullRequestHeadSha() if (!('error' in result)) { await resolvePushTarget() - return { ...result, ...(pushTarget ? { pushTarget } : {}) } + return { + ...result, + headSha: result.baseBranch, + branchNameOverride: headRefName, + ...(pushTarget ? { pushTarget } : {}), + ...(maintainerCanModify !== undefined ? { maintainerCanModify } : {}) + } } } return { diff --git a/src/main/index.ts b/src/main/index.ts index a841c176d26..95f878e21ea 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -609,6 +609,7 @@ function openMainWindow(): BrowserWindow { payload, receivedAt, stateStartedAt, + providerSession, isReplay }) => { if (mainWindow?.isDestroyed()) { @@ -626,6 +627,7 @@ function openMainWindow(): BrowserWindow { connectionId, receivedAt, stateStartedAt, + ...(providerSession ? { providerSession } : {}), ...(orchestration ? { orchestration } : {}) }) recordAgentStateCrashBreadcrumb(payload.agentType ?? 'unknown', payload.state) diff --git a/src/main/ipc/linear.ts b/src/main/ipc/linear.ts index fbce759cea4..1cf04dbb558 100644 --- a/src/main/ipc/linear.ts +++ b/src/main/ipc/linear.ts @@ -13,6 +13,7 @@ import { getIssueComments } from '../linear/issues' import { + createProject, getCustomView, getProject, listCustomViewIssues, @@ -56,6 +57,29 @@ function normalizeCustomViewModel(value: unknown): LinearCustomViewModel { return value } +function normalizeIdList(value: unknown, fieldName: string): string[] | undefined { + if (value === undefined) { + return undefined + } + if ( + !Array.isArray(value) || + !value.every((id): id is string => typeof id === 'string' && Boolean(id.trim())) + ) { + throw new Error(`Invalid ${fieldName}`) + } + return value.map((id) => id.trim()) +} + +function normalizeOptionalDate(value: unknown, fieldName: string): string | undefined { + if (value === undefined || value === null || value === '') { + return undefined + } + if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value.trim())) { + throw new Error(`Invalid ${fieldName}`) + } + return value.trim() +} + export function registerLinearHandlers(): void { ipcMain.handle('linear:connect', async (_event, args: { apiKey: string }) => { if (typeof args?.apiKey !== 'string' || !args.apiKey.trim()) { @@ -284,6 +308,72 @@ export function registerLinearHandlers(): void { } ) + ipcMain.handle( + 'linear:createProject', + async ( + _event, + args: { + name: string + description?: string + content?: string + teamIds?: string[] + leadId?: string | null + memberIds?: string[] + labelIds?: string[] + priority?: number + startDate?: string + targetDate?: string + workspaceId?: string + } + ) => { + if (typeof args?.name !== 'string' || !args.name.trim()) { + return { ok: false, error: 'Project name is required' } + } + let teamIds: string[] + try { + teamIds = normalizeIdList(args.teamIds, 'team IDs') ?? [] + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : 'Invalid team IDs' } + } + if (teamIds.length === 0) { + return { ok: false, error: 'At least one team is required' } + } + if ( + args.priority !== undefined && + (!Number.isInteger(args.priority) || args.priority < 0 || args.priority > 4) + ) { + return { ok: false, error: 'Invalid priority' } + } + let memberIds: string[] | undefined + let labelIds: string[] | undefined + let startDate: string | undefined + let targetDate: string | undefined + try { + memberIds = normalizeIdList(args.memberIds, 'member IDs') + labelIds = normalizeIdList(args.labelIds, 'label IDs') + startDate = normalizeOptionalDate(args.startDate, 'start date') + targetDate = normalizeOptionalDate(args.targetDate, 'target date') + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : 'Invalid project' } + } + return createProject( + { + name: args.name.trim(), + description: args.description?.trim() || undefined, + content: args.content?.trim() || undefined, + teamIds, + leadId: normalizeWorkspaceId(args.leadId), + memberIds, + labelIds, + priority: typeof args.priority === 'number' ? args.priority : undefined, + startDate, + targetDate + }, + normalizeWorkspaceId(args.workspaceId) + ) + } + ) + ipcMain.handle( 'linear:getProject', async (_event, args: { id: string; workspaceId?: string; force?: boolean }) => { diff --git a/src/main/ipc/worktree-push-target-cleanup.test.ts b/src/main/ipc/worktree-push-target-cleanup.test.ts new file mode 100644 index 00000000000..583e3c174a7 --- /dev/null +++ b/src/main/ipc/worktree-push-target-cleanup.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it, vi, type Mock } from 'vitest' +import type { GitPushTarget, WorktreeMeta } from '../../shared/types' +import { + cleanupUnusedWorktreePushTargetRemoteWithExec, + sameGitHubRemoteUrl, + type GitRemoteExec, + type WorktreePushTargetStore +} from './worktree-push-target-cleanup' + +type ExecMock = Mock + +const REPO_PATH = '/repo-root' +const FORK_URL = 'git@github.com:contributor/orca.git' +const FORK_REMOTE = 'pr-contributor-orca' + +function forkTarget(overrides: Partial = {}): GitPushTarget { + return { + remoteName: FORK_REMOTE, + branchName: 'contributor/fix', + remoteUrl: FORK_URL, + remoteCreated: true, + ...overrides + } +} + +// Why: cleanup only reads meta.pushTarget, so the rest of WorktreeMeta is irrelevant. +function metaWith(pushTarget: GitPushTarget | undefined): WorktreeMeta { + return { pushTarget } as unknown as WorktreeMeta +} + +function storeOf(entries: Record): WorktreePushTargetStore { + const meta: Record = {} + for (const [id, pushTarget] of Object.entries(entries)) { + meta[id] = metaWith(pushTarget) + } + return { getAllWorktreeMeta: () => meta } +} + +type ExecScript = { + branchConfig?: string + getUrl?: string + getUrlThrows?: boolean +} + +function makeExec(script: ExecScript = {}): ExecMock { + const { branchConfig = '', getUrl = FORK_URL, getUrlThrows = false } = script + return vi.fn(async (args: string[]) => { + if (args[0] === 'config') { + return { stdout: branchConfig, stderr: '' } + } + if (args[0] === 'remote' && args[1] === 'get-url') { + if (getUrlThrows) { + throw new Error('No such remote') + } + return { stdout: `${getUrl}\n`, stderr: '' } + } + if (args[0] === 'remote' && args[1] === 'remove') { + return { stdout: '', stderr: '' } + } + return { stdout: '', stderr: '' } + }) +} + +function removeCalls(exec: ExecMock): string[][] { + return exec.mock.calls + .map(([args]) => args) + .filter((args) => args[0] === 'remote' && args[1] === 'remove') +} + +describe('cleanupUnusedWorktreePushTargetRemoteWithExec', () => { + it('removes an Orca-created fork remote that nothing else uses', async () => { + const exec = makeExec() + await cleanupUnusedWorktreePushTargetRemoteWithExec( + REPO_PATH, + 'repo-1::/wt/a', + forkTarget(), + storeOf({ 'repo-1::/wt/a': forkTarget() }), + exec + ) + expect(removeCalls(exec)).toEqual([['remote', 'remove', FORK_REMOTE]]) + }) + + it('keeps a remote Orca did not create (remoteCreated falsy)', async () => { + const exec = makeExec() + await cleanupUnusedWorktreePushTargetRemoteWithExec( + REPO_PATH, + 'repo-1::/wt/a', + forkTarget({ remoteCreated: false }), + storeOf({ 'repo-1::/wt/a': forkTarget({ remoteCreated: false }) }), + exec + ) + expect(removeCalls(exec)).toEqual([]) + // No probing at all when we won't act. + expect(exec).not.toHaveBeenCalled() + }) + + it('never touches origin or upstream', async () => { + for (const remoteName of ['origin', 'upstream']) { + const exec = makeExec() + await cleanupUnusedWorktreePushTargetRemoteWithExec( + REPO_PATH, + 'repo-1::/wt/a', + forkTarget({ remoteName }), + storeOf({}), + exec + ) + expect(removeCalls(exec)).toEqual([]) + } + }) + + it('skips when the target has no remoteUrl', async () => { + const exec = makeExec() + await cleanupUnusedWorktreePushTargetRemoteWithExec( + REPO_PATH, + 'repo-1::/wt/a', + forkTarget({ remoteUrl: undefined }), + storeOf({}), + exec + ) + expect(exec).not.toHaveBeenCalled() + }) + + it('keeps the remote when another worktree in the same repo uses the same remote name (multi-fork)', async () => { + const exec = makeExec() + await cleanupUnusedWorktreePushTargetRemoteWithExec( + REPO_PATH, + 'repo-1::/wt/a', + forkTarget(), + storeOf({ + 'repo-1::/wt/a': forkTarget(), + 'repo-1::/wt/b': forkTarget({ branchName: 'contributor/other' }) + }), + exec + ) + expect(removeCalls(exec)).toEqual([]) + }) + + it('keeps the remote when another worktree points at the same fork via a differently-named remote', async () => { + const exec = makeExec() + await cleanupUnusedWorktreePushTargetRemoteWithExec( + REPO_PATH, + 'repo-1::/wt/a', + forkTarget(), + storeOf({ + 'repo-1::/wt/a': forkTarget(), + // Same fork URL (https form), different sanitized remote name. + 'repo-1::/wt/b': forkTarget({ + remoteName: 'fork-2', + remoteUrl: 'https://github.com/contributor/orca.git' + }) + }), + exec + ) + expect(removeCalls(exec)).toEqual([]) + }) + + it('removes the remote even if a same-named remote exists in a DIFFERENT repo (remotes are repo-local)', async () => { + const exec = makeExec() + await cleanupUnusedWorktreePushTargetRemoteWithExec( + REPO_PATH, + 'repo-1::/wt/a', + forkTarget(), + storeOf({ + 'repo-1::/wt/a': forkTarget(), + 'repo-2::/wt/c': forkTarget() + }), + exec + ) + expect(removeCalls(exec)).toEqual([['remote', 'remove', FORK_REMOTE]]) + }) + + it('keeps the remote when a branch config still tracks it', async () => { + const exec = makeExec({ + branchConfig: `branch.contributor/fix.remote ${FORK_REMOTE}` + }) + await cleanupUnusedWorktreePushTargetRemoteWithExec( + REPO_PATH, + 'repo-1::/wt/a', + forkTarget(), + storeOf({ 'repo-1::/wt/a': forkTarget() }), + exec + ) + expect(removeCalls(exec)).toEqual([]) + }) + + it('keeps the remote when its URL no longer matches the fork (repurposed by the user)', async () => { + const exec = makeExec({ getUrl: 'git@github.com:someone-else/orca.git' }) + await cleanupUnusedWorktreePushTargetRemoteWithExec( + REPO_PATH, + 'repo-1::/wt/a', + forkTarget(), + storeOf({ 'repo-1::/wt/a': forkTarget() }), + exec + ) + expect(removeCalls(exec)).toEqual([]) + }) + + it('does nothing when the remote is already gone (get-url throws)', async () => { + const exec = makeExec({ getUrlThrows: true }) + await cleanupUnusedWorktreePushTargetRemoteWithExec( + REPO_PATH, + 'repo-1::/wt/a', + forkTarget(), + storeOf({ 'repo-1::/wt/a': forkTarget() }), + exec + ) + expect(removeCalls(exec)).toEqual([]) + }) +}) + +describe('sameGitHubRemoteUrl', () => { + it('matches SSH and HTTPS forms of the same GitHub fork', () => { + expect( + sameGitHubRemoteUrl( + 'git@github.com:contributor/orca.git', + 'https://github.com/contributor/orca.git' + ) + ).toBe(true) + }) + + it('is case-insensitive on owner/repo', () => { + expect( + sameGitHubRemoteUrl( + 'git@github.com:Contributor/Orca.git', + 'git@github.com:contributor/orca.git' + ) + ).toBe(true) + }) + + it('does not match different forks', () => { + expect( + sameGitHubRemoteUrl( + 'git@github.com:contributor/orca.git', + 'git@github.com:someone-else/orca.git' + ) + ).toBe(false) + }) + + it('falls back to exact equality for non-GitHub hosts', () => { + expect( + sameGitHubRemoteUrl( + 'git@gitlab.com:contributor/orca.git', + 'git@gitlab.com:contributor/orca.git' + ) + ).toBe(true) + expect( + sameGitHubRemoteUrl( + 'git@gitlab.com:contributor/orca.git', + 'https://gitlab.com/contributor/orca.git' + ) + ).toBe(false) + }) +}) diff --git a/src/main/ipc/worktree-push-target-cleanup.ts b/src/main/ipc/worktree-push-target-cleanup.ts new file mode 100644 index 00000000000..b941efb9d22 --- /dev/null +++ b/src/main/ipc/worktree-push-target-cleanup.ts @@ -0,0 +1,115 @@ +// Why: fork-PR worktrees can add a contributor's fork as a git remote. When such +// a worktree is deleted we prune that remote, but only when it's truly unused. +// This module holds that decision logic behind an injectable `execGit` boundary so +// the multi-fork cleanup matrix is unit-testable without a real repo. + +import type { Store } from '../persistence' +import type { GitPushTarget } from '../../shared/types' +import { parseGitHubOwnerRepo } from '../github/gh-utils' +import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' + +export type GitRemoteExec = ( + args: string[], + cwd: string +) => Promise<{ stdout: string; stderr?: string }> +export type WorktreePushTargetStore = Pick + +export function sameGitHubRemoteUrl(left: string, right: string): boolean { + if (left === right) { + return true + } + const parsedLeft = parseGitHubOwnerRepo(left) + const parsedRight = parseGitHubOwnerRepo(right) + return Boolean( + parsedLeft && + parsedRight && + parsedLeft.owner.toLowerCase() === parsedRight.owner.toLowerCase() && + parsedLeft.repo.toLowerCase() === parsedRight.repo.toLowerCase() + ) +} + +function isPushTargetUsedByAnotherWorktree( + store: WorktreePushTargetStore, + removedWorktreeId: string, + target: GitPushTarget +): boolean { + const removedRepoId = getRepoIdFromWorktreeId(removedWorktreeId) + return Object.entries(store.getAllWorktreeMeta()).some(([worktreeId, meta]) => { + // Why: git remotes are repo-local; matching metadata from another repo + // must not pin this repo's fork remote forever. + const belongsToSameRepo = getRepoIdFromWorktreeId(worktreeId) === removedRepoId + if (worktreeId === removedWorktreeId || !belongsToSameRepo || !meta.pushTarget) { + return false + } + const otherRemoteUrl = meta.pushTarget.remoteUrl + const targetRemoteUrl = target.remoteUrl + return ( + meta.pushTarget.remoteName === target.remoteName || + (typeof otherRemoteUrl === 'string' && + typeof targetRemoteUrl === 'string' && + sameGitHubRemoteUrl(otherRemoteUrl, targetRemoteUrl)) + ) + }) +} + +async function hasBranchConfigUsingRemote( + execGit: GitRemoteExec, + repoPath: string, + target: GitPushTarget +): Promise { + try { + const { stdout } = await execGit( + ['config', '--get-regexp', '^branch\\..*\\.(remote|pushRemote)$'], + repoPath + ) + return stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .some((line) => { + const value = line.split(/\s+/).slice(1).join(' ') + return value === target.remoteName || value === target.remoteUrl + }) + } catch { + return false + } +} + +// Exported for unit tests: the `execGit` seam lets tests drive the multi-fork +// cleanup matrix without touching a real repo. +export async function cleanupUnusedWorktreePushTargetRemoteWithExec( + repoPath: string, + removedWorktreeId: string, + target: GitPushTarget | undefined, + store: WorktreePushTargetStore, + execGit: GitRemoteExec +): Promise { + if ( + !target?.remoteCreated || + !target.remoteUrl || + target.remoteName === 'origin' || + target.remoteName === 'upstream' + ) { + return + } + if (isPushTargetUsedByAnotherWorktree(store, removedWorktreeId, target)) { + return + } + if (await hasBranchConfigUsingRemote(execGit, repoPath, target)) { + return + } + + let configuredRemoteUrl: string + try { + configuredRemoteUrl = ( + await execGit(['remote', 'get-url', target.remoteName], repoPath) + ).stdout.trim() + } catch { + return + } + if (!sameGitHubRemoteUrl(configuredRemoteUrl, target.remoteUrl)) { + return + } + + await execGit(['remote', 'remove', target.remoteName], repoPath) +} diff --git a/src/main/ipc/worktree-push-target-setup.test.ts b/src/main/ipc/worktree-push-target-setup.test.ts new file mode 100644 index 00000000000..429c660e6e9 --- /dev/null +++ b/src/main/ipc/worktree-push-target-setup.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it, vi, type Mock } from 'vitest' +import type { GitPushTarget } from '../../shared/types' +import type { GitRemoteExec } from './worktree-push-target-cleanup' +import { + configureCreatedWorktreePushTargetWithExec, + ensureUniqueRemoteName, + findRemoteForUrl, + prepareWorktreePushTargetWithExec +} from './worktree-push-target-setup' + +type ExecMock = Mock + +const REPO = '/repo-root' +const FORK_SSH = 'git@github.com:contributor/orca.git' +const FORK_HTTPS = 'https://github.com/contributor/orca.git' + +// A stateful fake git: `remotes` maps name -> url. `remote add` mutates it so +// later lookups see the new remote, matching real git behavior. +function makeRepoExec(remotes: Record): ExecMock { + return vi.fn(async (args: string[]) => { + if (args[0] === 'remote' && args.length === 1) { + return { stdout: Object.keys(remotes).join('\n'), stderr: '' } + } + if (args[0] === 'remote' && args[1] === 'get-url') { + const url = remotes[args[2]!] + if (!url) { + throw new Error(`No such remote ${args[2]}`) + } + return { stdout: `${url}\n`, stderr: '' } + } + if (args[0] === 'remote' && args[1] === 'add') { + remotes[args[2]!] = args[3]! + return { stdout: '', stderr: '' } + } + return { stdout: '', stderr: '' } + }) +} + +function callsMatching(exec: ExecMock, head: string[]): string[][] { + return exec.mock.calls + .map(([args]) => args) + .filter((args) => head.every((part, i) => args[i] === part)) +} + +function forkTarget(overrides: Partial = {}): GitPushTarget { + return { + remoteName: 'pr-contributor-orca', + branchName: 'contributor/fix', + remoteUrl: FORK_SSH, + ...overrides + } +} + +describe('prepareWorktreePushTargetWithExec', () => { + it('adds a new fork remote and fetches its head when none matches', async () => { + const exec = makeRepoExec({ origin: 'git@github.com:stablyai/orca.git' }) + + const result = await prepareWorktreePushTargetWithExec(exec, REPO, forkTarget(), () => false) + + expect(callsMatching(exec, ['remote', 'add'])).toEqual([ + ['remote', 'add', 'pr-contributor-orca', FORK_SSH] + ]) + expect(callsMatching(exec, ['fetch'])).toEqual([ + [ + 'fetch', + 'pr-contributor-orca', + '+refs/heads/contributor/fix:refs/remotes/pr-contributor-orca/contributor/fix' + ] + ]) + expect(result).toEqual({ + remoteName: 'pr-contributor-orca', + branchName: 'contributor/fix', + remoteUrl: FORK_SSH, + remoteCreated: true + }) + }) + + it('reuses an existing remote pointing at the same fork (SSH vs HTTPS) without adding', async () => { + const exec = makeRepoExec({ + origin: 'git@github.com:stablyai/orca.git', + 'pr-contributor-orca': FORK_HTTPS + }) + + const result = await prepareWorktreePushTargetWithExec(exec, REPO, forkTarget(), () => false) + + expect(callsMatching(exec, ['remote', 'add'])).toEqual([]) + expect(callsMatching(exec, ['fetch'])).toEqual([ + [ + 'fetch', + 'pr-contributor-orca', + '+refs/heads/contributor/fix:refs/remotes/pr-contributor-orca/contributor/fix' + ] + ]) + // remoteCreated omitted because the predicate says no known worktree owns it. + expect(result).toEqual({ + remoteName: 'pr-contributor-orca', + branchName: 'contributor/fix', + remoteUrl: FORK_SSH + }) + }) + + it('inherits remoteCreated when the predicate says a known worktree created the reused remote', async () => { + const exec = makeRepoExec({ 'fork-x': FORK_HTTPS }) + + const result = await prepareWorktreePushTargetWithExec(exec, REPO, forkTarget(), () => true) + + expect(result.remoteName).toBe('fork-x') + expect(result.remoteCreated).toBe(true) + }) + + it('disambiguates with a numeric suffix when the preferred remote name is taken by a different URL', async () => { + const exec = makeRepoExec({ 'pr-contributor-orca': 'git@github.com:someone-else/orca.git' }) + + const result = await prepareWorktreePushTargetWithExec(exec, REPO, forkTarget(), () => false) + + expect(callsMatching(exec, ['remote', 'add'])).toEqual([ + ['remote', 'add', 'pr-contributor-orca-2', FORK_SSH] + ]) + expect(result.remoteName).toBe('pr-contributor-orca-2') + expect(result.remoteCreated).toBe(true) + }) + + it('strips an incoming remoteCreated flag and fetches the given remote when there is no remoteUrl', async () => { + const exec = makeRepoExec({ origin: 'git@github.com:stablyai/orca.git' }) + + const result = await prepareWorktreePushTargetWithExec( + exec, + REPO, + { remoteName: 'origin', branchName: 'feature', remoteCreated: true }, + () => false + ) + + expect(callsMatching(exec, ['remote', 'add'])).toEqual([]) + expect(callsMatching(exec, ['fetch'])).toEqual([ + ['fetch', 'origin', '+refs/heads/feature:refs/remotes/origin/feature'] + ]) + expect(result).toEqual({ remoteName: 'origin', branchName: 'feature' }) + }) +}) + +describe('findRemoteForUrl', () => { + it('matches by GitHub owner/repo across URL protocols', async () => { + const exec = makeRepoExec({ + origin: 'git@github.com:stablyai/orca.git', + fork: FORK_SSH + }) + await expect(findRemoteForUrl(exec, REPO, FORK_HTTPS)).resolves.toBe('fork') + }) + + it('returns null when no remote points at the fork', async () => { + const exec = makeRepoExec({ origin: 'git@github.com:stablyai/orca.git' }) + await expect(findRemoteForUrl(exec, REPO, FORK_SSH)).resolves.toBeNull() + }) +}) + +describe('ensureUniqueRemoteName', () => { + it('returns the preferred name when it is free', async () => { + const exec = makeRepoExec({ origin: 'x' }) + await expect(ensureUniqueRemoteName(exec, REPO, 'fork')).resolves.toBe('fork') + }) + + it('suffixes past taken names', async () => { + const exec = makeRepoExec({ fork: 'x', 'fork-2': 'y' }) + await expect(ensureUniqueRemoteName(exec, REPO, 'fork')).resolves.toBe('fork-3') + }) +}) + +describe('configureCreatedWorktreePushTargetWithExec', () => { + it('points the new branch upstream at the fork remote', async () => { + const exec = makeRepoExec({}) + const target = forkTarget() + + const result = await configureCreatedWorktreePushTargetWithExec( + exec, + '/wt/path', + 'local-branch', + target + ) + + expect(exec).toHaveBeenCalledWith( + ['branch', '--set-upstream-to', 'pr-contributor-orca/contributor/fix', 'local-branch'], + '/wt/path' + ) + expect(result).toBe(target) + }) +}) diff --git a/src/main/ipc/worktree-push-target-setup.ts b/src/main/ipc/worktree-push-target-setup.ts new file mode 100644 index 00000000000..e6a99ab4443 --- /dev/null +++ b/src/main/ipc/worktree-push-target-setup.ts @@ -0,0 +1,124 @@ +// Why: preparing a fork-PR push target means adding (or reusing) the contributor's +// fork as a git remote, fetching the head, and wiring the new branch's upstream. +// The git-driven core lives here behind an injectable `execGit` seam so the +// remote-reuse / unique-naming / fetch behavior is unit-testable without a real +// repo. The store-aware ownership decision stays with the caller via a predicate. + +import type { GitPushTarget } from '../../shared/types' +import { parseGitHubOwnerRepo } from '../github/gh-utils' +import type { GitRemoteExec } from './worktree-push-target-cleanup' + +export async function findRemoteForUrl( + execGit: GitRemoteExec, + repoPath: string, + remoteUrl: string +): Promise { + const target = parseGitHubOwnerRepo(remoteUrl) + try { + const { stdout } = await execGit(['remote'], repoPath) + for (const remote of stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean)) { + try { + const { stdout: urlStdout } = await execGit(['remote', 'get-url', remote], repoPath) + const candidateUrl = urlStdout.trim() + const candidate = parseGitHubOwnerRepo(candidateUrl) + if ( + target && + candidate && + target.owner.toLowerCase() === candidate.owner.toLowerCase() && + target.repo.toLowerCase() === candidate.repo.toLowerCase() + ) { + return remote + } + if (candidateUrl === remoteUrl) { + return remote + } + } catch { + // Ignore a remote that disappeared or has no fetch URL. + } + } + } catch { + return null + } + return null +} + +export async function ensureUniqueRemoteName( + execGit: GitRemoteExec, + repoPath: string, + preferred: string +): Promise { + const { stdout } = await execGit(['remote'], repoPath) + const existing = new Set( + stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + ) + if (!existing.has(preferred)) { + return preferred + } + for (let suffix = 2; suffix < 100; suffix += 1) { + const candidate = `${preferred}-${suffix}` + if (!existing.has(candidate)) { + return candidate + } + } + throw new Error(`Could not find an available remote name for ${preferred}.`) +} + +// Exported for unit tests: the `execGit` seam drives the remote add/reuse/fetch +// behavior without a real repo. `isRemoteCreatedByKnownWorktree` lets the caller +// inject the store-aware ownership decision for the reuse case. +export async function prepareWorktreePushTargetWithExec( + execGit: GitRemoteExec, + repoPath: string, + target: GitPushTarget, + isRemoteCreatedByKnownWorktree: (existingRemote: string) => boolean +): Promise { + const { remoteCreated: _ignoredRemoteCreated, ...sanitizedTarget } = target + let remoteName = target.remoteName + let remoteCreated = false + if (target.remoteUrl) { + const existingRemote = await findRemoteForUrl(execGit, repoPath, target.remoteUrl) + if (existingRemote) { + remoteName = existingRemote + // Why: if a later PR worktree reuses an Orca-created fork remote, it + // must inherit ownership so deleting the final user can remove it. + remoteCreated = isRemoteCreatedByKnownWorktree(existingRemote) + } else { + remoteName = await ensureUniqueRemoteName(execGit, repoPath, target.remoteName) + await execGit(['remote', 'add', remoteName, target.remoteUrl], repoPath) + remoteCreated = true + } + } + + await execGit( + [ + 'fetch', + remoteName, + `+refs/heads/${target.branchName}:refs/remotes/${remoteName}/${target.branchName}` + ], + repoPath + ) + return { + ...sanitizedTarget, + remoteName, + ...(remoteCreated ? { remoteCreated: true } : {}) + } +} + +export async function configureCreatedWorktreePushTargetWithExec( + execGit: GitRemoteExec, + worktreePath: string, + branchName: string, + target: GitPushTarget +): Promise { + await execGit( + ['branch', '--set-upstream-to', `${target.remoteName}/${target.branchName}`, branchName], + worktreePath + ) + return target +} diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 0211096db4c..4237c1a9e95 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -67,6 +67,15 @@ import { areWorktreePathsEqual } from './worktree-logic' import { getRepoIdFromWorktreeId } from '../../shared/worktree-id' +import { + cleanupUnusedWorktreePushTargetRemoteWithExec, + sameGitHubRemoteUrl, + type WorktreePushTargetStore +} from './worktree-push-target-cleanup' +import { + configureCreatedWorktreePushTargetWithExec, + prepareWorktreePushTargetWithExec +} from './worktree-push-target-setup' import { invalidateAuthorizedRootsCache, isENOENT } from './filesystem-auth' import { createWorktreeSymlinks } from './worktree-symlinks' import { normalizeSparseDirectories } from './sparse-checkout-directories' @@ -350,41 +359,6 @@ async function unsetRemoteWorktreeCreationBase( } } -async function findRemoteForUrl(repoPath: string, remoteUrl: string): Promise { - const target = parseGitHubOwnerRepo(remoteUrl) - try { - const { stdout } = await gitExecFileAsync(['remote'], { cwd: repoPath }) - for (const remote of stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean)) { - try { - const { stdout: urlStdout } = await gitExecFileAsync(['remote', 'get-url', remote], { - cwd: repoPath - }) - const candidateUrl = urlStdout.trim() - const candidate = parseGitHubOwnerRepo(candidateUrl) - if ( - target && - candidate && - target.owner.toLowerCase() === candidate.owner.toLowerCase() && - target.repo.toLowerCase() === candidate.repo.toLowerCase() - ) { - return remote - } - if (candidateUrl === remoteUrl) { - return remote - } - } catch { - // Ignore a remote that disappeared or has no fetch URL. - } - } - } catch { - return null - } - return null -} - async function resolveCreateBranchName( repoPath: string, branchNameOverride: string | undefined, @@ -616,26 +590,6 @@ async function remotePathExists( } } -async function ensureUniqueRemoteName(repoPath: string, preferred: string): Promise { - const { stdout } = await gitExecFileAsync(['remote'], { cwd: repoPath }) - const existing = new Set( - stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - ) - if (!existing.has(preferred)) { - return preferred - } - for (let suffix = 2; suffix < 100; suffix += 1) { - const candidate = `${preferred}-${suffix}` - if (!existing.has(candidate)) { - return candidate - } - } - throw new Error(`Could not find an available remote name for ${preferred}.`) -} - export async function prepareWorktreePushTarget( repoPath: string, target: GitPushTarget, @@ -643,86 +597,19 @@ export async function prepareWorktreePushTarget( repoId?: string ): Promise { await validateGitPushTarget(repoPath, target) - const { remoteCreated: _ignoredRemoteCreated, ...sanitizedTarget } = target - let remoteName = target.remoteName - let remoteCreated = false - if (target.remoteUrl) { - const existingRemote = await findRemoteForUrl(repoPath, target.remoteUrl) - if (existingRemote) { - remoteName = existingRemote - // Why: if a later PR worktree reuses an Orca-created fork remote, it - // must inherit ownership so deleting the final user can remove it. - remoteCreated = store + return prepareWorktreePushTargetWithExec( + (args, cwd) => gitExecFileAsync(args, { cwd }), + repoPath, + target, + (existingRemote) => + store ? isPushTargetRemoteCreatedByKnownWorktree( store, - { - ...target, - remoteName: existingRemote - }, + { ...target, remoteName: existingRemote }, repoId ) : false - } else { - remoteName = await ensureUniqueRemoteName(repoPath, target.remoteName) - await gitExecFileAsync(['remote', 'add', remoteName, target.remoteUrl], { cwd: repoPath }) - remoteCreated = true - } - } - - await gitExecFileAsync( - [ - 'fetch', - remoteName, - `+refs/heads/${target.branchName}:refs/remotes/${remoteName}/${target.branchName}` - ], - { cwd: repoPath } ) - return { - ...sanitizedTarget, - remoteName, - ...(remoteCreated ? { remoteCreated: true } : {}) - } -} - -type GitRemoteExec = (args: string[], cwd: string) => Promise<{ stdout: string; stderr?: string }> -type WorktreePushTargetStore = Pick - -function sameGitHubRemoteUrl(left: string, right: string): boolean { - if (left === right) { - return true - } - const parsedLeft = parseGitHubOwnerRepo(left) - const parsedRight = parseGitHubOwnerRepo(right) - return Boolean( - parsedLeft && - parsedRight && - parsedLeft.owner.toLowerCase() === parsedRight.owner.toLowerCase() && - parsedLeft.repo.toLowerCase() === parsedRight.repo.toLowerCase() - ) -} - -function isPushTargetUsedByAnotherWorktree( - store: WorktreePushTargetStore, - removedWorktreeId: string, - target: GitPushTarget -): boolean { - const removedRepoId = getRepoIdFromWorktreeId(removedWorktreeId) - return Object.entries(store.getAllWorktreeMeta()).some(([worktreeId, meta]) => { - // Why: git remotes are repo-local; matching metadata from another repo - // must not pin this repo's fork remote forever. - const belongsToSameRepo = getRepoIdFromWorktreeId(worktreeId) === removedRepoId - if (worktreeId === removedWorktreeId || !belongsToSameRepo || !meta.pushTarget) { - return false - } - const otherRemoteUrl = meta.pushTarget.remoteUrl - const targetRemoteUrl = target.remoteUrl - return ( - meta.pushTarget.remoteName === target.remoteName || - (typeof otherRemoteUrl === 'string' && - typeof targetRemoteUrl === 'string' && - sameGitHubRemoteUrl(otherRemoteUrl, targetRemoteUrl)) - ) - }) } function isPushTargetRemoteCreatedByKnownWorktree( @@ -748,66 +635,6 @@ function isPushTargetRemoteCreatedByKnownWorktree( }) } -async function hasBranchConfigUsingRemote( - execGit: GitRemoteExec, - repoPath: string, - target: GitPushTarget -): Promise { - try { - const { stdout } = await execGit( - ['config', '--get-regexp', '^branch\\..*\\.(remote|pushRemote)$'], - repoPath - ) - return stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - .some((line) => { - const value = line.split(/\s+/).slice(1).join(' ') - return value === target.remoteName || value === target.remoteUrl - }) - } catch { - return false - } -} - -async function cleanupUnusedWorktreePushTargetRemoteWithExec( - repoPath: string, - removedWorktreeId: string, - target: GitPushTarget | undefined, - store: WorktreePushTargetStore, - execGit: GitRemoteExec -): Promise { - if ( - !target?.remoteCreated || - !target.remoteUrl || - target.remoteName === 'origin' || - target.remoteName === 'upstream' - ) { - return - } - if (isPushTargetUsedByAnotherWorktree(store, removedWorktreeId, target)) { - return - } - if (await hasBranchConfigUsingRemote(execGit, repoPath, target)) { - return - } - - let configuredRemoteUrl: string - try { - configuredRemoteUrl = ( - await execGit(['remote', 'get-url', target.remoteName], repoPath) - ).stdout.trim() - } catch { - return - } - if (!sameGitHubRemoteUrl(configuredRemoteUrl, target.remoteUrl)) { - return - } - - await execGit(['remote', 'remove', target.remoteName], repoPath) -} - export async function cleanupUnusedWorktreePushTargetRemote( repoPath: string, removedWorktreeId: string, @@ -832,11 +659,12 @@ export async function configureCreatedWorktreePushTarget( branchName: string, target: GitPushTarget ): Promise { - await gitExecFileAsync( - ['branch', '--set-upstream-to', `${target.remoteName}/${target.branchName}`, branchName], - { cwd: worktreePath } + return configureCreatedWorktreePushTargetWithExec( + (args, cwd) => gitExecFileAsync(args, { cwd }), + worktreePath, + branchName, + target ) - return target } async function findRemoteForUrlSsh( diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 1288591c7d3..66ea1295ad3 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -1335,9 +1335,11 @@ describe('registerWorktreeHandlers', () => { it('returns the PR head push target when resolving a fork PR base', async () => { getPullRequestPushTargetMock.mockResolvedValue({ - remoteName: 'pr-prateek-orca', - branchName: 'prateek/fix-sidebar-agents-toggle', - remoteUrl: 'git@github.com:prateek/orca.git' + pushTarget: { + remoteName: 'pr-prateek-orca', + branchName: 'prateek/fix-sidebar-agents-toggle', + remoteUrl: 'git@github.com:prateek/orca.git' + } }) gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { if (args[0] === 'rev-parse') { @@ -1358,6 +1360,8 @@ describe('registerWorktreeHandlers', () => { }) expect(result).toMatchObject({ baseBranch: 'abc123', + headSha: 'abc123', + branchNameOverride: 'prateek/fix-sidebar-agents-toggle', pushTarget: { remoteName: 'pr-prateek-orca', branchName: 'prateek/fix-sidebar-agents-toggle', @@ -1420,7 +1424,11 @@ describe('registerWorktreeHandlers', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['fetch', 'origin', 'refs/pull/1849/head'], { cwd: '/workspace/repo' }) - expect(result).toMatchObject({ baseBranch: 'abc123' }) + expect(result).toEqual({ + baseBranch: 'abc123', + headSha: 'abc123', + branchNameOverride: 'feat/onboarding-model-choice-782' + }) }) it('falls back to refs/pull//head when branch fetch fails for a PR', async () => { @@ -1457,7 +1465,11 @@ describe('registerWorktreeHandlers', () => { expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['fetch', 'origin', 'refs/pull/1849/head'], { cwd: '/workspace/repo' }) - expect(result).toMatchObject({ baseBranch: 'abc123' }) + expect(result).toEqual({ + baseBranch: 'abc123', + headSha: 'abc123', + branchNameOverride: 'feat/onboarding-model-choice-782' + }) }) it('does not fall back to refs/pull//head when branch fetch hits a network failure', async () => { diff --git a/src/main/linear/projects.test.ts b/src/main/linear/projects.test.ts index 12f4a1ab520..c64c23971d0 100644 --- a/src/main/linear/projects.test.ts +++ b/src/main/linear/projects.test.ts @@ -210,6 +210,69 @@ describe('Linear project queries', () => { }) }) + it('creates a project with team metadata and maps the created project', async () => { + rawRequest.mockResolvedValueOnce({ + data: { + projectCreate: { + success: true, + project: { + ...rawProject('project-1'), + description: 'Summary', + content: 'Brief', + priority: 2, + targetDate: '2026-08-01', + teams: { nodes: [{ id: 'team-1', name: 'Team', key: 'TM' }] } + } + } + } + }) + const { createProject } = await import('./projects') + + const result = await createProject( + { + name: 'Roadmap', + description: 'Summary', + content: 'Brief', + teamIds: ['team-1'], + leadId: 'user-1', + memberIds: ['user-1', 'user-2'], + labelIds: ['label-1'], + priority: 2, + startDate: '2026-07-01', + targetDate: '2026-08-01' + }, + 'workspace-1' + ) + + expect(result).toMatchObject({ + ok: true, + project: { + id: 'project-1', + name: 'project-1', + workspaceId: 'workspace-1', + description: 'Summary', + content: 'Brief', + priority: 2, + targetDate: '2026-08-01', + teams: [{ id: 'team-1', name: 'Team', key: 'TM' }] + } + }) + expect(rawRequest.mock.calls[0]?.[1]).toEqual({ + input: { + name: 'Roadmap', + description: 'Summary', + content: 'Brief', + teamIds: ['team-1'], + leadId: 'user-1', + memberIds: ['user-1', 'user-2'], + labelIds: ['label-1'], + priority: 2, + startDate: '2026-07-01', + targetDate: '2026-08-01' + } + }) + }) + it('lets manual custom view list refresh bypass older in-flight reads', async () => { const staleRequest = deferred>() const refreshRequest = deferred>() diff --git a/src/main/linear/projects.ts b/src/main/linear/projects.ts index 5b0a257840c..bb9ff3bf9d0 100644 --- a/src/main/linear/projects.ts +++ b/src/main/linear/projects.ts @@ -160,6 +160,26 @@ type CustomViewConnectionResponse = { | null } +type ProjectMutationResponse = { + projectCreate?: { + success?: boolean | null + project?: LinearProjectNode | null + } | null +} + +export type LinearProjectCreateInput = { + name: string + description?: string + content?: string + teamIds: string[] + leadId?: string + memberIds?: string[] + labelIds?: string[] + priority?: number + startDate?: string + targetDate?: string +} + const ORCA_PROJECT_FIELDS = ` id name @@ -316,6 +336,17 @@ const PROJECT_QUERY = ` } ` +const CREATE_PROJECT_MUTATION = ` + mutation OrcaLinearProjectCreate($input: ProjectCreateInput!) { + projectCreate(input: $input) { + success + project { + ${ORCA_PROJECT_DETAIL_FIELDS} + } + } + } +` + const PROJECT_ISSUES_QUERY = ` query OrcaLinearProjectIssues( $id: String!, @@ -865,6 +896,39 @@ export async function getProject( ) } +export async function createProject( + input: LinearProjectCreateInput, + workspaceId?: string | null +): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> { + const entry = getClients(workspaceId)[0] + if (!entry) { + return { ok: false, error: 'Not connected to Linear' } + } + + await acquire() + try { + const result = await entry.client.client.rawRequest< + ProjectMutationResponse, + LinearRawVariables + >(CREATE_PROJECT_MUTATION, { input }) + const payload = result.data?.projectCreate + const project = payload?.project + if (!payload?.success || !project) { + return { ok: false, error: 'Linear project create failed' } + } + return { ok: true, project: mapProjectDetailForWorkspace(entry, project) } + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + throw error + } + const message = error instanceof Error ? error.message : String(error) + return { ok: false, error: message } + } finally { + release() + } +} + export async function listProjectIssues( projectId: string, limit = 20, diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index a07963b6763..add20ca79e2 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -110,6 +110,16 @@ describe('OpenCode hook plugin source', () => { expect(source).toContain('await post("AskUserQuestion", event.properties || {});') }) + it('forwards sessionID on status and message posts for resume metadata', () => { + const source = _internals.getOpenCodePluginSource() + + expect(source).toContain( + 'await post("MessagePart", { role, text: part.text, messageID: part.messageID, sessionID });' + ) + expect(source).toContain('await setStatus("busy", { sessionID });') + expect(source.match(/await setStatus\("idle", \{ sessionID \}\);/g) ?? []).toHaveLength(2) + }) + it('guards endpoint-file parse warnings with a process-lifetime latch', () => { // Why: ENOENT is the normal pre-install case and must stay silent, but a // malformed/unreadable file (EACCES, EIO, parse error) would otherwise diff --git a/src/main/opencode/hook-service.ts b/src/main/opencode/hook-service.ts index cfb723ede29..ff0d9bde7d8 100644 --- a/src/main/opencode/hook-service.ts +++ b/src/main/opencode/hook-service.ts @@ -312,23 +312,23 @@ function getOpenCodePluginSource(): string { ' if (!part || part.type !== "text" || !part.text) return;', ' const role = messageRoleById.get(part.messageID);', ' if (!role) return;', - ' await post("MessagePart", { role, text: part.text, messageID: part.messageID });', + ' await post("MessagePart", { role, text: part.text, messageID: part.messageID, sessionID });', ' return;', ' }', '', ' if (event.type === "session.idle" || event.type === "session.error") {', - ' await setStatus("idle");', + ' await setStatus("idle", { sessionID });', ' return;', ' }', '', ' if (event.type === "session.status") {', ' const statusType = getStatusType(event);', ' if (statusType === "busy" || statusType === "retry") {', - ' await setStatus("busy");', + ' await setStatus("busy", { sessionID });', ' return;', ' }', ' if (statusType === "idle") {', - ' await setStatus("idle");', + ' await setStatus("idle", { sessionID });', ' }', ' }', ' },', diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 10409df0397..5d618d8d0ea 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -606,6 +606,36 @@ describe('Store', () => { expect(store.getUI().groupBy).toBe('workspace-status') }) + it('defaults projectOrderBy to manual when absent, even with recent sortBy', async () => { + writeDataFile({ + schemaVersion: 1, + ui: { sortBy: 'recent' } + }) + const store = await createStore() + expect(store.getUI().projectOrderBy).toBe('manual') + }) + + it('falls back invalid projectOrderBy to manual', async () => { + writeDataFile({ + schemaVersion: 1, + ui: { projectOrderBy: 'bogus' } + }) + const store = await createStore() + expect(store.getUI().projectOrderBy).toBe('manual') + }) + + it('preserves and round-trips an explicit recent projectOrderBy', async () => { + writeDataFile({ + schemaVersion: 1, + ui: { projectOrderBy: 'recent' } + }) + const store = await createStore() + expect(store.getUI().projectOrderBy).toBe('recent') + + store.updateUI({ projectOrderBy: 'manual' }) + expect(store.getUI().projectOrderBy).toBe('manual') + }) + // ── 2. Load from existing valid file ───────────────────────────────── it('reads repos from an existing data file', async () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 736675dd0cd..fdece6281b2 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -334,6 +334,13 @@ function normalizeSortBy(sortBy: unknown): PersistedState['ui']['sortBy'] { return getDefaultUIState().sortBy } +function normalizeProjectOrderBy(projectOrderBy: unknown): PersistedState['ui']['projectOrderBy'] { + if (projectOrderBy === 'manual' || projectOrderBy === 'recent') { + return projectOrderBy + } + return getDefaultUIState().projectOrderBy +} + function normalizeRightSidebarTab(tab: unknown): PersistedState['ui']['rightSidebarTab'] { if ( tab === 'explorer' || @@ -3116,6 +3123,7 @@ export class Store { ...this.state.ui, groupBy: normalizeGroupBy(this.state.ui?.groupBy), sortBy: normalizeSortBy(this.state.ui?.sortBy), + projectOrderBy: normalizeProjectOrderBy(this.state.ui?.projectOrderBy), rightSidebarTab: normalizeRightSidebarTab(this.state.ui?.rightSidebarTab), worktreeCardProperties: normalizeWorktreeCardProperties( this.state.ui?.worktreeCardProperties @@ -3150,6 +3158,9 @@ export class Store { sortBy: updates.sortBy ? normalizeSortBy(updates.sortBy) : normalizeSortBy(this.state.ui?.sortBy), + projectOrderBy: updates.projectOrderBy + ? normalizeProjectOrderBy(updates.projectOrderBy) + : normalizeProjectOrderBy(this.state.ui?.projectOrderBy), rightSidebarTab: updates.rightSidebarTab !== undefined ? normalizeRightSidebarTab(updates.rightSidebarTab) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 54354fc8335..9976e422e92 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -7020,6 +7020,270 @@ describe('OrcaRuntimeService', () => { }) }) + it('keeps live headless mobile session terminals when a desktop renderer publishes without them', async () => { + const spawn = vi.fn().mockResolvedValue({ id: 'serve-mobile-pty' }) + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + const created = await runtime.createMobileSessionTerminal(`id:${TEST_WORKTREE_ID}`) + + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents: { send: vi.fn() } + }) + runtime.syncWindowGraph(0, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-empty', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(listed.tabs).toEqual([ + expect.objectContaining({ + type: 'terminal', + id: created.tab.id, + parentTabId: created.tab.parentTabId, + leafId: created.tab.leafId, + ptyId: 'serve-mobile-pty', + status: 'ready' + }) + ]) + }) + + it('keeps split sibling headless mobile terminal leaves when a desktop renderer omits them', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.syncWindowGraph(0, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'headless:split-siblings', + snapshotVersion: 1, + activeGroupId: 'headless-group', + activeTabId: 'host-tab::pane:2', + activeTabType: 'terminal', + tabGroups: [ + { + id: 'headless-group', + activeTabId: 'host-tab', + tabOrder: ['host-tab'] + } + ], + tabs: [ + { + type: 'terminal', + id: 'host-tab::pane:1', + parentTabId: 'host-tab', + leafId: 'pane:1', + title: 'left', + isActive: false + }, + { + type: 'terminal', + id: 'host-tab::pane:2', + parentTabId: 'host-tab', + leafId: 'pane:2', + title: 'right', + isActive: true + } + ] + } + ] + }) + + runtime.syncWindowGraph(0, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-empty', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(listed.tabs).toEqual([ + expect.objectContaining({ + type: 'terminal', + id: 'host-tab::pane:1', + parentTabId: 'host-tab', + leafId: 'pane:1' + }), + expect.objectContaining({ + type: 'terminal', + id: 'host-tab::pane:2', + parentTabId: 'host-tab', + leafId: 'pane:2' + }) + ]) + expect(listed.activeTabId).toBe('host-tab::pane:2') + }) + + it('keeps preserved headless mobile session publication epochs idempotent', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.syncWindowGraph(0, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'headless:stable-epoch', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: 'host-tab::pane:1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'host-tab::pane:1', + parentTabId: 'host-tab', + leafId: 'pane:1', + title: 'Terminal', + isActive: true + } + ] + } + ] + }) + + runtime.syncWindowGraph(0, { tabs: [], leaves: [], mobileSessionTabs: [] }) + const firstMerge = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + runtime.syncWindowGraph(0, { tabs: [], leaves: [], mobileSessionTabs: [] }) + const secondMerge = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(secondMerge.publicationEpoch).toBe(firstMerge.publicationEpoch) + expect(secondMerge.publicationEpoch.match(/:headless-merge:/g) ?? []).toHaveLength(1) + }) + + it('hydrates persisted serve-owned mobile session terminals while a renderer is attached', async () => { + const focusTerminal = vi.fn() + const spawn = vi.fn().mockResolvedValue({ id: 'serve-persisted-pty', isReattach: true }) + const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: 'serve-persisted-pty', + worktreeId: TEST_WORKTREE_ID, + title: 'Persisted Mobile Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: 'serve-persisted-pty' }) + } + }) + ) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + spawn, + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [] + }) + runtime.setNotifier({ + worktreesChanged: vi.fn(), + reposChanged: vi.fn(), + activateWorktree: vi.fn(), + createTerminal: vi.fn(), + revealTerminalSession: vi.fn(), + splitTerminal: vi.fn(), + renameTerminal: vi.fn(), + focusTerminal, + closeTerminal: vi.fn(), + sleepWorktree: vi.fn(), + terminalFitOverrideChanged: vi.fn(), + terminalDriverChanged: vi.fn() + }) + electronMocks.BrowserWindow.fromId.mockReturnValue({ + isDestroyed: () => false, + webContents: { send: vi.fn() } + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-empty', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + + const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(listed.tabs).toEqual([ + expect.objectContaining({ + type: 'terminal', + id: `host-tab::${HEADLESS_LEAF_ID}`, + parentTabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + ptyId: 'serve-persisted-pty', + status: 'pending-handle' + }) + ]) + expect(listed.tabGroups?.[0]).toMatchObject({ + activeTabId: 'host-tab', + tabOrder: ['host-tab'] + }) + + const activated = await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab') + + expect(spawn).toHaveBeenCalledWith( + expect.objectContaining({ + tabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + sessionId: 'serve-persisted-pty', + persistHostSessionBinding: true, + worktreeId: TEST_WORKTREE_ID + }) + ) + expect(focusTerminal).not.toHaveBeenCalled() + expect(activated.tabs[0]).toMatchObject({ + type: 'terminal', + parentTabId: 'host-tab', + leafId: HEADLESS_LEAF_ID, + status: 'ready' + }) + }) + it('hydrates legacy persisted terminal tabs without layout entries', async () => { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal({ @@ -7949,6 +8213,28 @@ describe('OrcaRuntimeService', () => { await waitPromise }) + it('does not resolve type-filtered message waiters for unrelated message types', async () => { + const runtime = new OrcaRuntimeService(store) + + const waitPromise = runtime.waitForMessage('term_abc', { + typeFilter: ['worker_done', 'escalation'], + timeoutMs: 5000 + }) + let settled = false + void waitPromise.then(() => { + settled = true + }) + + runtime.notifyMessageArrived('term_abc', 'heartbeat') + await Promise.resolve() + + expect(settled).toBe(false) + + runtime.notifyMessageArrived('term_abc', 'worker_done') + await waitPromise + expect(settled).toBe(true) + }) + it('removes message waiter abort listeners after message arrival', async () => { const runtime = new OrcaRuntimeService(store) const controller = new AbortController() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b4c32688a04..f96a966d691 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -163,6 +163,7 @@ import type { RuntimeMobileSessionTabMove, RuntimeMobileSessionTabMoveResult, RuntimeMobileSessionTabGroup, + RuntimeMobileSessionSnapshotTab, RuntimeMobileSessionTerminalTab, RuntimeMobileSessionTabsRemovedResult, RuntimeMobileSessionTabsResult, @@ -295,13 +296,15 @@ import { type LinearListFilter } from '../linear/issues' import { + createProject as createLinearProject, getCustomView as getLinearCustomView, getProject as getLinearProject, listCustomViewIssues as listLinearCustomViewIssues, listCustomViewProjects as listLinearCustomViewProjects, listCustomViews as listLinearCustomViews, listProjectIssues as listLinearProjectIssues, - listProjects as listLinearProjects + listProjects as listLinearProjects, + type LinearProjectCreateInput } from '../linear/projects' import { getTeamLabels as getLinearTeamLabels, @@ -2142,9 +2145,13 @@ export class OrcaRuntimeService { private hydrateHeadlessMobileSessionTabsFromWorkspaceSession( worktreeId?: string, - options: { force?: boolean } = {} + options: { + force?: boolean + allowAttachedWindow?: boolean + onlyServeOwnedTerminals?: boolean + } = {} ): void { - if (this.getAvailableAuthoritativeWindow()) { + if (this.getAvailableAuthoritativeWindow() && options.allowAttachedWindow !== true) { return } const session = this.store?.getWorkspaceSession?.() @@ -2157,35 +2164,150 @@ export class OrcaRuntimeService { : Object.entries(session.tabsByWorktree ?? {}) for (const [entryWorktreeId, persistedTabs] of entries) { const existing = this.mobileSessionTabsByWorktree.get(entryWorktreeId) - if (existing && existing.tabs.length > 0 && options.force !== true) { + if ( + existing && + existing.tabs.length > 0 && + options.force !== true && + options.onlyServeOwnedTerminals !== true + ) { continue } - const tabs = this.buildHeadlessMobileSessionTerminalTabs(entryWorktreeId, persistedTabs) + const tabs = this.buildHeadlessMobileSessionTerminalTabs( + entryWorktreeId, + persistedTabs + ).filter( + (tab) => options.onlyServeOwnedTerminals !== true || this.hasServeOwnedPtyBinding(tab) + ) if (tabs.length === 0) { continue } const activeTab = this.pickHeadlessActiveTerminalTab(tabs) const tabOrder = this.collectHeadlessParentTabOrder(tabs) const groupId = this.getHeadlessMobileSessionGroupId(entryWorktreeId) + const mergedTabs = + options.onlyServeOwnedTerminals === true && existing + ? this.mergeMobileSessionSnapshotTabs(existing.tabs, tabs) + : tabs + const mergedActiveTab = + existing?.tabs.find((tab) => tab.id === existing.activeTabId) ?? + activeTab ?? + (mergedTabs[0]?.type === 'terminal' ? mergedTabs[0] : null) + const mergedTerminalTabs = mergedTabs.filter( + (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' + ) this.mobileSessionTabsByWorktree.set(entryWorktreeId, { - worktree: entryWorktreeId, + worktree: existing?.worktree ?? entryWorktreeId, publicationEpoch: `headless-hydrated:${Date.now().toString(36)}`, snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, - activeGroupId: groupId, - activeTabId: activeTab?.id ?? null, - activeTabType: activeTab ? 'terminal' : null, - tabGroups: [ - { - id: groupId, - activeTabId: activeTab?.parentTabId ?? tabOrder[0] ?? null, - tabOrder - } - ], - tabs + activeGroupId: existing?.activeGroupId ?? groupId, + activeTabId: mergedActiveTab?.id ?? null, + activeTabType: mergedActiveTab?.type ?? null, + tabGroups: + options.onlyServeOwnedTerminals === true && existing?.tabGroups + ? this.mergeMobileSessionTabGroups( + entryWorktreeId, + existing.tabGroups, + mergedTerminalTabs, + mergedActiveTab?.type === 'terminal' ? mergedActiveTab : null + ) + : [ + { + id: groupId, + activeTabId: activeTab?.parentTabId ?? tabOrder[0] ?? null, + tabOrder + } + ], + tabs: mergedTabs }) } } + private isServeOwnedPtyId(ptyId: string | null | undefined): boolean { + return typeof ptyId === 'string' && ptyId.startsWith('serve-') + } + + private hasServeOwnedPtyBinding(tab: RuntimeMobileSessionTerminalTab): boolean { + if (this.isServeOwnedPtyId(tab.ptyId)) { + return true + } + return Object.values(tab.parentLayout?.ptyIdsByLeafId ?? {}).some((ptyId) => + this.isServeOwnedPtyId(ptyId) + ) + } + + private mergeMobileSessionSnapshotTabs( + baseTabs: readonly RuntimeMobileSessionSnapshotTab[], + extraTabs: readonly RuntimeMobileSessionSnapshotTab[] + ): RuntimeMobileSessionSnapshotTab[] { + const seenIds = new Set() + const merged: RuntimeMobileSessionSnapshotTab[] = [] + const add = (tab: RuntimeMobileSessionSnapshotTab): void => { + const ids = this.getMobileSessionSnapshotTabIdentityKeys(tab) + if (ids.some((id) => seenIds.has(id))) { + return + } + for (const id of ids) { + seenIds.add(id) + } + merged.push(tab) + } + for (const tab of baseTabs) { + add(tab) + } + for (const tab of extraTabs) { + add(tab) + } + return merged + } + + private getMobileSessionSnapshotTabIdentityKeys(tab: RuntimeMobileSessionSnapshotTab): string[] { + if (tab.type === 'terminal') { + // Why: split terminal leaves share one parent tab; merge dedup must stay + // leaf-scoped or preserved siblings collapse into a single surface. + return [tab.id, `${tab.parentTabId}::${tab.leafId}`] + } + if (tab.type === 'browser') { + return [tab.id, tab.browserWorkspaceId] + } + return [tab.id] + } + + private mergeMobileSessionTabGroups( + worktreeId: string, + groups: readonly RuntimeMobileSessionTabGroup[], + terminalTabs: readonly RuntimeMobileSessionTerminalTab[], + activeTab: RuntimeMobileSessionTerminalTab | null + ): RuntimeMobileSessionTabGroup[] { + const parentTabOrder = this.collectHeadlessParentTabOrder(terminalTabs) + if (parentTabOrder.length === 0) { + return [...groups] + } + const targetGroupId = groups[0]?.id ?? this.getHeadlessMobileSessionGroupId(worktreeId) + const nextGroups = + groups.length > 0 + ? groups.map((group) => ({ ...group, tabOrder: [...group.tabOrder] })) + : [ + { + id: targetGroupId, + activeTabId: null, + tabOrder: [] + } + ] + const target = nextGroups[0]! + for (const tabId of parentTabOrder) { + if (!target.tabOrder.includes(tabId)) { + target.tabOrder.push(tabId) + } + } + const activeParentId = + activeTab?.parentTabId ?? target.activeTabId ?? target.tabOrder[0] ?? null + target.activeTabId = + activeParentId && target.tabOrder.includes(activeParentId) + ? activeParentId + : (target.tabOrder[0] ?? null) + return nextGroups + } + private buildHeadlessMobileSessionTerminalTabs( worktreeId: string, persistedTabs: readonly TerminalTab[] @@ -2483,11 +2605,14 @@ export class OrcaRuntimeService { const publicTab = this.toMobileSessionTabsResult(snapshot!).tabs.find( (candidate) => candidate.type === 'terminal' && candidate.id === tab.id ) - if ( + // Why: serve-created tabs can be visible before any renderer has adopted + // their tab id, so focusing the renderer would silently no-op. + const shouldMaterializePendingTerminal = publicTab?.type === 'terminal' && publicTab.status !== 'ready' && - !this.notifier?.focusTerminal - ) { + (!this.notifier?.focusTerminal || + this.shouldMaterializeHeadlessMobileSessionTab(snapshot!, tab)) + if (shouldMaterializePendingTerminal) { const sessionId = tab.ptyId ?? tab.parentLayout?.ptyIdsByLeafId?.[tab.leafId] ?? undefined try { await this.createHeadlessMobileSessionTerminal(worktreeId, true, undefined, undefined, { @@ -2526,6 +2651,16 @@ export class OrcaRuntimeService { return this.getMobileSessionTabsForWorktree(worktreeId) } + private shouldMaterializeHeadlessMobileSessionTab( + snapshot: RuntimeMobileSessionTabsSnapshot, + tab: RuntimeMobileSessionTerminalTab + ): boolean { + return ( + this.isHeadlessMobileSessionPublication(snapshot.publicationEpoch) || + this.hasServeOwnedPtyBinding(tab) + ) + } + async closeMobileSessionTab(worktreeSelector: string, tabId: string): Promise<{ closed: true }> { const explicitWorktreeId = getExplicitWorktreeIdSelector(worktreeSelector) const worktreeId = @@ -12309,26 +12444,177 @@ export class OrcaRuntimeService { if (snapshots === undefined) { return } + // Why: renderer graphs are authoritative for renderer tabs, but headless + // serve terminals never enter that graph unless we preserve their bindings. + this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(undefined, { + allowAttachedWindow: true, + onlyServeOwnedTerminals: true + }) const nextWorktrees = new Set() for (const snapshot of snapshots) { nextWorktrees.add(snapshot.worktree) const existing = this.mobileSessionTabsByWorktree.get(snapshot.worktree) + const nextSnapshot = this.mergePreservedHeadlessMobileSessionTabs(snapshot, existing) if ( !existing || - snapshot.publicationEpoch !== existing.publicationEpoch || - snapshot.snapshotVersion >= existing.snapshotVersion + nextSnapshot.publicationEpoch !== existing.publicationEpoch || + nextSnapshot.snapshotVersion >= existing.snapshotVersion ) { - this.mobileSessionTabsByWorktree.set(snapshot.worktree, snapshot) + this.mobileSessionTabsByWorktree.set(snapshot.worktree, nextSnapshot) } } - for (const worktreeId of this.mobileSessionTabsByWorktree.keys()) { + for (const [worktreeId, existing] of [...this.mobileSessionTabsByWorktree.entries()]) { if (!nextWorktrees.has(worktreeId)) { - this.mobileSessionTabsByWorktree.delete(worktreeId) - this.notifyMobileSessionTabsRemoved(worktreeId) + const preserved = this.buildPreservedHeadlessMobileSessionSnapshot(existing) + if (preserved) { + this.mobileSessionTabsByWorktree.set(worktreeId, preserved) + nextWorktrees.add(worktreeId) + } else { + this.mobileSessionTabsByWorktree.delete(worktreeId) + this.notifyMobileSessionTabsRemoved(worktreeId) + } } } } + private mergePreservedHeadlessMobileSessionTabs( + snapshot: RuntimeMobileSessionTabsSnapshot, + existing: RuntimeMobileSessionTabsSnapshot | undefined + ): RuntimeMobileSessionTabsSnapshot { + if (!existing) { + return snapshot + } + const preservedTabs = this.collectPreservedHeadlessMobileSessionTabs(existing, snapshot) + if (preservedTabs.length === 0) { + return snapshot + } + const hasIncomingActiveTab = snapshot.tabs.some((tab) => tab.isActive) + const normalizedPreservedTabs = preservedTabs.map((tab) => + hasIncomingActiveTab ? { ...tab, isActive: false } : tab + ) + const tabs = this.mergeMobileSessionSnapshotTabs(snapshot.tabs, normalizedPreservedTabs) + if (tabs.length === snapshot.tabs.length) { + return snapshot + } + const activeTab = + snapshot.tabs.find((tab) => tab.id === snapshot.activeTabId) ?? + tabs.find((tab) => tab.id === existing.activeTabId) ?? + tabs.find((tab) => tab.isActive) ?? + tabs[0] ?? + null + const terminalTabs = tabs.filter( + (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' + ) + return { + ...snapshot, + publicationEpoch: this.getMergedMobileSessionPublicationEpoch( + snapshot, + normalizedPreservedTabs + ), + snapshotVersion: Math.max(snapshot.snapshotVersion, existing.snapshotVersion), + activeGroupId: snapshot.activeGroupId ?? existing.activeGroupId, + activeTabId: activeTab?.id ?? null, + activeTabType: activeTab?.type ?? null, + tabGroups: this.mergeMobileSessionTabGroups( + snapshot.worktree, + snapshot.tabGroups ?? existing.tabGroups ?? [], + terminalTabs, + activeTab?.type === 'terminal' ? activeTab : null + ), + tabs + } + } + + private buildPreservedHeadlessMobileSessionSnapshot( + existing: RuntimeMobileSessionTabsSnapshot + ): RuntimeMobileSessionTabsSnapshot | null { + const tabs = this.collectPreservedHeadlessMobileSessionTabs(existing) + if (tabs.length === 0) { + return null + } + const activeTab = + tabs.find((tab) => tab.id === existing.activeTabId) ?? + tabs.find((tab) => tab.isActive) ?? + tabs[0] ?? + null + const terminalTabs = tabs.filter( + (tab): tab is RuntimeMobileSessionTerminalTab => tab.type === 'terminal' + ) + return { + ...existing, + publicationEpoch: this.getMergedMobileSessionPublicationEpoch(existing, tabs), + activeGroupId: + existing.activeGroupId ?? this.getHeadlessMobileSessionGroupId(existing.worktree), + activeTabId: activeTab?.id ?? null, + activeTabType: activeTab?.type ?? null, + tabGroups: this.mergeMobileSessionTabGroups( + existing.worktree, + existing.tabGroups ?? [], + terminalTabs, + activeTab?.type === 'terminal' ? activeTab : null + ), + tabs + } + } + + private collectPreservedHeadlessMobileSessionTabs( + existing: RuntimeMobileSessionTabsSnapshot, + incoming?: RuntimeMobileSessionTabsSnapshot + ): RuntimeMobileSessionSnapshotTab[] { + const incomingIds = new Set( + incoming?.tabs.flatMap((tab) => this.getMobileSessionSnapshotTabIdentityKeys(tab)) ?? [] + ) + return existing.tabs.filter((tab) => { + if (this.getMobileSessionSnapshotTabIdentityKeys(tab).some((id) => incomingIds.has(id))) { + return false + } + return this.shouldPreserveHeadlessMobileSessionTab(existing, tab) + }) + } + + private shouldPreserveHeadlessMobileSessionTab( + snapshot: RuntimeMobileSessionTabsSnapshot, + tab: RuntimeMobileSessionSnapshotTab + ): boolean { + if (tab.type !== 'terminal') { + return false + } + return ( + this.isHeadlessMobileSessionPublication(snapshot.publicationEpoch) || + this.hasServeOwnedPtyBinding(tab) + ) + } + + private isHeadlessMobileSessionPublication(publicationEpoch: string): boolean { + return ( + publicationEpoch.startsWith('headless:') || + publicationEpoch.startsWith('headless-hydrated:') || + publicationEpoch.includes(':headless-merge:') + ) + } + + private getMergedMobileSessionPublicationEpoch( + snapshot: RuntimeMobileSessionTabsSnapshot, + preservedTabs: readonly RuntimeMobileSessionSnapshotTab[] + ): string { + // Why: preserved snapshots can be merged repeatedly; normalize the prior + // merge suffix before recomputing so the publication epoch is idempotent. + const normalizedPublicationEpoch = snapshot.publicationEpoch.split(':headless-merge:')[0] + const signature = createHash('sha1') + .update( + preservedTabs + .map((tab) => + tab.type === 'terminal' + ? `${tab.id}:${tab.parentTabId}:${tab.ptyId ?? ''}:${tab.leafId}` + : tab.id + ) + .join('|') + ) + .digest('hex') + .slice(0, 12) + return `${normalizedPublicationEpoch}:headless-merge:${signature}` + } + private notifyMobileSessionTabsRemoved(worktreeId: string): void { const removed: RuntimeMobileSessionTabsRemovedResult = { worktree: worktreeId, @@ -12876,12 +13162,17 @@ export class OrcaRuntimeService { // Why: after a message is inserted for a recipient, any blocking // orchestration.check --wait calls watching that handle must be woken // so they can return the new message immediately instead of polling. - notifyMessageArrived(handle: string): void { + notifyMessageArrived(handle: string, messageType?: string): void { const waiters = this.messageWaitersByHandle.get(handle) if (!waiters || waiters.size === 0) { return } for (const waiter of [...waiters]) { + // Why: a coordinator waiting for worker_done/escalation should not be + // woken by worker heartbeat noise and mistake that empty read for idleness. + if (messageType && waiter.typeFilter && !waiter.typeFilter.includes(messageType)) { + continue + } this.resolveMessageWaiter(waiter) } } @@ -13630,6 +13921,13 @@ export class OrcaRuntimeService { return listLinearProjects(query, Math.min(Math.max(1, limit), 50), workspaceId, force) } + linearCreateProject( + input: LinearProjectCreateInput, + workspaceId?: string + ): ReturnType { + return createLinearProject(input, workspaceId) + } + linearGetProject( id: string, workspaceId: string, diff --git a/src/main/runtime/rpc/methods/linear-project-create.ts b/src/main/runtime/rpc/methods/linear-project-create.ts new file mode 100644 index 00000000000..026c585aba2 --- /dev/null +++ b/src/main/runtime/rpc/methods/linear-project-create.ts @@ -0,0 +1,41 @@ +import { z } from 'zod' +import { defineMethod, type RpcMethod } from '../core' +import { OptionalString, requiredString } from '../schemas' + +const LinearPriority = z.number().int().min(0).max(4).optional() +const LinearLabelIds = z.array(requiredString('Invalid label ID')).optional() + +const CreateProject = z.object({ + name: requiredString('Project name is required'), + description: OptionalString, + content: OptionalString, + workspaceId: OptionalString, + teamIds: z.array(requiredString('Invalid team ID')).min(1, 'At least one team is required'), + leadId: z.union([z.string(), z.null()]).optional(), + memberIds: z.array(requiredString('Invalid member ID')).optional(), + labelIds: LinearLabelIds, + priority: LinearPriority, + startDate: OptionalString, + targetDate: OptionalString +}) + +export const LINEAR_PROJECT_CREATE_METHOD: RpcMethod = defineMethod({ + name: 'linear.createProject', + params: CreateProject, + handler: async (params, { runtime }) => + runtime.linearCreateProject( + { + name: params.name.trim(), + description: params.description?.trim() || undefined, + content: params.content?.trim() || undefined, + teamIds: params.teamIds.map((id) => id.trim()), + leadId: params.leadId ? params.leadId.trim() : undefined, + memberIds: params.memberIds?.map((id) => id.trim()), + labelIds: params.labelIds?.map((id) => id.trim()), + priority: params.priority, + startDate: params.startDate?.trim() || undefined, + targetDate: params.targetDate?.trim() || undefined + }, + params.workspaceId + ) +}) diff --git a/src/main/runtime/rpc/methods/linear.test.ts b/src/main/runtime/rpc/methods/linear.test.ts index e95fb56681f..f2c68db7801 100644 --- a/src/main/runtime/rpc/methods/linear.test.ts +++ b/src/main/runtime/rpc/methods/linear.test.ts @@ -163,6 +163,7 @@ describe('linear RPC methods', () => { linearListProjectIssues: vi.fn().mockResolvedValue({ items: [{ id: 'issue-2' }] }), linearListTeams: vi.fn().mockResolvedValue([{ id: 'team-1' }]), linearListProjects: vi.fn().mockResolvedValue({ items: [{ id: 'project-1' }] }), + linearCreateProject: vi.fn().mockResolvedValue({ ok: true, project: { id: 'project-3' } }), linearTeamStates: vi.fn().mockResolvedValue([{ id: 'state-1' }]), linearTeamLabels: vi.fn().mockResolvedValue([{ id: 'label-1' }]), linearTeamMembers: vi.fn().mockResolvedValue([{ id: 'member-1' }]) @@ -173,6 +174,21 @@ describe('linear RPC methods', () => { await dispatcher.dispatch( makeRequest('linear.listProjects', { query: 'roadmap', limit: 5, force: true }) ) + await dispatcher.dispatch( + makeRequest('linear.createProject', { + name: 'Roadmap', + description: 'Summary', + content: 'Brief', + teamIds: ['team-1'], + workspaceId: 'workspace-1', + leadId: 'user-1', + memberIds: ['user-1', 'user-2'], + labelIds: ['label-1'], + priority: 2, + startDate: '2026-07-01', + targetDate: '2026-08-01' + }) + ) await dispatcher.dispatch( makeRequest('linear.getProject', { id: 'project-1', @@ -232,6 +248,21 @@ describe('linear RPC methods', () => { expect(runtime.linearListTeams).toHaveBeenCalledWith('all') expect(runtime.linearListProjects).toHaveBeenCalledWith('roadmap', 5, undefined, true) + expect(runtime.linearCreateProject).toHaveBeenCalledWith( + { + name: 'Roadmap', + description: 'Summary', + content: 'Brief', + teamIds: ['team-1'], + leadId: 'user-1', + memberIds: ['user-1', 'user-2'], + labelIds: ['label-1'], + priority: 2, + startDate: '2026-07-01', + targetDate: '2026-08-01' + }, + 'workspace-1' + ) expect(runtime.linearGetProject).toHaveBeenCalledWith('project-1', 'workspace-1', true) expect(runtime.linearListProjectIssues).toHaveBeenCalledWith( 'project-1', diff --git a/src/main/runtime/rpc/methods/linear.ts b/src/main/runtime/rpc/methods/linear.ts index 3153a2c420b..d3581f78310 100644 --- a/src/main/runtime/rpc/methods/linear.ts +++ b/src/main/runtime/rpc/methods/linear.ts @@ -1,6 +1,7 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' +import { LINEAR_PROJECT_CREATE_METHOD } from './linear-project-create' const VALID_FILTERS = ['assigned', 'created', 'all', 'completed'] as const const VALID_CUSTOM_VIEW_MODELS = ['issue', 'project'] as const @@ -222,6 +223,7 @@ export const LINEAR_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.linearListProjects(params?.query, params?.limit, params?.workspaceId, params?.force) }), + LINEAR_PROJECT_CREATE_METHOD, defineMethod({ name: 'linear.getProject', params: ProjectId, diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index d1e07f91353..57687ede314 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -412,6 +412,42 @@ describe('orchestration RPC methods', () => { expect(db.getUnreadMessages('b')).toHaveLength(1) }) + it('keeps waiting for requested types when an unrelated heartbeat arrives', async () => { + setup() + + const waitPromise = call('orchestration.check', { + terminal: 'coord', + wait: true, + timeoutMs: 5000, + types: 'worker_done,escalation' + }) as Promise<{ count: number; messages: { type: string }[] }> + await Promise.resolve() + + await call('orchestration.send', { + from: 'worker', + to: 'coord', + subject: 'alive', + type: 'heartbeat' + }) + + const early = await Promise.race([ + waitPromise.then(() => 'settled'), + Promise.resolve('pending') + ]) + expect(early).toBe('pending') + + await call('orchestration.send', { + from: 'worker', + to: 'coord', + subject: 'done', + type: 'worker_done' + }) + + const result = await waitPromise + expect(result.count).toBe(1) + expect(result.messages[0].type).toBe('worker_done') + }) + it('does not mark existing messages read when the check starts aborted', async () => { setup() const abortController = new AbortController() diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 0f5c76a5664..a72120fc879 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -175,7 +175,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ payload: params.payload }) runtime.deliverPendingMessagesForHandle(params.to) - runtime.notifyMessageArrived(params.to) + runtime.notifyMessageArrived(params.to, msg.type) return { message: msg } } @@ -204,9 +204,9 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ payload: params.payload }) ) - for (const handle of handles) { - runtime.deliverPendingMessagesForHandle(handle) - runtime.notifyMessageArrived(handle) + for (const message of messages) { + runtime.deliverPendingMessagesForHandle(message.to_handle) + runtime.notifyMessageArrived(message.to_handle, message.type) } return { messages, recipients: handles.length } @@ -300,7 +300,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ threadId: original.thread_id ?? original.id }) - runtime.notifyMessageArrived(original.from_handle) + runtime.notifyMessageArrived(original.from_handle, reply.type) return { message: reply } } }), @@ -543,7 +543,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ payload }) runtime.deliverPendingMessagesForHandle(params.to) - runtime.notifyMessageArrived(params.to) + runtime.notifyMessageArrived(params.to, outbound.type) const threadId = outbound.id const deadline = Date.now() + timeoutMs diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index b29f74b4dbb..e692a10f7f4 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -227,6 +227,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'linear.addIssueComment', 'linear.connect', 'linear.createIssue', + 'linear.createProject', 'linear.issueComments', 'linear.listCustomViewIssues', 'linear.listCustomViewProjects', diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index 057e095de12..31b357ca047 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -423,6 +423,7 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { toolUseId: 'toolu-1', toolAgentId: 'agent-subagent-a', toolAgentType: 'Review', + providerSession: { key: 'session_id', id: 'ssh-relay-session-1' }, payload: { state: 'working', prompt: 'remote prompt', @@ -440,7 +441,8 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { promptInteractionKey: 'command-code-transcript-user-3', toolUseId: 'toolu-1', toolAgentId: 'agent-subagent-a', - toolAgentType: 'Review' + toolAgentType: 'Review', + providerSession: { key: 'session_id', id: 'ssh-relay-session-1' } }), 'conn-hook-metadata' ) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 19d94c35710..d9568a3335a 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -738,6 +738,7 @@ export class SshRelaySession { toolAgentId?: unknown toolAgentType?: unknown isReplay?: unknown + providerSession?: unknown payload?: unknown } if (typeof envelope.paneKey !== 'string') { @@ -767,6 +768,7 @@ export class SshRelaySession { toolAgentType: typeof envelope.toolAgentType === 'string' ? envelope.toolAgentType : undefined, isReplay: envelope.isReplay === true ? true : undefined, + providerSession: envelope.providerSession, payload: envelope.payload }, this.targetId diff --git a/src/main/startup/dev-education-suppression.test.ts b/src/main/startup/dev-education-suppression.test.ts index 079ce76342a..ab969b3faf4 100644 --- a/src/main/startup/dev-education-suppression.test.ts +++ b/src/main/startup/dev-education-suppression.test.ts @@ -132,7 +132,7 @@ describe('suppressDevEducationForStore', () => { outcome: 'dismissed', lastCompletedStep: 1 }) - expect(state.ui.featureTipsSeenIds).toEqual(['voice-dictation', 'orca-cli']) + expect(state.ui.featureTipsSeenIds).toEqual(['voice-dictation', 'orca-cli', 'cmd-j-palette']) expect(state.ui.contextualToursSeenIds).toEqual([ 'tasks', ...CONTEXTUAL_TOUR_IDS.filter((id) => id !== 'tasks') diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 582046e333d..23a4c002154 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1453,6 +1453,19 @@ export type PreloadApi = { workspaceId?: LinearWorkspaceSelection force?: boolean }) => Promise> + createProject: (args: { + name: string + description?: string + content?: string + teamIds: string[] + workspaceId?: string + leadId?: string | null + memberIds?: string[] + labelIds?: string[] + priority?: number + startDate?: string + targetDate?: string + }) => Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> getProject: (args: { id: string workspaceId: string diff --git a/src/preload/index.ts b/src/preload/index.ts index c36faca873f..df0e5e8a5c6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -26,6 +26,7 @@ import type { GitUpstreamStatus, GhosttyImportPreview, ListWorkItemsResult, + LinearProjectDetail, MemorySnapshot, NotificationDismissResult, NotificationDispatchResult, @@ -1282,6 +1283,21 @@ const api = { force?: boolean }): Promise => ipcRenderer.invoke('linear:listProjects', args), + createProject: (args: { + name: string + description?: string + content?: string + teamIds: string[] + workspaceId?: string + leadId?: string | null + memberIds?: string[] + labelIds?: string[] + priority?: number + startDate?: string + targetDate?: string + }): Promise<{ ok: true; project: LinearProjectDetail } | { ok: false; error: string }> => + ipcRenderer.invoke('linear:createProject', args), + getProject: (args: { id: string; workspaceId: string; force?: boolean }): Promise => ipcRenderer.invoke('linear:getProject', args), diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index 2dd9c23b0a4..fa404b88f3f 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -297,6 +297,7 @@ export class RelayAgentHookServer { toolUseId: event.toolUseId, toolAgentId: event.toolAgentId, toolAgentType: event.toolAgentType, + ...(event.providerSession ? { providerSession: event.providerSession } : {}), isReplay: options.isReplay === true ? true : undefined, env, version, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4bfa27bb5af..ad7ac871799 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -124,7 +124,10 @@ import { getFeatureTipsAppOpenDecision, isCliFeatureTipCompleted } from './components/feature-tips/feature-tip-startup-gate' -import { trackOrcaCliFeatureTipShown } from './components/feature-tips/feature-tip-telemetry' +import { + trackCmdJPaletteFeatureTipShown, + trackOrcaCliFeatureTipShown +} from './components/feature-tips/feature-tip-telemetry' import { keybindingMatchesAction, type KeybindingActionId, @@ -437,6 +440,7 @@ function App(): React.JSX.Element { const sidebarOpen = useAppStore((s) => s.sidebarOpen) const groupBy = useAppStore((s) => s.groupBy) const sortBy = useAppStore((s) => s.sortBy) + const projectOrderBy = useAppStore((s) => s.projectOrderBy) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace) const showDotfilesByWorktree = useAppStore((s) => s.showDotfilesByWorktree) @@ -578,6 +582,8 @@ function App(): React.JSX.Element { featureTipsPromptedThisSessionRef.current = true if (featureTipsDecision.tipId === 'orca-cli') { trackOrcaCliFeatureTipShown('app_open') + } else if (featureTipsDecision.tipId === 'cmd-j-palette') { + trackCmdJPaletteFeatureTipShown('app_open') } // Why: once a tip is visible, app quit/crash should not make it reappear // on the next launch just because the user never clicked a dismiss button. @@ -1023,6 +1029,7 @@ function App(): React.JSX.Element { rightSidebarWidth, groupBy, sortBy, + projectOrderBy, showActiveOnly: false, hideSleepingWorkspaces: !showSleepingWorkspaces, showSleepingWorkspaces, @@ -1047,6 +1054,7 @@ function App(): React.JSX.Element { rightSidebarWidth, groupBy, sortBy, + projectOrderBy, showSleepingWorkspaces, hideDefaultBranchWorkspace, showDotfilesByWorktree, diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 8e389248460..e7bdeb9704e 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1777,6 +1777,55 @@ border-width: 1.5px; } +/* Cmd+J feature tip — JS-driven sequence (keypress → palette → typing → results). + The keypress press-down and palette entrance are handled inline by React state + transitions; this file only defines the caret blink and the results fade-in. */ + +/* Blinking caret for the simulated command-palette input while "typing". */ +@keyframes cmd-j-tip-caret { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } +} + +.animate-cmd-j-tip-caret { + animation: cmd-j-tip-caret 1s step-end infinite; +} + +/* Worktree rows fade in when the palette opens — mirrors the real palette, + which shows recent worktrees on empty query and filters synchronously. */ +@keyframes cmd-j-tip-result-in { + 0% { + opacity: 0; + transform: translateY(2px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +.animate-cmd-j-tip-result-in { + animation: cmd-j-tip-result-in 120ms ease-out both; +} + +@media (prefers-reduced-motion: reduce) { + .animate-cmd-j-tip-caret { + animation: none; + opacity: 1; + } + + .animate-cmd-j-tip-result-in { + animation: none; + opacity: 1; + transform: none; + } +} + /* ── Feature wall: agents-orchestration animations ───────────────── */ /* Why: drives the supported-agents marquee on page 1 of the agents tile. @@ -2837,12 +2886,12 @@ html.onboarding-tour-start-transition::view-transition-new(root) { display: flex; align-items: flex-start; gap: 8px; - margin: 4px 0 6px 0; + margin: 2px 0 4px 0; padding-left: 8px; border-left: 2px solid color-mix(in srgb, #e7b10a 35%, transparent); color: var(--muted-foreground); font-size: 12.5px; - line-height: 1.4; + line-height: 1.35; } .orca-diff-comment-quote-text { @@ -2850,10 +2899,7 @@ html.onboarding-tour-start-transition::view-transition-new(root) { min-width: 0; overflow: hidden; text-overflow: ellipsis; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - word-break: break-all; + white-space: nowrap; } /* Comment Body */ diff --git a/src/renderer/src/assets/markdown-preview.css b/src/renderer/src/assets/markdown-preview.css index 5a3df9025d7..231bf6ec69b 100644 --- a/src/renderer/src/assets/markdown-preview.css +++ b/src/renderer/src/assets/markdown-preview.css @@ -281,7 +281,7 @@ .markdown-annotation-list-block { position: relative; display: grid; - grid-template-columns: minmax(0, 1fr) minmax(260px, min(34cqw, 360px)); + grid-template-columns: minmax(0, 1fr) minmax(220px, min(28cqw, 300px)); column-gap: 32px; align-items: start; } @@ -367,8 +367,8 @@ } .markdown-annotation-card.is-active > .orca-diff-comment-card { - border-color: color-mix(in srgb, var(--foreground) 32%, transparent); - background: color-mix(in srgb, var(--foreground) 8%, var(--editor-surface)); + border-color: color-mix(in srgb, var(--foreground) 18%, transparent); + background: color-mix(in srgb, var(--foreground) 4%, var(--editor-surface)); } .markdown-annotation-card.is-attention > .orca-diff-comment-card { diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index 948207dc586..7f85b880f89 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -123,7 +123,7 @@ } .rich-markdown-editor-shell.has-rich-markdown-review-notes .rich-markdown-editor { - padding-right: min(360px, 34%); + padding-right: clamp(252px, 28%, 308px); } .rich-markdown-review-rail-actions { @@ -178,7 +178,8 @@ position: absolute; top: 0; right: 16px; - width: min(300px, 30%); + /* Why: the card action pill needs the same minimum rail width as preview mode. */ + width: clamp(220px, 24%, 250px); pointer-events: none; z-index: 30; } @@ -191,8 +192,8 @@ } .rich-markdown-review-note-card.is-active > .orca-diff-comment-card { - border-color: color-mix(in srgb, var(--foreground) 32%, transparent); - background: color-mix(in srgb, var(--foreground) 8%, var(--editor-surface)); + border-color: color-mix(in srgb, var(--foreground) 18%, transparent); + background: color-mix(in srgb, var(--foreground) 4%, var(--editor-surface)); } .rich-markdown-review-note-card.is-attention > .orca-diff-comment-card { diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index 3496db1fc7e..e7b5afe5030 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -31,6 +31,12 @@ overflow-y: scroll; } +/* Round the xterm DOM scrollbar thumb; xterm has no option for this, and its + default .xterm-slider has square corners. */ +.xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider { + border-radius: 4px; +} + /* xterm.css sets position:absolute;top:0 on .xterm-helpers but omits left, leaving left:auto. In Electron's Blink, left:auto can resolve to a non-zero value and offset the IME candidate window away from the cursor. Anchoring it diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 5fe493a1167..d45fff7d1da 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -3,6 +3,7 @@ composer card markup together so the inline and modal variants share one UI surface without splitting the controlled form into hard-to-follow fragments. */ import React from 'react' import { + AlertTriangle, Check, ChevronDown, CornerDownLeft, @@ -62,6 +63,8 @@ type NewWorkspaceComposerCardProps = { onSmartLinearIssueSelect: (issue: LinearIssue) => void smartNameSelection: SmartWorkspaceNameSelection | null onClearSmartNameSelection: () => void + /** Advisory shown under the name field when a fork PR can't accept maintainer pushes. */ + forkPushWarning: string | null detectedAgentIds: Set | null onOpenAgentSettings: () => void advancedOpen: boolean @@ -228,6 +231,7 @@ export default function NewWorkspaceComposerCard({ onSmartLinearIssueSelect, smartNameSelection, onClearSmartNameSelection, + forkPushWarning, detectedAgentIds, onOpenAgentSettings, advancedOpen, @@ -495,6 +499,12 @@ export default function NewWorkspaceComposerCard({ agentTrigger?.focus() }} /> + {forkPushWarning ? ( +

+

+ ) : null}
diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index c03a42da12b..b385db3abc7 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -203,6 +203,7 @@ import { useTeamLabels } from '@/hooks/useIssueMetadata' import { + linearCreateProject, linearCreateIssue, linearGetIssue, linearTeamStates, @@ -498,10 +499,7 @@ const LINEAR_MODE_OPTIONS: { id: LinearMode; label: string }[] = [ { id: 'views', label: 'Views' } ] -const LINEAR_CUSTOM_VIEW_MODEL_OPTIONS: { id: LinearCustomViewModel; label: string }[] = [ - { id: 'issue', label: 'Issues' }, - { id: 'project', label: 'Projects' } -] +const LINEAR_CUSTOM_VIEW_MODELS = ['issue', 'project'] satisfies readonly LinearCustomViewModel[] const LINEAR_VIEW_OPTIONS: { id: LinearViewMode @@ -512,6 +510,17 @@ const LINEAR_VIEW_OPTIONS: { { id: 'board', label: 'Board', Icon: LayoutGrid } ] +function mergeLinearCollectionResults( + results: LinearCollectionResult[] +): LinearCollectionResult { + const errors = results.flatMap((result) => result.errors ?? []) + return { + items: results.flatMap((result) => result.items), + ...(errors.length > 0 ? { errors } : {}), + ...(results.some((result) => result.hasMore) ? { hasMore: true } : {}) + } +} + const LINEAR_GROUP_OPTIONS: { id: LinearGroupBy; label: string }[] = [ { id: 'none', label: 'No grouping' }, { id: 'status', label: 'Status' }, @@ -3200,7 +3209,6 @@ export default function TaskPage(): React.JSX.Element { >(null) const [linearProjectIssuesLoading, setLinearProjectIssuesLoading] = useState(false) const [linearProjectIssuesError, setLinearProjectIssuesError] = useState(null) - const [linearCustomViewModel, setLinearCustomViewModel] = useState('issue') const [linearCustomViewsResult, setLinearCustomViewsResult] = useState< LinearCollectionResult >({ items: [] }) @@ -3442,7 +3450,6 @@ export default function TaskPage(): React.JSX.Element { } if (context.kind === 'view' && context.model) { - setLinearCustomViewModel(context.model) setLinearMode('views') setLinearCustomViewsLoading(true) setLinearCustomViewsError(null) @@ -4246,6 +4253,41 @@ export default function TaskPage(): React.JSX.Element { [jiraIssues, jiraCacheSnapshot.issueCache, jiraCacheSnapshot.searchCache] ) + // New Linear project dialog state + const [newLinearProjectOpen, setNewLinearProjectOpen] = useState(false) + const [newLinearProjectName, setNewLinearProjectName] = useState('') + const [newLinearProjectDescription, setNewLinearProjectDescription] = useState('') + const [newLinearProjectContent, setNewLinearProjectContent] = useState('') + const [newLinearProjectTeamId, setNewLinearProjectTeamId] = useState(null) + const [newLinearProjectLeadId, setNewLinearProjectLeadId] = useState(null) + const [newLinearProjectMemberIds, setNewLinearProjectMemberIds] = useState([]) + const [newLinearProjectLabelIds, setNewLinearProjectLabelIds] = useState([]) + const [newLinearProjectPriority, setNewLinearProjectPriority] = useState(0) + const [newLinearProjectStartDate, setNewLinearProjectStartDate] = useState('') + const [newLinearProjectTargetDate, setNewLinearProjectTargetDate] = useState('') + const [newLinearProjectSubmitting, setNewLinearProjectSubmitting] = useState(false) + + const newLinearProjectTargetTeam = useMemo( + () => availableTeams.find((t) => t.id === newLinearProjectTeamId) ?? availableTeams[0] ?? null, + [availableTeams, newLinearProjectTeamId] + ) + const newLinearProjectMembers = useTeamMembers( + newLinearProjectOpen ? (newLinearProjectTargetTeam?.id ?? null) : null, + settings, + newLinearProjectTargetTeam?.workspaceId + ) + const newLinearProjectLabels = useTeamLabels( + newLinearProjectOpen ? (newLinearProjectTargetTeam?.id ?? null) : null, + settings, + newLinearProjectTargetTeam?.workspaceId + ) + + useEffect(() => { + setNewLinearProjectLeadId(null) + setNewLinearProjectMemberIds([]) + setNewLinearProjectLabelIds([]) + }, [newLinearProjectTargetTeam?.id, newLinearProjectTargetTeam?.workspaceId]) + // New Linear issue dialog state const [newLinearIssueOpen, setNewLinearIssueOpen] = useState(false) const [newLinearIssueTitle, setNewLinearIssueTitle] = useState('') @@ -4347,6 +4389,7 @@ export default function TaskPage(): React.JSX.Element { !gitlabDialogItem && !selectedLinearIssue && !newIssueOpen && + !newLinearProjectOpen && !newLinearIssueOpen && !linearConnectOpen && activeModal === 'none', @@ -5112,6 +5155,7 @@ export default function TaskPage(): React.JSX.Element { githubMode !== 'items' || dialogWorkItem || newIssueOpen || + newLinearProjectOpen || newLinearIssueOpen || newJiraIssueOpen || activeModal !== 'none' @@ -5154,6 +5198,7 @@ export default function TaskPage(): React.JSX.Element { dialogWorkItem, githubMode, newIssueOpen, + newLinearProjectOpen, newLinearIssueOpen, newJiraIssueOpen, taskSource @@ -5350,6 +5395,81 @@ export default function TaskPage(): React.JSX.Element { setDialogWorkItem ]) + const handleCreateNewLinearProject = useCallback(async (): Promise => { + if (!newLinearProjectTargetTeam) { + return + } + const name = newLinearProjectName.trim() + if (!name || newLinearProjectSubmitting) { + return + } + setNewLinearProjectSubmitting(true) + try { + const result = await linearCreateProject(settings, { + name, + description: newLinearProjectDescription.trim() || undefined, + content: newLinearProjectContent.trim() || undefined, + teamIds: [newLinearProjectTargetTeam.id], + workspaceId: newLinearProjectTargetTeam.workspaceId, + leadId: newLinearProjectLeadId || undefined, + memberIds: newLinearProjectMemberIds.length > 0 ? newLinearProjectMemberIds : undefined, + labelIds: newLinearProjectLabelIds.length > 0 ? newLinearProjectLabelIds : undefined, + priority: newLinearProjectPriority, + startDate: newLinearProjectStartDate || undefined, + targetDate: newLinearProjectTargetDate || undefined + }) + if (!result.ok) { + toast.error(result.error || 'Failed to create project.') + return + } + toast.success(`Created ${result.project.name}`, { + action: result.project.url + ? { + label: 'View', + onClick: () => window.open(result.project.url, '_blank') + } + : undefined + }) + setNewLinearProjectOpen(false) + setNewLinearProjectName('') + setNewLinearProjectDescription('') + setNewLinearProjectContent('') + setNewLinearProjectLeadId(null) + setNewLinearProjectMemberIds([]) + setNewLinearProjectLabelIds([]) + setNewLinearProjectPriority(0) + setNewLinearProjectStartDate('') + setNewLinearProjectTargetDate('') + setAppliedLinearProjectSearch('') + setLinearProjectSearchInput('') + setLinearProjectsResult((current) => ({ + ...current, + items: [result.project, ...current.items.filter((item) => item.id !== result.project.id)] + })) + setSelectedLinearProjectDetail(result.project) + openLinearProjectContext(result.project) + setLinearRefreshNonce((n) => n + 1) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to create project.') + } finally { + setNewLinearProjectSubmitting(false) + } + }, [ + newLinearProjectContent, + newLinearProjectDescription, + newLinearProjectLabelIds, + newLinearProjectLeadId, + newLinearProjectMemberIds, + newLinearProjectName, + newLinearProjectPriority, + newLinearProjectStartDate, + newLinearProjectSubmitting, + newLinearProjectTargetDate, + newLinearProjectTargetTeam, + openLinearProjectContext, + settings + ]) + const handleCreateNewLinearIssue = useCallback(async (): Promise => { if (!newLinearIssueTargetTeam) { return @@ -5884,17 +6004,28 @@ export default function TaskPage(): React.JSX.Element { return } let cancelled = false - const cached = getCachedLinearCustomViews(linearCustomViewModel, LINEAR_ITEM_LIMIT) - if (cached) { - setLinearCustomViewsResult(cached) + const cachedResults = LINEAR_CUSTOM_VIEW_MODELS.map((model) => + getCachedLinearCustomViews(model, LINEAR_ITEM_LIMIT) + ) + const allCached = cachedResults.every( + (result): result is LinearCollectionResult => result !== null + ) + if (allCached) { + setLinearCustomViewsResult(mergeLinearCollectionResults(cachedResults)) } const force = linearRefreshNonce > 0 - setLinearCustomViewsLoading(force || cached === null) + setLinearCustomViewsLoading(force || !allCached) setLinearCustomViewsError(null) - void listLinearCustomViews(linearCustomViewModel, LINEAR_ITEM_LIMIT, undefined, { force }) + // Why: the Views tab already has a Model column, so listing both view + // models avoids a second, redundant Issues/Projects switch. + void Promise.all( + LINEAR_CUSTOM_VIEW_MODELS.map((model) => + listLinearCustomViews(model, LINEAR_ITEM_LIMIT, undefined, { force }) + ) + ) .then((result) => { if (!cancelled) { - setLinearCustomViewsResult(result) + setLinearCustomViewsResult(mergeLinearCollectionResults(result)) setLinearCustomViewsLoading(false) } }) @@ -5917,9 +6048,9 @@ export default function TaskPage(): React.JSX.Element { linearStatus.connected, selectedLinearWorkspaceId, selectedLinearCustomView, - linearCustomViewModel, linearRefreshNonce, - getCachedLinearCustomViews + getCachedLinearCustomViews, + listLinearCustomViews ]) useEffect(() => { @@ -6755,6 +6886,20 @@ export default function TaskPage(): React.JSX.Element { variant="outline" size="icon" onClick={() => { + if (linearMode === 'projects' && !selectedLinearProject) { + setNewLinearProjectName('') + setNewLinearProjectDescription('') + setNewLinearProjectContent('') + setNewLinearProjectTeamId(availableTeams[0]?.id ?? null) + setNewLinearProjectLeadId(null) + setNewLinearProjectMemberIds([]) + setNewLinearProjectLabelIds([]) + setNewLinearProjectPriority(0) + setNewLinearProjectStartDate('') + setNewLinearProjectTargetDate('') + setNewLinearProjectOpen(true) + return + } setNewLinearIssueTitle('') setNewLinearIssueBody('') const projectTeamId = @@ -6770,14 +6915,20 @@ export default function TaskPage(): React.JSX.Element { setNewLinearIssueOpen(true) }} disabled={availableTeams.length === 0} - aria-label="New Linear issue" + aria-label={ + linearMode === 'projects' && !selectedLinearProject + ? 'New Linear project' + : 'New Linear issue' + } className="size-8 border-border/50 bg-transparent hover:bg-muted/50 backdrop-blur-md supports-[backdrop-filter]:bg-transparent" > - New Linear issue + {linearMode === 'projects' && !selectedLinearProject + ? 'New Linear project' + : 'New Linear issue'} @@ -6890,38 +7041,6 @@ export default function TaskPage(): React.JSX.Element { ) : null}
- ) : linearMode === 'views' && !selectedLinearCustomView ? ( -
- {LINEAR_CUSTOM_VIEW_MODEL_OPTIONS.map((option) => { - const active = option.id === linearCustomViewModel - return ( - - ) - })} -
) : null} ) : taskSource === 'jira' && jiraStatus.connected ? ( @@ -8256,7 +8375,6 @@ export default function TaskPage(): React.JSX.Element { ) : null} ) : selectedLinearCustomView?.model === 'project' && !selectedLinearProject ? ( @@ -9166,6 +9284,440 @@ export default function TaskPage(): React.JSX.Element { + { + if (!newLinearProjectSubmitting) { + setNewLinearProjectOpen(open) + } + }} + > + { + if (isScreenSubmitShortcut(event)) { + event.preventDefault() + void handleCreateNewLinearProject() + } + }} + > + New Linear project + + Create a Linear project for the selected team. + +
+
+ + New Project + + / + {availableTeams.length > 1 ? ( + + + + + +
+ Team +
+
+ {availableTeams.map((team) => ( + + ))} +
+
+
+ ) : ( + + {newLinearProjectTargetTeam + ? `${newLinearProjectTargetTeam.key} - ${newLinearProjectTargetTeam.name}` + : ''} + + )} +
+ +
+ +
+ setNewLinearProjectName(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && !event.nativeEvent.isComposing) { + event.preventDefault() + void handleCreateNewLinearProject() + } + }} + placeholder="Project name" + disabled={newLinearProjectSubmitting} + className="w-full border-none bg-transparent p-0 text-xl font-semibold text-foreground outline-none placeholder:text-muted-foreground/45 focus:outline-none focus:ring-0 focus-visible:ring-0" + /> + + setNewLinearProjectDescription(event.target.value)} + placeholder="Add a short summary..." + disabled={newLinearProjectSubmitting} + className="w-full border-none bg-transparent p-0 text-sm text-foreground outline-none placeholder:text-muted-foreground/45 focus:outline-none focus:ring-0 focus-visible:ring-0" + /> + +
+ + + + + +
+ Priority +
+ {[0, 1, 2, 3, 4].map((priority) => ( + + ))} +
+
+ + + + + + +
+ Lead +
+ {newLinearProjectMembers.loading ? ( +
+ +
+ ) : ( +
+ + {newLinearProjectMembers.data.map((member) => ( + + ))} +
+ )} +
+
+ + + + + + +
+ Members +
+ {newLinearProjectMembers.loading ? ( +
+ +
+ ) : ( +
+ {newLinearProjectMembers.data.map((member) => { + const selected = newLinearProjectMemberIds.includes(member.id) + return ( + + ) + })} +
+ )} +
+
+ + + + + + +
+ Labels +
+ {newLinearProjectLabels.loading ? ( +
+ +
+ ) : ( +
+ {newLinearProjectLabels.data.length === 0 ? ( +
No labels
+ ) : ( + newLinearProjectLabels.data.map((label) => { + const selected = newLinearProjectLabelIds.includes(label.id) + return ( + + ) + }) + )} +
+ )} +
+
+ + + + +
+ +
+