mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 04:46:43 +08:00
fix(vscode): restore past session references
This commit is contained in:
@@ -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.
|
||||
@@ -44,7 +44,7 @@ export async function handleSessionSearch(input: Input): Promise<void> {
|
||||
|
||||
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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
|
||||
@@ -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) {
|
||||
}}
|
||||
>
|
||||
<List<SessionSearchItem>
|
||||
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)
|
||||
|
||||
@@ -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<FileSearchItem |
|
||||
]
|
||||
}
|
||||
|
||||
export function filterSessions(sessions: SessionSearchItem[], query: string) {
|
||||
if (!query) return sessions.slice(0, 50)
|
||||
return fuzzysort
|
||||
.go(query.toLowerCase(), sessions, { keys: ["title", "worktreeName"], limit: 50 })
|
||||
.map((item) => item.obj)
|
||||
}
|
||||
|
||||
/** Single-line, safe display/filename forms for a session mention. */
|
||||
export function sessionMentionText(title: string) {
|
||||
return title.replace(/\s+/g, " ").trim()
|
||||
|
||||
@@ -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<Pick<typeof ProjectTable.$inferSelect, "id" | "worktree" | "sandboxes">>,
|
||||
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])]
|
||||
|
||||
@@ -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<Session.Info["id"]>, 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 },
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user