mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
wip: save working tree before merging main
This commit is contained in:
@@ -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>(() => {
|
||||
|
||||
Reference in New Issue
Block a user