From 13a9673d08cfc69eebb89898861a1ee80278f226 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 27 Aug 2026 14:44:04 +0200 Subject: [PATCH] feat(vscode): add searchable worktree references --- .changeset/agent-manager-worktree-mentions.md | 6 + .../tests/fixtures/session-tab-switcher.tsx | 9 + .../tests/fixtures/worktree-references.ts | 67 ++++ .../agent-manager-worktree-reference.test.ts | 300 ++++++++++++++++++ .../tests/unit/project-sessions-live.test.ts | 26 ++ .../tests/unit/worktree-recency.test.ts | 74 +++++ .../agent-manager/AgentManagerApp.tsx | 3 + .../agent-manager/project/sessions-live.ts | 5 +- .../agent-manager/worktree-recency.ts | 23 ++ .../agent-manager/worktree-references.ts | 75 +++++ .../src/components/chat/ChatView.tsx | 3 + .../src/components/chat/PromptInput.tsx | 39 ++- .../components/chat/WorktreeMentionPicker.tsx | 59 ++++ .../src/hooks/file-mention-utils.ts | 57 +++- .../webview-ui/src/hooks/useFileMention.ts | 77 ++++- .../kilo-vscode/webview-ui/src/i18n/ar.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/br.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/bs.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/da.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/de.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/en.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/es.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/fa.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/fr.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/it.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/ja.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/ko.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/nl.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/no.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/pl.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/ru.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/th.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/tr.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/uk.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/zh.ts | 2 + .../kilo-vscode/webview-ui/src/i18n/zht.ts | 2 + .../src/styles/prompt-dropdowns.css | 4 + packages/ui/src/components/list.tsx | 2 +- 38 files changed, 851 insertions(+), 20 deletions(-) create mode 100644 .changeset/agent-manager-worktree-mentions.md create mode 100644 packages/kilo-vscode/tests/fixtures/worktree-references.ts create mode 100644 packages/kilo-vscode/tests/unit/agent-manager-worktree-reference.test.ts create mode 100644 packages/kilo-vscode/tests/unit/worktree-recency.test.ts create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/worktree-recency.ts create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/worktree-references.ts create mode 100644 packages/kilo-vscode/webview-ui/src/components/chat/WorktreeMentionPicker.tsx diff --git a/.changeset/agent-manager-worktree-mentions.md b/.changeset/agent-manager-worktree-mentions.md new file mode 100644 index 0000000000..2c73e525fb --- /dev/null +++ b/.changeset/agent-manager-worktree-mentions.md @@ -0,0 +1,6 @@ +--- +"kilo-code": minor +"@opencode-ai/ui": patch +--- + +Reference other Agent Manager worktrees with a searchable @ picker that prioritizes recently opened worktrees. Share paths, branches, and session IDs without attaching diffs or chat history. Keep picker results in sync when clearing search. diff --git a/packages/kilo-vscode/tests/fixtures/session-tab-switcher.tsx b/packages/kilo-vscode/tests/fixtures/session-tab-switcher.tsx index b1260dc8a2..0817ffc545 100644 --- a/packages/kilo-vscode/tests/fixtures/session-tab-switcher.tsx +++ b/packages/kilo-vscode/tests/fixtures/session-tab-switcher.tsx @@ -107,6 +107,15 @@ async function closeFiltered() { input.dispatchEvent(new InputEvent("input", { bubbles: true, data: "be", inputType: "insertText" })) await settle() + query('[aria-label="Clear filter"]', "Clear button did not render").click() + await settle() + assert.equal(input.value, "", "Clearing did not reset the search input") + assert.equal(root.querySelectorAll('[data-slot="list-item"]').length, 3, "Clearing did not restore all tabs") + assert.equal(document.activeElement, input, "Clearing did not restore search focus") + input.value = "be" + input.dispatchEvent(new InputEvent("input", { bubbles: true, data: "be", inputType: "insertText" })) + await settle() + const close = query( '[aria-label="Close tab: Beta"]', "Filtered result close button did not render", diff --git a/packages/kilo-vscode/tests/fixtures/worktree-references.ts b/packages/kilo-vscode/tests/fixtures/worktree-references.ts new file mode 100644 index 0000000000..8a1e80fdb5 --- /dev/null +++ b/packages/kilo-vscode/tests/fixtures/worktree-references.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict" +import { createRoot, createSignal } from "solid-js" +import { createProjectStore } from "../../webview-ui/agent-manager/project/store" +import { createWorktreeReferences } from "../../webview-ui/agent-manager/worktree-references" + +await createRoot(async (dispose) => { + const first = createProjectStore("first") + const second = createProjectStore("second") + const time = "2026-08-27T00:00:00.000Z" + for (const project of [first, second]) { + project.setWorktrees( + ["one", "two"].map((id) => ({ + id, + path: `/${project.id}/${id}`, + branch: id, + parentBranch: "main", + createdAt: time, + })), + ) + project.setManagedSessions([{ id: "same", worktreeId: "one", createdAt: time }]) + } + const [project, setProject] = createSignal(first) + const [selection, select] = createSignal(null) + const storage = { value: {} as Record } + const sessions = () => [{ id: "same", title: project().id, updatedAt: time }] + const refs = createWorktreeReferences( + { + getState: () => storage.value as T, + setState: (value) => { + storage.value = value as Record + }, + }, + project, + sessions, + selection, + ) + try { + select("one") + await Promise.resolve() + assert.deepEqual(storage.value.worktreeMentionHistory, ["/first/one"]) + first.setStaleWorktreeIds(new Set(["two"])) + select("two") + await Promise.resolve() + assert.deepEqual(storage.value.worktreeMentionHistory, ["/first/one"]) + assert.equal(refs().find((item) => item.id === "two")?.disabled, true) + first.setStaleWorktreeIds(new Set()) + await Promise.resolve() + assert.deepEqual(storage.value.worktreeMentionHistory, ["/first/two", "/first/one"]) + first.setBusy(new Map([["one", { reason: "creating" }]])) + select("one") + await Promise.resolve() + assert.deepEqual(storage.value.worktreeMentionHistory, ["/first/two", "/first/one"]) + first.setBusy(new Map()) + await Promise.resolve() + assert.deepEqual( + refs().map((item) => item.path), + ["/first/one", "/first/two"], + ) + setProject(second) + await Promise.resolve() + assert.equal(refs()[0]?.sessions[0]?.title, "second") + assert.equal(refs()[0]?.path, "/second/one") + assert.equal((storage.value.worktreeMentionHistory as string[])[0], "/second/one") + } finally { + dispose() + } +}) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-worktree-reference.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-worktree-reference.test.ts new file mode 100644 index 0000000000..fd9240e8ba --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-worktree-reference.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it } from "bun:test" +import { createRoot } from "solid-js" +import { worktreeReferences } from "../../webview-ui/agent-manager/worktree-references" +import { createProjectStore } from "../../webview-ui/agent-manager/project/store" +import { + buildMentionResults, + buildWorktreeAttachments, + filterMentionResults, + type WorktreeReference, +} from "../../webview-ui/src/hooks/file-mention-utils" +import { useFileMention } from "../../webview-ui/src/hooks/useFileMention" +import type { ExtensionMessage, WebviewMessage, WorktreeState } from "../../webview-ui/src/types/messages" + +function tree(id: string, values: Partial = {}): WorktreeState { + return { + id, + branch: `feature/${id}`, + path: `/repo/.kilo/worktrees/${id}`, + parentBranch: "main", + createdAt: "2026-08-01T00:00:00.000Z", + ...values, + } +} + +function reference(values: Partial = {}): WorktreeReference { + return { + id: "wt-reference", + name: "Authentication", + branch: "feature/auth", + path: "/repo/.kilo/worktrees/reference", + base: "main", + sessions: [{ id: "ses_auth", title: "Fix login" }], + disabled: false, + ...values, + } +} + +function content(url: string) { + return decodeURIComponent(url.slice(url.indexOf(",") + 1)) +} + +function harness(worktrees: () => WorktreeReference[]) { + const posted: WebviewMessage[] = [] + const handlers = new Set<(message: ExtensionMessage) => void>() + return createRoot((dispose) => ({ + dispose, + posted, + receive: (message: ExtensionMessage) => handlers.forEach((handler) => handler(message)), + mention: useFileMention( + { + postMessage: (message) => posted.push(message), + onMessage: (handler) => { + handlers.add(handler) + return () => handlers.delete(handler) + }, + }, + undefined, + () => true, + worktrees, + ), + })) +} + +function reply(scope: ReturnType, paths: string[] = []) { + const request = scope.posted.findLast((message) => message.type === "requestFileSearch") + if (request?.type !== "requestFileSearch") throw new Error("Missing file search request") + scope.receive({ type: "fileSearchResult", requestId: request.requestId, dir: "/repo", paths }) +} + +describe("Agent Manager worktree references", () => { + it("uses sidebar names and includes all sessions without selecting a transcript", () => { + const state = createProjectStore("project") + state.setWorktrees([tree("named", { label: "Custom name" }), tree("ordered"), tree("empty")]) + state.setManagedSessions([ + { id: "ses_first", worktreeId: "ordered", createdAt: "" }, + { id: "ses_second", worktreeId: "ordered", createdAt: "" }, + { id: "ses_local", worktreeId: null, createdAt: "" }, + ]) + state.setTabOrder({ ordered: ["ses_second", "ses_first"] }) + const refs = worktreeReferences( + state, + [ + { id: "ses_first", title: "First task" }, + { id: "ses_second", title: "Second task" }, + { id: "ses_local", title: "Local task" }, + ], + "local", + ) + expect(refs.map((ref) => ref.name)).toEqual(["Custom name", "Second task", "empty"]) + expect(refs[1].sessions).toEqual([ + { id: "ses_first", title: "First task" }, + { id: "ses_second", title: "Second task" }, + ]) + expect(refs[2].sessions).toEqual([]) + }) + + it("omits current, stale, and busy worktrees from suggestions but retains their reference data", () => { + const state = createProjectStore("project") + state.setWorktrees([tree("current"), tree("stale"), tree("deleting"), tree("other")]) + state.setStaleWorktreeIds(new Set(["stale"])) + state.setBusy(new Map([["deleting", { reason: "deleting" }]])) + const refs = worktreeReferences(state, [], "current") + expect(refs).toHaveLength(4) + const scope = harness(() => refs) + expect(scope.mention.worktreeCandidates().map((item) => item.path)).toEqual(["/repo/.kilo/worktrees/other"]) + scope.dispose() + }) + + it("keeps project inventories separate even when worktree IDs match", () => { + const first = createProjectStore("first") + const second = createProjectStore("second") + first.setWorktrees([tree("same", { path: "/first/reference" })]) + second.setWorktrees([tree("same", { path: "/second/reference" })]) + expect(worktreeReferences(first, [], "local").map((ref) => ref.path)).toEqual(["/first/reference"]) + expect(worktreeReferences(second, [], "local").map((ref) => ref.path)).toEqual(["/second/reference"]) + }) + + it("keeps multi-version siblings distinct and supports Windows paths", () => { + const state = createProjectStore("project") + state.setWorktrees([ + tree("one", { groupId: "group", label: "Compare", path: "C:\\repo\\one\\" }), + tree("two", { groupId: "group", label: "Compare", path: "C:\\repo\\two" }), + tree("empty", { path: "C:\\repo\\empty\\" }), + ]) + const refs = worktreeReferences(state, [], "one") + expect(refs.map((ref) => ref.name)).toEqual(["Compare", "Compare", "empty"]) + expect(refs.map((ref) => ref.disabled)).toEqual([true, false, false]) + expect(refs[0].path).not.toBe(refs[1].path) + }) + + it("shows one Worktrees entry instead of individual worktrees in the main menu", () => { + for (const query of ["", "w", "worktree", "WORKTREES", "branch"]) { + const results = buildMentionResults(query, [], true, true) + expect(results.filter((item) => item.type === "worktrees")).toEqual([{ type: "worktrees", value: "worktrees" }]) + expect(filterMentionResults(query, results).filter((item) => item.type === "worktrees")).toHaveLength(1) + } + expect(buildMentionResults("unrelated", [], true, true).some((item) => item.type === "worktrees")).toBe(false) + expect(buildMentionResults("", []).some((item) => item.type === "worktrees")).toBe(false) + }) + + it("ranks recent visits ahead of activity, then uses newest activity and sidebar order", () => { + const state = createProjectStore("project") + state.setWorktrees([tree("old"), tree("active"), tree("new", { createdAt: "2026-08-25T00:00:00.000Z" })]) + state.setManagedSessions([{ id: "ses_active", worktreeId: "active", createdAt: "" }]) + const sessions = [{ id: "ses_active", title: "Recent task", updatedAt: "2026-08-26T00:00:00.000Z" }] + expect(worktreeReferences(state, sessions, "local").map((ref) => ref.id)).toEqual(["active", "new", "old"]) + expect(worktreeReferences(state, sessions, "local", [tree("old").path]).map((ref) => ref.id)).toEqual([ + "old", + "active", + "new", + ]) + expect( + worktreeReferences(state, sessions, "local", [tree("new").path, tree("old").path]).map((ref) => ref.id), + ).toEqual(["new", "old", "active"]) + state.setWorktrees([tree("first"), tree("second")]) + state.setWorktreeOrder(["second", "first"]) + expect(worktreeReferences(state, [], "local").map((ref) => ref.id)).toEqual(["second", "first"]) + }) + + it("does not let a visit in another project boost a colliding worktree ID", () => { + const state = createProjectStore("first") + state.setWorktrees([tree("same", { path: "/first/same" }), tree("other", { path: "/first/other" })]) + const refs = worktreeReferences(state, [], "local", ["/second/same", "/first/other"]) + expect(refs.map((ref) => ref.path)).toEqual(["/first/other", "/first/same"]) + }) + + it("attaches only metadata and preserves exact Unicode and spaced paths", () => { + const ref = reference({ name: 'Fix "登录"\nnow', path: "/repo/.kilo/worktrees/登录 100%" }) + const text = `Compare with @${ref.path} and report differences.` + const files = buildWorktreeAttachments(text, [ref]) + expect(files).toHaveLength(1) + expect(files[0]).toMatchObject({ + mime: "text/plain", + source: { + type: "file", + path: ref.path, + text: { value: `@${ref.path}`, start: 13, end: 14 + ref.path.length }, + }, + }) + expect(files[0].url.startsWith("data:text/plain;charset=utf-8,")).toBe(true) + const textfile = content(files[0].url) + expect(JSON.parse(textfile.slice(textfile.indexOf("{")))).toEqual({ + worktreeID: ref.id, + name: ref.name, + directory: ref.path, + branch: ref.branch, + baseBranch: ref.base, + sessions: ref.sessions, + }) + expect(textfile).toContain("metadata only") + expect(textfile).toContain("recall") + expect(files.some((file) => file.url.startsWith("file:") || file.url.startsWith("session:"))).toBe(false) + }) + + it("matches whole references, removes deleted references, and deduplicates repeated mentions", () => { + const ref = reference() + expect(buildWorktreeAttachments(`email@${ref.path}`, [ref])).toEqual([]) + expect(buildWorktreeAttachments(`@${ref.path}-other`, [ref])).toEqual([]) + expect(buildWorktreeAttachments(`@${ref.path} @${ref.path}`, [ref])).toHaveLength(1) + expect(buildWorktreeAttachments("Compare the changes", [ref])).toEqual([]) + const longer = reference({ id: "longer", path: `${ref.path} backup` }) + expect(buildWorktreeAttachments(`@${longer.path}`, [ref, longer]).map((file) => file.source?.path)).toEqual([ + longer.path, + ]) + }) + + it("does not auto-read worktree directories inside the local workspace", () => { + const ref = reference({ path: "/repo/.kilo/worktrees/reference branch" }) + const scope = harness(() => [ref]) + scope.mention.onInput("@", 1) + reply(scope, ["src/a.ts"]) + const text = `Compare @${ref.path} with @src/a.ts` + scope.mention.seedFromText(text) + expect(scope.mention.mentionedPaths()).toEqual(new Set([ref.path, "src/a.ts"])) + const files = scope.mention.parseFileAttachments(text) + expect(files).toHaveLength(2) + expect(files.filter((file) => file.url.startsWith("file:"))).toMatchObject([{ source: { path: "src/a.ts" } }]) + expect(files.filter((file) => file.source?.path === ref.path)[0].url.startsWith("data:")).toBe(true) + scope.dispose() + }) + + it("does not attach a truncated path when worktree data arrives after draft restoration", () => { + const ref = reference({ path: "/repo/.kilo/worktrees/reference branch" }) + const state = { refs: [] as WorktreeReference[] } + const scope = harness(() => state.refs) + scope.mention.onInput("@", 1) + reply(scope) + const text = `Compare @${ref.path}` + scope.mention.seedFromText(text) + state.refs = [ref] + const files = scope.mention.parseFileAttachments(text) + expect(files).toHaveLength(1) + expect(files[0].source?.path).toBe(ref.path) + expect(files[0].url.startsWith("data:")).toBe(true) + scope.dispose() + }) + + it("restores references without a prior picker selection and refreshes their metadata", () => { + const state = { refs: [reference()] } + const scope = harness(() => state.refs) + const text = `Compare @${state.refs[0].path}` + scope.mention.seedFromText(text) + expect(scope.mention.parseFileAttachments(text)).toHaveLength(1) + state.refs = [reference({ branch: "renamed", sessions: [{ id: "ses_new" }] })] + const files = scope.mention.parseFileAttachments(text) + expect(content(files[0].url)).toContain('"branch": "renamed"') + expect(content(files[0].url)).toContain("ses_new") + state.refs = [] + expect(content(scope.mention.parseFileAttachments(text)[0].url)).toContain(reference().path) + expect(scope.mention.parseFileAttachments("Removed reference")).toEqual([]) + scope.dispose() + }) + + it("retains reference metadata when a worktree disappears after search results arrive", () => { + const ref = reference() + const state = { refs: [] as WorktreeReference[] } + const scope = harness(() => state.refs) + scope.mention.onInput("@", 1) + state.refs = [ref] + reply(scope) + expect(scope.mention.worktreeCandidates().map((item) => item.path)).toEqual([ref.path]) + state.refs = [] + const text = `Compare @${ref.path}` + scope.mention.seedFromText(text) + const files = scope.mention.parseFileAttachments(text) + expect(files).toHaveLength(1) + expect(files[0].url.startsWith("data:")).toBe(true) + scope.dispose() + }) + + it("opens the local worktree picker without changing the prompt or requesting past chats", () => { + const state = { refs: [reference(), reference({ id: "tests", name: "Testing", path: "/repo/tests" })] } + const scope = harness(() => state.refs) + scope.mention.onInput("@", 1) + const count = scope.posted.length + const input = { value: "@", selectionStart: 1 } as HTMLTextAreaElement + scope.mention.selectMention({ type: "worktrees", value: "worktrees" }, input, () => {}) + expect(scope.mention.worktreePicker()).toBe(true) + expect(scope.mention.sessionPicker()).toBe(false) + expect(scope.mention.worktreeCandidates().map((item) => item.name)).toEqual(["Authentication", "Testing"]) + expect(scope.posted).toHaveLength(count) + expect(input.value).toBe("@") + state.refs = [reference({ name: "Renamed" })] + expect(scope.mention.worktreeCandidates().map((item) => item.name)).toEqual(["Renamed"]) + expect(scope.mention.parseFileAttachments("@worktrees")).toEqual([]) + scope.mention.closeMention() + expect(scope.mention.worktreePicker()).toBe(false) + expect(scope.mention.showMention()).toBe(false) + expect(scope.posted.some((message) => message.type === "requestSessionSearch")).toBe(false) + scope.dispose() + }) + + it("keeps the Worktrees entry available when there are no other worktrees", () => { + const scope = harness(() => [reference({ disabled: true })]) + scope.mention.onInput("@", 1) + expect(scope.mention.mentionResults().filter((item) => item.type === "worktrees")).toHaveLength(1) + expect(scope.mention.worktreeCandidates()).toEqual([]) + scope.dispose() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/project-sessions-live.test.ts b/packages/kilo-vscode/tests/unit/project-sessions-live.test.ts index fc10c70ee2..2303cf49d1 100644 --- a/packages/kilo-vscode/tests/unit/project-sessions-live.test.ts +++ b/packages/kilo-vscode/tests/unit/project-sessions-live.test.ts @@ -31,6 +31,32 @@ describe("project session live state", () => { title: "Restore worktree metadata", worktreeId: "worktree-1", }) + expect(live.current()).toEqual(live().project!) + dispose() + }) + }) + + it("returns only the active project and keeps the legacy source when disabled", () => { + createRoot((dispose) => { + const state = { pid: "second" as string | undefined, enabled: true, store: [] as SessionInfo[] } + const first = { ...session("same"), title: "First project" } + const second = { ...session("same"), title: "Second project" } + const live = createProjectSessionsLive({ + base: () => ({ first: [first], second: [second] }), + pid: () => state.pid, + enabled: () => state.enabled, + store: () => state.store, + managed: () => [], + locals: () => new Set(), + }) + expect(live.current()).toEqual([second]) + state.pid = "first" + expect(live.current()).toEqual([first]) + state.pid = undefined + expect(live.current()).toEqual([]) + state.enabled = false + state.store = [first] + expect(live.current()).toEqual([first]) dispose() }) }) diff --git a/packages/kilo-vscode/tests/unit/worktree-recency.test.ts b/packages/kilo-vscode/tests/unit/worktree-recency.test.ts new file mode 100644 index 0000000000..8f07bd2bed --- /dev/null +++ b/packages/kilo-vscode/tests/unit/worktree-recency.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "bun:test" +import path from "node:path" +import { createWorktreeRecency } from "../../webview-ui/agent-manager/worktree-recency" + +function storage(value: Record = {}) { + const state = { value, writes: 0 } + return { + get: () => state.value, + set: (value: Record) => { + state.value = value + state.writes++ + }, + state, + } +} + +describe("worktree mention recency", () => { + it("tracks real selection changes and project-scoped session metadata", () => { + const file = path.join(import.meta.dir, "../fixtures/worktree-references.ts") + const child = Bun.spawnSync(["bun", "--conditions=browser", file], { stdout: "pipe", stderr: "pipe" }) + expect(child.exitCode, child.stdout.toString() + child.stderr.toString()).toBe(0) + }) + + it("keeps the most recently opened worktree first without duplicate visits", () => { + const data = storage() + const history = createWorktreeRecency(data) + history.visit("/repo/first") + history.visit("/repo/second") + history.visit("/repo/first") + expect(history.recent()).toEqual(["/repo/first", "/repo/second"]) + expect(data.get().worktreeMentionHistory).toEqual(history.recent()) + history.visit("/repo/first") + history.visit("") + expect(data.state.writes).toBe(3) + }) + + it("restores visits across picker and webview recreation", () => { + const data = storage() + const first = createWorktreeRecency(data) + first.visit("/repo/first") + first.visit("/repo/second") + const restored = createWorktreeRecency(data) + expect(restored.recent()).toEqual(["/repo/second", "/repo/first"]) + restored.visit("/repo/first") + expect(restored.recent()).toEqual(["/repo/first", "/repo/second"]) + }) + + it("preserves unrelated webview state and uses paths rather than ambiguous IDs", () => { + const data = storage({ sidebarWidth: 240, localTabs: { project: ["ses_one"] } }) + const history = createWorktreeRecency(data) + history.visit("/first/.kilo/worktrees/same") + data.state.value = { ...data.get(), sidebarWidth: 300 } + history.visit("/second/.kilo/worktrees/same") + expect(history.recent()).toEqual(["/second/.kilo/worktrees/same", "/first/.kilo/worktrees/same"]) + expect(data.get()).toEqual({ + sidebarWidth: 300, + localTabs: { project: ["ses_one"] }, + worktreeMentionHistory: history.recent(), + }) + }) + + it("ignores invalid saved entries and bounds the history", () => { + const data = storage({ worktreeMentionHistory: [null, 42, "", "/repo/one", "/repo/one", "/repo/two"] }) + expect(createWorktreeRecency(data).recent()).toEqual(["/repo/one", "/repo/two"]) + expect(createWorktreeRecency(storage({ worktreeMentionHistory: "invalid" })).recent()).toEqual([]) + const many = storage({ worktreeMentionHistory: Array.from({ length: 110 }, (_, index) => `/repo/${index}`) }) + const history = createWorktreeRecency(many) + expect(history.recent()).toHaveLength(100) + history.visit("/repo/new") + expect(history.recent()).toHaveLength(100) + expect(history.recent()[0]).toBe("/repo/new") + expect(history.recent()).not.toContain("/repo/99") + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 6c1f949435..1d0041f542 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -168,6 +168,7 @@ import { createRevertFile } from "./revert-file" import { FullScreenDiffView } from "../diff-viewer/FullScreenDiffView" import { createApplyToLocal } from "./apply-to-local" import { createWorktreeDiffs, diffDataKey, wireDiffId } from "./worktree-diffs" +import { createWorktreeReferences } from "./worktree-references" import type { ReviewComment } from "../diff-viewer/review-comments" import { createReviewComposers } from "./review-composers" import type { SidebarSearchMenuRef } from "./SidebarSearchMenu" @@ -754,6 +755,7 @@ const AgentManagerContent: Component = () => { managed: managedSessions, locals: localSet, }) + const references = createWorktreeReferences(vscode, registry.active, projectSessionsLive.current, selection) /** Session ids shown in the project-scoped history view (every session of the project). */ const historySessionIds = createMemo(() => { @@ -2537,6 +2539,7 @@ const AgentManagerContent: Component = () => {
{ if (addSessionToCurrentWorktree(id)) return if (localSessionIDs().includes(id)) { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/sessions-live.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/sessions-live.ts index 1bdc8ea2a3..7910750969 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project/sessions-live.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/sessions-live.ts @@ -19,7 +19,7 @@ export function createProjectSessionsLive(opts: { managed: () => ManagedSessionState[] locals: () => Set }) { - return createMemo(() => { + const sessions = createMemo(() => { const base = opts.base() const pid = opts.pid() if (!pid || !opts.enabled()) return base @@ -41,4 +41,7 @@ export function createProjectSessionsLive(opts: { if (extra.length === 0 && merged.every((item, index) => item === pushed[index])) return base return { ...base, [pid]: [...merged, ...extra] } }) + return Object.assign(sessions, { + current: () => (opts.enabled() ? (sessions()[opts.pid() ?? ""] ?? []) : opts.store()), + }) } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/worktree-recency.ts b/packages/kilo-vscode/webview-ui/agent-manager/worktree-recency.ts new file mode 100644 index 0000000000..f136d55ed0 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/worktree-recency.ts @@ -0,0 +1,23 @@ +import { createSignal } from "solid-js" + +export function createWorktreeRecency(storage: { + get: () => Record | undefined + set: (state: Record) => void +}) { + const value = storage.get()?.worktreeMentionHistory + const [recent, setRecent] = createSignal( + Array.isArray(value) + ? [...new Set(value.filter((path): path is string => typeof path === "string" && path.length > 0))].slice(0, 100) + : [], + ) + const visit = (path: string) => { + if (!path) return + setRecent((previous) => { + if (previous[0] === path) return previous + const next = [path, ...previous.filter((item) => item !== path)].slice(0, 100) + storage.set({ ...storage.get(), worktreeMentionHistory: next }) + return next + }) + } + return { recent, visit } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/worktree-references.ts b/packages/kilo-vscode/webview-ui/agent-manager/worktree-references.ts new file mode 100644 index 0000000000..36d5c92896 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/worktree-references.ts @@ -0,0 +1,75 @@ +import { createEffect, createMemo, type Accessor } from "solid-js" +import type { SessionInfo } from "../src/types/messages" +import type { useVSCode } from "../src/context/vscode" +import type { WorktreeReference } from "../src/hooks/file-mention-utils" +import type { ProjectStore } from "./project/store" +import { firstOrderedTitle } from "./tab-order" +import { sortWorktrees } from "./section-helpers" +import { createWorktreeRecency } from "./worktree-recency" + +type Session = Pick & Partial> + +export function worktreeReferences( + state: ProjectStore, + sessions: Session[], + current: string | null, + recent: string[] = [], +): WorktreeReference[] { + const titles = new Map(sessions.map((session) => [session.id, session.title])) + const updated = new Map(sessions.map((session) => [session.id, Date.parse(session.updatedAt ?? "") || 0])) + const recency = new Map(recent.map((path, index) => [path, index])) + const activity = new Map() + const groups = new Map() + for (const session of state.managedSessions()) { + if (!session.worktreeId) continue + const group = groups.get(session.worktreeId) ?? [] + group.push({ id: session.id, title: titles.get(session.id) }) + groups.set(session.worktreeId, group) + } + return sortWorktrees(state.worktrees(), state.worktreeOrder()) + .map((worktree) => { + const sessions = groups.get(worktree.id) ?? [] + const basename = worktree.path.replaceAll("\\", "/").replace(/\/+$/, "").split("/").pop() + activity.set( + worktree.path, + Math.max(Date.parse(worktree.createdAt) || 0, ...sessions.map((session) => updated.get(session.id) ?? 0)), + ) + return { + id: worktree.id, + name: worktree.label || firstOrderedTitle(sessions, state.tabOrder()[worktree.id], basename || worktree.branch), + branch: worktree.branch, + path: worktree.path, + base: worktree.parentBranch, + sessions, + disabled: worktree.id === current || state.staleWorktreeIds().has(worktree.id) || state.busy().has(worktree.id), + } + }) + .sort( + (a, b) => + (recency.get(a.path) ?? recent.length) - (recency.get(b.path) ?? recent.length) || + (activity.get(b.path) ?? 0) - (activity.get(a.path) ?? 0), + ) +} + +export function createWorktreeReferences( + vscode: Pick, "getState" | "setState">, + state: Accessor, + sessions: Accessor, + selection: Accessor, +) { + const recency = createWorktreeRecency({ + get: () => vscode.getState>(), + set: (value) => vscode.setState(value), + }) + const current = createMemo(() => { + const project = state() + const id = selection() + if (!id || project.staleWorktreeIds().has(id) || project.busy().has(id)) return + return project.worktrees().find((worktree) => worktree.id === id)?.path + }) + createEffect(() => { + const path = current() + if (path) recency.visit(path) + }) + return createMemo(() => worktreeReferences(state(), sessions(), selection(), recency.recent())) +} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index ff1619d608..567276ddaf 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -28,6 +28,7 @@ import { useServer } from "../../context/server" import { TranscriptSearchProvider } from "../../context/transcript-search" import { isPromptBlocked, isSuggesting, isQuestioning } from "./prompt-input-utils" import { showTabStrip } from "../../utils/local-tabs" +import type { WorktreeReference } from "../../hooks/file-mention-utils" interface ChatViewProps { onSelectSession?: (id: string) => void @@ -40,6 +41,7 @@ interface ChatViewProps { worktree?: boolean promptBoxId?: string terminalContext?: () => string | undefined + worktrees?: () => WorktreeReference[] deferFocusToQuestion?: () => boolean pendingSessionID?: string focusOnDraftChange?: () => boolean @@ -391,6 +393,7 @@ export const ChatView: Component = (props) => { worktree={props.worktree} boxId={props.promptBoxId} terminalContext={props.terminalContext} + worktrees={props.worktrees} deferFocusToQuestion={props.deferFocusToQuestion} pendingSessionID={pendingSessionID()} focusOnDraftChange={props.focusOnDraftChange} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 3375151914..16a7b15e4e 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -27,7 +27,7 @@ import { SpeechToTextButton } from "../speech-to-text/SpeechToTextButton" import { canUseSpeechToText, selectedSpeechToTextModel } from "../speech-to-text/availability" import { ThinkingSelector } from "../shared/ThinkingSelector" import { useFileMention } from "../../hooks/useFileMention" -import type { MentionResult } from "../../hooks/file-mention-utils" +import type { MentionResult, WorktreeReference } from "../../hooks/file-mention-utils" import { useTerminalContext } from "../../hooks/useTerminalContext" import { useGitChangesContext } from "../../hooks/useGitChangesContext" import { hasTerminalMention } from "../../hooks/terminal-context-utils" @@ -40,6 +40,7 @@ import { createSpeechShortcut } from "../speech-to-text/shortcut" import { useImageAttachments, type ImageAttachment } from "../../hooks/useImageAttachments" import { convertToMentionPath } from "../../utils/path-mentions" import { SessionMentionPicker } from "./SessionMentionPicker" +import { WorktreeMentionPicker } from "./WorktreeMentionPicker" import { usePromptHistory } from "../../hooks/usePromptHistory" import { cycleVariant } from "../../context/session-variant-store" import { WandSparkles } from "@kilocode/kilo-ui/lucide" @@ -120,6 +121,7 @@ interface PromptInputProps { worktree?: boolean boxId?: string terminalContext?: () => string | undefined + worktrees?: () => WorktreeReference[] pendingSessionID?: string /** Agent Manager can suppress automatic prompt focus when this session last * used its side terminal instead. Other callers retain the old behavior. */ @@ -130,6 +132,7 @@ interface PromptInputProps { function MentionItemContent(props: { item: MentionResult }) { const item = props.item + const language = useLanguage() if (item.type === "terminal") return ( <> @@ -138,12 +141,16 @@ function MentionItemContent(props: { item: MentionResult }) { {item.description} ) - if (item.type === "git-changes") + if (item.type === "git-changes" || item.type === "worktrees") return ( <> - {item.label} - {item.description} + + {item.type === "worktrees" ? language.t("prompt.worktrees.title") : item.label} + + + {item.type === "worktrees" ? language.t("prompt.worktrees.search") : item.description} + ) if (item.type === "past-chats") @@ -194,7 +201,7 @@ export const PromptInput: Component = (props) => { return rest === "unassigned" ? undefined : rest } const hasGit = () => server.gitInstalled() - const mention = useFileMention(vscode, sid, hasGit) + const mention = useFileMention(vscode, sid, hasGit, props.worktrees) const terminal = useTerminalContext(props.resolveEmbeddedTerminal) const git = useGitChangesContext(vscode, ctx, hasGit) const imageAttach = useImageAttachments() @@ -391,6 +398,7 @@ export const PromptInput: Component = (props) => { const pending = reviewDrafts.get(key) ?? [] const scroll = scrollDrafts.get(key) ?? 0 setText(draft) + mention.seedFromText(draft) setReviewComments(pending) imageAttach.replace(imageDrafts.get(key) ?? []) setEnhancing(false) @@ -1337,14 +1345,31 @@ export const PromptInput: Component = (props) => { } > 0} - fallback={
No files or folders found
} + when={!mention.worktreePicker() && mention.mentionResults().length > 0} + fallback={ + No files or folders found
} + > + { + if (textareaRef) mention.selectWorktree(picked, textareaRef, setText, adjustHeight) + }} + onClose={() => { + mention.closeMention() + textareaRef?.focus() + }} + /> + + } > {(item, index) => ( <>
{ e.preventDefault() diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/WorktreeMentionPicker.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/WorktreeMentionPicker.tsx new file mode 100644 index 0000000000..ddbcf046e5 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/components/chat/WorktreeMentionPicker.tsx @@ -0,0 +1,59 @@ +import { createMemo, onMount } from "solid-js" +import { Icon } from "@kilocode/kilo-ui/icon" +import { List } from "@kilocode/kilo-ui/list" +import { useLanguage } from "../../context/language" +import type { WorktreeReference } from "../../hooks/file-mention-utils" + +interface Props { + worktrees: WorktreeReference[] + onSelect: (worktree: WorktreeReference) => void + onClose: () => void +} + +export function WorktreeMentionPicker(props: Props) { + const language = useLanguage() + const items = createMemo(() => + props.worktrees.map((worktree) => ({ + ...worktree, + search: [worktree.name, worktree.branch, ...worktree.sessions.map((session) => session.title)].join(" "), + })), + ) + let root: HTMLDivElement | undefined + onMount(() => queueMicrotask(() => root?.querySelector("input")?.focus({ preventScroll: true }))) + + return ( +
{ + if (event.key !== "Escape" || event.isComposing) return + event.preventDefault() + event.stopPropagation() + props.onClose() + }} + > + item.path} + filterKeys={["name", "branch", "search"]} + search={{ placeholder: language.t("prompt.worktrees.search"), autofocus: true }} + onSelect={(item) => { + if (item) props.onSelect(item) + }} + onKeyEvent={(event, item) => { + if (event.key !== "Tab" || event.shiftKey || event.isComposing || !item) return + event.preventDefault() + props.onSelect(item) + }} + > + {(item) => ( + + + {item.name} + {item.branch} + + )} + +
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts index 7bee8b936d..ce324670db 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/file-mention-utils.ts @@ -4,6 +4,16 @@ import { TERMINAL_MENTION } from "./terminal-context-utils" export const AT_PATTERN = /(?:^|\s)@(\S*)$/ +export type WorktreeReference = { + id: string + name: string + branch: string + path: string + base: string + sessions: { id: string; title?: string }[] + disabled: boolean +} + export type MentionResult = | { type: "terminal"; value: typeof TERMINAL_MENTION; label: string; description: string } | { type: "git-changes"; value: typeof GIT_CHANGES_MENTION; label: string; description: string } @@ -13,9 +23,11 @@ export type MentionResult = | { type: "folder"; value: string } | { type: "file-picker"; value: "file-picker"; label: string; description: string } | { type: "session"; value: string; session: SessionSearchItem } + | { type: "worktrees"; value: "worktrees" } export const PAST_CHATS_MENTION = "past-chats" const PAST_CHATS_ALIASES = ["past", "chats", "sessions", "session", "history"] +const WORKTREE_ALIASES = ["worktrees", "branches"] export const TERMINAL_RESULT: MentionResult = { type: "terminal", @@ -63,7 +75,16 @@ export function getPastChatsMentionResult(query: string): MentionResult[] { return [PAST_CHATS_RESULT] } -export function buildMentionResults(query: string, items: Array, git = true): MentionResult[] { +export function buildMentionResults( + query: string, + items: Array, + git = true, + worktrees = false, +): MentionResult[] { + const references: MentionResult[] = + worktrees && WORKTREE_ALIASES.some((alias) => alias.startsWith(query.toLowerCase())) + ? [{ type: "worktrees", value: "worktrees" }] + : [] const results: MentionResult[] = items.map((item) => { if (typeof item === "string") return { type: "file", value: item } if (item.type === "folder") return { type: "folder", value: item.path } @@ -74,6 +95,7 @@ export function buildMentionResults(query: string, items: Array alias.startsWith(value)) if (item.type === "file-picker") return true + if (item.type === "worktrees") return WORKTREE_ALIASES.some((alias) => alias.startsWith(value)) return item.value.toLowerCase().includes(value) }) } @@ -337,6 +360,38 @@ export function buildFileAttachments( return result } +export function buildWorktreeAttachments(text: string, worktrees: WorktreeReference[]): FileAttachment[] { + const paths = syncMentionedPaths(new Set(worktrees.map((worktree) => worktree.path)), text) + return worktrees + .filter((worktree) => paths.has(worktree.path)) + .map((worktree) => { + const value = `@${worktree.path}` + const start = text.indexOf(value) + const content = [ + "Agent Manager worktree reference (metadata only, not file contents or conversation history).", + "Use the directory to inspect files or git changes. Use the session IDs with Agent Manager or recall if needed.", + JSON.stringify( + { + worktreeID: worktree.id, + name: worktree.name, + directory: worktree.path, + branch: worktree.branch, + baseBranch: worktree.base, + sessions: worktree.sessions, + }, + null, + 2, + ), + ].join("\n\n") + return { + mime: "text/plain", + url: `data:text/plain;charset=utf-8,${encodeURIComponent(content)}`, + filename: `worktree-${worktree.id}.txt`, + source: { type: "file", path: worktree.path, text: { value, start, end: start + value.length } }, + } + }) +} + /** * Sync mentioned sessions against the current text: drop entries whose * `@title` token is no longer present. Uses the same boundary-aware matching diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts b/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts index d0d383af01..0480b4fd8b 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts @@ -13,6 +13,7 @@ import { buildFileAttachments, buildMentionResults, buildSessionAttachments, + buildWorktreeAttachments, filterMentionResults, isCursorAtMentionEnd, getMentionRemovalRange, @@ -22,6 +23,7 @@ import { syncMentionedSessions as _syncMentionedSessions, FILE_PICKER_RESULT, type MentionResult, + type WorktreeReference, } from "./file-mention-utils" const FILE_SEARCH_DEBOUNCE_MS = 150 @@ -54,6 +56,14 @@ export interface FileMention { sessionPicker: Accessor /** Directory-scoped past chats shown in the session picker. */ sessionCandidates: Accessor + worktreePicker: Accessor + worktreeCandidates: Accessor + selectWorktree: ( + worktree: WorktreeReference, + textarea: HTMLTextAreaElement, + setText: (text: string) => void, + onSelect?: () => void, + ) => void mentionResults: Accessor mentionIndex: Accessor showMention: Accessor @@ -124,6 +134,7 @@ export function useFileMention( vscode: VSCodeContext, sessionID?: Accessor, git?: Accessor, + worktrees?: Accessor, ): FileMention { const [mentionedPaths, setMentionedPaths] = createSignal>(new Set()) const [mentionedSessions, setMentionedSessions] = createSignal>(new Map()) @@ -132,6 +143,8 @@ export function useFileMention( const [mentionIndex, setMentionIndex] = createSignal(0) const [sessionPicker, setSessionPicker] = createSignal(false) const [sessionCandidates, setSessionCandidates] = createSignal([]) + const [worktreePicker, setWorktreePicker] = createSignal(false) + const worktreeCandidates = () => worktrees?.().filter((worktree) => !worktree.disabled) ?? [] let workspaceDir = "" const cache = new Map() const dirs = new Map() @@ -141,6 +154,18 @@ export function useFileMention( // Same accumulation for past-chat mentions, keyed by their exact visible // token. Duplicate titles receive a numeric suffix so they cannot overwrite. const knownSessions = new Map() + const knownWorktrees = new Map() + const references = () => { + for (const worktree of worktrees?.() ?? []) { + knownWorktrees.set(worktree.path, worktree) + knownPaths.add(worktree.path) + } + return [...knownWorktrees.values()] + } + const results = (query: string, items: Array) => { + references() + return buildMentionResults(query, items, git?.() ?? true, worktrees !== undefined) + } let fileSearchTimer: ReturnType | undefined let fileSearchCounter = 0 @@ -172,6 +197,7 @@ export function useFileMention( fileSearchRequest = undefined prewarmRequest = undefined workspaceDir = dirs.get(value) ?? "" + setWorktreePicker(false) setMentionResults([]) setMentionIndex(0) return value @@ -274,7 +300,7 @@ export function useFileMention( } if (!request.query) writeCache(message.dir, items, request.revision) if (!showMention() || request.query !== mentionQuery()) return - replaceResults(buildMentionResults(request.query, items, git?.() ?? true)) + replaceResults(results(request.query, items)) }) onCleanup(() => { @@ -315,6 +341,7 @@ export function useFileMention( setMentionQuery(null) setMentionResults([]) setSessionPicker(false) + setWorktreePicker(false) } const closeSessionPicker = () => { @@ -322,6 +349,7 @@ export function useFileMention( } const syncMentionedPaths = (text: string) => { + references() setMentionedPaths(() => _syncMentionedPaths(knownPaths, text)) setMentionedSessions(() => _syncMentionedSessions(knownSessions, text)) } @@ -363,6 +391,12 @@ export function useFileMention( return } + if (result.type === "worktrees") { + references() + setWorktreePicker(true) + return + } + if (result.type === "past-chats") { // Switch the dropdown into the AM-style session search; the actual // insertion happens when a session is picked there. @@ -404,6 +438,17 @@ export function useFileMention( onSelect?.() } + const selectWorktree = ( + worktree: WorktreeReference, + textarea: HTMLTextAreaElement, + setText: (text: string) => void, + onSelect?: () => void, + ) => { + if (worktree.disabled) return + knownWorktrees.set(worktree.path, worktree) + selectMention({ type: "file", value: worktree.path }, textarea, setText, onSelect) + } + const selectSession = ( session: SessionSearchItem, textarea: HTMLTextAreaElement, @@ -425,6 +470,7 @@ export function useFileMention( syncMentionedPaths(val) if (suppress) return closeSessionPicker() + setWorktreePicker(false) const before = val.substring(0, cursor) const match = before.match(AT_PATTERN) if (match) { @@ -432,16 +478,19 @@ export function useFileMention( setMentionQuery(query) const items = readCache(workspaceDir) if (!query) { - setMentionResults(buildMentionResults("", items, git?.() ?? true)) + setMentionResults(results("", items)) setMentionIndex(0) requestFileSearch("") return } setMentionResults((prev) => { - const base = prev.length ? prev : buildMentionResults("", items, git?.() ?? true) - const next = filterMentionResults(query, base) - if (next.length) return next - return buildMentionResults(query, [], git?.() ?? true) + const base = prev.length ? prev : results("", items) + const files = filterMentionResults(query, base).flatMap((item) => + item.type === "file" || item.type === "folder" || item.type === "opened-file" + ? [{ path: item.value, type: item.type }] + : [], + ) + return results(query, files) }) setMentionIndex(0) requestFileSearch(query) @@ -500,10 +549,15 @@ export function useFileMention( // and selection snapping: file paths plus past-chat title tokens. const mentionTokens = () => new Set([...mentionedPaths(), ...mentionedSessions().keys()]) - const parseFileAttachments = (text: string): FileAttachment[] => [ - ...buildFileAttachments(text, mentionedPaths(), workspaceDir), - ...buildSessionAttachments(text, mentionedSessions()), - ] + const parseFileAttachments = (text: string): FileAttachment[] => { + const worktrees = references() + const paths = new Set([..._syncMentionedPaths(knownPaths, text)].filter((path) => !knownWorktrees.has(path))) + return [ + ...buildFileAttachments(text, paths, workspaceDir), + ...buildSessionAttachments(text, mentionedSessions()), + ...buildWorktreeAttachments(text, worktrees), + ] + } const handleBackspace = ( e: KeyboardEvent, @@ -696,6 +750,9 @@ export function useFileMention( mentionedSessions, sessionPicker, sessionCandidates, + worktreePicker, + worktreeCandidates, + selectWorktree, mentionResults, mentionIndex, showMention, diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 69b73384ba..8b776301ae 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -170,6 +170,8 @@ export const dict = { "common.saving": "جارٍ الحفظ...", "common.default": "افتراضي", + "prompt.worktrees.title": "Worktrees", + "prompt.worktrees.search": "البحث في Worktrees", "prompt.thinking.tooltip": "جهد الاستدلال", "prompt.action.send": "إرسال", "prompt.action.send.blocked": "أجب عن السؤال المعلق أو تجاهله أولاً", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index 49e1e409db..eedfaa69dd 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -174,6 +174,8 @@ export const dict = { "common.saving": "Salvando...", "common.default": "Padrão", + "prompt.worktrees.title": "Worktrees", + "prompt.worktrees.search": "Pesquisar Worktrees", "prompt.thinking.tooltip": "Esforço de raciocínio", "prompt.action.send": "Enviar", "prompt.action.send.blocked": "Responda ou feche a pergunta pendente primeiro", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index 333f063bf6..e2e6946b90 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -175,6 +175,8 @@ export const dict = { "common.saving": "Čuvanje...", "common.default": "Podrazumijevano", + "prompt.worktrees.title": "Worktrees", + "prompt.worktrees.search": "Pretraži Worktree-ove", "prompt.thinking.tooltip": "Napor razmišljanja", "prompt.action.send": "Pošalji", "prompt.action.send.blocked": "Prvo odgovorite ili odbacite pitanje na čekanju", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index b38f118524..23e1ccc423 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -174,6 +174,8 @@ export const dict = { "common.saving": "Gemmer...", "common.default": "Standard", + "prompt.worktrees.title": "Worktrees", + "prompt.worktrees.search": "Søg i Worktrees", "prompt.thinking.tooltip": "Ræsonnementsindsats", "prompt.action.send": "Send", "prompt.action.send.blocked": "Besvar eller afvis det afventende spørgsmål først", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 376b4b43eb..dd4b6af334 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -180,6 +180,8 @@ export const dict = { "common.saving": "Speichert...", "common.default": "Standard", + "prompt.worktrees.title": "Worktrees", + "prompt.worktrees.search": "Worktrees durchsuchen", "prompt.thinking.tooltip": "Reasoning-Aufwand", "prompt.action.send": "Senden", "prompt.action.send.blocked": "Beantworten oder verwerfen Sie zuerst die ausstehende Frage", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 03b2150f01..128277b4d6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -174,6 +174,8 @@ export const dict = { "common.saving": "Saving...", "common.default": "Default", + "prompt.worktrees.title": "Worktrees", + "prompt.worktrees.search": "Search worktrees", "prompt.thinking.tooltip": "Reasoning effort", "prompt.action.send": "Send", "prompt.action.send.blocked": "Answer or dismiss the pending question first", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 1229a5e73c..fe91bf741e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -175,6 +175,8 @@ export const dict = { "common.saving": "Guardando...", "common.default": "Predeterminado", + "prompt.worktrees.title": "Worktrees", + "prompt.worktrees.search": "Buscar Worktrees", "prompt.thinking.tooltip": "Esfuerzo de razonamiento", "prompt.action.send": "Enviar", "prompt.action.send.blocked": "Responda o descarte la pregunta pendiente primero", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts index 8da19e2b78..eee02c10fb 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -175,6 +175,8 @@ export const dict = { "common.saving": "در حال ذخیره...", "common.default": "پیش‌فرض", + "prompt.worktrees.title": "Worktrees", + "prompt.worktrees.search": "جستجوی worktree‌ها", "prompt.thinking.tooltip": "میزان استدلال", "prompt.action.send": "ارسال", "prompt.action.send.blocked": "ابتدا به سؤال در انتظار پاسخ دهید یا آن را رد کنید", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 0fce1d864a..84ae4791ec 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -175,6 +175,8 @@ export const dict = { "common.saving": "Enregistrement...", "common.default": "Défaut", + "prompt.worktrees.title": "Worktrees", + "prompt.worktrees.search": "Rechercher des Worktrees", "prompt.thinking.tooltip": "Effort de raisonnement", "prompt.action.send": "Envoyer", "prompt.action.send.blocked": "Répondez ou rejetez d'abord la question en attente", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index e679b00553..0eebe695e3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -154,6 +154,8 @@ export const dict = { "common.save": "Salva", "common.saving": "Salvataggio...", "common.default": "Predefinito", + "prompt.worktrees.title": "Worktree", + "prompt.worktrees.search": "Cerca worktree", "prompt.thinking.tooltip": "Sforzo di ragionamento", "prompt.action.send": "Invia", "prompt.action.send.blocked": "Rispondi alla domanda in sospeso o ignorala prima di continuare", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 07ebc3c4c5..855f955515 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -174,6 +174,8 @@ export const dict = { "common.saving": "保存中...", "common.default": "デフォルト", + "prompt.worktrees.title": "Worktrees", + "prompt.worktrees.search": "Worktreeを検索", "prompt.thinking.tooltip": "推論の強度", "prompt.action.send": "送信", "prompt.action.send.blocked": "最初に保留中の質問に答えるか、閉じてください", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index cd76691dfe..0ed648f104 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -177,6 +177,8 @@ export const dict = { "common.saving": "저장 중...", "common.default": "기본값", + "prompt.worktrees.title": "Worktrees", + "prompt.worktrees.search": "Worktree 검색", "prompt.thinking.tooltip": "추론 강도", "prompt.action.send": "전송", "prompt.action.send.blocked": "먼저 대기 중인 질문에 답하거나 닫아주세요", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 2716c88c01..939fdb72fa 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -175,6 +175,8 @@ export const dict = { "common.saving": "Bezig met opslaan...", "common.default": "Standaard", + "prompt.worktrees.title": "Worktrees", + "prompt.worktrees.search": "Worktrees doorzoeken", "prompt.thinking.tooltip": "Redeneringsinspanning", "prompt.action.send": "Verzenden", "prompt.action.send.blocked": "Beantwoord of negeer eerst de openstaande vraag", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 70d7a8cbca..9de613801e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -177,6 +177,8 @@ export const dict = { "common.saving": "Lagrer...", "common.default": "Standard", + "prompt.worktrees.title": "Worktrees", + "prompt.worktrees.search": "Søk i Worktrees", "prompt.thinking.tooltip": "Resonnementsinnsats", "prompt.action.send": "Send", "prompt.action.send.blocked": "Svar på eller avvis det ventende spørsmålet først", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 940fdb7924..1a2901fc89 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -174,6 +174,8 @@ export const dict = { "common.saving": "Zapisywanie...", "common.default": "Domyślny", + "prompt.worktrees.title": "Worktrees", + "prompt.worktrees.search": "Wyszukaj Worktree", "prompt.thinking.tooltip": "Wysiłek rozumowania", "prompt.action.send": "Wyślij", "prompt.action.send.blocked": "Najpierw odpowiedz na oczekujące pytanie lub je odrzuć", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index bf50538442..7befd9fec0 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -173,6 +173,8 @@ export const dict = { "common.saving": "Сохранение...", "common.default": "По умолчанию", + "prompt.worktrees.title": "Worktrees", + "prompt.worktrees.search": "Поиск worktrees", "prompt.thinking.tooltip": "Усилие рассуждения", "prompt.action.send": "Отправить", "prompt.action.send.blocked": "Сначала ответьте на ожидающий вопрос или отклоните его", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index fe7e687401..088d9be14c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -173,6 +173,8 @@ export const dict = { "common.saving": "กำลังบันทึก...", "common.default": "ค่าเริ่มต้น", + "prompt.worktrees.title": "Worktrees", + "prompt.worktrees.search": "ค้นหา Worktree", "prompt.thinking.tooltip": "ความพยายามในการให้เหตุผล", "prompt.action.send": "ส่ง", "prompt.action.send.blocked": "โปรดตอบหรือข้ามคำถามที่รอดำเนินการก่อน", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index ac7fddbd19..13703de831 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -174,6 +174,8 @@ export const dict = { "common.saving": "Kaydediliyor...", "common.default": "Varsayılan", + "prompt.worktrees.title": "Worktree'ler", + "prompt.worktrees.search": "Worktree'leri ara", "prompt.thinking.tooltip": "Akıl yürütme eforu", "prompt.action.send": "Gönder", "prompt.action.send.blocked": "Bekleyen soruyu önce yanıtlayın veya kapatın", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index bab5fba3d8..75b33a9ebc 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -175,6 +175,8 @@ export const dict = { "common.saving": "Збереження...", "common.default": "За замовчуванням", + "prompt.worktrees.title": "Робочі дерева", + "prompt.worktrees.search": "Пошук робочих дерев", "prompt.thinking.tooltip": "Зусилля міркування", "prompt.action.send": "Надіслати", "prompt.action.send.blocked": "Спочатку дайте відповідь або закрийте очікуюче питання", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index ed70000dea..ce894d95f1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -169,6 +169,8 @@ export const dict = { "common.saving": "保存中...", "common.default": "默认", + "prompt.worktrees.title": "Worktrees", + "prompt.worktrees.search": "搜索 Worktree", "prompt.thinking.tooltip": "推理强度", "prompt.action.indexing": "索引设置", "prompt.action.autoApprove.enable": "启用自动审批", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index 8c0b62d92b..377da7647c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -169,6 +169,8 @@ export const dict = { "common.saving": "儲存中...", "common.default": "預設", + "prompt.worktrees.title": "Worktrees", + "prompt.worktrees.search": "搜尋 Worktree", "prompt.thinking.tooltip": "推理強度", "prompt.action.send": "傳送", "prompt.action.send.blocked": "請先回答或忽略待處理的問題", diff --git a/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css b/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css index 70901682b7..06ed3a63d5 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css +++ b/packages/kilo-vscode/webview-ui/src/styles/prompt-dropdowns.css @@ -148,6 +148,10 @@ white-space: nowrap; } +.worktree-mention-picker [data-slot="list-item"] { + text-align: start; +} + /* ============================================ Slash Command Dropdown ============================================ */ diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 7ec7c09e0a..f1459642d7 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -307,7 +307,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) icon="circle-x" variant="ghost" onClick={() => { - setInternalFilter("") + applyFilter("") // kilocode_change queueMicrotask(() => inputRef?.focus()) }} aria-label={i18n.t("ui.list.clearFilter")}