Merge branch 'dev' into feat/suggestions-after-plan-end

This commit is contained in:
Marian Alexandru Alecu
2026-02-20 14:20:56 +02:00
committed by GitHub
19 changed files with 1305 additions and 91 deletions
+1
View File
@@ -228,6 +228,7 @@
"@kilocode/kilo-ui": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "^7.0.0",
"dotenv": "^16.4.7",
"eventsource": "^2.0.2",
+1
View File
@@ -535,6 +535,7 @@
"@kilocode/kilo-ui": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "^7.0.0",
"dotenv": "^16.4.7",
"eventsource": "^2.0.2",
@@ -4,6 +4,8 @@ import { KiloProvider } from "../KiloProvider"
import { buildWebviewHtml } from "../utils"
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
import { WorktreeStateManager } from "./WorktreeStateManager"
import { SetupScriptService } from "./SetupScriptService"
import { SetupScriptRunner } from "./SetupScriptRunner"
import { SessionTerminalManager } from "./SessionTerminalManager"
/**
@@ -22,6 +24,7 @@ export class AgentManagerProvider implements vscode.Disposable {
private outputChannel: vscode.OutputChannel
private worktrees: WorktreeManager | undefined
private state: WorktreeStateManager | undefined
private setupScript: SetupScriptService | undefined
private terminalManager: SessionTerminalManager
constructor(
@@ -129,6 +132,10 @@ export class AgentManagerProvider implements vscode.Disposable {
return this.onAddSessionToWorktree(msg.worktreeId)
if (type === "agentManager.closeSession" && typeof msg.sessionId === "string")
return this.onCloseSession(msg.sessionId)
if (type === "agentManager.configureSetupScript") {
void this.configureSetupScript()
return null
}
if (type === "agentManager.showTerminal" && typeof msg.sessionId === "string") {
this.terminalManager.showTerminal(msg.sessionId, this.state)
return null
@@ -137,6 +144,10 @@ export class AgentManagerProvider implements vscode.Disposable {
void this.sendRepoInfo()
return null
}
if (type === "agentManager.setTabOrder" && typeof msg.key === "string" && Array.isArray(msg.order)) {
this.state?.setTabOrder(msg.key as string, msg.order as string[])
return null
}
// When switching sessions, show existing terminal if one is open
if (type === "loadMessages" && typeof msg.sessionID === "string") {
@@ -258,6 +269,9 @@ export class AgentManagerProvider implements vscode.Disposable {
const created = await this.createWorktreeOnDisk()
if (!created) return null
// Run setup script for new worktree (blocks until complete, shows in overlay)
await this.runSetupScriptForWorktree(created.result.path, created.result.branch)
const session = await this.createSessionInWorktree(created.result.path, created.result.branch)
if (!session) {
const state = this.getStateManager()
@@ -307,6 +321,9 @@ export class AgentManagerProvider implements vscode.Disposable {
const created = await this.createWorktreeOnDisk()
if (!created) return null
// Run setup script for new worktree (blocks until complete, shows in overlay)
await this.runSetupScriptForWorktree(created.result.path, created.result.branch)
const state = this.getStateManager()!
if (!state.getSession(sessionId)) {
state.addSession(sessionId, created.worktree.id)
@@ -376,6 +393,42 @@ export class AgentManagerProvider implements vscode.Disposable {
return null
}
// ---------------------------------------------------------------------------
// Setup script
// ---------------------------------------------------------------------------
/** Open the worktree setup script in the editor for user configuration. */
private async configureSetupScript(): Promise<void> {
const service = this.getSetupScriptService()
if (!service) return
try {
await service.openInEditor()
} catch (error) {
this.log(`Failed to open setup script: ${error}`)
}
}
/** Run the worktree setup script if configured. Blocks until complete. Shows progress in overlay. */
private async runSetupScriptForWorktree(worktreePath: string, branch?: string): Promise<void> {
const root = this.getWorkspaceRoot()
if (!root) return
try {
const service = this.getSetupScriptService()
if (!service || !service.hasScript()) return
this.postToWebview({
type: "agentManager.worktreeSetup",
status: "creating",
message: "Running setup script...",
branch,
})
const runner = new SetupScriptRunner(this.outputChannel, service)
await runner.runIfConfigured({ worktreePath, repoPath: root })
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
this.outputChannel.appendLine(`[AgentManager] Setup script error: ${msg}`)
}
}
// ---------------------------------------------------------------------------
// Repo info
// ---------------------------------------------------------------------------
@@ -408,6 +461,7 @@ export class AgentManagerProvider implements vscode.Disposable {
type: "agentManager.state",
worktrees: state.getWorktrees(),
sessions: state.getSessions(),
tabOrder: state.getTabOrder(),
})
}
@@ -443,6 +497,17 @@ export class AgentManagerProvider implements vscode.Disposable {
return this.state
}
private getSetupScriptService(): SetupScriptService | undefined {
if (this.setupScript) return this.setupScript
const root = this.getWorkspaceRoot()
if (!root) {
this.log("getSetupScriptService: no workspace folder available")
return undefined
}
this.setupScript = new SetupScriptService(root)
return this.setupScript
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -0,0 +1,152 @@
/**
* SetupScriptRunner - Executes worktree setup scripts
*
* Runs setup scripts in VS Code integrated terminal before agent starts.
* Uses VS Code shell integration to track execution and exit code.
* Falls back to sendText + onDidCloseTerminal if shell integration is unavailable.
* Cross-platform: Unix uses sh, Windows uses cmd.exe.
*/
import * as vscode from "vscode"
import { SetupScriptService } from "./SetupScriptService"
import { buildSetupCommand } from "./setup-script-command"
export interface SetupScriptEnvironment {
/** Absolute path to the worktree directory */
worktreePath: string
/** Absolute path to the main repository */
repoPath: string
}
export class SetupScriptRunner {
constructor(
private readonly output: vscode.OutputChannel,
private readonly service: SetupScriptService,
) {}
/**
* Execute setup script in a worktree if script exists.
* Waits for the script to finish before resolving.
*
* @returns true if script was executed, false if skipped (no script configured)
*/
async runIfConfigured(env: SetupScriptEnvironment): Promise<boolean> {
if (!this.service.hasScript()) {
this.log("No setup script configured, skipping")
return false
}
const script = this.service.getScriptPath()
this.log(`Running setup script: ${script}`)
try {
await this.executeInTerminal(script, env)
this.log("Setup script completed")
return true
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
this.log(`Setup script execution failed: ${msg}`)
return true // Script was attempted
}
}
/** Execute the setup script in a VS Code terminal and wait for it to finish. */
private async executeInTerminal(script: string, env: SetupScriptEnvironment): Promise<void> {
const terminal = vscode.window.createTerminal({
name: "Worktree Setup",
cwd: env.worktreePath,
env: {
WORKTREE_PATH: env.worktreePath,
REPO_PATH: env.repoPath,
},
iconPath: new vscode.ThemeIcon("gear"),
})
terminal.show(true)
// Try shell integration first — gives us proper exit code tracking
const integration = await this.waitForShellIntegration(terminal, 5000)
if (integration) {
this.log("Using shell integration for setup script execution")
await this.runViaShellIntegration(terminal, integration, script, env)
} else {
this.log("Shell integration unavailable, falling back to sendText")
await this.runViaSendText(terminal, script, env)
}
}
/** Wait for shell integration to become available on a terminal, with timeout. */
private waitForShellIntegration(
terminal: vscode.Terminal,
timeout: number,
): Promise<vscode.TerminalShellIntegration | undefined> {
if (terminal.shellIntegration) return Promise.resolve(terminal.shellIntegration)
return new Promise((resolve) => {
const timer = setTimeout(() => {
listener.dispose()
resolve(undefined)
}, timeout)
const listener = vscode.window.onDidChangeTerminalShellIntegration((e) => {
if (e.terminal !== terminal) return
clearTimeout(timer)
listener.dispose()
resolve(e.shellIntegration)
})
})
}
/** Run script via shell integration — tracks execution and exit code properly. */
private runViaShellIntegration(
terminal: vscode.Terminal,
integration: vscode.TerminalShellIntegration,
script: string,
env: SetupScriptEnvironment,
): Promise<void> {
return new Promise((resolve) => {
const command = buildSetupCommand(script, env)
const execution = integration.executeCommand(command)
const cleanup = () => {
execListener.dispose()
closeListener.dispose()
}
// Primary: shell integration reports execution finished with exit code
const execListener = vscode.window.onDidEndTerminalShellExecution((e) => {
if (e.execution !== execution) return
cleanup()
this.log(`Setup script exited with code ${e.exitCode ?? "unknown"}`)
resolve()
})
// Fallback: terminal was closed externally (user, VS Code restart, etc.)
const closeListener = vscode.window.onDidCloseTerminal((closed) => {
if (closed !== terminal) return
cleanup()
this.log("Setup script terminal closed before execution event fired")
resolve()
})
})
}
/** Fallback: run via sendText and wait for terminal to close. */
private runViaSendText(terminal: vscode.Terminal, script: string, env: SetupScriptEnvironment): Promise<void> {
return new Promise((resolve) => {
const listener = vscode.window.onDidCloseTerminal((closed) => {
if (closed !== terminal) return
listener.dispose()
resolve()
})
const command = buildSetupCommand(script, env) + (process.platform === "win32" ? "& exit" : "; exit")
terminal.sendText(command)
this.log("Setup script started in terminal, waiting for completion...")
})
}
private log(message: string): void {
this.output.appendLine(`[SetupScriptRunner] ${message}`)
}
}
@@ -0,0 +1,68 @@
/**
* SetupScriptService - Manages worktree setup scripts
*
* Handles reading, creating, and checking for setup scripts stored in .kilocode/setup-script.
* Setup scripts run before an agent starts in a worktree (new sessions only).
*/
import * as vscode from "vscode"
import * as fs from "node:fs"
import * as path from "node:path"
import { SETUP_SCRIPT_TEMPLATE } from "./setup-script-template"
const SETUP_SCRIPT_FILENAME = "setup-script"
const KILOCODE_DIR = ".kilocode"
export class SetupScriptService {
private readonly root: string
private readonly script: string
constructor(root: string) {
this.root = root
this.script = path.join(root, KILOCODE_DIR, SETUP_SCRIPT_FILENAME)
}
/** Get the path to the setup script */
getScriptPath(): string {
return this.script
}
/** Check if a setup script exists */
hasScript(): boolean {
return fs.existsSync(this.script)
}
/** Read the setup script content. Returns null if not found or read fails. */
async getScript(): Promise<string | null> {
if (!this.hasScript()) return null
try {
return await fs.promises.readFile(this.script, "utf-8")
} catch (error) {
this.log(`Failed to read setup script: ${error}`)
return null
}
}
/** Create a default setup script with helpful comments */
async createDefaultScript(): Promise<void> {
const dir = path.join(this.root, KILOCODE_DIR)
if (!fs.existsSync(dir)) {
await fs.promises.mkdir(dir, { recursive: true })
}
await fs.promises.writeFile(this.script, SETUP_SCRIPT_TEMPLATE, "utf-8")
}
/** Open the setup script in VS Code editor. Creates the default script if it doesn't exist. */
async openInEditor(): Promise<void> {
if (!this.hasScript()) {
await this.createDefaultScript()
}
const document = await vscode.workspace.openTextDocument(this.script)
await vscode.window.showTextDocument(document)
}
private log(message: string): void {
// Log to console since we don't have an OutputChannel here
console.log(`[SetupScriptService] ${message}`)
}
}
@@ -185,6 +185,7 @@ export class WorktreeManager {
const excludePath = path.join(gitDir, "info", "exclude")
await this.addExcludeEntry(excludePath, ".kilocode/worktrees/", "Kilo Code agent worktrees")
await this.addExcludeEntry(excludePath, ".kilocode/agent-manager.json", "Kilo Agent Manager state")
await this.addExcludeEntry(excludePath, ".kilocode/setup-script", "Kilo Code worktree setup script")
}
private async ensureWorktreeExclude(worktreePath: string): Promise<void> {
@@ -29,6 +29,7 @@ export interface ManagedSession {
interface StateFile {
worktrees: Record<string, Omit<Worktree, "id">>
sessions: Record<string, Omit<ManagedSession, "id">>
tabOrder?: Record<string, string[]>
}
const STATE_FILE = "agent-manager.json"
@@ -44,6 +45,7 @@ export class WorktreeStateManager {
private readonly file: string
private worktrees = new Map<string, Worktree>()
private sessions = new Map<string, ManagedSession>()
private tabOrder: Record<string, string[]> = {}
private readonly log: (msg: string) => void
private saving: Promise<void> | undefined
private pendingSave = false
@@ -125,6 +127,9 @@ export class WorktreeStateManager {
}
}
// Clean up tab order for this worktree
delete this.tabOrder[id]
this.log(`Removed worktree ${id}, orphaned ${orphaned.length} sessions`)
void this.save()
return orphaned
@@ -149,6 +154,34 @@ export class WorktreeStateManager {
removeSession(id: string): void {
this.sessions.delete(id)
// Remove this session from any tab order arrays
for (const [key, order] of Object.entries(this.tabOrder)) {
const idx = order.indexOf(id)
if (idx !== -1) {
order.splice(idx, 1)
if (order.length === 0) delete this.tabOrder[key]
}
}
void this.save()
}
// ---------------------------------------------------------------------------
// Tab order
// ---------------------------------------------------------------------------
getTabOrder(): Record<string, string[]> {
return this.tabOrder
}
setTabOrder(key: string, order: string[]): void {
this.tabOrder[key] = order
void this.save()
}
removeTabOrder(key: string): void {
delete this.tabOrder[key]
void this.save()
}
@@ -162,6 +195,7 @@ export class WorktreeStateManager {
const data = JSON.parse(content) as StateFile
this.worktrees.clear()
this.sessions.clear()
this.tabOrder = {}
for (const [id, wt] of Object.entries(data.worktrees ?? {})) {
this.worktrees.set(id, { id, ...wt })
@@ -169,6 +203,9 @@ export class WorktreeStateManager {
for (const [id, s] of Object.entries(data.sessions ?? {})) {
this.sessions.set(id, { id, ...s })
}
if (data.tabOrder) {
this.tabOrder = data.tabOrder
}
this.log(`Loaded state: ${this.worktrees.size} worktrees, ${this.sessions.size} sessions`)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
@@ -229,6 +266,9 @@ export class WorktreeStateManager {
const { id: _, ...rest } = s
data.sessions[id] = rest
}
if (Object.keys(this.tabOrder).length > 0) {
data.tabOrder = this.tabOrder
}
const dir = path.dirname(this.file)
if (!fs.existsSync(dir)) await fs.promises.mkdir(dir, { recursive: true })
@@ -0,0 +1,85 @@
import { describe, it, expect } from "vitest"
import { buildSetupCommand } from "../setup-script-command"
const env = {
worktreePath: "/repos/project/.kilocode/worktrees/wt-1",
repoPath: "/repos/project",
}
const script = "/repos/project/.kilocode/setup-script"
describe("buildSetupCommand", () => {
it("builds unix command with inline env vars and sh", () => {
const result = buildSetupCommand(script, env, "darwin")
expect(result).toBe(
`WORKTREE_PATH="/repos/project/.kilocode/worktrees/wt-1" REPO_PATH="/repos/project" sh "/repos/project/.kilocode/setup-script"`,
)
})
it("builds linux command same as darwin", () => {
const result = buildSetupCommand(script, env, "linux")
expect(result).toContain("sh ")
expect(result).not.toContain("set ")
expect(result).not.toContain("call ")
})
it("builds windows command with set and call", () => {
const result = buildSetupCommand(script, env, "win32")
expect(result).toBe(
`set "WORKTREE_PATH=/repos/project/.kilocode/worktrees/wt-1" && set "REPO_PATH=/repos/project" && call "/repos/project/.kilocode/setup-script"`,
)
})
it("includes both env vars in unix command", () => {
const result = buildSetupCommand(script, env, "darwin")
expect(result).toContain(`WORKTREE_PATH="${env.worktreePath}"`)
expect(result).toContain(`REPO_PATH="${env.repoPath}"`)
})
it("includes both env vars in windows command", () => {
const result = buildSetupCommand(script, env, "win32")
expect(result).toContain(`set "WORKTREE_PATH=${env.worktreePath}"`)
expect(result).toContain(`set "REPO_PATH=${env.repoPath}"`)
})
it("handles paths with spaces", () => {
const spaced = {
worktreePath: "/Users/dev/my project/.kilocode/worktrees/wt-1",
repoPath: "/Users/dev/my project",
}
const spacedScript = "/Users/dev/my project/.kilocode/setup-script"
const unix = buildSetupCommand(spacedScript, spaced, "darwin")
expect(unix).toContain(`sh "/Users/dev/my project/.kilocode/setup-script"`)
const win = buildSetupCommand(spacedScript, spaced, "win32")
expect(win).toContain(`call "/Users/dev/my project/.kilocode/setup-script"`)
})
it("escapes double quotes in unix paths", () => {
const dangerous = {
worktreePath: '/repos/proj"ect',
repoPath: "/repos/safe",
}
const result = buildSetupCommand(script, dangerous, "darwin")
expect(result).toContain(`WORKTREE_PATH="/repos/proj\\"ect"`)
})
it("escapes dollar signs in unix paths", () => {
const dangerous = {
worktreePath: "/repos/$HOME/project",
repoPath: "/repos/safe",
}
const result = buildSetupCommand(script, dangerous, "darwin")
expect(result).toContain(`WORKTREE_PATH="/repos/\\$HOME/project"`)
})
it("escapes backticks in unix paths", () => {
const dangerous = {
worktreePath: "/repos/`whoami`/project",
repoPath: "/repos/safe",
}
const result = buildSetupCommand(script, dangerous, "darwin")
expect(result).toContain('WORKTREE_PATH="/repos/\\`whoami\\`/project"')
})
})
@@ -0,0 +1,21 @@
/** Escape characters that are special inside double-quoted shell strings. */
function escapeShell(value: string): string {
return value.replace(/["$`\\]/g, "\\$&")
}
/** Build the platform-appropriate command string for running a setup script. */
export function buildSetupCommand(
script: string,
env: { worktreePath: string; repoPath: string },
platform: NodeJS.Platform = process.platform,
): string {
if (platform === "win32") {
// Windows cmd.exe: double quotes in set values don't need escaping the same way,
// but we escape for the call argument
return `set "WORKTREE_PATH=${env.worktreePath}" && set "REPO_PATH=${env.repoPath}" && call "${script}"`
}
const wt = escapeShell(env.worktreePath)
const repo = escapeShell(env.repoPath)
const path = escapeShell(script)
return `WORKTREE_PATH="${wt}" REPO_PATH="${repo}" sh "${path}"`
}
@@ -0,0 +1,41 @@
/** Default template for worktree setup scripts. */
export const SETUP_SCRIPT_TEMPLATE = `#!/bin/bash
# Kilo Code Worktree Setup Script
# This script runs before the agent starts in a worktree (new sessions only).
#
# Available environment variables:
# WORKTREE_PATH - Absolute path to the worktree directory
# REPO_PATH - Absolute path to the main repository
#
# Example tasks:
# - Copy .env files from main repo
# - Install dependencies
# - Run database migrations
# - Set up local configuration
set -e # Exit on error
echo "Setting up worktree: $WORKTREE_PATH"
# Uncomment and modify as needed:
# Copy environment files
# if [ -f "$REPO_PATH/.env" ]; then
# cp "$REPO_PATH/.env" "$WORKTREE_PATH/.env"
# echo "Copied .env"
# fi
# Install dependencies (Node.js)
# if [ -f "$WORKTREE_PATH/package.json" ]; then
# cd "$WORKTREE_PATH"
# npm install
# fi
# Install dependencies (Python)
# if [ -f "$WORKTREE_PATH/requirements.txt" ]; then
# cd "$WORKTREE_PATH"
# pip install -r requirements.txt
# fi
echo "Setup complete!"
`
@@ -14,9 +14,17 @@ import { Project, SyntaxKind } from "ts-morph"
const ROOT = path.resolve(import.meta.dir, "../..")
const CSS_FILE = path.join(ROOT, "webview-ui/agent-manager/agent-manager.css")
const TSX_FILE = path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx")
const TSX_FILES = [
path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"),
path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"),
]
const TSX_FILE = TSX_FILES[0]
const PROVIDER_FILE = path.join(ROOT, "src/agent-manager/AgentManagerProvider.ts")
function readAllTsx(): string {
return TSX_FILES.map((f) => fs.readFileSync(f, "utf-8")).join("\n")
}
describe("Agent Manager CSS Prefix", () => {
it("all class selectors should use am- prefix", () => {
const css = fs.readFileSync(CSS_FILE, "utf-8")
@@ -54,7 +62,7 @@ describe("Agent Manager CSS Prefix", () => {
describe("Agent Manager CSS/TSX Consistency", () => {
it("all classes used in TSX should be defined in CSS", () => {
const css = fs.readFileSync(CSS_FILE, "utf-8")
const tsx = fs.readFileSync(TSX_FILE, "utf-8")
const tsx = readAllTsx()
// Extract am- classes defined in CSS
const cssMatches = [...css.matchAll(/\.([a-z][a-z0-9-]*)/gi)]
@@ -71,7 +79,7 @@ describe("Agent Manager CSS/TSX Consistency", () => {
it("all am- classes defined in CSS should be used in TSX", () => {
const css = fs.readFileSync(CSS_FILE, "utf-8")
const tsx = fs.readFileSync(TSX_FILE, "utf-8")
const tsx = readAllTsx()
// Extract am- classes defined in CSS
const cssMatches = [...css.matchAll(/\.([a-z][a-z0-9-]*)/gi)]
@@ -0,0 +1,200 @@
import { describe, it, expect } from "bun:test"
import { reorderTabs, applyTabOrder, firstOrderedTitle } from "../../webview-ui/agent-manager/tab-order"
describe("reorderTabs", () => {
const tabs = ["a", "b", "c", "d"]
it("moves an item forward", () => {
expect(reorderTabs(tabs, "a", "c")).toEqual(["b", "c", "a", "d"])
})
it("moves an item backward", () => {
expect(reorderTabs(tabs, "c", "a")).toEqual(["c", "a", "b", "d"])
})
it("swaps adjacent items forward", () => {
expect(reorderTabs(tabs, "a", "b")).toEqual(["b", "a", "c", "d"])
})
it("swaps adjacent items backward", () => {
expect(reorderTabs(tabs, "b", "a")).toEqual(["b", "a", "c", "d"])
})
it("moves first to last", () => {
expect(reorderTabs(tabs, "a", "d")).toEqual(["b", "c", "d", "a"])
})
it("moves last to first", () => {
expect(reorderTabs(tabs, "d", "a")).toEqual(["d", "a", "b", "c"])
})
it("returns undefined when from equals to", () => {
expect(reorderTabs(tabs, "a", "a")).toBeUndefined()
})
it("returns undefined when from is not found", () => {
expect(reorderTabs(tabs, "x", "a")).toBeUndefined()
})
it("returns undefined when to is not found", () => {
expect(reorderTabs(tabs, "a", "x")).toBeUndefined()
})
it("returns undefined when both are missing", () => {
expect(reorderTabs(tabs, "x", "y")).toBeUndefined()
})
it("handles a two-item list", () => {
expect(reorderTabs(["a", "b"], "a", "b")).toEqual(["b", "a"])
expect(reorderTabs(["a", "b"], "b", "a")).toEqual(["b", "a"])
})
it("handles a single-item list (from === to)", () => {
expect(reorderTabs(["a"], "a", "a")).toBeUndefined()
})
it("handles empty list", () => {
expect(reorderTabs([], "a", "b")).toBeUndefined()
})
it("does not mutate the original array", () => {
const original = ["a", "b", "c"]
reorderTabs(original, "a", "c")
expect(original).toEqual(["a", "b", "c"])
})
it("preserves unrelated items", () => {
const result = reorderTabs(["a", "b", "c", "d", "e"], "b", "d")!
expect(result).toEqual(["a", "c", "d", "b", "e"])
expect(result.sort()).toEqual(["a", "b", "c", "d", "e"])
})
it("round-trip: moving forward then back restores original order", () => {
const moved = reorderTabs(tabs, "a", "c")!
const restored = reorderTabs(moved, "a", "b")!
expect(restored).toEqual(["a", "b", "c", "d"])
})
})
describe("applyTabOrder", () => {
const items = [
{ id: "a", name: "Alice" },
{ id: "b", name: "Bob" },
{ id: "c", name: "Carol" },
]
it("reorders items according to custom order", () => {
const result = applyTabOrder(items, ["c", "a", "b"])
expect(result.map((i) => i.id)).toEqual(["c", "a", "b"])
})
it("appends items not in the order", () => {
const result = applyTabOrder(items, ["b"])
expect(result.map((i) => i.id)).toEqual(["b", "a", "c"])
})
it("skips order IDs that are not in items", () => {
const result = applyTabOrder(items, ["x", "c", "y", "a"])
expect(result.map((i) => i.id)).toEqual(["c", "a", "b"])
})
it("returns original array when order is undefined", () => {
const result = applyTabOrder(items, undefined)
expect(result).toBe(items)
})
it("returns original array when order is empty", () => {
const result = applyTabOrder(items, [])
expect(result).toBe(items)
})
it("handles empty items", () => {
expect(applyTabOrder([], ["a", "b"])).toEqual([])
})
it("preserves item properties", () => {
const result = applyTabOrder(items, ["b", "a", "c"])
expect(result[0]).toEqual({ id: "b", name: "Bob" })
})
})
describe("firstOrderedTitle", () => {
const items = [{ id: "a", title: "Alpha" }, { id: "b", title: "Beta" }, { id: "c", title: "" }, { id: "d" }]
it("returns first titled item from custom order", () => {
expect(firstOrderedTitle(items, ["b", "a"], "fallback")).toBe("Beta")
})
it("skips items without titles in order", () => {
expect(firstOrderedTitle(items, ["d", "c", "b"], "fallback")).toBe("Beta")
})
it("falls back to first titled item when order has no matches", () => {
expect(firstOrderedTitle(items, ["x", "y"], "fallback")).toBe("Alpha")
})
it("falls back to first titled item when order is undefined", () => {
expect(firstOrderedTitle(items, undefined, "fallback")).toBe("Alpha")
})
it("returns fallback when no items have titles", () => {
expect(firstOrderedTitle([{ id: "a" }, { id: "b", title: "" }], ["a", "b"], "fallback")).toBe("fallback")
})
it("returns fallback for empty items", () => {
expect(firstOrderedTitle([], ["a"], "fallback")).toBe("fallback")
})
})
// Helper: simulate reconciliation the same way handleDragOver does
function reconcile(current: string[], stored: string[]): string[] {
return applyTabOrder(
current.map((id) => ({ id })),
stored,
).map((item) => item.id)
}
describe("applyTabOrder as reconciliation (string IDs)", () => {
it("returns stored order unchanged when it matches current IDs", () => {
expect(reconcile(["a", "b", "c"], ["a", "b", "c"])).toEqual(["a", "b", "c"])
})
it("appends new IDs not in stored order", () => {
expect(reconcile(["a", "b", "c"], ["a", "b"])).toEqual(["a", "b", "c"])
})
it("removes stale IDs no longer in current", () => {
expect(reconcile(["a", "c"], ["a", "b", "c"])).toEqual(["a", "c"])
})
it("preserves custom ordering while adding new tabs", () => {
expect(reconcile(["a", "b", "c"], ["b", "a"])).toEqual(["b", "a", "c"])
})
it("returns current IDs when stored order is undefined", () => {
expect(applyTabOrder([{ id: "a" }, { id: "b" }], undefined).map((i) => i.id)).toEqual(["a", "b"])
})
describe("regression: reorder a newly added tab immediately", () => {
it("new tab should be reorderable after reconcile via applyTabOrder", () => {
// Stored order from a previous drag: [s2, s1]
// A third session s3 was just added to the worktree
const stored = ["s2", "s1"]
const current = ["s2", "s1", "s3"]
const reconciled = reconcile(current, stored)
expect(reconciled).toEqual(["s2", "s1", "s3"])
// Now the user drags s3 to position of s2 — this must succeed
const reordered = reorderTabs(reconciled, "s3", "s2")
expect(reordered).toEqual(["s3", "s2", "s1"])
expect(reordered).not.toBeUndefined()
})
it("without reconcile, reorderTabs fails on the new tab", () => {
const stored = ["s2", "s1"]
const reordered = reorderTabs(stored, "s3", "s2")
expect(reordered).toBeUndefined()
})
})
})
@@ -174,6 +174,81 @@ describe("WorktreeStateManager", () => {
})
})
describe("tab order", () => {
it("sets and gets tab order for a key", () => {
manager.setTabOrder("wt-1", ["s1", "s2", "s3"])
expect(manager.getTabOrder()["wt-1"]).toEqual(["s1", "s2", "s3"])
})
it("overwrites existing tab order", () => {
manager.setTabOrder("wt-1", ["s1", "s2"])
manager.setTabOrder("wt-1", ["s2", "s1"])
expect(manager.getTabOrder()["wt-1"]).toEqual(["s2", "s1"])
})
it("removes tab order for a key", () => {
manager.setTabOrder("wt-1", ["s1"])
manager.removeTabOrder("wt-1")
expect(manager.getTabOrder()["wt-1"]).toBeUndefined()
})
it("removeTabOrder is a no-op for missing key", () => {
manager.removeTabOrder("nonexistent")
expect(Object.keys(manager.getTabOrder())).toHaveLength(0)
})
it("cleans up tab order when worktree is removed", () => {
const wt = manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" })
manager.addSession("s1", wt.id)
manager.setTabOrder(wt.id, ["s1"])
manager.removeWorktree(wt.id)
expect(manager.getTabOrder()[wt.id]).toBeUndefined()
})
it("removes session from tab order arrays when session is removed", () => {
const wt = manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" })
manager.addSession("s1", wt.id)
manager.addSession("s2", wt.id)
manager.setTabOrder(wt.id, ["s1", "s2"])
manager.removeSession("s1")
expect(manager.getTabOrder()[wt.id]).toEqual(["s2"])
})
it("removes tab order entry when last session in order is removed", () => {
manager.addSession("s1", null)
manager.setTabOrder("local", ["s1"])
manager.removeSession("s1")
expect(manager.getTabOrder()["local"]).toBeUndefined()
})
it("persists and loads tab order", async () => {
const wt = manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" })
manager.setTabOrder(wt.id, ["s2", "s1"])
manager.setTabOrder("local", ["s3", "s4"])
await manager.flush()
await manager.save()
const loaded = new WorktreeStateManager(root, () => {})
await loaded.load()
expect(loaded.getTabOrder()[wt.id]).toEqual(["s2", "s1"])
expect(loaded.getTabOrder()["local"]).toEqual(["s3", "s4"])
})
it("does not persist empty tab order", async () => {
manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" })
await manager.flush()
await manager.save()
const content = fs.readFileSync(path.join(root, ".kilocode", "agent-manager.json"), "utf-8")
const data = JSON.parse(content)
expect(data.tabOrder).toBeUndefined()
})
})
describe("validate", () => {
it("removes worktrees whose directories do not exist", async () => {
const existing = path.join(root, "wt-exists")
@@ -20,6 +20,8 @@ import type {
ManagedSessionState,
SessionInfo,
} from "../src/types/messages"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } 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"
@@ -29,11 +31,13 @@ import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff"
import { Code } from "@kilocode/kilo-ui/code"
import { Diff } from "@kilocode/kilo-ui/diff"
import { Toast } from "@kilocode/kilo-ui/toast"
import { ResizeHandle } from "@kilocode/kilo-ui/resize-handle"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Button } from "@kilocode/kilo-ui/button"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu"
import { VSCodeProvider, useVSCode } from "../src/context/vscode"
import { ServerProvider } from "../src/context/server"
import { ProviderProvider } from "../src/context/provider"
@@ -44,6 +48,8 @@ import { ChatView } from "../src/components/chat"
import { LanguageBridge, DataBridge } from "../src/App"
import { formatRelativeDate } from "../src/utils/date"
import { validateLocalSession, nextSelectionAfterDelete, LOCAL } from "./navigate"
import { reorderTabs, applyTabOrder, firstOrderedTitle } from "./tab-order"
import { ConstrainDragYAxis, SortableTab } from "./sortable-tab"
import "./agent-manager.css"
interface SetupState {
@@ -136,9 +142,14 @@ const AgentManagerContent: Component = () => {
const [repoBranch, setRepoBranch] = createSignal<string | undefined>()
const [deletingWorktrees, setDeletingWorktrees] = createSignal<Set<string>>(new Set())
const DEFAULT_SIDEBAR_WIDTH = 260
const MIN_SIDEBAR_WIDTH = 200
const MAX_SIDEBAR_WIDTH_RATIO = 0.4
// Recover persisted local session IDs from webview state
const persisted = vscode.getState<{ localSessionIDs?: string[] }>()
const persisted = vscode.getState<{ localSessionIDs?: string[]; sidebarWidth?: number }>()
const [localSessionIDs, setLocalSessionIDs] = createSignal<string[]>(persisted?.localSessionIDs ?? [])
const [sidebarWidth, setSidebarWidth] = createSignal(persisted?.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH)
// Pending local tab counter for generating unique IDs
let pendingCounter = 0
@@ -150,6 +161,11 @@ const AgentManagerContent: Component = () => {
const isPending = (id: string) => id.startsWith(PENDING_PREFIX)
// Drag-and-drop state for tab reordering
const [draggingTab, setDraggingTab] = createSignal<string | undefined>()
// Tab ordering: context key → ordered session ID array (recovered from extension state)
const [worktreeTabOrder, setWorktreeTabOrder] = createSignal<Record<string, string[]>>({})
const addPendingTab = () => {
const id = `${PENDING_PREFIX}${++pendingCounter}`
setLocalSessionIDs((prev) => [...prev, id])
@@ -158,9 +174,12 @@ const AgentManagerContent: Component = () => {
return id
}
// Persist local session IDs to webview state for recovery (exclude pending tabs)
// Persist local session IDs and sidebar width to webview state for recovery (exclude pending tabs)
createEffect(() => {
vscode.setState({ localSessionIDs: localSessionIDs().filter((id) => !isPending(id)) })
vscode.setState({
localSessionIDs: localSessionIDs().filter((id) => !isPending(id)),
sidebarWidth: sidebarWidth(),
})
})
// Save the currently active tab for the current sidebar context before switching away
@@ -221,16 +240,17 @@ const AgentManagerContent: Component = () => {
return result
})
// Sessions for the currently selected worktree (tab bar), sorted by creation date
// Sessions for the currently selected worktree (tab bar), respecting custom order if set
const activeWorktreeSessions = createMemo((): SessionInfo[] => {
const sel = selection()
if (!sel || sel === LOCAL) return []
const managed = managedSessions().filter((ms) => ms.worktreeId === sel)
const ids = new Set(managed.map((ms) => ms.id))
return session
const sessions = session
.sessions()
.filter((s) => ids.has(s.id))
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
return applyTabOrder(sessions, worktreeTabOrder()[sel])
})
// Active tab sessions: local sessions when on "local", worktree sessions otherwise
@@ -256,12 +276,12 @@ const AgentManagerContent: Component = () => {
const visibleTabId = createMemo(() => session.currentSessionID() ?? activePendingId())
const tabScroll = useTabScroll(activeTabs, visibleTabId)
// Display name for worktree
// Display name for worktree — uses first tab in custom order when available
const worktreeLabel = (wt: WorktreeState): string => {
const managed = managedSessions().filter((ms) => ms.worktreeId === wt.id)
const ids = new Set(managed.map((ms) => ms.id))
const first = session.sessions().find((s) => ids.has(s.id))
return first?.title || wt.branch
const sessions = session.sessions().filter((s) => ids.has(s.id))
return firstOrderedTitle(sessions, worktreeTabOrder()[wt.id], wt.branch)
}
const scrollIntoView = (el: HTMLElement) => {
@@ -439,11 +459,21 @@ const AgentManagerContent: Component = () => {
const state = msg as AgentManagerStateMessage
setWorktrees(state.worktrees)
setManagedSessions(state.sessions)
if (state.tabOrder) setWorktreeTabOrder(state.tabOrder)
const current = session.currentSessionID()
if (current) {
const ms = state.sessions.find((s) => s.id === current)
if (ms?.worktreeId) setSelection(ms.worktreeId)
}
// Recover local tab order from persisted state
const localOrder = state.tabOrder?.[LOCAL]
if (localOrder && localSessionIDs().length > 0) {
const reordered = applyTabOrder(
localSessionIDs().map((id) => ({ id })),
localOrder,
).map((item) => item.id)
setLocalSessionIDs(reordered)
}
// Clear deleting state for worktrees that have been removed
const ids = new Set(state.worktrees.map((wt) => wt.id))
setDeletingWorktrees((prev) => {
@@ -471,6 +501,10 @@ const AgentManagerContent: Component = () => {
}
})
const handleConfigureSetupScript = () => {
vscode.postMessage({ type: "agentManager.configureSetupScript" })
}
const handleCreateWorktree = () => {
vscode.postMessage({ type: "agentManager.createWorktree" })
}
@@ -574,6 +608,53 @@ const AgentManagerContent: Component = () => {
}
}
// Drag-and-drop handlers for tab reordering
const tabIds = createMemo(() => activeTabs().map((s) => s.id))
const handleDragStart = (event: DragEvent) => {
const id = event.draggable?.id
if (typeof id === "string") setDraggingTab(id)
}
const handleDragOver = (event: DragEvent) => {
const from = event.draggable?.id
const to = event.droppable?.id
if (typeof from !== "string" || typeof to !== "string") return
const sel = selection()
if (sel === LOCAL) {
setLocalSessionIDs((prev) => reorderTabs(prev, from, to) ?? prev)
} else if (sel) {
setWorktreeTabOrder((prev) => {
const ids = applyTabOrder(
tabIds().map((id) => ({ id })),
prev[sel],
).map((item) => item.id)
const reordered = reorderTabs(ids, from, to)
if (!reordered) return prev
return { ...prev, [sel]: reordered }
})
}
}
const handleDragEnd = () => {
setDraggingTab(undefined)
// Persist the new tab order to the extension
const sel = selection()
if (sel === LOCAL) {
const order = localSessionIDs().filter((id) => !isPending(id))
if (order.length > 0) vscode.postMessage({ type: "agentManager.setTabOrder", key: LOCAL, order })
} else if (sel) {
const order = worktreeTabOrder()[sel]
if (order) vscode.postMessage({ type: "agentManager.setTabOrder", key: sel, order })
}
}
const draggedTab = createMemo(() => {
const id = draggingTab()
if (!id) return undefined
return activeTabs().find((s) => s.id === id)
})
// Close the currently active tab via keyboard shortcut.
// If no tabs remain, fall through to close the selected worktree.
const closeActiveTab = () => {
@@ -625,7 +706,14 @@ const AgentManagerContent: Component = () => {
return (
<div class="am-layout">
<div class="am-sidebar">
<div class="am-sidebar" style={{ width: `${sidebarWidth()}px` }}>
<ResizeHandle
direction="horizontal"
size={sidebarWidth()}
min={MIN_SIDEBAR_WIDTH}
max={9999}
onResize={(width) => setSidebarWidth(Math.min(width, window.innerWidth * MAX_SIDEBAR_WIDTH_RATIO))}
/>
{/* Local workspace item */}
<button
class={`am-local-item ${selection() === LOCAL ? "am-local-item-active" : ""}`}
@@ -649,7 +737,31 @@ const AgentManagerContent: Component = () => {
<div class="am-section">
<div class="am-section-header">
<span class="am-section-label">WORKTREES</span>
<IconButton icon="plus" size="small" variant="ghost" label="New Worktree" onClick={handleCreateWorktree} />
<div class="am-section-actions">
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="settings-gear"
size="small"
variant="ghost"
label="Worktree settings"
/>
<DropdownMenu.Portal>
<DropdownMenu.Content>
<DropdownMenu.Item onSelect={handleConfigureSetupScript}>
<DropdownMenu.ItemLabel>Worktree Setup Script</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<IconButton
icon="plus"
size="small"
variant="ghost"
label="New Worktree"
onClick={handleCreateWorktree}
/>
</div>
</div>
<div class="am-worktree-list">
<For each={worktrees()}>
@@ -722,73 +834,82 @@ const AgentManagerContent: Component = () => {
<div class="am-detail">
{/* Tab bar — visible when a section is selected and has tabs or a pending new session */}
<Show when={selection() !== null && !contextEmpty()}>
<div class="am-tab-bar">
<div class="am-tab-scroll-area">
<div class={`am-tab-fade am-tab-fade-left ${tabScroll.showLeft() ? "am-tab-fade-visible" : ""}`} />
<div class="am-tab-list" ref={tabScroll.setRef}>
<For each={activeTabs()}>
{(s) => {
const pending = isPending(s.id)
const active = () =>
pending
? s.id === activePendingId() && !session.currentSessionID()
: s.id === session.currentSessionID()
return (
<Tooltip value={s.title || "Untitled"} placement="bottom">
<div
class={`am-tab ${active() ? "am-tab-active" : ""}`}
data-tab-id={s.id}
onClick={() => {
if (pending) {
setActivePendingId(s.id)
session.clearCurrentSession()
} else {
setActivePendingId(undefined)
session.selectSession(s.id)
}
}}
onMouseDown={(e: MouseEvent) => handleTabMouseDown(s.id, e)}
>
<span class="am-tab-label">{s.title || "Untitled"}</span>
<IconButton
icon="close-small"
size="small"
variant="ghost"
label="Close tab"
class="am-tab-close"
onClick={(e: MouseEvent) => handleCloseTab(s.id, e)}
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<div class="am-tab-bar">
<div class="am-tab-scroll-area">
<div class={`am-tab-fade am-tab-fade-left ${tabScroll.showLeft() ? "am-tab-fade-visible" : ""}`} />
<div class="am-tab-list" ref={tabScroll.setRef}>
<SortableProvider ids={tabIds()}>
<For each={activeTabs()}>
{(s) => {
const pending = isPending(s.id)
const active = () =>
pending
? s.id === activePendingId() && !session.currentSessionID()
: s.id === session.currentSessionID()
return (
<SortableTab
tab={s}
active={active()}
onSelect={() => {
if (pending) {
setActivePendingId(s.id)
session.clearCurrentSession()
} else {
setActivePendingId(undefined)
session.selectSession(s.id)
}
}}
onMiddleClick={(e: MouseEvent) => handleTabMouseDown(s.id, e)}
onClose={(e: MouseEvent) => handleCloseTab(s.id, e)}
/>
</div>
</Tooltip>
)
}}
</For>
)
}}
</For>
</SortableProvider>
</div>
<div class={`am-tab-fade am-tab-fade-right ${tabScroll.showRight() ? "am-tab-fade-visible" : ""}`} />
</div>
<IconButton
icon="plus"
size="small"
variant="ghost"
label={`New session (${modKey}T)`}
class="am-tab-add"
onClick={handleAddSession}
/>
<div class="am-tab-terminal">
<Tooltip value="Open Terminal" placement="bottom">
<IconButton
icon="console"
size="small"
variant="ghost"
label="Open Terminal"
onClick={() => {
const id = session.currentSessionID()
if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id })
}}
/>
</Tooltip>
</div>
<div class={`am-tab-fade am-tab-fade-right ${tabScroll.showRight() ? "am-tab-fade-visible" : ""}`} />
</div>
<IconButton
icon="plus"
size="small"
variant="ghost"
label={`New session (${modKey}T)`}
class="am-tab-add"
onClick={handleAddSession}
/>
<div class="am-tab-terminal">
<Tooltip value="Open Terminal" placement="bottom">
<IconButton
icon="console"
size="small"
variant="ghost"
label="Open Terminal"
onClick={() => {
const id = session.currentSessionID()
if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id })
}}
/>
</Tooltip>
</div>
</div>
<DragOverlay>
<Show when={draggedTab()}>
{(tab) => (
<div class="am-tab am-tab-overlay">
<span class="am-tab-label">{tab().title || "Untitled"}</span>
</div>
)}
</Show>
</DragOverlay>
</DragDropProvider>
</Show>
{/* Empty worktree state */}
@@ -7,7 +7,7 @@
}
.am-sidebar {
width: 260px;
position: relative;
min-width: 200px;
border-right: 1px solid var(--border-weak-base);
display: flex;
@@ -18,6 +18,10 @@
gap: 4px;
}
.am-sidebar > [data-component="resize-handle"]::after {
background: var(--surface-interactive-base);
}
/* Fixed local workspace item */
.am-local-item {
@@ -105,6 +109,12 @@
color: var(--text-weak);
}
.am-section-actions {
display: flex;
align-items: center;
gap: 2px;
}
/* Worktree list */
.am-worktree-list {
@@ -323,9 +333,10 @@
position: relative;
display: flex;
min-width: 0;
flex: 1;
flex-shrink: 1;
align-items: stretch;
height: 100%;
overflow: hidden;
}
/* Fade indicators for overflow */
@@ -406,6 +417,29 @@
opacity: 1;
}
/* Drag-and-drop sortable tab wrapper */
.am-tab-sortable {
display: flex;
height: 100%;
touch-action: none;
}
.am-tab-dragging {
opacity: 0.25;
}
/* Drag overlay tab (follows the cursor) */
.am-tab-overlay {
background: var(--surface-base);
border: 1px solid var(--border-weak-base);
border-radius: var(--radius-sm);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
pointer-events: none;
color: var(--text-base);
}
.am-tab-add {
flex-shrink: 0;
align-self: center;
@@ -0,0 +1,69 @@
/**
* Drag-and-drop sortable tab components for the agent manager tab bar.
*/
import { Component, onCleanup } from "solid-js"
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd"
import type { Transformer } from "@thisbeyond/solid-dnd"
import { createRoot } from "solid-js"
import type { SessionInfo } from "../src/types/messages"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
/** Lock drag movement to the X axis (horizontal-only tab dragging). */
export const ConstrainDragYAxis: Component = () => {
const context = useDragDropContext()
if (!context) return null
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
const transformer: Transformer = { id: "constrain-y-axis", order: 100, callback: (t) => ({ ...t, y: 0 }) }
const dispose = createRoot((dispose) => {
onDragStart(({ draggable }) => {
if (draggable) addTransformer("draggables", draggable.id as string, transformer)
})
onDragEnd(({ draggable }) => {
if (draggable) removeTransformer("draggables", draggable.id as string, transformer.id)
})
return dispose
})
onCleanup(dispose)
return null
}
/** Individual sortable tab wrapper using the `use:sortable` directive. */
export const SortableTab: Component<{
tab: SessionInfo
active: boolean
onSelect: () => void
onMiddleClick: (e: MouseEvent) => void
onClose: (e: MouseEvent) => void
}> = (props) => {
const sortable = createSortable(props.tab.id)
// Prevent tree-shaking of the directive reference used by `use:sortable`
void sortable
return (
// @ts-ignore - use:sortable is a SolidJS directive compiled by esbuild-plugin-solid
<div
use:sortable
class={`am-tab-sortable ${sortable.isActiveDraggable ? "am-tab-dragging" : ""}`}
data-tab-id={props.tab.id}
>
<Tooltip value={props.tab.title || "Untitled"} placement="bottom">
<div
class={`am-tab ${props.active ? "am-tab-active" : ""}`}
onClick={props.onSelect}
onMouseDown={props.onMiddleClick}
>
<span class="am-tab-label">{props.tab.title || "Untitled"}</span>
<IconButton
icon="close-small"
size="small"
variant="ghost"
label="Close tab"
class="am-tab-close"
onClick={props.onClose}
/>
</div>
</Tooltip>
</div>
)
}
@@ -0,0 +1,62 @@
/**
* Pure tab-ordering logic for the agent manager.
*/
/**
* Reorder an array by moving the item at `from` to the position of `to`.
* Returns a new array, or undefined if either ID is not found or they are equal.
*/
export function reorderTabs(tabs: readonly string[], from: string, to: string): string[] | undefined {
if (from === to) return undefined
const fi = tabs.indexOf(from)
const ti = tabs.indexOf(to)
if (fi === -1 || ti === -1) return undefined
const result = [...tabs]
result.splice(fi, 1)
result.splice(ti, 0, from)
return result
}
/**
* Apply a custom ordering to a list of items.
*
* Items are returned in `order` sequence (skipping IDs not in `items`),
* followed by any items not present in `order`.
* Returns the original array unchanged if `order` is undefined or empty.
*/
export function applyTabOrder<T extends { id: string }>(items: T[], order: string[] | undefined): T[] {
if (!order || order.length === 0) return items
const lookup = new Map(items.map((item) => [item.id, item]))
const ordered: T[] = []
for (const id of order) {
const item = lookup.get(id)
if (item) {
ordered.push(item)
lookup.delete(id)
}
}
for (const item of lookup.values()) ordered.push(item)
return ordered
}
/**
* Find the title of the first item according to a custom order.
*
* Falls back to the first titled item in `items` if the order
* doesn't produce a match, then to `fallback`.
*/
export function firstOrderedTitle(
items: { id: string; title?: string }[],
order: string[] | undefined,
fallback: string,
): string {
if (order) {
const lookup = new Map(items.map((item) => [item.id, item]))
for (const id of order) {
const item = lookup.get(id)
if (item?.title) return item.title
}
}
const first = items.find((item) => item.title)
return first?.title || fallback
}
@@ -563,6 +563,7 @@ export interface AgentManagerStateMessage {
type: "agentManager.state"
worktrees: WorktreeState[]
sessions: ManagedSessionState[]
tabOrder?: Record<string, string[]>
}
export type ExtensionMessage =
@@ -833,12 +834,24 @@ export interface RequestRepoInfoMessage {
type: "agentManager.requestRepoInfo"
}
// Configure worktree setup script
export interface ConfigureSetupScriptRequest {
type: "agentManager.configureSetupScript"
}
// Show terminal for a session
export interface ShowTerminalRequest {
type: "agentManager.showTerminal"
sessionId: string
}
// Persist tab order for a context (worktree ID or "local")
export interface SetTabOrderRequest {
type: "agentManager.setTabOrder"
key: string
order: string[]
}
export type WebviewMessage =
| SendMessageRequest
| AbortRequest
@@ -882,7 +895,9 @@ export type WebviewMessage =
| CloseSessionRequest
| TelemetryRequest
| RequestRepoInfoMessage
| ConfigureSetupScriptRequest
| ShowTerminalRequest
| SetTabOrderRequest
// ============================================
// VS Code API type
@@ -1,6 +1,7 @@
import { describe, expect, test, beforeEach } from "bun:test"
import { describe, expect, test, beforeEach, mock } from "bun:test"
// Mock Bun.spawnSync before importing the module under test
// Mock Bun.spawnSync via mock.module so it integrates properly with bun:test
// and doesn't conflict with other test files that mock "../git-context".
const spawnSyncResults: Record<string, string> = {}
function setGitOutput(args: string, output: string) {
@@ -13,17 +14,171 @@ function clearGitOutputs() {
}
}
// Replace global Bun.spawnSync — the git() helper in git-context.ts calls
// result.stdout.toString().trim(), so we return a Buffer and let git() trim.
Bun.spawnSync = ((cmd: string[], _opts?: any) => {
const args = cmd.slice(1).join(" ")
const output = spawnSyncResults[args] ?? ""
return {
stdout: Buffer.from(output),
stderr: Buffer.from(""),
exitCode: 0,
// Override the git-context module with a version that uses our mock spawnSync.
// This avoids conflicts with generate.test.ts which also mocks this module.
mock.module("../git-context", () => {
function git(args: string[], cwd: string): string {
const key = args.join(" ")
return spawnSyncResults[key] ?? ""
}
}) as typeof Bun.spawnSync
const LOCK_FILES = new Set([
"package-lock.json",
"npm-shrinkwrap.json",
"yarn.lock",
"pnpm-lock.yaml",
"shrinkwrap.yaml",
"bun.lockb",
"bun.lock",
".pnp.js",
".pnp.cjs",
"jspm.lock",
"Pipfile.lock",
"poetry.lock",
"pdm.lock",
".pdm-lock.toml",
"uv.lock",
"conda-lock.yml",
"pylock.toml",
"Gemfile.lock",
"composer.lock",
"gradle.lockfile",
"lockfile.json",
"dependency-lock.json",
"dependency-reduced-pom.xml",
"coursier.lock",
"build.sbt.lock",
"packages.lock.json",
"paket.lock",
"project.assets.json",
"Cargo.lock",
"go.sum",
"Gopkg.lock",
"glide.lock",
"build.zig.zon.lock",
"dune.lock",
"opam.lock",
"Package.resolved",
"Podfile.lock",
"Cartfile.resolved",
"pubspec.lock",
"mix.lock",
"rebar.lock",
"stack.yaml.lock",
"cabal.project.freeze",
"exact-dependencies.json",
"shard.lock",
"Manifest.toml",
"JuliaManifest.toml",
"renv.lock",
"packrat.lock",
"nimble.lock",
"dub.selections.json",
"rocks.lock",
"carton.lock",
"cpanfile.snapshot",
"conan.lock",
"vcpkg-lock.json",
".terraform.lock.hcl",
"Berksfile.lock",
"Puppetfile.lock",
"MODULE.bazel.lock",
"flake.lock",
"deno.lock",
"devcontainer.lock.json",
])
const MAX_DIFF_LENGTH = 4000
function isLockFile(filepath: string): boolean {
const name = filepath.split("/").pop() ?? filepath
return LOCK_FILES.has(name)
}
function parseNameStatus(output: string): Array<{ status: string; path: string }> {
if (!output) return []
return output.split("\n").map((line) => {
const [status, ...rest] = line.split("\t")
const path = status!.startsWith("R") ? (rest[1] ?? rest[0]) : rest.join("\t")
return { status: status!, path }
})
}
function parsePorcelain(output: string): Array<{ status: string; path: string }> {
if (!output) return []
return output
.split("\n")
.filter((line) => line.length > 0)
.map((line) => {
const xy = line.slice(0, 2)
const filepath = line.slice(3)
return { status: xy.trim(), path: filepath }
})
}
type FileStatus = "added" | "modified" | "deleted" | "renamed"
function mapStatus(code: string): FileStatus {
if (code.startsWith("R")) return "renamed"
if (code === "A" || code === "??" || code === "?") return "added"
if (code === "D") return "deleted"
if (code === "M") return "modified"
return "modified"
}
function isUntracked(code: string): boolean {
return code === "??" || code === "?"
}
async function getGitContext(repoPath: string, selectedFiles?: string[]) {
const branch = git(["branch", "--show-current"], repoPath) || "HEAD"
const log = git(["log", "--oneline", "-5"], repoPath)
const recentCommits = log ? log.split("\n") : []
const staged = parseNameStatus(git(["diff", "--name-status", "--cached"], repoPath))
const useStaged = staged.length > 0
const raw = useStaged ? staged : parsePorcelain(git(["status", "--porcelain"], repoPath))
const selected = selectedFiles ? new Set(selectedFiles) : undefined
const files: Array<{ status: FileStatus; path: string; diff: string }> = []
for (const entry of raw) {
if (isLockFile(entry.path)) continue
if (selected && !selected.has(entry.path)) continue
const status = mapStatus(entry.status)
const untracked = isUntracked(entry.status)
let diff: string
if (untracked) {
diff = `New untracked file: ${entry.path}`
} else if (status === "deleted") {
diff = useStaged
? git(["diff", "--cached", "--", entry.path], repoPath)
: git(["diff", "--", entry.path], repoPath)
} else {
const raw = useStaged
? git(["diff", "--cached", "--", entry.path], repoPath)
: git(["diff", "--", entry.path], repoPath)
if (raw.includes("Binary files") || raw.includes("GIT binary patch")) {
diff = `Binary file ${entry.path} has been modified`
} else {
diff = raw
}
}
if (diff.length > MAX_DIFF_LENGTH) {
diff = diff.slice(0, MAX_DIFF_LENGTH) + "\n... [truncated]"
}
files.push({ status, path: entry.path, diff })
}
return { branch, recentCommits, files }
}
return { getGitContext }
})
import { getGitContext } from "../git-context"