Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/paged-session-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
---

Load older chats on demand in the session history with a "Load more" control instead of stopping at the 100 most recent. Paging works across the workspace and every worktree directory.
31 changes: 22 additions & 9 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
MessageConfirmation,
runWithMessageConfirmation,
loadSessions as loadSessionsUtil,
loadMoreSessions as loadMoreSessionsUtil,
flushPendingSessionRefresh as flushPendingSessionRefreshUtil,
resolveContextDirectory,
resolveNewSessionDirectory,
Expand Down Expand Up @@ -75,6 +76,7 @@ import { interceptMessage } from "./kilo-provider/git-changes-request"
import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session"
import { clearCommandsCache, loadCommands } from "./kilo-provider/commands"
import { fetchMessagePage, MESSAGE_PAGE_LIMIT } from "./kilo-provider/message-page"
import { createSessionPageState, fetchSessionPage } from "./kilo-provider/session-page"
import { editPaths } from "./kilo-provider/session-edits"
import {
dismissNotification,
Expand Down Expand Up @@ -527,6 +529,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private cachedGitDirectory: string | undefined
private gitStatusRevision = 0
private sessionRefreshRevision = 0
private sessionPages = createSessionPageState()

private onBeforeMessage: ((msg: Record<string, unknown>) => Promise<Record<string, unknown> | null>) | null = null

Expand Down Expand Up @@ -1290,7 +1293,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
})
break
case "loadSessions":
this.handleLoadSessions().catch((e) => console.error("[Kilo New] handleLoadSessions failed:", e))
this.handleLoadSessions(message.more === true).catch((e) =>
console.error("[Kilo New] handleLoadSessions failed:", e),
)
break
case "requestSessionModelUsage":
void this.fetchAndSendSessionModelUsage(message.sessionID, message.requestID)
Expand Down Expand Up @@ -2437,6 +2442,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
? (dir: string) =>
client.session.list({ directory: dir, roots: true }, { throwOnError: true }).then(({ data }) => data)
: null,
listSessionPage: client ? (dir: string, cursor?: number) => fetchSessionPage(client, { dir, cursor }) : null,
page: this.sessionPages,
sessionDirectories: this.sessionDirectories,
worktreeDirectories: this.opts.worktreeDirectories,
workspaceDirectory: this.getWorkspaceDirectory(),
Expand Down Expand Up @@ -2467,20 +2474,26 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
/**
* Handle loading all sessions.
*/
private async handleLoadSessions(): Promise<void> {
private async handleLoadSessions(more = false): Promise<void> {
const revision = ++this.sessionRefreshRevision
const scope = this.opts.projectQualifier?.()?.projectId
if (scope !== undefined) this.projectID = undefined
if (!more && scope !== undefined) this.projectID = undefined
const ctx = this.getSessionRefreshContext(revision)
try {
const resolved = await loadSessionsUtil(ctx)
if (resolved && scope === this.opts.projectQualifier?.()?.projectId) this.projectID = resolved
if (more) {
await loadMoreSessionsUtil(ctx)
} else {
const resolved = await loadSessionsUtil(ctx)
if (resolved && scope === this.opts.projectQualifier?.()?.projectId) this.projectID = resolved
}
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to load sessions:", error)
this.postMessage({
type: "error",
message: getErrorMessage(error) || "Failed to load sessions",
})
if (!more) {
this.postMessage({
type: "error",
message: getErrorMessage(error) || "Failed to load sessions",
})
}
}
this.pendingSessionRefresh = ctx.pendingSessionRefresh
}
Expand Down
124 changes: 93 additions & 31 deletions packages/kilo-vscode/src/kilo-provider-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import { prettifyError } from "zod/v4"
import type { CloudSessionMessage, IndexingStatus } from "./services/cli-backend/types"
import type { PartBatch, PartUpdate } from "./kilo-provider/session-stream-scheduler"
import type { PartRemove } from "./shared/stream-messages"
import {
createSessionPageState,
mergeSessions,
type SessionPage,
type SessionPageState,
} from "./kilo-provider/session-page"
import * as path from "path"

export { SessionStreamScheduler } from "./kilo-provider/session-stream-scheduler"
Expand Down Expand Up @@ -254,54 +260,106 @@ export interface SessionRefreshContext {
pendingSessionRefresh: boolean
connectionState: "connecting" | "connected" | "disconnected" | "error"
listSessions: ((dir: string) => Promise<Session[]>) | null
listSessionPage?: ((dir: string, cursor?: number) => Promise<SessionPage>) | null
page?: SessionPageState
sessionDirectories: Map<string, string>
worktreeDirectories?: () => string[]
workspaceDirectory: string
isCurrent?: () => boolean
postMessage(message: unknown): void
}

/** Prefer the paged lister, and fall back to wrapping a plain session list. */
function pageSource(ctx: SessionRefreshContext): ((dir: string, cursor?: number) => Promise<SessionPage>) | null {
if (ctx.listSessionPage) return ctx.listSessionPage
const list = ctx.listSessions
if (!list) return null
return (dir) => list(dir).then((sessions) => ({ sessions }))
}

/** Workspace root plus every registered worktree directory, deduplicated. */
function sessionDirs(ctx: SessionRefreshContext): string[] {
const dirs = [ctx.workspaceDirectory]
const seen = new Set(dirs)
const extra = ctx.worktreeDirectories ? ctx.worktreeDirectories() : [...ctx.sessionDirectories.values()]
for (const dir of extra) {
if (seen.has(dir)) continue
seen.add(dir)
dirs.push(dir)
}
return dirs
}

async function listPages(
list: (dir: string, cursor?: number) => Promise<SessionPage>,
targets: Array<{ dir: string; cursor?: number }>,
) {
const failed = new Set<string>()
const results = await Promise.all(
targets.map((target) =>
list(target.dir, target.cursor).catch((err: unknown) => {
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
console.error(`[Kilo] Failed to list sessions for ${target.dir}:`, err)
failed.add(target.dir)
return undefined
}),
),
)
return { results, failed }
}

/**
* Load sessions from the workspace and all registered worktree directories.
* Sets pendingSessionRefresh when the HTTP client isn't ready yet.
* Returns the resolved projectID (if any) so the caller can update its own state.
* Load one page of sessions from the workspace and all registered worktree
* directories. `more` continues from the per-directory cursors and appends;
* otherwise it starts over and reconciles. Sets pendingSessionRefresh when the
* HTTP client isn't ready yet. Returns the resolved projectID (if any).
*/
export async function loadSessions(ctx: SessionRefreshContext): Promise<string | undefined> {
const list = ctx.listSessions
async function loadPage(ctx: SessionRefreshContext, more: boolean): Promise<string | undefined> {
const list = pageSource(ctx)
if (!list) {
ctx.pendingSessionRefresh = true
if (ctx.connectionState !== "connecting") {
if (!more && ctx.connectionState !== "connecting") {
ctx.postMessage({ type: "error", message: "Not connected to CLI backend" })
}
return
}
if (!more) ctx.pendingSessionRefresh = false

const page = ctx.page ?? (ctx.page = createSessionPageState())
const targets = more
? [...page.dirs.entries()].filter(([, entry]) => entry.more).map(([dir, entry]) => ({ dir, cursor: entry.cursor }))
: sessionDirs(ctx).map((dir) => ({ dir }))
if (more && targets.length === 0) {
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
page.hasMore = false
return
}
if (!more) page.dirs = new Map()

ctx.pendingSessionRefresh = false
const { results, failed } = await listPages(list, targets)
if (ctx.isCurrent && !ctx.isCurrent()) return

const sessions = await list(ctx.workspaceDirectory)
const projectID = sessions[0]?.projectID
const worktreeDirs = new Set(ctx.worktreeDirectories ? ctx.worktreeDirectories() : ctx.sessionDirectories.values())
const failed = new Set<string>()
const extra = await Promise.all(
[...worktreeDirs].map((dir) =>
list(dir).catch((err: unknown) => {
console.error(`[Kilo] Failed to list sessions for ${dir}:`, err)
failed.add(dir)
return [] as Session[]
}),
),
)
const seen = new Set(sessions.map((s) => s.id))
for (const batch of extra) {
for (const s of batch) {
if (seen.has(s.id)) continue
sessions.push(s)
seen.add(s.id)
const batches: Session[][] = []
targets.forEach((target, index) => {
const result = results[index]
if (!result) {
// Keep the directory pageable so a later load-more retries it.
if (!more) page.dirs.set(target.dir, { more: true })
return
}
batches.push(result.sessions)
page.dirs.set(target.dir, { cursor: result.cursor, more: result.cursor != null })
})
page.hasMore = [...page.dirs.values()].some((entry) => entry.more)

if (more) {
ctx.postMessage({
type: "sessionsLoaded",
sessions: mergeSessions(batches).map((s) => sessionToWebview(s)),
append: true,
hasMore: page.hasMore,
})
return
}

if (ctx.isCurrent && !ctx.isCurrent()) return

// Sessions whose worktree directories failed to list — the webview must
// not delete these during reconciliation since the absence is transient.
const preserve: string[] = []
Expand All @@ -313,20 +371,24 @@ export async function loadSessions(ctx: SessionRefreshContext): Promise<string |

ctx.postMessage({
type: "sessionsLoaded",
sessions: sessions.map((s) => sessionToWebview(s)),
sessions: mergeSessions(batches).map((s) => sessionToWebview(s)),
...(preserve.length ? { preserveSessionIds: preserve } : {}),
hasMore: page.hasMore,
})

return projectID
return results[0]?.sessions[0]?.projectID
}

export const loadSessions = (ctx: SessionRefreshContext) => loadPage(ctx, false)
export const loadMoreSessions = (ctx: SessionRefreshContext) => loadPage(ctx, true)

/**
* Flush a deferred session refresh when the HTTP client becomes available.
*/
export async function flushPendingSessionRefresh(ctx: SessionRefreshContext): Promise<string | undefined> {
if (!ctx.pendingSessionRefresh) return

if (!ctx.listSessions) {
if (!pageSource(ctx)) {
if (ctx.connectionState === "connecting") return
ctx.postMessage({ type: "error", message: "Not connected to CLI backend" })
return
Expand Down
65 changes: 65 additions & 0 deletions packages/kilo-vscode/src/kilo-provider/session-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { KiloClient, Session } from "@kilocode/sdk/v2/client"
import { retry } from "../services/cli-backend/retry"

export const SESSION_PAGE_LIMIT = 50

/** One page of sessions for a single directory plus the cursor for the next page. */
export interface SessionPage {
sessions: Session[]
cursor?: number
}

/** Per-directory paging state shared between the initial load and "load more". */
export interface SessionPageState {
dirs: Map<string, { cursor?: number; more: boolean }>
hasMore: boolean
}

export function createSessionPageState(): SessionPageState {
return { dirs: new Map(), hasMore: false }
}

/**
* Fetch one page of sessions for a directory. Uses the existing paged
* experimental endpoint so no shared upstream session API changes are needed.
* Older pages use the returned cursor, which is the `time_updated` of the
* oldest session in the page.
*/
export async function fetchSessionPage(
client: KiloClient,
input: { dir: string; cursor?: number; limit?: number },
): Promise<SessionPage> {
const limit = input.limit ?? SESSION_PAGE_LIMIT
const result = await retry(() =>
client.experimental.session.list(
{ directory: input.dir, roots: true, archived: true, limit, cursor: input.cursor },
{ throwOnError: true },
),
)
const items = result.data ?? []
// When a proxy strips x-next-cursor but the page is full, synthesize the
// cursor from the oldest item so "load more" keeps working.
const header = result.response.headers.get("x-next-cursor")
const parsed = header == null ? undefined : Number(header)
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
const cursor =
parsed != null && Number.isFinite(parsed)
? parsed
: items.length >= limit && items.length > 0
? items[items.length - 1]!.time.updated
Comment thread
marius-kilocode marked this conversation as resolved.
Resolved
: undefined
return { sessions: items, cursor }
}

/** Merge session batches, drop duplicate ids, and sort newest first. */
export function mergeSessions(batches: Session[][]): Session[] {
const seen = new Set<string>()
const merged: Session[] = []
for (const batch of batches) {
for (const session of batch) {
if (seen.has(session.id)) continue
seen.add(session.id)
merged.push(session)
}
}
return merged.sort((a, b) => b.time.updated - a.time.updated)
}
8 changes: 4 additions & 4 deletions packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1048,8 +1048,8 @@ describe("KiloProvider — pending session refresh on reconnect", () => {
* the pending refresh.
*/
it("loadSessions sets pendingSessionRefresh when client is null", () => {
const start = utils.indexOf("export async function loadSessions")
expect(start, "loadSessions must exist in kilo-provider-utils").toBeGreaterThan(-1)
const start = utils.indexOf("async function loadPage")
expect(start, "loadPage must exist in kilo-provider-utils").toBeGreaterThan(-1)
const snippet = utils.slice(start, start + 700)
expect(snippet, "must set pendingSessionRefresh when client missing").toContain("ctx.pendingSessionRefresh = true")
expect(snippet, "must avoid noisy errors while still connecting").toContain('ctx.connectionState !== "connecting"')
Expand All @@ -1059,9 +1059,9 @@ describe("KiloProvider — pending session refresh on reconnect", () => {
})

it("handleLoadSessions delegates to loadSessionsUtil", () => {
const start = provider.indexOf("private async handleLoadSessions()")
const start = provider.indexOf("private async handleLoadSessions(")
expect(start, "handleLoadSessions must exist").toBeGreaterThan(-1)
const snippet = provider.slice(start, start + 400)
const snippet = provider.slice(start, start + 700)
expect(snippet, "must call loadSessionsUtil").toContain("loadSessionsUtil")
})

Expand Down
Loading
Loading