mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
Merge pull request #11689 from Kilo-Org/feat/agent-manager-worktree-sandbox
feat(agent-manager): add sandbox toggle to new worktree modal
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Add a sandbox toggle to the Agent Manager New Worktree modal so each worktree session can start sandboxed
|
||||
@@ -39,6 +39,7 @@ import { startSession } from "./mcp-warmup"
|
||||
import { readTerminalFont, watchTerminalFont } from "./terminal-font"
|
||||
import { buildKeybindingMap } from "./format-keybinding"
|
||||
import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version"
|
||||
import { ensureSandbox } from "./sandbox-bootstrap"
|
||||
import { Semaphore } from "./semaphore"
|
||||
import { PLATFORM } from "./constants"
|
||||
import type { AgentManagerOutMessage, AgentManagerInMessage } from "./types"
|
||||
@@ -850,6 +851,28 @@ export class AgentManagerProvider implements Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a worktree whose session could not be safely initialized. */
|
||||
private async discardWorktree(id: string, dir: string, branch: string, sessionId?: string): Promise<void> {
|
||||
this.getStateManager()?.removeWorktree(id)
|
||||
this.pushState()
|
||||
|
||||
if (sessionId) {
|
||||
try {
|
||||
await this.connectionService
|
||||
.getClient()
|
||||
.session.delete({ sessionID: sessionId, directory: dir }, { throwOnError: true })
|
||||
} catch (err) {
|
||||
this.log(`Failed to delete session ${sessionId} after worktree setup failed:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.getWorktreeManager()?.removeWorktree(dir, branch)
|
||||
} catch (err) {
|
||||
this.log(`Failed to remove worktree ${id} after setup failed:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
/** Send worktreeSetup.ready + sessionMeta + pushState after worktree creation. */
|
||||
private notifyWorktreeReady(sessionId: string, result: CreateWorktreeResult, worktreeId?: string): void {
|
||||
this.pushState()
|
||||
@@ -1287,6 +1310,31 @@ export class AgentManagerProvider implements Disposable {
|
||||
|
||||
const state = this.getStateManager()!
|
||||
state.addSession(session.id, wt.worktree.id)
|
||||
|
||||
// Sandbox must match the user's choice before this session is exposed or
|
||||
// receives its initial prompt. A failed reconciliation aborts this version.
|
||||
if (msg.sandbox !== undefined) {
|
||||
try {
|
||||
await ensureSandbox(this.connectionService.getClient(), session.id, wt.result.path, msg.sandbox)
|
||||
} catch (error) {
|
||||
const err = getErrorMessage(error)
|
||||
this.log(`Failed to configure sandbox for ${session.id}: ${err}`)
|
||||
this.postToWebview({
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "error",
|
||||
message: `Failed to configure sandbox: ${err}`,
|
||||
worktreeId: wt.worktree.id,
|
||||
})
|
||||
this.host.capture("Agent Manager Session Error", {
|
||||
source: PLATFORM,
|
||||
error: err,
|
||||
context: "configureSandbox",
|
||||
})
|
||||
await this.discardWorktree(wt.worktree.id, wt.result.path, wt.result.branch, session.id)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
this.registerWorktreeSession(session.id, wt.result.path)
|
||||
this.notifyWorktreeReady(session.id, wt.result, wt.worktree.id)
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { KiloClient } from "@kilocode/sdk/v2/client"
|
||||
import { sameDirectory } from "../kilo-provider-utils"
|
||||
|
||||
type State = {
|
||||
directory: string
|
||||
enabled: boolean
|
||||
available: boolean
|
||||
reason?: string
|
||||
version: number
|
||||
}
|
||||
|
||||
function unavailable(state: State) {
|
||||
return new Error(state.reason ?? "Sandbox backend is unavailable")
|
||||
}
|
||||
|
||||
function routed(state: State, dir: string) {
|
||||
if (!sameDirectory(state.directory, dir)) throw new Error("Sandbox status resolved a different directory")
|
||||
}
|
||||
|
||||
function confirm(state: State, dir: string, desired: boolean) {
|
||||
routed(state, dir)
|
||||
if (desired && !state.available) throw unavailable(state)
|
||||
if (state.enabled !== desired) {
|
||||
throw new Error(`Sandbox remained ${state.enabled ? "enabled" : "disabled"} after reconciliation`)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
/** Ensure a new session uses the selected sandbox state before its first prompt. */
|
||||
export async function ensureSandbox(client: KiloClient, sid: string, dir: string, desired: boolean): Promise<State> {
|
||||
const sandbox = client.sandbox
|
||||
const { data: current } = await sandbox.status({ sessionID: sid, directory: dir }, { throwOnError: true })
|
||||
routed(current, dir)
|
||||
if (current.enabled === desired) return confirm(current, dir, desired)
|
||||
if (!current.available) throw unavailable(current)
|
||||
|
||||
const { data: next } = await sandbox.toggle({ sessionID: sid, directory: dir }, { throwOnError: true })
|
||||
return confirm(next, dir, desired)
|
||||
}
|
||||
@@ -439,6 +439,8 @@ interface CreateMultiVersionIn {
|
||||
baseBranch?: string
|
||||
branchName?: string
|
||||
modelAllocations?: Array<{ providerID: string; modelID: string; count: number }>
|
||||
/** When set, reconcile each created session's sandbox override to this state. */
|
||||
sandbox?: boolean
|
||||
}
|
||||
|
||||
interface RenameWorktreeIn {
|
||||
|
||||
@@ -3,8 +3,10 @@ import { readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
const path = join(__dirname, "..", "..", "webview-ui", "src", "components", "chat", "PromptInput.tsx")
|
||||
const src = readFileSync(path, "utf8")
|
||||
const buttonPath = join(__dirname, "..", "..", "webview-ui", "src", "components", "shared", "SandboxButton.tsx")
|
||||
const iconPath = join(__dirname, "..", "..", "..", "kilo-ui", "src", "components", "icon.tsx")
|
||||
const src = readFileSync(path, "utf8")
|
||||
const button = readFileSync(buttonPath, "utf8")
|
||||
const icons = readFileSync(iconPath, "utf8")
|
||||
|
||||
describe("PromptInput connection guard", () => {
|
||||
@@ -62,7 +64,7 @@ describe("PromptInput sandbox toggle", () => {
|
||||
expect(src).toContain("<Show when={sandboxVisible()}>")
|
||||
expect(src).toContain("{ action: toggleSandbox, enabled: () => sandboxVisible() && !sandboxDisabled() }")
|
||||
expect(src).toContain('if (!sandboxVisible()) hidden.add("sandbox")')
|
||||
expect(src).toContain("onClick={toggleSandbox}")
|
||||
expect(src).toContain("onToggle={toggleSandbox}")
|
||||
expect(src).toContain('message.type === "sandboxStatus"')
|
||||
expect(src).toContain("message.sessionID !== sandboxID() && !matching")
|
||||
expect(src).toContain("setSandboxState(state)")
|
||||
@@ -70,8 +72,11 @@ describe("PromptInput sandbox toggle", () => {
|
||||
expect(src).toContain("const target = untrack(sandboxTarget)")
|
||||
expect(src).toContain("if (target && target !== sessionID) clearSandboxRequest()")
|
||||
expect(src).toContain("sandbox()?.enabled ?? (!sandboxID() && config().experimental?.sandbox === true)")
|
||||
expect(src).toContain("aria-pressed={sandboxEnabled()}")
|
||||
expect(src).toContain("<SandboxButtonBase")
|
||||
expect(src).toContain("enabled={sandboxEnabled()}")
|
||||
expect(src).toContain("!sandboxReady()")
|
||||
expect(button).toContain("aria-pressed={props.enabled}")
|
||||
expect(button).toContain('class={`prompt-status-button ${props.enabled ? "prompt-status-button--active" : ""}`}')
|
||||
expect(src).toContain("if (sandboxRequest() && target === null) return")
|
||||
expect(src).not.toContain("if (state === current) return true")
|
||||
})
|
||||
@@ -91,13 +96,14 @@ describe("PromptInput sandbox toggle", () => {
|
||||
expect(src).toContain(
|
||||
"const sandboxNetworkEnabled = () => config().experimental?.sandbox_restrict_network !== false",
|
||||
)
|
||||
expect(src).toContain('<Icon name="lock" size="small" />')
|
||||
expect(src).toContain("<SandboxTooltipContent enabled={sandboxEnabled()} network={sandboxNetworkEnabled()} />")
|
||||
expect(src).toContain('<Icon name="folder" size="small" />')
|
||||
expect(src).toContain('<Icon name="globe" size="small" />')
|
||||
expect(src).toContain("props.enabled && props.network")
|
||||
expect(src).not.toContain('class="prompt-sandbox-network"')
|
||||
expect(src).not.toContain('class="prompt-sandbox-icon"')
|
||||
expect(src).toContain('tooltipClass="prompt-sandbox-tooltip-content"')
|
||||
expect(button).toContain('<Icon name="lock" size="small" />')
|
||||
expect(button).toContain('<Icon name="folder" size="small" />')
|
||||
expect(button).toContain('<Icon name="globe" size="small" />')
|
||||
expect(button).toContain("props.enabled && props.network")
|
||||
expect(button).not.toContain('class="prompt-sandbox-network"')
|
||||
expect(button).not.toContain('class="prompt-sandbox-icon"')
|
||||
expect(icons).toContain("globe: {")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { createKiloClient } from "@kilocode/sdk/v2/client"
|
||||
import { ensureSandbox } from "../../src/agent-manager/sandbox-bootstrap"
|
||||
|
||||
type State = {
|
||||
directory: string
|
||||
enabled: boolean
|
||||
available: boolean
|
||||
reason?: string
|
||||
version: number
|
||||
}
|
||||
|
||||
function setup(states: State[]) {
|
||||
const calls: string[] = []
|
||||
const fetch = Object.assign(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
calls.push(`${request.method} ${new URL(request.url).pathname}`)
|
||||
const state = states.shift()
|
||||
if (!state) return Response.json({ message: "Unexpected request" }, { status: 500 })
|
||||
return Response.json(state)
|
||||
},
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
) satisfies typeof globalThis.fetch
|
||||
|
||||
return {
|
||||
calls,
|
||||
client: createKiloClient({ baseUrl: "http://localhost", fetch }),
|
||||
}
|
||||
}
|
||||
|
||||
function state(enabled: boolean, available = true, directory = "/repo"): State {
|
||||
return { directory, enabled, available, version: 1 }
|
||||
}
|
||||
|
||||
describe("ensureSandbox", () => {
|
||||
test("does not toggle when the effective state already matches", async () => {
|
||||
const ctx = setup([state(true)])
|
||||
|
||||
const result = await ensureSandbox(ctx.client, "session-1", "/repo", true)
|
||||
|
||||
expect(result.enabled).toBe(true)
|
||||
expect(ctx.calls).toEqual(["GET /session/session-1/sandbox"])
|
||||
})
|
||||
|
||||
test("toggles and verifies the selected state", async () => {
|
||||
const ctx = setup([state(false), state(true)])
|
||||
|
||||
const result = await ensureSandbox(ctx.client, "session-1", "/repo", true)
|
||||
|
||||
expect(result.enabled).toBe(true)
|
||||
expect(ctx.calls).toEqual(["GET /session/session-1/sandbox", "POST /session/session-1/sandbox/toggle"])
|
||||
})
|
||||
|
||||
test("rejects unavailable sandboxing when sandbox was requested", async () => {
|
||||
const unavailable = { ...state(false, false), reason: "Sandbox backend unavailable" }
|
||||
const ctx = setup([unavailable])
|
||||
|
||||
expect(ensureSandbox(ctx.client, "session-1", "/repo", true)).rejects.toThrow("Sandbox backend unavailable")
|
||||
expect(ctx.calls).toEqual(["GET /session/session-1/sandbox"])
|
||||
})
|
||||
|
||||
test("allows an effectively disabled sandbox when the backend is unavailable", async () => {
|
||||
const ctx = setup([state(false, false)])
|
||||
|
||||
const result = await ensureSandbox(ctx.client, "session-1", "/repo", false)
|
||||
|
||||
expect(result.enabled).toBe(false)
|
||||
expect(ctx.calls).toEqual(["GET /session/session-1/sandbox"])
|
||||
})
|
||||
|
||||
test("rejects a toggle that does not reach the selected state", async () => {
|
||||
const ctx = setup([state(false), state(false)])
|
||||
|
||||
expect(ensureSandbox(ctx.client, "session-1", "/repo", true)).rejects.toThrow(
|
||||
"Sandbox remained disabled after reconciliation",
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects status returned for a different directory without toggling", async () => {
|
||||
const ctx = setup([state(false, true, "/other")])
|
||||
|
||||
expect(ensureSandbox(ctx.client, "session-1", "/repo", true)).rejects.toThrow(
|
||||
"Sandbox status resolved a different directory",
|
||||
)
|
||||
expect(ctx.calls).toEqual(["GET /session/session-1/sandbox"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Agent Manager sandbox startup", () => {
|
||||
const provider = readFileSync(join(__dirname, "..", "..", "src", "agent-manager", "AgentManagerProvider.ts"), "utf8")
|
||||
const dialog = readFileSync(
|
||||
join(__dirname, "..", "..", "webview-ui", "agent-manager", "NewWorktreeDialog.tsx"),
|
||||
"utf8",
|
||||
)
|
||||
|
||||
test("reconciles before exposing or prompting the session", () => {
|
||||
const start = provider.indexOf("private async onCreateMultiVersion")
|
||||
const end = provider.indexOf("\n private ", start + 1)
|
||||
const body = provider.slice(start, end)
|
||||
const ensure = body.indexOf("await ensureSandbox")
|
||||
const discard = body.indexOf("await this.discardWorktree", ensure)
|
||||
const skip = body.indexOf("continue", discard)
|
||||
const register = body.indexOf("this.registerWorktreeSession", ensure)
|
||||
const ready = body.indexOf("this.notifyWorktreeReady", register)
|
||||
const created = body.indexOf("created.push", ready)
|
||||
const prompt = body.indexOf('type: "agentManager.sendInitialMessage"', created)
|
||||
|
||||
expect(ensure).toBeGreaterThan(-1)
|
||||
expect(discard).toBeGreaterThan(ensure)
|
||||
expect(skip).toBeGreaterThan(discard)
|
||||
expect(register).toBeGreaterThan(skip)
|
||||
expect(ready).toBeGreaterThan(register)
|
||||
expect(created).toBeGreaterThan(ready)
|
||||
expect(prompt).toBeGreaterThan(created)
|
||||
})
|
||||
|
||||
test("deletes the fresh branch when sandbox setup rolls back", () => {
|
||||
expect(provider).toContain("private async discardWorktree(id: string, dir: string, branch: string")
|
||||
expect(provider).toContain("removeWorktree(dir, branch)")
|
||||
expect(provider).toContain("wt.result.path, wt.result.branch, session.id")
|
||||
})
|
||||
|
||||
test("uses the experiment-aware visibility condition for UI and payload", () => {
|
||||
expect(dialog).toContain("const sandboxVisible = () => isSandboxVisible(features(), config())")
|
||||
expect(dialog).toContain("sandbox: sandboxVisible() ? sandbox() : undefined")
|
||||
expect(dialog).toContain("<Show when={sandboxVisible()}>")
|
||||
})
|
||||
|
||||
test("places the sandbox toggle with prompt actions instead of model selectors", () => {
|
||||
const selectors = dialog.indexOf('<div class="prompt-input-hint-selectors">')
|
||||
const actions = dialog.indexOf('<div class="prompt-input-hint-actions">', selectors)
|
||||
const sandbox = dialog.indexOf("<SandboxButtonBase", actions)
|
||||
const speech = dialog.indexOf("<SpeechToTextButton", actions)
|
||||
|
||||
expect(selectors).toBeGreaterThan(-1)
|
||||
expect(actions).toBeGreaterThan(selectors)
|
||||
expect(dialog.slice(selectors, actions)).not.toContain("<SandboxButtonBase")
|
||||
expect(sandbox).toBeGreaterThan(actions)
|
||||
expect(speech).toBeGreaterThan(sandbox)
|
||||
})
|
||||
})
|
||||
@@ -20,7 +20,9 @@ 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 { visible as isSandboxVisible } from "../src/components/settings/sandboxing"
|
||||
import { ThinkingSelectorBase } from "../src/components/shared/ThinkingSelector"
|
||||
import { SandboxButtonBase, SandboxTooltipContent } from "../src/components/shared/SandboxButton"
|
||||
import {
|
||||
MultiModelSelector,
|
||||
type ModelAllocations,
|
||||
@@ -71,7 +73,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
const server = useServer()
|
||||
const session = useSession()
|
||||
const provider = useProvider()
|
||||
const { config } = useConfig()
|
||||
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)
|
||||
@@ -102,6 +104,8 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
const [compareOpen, setCompareOpen] = createSignal(false)
|
||||
const [highlightedIndex, setHighlightedIndex] = createSignal(0)
|
||||
const [variant, setVariant] = createSignal<string | undefined>(session.currentVariant())
|
||||
const [sandbox, setSandbox] = createSignal(config().experimental?.sandbox === true)
|
||||
const sandboxVisible = () => isSandboxVisible(features(), config())
|
||||
const speech = useSpeechToText(vscode, server, { t })
|
||||
const canUseSpeech = () => canUseSpeechToText(config(), provider.authStates())
|
||||
const speechModel = () => selectedSpeechToTextModel(config())
|
||||
@@ -246,6 +250,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
baseBranch: advanced ? (baseBranch() ?? undefined) : undefined,
|
||||
branchName: customBranch,
|
||||
modelAllocations: allocations,
|
||||
sandbox: sandboxVisible() ? sandbox() : undefined,
|
||||
files: imgFiles,
|
||||
})
|
||||
|
||||
@@ -460,6 +465,24 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
</Show>
|
||||
</div>
|
||||
<div class="prompt-input-hint-actions">
|
||||
<Show when={sandboxVisible()}>
|
||||
<SandboxButtonBase
|
||||
enabled={sandbox()}
|
||||
tooltip={
|
||||
<SandboxTooltipContent
|
||||
enabled={sandbox()}
|
||||
network={config().experimental?.sandbox_restrict_network !== false}
|
||||
/>
|
||||
}
|
||||
tooltipClass="prompt-sandbox-tooltip-content"
|
||||
onToggle={click(
|
||||
"sandbox_toggle",
|
||||
"configure_worktree_dialog",
|
||||
() => setSandbox(!sandbox()),
|
||||
() => ({ enabled: !sandbox() }),
|
||||
)}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={canUseSpeech()}>
|
||||
<SpeechToTextButton speech={speech} disabled={starting()} start={startSpeech} label={t} />
|
||||
</Show>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useConfig } from "../../context/config"
|
||||
import { useProvider } from "../../context/provider"
|
||||
import { ModelSelector } from "../shared/ModelSelector"
|
||||
import { ModeSwitcher } from "../shared/ModeSwitcher"
|
||||
import { SandboxButtonBase, SandboxTooltipContent } from "../shared/SandboxButton"
|
||||
import { SpeechToTextButton } from "../speech-to-text/SpeechToTextButton"
|
||||
import { canUseSpeechToText, selectedSpeechToTextModel } from "../speech-to-text/availability"
|
||||
import { ThinkingSelector } from "../shared/ThinkingSelector"
|
||||
@@ -83,49 +84,6 @@ interface PromptInputProps {
|
||||
pendingSessionID?: string
|
||||
}
|
||||
|
||||
export const SandboxTooltipContent: Component<{ enabled: boolean; network: boolean }> = (props) => {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="prompt-sandbox-tooltip">
|
||||
<div class="prompt-sandbox-tooltip-title">
|
||||
{language.t(props.enabled ? "prompt.action.sandbox.status.enabled" : "prompt.action.sandbox.status.disabled")}
|
||||
</div>
|
||||
<div class="prompt-sandbox-tooltip-row">
|
||||
<Icon name="folder" size="small" />
|
||||
<span>{language.t("prompt.action.sandbox.filesystem")}</span>
|
||||
<span class="prompt-sandbox-tooltip-state">
|
||||
{language.t(
|
||||
props.enabled ? "prompt.action.sandbox.filesystem.restricted" : "prompt.action.sandbox.unrestricted",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="prompt-sandbox-tooltip-row">
|
||||
<Icon name="globe" size="small" />
|
||||
<span>{language.t("prompt.action.sandbox.network")}</span>
|
||||
<span class="prompt-sandbox-tooltip-state">
|
||||
{language.t(
|
||||
props.enabled && props.network
|
||||
? "prompt.action.sandbox.network.blocked"
|
||||
: props.enabled
|
||||
? "prompt.action.sandbox.network.allowed"
|
||||
: "prompt.action.sandbox.unrestricted",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="prompt-sandbox-tooltip-description">
|
||||
{language.t(
|
||||
props.enabled
|
||||
? "prompt.action.sandbox.description.enabled"
|
||||
: props.network
|
||||
? "prompt.action.sandbox.description.disabled"
|
||||
: "prompt.action.sandbox.description.disabledNetworkAllowed",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const session = useSession()
|
||||
const server = useServer()
|
||||
@@ -1268,33 +1226,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Show when={sandboxVisible()}>
|
||||
<Tooltip
|
||||
value={
|
||||
sandbox()?.available === false ? (
|
||||
(sandbox()?.reason ?? language.t("common.requestFailed"))
|
||||
) : (
|
||||
<SandboxTooltipContent enabled={sandboxEnabled()} network={sandboxNetworkEnabled()} />
|
||||
)
|
||||
}
|
||||
contentClass="prompt-sandbox-tooltip-content"
|
||||
placement="top"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={toggleSandbox}
|
||||
disabled={sandboxDisabled()}
|
||||
aria-label={
|
||||
sandboxEnabled()
|
||||
? language.t("prompt.action.sandbox.disable")
|
||||
: language.t("prompt.action.sandbox.enable")
|
||||
}
|
||||
aria-pressed={sandboxEnabled()}
|
||||
class={`prompt-status-button ${sandboxEnabled() ? "prompt-status-button--active" : ""}`}
|
||||
>
|
||||
<Icon name="lock" size="small" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<SandboxButtonBase
|
||||
enabled={sandboxEnabled()}
|
||||
available={sandbox()?.available}
|
||||
reason={sandbox()?.reason}
|
||||
disabled={sandboxDisabled()}
|
||||
tooltip={<SandboxTooltipContent enabled={sandboxEnabled()} network={sandboxNetworkEnabled()} />}
|
||||
tooltipClass="prompt-sandbox-tooltip-content"
|
||||
onToggle={toggleSandbox}
|
||||
/>
|
||||
</Show>
|
||||
<Tooltip value={language.t("prompt.action.enhance")} placement="top">
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/** Shared sandbox lock control used by the chat prompt and Agent Manager. */
|
||||
|
||||
import { type Component, type JSX } from "solid-js"
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { useLanguage } from "../../context/language"
|
||||
|
||||
export interface SandboxButtonBaseProps {
|
||||
enabled: boolean
|
||||
available?: boolean
|
||||
reason?: string
|
||||
disabled?: boolean
|
||||
tooltip?: JSX.Element
|
||||
tooltipClass?: string
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
export const SandboxTooltipContent: Component<{ enabled: boolean; network: boolean }> = (props) => {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<div class="prompt-sandbox-tooltip">
|
||||
<div class="prompt-sandbox-tooltip-title">
|
||||
{language.t(props.enabled ? "prompt.action.sandbox.status.enabled" : "prompt.action.sandbox.status.disabled")}
|
||||
</div>
|
||||
<div class="prompt-sandbox-tooltip-row">
|
||||
<Icon name="folder" size="small" />
|
||||
<span>{language.t("prompt.action.sandbox.filesystem")}</span>
|
||||
<span class="prompt-sandbox-tooltip-state">
|
||||
{language.t(
|
||||
props.enabled ? "prompt.action.sandbox.filesystem.restricted" : "prompt.action.sandbox.unrestricted",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="prompt-sandbox-tooltip-row">
|
||||
<Icon name="globe" size="small" />
|
||||
<span>{language.t("prompt.action.sandbox.network")}</span>
|
||||
<span class="prompt-sandbox-tooltip-state">
|
||||
{language.t(
|
||||
props.enabled && props.network
|
||||
? "prompt.action.sandbox.network.blocked"
|
||||
: props.enabled
|
||||
? "prompt.action.sandbox.network.allowed"
|
||||
: "prompt.action.sandbox.unrestricted",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="prompt-sandbox-tooltip-description">
|
||||
{language.t(
|
||||
props.enabled
|
||||
? "prompt.action.sandbox.description.enabled"
|
||||
: props.network
|
||||
? "prompt.action.sandbox.description.disabled"
|
||||
: "prompt.action.sandbox.description.disabledNetworkAllowed",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SandboxButtonBase: Component<SandboxButtonBaseProps> = (props) => {
|
||||
const language = useLanguage()
|
||||
const unavailable = () => props.available === false
|
||||
const tooltip = () =>
|
||||
unavailable()
|
||||
? (props.reason ?? language.t("common.requestFailed"))
|
||||
: (props.tooltip ??
|
||||
language.t(props.enabled ? "prompt.action.sandbox.enabled" : "prompt.action.sandbox.disabled"))
|
||||
|
||||
return (
|
||||
<Tooltip value={tooltip()} contentClass={unavailable() ? undefined : props.tooltipClass} placement="top">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={props.onToggle}
|
||||
disabled={props.disabled || unavailable()}
|
||||
aria-label={
|
||||
props.enabled ? language.t("prompt.action.sandbox.disable") : language.t("prompt.action.sandbox.enable")
|
||||
}
|
||||
aria-pressed={props.enabled}
|
||||
class={`prompt-status-button ${props.enabled ? "prompt-status-button--active" : ""}`}
|
||||
>
|
||||
<Icon name="lock" size="small" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,8 @@ import type { Meta, StoryObj } from "storybook-solidjs-vite"
|
||||
import { type ParentComponent } from "solid-js"
|
||||
import { StoryProviders, mockSessionValue } from "./StoryProviders"
|
||||
import { SessionContext } from "../context/session"
|
||||
import { PromptInput, SandboxTooltipContent } from "../components/chat/PromptInput"
|
||||
import { PromptInput } from "../components/chat/PromptInput"
|
||||
import { SandboxTooltipContent } from "../components/shared/SandboxButton"
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
|
||||
@@ -685,6 +685,9 @@ export interface CreateMultiVersionRequest {
|
||||
// Overrides `versions`, `providerID`, and `modelID`.
|
||||
variant?: string
|
||||
modelAllocations?: ModelAllocation[]
|
||||
// When set, start each created worktree session with the sandbox override
|
||||
// reconciled to this state. Only sent when sandbox controls are available.
|
||||
sandbox?: boolean
|
||||
}
|
||||
|
||||
// Persist tab order for a context (worktree ID or "local")
|
||||
|
||||
Reference in New Issue
Block a user