mirror of
https://github.com/block/buzz.git
synced 2026-11-06 01:11:33 +00:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user