mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 01:51:21 +08:00
Merge branch 'main' into persist-model-variant-selection
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Let Agent Manager users choose the repository when creating or importing a worktree in multi-project mode.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Support the Agent Manager tool with llama.cpp servers that reject prefix-only JSON Schema patterns.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Avoid printing an error when closing the TUI cancels in-flight startup refreshes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Prevent concurrent Kilo startups from rewriting unchanged credentials, retry transient database locks, and redact bound values from database errors.
|
||||
@@ -0,0 +1,834 @@
|
||||
# Agent Manager — New Worktree Project Selector
|
||||
|
||||
Status: implemented 2026-08-06. The implementation is uncommitted.
|
||||
|
||||
This is Slice 4 item 6 ("Add project-aware New Worktree targeting") from
|
||||
`agent-manager-multi-project-uniform-ui.md`, the last unimplemented item of that slice.
|
||||
`agent-manager-multi-project-runtime.md:29` deferred it out of the backend-first scope.
|
||||
Everything the extension side needs already exists; this is almost entirely a webview
|
||||
change.
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- The project catalog is passed into the dialog as an accessor so the picker reflects
|
||||
registry changes while it remains open.
|
||||
- The per-project default-base resolver returns `undefined` when the project has no
|
||||
configured/local branch, allowing the backend-detected branch response to remain the
|
||||
fallback instead of being replaced by a hardcoded `main`.
|
||||
- Branch, import-result, and worktree-ready messages carry `projectId` in multi-project
|
||||
mode, which makes fast project changes and cross-project creation activation safe.
|
||||
- The slash-command hook accepts optional caller-owned commands; `/project` is scoped to
|
||||
this dialog and is hidden when multi-project mode is unavailable.
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
With `kilo-code.new.experimental.multiProject` enabled, the New Worktree dialog has no
|
||||
notion of which repository it targets, and the user cannot see or change it.
|
||||
|
||||
`Cmd+N` opens the dialog with no project at all:
|
||||
|
||||
```tsx
|
||||
// AgentManagerApp.tsx:1870-1876
|
||||
const showNewWorktreeDialog = () => {
|
||||
if (!loaded()) return
|
||||
expandSidebar()
|
||||
dialog.show(() => (
|
||||
<NewWorktreeDialog mode={mode} onClose={() => dialog.close()} defaultBaseBranch={repoDefaultBranch()} />
|
||||
))
|
||||
}
|
||||
```
|
||||
|
||||
`projectId` is `undefined`, so every message the dialog sends omits it
|
||||
(`agentManager.requestBranches` at `NewWorktreeDialog.tsx:321`,
|
||||
`agentManager.createMultiVersion` at `:373`, `agentManager.importFromPR` at `:566`,
|
||||
`agentManager.importFromBranch` at `:575`). The extension then silently falls back to the
|
||||
active project in `messageProject()` (`AgentManagerProvider.ts:474`) before running the
|
||||
message inside `ProjectScope`.
|
||||
|
||||
The result is correct but opaque:
|
||||
|
||||
- The dialog never shows which repository the worktree lands in.
|
||||
- The only way to target a specific project is the per-project `+` button
|
||||
(`ProjectList.tsx:136-146`), which does pass `projectId` explicitly.
|
||||
- `defaultBaseBranch` is resolved from the *active* project
|
||||
(`AgentManagerApp.tsx:261,272`), so even Advanced options' base-branch list and default
|
||||
badge are implicitly single-project.
|
||||
|
||||
The desired behavior, per the original request: the dialog should show the assigned
|
||||
project and let the user change it. Defaulting to the last selected project is fine as a
|
||||
default; it just must be visible and overridable.
|
||||
|
||||
---
|
||||
|
||||
## Design decisions
|
||||
|
||||
### Placement: inline with the tab switcher
|
||||
|
||||
The selector renders inside the New/Import pill row (`NewWorktreeDialog.tsx:620-699`),
|
||||
right-aligned with a constrained width, so it is shared by both tabs without adding a
|
||||
full-width form row.
|
||||
|
||||
```
|
||||
┌─ New Worktree ──────────────────────────────┐
|
||||
│ [ New ] [ Import ] [ folder kilocode ▾ ] │ ← inline, multiProject only
|
||||
│ [ Worktree name (optional) ] │
|
||||
│ [ prompt … ] Code ▾ GPT-5.6 ▾ None ▾ │
|
||||
│ › Advanced options │
|
||||
│ VERSIONS 1 2 3 4 ⧉ Compare Models │
|
||||
│ [ Create Worktree ] │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Rejected alternatives and why:
|
||||
|
||||
- **Inside Advanced options.** Wrong category. Advanced options holds refinements of a
|
||||
known target (branch name, base branch). The project *is* the target: changing it
|
||||
invalidates the branch list, base branch, default-branch badge, and setup scripts.
|
||||
Hiding it also fails the stated requirement of seeing which project is assigned.
|
||||
- **A full-width row directly beneath the tabs.** It covered both tabs, but consumed
|
||||
unnecessary vertical space and made the project control look like a primary form field.
|
||||
- **In the dialog title** (`New Worktree in [kilocode ▾]`). Reads nicely but requires
|
||||
widening `Dialog`'s `title` prop to accept JSX, which touches kilo-ui for one caller.
|
||||
|
||||
### Visibility
|
||||
|
||||
Render the row only when `multiProject` is true. With the flag off (the default) the
|
||||
dialog stays byte-identical to today, so there is no regression surface for the
|
||||
all-user path. When the flag is on but only the pinned project exists, still render it:
|
||||
showing the target is informative and the requirement is explicitly about seeing the
|
||||
assignment.
|
||||
|
||||
### Default value
|
||||
|
||||
`props.projectId ?? activeProjectId()`. `activeProjectId()` already exists at
|
||||
`AgentManagerApp.tsx:268` (`projectList().find((p) => p.active)?.id ?? currentProjectId()`).
|
||||
|
||||
No new persistence. The active project is already the durable "last selected" state
|
||||
(persisted per project as `activeTarget` in each repo's `.kilo/agent-manager.json`, plus
|
||||
the registry's ordering). Adding a separate "last dialog project" key would create a
|
||||
second source of truth that can disagree with the sidebar.
|
||||
|
||||
### Reuse, no new CSS
|
||||
|
||||
The row uses the existing `am-advanced-field` + `am-nv-config-label` +
|
||||
`am-selector-wrapper` + `am-selector-trigger` markup, i.e. exactly the structure the base
|
||||
branch selector already uses at `NewWorktreeDialog.tsx:813-905`, with `DeferredPopover`
|
||||
(already imported) instead of `BranchSelectPopover`.
|
||||
|
||||
### Component extraction
|
||||
|
||||
`NewWorktreeDialog.tsx` is already 1136 lines. The selector and its popover list go into a
|
||||
new `webview-ui/agent-manager/ProjectSelect.tsx` (roughly `BranchSelect.tsx`'s role):
|
||||
presentational, takes `projects`, `value`, `onSelect`, and labels, and owns nothing but
|
||||
its own list rendering. The dialog keeps only the signal, the popover trigger, and the
|
||||
effects.
|
||||
|
||||
Note: `webview-ui/agent-manager/NewWorktreeDialog.tsx` is not under a `maxLines` cap
|
||||
(`tests/unit/agent-manager-arch.test.ts` caps `src/agent-manager/*.ts` only), but the file
|
||||
is on the arch test's watched list and the caps exist to discourage exactly this kind of
|
||||
growth.
|
||||
|
||||
---
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. `webview-ui/agent-manager/ProjectSelect.tsx` (new)
|
||||
|
||||
Presentational popover body listing projects:
|
||||
|
||||
- Row = project label + dimmed root path (tooltip on the full root, matching
|
||||
`ProjectsSection.tsx:60`).
|
||||
- Check mark on the selected project.
|
||||
- Untrusted and missing projects are disabled and carry the same affordances the accordion
|
||||
uses: `lock` icon + trust hint, `warning` icon + missing hint
|
||||
(`ProjectsSection.tsx:67-75`). Selecting them is not possible; trust happens in the
|
||||
sidebar, not in this dialog. Keeps the dialog free of trust-flow branching.
|
||||
- There is intentionally no Add project action in this picker. Project registration and
|
||||
trust management stay in the Agent Manager Projects toolbar.
|
||||
|
||||
### 2. `webview-ui/agent-manager/NewWorktreeDialog.tsx`
|
||||
|
||||
Props change:
|
||||
|
||||
```ts
|
||||
export const NewWorktreeDialog: Component<{
|
||||
onClose: () => void
|
||||
projectId?: string // now: initial value, not fixed target
|
||||
projects?: AgentProjectSnapshot[] // omitted / empty => single-project, row hidden
|
||||
activeProjectId?: string
|
||||
defaultBase?: (projectId: string) => string // replaces defaultBaseBranch?: string
|
||||
mode: ModeRouter
|
||||
}>
|
||||
```
|
||||
|
||||
`defaultBaseBranch?: string` must become a per-project lookup because each project has its
|
||||
own configured default and its own local branch. `ProjectList.tsx:142` already computes
|
||||
that expression (`state?.defaultBaseBranch ?? props.local[projectId]?.branch`); hoist it
|
||||
into the callback so both call sites share it.
|
||||
|
||||
New state and derived values:
|
||||
|
||||
```ts
|
||||
const [project, setProject] = createSignal(props.projectId ?? props.activeProjectId)
|
||||
const [projectOpen, setProjectOpen] = createSignal(false)
|
||||
const selectable = () => (props.projects ?? []).filter((p) => p.trusted && !p.missing)
|
||||
const showProject = () => (props.projects?.length ?? 0) > 0
|
||||
```
|
||||
|
||||
Every outbound message switches from `props.projectId` to `project()`:
|
||||
`:321` `requestBranches`, `:373` `createMultiVersion`, `:566` `importFromPR`,
|
||||
`:575` `importFromBranch`.
|
||||
|
||||
Reload branch data on project change, replacing the one-shot `onMount` request at `:319-321`:
|
||||
|
||||
```ts
|
||||
createEffect(
|
||||
on(project, (id) => {
|
||||
setBranches([])
|
||||
setBranchSearch("")
|
||||
setHighlightedIndex(0)
|
||||
setBaseBranch(null) // custom base is project-specific
|
||||
setDefaultBranch(props.defaultBase?.(id) ?? "main")
|
||||
setBranchesLoading(true)
|
||||
vscode.postMessage({ type: "agentManager.requestBranches", projectId: id })
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Drop stale branch replies in the `agentManager.branches` handler (`:520-525`).
|
||||
`AgentManagerBranchesMessage` **already declares an optional `projectId`**
|
||||
(`extension-messages.ts:939-945`, `src/agent-manager/types.ts:293-298`); the field is
|
||||
simply never populated or read today. Without this guard, switching projects twice quickly
|
||||
can race a wrong branch list into the base-branch popover:
|
||||
|
||||
```ts
|
||||
if (ev.projectId && ev.projectId !== project()) return
|
||||
```
|
||||
|
||||
Also replace the `if (!props.defaultBaseBranch) setDefaultBranch(ev.defaultBranch)` guard
|
||||
at `:523` — with a per-project lookup, the guard must consult
|
||||
`props.defaultBase?.(project())` instead of a fixed prop.
|
||||
|
||||
Preserved across a project change (all project-agnostic): prompt text and its
|
||||
`advancedDialogPrompt` persistence, images, name, agent, model, variant, versions, compare
|
||||
allocations, sandbox override.
|
||||
|
||||
Keyboard: add `project` to `WORKTREE_PROMPT_COMMANDS` so `/project` opens the popover,
|
||||
consistent with mode/model/variant/sandbox already being reachable from the dialog's slash
|
||||
menu (`:302-314`). Hide it from the list when `showProject()` is false, using the same
|
||||
`hidden` set mechanism already used for `agents` / `variant` / `sandbox`.
|
||||
|
||||
### 3. `webview-ui/agent-manager/AgentManagerApp.tsx`
|
||||
|
||||
`showNewWorktreeDialog` (`:1870-1876`) passes the catalog and a per-project default
|
||||
resolver instead of a single branch string:
|
||||
|
||||
```tsx
|
||||
<NewWorktreeDialog
|
||||
mode={mode}
|
||||
onClose={() => dialog.close()}
|
||||
projects={multiProject() ? projectList() : undefined}
|
||||
activeProjectId={activeProjectId()}
|
||||
defaultBase={defaultBase}
|
||||
/>
|
||||
```
|
||||
|
||||
where `defaultBase(id)` reads `registry.ensure(id).defaultBaseBranch() ?? registry.ensure(id).localStats()?.branch ?? repoDetectedBranch() ?? "main"`.
|
||||
`registry.ensure` and both store fields already exist
|
||||
(`project/registry.ts:39`, `project/store.ts:62,67,113,123`).
|
||||
|
||||
### 4. `webview-ui/agent-manager/ProjectList.tsx`
|
||||
|
||||
`newWorktree(projectId)` (`:136-146`) passes the same `projects` / `activeProjectId` /
|
||||
`defaultBase` props with `projectId` as the initial value, so the per-project `+` button
|
||||
opens the dialog pre-scoped but still switchable. Its current inline
|
||||
`state?.defaultBaseBranch ?? props.local[projectId]?.branch` expression is replaced by the
|
||||
shared resolver passed down from `AgentManagerApp`.
|
||||
|
||||
### 5. `src/agent-manager/worktree-importer.ts`
|
||||
|
||||
Stamp `projectId` on all three `agentManager.branches` posts (`:27`, `:48`, `:54`). The
|
||||
field is already in the type; the value is available from the ambient `ProjectScope`
|
||||
context the message runs in (`AgentManagerProvider.ts:479`). Without this the stale-reply
|
||||
guard in the webview is inert.
|
||||
|
||||
### 6. Activate the created worktree when the project differs
|
||||
|
||||
Creating in a non-active project currently leaves the sidebar where it is:
|
||||
`createMultiVersion` never activates (no activation call in `provider-multi-version.ts`),
|
||||
and the new worktree just appears in that project's accordion. That is right for the
|
||||
per-project `+` button, but for `Cmd+N` where the user deliberately switched projects,
|
||||
landing in the new worktree is what the flow implies.
|
||||
|
||||
Post an `agentManager.activateSelection` for the first created worktree when the chosen
|
||||
project differs from the active one. `activateSelection` already handles readiness, trust,
|
||||
and stale-target fallback (`project/messages.ts:76-99`), so this is one message, not new
|
||||
machinery. Hook it to the existing `agentManager.worktreeSetup` / `multiVersionProgress`
|
||||
handling in `AgentManagerApp.tsx:1453-1471`, which already carries `projectId`.
|
||||
|
||||
### 7. i18n
|
||||
|
||||
New keys in `webview-ui/agent-manager/i18n/en.ts` (near the existing
|
||||
`agentManager.dialog.*` block at `:130`):
|
||||
|
||||
- `agentManager.dialog.project.select` — "Select project"
|
||||
- `agentManager.dialog.project.untrusted` — "Trust this project in the sidebar first"
|
||||
- `agentManager.dialog.project.missing` — "Repository not found"
|
||||
|
||||
Then translate the four keys into the other 20 locale files in that directory via the
|
||||
`translator` subagent.
|
||||
|
||||
---
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. `ProjectSelect.tsx` with the presentational list, plus i18n keys in `en.ts`.
|
||||
2. Dialog: `project` signal, prop rename to `defaultBase`, route all four outbound
|
||||
messages through `project()`, render the row behind `showProject()`.
|
||||
3. Dialog: `createEffect(on(project, …))` branch reload, base-branch reset, stale-reply
|
||||
guard.
|
||||
4. Call-site updates in `AgentManagerApp.tsx` and `ProjectList.tsx`, shared `defaultBase`
|
||||
resolver.
|
||||
5. `projectId` stamp in `worktree-importer.ts`.
|
||||
6. `/project` slash command.
|
||||
7. Post-create activation when the target project differs.
|
||||
8. Locale fan-out.
|
||||
9. Changeset (`minor`, user-facing): worktree creation targets an explicit project.
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
Existing source-text unit tests already assert against this dialog and will need to stay
|
||||
green: `tests/unit/new-worktree-dialog-sandbox.test.ts`,
|
||||
`tests/unit/prompt-input-bidirectional.test.ts`, and the dialog entry in
|
||||
`tests/unit/agent-manager-arch.test.ts`.
|
||||
|
||||
New coverage:
|
||||
|
||||
- The dialog posts `createMultiVersion` / `requestBranches` / `importFromBranch` /
|
||||
`importFromPR` with the *selected* project id, not the prop, after a project change.
|
||||
- A `agentManager.branches` reply carrying a non-current `projectId` does not mutate the
|
||||
branch list (the race guard).
|
||||
- Changing project clears `baseBranch` and re-derives `defaultBranch` from `defaultBase`.
|
||||
- Prompt text survives a project change (no accidental reset through the shared
|
||||
`advancedDialogPrompt` cache).
|
||||
- The row does not render when `projects` is empty, so the single-project dialog is
|
||||
unchanged.
|
||||
- Untrusted and missing projects are not selectable.
|
||||
|
||||
Checks to run before declaring done, from `packages/kilo-vscode/`:
|
||||
`bun run typecheck`, `bun run lint`, `bun run test:unit`, `bun run knip`.
|
||||
|
||||
---
|
||||
|
||||
## Manual verification
|
||||
|
||||
In the isolated harness (`bun run extension:isolated`) with
|
||||
`kilo-code.new.experimental.multiProject` enabled and two repositories registered:
|
||||
|
||||
1. `Cmd+N` from project A shows "Project: A". Switch to B, create, and confirm the
|
||||
worktree lands in B's accordion and the sidebar activates it.
|
||||
2. Switch project with Advanced options open and confirm the base-branch list and default
|
||||
badge follow the new project rather than showing A's branches.
|
||||
3. Switch project rapidly back and forth and confirm the branch list matches the selected
|
||||
project (the race guard).
|
||||
4. Type a prompt, switch project, confirm the prompt is retained.
|
||||
5. Use the Import tab after switching project and confirm branches and PR import target
|
||||
the selected repository.
|
||||
6. Turn the flag off and confirm the dialog is visually identical to today.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Trusting or removing a project from inside the dialog. Trust stays in the sidebar; the
|
||||
dialog only disables untrusted entries.
|
||||
- Any change to how the active project is persisted.
|
||||
- The quick-create path (`Cmd+Shift+N` → `agentManager.createWorktree`,
|
||||
`AgentManagerApp.tsx:1863-1867`). It has no dialog, so it keeps targeting the active
|
||||
project. Worth revisiting only if the explicit-target rule should apply there too.
|
||||
- Per-project setup-script or agent selection in the dialog.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Stale branch data** is the only real correctness risk, and it is why the `projectId`
|
||||
stamp plus the reply guard are mandatory rather than optional polish.
|
||||
- **Prop signature change** (`defaultBaseBranch: string` → `defaultBase: (id) => string`)
|
||||
touches both call sites; a partial migration would silently show one project's default
|
||||
branch while creating in another.
|
||||
- **Dialog file growth**; mitigated by extracting `ProjectSelect.tsx`.
|
||||
|
||||
---
|
||||
|
||||
# Appendix: exact UI and styling specification
|
||||
|
||||
Everything below is copy-paste ready. Class names, tokens, and icon names are all verified
|
||||
against the current tree. Do not invent new tokens or new class names beyond the ones
|
||||
listed here.
|
||||
|
||||
## A. Visual layout
|
||||
|
||||
```
|
||||
┌─ New Worktree ─────────────────────────────────────────── X ─┐
|
||||
│ │
|
||||
│ [ New ][ Import ] [ 📁 kilocode ⌃⌄ ] │
|
||||
│ └────────────────────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ Worktree name (optional) │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ prompt … │ │
|
||||
│ │ Code ▾ OpenAI / GPT-5.6 ▾ None ▾ ✨ 🔒 🎤 │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
│ › Advanced options │
|
||||
│ VERSIONS [1][2][3][4] [⧉ Compare Models] │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ Create Worktree │ │
|
||||
│ └──────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Open dropdown (anchored under the trigger, same width as the trigger):
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ 📁 kilocode ~/Documents/git/kilocode ✓│ ← .am-project-option-active
|
||||
│ 📁 cloud ~/Documents/git/cloud │
|
||||
│ 🔒 sample-app ~/dev/sample-app │ ← disabled, 50% opacity
|
||||
│ ⚠ old-repo ~/dev/old-repo │ ← disabled, 50% opacity
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- The selector is inline with the New/Import buttons inside the tab-switcher flex row, so
|
||||
it applies to New and Import alike.
|
||||
- The project name and folder icon identify the scope without a separate visible label.
|
||||
- The trigger is the same control as the Advanced options base-branch trigger
|
||||
(`.am-selector-trigger`), so the dialog has one visual language for "pick a thing".
|
||||
- The row is **not rendered at all** when `props.projects` is empty or undefined. That is
|
||||
the single-project / flag-off case, which must stay pixel-identical to today.
|
||||
|
||||
## B. Exact icon names
|
||||
|
||||
Only these, from `packages/ui/src/components/icon.tsx`:
|
||||
|
||||
| Where | `Icon name` | Notes |
|
||||
|---|---|---|
|
||||
| Trigger left | `folder` | Always, regardless of project state. |
|
||||
| Trigger right | `selector` | Same as every other `.am-selector-trigger`. |
|
||||
| Option row, normal | `folder` | |
|
||||
| Option row, untrusted | `lock` | Matches the sidebar accordion affordance. |
|
||||
| Option row, missing | `warning` | Matches the sidebar accordion affordance. |
|
||||
| Option row, selected | `check-small` | Right-aligned. |
|
||||
|
||||
All at `size="small"`. Do not use `folder-add-left`, `check`, or `plus`.
|
||||
|
||||
## C. New file: `webview-ui/agent-manager/ProjectSelect.tsx`
|
||||
|
||||
```tsx
|
||||
// Project picker list for the New Worktree dialog
|
||||
|
||||
/** @jsxImportSource solid-js */
|
||||
|
||||
import { For, Show, type Component } from "solid-js"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import type { AgentProjectSnapshot } from "../src/types/messages"
|
||||
|
||||
interface ProjectSelectProps {
|
||||
projects: AgentProjectSnapshot[]
|
||||
selected?: string
|
||||
onSelect: (id: string) => void
|
||||
labels: { untrusted: string; missing: string }
|
||||
}
|
||||
|
||||
export const ProjectSelect: Component<ProjectSelectProps> = (props) => (
|
||||
<div class="am-dropdown-list">
|
||||
<For each={props.projects}>
|
||||
{(project) => {
|
||||
const blocked = () => !project.trusted || project.missing
|
||||
const hint = () => {
|
||||
if (project.missing) return props.labels.missing
|
||||
if (!project.trusted) return props.labels.untrusted
|
||||
return project.root
|
||||
}
|
||||
const icon = () => {
|
||||
if (project.missing) return "warning" as const
|
||||
if (!project.trusted) return "lock" as const
|
||||
return "folder" as const
|
||||
}
|
||||
return (
|
||||
<button
|
||||
class="am-project-option"
|
||||
classList={{ "am-project-option-active": props.selected === project.id }}
|
||||
disabled={blocked()}
|
||||
title={hint()}
|
||||
onClick={() => props.onSelect(project.id)}
|
||||
type="button"
|
||||
>
|
||||
<span class="am-project-option-left">
|
||||
<Icon name={icon()} size="small" />
|
||||
<span class="am-project-option-name">{project.label}</span>
|
||||
<span class="am-project-option-root">{project.root}</span>
|
||||
</span>
|
||||
<Show when={props.selected === project.id}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
```
|
||||
|
||||
Notes for the implementer:
|
||||
|
||||
- Wrap in `.am-dropdown-list`, not a bare fragment: that class supplies the scroll cap and
|
||||
4px padding, and `.am-dropdown [data-slot="popover-body"]` zeroes the popover padding.
|
||||
- No search input. A project list is short; adding one would need keyboard nav plumbing for
|
||||
no benefit.
|
||||
- `props.labels` is passed in rather than calling `useLanguage()` here, matching how
|
||||
`BranchSelect` and `SidebarSearchMenu` take label props.
|
||||
|
||||
## D. Exact JSX inserted into `NewWorktreeDialog.tsx`
|
||||
|
||||
### D.1 Imports
|
||||
|
||||
Add to the existing type import block at lines 6-12:
|
||||
|
||||
```ts
|
||||
AgentProjectSnapshot,
|
||||
```
|
||||
|
||||
Add after line 48 (`import { BranchSelect, BranchSelectPopover } …`):
|
||||
|
||||
```ts
|
||||
import { ProjectSelect } from "./ProjectSelect"
|
||||
```
|
||||
|
||||
`Icon`, `Show`, `DeferredPopover`, `createSignal`, `createEffect` are already imported.
|
||||
`on` from `solid-js` must be added to the line 5 import list.
|
||||
|
||||
### D.2 Props
|
||||
|
||||
Replace the component signature at lines 84-89 with:
|
||||
|
||||
```tsx
|
||||
export const NewWorktreeDialog: Component<{
|
||||
onClose: () => void
|
||||
/** Resolves the default base branch for one project. */
|
||||
defaultBase?: (projectId: string) => string | undefined
|
||||
/** Initial target project. The user can change it while the dialog is open. */
|
||||
projectId?: string
|
||||
/** Full project catalog. Empty or undefined hides the project row entirely. */
|
||||
projects?: () => AgentProjectSnapshot[]
|
||||
/** Project the sidebar currently has active; used as the default target. */
|
||||
activeProjectId?: string
|
||||
mode: ModeRouter
|
||||
}> = (props) => {
|
||||
```
|
||||
|
||||
### D.3 State
|
||||
|
||||
Immediately after line 101 (`const [tab, setTab] = createSignal<DialogTab>("new")`):
|
||||
|
||||
```tsx
|
||||
const [project, setProject] = createSignal(props.projectId ?? props.activeProjectId)
|
||||
const [projectOpen, setProjectOpen] = createSignal(false)
|
||||
const projects = () => props.projects?.() ?? []
|
||||
const showProject = () => projects().length > 0
|
||||
const projectLabel = () => projects().find((p) => p.id === project())?.label ?? ""
|
||||
```
|
||||
|
||||
`defaultBranch` (line 106) changes from `props.defaultBaseBranch ?? "main"` to:
|
||||
|
||||
```tsx
|
||||
const [defaultBranch, setDefaultBranch] = createSignal(
|
||||
(project() && props.defaultBase?.(project()!)) || "main",
|
||||
)
|
||||
```
|
||||
|
||||
### D.4 The inline selector
|
||||
|
||||
Insert inside the tab switcher after the Import button:
|
||||
|
||||
```tsx
|
||||
{/* Project scope — applies to both tabs. Hidden unless multi-project is on. */}
|
||||
<Show when={showProject()}>
|
||||
<div class="am-nv-project-inline">
|
||||
<div class="am-selector-wrapper">
|
||||
<DeferredPopover
|
||||
open={projectOpen()}
|
||||
onOpenChange={setProjectOpen}
|
||||
placement="bottom-start"
|
||||
flip={false}
|
||||
sameWidth
|
||||
portal={false}
|
||||
deferDismiss
|
||||
class="am-dropdown"
|
||||
trigger={
|
||||
<button class="am-selector-trigger" type="button" disabled={starting() || isPending()}>
|
||||
<span class="am-selector-left">
|
||||
<Icon name="folder" size="small" />
|
||||
<Show
|
||||
when={projectLabel()}
|
||||
fallback={
|
||||
<span class="am-selector-value am-selector-placeholder">
|
||||
{t("agentManager.dialog.project.select")}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span class="am-selector-value">{projectLabel()}</span>
|
||||
</Show>
|
||||
</span>
|
||||
<span class="am-selector-right">
|
||||
<Icon name="selector" size="small" />
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<ProjectSelect
|
||||
projects={projects()}
|
||||
selected={project()}
|
||||
onSelect={(id) => {
|
||||
track("project_select", { changed: id !== props.activeProjectId })
|
||||
setProject(id)
|
||||
setProjectOpen(false)
|
||||
}}
|
||||
labels={{
|
||||
untrusted: t("agentManager.dialog.project.untrusted"),
|
||||
missing: t("agentManager.dialog.project.missing"),
|
||||
}}
|
||||
/>
|
||||
</DeferredPopover>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
```
|
||||
|
||||
Critical details, in order of how easily they get wrong:
|
||||
|
||||
1. `placement="bottom-start"`, **not** `top-start`. The rest of this dialog uses
|
||||
`top-start` because those triggers sit near the bottom of the panel. This one sits at
|
||||
the top, so it must open downward.
|
||||
2. `portal={false}` plus the escape CSS in section E.3. Do not switch to a portal unless
|
||||
the clipping fallback in E.3 is needed.
|
||||
3. `sameWidth` so the dropdown matches the trigger width, consistent with the base-branch
|
||||
and compare-models popovers.
|
||||
4. Never send the project label, root, or id as a telemetry property. `track` takes only
|
||||
the boolean shown above.
|
||||
|
||||
### D.5 Reactive reload on project change
|
||||
|
||||
Delete the one-shot request at lines 319-321 inside `onMount` and replace it with an effect
|
||||
placed next to the other `createEffect` calls:
|
||||
|
||||
```tsx
|
||||
// Project scope owns the branch data, the base branch, and the default badge.
|
||||
// Prompt, name, model, agent, versions and attachments are project-agnostic and survive.
|
||||
createEffect(
|
||||
on(project, (id) => {
|
||||
if (!id) return
|
||||
setBranches([])
|
||||
setBranchSearch("")
|
||||
setHighlightedIndex(0)
|
||||
setBaseBranch(null)
|
||||
setDefaultBranch(props.defaultBase?.(id) ?? "main")
|
||||
setBranchesLoading(true)
|
||||
vscode.postMessage({ type: "agentManager.requestBranches", projectId: id })
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
`on(project, …)` without `{ defer: true }` runs immediately, which replaces the removed
|
||||
`onMount` request. Keep the textarea focus logic in `onMount` untouched.
|
||||
|
||||
In the `agentManager.branches` handler (lines 520-525), replace the body with:
|
||||
|
||||
```tsx
|
||||
if (msg.type === "agentManager.branches") {
|
||||
const ev = msg as AgentManagerBranchesMessage
|
||||
if (ev.projectId && ev.projectId !== project()) return
|
||||
setBranches(ev.branches)
|
||||
const id = project()
|
||||
if (!id || !props.defaultBase?.(id)) setDefaultBranch(ev.defaultBranch)
|
||||
setBranchesLoading(false)
|
||||
}
|
||||
```
|
||||
|
||||
### D.6 Outbound project id
|
||||
|
||||
Four call sites change from `props.projectId` to `project()`:
|
||||
|
||||
| Line | Message |
|
||||
|---|---|
|
||||
| 321 (now inside the effect) | `agentManager.requestBranches` |
|
||||
| 373 | `agentManager.createMultiVersion` |
|
||||
| 566 | `agentManager.importFromPR` |
|
||||
| 575 | `agentManager.importFromBranch` |
|
||||
|
||||
Grep afterwards: `props.projectId` must appear exactly once in the file, in the `project`
|
||||
signal initializer.
|
||||
|
||||
## E. Exact CSS
|
||||
|
||||
All of it goes into `webview-ui/agent-manager/agent-manager.css`. No changes to kilo-ui.
|
||||
|
||||
### E.1 The inline selector
|
||||
|
||||
Insert directly after the `.am-tab-switcher-pill-active` rule (agent-manager.css:3614-3617),
|
||||
before the `/* Import tab layout */` comment at line 3619:
|
||||
|
||||
```css
|
||||
/* Project scope selector — inline with the New/Import tabs */
|
||||
|
||||
.am-nv-project-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
flex: 0 1 260px;
|
||||
min-width: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.am-nv-project-inline .am-selector-wrapper {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
```
|
||||
|
||||
The `flex: 0 1 260px` cap keeps the project control compact while allowing long project
|
||||
names to truncate. `margin-left: auto` keeps it aligned to the right of the New/Import
|
||||
buttons.
|
||||
|
||||
### E.2 The dropdown rows
|
||||
|
||||
Insert after the `.am-dropdown-empty` rule (agent-manager.css:3863-3868), before the
|
||||
`/* Import empty state */` comment at line 3870:
|
||||
|
||||
```css
|
||||
/* Project option rows in the New Worktree project dropdown.
|
||||
Deliberately distinct from .am-project-item, which styles the sidebar accordion. */
|
||||
|
||||
.am-project-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-base);
|
||||
font-size: var(--font-size-base);
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.am-project-option:hover:not(:disabled) {
|
||||
background: var(--surface-inset-base-hover);
|
||||
}
|
||||
|
||||
.am-project-option-active {
|
||||
background: var(--surface-inset-base);
|
||||
}
|
||||
|
||||
.am-project-option:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.am-project-option-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.am-project-option-left [data-component="icon"] {
|
||||
color: var(--text-weaker);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.am-project-option-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
max-width: 45%;
|
||||
}
|
||||
|
||||
.am-project-option-root {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
font-size: var(--kilo-font-size-11);
|
||||
color: var(--text-weaker);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Why not reuse `.am-branch-item`: it is defined twice (lines 3211 and 3810) and the earlier
|
||||
definition sets `font-family: var(--font-mono, monospace)` on `.am-branch-item-name`, which
|
||||
would render project labels in monospace. Reusing it also couples project rows to future
|
||||
branch-row changes. `.am-project-item` is likewise off limits: it already styles the
|
||||
sidebar project accordion header (line 325).
|
||||
|
||||
### E.3 Popover clipping escape
|
||||
|
||||
`[data-slot="dialog-body"]` is `overflow: hidden` in `packages/ui/src/components/dialog.css:99-105`,
|
||||
and `[data-slot="dialog-content"]` is `overflow: auto` (line 38). The existing escape rules
|
||||
at agent-manager.css:2822-2828 only match popovers **inside** `.am-nv-dialog`, and this row
|
||||
is deliberately outside it. Without the following, the dropdown is clipped by the dialog.
|
||||
|
||||
Add to that same rule group (extend the existing selector list rather than duplicating the
|
||||
declaration):
|
||||
|
||||
```css
|
||||
[data-component="dialog"]:has(.am-nv-project-inline [data-component="popover-content"]) [data-slot="dialog-content"],
|
||||
[data-component="dialog"]:has(.am-nv-project-inline [data-component="popover-content"]) [data-slot="dialog-body"] {
|
||||
overflow: visible;
|
||||
}
|
||||
```
|
||||
|
||||
Verification step, not optional: open the dropdown with four or more projects registered
|
||||
and confirm no row is cut off and no inner scrollbar appears on the dialog. If it still
|
||||
clips, the documented fallback is to drop `portal={false}` from the `DeferredPopover` in
|
||||
D.4 and delete this rule; the dialog already sets `overflow: visible` on
|
||||
`[data-slot="dialog-content"]` for portal-based dropdowns (agent-manager.css:2755-2760).
|
||||
|
||||
## F. i18n
|
||||
|
||||
Add to `webview-ui/agent-manager/i18n/en.ts`, immediately after
|
||||
`"agentManager.dialog.namePlaceholder"`:
|
||||
|
||||
```ts
|
||||
"agentManager.dialog.project.select": "Select project",
|
||||
"agentManager.dialog.project.untrusted": "Trust this project in the sidebar first",
|
||||
"agentManager.dialog.project.missing": "Repository not found",
|
||||
```
|
||||
|
||||
Then add the same three keys to all 20 sibling locale files in that directory (`ar bs br da
|
||||
de es fa fr it ja ko nl no pl ru th tr uk zh zht`) via the `translator` subagent.
|
||||
|
||||
## G. What must not change
|
||||
|
||||
- No new CSS variables or tokens. Only the ones listed above, all already in use in this
|
||||
file.
|
||||
- No edits to `packages/kilo-ui/` or `packages/ui/`.
|
||||
- No change to `.am-project-item`, `.am-branch-item`, `.am-selector-trigger`,
|
||||
`.am-nv-config-label`, or any other existing rule. The only existing rule touched is the
|
||||
`overflow: visible` selector group in E.3, and only by adding selectors to it.
|
||||
- No new message types. `agentManager.requestBranches`, `agentManager.createMultiVersion`,
|
||||
`agentManager.importFromBranch`, and `agentManager.importFromPR` all already exist and
|
||||
already accept what is needed.
|
||||
- With `props.projects` empty, the rendered dialog markup must be identical to before the
|
||||
change. Verify by toggling `kilo-code.new.experimental.multiProject` off.
|
||||
@@ -15,6 +15,7 @@ import { Global } from "./global"
|
||||
import { DataMigrationTable } from "./data-migration.sql"
|
||||
import path from "path"
|
||||
import { parse as parseKiloAccounts } from "./kilocode/credential-migration"
|
||||
import { isBusy } from "./kilocode/sqlite-error"
|
||||
import { NonNegativeInt } from "./schema"
|
||||
// kilocode_change end
|
||||
|
||||
@@ -170,6 +171,17 @@ export const legacyImportLayer = Layer.effectDiscard(
|
||||
const integration = Integration.ID.make(integrationID.replace(/\/+$/, ""))
|
||||
return [{ integration, value: legacyValue(integration, decoded.value) }]
|
||||
})
|
||||
const migrated = yield* db.select().from(DataMigrationTable).where(eq(DataMigrationTable.name, name)).get()
|
||||
const existing = yield* db.select().from(CredentialTable).orderBy(desc(CredentialTable.time_created)).all()
|
||||
const same = (left: Value, right: Value) => JSON.stringify(left) === JSON.stringify(right)
|
||||
if (
|
||||
migrated &&
|
||||
values.every((item) => {
|
||||
const current = existing.find((row) => row.integration_id === item.integration)
|
||||
return current !== undefined && same(current.value, item.value)
|
||||
})
|
||||
)
|
||||
return
|
||||
yield* db.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
for (const item of values) {
|
||||
@@ -181,7 +193,12 @@ export const legacyImportLayer = Layer.effectDiscard(
|
||||
.orderBy(desc(CredentialTable.time_created)) // kilocode_change - reconcile the active imported account
|
||||
.get()
|
||||
if (current) {
|
||||
yield* tx.update(CredentialTable).set({ value: item.value }).where(eq(CredentialTable.id, current.id)).run()
|
||||
if (!same(current.value, item.value))
|
||||
yield* tx
|
||||
.update(CredentialTable)
|
||||
.set({ value: item.value })
|
||||
.where(eq(CredentialTable.id, current.id))
|
||||
.run()
|
||||
continue
|
||||
}
|
||||
yield* tx.insert(CredentialTable).values({
|
||||
@@ -194,7 +211,15 @@ export const legacyImportLayer = Layer.effectDiscard(
|
||||
yield* tx.insert(DataMigrationTable).values({ name, time_completed: Date.now() }).onConflictDoNothing().run()
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.orDie),
|
||||
}).pipe(
|
||||
Effect.retry({ while: isBusy, times: 2 }),
|
||||
Effect.catch((error) =>
|
||||
isBusy(error)
|
||||
? Effect.logWarning("legacy credential reconciliation deferred because the database is busy")
|
||||
: Effect.fail(error),
|
||||
),
|
||||
Effect.orDie,
|
||||
),
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
|
||||
@@ -4,19 +4,29 @@ import type { Database } from "../database/database"
|
||||
type Db = Database.Interface["db"]
|
||||
|
||||
export function ensure(db: Db) {
|
||||
return db.transaction(
|
||||
(tx) =>
|
||||
Effect.gen(function* () {
|
||||
const rows = yield* tx.all<{ name: string }>("PRAGMA table_info('session_context_epoch')")
|
||||
const names = new Set(rows.map((row) => row.name))
|
||||
const load = db.all<{ name: string }>("PRAGMA table_info('session_context_epoch')")
|
||||
const ready = (rows: { name: string }[]) => {
|
||||
const names = new Set(rows.map((row) => row.name))
|
||||
return ["agent", "replacement_seq", "revision"].every((name) => names.has(name))
|
||||
}
|
||||
return load.pipe(
|
||||
Effect.flatMap((rows) => {
|
||||
if (ready(rows)) return Effect.void
|
||||
return db.transaction(
|
||||
(tx) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* tx.all<{ name: string }>("PRAGMA table_info('session_context_epoch')")
|
||||
const names = new Set(current.map((row) => row.name))
|
||||
|
||||
if (!names.has("agent"))
|
||||
yield* tx.run("ALTER TABLE `session_context_epoch` ADD `agent` text DEFAULT 'build' NOT NULL")
|
||||
if (!names.has("replacement_seq"))
|
||||
yield* tx.run("ALTER TABLE `session_context_epoch` ADD `replacement_seq` integer")
|
||||
if (!names.has("revision"))
|
||||
yield* tx.run("ALTER TABLE `session_context_epoch` ADD `revision` integer DEFAULT 0 NOT NULL")
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
if (!names.has("agent"))
|
||||
yield* tx.run("ALTER TABLE `session_context_epoch` ADD `agent` text DEFAULT 'build' NOT NULL")
|
||||
if (!names.has("replacement_seq"))
|
||||
yield* tx.run("ALTER TABLE `session_context_epoch` ADD `replacement_seq` integer")
|
||||
if (!names.has("revision"))
|
||||
yield* tx.run("ALTER TABLE `session_context_epoch` ADD `revision` integer DEFAULT 0 NOT NULL")
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Cause, Option } from "effect"
|
||||
import { isSqlError } from "effect/unstable/sql/SqlError"
|
||||
|
||||
export function isBusy(error: unknown): boolean {
|
||||
if (isSqlError(error)) return error.reason._tag === "LockTimeoutError"
|
||||
if (typeof error !== "object" || error === null || !("cause" in error) || error.cause === error) return false
|
||||
if (!Cause.isCause(error.cause)) return isBusy(error.cause)
|
||||
const failure = Cause.findErrorOption(error.cause)
|
||||
return Option.isSome(failure) && isBusy(failure.value)
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import path from "path"
|
||||
import { Database as SQLite } from "bun:sqlite" // kilocode_change
|
||||
import { describe, expect } from "bun:test"
|
||||
import { eq } from "drizzle-orm" // kilocode_change
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { CredentialTable } from "@opencode-ai/core/credential/sql" // kilocode_change
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
// kilocode_change start
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
// kilocode_change end
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
@@ -20,6 +24,16 @@ function localLayer(directory: string) {
|
||||
)
|
||||
}
|
||||
|
||||
// kilocode_change start
|
||||
function importer(dir: string, store: Database.Interface) {
|
||||
return Credential.legacyImportLayer.pipe(
|
||||
Layer.provide(Layer.succeed(Database.Service, store)),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Global.layerWith({ data: dir })),
|
||||
)
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
describe("Credential", () => {
|
||||
it.live("stores, updates, lists, and removes credentials", () =>
|
||||
Effect.acquireUseRelease(
|
||||
@@ -196,6 +210,70 @@ describe("Credential", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("skips unchanged legacy writes and defers locked reconciliation", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
const file = path.join(tmp.path, "credential.db")
|
||||
const auth = path.join(tmp.path, "auth.json")
|
||||
const write = (key: string) =>
|
||||
Effect.promise(() => Bun.write(auth, JSON.stringify({ kilo: { type: "api", key } })))
|
||||
return Effect.gen(function* () {
|
||||
yield* write("first")
|
||||
const store = yield* Database.Service
|
||||
const layer = importer(tmp.path, store)
|
||||
yield* Layer.build(Layer.fresh(layer))
|
||||
|
||||
const before = yield* store.db
|
||||
.select()
|
||||
.from(CredentialTable)
|
||||
.where(eq(CredentialTable.integration_id, Integration.ID.make("kilo")))
|
||||
.get()
|
||||
yield* Layer.build(Layer.fresh(layer))
|
||||
const unchanged = yield* store.db
|
||||
.select()
|
||||
.from(CredentialTable)
|
||||
.where(eq(CredentialTable.integration_id, Integration.ID.make("kilo")))
|
||||
.get()
|
||||
expect(unchanged?.time_updated).toBe(before?.time_updated)
|
||||
|
||||
yield* write("second")
|
||||
yield* store.db.run("PRAGMA busy_timeout = 0")
|
||||
yield* Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const holder = new SQLite(file)
|
||||
holder.run("PRAGMA busy_timeout = 0")
|
||||
holder.run("BEGIN IMMEDIATE")
|
||||
return holder
|
||||
}),
|
||||
() => Layer.build(Layer.fresh(layer)),
|
||||
(holder) =>
|
||||
Effect.sync(() => {
|
||||
if (holder.inTransaction) holder.run("ROLLBACK")
|
||||
holder.close()
|
||||
}),
|
||||
)
|
||||
|
||||
const stale = yield* store.db
|
||||
.select()
|
||||
.from(CredentialTable)
|
||||
.where(eq(CredentialTable.integration_id, Integration.ID.make("kilo")))
|
||||
.get()
|
||||
expect(stale?.value).toMatchObject({ type: "key", key: "first" })
|
||||
|
||||
yield* Layer.build(Layer.fresh(layer))
|
||||
const reconciled = yield* store.db
|
||||
.select()
|
||||
.from(CredentialTable)
|
||||
.where(eq(CredentialTable.integration_id, Integration.ID.make("kilo")))
|
||||
.get()
|
||||
expect(reconciled?.value).toMatchObject({ type: "key", key: "second" })
|
||||
}).pipe(Effect.provide(Database.layerFromPath(file)), Effect.scoped)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("dual-writes stored credentials for released auth.json readers", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Database as SQLite } from "bun:sqlite"
|
||||
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
||||
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
|
||||
@@ -159,6 +160,22 @@ describe("database migration compatibility", () => {
|
||||
sql`SELECT agent, replacement_seq AS replacementSeq, revision FROM session_context_epoch WHERE session_id = 'session'`,
|
||||
),
|
||||
).toEqual({ agent: "build", replacementSeq: 4, revision: 1 })
|
||||
|
||||
yield* db.run("PRAGMA busy_timeout = 0")
|
||||
yield* Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const holder = new SQLite(filename)
|
||||
holder.run("PRAGMA busy_timeout = 0")
|
||||
holder.run("BEGIN IMMEDIATE")
|
||||
return holder
|
||||
}),
|
||||
() => ensure(db),
|
||||
(holder) =>
|
||||
Effect.sync(() => {
|
||||
if (holder.inTransaction) holder.run("ROLLBACK")
|
||||
holder.close()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
).pipe(Effect.provide(Database.layerFromPath(filename)), Effect.scoped),
|
||||
)
|
||||
|
||||
@@ -279,7 +279,13 @@ export class SQLiteEffectPreparedQuery<
|
||||
assertUnreachable(cacheStrat)
|
||||
}).pipe(
|
||||
Effect.catch((e) => {
|
||||
return Effect.fail(new EffectDrizzleQueryError({ query: queryString, params, cause: Cause.fail(e) }))
|
||||
return Effect.fail(
|
||||
new EffectDrizzleQueryError({
|
||||
query: queryString,
|
||||
params: params.map(() => "<redacted>"), // kilocode_change - bound values may contain credentials
|
||||
cause: Cause.fail(e),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -130,6 +130,24 @@ test("preserves failed transaction begin errors", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// kilocode_change start - query errors must never expose bound credential values
|
||||
test("redacts bound values from query errors", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
const secret = "must-not-leak"
|
||||
yield* db.insert(users).values({ id: 1, name: "Ada" })
|
||||
|
||||
const error = yield* db.insert(users).values({ id: 1, name: secret }).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).not.toContain(secret)
|
||||
expect(error.params).not.toContain(secret)
|
||||
expect(error.params.every((param) => param === "<redacted>")).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
test("supports returning and rejects empty update sets", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:88a4490322f4f70694f7b785cff41c5900b4ef8c14da104396fe55d264a624b4
|
||||
size 14535
|
||||
@@ -811,15 +811,15 @@ export class AgentManagerProvider implements Disposable {
|
||||
|
||||
private onImportMessage(m: AgentManagerInMessage): Record<string, unknown> | null | undefined {
|
||||
if (m.type === "agentManager.requestBranches") {
|
||||
void this.importer.branches()
|
||||
void this.importer.branches(m.projectId)
|
||||
return null
|
||||
}
|
||||
if (m.type === "agentManager.importFromBranch") {
|
||||
void this.importer.branch(m.branch)
|
||||
void this.importer.branch(m.branch, m.projectId)
|
||||
return null
|
||||
}
|
||||
if (m.type === "agentManager.importFromPR") {
|
||||
void this.importer.pr(m.url)
|
||||
void this.importer.pr(m.url, m.projectId)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1055,6 +1055,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
this.pushState()
|
||||
this.postToWebview({
|
||||
type: "agentManager.worktreeSetup",
|
||||
projectId: this.host.multiProject() ? this.context?.id : undefined,
|
||||
status: "ready",
|
||||
message: "Worktree ready",
|
||||
sessionId,
|
||||
|
||||
@@ -299,6 +299,7 @@ interface BranchesMessage {
|
||||
|
||||
interface ImportResultMessage {
|
||||
type: "agentManager.importResult"
|
||||
projectId?: string
|
||||
success: boolean
|
||||
message: string
|
||||
errorCode?: WorktreeSetupErrorCode
|
||||
|
||||
@@ -21,10 +21,10 @@ export class WorktreeImporter {
|
||||
|
||||
constructor(private readonly host: WorktreeImporterHost) {}
|
||||
|
||||
async branches(): Promise<void> {
|
||||
async branches(projectId?: string): Promise<void> {
|
||||
const manager = this.host.manager()
|
||||
if (!manager) {
|
||||
this.host.post({ type: "agentManager.branches", branches: [], defaultBranch: "main" })
|
||||
this.host.post({ type: "agentManager.branches", projectId, branches: [], defaultBranch: "main" })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -46,31 +46,32 @@ export class WorktreeImporter {
|
||||
|
||||
this.host.post({
|
||||
type: "agentManager.branches",
|
||||
projectId,
|
||||
branches,
|
||||
defaultBranch: result.defaultBranch,
|
||||
})
|
||||
} catch (error) {
|
||||
this.host.log(`Failed to list branches: ${error}`)
|
||||
this.host.post({ type: "agentManager.branches", branches: [], defaultBranch: "main" })
|
||||
this.host.post({ type: "agentManager.branches", projectId, branches: [], defaultBranch: "main" })
|
||||
}
|
||||
}
|
||||
|
||||
async branch(branch: string): Promise<void> {
|
||||
await this.run({ branch })
|
||||
async branch(branch: string, projectId?: string): Promise<void> {
|
||||
await this.run({ branch }, projectId)
|
||||
}
|
||||
|
||||
async pr(url: string): Promise<void> {
|
||||
await this.run({ url })
|
||||
async pr(url: string, projectId?: string): Promise<void> {
|
||||
await this.run({ url }, projectId)
|
||||
}
|
||||
|
||||
private async run(target: { branch: string } | { url: string }): Promise<void> {
|
||||
private async run(target: { branch: string } | { url: string }, projectId?: string): Promise<void> {
|
||||
const manager = this.host.manager()
|
||||
const state = this.host.state()
|
||||
if (!manager || !state) {
|
||||
this.host.post({ type: "agentManager.importResult", success: false, message: "Not a git repository" })
|
||||
this.host.post({ type: "agentManager.importResult", projectId, success: false, message: "Not a git repository" })
|
||||
return
|
||||
}
|
||||
if (this.busy()) return
|
||||
if (this.busy(projectId)) return
|
||||
this.importing = true
|
||||
const branch = "branch" in target
|
||||
const creating = branch ? "Creating worktree from branch..." : "Resolving PR..."
|
||||
@@ -79,7 +80,7 @@ export class WorktreeImporter {
|
||||
? `Branch "${target.branch}" is already checked out in another worktree`
|
||||
: "This PR's branch is already checked out in another worktree"
|
||||
try {
|
||||
const progress = { type: "agentManager.worktreeSetup", status: "creating" } as const
|
||||
const progress = { type: "agentManager.worktreeSetup", projectId, status: "creating" } as const
|
||||
this.host.post({ ...progress, message: creating })
|
||||
const result = branch
|
||||
? await manager.createWorktree({ existingBranch: target.branch })
|
||||
@@ -102,7 +103,7 @@ export class WorktreeImporter {
|
||||
state.addSession(session.id, worktree.id)
|
||||
this.host.register(session.id, result.path)
|
||||
this.host.ready(session.id, result, worktree.id)
|
||||
this.host.post({ type: "agentManager.importResult", success: true, message: success })
|
||||
this.host.post({ type: "agentManager.importResult", projectId, success: true, message: success })
|
||||
this.host.log(`${log} as worktree ${worktree.id}`)
|
||||
} catch (error) {
|
||||
state.removeWorktree(worktree.id)
|
||||
@@ -111,27 +112,28 @@ export class WorktreeImporter {
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
this.importError(error, duplicate)
|
||||
this.importError(error, duplicate, projectId)
|
||||
} finally {
|
||||
this.importing = false
|
||||
}
|
||||
}
|
||||
|
||||
private busy(): boolean {
|
||||
private busy(projectId?: string): boolean {
|
||||
if (!this.importing) return false
|
||||
this.host.post({
|
||||
type: "agentManager.importResult",
|
||||
projectId,
|
||||
success: false,
|
||||
message: "Another import is already in progress",
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private importError(error: unknown, duplicate: string): void {
|
||||
private importError(error: unknown, duplicate: string, projectId?: string): void {
|
||||
const raw = error instanceof Error ? error.message : String(error)
|
||||
const message = raw.includes("already used by worktree") || raw.includes("already checked out") ? duplicate : raw
|
||||
const code = classifyWorktreeError(message)
|
||||
this.host.post({ type: "agentManager.worktreeSetup", status: "error", message, errorCode: code })
|
||||
this.host.post({ type: "agentManager.importResult", success: false, message, errorCode: code })
|
||||
this.host.post({ type: "agentManager.worktreeSetup", projectId, status: "error", message, errorCode: code })
|
||||
this.host.post({ type: "agentManager.importResult", projectId, success: false, message, errorCode: code })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ const TSX_FILES = [
|
||||
path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/UnassignedSessionsSection.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/NewWorktreeDialog.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/ProjectSelect.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/DiffPanel.tsx"),
|
||||
path.join(ROOT, "webview-ui/diff-viewer/FullScreenDiffView.tsx"),
|
||||
@@ -690,7 +691,7 @@ describe("Agent Manager Provider — onMessage routing", () => {
|
||||
|
||||
it("worktree import behavior lives in the cohesive importer", () => {
|
||||
const text = importer()
|
||||
for (const value of ["createFromPR", "createWorktree", "this.busy()"]) expect(text).toContain(value)
|
||||
for (const value of ["createFromPR", "createWorktree", "this.busy(projectId)"]) expect(text).toContain(value)
|
||||
expect(body("onImportMessage")).toContain("this.importer")
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { readdirSync, readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
const root = join(__dirname, "..", "..")
|
||||
const dialog = readFileSync(join(root, "webview-ui", "agent-manager", "NewWorktreeDialog.tsx"), "utf8")
|
||||
const app = readFileSync(join(root, "webview-ui", "agent-manager", "AgentManagerApp.tsx"), "utf8")
|
||||
const pending = readFileSync(join(root, "webview-ui", "agent-manager", "pending-create.ts"), "utf8")
|
||||
const importer = readFileSync(join(root, "src", "agent-manager", "worktree-importer.ts"), "utf8")
|
||||
const css = readFileSync(join(root, "webview-ui", "agent-manager", "agent-manager.css"), "utf8")
|
||||
|
||||
describe("Agent Manager New Worktree project targeting", () => {
|
||||
it("routes dialog operations through the selected project and rejects stale responses", () => {
|
||||
expect(dialog).toContain("const [project, setProject]")
|
||||
expect(dialog).toContain("if (ev.projectId !== project()) return")
|
||||
expect(dialog).toContain('type: "agentManager.requestBranches", projectId: id')
|
||||
expect(dialog).toContain('type: "agentManager.createMultiVersion"')
|
||||
expect(dialog).toContain("projectId: target")
|
||||
expect(dialog).toContain('type: "agentManager.importFromPR"')
|
||||
expect(dialog).toContain('type: "agentManager.importFromBranch"')
|
||||
})
|
||||
|
||||
it("does not replace a pending cross-project activation", () => {
|
||||
expect(pending).toContain("if (pending()) return")
|
||||
expect(app).toContain("usePendingCreate(activeProjectId")
|
||||
expect(app).toContain('msg.type === "agentManager.importResult"')
|
||||
expect(app).toContain("!msg.success) creation.abandon(msg.projectId)")
|
||||
})
|
||||
|
||||
it("tags branch and import responses with their owning project", () => {
|
||||
expect(importer).toContain("async branches(projectId?: string)")
|
||||
expect(importer).toContain('type: "agentManager.branches", projectId')
|
||||
expect(importer).toContain('type: "agentManager.importResult", projectId')
|
||||
expect(importer).toContain('type: "agentManager.worktreeSetup", projectId')
|
||||
})
|
||||
|
||||
it("keeps the project picker aligned with the dialog selector system", () => {
|
||||
expect(css).toContain(".am-nv-project-inline")
|
||||
expect(css).toContain(".am-project-option")
|
||||
expect(css).toContain('[data-component="dialog"]:has(.am-nv-project-inline [data-component="popover-content"])')
|
||||
})
|
||||
|
||||
it("defines project labels in every Agent Manager locale", () => {
|
||||
const keys = [
|
||||
"agentManager.dialog.project.select",
|
||||
"agentManager.dialog.project.untrusted",
|
||||
"agentManager.dialog.project.missing",
|
||||
]
|
||||
const locales = readdirSync(join(root, "webview-ui", "agent-manager", "i18n")).filter((file) =>
|
||||
file.endsWith(".ts"),
|
||||
)
|
||||
|
||||
for (const file of locales) {
|
||||
const source = readFileSync(join(root, "webview-ui", "agent-manager", "i18n", file), "utf8")
|
||||
for (const key of keys) expect(source, `${file} is missing ${key}`).toContain(`"${key}"`)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -180,6 +180,7 @@ import { clampPanelWidth, maxPanelWidth, minPanelWidth } from "./side-panel-layo
|
||||
import { buildShortcutCategories } from "./shortcuts"
|
||||
import { tracker } from "./telemetry"
|
||||
import { createChatFocus, hasQuestionOption } from "./focus"
|
||||
import { usePendingCreate } from "./pending-create"
|
||||
import "./agent-manager.css"
|
||||
import "./agent-manager-review.css"
|
||||
import { cycleAgent as cycle } from "../src/context/session-agent"
|
||||
@@ -267,6 +268,12 @@ const AgentManagerContent: Component = () => {
|
||||
const [currentProjectId, setCurrentProjectId] = createSignal<string | undefined>()
|
||||
const [projectStates, setProjectStates] = createSignal<Record<string, AgentManagerStateMessage>>({})
|
||||
const activeProjectId = () => projectList().find((p) => p.active)?.id ?? currentProjectId()
|
||||
const creation = usePendingCreate(activeProjectId, (projectId, worktreeId) =>
|
||||
vscode.postMessage({
|
||||
type: "agentManager.activateSelection",
|
||||
target: { projectId, kind: "worktree", worktreeId },
|
||||
}),
|
||||
)
|
||||
const isActivePayload = (pid: string | undefined) =>
|
||||
projectList().length === 0 || pid === undefined || pid === activeProjectId()
|
||||
|
||||
@@ -283,6 +290,14 @@ const AgentManagerContent: Component = () => {
|
||||
persisted: persisted ?? {},
|
||||
activeId: () => currentProjectId() ?? "single",
|
||||
})
|
||||
const defaultBase = (id: string) => {
|
||||
const store = registry.ensure(id)
|
||||
return (
|
||||
store.defaultBaseBranch() ??
|
||||
store.localStats()?.branch ??
|
||||
(id === activeProjectId() ? repoDetectedBranch() : undefined)
|
||||
)
|
||||
}
|
||||
const localSessionIDs = () => registry.active().tabs.ids()
|
||||
const setLocalSessionIDs = (next: string[] | ((prev: string[]) => string[])) => registry.active().tabs.set(next)
|
||||
/** Remove a session ID from the local tab (no-op if absent). */
|
||||
@@ -1379,6 +1394,7 @@ const AgentManagerContent: Component = () => {
|
||||
|
||||
if (msg.type === "agentManager.worktreeSetup") {
|
||||
const ev = msg as AgentManagerWorktreeSetupMessage
|
||||
creation.setup(ev)
|
||||
const store = ev.projectId ? registry.ensure(ev.projectId) : registry.active()
|
||||
const updateBusy: Setter<Map<string, WorktreeBusyState>> = (value) => store.setBusy(value)
|
||||
if (ev.status === "ready" || ev.status === "error") {
|
||||
@@ -1420,6 +1436,8 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.type === "agentManager.importResult" && !msg.success) creation.abandon(msg.projectId)
|
||||
|
||||
if (msg.type === "agentManager.sessionAdded") {
|
||||
const ev = msg as { type: string; sessionId: string; worktreeId: string }
|
||||
saveTabMemory()
|
||||
@@ -1461,6 +1479,7 @@ const AgentManagerContent: Component = () => {
|
||||
// When a multi-version progress update arrives, mark newly created worktrees as loading
|
||||
if ((msg as { type: string }).type === "agentManager.multiVersionProgress") {
|
||||
const ev = msg as unknown as AgentManagerMultiVersionProgressMessage
|
||||
if (ev.status === "done") creation.abandon(ev.projectId)
|
||||
if (ev.status === "done" && ev.groupId) {
|
||||
// Clear busy state for all worktrees in this group
|
||||
const store = ev.projectId ? registry.ensure(ev.projectId) : registry.active()
|
||||
@@ -1879,7 +1898,15 @@ const AgentManagerContent: Component = () => {
|
||||
if (!loaded()) return
|
||||
expandSidebar()
|
||||
dialog.show(() => (
|
||||
<NewWorktreeDialog mode={mode} onClose={() => dialog.close()} defaultBaseBranch={repoDefaultBranch()} />
|
||||
<NewWorktreeDialog
|
||||
mode={mode}
|
||||
onClose={() => dialog.close()}
|
||||
projectId={multiProject() ? activeProjectId() : undefined}
|
||||
projects={multiProject() ? projectList : undefined}
|
||||
activeProjectId={activeProjectId()}
|
||||
defaultBase={defaultBase}
|
||||
onCreate={creation.schedule}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
@@ -2356,6 +2383,8 @@ const AgentManagerContent: Component = () => {
|
||||
selection={selection() ?? undefined}
|
||||
currentSessionID={session.currentSessionID}
|
||||
mode={mode}
|
||||
defaultBase={defaultBase}
|
||||
onCreate={creation.schedule}
|
||||
bindings={kb()}
|
||||
t={t}
|
||||
onSearchRef={(ref) => (sidebarSearchMenu = ref)}
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
/** @jsxImportSource solid-js */
|
||||
|
||||
import { type Component, For, Show, createSignal, createEffect, createMemo, onMount, onCleanup } from "solid-js"
|
||||
import { type Component, For, Show, createSignal, createEffect, createMemo, on, onMount, onCleanup } from "solid-js"
|
||||
import type {
|
||||
AgentManagerBranchesMessage,
|
||||
AgentManagerImportResultMessage,
|
||||
AgentProjectSnapshot,
|
||||
BranchInfo,
|
||||
EnhancePromptResultMessage,
|
||||
EnhancePromptErrorMessage,
|
||||
@@ -49,10 +50,11 @@ import { BranchSelect, BranchSelectPopover } from "../src/components/shared/Bran
|
||||
import { tracker } from "./telemetry"
|
||||
import { cycleAgent } from "../src/context/session-agent"
|
||||
import type { ModeRouter } from "./mode-router"
|
||||
import { ProjectSelect } from "./ProjectSelect"
|
||||
|
||||
type VersionCount = 1 | 2 | 3 | 4
|
||||
const VERSION_OPTIONS: VersionCount[] = [1, 2, 3, 4]
|
||||
const WORKTREE_PROMPT_COMMANDS = new Set(["models", "agents", "variant", "sandbox"])
|
||||
const WORKTREE_PROMPT_COMMANDS = new Set(["models", "agents", "variant", "sandbox", "project"])
|
||||
const WORKTREE_PROMPT_SCOPE = "agent-manager-worktree-prompt"
|
||||
|
||||
type DialogTab = "new" | "import"
|
||||
@@ -124,8 +126,11 @@ function sanitizeBranchName(name: string): string {
|
||||
|
||||
export const NewWorktreeDialog: Component<{
|
||||
onClose: () => void
|
||||
defaultBaseBranch?: string
|
||||
defaultBase?: (projectId: string) => string | undefined
|
||||
projectId?: string
|
||||
projects?: () => AgentProjectSnapshot[]
|
||||
activeProjectId?: string
|
||||
onCreate?: (projectId: string) => void
|
||||
mode: ModeRouter
|
||||
}> = (props) => {
|
||||
const { t } = useLanguage()
|
||||
@@ -140,11 +145,20 @@ export const NewWorktreeDialog: Component<{
|
||||
const click = metrics.click
|
||||
|
||||
const [tab, setTab] = createSignal<DialogTab>("new")
|
||||
const [project, setProject] = createSignal(props.projectId ?? props.activeProjectId)
|
||||
const [projectOpen, setProjectOpen] = createSignal(false)
|
||||
const projects = () => props.projects?.() ?? []
|
||||
const showProject = () => projects().length > 0
|
||||
const projectLabel = () => projects().find((item) => item.id === project())?.label ?? ""
|
||||
const base = () => {
|
||||
const id = project()
|
||||
return id ? props.defaultBase?.(id) : undefined
|
||||
}
|
||||
|
||||
// --- Shared branch data (used by both New tab's base branch selector and Import tab) ---
|
||||
const [branches, setBranches] = createSignal<BranchInfo[]>([])
|
||||
const [branchesLoading, setBranchesLoading] = createSignal(false)
|
||||
const [defaultBranch, setDefaultBranch] = createSignal(props.defaultBaseBranch ?? "main")
|
||||
const [defaultBranch, setDefaultBranch] = createSignal(base() ?? "main")
|
||||
const [branchSearch, setBranchSearch] = createSignal("")
|
||||
|
||||
// --- New tab state ---
|
||||
@@ -367,18 +381,25 @@ export const NewWorktreeDialog: Component<{
|
||||
if (session.agents().length < 2) hidden.add("agents")
|
||||
if (variants().length === 0) hidden.add("variant")
|
||||
if (!sandboxVisible()) hidden.add("sandbox")
|
||||
if (!showProject()) hidden.add("project")
|
||||
return hidden
|
||||
},
|
||||
WORKTREE_PROMPT_COMMANDS,
|
||||
WORKTREE_PROMPT_SCOPE,
|
||||
[
|
||||
{
|
||||
name: "project",
|
||||
description: t("agentManager.dialog.project.select"),
|
||||
hints: [],
|
||||
action: () => setProjectOpen(true),
|
||||
},
|
||||
],
|
||||
)
|
||||
const onFocusPrompt = () => restorePrompt()
|
||||
window.addEventListener("focusPrompt", onFocusPrompt)
|
||||
onCleanup(() => window.removeEventListener("focusPrompt", onFocusPrompt))
|
||||
|
||||
onMount(() => {
|
||||
setBranchesLoading(true)
|
||||
vscode.postMessage({ type: "agentManager.requestBranches", projectId: props.projectId })
|
||||
// Resize textarea if restoring a cached prompt
|
||||
if (prompt()) adjustHeight()
|
||||
const focus = () => {
|
||||
@@ -394,6 +415,20 @@ export const NewWorktreeDialog: Component<{
|
||||
})
|
||||
})
|
||||
|
||||
// Branch data and base-branch defaults belong to the selected project. Other
|
||||
// dialog state deliberately survives project changes.
|
||||
createEffect(
|
||||
on(project, (id) => {
|
||||
setBranches([])
|
||||
setBranchSearch("")
|
||||
setHighlightedIndex(0)
|
||||
setBaseBranch(null)
|
||||
setDefaultBranch(id ? (props.defaultBase?.(id) ?? "main") : "main")
|
||||
setBranchesLoading(true)
|
||||
vscode.postMessage({ type: "agentManager.requestBranches", projectId: id })
|
||||
}),
|
||||
)
|
||||
|
||||
const effectiveBaseBranch = () => baseBranch() ?? defaultBranch()
|
||||
|
||||
const filteredBranches = createMemo(() => {
|
||||
@@ -427,10 +462,12 @@ export const NewWorktreeDialog: Component<{
|
||||
const allocations = isCompare ? allocationsToArray(modelAllocations()) : undefined
|
||||
const count = total()
|
||||
const sel = isCompare ? null : model()
|
||||
const target = project()
|
||||
if (target) props.onCreate?.(target)
|
||||
|
||||
vscode.postMessage({
|
||||
type: "agentManager.createMultiVersion",
|
||||
projectId: props.projectId,
|
||||
projectId: target,
|
||||
text,
|
||||
name: name().trim() || undefined,
|
||||
versions: count,
|
||||
@@ -579,12 +616,14 @@ export const NewWorktreeDialog: Component<{
|
||||
const importUnsub = vscode.onMessage((msg) => {
|
||||
if (msg.type === "agentManager.branches") {
|
||||
const ev = msg as AgentManagerBranchesMessage
|
||||
if (ev.projectId !== project()) return
|
||||
setBranches(ev.branches)
|
||||
if (!props.defaultBaseBranch) setDefaultBranch(ev.defaultBranch)
|
||||
if (!base()) setDefaultBranch(ev.defaultBranch)
|
||||
setBranchesLoading(false)
|
||||
}
|
||||
if (msg.type === "agentManager.importResult") {
|
||||
const ev = msg as AgentManagerImportResultMessage
|
||||
if (ev.projectId !== project()) return
|
||||
setPrPending(false)
|
||||
setImportPending(false)
|
||||
if (ev.success) {
|
||||
@@ -623,7 +662,9 @@ export const NewWorktreeDialog: Component<{
|
||||
const url = prUrl().trim()
|
||||
if (!url || isPending()) return
|
||||
setPrPending(true)
|
||||
vscode.postMessage({ type: "agentManager.importFromPR", projectId: props.projectId, url })
|
||||
const target = project()
|
||||
if (target) props.onCreate?.(target)
|
||||
vscode.postMessage({ type: "agentManager.importFromPR", projectId: target, url })
|
||||
}
|
||||
|
||||
const handleBranchSelect = (name: string) => {
|
||||
@@ -632,7 +673,9 @@ export const NewWorktreeDialog: Component<{
|
||||
setImportPending(true)
|
||||
setBranchOpen(false)
|
||||
setBranchSearch("")
|
||||
vscode.postMessage({ type: "agentManager.importFromBranch", projectId: props.projectId, branch: name })
|
||||
const target = project()
|
||||
if (target) props.onCreate?.(target)
|
||||
vscode.postMessage({ type: "agentManager.importFromBranch", projectId: target, branch: name })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -655,6 +698,62 @@ export const NewWorktreeDialog: Component<{
|
||||
>
|
||||
{t("agentManager.dialog.tab.import")}
|
||||
</button>
|
||||
{/* Project scope applies to both New and Import tabs. */}
|
||||
<Show when={showProject()}>
|
||||
<div class="am-nv-project-inline">
|
||||
<div class="am-selector-wrapper">
|
||||
<DeferredPopover
|
||||
open={projectOpen()}
|
||||
onOpenChange={setProjectOpen}
|
||||
placement="bottom-start"
|
||||
flip={false}
|
||||
sameWidth
|
||||
portal={false}
|
||||
deferDismiss
|
||||
class="am-dropdown"
|
||||
trigger={
|
||||
<button
|
||||
class="am-selector-trigger"
|
||||
type="button"
|
||||
aria-label={t("agentManager.dialog.project.select")}
|
||||
disabled={starting() || isPending()}
|
||||
>
|
||||
<span class="am-selector-left">
|
||||
<Icon name="folder" size="small" />
|
||||
<Show
|
||||
when={projectLabel()}
|
||||
fallback={
|
||||
<span class="am-selector-value am-selector-placeholder">
|
||||
{t("agentManager.dialog.project.select")}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span class="am-selector-value">{projectLabel()}</span>
|
||||
</Show>
|
||||
</span>
|
||||
<span class="am-selector-right">
|
||||
<Icon name="selector" size="small" />
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<ProjectSelect
|
||||
projects={projects()}
|
||||
selected={project()}
|
||||
onSelect={(id) => {
|
||||
track("project_select", { changed: id !== props.activeProjectId })
|
||||
setProject(id)
|
||||
setProjectOpen(false)
|
||||
}}
|
||||
labels={{
|
||||
untrusted: t("agentManager.dialog.project.untrusted"),
|
||||
missing: t("agentManager.dialog.project.missing"),
|
||||
}}
|
||||
/>
|
||||
</DeferredPopover>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* New tab */}
|
||||
|
||||
@@ -39,6 +39,8 @@ interface Props {
|
||||
selection?: string
|
||||
currentSessionID?: () => string | undefined
|
||||
mode: ModeRouter
|
||||
defaultBase?: (projectId: string) => string | undefined
|
||||
onCreate?: (projectId: string) => void
|
||||
busy?: (projectId: string, id: string) => boolean
|
||||
working?: (projectId: string, id: string) => boolean
|
||||
localBusy?: (projectId: string) => boolean
|
||||
@@ -141,12 +143,14 @@ export const ProjectList: Component<Props> = (props) => {
|
||||
return select({ projectId: item.projectId, kind: "session", sessionId: item.sessionId })
|
||||
}
|
||||
const newWorktree = (projectId: string) => {
|
||||
const state = props.states[projectId]
|
||||
dialog.show(() => (
|
||||
<NewWorktreeDialog
|
||||
projectId={projectId}
|
||||
projects={() => props.projects}
|
||||
activeProjectId={props.selectedProject}
|
||||
defaultBase={props.defaultBase}
|
||||
onCreate={props.onCreate}
|
||||
mode={props.mode}
|
||||
defaultBaseBranch={state?.defaultBaseBranch ?? props.local[projectId]?.branch}
|
||||
onClose={() => dialog.close()}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Project picker list for the New Worktree dialog
|
||||
|
||||
/** @jsxImportSource solid-js */
|
||||
|
||||
import { For, Show, type Component } from "solid-js"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import type { AgentProjectSnapshot } from "../src/types/messages"
|
||||
|
||||
interface ProjectSelectProps {
|
||||
projects: AgentProjectSnapshot[]
|
||||
selected?: string
|
||||
onSelect: (id: string) => void
|
||||
labels: { untrusted: string; missing: string }
|
||||
}
|
||||
|
||||
export const ProjectSelect: Component<ProjectSelectProps> = (props) => (
|
||||
<div class="am-dropdown-list">
|
||||
<For each={props.projects}>
|
||||
{(project) => {
|
||||
const blocked = () => !project.trusted || project.missing
|
||||
const hint = () => {
|
||||
if (project.missing) return props.labels.missing
|
||||
if (!project.trusted) return props.labels.untrusted
|
||||
return project.root
|
||||
}
|
||||
const icon = () => {
|
||||
if (project.missing) return "warning" as const
|
||||
if (!project.trusted) return "lock" as const
|
||||
return "folder" as const
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
class="am-project-option"
|
||||
classList={{ "am-project-option-active": props.selected === project.id }}
|
||||
disabled={blocked()}
|
||||
title={hint()}
|
||||
onClick={() => props.onSelect(project.id)}
|
||||
type="button"
|
||||
>
|
||||
<span class="am-project-option-left">
|
||||
<Icon name={icon()} size="small" />
|
||||
<span class="am-project-option-name">{project.label}</span>
|
||||
<span class="am-project-option-root">{project.root}</span>
|
||||
</span>
|
||||
<Show when={props.selected === project.id}>
|
||||
<Icon name="check-small" size="small" />
|
||||
</Show>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
@@ -2867,6 +2867,11 @@ body.am-wt-dragging-active * {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
[data-component="dialog"]:has(.am-nv-project-inline [data-component="popover-content"]) [data-slot="dialog-content"],
|
||||
[data-component="dialog"]:has(.am-nv-project-inline [data-component="popover-content"]) [data-slot="dialog-body"] {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.am-slash-command-dropdown {
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
@@ -3655,6 +3660,22 @@ body.am-wt-dragging-active * {
|
||||
color: var(--text-on-interactive-base) !important;
|
||||
}
|
||||
|
||||
/* Project scope selector — inline with the New/Import tabs */
|
||||
|
||||
.am-nv-project-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
flex: 0 1 260px;
|
||||
min-width: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.am-nv-project-inline .am-selector-wrapper {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Import tab layout */
|
||||
|
||||
.am-import-tab {
|
||||
@@ -3906,6 +3927,68 @@ body.am-wt-dragging-active * {
|
||||
color: var(--text-weaker);
|
||||
}
|
||||
|
||||
/* Project option rows in the New Worktree project dropdown. */
|
||||
|
||||
.am-project-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-base);
|
||||
font-size: var(--font-size-base);
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.am-project-option:hover:not(:disabled) {
|
||||
background: var(--surface-inset-base-hover);
|
||||
}
|
||||
|
||||
.am-project-option-active {
|
||||
background: var(--surface-inset-base);
|
||||
}
|
||||
|
||||
.am-project-option:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.am-project-option-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.am-project-option-left [data-component="icon"] {
|
||||
color: var(--text-weaker);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.am-project-option-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
max-width: 45%;
|
||||
}
|
||||
|
||||
.am-project-option-root {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
font-size: var(--kilo-font-size-11);
|
||||
color: var(--text-weaker);
|
||||
}
|
||||
|
||||
/* Import empty state */
|
||||
|
||||
.am-import-empty {
|
||||
|
||||
@@ -114,6 +114,9 @@ export const dict = {
|
||||
"agentManager.dialog.removeStaleWorktree.cancel": "إلغاء",
|
||||
"agentManager.dialog.removeStaleWorktree.confirm": "إزالة Worktree القديم",
|
||||
|
||||
"agentManager.dialog.project.select": "اختيار مشروع",
|
||||
"agentManager.dialog.project.untrusted": "يُرجى الوثوق بهذا المشروع من الشريط الجانبي أولًا",
|
||||
"agentManager.dialog.project.missing": "المستودع غير موجود",
|
||||
"agentManager.dialog.openWorktree": "شجرة عمل جديدة",
|
||||
"agentManager.dialog.configureWorktree": "تكوين Worktree جديد...",
|
||||
"agentManager.dialog.tab.new": "جديد",
|
||||
|
||||
@@ -116,6 +116,9 @@ export const dict = {
|
||||
"agentManager.dialog.removeStaleWorktree.cancel": "Cancelar",
|
||||
"agentManager.dialog.removeStaleWorktree.confirm": "Remover Worktree obsoleto",
|
||||
|
||||
"agentManager.dialog.project.select": "Selecionar projeto",
|
||||
"agentManager.dialog.project.untrusted": "Primeiro, confie neste projeto na barra lateral",
|
||||
"agentManager.dialog.project.missing": "Repositório não encontrado",
|
||||
"agentManager.dialog.openWorktree": "Novo Worktree",
|
||||
"agentManager.dialog.configureWorktree": "Configurar Novo Worktree...",
|
||||
"agentManager.dialog.tab.new": "Novo",
|
||||
|
||||
@@ -116,6 +116,9 @@ export const dict = {
|
||||
"agentManager.dialog.removeStaleWorktree.cancel": "Otkaži",
|
||||
"agentManager.dialog.removeStaleWorktree.confirm": "Ukloni zastarjeli Worktree",
|
||||
|
||||
"agentManager.dialog.project.select": "Odaberi projekat",
|
||||
"agentManager.dialog.project.untrusted": "Prvo vjeruj ovom projektu na bočnoj traci",
|
||||
"agentManager.dialog.project.missing": "Repozitorij nije pronađen",
|
||||
"agentManager.dialog.openWorktree": "Novi worktree",
|
||||
"agentManager.dialog.configureWorktree": "Konfiguriši Novi Worktree...",
|
||||
"agentManager.dialog.tab.new": "Novo",
|
||||
|
||||
@@ -117,6 +117,9 @@ export const dict = {
|
||||
"agentManager.dialog.removeStaleWorktree.cancel": "Annuller",
|
||||
"agentManager.dialog.removeStaleWorktree.confirm": "Fjern forældet Worktree",
|
||||
|
||||
"agentManager.dialog.project.select": "Vælg projekt",
|
||||
"agentManager.dialog.project.untrusted": "Godkend først dette projekt i sidepanelet",
|
||||
"agentManager.dialog.project.missing": "Repository ikke fundet",
|
||||
"agentManager.dialog.openWorktree": "Ny Worktree",
|
||||
"agentManager.dialog.configureWorktree": "Konfigurer Nyt Worktree...",
|
||||
"agentManager.dialog.tab.new": "Ny",
|
||||
|
||||
@@ -118,6 +118,9 @@ export const dict = {
|
||||
"agentManager.dialog.removeStaleWorktree.cancel": "Abbrechen",
|
||||
"agentManager.dialog.removeStaleWorktree.confirm": "Veralteten Worktree entfernen",
|
||||
|
||||
"agentManager.dialog.project.select": "Projekt auswählen",
|
||||
"agentManager.dialog.project.untrusted": "Vertrauen Sie diesem Projekt zuerst in der Seitenleiste",
|
||||
"agentManager.dialog.project.missing": "Repository nicht gefunden",
|
||||
"agentManager.dialog.openWorktree": "Neuer Worktree",
|
||||
"agentManager.dialog.configureWorktree": "Neuen Worktree konfigurieren...",
|
||||
"agentManager.dialog.tab.new": "Neu",
|
||||
|
||||
@@ -125,6 +125,9 @@ export const dict = {
|
||||
"agentManager.dialog.tab.new": "New",
|
||||
"agentManager.dialog.tab.import": "Import",
|
||||
"agentManager.dialog.namePlaceholder": "Worktree name (optional)",
|
||||
"agentManager.dialog.project.select": "Select project",
|
||||
"agentManager.dialog.project.untrusted": "Trust this project in the sidebar first",
|
||||
"agentManager.dialog.project.missing": "Repository not found",
|
||||
"agentManager.dialog.promptPlaceholder.mac": "Type a message (\u2318Enter to send)",
|
||||
"agentManager.dialog.promptPlaceholder.other": "Type a message (Ctrl+Enter to send)",
|
||||
"agentManager.dialog.advancedOptions": "Advanced options",
|
||||
|
||||
@@ -117,6 +117,9 @@ export const dict = {
|
||||
"agentManager.dialog.removeStaleWorktree.cancel": "Cancelar",
|
||||
"agentManager.dialog.removeStaleWorktree.confirm": "Eliminar Worktree obsoleto",
|
||||
|
||||
"agentManager.dialog.project.select": "Seleccionar proyecto",
|
||||
"agentManager.dialog.project.untrusted": "Confía primero en este proyecto desde la barra lateral",
|
||||
"agentManager.dialog.project.missing": "Repositorio no encontrado",
|
||||
"agentManager.dialog.openWorktree": "Nuevo Worktree",
|
||||
"agentManager.dialog.configureWorktree": "Configurar Nuevo Worktree...",
|
||||
"agentManager.dialog.tab.new": "Nuevo",
|
||||
|
||||
@@ -121,6 +121,9 @@ export const dict = {
|
||||
"agentManager.dialog.removeStaleWorktree.cancel": "لغو",
|
||||
"agentManager.dialog.removeStaleWorktree.confirm": "حذف Worktree قدیمی",
|
||||
|
||||
"agentManager.dialog.project.select": "انتخاب پروژه",
|
||||
"agentManager.dialog.project.untrusted": "ابتدا در نوار کناری به این پروژه اعتماد کنید",
|
||||
"agentManager.dialog.project.missing": "مخزن یافت نشد",
|
||||
"agentManager.dialog.openWorktree": "Worktree جدید",
|
||||
"agentManager.dialog.tab.new": "جدید",
|
||||
"agentManager.dialog.tab.import": "وارد کردن",
|
||||
|
||||
@@ -117,6 +117,9 @@ export const dict = {
|
||||
"agentManager.dialog.removeStaleWorktree.cancel": "Annuler",
|
||||
"agentManager.dialog.removeStaleWorktree.confirm": "Supprimer le Worktree obsolète",
|
||||
|
||||
"agentManager.dialog.project.select": "Sélectionner un projet",
|
||||
"agentManager.dialog.project.untrusted": "Approuvez d'abord ce projet dans la barre latérale",
|
||||
"agentManager.dialog.project.missing": "Dépôt introuvable",
|
||||
"agentManager.dialog.openWorktree": "Nouveau worktree",
|
||||
"agentManager.dialog.configureWorktree": "Configurer un Nouveau Worktree...",
|
||||
"agentManager.dialog.tab.new": "Nouveau",
|
||||
|
||||
@@ -123,6 +123,9 @@ export const dict = {
|
||||
"agentManager.dialog.removeStaleWorktree.cancel": "Annulla",
|
||||
"agentManager.dialog.removeStaleWorktree.confirm": "Rimuovi worktree obsoleto",
|
||||
|
||||
"agentManager.dialog.project.select": "Seleziona progetto",
|
||||
"agentManager.dialog.project.untrusted": "Prima, fidati di questo progetto nella barra laterale",
|
||||
"agentManager.dialog.project.missing": "Repository non trovata",
|
||||
"agentManager.dialog.openWorktree": "Nuovo worktree",
|
||||
"agentManager.dialog.tab.new": "Nuovo",
|
||||
"agentManager.dialog.tab.import": "Importa",
|
||||
|
||||
@@ -117,6 +117,9 @@ export const dict = {
|
||||
"agentManager.dialog.removeStaleWorktree.cancel": "キャンセル",
|
||||
"agentManager.dialog.removeStaleWorktree.confirm": "無効な Worktree を削除",
|
||||
|
||||
"agentManager.dialog.project.select": "プロジェクトを選択",
|
||||
"agentManager.dialog.project.untrusted": "まずサイドバーでこのプロジェクトを信頼してください",
|
||||
"agentManager.dialog.project.missing": "リポジトリが見つかりません",
|
||||
"agentManager.dialog.openWorktree": "新規ワークツリー",
|
||||
"agentManager.dialog.configureWorktree": "新規 Worktree の構成...",
|
||||
"agentManager.dialog.tab.new": "新規",
|
||||
|
||||
@@ -115,6 +115,9 @@ export const dict = {
|
||||
"agentManager.dialog.removeStaleWorktree.cancel": "취소",
|
||||
"agentManager.dialog.removeStaleWorktree.confirm": "오래된 Worktree 제거",
|
||||
|
||||
"agentManager.dialog.project.select": "프로젝트 선택",
|
||||
"agentManager.dialog.project.untrusted": "먼저 사이드바에서 이 프로젝트를 신뢰하세요",
|
||||
"agentManager.dialog.project.missing": "저장소를 찾을 수 없음",
|
||||
"agentManager.dialog.openWorktree": "새 워크트리",
|
||||
"agentManager.dialog.configureWorktree": "새 Worktree 구성...",
|
||||
"agentManager.dialog.tab.new": "새로 만들기",
|
||||
|
||||
@@ -122,6 +122,9 @@ export const dict = {
|
||||
"agentManager.dialog.removeStaleWorktree.cancel": "Annuleren",
|
||||
"agentManager.dialog.removeStaleWorktree.confirm": "Verouderde worktree verwijderen",
|
||||
|
||||
"agentManager.dialog.project.select": "Project selecteren",
|
||||
"agentManager.dialog.project.untrusted": "Vertrouw dit project eerst in de zijbalk",
|
||||
"agentManager.dialog.project.missing": "Repository niet gevonden",
|
||||
"agentManager.dialog.openWorktree": "Nieuwe worktree",
|
||||
"agentManager.dialog.configureWorktree": "Nieuwe Worktree Configureren...",
|
||||
"agentManager.dialog.tab.new": "Nieuw",
|
||||
|
||||
@@ -115,6 +115,9 @@ export const dict = {
|
||||
"agentManager.dialog.removeStaleWorktree.cancel": "Avbryt",
|
||||
"agentManager.dialog.removeStaleWorktree.confirm": "Fjern utdatert Worktree",
|
||||
|
||||
"agentManager.dialog.project.select": "Velg prosjekt",
|
||||
"agentManager.dialog.project.untrusted": "Stol på dette prosjektet i sidepanelet først",
|
||||
"agentManager.dialog.project.missing": "Repository ikke funnet",
|
||||
"agentManager.dialog.openWorktree": "Ny worktree",
|
||||
"agentManager.dialog.configureWorktree": "Konfigurer Nytt Worktree...",
|
||||
"agentManager.dialog.tab.new": "Ny",
|
||||
|
||||
@@ -117,6 +117,9 @@ export const dict = {
|
||||
"agentManager.dialog.removeStaleWorktree.cancel": "Anuluj",
|
||||
"agentManager.dialog.removeStaleWorktree.confirm": "Usuń nieaktualny Worktree",
|
||||
|
||||
"agentManager.dialog.project.select": "Wybierz projekt",
|
||||
"agentManager.dialog.project.untrusted": "Najpierw zaufaj temu projektowi na pasku bocznym",
|
||||
"agentManager.dialog.project.missing": "Nie znaleziono repozytorium",
|
||||
"agentManager.dialog.openWorktree": "Nowy Worktree",
|
||||
"agentManager.dialog.configureWorktree": "Skonfiguruj Nowe Worktree...",
|
||||
"agentManager.dialog.tab.new": "Nowy",
|
||||
|
||||
@@ -117,6 +117,9 @@ export const dict = {
|
||||
"agentManager.dialog.removeStaleWorktree.cancel": "Отмена",
|
||||
"agentManager.dialog.removeStaleWorktree.confirm": "Удалить устаревший Worktree",
|
||||
|
||||
"agentManager.dialog.project.select": "Выбрать проект",
|
||||
"agentManager.dialog.project.untrusted": "Сначала подтвердите доверие к этому проекту на боковой панели",
|
||||
"agentManager.dialog.project.missing": "Репозиторий не найден",
|
||||
"agentManager.dialog.openWorktree": "Новый worktree",
|
||||
"agentManager.dialog.configureWorktree": "Настроить новое Worktree...",
|
||||
"agentManager.dialog.tab.new": "Новый",
|
||||
|
||||
@@ -112,6 +112,9 @@ export const dict = {
|
||||
"agentManager.dialog.removeStaleWorktree.cancel": "ยกเลิก",
|
||||
"agentManager.dialog.removeStaleWorktree.confirm": "ลบ Worktree ที่ล้าสมัย",
|
||||
|
||||
"agentManager.dialog.project.select": "เลือกโปรเจกต์",
|
||||
"agentManager.dialog.project.untrusted": "โปรดเชื่อถือโปรเจกต์นี้ในแถบด้านข้างก่อน",
|
||||
"agentManager.dialog.project.missing": "ไม่พบ Repository",
|
||||
"agentManager.dialog.openWorktree": "Worktree ใหม่",
|
||||
"agentManager.dialog.configureWorktree": "กำหนดค่า Worktree ใหม่...",
|
||||
"agentManager.dialog.tab.new": "ใหม่",
|
||||
|
||||
@@ -123,6 +123,9 @@ export const dict = {
|
||||
"agentManager.dialog.removeStaleWorktree.cancel": "İptal",
|
||||
"agentManager.dialog.removeStaleWorktree.confirm": "Eskimiş worktree'yi kaldır",
|
||||
|
||||
"agentManager.dialog.project.select": "Proje seç",
|
||||
"agentManager.dialog.project.untrusted": "Önce kenar çubuğunda bu projeye güvenin",
|
||||
"agentManager.dialog.project.missing": "Depo bulunamadı",
|
||||
"agentManager.dialog.openWorktree": "Yeni Worktree",
|
||||
"agentManager.dialog.configureWorktree": "Yeni Worktree Yapılandır...",
|
||||
"agentManager.dialog.tab.new": "Yeni",
|
||||
|
||||
@@ -124,6 +124,9 @@ export const dict = {
|
||||
"agentManager.dialog.removeStaleWorktree.cancel": "Скасувати",
|
||||
"agentManager.dialog.removeStaleWorktree.confirm": "Видалити застаріле робоче дерево",
|
||||
|
||||
"agentManager.dialog.project.select": "Вибрати проєкт",
|
||||
"agentManager.dialog.project.untrusted": "Спочатку підтвердьте, що довіряєте цьому проєкту, на бічній панелі",
|
||||
"agentManager.dialog.project.missing": "Репозиторій не знайдено",
|
||||
"agentManager.dialog.openWorktree": "Нове робоче дерево",
|
||||
"agentManager.dialog.configureWorktree": "Налаштувати нове Worktree...",
|
||||
"agentManager.dialog.tab.new": "Нове",
|
||||
|
||||
@@ -111,6 +111,9 @@ export const dict = {
|
||||
"agentManager.dialog.removeStaleWorktree.cancel": "取消",
|
||||
"agentManager.dialog.removeStaleWorktree.confirm": "移除失效 Worktree",
|
||||
|
||||
"agentManager.dialog.project.select": "选择项目",
|
||||
"agentManager.dialog.project.untrusted": "请先在侧边栏中信任此项目",
|
||||
"agentManager.dialog.project.missing": "未找到仓库",
|
||||
"agentManager.dialog.openWorktree": "新建工作树",
|
||||
"agentManager.dialog.configureWorktree": "配置新 Worktree...",
|
||||
"agentManager.dialog.tab.new": "新建",
|
||||
|
||||
@@ -111,6 +111,9 @@ export const dict = {
|
||||
"agentManager.dialog.removeStaleWorktree.cancel": "取消",
|
||||
"agentManager.dialog.removeStaleWorktree.confirm": "移除失效 Worktree",
|
||||
|
||||
"agentManager.dialog.project.select": "選擇專案",
|
||||
"agentManager.dialog.project.untrusted": "請先在側邊欄信任此專案",
|
||||
"agentManager.dialog.project.missing": "找不到儲存庫",
|
||||
"agentManager.dialog.openWorktree": "新建工作樹",
|
||||
"agentManager.dialog.configureWorktree": "配置新 Worktree...",
|
||||
"agentManager.dialog.tab.new": "新建",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
/**
|
||||
* Tracks a cross-project worktree creation so the target project is activated
|
||||
* once its worktree is ready, and abandons the pending activation when the
|
||||
* creation fails or completes through another flow.
|
||||
*/
|
||||
export function usePendingCreate(
|
||||
active: () => string | undefined,
|
||||
activate: (projectId: string, worktreeId: string) => void,
|
||||
) {
|
||||
const [pending, setPending] = createSignal<{ projectId: string }>()
|
||||
|
||||
const schedule = (projectId: string) => {
|
||||
if (projectId === active()) return
|
||||
if (pending()) return
|
||||
setPending({ projectId })
|
||||
}
|
||||
|
||||
const abandon = (projectId?: string) => {
|
||||
if (pending()?.projectId === projectId) setPending(undefined)
|
||||
}
|
||||
|
||||
const setup = (ev: { status: string; projectId?: string; worktreeId?: string }) => {
|
||||
if (pending()?.projectId !== ev.projectId) return
|
||||
if (ev.status === "ready" && ev.projectId && ev.worktreeId) {
|
||||
setPending(undefined)
|
||||
activate(ev.projectId, ev.worktreeId)
|
||||
}
|
||||
if (ev.status === "error") setPending(undefined)
|
||||
}
|
||||
|
||||
return { schedule, abandon, setup }
|
||||
}
|
||||
@@ -58,6 +58,7 @@ export function useSlashCommand(
|
||||
exclude?: Set<string> | Accessor<Set<string>>,
|
||||
include?: Set<string> | Accessor<Set<string>>,
|
||||
scope?: string,
|
||||
extra?: SlashCommandEntry[],
|
||||
): SlashCommand {
|
||||
const [server, setServer] = createSignal<SlashCommandInfo[]>([])
|
||||
const [query, setQuery] = createSignal<string | null>(null)
|
||||
@@ -191,6 +192,7 @@ export function useSlashCommand(
|
||||
},
|
||||
},
|
||||
]
|
||||
all.push(...(extra ?? []))
|
||||
|
||||
const excluded = () => {
|
||||
if (typeof exclude === "function") return exclude()
|
||||
|
||||
@@ -26,8 +26,16 @@ import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
|
||||
import { ContextMenu } from "@kilocode/kilo-ui/context-menu"
|
||||
import { ThinkingSelectorBase } from "../components/shared/ThinkingSelector"
|
||||
import { DeferredPopover } from "../components/shared/DeferredPopover"
|
||||
import { ProjectSelect } from "../../agent-manager/ProjectSelect"
|
||||
import { createSignal, onCleanup, onMount, type JSX } from "solid-js"
|
||||
import type { WorktreeFileDiff, WorktreeState, WorktreeGitStats, PRStatus } from "../types/messages"
|
||||
import type {
|
||||
AgentProjectSnapshot,
|
||||
WorktreeFileDiff,
|
||||
WorktreeState,
|
||||
WorktreeGitStats,
|
||||
PRStatus,
|
||||
} from "../types/messages"
|
||||
import type { ReviewComment } from "../../diff-viewer/review-comments"
|
||||
import { createModeRouter } from "../../agent-manager/mode-router"
|
||||
import "../../agent-manager/agent-manager.css"
|
||||
@@ -1075,6 +1083,115 @@ export const NewWorktreeVariantDropdown1280: Story = {
|
||||
),
|
||||
}
|
||||
|
||||
const projectPickerProjects: AgentProjectSnapshot[] = [
|
||||
{
|
||||
id: "project-main",
|
||||
root: "/workspace/kilocode",
|
||||
label: "kilocode",
|
||||
pinned: true,
|
||||
active: true,
|
||||
expanded: true,
|
||||
initialized: true,
|
||||
trusted: true,
|
||||
missing: false,
|
||||
},
|
||||
{
|
||||
id: "project-cloud",
|
||||
root: "/workspace/cloud",
|
||||
label: "cloud",
|
||||
pinned: false,
|
||||
active: false,
|
||||
expanded: false,
|
||||
initialized: true,
|
||||
trusted: true,
|
||||
missing: false,
|
||||
},
|
||||
{
|
||||
id: "project-untrusted",
|
||||
root: "/workspace/sample-app",
|
||||
label: "sample-app",
|
||||
pinned: false,
|
||||
active: false,
|
||||
expanded: false,
|
||||
initialized: false,
|
||||
trusted: false,
|
||||
missing: false,
|
||||
},
|
||||
]
|
||||
|
||||
export const NewWorktreeProjectDropdown: Story = {
|
||||
name: "NewWorktreeDialog — project dropdown open",
|
||||
parameters: { layout: "fullscreen" },
|
||||
render: () => (
|
||||
<StoryProviders noPadding>
|
||||
<div style={{ height: "100vh", display: "flex", "flex-direction": "column" }}>
|
||||
<div data-component="dialog" data-fit="true">
|
||||
<div data-slot="dialog-container">
|
||||
<div data-slot="dialog-content">
|
||||
<div data-slot="dialog-header">
|
||||
<div data-slot="dialog-title">New Worktree</div>
|
||||
</div>
|
||||
<div data-slot="dialog-body">
|
||||
<div class="am-tab-switcher">
|
||||
<button class="am-tab-switcher-pill am-tab-switcher-pill-active" type="button">
|
||||
New
|
||||
</button>
|
||||
<button class="am-tab-switcher-pill" type="button">
|
||||
Import
|
||||
</button>
|
||||
<div class="am-nv-project-inline">
|
||||
<div class="am-selector-wrapper">
|
||||
<DeferredPopover
|
||||
open
|
||||
onOpenChange={() => undefined}
|
||||
placement="bottom-start"
|
||||
flip={false}
|
||||
sameWidth
|
||||
portal={false}
|
||||
deferDismiss
|
||||
class="am-dropdown"
|
||||
trigger={
|
||||
<button class="am-selector-trigger" type="button" aria-label="Select project">
|
||||
<span class="am-selector-left">
|
||||
<Icon name="folder" size="small" />
|
||||
<span class="am-selector-value">kilocode</span>
|
||||
</span>
|
||||
<span class="am-selector-right">
|
||||
<Icon name="selector" size="small" />
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<ProjectSelect
|
||||
projects={projectPickerProjects}
|
||||
selected="project-main"
|
||||
onSelect={() => undefined}
|
||||
labels={{
|
||||
untrusted: "Trust this project in the sidebar first",
|
||||
missing: "Repository not found",
|
||||
}}
|
||||
/>
|
||||
</DeferredPopover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="am-nv-dialog" style={{ "max-height": "520px" }}>
|
||||
<div class="am-nv-dialog-content">
|
||||
<div style={{ height: "420px", "flex-shrink": 0 }} />
|
||||
<div class="am-nv-version-bar">
|
||||
<span class="am-nv-config-label">VERSIONS</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StoryProviders>
|
||||
),
|
||||
}
|
||||
|
||||
const searchSection = { id: "polish", name: "Polish", color: "Blue", order: 0, collapsed: false }
|
||||
const slackedSection = { id: "slacked", name: "SLACKED", color: "Yellow", order: 1, collapsed: false }
|
||||
const sidebarSearchItems: SidebarSearchItem[] = [
|
||||
@@ -1198,12 +1315,7 @@ export const SidebarSearchOpen: Story = {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { ProjectList } from "../../agent-manager/ProjectList"
|
||||
import type {
|
||||
AgentManagerStateMessage,
|
||||
AgentProjectSnapshot,
|
||||
LocalGitStats,
|
||||
ProjectSessionInfo,
|
||||
} from "../types/messages"
|
||||
import type { AgentManagerStateMessage, LocalGitStats, ProjectSessionInfo } from "../types/messages"
|
||||
|
||||
const projectA: AgentProjectSnapshot = {
|
||||
id: "prj-aaaa1111aaaa",
|
||||
|
||||
@@ -952,6 +952,7 @@ export interface AgentManagerBranchesMessage {
|
||||
// Agent Manager Import tab: result feedback (extension → webview)
|
||||
export interface AgentManagerImportResultMessage {
|
||||
type: "agentManager.importResult"
|
||||
projectId?: string
|
||||
success: boolean
|
||||
message: string
|
||||
errorCode?: WorktreeErrorCode
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { isSqlError } from "effect/unstable/sql/SqlError"
|
||||
import { isBusy } from "@opencode-ai/core/kilocode/sqlite-error"
|
||||
|
||||
export { isBusy }
|
||||
|
||||
export const busyMessage = "Database is busy. Please try again in a moment."
|
||||
|
||||
export function isBusy(error: unknown) {
|
||||
return isSqlError(error) && error.reason._tag === "LockTimeoutError"
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ const WireParams = Schema.Struct({
|
||||
),
|
||||
filter: Schema.optional(ListParams.fields.filter),
|
||||
sessionID: Schema.optional(
|
||||
SessionID.annotate({ description: "For move, use a session ID returned by action=list." }),
|
||||
Schema.String.annotate({ description: "For move, use a session ID returned by action=list (IDs start with ses_)." }),
|
||||
),
|
||||
prompt: Schema.optional(PromptParams.fields.prompt),
|
||||
sectionID: Schema.optional(MoveParams.fields.sectionID),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, ManagedRuntime, Queue } from "effect"
|
||||
import { Effect, Layer, ManagedRuntime, Queue, Schema } from "effect"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AgentManagerTool } from "../../src/kilocode/tool/agent-manager"
|
||||
import { AgentManagerTool, Params } from "../../src/kilocode/tool/agent-manager"
|
||||
import { AgentManagerEvent, type AgentManagerStart } from "../../src/kilocode/agent-manager/event"
|
||||
import { AgentManager } from "../../src/kilocode/agent-manager/service"
|
||||
import { Bus } from "../../src/bus"
|
||||
@@ -164,8 +164,9 @@ describe("agent_manager tool", () => {
|
||||
expect(action && typeof action === "object" ? action.description : undefined).toContain("Use list first")
|
||||
expect(action && typeof action === "object" ? action.description : undefined).toContain("Never edit")
|
||||
expect(schema.properties?.sessionID).toEqual(
|
||||
expect.objectContaining({ description: expect.stringContaining("returned by action=list") }),
|
||||
expect.objectContaining({ description: expect.stringContaining("IDs start with ses_") }),
|
||||
)
|
||||
expect(schema.properties?.sessionID).not.toHaveProperty("pattern")
|
||||
expect(schema.properties?.sectionID).toEqual(
|
||||
expect.objectContaining({ description: expect.stringContaining("Use null to unassign") }),
|
||||
)
|
||||
@@ -186,6 +187,11 @@ describe("agent_manager tool", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps session ID validation local", () => {
|
||||
expect(Schema.is(Params)({ action: "stop", sessionID: "ses_target" })).toBe(true)
|
||||
expect(Schema.is(Params)({ action: "stop", sessionID: "invalid" })).toBe(false)
|
||||
})
|
||||
|
||||
test("asks for agent_manager permission", async () => {
|
||||
const tool = await init()
|
||||
const calls: unknown[] = []
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Cause } from "effect"
|
||||
import { LockTimeoutError, SqlError, UnknownError } from "effect/unstable/sql/SqlError"
|
||||
import { EffectDrizzleQueryError } from "drizzle-orm/effect-core/errors"
|
||||
import { busyMessage, isBusy } from "@/kilocode/database/sqlite-error"
|
||||
|
||||
describe("SQLite errors", () => {
|
||||
@@ -27,4 +29,22 @@ describe("SQLite errors", () => {
|
||||
|
||||
expect(isBusy(error)).toBe(false)
|
||||
})
|
||||
|
||||
test("recognizes lock timeouts wrapped by Drizzle", () => {
|
||||
const error = new EffectDrizzleQueryError({
|
||||
query: "update credential set value = ?",
|
||||
params: ["<redacted>"],
|
||||
cause: Cause.fail(
|
||||
new SqlError({
|
||||
reason: new LockTimeoutError({
|
||||
cause: new Error("database is locked"),
|
||||
message: "Failed to execute statement",
|
||||
operation: "execute",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
expect(isBusy(error)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -64,6 +64,23 @@ export function eventLocation(metadata: { directory: string; workspace?: string
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
// kilocode_change start - suppress only refreshes canceled by normal TUI disposal
|
||||
export function shouldReportDefaultLocationFailure(reason: unknown, disposed: boolean) {
|
||||
if (!disposed) return true
|
||||
return !(typeof reason === "object" && reason !== null && "name" in reason && reason.name === "AbortError")
|
||||
}
|
||||
|
||||
export async function reportDefaultLocationFailure(
|
||||
promise: Promise<void>,
|
||||
disposed: () => boolean,
|
||||
report: (reason: unknown) => void = (reason) => console.error("Failed to refresh default location data", reason),
|
||||
) {
|
||||
return promise.catch((reason) => {
|
||||
if (shouldReportDefaultLocationFailure(reason, disposed())) report(reason)
|
||||
})
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
name: "Data",
|
||||
init: () => {
|
||||
@@ -575,21 +592,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
},
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void Promise.allSettled([
|
||||
result.location.refresh(),
|
||||
result.location.agent.refresh(),
|
||||
result.location.integration.refresh(),
|
||||
result.location.model.refresh(),
|
||||
result.location.provider.refresh(),
|
||||
result.location.reference.refresh(),
|
||||
result.location.command.refresh(),
|
||||
result.location.skill.refresh(),
|
||||
]).then((settled) => {
|
||||
for (const failure of settled.filter((item) => item.status === "rejected"))
|
||||
console.error("Failed to refresh default location data", failure.reason)
|
||||
})
|
||||
// kilocode_change start - classify each rejection when it occurs so later disposal cannot hide earlier failures
|
||||
let disposed = false
|
||||
onCleanup(() => {
|
||||
disposed = true
|
||||
})
|
||||
// kilocode_change end
|
||||
// kilocode_change start
|
||||
onMount(() => {
|
||||
void Promise.all([
|
||||
reportDefaultLocationFailure(result.location.refresh(), () => disposed),
|
||||
reportDefaultLocationFailure(result.location.agent.refresh(), () => disposed),
|
||||
reportDefaultLocationFailure(result.location.integration.refresh(), () => disposed),
|
||||
reportDefaultLocationFailure(result.location.model.refresh(), () => disposed),
|
||||
reportDefaultLocationFailure(result.location.provider.refresh(), () => disposed),
|
||||
reportDefaultLocationFailure(result.location.reference.refresh(), () => disposed),
|
||||
reportDefaultLocationFailure(result.location.command.refresh(), () => disposed),
|
||||
reportDefaultLocationFailure(result.location.skill.refresh(), () => disposed),
|
||||
])
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
return result
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { eventLocation } from "../../src/context/data"
|
||||
import { eventLocation, reportDefaultLocationFailure, shouldReportDefaultLocationFailure } from "../../src/context/data"
|
||||
|
||||
describe("eventLocation", () => {
|
||||
test("uses the default location for global events", () => {
|
||||
@@ -13,3 +13,47 @@ describe("eventLocation", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("shouldReportDefaultLocationFailure", () => {
|
||||
test("suppresses lifecycle aborts after disposal", () => {
|
||||
expect(shouldReportDefaultLocationFailure(new DOMException("aborted", "AbortError"), true)).toBe(false)
|
||||
})
|
||||
|
||||
test("suppresses cross-realm-shaped lifecycle aborts after disposal", () => {
|
||||
expect(shouldReportDefaultLocationFailure({ name: "AbortError" }, true)).toBe(false)
|
||||
})
|
||||
|
||||
test("reports aborts while mounted", () => {
|
||||
expect(shouldReportDefaultLocationFailure(new DOMException("aborted", "AbortError"), false)).toBe(true)
|
||||
})
|
||||
|
||||
test("reports non-abort failures after disposal", () => {
|
||||
expect(shouldReportDefaultLocationFailure(new Error("network failed"), true)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("reportDefaultLocationFailure", () => {
|
||||
test("reports a mounted abort immediately even if disposal happens before other refreshes settle", async () => {
|
||||
let disposed = false
|
||||
const reports: unknown[] = []
|
||||
const abort = Promise.reject(new DOMException("aborted", "AbortError"))
|
||||
const pending = Promise.withResolvers<void>()
|
||||
|
||||
const first = reportDefaultLocationFailure(
|
||||
abort,
|
||||
() => disposed,
|
||||
(reason) => reports.push(reason),
|
||||
)
|
||||
await first
|
||||
disposed = true
|
||||
pending.reject(new DOMException("aborted", "AbortError"))
|
||||
await reportDefaultLocationFailure(
|
||||
pending.promise,
|
||||
() => disposed,
|
||||
(reason) => reports.push(reason),
|
||||
)
|
||||
|
||||
expect(reports).toHaveLength(1)
|
||||
expect(reports[0]).toBeInstanceOf(DOMException)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user