fix(agent-manager): restore multi-project progress indicators

This commit is contained in:
marius-kilocode
2026-08-04 12:16:22 +02:00
parent fab0e96d08
commit 561d1782ae
13 changed files with 152 additions and 22 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix Agent Manager progress indicators when multiple projects are expanded.
@@ -50,6 +50,7 @@ export async function createMultiVersion(
// Notify webview that multi-version creation has started
host.post({
type: "agentManager.multiVersionProgress",
projectId: ctx.id,
status: "creating",
total: versions,
completed: 0,
@@ -78,6 +79,7 @@ export async function createMultiVersion(
// Update progress
host.post({
type: "agentManager.multiVersionProgress",
projectId: ctx.id,
status: "creating",
total: versions,
completed: created.length,
@@ -86,11 +88,19 @@ export async function createMultiVersion(
}
// Phase 2: Send the initial prompt to all sessions, or clear busy state if no text.
await sendInitialPrompts(host, created, models, { providerID, modelID }, { text, agent, variant: msg.variant, files })
await sendInitialPrompts(
host,
ctx.id,
created,
models,
{ providerID, modelID },
{ text, agent, variant: msg.variant, files },
)
// Notify completion
host.post({
type: "agentManager.multiVersionProgress",
projectId: ctx.id,
status: "done",
total: versions,
completed: created.length,
@@ -172,6 +182,7 @@ async function createVersion(
if (earlyProviderID && earlyModelID) {
host.post({
type: "agentManager.setSessionModel",
projectId: ctx.id,
sessionId: session.id,
providerID: earlyProviderID,
modelID: earlyModelID,
@@ -232,6 +243,7 @@ async function reconcileSandbox(
/** Fan the initial prompt out to every created session, throttled between sends. */
async function sendInitialPrompts(
host: MultiVersionHost,
projectId: string,
created: CreatedVersion[],
models: VersionSpec["models"],
resolved: { providerID: string | undefined; modelID: string | undefined },
@@ -254,7 +266,7 @@ async function sendInitialPrompts(
modelID: msg.modelID,
})
}
host.post({ type: "agentManager.sendInitialMessage", ...msg })
host.post({ type: "agentManager.sendInitialMessage", projectId, ...msg })
if (input.text && i < messages.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 300))
}
@@ -258,6 +258,8 @@ interface SessionClosedMessage {
interface MultiVersionProgressMessage {
type: "agentManager.multiVersionProgress"
/** Owning project; absent in single-project mode. */
projectId?: string
status: "creating" | "done"
total: number
completed: number
@@ -266,6 +268,8 @@ interface MultiVersionProgressMessage {
interface SetSessionModelMessage {
type: "agentManager.setSessionModel"
/** Owning project; absent in single-project mode. */
projectId?: string
sessionId: string
providerID: string
modelID: string
@@ -273,6 +277,8 @@ interface SetSessionModelMessage {
interface SendInitialMessage {
type: "agentManager.sendInitialMessage"
/** Owning project; absent in single-project mode. */
projectId?: string
sessionId: string
worktreeId: string
text?: string
@@ -791,6 +797,7 @@ interface FileSourceIn {
interface SendMessageIn {
type: "sendMessage"
projectId?: string
text: string
messageID?: string
sessionID?: string
@@ -5,6 +5,7 @@ describe("Agent Manager initial message", () => {
it("forwards the selected variant to sendMessage", () => {
const msg = initialMessage({
type: "agentManager.sendInitialMessage",
projectId: "project-a",
sessionId: "session-a",
worktreeId: "wt-a",
text: "Fix it",
@@ -16,6 +17,7 @@ describe("Agent Manager initial message", () => {
expect(msg).toEqual({
type: "sendMessage",
projectId: "project-a",
text: "Fix it",
sessionID: "session-a",
providerID: "anthropic",
@@ -0,0 +1,45 @@
import { describe, expect, it } from "bun:test"
import { createProjectStore } from "../../webview-ui/agent-manager/project/store"
import { clearMultiVersionBusy, markMultiVersionBusy } from "../../webview-ui/agent-manager/project/progress"
const state = (projectId: string) => ({
type: "agentManager.state" as const,
projectId,
worktrees: [
{
id: "same",
branch: `${projectId}-same`,
path: `/repo/${projectId}/same`,
parentBranch: "main",
createdAt: "2026-01-01",
groupId: "group",
},
],
sessions: [{ id: `${projectId}-session`, worktreeId: "same", createdAt: "2026-01-01" }],
sections: [],
})
describe("multi-project progress state", () => {
it("updates only the owning project's grouped worktrees", () => {
const first = createProjectStore("a")
const second = createProjectStore("b")
first.applyState(state("a"))
second.applyState(state("b"))
first.setBusy(new Map([["same", { reason: "setting-up" as const }]]))
second.setBusy(new Map([["same", { reason: "setting-up" as const }]]))
clearMultiVersionBusy(second, "group")
expect(first.busy().has("same")).toBe(true)
expect(second.busy().has("same")).toBe(false)
})
it("marks a newly created grouped worktree as busy in its project store", () => {
const store = createProjectStore("a")
store.applyState(state("a"))
markMultiVersionBusy(store, "a-session")
expect(store.busy().get("same")?.reason).toBe("setting-up")
})
})
@@ -42,4 +42,16 @@ describe("project stores", () => {
same: { worktreeId: "same", state: "running" },
})
})
it("keeps busy worktrees isolated between projects", () => {
const first = createProjectStore("a")
const second = createProjectStore("b")
first.applyState(state("a", ["same"]))
second.applyState(state("b", ["same"]))
first.setBusy(new Map([["same", { reason: "setting-up" as const }]]))
expect(first.busy().has("same")).toBe(true)
expect(second.busy().has("same")).toBe(false)
})
})
@@ -89,6 +89,7 @@ import type { WorktreeBusyState } from "./project/store"
import { rememberTarget, restoreProjectTarget } from "./project/restore"
import { createProjectStateRouter } from "./project/state"
import { applyRunStatus } from "./project/run-status"
import { clearMultiVersionBusy, markMultiVersionBusy } from "./project/progress"
import { selectLocalAction, selectWorktreeAction } from "./selection-actions"
import { DataBridge } from "../src/App"
import { LanguageBridge } from "../src/context/language-bridge"
@@ -865,6 +866,16 @@ const AgentManagerContent: Component = () => {
/** True when a local session is actively working. */
const isLocalBusy = (): boolean => isAnySessionBusy(localSessionIDs())
const projectBusy = (projectId: string, worktreeId: string | null): boolean => {
if (projectId === activeProjectId()) {
return worktreeId === null ? isLocalBusy() : isAgentBusy(worktreeId)
}
const ids = (projectSessionsLive()[projectId] ?? [])
.filter((item) => item.worktreeId === worktreeId)
.map((item) => item.id)
return isAnySessionBusy(ids)
}
const isSessionBusy = (id: string): boolean => isAnySessionBusy([id])
/** Worktrees sorted so that grouped items are always adjacent, respecting custom order if set. */
@@ -1447,13 +1458,8 @@ const AgentManagerContent: Component = () => {
const ev = msg as unknown as AgentManagerMultiVersionProgressMessage
if (ev.status === "done" && ev.groupId) {
// Clear busy state for all worktrees in this group
setBusyWorktrees((prev) => {
const next = new Map(prev)
for (const wt of worktrees()) {
if (wt.groupId === ev.groupId) next.delete(wt.id)
}
return next
})
const store = ev.projectId ? registry.ensure(ev.projectId) : registry.active()
clearMultiVersionBusy(store, ev.groupId)
}
}
@@ -1462,11 +1468,8 @@ const AgentManagerContent: Component = () => {
if (msg.type === "agentManager.worktreeSetup") {
const ev = msg as AgentManagerWorktreeSetupMessage
if (ev.status === "ready" && ev.sessionId) {
const ms = managedSessions().find((s) => s.id === ev.sessionId)
const wt = ms?.worktreeId ? worktrees().find((w) => w.id === ms.worktreeId) : undefined
if (wt?.groupId) {
setBusyWorktrees((prev) => new Map([...prev, [wt.id, { reason: "setting-up" as const }]]))
}
const store = ev.projectId ? registry.ensure(ev.projectId) : registry.active()
markMultiVersionBusy(store, ev.sessionId)
}
}
@@ -1503,7 +1506,8 @@ const AgentManagerContent: Component = () => {
// Clear busy state — use worktreeId from the message directly
// to avoid race condition where managedSessions() hasn't updated yet
if (ev.worktreeId) {
setBusyWorktrees((prev) => {
const store = ev.projectId ? registry.ensure(ev.projectId) : registry.active()
store.setBusy((prev) => {
const next = new Map(prev)
next.delete(ev.worktreeId)
return next
@@ -2335,6 +2339,9 @@ const AgentManagerContent: Component = () => {
projects={projectList()}
states={projectStates()}
store={(id) => registry.ensure(id)}
busy={(projectId, id) => registry.ensure(projectId).busy().has(id)}
working={(projectId, id) => projectBusy(projectId, id)}
localBusy={(projectId) => projectBusy(projectId, null)}
stats={projectLive.stats()}
local={projectLive.local()}
prs={projectLive.prs()}
@@ -34,7 +34,9 @@ interface Props {
selection?: string
currentSessionID?: () => string | undefined
mode: ModeRouter
busy?: (id: string) => boolean
busy?: (projectId: string, id: string) => boolean
working?: (projectId: string, id: string) => boolean
localBusy?: (projectId: string) => boolean
bindings: Record<string, string>
t: LanguageContextValue["t"]
onSearchRef: (ref: SidebarSearchMenuRef) => void
@@ -211,6 +213,9 @@ export const ProjectList: Component<Props> = (props) => {
project={project}
state={props.states[project.id]}
store={props.store?.(project.id)}
busy={(id) => props.busy?.(project.id, id) ?? false}
working={(id) => props.working?.(project.id, id) ?? false}
localBusy={() => props.localBusy?.(project.id) ?? false}
stats={props.stats[project.id]}
local={props.local[project.id]}
prs={props.prs[project.id]}
@@ -36,6 +36,8 @@ interface Props {
state?: AgentManagerStateMessage
store?: ProjectStore
busy?: (id: string) => boolean
working?: (id: string) => boolean
localBusy?: () => boolean
stats?: Record<string, WorktreeGitStats>
local?: LocalGitStats
prs?: Record<string, PRStatus | null>
@@ -219,7 +221,7 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
active={active() && props.selection === worktree.id}
pendingDelete={pending() === worktree.id}
busy={props.busy?.(worktree.id) ?? false}
working={runs()[worktree.id]?.state === "running"}
working={props.working?.(worktree.id) || runs()[worktree.id]?.state === "running"}
stale={state()?.staleWorktreeIds?.includes(worktree.id) === true}
stats={props.stats?.[worktree.id]}
sessions={sessions(worktree.id).length}
@@ -279,11 +281,13 @@ export const ProjectSidebarBody: Component<Props> = (props) => {
data-sidebar-id={`${props.project.id}:local`}
onClick={() => props.onSelectLocal(props.project.id)}
>
<svg class="am-local-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor" />
<path d="M6 16.5H14" stroke="currentColor" stroke-linecap="square" />
<path d="M10 13.5V16.5" stroke="currentColor" />
</svg>
<Show when={!props.localBusy?.()} fallback={<Spinner class="am-worktree-spinner" />}>
<svg class="am-local-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor" />
<path d="M6 16.5H14" stroke="currentColor" stroke-linecap="square" />
<path d="M10 13.5V16.5" stroke="currentColor" />
</svg>
</Show>
<div class="am-local-text">
<span class="am-local-label">{props.t("agentManager.local")}</span>
<Show when={props.local?.branch}>
@@ -9,6 +9,7 @@ export function initialMessage(ev: AgentManagerSendInitialMessage): SendMessageR
if (!ev.text) return undefined
return {
type: "sendMessage",
...(ev.projectId ? { projectId: ev.projectId } : {}),
text: ev.text,
sessionID: ev.sessionId,
providerID: ev.providerID,
@@ -0,0 +1,23 @@
import type { ProjectStore } from "./store"
/** Clear the loading indicators for every worktree in one multi-version group. */
export function clearMultiVersionBusy(store: ProjectStore, groupId: string): void {
const ids = new Set(
store
.worktrees()
.filter((wt) => wt.groupId === groupId)
.map((wt) => wt.id),
)
if (ids.size === 0) return
store.setBusy((prev) => new Map([...prev].filter(([id]) => !ids.has(id))))
}
/** Keep a newly created grouped worktree showing progress until its prompt starts. */
export function markMultiVersionBusy(store: ProjectStore, sessionId: string): void {
const session = store.managedSessions().find((item) => item.id === sessionId)
const id = session?.worktreeId
if (!id) return
const worktree = store.worktrees().find((item) => item.id === id)
if (!worktree?.groupId) return
store.setBusy((prev) => new Map([...prev, [id, { reason: "setting-up" as const }]]))
}
@@ -894,6 +894,8 @@ export interface SandboxStatusErrorMessage {
// Multi-version creation progress (extension → webview)
export interface AgentManagerMultiVersionProgressMessage {
type: "agentManager.multiVersionProgress"
/** Owning project; absent in single-project mode. */
projectId?: string
status: "creating" | "done"
total: number
completed: number
@@ -1037,6 +1039,8 @@ export interface WorktreeStatsLoadedMessage {
// Set the model for a session (extension → webview, used during multi-version creation)
export interface AgentManagerSetSessionModelMessage {
type: "agentManager.setSessionModel"
/** Owning project; absent in single-project mode. */
projectId?: string
sessionId: string
providerID: string
modelID: string
@@ -1045,6 +1049,8 @@ export interface AgentManagerSetSessionModelMessage {
// Request webview to send initial prompt to a newly created session (extension → webview)
export interface AgentManagerSendInitialMessage {
type: "agentManager.sendInitialMessage"
/** Owning project; absent in single-project mode. */
projectId?: string
sessionId: string
worktreeId: string
text?: string
@@ -23,6 +23,7 @@ import type { MemoryShowMessage, MemoryOperationMessage, RequestMemoryMessage }
export interface SendMessageRequest {
type: "sendMessage"
projectId?: string
text: string
messageID?: string
sessionID?: string