Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/task-avatar-frame-shimmer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Show a neutral frame shimmer while a subagent starts, then animate it into the agent's identity glyph once the child session is known, and label the card with the agent type instead of the internal tool name.
1 change: 1 addition & 0 deletions packages/kilo-ui/src/components/agent-avatar.css
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
around it, so the symbol remains recognizable. */
&[data-status="running"] circle:not([data-lit]) {
animation: agent-avatar-pulse 1.4s ease-in-out infinite both;
animation-delay: var(--agent-avatar-delay);
}

/* Eight well-separated hues: the six VS Code chart colors plus teal and pink,
Expand Down
7 changes: 6 additions & 1 deletion packages/kilo-ui/src/components/agent-avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,12 @@ export function AgentAvatar(props: { id: string; status?: AgentAvatarStatus }) {
cx={(cell % 5) * 4 + 1.5}
cy={Math.floor(cell / 5) * 4 + 1.5}
r="1.5"
style={{ "animation-delay": `${-(((cell * 7) % 11) / 11) * 1.4}s` }}
style={{
// Shimmer desync and resolve order are separate so a dot can keep
// its shimmer phase while the resolve staggers in grid order.
"--agent-avatar-delay": `${-(((cell * 7) % 11) / 11) * 1.4}s`,
"--agent-avatar-order": `${cell}`,
}}
/>
)}
</For>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -445,16 +445,17 @@ export const MessageList: Component<MessageListProps> = (props) => {

// Matches TaskToolExpanded.tsx (the renderer this webview actually
// registers for "task", overriding kilo-ui's default) exactly: title is
// always `i18n.t("ui.tool.agent", { type })` regardless of status — the
// "capitalize" CSS class only changes how it *looks*, the DOM text node
// itself is the raw, lowercase subagent_type. The "(N)" child-tool-count
// suffix shown there is a live value from session.getSessionToolCount(),
// not stored on the part at all, so it can't be indexed from a snapshot —
// searching for that count isn't meaningful content anyway.
// `i18n.t("ui.tool.agent", { type })` once subagent_type is known, and
// `ui.tool.agent.default` while it is still absent. The "capitalize" CSS
// class only changes how it *looks*, the DOM text node itself is the raw,
// lowercase subagent_type. The "(N)" child-tool-count suffix shown there is
// a live value from session.getSessionToolCount(), not stored on the part at
// all, so it can't be indexed from a snapshot — searching for that count
// isn't meaningful content anyway.
function taskText(part: Part & { type: "tool" }, state: ToolState): string[] {
const input = state.input as { subagent_type?: string; description?: string } | undefined
const type = input?.subagent_type || part.tool
const chunks = [i18n.t("ui.tool.agent", { type })]
const type = input?.subagent_type
const chunks = [type ? i18n.t("ui.tool.agent", { type }) : i18n.t("ui.tool.agent.default")]
if (input?.description) chunks.push(input.description)
// TaskToolExpanded.tsx only shows the raw <task_result> body when there's
// no live child session to display instead (result() there resolves to
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,20 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
const id = childSessionId()
return taskAvatarStatus(id, props.status, session.allStatusMap())
})
// The avatar shimmers until the child session id is known, then plays a
// one-shot resolve into the identity glyph. Only an actual unknown-to-known
// transition sets this, so a virtualized remount that starts with the id
// already known shows the resolved glyph without replaying the animation.
const [resolved, setResolved] = createSignal(false)
createEffect(
on(
childSessionId,
(id, prev) => {
if (id && !prev) setResolved(true)
},
{ defer: true },
),
)
// BasicTool's forceOpen effect only fires onOpenChange on a false->true
// transition — a virtualized remount that starts with forceOpen already
// true never transitions, so this local signal must also seed itself from
Expand Down Expand Up @@ -90,7 +104,11 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
if (synced) session.unsyncSession(synced)
})

const title = createMemo(() => i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool }))
const title = createMemo(() =>
props.input.subagent_type
? i18n.t("ui.tool.agent", { type: props.input.subagent_type })
: i18n.t("ui.tool.agent.default"),
)

const description = createMemo(() => {
const val = props.input.description
Expand Down Expand Up @@ -208,18 +226,15 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
<BasicTool
icon="task"
iconNode={
<Show when={childSessionId()} fallback={<AgentAvatar id="" status={avatar()} />}>
{(id) => (
<span
data-slot="task-agent-avatar"
data-clickable="true"
title={worktree ? "Open sub-agent in panel" : "Open sub-agent in tab"}
onClick={openInTab}
>
<AgentAvatar id={id()} status={avatar()} />
</span>
)}
</Show>
<span
data-slot="task-agent-avatar"
data-clickable={childSessionId() ? "true" : undefined}
data-resolve={resolved() ? "true" : undefined}
title={childSessionId() ? (worktree ? "Open sub-agent in panel" : "Open sub-agent in tab") : undefined}
onClick={childSessionId() ? openInTab : undefined}
>
<AgentAvatar id={childSessionId() ?? ""} status={avatar()} />
</span>
}
status={props.status}
tool={props.tool}
Expand Down
84 changes: 81 additions & 3 deletions packages/kilo-vscode/webview-ui/src/styles/tool-overrides.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,102 @@
}
}

[data-slot="task-agent-avatar"][data-clickable="true"] {
[data-slot="task-agent-avatar"] {
display: inline-flex;
flex: 0 0 18px;
width: 18px;
height: 18px;
align-items: center;
justify-content: center;
cursor: pointer;

[data-component="agent-avatar"] {
transition: filter 120ms ease;
/* Both transitions live here so the base avatar rule stays free of them.
The shorthand would otherwise reset transition-property and drop the
color fade on this card while still applying it everywhere else. */
transition:
color 620ms ease,
filter 120ms ease;
}
}

[data-slot="task-agent-avatar"][data-clickable="true"] {
cursor: pointer;

&:hover [data-component="agent-avatar"] {
filter: drop-shadow(0 0 3px currentColor);
}
}

/* ============================================
Task avatar: neutral frame shimmer while the child session id is unknown,
then a one-shot resolve into the hashed identity glyph.
============================================ */

[data-slot="task-agent-avatar"] [data-component="agent-avatar"] circle {
transform-box: fill-box;
transform-origin: center;
}

/* No identity yet: every ring dot shimmers in place, so it reads as working
without showing a placeholder glyph that then has to be swapped out. The svg
qualifier outranks the base running-pulse rule so all dots shimmer, not just
the unlit ones. */
[data-slot="task-agent-avatar"] svg[data-component="agent-avatar"]:not([data-color]) circle {
animation: task-avatar-frame 1.4s ease-in-out infinite both;
animation-delay: var(--agent-avatar-delay);
}

/* Identity arrived: the lit dots ignite in grid order. The wrapper only sets
data-resolve on the unknown-to-known transition, so a virtualized remount
starts already resolved and does not replay this. */
[data-slot="task-agent-avatar"][data-resolve="true"] [data-component="agent-avatar"][data-color] circle[data-lit] {
animation: task-avatar-resolve 620ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
animation-delay: calc(var(--agent-avatar-order) * 16ms);
}

@keyframes task-avatar-frame {
0%,
100% {
opacity: 0.1;
}

50% {
opacity: 0.45;
}
}

@keyframes task-avatar-resolve {
0% {
opacity: 0.25;
transform: scale(0.62);
}

55% {
opacity: 1;
transform: scale(1.18);
}

100% {
opacity: 1;
transform: scale(1);
}
}

@media (prefers-reduced-motion: reduce) {
[data-slot="task-agent-avatar"] [data-component="agent-avatar"] {
transition: none;
}

[data-slot="task-agent-avatar"] svg[data-component="agent-avatar"]:not([data-color]) circle {
animation: none;
opacity: 0.3;
}

[data-slot="task-agent-avatar"] svg[data-component="agent-avatar"] circle[data-lit] {
animation: none;
}
}

[data-component="tool-output"][data-scrollable]:has([data-component="task-tools"]) {
margin-inline: -6px;
max-height: 200px;
Expand Down
Loading