From 5d1313f8a23dc5f58ae66fed35c4756bf81913d5 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 28 Aug 2026 08:44:07 +0200 Subject: [PATCH] fix(vscode): restore past session references --- .changeset/restore-past-session-mentions.md | 6 + .../src/kilo-provider/session-search.ts | 2 +- .../tests/unit/file-mention-utils.test.ts | 34 ++++ .../tests/unit/session-search.test.ts | 22 +- .../components/chat/SessionMentionPicker.tsx | 5 +- .../src/hooks/file-mention-utils.ts | 8 + .../opencode/src/kilocode/session/index.ts | 21 +- .../kilocode/server/session-mentions.test.ts | 192 ++++++++++++++++++ 8 files changed, 282 insertions(+), 8 deletions(-) create mode 100644 .changeset/restore-past-session-mentions.md create mode 100644 packages/opencode/test/kilocode/server/session-mentions.test.ts diff --git a/.changeset/restore-past-session-mentions.md b/.changeset/restore-past-session-mentions.md new file mode 100644 index 0000000000..4f1019b8fa --- /dev/null +++ b/.changeset/restore-past-session-mentions.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/cli": patch +--- + +Restore past-chat references across large histories and skip inaccessible folders from unrelated projects. Keep sidebar and Agent Manager search responsive by showing the best 50 matches. diff --git a/packages/kilo-vscode/src/kilo-provider/session-search.ts b/packages/kilo-vscode/src/kilo-provider/session-search.ts index e525b5a854..df58822a31 100644 --- a/packages/kilo-vscode/src/kilo-provider/session-search.ts +++ b/packages/kilo-vscode/src/kilo-provider/session-search.ts @@ -44,7 +44,7 @@ export async function handleSessionSearch(input: Input): Promise { try { const res = await client.experimental.session.list( - { worktrees: true, roots: true, directory: dir, limit: 50 }, + { worktrees: true, roots: true, directory: dir, limit: Number.MAX_SAFE_INTEGER }, { throwOnError: true }, ) const sessions: Item[] = res.data diff --git a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts index 30df1c1210..add791708e 100644 --- a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts @@ -7,6 +7,7 @@ import { buildMentionResults, buildSessionAttachments, filterMentionResults, + filterSessions, getMentionRemovalRange, getPastChatsMentionResult, isCursorAtMentionEnd, @@ -119,6 +120,39 @@ describe("filterMentionResults", () => { }) }) +describe("filterSessions", () => { + const sessions = Array.from({ length: 60 }, (_, index) => ({ + id: `ses_${index}`, + title: `Recent session ${index}`, + updated: 60 - index, + worktreeName: "branch", + })) + + it("shows the first 50 sessions in their existing order without a query", () => { + expect(filterSessions(sessions, "")).toEqual(sessions.slice(0, 50)) + }) + + it("limits broad search results to 50 matches", () => { + expect(filterSessions(sessions, "recent")).toHaveLength(50) + }) + + it.each(["ORCHID", "old-branch"])("finds older sessions beyond the display limit by %s", (query) => { + const source = { id: "ses_old", title: "Orchid reference source", updated: 0, worktreeName: "old-branch" } + expect(filterSessions([...sessions, source], query)).toEqual([source]) + }) + + it("ranks an older exact match before newer partial matches", () => { + const source = { id: "ses_old", title: "Recent", updated: 0 } + const matches = filterSessions([...sessions, source], "recent") + expect(matches).toHaveLength(50) + expect(matches[0]).toBe(source) + }) + + it("returns no results when nothing matches", () => { + expect(filterSessions(sessions, "zzzz")).toEqual([]) + }) +}) + describe("syncMentionedPaths", () => { it("keeps paths still referenced in text", () => { const paths = new Set(["foo.ts", "bar.ts"]) diff --git a/packages/kilo-vscode/tests/unit/session-search.test.ts b/packages/kilo-vscode/tests/unit/session-search.test.ts index 0bae8bb682..4fac5e416d 100644 --- a/packages/kilo-vscode/tests/unit/session-search.test.ts +++ b/packages/kilo-vscode/tests/unit/session-search.test.ts @@ -35,7 +35,9 @@ describe("handleSessionSearch", () => { post: (msg) => posted.push(msg), }) - expect(calls).toEqual([{ worktrees: true, roots: true, directory: "/repo/.kilo/worktrees/wt-1", limit: 50 }]) + expect(calls).toEqual([ + { worktrees: true, roots: true, directory: "/repo/.kilo/worktrees/wt-1", limit: Number.MAX_SAFE_INTEGER }, + ]) expect(posted).toEqual([ { type: "sessionSearchResult", @@ -89,6 +91,24 @@ describe("handleSessionSearch", () => { expect(posted[0]?.sessions.map((s) => s.id)).toEqual(["ses_keep"]) }) + it.each(["/repo", "/repo/.kilo/worktrees/branch"])("loads older chats in one request from %s", async (dir) => { + const recent = Array.from({ length: 60 }, (_, index) => session(`ses_${index}`, `Recent ${index}`, 2, "branch")) + const source = session("ses_old", "Older chat", 1, "main") + const api = stub([...recent, source]) + const posted: Array<{ sessions: Array<{ id: string }> }> = [] + + await handleSessionSearch({ + client: api.client as never, + message: { requestId: "all", sessionID: "ses_current" }, + dir: (id) => (id === "ses_current" ? dir : "/wrong-project"), + post: (msg) => posted.push(msg as never), + }) + + expect(api.calls).toEqual([{ worktrees: true, roots: true, directory: dir, limit: Number.MAX_SAFE_INTEGER }]) + expect(posted).toHaveLength(1) + expect(posted[0]?.sessions.map((item) => item.id)).toEqual([...recent.map((item) => item.id), source.id]) + }) + it("posts an empty result when the client is missing or the list fails", async () => { const posted: unknown[] = [] diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/SessionMentionPicker.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/SessionMentionPicker.tsx index 7471b99610..80767cb068 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/SessionMentionPicker.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/SessionMentionPicker.tsx @@ -3,6 +3,7 @@ import { onMount } from "solid-js" import { Icon } from "@kilocode/kilo-ui/icon" import { List } from "@kilocode/kilo-ui/list" +import { filterSessions } from "../../hooks/file-mention-utils" import type { SessionSearchItem } from "../../types/messages" import { formatRelativeDate } from "../../utils/date" @@ -40,9 +41,9 @@ export function SessionMentionPicker(props: Props) { }} > - items={props.sessions} + items={(query) => filterSessions(props.sessions, query)} key={(item) => item.id} - filterKeys={["title", "worktreeName"]} + skipFilter={() => true} search={{ placeholder: "Search sessions", autofocus: true }} onSelect={(item) => { if (item) props.onSelect(item) 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..e0e481959f 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 @@ -1,3 +1,4 @@ +import fuzzysort from "fuzzysort" import type { FileAttachment, FileSearchItem, SessionSearchItem } from "../types/messages" import { GIT_CHANGES_MENTION } from "./git-changes-context-utils" import { TERMINAL_MENTION } from "./terminal-context-utils" @@ -79,6 +80,13 @@ export function buildMentionResults(query: string, items: Array item.obj) +} + /** Single-line, safe display/filename forms for a session mention. */ export function sessionMentionText(title: string) { return title.replace(/\s+/g, " ").trim() diff --git a/packages/opencode/src/kilocode/session/index.ts b/packages/opencode/src/kilocode/session/index.ts index 679b844072..43d4112311 100644 --- a/packages/opencode/src/kilocode/session/index.ts +++ b/packages/opencode/src/kilocode/session/index.ts @@ -58,7 +58,7 @@ export namespace KiloSession { } }) if (!ctx) return - Bus.publish(ctx, Event.QueueChanged, input).catch(err => log.warn("queue changed publish failed", { err })) + Bus.publish(ctx, Event.QueueChanged, input).catch((err) => log.warn("queue changed publish failed", { err })) } // --------------------------------------------------------------------------- @@ -137,16 +137,29 @@ export namespace KiloSession { rows: Array>, directories: string[] = [], ): string[] { + const resolve = (dir: string) => { + try { + return Filesystem.resolve(dir) + } catch (err) { + const code = typeof err === "object" && err !== null && "code" in err ? err.code : undefined + if (code !== "EPERM" && code !== "EACCES") throw err + log.warn("Ignoring inaccessible saved project directory", { dir, code }) + return undefined + } + } const current = rows.find((row) => row.id === id) - const root = current?.worktree ? Filesystem.resolve(current.worktree) : undefined + const root = current?.worktree ? resolve(current.worktree) : undefined // Combine the stored root with Git's current sibling worktrees. const roots = new Set([...(root && root !== "/" ? [root] : []), ...directories.map(Filesystem.resolve)]) if (roots.size === 0) return [id] // Match both each project's recorded root and its saved worktrees. const ids = rows.flatMap((row) => { - const dirs = [row.worktree, ...row.sandboxes].map(Filesystem.resolve) - return dirs.some((dir) => roots.has(dir)) ? [row.id] : [] + const match = [row.worktree, ...row.sandboxes].some((dir) => { + const value = resolve(dir) + return value !== undefined && roots.has(value) + }) + return match ? [row.id] : [] }) // Always keep the requested ID and remove duplicates. return [...new Set([id, ...ids])] diff --git a/packages/opencode/test/kilocode/server/session-mentions.test.ts b/packages/opencode/test/kilocode/server/session-mentions.test.ts new file mode 100644 index 0000000000..6de21745fa --- /dev/null +++ b/packages/opencode/test/kilocode/server/session-mentions.test.ts @@ -0,0 +1,192 @@ +import { afterEach, describe, expect, spyOn } from "bun:test" +import path from "path" +import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { ProjectV2 } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { ProjectTable } from "@opencode-ai/core/project/sql" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { Effect, Layer, Schema } from "effect" +import { eq, inArray } from "drizzle-orm" +import { Session } from "../../../src/session/session" +import { Filesystem } from "../../../src/util/filesystem" +import { resetDatabase } from "../../fixture/db" +import { disposeAllInstances, provideInstance, TestInstance, tmpdir } from "../../fixture/fixture" +import { testEffect } from "../../lib/effect" +import { httpApiLayer, requestInDirectory } from "../../server/httpapi-layer" +import { HttpClientResponse } from "effect/unstable/http" + +const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Session.node, Database.node])), httpApiLayer)) + +function request(directory: string) { + const query = new URLSearchParams({ + directory, + worktrees: "true", + roots: "true", + limit: String(Number.MAX_SAFE_INTEGER), + }) + return requestInDirectory(`/experimental/session?${query}`, directory) +} + +function json(response: HttpClientResponse.HttpClientResponse) { + return Effect.gen(function* () { + const body: unknown = yield* response.json + if (!Schema.is(Schema.Array(Session.GlobalInfo))(body)) { + return yield* Effect.fail(new Error("Invalid session metadata")) + } + return body + }) +} + +function updated(ids: Array, time: number) { + return Database.Service.use(({ db }) => + db.update(SessionTable).set({ time_updated: time }).where(inArray(SessionTable.id, ids)).run().pipe(Effect.orDie), + ) +} + +function repo() { + return Effect.acquireRelease( + Effect.promise(() => tmpdir({ git: true })), + (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()).pipe(Effect.ignore), + ) +} + +function deny(dir: string, code: "EACCES" | "EPERM") { + const real = Filesystem.resolve + const spy = spyOn(Filesystem, "resolve").mockImplementation((input) => { + if (input === dir) throw Object.assign(new Error(`cannot resolve ${dir}`), { code }) + return real(input) + }) + return Effect.addFinalizer(() => Effect.sync(() => spy.mockRestore())) +} + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +describe.serial("Kilo session mentions", () => { + it.instance( + "lists more than 50 root sessions without a cursor", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const sessions = yield* Effect.forEach(Array.from({ length: 51 }), (_, index) => + Session.use.create({ title: `session ${index}` }), + ) + yield* updated( + sessions.map((session) => session.id), + 1, + ) + + const response = yield* request(test.directory) + expect(response.status).toBe(200) + expect(response.headers["x-next-cursor"]).toBeUndefined() + const body = yield* json(response) + expect(new Set(body.map((item) => item.id))).toEqual(new Set(sessions.map((session) => session.id))) + }), + { git: true }, + 30_000, + ) + + it.instance( + "ignores an inaccessible worktree from an unrelated project", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const other = yield* repo() + const current = yield* Session.use.create({ title: "current project" }) + const unrelated = yield* Session.use.create({ title: "unrelated project" }).pipe(provideInstance(other.path)) + const denied = path.join(other.path, "denied-worktree") + const { db } = yield* Database.Service + + yield* db + .update(ProjectTable) + .set({ worktree: AbsolutePath.make(denied) }) + .where(eq(ProjectTable.id, unrelated.projectID)) + .run() + .pipe(Effect.orDie) + yield* deny(denied, "EPERM") + + const response = yield* request(test.directory) + expect(response.status).toBe(200) + const body = yield* json(response) + const ids = body.map((item) => item.id) + expect(ids).toContain(current.id) + expect(ids).not.toContain(unrelated.id) + }), + { git: true }, + ) + + it.instance( + "ignores an inaccessible saved sandbox", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const other = yield* repo() + const current = yield* Session.use.create({ title: "current project" }) + const unrelated = yield* Session.use.create({ title: "unrelated project" }).pipe(provideInstance(other.path)) + const denied = path.join(test.directory, "denied-sandbox") + const { db } = yield* Database.Service + + yield* db + .update(ProjectTable) + .set({ sandboxes: [AbsolutePath.make(denied)] }) + .where(eq(ProjectTable.id, unrelated.projectID)) + .run() + .pipe(Effect.orDie) + yield* deny(denied, "EACCES") + + const response = yield* request(test.directory) + expect(response.status).toBe(200) + const body = yield* json(response) + expect(body.map((item) => item.id)).toEqual([current.id]) + }), + { git: true }, + ) + + it.instance( + "keeps a legacy root chat when a denied sandbox precedes a current-worktree alias", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const legacy = yield* repo() + const other = yield* repo() + const current = yield* Session.use.create({ title: "current project" }) + const root = yield* Session.use.create({ title: "legacy root" }) + const unrelated = yield* Session.use.create({ title: "unrelated project" }).pipe(provideInstance(other.path)) + const denied = path.join(legacy.path, "denied-sandbox") + const project = ProjectV2.ID.make("legacy-mentions-project") + const { db } = yield* Database.Service + + yield* db + .insert(ProjectTable) + .values({ + id: project, + worktree: AbsolutePath.make(legacy.path), + vcs: "git", + time_created: Date.now(), + time_updated: Date.now(), + sandboxes: [AbsolutePath.make(denied), AbsolutePath.make(test.directory)], + }) + .run() + .pipe(Effect.orDie) + yield* db + .update(SessionTable) + .set({ project_id: project }) + .where(eq(SessionTable.id, root.id)) + .run() + .pipe(Effect.orDie) + yield* deny(denied, "EPERM") + + const response = yield* request(test.directory) + expect(response.status).toBe(200) + const body = yield* json(response) + const ids = body.map((item) => item.id) + expect(ids).toContain(current.id) + expect(ids).toContain(root.id) + expect(ids).not.toContain(unrelated.id) + }), + { git: true }, + ) +})