mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
Merge pull request #13512 from Kilo-Org/support-worktree-references-in-prompts
feat(vscode): add searchable worktree references
This commit is contained in:
@@ -107,6 +107,15 @@ async function closeFiltered() {
|
||||
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: "be", inputType: "insertText" }))
|
||||
await settle()
|
||||
|
||||
query<HTMLButtonElement>('[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<HTMLButtonElement>(
|
||||
'[aria-label="Close tab: Beta"]',
|
||||
"Filtered result close button did not render",
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const storage = { value: {} as Record<string, unknown> }
|
||||
const sessions = () => [{ id: "same", title: project().id, updatedAt: time }]
|
||||
const refs = createWorktreeReferences(
|
||||
{
|
||||
getState: <T>() => storage.value as T,
|
||||
setState: (value) => {
|
||||
storage.value = value as Record<string, unknown>
|
||||
},
|
||||
},
|
||||
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()
|
||||
}
|
||||
})
|
||||
@@ -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> = {}): 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> = {}): 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<typeof harness>, 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()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { rootSessions } from "../../webview-ui/agent-manager/project/session-filter"
|
||||
import {
|
||||
rootSessions,
|
||||
worktreeSessionIds,
|
||||
worktreeSessions,
|
||||
} from "../../webview-ui/agent-manager/project/session-filter"
|
||||
import type { ProjectSessionInfo } from "../../webview-ui/src/types/messages"
|
||||
|
||||
const session = (id: string, worktreeId: string | null, parentID: string | null): ProjectSessionInfo => ({
|
||||
@@ -23,4 +27,19 @@ describe("rootSessions", () => {
|
||||
|
||||
expect(rootSessions(sessions, null).map((item) => item.id)).toEqual(["root"])
|
||||
})
|
||||
|
||||
it("preserves managed membership, chronological fallback, and custom tab order", () => {
|
||||
const rows = [
|
||||
{ ...session("new", "wt-1", null), createdAt: "2026-01-02T00:00:00.000Z" },
|
||||
session("old", "wt-1", null),
|
||||
session("child", "wt-1", "old"),
|
||||
session("other", "wt-2", null),
|
||||
]
|
||||
const managed = rows.map((item) => ({ id: item.id, worktreeId: item.worktreeId, createdAt: item.createdAt }))
|
||||
expect([...worktreeSessionIds("wt-1", managed)]).toEqual(["new", "old", "child"])
|
||||
expect(worktreeSessions("wt-1", managed, rows, undefined).map((item) => item.id)).toEqual(["old", "new"])
|
||||
expect(worktreeSessions("wt-1", managed, rows, ["missing", "new"]).map((item) => item.id)).toEqual(["new", "old"])
|
||||
expect(rows.map((item) => item.id)).toEqual(["new", "old", "child", "other"])
|
||||
expect(worktreeSessions("missing", managed, rows, undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
const state = { value, writes: 0 }
|
||||
return {
|
||||
get: () => state.value,
|
||||
set: (value: Record<string, unknown>) => {
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -85,6 +85,7 @@ import { SidebarBody } from "./SidebarBody"
|
||||
import { TabBar } from "./TabBar"
|
||||
import { createProjectLive } from "./project/live"
|
||||
import { createProjectSessionsLive } from "./project/sessions-live"
|
||||
import { worktreeSessionIds as worktreeMembership, worktreeSessions } from "./project/session-filter"
|
||||
import { applyProjectSelection, createTargetRememberer } from "./project/selection"
|
||||
import { createLocalSessions, persistLocalTabs, projectLocalIds, projectLocalSessions } from "./project/local-tabs"
|
||||
import { createProjectRegistry, type PersistedProjectTabs } from "./project/registry"
|
||||
@@ -168,6 +169,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 +756,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(() => {
|
||||
@@ -776,20 +779,8 @@ const AgentManagerContent: Component = () => {
|
||||
title: () => t("agentManager.session.newSession"),
|
||||
})
|
||||
|
||||
const sessionsForWorktree = (worktreeId: string): SessionInfo[] => {
|
||||
const ids = new Set(
|
||||
managedSessions()
|
||||
.filter((ms) => ms.worktreeId === worktreeId)
|
||||
.map((ms) => ms.id),
|
||||
)
|
||||
return applyTabOrder(
|
||||
session
|
||||
.sessions()
|
||||
.filter((s) => isKnownRootSession(s) && ids.has(s.id))
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()),
|
||||
worktreeTabOrder()[worktreeId],
|
||||
)
|
||||
}
|
||||
const sessionsForWorktree = (id: string) =>
|
||||
worktreeSessions(id, managedSessions(), session.sessions(), worktreeTabOrder()[id])
|
||||
|
||||
const activeWorktreeSessions = createMemo((): SessionInfo[] => {
|
||||
const sel = selection()
|
||||
@@ -800,11 +791,7 @@ const AgentManagerContent: Component = () => {
|
||||
const activeWorktreeSessionIds = createMemo<ReadonlySet<string> | undefined>(() => {
|
||||
const sel = selection()
|
||||
if (!sel || sel === LOCAL) return undefined
|
||||
return new Set(
|
||||
managedSessions()
|
||||
.filter((item) => item.worktreeId === sel)
|
||||
.map((item) => item.id),
|
||||
)
|
||||
return worktreeMembership(sel, managedSessions())
|
||||
})
|
||||
|
||||
const activeTabs = createMemo((): SessionInfo[] => {
|
||||
@@ -2540,6 +2527,7 @@ const AgentManagerContent: Component = () => {
|
||||
</Show>
|
||||
<div class="am-chat-wrapper" classList={{ "am-chat-wrapper-hidden": contextEmpty() }}>
|
||||
<ChatView
|
||||
worktrees={references}
|
||||
onSelectSession={(id) => {
|
||||
if (addSessionToCurrentWorktree(id)) return
|
||||
if (localSessionIDs().includes(id)) {
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
import type { ProjectSessionInfo } from "../../src/types/messages"
|
||||
import type { ManagedSessionState, ProjectSessionInfo, SessionInfo } from "../../src/types/messages"
|
||||
import { isKnownRootSession } from "../navigate"
|
||||
import { applyTabOrder } from "../tab-order"
|
||||
|
||||
export function rootSessions(sessions: ProjectSessionInfo[], worktreeId: string | null): ProjectSessionInfo[] {
|
||||
return sessions.filter((session) => session.worktreeId === worktreeId && isKnownRootSession(session))
|
||||
}
|
||||
|
||||
export function worktreeSessionIds(id: string, sessions: ManagedSessionState[]) {
|
||||
return new Set(sessions.filter((session) => session.worktreeId === id).map((session) => session.id))
|
||||
}
|
||||
|
||||
export function worktreeSessions(
|
||||
id: string,
|
||||
managed: ManagedSessionState[],
|
||||
sessions: SessionInfo[],
|
||||
order: string[] | undefined,
|
||||
) {
|
||||
const ids = worktreeSessionIds(id, managed)
|
||||
return applyTabOrder(
|
||||
sessions
|
||||
.filter((session) => isKnownRootSession(session) && ids.has(session.id))
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()),
|
||||
order,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function createProjectSessionsLive(opts: {
|
||||
managed: () => ManagedSessionState[]
|
||||
locals: () => Set<string>
|
||||
}) {
|
||||
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()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
export function createWorktreeRecency(storage: {
|
||||
get: () => Record<string, unknown> | undefined
|
||||
set: (state: Record<string, unknown>) => 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 }
|
||||
}
|
||||
@@ -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<SessionInfo, "id" | "title"> & Partial<Pick<SessionInfo, "updatedAt">>
|
||||
|
||||
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<string, number>()
|
||||
const groups = new Map<string, WorktreeReference["sessions"]>()
|
||||
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<ReturnType<typeof useVSCode>, "getState" | "setState">,
|
||||
state: Accessor<ProjectStore>,
|
||||
sessions: Accessor<Session[]>,
|
||||
selection: Accessor<string | null>,
|
||||
) {
|
||||
const recency = createWorktreeRecency({
|
||||
get: () => vscode.getState<Record<string, unknown>>(),
|
||||
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()))
|
||||
}
|
||||
@@ -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<ChatViewProps> = (props) => {
|
||||
worktree={props.worktree}
|
||||
boxId={props.promptBoxId}
|
||||
terminalContext={props.terminalContext}
|
||||
worktrees={props.worktrees}
|
||||
deferFocusToQuestion={props.deferFocusToQuestion}
|
||||
pendingSessionID={pendingSessionID()}
|
||||
focusOnDraftChange={props.focusOnDraftChange}
|
||||
|
||||
@@ -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 }) {
|
||||
<span class="file-mention-dir">{item.description}</span>
|
||||
</>
|
||||
)
|
||||
if (item.type === "git-changes")
|
||||
if (item.type === "git-changes" || item.type === "worktrees")
|
||||
return (
|
||||
<>
|
||||
<Icon name="branch" class="file-mention-icon" />
|
||||
<span class="file-mention-name">{item.label}</span>
|
||||
<span class="file-mention-dir">{item.description}</span>
|
||||
<span class="file-mention-name">
|
||||
{item.type === "worktrees" ? language.t("prompt.worktrees.title") : item.label}
|
||||
</span>
|
||||
<span class="file-mention-dir">
|
||||
{item.type === "worktrees" ? language.t("prompt.worktrees.search") : item.description}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
if (item.type === "past-chats")
|
||||
@@ -194,7 +201,7 @@ export const PromptInput: Component<PromptInputProps> = (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<PromptInputProps> = (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<PromptInputProps> = (props) => {
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={mention.mentionResults().length > 0}
|
||||
fallback={<div class="file-mention-empty">No files or folders found</div>}
|
||||
when={!mention.worktreePicker() && mention.mentionResults().length > 0}
|
||||
fallback={
|
||||
<Show
|
||||
when={mention.worktreePicker()}
|
||||
fallback={<div class="file-mention-empty">No files or folders found</div>}
|
||||
>
|
||||
<WorktreeMentionPicker
|
||||
worktrees={mention.worktreeCandidates()}
|
||||
onSelect={(picked) => {
|
||||
if (textareaRef) mention.selectWorktree(picked, textareaRef, setText, adjustHeight)
|
||||
}}
|
||||
onClose={() => {
|
||||
mention.closeMention()
|
||||
textareaRef?.focus()
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<For each={mention.mentionResults()}>
|
||||
{(item, index) => (
|
||||
<>
|
||||
<div
|
||||
class="file-mention-item"
|
||||
data-type={item.type}
|
||||
classList={{ "file-mention-item--active": index() === mention.mentionIndex() }}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
ref={root}
|
||||
class="session-mention-picker worktree-mention-picker"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Escape" || event.isComposing) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onClose()
|
||||
}}
|
||||
>
|
||||
<List
|
||||
items={items()}
|
||||
key={(item) => 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) => (
|
||||
<span class="session-mention-item" title={item.path}>
|
||||
<Icon name="branch" class="file-mention-icon" />
|
||||
<span class="session-mention-title">{item.name}</span>
|
||||
<span class="session-mention-worktree">{item.branch}</span>
|
||||
</span>
|
||||
)}
|
||||
</List>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<FileSearchItem | string>, git = true): MentionResult[] {
|
||||
export function buildMentionResults(
|
||||
query: string,
|
||||
items: Array<FileSearchItem | string>,
|
||||
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<FileSearchItem |
|
||||
...getTerminalMentionResult(query),
|
||||
...(git ? getGitChangesMentionResult(query) : []),
|
||||
...getPastChatsMentionResult(query),
|
||||
...filterMentionResults(query, references),
|
||||
...results,
|
||||
FILE_PICKER_RESULT,
|
||||
]
|
||||
@@ -115,6 +137,7 @@ export function filterMentionResults(query: string, items: MentionResult[]): Men
|
||||
if (item.type === "git-changes") return GIT_CHANGES_MENTION.startsWith(value) || "git".startsWith(value)
|
||||
if (item.type === "past-chats") return PAST_CHATS_ALIASES.some((alias) => 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
|
||||
|
||||
@@ -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<boolean>
|
||||
/** Directory-scoped past chats shown in the session picker. */
|
||||
sessionCandidates: Accessor<SessionSearchItem[]>
|
||||
worktreePicker: Accessor<boolean>
|
||||
worktreeCandidates: Accessor<WorktreeReference[]>
|
||||
selectWorktree: (
|
||||
worktree: WorktreeReference,
|
||||
textarea: HTMLTextAreaElement,
|
||||
setText: (text: string) => void,
|
||||
onSelect?: () => void,
|
||||
) => void
|
||||
mentionResults: Accessor<MentionResult[]>
|
||||
mentionIndex: Accessor<number>
|
||||
showMention: Accessor<boolean>
|
||||
@@ -124,6 +134,7 @@ export function useFileMention(
|
||||
vscode: VSCodeContext,
|
||||
sessionID?: Accessor<string | undefined>,
|
||||
git?: Accessor<boolean>,
|
||||
worktrees?: Accessor<WorktreeReference[]>,
|
||||
): FileMention {
|
||||
const [mentionedPaths, setMentionedPaths] = createSignal<Set<string>>(new Set())
|
||||
const [mentionedSessions, setMentionedSessions] = createSignal<Map<string, SessionSearchItem>>(new Map())
|
||||
@@ -132,6 +143,8 @@ export function useFileMention(
|
||||
const [mentionIndex, setMentionIndex] = createSignal(0)
|
||||
const [sessionPicker, setSessionPicker] = createSignal(false)
|
||||
const [sessionCandidates, setSessionCandidates] = createSignal<SessionSearchItem[]>([])
|
||||
const [worktreePicker, setWorktreePicker] = createSignal(false)
|
||||
const worktreeCandidates = () => worktrees?.().filter((worktree) => !worktree.disabled) ?? []
|
||||
let workspaceDir = ""
|
||||
const cache = new Map<string, FileSearchCache>()
|
||||
const dirs = new Map<string, string>()
|
||||
@@ -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<string, SessionSearchItem>()
|
||||
const knownWorktrees = new Map<string, WorktreeReference>()
|
||||
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<FileSearchItem | string>) => {
|
||||
references()
|
||||
return buildMentionResults(query, items, git?.() ?? true, worktrees !== undefined)
|
||||
}
|
||||
|
||||
let fileSearchTimer: ReturnType<typeof setTimeout> | 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,
|
||||
|
||||
+2
@@ -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": "أجب عن السؤال المعلق أو تجاهله أولاً",
|
||||
|
||||
+2
@@ -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",
|
||||
|
||||
+2
@@ -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",
|
||||
|
||||
+2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+2
@@ -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",
|
||||
|
||||
+2
@@ -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": "ابتدا به سؤال در انتظار پاسخ دهید یا آن را رد کنید",
|
||||
|
||||
+2
@@ -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",
|
||||
|
||||
+2
@@ -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",
|
||||
|
||||
+2
@@ -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": "最初に保留中の質問に答えるか、閉じてください",
|
||||
|
||||
+2
@@ -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": "먼저 대기 중인 질문에 답하거나 닫아주세요",
|
||||
|
||||
+2
@@ -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",
|
||||
|
||||
+2
@@ -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",
|
||||
|
||||
+2
@@ -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ć",
|
||||
|
||||
+2
@@ -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": "Сначала ответьте на ожидающий вопрос или отклоните его",
|
||||
|
||||
+2
@@ -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": "โปรดตอบหรือข้ามคำถามที่รอดำเนินการก่อน",
|
||||
|
||||
+2
@@ -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",
|
||||
|
||||
+2
@@ -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": "Спочатку дайте відповідь або закрийте очікуюче питання",
|
||||
|
||||
+2
@@ -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": "启用自动审批",
|
||||
|
||||
+2
@@ -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": "請先回答或忽略待處理的問題",
|
||||
|
||||
@@ -148,6 +148,10 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.worktree-mention-picker [data-slot="list-item"] {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Slash Command Dropdown
|
||||
============================================ */
|
||||
|
||||
@@ -307,7 +307,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
||||
icon="circle-x"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setInternalFilter("")
|
||||
applyFilter("") // kilocode_change
|
||||
queueMicrotask(() => inputRef?.focus())
|
||||
}}
|
||||
aria-label={i18n.t("ui.list.clearFilter")}
|
||||
|
||||
Reference in New Issue
Block a user