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
This commit is contained in:
Marius
2026-02-20 12:39:21 +00:00
committed by GitHub
parent cb18296f88
commit 2d2699f072
4 changed files with 254 additions and 112 deletions
@@ -27,6 +27,7 @@ export class AgentManagerProvider implements vscode.Disposable {
private state: WorktreeStateManager | undefined
private setupScript: SetupScriptService | undefined
private terminalManager: SessionTerminalManager
private stateReady: Promise<void> | 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
@@ -157,6 +157,8 @@ const AgentManagerContent: Component = () => {
const [selection, setSelection] = createSignal<SidebarSelection>(LOCAL)
const [repoBranch, setRepoBranch] = createSignal<string | undefined>()
const [deletingWorktrees, setDeletingWorktrees] = createSignal<Set<string>>(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 = () => {
</div>
</div>
<div class="am-worktree-list">
{(() => {
const [hoveredWt, setHoveredWt] = createSignal<string | null>(null)
const [overClose, setOverClose] = createSignal(false)
return (
<For each={worktrees()}>
{(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 (
<HoverCard
openDelay={100}
closeDelay={100}
placement="right-start"
gutter={8}
open={hoveredWt() === wt.id && !overClose()}
onOpenChange={(open) => setHoveredWt(open ? wt.id : null)}
trigger={
<div
class={`am-worktree-item ${selection() === wt.id ? "am-worktree-item-active" : ""}`}
data-sidebar-id={wt.id}
onClick={() => selectWorktree(wt.id)}
>
<Icon name="branch" size="small" />
<span class="am-worktree-branch">{worktreeLabel(wt)}</span>
<Show
when={!deletingWorktrees().has(wt.id)}
fallback={<Spinner class="am-worktree-spinner" />}
<Show
when={worktreesLoaded() && sessionsLoaded()}
fallback={
<div class="am-skeleton-list">
<div class="am-skeleton-wt">
<div class="am-skeleton-wt-icon" />
<div class="am-skeleton-wt-text" style={{ width: "60%" }} />
</div>
</div>
}
>
{(() => {
const [hoveredWt, setHoveredWt] = createSignal<string | null>(null)
const [overClose, setOverClose] = createSignal(false)
return (
<For each={worktrees()}>
{(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 (
<HoverCard
openDelay={100}
closeDelay={100}
placement="right-start"
gutter={8}
open={hoveredWt() === wt.id && !overClose()}
onOpenChange={(open) => setHoveredWt(open ? wt.id : null)}
trigger={
<div
class={`am-worktree-item ${selection() === wt.id ? "am-worktree-item-active" : ""}`}
data-sidebar-id={wt.id}
onClick={() => selectWorktree(wt.id)}
>
<div
class="am-worktree-close"
onMouseEnter={() => setOverClose(true)}
onMouseLeave={() => setOverClose(false)}
<Icon name="branch" size="small" />
<span class="am-worktree-branch">{worktreeLabel(wt)}</span>
<Show
when={!deletingWorktrees().has(wt.id)}
fallback={<Spinner class="am-worktree-spinner" />}
>
<TooltipKeybind
title="Delete worktree"
keybind={kb().closeWorktree ?? ""}
placement="top"
<div
class="am-worktree-close"
onMouseEnter={() => setOverClose(true)}
onMouseLeave={() => setOverClose(false)}
>
<IconButton
icon="close-small"
size="small"
variant="ghost"
label="Delete worktree"
onClick={(e: MouseEvent) => handleDeleteWorktree(wt.id, e)}
/>
</TooltipKeybind>
<TooltipKeybind
title="Delete worktree"
keybind={kb().closeWorktree ?? ""}
placement="top"
>
<IconButton
icon="close-small"
size="small"
variant="ghost"
label="Delete worktree"
onClick={(e: MouseEvent) => handleDeleteWorktree(wt.id, e)}
/>
</TooltipKeybind>
</div>
</Show>
</div>
}
>
<div class="am-hover-card">
<div class="am-hover-card-header">
<div>
<div class="am-hover-card-label">BRANCH</div>
<div class="am-hover-card-branch">{wt.branch}</div>
<div class="am-hover-card-meta">{formatRelativeDate(wt.createdAt)}</div>
</div>
<Show when={navHint()}>
<span class="am-hover-card-keybind">{navHint()}</span>
</Show>
</div>
<Show when={wt.parentBranch}>
<div class="am-hover-card-divider" />
<div class="am-hover-card-row">
<span class="am-hover-card-row-label">Base</span>
<span class="am-hover-card-row-value">{wt.parentBranch}</span>
</div>
</Show>
</div>
}
>
<div class="am-hover-card">
<div class="am-hover-card-header">
<div>
<div class="am-hover-card-label">BRANCH</div>
<div class="am-hover-card-branch">{wt.branch}</div>
<div class="am-hover-card-meta">{formatRelativeDate(wt.createdAt)}</div>
</div>
<Show when={navHint()}>
<span class="am-hover-card-keybind">{navHint()}</span>
</Show>
</div>
<Show when={wt.parentBranch}>
<div class="am-hover-card-divider" />
<div class="am-hover-card-row">
<span class="am-hover-card-row-label">Base</span>
<span class="am-hover-card-row-value">{wt.parentBranch}</span>
<span class="am-hover-card-row-label">Sessions</span>
<span class="am-hover-card-row-value">{sessions().length}</span>
</div>
</Show>
<div class="am-hover-card-divider" />
<div class="am-hover-card-row">
<span class="am-hover-card-row-label">Sessions</span>
<span class="am-hover-card-row-value">{sessions().length}</span>
</div>
</div>
</HoverCard>
)
}}
</For>
)
})()}
<Show when={worktrees().length === 0}>
<button class="am-worktree-create" onClick={handleCreateWorktree}>
<Icon name="plus" size="small" />
<span>New Worktree</span>
</button>
</HoverCard>
)
}}
</For>
)
})()}
<Show when={worktrees().length === 0}>
<button class="am-worktree-create" onClick={handleCreateWorktree}>
<Icon name="plus" size="small" />
<span>New Worktree</span>
</button>
</Show>
</Show>
</div>
</div>
@@ -891,33 +915,53 @@ const AgentManagerContent: Component = () => {
<span class="am-section-label">SESSIONS</span>
</div>
<div class="am-list">
<For each={unassignedSessions()}>
{(s) => (
<button
class={`am-item ${s.id === session.currentSessionID() && selection() === null ? "am-item-active" : ""}`}
data-sidebar-id={s.id}
onClick={() => {
saveTabMemory()
setSelection(null)
session.selectSession(s.id)
}}
>
<span class="am-item-title">{s.title || "Untitled"}</span>
<span class="am-item-time">{formatRelativeDate(s.updatedAt)}</span>
<div class="am-item-promote">
<TooltipKeybind title="Open in worktree" keybind={kb().newWorktree ?? ""} placement="right">
<IconButton
icon="branch"
size="small"
variant="ghost"
label="Open in worktree"
onClick={(e: MouseEvent) => handlePromote(s.id, e)}
/>
</TooltipKeybind>
<Show
when={sessionsLoaded()}
fallback={
<div class="am-skeleton-list">
<div class="am-skeleton-session">
<div class="am-skeleton-session-title" style={{ width: "70%" }} />
<div class="am-skeleton-session-time" />
</div>
</button>
)}
</For>
<div class="am-skeleton-session">
<div class="am-skeleton-session-title" style={{ width: "55%" }} />
<div class="am-skeleton-session-time" />
</div>
<div class="am-skeleton-session">
<div class="am-skeleton-session-title" style={{ width: "65%" }} />
<div class="am-skeleton-session-time" />
</div>
</div>
}
>
<For each={unassignedSessions()}>
{(s) => (
<button
class={`am-item ${s.id === session.currentSessionID() && selection() === null ? "am-item-active" : ""}`}
data-sidebar-id={s.id}
onClick={() => {
saveTabMemory()
setSelection(null)
session.selectSession(s.id)
}}
>
<span class="am-item-title">{s.title || "Untitled"}</span>
<span class="am-item-time">{formatRelativeDate(s.updatedAt)}</span>
<div class="am-item-promote">
<TooltipKeybind title="Open in worktree" keybind={kb().newWorktree ?? ""} placement="right">
<IconButton
icon="branch"
size="small"
variant="ghost"
label="Open in worktree"
onClick={(e: MouseEvent) => handlePromote(s.id, e)}
/>
</TooltipKeybind>
</div>
</button>
)}
</For>
</Show>
</div>
</div>
</div>
@@ -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;
}
@@ -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