wip: save working tree before merging main

This commit is contained in:
marius-kilocode
2026-08-17 08:51:44 +02:00
parent f8490f3a33
commit 4ea07a0b23
9 changed files with 199 additions and 155 deletions
+11 -4
View File
@@ -56,14 +56,21 @@ function enqueue(job: Job) {
schedule()
}
// When a large batch of diffs becomes near-visible at once, render one diff per
// animation frame. This keeps the UI responsive while preserving expanded state.
const FRAME_BUDGET_MS = 12
// When a batch of diffs becomes near-visible at once, render within a per-frame
// time budget so visible diffs populate smoothly in the same frame while preserving
// 60fps responsiveness for large lists.
function schedule() {
if (frame !== undefined) return
frame = requestAnimationFrame(() => {
frame = undefined
const job = queue.shift()
if (job && !job.cancelled) job.run()
const deadline = performance.now() + FRAME_BUDGET_MS
while (queue.length > 0) {
const job = queue.shift()
if (job && !job.cancelled) job.run()
if (performance.now() >= deadline) break
}
if (queue.length > 0) schedule()
})
}
@@ -589,6 +589,29 @@ describe("Cloud import parts cleanup contract", () => {
})
})
describe("Optimistic parts preservation and smooth status contract", () => {
const source = readFile(SESSION_FILE)
it("handleMessageCreated preserves optimistic parts instead of deleting them", () => {
const created = extractFunctionBody(source, "handleMessageCreated")
expect(created).not.toMatch(/delete\s+p\[message\.id\]/)
expect(created).toContain("pendingOptimistic.get(message.sessionID)")
})
it("handlePartUpdated replaces matching optimistic parts in place", () => {
const updated = extractFunctionBody(source, "handlePartUpdated")
expect(updated).toContain("pendingOptimisticParts.get(effectiveMessageID)")
expect(updated).toContain("optIds.delete")
})
it("statusText derives status only from the active turn after the last user message", () => {
const match = source.match(/const statusText = createMemo<string \| undefined>\(\(\) => \{([\s\S]*?)\n \}\)/)
expect(match).not.toBeNull()
expect(match![1]).toContain('msgs.findLastIndex((m) => m.role === "user")')
expect(match![1]).toContain('language.t("ui.sessionTurn.status.thinking")')
})
})
describe("KiloConnectionService pruneSession contract", () => {
const source = readFile(CONNECTION_SERVICE_FILE)
@@ -101,11 +101,41 @@ describe("queuedUserMessageIDs", () => {
expect(queuedUserMessageIDs(messages, { type: "busy" })).toEqual(["message_3"])
})
it("returns no queued messages while idle", () => {
it("returns no queued messages while idle without submitting flag", () => {
const messages = [user("message_1"), user("message_2")]
expect(queuedUserMessageIDs(messages, { type: "idle" })).toEqual([])
})
it("queues follow-ups while idle when submitting is true", () => {
const messages = [user("message_1"), user("message_2")]
expect(queuedUserMessageIDs(messages, { type: "idle" }, undefined, true)).toEqual(["message_2"])
})
})
describe("activeUserMessageID", () => {
it("returns undefined while idle and not submitting", () => {
const messages = [user("message_1")]
expect(activeUserMessageID(messages, { type: "idle" })).toBeUndefined()
})
it("returns the pending user message while idle when submitting is true", () => {
const messages = [user("message_1")]
expect(activeUserMessageID(messages, { type: "idle" }, undefined, true)).toBe("message_1")
})
it("returns the next pending user message after finished turn when submitting is true", () => {
const messages = [
user("message_1"),
assistant("message_2", "message_1", { finish: "stop", time: { created: 1, completed: 2 } }),
user("message_3"),
]
expect(activeUserMessageID(messages, { type: "idle" }, undefined, true)).toBe("message_3")
})
})
describe("partitionTurns", () => {
@@ -132,7 +132,21 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
})
const localComposer = createReviewComposer()
const composer = () => props.composer ?? localComposer
const [open, setOpen] = createSignal<string[]>([])
const [manualOpen, setManualOpen] = createSignal<Record<string, string[]>>({})
const open = createMemo(() => {
const key = props.sessionKey ?? ""
const diffs = props.diffs
if (diffs.length === 0) return []
const manual = manualOpen()[key]
if (manual) return sanitizeOpenFiles(diffs, manual)
return initialOpenFiles(diffs)
})
const setOpen = (files: string[] | ((prev: string[]) => string[])) => {
const key = props.sessionKey ?? ""
const current = open()
const next = typeof files === "function" ? files(current) : files
setManualOpen((prev) => ({ ...prev, [key]: sanitizeOpenFiles(props.diffs, next) }))
}
const [draft, setDraft] = createSignal<ReviewDraft | null>(reviewComposerDraft(composer()))
const [editing, setEditing] = createSignal<string | null>(reviewComposerEdit(composer()))
const speechKeys = createMemo(() => {
@@ -151,10 +165,6 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
keys: speechKeys,
})
let nextId = 0
// Initialize each worktree with every file expanded, then preserve manual
// collapse state while adding and removing files from live summaries.
let initializedKey: string | undefined
let known = new Set<string>()
// Reorder diffs to match the file-tree's depth-first visual order so
// scrolling through the accordion matches the tree grouping.
@@ -217,43 +227,6 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
focusRoot()
}
// Unified open-state effect: tracks both sessionKey and diffs in a single effect
// to eliminate the race condition between the old separate sessionKey-reset and
// diffs-watch effects. Uses the session key to decide when initialization is needed
// vs when we just prune stale entries from the open list.
createEffect(
on(
() => [props.sessionKey, props.diffs] as const,
([key, diffs]) => {
// No diffs yet (async fetch in progress) — don't mark as initialized
// so auto-open runs when data arrives.
// Important: do not prune on empty, otherwise transient empty updates
// collapse all files and they stay collapsed for the same key.
if (diffs.length === 0) return
const fileSet = new Set(diffs.map((diff) => diff.file))
// New context: initialize open state from the diff policy.
if (key !== initializedKey) {
initializedKey = key
known = fileSet
setOpen(initialOpenFiles(diffs))
return
}
// Preserve manual collapse state for known files, while keeping newly
// arriving files expanded when a live summary grows.
const added = diffs.filter((diff) => !known.has(diff.file)).map((diff) => diff.file)
known = fileSet
setOpen((prev) => {
const next = sanitizeOpenFiles(diffs, [...prev.filter((file) => fileSet.has(file)), ...added])
if (next.length === prev.length && next.every((file, index) => file === prev[index])) return prev
return next
})
},
),
)
createEffect(
on(
() => props.sessionKey,
@@ -134,7 +134,36 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
})
const localComposer = createReviewComposer()
const composer = () => props.composer ?? localComposer
const [open, setOpen] = createSignal<string[]>([])
const [manualOpen, setManualOpen] = createSignal<Record<string, string[]>>({})
const open = createMemo(() => {
const key = props.sessionKey ?? ""
const diffs = props.diffs
if (diffs.length === 0) return []
const manual = manualOpen()[key]
if (manual) return sanitizeOpenFiles(diffs, manual)
return initialOpenFiles(diffs)
})
const setOpen = (files: string[] | ((prev: string[]) => string[])) => {
const key = props.sessionKey ?? ""
const current = open()
const next = typeof files === "function" ? files(current) : files
setManualOpen((prev) => ({ ...prev, [key]: sanitizeOpenFiles(props.diffs, next) }))
}
const [manualActiveFile, setManualActiveFile] = createSignal<Record<string, string | null>>({})
const activeFile = createMemo(() => {
const key = props.sessionKey ?? ""
const diffs = props.diffs
if (diffs.length === 0) return null
const manual = manualActiveFile()[key]
if (manual && diffs.some((d) => d.file === manual)) return manual
return diffs[0]?.file ?? null
})
const setActiveFile = (file: string | null) => {
const key = props.sessionKey ?? ""
setManualActiveFile((prev) => ({ ...prev, [key]: file }))
}
const [draft, setDraft] = createSignal<ReviewDraft | null>(reviewComposerDraft(composer()))
const [editing, setEditing] = createSignal<string | null>(reviewComposerEdit(composer()))
const speechKeys = createMemo(() => {
@@ -152,15 +181,10 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
label: t,
keys: speechKeys,
})
const [activeFile, setActiveFile] = createSignal<string | null>(null)
const [treeWidth, setTreeWidth] = createSignal(240)
let nextId = 0
let draftMeta: AnnotationMeta | null = composer().draft
let editMeta: AnnotationMeta | null = composer().edit
// Initialize each worktree with every file expanded, then preserve manual
// collapse state while adding and removing files from live summaries.
let initializedKey: string | undefined
let known = new Set<string>()
let rootRef: HTMLDivElement | undefined
const [scroller, setScroller] = createSignal<HTMLDivElement>()
const [virtualizer, setVirtualizer] = createSignal<VirtualizerHandle>()
@@ -214,50 +238,6 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
focusRoot()
}
// Unified open-state effect: tracks both sessionKey and diffs in a single effect
// to eliminate the race condition between the old separate sessionKey-reset and
// diffs-watch effects. Uses the session key to decide when initialization is needed
// vs when we just prune stale entries from the open list.
createEffect(
on(
() => [props.sessionKey, props.diffs] as const,
([key, diffs]) => {
if (diffs.length === 0) {
// No diffs yet — clear active file only for a new key; keep current
// selection for transient empty updates in the same key.
if (key !== initializedKey) setActiveFile(null)
return
}
const fileSet = new Set(diffs.map((diff) => diff.file))
// Keep active file in sync — pick first if current is stale
const current = activeFile()
if (!current || !diffs.some((d) => d.file === current)) {
setActiveFile(diffs[0]!.file)
}
// New context: initialize open state from the diff policy.
if (key !== initializedKey) {
initializedKey = key
known = fileSet
setOpen(initialOpenFiles(diffs))
return
}
// Preserve manual collapse state for known files, while keeping newly
// arriving files expanded when a live summary grows.
const added = diffs.filter((diff) => !known.has(diff.file)).map((diff) => diff.file)
known = fileSet
setOpen((prev) => {
const next = sanitizeOpenFiles(diffs, [...prev.filter((file) => fileSet.has(file)), ...added])
if (next.length === prev.length && next.every((file, index) => file === prev[index])) return prev
return next
})
},
),
)
createEffect(
on(
() => props.sessionKey,
@@ -179,10 +179,23 @@ export const MessageList: Component<MessageListProps> = (props) => {
const isEmpty = () => turns().length === 0 && !session.loading() && !revert()
const activeUserID = createMemo(() =>
getActiveUserMessageID(session.messages(), session.statusInfo(), (msg) => session.getParts(msg.id)),
getActiveUserMessageID(
session.messages(),
session.statusInfo(),
(msg) => session.getParts(msg.id),
session.submitting(),
),
)
const queuedIDs = createMemo(
() => new Set(queuedUserMessageIDs(session.messages(), session.statusInfo(), (msg) => session.getParts(msg.id))),
() =>
new Set(
queuedUserMessageIDs(
session.messages(),
session.statusInfo(),
(msg) => session.getParts(msg.id),
session.submitting(),
),
),
)
const rows = createMemo((prev: TranscriptRow[] | undefined) => {
const active = activeUserID()
@@ -198,10 +198,11 @@ export function activeUserMessageID(
messages: Message[],
status: SessionStatusInfo,
parts?: (msg: Message) => Message["parts"],
submitting?: boolean,
) {
const id = active(messages, status, parts)
if (id) return id
if (status.type === "idle") return undefined
if (status.type === "idle" && !submitting) return undefined
return pending(messages, parts)
}
@@ -209,8 +210,9 @@ export function queuedUserMessageIDs(
messages: Message[],
status: SessionStatusInfo,
parts?: (msg: Message) => Message["parts"],
submitting?: boolean,
) {
if (status.type === "idle") return []
if (status.type === "idle" && !submitting) return []
const users = messages.filter((msg) => msg.role === "user")
const running = active(messages, status, parts)
if (running) {
@@ -1,5 +1,5 @@
import { reconcile } from "solid-js/store"
import type { Message, Part, ToolPart } from "../types/messages"
import type { Message, MessageLoadMode, Part, ToolPart } from "../types/messages"
export const SNAPSHOT_PROGRESS_TEXT = "Initializing snapshot..."
@@ -458,3 +458,27 @@ export function collapseCostBreakdown(
const aggregated = hidden.reduce((sum, e) => sum + e.cost, 0)
return [root, ...visible, { label: summaryLabel(hidden.length), cost: aggregated }]
}
export function mergeMessages(current: Message[], incoming: Message[], mode: Exclude<MessageLoadMode, "focus">) {
if (mode === "reconcile") {
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
})
}
export function messageParts(messages: Message[]): Record<string, Part[]> {
const parts: Record<string, Part[]> = {}
for (const msg of messages) {
if (msg.parts && msg.parts.length > 0) parts[msg.id] = msg.parts
}
return parts
}
@@ -61,6 +61,8 @@ import {
buildCostBreakdown,
buildSessionToolParts,
childID,
mergeMessages,
messageParts,
reconcileSessionToolParts,
removeSessionToolPart,
removeSessionToolPartsForMessage,
@@ -473,6 +475,7 @@ export const SessionProvider: ParentComponent = (props) => {
// Tracks optimistic messageIDs that haven't been confirmed by the server yet.
// Prevents handleMessagesLoaded from wiping them when it replaces the array.
const pendingOptimistic = new Map<string, Set<string>>()
const pendingOptimisticParts = new Map<string, Set<string>>()
// Sessions can be created/imported while an older list request is still in flight.
// Keep them until a later list payload confirms them or deletion arrives.
const freshSessions = new Set<string>()
@@ -1380,28 +1383,6 @@ export const SessionProvider: ParentComponent = (props) => {
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 recoverPrefs(sessionID: string, messages: Message[], names = agentNames()) {
const prefs = resolveMessagePrefs(messages, names)
if (prefs.agent && !store.agentSelections[sessionID]) {
@@ -1451,14 +1432,6 @@ export const SessionProvider: ParentComponent = (props) => {
setTools(sessionID, tools)
}
function messageParts(messages: Message[]): Record<string, Part[]> {
const parts: Record<string, Part[]> = {}
for (const msg of messages) {
if (msg.parts && msg.parts.length > 0) parts[msg.id] = msg.parts
}
return parts
}
function patchToolPart(sessionID: string | undefined, messageID: string, part: Part) {
const sid = sessionID ?? part.sessionID
if (!sid) return
@@ -1523,6 +1496,7 @@ export const SessionProvider: ParentComponent = (props) => {
}
for (const msg of messages) {
pendingOptimisticParts.delete(msg.id)
const parts = msg.parts ?? []
if (mode === "reconcile" && store.parts[msg.id]) {
// Reconcile on a message already hydrated into the reactive store:
@@ -1583,22 +1557,9 @@ export const SessionProvider: ParentComponent = (props) => {
function handleMessageCreated(message: Message) {
if (message.role === "assistant") clearSessionDraftDiscarded(message.sessionID)
// Message confirmed by server — no longer optimistic.
// Clear placeholder parts so they don't duplicate alongside real parts
// arriving via individual part.updated events (the server's message.updated
// SSE event does NOT include parts).
const pending = pendingOptimistic.get(message.sessionID)
const wasOptimistic = pending?.has(message.id)
pending?.delete(message.id)
if (wasOptimistic) {
setStore(
"parts",
produce((p) => {
delete p[message.id]
}),
)
}
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).
@@ -1619,6 +1580,7 @@ export const SessionProvider: ParentComponent = (props) => {
if (message.parts && message.parts.length > 0) {
stash.remove(message.id)
setStore("parts", message.id, message.parts)
pendingOptimisticParts.delete(message.id)
}
rebuildToolParts(message.sessionID, store.messages[message.sessionID] ?? [])
}
@@ -1652,6 +1614,8 @@ export const SessionProvider: ParentComponent = (props) => {
setStore("parts", effectiveMessageID, stashed)
}
const optIds = pendingOptimisticParts.get(effectiveMessageID)
setStore(
"parts",
produce((parts) => {
@@ -1659,11 +1623,12 @@ export const SessionProvider: ParentComponent = (props) => {
parts[effectiveMessageID] = []
}
const existingIndex = parts[effectiveMessageID].findIndex((p) => p.id === part.id)
const list = parts[effectiveMessageID]
const existingIndex = list.findIndex((p) => p.id === part.id)
if (existingIndex >= 0) {
// Update existing part
const existing = parts[effectiveMessageID][existingIndex]
const existing = list[existingIndex]
if (
delta?.type === "text-delta" &&
delta.textDelta &&
@@ -1680,9 +1645,21 @@ export const SessionProvider: ParentComponent = (props) => {
}
Object.assign(existing, part)
}
} else if (optIds && optIds.size > 0) {
// Server part arrived for a message with optimistic parts:
// replace matching optimistic part in place so the prompt never flickers empty.
const optIdx = list.findIndex((p) => optIds.has(p.id) && p.type === part.type)
if (optIdx >= 0) {
const replaced = list[optIdx]
optIds.delete(replaced.id)
list[optIdx] = part
} else {
list.push(part)
}
if (optIds.size === 0) pendingOptimisticParts.delete(effectiveMessageID)
} else {
// Add new part
parts[effectiveMessageID].push(part)
list.push(part)
}
}),
)
@@ -1861,6 +1838,7 @@ export const SessionProvider: ParentComponent = (props) => {
if (!message.messageID && sid) aborts.clear(sid)
if (sid && message.messageID) {
pendingOptimistic.get(sid)?.delete(message.messageID)
pendingOptimisticParts.delete(message.messageID)
stash.remove(message.messageID)
batch(() => {
setStore("messages", sid, (msgs = []) => msgs.filter((m) => m.id !== message.messageID))
@@ -2019,7 +1997,10 @@ export const SessionProvider: ParentComponent = (props) => {
// 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)
for (const id of msgIds) {
stash.remove(id)
pendingOptimisticParts.delete(id)
}
clearHiddenErrors(msgIds)
setStore(
@@ -2238,19 +2219,24 @@ export const SessionProvider: ParentComponent = (props) => {
pendingOptimistic.set(sid, pending)
const parts: Part[] = []
const partIds = new Set<string>()
if (text) {
const partId = Identifier.ascending("part")
partIds.add(partId)
parts.push({
type: "text" as const,
id: Identifier.ascending("part"),
id: partId,
messageID,
text,
metadata: review ? reviewMetadata(review) : undefined,
})
}
for (const file of files ?? []) {
const partId = Identifier.ascending("part")
partIds.add(partId)
parts.push({
type: "file" as const,
id: Identifier.ascending("part"),
id: partId,
messageID,
mime: file.mime,
url: file.url,
@@ -2258,6 +2244,7 @@ export const SessionProvider: ParentComponent = (props) => {
source: file.source,
})
}
pendingOptimisticParts.set(messageID, partIds)
setStore("messages", sid, (msgs = []) => [...msgs, temp])
setStore("parts", messageID, parts)
@@ -2862,6 +2849,7 @@ export const SessionProvider: ParentComponent = (props) => {
function deleteQueuedMessage(sessionID: string, messageID: string) {
if (!server.isConnected()) return
pendingOptimistic.get(sessionID)?.delete(messageID)
pendingOptimisticParts.delete(messageID)
finishSubmission(messageID)
vscode.postMessage({ type: "deleteMessage", sessionID, messageID })
}
@@ -2915,16 +2903,20 @@ export const SessionProvider: ParentComponent = (props) => {
return buildCostBreakdown(id, costs, familyLabels(), language.t("context.stats.thisSession"))
})
// Status text derived from last assistant message parts
// Status text derived from current turn's assistant message parts
const statusText = createMemo<string | undefined>(() => {
if (status() === "idle") return undefined
const thinking = language.t("ui.sessionTurn.status.thinking")
const fallback = language.t("ui.sessionTurn.status.consideringNextSteps")
const id = currentSessionID()
const msgs = messages()
for (let i = msgs.length - 1; i >= 0; i--) {
const lastUserIdx = msgs.findLastIndex((m) => m.role === "user")
if (lastUserIdx < 0) return thinking
for (let i = msgs.length - 1; i > lastUserIdx; i--) {
if (msgs[i].role !== "assistant") continue
const parts = getParts(msgs[i].id)
if (parts.length === 0) break
if (parts.length === 0) return thinking
const raw = computeStatus(parts[parts.length - 1], language.t) ?? fallback
// When delegating to a subagent and that subagent is blocked on a prompt,
// replace the generic "Delegating work" label with a more informative one
@@ -2937,7 +2929,7 @@ export const SessionProvider: ParentComponent = (props) => {
}
return raw
}
return fallback
return thinking
})
const modelUsage = createMemo<SessionModelUsage | undefined>(() => {