mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 08:03:09 +00:00
Merge remote-tracking branch 'refs/remotes/origin/main' into Jinwoo-H/mobile-emulator
# Conflicts: # src/renderer/src/lib/workspace-session-patch.ts # src/renderer/src/lib/workspace-session.ts
This commit is contained in:
@@ -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`.'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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 <session_id>`.
|
||||
- Codex: hook runtime sends `session_id`; resume command is `codex resume <session_id>`.
|
||||
- Gemini: hook payloads include `session_id`; resume command is `gemini --resume <session_id>`.
|
||||
- Antigravity: hook payloads include `conversationId`; resume command is `agy --conversation <conversationId>`.
|
||||
- OpenCode: Orca's managed plugin already reads OpenCode `sessionID`; resume command is `opencode --session <sessionID>`.
|
||||
- Droid: hook payloads include `session_id`; resume command is `droid --resume <session_id>`.
|
||||
- Grok: hook payloads include `sessionId`/`session_id`; resume command is `grok --resume <sessionId>`.
|
||||
|
||||
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 <session_id>` 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 <session_id>` 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 <session_id>` 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 <uuid>` verified by local help and Antigravity docs. | Antigravity hook docs list `conversationId` as the active conversation UUID. | Exact supported after extraction. |
|
||||
| OpenCode | `opencode --session <sessionID>` 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 <sessionId>` 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 <SESSION_ID>` 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 <chatId>` 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 <path|id>`, `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 <uuid>`, `--fork-session`, and `--no-session-persistence`.
|
||||
- Anthropic CLI docs show `claude -r "<session-id>" "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 <session_id>
|
||||
```
|
||||
|
||||
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 <thread_id>`.
|
||||
- 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 <session_id>
|
||||
```
|
||||
|
||||
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 <session_id>
|
||||
```
|
||||
|
||||
### Antigravity
|
||||
|
||||
Verified:
|
||||
|
||||
- Local `agy --help` shows `--continue` and `--conversation`.
|
||||
- Antigravity conversation docs show `agy --continue` for most recent and `agy --conversation <uuid>` for a specific session.
|
||||
- Antigravity hooks docs define common hook input `conversationId`.
|
||||
|
||||
Resume command:
|
||||
|
||||
```sh
|
||||
agy --conversation <conversationId>
|
||||
```
|
||||
|
||||
### 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 <sessionID>
|
||||
```
|
||||
|
||||
### Droid
|
||||
|
||||
Verified:
|
||||
|
||||
- Local `droid --help` shows `--resume [sessionId]`, `--fork <sessionId>`, and `--cwd`.
|
||||
- Factory CLI docs list resume behavior and `droid exec -s <id>` for exec mode.
|
||||
- Factory hooks reference says `SessionStart` has a `resume` matcher and hook input includes `session_id`.
|
||||
|
||||
Resume command:
|
||||
|
||||
```sh
|
||||
droid --resume <session_id>
|
||||
```
|
||||
|
||||
### Grok
|
||||
|
||||
Verified:
|
||||
|
||||
- Local `grok --help` shows `--resume [<SESSION_ID>]`, `--continue`, `--cwd`, and `--restore-code`.
|
||||
- Orca already reads `sessionId`/`session_id` in `getGrokChatHistoryPath`.
|
||||
|
||||
Resume command:
|
||||
|
||||
```sh
|
||||
grok --resume <sessionId>
|
||||
```
|
||||
|
||||
### 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 <chatId> <prompt>` 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 <path|id>`, `--fork <path|id>`, `--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 <path-or-id>
|
||||
```
|
||||
|
||||
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
|
||||
@@ -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 <model> --agent build --format default
|
||||
```
|
||||
|
||||
Also smoke `--variant <level>` 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 <model> --agent build --format default`, plus `--variant <thinkingLevel>` 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 <level>`.
|
||||
- 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:<cwd>` 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.
|
||||
@@ -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.
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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, '-')
|
||||
|
||||
@@ -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 <n>` instead of sleep/poll loops. Reply to `decision_gate` messages with `orca orchestration reply --id <msg_id> --body <answer> --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:<id>`.
|
||||
@@ -130,7 +132,7 @@ orca terminal send --terminal <handle> --text <text> --enter --json
|
||||
|
||||
If an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree <selector> --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:<newWorktreeId> --json
|
||||
orca terminal wait --terminal <handle> --for tui-idle --timeout-ms 60000 --json
|
||||
orca orchestration task-create --spec "Fix the login button CSS" --json
|
||||
orca orchestration dispatch --task <task_id> --to <handle> --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
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<string>
|
||||
}
|
||||
navigator: {
|
||||
permissions: {
|
||||
query: (descriptor: { name: string }) => Promise<PermissionQueryResult>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createContext(args: {
|
||||
nativeNotificationPermission: string
|
||||
requestedNotificationPermission: string
|
||||
}): AntiDetectionContext & Record<string, unknown> {
|
||||
class Permissions {
|
||||
query(): Promise<PermissionQueryResult> {
|
||||
return Promise.resolve({ state: 'denied', onchange: null })
|
||||
}
|
||||
}
|
||||
|
||||
const Notification = {
|
||||
permission: args.nativeNotificationPermission,
|
||||
requestPermission(callback?: (permission: string) => void): Promise<string> {
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
@@ -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<typeof vi.fn>
|
||||
let guestOnMock: ReturnType<typeof vi.fn>
|
||||
let guestOffMock: ReturnType<typeof vi.fn>
|
||||
|
||||
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> = {}
|
||||
): Electron.MouseWheelInputEvent {
|
||||
return {
|
||||
type: 'mouseWheel',
|
||||
x: 0,
|
||||
y: 0,
|
||||
deltaY: -120,
|
||||
modifiers: ['ctrl'],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function triggerBeforeMouse(mouse: Electron.MouseInputEvent): ReturnType<typeof vi.fn> {
|
||||
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<typeof vi.fn>
|
||||
|
||||
@@ -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<string>): 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<string, number>,
|
||||
browserTabId: string
|
||||
|
||||
@@ -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<string, () => void>()
|
||||
private readonly grabShortcutCleanupByTabId = new Map<string, () => void>()
|
||||
private readonly shortcutForwardingCleanupByTabId = new Map<string, () => void>()
|
||||
private readonly mouseWheelZoomCleanupByTabId = new Map<string, () => void>()
|
||||
private readonly annotationViewportBridgeOpsByTabId = new Map<string, Promise<unknown>>()
|
||||
private readonly worktreeIdByTabId = new Map<string, string>()
|
||||
private readonly policyAttachedGuestIds = new Set<number>()
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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<GitPushTarget | null> {
|
||||
): Promise<PullRequestPushTarget | null> {
|
||||
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()
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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<void> => {
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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<GitRemoteExec>
|
||||
|
||||
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> = {}): 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<string, GitPushTarget | undefined>): WorktreePushTargetStore {
|
||||
const meta: Record<string, WorktreeMeta> = {}
|
||||
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<GitRemoteExec>(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)
|
||||
})
|
||||
})
|
||||
@@ -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<Store, 'getAllWorktreeMeta'>
|
||||
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
@@ -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<GitRemoteExec>
|
||||
|
||||
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<string, string>): ExecMock {
|
||||
return vi.fn<GitRemoteExec>(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> = {}): 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)
|
||||
})
|
||||
})
|
||||
@@ -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<string | null> {
|
||||
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<string> {
|
||||
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<GitPushTarget> {
|
||||
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<GitPushTarget> {
|
||||
await execGit(
|
||||
['branch', '--set-upstream-to', `${target.remoteName}/${target.branchName}`, branchName],
|
||||
worktreePath
|
||||
)
|
||||
return target
|
||||
}
|
||||
+21
-193
@@ -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<string | null> {
|
||||
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<string> {
|
||||
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<GitPushTarget> {
|
||||
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<Store, 'getAllWorktreeMeta'>
|
||||
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<GitPushTarget> {
|
||||
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(
|
||||
|
||||
@@ -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/<N>/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/<N>/head when branch fetch hits a network failure', async () => {
|
||||
|
||||
@@ -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<ReturnType<typeof customViewsResponse>>()
|
||||
const refreshRequest = deferred<ReturnType<typeof customViewsResponse>>()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });',
|
||||
' }',
|
||||
' }',
|
||||
' },',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<string>()
|
||||
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<string>()
|
||||
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<typeof createLinearProject> {
|
||||
return createLinearProject(input, workspaceId)
|
||||
}
|
||||
|
||||
linearGetProject(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -1453,6 +1453,19 @@ export type PreloadApi = {
|
||||
workspaceId?: LinearWorkspaceSelection
|
||||
force?: boolean
|
||||
}) => Promise<LinearCollectionResult<LinearProjectSummary>>
|
||||
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
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
GitUpstreamStatus,
|
||||
GhosttyImportPreview,
|
||||
ListWorkItemsResult,
|
||||
LinearProjectDetail,
|
||||
MemorySnapshot,
|
||||
NotificationDismissResult,
|
||||
NotificationDispatchResult,
|
||||
@@ -1282,6 +1283,21 @@ const api = {
|
||||
force?: boolean
|
||||
}): Promise<unknown> => 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<unknown> =>
|
||||
ipcRenderer.invoke('linear:getProject', args),
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<TuiAgent> | 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 ? (
|
||||
<p className="flex items-start gap-1.5 text-[11px] text-yellow-600 dark:text-yellow-500">
|
||||
<AlertTriangle className="mt-0.5 size-3 shrink-0" aria-hidden="true" />
|
||||
<span>{forkPushWarning}</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1" data-contextual-tour-target="workspace-creation-agent">
|
||||
|
||||
@@ -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<T>(
|
||||
results: LinearCollectionResult<T>[]
|
||||
): LinearCollectionResult<T> {
|
||||
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<string | null>(null)
|
||||
const [linearCustomViewModel, setLinearCustomViewModel] = useState<LinearCustomViewModel>('issue')
|
||||
const [linearCustomViewsResult, setLinearCustomViewsResult] = useState<
|
||||
LinearCollectionResult<LinearCustomViewSummary>
|
||||
>({ 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<string | null>(null)
|
||||
const [newLinearProjectLeadId, setNewLinearProjectLeadId] = useState<string | null>(null)
|
||||
const [newLinearProjectMemberIds, setNewLinearProjectMemberIds] = useState<string[]>([])
|
||||
const [newLinearProjectLabelIds, setNewLinearProjectLabelIds] = useState<string[]>([])
|
||||
const [newLinearProjectPriority, setNewLinearProjectPriority] = useState<number>(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<void> => {
|
||||
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<void> => {
|
||||
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<LinearCustomViewSummary> => 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"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
New Linear issue
|
||||
{linearMode === 'projects' && !selectedLinearProject
|
||||
? 'New Linear project'
|
||||
: 'New Linear issue'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
@@ -6890,38 +7041,6 @@ export default function TaskPage(): React.JSX.Element {
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : linearMode === 'views' && !selectedLinearCustomView ? (
|
||||
<div
|
||||
className="mt-3 flex min-w-0 flex-wrap items-center gap-2"
|
||||
role="group"
|
||||
aria-label="Linear view type"
|
||||
>
|
||||
{LINEAR_CUSTOM_VIEW_MODEL_OPTIONS.map((option) => {
|
||||
const active = option.id === linearCustomViewModel
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => {
|
||||
setSelectedLinearCustomView(null)
|
||||
setLinearCustomViewModel(option.id)
|
||||
setLinearCustomViewsResult({ items: [] })
|
||||
setLinearCustomViewsError(null)
|
||||
setLinearRefreshNonce((n) => n + 1)
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md border px-2 py-1 text-xs transition',
|
||||
active
|
||||
? 'border-border/50 bg-foreground/90 text-background backdrop-blur-md'
|
||||
: 'border-border/50 bg-transparent text-foreground hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : taskSource === 'jira' && jiraStatus.connected ? (
|
||||
@@ -8256,7 +8375,6 @@ export default function TaskPage(): React.JSX.Element {
|
||||
) : null}
|
||||
<LinearCustomViewTable
|
||||
views={linearCustomViewsResult.items}
|
||||
model={linearCustomViewModel}
|
||||
loading={linearCustomViewsLoading}
|
||||
hasError={!!linearCustomViewsResult.errors?.length}
|
||||
workspaceSelection={selectedLinearWorkspaceId}
|
||||
@@ -8272,7 +8390,7 @@ export default function TaskPage(): React.JSX.Element {
|
||||
errors={linearCustomViewsResult.errors}
|
||||
hasMore={linearCustomViewsResult.hasMore}
|
||||
count={linearCustomViewsResult.items.length}
|
||||
label={`${linearCustomViewModel} views`}
|
||||
label="views"
|
||||
/>
|
||||
</div>
|
||||
) : selectedLinearCustomView?.model === 'project' && !selectedLinearProject ? (
|
||||
@@ -9166,6 +9284,440 @@ export default function TaskPage(): React.JSX.Element {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={newLinearProjectOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!newLinearProjectSubmitting) {
|
||||
setNewLinearProjectOpen(open)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="flex max-h-[88vh] flex-col gap-0 overflow-hidden rounded-xl border-border bg-background p-0 shadow-2xl sm:max-w-3xl"
|
||||
onKeyDown={(event) => {
|
||||
if (isScreenSubmitShortcut(event)) {
|
||||
event.preventDefault()
|
||||
void handleCreateNewLinearProject()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle className="sr-only">New Linear project</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Create a Linear project for the selected team.
|
||||
</DialogDescription>
|
||||
<div className="flex items-center justify-between border-b border-border/60 bg-muted/10 px-5 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
New Project
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/40">/</span>
|
||||
{availableTeams.length > 1 ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="h-7 max-w-56 gap-1 px-2 text-xs font-medium text-foreground hover:bg-muted"
|
||||
>
|
||||
<span className="truncate">
|
||||
{newLinearProjectTargetTeam
|
||||
? `${newLinearProjectTargetTeam.key} - ${newLinearProjectTargetTeam.name}`
|
||||
: 'Select team'}
|
||||
</span>
|
||||
<ChevronDown className="size-3 flex-none text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-72 p-1">
|
||||
<div className="px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Team
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto scrollbar-sleek">
|
||||
{availableTeams.map((team) => (
|
||||
<button
|
||||
key={team.id}
|
||||
type="button"
|
||||
onClick={() => setNewLinearProjectTeamId(team.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
|
||||
newLinearProjectTargetTeam?.id === team.id
|
||||
? 'bg-muted font-medium text-foreground'
|
||||
: 'text-foreground/80'
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{team.key} - {team.name}
|
||||
</span>
|
||||
{newLinearProjectTargetTeam?.id === team.id ? (
|
||||
<Check className="size-3 flex-none" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<span className="truncate text-xs font-medium text-foreground">
|
||||
{newLinearProjectTargetTeam
|
||||
? `${newLinearProjectTargetTeam.key} - ${newLinearProjectTargetTeam.name}`
|
||||
: ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewLinearProjectOpen(false)}
|
||||
className="rounded-md p-1 text-muted-foreground transition-colors hover:text-foreground"
|
||||
disabled={newLinearProjectSubmitting}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-6 py-5 scrollbar-sleek">
|
||||
<input
|
||||
autoFocus
|
||||
value={newLinearProjectName}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
|
||||
<input
|
||||
value={newLinearProjectDescription}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={newLinearProjectSubmitting}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border/80 bg-muted/15 px-2 py-1 text-xs text-foreground/80 transition-colors hover:bg-muted/50 active:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<LinearPriorityIcon priority={newLinearProjectPriority} className="size-3.5" />
|
||||
<span>{getLinearPriorityLabel(newLinearProjectPriority)}</span>
|
||||
<ChevronDown className="size-3 text-muted-foreground/70" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-48 p-1">
|
||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Priority
|
||||
</div>
|
||||
{[0, 1, 2, 3, 4].map((priority) => (
|
||||
<button
|
||||
key={priority}
|
||||
type="button"
|
||||
onClick={() => setNewLinearProjectPriority(priority)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
|
||||
newLinearProjectPriority === priority
|
||||
? 'bg-muted font-medium text-foreground'
|
||||
: 'text-foreground/80'
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<LinearPriorityIcon priority={priority} className="size-3.5" />
|
||||
{getLinearPriorityLabel(priority)}
|
||||
</span>
|
||||
{newLinearProjectPriority === priority ? (
|
||||
<Check className="size-3 text-foreground" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={newLinearProjectSubmitting}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border/80 bg-muted/15 px-2 py-1 text-xs text-foreground/80 transition-colors hover:bg-muted/50 active:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<UserRound className="size-3.5 text-muted-foreground/70" />
|
||||
<span className="max-w-[120px] truncate">
|
||||
{newLinearProjectMembers.data.find(
|
||||
(member) => member.id === newLinearProjectLeadId
|
||||
)?.displayName ?? 'Lead'}
|
||||
</span>
|
||||
<ChevronDown className="size-3 text-muted-foreground/70" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-64 p-1">
|
||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Lead
|
||||
</div>
|
||||
{newLinearProjectMembers.loading ? (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-64 overflow-y-auto scrollbar-sleek">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewLinearProjectLeadId(null)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
|
||||
newLinearProjectLeadId === null
|
||||
? 'bg-muted font-medium text-foreground'
|
||||
: 'text-foreground/80'
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<UserRound className="size-3.5 text-muted-foreground/50" />
|
||||
No lead
|
||||
</span>
|
||||
{newLinearProjectLeadId === null ? <Check className="size-3" /> : null}
|
||||
</button>
|
||||
{newLinearProjectMembers.data.map((member) => (
|
||||
<button
|
||||
key={member.id}
|
||||
type="button"
|
||||
onClick={() => setNewLinearProjectLeadId(member.id)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
|
||||
newLinearProjectLeadId === member.id
|
||||
? 'bg-muted font-medium text-foreground'
|
||||
: 'text-foreground/80'
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{member.avatarUrl ? (
|
||||
<img
|
||||
src={member.avatarUrl}
|
||||
alt={member.displayName}
|
||||
className="size-3.5 flex-none rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<UserRound className="size-3.5 flex-none text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="truncate">{member.displayName}</span>
|
||||
</span>
|
||||
{newLinearProjectLeadId === member.id ? (
|
||||
<Check className="size-3 flex-none" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={newLinearProjectSubmitting}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border/80 bg-muted/15 px-2 py-1 text-xs text-foreground/80 transition-colors hover:bg-muted/50 active:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<Users className="size-3.5 text-muted-foreground/70" />
|
||||
<span>
|
||||
{newLinearProjectMemberIds.length === 0
|
||||
? 'Members'
|
||||
: `${newLinearProjectMemberIds.length} member${newLinearProjectMemberIds.length > 1 ? 's' : ''}`}
|
||||
</span>
|
||||
<ChevronDown className="size-3 text-muted-foreground/70" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-64 p-1">
|
||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Members
|
||||
</div>
|
||||
{newLinearProjectMembers.loading ? (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-64 overflow-y-auto scrollbar-sleek">
|
||||
{newLinearProjectMembers.data.map((member) => {
|
||||
const selected = newLinearProjectMemberIds.includes(member.id)
|
||||
return (
|
||||
<button
|
||||
key={member.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setNewLinearProjectMemberIds((current) =>
|
||||
selected
|
||||
? current.filter((id) => id !== member.id)
|
||||
: [...current, member.id]
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
|
||||
selected
|
||||
? 'bg-muted font-medium text-foreground'
|
||||
: 'text-foreground/80'
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{member.avatarUrl ? (
|
||||
<img
|
||||
src={member.avatarUrl}
|
||||
alt={member.displayName}
|
||||
className="size-3.5 flex-none rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<UserRound className="size-3.5 flex-none text-muted-foreground/70" />
|
||||
)}
|
||||
<span className="truncate">{member.displayName}</span>
|
||||
</span>
|
||||
{selected ? <Check className="size-3 flex-none" /> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={newLinearProjectSubmitting}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border/80 bg-muted/15 px-2 py-1 text-xs text-foreground/80 transition-colors hover:bg-muted/50 active:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<Tag className="size-3.5 text-muted-foreground/70" />
|
||||
<span>
|
||||
{newLinearProjectLabelIds.length === 0
|
||||
? 'Labels'
|
||||
: `${newLinearProjectLabelIds.length} label${newLinearProjectLabelIds.length > 1 ? 's' : ''}`}
|
||||
</span>
|
||||
<ChevronDown className="size-3 text-muted-foreground/70" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-64 p-1">
|
||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Labels
|
||||
</div>
|
||||
{newLinearProjectLabels.loading ? (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<LoaderCircle className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-64 overflow-y-auto scrollbar-sleek">
|
||||
{newLinearProjectLabels.data.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">No labels</div>
|
||||
) : (
|
||||
newLinearProjectLabels.data.map((label) => {
|
||||
const selected = newLinearProjectLabelIds.includes(label.id)
|
||||
return (
|
||||
<button
|
||||
key={label.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setNewLinearProjectLabelIds((current) =>
|
||||
selected
|
||||
? current.filter((id) => id !== label.id)
|
||||
: [...current, label.id]
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted',
|
||||
selected
|
||||
? 'bg-muted font-medium text-foreground'
|
||||
: 'text-foreground/80'
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="size-2 flex-none rounded-full bg-muted-foreground/40"
|
||||
style={label.color ? { backgroundColor: label.color } : undefined}
|
||||
/>
|
||||
<span className="truncate">{label.name}</span>
|
||||
</span>
|
||||
{selected ? <Check className="size-3 flex-none" /> : null}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2 py-1 text-xs text-foreground transition-colors hover:bg-muted/50 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50">
|
||||
<Clock3 className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="shrink-0 text-muted-foreground">Start</span>
|
||||
<input
|
||||
type="date"
|
||||
value={newLinearProjectStartDate}
|
||||
onChange={(event) => setNewLinearProjectStartDate(event.target.value)}
|
||||
disabled={newLinearProjectSubmitting}
|
||||
className="h-5 min-w-[6.75rem] cursor-pointer border-none bg-transparent p-0 text-xs text-foreground outline-none disabled:cursor-not-allowed"
|
||||
aria-label="Start date"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2 py-1 text-xs text-foreground transition-colors hover:bg-muted/50 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50">
|
||||
<Clock3 className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="shrink-0 text-muted-foreground">Target</span>
|
||||
<input
|
||||
type="date"
|
||||
value={newLinearProjectTargetDate}
|
||||
onChange={(event) => setNewLinearProjectTargetDate(event.target.value)}
|
||||
disabled={newLinearProjectSubmitting}
|
||||
className="h-5 min-w-[6.75rem] cursor-pointer border-none bg-transparent p-0 text-xs text-foreground outline-none disabled:cursor-not-allowed"
|
||||
aria-label="Target date"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/40 pt-4">
|
||||
<textarea
|
||||
value={newLinearProjectContent}
|
||||
onChange={(event) => setNewLinearProjectContent(event.target.value)}
|
||||
placeholder="Write a description, project brief, or collect ideas..."
|
||||
rows={8}
|
||||
disabled={newLinearProjectSubmitting}
|
||||
className="max-h-72 min-h-40 w-full min-w-0 resize-none overflow-y-auto border-none bg-transparent p-0 text-sm text-foreground outline-none placeholder:text-muted-foreground/45 scrollbar-sleek focus:outline-none focus:ring-0 focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{submitShortcutLabel} to submit.</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t border-border/60 bg-muted/10 px-5 py-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setNewLinearProjectOpen(false)}
|
||||
disabled={newLinearProjectSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleCreateNewLinearProject()}
|
||||
disabled={
|
||||
!newLinearProjectTargetTeam ||
|
||||
!newLinearProjectName.trim() ||
|
||||
newLinearProjectSubmitting
|
||||
}
|
||||
>
|
||||
{newLinearProjectSubmitting ? (
|
||||
<>
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create project'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={newLinearIssueOpen}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -667,7 +667,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
entries.push({
|
||||
id: '__header_worktrees__',
|
||||
type: 'section-header',
|
||||
label: hasQuery ? 'Workspaces' : 'Recent Workspaces'
|
||||
label: hasQuery ? 'Worktrees' : 'Recent Worktrees'
|
||||
})
|
||||
}
|
||||
appendPaletteListEntries(entries, visibleWorktreeItems)
|
||||
@@ -680,7 +680,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
entries.push({
|
||||
id: '__hint_worktree_cap__',
|
||||
type: 'hint',
|
||||
label: `Type to see all ${worktreeItems.length} workspaces`
|
||||
label: `Type to see all ${worktreeItems.length} worktrees`
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1152,7 +1152,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
if ((hasAnySearchableWorktrees || hasAnyMiddleResults || hasAnyBrowserPages) && hasQuery) {
|
||||
return {
|
||||
title: 'No results match your search',
|
||||
subtitle: 'Try a workspace, setting, action, page title, URL, PR, or port.'
|
||||
subtitle: 'Try a worktree, setting, action, page title, URL, PR, or port.'
|
||||
}
|
||||
}
|
||||
// Why: empty-query rows exclude the current worktree, so a single-worktree
|
||||
@@ -1162,12 +1162,12 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
if (!hasQuery && hasAnyWorktrees && !hasAnyBrowserPages) {
|
||||
return {
|
||||
title: 'No other worktrees to switch to',
|
||||
subtitle: 'Type to search workspaces, settings, tabs, and actions.'
|
||||
subtitle: 'Type to search worktrees, settings, tabs, and actions.'
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: 'No active worktrees, settings, actions, or browser tabs',
|
||||
subtitle: 'Create a workspace or open a page in Orca to get started.'
|
||||
subtitle: 'Create a worktree or open a page in Orca to get started.'
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -1179,7 +1179,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
onOpenAutoFocus={handleOpenAutoFocus}
|
||||
onCloseAutoFocus={handleCloseAutoFocus}
|
||||
title="Jump to..."
|
||||
description="Search workspaces, settings, tabs, and actions"
|
||||
description="Search worktrees, settings, tabs, and actions"
|
||||
overlayClassName="bg-black/55 backdrop-blur-[2px]"
|
||||
contentClassName="top-[13%] w-[736px] max-w-[94vw] overflow-hidden rounded-xl border border-border/70 bg-background/96 shadow-[0_26px_84px_rgba(0,0,0,0.32)] backdrop-blur-xl"
|
||||
commandProps={{
|
||||
@@ -1190,7 +1190,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
}}
|
||||
>
|
||||
<CommandInput
|
||||
placeholder="Search workspaces, settings, tabs, and actions..."
|
||||
placeholder="Search worktrees, settings, tabs, and actions..."
|
||||
value={query}
|
||||
onValueChange={handleQueryChange}
|
||||
wrapperClassName="mx-3 mt-3 rounded-lg border border-border/55 bg-muted/28 px-3.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]"
|
||||
@@ -1247,7 +1247,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[14px] font-semibold tracking-[-0.01em] text-foreground">
|
||||
{`Create workspace "${createWorktreeName}"`}
|
||||
{`Create worktree "${createWorktreeName}"`}
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
@@ -1494,8 +1494,8 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
||||
</div>
|
||||
<div aria-live="polite" className="sr-only">
|
||||
{deferredQuery.trim()
|
||||
? `${resultCount} results found${showCreateAction ? ', create workspace action available' : ''}`
|
||||
: `${resultCount} items available${showCreateAction ? ', create workspace action available' : ''}`}
|
||||
? `${resultCount} results found${showCreateAction ? ', create worktree action available' : ''}`
|
||||
: `${resultCount} items available${showCreateAction ? ', create worktree action available' : ''}`}
|
||||
</div>
|
||||
</CommandDialog>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Import } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { useAppStore } from '@/store'
|
||||
import { isLinuxUserAgent, isMacUserAgent } from '@/components/terminal-pane/pane-helpers'
|
||||
import { getBrowserCookieImportSourceLabels } from '../../../../shared/browser-cookie-import-sources'
|
||||
import { shouldShowBrowserImportHint } from './browser-import-hint-visibility'
|
||||
import { formatBrowserImportSummary } from './browser-detected-browsers-summary'
|
||||
|
||||
type BrowserImportHintButtonProps = {
|
||||
profileId: string | null
|
||||
}
|
||||
|
||||
export function BrowserImportHintButton({
|
||||
profileId
|
||||
}: BrowserImportHintButtonProps): React.JSX.Element | null {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [importMenuOpen, setImportMenuOpen] = useState(false)
|
||||
const browserSessionImportState = useAppStore((s) => s.browserSessionImportState)
|
||||
const browserImportHintHidden = useAppStore((s) => s.browserImportHintHidden)
|
||||
const persistedUIReady = useAppStore((s) => s.persistedUIReady)
|
||||
const detectedBrowsers = useAppStore((s) => s.detectedBrowsers)
|
||||
const detectedBrowsersLoaded = useAppStore((s) => s.detectedBrowsersLoaded)
|
||||
const fetchDetectedBrowsers = useAppStore((s) => s.fetchDetectedBrowsers)
|
||||
const importCookiesFromBrowser = useAppStore((s) => s.importCookiesFromBrowser)
|
||||
const importCookiesToProfile = useAppStore((s) => s.importCookiesToProfile)
|
||||
const setBrowserImportHintHidden = useAppStore((s) => s.setBrowserImportHintHidden)
|
||||
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
|
||||
|
||||
const effectiveProfileId = profileId ?? 'default'
|
||||
const shouldShow = shouldShowBrowserImportHint({
|
||||
persistedUIReady,
|
||||
browserImportHintHidden
|
||||
})
|
||||
|
||||
const supportedImportLabels = useMemo(() => {
|
||||
const platform = isMacUserAgent() ? 'darwin' : isLinuxUserAgent() ? 'linux' : 'win32'
|
||||
return getBrowserCookieImportSourceLabels(platform)
|
||||
}, [])
|
||||
|
||||
const importSummary = useMemo(
|
||||
() =>
|
||||
formatBrowserImportSummary({
|
||||
detectedBrowsers,
|
||||
detectedBrowsersLoaded,
|
||||
supportedImportLabels
|
||||
}),
|
||||
[detectedBrowsers, detectedBrowsersLoaded, supportedImportLabels]
|
||||
)
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(nextOpen: boolean): void => {
|
||||
setOpen(nextOpen)
|
||||
if (!nextOpen) {
|
||||
setImportMenuOpen(false)
|
||||
}
|
||||
if (nextOpen) {
|
||||
// Why: macOS treats other browsers' profile folders as app data. Only
|
||||
// probe them when the user opens the import hint.
|
||||
void fetchDetectedBrowsers()
|
||||
}
|
||||
},
|
||||
[fetchDetectedBrowsers]
|
||||
)
|
||||
|
||||
const handleImportFromBrowser = useCallback(
|
||||
async (browserFamily: string, browserProfile?: string): Promise<void> => {
|
||||
setOpen(false)
|
||||
setImportMenuOpen(false)
|
||||
const result = await importCookiesFromBrowser(
|
||||
effectiveProfileId,
|
||||
browserFamily,
|
||||
browserProfile
|
||||
)
|
||||
if (result.ok) {
|
||||
const browser = detectedBrowsers.find((entry) => entry.family === browserFamily)
|
||||
toast.success(
|
||||
`Imported ${result.summary.importedCookies} cookies from ${browser?.label ?? browserFamily}${browserProfile ? ` (${browserProfile})` : ''}.`
|
||||
)
|
||||
return
|
||||
}
|
||||
toast.error(result.reason)
|
||||
},
|
||||
[detectedBrowsers, effectiveProfileId, importCookiesFromBrowser]
|
||||
)
|
||||
|
||||
const handleImportFromFile = useCallback(async (): Promise<void> => {
|
||||
setOpen(false)
|
||||
setImportMenuOpen(false)
|
||||
const result = await importCookiesToProfile(effectiveProfileId)
|
||||
if (result.ok) {
|
||||
toast.success(`Imported ${result.summary.importedCookies} cookies from file.`)
|
||||
return
|
||||
}
|
||||
if (result.reason !== 'canceled') {
|
||||
toast.error(result.reason)
|
||||
}
|
||||
}, [effectiveProfileId, importCookiesToProfile])
|
||||
|
||||
const handleOpenBrowserSettings = useCallback((): void => {
|
||||
openSettingsTarget({ pane: 'browser', repoId: null })
|
||||
openSettingsPage()
|
||||
setOpen(false)
|
||||
}, [openSettingsPage, openSettingsTarget])
|
||||
|
||||
const handleHideHint = useCallback((): void => {
|
||||
setBrowserImportHintHidden(true)
|
||||
setOpen(false)
|
||||
}, [setBrowserImportHintHidden])
|
||||
|
||||
// Why: Electron <webview> elements run in a separate process, so clicking
|
||||
// inside one never dispatches pointerdown on the renderer document. Radix
|
||||
// popovers rely on that event for outside-click detection, so window blur
|
||||
// catches the moment focus leaves the renderer (including into a webview).
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
const dismiss = (): void => {
|
||||
handleOpenChange(false)
|
||||
}
|
||||
window.addEventListener('blur', dismiss)
|
||||
return () => window.removeEventListener('blur', dismiss)
|
||||
}, [handleOpenChange, open])
|
||||
|
||||
if (!shouldShow) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover modal={false} open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 rounded-full px-2.5 text-xs"
|
||||
aria-label="Import browser data"
|
||||
data-contextual-tour-target="browser-import-hint"
|
||||
>
|
||||
<Import className="size-3.5" />
|
||||
Import
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" side="bottom" sideOffset={6} className="w-80 p-3">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm font-medium text-foreground">Import browser data</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">{importSummary}</p>
|
||||
<p className="text-[11px] leading-4 text-muted-foreground/80">
|
||||
You can always find this in Settings > Browser.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<DropdownMenu modal={false} open={importMenuOpen} onOpenChange={setImportMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
disabled={browserSessionImportState?.status === 'importing'}
|
||||
>
|
||||
Import…
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-52">
|
||||
{detectedBrowsers.map((browser) =>
|
||||
browser.profiles.length > 1 ? (
|
||||
<DropdownMenuSub key={browser.family}>
|
||||
<DropdownMenuSubTrigger>From {browser.label}</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
{browser.profiles.map((profile) => (
|
||||
<DropdownMenuItem
|
||||
key={profile.directory}
|
||||
onSelect={() =>
|
||||
void handleImportFromBrowser(browser.family, profile.directory)
|
||||
}
|
||||
>
|
||||
{profile.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
key={browser.family}
|
||||
onSelect={() => void handleImportFromBrowser(browser.family)}
|
||||
>
|
||||
From {browser.label}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
{detectedBrowsers.length > 0 ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem onSelect={() => void handleImportFromFile()}>
|
||||
From File…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenBrowserSettings}
|
||||
className="rounded-sm text-xs text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
Browser Settings
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleHideHint}
|
||||
className="ml-auto rounded-sm text-xs text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
>
|
||||
Hide Hint
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -99,6 +99,7 @@ import { formatBrowserAnnotationsAsMarkdown } from './browser-annotation-output'
|
||||
import { isEditableKeyboardTarget } from './browser-keyboard'
|
||||
import { getBrowserPagesForWorkspace } from './browser-pane-page-selection'
|
||||
import BrowserAddressBar from './BrowserAddressBar'
|
||||
import { BrowserImportHintButton } from './BrowserImportHintButton'
|
||||
import { BrowserToolbarMenu } from './BrowserToolbarMenu'
|
||||
import BrowserFind from './BrowserFind'
|
||||
import { BrowserMobileDriverOverlay } from './BrowserMobileDriverOverlay'
|
||||
@@ -4523,6 +4524,8 @@ function BrowserPagePane({
|
||||
inputRef={addressBarInputRef}
|
||||
/>
|
||||
|
||||
<BrowserImportHintButton profileId={sessionProfileId} />
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatBrowserImportSummary } from './browser-detected-browsers-summary'
|
||||
|
||||
const SUPPORTED_LABELS = [
|
||||
'Google Chrome',
|
||||
'Microsoft Edge',
|
||||
'Arc',
|
||||
'Brave',
|
||||
'Comet',
|
||||
'Firefox',
|
||||
'Safari'
|
||||
]
|
||||
|
||||
describe('formatBrowserImportSummary', () => {
|
||||
it('uses real detected browser labels after detection completes', () => {
|
||||
expect(
|
||||
formatBrowserImportSummary({
|
||||
detectedBrowsers: [{ label: 'Google Chrome' }, { label: 'Safari' }],
|
||||
detectedBrowsersLoaded: true,
|
||||
supportedImportLabels: SUPPORTED_LABELS
|
||||
})
|
||||
).toBe('Detected: Google Chrome, Safari.')
|
||||
})
|
||||
|
||||
it('summarizes long detected-browser lists with a trailing count', () => {
|
||||
expect(
|
||||
formatBrowserImportSummary({
|
||||
detectedBrowsers: [
|
||||
{ label: 'Google Chrome' },
|
||||
{ label: 'Safari' },
|
||||
{ label: 'Microsoft Edge' },
|
||||
{ label: 'Arc' },
|
||||
{ label: 'Firefox' },
|
||||
{ label: 'Brave' }
|
||||
],
|
||||
detectedBrowsersLoaded: true,
|
||||
supportedImportLabels: SUPPORTED_LABELS
|
||||
})
|
||||
).toBe('Detected: Google Chrome, Safari, Microsoft Edge, Arc, +2 more.')
|
||||
})
|
||||
|
||||
it('lists supported import sources before detection runs', () => {
|
||||
expect(
|
||||
formatBrowserImportSummary({
|
||||
detectedBrowsers: [],
|
||||
detectedBrowsersLoaded: false,
|
||||
supportedImportLabels: SUPPORTED_LABELS
|
||||
})
|
||||
).toBe('Import from: Google Chrome, Microsoft Edge, Arc, Brave, +3 more.')
|
||||
})
|
||||
|
||||
it('falls back to supported import sources when detection finds nothing', () => {
|
||||
expect(
|
||||
formatBrowserImportSummary({
|
||||
detectedBrowsers: [],
|
||||
detectedBrowsersLoaded: true,
|
||||
supportedImportLabels: SUPPORTED_LABELS
|
||||
})
|
||||
).toBe('Import from: Google Chrome, Microsoft Edge, Arc, Brave, +3 more.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
type BrowserImportSummaryLabel = {
|
||||
label: string
|
||||
}
|
||||
|
||||
export type FormatBrowserImportSummaryArgs = {
|
||||
detectedBrowsers: readonly BrowserImportSummaryLabel[]
|
||||
detectedBrowsersLoaded: boolean
|
||||
supportedImportLabels: readonly string[]
|
||||
maxNamed?: number
|
||||
}
|
||||
|
||||
function formatLabelList(prefix: string, labels: readonly string[], maxNamed: number): string {
|
||||
if (labels.length === 0) {
|
||||
return `${prefix} file.`
|
||||
}
|
||||
if (labels.length <= maxNamed) {
|
||||
return `${prefix}: ${labels.join(', ')}.`
|
||||
}
|
||||
|
||||
const named = labels.slice(0, maxNamed)
|
||||
const remaining = labels.length - maxNamed
|
||||
return `${prefix}: ${named.join(', ')}, +${remaining} more.`
|
||||
}
|
||||
|
||||
export function formatBrowserImportSummary({
|
||||
detectedBrowsers,
|
||||
detectedBrowsersLoaded,
|
||||
supportedImportLabels,
|
||||
maxNamed = 4
|
||||
}: FormatBrowserImportSummaryArgs): string {
|
||||
if (detectedBrowsersLoaded && detectedBrowsers.length > 0) {
|
||||
return formatLabelList(
|
||||
'Detected',
|
||||
detectedBrowsers.map((browser) => browser.label),
|
||||
maxNamed
|
||||
)
|
||||
}
|
||||
|
||||
return formatLabelList('Import from', supportedImportLabels, maxNamed)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldShowBrowserImportHint } from './browser-import-hint-visibility'
|
||||
|
||||
describe('shouldShowBrowserImportHint', () => {
|
||||
it('shows after persisted UI loads', () => {
|
||||
expect(
|
||||
shouldShowBrowserImportHint({
|
||||
persistedUIReady: true,
|
||||
browserImportHintHidden: false
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('stays hidden until persisted UI loads', () => {
|
||||
expect(
|
||||
shouldShowBrowserImportHint({
|
||||
persistedUIReady: false,
|
||||
browserImportHintHidden: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('honors explicit hint dismissal', () => {
|
||||
expect(
|
||||
shouldShowBrowserImportHint({
|
||||
persistedUIReady: true,
|
||||
browserImportHintHidden: true
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
export type BrowserImportHintVisibilityInput = {
|
||||
persistedUIReady: boolean
|
||||
browserImportHintHidden: boolean
|
||||
}
|
||||
|
||||
export function shouldShowBrowserImportHint({
|
||||
persistedUIReady,
|
||||
browserImportHintHidden
|
||||
}: BrowserImportHintVisibilityInput): boolean {
|
||||
// Why: keep import one click away so users can re-import cookies without
|
||||
// hunting through Settings or the toolbar overflow menu.
|
||||
return persistedUIReady && !browserImportHintHidden
|
||||
}
|
||||
@@ -45,20 +45,20 @@ const actions: CmdJQuickAction[] = [
|
||||
{
|
||||
id: 'create-workspace',
|
||||
kind: 'action',
|
||||
title: 'Create Workspace',
|
||||
description: 'Create workspace.',
|
||||
title: 'Create Worktree',
|
||||
description: 'Create worktree.',
|
||||
icon: Globe,
|
||||
verbKeywords: ['create workspace', 'add workspace', 'new workspace'],
|
||||
verbKeywords: ['create worktree', 'add worktree', 'new worktree'],
|
||||
isAvailable: available,
|
||||
run: noopRun
|
||||
},
|
||||
{
|
||||
id: 'delete-workspace',
|
||||
kind: 'action',
|
||||
title: 'Delete Workspace',
|
||||
description: 'Delete the current workspace.',
|
||||
title: 'Delete Worktree',
|
||||
description: 'Delete the current worktree.',
|
||||
icon: Globe,
|
||||
verbKeywords: ['delete workspace', 'delete current workspace', 'remove workspace'],
|
||||
verbKeywords: ['delete worktree', 'delete current worktree', 'remove worktree'],
|
||||
isAvailable: available,
|
||||
run: noopRun
|
||||
},
|
||||
@@ -154,11 +154,11 @@ describe('Cmd+J palette middle-band ranking', () => {
|
||||
['new terminal', 'new-terminal-tab'],
|
||||
['new markdown', 'new-markdown-file'],
|
||||
['new browser', 'new-browser-tab'],
|
||||
['create workspace', 'create-workspace'],
|
||||
['add workspace', 'create-workspace'],
|
||||
['new workspace', 'create-workspace'],
|
||||
['delete workspace', 'delete-workspace'],
|
||||
['remove workspace', 'delete-workspace'],
|
||||
['create worktree', 'create-workspace'],
|
||||
['add worktree', 'create-workspace'],
|
||||
['new worktree', 'create-workspace'],
|
||||
['delete worktree', 'delete-workspace'],
|
||||
['remove worktree', 'delete-workspace'],
|
||||
['terminal settings', 'settings:terminal'],
|
||||
['browser settings', 'settings:browser'],
|
||||
['ssh', 'settings:ssh'],
|
||||
|
||||
@@ -88,10 +88,10 @@ export const CMD_J_QUICK_ACTIONS: readonly CmdJQuickAction[] = [
|
||||
{
|
||||
id: CREATE_WORKSPACE_QUICK_ACTION_ID,
|
||||
kind: 'action',
|
||||
title: 'Create Workspace',
|
||||
description: 'Start a new workspace.',
|
||||
title: 'Create Worktree',
|
||||
description: 'Start a new worktree.',
|
||||
icon: FolderPlus,
|
||||
verbKeywords: ['create workspace', 'add workspace', 'new workspace'],
|
||||
verbKeywords: ['create worktree', 'add worktree', 'new worktree'],
|
||||
isAvailable: () => ({ available: true }),
|
||||
run: async (ctx) => {
|
||||
ctx.openCreateWorkspace()
|
||||
@@ -101,16 +101,14 @@ export const CMD_J_QUICK_ACTIONS: readonly CmdJQuickAction[] = [
|
||||
{
|
||||
id: 'delete-workspace',
|
||||
kind: 'action',
|
||||
title: 'Delete Workspace',
|
||||
description: 'Delete the current workspace.',
|
||||
title: 'Delete Worktree',
|
||||
description: 'Delete the current worktree.',
|
||||
icon: Trash2,
|
||||
verbKeywords: [
|
||||
'delete workspace',
|
||||
'delete current workspace',
|
||||
'delete worktree',
|
||||
'remove workspace',
|
||||
'delete current worktree',
|
||||
'remove worktree',
|
||||
'trash workspace'
|
||||
'trash worktree'
|
||||
],
|
||||
isAvailable: currentWorkspaceActionAvailability,
|
||||
run: async (ctx) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CornerDownLeft, Pencil, Trash, FileText } from 'lucide-react'
|
||||
import { CornerDownLeft, Pencil, Trash } from 'lucide-react'
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getDiffCommentLineLabel } from '@/lib/diff-comment-compat'
|
||||
@@ -213,7 +213,6 @@ export function DiffCommentCard({
|
||||
{/* Quote Block */}
|
||||
{quote ? (
|
||||
<div className="orca-diff-comment-quote">
|
||||
<FileText className="size-3.5 flex-shrink-0 text-amber-500 mt-0.5" />
|
||||
<div className="orca-diff-comment-quote-text">{quote}</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -36,6 +36,21 @@ describe('stackRichMarkdownReviewNotePositions', () => {
|
||||
expect(stacked[0].top).toBe(100)
|
||||
expect(stacked[1].top).toBeGreaterThan(stacked[0].top)
|
||||
})
|
||||
|
||||
it('reserves quote height for fallback source-line excerpts', () => {
|
||||
const stacked = stackRichMarkdownReviewNotePositions([
|
||||
{
|
||||
comment: makeComment({ id: 'first', body: 'note' }),
|
||||
top: 100
|
||||
},
|
||||
{
|
||||
comment: makeComment({ id: 'second', body: 'note' }),
|
||||
top: 100
|
||||
}
|
||||
])
|
||||
|
||||
expect(stacked[1].top).toBe(210)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldExpandRichMarkdownReviewRail', () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ export type RichMarkdownReviewRailState = {
|
||||
const REVIEW_NOTE_GAP_PX = 8
|
||||
const REVIEW_NOTE_BASE_HEIGHT_PX = 58 // new Notion-style card base height (padding, border, gap, header)
|
||||
const REVIEW_NOTE_BODY_LINE_HEIGHT_PX = 20 // new body line-height
|
||||
const REVIEW_NOTE_QUOTE_HEIGHT_PX = 32 // new quote height (border, margins, text)
|
||||
const REVIEW_NOTE_QUOTE_HEIGHT_PX = 24 // single-line quote height (line-height plus margins)
|
||||
|
||||
function getReviewNoteStartLine(comment: Pick<DiffComment, 'lineNumber' | 'startLine'>): number {
|
||||
return comment.startLine ?? comment.lineNumber
|
||||
@@ -46,10 +46,11 @@ export function stackRichMarkdownReviewNotePositions(
|
||||
return [...positions].sort(compareRichMarkdownReviewNotePositions).map((position) => {
|
||||
const top = Math.max(position.top, nextOpenTop)
|
||||
const measured = measuredHeights?.get(position.comment.id)
|
||||
// Why: cards render a fallback source-line quote even when no exact text was selected.
|
||||
const estimatedHeight =
|
||||
REVIEW_NOTE_BASE_HEIGHT_PX +
|
||||
position.comment.body.split('\n').length * REVIEW_NOTE_BODY_LINE_HEIGHT_PX +
|
||||
(position.comment.selectedText ? REVIEW_NOTE_QUOTE_HEIGHT_PX : 0)
|
||||
REVIEW_NOTE_QUOTE_HEIGHT_PX
|
||||
const height = measured ?? estimatedHeight
|
||||
nextOpenTop = top + height + REVIEW_NOTE_GAP_PX
|
||||
return { ...position, top }
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CmdJPaletteFeatureTipVisual } from './CmdJPaletteFeatureTipVisual'
|
||||
|
||||
const prefersReducedMotionMock = vi.hoisted(() => vi.fn(() => false))
|
||||
const shortcutKeysMock = vi.hoisted(() => vi.fn(() => ['⌘', 'J']))
|
||||
const formatShortcutKeysMock = vi.hoisted(() => vi.fn(() => ['⌘', 'J']))
|
||||
|
||||
vi.mock('@/components/feature-wall/feature-wall-modal-helpers', () => ({
|
||||
usePrefersReducedMotion: prefersReducedMotionMock
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/useShortcutLabel', () => ({
|
||||
useShortcutKeys: shortcutKeysMock,
|
||||
formatShortcutKeys: formatShortcutKeysMock
|
||||
}))
|
||||
|
||||
async function renderVisual(): Promise<{ container: HTMLDivElement; root: Root }> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
|
||||
await act(async () => {
|
||||
root.render(<CmdJPaletteFeatureTipVisual />)
|
||||
})
|
||||
|
||||
return { container, root }
|
||||
}
|
||||
|
||||
describe('CmdJPaletteFeatureTipVisual', () => {
|
||||
beforeEach(() => {
|
||||
prefersReducedMotionMock.mockReturnValue(false)
|
||||
shortcutKeysMock.mockReturnValue(['⌘', 'J'])
|
||||
formatShortcutKeysMock.mockReturnValue(['⌘', 'J'])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('renders the static end state without motion classes when reduced motion is preferred', () => {
|
||||
prefersReducedMotionMock.mockReturnValue(true)
|
||||
|
||||
const html = renderToStaticMarkup(<CmdJPaletteFeatureTipVisual />)
|
||||
|
||||
expect(html).toContain('auth')
|
||||
expect(html).toContain('auth-redirect')
|
||||
expect(html).not.toContain('payments-api')
|
||||
expect(html).not.toContain('animate-spin')
|
||||
expect(html).not.toContain('animate-cmd-j-tip-result-in')
|
||||
})
|
||||
|
||||
it('does not schedule animation timers when reduced motion is preferred', async () => {
|
||||
prefersReducedMotionMock.mockReturnValue(true)
|
||||
const setTimeoutSpy = vi.spyOn(window, 'setTimeout')
|
||||
|
||||
const { root } = await renderVisual()
|
||||
await act(async () => {
|
||||
root.unmount()
|
||||
})
|
||||
|
||||
expect(setTimeoutSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears pending timers on unmount', async () => {
|
||||
vi.useFakeTimers()
|
||||
const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout')
|
||||
|
||||
const { root } = await renderVisual()
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
await act(async () => {
|
||||
root.unmount()
|
||||
})
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to default per-key chips when the live binding is unassigned', () => {
|
||||
shortcutKeysMock.mockReturnValue([])
|
||||
formatShortcutKeysMock.mockReturnValue(['Ctrl', 'Shift', 'J'])
|
||||
|
||||
const html = renderToStaticMarkup(<CmdJPaletteFeatureTipVisual />)
|
||||
|
||||
expect(formatShortcutKeysMock).toHaveBeenCalledWith('worktree.palette')
|
||||
expect(html).toContain('Ctrl')
|
||||
expect(html).toContain('Shift')
|
||||
expect(html).toContain('J')
|
||||
})
|
||||
|
||||
it('shows the create-worktree row only once the demo query is non-empty', () => {
|
||||
prefersReducedMotionMock.mockReturnValue(true)
|
||||
const html = renderToStaticMarkup(<CmdJPaletteFeatureTipVisual />)
|
||||
|
||||
expect(html).toContain('Create worktree')
|
||||
expect(html).toContain('auth')
|
||||
})
|
||||
|
||||
it('renders the live binding as separate shortcut key chips with plus separators', () => {
|
||||
shortcutKeysMock.mockReturnValue(['⌘', 'J'])
|
||||
|
||||
const html = renderToStaticMarkup(<CmdJPaletteFeatureTipVisual />)
|
||||
|
||||
expect(html).toContain('⌘')
|
||||
expect(html).toContain('J')
|
||||
expect(html).toContain('+')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,266 @@
|
||||
import { Fragment, useEffect, useState, type JSX } from 'react'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { usePrefersReducedMotion } from '@/components/feature-wall/feature-wall-modal-helpers'
|
||||
import { formatShortcutKeys, useShortcutKeys } from '@/hooks/useShortcutLabel'
|
||||
|
||||
const TYPED_QUERY = 'auth'
|
||||
// Why: the real palette lists recent worktrees on open; typing only narrows the
|
||||
// list. Mix done + running states so the tip reads like a live worktree switcher.
|
||||
const DEMO_WORKTREES: readonly {
|
||||
key: string
|
||||
name: string
|
||||
branch: string
|
||||
status: 'done' | 'running'
|
||||
}[] = [
|
||||
{ key: '1', name: 'payments-api', branch: 'feat/payments-api', status: 'done' },
|
||||
{ key: '2', name: 'auth-redirect', branch: 'fix/auth-redirect', status: 'done' },
|
||||
{ key: '3', name: 'oauth-callback', branch: 'fix/oauth-callback', status: 'running' },
|
||||
{ key: '4', name: 'docs-site', branch: 'main', status: 'done' }
|
||||
]
|
||||
|
||||
function filterDemoWorktrees(query: string): typeof DEMO_WORKTREES {
|
||||
const normalized = query.trim().toLowerCase()
|
||||
if (!normalized) {
|
||||
return DEMO_WORKTREES
|
||||
}
|
||||
return DEMO_WORKTREES.filter(
|
||||
(worktree) =>
|
||||
worktree.name.toLowerCase().includes(normalized) ||
|
||||
worktree.branch.toLowerCase().includes(normalized)
|
||||
)
|
||||
}
|
||||
|
||||
// Why: cycle phases are sequenced so the keypress visibly precedes the palette
|
||||
// opening (cause → effect), matching what the user will see when they actually
|
||||
// press the shortcut.
|
||||
type CyclePhase = 'idle' | 'pressed' | 'open' | 'typing' | 'closing'
|
||||
|
||||
type ClosingFrame = {
|
||||
query: string
|
||||
worktrees: ReturnType<typeof filterDemoWorktrees>
|
||||
showCreate: boolean
|
||||
}
|
||||
|
||||
const KEYPRESS_AT_MS = 450
|
||||
const PALETTE_OPEN_AT_MS = 850
|
||||
// Why: linger on the empty-query worktree list so users notice the palette
|
||||
// already shows recent worktrees before filtering kicks in.
|
||||
const HOLD_BEFORE_TYPING_MS = 700
|
||||
// Per-character typing interval. Kept tight and constant so the cursor advances
|
||||
// at an even cadence instead of feeling staggered.
|
||||
const TYPE_INTERVAL_MS = 120
|
||||
// Pause on the final, filtered state before the cycle resets, so the
|
||||
// user has time to actually read the matched worktrees + create option.
|
||||
const HOLD_AFTER_RESULTS_MS = 3200
|
||||
// Matches the palette container's `duration-300` fade plus a small buffer so we
|
||||
// never swap list content while the closing fade is still running.
|
||||
const PALETTE_FADE_OUT_MS = 350
|
||||
|
||||
export function CmdJPaletteFeatureTipVisual(): JSX.Element {
|
||||
const reducedMotion = usePrefersReducedMotion()
|
||||
// Why: render the live binding so the cue stays correct after a rebind and on
|
||||
// platforms where Cmd+J is not the default (Linux/Windows use Ctrl+Shift+J).
|
||||
const shortcutKeys = useShortcutKeys('worktree.palette')
|
||||
// Why: the press animation staggers per-key chips (⌘ then J); fall back to the
|
||||
// platform default when the user disables the binding.
|
||||
const displayShortcutKeys =
|
||||
shortcutKeys.length > 0 ? shortcutKeys : formatShortcutKeys('worktree.palette')
|
||||
|
||||
const [phase, setPhase] = useState<CyclePhase>('idle')
|
||||
const [typedLength, setTypedLength] = useState(0)
|
||||
const [closingFrame, setClosingFrame] = useState<ClosingFrame | null>(null)
|
||||
|
||||
// Why: for reduced-motion users, jump straight to the fully-populated end
|
||||
// state so they see what the feature does without any animation.
|
||||
const isPressed = !reducedMotion && phase === 'pressed'
|
||||
const effectiveTypedLength = reducedMotion ? TYPED_QUERY.length : typedLength
|
||||
const currentQuery = TYPED_QUERY.slice(0, effectiveTypedLength)
|
||||
const visibleWorktrees = filterDemoWorktrees(currentQuery)
|
||||
const showCreateAction = currentQuery.trim().length > 0
|
||||
// Why: snapshot the final filtered frame during `closing` so loop reset never
|
||||
// re-renders the empty-query worktree list while the palette is still visible.
|
||||
const renderQuery = phase === 'closing' && closingFrame ? closingFrame.query : currentQuery
|
||||
const renderWorktrees =
|
||||
phase === 'closing' && closingFrame ? closingFrame.worktrees : visibleWorktrees
|
||||
const renderShowCreate =
|
||||
phase === 'closing' && closingFrame ? closingFrame.showCreate : showCreateAction
|
||||
// Why: mirror WorktreeJumpPalette — recent worktrees render as soon as the
|
||||
// palette opens; typing only filters them down. Keep the list mounted through
|
||||
// `closing` so the final filtered frame fades out with the palette.
|
||||
const showWorktreeList =
|
||||
reducedMotion || phase === 'open' || phase === 'typing' || phase === 'closing'
|
||||
// Why: keep the palette hidden during `pressed` — an empty search shell between
|
||||
// cycles read as the pre-search list flashing back before the fade finished.
|
||||
const paletteMounted =
|
||||
reducedMotion || phase === 'open' || phase === 'typing' || phase === 'closing'
|
||||
const paletteOpaque = reducedMotion || (paletteMounted && phase !== 'closing')
|
||||
const resultEnterClass =
|
||||
showWorktreeList && !reducedMotion && phase === 'open' ? 'animate-cmd-j-tip-result-in' : ''
|
||||
|
||||
useEffect(() => {
|
||||
if (reducedMotion) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const timeouts: number[] = []
|
||||
const later = (fn: () => void, ms: number): void => {
|
||||
timeouts.push(window.setTimeout(() => !cancelled && fn(), ms))
|
||||
}
|
||||
|
||||
const startTyping = (startIndex: number): void => {
|
||||
let i = startIndex
|
||||
const typeNext = (): void => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
i += 1
|
||||
setTypedLength(i)
|
||||
if (i >= TYPED_QUERY.length) {
|
||||
later(() => closeAndRestart(), HOLD_AFTER_RESULTS_MS)
|
||||
return
|
||||
}
|
||||
timeouts.push(window.setTimeout(typeNext, TYPE_INTERVAL_MS))
|
||||
}
|
||||
if (i >= TYPED_QUERY.length) {
|
||||
later(() => closeAndRestart(), HOLD_AFTER_RESULTS_MS)
|
||||
return
|
||||
}
|
||||
later(typeNext, TYPE_INTERVAL_MS)
|
||||
}
|
||||
|
||||
const scheduleCycle = (): void => {
|
||||
later(() => setPhase('pressed'), KEYPRESS_AT_MS)
|
||||
later(() => setPhase('open'), PALETTE_OPEN_AT_MS)
|
||||
later(() => {
|
||||
setPhase('typing')
|
||||
startTyping(0)
|
||||
}, PALETTE_OPEN_AT_MS + HOLD_BEFORE_TYPING_MS)
|
||||
}
|
||||
|
||||
const closeAndRestart = (): void => {
|
||||
setClosingFrame({
|
||||
query: TYPED_QUERY,
|
||||
worktrees: filterDemoWorktrees(TYPED_QUERY),
|
||||
showCreate: true
|
||||
})
|
||||
setPhase('closing')
|
||||
later(() => {
|
||||
setPhase('idle')
|
||||
setClosingFrame(null)
|
||||
setTypedLength(0)
|
||||
scheduleCycle()
|
||||
}, PALETTE_FADE_OUT_MS)
|
||||
}
|
||||
|
||||
setPhase('idle')
|
||||
setTypedLength(0)
|
||||
scheduleCycle()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
timeouts.forEach((id) => window.clearTimeout(id))
|
||||
}
|
||||
}, [reducedMotion])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex h-full min-h-[23rem] flex-col items-center justify-center overflow-hidden px-6 py-7"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{displayShortcutKeys.length > 0 ? (
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
{displayShortcutKeys.map((key, index) => (
|
||||
<Fragment key={`${key}-${index}`}>
|
||||
{index > 0 ? (
|
||||
<span className="text-xs text-muted-foreground" aria-hidden="true">
|
||||
+
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={`inline-flex h-7 min-w-7 items-center justify-center rounded-md border border-border/80 px-2 text-xs font-semibold text-muted-foreground shadow-xs transition-[transform,background-color] duration-150 ease-out ${
|
||||
isPressed
|
||||
? 'translate-y-[1.5px] bg-foreground/[0.18]'
|
||||
: 'translate-y-0 bg-foreground/[0.08]'
|
||||
}`}
|
||||
style={isPressed ? { transitionDelay: `${index * 40}ms` } : undefined}
|
||||
>
|
||||
{key}
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={`relative mt-3 h-[12.75rem] w-full max-w-[21rem] overflow-hidden rounded-xl border border-border bg-card text-left shadow-lg transition-opacity duration-300 ease-out ${
|
||||
!paletteMounted
|
||||
? 'pointer-events-none invisible opacity-0'
|
||||
: paletteOpaque
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
{/* Why: search and results are absolutely positioned so row content
|
||||
changes during the demo never reflow the input bar mid-animation. */}
|
||||
<div className="absolute inset-x-0 top-0 flex h-11 items-center gap-2 border-b border-border bg-muted/20 px-3">
|
||||
<Search className="size-4 shrink-0 text-muted-foreground/70" />
|
||||
<div className="h-5 min-w-0 flex-1 overflow-hidden text-[13px] leading-5 text-foreground/90">
|
||||
<span className="block truncate">
|
||||
{renderQuery}
|
||||
{!reducedMotion && (phase === 'open' || phase === 'typing') ? (
|
||||
<span className="ml-px inline-block h-[14px] w-px -translate-y-px align-middle bg-foreground/75 animate-cmd-j-tip-caret" />
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showWorktreeList ? (
|
||||
<div className="absolute inset-x-0 top-11 bottom-0 flex flex-col gap-0.5 overflow-hidden p-1.5">
|
||||
{renderWorktrees.map((result) => (
|
||||
<div
|
||||
key={result.key}
|
||||
className={`flex shrink-0 items-center gap-2.5 rounded-lg border border-transparent px-2.5 py-1.5 ${resultEnterClass}`}
|
||||
>
|
||||
<span className="flex w-4 shrink-0 items-center justify-center">
|
||||
{result.status === 'done' ? (
|
||||
<span className="size-2.5 rounded-full bg-emerald-500" aria-hidden="true" />
|
||||
) : (
|
||||
// Why: yellow border spinner mirrors StatusIndicator's
|
||||
// 'working' affordance, so users connect the icon to the
|
||||
// same running-workspace state they see in the sidebar.
|
||||
<span
|
||||
className={`block size-2.5 rounded-full border-[1.5px] border-yellow-500 ${
|
||||
reducedMotion ? 'border-t-yellow-500' : 'animate-spin border-t-transparent'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12.5px] font-semibold tracking-[-0.01em] text-foreground">
|
||||
{result.name}
|
||||
</span>
|
||||
<span className="block truncate text-[10px] text-muted-foreground/70">
|
||||
{result.branch}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{renderShowCreate ? (
|
||||
<div
|
||||
className={`mt-0.5 flex shrink-0 items-center gap-2.5 rounded-lg border border-dashed border-border/60 bg-muted/10 px-2.5 py-1.5 ${resultEnterClass}`}
|
||||
>
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-dashed border-border/60 bg-muted/25 text-muted-foreground/70">
|
||||
<Plus size={13} aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 truncate text-[12.5px] font-semibold tracking-[-0.01em] text-foreground">
|
||||
{`Create worktree "${renderQuery.trim()}"`}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { FEATURE_TIPS, type FeatureTip } from '../../../../shared/feature-tips'
|
||||
import { CmdJPaletteTipDialog } from './CmdJPaletteTipDialog'
|
||||
|
||||
const shortcutLabelMock = vi.hoisted(() => vi.fn(() => '⌘J'))
|
||||
const formatShortcutLabelMock = vi.hoisted(() => vi.fn(() => '⌘J'))
|
||||
|
||||
vi.mock('@/hooks/useShortcutLabel', () => ({
|
||||
useShortcutLabel: shortcutLabelMock,
|
||||
formatShortcutLabel: formatShortcutLabelMock
|
||||
}))
|
||||
|
||||
vi.mock('./CmdJPaletteFeatureTipVisual', () => ({
|
||||
CmdJPaletteFeatureTipVisual: () => <div data-testid="palette-visual" />
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/dialog', () => ({
|
||||
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
|
||||
DialogFooter: ({ children }: { children: ReactNode }) => <footer>{children}</footer>,
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => <header>{children}</header>,
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>
|
||||
}))
|
||||
|
||||
vi.mock('./FeatureTipActions', () => ({
|
||||
FeatureTipActions: () => <div data-testid="feature-tip-actions" />
|
||||
}))
|
||||
|
||||
function getCmdJPaletteTip(): FeatureTip {
|
||||
const tip = FEATURE_TIPS.find((entry) => entry.id === 'cmd-j-palette')
|
||||
if (!tip) {
|
||||
throw new Error('Expected cmd-j-palette feature tip fixture')
|
||||
}
|
||||
return { ...tip }
|
||||
}
|
||||
|
||||
const paletteTip = getCmdJPaletteTip()
|
||||
|
||||
function renderDialog(): string {
|
||||
return renderToStaticMarkup(
|
||||
<CmdJPaletteTipDialog
|
||||
open
|
||||
tip={paletteTip}
|
||||
primaryBusy={false}
|
||||
onOpenChange={vi.fn()}
|
||||
onPrimaryAction={vi.fn()}
|
||||
onSkip={vi.fn()}
|
||||
onRebindClick={vi.fn()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('CmdJPaletteTipDialog', () => {
|
||||
it('shows a tip badge so the dialog reads as education, not a new feature', () => {
|
||||
const html = renderDialog()
|
||||
|
||||
expect(html).toContain('TIP')
|
||||
})
|
||||
|
||||
it('inlines the live shortcut label in the title', () => {
|
||||
shortcutLabelMock.mockReturnValue('⌘J')
|
||||
|
||||
const html = renderDialog()
|
||||
|
||||
expect(html).toContain('Jump to a worktree with')
|
||||
expect(html).toContain('⌘J')
|
||||
})
|
||||
|
||||
it('falls back to the default shortcut label when the binding is unassigned', () => {
|
||||
shortcutLabelMock.mockReturnValue('Unassigned')
|
||||
formatShortcutLabelMock.mockReturnValue('Ctrl+Shift+J')
|
||||
|
||||
const html = renderDialog()
|
||||
|
||||
expect(formatShortcutLabelMock).toHaveBeenCalledWith('worktree.palette')
|
||||
expect(html).toContain('Ctrl+Shift+J')
|
||||
expect(html).not.toContain('Unassigned')
|
||||
})
|
||||
|
||||
it('keeps the rebind guidance inside the dialog description', () => {
|
||||
const html = renderDialog()
|
||||
|
||||
expect(html).toContain('Settings → Shortcuts')
|
||||
expect(html).toContain('spin up a new worktree')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { JSX } from 'react'
|
||||
import type { FeatureTip } from '../../../../shared/feature-tips'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { formatShortcutLabel, useShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
import { CmdJPaletteFeatureTipVisual } from './CmdJPaletteFeatureTipVisual'
|
||||
import { FeatureTipActions } from './FeatureTipActions'
|
||||
|
||||
export function CmdJPaletteTipDialog({
|
||||
open,
|
||||
tip,
|
||||
primaryBusy,
|
||||
onOpenChange,
|
||||
onPrimaryAction,
|
||||
onSkip,
|
||||
onRebindClick
|
||||
}: {
|
||||
open: boolean
|
||||
tip: FeatureTip
|
||||
primaryBusy: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onPrimaryAction: () => void
|
||||
onSkip: () => void
|
||||
onRebindClick: () => void
|
||||
}): JSX.Element {
|
||||
// Why: read the live binding so the title chip stays correct after a rebind
|
||||
// and on Linux/Windows (Ctrl+Shift+J) — matching the visual's key chips.
|
||||
const worktreePaletteShortcutLabel = useShortcutLabel('worktree.palette')
|
||||
const displayShortcutLabel =
|
||||
worktreePaletteShortcutLabel !== 'Unassigned'
|
||||
? worktreePaletteShortcutLabel
|
||||
: formatShortcutLabel('worktree.palette')
|
||||
// The tip's title uses "<shortcut>" as a placeholder token; split it so we
|
||||
// can render the live label as a styled <kbd> chip inline. Missing token
|
||||
// degrades to the plain title.
|
||||
const titleParts = tip.title.split('<shortcut>')
|
||||
const titlePrefix = titleParts[0]
|
||||
const titleSuffix = titleParts.slice(1).join('<shortcut>')
|
||||
|
||||
// Why: match the horizontal layout (text left, visual/animation right) used by the
|
||||
// CLI tip for a consistent "feature education" presentation; keeps the palette demo
|
||||
// prominent on the right.
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="!flex max-h-[calc(100vh-2rem)] flex-col gap-0 overflow-hidden bg-[color-mix(in_srgb,var(--foreground)_8%,var(--background))] p-0 dark:bg-[color-mix(in_srgb,var(--foreground)_16%,var(--background))] sm:max-w-4xl md:!h-[min(27rem,calc(100vh-2rem))] md:!flex-row"
|
||||
showCloseButton
|
||||
// Why: Radix auto-focuses the first focusable child; without this the
|
||||
// inline rebind link in the description gets the focus ring on open.
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="scrollbar-sleek flex min-h-0 min-w-0 flex-1 flex-col justify-between overflow-y-auto px-8 py-9 md:shrink-0 md:basis-1/2">
|
||||
<DialogHeader className="gap-4 text-left">
|
||||
<div>
|
||||
{/* Why: uppercase eyebrow reads as a category label, not a feature launch. */}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="mb-3 rounded-md px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground"
|
||||
>
|
||||
{tip.eyebrow.toUpperCase()}
|
||||
</Badge>
|
||||
<DialogTitle className="text-2xl font-semibold leading-tight tracking-tight md:text-[1.75rem]">
|
||||
<span className="inline-flex flex-wrap items-center gap-x-2.5 gap-y-1 md:flex-nowrap">
|
||||
<span>{titlePrefix.trimEnd()}</span>
|
||||
{displayShortcutLabel ? (
|
||||
<kbd className="inline-flex shrink-0 items-center rounded-md border border-border bg-card px-2 py-0.5 font-mono text-base font-medium text-foreground">
|
||||
{displayShortcutLabel}
|
||||
</kbd>
|
||||
) : null}
|
||||
{titleSuffix ? <span>{titleSuffix}</span> : null}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-3 max-w-2xl space-y-3 text-sm leading-relaxed">
|
||||
<span className="block">{tip.description}</span>
|
||||
<span className="block text-muted-foreground">
|
||||
Rebind the shortcut anytime in{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRebindClick}
|
||||
className="inline appearance-none border-0 bg-transparent p-0 font-medium text-foreground underline decoration-foreground/30 underline-offset-2 transition-colors hover:decoration-foreground focus-visible:outline-none focus-visible:decoration-foreground"
|
||||
>
|
||||
Settings → Shortcuts
|
||||
</button>
|
||||
.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="mt-8 flex sm:justify-stretch">
|
||||
<FeatureTipActions
|
||||
currentTip={tip}
|
||||
primaryBusy={primaryBusy}
|
||||
onPrimaryAction={onPrimaryAction}
|
||||
onSkip={onSkip}
|
||||
showSkip={false}
|
||||
fullWidth
|
||||
/>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
<div className="flex min-h-0 min-w-0 shrink-0 self-stretch overflow-hidden bg-muted/60 md:basis-1/2 md:border-l md:border-border/70">
|
||||
<div className="h-full min-h-[23rem] w-full md:w-[29.4rem]">
|
||||
<CmdJPaletteFeatureTipVisual />
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { JSX } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import type { FeatureTip, FeatureTipAction } from '../../../../shared/feature-tips'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
function getPrimaryBusyLabel(action: FeatureTipAction): string {
|
||||
if (action === 'setup-cli') {
|
||||
return 'Installing...'
|
||||
}
|
||||
return 'Working...'
|
||||
}
|
||||
|
||||
export function FeatureTipActions({
|
||||
currentTip,
|
||||
primaryBusy,
|
||||
onPrimaryAction,
|
||||
onSkip,
|
||||
showSkip = true,
|
||||
fullWidth = false
|
||||
}: {
|
||||
currentTip: FeatureTip
|
||||
primaryBusy: boolean
|
||||
onPrimaryAction: () => void
|
||||
onSkip: () => void
|
||||
showSkip?: boolean
|
||||
fullWidth?: boolean
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{showSkip ? (
|
||||
<Button variant="ghost" onClick={onSkip} disabled={primaryBusy}>
|
||||
Maybe Later
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
className={fullWidth ? 'w-full' : undefined}
|
||||
onClick={onPrimaryAction}
|
||||
disabled={primaryBusy}
|
||||
>
|
||||
{primaryBusy ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{getPrimaryBusyLabel(currentTip.action)}
|
||||
</>
|
||||
) : (
|
||||
currentTip.ctaLabel
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useEffect, useRef, useState, type JSX } from 'react'
|
||||
import { Loader2, Mic } from 'lucide-react'
|
||||
import { Mic } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { getDefaultVoiceSettings } from '../../../../shared/constants'
|
||||
import type { FeatureTip } from '../../../../shared/feature-tips'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
ORCHESTRATION_ENABLED_STORAGE_KEY,
|
||||
ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY,
|
||||
@@ -17,13 +16,18 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAppStore } from '@/store'
|
||||
import { CliFeatureTipVisual } from './CliFeatureTipVisual'
|
||||
import { CmdJPaletteFeatureTipVisual } from './CmdJPaletteFeatureTipVisual'
|
||||
import { CmdJPaletteTipDialog } from './CmdJPaletteTipDialog'
|
||||
import { CliSkillSetupTerminal } from './CliSkillSetupTerminal'
|
||||
import { FeatureTipActions } from './FeatureTipActions'
|
||||
import { installCliFromFeatureTip } from './feature-tip-cli-install-action'
|
||||
import { getFeatureTipForModal } from './feature-tip-modal-state'
|
||||
import {
|
||||
getOrcaCliFeatureTipTelemetrySource,
|
||||
trackCmdJPaletteFeatureTipAcknowledged,
|
||||
trackOrcaCliFeatureTipSetupClicked,
|
||||
trackOrcaCliFeatureTipSetupResult
|
||||
} from './feature-tip-telemetry'
|
||||
@@ -45,6 +49,10 @@ function FeatureTipVisual({ tip }: { tip: FeatureTip }): JSX.Element {
|
||||
}
|
||||
|
||||
switch (tip.action) {
|
||||
case 'learn-cmd-j-palette':
|
||||
// Kept for type exhaustiveness; the cmd-j tip is rendered via
|
||||
// CmdJPaletteTipDialog and never reaches this function at runtime.
|
||||
return <CmdJPaletteFeatureTipVisual />
|
||||
case 'enable-voice':
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2.5">
|
||||
@@ -66,46 +74,6 @@ function FeatureTipVisual({ tip }: { tip: FeatureTip }): JSX.Element {
|
||||
}
|
||||
}
|
||||
|
||||
function FeatureTipActions({
|
||||
currentTip,
|
||||
primaryBusy,
|
||||
onPrimaryAction,
|
||||
onSkip,
|
||||
showSkip = true,
|
||||
fullWidth = false
|
||||
}: {
|
||||
currentTip: FeatureTip
|
||||
primaryBusy: boolean
|
||||
onPrimaryAction: () => void
|
||||
onSkip: () => void
|
||||
showSkip?: boolean
|
||||
fullWidth?: boolean
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{showSkip ? (
|
||||
<Button variant="ghost" onClick={onSkip} disabled={primaryBusy}>
|
||||
Maybe Later
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
className={fullWidth ? 'w-full' : undefined}
|
||||
onClick={onPrimaryAction}
|
||||
disabled={primaryBusy}
|
||||
>
|
||||
{primaryBusy ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
currentTip.ctaLabel
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FeatureTipsModal(): JSX.Element | null {
|
||||
const activeModal = useAppStore((s) => s.activeModal)
|
||||
const closeModal = useAppStore((s) => s.closeModal)
|
||||
@@ -164,6 +132,16 @@ export default function FeatureTipsModal(): JSX.Element | null {
|
||||
openSettingsPage()
|
||||
}
|
||||
|
||||
const openShortcutsSettings = (): void => {
|
||||
// Why: dismiss the tip when navigating away — the tip's job is done once
|
||||
// the user clicks through to rebind, and leaving it mounted behind the
|
||||
// settings page would re-appear on close.
|
||||
markCurrentTipSeen()
|
||||
closeModal()
|
||||
openSettingsTarget({ pane: 'shortcuts', repoId: null })
|
||||
openSettingsPage()
|
||||
}
|
||||
|
||||
const enableOrchestrationSkillSetup = (): void => {
|
||||
localStorage.setItem(ORCHESTRATION_ENABLED_STORAGE_KEY, '1')
|
||||
localStorage.removeItem(ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY)
|
||||
@@ -177,6 +155,15 @@ export default function FeatureTipsModal(): JSX.Element | null {
|
||||
|
||||
markFeatureTipsSeen([currentTip.id])
|
||||
switch (currentTip.action) {
|
||||
case 'learn-cmd-j-palette': {
|
||||
// Why: passive education tip — acknowledging just dismisses; the rebind
|
||||
// path lives in Settings and is reachable from the palette itself.
|
||||
trackCmdJPaletteFeatureTipAcknowledged(
|
||||
getOrcaCliFeatureTipTelemetrySource(modalData.source)
|
||||
)
|
||||
closeModal()
|
||||
break
|
||||
}
|
||||
case 'enable-voice': {
|
||||
const voice = settings?.voice ?? getDefaultVoiceSettings()
|
||||
void updateSettings({
|
||||
@@ -341,6 +328,20 @@ export default function FeatureTipsModal(): JSX.Element | null {
|
||||
)
|
||||
}
|
||||
|
||||
if (currentTip.action === 'learn-cmd-j-palette') {
|
||||
return (
|
||||
<CmdJPaletteTipDialog
|
||||
open={isOpen}
|
||||
tip={currentTip}
|
||||
primaryBusy={primaryBusy}
|
||||
onOpenChange={handleOpenChange}
|
||||
onPrimaryAction={() => void handlePrimaryAction()}
|
||||
onSkip={handleSkip}
|
||||
onRebindClick={openShortcutsSettings}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md gap-4 p-7" showCloseButton>
|
||||
@@ -360,6 +361,7 @@ export default function FeatureTipsModal(): JSX.Element | null {
|
||||
primaryBusy={primaryBusy}
|
||||
onPrimaryAction={() => void handlePrimaryAction()}
|
||||
onSkip={handleSkip}
|
||||
showSkip
|
||||
/>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -49,11 +49,23 @@ describe('feature tip modal state', () => {
|
||||
expect(tip?.id).toBe('orca-cli')
|
||||
})
|
||||
|
||||
it('falls back to the command palette tip after the CLI tip is handled', () => {
|
||||
const tip = getFeatureTipForModal({
|
||||
cliInstalled: true,
|
||||
modalData: {},
|
||||
seenTipIds: ['orca-cli'],
|
||||
featureInteractions: {},
|
||||
settings: makeSettings()
|
||||
})
|
||||
|
||||
expect(tip?.id).toBe('cmd-j-palette')
|
||||
})
|
||||
|
||||
it('returns no tip when every tip is already seen and no modal tip id is pinned', () => {
|
||||
const tip = getFeatureTipForModal({
|
||||
cliInstalled: false,
|
||||
modalData: {},
|
||||
seenTipIds: ['voice-dictation', 'orca-cli'],
|
||||
seenTipIds: ['voice-dictation', 'orca-cli', 'cmd-j-palette'],
|
||||
featureInteractions: {},
|
||||
settings: makeSettings()
|
||||
})
|
||||
@@ -65,7 +77,7 @@ describe('feature tip modal state', () => {
|
||||
const tip = getFeatureTipForModal({
|
||||
cliInstalled: true,
|
||||
modalData: {},
|
||||
seenTipIds: ['voice-dictation'],
|
||||
seenTipIds: ['voice-dictation', 'cmd-j-palette'],
|
||||
featureInteractions: {},
|
||||
settings: makeSettings()
|
||||
})
|
||||
@@ -77,7 +89,7 @@ describe('feature tip modal state', () => {
|
||||
const tip = getFeatureTipForModal({
|
||||
cliInstalled: true,
|
||||
modalData: {},
|
||||
seenTipIds: [],
|
||||
seenTipIds: ['cmd-j-palette'],
|
||||
featureInteractions: {
|
||||
'voice-dictation': { firstInteractedAt: 100, interactionCount: 1 }
|
||||
},
|
||||
|
||||
@@ -121,12 +121,28 @@ describe('feature tip startup gate', () => {
|
||||
).toEqual({ kind: 'open', tipId: 'orca-cli' })
|
||||
})
|
||||
|
||||
it('opens the command palette tip after the CLI tip was marked seen', () => {
|
||||
expect(
|
||||
getFeatureTipsAppOpenDecision({
|
||||
activeModal: 'none',
|
||||
cliInstalled: true,
|
||||
featureTipsSeenIds: ['orca-cli'],
|
||||
featureInteractions: {},
|
||||
onboarding: existingUserOnboarding,
|
||||
persistedUIReady: true,
|
||||
promptedThisSession: false,
|
||||
settings: makeSettings(),
|
||||
suppressedByOnboardingThisSession: false
|
||||
})
|
||||
).toEqual({ kind: 'open', tipId: 'cmd-j-palette' })
|
||||
})
|
||||
|
||||
it('does not open after every tip was marked seen', () => {
|
||||
expect(
|
||||
getFeatureTipsAppOpenDecision({
|
||||
activeModal: 'none',
|
||||
cliInstalled: false,
|
||||
featureTipsSeenIds: ['voice-dictation', 'orca-cli'],
|
||||
featureTipsSeenIds: ['voice-dictation', 'orca-cli', 'cmd-j-palette'],
|
||||
featureInteractions: {},
|
||||
onboarding: existingUserOnboarding,
|
||||
persistedUIReady: true,
|
||||
@@ -142,7 +158,7 @@ describe('feature tip startup gate', () => {
|
||||
getFeatureTipsAppOpenDecision({
|
||||
activeModal: 'none',
|
||||
cliInstalled: true,
|
||||
featureTipsSeenIds: ['voice-dictation'],
|
||||
featureTipsSeenIds: ['voice-dictation', 'cmd-j-palette'],
|
||||
featureInteractions: {},
|
||||
onboarding: existingUserOnboarding,
|
||||
persistedUIReady: true,
|
||||
@@ -158,7 +174,7 @@ describe('feature tip startup gate', () => {
|
||||
getFeatureTipsAppOpenDecision({
|
||||
activeModal: 'none',
|
||||
cliInstalled: true,
|
||||
featureTipsSeenIds: ['voice-dictation'],
|
||||
featureTipsSeenIds: ['voice-dictation', 'cmd-j-palette'],
|
||||
featureInteractions: {},
|
||||
onboarding: existingUserOnboarding,
|
||||
persistedUIReady: true,
|
||||
@@ -206,7 +222,7 @@ describe('feature tip startup gate', () => {
|
||||
getFeatureTipsAppOpenDecision({
|
||||
activeModal: 'none',
|
||||
cliInstalled: true,
|
||||
featureTipsSeenIds: [],
|
||||
featureTipsSeenIds: ['cmd-j-palette'],
|
||||
featureInteractions: {
|
||||
'voice-dictation': { firstInteractedAt: 100, interactionCount: 1 }
|
||||
},
|
||||
|
||||
@@ -8,6 +8,8 @@ vi.mock('@/lib/telemetry', () => ({
|
||||
|
||||
import {
|
||||
getOrcaCliFeatureTipTelemetrySource,
|
||||
trackCmdJPaletteFeatureTipAcknowledged,
|
||||
trackCmdJPaletteFeatureTipShown,
|
||||
trackOrcaCliFeatureTipSetupClicked,
|
||||
trackOrcaCliFeatureTipSetupResult,
|
||||
trackOrcaCliFeatureTipShown
|
||||
@@ -33,6 +35,19 @@ describe('feature tip telemetry', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks command palette tip exposure and acknowledgement', () => {
|
||||
trackCmdJPaletteFeatureTipShown('app_open')
|
||||
trackCmdJPaletteFeatureTipAcknowledged('manual')
|
||||
|
||||
expect(trackMock).toHaveBeenCalledTimes(2)
|
||||
expect(trackMock).toHaveBeenNthCalledWith(1, 'cmd_j_palette_feature_tip_shown', {
|
||||
source: 'app_open'
|
||||
})
|
||||
expect(trackMock).toHaveBeenNthCalledWith(2, 'cmd_j_palette_feature_tip_acknowledged', {
|
||||
source: 'manual'
|
||||
})
|
||||
})
|
||||
|
||||
it('tracks setup click and result without raw CLI details', () => {
|
||||
trackOrcaCliFeatureTipSetupClicked('app_open')
|
||||
trackOrcaCliFeatureTipSetupResult('app_open', 'installed')
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { EventProps } from '../../../../shared/telemetry-events'
|
||||
|
||||
export type OrcaCliFeatureTipSource = EventProps<'orca_cli_feature_tip_shown'>['source']
|
||||
export type OrcaCliFeatureTipSetupResult = EventProps<'orca_cli_feature_tip_setup_result'>['result']
|
||||
export type CmdJPaletteFeatureTipSource = EventProps<'cmd_j_palette_feature_tip_shown'>['source']
|
||||
|
||||
export function getOrcaCliFeatureTipTelemetrySource(value: unknown): OrcaCliFeatureTipSource {
|
||||
return value === 'app_open' ? 'app_open' : 'manual'
|
||||
@@ -22,3 +23,11 @@ export function trackOrcaCliFeatureTipSetupResult(
|
||||
): void {
|
||||
track('orca_cli_feature_tip_setup_result', { source, result })
|
||||
}
|
||||
|
||||
export function trackCmdJPaletteFeatureTipShown(source: CmdJPaletteFeatureTipSource): void {
|
||||
track('cmd_j_palette_feature_tip_shown', { source })
|
||||
}
|
||||
|
||||
export function trackCmdJPaletteFeatureTipAcknowledged(source: CmdJPaletteFeatureTipSource): void {
|
||||
track('cmd_j_palette_feature_tip_acknowledged', { source })
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import { Progress } from '@/components/ui/progress'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type {
|
||||
LinearCustomViewModel,
|
||||
LinearCustomViewSummary,
|
||||
LinearProjectDetail,
|
||||
LinearProjectSummary,
|
||||
@@ -56,7 +55,6 @@ type LinearProjectTableProps = {
|
||||
|
||||
type LinearCustomViewTableProps = {
|
||||
views: LinearCustomViewSummary[]
|
||||
model: LinearCustomViewModel
|
||||
loading: boolean
|
||||
hasError?: boolean
|
||||
selectedViewId?: string | null
|
||||
@@ -388,7 +386,6 @@ export function LinearProjectTable({
|
||||
|
||||
export function LinearCustomViewTable({
|
||||
views,
|
||||
model,
|
||||
loading,
|
||||
hasError,
|
||||
selectedViewId,
|
||||
@@ -420,7 +417,7 @@ export function LinearCustomViewTable({
|
||||
return (
|
||||
<div className="px-4 py-10 text-center">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{hasError ? `Unable to load ${model} views` : `No ${model} views found`}
|
||||
{hasError ? 'Unable to load views' : 'No views found'}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{hasError
|
||||
|
||||
@@ -48,6 +48,7 @@ describe('ChecksPanelReviewHeader', () => {
|
||||
|
||||
expect(markup).toContain('Open on GitHub')
|
||||
expect(markup).toContain('#2964')
|
||||
expect(markup).toContain('underline decoration-border underline-offset-2')
|
||||
expect(markup).toContain('More PR actions')
|
||||
expect(markup).toContain('unlink PR')
|
||||
expect(markup).toContain('Link another PR')
|
||||
|
||||
@@ -139,7 +139,7 @@ export function ChecksPanelReviewHeader({
|
||||
<ReviewIcon className="size-4 text-muted-foreground shrink-0" />
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-0.5 text-[12px] font-semibold text-foreground underline-offset-2 hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="rounded px-0.5 text-[12px] font-semibold text-foreground underline decoration-border underline-offset-2 hover:text-foreground hover:decoration-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
title={`Open on ${reviewHostLabel}`}
|
||||
onClick={onOpenReview}
|
||||
>
|
||||
|
||||
+1
@@ -31,6 +31,7 @@ describe('HostedReviewHeaderLink', () => {
|
||||
|
||||
expect(markup).toContain('<button')
|
||||
expect(markup).toContain('PR #2192')
|
||||
expect(markup).toContain('underline decoration-border underline-offset-2')
|
||||
expect(markup).not.toContain('href=')
|
||||
expect(markup).not.toContain('target="_blank"')
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Copy,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
GitFork,
|
||||
GitMerge,
|
||||
GitPullRequestArrow,
|
||||
MessageSquare,
|
||||
@@ -86,6 +87,7 @@ import {
|
||||
type PendingDiscardConfirmation
|
||||
} from './source-control-discard-dialog'
|
||||
import { refreshGitStatusForWorktree } from './git-status-refresh'
|
||||
import { describeForkPushTarget } from './fork-push-target-label'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -1002,7 +1004,7 @@ export function HostedReviewHeaderLink({
|
||||
}): React.JSX.Element {
|
||||
const label = hostedReviewLabel(review)
|
||||
const className =
|
||||
'shrink-0 border-0 bg-transparent p-0 text-left font-medium leading-none text-foreground opacity-80 hover:text-foreground hover:underline'
|
||||
'shrink-0 border-0 bg-transparent p-0 text-left font-medium leading-none text-foreground underline decoration-border underline-offset-2 opacity-80 hover:text-foreground hover:decoration-foreground'
|
||||
|
||||
if (review.provider === 'github' || review.provider === 'gitlab') {
|
||||
return (
|
||||
@@ -4393,6 +4395,18 @@ function SourceControlInner(): React.JSX.Element {
|
||||
clears. Active merge/rebase/cherry-pick operations are the
|
||||
exception: commits would be misleading before the user continues
|
||||
or aborts the operation. */}
|
||||
{activeWorktree?.pushTarget && activeWorktree.pushTarget.remoteName !== 'origin' ? (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-1 text-[11px] text-muted-foreground"
|
||||
title={`Pushes to the fork at ${activeWorktree.pushTarget.remoteName} (not origin)`}
|
||||
>
|
||||
<GitFork className="size-3 shrink-0" aria-hidden="true" />
|
||||
<span className="truncate">
|
||||
Pushes to fork {describeForkPushTarget(activeWorktree.pushTarget)}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{shouldRenderCommitArea(scope, unresolvedConflicts.length, conflictOperation) &&
|
||||
(primaryAction.kind === 'create_pr' ? (
|
||||
<PullRequestComposer
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { GitPushTarget } from '../../../../shared/types'
|
||||
import { describeForkPushTarget } from './fork-push-target-label'
|
||||
|
||||
function target(overrides: Partial<GitPushTarget>): GitPushTarget {
|
||||
return {
|
||||
remoteName: 'pr-contributor-orca',
|
||||
branchName: 'contributor/fix',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('describeForkPushTarget', () => {
|
||||
it('derives owner:branch from an SSH fork URL', () => {
|
||||
expect(
|
||||
describeForkPushTarget(target({ remoteUrl: 'git@github.com:contributor/orca.git' }))
|
||||
).toBe('contributor:contributor/fix')
|
||||
})
|
||||
|
||||
it('derives owner:branch from an HTTPS fork URL', () => {
|
||||
expect(
|
||||
describeForkPushTarget(target({ remoteUrl: 'https://github.com/contributor/orca.git' }))
|
||||
).toBe('contributor:contributor/fix')
|
||||
})
|
||||
|
||||
it('handles a URL without a .git suffix', () => {
|
||||
expect(
|
||||
describeForkPushTarget(target({ remoteUrl: 'https://github.com/contributor/orca' }))
|
||||
).toBe('contributor:contributor/fix')
|
||||
})
|
||||
|
||||
it('falls back to remoteName/branch when there is no remote URL', () => {
|
||||
expect(describeForkPushTarget(target({ remoteUrl: undefined }))).toBe(
|
||||
'pr-contributor-orca/contributor/fix'
|
||||
)
|
||||
})
|
||||
|
||||
it('works for non-GitHub hosts via the generic owner segment', () => {
|
||||
expect(
|
||||
describeForkPushTarget(target({ remoteUrl: 'git@gitlab.com:contributor/orca.git' }))
|
||||
).toBe('contributor:contributor/fix')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { GitPushTarget } from '../../../../shared/types'
|
||||
|
||||
// Why: a fork-PR worktree pushes to a contributor's fork, not origin. Render
|
||||
// "owner:branch" from the fork remote URL when available so the maintainer can
|
||||
// see at a glance where a push lands; fall back to the sanitized remote name.
|
||||
export function describeForkPushTarget(pushTarget: GitPushTarget): string {
|
||||
const ownerMatch = pushTarget.remoteUrl?.match(/[:/]([^/:]+)\/[^/]+?(?:\.git)?$/)
|
||||
const owner = ownerMatch?.[1]
|
||||
return owner
|
||||
? `${owner}:${pushTarget.branchName}`
|
||||
: `${pushTarget.remoteName}/${pushTarget.branchName}`
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { toast } from 'sonner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { track } from '@/lib/telemetry'
|
||||
import { getRepositoryLocalCommandsSectionId } from '@/components/settings/repository-settings-targets'
|
||||
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import {
|
||||
buildImportedHookSettings,
|
||||
@@ -380,16 +381,24 @@ function SetupScriptPromptCard(): React.JSX.Element | null {
|
||||
const candidateProvenance = candidate ? formatCandidateProvenance(candidate) : null
|
||||
|
||||
return (
|
||||
<div className="px-3 pb-2">
|
||||
// Why: shrink-0 keeps the card from being squeezed by a long worktree list
|
||||
// in the overflow-hidden sidebar column, which clipped its top edge.
|
||||
<div className="shrink-0 px-3 pb-2">
|
||||
<div className="setup-script-prompt-card rounded-lg border border-worktree-sidebar-border p-3 text-worktree-sidebar-accent-foreground shadow-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold leading-snug">Add a setup script</p>
|
||||
<DismissButton onDismiss={handleDismiss} />
|
||||
</div>
|
||||
|
||||
{/* Why: name the repo on its own line so the prompt's project is clear in
|
||||
every body variant, not just the default one. */}
|
||||
<p className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<RepoBadgeMark color={activeRepo.badgeColor} />
|
||||
<span className="truncate font-medium text-foreground">{activeRepo.displayName}</span>
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-xs leading-snug text-muted-foreground">
|
||||
<SetupScriptPromptBody
|
||||
repo={activeRepo}
|
||||
isInspectionError={isInspectionError}
|
||||
sharedSetupIgnored={sharedSetupIgnored}
|
||||
isPackageManagerSuggestion={Boolean(isPackageManagerSuggestion && candidate)}
|
||||
|
||||
@@ -3,8 +3,6 @@ import { Check, Download, LoaderCircle, PackageCheck, RefreshCw, Settings, X } f
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { RepoBadgeMark } from '@/components/repo/RepoBadgeLabel'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
|
||||
type DismissButtonProps = {
|
||||
onDismiss: () => void
|
||||
@@ -109,7 +107,6 @@ export function PackageManagerActions({
|
||||
}
|
||||
|
||||
export type SetupScriptPromptBodyProps = {
|
||||
repo: Repo
|
||||
isInspectionError: boolean
|
||||
sharedSetupIgnored: boolean
|
||||
isPackageManagerSuggestion: boolean
|
||||
@@ -117,7 +114,6 @@ export type SetupScriptPromptBodyProps = {
|
||||
}
|
||||
|
||||
export function SetupScriptPromptBody({
|
||||
repo,
|
||||
isInspectionError,
|
||||
sharedSetupIgnored,
|
||||
isPackageManagerSuggestion,
|
||||
@@ -145,18 +141,7 @@ export function SetupScriptPromptBody({
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
Add a setup command for{' '}
|
||||
<span className="inline-flex items-center gap-1.5 align-baseline px-1.5 py-0.5 rounded-[4px] bg-accent border border-border dark:bg-accent/50 dark:border-border/60">
|
||||
<RepoBadgeMark color={repo.badgeColor} />
|
||||
<span className="text-[10px] font-semibold text-foreground truncate max-w-[8rem] leading-none lowercase">
|
||||
{repo.displayName}
|
||||
</span>
|
||||
</span>{' '}
|
||||
to run when Orca creates new worktrees.
|
||||
</>
|
||||
)
|
||||
return <>Add a setup command to run when Orca creates new worktrees.</>
|
||||
}
|
||||
|
||||
export type InspectionErrorActionsProps = {
|
||||
|
||||
@@ -72,6 +72,11 @@ const SORT_OPTIONS = [
|
||||
}
|
||||
] as const
|
||||
|
||||
const PROJECT_ORDER_OPTIONS = [
|
||||
{ id: 'manual', label: 'Manual', description: 'Drag projects to arrange them' },
|
||||
{ id: 'recent', label: 'Recent', description: 'Most recent workspace activity' }
|
||||
] as const
|
||||
|
||||
const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsMenu({
|
||||
preserveWorkspaceBoardOpen = false,
|
||||
onMenuOpenChange
|
||||
@@ -90,6 +95,8 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
|
||||
const setSortBy = useAppStore((s) => s.setSortBy)
|
||||
const groupBy = useAppStore((s) => s.groupBy)
|
||||
const setGroupBy = useAppStore((s) => s.setGroupBy)
|
||||
const projectOrderBy = useAppStore((s) => s.projectOrderBy)
|
||||
const setProjectOrderBy = useAppStore((s) => s.setProjectOrderBy)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
@@ -119,6 +126,8 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
|
||||
(hasSleepingFilter ? 1 : 0) + (hideDefaultBranchWorkspace ? 1 : 0) + selectedCount
|
||||
const activeFilterLabel = `${activeFilterCount} ${activeFilterCount === 1 ? 'filter' : 'filters'}`
|
||||
const sortLabel = SORT_OPTIONS.find((opt) => opt.id === sortBy)?.label ?? 'Sort'
|
||||
const projectOrderLabel =
|
||||
PROJECT_ORDER_OPTIONS.find((opt) => opt.id === projectOrderBy)?.label ?? 'Manual'
|
||||
const cardLayout = settings?.experimentalCompactWorktreeCards ? 'compact' : 'detailed'
|
||||
const cardLayoutLabel =
|
||||
CARD_LAYOUT_OPTIONS.find((opt) => opt.id === cardLayout)?.label ?? 'Detailed'
|
||||
@@ -238,6 +247,47 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Why: project order only has a visible effect when grouping by
|
||||
project; hide it in none/status/PR modes to avoid a dead control. */}
|
||||
{groupBy === 'repo' && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<span className="flex flex-1 items-center justify-between">
|
||||
<span>Project order</span>
|
||||
<span className="text-[11px] font-medium text-muted-foreground">
|
||||
{projectOrderLabel}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent
|
||||
className="w-44"
|
||||
data-workspace-board-preserve-open={preserveWorkspaceBoardOpen ? '' : undefined}
|
||||
>
|
||||
<DropdownMenuRadioGroup
|
||||
value={projectOrderBy}
|
||||
onValueChange={(v) => setProjectOrderBy(v as typeof projectOrderBy)}
|
||||
>
|
||||
{PROJECT_ORDER_OPTIONS.map((opt) => (
|
||||
<Tooltip key={opt.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuRadioItem
|
||||
value={opt.id}
|
||||
// Keep the menu open so people can compare order modes.
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{opt.label}
|
||||
</DropdownMenuRadioItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={6}>
|
||||
{opt.description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<span className="flex flex-1 items-center justify-between">
|
||||
|
||||
@@ -396,7 +396,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({
|
||||
aria-label="Agents"
|
||||
data-compact-agent-list="true"
|
||||
>
|
||||
{shouldUseSummaryRow ? (
|
||||
{agents.length === 0 ? null : shouldUseSummaryRow ? (
|
||||
// Why: the worktree card is already the surface. Expanded compact
|
||||
// agents stay a quiet tree; only the collapsed summary reads as a pill.
|
||||
<div
|
||||
|
||||
@@ -51,6 +51,7 @@ import type {
|
||||
Worktree,
|
||||
Repo,
|
||||
ProjectGroup,
|
||||
ProjectOrderBy,
|
||||
WorktreeLineage,
|
||||
WorktreeMeta,
|
||||
WorkspaceStatus,
|
||||
@@ -68,14 +69,12 @@ import { tabHasLivePty } from '@/lib/tab-has-live-pty'
|
||||
import { deriveRunningAgentSendTargets } from '@/lib/running-agent-targets'
|
||||
import { rightSidebarShowsPullRequestData } from '@/lib/right-sidebar-visibility'
|
||||
import {
|
||||
type ProjectGroupOrdering,
|
||||
type Row,
|
||||
type WorktreeGroupBy,
|
||||
ALL_GROUP_KEY,
|
||||
PINNED_GROUP_KEY,
|
||||
buildRows,
|
||||
getGroupKeysForWorktree,
|
||||
getProjectGroupOrdering,
|
||||
getLineageGroupKey
|
||||
} from './worktree-list-groups'
|
||||
import {
|
||||
@@ -357,7 +356,7 @@ type VirtualizedWorktreeViewportProps = {
|
||||
activeWorktreeId: string | null
|
||||
currentWorktreeId: string | null
|
||||
groupBy: WorktreeGroupBy
|
||||
projectGroupOrdering: ProjectGroupOrdering
|
||||
projectOrderBy: ProjectOrderBy
|
||||
toggleGroup: (key: string) => void
|
||||
collapsedGroups: Set<string>
|
||||
handleCreateForRepo: (projectId: string) => void
|
||||
@@ -395,7 +394,6 @@ type VirtualizedWorktreeViewportProps = {
|
||||
// (filtered out / collapsed-only). Visible-only ids would silently drop the
|
||||
// hidden repos on reorder.
|
||||
allRepoIds: string[]
|
||||
reorderRepos: (orderedIds: string[]) => void
|
||||
prCache: Record<string, unknown> | null
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[]
|
||||
projectGroups?: readonly ProjectGroup[]
|
||||
@@ -703,7 +701,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
activeWorktreeId,
|
||||
currentWorktreeId,
|
||||
groupBy,
|
||||
projectGroupOrdering,
|
||||
projectOrderBy,
|
||||
toggleGroup,
|
||||
collapsedGroups,
|
||||
handleCreateForRepo,
|
||||
@@ -734,7 +732,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
worktreeLineageById,
|
||||
repoOrder,
|
||||
allRepoIds,
|
||||
reorderRepos,
|
||||
prCache,
|
||||
workspaceStatuses,
|
||||
projectGroups = EMPTY_PROJECT_GROUPS,
|
||||
@@ -824,7 +821,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
const suppressWorktreeClickUntilRef = useRef(0)
|
||||
const hasProjectGroups = projectGroups.length > 0
|
||||
const canReorderRepoHeaders =
|
||||
groupBy === 'repo' && projectGroupOrdering === 'manual' && !hasProjectGroups
|
||||
groupBy === 'repo' && projectOrderBy === 'manual' && !hasProjectGroups
|
||||
const lastVisibleRefreshKeyRef = useRef('')
|
||||
const reportVisibleGitHubPRRefreshCandidates = useAppStore(
|
||||
(s) => s.reportVisibleGitHubPRRefreshCandidates
|
||||
@@ -836,6 +833,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
const prVisibleRefreshGeneration = useAppStore((s) => s.prVisibleRefreshGeneration)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const deleteStateByWorktreeId = useAppStore((s) => s.deleteStateByWorktreeId)
|
||||
const reorderRepos = useAppStore((s) => s.reorderRepos)
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
@@ -851,11 +849,27 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
[]
|
||||
)
|
||||
|
||||
// Why: a project reorder relocates a whole group (header + its worktree
|
||||
// rows) but leaves totalSize unchanged, so the current scrollTop is already
|
||||
// the visually-stable position. Flag direct scroll input (same refs as
|
||||
// markDirectScrollInput, defined later) so the scroll-anchor restore effect
|
||||
// skips re-pinning the old top row — otherwise it chases the moved row and
|
||||
// yanks the viewport, which is the "jumpy" drop.
|
||||
const commitRepoReorder = useCallback(
|
||||
(orderedIds: string[]) => {
|
||||
const suppressUntil =
|
||||
window.performance.now() + USER_SCROLL_MEASUREMENT_ADJUSTMENT_SUPPRESS_MS
|
||||
suppressMeasurementAdjustmentUntilRef.current = suppressUntil
|
||||
directScrollInputUntilRef.current = suppressUntil
|
||||
reorderRepos(orderedIds)
|
||||
},
|
||||
[reorderRepos]
|
||||
)
|
||||
// Drag is only meaningful when repo headers are using manual order. The
|
||||
// controller is still constructed for hook order stability when inert.
|
||||
const repoDrag = useRepoHeaderDrag({
|
||||
orderedRepoIds: allRepoIds,
|
||||
onCommit: reorderRepos,
|
||||
onCommit: commitRepoReorder,
|
||||
getScrollContainer: () => scrollRef.current
|
||||
})
|
||||
const worktreeDragGroups = useMemo(() => getWorktreeDragGroups(rows), [rows])
|
||||
@@ -1418,7 +1432,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
new Set<string>(),
|
||||
repoOrder,
|
||||
workspaceStatuses,
|
||||
projectGroupOrdering,
|
||||
projectOrderBy,
|
||||
worktreeLineageById,
|
||||
worktreeMap,
|
||||
true,
|
||||
@@ -1461,7 +1475,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
activeWorktreeId,
|
||||
virtualizer,
|
||||
groupBy,
|
||||
projectGroupOrdering,
|
||||
projectOrderBy,
|
||||
worktrees,
|
||||
repoMap,
|
||||
prCache,
|
||||
@@ -2765,7 +2779,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
repoDrag.state.dropIndicatorY !== null ? (
|
||||
<div
|
||||
role="presentation"
|
||||
className="pointer-events-none absolute left-2 right-2 z-10 border-t border-dashed border-muted-foreground/70"
|
||||
// Why z-30 (not z-10): the sticky pinned header is z-20, so a
|
||||
// lower indicator renders behind it whenever the drop slot is at
|
||||
// the top while scrolled. Match the worktree indicator's tier.
|
||||
className="pointer-events-none absolute left-2 right-2 z-30 border-t border-dashed border-muted-foreground/70"
|
||||
style={{ top: `${repoDrag.state.dropIndicatorY}px` }}
|
||||
/>
|
||||
) : null}
|
||||
@@ -2877,6 +2894,16 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
row.repo && 'overflow-hidden'
|
||||
)}
|
||||
style={{ paddingLeft: headerPaddingLeft }}
|
||||
// Why: arm project-header drag from anywhere on the row, not
|
||||
// just the icon — users grab the name to reorder. The hook
|
||||
// ignores presses on nested buttons (+/chevron) and only
|
||||
// promotes to a drag past a 4px threshold, so a plain click
|
||||
// still toggles collapse via onClick.
|
||||
onPointerDown={
|
||||
canReorderRepoHeaders && isRepoHeader && projectIdForHeader
|
||||
? (e) => repoDrag.onHandlePointerDown(e, projectIdForHeader)
|
||||
: undefined
|
||||
}
|
||||
onDragOver={
|
||||
isPinnedHeader
|
||||
? handleWorkspacePinDragOver
|
||||
@@ -2906,11 +2933,6 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
>
|
||||
{row.icon ? (
|
||||
<div
|
||||
onPointerDown={
|
||||
canReorderRepoHeaders && isRepoHeader && projectIdForHeader
|
||||
? (e) => repoDrag.onHandlePointerDown(e, projectIdForHeader)
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
'flex size-4 shrink-0 items-center justify-center rounded-[4px]',
|
||||
repoHeaderColor ? 'text-muted-foreground' : row.tone
|
||||
@@ -3616,6 +3638,7 @@ const WorktreeList = React.memo(function WorktreeList({
|
||||
const workspaceStatuses = useAppStore((s) => s.workspaceStatuses)
|
||||
const sortBy = useAppStore((s) => s.sortBy)
|
||||
const setSortBy = useAppStore((s) => s.setSortBy)
|
||||
const projectOrderBy = useAppStore((s) => s.projectOrderBy)
|
||||
const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces)
|
||||
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
|
||||
const filterRepoIds = useAppStore((s) => s.filterRepoIds)
|
||||
@@ -4058,8 +4081,6 @@ const WorktreeList = React.memo(function WorktreeList({
|
||||
return getEmptyProjectPlaceholderRepoIds({ groupBy, repos, worktreesByRepo, filterRepoIds })
|
||||
}, [filterRepoIds, groupBy, repos, worktreesByRepo])
|
||||
const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos])
|
||||
const reorderReposAction = useAppStore((s) => s.reorderRepos)
|
||||
const projectGroupOrdering = getProjectGroupOrdering(groupBy, sortBy)
|
||||
|
||||
// Build flat row list for rendering
|
||||
const rows: Row[] = useMemo(
|
||||
@@ -4072,7 +4093,7 @@ const WorktreeList = React.memo(function WorktreeList({
|
||||
effectiveCollapsedGroups,
|
||||
repoOrder,
|
||||
workspaceStatuses,
|
||||
projectGroupOrdering,
|
||||
projectOrderBy,
|
||||
worktreeLineageById,
|
||||
worktreeMap,
|
||||
true,
|
||||
@@ -4089,7 +4110,7 @@ const WorktreeList = React.memo(function WorktreeList({
|
||||
effectiveCollapsedGroups,
|
||||
repoOrder,
|
||||
workspaceStatuses,
|
||||
projectGroupOrdering,
|
||||
projectOrderBy,
|
||||
worktreeLineageById,
|
||||
worktreeMap,
|
||||
settings,
|
||||
@@ -4675,7 +4696,7 @@ const WorktreeList = React.memo(function WorktreeList({
|
||||
activeWorktreeId={selectedSidebarWorktreeId}
|
||||
currentWorktreeId={currentSidebarWorktreeId}
|
||||
groupBy={groupBy}
|
||||
projectGroupOrdering={projectGroupOrdering}
|
||||
projectOrderBy={projectOrderBy}
|
||||
toggleGroup={toggleGroup}
|
||||
collapsedGroups={effectiveCollapsedGroups}
|
||||
handleCreateForRepo={handleCreateForRepo}
|
||||
@@ -4706,9 +4727,6 @@ const WorktreeList = React.memo(function WorktreeList({
|
||||
worktreeLineageById={worktreeLineageById}
|
||||
repoOrder={repoOrder}
|
||||
allRepoIds={allRepoIds}
|
||||
reorderRepos={(orderedIds) => {
|
||||
void reorderReposAction(orderedIds)
|
||||
}}
|
||||
prCache={prCache}
|
||||
workspaceStatuses={workspaceStatuses}
|
||||
projectGroups={projectGroups}
|
||||
|
||||
@@ -118,10 +118,15 @@ export function useRepoHeaderDrag({
|
||||
// indicator just above the target header keeps it at the visual top of
|
||||
// where the dragged group would land.
|
||||
const INDICATOR_GAP_PX = 4
|
||||
const indicatorY =
|
||||
const rawIndicatorY =
|
||||
insertBefore >= rects.length
|
||||
? rects.at(-1)!.bottom + INDICATOR_GAP_PX
|
||||
: Math.max(0, rects[insertBefore].top - INDICATOR_GAP_PX)
|
||||
// Why: while scrolled, the topmost mounted header is pinned flush at the
|
||||
// container top, so `top - GAP` lands above the overflow clip region and
|
||||
// the line is painted invisibly. Floor the indicator at the current
|
||||
// scroll offset so a top-of-list drop stays visible just below the edge.
|
||||
const indicatorY = Math.max(container.scrollTop, rawIndicatorY)
|
||||
return { dropIndex: insertBefore, dropIndicatorY: indicatorY }
|
||||
},
|
||||
[]
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
getGroupKeysForWorktree,
|
||||
getLineageGroupKey,
|
||||
getLineageRenderInfo,
|
||||
getPRGroupKey,
|
||||
getProjectGroupOrdering
|
||||
getPRGroupKey
|
||||
} from './worktree-list-groups'
|
||||
import type {
|
||||
DetectedWorktree,
|
||||
@@ -611,10 +610,36 @@ describe('buildRows project grouping order', () => {
|
||||
[repoB.id, repoB],
|
||||
[repoC.id, repoC]
|
||||
])
|
||||
const wA: Worktree = { ...worktree, id: 'wt-a', repoId: repoA.id, displayName: 'a' }
|
||||
const wAStale: Worktree = { ...worktree, id: 'wt-a-stale', repoId: repoA.id, displayName: 'a2' }
|
||||
const wB: Worktree = { ...worktree, id: 'wt-b', repoId: repoB.id, displayName: 'b' }
|
||||
const wC: Worktree = { ...worktree, id: 'wt-c', repoId: repoC.id, displayName: 'c' }
|
||||
// Activity: C (300) is freshest, then A (200), then B (100). wAStale (50) is
|
||||
// an older sibling of A so a repo's rank is its max child, not its first.
|
||||
const wA: Worktree = {
|
||||
...worktree,
|
||||
id: 'wt-a',
|
||||
repoId: repoA.id,
|
||||
displayName: 'a',
|
||||
lastActivityAt: 200
|
||||
}
|
||||
const wAStale: Worktree = {
|
||||
...worktree,
|
||||
id: 'wt-a-stale',
|
||||
repoId: repoA.id,
|
||||
displayName: 'a2',
|
||||
lastActivityAt: 50
|
||||
}
|
||||
const wB: Worktree = {
|
||||
...worktree,
|
||||
id: 'wt-b',
|
||||
repoId: repoB.id,
|
||||
displayName: 'b',
|
||||
lastActivityAt: 100
|
||||
}
|
||||
const wC: Worktree = {
|
||||
...worktree,
|
||||
id: 'wt-c',
|
||||
repoId: repoC.id,
|
||||
displayName: 'c',
|
||||
lastActivityAt: 300
|
||||
}
|
||||
|
||||
it('orders repo headers by explicit repoOrder, not first-encounter', () => {
|
||||
// Worktree stream encounters in order C, A, B — but repoOrder says B, A, C.
|
||||
@@ -636,11 +661,10 @@ describe('buildRows project grouping order', () => {
|
||||
expect(headerKeys).toEqual(['repo:repo-b', 'repo:repo-a', 'repo:repo-c'])
|
||||
})
|
||||
|
||||
it('orders repo headers by first encounter when caller uses visible worktree order', () => {
|
||||
// Caller already sorted worktrees by recency: C is freshest, then A, then B.
|
||||
// Even though repoOrder pins B, A, C, dynamic sorts must follow the freshest
|
||||
// worktree out of each repo so a just-active worktree's parent group
|
||||
// bubbles to the top of the sidebar.
|
||||
it('orders repo headers by max(lastActivityAt) per repo in Recent mode', () => {
|
||||
// repoOrder pins B, A, C, but Recent ignores it: C (300) > A (200) > B (100).
|
||||
// The incoming array is name-sorted (not pre-sorted by recency), proving the
|
||||
// resolver computes the timestamp itself rather than trusting encounter order.
|
||||
const repoOrder = new Map([
|
||||
[repoB.id, 0],
|
||||
[repoA.id, 1],
|
||||
@@ -648,58 +672,57 @@ describe('buildRows project grouping order', () => {
|
||||
])
|
||||
const rows = buildRows(
|
||||
'repo',
|
||||
[wC, wA, wB],
|
||||
[wA, wB, wC],
|
||||
map,
|
||||
null,
|
||||
new Set(),
|
||||
repoOrder,
|
||||
undefined,
|
||||
'visible-worktree-order'
|
||||
'recent'
|
||||
)
|
||||
const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key)
|
||||
expect(headerKeys).toEqual(['repo:repo-c', 'repo:repo-a', 'repo:repo-b'])
|
||||
})
|
||||
|
||||
it('orders repo headers by each repo highest-ranked visible child', () => {
|
||||
const repoOrder = new Map([
|
||||
[repoB.id, 0],
|
||||
[repoA.id, 1],
|
||||
[repoC.id, 2]
|
||||
])
|
||||
it("uses each repo's freshest visible child, not its first, in Recent mode", () => {
|
||||
// repo-a has a fresh child (200) and a stale one (50); its rank is the max.
|
||||
const rows = buildRows(
|
||||
'repo',
|
||||
[wA, wB, wAStale, wC],
|
||||
[wAStale, wA, wB, wC],
|
||||
map,
|
||||
null,
|
||||
new Set(),
|
||||
repoOrder,
|
||||
undefined,
|
||||
'visible-worktree-order'
|
||||
undefined,
|
||||
'recent'
|
||||
)
|
||||
|
||||
expect(rows).toMatchObject([
|
||||
{ type: 'header', key: 'repo:repo-a' },
|
||||
{ type: 'item', worktree: { id: 'wt-a' } },
|
||||
{ type: 'item', worktree: { id: 'wt-a-stale' } },
|
||||
{ type: 'header', key: 'repo:repo-b' },
|
||||
{ type: 'item', worktree: { id: 'wt-b' } },
|
||||
{ type: 'header', key: 'repo:repo-c' },
|
||||
{ type: 'item', worktree: { id: 'wt-c' } }
|
||||
{ type: 'item', worktree: { id: 'wt-c' } },
|
||||
{ type: 'header', key: 'repo:repo-a' },
|
||||
// Child rows keep their input order; only the header rank uses max activity.
|
||||
{ type: 'item', worktree: { id: 'wt-a-stale' } },
|
||||
{ type: 'item', worktree: { id: 'wt-a' } },
|
||||
{ type: 'header', key: 'repo:repo-b' },
|
||||
{ type: 'item', worktree: { id: 'wt-b' } }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the main workspace first inside its project group', () => {
|
||||
it('keeps the main workspace first inside its project group in Recent mode', () => {
|
||||
const main = {
|
||||
...wA,
|
||||
id: 'wt-a-main',
|
||||
displayName: 'main',
|
||||
isMainWorktree: true
|
||||
isMainWorktree: true,
|
||||
lastActivityAt: 10
|
||||
}
|
||||
const freshChild = {
|
||||
...wA,
|
||||
id: 'wt-a-fresh-child',
|
||||
displayName: 'fresh-child',
|
||||
isMainWorktree: false
|
||||
isMainWorktree: false,
|
||||
lastActivityAt: 500
|
||||
}
|
||||
const rows = buildRows(
|
||||
'repo',
|
||||
@@ -709,7 +732,7 @@ describe('buildRows project grouping order', () => {
|
||||
new Set(),
|
||||
undefined,
|
||||
undefined,
|
||||
'visible-worktree-order'
|
||||
'recent'
|
||||
)
|
||||
|
||||
expect(rows).toMatchObject([
|
||||
@@ -721,7 +744,7 @@ describe('buildRows project grouping order', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps repoOrder for manual project group ordering', () => {
|
||||
it('orders repo headers by repoOrder in Manual mode (default), ignoring activity', () => {
|
||||
const repoOrder = new Map([
|
||||
[repoB.id, 0],
|
||||
[repoA.id, 1],
|
||||
@@ -749,17 +772,43 @@ describe('buildRows project grouping order', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getProjectGroupOrdering', () => {
|
||||
it.each([
|
||||
['repo', 'recent', 'visible-worktree-order'],
|
||||
['repo', 'smart', 'visible-worktree-order'],
|
||||
['repo', 'name', 'manual'],
|
||||
['repo', 'repo', 'manual'],
|
||||
['none', 'recent', 'manual'],
|
||||
['workspace-status', 'recent', 'manual'],
|
||||
['pr-status', 'recent', 'manual']
|
||||
] as const)('uses %s/%s -> %s', (groupBy, sortBy, expected) => {
|
||||
expect(getProjectGroupOrdering(groupBy, sortBy)).toBe(expected)
|
||||
describe('buildRows Recent project order fallbacks', () => {
|
||||
const active: Repo = { ...repo, id: 'repo-active', displayName: 'active', addedAt: 0 }
|
||||
// Empty project has no visible worktrees, so Recent falls back to addedAt.
|
||||
const empty: Repo = { ...repo, id: 'repo-empty', displayName: 'empty', addedAt: 999 }
|
||||
const map = new Map([
|
||||
[active.id, active],
|
||||
[empty.id, empty]
|
||||
])
|
||||
const activeWorktree: Worktree = {
|
||||
...worktree,
|
||||
id: 'wt-active',
|
||||
repoId: active.id,
|
||||
displayName: 'active',
|
||||
lastActivityAt: 100
|
||||
}
|
||||
|
||||
it('sorts placeholder projects after projects with activity', () => {
|
||||
// empty.addedAt (999) is numerically higher than active's worktree (100),
|
||||
// but a real activity timestamp must always outrank an addedAt fallback.
|
||||
const rows = buildRows(
|
||||
'repo',
|
||||
[activeWorktree],
|
||||
map,
|
||||
null,
|
||||
new Set(),
|
||||
undefined,
|
||||
undefined,
|
||||
'recent',
|
||||
{},
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
[],
|
||||
new Set([empty.id])
|
||||
)
|
||||
const headerKeys = rows.filter((r) => r.type === 'header').map((r) => r.key)
|
||||
expect(headerKeys).toEqual(['repo:repo-active', 'repo:repo-empty'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1134,6 +1183,71 @@ describe('project groups', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('orders repos inside a Project Group by activity in recent mode, keeping tabOrder', () => {
|
||||
const groupA: ProjectGroup = {
|
||||
id: 'group-a',
|
||||
name: 'Platform',
|
||||
parentPath: '/platform',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 1,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const groupB: ProjectGroup = { ...groupA, id: 'group-b', name: 'Infra', tabOrder: 0 }
|
||||
// Inside group A: repoStale ordered first by projectGroupOrder, but repoFresh
|
||||
// is more recently active so recent mode must lift it above repoStale.
|
||||
const repoStale: Repo = {
|
||||
...repo,
|
||||
id: 'repo-stale',
|
||||
displayName: 'stale',
|
||||
projectGroupId: groupA.id,
|
||||
projectGroupOrder: 0
|
||||
}
|
||||
const repoFresh: Repo = {
|
||||
...repo,
|
||||
id: 'repo-fresh',
|
||||
displayName: 'fresh',
|
||||
projectGroupId: groupA.id,
|
||||
projectGroupOrder: 1
|
||||
}
|
||||
const groupedMap = new Map([
|
||||
[repoStale.id, repoStale],
|
||||
[repoFresh.id, repoFresh]
|
||||
])
|
||||
const worktrees = [
|
||||
{ ...worktree, id: 'wt-stale', repoId: repoStale.id, lastActivityAt: 10 },
|
||||
{ ...worktree, id: 'wt-fresh', repoId: repoFresh.id, lastActivityAt: 500 }
|
||||
]
|
||||
|
||||
const rows = buildRows(
|
||||
'repo',
|
||||
worktrees,
|
||||
groupedMap,
|
||||
null,
|
||||
new Set(),
|
||||
undefined,
|
||||
undefined,
|
||||
'recent',
|
||||
{},
|
||||
new Map(worktrees.map((entry) => [entry.id, entry])),
|
||||
false,
|
||||
undefined,
|
||||
// Group headers always follow tabOrder (Infra=0 before Platform=1),
|
||||
// independent of projectOrderBy.
|
||||
[groupA, groupB]
|
||||
)
|
||||
|
||||
expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([
|
||||
'project-group:group-b',
|
||||
'project-group:group-a',
|
||||
'repo:repo-fresh',
|
||||
'repo:repo-stale'
|
||||
])
|
||||
})
|
||||
|
||||
it('renders nested Project Groups before repos assigned to their leaf group', () => {
|
||||
const rootGroup: ProjectGroup = {
|
||||
id: 'group-root',
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
DetectedWorktree,
|
||||
Repo,
|
||||
ProjectGroup,
|
||||
ProjectOrderBy,
|
||||
Worktree,
|
||||
WorktreeLineage,
|
||||
WorkspaceStatusDefinition
|
||||
@@ -22,7 +23,6 @@ import {
|
||||
ConductorReviewIcon
|
||||
} from './workspace-status-icons'
|
||||
import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses'
|
||||
import type { SortBy } from './smart-sort'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '@/store/slices/github-cache-key'
|
||||
import { UNGROUPED_PROJECT_GROUP_KEY } from '../../../../shared/project-groups'
|
||||
@@ -31,16 +31,6 @@ import { getRepoDisplayLabelsByPath } from '@/lib/repo-display-labels'
|
||||
export { branchName }
|
||||
|
||||
export type WorktreeGroupBy = 'none' | 'workspace-status' | 'repo' | 'pr-status'
|
||||
export type ProjectGroupOrdering = 'manual' | 'visible-worktree-order'
|
||||
|
||||
export function getProjectGroupOrdering(
|
||||
groupBy: WorktreeGroupBy,
|
||||
sortBy: SortBy
|
||||
): ProjectGroupOrdering {
|
||||
return groupBy === 'repo' && (sortBy === 'recent' || sortBy === 'smart')
|
||||
? 'visible-worktree-order'
|
||||
: 'manual'
|
||||
}
|
||||
|
||||
export type GroupHeaderRow = {
|
||||
type: 'header'
|
||||
@@ -413,6 +403,88 @@ function withRepoSectionDisplayLabels(entries: readonly OrderedGroupEntry[]): Or
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent rank for a project header. `hasActivity` projects (at least one
|
||||
* visible worktree) always sort before fallback projects, regardless of the
|
||||
* numeric values — a placeholder's `addedAt` must never outrank real activity.
|
||||
* Within each tier, higher timestamps come first.
|
||||
*/
|
||||
type RecentRank = { hasActivity: boolean; ts: number }
|
||||
|
||||
function recentRankForEntry(entry: OrderedGroupEntry): RecentRank {
|
||||
let max = Number.NEGATIVE_INFINITY
|
||||
for (const worktree of entry[1].items) {
|
||||
if (worktree.lastActivityAt > max) {
|
||||
max = worktree.lastActivityAt
|
||||
}
|
||||
}
|
||||
if (max !== Number.NEGATIVE_INFINITY) {
|
||||
// Why: Recent must be timestamp-based, not encounter order — the incoming
|
||||
// array is no longer pre-sorted by recency once decoupled from sortBy.
|
||||
return { hasActivity: true, ts: max }
|
||||
}
|
||||
const addedAt = entry[1].repo?.addedAt
|
||||
return {
|
||||
hasActivity: false,
|
||||
ts: typeof addedAt === 'number' ? addedAt : Number.NEGATIVE_INFINITY
|
||||
}
|
||||
}
|
||||
|
||||
function compareRecentRank(a: RecentRank, b: RecentRank): number {
|
||||
if (a.hasActivity !== b.hasActivity) {
|
||||
return a.hasActivity ? -1 : 1
|
||||
}
|
||||
return b.ts - a.ts
|
||||
}
|
||||
|
||||
function manualRankForEntry(
|
||||
entry: OrderedGroupEntry,
|
||||
repoOrder: Map<string, number> | undefined
|
||||
): number {
|
||||
const key = entry[0]
|
||||
const repoId = key.startsWith('repo:') ? key.slice('repo:'.length) : key
|
||||
const rank = repoOrder?.get(repoId)
|
||||
return rank === undefined ? Number.POSITIVE_INFINITY : rank
|
||||
}
|
||||
|
||||
/**
|
||||
* Order project header entries by the user's project-order preference. Manual
|
||||
* follows the canonical repoOrder; Recent follows each project's most recent
|
||||
* visible workspace activity (descending), with empty/imported-only projects
|
||||
* sorting after active ones, then by manual rank, then label.
|
||||
*/
|
||||
function sortProjectEntries(
|
||||
entries: OrderedGroupEntry[],
|
||||
projectOrderBy: ProjectOrderBy,
|
||||
repoOrder: Map<string, number> | undefined
|
||||
): OrderedGroupEntry[] {
|
||||
if (projectOrderBy === 'recent') {
|
||||
return [...entries].sort((a, b) => {
|
||||
const byRecent = compareRecentRank(recentRankForEntry(a), recentRankForEntry(b))
|
||||
if (byRecent !== 0) {
|
||||
return byRecent
|
||||
}
|
||||
const ma = manualRankForEntry(a, repoOrder)
|
||||
const mb = manualRankForEntry(b, repoOrder)
|
||||
if (ma !== mb) {
|
||||
return ma - mb
|
||||
}
|
||||
return a[1].label.localeCompare(b[1].label)
|
||||
})
|
||||
}
|
||||
if (!repoOrder) {
|
||||
return entries
|
||||
}
|
||||
return [...entries].sort((a, b) => {
|
||||
const ra = manualRankForEntry(a, repoOrder)
|
||||
const rb = manualRankForEntry(b, repoOrder)
|
||||
if (ra !== rb) {
|
||||
return ra - rb
|
||||
}
|
||||
return a[1].label.localeCompare(b[1].label)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the flat row list consumed by the virtualizer.
|
||||
* Extracted here to keep WorktreeList.tsx under the line-count lint limit.
|
||||
@@ -425,7 +497,7 @@ export function buildRows(
|
||||
collapsedGroups: Set<string>,
|
||||
repoOrder?: Map<string, number>,
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[] = cloneDefaultWorkspaceStatuses(),
|
||||
projectGroupOrdering: ProjectGroupOrdering = 'manual',
|
||||
projectOrderBy: ProjectOrderBy = 'manual',
|
||||
lineageById: Record<string, WorktreeLineage> = {},
|
||||
worktreeMap: Map<string, Worktree> = new Map(
|
||||
worktrees.map((worktree) => [worktree.id, worktree])
|
||||
@@ -546,25 +618,10 @@ export function buildRows(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Why: dynamic sorts need repo headers to follow their highest-ranked
|
||||
// visible child. Manual ordering still uses the canonical state.repos
|
||||
// order so repo-header drag has a stable source of truth.
|
||||
const entries = Array.from(grouped.entries())
|
||||
if (projectGroupOrdering === 'manual' && repoOrder) {
|
||||
const rankFor = (key: string): number => {
|
||||
const repoId = key.startsWith('repo:') ? key.slice('repo:'.length) : key
|
||||
const rank = repoOrder.get(repoId)
|
||||
return rank === undefined ? Number.POSITIVE_INFINITY : rank
|
||||
}
|
||||
entries.sort((a, b) => {
|
||||
const ra = rankFor(a[0])
|
||||
const rb = rankFor(b[0])
|
||||
if (ra !== rb) {
|
||||
return ra - rb
|
||||
}
|
||||
return a[1].label.localeCompare(b[1].label)
|
||||
})
|
||||
}
|
||||
// Why: project header order is its own user choice (projectOrderBy),
|
||||
// decoupled from workspace sortBy. Manual uses the canonical repoOrder so
|
||||
// header drag has a stable source of truth; Recent follows activity.
|
||||
const entries = sortProjectEntries(Array.from(grouped.entries()), projectOrderBy, repoOrder)
|
||||
// Why: large imported repo sets can have one group per repo; spreading
|
||||
// those entries into push can exceed V8's argument limit.
|
||||
for (const entry of entries) {
|
||||
@@ -656,9 +713,13 @@ export function buildRows(
|
||||
}
|
||||
|
||||
const sortRepoEntriesWithinGroup = (entries: OrderedGroupEntry[]): OrderedGroupEntry[] => {
|
||||
if (projectGroupOrdering !== 'manual') {
|
||||
return entries
|
||||
if (projectOrderBy === 'recent') {
|
||||
return [...entries].sort((left, right) =>
|
||||
compareRecentRank(recentRankForEntry(left), recentRankForEntry(right))
|
||||
)
|
||||
}
|
||||
// Manual: within a Project Group, projects order by their per-group rank
|
||||
// (projectGroupOrder), not the global repoOrder.
|
||||
return [...entries].sort((left, right) => {
|
||||
const leftOrder = left[1].repo?.projectGroupOrder
|
||||
const rightOrder = right[1].repo?.projectGroupOrder
|
||||
|
||||
@@ -12,10 +12,30 @@ describe('composeActiveTerminalTheme', () => {
|
||||
} as GlobalSettings
|
||||
}
|
||||
|
||||
it('returns the base theme when no overrides apply', () => {
|
||||
it('layers terminal scrollbar defaults under the base theme', () => {
|
||||
const base = { background: '#101010', foreground: '#fafafa', cursor: '#fafafa' }
|
||||
const result = composeActiveTerminalTheme(base, settingsWith({}))
|
||||
expect(result).toEqual(base)
|
||||
expect(result).toEqual({
|
||||
overviewRulerBorder: 'transparent',
|
||||
scrollbarSliderBackground: 'rgba(180, 180, 185, 0.4)',
|
||||
scrollbarSliderHoverBackground: 'rgba(180, 180, 185, 0.6)',
|
||||
scrollbarSliderActiveBackground: 'rgba(180, 180, 185, 0.8)',
|
||||
...base
|
||||
})
|
||||
})
|
||||
|
||||
it('lets the base theme override terminal scrollbar defaults', () => {
|
||||
const result = composeActiveTerminalTheme(
|
||||
{
|
||||
background: '#101010',
|
||||
overviewRulerBorder: '#222222',
|
||||
scrollbarSliderBackground: 'rgba(1, 2, 3, 0.4)'
|
||||
},
|
||||
settingsWith({})
|
||||
)
|
||||
|
||||
expect(result!.overviewRulerBorder).toBe('#222222')
|
||||
expect(result!.scrollbarSliderBackground).toBe('rgba(1, 2, 3, 0.4)')
|
||||
})
|
||||
|
||||
it('layers terminalColorOverrides on top of the base theme', () => {
|
||||
|
||||
@@ -156,7 +156,19 @@ export function composeActiveTerminalTheme(
|
||||
if (!baseTheme) {
|
||||
return null
|
||||
}
|
||||
let theme: ITheme = { ...baseTheme }
|
||||
// Why: setting scrollbar.width enables xterm's overview ruler, whose border
|
||||
// defaults to the foreground color and paints a bright vertical line beside
|
||||
// the scrollbar. We only want the slimmer scrollbar, not the ruler chrome.
|
||||
// Why: xterm's default slider alpha (~0.2) is nearly invisible on dark
|
||||
// backgrounds; raise the contrast so the thumb reads. Placed before the
|
||||
// spread so an explicit theme value still wins.
|
||||
let theme: ITheme = {
|
||||
overviewRulerBorder: 'transparent',
|
||||
scrollbarSliderBackground: 'rgba(180, 180, 185, 0.4)',
|
||||
scrollbarSliderHoverBackground: 'rgba(180, 180, 185, 0.6)',
|
||||
scrollbarSliderActiveBackground: 'rgba(180, 180, 185, 0.8)',
|
||||
...baseTheme
|
||||
}
|
||||
// Why: merge user-imported Ghostty color overrides on top of the resolved
|
||||
// base theme so individual colors can be tweaked without losing the rest.
|
||||
if (settings.terminalColorOverrides) {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FORK_PUSH_NO_MAINTAINER_EDIT_WARNING, getForkPushWarning } from './fork-push-warning'
|
||||
|
||||
const forkTarget = { remoteName: 'pr-contributor-orca', branchName: 'contributor/fix' }
|
||||
const originTarget = { remoteName: 'origin', branchName: 'feature/fix' }
|
||||
|
||||
describe('getForkPushWarning', () => {
|
||||
it('warns for a fork PR whose author disabled maintainer edits', () => {
|
||||
expect(getForkPushWarning({ pushTarget: forkTarget, maintainerCanModify: false })).toBe(
|
||||
FORK_PUSH_NO_MAINTAINER_EDIT_WARNING
|
||||
)
|
||||
})
|
||||
|
||||
it('does not warn when maintainer edits are allowed', () => {
|
||||
expect(getForkPushWarning({ pushTarget: forkTarget, maintainerCanModify: true })).toBeNull()
|
||||
})
|
||||
|
||||
it('does not warn when the maintainer flag is unknown', () => {
|
||||
expect(getForkPushWarning({ pushTarget: forkTarget })).toBeNull()
|
||||
})
|
||||
|
||||
it('does not warn for a same-repo PR even if the flag is false (we own origin)', () => {
|
||||
expect(getForkPushWarning({ pushTarget: originTarget, maintainerCanModify: false })).toBeNull()
|
||||
})
|
||||
|
||||
it('does not warn when there is no resolved push target', () => {
|
||||
expect(getForkPushWarning({ maintainerCanModify: false })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { GitHubPrStartPoint } from '../../../shared/types'
|
||||
|
||||
export const FORK_PUSH_NO_MAINTAINER_EDIT_WARNING =
|
||||
'This PR has "Allow edits from maintainers" off; pushing to the fork may be rejected by GitHub.'
|
||||
|
||||
// Why: only warn for fork PRs where the push target points away from origin and
|
||||
// whose author left "Allow edits from maintainers" off. That's the one case
|
||||
// where our push to the contributor's fork can be rejected by GitHub. Returns
|
||||
// the warning text to show, or null when no warning applies.
|
||||
export function getForkPushWarning(
|
||||
result: Pick<GitHubPrStartPoint, 'pushTarget' | 'maintainerCanModify'>
|
||||
): string | null {
|
||||
if (
|
||||
result.maintainerCanModify === false &&
|
||||
result.pushTarget !== undefined &&
|
||||
result.pushTarget.remoteName !== 'origin'
|
||||
) {
|
||||
return FORK_PUSH_NO_MAINTAINER_EDIT_WARNING
|
||||
}
|
||||
return null
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user