Skip to content

Commit 0e256d4

Browse files
authored
🤖 Add status_set tool for agent activity indicators (#462)
## Overview Implements a `status_set` tool that allows AI agents to display their current activity with an emoji and message. The status appears in the UI next to the streaming indicator. ## Implementation **Tool Specification:** - Accepts `{emoji: string, message: string}` - Emoji: Single emoji character, validated with Unicode properties (`\p{Emoji_Presentation}\p{Extended_Pictographic}`) - Message: Max 40 characters - Status persists after stream completion (unlike todos which are stream-scoped) **Visual Behavior:** - Shows emoji next to streaming dot indicator - Streaming: Full color emoji, message on hover - Idle: Greyscale emoji (60% opacity), message on hover - Appears in both WorkspaceHeader (main chat) and WorkspaceListItem (sidebar) **Architecture:** - Tool returns success, frontend tracks status via `StreamingMessageAggregator` - Uses `undefined` instead of `null` for optional types (more idiomatic TypeScript) - Component refactoring: Extracted `WorkspaceHeader`, renamed `StatusIndicator` → `AgentStatusIndicator` - Deduplicated tooltip logic via shared `statusTooltip` utility - Component uses `useWorkspaceSidebarState(workspaceId)` internally - minimal props **Emoji Validation:** ```typescript schema: z.object({ emoji: z.string() .regex(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}]$/u) .refine((val) => [...val].length === 1, { message: "Must be exactly one emoji" }), message: z.string().max(40), }) ``` Handles multi-byte emojis correctly using spread operator to count actual characters (not UTF-16 code units). ## Tests **Schema Validation (8 tests):** - Emoji validation: Accepts single emojis, rejects multiple/text/empty/mixed - Message validation: Length limits, empty messages **Aggregator Integration (5 tests):** - Status tracking through tool calls - Persistence after stream completion - Failed tool calls don't update status All tests passing ✅ _Generated with `cmux`_
1 parent 6eebd73 commit 0e256d4

17 files changed

+826
-420
lines changed

src/components/AIView.tsx

Lines changed: 8 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +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";
28-
29-
import { useGitStatus } from "@/stores/GitStatusStore";
30-
import { TooltipWrapper, Tooltip } from "./Tooltip";
3126
import type { DisplayedMessage } from "@/types/message";
3227
import type { RuntimeConfig } from "@/types/runtime";
3328
import { useAIViewKeybinds } from "@/hooks/useAIViewKeybinds";
@@ -75,9 +70,6 @@ const AIViewInner: React.FC<AIViewProps> = ({
7570
const workspaceState = useWorkspaceState(workspaceId);
7671
const aggregator = useWorkspaceAggregator(workspaceId);
7772

78-
// Get git status for this workspace
79-
const gitStatus = useGitStatus(workspaceId);
80-
8173
const [editingMessage, setEditingMessage] = useState<{ id: string; content: string } | undefined>(
8274
undefined
8375
);
@@ -339,41 +331,13 @@ const AIViewInner: React.FC<AIViewProps> = ({
339331
ref={chatAreaRef}
340332
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"
341333
>
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>
334+
<WorkspaceHeader
335+
workspaceId={workspaceId}
336+
projectName={projectName}
337+
branch={branch}
338+
namedWorkspacePath={namedWorkspacePath}
339+
runtimeConfig={runtimeConfig}
340+
/>
377341

378342
<div className="relative flex-1 overflow-hidden">
379343
<div
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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+
export const AgentStatusIndicator: 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 emoji and dot (emoji on left)
91+
const indicator = (
92+
<div className={cn("flex items-center gap-1.5", className)} onClick={handleClick}>
93+
{emoji}
94+
{dot}
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+
};

src/components/Messages/ToolMessage.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { FileEditToolCall } from "../tools/FileEditToolCall";
77
import { FileReadToolCall } from "../tools/FileReadToolCall";
88
import { ProposePlanToolCall } from "../tools/ProposePlanToolCall";
99
import { TodoToolCall } from "../tools/TodoToolCall";
10+
import { StatusSetToolCall } from "../tools/StatusSetToolCall";
1011
import type {
1112
BashToolArgs,
1213
BashToolResult,
@@ -22,6 +23,8 @@ import type {
2223
ProposePlanToolResult,
2324
TodoWriteToolArgs,
2425
TodoWriteToolResult,
26+
StatusSetToolArgs,
27+
StatusSetToolResult,
2528
} from "@/types/tools";
2629

2730
interface ToolMessageProps {
@@ -73,6 +76,11 @@ function isTodoWriteTool(toolName: string, args: unknown): args is TodoWriteTool
7376
return TOOL_DEFINITIONS.todo_write.schema.safeParse(args).success;
7477
}
7578

79+
function isStatusSetTool(toolName: string, args: unknown): args is StatusSetToolArgs {
80+
if (toolName !== "status_set") return false;
81+
return TOOL_DEFINITIONS.status_set.schema.safeParse(args).success;
82+
}
83+
7684
export const ToolMessage: React.FC<ToolMessageProps> = ({ message, className, workspaceId }) => {
7785
// Route to specialized components based on tool name
7886
if (isBashTool(message.toolName, message.args)) {
@@ -164,6 +172,18 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({ message, className, wo
164172
);
165173
}
166174

175+
if (isStatusSetTool(message.toolName, message.args)) {
176+
return (
177+
<div className={className}>
178+
<StatusSetToolCall
179+
args={message.args}
180+
result={message.result as StatusSetToolResult | undefined}
181+
status={message.status}
182+
/>
183+
</div>
184+
);
185+
}
186+
167187
// Fallback to generic tool call
168188
return (
169189
<div className={className}>

0 commit comments

Comments
 (0)