Merge pull request #13501 from Kilo-Org/agent-browser-context-view

feat(agent-manager): add local browser previews and element feedback
This commit is contained in:
Marius
2026-08-31 15:15:20 +02:00
committed by GitHub
122 changed files with 7097 additions and 519 deletions
@@ -0,0 +1,6 @@
---
"@kilocode/cli": minor
"kilo-code": minor
---
Preview local applications in Agent Manager with embedded developer tools, grouped diagnostics, and review-style element feedback for precise frontend changes.
+23 -9
View File
@@ -462,6 +462,7 @@
"@xterm/addon-unicode-graphemes": "0.4.0",
"@xterm/addon-web-links": "0.12.0",
"@xterm/xterm": "6.0.0",
"chromium-bidi": "0.8.0",
"diff": "8.0.4",
"fastest-levenshtein": "^1.0.16",
"friendly-words": "1.3.1",
@@ -471,12 +472,14 @@
"lru-cache": "^11.0.2",
"marked": "catalog:",
"openai": "^4.85.4",
"playwright-core": "1.57.0",
"quick-lru": "^7.0.0",
"simple-git": "3.36.0",
"solid-js": "^1.9.11",
"uri-js": "^4.4.1",
"virtua": "catalog:",
"web-tree-sitter": "^0.24.7",
"ws": "8.21.0",
"yaml": "2.8.3",
"zod": "^3.24.2",
},
@@ -492,6 +495,7 @@
"@types/node": "22.x",
"@types/qrcode": "^1.5.6",
"@types/vscode": "^1.105.1",
"@types/ws": "8.18.1",
"@vscode/test-cli": "^0.0.12",
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "^3.7.1",
@@ -1000,25 +1004,25 @@
},
},
"trustedDependencies": [
"esbuild",
"protobufjs",
"web-tree-sitter",
"esbuild",
"tree-sitter-bash",
"protobufjs",
],
"patchedDependencies": {
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch",
"@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch",
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"solid-js@1.9.12": "patches/solid-js@1.9.12.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
},
"overrides": {
"@effect/platform-node-shared": "4.0.0-beta.74",
@@ -3024,6 +3028,8 @@
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"chromium-bidi": ["chromium-bidi@0.8.0", "", { "dependencies": { "mitt": "3.0.1", "urlpattern-polyfill": "10.0.0", "zod": "3.23.8" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-uJydbGdTw0DEUjhoogGveneJVWX/9YuqkWePzMmkBYwtdAqo5d3J/ovNKFr+/2hWXYmYCr6it8mSSTIj6SS6Ug=="],
"chunkd": ["chunkd@2.0.1", "", {}, "sha512-7d58XsFmOq0j6el67Ug9mHf9ELUXsQXYJBkyxhH/k+6Ke0qXRnv0kbemx+Twc6fRJ07C49lcbdgm9FL1Ei/6SQ=="],
"ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
@@ -3252,6 +3258,8 @@
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"devtools-protocol": ["devtools-protocol@0.0.1682007", "", {}, "sha512-eZ75OyAerm6Z3LSvevMk8RHUiKzUQtQGRdwtYnTqzzkof30BGBQML43T7JqfAvBAi/yuTlJ1HtYXAJrDnm/J2A=="],
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
@@ -3966,6 +3974,8 @@
"minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="],
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
"mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="],
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
@@ -4734,6 +4744,8 @@
"url-join": ["url-join@4.0.1", "", {}, "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA=="],
"urlpattern-polyfill": ["urlpattern-polyfill@10.0.0", "", {}, "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
@@ -5354,6 +5366,8 @@
"cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
"chromium-bidi/zod": ["zod@3.23.8", "", {}, "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g=="],
"clean-stack/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"cli-truncate/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d023c0c87ae72134ae6f391cbe263921e19d2411c7ef74466b134d88a3193c42
size 11581
+14
View File
@@ -334,7 +334,21 @@ function getMarkdownShikiWorkerConfig() {
}
}
function notices() {
const deps = {
"playwright-core": ["LICENSE", "NOTICE", "ThirdPartyNotices.txt"],
"chromium-bidi": ["LICENSE"],
}
for (const [name, files] of Object.entries(deps)) {
const root = path.dirname(require.resolve(`${name}/package.json`))
const dir = path.join(__dirname, "dist", "licenses", name)
fs.mkdirSync(dir, { recursive: true })
for (const file of files) fs.copyFileSync(path.join(root, file), path.join(dir, file))
}
}
async function main() {
notices()
const extensionConfig = getExtensionConfig()
const webviewsConfig = getWebviewsConfig()
const shikiWorkerConfig = getShikiWorkerConfig()
+11 -11
View File
@@ -1070,6 +1070,12 @@
"scope": "application",
"description": "Allow Agent Manager to manage sessions and worktrees across multiple repositories as projects. The current workspace repository is always the default project; additional projects can be added and persist across restarts."
},
"kilo-code.new.experimental.browserAutomation": {
"type": "boolean",
"default": false,
"scope": "application",
"description": "Enable the experimental Agent Manager browser panel and browser_open tool."
},
"kilo-code.new.agentManager.terminalButtonDestination": {
"type": "string",
"scope": "application",
@@ -1118,20 +1124,10 @@
"maximum": 24,
"description": "Font size in pixels for the Kilo Code webview UI."
},
"kilo-code.new.browserAutomation.enabled": {
"type": "boolean",
"default": false,
"description": "Enable browser automation powered by Playwright. When enabled, the AI agent can interact with web pages in a visible Chrome window."
},
"kilo-code.new.browserAutomation.useSystemChrome": {
"type": "boolean",
"default": true,
"description": "Use your system's installed Chrome browser instead of downloading a separate Chromium instance."
},
"kilo-code.new.browserAutomation.headless": {
"type": "boolean",
"default": false,
"description": "Run browser automation in headless mode (no visible window). When disabled, you can watch the agent interact with the browser."
"description": "Use system-installed Google Chrome. Disable only when a compatible Playwright Chromium browser is already installed."
},
"kilo-code.new.attention.enabled": {
"type": "boolean",
@@ -1289,6 +1285,7 @@
"@types/node": "22.x",
"@types/qrcode": "^1.5.6",
"@types/vscode": "^1.105.1",
"@types/ws": "8.18.1",
"@vscode/test-cli": "^0.0.12",
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "^3.7.1",
@@ -1328,6 +1325,7 @@
"@xterm/addon-unicode-graphemes": "0.4.0",
"@xterm/addon-web-links": "0.12.0",
"@xterm/xterm": "6.0.0",
"chromium-bidi": "0.8.0",
"diff": "8.0.4",
"fastest-levenshtein": "^1.0.16",
"friendly-words": "1.3.1",
@@ -1337,12 +1335,14 @@
"lru-cache": "^11.0.2",
"marked": "catalog:",
"openai": "^4.85.4",
"playwright-core": "1.57.0",
"quick-lru": "^7.0.0",
"simple-git": "3.36.0",
"solid-js": "^1.9.11",
"uri-js": "^4.4.1",
"virtua": "catalog:",
"web-tree-sitter": "^0.24.7",
"ws": "8.21.0",
"yaml": "2.8.3",
"zod": "^3.24.2"
},
+57 -21
View File
@@ -143,7 +143,8 @@ import {
import { fetchAndSendPendingSuggestions } from "./kilo-provider/handlers/suggestion"
import { nativeTitle } from "./kilo-provider/native-tab-title"
import { isActivity, type Activity } from "../webview-ui/src/utils/session-activity"
import { parseReview, reviewMetadata, type ReviewMessageData } from "./shared/review-comments"
import { type ReviewMessageData } from "./shared/review-comments"
import { feedbackMetadata, parseFeedback, type BrowserFeedbackData } from "./shared/browser-feedback"
import { completesWithoutStatus } from "./kilo-provider/command-completion"
import { KiloProviderMemory } from "./kilo-provider/memory"
@@ -203,6 +204,29 @@ type TypedWebviewMessage = {
type: string
value?: unknown
}
type WebviewMessage = Parameters<Parameters<vscode.Webview["onDidReceiveMessage"]>[0]>[0]
function feedbackMessage(message: { text: string; review?: unknown; browserFeedback?: unknown }) {
return parseFeedback({ review: message.review, browserFeedback: message.browserFeedback }, message.text)
}
type SendWebviewMessage = {
type: "sendMessage"
text: string
messageID?: unknown
sessionID?: string
draftID?: unknown
providerID?: string
modelID?: string
agent?: string
variant?: string
files?: unknown
review?: unknown
browserFeedback?: unknown
agentManagerContext?: unknown
contextDirectory?: unknown
}
type SandboxSupportClient = {
support: (
parameters: { directory?: string },
@@ -1115,21 +1139,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.readyResolvers.splice(0).forEach((r) => r())
break
case "sendMessage": {
const msg = message as typeof message & ContextMessage
await this.handleSendMessage(
message.text,
typeof message.messageID === "string" ? message.messageID : undefined,
message.sessionID,
typeof message.draftID === "string" ? message.draftID : undefined,
message.providerID,
message.modelID,
message.agent,
message.variant,
parseMessageFiles(message.files),
parseReview(message.review, message.text),
typeof message.agentManagerContext === "string" ? message.agentManagerContext : undefined,
typeof msg.contextDirectory === "string" ? msg.contextDirectory : undefined,
)
await this.sendWebviewMessage(message as SendWebviewMessage)
break
}
case "sendCommand": {
@@ -1481,6 +1491,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
break
case "importAndSend": {
const files = parseMessageFiles(message.files)
const feedback = feedbackMessage(message)
void handleImportAndSend(
this.cloudSessionCtx,
message.cloudSessionId,
@@ -1491,9 +1502,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
message.agent,
message.variant,
files,
parseReview(message.review, message.text),
feedback?.review,
typeof message.command === "string" ? message.command : undefined,
typeof message.commandArgs === "string" ? message.commandArgs : undefined,
feedback?.browserFeedback,
)
break
}
@@ -1571,6 +1583,25 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.webviewMessageDisposable = watchWorkStyleConfig((msg) => this.postMessage(msg), this.webviewMessageDisposable)
}
private async sendWebviewMessage(message: SendWebviewMessage): Promise<void> {
const feedback = feedbackMessage(message)
await this.handleSendMessage(
message.text,
typeof message.messageID === "string" ? message.messageID : undefined,
message.sessionID,
typeof message.draftID === "string" ? message.draftID : undefined,
message.providerID,
message.modelID,
message.agent,
message.variant,
parseMessageFiles(message.files),
feedback?.review,
typeof message.agentManagerContext === "string" ? message.agentManagerContext : undefined,
typeof message.contextDirectory === "string" ? message.contextDirectory : undefined,
feedback?.browserFeedback,
)
}
private async handleProfileDataMessage(message: TypedWebviewMessage): Promise<boolean> {
if (message.type === "refreshProfile") {
await handleRefreshProfile(this.authCtx)
@@ -3726,6 +3757,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return vscode.workspace.getConfiguration("kilo-code.new.experimental").get<boolean>("multiProject", false)
}
private browserAutomationSetting(): boolean {
return vscode.workspace.getConfiguration("kilo-code.new.experimental").get<boolean>("browserAutomation", false)
}
private async sendIndexingSettings(projectId?: string) {
if (!this.extensionContext) {
this.postMessage(buildIndexingSettingsMessage())
@@ -3788,6 +3823,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
maxCost: this.maxCostSetting(),
languageCommitMessage: this.commitMessageLanguageSetting(),
multiProject: this.multiProjectSetting(),
browserAutomation: this.browserAutomationSetting(),
}
}
@@ -4008,6 +4044,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
review?: ReviewMessageData,
context?: string,
contextDirectory?: string,
browserFeedback?: BrowserFeedbackData,
): Promise<void> {
if (!this.client) {
this.postMessage({
@@ -4019,6 +4056,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
messageID,
files,
review,
browserFeedback,
})
return
}
@@ -4040,7 +4078,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
parts.push({ type: "file", mime: f.mime, url: f.url, filename: f.filename, source: f.source })
}
}
parts.push({ type: "text", text, metadata: review ? reviewMetadata(review) : undefined })
parts.push({ type: "text", text, metadata: feedbackMetadata(review, browserFeedback) })
const editorContext = await this.gatherEditorContext(dir)
if (draftID && this.closedDrafts.delete(draftID)) {
@@ -4082,6 +4120,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
messageID,
files,
review,
browserFeedback,
})
}
}
@@ -4416,7 +4455,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
/**
* Handle a generic setting update from the webview.
* The key uses dot notation relative to `kilo-code.new` (e.g. "browserAutomation.enabled").
*/
private async handleUpdateSetting(key: string, value: unknown): Promise<void> {
if (key === "maxCost") {
@@ -4515,9 +4553,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.postMessage({
type: "browserSettingsLoaded",
settings: {
enabled: config.get<boolean>("enabled", false),
useSystemChrome: config.get<boolean>("useSystemChrome", true),
headless: config.get<boolean>("headless", false),
},
})
}
@@ -2,8 +2,7 @@ import * as fs from "fs"
import * as path from "path"
import type { KiloClient, Session } from "@kilocode/sdk/v2/client"
import type { KiloConnectionService } from "../services/cli-backend"
import { getErrorMessage, sessionToWebview } from "../kilo-provider-utils"
import { samePath } from "./project/paths"
import { getErrorMessage } from "../kilo-provider-utils"
import { resolveLocalDiffTarget } from "../diff/shared/target"
import { DiffSourceCatalog } from "../diff/sources/catalog"
import { getDiffMarkdownRender, setDiffMarkdownRender } from "../review-settings"
@@ -81,6 +80,9 @@ import { ProjectScope } from "./project/scope"
import type { AgentManagerOutMessage, AgentManagerInMessage } from "./types"
import type { Host, PanelContext, OutputHandle, Disposable } from "./host"
import { focusPanelPrompt, revealPanel } from "./focus-panel"
import type { BrowserBroker } from "../services/browser-automation"
import { createBrowserLifecycle } from "./browser-lifecycle"
import { handleSessionLifecycle } from "./session-lifecycle"
export class AgentManagerProvider implements Disposable {
public static readonly viewType = "kilo-code.new.AgentManagerPanel"
private panel: PanelContext | undefined
@@ -122,6 +124,7 @@ export class AgentManagerProvider implements Disposable {
readonly settings: ProjectWiring["settings"]
/** Session ID most recently loaded via `loadMessages`; updated synchronously. */
private activeSessionId: string | undefined
private readonly browserLifecycle: ReturnType<typeof createBrowserLifecycle>
private visiblePresence = new AgentManagerVisiblePresence(
(ids) => this.connectionService.registerVisible("agent-manager", ids),
() => this.panel?.visible ?? false,
@@ -131,7 +134,16 @@ export class AgentManagerProvider implements Disposable {
private readonly host: Host,
private readonly connectionService: KiloConnectionService,
binary: GitExecutable | string = "git",
browser?: BrowserBroker,
) {
this.browserLifecycle = createBrowserLifecycle({
browser,
host: this.host,
contexts: () => this.contexts,
post: (message) => this.postToWebview(message),
openPanel: () => this.openPanel(true),
log: (...args) => this.log(...args),
})
this.outputChannel = host.createOutput("Kilo Agent Manager")
this.terminalManager = new SessionTerminalManager(
(msg) => this.outputChannel.appendLine(`[SessionTerminal] ${msg}`),
@@ -197,6 +209,7 @@ export class AgentManagerProvider implements Disposable {
push: () => this.pushProjects(),
pushState: (ctx) => this.pushState(ctx),
changed: () => this.onWorkspaceChanged(),
removed: (id) => this.browserLifecycle.closeProject(id),
selected: (target) => this.postToWebview({ type: "agentManager.selectionActivated", target }),
routeSession: (pid, sid, dir, gen) => routeProjectSession(this.panel?.sessions, pid, sid, dir, gen),
})
@@ -301,39 +314,13 @@ export class AgentManagerProvider implements Disposable {
* window, the CLI, the API) appear without waiting for a full re-list.
*/
private onSessionLifecycle(event: unknown): void {
const ev = event as { type?: string; properties?: { info?: Session; sessionID?: string } }
if (ev.type === "session.error") {
if (ev.properties?.sessionID) this.busySessions.delete(ev.properties.sessionID)
return
}
if (ev.type === "session.deleted") {
const id = ev.properties?.sessionID
if (!id) return
this.removedSessions.add(id)
this.busySessions.delete(id)
const ctx = this.contexts.byLiveSession(id)
if (!ctx) return
ctx.removeLiveSession(id)
this.postToWebview({ type: "agentManager.projectSessions", projectId: ctx.id, sessions: [...ctx.sessions()] })
return
}
const info = ev.properties?.info
if (ev.type === "session.created" && info) this.removedSessions.delete(info.id)
const dir = info && !this.removedSessions.has(info.id) ? info.directory : undefined
// Session events from sync or older backends can lack time/directory; a throw
// would escape into the SSE dispatch loop and starve the other listeners.
if (!info?.time || !dir || (info.parentID !== undefined && info.parentID !== null)) return
const ctx = this.contexts.byDirectory(dir)
if (!ctx || ctx.lifecycle !== "ready") return
const state = ctx.peekState()
const managed = state?.getSession(info.id)
const worktreeId =
managed?.worktreeId ?? state?.getWorktrees().find((wt) => wt.path && samePath(wt.path, dir))?.id ?? null
ctx.upsertSession({ ...sessionToWebview(info), worktreeId })
// The next regular push re-lists from the backend to reconcile the
// optimistic entry (position, subtrees, deletions elsewhere).
ctx.invalidateSessions()
this.postToWebview({ type: "agentManager.projectSessions", projectId: ctx.id, sessions: [...ctx.sessions()] })
handleSessionLifecycle(event, {
busy: this.busySessions,
removed: this.removedSessions,
contexts: this.contexts,
closeBrowser: (id) => this.browserLifecycle.close(id),
post: (message) => this.postToWebview(message),
})
}
private onSessionStatus(event: unknown): void {
const props = (event as { properties?: { sessionID?: string; status?: { type?: string } } }).properties
@@ -403,6 +390,7 @@ export class AgentManagerProvider implements Disposable {
panel.dispose()
}
this.panel = ctx
this.browserLifecycle.replay()
this.statsPoller.setVisible(ctx.visible)
this.projectPollers.setVisible(ctx.visible)
@@ -421,6 +409,7 @@ export class AgentManagerProvider implements Disposable {
this.pushProjects()
void this.sendRepoInfo()
this.sendKeybindings()
void ctx.waitForReady().then(() => this.browserLifecycle.replay())
this.prBridge.attachPanel(ctx)
ctx.onDidDispose(() => {
// Only clear if this is still the active panel — a newer panel may
@@ -499,8 +488,6 @@ export class AgentManagerProvider implements Disposable {
.catch((err) => this.log("Failed to initialize expanded project:", err))
}
// Message interceptor
private async onMessage(msg: Record<string, unknown>): Promise<Record<string, unknown> | null> {
if (this.prBridge.handleMessage(msg)) return null
if (msg.type === "requestFileSearch" && typeof msg.sessionID !== "string" && this.activeSessionId) {
@@ -535,6 +522,7 @@ export class AgentManagerProvider implements Disposable {
if (worktree !== undefined) return worktree
const session = this.onSessionMessage(m, msg)
if (session !== undefined) return session
if (this.browserLifecycle.handle(m)) return null
const ui = this.onUiMessage(m, msg)
if (ui !== undefined) return ui
const state = this.onStateMessage(m)
@@ -605,6 +593,7 @@ export class AgentManagerProvider implements Disposable {
msg: Record<string, unknown>,
): Record<string, unknown> | null | undefined {
if (m.type === "agentManager.openLocally") {
this.browserLifecycle.close(m.sessionId)
this.panel?.sessions.clearSessionDirectory(m.sessionId)
const state = this.getStateManager()
if (state?.getSession(m.sessionId)) {
@@ -635,6 +624,7 @@ export class AgentManagerProvider implements Disposable {
if (!state.getSession(m.sessionId)) state.addSession(m.sessionId, null)
return
}
this.browserLifecycle.close(m.sessionId)
state.removeSession(m.sessionId)
})
return null
@@ -1416,6 +1406,7 @@ export class AgentManagerProvider implements Disposable {
reviewMarkdownRender: getDiffMarkdownRender(),
terminalDestination: this.destination.value(),
terminalFont: readTerminalFont(),
browserAutomation: this.host.browserAutomation(),
isGitRepo: true,
defaultBaseBranch: state.getDefaultBaseBranch(),
activeTarget: state.getActiveTarget(),
@@ -1449,6 +1440,7 @@ export class AgentManagerProvider implements Disposable {
isGitRepo: false,
runStatuses: [],
runScriptConfigured: false,
browserAutomation: this.host.browserAutomation(),
})
}
private get lifecycleHost(): LifecycleHost {
@@ -1459,8 +1451,11 @@ export class AgentManagerProvider implements Disposable {
notifyReady: (sid, result, id) => this.notifyWorktreeReady(sid, result, id),
sessions: {
register: (session) => this.panel?.sessions.registerSession(session),
clearDirectory: (sid) => this.panel?.sessions.clearSessionDirectory(sid),
setSessionDirectory: (sid, dir) => this.panel?.sessions.setSessionDirectory(sid, dir),
clearDirectory: (sid) => (this.browserLifecycle?.close(sid), this.panel?.sessions.clearSessionDirectory(sid)),
setSessionDirectory: (sid, dir) => (
this.browserLifecycle?.close(sid),
this.panel?.sessions.setSessionDirectory(sid, dir)
),
registerSessionRoute: (ref, dir, gen) => this.panel?.sessions.registerSessionRoute?.(ref, dir, gen),
directories: () => this.panel?.sessions.getSessionDirectories(),
abort: (ids) => this.panel?.sessions.abortSessions(ids) ?? Promise.resolve(),
@@ -1607,6 +1602,7 @@ export class AgentManagerProvider implements Disposable {
}
private onWorkspaceChanged(): void {
if (this.contexts.syncPinned()) {
void this.browserLifecycle.closeAll()
this.activeSessionId = undefined
this.stateReady = this.initializeState()
void this.sendRepoInfo()
@@ -1864,9 +1860,18 @@ export class AgentManagerProvider implements Disposable {
void this.shutdown()
}
public refreshBrowserAutomation(): void {
if (!this.host.browserAutomation()) void this.browserLifecycle.closeAll()
if (!this.context) {
return this.pushEmptyState()
}
this.pushState()
}
private async disposeAsync(): Promise<void> {
await this.stateReady?.catch((err) => this.log("dispose: stateReady rejected:", err))
await this.contexts.dispose()
await this.browserLifecycle.dispose()
this.unsubTool?.()
this.activity.dispose()
this.unsubFont?.()
@@ -0,0 +1,67 @@
import { BrowserBroker, type BrowserRoute, type BrowserState } from "../services/browser-automation"
import type { Host } from "./host"
import type { AgentManagerInMessage, AgentManagerOutMessage } from "./types"
import type { ProjectContexts } from "./project/contexts"
import { canonicalizePath, samePath } from "./project/paths"
import { browserMessage, handleBrowserMessage } from "./browser-message"
export function createBrowserLifecycle(input: {
browser?: BrowserBroker
host: Host
contexts: () => ProjectContexts
post: (message: AgentManagerOutMessage) => void
openPanel: () => void
log: (...args: unknown[]) => void
}) {
const browser = input.browser ?? new BrowserBroker({ log: input.log })
browser.bind((route: BrowserRoute) => {
const directory = canonicalizePath(route.directory)
const ctx = input.contexts().byDirectory(directory)
if (!ctx || (route.projectId && route.projectId !== ctx.id)) return undefined
const state = ctx.peekState()
const stored = state?.getSession(route.sessionId)
const live = ctx.sessions().find((session) => session.id === route.sessionId)
if (!stored && !live) return undefined
const worktree = stored?.worktreeId ?? live?.worktreeId
const expected = worktree ? state?.getWorktree(worktree)?.path : ctx.root
if (!expected || !samePath(canonicalizePath(expected), directory)) return undefined
return { projectId: ctx.id, sessionId: route.sessionId, directory }
})
const post = (state: BrowserState) => {
const active = input.contexts().active()
if ((state.status === "starting" || state.status === "loading") && state.projectId === active?.id) {
input.openPanel()
}
input.post(browserMessage(state))
}
const off = browser.subscribe(post)
return {
handle(message: AgentManagerInMessage): boolean {
return handleBrowserMessage(message, {
host: input.host,
contexts: input.contexts(),
browser,
post: input.post,
log: input.log,
})
},
replay(): void {
browser.replay(post)
},
close(sessionId: string, projectId?: string): void {
void browser.close(sessionId, projectId).catch((error) => input.log("Failed to close browser session:", error))
},
closeProject(projectId: string): void {
for (const session of browser.sessions()) {
if (browser.get(session, projectId)) this.close(session, projectId)
}
},
closeAll(): Promise<void> {
return Promise.all([...browser.sessions()].map((sessionId) => browser.close(sessionId))).then(() => undefined)
},
dispose(): Promise<void> {
off()
return browser.disposeAsync()
},
}
}
@@ -0,0 +1,201 @@
import { diagnostic, type BrowserBroker, type BrowserState } from "../services/browser-automation"
import type { AgentManagerInMessage, AgentManagerOutMessage } from "./types"
import type { ProjectContexts } from "./project/contexts"
import type { Host } from "./host"
type BrowserMessage = Extract<AgentManagerInMessage, { type: `agentManager.browser.${string}` }>
function position(message: BrowserMessage): { x: number; y: number; width: number; height: number } | undefined {
if (
typeof message.x !== "number" ||
typeof message.y !== "number" ||
typeof message.width !== "number" ||
typeof message.height !== "number"
) {
return
}
return { x: message.x, y: message.y, width: message.width, height: message.height }
}
function fail(
deps: { post: (message: AgentManagerOutMessage) => void },
m: BrowserMessage,
error: string,
state?: BrowserState,
): void {
if (m.type === "agentManager.browser.inspect") {
deps.post({
type: "agentManager.browserInspection",
projectId: m.projectId,
sessionId: m.sessionId,
requestId: m.requestId ?? "",
hover: m.hover,
logs: [],
error,
})
return
}
if (state) {
deps.post(browserMessage({ ...state, error }))
return
}
deps.post({
type: "agentManager.browserState",
browserId: "",
projectId: m.projectId,
sessionId: m.sessionId,
status: "error",
errors: 0,
error,
})
}
function route(contexts: ProjectContexts, message: BrowserMessage): { project: string; directory: string } | undefined {
const ctx = contexts.resolve(message.projectId ?? contexts.active()?.id ?? "")
if (!ctx) return
const state = ctx.peekState()
const session = state?.getSession(message.sessionId)
const live = ctx.sessions().find((item) => item.id === message.sessionId)
if (!session && !live) return
const worktree = session?.worktreeId ?? live?.worktreeId
const directory = worktree ? state?.getWorktree(worktree)?.path : ctx.root
return directory ? { project: ctx.id, directory } : undefined
}
function action(
m: BrowserMessage,
deps: {
host: Host
contexts: ProjectContexts
browser: BrowserBroker
post: (message: AgentManagerOutMessage) => void
log: (...args: unknown[]) => void
},
): boolean {
const scope = route(deps.contexts, m)
if (!scope) {
fail(deps, m, "Browser session is not available in the selected project.")
return true
}
if (m.type === "agentManager.browser.state") {
const current = deps.browser.get(m.sessionId, scope.project)
if (current) deps.post(browserMessage(current))
return true
}
if (m.type === "agentManager.browser.devtools") {
void deps.browser
.devtools(m.sessionId, scope.project, m.theme === "light" ? "light" : "dark")
.then((tools) =>
deps.post({
type: "agentManager.browserDevtools",
projectId: scope.project,
sessionId: m.sessionId,
...tools,
}),
)
.catch((error: unknown) => {
deps.log("Browser developer tools failed:", error)
fail(deps, m, diagnostic(error), deps.browser.get(m.sessionId, scope.project))
})
return true
}
if (m.type === "agentManager.browser.open") {
if (!m.url) return true
void deps.browser
.open({ projectId: scope.project, sessionId: m.sessionId, directory: scope.directory }, m.url)
.catch((error: unknown) => {
deps.log("Browser open failed:", error)
const current = deps.browser.get(m.sessionId, scope.project)
if (current) {
deps.post(browserMessage(current))
return
}
fail(deps, m, diagnostic(error, m.url))
})
return true
}
if (m.type === "agentManager.browser.refresh") {
void deps.browser.refresh(m.sessionId, scope.project).catch((error: unknown) => {
deps.log("Browser refresh failed:", error)
const current = deps.browser.get(m.sessionId, scope.project)
if (current) deps.post(browserMessage(current))
})
return true
}
if (m.type === "agentManager.browser.inspect" || m.type === "agentManager.browser.input") {
const point = position(m)
if (!point) {
fail(deps, m, "Browser element coordinates are required.", deps.browser.get(m.sessionId, scope.project))
return true
}
if (m.type === "agentManager.browser.input") {
void deps.browser.input(m.sessionId, scope.project, point, m.click === true).catch((error: unknown) => {
deps.log("Browser developer tools input failed:", error)
fail(deps, { ...m, projectId: scope.project }, diagnostic(error), deps.browser.get(m.sessionId, scope.project))
})
return true
}
void deps.browser
.inspect(m.sessionId, scope.project, point, m.hover !== true)
.then((inspection) =>
deps.post({
type: "agentManager.browserInspection",
projectId: scope.project,
sessionId: m.sessionId,
requestId: m.requestId ?? "",
...inspection,
hover: m.hover,
}),
)
.catch((error: unknown) => {
deps.log("Browser element inspection failed:", error)
fail(deps, { ...m, projectId: scope.project }, diagnostic(error))
})
return true
}
void deps.browser
.close(m.sessionId, scope.project)
.catch((error: unknown) => deps.log("Browser close failed:", error))
return true
}
export function handleBrowserMessage(
message: AgentManagerInMessage,
deps: {
host: Host
contexts: ProjectContexts
browser: BrowserBroker
post: (message: AgentManagerOutMessage) => void
log: (...args: unknown[]) => void
},
): boolean {
if (!message.type.startsWith("agentManager.browser.")) return false
const m = message as BrowserMessage
if (!deps.host.isTrusted()) {
fail(deps, m, "Browser preview requires a trusted workspace.")
return true
}
if (!deps.host.browserAutomation()) {
fail(deps, m, "Browser automation is disabled. Enable it in Kilo Settings > Experimental.")
return true
}
return action(m, deps)
}
export function browserMessage(state: BrowserState): AgentManagerOutMessage {
return {
type: "agentManager.browserState",
browserId: state.browserId,
projectId: state.projectId,
sessionId: state.sessionId,
navigation: state.navigation,
status: state.status,
inspecting: state.inspecting,
url: state.url,
title: state.title,
errors: state.errors,
logs: state.logs,
error: state.error,
frameError: state.frameError,
}
}
@@ -131,6 +131,7 @@ export interface Host {
/** Whether the experimental multi-project Agent Manager mode is enabled. */
multiProject(): boolean
browserAutomation(): boolean
/** Read the persisted additional-project registry payload. */
readProjects(): unknown
@@ -33,4 +33,11 @@ export const STATE_GATED = new Set<string>([
"agentManager.toggleSectionCollapsed",
"agentManager.moveToSection",
"agentManager.moveSection",
"agentManager.browser.open",
"agentManager.browser.refresh",
"agentManager.browser.close",
"agentManager.browser.state",
"agentManager.browser.inspect",
"agentManager.browser.input",
"agentManager.browser.devtools",
])
@@ -42,6 +42,7 @@ export function createProjectWiring(opts: {
pushState: (ctx?: ProjectContext) => void
/** Re-derive the pinned project after workspace folder changes. */
changed: () => void
removed?: (id: string) => void
/** Acknowledge an atomically validated sidebar selection. */
selected: (target: import("./route").SidebarTarget) => void
/** Route one session to a directory inside a project (override + project route). */
@@ -55,7 +56,10 @@ export function createProjectWiring(opts: {
workspaceRoot: () => opts.host.workspacePath(),
registry,
enabled: () => opts.host.multiProject(),
remove: (id) => opts.host.unregisterProjectRoutes(id),
remove: (id) => {
opts.host.unregisterProjectRoutes(id)
opts.removed?.(id)
},
deps: { log: opts.output, git: opts.git },
})
const messages: ProjectMessageDeps = {
@@ -0,0 +1,57 @@
import type { Session } from "@kilocode/sdk/v2/client"
import { sessionToWebview } from "../kilo-provider-utils"
import { samePath } from "./project/paths"
import type { ProjectContexts } from "./project/contexts"
import type { AgentManagerOutMessage } from "./types"
type Deps = {
busy: Set<string>
removed: Set<string>
contexts: ProjectContexts
closeBrowser: (sessionId: string) => void
post: (message: AgentManagerOutMessage) => void
}
type Event = { type?: string; properties?: { info?: Session; sessionID?: string } }
function remove(id: string, deps: Deps): void {
deps.removed.add(id)
deps.busy.delete(id)
deps.closeBrowser(id)
const ctx = deps.contexts.byLiveSession(id)
if (!ctx) return
ctx.removeLiveSession(id)
deps.post({ type: "agentManager.projectSessions", projectId: ctx.id, sessions: [...ctx.sessions()] })
}
function upsert(info: Session, deps: Deps): void {
if (deps.removed.has(info.id)) return
const dir = info.directory
if (!info.time || !dir || (info.parentID !== undefined && info.parentID !== null)) return
const ctx = deps.contexts.byDirectory(dir)
if (!ctx || ctx.lifecycle !== "ready") return
const state = ctx.peekState()
const managed = state?.getSession(info.id)
const worktreeId =
managed?.worktreeId ?? state?.getWorktrees().find((wt) => wt.path && samePath(wt.path, dir))?.id ?? null
ctx.upsertSession({ ...sessionToWebview(info), worktreeId })
ctx.invalidateSessions()
deps.post({ type: "agentManager.projectSessions", projectId: ctx.id, sessions: [...ctx.sessions()] })
}
export function handleSessionLifecycle(event: unknown, deps: Deps): void {
const ev = event as Event
if (ev.type === "session.error") {
if (ev.properties?.sessionID) deps.busy.delete(ev.properties.sessionID)
return
}
if (ev.type === "session.deleted") {
const id = ev.properties?.sessionID ?? ev.properties?.info?.id
if (id) remove(id, deps)
return
}
const info = ev.properties?.info
if (!info) return
if (ev.type === "session.created") deps.removed.delete(info.id)
upsert(info, deps)
}
@@ -9,6 +9,7 @@
import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client"
import type { DiffImage } from "../diff/types"
import type { BrowserElement } from "../services/browser-automation"
import type { Worktree, ManagedSession, Section } from "./WorktreeStateManager"
import type { WorktreeStats, LocalStats } from "./GitStatsPoller"
import type { ApplyConflict } from "./GitOps"
@@ -19,6 +20,7 @@ import type { ProjectSnapshot } from "./project/contexts"
import type { SidebarTarget } from "./project/route"
import type { TerminalDestination } from "./terminal-destination"
import type { ScriptTerminalView } from "./ScriptTerminalManager"
import type { BrowserFeedbackData } from "../shared/browser-feedback"
export type { TerminalFont }
export type { ProjectSnapshot }
@@ -174,6 +176,7 @@ interface StateMessage {
activeTarget?: SidebarTarget
terminalDestination?: TerminalDestination
terminalFont?: TerminalFont
browserAutomation?: boolean
}
/** Project catalog pushed to the webview after registry or context changes. */
@@ -320,6 +323,7 @@ interface SendInitialMessage {
agent?: string
variant?: string
files?: Array<{ mime: string; url: string }>
browserFeedback?: BrowserFeedbackData
}
interface BranchesMessage {
@@ -452,6 +456,43 @@ interface ActionOutMessage {
action: string
}
interface BrowserStateMessage {
type: "agentManager.browserState"
browserId: string
projectId?: string
sessionId: string
navigation?: number
status: "starting" | "ready" | "loading" | "error" | "closed"
inspecting?: boolean
url?: string
title?: string
errors: number
logs?: string[]
error?: string
frameError?: string
}
interface BrowserInspectionMessage {
type: "agentManager.browserInspection"
error?: string
requestId: string
projectId?: string
sessionId: string
url?: string
title?: string
element?: BrowserElement
logs: string[]
hover?: boolean
}
interface BrowserDevtoolsMessage {
type: "agentManager.browserDevtools"
browserId: string
projectId?: string
sessionId: string
url: string
}
interface RunStatusMessage extends RunStatus {
type: "agentManager.runStatus"
/** Owning project for this status. Absent in legacy single-project mode. */
@@ -491,6 +532,9 @@ export type AgentManagerOutMessage =
| PRErrorOutMessage
| CommentActionResultMessage
| ActionOutMessage
| BrowserStateMessage
| BrowserInspectionMessage
| BrowserDevtoolsMessage
| RunStatusMessage
| TerminalCreatedMessage
| TerminalRestartedMessage
@@ -915,6 +959,7 @@ interface SendMessageIn {
files?: Array<{ mime: string; url: string; filename?: string; source?: FileSourceIn }>
agentManagerContext?: string
contextDirectory?: string
browserFeedback?: BrowserFeedbackData
}
interface SendCommandIn {
@@ -1084,6 +1129,28 @@ interface TerminalDestinationSelectedIn {
destination: TerminalDestination
}
interface BrowserRequestIn {
type:
| "agentManager.browser.open"
| "agentManager.browser.refresh"
| "agentManager.browser.close"
| "agentManager.browser.state"
| "agentManager.browser.inspect"
| "agentManager.browser.input"
| "agentManager.browser.devtools"
sessionId: string
requestId?: string
projectId?: string
url?: string
x?: number
y?: number
width?: number
height?: number
hover?: boolean
click?: boolean
theme?: "dark" | "light"
}
/** All messages the Agent Manager expects from the webview (onMessage input). */
export type AgentManagerInMessage =
| CreateWorktreeIn
@@ -1171,3 +1238,4 @@ export type AgentManagerInMessage =
| TerminalResizeIn
| TerminalRestartIn
| TerminalDestinationSelectedIn
| BrowserRequestIn
@@ -54,6 +54,7 @@ export class VscodeHost implements Host {
vscode.ViewColumn.One,
{
enableScripts: true,
enableForms: true,
retainContextWhenHidden: true,
localResourceRoots: [this.extensionUri],
},
@@ -85,6 +86,7 @@ export class VscodeHost implements Host {
): PanelContext {
panel.webview.options = {
enableScripts: true,
enableForms: true,
localResourceRoots: [this.extensionUri],
}
@@ -101,6 +103,8 @@ export class VscodeHost implements Host {
workerUri: panel.webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "shiki-worker.js")),
title: "Agent Manager",
port,
browserAutomation: this.browserAutomation(),
frameSrc: ["localhost", "127.0.0.1"].map((host) => `http://${host}:*`).join(" "),
})
const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context, {
@@ -244,6 +248,10 @@ export class VscodeHost implements Host {
return vscode.workspace.getConfiguration("kilo-code.new.experimental").get("multiProject", false)
}
browserAutomation(): boolean {
return vscode.workspace.getConfiguration("kilo-code.new.experimental").get("browserAutomation", false)
}
readProjects(): unknown {
return this.context.globalState.get("agentManager.projects")
}
+19 -11
View File
@@ -17,7 +17,7 @@ import { registerAutocompleteProvider } from "./services/autocomplete"
import { ensureBackendForAutocomplete } from "./services/autocomplete/ensure-backend"
import { AutocompleteServiceManager } from "./services/autocomplete/AutocompleteServiceManager"
import { AttentionService } from "./services/attention"
import { BrowserAutomationService } from "./services/browser-automation"
import { BrowserBroker } from "./services/browser-automation"
import { TelemetryEventName, TelemetryProxy } from "./services/telemetry"
import { registerCommitMessageService } from "./services/commit-message"
import { registerCodeActions, registerTerminalActions, KiloCodeActionProvider } from "./services/code-actions"
@@ -56,8 +56,16 @@ export async function activate(context: vscode.ExtensionContext) {
const telemetry = TelemetryProxy.getInstance()
const browserBroker = new BrowserBroker({
log: (...args) => console.warn("[Kilo New] BrowserBroker:", ...args),
enabled: () => vscode.workspace.getConfiguration("kilo-code.new.experimental").get("browserAutomation", false),
trusted: () => vscode.workspace.isTrusted,
useSystemChrome: () =>
vscode.workspace.getConfiguration("kilo-code.new.browserAutomation").get("useSystemChrome", true),
})
// Create shared connection service (one server for all webviews)
const connectionService = new KiloConnectionService(context)
const connectionService = new KiloConnectionService(context, () => browserBroker.env())
const notebookBridge = createNotebookBridge(connectionService)
let restore = context.workspaceState.get<RestoreState>(RESTORE_KEY) ?? {}
const remember = (patch: RestoreState) => {
@@ -67,20 +75,13 @@ export async function activate(context: vscode.ExtensionContext) {
void context.workspaceState.update(RESTORE_KEY, restore)
}
// Create browser automation service (manages Playwright MCP registration)
const browserAutomationService = new BrowserAutomationService(connectionService)
browserAutomationService.syncWithSettings()
// Create remote status service (one status bar item for all webviews)
const remoteService = new RemoteStatusService()
context.subscriptions.push(remoteService)
connectionService.setRemoteService(remoteService)
// Re-register browser automation MCP server on CLI backend reconnect, configure telemetry,
// set remote service client, and reload autocomplete so it picks up the now-available backend connection.
const unsubscribeStateChange = connectionService.onStateChange((state) => {
if (state === "connected") {
browserAutomationService.reregisterIfEnabled()
const config = connectionService.getServerConfig()
if (config) {
telemetry.configure(config.baseUrl, config.password)
@@ -171,9 +172,16 @@ export async function activate(context: vscode.ExtensionContext) {
log: (message) => console.warn(`[Kilo New] ${message}`),
})
const binary = process.platform === "win32" ? await git() : git
const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService, binary)
const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService, binary, browserBroker)
agentManagerProvider.onPanelVisibilityChange((visible) => remember({ agentManager: visible }))
agentManager = agentManagerProvider
context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration((event) => {
if (event.affectsConfiguration("kilo-code.new.experimental.browserAutomation")) {
agentManagerProvider.refreshBrowserAutomation()
}
}),
)
context.subscriptions.push(agentManagerProvider)
// Wire "Continue in Worktree" from sidebar → Agent Manager
@@ -636,7 +644,7 @@ export async function activate(context: vscode.ExtensionContext) {
shuttingDown = true
unsubscribeStateChange()
attention.dispose()
browserAutomationService.dispose()
browserBroker.dispose()
provider.dispose()
notebookBridge.dispose()
connectionService.dispose()
@@ -9,7 +9,8 @@ import type { KiloClient, Session, TextPartInput, FilePartInput } from "@kilocod
import type { CloudSessionData, EditorContext } from "../../services/cli-backend/types"
import { getErrorMessage, sessionToWebview, mapCloudSessionMessageToWebviewMessage } from "../../kilo-provider-utils"
import type { MessageFile } from "../message-files"
import { reviewMetadata, type ReviewMessageData } from "../../shared/review-comments"
import { type ReviewMessageData } from "../../shared/review-comments"
import { feedbackMetadata, type BrowserFeedbackData } from "../../shared/browser-feedback"
import { completesWithoutStatus } from "../command-completion"
const TIMEOUT = 30_000
@@ -124,6 +125,7 @@ export async function handleImportAndSend(
review?: ReviewMessageData,
command?: string,
commandArgs?: string,
browserFeedback?: BrowserFeedbackData,
): Promise<void> {
if (!ctx.client) {
ctx.postMessage({
@@ -216,7 +218,7 @@ export async function handleImportAndSend(
parts.push({ type: "file", mime: f.mime, url: f.url, filename: f.filename, source: f.source })
}
}
parts.push({ type: "text", text, metadata: review ? reviewMetadata(review) : undefined })
parts.push({ type: "text", text, metadata: feedbackMetadata(review, browserFeedback) })
const editorContext = await ctx.gatherEditorContext()
await client.session.promptAsync(
@@ -247,6 +249,7 @@ export async function handleImportAndSend(
messageID,
files,
review: command ? undefined : review,
browserFeedback: command ? undefined : browserFeedback,
})
}
}
@@ -1,166 +0,0 @@
import * as vscode from "vscode"
import type { KiloClient } from "@kilocode/sdk/v2/client"
import type { KiloConnectionService } from "../cli-backend"
type BrowserAutomationState = "disabled" | "registering" | "connected" | "failed" | "disconnected"
export class BrowserAutomationService implements vscode.Disposable {
private state: BrowserAutomationState = "disabled"
private disposables: vscode.Disposable[] = []
// MCP server name used when registering with the CLI backend
private static readonly MCP_SERVER_NAME = "kilo-playwright"
constructor(private readonly connectionService: KiloConnectionService) {
// Listen for settings changes
this.disposables.push(
vscode.workspace.onDidChangeConfiguration((e) => {
if (e.affectsConfiguration("kilo-code.new.browserAutomation")) {
this.syncWithSettings()
}
}),
)
}
/**
* Read settings and enable/disable accordingly.
* Called on construction and when settings change.
*/
async syncWithSettings(): Promise<void> {
const config = vscode.workspace.getConfiguration("kilo-code.new.browserAutomation")
const enabled = config.get<boolean>("enabled", false)
if (enabled) {
await this.register()
} else {
await this.unregister()
}
}
/**
* Re-register the MCP server after CLI backend reconnects.
* Should be called from the connection state change handler.
*/
async reregisterIfEnabled(): Promise<void> {
const config = vscode.workspace.getConfiguration("kilo-code.new.browserAutomation")
const enabled = config.get<boolean>("enabled", false)
if (enabled) {
await this.register()
}
}
/**
* Register the Playwright MCP server with the CLI backend.
*/
private async register(): Promise<void> {
this.setState("registering")
const client = this.getClient()
if (!client) {
console.error("[Kilo New] BrowserAutomationService: No SDK client available")
this.setState("failed")
return
}
const config = vscode.workspace.getConfiguration("kilo-code.new.browserAutomation")
const useSystemChrome = config.get<boolean>("useSystemChrome", true)
const headless = config.get<boolean>("headless", false)
// Build the command for the Playwright MCP server
const command = ["npx", "@playwright/mcp@latest"]
if (headless) {
command.push("--headless")
}
if (useSystemChrome) {
command.push("--browser", "chrome")
}
try {
const directory = this.getWorkspaceDirectory()
const { data: status } = await client.mcp.add(
{
name: BrowserAutomationService.MCP_SERVER_NAME,
config: {
type: "local",
command,
enabled: true,
timeout: 60000,
},
directory,
},
{ throwOnError: true },
)
const serverStatus = status[BrowserAutomationService.MCP_SERVER_NAME]
if (serverStatus?.status === "connected") {
this.setState("connected")
} else if (serverStatus?.status === "failed") {
console.error(
"[Kilo New] BrowserAutomationService: MCP server failed:",
(serverStatus as { error?: string }).error,
)
this.setState("failed")
} else {
this.setState("disconnected")
}
} catch (error) {
console.error("[Kilo New] BrowserAutomationService: Failed to register MCP server:", error)
this.setState("failed")
}
}
/**
* Unregister/disconnect the Playwright MCP server.
*/
private async unregister(): Promise<void> {
if (this.state === "disabled") {
return
}
const client = this.getClient()
if (client) {
try {
const directory = this.getWorkspaceDirectory()
await client.mcp.disconnect(
{ name: BrowserAutomationService.MCP_SERVER_NAME, directory },
{ throwOnError: true },
)
} catch (error) {
console.error("[Kilo New] BrowserAutomationService: Failed to disconnect MCP server:", error)
}
}
this.setState("disabled")
}
private getClient(): KiloClient | null {
try {
return this.connectionService.getClient()
} catch {
return null
}
}
private getWorkspaceDirectory(): string {
const folders = vscode.workspace.workspaceFolders
if (folders && folders.length > 0) {
return folders[0].uri.fsPath
}
return process.cwd()
}
private setState(state: BrowserAutomationState): void {
if (this.state === state) {
return
}
console.log(`[Kilo New] BrowserAutomationService: State ${this.state} → ${state}`)
this.state = state
}
dispose(): void {
for (const d of this.disposables) {
d.dispose()
}
this.disposables = []
}
}
@@ -0,0 +1,781 @@
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto"
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
import type { Socket } from "node:net"
import { URL } from "node:url"
import { stripVTControlCharacters } from "node:util"
import { chromium, type BrowserContext, type LaunchOptions, type Page } from "playwright-core"
import { BrowserDevtools } from "./browser-devtools"
import { capture as element, locate } from "./browser-element"
import { options } from "./browser-runtime"
export type BrowserStatus = "starting" | "ready" | "loading" | "error" | "closed"
export interface BrowserRoute {
projectId?: string
sessionId: string
directory: string
}
export interface BrowserState {
browserId: string
projectId?: string
sessionId: string
navigation: number
status: BrowserStatus
inspecting?: boolean
url?: string
title?: string
screenshot?: string
mime?: "image/jpeg"
errors: number
logs?: string[]
error?: string
frameError?: string
}
export interface BrowserElement {
tag: string
id?: string
classes?: string
text?: string
selector?: string
rect?: { x: number; y: number; width: number; height: number }
hierarchy?: string[]
html?: string
styles?: { color?: string; backgroundColor?: string }
source?: { file: string; line?: number; column?: number }
}
export interface BrowserInspection {
url?: string
title?: string
element?: BrowserElement
logs: string[]
}
interface BrowserDevtoolsInfo {
browserId: string
url: string
}
export interface BrowserBrokerOptions {
log: (...args: unknown[]) => void
enabled?: () => boolean
trusted?: () => boolean
launch?: (options: LaunchOptions) => Promise<BrowserContextFactory>
useSystemChrome?: () => boolean
}
export interface BrowserContextFactory {
debugging?: number
newContext(options: { serviceWorkers: "block"; viewport: { width: number; height: number } }): Promise<BrowserContext>
close(): Promise<void>
}
interface Entry {
route: BrowserRoute
browserId: string
context: BrowserContext
page: Page
origin: string
state: BrowserState
response?: number
}
class BrowserNavigationError extends Error {
constructor(
message: string,
readonly status?: number,
) {
super(message)
this.name = "BrowserNavigationError"
}
}
type RequestBody = {
sessionID?: unknown
projectID?: unknown
directory?: unknown
url?: unknown
}
const MAX_BODY = 32 * 1024
const MAX_SCREENSHOT = 2 * 1024 * 1024
export function diagnostic(error: unknown, url?: string): string {
const text = stripVTControlCharacters(error instanceof Error ? error.message : String(error))
if (/ERR_CONNECTION_REFUSED|ECONNREFUSED/i.test(text)) {
return `Cannot connect to ${url ?? "the local application"}. Make sure the local server is running.`
}
if (/ERR_CONNECTION_TIMED_OUT|ETIMEDOUT|Timeout \d+ms exceeded/i.test(text)) {
return "The local application did not respond in time. Check the server and try again."
}
if (/Target page, context or browser has been closed|Browser has been closed/i.test(text)) {
return "The browser session was closed. Reopen the browser and try again."
}
return text
.split(/\n\s*Call log:/i)[0]
.replace(/^page\.(?:goto|reload):\s*/i, "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 500)
}
function reserve(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer()
server.once("error", reject)
server.listen(0, "127.0.0.1", () => {
const address = server.address()
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Browser developer tools did not receive a local port")))
return
}
server.close((error) => {
if (error) return reject(error)
resolve(address.port)
})
})
})
}
function framing(headers: Record<string, string> | undefined): string | undefined {
if (!headers) return undefined
const options = headers["x-frame-options"]?.trim().toLowerCase()
if (options && (options === "deny" || options === "sameorigin" || options.startsWith("allow-from"))) {
return `This application blocks embedded browser previews with X-Frame-Options: ${options.toUpperCase()}.`
}
const policy = headers["content-security-policy"]
?.split(";")
.map((part) => part.trim())
.find((part) => part.toLowerCase().startsWith("frame-ancestors"))
if (policy && /(?:^|\s)'(?:none|self)'(?:\s|$)/i.test(policy)) {
return `This application blocks embedded browser previews with Content-Security-Policy: ${policy}.`
}
return undefined
}
function loopback(url: URL): boolean {
return url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname)
}
function json(res: ServerResponse, status: number, value: unknown): void {
const body = JSON.stringify(value)
res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(body) })
res.end(body)
}
function read(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
let size = 0
req.on("data", (chunk: Buffer) => {
size += chunk.length
if (size > MAX_BODY) {
reject(new Error("Request body is too large"))
req.destroy()
return
}
chunks.push(chunk)
})
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")))
req.on("error", reject)
})
}
export class BrowserBroker {
private readonly entries = new Map<string, Entry>()
private readonly pending = new Map<string, Promise<unknown>>()
private readonly listeners = new Set<(state: BrowserState) => void>()
private readonly token = randomBytes(32).toString("hex")
private owner: ((route: BrowserRoute) => BrowserRoute | undefined) | undefined
private server: Server | undefined
private readonly sockets = new Set<Socket>()
private port: number | undefined
private debugging: number | undefined
private tools: BrowserDevtools | undefined
private browser: BrowserContextFactory | undefined
private browserStarting: Promise<BrowserContextFactory> | undefined
private starting: Promise<void> | undefined
private closed = false
constructor(private readonly opts: BrowserBrokerOptions) {}
async start(): Promise<void> {
if (this.closed) throw new Error("Browser broker is closed")
if (this.port !== undefined) return
if (this.starting) return this.starting
this.starting = new Promise<void>((resolve, reject) => {
this.server = createServer((req, res) => {
void this.handle(req, res)
})
this.server.on("connection", (socket) => {
this.sockets.add(socket)
socket.once("close", () => this.sockets.delete(socket))
})
this.server.once("error", reject)
this.server.listen(0, "127.0.0.1", () => {
const server = this.server
const address = server?.address()
if (!server || !address || typeof address === "string") {
reject(new Error("Browser broker did not receive a local port"))
return
}
this.port = address.port
this.tools = new BrowserDevtools(server, address.port, this.opts.log, (browser, active) => {
const entry = [...this.entries.values()].find((item) => item.browserId === browser)
if (!entry || entry.state.inspecting === active) return
entry.state.inspecting = active
this.emit(entry.state)
})
resolve()
})
}).finally(() => {
this.starting = undefined
})
return this.starting
}
async env(): Promise<Record<string, string>> {
await this.start()
return {
KILO_BROWSER_BROKER_URL: `http://127.0.0.1:${this.port}`,
KILO_BROWSER_BROKER_TOKEN: this.token,
}
}
bind(owner: (route: BrowserRoute) => BrowserRoute | undefined): void {
this.owner = owner
}
subscribe(listener: (state: BrowserState) => void): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
replay(listener: (state: BrowserState) => void): void {
for (const entry of this.entries.values()) listener(this.copy(entry.state))
}
get(sessionId: string, projectId?: string): BrowserState | undefined {
const entries = [...this.entries.values()].filter(
(entry) =>
entry.route.sessionId === sessionId && (projectId === undefined || entry.route.projectId === projectId),
)
return entries.length === 1 ? this.copy(entries[0].state) : undefined
}
sessions(): string[] {
return [...new Set([...this.entries.values()].map((entry) => entry.route.sessionId))]
}
open(route: BrowserRoute, target: string): Promise<BrowserState> {
const scope = this.owner ? this.owner(route) : route
if (!scope)
return Promise.reject(new Error("Browser session does not belong to the requested project or directory"))
return this.serial(this.key(scope.sessionId, scope.projectId), () => this.create(scope, target))
}
private async create(scope: BrowserRoute, target: string): Promise<BrowserState> {
this.available()
const url = this.validate(target)
const existing = this.entries.get(this.key(scope.sessionId, scope.projectId))
if (existing) {
if (existing.route.directory !== scope.directory) throw new Error("Browser session directory cannot change")
if (scope.projectId && existing.route.projectId && existing.route.projectId !== scope.projectId) {
throw new Error("Browser session project cannot change")
}
existing.route.projectId = scope.projectId ?? existing.route.projectId
existing.state.projectId = existing.route.projectId
const reload = existing.state.status === "ready" && existing.page.url() === url.href
await this.goto(existing, url, reload)
return this.copy(existing.state)
}
const browser = await this.ensureBrowser()
this.available()
const context = await browser.newContext({
serviceWorkers: "block",
viewport: { width: 1280, height: 720 },
})
const page = await context.newPage().catch(async (error: unknown) => {
await context.close().catch((failure: unknown) => this.opts.log("Browser context close failed", failure))
throw error
})
if (this.closed) {
await context.close().catch((error: unknown) => this.opts.log("Browser context close failed", error))
throw new Error("Browser broker is closed")
}
const entry: Entry = {
route: { ...scope },
browserId: randomUUID(),
context,
page,
origin: url.origin,
state: {
browserId: "",
projectId: scope.projectId,
sessionId: scope.sessionId,
navigation: 0,
status: "starting",
errors: 0,
logs: [],
},
}
entry.state.browserId = entry.browserId
const key = this.key(scope.sessionId, scope.projectId)
this.entries.set(key, entry)
this.attach(entry)
await this.route(entry).catch(async (error: unknown) => {
this.entries.delete(key)
await context.close().catch((failure: unknown) => this.opts.log("Browser context close failed", failure))
throw error
})
this.emit(entry.state)
await this.goto(entry, url)
return this.copy(entry.state)
}
devtools(sessionId: string, projectId?: string, theme: "dark" | "light" = "dark"): Promise<BrowserDevtoolsInfo> {
return this.serial(this.key(sessionId, projectId), async () => {
this.available()
const entry = this.require(sessionId, undefined, projectId)
await this.start()
const port = this.debugging ?? this.browser?.debugging
if (!port || !this.tools || typeof entry.context.newCDPSession !== "function") {
throw new Error("Browser developer tools are unavailable for this browser session")
}
const session = await entry.context.newCDPSession(entry.page)
const info = await session
.send("Target.getTargetInfo")
.finally(() =>
session.detach().catch((error: unknown) => this.opts.log("Browser CDP session close failed", error)),
)
return {
browserId: entry.browserId,
url: this.tools.open(entry.browserId, info.targetInfo.targetId, port, theme),
}
})
}
inspect(
sessionId: string,
projectId: string | undefined,
position: { x: number; y: number; width: number; height: number },
detail = true,
): Promise<BrowserInspection> {
return this.serial(this.key(sessionId, projectId), async () => {
this.available()
const entry = this.require(sessionId, undefined, projectId)
await this.point(entry, position)
const selected: BrowserElement | undefined = await entry.page.evaluate(element, { ...position, detail })
if (selected?.source) selected.source = await locate(entry.route.directory, selected.source)
await this.update(entry)
return {
url: entry.state.url,
title: entry.state.title,
element: selected,
logs: [...(entry.state.logs ?? [])],
}
})
}
input(
sessionId: string,
projectId: string | undefined,
position: { x: number; y: number; width: number; height: number },
click: boolean,
): Promise<void> {
return this.serial(this.key(sessionId, projectId), async () => {
this.available()
const entry = this.require(sessionId, undefined, projectId)
if (!entry.state.inspecting) return
const point = await this.point(entry, position)
await entry.page.mouse.move(point.x, point.y)
if (!click) return
await entry.page.mouse.down()
await entry.page.mouse.up()
})
}
refresh(sessionId: string, projectId?: string): Promise<BrowserState> {
return this.serial(this.key(sessionId, projectId), async () => {
this.available()
const entry = this.require(sessionId, undefined, projectId)
const url = this.validate(entry.state.url ?? entry.origin)
await this.goto(entry, url, true)
return this.copy(entry.state)
})
}
close(sessionId: string, projectId?: string): Promise<void> {
const entries = [...this.entries.values()].filter(
(entry) =>
entry.route.sessionId === sessionId && (projectId === undefined || entry.route.projectId === projectId),
)
return Promise.all(
entries.map((entry) =>
this.serial(this.key(entry.route.sessionId, entry.route.projectId), async () => {
const key = this.key(entry.route.sessionId, entry.route.projectId)
if (this.entries.get(key) !== entry) return
this.entries.delete(key)
this.tools?.revoke(entry.browserId)
await entry.context.close().catch((error: unknown) => this.opts.log("Browser context close failed", error))
entry.state.status = "closed"
entry.state.screenshot = undefined
this.emit(entry.state)
}),
),
).then(() => undefined)
}
async disposeAsync(): Promise<void> {
if (this.closed) return
this.closed = true
await Promise.all(
[...this.entries.values()].map((entry) => this.close(entry.route.sessionId, entry.route.projectId)),
)
await this.browserStarting?.catch((error: unknown) => this.opts.log("Browser startup failed", error))
await this.browser?.close().catch((error: unknown) => this.opts.log("Browser close failed", error))
this.browser = undefined
this.debugging = undefined
this.tools?.dispose()
this.tools = undefined
await new Promise<void>((resolve) => {
if (!this.server) return resolve()
this.server.close(() => resolve())
for (const socket of this.sockets) socket.destroy()
this.sockets.clear()
if (!this.server.listening) resolve()
})
this.server = undefined
this.port = undefined
this.listeners.clear()
}
dispose(): void {
void this.disposeAsync().catch((error: unknown) => this.opts.log("Browser broker dispose failed", error))
}
private async ensureBrowser(): Promise<BrowserContextFactory> {
if (this.closed) throw new Error("Browser broker is closed")
if (this.browser) return this.browser
if (this.browserStarting) return this.browserStarting
this.browserStarting = (async () => {
const port = this.opts.launch ? undefined : await reserve()
const config = options(this.opts.useSystemChrome?.() !== false, port)
const browser = await (this.opts.launch?.(config) ?? chromium.launch(config))
this.debugging = ("debugging" in browser ? browser.debugging : undefined) ?? port
this.browser = browser
return browser
})()
try {
return await this.browserStarting
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
const action =
this.opts.useSystemChrome?.() === false
? "Install a compatible Playwright Chromium browser or enable Use System Chrome in Kilo Settings > Web Tools."
: "Install Google Chrome or select an existing Playwright Chromium browser in Kilo Settings > Web Tools."
throw new Error(`${action} ${detail}`.slice(0, 1000), { cause: error })
} finally {
this.browserStarting = undefined
}
}
private record(entry: Entry, message: string): void {
const text = message.replace(/\s+/g, " ").trim().slice(0, 1000)
if (!text) return
entry.state.logs = [...(entry.state.logs ?? []), text].slice(-20)
}
private attach(entry: Entry): void {
entry.page.on("response", (response) => {
if (response.request().isNavigationRequest() && response.frame() === entry.page.mainFrame()) {
entry.response = response.status()
}
})
entry.page.on("console", (message) => {
const type = message.type()
if (type === "error") entry.state.errors++
this.record(entry, `[${type}] ${message.text()}`)
this.emit(entry.state)
})
entry.page.on("pageerror", (error) => {
entry.state.errors++
this.record(entry, error.message)
this.emit(entry.state)
})
entry.page.on("popup", (page) => {
entry.state.errors++
this.record(entry, "Blocked browser popup")
this.emit(entry.state)
void page.close().catch((error: unknown) => this.opts.log("Browser popup close failed", error))
})
entry.page.on("framenavigated", (frame) => {
if (frame !== entry.page.mainFrame()) return
void this.update(entry).catch((error: unknown) => this.fail(entry, error))
})
}
private async route(entry: Entry): Promise<void> {
await entry.context.route("**/*", async (route, request) => {
const target = request.url()
if (this.allowed(entry, target, Boolean(request.isNavigationRequest?.()))) {
await route.continue()
return
}
const origin = URL.canParse(target) ? new URL(target).origin : "invalid"
this.opts.log("Blocked browser request", { sessionId: entry.route.sessionId, origin })
entry.state.errors++
this.record(entry, `Blocked browser request: ${origin}`)
this.emit(entry.state)
await route.abort("blockedbyclient")
})
await entry.context.routeWebSocket("**/*", async (socket) => {
const target = socket.url()
if (this.allowed(entry, target)) {
socket.connectToServer()
return
}
const origin = URL.canParse(target) ? new URL(target).origin : "invalid"
this.opts.log("Blocked browser WebSocket", { sessionId: entry.route.sessionId, origin })
entry.state.errors++
this.record(entry, `Blocked browser request: ${origin}`)
this.emit(entry.state)
await socket.close({ code: 1008, reason: "Blocked browser origin" })
})
}
private async goto(entry: Entry, url: URL, reload = false): Promise<void> {
entry.origin = url.origin
entry.response = undefined
entry.state.navigation++
entry.state.status = "loading"
entry.state.url = url.href
entry.state.title = undefined
entry.state.error = undefined
entry.state.frameError = undefined
entry.state.errors = 0
entry.state.logs = []
this.emit(entry.state)
try {
const response = reload
? await entry.page.reload({ waitUntil: "domcontentloaded", timeout: 30_000 })
: await entry.page.goto(url.href, { waitUntil: "domcontentloaded", timeout: 30_000 })
entry.response = response?.status() ?? entry.response
entry.state.frameError = framing(response?.headers?.())
if (entry.response !== undefined && entry.response >= 400) {
await this.update(entry)
await this.capture(entry)
throw new BrowserNavigationError(`Local application returned HTTP ${entry.response}`, entry.response)
}
await this.update(entry)
await this.capture(entry)
entry.state.status = "ready"
this.emit(entry.state)
} catch (error) {
entry.state.url = url.href
this.fail(entry, error)
throw error
}
}
private async point(
entry: Entry,
position: { x: number; y: number; width: number; height: number },
): Promise<{ x: number; y: number }> {
if (
!Number.isFinite(position.x) ||
!Number.isFinite(position.y) ||
position.x < 0 ||
position.x > 1 ||
position.y < 0 ||
position.y > 1 ||
!Number.isFinite(position.width) ||
!Number.isFinite(position.height) ||
position.width < 1 ||
position.height < 1
) {
throw new Error("Browser element coordinates are invalid")
}
const width = Math.max(1, Math.min(1920, Math.round(position.width)))
const height = Math.max(1, Math.min(1440, Math.round(position.height)))
const viewport = entry.page.viewportSize?.()
if (viewport?.width !== width || viewport.height !== height) {
await entry.page.setViewportSize({ width, height })
}
return { x: position.x * width, y: position.y * height }
}
private async update(entry: Entry): Promise<void> {
entry.state.url = entry.page.url()
entry.state.title = await entry.page.title().catch(() => undefined)
}
private async capture(entry: Entry): Promise<void> {
const data = await entry.page.screenshot({ type: "jpeg", quality: 70 })
if (data.byteLength > MAX_SCREENSHOT) throw new Error("Browser screenshot is too large")
entry.state.screenshot = `data:image/jpeg;base64,${data.toString("base64")}`
entry.state.mime = "image/jpeg"
this.emit(entry.state)
}
private key(session: string, project?: string): string {
return `${project ?? ""}\0${session}`
}
private async serial<T>(session: string, operation: () => Promise<T>): Promise<T> {
const previous = this.pending.get(session)?.catch(() => undefined) ?? Promise.resolve()
const next = previous.then(operation)
this.pending.set(session, next)
try {
return await next
} finally {
if (this.pending.get(session) === next) this.pending.delete(session)
}
}
private available(): void {
if (this.closed) throw new Error("Browser broker is closed")
if (this.opts.trusted && !this.opts.trusted()) throw new Error("Browser preview requires a trusted workspace.")
if (this.opts.enabled && !this.opts.enabled()) {
throw new Error("Browser automation is disabled. Enable it in Kilo Settings > Experimental.")
}
}
private fail(entry: Entry, error: unknown): void {
entry.state.status = "error"
const text = error instanceof Error ? error.message : String(error)
entry.state.error =
entry.response && text.includes("ERR_HTTP_RESPONSE_CODE_FAILURE")
? `Local application returned HTTP ${entry.response}`
: diagnostic(error, entry.state.url)
this.emit(entry.state)
}
private require(sessionId: string, directory?: string, projectId?: string): Entry {
const entries = [...this.entries.values()].filter((entry) => entry.route.sessionId === sessionId)
if (entries.length === 0) throw new Error("No browser is open for this Agent Manager session")
const projects = projectId === undefined ? entries : entries.filter((entry) => entry.route.projectId === projectId)
if (projects.length === 0) throw new Error("Browser session project does not match")
const directories =
directory === undefined ? projects : projects.filter((entry) => entry.route.directory === directory)
if (directories.length === 0) throw new Error("Browser session directory does not match")
if (directories.length !== 1) throw new Error("Browser session identity is ambiguous")
return directories[0]
}
validate(target: string): URL {
let url: URL
try {
url = new URL(target)
} catch {
throw new Error("Browser URL is invalid")
}
if (!loopback(url) || url.username || url.password) {
throw new Error(
"Browser URLs must use HTTP localhost or 127.0.0.1 without credentials. Use localhost for IPv6 loopback servers.",
)
}
return url
}
private allowed(entry: Entry, target: string, navigation = false): boolean {
let url: URL
try {
url = new URL(target)
} catch {
return false
}
if (["about:", "blob:", "data:"].includes(url.protocol)) return !navigation
if (url.protocol === "ws:" || url.protocol === "wss:")
return `${url.protocol === "ws:" ? "http:" : "https:"}//${url.host}` === entry.origin
if (url.protocol !== "http:" && url.protocol !== "https:") return false
return url.origin === entry.origin
}
private copy(state: BrowserState): BrowserState {
return { ...state, logs: state.logs ? [...state.logs] : undefined }
}
private emit(state: BrowserState): void {
if (state.status !== "closed") {
if (this.closed) return
const entry = this.entries.get(this.key(state.sessionId, state.projectId))
if (!entry || entry.browserId !== state.browserId) return
}
const next = this.copy(state)
for (const listener of this.listeners) listener(next)
}
private authorized(req: IncomingMessage): boolean {
const value = req.headers.authorization
if (typeof value !== "string") return false
const actual = Buffer.from(value)
const expected = Buffer.from(`Bearer ${this.token}`)
return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected)
}
private status(req: IncomingMessage, res: ServerResponse, route: URL): boolean {
if (req.method !== "GET" || route.pathname !== "/browser/status") return false
json(res, 200, {
enabled: !this.closed && this.opts.enabled?.() !== false && this.opts.trusted?.() !== false,
})
return true
}
private async operation(path: string, body: RequestBody & { sessionID: string; directory: string }) {
const project = typeof body.projectID === "string" ? body.projectID : undefined
if (path === "/browser/open") {
if (typeof body.url !== "string") throw new Error("A local application URL is required")
return this.open({ projectId: project, sessionId: body.sessionID, directory: body.directory }, body.url)
}
if (!["/browser/refresh", "/browser/close"].includes(path)) return undefined
const entry = this.require(body.sessionID, body.directory, project)
if (path === "/browser/refresh") return this.refresh(entry.route.sessionId, entry.route.projectId)
await this.close(entry.route.sessionId, entry.route.projectId)
return { sessionId: entry.route.sessionId, status: "closed" as const }
}
private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
const route = new URL(req.url ?? "/", "http://127.0.0.1")
if (this.tools && (await this.tools.handle(req, res, route))) return
if (!this.authorized(req)) {
json(res, 401, { error: "Unauthorized" })
return
}
if (this.status(req, res, route)) return
if (req.method !== "POST") {
json(res, 405, { error: "Method not allowed" })
return
}
let body: RequestBody
try {
const value: unknown = JSON.parse(await read(req))
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid JSON object")
body = value
} catch (error) {
json(res, 400, { error: error instanceof Error ? error.message : "Invalid JSON" })
return
}
if (typeof body.sessionID !== "string" || typeof body.directory !== "string") {
json(res, 400, { error: "sessionID and directory are required" })
return
}
try {
const result = await this.operation(route.pathname, {
...body,
sessionID: body.sessionID,
directory: body.directory,
})
if (!result) {
json(res, 404, { error: "Unknown browser operation" })
return
}
json(res, 200, result)
} catch (error) {
json(res, 400, { error: diagnostic(error, typeof body.url === "string" ? body.url : undefined) })
}
}
}
@@ -0,0 +1,259 @@
import { randomBytes, timingSafeEqual } from "node:crypto"
import { request, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse } from "node:http"
import type { Duplex } from "node:stream"
import { URL } from "node:url"
import WebSocket, { WebSocketServer, type RawData } from "ws"
type Target = {
browser: string
page: string
port: number
secret: string
theme: "dark" | "light"
expires: number
sockets: Set<WebSocket>
inspecting: Set<WebSocket>
}
type Match = { target: Target; path: string }
type Message = { method?: string; params?: { mode?: string } }
const LIFETIME = 15 * 60 * 1000
const PAYLOAD = 16 * 1024 * 1024
function equal(left: string, right: string): boolean {
const actual = Buffer.from(left)
const expected = Buffer.from(right)
return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected)
}
function reject(socket: Duplex, status: number, reason: string): void {
socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
socket.destroy()
}
function resource(port: number, path: string): Promise<{ status: number; headers: IncomingHttpHeaders; body: Buffer }> {
return new Promise((resolve, reject) => {
const req = request({ hostname: "127.0.0.1", port, path, method: "GET" }, (response) => {
const chunks: Buffer[] = []
let size = 0
response.on("data", (chunk: Buffer) => {
size += chunk.byteLength
if (size > PAYLOAD) {
response.destroy(new Error("Browser developer tools asset exceeds the size limit"))
return
}
chunks.push(chunk)
})
response.once("error", reject)
response.once("end", () =>
resolve({ status: response.statusCode ?? 502, headers: response.headers, body: Buffer.concat(chunks) }),
)
})
req.setTimeout(10_000, () => req.destroy(new Error("Browser developer tools asset request timed out")))
req.once("error", reject)
req.end()
})
}
function inspect(data: RawData): Message | undefined {
const value = Array.isArray(data)
? Buffer.concat(data).toString("utf8")
: data instanceof ArrayBuffer
? Buffer.from(data).toString("utf8")
: data.toString("utf8")
if (!value.includes("Overlay.")) return
try {
const message = JSON.parse(value) as Message
return typeof message.method === "string" ? message : undefined
} catch {
return undefined
}
}
export class BrowserDevtools {
private readonly targets = new Map<string, Target>()
private readonly sockets = new WebSocketServer({ noServer: true, maxPayload: PAYLOAD })
private readonly upgrade = (req: IncomingMessage, socket: Duplex, head: Buffer): void => {
this.connect(req, socket, head)
}
constructor(
private readonly server: Server,
private readonly port: number,
private readonly log: (...args: unknown[]) => void,
private readonly mode: (browser: string, active: boolean) => void,
) {
this.server.on("upgrade", this.upgrade)
}
open(browser: string, page: string, port: number, theme: "dark" | "light"): string {
this.revoke(browser)
const secret = randomBytes(32).toString("hex")
const target: Target = {
browser,
page,
port,
secret,
theme,
expires: Date.now() + LIFETIME,
sockets: new Set(),
inspecting: new Set(),
}
this.targets.set(browser, target)
const path = `/browser/devtools/${browser}/${secret}`
const query = new URLSearchParams({
ws: `127.0.0.1:${this.port}${path}/connect`,
})
return `http://127.0.0.1:${this.port}${path}/inspector.html?${query}`
}
async handle(req: IncomingMessage, res: ServerResponse, route: URL): Promise<boolean> {
if (!route.pathname.startsWith("/browser/devtools/")) return false
const scope = this.match(req, route)
if (!scope || req.method !== "GET" || scope.path === "connect") {
res.writeHead(scope ? 405 : 401, { "cache-control": "no-store" })
res.end()
return true
}
if (scope.path === "kilo-bootstrap.js") {
const script = [
`localStorage.setItem("ui-theme",JSON.stringify(${JSON.stringify(scope.target.theme)}))`,
'localStorage.setItem("currentDockState",JSON.stringify("undocked"))',
].join(";")
res.writeHead(200, {
"cache-control": "no-store",
"content-length": Buffer.byteLength(script),
"content-type": "application/javascript; charset=utf-8",
})
res.end(script)
return true
}
try {
const response = await resource(scope.target.port, `/devtools/${scope.path}${route.search}`)
const data = response.body
const body =
scope.path === "inspector.html" && response.status === 200
? Buffer.from(
data
.toString("utf8")
.replace(
/<script\s+type="module"/i,
'<script src="./kilo-bootstrap.js"></script><script type="module"',
),
)
: data
const headers: Record<string, string | number> = {
"cache-control": "no-store",
"content-length": body.byteLength,
"referrer-policy": "no-referrer",
}
for (const name of ["content-type", "content-security-policy"]) {
const value = response.headers[name]
if (typeof value === "string") headers[name] = value
}
res.writeHead(response.status, headers)
res.end(body)
} catch (error) {
this.log("Browser developer tools asset failed", { browser: scope.target.browser, error })
res.writeHead(502, { "cache-control": "no-store" })
res.end()
}
return true
}
revoke(browser: string): void {
const target = this.targets.get(browser)
if (!target) return
this.targets.delete(browser)
if (target.inspecting.size) this.mode(browser, false)
target.inspecting.clear()
for (const socket of target.sockets) socket.close(1001, "Browser session closed")
target.sockets.clear()
}
dispose(): void {
this.server.off("upgrade", this.upgrade)
for (const browser of [...this.targets.keys()]) this.revoke(browser)
this.sockets.close()
}
private match(req: IncomingMessage, route: URL): Match | undefined {
if (req.headers.host !== `127.0.0.1:${this.port}`) return
const parts = route.pathname.split("/")
if (parts[1] !== "browser" || parts[2] !== "devtools" || parts.length < 6) return
const target = this.targets.get(parts[3])
if (!target || target.expires < Date.now() || !equal(parts[4], target.secret)) return
const path = parts.slice(5).join("/")
if (!path || path.includes("..") || path.includes("\\") || /%2f|%5c/i.test(path)) return
target.expires = Date.now() + LIFETIME
return { target, path }
}
private connect(req: IncomingMessage, socket: Duplex, head: Buffer): void {
const route = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`)
const scope = this.match(req, route)
if (!scope || scope.path !== "connect") return reject(socket, 401, "Unauthorized")
if (req.headers.origin !== `http://127.0.0.1:${this.port}`) return reject(socket, 403, "Forbidden")
this.sockets.handleUpgrade(req, socket, head, (client) => this.bridge(scope.target, client))
}
private select(target: Target, client: WebSocket, active: boolean): void {
const previous = target.inspecting.size > 0
if (active) target.inspecting.add(client)
if (!active) target.inspecting.delete(client)
const current = target.inspecting.size > 0
if (previous !== current) this.mode(target.browser, current)
}
private bridge(target: Target, client: WebSocket): void {
const upstream = new WebSocket(`ws://127.0.0.1:${target.port}/devtools/page/${target.page}`, {
maxPayload: PAYLOAD,
})
const queued: Array<{ data: RawData; binary: boolean }> = []
target.sockets.add(client)
const close = () => {
this.select(target, client, false)
target.sockets.delete(client)
if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) client.close()
if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) upstream.close()
}
upstream.on("open", () => {
for (const item of queued.splice(0)) upstream.send(item.data, { binary: item.binary })
})
upstream.on("message", (data, binary) => {
const message = inspect(data)
if (message?.method === "Overlay.inspectNodeRequested" || message?.method === "Overlay.inspectModeCanceled") {
this.select(target, client, false)
}
if (client.readyState === WebSocket.OPEN) client.send(data, { binary })
})
upstream.on("error", (error) => {
this.log("Browser developer tools connection failed", { browser: target.browser, error })
close()
})
upstream.on("close", close)
client.on("message", (data, binary) => {
const message = inspect(data)
if (message?.method === "Overlay.setInspectMode") {
this.select(
target,
client,
message.params?.mode === "searchForNode" || message.params?.mode === "searchForUAShadowDOM",
)
}
if (upstream.readyState === WebSocket.OPEN) {
upstream.send(data, { binary })
return
}
if (upstream.readyState === WebSocket.CONNECTING) queued.push({ data, binary })
})
client.on("error", (error) => {
this.log("Browser developer tools client failed", { browser: target.browser, error })
close()
})
client.on("close", close)
}
}
@@ -0,0 +1,208 @@
import { readFile, realpath, stat } from "node:fs/promises"
import path from "node:path"
function integer(value: number | undefined) {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined
}
async function coordinates(file: string, source: { line?: number; column?: number }, size: number) {
const line = integer(source.line)
const text = line && size <= 1_048_576 ? await readFile(file, "utf8").catch(() => undefined) : undefined
const lines = text?.split("\n")
const valid = line && lines && line <= lines.length ? line : undefined
const column = integer(source.column)
return {
line: valid,
column: valid && column && column <= (lines?.[valid - 1]?.length ?? 0) + 1 ? column : undefined,
}
}
export async function locate(directory: string, source?: { file: string; line?: number; column?: number }) {
if (!source?.file || source.file.includes("\0") || /^[a-z][a-z0-9+.-]*:\/\//i.test(source.file)) return undefined
if (!/\.(?:[cm]?[jt]sx?|vue|svelte|html?|css|scss|sass|less)$/i.test(source.file)) return undefined
const candidate = path.resolve(directory, source.file)
const [root, file] = await Promise.all([
realpath(directory).catch(() => undefined),
realpath(candidate).catch(() => undefined),
])
if (!root || !file) return undefined
const relative = path.relative(root, file)
if (!relative || path.isAbsolute(relative) || relative.split(path.sep).includes("..")) return undefined
if (relative.split(path.sep).some((part) => part === "node_modules" || part === ".git")) return undefined
const info = await stat(file).catch(() => undefined)
if (!info?.isFile()) return undefined
return { file: relative.split(path.sep).join("/"), ...(await coordinates(file, source, info.size)) }
}
export function capture(position: { x: number; y: number; detail?: boolean }) {
const root = document.documentElement
if (root.scrollHeight > innerHeight && root.clientWidth === innerWidth) {
root.style.setProperty("scrollbar-gutter", "stable")
}
const node = document.elementFromPoint(position.x * innerWidth, position.y * innerHeight)
if (!(node instanceof Element)) return undefined
const label = (element: Element) => {
const tag = element.tagName.toLowerCase()
if (element.id) return `${tag}#${CSS.escape(element.id.slice(0, 120))}`
const classes = [...element.classList].slice(0, 3).map((item) => `.${CSS.escape(item.slice(0, 60))}`)
return `${tag}${classes.join("")}`
}
const locator = (element: Element) => {
if (element.id && element.id.length <= 180) {
const selector = `#${CSS.escape(element.id)}`
if (document.querySelectorAll(selector).length === 1) return selector
}
for (const name of ["data-testid", "data-test", "data-cy", "aria-label"]) {
const value = element.getAttribute(name)
if (!value || value.length > 160) continue
const selector = `${element.tagName.toLowerCase()}[${name}="${CSS.escape(value)}"]`
if (document.querySelectorAll(selector).length === 1) return selector
}
return undefined
}
const selector = () => {
const direct = locator(node)
if (direct) return direct
const path: string[] = []
let current: Element | null = node
while (current && path.length < 128) {
const anchor = locator(current)
if (anchor) {
path.unshift(anchor)
const value = path.join(" > ")
return value.length <= 2048 && document.querySelectorAll(value).length === 1 ? value : undefined
}
const tag = current.tagName.toLowerCase()
const siblings: Element[] = current.parentElement
? [...current.parentElement.children].filter((item) => item.tagName === current?.tagName)
: []
path.unshift(`${tag}${siblings.length > 1 ? `:nth-of-type(${siblings.indexOf(current) + 1})` : ""}`)
const value = path.join(" > ")
if (value.length > 2048) return undefined
if (document.querySelectorAll(value).length === 1) return value
current = current.parentElement
}
return undefined
}
const identity = selector()
if (!identity) return undefined
const rect = node.getBoundingClientRect()
const left = Math.max(0, Math.min(innerWidth, rect.left))
const top = Math.max(0, Math.min(innerHeight, rect.top))
const right = Math.max(left, Math.min(innerWidth, rect.right))
const bottom = Math.max(top, Math.min(innerHeight, rect.bottom))
const result = {
tag: node.tagName.toLowerCase(),
id: node.id.slice(0, 120) || undefined,
classes: node.getAttribute("class")?.trim().slice(0, 180) || undefined,
selector: identity,
rect: {
x: left / innerWidth,
y: top / innerHeight,
width: (right - left) / innerWidth,
height: (bottom - top) / innerHeight,
},
}
if (position.detail === false) return result
const ancestry: Element[] = []
let parent: Element | null = node
while (parent && ancestry.length < 6) {
ancestry.unshift(parent)
parent = parent.parentElement
}
const hidden = (element: Element) => {
if (
element.matches(
"script,style,noscript,template,input,textarea,select,[hidden],[aria-hidden=true],[contenteditable]:not([contenteditable=false])",
)
) {
return true
}
const style = getComputedStyle(element)
return (
style.display === "none" ||
style.visibility === "hidden" ||
style.visibility === "collapse" ||
style.opacity === "0" ||
style.contentVisibility === "hidden"
)
}
const content = () => {
const editable = node.closest("input,textarea,select,[contenteditable]:not([contenteditable=false])")
if (editable) return editable.getAttribute("aria-label")?.slice(0, 180)
const pending: Node[] = []
let current: Node | null = node.firstChild
let visits = 0
let value = ""
while (current && visits++ < 256 && value.length < 180) {
if (current instanceof Element && !hidden(current) && current.firstChild) {
if (current.nextSibling) pending.push(current.nextSibling)
current = current.firstChild
continue
}
if (current.nodeType === 3) value += (current.textContent ?? "").replace(/\s+/g, " ").slice(0, 180 - value.length)
current = current.nextSibling ?? pending.pop() ?? null
}
return value.trim() || undefined
}
const text = content()
const snippet = node.cloneNode(false) as Element
const allowed = new Set([
"id",
"class",
"role",
"type",
"name",
"aria-label",
"title",
"data-testid",
"data-test",
"data-cy",
])
for (const attribute of [...snippet.attributes]) {
if (!allowed.has(attribute.name)) snippet.removeAttribute(attribute.name)
else snippet.setAttribute(attribute.name, attribute.value.slice(0, 180))
}
snippet.textContent = text ?? ""
const styles = getComputedStyle(node)
const source = () => {
const file = node.getAttribute("data-source-file")
if (file) {
const line = Number(node.getAttribute("data-source-line"))
const column = Number(node.getAttribute("data-source-column"))
return {
file: file.slice(0, 4096),
line: Number.isInteger(line) && line > 0 ? line : undefined,
column: Number.isInteger(column) && column > 0 ? column : undefined,
}
}
const key = Object.keys(node).find(
(item) => item.startsWith("__reactFiber$") || item.startsWith("__reactInternalInstance$"),
)
if (!key) return undefined
type Fiber = {
_debugSource?: { fileName?: string; lineNumber?: number; columnNumber?: number }
_debugOwner?: Fiber
return?: Fiber
}
let fiber: Fiber | undefined = (node as unknown as Record<string, Fiber>)[key]
for (let depth = 0; fiber && depth < 8; depth++) {
const origin = fiber._debugSource
if (origin?.fileName) {
return { file: origin.fileName.slice(0, 4096), line: origin.lineNumber, column: origin.columnNumber }
}
fiber = fiber._debugOwner ?? fiber.return
}
return undefined
}
return {
...result,
text,
hierarchy: ancestry.map(label),
html: snippet.outerHTML.slice(0, 800),
styles: { color: styles.color.slice(0, 80), backgroundColor: styles.backgroundColor.slice(0, 80) },
source: source(),
}
}
@@ -0,0 +1,25 @@
import type { LaunchOptions } from "playwright-core"
export function options(
system: boolean,
port?: number,
env: Record<string, string | undefined> = process.env,
): LaunchOptions {
return {
chromiumSandbox: true,
headless: true,
env: Object.fromEntries(
Object.entries(env).filter(
([key, value]) =>
typeof value === "string" &&
!/proxy/i.test(key) &&
!/(TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY)(_|$)/i.test(key),
),
),
args: [
"--no-proxy-server",
...(port ? ["--remote-debugging-address=127.0.0.1", `--remote-debugging-port=${port}`] : []),
],
...(system ? { channel: "chrome" } : {}),
}
}
@@ -1 +1,9 @@
export { BrowserAutomationService } from "./browser-automation-service"
export { BrowserBroker, diagnostic } from "./browser-broker"
export type {
BrowserContextFactory,
BrowserElement,
BrowserInspection,
BrowserRoute,
BrowserState,
BrowserStatus,
} from "./browser-broker"
@@ -133,7 +133,7 @@ export class KiloConnectionService {
private viewedDirty = false
private unsubRemote: (() => void) | null = null
constructor(context: vscode.ExtensionContext) {
constructor(context: vscode.ExtensionContext, env?: () => Promise<Record<string, string>>) {
const state =
context.workspaceState ??
({
@@ -141,7 +141,7 @@ export class KiloConnectionService {
update: async () => undefined,
} satisfies Pick<vscode.Memento, "get" | "update">)
this.sandboxPreference = new SandboxPreference(state)
this.serverManager = new ServerManager(context, (code, signal) => this.handleServerExit(code, signal))
this.serverManager = new ServerManager(context, (code, signal) => this.handleServerExit(code, signal), env)
this.active = vscode.window.state.focused
this.windowStateDisposable = vscode.window.onDidChangeWindowState((ws) => {
this.active = ws.focused
@@ -45,6 +45,7 @@ export class ServerManager {
constructor(
private readonly context: vscode.ExtensionContext,
private readonly onExit?: ServerExitListener,
private readonly env?: () => Promise<Record<string, string>>,
) {}
/**
@@ -90,6 +91,7 @@ export class ServerManager {
console.log("[Kilo New] ServerManager: 📄 CLI isFile:", stat.isFile())
console.log("[Kilo New] ServerManager: 📄 CLI mode (octal):", (stat.mode & 0o777).toString(8))
const extraEnv = await this.env?.()
return new Promise((resolve, reject) => {
console.log("[Kilo New] ServerManager: 🎬 Spawning CLI process:", cliPath, ["serve", "--port", "0"])
const cfg = vscode.workspace.getConfiguration("kilo-code.new")
@@ -156,6 +158,7 @@ export class ServerManager {
...(!claudeCompat && { KILO_DISABLE_CLAUDE_CODE: "true" }),
...resolveTreeSitterEnv(this.context.extensionPath),
...bwrapEnv,
...extraEnv,
},
stdio: ["ignore", "pipe", "pipe"],
detached: true,
@@ -0,0 +1,333 @@
import { partReview, type ReviewMessageData } from "./review-comments"
export interface BrowserReference {
id: string
sessionId: string
selector: string
text?: string
url?: string
title?: string
hierarchy?: string[]
html?: string
styles?: { color?: string; backgroundColor?: string }
source?: { file: string; line?: number; column?: number }
content?: string
}
export interface BrowserFeedbackData {
version: 1
references: BrowserReference[]
}
export interface FeedbackView {
review?: ReviewMessageData
browserFeedback?: BrowserFeedbackData
body: string
}
const REFERENCE_LIMIT = 20
const HIERARCHY_LIMIT = 20
const TOTAL_LIMIT = 200_000
const ID_LIMIT = 512
const SESSION_LIMIT = 512
const SELECTOR_LIMIT = 4_096
const TEXT_LIMIT = 20_000
const URL_LIMIT = 4_096
const TITLE_LIMIT = 2_000
const HIERARCHY_ITEM_LIMIT = 512
const HTML_LIMIT = 20_000
const STYLE_LIMIT = 256
const SOURCE_LIMIT = 4_096
function record(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined
return value as Record<string, unknown>
}
function string(value: unknown, limit: number): string | undefined {
if (typeof value !== "string" || value.length > limit) return undefined
return value
}
function optionalString(value: unknown, limit: number): string | false | undefined {
if (value === undefined) return undefined
const result = string(value, limit)
return result === undefined ? false : result
}
function safePath(value: string): boolean {
const absolute = value.startsWith("/") || value.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(value)
return !absolute && !value.split(/[\\/]/).includes("..") && !value.includes("\0")
}
function url(value: string): string | undefined {
try {
const parsed = new URL(value)
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return undefined
parsed.username = ""
parsed.password = ""
parsed.search = ""
parsed.hash = ""
return parsed.toString()
} catch {
return undefined
}
}
function optionalUrl(value: unknown): string | false | undefined {
if (value === undefined) return undefined
if (typeof value !== "string" || value.length > URL_LIMIT) return false
const result = url(value)
return result === undefined ? false : result
}
function positive(value: unknown): number | false | undefined {
if (value === undefined) return undefined
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) return false
return value
}
function styles(value: unknown): BrowserReference["styles"] | false | undefined {
if (value === undefined) return undefined
const item = record(value)
if (!item) return false
const color = optionalString(item.color, STYLE_LIMIT)
const backgroundColor = optionalString(item.backgroundColor, STYLE_LIMIT)
if (color === false || backgroundColor === false) return false
if (color === undefined && backgroundColor === undefined) return undefined
return {
...(color === undefined ? {} : { color }),
...(backgroundColor === undefined ? {} : { backgroundColor }),
}
}
function source(value: unknown): BrowserReference["source"] | false | undefined {
if (value === undefined) return undefined
const item = record(value)
if (!item) return false
const file = string(item.file, SOURCE_LIMIT)
const line = positive(item.line)
const column = positive(item.column)
if (!file || !safePath(file) || line === false || column === false) return false
return {
file,
...(line === undefined ? {} : { line }),
...(column === undefined ? {} : { column }),
}
}
function hierarchy(value: unknown): string[] | false | undefined {
if (value === undefined) return undefined
if (!Array.isArray(value) || value.length > HIERARCHY_LIMIT) return false
const result = value.map((item) => string(item, HIERARCHY_ITEM_LIMIT))
if (result.some((item) => item === undefined)) return false
return result as string[]
}
function reference(value: unknown): BrowserReference | undefined {
const item = record(value)
if (!item) return undefined
const id = string(item.id, ID_LIMIT)
const sessionId = string(item.sessionId, SESSION_LIMIT)
const selector = string(item.selector, SELECTOR_LIMIT)
if (!id || !sessionId || !selector) return undefined
const text = optionalString(item.text, TEXT_LIMIT)
const pageUrl = optionalUrl(item.url)
const title = optionalString(item.title, TITLE_LIMIT)
const tree = hierarchy(item.hierarchy)
const html = optionalString(item.html, HTML_LIMIT)
const style = styles(item.styles)
const verified = source(item.source)
const content = item.content === undefined ? undefined : optionalString(item.content, 100_000)
if (!valid([text, pageUrl, title, tree, html, style, verified, content])) return undefined
const safeText = unwrap(text)
const safeUrl = unwrap(pageUrl)
const safeTitle = unwrap(title)
const safeTree = unwrap(tree)
const safeHtml = unwrap(html)
const safeStyle = unwrap(style)
const safeSource = unwrap(verified)
const safeContent = unwrap(content)
return {
id,
sessionId,
selector,
...(safeText === undefined ? {} : { text: safeText }),
...(safeUrl === undefined ? {} : { url: safeUrl }),
...(safeTitle === undefined ? {} : { title: safeTitle }),
...(safeTree === undefined ? {} : { hierarchy: safeTree }),
...(safeHtml === undefined ? {} : { html: safeHtml }),
...(safeStyle === undefined ? {} : { styles: safeStyle }),
...(safeSource === undefined ? {} : { source: safeSource }),
...(safeContent === undefined ? {} : { content: safeContent }),
}
}
function valid(
values: Array<string | string[] | BrowserReference["styles"] | BrowserReference["source"] | false | undefined>,
): boolean {
return !values.some((item) => item === false)
}
function unwrap<T>(item: T | false | undefined): T | undefined {
return item === false ? undefined : item
}
function weight(item: BrowserReference): number {
return JSON.stringify(item).length
}
function normalize(references: readonly BrowserReference[]): BrowserReference[] | undefined {
if (references.length === 0 || references.length > REFERENCE_LIMIT) return undefined
const result = references.map(reference)
if (result.some((item) => item === undefined)) return undefined
const list = result as BrowserReference[]
if (list.reduce((total, item) => total + weight(item), 0) > TOTAL_LIMIT) return undefined
return list
}
function escapeInline(value: string): string {
return value.replace(/[\r\n]+/g, " ").replace(/([\\`*_[\]{}()#+\-!|<>])/g, "\\$1")
}
function fenced(value: string): string[] {
const matches = value.match(/`+/g) ?? []
const longest = matches.reduce((max, item) => Math.max(max, item.length), 0)
const fence = "`".repeat(Math.max(3, longest + 1))
return [fence, value, fence]
}
function page(item: BrowserReference): string | undefined {
if (!item.url && !item.title) return undefined
return `Page: ${item.title ? escapeInline(item.title) : "Untitled"}${item.url ? ` (\`${escapeInline(item.url)}\`)` : ""}`
}
function detail(item: BrowserReference, index: number, first: BrowserReference): string[] {
const lines = [`Element ${index + 1}:`, ...fenced(item.selector)]
if (index > 0 && (item.url !== first.url || item.title !== first.title)) {
const value = page(item)
if (value) lines.push(value)
}
if (item.hierarchy?.length) lines.push(...["DOM:", ...fenced(item.hierarchy.join(" > "))])
if (item.text && (!item.html || item.html === item.text)) lines.push(...[`Text:`, ...fenced(item.text)])
if (item.html && item.html !== item.text) lines.push(...[`HTML:`, ...fenced(item.html)])
if (item.styles) {
const values = [
item.styles.color ? `color=${escapeInline(item.styles.color)}` : "",
item.styles.backgroundColor ? `background=${escapeInline(item.styles.backgroundColor)}` : "",
].filter(Boolean)
if (values.length) lines.push(`Styles: ${values.join(", ")}`)
}
if (item.source) {
const location = [item.source.file, item.source.line, item.source.column]
.filter((value) => value !== undefined)
.join(":")
lines.push(...[`Source:`, ...fenced(location)])
}
return lines
}
export function formatBrowserFeedback(references: BrowserReference[]): string {
const list = normalize(references) ?? []
if (list.length === 0) return "## Browser Feedback"
const lines = ["## Browser Feedback", ""]
const heading = page(list[0]!)
if (heading) lines.push(heading, "")
list.forEach((item, index) => lines.push(...detail(item, index, list[0]!), ""))
return lines.join("\n").trimEnd()
}
function view(value: unknown, content: string): { data: BrowserFeedbackData; body: string } | undefined {
const data = record(value)
if (!data || data.version !== 1 || !Array.isArray(data.references)) return undefined
const references = data.references.map(reference)
if (references.length === 0 || references.length > REFERENCE_LIMIT || references.some((item) => item === undefined))
return undefined
const list = (references as BrowserReference[]).map((item) => {
const result = { ...item }
delete result.content
return result
})
if (list.reduce((total, item) => total + weight(item), 0) > TOTAL_LIMIT) return undefined
const prefix = formatBrowserFeedback(list)
if (content === prefix) return { data: { version: 1, references: list }, body: "" }
if (!content.startsWith(`${prefix}\n\n`)) return undefined
return { data: { version: 1, references: list }, body: content.slice(prefix.length + 2) }
}
export function browserFeedbackData(references: BrowserReference[]): BrowserFeedbackData | undefined {
const list = normalize(references)
if (!list) return undefined
return {
version: 1,
references: list.map((item) => {
const result = { ...item }
delete result.content
return result
}),
}
}
export function mergeBrowserReferences(current: BrowserReference[], incoming: BrowserReference): BrowserReference[] {
const selected = browserFeedbackData([incoming])?.references[0]
if (!selected) return current
const kept = current.filter(
(item) => item.id !== selected.id && (item.selector !== selected.selector || item.url !== selected.url),
)
if (kept.length >= REFERENCE_LIMIT) return current
return [...kept, selected]
}
export function browserFeedbackMetadata(data: BrowserFeedbackData): Record<string, unknown> {
return { kilo: { browserFeedback: data } }
}
export function parseBrowserFeedback(value: unknown, content: string): BrowserFeedbackData | undefined {
return view(value, content)?.data
}
export function feedbackMetadata(
review: ReviewMessageData | undefined,
browserFeedback: BrowserFeedbackData | undefined,
): Record<string, unknown> | undefined {
if (!review && !browserFeedback) return undefined
return {
kilo: {
...(review ? { review } : {}),
...(browserFeedback ? { browserFeedback } : {}),
},
}
}
export function partFeedback(metadata: unknown, content: string): FeedbackView | undefined {
const root = record(metadata)
const kilo = record(root?.kilo)
const reviewValue = kilo?.review
const browserValue = kilo?.browserFeedback
let body = content
let review: ReviewMessageData | undefined
let browserFeedback: BrowserFeedbackData | undefined
if (reviewValue !== undefined) {
const parsed = partReview({ kilo: { review: reviewValue } }, body)
if (!parsed) return undefined
review = parsed.data
body = parsed.body
}
if (browserValue !== undefined) {
const parsed = view(browserValue, body)
if (!parsed) return undefined
browserFeedback = parsed.data
body = parsed.body
}
if (!review && !browserFeedback) return undefined
return { review, browserFeedback, body }
}
export function parseFeedback(
metadata: { review?: unknown; browserFeedback?: unknown },
content: string,
): Pick<FeedbackView, "review" | "browserFeedback"> | undefined {
const parsed = partFeedback({ kilo: metadata }, content)
if (!parsed) return undefined
return { review: parsed.review, browserFeedback: parsed.browserFeedback }
}
@@ -235,10 +235,6 @@ export function reviewMetadata(review: ReviewMessageData): Record<string, unknow
return { kilo: { review } }
}
export function reviewBody(review: ReviewMessageData, content: string): string | undefined {
return view(review, content)?.body
}
export function partReview(metadata: unknown, content: string): ReviewMessageView | undefined {
const root = record(metadata)
const kilo = record(root?.kilo)
+4 -2
View File
@@ -66,11 +66,13 @@ export function buildWebviewHtml(
topBar?: boolean
topBarSurface?: string
agentManagerSettings?: boolean
browserAutomation?: boolean
frameSrc?: string
sidebar?: "left" | "right"
},
): string {
const nonce = getNonce()
const csp = buildCspString(webview.cspSource, nonce, opts.port)
const csp = buildCspString(webview.cspSource, nonce, opts.port, opts.frameSrc)
const markdownWorkerUri = opts.workerUri.toString().replace(/shiki-worker\.js$/, "markdown-shiki-worker.js")
return `<!DOCTYPE html>
@@ -109,7 +111,7 @@ export function buildWebviewHtml(
</head>
<body>
<div id="root"></div>
<script nonce="${nonce}">window.ICONS_BASE_URI = "${opts.iconsBaseUri}"; window.KILO_SHIKI_WORKER_URI = "${opts.workerUri}"; window.KILO_MARKDOWN_SHIKI_WORKER_URI = "${markdownWorkerUri}"; window.KILO_TOP_BAR = ${opts.topBar !== false}; window.KILO_TOP_BAR_SURFACE = "${opts.topBarSurface ?? "sidebar_title"}"; window.KILO_AGENT_MANAGER_SETTINGS = ${opts.agentManagerSettings === true};</script>
<script nonce="${nonce}">window.ICONS_BASE_URI = "${opts.iconsBaseUri}"; window.KILO_SHIKI_WORKER_URI = "${opts.workerUri}"; window.KILO_MARKDOWN_SHIKI_WORKER_URI = "${markdownWorkerUri}"; window.KILO_TOP_BAR = ${opts.topBar !== false}; window.KILO_TOP_BAR_SURFACE = "${opts.topBarSurface ?? "sidebar_title"}"; window.KILO_AGENT_MANAGER_SETTINGS = ${opts.agentManagerSettings === true}; window.KILO_BROWSER_AUTOMATION = ${opts.browserAutomation === true};</script>
<script nonce="${nonce}" src="${opts.scriptUri}"></script>
</body>
</html>`
@@ -20,7 +20,7 @@ function joinCspDirectives(directives: string[]): string {
/**
* Build the full CSP policy string for a webview.
*/
export function buildCspString(cspSource: string, nonce: string, port?: number): string {
export function buildCspString(cspSource: string, nonce: string, port?: number, frame?: string): string {
const connectSrc = buildConnectSrc(port)
const directives = [
"default-src 'none'",
@@ -31,6 +31,7 @@ export function buildCspString(cspSource: string, nonce: string, port?: number):
`font-src ${cspSource}`,
`connect-src ${cspSource} ${connectSrc}`,
`img-src ${cspSource} data: https:`,
...(frame ? [`frame-src ${frame}`] : []),
]
return joinCspDirectives(directives)
}
@@ -0,0 +1,180 @@
import assert from "node:assert/strict"
import { Window } from "happy-dom"
const window = new Window({ url: "http://localhost" })
Object.assign(globalThis, {
window,
document: window.document,
navigator: window.navigator,
Node: window.Node,
Element: window.Element,
HTMLElement: window.HTMLElement,
HTMLInputElement: window.HTMLInputElement,
HTMLButtonElement: window.HTMLButtonElement,
SVGElement: window.SVGElement,
MutationObserver: window.MutationObserver,
ResizeObserver: window.ResizeObserver,
Event: window.Event,
MouseEvent: window.MouseEvent,
getComputedStyle: window.getComputedStyle.bind(window),
requestAnimationFrame: window.requestAnimationFrame.bind(window),
cancelAnimationFrame: window.cancelAnimationFrame.bind(window),
})
const { createSignal } = await import("solid-js")
const { render } = await import("solid-js/web")
const { BrowserPanel } = await import("../../webview-ui/browser")
import type { BrowserCommand, BrowserEvent, BrowserLabels } from "../../webview-ui/browser/types"
import type { BrowserReference } from "../../src/shared/browser-feedback"
const root = document.createElement("div")
document.body.append(root)
const scope = { sessionId: "standalone", projectId: "fixture" }
const sent: BrowserCommand[] = []
const references: BrowserReference[] = []
let receive: ((event: BrowserEvent) => void) | undefined
let closed = 0
const [labels, update] = createSignal<BrowserLabels>({
title: "Browser",
url: "Address",
urlPlaceholder: "Local URL",
open: "Go",
refresh: "Reload",
close: "Close",
inspect: "Select element",
devtoolsTitle: "Developer tools",
diagnostics: "Browser diagnostics",
diagnosticsHint: "Recent events from the automation browser. Security blocks are not console errors.",
empty: "Open a local page",
noSession: "Choose a session",
screenshotAlt: "Preview",
errors: (count) => `${count} errors`,
})
const dispose = render(
() => (
<BrowserPanel
scope={() => scope}
labels={labels()}
theme={() => "light"}
transport={{
send: (command) => sent.push(command),
subscribe: (handler) => {
receive = handler
return () => {
receive = undefined
}
},
}}
onReference={(reference) => references.push(reference)}
onClose={() => closed++}
/>
),
root,
)
assert.deepEqual(sent[0], { type: "state", scope })
const state = { scope, browserId: "browser", status: "ready" as const, errors: 0, url: "about:blank" }
receive?.({ type: "state", value: { ...state, status: "error", error: "Cannot connect to the local server" } })
const failure = root.querySelector('[role="alert"][data-component="card"][data-variant="error"]')
assert.ok(failure)
assert.equal(failure.querySelector(".error-card-message")?.textContent, "Cannot connect to the local server")
assert.ok(failure.querySelector('[data-component="icon"]'))
assert.equal(root.querySelector(".am-browser-frame"), null)
assert.equal(root.querySelector(".am-browser-empty"), null)
receive?.({ type: "state", value: state })
assert.equal(root.querySelector('[role="alert"]'), null)
await window.happyDOM.waitUntilComplete()
const frame = root.querySelector(".am-browser-frame")
assert.ok(frame)
assert.equal(root.querySelector(".am-browser-site"), null)
receive?.({ type: "state", value: { ...state, logs: ["[info] Updated"] } })
assert.equal(root.querySelector(".am-browser-frame"), frame)
assert.equal(root.querySelector(".am-browser-diagnostics button")?.textContent, "Browser diagnostics")
assert.equal(root.querySelector(".am-browser-console"), null)
;(root.querySelector("button[aria-label=Reload]") as HTMLButtonElement).click()
assert.deepEqual(sent.at(-1), { type: "refresh", scope })
receive?.({ type: "state", value: { ...state, navigation: 1 } })
await window.happyDOM.waitUntilComplete()
assert.equal(frame.isConnected, false)
const refreshed = root.querySelector(".am-browser-frame")
assert.ok(refreshed)
assert.equal(refreshed.getAttribute("src"), state.url)
assert.equal(refreshed.getAttribute("sandbox"), "allow-scripts allow-forms allow-same-origin")
receive?.({ type: "state", value: { ...state, navigation: 1, errors: 1 } })
assert.equal(root.querySelector(".am-browser-frame"), refreshed)
receive?.({
type: "state",
value: { ...state, navigation: 1, title: "", error: "Developer tools are unavailable" },
})
assert.equal(root.querySelector(".am-browser-frame"), refreshed)
assert.equal(
root.querySelector('[role="alert"][data-variant="error"] .error-card-message')?.textContent,
"Developer tools are unavailable",
)
assert.equal(root.querySelectorAll("button[aria-label=Close]").length, 1)
;(root.querySelector("button[aria-label='Select element']") as HTMLButtonElement).click()
const overlay = root.querySelector(".am-browser-inspect") as HTMLButtonElement
assert.ok(overlay)
overlay.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 200 }) as DOMRect
overlay.dispatchEvent(new window.MouseEvent("click", { bubbles: true, clientX: 200, clientY: 100 }))
const request = sent.at(-1)
assert.equal(request?.type, "inspect")
if (request?.type !== "inspect") throw new Error("Selection command was not emitted")
receive?.({ type: "inspection", value: { scope, requestId: request.requestId, hover: false, logs: [] } })
assert.equal(references.length, 0)
const retry = root.querySelector(".am-browser-inspect") as HTMLButtonElement
assert.ok(retry)
retry.getBoundingClientRect = overlay.getBoundingClientRect
retry.dispatchEvent(new window.MouseEvent("click", { bubbles: true, clientX: 200, clientY: 100 }))
const selected = sent.at(-1)
if (selected?.type !== "inspect") throw new Error("Retry selection command was not emitted")
receive?.({
type: "inspection",
value: {
scope,
requestId: selected.requestId,
hover: false,
logs: [],
element: { tag: "button", selector: "#save" },
},
})
assert.equal(references[0]?.selector, "#save")
update((value) => ({ ...value, close: "Fermer" }))
await window.happyDOM.waitUntilComplete()
assert.ok(root.querySelector("button[aria-label=Fermer]"))
const blocked = "Blocked browser request: http://127.0.0.1:4097"
const other = "Blocked browser request: http://127.0.0.1:4098"
receive?.({
type: "state",
value: { ...state, navigation: 1, errors: 5, logs: [blocked, blocked, other, blocked, "[error] Failed to load"] },
})
assert.equal(root.querySelector('[role="alert"]'), null)
assert.equal(root.querySelector(".am-browser-error-count"), null)
assert.ok(root.querySelector(".am-browser-tools-action button[aria-label='Developer tools']"))
const diagnostics = root.querySelector(".am-browser-diagnostics button") as HTMLButtonElement
assert.equal(diagnostics.textContent, "5 errors")
assert.equal(diagnostics.getAttribute("aria-expanded"), "false")
assert.equal(root.querySelector(".am-browser-console"), null)
diagnostics.click()
await window.happyDOM.waitUntilComplete()
assert.equal(diagnostics.getAttribute("aria-expanded"), "true")
const entries = [...root.querySelectorAll(".am-browser-console-entry")]
assert.equal(entries.length, 3, root.querySelector(".am-browser-diagnostics")?.outerHTML)
assert.equal(entries.at(0)?.textContent, `${blocked}×3`)
assert.equal(entries.at(1)?.textContent, other)
assert.equal(entries.at(2)?.textContent, "[error] Failed to load")
assert.equal(root.querySelector(".am-browser-frame"), refreshed)
;(root.querySelector(".am-browser-tools-action button") as HTMLButtonElement).click()
assert.deepEqual(sent.at(-1), { type: "devtools", scope, theme: "light" })
receive?.({ type: "devtools", value: { scope, browserId: state.browserId, url: "about:blank" } })
await window.happyDOM.waitUntilComplete()
assert.ok(root.querySelector(".am-browser-devtools-frame"))
assert.equal(root.querySelectorAll(".am-browser-console-entry").length, 3)
assert.equal(diagnostics.getAttribute("aria-expanded"), "true")
receive?.({ type: "state", value: { ...state, navigation: 2 } })
assert.equal(root.querySelector(".am-browser-diagnostics"), null)
;(root.querySelector("button[aria-label=Fermer]") as HTMLButtonElement).click()
assert.equal(closed, 1)
assert.deepEqual(sent.at(-1), { type: "close", scope })
dispose()
assert.equal(receive, undefined)
await window.happyDOM.close()
@@ -2,6 +2,7 @@ import assert from "node:assert/strict"
import { Window } from "happy-dom"
const window = new Window({ url: "http://localhost" })
Object.defineProperty(window, "origin", { value: window.location.origin })
class CSSStyleSheetStub {
replaceSync() {}
replace() {
@@ -2,6 +2,7 @@ import assert from "node:assert/strict"
import { Window } from "happy-dom"
const window = new Window({ url: "http://localhost" })
Object.defineProperty(window, "origin", { value: window.location.origin })
const sent: unknown[] = []
const api = {
postMessage: (message: unknown) => sent.push(message),
@@ -47,6 +48,7 @@ const { LanguageContext } = await import("../../webview-ui/src/context/language"
const { NotificationsProvider } = await import("../../webview-ui/src/context/notifications")
const { ProviderContext } = await import("../../webview-ui/src/context/provider")
const { SessionProvider, useSession } = await import("../../webview-ui/src/context/session")
const { post } = await import("../../webview-ui/src/utils/webview-message")
const { terminal } = await import("../../webview-ui/src/context/session-outcome")
const provider = {
@@ -198,7 +200,7 @@ const settle = async () => {
await window.happyDOM.waitUntilComplete()
}
const emit = async (data: unknown) => {
window.dispatchEvent(new MessageEvent("message", { data }))
post(data)
await settle()
}
const state = (id: string) => {
@@ -640,9 +642,7 @@ try {
} finally {
const before = state("background")
dispose()
window.dispatchEvent(
new MessageEvent("message", { data: { type: "sessionStatus", sessionID: "background", status: "retry" } }),
)
post({ type: "sessionStatus", sessionID: "background", status: "retry" })
assert.equal(state("background"), before)
await window.happyDOM.cancelAsync()
await window.happyDOM.close()
@@ -19,6 +19,7 @@ const EDIT_PREVIEW_PANEL_FILE = path.join(ROOT, "webview-ui/agent-manager/EditPr
const CSS_FILES = [
path.join(ROOT, "webview-ui/agent-manager/agent-manager.css"),
path.join(ROOT, "webview-ui/agent-manager/agent-manager-review.css"),
path.join(ROOT, "webview-ui/browser/browser.css"),
]
const TSX_FILES = [
path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"),
@@ -29,6 +30,8 @@ const TSX_FILES = [
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/agent-manager/BrowserPanel.tsx"),
path.join(ROOT, "webview-ui/browser/BrowserPanel.tsx"),
path.join(ROOT, "webview-ui/agent-manager/DiffPanelCache.tsx"),
path.join(ROOT, "webview-ui/agent-manager/review-composers.ts"),
path.join(ROOT, "webview-ui/documents/DocumentPanel.tsx"),
@@ -171,6 +174,23 @@ describe("Agent Manager CSS/TSX Consistency", () => {
})
})
describe("Browser module boundaries", () => {
it("keeps browser core independent from Agent Manager and VS Code context", () => {
const files = ["BrowserPanel.tsx", "controller.ts", "types.ts", "index.ts"]
for (const file of files) {
const source = fs.readFileSync(path.join(ROOT, "webview-ui/browser", file), "utf-8")
expect(source).not.toMatch(/agent-manager|AgentManager|SidePanel|useVSCode|window\.postMessage/)
}
})
it("owns browser styles in the reusable module", () => {
const css = fs.readFileSync(path.join(ROOT, "webview-ui/agent-manager/agent-manager.css"), "utf-8")
const browser = fs.readFileSync(path.join(ROOT, "webview-ui/browser/BrowserPanel.tsx"), "utf-8")
expect(css).not.toContain(".am-browser-")
expect(browser).toContain('import "./browser.css"')
})
})
describe("Agent Manager edit preview", () => {
it("provides a visible close action", () => {
const source = fs.readFileSync(EDIT_PREVIEW_PANEL_FILE, "utf-8")
@@ -647,10 +667,13 @@ describe("Agent Manager Provider — onMessage routing", () => {
it("does not restore running indicators after a session is deleted", () => {
const lifecycle = body("onSessionLifecycle")
const status = body("onSessionStatus")
const helper = fs.readFileSync(path.join(ROOT, "src/agent-manager/session-lifecycle.ts"), "utf-8")
expect(lifecycle).toContain("this.removedSessions.add(id)")
expect(lifecycle).toContain("this.busySessions.delete(id)")
expect(lifecycle).toContain("info && !this.removedSessions.has(info.id) ? info.directory : undefined")
expect(lifecycle).toContain("removed: this.removedSessions")
expect(lifecycle).toContain("busy: this.busySessions")
expect(helper).toContain("deps.removed.add(id)")
expect(helper).toContain("deps.busy.delete(id)")
expect(helper).toContain("if (deps.removed.has(info.id)) return")
expect(status).toContain("this.removedSessions.has(sid)")
})
@@ -25,6 +25,7 @@ import path from "node:path"
const ROOT = path.resolve(import.meta.dir, "../..")
const TSX_FILES = [
path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"),
path.join(ROOT, "webview-ui/agent-manager/BrowserPanel.tsx"),
path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"),
path.join(ROOT, "webview-ui/agent-manager/terminal/SideTerminalPanel.tsx"),
path.join(ROOT, "webview-ui/agent-manager/terminal/TerminalDestinationButton.tsx"),
@@ -3,8 +3,59 @@ import fs from "node:fs"
import path from "node:path"
const TAB_BAR = path.resolve(import.meta.dir, "../../webview-ui/agent-manager/TabBar.tsx")
const BROWSER_PANEL = path.resolve(import.meta.dir, "../../webview-ui/browser/BrowserPanel.tsx")
const BROWSER_ADAPTER = path.resolve(import.meta.dir, "../../webview-ui/agent-manager/BrowserPanel.tsx")
describe("Agent Manager diff toggle", () => {
it("exposes the browser action through an accessible button label", () => {
const source = fs.readFileSync(TAB_BAR, "utf-8")
const start = source.indexOf("<Show when={props.browserAutomation()}>")
const end = source.indexOf("</Show>", start)
const button = source.slice(start, end)
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
expect(button).toContain('aria-label={props.t("agentManager.browser.title")}')
})
it("renders a real sandboxed browser document instead of an image", () => {
const source = fs.readFileSync(BROWSER_PANEL, "utf-8")
expect(source).toContain("<iframe")
expect(source).toContain('sandbox="allow-scripts allow-forms allow-same-origin"')
expect(source).not.toContain("<img")
expect(source).not.toContain("<canvas")
})
it("reloads the visible document on each browser navigation and bridges native element inspection", () => {
const source = fs.readFileSync(BROWSER_PANEL, "utf-8")
expect(source).toContain("props.state?.navigation")
expect(source).toContain("when={identity()}")
expect(source).not.toContain("contentWindow")
expect(source).toContain("onMouseMove={(event) => props.controller.move(position(event))}")
expect(fs.readFileSync(BROWSER_ADAPTER, "utf-8")).toContain('type: "agentManager.browser.input"')
})
it("keeps browser chrome compact with one close action and no duplicate footer", () => {
const source = fs.readFileSync(BROWSER_PANEL, "utf-8")
expect(source).toContain('class="am-browser-address"')
expect(source).toContain('icon="arrow-right"')
expect(source).toContain('icon="window-cursor"')
expect(source.match(/icon="close"/g)).toHaveLength(1)
expect(source).not.toContain("am-browser-footer")
expect(source).not.toContain("am-browser-selected")
expect(source).not.toContain("am-browser-devtools-toolbar")
})
it("passes selected references through the host adapter", () => {
const source = fs.readFileSync(BROWSER_ADAPTER, "utf-8")
expect(source).toContain("onReference={reference}")
expect(source).toContain('import { post } from "../src/utils/webview-message"')
expect(source).toContain('post({ type: "appendChatBoxMessage"')
expect(source).toContain("const labels = createMemo(")
expect(source).toContain("theme={theme}")
expect(source).not.toContain("window.postMessage")
})
it("renders live Git stats rather than pull-request stats", () => {
const source = fs.readFileSync(TAB_BAR, "utf-8")
const start = source.indexOf('title={props.t("agentManager.diff.toggle")}')
@@ -0,0 +1,849 @@
import { afterEach, describe, expect, test } from "bun:test"
import { createServer, request, type IncomingMessage } from "node:http"
import { connect } from "node:net"
import { PassThrough } from "node:stream"
import { runInNewContext } from "node:vm"
import WebSocket, { WebSocketServer } from "ws"
import { BrowserBroker, diagnostic } from "../../src/services/browser-automation/browser-broker"
import { BrowserDevtools } from "../../src/services/browser-automation/browser-devtools"
const brokers: BrowserBroker[] = []
function fixture<T>(page: T) {
const broker = new BrowserBroker({
log: () => {},
launch: async () => ({
newContext: async () => ({
close: async () => undefined,
route: async () => undefined,
routeWebSocket: async () => undefined,
newPage: async () => page,
}),
close: async () => undefined,
}),
})
brokers.push(broker)
return broker
}
afterEach(async () => {
await Promise.all(brokers.splice(0).map((broker) => broker.disposeAsync()))
})
describe("BrowserBroker", () => {
test("accepts only HTTP loopback URLs", () => {
const broker = new BrowserBroker({ log: () => {} })
expect(broker.validate("http://localhost:3000/path").origin).toBe("http://localhost:3000")
expect(() => broker.validate("https://localhost:3000")).toThrow()
expect(() => broker.validate("http://127.0.0.1:3000")).not.toThrow()
expect(() => broker.validate("http://[::1]:3000")).toThrow("Use localhost for IPv6 loopback servers")
expect(() => broker.validate("http://0.0.0.0:3000")).toThrow()
expect(() => broker.validate("http://example.com")).toThrow()
expect(() => broker.validate("http://username:password@localhost:3000")).toThrow()
expect(() => broker.validate("file:///tmp/example.html")).toThrow()
})
test("normalizes browser failures without leaking Playwright call logs or ANSI formatting", () => {
const refused = new Error(
"page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:31847/\nCall log:\n\u001b[2m- navigating\u001b[22m",
)
expect(diagnostic(refused, "http://localhost:31847/")).toBe(
"Cannot connect to http://localhost:31847/. Make sure the local server is running.",
)
expect(diagnostic(new Error("page.goto: Timeout 30000ms exceeded"))).toBe(
"The local application did not respond in time. Check the server and try again.",
)
expect(diagnostic(new Error("\u001b[31mpage.goto: Custom navigation failure\u001b[0m\nCall log:\n- details"))).toBe(
"Custom navigation failure",
)
})
test("sanitizes refused navigation errors in browser state and agent responses", async () => {
const page = {
url: () => "about:blank",
title: async () => "",
screenshot: async () => Buffer.from("jpeg"),
on: (_type: string, _listener: (...args: never[]) => void) => undefined,
mainFrame: () => undefined,
goto: async () => {
throw new Error("page.goto: net::ERR_CONNECTION_REFUSED\nCall log:\n\u001b[2m- navigating\u001b[22m")
},
}
const broker = fixture(page)
const env = await broker.env()
const response = await fetch(`${env.KILO_BROWSER_BROKER_URL}/browser/open`, {
method: "POST",
headers: {
authorization: `Bearer ${env.KILO_BROWSER_BROKER_TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify({ sessionID: "refused", directory: "/tmp/project", url: "http://localhost:31847/" }),
})
const message = "Cannot connect to http://localhost:31847/. Make sure the local server is running."
expect(response.status).toBe(400)
expect(await response.json()).toEqual({ error: message })
expect(broker.get("refused")?.error).toBe(message)
})
test("reloads repeated agent opens and returns fresh page diagnostics", async () => {
const listeners = new Map<string, (value: unknown) => void>()
const loading: number[] = []
let version = 1
const page = {
url: () => "http://localhost:3000/",
title: async () => `Application version ${version}`,
screenshot: async () => Buffer.from(`version-${version}`),
on: (type: string, listener: (value: unknown) => void) => {
listeners.set(type, listener)
},
mainFrame: () => undefined,
goto: async () => {
listeners.get("console")?.({ type: () => "log", text: () => "STARTUP_VERSION_1" })
},
reload: async () => {
version++
listeners.get("console")?.({ type: () => "error", text: () => "STARTUP_VERSION_2" })
},
}
const broker = fixture(page)
broker.subscribe((state) => {
if (state.status === "loading" && loading.at(-1) !== state.navigation) loading.push(state.navigation)
})
const env = await broker.env()
const request = () =>
fetch(`${env.KILO_BROWSER_BROKER_URL}/browser/open`, {
method: "POST",
headers: {
authorization: `Bearer ${env.KILO_BROWSER_BROKER_TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify({ sessionID: "agent", directory: "/tmp/project", url: "http://localhost:3000/" }),
}).then((response) => response.json() as Promise<{ navigation: number; title: string; logs: string[] }>)
expect(await request()).toMatchObject({
navigation: 1,
title: "Application version 1",
logs: ["[log] STARTUP_VERSION_1"],
})
expect(await request()).toMatchObject({
navigation: 2,
title: "Application version 2",
logs: ["[error] STARTUP_VERSION_2"],
})
expect(loading).toEqual([1, 2])
})
test("protects its local bridge with a bearer token", async () => {
const broker = new BrowserBroker({ log: () => {} })
brokers.push(broker)
const env = await broker.env()
const result = await new Promise<{ status?: number; body: string }>((resolve, reject) => {
const url = new URL(`${env.KILO_BROWSER_BROKER_URL}/browser/state`)
const req = request(url, { method: "POST", headers: { "content-type": "application/json" } }, (res) => {
const chunks: Buffer[] = []
res.on("data", (chunk) => chunks.push(chunk))
res.on("end", () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString("utf8") }))
})
req.on("error", reject)
req.end("{}")
})
expect(result.status).toBe(401)
expect(JSON.parse(result.body)).toEqual({ error: "Unauthorized" })
const malformed = await fetch(`${env.KILO_BROWSER_BROKER_URL}/browser/status`, {
headers: { authorization: `Bearer ${"é".repeat(64)}` },
})
expect(malformed.status).toBe(401)
})
test("reports experimental availability only to authenticated clients", async () => {
let enabled = false
let trusted = true
const broker = new BrowserBroker({ log: () => {}, enabled: () => enabled, trusted: () => trusted })
brokers.push(broker)
const env = await broker.env()
const headers = { authorization: `Bearer ${env.KILO_BROWSER_BROKER_TOKEN}` }
const url = `${env.KILO_BROWSER_BROKER_URL}/browser/status`
expect((await fetch(url)).status).toBe(401)
expect(await (await fetch(url, { headers })).json()).toEqual({ enabled: false })
enabled = true
expect(await (await fetch(url, { headers })).json()).toEqual({ enabled: true })
trusted = false
expect(await (await fetch(url, { headers })).json()).toEqual({ enabled: false })
})
test("writes an explicit forbidden response before closing an untrusted upgrade", () => {
const server = createServer()
const tools = new BrowserDevtools(
server,
4567,
() => {},
() => {},
)
const url = new URL(tools.open("browser", "page", 1234, "dark"))
const endpoint = new URL(`ws://${url.searchParams.get("ws")}`)
const socket = new PassThrough()
const chunks: Buffer[] = []
socket.on("data", (chunk) => chunks.push(chunk))
server.emit(
"upgrade",
{
url: endpoint.pathname,
headers: { host: "127.0.0.1:4567", origin: "http://untrusted.invalid" },
} as IncomingMessage,
socket,
Buffer.alloc(0),
)
expect(Buffer.concat(chunks).toString()).toStartWith("HTTP/1.1 403 Forbidden\r\n")
expect(socket.destroyed).toBe(true)
tools.dispose()
})
test("proxies page-scoped developer tools and rejects invalid capabilities or origins", async () => {
const remote = createServer((req, res) => {
const path = new URL(req.url ?? "/", "http://127.0.0.1").pathname
if (path === "/devtools/inspector.html") {
res.writeHead(200, { "content-type": "text/html" })
res.end('<script type="module" src="./entrypoints/inspector.js"></script>')
return
}
if (path === "/devtools/entrypoints/inspector.js") {
res.writeHead(200, { "content-type": "text/javascript" })
res.end("globalThis.loaded = true")
return
}
res.writeHead(404)
res.end()
})
const websocket = new WebSocketServer({ server: remote })
const routes: string[] = []
const moves: Array<{ type: string; x?: number; y?: number }> = []
websocket.on("connection", (socket, req) => {
routes.push(req.url ?? "")
socket.on("message", (value) => {
const input = JSON.parse(value.toString()) as { id: number }
socket.send(JSON.stringify({ id: input.id, result: { value: "selected page" } }))
})
})
await new Promise<void>((resolve) => remote.listen(0, "127.0.0.1", resolve))
const address = remote.address()
if (!address || typeof address === "string") throw new Error("Test browser did not receive a local port")
let next = 0
const browser = {
debugging: address.port,
newContext: async () => {
const id = `target-${++next}`
let viewport: { width: number; height: number } | undefined
const page = {
url: () => "http://localhost:3000/",
title: async () => "Developer tools test",
screenshot: async () => Buffer.from("jpeg"),
on: (_type: string, _listener: (...args: never[]) => void) => undefined,
mainFrame: () => undefined,
goto: async () => undefined,
viewportSize: () => viewport,
setViewportSize: async (size: { width: number; height: number }) => {
viewport = size
},
mouse: {
move: async (x: number, y: number) => moves.push({ type: "move", x, y }),
down: async () => moves.push({ type: "down" }),
up: async () => {
moves.push({ type: "up" })
for (const client of websocket.clients) {
client.send(JSON.stringify({ method: "Overlay.inspectNodeRequested", params: { backendNodeId: 42 } }))
}
},
},
}
return {
close: async () => undefined,
route: async () => undefined,
routeWebSocket: async () => undefined,
newPage: async () => page,
newCDPSession: async () => ({
send: async () => ({ targetInfo: { targetId: id } }),
detach: async () => undefined,
}),
}
},
close: async () => undefined,
}
const broker = new BrowserBroker({ log: () => {}, launch: async () => browser })
brokers.push(broker)
try {
const env = await broker.env()
await broker.open(
{ projectId: "project", sessionId: "first", directory: "/tmp/project" },
"http://localhost:3000",
)
await broker.open(
{ projectId: "project", sessionId: "second", directory: "/tmp/project" },
"http://localhost:3000",
)
const first = await broker.devtools("first", "project")
const second = await broker.devtools("second", "project", "light")
expect(first.browserId).not.toBe(second.browserId)
expect(first.url).not.toContain(env.KILO_BROWSER_BROKER_TOKEN)
expect(new URL(first.url).searchParams.has("can_dock")).toBe(false)
const frontend = await fetch(first.url)
expect(frontend.status).toBe(200)
expect(await frontend.text()).toContain('<script src="./kilo-bootstrap.js"></script>')
for (const [entry, theme] of [
[first, "dark"],
[second, "light"],
] as const) {
const bootstrap = await fetch(new URL("./kilo-bootstrap.js", entry.url))
expect(bootstrap.status).toBe(200)
const storage = new Map<string, string>()
runInNewContext(await bootstrap.text(), {
localStorage: { setItem: (key: string, value: string) => storage.set(key, value) },
})
expect(storage.get("ui-theme")).toBe(JSON.stringify(theme))
expect(storage.get("currentDockState")).toBe(JSON.stringify("undocked"))
}
expect((await fetch(new URL("./entrypoints/inspector.js", first.url))).status).toBe(200)
const invalid = new URL(first.url)
const parts = invalid.pathname.split("/")
parts[4] = "0".repeat(64)
invalid.pathname = parts.join("/")
expect((await fetch(invalid)).status).toBe(401)
const swapped = new URL(first.url)
swapped.pathname = swapped.pathname.replace(first.browserId, second.browserId)
expect((await fetch(swapped)).status).toBe(401)
const endpoint = `ws://${new URL(first.url).searchParams.get("ws")}`
const forbidden = await new Promise<number>((resolve, reject) => {
const url = new URL(endpoint)
const socket = connect({ host: url.hostname, port: Number(url.port) }, () => {
socket.write(
[
`GET ${url.pathname} HTTP/1.1`,
`Host: ${url.host}`,
"Connection: Upgrade",
"Upgrade: websocket",
"Sec-WebSocket-Version: 13",
`Sec-WebSocket-Key: ${Buffer.from("browser-test-key").toString("base64")}`,
"Origin: http://untrusted.invalid",
"\r\n",
].join("\r\n"),
)
})
socket.once("data", (data) => {
resolve(Number(data.toString().match(/^HTTP\/1\.1 (\d+)/)?.[1] ?? 0))
socket.end()
})
socket.once("end", () => resolve(0))
socket.once("error", reject)
})
expect([0, 403]).toContain(forbidden)
const socket = new WebSocket(endpoint, { headers: { origin: new URL(first.url).origin } })
await new Promise<void>((resolve, reject) => {
socket.once("open", resolve)
socket.once("error", reject)
})
socket.send(JSON.stringify({ id: 9, method: "Runtime.evaluate" }))
const result = await new Promise<{ id: number; result: { value: string } }>((resolve) => {
socket.once("message", (value) => resolve(JSON.parse(value.toString())))
})
expect(result).toEqual({ id: 9, result: { value: "selected page" } })
expect(routes).toEqual(["/devtools/page/target-1"])
await broker.input("first", "project", { x: 0.5, y: 0.25, width: 400, height: 240 }, false)
expect(moves).toEqual([])
socket.send(
JSON.stringify({
id: 10,
method: "Overlay.setInspectMode",
params: { mode: "searchForNode", highlightConfig: { showInfo: true } },
}),
)
await new Promise<void>((resolve) => socket.once("message", () => resolve()))
expect(broker.get("first", "project")?.inspecting).toBe(true)
await broker.input("first", "project", { x: 0.5, y: 0.25, width: 400, height: 240 }, false)
expect(moves).toEqual([{ type: "move", x: 200, y: 60 }])
const selected = new Promise<{ method: string; params: { backendNodeId: number } }>((resolve) => {
socket.once("message", (value) => resolve(JSON.parse(value.toString())))
})
await broker.input("first", "project", { x: 0.75, y: 0.5, width: 400, height: 240 }, true)
expect(await selected).toEqual({ method: "Overlay.inspectNodeRequested", params: { backendNodeId: 42 } })
expect(moves).toEqual([
{ type: "move", x: 200, y: 60 },
{ type: "move", x: 300, y: 120 },
{ type: "down" },
{ type: "up" },
])
expect(broker.get("first", "project")?.inspecting).toBe(false)
expect(broker.get("second", "project")?.inspecting).not.toBe(true)
const closed = new Promise<void>((resolve) => socket.once("close", () => resolve()))
await broker.close("first", "project")
await closed
expect((await fetch(first.url)).status).toBe(401)
expect((await fetch(second.url)).status).toBe(200)
} finally {
await broker.disposeAsync()
await new Promise<void>((resolve) => websocket.close(() => resolve()))
await new Promise<void>((resolve) => remote.close(() => resolve()))
}
})
test("rejects authenticated first-open requests for unknown sessions and directories", async () => {
const broker = new BrowserBroker({ log: () => {} })
brokers.push(broker)
broker.bind((route) =>
route.sessionId === "known" && route.directory === "/tmp/known" ? { ...route, projectId: "project" } : undefined,
)
const env = await broker.env()
const headers = {
authorization: `Bearer ${env.KILO_BROWSER_BROKER_TOKEN}`,
"content-type": "application/json",
}
const url = `${env.KILO_BROWSER_BROKER_URL}/browser/open`
for (const input of [
{ sessionID: "unknown", directory: "/tmp/known", url: "http://localhost:3000/" },
{ sessionID: "known", directory: "/tmp/other", url: "http://localhost:3000/" },
]) {
const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(input) })
expect(response.status).toBe(400)
expect(await response.json()).toEqual({
error: "Browser session does not belong to the requested project or directory",
})
}
expect(broker.sessions()).toEqual([])
})
test("keeps browser contexts and HTTP rejection diagnostics isolated by session", async () => {
const contexts: Array<{ close: () => Promise<void> }> = []
const listeners = new Map<string, (value: unknown) => void>()
let routeHandler:
| ((
route: { continue: () => Promise<void>; abort: () => Promise<void> },
request: { url: () => string; isNavigationRequest?: () => boolean },
) => Promise<void>)
| undefined
let aborted = false
const browser = {
newContext: async () => {
const page = {
url: () => "http://localhost:3000",
title: async () => "Local app",
screenshot: async () => Buffer.from("jpeg"),
on: (type: string, listener: (value: unknown) => void) => {
listeners.set(type, listener)
},
mainFrame: () => undefined,
goto: async () => undefined,
}
const context = {
close: async () => undefined,
route: async (_pattern: string, handler: typeof routeHandler) => {
routeHandler = handler
},
routeWebSocket: async () => undefined,
newPage: async () => page,
}
contexts.push(context)
return context
},
close: async () => undefined,
}
const broker = new BrowserBroker({ log: () => {}, launch: async () => browser })
brokers.push(broker)
await broker.open({ sessionId: "one", directory: "/tmp/project" }, "http://localhost:3000")
await broker.open({ sessionId: "two", directory: "/tmp/project" }, "http://localhost:3000")
expect(contexts).toHaveLength(2)
expect(broker.get("one")?.browserId).not.toBe(broker.get("two")?.browserId)
await routeHandler!(
{ continue: async () => undefined, abort: async () => void (aborted = true) },
{ url: () => "http://example.com" },
)
expect(aborted).toBe(true)
const blocked = broker.get("two")
expect(blocked).toMatchObject({
errors: 1,
logs: ["Blocked browser request: http://example.com"],
error: undefined,
})
listeners.get("requestfailed")?.({ url: () => "http://example.com" })
expect(broker.get("two")).toEqual(blocked)
expect(broker.get("one")).toMatchObject({ errors: 0, logs: [], error: undefined })
aborted = false
await routeHandler!(
{ continue: async () => undefined, abort: async () => void (aborted = true) },
{ url: () => "data:text/html,<script>alert(1)</script>", isNavigationRequest: () => true },
)
expect(aborted).toBe(true)
expect(broker.get("two")).toMatchObject({
errors: 2,
logs: ["Blocked browser request: http://example.com", "Blocked browser request: null"],
error: undefined,
})
await broker.close("one")
expect(broker.get("two")?.status).toBe("ready")
})
test("rejects disabled and untrusted browser sessions before launching Chrome", async () => {
const route = { sessionId: "restricted", directory: "/tmp/project" }
const disabled = new BrowserBroker({ log: () => {}, enabled: () => false })
const untrusted = new BrowserBroker({ log: () => {}, trusted: () => false })
await expect(disabled.open(route, "http://localhost:3000")).rejects.toThrow("Browser automation is disabled")
await expect(untrusted.open(route, "http://localhost:3000")).rejects.toThrow("trusted workspace")
expect(disabled.sessions()).toEqual([])
expect(untrusted.sessions()).toEqual([])
})
test("explains how to recover when the selected browser runtime is missing", async () => {
const broker = new BrowserBroker({
log: () => {},
useSystemChrome: () => false,
launch: async () => {
throw new Error("Browser executable does not exist")
},
})
brokers.push(broker)
await expect(
broker.open({ sessionId: "missing-runtime", directory: "/tmp/project" }, "http://localhost:3000/"),
).rejects.toThrow("enable Use System Chrome")
expect(broker.sessions()).toEqual([])
})
test("rejects unregistered browser sessions before launching Chrome", async () => {
const broker = new BrowserBroker({ log: () => {} })
broker.bind(() => undefined)
await expect(
broker.open({ projectId: "unknown", sessionId: "missing", directory: "/tmp/project" }, "http://localhost:3000/"),
).rejects.toThrow("Browser session does not belong to the requested project or directory")
expect(broker.sessions()).toEqual([])
})
test("preserves project isolation, successful refresh, and captured HTTP errors", async () => {
let status = 200
let target = "about:blank"
let navigations = 0
let reloads = 0
const page = {
url: () => target,
title: async () => (status === 404 ? "Missing page" : "Local app"),
screenshot: async () => Buffer.from("jpeg"),
on: (_type: string, _listener: (...args: never[]) => void) => undefined,
mainFrame: () => undefined,
goto: async (url: string) => {
navigations++
target = url
return { status: () => status }
},
reload: async () => {
reloads++
return { status: () => status }
},
}
const broker = fixture(page)
broker.bind((route) => (route.sessionId === "session" && route.directory === "/tmp/project" ? route : undefined))
const route = { projectId: "project-one", sessionId: "session", directory: "/tmp/project" }
const opened = await broker.open(route, "http://localhost:3000/")
expect(opened.status).toBe("ready")
expect(opened.navigation).toBe(1)
expect(opened.screenshot).toStartWith("data:image/jpeg;base64,")
const reopened = await broker.open(route, "http://localhost:3000/")
expect(navigations).toBe(1)
expect(reloads).toBe(1)
expect(reopened.navigation).toBe(2)
const other = await broker.open({ ...route, projectId: "project-two" }, "http://localhost:3000/")
expect(other.browserId).not.toBe(opened.browserId)
expect(broker.get(route.sessionId)).toBeUndefined()
expect(broker.get(route.sessionId, "project-two")?.browserId).toBe(other.browserId)
const refreshed = await broker.refresh(route.sessionId, route.projectId)
expect(refreshed.status).toBe("ready")
expect(refreshed.navigation).toBe(3)
expect(reloads).toBe(2)
status = 404
await expect(broker.refresh(route.sessionId, route.projectId)).rejects.toThrow(
"Local application returned HTTP 404",
)
expect(broker.get(route.sessionId, route.projectId)).toMatchObject({
projectId: "project-one",
status: "error",
title: "Missing page",
url: "http://localhost:3000/",
error: "Local application returned HTTP 404",
})
expect(broker.get(route.sessionId, route.projectId)?.screenshot).toStartWith("data:image/jpeg;base64,")
status = 200
const recovered = await broker.open(route, "http://localhost:3000/recovered")
expect(recovered).toMatchObject({ status: "ready", errors: 0, url: "http://localhost:3000/recovered" })
expect(recovered.error).toBeUndefined()
})
test("reports response headers that prevent embedding the real application document", async () => {
let headers: Record<string, string> = {}
let target = "about:blank"
const page = {
url: () => target,
title: async () => "Local app",
screenshot: async () => Buffer.from("jpeg"),
on: (_type: string, _listener: (...args: never[]) => void) => undefined,
mainFrame: () => undefined,
goto: async (url: string) => {
target = url
return { status: () => 200, headers: () => headers }
},
}
const broker = fixture(page)
const route = { sessionId: "framed", directory: "/tmp/project" }
expect((await broker.open(route, "http://localhost:3000/open")).frameError).toBeUndefined()
headers = { "x-frame-options": "DENY" }
expect((await broker.open(route, "http://localhost:3000/deny")).frameError).toContain("X-Frame-Options: DENY")
headers = { "x-frame-options": "SAMEORIGIN" }
expect((await broker.open(route, "http://localhost:3000/same-origin")).frameError).toContain("SAMEORIGIN")
headers = { "content-security-policy": "default-src 'self'; frame-ancestors 'none'" }
expect((await broker.open(route, "http://localhost:3000/csp")).frameError).toContain("frame-ancestors 'none'")
headers = {}
expect((await broker.open(route, "http://localhost:3000/recovered")).frameError).toBeUndefined()
})
test("serializes concurrent navigation and close without reviving a stale session", async () => {
let contexts = 0
let release: (() => void) | undefined
let started: (() => void) | undefined
const waiting = new Promise<void>((resolve) => {
release = resolve
})
const navigating = new Promise<void>((resolve) => {
started = resolve
})
let target = "about:blank"
const page = {
url: () => target,
title: async () => "Local app",
screenshot: async () => Buffer.from("jpeg"),
on: (_type: string, _listener: (...args: never[]) => void) => undefined,
mainFrame: () => undefined,
goto: async (url: string) => {
target = url
started?.()
await waiting
},
}
const browser = {
newContext: async () => {
contexts++
return {
close: async () => undefined,
route: async () => undefined,
routeWebSocket: async () => undefined,
newPage: async () => page,
}
},
close: async () => undefined,
}
const broker = new BrowserBroker({ log: () => {}, launch: async () => browser })
brokers.push(broker)
const states: string[] = []
broker.subscribe((state) => states.push(state.status))
const route = { sessionId: "concurrent", directory: "/tmp/project" }
const first = broker.open(route, "http://localhost:3000/one")
await navigating
const second = broker.open(route, "http://localhost:3000/two")
const closed = broker.close(route.sessionId)
release?.()
await Promise.all([first, second, closed])
expect(contexts).toBe(1)
expect(broker.get(route.sessionId)).toBeUndefined()
expect(states.at(-1)).toBe("closed")
})
test("does not create a context after disposal interrupts browser launch", async () => {
let resume: (() => void) | undefined
let launched: (() => void) | undefined
let contexts = 0
const waiting = new Promise<void>((resolve) => {
resume = resolve
})
const starting = new Promise<void>((resolve) => {
launched = resolve
})
const browser = {
newContext: async () => {
contexts++
throw new Error("unexpected context")
},
close: async () => undefined,
}
const broker = new BrowserBroker({
log: () => {},
launch: async () => {
launched?.()
await waiting
return browser
},
})
const opened = broker.open({ sessionId: "disposed", directory: "/tmp/project" }, "http://localhost:3000/")
await starting
const disposed = broker.disposeAsync()
resume?.()
await expect(opened).rejects.toThrow("Browser broker is closed")
await disposed
expect(contexts).toBe(0)
expect(broker.sessions()).toEqual([])
})
test("records bounded console diagnostics and inspects selected page elements", async () => {
const listeners = new Map<string, (value: unknown) => void>()
let viewport: { width: number; height: number } | undefined
let resizes = 0
const page = {
url: () => "http://localhost:3000/",
title: async () => "Feedback demo",
screenshot: async () => Buffer.from("jpeg"),
on: (type: string, listener: (value: unknown) => void) => {
listeners.set(type, listener)
},
mainFrame: () => undefined,
goto: async () => undefined,
reload: async () => {
listeners.get("console")?.({ type: () => "log", text: () => "DEMO_RELOAD_LOG: refreshed application" })
},
viewportSize: () => viewport,
setViewportSize: async (size: { width: number; height: number }) => {
viewport = size
resizes++
},
evaluate: async () => ({
tag: "section",
id: "feature-card",
text: "Blue feedback card",
selector: "#feature-card",
}),
}
const broker = fixture(page)
await broker.open(
{ projectId: "project", sessionId: "feedback", directory: "/tmp/project" },
"http://localhost:3000/",
)
listeners.get("console")?.({ type: () => "log", text: () => "DEMO_STARTUP_LOG: page loaded" })
listeners.get("console")?.({ type: () => "info", text: () => "DEMO_STARTUP_INFO: ready" })
listeners.get("console")?.({ type: () => "warning", text: () => "DEMO_STARTUP_WARNING: check layout" })
listeners.get("console")?.({ type: () => "error", text: () => "DEMO_STARTUP_ERROR: script initialized" })
listeners.get("pageerror")?.(new Error("DEMO_PAGE_ERROR: broken element"))
const inspected = await broker.inspect("feedback", "project", { x: 0.4, y: 0.3, width: 760, height: 580 })
expect(viewport).toEqual({ width: 760, height: 580 })
expect(inspected.element).toMatchObject({
tag: "section",
id: "feature-card",
text: "Blue feedback card",
selector: "#feature-card",
})
expect(inspected.logs).toEqual([
"[log] DEMO_STARTUP_LOG: page loaded",
"[info] DEMO_STARTUP_INFO: ready",
"[warning] DEMO_STARTUP_WARNING: check layout",
"[error] DEMO_STARTUP_ERROR: script initialized",
"DEMO_PAGE_ERROR: broken element",
])
expect(broker.get("feedback", "project")?.errors).toBe(2)
expect(broker.get("feedback", "project")?.logs).toEqual(inspected.logs)
await broker.inspect("feedback", "project", { x: 0.2, y: 0.4, width: 760, height: 580 })
expect(resizes).toBe(1)
const reloaded = await broker.open(
{ projectId: "project", sessionId: "feedback", directory: "/tmp/project" },
"http://localhost:3000/",
)
expect(reloaded.navigation).toBe(2)
expect(reloaded.errors).toBe(0)
expect(reloaded.logs).toEqual(["[log] DEMO_RELOAD_LOG: refreshed application"])
await expect(broker.inspect("feedback", "project", { x: 1.2, y: 0.3, width: 760, height: 580 })).rejects.toThrow(
"Browser element coordinates are invalid",
)
for (let index = 0; index < 25; index++) {
listeners.get("console")?.({ type: () => "error", text: () => `error-${index}` })
}
expect(broker.get("feedback", "project")?.logs).toHaveLength(20)
})
test("blocks browser popups without replacing navigation failures", async () => {
const listeners = new Map<string, (...args: never[]) => void>()
const page = {
url: () => "http://localhost:3000/",
title: async () => "Local app",
screenshot: async () => Buffer.from("jpeg"),
on: (type: string, listener: (...args: never[]) => void) => {
listeners.set(type, listener)
},
mainFrame: () => undefined,
goto: async () => undefined,
reload: async () => {
throw new Error("Navigation failed")
},
}
const broker = fixture(page)
await broker.open({ sessionId: "popup", directory: "/tmp/project" }, "http://localhost:3000/")
let closed = false
listeners.get("popup")!({ close: async () => void (closed = true) } as never)
expect(closed).toBe(true)
expect(broker.get("popup")).toMatchObject({ errors: 1, logs: ["Blocked browser popup"], error: undefined })
await expect(broker.refresh("popup")).rejects.toThrow("Navigation failed")
closed = false
listeners.get("popup")!({ close: async () => void (closed = true) } as never)
expect(closed).toBe(true)
expect(broker.get("popup")).toMatchObject({
status: "error",
errors: 1,
logs: ["Blocked browser popup"],
error: "Navigation failed",
})
})
test("allows only same-origin WebSockets", async () => {
let handler:
| ((socket: { url: () => string; connectToServer: () => void; close: () => Promise<void> }) => Promise<void>)
| undefined
const page = {
url: () => "http://localhost:3000/",
title: async () => "Local app",
screenshot: async () => Buffer.from("jpeg"),
on: (_type: string, _listener: (...args: never[]) => void) => undefined,
mainFrame: () => undefined,
goto: async () => undefined,
}
const browser = {
newContext: async () => ({
close: async () => undefined,
route: async () => undefined,
routeWebSocket: async (_pattern: string, next: typeof handler) => {
handler = next
},
newPage: async () => page,
}),
close: async () => undefined,
}
const broker = new BrowserBroker({ log: () => {}, launch: async () => browser })
brokers.push(broker)
await broker.open({ sessionId: "socket", directory: "/tmp/project" }, "http://localhost:3000/")
let connected = false
let closed = false
await handler!({
url: () => "ws://localhost:3000/hmr",
connectToServer: () => void (connected = true),
close: async () => void (closed = true),
})
expect(connected).toBe(true)
expect(closed).toBe(false)
expect(broker.get("socket")).toMatchObject({ errors: 0, logs: [], error: undefined })
connected = false
await handler!({
url: () => "ws://localhost:4000/private",
connectToServer: () => void (connected = true),
close: async () => void (closed = true),
})
expect(connected).toBe(false)
expect(closed).toBe(true)
expect(broker.get("socket")).toMatchObject({
errors: 1,
logs: ["Blocked browser request: ws://localhost:4000"],
error: undefined,
})
})
})
@@ -0,0 +1,250 @@
import { describe, expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createBrowserController } from "../../webview-ui/browser/controller"
import type { BrowserReference } from "../../src/shared/browser-feedback"
import type {
BrowserCommand,
BrowserEvent,
BrowserInspection,
BrowserPosition,
BrowserScope,
} from "../../webview-ui/browser/types"
const point = (x: number): BrowserPosition => ({ x, y: x, width: 100, height: 100 })
function setup(theme: "dark" | "light" = "dark") {
const sent: BrowserCommand[] = []
const references: BrowserReference[] = []
const listeners = new Set<(event: BrowserEvent) => void>()
const frames = new Map<number, FrameRequestCallback>()
const cancelled: number[] = []
let nextFrame = 0
let setScope: (value: BrowserScope | undefined) => void = () => {}
let controller!: ReturnType<typeof createBrowserController>
let dispose!: () => void
createRoot((rootDispose) => {
dispose = rootDispose
const [scope, update] = createSignal<BrowserScope | undefined>({
sessionId: "session-a",
projectId: "project-a",
})
setScope = update
controller = createBrowserController({
scope,
transport: {
send: (command) => sent.push(command),
subscribe: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
},
onReference: (reference) => references.push(reference),
onClose: () => {},
theme: () => theme,
schedule: (callback) => {
const id = ++nextFrame
frames.set(id, callback)
return id
},
cancel: (id) => {
cancelled.push(id)
frames.delete(id)
},
})
})
const emit = (event: BrowserEvent) => listeners.forEach((listener) => listener(event))
const run = () => {
const item = frames.entries().next().value as [number, FrameRequestCallback] | undefined
if (!item) throw new Error("No animation frame scheduled")
frames.delete(item[0])
item[1](0)
}
return { controller, sent, emit, frames, cancelled, setScope, dispose, run, listeners, references }
}
function inspection(requestId: string, scope: BrowserScope, error?: string): BrowserInspection {
return {
requestId,
scope,
hover: true,
error,
element: error
? undefined
: { tag: "button", selector: `button-${requestId}`, rect: { x: 0, y: 0, width: 1, height: 1 } },
logs: [],
}
}
describe("browser controller", () => {
test("coalesces pointer movement while an inspection is active", () => {
const view = setup()
view.controller.toggleSelecting()
view.controller.move(point(0.1))
view.run()
view.controller.move(point(0.2))
view.controller.move(point(0.3))
expect(view.sent.filter((item) => item.type === "inspect")).toHaveLength(1)
view.emit({
type: "inspection",
value: inspection("1", { sessionId: "session-a", projectId: "project-a" }),
})
expect(view.frames.size).toBe(1)
view.run()
expect(view.sent.filter((item) => item.type === "inspect")).toHaveLength(2)
expect(view.sent.at(-1)).toMatchObject({ type: "inspect", requestId: "2", position: point(0.3) })
view.dispose()
})
test("recovers hover scheduling after a matching failed response", () => {
const view = setup()
view.controller.toggleSelecting()
view.controller.move(point(0.1))
view.run()
view.controller.move(point(0.4))
view.emit({
type: "inspection",
value: inspection("1", { sessionId: "session-a", projectId: "project-a" }, "Inspection failed"),
})
expect(view.controller.hovered()).toBeUndefined()
expect(view.frames.size).toBe(1)
view.run()
expect(view.sent.at(-1)).toMatchObject({ type: "inspect", requestId: "2", position: point(0.4) })
view.dispose()
})
test("ignores stale request and wrong scope responses", () => {
const view = setup()
view.controller.toggleSelecting()
view.controller.move(point(0.1))
view.run()
view.emit({
type: "inspection",
value: inspection("old", { sessionId: "session-a", projectId: "project-a" }),
})
view.emit({
type: "inspection",
value: inspection("1", { sessionId: "session-b", projectId: "project-a" }),
})
view.emit({
type: "inspection",
value: inspection("1", { sessionId: "session-a", projectId: "project-b" }),
})
expect(view.controller.hovered()).toBeUndefined()
expect(view.frames.size).toBe(0)
view.emit({
type: "inspection",
value: inspection("1", { sessionId: "session-a", projectId: "project-a" }),
})
expect(view.controller.hovered()?.element?.selector).toBe("button-1")
view.dispose()
})
test("ignores events and cancels scheduled work after cleanup", () => {
const view = setup()
view.controller.toggleSelecting()
view.controller.move(point(0.1))
expect(view.frames.size).toBe(1)
view.dispose()
expect(view.cancelled).toEqual([1])
expect(view.frames.size).toBe(0)
view.emit({
type: "inspection",
value: inspection("1", { sessionId: "session-a", projectId: "project-a" }),
})
expect(view.controller.hovered()).toBeUndefined()
view.controller.state()
view.controller.toggleSelecting()
view.controller.move(point(0.2))
view.controller.setUrl("localhost:3000")
view.controller.open()
view.controller.toggleTools()
expect(view.listeners.size).toBe(0)
expect(view.frames.size).toBe(0)
expect(view.controller.selecting()).toBe(false)
expect(view.sent).toEqual([{ type: "state", scope: { sessionId: "session-a", projectId: "project-a" } }])
})
test("uses an injected theme without reading host DOM or VS Code classes", () => {
const view = setup("light")
view.controller.toggleTools()
expect(view.sent.at(-1)).toEqual({
type: "devtools",
scope: { sessionId: "session-a", projectId: "project-a" },
theme: "light",
})
view.dispose()
})
test.each(["error", "empty", "selectorless"] as const)("recovers from %s selection responses", (mode) => {
const view = setup()
const scope = { sessionId: "session-a", projectId: "project-a" }
view.controller.toggleSelecting()
view.controller.select(point(0.2))
expect(view.controller.selecting()).toBe(false)
const value = inspection("1", scope, mode === "error" ? "Navigation interrupted" : undefined)
if (mode === "empty") value.element = undefined
if (mode === "selectorless") value.element = { tag: "button", selector: "" }
view.emit({ type: "inspection", value: { ...value, hover: false } })
expect(view.controller.selecting()).toBe(true)
expect(view.references).toEqual([])
view.controller.select(point(0.3))
view.emit({ type: "inspection", value: { ...inspection("2", scope), hover: false } })
expect(view.references).toHaveLength(1)
expect(view.references[0]?.selector).toBe("button-2")
expect(view.controller.selecting()).toBe(false)
view.dispose()
})
test("does not attach stale selections after navigation or browser closure", () => {
const view = setup()
const scope = { sessionId: "session-a", projectId: "project-a" }
const state = { scope, browserId: "browser", status: "ready" as const, navigation: 1, errors: 0 }
view.emit({ type: "state", value: state })
view.controller.select(point(0.2))
view.emit({ type: "state", value: { ...state, navigation: 2, status: "loading" } })
view.emit({ type: "inspection", value: { ...inspection("1", scope), hover: false } })
expect(view.references).toEqual([])
view.controller.select(point(0.3))
view.emit({ type: "state", value: { ...state, navigation: 2, status: "closed", inspecting: true } })
view.emit({ type: "inspection", value: { ...inspection("2", scope), hover: false } })
expect(view.references).toEqual([])
expect(view.controller.pointing()).toBe(false)
expect(view.controller.selecting()).toBe(false)
view.dispose()
})
test("drops state from the previous scope before accepting the new scope", async () => {
const view = setup()
view.emit({
type: "state",
value: {
scope: { sessionId: "session-a", projectId: "project-a" },
browserId: "browser-a",
status: "ready",
errors: 0,
url: "http://localhost:3000",
},
})
expect(view.controller.state()?.browserId).toBe("browser-a")
view.setScope({ sessionId: "session-b", projectId: "project-b" })
await Promise.resolve()
expect(view.controller.state()).toBeUndefined()
view.emit({
type: "state",
value: {
scope: { sessionId: "session-a", projectId: "project-a" },
browserId: "old-browser",
status: "ready",
errors: 0,
},
})
expect(view.controller.state()).toBeUndefined()
view.dispose()
})
})
@@ -0,0 +1,155 @@
import { afterEach, describe, expect, test } from "bun:test"
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { Window } from "happy-dom"
import { capture, locate } from "../../src/services/browser-automation/browser-element"
const windows: Window[] = []
afterEach(async () => {
await Promise.all(windows.splice(0).map((window) => window.happyDOM.close()))
})
function inspect(html: string, selector: string, detail = true) {
const window = new Window({ url: "http://localhost:3000/" })
windows.push(window)
window.document.body.innerHTML = html
const node = window.document.querySelector(selector)
if (!node) throw new Error("Selected test element is missing")
Object.defineProperty(window.document, "elementFromPoint", { value: () => node })
const run = new Function(
"document",
"Element",
"CSS",
"innerWidth",
"innerHeight",
"getComputedStyle",
`return (${capture.toString()})(${JSON.stringify({ x: 0.5, y: 0.5, detail })})`,
)
const result = run(
window.document,
window.Element,
window.CSS,
window.innerWidth,
window.innerHeight,
window.getComputedStyle.bind(window),
) as ReturnType<typeof capture> | undefined
if (!result) throw new Error("Browser element capture returned no element")
return { result, node, document: window.document }
}
describe("browser element context", () => {
test("builds a unique selector and bounded ancestry for repeated buttons without ids", () => {
const selected = inspect(
'<main id="app"><section class="hero"><div class="actions"><button class="primary">Save</button><button class="primary">Cancel</button></div></section><section><button class="primary">Save</button></section></main>',
".actions button:first-child",
)
expect(selected.document.querySelectorAll(selected.result.selector)).toHaveLength(1)
expect(selected.document.querySelector(selected.result.selector)).toBe(selected.node)
expect(selected.result.hierarchy).toEqual([
"html",
"body",
"main#app",
"section.hero",
"div.actions",
"button.primary",
])
expect(selected.result.html).toBe('<button class="primary">Save</button>')
})
test("prefers stable test ids and does not trust duplicate element ids", () => {
const stable = inspect('<button data-testid="checkout">Pay</button><button>Pay</button>', "[data-testid]")
expect(stable.result.selector).toBe('button[data-testid="checkout"]')
const duplicate = inspect(
'<div><button id="duplicate">One</button><button id="duplicate">Two</button></div>',
"button:last-child",
)
expect(duplicate.result.selector).not.toBe("#duplicate")
expect(duplicate.document.querySelectorAll(duplicate.result.selector)).toHaveLength(1)
expect(duplicate.document.querySelector(duplicate.result.selector)).toBe(duplicate.node)
})
test("excludes scripts, handlers, arbitrary attributes, hidden text, and input values", () => {
const selected = inspect(
'<button id="save" onclick="secret()" data-api-key="secret-token" aria-label="Save"><span>Save</span><script>secret-code</script><input value="secret-input"><textarea>secret-textarea</textarea><span hidden>secret-hidden</span><span style="display:none">secret-css</span><span style="visibility:hidden">secret-invisible</span><span style="opacity:0">secret-transparent</span><span contenteditable>secret-editable</span></button>',
"#save",
)
expect(selected.result.text).toBe("Save")
expect(selected.result.html).toBe('<button id="save" aria-label="Save">Save</button>')
expect(JSON.stringify(selected.result)).not.toContain("secret")
const password = inspect('<input type="password" value="private-password" aria-label="Password">', "input")
expect(password.result.text).toBe("Password")
expect(JSON.stringify(password.result)).not.toContain("private-password")
})
test("provides relevant colors and keeps hover responses lightweight", () => {
const selected = inspect('<button style="color: white; background-color: rgb(22, 163, 74)">Save</button>', "button")
expect(selected.result.styles?.backgroundColor).toBe("rgb(22, 163, 74)")
expect(selected.result.html).not.toContain("style=")
const hover = inspect('<button id="save">Save</button>', "button", false)
expect(hover.result.selector).toBe("#save")
expect(hover.result).not.toHaveProperty("html")
expect(hover.result).not.toHaveProperty("hierarchy")
expect(hover.result).not.toHaveProperty("source")
})
test("bounds text and HTML instead of copying the entire document", () => {
const selected = inspect(
`<main><button class="${"a".repeat(500)}">${"Save ".repeat(500)}</button></main>`,
"button",
)
expect(selected.result.text?.length).toBeLessThanOrEqual(180)
expect(selected.result.html?.length).toBeLessThanOrEqual(800)
expect(selected.result.classes?.length).toBeLessThanOrEqual(180)
})
test("does not fabricate ambiguous selectors for deeply repeated structures", () => {
const nested = (depth: number) =>
`<section>${"<div>".repeat(depth)}<button>Save</button>${"</div>".repeat(depth)}</section>`
const selected = inspect(nested(26).repeat(2), "section:nth-of-type(2) button")
expect(selected.document.querySelectorAll(selected.result.selector)).toHaveLength(1)
expect(selected.document.querySelector(selected.result.selector)).toBe(selected.node)
expect(() => inspect(nested(150).repeat(2), "section:nth-of-type(2) button")).toThrow(
"Browser element capture returned no element",
)
})
test("bounds traversal before reading a large selected subtree", () => {
const selected = inspect(
`<main id="large">${"<span></span>".repeat(300)}<span>late-private-text</span></main>`,
"main",
)
expect(selected.result.text).toBeUndefined()
expect(selected.result.html).toBe('<main id="large"></main>')
})
test("accepts only existing source files within the owning workspace", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "kilo-browser-source-"))
try {
const project = path.join(root, "project")
await mkdir(path.join(project, "src"), { recursive: true })
await writeFile(path.join(project, "src", "Button.tsx"), "export const Button = () => {\n\n\n return null\n}")
await writeFile(path.join(root, "private.ts"), "export const privateValue = true")
await writeFile(path.join(project, ".env"), "SECRET=value")
await symlink(path.join(root, "private.ts"), path.join(project, "src", "external.ts"))
expect(await locate(project, { file: "src/Button.tsx", line: 4, column: 2 })).toEqual({
file: "src/Button.tsx",
line: 4,
column: 2,
})
expect(await locate(project, { file: "src/Button.tsx", line: 999, column: 2 })).toEqual({
file: "src/Button.tsx",
line: undefined,
column: undefined,
})
expect(await locate(project, { file: "src/missing.tsx" })).toBeUndefined()
expect(await locate(project, { file: "../private.ts" })).toBeUndefined()
expect(await locate(project, { file: "src/external.ts" })).toBeUndefined()
expect(await locate(project, { file: ".env" })).toBeUndefined()
expect(await locate(project, { file: "https://example.com/Button.tsx" })).toBeUndefined()
} finally {
await rm(root, { recursive: true, force: true })
}
})
})
@@ -0,0 +1,114 @@
import { describe, expect, it } from "bun:test"
import {
browserFeedbackData,
browserFeedbackMetadata,
formatBrowserFeedback,
mergeBrowserReferences,
partFeedback,
parseBrowserFeedback,
type BrowserReference,
} from "../../src/shared/browser-feedback"
import { formatReviewCommentsMarkdown } from "../../webview-ui/src/utils/review-comment-markdown"
const reference = (overrides: Partial<BrowserReference> = {}): BrowserReference => ({
id: "browser-1",
sessionId: "session-1",
selector: "main > button.save",
url: "https://user:secret@example.com/app?token=private#section",
title: "Settings",
hierarchy: ["main", "button.save"],
text: "Save settings",
html: '<button class="save">Save settings</button>',
styles: { color: "rgb(1, 2, 3)", backgroundColor: "white" },
source: { file: "src/settings.tsx", line: 42, column: 7 },
content: "legacy dump and bounds",
...overrides,
})
describe("browser feedback formatter", () => {
it("formats grounded fields and omits legacy content and bounds", () => {
const text = formatBrowserFeedback([reference()])
expect(text).toContain("Page: Settings")
expect(text).toContain("https://example.com/app")
expect(text).toContain("main > button.save")
expect(text).toContain("Save settings")
expect(text).toContain("src/settings.tsx:42:7")
expect(text).not.toContain("secret")
expect(text).not.toContain("token")
expect(text).not.toContain("legacy dump")
expect(text).not.toContain("Bounds")
})
it("keeps equivalent text and html from duplicating context", () => {
const text = formatBrowserFeedback([reference({ html: "Save settings" })])
expect(text.match(/Save settings/g)?.length).toBe(1)
expect(text).not.toContain("HTML:")
})
it("includes readable text only once when a safe HTML snippet already contains it", () => {
const text = formatBrowserFeedback([reference()])
expect(text.match(/Save settings/g)).toHaveLength(1)
expect(text).not.toContain("Text:")
})
it("updates repeated selections without duplicating the same page element", () => {
const first = browserFeedbackData([reference()])!.references
const merged = mergeBrowserReferences(first, reference({ id: "new-selection", text: "Updated settings" }))
expect(merged).toHaveLength(1)
expect(merged[0]?.id).toBe("new-selection")
expect(merged[0]?.text).toBe("Updated settings")
expect(merged[0]?.url).toBe("https://example.com/app")
expect(merged[0]).not.toHaveProperty("content")
})
it("rejects invalid and oversized references", () => {
expect(browserFeedbackData([])).toBeUndefined()
expect(browserFeedbackData([reference({ selector: "x".repeat(5_000) })])).toBeUndefined()
expect(browserFeedbackData(Array.from({ length: 21 }, (_, id) => reference({ id: String(id) })))).toBeUndefined()
expect(browserFeedbackData([reference({ source: { file: "../secret" } })])).toBeUndefined()
expect(browserFeedbackData([reference({ url: "file:///tmp/private" })])).toBeUndefined()
expect(browserFeedbackData([reference({ text: "x".repeat(20_001) })])).toBeUndefined()
})
})
describe("browser feedback metadata", () => {
it("round-trips metadata while ignoring legacy content", () => {
const data = browserFeedbackData([reference()])!
const prefix = formatBrowserFeedback(data.references)
expect(parseBrowserFeedback(data, `${prefix}\n\nFix the save action`)).toEqual(data)
expect(partFeedback(browserFeedbackMetadata(data), `${prefix}\n\nFix the save action`)).toEqual({
browserFeedback: data,
body: "Fix the save action",
})
})
it("rejects arbitrary text that does not match the metadata prefix", () => {
const data = browserFeedbackData([reference()])!
expect(parseBrowserFeedback(data, "unrelated text")).toBeUndefined()
})
it("coexists with local and PR review metadata", () => {
const review = {
version: 1 as const,
comments: [
{
id: "review-1",
file: "src/app.ts",
side: "additions" as const,
line: 3,
comment: "Keep this branch safe",
selectedText: "return value",
},
],
}
const browser = browserFeedbackData([reference()])!
const reviewPrefix = formatReviewCommentsMarkdown(review.comments)
const browserPrefix = formatBrowserFeedback(browser.references)
const content = `${reviewPrefix}\n\n${browserPrefix}\n\nDo both`
expect(partFeedback({ kilo: { review, browserFeedback: browser } }, content)).toEqual({
review,
browserFeedback: browser,
body: "Do both",
})
})
})
@@ -0,0 +1,152 @@
import { afterEach, describe, expect, test } from "bun:test"
import { browserMessage, handleBrowserMessage } from "../../src/agent-manager/browser-message"
import type { Host } from "../../src/agent-manager/host"
import type { ProjectContexts } from "../../src/agent-manager/project/contexts"
import type { AgentManagerInMessage, AgentManagerOutMessage } from "../../src/agent-manager/types"
import { BrowserBroker } from "../../src/services/browser-automation/browser-broker"
const brokers: BrowserBroker[] = []
afterEach(async () => {
await Promise.all(brokers.splice(0).map((broker) => broker.disposeAsync()))
})
async function fixture() {
let attempts = 0
const state = { trusted: true, enabled: true }
const page = {
url: () => "http://localhost:3000/",
title: async () => "",
screenshot: async () => Buffer.from("jpeg"),
on: () => undefined,
mainFrame: () => undefined,
goto: async () => undefined,
setViewportSize: async () => undefined,
evaluate: async () => {
if (++attempts === 1) throw new Error("Execution context was destroyed")
return { tag: "button", selector: "#save", text: "Save" }
},
}
const broker = new BrowserBroker({
log: () => {},
launch: async () => ({
newContext: async () => ({
close: async () => undefined,
route: async () => undefined,
routeWebSocket: async () => undefined,
newPage: async () => page,
}),
close: async () => undefined,
}),
})
brokers.push(broker)
const current = await broker.open(
{ projectId: "project", sessionId: "session", directory: "/fixture" },
"http://localhost:3000/",
)
const context = { id: "project", root: "/fixture", peekState: () => undefined, sessions: () => [{ id: "session" }] }
const contexts = {
resolve: (id: string) => (id === "project" ? context : undefined),
active: () => context,
} as unknown as ProjectContexts
const host = { isTrusted: () => state.trusted, browserAutomation: () => state.enabled } as unknown as Host
const send = (message: AgentManagerInMessage) =>
new Promise<AgentManagerOutMessage>((resolve) => {
expect(handleBrowserMessage(message, { host, contexts, browser: broker, post: resolve, log: () => {} })).toBe(
true,
)
})
return { send, state, broker, current }
}
const request = (id: string): AgentManagerInMessage => ({
type: "agentManager.browser.inspect",
projectId: "project",
sessionId: "session",
requestId: id,
hover: true,
x: 0.5,
y: 0.5,
width: 400,
height: 300,
})
describe("browser inspection responses", () => {
test("returns correlated failures and continues processing later hover requests", async () => {
const test = await fixture()
expect(await test.send(request("first"))).toEqual({
type: "agentManager.browserInspection",
projectId: "project",
sessionId: "session",
requestId: "first",
hover: true,
logs: [],
error: "Execution context was destroyed",
})
expect(await test.send(request("second"))).toMatchObject({
type: "agentManager.browserInspection",
requestId: "second",
hover: true,
element: { selector: "#save" },
})
})
test("correlates validation and permission failures instead of leaving the picker waiting", async () => {
const test = await fixture()
expect(
await test.send({
type: "agentManager.browser.inspect",
projectId: "project",
sessionId: "session",
requestId: "invalid",
hover: true,
}),
).toMatchObject({ type: "agentManager.browserInspection", requestId: "invalid", error: expect.any(String) })
test.state.trusted = false
expect(await test.send(request("untrusted"))).toMatchObject({
type: "agentManager.browserInspection",
requestId: "untrusted",
error: "Browser preview requires a trusted workspace.",
})
test.state.trusted = true
test.state.enabled = false
expect(await test.send(request("disabled"))).toMatchObject({
type: "agentManager.browserInspection",
requestId: "disabled",
error: expect.any(String),
})
})
test("preserves the active project's untitled preview when DevTools fail", async () => {
const test = await fixture()
await test.broker.open({ projectId: "other", sessionId: "session", directory: "/other" }, "http://localhost:3000/")
expect(await test.send({ type: "agentManager.browser.devtools", sessionId: "session" })).toEqual({
...browserMessage(test.current),
error: "Browser developer tools are unavailable for this browser session",
})
expect(test.broker.get("session", "project")?.error).toBeUndefined()
})
test("preserves an untitled preview when native picker input fails", async () => {
const test = await fixture()
test.broker.input = async () => {
throw new Error("Pointer target is unavailable")
}
expect(
await test.send({
type: "agentManager.browser.input",
projectId: "project",
sessionId: "session",
x: 0.5,
y: 0.5,
width: 400,
height: 300,
click: true,
}),
).toEqual({ ...browserMessage(test.current), error: "Pointer target is unavailable" })
expect(await test.send({ type: "agentManager.browser.input", projectId: "project", sessionId: "session" })).toEqual(
{ ...browserMessage(test.current), error: "Browser element coordinates are required." },
)
expect(test.broker.get("session", "project")?.error).toBeUndefined()
})
})
@@ -0,0 +1,48 @@
import { describe, expect, test } from "bun:test"
import { unlinkSync } from "node:fs"
import path from "node:path"
import { build } from "esbuild"
import { solidPlugin } from "esbuild-plugin-solid"
const root = path.resolve(import.meta.dir, "../..")
const webview = path.join(root, "webview-ui")
describe("reusable browser view", () => {
test("renders and emits commands without Agent Manager or VS Code providers", async () => {
const solid = path.dirname(Bun.resolveSync("solid-js/package.json", webview))
const aliases: Record<string, string> = {
"solid-js": path.join(solid, "dist/solid.js"),
"solid-js/web": path.join(solid, "web/dist/web.js"),
"solid-js/store": path.join(solid, "store/dist/store.js"),
}
const result = await build({
entryPoints: [path.join(root, "tests/fixtures/browser-panel-render.tsx")],
bundle: true,
conditions: ["browser"],
external: ["happy-dom"],
format: "esm",
platform: "node",
logLevel: "silent",
loader: { ".css": "empty" },
plugins: [
{
name: "solid-dedupe",
setup(ctx) {
ctx.onResolve({ filter: /^solid-js(\/web|\/store)?$/ }, (args) => ({ path: aliases[args.path] }))
},
},
solidPlugin(),
],
target: "es2022",
write: false,
})
const file = path.join(root, `.browser-panel-render-${crypto.randomUUID()}.mjs`)
await Bun.write(file, result.outputFiles[0]!.contents)
try {
const child = Bun.spawnSync([process.execPath, file], { cwd: webview, stdout: "pipe", stderr: "pipe" })
expect(child.exitCode, child.stdout.toString() + child.stderr.toString()).toBe(0)
} finally {
unlinkSync(file)
}
})
})
@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test"
import { options } from "../../src/services/browser-automation/browser-runtime"
describe("browser runtime isolation", () => {
test("removes proxy settings and secrets while preserving normal environment values", () => {
const env = Object.freeze({
PATH: "/usr/bin",
HOME: "/home/test",
LANG: "en_US.UTF-8",
HTTP_PROXY: "http://proxy.invalid:8080",
HTTPS_PROXY: "http://proxy.invalid:8080",
ALL_PROXY: "socks5://proxy.invalid:1080",
NO_PROXY: "",
http_proxy: "http://proxy.invalid:8080",
Https_Proxy: "http://proxy.invalid:8080",
npm_config_proxy: "http://proxy.invalid:8080",
GLOBAL_AGENT_HTTP_PROXY: "http://proxy.invalid:8080",
API_KEY: "test-key",
KILO_BROWSER_BROKER_TOKEN: "test-token",
DATABASE_PASSWORD: "test-password",
})
const config = options(true, 9222, env)
expect(config.env).toEqual({ PATH: "/usr/bin", HOME: "/home/test", LANG: "en_US.UTF-8" })
expect(env.HTTP_PROXY).toBe("http://proxy.invalid:8080")
expect(config.chromiumSandbox).toBe(true)
expect(config.headless).toBe(true)
expect(config.channel).toBe("chrome")
})
test("forces direct Chromium networking without disabling localhost bypass", () => {
const config = options(true, 9222, {})
expect(config.args).toEqual([
"--no-proxy-server",
"--remote-debugging-address=127.0.0.1",
"--remote-debugging-port=9222",
])
expect(config.args?.some((arg) => arg.includes("<-loopback>"))).toBe(false)
})
test("uses the same proxy isolation for installed Playwright Chromium", () => {
const config = options(false, undefined, { http_proxy: "http://proxy.invalid", PATH: "/bin" })
expect(config.channel).toBeUndefined()
expect(config.env).toEqual({ PATH: "/bin" })
expect(config.args).toEqual(["--no-proxy-server"])
})
})
@@ -11,28 +11,32 @@ import {
isPendingSend,
promotePendingDraftDiscard,
reviewDrafts,
browserDrafts,
savePromptDraft,
scrollDrafts,
finishPendingSend,
} from "../../webview-ui/src/utils/draft-store"
const stores = [drafts, reviewDrafts, imageDrafts, scrollDrafts]
const stores = [drafts, browserDrafts, reviewDrafts, imageDrafts, scrollDrafts]
beforeEach(() => stores.forEach((store) => store.clear()))
describe("prompt draft storage", () => {
it("stores and clears all prompt artifacts together", () => {
const browser = [{ id: "browser", sessionId: "s1", selector: "#save", content: "legacy" }]
savePromptDraft(
"prompt:default:pending:sidebar-pending:1",
"draft",
[{ id: "review", file: "a.ts", side: "additions", line: 1, comment: "comment", selectedText: "line" }],
[{ id: "image", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,a" }],
42,
browser,
)
expect(drafts.size).toBe(1)
expect(reviewDrafts.size).toBe(1)
expect(imageDrafts.size).toBe(1)
expect(browserDrafts.get("prompt:default:pending:sidebar-pending:1")).toEqual(browser)
expect(scrollDrafts.size).toBe(1)
discardPendingDraft("sidebar-pending:1")
@@ -110,7 +110,12 @@ function createConnection() {
describe("KiloProvider indexing refresh", () => {
it("shares snapshot payloads across load, SSE refresh, and post-save refresh", async () => {
const conn = createConnection()
const settings = () => ({ maxCost: 0, languageCommitMessage: "sync", multiProject: false })
const settings = () => ({
maxCost: 0,
languageCommitMessage: "sync",
multiProject: false,
browserAutomation: false,
})
const snapshot = await fetchSnapshot(conn.client as never, "/repo", settings)
const provider = new KiloProvider({} as never, conn.service as never)
const internal = provider as unknown as Internals
@@ -9,6 +9,7 @@ const state = (projectId: string): AgentManagerStateMessage => ({
sessions: [],
sections: [],
isGitRepo: true,
browserAutomation: true,
})
describe("createProjectStateHandlers", () => {
@@ -30,6 +31,10 @@ describe("createProjectStateHandlers", () => {
setPending: () => {},
rename: () => {},
font: () => {},
browser: () => {},
current: () => "session-a",
closeBrowser: () => {},
openBrowser: () => {},
})
const value = state("project-a")
@@ -38,5 +43,56 @@ describe("createProjectStateHandlers", () => {
expect(stored["project-a"]).toBe(value)
expect(applied).toEqual([value])
expect(routed).toEqual([value])
expect(value.browserAutomation).toBe(true)
})
it("opens browser previews only for the active project and selected session", () => {
let opened = 0
let closed = 0
let enabled = false
const handler = createProjectStateHandlers({
setMulti: () => {},
setProjects: () => {},
setStates: () => {},
prune: () => {},
ensure: () => ({ sections: () => [], applyState: () => {} }),
active: () => ({ sections: () => [], applyState: () => {} }),
routeCatalog: () => {},
routeState: () => {},
isActive: (project) => project === "project-a",
pending: () => false,
setPending: () => {},
rename: () => {},
font: () => {},
browser: (value) => {
enabled = value
},
current: () => "session-a",
closeBrowser: () => {
closed++
},
openBrowser: () => {
opened++
},
})
const browser = {
type: "agentManager.browserState" as const,
browserId: "browser-a",
projectId: "project-a",
sessionId: "session-a",
status: "ready" as const,
errors: 0,
}
handler.browser({ ...browser, projectId: "project-b" })
handler.browser({ ...browser, sessionId: "session-b" })
handler.browser({ ...browser, status: "closed" })
expect(opened).toBe(0)
handler.browser(browser)
expect(opened).toBe(0)
handler.browser({ ...browser, status: "loading" })
expect(opened).toBe(1)
handler.state({ ...state("project-a"), browserAutomation: false })
expect(enabled).toBe(false)
expect(closed).toBe(1)
})
})
@@ -1,6 +1,9 @@
import { beforeEach, describe, it, expect } from "bun:test"
import { createEffect, createRoot, createSignal, on } from "solid-js"
import { browserFeedbackData, formatBrowserFeedback } from "../../src/shared/browser-feedback"
import { formatReviewCommentsMarkdown } from "../../src/shared/review-comments"
import {
browserDrafts,
deleteDraftsForSession,
drafts,
imageDrafts,
@@ -9,6 +12,7 @@ import {
} from "../../webview-ui/src/utils/draft-store"
import {
createdDraftKey,
failedPrompt,
movePromptDraft,
pendingDraftKey,
scopeDraftKey,
@@ -17,16 +21,65 @@ import {
beforeEach(() => {
drafts.clear()
browserDrafts.clear()
reviewDrafts.clear()
imageDrafts.clear()
mentionDrafts.clear()
})
describe("failedPrompt", () => {
it("restores browser feedback from the failed-send wire shape", () => {
const browser = browserFeedbackData([{ id: "button", sessionId: "session", selector: "#save", text: "Save" }])!
const text = `${formatBrowserFeedback(browser.references)}\n\nMake this button red`
expect(failedPrompt({ text, browserFeedback: browser })).toEqual({
text: "Make this button red",
comments: [],
browsers: browser.references,
})
})
it("restores both review comments and browser references without raw context text", () => {
const review = {
version: 1 as const,
comments: [
{
id: "review",
file: "src/app.ts",
side: "additions" as const,
line: 3,
comment: "Keep this",
selectedText: "value",
},
],
}
const browser = browserFeedbackData([{ id: "button", sessionId: "session", selector: "#save" }])!
const text = `${formatReviewCommentsMarkdown(review.comments)}\n\n${formatBrowserFeedback(browser.references)}\n\nApply both`
expect(failedPrompt({ text, review, browserFeedback: browser })).toEqual({
text: "Apply both",
comments: review.comments,
browsers: browser.references,
})
})
it("preserves empty instructions and rejects mismatched feedback metadata", () => {
const browser = browserFeedbackData([{ id: "button", sessionId: "session", selector: "#save" }])!
expect(failedPrompt({ text: formatBrowserFeedback(browser.references), browserFeedback: browser })?.text).toBe("")
expect(failedPrompt({ text: "Unrelated text", browserFeedback: browser })).toBeUndefined()
expect(failedPrompt({ text: "Plain draft" })).toEqual({ text: "Plain draft", comments: [], browsers: [] })
})
})
describe("deleteDraftsForSession", () => {
it("clears deleted-session drafts without touching other sessions", () => {
drafts.set("prompt:default:session:a", "draft a")
drafts.set("prompt:default:pending:a", "pending a")
drafts.set("prompt:default:session:b", "draft b")
browserDrafts.set("prompt:default:session:a", [
{ id: "element", sessionId: "a", selector: "#feature-card", content: "Browser feedback" },
])
browserDrafts.set("prompt:default:session:b", [
{ id: "other", sessionId: "b", selector: "#other", content: "Other browser feedback" },
])
reviewDrafts.set("prompt:default:session:a", [])
imageDrafts.set("prompt:default:session:a", [])
mentionDrafts.set("prompt:default:session:a", { paths: ["file with spaces.ts"], sessions: [] })
@@ -36,6 +89,8 @@ describe("deleteDraftsForSession", () => {
expect(drafts.has("prompt:default:session:a")).toBe(false)
expect(drafts.has("prompt:default:pending:a")).toBe(false)
expect(drafts.get("prompt:default:session:b")).toBe("draft b")
expect(browserDrafts.has("prompt:default:session:a")).toBe(false)
expect(browserDrafts.get("prompt:default:session:b")?.[0]?.selector).toBe("#other")
expect(reviewDrafts.has("prompt:default:session:a")).toBe(false)
expect(imageDrafts.has("prompt:default:session:a")).toBe(false)
expect(mentionDrafts.has("prompt:default:session:a")).toBe(false)
@@ -168,20 +223,25 @@ describe("movePromptDraft", () => {
const comments = new Map([[source, [comment]]])
const images = new Map([[source, [image]]])
const scrolls = new Map([[source, 128]])
const browser = new Map([[source, [{ id: "browser-1", sessionId: "session-1", selector: "#save" }]]])
const expected = browser.get(source)
expect(movePromptDraft({ text, comments, images, scrolls }, source, target)).toEqual({
expect(movePromptDraft({ text, comments, images, scrolls, browsers: browser }, source, target)).toEqual({
text: "Keep this prompt",
comments: [comment],
images: [image],
scroll: 128,
browsers: expected,
})
expect(text.get(target)).toBe("Keep this prompt")
expect(comments.get(target)).toEqual([comment])
expect(images.get(target)).toEqual([image])
expect(browser.get(target)).toEqual([{ id: "browser-1", sessionId: "session-1", selector: "#save" }])
expect(scrolls.get(target)).toBe(128)
expect(text.has(source)).toBe(false)
expect(comments.has(source)).toBe(false)
expect(images.has(source)).toBe(false)
expect(browser.has(source)).toBe(false)
expect(scrolls.has(source)).toBe(false)
})
})
@@ -18,7 +18,7 @@ describe("PromptInput connection guard", () => {
const attachments = src.indexOf("const gitFile = await git.resolveAttachment")
const guard = src.indexOf("if (isDisabled()) {", attachments)
const finish = src.indexOf("finishPending(pendingId)", guard)
const send = src.indexOf("session.sendMessage(message", guard)
const send = src.indexOf("session.sendMessage(", guard)
const clear = src.indexOf("drafts.delete(key)", send)
expect(attachments).toBeGreaterThan(-1)
@@ -53,8 +53,8 @@ describe("PromptInput sandbox toggle", () => {
})
it("captures edits made while sandbox session creation is pending", () => {
const start = src.indexOf('if (message.type === "sessionCreated")')
const end = src.indexOf('if (message.type === "action"', start)
const start = src.indexOf("const created = (message:")
const end = src.indexOf("const unsubscribe", start)
const created = src.slice(start, end)
const save = created.indexOf(
"if (source === draftKey()) saveDraft(source, text(), reviewComments(), imageAttach.images())",
@@ -65,7 +65,10 @@ describe("PromptInput sandbox toggle", () => {
expect(end).toBeGreaterThan(start)
expect(save).toBeGreaterThan(-1)
expect(move).toBeGreaterThan(save)
expect(created).toContain("{ text: drafts, comments: reviewDrafts, images: imageDrafts, scrolls: scrollDrafts }")
expect(created).toContain(
"{ text: drafts, comments: reviewDrafts, images: imageDrafts, scrolls: scrollDrafts, browsers: references }",
)
expect(created).toContain("saveDraft(source, text(), reviewComments(), imageAttach.images())")
})
it("restores each prompt draft's textarea and highlight scroll positions", () => {
@@ -74,8 +77,14 @@ describe("PromptInput sandbox toggle", () => {
expect(src).toContain("textareaRef.scrollTop = scroll")
expect(src).toContain("if (highlightRef) highlightRef.scrollTop = scroll")
expect(src).toContain("scrollDrafts.set(draftKey(), textareaRef.scrollTop)")
expect(src).toContain("images: imageAttach.images(),\n scroll: textareaRef?.scrollTop")
expect(src).toContain("draft.text, draft.comments, draft.images, draft.scroll")
expect(src).toContain(
"images: imageAttach.images(),\n browsers: browsers(),\n scroll: textareaRef?.scrollTop",
)
expect(src).toContain("draft.text,")
expect(src).toContain("draft.comments,")
expect(src).toContain("draft.images,")
expect(src).toContain("draft.scroll,")
expect(src).toContain("draft.browsers")
})
it("tracks in-flight toggles per session while switching", () => {
@@ -402,8 +402,9 @@ describe("PromptInput send origin contract", () => {
})
it("passes the captured origin to message and command sends", () => {
expect(source).toMatch(/session\.sendMessage\([\s\S]*origin \?\? null\)/)
expect(source).toMatch(/session\.sendCommand\([\s\S]*origin \?\? null\)/)
expect(source).toMatch(/session\.sendMessage\([\s\S]*origin \?\? null[\s\S]*browserData[\s\S]*\)/)
const command = source.slice(source.indexOf("session.sendCommand("))
expect(command).toMatch(/origin \?\? null[\s\S]*\{[\s\S]*agent: matched\.agent/)
})
it("records sent prompts before a pending session key change can return", () => {
@@ -671,6 +672,47 @@ describe("Optimistic parts preservation and smooth status contract", () => {
})
})
describe("browser element reference contract", () => {
const source = readFile(PROMPT_FILE)
it("keeps selected browser elements as visible attachments instead of inserting them into the draft", () => {
expect(source).toContain('data-component="browser-references"')
expect(source).toMatch(/const reference = message\.browser[\s\S]*?textareaRef\?\.focus\(\)[\s\S]*?return/)
})
it("includes browser reference content only when the user sends the prompt", () => {
expect(source).toContain("browserFeedbackData(browsers())")
expect(source).toContain("formatBrowserFeedback(browserData.references)")
expect(source).toContain('const message = [review, browserText, draft].filter(Boolean).join("\\n\\n")')
expect(source).toContain("references.delete(key)")
})
it("uses the tested failed-send parser before restoring text and references", () => {
expect(source).toContain("const restored = failedPrompt(failed)")
expect(source).toContain("const draft = restored.text")
expect(source).toContain("const browser = restored.browsers")
expect(source).not.toContain("partFeedback({ review: failed.review")
})
it("restores browser attachments for the correct session and allows attachment-only sends", () => {
expect(source).toContain("setBrowsers(references.get(key) ?? [])")
expect(source).toContain("if (reference.sessionId !== sid()) return")
expect(source).toContain("mergeBrowserReferences(browsers(), reference)")
expect(source).toContain("browsers().length > 0")
})
})
describe("sent browser feedback rendering contract", () => {
const message = readFile(path.join(ROOT, "webview-ui/src/components/chat/VscodeUserMessage.tsx"))
it("renders validated browser metadata as cards and exposes only the instruction body", () => {
expect(message).toContain("partFeedback")
expect(message).toContain("BrowserReferences")
expect(message).toContain("feedback()?.body")
expect(message).not.toContain("item.content")
})
})
describe("KiloConnectionService pruneSession contract", () => {
const source = readFile(CONNECTION_SERVICE_FILE)
@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test"
import { handleSessionLifecycle } from "../../src/agent-manager/session-lifecycle"
import type { ProjectContexts } from "../../src/agent-manager/project/contexts"
import type { AgentManagerOutMessage } from "../../src/agent-manager/types"
import type { Session } from "@kilocode/sdk/v2/client"
const info: Session = {
id: "session",
slug: "test-session",
projectID: "project",
directory: "/repo",
title: "Browser session",
version: "1",
time: { created: 1, updated: 2 },
}
describe("session lifecycle merge integration", () => {
test.each(["sessionID", "info"])("preserves deletion guards and browser cleanup for %s events", (shape) => {
const sessions: Array<{ id: string }> = []
const closed: string[] = []
const posted: AgentManagerOutMessage[] = []
const context = {
id: "project",
lifecycle: "ready",
peekState: () => undefined,
sessions: () => sessions,
upsertSession: (session: { id: string }) => sessions.push(session),
removeLiveSession: (id: string) =>
sessions.splice(
sessions.findIndex((session) => session.id === id),
1,
),
invalidateSessions: () => {},
}
const deps = {
busy: new Set([info.id]),
removed: new Set<string>(),
contexts: {
byDirectory: () => context,
byLiveSession: () => context,
} as unknown as ProjectContexts,
closeBrowser: (id: string) => closed.push(id),
post: (message: AgentManagerOutMessage) => posted.push(message),
}
handleSessionLifecycle({ type: "session.created", properties: { info } }, deps)
expect(sessions).toHaveLength(1)
handleSessionLifecycle(
{ type: "session.deleted", properties: shape === "info" ? { info } : { sessionID: info.id } },
deps,
)
expect(closed).toEqual([info.id])
expect(deps.removed.has(info.id)).toBe(true)
expect(deps.busy.has(info.id)).toBe(false)
expect(sessions).toHaveLength(0)
const count = posted.length
handleSessionLifecycle({ type: "session.updated", properties: { info } }, deps)
expect(sessions).toHaveLength(0)
expect(posted).toHaveLength(count)
handleSessionLifecycle({ type: "session.created", properties: { info } }, deps)
expect(deps.removed.has(info.id)).toBe(false)
expect(sessions).toHaveLength(1)
})
})
@@ -23,9 +23,11 @@ import {
removeSessionToolPartsForMessage,
upsertSessionToolPart,
recentSessions,
optimistic,
revertPromptState,
} from "../../webview-ui/src/context/session-utils"
import type { Message, Part, ToolPart } from "../../webview-ui/src/types/messages"
import { formatBrowserFeedback } from "../../src/shared/browser-feedback"
const t = (key: string) => key
@@ -985,6 +987,24 @@ describe("sessionThroughput", () => {
})
})
describe("optimistic parts", () => {
it("preserves attachments and message ownership with unique part IDs", () => {
const file = {
mime: "image/png",
url: "data:image/png;base64,abc",
filename: "shot.png",
source: { type: "file" as const, path: "shot.png", text: { value: "@shot.png", start: 0, end: 9 } },
}
const parts = optimistic("message", "Hello", [file])
expect(parts).toMatchObject([
{ type: "text", id: expect.any(String), messageID: "message", text: "Hello" },
{ type: "file", id: expect.any(String), messageID: "message", ...file },
])
expect(parts.at(0)?.id).not.toBe(parts.at(1)?.id)
expect(optimistic("empty", "")).toEqual([])
})
})
describe("revertPromptState", () => {
const text = (value: string, synthetic = false) =>
({ type: "text", id: `t-${value}`, text: value, synthetic }) as Part
@@ -1030,6 +1050,19 @@ describe("revertPromptState", () => {
it("returns empty collections for tool-only messages", () => {
const part: Part = { type: "tool", id: "p1", tool: "bash", state: { status: "running", input: {} } }
const state = revertPromptState([part])
expect(state).toEqual({ text: "", paths: [], sessions: [], images: [] })
expect(state).toEqual({ text: "", paths: [], sessions: [], images: [], review: [], browser: [] })
})
it("restores browser and review metadata without their formatted prefixes", () => {
const browser = {
version: 1 as const,
references: [{ id: "b", sessionId: "s1", selector: "#save", text: "Save" }],
}
const content = `${formatBrowserFeedback(browser.references)}\n\nPlease update it`
const parts = optimistic("message", content, undefined, undefined, browser)
expect(revertPromptState(parts)).toMatchObject({
text: "Please update it",
browser: browser.references,
})
})
})
@@ -78,6 +78,17 @@ describe("buildCspString", () => {
expect(result).toMatch(new RegExp(`connect-src\\s+${cspSource.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`))
})
it("does not allow frames for ordinary webviews", () => {
expect(buildCspString(cspSource, nonce)).not.toContain("frame-src")
})
it("limits Agent Manager browser frames to approved loopback origins", () => {
const result = buildCspString(cspSource, nonce, undefined, "http://localhost:* http://127.0.0.1:*")
expect(result).toContain("frame-src http://localhost:* http://127.0.0.1:*")
expect(result).not.toContain("frame-src *")
expect(result).not.toContain("frame-src https:")
})
it("joins directives with semicolons", () => {
const result = buildCspString(cspSource, nonce)
const parts = result.split(";")
@@ -0,0 +1,61 @@
import { describe, expect, test } from "bun:test"
import { Window } from "happy-dom"
import { post, protect, trusted } from "../../webview-ui/src/utils/webview-message"
describe("webview message trust", () => {
const origin = "vscode-webview://trusted-view"
test("accepts browser-generated extension and same-window messages from the exact webview origin", () => {
expect(trusted({ origin, isTrusted: true }, origin)).toBe(true)
expect(trusted({ origin: "https://webview.example", isTrusted: true }, "https://webview.example")).toBe(true)
})
test("rejects preview, DevTools, forged, and opaque-origin messages", () => {
for (const value of ["http://localhost:3000", "http://127.0.0.1:3000", `${origin}.invalid`, "", "null"]) {
expect(trusted({ origin: value, isTrusted: true }, origin)).toBe(false)
}
expect(trusted({ origin, isTrusted: false }, origin)).toBe(false)
expect(trusted({ origin: "", isTrusted: false }, origin)).toBe(false)
expect(trusted({ origin: "null", isTrusted: true }, "null")).toBe(false)
})
test("blocks all application listeners before dispatch and removes its capture guard on cleanup", async () => {
const window = new Window({ url: "https://webview.example" })
Object.defineProperty(window, "origin", { value: window.location.origin })
const target = window as unknown as globalThis.Window
let received = 0
window.addEventListener("message", () => received++)
const release = protect(target)
window.addEventListener("message", () => received++)
const event = () =>
new window.MessageEvent("message", {
origin: "http://localhost:3000",
data: { type: "action", action: "runScript" },
})
window.dispatchEvent(event())
expect(received).toBe(0)
release()
window.dispatchEvent(event())
expect(received).toBe(2)
await window.happyDOM.close()
})
test("keeps internal messages synchronous and authorizes only their local dispatch", () => {
const sent: MessageEvent[] = []
const target = {
origin,
dispatchEvent: (event: Event) => {
const message = event as MessageEvent
sent.push(message)
expect(trusted(message, origin)).toBe(true)
return true
},
}
const message = { type: "appendChatBoxMessage", text: "Selected element" }
post(message, target)
expect(sent).toHaveLength(1)
expect(sent[0]?.data).toEqual(message)
expect(sent[0]?.origin).toBe(origin)
expect(trusted(sent[0]!, origin)).toBe(false)
})
})
@@ -209,6 +209,7 @@ import { tracker } from "./telemetry"
import { createChatFocus, createFocusBridge, createPromptFocus, forgetTerminalFocus, hasQuestionOption } from "./focus"
import { usePendingCreate } from "./pending-create"
import { defaultBase as projectDefaultBase } from "./project/default-base"
import { createBrowserPanel } from "./BrowserPanel"
import "./agent-manager.css"
import "./agent-manager-review.css"
import { cycleAgent as cycle } from "../src/context/session-agent"
@@ -257,6 +258,7 @@ const AgentManagerContent: Component = () => {
const [repoDetectedBranch, setRepoDetectedBranch] = createSignal<string | undefined>()
const [projectList, setProjectList] = createSignal<AgentProjectSnapshot[]>([])
const [multiProject, setMultiProject] = createSignal(false)
const [currentProjectId, setCurrentProjectId] = createSignal<string | undefined>()
const [projectStates, setProjectStates] = createSignal<Record<string, AgentManagerStateMessage>>({})
const activeProjectId = () => projectList().find((p) => p.active)?.id ?? currentProjectId()
@@ -351,6 +353,7 @@ const AgentManagerContent: Component = () => {
const reviewCommentsByContext = reviewState.comments
const setReviewCommentsByContext = reviewState.setComments
const [reviewActive, setReviewActive] = createSignal(false)
const browser = createBrowserPanel(sidePanel, setSidePanel, setHistory, setReviewActive)
const diffStyle = useDiffStyle()!
const setSharedDiffStyle = (style: "unified" | "split") => {
if (diffStyle.style() === style) return
@@ -1088,9 +1091,9 @@ const AgentManagerContent: Component = () => {
setPending: (value) => (pendingNewSection = value),
rename: setRenamingSection,
font: (font) => font && setTerminalFont(font),
...browser.bind(session.currentSessionID),
})
const preserveSidebarScroll = createSidebarScrollPreserver(() => selection() ?? session.currentSessionID())
/** Apply the active-transition effects of a state payload (data already landed in the store). */
const applyActiveState = (state: AgentManagerStateMessage) => {
const switched = applyProjectSwitch(state)
if (state.isGitRepo !== undefined) setIsGitRepo(state.isGitRepo)
@@ -1462,11 +1465,10 @@ const AgentManagerContent: Component = () => {
}
if (msg.type === "agentManager.focusContextRequested") focusCtl.report()
if (msg.type === "agentManager.state" && msg.isGitRepo === false && !sessionsLoaded()) setSessionsLoaded(true)
if (msg.type === "agentManager.state") preserveSidebarScroll(() => stateHandlers.state(msg))
stateHandlers.browser(msg)
// 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)
@@ -1477,8 +1479,6 @@ const AgentManagerContent: Component = () => {
}
}
// When state updates arrive, mark new grouped worktrees as loading
// (they were just created and haven't received their prompt yet)
if (msg.type === "agentManager.worktreeSetup") {
const ev = msg as AgentManagerWorktreeSetupMessage
if (ev.status === "ready" && ev.sessionId) {
@@ -2394,6 +2394,7 @@ const AgentManagerContent: Component = () => {
diffOpen={diffOpen}
reviewActive={reviewActive}
onToggleDiff={toggleDiffPanel}
{...browser.tabs}
onToggleReview={metrics.click("fullscreen_review", "tab_toolbar", toggleReviewTab)}
prStatus={() => activePR()?.pr}
prOpen={prOpen}
@@ -2648,6 +2649,7 @@ const AgentManagerContent: Component = () => {
onClose={() => setSidePanel(null)}
/>
</Show>
{browser.render(session.currentSessionID, activeProjectId)}
<Show when={subagents.tabs().length > 0}>
<SubagentPanel
tabs={subagents.tabs}
@@ -2742,20 +2744,18 @@ const AgentManagerContent: Component = () => {
</div>
)
}
export const AgentManagerApp: Component = () => {
return (
<ProviderShell.Root>
<ProviderShell.Session>
<ProviderShell.Chat>
<WorktreeModeProvider>
<DiffStyleProvider>
<DataBridge>
<AgentManagerContent />
</DataBridge>
</DiffStyleProvider>
</WorktreeModeProvider>
</ProviderShell.Chat>
</ProviderShell.Session>
</ProviderShell.Root>
)
}
export const AgentManagerApp: Component = () => (
<ProviderShell.Root>
<ProviderShell.Session>
<ProviderShell.Chat>
<WorktreeModeProvider>
<DiffStyleProvider>
<DataBridge>
<AgentManagerContent />
</DataBridge>
</DiffStyleProvider>
</WorktreeModeProvider>
</ProviderShell.Chat>
</ProviderShell.Session>
</ProviderShell.Root>
)
@@ -0,0 +1,173 @@
import { createMemo, createSignal, Show, type Accessor, type Setter } from "solid-js"
import { useLanguage } from "../src/context/language"
import { useVSCode } from "../src/context/vscode"
import type { ExtensionMessage, WebviewMessage } from "../src/types/messages"
import { formatBrowserFeedback, type BrowserReference } from "../../src/shared/browser-feedback"
import { BrowserPanel as BrowserPanelView } from "../browser"
import type {
BrowserCommand,
BrowserEvent,
BrowserInspection,
BrowserScope,
BrowserState,
BrowserTransport,
} from "../browser"
import { SidePanel } from "./side-panel-layout"
import { post } from "../src/utils/webview-message"
export function createBrowserPanel(
current: Accessor<SidePanel | null>,
panel: Setter<SidePanel | null>,
history: Setter<boolean>,
review: Setter<boolean>,
) {
const [enabled, configure] = createSignal(
(globalThis as typeof globalThis & { KILO_BROWSER_AUTOMATION?: boolean }).KILO_BROWSER_AUTOMATION === true,
)
const visible = () => current() === SidePanel.Browser
const close = () => panel(null)
const open = () => {
history(false)
review(false)
panel(SidePanel.Browser)
}
const toggle = () => {
if (!enabled()) return
if (visible()) return close()
open()
}
return {
tabs: { browserOpen: visible, browserAutomation: enabled, onToggleBrowser: toggle },
bind: (current: Accessor<string | undefined>) => ({
browser: configure,
current,
closeBrowser: close,
openBrowser: open,
}),
render: (session: Accessor<string | undefined>, project: Accessor<string | undefined>) => (
<Show when={enabled() && visible()}>
<BrowserAdapter sessionId={session} projectId={project} onClose={close} />
</Show>
),
}
}
function scope(sessionId: string, projectId?: string): BrowserScope {
return { sessionId, projectId }
}
function command(command: BrowserCommand): WebviewMessage {
if (command.type === "open") {
return { type: "agentManager.browser.open", ...command.scope, url: command.url }
}
if (command.type === "refresh") return { type: "agentManager.browser.refresh", ...command.scope }
if (command.type === "close") return { type: "agentManager.browser.close", ...command.scope }
if (command.type === "state") return { type: "agentManager.browser.state", ...command.scope }
if (command.type === "devtools") {
return { type: "agentManager.browser.devtools", ...command.scope, theme: command.theme }
}
if (command.type === "input") {
return { type: "agentManager.browser.input", ...command.scope, ...command.position, click: command.click }
}
return {
type: "agentManager.browser.inspect",
...command.scope,
...command.position,
hover: command.hover,
requestId: command.requestId,
}
}
function event(message: ExtensionMessage): BrowserEvent | undefined {
if (message.type === "agentManager.browserState") {
const value: BrowserState = {
scope: scope(message.sessionId, message.projectId),
browserId: message.browserId,
navigation: message.navigation,
status: message.status,
inspecting: message.inspecting,
url: message.url,
title: message.title,
errors: message.errors,
logs: message.logs,
error: message.error,
frameError: message.frameError,
}
return { type: "state", value }
}
if (message.type === "agentManager.browserInspection") {
const value: BrowserInspection = {
scope: scope(message.sessionId, message.projectId),
requestId: message.requestId,
url: message.url,
title: message.title,
element: message.element,
logs: message.logs,
hover: message.hover,
error: message.error,
}
return { type: "inspection", value }
}
if (message.type !== "agentManager.browserDevtools") return
return {
type: "devtools",
value: {
scope: scope(message.sessionId, message.projectId),
browserId: message.browserId,
url: message.url,
},
}
}
function BrowserAdapter(props: {
sessionId: Accessor<string | undefined>
projectId: Accessor<string | undefined>
onClose: () => void
}) {
const language = useLanguage()
const vscode = useVSCode()
const transport: BrowserTransport = {
send: (value) => vscode.postMessage(command(value)),
subscribe: (listener) =>
vscode.onMessage((message) => {
const value = event(message)
if (value) listener(value)
}),
}
const labels = createMemo(() => ({
title: language.t("agentManager.browser.title"),
url: language.t("agentManager.browser.url"),
urlPlaceholder: language.t("agentManager.browser.urlPlaceholder"),
open: language.t("agentManager.browser.open"),
refresh: language.t("agentManager.browser.refresh"),
close: language.t("agentManager.browser.close"),
inspect: language.t("agentManager.browser.inspect"),
devtoolsTitle: language.t("agentManager.browser.devtoolsTitle"),
diagnostics: language.t("agentManager.browser.diagnostics"),
diagnosticsHint: language.t("agentManager.browser.diagnosticsHint"),
empty: language.t("agentManager.browser.empty"),
noSession: language.t("agentManager.browser.noSession"),
screenshotAlt: language.t("agentManager.browser.screenshotAlt"),
errors: (count: number) => language.t("agentManager.browser.errors", { count }),
}))
const reference = (value: BrowserReference) => {
post({ type: "appendChatBoxMessage", text: formatBrowserFeedback([value]), browser: value })
}
const theme = () =>
document.body.classList.contains("vscode-light") || document.body.classList.contains("vscode-high-contrast-light")
? "light"
: "dark"
return (
<BrowserPanelView
scope={() => {
const session = props.sessionId()
return session ? scope(session, props.projectId()) : undefined
}}
transport={transport}
labels={labels()}
theme={theme}
onReference={reference}
onClose={props.onClose}
/>
)
}
@@ -52,6 +52,9 @@ export interface TabBarProps {
onRun: (id: string) => void
onConfigureRun: () => void
diffOpen: () => boolean
browserOpen: () => boolean
browserAutomation: () => boolean
onToggleBrowser: () => void
reviewActive: () => boolean
onToggleDiff: () => void
onToggleReview: () => void
@@ -275,6 +278,18 @@ export const TabBar: Component<TabBarProps> = (props) => (
</Show>
</button>
</TooltipKeybind>
<Show when={props.browserAutomation()}>
<Tooltip value={props.t("agentManager.browser.title")} placement="bottom">
<IconButton
icon="globe"
size="small"
variant="ghost"
aria-label={props.t("agentManager.browser.title")}
class={props.browserOpen() ? "am-tab-diff-btn-active" : ""}
onClick={props.onToggleBrowser}
/>
</Tooltip>
</Show>
</>
)
})()}
+15
View File
@@ -242,6 +242,21 @@ export const dict = {
"agentManager.import.noBranchesFound": "لم يتم العثور على فروع.",
"agentManager.import.noBranchesHint": "الصق رابط PR أعلاه أو أنشئ Worktree جديدًا.",
"agentManager.import.failed": "فشل الاستيراد",
"agentManager.browser.title": "المتصفح",
"agentManager.browser.url": "URL التطبيق المحلي",
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
"agentManager.browser.open": "فتح",
"agentManager.browser.refresh": "تحديث المتصفح",
"agentManager.browser.close": "إغلاق المتصفح",
"agentManager.browser.inspect": "تحديد عنصر",
"agentManager.browser.devtoolsTitle": "أدوات المطوّرين",
"agentManager.browser.empty": "افتح تطبيقًا محليًا لمعاينته هنا.",
"agentManager.browser.noSession": "اختر جلسة Agent Manager أولًا.",
"agentManager.browser.screenshotAlt": "صفحة المتصفح الحالية",
"agentManager.browser.errors": "مشكلات المتصفح: {{count}}",
"agentManager.browser.diagnostics": "تشخيص المتصفح",
"agentManager.browser.diagnosticsHint":
"الأحداث الأخيرة من متصفح الأتمتة. عمليات الحظر لأسباب أمنية ليست أخطاء في وحدة التحكم.",
"agentManager.pr.error.gh_auth.title": "مصادقة GitHub مطلوبة",
"agentManager.pr.error.gh_auth.description": "شغّل 'gh auth login' في الطرفية لاستعادة حالة PR.",
+15
View File
@@ -247,6 +247,21 @@ export const dict = {
"agentManager.import.noBranchesFound": "Nenhum branch encontrado.",
"agentManager.import.noBranchesHint": "Cole uma URL de PR acima ou crie um novo Worktree.",
"agentManager.import.failed": "Falha na importação",
"agentManager.browser.title": "Navegador",
"agentManager.browser.url": "URL da aplicação local",
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
"agentManager.browser.open": "Abrir",
"agentManager.browser.inspect": "Selecionar elemento",
"agentManager.browser.devtoolsTitle": "Ferramentas de desenvolvedor",
"agentManager.browser.refresh": "Atualizar navegador",
"agentManager.browser.close": "Fechar navegador",
"agentManager.browser.empty": "Abra uma aplicação local para visualizá-la aqui.",
"agentManager.browser.noSession": "Selecione primeiro uma sessão do Agent Manager.",
"agentManager.browser.screenshotAlt": "Página atual do navegador",
"agentManager.browser.errors": "Problemas do navegador: {{count}}",
"agentManager.browser.diagnostics": "Diagnóstico do navegador",
"agentManager.browser.diagnosticsHint":
"Eventos recentes do navegador de automação. Bloqueios de segurança não são erros do console.",
"agentManager.pr.error.gh_auth.title": "Autenticação do GitHub necessária",
"agentManager.pr.error.gh_auth.description": "Execute 'gh auth login' no terminal para restaurar o status do PR.",
+15
View File
@@ -245,6 +245,21 @@ export const dict = {
"agentManager.import.noBranchesFound": "Nisu pronađeni branchevi.",
"agentManager.import.noBranchesHint": "Zalijepite PR URL iznad ili kreirajte novi Worktree.",
"agentManager.import.failed": "Uvoz neuspješan",
"agentManager.browser.title": "Preglednik",
"agentManager.browser.url": "URL lokalne aplikacije",
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
"agentManager.browser.open": "Otvori",
"agentManager.browser.inspect": "Odaberite element",
"agentManager.browser.devtoolsTitle": "Razvojni alati",
"agentManager.browser.refresh": "Osvježi preglednik",
"agentManager.browser.close": "Zatvori preglednik",
"agentManager.browser.empty": "Otvorite lokalnu aplikaciju da biste je ovdje pregledali.",
"agentManager.browser.noSession": "Najprije odaberite sesiju aplikacije Agent Manager.",
"agentManager.browser.screenshotAlt": "Trenutna stranica preglednika",
"agentManager.browser.errors": "Problemi preglednika: {{count}}",
"agentManager.browser.diagnostics": "Dijagnostika preglednika",
"agentManager.browser.diagnosticsHint":
"Nedavni događaji iz preglednika za automatizaciju. Sigurnosne blokade nisu greške konzole.",
"agentManager.pr.error.gh_auth.title": "Potrebna GitHub autentikacija",
"agentManager.pr.error.gh_auth.description": "Pokrenite 'gh auth login' u terminalu da vratite status PR-a.",
+15
View File
@@ -247,6 +247,21 @@ export const dict = {
"agentManager.import.noBranchesFound": "Ingen branches fundet.",
"agentManager.import.noBranchesHint": "Indsæt en PR URL ovenfor eller opret et nyt Worktree.",
"agentManager.import.failed": "Import mislykkedes",
"agentManager.browser.title": "Browser",
"agentManager.browser.url": "URL til lokal applikation",
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
"agentManager.browser.open": "Åbn",
"agentManager.browser.inspect": "Vælg element",
"agentManager.browser.devtoolsTitle": "Udviklerværktøjer",
"agentManager.browser.refresh": "Opdater browser",
"agentManager.browser.close": "Luk browser",
"agentManager.browser.empty": "Åbn en lokal applikation for at få vist en forhåndsvisning her.",
"agentManager.browser.noSession": "Vælg først en session i Agent Manager.",
"agentManager.browser.screenshotAlt": "Aktuel browserside",
"agentManager.browser.errors": "Browserproblemer: {{count}}",
"agentManager.browser.diagnostics": "Browserdiagnostik",
"agentManager.browser.diagnosticsHint":
"Seneste hændelser fra automatiseringsbrowseren. Sikkerhedsblokeringer er ikke konsolfejl.",
"agentManager.pr.error.gh_auth.title": "GitHub-godkendelse påkrævet",
"agentManager.pr.error.gh_auth.description": "Kør 'gh auth login' i din terminal for at gendanne PR-status.",
@@ -249,6 +249,21 @@ export const dict = {
"agentManager.import.noBranchesFound": "Keine Branches gefunden.",
"agentManager.import.noBranchesHint": "Fügen Sie oben eine PR-URL ein oder erstellen Sie einen neuen Worktree.",
"agentManager.import.failed": "Import fehlgeschlagen",
"agentManager.browser.title": "Browser",
"agentManager.browser.url": "URL der lokalen Anwendung",
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
"agentManager.browser.open": "Öffnen",
"agentManager.browser.inspect": "Element auswählen",
"agentManager.browser.devtoolsTitle": "Entwicklertools",
"agentManager.browser.refresh": "Browser aktualisieren",
"agentManager.browser.close": "Browser schließen",
"agentManager.browser.empty": "Öffnen Sie eine lokale Anwendung, um sie hier in der Vorschau anzuzeigen.",
"agentManager.browser.noSession": "Wählen Sie zuerst eine Sitzung im Agent Manager aus.",
"agentManager.browser.screenshotAlt": "Aktuelle Browserseite",
"agentManager.browser.errors": "Browserprobleme: {{count}}",
"agentManager.browser.diagnostics": "Browserdiagnose",
"agentManager.browser.diagnosticsHint":
"Aktuelle Ereignisse aus dem Automatisierungsbrowser. Sicherheitsblockierungen sind keine Konsolenfehler.",
"agentManager.pr.error.gh_auth.title": "GitHub-Authentifizierung erforderlich",
"agentManager.pr.error.gh_auth.description":
@@ -236,6 +236,21 @@ export const dict = {
"agentManager.documents.preview": "Preview document",
"agentManager.documents.source": "Show source",
"agentManager.documents.comments": "{{count}} comments",
"agentManager.browser.title": "Browser",
"agentManager.browser.url": "Local application URL",
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
"agentManager.browser.open": "Open",
"agentManager.browser.inspect": "Select element",
"agentManager.browser.devtoolsTitle": "Developer tools",
"agentManager.browser.refresh": "Refresh browser",
"agentManager.browser.close": "Close browser",
"agentManager.browser.empty": "Open a local application to preview it here.",
"agentManager.browser.noSession": "Select an Agent Manager session first.",
"agentManager.browser.screenshotAlt": "Current browser page",
"agentManager.browser.errors": "Browser issues: {{count}}",
"agentManager.browser.diagnostics": "Browser diagnostics",
"agentManager.browser.diagnosticsHint":
"Recent events from the automation browser. Security blocks are not console errors.",
"agentManager.import.pullRequest": "Pull Request",
"agentManager.import.pastePrUrl": "Paste PR URL...",
+15
View File
@@ -248,6 +248,21 @@ export const dict = {
"agentManager.import.noBranchesFound": "No se encontraron branches.",
"agentManager.import.noBranchesHint": "Pega una URL de PR arriba o crea un nuevo Worktree.",
"agentManager.import.failed": "Error en la importación",
"agentManager.browser.title": "Navegador",
"agentManager.browser.url": "URL de la aplicación local",
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
"agentManager.browser.open": "Abrir",
"agentManager.browser.inspect": "Seleccionar elemento",
"agentManager.browser.devtoolsTitle": "Herramientas de desarrollo",
"agentManager.browser.refresh": "Actualizar navegador",
"agentManager.browser.close": "Cerrar navegador",
"agentManager.browser.empty": "Abre una aplicación local para previsualizarla aquí.",
"agentManager.browser.noSession": "Selecciona primero una sesión de Agent Manager.",
"agentManager.browser.screenshotAlt": "Página actual del navegador",
"agentManager.browser.errors": "Problemas del navegador: {{count}}",
"agentManager.browser.diagnostics": "Diagnóstico del navegador",
"agentManager.browser.diagnosticsHint":
"Eventos recientes del navegador de automatización. Los bloqueos de seguridad no son errores de consola.",
"agentManager.pr.error.gh_auth.title": "Se requiere autenticación de GitHub",
"agentManager.pr.error.gh_auth.description":
+14
View File
@@ -250,6 +250,20 @@ export const dict = {
"agentManager.import.noBranchesFound": "هیچ شاخه‌ای یافت نشد.",
"agentManager.import.noBranchesHint": "یک URL درخواست ادغام را در بالا جای‌گذاری کنید یا یک worktree جدید بسازید.",
"agentManager.import.failed": "وارد کردن ناموفق بود",
"agentManager.browser.title": "مرورگر",
"agentManager.browser.url": "URL برنامه محلی",
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
"agentManager.browser.open": "باز کردن",
"agentManager.browser.refresh": "بازخوانی مرورگر",
"agentManager.browser.close": "بستن مرورگر",
"agentManager.browser.inspect": "انتخاب عنصر",
"agentManager.browser.devtoolsTitle": "ابزارهای توسعه",
"agentManager.browser.empty": "برای پیش‌نمایش، یک برنامه محلی را باز کنید.",
"agentManager.browser.noSession": "ابتدا یک جلسه Agent Manager را انتخاب کنید.",
"agentManager.browser.screenshotAlt": "صفحه فعلی مرورگر",
"agentManager.browser.errors": "مشکلات مرورگر: {{count}}",
"agentManager.browser.diagnostics": "عیب‌یابی مرورگر",
"agentManager.browser.diagnosticsHint": "رویدادهای اخیر مرورگر خودکار. مسدودسازی‌های امنیتی خطاهای کنسول نیستند.",
"agentManager.pr.error.gh_auth.title": "احراز هویت GitHub لازم است",
"agentManager.pr.error.gh_auth.description": "برای بازیابی وضعیت PR، دستور 'gh auth login' را در ترمینال اجرا کنید.",
+15
View File
@@ -249,6 +249,21 @@ export const dict = {
"agentManager.import.noBranchesFound": "Aucune branche trouvée.",
"agentManager.import.noBranchesHint": "Collez une URL de PR ci-dessus ou créez un nouveau Worktree.",
"agentManager.import.failed": "Échec de l'importation",
"agentManager.browser.title": "Navigateur",
"agentManager.browser.url": "URL de l'application locale",
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
"agentManager.browser.open": "Ouvrir",
"agentManager.browser.inspect": "Sélectionner l'élément",
"agentManager.browser.devtoolsTitle": "Outils de développement",
"agentManager.browser.refresh": "Actualiser le navigateur",
"agentManager.browser.close": "Fermer le navigateur",
"agentManager.browser.empty": "Ouvrez une application locale pour l'afficher ici en aperçu.",
"agentManager.browser.noSession": "Sélectionnez d'abord une session dans Agent Manager.",
"agentManager.browser.screenshotAlt": "Page actuelle du navigateur",
"agentManager.browser.errors": "Problèmes du navigateur : {{count}}",
"agentManager.browser.diagnostics": "Diagnostics du navigateur",
"agentManager.browser.diagnosticsHint":
"Événements récents du navigateur d'automatisation. Les blocages de sécurité ne sont pas des erreurs de console.",
"agentManager.pr.error.gh_auth.title": "Authentification GitHub requise",
"agentManager.pr.error.gh_auth.description":
+15
View File
@@ -255,6 +255,21 @@ export const dict = {
"agentManager.import.noBranchesFound": "Nessun branch trovato.",
"agentManager.import.noBranchesHint": "Incolla un URL PR sopra o crea un nuovo worktree.",
"agentManager.import.failed": "Importazione non riuscita",
"agentManager.browser.title": "Browser",
"agentManager.browser.url": "URL dell'applicazione locale",
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
"agentManager.browser.open": "Apri",
"agentManager.browser.inspect": "Seleziona elemento",
"agentManager.browser.devtoolsTitle": "Strumenti per sviluppatori",
"agentManager.browser.refresh": "Aggiorna browser",
"agentManager.browser.close": "Chiudi browser",
"agentManager.browser.empty": "Apri un'applicazione locale per visualizzarla qui.",
"agentManager.browser.noSession": "Seleziona prima una sessione di Agent Manager.",
"agentManager.browser.screenshotAlt": "Pagina corrente del browser",
"agentManager.browser.errors": "Problemi del browser: {{count}}",
"agentManager.browser.diagnostics": "Diagnostica del browser",
"agentManager.browser.diagnosticsHint":
"Eventi recenti del browser di automazione. I blocchi di sicurezza non sono errori della console.",
"agentManager.pr.error.gh_auth.title": "Autenticazione GitHub richiesta",
"agentManager.pr.error.gh_auth.description":
+15
View File
@@ -246,6 +246,21 @@ export const dict = {
"agentManager.import.noBranchesFound": "ブランチが見つかりません。",
"agentManager.import.noBranchesHint": "上にPR URLを貼り付けるか、新しいWorktreeを作成してください。",
"agentManager.import.failed": "インポートに失敗しました",
"agentManager.browser.title": "ブラウザー",
"agentManager.browser.url": "ローカルアプリケーション URL",
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
"agentManager.browser.open": "開く",
"agentManager.browser.refresh": "ブラウザーを更新",
"agentManager.browser.close": "ブラウザーを閉じる",
"agentManager.browser.inspect": "要素を選択",
"agentManager.browser.devtoolsTitle": "開発者ツール",
"agentManager.browser.empty": "ローカルアプリケーションを開くと、ここでプレビューできます。",
"agentManager.browser.noSession": "先に Agent Manager セッションを選択してください。",
"agentManager.browser.screenshotAlt": "現在のブラウザーページ",
"agentManager.browser.errors": "ブラウザーの問題: {{count}} 件",
"agentManager.browser.diagnostics": "ブラウザー診断",
"agentManager.browser.diagnosticsHint":
"自動操作用ブラウザーの最近のイベントです。セキュリティによるブロックはコンソールエラーではありません。",
"agentManager.pr.error.gh_auth.title": "GitHub認証が必要です",
"agentManager.pr.error.gh_auth.description":
+14
View File
@@ -244,6 +244,20 @@ export const dict = {
"agentManager.import.noBranchesFound": "브랜치를 찾을 수 없습니다.",
"agentManager.import.noBranchesHint": "위에 PR URL을 붙여넣거나 새 Worktree를 만드세요.",
"agentManager.import.failed": "가져오기 실패",
"agentManager.browser.title": "브라우저",
"agentManager.browser.url": "로컬 애플리케이션 URL",
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
"agentManager.browser.open": "열기",
"agentManager.browser.refresh": "브라우저 새로 고침",
"agentManager.browser.close": "브라우저 닫기",
"agentManager.browser.inspect": "요소 선택",
"agentManager.browser.devtoolsTitle": "개발자 도구",
"agentManager.browser.empty": "로컬 애플리케이션을 열어 여기에서 미리 보세요.",
"agentManager.browser.noSession": "먼저 Agent Manager 세션을 선택하세요.",
"agentManager.browser.screenshotAlt": "현재 브라우저 페이지",
"agentManager.browser.errors": "브라우저 문제: {{count}}개",
"agentManager.browser.diagnostics": "브라우저 진단",
"agentManager.browser.diagnosticsHint": "자동화 브라우저의 최근 이벤트입니다. 보안 차단은 콘솔 오류가 아닙니다.",
"agentManager.pr.error.gh_auth.title": "GitHub 인증 필요",
"agentManager.pr.error.gh_auth.description": "PR 상태를 복원하려면 터미널에서 'gh auth login'을 실행하세요.",
+15
View File
@@ -252,6 +252,21 @@ export const dict = {
"agentManager.import.noBranchesFound": "Geen branches gevonden.",
"agentManager.import.noBranchesHint": "Plak hierboven een PR URL of maak een nieuwe worktree.",
"agentManager.import.failed": "Importeren mislukt",
"agentManager.browser.title": "Browser",
"agentManager.browser.url": "URL van lokale applicatie",
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
"agentManager.browser.open": "Openen",
"agentManager.browser.inspect": "Element selecteren",
"agentManager.browser.devtoolsTitle": "Ontwikkeltools",
"agentManager.browser.refresh": "Browser vernieuwen",
"agentManager.browser.close": "Browser sluiten",
"agentManager.browser.empty": "Open een lokale applicatie om deze hier te bekijken.",
"agentManager.browser.noSession": "Selecteer eerst een sessie in Agent Manager.",
"agentManager.browser.screenshotAlt": "Huidige browserpagina",
"agentManager.browser.errors": "Browserproblemen: {{count}}",
"agentManager.browser.diagnostics": "Browserdiagnostiek",
"agentManager.browser.diagnosticsHint":
"Recente gebeurtenissen van de automatiseringsbrowser. Beveiligingsblokkeringen zijn geen consolefouten.",
"agentManager.pr.error.gh_auth.title": "GitHub-authenticatie vereist",
"agentManager.pr.error.gh_auth.description": "Voer 'gh auth login' uit in je terminal om de PR-status te herstellen.",
+15
View File
@@ -244,6 +244,21 @@ export const dict = {
"agentManager.import.noBranchesFound": "Ingen branches funnet.",
"agentManager.import.noBranchesHint": "Lim inn en PR URL ovenfor eller opprett et nytt Worktree.",
"agentManager.import.failed": "Import mislyktes",
"agentManager.browser.title": "Nettleser",
"agentManager.browser.url": "URL for lokal applikasjon",
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
"agentManager.browser.open": "Åpne",
"agentManager.browser.inspect": "Velg element",
"agentManager.browser.devtoolsTitle": "Utviklerverktøy",
"agentManager.browser.refresh": "Oppdater nettleser",
"agentManager.browser.close": "Lukk nettleser",
"agentManager.browser.empty": "Åpne en lokal applikasjon for å forhåndsvise den her.",
"agentManager.browser.noSession": "Velg en økt i Agent Manager først.",
"agentManager.browser.screenshotAlt": "Gjeldende nettleserside",
"agentManager.browser.errors": "Nettleserproblemer: {{count}}",
"agentManager.browser.diagnostics": "Nettleserdiagnostikk",
"agentManager.browser.diagnosticsHint":
"Nylige hendelser fra automatiseringsnettleseren. Sikkerhetsblokkeringer er ikke konsollfeil.",
"agentManager.pr.error.gh_auth.title": "GitHub-autentisering kreves",
"agentManager.pr.error.gh_auth.description": "Kjør 'gh auth login' i terminalen for å gjenopprette PR-status.",
+15
View File
@@ -245,6 +245,21 @@ export const dict = {
"agentManager.import.noBranchesFound": "Nie znaleziono branchy.",
"agentManager.import.noBranchesHint": "Wklej URL PR powyżej lub utwórz nowy Worktree.",
"agentManager.import.failed": "Import nie powiódł się",
"agentManager.browser.title": "Przeglądarka",
"agentManager.browser.url": "URL lokalnej aplikacji",
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
"agentManager.browser.open": "Otwórz",
"agentManager.browser.inspect": "Wybierz element",
"agentManager.browser.devtoolsTitle": "Narzędzia deweloperskie",
"agentManager.browser.refresh": "Odśwież przeglądarkę",
"agentManager.browser.close": "Zamknij przeglądarkę",
"agentManager.browser.empty": "Otwórz lokalną aplikację, aby wyświetlić ją tutaj.",
"agentManager.browser.noSession": "Najpierw wybierz sesję aplikacji Agent Manager.",
"agentManager.browser.screenshotAlt": "Bieżąca strona przeglądarki",
"agentManager.browser.errors": "Problemy przeglądarki: {{count}}",
"agentManager.browser.diagnostics": "Diagnostyka przeglądarki",
"agentManager.browser.diagnosticsHint":
"Ostatnie zdarzenia z przeglądarki używanej do automatyzacji. Blokady ze względów bezpieczeństwa nie są błędami konsoli.",
"agentManager.pr.error.gh_auth.title": "Wymagana autoryzacja GitHub",
"agentManager.pr.error.gh_auth.description": "Uruchom 'gh auth login' w terminalu, aby przywrócić status PR.",
+15
View File
@@ -247,6 +247,21 @@ export const dict = {
"agentManager.import.noBranchesFound": "Ветки не найдены.",
"agentManager.import.noBranchesHint": "Вставьте URL PR выше или создайте новый Worktree.",
"agentManager.import.failed": "Ошибка импорта",
"agentManager.browser.title": "Браузер",
"agentManager.browser.url": "URL локального приложения",
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
"agentManager.browser.open": "Открыть",
"agentManager.browser.refresh": "Обновить браузер",
"agentManager.browser.close": "Закрыть браузер",
"agentManager.browser.inspect": "Выбрать элемент",
"agentManager.browser.devtoolsTitle": "Инструменты разработчика",
"agentManager.browser.empty": "Откройте локальное приложение, чтобы просмотреть его здесь.",
"agentManager.browser.noSession": "Сначала выберите сеанс Agent Manager.",
"agentManager.browser.screenshotAlt": "Текущая страница браузера",
"agentManager.browser.errors": "Проблемы браузера: {{count}}",
"agentManager.browser.diagnostics": "Диагностика браузера",
"agentManager.browser.diagnosticsHint":
"Последние события из браузера, используемого для автоматизации. Блокировки в целях безопасности не являются ошибками консоли.",
"agentManager.pr.error.gh_auth.title": "Требуется аутентификация GitHub",
"agentManager.pr.error.gh_auth.description": "Выполните 'gh auth login' в терминале, чтобы восстановить статус PR.",
+15
View File
@@ -240,6 +240,21 @@ export const dict = {
"agentManager.import.noBranchesFound": "ไม่พบ Branch",
"agentManager.import.noBranchesHint": "วาง URL ของ PR ด้านบนหรือสร้าง Worktree ใหม่",
"agentManager.import.failed": "นำเข้าล้มเหลว",
"agentManager.browser.title": "เบราว์เซอร์",
"agentManager.browser.url": "URL ของแอปพลิเคชันในเครื่อง",
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
"agentManager.browser.open": "เปิด",
"agentManager.browser.refresh": "รีเฟรชเบราว์เซอร์",
"agentManager.browser.close": "ปิดเบราว์เซอร์",
"agentManager.browser.inspect": "เลือกองค์ประกอบ",
"agentManager.browser.devtoolsTitle": "เครื่องมือสำหรับนักพัฒนา",
"agentManager.browser.empty": "เปิดแอปพลิเคชันในเครื่องเพื่อดูตัวอย่างที่นี่",
"agentManager.browser.noSession": "เลือกเซสชัน Agent Manager ก่อน",
"agentManager.browser.screenshotAlt": "หน้าปัจจุบันของเบราว์เซอร์",
"agentManager.browser.errors": "ปัญหาเบราว์เซอร์: {{count}} รายการ",
"agentManager.browser.diagnostics": "การวินิจฉัยเบราว์เซอร์",
"agentManager.browser.diagnosticsHint":
"เหตุการณ์ล่าสุดจากเบราว์เซอร์สำหรับงานอัตโนมัติ การบล็อกเพื่อความปลอดภัยไม่ใช่ข้อผิดพลาดในคอนโซล",
"agentManager.pr.error.gh_auth.title": "ต้องยืนยันตัวตน GitHub",
"agentManager.pr.error.gh_auth.description": "รันคำสั่ง 'gh auth login' ในเทอร์มินัลเพื่อกู้คืนสถานะ PR",
+15
View File
@@ -254,6 +254,21 @@ export const dict = {
"agentManager.import.noBranchesFound": "Hiçbir dal bulunamadı.",
"agentManager.import.noBranchesHint": "Yukarıya bir PR URL'si yapıştırın veya yeni bir worktree oluşturun.",
"agentManager.import.failed": "İçe aktarma başarısız oldu",
"agentManager.browser.title": "Tarayıcı",
"agentManager.browser.url": "Yerel uygulama URL'si",
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
"agentManager.browser.open": "Aç",
"agentManager.browser.refresh": "Tarayıcıyı yenile",
"agentManager.browser.close": "Tarayıcıyı kapat",
"agentManager.browser.inspect": "Öğeyi seç",
"agentManager.browser.devtoolsTitle": "Geliştirici araçları",
"agentManager.browser.empty": "Burada önizlemek için yerel bir uygulama açın.",
"agentManager.browser.noSession": "Önce bir Agent Manager oturumu seçin.",
"agentManager.browser.screenshotAlt": "Geçerli tarayıcı sayfası",
"agentManager.browser.errors": "Tarayıcı sorunları: {{count}}",
"agentManager.browser.diagnostics": "Tarayıcı tanılaması",
"agentManager.browser.diagnosticsHint":
"Otomasyon tarayıcısındaki son olaylar. Güvenlik engellemeleri konsol hatası değildir.",
"agentManager.pr.error.gh_auth.title": "GitHub kimlik doğrulaması gerekli",
"agentManager.pr.error.gh_auth.description":
+15
View File
@@ -255,6 +255,21 @@ export const dict = {
"agentManager.import.noBranchesFound": "Гілок не знайдено.",
"agentManager.import.noBranchesHint": "Вставте URL PR вище або створіть нове робоче дерево.",
"agentManager.import.failed": "Імпортування не вдалося",
"agentManager.browser.title": "Браузер",
"agentManager.browser.url": "URL локальної програми",
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
"agentManager.browser.open": "Відкрити",
"agentManager.browser.refresh": "Оновити браузер",
"agentManager.browser.close": "Закрити браузер",
"agentManager.browser.inspect": "Вибрати елемент",
"agentManager.browser.devtoolsTitle": "Інструменти розробника",
"agentManager.browser.empty": "Відкрийте локальну програму, щоб переглянути її тут.",
"agentManager.browser.noSession": "Спочатку виберіть сесію Agent Manager.",
"agentManager.browser.screenshotAlt": "Поточна сторінка браузера",
"agentManager.browser.errors": "Проблеми браузера: {{count}}",
"agentManager.browser.diagnostics": "Діагностика браузера",
"agentManager.browser.diagnosticsHint":
"Останні події з браузера для автоматизації. Блокування з міркувань безпеки не є помилками консолі.",
"agentManager.pr.error.gh_auth.title": "Потрібна автентифікація GitHub",
"agentManager.pr.error.gh_auth.description": "Виконайте 'gh auth login' у терміналі, щоб відновити статус PR.",
+15
View File
@@ -239,6 +239,21 @@ export const dict = {
"agentManager.import.noBranchesHint": "在上方粘贴 PR URL 或创建新的 Worktree。",
"agentManager.import.failed": "导入失败",
"agentManager.browser.title": "浏览器",
"agentManager.browser.url": "本地应用 URL",
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
"agentManager.browser.open": "打开",
"agentManager.browser.refresh": "刷新浏览器",
"agentManager.browser.close": "关闭浏览器",
"agentManager.browser.inspect": "选择元素",
"agentManager.browser.devtoolsTitle": "开发者工具",
"agentManager.browser.empty": "打开本地应用以在此处预览。",
"agentManager.browser.noSession": "请先选择 Agent Manager 会话。",
"agentManager.browser.screenshotAlt": "当前浏览器页面",
"agentManager.browser.errors": "浏览器问题:{{count}}",
"agentManager.browser.diagnostics": "浏览器诊断",
"agentManager.browser.diagnosticsHint": "来自自动化浏览器的近期事件。安全拦截不是控制台错误。",
"agentManager.pr.error.gh_auth.title": "需要 GitHub 身份验证",
"agentManager.pr.error.gh_auth.description": "在终端中运行 'gh auth login' 以恢复 PR 状态。",
"agentManager.pr.error.gh_missing.title": "未安装 GitHub CLI",
@@ -238,6 +238,20 @@ export const dict = {
"agentManager.import.noBranchesFound": "找不到分支。",
"agentManager.import.noBranchesHint": "在上方貼上 PR URL 或建立新的 Worktree。",
"agentManager.import.failed": "匯入失敗",
"agentManager.browser.title": "瀏覽器",
"agentManager.browser.url": "本機應用程式 URL",
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
"agentManager.browser.open": "開啟",
"agentManager.browser.refresh": "重新整理瀏覽器",
"agentManager.browser.close": "關閉瀏覽器",
"agentManager.browser.inspect": "選取元素",
"agentManager.browser.devtoolsTitle": "開發人員工具",
"agentManager.browser.empty": "開啟本機應用程式,即可在此處預覽。",
"agentManager.browser.noSession": "請先選取 Agent Manager 工作階段。",
"agentManager.browser.screenshotAlt": "目前的瀏覽器頁面",
"agentManager.browser.errors": "瀏覽器問題:{{count}}",
"agentManager.browser.diagnostics": "瀏覽器診斷",
"agentManager.browser.diagnosticsHint": "來自自動化瀏覽器的近期事件。安全性封鎖不是主控台錯誤。",
"agentManager.pr.error.gh_auth.title": "需要 GitHub 驗證",
"agentManager.pr.error.gh_auth.description": "在終端機中執行 'gh auth login' 以還原 PR 狀態。",
@@ -1,4 +1,5 @@
import type { AgentManagerSendInitialMessage, SendMessageRequest } from "../src/types/messages"
import { formatBrowserFeedback } from "../../src/shared/browser-feedback"
interface VariantSession {
getSessionAgent: (sessionID: string) => string
@@ -7,16 +8,18 @@ interface VariantSession {
export function initialMessage(ev: AgentManagerSendInitialMessage): SendMessageRequest | undefined {
if (!ev.text) return undefined
const text = ev.browserFeedback ? `${formatBrowserFeedback(ev.browserFeedback.references)}\n\n${ev.text}` : ev.text
return {
type: "sendMessage",
...(ev.projectId ? { projectId: ev.projectId } : {}),
text: ev.text,
text,
sessionID: ev.sessionId,
providerID: ev.providerID,
modelID: ev.modelID,
agent: ev.agent,
variant: ev.variant,
files: ev.files,
browserFeedback: ev.browserFeedback,
}
}
@@ -19,6 +19,10 @@ export function createProjectStateHandlers(opts: {
setPending: (value: boolean) => void
rename: (id: string) => void
font: (font: AgentManagerStateMessage["terminalFont"]) => void
browser: (enabled: boolean) => void
current: () => string | undefined
closeBrowser: () => void
openBrowser: () => void
}) {
const projects = (msg: ExtensionMessage) => {
if (msg.type !== "agentManager.projects") return
@@ -32,6 +36,8 @@ export function createProjectStateHandlers(opts: {
const state = (msg: ExtensionMessage) => {
if (msg.type !== "agentManager.state") return
if (msg.browserAutomation !== undefined) opts.browser(msg.browserAutomation)
if (msg.browserAutomation === false) opts.closeBrowser()
if (msg.terminalFont) opts.font(msg.terminalFont)
if (msg.projectId) opts.setStates((prev) => ({ ...prev, [msg.projectId!]: msg }))
const store = msg.projectId ? opts.ensure(msg.projectId) : opts.active()
@@ -45,5 +51,11 @@ export function createProjectStateHandlers(opts: {
opts.routeState(msg)
}
return { projects, state }
const browser = (msg: ExtensionMessage) => {
if (msg.type !== "agentManager.browserState" || (msg.status !== "starting" && msg.status !== "loading")) return
if (!opts.isActive(msg.projectId) || msg.sessionId !== opts.current()) return
opts.openBrowser()
}
return { projects, state, browser }
}
@@ -12,6 +12,7 @@ export enum SidePanel {
Subagents = "subagents",
EditPreview = "edit-preview",
Documents = "documents",
Browser = "browser",
}
function viewportWidth(viewport: number): number {
@@ -0,0 +1,295 @@
import { For, Show, createMemo, type Accessor, type Component } from "solid-js"
import { Card } from "@kilocode/kilo-ui/card"
import { Collapsible } from "@kilocode/kilo-ui/collapsible"
import { Icon } from "@kilocode/kilo-ui/icon"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { TextField } from "@kilocode/kilo-ui/text-field"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { createBrowserController } from "./controller"
import type { BrowserController } from "./controller"
import type { BrowserLabels, BrowserPosition, BrowserScope, BrowserState, BrowserTransport } from "./types"
import type { BrowserReference } from "../../src/shared/browser-feedback"
import "./browser.css"
function position(event: MouseEvent & { currentTarget: HTMLButtonElement }): BrowserPosition {
const bounds = event.currentTarget.getBoundingClientRect()
return {
x: Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)),
y: Math.max(0, Math.min(1, (event.clientY - bounds.top) / bounds.height)),
width: bounds.width,
height: bounds.height,
}
}
const Toolbar: Component<{
controller: BrowserController
labels: BrowserLabels
title?: string
active: boolean
}> = (props) => {
const ready = () => !!props.controller.state()?.url && props.controller.state()?.status !== "closed"
return (
<div class="am-browser-toolbar">
<Tooltip value={props.labels.refresh} placement="bottom">
<IconButton
icon="refresh"
size="small"
variant="ghost"
aria-label={props.labels.refresh}
onClick={props.controller.refresh}
disabled={!ready() || props.controller.loading()}
/>
</Tooltip>
<form
class="am-browser-address"
title={props.title}
onSubmit={(event) => {
event.preventDefault()
if (props.active && props.controller.url().trim() && !props.controller.loading()) props.controller.open()
}}
>
<Show when={props.controller.loading()}>
<span class="am-browser-site" aria-hidden="true">
<Spinner />
</span>
</Show>
<TextField
class="am-browser-url"
variant="ghost"
value={props.controller.url()}
onChange={props.controller.setUrl}
placeholder={props.labels.urlPlaceholder}
aria-label={props.labels.url}
spellcheck={false}
autocomplete="off"
onFocus={(event: FocusEvent & { currentTarget: HTMLInputElement }) => event.currentTarget.select()}
/>
<Tooltip value={props.labels.open} placement="bottom">
<IconButton
type="submit"
icon="arrow-right"
size="small"
variant="ghost"
aria-label={props.labels.open}
disabled={!props.controller.url().trim() || !props.active || props.controller.loading()}
/>
</Tooltip>
</form>
<Tooltip value={props.labels.inspect} placement="bottom">
<IconButton
icon="window-cursor"
size="small"
variant={props.controller.selecting() ? "secondary" : "ghost"}
aria-label={props.labels.inspect}
aria-pressed={props.controller.selecting()}
disabled={!ready() || props.controller.loading()}
onClick={props.controller.toggleSelecting}
/>
</Tooltip>
<div class="am-browser-tools-action">
<Tooltip value={props.labels.devtoolsTitle} placement="bottom">
<IconButton
icon="console"
size="small"
variant={props.controller.tools() ? "secondary" : "ghost"}
aria-label={props.labels.devtoolsTitle}
aria-pressed={!!props.controller.tools()}
onClick={props.controller.toggleTools}
disabled={!ready() || props.controller.loading()}
/>
</Tooltip>
</div>
<Tooltip value={props.labels.close} placement="bottom">
<IconButton
icon="close"
size="small"
variant="ghost"
aria-label={props.labels.close}
onClick={props.controller.close}
/>
</Tooltip>
</div>
)
}
const Picker: Component<{
active: boolean
controller: BrowserController
labels: BrowserLabels
}> = (props) => {
const bounds = () => props.controller.hovered()?.element?.rect
return (
<Show when={props.active}>
<button
type="button"
class="am-browser-inspect"
aria-label={props.labels.inspect}
onMouseMove={(event) => props.controller.move(position(event))}
onClick={(event) => props.controller.select(position(event))}
/>
<Show when={bounds()} keyed>
{(rect) => (
<div
class="am-browser-hover-outline"
style={{
left: `${rect.x * 100}%`,
top: `${rect.y * 100}%`,
width: `${rect.width * 100}%`,
height: `${rect.height * 100}%`,
}}
>
<span classList={{ "am-browser-hover-label-inside": rect.y < 0.05 }}>
{props.controller.hovered()?.element?.selector}
</span>
</div>
)}
</Show>
</Show>
)
}
const Viewport: Component<{
state?: BrowserState
session?: string
controller: BrowserController
labels: BrowserLabels
}> = (props) => {
const issue = () => props.state?.frameError || props.state?.error
const page = () =>
props.state?.url &&
props.state.status !== "closed" &&
(props.state.status !== "error" || !!props.state.title) &&
props.state.url
const identity = () => {
const url = page()
return url ? `${props.state?.browserId}:${props.state?.navigation ?? 0}:${url}` : undefined
}
return (
<div class="am-browser-viewport" aria-live="polite">
<Show
when={identity()}
keyed
fallback={
<Show when={!issue()}>
<div class="am-browser-empty">
<div>{props.session ? props.labels.empty : props.labels.noSession}</div>
</div>
</Show>
}
>
{(_key) => (
<iframe
class="am-browser-frame"
src={props.state?.url}
title={props.labels.screenshotAlt}
sandbox="allow-scripts allow-forms allow-same-origin"
referrerpolicy="no-referrer"
/>
)}
</Show>
<Picker
active={(props.controller.selecting() || props.controller.pointing()) && !!props.state?.url}
controller={props.controller}
labels={props.labels}
/>
<Show when={issue()}>
{(message) => (
<Card variant="error" class="error-card am-browser-error-overlay" role="alert">
<div class="error-card-body">
<Icon name="warning" size="small" />
<div class="error-card-message">{message()}</div>
</div>
</Card>
)}
</Show>
</div>
)
}
const Tools: Component<{ url: string; labels: BrowserLabels }> = (props) => (
<section class="am-browser-devtools" aria-label={props.labels.devtoolsTitle}>
<iframe
class="am-browser-devtools-frame"
src={props.url}
title={props.labels.devtoolsTitle}
sandbox="allow-scripts allow-forms allow-same-origin"
referrerpolicy="no-referrer"
/>
</section>
)
const Diagnostics: Component<{ logs: string[]; errors: number; labels: BrowserLabels }> = (props) => {
const entries = createMemo(() => {
const counts = new Map<string, number>()
for (const text of props.logs) counts.set(text, (counts.get(text) ?? 0) + 1)
return Array.from(counts, ([text, count]) => ({ text, count }))
})
return (
<Show when={props.logs.length || props.errors}>
<Collapsible variant="ghost" class="am-browser-diagnostics">
<Collapsible.Trigger>
<span>{props.errors ? props.labels.errors(props.errors) : props.labels.diagnostics}</span>
<Collapsible.Arrow />
</Collapsible.Trigger>
<Collapsible.Content>
<div class="am-browser-diagnostics-hint">{props.labels.diagnosticsHint}</div>
<div class="am-browser-console" role="log" aria-live="polite">
<For each={entries()}>
{(entry) => (
<div class="am-browser-console-entry" data-level={entry.text.match(/^\[([^\]]+)\]/)?.[1] ?? "error"}>
<span>{entry.text}</span>
<Show when={entry.count > 1}>
<span class="am-browser-console-count">×{entry.count}</span>
</Show>
</div>
)}
</For>
</div>
</Collapsible.Content>
</Collapsible>
</Show>
)
}
export interface BrowserPanelProps {
scope: Accessor<BrowserScope | undefined>
transport: BrowserTransport
labels: BrowserLabels
onReference: (reference: BrowserReference) => void
onClose: () => void
theme?: Accessor<"dark" | "light">
}
export const BrowserPanel: Component<BrowserPanelProps> = (props) => {
const controller = createBrowserController({
scope: props.scope,
transport: props.transport,
onReference: props.onReference,
onClose: props.onClose,
theme: props.theme,
})
const state = controller.state
return (
<div
class="am-browser-panel"
aria-label={props.labels.title}
aria-busy={controller.loading()}
data-status={state()?.status ?? "closed"}
>
<Toolbar
controller={controller}
labels={props.labels}
title={state()?.title}
active={!!props.scope()?.sessionId}
/>
<div class="am-browser-workspace" classList={{ "am-browser-workspace-docked": !!controller.tools() }}>
<Viewport state={state()} session={props.scope()?.sessionId} controller={controller} labels={props.labels} />
<Show when={controller.tools()} keyed>
{(entry) => <Tools url={entry.url} labels={props.labels} />}
</Show>
</div>
<Diagnostics logs={state()?.logs ?? []} errors={state()?.errors ?? 0} labels={props.labels} />
</div>
)
}
@@ -0,0 +1,249 @@
.am-browser-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
min-width: 0;
background: var(--surface-base);
container-type: inline-size;
}
.am-browser-toolbar {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 4px;
padding: 6px;
border-bottom: 1px solid var(--border-weak-base);
}
.am-browser-address {
display: flex;
flex: 1;
align-items: center;
gap: 4px;
height: 28px;
min-width: 0;
padding: 0 2px 0 8px;
background: var(--surface-inset-base);
border: 1px solid var(--border-weak-base);
border-radius: 6px;
}
.am-browser-address:focus-within {
border-color: var(--vscode-focusBorder);
}
.am-browser-address [data-component="input"] {
flex: 1;
min-width: 0;
}
.am-browser-address [data-slot="input-wrapper"] {
min-width: 0;
}
.am-browser-address .am-browser-url {
height: 26px;
min-width: 0;
padding: 0;
font-size: var(--font-size-small);
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
}
.am-browser-site {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: var(--text-weak);
}
.am-browser-site > svg {
width: 16px;
height: 16px;
}
.am-browser-tools-action {
position: relative;
flex-shrink: 0;
}
.am-browser-workspace {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
flex-direction: column;
}
.am-browser-viewport {
position: relative;
display: flex;
flex: 1;
min-height: 0;
min-width: 0;
align-items: center;
justify-content: center;
overflow: auto;
background: var(--surface-inset-base);
}
.am-browser-frame {
display: block;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
border: 0;
background: #fff;
}
.am-browser-workspace-docked .am-browser-viewport {
flex: 1 1 40%;
}
.am-browser-devtools {
display: flex;
flex: 1 1 60%;
min-width: 0;
min-height: 180px;
flex-direction: column;
border-top: 1px solid var(--border-weak-base);
}
.am-browser-devtools-frame {
flex: 1;
width: 100%;
min-width: 0;
min-height: 0;
border: 0;
background: var(--surface-base);
}
@container (min-width: 760px) {
.am-browser-workspace-docked {
flex-direction: row;
}
.am-browser-workspace-docked .am-browser-viewport {
flex-basis: 45%;
}
.am-browser-devtools {
flex-basis: 55%;
min-height: 0;
border-top: 0;
border-left: 1px solid var(--border-weak-base);
}
}
.am-browser-inspect {
position: absolute;
inset: 0;
z-index: 1;
cursor: crosshair;
background: transparent;
border: 1px solid var(--border-strong-base);
}
.am-browser-hover-outline {
position: absolute;
z-index: 2;
pointer-events: none;
border: 2px solid var(--focus-border, #3794ff);
background: color-mix(in srgb, var(--focus-border, #3794ff) 12%, transparent);
}
.am-browser-hover-outline span {
position: absolute;
top: 0;
left: -2px;
padding: 2px 5px;
font-family: var(--font-family-mono);
font-size: var(--font-size-small);
color: var(--text-on-accent);
white-space: nowrap;
background: var(--focus-border, #3794ff);
transform: translateY(-100%);
}
.am-browser-hover-outline .am-browser-hover-label-inside {
left: 0;
transform: none;
}
[data-component="collapsible"].am-browser-diagnostics {
flex-shrink: 0;
border-top: 1px solid var(--border-weak-base);
border-radius: 0;
}
[data-component="collapsible"].am-browser-diagnostics > [data-slot="collapsible-trigger"] {
justify-content: space-between;
padding: 0 12px;
}
.am-browser-diagnostics [data-slot="collapsible-arrow"] {
opacity: 1;
}
.am-browser-diagnostics-hint {
padding: 0 12px 4px;
font-size: var(--font-size-small);
color: var(--text-weak);
}
.am-browser-console {
max-height: 140px;
padding: 4px 12px 8px;
overflow: auto;
font-family: var(--font-family-mono);
font-size: var(--font-size-small);
color: var(--text-weak);
}
.am-browser-console-entry {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
overflow-wrap: anywhere;
}
.am-browser-console-count {
flex-shrink: 0;
color: var(--text-weak);
}
.am-browser-console-entry[data-level="warning"],
.am-browser-console-entry[data-level="warn"] {
color: var(--text-warning, #d6a23a);
}
.am-browser-console-entry[data-level="error"] {
color: var(--syntax-diff-delete, #da3319);
}
[data-component="card"].am-browser-error-overlay {
position: absolute;
right: 16px;
bottom: 16px;
left: 16px;
width: auto;
}
.am-browser-empty {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 280px;
padding: 24px;
color: var(--text-weak);
text-align: center;
}
@@ -0,0 +1,291 @@
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
import type { BrowserReference } from "../../src/shared/browser-feedback"
import type {
BrowserCommand,
BrowserEvent,
BrowserInspection,
BrowserPosition,
BrowserScope,
BrowserState,
BrowserTransport,
} from "./types"
export interface BrowserControllerOptions {
scope: Accessor<BrowserScope | undefined>
transport: BrowserTransport
onReference: (reference: BrowserReference) => void
onClose: () => void
theme?: Accessor<"dark" | "light">
schedule?: (callback: FrameRequestCallback) => number
cancel?: (frame: number) => void
}
export interface BrowserController {
url: Accessor<string>
state: Accessor<BrowserState | undefined>
selecting: Accessor<boolean>
pointing: Accessor<boolean>
hovered: Accessor<BrowserInspection | undefined>
tools: Accessor<{ browserId: string; url: string } | undefined>
loading: Accessor<boolean>
setUrl: (value: string) => void
open: () => void
refresh: () => void
close: () => void
toggleSelecting: () => void
toggleTools: () => void
move: (value: BrowserPosition) => void
select: (value: BrowserPosition) => void
dispose: () => void
}
export function createBrowserController(props: BrowserControllerOptions): BrowserController {
const [url, setUrl] = createSignal("")
const [selecting, setSelecting] = createSignal(false)
const [pointing, setPointing] = createSignal(false)
const [hovered, setHovered] = createSignal<BrowserInspection>()
const [state, setState] = createSignal<BrowserState>()
const [tools, setTools] = createSignal<{ browserId: string; url: string }>()
const scheduleFrame = props.schedule ?? ((callback: FrameRequestCallback) => requestAnimationFrame(callback))
const cancelFrame = props.cancel ?? ((frame: number) => cancelAnimationFrame(frame))
let frame: number | undefined
let pending: BrowserPosition | undefined
let active: string | undefined
let selected: string | undefined
let sequence = 0
let current: BrowserScope | undefined
let unsubscribe: (() => void) | undefined
let generation = 0
let disposed = false
const stop = () => {
if (frame !== undefined) cancelFrame(frame)
frame = undefined
pending = undefined
active = undefined
selected = undefined
setHovered(undefined)
}
const reset = () => {
stop()
setState(undefined)
setUrl("")
setSelecting(false)
setPointing(false)
setTools(undefined)
}
const same = (scope: BrowserScope, value: BrowserScope) =>
scope.sessionId === value.sessionId && scope.projectId === value.projectId
const send = (command: BrowserCommand) => {
if (disposed) return
props.transport.send(command)
}
const request = (type: "refresh" | "close" | "state") => {
sync()
if (!current) return
send({ type, scope: current })
}
const inspect = (value: BrowserPosition, hover: boolean) => {
sync()
if (!current) return
const requestId = String(++sequence)
if (hover) active = requestId
else selected = requestId
send({ type: "inspect", scope: current, position: value, hover, requestId })
}
const input = (value: BrowserPosition, click: boolean) => {
sync()
if (!current) return
send({ type: "input", scope: current, position: value, click })
}
const schedule = () => {
if (frame !== undefined || active || !pending || (!selecting() && !pointing())) return
frame = scheduleFrame(() => {
frame = undefined
const value = pending
pending = undefined
if (!value || (!selecting() && !pointing())) return
if (pointing()) input(value, false)
inspect(value, true)
})
}
const receiveState = (value: BrowserState) => {
if (!current || !same(current, value.scope)) return
const previous = state()
const changed = value.url !== previous?.url
if (
value.browserId !== previous?.browserId ||
value.navigation !== previous?.navigation ||
value.status === "closed"
) {
stop()
setSelecting(false)
}
const inspecting = value.status !== "closed" && value.status !== "error" && value.inspecting === true
if (inspecting !== pointing()) {
stop()
setPointing(inspecting)
if (inspecting) setSelecting(false)
}
if (tools()?.browserId !== value.browserId || value.status === "closed") setTools(undefined)
setState(value)
if (value.url && changed) setUrl(value.url)
}
const receiveInspection = (value: BrowserInspection) => {
if (!current || !same(current, value.scope)) return
if (value.hover) {
if ((!selecting() && !pointing()) || value.requestId !== active) return
active = undefined
setHovered(value.error ? undefined : value)
schedule()
return
}
if (value.requestId !== selected) return
selected = undefined
const element = value.element
if (value.error || !element?.selector) {
setSelecting(true)
return
}
props.onReference({
id: crypto.randomUUID(),
sessionId: current.sessionId,
selector: element.selector,
text: element.text,
url: value.url,
title: value.title,
hierarchy: element.hierarchy,
html: element.html,
styles: element.styles,
source: element.source,
})
setSelecting(false)
stop()
}
const receive = (event: BrowserEvent) => {
if (disposed) return
sync()
if (!current) return
if (event.type === "state") return receiveState(event.value)
if (event.type === "devtools") {
if (!same(current, event.value.scope) || event.value.browserId !== state()?.browserId) return
setTools({ browserId: event.value.browserId, url: event.value.url })
return
}
receiveInspection(event.value)
}
const attach = (scope: BrowserScope | undefined) => {
unsubscribe?.()
unsubscribe = undefined
generation++
current = scope ? { ...scope } : undefined
reset()
if (!current) return
const version = generation
unsubscribe = props.transport.subscribe((event) => {
if (version !== generation) return
receive(event)
})
send({ type: "state", scope: current })
}
const sync = () => {
if (disposed) return false
const next = props.scope()
if (next?.sessionId !== current?.sessionId || next?.projectId !== current?.projectId) attach(next)
return true
}
attach(props.scope())
createEffect(() => sync())
const read =
<T>(value: Accessor<T>): Accessor<T> =>
() => {
sync()
return value()
}
const controller: BrowserController = {
url: read(url),
state: read(state),
selecting: read(selecting),
pointing: read(pointing),
hovered: read(hovered),
tools: read(tools),
loading: read(() => state()?.status === "loading" || state()?.status === "starting"),
setUrl: (value) => {
if (!sync()) return
setUrl(value)
},
open: () => {
if (!sync() || !current) return
const value = url().trim()
if (!value) return
send({ type: "open", scope: current, url: /^https?:\/\//i.test(value) ? value : `http://${value}` })
},
refresh: () => request("refresh"),
close: () => {
if (!sync()) return
stop()
setSelecting(false)
setPointing(false)
request("close")
props.onClose()
},
toggleSelecting: () => {
if (!sync()) return
const next = !selecting()
stop()
setSelecting(next)
},
toggleTools: () => {
if (!sync()) return
if (tools()) {
setTools(undefined)
return
}
if (!current) return
send({
type: "devtools",
scope: current,
theme: props.theme?.() ?? "dark",
})
},
move: (value) => {
if (!sync()) return
pending = value
schedule()
},
select: (value) => {
if (!sync()) return
stop()
if (pointing()) {
input(value, true)
return
}
setSelecting(false)
inspect(value, false)
},
dispose: () => {
if (disposed) return
disposed = true
unsubscribe?.()
unsubscribe = undefined
reset()
current = undefined
},
}
onCleanup(controller.dispose)
return controller
}
@@ -0,0 +1,15 @@
export { BrowserPanel } from "./BrowserPanel"
export { createBrowserController } from "./controller"
export type { BrowserController, BrowserControllerOptions } from "./controller"
export type {
BrowserCommand,
BrowserDevtools,
BrowserElement,
BrowserEvent,
BrowserInspection,
BrowserLabels,
BrowserPosition,
BrowserScope,
BrowserState,
BrowserTransport,
} from "./types"
@@ -0,0 +1,91 @@
export interface BrowserScope {
sessionId: string
projectId?: string
}
export interface BrowserPosition {
x: number
y: number
width: number
height: number
}
export interface BrowserElement {
tag: string
id?: string
classes?: string
text?: string
selector?: string
rect?: { x: number; y: number; width: number; height: number }
hierarchy?: string[]
html?: string
styles?: { color?: string; backgroundColor?: string }
source?: { file: string; line?: number; column?: number }
}
export interface BrowserState {
scope: BrowserScope
browserId: string
navigation?: number
status: "starting" | "ready" | "loading" | "error" | "closed"
inspecting?: boolean
url?: string
title?: string
errors: number
logs?: string[]
error?: string
frameError?: string
}
export interface BrowserInspection {
scope: BrowserScope
requestId: string
url?: string
title?: string
element?: BrowserElement
logs: string[]
hover?: boolean
error?: string
}
export interface BrowserDevtools {
scope: BrowserScope
browserId: string
url: string
}
export type BrowserCommand =
| { type: "open"; scope: BrowserScope; url: string }
| { type: "refresh"; scope: BrowserScope }
| { type: "close"; scope: BrowserScope }
| { type: "state"; scope: BrowserScope }
| { type: "inspect"; scope: BrowserScope; position: BrowserPosition; hover: boolean; requestId: string }
| { type: "input"; scope: BrowserScope; position: BrowserPosition; click: boolean }
| { type: "devtools"; scope: BrowserScope; theme: "dark" | "light" }
export type BrowserEvent =
| { type: "state"; value: BrowserState }
| { type: "inspection"; value: BrowserInspection }
| { type: "devtools"; value: BrowserDevtools }
export interface BrowserTransport {
send(command: BrowserCommand): void
subscribe(listener: (event: BrowserEvent) => void): () => void
}
export interface BrowserLabels {
title: string
url: string
urlPlaceholder: string
open: string
refresh: string
close: string
inspect: string
devtoolsTitle: string
diagnostics: string
diagnosticsHint: string
empty: string
noSession: string
screenshotAlt: string
errors: (count: number) => string
}
@@ -2,6 +2,7 @@ import type { AnnotationSide, DiffLineAnnotation } from "@pierre/diffs"
import type { WorktreeFileDiff } from "../src/types/messages"
import { extractLines, type ReviewComment } from "./review-comments"
import type { ReviewCommentEntry } from "../src/types/messages"
import { post } from "../src/utils/webview-message"
export interface AnnotationLabels {
commentOnLine: (line: number) => string
@@ -154,16 +155,12 @@ function makeActionButton(title: string, icon: SVGSVGElement, action: () => void
}
export function sendReviewComments(comments: ReviewCommentEntry[], activeTerminalId?: string): void {
window.dispatchEvent(
new MessageEvent("message", {
data: {
type: activeTerminalId ? "appendReviewCommentsToTerminal" : "appendReviewComments",
comments,
autoSend: true,
targetTerminalId: activeTerminalId,
},
}),
)
post({
type: activeTerminalId ? "appendReviewCommentsToTerminal" : "appendReviewComments",
comments,
autoSend: true,
targetTerminalId: activeTerminalId,
})
}
export function buildFileAnnotations(
@@ -0,0 +1,121 @@
import { For, Show, createSignal, type Component } from "solid-js"
import { Button } from "@kilocode/kilo-ui/button"
import { Icon } from "@kilocode/kilo-ui/icon"
import { browserFeedbackData, type BrowserReference } from "../../../../src/shared/browser-feedback"
import { useLanguage } from "../../context/language"
interface BrowserReferencesProps {
references: BrowserReference[]
variant?: "draft" | "message"
onRemove?: (id: string) => void
onClear?: () => void
}
export const BrowserReferences: Component<BrowserReferencesProps> = (props) => {
const language = useLanguage()
const [open, setOpen] = createSignal(true)
const [full, setFull] = createSignal<string[]>([])
const data = () => browserFeedbackData(props.references)
const rows = () => data()?.references ?? []
const toggle = (id: string) =>
setFull((current) => (current.includes(id) ? current.filter((item) => item !== id) : [...current, id]))
const page = (item: BrowserReference) => item.title || item.url
const styles = (item: BrowserReference) =>
[
item.styles?.color ? `color=${item.styles.color}` : "",
item.styles?.backgroundColor ? `background=${item.styles.backgroundColor}` : "",
]
.filter(Boolean)
.join(", ")
const source = (item: BrowserReference) =>
item.source
? [item.source.file, item.source.line, item.source.column].filter((value) => value !== undefined).join(":")
: ""
return (
<div
class="prompt-review-comments"
classList={{ "prompt-review-comments--message": props.variant === "message" }}
data-component="browser-references"
>
<div class="prompt-review-comments-header">
<button
type="button"
class="prompt-review-comments-toggle"
aria-expanded={open()}
onClick={() => setOpen(!open())}
>
<Icon name={open() ? "chevron-down" : "chevron-right"} size="small" />
<span class="prompt-review-comments-title">
{language.t("agentManager.browser.title")} ({rows().length})
</span>
</button>
<Show when={props.onClear}>
<Button variant="ghost" size="small" onClick={() => props.onClear?.()}>
{language.t("agentManager.review.clearAll")}
</Button>
</Show>
</div>
<Show when={open()}>
<div class="prompt-review-list">
<For each={rows()}>
{(item) => (
<div class="prompt-review-row" classList={{ "prompt-review-row--full": full().includes(item.id) }}>
<div class="prompt-review-row-top">
<span class="prompt-review-row-icon">
<Icon name="globe" size="small" />
</span>
<button
type="button"
class="prompt-review-row-main"
aria-expanded={full().includes(item.id)}
onClick={() => toggle(item.id)}
>
<span class="prompt-review-row-head">
<span class="prompt-review-row-label">{item.selector}</span>
</span>
<Show when={!full().includes(item.id)}>
<span class="prompt-review-row-preview">
{item.text || item.source?.file || page(item) || ""}
</span>
</Show>
</button>
<Show when={props.onRemove}>
<button
type="button"
class="prompt-review-row-remove"
onClick={() => props.onRemove?.(item.id)}
aria-label={language.t("common.delete")}
>
×
</button>
</Show>
</div>
<Show when={full().includes(item.id)}>
<div class="prompt-review-row-detail">
<Show when={item.url || item.title}>
{(value) => <div class="prompt-review-row-path">{value()}</div>}
</Show>
<Show when={item.hierarchy?.length}>
<pre class="prompt-review-row-snippet">{item.hierarchy?.join(" > ")}</pre>
</Show>
<Show when={item.text && (!item.html || item.html === item.text)}>
<pre class="prompt-review-row-snippet">{item.text}</pre>
</Show>
<Show when={item.html && item.html !== item.text}>
<pre class="prompt-review-row-snippet">{item.html}</pre>
</Show>
<Show when={styles(item)}>{(value) => <div class="prompt-review-row-text">{value()}</div>}</Show>
<Show when={source(item)}>{(value) => <code class="prompt-review-row-path">{value()}</code>}</Show>
</div>
</Show>
</div>
)}
</For>
</div>
</Show>
</div>
)
}
@@ -58,10 +58,11 @@ import {
type SandboxState,
} from "./prompt-input-utils"
import { sandboxMessages } from "./prompt-sandbox-messages"
import type { ReviewCommentEntry, SendMessageFailedMessage, TextPart } from "../../types/messages"
import type { ExtensionMessage, ReviewCommentEntry, SendMessageFailedMessage, TextPart } from "../../types/messages"
import { formatReviewCommentsMarkdown } from "../../utils/review-comment-markdown"
import {
createdDraftKey,
failedPrompt,
movePromptDraft,
pendingDraftKey,
scopeDraftKey,
@@ -69,6 +70,7 @@ import {
} from "../../utils/prompt-drafts"
import {
beginPendingSend,
browserDrafts as references,
clearPendingDraftDiscarded,
clearSessionDraftDiscarded,
drafts,
@@ -82,7 +84,14 @@ import {
scrollDrafts,
} from "../../utils/draft-store"
import { ReviewComments } from "./ReviewComments"
import { partReview, reviewBody } from "../../../../src/shared/review-comments"
import { BrowserReferences } from "./BrowserReferences"
import {
browserFeedbackData,
formatBrowserFeedback,
mergeBrowserReferences,
partFeedback,
type BrowserReference,
} from "../../../../src/shared/browser-feedback"
import { isEnterKeyCommitNotIme } from "../../utils/ime-enter"
import { parseMemoryCommand, type ParsedMemoryCommand } from "../../utils/memory-command"
import { useMemory } from "../../context/memory"
@@ -249,16 +258,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
comments: ReviewCommentEntry[],
imgs: ImageAttachment[],
scroll = textareaRef?.scrollTop ?? scrollDrafts.get(key) ?? 0,
) => savePromptDraft(key, next, comments, imgs, scroll)
browser: BrowserReference[] = browsers(),
) => savePromptDraft(key, next, comments, imgs, scroll, browser)
const readDraft = () => ({
text: text().trim(),
comments: reviewComments(),
images: imageAttach.images(),
browsers: browsers(),
scroll: textareaRef?.scrollTop ?? scrollDrafts.get(draftKey()) ?? 0,
})
const [text, setText] = createSignal("")
const [reviewComments, setReviewComments] = createSignal<ReviewCommentEntry[]>([])
const [browsers, setBrowsers] = createSignal<BrowserReference[]>([])
const [enhancing, setEnhancing] = createSignal(false)
const [autoApprove, setAutoApprove] = createSignal(false)
const [sandboxes, setSandboxes] = createSignal<Record<string, SandboxState>>({})
@@ -384,6 +396,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const clearReviewComments = () => replaceReviewComments([])
const replace = (next: BrowserReference[]) => {
setBrowsers(next)
if (next.length === 0) {
references.delete(draftKey())
return
}
references.set(draftKey(), next)
}
const remove = (id: string) => replace(browsers().filter((item) => item.id !== id))
const clear = () => replace([])
const removeReviewComment = (id: string) => {
replaceReviewComments(reviewComments().filter((item) => item.id !== id))
}
@@ -396,8 +420,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const val = untrack(text)
const comments = untrack(reviewComments)
const imgs = untrack(imageAttach.images)
if (val || comments.length > 0 || imgs.length > 0 || drafts.has(prev)) {
saveDraft(prev, val, comments, imgs)
const browser = untrack(browsers)
if (val || comments.length > 0 || imgs.length > 0 || browser.length > 0 || drafts.has(prev)) {
saveDraft(prev, val, comments, imgs, undefined, browser)
}
}
const draft = drafts.get(key) ?? ""
@@ -411,6 +436,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
mention.seedSessions(refs.sessions, draft)
}
setReviewComments(pending)
setBrowsers(references.get(key) ?? [])
imageAttach.replace(imageDrafts.get(key) ?? [])
setEnhancing(false)
preEnhanceText = null
@@ -442,7 +468,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const parts = session.getParts(m.id)
return parts
.filter((part): part is TextPart => part.type === "text")
.map((part) => partReview(part.metadata, part.text)?.body ?? part.text.replace(REVIEW_PREFIX, ""))
.map((part) => partFeedback(part.metadata, part.text)?.body ?? part.text.replace(REVIEW_PREFIX, ""))
.join("")
})
history.seed(texts)
@@ -487,11 +513,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const draft = text().trim()
const comments = reviewComments()
const imgs = imageAttach.images()
const browser = browsers()
const scroll = textareaRef?.scrollTop ?? 0
const id = tabs?.add()
if (!id) session.clearCurrentSession()
const key = id ? scopeDraftKey(boxKey(), pendingDraftKey(id) ?? "new") : draftKey()
saveDraft(key, draft, comments, imgs, scroll)
saveDraft(key, draft, comments, imgs, scroll, browser)
}
window.addEventListener("newTaskRequest", onNewTaskRequest)
onCleanup(() => window.removeEventListener("newTaskRequest", onNewTaskRequest))
@@ -513,7 +540,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const draft = captured.get(id)
captured.delete(id)
if (!draft) return
saveDraft(scopeDraftKey(box, sessionDraftKey(sid)), draft.text, draft.comments, draft.images, draft.scroll)
saveDraft(
scopeDraftKey(box, sessionDraftKey(sid)),
draft.text,
draft.comments,
draft.images,
draft.scroll,
draft.browsers,
)
}
window.addEventListener("agentManagerApplyDraft", onAgentManagerApplyDraft)
onCleanup(() => window.removeEventListener("agentManagerApplyDraft", onAgentManagerApplyDraft))
@@ -554,7 +588,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isDisabled = () => !server.isConnected() || locked()
const canUseSpeech = () => canUseSpeechToText(config(), provider.authStates())
const speechModel = () => selectedSpeechToTextModel(config(), speechModels.models())
const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0
const hasInput = () =>
text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0 || browsers().length > 0
const sendReady = () => !isDisabled() && !terminal.pending() && !git.pending() && !props.blocked?.()
const canContinue = () => speech.state() === "idle" && !hasInput() && session.canResume()
const canSend = () =>
@@ -653,8 +688,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
const restoreFailed = (failed: SendMessageFailedMessage) => {
const draft = failed.review ? reviewBody(failed.review, failed.text) : failed.text
if (draft === undefined) return
const restored = failedPrompt(failed)
if (!restored) return
const draft = restored.text
if (
(failed.draftID && isPendingDraftDiscarded(failed.draftID)) ||
(failed.sessionID && isSessionDraftDiscarded(failed.sessionID))
@@ -672,7 +708,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
? scopeDraftKey(boxKey(), "new")
: undefined
if (!target) return
const comments = failed.review?.comments ?? []
const comments = restored.comments
const browser = restored.browsers
const images = (failed.files ?? [])
.filter((file) => file.mime.startsWith("image/") && file.url.startsWith("data:"))
.map((file) => ({
@@ -682,12 +719,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
dataUrl: file.url,
}))
if (target !== draftKey()) {
saveDraft(target, draft, comments, images, scrollDrafts.get(target) ?? 0)
saveDraft(target, draft, comments, images, scrollDrafts.get(target) ?? 0, browser)
return
}
// Do not overwrite a new draft the user started while the send was in flight.
if (text().trim() || reviewComments().length > 0 || imageAttach.images().length > 0) return
if (text().trim() || reviewComments().length > 0 || imageAttach.images().length > 0 || browsers().length > 0) return
replaceReviewComments(comments)
replace(browser)
if (draft) {
setText(draft)
mention.seedFromText(draft)
@@ -726,59 +764,94 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}),
})
const restoreBox = (message: Extract<ExtensionMessage, { type: "setChatBoxMessage" }>) => {
setText(message.text)
if (message.paths?.length) mention.seedFromParts(message.paths, message.text)
else mention.seedFromText(message.text)
if (message.sessions?.length) mention.seedSessions(message.sessions, message.text)
if (textareaRef) {
textareaRef.value = message.text
adjustHeight()
}
if (message.review || message.browser) {
replaceReviewComments(message.review ?? [])
replace(message.browser ?? [])
}
if (message.images) {
const imgs = message.images.map((img) => ({
id: crypto.randomUUID(),
filename: img.filename ?? "image",
mime: img.mime,
dataUrl: img.dataUrl,
}))
imageAttach.replace(imgs)
imageDrafts.set(draftKey(), imgs)
}
}
const appendBox = (message: Extract<ExtensionMessage, { type: "appendChatBoxMessage" }>) => {
const reference = message.browser
if (reference) {
if (reference.sessionId !== sid()) return
replace(mergeBrowserReferences(browsers(), reference))
textareaRef?.focus()
return
}
const current = text()
const separator = current && !current.endsWith("\n") ? "\n\n" : ""
const next = current + separator + message.text
setText(next)
if (textareaRef) {
textareaRef.value = next
adjustHeight()
textareaRef.focus()
textareaRef.scrollTop = textareaRef.scrollHeight
syncHighlightScroll()
}
}
const appendReviews = (message: Extract<ExtensionMessage, { type: "appendReviewComments" }>) => {
const empty =
!text().trim() && reviewComments().length === 0 && imageAttach.images().length === 0 && browsers().length === 0
replaceReviewComments(mergeReviewComments(reviewComments(), message.comments))
if (message.autoSend && empty && !isDisabled() && !props.blocked?.()) {
void handleSend()
return
}
textareaRef?.focus()
}
const created = (message: Extract<ExtensionMessage, { type: "sessionCreated" }>) => {
const raw = createdDraftKey(message.draftID, sandboxRequest(undefined) !== undefined)
if (!raw) return
const source = scopeDraftKey(boxKey(), raw)
const target = scopeDraftKey(boxKey(), sessionDraftKey(message.session.id))
if (source === draftKey()) saveDraft(source, text(), reviewComments(), imageAttach.images())
movePromptDraft(
{ text: drafts, comments: reviewDrafts, images: imageDrafts, scrolls: scrollDrafts, browsers: references },
source,
target,
)
if (
message.draftID &&
!session.currentSessionID() &&
(props.pendingSessionID ?? session.draftSessionID()) === message.draftID
) {
session.setDraftSessionID(message.session.id)
}
}
const unsubscribe = vscode.onMessage((message) => {
if (handleSandboxMessage(message)) return
if (message.type === "setChatBoxMessage") {
setText(message.text)
// Prefer the exact attachment paths when available (e.g. reverting to a
// message with @mentions) — seedFromText re-derives candidate mentions
// from raw text via regex, which truncates at the first space in a
// filename and cannot be relied on to reconstruct spaced paths correctly.
if (message.paths?.length) mention.seedFromParts(message.paths, message.text)
else mention.seedFromText(message.text)
if (message.sessions?.length) mention.seedSessions(message.sessions, message.text)
if (textareaRef) {
textareaRef.value = message.text
adjustHeight()
}
// When present, images are authoritative: replace current attachments
// (an empty array clears them, e.g. on redo). Absent leaves them alone.
if (message.images) {
const imgs = message.images.map((img) => ({
id: crypto.randomUUID(),
filename: img.filename ?? "image",
mime: img.mime,
dataUrl: img.dataUrl,
}))
imageAttach.replace(imgs)
imageDrafts.set(draftKey(), imgs)
}
restoreBox(message)
}
if (message.type === "appendChatBoxMessage") {
const current = text()
const separator = current && !current.endsWith("\n") ? "\n\n" : ""
const next = current + separator + message.text
setText(next)
if (textareaRef) {
textareaRef.value = next
adjustHeight()
textareaRef.focus()
textareaRef.scrollTop = textareaRef.scrollHeight
syncHighlightScroll()
}
}
if (message.type === "appendChatBoxMessage") appendBox(message)
if (message.type === "appendReviewComments") {
const empty = !text().trim() && reviewComments().length === 0 && imageAttach.images().length === 0
const merged = mergeReviewComments(reviewComments(), message.comments)
replaceReviewComments(merged)
if (message.autoSend && empty && !isDisabled() && !props.blocked?.()) {
void handleSend()
} else {
textareaRef?.focus()
}
appendReviews(message)
}
if (message.type === "triggerTask") {
@@ -791,26 +864,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
restoreFailed(message as SendMessageFailedMessage)
}
if (message.type === "sessionCreated") {
const raw = createdDraftKey(message.draftID, sandboxRequest(undefined) !== undefined)
if (raw) {
const source = scopeDraftKey(boxKey(), raw)
const target = scopeDraftKey(boxKey(), sessionDraftKey(message.session.id))
if (source === draftKey()) saveDraft(source, text(), reviewComments(), imageAttach.images())
movePromptDraft(
{ text: drafts, comments: reviewDrafts, images: imageDrafts, scrolls: scrollDrafts },
source,
target,
)
}
if (
message.draftID &&
!session.currentSessionID() &&
(props.pendingSessionID ?? session.draftSessionID()) === message.draftID
) {
session.setDraftSessionID(message.session.id)
}
}
if (message.type === "sessionCreated") created(message)
if (message.type === "action" && message.action === "focusInput") {
textareaRef?.focus()
@@ -1078,6 +1132,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const context = ctx()
const value = text()
const comments = reviewComments()
const browser = browsers()
const images = imageAttach.images()
speech.stop({
done: () => void handleSend(),
@@ -1087,6 +1142,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
ctx() === context &&
text() === value &&
reviewComments() === comments &&
browsers() === browser &&
imageAttach.images() === images,
})
}
@@ -1191,6 +1247,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
history.append(draft)
setMemoryText(memory)
clearReviewComments()
clear()
imageAttach.clear()
mention.closeMention()
slash.close()
@@ -1217,6 +1274,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (matched.enabled && !matched.enabled()) return
setText("")
clearReviewComments()
clear()
imageAttach.clear()
mention.closeMention()
slash.close()
@@ -1233,11 +1291,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const imgs = imageAttach.images()
const pending = reviewComments()
const review = pending.length > 0 ? formatReviewCommentsMarkdown(pending) : ""
const browserData = browserFeedbackData(browsers())
const browserText = browserData ? formatBrowserFeedback(browserData.references) : ""
const message = [review, browserText, draft].filter(Boolean).join("\n\n")
if (canSendContinue()) {
session.resume()
return
}
const message = draft && review ? `${review}\n\n${draft}` : draft || review
const data = review ? { version: 1 as const, comments: pending } : undefined
if ((!message && imgs.length === 0) || !sendReady() || speech.active()) return
@@ -1285,7 +1345,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const attachments = allFiles.length > 0 ? allFiles : undefined
// Server-side slash command (cmdMatch/matched already computed above)
if (matched && !data) {
if (matched && !data && !browserData) {
const args = draft.slice(cmdMatch![0].length).trim()
session.sendCommand(
matched.name,
@@ -1303,11 +1363,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
},
)
} else {
session.sendMessage(message, sel?.providerID, sel?.modelID, attachments, pendingId, context, data, origin ?? null)
session.sendMessage(
message,
sel?.providerID,
sel?.modelID,
attachments,
pendingId,
context,
data,
origin ?? null,
browserData,
)
}
drafts.delete(key)
reviewDrafts.delete(key)
references.delete(key)
imageDrafts.delete(key)
mentionDrafts.delete(key)
scrollDrafts.delete(key)
@@ -1317,6 +1388,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
history.reset()
setText("")
clearReviewComments()
setBrowsers([])
imageAttach.clear()
mention.closeMention()
slash.close()
@@ -1346,6 +1418,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onClear={clearReviewComments}
/>
</Show>
<Show when={browsers().length > 0}>
<div data-component="browser-references">
<BrowserReferences references={browsers()} onRemove={remove} onClear={clear} />
</div>
</Show>
<Show when={mention.showMention()}>
<div class="file-mention-dropdown" ref={dropdownRef}>
<Show
@@ -1,7 +1,8 @@
import { createMemo, type Component } from "solid-js"
import { createMemo, Show, type Component } from "solid-js"
import { UserMessageDisplay } from "@kilocode/kilo-ui/message-part"
import { partReview } from "../../../../src/shared/review-comments"
import { partFeedback } from "../../../../src/shared/browser-feedback"
import type { Message, Part, TextPart } from "../../types/messages"
import { BrowserReferences } from "./BrowserReferences"
import { ReviewComments } from "./ReviewComments"
import { useLanguage } from "../../context/language"
@@ -21,22 +22,31 @@ interface VscodeUserMessageProps {
export const VscodeUserMessage: Component<VscodeUserMessageProps> = (props) => {
const language = useLanguage()
const text = createMemo(() => props.parts.find((part): part is TextPart => part.type === "text" && !part.synthetic))
const review = createMemo(() => {
const feedback = createMemo(() => {
const part = text()
if (!part) return undefined
return partReview(part.metadata, part.text)
return partFeedback(part.metadata, part.text)
})
const body = createMemo(() => review()?.body)
const body = createMemo(() => feedback()?.body)
return (
<UserMessageDisplay
message={props.message as unknown as Parameters<typeof UserMessageDisplay>[0]["message"]}
parts={props.parts as unknown as Parameters<typeof UserMessageDisplay>[0]["parts"]}
text={body()}
copyText={review() ? text()?.text : undefined}
copyText={feedback() ? text()?.text : undefined}
header={
review() ? (
<ReviewComments comments={review()!.data.comments} sessionID={props.message.sessionID} variant="message" />
feedback() ? (
<>
<Show when={feedback()?.review}>
{(review) => (
<ReviewComments comments={review().comments} sessionID={props.message.sessionID} variant="message" />
)}
</Show>
<Show when={feedback()?.browserFeedback}>
{(browser) => <BrowserReferences references={browser().references} variant="message" />}
</Show>
</>
) : undefined
}
interrupted={props.interrupted}
@@ -29,9 +29,7 @@ const BrowserTab: Component = () => {
const { globalConfig, projectConfig, updateGlobalConfig } = useConfig()
const [settings, setSettings] = createSignal<BrowserSettings>({
enabled: false,
useSystemChrome: true,
headless: false,
})
onMount(() => {
@@ -120,16 +118,6 @@ const BrowserTab: Component = () => {
{t("settings.browser.description")}
</p>
<Card>
{/* Enable toggle */}
<SettingsRow
title={t("settings.browser.enable.title")}
description={t("settings.browser.enable.description")}
>
<Switch checked={settings().enabled} onChange={(checked: boolean) => update("enabled", checked)} hideLabel>
{t("settings.browser.enable.title")}
</Switch>
</SettingsRow>
{/* Use System Chrome */}
<SettingsRow
title={t("settings.browser.systemChrome.title")}
@@ -143,18 +131,12 @@ const BrowserTab: Component = () => {
{t("settings.browser.systemChrome.title")}
</Switch>
</SettingsRow>
{/* Headless mode */}
<SettingsRow
title={t("settings.browser.headless.title")}
description={t("settings.browser.headless.description")}
last
>
<Switch
checked={settings().headless}
onChange={(checked: boolean) => update("headless", checked)}
hideLabel
>
<Switch checked disabled hideLabel>
{t("settings.browser.headless.title")}
</Switch>
</SettingsRow>
@@ -217,6 +217,19 @@ const ExperimentalTab: Component = () => {
</Switch>
</SettingsRow>
<SettingsRow
title={language.t("settings.browser.enable.title")}
description={language.t("settings.browser.enable.description")}
>
<Switch
checked={settings().browserAutomation === true}
onChange={(checked) => applySetting("browserAutomation", checked, "experimental.browserAutomation")}
hideLabel
>
{language.t("settings.browser.enable.title")}
</Switch>
</SettingsRow>
<SettingsRow
title={language.t("settings.experimental.taskModelSelection.title")}
description={language.t("settings.experimental.taskModelSelection.description")}
@@ -205,7 +205,7 @@ export const ServerProvider: ParentComponent = (props) => {
* user has no way to see the code or cancel if the browser is dismissed.
*/
const goToLogin = () => {
window.postMessage({ type: "navigate", view: "profile" }, "*")
window.postMessage({ type: "navigate", view: "profile" }, window.origin)
startLogin()
}
@@ -1,5 +1,6 @@
import type { Accessor } from "solid-js"
import type { ReviewMessageData } from "../../../src/shared/review-comments"
import type { BrowserFeedbackData } from "../../../src/shared/browser-feedback"
import type {
AgentInfo,
ContextUsage,
@@ -171,6 +172,7 @@ export interface SessionContextValue {
context?: string,
review?: ReviewMessageData,
origin?: string | null,
browserFeedback?: BrowserFeedbackData,
) => void
sendCommand: (
command: string,
@@ -1,5 +1,13 @@
import { reconcile } from "solid-js/store"
import type { Message, MessageLoadMode, Part, ToolPart } from "../types/messages"
import type { FileAttachment, Message, MessageLoadMode, Part, ToolPart } from "../types/messages"
import { Identifier } from "../utils/id"
import {
feedbackMetadata,
partFeedback,
type BrowserFeedbackData,
type BrowserReference,
} from "../../../src/shared/browser-feedback"
import type { ReviewCommentEntry, ReviewMessageData } from "../../../src/shared/review-comments"
export const SNAPSHOT_PROGRESS_TEXT = "Initializing snapshot..."
@@ -34,12 +42,45 @@ export function messageParts(messages: Message[]): Record<string, Part[]> {
return parts
}
export function optimistic(
id: string,
text: string,
files?: FileAttachment[],
review?: ReviewMessageData,
browser?: BrowserFeedbackData,
): Part[] {
const parts: Part[] = []
if (text) {
parts.push({
type: "text",
id: Identifier.ascending("part"),
messageID: id,
text,
metadata: feedbackMetadata(review, browser),
})
}
for (const file of files ?? []) {
parts.push({
type: "file",
id: Identifier.ascending("part"),
messageID: id,
mime: file.mime,
url: file.url,
filename: file.filename,
source: file.source,
})
}
return parts
}
/** Prompt input state rebuilt from a reverted user message's parts. */
export interface RevertPromptState {
text: string
paths: string[]
sessions: Array<{ id: string; title: string; updated: number }>
images: Array<{ dataUrl: string; mime: string; filename?: string }>
review: ReviewCommentEntry[]
browser: BrowserReference[]
}
/**
@@ -49,10 +90,17 @@ export interface RevertPromptState {
*/
export function revertPromptState(parts: readonly Part[]): RevertPromptState {
const files = parts.filter((p): p is Extract<Part, { type: "file" }> => p.type === "file")
const feedback = parts
.filter((p): p is Extract<Part, { type: "text" }> => p.type === "text" && !p.synthetic)
.map((p) => partFeedback(p.metadata, p.text))
.filter((p): p is NonNullable<typeof p> => p !== undefined)
return {
text: parts
.filter((p) => p.type === "text" && !(p as { synthetic?: boolean }).synthetic)
.map((p) => (p as { text: string }).text ?? "")
.map((p) => {
if (p.type !== "text") return ""
return partFeedback(p.metadata, p.text)?.body ?? p.text
})
.join(""),
paths: files.map((p) => p.source?.path).filter((p): p is string => !!p && !p.startsWith("session:")),
sessions: files
@@ -65,6 +113,8 @@ export function revertPromptState(parts: readonly Part[]): RevertPromptState {
images: files
.filter((p) => p.mime.startsWith("image/") && p.url.startsWith("data:"))
.map((p) => ({ dataUrl: p.url, mime: p.mime, filename: p.filename })),
review: feedback.flatMap((p) => p.review?.comments ?? []),
browser: feedback.flatMap((p) => p.browserFeedback?.references ?? []),
}
}
@@ -68,6 +68,7 @@ import {
dropSet,
emptyPageState,
messageParts,
optimistic,
reconcileSessionToolParts,
removeSessionToolPart,
removeSessionToolPartsForMessage,
@@ -87,7 +88,8 @@ import { state as todoState } from "./todo-revert"
import { sessionVariantKeys, transferVariants, variantKey } from "./session-variant-store"
import { createSessionVariants } from "./session-variants"
import { KILO_AUTO, KILO_PROVIDER_ID, parseModelString } from "../../../src/shared/provider-model"
import { reviewMetadata, type ReviewMessageData } from "../../../src/shared/review-comments"
import { type ReviewMessageData } from "../../../src/shared/review-comments"
import type { BrowserFeedbackData } from "../../../src/shared/browser-feedback"
import { activeUserMessageID, removeQueuedMessage, visibleMessages as filterVisibleMessages } from "./session-queue"
import { clearSessionDraftDiscarded, deleteDraftsForSession } from "../utils/draft-store"
import { createAbortState } from "./abort-state"
@@ -2097,6 +2099,7 @@ export const SessionProvider: ParentComponent = (props) => {
text: string,
files?: FileAttachment[],
review?: ReviewMessageData,
browserFeedback?: BrowserFeedbackData,
) {
const now = Date.now()
const temp: Message = {
@@ -2110,32 +2113,7 @@ export const SessionProvider: ParentComponent = (props) => {
pending.add(messageID)
pendingOptimistic.set(sid, pending)
const parts: Part[] = []
const partIds = new Set<string>()
if (text) {
const partId = Identifier.ascending("part")
partIds.add(partId)
parts.push({
type: "text" as const,
id: partId,
messageID,
text,
metadata: review ? reviewMetadata(review) : undefined,
})
}
for (const file of files ?? []) {
const partId = Identifier.ascending("part")
partIds.add(partId)
parts.push({
type: "file" as const,
id: partId,
messageID,
mime: file.mime,
url: file.url,
filename: file.filename,
source: file.source,
})
}
const parts = optimistic(messageID, text, files, review, browserFeedback)
setStore("messages", sid, (msgs = []) => [...msgs, temp])
setStore("parts", messageID, parts)
if (parts.length > 0) optimisticParts.set(messageID, new Set(parts.map((part) => part.id)))
@@ -2152,6 +2130,7 @@ export const SessionProvider: ParentComponent = (props) => {
context?: string,
review?: ReviewMessageData,
origin?: string | null,
browserFeedback?: BrowserFeedbackData,
) {
if (!server.isConnected()) {
console.warn("[Kilo New] Cannot send message: not connected")
@@ -2182,6 +2161,7 @@ export const SessionProvider: ParentComponent = (props) => {
variant: variants.request(scope),
files,
review,
browserFeedback,
})
return
}
@@ -2197,7 +2177,7 @@ export const SessionProvider: ParentComponent = (props) => {
if (!sid && !draftID && effectiveDraftID) agentDrafts.seed(effectiveDraftID)
if (scope) {
clearClose(scope)
addOptimistic(scope, messageID, text, files, review)
addOptimistic(scope, messageID, text, files, review, browserFeedback)
startSubmission(scope, messageID)
if (!sid && (!draftID || draftSessionID() === scope)) {
setUserClearedSession(false)
@@ -2218,6 +2198,7 @@ export const SessionProvider: ParentComponent = (props) => {
variant: variants.request(scope),
files,
review,
browserFeedback,
agentManagerContext: context,
})
}
@@ -2735,14 +2716,12 @@ export const SessionProvider: ParentComponent = (props) => {
// Restore the reverted user message's prompt text and attachments into the
// input. Dispatch as a window message so PromptInput picks it up via onMessage.
const state = revertPromptState(getParts(messageID))
const { text, paths, sessions, images } = state
const { text, paths, sessions, images, review, browser } = state
// Paths carry the attachments' exact locations so PromptInput can seed them
// directly rather than re-deriving mentions from the text via regex, which
// truncates at the first space in a filename (see PromptInput's
// setChatBoxMessage handler).
if (text || paths.length > 0 || sessions.length > 0 || images.length > 0) {
window.postMessage({ type: "setChatBoxMessage", text, paths, sessions, images }, "*")
}
window.postMessage({ type: "setChatBoxMessage", text, paths, sessions, images, review, browser }, window.origin)
vscode.postMessage({ type: "revertSession", sessionID: id, messageID, partID })
}
@@ -2750,7 +2729,7 @@ export const SessionProvider: ParentComponent = (props) => {
const id = currentSessionID()
if (!id) return
// Clear the prompt input on full redo (matching TUI/desktop behavior)
window.postMessage({ type: "setChatBoxMessage", text: "", images: [] }, "*")
window.postMessage({ type: "setChatBoxMessage", text: "", images: [], review: [], browser: [] }, window.origin)
vscode.postMessage({ type: "unrevertSession", sessionID: id })
}
@@ -7,6 +7,7 @@ import { createContext, useContext, onCleanup, ParentComponent, createSignal } f
import type { VSCodeAPI, WebviewMessage, ExtensionMessage } from "../types/messages"
import { ClipboardProvider } from "@kilocode/kilo-ui/context/clipboard"
import { edge } from "../sidebar-position"
import { protect } from "../utils/webview-message"
// Get the VS Code API (only available in webview context)
let vscodeApi: VSCodeAPI | undefined
@@ -43,6 +44,7 @@ interface VSCodeContextValue {
const VSCodeContext = createContext<VSCodeContextValue>()
export const VSCodeProvider: ParentComponent = (props) => {
const release = protect()
const api = getVSCodeAPI()
const handlers = new Set<(message: ExtensionMessage) => void>()
const copies = new Map<string, { resolve: () => void; reject: (err: Error) => void }>()
@@ -97,6 +99,7 @@ export const VSCodeProvider: ParentComponent = (props) => {
api.postMessage({ type: "requestModelSelectorExpanded" })
onCleanup(() => {
release()
window.removeEventListener("message", messageListener)
window.removeEventListener("focus", reportFocus)
window.removeEventListener("blur", reportFocus)
@@ -76,7 +76,7 @@ export function useSlashCommand(
hints: ["clear"],
action: () => {
window.dispatchEvent(new CustomEvent("newTaskRequest"))
window.postMessage({ type: "navigate", view: "newTask" }, "*")
window.postMessage({ type: "navigate", view: "newTask" }, window.origin)
},
},
{
@@ -84,7 +84,7 @@ export function useSlashCommand(
description: "Switch to another session",
hints: ["resume", "continue", "history"],
action: () => {
window.postMessage({ type: "navigate", view: "history" }, "*")
window.postMessage({ type: "navigate", view: "history" }, window.origin)
},
},
{
@@ -795,14 +795,15 @@ export const dict = {
"settings.agentBehaviour.subtab.skills": "Skills",
"settings.browser.description":
"When enabled, the AI agent can interact with web pages — navigating, clicking, typing, and taking screenshots. A Chrome window will open so you can watch the agent work.",
"Configure the browser runtime used by the experimental Agent Manager browser. The page preview stays in Agent Manager.",
"settings.browser.enable.title": "Enable Browser Automation",
"settings.browser.enable.description": "Register the Playwright MCP server with the CLI backend.",
"settings.browser.enable.description": "Enable the session-scoped Agent Manager browser for local applications.",
"settings.browser.systemChrome.title": "Use System Chrome",
"settings.browser.systemChrome.description":
"Use your installed Chrome browser instead of a separate Chromium instance.",
"Use installed Google Chrome. Disable only when a compatible Playwright Chromium browser is already installed.",
"settings.browser.headless.title": "Headless Mode",
"settings.browser.headless.description": "Run in headless mode (no visible browser window).",
"settings.browser.headless.description":
"Agent Manager always runs the browser headlessly. The page preview appears in the Browser panel.",
"settings.language.description":
'Choose the language for the Kilo Code UI. "Auto" uses your VS Code display language.',
@@ -32,6 +32,7 @@ import { ServerContext } from "../context/server"
import { WorktreeModeProvider } from "../context/worktree-mode"
import type {
Message,
BrowserReference,
Part,
QuestionRequest,
ReviewComment,
@@ -42,6 +43,7 @@ import type {
ToolPart,
} from "../types/messages"
import { formatReviewCommentsMarkdown } from "../utils/review-comment-markdown"
import { feedbackMetadata, formatBrowserFeedback } from "../../../src/shared/browser-feedback"
import { reviewMetadata } from "../../../src/shared/review-comments"
const SESSION_ID = "story-session-chat-001"
@@ -266,6 +268,28 @@ function reviewMessage(comments: ReviewCommentEntry[]) {
return <VscodeUserMessage message={message} parts={parts} />
}
function browserMessage(references: BrowserReference[]) {
const data = { version: 1 as const, references }
const message: Message = {
id: "browser-user-message",
sessionID: SESSION_ID,
role: "user",
createdAt: new Date(0).toISOString(),
time: { created: 0 },
}
const parts: Part[] = [
{
id: "browser-user-part",
sessionID: SESSION_ID,
messageID: message.id,
type: "text",
text: `${formatBrowserFeedback(references)}\n\nPlease fix the selected browser elements.`,
metadata: feedbackMetadata(undefined, data),
},
]
return <VscodeUserMessage message={message} parts={parts} />
}
export const UserMessageReviewComments: Story = {
name: "User message — interactive review comments",
render: () => {
@@ -320,6 +344,39 @@ export const UserMessageManyReviewComments: Story = {
},
}
export const UserMessageBrowserFeedback: Story = {
name: "User message — browser feedback",
render: () => (
<StoryProviders sessionID={SESSION_ID} status="idle">
<div style={{ "max-height": "620px", padding: "12px" }}>
{browserMessage([
{
id: "browser-1",
sessionId: SESSION_ID,
selector: "main > button.save",
url: "https://example.com/settings",
title: "Settings",
hierarchy: ["main", "button.save"],
text: "Save settings",
html: '<button class="save">Save settings</button>',
styles: { color: "rgb(30, 30, 30)", backgroundColor: "white" },
source: { file: "src/settings.tsx", line: 42, column: 7 },
},
{
id: "browser-2",
sessionId: SESSION_ID,
selector: "form input[name=email]",
url: "https://example.com/settings",
title: "Settings",
hierarchy: ["main", "form", "input[name=email]"],
text: "Email address",
},
])}
</div>
</StoryProviders>
),
}
/**
* ChatView with a pending question tool call and an empty input.
*
@@ -7,6 +7,7 @@ import type { Meta, StoryObj } from "storybook-solidjs-vite"
import { onMount, type Component } from "solid-js"
import MigrationWizard from "../components/migration/MigrationWizard"
import { StoryProviders } from "./StoryProviders"
import { post } from "../utils/webview-message"
const meta: Meta = {
title: "Migration",
@@ -20,30 +21,26 @@ const operationId = "roo-migration-story"
const RooWizard: Component = () => {
onMount(() => {
queueMicrotask(() => {
window.dispatchEvent(
new MessageEvent("message", {
data: {
type: "migrationData",
source: "roo",
operationId,
data: {
providers: [],
mcpServers: [],
customModes: [],
sessions: [
{
id: "roo-1",
title: "Refactor authentication flow",
directory: "/workspace/app",
time: 1760443200000,
},
{ id: "roo-2", title: "Add billing dashboard", directory: "/workspace/app", time: 1760356800000 },
{ id: "roo-3", title: "Investigate flaky tests", directory: "/workspace/app", time: 1760270400000 },
],
post({
type: "migrationData",
source: "roo",
operationId,
data: {
providers: [],
mcpServers: [],
customModes: [],
sessions: [
{
id: "roo-1",
title: "Refactor authentication flow",
directory: "/workspace/app",
time: 1760443200000,
},
},
}),
)
{ id: "roo-2", title: "Add billing dashboard", directory: "/workspace/app", time: 1760356800000 },
{ id: "roo-3", title: "Investigate flaky tests", directory: "/workspace/app", time: 1760270400000 },
],
},
})
})
})
@@ -125,9 +125,7 @@ export type KiloEmbeddingModelCatalog = {
export type IndexingStatus = SdkIndexingStatus
export interface BrowserSettings {
enabled: boolean
useSystemChrome: boolean
headless: boolean
}
export type TerminalCommandDisplay = "expanded" | "collapsed"

Some files were not shown because too many files have changed in this diff Show More