mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user