fix(vscode): polish sidebar worktree actions

This commit is contained in:
kirillk
2026-04-24 14:58:33 -04:00
parent 1da22b9ef2
commit 696ed061e6
10 changed files with 200 additions and 82 deletions
@@ -1543,6 +1543,16 @@ export class AgentManagerProvider implements Disposable {
await this.onCreateWorktree(baseBranch, branchName)
}
public async openAdvancedWorktree(): Promise<void> {
this.openPanel()
const panel = this.panel
if (!panel) return
await panel.waitForActive()
await panel.waitForReady()
await this.waitForStateReady("openAdvancedWorktree")
queueMicrotask(() => this.postToWebview({ type: "action", action: "advancedWorktree" }))
}
private handleSection(m: AgentManagerInMessage): boolean {
return handleSection(this.state, m, () => this.pushState())
}
@@ -56,6 +56,12 @@ export interface PanelContext {
/** Send a message to the webview. */
postMessage(msg: unknown): void
/** Resolve once the panel webview is ready to receive messages. */
waitForReady(): Promise<void>
/** Resolve once the panel is the active editor tab. */
waitForActive(): Promise<void>
/** Reveal the panel. */
reveal(preserveFocus?: boolean): void
@@ -110,6 +110,19 @@ export class VscodeHost implements Host {
postMessage(msg) {
void panel.webview.postMessage(msg)
},
waitForReady() {
return provider.waitForReady()
},
waitForActive() {
if (panel.active) return Promise.resolve()
return new Promise((resolve) => {
const sub = panel.onDidChangeViewState((e) => {
if (!e.webviewPanel.active) return
sub.dispose()
resolve()
})
})
},
reveal(preserveFocus) {
panel.reveal(vscode.ViewColumn.One, preserveFocus ?? false)
},
+3 -3
View File
@@ -338,9 +338,9 @@ export function activate(context: vscode.ExtensionContext) {
vscode.commands.registerCommand("kilo-code.new.agentManager.closeWorktree", () => {
agentManagerProvider.postMessage({ type: "action", action: "closeWorktree" })
}),
vscode.commands.registerCommand("kilo-code.new.agentManager.advancedWorktree", () => {
agentManagerProvider.postMessage({ type: "action", action: "advancedWorktree" })
}),
vscode.commands.registerCommand("kilo-code.new.agentManager.advancedWorktree", () =>
agentManagerProvider.openAdvancedWorktree(),
),
...Array.from({ length: 9 }, (_, i) =>
vscode.commands.registerCommand(`kilo-code.new.agentManager.jumpTo${i + 1}`, () => {
agentManagerProvider.postMessage({ type: "action", action: `jumpTo${i + 1}` })
@@ -1,7 +1,8 @@
// Agent Manager root component
/** @jsxImportSource solid-js */
import {
Component,
For,
Show,
createSignal,
@@ -11,6 +12,7 @@ import {
onMount,
onCleanup,
type Accessor,
type Component,
} from "solid-js"
import type {
ExtensionMessage,
@@ -2285,7 +2287,7 @@ const AgentManagerContent: Component = () => {
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={showAdvancedWorktreeDialog}>
<Icon name="settings-gear" size="small" />
<DropdownMenu.ItemLabel>{t("agentManager.dialog.advanced")}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemLabel>{t("agentManager.dialog.configureWorktree")}</DropdownMenu.ItemLabel>
<span class="am-menu-shortcut">
{parseBindingTokens(kb().advancedWorktree ?? "").map((token) => (
<kbd class="am-menu-key">{token}</kbd>
@@ -1,6 +1,8 @@
// New Worktree dialog — prompt, versions, model, mode, import tab
import { Component, For, Show, createSignal, createEffect, createMemo, onMount, onCleanup } from "solid-js"
/** @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"
@@ -171,6 +173,17 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
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()
@@ -77,7 +77,7 @@ export const dict = {
"agentManager.shortcuts.nextItem": "Next item",
"agentManager.shortcuts.newWorktree": "New worktree",
"agentManager.shortcuts.openWorktree": "Open worktree",
"agentManager.shortcuts.advancedWorktree": "Advanced worktree",
"agentManager.shortcuts.advancedWorktree": "Configure new worktree",
"agentManager.shortcuts.deleteWorktree": "Delete worktree",
"agentManager.shortcuts.previousTab": "Previous tab",
"agentManager.shortcuts.nextTab": "Next tab",
@@ -122,6 +122,7 @@ export const dict = {
"agentManager.dialog.createWorktree": "Create Worktree",
"agentManager.dialog.removeImage": "Remove image",
"agentManager.dialog.advanced": "Advanced...",
"agentManager.dialog.configureWorktree": "Configure New Worktree...",
"agentManager.diff.toggle": "Toggle diff",
"agentManager.diff.openFile": "Open file",
@@ -54,6 +54,7 @@ export const ChatView: Component<ChatViewProps> = (props) => {
const [transferring, setTransferring] = createSignal(false)
const [transferDetail, setTransferDetail] = createSignal("")
const [repoBranch, setRepoBranch] = createSignal<string>()
let worktreeRef: HTMLDivElement | undefined
// Permissions and questions scoped to this session's family (self + subagents).
// Each ChatView only sees its own session tree — no cross-session leakage.
@@ -146,7 +147,10 @@ export const ChatView: Component<ChatViewProps> = (props) => {
const openAgentManager = () => vscode.postMessage({ type: "openAgentManager" })
const openChanges = () => vscode.postMessage({ type: "openChanges" })
const moveToWorktree = () => {
if (transferring()) return
const sid = id()
if (!sid) return
setTransferring(true)
@@ -159,6 +163,19 @@ export const ChatView: Component<ChatViewProps> = (props) => {
const advancedTooltip = "Open the Agent Manager worktree dialog to configure a new worktree before creating it."
const moveTooltip = () => {
const stats = session.worktreeStats()
if (!stats?.files)
return "Move this conversation and your current local changes into a dedicated worktree for isolated follow-up work."
return `Move this conversation and ${stats.files} changed file${stats.files > 1 ? "s" : ""} into a dedicated worktree for isolated follow-up work.`
}
const changesTooltip = () => {
const stats = session.worktreeStats()
if (!stats?.files) return "Open the changes view to inspect the current working tree."
return `${stats.files} file${stats.files > 1 ? "s" : ""} changed · +${stats.additions} -${stats.deletions}. Open the changes view.`
}
const showAdvancedWorktree = () => vscode.postMessage({ type: "openAdvancedWorktree" })
createEffect(() => {
@@ -169,13 +186,27 @@ export const ChatView: Component<ChatViewProps> = (props) => {
const renderActions = (hasChat: boolean) => (
<div class="new-task-button-wrapper" classList={{ "new-task-button-wrapper--empty": !hasChat }}>
<div class="session-actions-row">
<Tooltip value="Start a fresh conversation while keeping the current session intact." placement="top">
<Button variant="secondary" size="small" onClick={startSession} aria-label="New Session">
<Tooltip
value={
hasChat
? "Start a fresh conversation while keeping the current session intact."
: "This session is already new. Start chatting or create a worktree instead."
}
placement="top"
>
<Button
variant="secondary"
size="small"
class="session-new-button"
onClick={startSession}
disabled={!hasChat}
aria-label="New Session"
>
New Session
</Button>
</Tooltip>
<Show when={isSidebar() && server.gitInstalled()}>
<div class="session-worktree-split">
<div class="session-worktree-split" ref={worktreeRef}>
<Tooltip value={worktreeTooltip} placement="top">
<Button
variant="secondary"
@@ -187,7 +218,7 @@ export const ChatView: Component<ChatViewProps> = (props) => {
New Worktree
</Button>
</Tooltip>
<DropdownMenu gutter={4} placement="top-end">
<DropdownMenu gutter={4} placement="top-start" getAnchorRect={() => worktreeRef?.getBoundingClientRect()}>
<Tooltip value={advancedTooltip} placement="top">
<DropdownMenu.Trigger class="session-worktree-split-arrow" aria-label="Advanced worktree options">
<Icon name="chevron-down" size="small" />
@@ -207,7 +238,9 @@ export const ChatView: Component<ChatViewProps> = (props) => {
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={showAdvancedWorktree}>
<Icon name="settings-gear" size="small" />
<DropdownMenu.ItemLabel>Configure New Worktree...</DropdownMenu.ItemLabel>
<DropdownMenu.ItemLabel>
{language.t("agentManager.dialog.configureWorktree")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
@@ -216,42 +249,42 @@ export const ChatView: Component<ChatViewProps> = (props) => {
</Show>
<Show when={!hasChat}></Show>
<Show when={hasChat && canContinueInWorktree() && server.gitInstalled()}>
<Tooltip
value={
session.worktreeStats()?.files
? `Move this conversation and ${session.worktreeStats()!.files} changed file${session.worktreeStats()!.files > 1 ? "s" : ""} into a dedicated worktree for isolated follow-up work.`
: "Move this conversation and your current local changes into a dedicated worktree for isolated follow-up work."
}
placement="top"
>
<Button
variant="ghost"
size="small"
class="session-move-button"
classList={{
"session-move-button--empty": !session.worktreeStats()?.files,
"session-move-button--has-changes": !!session.worktreeStats()?.files,
}}
disabled={transferring()}
onClick={moveToWorktree}
aria-label="Move to Worktree"
>
<Show when={transferring()} fallback={<Icon name="branch" size="small" />}>
<Spinner class="chat-spinner-small" />
</Show>
<span class="session-move-label">{transferring() ? transferDetail() : "Move to Worktree"}</span>
<Show when={!transferring() && session.worktreeStats()?.files}>
<span class="session-move-tail" aria-hidden="true">
<span class="session-move-divider" />
<span class="session-move-stats">
<Icon name="layers" size="small" />
<span class="session-diff-add">+{session.worktreeStats()!.additions}</span>
<span class="session-diff-del">-{session.worktreeStats()!.deletions}</span>
</span>
</span>
</Show>
</Button>
</Tooltip>
<div class="session-move-group">
<Tooltip value={moveTooltip()} placement="top">
<Button
variant="ghost"
size="small"
class="session-move-action"
aria-disabled={transferring()}
onClick={moveToWorktree}
aria-label="Move to Worktree"
>
<Show when={transferring()} fallback={<Icon name="branch" size="small" />}>
<Spinner class="chat-spinner-small" />
</Show>
<span class="session-move-label">{transferring() ? transferDetail() : "Move to Worktree"}</span>
</Button>
</Tooltip>
<Tooltip value={changesTooltip()} placement="top">
<Button
variant="ghost"
size="small"
class="session-move-changes"
classList={{
"session-move-changes--empty": !session.worktreeStats()?.files,
"session-move-changes--has-changes": !!session.worktreeStats()?.files,
}}
onClick={openChanges}
aria-label={language.t("command.session.show.changes")}
>
<Icon name="layers" size="small" />
<Show when={session.worktreeStats()?.files}>
<span class="session-diff-add">+{session.worktreeStats()!.additions}</span>
<span class="session-diff-del">-{session.worktreeStats()!.deletions}</span>
</Show>
</Button>
</Tooltip>
</div>
</Show>
<div class="session-agent-manager-slot">
<Tooltip
@@ -945,7 +945,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
variant="ghost"
size="small"
onClick={() => void handleSend()}
disabled={!canSend()}
aria-disabled={!canSend()}
aria-label={language.t("prompt.action.send")}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
@@ -63,6 +63,10 @@
color: var(--vscode-foreground);
}
.session-actions-row > [data-component="tooltip-trigger"] > [data-component="button"].session-new-button:disabled {
cursor: default;
}
.session-worktree-split {
display: flex;
flex: 0 0 auto;
@@ -177,22 +181,57 @@
font-weight: 600;
}
/* Diff wrapper also stretches evenly (same flex as buttons) */
.session-move-button {
.session-move-group {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 2px;
border-radius: var(--session-action-radius);
color: var(--vscode-descriptionForeground);
transition:
background-color 150ms ease,
color 150ms ease;
}
.session-move-group > [data-component="tooltip-trigger"] {
display: flex;
flex: 0 0 auto;
}
.session-move-group:hover,
.session-move-group:has(.session-move-action:focus-visible),
.session-move-group:has(.session-move-changes:focus-visible) {
color: var(--vscode-foreground);
}
.session-move-group:hover .session-move-action,
.session-move-group:hover .session-move-changes,
.session-move-group:has(.session-move-action:focus-visible) .session-move-action,
.session-move-group:has(.session-move-action:focus-visible) .session-move-changes,
.session-move-group:has(.session-move-changes:focus-visible) .session-move-action,
.session-move-group:has(.session-move-changes:focus-visible) .session-move-changes {
background: var(--surface-base-hover);
}
.session-move-action {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
gap: 5px;
border-radius: var(--session-action-radius);
color: var(--vscode-descriptionForeground);
border-radius: var(--session-action-radius) 0 0 var(--session-action-radius);
color: inherit;
padding: 4px 6px !important;
background: transparent;
box-shadow: none;
}
.session-move-button:hover:not(:disabled),
.session-move-button:focus-visible:not(:disabled),
.session-move-button:active:not(:disabled) {
color: var(--vscode-foreground);
.session-move-action:hover:not(:disabled),
.session-move-action:focus-visible:not(:disabled),
.session-move-action:active:not(:disabled) {
color: inherit;
background: inherit;
box-shadow: none;
}
.session-move-label {
@@ -202,31 +241,41 @@
white-space: nowrap;
}
.session-move-tail {
display: inline-flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
padding-left: 5px;
}
.session-move-divider {
width: 1px;
height: 14px;
background: color-mix(in srgb, currentColor 25%, transparent);
.session-move-action [data-component="icon"],
.session-move-changes [data-component="icon"] {
opacity: 0.7;
}
.session-move-stats {
.session-move-changes {
display: inline-flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
flex: 0 0 auto;
border-radius: 0 var(--session-action-radius) var(--session-action-radius) 0;
color: inherit;
padding: 4px 6px !important;
background: transparent;
box-shadow: none;
}
.session-move-changes:hover:not(:disabled),
.session-move-changes:focus-visible:not(:disabled),
.session-move-changes:active:not(:disabled) {
color: inherit;
background: inherit;
box-shadow: none;
}
.session-move-changes {
font-family: var(--font-family-sans);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.session-move-changes--empty {
opacity: 0.7;
}
.session-agent-manager-slot {
flex: 0 0 auto;
margin-left: auto;
@@ -254,10 +303,6 @@
color: var(--vscode-foreground);
}
.session-move-stats [data-component="icon"] {
opacity: 0.7;
}
/* Diff stats badge styles retained for the inline move button stats */
.session-diff-badge {
display: flex;
@@ -315,21 +360,16 @@
margin-left: 0;
}
.session-move-divider,
.session-diff-add,
.session-diff-del {
display: none;
}
.session-move-tail {
padding-left: 4px;
}
.session-move-stats {
.session-move-changes {
position: relative;
}
.session-move-button--has-changes .session-move-stats [data-component="icon"]::after {
.session-move-changes--has-changes [data-component="icon"]::after {
content: "";
position: absolute;
top: -2px;