diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f443edc..b0204cb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: [main] pull_request: + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 19aea5a1..2c0158f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- OpenCode falls back to readable local paths for unsupported attachment formats instead of sending provider-rejected file parts, and repairs sessions already stuck on an unsupported file turn. Fixes #211. + ## [0.1.45] - 2026-09-13 ### Added diff --git a/docs/worktree-naming-failures-research.md b/docs/worktree-naming-failures-research.md new file mode 100644 index 00000000..28ff1f82 --- /dev/null +++ b/docs/worktree-naming-failures-research.md @@ -0,0 +1,40 @@ +# AI naming failures: T3 Code comparison + +Reviewed 2026-09-14 against official T3 Code main, commit `77bca8b2d76a1f42552e5eee7d277fcb1160347a`. The official GitHub repository was opened and `git ls-remote origin refs/heads/main` confirmed that the existing research checkout still matches main. This is a source investigation; no T3 provider quota was exhausted or live generation run. + +## What T3 does + +T3 treats first-message branch naming and thread-title generation as separate background operations. Both are forked before the main provider turn proceeds. A failed metadata request does not block worktree creation or fail the main conversation. [First-turn orchestration](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts#L1404-L1433) + +| Behavior | Automatic branch name | Automatic thread title | +| --- | --- | --- | +| Automatic retries | None in the naming path. | Two additional attempts with exponential backoff starting at two seconds (two and four seconds). The retry does not classify quota errors separately. | +| Model failover after request failure | None. | None. | +| Final failure | Logs a warning; leaves the temporary branch intact if generation failed before rename. | Logs a warning; leaves the seeded/current title intact. | +| User-visible naming error | No error event or toast dispatched from this background path. | No error event or toast dispatched from this background path. | + +These differences are explicit in the adjacent [branch and title helpers](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts#L991-L1106). A focused test exercises a transient title-generation timeout followed by a successful retry. [Retry test](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts#L1583-L1645) + +There is a manual **Regenerate title** action. The client shows an error if submitting that command fails, but the background generation worker catches generation failures, logs them, and clears the pending regeneration without changing the title. This is not a branch-naming retry action or a quota-failover UI. [Client action](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/web/src/components/Sidebar.tsx#L4390-L4408), [regeneration worker](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts#L1207-L1257) + +## Model selection and quota + +The model for branch names is the configured source-control writer selection, or the configured text-generation selection when no writer override is set. An unavailable/disabled writer instance falls back to text generation **before** requesting a name. The runtime service then resolves that single instance and calls it directly; it does not try another provider after a quota or inference error. [Selection helper](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/packages/shared/src/serverSettings.ts#L84-L100), [single-instance routing](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/TextGeneration.ts#L124-L171) + +Both selections have settings UI. Text generation defaults to Codex `gpt-5.6-luna` with low reasoning; per-provider defaults include Claude Haiku 4.5 and Cursor Composer 2. These are source defaults, not guarantees that a user's provider supports or has quota for that model. [Defaults](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/packages/contracts/src/model.ts#L164-L188), [text-generation setting](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/web/src/components/settings/SettingsPanels.tsx#L2918-L2944), [writer setting](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/web/src/components/settings/SourceControlWritingSettings.tsx#L283-L353) + +Codex, Claude, Cursor, Grok, and Antigravity text-generation runners have 180-second timeouts. OpenCode's text-generation wrapper has no equivalent explicit timeout in that file, so a universal three-minute guarantee should not be inferred. Provider CLIs or services may implement their own retries; that is separate from T3's naming orchestration. [Codex](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/CodexTextGeneration.ts#L41), [Claude](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/ClaudeTextGeneration.ts#L53), [Cursor](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/CursorTextGeneration.ts#L30), [Grok](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/GrokTextGeneration.ts#L35), [Antigravity](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/AntigravityTextGeneration.ts#L36), [OpenCode](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts) + +## Why plain billing errors do not become T3 names + +T3 asks for a JSON object and validates the expected field (`branch: string` or `title: string`) before sanitizing it. Cursor extracts a JSON object from the response and schema-decodes it; empty or invalid output produces a typed `TextGenerationError`. It has no plain-text naming fallback. Therefore a plain response such as “Upgrade your plan to continue” fails parsing rather than becoming a branch or title. [Prompt/schema](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/TextGenerationPrompts.ts#L185-L207), [Cursor validation](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/CursorTextGeneration.ts#L107-L168) + +Codex additionally passes an output schema to the CLI, checks the process exit code, and schema-decodes its output file. Claude checks the exit code and validates the `structured_output` envelope. OpenCode checks `result.data.info.error` before reading text and validates its JSON. [Codex runner](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/CodexTextGeneration.ts#L207-L312), [Claude runner](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/ClaudeTextGeneration.ts#L248-L312), [OpenCode validation](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts#L246-L375) + +Schema validation is structural, not semantic: T3 does not reject an otherwise schema-valid object merely because its `branch` or `title` contains billing-error words. That limitation follows from the string-only schema and subsequent sanitizer. [Schema](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/TextGenerationPrompts.ts#L201-L207), [Cursor sanitation](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/CursorTextGeneration.ts#L218-L239) + +## Implications for Monocode + +The useful T3 patterns are structured-result validation, preserved fallback names, independent background generation, and bounded retries (currently implemented for T3 titles). Monocode's combined title/branch request can reuse the retry policy while retaining its own cancellation, deadline, and native rename eligibility checks. Validate provider success/error metadata and structured fields before changing either name; arbitrary response prose must not be treated as a title or branch. + +A visible **AI naming unavailable—kept the default branch name** notice with manual retry is an explicit Monocode improvement requested in this conversation, not behavior already present in T3's branch path. Quota/account failures should remain understandable and should not silently switch to another provider/account. Avoid describing pre-request provider selection fallback as runtime quota failover. diff --git a/docs/worktree-naming-research.md b/docs/worktree-naming-research.md new file mode 100644 index 00000000..2869583b --- /dev/null +++ b/docs/worktree-naming-research.md @@ -0,0 +1,113 @@ +# AI worktree naming investigation + +Reviewed 2026-09-13 against Monocode `e080298ca5fe4b356ed4b02b1bf476d2de945547` and T3 Code `77bca8b2d76a1f42552e5eee7d277fcb1160347a`. The findings below describe the pre-implementation source. Implementation followed this investigation; current user-facing behavior is documented in [Worktrees](worktrees.md). + +## Finding + +Monocode already has the AI generation infrastructure. Worktree creation bypasses it and uses the beginning of the raw message plus the full session ID as the Git branch name. The recommended change is to generate a semantic branch name from the first-message context in the background, keeping creation and agent startup independent of AI availability. Preserve the checkout's stable directory and update the branch through the native worktree lifecycle, including its ownership metadata. + +For this request, the current algorithm produces `monocode/right-now-the-name-of-the-worktree-is-aut-`. The desired result could be `monocode/ai-worktree-naming`. The latter is an illustrative target, not a measured model response. + +## What Monocode does today + +| Area | Confirmed implementation | +| --- | --- | +| First send | `onSubmit` calls `prepareSessionWorktree(current, submittedText)`, waits for checkout preparation and setup, persists `worktreeCwd`, then starts the provider turn. [Send flow](../src/App.tsx#L4536) | +| Branch name | Native `create` calls `slug(name)`: ASCII lowercase, punctuation replaced with hyphens, truncate to 42 characters before collapsing hyphens, fallback `task`. It creates `monocode/{slug}-{full session ID}`. [Allocator](../src-tauri/src/worktrees.rs#L3401) | +| Directory | The physical directory is `/worktrees/-/`. It is already independent of the human-readable branch fragment. [Creation](../src-tauri/src/worktrees.rs#L3452) | +| Visible name | The worktree menu and Settings inventory display the Git branch. There is no separate worktree display-name field in `WorktreeEntry`. [Workspace picker](../src/chrome/WorkspacePicker.tsx#L181), [inventory](../src/chrome/WorktreeManager.tsx#L202), [type](../src/lib/worktrees.ts#L20) | +| AI session title | First send independently launches `generateHarnessTitle`. Its callback updates the conversation title and optional linked work item, but never the worktree branch. It preserves a title that the user has changed. [Title dispatch](../src/App.tsx#L4388), [metadata prompt/parser](../src/lib/sessionTitle.ts) | +| Existing AI branch API | `generateHarnessBranchName` and the optional adapter hook already exist. Codex, Claude, Cursor, OpenCode and Grok implement them. The symbol has no application call site and is not exported from the harness barrel. [Registry](../src/lib/harness/registry.ts#L298), [exports](../src/lib/harness/index.ts#L130) | +| Existing branch prompt | Requests a short, specific 2–6 word description of the work as JSON. Input is capped at 8,000 characters; output is sanitized to a branch fragment. [Prompt and parser](../src/lib/gitText.ts#L75) | + +The reusable provider runners use installed harnesses. Current utility-model choices include Codex's `gpt-5.6-luna` with low effort, Claude's discovered Haiku model (fallback `claude-haiku-4-5`), Cursor's `composer-2.5`, Grok's `grok-4.6`, and OpenCode's first usable catalog model (fallback `opencode/glm-5`). These are observed code defaults, not recommendations about current model availability. [Codex](../src/lib/harness/codexText.ts), [Claude](../src/lib/harness/claudeText.ts), [Cursor](../src/lib/harness/cursorText.ts), [Grok](../src/lib/harness/grokProtocol.ts#L23), [OpenCode](../src/lib/harness/opencodeText.ts#L165) + +Pi and OMP have title generation and text runners, but no branch-generation adapter hook. FX exposes neither title nor branch generation. Extending the existing title metadata would cover Pi/OMP without adding another provider transport. [Pi adapter](../src/lib/harness/piAdapter.ts), [OMP adapter](../src/lib/harness/ompAdapter.ts), [Pi titles](../src/lib/harness/piTitle.ts), [FX adapter](../src/lib/harness/fxAdapter.ts) + +## What T3 Code actually does + +T3's implementation names the **Git branch**, while retaining the original checkout directory: + +1. First send requests a worktree with a temporary `t3code/<8 hex characters>` branch. Server bootstrap creates the checkout and records its path/branch before dispatching the turn. [Composer bootstrap](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/web/src/components/ChatView.tsx#L7405) +2. On the first user turn, excluding its compact command, the provider reactor forks branch generation and separately forks title generation. The branch task requires an existing worktree path and a branch matching T3's temporary pattern. Provider startup proceeds independently. [First-turn dispatch](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts#L1402) +3. Branch generation uses the configured source-control writer model when available, otherwise the configured text-generation model. This selection is independent of the conversation's model. The text service routes through that provider instance. [Model selection](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts#L1009), [writer resolver](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/packages/shared/src/serverSettings.ts#L84), [provider routing](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/TextGeneration.ts#L149) +4. Context comes from the first message with citation markup converted to plain text, plus attachments. The prompt asks for a short semantic description; it is not a random word-pair generator. [Generation input](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts#L1414), [branch prompt](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/TextGenerationPrompts.ts#L185) +5. The result is normalized to a maximum 64-character fragment, prefixed with `t3code/`, and renamed using Git. If a local name is taken, the driver tries suffixes `-1` through `-100`. It uses non-forced `git branch -m` with the explicit old name. [Normalization](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts#L299), [collision handling](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/vcs/GitVcsDriverCore.ts#L973), [Git rename](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/vcs/GitVcsDriverCore.ts#L3312) +6. On success, the reactor updates thread metadata and refreshes Git status. Generation or rename failure is logged and does not fail the main turn. A generation failure leaves the temporary name usable. [Completion/failure handling](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts#L1033) + +T3 launches its configured setup program before dispatching the first turn; that interface reports that setup has started, so naming is not guaranteed to wait for setup completion. Monocode currently waits for setup completion before its main agent starts. This difference matters when choosing when to apply the name. [T3 bootstrap order](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/ws.ts#L1189), [setup launch](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/ws.ts#L1086), [Monocode prepare/setup](../src/lib/worktrees.ts#L164) + +T3 caps prompt message text at 8,000 characters and attachment metadata at 4,000. Attachment handling differs by provider: its Codex helper also supplies image files to an ephemeral, read-only `codex exec` request with a JSON output schema; the Claude branch helper includes attachment metadata in its text prompt. These utility requests are separate from the visible conversation. [Prompt construction](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/TextGenerationPrompts.ts#L158), [Codex execution](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/CodexTextGeneration.ts#L186), [Codex branch/images](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/CodexTextGeneration.ts#L367), [Claude branch helper](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/ClaudeTextGeneration.ts#L370) + +The inspected AI helper checks the temporary name before inference. It does not explicitly re-read the current branch or check upstream/publication state after inference. The explicit old-name, non-forced rename supplies some protection, but this should not be described as a comprehensive user-rename or publish-race guarantee. Monocode should enforce its own eligibility checks when applying a delayed result. [AI helper](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts#L991), [rename implementation](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/vcs/GitVcsDriverCore.ts#L3312) + +Inspected tests cover first-turn generation and citation conversion, branch prompt attachments, temporary-name eligibility and Git rename/no-op behavior. They were not run, and this investigation did not establish coverage for a publish race or a crash between Git rename and metadata persistence. [Reactor tests](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts#L2422), [prompt tests](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts#L116), [temporary-name tests](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/packages/shared/src/git.test.ts#L135), [Git rename tests](https://github.com/pingdotgg/t3code/blob/77bca8b2d76a1f42552e5eee7d277fcb1160347a/apps/server/src/vcs/GitVcsDriverCore.test.ts#L1511) + +## Recommended Monocode implementation + +These are proposed choices, not existing behavior. + +### One first-message metadata request + +Extend the existing title request to optionally return a `branch` fragment alongside `title` and `workItem`, reusing the branch prompt's semantic rules and sanitizer. Validate fields independently so a malformed branch cannot discard a usable title or work-item hint. + +This avoids two queued calls: Monocode's utility runners serialize requests through a per-provider promise chain, and the current send flow starts title generation first. Simply adding and awaiting `generateHarnessBranchName` would wait behind title generation. Existing branch-generation timeouts are 60 seconds for Cursor and 90 seconds for the other branch adapters, before accounting for queue and initialization time. [Codex queue](../src/lib/harness/codexText.ts#L84), [Claude queue](../src/lib/harness/claudeText.ts#L69), [Cursor branch timeout](../src/lib/harness/cursorGit.ts#L15), [Codex branch timeout](../src/lib/harness/codexGit.ts#L15) + +Trigger metadata generation when a new worktree needs a name even if the conversation already has a custom title. Keep title replacement eligibility separate from worktree naming eligibility. Use the selected harness's existing metadata capability; retain a deterministic fallback when unsupported or unavailable. + +Build context from the actual initial request and relevant note, linked-item or handoff context, with attachment filenames when no meaningful text is available. Do not name a plan-based task from the generic `Build approved plan` string. The current worktree path receives `submittedText`, whereas title generation receives `harnessText` or attachment names. Neither existing branch hook nor `TitleInput` accepts image payloads; visual understanding from attachments would be an explicit extension. [Submission context](../src/App.tsx#L4165), [title input](../src/App.tsx#L4388), [adapter input](../src/lib/harness/registry.ts#L14) + +### Create immediately, finalize the name independently + +Keep the current UUID directory and stable ownership ID. Create with a concise temporary/fallback branch such as `monocode/task-`, using native collision checks. Start inference independently; creation and setup continue without waiting for it. Once setup finishes, apply an available result, or let the bounded background task apply it later if the checkout is still eligible. A failed or timed-out naming task must not fail the user's turn. + +Final names should be `monocode/` with a short collision suffix only when necessary. Keep `monocode/`: the existing lifecycle uses that prefix as part of its branch-ownership checks. The full session UUID should remain an internal identity rather than a permanent suffix on every visible branch. [Ownership checks](../src-tauri/src/worktrees.rs#L781) + +Generating before creation is a smaller alternative because it avoids synchronizing a later rename, but it adds model latency to the critical path. A short deadline limits that delay at the cost of abandoning valid late names. A separate display-name field would also avoid Git renames, but leave the underlying Git branch verbose. Background branch naming most closely matches the requested T3 experience. + +### Add a metadata-aware native rename operation + +This is the main integration work. A frontend `git branch -m` alone is insufficient: setup, retirement and reopening compare the checkout's branch against `managed_worktrees.branch`. A mismatch currently blocks them. [Setup validation](../src-tauri/src/worktree_setup.rs#L79), [retirement validation](../src-tauri/src/worktrees.rs#L1294), [reopen validation](../src-tauri/src/worktrees.rs#L3777) + +The native operation should: + +- Accept managed identity, expected temporary branch and proposed fragment. Re-read repository, checkout and naming eligibility under the existing repository reservation. Only worktrees explicitly marked as awaiting an automatic name qualify; a `monocode/` prefix alone is insufficient. +- Validate and allocate the final name in Rust using Git. Never overwrite an existing branch. Preserve the directory, base ref, commit, setup state and recovery identity. +- Decline a stale result if the branch was manually changed, the checkout was retired/replaced, retirement is underway, or publication makes a rename inappropriate. Do not rename during active setup. Intentional reuse of an existing worktree must never schedule a new name from the new conversation. +- Journal rename intent before mutating Git, then update the owned branch and affected session metadata. Git and SQLite cannot commit atomically; reconcile an interrupted, explicitly journaled operation without loosening the existing checks to accept arbitrary branch changes. +- Update every session referencing the checkout, including nested project paths. Protect against a stale frontend session save restoring the old branch: current session persistence prefers a supplied branch over fresh Git metadata. [Session persistence](../src-tauri/src/session_store.rs#L717) +- Notify all relevant windows, refresh branch/worktree caches and update visible session state. Current `notifyGitChanged()` is window-local. [Refresh event](../src/lib/fs.ts#L302), [worktree cache](../src/hooks/useWorktrees.ts), [branch cache](../src/hooks/useProjectBranches.ts) + +Persist naming status per worktree, so setup retry, app restart or a second conversation cannot repeatedly rename it. Generation must run outside lifecycle/database locks. Any coordination with Monocode's own publish actions should share the naming eligibility rule; external Git operations remain a race to handle conservatively. + +## Implementation scope and validation + +| Change | Main location | +| --- | --- | +| Combined title/work-item/branch response and parsing | `src/lib/sessionTitle.ts`, `src/lib/harness/registry.ts`, provider title adapters | +| First-message context and independent naming orchestration | `src/App.tsx`, preferably with the naming lifecycle extracted into a small helper | +| Prepare result identifies created versus reused worktree and pending naming | `src/lib/worktrees.ts`, native `PrepareWorktree`/prepare result | +| Collision allocation, rename journal, native rename and recovery reconciliation | `src-tauri/src/worktrees.rs` or a focused worktree naming module, command registration in `src-tauri/src/lib.rs` | +| Branch metadata freshness and window updates | `src-tauri/src/session_store.rs`, Git/worktree event subscribers | + +The work is a contained feature, but more than wiring one AI function: the existing provider plumbing is reusable; native lifecycle consistency is the substantive part. + +Acceptance checks should cover: + +- A conversational, long initial prompt yields a semantic name rather than its opening words; a manually set conversation title remains intact. +- AI failure, invalid/empty output, unsupported provider and timeout leave creation and the agent working. Exercise the total queue/startup deadline, not only the provider response timeout. +- Identical tasks in concurrent windows get distinct valid branches without overwriting refs. Names preserve the `monocode/` prefix and do not expose the full session UUID. +- Existing/local/external/shared worktrees are not renamed by subsequent conversations; setup retry resumes the same identity without another naming cycle. +- Late results after manual branch change, setup, publication, cancellation or retirement obey the chosen eligibility policy. +- A process interruption between Git rename and SQLite completion is recoverable; stale session saves cannot undo the displayed branch metadata. +- The named worktree can still run setup, archive, retire and restore, including nested projects. Terminal/editor/provider paths remain unchanged. + +Validation performed for this investigation: traced both source flows, searched branch-generation callers and adapter coverage, inspected lifecycle invariants, and reproduced the current slug calculation for the user's message. No application code changed; no live model calls, branch mutations or test suites were run. This is source-level evidence, not a latency or model-quality benchmark. + +## Implementation notes + +The implementation combines branch generation with the existing title/work-item request and bounds acceptance of that request to 45 seconds, including queue/startup time. Unsupported providers keep a short fallback name. A unique request token registers one naming operation during native creation; the prepare API continues returning the stable path. Naming errors remain separate from the main turn. + +Suggestions are saved after setup settles. A failed setup retains its suggestion for a later successful retry. Native naming validates ownership, branch identity, setup, review/shared/archive state and known publication state, allocates a non-conflicting `monocode/` name, and journals the Git-to-SQLite transition. Startup and reopening reconcile an interrupted rename. Events refresh every window, and session saves cannot reintroduce the temporary branch. Monocode push/sync/PR and checkout actions freeze pending naming before proceeding. External Git operations are still outside Monocode's coordination. + +Code and executable coverage: [native naming](../src-tauri/src/worktree_naming.rs), [Git lifecycle tests](../src-tauri/src/worktree_naming_tests.rs), [metadata deadline/context tests](../src/lib/initialSessionMetadata.test.ts), [preparation tests](../src/lib/worktrees.test.ts), [metadata parsing tests](../src/lib/sessionTitle.test.ts). diff --git a/docs/worktrees.md b/docs/worktrees.md index 0c237b34..c2720766 100644 --- a/docs/worktrees.md +++ b/docs/worktrees.md @@ -2,12 +2,20 @@ Choose where a conversation works using the two controls above the message box: -- **New worktree · From main** — start an isolated task. Pick a different base if needed, then send your message. Monocode creates and names the branch and checkout before starting the agent. Selecting a base does not switch the source checkout. +- **New worktree · From main** — start an isolated task. Pick a different base if needed, then send your message. Monocode creates the branch and checkout and completes setup before starting the agent. Selecting a base does not switch the source checkout. - **Current checkout · main** — work directly in your project folder. - **Existing worktree** — select a checkout from the workspace menu to continue work on that branch in another conversation. New worktree is the default in this fork. Your explicit choice belongs to the draft and survives a restart; it does not change another conversation. Once the conversation starts, its checkout stays fixed. Its agent, files, changes and terminal dock use that directory. Conversations remain grouped under the original project. +New worktrees receive an AI-generated branch name based on your initial request, such as `monocode/add-history-search`. A short temporary name appears immediately; naming runs in the background alongside the conversation-title request and never delays the agent. Duplicate names receive a numeric suffix. If the provider cannot generate a name, the temporary name stays usable. A custom conversation title is preserved. + +Naming accepts structured JSON only, so provider quota or sign-in messages cannot become titles or branches. If naming fails or exceeds its 45-second deadline, a dismissible **AI naming unavailable** notice explaining that the default branch name was kept offers **Retry** when the provider supports naming. Retry uses the same provider and initial request; it does not create another worktree or automatically switch accounts/models. Repeated clicks share one attempt. The notice remains until dismissed or the retry completes; it is not restored after restarting the app. + +Retries remain subject to the original naming request's ownership and branch checks. A saved suggestion can be applied after setup succeeds. Native naming errors are shown separately from generation failures, and a branch that is no longer eligible is kept without reporting a successful rename. See the [T3 failure-handling comparison](worktree-naming-failures-research.md) for the source behavior behind this design. + +Naming keeps the checkout directory fixed. It only applies once to a newly created worktree, after successful setup; a setup retry can use an already saved suggestion. Existing worktrees and branches you switch or publish through Monocode are left as chosen. Observed external branch changes and upstream configuration also prevent a delayed automatic rename. + A new worktree starts with committed files. In **Settings → Worktrees**, choose a project and configure its environment once: - **Setup command** runs in the selected project's directory inside a newly created or restored worktree before the agent starts, for example `npm ci`. Setup failures keep the checkout and can be retried. Newly added copy paths are applied on retry without overwriting previously copied files that you edited. An existing checkout that has completed setup is not set up again on every message. diff --git a/src-tauri/src/fs.rs b/src-tauri/src/fs.rs index ca37d935..7fece027 100644 --- a/src-tauri/src/fs.rs +++ b/src-tauri/src/fs.rs @@ -426,10 +426,13 @@ pub async fn git_commit(cwd: String, message: String) -> Result<(), String> { /// Push the current branch to its upstream, or set upstream on first push. #[tauri::command] -pub async fn git_push(cwd: String) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || git_push_for(&expand_home(&cwd))) - .await - .map_err(|e| e.to_string())? +pub async fn git_push(app: tauri::AppHandle, cwd: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + crate::worktrees::naming::stabilize_branch(&app, &cwd)?; + git_push_for(&expand_home(&cwd)) + }) + .await + .map_err(|e| e.to_string())? } /// Fast-forward the current branch from its upstream. @@ -444,10 +447,13 @@ pub async fn git_pull(cwd: String) -> Result<(), String> { /// Pull incoming commits, then push local commits. #[tauri::command] -pub async fn git_sync(cwd: String) -> Result<(), String> { - tauri::async_runtime::spawn_blocking(move || git_sync_changes_for(&expand_home(&cwd))) - .await - .map_err(|e| e.to_string())? +pub async fn git_sync(app: tauri::AppHandle, cwd: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + crate::worktrees::naming::stabilize_branch(&app, &cwd)?; + git_sync_changes_for(&expand_home(&cwd)) + }) + .await + .map_err(|e| e.to_string())? } #[derive(Serialize, Clone, Debug, PartialEq, Eq)] @@ -496,6 +502,7 @@ struct GitPrCreateInput { /// Create a GitHub pull request with `gh` and return its URL. #[tauri::command] pub async fn git_pr_create( + app: tauri::AppHandle, cwd: String, title: String, body: String, @@ -503,6 +510,7 @@ pub async fn git_pr_create( head: String, ) -> Result { tauri::async_runtime::spawn_blocking(move || { + crate::worktrees::naming::stabilize_branch(&app, &cwd)?; git_pr_create_for( &expand_home(&cwd), &GitPrCreateInput { @@ -597,10 +605,21 @@ pub async fn git_github_repo(cwd: String) -> Result { .map_err(|e| e.to_string())? } -/// Open issues or pull requests for the current GitHub remote, via `gh`. +/// The local repository and its fork parent, independently of `gh`'s default. +#[tauri::command] +pub async fn git_github_inbox_repos(cwd: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + git_github_inbox_repos_for(&expand_home(&cwd), gh_checked) + }) + .await + .map_err(|e| e.to_string())? +} + +/// Issues or pull requests for an explicit GitHub repository, via `gh`. #[tauri::command] pub async fn git_github_work_items( cwd: String, + repo: String, kind: String, assigned_to_me: bool, state: String, @@ -610,6 +629,7 @@ pub async fn git_github_work_items( tauri::async_runtime::spawn_blocking(move || { git_github_work_items_for( &expand_home(&cwd), + &repo, &kind, assigned_to_me, &state, @@ -652,11 +672,12 @@ pub struct GitHubWorkItemDetails { #[tauri::command] pub async fn git_github_work_item_details( cwd: String, + repo: String, kind: String, number: i64, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - git_github_work_item_details_for(&expand_home(&cwd), &kind, number) + git_github_work_item_details_for(&expand_home(&cwd), &repo, &kind, number) }) .await .map_err(|e| e.to_string())? @@ -705,11 +726,12 @@ pub struct GitHubWorkItemThread { #[tauri::command] pub async fn git_github_work_item_thread( cwd: String, + repo: String, kind: String, number: i64, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - git_github_work_item_thread_for(&expand_home(&cwd), &kind, number) + git_github_work_item_thread_for(&expand_home(&cwd), &repo, &kind, number) }) .await .map_err(|e| e.to_string())? @@ -719,13 +741,21 @@ pub async fn git_github_work_item_thread( #[tauri::command] pub async fn git_github_work_item_comment( cwd: String, + repo: String, kind: String, number: i64, body: String, in_reply_to: String, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - git_github_work_item_comment_for(&expand_home(&cwd), &kind, number, &body, &in_reply_to) + git_github_work_item_comment_for( + &expand_home(&cwd), + &repo, + &kind, + number, + &body, + &in_reply_to, + ) }) .await .map_err(|e| e.to_string())? @@ -753,10 +783,16 @@ const MAX_PR_DIFF_BYTES: usize = 2 * 1024 * 1024; /// Unified diff and file stats for a pull request, via `gh`. #[tauri::command] -pub async fn git_github_pr_diff(cwd: String, number: i64) -> Result { - tauri::async_runtime::spawn_blocking(move || git_github_pr_diff_for(&expand_home(&cwd), number)) - .await - .map_err(|e| e.to_string())? +pub async fn git_github_pr_diff( + cwd: String, + repo: String, + number: i64, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + git_github_pr_diff_for(&expand_home(&cwd), &repo, number) + }) + .await + .map_err(|e| e.to_string())? } #[derive(Serialize, Clone, Debug, Default, PartialEq, Eq)] @@ -786,11 +822,13 @@ pub async fn git_branches(cwd: String) -> Result { /// Switch to an existing local branch, or create a local tracking branch from a remote. #[tauri::command] pub async fn git_checkout( + app: tauri::AppHandle, cwd: String, name: String, remote: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || { + crate::worktrees::naming::stabilize_branch(&app, &cwd)?; git_checkout_for(&expand_home(&cwd), &name, remote.as_deref()) }) .await @@ -799,10 +837,17 @@ pub async fn git_checkout( /// Create a branch from HEAD and switch to it. #[tauri::command] -pub async fn git_create_branch(cwd: String, name: String) -> Result { - tauri::async_runtime::spawn_blocking(move || git_create_branch_for(&expand_home(&cwd), &name)) - .await - .map_err(|e| e.to_string())? +pub async fn git_create_branch( + app: tauri::AppHandle, + cwd: String, + name: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + crate::worktrees::naming::stabilize_branch(&app, &cwd)?; + git_create_branch_for(&expand_home(&cwd), &name) + }) + .await + .map_err(|e| e.to_string())? } /// Stash tracked and untracked local changes so a checkout can proceed. @@ -1678,14 +1723,62 @@ fn git_github_repo_for(root: &Path) -> Result { Ok(slug.to_string()) } +fn git_github_inbox_repos_for( + root: &Path, + run_gh: impl FnOnce(&Path, &[&str]) -> Result, +) -> Result, String> { + // `gh` may default to upstream. Resolve the checkout's origin explicitly so + // the fork remains in Inbox even when the default points at upstream. + let remote_url = + git_remote_name(root).and_then(|remote| git_stdout(root, &["remote", "get-url", &remote])); + let mut args = vec!["repo", "view"]; + if let Some(url) = remote_url.as_deref() { + args.push(url); + } + args.extend(["--json", "nameWithOwner,parent"]); + parse_github_inbox_repos(&run_gh(root, &args)?) +} + +fn parse_github_inbox_repos(json: &str) -> Result, String> { + #[derive(Deserialize)] + struct Owner { + login: String, + } + #[derive(Deserialize)] + struct Parent { + name: String, + owner: Owner, + } + #[derive(Deserialize)] + struct View { + #[serde(rename = "nameWithOwner")] + repo: String, + parent: Option, + } + let view: View = serde_json::from_str(json).map_err(|error| error.to_string())?; + let (owner, name) = split_github_repo(&view.repo)?; + let mut repos = vec![format!("{owner}/{name}")]; + if let Some(parent) = view.parent { + let (owner, name) = split_github_repo(&format!("{}/{}", parent.owner.login, parent.name))?; + let repo = format!("{owner}/{name}"); + if !repos[0].eq_ignore_ascii_case(&repo) { + repos.push(repo); + } + } + Ok(repos) +} + fn git_github_work_items_for( root: &Path, + repo: &str, kind: &str, assigned_to_me: bool, state: &str, search: &str, limit: u32, ) -> Result, String> { + let (owner, name) = split_github_repo(repo)?; + let repo = format!("{owner}/{name}"); let kind = kind.trim(); if kind != "issue" && kind != "pr" { return Err("Unknown GitHub task kind".into()); @@ -1704,6 +1797,8 @@ fn git_github_work_items_for( let mut args = vec![ kind.to_string(), "list".into(), + "--repo".into(), + repo.clone(), "--state".into(), state.into(), "--limit".into(), @@ -1722,7 +1817,6 @@ fn git_github_work_items_for( } let refs: Vec<&str> = args.iter().map(String::as_str).collect(); let json = gh_checked(root, &refs)?; - let repo = git_github_repo_for(root).unwrap_or_default(); parse_github_work_items(&json, kind, &repo) } @@ -1756,9 +1850,12 @@ fn git_github_work_item_for( fn git_github_work_item_details_for( root: &Path, + repo: &str, kind: &str, number: i64, ) -> Result { + let (owner, name) = split_github_repo(repo)?; + let repo = format!("{owner}/{name}"); let kind = kind.trim(); if kind != "issue" && kind != "pr" { return Err("Unknown GitHub task kind".into()); @@ -1769,7 +1866,10 @@ fn git_github_work_item_details_for( } else { "body,author" }; - let json = gh_checked(root, &[kind, "view", &number, "--json", fields])?; + let json = gh_checked( + root, + &[kind, "view", &number, "--repo", &repo, "--json", fields], + )?; parse_github_work_item_details(&json) } @@ -1910,6 +2010,7 @@ mutation InboxReviewReply($threadId: ID!, $body: String!) { fn git_github_work_item_thread_for( root: &Path, + repo: &str, kind: &str, number: i64, ) -> Result { @@ -1920,8 +2021,7 @@ fn git_github_work_item_thread_for( if number <= 0 { return Err("Invalid GitHub item number".into()); } - let repo = git_github_repo_for(root)?; - let (owner, name) = split_github_repo(&repo)?; + let (owner, name) = split_github_repo(repo)?; let query = if kind == "pr" { GITHUB_PR_THREAD_QUERY } else { @@ -1969,11 +2069,14 @@ fn github_comment_input<'a>( fn git_github_work_item_comment_for( root: &Path, + repo: &str, kind: &str, number: i64, body: &str, in_reply_to: &str, ) -> Result { + let (owner, name) = split_github_repo(repo)?; + let repo = format!("{owner}/{name}"); let (kind, body) = github_comment_input(kind, number, body)?; let reply = in_reply_to.trim(); if !reply.is_empty() { @@ -1981,7 +2084,18 @@ fn git_github_work_item_comment_for( } let number = number.to_string(); with_temp_markdown(body, |path| { - let output = gh_checked(root, &[kind, "comment", &number, "--body-file", path])?; + let output = gh_checked( + root, + &[ + kind, + "comment", + &number, + "--repo", + &repo, + "--body-file", + path, + ], + )?; github_url_from_output(&output, "GitHub did not return a comment URL") }) } @@ -2473,18 +2587,28 @@ fn github_avatar_url(login: &str) -> String { format!("https://avatars.githubusercontent.com/{encoded}?s=64") } -fn git_github_pr_diff_for(root: &Path, number: i64) -> Result { +fn git_github_pr_diff_for(root: &Path, repo: &str, number: i64) -> Result { + let (owner, name) = split_github_repo(repo)?; + let repo = format!("{owner}/{name}"); if number <= 0 { return Err("Invalid pull request number".into()); } let number = number.to_string(); let json = gh_run( root, - &["pr", "view", &number, "--json", "files,additions,deletions"], + &[ + "pr", + "view", + &number, + "--repo", + &repo, + "--json", + "files,additions,deletions", + ], false, )?; let mut diff = parse_github_pr_diff_meta(&json)?; - let patch = gh_run(root, &["pr", "diff", &number], true)?; + let patch = gh_run(root, &["pr", "diff", &number, "--repo", &repo], true)?; if patch.len() > MAX_PR_DIFF_BYTES { diff.truncated = true; } else { @@ -5112,6 +5236,61 @@ mod tests { ); } + #[test] + fn github_inbox_discovers_parent_without_an_upstream_remote() { + let dir = tmp("github-inbox-fork"); + git_run(&dir.0, &["init"]).unwrap(); + git_run( + &dir.0, + &["remote", "add", "origin", "git@github.com:me/widget.git"], + ) + .unwrap(); + let repos = git_github_inbox_repos_for(&dir.0, |root, args| { + assert_eq!(root, dir.0); + assert_eq!(args, ["repo", "view", "git@github.com:me/widget.git", "--json", "nameWithOwner,parent"]); + Ok(r#"{"nameWithOwner":"me/widget","parent":{"name":"widget","owner":{"login":"acme"}}}"#.into()) + }).unwrap(); + assert_eq!(repos, ["me/widget", "acme/widget"]); + + git_run( + &dir.0, + &[ + "remote", + "add", + "upstream", + "https://github.com/acme/widget.git", + ], + ) + .unwrap(); + git_run(&dir.0, &["config", "remote.upstream.gh-resolved", "base"]).unwrap(); + let repos_with_upstream_default = git_github_inbox_repos_for(&dir.0, |_, args| { + assert_eq!(args[2], "git@github.com:me/widget.git"); + Ok(r#"{"nameWithOwner":"me/widget","parent":{"name":"widget","owner":{"login":"acme"}}}"#.into()) + }).unwrap(); + assert_eq!(repos_with_upstream_default, repos); + } + + #[test] + fn github_inbox_keeps_repositories_without_a_visible_parent() { + for json in [ + r#"{"nameWithOwner":"acme/widget","parent":null}"#, + r#"{"nameWithOwner":"acme/widget"}"#, + ] { + assert_eq!(parse_github_inbox_repos(json).unwrap(), ["acme/widget"]); + } + } + + #[test] + fn github_inbox_validates_and_deduplicates_repository_identities() { + let json = r#"{"nameWithOwner":"acme/widget","parent":{"name":"Widget","owner":{"login":"ACME"}}}"#; + assert_eq!(parse_github_inbox_repos(json).unwrap(), ["acme/widget"]); + assert!(parse_github_inbox_repos(r#"{"nameWithOwner":"widget"}"#).is_err()); + assert!(parse_github_inbox_repos( + r#"{"nameWithOwner":"acme/widget","parent":{"name":"widget","owner":{"login":""}}}"# + ) + .is_err()); + } + #[test] fn parse_github_work_items_maps_issue_fields() { let json = r#"[{ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 06dc87eb..cc25c078 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -239,6 +239,7 @@ pub fn run() { fs::git_pr_create, fs::git_github_status, fs::git_github_repo, + fs::git_github_inbox_repos, fs::git_github_work_item, fs::git_github_work_items, fs::git_github_work_item_details, @@ -268,6 +269,8 @@ pub fn run() { worktrees::worktree_storage_limit_set, worktrees::worktree_create, worktrees::worktree_prepare, + worktrees::naming::worktree_name, + worktrees::naming::worktree_name_status, worktrees::setup::worktree_setup, worktrees::worktree_heartbeat, worktrees::worktree_pin, diff --git a/src-tauri/src/session_store.rs b/src-tauri/src/session_store.rs index 3a141a0f..2389efe4 100644 --- a/src-tauri/src/session_store.rs +++ b/src-tauri/src/session_store.rs @@ -717,12 +717,36 @@ fn upsert_session(conn: &Connection, session: &SessionUpsert) -> rusqlite::Resul let git = crate::fs::git_info_for(&crate::fs::expand_home( session.worktree_cwd.as_deref().unwrap_or(&session.cwd), )); - let branch = session + let supplied_branch = session .branch .as_deref() .map(str::trim) - .filter(|value| !value.is_empty()) - .or_else(|| git.branch.as_deref().filter(|value| !value.is_empty())); + .filter(|value| !value.is_empty()); + // A delayed save from another window must not reinstate the temporary + // branch after native naming. The journal also covers a retired checkout. + let named_branch: Option = conn + .query_row( + "SELECT managed.branch FROM managed_worktrees managed + JOIN worktree_naming naming ON naming.worktree_id = managed.id + WHERE naming.state = 'done' AND + (managed.id = ?1 OR COALESCE(?2, ?3) = managed.path OR + substr(COALESCE(?2, ?3), 1, length(managed.path) + 1) = managed.path || '/') + LIMIT 1", + params![session.id, session.worktree_cwd, session.cwd], + |row| row.get(0), + ) + .optional()?; + let observed_branch = git.branch.as_deref().filter(|value| !value.is_empty()); + let branch = if named_branch.is_some() { + // Before first checkout persistence, cwd can still be the primary repo. + if session.worktree_cwd.is_some() { + observed_branch.or(named_branch.as_deref()) + } else { + named_branch.as_deref() + } + } else { + supplied_branch.or(observed_branch) + }; let worktree_cwd = session .worktree_cwd .as_deref() @@ -1502,6 +1526,28 @@ mod tests { assert_eq!(second.provider_session_id.as_deref(), Some("acp-session-2")); } + #[test] + fn stale_session_save_cannot_restore_a_temporary_worktree_branch() { + let store = SessionStore::open_in_memory().unwrap(); + let conn = store.conn.lock().unwrap(); + let path = "/nonexistent/monocode-naming-test/worktree"; + conn.execute("INSERT INTO managed_worktrees (id, repo, common_dir, path, branch, base_ref, last_used) + VALUES ('owner', '/nonexistent/project', '/nonexistent/project/.git', ?1, 'monocode/semantic-name', 'main', 0)", [path]).unwrap(); + conn.execute("INSERT INTO worktree_naming(worktree_id, token, source_branch, target_branch, state) + VALUES ('owner', 'request-token', 'monocode/task-abcd1234', 'monocode/semantic-name', 'done')", []).unwrap(); + for (id, cwd) in [ + ("owner", path.to_string()), + ("nested", format!("{path}/apps/web")), + ] { + let mut session = sample(id, "/nonexistent/project", "Custom title"); + session.worktree_cwd = Some(cwd); + session.branch = Some("monocode/task-abcd1234".into()); + let saved = upsert_session(&conn, &session).unwrap(); + assert_eq!(saved.branch.as_deref(), Some("monocode/semantic-name")); + assert_eq!(saved.title, "Custom title"); + } + } + #[test] fn context_usage_round_trips() { let store = SessionStore::open_in_memory().unwrap(); diff --git a/src-tauri/src/worktree_environment.rs b/src-tauri/src/worktree_environment.rs index 786bf145..f35da9b2 100644 --- a/src-tauri/src/worktree_environment.rs +++ b/src-tauri/src/worktree_environment.rs @@ -75,7 +75,7 @@ impl SetupOrigin { pub(super) enum BeginSetup { Skip, - Run(SetupOperation), + Run(Box), } pub(super) enum FinishSetup { @@ -774,7 +774,7 @@ pub(super) fn begin_setup(conn: &Connection, requested_path: &str) -> Result Result bool { == Some(ERROR_INVALID_PARAMETER as i32); } let process = unsafe { OwnedHandle::from_raw_handle(raw) }; - return unsafe { WaitForSingleObject(process.as_raw_handle(), 0) } == WAIT_OBJECT_0; + (unsafe { WaitForSingleObject(process.as_raw_handle(), 0) }) == WAIT_OBJECT_0 } #[cfg(not(windows))] { diff --git a/src-tauri/src/worktree_naming.rs b/src-tauri/src/worktree_naming.rs new file mode 100644 index 00000000..9094f46a --- /dev/null +++ b/src-tauri/src/worktree_naming.rs @@ -0,0 +1,474 @@ +//! One-time semantic branch naming. The checkout path never changes. A durable +//! intent bridges Git's rename and SQLite's ownership update after interruption. +use super::*; + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeNamed { + pub id: String, + pub session_ids: Vec, + pub path: String, + pub branch: String, +} + +#[derive(Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum WorktreeNameStatus { + Waiting, + Pending, + Named, + Skipped, +} + +fn status(conn: &Connection, id: &str, token: &str) -> Result { + let state: Option = conn + .query_row( + "SELECT n.state FROM worktree_naming n + JOIN managed_worktrees m ON m.id = n.worktree_id + WHERE n.worktree_id = ?1 AND n.token = ?2 AND m.removed = 0", + params![id, token], + |row| row.get(0), + ) + .optional() + .map_err(|e| e.to_string())?; + Ok(match state.as_deref() { + Some("waiting") => WorktreeNameStatus::Waiting, + Some("ready" | "renaming") => WorktreeNameStatus::Pending, + Some("done") => WorktreeNameStatus::Named, + _ => WorktreeNameStatus::Skipped, + }) +} + +/// A failed generation leaves the original request waiting. Explicit retries +/// can inspect it, but never reopen a request frozen by a user Git operation. +#[tauri::command(async)] +pub fn worktree_name_status( + store: State<'_, SessionStore>, + session_id: String, + token: String, +) -> Result { + status(&store.open_auxiliary_conn()?, &session_id, &token) +} + +pub(super) fn schema(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS worktree_naming ( + worktree_id TEXT PRIMARY KEY REFERENCES managed_worktrees(id) ON DELETE CASCADE, + token TEXT NOT NULL, source_branch TEXT NOT NULL, + state TEXT NOT NULL, target_branch TEXT, rename_oid TEXT + );", + ) +} + +pub(super) fn register(conn: &Connection, entry: &Owned, token: &str) -> Result<(), String> { + validate_id(token)?; + conn.execute( + "INSERT INTO worktree_naming (worktree_id, token, source_branch, state) + VALUES (?1, ?2, ?3, 'waiting')", + params![entry.id, token, entry.branch], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +pub(super) fn available_branch(repo: &Path, desired: &str) -> Result { + git(repo, &["check-ref-format", "--branch", desired])?; + for suffix in 0..=100 { + let name = if suffix == 0 { + desired.to_string() + } else { + format!("{desired}-{suffix}") + }; + if ref_oid(repo, &format!("refs/heads/{name}"))?.is_none() { + return Ok(name); + } + } + Err("Could not allocate an unused worktree branch name".into()) +} + +fn normalize(raw: &str) -> Option { + let lower = raw.trim().to_ascii_lowercase(); + let raw = lower.strip_prefix("refs/heads/").unwrap_or(&lower); + let raw = raw.strip_prefix("monocode/").unwrap_or(raw); + let fragment = raw + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|word| !word.is_empty()) + .collect::>() + .join("-"); + let fragment = fragment.chars().take(64).collect::(); + let fragment = fragment.trim_end_matches('-'); + (!fragment.is_empty()).then(|| format!("monocode/{fragment}")) +} + +#[derive(Debug)] +struct Naming { + source: String, + target: Option, + oid: Option, + state: String, +} + +fn record(conn: &Connection, id: &str) -> Result, String> { + conn.query_row( + "SELECT source_branch, target_branch, rename_oid, state FROM worktree_naming + WHERE worktree_id = ?1", + [id], + |row| { + Ok(Naming { + source: row.get(0)?, + target: row.get(1)?, + oid: row.get(2)?, + state: row.get(3)?, + }) + }, + ) + .optional() + .map_err(|e| e.to_string()) +} + +fn skip(conn: &Connection, id: &str) -> Result<(), String> { + conn.execute( + "UPDATE worktree_naming SET state = 'skipped' WHERE worktree_id = ?1", + [id], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +fn complete(conn: &Connection, entry: &Owned, naming: &Naming) -> Result { + let target = naming + .target + .as_deref() + .ok_or("Missing worktree rename target")?; + let tx = conn.unchecked_transaction().map_err(|e| e.to_string())?; + let changed = tx + .execute( + "UPDATE managed_worktrees SET branch = ?1 + WHERE id = ?2 AND branch = ?3 AND removed = 0", + params![target, entry.id, naming.source], + ) + .map_err(|e| e.to_string())?; + if changed != 1 { + return Err("Worktree ownership changed during naming".into()); + } + let session_ids = { + let mut statement = tx + .prepare( + "SELECT id FROM sessions WHERE id = ?1 OR COALESCE(worktree_cwd, cwd) = ?2 OR + substr(COALESCE(worktree_cwd, cwd), 1, length(?2) + 1) = ?2 || '/'", + ) + .map_err(|e| e.to_string())?; + let rows = statement + .query_map(params![entry.id, entry.path], |row| row.get::<_, String>(0)) + .map_err(|e| e.to_string())?; + rows.collect::, _>>() + .map_err(|e| e.to_string())? + }; + tx.execute( + "UPDATE sessions SET branch = ?1 WHERE id = ?2 OR + COALESCE(worktree_cwd, cwd) = ?3 OR + substr(COALESCE(worktree_cwd, cwd), 1, length(?3) + 1) = ?3 || '/'", + params![target, entry.id, entry.path], + ) + .map_err(|e| e.to_string())?; + tx.execute( + "UPDATE worktree_naming SET state = 'done' WHERE worktree_id = ?1", + [&entry.id], + ) + .map_err(|e| e.to_string())?; + tx.commit().map_err(|e| e.to_string())?; + Ok(WorktreeNamed { + id: entry.id.clone(), + session_ids, + path: entry.path.clone(), + branch: target.into(), + }) +} + +/// Only recognize the exact journaled transition. Never adopt arbitrary branch +/// drift as an owned rename. The branch may have gained commits after Git ran. +fn reconcile( + conn: &Connection, + entry: &Owned, + naming: &Naming, +) -> Result, String> { + let target = naming + .target + .as_deref() + .ok_or("Missing worktree rename target")?; + let oid = naming + .oid + .as_deref() + .ok_or("Missing worktree rename commit")?; + if entry.removed || entry.branch != naming.source { + return Err("Worktree ownership changed during interrupted naming".into()); + } + let mut renamed = entry.clone(); + renamed.branch = target.into(); + let source_oid = ref_oid( + Path::new(&entry.repo), + &format!("refs/heads/{}", naming.source), + )?; + if source_oid.is_none() && setup::validate_checkout(&renamed, &entry.path).is_ok() { + let head = resolve_commit(Path::new(&entry.path), "HEAD")?; + if is_ancestor(Path::new(&entry.repo), oid, &head)? { + return complete(conn, entry, naming).map(Some); + } + } + if source_oid.is_some() && setup::validate_checkout(entry, &entry.path).is_ok() { + // Git did not complete the rename. Keep the original branch; never + // replay a delayed mutation after a restart or a user operation. + skip(conn, &entry.id)?; + return Ok(None); + } + Err("Interrupted worktree naming could not be verified; checkout was preserved".into()) +} + +/// Caller holds this repository's reservation and the short lifecycle lock. +pub(super) fn apply_pending(conn: &Connection, id: &str) -> Result, String> { + let Some(naming) = record(conn, id)? else { + return Ok(None); + }; + if !matches!(naming.state.as_str(), "ready" | "renaming") { + return Ok(None); + } + let Some(entry) = owned(conn)?.into_iter().find(|entry| entry.id == id) else { + return Ok(None); + }; + if naming.state == "renaming" { + return reconcile(conn, &entry, &naming); + } + let has_review: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM worktree_retirement_items WHERE worktree_id = ?1)", + [id], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + let unavailable_session: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sessions WHERE (id = ?1 AND archived = 1) OR + (id != ?1 AND (COALESCE(worktree_cwd, cwd) = ?2 OR + substr(COALESCE(worktree_cwd, cwd), 1, length(?2) + 1) = ?2 || '/')))", + params![id, entry.path], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + if entry.removed + || entry.creation_oid.is_some() + || entry.branch != naming.source + || entry.pending_retirement_plan_id.is_some() + || entry.active_retirement_plan_id.is_some() + || has_review + || unavailable_session + || setup::validate_checkout(&entry, &entry.path).is_err() + { + skip(conn, id)?; + return Ok(None); + } + let ready: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM worktree_environment_setup + WHERE worktree_id = ?1 AND status = 'ready')", + [id], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + if !ready { + return Ok(None); + } + if checkouts(Path::new(&entry.repo))? + .iter() + .any(|checkout| checkout.path == entry.path && checkout.locked) + { + skip(conn, id)?; + return Ok(None); + } + let repo = Path::new(&entry.repo); + let upstream = git( + repo, + &[ + "for-each-ref", + "--format=%(upstream)", + &format!("refs/heads/{}", entry.branch), + ], + )?; + let remotes = git( + repo, + &["for-each-ref", "--format=%(refname)", "refs/remotes/"], + )?; + if !upstream.trim().is_empty() + || remotes + .lines() + .any(|line| line.ends_with(&format!("/{}", entry.branch))) + { + skip(conn, id)?; + return Ok(None); + } + let desired = naming + .target + .as_deref() + .ok_or("Missing worktree name suggestion")?; + let target = available_branch(repo, desired)?; + let oid = resolve_commit(Path::new(&entry.path), "HEAD")?; + conn.execute( + "UPDATE worktree_naming SET state = 'renaming', target_branch = ?1, rename_oid = ?2 + WHERE worktree_id = ?3 AND state = 'ready'", + params![target, oid, id], + ) + .map_err(|e| e.to_string())?; + // Never force a rename: another process may have claimed the target since + // allocation. The explicit source avoids renaming a newly selected branch. + if let Err(error) = git( + Path::new(&entry.path), + &["branch", "-m", "--", &naming.source, &target], + ) { + // Reconcile even when Git reports failure: it may have partially run. + let journal = record(conn, id)?.ok_or("Missing worktree naming journal")?; + return match reconcile(conn, &entry, &journal) { + Ok(Some(changed)) => Ok(Some(changed)), + Ok(None) => { + // The live failure left the original checkout intact. Keep its + // saved suggestion retryable; interrupted-operation recovery + // still skips an uncompleted rename rather than replaying it. + conn.execute( + "UPDATE worktree_naming SET state = 'ready', rename_oid = NULL + WHERE worktree_id = ?1 AND state = 'skipped'", + [id], + ) + .map_err(|e| e.to_string())?; + Err(error) + } + Err(_) => Err(error), + }; + } + let journal = record(conn, id)?.ok_or("Missing worktree naming journal")?; + reconcile(conn, &entry, &journal) +} + +fn suggest( + conn: &Connection, + id: &str, + token: &str, + branch: Option<&str>, +) -> Result, String> { + let target = branch.and_then(normalize); + let changed = conn + .execute( + "UPDATE worktree_naming SET target_branch = ?1, state = ?2 + WHERE worktree_id = ?3 AND token = ?4 AND state = 'waiting'", + params![ + target, + if target.is_some() { "ready" } else { "skipped" }, + id, + token + ], + ) + .map_err(|e| e.to_string())?; + if changed == 0 { + if status(conn, id, token)? == WorktreeNameStatus::Pending { + return apply_pending(conn, id); + } + return Ok(None); + } + apply_pending(conn, id) +} + +pub(super) fn emit(app: &AppHandle, result: Result, String>) { + match result { + Ok(Some(event)) => { + let _ = app.emit("worktree-named", event); + } + Ok(None) => {} + Err(error) => eprintln!("[monocode] worktree naming: {error}"), + } +} + +#[tauri::command(async)] +pub fn worktree_name( + app: AppHandle, + store: State<'_, SessionStore>, + host: State<'_, WorktreeHost>, + session_id: String, + token: String, + branch: Option, +) -> Result { + let conn = store.open_auxiliary_conn()?; + let Some(entry) = owned(&conn)? + .into_iter() + .find(|entry| entry.id == session_id) + else { + return Ok(WorktreeNameStatus::Skipped); + }; + let _repository = host.repository_guard(&entry.common)?; + let _windows = host.operation_guard()?; + let result = suggest(&conn, &session_id, &token, branch.as_deref())?; + emit(&app, Ok(result)); + status(&conn, &session_id, &token) +} + +pub(super) fn reconcile_repository( + conn: &Connection, + common: &str, +) -> Result, String> { + let mut changes = Vec::new(); + for entry in owned(conn)? + .into_iter() + .filter(|entry| entry.common == common) + { + if let Some(naming) = record(conn, &entry.id)? { + if naming.state == "renaming" { + if let Some(changed) = reconcile(conn, &entry, &naming)? { + changes.push(changed); + } + } + } + } + Ok(changes) +} + +pub(super) fn recover(app: &AppHandle, conn: &Connection) -> Result<(), String> { + for entry in owned(conn)? { + // At startup no other app operation is running. Only finish journaled + // Git mutations, never resume model requests or start fresh renames. + if let Some(naming) = record(conn, &entry.id)? { + if naming.state == "renaming" { + emit(app, reconcile(conn, &entry, &naming)); + } + } + } + Ok(()) +} + +/// Resolve a possible interrupted rename, then freeze naming before publishing +/// or an explicit checkout change. Release locks before any network operation. +pub(crate) fn stabilize_branch(app: &AppHandle, cwd: &str) -> Result<(), String> { + let conn = app.state::().open_auxiliary_conn()?; + let Some(entry) = owned(&conn)? + .into_iter() + .find(|entry| path_inside(&expand_home(cwd), Path::new(&entry.path))) + else { + return Ok(()); + }; + let host = app.state::(); + let _repository = host.repository_guard(&entry.common)?; + let _windows = host.operation_guard()?; + emit(app, Ok(freeze_pending(&conn, &entry)?)); + Ok(()) +} + +fn freeze_pending(conn: &Connection, entry: &Owned) -> Result, String> { + let mut changed = None; + if let Some(naming) = record(conn, &entry.id)? { + if naming.state == "renaming" { + changed = reconcile(conn, entry, &naming)?; + } + conn.execute("UPDATE worktree_naming SET state = 'skipped' WHERE worktree_id = ?1 AND state IN ('waiting', 'ready')", [&entry.id]) + .map_err(|e| e.to_string())?; + } + Ok(changed) +} + +#[cfg(test)] +#[path = "worktree_naming_tests.rs"] +mod tests; diff --git a/src-tauri/src/worktree_naming_tests.rs b/src-tauri/src/worktree_naming_tests.rs new file mode 100644 index 00000000..61e192a3 --- /dev/null +++ b/src-tauri/src/worktree_naming_tests.rs @@ -0,0 +1,483 @@ +use super::*; +use crate::worktrees::tests::Fixture; + +const TOKEN: &str = "naming-request-one"; + +#[test] +fn failed_generation_can_retry_but_never_reopens_a_frozen_or_completed_request() { + let f = Fixture::new(); + let entry = draft(&f, "retry-session"); + setup_ready(&f, &entry); + // Generation failure performs no Git mutation and leaves this request + // waiting. A status read neither consumes nor renews its original token. + assert_eq!( + status(&f.conn, &entry.id, TOKEN).unwrap(), + WorktreeNameStatus::Waiting + ); + assert_eq!( + status(&f.conn, &entry.id, "stale-token").unwrap(), + WorktreeNameStatus::Skipped + ); + assert_eq!(current(&f, &entry.id).branch, entry.branch); + let named = suggest(&f.conn, &entry.id, TOKEN, Some("retry-naming")) + .unwrap() + .unwrap(); + assert_eq!(named.branch, "monocode/retry-naming"); + assert_eq!( + status(&f.conn, &entry.id, TOKEN).unwrap(), + WorktreeNameStatus::Named + ); + assert!(suggest(&f.conn, &entry.id, TOKEN, Some("rename-again")) + .unwrap() + .is_none()); + + let frozen = draft(&f, "frozen-retry-session"); + setup_ready(&f, &frozen); + freeze_pending(&f.conn, &frozen).unwrap(); + assert_eq!( + status(&f.conn, &frozen.id, TOKEN).unwrap(), + WorktreeNameStatus::Skipped + ); + assert!(suggest(&f.conn, &frozen.id, TOKEN, Some("late-retry")) + .unwrap() + .is_none()); + assert_eq!(current(&f, &frozen.id).branch, frozen.branch); +} + +#[test] +fn explicit_retry_reapplies_only_the_original_saved_suggestion() { + let f = Fixture::new(); + let entry = draft(&f, "saved-retry-session"); + assert!(suggest(&f.conn, &entry.id, TOKEN, Some("original-name")) + .unwrap() + .is_none()); + assert_eq!( + status(&f.conn, &entry.id, TOKEN).unwrap(), + WorktreeNameStatus::Pending + ); + setup_ready(&f, &entry); + assert!( + suggest(&f.conn, &entry.id, "wrong-token", Some("wrong-name")) + .unwrap() + .is_none() + ); + let named = suggest(&f.conn, &entry.id, TOKEN, Some("replacement-name")) + .unwrap() + .unwrap(); + assert_eq!(named.branch, "monocode/original-name"); +} + +#[test] +fn naming_freezes_before_publish_or_manual_checkout_without_waiting_for_ai() { + for suggestion_ready in [false, true] { + let f = Fixture::new(); + let entry = draft(&f, "session-one"); + if suggestion_ready { + suggest(&f.conn, &entry.id, TOKEN, Some("pending-name")).unwrap(); + } + assert!(freeze_pending(&f.conn, &entry).unwrap().is_none()); + setup_ready(&f, &entry); + assert!(suggest(&f.conn, &entry.id, TOKEN, Some("late-name")) + .unwrap() + .is_none()); + assert!(apply_pending(&f.conn, &entry.id).unwrap().is_none()); + assert_eq!(current(&f, &entry.id).branch, entry.branch); + } +} + +#[test] +fn explicit_retry_reuses_the_suggestion_after_a_non_mutating_git_failure() { + let f = Fixture::new(); + let entry = draft(&f, "git-failure-retry-session"); + setup_ready(&f, &entry); + git(&f.repo, &["pack-refs", "--all"]).unwrap(); + let lock = f.repo.join(".git/packed-refs.lock"); + std::fs::write(&lock, "held by another Git operation").unwrap(); + let result = suggest(&f.conn, &entry.id, TOKEN, Some("retry-original-name")); + std::fs::remove_file(lock).unwrap(); + assert!(result.is_err()); + assert_eq!( + git(Path::new(&entry.path), &["branch", "--show-current"]).unwrap(), + entry.branch + ); + assert_eq!( + status(&f.conn, &entry.id, TOKEN).unwrap(), + WorktreeNameStatus::Pending + ); + let named = suggest(&f.conn, &entry.id, TOKEN, Some("replacement-name")) + .unwrap() + .unwrap(); + assert_eq!(named.branch, "monocode/retry-original-name"); + setup::validate_checkout(¤t(&f, &entry.id), &entry.path).unwrap(); +} + +#[test] +fn naming_serializes_two_windows_competing_for_the_same_branch() { + let f = Fixture::new(); + let one = draft(&f, "session-one"); + let two = draft(&f, "session-two"); + setup_ready(&f, &one); + setup_ready(&f, &two); + let barrier = std::sync::Barrier::new(2); + let host = &f.host; + let db = &f.db; + let names = std::thread::scope(|scope| { + let run = |entry: &Owned| { + let conn = Connection::open(db).unwrap(); + barrier.wait(); + let _repository = host.repository_guard(&entry.common).unwrap(); + let _windows = host.operation_guard().unwrap(); + suggest(&conn, &entry.id, TOKEN, Some("same-task")) + .unwrap() + .unwrap() + .branch + }; + let first = scope.spawn(move || run(&one)); + let second = scope.spawn(move || run(&two)); + vec![first.join().unwrap(), second.join().unwrap()] + }); + assert_ne!(names[0], names[1]); + assert!(names.contains(&"monocode/same-task".to_string())); + assert!(names.contains(&"monocode/same-task-1".to_string())); +} + +fn draft(f: &Fixture, id: &str) -> Owned { + create_with_naming( + &f.conn, + &f.host, + &path_to_js(&f.repo), + id, + "Raw verbose request", + Some("main"), + Some(TOKEN), + ) + .unwrap() +} + +fn setup_ready(f: &Fixture, entry: &Owned) { + if let environment::BeginSetup::Run(operation) = + environment::begin_setup(&f.conn, &entry.path).unwrap() + { + let result = environment::run_setup(&operation, |_| {}); + environment::finish_setup(&f.conn, &operation, &result).unwrap(); + result.unwrap(); + } +} + +fn current(f: &Fixture, id: &str) -> Owned { + owned(&f.conn) + .unwrap() + .into_iter() + .find(|entry| entry.id == id) + .unwrap() +} + +#[test] +fn naming_preserves_checkout_and_full_retirement_recovery() { + let f = Fixture::new(); + let original = draft(&f, "aabbccdd-1234-5678-long-session-identity"); + assert_eq!(original.branch, "monocode/task-aabbccdd"); + setup_ready(&f, &original); + let head = resolve_commit(Path::new(&original.path), "HEAD").unwrap(); + let named = suggest(&f.conn, &original.id, TOKEN, Some("AI worktree naming")) + .unwrap() + .unwrap(); + assert_eq!(named.branch, "monocode/ai-worktree-naming"); + let entry = current(&f, &original.id); + assert_eq!(entry.path, original.path); + assert_eq!(entry.base_ref, original.base_ref); + assert_eq!( + resolve_commit(Path::new(&entry.path), "HEAD").unwrap(), + head + ); + assert!(ref_oid(&f.repo, &format!("refs/heads/{}", original.branch)) + .unwrap() + .is_none()); + setup::validate_checkout(&entry, &entry.path).unwrap(); + open_owned(&f.conn, &entry).unwrap(); + let plan = build_retirement_plan( + &f.conn, + &HashMap::new(), + &[], + Some(&path_to_js(&f.repo)), + std::slice::from_ref(&entry.id), + ) + .unwrap(); + assert_eq!(plan.entries.len(), 1); + let report = execute_retirement( + &f.conn, + &HashMap::new(), + &plan.plan_id, + &[WorktreeRetirementSelection { + id: entry.id.clone(), + delete_local_branch: false, + delete_remote_branch: false, + }], + ) + .unwrap(); + assert!( + report.results[0].worktree_removed, + "{:?}", + report.results[0].error + ); + let removed = current(&f, &entry.id); + open_owned(&f.conn, &removed).unwrap(); + let restored = current(&f, &entry.id); + assert_eq!(restored.branch, named.branch); + assert_eq!( + resolve_commit(Path::new(&restored.path), "HEAD").unwrap(), + head + ); +} + +#[test] +fn naming_survives_failed_setup_and_consumes_the_first_suggestion_once() { + let f = Fixture::new(); + let entry = draft(&f, "session-one"); + f.conn + .execute( + "UPDATE worktree_environment_setup SET status = 'failed' WHERE worktree_id = ?1", + [&entry.id], + ) + .unwrap(); + assert!(suggest(&f.conn, &entry.id, TOKEN, Some("Fix startup")) + .unwrap() + .is_none()); + assert_eq!(current(&f, &entry.id).branch, entry.branch); + // Reopen the database to prove this is durable, not an in-memory callback. + let conn = Connection::open(&f.db).unwrap(); + assert_eq!(record(&conn, &entry.id).unwrap().unwrap().state, "ready"); + assert!(suggest(&conn, &entry.id, TOKEN, Some("Different followup")) + .unwrap() + .is_none()); + setup_ready(&f, &entry); + assert_eq!( + apply_pending(&conn, &entry.id).unwrap().unwrap().branch, + "monocode/fix-startup" + ); + assert!(apply_pending(&conn, &entry.id).unwrap().is_none()); +} + +#[test] +fn naming_allocates_collisions_without_claiming_existing_refs() { + let f = Fixture::new(); + let first = draft(&f, "session-one"); + let second = draft(&f, "session-two"); + assert_ne!(first.branch, second.branch); // identical first eight ID characters + setup_ready(&f, &first); + setup_ready(&f, &second); + git(&f.repo, &["branch", "monocode/add-search"]).unwrap(); + let one = suggest(&f.conn, &first.id, TOKEN, Some("add-search")) + .unwrap() + .unwrap(); + let two = suggest(&f.conn, &second.id, TOKEN, Some("add-search")) + .unwrap() + .unwrap(); + assert_eq!(one.branch, "monocode/add-search-1"); + assert_eq!(two.branch, "monocode/add-search-2"); + assert!(ref_oid(&f.repo, "refs/heads/monocode/add-search") + .unwrap() + .is_some()); +} + +#[test] +fn naming_rejects_unowned_reused_and_stale_requests() { + let f = Fixture::new(); + let manual = create( + &f.conn, + &f.host, + &path_to_js(&f.repo), + "manual-session", + "Chosen name", + Some("main"), + ) + .unwrap(); + assert!(suggest(&f.conn, &manual.id, TOKEN, Some("ignored")) + .unwrap() + .is_none()); + let entry = draft(&f, "session-one"); + setup_ready(&f, &entry); + assert!( + suggest(&f.conn, &entry.id, "another-token", Some("ignored")) + .unwrap() + .is_none() + ); + git(Path::new(&entry.path), &["branch", "-m", "user-chosen"]).unwrap(); + assert!(suggest(&f.conn, &entry.id, TOKEN, Some("ignored")) + .unwrap() + .is_none()); + assert_eq!( + git(Path::new(&entry.path), &["branch", "--show-current"]).unwrap(), + "user-chosen" + ); + assert_eq!( + record(&f.conn, &entry.id).unwrap().unwrap().state, + "skipped" + ); +} + +#[test] +fn naming_preserves_published_shared_archived_and_locked_worktrees() { + for reason in ["published", "shared", "archived", "locked", "retired"] { + let f = Fixture::new(); + let entry = draft(&f, "session-one"); + setup_ready(&f, &entry); + match reason { + "published" => { + git( + &f.repo, + &[ + "config", + &format!("branch.{}.remote", entry.branch), + "origin", + ], + ) + .unwrap(); + git( + &f.repo, + &[ + "config", + &format!("branch.{}.merge", entry.branch), + &format!("refs/heads/{}", entry.branch), + ], + ) + .unwrap(); + git( + &f.repo, + &[ + "remote", + "add", + "origin", + "https://example.invalid/repo.git", + ], + ) + .unwrap(); + } + "shared" => { + f.conn.execute("INSERT INTO sessions(id, cwd, worktree_cwd) VALUES ('another-session', ?1, ?2)", params![path_to_js(&f.repo), format!("{}/nested", entry.path)]).unwrap(); + } + "archived" => { + f.conn.execute("INSERT INTO sessions(id, cwd, worktree_cwd, archived) VALUES (?1, ?2, ?3, 1)", params![entry.id, path_to_js(&f.repo), entry.path]).unwrap(); + } + "locked" => { + git(&f.repo, &["worktree", "lock", &entry.path]).unwrap(); + } + "retired" => { + f.conn + .execute( + "UPDATE managed_worktrees SET removed = 1 WHERE id = ?1", + [&entry.id], + ) + .unwrap(); + } + _ => unreachable!(), + } + assert!( + suggest(&f.conn, &entry.id, TOKEN, Some("ignored")) + .unwrap() + .is_none(), + "{reason}" + ); + assert_eq!( + record(&f.conn, &entry.id).unwrap().unwrap().state, + "skipped", + "{reason}" + ); + assert_eq!( + git(Path::new(&entry.path), &["branch", "--show-current"]).unwrap(), + entry.branch + ); + } +} + +#[test] +fn naming_reconciles_git_success_after_database_failure_including_nested_sessions() { + let f = Fixture::new(); + let entry = draft(&f, "session-one"); + setup_ready(&f, &entry); + f.conn + .execute( + "INSERT INTO sessions(id, cwd, worktree_cwd, branch) VALUES (?1, ?2, ?3, ?4)", + params![entry.id, path_to_js(&f.repo), entry.path, entry.branch], + ) + .unwrap(); + // Fail the metadata transaction after the Git operation, as a disk error would. + f.conn.execute_batch("CREATE TRIGGER fail_name BEFORE UPDATE OF branch ON managed_worktrees BEGIN SELECT RAISE(FAIL, 'simulated storage failure'); END;").unwrap(); + assert!(suggest(&f.conn, &entry.id, TOKEN, Some("recover-name")).is_err()); + assert_eq!( + record(&f.conn, &entry.id).unwrap().unwrap().state, + "renaming" + ); + assert_eq!( + git(Path::new(&entry.path), &["branch", "--show-current"]).unwrap(), + "monocode/recover-name" + ); + f.conn.execute_batch("DROP TRIGGER fail_name;").unwrap(); + f.conn.execute("INSERT INTO sessions(id, cwd, worktree_cwd, branch) VALUES ('nested-session', ?1, ?2, ?3)", params![path_to_js(&f.repo), format!("{}/nested", entry.path), entry.branch]).unwrap(); + git( + Path::new(&entry.path), + &["commit", "--allow-empty", "-m", "Agent continued"], + ) + .unwrap(); + let reopened = Connection::open(&f.db).unwrap(); + let named = apply_pending(&reopened, &entry.id).unwrap().unwrap(); + assert!(named.session_ids.contains(&"nested-session".to_string())); + let branches: i64 = reopened + .query_row( + "SELECT COUNT(*) FROM sessions WHERE branch = 'monocode/recover-name'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(branches, 2); + assert!(apply_pending(&reopened, &entry.id).unwrap().is_none()); + setup::validate_checkout(¤t(&f, &entry.id), &entry.path).unwrap(); +} + +#[test] +fn naming_does_not_replay_an_interrupted_intent_or_adopt_an_unrelated_checkout() { + for renamed_elsewhere in [false, true] { + let f = Fixture::new(); + let entry = draft(&f, "session-one"); + setup_ready(&f, &entry); + let oid = resolve_commit(Path::new(&entry.path), "HEAD").unwrap(); + f.conn.execute("UPDATE worktree_naming SET state = 'renaming', target_branch = 'monocode/proposed', rename_oid = ?1 WHERE worktree_id = ?2", params![oid, entry.id]).unwrap(); + if renamed_elsewhere { + git(Path::new(&entry.path), &["branch", "-m", "user-branch"]).unwrap(); + assert!(apply_pending(&f.conn, &entry.id).is_err()); + assert_eq!(current(&f, &entry.id).branch, entry.branch); + } else { + assert!(apply_pending(&f.conn, &entry.id).unwrap().is_none()); + assert_eq!( + record(&f.conn, &entry.id).unwrap().unwrap().state, + "skipped" + ); + } + assert!(ref_oid(&f.repo, "refs/heads/monocode/proposed") + .unwrap() + .is_none()); + } +} + +#[test] +fn naming_failure_keeps_fallback_and_normalization_is_git_safe() { + let f = Fixture::new(); + for (i, suggestion) in [None, Some("... / "), Some("")].into_iter().enumerate() { + let entry = draft(&f, &format!("session-{i}")); + setup_ready(&f, &entry); + assert!(suggest(&f.conn, &entry.id, TOKEN, suggestion) + .unwrap() + .is_none()); + assert_eq!(current(&f, &entry.id).branch, entry.branch); + assert!(suggest(&f.conn, &entry.id, TOKEN, Some("late-result")) + .unwrap() + .is_none()); + } + assert_eq!( + normalize("refs/heads/monocode/Fix `Search`... @ UI"), + Some("monocode/fix-search-ui".into()) + ); + let long = normalize(&"very-long-name-".repeat(20)).unwrap(); + assert!(long.len() <= "monocode/".len() + 64); + git(&f.repo, &["check-ref-format", "--branch", &long]).unwrap(); +} diff --git a/src-tauri/src/worktree_setup.rs b/src-tauri/src/worktree_setup.rs index 66614267..63b486dc 100644 --- a/src-tauri/src/worktree_setup.rs +++ b/src-tauri/src/worktree_setup.rs @@ -112,6 +112,9 @@ pub fn worktree_setup( let (operation, _lease) = { let _repository = host.repository_guard(&common)?; let mut windows = host.operation_guard()?; + for changed in super::naming::reconcile_repository(&conn, &common)? { + super::naming::emit(&app, Ok(Some(changed))); + } let Some(entry) = owned(&conn)?.into_iter().find(|entry| { entry.id == candidate.id && path_inside(Path::new(&path), Path::new(&entry.path)) }) else { @@ -125,7 +128,10 @@ pub fn worktree_setup( ); } let operation = match environment::begin_setup(&conn, &path)? { - environment::BeginSetup::Skip => return Ok(()), + environment::BeginSetup::Skip => { + super::naming::emit(&app, super::naming::apply_pending(&conn, &entry.id)); + return Ok(()); + } environment::BeginSetup::Run(operation) => operation, }; active.insert(operation.root_path().to_string()); @@ -154,9 +160,12 @@ pub fn worktree_setup( let _windows = host.operation_guard().map_err(|error| { format!("Setup state could not be saved. Restart Monocode before retrying: {error}") })?; - environment::finish_setup(&conn, &operation, &result).map_err(|error| { - format!("Setup state could not be saved. Restart Monocode before retrying: {error}") - })? + let completion = + environment::finish_setup(&conn, &operation, &result).map_err(|error| { + format!("Setup state could not be saved. Restart Monocode before retrying: {error}") + })?; + super::naming::emit(&app, super::naming::apply_pending(&conn, &candidate.id)); + completion }; let result = match (result, completion) { (Ok(()), environment::FinishSetup::Retry(error)) => Err(error), diff --git a/src-tauri/src/worktrees.rs b/src-tauri/src/worktrees.rs index f77c630a..8c010e91 100644 --- a/src-tauri/src/worktrees.rs +++ b/src-tauri/src/worktrees.rs @@ -23,6 +23,8 @@ mod environment; #[cfg(all(test, unix))] #[path = "worktree_environment_integration_tests.rs"] mod environment_integration_tests; +#[path = "worktree_naming.rs"] +pub(crate) mod naming; #[path = "worktree_setup.rs"] pub(crate) mod setup; #[path = "worktree_storage.rs"] @@ -457,6 +459,7 @@ pub(crate) fn schema(conn: &Connection) -> rusqlite::Result<()> { WHERE removed = 1 AND active_retirement_plan_id IS NULL", [], )?; + naming::schema(conn)?; Ok(()) } @@ -493,6 +496,7 @@ pub fn init(app: &AppHandle) -> Result<(), String> { repositories: RepositoryReservations::default(), }); environment::reset_interrupted(&app.state::().open_auxiliary_conn()?)?; + naming::recover(app, &app.state::().open_auxiliary_conn()?)?; storage_maintenance::schedule(app); // Cleanup is only invoked with the IDs the user reviewed and confirmed. Ok(()) @@ -672,6 +676,12 @@ fn repository_common(cwd: &str) -> Result { fn path_inside(path: &Path, parent: &Path) -> bool { let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); let parent = std::fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf()); + // Windows canonicalization adds a verbatim prefix only to existing paths. + // Missing descendants must still match their ordinary stored checkout path. + #[cfg(windows)] + let path = PathBuf::from(path_to_js(&path)); + #[cfg(windows)] + let parent = PathBuf::from(path_to_js(&parent)); path.starts_with(parent) } @@ -3429,8 +3439,23 @@ fn create( id: &str, name: &str, base_ref: Option<&str>, +) -> Result { + create_with_naming(conn, host, cwd, id, name, base_ref, None) +} + +fn create_with_naming( + conn: &Connection, + host: &WorktreeHost, + cwd: &str, + id: &str, + name: &str, + base_ref: Option<&str>, + auto_name_token: Option<&str>, ) -> Result { validate_id(id)?; + if let Some(token) = auto_name_token { + validate_id(token)?; + } let (repo, common) = repository(cwd)?; if owned(conn)?.iter().any(|v| v.id == id) { return Err("This session already owns a worktree".into()); @@ -3449,7 +3474,11 @@ fn create( } else { base_ref }; - let branch = format!("monocode/{}-{}", slug(name), id); + let branch = if auto_name_token.is_some() { + naming::available_branch(Path::new(&repo), &format!("monocode/task-{}", &id[..8]))? + } else { + format!("monocode/{}-{}", slug(name), id) + }; git(Path::new(&repo), &["check-ref-format", "--branch", &branch])?; let hash = common.bytes().fold(0xcbf29ce484222325u64, |hash, byte| { (hash ^ byte as u64).wrapping_mul(0x100000001b3) @@ -3497,6 +3526,9 @@ fn create( Some(&project_scope), None, )?; + if let Some(token) = auto_name_token { + naming::register(&tx, &entry, token)?; + } tx.commit().map_err(|e| e.to_string())?; if let Err(error) = git( Path::new(&repo), @@ -3901,6 +3933,8 @@ pub struct PrepareWorktree { use_worktree: Option, #[serde(default)] base_ref: Option, + #[serde(default)] + auto_name_token: Option, } #[tauri::command(async)] @@ -3913,7 +3947,11 @@ pub fn worktree_prepare( let common = repository_common(&request.cwd)?; let _repository = host.repository_guard(&common)?; let mut windows = host.operation_guard()?; - let work_path = prepare(&store.open_auxiliary_conn()?, &host, request)?; + let conn = store.open_auxiliary_conn()?; + for changed in naming::reconcile_repository(&conn, &common)? { + naming::emit(window.app_handle(), Ok(Some(changed))); + } + let work_path = prepare(&conn, &host, request)?; if let Some(path) = &work_path { let leases = windows.entry(window.label().into()).or_default(); let path = PathBuf::from(path); @@ -3936,6 +3974,7 @@ fn prepare( create_new, use_worktree, base_ref, + auto_name_token, } = request; let records = owned(conn)?; let entry = records.iter().find(|v| { @@ -3986,7 +4025,16 @@ fn prepare( } Some(scoped_path( &cwd, - &create(conn, host, &cwd, &session_id, &name, base_ref.as_deref())?.path, + &create_with_naming( + conn, + host, + &cwd, + &session_id, + &name, + base_ref.as_deref(), + auto_name_token.as_deref(), + )? + .path, )?) } else { None @@ -4135,16 +4183,16 @@ mod tests { use std::sync::atomic::{AtomicU64, Ordering}; static SEQUENCE: AtomicU64 = AtomicU64::new(0); - struct Fixture { - dir: PathBuf, - repo: PathBuf, - db: PathBuf, - conn: Connection, - host: WorktreeHost, + pub(super) struct Fixture { + pub(super) dir: PathBuf, + pub(super) repo: PathBuf, + pub(super) db: PathBuf, + pub(super) conn: Connection, + pub(super) host: WorktreeHost, } impl Fixture { - fn new() -> Self { + pub(super) fn new() -> Self { let dir = std::env::temp_dir().join(format!( "monocode-worktree-test-{}-{}-{}", std::process::id(), @@ -4157,6 +4205,7 @@ mod tests { git(&repo, &["config", "user.name", "Worktree Test"]).unwrap(); git(&repo, &["config", "user.email", "worktree@example.invalid"]).unwrap(); git(&repo, &["config", "commit.gpgsign", "false"]).unwrap(); + git(&repo, &["config", "core.autocrlf", "false"]).unwrap(); git( &repo, &[ @@ -4470,7 +4519,12 @@ mod tests { fn external_primary_locked_and_detached_checkouts_are_preserved() { let fixture = Fixture::new(); let entry = fixture.create("session-one"); - let external = path_to_js(&fixture.dir.join("external\nwith newline")); + let external_name = if cfg!(windows) { + "external with spaces" + } else { + "external\nwith newline" + }; + let external = path_to_js(&fixture.dir.join(external_name)); git( &fixture.repo, &["worktree", "add", "-b", "external", &external, "main"], @@ -4627,6 +4681,7 @@ mod tests { fn preparation_is_idempotent_and_respects_repository_defaults() { let fixture = Fixture::new(); let request = || PrepareWorktree { + auto_name_token: None, cwd: path_to_js(&fixture.repo), session_id: "session-one".into(), path: None, @@ -4711,6 +4766,7 @@ mod tests { ) .unwrap(); let request = || PrepareWorktree { + auto_name_token: None, cwd: cwd.clone(), session_id: "draft-one".into(), path: None, @@ -4756,6 +4812,7 @@ mod tests { git(&fixture.repo, &["add", "."]).unwrap(); git(&fixture.repo, &["commit", "-m", "Subproject"]).unwrap(); let request = || PrepareWorktree { + auto_name_token: None, cwd: path_to_js(&nested), session_id: "session-one".into(), path: None, diff --git a/src/App.tsx b/src/App.tsx index 6ac7a9fb..ccb1fcb8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,8 +5,14 @@ import { heartbeatWorktrees, prepareSessionWorktree, protectedWorktreePaths, + shouldIsolateSession, type WorktreeRetirementPlan, } from "./lib/worktrees"; +import { + initialMessageContext, + initialSessionMetadata, +} from "./lib/initialSessionMetadata"; +import { getHarness } from "./lib/harness/registry"; import { archiveSessionsWithRetirement, resumeArchivedWorktreeSession, @@ -153,7 +159,6 @@ import { canSteerHarness, compactHarnessContext, forgetHarnessSession, - generateHarnessTitle, isLiveHarness, probeHarnessAvailability, refreshHarnessCatalogs, @@ -803,6 +808,9 @@ export default function App({ canForward: false, }); const turnGen = useRef(new Map()); + // Naming belongs to the checkout's first request, not its currently running + // turn. A normal follow-up must not invalidate a still-pending suggestion. + const cancelledWorktreeNames = useRef(new Set()); const lastPersisted = useRef(new Map()); const lastBoundProvider = useRef(new Map()); const lastPersistedUserBlock = useRef(new Map()); @@ -857,6 +865,7 @@ export default function App({ const open = sessionsRef.current.find( (session) => session.id === sessionId, ); + if (open) cancelledWorktreeNames.current.add(sessionId); if (!open?.busy) return open; turnGen.current.set(sessionId, (turnGen.current.get(sessionId) ?? 0) + 1); @@ -2846,12 +2855,6 @@ export default function App({ void refreshHistory(sidebarCwd); return null; } - const restoringToast = loaded.worktreeCwd - ? toast.loading("Opening worktree…", { - description: - "Preparing this conversation’s working folder.", - }) - : null; try { loaded = await resumeArchivedWorktreeSession(loaded); } catch (error) { @@ -2863,8 +2866,6 @@ export default function App({ }, ); return null; - } finally { - if (restoringToast != null) toast.dismiss(restoringToast); } setHistory((current) => current.map((entry) => @@ -2932,6 +2933,7 @@ export default function App({ void githubWorkItemThread( session.cwd, + session.linkedWorkItem.repo, session.linkedWorkItem.kind, session.linkedWorkItem.number, { force: true }, @@ -3808,6 +3810,7 @@ export default function App({ const projectSessionIds = new Set( projectSessions.map((session) => session.id), ); + for (const id of projectSessionIds) cancelledWorktreeNames.current.add(id); if (options.purgeData) { for (const session of projectSessions) { @@ -4385,14 +4388,45 @@ export default function App({ }), ); - if (isFirstTurn && live && placeholderTitle) { - const titleMessage = - harnessText || attachments.map((file) => file.name).join(", "); - void generateHarnessTitle(current.harness, { + const nameNewWorktree = + isFirstTurn && + live && + shouldIsolateSession(current) && + !current.workspaceChoice?.path; + const titleMessage = initialMessageContext({ + message: [harnessText, current.inboxCard?.prompt] + .filter(Boolean) + .join("\n\n"), + plan: intent === "build" ? approvedPlan?.text : undefined, + handoff: handoffCard?.brief ?? queuedHandoff?.text, + attachmentNames: attachments.map((file) => file.name), + }); + const requestMetadata = () => + initialSessionMetadata(current.harness, { sessionId, cwd: workCwd, message: titleMessage, - }) + includeBranch: nameNewWorktree, + }); + const metadata = + isFirstTurn && live && (placeholderTitle || nameNewWorktree) + ? requestMetadata() + : Promise.resolve(null); + const naming = nameNewWorktree + ? { + token: crypto.randomUUID(), + result: metadata.then((generated) => generated?.branch || null), + retry: getHarness(current.harness)?.generateTitle + ? async () => (await requestMetadata())?.branch || null + : undefined, + isCurrent: () => + !cancelledWorktreeNames.current.has(sessionId) && + sessionsRef.current.some((session) => session.id === sessionId) && + !removingSessionIds.current.has(sessionId), + } + : undefined; + if (isFirstTurn && live && placeholderTitle) { + void metadata .then(async (generated) => { const linkedWorkItem = await resolveLinkedWorkItem( titleMessage, @@ -4405,7 +4439,7 @@ export default function App({ if (s.id !== sessionId) return s; let next = s; if ( - generated && + generated?.title && canReplaceSessionTitle(s.title, s.harness, titleSeed) ) { next = { @@ -4540,6 +4574,7 @@ export default function App({ worktreeCwd = await prepareSessionWorktree( current, submittedText, + naming, ); } finally { showWorktreePreparation(false); @@ -5142,6 +5177,7 @@ export default function App({ const onStop = useCallback( (sessionId: string) => { + cancelledWorktreeNames.current.add(sessionId); const session = sessionsRef.current.find((s) => s.id === sessionId); turnGen.current.set(sessionId, (turnGen.current.get(sessionId) ?? 0) + 1); flushHarnessEvents(); @@ -5809,6 +5845,29 @@ export default function App({ useEffect(() => { const unlisten: Array void>> = [ + listen<{ + id: string; + sessionIds: string[]; + path: string; + branch: string; + }>("worktree-named", ({ payload }) => { + const update = (session: Session) => + session.id === payload.id || + payload.sessionIds.includes(session.id) || + isEqualOrInside(sessionWorkCwd(session), payload.path) + ? { ...session, branch: payload.branch } + : session; + sessionsRef.current = sessionsRef.current.map(update); + setSessions((previous) => previous.map(update)); + setHistory((previous) => + previous.map((session) => + session.id === payload.id || payload.sessionIds.includes(session.id) + ? { ...session, branch: payload.branch } + : session, + ), + ); + notifyGitChanged(); + }), listen("new_tab", () => run("new", actions.current.onNew)), listen("close_other_tabs", () => run("close-others", actions.current.onCloseOtherTabs), diff --git a/src/chrome/BranchPicker.tsx b/src/chrome/BranchPicker.tsx index 6a31434a..7071b601 100644 --- a/src/chrome/BranchPicker.tsx +++ b/src/chrome/BranchPicker.tsx @@ -262,7 +262,7 @@ export function BranchPicker({ const interactive = enabled && !awaitingBranch && !missingGit; return ( -
+
)}
{noSourcesConnected ? ( @@ -849,7 +886,7 @@ export function InboxView({

) : (
    - {visibleItems.map((item) => { + {shownItems.map((item) => { const key = inboxItemKey(item); const projectId = projectKey(item.projectPath); const relatedSessions = relatedSessionsForInboxItem( @@ -881,6 +918,9 @@ export function InboxView({ ); })} + {hasMoreItems ? ( +
  • + ) : null}
)}
@@ -1216,19 +1256,19 @@ export function InboxDetail({ : gitlabKind ? peekGitlabWorkItemDetails(item.repo, gitlabKind, item.number) : githubKind - ? peekGithubWorkItemDetails(item.projectPath, githubKind, item.number) + ? peekGithubWorkItemDetails(item.repo, githubKind, item.number) : null; const cachedDiff = isPr ? gitlab ? peekGitlabMrDiff(item.repo, item.number) - : peekGithubPrDiff(item.projectPath, item.number) + : peekGithubPrDiff(item.repo, item.number) : null; const cachedThread = linear ? peekLinearIssueThread(item.id ?? "") : gitlabKind ? peekGitlabWorkItemThread(item.repo, gitlabKind, item.number) : githubKind - ? peekGithubWorkItemThread(item.projectPath, githubKind, item.number) + ? peekGithubWorkItemThread(item.repo, githubKind, item.number) : null; const [details, setDetails] = useState(cached); const [loading, setLoading] = useState(cached == null); @@ -1296,7 +1336,7 @@ export function InboxDetail({ : gitlabKind ? peekGitlabWorkItemDetails(item.repo, gitlabKind, item.number) : githubKind - ? peekGithubWorkItemDetails(item.projectPath, githubKind, item.number) + ? peekGithubWorkItemDetails(item.repo, githubKind, item.number) : null; if (cachedDetails) { setDetails(cachedDetails); @@ -1314,7 +1354,12 @@ export function InboxDetail({ : gitlabKind ? gitlabWorkItemDetails(item.repo, gitlabKind, item.number) : githubKind - ? githubWorkItemDetails(item.projectPath, githubKind, item.number) + ? githubWorkItemDetails( + item.projectPath, + item.repo, + githubKind, + item.number, + ) : Promise.reject(new Error("Unknown inbox item")); void pending .then((next) => { @@ -1411,7 +1456,7 @@ export function InboxDetail({ } if (!githubKind) return; const cachedThread = peekGithubWorkItemThread( - item.projectPath, + item.repo, githubKind, item.number, ); @@ -1424,7 +1469,12 @@ export function InboxDetail({ setThreadError(null); setThread(null); } - void githubWorkItemThread(item.projectPath, githubKind, item.number) + void githubWorkItemThread( + item.projectPath, + item.repo, + githubKind, + item.number, + ) .then((next) => { if (cancelled) return; setThread(next); @@ -1457,7 +1507,7 @@ export function InboxDetail({ let cancelled = false; const cachedDiff = gitlab ? peekGitlabMrDiff(item.repo, item.number) - : peekGithubPrDiff(item.projectPath, item.number); + : peekGithubPrDiff(item.repo, item.number); if (cachedDiff) { setPrDiff(cachedDiff); setDiffLoading(false); @@ -1469,7 +1519,7 @@ export function InboxDetail({ } const pending = gitlab ? gitlabMrDiff(item.repo, item.number) - : githubPrDiff(item.projectPath, item.number); + : githubPrDiff(item.projectPath, item.repo, item.number); void pending .then((next) => { if (cancelled) return; @@ -1521,6 +1571,7 @@ export function InboxDetail({ if (!githubKind) throw new Error("Unknown inbox item"); await githubWorkItemComment( item.projectPath, + item.repo, githubKind, item.number, body, @@ -1531,6 +1582,7 @@ export function InboxDetail({ setThread( await githubWorkItemThread( item.projectPath, + item.repo, githubKind, item.number, { @@ -1808,7 +1860,7 @@ export function InboxDetail({

{diffError}

) : prDiff ? ( ) : (