mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge pull request #13501 from Kilo-Org/agent-browser-context-view
feat(agent-manager): add local browser previews and element feedback
This commit is contained in:
@@ -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.
|
||||
@@ -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=="],
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d023c0c87ae72134ae6f391cbe263921e19d2411c7ef74466b134d88a3193c42
|
||||
size 11581
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -143,7 +143,8 @@ import {
|
||||
import { fetchAndSendPendingSuggestions } from "./kilo-provider/handlers/suggestion"
|
||||
import { nativeTitle } from "./kilo-provider/native-tab-title"
|
||||
import { isActivity, type Activity } from "../webview-ui/src/utils/session-activity"
|
||||
import { parseReview, reviewMetadata, type ReviewMessageData } from "./shared/review-comments"
|
||||
import { type ReviewMessageData } from "./shared/review-comments"
|
||||
import { feedbackMetadata, parseFeedback, type BrowserFeedbackData } from "./shared/browser-feedback"
|
||||
import { completesWithoutStatus } from "./kilo-provider/command-completion"
|
||||
import { KiloProviderMemory } from "./kilo-provider/memory"
|
||||
|
||||
@@ -203,6 +204,29 @@ type TypedWebviewMessage = {
|
||||
type: string
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
type WebviewMessage = Parameters<Parameters<vscode.Webview["onDidReceiveMessage"]>[0]>[0]
|
||||
|
||||
function feedbackMessage(message: { text: string; review?: unknown; browserFeedback?: unknown }) {
|
||||
return parseFeedback({ review: message.review, browserFeedback: message.browserFeedback }, message.text)
|
||||
}
|
||||
|
||||
type SendWebviewMessage = {
|
||||
type: "sendMessage"
|
||||
text: string
|
||||
messageID?: unknown
|
||||
sessionID?: string
|
||||
draftID?: unknown
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
agent?: string
|
||||
variant?: string
|
||||
files?: unknown
|
||||
review?: unknown
|
||||
browserFeedback?: unknown
|
||||
agentManagerContext?: unknown
|
||||
contextDirectory?: unknown
|
||||
}
|
||||
type SandboxSupportClient = {
|
||||
support: (
|
||||
parameters: { directory?: string },
|
||||
@@ -1115,21 +1139,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.readyResolvers.splice(0).forEach((r) => r())
|
||||
break
|
||||
case "sendMessage": {
|
||||
const msg = message as typeof message & ContextMessage
|
||||
await this.handleSendMessage(
|
||||
message.text,
|
||||
typeof message.messageID === "string" ? message.messageID : undefined,
|
||||
message.sessionID,
|
||||
typeof message.draftID === "string" ? message.draftID : undefined,
|
||||
message.providerID,
|
||||
message.modelID,
|
||||
message.agent,
|
||||
message.variant,
|
||||
parseMessageFiles(message.files),
|
||||
parseReview(message.review, message.text),
|
||||
typeof message.agentManagerContext === "string" ? message.agentManagerContext : undefined,
|
||||
typeof msg.contextDirectory === "string" ? msg.contextDirectory : undefined,
|
||||
)
|
||||
await this.sendWebviewMessage(message as SendWebviewMessage)
|
||||
break
|
||||
}
|
||||
case "sendCommand": {
|
||||
@@ -1481,6 +1491,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
break
|
||||
case "importAndSend": {
|
||||
const files = parseMessageFiles(message.files)
|
||||
const feedback = feedbackMessage(message)
|
||||
void handleImportAndSend(
|
||||
this.cloudSessionCtx,
|
||||
message.cloudSessionId,
|
||||
@@ -1491,9 +1502,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
message.agent,
|
||||
message.variant,
|
||||
files,
|
||||
parseReview(message.review, message.text),
|
||||
feedback?.review,
|
||||
typeof message.command === "string" ? message.command : undefined,
|
||||
typeof message.commandArgs === "string" ? message.commandArgs : undefined,
|
||||
feedback?.browserFeedback,
|
||||
)
|
||||
break
|
||||
}
|
||||
@@ -1571,6 +1583,25 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.webviewMessageDisposable = watchWorkStyleConfig((msg) => this.postMessage(msg), this.webviewMessageDisposable)
|
||||
}
|
||||
|
||||
private async sendWebviewMessage(message: SendWebviewMessage): Promise<void> {
|
||||
const feedback = feedbackMessage(message)
|
||||
await this.handleSendMessage(
|
||||
message.text,
|
||||
typeof message.messageID === "string" ? message.messageID : undefined,
|
||||
message.sessionID,
|
||||
typeof message.draftID === "string" ? message.draftID : undefined,
|
||||
message.providerID,
|
||||
message.modelID,
|
||||
message.agent,
|
||||
message.variant,
|
||||
parseMessageFiles(message.files),
|
||||
feedback?.review,
|
||||
typeof message.agentManagerContext === "string" ? message.agentManagerContext : undefined,
|
||||
typeof message.contextDirectory === "string" ? message.contextDirectory : undefined,
|
||||
feedback?.browserFeedback,
|
||||
)
|
||||
}
|
||||
|
||||
private async handleProfileDataMessage(message: TypedWebviewMessage): Promise<boolean> {
|
||||
if (message.type === "refreshProfile") {
|
||||
await handleRefreshProfile(this.authCtx)
|
||||
@@ -3726,6 +3757,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
return vscode.workspace.getConfiguration("kilo-code.new.experimental").get<boolean>("multiProject", false)
|
||||
}
|
||||
|
||||
private browserAutomationSetting(): boolean {
|
||||
return vscode.workspace.getConfiguration("kilo-code.new.experimental").get<boolean>("browserAutomation", false)
|
||||
}
|
||||
|
||||
private async sendIndexingSettings(projectId?: string) {
|
||||
if (!this.extensionContext) {
|
||||
this.postMessage(buildIndexingSettingsMessage())
|
||||
@@ -3788,6 +3823,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
maxCost: this.maxCostSetting(),
|
||||
languageCommitMessage: this.commitMessageLanguageSetting(),
|
||||
multiProject: this.multiProjectSetting(),
|
||||
browserAutomation: this.browserAutomationSetting(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4008,6 +4044,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
review?: ReviewMessageData,
|
||||
context?: string,
|
||||
contextDirectory?: string,
|
||||
browserFeedback?: BrowserFeedbackData,
|
||||
): Promise<void> {
|
||||
if (!this.client) {
|
||||
this.postMessage({
|
||||
@@ -4019,6 +4056,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
messageID,
|
||||
files,
|
||||
review,
|
||||
browserFeedback,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -4040,7 +4078,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
parts.push({ type: "file", mime: f.mime, url: f.url, filename: f.filename, source: f.source })
|
||||
}
|
||||
}
|
||||
parts.push({ type: "text", text, metadata: review ? reviewMetadata(review) : undefined })
|
||||
parts.push({ type: "text", text, metadata: feedbackMetadata(review, browserFeedback) })
|
||||
|
||||
const editorContext = await this.gatherEditorContext(dir)
|
||||
if (draftID && this.closedDrafts.delete(draftID)) {
|
||||
@@ -4082,6 +4120,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
messageID,
|
||||
files,
|
||||
review,
|
||||
browserFeedback,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4416,7 +4455,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
/**
|
||||
* Handle a generic setting update from the webview.
|
||||
* The key uses dot notation relative to `kilo-code.new` (e.g. "browserAutomation.enabled").
|
||||
*/
|
||||
private async handleUpdateSetting(key: string, value: unknown): Promise<void> {
|
||||
if (key === "maxCost") {
|
||||
@@ -4515,9 +4553,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.postMessage({
|
||||
type: "browserSettingsLoaded",
|
||||
settings: {
|
||||
enabled: config.get<boolean>("enabled", false),
|
||||
useSystemChrome: config.get<boolean>("useSystemChrome", true),
|
||||
headless: config.get<boolean>("headless", false),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import type { KiloClient, Session } from "@kilocode/sdk/v2/client"
|
||||
import type { KiloConnectionService } from "../services/cli-backend"
|
||||
import { getErrorMessage, sessionToWebview } from "../kilo-provider-utils"
|
||||
import { samePath } from "./project/paths"
|
||||
import { getErrorMessage } from "../kilo-provider-utils"
|
||||
import { resolveLocalDiffTarget } from "../diff/shared/target"
|
||||
import { DiffSourceCatalog } from "../diff/sources/catalog"
|
||||
import { getDiffMarkdownRender, setDiffMarkdownRender } from "../review-settings"
|
||||
@@ -81,6 +80,9 @@ import { ProjectScope } from "./project/scope"
|
||||
import type { AgentManagerOutMessage, AgentManagerInMessage } from "./types"
|
||||
import type { Host, PanelContext, OutputHandle, Disposable } from "./host"
|
||||
import { focusPanelPrompt, revealPanel } from "./focus-panel"
|
||||
import type { BrowserBroker } from "../services/browser-automation"
|
||||
import { createBrowserLifecycle } from "./browser-lifecycle"
|
||||
import { handleSessionLifecycle } from "./session-lifecycle"
|
||||
export class AgentManagerProvider implements Disposable {
|
||||
public static readonly viewType = "kilo-code.new.AgentManagerPanel"
|
||||
private panel: PanelContext | undefined
|
||||
@@ -122,6 +124,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
readonly settings: ProjectWiring["settings"]
|
||||
/** Session ID most recently loaded via `loadMessages`; updated synchronously. */
|
||||
private activeSessionId: string | undefined
|
||||
private readonly browserLifecycle: ReturnType<typeof createBrowserLifecycle>
|
||||
private visiblePresence = new AgentManagerVisiblePresence(
|
||||
(ids) => this.connectionService.registerVisible("agent-manager", ids),
|
||||
() => this.panel?.visible ?? false,
|
||||
@@ -131,7 +134,16 @@ export class AgentManagerProvider implements Disposable {
|
||||
private readonly host: Host,
|
||||
private readonly connectionService: KiloConnectionService,
|
||||
binary: GitExecutable | string = "git",
|
||||
browser?: BrowserBroker,
|
||||
) {
|
||||
this.browserLifecycle = createBrowserLifecycle({
|
||||
browser,
|
||||
host: this.host,
|
||||
contexts: () => this.contexts,
|
||||
post: (message) => this.postToWebview(message),
|
||||
openPanel: () => this.openPanel(true),
|
||||
log: (...args) => this.log(...args),
|
||||
})
|
||||
this.outputChannel = host.createOutput("Kilo Agent Manager")
|
||||
this.terminalManager = new SessionTerminalManager(
|
||||
(msg) => this.outputChannel.appendLine(`[SessionTerminal] ${msg}`),
|
||||
@@ -197,6 +209,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
push: () => this.pushProjects(),
|
||||
pushState: (ctx) => this.pushState(ctx),
|
||||
changed: () => this.onWorkspaceChanged(),
|
||||
removed: (id) => this.browserLifecycle.closeProject(id),
|
||||
selected: (target) => this.postToWebview({ type: "agentManager.selectionActivated", target }),
|
||||
routeSession: (pid, sid, dir, gen) => routeProjectSession(this.panel?.sessions, pid, sid, dir, gen),
|
||||
})
|
||||
@@ -301,39 +314,13 @@ export class AgentManagerProvider implements Disposable {
|
||||
* window, the CLI, the API) appear without waiting for a full re-list.
|
||||
*/
|
||||
private onSessionLifecycle(event: unknown): void {
|
||||
const ev = event as { type?: string; properties?: { info?: Session; sessionID?: string } }
|
||||
if (ev.type === "session.error") {
|
||||
if (ev.properties?.sessionID) this.busySessions.delete(ev.properties.sessionID)
|
||||
return
|
||||
}
|
||||
if (ev.type === "session.deleted") {
|
||||
const id = ev.properties?.sessionID
|
||||
if (!id) return
|
||||
this.removedSessions.add(id)
|
||||
this.busySessions.delete(id)
|
||||
const ctx = this.contexts.byLiveSession(id)
|
||||
if (!ctx) return
|
||||
ctx.removeLiveSession(id)
|
||||
this.postToWebview({ type: "agentManager.projectSessions", projectId: ctx.id, sessions: [...ctx.sessions()] })
|
||||
return
|
||||
}
|
||||
const info = ev.properties?.info
|
||||
if (ev.type === "session.created" && info) this.removedSessions.delete(info.id)
|
||||
const dir = info && !this.removedSessions.has(info.id) ? info.directory : undefined
|
||||
// Session events from sync or older backends can lack time/directory; a throw
|
||||
// would escape into the SSE dispatch loop and starve the other listeners.
|
||||
if (!info?.time || !dir || (info.parentID !== undefined && info.parentID !== null)) return
|
||||
const ctx = this.contexts.byDirectory(dir)
|
||||
if (!ctx || ctx.lifecycle !== "ready") return
|
||||
const state = ctx.peekState()
|
||||
const managed = state?.getSession(info.id)
|
||||
const worktreeId =
|
||||
managed?.worktreeId ?? state?.getWorktrees().find((wt) => wt.path && samePath(wt.path, dir))?.id ?? null
|
||||
ctx.upsertSession({ ...sessionToWebview(info), worktreeId })
|
||||
// The next regular push re-lists from the backend to reconcile the
|
||||
// optimistic entry (position, subtrees, deletions elsewhere).
|
||||
ctx.invalidateSessions()
|
||||
this.postToWebview({ type: "agentManager.projectSessions", projectId: ctx.id, sessions: [...ctx.sessions()] })
|
||||
handleSessionLifecycle(event, {
|
||||
busy: this.busySessions,
|
||||
removed: this.removedSessions,
|
||||
contexts: this.contexts,
|
||||
closeBrowser: (id) => this.browserLifecycle.close(id),
|
||||
post: (message) => this.postToWebview(message),
|
||||
})
|
||||
}
|
||||
private onSessionStatus(event: unknown): void {
|
||||
const props = (event as { properties?: { sessionID?: string; status?: { type?: string } } }).properties
|
||||
@@ -403,6 +390,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
panel.dispose()
|
||||
}
|
||||
this.panel = ctx
|
||||
this.browserLifecycle.replay()
|
||||
|
||||
this.statsPoller.setVisible(ctx.visible)
|
||||
this.projectPollers.setVisible(ctx.visible)
|
||||
@@ -421,6 +409,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
this.pushProjects()
|
||||
void this.sendRepoInfo()
|
||||
this.sendKeybindings()
|
||||
void ctx.waitForReady().then(() => this.browserLifecycle.replay())
|
||||
this.prBridge.attachPanel(ctx)
|
||||
ctx.onDidDispose(() => {
|
||||
// Only clear if this is still the active panel — a newer panel may
|
||||
@@ -499,8 +488,6 @@ export class AgentManagerProvider implements Disposable {
|
||||
.catch((err) => this.log("Failed to initialize expanded project:", err))
|
||||
}
|
||||
|
||||
// Message interceptor
|
||||
|
||||
private async onMessage(msg: Record<string, unknown>): Promise<Record<string, unknown> | null> {
|
||||
if (this.prBridge.handleMessage(msg)) return null
|
||||
if (msg.type === "requestFileSearch" && typeof msg.sessionID !== "string" && this.activeSessionId) {
|
||||
@@ -535,6 +522,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
if (worktree !== undefined) return worktree
|
||||
const session = this.onSessionMessage(m, msg)
|
||||
if (session !== undefined) return session
|
||||
if (this.browserLifecycle.handle(m)) return null
|
||||
const ui = this.onUiMessage(m, msg)
|
||||
if (ui !== undefined) return ui
|
||||
const state = this.onStateMessage(m)
|
||||
@@ -605,6 +593,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
msg: Record<string, unknown>,
|
||||
): Record<string, unknown> | null | undefined {
|
||||
if (m.type === "agentManager.openLocally") {
|
||||
this.browserLifecycle.close(m.sessionId)
|
||||
this.panel?.sessions.clearSessionDirectory(m.sessionId)
|
||||
const state = this.getStateManager()
|
||||
if (state?.getSession(m.sessionId)) {
|
||||
@@ -635,6 +624,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
if (!state.getSession(m.sessionId)) state.addSession(m.sessionId, null)
|
||||
return
|
||||
}
|
||||
this.browserLifecycle.close(m.sessionId)
|
||||
state.removeSession(m.sessionId)
|
||||
})
|
||||
return null
|
||||
@@ -1416,6 +1406,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
reviewMarkdownRender: getDiffMarkdownRender(),
|
||||
terminalDestination: this.destination.value(),
|
||||
terminalFont: readTerminalFont(),
|
||||
browserAutomation: this.host.browserAutomation(),
|
||||
isGitRepo: true,
|
||||
defaultBaseBranch: state.getDefaultBaseBranch(),
|
||||
activeTarget: state.getActiveTarget(),
|
||||
@@ -1449,6 +1440,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
isGitRepo: false,
|
||||
runStatuses: [],
|
||||
runScriptConfigured: false,
|
||||
browserAutomation: this.host.browserAutomation(),
|
||||
})
|
||||
}
|
||||
private get lifecycleHost(): LifecycleHost {
|
||||
@@ -1459,8 +1451,11 @@ export class AgentManagerProvider implements Disposable {
|
||||
notifyReady: (sid, result, id) => this.notifyWorktreeReady(sid, result, id),
|
||||
sessions: {
|
||||
register: (session) => this.panel?.sessions.registerSession(session),
|
||||
clearDirectory: (sid) => this.panel?.sessions.clearSessionDirectory(sid),
|
||||
setSessionDirectory: (sid, dir) => this.panel?.sessions.setSessionDirectory(sid, dir),
|
||||
clearDirectory: (sid) => (this.browserLifecycle?.close(sid), this.panel?.sessions.clearSessionDirectory(sid)),
|
||||
setSessionDirectory: (sid, dir) => (
|
||||
this.browserLifecycle?.close(sid),
|
||||
this.panel?.sessions.setSessionDirectory(sid, dir)
|
||||
),
|
||||
registerSessionRoute: (ref, dir, gen) => this.panel?.sessions.registerSessionRoute?.(ref, dir, gen),
|
||||
directories: () => this.panel?.sessions.getSessionDirectories(),
|
||||
abort: (ids) => this.panel?.sessions.abortSessions(ids) ?? Promise.resolve(),
|
||||
@@ -1607,6 +1602,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
}
|
||||
private onWorkspaceChanged(): void {
|
||||
if (this.contexts.syncPinned()) {
|
||||
void this.browserLifecycle.closeAll()
|
||||
this.activeSessionId = undefined
|
||||
this.stateReady = this.initializeState()
|
||||
void this.sendRepoInfo()
|
||||
@@ -1864,9 +1860,18 @@ export class AgentManagerProvider implements Disposable {
|
||||
void this.shutdown()
|
||||
}
|
||||
|
||||
public refreshBrowserAutomation(): void {
|
||||
if (!this.host.browserAutomation()) void this.browserLifecycle.closeAll()
|
||||
if (!this.context) {
|
||||
return this.pushEmptyState()
|
||||
}
|
||||
this.pushState()
|
||||
}
|
||||
|
||||
private async disposeAsync(): Promise<void> {
|
||||
await this.stateReady?.catch((err) => this.log("dispose: stateReady rejected:", err))
|
||||
await this.contexts.dispose()
|
||||
await this.browserLifecycle.dispose()
|
||||
this.unsubTool?.()
|
||||
this.activity.dispose()
|
||||
this.unsubFont?.()
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { BrowserBroker, type BrowserRoute, type BrowserState } from "../services/browser-automation"
|
||||
import type { Host } from "./host"
|
||||
import type { AgentManagerInMessage, AgentManagerOutMessage } from "./types"
|
||||
import type { ProjectContexts } from "./project/contexts"
|
||||
import { canonicalizePath, samePath } from "./project/paths"
|
||||
import { browserMessage, handleBrowserMessage } from "./browser-message"
|
||||
|
||||
export function createBrowserLifecycle(input: {
|
||||
browser?: BrowserBroker
|
||||
host: Host
|
||||
contexts: () => ProjectContexts
|
||||
post: (message: AgentManagerOutMessage) => void
|
||||
openPanel: () => void
|
||||
log: (...args: unknown[]) => void
|
||||
}) {
|
||||
const browser = input.browser ?? new BrowserBroker({ log: input.log })
|
||||
browser.bind((route: BrowserRoute) => {
|
||||
const directory = canonicalizePath(route.directory)
|
||||
const ctx = input.contexts().byDirectory(directory)
|
||||
if (!ctx || (route.projectId && route.projectId !== ctx.id)) return undefined
|
||||
const state = ctx.peekState()
|
||||
const stored = state?.getSession(route.sessionId)
|
||||
const live = ctx.sessions().find((session) => session.id === route.sessionId)
|
||||
if (!stored && !live) return undefined
|
||||
const worktree = stored?.worktreeId ?? live?.worktreeId
|
||||
const expected = worktree ? state?.getWorktree(worktree)?.path : ctx.root
|
||||
if (!expected || !samePath(canonicalizePath(expected), directory)) return undefined
|
||||
return { projectId: ctx.id, sessionId: route.sessionId, directory }
|
||||
})
|
||||
const post = (state: BrowserState) => {
|
||||
const active = input.contexts().active()
|
||||
if ((state.status === "starting" || state.status === "loading") && state.projectId === active?.id) {
|
||||
input.openPanel()
|
||||
}
|
||||
input.post(browserMessage(state))
|
||||
}
|
||||
const off = browser.subscribe(post)
|
||||
return {
|
||||
handle(message: AgentManagerInMessage): boolean {
|
||||
return handleBrowserMessage(message, {
|
||||
host: input.host,
|
||||
contexts: input.contexts(),
|
||||
browser,
|
||||
post: input.post,
|
||||
log: input.log,
|
||||
})
|
||||
},
|
||||
replay(): void {
|
||||
browser.replay(post)
|
||||
},
|
||||
close(sessionId: string, projectId?: string): void {
|
||||
void browser.close(sessionId, projectId).catch((error) => input.log("Failed to close browser session:", error))
|
||||
},
|
||||
closeProject(projectId: string): void {
|
||||
for (const session of browser.sessions()) {
|
||||
if (browser.get(session, projectId)) this.close(session, projectId)
|
||||
}
|
||||
},
|
||||
closeAll(): Promise<void> {
|
||||
return Promise.all([...browser.sessions()].map((sessionId) => browser.close(sessionId))).then(() => undefined)
|
||||
},
|
||||
dispose(): Promise<void> {
|
||||
off()
|
||||
return browser.disposeAsync()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { diagnostic, type BrowserBroker, type BrowserState } from "../services/browser-automation"
|
||||
import type { AgentManagerInMessage, AgentManagerOutMessage } from "./types"
|
||||
import type { ProjectContexts } from "./project/contexts"
|
||||
import type { Host } from "./host"
|
||||
|
||||
type BrowserMessage = Extract<AgentManagerInMessage, { type: `agentManager.browser.${string}` }>
|
||||
|
||||
function position(message: BrowserMessage): { x: number; y: number; width: number; height: number } | undefined {
|
||||
if (
|
||||
typeof message.x !== "number" ||
|
||||
typeof message.y !== "number" ||
|
||||
typeof message.width !== "number" ||
|
||||
typeof message.height !== "number"
|
||||
) {
|
||||
return
|
||||
}
|
||||
return { x: message.x, y: message.y, width: message.width, height: message.height }
|
||||
}
|
||||
|
||||
function fail(
|
||||
deps: { post: (message: AgentManagerOutMessage) => void },
|
||||
m: BrowserMessage,
|
||||
error: string,
|
||||
state?: BrowserState,
|
||||
): void {
|
||||
if (m.type === "agentManager.browser.inspect") {
|
||||
deps.post({
|
||||
type: "agentManager.browserInspection",
|
||||
projectId: m.projectId,
|
||||
sessionId: m.sessionId,
|
||||
requestId: m.requestId ?? "",
|
||||
hover: m.hover,
|
||||
logs: [],
|
||||
error,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (state) {
|
||||
deps.post(browserMessage({ ...state, error }))
|
||||
return
|
||||
}
|
||||
deps.post({
|
||||
type: "agentManager.browserState",
|
||||
browserId: "",
|
||||
projectId: m.projectId,
|
||||
sessionId: m.sessionId,
|
||||
status: "error",
|
||||
errors: 0,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
function route(contexts: ProjectContexts, message: BrowserMessage): { project: string; directory: string } | undefined {
|
||||
const ctx = contexts.resolve(message.projectId ?? contexts.active()?.id ?? "")
|
||||
if (!ctx) return
|
||||
const state = ctx.peekState()
|
||||
const session = state?.getSession(message.sessionId)
|
||||
const live = ctx.sessions().find((item) => item.id === message.sessionId)
|
||||
if (!session && !live) return
|
||||
const worktree = session?.worktreeId ?? live?.worktreeId
|
||||
const directory = worktree ? state?.getWorktree(worktree)?.path : ctx.root
|
||||
return directory ? { project: ctx.id, directory } : undefined
|
||||
}
|
||||
|
||||
function action(
|
||||
m: BrowserMessage,
|
||||
deps: {
|
||||
host: Host
|
||||
contexts: ProjectContexts
|
||||
browser: BrowserBroker
|
||||
post: (message: AgentManagerOutMessage) => void
|
||||
log: (...args: unknown[]) => void
|
||||
},
|
||||
): boolean {
|
||||
const scope = route(deps.contexts, m)
|
||||
if (!scope) {
|
||||
fail(deps, m, "Browser session is not available in the selected project.")
|
||||
return true
|
||||
}
|
||||
if (m.type === "agentManager.browser.state") {
|
||||
const current = deps.browser.get(m.sessionId, scope.project)
|
||||
if (current) deps.post(browserMessage(current))
|
||||
return true
|
||||
}
|
||||
if (m.type === "agentManager.browser.devtools") {
|
||||
void deps.browser
|
||||
.devtools(m.sessionId, scope.project, m.theme === "light" ? "light" : "dark")
|
||||
.then((tools) =>
|
||||
deps.post({
|
||||
type: "agentManager.browserDevtools",
|
||||
projectId: scope.project,
|
||||
sessionId: m.sessionId,
|
||||
...tools,
|
||||
}),
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
deps.log("Browser developer tools failed:", error)
|
||||
fail(deps, m, diagnostic(error), deps.browser.get(m.sessionId, scope.project))
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (m.type === "agentManager.browser.open") {
|
||||
if (!m.url) return true
|
||||
void deps.browser
|
||||
.open({ projectId: scope.project, sessionId: m.sessionId, directory: scope.directory }, m.url)
|
||||
.catch((error: unknown) => {
|
||||
deps.log("Browser open failed:", error)
|
||||
const current = deps.browser.get(m.sessionId, scope.project)
|
||||
if (current) {
|
||||
deps.post(browserMessage(current))
|
||||
return
|
||||
}
|
||||
fail(deps, m, diagnostic(error, m.url))
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (m.type === "agentManager.browser.refresh") {
|
||||
void deps.browser.refresh(m.sessionId, scope.project).catch((error: unknown) => {
|
||||
deps.log("Browser refresh failed:", error)
|
||||
const current = deps.browser.get(m.sessionId, scope.project)
|
||||
if (current) deps.post(browserMessage(current))
|
||||
})
|
||||
return true
|
||||
}
|
||||
if (m.type === "agentManager.browser.inspect" || m.type === "agentManager.browser.input") {
|
||||
const point = position(m)
|
||||
if (!point) {
|
||||
fail(deps, m, "Browser element coordinates are required.", deps.browser.get(m.sessionId, scope.project))
|
||||
return true
|
||||
}
|
||||
if (m.type === "agentManager.browser.input") {
|
||||
void deps.browser.input(m.sessionId, scope.project, point, m.click === true).catch((error: unknown) => {
|
||||
deps.log("Browser developer tools input failed:", error)
|
||||
fail(deps, { ...m, projectId: scope.project }, diagnostic(error), deps.browser.get(m.sessionId, scope.project))
|
||||
})
|
||||
return true
|
||||
}
|
||||
void deps.browser
|
||||
.inspect(m.sessionId, scope.project, point, m.hover !== true)
|
||||
.then((inspection) =>
|
||||
deps.post({
|
||||
type: "agentManager.browserInspection",
|
||||
projectId: scope.project,
|
||||
sessionId: m.sessionId,
|
||||
requestId: m.requestId ?? "",
|
||||
...inspection,
|
||||
hover: m.hover,
|
||||
}),
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
deps.log("Browser element inspection failed:", error)
|
||||
fail(deps, { ...m, projectId: scope.project }, diagnostic(error))
|
||||
})
|
||||
return true
|
||||
}
|
||||
void deps.browser
|
||||
.close(m.sessionId, scope.project)
|
||||
.catch((error: unknown) => deps.log("Browser close failed:", error))
|
||||
return true
|
||||
}
|
||||
|
||||
export function handleBrowserMessage(
|
||||
message: AgentManagerInMessage,
|
||||
deps: {
|
||||
host: Host
|
||||
contexts: ProjectContexts
|
||||
browser: BrowserBroker
|
||||
post: (message: AgentManagerOutMessage) => void
|
||||
log: (...args: unknown[]) => void
|
||||
},
|
||||
): boolean {
|
||||
if (!message.type.startsWith("agentManager.browser.")) return false
|
||||
const m = message as BrowserMessage
|
||||
if (!deps.host.isTrusted()) {
|
||||
fail(deps, m, "Browser preview requires a trusted workspace.")
|
||||
return true
|
||||
}
|
||||
if (!deps.host.browserAutomation()) {
|
||||
fail(deps, m, "Browser automation is disabled. Enable it in Kilo Settings > Experimental.")
|
||||
return true
|
||||
}
|
||||
return action(m, deps)
|
||||
}
|
||||
|
||||
export function browserMessage(state: BrowserState): AgentManagerOutMessage {
|
||||
return {
|
||||
type: "agentManager.browserState",
|
||||
browserId: state.browserId,
|
||||
projectId: state.projectId,
|
||||
sessionId: state.sessionId,
|
||||
navigation: state.navigation,
|
||||
status: state.status,
|
||||
inspecting: state.inspecting,
|
||||
url: state.url,
|
||||
title: state.title,
|
||||
errors: state.errors,
|
||||
logs: state.logs,
|
||||
error: state.error,
|
||||
frameError: state.frameError,
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,7 @@ export interface Host {
|
||||
|
||||
/** Whether the experimental multi-project Agent Manager mode is enabled. */
|
||||
multiProject(): boolean
|
||||
browserAutomation(): boolean
|
||||
|
||||
/** Read the persisted additional-project registry payload. */
|
||||
readProjects(): unknown
|
||||
|
||||
@@ -33,4 +33,11 @@ export const STATE_GATED = new Set<string>([
|
||||
"agentManager.toggleSectionCollapsed",
|
||||
"agentManager.moveToSection",
|
||||
"agentManager.moveSection",
|
||||
"agentManager.browser.open",
|
||||
"agentManager.browser.refresh",
|
||||
"agentManager.browser.close",
|
||||
"agentManager.browser.state",
|
||||
"agentManager.browser.inspect",
|
||||
"agentManager.browser.input",
|
||||
"agentManager.browser.devtools",
|
||||
])
|
||||
|
||||
@@ -42,6 +42,7 @@ export function createProjectWiring(opts: {
|
||||
pushState: (ctx?: ProjectContext) => void
|
||||
/** Re-derive the pinned project after workspace folder changes. */
|
||||
changed: () => void
|
||||
removed?: (id: string) => void
|
||||
/** Acknowledge an atomically validated sidebar selection. */
|
||||
selected: (target: import("./route").SidebarTarget) => void
|
||||
/** Route one session to a directory inside a project (override + project route). */
|
||||
@@ -55,7 +56,10 @@ export function createProjectWiring(opts: {
|
||||
workspaceRoot: () => opts.host.workspacePath(),
|
||||
registry,
|
||||
enabled: () => opts.host.multiProject(),
|
||||
remove: (id) => opts.host.unregisterProjectRoutes(id),
|
||||
remove: (id) => {
|
||||
opts.host.unregisterProjectRoutes(id)
|
||||
opts.removed?.(id)
|
||||
},
|
||||
deps: { log: opts.output, git: opts.git },
|
||||
})
|
||||
const messages: ProjectMessageDeps = {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Session } from "@kilocode/sdk/v2/client"
|
||||
import { sessionToWebview } from "../kilo-provider-utils"
|
||||
import { samePath } from "./project/paths"
|
||||
import type { ProjectContexts } from "./project/contexts"
|
||||
import type { AgentManagerOutMessage } from "./types"
|
||||
|
||||
type Deps = {
|
||||
busy: Set<string>
|
||||
removed: Set<string>
|
||||
contexts: ProjectContexts
|
||||
closeBrowser: (sessionId: string) => void
|
||||
post: (message: AgentManagerOutMessage) => void
|
||||
}
|
||||
|
||||
type Event = { type?: string; properties?: { info?: Session; sessionID?: string } }
|
||||
|
||||
function remove(id: string, deps: Deps): void {
|
||||
deps.removed.add(id)
|
||||
deps.busy.delete(id)
|
||||
deps.closeBrowser(id)
|
||||
const ctx = deps.contexts.byLiveSession(id)
|
||||
if (!ctx) return
|
||||
ctx.removeLiveSession(id)
|
||||
deps.post({ type: "agentManager.projectSessions", projectId: ctx.id, sessions: [...ctx.sessions()] })
|
||||
}
|
||||
|
||||
function upsert(info: Session, deps: Deps): void {
|
||||
if (deps.removed.has(info.id)) return
|
||||
const dir = info.directory
|
||||
if (!info.time || !dir || (info.parentID !== undefined && info.parentID !== null)) return
|
||||
const ctx = deps.contexts.byDirectory(dir)
|
||||
if (!ctx || ctx.lifecycle !== "ready") return
|
||||
const state = ctx.peekState()
|
||||
const managed = state?.getSession(info.id)
|
||||
const worktreeId =
|
||||
managed?.worktreeId ?? state?.getWorktrees().find((wt) => wt.path && samePath(wt.path, dir))?.id ?? null
|
||||
ctx.upsertSession({ ...sessionToWebview(info), worktreeId })
|
||||
ctx.invalidateSessions()
|
||||
deps.post({ type: "agentManager.projectSessions", projectId: ctx.id, sessions: [...ctx.sessions()] })
|
||||
}
|
||||
|
||||
export function handleSessionLifecycle(event: unknown, deps: Deps): void {
|
||||
const ev = event as Event
|
||||
if (ev.type === "session.error") {
|
||||
if (ev.properties?.sessionID) deps.busy.delete(ev.properties.sessionID)
|
||||
return
|
||||
}
|
||||
if (ev.type === "session.deleted") {
|
||||
const id = ev.properties?.sessionID ?? ev.properties?.info?.id
|
||||
if (id) remove(id, deps)
|
||||
return
|
||||
}
|
||||
const info = ev.properties?.info
|
||||
if (!info) return
|
||||
if (ev.type === "session.created") deps.removed.delete(info.id)
|
||||
upsert(info, deps)
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import type { SnapshotFileDiff } from "@kilocode/sdk/v2/client"
|
||||
import type { DiffImage } from "../diff/types"
|
||||
import type { BrowserElement } from "../services/browser-automation"
|
||||
import type { Worktree, ManagedSession, Section } from "./WorktreeStateManager"
|
||||
import type { WorktreeStats, LocalStats } from "./GitStatsPoller"
|
||||
import type { ApplyConflict } from "./GitOps"
|
||||
@@ -19,6 +20,7 @@ import type { ProjectSnapshot } from "./project/contexts"
|
||||
import type { SidebarTarget } from "./project/route"
|
||||
import type { TerminalDestination } from "./terminal-destination"
|
||||
import type { ScriptTerminalView } from "./ScriptTerminalManager"
|
||||
import type { BrowserFeedbackData } from "../shared/browser-feedback"
|
||||
|
||||
export type { TerminalFont }
|
||||
export type { ProjectSnapshot }
|
||||
@@ -174,6 +176,7 @@ interface StateMessage {
|
||||
activeTarget?: SidebarTarget
|
||||
terminalDestination?: TerminalDestination
|
||||
terminalFont?: TerminalFont
|
||||
browserAutomation?: boolean
|
||||
}
|
||||
|
||||
/** Project catalog pushed to the webview after registry or context changes. */
|
||||
@@ -320,6 +323,7 @@ interface SendInitialMessage {
|
||||
agent?: string
|
||||
variant?: string
|
||||
files?: Array<{ mime: string; url: string }>
|
||||
browserFeedback?: BrowserFeedbackData
|
||||
}
|
||||
|
||||
interface BranchesMessage {
|
||||
@@ -452,6 +456,43 @@ interface ActionOutMessage {
|
||||
action: string
|
||||
}
|
||||
|
||||
interface BrowserStateMessage {
|
||||
type: "agentManager.browserState"
|
||||
browserId: string
|
||||
projectId?: string
|
||||
sessionId: string
|
||||
navigation?: number
|
||||
status: "starting" | "ready" | "loading" | "error" | "closed"
|
||||
inspecting?: boolean
|
||||
url?: string
|
||||
title?: string
|
||||
errors: number
|
||||
logs?: string[]
|
||||
error?: string
|
||||
frameError?: string
|
||||
}
|
||||
|
||||
interface BrowserInspectionMessage {
|
||||
type: "agentManager.browserInspection"
|
||||
error?: string
|
||||
requestId: string
|
||||
projectId?: string
|
||||
sessionId: string
|
||||
url?: string
|
||||
title?: string
|
||||
element?: BrowserElement
|
||||
logs: string[]
|
||||
hover?: boolean
|
||||
}
|
||||
|
||||
interface BrowserDevtoolsMessage {
|
||||
type: "agentManager.browserDevtools"
|
||||
browserId: string
|
||||
projectId?: string
|
||||
sessionId: string
|
||||
url: string
|
||||
}
|
||||
|
||||
interface RunStatusMessage extends RunStatus {
|
||||
type: "agentManager.runStatus"
|
||||
/** Owning project for this status. Absent in legacy single-project mode. */
|
||||
@@ -491,6 +532,9 @@ export type AgentManagerOutMessage =
|
||||
| PRErrorOutMessage
|
||||
| CommentActionResultMessage
|
||||
| ActionOutMessage
|
||||
| BrowserStateMessage
|
||||
| BrowserInspectionMessage
|
||||
| BrowserDevtoolsMessage
|
||||
| RunStatusMessage
|
||||
| TerminalCreatedMessage
|
||||
| TerminalRestartedMessage
|
||||
@@ -915,6 +959,7 @@ interface SendMessageIn {
|
||||
files?: Array<{ mime: string; url: string; filename?: string; source?: FileSourceIn }>
|
||||
agentManagerContext?: string
|
||||
contextDirectory?: string
|
||||
browserFeedback?: BrowserFeedbackData
|
||||
}
|
||||
|
||||
interface SendCommandIn {
|
||||
@@ -1084,6 +1129,28 @@ interface TerminalDestinationSelectedIn {
|
||||
destination: TerminalDestination
|
||||
}
|
||||
|
||||
interface BrowserRequestIn {
|
||||
type:
|
||||
| "agentManager.browser.open"
|
||||
| "agentManager.browser.refresh"
|
||||
| "agentManager.browser.close"
|
||||
| "agentManager.browser.state"
|
||||
| "agentManager.browser.inspect"
|
||||
| "agentManager.browser.input"
|
||||
| "agentManager.browser.devtools"
|
||||
sessionId: string
|
||||
requestId?: string
|
||||
projectId?: string
|
||||
url?: string
|
||||
x?: number
|
||||
y?: number
|
||||
width?: number
|
||||
height?: number
|
||||
hover?: boolean
|
||||
click?: boolean
|
||||
theme?: "dark" | "light"
|
||||
}
|
||||
|
||||
/** All messages the Agent Manager expects from the webview (onMessage input). */
|
||||
export type AgentManagerInMessage =
|
||||
| CreateWorktreeIn
|
||||
@@ -1171,3 +1238,4 @@ export type AgentManagerInMessage =
|
||||
| TerminalResizeIn
|
||||
| TerminalRestartIn
|
||||
| TerminalDestinationSelectedIn
|
||||
| BrowserRequestIn
|
||||
|
||||
@@ -54,6 +54,7 @@ export class VscodeHost implements Host {
|
||||
vscode.ViewColumn.One,
|
||||
{
|
||||
enableScripts: true,
|
||||
enableForms: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [this.extensionUri],
|
||||
},
|
||||
@@ -85,6 +86,7 @@ export class VscodeHost implements Host {
|
||||
): PanelContext {
|
||||
panel.webview.options = {
|
||||
enableScripts: true,
|
||||
enableForms: true,
|
||||
localResourceRoots: [this.extensionUri],
|
||||
}
|
||||
|
||||
@@ -101,6 +103,8 @@ export class VscodeHost implements Host {
|
||||
workerUri: panel.webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "shiki-worker.js")),
|
||||
title: "Agent Manager",
|
||||
port,
|
||||
browserAutomation: this.browserAutomation(),
|
||||
frameSrc: ["localhost", "127.0.0.1"].map((host) => `http://${host}:*`).join(" "),
|
||||
})
|
||||
|
||||
const provider = new KiloProvider(this.extensionUri, this.connectionService, this.context, {
|
||||
@@ -244,6 +248,10 @@ export class VscodeHost implements Host {
|
||||
return vscode.workspace.getConfiguration("kilo-code.new.experimental").get("multiProject", false)
|
||||
}
|
||||
|
||||
browserAutomation(): boolean {
|
||||
return vscode.workspace.getConfiguration("kilo-code.new.experimental").get("browserAutomation", false)
|
||||
}
|
||||
|
||||
readProjects(): unknown {
|
||||
return this.context.globalState.get("agentManager.projects")
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { registerAutocompleteProvider } from "./services/autocomplete"
|
||||
import { ensureBackendForAutocomplete } from "./services/autocomplete/ensure-backend"
|
||||
import { AutocompleteServiceManager } from "./services/autocomplete/AutocompleteServiceManager"
|
||||
import { AttentionService } from "./services/attention"
|
||||
import { BrowserAutomationService } from "./services/browser-automation"
|
||||
import { BrowserBroker } from "./services/browser-automation"
|
||||
import { TelemetryEventName, TelemetryProxy } from "./services/telemetry"
|
||||
import { registerCommitMessageService } from "./services/commit-message"
|
||||
import { registerCodeActions, registerTerminalActions, KiloCodeActionProvider } from "./services/code-actions"
|
||||
@@ -56,8 +56,16 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
const telemetry = TelemetryProxy.getInstance()
|
||||
|
||||
const browserBroker = new BrowserBroker({
|
||||
log: (...args) => console.warn("[Kilo New] BrowserBroker:", ...args),
|
||||
enabled: () => vscode.workspace.getConfiguration("kilo-code.new.experimental").get("browserAutomation", false),
|
||||
trusted: () => vscode.workspace.isTrusted,
|
||||
useSystemChrome: () =>
|
||||
vscode.workspace.getConfiguration("kilo-code.new.browserAutomation").get("useSystemChrome", true),
|
||||
})
|
||||
|
||||
// Create shared connection service (one server for all webviews)
|
||||
const connectionService = new KiloConnectionService(context)
|
||||
const connectionService = new KiloConnectionService(context, () => browserBroker.env())
|
||||
const notebookBridge = createNotebookBridge(connectionService)
|
||||
let restore = context.workspaceState.get<RestoreState>(RESTORE_KEY) ?? {}
|
||||
const remember = (patch: RestoreState) => {
|
||||
@@ -67,20 +75,13 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
void context.workspaceState.update(RESTORE_KEY, restore)
|
||||
}
|
||||
|
||||
// Create browser automation service (manages Playwright MCP registration)
|
||||
const browserAutomationService = new BrowserAutomationService(connectionService)
|
||||
browserAutomationService.syncWithSettings()
|
||||
|
||||
// Create remote status service (one status bar item for all webviews)
|
||||
const remoteService = new RemoteStatusService()
|
||||
context.subscriptions.push(remoteService)
|
||||
connectionService.setRemoteService(remoteService)
|
||||
|
||||
// Re-register browser automation MCP server on CLI backend reconnect, configure telemetry,
|
||||
// set remote service client, and reload autocomplete so it picks up the now-available backend connection.
|
||||
const unsubscribeStateChange = connectionService.onStateChange((state) => {
|
||||
if (state === "connected") {
|
||||
browserAutomationService.reregisterIfEnabled()
|
||||
const config = connectionService.getServerConfig()
|
||||
if (config) {
|
||||
telemetry.configure(config.baseUrl, config.password)
|
||||
@@ -171,9 +172,16 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
log: (message) => console.warn(`[Kilo New] ${message}`),
|
||||
})
|
||||
const binary = process.platform === "win32" ? await git() : git
|
||||
const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService, binary)
|
||||
const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService, binary, browserBroker)
|
||||
agentManagerProvider.onPanelVisibilityChange((visible) => remember({ agentManager: visible }))
|
||||
agentManager = agentManagerProvider
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidChangeConfiguration((event) => {
|
||||
if (event.affectsConfiguration("kilo-code.new.experimental.browserAutomation")) {
|
||||
agentManagerProvider.refreshBrowserAutomation()
|
||||
}
|
||||
}),
|
||||
)
|
||||
context.subscriptions.push(agentManagerProvider)
|
||||
|
||||
// Wire "Continue in Worktree" from sidebar → Agent Manager
|
||||
@@ -636,7 +644,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
shuttingDown = true
|
||||
unsubscribeStateChange()
|
||||
attention.dispose()
|
||||
browserAutomationService.dispose()
|
||||
browserBroker.dispose()
|
||||
provider.dispose()
|
||||
notebookBridge.dispose()
|
||||
connectionService.dispose()
|
||||
|
||||
@@ -9,7 +9,8 @@ import type { KiloClient, Session, TextPartInput, FilePartInput } from "@kilocod
|
||||
import type { CloudSessionData, EditorContext } from "../../services/cli-backend/types"
|
||||
import { getErrorMessage, sessionToWebview, mapCloudSessionMessageToWebviewMessage } from "../../kilo-provider-utils"
|
||||
import type { MessageFile } from "../message-files"
|
||||
import { reviewMetadata, type ReviewMessageData } from "../../shared/review-comments"
|
||||
import { type ReviewMessageData } from "../../shared/review-comments"
|
||||
import { feedbackMetadata, type BrowserFeedbackData } from "../../shared/browser-feedback"
|
||||
import { completesWithoutStatus } from "../command-completion"
|
||||
|
||||
const TIMEOUT = 30_000
|
||||
@@ -124,6 +125,7 @@ export async function handleImportAndSend(
|
||||
review?: ReviewMessageData,
|
||||
command?: string,
|
||||
commandArgs?: string,
|
||||
browserFeedback?: BrowserFeedbackData,
|
||||
): Promise<void> {
|
||||
if (!ctx.client) {
|
||||
ctx.postMessage({
|
||||
@@ -216,7 +218,7 @@ export async function handleImportAndSend(
|
||||
parts.push({ type: "file", mime: f.mime, url: f.url, filename: f.filename, source: f.source })
|
||||
}
|
||||
}
|
||||
parts.push({ type: "text", text, metadata: review ? reviewMetadata(review) : undefined })
|
||||
parts.push({ type: "text", text, metadata: feedbackMetadata(review, browserFeedback) })
|
||||
|
||||
const editorContext = await ctx.gatherEditorContext()
|
||||
await client.session.promptAsync(
|
||||
@@ -247,6 +249,7 @@ export async function handleImportAndSend(
|
||||
messageID,
|
||||
files,
|
||||
review: command ? undefined : review,
|
||||
browserFeedback: command ? undefined : browserFeedback,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import * as vscode from "vscode"
|
||||
import type { KiloClient } from "@kilocode/sdk/v2/client"
|
||||
import type { KiloConnectionService } from "../cli-backend"
|
||||
|
||||
type BrowserAutomationState = "disabled" | "registering" | "connected" | "failed" | "disconnected"
|
||||
|
||||
export class BrowserAutomationService implements vscode.Disposable {
|
||||
private state: BrowserAutomationState = "disabled"
|
||||
private disposables: vscode.Disposable[] = []
|
||||
|
||||
// MCP server name used when registering with the CLI backend
|
||||
private static readonly MCP_SERVER_NAME = "kilo-playwright"
|
||||
|
||||
constructor(private readonly connectionService: KiloConnectionService) {
|
||||
// Listen for settings changes
|
||||
this.disposables.push(
|
||||
vscode.workspace.onDidChangeConfiguration((e) => {
|
||||
if (e.affectsConfiguration("kilo-code.new.browserAutomation")) {
|
||||
this.syncWithSettings()
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read settings and enable/disable accordingly.
|
||||
* Called on construction and when settings change.
|
||||
*/
|
||||
async syncWithSettings(): Promise<void> {
|
||||
const config = vscode.workspace.getConfiguration("kilo-code.new.browserAutomation")
|
||||
const enabled = config.get<boolean>("enabled", false)
|
||||
|
||||
if (enabled) {
|
||||
await this.register()
|
||||
} else {
|
||||
await this.unregister()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-register the MCP server after CLI backend reconnects.
|
||||
* Should be called from the connection state change handler.
|
||||
*/
|
||||
async reregisterIfEnabled(): Promise<void> {
|
||||
const config = vscode.workspace.getConfiguration("kilo-code.new.browserAutomation")
|
||||
const enabled = config.get<boolean>("enabled", false)
|
||||
if (enabled) {
|
||||
await this.register()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Playwright MCP server with the CLI backend.
|
||||
*/
|
||||
private async register(): Promise<void> {
|
||||
this.setState("registering")
|
||||
|
||||
const client = this.getClient()
|
||||
if (!client) {
|
||||
console.error("[Kilo New] BrowserAutomationService: No SDK client available")
|
||||
this.setState("failed")
|
||||
return
|
||||
}
|
||||
|
||||
const config = vscode.workspace.getConfiguration("kilo-code.new.browserAutomation")
|
||||
const useSystemChrome = config.get<boolean>("useSystemChrome", true)
|
||||
const headless = config.get<boolean>("headless", false)
|
||||
|
||||
// Build the command for the Playwright MCP server
|
||||
const command = ["npx", "@playwright/mcp@latest"]
|
||||
if (headless) {
|
||||
command.push("--headless")
|
||||
}
|
||||
if (useSystemChrome) {
|
||||
command.push("--browser", "chrome")
|
||||
}
|
||||
|
||||
try {
|
||||
const directory = this.getWorkspaceDirectory()
|
||||
const { data: status } = await client.mcp.add(
|
||||
{
|
||||
name: BrowserAutomationService.MCP_SERVER_NAME,
|
||||
config: {
|
||||
type: "local",
|
||||
command,
|
||||
enabled: true,
|
||||
timeout: 60000,
|
||||
},
|
||||
directory,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
|
||||
const serverStatus = status[BrowserAutomationService.MCP_SERVER_NAME]
|
||||
if (serverStatus?.status === "connected") {
|
||||
this.setState("connected")
|
||||
} else if (serverStatus?.status === "failed") {
|
||||
console.error(
|
||||
"[Kilo New] BrowserAutomationService: MCP server failed:",
|
||||
(serverStatus as { error?: string }).error,
|
||||
)
|
||||
this.setState("failed")
|
||||
} else {
|
||||
this.setState("disconnected")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] BrowserAutomationService: Failed to register MCP server:", error)
|
||||
this.setState("failed")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister/disconnect the Playwright MCP server.
|
||||
*/
|
||||
private async unregister(): Promise<void> {
|
||||
if (this.state === "disabled") {
|
||||
return
|
||||
}
|
||||
|
||||
const client = this.getClient()
|
||||
if (client) {
|
||||
try {
|
||||
const directory = this.getWorkspaceDirectory()
|
||||
await client.mcp.disconnect(
|
||||
{ name: BrowserAutomationService.MCP_SERVER_NAME, directory },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] BrowserAutomationService: Failed to disconnect MCP server:", error)
|
||||
}
|
||||
}
|
||||
|
||||
this.setState("disabled")
|
||||
}
|
||||
|
||||
private getClient(): KiloClient | null {
|
||||
try {
|
||||
return this.connectionService.getClient()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private getWorkspaceDirectory(): string {
|
||||
const folders = vscode.workspace.workspaceFolders
|
||||
if (folders && folders.length > 0) {
|
||||
return folders[0].uri.fsPath
|
||||
}
|
||||
return process.cwd()
|
||||
}
|
||||
|
||||
private setState(state: BrowserAutomationState): void {
|
||||
if (this.state === state) {
|
||||
return
|
||||
}
|
||||
console.log(`[Kilo New] BrowserAutomationService: State ${this.state} → ${state}`)
|
||||
this.state = state
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const d of this.disposables) {
|
||||
d.dispose()
|
||||
}
|
||||
this.disposables = []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,781 @@
|
||||
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto"
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
|
||||
import type { Socket } from "node:net"
|
||||
import { URL } from "node:url"
|
||||
import { stripVTControlCharacters } from "node:util"
|
||||
import { chromium, type BrowserContext, type LaunchOptions, type Page } from "playwright-core"
|
||||
import { BrowserDevtools } from "./browser-devtools"
|
||||
import { capture as element, locate } from "./browser-element"
|
||||
import { options } from "./browser-runtime"
|
||||
|
||||
export type BrowserStatus = "starting" | "ready" | "loading" | "error" | "closed"
|
||||
|
||||
export interface BrowserRoute {
|
||||
projectId?: string
|
||||
sessionId: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
export interface BrowserState {
|
||||
browserId: string
|
||||
projectId?: string
|
||||
sessionId: string
|
||||
navigation: number
|
||||
status: BrowserStatus
|
||||
inspecting?: boolean
|
||||
url?: string
|
||||
title?: string
|
||||
screenshot?: string
|
||||
mime?: "image/jpeg"
|
||||
errors: number
|
||||
logs?: string[]
|
||||
error?: string
|
||||
frameError?: string
|
||||
}
|
||||
|
||||
export interface BrowserElement {
|
||||
tag: string
|
||||
id?: string
|
||||
classes?: string
|
||||
text?: string
|
||||
selector?: string
|
||||
rect?: { x: number; y: number; width: number; height: number }
|
||||
hierarchy?: string[]
|
||||
html?: string
|
||||
styles?: { color?: string; backgroundColor?: string }
|
||||
source?: { file: string; line?: number; column?: number }
|
||||
}
|
||||
|
||||
export interface BrowserInspection {
|
||||
url?: string
|
||||
title?: string
|
||||
element?: BrowserElement
|
||||
logs: string[]
|
||||
}
|
||||
|
||||
interface BrowserDevtoolsInfo {
|
||||
browserId: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface BrowserBrokerOptions {
|
||||
log: (...args: unknown[]) => void
|
||||
enabled?: () => boolean
|
||||
trusted?: () => boolean
|
||||
launch?: (options: LaunchOptions) => Promise<BrowserContextFactory>
|
||||
useSystemChrome?: () => boolean
|
||||
}
|
||||
|
||||
export interface BrowserContextFactory {
|
||||
debugging?: number
|
||||
newContext(options: { serviceWorkers: "block"; viewport: { width: number; height: number } }): Promise<BrowserContext>
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
route: BrowserRoute
|
||||
browserId: string
|
||||
context: BrowserContext
|
||||
page: Page
|
||||
origin: string
|
||||
state: BrowserState
|
||||
response?: number
|
||||
}
|
||||
|
||||
class BrowserNavigationError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status?: number,
|
||||
) {
|
||||
super(message)
|
||||
this.name = "BrowserNavigationError"
|
||||
}
|
||||
}
|
||||
|
||||
type RequestBody = {
|
||||
sessionID?: unknown
|
||||
projectID?: unknown
|
||||
directory?: unknown
|
||||
url?: unknown
|
||||
}
|
||||
|
||||
const MAX_BODY = 32 * 1024
|
||||
const MAX_SCREENSHOT = 2 * 1024 * 1024
|
||||
|
||||
export function diagnostic(error: unknown, url?: string): string {
|
||||
const text = stripVTControlCharacters(error instanceof Error ? error.message : String(error))
|
||||
if (/ERR_CONNECTION_REFUSED|ECONNREFUSED/i.test(text)) {
|
||||
return `Cannot connect to ${url ?? "the local application"}. Make sure the local server is running.`
|
||||
}
|
||||
if (/ERR_CONNECTION_TIMED_OUT|ETIMEDOUT|Timeout \d+ms exceeded/i.test(text)) {
|
||||
return "The local application did not respond in time. Check the server and try again."
|
||||
}
|
||||
if (/Target page, context or browser has been closed|Browser has been closed/i.test(text)) {
|
||||
return "The browser session was closed. Reopen the browser and try again."
|
||||
}
|
||||
return text
|
||||
.split(/\n\s*Call log:/i)[0]
|
||||
.replace(/^page\.(?:goto|reload):\s*/i, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 500)
|
||||
}
|
||||
|
||||
function reserve(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer()
|
||||
server.once("error", reject)
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Browser developer tools did not receive a local port")))
|
||||
return
|
||||
}
|
||||
server.close((error) => {
|
||||
if (error) return reject(error)
|
||||
resolve(address.port)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function framing(headers: Record<string, string> | undefined): string | undefined {
|
||||
if (!headers) return undefined
|
||||
const options = headers["x-frame-options"]?.trim().toLowerCase()
|
||||
if (options && (options === "deny" || options === "sameorigin" || options.startsWith("allow-from"))) {
|
||||
return `This application blocks embedded browser previews with X-Frame-Options: ${options.toUpperCase()}.`
|
||||
}
|
||||
const policy = headers["content-security-policy"]
|
||||
?.split(";")
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.toLowerCase().startsWith("frame-ancestors"))
|
||||
if (policy && /(?:^|\s)'(?:none|self)'(?:\s|$)/i.test(policy)) {
|
||||
return `This application blocks embedded browser previews with Content-Security-Policy: ${policy}.`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function loopback(url: URL): boolean {
|
||||
return url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname)
|
||||
}
|
||||
|
||||
function json(res: ServerResponse, status: number, value: unknown): void {
|
||||
const body = JSON.stringify(value)
|
||||
res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(body) })
|
||||
res.end(body)
|
||||
}
|
||||
|
||||
function read(req: IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = []
|
||||
let size = 0
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
size += chunk.length
|
||||
if (size > MAX_BODY) {
|
||||
reject(new Error("Request body is too large"))
|
||||
req.destroy()
|
||||
return
|
||||
}
|
||||
chunks.push(chunk)
|
||||
})
|
||||
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")))
|
||||
req.on("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
export class BrowserBroker {
|
||||
private readonly entries = new Map<string, Entry>()
|
||||
private readonly pending = new Map<string, Promise<unknown>>()
|
||||
private readonly listeners = new Set<(state: BrowserState) => void>()
|
||||
private readonly token = randomBytes(32).toString("hex")
|
||||
private owner: ((route: BrowserRoute) => BrowserRoute | undefined) | undefined
|
||||
private server: Server | undefined
|
||||
private readonly sockets = new Set<Socket>()
|
||||
private port: number | undefined
|
||||
private debugging: number | undefined
|
||||
private tools: BrowserDevtools | undefined
|
||||
private browser: BrowserContextFactory | undefined
|
||||
private browserStarting: Promise<BrowserContextFactory> | undefined
|
||||
private starting: Promise<void> | undefined
|
||||
private closed = false
|
||||
|
||||
constructor(private readonly opts: BrowserBrokerOptions) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.closed) throw new Error("Browser broker is closed")
|
||||
if (this.port !== undefined) return
|
||||
if (this.starting) return this.starting
|
||||
this.starting = new Promise<void>((resolve, reject) => {
|
||||
this.server = createServer((req, res) => {
|
||||
void this.handle(req, res)
|
||||
})
|
||||
this.server.on("connection", (socket) => {
|
||||
this.sockets.add(socket)
|
||||
socket.once("close", () => this.sockets.delete(socket))
|
||||
})
|
||||
this.server.once("error", reject)
|
||||
this.server.listen(0, "127.0.0.1", () => {
|
||||
const server = this.server
|
||||
const address = server?.address()
|
||||
if (!server || !address || typeof address === "string") {
|
||||
reject(new Error("Browser broker did not receive a local port"))
|
||||
return
|
||||
}
|
||||
this.port = address.port
|
||||
this.tools = new BrowserDevtools(server, address.port, this.opts.log, (browser, active) => {
|
||||
const entry = [...this.entries.values()].find((item) => item.browserId === browser)
|
||||
if (!entry || entry.state.inspecting === active) return
|
||||
entry.state.inspecting = active
|
||||
this.emit(entry.state)
|
||||
})
|
||||
resolve()
|
||||
})
|
||||
}).finally(() => {
|
||||
this.starting = undefined
|
||||
})
|
||||
return this.starting
|
||||
}
|
||||
|
||||
async env(): Promise<Record<string, string>> {
|
||||
await this.start()
|
||||
return {
|
||||
KILO_BROWSER_BROKER_URL: `http://127.0.0.1:${this.port}`,
|
||||
KILO_BROWSER_BROKER_TOKEN: this.token,
|
||||
}
|
||||
}
|
||||
|
||||
bind(owner: (route: BrowserRoute) => BrowserRoute | undefined): void {
|
||||
this.owner = owner
|
||||
}
|
||||
|
||||
subscribe(listener: (state: BrowserState) => void): () => void {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}
|
||||
|
||||
replay(listener: (state: BrowserState) => void): void {
|
||||
for (const entry of this.entries.values()) listener(this.copy(entry.state))
|
||||
}
|
||||
|
||||
get(sessionId: string, projectId?: string): BrowserState | undefined {
|
||||
const entries = [...this.entries.values()].filter(
|
||||
(entry) =>
|
||||
entry.route.sessionId === sessionId && (projectId === undefined || entry.route.projectId === projectId),
|
||||
)
|
||||
return entries.length === 1 ? this.copy(entries[0].state) : undefined
|
||||
}
|
||||
|
||||
sessions(): string[] {
|
||||
return [...new Set([...this.entries.values()].map((entry) => entry.route.sessionId))]
|
||||
}
|
||||
|
||||
open(route: BrowserRoute, target: string): Promise<BrowserState> {
|
||||
const scope = this.owner ? this.owner(route) : route
|
||||
if (!scope)
|
||||
return Promise.reject(new Error("Browser session does not belong to the requested project or directory"))
|
||||
return this.serial(this.key(scope.sessionId, scope.projectId), () => this.create(scope, target))
|
||||
}
|
||||
|
||||
private async create(scope: BrowserRoute, target: string): Promise<BrowserState> {
|
||||
this.available()
|
||||
const url = this.validate(target)
|
||||
const existing = this.entries.get(this.key(scope.sessionId, scope.projectId))
|
||||
if (existing) {
|
||||
if (existing.route.directory !== scope.directory) throw new Error("Browser session directory cannot change")
|
||||
if (scope.projectId && existing.route.projectId && existing.route.projectId !== scope.projectId) {
|
||||
throw new Error("Browser session project cannot change")
|
||||
}
|
||||
existing.route.projectId = scope.projectId ?? existing.route.projectId
|
||||
existing.state.projectId = existing.route.projectId
|
||||
const reload = existing.state.status === "ready" && existing.page.url() === url.href
|
||||
await this.goto(existing, url, reload)
|
||||
return this.copy(existing.state)
|
||||
}
|
||||
|
||||
const browser = await this.ensureBrowser()
|
||||
this.available()
|
||||
const context = await browser.newContext({
|
||||
serviceWorkers: "block",
|
||||
viewport: { width: 1280, height: 720 },
|
||||
})
|
||||
const page = await context.newPage().catch(async (error: unknown) => {
|
||||
await context.close().catch((failure: unknown) => this.opts.log("Browser context close failed", failure))
|
||||
throw error
|
||||
})
|
||||
if (this.closed) {
|
||||
await context.close().catch((error: unknown) => this.opts.log("Browser context close failed", error))
|
||||
throw new Error("Browser broker is closed")
|
||||
}
|
||||
const entry: Entry = {
|
||||
route: { ...scope },
|
||||
browserId: randomUUID(),
|
||||
context,
|
||||
page,
|
||||
origin: url.origin,
|
||||
state: {
|
||||
browserId: "",
|
||||
projectId: scope.projectId,
|
||||
sessionId: scope.sessionId,
|
||||
navigation: 0,
|
||||
status: "starting",
|
||||
errors: 0,
|
||||
logs: [],
|
||||
},
|
||||
}
|
||||
entry.state.browserId = entry.browserId
|
||||
const key = this.key(scope.sessionId, scope.projectId)
|
||||
this.entries.set(key, entry)
|
||||
this.attach(entry)
|
||||
await this.route(entry).catch(async (error: unknown) => {
|
||||
this.entries.delete(key)
|
||||
await context.close().catch((failure: unknown) => this.opts.log("Browser context close failed", failure))
|
||||
throw error
|
||||
})
|
||||
this.emit(entry.state)
|
||||
await this.goto(entry, url)
|
||||
return this.copy(entry.state)
|
||||
}
|
||||
|
||||
devtools(sessionId: string, projectId?: string, theme: "dark" | "light" = "dark"): Promise<BrowserDevtoolsInfo> {
|
||||
return this.serial(this.key(sessionId, projectId), async () => {
|
||||
this.available()
|
||||
const entry = this.require(sessionId, undefined, projectId)
|
||||
await this.start()
|
||||
const port = this.debugging ?? this.browser?.debugging
|
||||
if (!port || !this.tools || typeof entry.context.newCDPSession !== "function") {
|
||||
throw new Error("Browser developer tools are unavailable for this browser session")
|
||||
}
|
||||
const session = await entry.context.newCDPSession(entry.page)
|
||||
const info = await session
|
||||
.send("Target.getTargetInfo")
|
||||
.finally(() =>
|
||||
session.detach().catch((error: unknown) => this.opts.log("Browser CDP session close failed", error)),
|
||||
)
|
||||
return {
|
||||
browserId: entry.browserId,
|
||||
url: this.tools.open(entry.browserId, info.targetInfo.targetId, port, theme),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
inspect(
|
||||
sessionId: string,
|
||||
projectId: string | undefined,
|
||||
position: { x: number; y: number; width: number; height: number },
|
||||
detail = true,
|
||||
): Promise<BrowserInspection> {
|
||||
return this.serial(this.key(sessionId, projectId), async () => {
|
||||
this.available()
|
||||
const entry = this.require(sessionId, undefined, projectId)
|
||||
await this.point(entry, position)
|
||||
const selected: BrowserElement | undefined = await entry.page.evaluate(element, { ...position, detail })
|
||||
if (selected?.source) selected.source = await locate(entry.route.directory, selected.source)
|
||||
await this.update(entry)
|
||||
return {
|
||||
url: entry.state.url,
|
||||
title: entry.state.title,
|
||||
element: selected,
|
||||
logs: [...(entry.state.logs ?? [])],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
input(
|
||||
sessionId: string,
|
||||
projectId: string | undefined,
|
||||
position: { x: number; y: number; width: number; height: number },
|
||||
click: boolean,
|
||||
): Promise<void> {
|
||||
return this.serial(this.key(sessionId, projectId), async () => {
|
||||
this.available()
|
||||
const entry = this.require(sessionId, undefined, projectId)
|
||||
if (!entry.state.inspecting) return
|
||||
const point = await this.point(entry, position)
|
||||
await entry.page.mouse.move(point.x, point.y)
|
||||
if (!click) return
|
||||
await entry.page.mouse.down()
|
||||
await entry.page.mouse.up()
|
||||
})
|
||||
}
|
||||
|
||||
refresh(sessionId: string, projectId?: string): Promise<BrowserState> {
|
||||
return this.serial(this.key(sessionId, projectId), async () => {
|
||||
this.available()
|
||||
const entry = this.require(sessionId, undefined, projectId)
|
||||
const url = this.validate(entry.state.url ?? entry.origin)
|
||||
await this.goto(entry, url, true)
|
||||
return this.copy(entry.state)
|
||||
})
|
||||
}
|
||||
|
||||
close(sessionId: string, projectId?: string): Promise<void> {
|
||||
const entries = [...this.entries.values()].filter(
|
||||
(entry) =>
|
||||
entry.route.sessionId === sessionId && (projectId === undefined || entry.route.projectId === projectId),
|
||||
)
|
||||
return Promise.all(
|
||||
entries.map((entry) =>
|
||||
this.serial(this.key(entry.route.sessionId, entry.route.projectId), async () => {
|
||||
const key = this.key(entry.route.sessionId, entry.route.projectId)
|
||||
if (this.entries.get(key) !== entry) return
|
||||
this.entries.delete(key)
|
||||
this.tools?.revoke(entry.browserId)
|
||||
await entry.context.close().catch((error: unknown) => this.opts.log("Browser context close failed", error))
|
||||
entry.state.status = "closed"
|
||||
entry.state.screenshot = undefined
|
||||
this.emit(entry.state)
|
||||
}),
|
||||
),
|
||||
).then(() => undefined)
|
||||
}
|
||||
|
||||
async disposeAsync(): Promise<void> {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
await Promise.all(
|
||||
[...this.entries.values()].map((entry) => this.close(entry.route.sessionId, entry.route.projectId)),
|
||||
)
|
||||
await this.browserStarting?.catch((error: unknown) => this.opts.log("Browser startup failed", error))
|
||||
await this.browser?.close().catch((error: unknown) => this.opts.log("Browser close failed", error))
|
||||
this.browser = undefined
|
||||
this.debugging = undefined
|
||||
this.tools?.dispose()
|
||||
this.tools = undefined
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!this.server) return resolve()
|
||||
this.server.close(() => resolve())
|
||||
for (const socket of this.sockets) socket.destroy()
|
||||
this.sockets.clear()
|
||||
if (!this.server.listening) resolve()
|
||||
})
|
||||
this.server = undefined
|
||||
this.port = undefined
|
||||
this.listeners.clear()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
void this.disposeAsync().catch((error: unknown) => this.opts.log("Browser broker dispose failed", error))
|
||||
}
|
||||
|
||||
private async ensureBrowser(): Promise<BrowserContextFactory> {
|
||||
if (this.closed) throw new Error("Browser broker is closed")
|
||||
if (this.browser) return this.browser
|
||||
if (this.browserStarting) return this.browserStarting
|
||||
this.browserStarting = (async () => {
|
||||
const port = this.opts.launch ? undefined : await reserve()
|
||||
const config = options(this.opts.useSystemChrome?.() !== false, port)
|
||||
const browser = await (this.opts.launch?.(config) ?? chromium.launch(config))
|
||||
this.debugging = ("debugging" in browser ? browser.debugging : undefined) ?? port
|
||||
this.browser = browser
|
||||
return browser
|
||||
})()
|
||||
try {
|
||||
return await this.browserStarting
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
const action =
|
||||
this.opts.useSystemChrome?.() === false
|
||||
? "Install a compatible Playwright Chromium browser or enable Use System Chrome in Kilo Settings > Web Tools."
|
||||
: "Install Google Chrome or select an existing Playwright Chromium browser in Kilo Settings > Web Tools."
|
||||
throw new Error(`${action} ${detail}`.slice(0, 1000), { cause: error })
|
||||
} finally {
|
||||
this.browserStarting = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private record(entry: Entry, message: string): void {
|
||||
const text = message.replace(/\s+/g, " ").trim().slice(0, 1000)
|
||||
if (!text) return
|
||||
entry.state.logs = [...(entry.state.logs ?? []), text].slice(-20)
|
||||
}
|
||||
|
||||
private attach(entry: Entry): void {
|
||||
entry.page.on("response", (response) => {
|
||||
if (response.request().isNavigationRequest() && response.frame() === entry.page.mainFrame()) {
|
||||
entry.response = response.status()
|
||||
}
|
||||
})
|
||||
entry.page.on("console", (message) => {
|
||||
const type = message.type()
|
||||
if (type === "error") entry.state.errors++
|
||||
this.record(entry, `[${type}] ${message.text()}`)
|
||||
this.emit(entry.state)
|
||||
})
|
||||
entry.page.on("pageerror", (error) => {
|
||||
entry.state.errors++
|
||||
this.record(entry, error.message)
|
||||
this.emit(entry.state)
|
||||
})
|
||||
entry.page.on("popup", (page) => {
|
||||
entry.state.errors++
|
||||
this.record(entry, "Blocked browser popup")
|
||||
this.emit(entry.state)
|
||||
void page.close().catch((error: unknown) => this.opts.log("Browser popup close failed", error))
|
||||
})
|
||||
entry.page.on("framenavigated", (frame) => {
|
||||
if (frame !== entry.page.mainFrame()) return
|
||||
void this.update(entry).catch((error: unknown) => this.fail(entry, error))
|
||||
})
|
||||
}
|
||||
|
||||
private async route(entry: Entry): Promise<void> {
|
||||
await entry.context.route("**/*", async (route, request) => {
|
||||
const target = request.url()
|
||||
if (this.allowed(entry, target, Boolean(request.isNavigationRequest?.()))) {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
const origin = URL.canParse(target) ? new URL(target).origin : "invalid"
|
||||
this.opts.log("Blocked browser request", { sessionId: entry.route.sessionId, origin })
|
||||
entry.state.errors++
|
||||
this.record(entry, `Blocked browser request: ${origin}`)
|
||||
this.emit(entry.state)
|
||||
await route.abort("blockedbyclient")
|
||||
})
|
||||
await entry.context.routeWebSocket("**/*", async (socket) => {
|
||||
const target = socket.url()
|
||||
if (this.allowed(entry, target)) {
|
||||
socket.connectToServer()
|
||||
return
|
||||
}
|
||||
const origin = URL.canParse(target) ? new URL(target).origin : "invalid"
|
||||
this.opts.log("Blocked browser WebSocket", { sessionId: entry.route.sessionId, origin })
|
||||
entry.state.errors++
|
||||
this.record(entry, `Blocked browser request: ${origin}`)
|
||||
this.emit(entry.state)
|
||||
await socket.close({ code: 1008, reason: "Blocked browser origin" })
|
||||
})
|
||||
}
|
||||
|
||||
private async goto(entry: Entry, url: URL, reload = false): Promise<void> {
|
||||
entry.origin = url.origin
|
||||
entry.response = undefined
|
||||
entry.state.navigation++
|
||||
entry.state.status = "loading"
|
||||
entry.state.url = url.href
|
||||
entry.state.title = undefined
|
||||
entry.state.error = undefined
|
||||
entry.state.frameError = undefined
|
||||
entry.state.errors = 0
|
||||
entry.state.logs = []
|
||||
this.emit(entry.state)
|
||||
try {
|
||||
const response = reload
|
||||
? await entry.page.reload({ waitUntil: "domcontentloaded", timeout: 30_000 })
|
||||
: await entry.page.goto(url.href, { waitUntil: "domcontentloaded", timeout: 30_000 })
|
||||
entry.response = response?.status() ?? entry.response
|
||||
entry.state.frameError = framing(response?.headers?.())
|
||||
if (entry.response !== undefined && entry.response >= 400) {
|
||||
await this.update(entry)
|
||||
await this.capture(entry)
|
||||
throw new BrowserNavigationError(`Local application returned HTTP ${entry.response}`, entry.response)
|
||||
}
|
||||
await this.update(entry)
|
||||
await this.capture(entry)
|
||||
entry.state.status = "ready"
|
||||
this.emit(entry.state)
|
||||
} catch (error) {
|
||||
entry.state.url = url.href
|
||||
this.fail(entry, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async point(
|
||||
entry: Entry,
|
||||
position: { x: number; y: number; width: number; height: number },
|
||||
): Promise<{ x: number; y: number }> {
|
||||
if (
|
||||
!Number.isFinite(position.x) ||
|
||||
!Number.isFinite(position.y) ||
|
||||
position.x < 0 ||
|
||||
position.x > 1 ||
|
||||
position.y < 0 ||
|
||||
position.y > 1 ||
|
||||
!Number.isFinite(position.width) ||
|
||||
!Number.isFinite(position.height) ||
|
||||
position.width < 1 ||
|
||||
position.height < 1
|
||||
) {
|
||||
throw new Error("Browser element coordinates are invalid")
|
||||
}
|
||||
const width = Math.max(1, Math.min(1920, Math.round(position.width)))
|
||||
const height = Math.max(1, Math.min(1440, Math.round(position.height)))
|
||||
const viewport = entry.page.viewportSize?.()
|
||||
if (viewport?.width !== width || viewport.height !== height) {
|
||||
await entry.page.setViewportSize({ width, height })
|
||||
}
|
||||
return { x: position.x * width, y: position.y * height }
|
||||
}
|
||||
|
||||
private async update(entry: Entry): Promise<void> {
|
||||
entry.state.url = entry.page.url()
|
||||
entry.state.title = await entry.page.title().catch(() => undefined)
|
||||
}
|
||||
|
||||
private async capture(entry: Entry): Promise<void> {
|
||||
const data = await entry.page.screenshot({ type: "jpeg", quality: 70 })
|
||||
if (data.byteLength > MAX_SCREENSHOT) throw new Error("Browser screenshot is too large")
|
||||
entry.state.screenshot = `data:image/jpeg;base64,${data.toString("base64")}`
|
||||
entry.state.mime = "image/jpeg"
|
||||
this.emit(entry.state)
|
||||
}
|
||||
|
||||
private key(session: string, project?: string): string {
|
||||
return `${project ?? ""}\0${session}`
|
||||
}
|
||||
|
||||
private async serial<T>(session: string, operation: () => Promise<T>): Promise<T> {
|
||||
const previous = this.pending.get(session)?.catch(() => undefined) ?? Promise.resolve()
|
||||
const next = previous.then(operation)
|
||||
this.pending.set(session, next)
|
||||
try {
|
||||
return await next
|
||||
} finally {
|
||||
if (this.pending.get(session) === next) this.pending.delete(session)
|
||||
}
|
||||
}
|
||||
|
||||
private available(): void {
|
||||
if (this.closed) throw new Error("Browser broker is closed")
|
||||
if (this.opts.trusted && !this.opts.trusted()) throw new Error("Browser preview requires a trusted workspace.")
|
||||
if (this.opts.enabled && !this.opts.enabled()) {
|
||||
throw new Error("Browser automation is disabled. Enable it in Kilo Settings > Experimental.")
|
||||
}
|
||||
}
|
||||
|
||||
private fail(entry: Entry, error: unknown): void {
|
||||
entry.state.status = "error"
|
||||
const text = error instanceof Error ? error.message : String(error)
|
||||
entry.state.error =
|
||||
entry.response && text.includes("ERR_HTTP_RESPONSE_CODE_FAILURE")
|
||||
? `Local application returned HTTP ${entry.response}`
|
||||
: diagnostic(error, entry.state.url)
|
||||
this.emit(entry.state)
|
||||
}
|
||||
|
||||
private require(sessionId: string, directory?: string, projectId?: string): Entry {
|
||||
const entries = [...this.entries.values()].filter((entry) => entry.route.sessionId === sessionId)
|
||||
if (entries.length === 0) throw new Error("No browser is open for this Agent Manager session")
|
||||
const projects = projectId === undefined ? entries : entries.filter((entry) => entry.route.projectId === projectId)
|
||||
if (projects.length === 0) throw new Error("Browser session project does not match")
|
||||
const directories =
|
||||
directory === undefined ? projects : projects.filter((entry) => entry.route.directory === directory)
|
||||
if (directories.length === 0) throw new Error("Browser session directory does not match")
|
||||
if (directories.length !== 1) throw new Error("Browser session identity is ambiguous")
|
||||
return directories[0]
|
||||
}
|
||||
|
||||
validate(target: string): URL {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(target)
|
||||
} catch {
|
||||
throw new Error("Browser URL is invalid")
|
||||
}
|
||||
if (!loopback(url) || url.username || url.password) {
|
||||
throw new Error(
|
||||
"Browser URLs must use HTTP localhost or 127.0.0.1 without credentials. Use localhost for IPv6 loopback servers.",
|
||||
)
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private allowed(entry: Entry, target: string, navigation = false): boolean {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(target)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (["about:", "blob:", "data:"].includes(url.protocol)) return !navigation
|
||||
if (url.protocol === "ws:" || url.protocol === "wss:")
|
||||
return `${url.protocol === "ws:" ? "http:" : "https:"}//${url.host}` === entry.origin
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return false
|
||||
return url.origin === entry.origin
|
||||
}
|
||||
|
||||
private copy(state: BrowserState): BrowserState {
|
||||
return { ...state, logs: state.logs ? [...state.logs] : undefined }
|
||||
}
|
||||
|
||||
private emit(state: BrowserState): void {
|
||||
if (state.status !== "closed") {
|
||||
if (this.closed) return
|
||||
const entry = this.entries.get(this.key(state.sessionId, state.projectId))
|
||||
if (!entry || entry.browserId !== state.browserId) return
|
||||
}
|
||||
const next = this.copy(state)
|
||||
for (const listener of this.listeners) listener(next)
|
||||
}
|
||||
|
||||
private authorized(req: IncomingMessage): boolean {
|
||||
const value = req.headers.authorization
|
||||
if (typeof value !== "string") return false
|
||||
const actual = Buffer.from(value)
|
||||
const expected = Buffer.from(`Bearer ${this.token}`)
|
||||
return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected)
|
||||
}
|
||||
|
||||
private status(req: IncomingMessage, res: ServerResponse, route: URL): boolean {
|
||||
if (req.method !== "GET" || route.pathname !== "/browser/status") return false
|
||||
json(res, 200, {
|
||||
enabled: !this.closed && this.opts.enabled?.() !== false && this.opts.trusted?.() !== false,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private async operation(path: string, body: RequestBody & { sessionID: string; directory: string }) {
|
||||
const project = typeof body.projectID === "string" ? body.projectID : undefined
|
||||
if (path === "/browser/open") {
|
||||
if (typeof body.url !== "string") throw new Error("A local application URL is required")
|
||||
return this.open({ projectId: project, sessionId: body.sessionID, directory: body.directory }, body.url)
|
||||
}
|
||||
if (!["/browser/refresh", "/browser/close"].includes(path)) return undefined
|
||||
const entry = this.require(body.sessionID, body.directory, project)
|
||||
if (path === "/browser/refresh") return this.refresh(entry.route.sessionId, entry.route.projectId)
|
||||
await this.close(entry.route.sessionId, entry.route.projectId)
|
||||
return { sessionId: entry.route.sessionId, status: "closed" as const }
|
||||
}
|
||||
|
||||
private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
const route = new URL(req.url ?? "/", "http://127.0.0.1")
|
||||
if (this.tools && (await this.tools.handle(req, res, route))) return
|
||||
if (!this.authorized(req)) {
|
||||
json(res, 401, { error: "Unauthorized" })
|
||||
return
|
||||
}
|
||||
if (this.status(req, res, route)) return
|
||||
if (req.method !== "POST") {
|
||||
json(res, 405, { error: "Method not allowed" })
|
||||
return
|
||||
}
|
||||
let body: RequestBody
|
||||
try {
|
||||
const value: unknown = JSON.parse(await read(req))
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid JSON object")
|
||||
body = value
|
||||
} catch (error) {
|
||||
json(res, 400, { error: error instanceof Error ? error.message : "Invalid JSON" })
|
||||
return
|
||||
}
|
||||
if (typeof body.sessionID !== "string" || typeof body.directory !== "string") {
|
||||
json(res, 400, { error: "sessionID and directory are required" })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await this.operation(route.pathname, {
|
||||
...body,
|
||||
sessionID: body.sessionID,
|
||||
directory: body.directory,
|
||||
})
|
||||
if (!result) {
|
||||
json(res, 404, { error: "Unknown browser operation" })
|
||||
return
|
||||
}
|
||||
json(res, 200, result)
|
||||
} catch (error) {
|
||||
json(res, 400, { error: diagnostic(error, typeof body.url === "string" ? body.url : undefined) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto"
|
||||
import { request, type IncomingHttpHeaders, type IncomingMessage, type Server, type ServerResponse } from "node:http"
|
||||
import type { Duplex } from "node:stream"
|
||||
import { URL } from "node:url"
|
||||
import WebSocket, { WebSocketServer, type RawData } from "ws"
|
||||
|
||||
type Target = {
|
||||
browser: string
|
||||
page: string
|
||||
port: number
|
||||
secret: string
|
||||
theme: "dark" | "light"
|
||||
expires: number
|
||||
sockets: Set<WebSocket>
|
||||
inspecting: Set<WebSocket>
|
||||
}
|
||||
|
||||
type Match = { target: Target; path: string }
|
||||
type Message = { method?: string; params?: { mode?: string } }
|
||||
|
||||
const LIFETIME = 15 * 60 * 1000
|
||||
const PAYLOAD = 16 * 1024 * 1024
|
||||
|
||||
function equal(left: string, right: string): boolean {
|
||||
const actual = Buffer.from(left)
|
||||
const expected = Buffer.from(right)
|
||||
return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected)
|
||||
}
|
||||
|
||||
function reject(socket: Duplex, status: number, reason: string): void {
|
||||
socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
|
||||
socket.destroy()
|
||||
}
|
||||
|
||||
function resource(port: number, path: string): Promise<{ status: number; headers: IncomingHttpHeaders; body: Buffer }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = request({ hostname: "127.0.0.1", port, path, method: "GET" }, (response) => {
|
||||
const chunks: Buffer[] = []
|
||||
let size = 0
|
||||
response.on("data", (chunk: Buffer) => {
|
||||
size += chunk.byteLength
|
||||
if (size > PAYLOAD) {
|
||||
response.destroy(new Error("Browser developer tools asset exceeds the size limit"))
|
||||
return
|
||||
}
|
||||
chunks.push(chunk)
|
||||
})
|
||||
response.once("error", reject)
|
||||
response.once("end", () =>
|
||||
resolve({ status: response.statusCode ?? 502, headers: response.headers, body: Buffer.concat(chunks) }),
|
||||
)
|
||||
})
|
||||
req.setTimeout(10_000, () => req.destroy(new Error("Browser developer tools asset request timed out")))
|
||||
req.once("error", reject)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
function inspect(data: RawData): Message | undefined {
|
||||
const value = Array.isArray(data)
|
||||
? Buffer.concat(data).toString("utf8")
|
||||
: data instanceof ArrayBuffer
|
||||
? Buffer.from(data).toString("utf8")
|
||||
: data.toString("utf8")
|
||||
if (!value.includes("Overlay.")) return
|
||||
try {
|
||||
const message = JSON.parse(value) as Message
|
||||
return typeof message.method === "string" ? message : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export class BrowserDevtools {
|
||||
private readonly targets = new Map<string, Target>()
|
||||
private readonly sockets = new WebSocketServer({ noServer: true, maxPayload: PAYLOAD })
|
||||
private readonly upgrade = (req: IncomingMessage, socket: Duplex, head: Buffer): void => {
|
||||
this.connect(req, socket, head)
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly server: Server,
|
||||
private readonly port: number,
|
||||
private readonly log: (...args: unknown[]) => void,
|
||||
private readonly mode: (browser: string, active: boolean) => void,
|
||||
) {
|
||||
this.server.on("upgrade", this.upgrade)
|
||||
}
|
||||
|
||||
open(browser: string, page: string, port: number, theme: "dark" | "light"): string {
|
||||
this.revoke(browser)
|
||||
const secret = randomBytes(32).toString("hex")
|
||||
const target: Target = {
|
||||
browser,
|
||||
page,
|
||||
port,
|
||||
secret,
|
||||
theme,
|
||||
expires: Date.now() + LIFETIME,
|
||||
sockets: new Set(),
|
||||
inspecting: new Set(),
|
||||
}
|
||||
this.targets.set(browser, target)
|
||||
const path = `/browser/devtools/${browser}/${secret}`
|
||||
const query = new URLSearchParams({
|
||||
ws: `127.0.0.1:${this.port}${path}/connect`,
|
||||
})
|
||||
return `http://127.0.0.1:${this.port}${path}/inspector.html?${query}`
|
||||
}
|
||||
|
||||
async handle(req: IncomingMessage, res: ServerResponse, route: URL): Promise<boolean> {
|
||||
if (!route.pathname.startsWith("/browser/devtools/")) return false
|
||||
const scope = this.match(req, route)
|
||||
if (!scope || req.method !== "GET" || scope.path === "connect") {
|
||||
res.writeHead(scope ? 405 : 401, { "cache-control": "no-store" })
|
||||
res.end()
|
||||
return true
|
||||
}
|
||||
if (scope.path === "kilo-bootstrap.js") {
|
||||
const script = [
|
||||
`localStorage.setItem("ui-theme",JSON.stringify(${JSON.stringify(scope.target.theme)}))`,
|
||||
'localStorage.setItem("currentDockState",JSON.stringify("undocked"))',
|
||||
].join(";")
|
||||
res.writeHead(200, {
|
||||
"cache-control": "no-store",
|
||||
"content-length": Buffer.byteLength(script),
|
||||
"content-type": "application/javascript; charset=utf-8",
|
||||
})
|
||||
res.end(script)
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const response = await resource(scope.target.port, `/devtools/${scope.path}${route.search}`)
|
||||
const data = response.body
|
||||
const body =
|
||||
scope.path === "inspector.html" && response.status === 200
|
||||
? Buffer.from(
|
||||
data
|
||||
.toString("utf8")
|
||||
.replace(
|
||||
/<script\s+type="module"/i,
|
||||
'<script src="./kilo-bootstrap.js"></script><script type="module"',
|
||||
),
|
||||
)
|
||||
: data
|
||||
const headers: Record<string, string | number> = {
|
||||
"cache-control": "no-store",
|
||||
"content-length": body.byteLength,
|
||||
"referrer-policy": "no-referrer",
|
||||
}
|
||||
for (const name of ["content-type", "content-security-policy"]) {
|
||||
const value = response.headers[name]
|
||||
if (typeof value === "string") headers[name] = value
|
||||
}
|
||||
res.writeHead(response.status, headers)
|
||||
res.end(body)
|
||||
} catch (error) {
|
||||
this.log("Browser developer tools asset failed", { browser: scope.target.browser, error })
|
||||
res.writeHead(502, { "cache-control": "no-store" })
|
||||
res.end()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
revoke(browser: string): void {
|
||||
const target = this.targets.get(browser)
|
||||
if (!target) return
|
||||
this.targets.delete(browser)
|
||||
if (target.inspecting.size) this.mode(browser, false)
|
||||
target.inspecting.clear()
|
||||
for (const socket of target.sockets) socket.close(1001, "Browser session closed")
|
||||
target.sockets.clear()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.server.off("upgrade", this.upgrade)
|
||||
for (const browser of [...this.targets.keys()]) this.revoke(browser)
|
||||
this.sockets.close()
|
||||
}
|
||||
|
||||
private match(req: IncomingMessage, route: URL): Match | undefined {
|
||||
if (req.headers.host !== `127.0.0.1:${this.port}`) return
|
||||
const parts = route.pathname.split("/")
|
||||
if (parts[1] !== "browser" || parts[2] !== "devtools" || parts.length < 6) return
|
||||
const target = this.targets.get(parts[3])
|
||||
if (!target || target.expires < Date.now() || !equal(parts[4], target.secret)) return
|
||||
const path = parts.slice(5).join("/")
|
||||
if (!path || path.includes("..") || path.includes("\\") || /%2f|%5c/i.test(path)) return
|
||||
target.expires = Date.now() + LIFETIME
|
||||
return { target, path }
|
||||
}
|
||||
|
||||
private connect(req: IncomingMessage, socket: Duplex, head: Buffer): void {
|
||||
const route = new URL(req.url ?? "/", `http://127.0.0.1:${this.port}`)
|
||||
const scope = this.match(req, route)
|
||||
if (!scope || scope.path !== "connect") return reject(socket, 401, "Unauthorized")
|
||||
if (req.headers.origin !== `http://127.0.0.1:${this.port}`) return reject(socket, 403, "Forbidden")
|
||||
this.sockets.handleUpgrade(req, socket, head, (client) => this.bridge(scope.target, client))
|
||||
}
|
||||
|
||||
private select(target: Target, client: WebSocket, active: boolean): void {
|
||||
const previous = target.inspecting.size > 0
|
||||
if (active) target.inspecting.add(client)
|
||||
if (!active) target.inspecting.delete(client)
|
||||
const current = target.inspecting.size > 0
|
||||
if (previous !== current) this.mode(target.browser, current)
|
||||
}
|
||||
|
||||
private bridge(target: Target, client: WebSocket): void {
|
||||
const upstream = new WebSocket(`ws://127.0.0.1:${target.port}/devtools/page/${target.page}`, {
|
||||
maxPayload: PAYLOAD,
|
||||
})
|
||||
const queued: Array<{ data: RawData; binary: boolean }> = []
|
||||
target.sockets.add(client)
|
||||
|
||||
const close = () => {
|
||||
this.select(target, client, false)
|
||||
target.sockets.delete(client)
|
||||
if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) client.close()
|
||||
if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) upstream.close()
|
||||
}
|
||||
|
||||
upstream.on("open", () => {
|
||||
for (const item of queued.splice(0)) upstream.send(item.data, { binary: item.binary })
|
||||
})
|
||||
upstream.on("message", (data, binary) => {
|
||||
const message = inspect(data)
|
||||
if (message?.method === "Overlay.inspectNodeRequested" || message?.method === "Overlay.inspectModeCanceled") {
|
||||
this.select(target, client, false)
|
||||
}
|
||||
if (client.readyState === WebSocket.OPEN) client.send(data, { binary })
|
||||
})
|
||||
upstream.on("error", (error) => {
|
||||
this.log("Browser developer tools connection failed", { browser: target.browser, error })
|
||||
close()
|
||||
})
|
||||
upstream.on("close", close)
|
||||
client.on("message", (data, binary) => {
|
||||
const message = inspect(data)
|
||||
if (message?.method === "Overlay.setInspectMode") {
|
||||
this.select(
|
||||
target,
|
||||
client,
|
||||
message.params?.mode === "searchForNode" || message.params?.mode === "searchForUAShadowDOM",
|
||||
)
|
||||
}
|
||||
if (upstream.readyState === WebSocket.OPEN) {
|
||||
upstream.send(data, { binary })
|
||||
return
|
||||
}
|
||||
if (upstream.readyState === WebSocket.CONNECTING) queued.push({ data, binary })
|
||||
})
|
||||
client.on("error", (error) => {
|
||||
this.log("Browser developer tools client failed", { browser: target.browser, error })
|
||||
close()
|
||||
})
|
||||
client.on("close", close)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { readFile, realpath, stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
function integer(value: number | undefined) {
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined
|
||||
}
|
||||
|
||||
async function coordinates(file: string, source: { line?: number; column?: number }, size: number) {
|
||||
const line = integer(source.line)
|
||||
const text = line && size <= 1_048_576 ? await readFile(file, "utf8").catch(() => undefined) : undefined
|
||||
const lines = text?.split("\n")
|
||||
const valid = line && lines && line <= lines.length ? line : undefined
|
||||
const column = integer(source.column)
|
||||
return {
|
||||
line: valid,
|
||||
column: valid && column && column <= (lines?.[valid - 1]?.length ?? 0) + 1 ? column : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function locate(directory: string, source?: { file: string; line?: number; column?: number }) {
|
||||
if (!source?.file || source.file.includes("\0") || /^[a-z][a-z0-9+.-]*:\/\//i.test(source.file)) return undefined
|
||||
if (!/\.(?:[cm]?[jt]sx?|vue|svelte|html?|css|scss|sass|less)$/i.test(source.file)) return undefined
|
||||
const candidate = path.resolve(directory, source.file)
|
||||
const [root, file] = await Promise.all([
|
||||
realpath(directory).catch(() => undefined),
|
||||
realpath(candidate).catch(() => undefined),
|
||||
])
|
||||
if (!root || !file) return undefined
|
||||
const relative = path.relative(root, file)
|
||||
if (!relative || path.isAbsolute(relative) || relative.split(path.sep).includes("..")) return undefined
|
||||
if (relative.split(path.sep).some((part) => part === "node_modules" || part === ".git")) return undefined
|
||||
const info = await stat(file).catch(() => undefined)
|
||||
if (!info?.isFile()) return undefined
|
||||
return { file: relative.split(path.sep).join("/"), ...(await coordinates(file, source, info.size)) }
|
||||
}
|
||||
|
||||
export function capture(position: { x: number; y: number; detail?: boolean }) {
|
||||
const root = document.documentElement
|
||||
if (root.scrollHeight > innerHeight && root.clientWidth === innerWidth) {
|
||||
root.style.setProperty("scrollbar-gutter", "stable")
|
||||
}
|
||||
const node = document.elementFromPoint(position.x * innerWidth, position.y * innerHeight)
|
||||
if (!(node instanceof Element)) return undefined
|
||||
|
||||
const label = (element: Element) => {
|
||||
const tag = element.tagName.toLowerCase()
|
||||
if (element.id) return `${tag}#${CSS.escape(element.id.slice(0, 120))}`
|
||||
const classes = [...element.classList].slice(0, 3).map((item) => `.${CSS.escape(item.slice(0, 60))}`)
|
||||
return `${tag}${classes.join("")}`
|
||||
}
|
||||
const locator = (element: Element) => {
|
||||
if (element.id && element.id.length <= 180) {
|
||||
const selector = `#${CSS.escape(element.id)}`
|
||||
if (document.querySelectorAll(selector).length === 1) return selector
|
||||
}
|
||||
for (const name of ["data-testid", "data-test", "data-cy", "aria-label"]) {
|
||||
const value = element.getAttribute(name)
|
||||
if (!value || value.length > 160) continue
|
||||
const selector = `${element.tagName.toLowerCase()}[${name}="${CSS.escape(value)}"]`
|
||||
if (document.querySelectorAll(selector).length === 1) return selector
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const selector = () => {
|
||||
const direct = locator(node)
|
||||
if (direct) return direct
|
||||
const path: string[] = []
|
||||
let current: Element | null = node
|
||||
while (current && path.length < 128) {
|
||||
const anchor = locator(current)
|
||||
if (anchor) {
|
||||
path.unshift(anchor)
|
||||
const value = path.join(" > ")
|
||||
return value.length <= 2048 && document.querySelectorAll(value).length === 1 ? value : undefined
|
||||
}
|
||||
const tag = current.tagName.toLowerCase()
|
||||
const siblings: Element[] = current.parentElement
|
||||
? [...current.parentElement.children].filter((item) => item.tagName === current?.tagName)
|
||||
: []
|
||||
path.unshift(`${tag}${siblings.length > 1 ? `:nth-of-type(${siblings.indexOf(current) + 1})` : ""}`)
|
||||
const value = path.join(" > ")
|
||||
if (value.length > 2048) return undefined
|
||||
if (document.querySelectorAll(value).length === 1) return value
|
||||
current = current.parentElement
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const identity = selector()
|
||||
if (!identity) return undefined
|
||||
const rect = node.getBoundingClientRect()
|
||||
const left = Math.max(0, Math.min(innerWidth, rect.left))
|
||||
const top = Math.max(0, Math.min(innerHeight, rect.top))
|
||||
const right = Math.max(left, Math.min(innerWidth, rect.right))
|
||||
const bottom = Math.max(top, Math.min(innerHeight, rect.bottom))
|
||||
const result = {
|
||||
tag: node.tagName.toLowerCase(),
|
||||
id: node.id.slice(0, 120) || undefined,
|
||||
classes: node.getAttribute("class")?.trim().slice(0, 180) || undefined,
|
||||
selector: identity,
|
||||
rect: {
|
||||
x: left / innerWidth,
|
||||
y: top / innerHeight,
|
||||
width: (right - left) / innerWidth,
|
||||
height: (bottom - top) / innerHeight,
|
||||
},
|
||||
}
|
||||
if (position.detail === false) return result
|
||||
|
||||
const ancestry: Element[] = []
|
||||
let parent: Element | null = node
|
||||
while (parent && ancestry.length < 6) {
|
||||
ancestry.unshift(parent)
|
||||
parent = parent.parentElement
|
||||
}
|
||||
const hidden = (element: Element) => {
|
||||
if (
|
||||
element.matches(
|
||||
"script,style,noscript,template,input,textarea,select,[hidden],[aria-hidden=true],[contenteditable]:not([contenteditable=false])",
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
const style = getComputedStyle(element)
|
||||
return (
|
||||
style.display === "none" ||
|
||||
style.visibility === "hidden" ||
|
||||
style.visibility === "collapse" ||
|
||||
style.opacity === "0" ||
|
||||
style.contentVisibility === "hidden"
|
||||
)
|
||||
}
|
||||
const content = () => {
|
||||
const editable = node.closest("input,textarea,select,[contenteditable]:not([contenteditable=false])")
|
||||
if (editable) return editable.getAttribute("aria-label")?.slice(0, 180)
|
||||
const pending: Node[] = []
|
||||
let current: Node | null = node.firstChild
|
||||
let visits = 0
|
||||
let value = ""
|
||||
while (current && visits++ < 256 && value.length < 180) {
|
||||
if (current instanceof Element && !hidden(current) && current.firstChild) {
|
||||
if (current.nextSibling) pending.push(current.nextSibling)
|
||||
current = current.firstChild
|
||||
continue
|
||||
}
|
||||
if (current.nodeType === 3) value += (current.textContent ?? "").replace(/\s+/g, " ").slice(0, 180 - value.length)
|
||||
current = current.nextSibling ?? pending.pop() ?? null
|
||||
}
|
||||
return value.trim() || undefined
|
||||
}
|
||||
const text = content()
|
||||
const snippet = node.cloneNode(false) as Element
|
||||
const allowed = new Set([
|
||||
"id",
|
||||
"class",
|
||||
"role",
|
||||
"type",
|
||||
"name",
|
||||
"aria-label",
|
||||
"title",
|
||||
"data-testid",
|
||||
"data-test",
|
||||
"data-cy",
|
||||
])
|
||||
for (const attribute of [...snippet.attributes]) {
|
||||
if (!allowed.has(attribute.name)) snippet.removeAttribute(attribute.name)
|
||||
else snippet.setAttribute(attribute.name, attribute.value.slice(0, 180))
|
||||
}
|
||||
snippet.textContent = text ?? ""
|
||||
const styles = getComputedStyle(node)
|
||||
const source = () => {
|
||||
const file = node.getAttribute("data-source-file")
|
||||
if (file) {
|
||||
const line = Number(node.getAttribute("data-source-line"))
|
||||
const column = Number(node.getAttribute("data-source-column"))
|
||||
return {
|
||||
file: file.slice(0, 4096),
|
||||
line: Number.isInteger(line) && line > 0 ? line : undefined,
|
||||
column: Number.isInteger(column) && column > 0 ? column : undefined,
|
||||
}
|
||||
}
|
||||
const key = Object.keys(node).find(
|
||||
(item) => item.startsWith("__reactFiber$") || item.startsWith("__reactInternalInstance$"),
|
||||
)
|
||||
if (!key) return undefined
|
||||
type Fiber = {
|
||||
_debugSource?: { fileName?: string; lineNumber?: number; columnNumber?: number }
|
||||
_debugOwner?: Fiber
|
||||
return?: Fiber
|
||||
}
|
||||
let fiber: Fiber | undefined = (node as unknown as Record<string, Fiber>)[key]
|
||||
for (let depth = 0; fiber && depth < 8; depth++) {
|
||||
const origin = fiber._debugSource
|
||||
if (origin?.fileName) {
|
||||
return { file: origin.fileName.slice(0, 4096), line: origin.lineNumber, column: origin.columnNumber }
|
||||
}
|
||||
fiber = fiber._debugOwner ?? fiber.return
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
text,
|
||||
hierarchy: ancestry.map(label),
|
||||
html: snippet.outerHTML.slice(0, 800),
|
||||
styles: { color: styles.color.slice(0, 80), backgroundColor: styles.backgroundColor.slice(0, 80) },
|
||||
source: source(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { LaunchOptions } from "playwright-core"
|
||||
|
||||
export function options(
|
||||
system: boolean,
|
||||
port?: number,
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): LaunchOptions {
|
||||
return {
|
||||
chromiumSandbox: true,
|
||||
headless: true,
|
||||
env: Object.fromEntries(
|
||||
Object.entries(env).filter(
|
||||
([key, value]) =>
|
||||
typeof value === "string" &&
|
||||
!/proxy/i.test(key) &&
|
||||
!/(TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY)(_|$)/i.test(key),
|
||||
),
|
||||
),
|
||||
args: [
|
||||
"--no-proxy-server",
|
||||
...(port ? ["--remote-debugging-address=127.0.0.1", `--remote-debugging-port=${port}`] : []),
|
||||
],
|
||||
...(system ? { channel: "chrome" } : {}),
|
||||
}
|
||||
}
|
||||
@@ -1 +1,9 @@
|
||||
export { BrowserAutomationService } from "./browser-automation-service"
|
||||
export { BrowserBroker, diagnostic } from "./browser-broker"
|
||||
export type {
|
||||
BrowserContextFactory,
|
||||
BrowserElement,
|
||||
BrowserInspection,
|
||||
BrowserRoute,
|
||||
BrowserState,
|
||||
BrowserStatus,
|
||||
} from "./browser-broker"
|
||||
|
||||
@@ -133,7 +133,7 @@ export class KiloConnectionService {
|
||||
private viewedDirty = false
|
||||
private unsubRemote: (() => void) | null = null
|
||||
|
||||
constructor(context: vscode.ExtensionContext) {
|
||||
constructor(context: vscode.ExtensionContext, env?: () => Promise<Record<string, string>>) {
|
||||
const state =
|
||||
context.workspaceState ??
|
||||
({
|
||||
@@ -141,7 +141,7 @@ export class KiloConnectionService {
|
||||
update: async () => undefined,
|
||||
} satisfies Pick<vscode.Memento, "get" | "update">)
|
||||
this.sandboxPreference = new SandboxPreference(state)
|
||||
this.serverManager = new ServerManager(context, (code, signal) => this.handleServerExit(code, signal))
|
||||
this.serverManager = new ServerManager(context, (code, signal) => this.handleServerExit(code, signal), env)
|
||||
this.active = vscode.window.state.focused
|
||||
this.windowStateDisposable = vscode.window.onDidChangeWindowState((ws) => {
|
||||
this.active = ws.focused
|
||||
|
||||
@@ -45,6 +45,7 @@ export class ServerManager {
|
||||
constructor(
|
||||
private readonly context: vscode.ExtensionContext,
|
||||
private readonly onExit?: ServerExitListener,
|
||||
private readonly env?: () => Promise<Record<string, string>>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -90,6 +91,7 @@ export class ServerManager {
|
||||
console.log("[Kilo New] ServerManager: 📄 CLI isFile:", stat.isFile())
|
||||
console.log("[Kilo New] ServerManager: 📄 CLI mode (octal):", (stat.mode & 0o777).toString(8))
|
||||
|
||||
const extraEnv = await this.env?.()
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log("[Kilo New] ServerManager: 🎬 Spawning CLI process:", cliPath, ["serve", "--port", "0"])
|
||||
const cfg = vscode.workspace.getConfiguration("kilo-code.new")
|
||||
@@ -156,6 +158,7 @@ export class ServerManager {
|
||||
...(!claudeCompat && { KILO_DISABLE_CLAUDE_CODE: "true" }),
|
||||
...resolveTreeSitterEnv(this.context.extensionPath),
|
||||
...bwrapEnv,
|
||||
...extraEnv,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: true,
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import { partReview, type ReviewMessageData } from "./review-comments"
|
||||
|
||||
export interface BrowserReference {
|
||||
id: string
|
||||
sessionId: string
|
||||
selector: string
|
||||
text?: string
|
||||
url?: string
|
||||
title?: string
|
||||
hierarchy?: string[]
|
||||
html?: string
|
||||
styles?: { color?: string; backgroundColor?: string }
|
||||
source?: { file: string; line?: number; column?: number }
|
||||
content?: string
|
||||
}
|
||||
|
||||
export interface BrowserFeedbackData {
|
||||
version: 1
|
||||
references: BrowserReference[]
|
||||
}
|
||||
|
||||
export interface FeedbackView {
|
||||
review?: ReviewMessageData
|
||||
browserFeedback?: BrowserFeedbackData
|
||||
body: string
|
||||
}
|
||||
|
||||
const REFERENCE_LIMIT = 20
|
||||
const HIERARCHY_LIMIT = 20
|
||||
const TOTAL_LIMIT = 200_000
|
||||
const ID_LIMIT = 512
|
||||
const SESSION_LIMIT = 512
|
||||
const SELECTOR_LIMIT = 4_096
|
||||
const TEXT_LIMIT = 20_000
|
||||
const URL_LIMIT = 4_096
|
||||
const TITLE_LIMIT = 2_000
|
||||
const HIERARCHY_ITEM_LIMIT = 512
|
||||
const HTML_LIMIT = 20_000
|
||||
const STYLE_LIMIT = 256
|
||||
const SOURCE_LIMIT = 4_096
|
||||
|
||||
function record(value: unknown): Record<string, unknown> | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function string(value: unknown, limit: number): string | undefined {
|
||||
if (typeof value !== "string" || value.length > limit) return undefined
|
||||
return value
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, limit: number): string | false | undefined {
|
||||
if (value === undefined) return undefined
|
||||
const result = string(value, limit)
|
||||
return result === undefined ? false : result
|
||||
}
|
||||
|
||||
function safePath(value: string): boolean {
|
||||
const absolute = value.startsWith("/") || value.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(value)
|
||||
return !absolute && !value.split(/[\\/]/).includes("..") && !value.includes("\0")
|
||||
}
|
||||
|
||||
function url(value: string): string | undefined {
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return undefined
|
||||
parsed.username = ""
|
||||
parsed.password = ""
|
||||
parsed.search = ""
|
||||
parsed.hash = ""
|
||||
return parsed.toString()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function optionalUrl(value: unknown): string | false | undefined {
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value !== "string" || value.length > URL_LIMIT) return false
|
||||
const result = url(value)
|
||||
return result === undefined ? false : result
|
||||
}
|
||||
|
||||
function positive(value: unknown): number | false | undefined {
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) return false
|
||||
return value
|
||||
}
|
||||
|
||||
function styles(value: unknown): BrowserReference["styles"] | false | undefined {
|
||||
if (value === undefined) return undefined
|
||||
const item = record(value)
|
||||
if (!item) return false
|
||||
const color = optionalString(item.color, STYLE_LIMIT)
|
||||
const backgroundColor = optionalString(item.backgroundColor, STYLE_LIMIT)
|
||||
if (color === false || backgroundColor === false) return false
|
||||
if (color === undefined && backgroundColor === undefined) return undefined
|
||||
return {
|
||||
...(color === undefined ? {} : { color }),
|
||||
...(backgroundColor === undefined ? {} : { backgroundColor }),
|
||||
}
|
||||
}
|
||||
|
||||
function source(value: unknown): BrowserReference["source"] | false | undefined {
|
||||
if (value === undefined) return undefined
|
||||
const item = record(value)
|
||||
if (!item) return false
|
||||
const file = string(item.file, SOURCE_LIMIT)
|
||||
const line = positive(item.line)
|
||||
const column = positive(item.column)
|
||||
if (!file || !safePath(file) || line === false || column === false) return false
|
||||
return {
|
||||
file,
|
||||
...(line === undefined ? {} : { line }),
|
||||
...(column === undefined ? {} : { column }),
|
||||
}
|
||||
}
|
||||
|
||||
function hierarchy(value: unknown): string[] | false | undefined {
|
||||
if (value === undefined) return undefined
|
||||
if (!Array.isArray(value) || value.length > HIERARCHY_LIMIT) return false
|
||||
const result = value.map((item) => string(item, HIERARCHY_ITEM_LIMIT))
|
||||
if (result.some((item) => item === undefined)) return false
|
||||
return result as string[]
|
||||
}
|
||||
|
||||
function reference(value: unknown): BrowserReference | undefined {
|
||||
const item = record(value)
|
||||
if (!item) return undefined
|
||||
const id = string(item.id, ID_LIMIT)
|
||||
const sessionId = string(item.sessionId, SESSION_LIMIT)
|
||||
const selector = string(item.selector, SELECTOR_LIMIT)
|
||||
if (!id || !sessionId || !selector) return undefined
|
||||
const text = optionalString(item.text, TEXT_LIMIT)
|
||||
const pageUrl = optionalUrl(item.url)
|
||||
const title = optionalString(item.title, TITLE_LIMIT)
|
||||
const tree = hierarchy(item.hierarchy)
|
||||
const html = optionalString(item.html, HTML_LIMIT)
|
||||
const style = styles(item.styles)
|
||||
const verified = source(item.source)
|
||||
const content = item.content === undefined ? undefined : optionalString(item.content, 100_000)
|
||||
if (!valid([text, pageUrl, title, tree, html, style, verified, content])) return undefined
|
||||
const safeText = unwrap(text)
|
||||
const safeUrl = unwrap(pageUrl)
|
||||
const safeTitle = unwrap(title)
|
||||
const safeTree = unwrap(tree)
|
||||
const safeHtml = unwrap(html)
|
||||
const safeStyle = unwrap(style)
|
||||
const safeSource = unwrap(verified)
|
||||
const safeContent = unwrap(content)
|
||||
return {
|
||||
id,
|
||||
sessionId,
|
||||
selector,
|
||||
...(safeText === undefined ? {} : { text: safeText }),
|
||||
...(safeUrl === undefined ? {} : { url: safeUrl }),
|
||||
...(safeTitle === undefined ? {} : { title: safeTitle }),
|
||||
...(safeTree === undefined ? {} : { hierarchy: safeTree }),
|
||||
...(safeHtml === undefined ? {} : { html: safeHtml }),
|
||||
...(safeStyle === undefined ? {} : { styles: safeStyle }),
|
||||
...(safeSource === undefined ? {} : { source: safeSource }),
|
||||
...(safeContent === undefined ? {} : { content: safeContent }),
|
||||
}
|
||||
}
|
||||
|
||||
function valid(
|
||||
values: Array<string | string[] | BrowserReference["styles"] | BrowserReference["source"] | false | undefined>,
|
||||
): boolean {
|
||||
return !values.some((item) => item === false)
|
||||
}
|
||||
|
||||
function unwrap<T>(item: T | false | undefined): T | undefined {
|
||||
return item === false ? undefined : item
|
||||
}
|
||||
|
||||
function weight(item: BrowserReference): number {
|
||||
return JSON.stringify(item).length
|
||||
}
|
||||
|
||||
function normalize(references: readonly BrowserReference[]): BrowserReference[] | undefined {
|
||||
if (references.length === 0 || references.length > REFERENCE_LIMIT) return undefined
|
||||
const result = references.map(reference)
|
||||
if (result.some((item) => item === undefined)) return undefined
|
||||
const list = result as BrowserReference[]
|
||||
if (list.reduce((total, item) => total + weight(item), 0) > TOTAL_LIMIT) return undefined
|
||||
return list
|
||||
}
|
||||
|
||||
function escapeInline(value: string): string {
|
||||
return value.replace(/[\r\n]+/g, " ").replace(/([\\`*_[\]{}()#+\-!|<>])/g, "\\$1")
|
||||
}
|
||||
|
||||
function fenced(value: string): string[] {
|
||||
const matches = value.match(/`+/g) ?? []
|
||||
const longest = matches.reduce((max, item) => Math.max(max, item.length), 0)
|
||||
const fence = "`".repeat(Math.max(3, longest + 1))
|
||||
return [fence, value, fence]
|
||||
}
|
||||
|
||||
function page(item: BrowserReference): string | undefined {
|
||||
if (!item.url && !item.title) return undefined
|
||||
return `Page: ${item.title ? escapeInline(item.title) : "Untitled"}${item.url ? ` (\`${escapeInline(item.url)}\`)` : ""}`
|
||||
}
|
||||
|
||||
function detail(item: BrowserReference, index: number, first: BrowserReference): string[] {
|
||||
const lines = [`Element ${index + 1}:`, ...fenced(item.selector)]
|
||||
if (index > 0 && (item.url !== first.url || item.title !== first.title)) {
|
||||
const value = page(item)
|
||||
if (value) lines.push(value)
|
||||
}
|
||||
if (item.hierarchy?.length) lines.push(...["DOM:", ...fenced(item.hierarchy.join(" > "))])
|
||||
if (item.text && (!item.html || item.html === item.text)) lines.push(...[`Text:`, ...fenced(item.text)])
|
||||
if (item.html && item.html !== item.text) lines.push(...[`HTML:`, ...fenced(item.html)])
|
||||
if (item.styles) {
|
||||
const values = [
|
||||
item.styles.color ? `color=${escapeInline(item.styles.color)}` : "",
|
||||
item.styles.backgroundColor ? `background=${escapeInline(item.styles.backgroundColor)}` : "",
|
||||
].filter(Boolean)
|
||||
if (values.length) lines.push(`Styles: ${values.join(", ")}`)
|
||||
}
|
||||
if (item.source) {
|
||||
const location = [item.source.file, item.source.line, item.source.column]
|
||||
.filter((value) => value !== undefined)
|
||||
.join(":")
|
||||
lines.push(...[`Source:`, ...fenced(location)])
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
export function formatBrowserFeedback(references: BrowserReference[]): string {
|
||||
const list = normalize(references) ?? []
|
||||
if (list.length === 0) return "## Browser Feedback"
|
||||
const lines = ["## Browser Feedback", ""]
|
||||
const heading = page(list[0]!)
|
||||
if (heading) lines.push(heading, "")
|
||||
list.forEach((item, index) => lines.push(...detail(item, index, list[0]!), ""))
|
||||
return lines.join("\n").trimEnd()
|
||||
}
|
||||
|
||||
function view(value: unknown, content: string): { data: BrowserFeedbackData; body: string } | undefined {
|
||||
const data = record(value)
|
||||
if (!data || data.version !== 1 || !Array.isArray(data.references)) return undefined
|
||||
const references = data.references.map(reference)
|
||||
if (references.length === 0 || references.length > REFERENCE_LIMIT || references.some((item) => item === undefined))
|
||||
return undefined
|
||||
const list = (references as BrowserReference[]).map((item) => {
|
||||
const result = { ...item }
|
||||
delete result.content
|
||||
return result
|
||||
})
|
||||
if (list.reduce((total, item) => total + weight(item), 0) > TOTAL_LIMIT) return undefined
|
||||
const prefix = formatBrowserFeedback(list)
|
||||
if (content === prefix) return { data: { version: 1, references: list }, body: "" }
|
||||
if (!content.startsWith(`${prefix}\n\n`)) return undefined
|
||||
return { data: { version: 1, references: list }, body: content.slice(prefix.length + 2) }
|
||||
}
|
||||
|
||||
export function browserFeedbackData(references: BrowserReference[]): BrowserFeedbackData | undefined {
|
||||
const list = normalize(references)
|
||||
if (!list) return undefined
|
||||
return {
|
||||
version: 1,
|
||||
references: list.map((item) => {
|
||||
const result = { ...item }
|
||||
delete result.content
|
||||
return result
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeBrowserReferences(current: BrowserReference[], incoming: BrowserReference): BrowserReference[] {
|
||||
const selected = browserFeedbackData([incoming])?.references[0]
|
||||
if (!selected) return current
|
||||
const kept = current.filter(
|
||||
(item) => item.id !== selected.id && (item.selector !== selected.selector || item.url !== selected.url),
|
||||
)
|
||||
if (kept.length >= REFERENCE_LIMIT) return current
|
||||
return [...kept, selected]
|
||||
}
|
||||
|
||||
export function browserFeedbackMetadata(data: BrowserFeedbackData): Record<string, unknown> {
|
||||
return { kilo: { browserFeedback: data } }
|
||||
}
|
||||
|
||||
export function parseBrowserFeedback(value: unknown, content: string): BrowserFeedbackData | undefined {
|
||||
return view(value, content)?.data
|
||||
}
|
||||
|
||||
export function feedbackMetadata(
|
||||
review: ReviewMessageData | undefined,
|
||||
browserFeedback: BrowserFeedbackData | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!review && !browserFeedback) return undefined
|
||||
return {
|
||||
kilo: {
|
||||
...(review ? { review } : {}),
|
||||
...(browserFeedback ? { browserFeedback } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function partFeedback(metadata: unknown, content: string): FeedbackView | undefined {
|
||||
const root = record(metadata)
|
||||
const kilo = record(root?.kilo)
|
||||
const reviewValue = kilo?.review
|
||||
const browserValue = kilo?.browserFeedback
|
||||
let body = content
|
||||
let review: ReviewMessageData | undefined
|
||||
let browserFeedback: BrowserFeedbackData | undefined
|
||||
if (reviewValue !== undefined) {
|
||||
const parsed = partReview({ kilo: { review: reviewValue } }, body)
|
||||
if (!parsed) return undefined
|
||||
review = parsed.data
|
||||
body = parsed.body
|
||||
}
|
||||
if (browserValue !== undefined) {
|
||||
const parsed = view(browserValue, body)
|
||||
if (!parsed) return undefined
|
||||
browserFeedback = parsed.data
|
||||
body = parsed.body
|
||||
}
|
||||
if (!review && !browserFeedback) return undefined
|
||||
return { review, browserFeedback, body }
|
||||
}
|
||||
|
||||
export function parseFeedback(
|
||||
metadata: { review?: unknown; browserFeedback?: unknown },
|
||||
content: string,
|
||||
): Pick<FeedbackView, "review" | "browserFeedback"> | undefined {
|
||||
const parsed = partFeedback({ kilo: metadata }, content)
|
||||
if (!parsed) return undefined
|
||||
return { review: parsed.review, browserFeedback: parsed.browserFeedback }
|
||||
}
|
||||
@@ -235,10 +235,6 @@ export function reviewMetadata(review: ReviewMessageData): Record<string, unknow
|
||||
return { kilo: { review } }
|
||||
}
|
||||
|
||||
export function reviewBody(review: ReviewMessageData, content: string): string | undefined {
|
||||
return view(review, content)?.body
|
||||
}
|
||||
|
||||
export function partReview(metadata: unknown, content: string): ReviewMessageView | undefined {
|
||||
const root = record(metadata)
|
||||
const kilo = record(root?.kilo)
|
||||
|
||||
@@ -66,11 +66,13 @@ export function buildWebviewHtml(
|
||||
topBar?: boolean
|
||||
topBarSurface?: string
|
||||
agentManagerSettings?: boolean
|
||||
browserAutomation?: boolean
|
||||
frameSrc?: string
|
||||
sidebar?: "left" | "right"
|
||||
},
|
||||
): string {
|
||||
const nonce = getNonce()
|
||||
const csp = buildCspString(webview.cspSource, nonce, opts.port)
|
||||
const csp = buildCspString(webview.cspSource, nonce, opts.port, opts.frameSrc)
|
||||
const markdownWorkerUri = opts.workerUri.toString().replace(/shiki-worker\.js$/, "markdown-shiki-worker.js")
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
@@ -109,7 +111,7 @@ export function buildWebviewHtml(
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script nonce="${nonce}">window.ICONS_BASE_URI = "${opts.iconsBaseUri}"; window.KILO_SHIKI_WORKER_URI = "${opts.workerUri}"; window.KILO_MARKDOWN_SHIKI_WORKER_URI = "${markdownWorkerUri}"; window.KILO_TOP_BAR = ${opts.topBar !== false}; window.KILO_TOP_BAR_SURFACE = "${opts.topBarSurface ?? "sidebar_title"}"; window.KILO_AGENT_MANAGER_SETTINGS = ${opts.agentManagerSettings === true};</script>
|
||||
<script nonce="${nonce}">window.ICONS_BASE_URI = "${opts.iconsBaseUri}"; window.KILO_SHIKI_WORKER_URI = "${opts.workerUri}"; window.KILO_MARKDOWN_SHIKI_WORKER_URI = "${markdownWorkerUri}"; window.KILO_TOP_BAR = ${opts.topBar !== false}; window.KILO_TOP_BAR_SURFACE = "${opts.topBarSurface ?? "sidebar_title"}"; window.KILO_AGENT_MANAGER_SETTINGS = ${opts.agentManagerSettings === true}; window.KILO_BROWSER_AUTOMATION = ${opts.browserAutomation === true};</script>
|
||||
<script nonce="${nonce}" src="${opts.scriptUri}"></script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
@@ -20,7 +20,7 @@ function joinCspDirectives(directives: string[]): string {
|
||||
/**
|
||||
* Build the full CSP policy string for a webview.
|
||||
*/
|
||||
export function buildCspString(cspSource: string, nonce: string, port?: number): string {
|
||||
export function buildCspString(cspSource: string, nonce: string, port?: number, frame?: string): string {
|
||||
const connectSrc = buildConnectSrc(port)
|
||||
const directives = [
|
||||
"default-src 'none'",
|
||||
@@ -31,6 +31,7 @@ export function buildCspString(cspSource: string, nonce: string, port?: number):
|
||||
`font-src ${cspSource}`,
|
||||
`connect-src ${cspSource} ${connectSrc}`,
|
||||
`img-src ${cspSource} data: https:`,
|
||||
...(frame ? [`frame-src ${frame}`] : []),
|
||||
]
|
||||
return joinCspDirectives(directives)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { Window } from "happy-dom"
|
||||
|
||||
const window = new Window({ url: "http://localhost" })
|
||||
Object.assign(globalThis, {
|
||||
window,
|
||||
document: window.document,
|
||||
navigator: window.navigator,
|
||||
Node: window.Node,
|
||||
Element: window.Element,
|
||||
HTMLElement: window.HTMLElement,
|
||||
HTMLInputElement: window.HTMLInputElement,
|
||||
HTMLButtonElement: window.HTMLButtonElement,
|
||||
SVGElement: window.SVGElement,
|
||||
MutationObserver: window.MutationObserver,
|
||||
ResizeObserver: window.ResizeObserver,
|
||||
Event: window.Event,
|
||||
MouseEvent: window.MouseEvent,
|
||||
getComputedStyle: window.getComputedStyle.bind(window),
|
||||
requestAnimationFrame: window.requestAnimationFrame.bind(window),
|
||||
cancelAnimationFrame: window.cancelAnimationFrame.bind(window),
|
||||
})
|
||||
|
||||
const { createSignal } = await import("solid-js")
|
||||
const { render } = await import("solid-js/web")
|
||||
const { BrowserPanel } = await import("../../webview-ui/browser")
|
||||
import type { BrowserCommand, BrowserEvent, BrowserLabels } from "../../webview-ui/browser/types"
|
||||
import type { BrowserReference } from "../../src/shared/browser-feedback"
|
||||
|
||||
const root = document.createElement("div")
|
||||
document.body.append(root)
|
||||
const scope = { sessionId: "standalone", projectId: "fixture" }
|
||||
const sent: BrowserCommand[] = []
|
||||
const references: BrowserReference[] = []
|
||||
let receive: ((event: BrowserEvent) => void) | undefined
|
||||
let closed = 0
|
||||
const [labels, update] = createSignal<BrowserLabels>({
|
||||
title: "Browser",
|
||||
url: "Address",
|
||||
urlPlaceholder: "Local URL",
|
||||
open: "Go",
|
||||
refresh: "Reload",
|
||||
close: "Close",
|
||||
inspect: "Select element",
|
||||
devtoolsTitle: "Developer tools",
|
||||
diagnostics: "Browser diagnostics",
|
||||
diagnosticsHint: "Recent events from the automation browser. Security blocks are not console errors.",
|
||||
empty: "Open a local page",
|
||||
noSession: "Choose a session",
|
||||
screenshotAlt: "Preview",
|
||||
errors: (count) => `${count} errors`,
|
||||
})
|
||||
const dispose = render(
|
||||
() => (
|
||||
<BrowserPanel
|
||||
scope={() => scope}
|
||||
labels={labels()}
|
||||
theme={() => "light"}
|
||||
transport={{
|
||||
send: (command) => sent.push(command),
|
||||
subscribe: (handler) => {
|
||||
receive = handler
|
||||
return () => {
|
||||
receive = undefined
|
||||
}
|
||||
},
|
||||
}}
|
||||
onReference={(reference) => references.push(reference)}
|
||||
onClose={() => closed++}
|
||||
/>
|
||||
),
|
||||
root,
|
||||
)
|
||||
assert.deepEqual(sent[0], { type: "state", scope })
|
||||
const state = { scope, browserId: "browser", status: "ready" as const, errors: 0, url: "about:blank" }
|
||||
receive?.({ type: "state", value: { ...state, status: "error", error: "Cannot connect to the local server" } })
|
||||
const failure = root.querySelector('[role="alert"][data-component="card"][data-variant="error"]')
|
||||
assert.ok(failure)
|
||||
assert.equal(failure.querySelector(".error-card-message")?.textContent, "Cannot connect to the local server")
|
||||
assert.ok(failure.querySelector('[data-component="icon"]'))
|
||||
assert.equal(root.querySelector(".am-browser-frame"), null)
|
||||
assert.equal(root.querySelector(".am-browser-empty"), null)
|
||||
receive?.({ type: "state", value: state })
|
||||
assert.equal(root.querySelector('[role="alert"]'), null)
|
||||
await window.happyDOM.waitUntilComplete()
|
||||
const frame = root.querySelector(".am-browser-frame")
|
||||
assert.ok(frame)
|
||||
assert.equal(root.querySelector(".am-browser-site"), null)
|
||||
receive?.({ type: "state", value: { ...state, logs: ["[info] Updated"] } })
|
||||
assert.equal(root.querySelector(".am-browser-frame"), frame)
|
||||
assert.equal(root.querySelector(".am-browser-diagnostics button")?.textContent, "Browser diagnostics")
|
||||
assert.equal(root.querySelector(".am-browser-console"), null)
|
||||
;(root.querySelector("button[aria-label=Reload]") as HTMLButtonElement).click()
|
||||
assert.deepEqual(sent.at(-1), { type: "refresh", scope })
|
||||
receive?.({ type: "state", value: { ...state, navigation: 1 } })
|
||||
await window.happyDOM.waitUntilComplete()
|
||||
assert.equal(frame.isConnected, false)
|
||||
const refreshed = root.querySelector(".am-browser-frame")
|
||||
assert.ok(refreshed)
|
||||
assert.equal(refreshed.getAttribute("src"), state.url)
|
||||
assert.equal(refreshed.getAttribute("sandbox"), "allow-scripts allow-forms allow-same-origin")
|
||||
receive?.({ type: "state", value: { ...state, navigation: 1, errors: 1 } })
|
||||
assert.equal(root.querySelector(".am-browser-frame"), refreshed)
|
||||
receive?.({
|
||||
type: "state",
|
||||
value: { ...state, navigation: 1, title: "", error: "Developer tools are unavailable" },
|
||||
})
|
||||
assert.equal(root.querySelector(".am-browser-frame"), refreshed)
|
||||
assert.equal(
|
||||
root.querySelector('[role="alert"][data-variant="error"] .error-card-message')?.textContent,
|
||||
"Developer tools are unavailable",
|
||||
)
|
||||
assert.equal(root.querySelectorAll("button[aria-label=Close]").length, 1)
|
||||
;(root.querySelector("button[aria-label='Select element']") as HTMLButtonElement).click()
|
||||
const overlay = root.querySelector(".am-browser-inspect") as HTMLButtonElement
|
||||
assert.ok(overlay)
|
||||
overlay.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 200 }) as DOMRect
|
||||
overlay.dispatchEvent(new window.MouseEvent("click", { bubbles: true, clientX: 200, clientY: 100 }))
|
||||
const request = sent.at(-1)
|
||||
assert.equal(request?.type, "inspect")
|
||||
if (request?.type !== "inspect") throw new Error("Selection command was not emitted")
|
||||
receive?.({ type: "inspection", value: { scope, requestId: request.requestId, hover: false, logs: [] } })
|
||||
assert.equal(references.length, 0)
|
||||
const retry = root.querySelector(".am-browser-inspect") as HTMLButtonElement
|
||||
assert.ok(retry)
|
||||
retry.getBoundingClientRect = overlay.getBoundingClientRect
|
||||
retry.dispatchEvent(new window.MouseEvent("click", { bubbles: true, clientX: 200, clientY: 100 }))
|
||||
const selected = sent.at(-1)
|
||||
if (selected?.type !== "inspect") throw new Error("Retry selection command was not emitted")
|
||||
receive?.({
|
||||
type: "inspection",
|
||||
value: {
|
||||
scope,
|
||||
requestId: selected.requestId,
|
||||
hover: false,
|
||||
logs: [],
|
||||
element: { tag: "button", selector: "#save" },
|
||||
},
|
||||
})
|
||||
assert.equal(references[0]?.selector, "#save")
|
||||
update((value) => ({ ...value, close: "Fermer" }))
|
||||
await window.happyDOM.waitUntilComplete()
|
||||
assert.ok(root.querySelector("button[aria-label=Fermer]"))
|
||||
const blocked = "Blocked browser request: http://127.0.0.1:4097"
|
||||
const other = "Blocked browser request: http://127.0.0.1:4098"
|
||||
receive?.({
|
||||
type: "state",
|
||||
value: { ...state, navigation: 1, errors: 5, logs: [blocked, blocked, other, blocked, "[error] Failed to load"] },
|
||||
})
|
||||
assert.equal(root.querySelector('[role="alert"]'), null)
|
||||
assert.equal(root.querySelector(".am-browser-error-count"), null)
|
||||
assert.ok(root.querySelector(".am-browser-tools-action button[aria-label='Developer tools']"))
|
||||
const diagnostics = root.querySelector(".am-browser-diagnostics button") as HTMLButtonElement
|
||||
assert.equal(diagnostics.textContent, "5 errors")
|
||||
assert.equal(diagnostics.getAttribute("aria-expanded"), "false")
|
||||
assert.equal(root.querySelector(".am-browser-console"), null)
|
||||
diagnostics.click()
|
||||
await window.happyDOM.waitUntilComplete()
|
||||
assert.equal(diagnostics.getAttribute("aria-expanded"), "true")
|
||||
const entries = [...root.querySelectorAll(".am-browser-console-entry")]
|
||||
assert.equal(entries.length, 3, root.querySelector(".am-browser-diagnostics")?.outerHTML)
|
||||
assert.equal(entries.at(0)?.textContent, `${blocked}×3`)
|
||||
assert.equal(entries.at(1)?.textContent, other)
|
||||
assert.equal(entries.at(2)?.textContent, "[error] Failed to load")
|
||||
assert.equal(root.querySelector(".am-browser-frame"), refreshed)
|
||||
;(root.querySelector(".am-browser-tools-action button") as HTMLButtonElement).click()
|
||||
assert.deepEqual(sent.at(-1), { type: "devtools", scope, theme: "light" })
|
||||
receive?.({ type: "devtools", value: { scope, browserId: state.browserId, url: "about:blank" } })
|
||||
await window.happyDOM.waitUntilComplete()
|
||||
assert.ok(root.querySelector(".am-browser-devtools-frame"))
|
||||
assert.equal(root.querySelectorAll(".am-browser-console-entry").length, 3)
|
||||
assert.equal(diagnostics.getAttribute("aria-expanded"), "true")
|
||||
receive?.({ type: "state", value: { ...state, navigation: 2 } })
|
||||
assert.equal(root.querySelector(".am-browser-diagnostics"), null)
|
||||
;(root.querySelector("button[aria-label=Fermer]") as HTMLButtonElement).click()
|
||||
assert.equal(closed, 1)
|
||||
assert.deepEqual(sent.at(-1), { type: "close", scope })
|
||||
dispose()
|
||||
assert.equal(receive, undefined)
|
||||
await window.happyDOM.close()
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict"
|
||||
import { Window } from "happy-dom"
|
||||
|
||||
const window = new Window({ url: "http://localhost" })
|
||||
Object.defineProperty(window, "origin", { value: window.location.origin })
|
||||
class CSSStyleSheetStub {
|
||||
replaceSync() {}
|
||||
replace() {
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict"
|
||||
import { Window } from "happy-dom"
|
||||
|
||||
const window = new Window({ url: "http://localhost" })
|
||||
Object.defineProperty(window, "origin", { value: window.location.origin })
|
||||
const sent: unknown[] = []
|
||||
const api = {
|
||||
postMessage: (message: unknown) => sent.push(message),
|
||||
@@ -47,6 +48,7 @@ const { LanguageContext } = await import("../../webview-ui/src/context/language"
|
||||
const { NotificationsProvider } = await import("../../webview-ui/src/context/notifications")
|
||||
const { ProviderContext } = await import("../../webview-ui/src/context/provider")
|
||||
const { SessionProvider, useSession } = await import("../../webview-ui/src/context/session")
|
||||
const { post } = await import("../../webview-ui/src/utils/webview-message")
|
||||
const { terminal } = await import("../../webview-ui/src/context/session-outcome")
|
||||
|
||||
const provider = {
|
||||
@@ -198,7 +200,7 @@ const settle = async () => {
|
||||
await window.happyDOM.waitUntilComplete()
|
||||
}
|
||||
const emit = async (data: unknown) => {
|
||||
window.dispatchEvent(new MessageEvent("message", { data }))
|
||||
post(data)
|
||||
await settle()
|
||||
}
|
||||
const state = (id: string) => {
|
||||
@@ -640,9 +642,7 @@ try {
|
||||
} finally {
|
||||
const before = state("background")
|
||||
dispose()
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", { data: { type: "sessionStatus", sessionID: "background", status: "retry" } }),
|
||||
)
|
||||
post({ type: "sessionStatus", sessionID: "background", status: "retry" })
|
||||
assert.equal(state("background"), before)
|
||||
await window.happyDOM.cancelAsync()
|
||||
await window.happyDOM.close()
|
||||
|
||||
@@ -19,6 +19,7 @@ const EDIT_PREVIEW_PANEL_FILE = path.join(ROOT, "webview-ui/agent-manager/EditPr
|
||||
const CSS_FILES = [
|
||||
path.join(ROOT, "webview-ui/agent-manager/agent-manager.css"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/agent-manager-review.css"),
|
||||
path.join(ROOT, "webview-ui/browser/browser.css"),
|
||||
]
|
||||
const TSX_FILES = [
|
||||
path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"),
|
||||
@@ -29,6 +30,8 @@ const TSX_FILES = [
|
||||
path.join(ROOT, "webview-ui/agent-manager/ProjectSelect.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/DiffPanel.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/BrowserPanel.tsx"),
|
||||
path.join(ROOT, "webview-ui/browser/BrowserPanel.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/DiffPanelCache.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/review-composers.ts"),
|
||||
path.join(ROOT, "webview-ui/documents/DocumentPanel.tsx"),
|
||||
@@ -171,6 +174,23 @@ describe("Agent Manager CSS/TSX Consistency", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("Browser module boundaries", () => {
|
||||
it("keeps browser core independent from Agent Manager and VS Code context", () => {
|
||||
const files = ["BrowserPanel.tsx", "controller.ts", "types.ts", "index.ts"]
|
||||
for (const file of files) {
|
||||
const source = fs.readFileSync(path.join(ROOT, "webview-ui/browser", file), "utf-8")
|
||||
expect(source).not.toMatch(/agent-manager|AgentManager|SidePanel|useVSCode|window\.postMessage/)
|
||||
}
|
||||
})
|
||||
|
||||
it("owns browser styles in the reusable module", () => {
|
||||
const css = fs.readFileSync(path.join(ROOT, "webview-ui/agent-manager/agent-manager.css"), "utf-8")
|
||||
const browser = fs.readFileSync(path.join(ROOT, "webview-ui/browser/BrowserPanel.tsx"), "utf-8")
|
||||
expect(css).not.toContain(".am-browser-")
|
||||
expect(browser).toContain('import "./browser.css"')
|
||||
})
|
||||
})
|
||||
|
||||
describe("Agent Manager edit preview", () => {
|
||||
it("provides a visible close action", () => {
|
||||
const source = fs.readFileSync(EDIT_PREVIEW_PANEL_FILE, "utf-8")
|
||||
@@ -647,10 +667,13 @@ describe("Agent Manager Provider — onMessage routing", () => {
|
||||
it("does not restore running indicators after a session is deleted", () => {
|
||||
const lifecycle = body("onSessionLifecycle")
|
||||
const status = body("onSessionStatus")
|
||||
const helper = fs.readFileSync(path.join(ROOT, "src/agent-manager/session-lifecycle.ts"), "utf-8")
|
||||
|
||||
expect(lifecycle).toContain("this.removedSessions.add(id)")
|
||||
expect(lifecycle).toContain("this.busySessions.delete(id)")
|
||||
expect(lifecycle).toContain("info && !this.removedSessions.has(info.id) ? info.directory : undefined")
|
||||
expect(lifecycle).toContain("removed: this.removedSessions")
|
||||
expect(lifecycle).toContain("busy: this.busySessions")
|
||||
expect(helper).toContain("deps.removed.add(id)")
|
||||
expect(helper).toContain("deps.busy.delete(id)")
|
||||
expect(helper).toContain("if (deps.removed.has(info.id)) return")
|
||||
expect(status).toContain("this.removedSessions.has(sid)")
|
||||
})
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import path from "node:path"
|
||||
const ROOT = path.resolve(import.meta.dir, "../..")
|
||||
const TSX_FILES = [
|
||||
path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/BrowserPanel.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/terminal/SideTerminalPanel.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/terminal/TerminalDestinationButton.tsx"),
|
||||
|
||||
@@ -3,8 +3,59 @@ import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
const TAB_BAR = path.resolve(import.meta.dir, "../../webview-ui/agent-manager/TabBar.tsx")
|
||||
const BROWSER_PANEL = path.resolve(import.meta.dir, "../../webview-ui/browser/BrowserPanel.tsx")
|
||||
const BROWSER_ADAPTER = path.resolve(import.meta.dir, "../../webview-ui/agent-manager/BrowserPanel.tsx")
|
||||
|
||||
describe("Agent Manager diff toggle", () => {
|
||||
it("exposes the browser action through an accessible button label", () => {
|
||||
const source = fs.readFileSync(TAB_BAR, "utf-8")
|
||||
const start = source.indexOf("<Show when={props.browserAutomation()}>")
|
||||
const end = source.indexOf("</Show>", start)
|
||||
const button = source.slice(start, end)
|
||||
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
expect(button).toContain('aria-label={props.t("agentManager.browser.title")}')
|
||||
})
|
||||
|
||||
it("renders a real sandboxed browser document instead of an image", () => {
|
||||
const source = fs.readFileSync(BROWSER_PANEL, "utf-8")
|
||||
expect(source).toContain("<iframe")
|
||||
expect(source).toContain('sandbox="allow-scripts allow-forms allow-same-origin"')
|
||||
expect(source).not.toContain("<img")
|
||||
expect(source).not.toContain("<canvas")
|
||||
})
|
||||
|
||||
it("reloads the visible document on each browser navigation and bridges native element inspection", () => {
|
||||
const source = fs.readFileSync(BROWSER_PANEL, "utf-8")
|
||||
expect(source).toContain("props.state?.navigation")
|
||||
expect(source).toContain("when={identity()}")
|
||||
expect(source).not.toContain("contentWindow")
|
||||
expect(source).toContain("onMouseMove={(event) => props.controller.move(position(event))}")
|
||||
expect(fs.readFileSync(BROWSER_ADAPTER, "utf-8")).toContain('type: "agentManager.browser.input"')
|
||||
})
|
||||
|
||||
it("keeps browser chrome compact with one close action and no duplicate footer", () => {
|
||||
const source = fs.readFileSync(BROWSER_PANEL, "utf-8")
|
||||
expect(source).toContain('class="am-browser-address"')
|
||||
expect(source).toContain('icon="arrow-right"')
|
||||
expect(source).toContain('icon="window-cursor"')
|
||||
expect(source.match(/icon="close"/g)).toHaveLength(1)
|
||||
expect(source).not.toContain("am-browser-footer")
|
||||
expect(source).not.toContain("am-browser-selected")
|
||||
expect(source).not.toContain("am-browser-devtools-toolbar")
|
||||
})
|
||||
|
||||
it("passes selected references through the host adapter", () => {
|
||||
const source = fs.readFileSync(BROWSER_ADAPTER, "utf-8")
|
||||
expect(source).toContain("onReference={reference}")
|
||||
expect(source).toContain('import { post } from "../src/utils/webview-message"')
|
||||
expect(source).toContain('post({ type: "appendChatBoxMessage"')
|
||||
expect(source).toContain("const labels = createMemo(")
|
||||
expect(source).toContain("theme={theme}")
|
||||
expect(source).not.toContain("window.postMessage")
|
||||
})
|
||||
|
||||
it("renders live Git stats rather than pull-request stats", () => {
|
||||
const source = fs.readFileSync(TAB_BAR, "utf-8")
|
||||
const start = source.indexOf('title={props.t("agentManager.diff.toggle")}')
|
||||
|
||||
@@ -0,0 +1,849 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { createServer, request, type IncomingMessage } from "node:http"
|
||||
import { connect } from "node:net"
|
||||
import { PassThrough } from "node:stream"
|
||||
import { runInNewContext } from "node:vm"
|
||||
import WebSocket, { WebSocketServer } from "ws"
|
||||
import { BrowserBroker, diagnostic } from "../../src/services/browser-automation/browser-broker"
|
||||
import { BrowserDevtools } from "../../src/services/browser-automation/browser-devtools"
|
||||
|
||||
const brokers: BrowserBroker[] = []
|
||||
|
||||
function fixture<T>(page: T) {
|
||||
const broker = new BrowserBroker({
|
||||
log: () => {},
|
||||
launch: async () => ({
|
||||
newContext: async () => ({
|
||||
close: async () => undefined,
|
||||
route: async () => undefined,
|
||||
routeWebSocket: async () => undefined,
|
||||
newPage: async () => page,
|
||||
}),
|
||||
close: async () => undefined,
|
||||
}),
|
||||
})
|
||||
brokers.push(broker)
|
||||
return broker
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(brokers.splice(0).map((broker) => broker.disposeAsync()))
|
||||
})
|
||||
|
||||
describe("BrowserBroker", () => {
|
||||
test("accepts only HTTP loopback URLs", () => {
|
||||
const broker = new BrowserBroker({ log: () => {} })
|
||||
expect(broker.validate("http://localhost:3000/path").origin).toBe("http://localhost:3000")
|
||||
expect(() => broker.validate("https://localhost:3000")).toThrow()
|
||||
expect(() => broker.validate("http://127.0.0.1:3000")).not.toThrow()
|
||||
expect(() => broker.validate("http://[::1]:3000")).toThrow("Use localhost for IPv6 loopback servers")
|
||||
expect(() => broker.validate("http://0.0.0.0:3000")).toThrow()
|
||||
expect(() => broker.validate("http://example.com")).toThrow()
|
||||
expect(() => broker.validate("http://username:password@localhost:3000")).toThrow()
|
||||
expect(() => broker.validate("file:///tmp/example.html")).toThrow()
|
||||
})
|
||||
|
||||
test("normalizes browser failures without leaking Playwright call logs or ANSI formatting", () => {
|
||||
const refused = new Error(
|
||||
"page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:31847/\nCall log:\n\u001b[2m- navigating\u001b[22m",
|
||||
)
|
||||
expect(diagnostic(refused, "http://localhost:31847/")).toBe(
|
||||
"Cannot connect to http://localhost:31847/. Make sure the local server is running.",
|
||||
)
|
||||
expect(diagnostic(new Error("page.goto: Timeout 30000ms exceeded"))).toBe(
|
||||
"The local application did not respond in time. Check the server and try again.",
|
||||
)
|
||||
expect(diagnostic(new Error("\u001b[31mpage.goto: Custom navigation failure\u001b[0m\nCall log:\n- details"))).toBe(
|
||||
"Custom navigation failure",
|
||||
)
|
||||
})
|
||||
|
||||
test("sanitizes refused navigation errors in browser state and agent responses", async () => {
|
||||
const page = {
|
||||
url: () => "about:blank",
|
||||
title: async () => "",
|
||||
screenshot: async () => Buffer.from("jpeg"),
|
||||
on: (_type: string, _listener: (...args: never[]) => void) => undefined,
|
||||
mainFrame: () => undefined,
|
||||
goto: async () => {
|
||||
throw new Error("page.goto: net::ERR_CONNECTION_REFUSED\nCall log:\n\u001b[2m- navigating\u001b[22m")
|
||||
},
|
||||
}
|
||||
const broker = fixture(page)
|
||||
const env = await broker.env()
|
||||
const response = await fetch(`${env.KILO_BROWSER_BROKER_URL}/browser/open`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${env.KILO_BROWSER_BROKER_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ sessionID: "refused", directory: "/tmp/project", url: "http://localhost:31847/" }),
|
||||
})
|
||||
const message = "Cannot connect to http://localhost:31847/. Make sure the local server is running."
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({ error: message })
|
||||
expect(broker.get("refused")?.error).toBe(message)
|
||||
})
|
||||
|
||||
test("reloads repeated agent opens and returns fresh page diagnostics", async () => {
|
||||
const listeners = new Map<string, (value: unknown) => void>()
|
||||
const loading: number[] = []
|
||||
let version = 1
|
||||
const page = {
|
||||
url: () => "http://localhost:3000/",
|
||||
title: async () => `Application version ${version}`,
|
||||
screenshot: async () => Buffer.from(`version-${version}`),
|
||||
on: (type: string, listener: (value: unknown) => void) => {
|
||||
listeners.set(type, listener)
|
||||
},
|
||||
mainFrame: () => undefined,
|
||||
goto: async () => {
|
||||
listeners.get("console")?.({ type: () => "log", text: () => "STARTUP_VERSION_1" })
|
||||
},
|
||||
reload: async () => {
|
||||
version++
|
||||
listeners.get("console")?.({ type: () => "error", text: () => "STARTUP_VERSION_2" })
|
||||
},
|
||||
}
|
||||
const broker = fixture(page)
|
||||
broker.subscribe((state) => {
|
||||
if (state.status === "loading" && loading.at(-1) !== state.navigation) loading.push(state.navigation)
|
||||
})
|
||||
const env = await broker.env()
|
||||
const request = () =>
|
||||
fetch(`${env.KILO_BROWSER_BROKER_URL}/browser/open`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${env.KILO_BROWSER_BROKER_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ sessionID: "agent", directory: "/tmp/project", url: "http://localhost:3000/" }),
|
||||
}).then((response) => response.json() as Promise<{ navigation: number; title: string; logs: string[] }>)
|
||||
|
||||
expect(await request()).toMatchObject({
|
||||
navigation: 1,
|
||||
title: "Application version 1",
|
||||
logs: ["[log] STARTUP_VERSION_1"],
|
||||
})
|
||||
expect(await request()).toMatchObject({
|
||||
navigation: 2,
|
||||
title: "Application version 2",
|
||||
logs: ["[error] STARTUP_VERSION_2"],
|
||||
})
|
||||
expect(loading).toEqual([1, 2])
|
||||
})
|
||||
|
||||
test("protects its local bridge with a bearer token", async () => {
|
||||
const broker = new BrowserBroker({ log: () => {} })
|
||||
brokers.push(broker)
|
||||
const env = await broker.env()
|
||||
const result = await new Promise<{ status?: number; body: string }>((resolve, reject) => {
|
||||
const url = new URL(`${env.KILO_BROWSER_BROKER_URL}/browser/state`)
|
||||
const req = request(url, { method: "POST", headers: { "content-type": "application/json" } }, (res) => {
|
||||
const chunks: Buffer[] = []
|
||||
res.on("data", (chunk) => chunks.push(chunk))
|
||||
res.on("end", () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString("utf8") }))
|
||||
})
|
||||
req.on("error", reject)
|
||||
req.end("{}")
|
||||
})
|
||||
expect(result.status).toBe(401)
|
||||
expect(JSON.parse(result.body)).toEqual({ error: "Unauthorized" })
|
||||
const malformed = await fetch(`${env.KILO_BROWSER_BROKER_URL}/browser/status`, {
|
||||
headers: { authorization: `Bearer ${"é".repeat(64)}` },
|
||||
})
|
||||
expect(malformed.status).toBe(401)
|
||||
})
|
||||
|
||||
test("reports experimental availability only to authenticated clients", async () => {
|
||||
let enabled = false
|
||||
let trusted = true
|
||||
const broker = new BrowserBroker({ log: () => {}, enabled: () => enabled, trusted: () => trusted })
|
||||
brokers.push(broker)
|
||||
const env = await broker.env()
|
||||
const headers = { authorization: `Bearer ${env.KILO_BROWSER_BROKER_TOKEN}` }
|
||||
const url = `${env.KILO_BROWSER_BROKER_URL}/browser/status`
|
||||
expect((await fetch(url)).status).toBe(401)
|
||||
expect(await (await fetch(url, { headers })).json()).toEqual({ enabled: false })
|
||||
enabled = true
|
||||
expect(await (await fetch(url, { headers })).json()).toEqual({ enabled: true })
|
||||
trusted = false
|
||||
expect(await (await fetch(url, { headers })).json()).toEqual({ enabled: false })
|
||||
})
|
||||
|
||||
test("writes an explicit forbidden response before closing an untrusted upgrade", () => {
|
||||
const server = createServer()
|
||||
const tools = new BrowserDevtools(
|
||||
server,
|
||||
4567,
|
||||
() => {},
|
||||
() => {},
|
||||
)
|
||||
const url = new URL(tools.open("browser", "page", 1234, "dark"))
|
||||
const endpoint = new URL(`ws://${url.searchParams.get("ws")}`)
|
||||
const socket = new PassThrough()
|
||||
const chunks: Buffer[] = []
|
||||
socket.on("data", (chunk) => chunks.push(chunk))
|
||||
server.emit(
|
||||
"upgrade",
|
||||
{
|
||||
url: endpoint.pathname,
|
||||
headers: { host: "127.0.0.1:4567", origin: "http://untrusted.invalid" },
|
||||
} as IncomingMessage,
|
||||
socket,
|
||||
Buffer.alloc(0),
|
||||
)
|
||||
expect(Buffer.concat(chunks).toString()).toStartWith("HTTP/1.1 403 Forbidden\r\n")
|
||||
expect(socket.destroyed).toBe(true)
|
||||
tools.dispose()
|
||||
})
|
||||
|
||||
test("proxies page-scoped developer tools and rejects invalid capabilities or origins", async () => {
|
||||
const remote = createServer((req, res) => {
|
||||
const path = new URL(req.url ?? "/", "http://127.0.0.1").pathname
|
||||
if (path === "/devtools/inspector.html") {
|
||||
res.writeHead(200, { "content-type": "text/html" })
|
||||
res.end('<script type="module" src="./entrypoints/inspector.js"></script>')
|
||||
return
|
||||
}
|
||||
if (path === "/devtools/entrypoints/inspector.js") {
|
||||
res.writeHead(200, { "content-type": "text/javascript" })
|
||||
res.end("globalThis.loaded = true")
|
||||
return
|
||||
}
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
})
|
||||
const websocket = new WebSocketServer({ server: remote })
|
||||
const routes: string[] = []
|
||||
const moves: Array<{ type: string; x?: number; y?: number }> = []
|
||||
websocket.on("connection", (socket, req) => {
|
||||
routes.push(req.url ?? "")
|
||||
socket.on("message", (value) => {
|
||||
const input = JSON.parse(value.toString()) as { id: number }
|
||||
socket.send(JSON.stringify({ id: input.id, result: { value: "selected page" } }))
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve) => remote.listen(0, "127.0.0.1", resolve))
|
||||
const address = remote.address()
|
||||
if (!address || typeof address === "string") throw new Error("Test browser did not receive a local port")
|
||||
let next = 0
|
||||
const browser = {
|
||||
debugging: address.port,
|
||||
newContext: async () => {
|
||||
const id = `target-${++next}`
|
||||
let viewport: { width: number; height: number } | undefined
|
||||
const page = {
|
||||
url: () => "http://localhost:3000/",
|
||||
title: async () => "Developer tools test",
|
||||
screenshot: async () => Buffer.from("jpeg"),
|
||||
on: (_type: string, _listener: (...args: never[]) => void) => undefined,
|
||||
mainFrame: () => undefined,
|
||||
goto: async () => undefined,
|
||||
viewportSize: () => viewport,
|
||||
setViewportSize: async (size: { width: number; height: number }) => {
|
||||
viewport = size
|
||||
},
|
||||
mouse: {
|
||||
move: async (x: number, y: number) => moves.push({ type: "move", x, y }),
|
||||
down: async () => moves.push({ type: "down" }),
|
||||
up: async () => {
|
||||
moves.push({ type: "up" })
|
||||
for (const client of websocket.clients) {
|
||||
client.send(JSON.stringify({ method: "Overlay.inspectNodeRequested", params: { backendNodeId: 42 } }))
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
return {
|
||||
close: async () => undefined,
|
||||
route: async () => undefined,
|
||||
routeWebSocket: async () => undefined,
|
||||
newPage: async () => page,
|
||||
newCDPSession: async () => ({
|
||||
send: async () => ({ targetInfo: { targetId: id } }),
|
||||
detach: async () => undefined,
|
||||
}),
|
||||
}
|
||||
},
|
||||
close: async () => undefined,
|
||||
}
|
||||
const broker = new BrowserBroker({ log: () => {}, launch: async () => browser })
|
||||
brokers.push(broker)
|
||||
try {
|
||||
const env = await broker.env()
|
||||
await broker.open(
|
||||
{ projectId: "project", sessionId: "first", directory: "/tmp/project" },
|
||||
"http://localhost:3000",
|
||||
)
|
||||
await broker.open(
|
||||
{ projectId: "project", sessionId: "second", directory: "/tmp/project" },
|
||||
"http://localhost:3000",
|
||||
)
|
||||
const first = await broker.devtools("first", "project")
|
||||
const second = await broker.devtools("second", "project", "light")
|
||||
expect(first.browserId).not.toBe(second.browserId)
|
||||
expect(first.url).not.toContain(env.KILO_BROWSER_BROKER_TOKEN)
|
||||
expect(new URL(first.url).searchParams.has("can_dock")).toBe(false)
|
||||
const frontend = await fetch(first.url)
|
||||
expect(frontend.status).toBe(200)
|
||||
expect(await frontend.text()).toContain('<script src="./kilo-bootstrap.js"></script>')
|
||||
for (const [entry, theme] of [
|
||||
[first, "dark"],
|
||||
[second, "light"],
|
||||
] as const) {
|
||||
const bootstrap = await fetch(new URL("./kilo-bootstrap.js", entry.url))
|
||||
expect(bootstrap.status).toBe(200)
|
||||
const storage = new Map<string, string>()
|
||||
runInNewContext(await bootstrap.text(), {
|
||||
localStorage: { setItem: (key: string, value: string) => storage.set(key, value) },
|
||||
})
|
||||
expect(storage.get("ui-theme")).toBe(JSON.stringify(theme))
|
||||
expect(storage.get("currentDockState")).toBe(JSON.stringify("undocked"))
|
||||
}
|
||||
expect((await fetch(new URL("./entrypoints/inspector.js", first.url))).status).toBe(200)
|
||||
|
||||
const invalid = new URL(first.url)
|
||||
const parts = invalid.pathname.split("/")
|
||||
parts[4] = "0".repeat(64)
|
||||
invalid.pathname = parts.join("/")
|
||||
expect((await fetch(invalid)).status).toBe(401)
|
||||
|
||||
const swapped = new URL(first.url)
|
||||
swapped.pathname = swapped.pathname.replace(first.browserId, second.browserId)
|
||||
expect((await fetch(swapped)).status).toBe(401)
|
||||
|
||||
const endpoint = `ws://${new URL(first.url).searchParams.get("ws")}`
|
||||
const forbidden = await new Promise<number>((resolve, reject) => {
|
||||
const url = new URL(endpoint)
|
||||
const socket = connect({ host: url.hostname, port: Number(url.port) }, () => {
|
||||
socket.write(
|
||||
[
|
||||
`GET ${url.pathname} HTTP/1.1`,
|
||||
`Host: ${url.host}`,
|
||||
"Connection: Upgrade",
|
||||
"Upgrade: websocket",
|
||||
"Sec-WebSocket-Version: 13",
|
||||
`Sec-WebSocket-Key: ${Buffer.from("browser-test-key").toString("base64")}`,
|
||||
"Origin: http://untrusted.invalid",
|
||||
"\r\n",
|
||||
].join("\r\n"),
|
||||
)
|
||||
})
|
||||
socket.once("data", (data) => {
|
||||
resolve(Number(data.toString().match(/^HTTP\/1\.1 (\d+)/)?.[1] ?? 0))
|
||||
socket.end()
|
||||
})
|
||||
socket.once("end", () => resolve(0))
|
||||
socket.once("error", reject)
|
||||
})
|
||||
expect([0, 403]).toContain(forbidden)
|
||||
|
||||
const socket = new WebSocket(endpoint, { headers: { origin: new URL(first.url).origin } })
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("open", resolve)
|
||||
socket.once("error", reject)
|
||||
})
|
||||
socket.send(JSON.stringify({ id: 9, method: "Runtime.evaluate" }))
|
||||
const result = await new Promise<{ id: number; result: { value: string } }>((resolve) => {
|
||||
socket.once("message", (value) => resolve(JSON.parse(value.toString())))
|
||||
})
|
||||
expect(result).toEqual({ id: 9, result: { value: "selected page" } })
|
||||
expect(routes).toEqual(["/devtools/page/target-1"])
|
||||
await broker.input("first", "project", { x: 0.5, y: 0.25, width: 400, height: 240 }, false)
|
||||
expect(moves).toEqual([])
|
||||
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
id: 10,
|
||||
method: "Overlay.setInspectMode",
|
||||
params: { mode: "searchForNode", highlightConfig: { showInfo: true } },
|
||||
}),
|
||||
)
|
||||
await new Promise<void>((resolve) => socket.once("message", () => resolve()))
|
||||
expect(broker.get("first", "project")?.inspecting).toBe(true)
|
||||
await broker.input("first", "project", { x: 0.5, y: 0.25, width: 400, height: 240 }, false)
|
||||
expect(moves).toEqual([{ type: "move", x: 200, y: 60 }])
|
||||
const selected = new Promise<{ method: string; params: { backendNodeId: number } }>((resolve) => {
|
||||
socket.once("message", (value) => resolve(JSON.parse(value.toString())))
|
||||
})
|
||||
await broker.input("first", "project", { x: 0.75, y: 0.5, width: 400, height: 240 }, true)
|
||||
expect(await selected).toEqual({ method: "Overlay.inspectNodeRequested", params: { backendNodeId: 42 } })
|
||||
expect(moves).toEqual([
|
||||
{ type: "move", x: 200, y: 60 },
|
||||
{ type: "move", x: 300, y: 120 },
|
||||
{ type: "down" },
|
||||
{ type: "up" },
|
||||
])
|
||||
expect(broker.get("first", "project")?.inspecting).toBe(false)
|
||||
expect(broker.get("second", "project")?.inspecting).not.toBe(true)
|
||||
|
||||
const closed = new Promise<void>((resolve) => socket.once("close", () => resolve()))
|
||||
await broker.close("first", "project")
|
||||
await closed
|
||||
expect((await fetch(first.url)).status).toBe(401)
|
||||
expect((await fetch(second.url)).status).toBe(200)
|
||||
} finally {
|
||||
await broker.disposeAsync()
|
||||
await new Promise<void>((resolve) => websocket.close(() => resolve()))
|
||||
await new Promise<void>((resolve) => remote.close(() => resolve()))
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects authenticated first-open requests for unknown sessions and directories", async () => {
|
||||
const broker = new BrowserBroker({ log: () => {} })
|
||||
brokers.push(broker)
|
||||
broker.bind((route) =>
|
||||
route.sessionId === "known" && route.directory === "/tmp/known" ? { ...route, projectId: "project" } : undefined,
|
||||
)
|
||||
const env = await broker.env()
|
||||
const headers = {
|
||||
authorization: `Bearer ${env.KILO_BROWSER_BROKER_TOKEN}`,
|
||||
"content-type": "application/json",
|
||||
}
|
||||
const url = `${env.KILO_BROWSER_BROKER_URL}/browser/open`
|
||||
for (const input of [
|
||||
{ sessionID: "unknown", directory: "/tmp/known", url: "http://localhost:3000/" },
|
||||
{ sessionID: "known", directory: "/tmp/other", url: "http://localhost:3000/" },
|
||||
]) {
|
||||
const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(input) })
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({
|
||||
error: "Browser session does not belong to the requested project or directory",
|
||||
})
|
||||
}
|
||||
expect(broker.sessions()).toEqual([])
|
||||
})
|
||||
|
||||
test("keeps browser contexts and HTTP rejection diagnostics isolated by session", async () => {
|
||||
const contexts: Array<{ close: () => Promise<void> }> = []
|
||||
const listeners = new Map<string, (value: unknown) => void>()
|
||||
let routeHandler:
|
||||
| ((
|
||||
route: { continue: () => Promise<void>; abort: () => Promise<void> },
|
||||
request: { url: () => string; isNavigationRequest?: () => boolean },
|
||||
) => Promise<void>)
|
||||
| undefined
|
||||
let aborted = false
|
||||
const browser = {
|
||||
newContext: async () => {
|
||||
const page = {
|
||||
url: () => "http://localhost:3000",
|
||||
title: async () => "Local app",
|
||||
screenshot: async () => Buffer.from("jpeg"),
|
||||
on: (type: string, listener: (value: unknown) => void) => {
|
||||
listeners.set(type, listener)
|
||||
},
|
||||
mainFrame: () => undefined,
|
||||
goto: async () => undefined,
|
||||
}
|
||||
const context = {
|
||||
close: async () => undefined,
|
||||
route: async (_pattern: string, handler: typeof routeHandler) => {
|
||||
routeHandler = handler
|
||||
},
|
||||
routeWebSocket: async () => undefined,
|
||||
newPage: async () => page,
|
||||
}
|
||||
contexts.push(context)
|
||||
return context
|
||||
},
|
||||
close: async () => undefined,
|
||||
}
|
||||
const broker = new BrowserBroker({ log: () => {}, launch: async () => browser })
|
||||
brokers.push(broker)
|
||||
await broker.open({ sessionId: "one", directory: "/tmp/project" }, "http://localhost:3000")
|
||||
await broker.open({ sessionId: "two", directory: "/tmp/project" }, "http://localhost:3000")
|
||||
expect(contexts).toHaveLength(2)
|
||||
expect(broker.get("one")?.browserId).not.toBe(broker.get("two")?.browserId)
|
||||
await routeHandler!(
|
||||
{ continue: async () => undefined, abort: async () => void (aborted = true) },
|
||||
{ url: () => "http://example.com" },
|
||||
)
|
||||
expect(aborted).toBe(true)
|
||||
const blocked = broker.get("two")
|
||||
expect(blocked).toMatchObject({
|
||||
errors: 1,
|
||||
logs: ["Blocked browser request: http://example.com"],
|
||||
error: undefined,
|
||||
})
|
||||
listeners.get("requestfailed")?.({ url: () => "http://example.com" })
|
||||
expect(broker.get("two")).toEqual(blocked)
|
||||
expect(broker.get("one")).toMatchObject({ errors: 0, logs: [], error: undefined })
|
||||
aborted = false
|
||||
await routeHandler!(
|
||||
{ continue: async () => undefined, abort: async () => void (aborted = true) },
|
||||
{ url: () => "data:text/html,<script>alert(1)</script>", isNavigationRequest: () => true },
|
||||
)
|
||||
expect(aborted).toBe(true)
|
||||
expect(broker.get("two")).toMatchObject({
|
||||
errors: 2,
|
||||
logs: ["Blocked browser request: http://example.com", "Blocked browser request: null"],
|
||||
error: undefined,
|
||||
})
|
||||
await broker.close("one")
|
||||
expect(broker.get("two")?.status).toBe("ready")
|
||||
})
|
||||
|
||||
test("rejects disabled and untrusted browser sessions before launching Chrome", async () => {
|
||||
const route = { sessionId: "restricted", directory: "/tmp/project" }
|
||||
const disabled = new BrowserBroker({ log: () => {}, enabled: () => false })
|
||||
const untrusted = new BrowserBroker({ log: () => {}, trusted: () => false })
|
||||
await expect(disabled.open(route, "http://localhost:3000")).rejects.toThrow("Browser automation is disabled")
|
||||
await expect(untrusted.open(route, "http://localhost:3000")).rejects.toThrow("trusted workspace")
|
||||
expect(disabled.sessions()).toEqual([])
|
||||
expect(untrusted.sessions()).toEqual([])
|
||||
})
|
||||
|
||||
test("explains how to recover when the selected browser runtime is missing", async () => {
|
||||
const broker = new BrowserBroker({
|
||||
log: () => {},
|
||||
useSystemChrome: () => false,
|
||||
launch: async () => {
|
||||
throw new Error("Browser executable does not exist")
|
||||
},
|
||||
})
|
||||
brokers.push(broker)
|
||||
await expect(
|
||||
broker.open({ sessionId: "missing-runtime", directory: "/tmp/project" }, "http://localhost:3000/"),
|
||||
).rejects.toThrow("enable Use System Chrome")
|
||||
expect(broker.sessions()).toEqual([])
|
||||
})
|
||||
|
||||
test("rejects unregistered browser sessions before launching Chrome", async () => {
|
||||
const broker = new BrowserBroker({ log: () => {} })
|
||||
broker.bind(() => undefined)
|
||||
await expect(
|
||||
broker.open({ projectId: "unknown", sessionId: "missing", directory: "/tmp/project" }, "http://localhost:3000/"),
|
||||
).rejects.toThrow("Browser session does not belong to the requested project or directory")
|
||||
expect(broker.sessions()).toEqual([])
|
||||
})
|
||||
|
||||
test("preserves project isolation, successful refresh, and captured HTTP errors", async () => {
|
||||
let status = 200
|
||||
let target = "about:blank"
|
||||
let navigations = 0
|
||||
let reloads = 0
|
||||
const page = {
|
||||
url: () => target,
|
||||
title: async () => (status === 404 ? "Missing page" : "Local app"),
|
||||
screenshot: async () => Buffer.from("jpeg"),
|
||||
on: (_type: string, _listener: (...args: never[]) => void) => undefined,
|
||||
mainFrame: () => undefined,
|
||||
goto: async (url: string) => {
|
||||
navigations++
|
||||
target = url
|
||||
return { status: () => status }
|
||||
},
|
||||
reload: async () => {
|
||||
reloads++
|
||||
return { status: () => status }
|
||||
},
|
||||
}
|
||||
const broker = fixture(page)
|
||||
broker.bind((route) => (route.sessionId === "session" && route.directory === "/tmp/project" ? route : undefined))
|
||||
const route = { projectId: "project-one", sessionId: "session", directory: "/tmp/project" }
|
||||
const opened = await broker.open(route, "http://localhost:3000/")
|
||||
expect(opened.status).toBe("ready")
|
||||
expect(opened.navigation).toBe(1)
|
||||
expect(opened.screenshot).toStartWith("data:image/jpeg;base64,")
|
||||
const reopened = await broker.open(route, "http://localhost:3000/")
|
||||
expect(navigations).toBe(1)
|
||||
expect(reloads).toBe(1)
|
||||
expect(reopened.navigation).toBe(2)
|
||||
const other = await broker.open({ ...route, projectId: "project-two" }, "http://localhost:3000/")
|
||||
expect(other.browserId).not.toBe(opened.browserId)
|
||||
expect(broker.get(route.sessionId)).toBeUndefined()
|
||||
expect(broker.get(route.sessionId, "project-two")?.browserId).toBe(other.browserId)
|
||||
const refreshed = await broker.refresh(route.sessionId, route.projectId)
|
||||
expect(refreshed.status).toBe("ready")
|
||||
expect(refreshed.navigation).toBe(3)
|
||||
expect(reloads).toBe(2)
|
||||
status = 404
|
||||
await expect(broker.refresh(route.sessionId, route.projectId)).rejects.toThrow(
|
||||
"Local application returned HTTP 404",
|
||||
)
|
||||
expect(broker.get(route.sessionId, route.projectId)).toMatchObject({
|
||||
projectId: "project-one",
|
||||
status: "error",
|
||||
title: "Missing page",
|
||||
url: "http://localhost:3000/",
|
||||
error: "Local application returned HTTP 404",
|
||||
})
|
||||
expect(broker.get(route.sessionId, route.projectId)?.screenshot).toStartWith("data:image/jpeg;base64,")
|
||||
status = 200
|
||||
const recovered = await broker.open(route, "http://localhost:3000/recovered")
|
||||
expect(recovered).toMatchObject({ status: "ready", errors: 0, url: "http://localhost:3000/recovered" })
|
||||
expect(recovered.error).toBeUndefined()
|
||||
})
|
||||
|
||||
test("reports response headers that prevent embedding the real application document", async () => {
|
||||
let headers: Record<string, string> = {}
|
||||
let target = "about:blank"
|
||||
const page = {
|
||||
url: () => target,
|
||||
title: async () => "Local app",
|
||||
screenshot: async () => Buffer.from("jpeg"),
|
||||
on: (_type: string, _listener: (...args: never[]) => void) => undefined,
|
||||
mainFrame: () => undefined,
|
||||
goto: async (url: string) => {
|
||||
target = url
|
||||
return { status: () => 200, headers: () => headers }
|
||||
},
|
||||
}
|
||||
const broker = fixture(page)
|
||||
const route = { sessionId: "framed", directory: "/tmp/project" }
|
||||
expect((await broker.open(route, "http://localhost:3000/open")).frameError).toBeUndefined()
|
||||
headers = { "x-frame-options": "DENY" }
|
||||
expect((await broker.open(route, "http://localhost:3000/deny")).frameError).toContain("X-Frame-Options: DENY")
|
||||
headers = { "x-frame-options": "SAMEORIGIN" }
|
||||
expect((await broker.open(route, "http://localhost:3000/same-origin")).frameError).toContain("SAMEORIGIN")
|
||||
headers = { "content-security-policy": "default-src 'self'; frame-ancestors 'none'" }
|
||||
expect((await broker.open(route, "http://localhost:3000/csp")).frameError).toContain("frame-ancestors 'none'")
|
||||
headers = {}
|
||||
expect((await broker.open(route, "http://localhost:3000/recovered")).frameError).toBeUndefined()
|
||||
})
|
||||
|
||||
test("serializes concurrent navigation and close without reviving a stale session", async () => {
|
||||
let contexts = 0
|
||||
let release: (() => void) | undefined
|
||||
let started: (() => void) | undefined
|
||||
const waiting = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const navigating = new Promise<void>((resolve) => {
|
||||
started = resolve
|
||||
})
|
||||
let target = "about:blank"
|
||||
const page = {
|
||||
url: () => target,
|
||||
title: async () => "Local app",
|
||||
screenshot: async () => Buffer.from("jpeg"),
|
||||
on: (_type: string, _listener: (...args: never[]) => void) => undefined,
|
||||
mainFrame: () => undefined,
|
||||
goto: async (url: string) => {
|
||||
target = url
|
||||
started?.()
|
||||
await waiting
|
||||
},
|
||||
}
|
||||
const browser = {
|
||||
newContext: async () => {
|
||||
contexts++
|
||||
return {
|
||||
close: async () => undefined,
|
||||
route: async () => undefined,
|
||||
routeWebSocket: async () => undefined,
|
||||
newPage: async () => page,
|
||||
}
|
||||
},
|
||||
close: async () => undefined,
|
||||
}
|
||||
const broker = new BrowserBroker({ log: () => {}, launch: async () => browser })
|
||||
brokers.push(broker)
|
||||
const states: string[] = []
|
||||
broker.subscribe((state) => states.push(state.status))
|
||||
const route = { sessionId: "concurrent", directory: "/tmp/project" }
|
||||
const first = broker.open(route, "http://localhost:3000/one")
|
||||
await navigating
|
||||
const second = broker.open(route, "http://localhost:3000/two")
|
||||
const closed = broker.close(route.sessionId)
|
||||
release?.()
|
||||
await Promise.all([first, second, closed])
|
||||
expect(contexts).toBe(1)
|
||||
expect(broker.get(route.sessionId)).toBeUndefined()
|
||||
expect(states.at(-1)).toBe("closed")
|
||||
})
|
||||
|
||||
test("does not create a context after disposal interrupts browser launch", async () => {
|
||||
let resume: (() => void) | undefined
|
||||
let launched: (() => void) | undefined
|
||||
let contexts = 0
|
||||
const waiting = new Promise<void>((resolve) => {
|
||||
resume = resolve
|
||||
})
|
||||
const starting = new Promise<void>((resolve) => {
|
||||
launched = resolve
|
||||
})
|
||||
const browser = {
|
||||
newContext: async () => {
|
||||
contexts++
|
||||
throw new Error("unexpected context")
|
||||
},
|
||||
close: async () => undefined,
|
||||
}
|
||||
const broker = new BrowserBroker({
|
||||
log: () => {},
|
||||
launch: async () => {
|
||||
launched?.()
|
||||
await waiting
|
||||
return browser
|
||||
},
|
||||
})
|
||||
const opened = broker.open({ sessionId: "disposed", directory: "/tmp/project" }, "http://localhost:3000/")
|
||||
await starting
|
||||
const disposed = broker.disposeAsync()
|
||||
resume?.()
|
||||
await expect(opened).rejects.toThrow("Browser broker is closed")
|
||||
await disposed
|
||||
expect(contexts).toBe(0)
|
||||
expect(broker.sessions()).toEqual([])
|
||||
})
|
||||
|
||||
test("records bounded console diagnostics and inspects selected page elements", async () => {
|
||||
const listeners = new Map<string, (value: unknown) => void>()
|
||||
let viewport: { width: number; height: number } | undefined
|
||||
let resizes = 0
|
||||
const page = {
|
||||
url: () => "http://localhost:3000/",
|
||||
title: async () => "Feedback demo",
|
||||
screenshot: async () => Buffer.from("jpeg"),
|
||||
on: (type: string, listener: (value: unknown) => void) => {
|
||||
listeners.set(type, listener)
|
||||
},
|
||||
mainFrame: () => undefined,
|
||||
goto: async () => undefined,
|
||||
reload: async () => {
|
||||
listeners.get("console")?.({ type: () => "log", text: () => "DEMO_RELOAD_LOG: refreshed application" })
|
||||
},
|
||||
viewportSize: () => viewport,
|
||||
setViewportSize: async (size: { width: number; height: number }) => {
|
||||
viewport = size
|
||||
resizes++
|
||||
},
|
||||
evaluate: async () => ({
|
||||
tag: "section",
|
||||
id: "feature-card",
|
||||
text: "Blue feedback card",
|
||||
selector: "#feature-card",
|
||||
}),
|
||||
}
|
||||
const broker = fixture(page)
|
||||
await broker.open(
|
||||
{ projectId: "project", sessionId: "feedback", directory: "/tmp/project" },
|
||||
"http://localhost:3000/",
|
||||
)
|
||||
listeners.get("console")?.({ type: () => "log", text: () => "DEMO_STARTUP_LOG: page loaded" })
|
||||
listeners.get("console")?.({ type: () => "info", text: () => "DEMO_STARTUP_INFO: ready" })
|
||||
listeners.get("console")?.({ type: () => "warning", text: () => "DEMO_STARTUP_WARNING: check layout" })
|
||||
listeners.get("console")?.({ type: () => "error", text: () => "DEMO_STARTUP_ERROR: script initialized" })
|
||||
listeners.get("pageerror")?.(new Error("DEMO_PAGE_ERROR: broken element"))
|
||||
const inspected = await broker.inspect("feedback", "project", { x: 0.4, y: 0.3, width: 760, height: 580 })
|
||||
expect(viewport).toEqual({ width: 760, height: 580 })
|
||||
expect(inspected.element).toMatchObject({
|
||||
tag: "section",
|
||||
id: "feature-card",
|
||||
text: "Blue feedback card",
|
||||
selector: "#feature-card",
|
||||
})
|
||||
expect(inspected.logs).toEqual([
|
||||
"[log] DEMO_STARTUP_LOG: page loaded",
|
||||
"[info] DEMO_STARTUP_INFO: ready",
|
||||
"[warning] DEMO_STARTUP_WARNING: check layout",
|
||||
"[error] DEMO_STARTUP_ERROR: script initialized",
|
||||
"DEMO_PAGE_ERROR: broken element",
|
||||
])
|
||||
expect(broker.get("feedback", "project")?.errors).toBe(2)
|
||||
expect(broker.get("feedback", "project")?.logs).toEqual(inspected.logs)
|
||||
await broker.inspect("feedback", "project", { x: 0.2, y: 0.4, width: 760, height: 580 })
|
||||
expect(resizes).toBe(1)
|
||||
const reloaded = await broker.open(
|
||||
{ projectId: "project", sessionId: "feedback", directory: "/tmp/project" },
|
||||
"http://localhost:3000/",
|
||||
)
|
||||
expect(reloaded.navigation).toBe(2)
|
||||
expect(reloaded.errors).toBe(0)
|
||||
expect(reloaded.logs).toEqual(["[log] DEMO_RELOAD_LOG: refreshed application"])
|
||||
await expect(broker.inspect("feedback", "project", { x: 1.2, y: 0.3, width: 760, height: 580 })).rejects.toThrow(
|
||||
"Browser element coordinates are invalid",
|
||||
)
|
||||
for (let index = 0; index < 25; index++) {
|
||||
listeners.get("console")?.({ type: () => "error", text: () => `error-${index}` })
|
||||
}
|
||||
expect(broker.get("feedback", "project")?.logs).toHaveLength(20)
|
||||
})
|
||||
|
||||
test("blocks browser popups without replacing navigation failures", async () => {
|
||||
const listeners = new Map<string, (...args: never[]) => void>()
|
||||
const page = {
|
||||
url: () => "http://localhost:3000/",
|
||||
title: async () => "Local app",
|
||||
screenshot: async () => Buffer.from("jpeg"),
|
||||
on: (type: string, listener: (...args: never[]) => void) => {
|
||||
listeners.set(type, listener)
|
||||
},
|
||||
mainFrame: () => undefined,
|
||||
goto: async () => undefined,
|
||||
reload: async () => {
|
||||
throw new Error("Navigation failed")
|
||||
},
|
||||
}
|
||||
const broker = fixture(page)
|
||||
await broker.open({ sessionId: "popup", directory: "/tmp/project" }, "http://localhost:3000/")
|
||||
let closed = false
|
||||
listeners.get("popup")!({ close: async () => void (closed = true) } as never)
|
||||
expect(closed).toBe(true)
|
||||
expect(broker.get("popup")).toMatchObject({ errors: 1, logs: ["Blocked browser popup"], error: undefined })
|
||||
await expect(broker.refresh("popup")).rejects.toThrow("Navigation failed")
|
||||
closed = false
|
||||
listeners.get("popup")!({ close: async () => void (closed = true) } as never)
|
||||
expect(closed).toBe(true)
|
||||
expect(broker.get("popup")).toMatchObject({
|
||||
status: "error",
|
||||
errors: 1,
|
||||
logs: ["Blocked browser popup"],
|
||||
error: "Navigation failed",
|
||||
})
|
||||
})
|
||||
|
||||
test("allows only same-origin WebSockets", async () => {
|
||||
let handler:
|
||||
| ((socket: { url: () => string; connectToServer: () => void; close: () => Promise<void> }) => Promise<void>)
|
||||
| undefined
|
||||
const page = {
|
||||
url: () => "http://localhost:3000/",
|
||||
title: async () => "Local app",
|
||||
screenshot: async () => Buffer.from("jpeg"),
|
||||
on: (_type: string, _listener: (...args: never[]) => void) => undefined,
|
||||
mainFrame: () => undefined,
|
||||
goto: async () => undefined,
|
||||
}
|
||||
const browser = {
|
||||
newContext: async () => ({
|
||||
close: async () => undefined,
|
||||
route: async () => undefined,
|
||||
routeWebSocket: async (_pattern: string, next: typeof handler) => {
|
||||
handler = next
|
||||
},
|
||||
newPage: async () => page,
|
||||
}),
|
||||
close: async () => undefined,
|
||||
}
|
||||
const broker = new BrowserBroker({ log: () => {}, launch: async () => browser })
|
||||
brokers.push(broker)
|
||||
await broker.open({ sessionId: "socket", directory: "/tmp/project" }, "http://localhost:3000/")
|
||||
let connected = false
|
||||
let closed = false
|
||||
await handler!({
|
||||
url: () => "ws://localhost:3000/hmr",
|
||||
connectToServer: () => void (connected = true),
|
||||
close: async () => void (closed = true),
|
||||
})
|
||||
expect(connected).toBe(true)
|
||||
expect(closed).toBe(false)
|
||||
expect(broker.get("socket")).toMatchObject({ errors: 0, logs: [], error: undefined })
|
||||
connected = false
|
||||
await handler!({
|
||||
url: () => "ws://localhost:4000/private",
|
||||
connectToServer: () => void (connected = true),
|
||||
close: async () => void (closed = true),
|
||||
})
|
||||
expect(connected).toBe(false)
|
||||
expect(closed).toBe(true)
|
||||
expect(broker.get("socket")).toMatchObject({
|
||||
errors: 1,
|
||||
logs: ["Blocked browser request: ws://localhost:4000"],
|
||||
error: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,250 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot, createSignal } from "solid-js"
|
||||
import { createBrowserController } from "../../webview-ui/browser/controller"
|
||||
import type { BrowserReference } from "../../src/shared/browser-feedback"
|
||||
import type {
|
||||
BrowserCommand,
|
||||
BrowserEvent,
|
||||
BrowserInspection,
|
||||
BrowserPosition,
|
||||
BrowserScope,
|
||||
} from "../../webview-ui/browser/types"
|
||||
|
||||
const point = (x: number): BrowserPosition => ({ x, y: x, width: 100, height: 100 })
|
||||
|
||||
function setup(theme: "dark" | "light" = "dark") {
|
||||
const sent: BrowserCommand[] = []
|
||||
const references: BrowserReference[] = []
|
||||
const listeners = new Set<(event: BrowserEvent) => void>()
|
||||
const frames = new Map<number, FrameRequestCallback>()
|
||||
const cancelled: number[] = []
|
||||
let nextFrame = 0
|
||||
let setScope: (value: BrowserScope | undefined) => void = () => {}
|
||||
let controller!: ReturnType<typeof createBrowserController>
|
||||
let dispose!: () => void
|
||||
createRoot((rootDispose) => {
|
||||
dispose = rootDispose
|
||||
const [scope, update] = createSignal<BrowserScope | undefined>({
|
||||
sessionId: "session-a",
|
||||
projectId: "project-a",
|
||||
})
|
||||
setScope = update
|
||||
controller = createBrowserController({
|
||||
scope,
|
||||
transport: {
|
||||
send: (command) => sent.push(command),
|
||||
subscribe: (listener) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
},
|
||||
onReference: (reference) => references.push(reference),
|
||||
onClose: () => {},
|
||||
theme: () => theme,
|
||||
schedule: (callback) => {
|
||||
const id = ++nextFrame
|
||||
frames.set(id, callback)
|
||||
return id
|
||||
},
|
||||
cancel: (id) => {
|
||||
cancelled.push(id)
|
||||
frames.delete(id)
|
||||
},
|
||||
})
|
||||
})
|
||||
const emit = (event: BrowserEvent) => listeners.forEach((listener) => listener(event))
|
||||
const run = () => {
|
||||
const item = frames.entries().next().value as [number, FrameRequestCallback] | undefined
|
||||
if (!item) throw new Error("No animation frame scheduled")
|
||||
frames.delete(item[0])
|
||||
item[1](0)
|
||||
}
|
||||
return { controller, sent, emit, frames, cancelled, setScope, dispose, run, listeners, references }
|
||||
}
|
||||
|
||||
function inspection(requestId: string, scope: BrowserScope, error?: string): BrowserInspection {
|
||||
return {
|
||||
requestId,
|
||||
scope,
|
||||
hover: true,
|
||||
error,
|
||||
element: error
|
||||
? undefined
|
||||
: { tag: "button", selector: `button-${requestId}`, rect: { x: 0, y: 0, width: 1, height: 1 } },
|
||||
logs: [],
|
||||
}
|
||||
}
|
||||
|
||||
describe("browser controller", () => {
|
||||
test("coalesces pointer movement while an inspection is active", () => {
|
||||
const view = setup()
|
||||
view.controller.toggleSelecting()
|
||||
view.controller.move(point(0.1))
|
||||
view.run()
|
||||
view.controller.move(point(0.2))
|
||||
view.controller.move(point(0.3))
|
||||
|
||||
expect(view.sent.filter((item) => item.type === "inspect")).toHaveLength(1)
|
||||
view.emit({
|
||||
type: "inspection",
|
||||
value: inspection("1", { sessionId: "session-a", projectId: "project-a" }),
|
||||
})
|
||||
expect(view.frames.size).toBe(1)
|
||||
view.run()
|
||||
|
||||
expect(view.sent.filter((item) => item.type === "inspect")).toHaveLength(2)
|
||||
expect(view.sent.at(-1)).toMatchObject({ type: "inspect", requestId: "2", position: point(0.3) })
|
||||
view.dispose()
|
||||
})
|
||||
|
||||
test("recovers hover scheduling after a matching failed response", () => {
|
||||
const view = setup()
|
||||
view.controller.toggleSelecting()
|
||||
view.controller.move(point(0.1))
|
||||
view.run()
|
||||
view.controller.move(point(0.4))
|
||||
|
||||
view.emit({
|
||||
type: "inspection",
|
||||
value: inspection("1", { sessionId: "session-a", projectId: "project-a" }, "Inspection failed"),
|
||||
})
|
||||
expect(view.controller.hovered()).toBeUndefined()
|
||||
expect(view.frames.size).toBe(1)
|
||||
view.run()
|
||||
|
||||
expect(view.sent.at(-1)).toMatchObject({ type: "inspect", requestId: "2", position: point(0.4) })
|
||||
view.dispose()
|
||||
})
|
||||
|
||||
test("ignores stale request and wrong scope responses", () => {
|
||||
const view = setup()
|
||||
view.controller.toggleSelecting()
|
||||
view.controller.move(point(0.1))
|
||||
view.run()
|
||||
view.emit({
|
||||
type: "inspection",
|
||||
value: inspection("old", { sessionId: "session-a", projectId: "project-a" }),
|
||||
})
|
||||
view.emit({
|
||||
type: "inspection",
|
||||
value: inspection("1", { sessionId: "session-b", projectId: "project-a" }),
|
||||
})
|
||||
view.emit({
|
||||
type: "inspection",
|
||||
value: inspection("1", { sessionId: "session-a", projectId: "project-b" }),
|
||||
})
|
||||
expect(view.controller.hovered()).toBeUndefined()
|
||||
expect(view.frames.size).toBe(0)
|
||||
|
||||
view.emit({
|
||||
type: "inspection",
|
||||
value: inspection("1", { sessionId: "session-a", projectId: "project-a" }),
|
||||
})
|
||||
expect(view.controller.hovered()?.element?.selector).toBe("button-1")
|
||||
view.dispose()
|
||||
})
|
||||
|
||||
test("ignores events and cancels scheduled work after cleanup", () => {
|
||||
const view = setup()
|
||||
view.controller.toggleSelecting()
|
||||
view.controller.move(point(0.1))
|
||||
expect(view.frames.size).toBe(1)
|
||||
view.dispose()
|
||||
|
||||
expect(view.cancelled).toEqual([1])
|
||||
expect(view.frames.size).toBe(0)
|
||||
view.emit({
|
||||
type: "inspection",
|
||||
value: inspection("1", { sessionId: "session-a", projectId: "project-a" }),
|
||||
})
|
||||
expect(view.controller.hovered()).toBeUndefined()
|
||||
view.controller.state()
|
||||
view.controller.toggleSelecting()
|
||||
view.controller.move(point(0.2))
|
||||
view.controller.setUrl("localhost:3000")
|
||||
view.controller.open()
|
||||
view.controller.toggleTools()
|
||||
expect(view.listeners.size).toBe(0)
|
||||
expect(view.frames.size).toBe(0)
|
||||
expect(view.controller.selecting()).toBe(false)
|
||||
expect(view.sent).toEqual([{ type: "state", scope: { sessionId: "session-a", projectId: "project-a" } }])
|
||||
})
|
||||
|
||||
test("uses an injected theme without reading host DOM or VS Code classes", () => {
|
||||
const view = setup("light")
|
||||
view.controller.toggleTools()
|
||||
expect(view.sent.at(-1)).toEqual({
|
||||
type: "devtools",
|
||||
scope: { sessionId: "session-a", projectId: "project-a" },
|
||||
theme: "light",
|
||||
})
|
||||
view.dispose()
|
||||
})
|
||||
|
||||
test.each(["error", "empty", "selectorless"] as const)("recovers from %s selection responses", (mode) => {
|
||||
const view = setup()
|
||||
const scope = { sessionId: "session-a", projectId: "project-a" }
|
||||
view.controller.toggleSelecting()
|
||||
view.controller.select(point(0.2))
|
||||
expect(view.controller.selecting()).toBe(false)
|
||||
const value = inspection("1", scope, mode === "error" ? "Navigation interrupted" : undefined)
|
||||
if (mode === "empty") value.element = undefined
|
||||
if (mode === "selectorless") value.element = { tag: "button", selector: "" }
|
||||
view.emit({ type: "inspection", value: { ...value, hover: false } })
|
||||
expect(view.controller.selecting()).toBe(true)
|
||||
expect(view.references).toEqual([])
|
||||
view.controller.select(point(0.3))
|
||||
view.emit({ type: "inspection", value: { ...inspection("2", scope), hover: false } })
|
||||
expect(view.references).toHaveLength(1)
|
||||
expect(view.references[0]?.selector).toBe("button-2")
|
||||
expect(view.controller.selecting()).toBe(false)
|
||||
view.dispose()
|
||||
})
|
||||
|
||||
test("does not attach stale selections after navigation or browser closure", () => {
|
||||
const view = setup()
|
||||
const scope = { sessionId: "session-a", projectId: "project-a" }
|
||||
const state = { scope, browserId: "browser", status: "ready" as const, navigation: 1, errors: 0 }
|
||||
view.emit({ type: "state", value: state })
|
||||
view.controller.select(point(0.2))
|
||||
view.emit({ type: "state", value: { ...state, navigation: 2, status: "loading" } })
|
||||
view.emit({ type: "inspection", value: { ...inspection("1", scope), hover: false } })
|
||||
expect(view.references).toEqual([])
|
||||
view.controller.select(point(0.3))
|
||||
view.emit({ type: "state", value: { ...state, navigation: 2, status: "closed", inspecting: true } })
|
||||
view.emit({ type: "inspection", value: { ...inspection("2", scope), hover: false } })
|
||||
expect(view.references).toEqual([])
|
||||
expect(view.controller.pointing()).toBe(false)
|
||||
expect(view.controller.selecting()).toBe(false)
|
||||
view.dispose()
|
||||
})
|
||||
|
||||
test("drops state from the previous scope before accepting the new scope", async () => {
|
||||
const view = setup()
|
||||
view.emit({
|
||||
type: "state",
|
||||
value: {
|
||||
scope: { sessionId: "session-a", projectId: "project-a" },
|
||||
browserId: "browser-a",
|
||||
status: "ready",
|
||||
errors: 0,
|
||||
url: "http://localhost:3000",
|
||||
},
|
||||
})
|
||||
expect(view.controller.state()?.browserId).toBe("browser-a")
|
||||
view.setScope({ sessionId: "session-b", projectId: "project-b" })
|
||||
await Promise.resolve()
|
||||
expect(view.controller.state()).toBeUndefined()
|
||||
view.emit({
|
||||
type: "state",
|
||||
value: {
|
||||
scope: { sessionId: "session-a", projectId: "project-a" },
|
||||
browserId: "old-browser",
|
||||
status: "ready",
|
||||
errors: 0,
|
||||
},
|
||||
})
|
||||
expect(view.controller.state()).toBeUndefined()
|
||||
view.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { Window } from "happy-dom"
|
||||
import { capture, locate } from "../../src/services/browser-automation/browser-element"
|
||||
|
||||
const windows: Window[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(windows.splice(0).map((window) => window.happyDOM.close()))
|
||||
})
|
||||
|
||||
function inspect(html: string, selector: string, detail = true) {
|
||||
const window = new Window({ url: "http://localhost:3000/" })
|
||||
windows.push(window)
|
||||
window.document.body.innerHTML = html
|
||||
const node = window.document.querySelector(selector)
|
||||
if (!node) throw new Error("Selected test element is missing")
|
||||
Object.defineProperty(window.document, "elementFromPoint", { value: () => node })
|
||||
const run = new Function(
|
||||
"document",
|
||||
"Element",
|
||||
"CSS",
|
||||
"innerWidth",
|
||||
"innerHeight",
|
||||
"getComputedStyle",
|
||||
`return (${capture.toString()})(${JSON.stringify({ x: 0.5, y: 0.5, detail })})`,
|
||||
)
|
||||
const result = run(
|
||||
window.document,
|
||||
window.Element,
|
||||
window.CSS,
|
||||
window.innerWidth,
|
||||
window.innerHeight,
|
||||
window.getComputedStyle.bind(window),
|
||||
) as ReturnType<typeof capture> | undefined
|
||||
if (!result) throw new Error("Browser element capture returned no element")
|
||||
return { result, node, document: window.document }
|
||||
}
|
||||
|
||||
describe("browser element context", () => {
|
||||
test("builds a unique selector and bounded ancestry for repeated buttons without ids", () => {
|
||||
const selected = inspect(
|
||||
'<main id="app"><section class="hero"><div class="actions"><button class="primary">Save</button><button class="primary">Cancel</button></div></section><section><button class="primary">Save</button></section></main>',
|
||||
".actions button:first-child",
|
||||
)
|
||||
expect(selected.document.querySelectorAll(selected.result.selector)).toHaveLength(1)
|
||||
expect(selected.document.querySelector(selected.result.selector)).toBe(selected.node)
|
||||
expect(selected.result.hierarchy).toEqual([
|
||||
"html",
|
||||
"body",
|
||||
"main#app",
|
||||
"section.hero",
|
||||
"div.actions",
|
||||
"button.primary",
|
||||
])
|
||||
expect(selected.result.html).toBe('<button class="primary">Save</button>')
|
||||
})
|
||||
|
||||
test("prefers stable test ids and does not trust duplicate element ids", () => {
|
||||
const stable = inspect('<button data-testid="checkout">Pay</button><button>Pay</button>', "[data-testid]")
|
||||
expect(stable.result.selector).toBe('button[data-testid="checkout"]')
|
||||
const duplicate = inspect(
|
||||
'<div><button id="duplicate">One</button><button id="duplicate">Two</button></div>',
|
||||
"button:last-child",
|
||||
)
|
||||
expect(duplicate.result.selector).not.toBe("#duplicate")
|
||||
expect(duplicate.document.querySelectorAll(duplicate.result.selector)).toHaveLength(1)
|
||||
expect(duplicate.document.querySelector(duplicate.result.selector)).toBe(duplicate.node)
|
||||
})
|
||||
|
||||
test("excludes scripts, handlers, arbitrary attributes, hidden text, and input values", () => {
|
||||
const selected = inspect(
|
||||
'<button id="save" onclick="secret()" data-api-key="secret-token" aria-label="Save"><span>Save</span><script>secret-code</script><input value="secret-input"><textarea>secret-textarea</textarea><span hidden>secret-hidden</span><span style="display:none">secret-css</span><span style="visibility:hidden">secret-invisible</span><span style="opacity:0">secret-transparent</span><span contenteditable>secret-editable</span></button>',
|
||||
"#save",
|
||||
)
|
||||
expect(selected.result.text).toBe("Save")
|
||||
expect(selected.result.html).toBe('<button id="save" aria-label="Save">Save</button>')
|
||||
expect(JSON.stringify(selected.result)).not.toContain("secret")
|
||||
const password = inspect('<input type="password" value="private-password" aria-label="Password">', "input")
|
||||
expect(password.result.text).toBe("Password")
|
||||
expect(JSON.stringify(password.result)).not.toContain("private-password")
|
||||
})
|
||||
|
||||
test("provides relevant colors and keeps hover responses lightweight", () => {
|
||||
const selected = inspect('<button style="color: white; background-color: rgb(22, 163, 74)">Save</button>', "button")
|
||||
expect(selected.result.styles?.backgroundColor).toBe("rgb(22, 163, 74)")
|
||||
expect(selected.result.html).not.toContain("style=")
|
||||
const hover = inspect('<button id="save">Save</button>', "button", false)
|
||||
expect(hover.result.selector).toBe("#save")
|
||||
expect(hover.result).not.toHaveProperty("html")
|
||||
expect(hover.result).not.toHaveProperty("hierarchy")
|
||||
expect(hover.result).not.toHaveProperty("source")
|
||||
})
|
||||
|
||||
test("bounds text and HTML instead of copying the entire document", () => {
|
||||
const selected = inspect(
|
||||
`<main><button class="${"a".repeat(500)}">${"Save ".repeat(500)}</button></main>`,
|
||||
"button",
|
||||
)
|
||||
expect(selected.result.text?.length).toBeLessThanOrEqual(180)
|
||||
expect(selected.result.html?.length).toBeLessThanOrEqual(800)
|
||||
expect(selected.result.classes?.length).toBeLessThanOrEqual(180)
|
||||
})
|
||||
|
||||
test("does not fabricate ambiguous selectors for deeply repeated structures", () => {
|
||||
const nested = (depth: number) =>
|
||||
`<section>${"<div>".repeat(depth)}<button>Save</button>${"</div>".repeat(depth)}</section>`
|
||||
const selected = inspect(nested(26).repeat(2), "section:nth-of-type(2) button")
|
||||
expect(selected.document.querySelectorAll(selected.result.selector)).toHaveLength(1)
|
||||
expect(selected.document.querySelector(selected.result.selector)).toBe(selected.node)
|
||||
expect(() => inspect(nested(150).repeat(2), "section:nth-of-type(2) button")).toThrow(
|
||||
"Browser element capture returned no element",
|
||||
)
|
||||
})
|
||||
|
||||
test("bounds traversal before reading a large selected subtree", () => {
|
||||
const selected = inspect(
|
||||
`<main id="large">${"<span></span>".repeat(300)}<span>late-private-text</span></main>`,
|
||||
"main",
|
||||
)
|
||||
expect(selected.result.text).toBeUndefined()
|
||||
expect(selected.result.html).toBe('<main id="large"></main>')
|
||||
})
|
||||
|
||||
test("accepts only existing source files within the owning workspace", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "kilo-browser-source-"))
|
||||
try {
|
||||
const project = path.join(root, "project")
|
||||
await mkdir(path.join(project, "src"), { recursive: true })
|
||||
await writeFile(path.join(project, "src", "Button.tsx"), "export const Button = () => {\n\n\n return null\n}")
|
||||
await writeFile(path.join(root, "private.ts"), "export const privateValue = true")
|
||||
await writeFile(path.join(project, ".env"), "SECRET=value")
|
||||
await symlink(path.join(root, "private.ts"), path.join(project, "src", "external.ts"))
|
||||
expect(await locate(project, { file: "src/Button.tsx", line: 4, column: 2 })).toEqual({
|
||||
file: "src/Button.tsx",
|
||||
line: 4,
|
||||
column: 2,
|
||||
})
|
||||
expect(await locate(project, { file: "src/Button.tsx", line: 999, column: 2 })).toEqual({
|
||||
file: "src/Button.tsx",
|
||||
line: undefined,
|
||||
column: undefined,
|
||||
})
|
||||
expect(await locate(project, { file: "src/missing.tsx" })).toBeUndefined()
|
||||
expect(await locate(project, { file: "../private.ts" })).toBeUndefined()
|
||||
expect(await locate(project, { file: "src/external.ts" })).toBeUndefined()
|
||||
expect(await locate(project, { file: ".env" })).toBeUndefined()
|
||||
expect(await locate(project, { file: "https://example.com/Button.tsx" })).toBeUndefined()
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
browserFeedbackData,
|
||||
browserFeedbackMetadata,
|
||||
formatBrowserFeedback,
|
||||
mergeBrowserReferences,
|
||||
partFeedback,
|
||||
parseBrowserFeedback,
|
||||
type BrowserReference,
|
||||
} from "../../src/shared/browser-feedback"
|
||||
import { formatReviewCommentsMarkdown } from "../../webview-ui/src/utils/review-comment-markdown"
|
||||
|
||||
const reference = (overrides: Partial<BrowserReference> = {}): BrowserReference => ({
|
||||
id: "browser-1",
|
||||
sessionId: "session-1",
|
||||
selector: "main > button.save",
|
||||
url: "https://user:secret@example.com/app?token=private#section",
|
||||
title: "Settings",
|
||||
hierarchy: ["main", "button.save"],
|
||||
text: "Save settings",
|
||||
html: '<button class="save">Save settings</button>',
|
||||
styles: { color: "rgb(1, 2, 3)", backgroundColor: "white" },
|
||||
source: { file: "src/settings.tsx", line: 42, column: 7 },
|
||||
content: "legacy dump and bounds",
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe("browser feedback formatter", () => {
|
||||
it("formats grounded fields and omits legacy content and bounds", () => {
|
||||
const text = formatBrowserFeedback([reference()])
|
||||
expect(text).toContain("Page: Settings")
|
||||
expect(text).toContain("https://example.com/app")
|
||||
expect(text).toContain("main > button.save")
|
||||
expect(text).toContain("Save settings")
|
||||
expect(text).toContain("src/settings.tsx:42:7")
|
||||
expect(text).not.toContain("secret")
|
||||
expect(text).not.toContain("token")
|
||||
expect(text).not.toContain("legacy dump")
|
||||
expect(text).not.toContain("Bounds")
|
||||
})
|
||||
|
||||
it("keeps equivalent text and html from duplicating context", () => {
|
||||
const text = formatBrowserFeedback([reference({ html: "Save settings" })])
|
||||
expect(text.match(/Save settings/g)?.length).toBe(1)
|
||||
expect(text).not.toContain("HTML:")
|
||||
})
|
||||
|
||||
it("includes readable text only once when a safe HTML snippet already contains it", () => {
|
||||
const text = formatBrowserFeedback([reference()])
|
||||
expect(text.match(/Save settings/g)).toHaveLength(1)
|
||||
expect(text).not.toContain("Text:")
|
||||
})
|
||||
|
||||
it("updates repeated selections without duplicating the same page element", () => {
|
||||
const first = browserFeedbackData([reference()])!.references
|
||||
const merged = mergeBrowserReferences(first, reference({ id: "new-selection", text: "Updated settings" }))
|
||||
expect(merged).toHaveLength(1)
|
||||
expect(merged[0]?.id).toBe("new-selection")
|
||||
expect(merged[0]?.text).toBe("Updated settings")
|
||||
expect(merged[0]?.url).toBe("https://example.com/app")
|
||||
expect(merged[0]).not.toHaveProperty("content")
|
||||
})
|
||||
|
||||
it("rejects invalid and oversized references", () => {
|
||||
expect(browserFeedbackData([])).toBeUndefined()
|
||||
expect(browserFeedbackData([reference({ selector: "x".repeat(5_000) })])).toBeUndefined()
|
||||
expect(browserFeedbackData(Array.from({ length: 21 }, (_, id) => reference({ id: String(id) })))).toBeUndefined()
|
||||
expect(browserFeedbackData([reference({ source: { file: "../secret" } })])).toBeUndefined()
|
||||
expect(browserFeedbackData([reference({ url: "file:///tmp/private" })])).toBeUndefined()
|
||||
expect(browserFeedbackData([reference({ text: "x".repeat(20_001) })])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("browser feedback metadata", () => {
|
||||
it("round-trips metadata while ignoring legacy content", () => {
|
||||
const data = browserFeedbackData([reference()])!
|
||||
const prefix = formatBrowserFeedback(data.references)
|
||||
expect(parseBrowserFeedback(data, `${prefix}\n\nFix the save action`)).toEqual(data)
|
||||
expect(partFeedback(browserFeedbackMetadata(data), `${prefix}\n\nFix the save action`)).toEqual({
|
||||
browserFeedback: data,
|
||||
body: "Fix the save action",
|
||||
})
|
||||
})
|
||||
|
||||
it("rejects arbitrary text that does not match the metadata prefix", () => {
|
||||
const data = browserFeedbackData([reference()])!
|
||||
expect(parseBrowserFeedback(data, "unrelated text")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("coexists with local and PR review metadata", () => {
|
||||
const review = {
|
||||
version: 1 as const,
|
||||
comments: [
|
||||
{
|
||||
id: "review-1",
|
||||
file: "src/app.ts",
|
||||
side: "additions" as const,
|
||||
line: 3,
|
||||
comment: "Keep this branch safe",
|
||||
selectedText: "return value",
|
||||
},
|
||||
],
|
||||
}
|
||||
const browser = browserFeedbackData([reference()])!
|
||||
const reviewPrefix = formatReviewCommentsMarkdown(review.comments)
|
||||
const browserPrefix = formatBrowserFeedback(browser.references)
|
||||
const content = `${reviewPrefix}\n\n${browserPrefix}\n\nDo both`
|
||||
expect(partFeedback({ kilo: { review, browserFeedback: browser } }, content)).toEqual({
|
||||
review,
|
||||
browserFeedback: browser,
|
||||
body: "Do both",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { browserMessage, handleBrowserMessage } from "../../src/agent-manager/browser-message"
|
||||
import type { Host } from "../../src/agent-manager/host"
|
||||
import type { ProjectContexts } from "../../src/agent-manager/project/contexts"
|
||||
import type { AgentManagerInMessage, AgentManagerOutMessage } from "../../src/agent-manager/types"
|
||||
import { BrowserBroker } from "../../src/services/browser-automation/browser-broker"
|
||||
|
||||
const brokers: BrowserBroker[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(brokers.splice(0).map((broker) => broker.disposeAsync()))
|
||||
})
|
||||
|
||||
async function fixture() {
|
||||
let attempts = 0
|
||||
const state = { trusted: true, enabled: true }
|
||||
const page = {
|
||||
url: () => "http://localhost:3000/",
|
||||
title: async () => "",
|
||||
screenshot: async () => Buffer.from("jpeg"),
|
||||
on: () => undefined,
|
||||
mainFrame: () => undefined,
|
||||
goto: async () => undefined,
|
||||
setViewportSize: async () => undefined,
|
||||
evaluate: async () => {
|
||||
if (++attempts === 1) throw new Error("Execution context was destroyed")
|
||||
return { tag: "button", selector: "#save", text: "Save" }
|
||||
},
|
||||
}
|
||||
const broker = new BrowserBroker({
|
||||
log: () => {},
|
||||
launch: async () => ({
|
||||
newContext: async () => ({
|
||||
close: async () => undefined,
|
||||
route: async () => undefined,
|
||||
routeWebSocket: async () => undefined,
|
||||
newPage: async () => page,
|
||||
}),
|
||||
close: async () => undefined,
|
||||
}),
|
||||
})
|
||||
brokers.push(broker)
|
||||
const current = await broker.open(
|
||||
{ projectId: "project", sessionId: "session", directory: "/fixture" },
|
||||
"http://localhost:3000/",
|
||||
)
|
||||
const context = { id: "project", root: "/fixture", peekState: () => undefined, sessions: () => [{ id: "session" }] }
|
||||
const contexts = {
|
||||
resolve: (id: string) => (id === "project" ? context : undefined),
|
||||
active: () => context,
|
||||
} as unknown as ProjectContexts
|
||||
const host = { isTrusted: () => state.trusted, browserAutomation: () => state.enabled } as unknown as Host
|
||||
const send = (message: AgentManagerInMessage) =>
|
||||
new Promise<AgentManagerOutMessage>((resolve) => {
|
||||
expect(handleBrowserMessage(message, { host, contexts, browser: broker, post: resolve, log: () => {} })).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
return { send, state, broker, current }
|
||||
}
|
||||
|
||||
const request = (id: string): AgentManagerInMessage => ({
|
||||
type: "agentManager.browser.inspect",
|
||||
projectId: "project",
|
||||
sessionId: "session",
|
||||
requestId: id,
|
||||
hover: true,
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
width: 400,
|
||||
height: 300,
|
||||
})
|
||||
|
||||
describe("browser inspection responses", () => {
|
||||
test("returns correlated failures and continues processing later hover requests", async () => {
|
||||
const test = await fixture()
|
||||
expect(await test.send(request("first"))).toEqual({
|
||||
type: "agentManager.browserInspection",
|
||||
projectId: "project",
|
||||
sessionId: "session",
|
||||
requestId: "first",
|
||||
hover: true,
|
||||
logs: [],
|
||||
error: "Execution context was destroyed",
|
||||
})
|
||||
expect(await test.send(request("second"))).toMatchObject({
|
||||
type: "agentManager.browserInspection",
|
||||
requestId: "second",
|
||||
hover: true,
|
||||
element: { selector: "#save" },
|
||||
})
|
||||
})
|
||||
|
||||
test("correlates validation and permission failures instead of leaving the picker waiting", async () => {
|
||||
const test = await fixture()
|
||||
expect(
|
||||
await test.send({
|
||||
type: "agentManager.browser.inspect",
|
||||
projectId: "project",
|
||||
sessionId: "session",
|
||||
requestId: "invalid",
|
||||
hover: true,
|
||||
}),
|
||||
).toMatchObject({ type: "agentManager.browserInspection", requestId: "invalid", error: expect.any(String) })
|
||||
test.state.trusted = false
|
||||
expect(await test.send(request("untrusted"))).toMatchObject({
|
||||
type: "agentManager.browserInspection",
|
||||
requestId: "untrusted",
|
||||
error: "Browser preview requires a trusted workspace.",
|
||||
})
|
||||
test.state.trusted = true
|
||||
test.state.enabled = false
|
||||
expect(await test.send(request("disabled"))).toMatchObject({
|
||||
type: "agentManager.browserInspection",
|
||||
requestId: "disabled",
|
||||
error: expect.any(String),
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves the active project's untitled preview when DevTools fail", async () => {
|
||||
const test = await fixture()
|
||||
await test.broker.open({ projectId: "other", sessionId: "session", directory: "/other" }, "http://localhost:3000/")
|
||||
expect(await test.send({ type: "agentManager.browser.devtools", sessionId: "session" })).toEqual({
|
||||
...browserMessage(test.current),
|
||||
error: "Browser developer tools are unavailable for this browser session",
|
||||
})
|
||||
expect(test.broker.get("session", "project")?.error).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves an untitled preview when native picker input fails", async () => {
|
||||
const test = await fixture()
|
||||
test.broker.input = async () => {
|
||||
throw new Error("Pointer target is unavailable")
|
||||
}
|
||||
expect(
|
||||
await test.send({
|
||||
type: "agentManager.browser.input",
|
||||
projectId: "project",
|
||||
sessionId: "session",
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
width: 400,
|
||||
height: 300,
|
||||
click: true,
|
||||
}),
|
||||
).toEqual({ ...browserMessage(test.current), error: "Pointer target is unavailable" })
|
||||
expect(await test.send({ type: "agentManager.browser.input", projectId: "project", sessionId: "session" })).toEqual(
|
||||
{ ...browserMessage(test.current), error: "Browser element coordinates are required." },
|
||||
)
|
||||
expect(test.broker.get("session", "project")?.error).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { unlinkSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { build } from "esbuild"
|
||||
import { solidPlugin } from "esbuild-plugin-solid"
|
||||
|
||||
const root = path.resolve(import.meta.dir, "../..")
|
||||
const webview = path.join(root, "webview-ui")
|
||||
|
||||
describe("reusable browser view", () => {
|
||||
test("renders and emits commands without Agent Manager or VS Code providers", async () => {
|
||||
const solid = path.dirname(Bun.resolveSync("solid-js/package.json", webview))
|
||||
const aliases: Record<string, string> = {
|
||||
"solid-js": path.join(solid, "dist/solid.js"),
|
||||
"solid-js/web": path.join(solid, "web/dist/web.js"),
|
||||
"solid-js/store": path.join(solid, "store/dist/store.js"),
|
||||
}
|
||||
const result = await build({
|
||||
entryPoints: [path.join(root, "tests/fixtures/browser-panel-render.tsx")],
|
||||
bundle: true,
|
||||
conditions: ["browser"],
|
||||
external: ["happy-dom"],
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
logLevel: "silent",
|
||||
loader: { ".css": "empty" },
|
||||
plugins: [
|
||||
{
|
||||
name: "solid-dedupe",
|
||||
setup(ctx) {
|
||||
ctx.onResolve({ filter: /^solid-js(\/web|\/store)?$/ }, (args) => ({ path: aliases[args.path] }))
|
||||
},
|
||||
},
|
||||
solidPlugin(),
|
||||
],
|
||||
target: "es2022",
|
||||
write: false,
|
||||
})
|
||||
const file = path.join(root, `.browser-panel-render-${crypto.randomUUID()}.mjs`)
|
||||
await Bun.write(file, result.outputFiles[0]!.contents)
|
||||
try {
|
||||
const child = Bun.spawnSync([process.execPath, file], { cwd: webview, stdout: "pipe", stderr: "pipe" })
|
||||
expect(child.exitCode, child.stdout.toString() + child.stderr.toString()).toBe(0)
|
||||
} finally {
|
||||
unlinkSync(file)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { options } from "../../src/services/browser-automation/browser-runtime"
|
||||
|
||||
describe("browser runtime isolation", () => {
|
||||
test("removes proxy settings and secrets while preserving normal environment values", () => {
|
||||
const env = Object.freeze({
|
||||
PATH: "/usr/bin",
|
||||
HOME: "/home/test",
|
||||
LANG: "en_US.UTF-8",
|
||||
HTTP_PROXY: "http://proxy.invalid:8080",
|
||||
HTTPS_PROXY: "http://proxy.invalid:8080",
|
||||
ALL_PROXY: "socks5://proxy.invalid:1080",
|
||||
NO_PROXY: "",
|
||||
http_proxy: "http://proxy.invalid:8080",
|
||||
Https_Proxy: "http://proxy.invalid:8080",
|
||||
npm_config_proxy: "http://proxy.invalid:8080",
|
||||
GLOBAL_AGENT_HTTP_PROXY: "http://proxy.invalid:8080",
|
||||
API_KEY: "test-key",
|
||||
KILO_BROWSER_BROKER_TOKEN: "test-token",
|
||||
DATABASE_PASSWORD: "test-password",
|
||||
})
|
||||
const config = options(true, 9222, env)
|
||||
expect(config.env).toEqual({ PATH: "/usr/bin", HOME: "/home/test", LANG: "en_US.UTF-8" })
|
||||
expect(env.HTTP_PROXY).toBe("http://proxy.invalid:8080")
|
||||
expect(config.chromiumSandbox).toBe(true)
|
||||
expect(config.headless).toBe(true)
|
||||
expect(config.channel).toBe("chrome")
|
||||
})
|
||||
|
||||
test("forces direct Chromium networking without disabling localhost bypass", () => {
|
||||
const config = options(true, 9222, {})
|
||||
expect(config.args).toEqual([
|
||||
"--no-proxy-server",
|
||||
"--remote-debugging-address=127.0.0.1",
|
||||
"--remote-debugging-port=9222",
|
||||
])
|
||||
expect(config.args?.some((arg) => arg.includes("<-loopback>"))).toBe(false)
|
||||
})
|
||||
|
||||
test("uses the same proxy isolation for installed Playwright Chromium", () => {
|
||||
const config = options(false, undefined, { http_proxy: "http://proxy.invalid", PATH: "/bin" })
|
||||
expect(config.channel).toBeUndefined()
|
||||
expect(config.env).toEqual({ PATH: "/bin" })
|
||||
expect(config.args).toEqual(["--no-proxy-server"])
|
||||
})
|
||||
})
|
||||
@@ -11,28 +11,32 @@ import {
|
||||
isPendingSend,
|
||||
promotePendingDraftDiscard,
|
||||
reviewDrafts,
|
||||
browserDrafts,
|
||||
savePromptDraft,
|
||||
scrollDrafts,
|
||||
finishPendingSend,
|
||||
} from "../../webview-ui/src/utils/draft-store"
|
||||
|
||||
const stores = [drafts, reviewDrafts, imageDrafts, scrollDrafts]
|
||||
const stores = [drafts, browserDrafts, reviewDrafts, imageDrafts, scrollDrafts]
|
||||
|
||||
beforeEach(() => stores.forEach((store) => store.clear()))
|
||||
|
||||
describe("prompt draft storage", () => {
|
||||
it("stores and clears all prompt artifacts together", () => {
|
||||
const browser = [{ id: "browser", sessionId: "s1", selector: "#save", content: "legacy" }]
|
||||
savePromptDraft(
|
||||
"prompt:default:pending:sidebar-pending:1",
|
||||
"draft",
|
||||
[{ id: "review", file: "a.ts", side: "additions", line: 1, comment: "comment", selectedText: "line" }],
|
||||
[{ id: "image", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,a" }],
|
||||
42,
|
||||
browser,
|
||||
)
|
||||
|
||||
expect(drafts.size).toBe(1)
|
||||
expect(reviewDrafts.size).toBe(1)
|
||||
expect(imageDrafts.size).toBe(1)
|
||||
expect(browserDrafts.get("prompt:default:pending:sidebar-pending:1")).toEqual(browser)
|
||||
expect(scrollDrafts.size).toBe(1)
|
||||
|
||||
discardPendingDraft("sidebar-pending:1")
|
||||
|
||||
@@ -110,7 +110,12 @@ function createConnection() {
|
||||
describe("KiloProvider indexing refresh", () => {
|
||||
it("shares snapshot payloads across load, SSE refresh, and post-save refresh", async () => {
|
||||
const conn = createConnection()
|
||||
const settings = () => ({ maxCost: 0, languageCommitMessage: "sync", multiProject: false })
|
||||
const settings = () => ({
|
||||
maxCost: 0,
|
||||
languageCommitMessage: "sync",
|
||||
multiProject: false,
|
||||
browserAutomation: false,
|
||||
})
|
||||
const snapshot = await fetchSnapshot(conn.client as never, "/repo", settings)
|
||||
const provider = new KiloProvider({} as never, conn.service as never)
|
||||
const internal = provider as unknown as Internals
|
||||
|
||||
@@ -9,6 +9,7 @@ const state = (projectId: string): AgentManagerStateMessage => ({
|
||||
sessions: [],
|
||||
sections: [],
|
||||
isGitRepo: true,
|
||||
browserAutomation: true,
|
||||
})
|
||||
|
||||
describe("createProjectStateHandlers", () => {
|
||||
@@ -30,6 +31,10 @@ describe("createProjectStateHandlers", () => {
|
||||
setPending: () => {},
|
||||
rename: () => {},
|
||||
font: () => {},
|
||||
browser: () => {},
|
||||
current: () => "session-a",
|
||||
closeBrowser: () => {},
|
||||
openBrowser: () => {},
|
||||
})
|
||||
const value = state("project-a")
|
||||
|
||||
@@ -38,5 +43,56 @@ describe("createProjectStateHandlers", () => {
|
||||
expect(stored["project-a"]).toBe(value)
|
||||
expect(applied).toEqual([value])
|
||||
expect(routed).toEqual([value])
|
||||
expect(value.browserAutomation).toBe(true)
|
||||
})
|
||||
|
||||
it("opens browser previews only for the active project and selected session", () => {
|
||||
let opened = 0
|
||||
let closed = 0
|
||||
let enabled = false
|
||||
const handler = createProjectStateHandlers({
|
||||
setMulti: () => {},
|
||||
setProjects: () => {},
|
||||
setStates: () => {},
|
||||
prune: () => {},
|
||||
ensure: () => ({ sections: () => [], applyState: () => {} }),
|
||||
active: () => ({ sections: () => [], applyState: () => {} }),
|
||||
routeCatalog: () => {},
|
||||
routeState: () => {},
|
||||
isActive: (project) => project === "project-a",
|
||||
pending: () => false,
|
||||
setPending: () => {},
|
||||
rename: () => {},
|
||||
font: () => {},
|
||||
browser: (value) => {
|
||||
enabled = value
|
||||
},
|
||||
current: () => "session-a",
|
||||
closeBrowser: () => {
|
||||
closed++
|
||||
},
|
||||
openBrowser: () => {
|
||||
opened++
|
||||
},
|
||||
})
|
||||
const browser = {
|
||||
type: "agentManager.browserState" as const,
|
||||
browserId: "browser-a",
|
||||
projectId: "project-a",
|
||||
sessionId: "session-a",
|
||||
status: "ready" as const,
|
||||
errors: 0,
|
||||
}
|
||||
handler.browser({ ...browser, projectId: "project-b" })
|
||||
handler.browser({ ...browser, sessionId: "session-b" })
|
||||
handler.browser({ ...browser, status: "closed" })
|
||||
expect(opened).toBe(0)
|
||||
handler.browser(browser)
|
||||
expect(opened).toBe(0)
|
||||
handler.browser({ ...browser, status: "loading" })
|
||||
expect(opened).toBe(1)
|
||||
handler.state({ ...state("project-a"), browserAutomation: false })
|
||||
expect(enabled).toBe(false)
|
||||
expect(closed).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { beforeEach, describe, it, expect } from "bun:test"
|
||||
import { createEffect, createRoot, createSignal, on } from "solid-js"
|
||||
import { browserFeedbackData, formatBrowserFeedback } from "../../src/shared/browser-feedback"
|
||||
import { formatReviewCommentsMarkdown } from "../../src/shared/review-comments"
|
||||
import {
|
||||
browserDrafts,
|
||||
deleteDraftsForSession,
|
||||
drafts,
|
||||
imageDrafts,
|
||||
@@ -9,6 +12,7 @@ import {
|
||||
} from "../../webview-ui/src/utils/draft-store"
|
||||
import {
|
||||
createdDraftKey,
|
||||
failedPrompt,
|
||||
movePromptDraft,
|
||||
pendingDraftKey,
|
||||
scopeDraftKey,
|
||||
@@ -17,16 +21,65 @@ import {
|
||||
|
||||
beforeEach(() => {
|
||||
drafts.clear()
|
||||
browserDrafts.clear()
|
||||
reviewDrafts.clear()
|
||||
imageDrafts.clear()
|
||||
mentionDrafts.clear()
|
||||
})
|
||||
|
||||
describe("failedPrompt", () => {
|
||||
it("restores browser feedback from the failed-send wire shape", () => {
|
||||
const browser = browserFeedbackData([{ id: "button", sessionId: "session", selector: "#save", text: "Save" }])!
|
||||
const text = `${formatBrowserFeedback(browser.references)}\n\nMake this button red`
|
||||
expect(failedPrompt({ text, browserFeedback: browser })).toEqual({
|
||||
text: "Make this button red",
|
||||
comments: [],
|
||||
browsers: browser.references,
|
||||
})
|
||||
})
|
||||
|
||||
it("restores both review comments and browser references without raw context text", () => {
|
||||
const review = {
|
||||
version: 1 as const,
|
||||
comments: [
|
||||
{
|
||||
id: "review",
|
||||
file: "src/app.ts",
|
||||
side: "additions" as const,
|
||||
line: 3,
|
||||
comment: "Keep this",
|
||||
selectedText: "value",
|
||||
},
|
||||
],
|
||||
}
|
||||
const browser = browserFeedbackData([{ id: "button", sessionId: "session", selector: "#save" }])!
|
||||
const text = `${formatReviewCommentsMarkdown(review.comments)}\n\n${formatBrowserFeedback(browser.references)}\n\nApply both`
|
||||
expect(failedPrompt({ text, review, browserFeedback: browser })).toEqual({
|
||||
text: "Apply both",
|
||||
comments: review.comments,
|
||||
browsers: browser.references,
|
||||
})
|
||||
})
|
||||
|
||||
it("preserves empty instructions and rejects mismatched feedback metadata", () => {
|
||||
const browser = browserFeedbackData([{ id: "button", sessionId: "session", selector: "#save" }])!
|
||||
expect(failedPrompt({ text: formatBrowserFeedback(browser.references), browserFeedback: browser })?.text).toBe("")
|
||||
expect(failedPrompt({ text: "Unrelated text", browserFeedback: browser })).toBeUndefined()
|
||||
expect(failedPrompt({ text: "Plain draft" })).toEqual({ text: "Plain draft", comments: [], browsers: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe("deleteDraftsForSession", () => {
|
||||
it("clears deleted-session drafts without touching other sessions", () => {
|
||||
drafts.set("prompt:default:session:a", "draft a")
|
||||
drafts.set("prompt:default:pending:a", "pending a")
|
||||
drafts.set("prompt:default:session:b", "draft b")
|
||||
browserDrafts.set("prompt:default:session:a", [
|
||||
{ id: "element", sessionId: "a", selector: "#feature-card", content: "Browser feedback" },
|
||||
])
|
||||
browserDrafts.set("prompt:default:session:b", [
|
||||
{ id: "other", sessionId: "b", selector: "#other", content: "Other browser feedback" },
|
||||
])
|
||||
reviewDrafts.set("prompt:default:session:a", [])
|
||||
imageDrafts.set("prompt:default:session:a", [])
|
||||
mentionDrafts.set("prompt:default:session:a", { paths: ["file with spaces.ts"], sessions: [] })
|
||||
@@ -36,6 +89,8 @@ describe("deleteDraftsForSession", () => {
|
||||
expect(drafts.has("prompt:default:session:a")).toBe(false)
|
||||
expect(drafts.has("prompt:default:pending:a")).toBe(false)
|
||||
expect(drafts.get("prompt:default:session:b")).toBe("draft b")
|
||||
expect(browserDrafts.has("prompt:default:session:a")).toBe(false)
|
||||
expect(browserDrafts.get("prompt:default:session:b")?.[0]?.selector).toBe("#other")
|
||||
expect(reviewDrafts.has("prompt:default:session:a")).toBe(false)
|
||||
expect(imageDrafts.has("prompt:default:session:a")).toBe(false)
|
||||
expect(mentionDrafts.has("prompt:default:session:a")).toBe(false)
|
||||
@@ -168,20 +223,25 @@ describe("movePromptDraft", () => {
|
||||
const comments = new Map([[source, [comment]]])
|
||||
const images = new Map([[source, [image]]])
|
||||
const scrolls = new Map([[source, 128]])
|
||||
const browser = new Map([[source, [{ id: "browser-1", sessionId: "session-1", selector: "#save" }]]])
|
||||
const expected = browser.get(source)
|
||||
|
||||
expect(movePromptDraft({ text, comments, images, scrolls }, source, target)).toEqual({
|
||||
expect(movePromptDraft({ text, comments, images, scrolls, browsers: browser }, source, target)).toEqual({
|
||||
text: "Keep this prompt",
|
||||
comments: [comment],
|
||||
images: [image],
|
||||
scroll: 128,
|
||||
browsers: expected,
|
||||
})
|
||||
expect(text.get(target)).toBe("Keep this prompt")
|
||||
expect(comments.get(target)).toEqual([comment])
|
||||
expect(images.get(target)).toEqual([image])
|
||||
expect(browser.get(target)).toEqual([{ id: "browser-1", sessionId: "session-1", selector: "#save" }])
|
||||
expect(scrolls.get(target)).toBe(128)
|
||||
expect(text.has(source)).toBe(false)
|
||||
expect(comments.has(source)).toBe(false)
|
||||
expect(images.has(source)).toBe(false)
|
||||
expect(browser.has(source)).toBe(false)
|
||||
expect(scrolls.has(source)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("PromptInput connection guard", () => {
|
||||
const attachments = src.indexOf("const gitFile = await git.resolveAttachment")
|
||||
const guard = src.indexOf("if (isDisabled()) {", attachments)
|
||||
const finish = src.indexOf("finishPending(pendingId)", guard)
|
||||
const send = src.indexOf("session.sendMessage(message", guard)
|
||||
const send = src.indexOf("session.sendMessage(", guard)
|
||||
const clear = src.indexOf("drafts.delete(key)", send)
|
||||
|
||||
expect(attachments).toBeGreaterThan(-1)
|
||||
@@ -53,8 +53,8 @@ describe("PromptInput sandbox toggle", () => {
|
||||
})
|
||||
|
||||
it("captures edits made while sandbox session creation is pending", () => {
|
||||
const start = src.indexOf('if (message.type === "sessionCreated")')
|
||||
const end = src.indexOf('if (message.type === "action"', start)
|
||||
const start = src.indexOf("const created = (message:")
|
||||
const end = src.indexOf("const unsubscribe", start)
|
||||
const created = src.slice(start, end)
|
||||
const save = created.indexOf(
|
||||
"if (source === draftKey()) saveDraft(source, text(), reviewComments(), imageAttach.images())",
|
||||
@@ -65,7 +65,10 @@ describe("PromptInput sandbox toggle", () => {
|
||||
expect(end).toBeGreaterThan(start)
|
||||
expect(save).toBeGreaterThan(-1)
|
||||
expect(move).toBeGreaterThan(save)
|
||||
expect(created).toContain("{ text: drafts, comments: reviewDrafts, images: imageDrafts, scrolls: scrollDrafts }")
|
||||
expect(created).toContain(
|
||||
"{ text: drafts, comments: reviewDrafts, images: imageDrafts, scrolls: scrollDrafts, browsers: references }",
|
||||
)
|
||||
expect(created).toContain("saveDraft(source, text(), reviewComments(), imageAttach.images())")
|
||||
})
|
||||
|
||||
it("restores each prompt draft's textarea and highlight scroll positions", () => {
|
||||
@@ -74,8 +77,14 @@ describe("PromptInput sandbox toggle", () => {
|
||||
expect(src).toContain("textareaRef.scrollTop = scroll")
|
||||
expect(src).toContain("if (highlightRef) highlightRef.scrollTop = scroll")
|
||||
expect(src).toContain("scrollDrafts.set(draftKey(), textareaRef.scrollTop)")
|
||||
expect(src).toContain("images: imageAttach.images(),\n scroll: textareaRef?.scrollTop")
|
||||
expect(src).toContain("draft.text, draft.comments, draft.images, draft.scroll")
|
||||
expect(src).toContain(
|
||||
"images: imageAttach.images(),\n browsers: browsers(),\n scroll: textareaRef?.scrollTop",
|
||||
)
|
||||
expect(src).toContain("draft.text,")
|
||||
expect(src).toContain("draft.comments,")
|
||||
expect(src).toContain("draft.images,")
|
||||
expect(src).toContain("draft.scroll,")
|
||||
expect(src).toContain("draft.browsers")
|
||||
})
|
||||
|
||||
it("tracks in-flight toggles per session while switching", () => {
|
||||
|
||||
@@ -402,8 +402,9 @@ describe("PromptInput send origin contract", () => {
|
||||
})
|
||||
|
||||
it("passes the captured origin to message and command sends", () => {
|
||||
expect(source).toMatch(/session\.sendMessage\([\s\S]*origin \?\? null\)/)
|
||||
expect(source).toMatch(/session\.sendCommand\([\s\S]*origin \?\? null\)/)
|
||||
expect(source).toMatch(/session\.sendMessage\([\s\S]*origin \?\? null[\s\S]*browserData[\s\S]*\)/)
|
||||
const command = source.slice(source.indexOf("session.sendCommand("))
|
||||
expect(command).toMatch(/origin \?\? null[\s\S]*\{[\s\S]*agent: matched\.agent/)
|
||||
})
|
||||
|
||||
it("records sent prompts before a pending session key change can return", () => {
|
||||
@@ -671,6 +672,47 @@ describe("Optimistic parts preservation and smooth status contract", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("browser element reference contract", () => {
|
||||
const source = readFile(PROMPT_FILE)
|
||||
|
||||
it("keeps selected browser elements as visible attachments instead of inserting them into the draft", () => {
|
||||
expect(source).toContain('data-component="browser-references"')
|
||||
expect(source).toMatch(/const reference = message\.browser[\s\S]*?textareaRef\?\.focus\(\)[\s\S]*?return/)
|
||||
})
|
||||
|
||||
it("includes browser reference content only when the user sends the prompt", () => {
|
||||
expect(source).toContain("browserFeedbackData(browsers())")
|
||||
expect(source).toContain("formatBrowserFeedback(browserData.references)")
|
||||
expect(source).toContain('const message = [review, browserText, draft].filter(Boolean).join("\\n\\n")')
|
||||
expect(source).toContain("references.delete(key)")
|
||||
})
|
||||
|
||||
it("uses the tested failed-send parser before restoring text and references", () => {
|
||||
expect(source).toContain("const restored = failedPrompt(failed)")
|
||||
expect(source).toContain("const draft = restored.text")
|
||||
expect(source).toContain("const browser = restored.browsers")
|
||||
expect(source).not.toContain("partFeedback({ review: failed.review")
|
||||
})
|
||||
|
||||
it("restores browser attachments for the correct session and allows attachment-only sends", () => {
|
||||
expect(source).toContain("setBrowsers(references.get(key) ?? [])")
|
||||
expect(source).toContain("if (reference.sessionId !== sid()) return")
|
||||
expect(source).toContain("mergeBrowserReferences(browsers(), reference)")
|
||||
expect(source).toContain("browsers().length > 0")
|
||||
})
|
||||
})
|
||||
|
||||
describe("sent browser feedback rendering contract", () => {
|
||||
const message = readFile(path.join(ROOT, "webview-ui/src/components/chat/VscodeUserMessage.tsx"))
|
||||
|
||||
it("renders validated browser metadata as cards and exposes only the instruction body", () => {
|
||||
expect(message).toContain("partFeedback")
|
||||
expect(message).toContain("BrowserReferences")
|
||||
expect(message).toContain("feedback()?.body")
|
||||
expect(message).not.toContain("item.content")
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloConnectionService pruneSession contract", () => {
|
||||
const source = readFile(CONNECTION_SERVICE_FILE)
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { handleSessionLifecycle } from "../../src/agent-manager/session-lifecycle"
|
||||
import type { ProjectContexts } from "../../src/agent-manager/project/contexts"
|
||||
import type { AgentManagerOutMessage } from "../../src/agent-manager/types"
|
||||
import type { Session } from "@kilocode/sdk/v2/client"
|
||||
|
||||
const info: Session = {
|
||||
id: "session",
|
||||
slug: "test-session",
|
||||
projectID: "project",
|
||||
directory: "/repo",
|
||||
title: "Browser session",
|
||||
version: "1",
|
||||
time: { created: 1, updated: 2 },
|
||||
}
|
||||
|
||||
describe("session lifecycle merge integration", () => {
|
||||
test.each(["sessionID", "info"])("preserves deletion guards and browser cleanup for %s events", (shape) => {
|
||||
const sessions: Array<{ id: string }> = []
|
||||
const closed: string[] = []
|
||||
const posted: AgentManagerOutMessage[] = []
|
||||
const context = {
|
||||
id: "project",
|
||||
lifecycle: "ready",
|
||||
peekState: () => undefined,
|
||||
sessions: () => sessions,
|
||||
upsertSession: (session: { id: string }) => sessions.push(session),
|
||||
removeLiveSession: (id: string) =>
|
||||
sessions.splice(
|
||||
sessions.findIndex((session) => session.id === id),
|
||||
1,
|
||||
),
|
||||
invalidateSessions: () => {},
|
||||
}
|
||||
const deps = {
|
||||
busy: new Set([info.id]),
|
||||
removed: new Set<string>(),
|
||||
contexts: {
|
||||
byDirectory: () => context,
|
||||
byLiveSession: () => context,
|
||||
} as unknown as ProjectContexts,
|
||||
closeBrowser: (id: string) => closed.push(id),
|
||||
post: (message: AgentManagerOutMessage) => posted.push(message),
|
||||
}
|
||||
handleSessionLifecycle({ type: "session.created", properties: { info } }, deps)
|
||||
expect(sessions).toHaveLength(1)
|
||||
handleSessionLifecycle(
|
||||
{ type: "session.deleted", properties: shape === "info" ? { info } : { sessionID: info.id } },
|
||||
deps,
|
||||
)
|
||||
expect(closed).toEqual([info.id])
|
||||
expect(deps.removed.has(info.id)).toBe(true)
|
||||
expect(deps.busy.has(info.id)).toBe(false)
|
||||
expect(sessions).toHaveLength(0)
|
||||
const count = posted.length
|
||||
handleSessionLifecycle({ type: "session.updated", properties: { info } }, deps)
|
||||
expect(sessions).toHaveLength(0)
|
||||
expect(posted).toHaveLength(count)
|
||||
handleSessionLifecycle({ type: "session.created", properties: { info } }, deps)
|
||||
expect(deps.removed.has(info.id)).toBe(false)
|
||||
expect(sessions).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -23,9 +23,11 @@ import {
|
||||
removeSessionToolPartsForMessage,
|
||||
upsertSessionToolPart,
|
||||
recentSessions,
|
||||
optimistic,
|
||||
revertPromptState,
|
||||
} from "../../webview-ui/src/context/session-utils"
|
||||
import type { Message, Part, ToolPart } from "../../webview-ui/src/types/messages"
|
||||
import { formatBrowserFeedback } from "../../src/shared/browser-feedback"
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
@@ -985,6 +987,24 @@ describe("sessionThroughput", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("optimistic parts", () => {
|
||||
it("preserves attachments and message ownership with unique part IDs", () => {
|
||||
const file = {
|
||||
mime: "image/png",
|
||||
url: "data:image/png;base64,abc",
|
||||
filename: "shot.png",
|
||||
source: { type: "file" as const, path: "shot.png", text: { value: "@shot.png", start: 0, end: 9 } },
|
||||
}
|
||||
const parts = optimistic("message", "Hello", [file])
|
||||
expect(parts).toMatchObject([
|
||||
{ type: "text", id: expect.any(String), messageID: "message", text: "Hello" },
|
||||
{ type: "file", id: expect.any(String), messageID: "message", ...file },
|
||||
])
|
||||
expect(parts.at(0)?.id).not.toBe(parts.at(1)?.id)
|
||||
expect(optimistic("empty", "")).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("revertPromptState", () => {
|
||||
const text = (value: string, synthetic = false) =>
|
||||
({ type: "text", id: `t-${value}`, text: value, synthetic }) as Part
|
||||
@@ -1030,6 +1050,19 @@ describe("revertPromptState", () => {
|
||||
it("returns empty collections for tool-only messages", () => {
|
||||
const part: Part = { type: "tool", id: "p1", tool: "bash", state: { status: "running", input: {} } }
|
||||
const state = revertPromptState([part])
|
||||
expect(state).toEqual({ text: "", paths: [], sessions: [], images: [] })
|
||||
expect(state).toEqual({ text: "", paths: [], sessions: [], images: [], review: [], browser: [] })
|
||||
})
|
||||
|
||||
it("restores browser and review metadata without their formatted prefixes", () => {
|
||||
const browser = {
|
||||
version: 1 as const,
|
||||
references: [{ id: "b", sessionId: "s1", selector: "#save", text: "Save" }],
|
||||
}
|
||||
const content = `${formatBrowserFeedback(browser.references)}\n\nPlease update it`
|
||||
const parts = optimistic("message", content, undefined, undefined, browser)
|
||||
expect(revertPromptState(parts)).toMatchObject({
|
||||
text: "Please update it",
|
||||
browser: browser.references,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -78,6 +78,17 @@ describe("buildCspString", () => {
|
||||
expect(result).toMatch(new RegExp(`connect-src\\s+${cspSource.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`))
|
||||
})
|
||||
|
||||
it("does not allow frames for ordinary webviews", () => {
|
||||
expect(buildCspString(cspSource, nonce)).not.toContain("frame-src")
|
||||
})
|
||||
|
||||
it("limits Agent Manager browser frames to approved loopback origins", () => {
|
||||
const result = buildCspString(cspSource, nonce, undefined, "http://localhost:* http://127.0.0.1:*")
|
||||
expect(result).toContain("frame-src http://localhost:* http://127.0.0.1:*")
|
||||
expect(result).not.toContain("frame-src *")
|
||||
expect(result).not.toContain("frame-src https:")
|
||||
})
|
||||
|
||||
it("joins directives with semicolons", () => {
|
||||
const result = buildCspString(cspSource, nonce)
|
||||
const parts = result.split(";")
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Window } from "happy-dom"
|
||||
import { post, protect, trusted } from "../../webview-ui/src/utils/webview-message"
|
||||
|
||||
describe("webview message trust", () => {
|
||||
const origin = "vscode-webview://trusted-view"
|
||||
|
||||
test("accepts browser-generated extension and same-window messages from the exact webview origin", () => {
|
||||
expect(trusted({ origin, isTrusted: true }, origin)).toBe(true)
|
||||
expect(trusted({ origin: "https://webview.example", isTrusted: true }, "https://webview.example")).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects preview, DevTools, forged, and opaque-origin messages", () => {
|
||||
for (const value of ["http://localhost:3000", "http://127.0.0.1:3000", `${origin}.invalid`, "", "null"]) {
|
||||
expect(trusted({ origin: value, isTrusted: true }, origin)).toBe(false)
|
||||
}
|
||||
expect(trusted({ origin, isTrusted: false }, origin)).toBe(false)
|
||||
expect(trusted({ origin: "", isTrusted: false }, origin)).toBe(false)
|
||||
expect(trusted({ origin: "null", isTrusted: true }, "null")).toBe(false)
|
||||
})
|
||||
|
||||
test("blocks all application listeners before dispatch and removes its capture guard on cleanup", async () => {
|
||||
const window = new Window({ url: "https://webview.example" })
|
||||
Object.defineProperty(window, "origin", { value: window.location.origin })
|
||||
const target = window as unknown as globalThis.Window
|
||||
let received = 0
|
||||
window.addEventListener("message", () => received++)
|
||||
const release = protect(target)
|
||||
window.addEventListener("message", () => received++)
|
||||
const event = () =>
|
||||
new window.MessageEvent("message", {
|
||||
origin: "http://localhost:3000",
|
||||
data: { type: "action", action: "runScript" },
|
||||
})
|
||||
window.dispatchEvent(event())
|
||||
expect(received).toBe(0)
|
||||
release()
|
||||
window.dispatchEvent(event())
|
||||
expect(received).toBe(2)
|
||||
await window.happyDOM.close()
|
||||
})
|
||||
|
||||
test("keeps internal messages synchronous and authorizes only their local dispatch", () => {
|
||||
const sent: MessageEvent[] = []
|
||||
const target = {
|
||||
origin,
|
||||
dispatchEvent: (event: Event) => {
|
||||
const message = event as MessageEvent
|
||||
sent.push(message)
|
||||
expect(trusted(message, origin)).toBe(true)
|
||||
return true
|
||||
},
|
||||
}
|
||||
const message = { type: "appendChatBoxMessage", text: "Selected element" }
|
||||
post(message, target)
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0]?.data).toEqual(message)
|
||||
expect(sent[0]?.origin).toBe(origin)
|
||||
expect(trusted(sent[0]!, origin)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -209,6 +209,7 @@ import { tracker } from "./telemetry"
|
||||
import { createChatFocus, createFocusBridge, createPromptFocus, forgetTerminalFocus, hasQuestionOption } from "./focus"
|
||||
import { usePendingCreate } from "./pending-create"
|
||||
import { defaultBase as projectDefaultBase } from "./project/default-base"
|
||||
import { createBrowserPanel } from "./BrowserPanel"
|
||||
import "./agent-manager.css"
|
||||
import "./agent-manager-review.css"
|
||||
import { cycleAgent as cycle } from "../src/context/session-agent"
|
||||
@@ -257,6 +258,7 @@ const AgentManagerContent: Component = () => {
|
||||
const [repoDetectedBranch, setRepoDetectedBranch] = createSignal<string | undefined>()
|
||||
const [projectList, setProjectList] = createSignal<AgentProjectSnapshot[]>([])
|
||||
const [multiProject, setMultiProject] = createSignal(false)
|
||||
|
||||
const [currentProjectId, setCurrentProjectId] = createSignal<string | undefined>()
|
||||
const [projectStates, setProjectStates] = createSignal<Record<string, AgentManagerStateMessage>>({})
|
||||
const activeProjectId = () => projectList().find((p) => p.active)?.id ?? currentProjectId()
|
||||
@@ -351,6 +353,7 @@ const AgentManagerContent: Component = () => {
|
||||
const reviewCommentsByContext = reviewState.comments
|
||||
const setReviewCommentsByContext = reviewState.setComments
|
||||
const [reviewActive, setReviewActive] = createSignal(false)
|
||||
const browser = createBrowserPanel(sidePanel, setSidePanel, setHistory, setReviewActive)
|
||||
const diffStyle = useDiffStyle()!
|
||||
const setSharedDiffStyle = (style: "unified" | "split") => {
|
||||
if (diffStyle.style() === style) return
|
||||
@@ -1088,9 +1091,9 @@ const AgentManagerContent: Component = () => {
|
||||
setPending: (value) => (pendingNewSection = value),
|
||||
rename: setRenamingSection,
|
||||
font: (font) => font && setTerminalFont(font),
|
||||
...browser.bind(session.currentSessionID),
|
||||
})
|
||||
const preserveSidebarScroll = createSidebarScrollPreserver(() => selection() ?? session.currentSessionID())
|
||||
/** Apply the active-transition effects of a state payload (data already landed in the store). */
|
||||
const applyActiveState = (state: AgentManagerStateMessage) => {
|
||||
const switched = applyProjectSwitch(state)
|
||||
if (state.isGitRepo !== undefined) setIsGitRepo(state.isGitRepo)
|
||||
@@ -1462,11 +1465,10 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
|
||||
if (msg.type === "agentManager.focusContextRequested") focusCtl.report()
|
||||
|
||||
if (msg.type === "agentManager.state" && msg.isGitRepo === false && !sessionsLoaded()) setSessionsLoaded(true)
|
||||
if (msg.type === "agentManager.state") preserveSidebarScroll(() => stateHandlers.state(msg))
|
||||
stateHandlers.browser(msg)
|
||||
|
||||
// When a multi-version progress update arrives, mark newly created worktrees as loading
|
||||
if ((msg as { type: string }).type === "agentManager.multiVersionProgress") {
|
||||
const ev = msg as unknown as AgentManagerMultiVersionProgressMessage
|
||||
if (ev.status === "done") creation.abandon(ev.projectId)
|
||||
@@ -1477,8 +1479,6 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// When state updates arrive, mark new grouped worktrees as loading
|
||||
// (they were just created and haven't received their prompt yet)
|
||||
if (msg.type === "agentManager.worktreeSetup") {
|
||||
const ev = msg as AgentManagerWorktreeSetupMessage
|
||||
if (ev.status === "ready" && ev.sessionId) {
|
||||
@@ -2394,6 +2394,7 @@ const AgentManagerContent: Component = () => {
|
||||
diffOpen={diffOpen}
|
||||
reviewActive={reviewActive}
|
||||
onToggleDiff={toggleDiffPanel}
|
||||
{...browser.tabs}
|
||||
onToggleReview={metrics.click("fullscreen_review", "tab_toolbar", toggleReviewTab)}
|
||||
prStatus={() => activePR()?.pr}
|
||||
prOpen={prOpen}
|
||||
@@ -2648,6 +2649,7 @@ const AgentManagerContent: Component = () => {
|
||||
onClose={() => setSidePanel(null)}
|
||||
/>
|
||||
</Show>
|
||||
{browser.render(session.currentSessionID, activeProjectId)}
|
||||
<Show when={subagents.tabs().length > 0}>
|
||||
<SubagentPanel
|
||||
tabs={subagents.tabs}
|
||||
@@ -2742,20 +2744,18 @@ const AgentManagerContent: Component = () => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export const AgentManagerApp: Component = () => {
|
||||
return (
|
||||
<ProviderShell.Root>
|
||||
<ProviderShell.Session>
|
||||
<ProviderShell.Chat>
|
||||
<WorktreeModeProvider>
|
||||
<DiffStyleProvider>
|
||||
<DataBridge>
|
||||
<AgentManagerContent />
|
||||
</DataBridge>
|
||||
</DiffStyleProvider>
|
||||
</WorktreeModeProvider>
|
||||
</ProviderShell.Chat>
|
||||
</ProviderShell.Session>
|
||||
</ProviderShell.Root>
|
||||
)
|
||||
}
|
||||
export const AgentManagerApp: Component = () => (
|
||||
<ProviderShell.Root>
|
||||
<ProviderShell.Session>
|
||||
<ProviderShell.Chat>
|
||||
<WorktreeModeProvider>
|
||||
<DiffStyleProvider>
|
||||
<DataBridge>
|
||||
<AgentManagerContent />
|
||||
</DataBridge>
|
||||
</DiffStyleProvider>
|
||||
</WorktreeModeProvider>
|
||||
</ProviderShell.Chat>
|
||||
</ProviderShell.Session>
|
||||
</ProviderShell.Root>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { createMemo, createSignal, Show, type Accessor, type Setter } from "solid-js"
|
||||
import { useLanguage } from "../src/context/language"
|
||||
import { useVSCode } from "../src/context/vscode"
|
||||
import type { ExtensionMessage, WebviewMessage } from "../src/types/messages"
|
||||
import { formatBrowserFeedback, type BrowserReference } from "../../src/shared/browser-feedback"
|
||||
import { BrowserPanel as BrowserPanelView } from "../browser"
|
||||
import type {
|
||||
BrowserCommand,
|
||||
BrowserEvent,
|
||||
BrowserInspection,
|
||||
BrowserScope,
|
||||
BrowserState,
|
||||
BrowserTransport,
|
||||
} from "../browser"
|
||||
import { SidePanel } from "./side-panel-layout"
|
||||
import { post } from "../src/utils/webview-message"
|
||||
|
||||
export function createBrowserPanel(
|
||||
current: Accessor<SidePanel | null>,
|
||||
panel: Setter<SidePanel | null>,
|
||||
history: Setter<boolean>,
|
||||
review: Setter<boolean>,
|
||||
) {
|
||||
const [enabled, configure] = createSignal(
|
||||
(globalThis as typeof globalThis & { KILO_BROWSER_AUTOMATION?: boolean }).KILO_BROWSER_AUTOMATION === true,
|
||||
)
|
||||
const visible = () => current() === SidePanel.Browser
|
||||
const close = () => panel(null)
|
||||
const open = () => {
|
||||
history(false)
|
||||
review(false)
|
||||
panel(SidePanel.Browser)
|
||||
}
|
||||
const toggle = () => {
|
||||
if (!enabled()) return
|
||||
if (visible()) return close()
|
||||
open()
|
||||
}
|
||||
return {
|
||||
tabs: { browserOpen: visible, browserAutomation: enabled, onToggleBrowser: toggle },
|
||||
bind: (current: Accessor<string | undefined>) => ({
|
||||
browser: configure,
|
||||
current,
|
||||
closeBrowser: close,
|
||||
openBrowser: open,
|
||||
}),
|
||||
render: (session: Accessor<string | undefined>, project: Accessor<string | undefined>) => (
|
||||
<Show when={enabled() && visible()}>
|
||||
<BrowserAdapter sessionId={session} projectId={project} onClose={close} />
|
||||
</Show>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function scope(sessionId: string, projectId?: string): BrowserScope {
|
||||
return { sessionId, projectId }
|
||||
}
|
||||
|
||||
function command(command: BrowserCommand): WebviewMessage {
|
||||
if (command.type === "open") {
|
||||
return { type: "agentManager.browser.open", ...command.scope, url: command.url }
|
||||
}
|
||||
if (command.type === "refresh") return { type: "agentManager.browser.refresh", ...command.scope }
|
||||
if (command.type === "close") return { type: "agentManager.browser.close", ...command.scope }
|
||||
if (command.type === "state") return { type: "agentManager.browser.state", ...command.scope }
|
||||
if (command.type === "devtools") {
|
||||
return { type: "agentManager.browser.devtools", ...command.scope, theme: command.theme }
|
||||
}
|
||||
if (command.type === "input") {
|
||||
return { type: "agentManager.browser.input", ...command.scope, ...command.position, click: command.click }
|
||||
}
|
||||
return {
|
||||
type: "agentManager.browser.inspect",
|
||||
...command.scope,
|
||||
...command.position,
|
||||
hover: command.hover,
|
||||
requestId: command.requestId,
|
||||
}
|
||||
}
|
||||
|
||||
function event(message: ExtensionMessage): BrowserEvent | undefined {
|
||||
if (message.type === "agentManager.browserState") {
|
||||
const value: BrowserState = {
|
||||
scope: scope(message.sessionId, message.projectId),
|
||||
browserId: message.browserId,
|
||||
navigation: message.navigation,
|
||||
status: message.status,
|
||||
inspecting: message.inspecting,
|
||||
url: message.url,
|
||||
title: message.title,
|
||||
errors: message.errors,
|
||||
logs: message.logs,
|
||||
error: message.error,
|
||||
frameError: message.frameError,
|
||||
}
|
||||
return { type: "state", value }
|
||||
}
|
||||
if (message.type === "agentManager.browserInspection") {
|
||||
const value: BrowserInspection = {
|
||||
scope: scope(message.sessionId, message.projectId),
|
||||
requestId: message.requestId,
|
||||
url: message.url,
|
||||
title: message.title,
|
||||
element: message.element,
|
||||
logs: message.logs,
|
||||
hover: message.hover,
|
||||
error: message.error,
|
||||
}
|
||||
return { type: "inspection", value }
|
||||
}
|
||||
if (message.type !== "agentManager.browserDevtools") return
|
||||
return {
|
||||
type: "devtools",
|
||||
value: {
|
||||
scope: scope(message.sessionId, message.projectId),
|
||||
browserId: message.browserId,
|
||||
url: message.url,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function BrowserAdapter(props: {
|
||||
sessionId: Accessor<string | undefined>
|
||||
projectId: Accessor<string | undefined>
|
||||
onClose: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const vscode = useVSCode()
|
||||
const transport: BrowserTransport = {
|
||||
send: (value) => vscode.postMessage(command(value)),
|
||||
subscribe: (listener) =>
|
||||
vscode.onMessage((message) => {
|
||||
const value = event(message)
|
||||
if (value) listener(value)
|
||||
}),
|
||||
}
|
||||
const labels = createMemo(() => ({
|
||||
title: language.t("agentManager.browser.title"),
|
||||
url: language.t("agentManager.browser.url"),
|
||||
urlPlaceholder: language.t("agentManager.browser.urlPlaceholder"),
|
||||
open: language.t("agentManager.browser.open"),
|
||||
refresh: language.t("agentManager.browser.refresh"),
|
||||
close: language.t("agentManager.browser.close"),
|
||||
inspect: language.t("agentManager.browser.inspect"),
|
||||
devtoolsTitle: language.t("agentManager.browser.devtoolsTitle"),
|
||||
diagnostics: language.t("agentManager.browser.diagnostics"),
|
||||
diagnosticsHint: language.t("agentManager.browser.diagnosticsHint"),
|
||||
empty: language.t("agentManager.browser.empty"),
|
||||
noSession: language.t("agentManager.browser.noSession"),
|
||||
screenshotAlt: language.t("agentManager.browser.screenshotAlt"),
|
||||
errors: (count: number) => language.t("agentManager.browser.errors", { count }),
|
||||
}))
|
||||
const reference = (value: BrowserReference) => {
|
||||
post({ type: "appendChatBoxMessage", text: formatBrowserFeedback([value]), browser: value })
|
||||
}
|
||||
const theme = () =>
|
||||
document.body.classList.contains("vscode-light") || document.body.classList.contains("vscode-high-contrast-light")
|
||||
? "light"
|
||||
: "dark"
|
||||
return (
|
||||
<BrowserPanelView
|
||||
scope={() => {
|
||||
const session = props.sessionId()
|
||||
return session ? scope(session, props.projectId()) : undefined
|
||||
}}
|
||||
transport={transport}
|
||||
labels={labels()}
|
||||
theme={theme}
|
||||
onReference={reference}
|
||||
onClose={props.onClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -52,6 +52,9 @@ export interface TabBarProps {
|
||||
onRun: (id: string) => void
|
||||
onConfigureRun: () => void
|
||||
diffOpen: () => boolean
|
||||
browserOpen: () => boolean
|
||||
browserAutomation: () => boolean
|
||||
onToggleBrowser: () => void
|
||||
reviewActive: () => boolean
|
||||
onToggleDiff: () => void
|
||||
onToggleReview: () => void
|
||||
@@ -275,6 +278,18 @@ export const TabBar: Component<TabBarProps> = (props) => (
|
||||
</Show>
|
||||
</button>
|
||||
</TooltipKeybind>
|
||||
<Show when={props.browserAutomation()}>
|
||||
<Tooltip value={props.t("agentManager.browser.title")} placement="bottom">
|
||||
<IconButton
|
||||
icon="globe"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={props.t("agentManager.browser.title")}
|
||||
class={props.browserOpen() ? "am-tab-diff-btn-active" : ""}
|
||||
onClick={props.onToggleBrowser}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -247,6 +247,21 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "Ingen branches fundet.",
|
||||
"agentManager.import.noBranchesHint": "Indsæt en PR URL ovenfor eller opret et nyt Worktree.",
|
||||
"agentManager.import.failed": "Import mislykkedes",
|
||||
"agentManager.browser.title": "Browser",
|
||||
"agentManager.browser.url": "URL til lokal applikation",
|
||||
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
|
||||
"agentManager.browser.open": "Åbn",
|
||||
"agentManager.browser.inspect": "Vælg element",
|
||||
"agentManager.browser.devtoolsTitle": "Udviklerværktøjer",
|
||||
"agentManager.browser.refresh": "Opdater browser",
|
||||
"agentManager.browser.close": "Luk browser",
|
||||
"agentManager.browser.empty": "Åbn en lokal applikation for at få vist en forhåndsvisning her.",
|
||||
"agentManager.browser.noSession": "Vælg først en session i Agent Manager.",
|
||||
"agentManager.browser.screenshotAlt": "Aktuel browserside",
|
||||
"agentManager.browser.errors": "Browserproblemer: {{count}}",
|
||||
"agentManager.browser.diagnostics": "Browserdiagnostik",
|
||||
"agentManager.browser.diagnosticsHint":
|
||||
"Seneste hændelser fra automatiseringsbrowseren. Sikkerhedsblokeringer er ikke konsolfejl.",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "GitHub-godkendelse påkrævet",
|
||||
"agentManager.pr.error.gh_auth.description": "Kør 'gh auth login' i din terminal for at gendanne PR-status.",
|
||||
|
||||
@@ -249,6 +249,21 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "Keine Branches gefunden.",
|
||||
"agentManager.import.noBranchesHint": "Fügen Sie oben eine PR-URL ein oder erstellen Sie einen neuen Worktree.",
|
||||
"agentManager.import.failed": "Import fehlgeschlagen",
|
||||
"agentManager.browser.title": "Browser",
|
||||
"agentManager.browser.url": "URL der lokalen Anwendung",
|
||||
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
|
||||
"agentManager.browser.open": "Öffnen",
|
||||
"agentManager.browser.inspect": "Element auswählen",
|
||||
"agentManager.browser.devtoolsTitle": "Entwicklertools",
|
||||
"agentManager.browser.refresh": "Browser aktualisieren",
|
||||
"agentManager.browser.close": "Browser schließen",
|
||||
"agentManager.browser.empty": "Öffnen Sie eine lokale Anwendung, um sie hier in der Vorschau anzuzeigen.",
|
||||
"agentManager.browser.noSession": "Wählen Sie zuerst eine Sitzung im Agent Manager aus.",
|
||||
"agentManager.browser.screenshotAlt": "Aktuelle Browserseite",
|
||||
"agentManager.browser.errors": "Browserprobleme: {{count}}",
|
||||
"agentManager.browser.diagnostics": "Browserdiagnose",
|
||||
"agentManager.browser.diagnosticsHint":
|
||||
"Aktuelle Ereignisse aus dem Automatisierungsbrowser. Sicherheitsblockierungen sind keine Konsolenfehler.",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "GitHub-Authentifizierung erforderlich",
|
||||
"agentManager.pr.error.gh_auth.description":
|
||||
|
||||
@@ -236,6 +236,21 @@ export const dict = {
|
||||
"agentManager.documents.preview": "Preview document",
|
||||
"agentManager.documents.source": "Show source",
|
||||
"agentManager.documents.comments": "{{count}} comments",
|
||||
"agentManager.browser.title": "Browser",
|
||||
"agentManager.browser.url": "Local application URL",
|
||||
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
|
||||
"agentManager.browser.open": "Open",
|
||||
"agentManager.browser.inspect": "Select element",
|
||||
"agentManager.browser.devtoolsTitle": "Developer tools",
|
||||
"agentManager.browser.refresh": "Refresh browser",
|
||||
"agentManager.browser.close": "Close browser",
|
||||
"agentManager.browser.empty": "Open a local application to preview it here.",
|
||||
"agentManager.browser.noSession": "Select an Agent Manager session first.",
|
||||
"agentManager.browser.screenshotAlt": "Current browser page",
|
||||
"agentManager.browser.errors": "Browser issues: {{count}}",
|
||||
"agentManager.browser.diagnostics": "Browser diagnostics",
|
||||
"agentManager.browser.diagnosticsHint":
|
||||
"Recent events from the automation browser. Security blocks are not console errors.",
|
||||
|
||||
"agentManager.import.pullRequest": "Pull Request",
|
||||
"agentManager.import.pastePrUrl": "Paste PR URL...",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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' را در ترمینال اجرا کنید.",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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'을 실행하세요.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -239,6 +239,21 @@ export const dict = {
|
||||
"agentManager.import.noBranchesHint": "在上方粘贴 PR URL 或创建新的 Worktree。",
|
||||
"agentManager.import.failed": "导入失败",
|
||||
|
||||
"agentManager.browser.title": "浏览器",
|
||||
"agentManager.browser.url": "本地应用 URL",
|
||||
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
|
||||
"agentManager.browser.open": "打开",
|
||||
"agentManager.browser.refresh": "刷新浏览器",
|
||||
"agentManager.browser.close": "关闭浏览器",
|
||||
"agentManager.browser.inspect": "选择元素",
|
||||
"agentManager.browser.devtoolsTitle": "开发者工具",
|
||||
"agentManager.browser.empty": "打开本地应用以在此处预览。",
|
||||
"agentManager.browser.noSession": "请先选择 Agent Manager 会话。",
|
||||
"agentManager.browser.screenshotAlt": "当前浏览器页面",
|
||||
"agentManager.browser.errors": "浏览器问题:{{count}}",
|
||||
"agentManager.browser.diagnostics": "浏览器诊断",
|
||||
"agentManager.browser.diagnosticsHint": "来自自动化浏览器的近期事件。安全拦截不是控制台错误。",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "需要 GitHub 身份验证",
|
||||
"agentManager.pr.error.gh_auth.description": "在终端中运行 'gh auth login' 以恢复 PR 状态。",
|
||||
"agentManager.pr.error.gh_missing.title": "未安装 GitHub CLI",
|
||||
|
||||
@@ -238,6 +238,20 @@ export const dict = {
|
||||
"agentManager.import.noBranchesFound": "找不到分支。",
|
||||
"agentManager.import.noBranchesHint": "在上方貼上 PR URL 或建立新的 Worktree。",
|
||||
"agentManager.import.failed": "匯入失敗",
|
||||
"agentManager.browser.title": "瀏覽器",
|
||||
"agentManager.browser.url": "本機應用程式 URL",
|
||||
"agentManager.browser.urlPlaceholder": "http://localhost:3000",
|
||||
"agentManager.browser.open": "開啟",
|
||||
"agentManager.browser.refresh": "重新整理瀏覽器",
|
||||
"agentManager.browser.close": "關閉瀏覽器",
|
||||
"agentManager.browser.inspect": "選取元素",
|
||||
"agentManager.browser.devtoolsTitle": "開發人員工具",
|
||||
"agentManager.browser.empty": "開啟本機應用程式,即可在此處預覽。",
|
||||
"agentManager.browser.noSession": "請先選取 Agent Manager 工作階段。",
|
||||
"agentManager.browser.screenshotAlt": "目前的瀏覽器頁面",
|
||||
"agentManager.browser.errors": "瀏覽器問題:{{count}}",
|
||||
"agentManager.browser.diagnostics": "瀏覽器診斷",
|
||||
"agentManager.browser.diagnosticsHint": "來自自動化瀏覽器的近期事件。安全性封鎖不是主控台錯誤。",
|
||||
|
||||
"agentManager.pr.error.gh_auth.title": "需要 GitHub 驗證",
|
||||
"agentManager.pr.error.gh_auth.description": "在終端機中執行 'gh auth login' 以還原 PR 狀態。",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentManagerSendInitialMessage, SendMessageRequest } from "../src/types/messages"
|
||||
import { formatBrowserFeedback } from "../../src/shared/browser-feedback"
|
||||
|
||||
interface VariantSession {
|
||||
getSessionAgent: (sessionID: string) => string
|
||||
@@ -7,16 +8,18 @@ interface VariantSession {
|
||||
|
||||
export function initialMessage(ev: AgentManagerSendInitialMessage): SendMessageRequest | undefined {
|
||||
if (!ev.text) return undefined
|
||||
const text = ev.browserFeedback ? `${formatBrowserFeedback(ev.browserFeedback.references)}\n\n${ev.text}` : ev.text
|
||||
return {
|
||||
type: "sendMessage",
|
||||
...(ev.projectId ? { projectId: ev.projectId } : {}),
|
||||
text: ev.text,
|
||||
text,
|
||||
sessionID: ev.sessionId,
|
||||
providerID: ev.providerID,
|
||||
modelID: ev.modelID,
|
||||
agent: ev.agent,
|
||||
variant: ev.variant,
|
||||
files: ev.files,
|
||||
browserFeedback: ev.browserFeedback,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ export function createProjectStateHandlers(opts: {
|
||||
setPending: (value: boolean) => void
|
||||
rename: (id: string) => void
|
||||
font: (font: AgentManagerStateMessage["terminalFont"]) => void
|
||||
browser: (enabled: boolean) => void
|
||||
current: () => string | undefined
|
||||
closeBrowser: () => void
|
||||
openBrowser: () => void
|
||||
}) {
|
||||
const projects = (msg: ExtensionMessage) => {
|
||||
if (msg.type !== "agentManager.projects") return
|
||||
@@ -32,6 +36,8 @@ export function createProjectStateHandlers(opts: {
|
||||
|
||||
const state = (msg: ExtensionMessage) => {
|
||||
if (msg.type !== "agentManager.state") return
|
||||
if (msg.browserAutomation !== undefined) opts.browser(msg.browserAutomation)
|
||||
if (msg.browserAutomation === false) opts.closeBrowser()
|
||||
if (msg.terminalFont) opts.font(msg.terminalFont)
|
||||
if (msg.projectId) opts.setStates((prev) => ({ ...prev, [msg.projectId!]: msg }))
|
||||
const store = msg.projectId ? opts.ensure(msg.projectId) : opts.active()
|
||||
@@ -45,5 +51,11 @@ export function createProjectStateHandlers(opts: {
|
||||
opts.routeState(msg)
|
||||
}
|
||||
|
||||
return { projects, state }
|
||||
const browser = (msg: ExtensionMessage) => {
|
||||
if (msg.type !== "agentManager.browserState" || (msg.status !== "starting" && msg.status !== "loading")) return
|
||||
if (!opts.isActive(msg.projectId) || msg.sessionId !== opts.current()) return
|
||||
opts.openBrowser()
|
||||
}
|
||||
|
||||
return { projects, state, browser }
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export enum SidePanel {
|
||||
Subagents = "subagents",
|
||||
EditPreview = "edit-preview",
|
||||
Documents = "documents",
|
||||
Browser = "browser",
|
||||
}
|
||||
|
||||
function viewportWidth(viewport: number): number {
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import { For, Show, createMemo, type Accessor, type Component } from "solid-js"
|
||||
import { Card } from "@kilocode/kilo-ui/card"
|
||||
import { Collapsible } from "@kilocode/kilo-ui/collapsible"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import { TextField } from "@kilocode/kilo-ui/text-field"
|
||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
import { createBrowserController } from "./controller"
|
||||
import type { BrowserController } from "./controller"
|
||||
import type { BrowserLabels, BrowserPosition, BrowserScope, BrowserState, BrowserTransport } from "./types"
|
||||
import type { BrowserReference } from "../../src/shared/browser-feedback"
|
||||
import "./browser.css"
|
||||
|
||||
function position(event: MouseEvent & { currentTarget: HTMLButtonElement }): BrowserPosition {
|
||||
const bounds = event.currentTarget.getBoundingClientRect()
|
||||
return {
|
||||
x: Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)),
|
||||
y: Math.max(0, Math.min(1, (event.clientY - bounds.top) / bounds.height)),
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
}
|
||||
}
|
||||
|
||||
const Toolbar: Component<{
|
||||
controller: BrowserController
|
||||
labels: BrowserLabels
|
||||
title?: string
|
||||
active: boolean
|
||||
}> = (props) => {
|
||||
const ready = () => !!props.controller.state()?.url && props.controller.state()?.status !== "closed"
|
||||
return (
|
||||
<div class="am-browser-toolbar">
|
||||
<Tooltip value={props.labels.refresh} placement="bottom">
|
||||
<IconButton
|
||||
icon="refresh"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={props.labels.refresh}
|
||||
onClick={props.controller.refresh}
|
||||
disabled={!ready() || props.controller.loading()}
|
||||
/>
|
||||
</Tooltip>
|
||||
<form
|
||||
class="am-browser-address"
|
||||
title={props.title}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (props.active && props.controller.url().trim() && !props.controller.loading()) props.controller.open()
|
||||
}}
|
||||
>
|
||||
<Show when={props.controller.loading()}>
|
||||
<span class="am-browser-site" aria-hidden="true">
|
||||
<Spinner />
|
||||
</span>
|
||||
</Show>
|
||||
<TextField
|
||||
class="am-browser-url"
|
||||
variant="ghost"
|
||||
value={props.controller.url()}
|
||||
onChange={props.controller.setUrl}
|
||||
placeholder={props.labels.urlPlaceholder}
|
||||
aria-label={props.labels.url}
|
||||
spellcheck={false}
|
||||
autocomplete="off"
|
||||
onFocus={(event: FocusEvent & { currentTarget: HTMLInputElement }) => event.currentTarget.select()}
|
||||
/>
|
||||
<Tooltip value={props.labels.open} placement="bottom">
|
||||
<IconButton
|
||||
type="submit"
|
||||
icon="arrow-right"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={props.labels.open}
|
||||
disabled={!props.controller.url().trim() || !props.active || props.controller.loading()}
|
||||
/>
|
||||
</Tooltip>
|
||||
</form>
|
||||
<Tooltip value={props.labels.inspect} placement="bottom">
|
||||
<IconButton
|
||||
icon="window-cursor"
|
||||
size="small"
|
||||
variant={props.controller.selecting() ? "secondary" : "ghost"}
|
||||
aria-label={props.labels.inspect}
|
||||
aria-pressed={props.controller.selecting()}
|
||||
disabled={!ready() || props.controller.loading()}
|
||||
onClick={props.controller.toggleSelecting}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div class="am-browser-tools-action">
|
||||
<Tooltip value={props.labels.devtoolsTitle} placement="bottom">
|
||||
<IconButton
|
||||
icon="console"
|
||||
size="small"
|
||||
variant={props.controller.tools() ? "secondary" : "ghost"}
|
||||
aria-label={props.labels.devtoolsTitle}
|
||||
aria-pressed={!!props.controller.tools()}
|
||||
onClick={props.controller.toggleTools}
|
||||
disabled={!ready() || props.controller.loading()}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip value={props.labels.close} placement="bottom">
|
||||
<IconButton
|
||||
icon="close"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={props.labels.close}
|
||||
onClick={props.controller.close}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Picker: Component<{
|
||||
active: boolean
|
||||
controller: BrowserController
|
||||
labels: BrowserLabels
|
||||
}> = (props) => {
|
||||
const bounds = () => props.controller.hovered()?.element?.rect
|
||||
return (
|
||||
<Show when={props.active}>
|
||||
<button
|
||||
type="button"
|
||||
class="am-browser-inspect"
|
||||
aria-label={props.labels.inspect}
|
||||
onMouseMove={(event) => props.controller.move(position(event))}
|
||||
onClick={(event) => props.controller.select(position(event))}
|
||||
/>
|
||||
<Show when={bounds()} keyed>
|
||||
{(rect) => (
|
||||
<div
|
||||
class="am-browser-hover-outline"
|
||||
style={{
|
||||
left: `${rect.x * 100}%`,
|
||||
top: `${rect.y * 100}%`,
|
||||
width: `${rect.width * 100}%`,
|
||||
height: `${rect.height * 100}%`,
|
||||
}}
|
||||
>
|
||||
<span classList={{ "am-browser-hover-label-inside": rect.y < 0.05 }}>
|
||||
{props.controller.hovered()?.element?.selector}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
const Viewport: Component<{
|
||||
state?: BrowserState
|
||||
session?: string
|
||||
controller: BrowserController
|
||||
labels: BrowserLabels
|
||||
}> = (props) => {
|
||||
const issue = () => props.state?.frameError || props.state?.error
|
||||
const page = () =>
|
||||
props.state?.url &&
|
||||
props.state.status !== "closed" &&
|
||||
(props.state.status !== "error" || !!props.state.title) &&
|
||||
props.state.url
|
||||
const identity = () => {
|
||||
const url = page()
|
||||
return url ? `${props.state?.browserId}:${props.state?.navigation ?? 0}:${url}` : undefined
|
||||
}
|
||||
return (
|
||||
<div class="am-browser-viewport" aria-live="polite">
|
||||
<Show
|
||||
when={identity()}
|
||||
keyed
|
||||
fallback={
|
||||
<Show when={!issue()}>
|
||||
<div class="am-browser-empty">
|
||||
<div>{props.session ? props.labels.empty : props.labels.noSession}</div>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
{(_key) => (
|
||||
<iframe
|
||||
class="am-browser-frame"
|
||||
src={props.state?.url}
|
||||
title={props.labels.screenshotAlt}
|
||||
sandbox="allow-scripts allow-forms allow-same-origin"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Picker
|
||||
active={(props.controller.selecting() || props.controller.pointing()) && !!props.state?.url}
|
||||
controller={props.controller}
|
||||
labels={props.labels}
|
||||
/>
|
||||
<Show when={issue()}>
|
||||
{(message) => (
|
||||
<Card variant="error" class="error-card am-browser-error-overlay" role="alert">
|
||||
<div class="error-card-body">
|
||||
<Icon name="warning" size="small" />
|
||||
<div class="error-card-message">{message()}</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Tools: Component<{ url: string; labels: BrowserLabels }> = (props) => (
|
||||
<section class="am-browser-devtools" aria-label={props.labels.devtoolsTitle}>
|
||||
<iframe
|
||||
class="am-browser-devtools-frame"
|
||||
src={props.url}
|
||||
title={props.labels.devtoolsTitle}
|
||||
sandbox="allow-scripts allow-forms allow-same-origin"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
|
||||
const Diagnostics: Component<{ logs: string[]; errors: number; labels: BrowserLabels }> = (props) => {
|
||||
const entries = createMemo(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const text of props.logs) counts.set(text, (counts.get(text) ?? 0) + 1)
|
||||
return Array.from(counts, ([text, count]) => ({ text, count }))
|
||||
})
|
||||
return (
|
||||
<Show when={props.logs.length || props.errors}>
|
||||
<Collapsible variant="ghost" class="am-browser-diagnostics">
|
||||
<Collapsible.Trigger>
|
||||
<span>{props.errors ? props.labels.errors(props.errors) : props.labels.diagnostics}</span>
|
||||
<Collapsible.Arrow />
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<div class="am-browser-diagnostics-hint">{props.labels.diagnosticsHint}</div>
|
||||
<div class="am-browser-console" role="log" aria-live="polite">
|
||||
<For each={entries()}>
|
||||
{(entry) => (
|
||||
<div class="am-browser-console-entry" data-level={entry.text.match(/^\[([^\]]+)\]/)?.[1] ?? "error"}>
|
||||
<span>{entry.text}</span>
|
||||
<Show when={entry.count > 1}>
|
||||
<span class="am-browser-console-count">×{entry.count}</span>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export interface BrowserPanelProps {
|
||||
scope: Accessor<BrowserScope | undefined>
|
||||
transport: BrowserTransport
|
||||
labels: BrowserLabels
|
||||
onReference: (reference: BrowserReference) => void
|
||||
onClose: () => void
|
||||
theme?: Accessor<"dark" | "light">
|
||||
}
|
||||
|
||||
export const BrowserPanel: Component<BrowserPanelProps> = (props) => {
|
||||
const controller = createBrowserController({
|
||||
scope: props.scope,
|
||||
transport: props.transport,
|
||||
onReference: props.onReference,
|
||||
onClose: props.onClose,
|
||||
theme: props.theme,
|
||||
})
|
||||
const state = controller.state
|
||||
return (
|
||||
<div
|
||||
class="am-browser-panel"
|
||||
aria-label={props.labels.title}
|
||||
aria-busy={controller.loading()}
|
||||
data-status={state()?.status ?? "closed"}
|
||||
>
|
||||
<Toolbar
|
||||
controller={controller}
|
||||
labels={props.labels}
|
||||
title={state()?.title}
|
||||
active={!!props.scope()?.sessionId}
|
||||
/>
|
||||
<div class="am-browser-workspace" classList={{ "am-browser-workspace-docked": !!controller.tools() }}>
|
||||
<Viewport state={state()} session={props.scope()?.sessionId} controller={controller} labels={props.labels} />
|
||||
<Show when={controller.tools()} keyed>
|
||||
{(entry) => <Tools url={entry.url} labels={props.labels} />}
|
||||
</Show>
|
||||
</div>
|
||||
<Diagnostics logs={state()?.logs ?? []} errors={state()?.errors ?? 0} labels={props.labels} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
.am-browser-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
background: var(--surface-base);
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.am-browser-toolbar {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid var(--border-weak-base);
|
||||
}
|
||||
|
||||
.am-browser-address {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
min-width: 0;
|
||||
padding: 0 2px 0 8px;
|
||||
background: var(--surface-inset-base);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.am-browser-address:focus-within {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
.am-browser-address [data-component="input"] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.am-browser-address [data-slot="input-wrapper"] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.am-browser-address .am-browser-url {
|
||||
height: 26px;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
font-size: var(--font-size-small);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.am-browser-site {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
.am-browser-site > svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.am-browser-tools-action {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.am-browser-workspace {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.am-browser-viewport {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: auto;
|
||||
background: var(--surface-inset-base);
|
||||
}
|
||||
|
||||
.am-browser-frame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.am-browser-workspace-docked .am-browser-viewport {
|
||||
flex: 1 1 40%;
|
||||
}
|
||||
|
||||
.am-browser-devtools {
|
||||
display: flex;
|
||||
flex: 1 1 60%;
|
||||
min-width: 0;
|
||||
min-height: 180px;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid var(--border-weak-base);
|
||||
}
|
||||
|
||||
.am-browser-devtools-frame {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
background: var(--surface-base);
|
||||
}
|
||||
|
||||
@container (min-width: 760px) {
|
||||
.am-browser-workspace-docked {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.am-browser-workspace-docked .am-browser-viewport {
|
||||
flex-basis: 45%;
|
||||
}
|
||||
|
||||
.am-browser-devtools {
|
||||
flex-basis: 55%;
|
||||
min-height: 0;
|
||||
border-top: 0;
|
||||
border-left: 1px solid var(--border-weak-base);
|
||||
}
|
||||
}
|
||||
|
||||
.am-browser-inspect {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
cursor: crosshair;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-strong-base);
|
||||
}
|
||||
|
||||
.am-browser-hover-outline {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
border: 2px solid var(--focus-border, #3794ff);
|
||||
background: color-mix(in srgb, var(--focus-border, #3794ff) 12%, transparent);
|
||||
}
|
||||
|
||||
.am-browser-hover-outline span {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -2px;
|
||||
padding: 2px 5px;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--text-on-accent);
|
||||
white-space: nowrap;
|
||||
background: var(--focus-border, #3794ff);
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
.am-browser-hover-outline .am-browser-hover-label-inside {
|
||||
left: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
[data-component="collapsible"].am-browser-diagnostics {
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid var(--border-weak-base);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
[data-component="collapsible"].am-browser-diagnostics > [data-slot="collapsible-trigger"] {
|
||||
justify-content: space-between;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.am-browser-diagnostics [data-slot="collapsible-arrow"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.am-browser-diagnostics-hint {
|
||||
padding: 0 12px 4px;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
.am-browser-console {
|
||||
max-height: 140px;
|
||||
padding: 4px 12px 8px;
|
||||
overflow: auto;
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
.am-browser-console-entry {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.am-browser-console-count {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
.am-browser-console-entry[data-level="warning"],
|
||||
.am-browser-console-entry[data-level="warn"] {
|
||||
color: var(--text-warning, #d6a23a);
|
||||
}
|
||||
|
||||
.am-browser-console-entry[data-level="error"] {
|
||||
color: var(--syntax-diff-delete, #da3319);
|
||||
}
|
||||
|
||||
[data-component="card"].am-browser-error-overlay {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.am-browser-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: 280px;
|
||||
padding: 24px;
|
||||
color: var(--text-weak);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
|
||||
import type { BrowserReference } from "../../src/shared/browser-feedback"
|
||||
import type {
|
||||
BrowserCommand,
|
||||
BrowserEvent,
|
||||
BrowserInspection,
|
||||
BrowserPosition,
|
||||
BrowserScope,
|
||||
BrowserState,
|
||||
BrowserTransport,
|
||||
} from "./types"
|
||||
|
||||
export interface BrowserControllerOptions {
|
||||
scope: Accessor<BrowserScope | undefined>
|
||||
transport: BrowserTransport
|
||||
onReference: (reference: BrowserReference) => void
|
||||
onClose: () => void
|
||||
theme?: Accessor<"dark" | "light">
|
||||
schedule?: (callback: FrameRequestCallback) => number
|
||||
cancel?: (frame: number) => void
|
||||
}
|
||||
|
||||
export interface BrowserController {
|
||||
url: Accessor<string>
|
||||
state: Accessor<BrowserState | undefined>
|
||||
selecting: Accessor<boolean>
|
||||
pointing: Accessor<boolean>
|
||||
hovered: Accessor<BrowserInspection | undefined>
|
||||
tools: Accessor<{ browserId: string; url: string } | undefined>
|
||||
loading: Accessor<boolean>
|
||||
setUrl: (value: string) => void
|
||||
open: () => void
|
||||
refresh: () => void
|
||||
close: () => void
|
||||
toggleSelecting: () => void
|
||||
toggleTools: () => void
|
||||
move: (value: BrowserPosition) => void
|
||||
select: (value: BrowserPosition) => void
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
export function createBrowserController(props: BrowserControllerOptions): BrowserController {
|
||||
const [url, setUrl] = createSignal("")
|
||||
const [selecting, setSelecting] = createSignal(false)
|
||||
const [pointing, setPointing] = createSignal(false)
|
||||
const [hovered, setHovered] = createSignal<BrowserInspection>()
|
||||
const [state, setState] = createSignal<BrowserState>()
|
||||
const [tools, setTools] = createSignal<{ browserId: string; url: string }>()
|
||||
const scheduleFrame = props.schedule ?? ((callback: FrameRequestCallback) => requestAnimationFrame(callback))
|
||||
const cancelFrame = props.cancel ?? ((frame: number) => cancelAnimationFrame(frame))
|
||||
let frame: number | undefined
|
||||
let pending: BrowserPosition | undefined
|
||||
let active: string | undefined
|
||||
let selected: string | undefined
|
||||
let sequence = 0
|
||||
let current: BrowserScope | undefined
|
||||
let unsubscribe: (() => void) | undefined
|
||||
let generation = 0
|
||||
let disposed = false
|
||||
|
||||
const stop = () => {
|
||||
if (frame !== undefined) cancelFrame(frame)
|
||||
frame = undefined
|
||||
pending = undefined
|
||||
active = undefined
|
||||
selected = undefined
|
||||
setHovered(undefined)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
stop()
|
||||
setState(undefined)
|
||||
setUrl("")
|
||||
setSelecting(false)
|
||||
setPointing(false)
|
||||
setTools(undefined)
|
||||
}
|
||||
|
||||
const same = (scope: BrowserScope, value: BrowserScope) =>
|
||||
scope.sessionId === value.sessionId && scope.projectId === value.projectId
|
||||
|
||||
const send = (command: BrowserCommand) => {
|
||||
if (disposed) return
|
||||
props.transport.send(command)
|
||||
}
|
||||
|
||||
const request = (type: "refresh" | "close" | "state") => {
|
||||
sync()
|
||||
if (!current) return
|
||||
send({ type, scope: current })
|
||||
}
|
||||
|
||||
const inspect = (value: BrowserPosition, hover: boolean) => {
|
||||
sync()
|
||||
if (!current) return
|
||||
const requestId = String(++sequence)
|
||||
if (hover) active = requestId
|
||||
else selected = requestId
|
||||
send({ type: "inspect", scope: current, position: value, hover, requestId })
|
||||
}
|
||||
|
||||
const input = (value: BrowserPosition, click: boolean) => {
|
||||
sync()
|
||||
if (!current) return
|
||||
send({ type: "input", scope: current, position: value, click })
|
||||
}
|
||||
|
||||
const schedule = () => {
|
||||
if (frame !== undefined || active || !pending || (!selecting() && !pointing())) return
|
||||
frame = scheduleFrame(() => {
|
||||
frame = undefined
|
||||
const value = pending
|
||||
pending = undefined
|
||||
if (!value || (!selecting() && !pointing())) return
|
||||
if (pointing()) input(value, false)
|
||||
inspect(value, true)
|
||||
})
|
||||
}
|
||||
|
||||
const receiveState = (value: BrowserState) => {
|
||||
if (!current || !same(current, value.scope)) return
|
||||
const previous = state()
|
||||
const changed = value.url !== previous?.url
|
||||
if (
|
||||
value.browserId !== previous?.browserId ||
|
||||
value.navigation !== previous?.navigation ||
|
||||
value.status === "closed"
|
||||
) {
|
||||
stop()
|
||||
setSelecting(false)
|
||||
}
|
||||
const inspecting = value.status !== "closed" && value.status !== "error" && value.inspecting === true
|
||||
if (inspecting !== pointing()) {
|
||||
stop()
|
||||
setPointing(inspecting)
|
||||
if (inspecting) setSelecting(false)
|
||||
}
|
||||
if (tools()?.browserId !== value.browserId || value.status === "closed") setTools(undefined)
|
||||
setState(value)
|
||||
if (value.url && changed) setUrl(value.url)
|
||||
}
|
||||
|
||||
const receiveInspection = (value: BrowserInspection) => {
|
||||
if (!current || !same(current, value.scope)) return
|
||||
if (value.hover) {
|
||||
if ((!selecting() && !pointing()) || value.requestId !== active) return
|
||||
active = undefined
|
||||
setHovered(value.error ? undefined : value)
|
||||
schedule()
|
||||
return
|
||||
}
|
||||
if (value.requestId !== selected) return
|
||||
selected = undefined
|
||||
const element = value.element
|
||||
if (value.error || !element?.selector) {
|
||||
setSelecting(true)
|
||||
return
|
||||
}
|
||||
props.onReference({
|
||||
id: crypto.randomUUID(),
|
||||
sessionId: current.sessionId,
|
||||
selector: element.selector,
|
||||
text: element.text,
|
||||
url: value.url,
|
||||
title: value.title,
|
||||
hierarchy: element.hierarchy,
|
||||
html: element.html,
|
||||
styles: element.styles,
|
||||
source: element.source,
|
||||
})
|
||||
setSelecting(false)
|
||||
stop()
|
||||
}
|
||||
|
||||
const receive = (event: BrowserEvent) => {
|
||||
if (disposed) return
|
||||
sync()
|
||||
if (!current) return
|
||||
if (event.type === "state") return receiveState(event.value)
|
||||
if (event.type === "devtools") {
|
||||
if (!same(current, event.value.scope) || event.value.browserId !== state()?.browserId) return
|
||||
setTools({ browserId: event.value.browserId, url: event.value.url })
|
||||
return
|
||||
}
|
||||
receiveInspection(event.value)
|
||||
}
|
||||
|
||||
const attach = (scope: BrowserScope | undefined) => {
|
||||
unsubscribe?.()
|
||||
unsubscribe = undefined
|
||||
generation++
|
||||
current = scope ? { ...scope } : undefined
|
||||
reset()
|
||||
if (!current) return
|
||||
const version = generation
|
||||
unsubscribe = props.transport.subscribe((event) => {
|
||||
if (version !== generation) return
|
||||
receive(event)
|
||||
})
|
||||
send({ type: "state", scope: current })
|
||||
}
|
||||
|
||||
const sync = () => {
|
||||
if (disposed) return false
|
||||
const next = props.scope()
|
||||
if (next?.sessionId !== current?.sessionId || next?.projectId !== current?.projectId) attach(next)
|
||||
return true
|
||||
}
|
||||
attach(props.scope())
|
||||
createEffect(() => sync())
|
||||
const read =
|
||||
<T>(value: Accessor<T>): Accessor<T> =>
|
||||
() => {
|
||||
sync()
|
||||
return value()
|
||||
}
|
||||
|
||||
const controller: BrowserController = {
|
||||
url: read(url),
|
||||
state: read(state),
|
||||
selecting: read(selecting),
|
||||
pointing: read(pointing),
|
||||
hovered: read(hovered),
|
||||
tools: read(tools),
|
||||
loading: read(() => state()?.status === "loading" || state()?.status === "starting"),
|
||||
setUrl: (value) => {
|
||||
if (!sync()) return
|
||||
setUrl(value)
|
||||
},
|
||||
open: () => {
|
||||
if (!sync() || !current) return
|
||||
const value = url().trim()
|
||||
if (!value) return
|
||||
send({ type: "open", scope: current, url: /^https?:\/\//i.test(value) ? value : `http://${value}` })
|
||||
},
|
||||
refresh: () => request("refresh"),
|
||||
close: () => {
|
||||
if (!sync()) return
|
||||
stop()
|
||||
setSelecting(false)
|
||||
setPointing(false)
|
||||
request("close")
|
||||
props.onClose()
|
||||
},
|
||||
toggleSelecting: () => {
|
||||
if (!sync()) return
|
||||
const next = !selecting()
|
||||
stop()
|
||||
setSelecting(next)
|
||||
},
|
||||
toggleTools: () => {
|
||||
if (!sync()) return
|
||||
if (tools()) {
|
||||
setTools(undefined)
|
||||
return
|
||||
}
|
||||
if (!current) return
|
||||
send({
|
||||
type: "devtools",
|
||||
scope: current,
|
||||
theme: props.theme?.() ?? "dark",
|
||||
})
|
||||
},
|
||||
move: (value) => {
|
||||
if (!sync()) return
|
||||
pending = value
|
||||
schedule()
|
||||
},
|
||||
select: (value) => {
|
||||
if (!sync()) return
|
||||
stop()
|
||||
if (pointing()) {
|
||||
input(value, true)
|
||||
return
|
||||
}
|
||||
setSelecting(false)
|
||||
inspect(value, false)
|
||||
},
|
||||
dispose: () => {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
unsubscribe?.()
|
||||
unsubscribe = undefined
|
||||
reset()
|
||||
current = undefined
|
||||
},
|
||||
}
|
||||
|
||||
onCleanup(controller.dispose)
|
||||
return controller
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export { BrowserPanel } from "./BrowserPanel"
|
||||
export { createBrowserController } from "./controller"
|
||||
export type { BrowserController, BrowserControllerOptions } from "./controller"
|
||||
export type {
|
||||
BrowserCommand,
|
||||
BrowserDevtools,
|
||||
BrowserElement,
|
||||
BrowserEvent,
|
||||
BrowserInspection,
|
||||
BrowserLabels,
|
||||
BrowserPosition,
|
||||
BrowserScope,
|
||||
BrowserState,
|
||||
BrowserTransport,
|
||||
} from "./types"
|
||||
@@ -0,0 +1,91 @@
|
||||
export interface BrowserScope {
|
||||
sessionId: string
|
||||
projectId?: string
|
||||
}
|
||||
|
||||
export interface BrowserPosition {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export interface BrowserElement {
|
||||
tag: string
|
||||
id?: string
|
||||
classes?: string
|
||||
text?: string
|
||||
selector?: string
|
||||
rect?: { x: number; y: number; width: number; height: number }
|
||||
hierarchy?: string[]
|
||||
html?: string
|
||||
styles?: { color?: string; backgroundColor?: string }
|
||||
source?: { file: string; line?: number; column?: number }
|
||||
}
|
||||
|
||||
export interface BrowserState {
|
||||
scope: BrowserScope
|
||||
browserId: string
|
||||
navigation?: number
|
||||
status: "starting" | "ready" | "loading" | "error" | "closed"
|
||||
inspecting?: boolean
|
||||
url?: string
|
||||
title?: string
|
||||
errors: number
|
||||
logs?: string[]
|
||||
error?: string
|
||||
frameError?: string
|
||||
}
|
||||
|
||||
export interface BrowserInspection {
|
||||
scope: BrowserScope
|
||||
requestId: string
|
||||
url?: string
|
||||
title?: string
|
||||
element?: BrowserElement
|
||||
logs: string[]
|
||||
hover?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface BrowserDevtools {
|
||||
scope: BrowserScope
|
||||
browserId: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type BrowserCommand =
|
||||
| { type: "open"; scope: BrowserScope; url: string }
|
||||
| { type: "refresh"; scope: BrowserScope }
|
||||
| { type: "close"; scope: BrowserScope }
|
||||
| { type: "state"; scope: BrowserScope }
|
||||
| { type: "inspect"; scope: BrowserScope; position: BrowserPosition; hover: boolean; requestId: string }
|
||||
| { type: "input"; scope: BrowserScope; position: BrowserPosition; click: boolean }
|
||||
| { type: "devtools"; scope: BrowserScope; theme: "dark" | "light" }
|
||||
|
||||
export type BrowserEvent =
|
||||
| { type: "state"; value: BrowserState }
|
||||
| { type: "inspection"; value: BrowserInspection }
|
||||
| { type: "devtools"; value: BrowserDevtools }
|
||||
|
||||
export interface BrowserTransport {
|
||||
send(command: BrowserCommand): void
|
||||
subscribe(listener: (event: BrowserEvent) => void): () => void
|
||||
}
|
||||
|
||||
export interface BrowserLabels {
|
||||
title: string
|
||||
url: string
|
||||
urlPlaceholder: string
|
||||
open: string
|
||||
refresh: string
|
||||
close: string
|
||||
inspect: string
|
||||
devtoolsTitle: string
|
||||
diagnostics: string
|
||||
diagnosticsHint: string
|
||||
empty: string
|
||||
noSession: string
|
||||
screenshotAlt: string
|
||||
errors: (count: number) => string
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { AnnotationSide, DiffLineAnnotation } from "@pierre/diffs"
|
||||
import type { WorktreeFileDiff } from "../src/types/messages"
|
||||
import { extractLines, type ReviewComment } from "./review-comments"
|
||||
import type { ReviewCommentEntry } from "../src/types/messages"
|
||||
import { post } from "../src/utils/webview-message"
|
||||
|
||||
export interface AnnotationLabels {
|
||||
commentOnLine: (line: number) => string
|
||||
@@ -154,16 +155,12 @@ function makeActionButton(title: string, icon: SVGSVGElement, action: () => void
|
||||
}
|
||||
|
||||
export function sendReviewComments(comments: ReviewCommentEntry[], activeTerminalId?: string): void {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: activeTerminalId ? "appendReviewCommentsToTerminal" : "appendReviewComments",
|
||||
comments,
|
||||
autoSend: true,
|
||||
targetTerminalId: activeTerminalId,
|
||||
},
|
||||
}),
|
||||
)
|
||||
post({
|
||||
type: activeTerminalId ? "appendReviewCommentsToTerminal" : "appendReviewComments",
|
||||
comments,
|
||||
autoSend: true,
|
||||
targetTerminalId: activeTerminalId,
|
||||
})
|
||||
}
|
||||
|
||||
export function buildFileAnnotations(
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { For, Show, createSignal, type Component } from "solid-js"
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { browserFeedbackData, type BrowserReference } from "../../../../src/shared/browser-feedback"
|
||||
import { useLanguage } from "../../context/language"
|
||||
|
||||
interface BrowserReferencesProps {
|
||||
references: BrowserReference[]
|
||||
variant?: "draft" | "message"
|
||||
onRemove?: (id: string) => void
|
||||
onClear?: () => void
|
||||
}
|
||||
|
||||
export const BrowserReferences: Component<BrowserReferencesProps> = (props) => {
|
||||
const language = useLanguage()
|
||||
const [open, setOpen] = createSignal(true)
|
||||
const [full, setFull] = createSignal<string[]>([])
|
||||
const data = () => browserFeedbackData(props.references)
|
||||
const rows = () => data()?.references ?? []
|
||||
const toggle = (id: string) =>
|
||||
setFull((current) => (current.includes(id) ? current.filter((item) => item !== id) : [...current, id]))
|
||||
const page = (item: BrowserReference) => item.title || item.url
|
||||
const styles = (item: BrowserReference) =>
|
||||
[
|
||||
item.styles?.color ? `color=${item.styles.color}` : "",
|
||||
item.styles?.backgroundColor ? `background=${item.styles.backgroundColor}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ")
|
||||
const source = (item: BrowserReference) =>
|
||||
item.source
|
||||
? [item.source.file, item.source.line, item.source.column].filter((value) => value !== undefined).join(":")
|
||||
: ""
|
||||
|
||||
return (
|
||||
<div
|
||||
class="prompt-review-comments"
|
||||
classList={{ "prompt-review-comments--message": props.variant === "message" }}
|
||||
data-component="browser-references"
|
||||
>
|
||||
<div class="prompt-review-comments-header">
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-review-comments-toggle"
|
||||
aria-expanded={open()}
|
||||
onClick={() => setOpen(!open())}
|
||||
>
|
||||
<Icon name={open() ? "chevron-down" : "chevron-right"} size="small" />
|
||||
<span class="prompt-review-comments-title">
|
||||
{language.t("agentManager.browser.title")} ({rows().length})
|
||||
</span>
|
||||
</button>
|
||||
<Show when={props.onClear}>
|
||||
<Button variant="ghost" size="small" onClick={() => props.onClear?.()}>
|
||||
{language.t("agentManager.review.clearAll")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={open()}>
|
||||
<div class="prompt-review-list">
|
||||
<For each={rows()}>
|
||||
{(item) => (
|
||||
<div class="prompt-review-row" classList={{ "prompt-review-row--full": full().includes(item.id) }}>
|
||||
<div class="prompt-review-row-top">
|
||||
<span class="prompt-review-row-icon">
|
||||
<Icon name="globe" size="small" />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-review-row-main"
|
||||
aria-expanded={full().includes(item.id)}
|
||||
onClick={() => toggle(item.id)}
|
||||
>
|
||||
<span class="prompt-review-row-head">
|
||||
<span class="prompt-review-row-label">{item.selector}</span>
|
||||
</span>
|
||||
<Show when={!full().includes(item.id)}>
|
||||
<span class="prompt-review-row-preview">
|
||||
{item.text || item.source?.file || page(item) || ""}
|
||||
</span>
|
||||
</Show>
|
||||
</button>
|
||||
<Show when={props.onRemove}>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-review-row-remove"
|
||||
onClick={() => props.onRemove?.(item.id)}
|
||||
aria-label={language.t("common.delete")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={full().includes(item.id)}>
|
||||
<div class="prompt-review-row-detail">
|
||||
<Show when={item.url || item.title}>
|
||||
{(value) => <div class="prompt-review-row-path">{value()}</div>}
|
||||
</Show>
|
||||
<Show when={item.hierarchy?.length}>
|
||||
<pre class="prompt-review-row-snippet">{item.hierarchy?.join(" > ")}</pre>
|
||||
</Show>
|
||||
<Show when={item.text && (!item.html || item.html === item.text)}>
|
||||
<pre class="prompt-review-row-snippet">{item.text}</pre>
|
||||
</Show>
|
||||
<Show when={item.html && item.html !== item.text}>
|
||||
<pre class="prompt-review-row-snippet">{item.html}</pre>
|
||||
</Show>
|
||||
<Show when={styles(item)}>{(value) => <div class="prompt-review-row-text">{value()}</div>}</Show>
|
||||
<Show when={source(item)}>{(value) => <code class="prompt-review-row-path">{value()}</code>}</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -58,10 +58,11 @@ import {
|
||||
type SandboxState,
|
||||
} from "./prompt-input-utils"
|
||||
import { sandboxMessages } from "./prompt-sandbox-messages"
|
||||
import type { ReviewCommentEntry, SendMessageFailedMessage, TextPart } from "../../types/messages"
|
||||
import type { ExtensionMessage, ReviewCommentEntry, SendMessageFailedMessage, TextPart } from "../../types/messages"
|
||||
import { formatReviewCommentsMarkdown } from "../../utils/review-comment-markdown"
|
||||
import {
|
||||
createdDraftKey,
|
||||
failedPrompt,
|
||||
movePromptDraft,
|
||||
pendingDraftKey,
|
||||
scopeDraftKey,
|
||||
@@ -69,6 +70,7 @@ import {
|
||||
} from "../../utils/prompt-drafts"
|
||||
import {
|
||||
beginPendingSend,
|
||||
browserDrafts as references,
|
||||
clearPendingDraftDiscarded,
|
||||
clearSessionDraftDiscarded,
|
||||
drafts,
|
||||
@@ -82,7 +84,14 @@ import {
|
||||
scrollDrafts,
|
||||
} from "../../utils/draft-store"
|
||||
import { ReviewComments } from "./ReviewComments"
|
||||
import { partReview, reviewBody } from "../../../../src/shared/review-comments"
|
||||
import { BrowserReferences } from "./BrowserReferences"
|
||||
import {
|
||||
browserFeedbackData,
|
||||
formatBrowserFeedback,
|
||||
mergeBrowserReferences,
|
||||
partFeedback,
|
||||
type BrowserReference,
|
||||
} from "../../../../src/shared/browser-feedback"
|
||||
import { isEnterKeyCommitNotIme } from "../../utils/ime-enter"
|
||||
import { parseMemoryCommand, type ParsedMemoryCommand } from "../../utils/memory-command"
|
||||
import { useMemory } from "../../context/memory"
|
||||
@@ -249,16 +258,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
comments: ReviewCommentEntry[],
|
||||
imgs: ImageAttachment[],
|
||||
scroll = textareaRef?.scrollTop ?? scrollDrafts.get(key) ?? 0,
|
||||
) => savePromptDraft(key, next, comments, imgs, scroll)
|
||||
browser: BrowserReference[] = browsers(),
|
||||
) => savePromptDraft(key, next, comments, imgs, scroll, browser)
|
||||
const readDraft = () => ({
|
||||
text: text().trim(),
|
||||
comments: reviewComments(),
|
||||
images: imageAttach.images(),
|
||||
browsers: browsers(),
|
||||
scroll: textareaRef?.scrollTop ?? scrollDrafts.get(draftKey()) ?? 0,
|
||||
})
|
||||
|
||||
const [text, setText] = createSignal("")
|
||||
const [reviewComments, setReviewComments] = createSignal<ReviewCommentEntry[]>([])
|
||||
const [browsers, setBrowsers] = createSignal<BrowserReference[]>([])
|
||||
const [enhancing, setEnhancing] = createSignal(false)
|
||||
const [autoApprove, setAutoApprove] = createSignal(false)
|
||||
const [sandboxes, setSandboxes] = createSignal<Record<string, SandboxState>>({})
|
||||
@@ -384,6 +396,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
const clearReviewComments = () => replaceReviewComments([])
|
||||
|
||||
const replace = (next: BrowserReference[]) => {
|
||||
setBrowsers(next)
|
||||
if (next.length === 0) {
|
||||
references.delete(draftKey())
|
||||
return
|
||||
}
|
||||
references.set(draftKey(), next)
|
||||
}
|
||||
|
||||
const remove = (id: string) => replace(browsers().filter((item) => item.id !== id))
|
||||
const clear = () => replace([])
|
||||
|
||||
const removeReviewComment = (id: string) => {
|
||||
replaceReviewComments(reviewComments().filter((item) => item.id !== id))
|
||||
}
|
||||
@@ -396,8 +420,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const val = untrack(text)
|
||||
const comments = untrack(reviewComments)
|
||||
const imgs = untrack(imageAttach.images)
|
||||
if (val || comments.length > 0 || imgs.length > 0 || drafts.has(prev)) {
|
||||
saveDraft(prev, val, comments, imgs)
|
||||
const browser = untrack(browsers)
|
||||
if (val || comments.length > 0 || imgs.length > 0 || browser.length > 0 || drafts.has(prev)) {
|
||||
saveDraft(prev, val, comments, imgs, undefined, browser)
|
||||
}
|
||||
}
|
||||
const draft = drafts.get(key) ?? ""
|
||||
@@ -411,6 +436,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
mention.seedSessions(refs.sessions, draft)
|
||||
}
|
||||
setReviewComments(pending)
|
||||
setBrowsers(references.get(key) ?? [])
|
||||
imageAttach.replace(imageDrafts.get(key) ?? [])
|
||||
setEnhancing(false)
|
||||
preEnhanceText = null
|
||||
@@ -442,7 +468,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const parts = session.getParts(m.id)
|
||||
return parts
|
||||
.filter((part): part is TextPart => part.type === "text")
|
||||
.map((part) => partReview(part.metadata, part.text)?.body ?? part.text.replace(REVIEW_PREFIX, ""))
|
||||
.map((part) => partFeedback(part.metadata, part.text)?.body ?? part.text.replace(REVIEW_PREFIX, ""))
|
||||
.join("")
|
||||
})
|
||||
history.seed(texts)
|
||||
@@ -487,11 +513,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const draft = text().trim()
|
||||
const comments = reviewComments()
|
||||
const imgs = imageAttach.images()
|
||||
const browser = browsers()
|
||||
const scroll = textareaRef?.scrollTop ?? 0
|
||||
const id = tabs?.add()
|
||||
if (!id) session.clearCurrentSession()
|
||||
const key = id ? scopeDraftKey(boxKey(), pendingDraftKey(id) ?? "new") : draftKey()
|
||||
saveDraft(key, draft, comments, imgs, scroll)
|
||||
saveDraft(key, draft, comments, imgs, scroll, browser)
|
||||
}
|
||||
window.addEventListener("newTaskRequest", onNewTaskRequest)
|
||||
onCleanup(() => window.removeEventListener("newTaskRequest", onNewTaskRequest))
|
||||
@@ -513,7 +540,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const draft = captured.get(id)
|
||||
captured.delete(id)
|
||||
if (!draft) return
|
||||
saveDraft(scopeDraftKey(box, sessionDraftKey(sid)), draft.text, draft.comments, draft.images, draft.scroll)
|
||||
saveDraft(
|
||||
scopeDraftKey(box, sessionDraftKey(sid)),
|
||||
draft.text,
|
||||
draft.comments,
|
||||
draft.images,
|
||||
draft.scroll,
|
||||
draft.browsers,
|
||||
)
|
||||
}
|
||||
window.addEventListener("agentManagerApplyDraft", onAgentManagerApplyDraft)
|
||||
onCleanup(() => window.removeEventListener("agentManagerApplyDraft", onAgentManagerApplyDraft))
|
||||
@@ -554,7 +588,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const isDisabled = () => !server.isConnected() || locked()
|
||||
const canUseSpeech = () => canUseSpeechToText(config(), provider.authStates())
|
||||
const speechModel = () => selectedSpeechToTextModel(config(), speechModels.models())
|
||||
const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0
|
||||
const hasInput = () =>
|
||||
text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0 || browsers().length > 0
|
||||
const sendReady = () => !isDisabled() && !terminal.pending() && !git.pending() && !props.blocked?.()
|
||||
const canContinue = () => speech.state() === "idle" && !hasInput() && session.canResume()
|
||||
const canSend = () =>
|
||||
@@ -653,8 +688,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
})
|
||||
|
||||
const restoreFailed = (failed: SendMessageFailedMessage) => {
|
||||
const draft = failed.review ? reviewBody(failed.review, failed.text) : failed.text
|
||||
if (draft === undefined) return
|
||||
const restored = failedPrompt(failed)
|
||||
if (!restored) return
|
||||
const draft = restored.text
|
||||
if (
|
||||
(failed.draftID && isPendingDraftDiscarded(failed.draftID)) ||
|
||||
(failed.sessionID && isSessionDraftDiscarded(failed.sessionID))
|
||||
@@ -672,7 +708,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
? scopeDraftKey(boxKey(), "new")
|
||||
: undefined
|
||||
if (!target) return
|
||||
const comments = failed.review?.comments ?? []
|
||||
const comments = restored.comments
|
||||
const browser = restored.browsers
|
||||
const images = (failed.files ?? [])
|
||||
.filter((file) => file.mime.startsWith("image/") && file.url.startsWith("data:"))
|
||||
.map((file) => ({
|
||||
@@ -682,12 +719,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
dataUrl: file.url,
|
||||
}))
|
||||
if (target !== draftKey()) {
|
||||
saveDraft(target, draft, comments, images, scrollDrafts.get(target) ?? 0)
|
||||
saveDraft(target, draft, comments, images, scrollDrafts.get(target) ?? 0, browser)
|
||||
return
|
||||
}
|
||||
// Do not overwrite a new draft the user started while the send was in flight.
|
||||
if (text().trim() || reviewComments().length > 0 || imageAttach.images().length > 0) return
|
||||
if (text().trim() || reviewComments().length > 0 || imageAttach.images().length > 0 || browsers().length > 0) return
|
||||
replaceReviewComments(comments)
|
||||
replace(browser)
|
||||
if (draft) {
|
||||
setText(draft)
|
||||
mention.seedFromText(draft)
|
||||
@@ -726,59 +764,94 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}),
|
||||
})
|
||||
|
||||
const restoreBox = (message: Extract<ExtensionMessage, { type: "setChatBoxMessage" }>) => {
|
||||
setText(message.text)
|
||||
if (message.paths?.length) mention.seedFromParts(message.paths, message.text)
|
||||
else mention.seedFromText(message.text)
|
||||
if (message.sessions?.length) mention.seedSessions(message.sessions, message.text)
|
||||
if (textareaRef) {
|
||||
textareaRef.value = message.text
|
||||
adjustHeight()
|
||||
}
|
||||
if (message.review || message.browser) {
|
||||
replaceReviewComments(message.review ?? [])
|
||||
replace(message.browser ?? [])
|
||||
}
|
||||
if (message.images) {
|
||||
const imgs = message.images.map((img) => ({
|
||||
id: crypto.randomUUID(),
|
||||
filename: img.filename ?? "image",
|
||||
mime: img.mime,
|
||||
dataUrl: img.dataUrl,
|
||||
}))
|
||||
imageAttach.replace(imgs)
|
||||
imageDrafts.set(draftKey(), imgs)
|
||||
}
|
||||
}
|
||||
|
||||
const appendBox = (message: Extract<ExtensionMessage, { type: "appendChatBoxMessage" }>) => {
|
||||
const reference = message.browser
|
||||
if (reference) {
|
||||
if (reference.sessionId !== sid()) return
|
||||
replace(mergeBrowserReferences(browsers(), reference))
|
||||
textareaRef?.focus()
|
||||
return
|
||||
}
|
||||
const current = text()
|
||||
const separator = current && !current.endsWith("\n") ? "\n\n" : ""
|
||||
const next = current + separator + message.text
|
||||
setText(next)
|
||||
if (textareaRef) {
|
||||
textareaRef.value = next
|
||||
adjustHeight()
|
||||
textareaRef.focus()
|
||||
textareaRef.scrollTop = textareaRef.scrollHeight
|
||||
syncHighlightScroll()
|
||||
}
|
||||
}
|
||||
|
||||
const appendReviews = (message: Extract<ExtensionMessage, { type: "appendReviewComments" }>) => {
|
||||
const empty =
|
||||
!text().trim() && reviewComments().length === 0 && imageAttach.images().length === 0 && browsers().length === 0
|
||||
replaceReviewComments(mergeReviewComments(reviewComments(), message.comments))
|
||||
if (message.autoSend && empty && !isDisabled() && !props.blocked?.()) {
|
||||
void handleSend()
|
||||
return
|
||||
}
|
||||
textareaRef?.focus()
|
||||
}
|
||||
|
||||
const created = (message: Extract<ExtensionMessage, { type: "sessionCreated" }>) => {
|
||||
const raw = createdDraftKey(message.draftID, sandboxRequest(undefined) !== undefined)
|
||||
if (!raw) return
|
||||
const source = scopeDraftKey(boxKey(), raw)
|
||||
const target = scopeDraftKey(boxKey(), sessionDraftKey(message.session.id))
|
||||
if (source === draftKey()) saveDraft(source, text(), reviewComments(), imageAttach.images())
|
||||
movePromptDraft(
|
||||
{ text: drafts, comments: reviewDrafts, images: imageDrafts, scrolls: scrollDrafts, browsers: references },
|
||||
source,
|
||||
target,
|
||||
)
|
||||
if (
|
||||
message.draftID &&
|
||||
!session.currentSessionID() &&
|
||||
(props.pendingSessionID ?? session.draftSessionID()) === message.draftID
|
||||
) {
|
||||
session.setDraftSessionID(message.session.id)
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribe = vscode.onMessage((message) => {
|
||||
if (handleSandboxMessage(message)) return
|
||||
|
||||
if (message.type === "setChatBoxMessage") {
|
||||
setText(message.text)
|
||||
// Prefer the exact attachment paths when available (e.g. reverting to a
|
||||
// message with @mentions) — seedFromText re-derives candidate mentions
|
||||
// from raw text via regex, which truncates at the first space in a
|
||||
// filename and cannot be relied on to reconstruct spaced paths correctly.
|
||||
if (message.paths?.length) mention.seedFromParts(message.paths, message.text)
|
||||
else mention.seedFromText(message.text)
|
||||
if (message.sessions?.length) mention.seedSessions(message.sessions, message.text)
|
||||
if (textareaRef) {
|
||||
textareaRef.value = message.text
|
||||
adjustHeight()
|
||||
}
|
||||
// When present, images are authoritative: replace current attachments
|
||||
// (an empty array clears them, e.g. on redo). Absent leaves them alone.
|
||||
if (message.images) {
|
||||
const imgs = message.images.map((img) => ({
|
||||
id: crypto.randomUUID(),
|
||||
filename: img.filename ?? "image",
|
||||
mime: img.mime,
|
||||
dataUrl: img.dataUrl,
|
||||
}))
|
||||
imageAttach.replace(imgs)
|
||||
imageDrafts.set(draftKey(), imgs)
|
||||
}
|
||||
restoreBox(message)
|
||||
}
|
||||
|
||||
if (message.type === "appendChatBoxMessage") {
|
||||
const current = text()
|
||||
const separator = current && !current.endsWith("\n") ? "\n\n" : ""
|
||||
const next = current + separator + message.text
|
||||
setText(next)
|
||||
if (textareaRef) {
|
||||
textareaRef.value = next
|
||||
adjustHeight()
|
||||
textareaRef.focus()
|
||||
textareaRef.scrollTop = textareaRef.scrollHeight
|
||||
syncHighlightScroll()
|
||||
}
|
||||
}
|
||||
if (message.type === "appendChatBoxMessage") appendBox(message)
|
||||
|
||||
if (message.type === "appendReviewComments") {
|
||||
const empty = !text().trim() && reviewComments().length === 0 && imageAttach.images().length === 0
|
||||
const merged = mergeReviewComments(reviewComments(), message.comments)
|
||||
replaceReviewComments(merged)
|
||||
if (message.autoSend && empty && !isDisabled() && !props.blocked?.()) {
|
||||
void handleSend()
|
||||
} else {
|
||||
textareaRef?.focus()
|
||||
}
|
||||
appendReviews(message)
|
||||
}
|
||||
|
||||
if (message.type === "triggerTask") {
|
||||
@@ -791,26 +864,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
restoreFailed(message as SendMessageFailedMessage)
|
||||
}
|
||||
|
||||
if (message.type === "sessionCreated") {
|
||||
const raw = createdDraftKey(message.draftID, sandboxRequest(undefined) !== undefined)
|
||||
if (raw) {
|
||||
const source = scopeDraftKey(boxKey(), raw)
|
||||
const target = scopeDraftKey(boxKey(), sessionDraftKey(message.session.id))
|
||||
if (source === draftKey()) saveDraft(source, text(), reviewComments(), imageAttach.images())
|
||||
movePromptDraft(
|
||||
{ text: drafts, comments: reviewDrafts, images: imageDrafts, scrolls: scrollDrafts },
|
||||
source,
|
||||
target,
|
||||
)
|
||||
}
|
||||
if (
|
||||
message.draftID &&
|
||||
!session.currentSessionID() &&
|
||||
(props.pendingSessionID ?? session.draftSessionID()) === message.draftID
|
||||
) {
|
||||
session.setDraftSessionID(message.session.id)
|
||||
}
|
||||
}
|
||||
if (message.type === "sessionCreated") created(message)
|
||||
|
||||
if (message.type === "action" && message.action === "focusInput") {
|
||||
textareaRef?.focus()
|
||||
@@ -1078,6 +1132,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const context = ctx()
|
||||
const value = text()
|
||||
const comments = reviewComments()
|
||||
const browser = browsers()
|
||||
const images = imageAttach.images()
|
||||
speech.stop({
|
||||
done: () => void handleSend(),
|
||||
@@ -1087,6 +1142,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
ctx() === context &&
|
||||
text() === value &&
|
||||
reviewComments() === comments &&
|
||||
browsers() === browser &&
|
||||
imageAttach.images() === images,
|
||||
})
|
||||
}
|
||||
@@ -1191,6 +1247,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
history.append(draft)
|
||||
setMemoryText(memory)
|
||||
clearReviewComments()
|
||||
clear()
|
||||
imageAttach.clear()
|
||||
mention.closeMention()
|
||||
slash.close()
|
||||
@@ -1217,6 +1274,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
if (matched.enabled && !matched.enabled()) return
|
||||
setText("")
|
||||
clearReviewComments()
|
||||
clear()
|
||||
imageAttach.clear()
|
||||
mention.closeMention()
|
||||
slash.close()
|
||||
@@ -1233,11 +1291,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const imgs = imageAttach.images()
|
||||
const pending = reviewComments()
|
||||
const review = pending.length > 0 ? formatReviewCommentsMarkdown(pending) : ""
|
||||
const browserData = browserFeedbackData(browsers())
|
||||
const browserText = browserData ? formatBrowserFeedback(browserData.references) : ""
|
||||
const message = [review, browserText, draft].filter(Boolean).join("\n\n")
|
||||
if (canSendContinue()) {
|
||||
session.resume()
|
||||
return
|
||||
}
|
||||
const message = draft && review ? `${review}\n\n${draft}` : draft || review
|
||||
const data = review ? { version: 1 as const, comments: pending } : undefined
|
||||
if ((!message && imgs.length === 0) || !sendReady() || speech.active()) return
|
||||
|
||||
@@ -1285,7 +1345,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const attachments = allFiles.length > 0 ? allFiles : undefined
|
||||
|
||||
// Server-side slash command (cmdMatch/matched already computed above)
|
||||
if (matched && !data) {
|
||||
if (matched && !data && !browserData) {
|
||||
const args = draft.slice(cmdMatch![0].length).trim()
|
||||
session.sendCommand(
|
||||
matched.name,
|
||||
@@ -1303,11 +1363,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
},
|
||||
)
|
||||
} else {
|
||||
session.sendMessage(message, sel?.providerID, sel?.modelID, attachments, pendingId, context, data, origin ?? null)
|
||||
session.sendMessage(
|
||||
message,
|
||||
sel?.providerID,
|
||||
sel?.modelID,
|
||||
attachments,
|
||||
pendingId,
|
||||
context,
|
||||
data,
|
||||
origin ?? null,
|
||||
browserData,
|
||||
)
|
||||
}
|
||||
|
||||
drafts.delete(key)
|
||||
reviewDrafts.delete(key)
|
||||
references.delete(key)
|
||||
imageDrafts.delete(key)
|
||||
mentionDrafts.delete(key)
|
||||
scrollDrafts.delete(key)
|
||||
@@ -1317,6 +1388,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
history.reset()
|
||||
setText("")
|
||||
clearReviewComments()
|
||||
setBrowsers([])
|
||||
imageAttach.clear()
|
||||
mention.closeMention()
|
||||
slash.close()
|
||||
@@ -1346,6 +1418,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
onClear={clearReviewComments}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={browsers().length > 0}>
|
||||
<div data-component="browser-references">
|
||||
<BrowserReferences references={browsers()} onRemove={remove} onClear={clear} />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={mention.showMention()}>
|
||||
<div class="file-mention-dropdown" ref={dropdownRef}>
|
||||
<Show
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createMemo, type Component } from "solid-js"
|
||||
import { createMemo, Show, type Component } from "solid-js"
|
||||
import { UserMessageDisplay } from "@kilocode/kilo-ui/message-part"
|
||||
import { partReview } from "../../../../src/shared/review-comments"
|
||||
import { partFeedback } from "../../../../src/shared/browser-feedback"
|
||||
import type { Message, Part, TextPart } from "../../types/messages"
|
||||
import { BrowserReferences } from "./BrowserReferences"
|
||||
import { ReviewComments } from "./ReviewComments"
|
||||
import { useLanguage } from "../../context/language"
|
||||
|
||||
@@ -21,22 +22,31 @@ interface VscodeUserMessageProps {
|
||||
export const VscodeUserMessage: Component<VscodeUserMessageProps> = (props) => {
|
||||
const language = useLanguage()
|
||||
const text = createMemo(() => props.parts.find((part): part is TextPart => part.type === "text" && !part.synthetic))
|
||||
const review = createMemo(() => {
|
||||
const feedback = createMemo(() => {
|
||||
const part = text()
|
||||
if (!part) return undefined
|
||||
return partReview(part.metadata, part.text)
|
||||
return partFeedback(part.metadata, part.text)
|
||||
})
|
||||
const body = createMemo(() => review()?.body)
|
||||
const body = createMemo(() => feedback()?.body)
|
||||
|
||||
return (
|
||||
<UserMessageDisplay
|
||||
message={props.message as unknown as Parameters<typeof UserMessageDisplay>[0]["message"]}
|
||||
parts={props.parts as unknown as Parameters<typeof UserMessageDisplay>[0]["parts"]}
|
||||
text={body()}
|
||||
copyText={review() ? text()?.text : undefined}
|
||||
copyText={feedback() ? text()?.text : undefined}
|
||||
header={
|
||||
review() ? (
|
||||
<ReviewComments comments={review()!.data.comments} sessionID={props.message.sessionID} variant="message" />
|
||||
feedback() ? (
|
||||
<>
|
||||
<Show when={feedback()?.review}>
|
||||
{(review) => (
|
||||
<ReviewComments comments={review().comments} sessionID={props.message.sessionID} variant="message" />
|
||||
)}
|
||||
</Show>
|
||||
<Show when={feedback()?.browserFeedback}>
|
||||
{(browser) => <BrowserReferences references={browser().references} variant="message" />}
|
||||
</Show>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
interrupted={props.interrupted}
|
||||
|
||||
@@ -29,9 +29,7 @@ const BrowserTab: Component = () => {
|
||||
const { globalConfig, projectConfig, updateGlobalConfig } = useConfig()
|
||||
|
||||
const [settings, setSettings] = createSignal<BrowserSettings>({
|
||||
enabled: false,
|
||||
useSystemChrome: true,
|
||||
headless: false,
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
@@ -120,16 +118,6 @@ const BrowserTab: Component = () => {
|
||||
{t("settings.browser.description")}
|
||||
</p>
|
||||
<Card>
|
||||
{/* Enable toggle */}
|
||||
<SettingsRow
|
||||
title={t("settings.browser.enable.title")}
|
||||
description={t("settings.browser.enable.description")}
|
||||
>
|
||||
<Switch checked={settings().enabled} onChange={(checked: boolean) => update("enabled", checked)} hideLabel>
|
||||
{t("settings.browser.enable.title")}
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
|
||||
{/* Use System Chrome */}
|
||||
<SettingsRow
|
||||
title={t("settings.browser.systemChrome.title")}
|
||||
@@ -143,18 +131,12 @@ const BrowserTab: Component = () => {
|
||||
{t("settings.browser.systemChrome.title")}
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
|
||||
{/* Headless mode */}
|
||||
<SettingsRow
|
||||
title={t("settings.browser.headless.title")}
|
||||
description={t("settings.browser.headless.description")}
|
||||
last
|
||||
>
|
||||
<Switch
|
||||
checked={settings().headless}
|
||||
onChange={(checked: boolean) => update("headless", checked)}
|
||||
hideLabel
|
||||
>
|
||||
<Switch checked disabled hideLabel>
|
||||
{t("settings.browser.headless.title")}
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
|
||||
@@ -217,6 +217,19 @@ const ExperimentalTab: Component = () => {
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.browser.enable.title")}
|
||||
description={language.t("settings.browser.enable.description")}
|
||||
>
|
||||
<Switch
|
||||
checked={settings().browserAutomation === true}
|
||||
onChange={(checked) => applySetting("browserAutomation", checked, "experimental.browserAutomation")}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.browser.enable.title")}
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.experimental.taskModelSelection.title")}
|
||||
description={language.t("settings.experimental.taskModelSelection.description")}
|
||||
|
||||
@@ -205,7 +205,7 @@ export const ServerProvider: ParentComponent = (props) => {
|
||||
* user has no way to see the code or cancel if the browser is dismissed.
|
||||
*/
|
||||
const goToLogin = () => {
|
||||
window.postMessage({ type: "navigate", view: "profile" }, "*")
|
||||
window.postMessage({ type: "navigate", view: "profile" }, window.origin)
|
||||
startLogin()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { ReviewMessageData } from "../../../src/shared/review-comments"
|
||||
import type { BrowserFeedbackData } from "../../../src/shared/browser-feedback"
|
||||
import type {
|
||||
AgentInfo,
|
||||
ContextUsage,
|
||||
@@ -171,6 +172,7 @@ export interface SessionContextValue {
|
||||
context?: string,
|
||||
review?: ReviewMessageData,
|
||||
origin?: string | null,
|
||||
browserFeedback?: BrowserFeedbackData,
|
||||
) => void
|
||||
sendCommand: (
|
||||
command: string,
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { reconcile } from "solid-js/store"
|
||||
import type { Message, MessageLoadMode, Part, ToolPart } from "../types/messages"
|
||||
import type { FileAttachment, Message, MessageLoadMode, Part, ToolPart } from "../types/messages"
|
||||
import { Identifier } from "../utils/id"
|
||||
import {
|
||||
feedbackMetadata,
|
||||
partFeedback,
|
||||
type BrowserFeedbackData,
|
||||
type BrowserReference,
|
||||
} from "../../../src/shared/browser-feedback"
|
||||
import type { ReviewCommentEntry, ReviewMessageData } from "../../../src/shared/review-comments"
|
||||
|
||||
export const SNAPSHOT_PROGRESS_TEXT = "Initializing snapshot..."
|
||||
|
||||
@@ -34,12 +42,45 @@ export function messageParts(messages: Message[]): Record<string, Part[]> {
|
||||
return parts
|
||||
}
|
||||
|
||||
export function optimistic(
|
||||
id: string,
|
||||
text: string,
|
||||
files?: FileAttachment[],
|
||||
review?: ReviewMessageData,
|
||||
browser?: BrowserFeedbackData,
|
||||
): Part[] {
|
||||
const parts: Part[] = []
|
||||
if (text) {
|
||||
parts.push({
|
||||
type: "text",
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: id,
|
||||
text,
|
||||
metadata: feedbackMetadata(review, browser),
|
||||
})
|
||||
}
|
||||
for (const file of files ?? []) {
|
||||
parts.push({
|
||||
type: "file",
|
||||
id: Identifier.ascending("part"),
|
||||
messageID: id,
|
||||
mime: file.mime,
|
||||
url: file.url,
|
||||
filename: file.filename,
|
||||
source: file.source,
|
||||
})
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
/** Prompt input state rebuilt from a reverted user message's parts. */
|
||||
export interface RevertPromptState {
|
||||
text: string
|
||||
paths: string[]
|
||||
sessions: Array<{ id: string; title: string; updated: number }>
|
||||
images: Array<{ dataUrl: string; mime: string; filename?: string }>
|
||||
review: ReviewCommentEntry[]
|
||||
browser: BrowserReference[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,10 +90,17 @@ export interface RevertPromptState {
|
||||
*/
|
||||
export function revertPromptState(parts: readonly Part[]): RevertPromptState {
|
||||
const files = parts.filter((p): p is Extract<Part, { type: "file" }> => p.type === "file")
|
||||
const feedback = parts
|
||||
.filter((p): p is Extract<Part, { type: "text" }> => p.type === "text" && !p.synthetic)
|
||||
.map((p) => partFeedback(p.metadata, p.text))
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined)
|
||||
return {
|
||||
text: parts
|
||||
.filter((p) => p.type === "text" && !(p as { synthetic?: boolean }).synthetic)
|
||||
.map((p) => (p as { text: string }).text ?? "")
|
||||
.map((p) => {
|
||||
if (p.type !== "text") return ""
|
||||
return partFeedback(p.metadata, p.text)?.body ?? p.text
|
||||
})
|
||||
.join(""),
|
||||
paths: files.map((p) => p.source?.path).filter((p): p is string => !!p && !p.startsWith("session:")),
|
||||
sessions: files
|
||||
@@ -65,6 +113,8 @@ export function revertPromptState(parts: readonly Part[]): RevertPromptState {
|
||||
images: files
|
||||
.filter((p) => p.mime.startsWith("image/") && p.url.startsWith("data:"))
|
||||
.map((p) => ({ dataUrl: p.url, mime: p.mime, filename: p.filename })),
|
||||
review: feedback.flatMap((p) => p.review?.comments ?? []),
|
||||
browser: feedback.flatMap((p) => p.browserFeedback?.references ?? []),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
dropSet,
|
||||
emptyPageState,
|
||||
messageParts,
|
||||
optimistic,
|
||||
reconcileSessionToolParts,
|
||||
removeSessionToolPart,
|
||||
removeSessionToolPartsForMessage,
|
||||
@@ -87,7 +88,8 @@ import { state as todoState } from "./todo-revert"
|
||||
import { sessionVariantKeys, transferVariants, variantKey } from "./session-variant-store"
|
||||
import { createSessionVariants } from "./session-variants"
|
||||
import { KILO_AUTO, KILO_PROVIDER_ID, parseModelString } from "../../../src/shared/provider-model"
|
||||
import { reviewMetadata, type ReviewMessageData } from "../../../src/shared/review-comments"
|
||||
import { type ReviewMessageData } from "../../../src/shared/review-comments"
|
||||
import type { BrowserFeedbackData } from "../../../src/shared/browser-feedback"
|
||||
import { activeUserMessageID, removeQueuedMessage, visibleMessages as filterVisibleMessages } from "./session-queue"
|
||||
import { clearSessionDraftDiscarded, deleteDraftsForSession } from "../utils/draft-store"
|
||||
import { createAbortState } from "./abort-state"
|
||||
@@ -2097,6 +2099,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
text: string,
|
||||
files?: FileAttachment[],
|
||||
review?: ReviewMessageData,
|
||||
browserFeedback?: BrowserFeedbackData,
|
||||
) {
|
||||
const now = Date.now()
|
||||
const temp: Message = {
|
||||
@@ -2110,32 +2113,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
pending.add(messageID)
|
||||
pendingOptimistic.set(sid, pending)
|
||||
|
||||
const parts: Part[] = []
|
||||
const partIds = new Set<string>()
|
||||
if (text) {
|
||||
const partId = Identifier.ascending("part")
|
||||
partIds.add(partId)
|
||||
parts.push({
|
||||
type: "text" as const,
|
||||
id: partId,
|
||||
messageID,
|
||||
text,
|
||||
metadata: review ? reviewMetadata(review) : undefined,
|
||||
})
|
||||
}
|
||||
for (const file of files ?? []) {
|
||||
const partId = Identifier.ascending("part")
|
||||
partIds.add(partId)
|
||||
parts.push({
|
||||
type: "file" as const,
|
||||
id: partId,
|
||||
messageID,
|
||||
mime: file.mime,
|
||||
url: file.url,
|
||||
filename: file.filename,
|
||||
source: file.source,
|
||||
})
|
||||
}
|
||||
const parts = optimistic(messageID, text, files, review, browserFeedback)
|
||||
setStore("messages", sid, (msgs = []) => [...msgs, temp])
|
||||
setStore("parts", messageID, parts)
|
||||
if (parts.length > 0) optimisticParts.set(messageID, new Set(parts.map((part) => part.id)))
|
||||
@@ -2152,6 +2130,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
context?: string,
|
||||
review?: ReviewMessageData,
|
||||
origin?: string | null,
|
||||
browserFeedback?: BrowserFeedbackData,
|
||||
) {
|
||||
if (!server.isConnected()) {
|
||||
console.warn("[Kilo New] Cannot send message: not connected")
|
||||
@@ -2182,6 +2161,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
variant: variants.request(scope),
|
||||
files,
|
||||
review,
|
||||
browserFeedback,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -2197,7 +2177,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
if (!sid && !draftID && effectiveDraftID) agentDrafts.seed(effectiveDraftID)
|
||||
if (scope) {
|
||||
clearClose(scope)
|
||||
addOptimistic(scope, messageID, text, files, review)
|
||||
addOptimistic(scope, messageID, text, files, review, browserFeedback)
|
||||
startSubmission(scope, messageID)
|
||||
if (!sid && (!draftID || draftSessionID() === scope)) {
|
||||
setUserClearedSession(false)
|
||||
@@ -2218,6 +2198,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
variant: variants.request(scope),
|
||||
files,
|
||||
review,
|
||||
browserFeedback,
|
||||
agentManagerContext: context,
|
||||
})
|
||||
}
|
||||
@@ -2735,14 +2716,12 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
// Restore the reverted user message's prompt text and attachments into the
|
||||
// input. Dispatch as a window message so PromptInput picks it up via onMessage.
|
||||
const state = revertPromptState(getParts(messageID))
|
||||
const { text, paths, sessions, images } = state
|
||||
const { text, paths, sessions, images, review, browser } = state
|
||||
// Paths carry the attachments' exact locations so PromptInput can seed them
|
||||
// directly rather than re-deriving mentions from the text via regex, which
|
||||
// truncates at the first space in a filename (see PromptInput's
|
||||
// setChatBoxMessage handler).
|
||||
if (text || paths.length > 0 || sessions.length > 0 || images.length > 0) {
|
||||
window.postMessage({ type: "setChatBoxMessage", text, paths, sessions, images }, "*")
|
||||
}
|
||||
window.postMessage({ type: "setChatBoxMessage", text, paths, sessions, images, review, browser }, window.origin)
|
||||
vscode.postMessage({ type: "revertSession", sessionID: id, messageID, partID })
|
||||
}
|
||||
|
||||
@@ -2750,7 +2729,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
const id = currentSessionID()
|
||||
if (!id) return
|
||||
// Clear the prompt input on full redo (matching TUI/desktop behavior)
|
||||
window.postMessage({ type: "setChatBoxMessage", text: "", images: [] }, "*")
|
||||
window.postMessage({ type: "setChatBoxMessage", text: "", images: [], review: [], browser: [] }, window.origin)
|
||||
vscode.postMessage({ type: "unrevertSession", sessionID: id })
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createContext, useContext, onCleanup, ParentComponent, createSignal } f
|
||||
import type { VSCodeAPI, WebviewMessage, ExtensionMessage } from "../types/messages"
|
||||
import { ClipboardProvider } from "@kilocode/kilo-ui/context/clipboard"
|
||||
import { edge } from "../sidebar-position"
|
||||
import { protect } from "../utils/webview-message"
|
||||
|
||||
// Get the VS Code API (only available in webview context)
|
||||
let vscodeApi: VSCodeAPI | undefined
|
||||
@@ -43,6 +44,7 @@ interface VSCodeContextValue {
|
||||
const VSCodeContext = createContext<VSCodeContextValue>()
|
||||
|
||||
export const VSCodeProvider: ParentComponent = (props) => {
|
||||
const release = protect()
|
||||
const api = getVSCodeAPI()
|
||||
const handlers = new Set<(message: ExtensionMessage) => void>()
|
||||
const copies = new Map<string, { resolve: () => void; reject: (err: Error) => void }>()
|
||||
@@ -97,6 +99,7 @@ export const VSCodeProvider: ParentComponent = (props) => {
|
||||
api.postMessage({ type: "requestModelSelectorExpanded" })
|
||||
|
||||
onCleanup(() => {
|
||||
release()
|
||||
window.removeEventListener("message", messageListener)
|
||||
window.removeEventListener("focus", reportFocus)
|
||||
window.removeEventListener("blur", reportFocus)
|
||||
|
||||
@@ -76,7 +76,7 @@ export function useSlashCommand(
|
||||
hints: ["clear"],
|
||||
action: () => {
|
||||
window.dispatchEvent(new CustomEvent("newTaskRequest"))
|
||||
window.postMessage({ type: "navigate", view: "newTask" }, "*")
|
||||
window.postMessage({ type: "navigate", view: "newTask" }, window.origin)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -84,7 +84,7 @@ export function useSlashCommand(
|
||||
description: "Switch to another session",
|
||||
hints: ["resume", "continue", "history"],
|
||||
action: () => {
|
||||
window.postMessage({ type: "navigate", view: "history" }, "*")
|
||||
window.postMessage({ type: "navigate", view: "history" }, window.origin)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -795,14 +795,15 @@ export const dict = {
|
||||
"settings.agentBehaviour.subtab.skills": "Skills",
|
||||
|
||||
"settings.browser.description":
|
||||
"When enabled, the AI agent can interact with web pages — navigating, clicking, typing, and taking screenshots. A Chrome window will open so you can watch the agent work.",
|
||||
"Configure the browser runtime used by the experimental Agent Manager browser. The page preview stays in Agent Manager.",
|
||||
"settings.browser.enable.title": "Enable Browser Automation",
|
||||
"settings.browser.enable.description": "Register the Playwright MCP server with the CLI backend.",
|
||||
"settings.browser.enable.description": "Enable the session-scoped Agent Manager browser for local applications.",
|
||||
"settings.browser.systemChrome.title": "Use System Chrome",
|
||||
"settings.browser.systemChrome.description":
|
||||
"Use your installed Chrome browser instead of a separate Chromium instance.",
|
||||
"Use installed Google Chrome. Disable only when a compatible Playwright Chromium browser is already installed.",
|
||||
"settings.browser.headless.title": "Headless Mode",
|
||||
"settings.browser.headless.description": "Run in headless mode (no visible browser window).",
|
||||
"settings.browser.headless.description":
|
||||
"Agent Manager always runs the browser headlessly. The page preview appears in the Browser panel.",
|
||||
|
||||
"settings.language.description":
|
||||
'Choose the language for the Kilo Code UI. "Auto" uses your VS Code display language.',
|
||||
|
||||
@@ -32,6 +32,7 @@ import { ServerContext } from "../context/server"
|
||||
import { WorktreeModeProvider } from "../context/worktree-mode"
|
||||
import type {
|
||||
Message,
|
||||
BrowserReference,
|
||||
Part,
|
||||
QuestionRequest,
|
||||
ReviewComment,
|
||||
@@ -42,6 +43,7 @@ import type {
|
||||
ToolPart,
|
||||
} from "../types/messages"
|
||||
import { formatReviewCommentsMarkdown } from "../utils/review-comment-markdown"
|
||||
import { feedbackMetadata, formatBrowserFeedback } from "../../../src/shared/browser-feedback"
|
||||
import { reviewMetadata } from "../../../src/shared/review-comments"
|
||||
|
||||
const SESSION_ID = "story-session-chat-001"
|
||||
@@ -266,6 +268,28 @@ function reviewMessage(comments: ReviewCommentEntry[]) {
|
||||
return <VscodeUserMessage message={message} parts={parts} />
|
||||
}
|
||||
|
||||
function browserMessage(references: BrowserReference[]) {
|
||||
const data = { version: 1 as const, references }
|
||||
const message: Message = {
|
||||
id: "browser-user-message",
|
||||
sessionID: SESSION_ID,
|
||||
role: "user",
|
||||
createdAt: new Date(0).toISOString(),
|
||||
time: { created: 0 },
|
||||
}
|
||||
const parts: Part[] = [
|
||||
{
|
||||
id: "browser-user-part",
|
||||
sessionID: SESSION_ID,
|
||||
messageID: message.id,
|
||||
type: "text",
|
||||
text: `${formatBrowserFeedback(references)}\n\nPlease fix the selected browser elements.`,
|
||||
metadata: feedbackMetadata(undefined, data),
|
||||
},
|
||||
]
|
||||
return <VscodeUserMessage message={message} parts={parts} />
|
||||
}
|
||||
|
||||
export const UserMessageReviewComments: Story = {
|
||||
name: "User message — interactive review comments",
|
||||
render: () => {
|
||||
@@ -320,6 +344,39 @@ export const UserMessageManyReviewComments: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
export const UserMessageBrowserFeedback: Story = {
|
||||
name: "User message — browser feedback",
|
||||
render: () => (
|
||||
<StoryProviders sessionID={SESSION_ID} status="idle">
|
||||
<div style={{ "max-height": "620px", padding: "12px" }}>
|
||||
{browserMessage([
|
||||
{
|
||||
id: "browser-1",
|
||||
sessionId: SESSION_ID,
|
||||
selector: "main > button.save",
|
||||
url: "https://example.com/settings",
|
||||
title: "Settings",
|
||||
hierarchy: ["main", "button.save"],
|
||||
text: "Save settings",
|
||||
html: '<button class="save">Save settings</button>',
|
||||
styles: { color: "rgb(30, 30, 30)", backgroundColor: "white" },
|
||||
source: { file: "src/settings.tsx", line: 42, column: 7 },
|
||||
},
|
||||
{
|
||||
id: "browser-2",
|
||||
sessionId: SESSION_ID,
|
||||
selector: "form input[name=email]",
|
||||
url: "https://example.com/settings",
|
||||
title: "Settings",
|
||||
hierarchy: ["main", "form", "input[name=email]"],
|
||||
text: "Email address",
|
||||
},
|
||||
])}
|
||||
</div>
|
||||
</StoryProviders>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatView with a pending question tool call and an empty input.
|
||||
*
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Meta, StoryObj } from "storybook-solidjs-vite"
|
||||
import { onMount, type Component } from "solid-js"
|
||||
import MigrationWizard from "../components/migration/MigrationWizard"
|
||||
import { StoryProviders } from "./StoryProviders"
|
||||
import { post } from "../utils/webview-message"
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Migration",
|
||||
@@ -20,30 +21,26 @@ const operationId = "roo-migration-story"
|
||||
const RooWizard: Component = () => {
|
||||
onMount(() => {
|
||||
queueMicrotask(() => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "migrationData",
|
||||
source: "roo",
|
||||
operationId,
|
||||
data: {
|
||||
providers: [],
|
||||
mcpServers: [],
|
||||
customModes: [],
|
||||
sessions: [
|
||||
{
|
||||
id: "roo-1",
|
||||
title: "Refactor authentication flow",
|
||||
directory: "/workspace/app",
|
||||
time: 1760443200000,
|
||||
},
|
||||
{ id: "roo-2", title: "Add billing dashboard", directory: "/workspace/app", time: 1760356800000 },
|
||||
{ id: "roo-3", title: "Investigate flaky tests", directory: "/workspace/app", time: 1760270400000 },
|
||||
],
|
||||
post({
|
||||
type: "migrationData",
|
||||
source: "roo",
|
||||
operationId,
|
||||
data: {
|
||||
providers: [],
|
||||
mcpServers: [],
|
||||
customModes: [],
|
||||
sessions: [
|
||||
{
|
||||
id: "roo-1",
|
||||
title: "Refactor authentication flow",
|
||||
directory: "/workspace/app",
|
||||
time: 1760443200000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
{ id: "roo-2", title: "Add billing dashboard", directory: "/workspace/app", time: 1760356800000 },
|
||||
{ id: "roo-3", title: "Investigate flaky tests", directory: "/workspace/app", time: 1760270400000 },
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -125,9 +125,7 @@ export type KiloEmbeddingModelCatalog = {
|
||||
export type IndexingStatus = SdkIndexingStatus
|
||||
|
||||
export interface BrowserSettings {
|
||||
enabled: boolean
|
||||
useSystemChrome: boolean
|
||||
headless: boolean
|
||||
}
|
||||
|
||||
export type TerminalCommandDisplay = "expanded" | "collapsed"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user