mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge branch 'main' into docs/kiloclaw-setting-up-other-tools
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix a native memory leak on Windows where `kilo serve` would grow to several GB of RAM within minutes of opening the Agent Manager. Git diff polling now runs directly in the extension host instead of routing through the CLI subprocess, and the diff detail view caps per-file reads at 20 MB to prevent memory spikes when opening very large files.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
"@opencode-ai/ui": patch
|
||||
---
|
||||
|
||||
Make switching between sessions in Agent Manager near-instant. Long sessions no longer freeze the UI when selected, and the chat view self-heals if it missed any messages while the session was in the background.
|
||||
@@ -254,89 +254,25 @@ jobs:
|
||||
# APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
||||
|
||||
# kilocode_change start
|
||||
# Smoke test disabled as a release gate due to infrastructure issues.
|
||||
# The job is skipped via `if: false` so it no longer blocks publishing.
|
||||
# Re-enable by restoring the original `if:` condition and uncommenting
|
||||
# `- smoke-test` in the publish job's `needs` list below.
|
||||
# Run smoke tests against CLI assets uploaded to the draft GitHub release
|
||||
# before publishing the release and package artifacts.
|
||||
smoke-test:
|
||||
name: Smoke Test (pre-publish gate) [DISABLED]
|
||||
name: Smoke Test (pre-publish gate)
|
||||
needs:
|
||||
- version
|
||||
- build-cli
|
||||
if: false # was: github.repository == 'Kilo-Org/kilocode'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Trigger kilo-bench smoke test
|
||||
id: trigger
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.BENCH_GITHUB_TOKEN }}
|
||||
run: |
|
||||
BEFORE=$(date -u -d '60 seconds ago' +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
gh api repos/Kilo-Org/kilo-bench/dispatches \
|
||||
--method POST \
|
||||
-f event_type=smoke-test \
|
||||
-f 'client_payload[release_tag]=${{ needs.version.outputs.tag }}' \
|
||||
-f 'client_payload[source_run_id]=${{ github.run_id }}'
|
||||
|
||||
# Poll for the run created after our dispatch timestamp.
|
||||
# The dispatch API returns no run ID, so we query by created time
|
||||
# and pick the oldest match to avoid grabbing an unrelated run.
|
||||
echo "Waiting for smoke-test run to appear (dispatched after $BEFORE)..."
|
||||
for attempt in $(seq 1 30); do
|
||||
RUN_ID=$(gh api \
|
||||
"repos/Kilo-Org/kilo-bench/actions/workflows/smoke-test.yml/runs?event=repository_dispatch&created=>=$BEFORE" \
|
||||
--jq '.workflow_runs | sort_by(.created_at) | .[0].id // empty')
|
||||
|
||||
if [[ -n "$RUN_ID" && "$RUN_ID" != "null" ]]; then
|
||||
echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Smoke test run: https://github.com/Kilo-Org/kilo-bench/actions/runs/$RUN_ID"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo " attempt $attempt: run not yet registered, retrying in 10s..."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo "::error::Smoke test run did not appear within 5 minutes after dispatch."
|
||||
exit 1
|
||||
|
||||
- name: Wait for smoke test to complete
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.BENCH_GITHUB_TOKEN }}
|
||||
run: |
|
||||
RUN_ID="${{ steps.trigger.outputs.run_id }}"
|
||||
echo "Waiting for run $RUN_ID..."
|
||||
|
||||
for i in $(seq 1 60); do
|
||||
CONCLUSION=$(gh run view "$RUN_ID" \
|
||||
--repo Kilo-Org/kilo-bench \
|
||||
--json conclusion \
|
||||
--jq '.conclusion')
|
||||
|
||||
echo " attempt $i: $CONCLUSION"
|
||||
|
||||
if [[ "$CONCLUSION" == "success" ]]; then
|
||||
echo "::notice::Smoke test passed."
|
||||
exit 0
|
||||
elif [[ "$CONCLUSION" != "null" && "$CONCLUSION" != "" ]]; then
|
||||
echo "::error::Smoke test failed with conclusion: $CONCLUSION"
|
||||
echo "See: https://github.com/Kilo-Org/kilo-bench/actions/runs/$RUN_ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 30
|
||||
done
|
||||
|
||||
echo "::error::Smoke test did not complete within 30 minutes."
|
||||
exit 1
|
||||
if: github.repository == 'Kilo-Org/kilocode'
|
||||
uses: ./.github/workflows/smoke-test.yml
|
||||
with:
|
||||
cli_version: ${{ needs.version.outputs.version }}
|
||||
secrets: inherit
|
||||
# kilocode_change end
|
||||
publish:
|
||||
needs:
|
||||
- version
|
||||
- build-cli
|
||||
- build-vscode
|
||||
# - smoke-test # disabled: infrastructure issues (see smoke-test job comment)
|
||||
- smoke-test
|
||||
# - build-tauri
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
#
|
||||
# Triggers:
|
||||
# - workflow_dispatch: manually from Actions tab (optionally pass a CLI version)
|
||||
# - push to main: automatically after every merge
|
||||
# - workflow_call: from publish.yml after draft release assets are uploaded
|
||||
#
|
||||
# Required secrets:
|
||||
# KILO_API_KEY — Kilo Gateway key
|
||||
@@ -23,6 +23,12 @@ on:
|
||||
description: "CLI version to test (e.g. 7.0.36). Leave blank for latest npm release."
|
||||
required: false
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
cli_version:
|
||||
description: "CLI version to test from draft release assets."
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: smoke-test
|
||||
|
||||
@@ -203,6 +203,8 @@ We regularly merge upstream changes from opencode. To minimize merge conflicts a
|
||||
|
||||
4. **Avoid restructuring upstream code** - Don't refactor or reorganize code that comes from opencode unless absolutely necessary.
|
||||
|
||||
5. **Mirror new config keys to the cloud schema** - When adding a `kilocode_change` key to `Config.Info` in `packages/opencode/src/config/config.ts`, also add the matching JSON Schema entry in `apps/web/src/app/config.json/extras.ts` in the [cloud repo](https://github.com/Kilo-Org/cloud). See [CLI Config Schema](packages/kilo-docs/pages/contributing/architecture/config-schema.md) for the step-by-step.
|
||||
|
||||
The goal is to keep our diff from upstream as small as possible, making regular merges straightforward and reducing the risk of conflicts.
|
||||
|
||||
### Kilocode Change Markers
|
||||
|
||||
@@ -322,6 +322,7 @@
|
||||
"simple-git": "3.35.2",
|
||||
"solid-js": "^1.9.11",
|
||||
"uri-js": "^4.4.1",
|
||||
"virtua": "catalog:",
|
||||
"web-tree-sitter": "^0.24.7",
|
||||
"yaml": "2.8.3",
|
||||
"zod": "^3.24.2",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-B8kyomBd8nlqFG+9idfs+y8P1M+4eVUBP3sDJDh6upw=",
|
||||
"aarch64-linux": "sha256-Xzcrz1R3Gunp0aVgNpEVPLd+SahV8wyuwgUgVNOTgfI=",
|
||||
"aarch64-darwin": "sha256-5Yq09XbErOYRsO+DOqOYAUmz2go7kAQFnLvTXb2CDcc=",
|
||||
"x86_64-darwin": "sha256-c4Cxv5jFTbb1v56c9I18SgGaigtgHeeyEwfn7QjCUdY="
|
||||
"x86_64-linux": "sha256-eEuIR+GbjhIU5+LMlqYSMlP+8K1jhMdqkH5x+IN4gN8=",
|
||||
"aarch64-linux": "sha256-SBL6g8ad7apxtRH865XOVObm4krJS2whvLHERuliSKU=",
|
||||
"aarch64-darwin": "sha256-gf5MCF06yN6JQCtsWlMcFfMU1BpK2DFXgo1hK+ZPeT4=",
|
||||
"x86_64-darwin": "sha256-ySFUIToMSy9vLzX8s/A7BwO+qrtRlxleJziWmikgePw="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,10 @@ export const ContributingNav: NavSection[] = [
|
||||
href: "/contributing/architecture/benchmarking",
|
||||
children: "Benchmarking",
|
||||
},
|
||||
{
|
||||
href: "/contributing/architecture/config-schema",
|
||||
children: "CLI Config Schema",
|
||||
},
|
||||
{
|
||||
href: "/contributing/architecture/enterprise-mcp-controls",
|
||||
children: "Enterprise MCP Controls",
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: "CLI Config Schema"
|
||||
description: "How the Kilo CLI config JSON Schema is served at app.kilo.ai/config.json"
|
||||
---
|
||||
|
||||
# CLI Config Schema
|
||||
|
||||
The JSON Schema referenced by `"$schema": "https://app.kilo.ai/config.json"` in `kilo.json` files is served by the cloud repo. It is a runtime overlay of the upstream opencode schema with Kilo-specific additions on top.
|
||||
|
||||
## Flow
|
||||
|
||||
1. Client fetches `https://app.kilo.ai/config.json`.
|
||||
2. Cloud route `apps/web/src/app/config.json/route.ts` fetches `https://opencode.ai/config.json`, runs `merge()` on it, and returns the result.
|
||||
3. `merge()` overlays three sections from `apps/web/src/app/config.json/extras.ts`:
|
||||
- `top` — top-level keys like `commit_message`, `remote_control`, nullable `model` / `small_model`
|
||||
- `agents` — Kilo primary agents (`ask`, `debug`, `orchestrator`)
|
||||
- `experimental` — `codebase_search`, `openTelemetry`
|
||||
|
||||
## Adding a new Kilo-only config key
|
||||
|
||||
The source of truth is the zod schema in `packages/opencode/src/config/config.ts`. The cloud overlay must match it.
|
||||
|
||||
1. Add the zod field with a `kilocode_change` marker in `config.ts`.
|
||||
2. Generate the JSON Schema shape: `bun --bun packages/opencode/script/schema.ts /tmp/kilo.json`, then `jq '.properties.<new_key>' /tmp/kilo.json`.
|
||||
3. Paste the shape into the correct bucket in `apps/web/src/app/config.json/extras.ts` in the [cloud repo](https://github.com/Kilo-Org/cloud).
|
||||
- Top-level → `top`; under `experimental` → `experimental`; new primary agent → `agents`; anywhere else → add a new bucket and extend `merge()` in `route.ts`.
|
||||
4. Add an assertion in `apps/web/src/tests/cli-config-schema.test.ts`.
|
||||
|
||||
If step 3 is skipped, users with `$schema: https://app.kilo.ai/config.json` will see "unknown property" warnings for the new key.
|
||||
|
||||
## Caching
|
||||
|
||||
The cloud route caches the upstream fetch for 1 hour (`next: { revalidate: 3600 }`) and emits `s-maxage=3600, stale-while-revalidate=3600`, so the response is served from the Cloudflare + Vercel edge cache for all but one request per hour per region.
|
||||
@@ -880,6 +880,7 @@
|
||||
"simple-git": "3.35.2",
|
||||
"solid-js": "^1.9.11",
|
||||
"uri-js": "^4.4.1",
|
||||
"virtua": "catalog:",
|
||||
"web-tree-sitter": "^0.24.7",
|
||||
"yaml": "2.8.3",
|
||||
"zod": "^3.24.2"
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "./kilo-provider-utils"
|
||||
import { GitOps } from "./agent-manager/GitOps"
|
||||
import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller"
|
||||
import { diffSummary as localDiffSummary } from "./agent-manager/local-diff"
|
||||
import { getWorkspaceRoot } from "./review-utils"
|
||||
import { MarketplaceService, type MarketplaceItem, type RemoveResult } from "./services/marketplace"
|
||||
import type { RemoteStatusService } from "./services/RemoteStatusService"
|
||||
@@ -49,6 +50,8 @@ import { parseMessageFiles, type MessageFile } from "./kilo-provider/message-fil
|
||||
import { handleFileSearch } from "./kilo-provider/file-search"
|
||||
import { getTerminalContents } from "./services/terminal/context"
|
||||
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 { childID } from "./kilo-provider/task-session"
|
||||
import { handleNetworkEvent, clearNetworkWaits } from "./kilo-provider/network"
|
||||
import { abortSession, parseQueued } from "./kilo-provider/abort"
|
||||
@@ -110,6 +113,8 @@ type KiloProviderOptions = {
|
||||
slimEditMetadata?: boolean
|
||||
}
|
||||
|
||||
type MessageLoadMode = "replace" | "prepend" | "focus" | "reconcile"
|
||||
|
||||
// Helper to map agent data to the subset of fields sent to the webview
|
||||
const mapAgent = (a: Agent) => ({
|
||||
name: a.name,
|
||||
@@ -172,6 +177,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
private projectID: string | undefined
|
||||
/** Abort controller for the current loadMessages request; aborted when a new session is selected. */
|
||||
private loadMessagesAbort: AbortController | null = null
|
||||
/** Per-session last focus-mode reconcile timestamp — throttles rapid tab switching. */
|
||||
private lastReconciledAt = new Map<string, number>()
|
||||
/** Set when refreshSessions() is called before the client is ready.
|
||||
* Cleared and retried once the connection transitions to "connected". */
|
||||
private pendingSessionRefresh = false
|
||||
@@ -453,6 +460,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.trackedSessionIds.add(sessionId)
|
||||
}
|
||||
|
||||
public loadMessages(sessionID: string): Promise<void> {
|
||||
// Sub-agent viewer: full transcript (no "load earlier" UI, no pagination).
|
||||
return this.handleLoadMessages(sessionID, { limit: 0 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a directory override for a session (e.g., worktree path).
|
||||
* When set, all operations for this session use this directory instead of the workspace root.
|
||||
@@ -638,7 +650,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
case "loadMessages":
|
||||
// Don't await: allow parallel loads so rapid session switching
|
||||
// isn't blocked by slow responses for earlier sessions.
|
||||
void this.handleLoadMessages(message.sessionID)
|
||||
void this.handleLoadMessages(message.sessionID, {
|
||||
mode: message.mode,
|
||||
before: message.before,
|
||||
limit: message.limit,
|
||||
})
|
||||
break
|
||||
case "syncSession":
|
||||
this.handleSyncSession(message.sessionID, message.parentSessionID).catch((e) =>
|
||||
@@ -1273,109 +1289,103 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle loading messages for a session.
|
||||
*/
|
||||
private async handleLoadMessages(sessionID: string): Promise<void> {
|
||||
// Track the session so we receive its SSE events
|
||||
this.trackedSessionIds.add(sessionID)
|
||||
this.focusSession(sessionID)
|
||||
this.contextSessionID = sessionID
|
||||
|
||||
if (!this.client) {
|
||||
this.postMessage({
|
||||
type: "error",
|
||||
message: "Not connected to CLI backend",
|
||||
sessionID,
|
||||
/** Non-blocking: refresh session metadata + status for the webview after switching. */
|
||||
private refreshSessionDetails(sessionID: string, dir: string, signal?: AbortSignal): void {
|
||||
if (!this.client) return
|
||||
this.client.session
|
||||
.get({ sessionID, directory: dir })
|
||||
.then((r) => {
|
||||
if (r.data && !signal?.aborted) {
|
||||
this.currentSession = r.data
|
||||
this.contextSessionID = r.data.id
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => console.warn("[Kilo New] KiloProvider: getSession failed (non-critical):", e))
|
||||
this.postMessage({ type: "workspaceDirectoryChanged", directory: this.getWorkspaceDirectory(sessionID) })
|
||||
this.client.session
|
||||
.status({ directory: dir })
|
||||
.then((r) => {
|
||||
if (!r.data || signal?.aborted) return
|
||||
for (const [sid, info] of Object.entries(r.data) as [string, SessionStatus][]) {
|
||||
if (!this.trackedSessionIds.has(sid)) continue
|
||||
this.postMessage({
|
||||
type: "sessionStatus",
|
||||
sessionID: sid,
|
||||
status: info.type,
|
||||
...(info.type === "retry" ? { attempt: info.attempt, message: info.message, next: info.next } : {}),
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => console.error("[Kilo New] KiloProvider: Failed to fetch session statuses:", e))
|
||||
}
|
||||
|
||||
private async handleLoadMessages(
|
||||
sessionID: string,
|
||||
options: { mode?: MessageLoadMode; before?: string; limit?: number } = {},
|
||||
): Promise<void> {
|
||||
const mode = options.mode ?? "replace"
|
||||
if (mode !== "prepend") {
|
||||
this.trackedSessionIds.add(sessionID)
|
||||
this.focusSession(sessionID)
|
||||
this.contextSessionID = sessionID
|
||||
}
|
||||
if (!this.client) {
|
||||
this.postMessage({ type: "error", message: "Not connected to CLI backend", sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
// Abort any previous in-flight loadMessages request so the backend
|
||||
// isn't overwhelmed when the user switches sessions rapidly.
|
||||
this.loadMessagesAbort?.abort()
|
||||
const abort = new AbortController()
|
||||
this.loadMessagesAbort = abort
|
||||
|
||||
const dir = this.getWorkspaceDirectory(sessionID)
|
||||
if (mode === "focus") {
|
||||
this.refreshSessionDetails(sessionID, dir)
|
||||
// Reconcile tail so SSE drops self-heal. Throttled to skip rapid tab-switching bursts.
|
||||
if (Date.now() - (this.lastReconciledAt.get(sessionID) ?? 0) < 1000) return
|
||||
await this.handleLoadMessages(sessionID, { mode: "reconcile", limit: options.limit ?? MESSAGE_PAGE_LIMIT })
|
||||
return
|
||||
}
|
||||
// Replace competes for the spinner and cancels earlier loads; prepend/reconcile run in parallel.
|
||||
const abort = mode === "replace" ? new AbortController() : undefined
|
||||
if (abort) {
|
||||
this.loadMessagesAbort?.abort()
|
||||
this.loadMessagesAbort = abort
|
||||
this.refreshSessionDetails(sessionID, dir, abort.signal)
|
||||
}
|
||||
try {
|
||||
const workspaceDir = this.getWorkspaceDirectory(sessionID)
|
||||
const { data: messagesData } = await retry(() =>
|
||||
this.client!.session.messages(
|
||||
{ sessionID, directory: workspaceDir },
|
||||
{ throwOnError: true, signal: abort.signal },
|
||||
),
|
||||
)
|
||||
|
||||
// If this request was aborted while awaiting, skip posting stale results
|
||||
if (abort.signal.aborted) return
|
||||
|
||||
// Update currentSession so fallback logic in handleSendMessage/handleAbort
|
||||
// references the correct session after switching. loadMessages is the
|
||||
// canonical "user switched to this session" signal, so always update —
|
||||
// the old guard `this.currentSession.id === sessionID` prevented updates
|
||||
// when switching between different sessions.
|
||||
// Non-blocking: don't let a failure here prevent messages from loading.
|
||||
// 404s are expected for cross-worktree sessions — use silent to suppress HTTP error logs.
|
||||
this.client.session
|
||||
.get({ sessionID, directory: workspaceDir })
|
||||
.then((result) => {
|
||||
if (result.data && !abort.signal.aborted) {
|
||||
this.currentSession = result.data
|
||||
this.contextSessionID = result.data.id
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => console.warn("[Kilo New] KiloProvider: getSession failed (non-critical):", err))
|
||||
|
||||
this.postMessage({
|
||||
type: "workspaceDirectoryChanged",
|
||||
directory: this.getWorkspaceDirectory(sessionID),
|
||||
const page = await fetchMessagePage(this.client, {
|
||||
sessionID,
|
||||
workspaceDir: dir,
|
||||
limit: options.limit ?? MESSAGE_PAGE_LIMIT,
|
||||
before: options.before,
|
||||
signal: abort?.signal,
|
||||
})
|
||||
|
||||
// Fetch current session status so the webview has the correct busy/idle
|
||||
// state after switching tabs (SSE events may have been missed).
|
||||
this.client.session
|
||||
.status({ directory: workspaceDir })
|
||||
.then((result) => {
|
||||
if (!result.data) return
|
||||
for (const [sid, info] of Object.entries(result.data) as [string, SessionStatus][]) {
|
||||
if (!this.trackedSessionIds.has(sid)) continue
|
||||
this.postMessage({
|
||||
type: "sessionStatus",
|
||||
sessionID: sid,
|
||||
status: info.type,
|
||||
...(info.type === "retry" ? { attempt: info.attempt, message: info.message, next: info.next } : {}),
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => console.error("[Kilo New] KiloProvider: Failed to fetch session statuses:", err))
|
||||
|
||||
const messages = messagesData.map((m) => ({
|
||||
if (abort?.signal.aborted) return
|
||||
// Drop results for a session deleted mid-fetch. Prepend/reconcile have
|
||||
// no abort controller, so this guard prevents ghost entries.
|
||||
if (!this.trackedSessionIds.has(sessionID)) return
|
||||
const messages = page.items.map((m) => ({
|
||||
...m.info,
|
||||
parts: this.slimParts(m.parts),
|
||||
createdAt: new Date(m.info.time.created).toISOString(),
|
||||
}))
|
||||
|
||||
for (const message of messages) {
|
||||
this.connectionService.recordMessageSessionId(message.id, message.sessionID)
|
||||
}
|
||||
|
||||
// Snapshot must reflect every SSE event up to its taken-time; any
|
||||
// delta still queued here is either already applied in the snapshot
|
||||
// (re-emitting would duplicate streamed text) or trails the snapshot
|
||||
// and is silently lost via drop().
|
||||
this.streams.drop(sessionID)
|
||||
this.postMessage({ type: "messagesLoaded", sessionID, messages })
|
||||
// Authoritative snapshot: drop queued deltas. Prepend is older history
|
||||
// and must not clobber live deltas.
|
||||
if (mode === "replace" || mode === "reconcile") this.streams.drop(sessionID)
|
||||
if (mode === "reconcile") this.lastReconciledAt.set(sessionID, Date.now())
|
||||
this.postMessage({
|
||||
type: "messagesLoaded",
|
||||
sessionID,
|
||||
messages,
|
||||
mode,
|
||||
cursor: page.cursor,
|
||||
hasMore: Boolean(page.cursor),
|
||||
})
|
||||
// Recover any prompts missed while the webview was loading or during an SSE reconnection.
|
||||
this.recoverPendingPrompts()
|
||||
} catch (error) {
|
||||
// Silently ignore aborted requests — the user switched to a different session
|
||||
if (abort.signal.aborted) return
|
||||
if (abort?.signal.aborted) return
|
||||
console.error("[Kilo New] KiloProvider: Failed to load messages:", error)
|
||||
this.postMessage({
|
||||
type: "error",
|
||||
message: getErrorMessage(error) || "Failed to load messages",
|
||||
sessionID,
|
||||
})
|
||||
this.postMessage({ type: "error", message: getErrorMessage(error) || "Failed to load messages", sessionID })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1420,7 +1430,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
// Snapshot supersedes any queued deltas (see handleLoadMessages for the
|
||||
// snapshot-freshness assumption that governs drop() here).
|
||||
this.streams.drop(sessionID)
|
||||
this.postMessage({ type: "messagesLoaded", sessionID, messages })
|
||||
this.postMessage({
|
||||
type: "messagesLoaded",
|
||||
sessionID,
|
||||
messages,
|
||||
mode: "replace",
|
||||
hasMore: false,
|
||||
})
|
||||
|
||||
// Recover any prompts emitted by the child before we started tracking it.
|
||||
this.recoverPendingPrompts()
|
||||
} catch (err) {
|
||||
@@ -1516,6 +1533,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.streams.drop(sessionID)
|
||||
this.syncedChildSessions.delete(sessionID)
|
||||
this.sessionDirectories.delete(sessionID)
|
||||
this.lastReconciledAt.delete(sessionID)
|
||||
this.connectionService.pruneSession(sessionID)
|
||||
if (this.currentSession?.id === sessionID) {
|
||||
this.currentSession = null
|
||||
@@ -1735,6 +1753,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
}
|
||||
|
||||
private clearCommandsCache(): void {
|
||||
this.cachedCommandsMessage = null
|
||||
clearCommandsCache()
|
||||
}
|
||||
|
||||
private async fetchAndSendCommands(): Promise<void> {
|
||||
if (!this.client) {
|
||||
if (this.cachedCommandsMessage) {
|
||||
@@ -1745,19 +1768,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
|
||||
try {
|
||||
const dir = this.getWorkspaceDirectory()
|
||||
const { data: commands } = await retry(() =>
|
||||
this.client!.command.list({ directory: dir }, { throwOnError: true }),
|
||||
)
|
||||
const message = await loadCommands(this.client, dir)
|
||||
|
||||
const message = {
|
||||
type: "commandsLoaded",
|
||||
commands: commands.map((c) => ({
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
source: c.source,
|
||||
hints: c.hints,
|
||||
})),
|
||||
}
|
||||
this.cachedCommandsMessage = message
|
||||
this.postMessage(message)
|
||||
} catch (error) {
|
||||
@@ -1790,7 +1802,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
if (result.error) {
|
||||
console.error("[Kilo New] removeSkill returned error:", result.error)
|
||||
this.cachedSkillsMessage = null
|
||||
this.cachedCommandsMessage = null
|
||||
this.clearCommandsCache()
|
||||
await Promise.all([this.fetchAndSendSkills(), this.fetchAndSendCommands()])
|
||||
return false
|
||||
}
|
||||
@@ -3282,7 +3294,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.statsPoller = new GitStatsPoller({
|
||||
getWorktrees: () => [],
|
||||
getWorkspaceRoot: () => getWorkspaceRoot(),
|
||||
getClient: () => this.connectionService.getClient(),
|
||||
localDiff: (dir, base) => localDiffSummary(git, dir, base),
|
||||
git,
|
||||
onStats: () => {},
|
||||
onLocalStats: (stats: LocalStats) => {
|
||||
|
||||
@@ -61,18 +61,8 @@ export class SubAgentViewerProvider implements vscode.Disposable {
|
||||
// sessionCreated to the webview.
|
||||
provider.registerSession(session)
|
||||
|
||||
// Fetch and send existing messages
|
||||
const { data: messagesData } = await client.session.messages({ sessionID }, { throwOnError: true })
|
||||
const messages = messagesData.map((m) => ({
|
||||
...m.info,
|
||||
parts: m.parts,
|
||||
createdAt: new Date(m.info.time.created).toISOString(),
|
||||
}))
|
||||
provider.postMessage({
|
||||
type: "messagesLoaded",
|
||||
sessionID,
|
||||
messages,
|
||||
})
|
||||
// Fetch the newest page before navigating so the tab opens on the latest turn.
|
||||
await provider.loadMessages(sessionID)
|
||||
|
||||
// Navigate to the sub-agent viewer
|
||||
provider.postMessage({ type: "viewSubAgentSession", sessionID })
|
||||
|
||||
@@ -26,6 +26,7 @@ import { forkSession } from "./fork-session"
|
||||
import { continueInWorktree } from "./continue-in-worktree"
|
||||
import { WorktreeDiffController } from "./worktree-diff-controller"
|
||||
import { WorktreeImporter } from "./worktree-importer"
|
||||
import { diffSummary as localDiffSummary, diffFile as localDiffFile } from "./local-diff"
|
||||
|
||||
import { buildKeybindingMap } from "./format-keybinding"
|
||||
import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version"
|
||||
@@ -104,13 +105,15 @@ export class AgentManagerProvider implements Disposable {
|
||||
getStateReady: () => this.stateReady,
|
||||
getClient: () => this.connectionService.getClient(),
|
||||
git: this.gitOps,
|
||||
localDiff: (dir, base) => localDiffSummary(this.gitOps, dir, base, (...args) => this.log(...args)),
|
||||
localDiffFile: (dir, base, file) => localDiffFile(this.gitOps, dir, base, file, (...args) => this.log(...args)),
|
||||
post: (msg) => this.postToWebview(msg),
|
||||
log: (...args) => this.log(...args),
|
||||
})
|
||||
this.statsPoller = new GitStatsPoller({
|
||||
getWorktrees: () => this.state?.getWorktrees() ?? [],
|
||||
getWorkspaceRoot: () => this.getRoot(),
|
||||
getClient: () => this.connectionService.getClient(),
|
||||
localDiff: (dir, base) => localDiffSummary(this.gitOps, dir, base, (...args) => this.log(...args)),
|
||||
semaphore,
|
||||
onStats: (stats) => {
|
||||
const msg = { type: "agentManager.worktreeStats" as const, stats }
|
||||
|
||||
@@ -36,7 +36,7 @@ interface ExecOptions {
|
||||
stdin?: string
|
||||
}
|
||||
|
||||
interface ExecResult {
|
||||
export interface ExecResult {
|
||||
code: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
@@ -473,6 +473,17 @@ export class GitOps {
|
||||
return [{ reason: "Patch does not apply cleanly" }]
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a git command returning `{code, stdout, stderr}`. Gated by the shared
|
||||
* semaphore and respects the dispose abort signal. Never throws — commands
|
||||
* with non-zero exit codes resolve normally (nothrow semantics), making this
|
||||
* suitable for callers that need to tolerate legitimate failures (e.g.
|
||||
* `merge-base` on an orphan branch, `ls-files --error-unmatch`).
|
||||
*/
|
||||
execGit(args: string[], cwd: string, options?: { stdin?: string }): Promise<ExecResult> {
|
||||
return this.exec(args, cwd, options)
|
||||
}
|
||||
|
||||
private exec(args: string[], cwd: string, options?: ExecOptions): Promise<ExecResult> {
|
||||
if (this.controller.signal.aborted) {
|
||||
return Promise.resolve({ code: 1, stdout: "", stderr: "GitOps disposed" })
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import type { KiloClient, SnapshotFileDiff } from "@kilocode/sdk/v2/client"
|
||||
import { remoteRef, type Worktree } from "./WorktreeStateManager"
|
||||
import type { GitOps } from "./GitOps"
|
||||
import type { Semaphore } from "./semaphore"
|
||||
import { normalizePath } from "./git-import"
|
||||
import type { WorktreeDiffEntry } from "./types"
|
||||
|
||||
export interface WorktreeStats {
|
||||
worktreeId: string
|
||||
@@ -39,7 +39,12 @@ export interface WorktreePresenceResult {
|
||||
interface GitStatsPollerOptions {
|
||||
getWorktrees: () => Worktree[]
|
||||
getWorkspaceRoot: () => string | undefined
|
||||
getClient: () => KiloClient
|
||||
/**
|
||||
* Compute diff summaries locally (in the extension host) rather than over
|
||||
* HTTP to `kilo serve`. Keeps git spawning out of the Bun process, which
|
||||
* leaks native memory on Windows (oven-sh/bun#18265).
|
||||
*/
|
||||
localDiff: (dir: string, base: string) => Promise<WorktreeDiffEntry[]>
|
||||
git: GitOps
|
||||
onStats: (stats: WorktreeStats[]) => void
|
||||
onLocalStats: (stats: LocalStats) => void
|
||||
@@ -142,27 +147,16 @@ export class GitStatsPoller {
|
||||
}
|
||||
|
||||
private async fetch(): Promise<void> {
|
||||
const client = (() => {
|
||||
try {
|
||||
return this.options.getClient()
|
||||
} catch (err) {
|
||||
this.options.log("Failed to get client for stats:", err)
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
|
||||
await Promise.all([this.fetchWorktreeStats(client), this.fetchLocalStats(client)])
|
||||
await Promise.all([this.fetchWorktreeStats(), this.fetchLocalStats()])
|
||||
}
|
||||
|
||||
private async fetchWorktreeStats(client: KiloClient | undefined): Promise<void> {
|
||||
private async fetchWorktreeStats(): Promise<void> {
|
||||
const worktrees = this.options.getWorktrees()
|
||||
if (worktrees.length === 0) return
|
||||
|
||||
const presence = await this.probeWorktreePresence(worktrees)
|
||||
this.options.onWorktreePresence?.(presence)
|
||||
|
||||
if (!client) return
|
||||
|
||||
const missing = new Set(
|
||||
presence.degraded ? [] : presence.worktrees.filter((item) => item.missing).map((item) => item.worktreeId),
|
||||
)
|
||||
@@ -181,23 +175,21 @@ export class GitStatsPoller {
|
||||
return
|
||||
}
|
||||
|
||||
// Gate the HTTP diffSummary call through the semaphore but NOT the
|
||||
// aheadBehind call — that goes through GitOps.raw() which already
|
||||
// acquires the same semaphore. Wrapping both would deadlock.
|
||||
const gate = this.options.semaphore
|
||||
const diff = (dir: string, base: string) => {
|
||||
const invoke = () => client.worktree.diffSummary({ directory: dir, base }, { throwOnError: true })
|
||||
return gate ? gate.run(invoke) : invoke()
|
||||
}
|
||||
// localDiff runs in-process via GitOps.execGit() which already acquires
|
||||
// the shared semaphore internally; same goes for aheadBehind via
|
||||
// GitOps.raw(). Wrapping either again here would deadlock.
|
||||
const stats = (
|
||||
await Promise.all(
|
||||
active.map(async (wt) => {
|
||||
try {
|
||||
const base = remoteRef(wt)
|
||||
const [{ data: diffs }, ab] = await Promise.all([diff(wt.path, base), this.git.aheadBehind(wt.path, base)])
|
||||
const [diffs, ab] = await Promise.all([
|
||||
this.options.localDiff(wt.path, base),
|
||||
this.git.aheadBehind(wt.path, base),
|
||||
])
|
||||
const files = diffs.length
|
||||
const additions = diffs.reduce((sum: number, diff: SnapshotFileDiff) => sum + diff.additions, 0)
|
||||
const deletions = diffs.reduce((sum: number, diff: SnapshotFileDiff) => sum + diff.deletions, 0)
|
||||
const additions = diffs.reduce((sum, diff) => sum + diff.additions, 0)
|
||||
const deletions = diffs.reduce((sum, diff) => sum + diff.deletions, 0)
|
||||
return { worktreeId: wt.id, files, additions, deletions, ahead: ab.ahead, behind: ab.behind }
|
||||
} catch (err) {
|
||||
this.options.log(`Failed to fetch worktree stats for ${wt.branch} (${wt.path}):`, err)
|
||||
@@ -257,7 +249,7 @@ export class GitStatsPoller {
|
||||
return { worktrees: worktreeStatuses, degraded: false }
|
||||
}
|
||||
|
||||
private async fetchLocalStats(client: KiloClient | undefined): Promise<void> {
|
||||
private async fetchLocalStats(): Promise<void> {
|
||||
const root = this.options.getWorkspaceRoot()
|
||||
if (!root) return
|
||||
|
||||
@@ -274,21 +266,16 @@ export class GitStatsPoller {
|
||||
let ahead: number
|
||||
let behind: number
|
||||
try {
|
||||
if (base && client) {
|
||||
this.options.log(`Local stats: using HTTP client with base=${base}`)
|
||||
const gate = this.options.semaphore
|
||||
const invoke = () => client.worktree.diffSummary({ directory: root, base }, { throwOnError: true })
|
||||
const [{ data: diffs }, ab] = await Promise.all([
|
||||
gate ? gate.run(invoke) : invoke(),
|
||||
this.git.aheadBehind(root, base),
|
||||
])
|
||||
if (base) {
|
||||
this.options.log(`Local stats: using localDiff with base=${base}`)
|
||||
const [diffs, ab] = await Promise.all([this.options.localDiff(root, base), this.git.aheadBehind(root, base)])
|
||||
files = diffs.length
|
||||
additions = diffs.reduce((sum: number, d: SnapshotFileDiff) => sum + d.additions, 0)
|
||||
deletions = diffs.reduce((sum: number, d: SnapshotFileDiff) => sum + d.deletions, 0)
|
||||
additions = diffs.reduce((sum, d) => sum + d.additions, 0)
|
||||
deletions = diffs.reduce((sum, d) => sum + d.deletions, 0)
|
||||
ahead = ab.ahead
|
||||
behind = ab.behind
|
||||
} else {
|
||||
this.options.log(`Local stats: fallback to workingTreeStats (base=${base ?? "none"} client=${!!client})`)
|
||||
this.options.log(`Local stats: fallback to workingTreeStats (no base branch)`)
|
||||
const wt = await this.git.workingTreeStats(root)
|
||||
files = wt.files
|
||||
additions = wt.additions
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import type { GitOps } from "./GitOps"
|
||||
import type { WorktreeDiffEntry } from "./types"
|
||||
|
||||
type Status = "added" | "deleted" | "modified"
|
||||
|
||||
type Meta = {
|
||||
file: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status: Status
|
||||
tracked: boolean
|
||||
generatedLike: boolean
|
||||
stamp: string
|
||||
}
|
||||
|
||||
type Log = (...args: unknown[]) => void
|
||||
|
||||
/** Cap untracked file reads so line-counting a multi-megabyte log file does
|
||||
* not stall the poll. Matches `GitOps.workingTreeStats()`. */
|
||||
const MAX_UNTRACKED_BYTES = 1_000_000
|
||||
|
||||
/** Cap per-side reads in the detail view. Opening very large tracked files
|
||||
* used to spike `kilo serve`; now that the detail path runs in the
|
||||
* extension host, the same file would spike VS Code's RSS. Over this
|
||||
* threshold we return a summarized entry (empty `before`/`after`/`patch`,
|
||||
* metadata preserved) so the webview can render counts without
|
||||
* materializing the content. */
|
||||
export const MAX_DETAIL_BYTES = 20_000_000
|
||||
|
||||
/**
|
||||
* Local, Node.js-side replacement for the server's `WorktreeDiff.summary()` and
|
||||
* `WorktreeDiff.detail()` routes. Keeps Agent Manager polling out of the Bun
|
||||
* `kilo serve` process, which leaks native memory on every `Bun.spawn` on
|
||||
* Windows (oven-sh/bun#18265).
|
||||
*
|
||||
* All git calls go through `GitOps.execGit()` → `child_process.spawn` with
|
||||
* `windowsHide: true` and the shared semaphore. No Bun involvement.
|
||||
*/
|
||||
|
||||
/** Ported from `packages/opencode/src/file/ignore.ts` — identical patterns,
|
||||
* no runtime dependency on minimatch/picomatch. */
|
||||
const FOLDERS = new Set([
|
||||
"node_modules",
|
||||
"bower_components",
|
||||
".pnpm-store",
|
||||
"vendor",
|
||||
".npm",
|
||||
"dist",
|
||||
"build",
|
||||
"out",
|
||||
".next",
|
||||
"target",
|
||||
"bin",
|
||||
"obj",
|
||||
".git",
|
||||
".svn",
|
||||
".hg",
|
||||
".vscode",
|
||||
".idea",
|
||||
".turbo",
|
||||
".output",
|
||||
"desktop",
|
||||
".sst",
|
||||
".cache",
|
||||
".webkit-cache",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
"mypy_cache",
|
||||
".history",
|
||||
".gradle",
|
||||
])
|
||||
|
||||
const SUFFIXES = [".swp", ".swo", ".pyc", ".log"]
|
||||
const BASENAMES = new Set([".DS_Store", "Thumbs.db"])
|
||||
const CONTAINS_SEGMENTS = ["logs", "tmp", "temp", "coverage", ".nyc_output"]
|
||||
|
||||
export function generatedLike(file: string): boolean {
|
||||
const parts = file.split(/[/\\]/)
|
||||
for (const part of parts) {
|
||||
if (FOLDERS.has(part)) return true
|
||||
if (CONTAINS_SEGMENTS.includes(part)) return true
|
||||
}
|
||||
for (const suffix of SUFFIXES) {
|
||||
if (file.endsWith(suffix)) return true
|
||||
}
|
||||
const base = parts[parts.length - 1] ?? ""
|
||||
if (BASENAMES.has(base)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
async function ancestor(git: GitOps, dir: string, base: string, log?: Log): Promise<string | undefined> {
|
||||
const result = await git.execGit(["merge-base", "HEAD", base], dir)
|
||||
if (result.code !== 0) {
|
||||
log?.("git merge-base failed", { code: result.code, stderr: result.stderr.trim(), dir, base })
|
||||
return undefined
|
||||
}
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
async function numstat(git: GitOps, dir: string, base: string, file?: string) {
|
||||
const args = ["-c", "core.quotepath=false", "diff", "--numstat", "--no-renames", base]
|
||||
if (file) args.push("--", file)
|
||||
const result = await git.execGit(args, dir)
|
||||
const map = new Map<string, { additions: number; deletions: number }>()
|
||||
if (result.code !== 0) return map
|
||||
for (const line of result.stdout.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const parts = line.split("\t")
|
||||
const add = parts[0]
|
||||
const del = parts[1]
|
||||
const name = parts.slice(2).join("\t")
|
||||
if (!name) continue
|
||||
map.set(name, {
|
||||
additions: add === "-" ? 0 : parseInt(add || "0", 10) || 0,
|
||||
deletions: del === "-" ? 0 : parseInt(del || "0", 10) || 0,
|
||||
})
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
async function statStamp(dir: string, file: string): Promise<string> {
|
||||
const stat = await fs.stat(path.join(dir, file)).catch(() => undefined)
|
||||
if (!stat) return `missing:${file}`
|
||||
return `${stat.size}:${stat.mtimeMs}`
|
||||
}
|
||||
|
||||
async function lineCount(file: string): Promise<number> {
|
||||
const stat = await fs.stat(file).catch(() => undefined)
|
||||
if (!stat || stat.size === 0) return 0
|
||||
if (stat.size > MAX_UNTRACKED_BYTES) return 0
|
||||
const content = await fs.readFile(file, "utf-8").catch(() => "")
|
||||
if (!content) return 0
|
||||
if (content.endsWith("\n")) return content.split("\n").length - 1
|
||||
return content.split("\n").length
|
||||
}
|
||||
|
||||
function statusFromCode(code: string): Status {
|
||||
if (code === "A") return "added"
|
||||
if (code === "D") return "deleted"
|
||||
return "modified"
|
||||
}
|
||||
|
||||
async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise<Meta[]> {
|
||||
const nameStatus = await git.execGit(
|
||||
["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames", anc],
|
||||
dir,
|
||||
)
|
||||
if (nameStatus.code !== 0) {
|
||||
log?.("git diff --name-status failed", { code: nameStatus.code, stderr: nameStatus.stderr.trim() })
|
||||
return []
|
||||
}
|
||||
|
||||
const counts = await numstat(git, dir, anc)
|
||||
const result: Meta[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const line of nameStatus.stdout.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const parts = line.split("\t")
|
||||
const code = parts[0]
|
||||
const file = parts.slice(1).join("\t")
|
||||
if (!file || !code) continue
|
||||
seen.add(file)
|
||||
const status = statusFromCode(code)
|
||||
const stat = counts.get(file) ?? { additions: 0, deletions: 0 }
|
||||
result.push({
|
||||
file,
|
||||
additions: stat.additions,
|
||||
deletions: stat.deletions,
|
||||
status,
|
||||
tracked: true,
|
||||
generatedLike: generatedLike(file),
|
||||
stamp: status === "deleted" ? `deleted:${anc}` : await statStamp(dir, file),
|
||||
})
|
||||
}
|
||||
|
||||
const untracked = await git.execGit(["ls-files", "--others", "--exclude-standard"], dir)
|
||||
if (untracked.code !== 0) {
|
||||
log?.("git ls-files --others failed", { code: untracked.code, stderr: untracked.stderr.trim() })
|
||||
return result
|
||||
}
|
||||
|
||||
const files = untracked.stdout.trim()
|
||||
if (!files) return result
|
||||
|
||||
for (const file of files.split("\n")) {
|
||||
if (!file || seen.has(file)) continue
|
||||
const full = path.join(dir, file)
|
||||
const exists = await fs.stat(full).catch(() => undefined)
|
||||
if (!exists) continue
|
||||
result.push({
|
||||
file,
|
||||
additions: await lineCount(full),
|
||||
deletions: 0,
|
||||
status: "added",
|
||||
tracked: false,
|
||||
generatedLike: generatedLike(file),
|
||||
stamp: await statStamp(dir, file),
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function summarize(meta: Meta): WorktreeDiffEntry {
|
||||
return {
|
||||
file: meta.file,
|
||||
patch: "",
|
||||
before: "",
|
||||
after: "",
|
||||
additions: meta.additions,
|
||||
deletions: meta.deletions,
|
||||
status: meta.status,
|
||||
tracked: meta.tracked,
|
||||
generatedLike: meta.generatedLike,
|
||||
summarized: true,
|
||||
stamp: meta.stamp,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hot polling path. Returns one summarized entry per changed file (tracked or
|
||||
* untracked) relative to `merge-base HEAD base`. No file contents are read —
|
||||
* `before`/`after`/`patch` are empty strings. Matches the shape the server's
|
||||
* `WorktreeDiff.summary` emits.
|
||||
*/
|
||||
export async function diffSummary(git: GitOps, dir: string, base: string, log?: Log): Promise<WorktreeDiffEntry[]> {
|
||||
const anc = await ancestor(git, dir, base, log)
|
||||
if (!anc) return []
|
||||
const items = await list(git, dir, anc, log)
|
||||
return items.map(summarize)
|
||||
}
|
||||
|
||||
async function detailMeta(git: GitOps, dir: string, anc: string, file: string): Promise<Meta | undefined> {
|
||||
const tracked = await git.execGit(["ls-files", "--error-unmatch", "--", file], dir)
|
||||
if (tracked.code !== 0) {
|
||||
const full = path.join(dir, file)
|
||||
const exists = await fs.stat(full).catch(() => undefined)
|
||||
if (!exists) return undefined
|
||||
return {
|
||||
file,
|
||||
additions: await lineCount(full),
|
||||
deletions: 0,
|
||||
status: "added",
|
||||
tracked: false,
|
||||
generatedLike: generatedLike(file),
|
||||
stamp: await statStamp(dir, file),
|
||||
}
|
||||
}
|
||||
|
||||
const nameStatus = await git.execGit(
|
||||
["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames", anc, "--", file],
|
||||
dir,
|
||||
)
|
||||
if (nameStatus.code !== 0) return undefined
|
||||
const line = nameStatus.stdout.trim().split("\n")[0]
|
||||
if (!line) return undefined
|
||||
const parts = line.split("\t")
|
||||
const code = parts[0]
|
||||
const pathPart = parts.slice(1).join("\t") || file
|
||||
if (!code) return undefined
|
||||
|
||||
const counts = await numstat(git, dir, anc, file)
|
||||
const stat = counts.get(file) ?? counts.get(pathPart) ?? { additions: 0, deletions: 0 }
|
||||
const status = statusFromCode(code)
|
||||
return {
|
||||
file: pathPart,
|
||||
additions: stat.additions,
|
||||
deletions: stat.deletions,
|
||||
status,
|
||||
tracked: true,
|
||||
generatedLike: generatedLike(pathPart),
|
||||
stamp: status === "deleted" ? `deleted:${anc}` : await statStamp(dir, pathPart),
|
||||
}
|
||||
}
|
||||
|
||||
async function blobSize(git: GitOps, dir: string, anc: string, file: string): Promise<number> {
|
||||
const result = await git.execGit(["cat-file", "-s", `${anc}:${file}`], dir)
|
||||
if (result.code !== 0) return 0
|
||||
return parseInt(result.stdout.trim(), 10) || 0
|
||||
}
|
||||
|
||||
async function fileSize(dir: string, file: string): Promise<number> {
|
||||
const stat = await fs.stat(path.join(dir, file)).catch(() => undefined)
|
||||
return stat?.size ?? 0
|
||||
}
|
||||
|
||||
async function readBefore(git: GitOps, dir: string, anc: string, file: string, status: Status): Promise<string> {
|
||||
if (status === "added") return ""
|
||||
const result = await git.execGit(["show", `${anc}:${file}`], dir)
|
||||
return result.code === 0 ? result.stdout : ""
|
||||
}
|
||||
|
||||
async function readAfter(dir: string, file: string, status: Status): Promise<string> {
|
||||
if (status === "deleted") return ""
|
||||
const full = path.join(dir, file)
|
||||
const exists = await fs.stat(full).catch(() => undefined)
|
||||
if (!exists) return ""
|
||||
return fs.readFile(full, "utf-8").catch(() => "")
|
||||
}
|
||||
|
||||
async function unifiedPatch(git: GitOps, dir: string, anc: string, file: string): Promise<string> {
|
||||
const result = await git.execGit(
|
||||
["-c", "core.quotepath=false", "diff", "--no-ext-diff", "--no-renames", anc, "--", file],
|
||||
dir,
|
||||
)
|
||||
return result.code === 0 ? result.stdout : ""
|
||||
}
|
||||
|
||||
function linesOf(text: string): number {
|
||||
if (!text) return 0
|
||||
return text.endsWith("\n") ? text.split("\n").length - 1 : text.split("\n").length
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-file detail view (infrequent — opened on demand when the user clicks
|
||||
* a file in the review panel). Returns full `before`, `after`, and unified
|
||||
* patch. Returns `null` if the file cannot be resolved.
|
||||
*/
|
||||
export async function diffFile(
|
||||
git: GitOps,
|
||||
dir: string,
|
||||
base: string,
|
||||
file: string,
|
||||
log?: Log,
|
||||
): Promise<WorktreeDiffEntry | null> {
|
||||
const anc = await ancestor(git, dir, base, log)
|
||||
if (!anc) return null
|
||||
const meta = await detailMeta(git, dir, anc, file)
|
||||
if (!meta) return null
|
||||
|
||||
// Cheap size probe before materializing content — protects the extension
|
||||
// host from OOM on huge tracked files. `git cat-file -s` returns the blob
|
||||
// size without streaming its contents, and `fs.stat` is a plain syscall.
|
||||
const beforeBytes = meta.status === "added" ? 0 : await blobSize(git, dir, anc, meta.file)
|
||||
const afterBytes = meta.status === "deleted" ? 0 : await fileSize(dir, meta.file)
|
||||
if (beforeBytes > MAX_DETAIL_BYTES || afterBytes > MAX_DETAIL_BYTES) {
|
||||
log?.("diffFile: file too large for detail view, returning summarized entry", {
|
||||
file: meta.file,
|
||||
beforeBytes,
|
||||
afterBytes,
|
||||
cap: MAX_DETAIL_BYTES,
|
||||
})
|
||||
return summarize(meta)
|
||||
}
|
||||
|
||||
const before = await readBefore(git, dir, anc, meta.file, meta.status)
|
||||
const after = await readAfter(dir, meta.file, meta.status)
|
||||
const patch = meta.tracked ? await unifiedPatch(git, dir, anc, meta.file) : buildUntrackedPatch(meta.file, after)
|
||||
const additions = meta.status === "added" && meta.additions === 0 && !meta.tracked ? linesOf(after) : meta.additions
|
||||
return {
|
||||
file: meta.file,
|
||||
patch,
|
||||
before,
|
||||
after,
|
||||
additions,
|
||||
deletions: meta.deletions,
|
||||
status: meta.status,
|
||||
tracked: meta.tracked,
|
||||
generatedLike: meta.generatedLike,
|
||||
summarized: false,
|
||||
stamp: meta.stamp,
|
||||
}
|
||||
}
|
||||
|
||||
/** Synthesize a unified-diff patch for an untracked (new) file. `git diff`
|
||||
* only covers tracked paths, so we render the "everything added" patch
|
||||
* ourselves. Format matches `git diff --no-index /dev/null <file>`. */
|
||||
function buildUntrackedPatch(file: string, content: string): string {
|
||||
if (!content) {
|
||||
return `diff --git a/${file} b/${file}\nnew file mode 100644\n--- /dev/null\n+++ b/${file}\n`
|
||||
}
|
||||
const lines = content.split("\n")
|
||||
const trailing = content.endsWith("\n")
|
||||
const body = trailing ? lines.slice(0, -1) : lines
|
||||
const header =
|
||||
`diff --git a/${file} b/${file}\n` +
|
||||
`new file mode 100644\n` +
|
||||
`--- /dev/null\n` +
|
||||
`+++ b/${file}\n` +
|
||||
`@@ -0,0 +1,${body.length} @@\n`
|
||||
const hunk = body.map((line) => `+${line}`).join("\n")
|
||||
return header + hunk + (trailing ? "\n" : "\n\\ No newline at end of file\n")
|
||||
}
|
||||
@@ -24,6 +24,8 @@ type SessionMode = "worktree" | "local"
|
||||
export type ApplyDiffStatus = "checking" | "applying" | "success" | "conflict" | "error"
|
||||
|
||||
export type WorktreeDiffEntry = SnapshotFileDiff & {
|
||||
before?: string
|
||||
after?: string
|
||||
tracked?: boolean
|
||||
generatedLike?: boolean
|
||||
summarized?: boolean
|
||||
@@ -532,6 +534,9 @@ interface PreviewImageIn {
|
||||
interface LoadMessagesIn {
|
||||
type: "loadMessages"
|
||||
sessionID: string
|
||||
mode?: "replace" | "prepend" | "focus"
|
||||
before?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
interface FileSourceIn {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { WorktreeDiffClient } from "../worktree-diff-client"
|
||||
import type { ApplyConflict, GitOps } from "./GitOps"
|
||||
import { shouldStopDiffPolling } from "./delete-worktree"
|
||||
import { remoteRef, type ManagedSession, type WorktreeStateManager } from "./WorktreeStateManager"
|
||||
import type { AgentManagerOutMessage } from "./types"
|
||||
import type { AgentManagerOutMessage, WorktreeDiffEntry } from "./types"
|
||||
|
||||
const LOCAL_DIFF_ID = "local" as const
|
||||
|
||||
@@ -14,8 +14,18 @@ export interface WorktreeDiffControllerContext {
|
||||
getState: () => WorktreeStateManager | undefined
|
||||
getRoot: () => string | undefined
|
||||
getStateReady: () => Promise<void> | undefined
|
||||
/**
|
||||
* SDK client — used by `revert()` via `WorktreeDiffClient` for the one-shot
|
||||
* file-status lookup. Hot polling paths (`request`, `requestFile`, `poll`)
|
||||
* deliberately bypass the client and go through `localDiff`/`localDiffFile`
|
||||
* to keep git spawns out of the Bun `kilo serve` process (see oven-sh/bun#18265).
|
||||
*/
|
||||
getClient: () => KiloClient
|
||||
git: GitOps
|
||||
/** In-process diff summary (replaces client.worktree.diffSummary). */
|
||||
localDiff: (dir: string, base: string) => Promise<WorktreeDiffEntry[]>
|
||||
/** In-process single-file diff (replaces client.worktree.diffFile). */
|
||||
localDiffFile: (dir: string, base: string, file: string) => Promise<WorktreeDiffEntry | null>
|
||||
post: (msg: AgentManagerOutMessage) => void
|
||||
log: (...args: unknown[]) => void
|
||||
}
|
||||
@@ -145,11 +155,7 @@ export class WorktreeDiffController {
|
||||
this.ctx.post({ type: "agentManager.worktreeDiffLoading", sessionId, loading: true })
|
||||
|
||||
try {
|
||||
const { data } = await this.ctx
|
||||
.getClient()
|
||||
.worktree.diffSummary({ directory: target.directory, base: target.baseBranch }, { throwOnError: true })
|
||||
|
||||
const files = data ?? []
|
||||
const files = await this.ctx.localDiff(target.directory, target.baseBranch)
|
||||
this.ctx.log(`Worktree diff returned ${files.length} file(s) for session ${sessionId}`)
|
||||
this.hash = hashFileDiffs(files)
|
||||
this.session = sessionId
|
||||
@@ -171,10 +177,8 @@ export class WorktreeDiffController {
|
||||
this.target = { sessionId, directory: target.directory, baseBranch: target.baseBranch }
|
||||
|
||||
try {
|
||||
const { data } = await this.ctx
|
||||
.getClient()
|
||||
.worktree.diffFile({ directory: target.directory, base: target.baseBranch, file }, { throwOnError: true })
|
||||
this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: data ?? null })
|
||||
const data = await this.ctx.localDiffFile(target.directory, target.baseBranch, file)
|
||||
this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: data })
|
||||
} catch (error) {
|
||||
this.ctx.log("Failed to fetch worktree diff file:", error)
|
||||
this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: null })
|
||||
@@ -215,11 +219,7 @@ export class WorktreeDiffController {
|
||||
if (!target) return
|
||||
|
||||
try {
|
||||
const { data } = await this.ctx
|
||||
.getClient()
|
||||
.worktree.diffSummary({ directory: target.directory, base: target.baseBranch }, { throwOnError: true })
|
||||
|
||||
const files = data ?? []
|
||||
const files = await this.ctx.localDiff(target.directory, target.baseBranch)
|
||||
const hash = hashFileDiffs(files)
|
||||
if (hash === this.hash && this.session === sessionId) return
|
||||
this.hash = hash
|
||||
|
||||
@@ -326,6 +326,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
const match = uri.path.match(/^\/kilocode\/s\/([a-zA-Z0-9_-]+)$/)
|
||||
if (!match) return
|
||||
const sessionId = match[1]
|
||||
if (!sessionId) return
|
||||
console.log("[Kilo New] URI handler: opening cloud session:", sessionId)
|
||||
await vscode.commands.executeCommand(`${KiloProvider.viewType}.focus`)
|
||||
provider.openCloudSession(sessionId)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { KiloClient } from "@kilocode/sdk/v2/client"
|
||||
import { retry } from "../services/cli-backend/retry"
|
||||
|
||||
const promises = new Map<string, Promise<unknown>>()
|
||||
|
||||
export function clearCommandsCache(): void {
|
||||
promises.clear()
|
||||
}
|
||||
|
||||
export async function loadCommands(client: KiloClient, dir: string): Promise<unknown> {
|
||||
const pending = promises.get(dir)
|
||||
if (pending) return pending
|
||||
|
||||
const promise = retry(() => client.command.list({ directory: dir }, { throwOnError: true })).then(({ data }) => ({
|
||||
type: "commandsLoaded",
|
||||
commands: data.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
source: cmd.source,
|
||||
hints: cmd.hints,
|
||||
})),
|
||||
}))
|
||||
|
||||
promises.set(dir, promise)
|
||||
try {
|
||||
return await promise
|
||||
} finally {
|
||||
// Clear the cache entry once the request settles so subsequent calls
|
||||
// fetch fresh data. Identity check guards against clear-then-restart
|
||||
// races: if clearCommandsCache() wiped the map and a new loadCommands()
|
||||
// already stored a fresh promise, don't delete its entry.
|
||||
if (promises.get(dir) === promise) promises.delete(dir)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { KiloClient } from "@kilocode/sdk/v2/client"
|
||||
import { retry } from "../services/cli-backend/retry"
|
||||
|
||||
export const MESSAGE_PAGE_LIMIT = 80
|
||||
|
||||
/**
|
||||
* Build the same base64url-encoded cursor format the server emits so a
|
||||
* synthesized cursor round-trips through `session.messages({ before })`.
|
||||
* Server contract: `{ id, time }` JSON → base64url. See MessageV2.cursor.
|
||||
*/
|
||||
function synthesizeCursor(oldest: { info: { id: string; time: { created: number } } }): string {
|
||||
const payload = JSON.stringify({ id: oldest.info.id, time: oldest.info.time.created })
|
||||
return Buffer.from(payload, "utf8").toString("base64url")
|
||||
}
|
||||
|
||||
export async function fetchMessagePage(
|
||||
client: KiloClient,
|
||||
input: {
|
||||
sessionID: string
|
||||
workspaceDir: string
|
||||
limit: number
|
||||
before?: string
|
||||
signal?: AbortSignal
|
||||
},
|
||||
) {
|
||||
// limit: 0 is the server contract for "return every message" — used by
|
||||
// the sub-agent viewer, which has no "load earlier" UI.
|
||||
const full = input.limit === 0
|
||||
const read = async (before?: string) => {
|
||||
const result = await retry(() =>
|
||||
client.session.messages(
|
||||
{ sessionID: input.sessionID, directory: input.workspaceDir, limit: input.limit, before },
|
||||
{ throwOnError: true, signal: input.signal },
|
||||
),
|
||||
)
|
||||
// When a proxy/auth gateway strips X-Next-Cursor but the response fills
|
||||
// the requested limit, synthesize a cursor from the oldest item so the
|
||||
// "load earlier" path keeps working. Risk of one extra empty request is
|
||||
// preferable to silently hiding older history. Never synthesize for
|
||||
// full loads — those return everything by contract.
|
||||
const items = result.data
|
||||
const header = result.response.headers.get("X-Next-Cursor")
|
||||
const cursor = full
|
||||
? undefined
|
||||
: (header ?? (items.length >= input.limit && items[0] ? synthesizeCursor(items[0]) : undefined))
|
||||
return { items, cursor }
|
||||
}
|
||||
|
||||
const suffix = (items: Awaited<ReturnType<typeof read>>["items"]) => {
|
||||
const index = [...items].reverse().findIndex((item) => item.info.role === "user")
|
||||
if (index === -1) return items
|
||||
return items.slice(items.length - index - 1)
|
||||
}
|
||||
|
||||
const fill = async (page: Awaited<ReturnType<typeof read>>): Promise<Awaited<ReturnType<typeof read>>> => {
|
||||
if (page.items[0]?.info.role !== "assistant") return page
|
||||
if (!page.cursor || input.signal?.aborted) return page
|
||||
const next = await read(page.cursor)
|
||||
const items = [...suffix(next.items), ...page.items]
|
||||
return fill({ items, cursor: next.cursor })
|
||||
}
|
||||
|
||||
return fill(await read(input.before))
|
||||
}
|
||||
@@ -139,16 +139,22 @@ function slimWrite(state: Record<string, unknown>): Record<string, unknown> {
|
||||
return next
|
||||
}
|
||||
|
||||
/** bash: truncate metadata.output (up to 30KB) and state.output (up to 50KB). */
|
||||
function slimBash(state: Record<string, unknown>): Record<string, unknown> {
|
||||
/** read/list/search: keep the rendered tool details lightweight on historical loads. */
|
||||
function slimOutput(state: Record<string, unknown>): Record<string, unknown> {
|
||||
const next = { ...state }
|
||||
if (typeof state.output === "string" && state.output.length > OUTPUT_CAP) {
|
||||
next.output = cap(state.output)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** bash: truncate metadata.output and state.output. */
|
||||
function slimBash(state: Record<string, unknown>): Record<string, unknown> {
|
||||
const next = slimOutput(state)
|
||||
const meta = state.metadata
|
||||
if (isObj(meta) && typeof meta.output === "string" && meta.output.length > OUTPUT_CAP) {
|
||||
next.metadata = { ...meta, output: cap(meta.output) }
|
||||
}
|
||||
if (typeof state.output === "string" && (state.output as string).length > OUTPUT_CAP) {
|
||||
next.output = cap(state.output)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -157,6 +163,10 @@ function slimBash(state: Record<string, unknown>): Record<string, unknown> {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const slimmers: Record<string, (state: Record<string, unknown>) => Record<string, unknown>> = {
|
||||
read: slimOutput,
|
||||
list: slimOutput,
|
||||
glob: slimOutput,
|
||||
grep: slimOutput,
|
||||
edit: slimEdit,
|
||||
apply_patch: slimPatch,
|
||||
multiedit: slimMultiedit,
|
||||
|
||||
@@ -70,6 +70,13 @@ export class ServerManager {
|
||||
const serverProcess = spawn(cliPath, ["serve", "--port", "0"], {
|
||||
env: {
|
||||
...process.env,
|
||||
// Force mimalloc (the allocator Bun ships with) to return freed pages
|
||||
// to the OS immediately instead of retaining them in its arenas.
|
||||
// Without this, Bun.spawn's piped stdio accumulates ~2 MB of native
|
||||
// RSS per call on Windows, causing the Agent Manager (which polls git
|
||||
// once per second per worktree) to reach multi-GB RSS in minutes.
|
||||
// See oven-sh/bun#18265 and Jarred's workaround note in #21560.
|
||||
MIMALLOC_PURGE_DELAY: "0",
|
||||
KILO_SERVER_PASSWORD: password,
|
||||
KILO_CLIENT: "vscode",
|
||||
KILO_ENABLE_QUESTION_TOOL: "true",
|
||||
|
||||
@@ -2,11 +2,11 @@ import { describe, it, expect } from "bun:test"
|
||||
import * as fs from "fs"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import type { KiloClient } from "@kilocode/sdk/v2/client"
|
||||
import { GitStatsPoller, type WorktreePresenceResult } from "../../src/agent-manager/GitStatsPoller"
|
||||
import { GitOps } from "../../src/agent-manager/GitOps"
|
||||
import { Semaphore } from "../../src/agent-manager/semaphore"
|
||||
import type { Worktree } from "../../src/agent-manager/WorktreeStateManager"
|
||||
import type { WorktreeDiffEntry } from "../../src/agent-manager/types"
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
@@ -31,8 +31,22 @@ function worktree(id: string, remote = "origin"): Worktree {
|
||||
}
|
||||
}
|
||||
|
||||
function diff(additions: number, deletions: number) {
|
||||
return [{ file: "file.ts", before: "", after: "", additions, deletions, status: "modified" as const }]
|
||||
function diff(additions: number, deletions: number): WorktreeDiffEntry[] {
|
||||
return [
|
||||
{
|
||||
file: "file.ts",
|
||||
patch: "",
|
||||
before: "",
|
||||
after: "",
|
||||
additions,
|
||||
deletions,
|
||||
status: "modified",
|
||||
tracked: true,
|
||||
generatedLike: false,
|
||||
summarized: true,
|
||||
stamp: `${additions}:${deletions}`,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function gitOps(handler: (args: string[], cwd: string) => Promise<string>): GitOps {
|
||||
@@ -45,23 +59,19 @@ describe("GitStatsPoller", () => {
|
||||
let max = 0
|
||||
let calls = 0
|
||||
|
||||
const client = {
|
||||
worktree: {
|
||||
diffSummary: async () => {
|
||||
calls += 1
|
||||
running += 1
|
||||
max = Math.max(max, running)
|
||||
await sleep(40)
|
||||
running -= 1
|
||||
return { data: diff(2, 1) }
|
||||
},
|
||||
},
|
||||
} as unknown as KiloClient
|
||||
const localDiff = async () => {
|
||||
calls += 1
|
||||
running += 1
|
||||
max = Math.max(max, running)
|
||||
await sleep(40)
|
||||
running -= 1
|
||||
return diff(2, 1)
|
||||
}
|
||||
|
||||
const poller = new GitStatsPoller({
|
||||
getWorktrees: () => [worktree("a")],
|
||||
getWorkspaceRoot: () => undefined,
|
||||
getClient: () => client,
|
||||
localDiff,
|
||||
onStats: () => undefined,
|
||||
onLocalStats: () => undefined,
|
||||
log: () => undefined,
|
||||
@@ -85,20 +95,16 @@ describe("GitStatsPoller", () => {
|
||||
Array<{ worktreeId: string; files: number; additions: number; deletions: number; ahead: number; behind: number }>
|
||||
> = []
|
||||
|
||||
const client = {
|
||||
worktree: {
|
||||
diffSummary: async () => {
|
||||
calls += 1
|
||||
if (calls === 1) return { data: diff(7, 3) }
|
||||
throw new Error("transient backend failure")
|
||||
},
|
||||
},
|
||||
} as unknown as KiloClient
|
||||
const localDiff = async () => {
|
||||
calls += 1
|
||||
if (calls === 1) return diff(7, 3)
|
||||
throw new Error("transient backend failure")
|
||||
}
|
||||
|
||||
const poller = new GitStatsPoller({
|
||||
getWorktrees: () => [worktree("a")],
|
||||
getWorkspaceRoot: () => undefined,
|
||||
getClient: () => client,
|
||||
localDiff,
|
||||
onStats: (stats) => emitted.push(stats),
|
||||
onLocalStats: () => undefined,
|
||||
log: () => undefined,
|
||||
@@ -134,8 +140,8 @@ describe("GitStatsPoller", () => {
|
||||
const poller = new GitStatsPoller({
|
||||
getWorktrees: () => [{ ...worktree("a"), path: wtPath }],
|
||||
getWorkspaceRoot: () => root,
|
||||
getClient: () => {
|
||||
throw new Error("backend unavailable")
|
||||
localDiff: async () => {
|
||||
throw new Error("should not be called when backend unavailable path")
|
||||
},
|
||||
onStats: () => undefined,
|
||||
onLocalStats: () => undefined,
|
||||
@@ -171,9 +177,7 @@ describe("GitStatsPoller", () => {
|
||||
const poller = new GitStatsPoller({
|
||||
getWorktrees: () => [{ ...worktree("a"), path: wtPath }],
|
||||
getWorkspaceRoot: () => root,
|
||||
getClient: () => {
|
||||
throw new Error("backend unavailable")
|
||||
},
|
||||
localDiff: async () => diff(0, 0),
|
||||
onStats: () => undefined,
|
||||
onLocalStats: () => undefined,
|
||||
onWorktreePresence: (result) => presence.push(result),
|
||||
@@ -202,25 +206,19 @@ describe("GitStatsPoller", () => {
|
||||
fs.mkdirSync(wtAPath, { recursive: true })
|
||||
|
||||
const calls: string[] = []
|
||||
const emitted: Array<Array<{ worktreeId: string; additions: number; deletions: number; commits: number }>> = []
|
||||
const emitted: Array<Array<{ worktreeId: string; additions: number; deletions: number }>> = []
|
||||
const presence: WorktreePresenceResult[] = []
|
||||
|
||||
const client = {
|
||||
worktree: {
|
||||
diffSummary: async ({ directory }: { directory: string }) => {
|
||||
calls.push(directory)
|
||||
return { data: diff(1, 1) }
|
||||
},
|
||||
},
|
||||
} as unknown as KiloClient
|
||||
|
||||
const poller = new GitStatsPoller({
|
||||
getWorktrees: () => [
|
||||
{ ...worktree("a"), path: wtAPath },
|
||||
{ ...worktree("b"), path: wtBPath },
|
||||
],
|
||||
getWorkspaceRoot: () => root,
|
||||
getClient: () => client,
|
||||
localDiff: async (dir) => {
|
||||
calls.push(dir)
|
||||
return diff(1, 1)
|
||||
},
|
||||
onStats: (stats) => emitted.push(stats),
|
||||
onLocalStats: () => undefined,
|
||||
onWorktreePresence: (result) => presence.push(result),
|
||||
@@ -252,7 +250,7 @@ describe("GitStatsPoller", () => {
|
||||
expect(emitted[0]?.map((item) => item.worktreeId)).toEqual(["a"])
|
||||
})
|
||||
|
||||
it("preserves local stats when client fails after initial success", async () => {
|
||||
it("preserves local stats when diff fails after initial success", async () => {
|
||||
let diffCalls = 0
|
||||
const emitted: Array<{
|
||||
branch: string
|
||||
@@ -263,20 +261,16 @@ describe("GitStatsPoller", () => {
|
||||
behind: number
|
||||
}> = []
|
||||
|
||||
const client = {
|
||||
worktree: {
|
||||
diffSummary: async () => {
|
||||
diffCalls += 1
|
||||
if (diffCalls === 1) return { data: diff(5, 2) }
|
||||
throw new Error("transient backend failure")
|
||||
},
|
||||
},
|
||||
} as unknown as KiloClient
|
||||
const localDiff = async () => {
|
||||
diffCalls += 1
|
||||
if (diffCalls === 1) return diff(5, 2)
|
||||
throw new Error("transient backend failure")
|
||||
}
|
||||
|
||||
const poller = new GitStatsPoller({
|
||||
getWorktrees: () => [],
|
||||
getWorkspaceRoot: () => "/workspace",
|
||||
getClient: () => client,
|
||||
localDiff,
|
||||
onStats: () => undefined,
|
||||
onLocalStats: (stats) => emitted.push(stats),
|
||||
log: () => undefined,
|
||||
@@ -310,14 +304,10 @@ describe("GitStatsPoller", () => {
|
||||
behind: number
|
||||
}> = []
|
||||
|
||||
const client = {
|
||||
worktree: { diffSummary: async () => ({ data: diff(10, 4) }) },
|
||||
} as unknown as KiloClient
|
||||
|
||||
const poller = new GitStatsPoller({
|
||||
getWorktrees: () => [],
|
||||
getWorkspaceRoot: () => "/workspace",
|
||||
getClient: () => client,
|
||||
localDiff: async () => diff(10, 4),
|
||||
onStats: () => undefined,
|
||||
onLocalStats: (stats) => emitted.push(stats),
|
||||
log: () => undefined,
|
||||
@@ -358,14 +348,10 @@ describe("GitStatsPoller", () => {
|
||||
behind: number
|
||||
}> = []
|
||||
|
||||
const client = {
|
||||
worktree: { diffSummary: async () => ({ data: diff(0, 0) }) },
|
||||
} as unknown as KiloClient
|
||||
|
||||
const poller = new GitStatsPoller({
|
||||
getWorktrees: () => [],
|
||||
getWorkspaceRoot: () => "/workspace",
|
||||
getClient: () => client,
|
||||
localDiff: async () => diff(0, 0),
|
||||
onStats: () => undefined,
|
||||
onLocalStats: (stats) => emitted.push(stats),
|
||||
log: () => undefined,
|
||||
@@ -405,14 +391,10 @@ describe("GitStatsPoller", () => {
|
||||
Array<{ worktreeId: string; files: number; additions: number; deletions: number; ahead: number; behind: number }>
|
||||
> = []
|
||||
|
||||
const client = {
|
||||
worktree: { diffSummary: async () => ({ data: diff(0, 0) }) },
|
||||
} as unknown as KiloClient
|
||||
|
||||
const poller = new GitStatsPoller({
|
||||
getWorktrees: () => [worktree("a", "upstream"), worktree("b", "upstream")],
|
||||
getWorkspaceRoot: () => undefined,
|
||||
getClient: () => client,
|
||||
localDiff: async () => diff(0, 0),
|
||||
onStats: (stats) => emitted.push(stats),
|
||||
onLocalStats: () => undefined,
|
||||
log: () => undefined,
|
||||
@@ -432,32 +414,57 @@ describe("GitStatsPoller", () => {
|
||||
expect(fetches.length).toBe(0)
|
||||
})
|
||||
|
||||
it("limits concurrent diffSummary calls when semaphore is provided", async () => {
|
||||
it("runs diffs in parallel without stalling (no extra semaphore layer)", async () => {
|
||||
// localDiff is a synchronous promise — since the poller no longer wraps
|
||||
// it in a semaphore (GitOps.execGit() gates at the child-process layer),
|
||||
// many worktrees can have their diffs computed concurrently without
|
||||
// contending for a dedicated outer gate.
|
||||
let running = 0
|
||||
let peak = 0
|
||||
let ticks = 0
|
||||
const sem = new Semaphore(2)
|
||||
|
||||
const client = {
|
||||
worktree: {
|
||||
diffSummary: async () => {
|
||||
running++
|
||||
peak = Math.max(peak, running)
|
||||
await sleep(20)
|
||||
running--
|
||||
return { data: diff(1, 0) }
|
||||
},
|
||||
},
|
||||
} as unknown as KiloClient
|
||||
|
||||
// Wire the SAME semaphore into GitOps to prove there's no deadlock —
|
||||
// aheadBehind acquires the semaphore independently, not nested inside
|
||||
// the diffSummary gate.
|
||||
const wts = Array.from({ length: 5 }, (_, i) => worktree(String(i)))
|
||||
const poller = new GitStatsPoller({
|
||||
getWorktrees: () => wts,
|
||||
getWorkspaceRoot: () => undefined,
|
||||
getClient: () => client,
|
||||
localDiff: async () => {
|
||||
running++
|
||||
peak = Math.max(peak, running)
|
||||
await sleep(20)
|
||||
running--
|
||||
return diff(1, 0)
|
||||
},
|
||||
onStats: () => {
|
||||
ticks++
|
||||
},
|
||||
onLocalStats: () => undefined,
|
||||
log: () => undefined,
|
||||
intervalMs: 5,
|
||||
git: gitOps(async (args) => {
|
||||
if (args[0] === "rev-list" && args[1] === "--left-right") return "0\t0"
|
||||
return ""
|
||||
}),
|
||||
})
|
||||
|
||||
poller.setEnabled(true)
|
||||
await waitFor(() => ticks >= 1)
|
||||
poller.stop()
|
||||
|
||||
// All 5 diffs can run in parallel (no artificial cap at this layer).
|
||||
expect(peak).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it("runs concurrent diffs without deadlock when GitOps semaphore is shared", async () => {
|
||||
// Wire the SAME semaphore into GitOps to prove the aheadBehind path
|
||||
// (which goes through GitOps.raw) does not deadlock with the diff path.
|
||||
const sem = new Semaphore(2)
|
||||
let ticks = 0
|
||||
|
||||
const wts = Array.from({ length: 5 }, (_, i) => worktree(String(i)))
|
||||
const poller = new GitStatsPoller({
|
||||
getWorktrees: () => wts,
|
||||
getWorkspaceRoot: () => undefined,
|
||||
localDiff: async () => diff(1, 0),
|
||||
onStats: () => {
|
||||
ticks++
|
||||
},
|
||||
@@ -479,7 +486,6 @@ describe("GitStatsPoller", () => {
|
||||
await waitFor(() => ticks >= 1)
|
||||
poller.stop()
|
||||
|
||||
// Only diffSummary calls are tracked — they should be bounded.
|
||||
expect(peak).toBeLessThanOrEqual(2)
|
||||
expect(ticks).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
|
||||
// vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts)
|
||||
const { KiloProvider } = await import("../../src/KiloProvider")
|
||||
|
||||
type State = "connecting" | "connected" | "disconnected" | "error"
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (reason?: unknown) => void
|
||||
}
|
||||
|
||||
function defer<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function mkMessage(id: string, role: "user" | "assistant", time = 0) {
|
||||
return {
|
||||
info: {
|
||||
id,
|
||||
sessionID: "s1",
|
||||
role,
|
||||
time: { created: time },
|
||||
},
|
||||
parts: [],
|
||||
}
|
||||
}
|
||||
|
||||
function mkResult(items: unknown[]) {
|
||||
return { data: items, response: { headers: new Headers() } }
|
||||
}
|
||||
|
||||
function createClient(options?: {
|
||||
messagesDeferred?: Deferred<{ data: unknown[]; response: { headers: Headers } }>
|
||||
messagesData?: unknown[]
|
||||
deleteDeferred?: Deferred<unknown>
|
||||
}) {
|
||||
const calls: { before?: string; limit?: number }[] = []
|
||||
return {
|
||||
calls,
|
||||
session: {
|
||||
list: async () => ({ data: [] }),
|
||||
get: async () => ({ data: null }),
|
||||
status: async () => ({ data: {} }),
|
||||
messages: async (params: { before?: string; limit?: number }) => {
|
||||
calls.push({ before: params.before, limit: params.limit })
|
||||
if (options?.messagesDeferred) return options.messagesDeferred.promise
|
||||
return mkResult(options?.messagesData ?? [])
|
||||
},
|
||||
delete: async () => {
|
||||
if (options?.deleteDeferred) return options.deleteDeferred.promise
|
||||
return { data: {} }
|
||||
},
|
||||
},
|
||||
provider: { list: async () => ({ data: { all: [], connected: {}, default: {} } }) },
|
||||
app: { agents: async () => ({ data: [] }) },
|
||||
config: { get: async () => ({ data: {} }) },
|
||||
kilo: {
|
||||
notifications: async () => ({ data: [] }),
|
||||
profile: async () => ({ data: {} }),
|
||||
},
|
||||
command: { list: async () => ({ data: [] }) },
|
||||
}
|
||||
}
|
||||
|
||||
function createConnection(client: ReturnType<typeof createClient>) {
|
||||
return {
|
||||
connect: async () => {},
|
||||
getClient: () => client,
|
||||
onEventFiltered: () => () => undefined,
|
||||
onStateChange: (_l: (s: State) => void) => () => undefined,
|
||||
onNotificationDismissed: () => () => undefined,
|
||||
onLanguageChanged: () => () => undefined,
|
||||
onProfileChanged: () => () => undefined,
|
||||
onMigrationComplete: () => () => undefined,
|
||||
onFavoritesChanged: () => () => undefined,
|
||||
onClearPendingPrompts: () => () => undefined,
|
||||
registerDirectoryProvider: () => () => undefined,
|
||||
getServerInfo: () => ({ port: 12345 }),
|
||||
getConnectionState: () => "connected" as const,
|
||||
resolveEventSessionId: () => undefined,
|
||||
recordMessageSessionId: () => undefined,
|
||||
notifyNotificationDismissed: () => undefined,
|
||||
pruneSession: () => undefined,
|
||||
registerFocused: () => undefined,
|
||||
unregisterFocused: () => undefined,
|
||||
}
|
||||
}
|
||||
|
||||
type ProviderInternals = {
|
||||
connectionState: State
|
||||
webview: { postMessage: (message: unknown) => Promise<unknown> } | null
|
||||
trackedSessionIds: Set<string>
|
||||
handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise<void>
|
||||
handleDeleteSession: (sid: string) => Promise<void>
|
||||
}
|
||||
|
||||
function makeProvider(client: ReturnType<typeof createClient>) {
|
||||
const connection = createConnection(client)
|
||||
const provider = new KiloProvider({} as never, connection as never)
|
||||
const internal = provider as unknown as ProviderInternals
|
||||
internal.connectionState = "connected"
|
||||
const sent: unknown[] = []
|
||||
internal.webview = {
|
||||
postMessage: async (message: unknown) => {
|
||||
sent.push(message)
|
||||
},
|
||||
}
|
||||
return { provider, internal, sent }
|
||||
}
|
||||
|
||||
describe("KiloProvider.handleLoadMessages / focus mode freshness", () => {
|
||||
it("refetches the tail page on focus-mode reselection and posts a reconcile snapshot", async () => {
|
||||
// Regression: switching to an already-loaded session sent mode: "focus"
|
||||
// which only refreshed session metadata and status — not messages. If
|
||||
// SSE dropped events during the gap (reconnect, missed child-task
|
||||
// messages, backend crash-restart) the webview showed stale content with
|
||||
// no way to recover short of reloading the extension. Focus mode must
|
||||
// still reconcile the tail against the server snapshot so silent drift
|
||||
// self-heals on the next session switch.
|
||||
const messages = [
|
||||
mkMessage("m1", "user", 1),
|
||||
mkMessage("m2", "assistant", 2),
|
||||
mkMessage("m3", "user", 3), // delivered after SSE reconnect, missed by webview
|
||||
]
|
||||
const client = createClient({ messagesData: messages })
|
||||
const { internal, sent } = makeProvider(client)
|
||||
internal.trackedSessionIds.add("s1")
|
||||
|
||||
await internal.handleLoadMessages("s1", { mode: "focus" })
|
||||
|
||||
// Server must be hit to reconcile the current state.
|
||||
expect(client.calls.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// Must post a messagesLoaded snapshot tagged reconcile — not replace —
|
||||
// so the webview merges without tearing down existing reactive proxies.
|
||||
const loaded = sent.find(
|
||||
(msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "messagesLoaded",
|
||||
) as { mode?: string; messages: { id: string }[] } | undefined
|
||||
expect(loaded).toBeDefined()
|
||||
expect(loaded!.mode).toBe("reconcile")
|
||||
expect(loaded!.messages.map((m) => m.id)).toContain("m3")
|
||||
})
|
||||
|
||||
it("throttles repeat focus-mode reconciles within 1s", async () => {
|
||||
// Regression: rapid session tab switching (A→B→A) used to stack up one
|
||||
// reconcile fetch per click, each doing a full-page fetch + 80-message
|
||||
// reactive-store reconcile. A 1s throttle kills the redundant work while
|
||||
// still catching SSE drops on normal use patterns.
|
||||
const client = createClient({ messagesData: [mkMessage("m1", "user", 1)] })
|
||||
const { internal } = makeProvider(client)
|
||||
internal.trackedSessionIds.add("s1")
|
||||
|
||||
await internal.handleLoadMessages("s1", { mode: "focus" })
|
||||
const callsAfterFirst = client.calls.length
|
||||
|
||||
// Second focus within the throttle window — no fetch should happen.
|
||||
await internal.handleLoadMessages("s1", { mode: "focus" })
|
||||
expect(client.calls.length).toBe(callsAfterFirst)
|
||||
})
|
||||
|
||||
it("does not post messagesLoaded on focus when the session is no longer tracked", async () => {
|
||||
// Defensive: if the user deletes the session while the background focus
|
||||
// refetch is in flight, drop the response (same invariant as prepend).
|
||||
const messages = defer<{ data: unknown[]; response: { headers: Headers } }>()
|
||||
const client = createClient({ messagesDeferred: messages })
|
||||
const { internal, sent } = makeProvider(client)
|
||||
internal.trackedSessionIds.add("s1")
|
||||
|
||||
const load = internal.handleLoadMessages("s1", { mode: "focus" })
|
||||
await internal.handleDeleteSession("s1")
|
||||
messages.resolve(mkResult([mkMessage("m1", "user", 10)]))
|
||||
await load
|
||||
|
||||
const loaded = sent.filter(
|
||||
(msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "messagesLoaded",
|
||||
)
|
||||
expect(loaded).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloProvider.loadMessages / sub-agent viewer full history", () => {
|
||||
it("loads all messages without the MESSAGE_PAGE_LIMIT cap (sub-agent viewer needs full turn history)", async () => {
|
||||
// Regression: SubAgentViewerProvider used to call client.session.messages
|
||||
// with no limit, loading every turn. After switching to provider.loadMessages
|
||||
// it inherited the 80-message page cap and sub-agents with more than 80
|
||||
// turns would open truncated with no visible indicator. loadMessages() is
|
||||
// the sub-agent viewer's single entry point — it must request the full
|
||||
// transcript.
|
||||
const big = Array.from({ length: 200 }, (_, i) => mkMessage(`m${i}`, i % 2 === 0 ? "user" : "assistant", i))
|
||||
const client = createClient({ messagesData: big })
|
||||
const { provider, sent } = makeProvider(client)
|
||||
|
||||
await provider.loadMessages("s1")
|
||||
|
||||
const loaded = sent.find(
|
||||
(msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "messagesLoaded",
|
||||
) as { messages: unknown[] } | undefined
|
||||
expect(loaded).toBeDefined()
|
||||
expect(loaded!.messages).toHaveLength(200)
|
||||
|
||||
// Server contract: limit: 0 (or undefined) returns everything.
|
||||
expect(client.calls).toHaveLength(1)
|
||||
const limit = client.calls[0]?.limit
|
||||
expect(limit === undefined || limit === 0).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("KiloProvider.handleLoadMessages / prepend into deleted session", () => {
|
||||
it("does not post messagesLoaded for a session deleted mid-prepend", async () => {
|
||||
// Regression: handleLoadMessages fires fire-and-forget from the webview
|
||||
// message dispatcher. If the user deletes the session while a prepend
|
||||
// fetch is in flight, the response still arrives and posts messagesLoaded
|
||||
// for a now-dead session ID, resurrecting a ghost entry in the webview
|
||||
// store until something else clears it.
|
||||
const messages = defer<{ data: unknown[]; response: { headers: Headers } }>()
|
||||
const client = createClient({ messagesDeferred: messages })
|
||||
const { internal, sent } = makeProvider(client)
|
||||
|
||||
// Simulate the session being tracked (as it would after the initial load).
|
||||
internal.trackedSessionIds.add("s1")
|
||||
|
||||
const load = internal.handleLoadMessages("s1", { mode: "prepend", before: "cursor-1", limit: 80 })
|
||||
|
||||
// User deletes the session while the fetch is still pending.
|
||||
await internal.handleDeleteSession("s1")
|
||||
|
||||
// Fetch finally resolves after deletion.
|
||||
messages.resolve(mkResult([mkMessage("m1", "user", 10)]))
|
||||
await load
|
||||
|
||||
const loaded = sent.filter(
|
||||
(msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "messagesLoaded",
|
||||
)
|
||||
expect(loaded).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,274 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import * as fs from "fs/promises"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import { diffSummary, diffFile, generatedLike, MAX_DETAIL_BYTES } from "../../src/agent-manager/local-diff"
|
||||
import { GitOps } from "../../src/agent-manager/GitOps"
|
||||
|
||||
function git(): GitOps {
|
||||
return new GitOps({ log: () => undefined })
|
||||
}
|
||||
|
||||
function runSync(cwd: string, args: string[]): string {
|
||||
const result = Bun.spawnSync({
|
||||
cmd: ["git", ...args],
|
||||
cwd,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
GIT_AUTHOR_NAME: "Test",
|
||||
GIT_AUTHOR_EMAIL: "test@example.com",
|
||||
GIT_COMMITTER_NAME: "Test",
|
||||
GIT_COMMITTER_EMAIL: "test@example.com",
|
||||
},
|
||||
})
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(Buffer.from(result.stderr).toString("utf8") || Buffer.from(result.stdout).toString("utf8"))
|
||||
}
|
||||
return Buffer.from(result.stdout).toString("utf8").trim()
|
||||
}
|
||||
|
||||
async function withRepo(run: (dir: string, base: string) => Promise<void>): Promise<void> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "local-diff-test-"))
|
||||
try {
|
||||
runSync(dir, ["init", "-b", "main"])
|
||||
runSync(dir, ["config", "user.email", "test@example.com"])
|
||||
runSync(dir, ["config", "user.name", "Test"])
|
||||
runSync(dir, ["config", "commit.gpgsign", "false"])
|
||||
// Seed commit so `merge-base HEAD main` resolves.
|
||||
await fs.writeFile(path.join(dir, "seed.txt"), "seed\n")
|
||||
runSync(dir, ["add", "seed.txt"])
|
||||
runSync(dir, ["commit", "-m", "seed"])
|
||||
runSync(dir, ["branch", "base-branch"])
|
||||
await run(dir, "base-branch")
|
||||
} finally {
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
describe("generatedLike", () => {
|
||||
it("matches files in ignored folders", () => {
|
||||
expect(generatedLike("node_modules/foo.js")).toBe(true)
|
||||
expect(generatedLike("packages/app/node_modules/foo/index.js")).toBe(true)
|
||||
expect(generatedLike("dist/bundle.js")).toBe(true)
|
||||
expect(generatedLike("build/out.js")).toBe(true)
|
||||
expect(generatedLike(".git/HEAD")).toBe(true)
|
||||
expect(generatedLike("__pycache__/mod.cpython-39.pyc")).toBe(true)
|
||||
})
|
||||
|
||||
it("matches files by suffix", () => {
|
||||
expect(generatedLike("src/app.log")).toBe(true)
|
||||
expect(generatedLike("something.swp")).toBe(true)
|
||||
expect(generatedLike("something.swo")).toBe(true)
|
||||
expect(generatedLike("src/module.pyc")).toBe(true)
|
||||
})
|
||||
|
||||
it("matches known basenames", () => {
|
||||
expect(generatedLike("src/.DS_Store")).toBe(true)
|
||||
expect(generatedLike("Thumbs.db")).toBe(true)
|
||||
})
|
||||
|
||||
it("matches contained directory segments", () => {
|
||||
expect(generatedLike("src/logs/app.txt")).toBe(true)
|
||||
expect(generatedLike("tmp/foo")).toBe(true)
|
||||
expect(generatedLike("a/temp/b")).toBe(true)
|
||||
expect(generatedLike("coverage/report.html")).toBe(true)
|
||||
expect(generatedLike(".nyc_output/out.json")).toBe(true)
|
||||
})
|
||||
|
||||
it("rejects normal source files", () => {
|
||||
expect(generatedLike("src/index.ts")).toBe(false)
|
||||
expect(generatedLike("README.md")).toBe(false)
|
||||
expect(generatedLike("packages/kilo-vscode/src/extension.ts")).toBe(false)
|
||||
})
|
||||
|
||||
it("handles Windows-style separators", () => {
|
||||
expect(generatedLike("node_modules\\foo\\bar.js")).toBe(true)
|
||||
expect(generatedLike("src\\index.ts")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("diffSummary", () => {
|
||||
it("returns empty array when ancestor cannot be resolved", async () => {
|
||||
await withRepo(async (dir) => {
|
||||
const result = await diffSummary(git(), dir, "nonexistent-branch")
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it("reports modified, added, and deleted tracked files", async () => {
|
||||
await withRepo(async (dir, base) => {
|
||||
// seed.txt is tracked on base. Modify it; add new.txt; delete seed.txt on HEAD.
|
||||
await fs.writeFile(path.join(dir, "seed.txt"), "seed\nextra line\n")
|
||||
await fs.writeFile(path.join(dir, "new.txt"), "hello\nworld\n")
|
||||
runSync(dir, ["add", "."])
|
||||
runSync(dir, ["commit", "-m", "modify+add"])
|
||||
await fs.rm(path.join(dir, "seed.txt"))
|
||||
runSync(dir, ["add", "-A"])
|
||||
runSync(dir, ["commit", "-m", "delete seed"])
|
||||
|
||||
const result = await diffSummary(git(), dir, base)
|
||||
const byFile = new Map(result.map((entry) => [entry.file, entry]))
|
||||
|
||||
expect(byFile.get("new.txt")?.status).toBe("added")
|
||||
expect(byFile.get("new.txt")?.additions).toBe(2)
|
||||
expect(byFile.get("new.txt")?.tracked).toBe(true)
|
||||
expect(byFile.get("seed.txt")?.status).toBe("deleted")
|
||||
})
|
||||
})
|
||||
|
||||
it("includes untracked files as added with tracked=false", async () => {
|
||||
await withRepo(async (dir, base) => {
|
||||
await fs.writeFile(path.join(dir, "untracked.txt"), "a\nb\nc\n")
|
||||
const result = await diffSummary(git(), dir, base)
|
||||
const entry = result.find((e) => e.file === "untracked.txt")
|
||||
expect(entry).toBeTruthy()
|
||||
expect(entry?.status).toBe("added")
|
||||
expect(entry?.tracked).toBe(false)
|
||||
expect(entry?.additions).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
it("all entries are summarized with empty before/after/patch", async () => {
|
||||
await withRepo(async (dir, base) => {
|
||||
await fs.writeFile(path.join(dir, "untracked.txt"), "x\n")
|
||||
await fs.writeFile(path.join(dir, "seed.txt"), "changed\n")
|
||||
runSync(dir, ["add", "seed.txt"])
|
||||
runSync(dir, ["commit", "-m", "change seed"])
|
||||
const result = await diffSummary(git(), dir, base)
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
for (const entry of result) {
|
||||
expect(entry.summarized).toBe(true)
|
||||
expect(entry.before).toBe("")
|
||||
expect(entry.after).toBe("")
|
||||
expect(entry.patch).toBe("")
|
||||
expect(typeof entry.stamp).toBe("string")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it("marks generated-like files via generatedLike flag", async () => {
|
||||
await withRepo(async (dir, base) => {
|
||||
await fs.mkdir(path.join(dir, "dist"), { recursive: true })
|
||||
await fs.writeFile(path.join(dir, "dist/app.js"), "console.log(1)\n")
|
||||
await fs.writeFile(path.join(dir, "src.ts"), "export {}\n")
|
||||
const result = await diffSummary(git(), dir, base)
|
||||
const dist = result.find((e) => e.file === "dist/app.js")
|
||||
const src = result.find((e) => e.file === "src.ts")
|
||||
expect(dist?.generatedLike).toBe(true)
|
||||
expect(src?.generatedLike).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("diffFile", () => {
|
||||
it("returns null when ancestor cannot be resolved", async () => {
|
||||
await withRepo(async (dir) => {
|
||||
const result = await diffFile(git(), dir, "nonexistent-branch", "any.txt")
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it("returns null for a missing file that isn't tracked either", async () => {
|
||||
await withRepo(async (dir, base) => {
|
||||
const result = await diffFile(git(), dir, base, "does-not-exist.txt")
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it("returns before/after/patch for a modified tracked file", async () => {
|
||||
await withRepo(async (dir, base) => {
|
||||
await fs.writeFile(path.join(dir, "seed.txt"), "seed\nmore\n")
|
||||
runSync(dir, ["add", "seed.txt"])
|
||||
runSync(dir, ["commit", "-m", "modify seed"])
|
||||
const result = await diffFile(git(), dir, base, "seed.txt")
|
||||
expect(result).toBeTruthy()
|
||||
expect(result?.status).toBe("modified")
|
||||
expect(result?.tracked).toBe(true)
|
||||
expect(result?.before).toBe("seed\n")
|
||||
expect(result?.after).toBe("seed\nmore\n")
|
||||
expect(result?.patch.length).toBeGreaterThan(0)
|
||||
expect(result?.summarized).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it("returns synthetic patch for an untracked added file", async () => {
|
||||
await withRepo(async (dir, base) => {
|
||||
await fs.writeFile(path.join(dir, "fresh.txt"), "one\ntwo\n")
|
||||
const result = await diffFile(git(), dir, base, "fresh.txt")
|
||||
expect(result).toBeTruthy()
|
||||
expect(result?.status).toBe("added")
|
||||
expect(result?.tracked).toBe(false)
|
||||
expect(result?.before).toBe("")
|
||||
expect(result?.after).toBe("one\ntwo\n")
|
||||
expect(result?.patch).toContain("new file mode")
|
||||
expect(result?.patch).toContain("+one")
|
||||
expect(result?.patch).toContain("+two")
|
||||
})
|
||||
})
|
||||
|
||||
it("falls back to summarized entry when the working-copy file exceeds the detail cap", async () => {
|
||||
await withRepo(async (dir, base) => {
|
||||
// Write a tracked file that's ~2.5x the cap on the working-copy side.
|
||||
const big = "a".repeat(MAX_DETAIL_BYTES + 500_000) + "\n"
|
||||
await fs.writeFile(path.join(dir, "seed.txt"), big)
|
||||
runSync(dir, ["add", "seed.txt"])
|
||||
runSync(dir, ["commit", "-m", "grow seed"])
|
||||
|
||||
const result = await diffFile(git(), dir, base, "seed.txt")
|
||||
expect(result).toBeTruthy()
|
||||
// Metadata (status, counts, stamp) is preserved so the UI can still
|
||||
// show the file and its add/delete totals.
|
||||
expect(result?.status).toBe("modified")
|
||||
expect(result?.tracked).toBe(true)
|
||||
expect(result?.additions).toBeGreaterThan(0)
|
||||
// Content is intentionally blank — the cap prevents materialization.
|
||||
expect(result?.before).toBe("")
|
||||
expect(result?.after).toBe("")
|
||||
expect(result?.patch).toBe("")
|
||||
expect(result?.summarized).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it("falls back to summarized entry when the ancestor blob exceeds the detail cap", async () => {
|
||||
await withRepo(async (dir, base) => {
|
||||
// Put the large content in the base commit, then delete the file on HEAD.
|
||||
// `before` is read from the base blob (over cap); `after` is empty.
|
||||
const big = "b".repeat(MAX_DETAIL_BYTES + 500_000) + "\n"
|
||||
await fs.writeFile(path.join(dir, "big.txt"), big)
|
||||
runSync(dir, ["add", "big.txt"])
|
||||
runSync(dir, ["commit", "-m", "add big"])
|
||||
// Re-create the base-branch pointer so it includes the big blob.
|
||||
runSync(dir, ["branch", "-f", base])
|
||||
// Shrink on HEAD.
|
||||
await fs.writeFile(path.join(dir, "big.txt"), "small\n")
|
||||
runSync(dir, ["add", "big.txt"])
|
||||
runSync(dir, ["commit", "-m", "shrink"])
|
||||
|
||||
const result = await diffFile(git(), dir, base, "big.txt")
|
||||
expect(result).toBeTruthy()
|
||||
expect(result?.tracked).toBe(true)
|
||||
expect(result?.before).toBe("")
|
||||
expect(result?.after).toBe("")
|
||||
expect(result?.patch).toBe("")
|
||||
expect(result?.summarized).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it("still returns full detail when both sides are under the cap", async () => {
|
||||
await withRepo(async (dir, base) => {
|
||||
// Modest file, well under cap — behaves as before.
|
||||
const content = "a".repeat(50_000) + "\n"
|
||||
await fs.writeFile(path.join(dir, "seed.txt"), content)
|
||||
runSync(dir, ["add", "seed.txt"])
|
||||
runSync(dir, ["commit", "-m", "modest change"])
|
||||
|
||||
const result = await diffFile(git(), dir, base, "seed.txt")
|
||||
expect(result?.summarized).toBe(false)
|
||||
expect((result?.after ?? "").length).toBeGreaterThan(0)
|
||||
expect((result?.patch ?? "").length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { fetchMessagePage } from "../../src/kilo-provider/message-page"
|
||||
|
||||
type Message = { info: { id: string; role: "user" | "assistant"; time: { created: number } }; parts: unknown[] }
|
||||
|
||||
function message(id: string, role: "user" | "assistant", time: number): Message {
|
||||
return { info: { id, role, time: { created: time } }, parts: [] }
|
||||
}
|
||||
|
||||
function mockClient(pages: { items: Message[]; cursor?: string }[]) {
|
||||
const calls: { before?: string; limit?: number }[] = []
|
||||
let idx = 0
|
||||
const client = {
|
||||
session: {
|
||||
messages: async (
|
||||
params: { sessionID: string; directory: string; limit: number; before?: string },
|
||||
_opts: { throwOnError: boolean; signal?: AbortSignal },
|
||||
) => {
|
||||
calls.push({ before: params.before, limit: params.limit })
|
||||
const page = pages[idx++]
|
||||
if (!page) throw new Error("no more mock pages")
|
||||
const headers = new Headers()
|
||||
if (page.cursor) headers.set("X-Next-Cursor", page.cursor)
|
||||
return {
|
||||
data: page.items,
|
||||
response: { headers } as Response,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
return { client, calls }
|
||||
}
|
||||
|
||||
describe("fetchMessagePage / cursor fallback", () => {
|
||||
it("returns server cursor when X-Next-Cursor header is present", async () => {
|
||||
const { client } = mockClient([
|
||||
{
|
||||
items: [message("m1", "user", 1), message("m2", "assistant", 2), message("m3", "user", 3)],
|
||||
cursor: "server-cursor-abc",
|
||||
},
|
||||
])
|
||||
const page = await fetchMessagePage(client as never, {
|
||||
sessionID: "s1",
|
||||
workspaceDir: "/repo",
|
||||
limit: 3,
|
||||
})
|
||||
expect(page.cursor).toBe("server-cursor-abc")
|
||||
})
|
||||
|
||||
it("synthesizes a cursor when server omits X-Next-Cursor but page is full (header stripped by proxy / missing permission)", async () => {
|
||||
// Regression: if a proxy or auth layer strips X-Next-Cursor, the webview
|
||||
// loses access to older messages even when they exist. When the response
|
||||
// fills the requested limit, derive a cursor from the oldest item so the
|
||||
// "load earlier" path keeps working.
|
||||
const { client } = mockClient([
|
||||
{
|
||||
items: [
|
||||
message("m1", "user", 10),
|
||||
message("m2", "assistant", 20),
|
||||
message("m3", "user", 30),
|
||||
message("m4", "assistant", 40),
|
||||
],
|
||||
// Intentionally no cursor — simulating a stripped header.
|
||||
},
|
||||
])
|
||||
const page = await fetchMessagePage(client as never, {
|
||||
sessionID: "s1",
|
||||
workspaceDir: "/repo",
|
||||
limit: 4,
|
||||
})
|
||||
expect(page.cursor).toBeDefined()
|
||||
// Cursor must be a base64url-encoded { id, time } of the oldest item so
|
||||
// the server's before parser accepts it on the next request.
|
||||
const decoded = JSON.parse(Buffer.from(page.cursor!, "base64url").toString("utf8"))
|
||||
expect(decoded).toEqual({ id: "m1", time: 10 })
|
||||
})
|
||||
|
||||
it("leaves cursor undefined when server omits header AND page is not full (truly no more)", async () => {
|
||||
const { client } = mockClient([
|
||||
{
|
||||
items: [message("m1", "user", 10), message("m2", "assistant", 20)],
|
||||
},
|
||||
])
|
||||
const page = await fetchMessagePage(client as never, {
|
||||
sessionID: "s1",
|
||||
workspaceDir: "/repo",
|
||||
limit: 80,
|
||||
})
|
||||
expect(page.cursor).toBeUndefined()
|
||||
})
|
||||
|
||||
it("synthesized cursor round-trips through the server's before parameter", async () => {
|
||||
// First page: server strips header, items fill limit → cursor synthesized.
|
||||
// Next page request uses that cursor and returns more items.
|
||||
const { client, calls } = mockClient([
|
||||
{
|
||||
items: [message("m3", "user", 30), message("m4", "assistant", 40)],
|
||||
},
|
||||
{
|
||||
items: [message("m1", "user", 10), message("m2", "assistant", 20)],
|
||||
},
|
||||
])
|
||||
const first = await fetchMessagePage(client as never, {
|
||||
sessionID: "s1",
|
||||
workspaceDir: "/repo",
|
||||
limit: 2,
|
||||
})
|
||||
expect(first.cursor).toBeDefined()
|
||||
|
||||
await fetchMessagePage(client as never, {
|
||||
sessionID: "s1",
|
||||
workspaceDir: "/repo",
|
||||
limit: 2,
|
||||
before: first.cursor,
|
||||
})
|
||||
expect(calls[1]?.before).toBe(first.cursor)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { PartStash } from "../../webview-ui/src/context/part-stash"
|
||||
import type { Part } from "../../webview-ui/src/types/messages"
|
||||
|
||||
function text(id: string, messageID: string, value: string): Part {
|
||||
return { type: "text", id, messageID, text: value } as Part
|
||||
}
|
||||
|
||||
describe("PartStash", () => {
|
||||
it("put / peek round-trips parts", () => {
|
||||
const stash = new PartStash()
|
||||
stash.put("m1", [text("p1", "m1", "hi")])
|
||||
const peeked = stash.peek("m1")
|
||||
expect(peeked?.[0] && "text" in peeked[0] ? peeked[0].text : undefined).toBe("hi")
|
||||
})
|
||||
|
||||
it("remove() clears stashed parts — regression for handleMessageRemoved leak", () => {
|
||||
// Before the fix, handleMessageRemoved wiped reactive parts but left the
|
||||
// stash entry alive. If an off-screen message was removed before its turn
|
||||
// mounted, its parts would sit in the stash forever. Worse: a later call
|
||||
// to peek() or getParts() could surface the parts of a deleted message.
|
||||
const stash = new PartStash()
|
||||
stash.put("m1", [text("p1", "m1", "stale")])
|
||||
stash.remove("m1")
|
||||
expect(stash.peek("m1")).toBeUndefined()
|
||||
expect(stash.size()).toBe(0)
|
||||
})
|
||||
|
||||
it("take() consumes stashed parts atomically", () => {
|
||||
const stash = new PartStash()
|
||||
stash.put("m1", [text("p1", "m1", "a")])
|
||||
stash.put("m2", [text("p2", "m2", "b")])
|
||||
const taken = stash.take(["m1", "m2"])
|
||||
expect(Object.keys(taken).sort()).toEqual(["m1", "m2"])
|
||||
expect(stash.size()).toBe(0)
|
||||
})
|
||||
|
||||
it("take() skips IDs already hydrated into the reactive store", () => {
|
||||
const stash = new PartStash()
|
||||
stash.put("m1", [text("p1", "m1", "stash")])
|
||||
const taken = stash.take(["m1"], (id) => id === "m1")
|
||||
expect(taken).toEqual({})
|
||||
// The stash entry should be preserved — hydrateParts will noop and
|
||||
// subsequent SSE updates that target the message can still merge into it
|
||||
// if needed.
|
||||
expect(stash.peek("m1")).toBeDefined()
|
||||
})
|
||||
|
||||
it("take() returns empty when no IDs match", () => {
|
||||
const stash = new PartStash()
|
||||
stash.put("m1", [text("p1", "m1", "a")])
|
||||
expect(stash.take(["m2", "m3"])).toEqual({})
|
||||
})
|
||||
})
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8348db9191e1e616c93bf54ae33702a57e25c092a8f80a24d1da04811e523500
|
||||
size 26519
|
||||
oid sha256:4294f36eea4005ca5f3f6d4a3f7ed84d5113f7454c2149280f5182ccbc686124
|
||||
size 26407
|
||||
|
||||
@@ -762,20 +762,28 @@ const AgentManagerContent: Component = () => {
|
||||
return result
|
||||
})
|
||||
|
||||
// Sessions for the currently selected worktree (tab bar), respecting custom order if set
|
||||
// Oldest-first sort before applyTabOrder — worktree label and tab bar must agree on "first session".
|
||||
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) => ids.has(s.id))
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()),
|
||||
worktreeTabOrder()[worktreeId],
|
||||
)
|
||||
}
|
||||
|
||||
const activeWorktreeSessions = createMemo((): SessionInfo[] => {
|
||||
const sel = selection()
|
||||
if (!sel || sel === LOCAL) return []
|
||||
const managed = managedSessions().filter((ms) => ms.worktreeId === sel)
|
||||
const ids = new Set(managed.map((ms) => ms.id))
|
||||
const sessions = session
|
||||
.sessions()
|
||||
.filter((s) => ids.has(s.id))
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
return applyTabOrder(sessions, worktreeTabOrder()[sel])
|
||||
return sessionsForWorktree(sel)
|
||||
})
|
||||
|
||||
// Active tab sessions: local sessions when on "local", worktree sessions otherwise
|
||||
const activeTabs = createMemo((): SessionInfo[] => {
|
||||
const sel = selection()
|
||||
if (sel === LOCAL) return localSessions()
|
||||
@@ -783,11 +791,10 @@ const AgentManagerContent: Component = () => {
|
||||
return []
|
||||
})
|
||||
|
||||
// Whether the selected context has zero sessions
|
||||
const contextEmpty = createMemo(() => {
|
||||
const sel = selection()
|
||||
if (sel === LOCAL) return localSessionIDs().length === 0
|
||||
if (sel) return activeWorktreeSessions().length === 0
|
||||
if (sel) return activeWorktreeSessions().length === 0 && managedSessions().every((ms) => ms.worktreeId !== sel)
|
||||
return false
|
||||
})
|
||||
|
||||
@@ -802,8 +809,6 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Scroll the sidebar to the focused item whenever selection changes (covers keyboard
|
||||
// navigation, new worktree creation, and any other programmatic selection change).
|
||||
createEffect(() => {
|
||||
const id = selection() ?? session.currentSessionID()
|
||||
if (!id) return
|
||||
@@ -813,22 +818,16 @@ const AgentManagerContent: Component = () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Read-only mode: viewing an unassigned session (not in a worktree or local)
|
||||
const readOnly = createMemo(() => selection() === null && !!session.currentSessionID())
|
||||
|
||||
// Tab scroll: hidden scrollbar with fade overflow indicators
|
||||
const visibleTabId = createMemo(() =>
|
||||
reviewActive() ? REVIEW_TAB_ID : (session.currentSessionID() ?? activePendingId()),
|
||||
)
|
||||
const tabScroll = useTabScroll(activeTabs, visibleTabId)
|
||||
|
||||
// Display name for worktree — prefers persisted label, then first session title, then branch
|
||||
const worktreeLabel = (wt: WorktreeState): string => {
|
||||
if (wt.label) return wt.label
|
||||
const managed = managedSessions().filter((ms) => ms.worktreeId === wt.id)
|
||||
const ids = new Set(managed.map((ms) => ms.id))
|
||||
const sessions = session.sessions().filter((s) => ids.has(s.id))
|
||||
return firstOrderedTitle(sessions, worktreeTabOrder()[wt.id], wt.branch)
|
||||
return firstOrderedTitle(sessionsForWorktree(wt.id), worktreeTabOrder()[wt.id], wt.branch)
|
||||
}
|
||||
|
||||
const worktreeSubtitle = (wt: WorktreeState): string | undefined => {
|
||||
@@ -838,7 +837,6 @@ const AgentManagerContent: Component = () => {
|
||||
|
||||
const isStaleWorktree = (worktreeId: string): boolean => staleWorktreeIds().has(worktreeId)
|
||||
|
||||
/** True when any session in the given ID list is actively working (busy/retry and not blocked by permissions/questions). */
|
||||
const isAnySessionBusy = (ids: string[]): boolean => {
|
||||
if (ids.length === 0) return false
|
||||
const statuses = session.allStatusMap()
|
||||
@@ -1008,12 +1006,15 @@ const AgentManagerContent: Component = () => {
|
||||
const selectWorktree = (worktreeId: string) => {
|
||||
saveTabMemory()
|
||||
setSelection(worktreeId)
|
||||
// Try rich session list first, fall back to managed session IDs when
|
||||
// session.sessions() hasn't been populated yet for this worktree.
|
||||
const rich = sessionsForWorktree(worktreeId)
|
||||
const managed = managedSessions().filter((ms) => ms.worktreeId === worktreeId)
|
||||
const ids = new Set(managed.map((ms) => ms.id))
|
||||
const sessions = session.sessions().filter((s) => ids.has(s.id))
|
||||
const remembered = tabMemory()[worktreeId]
|
||||
const target = remembered ? sessions.find((s) => s.id === remembered) : undefined
|
||||
const fallback = target ?? sessions[0]
|
||||
const target = remembered
|
||||
? (rich.find((s) => s.id === remembered) ?? managed.find((ms) => ms.id === remembered))
|
||||
: undefined
|
||||
const fallback = target ?? rich[0] ?? managed[0]
|
||||
if (fallback) session.selectSession(fallback.id)
|
||||
else session.setCurrentSessionID(undefined)
|
||||
setReviewActive(remembered === REVIEW_TAB_ID && reviewOpenByContext()[worktreeId] === true)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* MessageList component
|
||||
* Scrollable turn-based message list.
|
||||
* Scrollable turn-based message list with virtualization.
|
||||
* Each user message is rendered as a VscodeSessionTurn — a custom component that
|
||||
* renders all assistant parts as a flat, verbose list with no context grouping,
|
||||
* and fully expands sub-agent (task tool) parts inline.
|
||||
* Shows recent sessions in the empty state for quick resumption.
|
||||
*/
|
||||
|
||||
import { Component, For, Show, createEffect, createMemo, onCleanup, JSX } from "solid-js"
|
||||
import { Component, For, Show, createEffect, createMemo, createSignal, on, onCleanup, JSX } from "solid-js"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import { useDialog } from "@kilocode/kilo-ui/context/dialog"
|
||||
@@ -17,12 +17,13 @@ import { useServer } from "../../context/server"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import { formatRelativeDate } from "../../utils/date"
|
||||
import { FeedbackDialog } from "./FeedbackDialog"
|
||||
import { VscodeSessionTurn } from "./VscodeSessionTurn"
|
||||
import { VscodeSessionTurn, type VscodeTurn } from "./VscodeSessionTurn"
|
||||
import { RevertBanner } from "./RevertBanner"
|
||||
import { AccountSwitcher } from "../shared/AccountSwitcher"
|
||||
import { KiloNotifications } from "./KiloNotifications"
|
||||
import { WorkingIndicator } from "../shared/WorkingIndicator"
|
||||
import { QuestionDock } from "./QuestionDock"
|
||||
import { Virtualizer } from "virtua/solid"
|
||||
import { SuggestBar } from "./SuggestBar"
|
||||
import { activeUserMessageID as getActiveUserMessageID } from "../../context/session-queue"
|
||||
import type { QuestionRequest, SuggestionRequest } from "../../types/messages"
|
||||
@@ -74,14 +75,25 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
}
|
||||
})
|
||||
|
||||
const allUserMessages = () => session.userMessages()
|
||||
const [scrollEl, setScrollEl] = createSignal<HTMLElement>()
|
||||
const positions = new Map<string, { top: number; userScrolled: boolean }>()
|
||||
|
||||
const boundary = () => session.revert()?.messageID
|
||||
const userMessages = createMemo(() => {
|
||||
const turns = createMemo<VscodeTurn[]>(() => {
|
||||
const result: VscodeTurn[] = []
|
||||
const b = boundary()
|
||||
if (!b) return allUserMessages()
|
||||
return allUserMessages().filter((m) => m.id < b)
|
||||
for (const msg of session.messages()) {
|
||||
if (msg.role === "user") {
|
||||
if (b && msg.id >= b) break
|
||||
result.push({ id: msg.id, user: msg, assistant: [] })
|
||||
continue
|
||||
}
|
||||
const turn = result[result.length - 1]
|
||||
if (turn && msg.role === "assistant") turn.assistant.push(msg)
|
||||
}
|
||||
return result
|
||||
})
|
||||
const isEmpty = () => userMessages().length === 0 && !session.loading() && !boundary()
|
||||
const isEmpty = () => turns().length === 0 && !session.loading() && !boundary()
|
||||
|
||||
const recent = createMemo(() =>
|
||||
[...session.sessions()]
|
||||
@@ -94,9 +106,67 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
const activeUserIndex = createMemo(() => {
|
||||
const active = activeUserID()
|
||||
if (!active) return -1
|
||||
return userMessages().findIndex((msg) => msg.id === active)
|
||||
return turns().findIndex((turn) => turn.user.id === active)
|
||||
})
|
||||
|
||||
const save = (id: string | undefined) => {
|
||||
const el = scrollEl()
|
||||
if (!id || !el) return
|
||||
positions.set(id, { top: el.scrollTop, userScrolled: autoScroll.userScrolled() })
|
||||
}
|
||||
|
||||
const maybeLoadOlder = () => {
|
||||
const el = scrollEl()
|
||||
if (!el || el.scrollTop > 600) return
|
||||
session.loadOlderMessages()
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
autoScroll.handleScroll()
|
||||
maybeLoadOlder()
|
||||
}
|
||||
|
||||
const setScrollRef = (el: HTMLElement | undefined) => {
|
||||
setScrollEl(el)
|
||||
autoScroll.scrollRef(el)
|
||||
}
|
||||
|
||||
const [pendingRestore, setPendingRestore] = createSignal<string>()
|
||||
|
||||
createEffect(
|
||||
on(session.currentSessionID, (id, prev) => {
|
||||
save(prev)
|
||||
setPendingRestore(id)
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const id = pendingRestore()
|
||||
if (!id || session.loading()) return
|
||||
turns().length
|
||||
// Double-rAF: the first frame lets the browser paint the new DOM from
|
||||
// the messagesLoaded batch. The second frame restores scroll position
|
||||
// without forcing a synchronous layout reflow mid-paint.
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (pendingRestore() !== id) return
|
||||
const el = scrollEl()
|
||||
if (!el) return
|
||||
const pos = positions.get(id)
|
||||
if (pos?.userScrolled) {
|
||||
el.scrollTop = pos.top
|
||||
autoScroll.pause()
|
||||
} else {
|
||||
autoScroll.forceScrollToBottom()
|
||||
}
|
||||
setPendingRestore(undefined)
|
||||
maybeLoadOlder()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => save(session.currentSessionID()))
|
||||
|
||||
return (
|
||||
<div class="message-list-container">
|
||||
<Show when={isEmpty()}>
|
||||
@@ -105,13 +175,7 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
<KiloNotifications />
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
ref={autoScroll.scrollRef}
|
||||
onScroll={autoScroll.handleScroll}
|
||||
class="message-list"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div ref={setScrollRef} onScroll={handleScroll} class="message-list" role="log" aria-live="polite">
|
||||
<div ref={autoScroll.contentRef} class={isEmpty() ? "message-list-content-empty" : "message-list-content"}>
|
||||
<Show when={session.loading()}>
|
||||
<div class="message-list-loading" role="status">
|
||||
@@ -153,24 +217,37 @@ export const MessageList: Component<MessageListProps> = (props) => {
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!session.loading()}>
|
||||
<For each={userMessages()}>
|
||||
{(msg, index) => {
|
||||
const queued = createMemo(() => {
|
||||
const active = activeUserIndex()
|
||||
if (active === -1) return false
|
||||
return index() > active
|
||||
})
|
||||
<Show when={!session.loading() && !isEmpty()}>
|
||||
<Show when={session.loadingOlderMessages()}>
|
||||
<div class="message-list-page-loader" role="status">
|
||||
<Spinner />
|
||||
<span>{language.t("session.messages.loadingEarlier")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={session.hasOlderMessages() && !session.loadingOlderMessages()}>
|
||||
<button class="message-list-load-older" onClick={() => session.loadOlderMessages()}>
|
||||
{language.t("session.messages.loadEarlier")}
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={scrollEl()}>
|
||||
<Virtualizer
|
||||
data={turns()}
|
||||
scrollRef={scrollEl()}
|
||||
shift={session.messageMutation() === "prepend"}
|
||||
overscan={6}
|
||||
itemSize={260}
|
||||
>
|
||||
{(turn, index) => {
|
||||
const queued = createMemo(() => {
|
||||
const active = activeUserIndex()
|
||||
if (active === -1) return false
|
||||
return index() > active
|
||||
})
|
||||
|
||||
return (
|
||||
<VscodeSessionTurn
|
||||
sessionID={session.currentSessionID() ?? ""}
|
||||
messageID={msg.id}
|
||||
queued={queued()}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
return <VscodeSessionTurn turn={turn} queued={queued()} />
|
||||
}}
|
||||
</Virtualizer>
|
||||
</Show>
|
||||
<Show when={boundary()}>
|
||||
<RevertBanner />
|
||||
</Show>
|
||||
|
||||
@@ -32,6 +32,7 @@ import { ErrorDisplay } from "./ErrorDisplay"
|
||||
import { useServer } from "../../context/server"
|
||||
import { useSession } from "../../context/session"
|
||||
import { useLanguage } from "../../context/language"
|
||||
import type { Message as WebMessage } from "../../types/messages"
|
||||
|
||||
function getDirectory(path: string): string {
|
||||
const sep = path.includes("/") ? "/" : "\\"
|
||||
@@ -45,9 +46,14 @@ function getFilename(path: string): string {
|
||||
return idx === -1 ? path : path.slice(idx + 1)
|
||||
}
|
||||
|
||||
export interface VscodeTurn {
|
||||
id: string
|
||||
user: WebMessage
|
||||
assistant: WebMessage[]
|
||||
}
|
||||
|
||||
interface VscodeSessionTurnProps {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
turn: VscodeTurn
|
||||
queued?: boolean
|
||||
}
|
||||
|
||||
@@ -59,45 +65,22 @@ export const VscodeSessionTurn: Component<VscodeSessionTurnProps> = (props) => {
|
||||
const session = useSession()
|
||||
const language = useLanguage()
|
||||
|
||||
const emptyMessages: SDKMessage[] = []
|
||||
const emptyParts: SDKPart[] = []
|
||||
const emptyDiffs: SnapshotFileDiff[] = []
|
||||
|
||||
const allMessages = createMemo(() => {
|
||||
const msgs = data.store.message?.[props.sessionID]
|
||||
return (msgs ?? emptyMessages) as SDKMessage[]
|
||||
createEffect(() => {
|
||||
const turn = props.turn
|
||||
session.hydrateParts([turn.user.id, ...turn.assistant.map((m) => m.id)])
|
||||
})
|
||||
|
||||
const message = createMemo(() => {
|
||||
return allMessages().find((m) => m.id === props.messageID && m.role === "user") as
|
||||
| (SDKMessage & { role: "user" })
|
||||
| undefined
|
||||
})
|
||||
const message = createMemo(() => props.turn.user as SDKMessage & { role: "user" })
|
||||
|
||||
const parts = createMemo(() => {
|
||||
const msg = message()
|
||||
if (!msg) return emptyParts
|
||||
return (data.store.part?.[msg.id] ?? emptyParts) as SDKPart[]
|
||||
})
|
||||
|
||||
const messageIndex = createMemo(() => {
|
||||
const msgs = allMessages()
|
||||
return msgs.findIndex((m) => m.id === props.messageID)
|
||||
})
|
||||
|
||||
const assistantMessages = createMemo(() => {
|
||||
const index = messageIndex()
|
||||
if (index < 0) return [] as SDKAssistantMessage[]
|
||||
const msgs = allMessages()
|
||||
const result: SDKAssistantMessage[] = []
|
||||
for (let i = index + 1; i < msgs.length; i++) {
|
||||
const m = msgs[i]
|
||||
if (!m) continue
|
||||
if (m.role === "user") break
|
||||
if (m.role === "assistant") result.push(m as SDKAssistantMessage)
|
||||
}
|
||||
return result
|
||||
})
|
||||
const assistantMessages = createMemo(() => props.turn.assistant as SDKAssistantMessage[])
|
||||
|
||||
const interrupted = createMemo(() => assistantMessages().some((m) => m.error?.name === "MessageAbortedError"))
|
||||
|
||||
@@ -174,7 +157,7 @@ export const VscodeSessionTurn: Component<VscodeSessionTurnProps> = (props) => {
|
||||
assistantMessages().length > 0 && !session.revert()
|
||||
? () => {
|
||||
if (session.status() !== "idle") return
|
||||
session.revertSession(props.messageID)
|
||||
session.revertSession(msg().id)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* PartStash holds message parts outside the reactive Solid store until a
|
||||
* turn is actually rendered by the virtualizer. Writing parts for off-screen
|
||||
* messages into the reactive store triggers expensive DOM work for invisible
|
||||
* content — parking them here keeps initial-load churn cheap.
|
||||
*
|
||||
* The stash lives alongside (not inside) the reactive store. Every lifecycle
|
||||
* event that invalidates a message must reach both the store and the stash.
|
||||
* Centralising stash access behind this helper keeps that invariant easy to
|
||||
* audit (and easy to unit-test, since the store is Solid-specific).
|
||||
*/
|
||||
import type { Part } from "../types/messages"
|
||||
|
||||
export class PartStash {
|
||||
private map = new Map<string, Part[]>()
|
||||
|
||||
/** Stash parts for a message that hasn't been rendered yet. */
|
||||
put(messageID: string, parts: Part[]): void {
|
||||
this.map.set(messageID, parts)
|
||||
}
|
||||
|
||||
/** Read without consuming. Returns `undefined` if absent. */
|
||||
peek(messageID: string): Part[] | undefined {
|
||||
return this.map.get(messageID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate any stashed parts for a message. Callers MUST invoke this in
|
||||
* every path that removes a message from state (messageRemoved,
|
||||
* sendMessageFailed, sessionDeleted) or promotes it into the reactive
|
||||
* store (messageCreated, partUpdated, hydrateParts). Missing a call here
|
||||
* leaks memory and, worse, can resurface stale parts via `peek()` after
|
||||
* the message is gone.
|
||||
*/
|
||||
remove(messageID: string): void {
|
||||
this.map.delete(messageID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect parts for the given IDs, consuming the stash. Used by the
|
||||
* virtualizer when a turn is about to render: the returned parts should
|
||||
* be written to the reactive store atomically by the caller.
|
||||
*
|
||||
* IDs already present in the reactive store are skipped — pass an optional
|
||||
* `isHydrated` predicate for that check.
|
||||
*/
|
||||
take(ids: string[], isHydrated?: (id: string) => boolean): Record<string, Part[]> {
|
||||
const out: Record<string, Part[]> = {}
|
||||
for (const id of ids) {
|
||||
if (isHydrated?.(id)) continue
|
||||
const parts = this.map.get(id)
|
||||
if (!parts) continue
|
||||
out[id] = parts
|
||||
this.map.delete(id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Diagnostics and tests only. */
|
||||
size(): number {
|
||||
return this.map.size
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
FileAttachment,
|
||||
SendMessageFailedMessage,
|
||||
McpStatusEntry,
|
||||
MessageLoadMode,
|
||||
} from "../types/messages"
|
||||
import { removeSessionPermissions, upsertPermission } from "./permission-queue"
|
||||
import {
|
||||
@@ -46,9 +47,29 @@ import { Identifier } from "../utils/id"
|
||||
import { resolveModelSelection } from "./model-selection"
|
||||
import { resolveSessionAgent } from "./session-agent"
|
||||
import { queuedUserMessageIDs } from "./session-queue"
|
||||
import { PartStash } from "./part-stash"
|
||||
import { KILO_AUTO, parseModelString } from "../../../src/shared/provider-model"
|
||||
|
||||
const RECENT_LIMIT = 5
|
||||
const MESSAGE_PAGE_LIMIT = 80
|
||||
|
||||
type MessageMutation = Exclude<MessageLoadMode, "focus"> | "append" | "update"
|
||||
|
||||
interface MessagePageState {
|
||||
initialLoaded: boolean
|
||||
loadingInitial: boolean
|
||||
loadingOlder: boolean
|
||||
before?: string
|
||||
hasMore: boolean
|
||||
lastMutation?: MessageMutation
|
||||
}
|
||||
|
||||
const emptyPageState: MessagePageState = {
|
||||
initialLoaded: false,
|
||||
loadingInitial: false,
|
||||
loadingOlder: false,
|
||||
hasMore: false,
|
||||
}
|
||||
|
||||
// Store structure for messages and parts
|
||||
interface SessionStore {
|
||||
@@ -79,6 +100,9 @@ interface SessionContextValue {
|
||||
statusText: Accessor<string | undefined>
|
||||
busySince: Accessor<number | undefined>
|
||||
loading: Accessor<boolean>
|
||||
loadingOlderMessages: Accessor<boolean>
|
||||
hasOlderMessages: Accessor<boolean>
|
||||
messageMutation: Accessor<MessageMutation | undefined>
|
||||
|
||||
// Messages for current session
|
||||
messages: Accessor<Message[]>
|
||||
@@ -105,6 +129,10 @@ interface SessionContextValue {
|
||||
// Parts for a specific message
|
||||
getParts: (messageID: string) => Part[]
|
||||
|
||||
// Move stashed parts into the reactive store for the given message IDs.
|
||||
// Called by VscodeSessionTurn when the virtualizer renders a turn.
|
||||
hydrateParts: (messageIDs: string[]) => void
|
||||
|
||||
// Todos for current session
|
||||
todos: Accessor<TodoItem[]>
|
||||
|
||||
@@ -202,6 +230,7 @@ interface SessionContextValue {
|
||||
createSession: () => void
|
||||
clearCurrentSession: () => void
|
||||
loadSessions: () => void
|
||||
loadOlderMessages: () => void
|
||||
selectSession: (id: string) => void
|
||||
deleteSession: (id: string) => void
|
||||
renameSession: (id: string, title: string) => void
|
||||
@@ -246,6 +275,13 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [loaded, setLoaded] = createSignal<Set<string>>(new Set())
|
||||
const [pages, setPages] = createStore<Record<string, MessagePageState>>({})
|
||||
|
||||
// Parts stash: holds parts from messagesLoaded outside the reactive store
|
||||
// until a VscodeSessionTurn is rendered by the virtualizer and calls
|
||||
// hydrateParts(). This avoids writing parts for off-screen messages into
|
||||
// the store, which would trigger expensive DOM work for invisible content.
|
||||
const stash = new PartStash()
|
||||
|
||||
// Pending permissions
|
||||
const [permissions, setPermissions] = createSignal<PermissionRequest[]>([])
|
||||
@@ -641,6 +677,11 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
vscode.postMessage({ type: "requestFavorites" })
|
||||
onCleanup(unsubFavorites)
|
||||
|
||||
function handleError(message: Extract<ExtensionMessage, { type: "error" }>) {
|
||||
if (!message.sessionID || message.sessionID === currentSessionID()) setLoading(false)
|
||||
if (message.sessionID) patchPage(message.sessionID, { loadingInitial: false, loadingOlder: false })
|
||||
}
|
||||
|
||||
function toggleFavorite(providerID: string, modelID: string) {
|
||||
const key = `${providerID}/${modelID}`
|
||||
const idx = store.favoriteModels.findIndex((f) => `${f.providerID}/${f.modelID}` === key)
|
||||
@@ -679,7 +720,11 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
break
|
||||
|
||||
case "messagesLoaded":
|
||||
handleMessagesLoaded(message.sessionID, message.messages)
|
||||
handleMessagesLoaded(message.sessionID, message.messages, {
|
||||
mode: message.mode,
|
||||
cursor: message.cursor,
|
||||
hasMore: message.hasMore,
|
||||
})
|
||||
break
|
||||
|
||||
case "messageCreated":
|
||||
@@ -751,9 +796,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
}
|
||||
|
||||
case "error":
|
||||
// Only clear loading if the error is for the current session
|
||||
// (or has no sessionID for backwards compatibility)
|
||||
if (!message.sessionID || message.sessionID === currentSessionID()) setLoading(false)
|
||||
handleError(message)
|
||||
break
|
||||
|
||||
case "sendMessageFailed":
|
||||
@@ -822,7 +865,72 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
})
|
||||
}
|
||||
|
||||
function handleMessagesLoaded(sessionID: string, messages: Message[]) {
|
||||
function patchPage(sessionID: string, patch: Partial<MessagePageState>) {
|
||||
setPages(sessionID, { ...(pages[sessionID] ?? emptyPageState), ...patch })
|
||||
}
|
||||
|
||||
function mergeMessages(current: Message[], incoming: Message[], mode: Exclude<MessageLoadMode, "focus">) {
|
||||
if (mode === "reconcile") {
|
||||
// Tail reconcile: incoming is the authoritative newest-N snapshot.
|
||||
// Local state may already hold some of those IDs and may also hold
|
||||
// newer optimistic entries created after the fetch was taken. Merge
|
||||
// by id (server wins on collision) then sort by createdAt so new
|
||||
// server messages land in the right position and optimistic tail
|
||||
// entries stay at the end.
|
||||
const byId = new Map<string, Message>()
|
||||
for (const msg of current) byId.set(msg.id, msg)
|
||||
for (const msg of incoming) byId.set(msg.id, msg)
|
||||
return [...byId.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
const source = mode === "prepend" ? [...incoming, ...current] : incoming
|
||||
return source.filter((msg) => {
|
||||
if (seen.has(msg.id)) return false
|
||||
seen.add(msg.id)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function withPending(sessionID: string, messages: Message[]) {
|
||||
const pending = pendingOptimistic.get(sessionID)
|
||||
if (!pending || pending.size === 0) return messages
|
||||
const ids = new Set(messages.map((msg) => msg.id))
|
||||
const current = store.messages[sessionID] ?? []
|
||||
const orphans = current.filter((msg) => pending.has(msg.id) && !ids.has(msg.id))
|
||||
return [...messages, ...orphans]
|
||||
}
|
||||
|
||||
// Cheap shape check: same ids in same order AND same part counts per message.
|
||||
// Short-circuits reconcile when the server snapshot matches local state
|
||||
// (the common case — SSE didn't actually miss anything), avoiding the
|
||||
// 80 setStore("parts", ...) calls per session switch.
|
||||
function sameReconcileShape(current: Message[], incoming: Message[]): boolean {
|
||||
if (current.length !== incoming.length) return false
|
||||
for (let i = 0; i < incoming.length; i++) {
|
||||
const c = current[i]!
|
||||
const n = incoming[i]!
|
||||
if (c.id !== n.id) return false
|
||||
if ((c.parts?.length ?? 0) !== (n.parts?.length ?? 0)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function handleMessagesLoaded(
|
||||
sessionID: string,
|
||||
messages: Message[],
|
||||
input: { mode?: Exclude<MessageLoadMode, "focus">; cursor?: string; hasMore?: boolean } = {},
|
||||
) {
|
||||
const mode = input.mode ?? "replace"
|
||||
const reset = mode === "prepend"
|
||||
|
||||
// Reconcile fast-path: if the tail matches local state shape-wise, every
|
||||
// message+part-count already agrees with the server. Skip the reactive
|
||||
// store churn entirely — virtualizer and rendering stay untouched.
|
||||
if (mode === "reconcile" && sameReconcileShape(store.messages[sessionID] ?? [], messages)) {
|
||||
patchPage(sessionID, { initialLoaded: true, lastMutation: "update" })
|
||||
return
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
setLoaded((prev) => {
|
||||
if (prev.has(sessionID)) return prev
|
||||
@@ -832,31 +940,59 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
})
|
||||
if (sessionID === currentSessionID()) setLoading(false)
|
||||
|
||||
// Preserve optimistic messages that haven't been confirmed yet.
|
||||
// The server may not have created the message record by the time
|
||||
// this session's messages are loaded (e.g. on session switch).
|
||||
const pending = pendingOptimistic.get(sessionID)
|
||||
if (pending && pending.size > 0) {
|
||||
const loadedIds = new Set(messages.map((m) => m.id))
|
||||
const current = store.messages[sessionID] ?? []
|
||||
const orphans = current.filter((m) => pending.has(m.id) && !loadedIds.has(m.id))
|
||||
setStore("messages", sessionID, reconcile([...messages, ...orphans], { key: "id" }))
|
||||
const current = store.messages[sessionID] ?? []
|
||||
const merged =
|
||||
mode === "prepend" || mode === "reconcile"
|
||||
? mergeMessages(current, messages, mode)
|
||||
: withPending(sessionID, messages)
|
||||
// "replace" mode (session switch): assign directly — reconcile's O(n)
|
||||
// diff is unnecessary when the entire list is new, and its reactive
|
||||
// proxy creation for each message object dominated the trace (~900ms).
|
||||
// "prepend" / "reconcile": reconcile to preserve existing proxies.
|
||||
if (mode === "replace") {
|
||||
setStore("messages", sessionID, merged)
|
||||
} else {
|
||||
setStore("messages", sessionID, reconcile(messages, { key: "id" }))
|
||||
setStore("messages", sessionID, reconcile(merged, { key: "id" }))
|
||||
}
|
||||
|
||||
// Also extract parts from messages
|
||||
for (const msg of messages) {
|
||||
if (msg.parts && msg.parts.length > 0) {
|
||||
if (!msg.parts || msg.parts.length === 0) continue
|
||||
if (mode === "reconcile" && store.parts[msg.id]) {
|
||||
// Reconcile on a message already hydrated into the reactive store:
|
||||
// write parts directly so visible turns pick up the server-
|
||||
// authoritative state immediately instead of waiting for the
|
||||
// virtualizer to re-render.
|
||||
setStore("parts", msg.id, reconcile(msg.parts, { key: "id" }))
|
||||
stash.remove(msg.id)
|
||||
} else {
|
||||
// Stash parts outside the reactive store — they'll be hydrated
|
||||
// on demand when the virtualizer renders the corresponding turn.
|
||||
stash.put(msg.id, msg.parts)
|
||||
}
|
||||
}
|
||||
|
||||
const agent = resolveSessionAgent(messages, agentNames())
|
||||
// "reconcile" is a background tail refresh, not a page navigation —
|
||||
// preserve the existing pagination cursor/hasMore so "load earlier"
|
||||
// keeps working.
|
||||
if (mode === "reconcile") {
|
||||
patchPage(sessionID, { initialLoaded: true, lastMutation: "update" })
|
||||
} else {
|
||||
setPages(sessionID, {
|
||||
initialLoaded: true,
|
||||
loadingInitial: false,
|
||||
loadingOlder: false,
|
||||
before: input.cursor,
|
||||
hasMore: input.hasMore ?? Boolean(input.cursor),
|
||||
lastMutation: mode,
|
||||
})
|
||||
}
|
||||
|
||||
const agent = resolveSessionAgent(merged, agentNames())
|
||||
if (agent) {
|
||||
setStore("agentSelections", sessionID, agent)
|
||||
}
|
||||
})
|
||||
if (reset) requestAnimationFrame(() => patchPage(sessionID, { lastMutation: undefined }))
|
||||
}
|
||||
|
||||
function handleMessageCreated(message: Message) {
|
||||
@@ -877,6 +1013,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
)
|
||||
}
|
||||
|
||||
const exists = (store.messages[message.sessionID] ?? []).some((msg) => msg.id === message.id)
|
||||
setStore("messages", message.sessionID, (msgs = []) => {
|
||||
// Check if message already exists (optimistic or update case).
|
||||
// Since we now use the same messageID for optimistic and server messages,
|
||||
@@ -889,6 +1026,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
}
|
||||
return [...msgs, message]
|
||||
})
|
||||
patchPage(message.sessionID, { initialLoaded: true, lastMutation: exists ? "update" : "append" })
|
||||
|
||||
// Sync mode picker from any message role (user or assistant).
|
||||
// agentNames() already excludes subagent/hidden agents, so subtask
|
||||
@@ -899,6 +1037,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
}
|
||||
|
||||
if (message.parts && message.parts.length > 0) {
|
||||
stash.remove(message.id)
|
||||
setStore("parts", message.id, message.parts)
|
||||
}
|
||||
}
|
||||
@@ -917,6 +1056,16 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionID) patchPage(sessionID, { lastMutation: "update" })
|
||||
|
||||
// If the stash has parts for this message, hydrate them first so the
|
||||
// SSE update merges into the full part list rather than an empty array.
|
||||
const stashed = stash.peek(effectiveMessageID)
|
||||
if (stashed) {
|
||||
stash.remove(effectiveMessageID)
|
||||
setStore("parts", effectiveMessageID, stashed)
|
||||
}
|
||||
|
||||
setStore(
|
||||
"parts",
|
||||
produce((parts) => {
|
||||
@@ -1094,6 +1243,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
function handleSendMessageFailed(message: SendMessageFailedMessage) {
|
||||
if (message.sessionID && message.messageID) {
|
||||
pendingOptimistic.get(message.sessionID)?.delete(message.messageID)
|
||||
stash.remove(message.messageID)
|
||||
batch(() => {
|
||||
setStore("messages", message.sessionID!, (msgs = []) => msgs.filter((m) => m.id !== message.messageID))
|
||||
setStore(
|
||||
@@ -1239,9 +1389,10 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
function handleSessionDeleted(sessionID: string) {
|
||||
pendingOptimistic.delete(sessionID)
|
||||
batch(() => {
|
||||
// Collect message IDs so we can clean up their parts
|
||||
// Collect message IDs so we can clean up their parts (store + stash)
|
||||
const msgs = store.messages[sessionID] ?? []
|
||||
const msgIds = msgs.map((m) => m.id)
|
||||
for (const id of msgIds) stash.remove(id)
|
||||
|
||||
setStore(
|
||||
"sessions",
|
||||
@@ -1269,6 +1420,11 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
delete todos[sessionID]
|
||||
}),
|
||||
)
|
||||
setPages(
|
||||
produce((map) => {
|
||||
delete map[sessionID]
|
||||
}),
|
||||
)
|
||||
setStore(
|
||||
"agentSelections",
|
||||
produce((selections) => {
|
||||
@@ -1334,6 +1490,10 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
delete parts[messageID]
|
||||
}),
|
||||
)
|
||||
// Also clear any stashed parts for this message. Without this, a
|
||||
// removed-before-hydrated message leaks parts in the stash and can
|
||||
// resurface them via getParts() after the message is gone.
|
||||
stash.remove(messageID)
|
||||
}
|
||||
|
||||
function handleCloudSessionDataLoaded(cloudSessionId: string, title: string, messages: Message[]) {
|
||||
@@ -1352,6 +1512,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
patchPage(key, { initialLoaded: true, hasMore: false, lastMutation: "replace" })
|
||||
setStore("messages", key, messages)
|
||||
for (const msg of messages) {
|
||||
if (msg.parts && msg.parts.length > 0) {
|
||||
@@ -1425,7 +1586,8 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
})
|
||||
// Load real messages in the background (picks up server-assigned IDs
|
||||
// and the new user message once the send completes via SSE)
|
||||
vscode.postMessage({ type: "loadMessages", sessionID: session.id })
|
||||
patchPage(session.id, { loadingInitial: true, before: undefined, hasMore: false })
|
||||
vscode.postMessage({ type: "loadMessages", sessionID: session.id, mode: "replace", limit: MESSAGE_PAGE_LIMIT })
|
||||
}
|
||||
|
||||
// Actions
|
||||
@@ -1483,6 +1645,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
|
||||
setStore("messages", sid, (msgs = []) => [...msgs, temp])
|
||||
setStore("parts", messageID, parts)
|
||||
patchPage(sid, { initialLoaded: true, lastMutation: "append" })
|
||||
queueMicrotask(() => window.dispatchEvent(new CustomEvent("resumeAutoScroll")))
|
||||
}
|
||||
|
||||
@@ -1754,6 +1917,21 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
vscode.postMessage({ type: "loadSessions" })
|
||||
}
|
||||
|
||||
function loadOlderMessages() {
|
||||
const id = currentSessionID()
|
||||
if (!id || !server.isConnected()) return
|
||||
const page = pages[id] ?? emptyPageState
|
||||
if (!page.hasMore || page.loadingOlder || page.loadingInitial || !page.before) return
|
||||
patchPage(id, { loadingOlder: true })
|
||||
vscode.postMessage({
|
||||
type: "loadMessages",
|
||||
sessionID: id,
|
||||
mode: "prepend",
|
||||
before: page.before,
|
||||
limit: MESSAGE_PAGE_LIMIT,
|
||||
})
|
||||
}
|
||||
|
||||
function selectSession(id: string) {
|
||||
if (!server.isConnected()) {
|
||||
console.warn("[Kilo New] Cannot select session: not connected")
|
||||
@@ -1763,10 +1941,16 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
console.warn("[Kilo New] Cannot select cloud preview session via selectSession")
|
||||
return
|
||||
}
|
||||
const ready = loaded().has(id)
|
||||
setCurrentSessionID(id)
|
||||
setDraftSessionID(id)
|
||||
setLoading(!loaded().has(id))
|
||||
vscode.postMessage({ type: "loadMessages", sessionID: id })
|
||||
setLoading(!ready)
|
||||
if (ready) {
|
||||
vscode.postMessage({ type: "loadMessages", sessionID: id, mode: "focus" })
|
||||
return
|
||||
}
|
||||
patchPage(id, { loadingInitial: true, loadingOlder: false, before: undefined, hasMore: false })
|
||||
vscode.postMessage({ type: "loadMessages", sessionID: id, mode: "replace", limit: MESSAGE_PAGE_LIMIT })
|
||||
}
|
||||
|
||||
function selectCloudSession(cloudSessionId: string) {
|
||||
@@ -1817,13 +2001,33 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
return id ? store.sessions[id] : undefined
|
||||
}
|
||||
|
||||
const pageState = () => {
|
||||
const id = currentSessionID()
|
||||
return id ? (pages[id] ?? emptyPageState) : emptyPageState
|
||||
}
|
||||
|
||||
const loadingOlderMessages = () => pageState().loadingOlder
|
||||
const hasOlderMessages = () => pageState().hasMore
|
||||
const messageMutation = () => pageState().lastMutation
|
||||
|
||||
const messages = () => {
|
||||
const id = currentSessionID()
|
||||
return id ? store.messages[id] || [] : []
|
||||
}
|
||||
|
||||
const getParts = (messageID: string) => {
|
||||
return store.parts[messageID] || []
|
||||
return store.parts[messageID] || stash.peek(messageID) || []
|
||||
}
|
||||
|
||||
function hydrateParts(ids: string[]) {
|
||||
const pending = stash.take(ids, (id) => Boolean(store.parts[id]))
|
||||
if (Object.keys(pending).length === 0) return
|
||||
setStore(
|
||||
"parts",
|
||||
produce((p) => {
|
||||
for (const [id, parts] of Object.entries(pending)) p[id] = parts
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const allMessages = () => store.messages
|
||||
@@ -1962,9 +2166,13 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
statusText,
|
||||
busySince,
|
||||
loading,
|
||||
loadingOlderMessages,
|
||||
hasOlderMessages,
|
||||
messageMutation,
|
||||
messages,
|
||||
userMessages,
|
||||
getParts,
|
||||
hydrateParts,
|
||||
todos,
|
||||
permissions,
|
||||
respondingPermissions,
|
||||
@@ -2042,6 +2250,7 @@ export const SessionProvider: ParentComponent = (props) => {
|
||||
createSession,
|
||||
clearCurrentSession,
|
||||
loadSessions,
|
||||
loadOlderMessages,
|
||||
selectSession,
|
||||
deleteSession,
|
||||
renameSession,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { SlashCommandInfo, WebviewMessage, ExtensionMessage } from "../types/messages"
|
||||
|
||||
@@ -39,6 +39,7 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set<string>): S
|
||||
const [server, setServer] = createSignal<SlashCommandInfo[]>([])
|
||||
const [query, setQuery] = createSignal<string | null>(null)
|
||||
const [index, setIndex] = createSignal(0)
|
||||
const [requested, setRequested] = createSignal(false)
|
||||
|
||||
const all: SlashCommandEntry[] = [
|
||||
{
|
||||
@@ -118,6 +119,12 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set<string>): S
|
||||
|
||||
const show = () => query() !== null
|
||||
|
||||
const request = () => {
|
||||
if (requested()) return
|
||||
setRequested(true)
|
||||
vscode.postMessage({ type: "requestCommands" })
|
||||
}
|
||||
|
||||
const results = () => {
|
||||
const q = query()
|
||||
if (q === null) return []
|
||||
@@ -137,10 +144,6 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set<string>): S
|
||||
setServer(message.commands)
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
vscode.postMessage({ type: "requestCommands" })
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
unsubscribe()
|
||||
})
|
||||
@@ -153,6 +156,7 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set<string>): S
|
||||
const before = val.substring(0, cursor)
|
||||
const match = before.match(SLASH_PATTERN)
|
||||
if (match) {
|
||||
request()
|
||||
setQuery(match[1])
|
||||
setIndex(0)
|
||||
} else {
|
||||
|
||||
@@ -150,6 +150,9 @@ export function mockSessionValue(overrides?: {
|
||||
statusText: () => (status === "idle" ? undefined : "Thinking…"),
|
||||
busySince: () => (status === "busy" ? Date.now() - 2000 : undefined),
|
||||
loading: () => false,
|
||||
loadingOlderMessages: () => false,
|
||||
hasOlderMessages: () => false,
|
||||
messageMutation: () => undefined,
|
||||
messages: () => [],
|
||||
userMessages: () => [],
|
||||
allMessages: () => ({}),
|
||||
@@ -157,6 +160,7 @@ export function mockSessionValue(overrides?: {
|
||||
allStatusMap: () => ({}),
|
||||
familyData: () => ({ messages: {}, parts: {}, status: {} }),
|
||||
getParts: () => [],
|
||||
hydrateParts: noop,
|
||||
todos: () => [],
|
||||
permissions: () => permissions,
|
||||
respondingPermissions: () => new Set<string>(),
|
||||
@@ -209,6 +213,7 @@ export function mockSessionValue(overrides?: {
|
||||
createSession: noop,
|
||||
clearCurrentSession: noop,
|
||||
loadSessions: noop,
|
||||
loadOlderMessages: noop,
|
||||
selectSession: noop,
|
||||
deleteSession: noop,
|
||||
renameSession: noop,
|
||||
|
||||
@@ -1040,7 +1040,13 @@ export const DiffSummaryCollapsed: Story = {
|
||||
<ServerContext.Provider value={server as any}>
|
||||
<SessionContext.Provider value={session as any}>
|
||||
<div style={{ width: "380px", padding: "12px" }}>
|
||||
<VscodeSessionTurn sessionID={SESSION_ID} messageID={USER_MSG_ID} />
|
||||
<VscodeSessionTurn
|
||||
turn={{
|
||||
id: USER_MSG_ID,
|
||||
user: data.message[SESSION_ID][0] as any,
|
||||
assistant: [data.message[SESSION_ID][1] as any],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SessionContext.Provider>
|
||||
</ServerContext.Provider>
|
||||
|
||||
@@ -63,6 +63,33 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message-list-page-loader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message-list-load-older {
|
||||
display: block;
|
||||
margin: 0 auto 12px;
|
||||
border: 1px solid var(--vscode-button-border, transparent);
|
||||
border-radius: 6px;
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
cursor: pointer;
|
||||
padding: 5px 10px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message-list-load-older:hover {
|
||||
background: var(--vscode-button-secondaryHoverBackground);
|
||||
}
|
||||
|
||||
.message-list-content {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
|
||||
@@ -589,10 +589,15 @@ export interface MessageRemovedMessage {
|
||||
messageID: string
|
||||
}
|
||||
|
||||
export type MessageLoadMode = "replace" | "prepend" | "focus" | "reconcile"
|
||||
|
||||
export interface MessagesLoadedMessage {
|
||||
type: "messagesLoaded"
|
||||
sessionID: string
|
||||
messages: Message[]
|
||||
mode?: Exclude<MessageLoadMode, "focus">
|
||||
cursor?: string
|
||||
hasMore?: boolean
|
||||
}
|
||||
|
||||
export interface MessageCreatedMessage {
|
||||
@@ -1685,6 +1690,9 @@ export interface ClearSessionRequest {
|
||||
export interface LoadMessagesRequest {
|
||||
type: "loadMessages"
|
||||
sessionID: string
|
||||
mode?: MessageLoadMode
|
||||
before?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface LoadSessionsRequest {
|
||||
|
||||
@@ -1033,10 +1033,15 @@ export namespace Config {
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"),
|
||||
remote_control: z // kilocode_change
|
||||
// kilocode_change start
|
||||
// NOTE: Any new kilocode_change key added to Config.Info must also be mirrored in
|
||||
// apps/web/src/app/config.json/extras.ts in the cloud repo, otherwise
|
||||
// $schema: https://app.kilo.ai/config.json will not recognize it.
|
||||
remote_control: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Enable remote control of sessions via Kilo Cloud. Equivalent to running /remote on startup."),
|
||||
// kilocode_change end
|
||||
autoupdate: z
|
||||
.union([z.boolean(), z.literal("notify")])
|
||||
.optional()
|
||||
|
||||
@@ -475,40 +475,6 @@ export type EventTodoUpdated = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionStatus =
|
||||
| {
|
||||
type: "idle"
|
||||
}
|
||||
| {
|
||||
type: "retry"
|
||||
attempt: number
|
||||
message: string
|
||||
next: number
|
||||
}
|
||||
| {
|
||||
type: "busy"
|
||||
}
|
||||
| {
|
||||
type: "offline"
|
||||
requestID: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type EventSessionStatus = {
|
||||
type: "session.status"
|
||||
properties: {
|
||||
sessionID: string
|
||||
status: SessionStatus
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionIdle = {
|
||||
type: "session.idle"
|
||||
properties: {
|
||||
sessionID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SuggestionAction = {
|
||||
/**
|
||||
* Button or option label (1-5 words)
|
||||
@@ -568,6 +534,40 @@ export type EventSuggestionDismissed = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionStatus =
|
||||
| {
|
||||
type: "idle"
|
||||
}
|
||||
| {
|
||||
type: "retry"
|
||||
attempt: number
|
||||
message: string
|
||||
next: number
|
||||
}
|
||||
| {
|
||||
type: "busy"
|
||||
}
|
||||
| {
|
||||
type: "offline"
|
||||
requestID: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type EventSessionStatus = {
|
||||
type: "session.status"
|
||||
properties: {
|
||||
sessionID: string
|
||||
status: SessionStatus
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionIdle = {
|
||||
type: "session.idle"
|
||||
properties: {
|
||||
sessionID: string
|
||||
}
|
||||
}
|
||||
|
||||
export type EventSessionCompacted = {
|
||||
type: "session.compacted"
|
||||
properties: {
|
||||
@@ -1145,11 +1145,11 @@ export type Event =
|
||||
| EventQuestionReplied
|
||||
| EventQuestionRejected
|
||||
| EventTodoUpdated
|
||||
| EventSessionStatus
|
||||
| EventSessionIdle
|
||||
| EventSuggestionShown
|
||||
| EventSuggestionAccepted
|
||||
| EventSuggestionDismissed
|
||||
| EventSessionStatus
|
||||
| EventSessionIdle
|
||||
| EventSessionCompacted
|
||||
| EventKiloSessionsRemoteStatusChanged
|
||||
| EventWorkspaceReady
|
||||
|
||||
@@ -6,6 +6,7 @@ import { checksum } from "@opencode-ai/util/encode"
|
||||
import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js"
|
||||
import { isServer } from "solid-js/web"
|
||||
import { stream } from "./markdown-stream"
|
||||
import { tryFastRender } from "../kilocode/markdown-fast-path" // kilocode_change
|
||||
|
||||
type Entry = {
|
||||
hash: string
|
||||
@@ -308,6 +309,16 @@ export function Markdown(
|
||||
copy: i18n.t("ui.message.copy"),
|
||||
copied: i18n.t("ui.message.copied"),
|
||||
}
|
||||
|
||||
// kilocode_change start
|
||||
const fast = tryFastRender(container, content, local.streaming, decorate, setupCodeCopy, () => labels, copyCleanup)
|
||||
if (fast.handled) {
|
||||
copyCleanup = fast.copyCleanup
|
||||
kickHighlight(container, labels)
|
||||
return
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
const temp = document.createElement("div")
|
||||
temp.innerHTML = content
|
||||
decorate(temp, labels)
|
||||
@@ -356,14 +367,37 @@ export function Markdown(
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
if (!copyCleanup)
|
||||
copyCleanup = setupCodeCopy(container, () => ({
|
||||
copy: i18n.t("ui.message.copy"),
|
||||
copied: i18n.t("ui.message.copied"),
|
||||
}))
|
||||
kickHighlight(container, labels)
|
||||
})
|
||||
|
||||
// kilocode_change start: progressive Shiki highlighting (issue #6221, PR #7102).
|
||||
// Parser emits plain <pre><code data-lang="..."> blocks; we upgrade them to
|
||||
// Shiki-highlighted <pre class="shiki"> here via setTimeout(0) so initial
|
||||
// paint is instant and session switches with many code blocks don't freeze.
|
||||
// The generation counter + abort signal cancel a previous in-flight pass
|
||||
// when streaming tokens (or session switches) spawn a new render.
|
||||
function kickHighlight(container: HTMLDivElement, labels: { copy: string; copied: string }) {
|
||||
highlightState.signal.aborted = true
|
||||
const gen = ++highlightState.gen
|
||||
const signal = { aborted: false }
|
||||
highlightState.signal = signal
|
||||
void deferredHighlight(
|
||||
container,
|
||||
() => {
|
||||
if (gen !== highlightState.gen) return
|
||||
if (copyCleanup) copyCleanup()
|
||||
copyCleanup = setupCodeCopy(container, () => labels)
|
||||
},
|
||||
signal,
|
||||
)
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
onCleanup(() => {
|
||||
// kilocode_change: cancel any in-flight deferredHighlight pass so its
|
||||
// completion callback doesn't touch the unmounted DOM.
|
||||
highlightState.signal.aborted = true
|
||||
highlightState.gen++
|
||||
if (copyCleanup) copyCleanup()
|
||||
})
|
||||
|
||||
|
||||
@@ -1596,6 +1596,7 @@ ToolRegistry.register({
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="bullet-list"
|
||||
defer
|
||||
trigger={{ title: i18n.t("ui.tool.list"), subtitle: getDirectory(props.input.path || "/") }}
|
||||
>
|
||||
<Show when={props.output}>
|
||||
@@ -1616,6 +1617,7 @@ ToolRegistry.register({
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="magnifying-glass-menu"
|
||||
defer
|
||||
trigger={{
|
||||
title: i18n.t("ui.tool.glob"),
|
||||
subtitle: getDirectory(props.input.path || "/"),
|
||||
@@ -1643,6 +1645,7 @@ ToolRegistry.register({
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="magnifying-glass-menu"
|
||||
defer
|
||||
trigger={{
|
||||
title: i18n.t("ui.tool.grep"),
|
||||
subtitle: getDirectory(props.input.path || "/"),
|
||||
@@ -1863,6 +1866,7 @@ ToolRegistry.register({
|
||||
<BasicTool
|
||||
{...props}
|
||||
icon="console"
|
||||
defer
|
||||
trigger={
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { marked } from "marked"
|
||||
import markedKatex from "marked-katex-extension"
|
||||
import markedShiki from "marked-shiki"
|
||||
// kilocode_change: marked-shiki highlighted code blocks synchronously during
|
||||
// parse, freezing the main thread on session switches with many code blocks
|
||||
// (issue #6221 / PR #7102). We render plain <pre><code data-lang="..."> here
|
||||
// and hand off to deferredHighlight() in markdown.tsx for progressive Shiki.
|
||||
// This import was re-added by an upstream merge; removing it restores the
|
||||
// two-pass rendering design.
|
||||
import katex from "katex"
|
||||
import { bundledLanguages, type BundledLanguage } from "shiki"
|
||||
import { parseFilePath } from "../file-path" // kilocode_change
|
||||
@@ -670,26 +675,10 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext(
|
||||
throwOnError: false,
|
||||
nonStandard: true,
|
||||
}),
|
||||
markedShiki({
|
||||
async highlight(code, lang) {
|
||||
const highlighter = await getSharedHighlighter({
|
||||
themes: ["Kilo"],
|
||||
langs: [],
|
||||
preferredHighlighter: "shiki-wasm",
|
||||
})
|
||||
if (!(lang in bundledLanguages)) {
|
||||
lang = "text"
|
||||
}
|
||||
if (!highlighter.getLoadedLanguages().includes(lang)) {
|
||||
await highlighter.loadLanguage(lang as BundledLanguage)
|
||||
}
|
||||
return highlighter.codeToHtml(code, {
|
||||
lang: lang || "text",
|
||||
theme: "Kilo",
|
||||
tabindex: false,
|
||||
})
|
||||
},
|
||||
}),
|
||||
// kilocode_change: markedShiki removed — the custom `code` renderer
|
||||
// above returns plain <pre><code data-lang="..."> and markdown.tsx
|
||||
// calls deferredHighlight() after paint. Running Shiki inside parse
|
||||
// blocks the main thread on session switches (issue #6221).
|
||||
)
|
||||
// kilocode_change end
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Fast-path initial render for completed (non-streaming) markdown blocks.
|
||||
// Skips morphdom's expensive tree-matching by writing innerHTML directly
|
||||
// when the container is empty. On large session switches this avoids the
|
||||
// dominant "Parse HTML + morphdom diff" cost for historical messages.
|
||||
|
||||
type CopyLabels = { copy: string; copied: string }
|
||||
|
||||
/**
|
||||
* If the content is a first paint of completed markdown (not streaming,
|
||||
* container empty), render directly via innerHTML and return true.
|
||||
* The caller should skip morphdom when this returns true.
|
||||
*/
|
||||
export function tryFastRender(
|
||||
container: HTMLDivElement,
|
||||
content: string,
|
||||
streaming: boolean | undefined,
|
||||
decorate: (root: HTMLDivElement, labels: CopyLabels) => void,
|
||||
setupCopy: (root: HTMLDivElement, getLabels: () => CopyLabels) => (() => void) | undefined,
|
||||
getLabels: () => CopyLabels,
|
||||
copyCleanup: (() => void) | undefined,
|
||||
): { handled: boolean; copyCleanup: (() => void) | undefined } {
|
||||
if (streaming || container.childNodes.length > 0) {
|
||||
return { handled: false, copyCleanup }
|
||||
}
|
||||
container.innerHTML = content
|
||||
decorate(container, getLabels())
|
||||
const cleanup = copyCleanup ?? setupCopy(container, getLabels)
|
||||
return { handled: true, copyCleanup: cleanup }
|
||||
}
|
||||
Reference in New Issue
Block a user