Files
kilocode/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx
T
2026-07-08 20:48:27 +02:00

894 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// New Worktree dialog — prompt, versions, model, mode, import tab
/** @jsxImportSource solid-js */
import { type Component, For, Show, createSignal, createEffect, createMemo, onMount, onCleanup } from "solid-js"
import type { AgentManagerBranchesMessage, AgentManagerImportResultMessage, BranchInfo } from "../src/types/messages"
import { Dialog } from "@kilocode/kilo-ui/dialog"
import { showToast } from "@kilocode/kilo-ui/toast"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Button } from "@kilocode/kilo-ui/button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { DeferredPopover } from "../src/components/shared/DeferredPopover"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { useVSCode } from "../src/context/vscode"
import { useServer } from "../src/context/server"
import { useSession } from "../src/context/session"
import { useProvider } from "../src/context/provider"
import { useConfig } from "../src/context/config"
import { ModelSelectorBase } from "../src/components/shared/ModelSelector"
import { ModeSwitcherBase } from "../src/components/shared/ModeSwitcher"
import { SpeechToTextButton } from "../src/components/speech-to-text/SpeechToTextButton"
import { canUseSpeechToText, selectedSpeechToTextModel } from "../src/components/speech-to-text/availability"
import { ThinkingSelectorBase } from "../src/components/shared/ThinkingSelector"
import { SandboxButtonBase, SandboxTooltipContent } from "../src/components/shared/SandboxButton"
import {
MultiModelSelector,
type ModelAllocations,
MAX_MULTI_VERSIONS,
totalAllocations,
allocationsToArray,
} from "./MultiModelSelector"
import { useLanguage } from "../src/context/language"
import { useImageAttachments, type ImageAttachment } from "../src/hooks/useImageAttachments"
import { useSpeechToText } from "../src/components/speech-to-text/useSpeechToText"
import { convertToMentionPath } from "../src/utils/path-mentions"
import { insertSpacedText } from "../src/components/chat/prompt-input-utils"
import { BranchSelect, BranchSelectPopover } from "../src/components/shared/BranchSelect"
import { tracker } from "./telemetry"
type VersionCount = 1 | 2 | 3 | 4
const VERSION_OPTIONS: VersionCount[] = [1, 2, 3, 4]
type DialogTab = "new" | "import"
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
function sanitizeSegment(text: string, maxLength = 50): string {
return text
.toLowerCase()
.trim()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9._+@-]/g, "")
.replace(/\.{2,}/g, ".")
.replace(/@\{/g, "@")
.replace(/-+/g, "-")
.replace(/^[-.]|[-.]+$/g, "")
.replace(/\.lock$/g, "")
.slice(0, maxLength)
}
function sanitizeBranchName(name: string): string {
return name
.split("/")
.map((s) => sanitizeSegment(s))
.filter(Boolean)
.join("/")
}
export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBranch?: string }> = (props) => {
const { t } = useLanguage()
const vscode = useVSCode()
const server = useServer()
const session = useSession()
const provider = useProvider()
const { config, features } = useConfig()
const metrics = tracker(vscode)
const track = (button: string, properties?: Record<string, string | number | boolean | undefined>) =>
metrics.track(button, "configure_worktree_dialog", properties)
const click = metrics.click
const [tab, setTab] = createSignal<DialogTab>("new")
// --- 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 [branchSearch, setBranchSearch] = createSignal("")
// --- New tab state ---
const [name, setName] = createSignal("")
const cached = vscode.getState<Record<string, unknown>>()
const [prompt, setPrompt] = createSignal((cached?.advancedDialogPrompt as string) ?? "")
const [versions, setVersions] = createSignal<VersionCount>(1)
const [model, setModel] = createSignal<{ providerID: string; modelID: string } | null>(session.configModel())
const [compareMode, setCompareMode] = createSignal(false)
const [modelAllocations, setModelAllocations] = createSignal<ModelAllocations>(new Map())
const [agent, setAgent] = createSignal(session.selectedAgent())
const [starting, setStarting] = createSignal(false)
const [showAdvanced, setShowAdvanced] = createSignal(false)
const [branchName, setBranchName] = createSignal("")
const [baseBranch, setBaseBranch] = createSignal<string | null>(null)
const [baseBranchOpen, setBaseBranchOpen] = createSignal(false)
const [compareOpen, setCompareOpen] = createSignal(false)
const [highlightedIndex, setHighlightedIndex] = createSignal(0)
const [variant, setVariant] = createSignal<string | undefined>(session.currentVariant())
const [sandbox, setSandbox] = createSignal<boolean | undefined>()
const [sandboxDefault, setSandboxDefault] = createSignal<boolean | undefined>()
const [sandboxOverride, setSandboxOverride] = createSignal<boolean | undefined>()
const [sandboxAvailable, setSandboxAvailable] = createSignal(true)
const [sandboxReason, setSandboxReason] = createSignal<string | undefined>()
const [sandboxRevision, setSandboxRevision] = createSignal(-1)
const sandboxRequestID = crypto.randomUUID()
const sandboxVisible = () => features().sandboxControls
const speech = useSpeechToText(vscode, server, { t })
const canUseSpeech = () => canUseSpeechToText(config(), provider.authStates())
const speechModel = () => selectedSpeechToTextModel(config())
// Variant list for the currently selected model
const variants = createMemo(() => {
const sel = model()
if (!sel) return []
const found = provider.findModel(sel)
if (!found?.variants) return []
return Object.keys(found.variants)
})
// Current effective variant — falls back to first available if stored value is invalid
const effectiveVariant = createMemo(() => {
const list = variants()
if (list.length === 0) return undefined
const stored = variant()
return stored && list.includes(stored) ? stored : list[0]
})
// True when the user has changed the model from the session/config default
const overridden = createMemo(() => {
const sel = model()
const cfg = session.configModel()
if (!sel || !cfg) return false
return sel.providerID !== cfg.providerID || sel.modelID !== cfg.modelID
})
// Reset variant when model changes and stored variant is not in new list
createEffect(() => {
const list = variants()
if (list.length === 0) {
setVariant(undefined)
return
}
const stored = variant()
if (!stored || !list.includes(stored)) setVariant(list[0])
})
createEffect(() => {
if (!sandboxVisible()) return
if (server.connectionState() !== "connected") {
setSandbox(undefined)
setSandboxDefault(undefined)
setSandboxOverride(undefined)
return
}
vscode.postMessage({ type: "requestSandboxDefault", requestID: sandboxRequestID })
})
const unsubSandbox = vscode.onMessage((message) => {
if (message.type !== "sandboxDefaultStatus") return
if (message.requestID !== sandboxRequestID) return
if (message.revision < sandboxRevision()) return
setSandboxRevision(message.revision)
setSandboxDefault(message.desired)
setSandboxAvailable(message.available)
setSandboxReason(message.reason)
const override = sandboxOverride()
if (override === undefined) {
setSandbox(message.enabled)
return
}
if (override === message.desired) setSandboxOverride(undefined)
})
onCleanup(unsubSandbox)
const toggleSandbox = () => {
const current = sandbox()
if (current === undefined || !sandboxAvailable()) return
const next = !current
setSandbox(next)
setSandboxOverride(next === sandboxDefault() ? undefined : next)
vscode.postMessage({ type: "setSandboxDefault", enabled: next, requestID: sandboxRequestID })
}
const imageAttach = useImageAttachments()
imageAttach.setFilePathDropHandler((paths) => {
const cwd = server.workspaceDirectory()
const resolved = paths.map((p) => convertToMentionPath(p, cwd))
const ref = textareaRef
if (!ref) return
const val = ref.value
const cursor = ref.selectionStart ?? val.length
const before = val.substring(0, cursor)
const after = val.substring(cursor)
const inserted = resolved.map((p) => `@${p}`).join(" ")
const result = before + inserted + " " + after
ref.value = result
setPrompt(result)
persistPrompt(result)
const pos = cursor + inserted.length + 1
ref.setSelectionRange(pos, pos)
ref.focus()
adjustHeight()
})
// Restore cached images from webview state
const cachedImages = cached?.advancedDialogImages as ImageAttachment[] | undefined
if (cachedImages?.length) imageAttach.replace(cachedImages)
const persistPrompt = (value: string) => {
const state = vscode.getState<Record<string, unknown>>() ?? {}
vscode.setState({ ...state, advancedDialogPrompt: value || undefined })
}
const persistImages = (imgs: ImageAttachment[]) => {
const state = vscode.getState<Record<string, unknown>>() ?? {}
vscode.setState({ ...state, advancedDialogImages: imgs.length > 0 ? imgs : undefined })
}
// Auto-persist images to webview state on any change
createEffect(() => persistImages(imageAttach.images()))
let textareaRef: HTMLTextAreaElement | undefined
onMount(() => {
setBranchesLoading(true)
vscode.postMessage({ type: "agentManager.requestBranches" })
// Resize textarea if restoring a cached prompt
if (prompt()) adjustHeight()
const focus = () => {
textareaRef?.focus({ preventScroll: true })
const end = textareaRef?.value.length ?? 0
textareaRef?.setSelectionRange(end, end)
}
requestAnimationFrame(() => {
focus()
requestAnimationFrame(focus)
setTimeout(focus, 0)
setTimeout(focus, 50)
})
})
const effectiveBaseBranch = () => baseBranch() ?? defaultBranch()
const filteredBranches = createMemo(() => {
const search = branchSearch().toLowerCase()
if (!search) return branches()
return branches().filter((b) => b.name.toLowerCase().includes(search))
})
const canSubmit = () => {
if (starting()) return false
if (speech.active()) return false
if (compareMode() && totalAllocations(modelAllocations()) === 0) return false
return true
}
const total = () => (compareMode() ? totalAllocations(modelAllocations()) : versions())
const mode = () => (compareMode() ? "compare_models" : versions() > 1 ? "multiple_versions" : "single")
const handleSubmit = () => {
if (!canSubmit()) return
setStarting(true)
const text = prompt().trim() || undefined
const defaultAgent = session.agents()[0]?.name
const selectedAgent = agent() !== defaultAgent ? agent() : undefined
const advanced = showAdvanced()
const customBranch = advanced ? branchName().trim() || undefined : undefined
const imgs = imageAttach.images()
const imgFiles = imgs.length > 0 ? imgs.map((img) => ({ mime: img.mime, url: img.dataUrl })) : undefined
const isCompare = compareMode()
const allocations = isCompare ? allocationsToArray(modelAllocations()) : undefined
const count = total()
const sel = isCompare ? null : model()
vscode.postMessage({
type: "agentManager.createMultiVersion",
text,
name: name().trim() || undefined,
versions: count,
providerID: sel?.providerID,
modelID: sel?.modelID,
agent: selectedAgent,
variant: isCompare ? undefined : effectiveVariant(),
baseBranch: advanced ? (baseBranch() ?? undefined) : undefined,
branchName: customBranch,
modelAllocations: allocations,
sandbox: sandboxVisible() ? sandboxOverride() : undefined,
files: imgFiles,
})
persistPrompt("")
persistImages([])
props.onClose()
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
handleSubmit()
}
}
const adjustHeight = () => {
if (!textareaRef) return
textareaRef.style.height = "auto"
textareaRef.style.height = `${Math.min(textareaRef.scrollHeight, 200)}px`
}
const insertSpeechText = (value: string) => {
const ref = textareaRef
const current = prompt()
const start = ref?.selectionStart ?? current.length
const end = ref?.selectionEnd ?? start
const result = insertSpacedText(current, value, start, end)
setPrompt(result.text)
persistPrompt(result.text)
if (!ref) return
ref.value = result.text
ref.setSelectionRange(result.pos, result.pos)
ref.focus()
adjustHeight()
}
const startSpeech = () => {
speech.start({ model: speechModel(), insert: insertSpeechText })
}
// --- Import tab state ---
const [prUrl, setPrUrl] = createSignal("")
const [prPending, setPrPending] = createSignal(false)
const [branchOpen, setBranchOpen] = createSignal(false)
const [importPending, setImportPending] = createSignal(false)
const isPending = () => prPending() || importPending()
// Listen for branch data + import results
const importUnsub = vscode.onMessage((msg) => {
if (msg.type === "agentManager.branches") {
const ev = msg as AgentManagerBranchesMessage
setBranches(ev.branches)
if (!props.defaultBaseBranch) setDefaultBranch(ev.defaultBranch)
setBranchesLoading(false)
}
if (msg.type === "agentManager.importResult") {
const ev = msg as AgentManagerImportResultMessage
setPrPending(false)
setImportPending(false)
if (ev.success) {
props.onClose()
} else {
const description = ev.errorCode ? t(`agentManager.setup.error.${ev.errorCode}`) : ev.message
showToast({ variant: "error", title: t("agentManager.import.failed"), description })
}
}
})
onCleanup(() => importUnsub())
const handlePRSubmit = () => {
const url = prUrl().trim()
if (!url || isPending()) return
setPrPending(true)
vscode.postMessage({ type: "agentManager.importFromPR", url })
}
const handleBranchSelect = (name: string) => {
if (isPending()) return
track("import_branch")
setImportPending(true)
setBranchOpen(false)
setBranchSearch("")
vscode.postMessage({ type: "agentManager.importFromBranch", branch: name })
}
return (
<Dialog title={t("agentManager.dialog.openWorktree")} fit>
{/* Tab switcher */}
<div class="am-tab-switcher">
<button
class="am-tab-switcher-pill"
classList={{ "am-tab-switcher-pill-active": tab() === "new" }}
onClick={click("switch_dialog_tab", "configure_worktree_dialog", () => setTab("new"), { tab: "new" })}
type="button"
>
{t("agentManager.dialog.tab.new")}
</button>
<button
class="am-tab-switcher-pill"
classList={{ "am-tab-switcher-pill-active": tab() === "import" }}
onClick={click("switch_dialog_tab", "configure_worktree_dialog", () => setTab("import"), { tab: "import" })}
type="button"
>
{t("agentManager.dialog.tab.import")}
</button>
</div>
{/* New tab */}
<Show when={tab() === "new"}>
<div class="am-nv-dialog" onKeyDown={handleKeyDown}>
<div class="am-nv-dialog-content">
<input
class="am-nv-name-input"
placeholder={t("agentManager.dialog.namePlaceholder")}
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
/>
{/* Prompt input — reuses the sidebar chat-input base classes for consistent styling */}
<div
class="prompt-input-container am-prompt-input-container"
classList={{ "prompt-input-container--dragging": imageAttach.dragging() }}
onDragOver={imageAttach.handleDragOver}
onDragLeave={imageAttach.handleDragLeave}
onDrop={imageAttach.handleDrop}
>
<Show when={imageAttach.images().length > 0}>
<div class="image-attachments">
<For each={imageAttach.images()}>
{(img) => (
<div class="image-attachment">
<img
src={img.dataUrl}
alt={img.filename}
title={img.filename}
onClick={() =>
vscode.postMessage({ type: "previewImage", dataUrl: img.dataUrl, filename: img.filename })
}
/>
<button
type="button"
class="image-attachment-remove"
onClick={() => imageAttach.remove(img.id)}
aria-label={t("agentManager.dialog.removeImage")}
>
×
</button>
</div>
)}
</For>
</div>
</Show>
<div class="prompt-input-wrapper am-prompt-input-wrapper">
<div class="prompt-input-ghost-wrapper am-prompt-input-ghost-wrapper">
<textarea
ref={textareaRef}
class="prompt-input am-prompt-input"
placeholder={t(
isMac
? "agentManager.dialog.promptPlaceholder.mac"
: "agentManager.dialog.promptPlaceholder.other",
)}
value={prompt()}
onInput={(e) => {
const val = e.currentTarget.value
setPrompt(val)
persistPrompt(val)
adjustHeight()
}}
onPaste={(e) => imageAttach.handlePaste(e)}
rows={3}
dir="auto"
/>
</div>
</div>
<div class="prompt-input-hint">
<div class="prompt-input-hint-selectors">
<Show when={session.agents().length > 1}>
<ModeSwitcherBase
agents={session.agents()}
value={agent()}
onSelect={setAgent}
portal={false}
deferDismiss
/>
</Show>
<Show when={!compareMode()}>
<ModelSelectorBase
value={model()}
onSelect={(pid, mid) => {
if (pid && mid) setModel({ providerID: pid, modelID: mid })
}}
placement="top-start"
portal={false}
deferDismiss
/>
<ThinkingSelectorBase
variants={variants()}
value={effectiveVariant()}
onSelect={setVariant}
portal={false}
deferDismiss
/>
<Show when={overridden()}>
<Tooltip value={t("prompt.action.resetModel")} placement="top">
<Button
variant="ghost"
size="small"
onClick={() => setModel(session.configModel())}
aria-label={t("prompt.action.resetModel")}
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z" />
</svg>
</Button>
</Tooltip>
</Show>
</Show>
</div>
<div class="prompt-input-hint-actions">
<Show when={sandboxVisible()}>
<SandboxButtonBase
enabled={sandbox() ?? false}
available={sandbox() === undefined ? undefined : sandboxAvailable()}
reason={sandboxReason()}
disabled={sandbox() === undefined}
tooltip={
<SandboxTooltipContent
enabled={sandbox() ?? false}
network={config().sandbox?.network !== "allow"}
/>
}
tooltipClass="prompt-sandbox-tooltip-content"
onToggle={click("sandbox_toggle", "configure_worktree_dialog", toggleSandbox, () => ({
enabled: !(sandbox() ?? false),
}))}
/>
</Show>
<Show when={canUseSpeech()}>
<SpeechToTextButton speech={speech} disabled={starting()} start={startSpeech} label={t} />
</Show>
</div>
</div>
</div>
{/* Advanced options toggle */}
<button
class="am-advanced-toggle"
onClick={click(
"advanced_options",
"configure_worktree_dialog",
() => setShowAdvanced(!showAdvanced()),
() => ({ action: showAdvanced() ? "close" : "open" }),
)}
type="button"
>
<Icon name={showAdvanced() ? "chevron-down" : "chevron-right"} size="small" />
<span>{t("agentManager.dialog.advancedOptions")}</span>
</button>
<Show when={showAdvanced()}>
<div class="am-advanced-section">
<div class="am-advanced-field">
<span class="am-nv-config-label">{t("agentManager.dialog.branchName")}</span>
<input
class="am-advanced-input"
type="text"
placeholder={t("agentManager.dialog.branchNamePlaceholder")}
value={branchName()}
onInput={(e) => setBranchName(sanitizeBranchName(e.currentTarget.value))}
/>
</div>
<div class="am-advanced-field">
<span class="am-nv-config-label">{t("agentManager.dialog.baseBranch")}</span>
<div class="am-selector-wrapper">
<BranchSelectPopover
open={baseBranchOpen()}
onOpenChange={(open) => {
setBaseBranchOpen(open)
if (!open) {
setBranchSearch("")
setHighlightedIndex(0)
}
}}
trigger={
<button class="am-selector-trigger" type="button">
<span class="am-selector-left">
<Icon name="branch" size="small" />
<span class="am-selector-value">{effectiveBaseBranch()}</span>
<Show when={!baseBranch()}>
<span class="am-branch-badge">{t("agentManager.dialog.branchBadge.default")}</span>
</Show>
</span>
<span class="am-selector-right">
<Icon name="selector" size="small" />
</span>
</button>
}
>
<BranchSelect
branches={filteredBranches()}
loading={branchesLoading()}
search={branchSearch()}
onSearch={(v) => {
setBranchSearch(v)
setHighlightedIndex(0)
}}
onSelect={(b) => {
setBaseBranch(b.name)
setBaseBranchOpen(false)
setBranchSearch("")
setHighlightedIndex(0)
}}
onSearchKeyDown={(e) => {
const items = filteredBranches()
if (e.key === "ArrowDown") {
e.preventDefault()
e.stopPropagation()
const next = Math.min(highlightedIndex() + 1, items.length - 1)
setHighlightedIndex(next)
requestAnimationFrame(() => {
document
.querySelector(`.am-branch-item[data-index="${next}"]`)
?.scrollIntoView({ block: "nearest" })
})
} else if (e.key === "ArrowUp") {
e.preventDefault()
e.stopPropagation()
const prev = Math.max(highlightedIndex() - 1, 0)
setHighlightedIndex(prev)
requestAnimationFrame(() => {
document
.querySelector(`.am-branch-item[data-index="${prev}"]`)
?.scrollIntoView({ block: "nearest" })
})
} else if (e.key === "Enter") {
e.preventDefault()
e.stopPropagation()
const selected = items[highlightedIndex()]
if (selected) {
setBaseBranch(selected.name)
setBaseBranchOpen(false)
setBranchSearch("")
setHighlightedIndex(0)
}
} else if (e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
setBaseBranchOpen(false)
setBranchSearch("")
setHighlightedIndex(0)
}
}}
selected={effectiveBaseBranch()}
highlighted={highlightedIndex()}
onHighlight={setHighlightedIndex}
searchPlaceholder={t("agentManager.dialog.searchBranches")}
emptyLabel={t("agentManager.import.noMatchingBranches")}
defaultLabel={t("agentManager.dialog.branchBadge.default")}
remoteLabel={t("agentManager.dialog.branchBadge.remote")}
defaultName={defaultBranch()}
/>
</BranchSelectPopover>
</div>
</div>
</div>
</Show>
{/* Version / compare mode selector */}
<Show
when={compareMode()}
fallback={
<div class="am-nv-version-bar">
<span class="am-nv-config-label">{t("agentManager.dialog.versions")}</span>
<div class="am-nv-pills">
{VERSION_OPTIONS.map((count) => (
<button
class="am-nv-pill"
classList={{ "am-nv-pill-active": versions() === count }}
onClick={click("version_count", "configure_worktree_dialog", () => setVersions(count), {
count,
})}
type="button"
>
{count}
</button>
))}
<Tooltip
value={t("agentManager.dialog.compareModels.tooltip")}
placement="top"
contentClass="am-tooltip-wrap"
>
<button
class="am-nv-pill am-nv-pill-compare"
onClick={click("compare_models", "configure_worktree_dialog", () => setCompareMode(true), {
action: "open",
})}
type="button"
>
<Icon name="layers" size="small" />
<span class="am-nv-pill-compare-label">{t("agentManager.dialog.compareModels")}</span>
</button>
</Tooltip>
</div>
<Show when={versions() > 1}>
<span class="am-nv-version-hint">
{t("agentManager.dialog.versionHint", { count: versions() })}
</span>
</Show>
</div>
}
>
<div class="am-nv-compare-section">
<div class="am-nv-version-bar">
<span class="am-nv-config-label">
{t("agentManager.dialog.compareModels")}
<Show when={totalAllocations(modelAllocations()) > 0}>
<span class="am-nv-compare-count">
{totalAllocations(modelAllocations())}/{MAX_MULTI_VERSIONS}
</span>
</Show>
</span>
<button
class="am-nv-pill-back"
onClick={() => {
track("compare_models", { action: "close" })
setCompareMode(false)
setModelAllocations(new Map())
}}
type="button"
title={t("agentManager.dialog.versions")}
>
<Icon name="close-small" size="small" />
</button>
</div>
<DeferredPopover
open={compareOpen()}
onOpenChange={setCompareOpen}
placement="top-start"
flip={false}
sameWidth
portal={false}
deferDismiss
class="am-compare-popover"
trigger={
<button class="am-selector-trigger" type="button">
<span class="am-selector-left">
<Icon name="layers" size="small" />
<Show
when={modelAllocations().size > 0}
fallback={
<span class="am-selector-value am-selector-placeholder">
{t("agentManager.dialog.compareModels.selectModels")}
</span>
}
>
<span class="am-selector-value">
{[...modelAllocations().values()].map((e) => e.name).join(", ")}
</span>
</Show>
</span>
<span class="am-selector-right">
<Icon name="selector" size="small" />
</span>
</button>
}
>
<MultiModelSelector allocations={modelAllocations()} onChange={setModelAllocations} />
</DeferredPopover>
</div>
</Show>
</div>
{/* Submit button — fixed footer, always visible */}
<div class="am-nv-dialog-footer">
<Button
variant="primary"
size="large"
class="am-nv-submit"
onClick={click("create_worktree", "configure_worktree_dialog", handleSubmit, () => ({
mode: mode(),
versionCount: total(),
advanced: showAdvanced(),
customBranch: showAdvanced() && !!branchName().trim(),
customBase: showAdvanced() && !!baseBranch(),
hasPrompt: !!prompt().trim(),
hasAttachments: imageAttach.images().length > 0,
}))}
disabled={!canSubmit()}
>
<Show
when={!starting()}
fallback={
<>
<Spinner class="am-nv-spinner" />
<span>{t("agentManager.dialog.creating")}</span>
</>
}
>
{t("agentManager.dialog.createWorktree")}
</Show>
</Button>
</div>
</div>
</Show>
{/* Import tab */}
<Show when={tab() === "import"}>
<div class="am-import-tab">
{/* Pull Request section */}
<div class="am-import-section">
<span class="am-nv-config-label">{t("agentManager.import.pullRequest")}</span>
<div class="am-pr-row">
<div class="am-pr-input-wrapper">
<Icon name="branch" size="small" />
<input
class="am-pr-input"
type="text"
placeholder={t("agentManager.import.pastePrUrl")}
value={prUrl()}
onInput={(e) => setPrUrl(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
handlePRSubmit()
}
}}
disabled={isPending()}
/>
</div>
<Button
variant="secondary"
size="small"
onClick={click("import_pull_request", "configure_worktree_dialog", handlePRSubmit)}
disabled={!prUrl().trim() || isPending()}
>
<Show when={prPending()} fallback={t("agentManager.import.open")}>
<Spinner class="am-nv-spinner" />
</Show>
</Button>
</div>
</div>
<div class="am-import-divider" />
{/* Branches section */}
<div class="am-import-section">
<span class="am-nv-config-label">{t("agentManager.import.branches")}</span>
<div class="am-selector-wrapper">
<BranchSelectPopover
open={branchOpen()}
onOpenChange={setBranchOpen}
trigger={
<button class="am-selector-trigger" disabled={isPending()} type="button">
<span class="am-selector-left">
<Icon name="branch" size="small" />
<span class="am-selector-value">
{branchesLoading() ? t("agentManager.import.loading") : t("agentManager.import.selectBranch")}
</span>
</span>
<span class="am-selector-right">
<Icon name="selector" size="small" />
</span>
</button>
}
>
<BranchSelect
branches={filteredBranches().filter((b) => !b.isCheckedOut)}
loading={branchesLoading()}
search={branchSearch()}
onSearch={setBranchSearch}
onSelect={(b) => handleBranchSelect(b.name)}
searchPlaceholder={t("agentManager.dialog.searchBranches")}
loadingLabel={t("agentManager.import.loadingBranches")}
emptyLabel={t("agentManager.import.noMatchingBranches")}
defaultLabel={t("agentManager.dialog.branchBadge.default")}
remoteLabel={t("agentManager.dialog.branchBadge.remote")}
/>
</BranchSelectPopover>
</div>
</div>
{/* Empty state when no branches are available */}
<Show when={!branchesLoading() && branches().length === 0}>
<div class="am-import-empty">
{t("agentManager.import.noBranchesFound")}
<br />
{t("agentManager.import.noBranchesHint")}
</div>
</Show>
</div>
</Show>
</Dialog>
)
}