diff --git a/bun.lock b/bun.lock index 115c02a19e..247c12614c 100644 --- a/bun.lock +++ b/bun.lock @@ -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", diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index f2fee4118a..265c7213b5 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -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", diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index c91f801fa1..31f3b532a7 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -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 { + 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 { + 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 // --------------------------------------------------------------------------- diff --git a/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts b/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts new file mode 100644 index 0000000000..3703c4bb6c --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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}`) + } +} diff --git a/packages/kilo-vscode/src/agent-manager/SetupScriptService.ts b/packages/kilo-vscode/src/agent-manager/SetupScriptService.ts new file mode 100644 index 0000000000..26f08eec22 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/SetupScriptService.ts @@ -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 { + 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 { + 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 { + 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}`) + } +} diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts index 8f3c203f26..28df15009d 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -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 { diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index b36b79d7d7..cb722f2fa1 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -29,6 +29,7 @@ export interface ManagedSession { interface StateFile { worktrees: Record> sessions: Record> + tabOrder?: Record } const STATE_FILE = "agent-manager.json" @@ -44,6 +45,7 @@ export class WorktreeStateManager { private readonly file: string private worktrees = new Map() private sessions = new Map() + private tabOrder: Record = {} private readonly log: (msg: string) => void private saving: Promise | 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 { + 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 }) diff --git a/packages/kilo-vscode/src/agent-manager/__tests__/buildSetupCommand.spec.ts b/packages/kilo-vscode/src/agent-manager/__tests__/buildSetupCommand.spec.ts new file mode 100644 index 0000000000..ed69b1c37b --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/__tests__/buildSetupCommand.spec.ts @@ -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"') + }) +}) diff --git a/packages/kilo-vscode/src/agent-manager/setup-script-command.ts b/packages/kilo-vscode/src/agent-manager/setup-script-command.ts new file mode 100644 index 0000000000..3d3dd14718 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/setup-script-command.ts @@ -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}"` +} diff --git a/packages/kilo-vscode/src/agent-manager/setup-script-template.ts b/packages/kilo-vscode/src/agent-manager/setup-script-template.ts new file mode 100644 index 0000000000..8c59a63b6f --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/setup-script-template.ts @@ -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!" +` diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index 4bccd44da8..7a6e2887f4 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -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)] diff --git a/packages/kilo-vscode/tests/unit/tab-order.test.ts b/packages/kilo-vscode/tests/unit/tab-order.test.ts new file mode 100644 index 0000000000..999bb9b267 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/tab-order.test.ts @@ -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() + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts b/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts index decdcc9e8c..8b4f69a170 100644 --- a/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts @@ -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") diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index b2017be6af..9ec7b3c615 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -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() const [deletingWorktrees, setDeletingWorktrees] = createSignal>(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(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() + // Tab ordering: context key → ordered session ID array (recovered from extension state) + const [worktreeTabOrder, setWorktreeTabOrder] = createSignal>({}) + 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 (
-
+
+ setSidebarWidth(Math.min(width, window.innerWidth * MAX_SIDEBAR_WIDTH_RATIO))} + /> {/* Local workspace item */}