mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge branch 'main' into mark/reimplement-mcp-removal
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
name: Check opencode annotations
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "packages/opencode/**"
|
||||
- "script/check-opencode-annotations.ts"
|
||||
- ".github/workflows/check-opencode-annotations.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check-annotations:
|
||||
name: Check kilocode_change annotations
|
||||
if: github.repository == 'Kilo-Org/kilocode'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- name: Check kilocode_change annotations in shared opencode files
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
if [ -n "$BASE_SHA" ]; then
|
||||
bun run script/check-opencode-annotations.ts --base "$BASE_SHA"
|
||||
else
|
||||
echo "No PR base SHA available (workflow_dispatch without PR context) — skipping."
|
||||
fi
|
||||
@@ -18,6 +18,7 @@ Kilo CLI is an open source AI coding agent that generates code from natural lang
|
||||
- **Knip** (unused exports): `bun run knip` from `packages/kilo-vscode/`. CI runs this — all exported types/functions must be imported somewhere. Remove or unexport unused exports before pushing.
|
||||
- **Source links**: After adding or changing URLs in `packages/kilo-vscode/`, `packages/kilo-vscode/webview-ui/`, or `packages/opencode/src/`, run `bun run script/extract-source-links.ts` from the repo root and commit the updated `packages/kilo-docs/source-links.md`. CI runs this check — the build fails if the file is stale.
|
||||
- **kilocode_change check**: `bun run check-kilocode-change` from `packages/kilo-vscode/`. CI runs this — `kilocode_change` is a marker for upstream merge conflicts and must not appear in `packages/kilo-vscode/` or `packages/kilo-ui/` (these are entirely Kilo Code additions). Remove the markers before pushing.
|
||||
- **opencode annotation check**: `bun run script/check-opencode-annotations.ts` from repo root. CI runs this on PRs touching `packages/opencode/` — every Kilo-specific change in shared opencode files must be annotated with `kilocode_change` markers. Exempt paths (no markers needed): `packages/opencode/src/kilocode/`, `packages/opencode/test/kilocode/`, and any path containing `kilocode` in the name.
|
||||
|
||||
## Products
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
- ⚡ Inline autocomplete suggestions
|
||||
- 🤖 Latest AI models
|
||||
- 🎁 API keys optional
|
||||
- 💡 **Get $20 in bonus credits when you top-up for the first time** Credits can be used with 500+ models like Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.2
|
||||
- 💡 **Get $20 in bonus credits when you top-up for the first time** Credits can be used with 500+ models like Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.4
|
||||
|
||||
## Quick Links
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
## Get Started in Visual Studio Code
|
||||
|
||||
1. Install the Kilo Code extension from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code).
|
||||
2. Create your account to access 500+ cutting-edge AI models including Gemini 3 Pro, Claude 4.5 Sonnet & Opus, and GPT-5 – with transparent pricing that matches provider rates exactly.
|
||||
2. Create your account to access 500+ cutting-edge AI models including Gemini 3.1 Pro, Claude 4.6 Sonnet & Opus, and GPT-5.4 – with transparent pricing that matches provider rates exactly.
|
||||
3. Start coding with AI that adapts to your workflow. Watch our quick-start guide to see Kilo in action:
|
||||
|
||||
<a href="https://youtu.be/pqGfYXgrhig"><img src="https://img.youtube.com/vi/pqGfYXgrhig/maxresdefault.jpg" alt="Watch the video" width="640" height="360"></a>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"dependencies": {
|
||||
"@kilocode/kilo-i18n": "workspace:*",
|
||||
"@kilocode/kilo-ui": "workspace:*",
|
||||
@@ -79,7 +79,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -142,7 +142,7 @@
|
||||
},
|
||||
"packages/kilo-docs": {
|
||||
"name": "@kilocode/kilo-docs",
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"dependencies": {
|
||||
"@docsearch/css": "^4",
|
||||
"@docsearch/js": "^4",
|
||||
@@ -171,7 +171,7 @@
|
||||
},
|
||||
"packages/kilo-gateway": {
|
||||
"name": "@kilocode/kilo-gateway",
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.65",
|
||||
"@ai-sdk/openai": "2.0.101",
|
||||
@@ -206,7 +206,7 @@
|
||||
},
|
||||
"packages/kilo-i18n": {
|
||||
"name": "@kilocode/kilo-i18n",
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
@@ -219,7 +219,7 @@
|
||||
},
|
||||
"packages/kilo-telemetry": {
|
||||
"name": "@kilocode/kilo-telemetry",
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"dependencies": {
|
||||
"@kilocode/kilo-gateway": "workspace:*",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
@@ -239,7 +239,7 @@
|
||||
},
|
||||
"packages/kilo-ui": {
|
||||
"name": "@kilocode/kilo-ui",
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -274,7 +274,7 @@
|
||||
},
|
||||
"packages/kilo-vscode": {
|
||||
"name": "kilo-code",
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"@kilocode/kilo-i18n": "workspace:*",
|
||||
@@ -327,7 +327,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "@kilocode/cli",
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"bin": {
|
||||
"kilo": "./bin/kilo",
|
||||
"kilocode": "./bin/kilo",
|
||||
@@ -451,7 +451,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@kilocode/plugin",
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -465,14 +465,14 @@
|
||||
},
|
||||
"packages/script": {
|
||||
"name": "@opencode-ai/script",
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@kilocode/sdk",
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -483,7 +483,7 @@
|
||||
},
|
||||
"packages/storybook": {
|
||||
"name": "@opencode-ai/storybook",
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"devDependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@solidjs/meta": "catalog:",
|
||||
@@ -506,7 +506,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"dependencies": {
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -553,7 +553,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
|
||||
+1
-1
@@ -122,6 +122,6 @@
|
||||
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
|
||||
"ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch"
|
||||
},
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"peerDependencies": {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "kilo"
|
||||
name = "Kilo"
|
||||
description = "The open source coding agent."
|
||||
version = "7.1.22"
|
||||
version = "7.2.0"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/Kilo-Org/kilocode"
|
||||
@@ -11,26 +11,26 @@ name = "Kilo"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.22/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.0/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.22/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.0/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.22/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.0/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.22/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.0/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.22/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.0/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-docs",
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack --port 3002",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-gateway",
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-i18n",
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Kilo-specific i18n translations and overrides",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-telemetry",
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Telemetry for Kilo CLI - PostHog analytics integration",
|
||||
|
||||
@@ -25,6 +25,9 @@ export enum TelemetryEvent {
|
||||
MCP_SERVER_CONNECTED = "MCP Server Connected",
|
||||
MCP_SERVER_ERROR = "MCP Server Error",
|
||||
|
||||
// Remote Events
|
||||
REMOTE_CONNECTION_OPENED = "Remote Connection Opened",
|
||||
|
||||
// Auth Events
|
||||
AUTH_SUCCESS = "Auth Success",
|
||||
AUTH_LOGOUT = "Auth Logout",
|
||||
|
||||
@@ -184,6 +184,11 @@ export namespace Telemetry {
|
||||
track(TelemetryEvent.MCP_SERVER_ERROR, { server, error })
|
||||
}
|
||||
|
||||
// Remote
|
||||
export function trackRemoteConnectionOpened() {
|
||||
track(TelemetryEvent.REMOTE_CONNECTION_OPENED)
|
||||
}
|
||||
|
||||
// Auth
|
||||
export function trackAuthSuccess(provider: string) {
|
||||
track(TelemetryEvent.AUTH_SUCCESS, { provider })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-ui",
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -19,6 +19,18 @@
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="assistant-copy-wrapper"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
margin-top: 2px;
|
||||
|
||||
[data-component="icon-button"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent long title/path from hiding the collapsible expand arrow */
|
||||
|
||||
@@ -1155,6 +1155,7 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() {
|
||||
|
||||
PART_MAPPING["text"] = function TextPartDisplay(props) {
|
||||
const data = useData()
|
||||
const i18n = useI18n()
|
||||
const part = () => props.part as TextPart
|
||||
|
||||
const displayText = () => (part().text ?? "").trim()
|
||||
@@ -1166,6 +1167,21 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
|
||||
return props.turnDiffSummary
|
||||
})
|
||||
|
||||
const showCopy = createMemo(() => {
|
||||
if (props.message.role !== "assistant") return false
|
||||
if (props.showAssistantCopyPartID === null) return false
|
||||
return props.showAssistantCopyPartID === part().id
|
||||
})
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
const content = displayText()
|
||||
if (!content) return
|
||||
await navigator.clipboard.writeText(content)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleMarkdownClick = (e: MouseEvent) => {
|
||||
if (!data.openFile) return
|
||||
const target = e.target
|
||||
@@ -1200,6 +1216,24 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
|
||||
<div data-slot="text-part-body">
|
||||
<Markdown text={throttledText()} cacheKey={part().id} onClick={handleMarkdownClick} />
|
||||
</div>
|
||||
<Show when={showCopy()}>
|
||||
<div data-slot="assistant-copy-wrapper">
|
||||
<Tooltip
|
||||
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
|
||||
placement="right"
|
||||
gutter={4}
|
||||
>
|
||||
<IconButton
|
||||
icon={copied() ? "check" : "copy"}
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={handleCopy}
|
||||
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={summary()}>
|
||||
{(render) => (
|
||||
<GrowBox animate={!!props.animate} fade gap={4} class="w-full min-w-0">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "kilo-code",
|
||||
"displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete",
|
||||
"description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.",
|
||||
"version": "7.1.22",
|
||||
"version": "7.2.0",
|
||||
"icon": "assets/icons/logo-outline-black.png",
|
||||
"galleryBanner": {
|
||||
"color": "#FFFFFF",
|
||||
|
||||
@@ -212,6 +212,7 @@ export class DiffViewerProvider implements vscode.Disposable {
|
||||
|
||||
public dispose(): void {
|
||||
this.stopDiffPolling()
|
||||
this.gitOps.dispose()
|
||||
this.panel?.dispose()
|
||||
this.outputChannel.dispose()
|
||||
}
|
||||
|
||||
@@ -38,8 +38,10 @@ import { getWorkspaceRoot } from "./review-utils"
|
||||
import { MarketplaceService, type MarketplaceItem, type RemoveResult } from "./services/marketplace"
|
||||
import { resolveProjectDirectory } from "./project-directory"
|
||||
import { getBusySessionCount, seedSessionStatuses } from "./session-status"
|
||||
import { retry } from "./services/cli-backend/retry"
|
||||
import { slimPart, slimParts } from "./kilo-provider/slim-metadata"
|
||||
import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session"
|
||||
import { retryable, backoff, MAX_RETRIES } from "./util/retry"
|
||||
// legacy-migration start
|
||||
import {
|
||||
checkAndShowMigrationWizard,
|
||||
@@ -124,6 +126,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
private cachedMcpStatusMessage: unknown = null
|
||||
/** Ref-count of in-flight handleUpdateConfig calls; prevents fetchAndSendConfig from sending stale data */
|
||||
private pending = 0
|
||||
private configWarningsShown = false
|
||||
/** Cached notificationsLoaded payload */
|
||||
private cachedNotificationsMessage: unknown = null
|
||||
private pendingReviewComments: { comments: unknown[]; autoSend: boolean }[] = []
|
||||
@@ -168,6 +171,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
private pendingFollowup: Followup | null = null
|
||||
/** Worktree diff stats poller for the sidebar badge — reuses GitStatsPoller (local stats only) */
|
||||
private statsPoller: GitStatsPoller | null = null
|
||||
private statsGitOps: GitOps | null = null
|
||||
private cachedStats: unknown = null
|
||||
|
||||
/** Optional interceptor called before the standard message handler.
|
||||
@@ -281,7 +285,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
// Use fire-and-forget (no throwOnError) to match old getProfile() which returned null on error.
|
||||
if (this.connectionState === "connected" && this.client) {
|
||||
console.log("[Kilo New] KiloProvider: 👤 syncWebviewState fetching profile...")
|
||||
const profileResult = await this.client.kilo.profile()
|
||||
const profileResult = await retry(() => this.client!.kilo.profile())
|
||||
const profileData = profileResult.data ?? null
|
||||
console.log("[Kilo New] KiloProvider: 👤 syncWebviewState profile:", profileData ? "received" : "null")
|
||||
this.postMessage({
|
||||
@@ -529,6 +533,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
break
|
||||
}
|
||||
case "abort":
|
||||
this.cancelRetry(message.sessionID ?? "")
|
||||
await this.handleAbort(message.sessionID)
|
||||
break
|
||||
case "revertSession":
|
||||
@@ -1048,6 +1053,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.postMessage({ type: "connectionState", state })
|
||||
|
||||
if (state === "connected") {
|
||||
// Fire config warnings independently so a failure in the
|
||||
// sequential await chain doesn't prevent warnings from being shown
|
||||
void this.checkConfigWarnings("state")
|
||||
try {
|
||||
// Profile fetch is best-effort — returns 401 when user isn't logged into gateway.
|
||||
const sdkClient = this.client
|
||||
@@ -1123,6 +1131,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
|
||||
this.postMessage({ type: "connectionState", state: this.connectionState })
|
||||
|
||||
// connect() can resolve after SSE reaches "connected" but before this
|
||||
// provider subscribes to onStateChange(). In that case the initial
|
||||
// connected callback is missed, so run the warning check here too.
|
||||
if (this.connectionState === "connected") {
|
||||
void this.checkConfigWarnings("init")
|
||||
}
|
||||
|
||||
await this.syncWebviewState("initializeConnection")
|
||||
await this.flushPendingSessionRefresh("initializeConnection")
|
||||
|
||||
@@ -1221,9 +1237,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
try {
|
||||
const workspaceDir = this.getWorkspaceDirectory(sessionID)
|
||||
const { data: messagesData } = await this.client.session.messages(
|
||||
{ sessionID, directory: workspaceDir },
|
||||
{ throwOnError: true, signal: abort.signal },
|
||||
const { data: messagesData } = await retry(() =>
|
||||
this.client!.session.messages(
|
||||
{ sessionID, directory: workspaceDir },
|
||||
{ throwOnError: true, signal: abort.signal },
|
||||
),
|
||||
)
|
||||
|
||||
// If this request was aborted while awaiting, skip posting stale results
|
||||
@@ -1324,9 +1342,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
try {
|
||||
const workspaceDir = this.getWorkspaceDirectory(sessionID)
|
||||
const { data: messagesData } = await this.client.session.messages(
|
||||
{ sessionID, directory: workspaceDir },
|
||||
{ throwOnError: true },
|
||||
const { data: messagesData } = await retry(() =>
|
||||
this.client!.session.messages({ sessionID, directory: workspaceDir }, { throwOnError: true }),
|
||||
)
|
||||
|
||||
const messages = messagesData.map((m) => ({
|
||||
@@ -1596,7 +1613,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
try {
|
||||
const workspaceDir = this.getWorkspaceDirectory()
|
||||
const { data: agents } = await this.client.app.agents({ directory: workspaceDir }, { throwOnError: true })
|
||||
const { data: agents } = await retry(() =>
|
||||
this.client!.app.agents({ directory: workspaceDir }, { throwOnError: true }),
|
||||
)
|
||||
|
||||
const { visible, defaultAgent } = filterVisibleAgents(agents)
|
||||
|
||||
@@ -1630,7 +1649,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
try {
|
||||
const workspaceDir = this.getWorkspaceDirectory()
|
||||
const { data: skills } = await this.client.app.skills({ directory: workspaceDir }, { throwOnError: true })
|
||||
const { data: skills } = await retry(() =>
|
||||
this.client!.app.skills({ directory: workspaceDir }, { throwOnError: true }),
|
||||
)
|
||||
|
||||
const message = {
|
||||
type: "skillsLoaded",
|
||||
@@ -1653,7 +1674,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
try {
|
||||
const dir = this.getWorkspaceDirectory()
|
||||
const { data: commands } = await this.client.command.list({ directory: dir }, { throwOnError: true })
|
||||
const { data: commands } = await retry(() =>
|
||||
this.client!.command.list({ directory: dir }, { throwOnError: true }),
|
||||
)
|
||||
|
||||
const message = {
|
||||
type: "commandsLoaded",
|
||||
@@ -1675,7 +1698,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
if (!this.client) return undefined
|
||||
try {
|
||||
const dir = this.getWorkspaceDirectory()
|
||||
const { data } = await this.client.app.skills({ directory: dir }, { throwOnError: true })
|
||||
const { data } = await retry(() => this.client!.app.skills({ directory: dir }, { throwOnError: true }))
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to fetch CLI skills for marketplace:", error)
|
||||
@@ -1811,7 +1834,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
try {
|
||||
const directory = this.getWorkspaceDirectory()
|
||||
const { data } = await this.client.mcp.status({ directory })
|
||||
const { data } = await retry(() => this.client!.mcp.status({ directory }))
|
||||
if (data) {
|
||||
const message = { type: "mcpStatusLoaded", status: data }
|
||||
this.cachedMcpStatusMessage = message
|
||||
@@ -1934,7 +1957,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
try {
|
||||
const workspaceDir = this.getWorkspaceDirectory()
|
||||
const { data: config } = await this.client.config.get({ directory: workspaceDir }, { throwOnError: true })
|
||||
const { data: config } = await retry(() =>
|
||||
this.client!.config.get({ directory: workspaceDir }, { throwOnError: true }),
|
||||
)
|
||||
|
||||
const message = {
|
||||
type: "configLoaded",
|
||||
@@ -1981,7 +2006,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
if (!this.client || this.connectionState !== "connected") return
|
||||
try {
|
||||
const dir = this.getWorkspaceDirectory()
|
||||
const { data: config } = await this.client.config.get({ directory: dir }, { throwOnError: true })
|
||||
const { data: config } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true }))
|
||||
this.cachedConfigMessage = { type: "configLoaded", config }
|
||||
this.postMessage({ type: "configUpdated", config })
|
||||
} catch (error) {
|
||||
@@ -1989,6 +2014,49 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch config warnings from the server and display a single consolidated
|
||||
* VS Code warning with a "Show Details" action button.
|
||||
* Only shown once per provider lifecycle (flag resets on dispose/re-create, not on SSE reconnect).
|
||||
*/
|
||||
private async checkConfigWarnings(from: string): Promise<void> {
|
||||
if (this.configWarningsShown) {
|
||||
console.log("[Kilo New] KiloProvider: config warnings already shown", { from })
|
||||
return
|
||||
}
|
||||
if (!this.client) {
|
||||
console.log("[Kilo New] KiloProvider: config warnings skipped (no client)", { from })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const dir = this.getWorkspaceDirectory()
|
||||
console.log("[Kilo New] KiloProvider: checking config warnings", { from, dir })
|
||||
const result = await this.client.config.warnings({ directory: dir })
|
||||
const list = result?.data ?? []
|
||||
console.log("[Kilo New] KiloProvider: config warnings fetched", { from, count: list.length })
|
||||
if (list.length === 0) return
|
||||
this.configWarningsShown = true
|
||||
|
||||
const first = list[0]!
|
||||
const summary = list.length === 1 ? first.message : `${first.message} (and ${list.length - 1} more)`
|
||||
console.warn("[Kilo New] KiloProvider: showing config warnings", { from, count: list.length, path: first.path })
|
||||
|
||||
const action = await vscode.window.showWarningMessage(`Config: ${summary}`, "Show Details")
|
||||
if (action === "Show Details") {
|
||||
const lines = list.map((w) => {
|
||||
const base = `${w.path}\n ${w.message}`
|
||||
return w.detail ? `${base}\n ${w.detail}` : base
|
||||
})
|
||||
const channel = vscode.window.createOutputChannel("Kilo Config Warnings")
|
||||
channel.clear()
|
||||
channel.appendLine(lines.join("\n\n"))
|
||||
channel.show()
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Kilo New] KiloProvider: checkConfigWarnings failed:", { from, err })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Kilo news/notifications and send to webview.
|
||||
* Uses the cached message pattern so the webview gets data immediately on refresh.
|
||||
@@ -2014,7 +2082,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
|
||||
try {
|
||||
const { data: all } = await this.client.kilo.notifications(undefined, { throwOnError: true })
|
||||
const { data: all } = await retry(() => this.client!.kilo.notifications(undefined, { throwOnError: true }))
|
||||
const notifications = all.filter((n) => !n.showIn || n.showIn.includes("extension"))
|
||||
const existing = this.extensionContext?.globalState.get<string[]>("kilo.dismissedNotificationIds", []) ?? []
|
||||
const active = new Set(notifications.map((n) => n.id))
|
||||
@@ -2129,7 +2197,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
// Config.state is reset by updateGlobal (via Instance.resetStateEntry) so
|
||||
// config.get() returns fresh data without a full dispose cycle.
|
||||
const dir = this.getWorkspaceDirectory()
|
||||
const { data: merged } = await this.client.config.get({ directory: dir }, { throwOnError: true })
|
||||
const { data: merged } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true }))
|
||||
|
||||
this.cachedConfigMessage = { type: "configLoaded", config: merged }
|
||||
this.postMessage({ type: "configUpdated", config: merged })
|
||||
@@ -2186,6 +2254,85 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
return { sid, dir }
|
||||
}
|
||||
|
||||
/** Abort controllers for active retry loops, keyed by session ID */
|
||||
private retryAbortControllers = new Map<string, AbortController>()
|
||||
|
||||
/**
|
||||
* Execute an SDK call with exponential backoff on HTTP errors.
|
||||
* Retries on 429, 5xx, and other retryable status codes.
|
||||
* When the response includes `Retry-After` / `Retry-After-MS` headers,
|
||||
* the delay honours that value (capped at 5 min). Otherwise uses the
|
||||
* predefined backoff schedule: 5s -> 10s -> 30s -> 60s -> 300s.
|
||||
*
|
||||
* After MAX_RETRIES (5) attempts, automatically throws the error.
|
||||
* Users can cancel via the cancel button in the UI which sends an abort
|
||||
* message — this interrupts the backoff delay and stops the retry loop.
|
||||
*
|
||||
* The webview receives `sessionStatus` messages with a countdown so the
|
||||
* user can see that a retry is in progress.
|
||||
*/
|
||||
private async withRetry(fn: () => Promise<{ error?: unknown; response: Response }>, sid: string): Promise<void> {
|
||||
const abortController = new AbortController()
|
||||
this.retryAbortControllers.set(sid, abortController)
|
||||
|
||||
try {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
if (abortController.signal.aborted) {
|
||||
// User cancelled — return normally without triggering sendMessageFailed
|
||||
return
|
||||
}
|
||||
|
||||
const result = await fn()
|
||||
if (!result.error) return
|
||||
|
||||
const status = result.response?.status ?? 0
|
||||
|
||||
// Non-retryable status codes fail immediately without retry
|
||||
if (!retryable(status)) {
|
||||
this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" })
|
||||
throw result.error
|
||||
}
|
||||
|
||||
// Stop retrying after MAX_RETRIES attempts
|
||||
if (attempt >= MAX_RETRIES) {
|
||||
this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" })
|
||||
throw result.error
|
||||
}
|
||||
|
||||
const delay = backoff(attempt, result.response?.headers)
|
||||
console.log(`[Kilo New] KiloProvider: Retry on ${status}, attempt ${attempt}/${MAX_RETRIES}, delay ${delay}ms`)
|
||||
|
||||
this.postMessage({
|
||||
type: "sessionStatus",
|
||||
sessionID: sid,
|
||||
status: "retry",
|
||||
attempt,
|
||||
message: `Error (${status}). Retrying...`,
|
||||
next: Date.now() + delay,
|
||||
})
|
||||
|
||||
// Wait for delay or until aborted
|
||||
await new Promise((resolve) => {
|
||||
const timer = setTimeout(resolve, delay)
|
||||
abortController.signal.addEventListener("abort", () => {
|
||||
clearTimeout(timer)
|
||||
})
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
this.retryAbortControllers.delete(sid)
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancel an active retry loop for a session */
|
||||
private cancelRetry(sid: string): void {
|
||||
const controller = this.retryAbortControllers.get(sid)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" })
|
||||
}
|
||||
}
|
||||
|
||||
private async handleSendMessage(
|
||||
text: string,
|
||||
messageID?: string,
|
||||
@@ -2228,18 +2375,21 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.connectionService.recordMessageSessionId(messageID, resolved!.sid)
|
||||
}
|
||||
|
||||
await this.client.session.promptAsync(
|
||||
{
|
||||
sessionID: resolved!.sid,
|
||||
directory: resolved!.dir,
|
||||
messageID,
|
||||
parts,
|
||||
model: providerID && modelID ? { providerID, modelID } : undefined,
|
||||
agent,
|
||||
variant,
|
||||
editorContext,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
const sid = resolved!.sid
|
||||
const dir = resolved!.dir
|
||||
await this.withRetry(
|
||||
() =>
|
||||
this.client!.session.promptAsync({
|
||||
sessionID: sid,
|
||||
directory: dir,
|
||||
messageID,
|
||||
parts,
|
||||
model: providerID && modelID ? { providerID, modelID } : undefined,
|
||||
agent,
|
||||
variant,
|
||||
editorContext,
|
||||
}),
|
||||
sid,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to send message:", error)
|
||||
@@ -2290,19 +2440,22 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
const parts = files?.map((f) => ({ type: "file" as const, mime: f.mime, url: f.url }))
|
||||
|
||||
await this.client.session.command(
|
||||
{
|
||||
sessionID: resolved!.sid,
|
||||
directory: resolved!.dir,
|
||||
command,
|
||||
arguments: args,
|
||||
messageID,
|
||||
model: providerID && modelID ? `${providerID}/${modelID}` : undefined,
|
||||
agent,
|
||||
variant,
|
||||
parts,
|
||||
},
|
||||
{ throwOnError: true },
|
||||
const sid = resolved!.sid
|
||||
const dir = resolved!.dir
|
||||
await this.withRetry(
|
||||
() =>
|
||||
this.client!.session.command({
|
||||
sessionID: sid,
|
||||
directory: dir,
|
||||
command,
|
||||
arguments: args,
|
||||
messageID,
|
||||
model: providerID && modelID ? `${providerID}/${modelID}` : undefined,
|
||||
agent,
|
||||
variant,
|
||||
parts,
|
||||
}),
|
||||
sid,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to send command:", error)
|
||||
@@ -3032,7 +3185,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
private startStatsPolling(): void {
|
||||
this.statsPoller?.stop()
|
||||
this.statsGitOps?.dispose()
|
||||
const git = new GitOps({ log: () => {} })
|
||||
this.statsGitOps = git
|
||||
this.statsPoller = new GitStatsPoller({
|
||||
getWorktrees: () => [],
|
||||
getWorkspaceRoot: () => getWorkspaceRoot(),
|
||||
@@ -3060,6 +3215,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
*/
|
||||
dispose(): void {
|
||||
this.statsPoller?.stop()
|
||||
this.statsGitOps?.dispose()
|
||||
this.unsubscribeEvent?.()
|
||||
this.unsubscribeState?.()
|
||||
this.unsubscribeNotificationDismiss?.()
|
||||
|
||||
@@ -6,8 +6,10 @@ import { getErrorMessage } from "../kilo-provider-utils"
|
||||
import { isAbsolutePath } from "../path-utils"
|
||||
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
|
||||
import { WorktreeStateManager, remoteRef } from "./WorktreeStateManager"
|
||||
import { handleSection } from "./section-handler"
|
||||
import { chooseBaseBranch, normalizeBaseBranch } from "./base-branch"
|
||||
import { GitStatsPoller, type WorktreePresenceResult } from "./GitStatsPoller"
|
||||
import { PRStatusBridge } from "./pr-status-bridge"
|
||||
import { GitOps, type ApplyConflict } from "./GitOps"
|
||||
import { versionedName } from "./branch-name"
|
||||
import { normalizePath, classifyWorktreeError } from "./git-import"
|
||||
@@ -53,6 +55,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
private diffSessionId: string | undefined
|
||||
private lastDiffHash: string | undefined
|
||||
private statsPoller: GitStatsPoller
|
||||
private prBridge!: PRStatusBridge
|
||||
private gitOps: GitOps
|
||||
private cachedDiffTarget: { sessionId: string; directory: string; baseBranch: string } | undefined
|
||||
private staleWorktreeIds = new Set<string>()
|
||||
@@ -94,6 +97,15 @@ export class AgentManagerProvider implements Disposable {
|
||||
log: (...args) => this.log(...args),
|
||||
git: this.gitOps,
|
||||
})
|
||||
this.prBridge = PRStatusBridge.create({
|
||||
getWorktrees: () => this.state?.getWorktrees() ?? [],
|
||||
getWorkspaceRoot: () => this.getRoot(),
|
||||
postToWebview: (m) => this.postToWebview(m),
|
||||
updateWorktreePR: (id, n, u, s) => this.state?.updateWorktreePR(id, n, u, s),
|
||||
hasPersistedPR: (id: string) => !!this.state?.getWorktree(id)?.prNumber,
|
||||
openExternal: (u) => this.host.openExternal(u),
|
||||
log: (...a) => this.log(...a),
|
||||
})
|
||||
}
|
||||
|
||||
private log(...args: unknown[]) {
|
||||
@@ -147,13 +159,14 @@ export class AgentManagerProvider implements Disposable {
|
||||
this.stateReady = this.initializeState()
|
||||
void this.sendRepoInfo()
|
||||
this.sendKeybindings()
|
||||
|
||||
this.prBridge.attachPanel(ctx)
|
||||
ctx.onDidDispose(() => {
|
||||
// Only clear if this is still the active panel — a newer panel may
|
||||
// have already replaced us via attachPanel.
|
||||
if (this.panel === ctx) {
|
||||
this.log("Panel disposed")
|
||||
this.statsPoller.stop()
|
||||
this.prBridge.poller.stop()
|
||||
this.stopDiffPolling()
|
||||
this.panel = undefined
|
||||
}
|
||||
@@ -209,6 +222,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async onMessage(msg: Record<string, unknown>): Promise<Record<string, unknown> | null> {
|
||||
if (this.prBridge.handleMessage(msg)) return null
|
||||
const m = msg as unknown as AgentManagerInMessage
|
||||
|
||||
if (m.type === "agentManager.createWorktree") {
|
||||
@@ -292,6 +306,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
// already emitted before the webview was ready to receive messages.
|
||||
if (this.cachedWorktreeStats) this.postToWebview(this.cachedWorktreeStats)
|
||||
if (this.cachedLocalStats) this.postToWebview(this.cachedLocalStats)
|
||||
this.prBridge.replay()
|
||||
// Refresh sessions after pushState so the webview's sessionsLoaded
|
||||
// handler is guaranteed to be registered (requestState fires from
|
||||
// onMount). Without this, the initial refreshSessions() in
|
||||
@@ -327,6 +342,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
this.state?.setSessionsCollapsed(m.collapsed)
|
||||
return null
|
||||
}
|
||||
if (this.handleSection(m)) return null
|
||||
if (m.type === "agentManager.setReviewDiffStyle") {
|
||||
this.state?.setReviewDiffStyle(m.style)
|
||||
return null
|
||||
@@ -408,6 +424,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
if (m.type === "loadMessages") {
|
||||
this.activeSessionId = m.sessionID
|
||||
this.terminalManager.syncOnSessionSwitch(m.sessionID)
|
||||
this.prBridge.poller.setActiveWorktreeId(this.state?.getSession(m.sessionID)?.worktreeId ?? undefined)
|
||||
}
|
||||
|
||||
// After clearSession, clear active tracking and re-register worktree sessions
|
||||
@@ -671,6 +688,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
}
|
||||
// Remove from state BEFORE disk removal so pollers immediately stop targeting this worktree.
|
||||
this.statsPoller.skipWorktree(worktreeId)
|
||||
this.prBridge.remove(worktreeId)
|
||||
const orphaned = state.removeWorktree(worktreeId)
|
||||
if (shouldStopDiffPolling(worktree.path, orphaned, this.cachedDiffTarget, this.diffSessionId)) {
|
||||
this.stopDiffPolling()
|
||||
@@ -679,11 +697,11 @@ export class AgentManagerProvider implements Disposable {
|
||||
this.pushState()
|
||||
// Disk removal after state is clean — pollers no longer reference this worktree.
|
||||
try {
|
||||
await manager.removeWorktree(worktree.path, worktree.branch)
|
||||
await manager.removeWorktree(worktree.path, worktree.originalBranch ?? worktree.branch)
|
||||
} catch (error) {
|
||||
this.log(`Failed to remove worktree from disk: ${error}`)
|
||||
}
|
||||
this.log(`Deleted worktree ${worktreeId} (${worktree.branch})`)
|
||||
this.log(`Deleted worktree ${worktreeId} (${worktree.originalBranch ?? worktree.branch})`)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1428,12 +1446,20 @@ export class AgentManagerProvider implements Disposable {
|
||||
const entries = result.worktrees.filter((item) => ids.has(item.worktreeId))
|
||||
if (entries.length === 0) return
|
||||
|
||||
// Sync branches from git worktree list (no extra git calls)
|
||||
let branchChanged = false
|
||||
for (const entry of entries) {
|
||||
if (entry.branch && state.updateWorktreeBranch(entry.worktreeId, entry.branch)) {
|
||||
branchChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
const next = new Set(entries.filter((entry) => entry.missing).map((entry) => entry.worktreeId))
|
||||
const changed =
|
||||
const staleChanged =
|
||||
next.size !== this.staleWorktreeIds.size || [...next].some((worktreeId) => !this.staleWorktreeIds.has(worktreeId))
|
||||
this.staleWorktreeIds = next
|
||||
|
||||
if (changed) {
|
||||
if (staleChanged || branchChanged) {
|
||||
this.pushState()
|
||||
}
|
||||
}
|
||||
@@ -1464,6 +1490,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
type: "agentManager.state",
|
||||
worktrees,
|
||||
sessions: state.getSessions(),
|
||||
sections: state.getSections(),
|
||||
staleWorktreeIds,
|
||||
tabOrder: state.getTabOrder(),
|
||||
worktreeOrder: state.getWorktreeOrder(),
|
||||
@@ -1474,6 +1501,7 @@ export class AgentManagerProvider implements Disposable {
|
||||
})
|
||||
|
||||
this.statsPoller.setEnabled(worktrees.length > 0 || this.panel !== undefined)
|
||||
this.prBridge.poller.setEnabled(worktrees.length > 0)
|
||||
}
|
||||
|
||||
/** Push empty state when the folder is not a git repo or has no folder open. */
|
||||
@@ -1886,6 +1914,10 @@ export class AgentManagerProvider implements Disposable {
|
||||
)
|
||||
}
|
||||
|
||||
private handleSection(m: AgentManagerInMessage): boolean {
|
||||
return handleSection(this.state, m, () => this.pushState())
|
||||
}
|
||||
|
||||
public postMessage(message: unknown): void {
|
||||
this.panel?.postMessage(message)
|
||||
}
|
||||
@@ -1893,6 +1925,8 @@ export class AgentManagerProvider implements Disposable {
|
||||
public dispose(): void {
|
||||
this.stopDiffPolling()
|
||||
this.statsPoller.stop()
|
||||
this.gitOps.dispose()
|
||||
this.prBridge.poller.stop()
|
||||
this.terminalManager.dispose()
|
||||
this.panel?.dispose()
|
||||
this.outputChannel.dispose()
|
||||
|
||||
@@ -62,19 +62,45 @@ export function nonInteractiveEnv(): NodeJS.ProcessEnv {
|
||||
export class GitOps {
|
||||
private readonly log: (...args: unknown[]) => void
|
||||
private readonly runGit: (args: string[], cwd: string) => Promise<string>
|
||||
private readonly controller = new AbortController()
|
||||
|
||||
get disposed(): boolean {
|
||||
return this.controller.signal.aborted
|
||||
}
|
||||
|
||||
constructor(options: GitOpsOptions) {
|
||||
this.log = options.log
|
||||
this.runGit =
|
||||
options.runGit ??
|
||||
((args, cwd) =>
|
||||
simpleGit(cwd)
|
||||
simpleGit(cwd, { abort: this.controller.signal })
|
||||
.raw(args)
|
||||
.then((out) => out.trim()))
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (!this.controller.signal.aborted) {
|
||||
this.controller.abort()
|
||||
}
|
||||
}
|
||||
|
||||
private raw(args: string[], cwd: string): Promise<string> {
|
||||
return this.runGit(args, cwd)
|
||||
const signal = this.controller.signal
|
||||
if (signal.aborted) return Promise.reject(new Error("GitOps disposed"))
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const onAbort = () => reject(new Error("GitOps disposed"))
|
||||
signal.addEventListener("abort", onAbort, { once: true })
|
||||
this.runGit(args, cwd).then(
|
||||
(value) => {
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
resolve(value)
|
||||
},
|
||||
(err) => {
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
reject(err)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** Return the name of the currently checked-out branch, or `"HEAD"` if detached. */
|
||||
@@ -130,14 +156,14 @@ export class GitOps {
|
||||
}
|
||||
|
||||
/** Return the set of worktree paths for the repo, excluding bare entries. */
|
||||
async listWorktreePaths(cwd: string): Promise<Set<string>> {
|
||||
async listWorktreePaths(cwd: string): Promise<Map<string, string>> {
|
||||
const raw = await this.raw(["worktree", "list", "--porcelain"], cwd)
|
||||
const paths = new Set<string>()
|
||||
const result = new Map<string, string>()
|
||||
for (const entry of parseWorktreeList(raw)) {
|
||||
if (entry.bare) continue
|
||||
paths.add(normalizePath(entry.path))
|
||||
result.set(normalizePath(entry.path), entry.branch)
|
||||
}
|
||||
return paths
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -335,10 +361,14 @@ export class GitOps {
|
||||
}
|
||||
|
||||
private exec(args: string[], cwd: string, options?: ExecOptions): Promise<ExecResult> {
|
||||
if (this.controller.signal.aborted) {
|
||||
return Promise.resolve({ code: 1, stdout: "", stderr: "GitOps disposed" })
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("git", args, {
|
||||
cwd,
|
||||
env: options?.env,
|
||||
signal: this.controller.signal,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
})
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface LocalStats {
|
||||
export interface WorktreePresence {
|
||||
worktreeId: string
|
||||
missing: boolean
|
||||
/** Current branch from `git worktree list`, if available. */
|
||||
branch?: string
|
||||
}
|
||||
|
||||
export interface WorktreePresenceResult {
|
||||
@@ -231,7 +233,8 @@ export class GitStatsPoller {
|
||||
() => false,
|
||||
)
|
||||
const missing = !exists || !tracked.has(normalized)
|
||||
return { worktreeId: wt.id, missing }
|
||||
const branch = tracked.get(normalized)
|
||||
return { worktreeId: wt.id, missing, branch }
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
import type { Worktree } from "./WorktreeStateManager"
|
||||
import type { PRStatus, PRCheck, PRComment, CheckStatus, AggregateCheckStatus, PRState, ReviewDecision } from "./types"
|
||||
import { execWithShellEnv } from "./shell-env"
|
||||
import { classifyPRError } from "./git-import"
|
||||
|
||||
interface PRStatusPollerOptions {
|
||||
getWorktrees: () => Worktree[]
|
||||
getWorkspaceRoot: () => string | undefined
|
||||
onStatus: (worktreeId: string, pr: PRStatus | null, error?: "gh_missing" | "gh_auth" | "fetch_failed") => void
|
||||
log: (...args: unknown[]) => void
|
||||
intervalMs?: number
|
||||
}
|
||||
|
||||
const GH_PROBE_TTL = 300_000 // 5 minutes — gh installation state rarely changes at runtime
|
||||
const MAX_BACKOFF = 120_000 // 2 minutes — cap for exponential backoff on repeated errors
|
||||
const BACKOFF_MULTIPLIER = 2
|
||||
|
||||
export class PRStatusPoller {
|
||||
private timer: ReturnType<typeof setTimeout> | undefined
|
||||
private active = false
|
||||
private visible = true
|
||||
private busy = false
|
||||
private lastHash = new Map<string, string>()
|
||||
private lastError: string | undefined // tracks global error state for de-duplication
|
||||
private failures = 0 // consecutive failure count for backoff
|
||||
private ghAvailable: boolean | undefined
|
||||
private ghProbeTime = 0
|
||||
private activeWorktreeId: string | undefined
|
||||
private cachedRepo: { owner: string; name: string; cwd: string } | undefined
|
||||
private readonly intervalMs: number
|
||||
|
||||
constructor(private readonly options: PRStatusPollerOptions) {
|
||||
this.intervalMs = options.intervalMs ?? 15_000
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean): void {
|
||||
if (enabled) {
|
||||
if (this.active) return
|
||||
this.start()
|
||||
return
|
||||
}
|
||||
this.stop()
|
||||
}
|
||||
|
||||
/** Pause/resume polling based on panel visibility. */
|
||||
setVisible(visible: boolean): void {
|
||||
if (this.visible === visible) return
|
||||
this.visible = visible
|
||||
if (!this.active) return
|
||||
if (visible) {
|
||||
// Resume — poll immediately then schedule normally
|
||||
if (this.timer) clearTimeout(this.timer)
|
||||
this.timer = undefined
|
||||
void this.poll()
|
||||
return
|
||||
}
|
||||
// Pause — cancel pending timer
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.active = false
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = undefined
|
||||
}
|
||||
this.busy = false
|
||||
this.lastHash.clear()
|
||||
this.lastError = undefined
|
||||
this.failures = 0
|
||||
this.ghAvailable = undefined
|
||||
this.ghProbeTime = 0
|
||||
this.cachedRepo = undefined
|
||||
}
|
||||
|
||||
/** Force-refresh a specific worktree immediately. */
|
||||
refresh(worktreeId: string): void {
|
||||
if (!this.active) return
|
||||
void this.fetchOne(worktreeId)
|
||||
}
|
||||
|
||||
setActiveWorktreeId(id: string | undefined): void {
|
||||
this.activeWorktreeId = id
|
||||
}
|
||||
|
||||
private start(): void {
|
||||
this.stop()
|
||||
this.active = true
|
||||
// Don't override this.visible — it may already be set to false by
|
||||
// setVisible() before setEnabled(true) is called.
|
||||
void this.poll()
|
||||
}
|
||||
|
||||
private nextDelay(): number {
|
||||
if (this.failures === 0) return this.intervalMs
|
||||
return Math.min(this.intervalMs * Math.pow(BACKOFF_MULTIPLIER, this.failures), MAX_BACKOFF)
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (!this.active || !this.visible) return
|
||||
const delay = this.nextDelay()
|
||||
this.timer = setTimeout(() => {
|
||||
void this.poll()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private poll(): Promise<void> {
|
||||
if (!this.active || !this.visible) return Promise.resolve()
|
||||
if (this.busy) return Promise.resolve()
|
||||
this.busy = true
|
||||
return this.fetchAll().finally(() => {
|
||||
this.busy = false
|
||||
this.schedule()
|
||||
})
|
||||
}
|
||||
|
||||
private async probeGh(): Promise<boolean> {
|
||||
const now = Date.now()
|
||||
if (this.ghAvailable !== undefined && now - this.ghProbeTime < GH_PROBE_TTL) {
|
||||
return this.ghAvailable
|
||||
}
|
||||
try {
|
||||
await execWithShellEnv("gh", ["--version"], { timeout: 5_000 })
|
||||
this.ghAvailable = true
|
||||
} catch {
|
||||
this.ghAvailable = false
|
||||
}
|
||||
this.ghProbeTime = Date.now()
|
||||
return this.ghAvailable
|
||||
}
|
||||
|
||||
private async fetchAll(): Promise<void> {
|
||||
if (!(await this.probeGh())) {
|
||||
// De-duplicate: only emit gh_missing once, not every poll cycle
|
||||
if (this.lastError !== "gh_missing") {
|
||||
this.lastError = "gh_missing"
|
||||
for (const wt of this.options.getWorktrees()) {
|
||||
this.options.onStatus(wt.id, null, "gh_missing")
|
||||
}
|
||||
}
|
||||
this.failures++
|
||||
return
|
||||
}
|
||||
|
||||
this.lastError = undefined
|
||||
const worktrees = this.options.getWorktrees()
|
||||
const results = await Promise.allSettled(worktrees.map((wt) => this.fetchOne(wt.id)))
|
||||
const ok = results.every((r) => r.status === "fulfilled")
|
||||
if (ok) {
|
||||
this.failures = 0
|
||||
return
|
||||
}
|
||||
this.failures++
|
||||
}
|
||||
|
||||
private async fetchOne(worktreeId: string): Promise<void> {
|
||||
const worktrees = this.options.getWorktrees()
|
||||
const wt = worktrees.find((w) => w.id === worktreeId)
|
||||
if (!wt) return
|
||||
|
||||
if (!this.options.getWorkspaceRoot()) return
|
||||
|
||||
try {
|
||||
const pr = await this.fetchPRForBranch(wt.branch, wt.path)
|
||||
if (!pr) {
|
||||
const hash = `${worktreeId}:none`
|
||||
if (this.lastHash.get(worktreeId) === hash) return
|
||||
this.lastHash.set(worktreeId, hash)
|
||||
this.options.onStatus(worktreeId, null)
|
||||
return
|
||||
}
|
||||
|
||||
const [checks, comments] = await Promise.all([
|
||||
this.fetchChecks(pr.number, wt.path),
|
||||
this.activeWorktreeId === worktreeId ? this.fetchComments(pr.number, wt.path) : undefined,
|
||||
])
|
||||
|
||||
const status: PRStatus = {
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
url: pr.url,
|
||||
state: pr.state,
|
||||
review: pr.review,
|
||||
checks,
|
||||
...(comments && { comments }),
|
||||
additions: pr.additions,
|
||||
deletions: pr.deletions,
|
||||
files: pr.files,
|
||||
}
|
||||
|
||||
const hash = `${worktreeId}:${pr.number}:${pr.state}:${pr.review}:${checks.status}:${checks.passed}/${checks.total}:${comments?.total ?? ""}:${comments?.unresolved ?? ""}`
|
||||
if (this.lastHash.get(worktreeId) === hash) return
|
||||
this.lastHash.set(worktreeId, hash)
|
||||
|
||||
this.options.onStatus(worktreeId, status)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
const kind = classifyPRError(msg)
|
||||
this.options.log(`PR fetch failed for ${wt.branch}:`, msg)
|
||||
|
||||
const errKey = kind === "gh_missing" ? "gh_missing" : kind === "gh_auth" ? "gh_auth" : "fetch_failed"
|
||||
if (kind === "gh_missing") this.ghAvailable = false
|
||||
|
||||
// De-duplicate: only emit if the error state changed for this worktree
|
||||
const hash = `${worktreeId}:error:${errKey}`
|
||||
if (this.lastHash.get(worktreeId) !== hash) {
|
||||
this.lastHash.set(worktreeId, hash)
|
||||
this.options.onStatus(worktreeId, null, errKey)
|
||||
}
|
||||
throw err // propagate so fetchAll can track failures for backoff
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly PR_JSON_FIELDS =
|
||||
"number,title,url,state,isDraft,reviewDecision,additions,deletions,changedFiles,headRefName,headRefOid"
|
||||
|
||||
private async fetchPRForBranch(branch: string, cwd: string): Promise<PRResult | null> {
|
||||
// Strategy 1: bare `gh pr view` — resolves via the branch's tracking ref.
|
||||
// Works for fork PRs checked out with `gh pr checkout` (tracking ref = refs/pull/N/head).
|
||||
// Strategy 2: `gh pr view <branch>` — works for same-repo branches pushed to origin.
|
||||
// Strategy 3: `gh pr list --search "<sha>"` — last resort, finds PRs by HEAD commit SHA.
|
||||
return (await this.ghPRView(cwd)) ?? (await this.ghPRView(cwd, branch)) ?? (await this.ghPRListBySHA(cwd))
|
||||
}
|
||||
|
||||
/** Run `gh pr view [branch] --json ...` and parse the result, or return null. */
|
||||
private async ghPRView(cwd: string, branch?: string): Promise<PRResult | null> {
|
||||
try {
|
||||
const args = ["pr", "view"]
|
||||
if (branch) args.push(branch)
|
||||
args.push("--json", PRStatusPoller.PR_JSON_FIELDS)
|
||||
|
||||
const { stdout } = await execWithShellEnv("gh", args, { cwd, timeout: 15_000 })
|
||||
return parsePRResult(stdout)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
if (msg.includes("no pull requests found") || msg.includes("Could not resolve")) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** Search for PRs containing the current HEAD SHA. Finds PRs when branch name/tracking ref don't match. */
|
||||
private async ghPRListBySHA(cwd: string): Promise<PRResult | null> {
|
||||
try {
|
||||
const { stdout: sha } = await execWithShellEnv("git", ["rev-parse", "HEAD"], { cwd, timeout: 5_000 })
|
||||
const head = sha.trim()
|
||||
if (!head) return null
|
||||
|
||||
const { stdout } = await execWithShellEnv(
|
||||
"gh",
|
||||
[
|
||||
"pr",
|
||||
"list",
|
||||
"--state",
|
||||
"all",
|
||||
"--search",
|
||||
`${head} is:pr`,
|
||||
"--limit",
|
||||
"5",
|
||||
"--json",
|
||||
PRStatusPoller.PR_JSON_FIELDS,
|
||||
],
|
||||
{ cwd, timeout: 15_000 },
|
||||
)
|
||||
const items = JSON.parse(stdout) as unknown[]
|
||||
if (!Array.isArray(items) || items.length === 0) return null
|
||||
|
||||
// Only accept PRs where headRefOid matches our HEAD exactly
|
||||
for (const item of items) {
|
||||
const data = item as Record<string, unknown>
|
||||
if (data.headRefOid === head) return parsePRResult(JSON.stringify(data))
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchChecks(
|
||||
prNumber: number,
|
||||
cwd: string,
|
||||
): Promise<{
|
||||
status: AggregateCheckStatus
|
||||
total: number
|
||||
passed: number
|
||||
failed: number
|
||||
pending: number
|
||||
items: PRCheck[]
|
||||
}> {
|
||||
try {
|
||||
const { stdout } = await execWithShellEnv(
|
||||
"gh",
|
||||
["pr", "checks", String(prNumber), "--json", "name,state,link,startedAt,completedAt"],
|
||||
{ cwd, timeout: 15_000 },
|
||||
)
|
||||
const data = JSON.parse(stdout) as Array<{
|
||||
name: string
|
||||
state: string
|
||||
link?: string
|
||||
startedAt?: string
|
||||
completedAt?: string
|
||||
}>
|
||||
|
||||
const items: PRCheck[] = data.map((c) => ({
|
||||
name: c.name,
|
||||
status: mapCheckStatus(c.state),
|
||||
url: c.link,
|
||||
duration: formatCheckDuration(c.startedAt, c.completedAt),
|
||||
}))
|
||||
|
||||
const total = items.length
|
||||
const passed = items.filter((c) => c.status === "success").length
|
||||
const failed = items.filter((c) => c.status === "failure").length
|
||||
const pending = items.filter((c) => c.status === "pending").length
|
||||
|
||||
const status: AggregateCheckStatus =
|
||||
total === 0 ? "none" : failed > 0 ? "failure" : pending > 0 ? "pending" : "success"
|
||||
|
||||
return { status, total, passed, failed, pending, items }
|
||||
} catch {
|
||||
return { status: "none", total: 0, passed: 0, failed: 0, pending: 0, items: [] }
|
||||
}
|
||||
}
|
||||
|
||||
private async getRepoInfo(cwd: string): Promise<{ owner: string; name: string }> {
|
||||
if (this.cachedRepo && this.cachedRepo.cwd === cwd) {
|
||||
return this.cachedRepo
|
||||
}
|
||||
const { stdout } = await execWithShellEnv("gh", ["repo", "view", "--json", "owner,name"], {
|
||||
cwd,
|
||||
timeout: 10_000,
|
||||
})
|
||||
const data = JSON.parse(stdout)
|
||||
const info = { owner: data.owner.login as string, name: data.name as string, cwd }
|
||||
this.cachedRepo = info
|
||||
return info
|
||||
}
|
||||
|
||||
private async fetchComments(
|
||||
prNumber: number,
|
||||
cwd: string,
|
||||
): Promise<{ total: number; unresolved: number; items: PRComment[] }> {
|
||||
try {
|
||||
const repo = await this.getRepoInfo(cwd)
|
||||
|
||||
const query = `query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
reviewThreads(first: 100) {
|
||||
nodes {
|
||||
isResolved
|
||||
comments(first: 1) {
|
||||
nodes {
|
||||
id
|
||||
author { login avatarUrl }
|
||||
body
|
||||
path
|
||||
line
|
||||
url
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
const { stdout } = await execWithShellEnv(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
"graphql",
|
||||
"-f",
|
||||
`query=${query}`,
|
||||
"-F",
|
||||
`owner=${repo.owner}`,
|
||||
"-F",
|
||||
`repo=${repo.name}`,
|
||||
"-F",
|
||||
`number=${prNumber}`,
|
||||
],
|
||||
{ cwd, timeout: 15_000 },
|
||||
)
|
||||
const result = JSON.parse(stdout)
|
||||
const threads = result?.data?.repository?.pullRequest?.reviewThreads?.nodes ?? []
|
||||
|
||||
const items: PRComment[] = []
|
||||
for (const thread of threads) {
|
||||
const first = thread.comments?.nodes?.[0]
|
||||
if (!first) continue
|
||||
items.push({
|
||||
id: first.id,
|
||||
author: first.author?.login ?? "unknown",
|
||||
avatar: first.author?.avatarUrl,
|
||||
body: first.body ?? "",
|
||||
file: first.path,
|
||||
line: first.line,
|
||||
url: first.url,
|
||||
resolved: thread.isResolved ?? false,
|
||||
createdAt: first.createdAt ? new Date(first.createdAt).getTime() : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const total = items.length
|
||||
const unresolved = items.filter((c) => !c.resolved).length
|
||||
return { total, unresolved, items }
|
||||
} catch (err) {
|
||||
this.options.log("Failed to fetch PR comments:", err)
|
||||
return { total: 0, unresolved: 0, items: [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface PRResult {
|
||||
number: number
|
||||
title: string
|
||||
url: string
|
||||
state: PRState
|
||||
review: ReviewDecision | null
|
||||
additions: number
|
||||
deletions: number
|
||||
files: number
|
||||
}
|
||||
|
||||
function parsePRResult(json: string): PRResult | null {
|
||||
const data = JSON.parse(json)
|
||||
if (!data.number) return null
|
||||
return {
|
||||
number: data.number,
|
||||
title: data.title ?? "",
|
||||
url: data.url ?? "",
|
||||
state: parsePRState(data.isDraft, data.state),
|
||||
review: parseReviewDecision(data.reviewDecision),
|
||||
additions: data.additions ?? 0,
|
||||
deletions: data.deletions ?? 0,
|
||||
files: data.changedFiles ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
function parsePRState(isDraft: boolean, ghState: string): PRState {
|
||||
if (isDraft) return "draft"
|
||||
if (ghState === "MERGED") return "merged"
|
||||
if (ghState === "CLOSED") return "closed"
|
||||
return "open"
|
||||
}
|
||||
|
||||
function parseReviewDecision(decision: string | undefined): ReviewDecision | null {
|
||||
if (decision === "APPROVED") return "approved"
|
||||
if (decision === "CHANGES_REQUESTED") return "changes_requested"
|
||||
if (decision === "REVIEW_REQUIRED") return "pending"
|
||||
return null
|
||||
}
|
||||
|
||||
function mapCheckStatus(state: string): CheckStatus {
|
||||
switch (state.toUpperCase()) {
|
||||
case "SUCCESS":
|
||||
return "success"
|
||||
case "FAILURE":
|
||||
case "ERROR":
|
||||
return "failure"
|
||||
case "PENDING":
|
||||
case "QUEUED":
|
||||
case "IN_PROGRESS":
|
||||
case "REQUESTED":
|
||||
case "WAITING":
|
||||
return "pending"
|
||||
case "SKIPPED":
|
||||
return "skipped"
|
||||
case "CANCELLED":
|
||||
case "TIMED_OUT":
|
||||
case "STALE":
|
||||
case "STARTUP_FAILURE":
|
||||
return "cancelled"
|
||||
default:
|
||||
return "pending"
|
||||
}
|
||||
}
|
||||
|
||||
function formatCheckDuration(startedAt?: string, completedAt?: string): string | undefined {
|
||||
if (!startedAt || !completedAt) return undefined
|
||||
const secs = Math.round((new Date(completedAt).getTime() - new Date(startedAt).getTime()) / 1000)
|
||||
return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`
|
||||
}
|
||||
@@ -26,6 +26,27 @@ export interface Worktree {
|
||||
groupId?: string
|
||||
/** User-provided display name for the worktree. */
|
||||
label?: string
|
||||
/** Cached PR number for instant badge display on reload. */
|
||||
prNumber?: number
|
||||
/** Cached PR URL for instant badge display on reload. */
|
||||
prUrl?: string
|
||||
/** Cached PR state for correct badge color on reload (open/merged/closed/draft). */
|
||||
prState?: string
|
||||
/** Original branch created with the worktree, used for cleanup on deletion.
|
||||
* Set automatically when `branch` is updated via live sync. */
|
||||
originalBranch?: string
|
||||
/** Section this worktree belongs to, or undefined for ungrouped. */
|
||||
sectionId?: string
|
||||
}
|
||||
|
||||
export interface Section {
|
||||
id: string
|
||||
name: string
|
||||
/** Color label (e.g. "Red", "Blue") mapped to VS Code theme CSS vars at render time, or null for default. */
|
||||
color: string | null
|
||||
/** Position among top-level sidebar children (interleaved with ungrouped worktrees). */
|
||||
order: number
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,6 +67,7 @@ export interface ManagedSession {
|
||||
interface StateFile {
|
||||
worktrees: Record<string, Omit<Worktree, "id">>
|
||||
sessions: Record<string, Omit<ManagedSession, "id">>
|
||||
sections?: Record<string, Omit<Section, "id">>
|
||||
tabOrder?: Record<string, string[]>
|
||||
worktreeOrder?: string[]
|
||||
sessionsCollapsed?: boolean
|
||||
@@ -67,6 +89,7 @@ export class WorktreeStateManager {
|
||||
private readonly file: string
|
||||
private worktrees = new Map<string, Worktree>()
|
||||
private sessions = new Map<string, ManagedSession>()
|
||||
private sections = new Map<string, Section>()
|
||||
private tabOrder: Record<string, string[]> = {}
|
||||
private worktreeOrder: string[] = []
|
||||
private collapsed = false
|
||||
@@ -163,6 +186,16 @@ export class WorktreeStateManager {
|
||||
return wt
|
||||
}
|
||||
|
||||
updateWorktreeBranch(id: string, branch: string): boolean {
|
||||
const wt = this.worktrees.get(id)
|
||||
if (!wt || wt.branch === branch) return false
|
||||
if (!wt.originalBranch) wt.originalBranch = wt.branch
|
||||
this.log(`Updated worktree ${id} branch: ${wt.branch} → ${branch}`)
|
||||
wt.branch = branch
|
||||
void this.save()
|
||||
return true
|
||||
}
|
||||
|
||||
updateWorktreeLabel(id: string, label: string): void {
|
||||
const wt = this.worktrees.get(id)
|
||||
if (!wt) return
|
||||
@@ -171,6 +204,16 @@ export class WorktreeStateManager {
|
||||
void this.save()
|
||||
}
|
||||
|
||||
updateWorktreePR(id: string, prNumber?: number, prUrl?: string, prState?: string): void {
|
||||
const wt = this.worktrees.get(id)
|
||||
if (!wt) return
|
||||
if (wt.prNumber === prNumber && wt.prUrl === prUrl && wt.prState === prState) return
|
||||
wt.prNumber = prNumber
|
||||
wt.prUrl = prUrl
|
||||
wt.prState = prState
|
||||
void this.save()
|
||||
}
|
||||
|
||||
removeWorktree(id: string): ManagedSession[] {
|
||||
const removed = this.worktrees.delete(id)
|
||||
if (!removed) return []
|
||||
@@ -255,7 +298,128 @@ export class WorktreeStateManager {
|
||||
}
|
||||
|
||||
setWorktreeOrder(order: string[]): void {
|
||||
this.worktreeOrder = order
|
||||
const top = new Set<string>()
|
||||
for (const sec of this.sections.values()) top.add(sec.id)
|
||||
for (const wt of this.worktrees.values()) {
|
||||
if (!wt.sectionId) top.add(wt.id)
|
||||
}
|
||||
this.worktreeOrder = order.filter((id) => top.has(id))
|
||||
void this.save()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sections
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getSections(): Section[] {
|
||||
return [...this.sections.values()]
|
||||
}
|
||||
|
||||
getSection(id: string): Section | undefined {
|
||||
return this.sections.get(id)
|
||||
}
|
||||
|
||||
addSection(name: string, color: string | null, worktreeIds?: string[]): Section {
|
||||
const id = generateId("sec")
|
||||
const order = this.worktreeOrder.length
|
||||
const sec: Section = { id, name, color, order, collapsed: false }
|
||||
this.sections.set(id, sec)
|
||||
this.worktreeOrder.push(id)
|
||||
if (worktreeIds) {
|
||||
for (const wtId of worktreeIds) {
|
||||
const wt = this.worktrees.get(wtId)
|
||||
if (wt) {
|
||||
wt.sectionId = id
|
||||
// Remove from top-level worktreeOrder since it's now inside a section
|
||||
const idx = this.worktreeOrder.indexOf(wtId)
|
||||
if (idx !== -1) this.worktreeOrder.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
this.log(`Added section ${id}: "${name}"`)
|
||||
void this.save()
|
||||
return sec
|
||||
}
|
||||
|
||||
renameSection(id: string, name: string): void {
|
||||
const sec = this.sections.get(id)
|
||||
if (!sec || !name) return
|
||||
sec.name = name
|
||||
this.log(`Renamed section ${id} to "${name}"`)
|
||||
void this.save()
|
||||
}
|
||||
|
||||
setSectionColor(id: string, color: string | null): void {
|
||||
const sec = this.sections.get(id)
|
||||
if (!sec) return
|
||||
sec.color = color
|
||||
void this.save()
|
||||
}
|
||||
|
||||
toggleSection(id: string): void {
|
||||
const sec = this.sections.get(id)
|
||||
if (!sec) return
|
||||
sec.collapsed = !sec.collapsed
|
||||
void this.save()
|
||||
}
|
||||
|
||||
deleteSection(id: string): void {
|
||||
if (!this.sections.delete(id)) return
|
||||
// Ungroup all worktrees in this section — do NOT delete them
|
||||
for (const wt of this.worktrees.values()) {
|
||||
if (wt.sectionId === id) {
|
||||
wt.sectionId = undefined
|
||||
if (!this.worktreeOrder.includes(wt.id)) this.worktreeOrder.push(wt.id)
|
||||
}
|
||||
}
|
||||
// Remove from sidebar order
|
||||
const idx = this.worktreeOrder.indexOf(id)
|
||||
if (idx !== -1) this.worktreeOrder.splice(idx, 1)
|
||||
this.log(`Deleted section ${id}, ungrouped its worktrees`)
|
||||
void this.save()
|
||||
}
|
||||
|
||||
moveSection(id: string, dir: -1 | 1): void {
|
||||
const top = this.worktreeOrder.filter((item) => {
|
||||
if (this.sections.has(item)) return true
|
||||
const wt = this.worktrees.get(item)
|
||||
return !!wt && !wt.sectionId
|
||||
})
|
||||
const idx = top.indexOf(id)
|
||||
const next = idx + dir
|
||||
if (idx === -1 || next < 0 || next >= top.length) return
|
||||
const target = top[next]!
|
||||
const result = [...this.worktreeOrder]
|
||||
const fi = result.indexOf(id)
|
||||
if (fi === -1 || result.indexOf(target) === -1) return
|
||||
result.splice(fi, 1)
|
||||
const insertAt = result.indexOf(target) + (dir === 1 ? 1 : 0)
|
||||
result.splice(insertAt, 0, id)
|
||||
this.worktreeOrder = result
|
||||
void this.save()
|
||||
}
|
||||
|
||||
moveToSection(worktreeIds: string[], sectionId: string | null): void {
|
||||
// Expand to include all multi-version siblings (same groupId)
|
||||
const expanded = new Set(worktreeIds)
|
||||
for (const wtId of worktreeIds) {
|
||||
const wt = this.worktrees.get(wtId)
|
||||
if (!wt?.groupId) continue
|
||||
for (const sibling of this.worktrees.values()) {
|
||||
if (sibling.groupId === wt.groupId) expanded.add(sibling.id)
|
||||
}
|
||||
}
|
||||
for (const wtId of expanded) {
|
||||
const wt = this.worktrees.get(wtId)
|
||||
if (!wt) continue
|
||||
wt.sectionId = sectionId ?? undefined
|
||||
if (sectionId) {
|
||||
const idx = this.worktreeOrder.indexOf(wtId)
|
||||
if (idx !== -1) this.worktreeOrder.splice(idx, 1)
|
||||
} else {
|
||||
if (!this.worktreeOrder.includes(wtId)) this.worktreeOrder.push(wtId)
|
||||
}
|
||||
}
|
||||
void this.save()
|
||||
}
|
||||
|
||||
@@ -314,6 +478,7 @@ export class WorktreeStateManager {
|
||||
const data = JSON.parse(content) as StateFile
|
||||
this.worktrees.clear()
|
||||
this.sessions.clear()
|
||||
this.sections.clear()
|
||||
this.tabOrder = {}
|
||||
this.worktreeOrder = []
|
||||
this.reviewDiffStyle = "unified"
|
||||
@@ -329,12 +494,23 @@ export class WorktreeStateManager {
|
||||
for (const [id, s] of Object.entries(data.sessions ?? {})) {
|
||||
this.sessions.set(id, { id, ...s })
|
||||
}
|
||||
for (const [id, sec] of Object.entries(data.sections ?? {})) {
|
||||
this.sections.set(id, { id, ...sec })
|
||||
}
|
||||
if (data.tabOrder) {
|
||||
this.tabOrder = data.tabOrder
|
||||
}
|
||||
if (data.worktreeOrder) {
|
||||
this.worktreeOrder = data.worktreeOrder
|
||||
}
|
||||
// Normalize: ensure all section IDs and ungrouped worktree IDs are in worktreeOrder
|
||||
const present = new Set(this.worktreeOrder)
|
||||
for (const id of this.sections.keys()) {
|
||||
if (!present.has(id)) this.worktreeOrder.push(id)
|
||||
}
|
||||
for (const wt of this.worktrees.values()) {
|
||||
if (!wt.sectionId && !present.has(wt.id)) this.worktreeOrder.push(wt.id)
|
||||
}
|
||||
this.collapsed = data.sessionsCollapsed ?? false
|
||||
if (data.reviewDiffStyle === "split") {
|
||||
this.reviewDiffStyle = "split"
|
||||
@@ -404,6 +580,13 @@ export class WorktreeStateManager {
|
||||
const { id: _, ...rest } = s
|
||||
data.sessions[id] = rest
|
||||
}
|
||||
if (this.sections.size > 0) {
|
||||
data.sections = {}
|
||||
for (const [id, sec] of this.sections) {
|
||||
const { id: _, ...rest } = sec
|
||||
data.sections[id] = rest
|
||||
}
|
||||
}
|
||||
if (Object.keys(this.tabOrder).length > 0) {
|
||||
data.tabOrder = this.tabOrder
|
||||
}
|
||||
|
||||
@@ -56,9 +56,15 @@ export interface PanelContext {
|
||||
/** Whether the panel is currently the active tab. */
|
||||
readonly active: boolean
|
||||
|
||||
/** Whether the panel is visible (may be unfocused in a split editor group). */
|
||||
readonly visible: boolean
|
||||
|
||||
/** Session provider wired to this panel. */
|
||||
readonly sessions: SessionProvider
|
||||
|
||||
/** Register a callback for when panel visibility changes. */
|
||||
onDidChangeVisibility(cb: (visible: boolean) => void): Disposable
|
||||
|
||||
/** Register a callback for when the panel is disposed. */
|
||||
onDidDispose(cb: () => void): Disposable
|
||||
|
||||
@@ -107,6 +113,9 @@ export interface Host {
|
||||
/** Capture a telemetry event. */
|
||||
capture(event: string, properties?: Record<string, unknown>): void
|
||||
|
||||
/** Open a URL in the user's default browser. */
|
||||
openExternal(url: string): void
|
||||
|
||||
/** Ask VS Code's git extension to re-scan repositories (e.g. after worktree ref migration). */
|
||||
refreshGit(): void
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Bridges the PRStatusPoller with the AgentManagerProvider.
|
||||
*
|
||||
* Owns the poller instance, the cached PR messages, and all message/panel handling
|
||||
* so the provider only needs thin delegation calls.
|
||||
*/
|
||||
import type { Worktree } from "./WorktreeStateManager"
|
||||
import type { AgentManagerOutMessage, PRStatus } from "./types"
|
||||
import type { Disposable } from "./host"
|
||||
import { PRStatusPoller } from "./PRStatusPoller"
|
||||
|
||||
interface PRBridgeHost {
|
||||
getWorktrees(): Worktree[]
|
||||
getWorkspaceRoot(): string | undefined
|
||||
postToWebview(msg: AgentManagerOutMessage): void
|
||||
updateWorktreePR(id: string, number?: number, url?: string, state?: string): void
|
||||
hasPersistedPR(id: string): boolean
|
||||
openExternal(url: string): void
|
||||
log(...args: unknown[]): void
|
||||
}
|
||||
|
||||
/** Minimal panel surface needed by the bridge (subset of PanelContext). */
|
||||
interface PanelLike {
|
||||
readonly visible: boolean
|
||||
onDidChangeVisibility(cb: (visible: boolean) => void): Disposable
|
||||
}
|
||||
|
||||
export class PRStatusBridge {
|
||||
readonly poller: PRStatusPoller
|
||||
private readonly cache = new Map<string, AgentManagerOutMessage>()
|
||||
private readonly host: PRBridgeHost
|
||||
|
||||
constructor(host: PRBridgeHost) {
|
||||
this.host = host
|
||||
this.poller = new PRStatusPoller(bridgePollerOpts(this, host))
|
||||
}
|
||||
|
||||
static create(opts: {
|
||||
getWorktrees: () => Worktree[]
|
||||
getWorkspaceRoot: () => string | undefined
|
||||
postToWebview: (msg: AgentManagerOutMessage) => void
|
||||
updateWorktreePR: (id: string, n?: number, u?: string, s?: string) => void
|
||||
hasPersistedPR: (id: string) => boolean
|
||||
openExternal: (url: string) => void
|
||||
log: (...args: unknown[]) => void
|
||||
}): PRStatusBridge {
|
||||
return new PRStatusBridge(opts)
|
||||
}
|
||||
|
||||
/** Wire visibility tracking to a panel — pauses polling when hidden. */
|
||||
attachPanel(panel: PanelLike): void {
|
||||
this.poller.setVisible(panel.visible)
|
||||
panel.onDidChangeVisibility((v) => {
|
||||
this.poller.setVisible(v)
|
||||
})
|
||||
}
|
||||
|
||||
/** Replay cached PR statuses to a freshly-connected webview. */
|
||||
replay(): void {
|
||||
this.cache.forEach((msg) => this.host.postToWebview(msg))
|
||||
}
|
||||
|
||||
/** Handle an incoming webview message. Returns true if handled. */
|
||||
handleMessage(m: Record<string, unknown>): boolean {
|
||||
if (m.type === "agentManager.refreshPR") {
|
||||
this.poller.refresh(m.worktreeId as string)
|
||||
return true
|
||||
}
|
||||
if (m.type === "agentManager.openPR") {
|
||||
const wt = this.host.getWorktrees().find((w: Worktree) => w.id === m.worktreeId)
|
||||
if (wt?.prUrl) this.host.openExternal(wt.prUrl)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Remove cached status for a deleted worktree. */
|
||||
remove(worktreeId: string): void {
|
||||
this.cache.delete(worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Build PRStatusPoller options that forward events through the bridge cache. */
|
||||
function bridgePollerOpts(bridge: PRStatusBridge, host: PRBridgeHost) {
|
||||
return {
|
||||
getWorktrees: () => host.getWorktrees(),
|
||||
getWorkspaceRoot: () => host.getWorkspaceRoot(),
|
||||
onStatus: (id: string, pr: PRStatus | null, err?: "gh_missing" | "gh_auth" | "fetch_failed") => {
|
||||
if (err) {
|
||||
// Don't forward errors to the webview when we have prior PR data
|
||||
// (in-memory cache or persisted prNumber) — that would overwrite
|
||||
// the live badge with pr:null. Only forward when there's truly no
|
||||
// prior data (first poll failed, nothing persisted).
|
||||
if (!bridge["cache"].has(id) && !host.hasPersistedPR(id))
|
||||
host.postToWebview({
|
||||
type: "agentManager.prStatus",
|
||||
worktreeId: id,
|
||||
pr: null,
|
||||
error: err,
|
||||
} as AgentManagerOutMessage)
|
||||
return
|
||||
}
|
||||
const msg = { type: "agentManager.prStatus", worktreeId: id, pr, error: err } as AgentManagerOutMessage
|
||||
bridge["cache"].set(id, msg)
|
||||
host.postToWebview(msg)
|
||||
host.updateWorktreePR(id, pr?.number, pr?.url, pr?.state)
|
||||
},
|
||||
log: (...args: unknown[]) => host.log(...args),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { WorktreeStateManager } from "./WorktreeStateManager"
|
||||
import type { AgentManagerInMessage } from "./types"
|
||||
|
||||
/** Handle section CRUD messages. Returns true if handled. */
|
||||
export function handleSection(
|
||||
state: WorktreeStateManager | undefined,
|
||||
m: AgentManagerInMessage,
|
||||
push: () => void,
|
||||
): boolean {
|
||||
if (!state) return false
|
||||
if (m.type === "agentManager.createSection") state.addSection(m.name, m.color ?? null, m.worktreeIds)
|
||||
else if (m.type === "agentManager.renameSection") state.renameSection(m.sectionId, m.name)
|
||||
else if (m.type === "agentManager.deleteSection") state.deleteSection(m.sectionId)
|
||||
else if (m.type === "agentManager.setSectionColor") state.setSectionColor(m.sectionId, m.color)
|
||||
else if (m.type === "agentManager.toggleSectionCollapsed") state.toggleSection(m.sectionId)
|
||||
else if (m.type === "agentManager.moveToSection") state.moveToSection(m.worktreeIds, m.sectionId)
|
||||
else if (m.type === "agentManager.moveSection") state.moveSection(m.sectionId, m.dir)
|
||||
else return false
|
||||
push()
|
||||
return true
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import type { FileDiff } from "@kilocode/sdk/v2/client"
|
||||
import type { Worktree, ManagedSession } from "./WorktreeStateManager"
|
||||
import type { Worktree, ManagedSession, Section } from "./WorktreeStateManager"
|
||||
import type { WorktreeStats, LocalStats } from "./GitStatsPoller"
|
||||
import type { ApplyConflict } from "./GitOps"
|
||||
import type { BranchListItem, WorktreeSetupErrorCode } from "./git-import"
|
||||
@@ -29,6 +29,58 @@ export type WorktreeDiffEntry = FileDiff & {
|
||||
stamp?: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PR status types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PRState = "open" | "draft" | "merged" | "closed"
|
||||
export type ReviewDecision = "approved" | "changes_requested" | "pending"
|
||||
export type CheckStatus = "success" | "failure" | "pending" | "skipped" | "cancelled"
|
||||
export type AggregateCheckStatus = "success" | "failure" | "pending" | "none"
|
||||
|
||||
export interface PRCheck {
|
||||
name: string
|
||||
status: CheckStatus
|
||||
url?: string
|
||||
duration?: string
|
||||
}
|
||||
|
||||
export interface PRComment {
|
||||
id: string
|
||||
author: string
|
||||
avatar?: string
|
||||
body: string
|
||||
file?: string
|
||||
line?: number
|
||||
url?: string
|
||||
resolved: boolean
|
||||
createdAt?: number
|
||||
}
|
||||
|
||||
export interface PRStatus {
|
||||
number: number
|
||||
title: string
|
||||
url: string
|
||||
state: PRState
|
||||
review: ReviewDecision | null
|
||||
checks: {
|
||||
status: AggregateCheckStatus
|
||||
total: number
|
||||
passed: number
|
||||
failed: number
|
||||
pending: number
|
||||
items: PRCheck[]
|
||||
}
|
||||
comments?: {
|
||||
total: number
|
||||
unresolved: number
|
||||
items: PRComment[]
|
||||
}
|
||||
additions: number
|
||||
deletions: number
|
||||
files: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extension → Webview messages (postToWebview)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -66,6 +118,7 @@ interface StateMessage {
|
||||
type: "agentManager.state"
|
||||
worktrees: Worktree[]
|
||||
sessions: ManagedSession[]
|
||||
sections?: Section[]
|
||||
staleWorktreeIds?: string[]
|
||||
tabOrder?: Record<string, string[]>
|
||||
worktreeOrder?: string[]
|
||||
@@ -175,6 +228,13 @@ interface WorktreeDiffFileMessage {
|
||||
diff: WorktreeDiffEntry | null
|
||||
}
|
||||
|
||||
interface PRStatusOutMessage {
|
||||
type: "agentManager.prStatus"
|
||||
worktreeId: string
|
||||
pr: PRStatus | null
|
||||
error?: "gh_missing" | "gh_auth" | "fetch_failed"
|
||||
}
|
||||
|
||||
interface ActionOutMessage {
|
||||
type: "action"
|
||||
action: string
|
||||
@@ -202,6 +262,7 @@ export type AgentManagerOutMessage =
|
||||
| WorktreeDiffLoadingMessage
|
||||
| WorktreeDiffMessage
|
||||
| WorktreeDiffFileMessage
|
||||
| PRStatusOutMessage
|
||||
| ActionOutMessage
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -379,6 +440,16 @@ interface StopDiffWatchIn {
|
||||
type: "agentManager.stopDiffWatch"
|
||||
}
|
||||
|
||||
interface RefreshPRIn {
|
||||
type: "agentManager.refreshPR"
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
interface OpenPRIn {
|
||||
type: "agentManager.openPR"
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
interface OpenFileIn {
|
||||
type: "agentManager.openFile"
|
||||
sessionId: string
|
||||
@@ -453,6 +524,47 @@ interface ContinueInWorktreeIn {
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
interface CreateSectionIn {
|
||||
type: "agentManager.createSection"
|
||||
name: string
|
||||
color?: string
|
||||
worktreeIds?: string[]
|
||||
}
|
||||
|
||||
interface RenameSectionIn {
|
||||
type: "agentManager.renameSection"
|
||||
sectionId: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface DeleteSectionIn {
|
||||
type: "agentManager.deleteSection"
|
||||
sectionId: string
|
||||
}
|
||||
|
||||
interface SetSectionColorIn {
|
||||
type: "agentManager.setSectionColor"
|
||||
sectionId: string
|
||||
color: string | null
|
||||
}
|
||||
|
||||
interface ToggleSectionCollapsedIn {
|
||||
type: "agentManager.toggleSectionCollapsed"
|
||||
sectionId: string
|
||||
}
|
||||
|
||||
interface MoveToSectionIn {
|
||||
type: "agentManager.moveToSection"
|
||||
worktreeIds: string[]
|
||||
sectionId: string | null
|
||||
}
|
||||
|
||||
interface MoveSectionIn {
|
||||
type: "agentManager.moveSection"
|
||||
sectionId: string
|
||||
dir: -1 | 1
|
||||
}
|
||||
|
||||
/** All messages the Agent Manager expects from the webview (onMessage input). */
|
||||
export type AgentManagerInMessage =
|
||||
| CreateWorktreeIn
|
||||
@@ -489,6 +601,8 @@ export type AgentManagerInMessage =
|
||||
| ApplyWorktreeDiffIn
|
||||
| StartDiffWatchIn
|
||||
| StopDiffWatchIn
|
||||
| RefreshPRIn
|
||||
| OpenPRIn
|
||||
| OpenFileIn
|
||||
| GenericOpenFileIn
|
||||
| PreviewImageIn
|
||||
@@ -498,3 +612,10 @@ export type AgentManagerInMessage =
|
||||
| ClearSessionIn
|
||||
| AbortIn
|
||||
| ContinueInWorktreeIn
|
||||
| CreateSectionIn
|
||||
| RenameSectionIn
|
||||
| DeleteSectionIn
|
||||
| SetSectionColorIn
|
||||
| ToggleSectionCollapsedIn
|
||||
| MoveToSectionIn
|
||||
| MoveSectionIn
|
||||
|
||||
@@ -92,6 +92,9 @@ export class VscodeHost implements Host {
|
||||
get active() {
|
||||
return panel.active
|
||||
},
|
||||
get visible() {
|
||||
return panel.visible
|
||||
},
|
||||
postMessage(msg) {
|
||||
void panel.webview.postMessage(msg)
|
||||
},
|
||||
@@ -99,6 +102,9 @@ export class VscodeHost implements Host {
|
||||
panel.reveal(vscode.ViewColumn.One, preserveFocus ?? false)
|
||||
},
|
||||
sessions,
|
||||
onDidChangeVisibility(cb) {
|
||||
return panel.onDidChangeViewState((e) => cb(e.webviewPanel.visible))
|
||||
},
|
||||
onDidDispose(cb) {
|
||||
return panel.onDidDispose(cb)
|
||||
},
|
||||
@@ -160,9 +166,11 @@ export class VscodeHost implements Host {
|
||||
TelemetryProxy.capture(event as TelemetryEventName, properties)
|
||||
}
|
||||
|
||||
openExternal(url: string): void {
|
||||
void vscode.env.openExternal(vscode.Uri.parse(url))
|
||||
}
|
||||
|
||||
refreshGit(): void {
|
||||
// Trigger VS Code's built-in git extension to re-scan repositories.
|
||||
// This picks up worktrees whose gitdir refs were just rewritten by migration.
|
||||
void vscode.commands.executeCommand("git.refresh")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Replicated from packages/util/src/retry.ts to avoid adding @opencode-ai/util
|
||||
// as a dependency of the extension. Keep in sync with the original.
|
||||
|
||||
const TRANSIENT = [
|
||||
"load failed",
|
||||
"network connection was lost",
|
||||
"network request failed",
|
||||
"failed to fetch",
|
||||
"fetch failed",
|
||||
"econnreset",
|
||||
"econnrefused",
|
||||
"etimedout",
|
||||
"socket hang up",
|
||||
]
|
||||
|
||||
function transient(error: unknown): boolean {
|
||||
if (!error) return false
|
||||
const msg = String(error instanceof Error ? error.message : error).toLowerCase()
|
||||
return TRANSIENT.some((m) => msg.includes(m))
|
||||
}
|
||||
|
||||
export async function retry<T>(fn: () => Promise<T>, attempts = 3, delay = 500): Promise<T> {
|
||||
let last: unknown
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
last = error
|
||||
if (i === attempts - 1 || !transient(error)) throw error
|
||||
await new Promise((r) => setTimeout(r, delay * 2 ** i))
|
||||
}
|
||||
}
|
||||
throw last
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Exponential backoff retry utilities for rate-limited API calls.
|
||||
*
|
||||
* When the CLI backend (or the upstream AI provider it proxies) returns
|
||||
* HTTP 429, retries are scheduled with exponential backoff. The delay
|
||||
* respects `Retry-After` / `Retry-After-MS` headers when present.
|
||||
*/
|
||||
|
||||
/** Backoff delays per attempt: 5s -> 10s -> 30s -> 60s -> 300s */
|
||||
const BACKOFF_DELAYS_MS = [5_000, 10_000, 30_000, 60_000, 300_000]
|
||||
|
||||
/** Maximum backoff delay in ms (5 minutes) */
|
||||
const MAX_MS = 300_000
|
||||
|
||||
/** Maximum number of retry attempts */
|
||||
const MAX_RETRIES = BACKOFF_DELAYS_MS.length
|
||||
|
||||
/** HTTP status codes that are safe to retry */
|
||||
const RETRYABLE = new Set([408, 409, 425, 429, 500, 502, 503, 504])
|
||||
|
||||
/**
|
||||
* Whether an HTTP status code is retryable.
|
||||
*/
|
||||
export function retryable(status: number): boolean {
|
||||
if (RETRYABLE.has(status)) return true
|
||||
return status >= 500
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a retry delay (in ms) from standard response headers.
|
||||
*
|
||||
* Checks `retry-after-ms` first (milliseconds), then `retry-after`
|
||||
* (seconds or HTTP-date). Returns `null` when no usable header is found.
|
||||
*/
|
||||
export function headerDelay(headers: Headers): number | null {
|
||||
const ms = headers.get("retry-after-ms")
|
||||
if (ms) {
|
||||
const parsed = Number.parseFloat(ms)
|
||||
if (!Number.isNaN(parsed) && parsed > 0) return parsed
|
||||
}
|
||||
|
||||
const after = headers.get("retry-after")
|
||||
if (after) {
|
||||
const seconds = Number.parseFloat(after)
|
||||
if (!Number.isNaN(seconds) && seconds > 0) return Math.ceil(seconds * 1000)
|
||||
// Try HTTP-date format
|
||||
const date = Date.parse(after) - Date.now()
|
||||
if (!Number.isNaN(date) && date > 0) return Math.ceil(date)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate backoff delay for a given attempt.
|
||||
*
|
||||
* If `headers` are provided and contain a `Retry-After` value, that
|
||||
* value is used (capped at MAX_MS). Otherwise uses the predefined
|
||||
* backoff schedule: 5s, 10s, 30s, 60s, 300s.
|
||||
*/
|
||||
export function backoff(attempt: number, headers?: Headers): number {
|
||||
if (headers) {
|
||||
const fromHeader = headerDelay(headers)
|
||||
if (fromHeader !== null) return Math.min(fromHeader, MAX_MS)
|
||||
}
|
||||
const index = Math.min(attempt - 1, BACKOFF_DELAYS_MS.length - 1)
|
||||
return BACKOFF_DELAYS_MS[index] ?? MAX_MS
|
||||
}
|
||||
|
||||
export { MAX_RETRIES, MAX_MS }
|
||||
@@ -31,6 +31,7 @@ const TSX_FILES = [
|
||||
path.join(ROOT, "webview-ui/agent-manager/ApplyDialog.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/BranchSelect.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/WorktreeItem.tsx"),
|
||||
path.join(ROOT, "webview-ui/agent-manager/SectionHeader.tsx"),
|
||||
]
|
||||
const TSX_FILE = TSX_FILES[0]
|
||||
const PROVIDER_FILE = path.join(ROOT, "src/agent-manager/AgentManagerProvider.ts")
|
||||
@@ -532,7 +533,7 @@ const VSCODE_ALLOWED: Record<string, { note: string }> = {
|
||||
*/
|
||||
const MAX_LINES: Record<string, { maxLines: number; note: string }> = {
|
||||
"AgentManagerProvider.ts": {
|
||||
maxLines: 1910,
|
||||
maxLines: 2000,
|
||||
note: "primary extraction target: break into smaller orchestrators",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ function ops(handler: (args: string[], cwd: string) => Promise<string>): GitOps
|
||||
return new GitOps({ log: () => undefined, runGit: handler })
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function runGit(cwd: string, args: string[]): string {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ["git", ...args],
|
||||
@@ -475,4 +479,81 @@ describe("GitOps", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("dispose", () => {
|
||||
it("aborts in-flight runGit calls quickly", async () => {
|
||||
let resolved = false
|
||||
const git = new GitOps({
|
||||
log: () => undefined,
|
||||
runGit: async () => {
|
||||
await sleep(5000)
|
||||
resolved = true
|
||||
return "should not reach"
|
||||
},
|
||||
})
|
||||
|
||||
const start = Date.now()
|
||||
const pending = git.currentBranch("/repo")
|
||||
git.dispose()
|
||||
await pending
|
||||
const elapsed = Date.now() - start
|
||||
expect(elapsed).toBeLessThan(500)
|
||||
expect(resolved).toBe(false)
|
||||
})
|
||||
|
||||
it("causes subsequent runGit calls to fail immediately", async () => {
|
||||
let called = false
|
||||
const git = new GitOps({
|
||||
log: () => undefined,
|
||||
runGit: async () => {
|
||||
called = true
|
||||
return "ok"
|
||||
},
|
||||
})
|
||||
git.dispose()
|
||||
|
||||
// currentBranch swallows errors — should return "" without calling runGit
|
||||
const result = await git.currentBranch("/repo")
|
||||
expect(result).toBe("")
|
||||
expect(called).toBe(false)
|
||||
})
|
||||
|
||||
it("reports disposed state", () => {
|
||||
const git = ops(async () => "ok")
|
||||
expect(git.disposed).toBe(false)
|
||||
git.dispose()
|
||||
expect(git.disposed).toBe(true)
|
||||
})
|
||||
|
||||
it("kills in-flight exec (spawn) processes", async () => {
|
||||
await withRepo(async (cwd) => {
|
||||
const git = new GitOps({ log: () => undefined })
|
||||
await fs.writeFile(nodePath.join(cwd, "a.txt"), "one\n", "utf8")
|
||||
runGit(cwd, ["add", "-A"])
|
||||
runGit(cwd, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "init"])
|
||||
await fs.writeFile(nodePath.join(cwd, "a.txt"), "two\n", "utf8")
|
||||
|
||||
const branch = runGit(cwd, ["branch", "--show-current"]) || "HEAD"
|
||||
const pending = git.buildWorktreePatch(cwd, branch)
|
||||
// Give spawn a moment to start, then dispose
|
||||
await sleep(10)
|
||||
git.dispose()
|
||||
|
||||
// Should either reject or return (but process should be killed)
|
||||
try {
|
||||
await pending
|
||||
} catch {
|
||||
// expected — aborted
|
||||
}
|
||||
expect(git.disposed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it("is safe to call multiple times", () => {
|
||||
const git = ops(async () => "ok")
|
||||
git.dispose()
|
||||
git.dispose()
|
||||
expect(git.disposed).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as fs from "fs"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import type { KiloClient } from "@kilocode/sdk/v2/client"
|
||||
import { GitStatsPoller } from "../../src/agent-manager/GitStatsPoller"
|
||||
import { GitStatsPoller, type WorktreePresenceResult } from "../../src/agent-manager/GitStatsPoller"
|
||||
import { GitOps } from "../../src/agent-manager/GitOps"
|
||||
import type { Worktree } from "../../src/agent-manager/WorktreeStateManager"
|
||||
|
||||
@@ -128,7 +128,7 @@ describe("GitStatsPoller", () => {
|
||||
const wtPath = path.join(root, "wt-a")
|
||||
fs.mkdirSync(wtPath, { recursive: true })
|
||||
|
||||
const presence: Array<{ worktrees: Array<{ worktreeId: string; missing: boolean }>; degraded: boolean }> = []
|
||||
const presence: WorktreePresenceResult[] = []
|
||||
|
||||
const poller = new GitStatsPoller({
|
||||
getWorktrees: () => [{ ...worktree("a"), path: wtPath }],
|
||||
@@ -154,7 +154,10 @@ describe("GitStatsPoller", () => {
|
||||
poller.stop()
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
|
||||
expect(presence[0]).toEqual({ worktrees: [{ worktreeId: "a", missing: false }], degraded: false })
|
||||
expect(presence[0]).toEqual({
|
||||
worktrees: [{ worktreeId: "a", missing: false, branch: "branch-a" }],
|
||||
degraded: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("emits degraded probe when git worktree listing fails", async () => {
|
||||
@@ -162,7 +165,7 @@ describe("GitStatsPoller", () => {
|
||||
const wtPath = path.join(root, "wt-a")
|
||||
fs.mkdirSync(wtPath, { recursive: true })
|
||||
|
||||
const presence: Array<{ worktrees: Array<{ worktreeId: string; missing: boolean }>; degraded: boolean }> = []
|
||||
const presence: WorktreePresenceResult[] = []
|
||||
|
||||
const poller = new GitStatsPoller({
|
||||
getWorktrees: () => [{ ...worktree("a"), path: wtPath }],
|
||||
@@ -199,7 +202,7 @@ describe("GitStatsPoller", () => {
|
||||
|
||||
const calls: string[] = []
|
||||
const emitted: Array<Array<{ worktreeId: string; additions: number; deletions: number; commits: number }>> = []
|
||||
const presence: Array<{ worktrees: Array<{ worktreeId: string; missing: boolean }>; degraded: boolean }> = []
|
||||
const presence: WorktreePresenceResult[] = []
|
||||
|
||||
const client = {
|
||||
worktree: {
|
||||
@@ -240,8 +243,8 @@ describe("GitStatsPoller", () => {
|
||||
expect(calls.some((cwd) => cwd === wtBPath)).toBe(false)
|
||||
expect(presence[0]).toEqual({
|
||||
worktrees: [
|
||||
{ worktreeId: "a", missing: false },
|
||||
{ worktreeId: "b", missing: true },
|
||||
{ worktreeId: "a", missing: false, branch: "branch-a" },
|
||||
{ worktreeId: "b", missing: true, branch: undefined },
|
||||
],
|
||||
degraded: false,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { retry } from "../../src/services/cli-backend/retry"
|
||||
|
||||
describe("retry", () => {
|
||||
it("returns on first success", async () => {
|
||||
const result = await retry(() => Promise.resolve(42))
|
||||
expect(result).toBe(42)
|
||||
})
|
||||
|
||||
it("retries transient errors and succeeds", async () => {
|
||||
let calls = 0
|
||||
const result = await retry(
|
||||
() => {
|
||||
calls++
|
||||
if (calls < 3) throw new TypeError("fetch failed")
|
||||
return Promise.resolve("ok")
|
||||
},
|
||||
3,
|
||||
10,
|
||||
)
|
||||
expect(result).toBe("ok")
|
||||
expect(calls).toBe(3)
|
||||
})
|
||||
|
||||
it("throws immediately on non-transient errors", async () => {
|
||||
let calls = 0
|
||||
await expect(
|
||||
retry(
|
||||
() => {
|
||||
calls++
|
||||
throw new Error("404 not found")
|
||||
},
|
||||
3,
|
||||
10,
|
||||
),
|
||||
).rejects.toThrow("404 not found")
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it("throws after exhausting attempts on transient errors", async () => {
|
||||
let calls = 0
|
||||
await expect(
|
||||
retry(
|
||||
() => {
|
||||
calls++
|
||||
throw new Error("ECONNREFUSED")
|
||||
},
|
||||
3,
|
||||
10,
|
||||
),
|
||||
).rejects.toThrow("ECONNREFUSED")
|
||||
expect(calls).toBe(3)
|
||||
})
|
||||
|
||||
it("detects all transient error messages", async () => {
|
||||
const messages = [
|
||||
"load failed",
|
||||
"network connection was lost",
|
||||
"network request failed",
|
||||
"failed to fetch",
|
||||
"fetch failed",
|
||||
"ECONNRESET",
|
||||
"ECONNREFUSED",
|
||||
"ETIMEDOUT",
|
||||
"socket hang up",
|
||||
]
|
||||
for (const msg of messages) {
|
||||
let calls = 0
|
||||
await retry(
|
||||
() => {
|
||||
calls++
|
||||
if (calls === 1) throw new Error(msg)
|
||||
return Promise.resolve(true)
|
||||
},
|
||||
2,
|
||||
10,
|
||||
)
|
||||
expect(calls).toBe(2)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { acronymMatch, searchMatch } from "../../webview-ui/src/utils/search-match"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// acronymMatch — low-level word-boundary matching
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("acronymMatch", () => {
|
||||
describe("basic word boundary matching", () => {
|
||||
it("matches at word start", () => {
|
||||
expect(acronymMatch("fool org", "foo")).toBe(true)
|
||||
expect(acronymMatch("the fool", "foo")).toBe(true)
|
||||
})
|
||||
|
||||
it("does not match arbitrary substrings", () => {
|
||||
expect(acronymMatch("faoboc", "foo")).toBe(false)
|
||||
expect(acronymMatch("barfoo", "foo")).toBe(false)
|
||||
})
|
||||
|
||||
it("matches prefix of a single word", () => {
|
||||
expect(acronymMatch("foobar", "foob")).toBe(true)
|
||||
})
|
||||
|
||||
it("matches exact word", () => {
|
||||
expect(acronymMatch("test", "test")).toBe(true)
|
||||
expect(acronymMatch("testing", "test")).toBe(true)
|
||||
expect(acronymMatch("the test", "test")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("word separators", () => {
|
||||
it("recognizes space", () => {
|
||||
expect(acronymMatch("hello world", "wor")).toBe(true)
|
||||
})
|
||||
|
||||
it("recognizes hyphen", () => {
|
||||
expect(acronymMatch("hello-world", "wor")).toBe(true)
|
||||
})
|
||||
|
||||
it("recognizes underscore", () => {
|
||||
expect(acronymMatch("hello_world", "wor")).toBe(true)
|
||||
})
|
||||
|
||||
it("recognizes slash", () => {
|
||||
expect(acronymMatch("hello/world", "wor")).toBe(true)
|
||||
})
|
||||
|
||||
it("recognizes dot", () => {
|
||||
expect(acronymMatch("hello.world", "wor")).toBe(true)
|
||||
})
|
||||
|
||||
it("recognizes parentheses", () => {
|
||||
expect(acronymMatch("Grok Code Fast 1 (free)", "free")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("acronym matching", () => {
|
||||
it("matches acronyms from word starts", () => {
|
||||
expect(acronymMatch("Claude Sonnet", "clso")).toBe(true)
|
||||
})
|
||||
|
||||
it("matches partial acronyms", () => {
|
||||
expect(acronymMatch("Claude Sonnet 3.5", "cls")).toBe(true)
|
||||
})
|
||||
|
||||
it("matches direct and acronym", () => {
|
||||
expect(acronymMatch("clso tool", "clso")).toBe(true)
|
||||
expect(acronymMatch("Claude Sonnet", "clso")).toBe(true)
|
||||
})
|
||||
|
||||
it("does not match non-boundary acronym", () => {
|
||||
expect(acronymMatch("aclbso", "clso")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("camelCase and PascalCase", () => {
|
||||
it("recognizes camelCase boundary", () => {
|
||||
expect(acronymMatch("gitRebase", "gr")).toBe(true)
|
||||
})
|
||||
|
||||
it("matches PascalCase acronyms", () => {
|
||||
expect(acronymMatch("NewFileCreation", "nfc")).toBe(true)
|
||||
})
|
||||
|
||||
it("splits camelCase at uppercase transitions", () => {
|
||||
expect(acronymMatch("parseMarkdownContent", "pmc")).toBe(true)
|
||||
})
|
||||
|
||||
it("handles mixed case scenarios", () => {
|
||||
expect(acronymMatch("gitRebase", "gitr")).toBe(true)
|
||||
expect(acronymMatch("GitRebase", "gitr")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("backtracking", () => {
|
||||
it("matches word that appears later in text", () => {
|
||||
expect(acronymMatch("google gemini", "gemini")).toBe(true)
|
||||
expect(acronymMatch("gemini pro", "gemini")).toBe(true)
|
||||
expect(acronymMatch("google em emini", "gemini")).toBe(true)
|
||||
})
|
||||
|
||||
it("matches partial word that appears later", () => {
|
||||
expect(acronymMatch("Microsoft Copilot", "copilot")).toBe(true)
|
||||
expect(acronymMatch("GitHub Copilot", "copilot")).toBe(true)
|
||||
})
|
||||
|
||||
it("still respects word boundaries with backtracking", () => {
|
||||
expect(acronymMatch("google gemini", "gemini")).toBe(true)
|
||||
expect(acronymMatch("googlegemini", "gemini")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles empty text", () => {
|
||||
expect(acronymMatch("", "foo")).toBe(false)
|
||||
})
|
||||
|
||||
it("handles empty query", () => {
|
||||
expect(acronymMatch("foo", "")).toBe(true)
|
||||
})
|
||||
|
||||
it("handles special characters in text", () => {
|
||||
expect(acronymMatch("foo-bar", "foob")).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// searchMatch — high-level search with trimming and multi-word support
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("searchMatch", () => {
|
||||
describe("empty and whitespace queries", () => {
|
||||
it("returns true for empty query", () => {
|
||||
expect(searchMatch("", "anything")).toBe(true)
|
||||
})
|
||||
|
||||
it("returns true for whitespace-only query", () => {
|
||||
expect(searchMatch(" ", "anything")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("case insensitivity", () => {
|
||||
it("matches case-insensitively", () => {
|
||||
expect(searchMatch("foo", "Foo Bar")).toBe(true)
|
||||
expect(searchMatch("foo", "FOO BAZ")).toBe(true)
|
||||
expect(searchMatch("FoO", "foo qux")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("trimming", () => {
|
||||
it("trims leading spaces", () => {
|
||||
expect(searchMatch(" foo", "foo bar")).toBe(true)
|
||||
})
|
||||
|
||||
it("trims trailing spaces", () => {
|
||||
expect(searchMatch("foo ", "foo bar")).toBe(true)
|
||||
})
|
||||
|
||||
it("trims spaces on both sides", () => {
|
||||
expect(searchMatch(" foo ", "foo bar")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("multi-word queries", () => {
|
||||
it("matches when all words present", () => {
|
||||
expect(searchMatch("claude sonnet", "Claude Sonnet 3.5")).toBe(true)
|
||||
})
|
||||
|
||||
it("does not match when any word missing", () => {
|
||||
expect(searchMatch("claude sonnet", "Claude Opus")).toBe(false)
|
||||
expect(searchMatch("claude sonnet", "GPT Sonnet")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("real-world model search", () => {
|
||||
it("finds model with hyphen in query", () => {
|
||||
expect(searchMatch("gpt-5", "OpenAI: gpt-5 mini")).toBe(true)
|
||||
expect(searchMatch("gpt-5", "OpenAI: gpt-4")).toBe(false)
|
||||
})
|
||||
|
||||
it("finds model when hyphen omitted from query", () => {
|
||||
expect(searchMatch("gpt5", "OpenAI: gpt-5 mini")).toBe(true)
|
||||
})
|
||||
|
||||
it("finds all models with trailing hyphen", () => {
|
||||
expect(searchMatch("gpt-", "OpenAI: gpt-5 mini")).toBe(true)
|
||||
expect(searchMatch("gpt-", "OpenAI: gpt-4")).toBe(true)
|
||||
expect(searchMatch("gpt-", "Anthropic: claude-3")).toBe(false)
|
||||
})
|
||||
|
||||
it("matches file paths", () => {
|
||||
expect(searchMatch("code", "src/services/code-index/manager.ts")).toBe(true)
|
||||
})
|
||||
|
||||
it("matches mode selector options by label+value", () => {
|
||||
expect(searchMatch("cod", "Code code Write code")).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { buildTopLevelItems, isGrouped, isGroupStart, isGroupEnd } from "../../webview-ui/agent-manager/section-helpers"
|
||||
import type { WorktreeState, SectionState } from "../../webview-ui/src/types/messages"
|
||||
|
||||
function wt(id: string, opts: Partial<WorktreeState> = {}): WorktreeState {
|
||||
return {
|
||||
id,
|
||||
branch: `branch-${id}`,
|
||||
path: `/tmp/${id}`,
|
||||
parentBranch: "main",
|
||||
createdAt: "2024-01-01",
|
||||
...opts,
|
||||
}
|
||||
}
|
||||
|
||||
function sec(id: string, order: number, opts: Partial<SectionState> = {}): SectionState {
|
||||
return { id, name: `Section ${id}`, color: null, order, collapsed: false, ...opts }
|
||||
}
|
||||
|
||||
describe("buildTopLevelItems", () => {
|
||||
it("returns flat worktree list when no sections", () => {
|
||||
const all = [wt("a"), wt("b"), wt("c")]
|
||||
const result = buildTopLevelItems([], [], all, [])
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result.every((r) => r.kind === "worktree")).toBe(true)
|
||||
})
|
||||
|
||||
it("interleaves sections and worktrees per order", () => {
|
||||
const s1 = sec("s1", 0)
|
||||
const w1 = wt("w1")
|
||||
const s2 = sec("s2", 1)
|
||||
const result = buildTopLevelItems([s1, s2], [w1], [w1], ["s1", "w1", "s2"])
|
||||
expect(result).toHaveLength(3)
|
||||
expect(result[0]).toEqual({ kind: "section", section: s1 })
|
||||
expect(result[1]).toEqual({ kind: "worktree", wt: w1 })
|
||||
expect(result[2]).toEqual({ kind: "section", section: s2 })
|
||||
})
|
||||
|
||||
it("appends unordered sections and worktrees at the end", () => {
|
||||
const s1 = sec("s1", 0)
|
||||
const s2 = sec("s2", 1)
|
||||
const w1 = wt("w1")
|
||||
const w2 = wt("w2")
|
||||
// Only s1 is in the order array
|
||||
const result = buildTopLevelItems([s1, s2], [w1, w2], [w1, w2], ["s1", "w1"])
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result[0]).toEqual({ kind: "section", section: s1 })
|
||||
expect(result[1]).toEqual({ kind: "worktree", wt: w1 })
|
||||
// unordered items appended
|
||||
expect(result[2]).toEqual({ kind: "section", section: s2 })
|
||||
expect(result[3]).toEqual({ kind: "worktree", wt: w2 })
|
||||
})
|
||||
|
||||
it("skips duplicate ids in order array", () => {
|
||||
const s1 = sec("s1", 0)
|
||||
const w1 = wt("w1")
|
||||
const result = buildTopLevelItems([s1], [w1], [w1], ["s1", "w1", "s1", "w1"])
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isGrouped", () => {
|
||||
it("returns true when groupId is set", () => {
|
||||
expect(isGrouped(wt("a", { groupId: "g1" }))).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when groupId is undefined", () => {
|
||||
expect(isGrouped(wt("a"))).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false when groupId is empty string", () => {
|
||||
expect(isGrouped(wt("a", { groupId: "" }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isGroupStart", () => {
|
||||
const list = [wt("a", { groupId: "g1" }), wt("b", { groupId: "g1" }), wt("c", { groupId: "g2" }), wt("d")]
|
||||
|
||||
it("returns true for first item in group", () => {
|
||||
expect(isGroupStart(list[0]!, 0, list)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false for middle/end item in same group", () => {
|
||||
expect(isGroupStart(list[1]!, 1, list)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true when previous item has different groupId", () => {
|
||||
expect(isGroupStart(list[2]!, 2, list)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false for ungrouped worktree", () => {
|
||||
expect(isGroupStart(list[3]!, 3, list)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isGroupEnd", () => {
|
||||
const list = [wt("a", { groupId: "g1" }), wt("b", { groupId: "g1" }), wt("c", { groupId: "g2" }), wt("d")]
|
||||
|
||||
it("returns false for start/middle of group", () => {
|
||||
expect(isGroupEnd(list[0]!, 0, list)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true for last item in group when next has different groupId", () => {
|
||||
expect(isGroupEnd(list[1]!, 1, list)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns true for last item in list with groupId", () => {
|
||||
expect(isGroupEnd(list[2]!, 2, list)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false for ungrouped worktree", () => {
|
||||
expect(isGroupEnd(list[3]!, 3, list)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,286 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import * as os from "os"
|
||||
import { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
|
||||
|
||||
describe("WorktreeStateManager sections", () => {
|
||||
let root: string
|
||||
let mgr: WorktreeStateManager
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "wtsm-sec-"))
|
||||
fs.mkdirSync(path.join(root, ".kilo"), { recursive: true })
|
||||
mgr = new WorktreeStateManager(root, () => {})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await mgr.flush()
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("addSection", () => {
|
||||
it("creates a section with correct defaults", () => {
|
||||
const sec = mgr.addSection("Backend", "Blue")
|
||||
expect(sec.id).toMatch(/^sec-/)
|
||||
expect(sec.name).toBe("Backend")
|
||||
expect(sec.color).toBe("Blue")
|
||||
expect(sec.collapsed).toBe(false)
|
||||
expect(mgr.getWorktreeOrder()).toContain(sec.id)
|
||||
})
|
||||
|
||||
it("adds section id to worktreeOrder", () => {
|
||||
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
|
||||
mgr.setWorktreeOrder([wt.id])
|
||||
const sec = mgr.addSection("Infra", null)
|
||||
expect(mgr.getWorktreeOrder()).toEqual([wt.id, sec.id])
|
||||
})
|
||||
|
||||
it("moves specified worktrees into the section", () => {
|
||||
const wt1 = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
|
||||
const wt2 = mgr.addWorktree({ branch: "b", path: "/tmp/b", parentBranch: "main" })
|
||||
mgr.setWorktreeOrder([wt1.id, wt2.id])
|
||||
|
||||
const sec = mgr.addSection("Group", "Red", [wt1.id])
|
||||
expect(mgr.getWorktree(wt1.id)?.sectionId).toBe(sec.id)
|
||||
expect(mgr.getWorktree(wt2.id)?.sectionId).toBeUndefined()
|
||||
// wt1 removed from top-level order, wt2 remains
|
||||
expect(mgr.getWorktreeOrder()).not.toContain(wt1.id)
|
||||
expect(mgr.getWorktreeOrder()).toContain(wt2.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe("renameSection", () => {
|
||||
it("updates the name", () => {
|
||||
const sec = mgr.addSection("Old", null)
|
||||
mgr.renameSection(sec.id, "New")
|
||||
expect(mgr.getSection(sec.id)?.name).toBe("New")
|
||||
})
|
||||
|
||||
it("rejects empty string", () => {
|
||||
const sec = mgr.addSection("Keep", null)
|
||||
mgr.renameSection(sec.id, "")
|
||||
expect(mgr.getSection(sec.id)?.name).toBe("Keep")
|
||||
})
|
||||
|
||||
it("is a no-op for unknown id", () => {
|
||||
mgr.renameSection("nonexistent", "Foo")
|
||||
expect(mgr.getSections()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("setSectionColor", () => {
|
||||
it("updates color", () => {
|
||||
const sec = mgr.addSection("X", null)
|
||||
mgr.setSectionColor(sec.id, "Green")
|
||||
expect(mgr.getSection(sec.id)?.color).toBe("Green")
|
||||
})
|
||||
|
||||
it("accepts null for default", () => {
|
||||
const sec = mgr.addSection("X", "Red")
|
||||
mgr.setSectionColor(sec.id, null)
|
||||
expect(mgr.getSection(sec.id)?.color).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("toggleSection", () => {
|
||||
it("flips collapsed state", () => {
|
||||
const sec = mgr.addSection("T", null)
|
||||
expect(sec.collapsed).toBe(false)
|
||||
|
||||
mgr.toggleSection(sec.id)
|
||||
expect(mgr.getSection(sec.id)?.collapsed).toBe(true)
|
||||
|
||||
mgr.toggleSection(sec.id)
|
||||
expect(mgr.getSection(sec.id)?.collapsed).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("deleteSection", () => {
|
||||
it("removes section and ungroups its worktrees", () => {
|
||||
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
|
||||
const sec = mgr.addSection("Del", null, [wt.id])
|
||||
expect(mgr.getWorktree(wt.id)?.sectionId).toBe(sec.id)
|
||||
|
||||
mgr.deleteSection(sec.id)
|
||||
expect(mgr.getSection(sec.id)).toBeUndefined()
|
||||
// worktree still exists but sectionId cleared
|
||||
expect(mgr.getWorktree(wt.id)).toBeTruthy()
|
||||
expect(mgr.getWorktree(wt.id)?.sectionId).toBeUndefined()
|
||||
})
|
||||
|
||||
it("removes section from worktreeOrder", () => {
|
||||
const sec = mgr.addSection("Gone", null)
|
||||
expect(mgr.getWorktreeOrder()).toContain(sec.id)
|
||||
mgr.deleteSection(sec.id)
|
||||
expect(mgr.getWorktreeOrder()).not.toContain(sec.id)
|
||||
})
|
||||
|
||||
it("is a no-op for unknown id", () => {
|
||||
mgr.deleteSection("nonexistent")
|
||||
expect(mgr.getSections()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("moveToSection", () => {
|
||||
it("sets sectionId and removes from worktreeOrder", () => {
|
||||
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
|
||||
mgr.setWorktreeOrder([wt.id])
|
||||
const sec = mgr.addSection("Target", null)
|
||||
|
||||
mgr.moveToSection([wt.id], sec.id)
|
||||
expect(mgr.getWorktree(wt.id)?.sectionId).toBe(sec.id)
|
||||
expect(mgr.getWorktreeOrder()).not.toContain(wt.id)
|
||||
})
|
||||
|
||||
it("ungroups worktrees with null sectionId and adds back to order", () => {
|
||||
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
|
||||
const sec = mgr.addSection("Temp", null, [wt.id])
|
||||
expect(mgr.getWorktreeOrder()).not.toContain(wt.id)
|
||||
|
||||
mgr.moveToSection([wt.id], null)
|
||||
expect(mgr.getWorktree(wt.id)?.sectionId).toBeUndefined()
|
||||
expect(mgr.getWorktreeOrder()).toContain(wt.id)
|
||||
})
|
||||
|
||||
it("expands to multi-version siblings with same groupId", () => {
|
||||
const wt1 = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main", groupId: "g1" })
|
||||
const wt2 = mgr.addWorktree({ branch: "b", path: "/tmp/b", parentBranch: "main", groupId: "g1" })
|
||||
const wt3 = mgr.addWorktree({ branch: "c", path: "/tmp/c", parentBranch: "main" })
|
||||
mgr.setWorktreeOrder([wt1.id, wt2.id, wt3.id])
|
||||
const sec = mgr.addSection("Multi", null)
|
||||
|
||||
// Move only wt1 — wt2 should follow because of shared groupId
|
||||
mgr.moveToSection([wt1.id], sec.id)
|
||||
expect(mgr.getWorktree(wt1.id)?.sectionId).toBe(sec.id)
|
||||
expect(mgr.getWorktree(wt2.id)?.sectionId).toBe(sec.id)
|
||||
expect(mgr.getWorktree(wt3.id)?.sectionId).toBeUndefined()
|
||||
})
|
||||
|
||||
it("does not duplicate in worktreeOrder when ungrouping already-present id", () => {
|
||||
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
|
||||
mgr.setWorktreeOrder([wt.id])
|
||||
// Ungroup when already in order
|
||||
mgr.moveToSection([wt.id], null)
|
||||
const count = mgr.getWorktreeOrder().filter((id) => id === wt.id).length
|
||||
expect(count).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getSections", () => {
|
||||
it("returns all sections", () => {
|
||||
mgr.addSection("A", null)
|
||||
mgr.addSection("B", "Red")
|
||||
mgr.addSection("C", "Blue")
|
||||
expect(mgr.getSections()).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("returns empty array when no sections", () => {
|
||||
expect(mgr.getSections()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("moveSection", () => {
|
||||
it("moves a section up within mixed top-level order", () => {
|
||||
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
|
||||
mgr.setWorktreeOrder([wt.id])
|
||||
const a = mgr.addSection("A", null)
|
||||
const b = mgr.addSection("B", null)
|
||||
expect(mgr.getWorktreeOrder()).toEqual([wt.id, a.id, b.id])
|
||||
mgr.moveSection(b.id, -1)
|
||||
expect(mgr.getWorktreeOrder()).toEqual([wt.id, b.id, a.id])
|
||||
})
|
||||
|
||||
it("moves a section down within mixed top-level order", () => {
|
||||
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
|
||||
mgr.setWorktreeOrder([wt.id])
|
||||
const a = mgr.addSection("A", null)
|
||||
const b = mgr.addSection("B", null)
|
||||
expect(mgr.getWorktreeOrder()).toEqual([wt.id, a.id, b.id])
|
||||
mgr.moveSection(a.id, 1)
|
||||
expect(mgr.getWorktreeOrder()).toEqual([wt.id, b.id, a.id])
|
||||
})
|
||||
|
||||
it("is a no-op at boundaries", () => {
|
||||
const a = mgr.addSection("A", null)
|
||||
const b = mgr.addSection("B", null)
|
||||
mgr.moveSection(a.id, -1)
|
||||
expect(mgr.getWorktreeOrder()).toEqual([a.id, b.id])
|
||||
mgr.moveSection(b.id, 1)
|
||||
expect(mgr.getWorktreeOrder()).toEqual([a.id, b.id])
|
||||
})
|
||||
|
||||
it("does not change section membership", () => {
|
||||
const wt1 = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
|
||||
const wt2 = mgr.addWorktree({ branch: "b", path: "/tmp/b", parentBranch: "main" })
|
||||
const a = mgr.addSection("A", null, [wt1.id, wt2.id])
|
||||
const b = mgr.addSection("B", null)
|
||||
mgr.moveSection(b.id, -1)
|
||||
expect(mgr.getWorktree(wt1.id)?.sectionId).toBe(a.id)
|
||||
expect(mgr.getWorktree(wt2.id)?.sectionId).toBe(a.id)
|
||||
})
|
||||
|
||||
it("persists reordered sections across save/load", async () => {
|
||||
const a = mgr.addSection("A", null)
|
||||
const b = mgr.addSection("B", null)
|
||||
mgr.moveSection(b.id, -1)
|
||||
await mgr.flush()
|
||||
await mgr.save()
|
||||
const loaded = new WorktreeStateManager(root, () => {})
|
||||
await loaded.load()
|
||||
expect(loaded.getWorktreeOrder()).toEqual([b.id, a.id])
|
||||
})
|
||||
})
|
||||
|
||||
describe("persistence", () => {
|
||||
it("saves and loads sections", async () => {
|
||||
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
|
||||
const sec = mgr.addSection("Persist", "Green", [wt.id])
|
||||
mgr.toggleSection(sec.id)
|
||||
await mgr.flush()
|
||||
await mgr.save()
|
||||
|
||||
const loaded = new WorktreeStateManager(root, () => {})
|
||||
await loaded.load()
|
||||
|
||||
const secs = loaded.getSections()
|
||||
expect(secs).toHaveLength(1)
|
||||
const first = secs[0]!
|
||||
expect(first.name).toBe("Persist")
|
||||
expect(first.color).toBe("Green")
|
||||
expect(first.collapsed).toBe(true)
|
||||
expect(loaded.getWorktree(wt.id)?.sectionId).toBe(sec.id)
|
||||
})
|
||||
|
||||
it("normalizes worktreeOrder on load to include missing section IDs", async () => {
|
||||
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
|
||||
const sec = mgr.addSection("S", null)
|
||||
await mgr.flush()
|
||||
await mgr.save()
|
||||
// Simulate stale data: manually remove section from worktreeOrder
|
||||
const file = path.join(root, ".kilo", "agent-manager.json")
|
||||
const data = JSON.parse(fs.readFileSync(file, "utf-8"))
|
||||
data.worktreeOrder = [wt.id] // section ID missing
|
||||
fs.writeFileSync(file, JSON.stringify(data))
|
||||
|
||||
const loaded = new WorktreeStateManager(root, () => {})
|
||||
await loaded.load()
|
||||
expect(loaded.getWorktreeOrder()).toContain(sec.id)
|
||||
expect(loaded.getWorktreeOrder()).toContain(wt.id)
|
||||
})
|
||||
|
||||
it("normalizes worktreeOrder on load to include missing ungrouped worktrees", async () => {
|
||||
const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" })
|
||||
await mgr.flush()
|
||||
await mgr.save()
|
||||
const file = path.join(root, ".kilo", "agent-manager.json")
|
||||
const data = JSON.parse(fs.readFileSync(file, "utf-8"))
|
||||
data.worktreeOrder = [] // worktree ID missing
|
||||
fs.writeFileSync(file, JSON.stringify(data))
|
||||
|
||||
const loaded = new WorktreeStateManager(root, () => {})
|
||||
await loaded.load()
|
||||
expect(loaded.getWorktreeOrder()).toContain(wt.id)
|
||||
})
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ed6e36e191174cd14d61fc3480014d59c9a92bd526f4f889dc71bdbab4b44e66
|
||||
size 2106
|
||||
oid sha256:270e0dafc2601996e7d048873e55db4680e8803d6800dc44b1fa6ce6239827cc
|
||||
size 2017
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b313dd76197ff089105d6b80ff22bdf570b878f67dcc99e5d050e85573c52d60
|
||||
size 1959
|
||||
oid sha256:5feb854ee2693b655ab504b8e2ed8794af77a357c3d716e2deb9668a08f3aa0c
|
||||
size 1847
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e386126977ece9eae41cce0b13677ae4243b985b39a7ee0977e9b5da32415796
|
||||
size 5837
|
||||
oid sha256:937c4398730fd3e07bc064b4cb370717a5bc280ffaced87fa6f3c626774878a8
|
||||
size 5598
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:24d02a1a394c5e18e3783b19c8c7145eafbd8cabfb624c53c82137369d2c5784
|
||||
size 2570
|
||||
oid sha256:1d9935cc07b9eefe0c1eca1852b00ac2b39b76cdf12ee2f01c52e4ac0771b39e
|
||||
size 2910
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:07039f10de94b6aa2eba14674a9f3370fef2f8b23d6712e91f43ddd77f169c34
|
||||
size 1930
|
||||
oid sha256:54e7142d591391c6e99631c9bf87b011321e52f17a8ee39e5aeeaa9b1350f2f9
|
||||
size 1831
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:519310e5cb841302d651f450b4fc281936c331646a4d3759de0c010d1761fe63
|
||||
size 2687
|
||||
oid sha256:7e4a37b5b7d71e0eb7f043176f410030385a8a61f3913bd6419b1d96c6cad2de
|
||||
size 2422
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
createSignal,
|
||||
createMemo,
|
||||
createEffect,
|
||||
createRoot,
|
||||
on,
|
||||
onMount,
|
||||
onCleanup,
|
||||
@@ -34,7 +33,10 @@ import type {
|
||||
WorktreeGitStats,
|
||||
LocalGitStats,
|
||||
WorktreeState,
|
||||
PRStatus,
|
||||
AgentManagerPRStatusMessage,
|
||||
ManagedSessionState,
|
||||
SectionState,
|
||||
SessionInfo,
|
||||
BranchInfo,
|
||||
} from "../src/types/messages"
|
||||
@@ -45,9 +47,8 @@ import {
|
||||
SortableProvider,
|
||||
closestCenter,
|
||||
createSortable,
|
||||
useDragDropContext,
|
||||
} from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import { ThemeProvider } from "@kilocode/kilo-ui/theme"
|
||||
import { DialogProvider, useDialog } from "@kilocode/kilo-ui/context/dialog"
|
||||
import { Dialog } from "@kilocode/kilo-ui/dialog"
|
||||
@@ -90,6 +91,11 @@ import { groupApplyConflicts } from "./apply-conflicts"
|
||||
import type { ReviewComment } from "./review-comments"
|
||||
import { BranchSelect } from "./BranchSelect"
|
||||
import { WorktreeItem } from "./WorktreeItem"
|
||||
import SectionHeader from "./SectionHeader"
|
||||
import { randomColor } from "./section-colors"
|
||||
import { buildTopLevelItems, isGrouped, isGroupStart, isGroupEnd, type TopLevelItem } from "./section-helpers"
|
||||
import { sectionAwareDetector } from "./section-dnd"
|
||||
import { ConstrainDragXAxis } from "./constrain-drag-x"
|
||||
import { mergeWorktreeDiffs } from "./diff-state"
|
||||
import "./agent-manager.css"
|
||||
import "./agent-manager-review.css"
|
||||
@@ -120,6 +126,8 @@ interface ApplyState {
|
||||
/** Sidebar selection: LOCAL for local repo, worktree ID for a worktree, or null for an unassigned session. */
|
||||
type SidebarSelection = typeof LOCAL | string | null
|
||||
|
||||
type SidePanel = "diff" | "pr" | null
|
||||
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
|
||||
|
||||
// Fallback keybindings before extension sends resolved ones
|
||||
@@ -194,7 +202,6 @@ function useTabScroll(activeTabs: Accessor<SessionInfo[]>, activeId: Accessor<st
|
||||
})
|
||||
})
|
||||
|
||||
// Auto-scroll active tab into view
|
||||
createEffect(() => {
|
||||
const id = activeId()
|
||||
const el = ref()
|
||||
@@ -321,6 +328,7 @@ const AgentManagerContent: Component = () => {
|
||||
const [localSessionIDs, setLocalSessionIDs] = createSignal<string[]>(persisted?.localSessionIDs ?? [])
|
||||
const [sidebarWidth, setSidebarWidth] = createSignal(persisted?.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH)
|
||||
const [sessionsCollapsed, setSessionsCollapsed] = createSignal(false)
|
||||
const [sections, setSections] = createSignal<SectionState[]>([])
|
||||
|
||||
// rAF coalescing for resize handlers — at most one signal write per frame
|
||||
let sidebarRaf: number | undefined
|
||||
@@ -328,8 +336,8 @@ const AgentManagerContent: Component = () => {
|
||||
let diffRaf: number | undefined
|
||||
let pendingDiffWidth: number | undefined
|
||||
|
||||
// Diff panel state
|
||||
const [diffOpen, setDiffOpen] = createSignal(false)
|
||||
const [sidePanel, setSidePanel] = createSignal<SidePanel>(null)
|
||||
const diffOpen = () => sidePanel() === "diff"
|
||||
const [diffDatas, setDiffDatas] = createSignal<Record<string, WorktreeFileDiff[]>>({})
|
||||
const [diffLoading, setDiffLoading] = createSignal(false)
|
||||
const [diffFileLoading, setDiffFileLoading] = createSignal<Record<string, Record<string, true>>>({})
|
||||
@@ -345,6 +353,9 @@ const AgentManagerContent: Component = () => {
|
||||
// Per-worktree git stats (diff additions/deletions, commits missing from origin)
|
||||
const [worktreeStats, setWorktreeStats] = createSignal<Record<string, WorktreeGitStats>>({})
|
||||
|
||||
// Per-worktree PR status data
|
||||
const [prStatuses, setPrStatuses] = createSignal<Record<string, PRStatus | null>>({})
|
||||
|
||||
// Local repo git stats (branch name, diff additions/deletions, commits)
|
||||
const [localStats, setLocalStats] = createSignal<LocalGitStats | undefined>()
|
||||
|
||||
@@ -609,6 +620,8 @@ const AgentManagerContent: Component = () => {
|
||||
// Sidebar worktree order (persisted to extension state)
|
||||
const [sidebarWorktreeOrder, setSidebarWorktreeOrder] = createSignal<string[]>([])
|
||||
const [draggingWorktree, setDraggingWorktree] = createSignal<string | undefined>()
|
||||
const [renamingSection, setRenamingSection] = createSignal<string | null>(null)
|
||||
let pendingNewSection = false
|
||||
|
||||
const addPendingTab = () => {
|
||||
const id = `${PENDING_PREFIX}${++pendingCounter}`
|
||||
@@ -786,6 +799,11 @@ const AgentManagerContent: Component = () => {
|
||||
return firstOrderedTitle(sessions, worktreeTabOrder()[wt.id], wt.branch)
|
||||
}
|
||||
|
||||
const worktreeSubtitle = (wt: WorktreeState): string | undefined => {
|
||||
const label = worktreeLabel(wt)
|
||||
return label !== wt.branch ? wt.branch : undefined
|
||||
}
|
||||
|
||||
const isStaleWorktree = (worktreeId: string): boolean => staleWorktreeIds().has(worktreeId)
|
||||
|
||||
/** True when any session in the given ID list is actively working (busy/retry and not blocked by permissions/questions). */
|
||||
@@ -849,23 +867,23 @@ const AgentManagerContent: Component = () => {
|
||||
return result
|
||||
})
|
||||
|
||||
/** Check if this worktree is part of a group. */
|
||||
const isGrouped = (wt: WorktreeState) => !!wt.groupId
|
||||
|
||||
/** Check if this is the first item in its group. */
|
||||
const isGroupStart = (wt: WorktreeState, idx: number) => {
|
||||
if (!wt.groupId) return false
|
||||
const list = sortedWorktrees()
|
||||
if (idx === 0) return true
|
||||
return list[idx - 1]?.groupId !== wt.groupId
|
||||
}
|
||||
|
||||
/** Check if this is the last item in its group. */
|
||||
const isGroupEnd = (wt: WorktreeState, idx: number) => {
|
||||
if (!wt.groupId) return false
|
||||
const list = sortedWorktrees()
|
||||
if (idx === list.length - 1) return true
|
||||
return list[idx + 1]?.groupId !== wt.groupId
|
||||
const worktreesInSection = (id: string) => sortedWorktrees().filter((wt) => wt.sectionId === id)
|
||||
const ungrouped = createMemo(() => sortedWorktrees().filter((wt) => !wt.sectionId))
|
||||
const topLevelItems = createMemo((): TopLevelItem[] =>
|
||||
buildTopLevelItems(sections(), ungrouped(), sortedWorktrees(), sidebarWorktreeOrder()),
|
||||
)
|
||||
const moveToSection = (ids: string[], sec: string | null) =>
|
||||
vscode.postMessage({ type: "agentManager.moveToSection", worktreeIds: ids, sectionId: sec })
|
||||
const moveSection = (sectionId: string, dir: -1 | 1) =>
|
||||
vscode.postMessage({ type: "agentManager.moveSection", sectionId, dir })
|
||||
const newSection = (ids?: string[]) => {
|
||||
pendingNewSection = true
|
||||
vscode.postMessage({
|
||||
type: "agentManager.createSection",
|
||||
name: t("agentManager.section.defaultName"),
|
||||
color: randomColor(),
|
||||
worktreeIds: ids,
|
||||
})
|
||||
}
|
||||
|
||||
const scrollIntoView = (el: HTMLElement) => {
|
||||
@@ -1012,9 +1030,9 @@ const AgentManagerContent: Component = () => {
|
||||
} else if (msg.action === "toggleDiff") {
|
||||
if (reviewActive()) {
|
||||
closeReviewTab()
|
||||
setDiffOpen(true)
|
||||
setSidePanel("diff")
|
||||
} else {
|
||||
setDiffOpen((prev) => !prev)
|
||||
setSidePanel((prev) => (prev === "diff" ? null : "diff"))
|
||||
}
|
||||
} else if (msg.action === "newTab") handleNewTabForCurrentSelection()
|
||||
else if (msg.action === "closeTab") closeActiveTab()
|
||||
@@ -1153,7 +1171,7 @@ const AgentManagerContent: Component = () => {
|
||||
setSelection(ev.worktreeId)
|
||||
}
|
||||
// Close diff/review panels — nothing to show during setup
|
||||
setDiffOpen(false)
|
||||
setSidePanel(null)
|
||||
setReviewActive(false)
|
||||
setSetup({ active: true, message: ev.message, branch: ev.branch, worktreeId: ev.worktreeId })
|
||||
}
|
||||
@@ -1194,6 +1212,14 @@ const AgentManagerContent: Component = () => {
|
||||
// When not a git repo, also mark sessions as loaded since the Kilo
|
||||
// server won't connect to send the sessionsLoaded message.
|
||||
if (state.isGitRepo === false && !sessionsLoaded()) setSessionsLoaded(true)
|
||||
const prev = new Set(sections().map((s) => s.id)),
|
||||
incoming = state.sections ?? []
|
||||
setSections(incoming)
|
||||
if (pendingNewSection) {
|
||||
pendingNewSection = false
|
||||
const c = incoming.find((s) => !prev.has(s.id))
|
||||
if (c) setRenamingSection(c.id)
|
||||
}
|
||||
if (state.tabOrder) setWorktreeTabOrder(state.tabOrder)
|
||||
if (state.worktreeOrder) setSidebarWorktreeOrder(state.worktreeOrder)
|
||||
if (state.reviewDiffStyle === "split" || state.reviewDiffStyle === "unified") {
|
||||
@@ -1383,6 +1409,11 @@ const AgentManagerContent: Component = () => {
|
||||
setLocalStats(ev.stats)
|
||||
setRepoBranch(ev.stats.branch)
|
||||
}
|
||||
|
||||
if (msg.type === "agentManager.prStatus") {
|
||||
const ev = msg as AgentManagerPRStatusMessage
|
||||
setPrStatuses((prev) => ({ ...prev, [ev.worktreeId]: ev.pr }))
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
@@ -1458,7 +1489,7 @@ const AgentManagerContent: Component = () => {
|
||||
const openReviewTab = () => {
|
||||
const sel = selection()
|
||||
if (sel === null) return
|
||||
setDiffOpen(false)
|
||||
setSidePanel(null)
|
||||
setReviewOpenForContext(sel, true)
|
||||
setReviewActive(true)
|
||||
}
|
||||
@@ -2128,6 +2159,11 @@ const AgentManagerContent: Component = () => {
|
||||
))}
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onSelect={() => newSection()}>
|
||||
<Icon name="plus" size="small" />
|
||||
<DropdownMenu.ItemLabel>{t("agentManager.worktree.newSection")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
@@ -2216,56 +2252,40 @@ const AgentManagerContent: Component = () => {
|
||||
setRenamingWt(null)
|
||||
}
|
||||
|
||||
const hasSections = createMemo(() => sections().length > 0)
|
||||
const wtIds = createMemo(() => sortedWorktrees().map((wt) => wt.id))
|
||||
const secIds = createMemo(() => new Set(sections().map((s) => s.id)))
|
||||
const home = () => new Map(sortedWorktrees().map((w) => [w.id, w.sectionId] as const))
|
||||
const sectionAware = sectionAwareDetector(secIds, home)
|
||||
|
||||
const onWtDragStart = (event: DragEvent) => {
|
||||
const id = event.draggable?.id
|
||||
if (typeof id === "string") setDraggingWorktree(id)
|
||||
document.body.classList.add("am-wt-dragging-active")
|
||||
}
|
||||
|
||||
const onWtDragOver = (event: DragEvent) => {
|
||||
const from = event.draggable?.id
|
||||
const to = event.droppable?.id
|
||||
if (typeof from !== "string" || typeof to !== "string") return
|
||||
if (secIds().has(to)) return
|
||||
setSidebarWorktreeOrder((prev) => {
|
||||
const current = applyTabOrder(
|
||||
sortedWorktrees().map((wt) => ({ id: wt.id })),
|
||||
const cur = applyTabOrder(
|
||||
sortedWorktrees().map((w) => ({ id: w.id })),
|
||||
prev,
|
||||
).map((item) => item.id)
|
||||
return reorderTabs(current, from, to) ?? prev
|
||||
).map((i) => i.id)
|
||||
return reorderTabs(cur, from, to) ?? prev
|
||||
})
|
||||
}
|
||||
|
||||
const onWtDragEnd = () => {
|
||||
const onWtDragEnd = (event: DragEvent) => {
|
||||
const from = event.draggable?.id
|
||||
const to = event.droppable?.id
|
||||
setDraggingWorktree(undefined)
|
||||
document.body.classList.remove("am-wt-dragging-active")
|
||||
const order = sortedWorktrees().map((wt) => wt.id)
|
||||
if (order.length > 0) {
|
||||
vscode.postMessage({ type: "agentManager.setWorktreeOrder", order })
|
||||
if (typeof from === "string" && typeof to === "string" && secIds().has(to)) {
|
||||
moveToSection([from], to)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/** Lock drag movement to the Y axis (vertical-only worktree dragging). */
|
||||
const ConstrainDragXAxis: Component = () => {
|
||||
const ctx = useDragDropContext()
|
||||
if (!ctx) return null
|
||||
const [
|
||||
,
|
||||
{ onDragStart: onStart, onDragEnd: onEnd, addTransformer: add, removeTransformer: remove },
|
||||
] = ctx
|
||||
const xform: Transformer = { id: "constrain-x-axis", order: 100, callback: (t) => ({ ...t, x: 0 }) }
|
||||
const dispose = createRoot((dispose) => {
|
||||
onStart(({ draggable }) => {
|
||||
if (draggable) add("draggables", draggable.id as string, xform)
|
||||
})
|
||||
onEnd(({ draggable }) => {
|
||||
if (draggable) remove("draggables", draggable.id as string, xform.id)
|
||||
})
|
||||
return dispose
|
||||
})
|
||||
onCleanup(dispose)
|
||||
return null
|
||||
vscode.postMessage({ type: "agentManager.setWorktreeOrder", order: sidebarWorktreeOrder() })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -2273,33 +2293,35 @@ const AgentManagerContent: Component = () => {
|
||||
onDragStart={onWtDragStart}
|
||||
onDragEnd={onWtDragEnd}
|
||||
onDragOver={onWtDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
collisionDetector={sectionAware}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragXAxis />
|
||||
<SortableProvider ids={wtIds()}>
|
||||
<For each={sortedWorktrees()}>
|
||||
{(wt, idx) => {
|
||||
const sessions = createMemo(() => managedSessions().filter((ms) => ms.worktreeId === wt.id))
|
||||
const navHint = () => {
|
||||
const flat = [
|
||||
LOCAL as string,
|
||||
...sortedWorktrees().map((w) => w.id),
|
||||
...unassignedSessions().map((s) => s.id),
|
||||
]
|
||||
const active = selection() ?? session.currentSessionID() ?? ""
|
||||
return adjacentHint(
|
||||
{(() => {
|
||||
const renderWt = (
|
||||
wt: WorktreeState,
|
||||
idx: () => number,
|
||||
inSection?: boolean,
|
||||
list?: WorktreeState[],
|
||||
) => {
|
||||
const wtSessions = createMemo(() =>
|
||||
managedSessions().filter((ms) => ms.worktreeId === wt.id),
|
||||
)
|
||||
const navHint = () =>
|
||||
adjacentHint(
|
||||
wt.id,
|
||||
active,
|
||||
flat,
|
||||
selection() ?? session.currentSessionID() ?? "",
|
||||
[
|
||||
LOCAL as string,
|
||||
...sortedWorktrees().map((w) => w.id),
|
||||
...unassignedSessions().map((s) => s.id),
|
||||
],
|
||||
kb().previousSession ?? "",
|
||||
kb().nextSession ?? "",
|
||||
)
|
||||
}
|
||||
const groupSize = () => {
|
||||
if (!wt.groupId) return 0
|
||||
return sortedWorktrees().filter((w) => w.groupId === wt.groupId).length
|
||||
}
|
||||
const groupSize = () =>
|
||||
!wt.groupId ? 0 : sortedWorktrees().filter((w) => w.groupId === wt.groupId).length
|
||||
const sortable = createSortable(wt.id)
|
||||
void sortable
|
||||
return (
|
||||
@@ -2315,18 +2337,22 @@ const AgentManagerContent: Component = () => {
|
||||
busy={busyWorktrees().has(wt.id)}
|
||||
working={isAgentBusy(wt.id)}
|
||||
stale={isStaleWorktree(wt.id)}
|
||||
shortcut={idx() + 2}
|
||||
shortcut={inSection ? undefined : idx() + 2}
|
||||
stats={worktreeStats()[wt.id]}
|
||||
navHint={navHint()}
|
||||
sessions={sessions().length}
|
||||
sessions={wtSessions().length}
|
||||
grouped={isGrouped(wt)}
|
||||
groupStart={isGroupStart(wt, idx())}
|
||||
groupEnd={isGroupEnd(wt, idx())}
|
||||
groupStart={isGroupStart(wt, idx(), list ?? sortedWorktrees())}
|
||||
groupEnd={isGroupEnd(wt, idx(), list ?? sortedWorktrees())}
|
||||
groupSize={groupSize()}
|
||||
renaming={renamingWt() === wt.id}
|
||||
renameValue={renameValue()}
|
||||
closeKeybind={kb().closeWorktree ?? ""}
|
||||
openKeybind={kb().openWorktree ?? ""}
|
||||
sections={sections()}
|
||||
currentSectionId={wt.sectionId}
|
||||
onMoveToSection={(secId) => moveToSection([wt.id], secId)}
|
||||
onMoveToNewSection={() => newSection()}
|
||||
onClick={() => {
|
||||
if (pendingDelete() === wt.id) {
|
||||
confirmDeleteWorktree(wt.id)
|
||||
@@ -2340,17 +2366,60 @@ const AgentManagerContent: Component = () => {
|
||||
onCommitRename={() => commitRename(wt.id)}
|
||||
onCancelRename={cancelRename}
|
||||
onRemoveStale={() => confirmRemoveStaleWorktree(wt.id)}
|
||||
onCopyPath={() =>
|
||||
vscode.postMessage({ type: "agentManager.copyToClipboard", text: wt.path })
|
||||
}
|
||||
onCopyPath={() => navigator.clipboard.writeText(wt.path)}
|
||||
onOpen={() =>
|
||||
vscode.postMessage({ type: "agentManager.openWorktree", worktreeId: wt.id })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
}
|
||||
if (hasSections()) {
|
||||
const post = vscode.postMessage.bind(vscode)
|
||||
return (
|
||||
<For each={topLevelItems()}>
|
||||
{(item, idx) => {
|
||||
if (item.kind === "section") {
|
||||
const sec = item.section
|
||||
const members = createMemo(() => worktreesInSection(sec.id))
|
||||
return (
|
||||
<SectionHeader
|
||||
section={sec}
|
||||
count={members().length}
|
||||
autoRename={renamingSection() === sec.id}
|
||||
onRenameEnd={() => setRenamingSection(null)}
|
||||
onToggle={() =>
|
||||
post({ type: "agentManager.toggleSectionCollapsed", sectionId: sec.id })
|
||||
}
|
||||
onRename={(name) =>
|
||||
post({ type: "agentManager.renameSection", sectionId: sec.id, name })
|
||||
}
|
||||
onDelete={() => post({ type: "agentManager.deleteSection", sectionId: sec.id })}
|
||||
onSetColor={(color) =>
|
||||
post({ type: "agentManager.setSectionColor", sectionId: sec.id, color })
|
||||
}
|
||||
isFirst={idx() === 0}
|
||||
isLast={idx() === topLevelItems().length - 1}
|
||||
onMoveUp={() => moveSection(sec.id, -1)}
|
||||
onMoveDown={() => moveSection(sec.id, 1)}
|
||||
>
|
||||
<Show when={!sec.collapsed}>
|
||||
<div class="am-section-group-body">
|
||||
<For each={members()}>
|
||||
{(wt, wtIdx) => renderWt(wt, wtIdx, true, members())}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</SectionHeader>
|
||||
)
|
||||
}
|
||||
return renderWt(item.wt, idx)
|
||||
}}
|
||||
</For>
|
||||
)
|
||||
}
|
||||
return <For each={sortedWorktrees()}>{(wt, idx) => renderWt(wt, idx)}</For>
|
||||
})()}
|
||||
</SortableProvider>
|
||||
<DragOverlay>
|
||||
{(() => {
|
||||
@@ -2622,10 +2691,10 @@ const AgentManagerContent: Component = () => {
|
||||
onClick={() => {
|
||||
if (reviewActive()) {
|
||||
closeReviewTab()
|
||||
setDiffOpen(true)
|
||||
setSidePanel("diff")
|
||||
return
|
||||
}
|
||||
setDiffOpen((prev) => !prev)
|
||||
setSidePanel((prev) => (prev === "diff" ? null : "diff"))
|
||||
}}
|
||||
title={t("agentManager.diff.toggle")}
|
||||
>
|
||||
@@ -2752,7 +2821,7 @@ const AgentManagerContent: Component = () => {
|
||||
<Show when={!contextEmpty()}>
|
||||
{/* Chat + side diff panel (hidden when review tab is active) */}
|
||||
<div
|
||||
class={`am-detail-content ${diffOpen() ? "am-detail-split" : ""}`}
|
||||
class={`am-detail-content ${sidePanel() !== null ? "am-detail-split" : ""}`}
|
||||
style={{ display: reviewActive() ? "none" : undefined }}
|
||||
>
|
||||
<div class="am-chat-wrapper">
|
||||
@@ -2810,7 +2879,7 @@ const AgentManagerContent: Component = () => {
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={diffOpen()}>
|
||||
<Show when={sidePanel() !== null}>
|
||||
<div class="am-diff-resize" style={{ width: `${diffWidth()}px` }}>
|
||||
<ResizeHandle
|
||||
direction="horizontal"
|
||||
@@ -2829,25 +2898,27 @@ const AgentManagerContent: Component = () => {
|
||||
}}
|
||||
/>
|
||||
<div class="am-diff-panel-wrapper">
|
||||
<DiffPanel
|
||||
diffs={reviewDiffs()}
|
||||
loading={diffLoading()}
|
||||
loadingFiles={diffFileLoadingForCurrent()}
|
||||
sessionId={currentDiffSessionId()}
|
||||
sessionKey={diffSessionKey()}
|
||||
diffStyle={reviewDiffStyle()}
|
||||
onDiffStyleChange={setSharedDiffStyle}
|
||||
comments={reviewComments()}
|
||||
onCommentsChange={setReviewCommentsForSelection}
|
||||
onClose={() => setDiffOpen(false)}
|
||||
onExpand={selection() !== null ? openReviewTab : undefined}
|
||||
onRequestDiff={requestDiffFile}
|
||||
onOpenFile={(file) => {
|
||||
const id = currentDiffSessionId()
|
||||
if (id) vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file })
|
||||
else if (selection() === LOCAL) vscode.postMessage({ type: "openFile", filePath: file })
|
||||
}}
|
||||
/>
|
||||
<Show when={sidePanel() === "diff"}>
|
||||
<DiffPanel
|
||||
diffs={reviewDiffs()}
|
||||
loading={diffLoading()}
|
||||
loadingFiles={diffFileLoadingForCurrent()}
|
||||
sessionId={currentDiffSessionId()}
|
||||
sessionKey={diffSessionKey()}
|
||||
diffStyle={reviewDiffStyle()}
|
||||
onDiffStyleChange={setSharedDiffStyle}
|
||||
comments={reviewComments()}
|
||||
onCommentsChange={setReviewCommentsForSelection}
|
||||
onClose={() => setSidePanel(null)}
|
||||
onExpand={selection() !== null ? openReviewTab : undefined}
|
||||
onRequestDiff={requestDiffFile}
|
||||
onOpenFile={(file) => {
|
||||
const id = currentDiffSessionId()
|
||||
if (id) vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file })
|
||||
else if (selection() === LOCAL) vscode.postMessage({ type: "openFile", filePath: file })
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -307,6 +307,11 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
|
||||
sendAllToChat()
|
||||
}
|
||||
|
||||
const handleExpandAll = () => {
|
||||
const allOpen = open().length === props.diffs.length
|
||||
setOpen(allOpen ? [] : props.diffs.map((d) => d.file))
|
||||
}
|
||||
|
||||
const totals = createMemo(() => ({
|
||||
files: props.diffs.length,
|
||||
additions: props.diffs.reduce((sum, diff) => sum + diff.additions, 0),
|
||||
@@ -354,6 +359,28 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
<div class="am-diff-header-actions">
|
||||
<Show when={props.diffs.length > 0}>
|
||||
<Tooltip
|
||||
value={
|
||||
open().length === props.diffs.length
|
||||
? t("ui.sessionReview.collapseAll")
|
||||
: t("ui.sessionReview.expandAll")
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
<IconButton
|
||||
icon="chevron-grabber-vertical"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label={
|
||||
open().length === props.diffs.length
|
||||
? t("ui.sessionReview.collapseAll")
|
||||
: t("ui.sessionReview.expandAll")
|
||||
}
|
||||
onClick={handleExpandAll}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={props.onExpand}>
|
||||
<Tooltip value={t("command.review.toggle")} placement="bottom">
|
||||
<IconButton
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Component, Show, createEffect, createSignal, type JSX } from "solid-js"
|
||||
import { createDroppable } from "@thisbeyond/solid-dnd"
|
||||
import { ContextMenu } from "@kilocode/kilo-ui/context-menu"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import type { SectionState } from "../src/types/messages"
|
||||
import { SECTION_COLORS, colorCss } from "./section-colors"
|
||||
import { useLanguage } from "../src/context/language"
|
||||
|
||||
interface Props {
|
||||
section: SectionState
|
||||
count: number
|
||||
children?: JSX.Element
|
||||
/** When true, auto-enter rename mode (e.g. after creation). */
|
||||
autoRename?: boolean
|
||||
onToggle: () => void
|
||||
onRename: (name: string) => void
|
||||
onDelete: () => void
|
||||
onSetColor: (color: string | null) => void
|
||||
/** Called when rename ends (commit or cancel) so parent clears autoRename. */
|
||||
onRenameEnd?: () => void
|
||||
onMoveUp?: () => void
|
||||
onMoveDown?: () => void
|
||||
isFirst?: boolean
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
const SectionHeader: Component<Props> = (props) => {
|
||||
const { t } = useLanguage()
|
||||
const [renaming, setRenaming] = createSignal(false)
|
||||
const [value, setValue] = createSignal("")
|
||||
|
||||
const border = () => colorCss(props.section.color) ?? "var(--vscode-panel-border)"
|
||||
|
||||
const startRename = () => {
|
||||
setValue(props.section.name)
|
||||
setRenaming(true)
|
||||
}
|
||||
|
||||
const commit = () => {
|
||||
const trimmed = value().trim()
|
||||
setRenaming(false)
|
||||
props.onRenameEnd?.()
|
||||
if (trimmed && trimmed !== props.section.name) {
|
||||
props.onRename(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
setRenaming(false)
|
||||
props.onRenameEnd?.()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (props.autoRename && !renaming()) startRename()
|
||||
})
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (e.button !== 0 || renaming()) return
|
||||
props.onToggle()
|
||||
}
|
||||
|
||||
const droppable = createDroppable(props.section.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={droppable.ref}
|
||||
class={`am-section-group ${droppable.isActiveDroppable ? "am-section-group-drop" : ""}`}
|
||||
style={{ "--section-color": border() }}
|
||||
>
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger class="am-section-group-header" onClick={handleClick}>
|
||||
<div class="am-section-group-left">
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
size="small"
|
||||
class={`am-section-group-chevron ${props.section.collapsed ? "am-section-group-chevron-collapsed" : ""}`}
|
||||
/>
|
||||
<Show
|
||||
when={!renaming()}
|
||||
fallback={
|
||||
<input
|
||||
class="am-section-group-rename"
|
||||
value={value()}
|
||||
onInput={(e) => setValue(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commit()
|
||||
if (e.key === "Escape") cancel()
|
||||
}}
|
||||
onBlur={commit}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
ref={(el) =>
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => {
|
||||
el.focus()
|
||||
el.select()
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span class="am-section-group-name">{props.section.name}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<span class="am-section-group-count">{props.count}</span>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content class="am-ctx-menu">
|
||||
<ContextMenu.Item onSelect={startRename}>
|
||||
<Icon name="edit" size="small" />
|
||||
<ContextMenu.ItemLabel>{t("agentManager.section.rename")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Group>
|
||||
<ContextMenu.GroupLabel>{t("agentManager.section.setColor")}</ContextMenu.GroupLabel>
|
||||
<div class="am-color-grid">
|
||||
<ContextMenu.Item onSelect={() => props.onSetColor(null)} class="am-color-grid-item">
|
||||
<span class="am-color-swatch am-color-swatch-default"></span>
|
||||
</ContextMenu.Item>
|
||||
{SECTION_COLORS.map((c) => (
|
||||
<ContextMenu.Item onSelect={() => props.onSetColor(c.label)} class="am-color-grid-item">
|
||||
<span
|
||||
class={`am-color-swatch ${props.section.color === c.label ? "am-color-swatch-active" : ""}`}
|
||||
style={{ background: c.css }}
|
||||
></span>
|
||||
</ContextMenu.Item>
|
||||
))}
|
||||
</div>
|
||||
</ContextMenu.Group>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item onSelect={() => props.onMoveUp?.()} disabled={props.isFirst}>
|
||||
<Icon name="arrow-up" size="small" />
|
||||
<ContextMenu.ItemLabel>{t("agentManager.section.moveUp")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Item onSelect={() => props.onMoveDown?.()} disabled={props.isLast}>
|
||||
<Icon name="arrow-up" size="small" class="am-icon-flip" />
|
||||
<ContextMenu.ItemLabel>{t("agentManager.section.moveDown")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item onSelect={props.onDelete} class="am-ctx-menu-danger">
|
||||
<Icon name="trash" size="small" />
|
||||
<ContextMenu.ItemLabel>{t("agentManager.section.delete")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SectionHeader
|
||||
@@ -2,7 +2,7 @@
|
||||
* Sidebar worktree item with inline delete confirmation, HoverCard, rename, and stats.
|
||||
* Extracted from AgentManagerApp for reuse and visual-regression testing via Storybook.
|
||||
*/
|
||||
import { Component, Show, createSignal } from "solid-js"
|
||||
import { Component, For, Show, createSignal } from "solid-js"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
@@ -10,7 +10,8 @@ import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
|
||||
import { HoverCard } from "@kilocode/kilo-ui/hover-card"
|
||||
import { ContextMenu } from "@kilocode/kilo-ui/context-menu"
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import type { WorktreeState, WorktreeGitStats } from "../src/types/messages"
|
||||
import type { WorktreeState, WorktreeGitStats, PRStatus, SectionState } from "../src/types/messages"
|
||||
import { colorCss } from "./section-colors"
|
||||
import { useLanguage } from "../src/context/language"
|
||||
import { formatRelativeDate } from "../src/utils/date"
|
||||
|
||||
@@ -22,14 +23,16 @@ interface WorktreeItemProps {
|
||||
worktree: WorktreeState
|
||||
/** Display label (resolved from label, first session title, or branch). */
|
||||
label: string
|
||||
/** Branch name shown as subtitle when it differs from the label. */
|
||||
subtitle?: string
|
||||
active: boolean
|
||||
pendingDelete: boolean
|
||||
busy: boolean
|
||||
/** Whether an agent session on this worktree is actively working (shows spinner instead of branch icon). */
|
||||
working: boolean
|
||||
stale: boolean
|
||||
/** 1-indexed shortcut number shown as ⌘2, ⌘3, etc. Pass 0 or >9 to hide. */
|
||||
shortcut: number
|
||||
/** 1-indexed shortcut number shown as ⌘2, ⌘3, etc. Pass 0, >9, or undefined to hide. */
|
||||
shortcut?: number
|
||||
stats?: WorktreeGitStats
|
||||
/** Navigation hint text shown in the hover card (e.g. "⌘⌥↑"). */
|
||||
navHint?: string
|
||||
@@ -51,6 +54,18 @@ interface WorktreeItemProps {
|
||||
closeKeybind: string
|
||||
/** Keybinding string for the open-in-vscode action. */
|
||||
openKeybind: string
|
||||
/** PR status for this worktree's branch, or null if no PR. */
|
||||
pr?: PRStatus | null
|
||||
/** Callback when the PR badge is clicked. */
|
||||
onOpenPR?: () => void
|
||||
/** Available sections for the "Move to Section" submenu. */
|
||||
sections?: SectionState[]
|
||||
/** ID of the section this worktree currently belongs to (for disabling current item). */
|
||||
currentSectionId?: string
|
||||
/** Move this worktree to a section (or null for ungrouped). */
|
||||
onMoveToSection?: (sectionId: string | null) => void
|
||||
/** Move this worktree to a new section. */
|
||||
onMoveToNewSection?: () => void
|
||||
|
||||
onClick: () => void
|
||||
onDelete: (e: MouseEvent) => void
|
||||
@@ -68,11 +83,41 @@ const MAX_SHORTCUT = 9
|
||||
const hasStats = (s: WorktreeGitStats | undefined): s is WorktreeGitStats =>
|
||||
!!s && (s.files > 0 || s.additions > 0 || s.deletions > 0 || s.ahead > 0 || s.behind > 0)
|
||||
|
||||
/** Returns the accent color for a PR badge based on state priority. */
|
||||
export function prAccentColor(pr: PRStatus): string {
|
||||
if (pr.state === "draft") return "var(--text-weaker)"
|
||||
if (pr.state === "merged") return "#a78bfa"
|
||||
if (pr.state === "closed") return "#f87171"
|
||||
if (pr.checks.status === "failure") return "#ef4444"
|
||||
if (pr.review === "changes_requested") return "#fbbf24"
|
||||
if (pr.checks.status === "pending") return "#fbbf24"
|
||||
return "#34d399"
|
||||
}
|
||||
|
||||
function prStateLabel(state: PRStatus["state"]): string {
|
||||
if (state === "draft") return "Draft"
|
||||
if (state === "merged") return "Merged"
|
||||
if (state === "closed") return "Closed"
|
||||
return "Open"
|
||||
}
|
||||
|
||||
function reviewLabel(review: string): string {
|
||||
if (review === "approved") return "Approved"
|
||||
if (review === "changes_requested") return "Changes Requested"
|
||||
return "Pending"
|
||||
}
|
||||
|
||||
export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
|
||||
const { t } = useLanguage()
|
||||
const [hovered, setHovered] = createSignal(false)
|
||||
const [overClose, setOverClose] = createSignal(false)
|
||||
|
||||
const handleOpenPR = (e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
props.onOpenPR?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={props.groupStart}>
|
||||
@@ -102,128 +147,164 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
|
||||
data-sidebar-id={props.worktree.id}
|
||||
onClick={() => props.onClick()}
|
||||
>
|
||||
<Show when={!props.busy && !props.working} fallback={<Spinner class="am-worktree-spinner" />}>
|
||||
<Icon name="branch" size="small" />
|
||||
</Show>
|
||||
<Show when={props.stale}>
|
||||
<Tooltip
|
||||
value={t("agentManager.worktree.staleTooltip")}
|
||||
placement="top"
|
||||
contentClass="am-tooltip-wrap"
|
||||
>
|
||||
<span class="am-worktree-stale-badge">
|
||||
<Icon name="warning" size="small" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show
|
||||
when={props.renaming}
|
||||
fallback={
|
||||
<span
|
||||
class="am-worktree-branch"
|
||||
onDblClick={(e) => {
|
||||
e.stopPropagation()
|
||||
props.onStartRename(props.label)
|
||||
}}
|
||||
title={t("agentManager.worktree.doubleClickRename")}
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<input
|
||||
class="am-worktree-rename-input"
|
||||
value={props.renameValue}
|
||||
onInput={(e) => props.onRenameInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
props.onCommitRename()
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
props.onCancelRename()
|
||||
}
|
||||
}}
|
||||
onBlur={() => props.onCommitRename()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
ref={(el) =>
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => {
|
||||
el.focus()
|
||||
el.select()
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={props.shortcut >= 2 && props.shortcut <= MAX_SHORTCUT}>
|
||||
<span class="am-shortcut-badge">
|
||||
{isMac ? "⌘" : "Ctrl+"}
|
||||
{props.shortcut}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={props.stats === undefined}>
|
||||
<div class="am-worktree-stats-skeleton">
|
||||
<div class="am-worktree-stats-skeleton-row" />
|
||||
<div class="am-worktree-stats-skeleton-row" style={{ width: "70%" }} />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={hasStats(props.stats)}>
|
||||
<div class="am-worktree-stats">
|
||||
<div class="am-wt-icon">
|
||||
<Show when={!props.busy && !props.working} fallback={<Spinner class="am-worktree-spinner" />}>
|
||||
<Icon name="branch" size="small" />
|
||||
</Show>
|
||||
</div>
|
||||
<div class="am-wt-content">
|
||||
{/* Row 1: label + stale badge + stats/hover-actions overlay */}
|
||||
<div class="am-wt-row1">
|
||||
<Show when={props.stale}>
|
||||
<Tooltip
|
||||
value={t("agentManager.worktree.staleTooltip")}
|
||||
placement="top"
|
||||
contentClass="am-tooltip-wrap"
|
||||
>
|
||||
<span class="am-worktree-stale-badge">
|
||||
<Icon name="warning" size="small" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show
|
||||
when={props.stats!.additions > 0 || props.stats!.deletions > 0}
|
||||
when={props.renaming}
|
||||
fallback={
|
||||
<Show when={props.stats!.files > 0}>
|
||||
<span class="am-stat-files">{props.stats!.files}f</span>
|
||||
<span
|
||||
class="am-worktree-branch"
|
||||
onDblClick={(e) => {
|
||||
e.stopPropagation()
|
||||
props.onStartRename(props.label)
|
||||
}}
|
||||
title={t("agentManager.worktree.doubleClickRename")}
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<input
|
||||
class="am-worktree-rename-input"
|
||||
value={props.renameValue}
|
||||
onInput={(e) => props.onRenameInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
props.onCommitRename()
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
props.onCancelRename()
|
||||
}
|
||||
}}
|
||||
onBlur={() => props.onCommitRename()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
ref={(el) =>
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => {
|
||||
el.focus()
|
||||
el.select()
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
{/* Grid cell: stats visible by default, hover actions on top */}
|
||||
<div class="am-wt-actions-cell">
|
||||
<Show when={props.stats === undefined}>
|
||||
<div class="am-worktree-stats-skeleton">
|
||||
<div class="am-worktree-stats-skeleton-row" />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={hasStats(props.stats)}>
|
||||
<div class="am-worktree-stats">
|
||||
<Show when={props.stats!.behind > 0}>
|
||||
<span class="am-worktree-behind">↓{props.stats!.behind}</span>
|
||||
</Show>
|
||||
<Show when={props.stats!.ahead > 0}>
|
||||
<span class="am-worktree-commits">↑{props.stats!.ahead}</span>
|
||||
</Show>
|
||||
<Show
|
||||
when={props.stats!.additions > 0 || props.stats!.deletions > 0}
|
||||
fallback={
|
||||
<Show when={props.stats!.files > 0}>
|
||||
<span class="am-stat-files">{props.stats!.files}f</span>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Show when={props.stats!.additions > 0}>
|
||||
<span class="am-stat-additions">+{props.stats!.additions}</span>
|
||||
</Show>
|
||||
<Show when={props.stats!.deletions > 0}>
|
||||
<span class="am-stat-deletions">−{props.stats!.deletions}</span>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.pendingDelete && !props.busy}>
|
||||
<span class="am-worktree-delete-hint">{t("agentManager.worktree.confirmDelete")}</span>
|
||||
</Show>
|
||||
<div class="am-wt-hover-actions">
|
||||
<Show
|
||||
when={props.shortcut !== undefined && props.shortcut >= 2 && props.shortcut <= MAX_SHORTCUT}
|
||||
>
|
||||
<span class="am-shortcut-badge">
|
||||
{isMac ? "⌘" : "Ctrl+"}
|
||||
{props.shortcut}
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={!props.busy && !props.pendingDelete}>
|
||||
<div
|
||||
class="am-worktree-close"
|
||||
onMouseEnter={() => setOverClose(true)}
|
||||
onMouseLeave={() => setOverClose(false)}
|
||||
>
|
||||
<TooltipKeybind
|
||||
title={t("agentManager.worktree.delete")}
|
||||
keybind={props.closeKeybind}
|
||||
placement="top"
|
||||
>
|
||||
<IconButton
|
||||
icon="trash"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label={t("agentManager.worktree.delete")}
|
||||
onClick={(e: MouseEvent) => props.onDelete(e)}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Row 2: branch subtitle + PR badge */}
|
||||
<div class="am-wt-row2">
|
||||
<Show when={props.subtitle}>
|
||||
<span class="am-worktree-subtitle">{props.subtitle}</span>
|
||||
</Show>
|
||||
<Show
|
||||
when={props.pr}
|
||||
fallback={
|
||||
<Show when={props.stats === undefined}>
|
||||
<div class="am-pr-badge-skeleton" />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="am-worktree-stats-row">
|
||||
<Show when={props.stats!.additions > 0}>
|
||||
<span class="am-stat-additions">+{props.stats!.additions}</span>
|
||||
</Show>
|
||||
<Show when={props.stats!.deletions > 0}>
|
||||
<span class="am-stat-deletions">−{props.stats!.deletions}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.stats!.ahead > 0 || props.stats!.behind > 0}>
|
||||
<div class="am-worktree-stats-row">
|
||||
<Show when={props.stats!.ahead > 0}>
|
||||
<span class="am-worktree-commits">↑{props.stats!.ahead}</span>
|
||||
</Show>
|
||||
<Show when={props.stats!.behind > 0}>
|
||||
<span class="am-worktree-behind">↓{props.stats!.behind}</span>
|
||||
</Show>
|
||||
</div>
|
||||
{(pr) => {
|
||||
const accent = () => prAccentColor(pr())
|
||||
return (
|
||||
<span
|
||||
class="am-pr-badge"
|
||||
style={{ "--pr-accent": accent() }}
|
||||
data-pending={pr().state === "open" && pr().checks.status === "pending" ? "" : undefined}
|
||||
onClick={handleOpenPR}
|
||||
>
|
||||
<Icon name="branch" size="small" />
|
||||
<span class="am-pr-badge-number">#{pr().number}</span>
|
||||
</span>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.pendingDelete && !props.busy}>
|
||||
<span class="am-worktree-delete-hint">{t("agentManager.worktree.confirmDelete")}</span>
|
||||
</Show>
|
||||
<Show when={!props.busy && !props.pendingDelete}>
|
||||
<div
|
||||
class="am-worktree-close"
|
||||
onMouseEnter={() => setOverClose(true)}
|
||||
onMouseLeave={() => setOverClose(false)}
|
||||
>
|
||||
<TooltipKeybind
|
||||
title={t("agentManager.worktree.delete")}
|
||||
keybind={props.closeKeybind}
|
||||
placement="top"
|
||||
>
|
||||
<IconButton
|
||||
icon="trash"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
label={t("agentManager.worktree.delete")}
|
||||
onClick={(e: MouseEvent) => props.onDelete(e)}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenu.Trigger>
|
||||
}
|
||||
@@ -313,6 +394,34 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={props.pr}>
|
||||
{(pr) => (
|
||||
<>
|
||||
<div class="am-hover-card-divider" />
|
||||
<div class="am-hover-card-row">
|
||||
<span class="am-hover-card-row-label">PR #{pr().number}</span>
|
||||
<span class="am-hover-card-row-value">
|
||||
<span class="am-pr-link" onClick={handleOpenPR}>
|
||||
<Icon name="link" size="small" />
|
||||
</span>
|
||||
{prStateLabel(pr().state)}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={pr().review}>
|
||||
<div class="am-hover-card-row">
|
||||
<span class="am-hover-card-row-label">Review</span>
|
||||
<span class="am-hover-card-row-value">{reviewLabel(pr().review!)}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="am-hover-card-row">
|
||||
<span class="am-hover-card-row-label">Checks</span>
|
||||
<span class="am-hover-card-row-value">
|
||||
{pr().checks.passed}/{pr().checks.total} passed
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
<div class="am-hover-card-divider" />
|
||||
<div class="am-hover-card-hint">
|
||||
<Icon name="edit" size="small" />
|
||||
@@ -353,6 +462,38 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
|
||||
<Icon name="copy" size="small" />
|
||||
<ContextMenu.ItemLabel>{t("agentManager.worktree.copyPath")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item onSelect={() => props.onMoveToNewSection?.()}>
|
||||
<Icon name="plus" size="small" />
|
||||
<ContextMenu.ItemLabel>{t("agentManager.worktree.newSection")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
<Show when={props.sections && props.sections.length > 0}>
|
||||
<ContextMenu.Separator />
|
||||
<ContextMenu.Item onSelect={() => props.onMoveToSection?.(null)}>
|
||||
<Show when={!props.currentSectionId}>
|
||||
<Icon name="check" size="small" />
|
||||
</Show>
|
||||
<ContextMenu.ItemLabel>{t("agentManager.worktree.ungrouped")}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
<For each={props.sections}>
|
||||
{(sec) => (
|
||||
<ContextMenu.Item onSelect={() => props.onMoveToSection?.(sec.id)}>
|
||||
<Show
|
||||
when={props.currentSectionId === sec.id}
|
||||
fallback={
|
||||
<span
|
||||
class="am-color-swatch am-color-swatch-sm"
|
||||
style={{ background: colorCss(sec.color) ?? "var(--vscode-panel-border)" }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Icon name="check" size="small" />
|
||||
</Show>
|
||||
<ContextMenu.ItemLabel>{sec.name}</ContextMenu.ItemLabel>
|
||||
</ContextMenu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</ContextMenu.Content>
|
||||
</ContextMenu.Portal>
|
||||
</ContextMenu>
|
||||
|
||||
@@ -62,8 +62,7 @@
|
||||
}
|
||||
|
||||
.am-local-item-active .am-local-branch {
|
||||
color: var(--text-on-interactive-base);
|
||||
opacity: 0.7;
|
||||
color: color-mix(in srgb, var(--text-on-interactive-base) 70%, transparent);
|
||||
}
|
||||
|
||||
.am-local-item-active .am-stat-files {
|
||||
@@ -92,8 +91,8 @@
|
||||
}
|
||||
|
||||
.am-local-branch {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--text-weak);
|
||||
font-size: 10px;
|
||||
color: var(--text-weaker);
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -181,12 +180,11 @@ button.am-section-toggle:hover .am-section-label {
|
||||
}
|
||||
|
||||
.am-worktree-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
min-height: 40px;
|
||||
padding: 6px 10px;
|
||||
min-height: 36px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
@@ -197,6 +195,87 @@ button.am-section-toggle:hover .am-section-label {
|
||||
transition: background 200ms ease;
|
||||
}
|
||||
|
||||
/* Left icon column — shrink-0, vertically centered with first row */
|
||||
.am-wt-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Right content column — flex-1 with two rows */
|
||||
.am-wt-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* Row 1: label + stats/hover-actions */
|
||||
.am-wt-row1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Grid overlay cell: stats visible by default, hover actions stacked on top */
|
||||
.am-wt-actions-cell {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
.am-wt-actions-cell > * {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
/* Hover actions (shortcut badge + close button) — hidden by default, shown on hover */
|
||||
.am-wt-hover-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
.am-worktree-item:hover .am-wt-hover-actions {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
/* Stats fade out on hover (replaced by hover actions in same grid cell) */
|
||||
.am-worktree-item:hover .am-wt-actions-cell > .am-worktree-stats,
|
||||
.am-worktree-item:hover .am-wt-actions-cell > .am-worktree-stats-skeleton {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Row 2: always present for consistent height; PR badge right-aligned */
|
||||
.am-wt-row2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
.am-worktree-subtitle {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 10px;
|
||||
color: var(--text-weaker);
|
||||
line-height: 1.2;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.am-worktree-item-active .am-worktree-subtitle {
|
||||
color: color-mix(in srgb, var(--text-on-interactive-base) 70%, transparent);
|
||||
}
|
||||
|
||||
.am-worktree-item:hover {
|
||||
background: var(--surface-inset-base-hover);
|
||||
}
|
||||
@@ -238,13 +317,9 @@ button.am-section-toggle:hover .am-section-label {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Shortcut badge — hover-reveal index badge (⌘1, ⌘2, etc.) matching Superset style */
|
||||
/* Shortcut badge — hover-reveal index badge (⌘1, ⌘2, etc.) */
|
||||
|
||||
.am-shortcut-badge {
|
||||
position: absolute;
|
||||
right: 30px;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-weaker);
|
||||
@@ -256,8 +331,7 @@ button.am-section-toggle:hover .am-section-label {
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.am-local-item:hover .am-shortcut-badge,
|
||||
.am-worktree-item:hover .am-shortcut-badge {
|
||||
.am-local-item:hover .am-shortcut-badge {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -278,29 +352,27 @@ button.am-section-toggle:hover .am-section-label {
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
.am-worktree-item:has(.am-worktree-rename-input) .am-shortcut-badge {
|
||||
opacity: 0;
|
||||
.am-worktree-item:has(.am-worktree-rename-input) .am-wt-row2 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.am-worktree-close {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* Compact close button inside worktree items */
|
||||
.am-worktree-close [data-component="icon-button"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 2px;
|
||||
}
|
||||
.am-worktree-close [data-component="icon"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.am-worktree-item:has(.am-worktree-rename-input) .am-wt-hover-actions {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.am-worktree-item:hover .am-worktree-branch {
|
||||
mask-image: linear-gradient(to right, black calc(100% - 72px), transparent calc(100% - 36px));
|
||||
-webkit-mask-image: linear-gradient(to right, black calc(100% - 72px), transparent calc(100% - 36px));
|
||||
}
|
||||
|
||||
.am-worktree-item:hover .am-worktree-close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.am-worktree-item:has(.am-worktree-rename-input) .am-worktree-close {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Inline delete confirmation */
|
||||
@@ -315,7 +387,8 @@ button.am-section-toggle:hover .am-section-label {
|
||||
background: color-mix(in srgb, var(--surface-critical-strong) 25%, transparent);
|
||||
}
|
||||
|
||||
.am-worktree-pending-delete .am-worktree-branch {
|
||||
.am-worktree-pending-delete .am-worktree-branch,
|
||||
.am-worktree-pending-delete .am-worktree-subtitle {
|
||||
opacity: 0.5;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
@@ -325,16 +398,22 @@ button.am-section-toggle:hover .am-section-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.am-worktree-pending-delete .am-shortcut-badge {
|
||||
.am-worktree-pending-delete .am-wt-row2 {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.am-worktree-pending-delete .am-wt-hover-actions {
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
.am-worktree-delete-hint {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--text-on-brand-base);
|
||||
background: var(--surface-critical-strong);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
padding: 1px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
@@ -383,16 +462,14 @@ button.am-section-toggle:hover .am-section-label {
|
||||
.am-worktree-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Per-worktree git stats (diff lines + commits missing from origin) */
|
||||
|
||||
.am-worktree-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 10px;
|
||||
@@ -408,9 +485,7 @@ button.am-section-toggle:hover .am-section-label {
|
||||
|
||||
.am-worktree-stats-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -422,8 +497,15 @@ button.am-section-toggle:hover .am-section-label {
|
||||
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.am-worktree-stats-skeleton-row:nth-child(2) {
|
||||
animation-delay: 0.15s;
|
||||
.am-pr-badge-skeleton {
|
||||
width: 52px;
|
||||
height: 14px;
|
||||
border-radius: 6px;
|
||||
background: var(--text-base);
|
||||
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
|
||||
animation-delay: 0.3s;
|
||||
opacity: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.am-stat-files {
|
||||
@@ -438,6 +520,76 @@ button.am-section-toggle:hover .am-section-label {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
/* PR pill badge — second row inside am-wt-row2.
|
||||
Uses --pr-accent custom property (set inline) to derive all colors. */
|
||||
.am-pr-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px 7px 2px 4px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: color-mix(in srgb, var(--pr-accent) 12%, transparent);
|
||||
color: var(--pr-accent);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: background 150ms ease;
|
||||
margin-left: auto;
|
||||
}
|
||||
.am-pr-badge:hover {
|
||||
background: color-mix(in srgb, var(--pr-accent) 25%, transparent);
|
||||
}
|
||||
.am-pr-badge [data-component="icon"] {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: var(--pr-accent);
|
||||
}
|
||||
.am-pr-badge-number {
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.am-pr-badge:hover .am-pr-badge-number {
|
||||
color: var(--pr-accent);
|
||||
}
|
||||
.am-pr-badge[data-pending] {
|
||||
animation: am-pr-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes am-pr-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.9;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.am-worktree-item-active .am-pr-badge {
|
||||
background: color-mix(in srgb, var(--pr-accent) 18%, transparent);
|
||||
}
|
||||
.am-worktree-item-active .am-pr-badge:hover {
|
||||
background: color-mix(in srgb, var(--pr-accent) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Clickable link icon in hover card to open PR on GitHub */
|
||||
.am-pr-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-right: 3px;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
.am-pr-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.am-pr-link [data-component="icon"] {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
.am-worktree-item-active .am-stat-files {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
@@ -470,13 +622,156 @@ button.am-section-toggle:hover .am-section-label {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.am-worktree-item:hover .am-worktree-stats,
|
||||
.am-worktree-item:hover .am-worktree-stats-skeleton,
|
||||
.am-local-item:hover .am-worktree-stats,
|
||||
.am-local-item:hover .am-worktree-stats-skeleton {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* User-defined sections — collapsible, color-coded groups */
|
||||
|
||||
.am-section-group {
|
||||
margin-top: 2px;
|
||||
border-left: 2px solid var(--section-color, var(--vscode-panel-border));
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
transition: border-color 120ms;
|
||||
}
|
||||
|
||||
.am-section-group-drop {
|
||||
background: color-mix(in srgb, var(--section-color) 10%, transparent);
|
||||
border-color: var(--section-color);
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.am-section-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 5px 10px 5px 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
.am-section-group-header:hover {
|
||||
background: var(--surface-interactive-hover, var(--vscode-list-hoverBackground));
|
||||
}
|
||||
|
||||
.am-section-group-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.am-section-group-chevron {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.am-section-group-chevron-collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.am-section-group-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.am-section-group-count {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
color: var(--text-weaker);
|
||||
min-width: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.am-section-group-body {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.am-section-group-rename {
|
||||
background: var(--input-base, var(--vscode-input-background, #3c3c3c));
|
||||
color: var(--vscode-input-foreground);
|
||||
border: 1px solid var(--border-focus, var(--vscode-focusBorder, #007fd4));
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Color picker grid in section context menu */
|
||||
|
||||
.am-color-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.am-color-grid-item {
|
||||
padding: 3px !important;
|
||||
border-radius: 4px !important;
|
||||
min-height: unset !important;
|
||||
}
|
||||
|
||||
.am-color-swatch {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
border: 1.5px solid transparent;
|
||||
}
|
||||
|
||||
.am-color-swatch-active {
|
||||
border-color: var(--vscode-foreground);
|
||||
box-shadow: 0 0 0 1px var(--vscode-editor-background);
|
||||
}
|
||||
|
||||
.am-color-swatch-default {
|
||||
background: var(--vscode-panel-border);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.am-color-swatch-default::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 70%;
|
||||
height: 1.5px;
|
||||
background: var(--text-weaker);
|
||||
transform: translate(-50%, -50%) rotate(-45deg);
|
||||
}
|
||||
|
||||
.am-color-swatch-sm {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.am-icon-flip {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Context menu danger item (delete section) */
|
||||
|
||||
.am-ctx-menu-danger {
|
||||
color: var(--text-error, var(--vscode-errorForeground, #f44));
|
||||
}
|
||||
|
||||
/* Grouped worktrees — visual grouping with header and left accent */
|
||||
|
||||
.am-wt-group-header {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type Component, createRoot, onCleanup } from "solid-js"
|
||||
import { useDragDropContext, type Transformer } from "@thisbeyond/solid-dnd"
|
||||
|
||||
/** Lock drag movement to the Y axis (vertical-only worktree dragging). */
|
||||
export const ConstrainDragXAxis: Component = () => {
|
||||
const ctx = useDragDropContext()
|
||||
if (!ctx) return null
|
||||
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = ctx
|
||||
const xform: Transformer = { id: "constrain-x-axis", order: 100, callback: (t) => ({ ...t, x: 0 }) }
|
||||
const dispose = createRoot((d) => {
|
||||
onDragStart(({ draggable }) => {
|
||||
if (draggable) addTransformer("draggables", draggable.id as string, xform)
|
||||
})
|
||||
onDragEnd(({ draggable }) => {
|
||||
if (draggable) removeTransformer("draggables", draggable.id as string, xform.id)
|
||||
})
|
||||
return d
|
||||
})
|
||||
onCleanup(dispose)
|
||||
return null
|
||||
}
|
||||
@@ -19,6 +19,17 @@ export const dict = {
|
||||
"agentManager.worktree.copyPath": "نسخ المسار",
|
||||
"agentManager.worktree.openInVscode": "فتح في VS Code",
|
||||
"agentManager.worktree.rename": "إعادة تسمية",
|
||||
"agentManager.worktree.moveToSection": "نقل إلى القسم",
|
||||
"agentManager.worktree.newSection": "قسم جديد",
|
||||
"agentManager.worktree.ungrouped": "غير مجمع",
|
||||
"agentManager.section.rename": "إعادة تسمية القسم",
|
||||
"agentManager.section.setColor": "تعيين اللون",
|
||||
"agentManager.section.delete": "حذف القسم",
|
||||
"agentManager.section.defaultColor": "افتراضي",
|
||||
"agentManager.section.defaultName": "قسم جديد",
|
||||
"agentManager.section.moveUp": "تحريك لأعلى",
|
||||
"agentManager.section.moveDown": "تحريك لأسفل",
|
||||
"agentManager.section.count": "{{count}}",
|
||||
"agentManager.hoverCard.branch": "الفرع",
|
||||
"agentManager.hoverCard.base": "الأساس",
|
||||
"agentManager.hoverCard.sessions": "الجلسات",
|
||||
|
||||
@@ -19,6 +19,17 @@ export const dict = {
|
||||
"agentManager.worktree.copyPath": "Copiar Caminho",
|
||||
"agentManager.worktree.openInVscode": "Abrir no VS Code",
|
||||
"agentManager.worktree.rename": "Renomear",
|
||||
"agentManager.worktree.moveToSection": "Mover para a Seção",
|
||||
"agentManager.worktree.newSection": "Nova Seção",
|
||||
"agentManager.worktree.ungrouped": "Não Agrupado",
|
||||
"agentManager.section.rename": "Renomear Seção",
|
||||
"agentManager.section.setColor": "Definir Cor",
|
||||
"agentManager.section.delete": "Excluir Seção",
|
||||
"agentManager.section.defaultColor": "Padrão",
|
||||
"agentManager.section.defaultName": "Nova Seção",
|
||||
"agentManager.section.moveUp": "Mover para Cima",
|
||||
"agentManager.section.moveDown": "Mover para Baixo",
|
||||
"agentManager.section.count": "{{count}}",
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.base": "Base",
|
||||
"agentManager.hoverCard.sessions": "Sessões",
|
||||
|
||||
@@ -19,6 +19,17 @@ export const dict = {
|
||||
"agentManager.worktree.copyPath": "Kopiraj putanju",
|
||||
"agentManager.worktree.openInVscode": "Otvori u VS Code",
|
||||
"agentManager.worktree.rename": "Preimenuj",
|
||||
"agentManager.worktree.moveToSection": "Premjesti u sekciju",
|
||||
"agentManager.worktree.newSection": "Nova sekcija",
|
||||
"agentManager.worktree.ungrouped": "Bez grupe",
|
||||
"agentManager.section.rename": "Preimenuj sekciju",
|
||||
"agentManager.section.setColor": "Postavi boju",
|
||||
"agentManager.section.delete": "Izbriši sekciju",
|
||||
"agentManager.section.defaultColor": "Zadano",
|
||||
"agentManager.section.defaultName": "Nova sekcija",
|
||||
"agentManager.section.moveUp": "Pomjeri gore",
|
||||
"agentManager.section.moveDown": "Pomjeri dolje",
|
||||
"agentManager.section.count": "{{count}}",
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.base": "Baza",
|
||||
"agentManager.hoverCard.sessions": "Sesije",
|
||||
|
||||
@@ -19,6 +19,17 @@ export const dict = {
|
||||
"agentManager.worktree.copyPath": "Kopier sti",
|
||||
"agentManager.worktree.openInVscode": "Åbn i VS Code",
|
||||
"agentManager.worktree.rename": "Omdøb",
|
||||
"agentManager.worktree.moveToSection": "Flyt til sektion",
|
||||
"agentManager.worktree.newSection": "Ny sektion",
|
||||
"agentManager.worktree.ungrouped": "Ugrupperet",
|
||||
"agentManager.section.rename": "Omdøb sektion",
|
||||
"agentManager.section.setColor": "Indstil farve",
|
||||
"agentManager.section.delete": "Slet sektion",
|
||||
"agentManager.section.defaultColor": "Standard",
|
||||
"agentManager.section.defaultName": "Ny sektion",
|
||||
"agentManager.section.moveUp": "Flyt op",
|
||||
"agentManager.section.moveDown": "Flyt ned",
|
||||
"agentManager.section.count": "{{count}}",
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.base": "Base",
|
||||
"agentManager.hoverCard.sessions": "Sessioner",
|
||||
|
||||
@@ -19,6 +19,17 @@ export const dict = {
|
||||
"agentManager.worktree.copyPath": "Pfad kopieren",
|
||||
"agentManager.worktree.openInVscode": "In VS Code öffnen",
|
||||
"agentManager.worktree.rename": "Umbenennen",
|
||||
"agentManager.worktree.moveToSection": "In Bereich verschieben",
|
||||
"agentManager.worktree.newSection": "Neuer Bereich",
|
||||
"agentManager.worktree.ungrouped": "Nicht gruppiert",
|
||||
"agentManager.section.rename": "Bereich umbenennen",
|
||||
"agentManager.section.setColor": "Farbe festlegen",
|
||||
"agentManager.section.delete": "Bereich löschen",
|
||||
"agentManager.section.defaultColor": "Standard",
|
||||
"agentManager.section.defaultName": "Neuer Bereich",
|
||||
"agentManager.section.moveUp": "Nach oben verschieben",
|
||||
"agentManager.section.moveDown": "Nach unten verschieben",
|
||||
"agentManager.section.count": "{{count}}",
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.base": "Basis",
|
||||
"agentManager.hoverCard.sessions": "Sitzungen",
|
||||
|
||||
@@ -20,6 +20,17 @@ export const dict = {
|
||||
"agentManager.worktree.copyPath": "Copy Path",
|
||||
"agentManager.worktree.openInVscode": "Open in VS Code",
|
||||
"agentManager.worktree.rename": "Rename",
|
||||
"agentManager.worktree.moveToSection": "Move to Section",
|
||||
"agentManager.worktree.newSection": "New Section",
|
||||
"agentManager.worktree.ungrouped": "Ungrouped",
|
||||
"agentManager.section.rename": "Rename Section",
|
||||
"agentManager.section.setColor": "Set Color",
|
||||
"agentManager.section.delete": "Delete Section",
|
||||
"agentManager.section.defaultColor": "Default",
|
||||
"agentManager.section.defaultName": "New Section",
|
||||
"agentManager.section.moveUp": "Move Up",
|
||||
"agentManager.section.moveDown": "Move Down",
|
||||
"agentManager.section.count": "{{count}}",
|
||||
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.base": "Base",
|
||||
|
||||
@@ -19,6 +19,17 @@ export const dict = {
|
||||
"agentManager.worktree.copyPath": "Copiar ruta",
|
||||
"agentManager.worktree.openInVscode": "Abrir en VS Code",
|
||||
"agentManager.worktree.rename": "Renombrar",
|
||||
"agentManager.worktree.moveToSection": "Mover a la sección",
|
||||
"agentManager.worktree.newSection": "Nueva sección",
|
||||
"agentManager.worktree.ungrouped": "Desagrupado",
|
||||
"agentManager.section.rename": "Renombrar sección",
|
||||
"agentManager.section.setColor": "Establecer color",
|
||||
"agentManager.section.delete": "Eliminar sección",
|
||||
"agentManager.section.defaultColor": "Predeterminado",
|
||||
"agentManager.section.defaultName": "Nueva sección",
|
||||
"agentManager.section.moveUp": "Mover hacia arriba",
|
||||
"agentManager.section.moveDown": "Mover hacia abajo",
|
||||
"agentManager.section.count": "{{count}}",
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.base": "Base",
|
||||
"agentManager.hoverCard.sessions": "Sesiones",
|
||||
|
||||
@@ -19,6 +19,17 @@ export const dict = {
|
||||
"agentManager.worktree.copyPath": "Copier le chemin",
|
||||
"agentManager.worktree.openInVscode": "Ouvrir dans VS Code",
|
||||
"agentManager.worktree.rename": "Renommer",
|
||||
"agentManager.worktree.moveToSection": "Déplacer vers la section",
|
||||
"agentManager.worktree.newSection": "Nouvelle section",
|
||||
"agentManager.worktree.ungrouped": "Non groupé",
|
||||
"agentManager.section.rename": "Renommer la section",
|
||||
"agentManager.section.setColor": "Définir la couleur",
|
||||
"agentManager.section.delete": "Supprimer la section",
|
||||
"agentManager.section.defaultColor": "Par défaut",
|
||||
"agentManager.section.defaultName": "Nouvelle section",
|
||||
"agentManager.section.moveUp": "Déplacer vers le haut",
|
||||
"agentManager.section.moveDown": "Déplacer vers le bas",
|
||||
"agentManager.section.count": "{{count}}",
|
||||
"agentManager.hoverCard.branch": "BRANCHE",
|
||||
"agentManager.hoverCard.base": "Base",
|
||||
"agentManager.hoverCard.sessions": "Sessions",
|
||||
|
||||
@@ -19,6 +19,17 @@ export const dict = {
|
||||
"agentManager.worktree.copyPath": "パスをコピー",
|
||||
"agentManager.worktree.openInVscode": "VS Codeで開く",
|
||||
"agentManager.worktree.rename": "名前を変更",
|
||||
"agentManager.worktree.moveToSection": "セクションに移動",
|
||||
"agentManager.worktree.newSection": "新しいセクション",
|
||||
"agentManager.worktree.ungrouped": "グループなし",
|
||||
"agentManager.section.rename": "セクションの名前変更",
|
||||
"agentManager.section.setColor": "色の設定",
|
||||
"agentManager.section.delete": "セクションの削除",
|
||||
"agentManager.section.defaultColor": "デフォルト",
|
||||
"agentManager.section.defaultName": "新しいセクション",
|
||||
"agentManager.section.moveUp": "上に移動",
|
||||
"agentManager.section.moveDown": "下に移動",
|
||||
"agentManager.section.count": "{{count}}",
|
||||
"agentManager.hoverCard.branch": "ブランチ",
|
||||
"agentManager.hoverCard.base": "ベース",
|
||||
"agentManager.hoverCard.sessions": "セッション",
|
||||
|
||||
@@ -19,6 +19,17 @@ export const dict = {
|
||||
"agentManager.worktree.copyPath": "경로 복사",
|
||||
"agentManager.worktree.openInVscode": "VS Code에서 열기",
|
||||
"agentManager.worktree.rename": "이름 바꾸기",
|
||||
"agentManager.worktree.moveToSection": "섹션으로 이동",
|
||||
"agentManager.worktree.newSection": "새 섹션",
|
||||
"agentManager.worktree.ungrouped": "그룹 해제됨",
|
||||
"agentManager.section.rename": "섹션 이름 바꾸기",
|
||||
"agentManager.section.setColor": "색상 설정",
|
||||
"agentManager.section.delete": "섹션 삭제",
|
||||
"agentManager.section.defaultColor": "기본값",
|
||||
"agentManager.section.defaultName": "새 섹션",
|
||||
"agentManager.section.moveUp": "위로 이동",
|
||||
"agentManager.section.moveDown": "아래로 이동",
|
||||
"agentManager.section.count": "{{count}}",
|
||||
"agentManager.hoverCard.branch": "브랜치",
|
||||
"agentManager.hoverCard.base": "베이스",
|
||||
"agentManager.hoverCard.sessions": "세션",
|
||||
|
||||
@@ -20,6 +20,17 @@ export const dict = {
|
||||
"agentManager.worktree.copyPath": "Pad kopiëren",
|
||||
"agentManager.worktree.openInVscode": "Openen in VS Code",
|
||||
"agentManager.worktree.rename": "Hernoemen",
|
||||
"agentManager.worktree.moveToSection": "Verplaatsen naar sectie",
|
||||
"agentManager.worktree.newSection": "Nieuwe sectie",
|
||||
"agentManager.worktree.ungrouped": "Niet gegroepeerd",
|
||||
"agentManager.section.rename": "Sectie hernoemen",
|
||||
"agentManager.section.setColor": "Kleur instellen",
|
||||
"agentManager.section.delete": "Sectie verwijderen",
|
||||
"agentManager.section.defaultColor": "Standaard",
|
||||
"agentManager.section.defaultName": "Nieuwe sectie",
|
||||
"agentManager.section.moveUp": "Omhoog verplaatsen",
|
||||
"agentManager.section.moveDown": "Omlaag verplaatsen",
|
||||
"agentManager.section.count": "{{count}}",
|
||||
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.base": "Basis",
|
||||
|
||||
@@ -19,6 +19,17 @@ export const dict = {
|
||||
"agentManager.worktree.copyPath": "Kopier sti",
|
||||
"agentManager.worktree.openInVscode": "Åpne i VS Code",
|
||||
"agentManager.worktree.rename": "Gi nytt navn",
|
||||
"agentManager.worktree.moveToSection": "Flytt til seksjon",
|
||||
"agentManager.worktree.newSection": "Ny seksjon",
|
||||
"agentManager.worktree.ungrouped": "Ugruppert",
|
||||
"agentManager.section.rename": "Gi seksjon nytt navn",
|
||||
"agentManager.section.setColor": "Angi farge",
|
||||
"agentManager.section.delete": "Slett seksjon",
|
||||
"agentManager.section.defaultColor": "Standard",
|
||||
"agentManager.section.defaultName": "Ny seksjon",
|
||||
"agentManager.section.moveUp": "Flytt opp",
|
||||
"agentManager.section.moveDown": "Flytt ned",
|
||||
"agentManager.section.count": "{{count}}",
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.base": "Base",
|
||||
"agentManager.hoverCard.sessions": "Økter",
|
||||
|
||||
@@ -19,6 +19,17 @@ export const dict = {
|
||||
"agentManager.worktree.copyPath": "Kopiuj ścieżkę",
|
||||
"agentManager.worktree.openInVscode": "Otwórz w VS Code",
|
||||
"agentManager.worktree.rename": "Zmień nazwę",
|
||||
"agentManager.worktree.moveToSection": "Przenieś do sekcji",
|
||||
"agentManager.worktree.newSection": "Nowa sekcja",
|
||||
"agentManager.worktree.ungrouped": "Niezgrupowane",
|
||||
"agentManager.section.rename": "Zmień nazwę sekcji",
|
||||
"agentManager.section.setColor": "Ustaw kolor",
|
||||
"agentManager.section.delete": "Usuń sekcję",
|
||||
"agentManager.section.defaultColor": "Domyślny",
|
||||
"agentManager.section.defaultName": "Nowa sekcja",
|
||||
"agentManager.section.moveUp": "Przenieś w górę",
|
||||
"agentManager.section.moveDown": "Przenieś w dół",
|
||||
"agentManager.section.count": "{{count}}",
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.base": "Baza",
|
||||
"agentManager.hoverCard.sessions": "Sesje",
|
||||
|
||||
@@ -19,6 +19,17 @@ export const dict = {
|
||||
"agentManager.worktree.copyPath": "Копировать путь",
|
||||
"agentManager.worktree.openInVscode": "Открыть в VS Code",
|
||||
"agentManager.worktree.rename": "Переименовать",
|
||||
"agentManager.worktree.moveToSection": "Переместить в раздел",
|
||||
"agentManager.worktree.newSection": "Новый раздел",
|
||||
"agentManager.worktree.ungrouped": "Без группы",
|
||||
"agentManager.section.rename": "Переименовать раздел",
|
||||
"agentManager.section.setColor": "Задать цвет",
|
||||
"agentManager.section.delete": "Удалить раздел",
|
||||
"agentManager.section.defaultColor": "По умолчанию",
|
||||
"agentManager.section.defaultName": "Новый раздел",
|
||||
"agentManager.section.moveUp": "Переместить вверх",
|
||||
"agentManager.section.moveDown": "Переместить вниз",
|
||||
"agentManager.section.count": "{{count}}",
|
||||
"agentManager.hoverCard.branch": "ВЕТКА",
|
||||
"agentManager.hoverCard.base": "Основа",
|
||||
"agentManager.hoverCard.sessions": "Сессии",
|
||||
|
||||
@@ -19,6 +19,17 @@ export const dict = {
|
||||
"agentManager.worktree.copyPath": "คัดลอกเส้นทาง",
|
||||
"agentManager.worktree.openInVscode": "เปิดใน VS Code",
|
||||
"agentManager.worktree.rename": "เปลี่ยนชื่อ",
|
||||
"agentManager.worktree.moveToSection": "ย้ายไปที่ส่วน",
|
||||
"agentManager.worktree.newSection": "ส่วนใหม่",
|
||||
"agentManager.worktree.ungrouped": "ไม่ได้จัดกลุ่ม",
|
||||
"agentManager.section.rename": "เปลี่ยนชื่อส่วน",
|
||||
"agentManager.section.setColor": "ตั้งค่าสี",
|
||||
"agentManager.section.delete": "ลบส่วน",
|
||||
"agentManager.section.defaultColor": "ค่าเริ่มต้น",
|
||||
"agentManager.section.defaultName": "ส่วนใหม่",
|
||||
"agentManager.section.moveUp": "เลื่อนขึ้น",
|
||||
"agentManager.section.moveDown": "เลื่อนลง",
|
||||
"agentManager.section.count": "{{count}}",
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.base": "ฐาน",
|
||||
"agentManager.hoverCard.sessions": "เซสชัน",
|
||||
|
||||
@@ -20,6 +20,17 @@ export const dict = {
|
||||
"agentManager.worktree.copyPath": "Yolu Kopyala",
|
||||
"agentManager.worktree.openInVscode": "VS Code'da Aç",
|
||||
"agentManager.worktree.rename": "Yeniden Adlandır",
|
||||
"agentManager.worktree.moveToSection": "Bölüme Taşı",
|
||||
"agentManager.worktree.newSection": "Yeni Bölüm",
|
||||
"agentManager.worktree.ungrouped": "Gruplandırılmamış",
|
||||
"agentManager.section.rename": "Bölümü Yeniden Adlandır",
|
||||
"agentManager.section.setColor": "Renk Ayarla",
|
||||
"agentManager.section.delete": "Bölümü Sil",
|
||||
"agentManager.section.defaultColor": "Varsayılan",
|
||||
"agentManager.section.defaultName": "Yeni Bölüm",
|
||||
"agentManager.section.moveUp": "Yukarı Taşı",
|
||||
"agentManager.section.moveDown": "Aşağı Taşı",
|
||||
"agentManager.section.count": "{{count}}",
|
||||
|
||||
"agentManager.hoverCard.branch": "DAL",
|
||||
"agentManager.hoverCard.base": "Temel",
|
||||
|
||||
@@ -20,6 +20,17 @@ export const dict = {
|
||||
"agentManager.worktree.copyPath": "Копіювати шлях",
|
||||
"agentManager.worktree.openInVscode": "Відкрити у VS Code",
|
||||
"agentManager.worktree.rename": "Перейменувати",
|
||||
"agentManager.worktree.moveToSection": "Перемістити до розділу",
|
||||
"agentManager.worktree.newSection": "Новий розділь",
|
||||
"agentManager.worktree.ungrouped": "Без групи",
|
||||
"agentManager.section.rename": "Перейменувати розділ",
|
||||
"agentManager.section.setColor": "Встановити колір",
|
||||
"agentManager.section.delete": "Видалити розділ",
|
||||
"agentManager.section.defaultColor": "За замовчуванням",
|
||||
"agentManager.section.defaultName": "Новий розділ",
|
||||
"agentManager.section.moveUp": "Перемістити вгору",
|
||||
"agentManager.section.moveDown": "Перемістити вниз",
|
||||
"agentManager.section.count": "{{count}}",
|
||||
|
||||
"agentManager.hoverCard.branch": "ГІЛКА",
|
||||
"agentManager.hoverCard.base": "База",
|
||||
|
||||
@@ -19,6 +19,17 @@ export const dict = {
|
||||
"agentManager.worktree.copyPath": "复制路径",
|
||||
"agentManager.worktree.openInVscode": "在 VS Code 中打开",
|
||||
"agentManager.worktree.rename": "重命名",
|
||||
"agentManager.worktree.moveToSection": "移动到分区",
|
||||
"agentManager.worktree.newSection": "新分区",
|
||||
"agentManager.worktree.ungrouped": "未分组",
|
||||
"agentManager.section.rename": "重命名分区",
|
||||
"agentManager.section.setColor": "设置颜色",
|
||||
"agentManager.section.delete": "删除分区",
|
||||
"agentManager.section.defaultColor": "默认",
|
||||
"agentManager.section.defaultName": "新分区",
|
||||
"agentManager.section.moveUp": "上移",
|
||||
"agentManager.section.moveDown": "下移",
|
||||
"agentManager.section.count": "{{count}}",
|
||||
"agentManager.hoverCard.branch": "分支",
|
||||
"agentManager.hoverCard.base": "基础",
|
||||
"agentManager.hoverCard.sessions": "会话",
|
||||
|
||||
@@ -19,6 +19,17 @@ export const dict = {
|
||||
"agentManager.worktree.copyPath": "複製路徑",
|
||||
"agentManager.worktree.openInVscode": "在 VS Code 中打開",
|
||||
"agentManager.worktree.rename": "重新命名",
|
||||
"agentManager.worktree.moveToSection": "移動到分區",
|
||||
"agentManager.worktree.newSection": "新分區",
|
||||
"agentManager.worktree.ungrouped": "未分組",
|
||||
"agentManager.section.rename": "重新命名分區",
|
||||
"agentManager.section.setColor": "設定顏色",
|
||||
"agentManager.section.delete": "刪除分區",
|
||||
"agentManager.section.defaultColor": "預設",
|
||||
"agentManager.section.defaultName": "新分區",
|
||||
"agentManager.section.moveUp": "上移",
|
||||
"agentManager.section.moveDown": "下移",
|
||||
"agentManager.section.count": "{{count}}",
|
||||
"agentManager.hoverCard.branch": "分支",
|
||||
"agentManager.hoverCard.base": "基底",
|
||||
"agentManager.hoverCard.sessions": "工作階段",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Section color palette using VS Code theme CSS variables for theme-adaptive colors. */
|
||||
export const SECTION_COLORS = [
|
||||
{ label: "Red", css: "var(--vscode-terminal-ansiRed)" },
|
||||
{ label: "Orange", css: "var(--vscode-charts-orange)" },
|
||||
{ label: "Yellow", css: "var(--vscode-terminal-ansiYellow)" },
|
||||
{ label: "Green", css: "var(--vscode-terminal-ansiGreen)" },
|
||||
{ label: "Cyan", css: "var(--vscode-terminal-ansiCyan)" },
|
||||
{ label: "Blue", css: "var(--vscode-charts-blue)" },
|
||||
{ label: "Purple", css: "var(--vscode-charts-purple)" },
|
||||
{ label: "Magenta", css: "var(--vscode-terminal-ansiMagenta)" },
|
||||
] as const
|
||||
|
||||
/** Map a stored color label to its CSS variable string. Returns undefined for null/unknown labels. */
|
||||
export function colorCss(label: string | null): string | undefined {
|
||||
if (!label) return undefined
|
||||
return SECTION_COLORS.find((c) => c.label === label)?.css
|
||||
}
|
||||
|
||||
/** Pick a random color label for new sections. */
|
||||
export function randomColor(): string {
|
||||
return SECTION_COLORS[Math.floor(Math.random() * SECTION_COLORS.length)]!.label
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Section drag-and-drop helpers. Separated from section-helpers.ts to avoid
|
||||
* pulling solid-dnd into test environments.
|
||||
*/
|
||||
import { closestCenter } from "@thisbeyond/solid-dnd"
|
||||
import type { CollisionDetector } from "@thisbeyond/solid-dnd"
|
||||
|
||||
/**
|
||||
* Collision detector that prioritizes section drop zones when a worktree is
|
||||
* dragged (checks bounding box, not just center). Skips the worktree's home
|
||||
* section so within-section reorder works. Falls back to closestCenter.
|
||||
*
|
||||
* @param secIds Accessor for all section IDs
|
||||
* @param home Accessor for worktree ID → its sectionId (or undefined if ungrouped)
|
||||
*/
|
||||
export function sectionAwareDetector(
|
||||
secIds: () => Set<string>,
|
||||
home: () => Map<string, string | undefined>,
|
||||
): CollisionDetector {
|
||||
return (draggable, droppables, ctx) => {
|
||||
const secs = secIds()
|
||||
const id = draggable.id as string
|
||||
const mySection = home().get(id)
|
||||
if (!secs.has(id)) {
|
||||
const pt = draggable.transformed.center
|
||||
for (const d of droppables) {
|
||||
if (!secs.has(d.id as string)) continue
|
||||
if (d.id === mySection) continue
|
||||
const { top, bottom, left, right } = d.layout
|
||||
if (pt.x >= left && pt.x <= right && pt.y >= top && pt.y <= bottom) return d
|
||||
}
|
||||
}
|
||||
return closestCenter(draggable, droppables, ctx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Section computation helpers for the agent manager sidebar.
|
||||
* Pure functions — no solid-dnd dependency so they remain testable.
|
||||
*/
|
||||
import type { WorktreeState, SectionState } from "../src/types/messages"
|
||||
|
||||
export type TopLevelItem = { kind: "section"; section: SectionState } | { kind: "worktree"; wt: WorktreeState }
|
||||
|
||||
/** Check if this worktree is part of a multi-version group. */
|
||||
export const isGrouped = (wt: WorktreeState) => !!wt.groupId
|
||||
|
||||
/** Check if this is the first item in its group within a given list. */
|
||||
export const isGroupStart = (wt: WorktreeState, idx: number, list: WorktreeState[]) => {
|
||||
if (!wt.groupId) return false
|
||||
if (idx === 0) return true
|
||||
return list[idx - 1]?.groupId !== wt.groupId
|
||||
}
|
||||
|
||||
/** Check if this is the last item in its group within a given list. */
|
||||
export const isGroupEnd = (wt: WorktreeState, idx: number, list: WorktreeState[]) => {
|
||||
if (!wt.groupId) return false
|
||||
if (idx === list.length - 1) return true
|
||||
return list[idx + 1]?.groupId !== wt.groupId
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the interleaved list of sections and ungrouped worktrees
|
||||
* ordered by sidebarWorktreeOrder.
|
||||
*/
|
||||
export function buildTopLevelItems(
|
||||
secs: SectionState[],
|
||||
ungrouped: WorktreeState[],
|
||||
all: WorktreeState[],
|
||||
order: string[],
|
||||
): TopLevelItem[] {
|
||||
if (secs.length === 0) {
|
||||
return all.map((wt) => ({ kind: "worktree" as const, wt }))
|
||||
}
|
||||
const secMap = new Map(secs.map((s) => [s.id, s]))
|
||||
const wtMap = new Map(ungrouped.map((wt) => [wt.id, wt]))
|
||||
const result: TopLevelItem[] = []
|
||||
const placed = new Set<string>()
|
||||
|
||||
for (const id of order) {
|
||||
if (placed.has(id)) continue
|
||||
placed.add(id)
|
||||
const sec = secMap.get(id)
|
||||
if (sec) {
|
||||
result.push({ kind: "section", section: sec })
|
||||
continue
|
||||
}
|
||||
const wt = wtMap.get(id)
|
||||
if (wt) result.push({ kind: "worktree", wt })
|
||||
}
|
||||
for (const sec of secs) {
|
||||
if (!placed.has(sec.id)) result.push({ kind: "section", section: sec })
|
||||
}
|
||||
for (const wt of ungrouped) {
|
||||
if (!placed.has(wt.id)) result.push({ kind: "worktree", wt })
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -62,12 +62,16 @@ export const KiloNotifications: Component = () => {
|
||||
return true
|
||||
})
|
||||
|
||||
const MAX_NAME = 30
|
||||
|
||||
const suggestedName = createMemo(() => {
|
||||
const suggestion = suggestedModel()
|
||||
if (!suggestion) return undefined
|
||||
const model = provider.findModel(suggestion)
|
||||
if (!model?.name) return undefined
|
||||
return stripSubProviderPrefix(model.name)
|
||||
const name = stripSubProviderPrefix(model.name)
|
||||
if (name.length > MAX_NAME) return undefined
|
||||
return name
|
||||
})
|
||||
|
||||
const handleTryModel = () => {
|
||||
@@ -98,7 +102,9 @@ export const KiloNotifications: Component = () => {
|
||||
<div class="kilo-notifications-cta-group">
|
||||
<Show when={canSwitchModel()}>
|
||||
<button class="kilo-notifications-action-btn" onClick={handleTryModel}>
|
||||
{language.t("notifications.action.tryModel", { model: suggestedName() ?? "" })}
|
||||
{suggestedName()
|
||||
? language.t("notifications.action.tryModel", { model: suggestedName()! })
|
||||
: language.t("notifications.action.tryModelGeneric")}
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={current()?.action}>
|
||||
|
||||
@@ -244,7 +244,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
window.addEventListener("compactSession", onCompact)
|
||||
onCleanup(() => window.removeEventListener("compactSession", onCompact))
|
||||
|
||||
const isBusy = () => session.status() === "busy"
|
||||
const isBusy = () => session.status() !== "idle"
|
||||
const isDisabled = () => !server.isConnected()
|
||||
const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0
|
||||
const canSend = () => hasInput() && !isDisabled() && !props.blocked?.()
|
||||
|
||||
@@ -1004,7 +1004,15 @@ const AgentBehaviourTab: Component = () => {
|
||||
>
|
||||
{path}
|
||||
</span>
|
||||
<IconButton size="small" variant="ghost" icon="close" onClick={() => removeInstruction(index())} />
|
||||
<div style={{ display: "flex", "align-items": "center", gap: "4px" }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="ghost"
|
||||
icon="pencil-line"
|
||||
onClick={() => vscode.postMessage({ type: "openFile", filePath: path })}
|
||||
/>
|
||||
<IconButton size="small" variant="ghost" icon="close" onClick={() => removeInstruction(index())} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
sanitizeName,
|
||||
} from "./model-selector-utils"
|
||||
import { ModelPreview } from "./ModelPreview"
|
||||
import { searchMatch } from "../../utils/search-match"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row / group key helpers — single source of truth for key formatting
|
||||
@@ -167,11 +168,13 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
|
||||
|
||||
// Flat filtered list for keyboard navigation
|
||||
const filtered = createMemo(() => {
|
||||
const q = debouncedSearch().toLowerCase()
|
||||
const q = debouncedSearch().trim()
|
||||
if (!q) {
|
||||
return visibleModels()
|
||||
}
|
||||
return visibleModels().filter((m) => m.name.toLowerCase().includes(q))
|
||||
return visibleModels().filter(
|
||||
(m) => searchMatch(q, m.name) || searchMatch(q, m.id) || searchMatch(q, m.providerName),
|
||||
)
|
||||
})
|
||||
|
||||
// Live set of favorited keys — drives star icon visual state (filled vs outline).
|
||||
|
||||
@@ -4,14 +4,17 @@
|
||||
* Matches the v1.0.25 working indicator UX.
|
||||
*/
|
||||
|
||||
import { Component, Show, createSignal, createEffect, onCleanup } from "solid-js"
|
||||
import { type Component, Show, createSignal, createEffect, onCleanup } from "solid-js"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { useSession } from "../../context/session"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { useVSCode } from "../../context/vscode"
|
||||
|
||||
export const WorkingIndicator: Component = () => {
|
||||
const session = useSession()
|
||||
const language = useLanguage()
|
||||
const vscode = useVSCode()
|
||||
|
||||
const [elapsed, setElapsed] = createSignal(0)
|
||||
const [retryCountdown, setRetryCountdown] = createSignal(0)
|
||||
@@ -80,6 +83,15 @@ export const WorkingIndicator: Component = () => {
|
||||
return perms.length > 0 || questions.length > 0
|
||||
}
|
||||
|
||||
const isRetrying = () => session.statusInfo().type === "retry"
|
||||
|
||||
const handleCancelRetry = () => {
|
||||
const sid = session.currentSessionID()
|
||||
if (sid) {
|
||||
vscode.postMessage({ type: "abort", sessionID: sid })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={session.status() !== "idle" && !blocked()}>
|
||||
<div class="working-indicator">
|
||||
@@ -88,6 +100,17 @@ export const WorkingIndicator: Component = () => {
|
||||
<Show when={elapsed() > 0}>
|
||||
<span class="working-elapsed">{formatElapsed()}</span>
|
||||
</Show>
|
||||
<Show when={isRetrying()}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
onClick={handleCancelRetry}
|
||||
class="working-cancel"
|
||||
style={{ "font-weight": "600", color: "var(--vscode-errorForeground, #f85149)" }}
|
||||
>
|
||||
{language.t("ui.sessionTurn.cancel") || "Cancel"}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -414,6 +414,10 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
|
||||
function selectModel(providerID: string, modelID: string) {
|
||||
applyModel(selectedAgentName(), { providerID, modelID })
|
||||
const sid = currentSessionID()
|
||||
if (sid) {
|
||||
setStore("messages", sid, (msgs = []) => msgs.filter((m) => !m.error))
|
||||
}
|
||||
}
|
||||
|
||||
/** The config/default model for the current mode (what settings says). */
|
||||
|
||||
+5
@@ -935,6 +935,10 @@ export const dict = {
|
||||
"session.status.retrying": "...إعادة المحاولة (المحاولة {{ attempt }})… {{ message }}",
|
||||
"session.status.working": "...جارٍ العمل",
|
||||
|
||||
"ui.sessionTurn.cancel": "إلغاء",
|
||||
"ui.sessionTurn.status.thinking": "...جارٍ التفكير",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "...جارٍ التفكير في الخطوات التالية",
|
||||
|
||||
"dialog.model.noProviders": "لا يوجد موفرون",
|
||||
|
||||
"prompt.placeholder.connecting": "جارٍ الاتصال بالخادم...",
|
||||
@@ -1330,4 +1334,5 @@ export const dict = {
|
||||
"notifications.action.next": "التالي",
|
||||
"notifications.action.close": "إغلاق",
|
||||
"notifications.action.tryModel": "جرّب {{model}}",
|
||||
"notifications.action.tryModelGeneric": "جرّب النموذج",
|
||||
}
|
||||
|
||||
+5
@@ -943,6 +943,10 @@ export const dict = {
|
||||
"session.status.retrying": "Tentando novamente (tentativa {{ attempt }})… {{ message }}",
|
||||
"session.status.working": "Trabalhando…",
|
||||
|
||||
"ui.sessionTurn.cancel": "Cancelar",
|
||||
"ui.sessionTurn.status.thinking": "Pensando...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "Considerando próximos passos...",
|
||||
|
||||
"dialog.model.noProviders": "Nenhum provedor",
|
||||
|
||||
"prompt.placeholder.connecting": "Conectando ao servidor...",
|
||||
@@ -1359,4 +1363,5 @@ export const dict = {
|
||||
"notifications.action.next": "Próximo",
|
||||
"notifications.action.close": "Fechar",
|
||||
"notifications.action.tryModel": "Experimentar {{model}}",
|
||||
"notifications.action.tryModelGeneric": "Experimentar modelo",
|
||||
}
|
||||
|
||||
+5
@@ -948,6 +948,10 @@ export const dict = {
|
||||
"session.status.retrying": "Ponovni pokušaj (pokušaj {{ attempt }})… {{ message }}",
|
||||
"session.status.working": "Radim…",
|
||||
|
||||
"ui.sessionTurn.cancel": "Otkaži",
|
||||
"ui.sessionTurn.status.thinking": "Razmišljam...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "Razmatram sljedeće korake...",
|
||||
|
||||
"dialog.model.noProviders": "Nema pružatelja",
|
||||
|
||||
"prompt.placeholder.connecting": "Povezivanje na server...",
|
||||
@@ -1355,4 +1359,5 @@ export const dict = {
|
||||
"notifications.action.next": "Sljedeći",
|
||||
"notifications.action.close": "Zatvori",
|
||||
"notifications.action.tryModel": "Probaj {{model}}",
|
||||
"notifications.action.tryModelGeneric": "Probaj model",
|
||||
}
|
||||
|
||||
+5
@@ -941,6 +941,10 @@ export const dict = {
|
||||
"session.status.retrying": "Prøver igen (forsøg {{ attempt }})… {{ message }}",
|
||||
"session.status.working": "Arbejder…",
|
||||
|
||||
"ui.sessionTurn.cancel": "Annuller",
|
||||
"ui.sessionTurn.status.thinking": "Tænker...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "Overvejer næste trin...",
|
||||
|
||||
"dialog.model.noProviders": "Ingen udbydere",
|
||||
|
||||
"prompt.placeholder.connecting": "Opretter forbindelse til server...",
|
||||
@@ -1346,4 +1350,5 @@ export const dict = {
|
||||
"notifications.action.next": "Næste",
|
||||
"notifications.action.close": "Luk",
|
||||
"notifications.action.tryModel": "Prøv {{model}}",
|
||||
"notifications.action.tryModelGeneric": "Prøv model",
|
||||
}
|
||||
|
||||
+5
@@ -953,6 +953,10 @@ export const dict = {
|
||||
"session.status.retrying": "Erneuter Versuch ({{ attempt }})… {{ message }}",
|
||||
"session.status.working": "Wird bearbeitet…",
|
||||
|
||||
"ui.sessionTurn.cancel": "Abbrechen",
|
||||
"ui.sessionTurn.status.thinking": "Denke nach...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "Überlege nächste Schritte...",
|
||||
|
||||
"dialog.model.noProviders": "Keine Anbieter",
|
||||
|
||||
"prompt.placeholder.connecting": "Verbindung zum Server wird hergestellt...",
|
||||
@@ -1373,4 +1377,5 @@ export const dict = {
|
||||
"notifications.action.next": "Weiter",
|
||||
"notifications.action.close": "Schließen",
|
||||
"notifications.action.tryModel": "{{model}} ausprobieren",
|
||||
"notifications.action.tryModelGeneric": "Modell ausprobieren",
|
||||
} satisfies Partial<Record<Keys, string>>
|
||||
|
||||
@@ -941,6 +941,10 @@ export const dict = {
|
||||
"session.status.retrying": "Retrying (attempt {{ attempt }})… {{ message }}",
|
||||
"session.status.working": "Working...",
|
||||
|
||||
"ui.sessionTurn.cancel": "Cancel",
|
||||
"ui.sessionTurn.status.thinking": "Thinking...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "Considering next steps...",
|
||||
|
||||
"dialog.model.noProviders": "No providers",
|
||||
|
||||
"prompt.placeholder.connecting": "Connecting to server...",
|
||||
@@ -1171,7 +1175,8 @@ export const dict = {
|
||||
"settings.agentBehaviour.editMode.prompt": "System Prompt",
|
||||
"settings.agentBehaviour.editMode.save": "Done",
|
||||
"settings.agentBehaviour.editMode.back": "Back to list",
|
||||
"settings.agentBehaviour.editMode.native": "Built-in mode (read-only definition)",
|
||||
"settings.agentBehaviour.editMode.native":
|
||||
"This is a built-in mode. Its base definition cannot be changed, but you can configure overrides below.",
|
||||
"settings.agentBehaviour.editMode.promptOverride": "Custom prompt override for this built-in mode",
|
||||
|
||||
"settings.autoApprove.description":
|
||||
@@ -1356,4 +1361,5 @@ export const dict = {
|
||||
"notifications.action.next": "Next",
|
||||
"notifications.action.close": "Close",
|
||||
"notifications.action.tryModel": "Try {{model}}",
|
||||
"notifications.action.tryModelGeneric": "Try Model",
|
||||
}
|
||||
|
||||
+5
@@ -949,6 +949,10 @@ export const dict = {
|
||||
"session.status.retrying": "Reintentando (intento {{ attempt }})… {{ message }}",
|
||||
"session.status.working": "Trabajando…",
|
||||
|
||||
"ui.sessionTurn.cancel": "Cancelar",
|
||||
"ui.sessionTurn.status.thinking": "Pensando...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "Considerando siguientes pasos...",
|
||||
|
||||
"dialog.model.noProviders": "Sin proveedores",
|
||||
|
||||
"prompt.placeholder.connecting": "Conectando al servidor...",
|
||||
@@ -1362,4 +1366,5 @@ export const dict = {
|
||||
"notifications.action.next": "Siguiente",
|
||||
"notifications.action.close": "Cerrar",
|
||||
"notifications.action.tryModel": "Probar {{model}}",
|
||||
"notifications.action.tryModelGeneric": "Probar modelo",
|
||||
}
|
||||
|
||||
+5
@@ -955,6 +955,10 @@ export const dict = {
|
||||
"session.status.retrying": "Nouvelle tentative (essai {{ attempt }})… {{ message }}",
|
||||
"session.status.working": "En cours…",
|
||||
|
||||
"ui.sessionTurn.cancel": "Annuler",
|
||||
"ui.sessionTurn.status.thinking": "Réflexion...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "Envisage les prochaines étapes...",
|
||||
|
||||
"dialog.model.noProviders": "Aucun fournisseur",
|
||||
|
||||
"prompt.placeholder.connecting": "Connexion au serveur...",
|
||||
@@ -1378,4 +1382,5 @@ export const dict = {
|
||||
"notifications.action.next": "Suivant",
|
||||
"notifications.action.close": "Fermer",
|
||||
"notifications.action.tryModel": "Essayer {{model}}",
|
||||
"notifications.action.tryModelGeneric": "Essayer le modèle",
|
||||
}
|
||||
|
||||
+5
@@ -940,6 +940,10 @@ export const dict = {
|
||||
"session.status.retrying": "再試行中({{ attempt }}回目)… {{ message }}",
|
||||
"session.status.working": "作業中…",
|
||||
|
||||
"ui.sessionTurn.cancel": "キャンセル",
|
||||
"ui.sessionTurn.status.thinking": "考え中...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "次のステップを検討中...",
|
||||
|
||||
"dialog.model.noProviders": "プロバイダーなし",
|
||||
|
||||
"prompt.placeholder.connecting": "サーバーに接続中...",
|
||||
@@ -1345,4 +1349,5 @@ export const dict = {
|
||||
"notifications.action.next": "次へ",
|
||||
"notifications.action.close": "閉じる",
|
||||
"notifications.action.tryModel": "{{model}}を試す",
|
||||
"notifications.action.tryModelGeneric": "モデルを試す",
|
||||
}
|
||||
|
||||
+5
@@ -940,6 +940,10 @@ export const dict = {
|
||||
"session.status.retrying": "재시도 중 ({{ attempt }}번째 시도)… {{ message }}",
|
||||
"session.status.working": "작업 중...",
|
||||
|
||||
"ui.sessionTurn.cancel": "취소",
|
||||
"ui.sessionTurn.status.thinking": "생각 중...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "다음 단계 고려 중...",
|
||||
|
||||
"dialog.model.noProviders": "공급자 없음",
|
||||
|
||||
"prompt.placeholder.connecting": "서버에 연결 중...",
|
||||
@@ -1333,4 +1337,5 @@ export const dict = {
|
||||
"notifications.action.next": "다음",
|
||||
"notifications.action.close": "닫기",
|
||||
"notifications.action.tryModel": "{{model}} 시도",
|
||||
"notifications.action.tryModelGeneric": "모델 시도",
|
||||
}
|
||||
|
||||
+7
-1
@@ -942,6 +942,10 @@ export const dict = {
|
||||
"session.status.retrying": "Opnieuw proberen (poging {{ attempt }})... {{ message }}",
|
||||
"session.status.working": "Bezig...",
|
||||
|
||||
"ui.sessionTurn.cancel": "Annuleren",
|
||||
"ui.sessionTurn.status.thinking": "Denken...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "Volgende stappen overwegen...",
|
||||
|
||||
"dialog.model.noProviders": "Geen providers",
|
||||
|
||||
"prompt.placeholder.connecting": "Verbinden met server...",
|
||||
@@ -1351,6 +1355,7 @@ export const dict = {
|
||||
"notifications.action.next": "Volgende",
|
||||
"notifications.action.close": "Sluiten",
|
||||
"notifications.action.tryModel": "Probeer {{model}}",
|
||||
"notifications.action.tryModelGeneric": "Probeer model",
|
||||
// Missing translations - English fallbacks until translated
|
||||
"settings.agentBehaviour.createMode": "Nieuwe modus aanmaken",
|
||||
"settings.agentBehaviour.createMode.button": "Aanmaken",
|
||||
@@ -1373,7 +1378,8 @@ export const dict = {
|
||||
"settings.agentBehaviour.editMode": "Modus bewerken",
|
||||
"settings.agentBehaviour.editMode.back": "Terug naar lijst",
|
||||
"settings.agentBehaviour.editMode.description": "Beschrijving",
|
||||
"settings.agentBehaviour.editMode.native": "Ingebouwde modus (alleen-lezen definitie)",
|
||||
"settings.agentBehaviour.editMode.native":
|
||||
"Dit is een ingebouwde modus. De basisdefinitie kan niet worden gewijzigd, maar u kunt hieronder overrides configureren.",
|
||||
"settings.agentBehaviour.editMode.prompt": "Systeemprompt",
|
||||
"settings.agentBehaviour.editMode.promptOverride": "Aangepaste systeemprompt voor deze ingebouwde modus",
|
||||
"settings.agentBehaviour.editMode.save": "Klaar",
|
||||
|
||||
+5
@@ -945,6 +945,10 @@ export const dict = {
|
||||
"session.status.retrying": "Prøver på nytt (forsøk {{ attempt }})… {{ message }}",
|
||||
"session.status.working": "Arbeider…",
|
||||
|
||||
"ui.sessionTurn.cancel": "Avbryt",
|
||||
"ui.sessionTurn.status.thinking": "Tenker...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "Vurderer neste steg...",
|
||||
|
||||
"dialog.model.noProviders": "Ingen leverandører",
|
||||
|
||||
"prompt.placeholder.connecting": "Kobler til server...",
|
||||
@@ -1344,4 +1348,5 @@ export const dict = {
|
||||
"notifications.action.next": "Neste",
|
||||
"notifications.action.close": "Lukk",
|
||||
"notifications.action.tryModel": "Prøv {{model}}",
|
||||
"notifications.action.tryModelGeneric": "Prøv modell",
|
||||
} satisfies Partial<Record<Keys, string>>
|
||||
|
||||
+5
@@ -945,6 +945,10 @@ export const dict = {
|
||||
"session.status.retrying": "Ponawiam próbę ({{ attempt }})… {{ message }}",
|
||||
"session.status.working": "Pracuję…",
|
||||
|
||||
"ui.sessionTurn.cancel": "Anuluj",
|
||||
"ui.sessionTurn.status.thinking": "Myślę...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "Rozważam następne kroki...",
|
||||
|
||||
"dialog.model.noProviders": "Brak dostawców",
|
||||
|
||||
"prompt.placeholder.connecting": "Łączenie z serwerem...",
|
||||
@@ -1354,4 +1358,5 @@ export const dict = {
|
||||
"notifications.action.next": "Następny",
|
||||
"notifications.action.close": "Zamknij",
|
||||
"notifications.action.tryModel": "Wypróbuj {{model}}",
|
||||
"notifications.action.tryModelGeneric": "Wypróbuj model",
|
||||
}
|
||||
|
||||
+5
@@ -948,6 +948,10 @@ export const dict = {
|
||||
"session.status.retrying": "Повторная попытка ({{ attempt }})… {{ message }}",
|
||||
"session.status.working": "Работаю…",
|
||||
|
||||
"ui.sessionTurn.cancel": "Отмена",
|
||||
"ui.sessionTurn.status.thinking": "Думаю...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "Продумываю следующие шаги...",
|
||||
|
||||
"dialog.model.noProviders": "Нет провайдеров",
|
||||
|
||||
"prompt.placeholder.connecting": "Подключение к серверу...",
|
||||
@@ -1353,4 +1357,5 @@ export const dict = {
|
||||
"notifications.action.next": "Далее",
|
||||
"notifications.action.close": "Закрыть",
|
||||
"notifications.action.tryModel": "Попробовать {{model}}",
|
||||
"notifications.action.tryModelGeneric": "Попробовать модель",
|
||||
}
|
||||
|
||||
+5
@@ -936,6 +936,10 @@ export const dict = {
|
||||
"session.status.retrying": "กำลังลองใหม่ (ครั้งที่ {{ attempt }})… {{ message }}",
|
||||
"session.status.working": "กำลังทำงาน...",
|
||||
|
||||
"ui.sessionTurn.cancel": "ยกเลิก",
|
||||
"ui.sessionTurn.status.thinking": "กำลังคิด...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "กำลังพิจารณาขั้นตอนถัดไป...",
|
||||
|
||||
"dialog.model.noProviders": "ไม่มีผู้ให้บริการ",
|
||||
|
||||
"prompt.placeholder.connecting": "กำลังเชื่อมต่อกับเซิร์ฟเวอร์...",
|
||||
@@ -1329,4 +1333,5 @@ export const dict = {
|
||||
"notifications.action.next": "ถัดไป",
|
||||
"notifications.action.close": "ปิด",
|
||||
"notifications.action.tryModel": "ลองใช้ {{model}}",
|
||||
"notifications.action.tryModelGeneric": "ลองใช้โมเดล",
|
||||
}
|
||||
|
||||
+7
-1
@@ -944,6 +944,10 @@ export const dict = {
|
||||
"session.status.retrying": "Yeniden deneniyor (deneme {{ attempt }})… {{ message }}",
|
||||
"session.status.working": "Çalışıyor...",
|
||||
|
||||
"ui.sessionTurn.cancel": "İptal",
|
||||
"ui.sessionTurn.status.thinking": "Düşünüyor...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "Sonraki adımları değerlendiriyor...",
|
||||
|
||||
"dialog.model.noProviders": "Sağlayıcı yok",
|
||||
|
||||
"prompt.placeholder.connecting": "Sunucuya bağlanılıyor...",
|
||||
@@ -1343,6 +1347,7 @@ export const dict = {
|
||||
"notifications.action.next": "Sonraki",
|
||||
"notifications.action.close": "Kapat",
|
||||
"notifications.action.tryModel": "Dene {{model}}",
|
||||
"notifications.action.tryModelGeneric": "Modeli Dene",
|
||||
// Missing translations - English fallbacks until translated
|
||||
"profile.switchingAccount": "Hesap değiştiriliyor…",
|
||||
"settings.agentBehaviour.createMode": "Yeni Mod Oluştur",
|
||||
@@ -1366,7 +1371,8 @@ export const dict = {
|
||||
"settings.agentBehaviour.editMode": "Modu Düzenle",
|
||||
"settings.agentBehaviour.editMode.back": "Listeye dön",
|
||||
"settings.agentBehaviour.editMode.description": "Açıklama",
|
||||
"settings.agentBehaviour.editMode.native": "Yerleşik mod (salt okunur tanım)",
|
||||
"settings.agentBehaviour.editMode.native":
|
||||
"Bu yerleşik bir moddur. Temel tanımı değiştirilemez, ancak aşağıdan geçersiz kılmaları yapılandırabilirsiniz.",
|
||||
"settings.agentBehaviour.editMode.prompt": "Sistem İstemi",
|
||||
"settings.agentBehaviour.editMode.promptOverride": "Bu yerleşik mod için özel sistem istemi geçersiz kılma",
|
||||
"settings.agentBehaviour.editMode.save": "Tamam",
|
||||
|
||||
+7
-1
@@ -945,6 +945,10 @@ export const dict = {
|
||||
"session.status.retrying": "Повторна спроба (спроба {{ attempt }})… {{ message }}",
|
||||
"session.status.working": "Працює...",
|
||||
|
||||
"ui.sessionTurn.cancel": "Скасувати",
|
||||
"ui.sessionTurn.status.thinking": "Думаю...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "Обдумую наступні кроки...",
|
||||
|
||||
"dialog.model.noProviders": "Немає провайдерів",
|
||||
|
||||
"prompt.placeholder.connecting": "Підключення до сервера...",
|
||||
@@ -1344,6 +1348,7 @@ export const dict = {
|
||||
"notifications.action.next": "Далі",
|
||||
"notifications.action.close": "Закрити",
|
||||
"notifications.action.tryModel": "Спробувати {{model}}",
|
||||
"notifications.action.tryModelGeneric": "Спробувати модель",
|
||||
// Missing translations - English fallbacks until translated
|
||||
"profile.switchingAccount": "Перемикання акаунту…",
|
||||
"settings.agentBehaviour.createMode": "Створити новий режим",
|
||||
@@ -1367,7 +1372,8 @@ export const dict = {
|
||||
"settings.agentBehaviour.editMode": "Редагувати режим",
|
||||
"settings.agentBehaviour.editMode.back": "Назад до списку",
|
||||
"settings.agentBehaviour.editMode.description": "Опис",
|
||||
"settings.agentBehaviour.editMode.native": "Вбудований режим (визначення лише для читання)",
|
||||
"settings.agentBehaviour.editMode.native":
|
||||
"Це вбудований режим. Його базове визначення неможливо змінити, але ви можете налаштувати перевизначення нижче.",
|
||||
"settings.agentBehaviour.editMode.prompt": "Системний запит",
|
||||
"settings.agentBehaviour.editMode.promptOverride":
|
||||
"Власне перевизначення системного запиту для цього вбудованого режиму",
|
||||
|
||||
+5
@@ -928,6 +928,10 @@ export const dict = {
|
||||
"session.status.retrying": "正在重试(第 {{ attempt }} 次)… {{ message }}",
|
||||
"session.status.working": "处理中…",
|
||||
|
||||
"ui.sessionTurn.cancel": "取消",
|
||||
"ui.sessionTurn.status.thinking": "思考中...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "正在考虑下一步...",
|
||||
|
||||
"dialog.model.noProviders": "无供应商",
|
||||
|
||||
"prompt.placeholder.connecting": "正在连接服务器...",
|
||||
@@ -1304,4 +1308,5 @@ export const dict = {
|
||||
"notifications.action.next": "下一个",
|
||||
"notifications.action.close": "关闭",
|
||||
"notifications.action.tryModel": "尝试 {{model}}",
|
||||
"notifications.action.tryModelGeneric": "尝试模型",
|
||||
} satisfies Partial<Record<Keys, string>>
|
||||
|
||||
+5
@@ -930,6 +930,10 @@ export const dict = {
|
||||
"session.status.retrying": "正在重試(第 {{ attempt }} 次)… {{ message }}",
|
||||
"session.status.working": "處理中…",
|
||||
|
||||
"ui.sessionTurn.cancel": "取消",
|
||||
"ui.sessionTurn.status.thinking": "思考中...",
|
||||
"ui.sessionTurn.status.consideringNextSteps": "正在考慮下一步...",
|
||||
|
||||
"dialog.model.noProviders": "沒有供應商",
|
||||
|
||||
"prompt.placeholder.connecting": "正在連線至伺服器...",
|
||||
@@ -1307,4 +1311,5 @@ export const dict = {
|
||||
"notifications.action.next": "下一個",
|
||||
"notifications.action.close": "關閉",
|
||||
"notifications.action.tryModel": "嘗試 {{model}}",
|
||||
"notifications.action.tryModelGeneric": "嘗試模型",
|
||||
} satisfies Partial<Record<Keys, string>>
|
||||
|
||||
@@ -799,6 +799,75 @@ export interface WorktreeState {
|
||||
groupId?: string
|
||||
/** User-provided display name for the worktree. */
|
||||
label?: string
|
||||
/** Cached PR number for instant badge display on reload. */
|
||||
prNumber?: number
|
||||
/** Cached PR URL for instant badge display on reload. */
|
||||
prUrl?: string
|
||||
/** Cached PR state for correct badge color on reload (open/merged/closed/draft). */
|
||||
prState?: string
|
||||
/** Section this worktree belongs to, or undefined for ungrouped. */
|
||||
sectionId?: string
|
||||
}
|
||||
|
||||
export interface SectionState {
|
||||
id: string
|
||||
name: string
|
||||
/** Color label (e.g. "Red", "Blue") or null for default. */
|
||||
color: string | null
|
||||
order: number
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PR status types (mirrored from extension types.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PRState = "open" | "draft" | "merged" | "closed"
|
||||
export type ReviewDecision = "approved" | "changes_requested" | "pending"
|
||||
export type CheckStatus = "success" | "failure" | "pending" | "skipped" | "cancelled"
|
||||
export type AggregateCheckStatus = "success" | "failure" | "pending" | "none"
|
||||
|
||||
export interface PRCheck {
|
||||
name: string
|
||||
status: CheckStatus
|
||||
url?: string
|
||||
duration?: string
|
||||
}
|
||||
|
||||
export interface PRComment {
|
||||
id: string
|
||||
author: string
|
||||
avatar?: string
|
||||
body: string
|
||||
file?: string
|
||||
line?: number
|
||||
url?: string
|
||||
resolved: boolean
|
||||
createdAt?: number
|
||||
}
|
||||
|
||||
export interface PRStatus {
|
||||
number: number
|
||||
title: string
|
||||
url: string
|
||||
state: PRState
|
||||
review: ReviewDecision | null
|
||||
checks: {
|
||||
status: AggregateCheckStatus
|
||||
total: number
|
||||
passed: number
|
||||
failed: number
|
||||
pending: number
|
||||
items: PRCheck[]
|
||||
}
|
||||
comments?: {
|
||||
total: number
|
||||
unresolved: number
|
||||
items: PRComment[]
|
||||
}
|
||||
additions: number
|
||||
deletions: number
|
||||
files: number
|
||||
}
|
||||
|
||||
export interface ManagedSessionState {
|
||||
@@ -827,6 +896,7 @@ export interface AgentManagerStateMessage {
|
||||
type: "agentManager.state"
|
||||
worktrees: WorktreeState[]
|
||||
sessions: ManagedSessionState[]
|
||||
sections?: SectionState[]
|
||||
staleWorktreeIds?: string[]
|
||||
tabOrder?: Record<string, string[]>
|
||||
worktreeOrder?: string[]
|
||||
@@ -983,6 +1053,14 @@ export interface AgentManagerLocalStatsMessage {
|
||||
stats: LocalGitStats
|
||||
}
|
||||
|
||||
// Agent Manager: PR status push (extension → webview)
|
||||
export interface AgentManagerPRStatusMessage {
|
||||
type: "agentManager.prStatus"
|
||||
worktreeId: string
|
||||
pr: PRStatus | null
|
||||
error?: "gh_missing" | "gh_auth" | "fetch_failed"
|
||||
}
|
||||
|
||||
// Sidebar: Live worktree diff stats (extension → webview)
|
||||
export interface WorktreeStatsLoadedMessage {
|
||||
type: "worktreeStatsLoaded"
|
||||
@@ -1360,6 +1438,7 @@ export type ExtensionMessage =
|
||||
| AgentManagerApplyWorktreeDiffResultMessage
|
||||
| AgentManagerWorktreeStatsMessage
|
||||
| AgentManagerLocalStatsMessage
|
||||
| AgentManagerPRStatusMessage
|
||||
// legacy-migration start
|
||||
| MigrationStateMessage
|
||||
| LegacyMigrationDataMessage
|
||||
@@ -1959,6 +2038,17 @@ export interface StopDiffWatchMessage {
|
||||
type: "agentManager.stopDiffWatch"
|
||||
}
|
||||
|
||||
// Agent Manager: PR messages (webview → extension)
|
||||
export interface RefreshPRMessage {
|
||||
type: "agentManager.refreshPR"
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
export interface OpenPRMessage {
|
||||
type: "agentManager.openPR"
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
export interface ApplyWorktreeDiffMessage {
|
||||
type: "agentManager.applyWorktreeDiff"
|
||||
worktreeId: string
|
||||
@@ -2084,6 +2174,48 @@ export interface ContinueInWorktreeRequest {
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
// Section CRUD messages (webview → extension)
|
||||
export interface CreateSectionRequest {
|
||||
type: "agentManager.createSection"
|
||||
name: string
|
||||
color?: string
|
||||
worktreeIds?: string[]
|
||||
}
|
||||
|
||||
export interface RenameSectionRequest {
|
||||
type: "agentManager.renameSection"
|
||||
sectionId: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface DeleteSectionRequest {
|
||||
type: "agentManager.deleteSection"
|
||||
sectionId: string
|
||||
}
|
||||
|
||||
export interface SetSectionColorRequest {
|
||||
type: "agentManager.setSectionColor"
|
||||
sectionId: string
|
||||
color: string | null
|
||||
}
|
||||
|
||||
export interface ToggleSectionCollapsedRequest {
|
||||
type: "agentManager.toggleSectionCollapsed"
|
||||
sectionId: string
|
||||
}
|
||||
|
||||
export interface MoveToSectionRequest {
|
||||
type: "agentManager.moveToSection"
|
||||
worktreeIds: string[]
|
||||
sectionId: string | null
|
||||
}
|
||||
|
||||
export interface MoveSectionRequest {
|
||||
type: "agentManager.moveSection"
|
||||
sectionId: string
|
||||
dir: -1 | 1
|
||||
}
|
||||
|
||||
export type ContinueInWorktreeStatus =
|
||||
| "capturing"
|
||||
| "creating"
|
||||
@@ -2198,6 +2330,8 @@ export type WebviewMessage =
|
||||
| RequestWorktreeDiffFileMessage
|
||||
| StartDiffWatchMessage
|
||||
| StopDiffWatchMessage
|
||||
| RefreshPRMessage
|
||||
| OpenPRMessage
|
||||
// legacy-migration start
|
||||
| RequestLegacyMigrationDataMessage
|
||||
| StartLegacyMigrationMessage
|
||||
@@ -2227,6 +2361,13 @@ export type WebviewMessage =
|
||||
| ToggleFavoriteRequest
|
||||
| RequestFavoritesMessage
|
||||
| ContinueInWorktreeRequest
|
||||
| CreateSectionRequest
|
||||
| RenameSectionRequest
|
||||
| DeleteSectionRequest
|
||||
| SetSectionColorRequest
|
||||
| ToggleSectionCollapsedRequest
|
||||
| MoveToSectionRequest
|
||||
| MoveSectionRequest
|
||||
|
||||
// ============================================
|
||||
// VS Code API type
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user