feat(desktop): add agent runtime popover to typing indicator

Click the typing indicator when a bot is typing to see:
- Agent name, command, and model
- Live-ticking elapsed runtime from lastStartedAt
- Destructive 'Interrupt' button that calls stopManagedAgent

Human typers are unchanged — no popover shown.
Hooks (useManagedAgentsQuery, useStopManagedAgentMutation) live
inside TypingIndicatorRow — zero prop changes to ChannelPane.
This commit is contained in:
Taylor Ho
2026-04-09 07:53:14 -10:00
parent 8d624903e9
commit 95e4919a68

View File

@@ -1,11 +1,16 @@
import * as React from "react";
import {
useManagedAgentsQuery,
useStopManagedAgentMutation,
} from "@/features/agents/hooks";
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { Channel } from "@/shared/api/types";
import type { Channel, ManagedAgent } from "@/shared/api/types";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
type TypingIndicatorRowProps = {
channel: Channel | null;
@@ -46,12 +51,91 @@ function formatTypingLabel(names: string[]) {
return `${names[0]}, ${names[1]}, and ${names.length - 2} others are typing...`;
}
function formatElapsed(startIso: string): string {
const startMs = new Date(startIso).getTime();
const nowMs = Date.now();
const totalSeconds = Math.max(0, Math.floor((nowMs - startMs) / 1000));
if (totalSeconds >= 3600) {
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
if (totalSeconds >= 60) {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}m ${seconds}s`;
}
return `${totalSeconds}s`;
}
type BotTypingPopoverContentProps = {
botAgents: ManagedAgent[];
};
function BotTypingPopoverContent({ botAgents }: BotTypingPopoverContentProps) {
const [, setTick] = React.useState(0);
const mutation = useStopManagedAgentMutation();
React.useEffect(() => {
const interval = setInterval(() => {
setTick((prev) => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
function handleInterrupt() {
for (const agent of botAgents) {
mutation.mutate(agent.pubkey);
}
}
return (
<div>
{botAgents.map((agent) => (
<div key={agent.pubkey} className="mb-2 last:mb-0">
<div className="font-bold text-sm">{agent.name}</div>
{agent.model && (
<div className="text-xs text-muted-foreground">{agent.model}</div>
)}
<div className="text-xs text-muted-foreground">
{agent.lastStartedAt ? formatElapsed(agent.lastStartedAt) : "—"}
</div>
</div>
))}
<button
type="button"
className="mt-3 w-full rounded-md bg-destructive px-3 py-1.5 text-sm font-medium text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
data-testid="typing-interrupt-button"
disabled={mutation.isPending}
onClick={handleInterrupt}
>
{mutation.isPending ? "Interrupting…" : "Interrupt"}
</button>
</div>
);
}
export function TypingIndicatorRow({
channel,
currentPubkey,
profiles,
typingPubkeys,
}: TypingIndicatorRowProps) {
const { data: managedAgents } = useManagedAgentsQuery();
const managedAgentMap = React.useMemo(() => {
const map = new Map<string, ManagedAgent>();
if (managedAgents) {
for (const agent of managedAgents) {
map.set(agent.pubkey.toLowerCase(), agent);
}
}
return map;
}, [managedAgents]);
const labels = React.useMemo(
() =>
typingPubkeys.map((pubkey) =>
@@ -66,10 +150,22 @@ export function TypingIndicatorRow({
[channel, currentPubkey, profiles, typingPubkeys],
);
const botAgents = React.useMemo(
() =>
typingPubkeys
.map((pubkey) => managedAgentMap.get(pubkey.toLowerCase()))
.filter((agent): agent is ManagedAgent => agent !== undefined),
[typingPubkeys, managedAgentMap],
);
const hasBotTypers = botAgents.length > 0;
if (labels.length === 0) {
return null;
}
const typingText = formatTypingLabel(labels);
return (
<div
aria-live="polite"
@@ -97,12 +193,29 @@ export function TypingIndicatorRow({
);
})}
</div>
<p
className="truncate text-sm text-muted-foreground"
data-testid="message-typing-indicator-label"
>
{formatTypingLabel(labels)}
</p>
{hasBotTypers ? (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="truncate text-sm text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
data-testid="message-typing-indicator-label"
>
{typingText}
</button>
</PopoverTrigger>
<PopoverContent side="top" align="start" className="w-64 p-3">
<BotTypingPopoverContent botAgents={botAgents} />
</PopoverContent>
</Popover>
) : (
<p
className="truncate text-sm text-muted-foreground"
data-testid="message-typing-indicator-label"
>
{typingText}
</p>
)}
</div>
</div>
);