From 2d2699f0727c9fe2cb0a67ad678bedf512b54754 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 20 Feb 2026 13:39:21 +0100 Subject: [PATCH] feat(vscode): add loading skeletons for Agent Manager sessions and worktrees (#518) * feat: add loading skeletons for agent manager sessions and worktrees Add skeleton loading states to the Agent Manager sidebar so users see pulsing placeholders while worktrees and sessions load from the backend. - Track worktreesLoaded/sessionsLoaded signals in the webview - Add agentManager.requestState message so the webview can pull state on mount (fixes race where initial pushState fires before mount) - Await stateReady in the extension before responding to requestState (ensures JSON is loaded from disk before sending data) - Mark sessions loaded from both agentManager.state and sessionsLoaded message paths for consistent behavior - Add skeleton CSS with staggered pulse animations * fix: gate worktree list on sessionsLoaded to prevent branch name flash The worktreeLabel() function falls back to wt.branch (e.g. kilo-...) when session data hasn't arrived yet. Gate the worktree list render on both worktreesLoaded and sessionsLoaded so the skeleton stays visible until session titles are available. - Listen for sessionsLoaded message directly instead of watching session.sessions() length (handles empty session lists too) - Remove premature sessionsLoaded=true from agentManager.state handler since that message only carries IDs, not session titles * fix: make skeleton shapes match actual worktree/session item layout Worktree skeleton: single row with branch icon placeholder + text bar, matching the .am-worktree-item padding (10px) and gap (8px). Session skeleton: title bar + timestamp bar on the right, matching the .am-item layout with justify-content: space-between, padding (6px 10px), and smaller font size for the time placeholder. * fix: add .catch() to requestState handler to prevent unhandled rejection If initializeState() rejects, pushState() still fires with whatever state is available (empty arrays), so skeletons resolve to the empty state instead of loading forever. * fix: log error in requestState catch handler per style guide --- .../src/agent-manager/AgentManagerProvider.ts | 12 +- .../agent-manager/AgentManagerApp.tsx | 266 ++++++++++-------- .../agent-manager/agent-manager.css | 83 ++++++ .../webview-ui/src/types/messages.ts | 5 + 4 files changed, 254 insertions(+), 112 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 349205d9a6b..8239509744e 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -27,6 +27,7 @@ export class AgentManagerProvider implements vscode.Disposable { private state: WorktreeStateManager | undefined private setupScript: SetupScriptService | undefined private terminalManager: SessionTerminalManager + private stateReady: Promise | undefined constructor( private readonly extensionUri: vscode.Uri, @@ -74,7 +75,7 @@ export class AgentManagerProvider implements vscode.Disposable { onBeforeMessage: (msg) => this.onMessage(msg), }) - void this.initializeState() + this.stateReady = this.initializeState() void this.sendRepoInfo() this.sendKeybindings() @@ -146,6 +147,15 @@ export class AgentManagerProvider implements vscode.Disposable { void this.sendRepoInfo() return null } + if (type === "agentManager.requestState") { + void this.stateReady + ?.then(() => this.pushState()) + .catch((err) => { + this.log("initializeState failed, pushing partial state:", err) + this.pushState() + }) + return null + } if (type === "agentManager.setTabOrder" && typeof msg.key === "string" && Array.isArray(msg.order)) { this.state?.setTabOrder(msg.key as string, msg.order as string[]) return null diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 77b1fa731b7..e3a2a695ce5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -157,6 +157,8 @@ const AgentManagerContent: Component = () => { const [selection, setSelection] = createSignal(LOCAL) const [repoBranch, setRepoBranch] = createSignal() const [deletingWorktrees, setDeletingWorktrees] = createSignal>(new Set()) + const [worktreesLoaded, setWorktreesLoaded] = createSignal(false) + const [sessionsLoaded, setSessionsLoaded] = createSignal(false) const DEFAULT_SIDEBAR_WIDTH = 260 const MIN_SIDEBAR_WIDTH = 200 @@ -443,6 +445,11 @@ const AgentManagerContent: Component = () => { } }) + // Mark sessions loaded as soon as the session context receives data (even if empty) + const unsubSessions = vscode.onMessage((msg) => { + if (msg.type === "sessionsLoaded" && !sessionsLoaded()) setSessionsLoaded(true) + }) + const unsub = vscode.onMessage((msg) => { if (msg.type === "agentManager.repoInfo") { const info = msg as AgentManagerRepoInfoMessage @@ -480,6 +487,7 @@ const AgentManagerContent: Component = () => { const state = msg as AgentManagerStateMessage setWorktrees(state.worktrees) setManagedSessions(state.sessions) + if (!worktreesLoaded()) setWorktreesLoaded(true) if (state.tabOrder) setWorktreeTabOrder(state.tabOrder) const current = session.currentSessionID() if (current) { @@ -509,6 +517,7 @@ const AgentManagerContent: Component = () => { window.removeEventListener("keydown", preventDefaults) window.removeEventListener("focus", onWindowFocus) unsubCreate() + unsubSessions() unsub() }) }) @@ -516,6 +525,9 @@ const AgentManagerContent: Component = () => { // Always select local on mount to initialize branch info and session state onMount(() => { selectLocal() + // Request worktree/session state from extension — handles race where + // initializeState() pushState fires before the webview is mounted + vscode.postMessage({ type: "agentManager.requestState" }) // Open a pending "New Session" tab if there are no persisted local sessions if (localSessionIDs().length === 0) { addPendingTab() @@ -787,100 +799,112 @@ const AgentManagerContent: Component = () => {
- {(() => { - const [hoveredWt, setHoveredWt] = createSignal(null) - const [overClose, setOverClose] = createSignal(false) - return ( - - {(wt, wtIdx) => { - const sessions = createMemo(() => managedSessions().filter((ms) => ms.worktreeId === wt.id)) - const navHint = () => { - const flat = [ - LOCAL as string, - ...worktrees().map((w) => w.id), - ...unassignedSessions().map((s) => s.id), - ] - const active = selection() ?? session.currentSessionID() ?? "" - return adjacentHint(wt.id, active, flat, kb().previousSession ?? "", kb().nextSession ?? "") - } - return ( - setHoveredWt(open ? wt.id : null)} - trigger={ -
selectWorktree(wt.id)} - > - - {worktreeLabel(wt)} - } + +
+
+
+
+
+ } + > + {(() => { + const [hoveredWt, setHoveredWt] = createSignal(null) + const [overClose, setOverClose] = createSignal(false) + return ( + + {(wt, wtIdx) => { + const sessions = createMemo(() => managedSessions().filter((ms) => ms.worktreeId === wt.id)) + const navHint = () => { + const flat = [ + LOCAL as string, + ...worktrees().map((w) => w.id), + ...unassignedSessions().map((s) => s.id), + ] + const active = selection() ?? session.currentSessionID() ?? "" + return adjacentHint(wt.id, active, flat, kb().previousSession ?? "", kb().nextSession ?? "") + } + return ( + setHoveredWt(open ? wt.id : null)} + trigger={ +
selectWorktree(wt.id)} > -
setOverClose(true)} - onMouseLeave={() => setOverClose(false)} + + {worktreeLabel(wt)} + } > - setOverClose(true)} + onMouseLeave={() => setOverClose(false)} > - handleDeleteWorktree(wt.id, e)} - /> - + + handleDeleteWorktree(wt.id, e)} + /> + +
+ +
+ } + > +
+
+
+
BRANCH
+
{wt.branch}
+
{formatRelativeDate(wt.createdAt)}
+
+ + {navHint()} + +
+ +
+
+ Base + {wt.parentBranch}
-
- } - > -
-
-
-
BRANCH
-
{wt.branch}
-
{formatRelativeDate(wt.createdAt)}
-
- - {navHint()} - -
-
- Base - {wt.parentBranch} + Sessions + {sessions().length}
- -
-
- Sessions - {sessions().length}
-
- - ) - }} - - ) - })()} - - + + ) + }} + + ) + })()} + + +
@@ -891,33 +915,53 @@ const AgentManagerContent: Component = () => {
- - {(s) => ( - - )} - +
+
+
+
+
+
+
+
+
+ } + > + + {(s) => ( + + )} + +
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 0f217669f0c..fec15a4f045 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -719,3 +719,86 @@ font-weight: 500; color: var(--text-base); } + +/* Skeleton loading states */ + +@keyframes am-skeleton-pulse { + 0%, + 100% { + opacity: 0.12; + } + 50% { + opacity: 0.28; + } +} + +.am-skeleton-list { + display: flex; + flex-direction: column; + gap: 2px; + animation: am-fade-in 0.2s ease; +} + +/* Worktree skeleton — matches .am-worktree-item layout */ +.am-skeleton-wt { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 10px; +} + +.am-skeleton-wt-icon { + width: 16px; + height: 16px; + border-radius: 3px; + background: var(--text-base); + animation: am-skeleton-pulse 1.5s ease-in-out infinite; + flex-shrink: 0; +} + +.am-skeleton-wt-text { + height: 13px; + border-radius: 3px; + background: var(--text-base); + animation: am-skeleton-pulse 1.5s ease-in-out infinite; +} + +.am-skeleton-wt:nth-child(2) .am-skeleton-wt-icon, +.am-skeleton-wt:nth-child(2) .am-skeleton-wt-text { + animation-delay: 0.15s; +} + +/* Session skeleton — matches .am-item layout */ +.am-skeleton-session { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 10px; +} + +.am-skeleton-session-title { + height: 13px; + border-radius: 3px; + background: var(--text-base); + animation: am-skeleton-pulse 1.5s ease-in-out infinite; +} + +.am-skeleton-session-time { + height: 10px; + width: 52px; + border-radius: 3px; + background: var(--text-base); + animation: am-skeleton-pulse 1.5s ease-in-out infinite; + flex-shrink: 0; +} + +.am-skeleton-session:nth-child(2) .am-skeleton-session-title, +.am-skeleton-session:nth-child(2) .am-skeleton-session-time { + animation-delay: 0.15s; +} + +.am-skeleton-session:nth-child(3) .am-skeleton-session-title, +.am-skeleton-session:nth-child(3) .am-skeleton-session-time { + animation-delay: 0.3s; +} diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 2be7c991e86..81160ae3962 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -841,6 +841,10 @@ export interface RequestRepoInfoMessage { type: "agentManager.requestRepoInfo" } +export interface RequestStateMessage { + type: "agentManager.requestState" +} + // Configure worktree setup script export interface ConfigureSetupScriptRequest { type: "agentManager.configureSetupScript" @@ -902,6 +906,7 @@ export type WebviewMessage = | CloseSessionRequest | TelemetryRequest | RequestRepoInfoMessage + | RequestStateMessage | ConfigureSetupScriptRequest | ShowTerminalRequest | SetTabOrderRequest