Skip to content

Commit e3d8f2c

Browse files
committed
🤖 Add status_set tool for agent activity indicators
Implements a new status_set tool that allows agents to display their current activity with an emoji and message. Status appears in the UI next to the streaming indicator and persists after stream completion. Features: - Tool accepts {emoji: string, message: string} with Zod validation - Emoji: Single emoji character validated with Unicode properties - Message: Max 40 characters - Visual behavior: Full color when streaming, greyscale (60% opacity) when idle - Appears in WorkspaceHeader (main chat) and WorkspaceListItem (sidebar) Architecture: - Tool is declarative - returns success, frontend tracks via StreamingMessageAggregator - Status persists after stream ends (unlike todos which are stream-scoped) - Uses undefined instead of null for optional types (more idiomatic) - Component refactoring: Extracted WorkspaceHeader, renamed StatusIndicator → AgentStatusIndicator - Deduplicated tooltip logic via shared statusTooltip utility Tests: - Comprehensive Zod schema validation (8 tests) - Tests emoji regex, multi-byte character counting, message length validation - StreamingMessageAggregator status tracking (5 tests) - All tests passing ✅ Generated with `cmux`
1 parent ec66ae4 commit e3d8f2c

14 files changed

+580
-418
lines changed

src/components/AIView.tsx

Lines changed: 9 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,8 @@ import { useAutoScroll } from "@/hooks/useAutoScroll";
2121
import { usePersistedState } from "@/hooks/usePersistedState";
2222
import { useThinking } from "@/contexts/ThinkingContext";
2323
import { useWorkspaceState, useWorkspaceAggregator } from "@/stores/WorkspaceStore";
24-
import { StatusIndicator } from "./StatusIndicator";
24+
import { WorkspaceHeader } from "./WorkspaceHeader";
2525
import { getModelName } from "@/utils/ai/models";
26-
import { GitStatusIndicator } from "./GitStatusIndicator";
27-
import { RuntimeBadge } from "./RuntimeBadge";
2826

2927
import { useGitStatus } from "@/stores/GitStatusStore";
3028
import { TooltipWrapper, Tooltip } from "./Tooltip";
@@ -75,9 +73,6 @@ const AIViewInner: React.FC<AIViewProps> = ({
7573
const workspaceState = useWorkspaceState(workspaceId);
7674
const aggregator = useWorkspaceAggregator(workspaceId);
7775

78-
// Get git status for this workspace
79-
const gitStatus = useGitStatus(workspaceId);
80-
8176
const [editingMessage, setEditingMessage] = useState<{ id: string; content: string } | undefined>(
8277
undefined
8378
);
@@ -270,7 +265,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
270265
}
271266

272267
// Extract state from workspace state
273-
const { messages, canInterrupt, isCompacting, loading, currentModel } = workspaceState;
268+
const { messages, canInterrupt, isCompacting, loading, currentModel, agentStatus } = workspaceState;
274269

275270
// Get active stream message ID for token counting
276271
const activeStreamMessageId = aggregator.getActiveStreamMessageId();
@@ -339,41 +334,13 @@ const AIViewInner: React.FC<AIViewProps> = ({
339334
ref={chatAreaRef}
340335
className="flex min-w-96 flex-1 flex-col [@media(max-width:768px)]:max-h-full [@media(max-width:768px)]:w-full [@media(max-width:768px)]:min-w-0"
341336
>
342-
<div className="bg-separator border-border-light flex items-center justify-between border-b px-[15px] py-1 [@media(max-width:768px)]:flex-wrap [@media(max-width:768px)]:gap-2 [@media(max-width:768px)]:py-2 [@media(max-width:768px)]:pl-[60px]">
343-
<div className="text-foreground flex min-w-0 items-center gap-2 overflow-hidden font-semibold">
344-
<StatusIndicator
345-
streaming={canInterrupt}
346-
title={
347-
canInterrupt && currentModel ? `${getModelName(currentModel)} streaming` : "Idle"
348-
}
349-
/>
350-
<GitStatusIndicator
351-
gitStatus={gitStatus}
352-
workspaceId={workspaceId}
353-
tooltipPosition="bottom"
354-
/>
355-
<RuntimeBadge runtimeConfig={runtimeConfig} />
356-
<span className="min-w-0 truncate font-mono text-xs">
357-
{projectName} / {branch}
358-
</span>
359-
<span className="text-muted min-w-0 truncate font-mono text-[11px] font-normal">
360-
{namedWorkspacePath}
361-
</span>
362-
<TooltipWrapper inline>
363-
<button
364-
onClick={handleOpenTerminal}
365-
className="text-muted hover:text-foreground flex cursor-pointer items-center justify-center border-none bg-transparent p-1 transition-colors [&_svg]:h-4 [&_svg]:w-4"
366-
>
367-
<svg viewBox="0 0 16 16" fill="currentColor">
368-
<path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0114.25 15H1.75A1.75 1.75 0 010 13.25V2.75zm1.75-.25a.25.25 0 00-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 00.25-.25V2.75a.25.25 0 00-.25-.25H1.75zM7.25 8a.75.75 0 01-.22.53l-2.25 2.25a.75.75 0 01-1.06-1.06L5.44 8 3.72 6.28a.75.75 0 111.06-1.06l2.25 2.25c.141.14.22.331.22.53zm1.5 1.5a.75.75 0 000 1.5h3a.75.75 0 000-1.5h-3z" />
369-
</svg>
370-
</button>
371-
<Tooltip className="tooltip" position="bottom" align="center">
372-
Open in terminal ({formatKeybind(KEYBINDS.OPEN_TERMINAL)})
373-
</Tooltip>
374-
</TooltipWrapper>
375-
</div>
376-
</div>
337+
<WorkspaceHeader
338+
workspaceId={workspaceId}
339+
projectName={projectName}
340+
branch={branch}
341+
namedWorkspacePath={namedWorkspacePath}
342+
runtimeConfig={runtimeConfig}
343+
/>
377344

378345
<div className="relative flex-1 overflow-hidden">
379346
<div
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import React, { useCallback, useMemo } from "react";
2+
import { cn } from "@/lib/utils";
3+
import { TooltipWrapper, Tooltip } from "./Tooltip";
4+
import { useWorkspaceSidebarState } from "@/stores/WorkspaceStore";
5+
import { getStatusTooltip } from "@/utils/ui/statusTooltip";
6+
7+
interface AgentStatusIndicatorProps {
8+
workspaceId: string;
9+
// Sidebar-specific props (optional)
10+
lastReadTimestamp?: number;
11+
onClick?: (e: React.MouseEvent) => void;
12+
// Display props
13+
size?: number;
14+
className?: string;
15+
}
16+
17+
const AgentStatusIndicatorInner: React.FC<AgentStatusIndicatorProps> = ({
18+
workspaceId,
19+
lastReadTimestamp,
20+
onClick,
21+
size = 8,
22+
className,
23+
}) => {
24+
// Get workspace state
25+
const { canInterrupt, currentModel, agentStatus, recencyTimestamp } =
26+
useWorkspaceSidebarState(workspaceId);
27+
28+
const streaming = canInterrupt;
29+
30+
// Compute unread status if lastReadTimestamp provided (sidebar only)
31+
const unread = useMemo(() => {
32+
if (lastReadTimestamp === undefined) return false;
33+
return recencyTimestamp !== null && recencyTimestamp > lastReadTimestamp;
34+
}, [lastReadTimestamp, recencyTimestamp]);
35+
36+
// Compute tooltip
37+
const title = useMemo(
38+
() =>
39+
getStatusTooltip({
40+
isStreaming: streaming,
41+
streamingModel: currentModel,
42+
agentStatus,
43+
isUnread: unread,
44+
recencyTimestamp,
45+
}),
46+
[streaming, currentModel, agentStatus, unread, recencyTimestamp]
47+
);
48+
49+
const handleClick = useCallback(
50+
(e: React.MouseEvent) => {
51+
// Only allow clicking when not streaming
52+
if (!streaming && onClick) {
53+
e.stopPropagation(); // Prevent workspace selection
54+
onClick(e);
55+
}
56+
},
57+
[streaming, onClick]
58+
);
59+
60+
const bgColor = streaming ? "bg-assistant-border" : unread ? "bg-white" : "bg-muted-dark";
61+
const cursor = onClick && !streaming ? "cursor-pointer" : "cursor-default";
62+
63+
// Always show dot, add emoji next to it when available
64+
const dot = (
65+
<div
66+
style={{ width: size, height: size }}
67+
className={cn(
68+
"rounded-full shrink-0 transition-colors duration-200",
69+
bgColor,
70+
cursor,
71+
onClick && !streaming && "hover:opacity-70"
72+
)}
73+
onClick={handleClick}
74+
/>
75+
);
76+
77+
const emoji = agentStatus ? (
78+
<div
79+
className="flex shrink-0 items-center justify-center transition-all duration-200"
80+
style={{
81+
fontSize: size * 1.5,
82+
filter: streaming ? "none" : "grayscale(100%)",
83+
opacity: streaming ? 1 : 0.6,
84+
}}
85+
>
86+
{agentStatus.emoji}
87+
</div>
88+
) : null;
89+
90+
// Container holds both dot and emoji
91+
const indicator = (
92+
<div className={cn("flex items-center gap-1.5", className)} onClick={handleClick}>
93+
{dot}
94+
{emoji}
95+
</div>
96+
);
97+
98+
// If tooltip content provided, wrap with proper Tooltip component
99+
if (title) {
100+
return (
101+
<TooltipWrapper inline>
102+
{indicator}
103+
<Tooltip className="tooltip" align="center">
104+
{title}
105+
</Tooltip>
106+
</TooltipWrapper>
107+
);
108+
}
109+
110+
return indicator;
111+
};
112+
113+
// Memoize to prevent re-renders when props haven't changed
114+
export const AgentStatusIndicator = React.memo(AgentStatusIndicatorInner);

0 commit comments

Comments
 (0)