diff --git a/.changeset/agent-manager-browser-context.md b/.changeset/agent-manager-browser-context.md new file mode 100644 index 00000000000..37018846975 --- /dev/null +++ b/.changeset/agent-manager-browser-context.md @@ -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. diff --git a/bun.lock b/bun.lock index c7754cace12..d5aef8fc114 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/user-message-browser-feedback-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/user-message-browser-feedback-chromium-linux.png new file mode 100644 index 00000000000..7585371f92c --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/user-message-browser-feedback-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d023c0c87ae72134ae6f391cbe263921e19d2411c7ef74466b134d88a3193c42 +size 11581 diff --git a/packages/kilo-vscode/esbuild.js b/packages/kilo-vscode/esbuild.js index ec5b59f98bd..dffab706be1 100644 --- a/packages/kilo-vscode/esbuild.js +++ b/packages/kilo-vscode/esbuild.js @@ -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() diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 64027e1d602..90931c7ebda 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -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" }, diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index a9814cbad00..e854d119c87 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -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[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 { + 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 { 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("multiProject", false) } + private browserAutomationSetting(): boolean { + return vscode.workspace.getConfiguration("kilo-code.new.experimental").get("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 { 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 { if (key === "maxCost") { @@ -4515,9 +4553,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage({ type: "browserSettingsLoaded", settings: { - enabled: config.get("enabled", false), useSystemChrome: config.get("useSystemChrome", true), - headless: config.get("headless", false), }, }) } diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 77e595d8d27..4894341d9e4 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -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 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): Promise | 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, ): Record | 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 { 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?.() diff --git a/packages/kilo-vscode/src/agent-manager/browser-lifecycle.ts b/packages/kilo-vscode/src/agent-manager/browser-lifecycle.ts new file mode 100644 index 00000000000..62dcc454b79 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/browser-lifecycle.ts @@ -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 { + return Promise.all([...browser.sessions()].map((sessionId) => browser.close(sessionId))).then(() => undefined) + }, + dispose(): Promise { + off() + return browser.disposeAsync() + }, + } +} diff --git a/packages/kilo-vscode/src/agent-manager/browser-message.ts b/packages/kilo-vscode/src/agent-manager/browser-message.ts new file mode 100644 index 00000000000..81f822c6a53 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/browser-message.ts @@ -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 + +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, + } +} diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts index 45d3f1ee2e9..134b7833a54 100644 --- a/packages/kilo-vscode/src/agent-manager/host.ts +++ b/packages/kilo-vscode/src/agent-manager/host.ts @@ -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 diff --git a/packages/kilo-vscode/src/agent-manager/project/state-gate.ts b/packages/kilo-vscode/src/agent-manager/project/state-gate.ts index 876ca9b1277..2812a7b634c 100644 --- a/packages/kilo-vscode/src/agent-manager/project/state-gate.ts +++ b/packages/kilo-vscode/src/agent-manager/project/state-gate.ts @@ -33,4 +33,11 @@ export const STATE_GATED = new Set([ "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", ]) diff --git a/packages/kilo-vscode/src/agent-manager/project/wiring.ts b/packages/kilo-vscode/src/agent-manager/project/wiring.ts index 065c6c71b6e..2ae51fb0c8f 100644 --- a/packages/kilo-vscode/src/agent-manager/project/wiring.ts +++ b/packages/kilo-vscode/src/agent-manager/project/wiring.ts @@ -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 = { diff --git a/packages/kilo-vscode/src/agent-manager/session-lifecycle.ts b/packages/kilo-vscode/src/agent-manager/session-lifecycle.ts new file mode 100644 index 00000000000..7aa99831599 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/session-lifecycle.ts @@ -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 + removed: Set + 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) +} diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index ba7ac6d4ddc..07240a4ee36 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -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 diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index a015fc45af8..8cee9c6d0f2 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -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") } diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index dcb13e2b342..7e7bfff1625 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -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(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() diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts b/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts index e978f40576f..f64652dfe4d 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts @@ -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 { 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, }) } } diff --git a/packages/kilo-vscode/src/services/browser-automation/browser-automation-service.ts b/packages/kilo-vscode/src/services/browser-automation/browser-automation-service.ts deleted file mode 100644 index 5afdf98a8dd..00000000000 --- a/packages/kilo-vscode/src/services/browser-automation/browser-automation-service.ts +++ /dev/null @@ -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 { - const config = vscode.workspace.getConfiguration("kilo-code.new.browserAutomation") - const enabled = config.get("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 { - const config = vscode.workspace.getConfiguration("kilo-code.new.browserAutomation") - const enabled = config.get("enabled", false) - if (enabled) { - await this.register() - } - } - - /** - * Register the Playwright MCP server with the CLI backend. - */ - private async register(): Promise { - 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("useSystemChrome", true) - const headless = config.get("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 { - 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 = [] - } -} diff --git a/packages/kilo-vscode/src/services/browser-automation/browser-broker.ts b/packages/kilo-vscode/src/services/browser-automation/browser-broker.ts new file mode 100644 index 00000000000..7556fd28c65 --- /dev/null +++ b/packages/kilo-vscode/src/services/browser-automation/browser-broker.ts @@ -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 + useSystemChrome?: () => boolean +} + +export interface BrowserContextFactory { + debugging?: number + newContext(options: { serviceWorkers: "block"; viewport: { width: number; height: number } }): Promise + close(): Promise +} + +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 { + 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 | 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 { + 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() + private readonly pending = new Map>() + 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() + private port: number | undefined + private debugging: number | undefined + private tools: BrowserDevtools | undefined + private browser: BrowserContextFactory | undefined + private browserStarting: Promise | undefined + private starting: Promise | undefined + private closed = false + + constructor(private readonly opts: BrowserBrokerOptions) {} + + async start(): Promise { + 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((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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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((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 { + 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 { + 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 { + 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 { + entry.state.url = entry.page.url() + entry.state.title = await entry.page.title().catch(() => undefined) + } + + private async capture(entry: Entry): Promise { + 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(session: string, operation: () => Promise): Promise { + 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 { + 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) }) + } + } +} diff --git a/packages/kilo-vscode/src/services/browser-automation/browser-devtools.ts b/packages/kilo-vscode/src/services/browser-automation/browser-devtools.ts new file mode 100644 index 00000000000..8edbcc5f5bd --- /dev/null +++ b/packages/kilo-vscode/src/services/browser-automation/browser-devtools.ts @@ -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 + inspecting: Set +} + +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() + 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 { + 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( + / + ` diff --git a/packages/kilo-vscode/src/webview-html-utils.ts b/packages/kilo-vscode/src/webview-html-utils.ts index ffe6e0b7168..29f946c0fca 100644 --- a/packages/kilo-vscode/src/webview-html-utils.ts +++ b/packages/kilo-vscode/src/webview-html-utils.ts @@ -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) } diff --git a/packages/kilo-vscode/tests/fixtures/browser-panel-render.tsx b/packages/kilo-vscode/tests/fixtures/browser-panel-render.tsx new file mode 100644 index 00000000000..c3ad9695fbc --- /dev/null +++ b/packages/kilo-vscode/tests/fixtures/browser-panel-render.tsx @@ -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({ + 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( + () => ( + 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() diff --git a/packages/kilo-vscode/tests/fixtures/pr-comments-render.tsx b/packages/kilo-vscode/tests/fixtures/pr-comments-render.tsx index e9bb18b8b11..890d378b4f0 100644 --- a/packages/kilo-vscode/tests/fixtures/pr-comments-render.tsx +++ b/packages/kilo-vscode/tests/fixtures/pr-comments-render.tsx @@ -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() { diff --git a/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx index 36c70420256..dde104a8040 100644 --- a/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx +++ b/packages/kilo-vscode/tests/fixtures/session-provider-activity.tsx @@ -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() diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index db790883765..0e50c34c036 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -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)") }) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-i18n.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-i18n.test.ts index 50f79582104..1c22dbdd69f 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-i18n.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-i18n.test.ts @@ -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"), diff --git a/packages/kilo-vscode/tests/unit/agent-manager-tab-bar.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-tab-bar.test.ts index 2a61200a11b..bfe9af64a75 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-tab-bar.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-tab-bar.test.ts @@ -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("") + const end = source.indexOf("", 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(" { + 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")}') diff --git a/packages/kilo-vscode/tests/unit/browser-broker.test.ts b/packages/kilo-vscode/tests/unit/browser-broker.test.ts new file mode 100644 index 00000000000..8fbd650eed6 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/browser-broker.test.ts @@ -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(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 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('') + 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((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('') + 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() + 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((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((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((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((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((resolve) => websocket.close(() => resolve())) + await new Promise((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 }> = [] + const listeners = new Map void>() + let routeHandler: + | (( + route: { continue: () => Promise; abort: () => Promise }, + request: { url: () => string; isNavigationRequest?: () => boolean }, + ) => Promise) + | 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,", 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 = {} + 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((resolve) => { + release = resolve + }) + const navigating = new Promise((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((resolve) => { + resume = resolve + }) + const starting = new Promise((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 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 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 }) => Promise) + | 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, + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/browser-controller.test.ts b/packages/kilo-vscode/tests/unit/browser-controller.test.ts new file mode 100644 index 00000000000..ada7e3dd454 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/browser-controller.test.ts @@ -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() + const cancelled: number[] = [] + let nextFrame = 0 + let setScope: (value: BrowserScope | undefined) => void = () => {} + let controller!: ReturnType + let dispose!: () => void + createRoot((rootDispose) => { + dispose = rootDispose + const [scope, update] = createSignal({ + 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() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/browser-element.test.ts b/packages/kilo-vscode/tests/unit/browser-element.test.ts new file mode 100644 index 00000000000..4ec3fb146ae --- /dev/null +++ b/packages/kilo-vscode/tests/unit/browser-element.test.ts @@ -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 | 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( + '
', + ".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('') + }) + + test("prefers stable test ids and does not trust duplicate element ids", () => { + const stable = inspect('', "[data-testid]") + expect(stable.result.selector).toBe('button[data-testid="checkout"]') + const duplicate = inspect( + '
', + "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( + '', + "#save", + ) + expect(selected.result.text).toBe("Save") + expect(selected.result.html).toBe('') + expect(JSON.stringify(selected.result)).not.toContain("secret") + const password = inspect('', "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") + expect(selected.result.styles?.backgroundColor).toBe("rgb(22, 163, 74)") + expect(selected.result.html).not.toContain("style=") + const hover = inspect('', "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( + `
`, + "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) => + `
${"
".repeat(depth)}${"
".repeat(depth)}
` + 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( + `
${"".repeat(300)}late-private-text
`, + "main", + ) + expect(selected.result.text).toBeUndefined() + expect(selected.result.html).toBe('
') + }) + + 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 }) + } + }) +}) diff --git a/packages/kilo-vscode/tests/unit/browser-feedback.test.ts b/packages/kilo-vscode/tests/unit/browser-feedback.test.ts new file mode 100644 index 00000000000..870a51290be --- /dev/null +++ b/packages/kilo-vscode/tests/unit/browser-feedback.test.ts @@ -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 => ({ + 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: '', + 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", + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/browser-message.test.ts b/packages/kilo-vscode/tests/unit/browser-message.test.ts new file mode 100644 index 00000000000..2e494ed4ae7 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/browser-message.test.ts @@ -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((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() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/browser-panel-render.test.ts b/packages/kilo-vscode/tests/unit/browser-panel-render.test.ts new file mode 100644 index 00000000000..7facef57162 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/browser-panel-render.test.ts @@ -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 = { + "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) + } + }) +}) diff --git a/packages/kilo-vscode/tests/unit/browser-runtime.test.ts b/packages/kilo-vscode/tests/unit/browser-runtime.test.ts new file mode 100644 index 00000000000..40c93105c43 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/browser-runtime.test.ts @@ -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"]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/draft-store.test.ts b/packages/kilo-vscode/tests/unit/draft-store.test.ts index 181c5f5e4b7..fa640137caf 100644 --- a/packages/kilo-vscode/tests/unit/draft-store.test.ts +++ b/packages/kilo-vscode/tests/unit/draft-store.test.ts @@ -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") diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts index ebb3ad18332..340473b836c 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts @@ -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 diff --git a/packages/kilo-vscode/tests/unit/project-state-handlers.test.ts b/packages/kilo-vscode/tests/unit/project-state-handlers.test.ts index 0825b116ba3..fcff0110a36 100644 --- a/packages/kilo-vscode/tests/unit/project-state-handlers.test.ts +++ b/packages/kilo-vscode/tests/unit/project-state-handlers.test.ts @@ -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) }) }) diff --git a/packages/kilo-vscode/tests/unit/prompt-drafts.test.ts b/packages/kilo-vscode/tests/unit/prompt-drafts.test.ts index 76052e1211c..14c4b46e23b 100644 --- a/packages/kilo-vscode/tests/unit/prompt-drafts.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-drafts.test.ts @@ -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) }) }) diff --git a/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts b/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts index ec6b4fbd84f..14065539f19 100644 --- a/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts @@ -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", () => { diff --git a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts index c0f6e5e2da8..d031bb96f45 100644 --- a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts @@ -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) diff --git a/packages/kilo-vscode/tests/unit/session-lifecycle.test.ts b/packages/kilo-vscode/tests/unit/session-lifecycle.test.ts new file mode 100644 index 00000000000..ad718529ee2 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-lifecycle.test.ts @@ -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(), + 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) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-utils.test.ts b/packages/kilo-vscode/tests/unit/session-utils.test.ts index a401bb2ab1e..1c2ce83e5b3 100644 --- a/packages/kilo-vscode/tests/unit/session-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/session-utils.test.ts @@ -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, + }) }) }) diff --git a/packages/kilo-vscode/tests/unit/webview-html.test.ts b/packages/kilo-vscode/tests/unit/webview-html.test.ts index d54c99ff8cf..0191bc64a18 100644 --- a/packages/kilo-vscode/tests/unit/webview-html.test.ts +++ b/packages/kilo-vscode/tests/unit/webview-html.test.ts @@ -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(";") diff --git a/packages/kilo-vscode/tests/unit/webview-message-trust.test.ts b/packages/kilo-vscode/tests/unit/webview-message-trust.test.ts new file mode 100644 index 00000000000..9aa48dc36ca --- /dev/null +++ b/packages/kilo-vscode/tests/unit/webview-message-trust.test.ts @@ -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) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 4030ef17dd3..704ae87ec95 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -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() const [projectList, setProjectList] = createSignal([]) const [multiProject, setMultiProject] = createSignal(false) + const [currentProjectId, setCurrentProjectId] = createSignal() const [projectStates, setProjectStates] = createSignal>({}) 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)} /> + {browser.render(session.currentSessionID, activeProjectId)} 0}> { ) } -export const AgentManagerApp: Component = () => { - return ( - - - - - - - - - - - - - - ) -} +export const AgentManagerApp: Component = () => ( + + + + + + + + + + + + + +) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/BrowserPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/BrowserPanel.tsx new file mode 100644 index 00000000000..ca32da4a153 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/BrowserPanel.tsx @@ -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, + panel: Setter, + history: Setter, + review: Setter, +) { + 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) => ({ + browser: configure, + current, + closeBrowser: close, + openBrowser: open, + }), + render: (session: Accessor, project: Accessor) => ( + + + + ), + } +} + +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 + projectId: Accessor + 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 ( + { + const session = props.sessionId() + return session ? scope(session, props.projectId()) : undefined + }} + transport={transport} + labels={labels()} + theme={theme} + onReference={reference} + onClose={props.onClose} + /> + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx b/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx index d1d6813409b..5991d665854 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/TabBar.tsx @@ -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 = (props) => ( + + + + + ) })()} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts index 359fbe357b9..efb0cdd7d33 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -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.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts index 935efbf5309..3d047b4e1f8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -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.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts index b2ccf4ce7e1..975e13a242b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -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.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts index 4c060fc44ca..d78bd7afb5f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -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.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts index 91ced0592d9..73f045e45db 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -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": diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts index a885178ff3f..d531e94facb 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -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...", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts index d8d1ee92f16..7ec2a010c42 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -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": diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts index 384721115fb..3356516cfd6 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fa.ts @@ -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' را در ترمینال اجرا کنید.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts index ded5b2fa1cc..bbd3595386c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -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": diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts index feac34121b8..8e0cf1bd541 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/it.ts @@ -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": diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts index 6354108a8e0..7066b41e260 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -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": diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts index 8be5f56b8fc..95ac113b400 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -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'을 실행하세요.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts index 9f142e69e9d..5da00dcc631 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -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.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts index ed8e270a8ff..e09e2bbaedc 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -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.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts index 62583d9ba31..e45f1b49bc5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -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.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts index de9e60aa938..3ee764f189b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -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.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts index 86de0544e50..2fe6967c851 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts index 90f3da62883..c455aed8084 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -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": diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts index 11aa8d8af14..efb3afb6660 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -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.", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts index aeaf425a7dd..2350dce4e3c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -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", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts index d6030bb2b27..a0f95aedb08 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -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 狀態。", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/initial-message.ts b/packages/kilo-vscode/webview-ui/agent-manager/initial-message.ts index 925d79fa3b1..bbfaf0f3e2e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/initial-message.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/initial-message.ts @@ -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, } } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/state-handlers.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/state-handlers.ts index 742bee0b4bc..0387965a1e1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project/state-handlers.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/state-handlers.ts @@ -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 } } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/side-panel-layout.ts b/packages/kilo-vscode/webview-ui/agent-manager/side-panel-layout.ts index 43f901dae4a..4d7b882b6bf 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/side-panel-layout.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/side-panel-layout.ts @@ -12,6 +12,7 @@ export enum SidePanel { Subagents = "subagents", EditPreview = "edit-preview", Documents = "documents", + Browser = "browser", } function viewportWidth(viewport: number): number { diff --git a/packages/kilo-vscode/webview-ui/browser/BrowserPanel.tsx b/packages/kilo-vscode/webview-ui/browser/BrowserPanel.tsx new file mode 100644 index 00000000000..eb6bbfc648c --- /dev/null +++ b/packages/kilo-vscode/webview-ui/browser/BrowserPanel.tsx @@ -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 ( +
+ + + +
{ + event.preventDefault() + if (props.active && props.controller.url().trim() && !props.controller.loading()) props.controller.open() + }} + > + + + + event.currentTarget.select()} + /> + + + + + + + +
+ + + +
+ + + +
+ ) +} + +const Picker: Component<{ + active: boolean + controller: BrowserController + labels: BrowserLabels +}> = (props) => { + const bounds = () => props.controller.hovered()?.element?.rect + return ( + +