diff --git a/src/components/AIView.tsx b/src/components/AIView.tsx index b1d635a7c1..cefc027ccf 100644 --- a/src/components/AIView.tsx +++ b/src/components/AIView.tsx @@ -21,13 +21,8 @@ import { useAutoScroll } from "@/hooks/useAutoScroll"; import { usePersistedState } from "@/hooks/usePersistedState"; import { useThinking } from "@/contexts/ThinkingContext"; import { useWorkspaceState, useWorkspaceAggregator } from "@/stores/WorkspaceStore"; -import { StatusIndicator } from "./StatusIndicator"; +import { WorkspaceHeader } from "./WorkspaceHeader"; import { getModelName } from "@/utils/ai/models"; -import { GitStatusIndicator } from "./GitStatusIndicator"; -import { RuntimeBadge } from "./RuntimeBadge"; - -import { useGitStatus } from "@/stores/GitStatusStore"; -import { TooltipWrapper, Tooltip } from "./Tooltip"; import type { DisplayedMessage } from "@/types/message"; import type { RuntimeConfig } from "@/types/runtime"; import { useAIViewKeybinds } from "@/hooks/useAIViewKeybinds"; @@ -75,9 +70,6 @@ const AIViewInner: React.FC = ({ const workspaceState = useWorkspaceState(workspaceId); const aggregator = useWorkspaceAggregator(workspaceId); - // Get git status for this workspace - const gitStatus = useGitStatus(workspaceId); - const [editingMessage, setEditingMessage] = useState<{ id: string; content: string } | undefined>( undefined ); @@ -339,41 +331,13 @@ const AIViewInner: React.FC = ({ ref={chatAreaRef} 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" > -
-
- - - - - {projectName} / {branch} - - - {namedWorkspacePath} - - - - - Open in terminal ({formatKeybind(KEYBINDS.OPEN_TERMINAL)}) - - -
-
+
void; + // Display props + size?: number; + className?: string; +} + +export const AgentStatusIndicator: React.FC = ({ + workspaceId, + lastReadTimestamp, + onClick, + size = 8, + className, +}) => { + // Get workspace state + const { canInterrupt, currentModel, agentStatus, recencyTimestamp } = + useWorkspaceSidebarState(workspaceId); + + const streaming = canInterrupt; + + // Compute unread status if lastReadTimestamp provided (sidebar only) + const unread = useMemo(() => { + if (lastReadTimestamp === undefined) return false; + return recencyTimestamp !== null && recencyTimestamp > lastReadTimestamp; + }, [lastReadTimestamp, recencyTimestamp]); + + // Compute tooltip + const title = useMemo( + () => + getStatusTooltip({ + isStreaming: streaming, + streamingModel: currentModel, + agentStatus, + isUnread: unread, + recencyTimestamp, + }), + [streaming, currentModel, agentStatus, unread, recencyTimestamp] + ); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + // Only allow clicking when not streaming + if (!streaming && onClick) { + e.stopPropagation(); // Prevent workspace selection + onClick(e); + } + }, + [streaming, onClick] + ); + + const bgColor = streaming ? "bg-assistant-border" : unread ? "bg-white" : "bg-muted-dark"; + const cursor = onClick && !streaming ? "cursor-pointer" : "cursor-default"; + + // Always show dot, add emoji next to it when available + const dot = ( +
+ ); + + const emoji = agentStatus ? ( +
+ {agentStatus.emoji} +
+ ) : null; + + // Container holds both emoji and dot (emoji on left) + const indicator = ( +
+ {emoji} + {dot} +
+ ); + + // If tooltip content provided, wrap with proper Tooltip component + if (title) { + return ( + + {indicator} + + {title} + + + ); + } + + return indicator; +}; diff --git a/src/components/Messages/ToolMessage.tsx b/src/components/Messages/ToolMessage.tsx index ec2e2875ac..f46c8f2c0e 100644 --- a/src/components/Messages/ToolMessage.tsx +++ b/src/components/Messages/ToolMessage.tsx @@ -7,6 +7,7 @@ import { FileEditToolCall } from "../tools/FileEditToolCall"; import { FileReadToolCall } from "../tools/FileReadToolCall"; import { ProposePlanToolCall } from "../tools/ProposePlanToolCall"; import { TodoToolCall } from "../tools/TodoToolCall"; +import { StatusSetToolCall } from "../tools/StatusSetToolCall"; import type { BashToolArgs, BashToolResult, @@ -22,6 +23,8 @@ import type { ProposePlanToolResult, TodoWriteToolArgs, TodoWriteToolResult, + StatusSetToolArgs, + StatusSetToolResult, } from "@/types/tools"; interface ToolMessageProps { @@ -73,6 +76,11 @@ function isTodoWriteTool(toolName: string, args: unknown): args is TodoWriteTool return TOOL_DEFINITIONS.todo_write.schema.safeParse(args).success; } +function isStatusSetTool(toolName: string, args: unknown): args is StatusSetToolArgs { + if (toolName !== "status_set") return false; + return TOOL_DEFINITIONS.status_set.schema.safeParse(args).success; +} + export const ToolMessage: React.FC = ({ message, className, workspaceId }) => { // Route to specialized components based on tool name if (isBashTool(message.toolName, message.args)) { @@ -164,6 +172,18 @@ export const ToolMessage: React.FC = ({ message, className, wo ); } + if (isStatusSetTool(message.toolName, message.args)) { + return ( +
+ +
+ ); + } + // Fallback to generic tool call return (
diff --git a/src/components/StatusIndicator.stories.tsx b/src/components/StatusIndicator.stories.tsx deleted file mode 100644 index 4dd8995a73..0000000000 --- a/src/components/StatusIndicator.stories.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { action } from "@storybook/addon-actions"; -import { expect, userEvent, waitFor } from "@storybook/test"; -import { StatusIndicator } from "./StatusIndicator"; -import { useArgs } from "storybook/internal/preview-api"; - -const meta = { - title: "Components/StatusIndicator", - component: StatusIndicator, - parameters: { - layout: "centered", - controls: { - exclude: ["onClick", "className"], - }, - }, - tags: ["autodocs"], - argTypes: { - streaming: { - control: "boolean", - description: "Whether the indicator is in streaming state", - }, - unread: { - control: "boolean", - description: "Whether there are unread messages", - }, - size: { - control: { type: "number", min: 4, max: 20, step: 2 }, - description: "Size of the indicator in pixels", - }, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - streaming: false, - unread: false, - }, -}; - -export const Streaming: Story = { - args: { - streaming: true, - unread: false, - }, -}; - -export const Unread: Story = { - args: { - streaming: false, - unread: true, - }, -}; - -export const AllStates: Story = { - args: { streaming: false, unread: false }, - render: () => ( -
-
- - Default -
- -
- - Streaming -
- -
- - Unread -
- -
- - - Streaming (unread ignored) - -
-
- ), -}; - -export const DifferentSizes: Story = { - args: { streaming: false, unread: false }, - render: () => ( -
-
- - 4px -
- -
- - 8px (default) -
- -
- - 12px -
- -
- - 16px -
- -
- - 20px -
-
- ), -}; - -export const WithTooltip: Story = { - args: { - streaming: false, - unread: true, - title: "3 unread messages", - }, -}; - -export const Clickable: Story = { - args: { - streaming: false, - unread: true, - onClick: action("indicator-clicked"), - title: "Click to mark as read", - }, - render: function Render(args) { - const [{ unread }, updateArgs] = useArgs(); - return ( - updateArgs({ unread: !unread })} /> - ); - }, - play: async ({ canvasElement }) => { - // Find the indicator div directly - const wrapper = canvasElement.querySelector("span"); - const indicator = wrapper?.querySelector("div"); - if (!indicator) throw new Error("Could not find indicator"); - - // Initial state - should be unread (white background) - const initialBg = window.getComputedStyle(indicator).backgroundColor; - await expect(initialBg).toContain("255"); // White color contains 255 - - // Click to toggle - await userEvent.click(indicator); - - // Wait for state change - should become read (gray background) - await waitFor(() => { - const newBg = window.getComputedStyle(indicator).backgroundColor; - void expect(newBg).toContain("110"); // Gray color #6e6e6e contains 110 - }); - - // Click again to toggle back - await userEvent.click(indicator); - - // Should be unread (white) again - await waitFor(() => { - const finalBg = window.getComputedStyle(indicator).backgroundColor; - void expect(finalBg).toContain("255"); - }); - }, -}; - -export const StreamingPreventsClick: Story = { - args: { - streaming: true, - unread: false, - onClick: action("indicator-clicked"), - }, - render: function Render(args) { - const [{ unread }, updateArgs] = useArgs(); - return ( - updateArgs({ unread: !unread })} /> - ); - }, - play: async ({ canvasElement }) => { - // Find the indicator div - const indicator = canvasElement.querySelector("div"); - if (!indicator) throw new Error("Could not find indicator"); - - // Verify cursor is default (not clickable) when streaming - const cursorStyle = window.getComputedStyle(indicator).cursor; - await expect(cursorStyle).toBe("default"); - - // Try to click - state should NOT change - await userEvent.click(indicator); - - // Brief wait to ensure no state change occurs - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Verify cursor is still default (state hasn't changed) - const cursorAfter = window.getComputedStyle(indicator).cursor; - await expect(cursorAfter).toBe("default"); - }, -}; - -export const WithTooltipInteraction: Story = { - args: { - streaming: false, - unread: true, - title: "3 unread messages", - }, - play: async ({ canvasElement }) => { - // Find the wrapper span - const wrapper = canvasElement.querySelector("span"); - if (!wrapper) throw new Error("Could not find wrapper"); - - // Hover over the indicator to show tooltip - await userEvent.hover(wrapper); - - // Wait for tooltip to appear (uses portal to document.body) - await waitFor( - async () => { - const tooltip = document.body.querySelector(".tooltip"); - await expect(tooltip).toBeInTheDocument(); - await expect(tooltip).toHaveTextContent("3 unread messages"); - }, - { timeout: 2000 } - ); - - // Unhover to hide tooltip - await userEvent.unhover(wrapper); - - // Wait for tooltip to disappear - await waitFor( - async () => { - const tooltip = document.body.querySelector(".tooltip"); - await expect(tooltip).not.toBeInTheDocument(); - }, - { timeout: 2000 } - ); - }, -}; - -export const InContext: Story = { - args: { streaming: false, unread: false }, - parameters: { - controls: { disable: true }, - }, - render: () => { - return ( -
-
- - workspace-feature-branch -
- -
- - workspace-main (streaming) -
- -
- - workspace-bugfix (3 unread) -
-
- ); - }, -}; diff --git a/src/components/StatusIndicator.tsx b/src/components/StatusIndicator.tsx deleted file mode 100644 index 56757b1eee..0000000000 --- a/src/components/StatusIndicator.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React, { useCallback } from "react"; -import { cn } from "@/lib/utils"; -import { TooltipWrapper, Tooltip } from "./Tooltip"; - -interface StatusIndicatorProps { - streaming: boolean; - unread?: boolean; - size?: number; - className?: string; - title?: React.ReactNode; - onClick?: (e: React.MouseEvent) => void; -} - -const StatusIndicatorInner: React.FC = ({ - streaming, - unread, - size = 8, - className, - title, - onClick, -}) => { - const handleClick = useCallback( - (e: React.MouseEvent) => { - // Only allow clicking when not streaming - if (!streaming && onClick) { - e.stopPropagation(); // Prevent workspace selection - onClick(e); - } - }, - [streaming, onClick] - ); - - const bgColor = streaming ? "bg-assistant-border" : unread ? "bg-white" : "bg-muted-dark"; - - const cursor = onClick && !streaming ? "cursor-pointer" : "cursor-default"; - - const indicator = ( -
- ); - - // If title provided, wrap with proper Tooltip component - if (title) { - return ( - - {indicator} - - {title} - - - ); - } - - return indicator; -}; - -// Memoize to prevent re-renders when props haven't changed -export const StatusIndicator = React.memo(StatusIndicatorInner); diff --git a/src/components/WorkspaceHeader.tsx b/src/components/WorkspaceHeader.tsx new file mode 100644 index 0000000000..4b0dbac998 --- /dev/null +++ b/src/components/WorkspaceHeader.tsx @@ -0,0 +1,62 @@ +import React, { useCallback } from "react"; +import { AgentStatusIndicator } from "./AgentStatusIndicator"; +import { GitStatusIndicator } from "./GitStatusIndicator"; +import { RuntimeBadge } from "./RuntimeBadge"; +import { TooltipWrapper, Tooltip } from "./Tooltip"; +import { formatKeybind, KEYBINDS } from "@/utils/ui/keybinds"; +import { useGitStatus } from "@/stores/GitStatusStore"; +import type { RuntimeConfig } from "@/types/runtime"; + +interface WorkspaceHeaderProps { + workspaceId: string; + projectName: string; + branch: string; + namedWorkspacePath: string; + runtimeConfig?: RuntimeConfig; +} + +export const WorkspaceHeader: React.FC = ({ + workspaceId, + projectName, + branch, + namedWorkspacePath, + runtimeConfig, +}) => { + const gitStatus = useGitStatus(workspaceId); + const handleOpenTerminal = useCallback(() => { + void window.api.workspace.openTerminal(namedWorkspacePath); + }, [namedWorkspacePath]); + + return ( +
+
+ + + + + {projectName} / {branch} + + + {namedWorkspacePath} + + + + + Open in terminal ({formatKeybind(KEYBINDS.OPEN_TERMINAL)}) + + +
+
+ ); +}; diff --git a/src/components/WorkspaceListItem.tsx b/src/components/WorkspaceListItem.tsx index 4ba1755c45..8c1a68f158 100644 --- a/src/components/WorkspaceListItem.tsx +++ b/src/components/WorkspaceListItem.tsx @@ -1,12 +1,9 @@ -import React, { useState, useCallback, useMemo } from "react"; +import React, { useState, useCallback } from "react"; import type { FrontendWorkspaceMetadata } from "@/types/workspace"; -import { useWorkspaceSidebarState } from "@/stores/WorkspaceStore"; import { useGitStatus } from "@/stores/GitStatusStore"; -import { formatRelativeTime } from "@/utils/ui/dateTime"; import { TooltipWrapper, Tooltip } from "./Tooltip"; import { GitStatusIndicator } from "./GitStatusIndicator"; -import { ModelDisplay } from "./Messages/ModelDisplay"; -import { StatusIndicator } from "./StatusIndicator"; +import { AgentStatusIndicator } from "./AgentStatusIndicator"; import { useRename } from "@/contexts/WorkspaceRenameContext"; import { cn } from "@/lib/utils"; import { RuntimeBadge } from "./RuntimeBadge"; @@ -42,8 +39,6 @@ const WorkspaceListItemInner: React.FC = ({ }) => { // Destructure metadata for convenience const { id: workspaceId, name: workspaceName, namedWorkspacePath } = metadata; - // Subscribe to this specific workspace's sidebar state (streaming status, model, recency) - const sidebarState = useWorkspaceSidebarState(workspaceId); const gitStatus = useGitStatus(workspaceId); // Get rename context @@ -55,16 +50,8 @@ const WorkspaceListItemInner: React.FC = ({ // Use workspace name from metadata instead of deriving from path const displayName = workspaceName; - const isStreaming = sidebarState.canInterrupt; - const streamingModel = sidebarState.currentModel; const isEditing = editingWorkspaceId === workspaceId; - // Compute unread status locally based on recency vs last read timestamp - // Note: We don't check !isSelected here because user should be able to see - // and toggle unread status even for the selected workspace - const isUnread = - sidebarState.recencyTimestamp !== null && sidebarState.recencyTimestamp > lastReadTimestamp; - const startRenaming = () => { if (requestRename(workspaceId, displayName)) { setEditingName(displayName); @@ -102,33 +89,12 @@ const WorkspaceListItemInner: React.FC = ({ } }; - // Memoize toggle unread handler to prevent StatusIndicator re-renders + // Memoize toggle unread handler to prevent AgentStatusIndicator re-renders const handleToggleUnread = useCallback( () => onToggleUnread(workspaceId), [onToggleUnread, workspaceId] ); - // Memoize tooltip title to prevent creating new React elements on every render - const statusTooltipTitle = useMemo(() => { - if (isStreaming && streamingModel) { - return ( - - is responding - - ); - } - if (isStreaming) { - return "Assistant is responding"; - } - if (isUnread) { - return "Unread messages"; - } - if (sidebarState.recencyTimestamp) { - return `Idle • Last used ${formatRelativeTime(sidebarState.recencyTimestamp)}`; - } - return "Idle"; - }, [isStreaming, streamingModel, isUnread, sidebarState.recencyTimestamp]); - return (
= ({ )}
-
{renameError && isEditing && ( diff --git a/src/components/tools/StatusSetToolCall.tsx b/src/components/tools/StatusSetToolCall.tsx new file mode 100644 index 0000000000..4c313fa5c8 --- /dev/null +++ b/src/components/tools/StatusSetToolCall.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import type { StatusSetToolArgs, StatusSetToolResult } from "@/types/tools"; +import { ToolContainer, ToolHeader, StatusIndicator } from "./shared/ToolPrimitives"; +import { getStatusDisplay, type ToolStatus } from "./shared/toolUtils"; +import { TooltipWrapper, Tooltip } from "../Tooltip"; + +interface StatusSetToolCallProps { + args: StatusSetToolArgs; + result?: StatusSetToolResult; + status?: ToolStatus; +} + +export const StatusSetToolCall: React.FC = ({ + args, + result: _result, + status = "pending", +}) => { + const statusDisplay = getStatusDisplay(status); + + return ( + + + + {args.emoji} + status_set + + {args.message} + {statusDisplay} + + + ); +}; diff --git a/src/services/tools/status_set.test.ts b/src/services/tools/status_set.test.ts new file mode 100644 index 0000000000..5625c7b133 --- /dev/null +++ b/src/services/tools/status_set.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from "bun:test"; +import { createStatusSetTool } from "./status_set"; +import type { ToolConfiguration } from "@/utils/tools/tools"; +import { createRuntime } from "@/runtime/runtimeFactory"; +import type { ToolCallOptions } from "ai"; + +describe("status_set tool validation", () => { + const mockConfig: ToolConfiguration = { + cwd: "/test", + runtime: createRuntime({ type: "local", srcBaseDir: "/tmp" }), + runtimeTempDir: "/tmp", + }; + + const mockToolCallOptions: ToolCallOptions = { + toolCallId: "test-call-id", + messages: [], + }; + + describe("emoji validation", () => { + it("should accept single emoji characters", async () => { + const tool = createStatusSetTool(mockConfig); + + const emojis = ["🔍", "📝", "✅", "🚀", "⏳"]; + for (const emoji of emojis) { + const result = (await tool.execute!({ emoji, message: "Test" }, mockToolCallOptions)) as { + success: boolean; + emoji: string; + message: string; + }; + expect(result).toEqual({ success: true, emoji, message: "Test" }); + } + }); + + it("should reject multiple emojis", async () => { + const tool = createStatusSetTool(mockConfig); + + const result1 = (await tool.execute!( + { emoji: "🔍📝", message: "Test" }, + mockToolCallOptions + )) as { success: boolean; error: string }; + expect(result1.success).toBe(false); + expect(result1.error).toBe("emoji must be a single emoji character"); + + const result2 = (await tool.execute!( + { emoji: "✅✅", message: "Test" }, + mockToolCallOptions + )) as { success: boolean; error: string }; + expect(result2.success).toBe(false); + expect(result2.error).toBe("emoji must be a single emoji character"); + }); + + it("should reject text (non-emoji)", async () => { + const tool = createStatusSetTool(mockConfig); + + const result1 = (await tool.execute!( + { emoji: "a", message: "Test" }, + mockToolCallOptions + )) as { + success: boolean; + error: string; + }; + expect(result1.success).toBe(false); + expect(result1.error).toBe("emoji must be a single emoji character"); + + const result2 = (await tool.execute!( + { emoji: "abc", message: "Test" }, + mockToolCallOptions + )) as { success: boolean; error: string }; + expect(result2.success).toBe(false); + expect(result2.error).toBe("emoji must be a single emoji character"); + + const result3 = (await tool.execute!( + { emoji: "!", message: "Test" }, + mockToolCallOptions + )) as { + success: boolean; + error: string; + }; + expect(result3.success).toBe(false); + expect(result3.error).toBe("emoji must be a single emoji character"); + }); + + it("should reject empty emoji", async () => { + const tool = createStatusSetTool(mockConfig); + + const result = (await tool.execute!({ emoji: "", message: "Test" }, mockToolCallOptions)) as { + success: boolean; + error: string; + }; + expect(result.success).toBe(false); + expect(result.error).toBe("emoji must be a single emoji character"); + }); + + it("should reject emoji with text", async () => { + const tool = createStatusSetTool(mockConfig); + + const result1 = (await tool.execute!( + { emoji: "🔍a", message: "Test" }, + mockToolCallOptions + )) as { success: boolean; error: string }; + expect(result1.success).toBe(false); + expect(result1.error).toBe("emoji must be a single emoji character"); + + const result2 = (await tool.execute!( + { emoji: "x🔍", message: "Test" }, + mockToolCallOptions + )) as { success: boolean; error: string }; + expect(result2.success).toBe(false); + expect(result2.error).toBe("emoji must be a single emoji character"); + }); + }); + + describe("message validation", () => { + it("should accept messages up to 40 characters", async () => { + const tool = createStatusSetTool(mockConfig); + + const result1 = (await tool.execute!( + { emoji: "✅", message: "a".repeat(40) }, + mockToolCallOptions + )) as { success: boolean }; + expect(result1.success).toBe(true); + + const result2 = (await tool.execute!( + { emoji: "✅", message: "Analyzing code structure" }, + mockToolCallOptions + )) as { success: boolean }; + expect(result2.success).toBe(true); + }); + + it("should accept empty message", async () => { + const tool = createStatusSetTool(mockConfig); + + const result = (await tool.execute!({ emoji: "✅", message: "" }, mockToolCallOptions)) as { + success: boolean; + }; + expect(result.success).toBe(true); + }); + }); +}); diff --git a/src/services/tools/status_set.ts b/src/services/tools/status_set.ts new file mode 100644 index 0000000000..ee73d90d98 --- /dev/null +++ b/src/services/tools/status_set.ts @@ -0,0 +1,62 @@ +import { tool } from "ai"; +import type { ToolFactory } from "@/utils/tools/tools"; +import { TOOL_DEFINITIONS } from "@/utils/tools/toolDefinitions"; + +/** + * Result type for status_set tool + */ +export type StatusSetToolResult = + | { + success: true; + emoji: string; + message: string; + } + | { + success: false; + error: string; + }; + +/** + * Validates that a string is a single emoji character + * Uses Unicode property escapes to match emoji characters + */ +function isValidEmoji(str: string): boolean { + // Check if string contains exactly one character (handles multi-byte emojis) + const chars = [...str]; + if (chars.length !== 1) { + return false; + } + + // Check if it's an emoji using Unicode properties + const emojiRegex = /^[\p{Emoji_Presentation}\p{Extended_Pictographic}]$/u; + return emojiRegex.test(str); +} + +/** + * Status set tool factory for AI assistant + * Creates a tool that allows the AI to set status indicator showing current activity + * @param config Required configuration (not used for this tool, but required by interface) + */ +export const createStatusSetTool: ToolFactory = () => { + return tool({ + description: TOOL_DEFINITIONS.status_set.description, + inputSchema: TOOL_DEFINITIONS.status_set.schema, + execute: ({ emoji, message }): Promise => { + // Validate emoji + if (!isValidEmoji(emoji)) { + return Promise.resolve({ + success: false, + error: "emoji must be a single emoji character", + }); + } + + // Tool execution is a no-op on the backend + // The status is tracked by StreamingMessageAggregator and displayed in the frontend + return Promise.resolve({ + success: true, + emoji, + message, + }); + }, + }); +}; diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index 609fbbe5fa..73e598acc7 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -29,6 +29,7 @@ export interface WorkspaceState { currentModel: string | null; recencyTimestamp: number | null; todos: TodoItem[]; + agentStatus: { emoji: string; message: string } | undefined; pendingStreamStartTime: number | null; } @@ -40,6 +41,7 @@ export interface WorkspaceSidebarState { canInterrupt: boolean; currentModel: string | null; recencyTimestamp: number | null; + agentStatus: { emoji: string; message: string } | undefined; } /** @@ -50,6 +52,7 @@ function extractSidebarState(aggregator: StreamingMessageAggregator): WorkspaceS canInterrupt: aggregator.getActiveStreams().length > 0, currentModel: aggregator.getCurrentModel() ?? null, recencyTimestamp: aggregator.getRecencyTimestamp(), + agentStatus: aggregator.getAgentStatus(), }; } @@ -306,7 +309,8 @@ export class WorkspaceStore { !previous || previous.canInterrupt !== current.canInterrupt || previous.currentModel !== current.currentModel || - previous.recencyTimestamp !== current.recencyTimestamp + previous.recencyTimestamp !== current.recencyTimestamp || + previous.agentStatus !== current.agentStatus ) { this.previousSidebarValues.set(workspaceId, current); this.states.bump(workspaceId); @@ -363,6 +367,7 @@ export class WorkspaceStore { currentModel: aggregator.getCurrentModel() ?? null, recencyTimestamp: aggregator.getRecencyTimestamp(), todos: aggregator.getCurrentTodos(), + agentStatus: aggregator.getAgentStatus(), pendingStreamStartTime: aggregator.getPendingStreamStartTime(), }; }); @@ -385,7 +390,8 @@ export class WorkspaceStore { cached && cached.canInterrupt === fullState.canInterrupt && cached.currentModel === fullState.currentModel && - cached.recencyTimestamp === fullState.recencyTimestamp + cached.recencyTimestamp === fullState.recencyTimestamp && + cached.agentStatus === fullState.agentStatus ) { return cached; } @@ -395,6 +401,7 @@ export class WorkspaceStore { canInterrupt: fullState.canInterrupt, currentModel: fullState.currentModel, recencyTimestamp: fullState.recencyTimestamp, + agentStatus: fullState.agentStatus, }; this.sidebarStateCache.set(workspaceId, newState); return newState; diff --git a/src/types/tools.ts b/src/types/tools.ts index bc5169adbb..3c95bef07b 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -157,3 +157,20 @@ export interface TodoWriteToolResult { success: true; count: number; } + +// Status Set Tool Types +export interface StatusSetToolArgs { + emoji: string; + message: string; +} + +export type StatusSetToolResult = + | { + success: true; + emoji: string; + message: string; + } + | { + success: false; + error: string; + }; diff --git a/src/utils/messages/StreamingMessageAggregator.status.test.ts b/src/utils/messages/StreamingMessageAggregator.status.test.ts new file mode 100644 index 0000000000..fac616c4aa --- /dev/null +++ b/src/utils/messages/StreamingMessageAggregator.status.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect } from "bun:test"; +import { StreamingMessageAggregator } from "./StreamingMessageAggregator"; + +describe("StreamingMessageAggregator - Agent Status", () => { + it("should start with undefined agent status", () => { + const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); + expect(aggregator.getAgentStatus()).toBeUndefined(); + }); + + it("should update agent status when status_set tool succeeds", () => { + const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); + const messageId = "msg1"; + const toolCallId = "tool1"; + + // Start a stream + aggregator.handleStreamStart({ + type: "stream-start", + workspaceId: "workspace1", + messageId, + model: "test-model", + historySequence: 1, + }); + + // Add a status_set tool call + aggregator.handleToolCallStart({ + type: "tool-call-start", + workspaceId: "workspace1", + messageId, + toolCallId, + toolName: "status_set", + args: { emoji: "🔍", message: "Analyzing code" }, + tokens: 10, + timestamp: Date.now(), + }); + + // Complete the tool call + aggregator.handleToolCallEnd({ + type: "tool-call-end", + workspaceId: "workspace1", + messageId, + toolCallId, + toolName: "status_set", + result: { success: true, emoji: "🔍", message: "Analyzing code" }, + }); + + const status = aggregator.getAgentStatus(); + expect(status).toBeDefined(); + expect(status?.emoji).toBe("🔍"); + expect(status?.message).toBe("Analyzing code"); + }); + + it("should update agent status multiple times", () => { + const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); + const messageId = "msg1"; + + // Start a stream + aggregator.handleStreamStart({ + type: "stream-start", + workspaceId: "workspace1", + messageId, + model: "test-model", + historySequence: 1, + }); + + // First status_set + aggregator.handleToolCallStart({ + type: "tool-call-start", + workspaceId: "workspace1", + messageId, + toolCallId: "tool1", + toolName: "status_set", + args: { emoji: "🔍", message: "Analyzing" }, + tokens: 10, + timestamp: Date.now(), + }); + + aggregator.handleToolCallEnd({ + type: "tool-call-end", + workspaceId: "workspace1", + messageId, + toolCallId: "tool1", + toolName: "status_set", + result: { success: true, emoji: "🔍", message: "Analyzing" }, + }); + + expect(aggregator.getAgentStatus()?.emoji).toBe("🔍"); + + // Second status_set + aggregator.handleToolCallStart({ + type: "tool-call-start", + workspaceId: "workspace1", + messageId, + toolCallId: "tool2", + toolName: "status_set", + args: { emoji: "📝", message: "Writing" }, + tokens: 10, + timestamp: Date.now(), + }); + + aggregator.handleToolCallEnd({ + type: "tool-call-end", + workspaceId: "workspace1", + messageId, + toolCallId: "tool2", + toolName: "status_set", + result: { success: true, emoji: "📝", message: "Writing" }, + }); + + expect(aggregator.getAgentStatus()?.emoji).toBe("📝"); + expect(aggregator.getAgentStatus()?.message).toBe("Writing"); + }); + + it("should persist agent status after stream ends", () => { + const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); + const messageId = "msg1"; + + // Start a stream + aggregator.handleStreamStart({ + type: "stream-start", + workspaceId: "workspace1", + messageId, + model: "test-model", + historySequence: 1, + }); + + // Set status + aggregator.handleToolCallStart({ + type: "tool-call-start", + workspaceId: "workspace1", + messageId, + toolCallId: "tool1", + toolName: "status_set", + args: { emoji: "🔍", message: "Working" }, + tokens: 10, + timestamp: Date.now(), + }); + + aggregator.handleToolCallEnd({ + type: "tool-call-end", + workspaceId: "workspace1", + messageId, + toolCallId: "tool1", + toolName: "status_set", + result: { success: true, emoji: "🔍", message: "Working" }, + }); + + expect(aggregator.getAgentStatus()).toBeDefined(); + + // End the stream + aggregator.handleStreamEnd({ + type: "stream-end", + workspaceId: "workspace1", + messageId, + metadata: { model: "test-model" }, + parts: [], + }); + + // Status should persist after stream ends (unlike todos) + expect(aggregator.getAgentStatus()).toBeDefined(); + expect(aggregator.getAgentStatus()?.emoji).toBe("🔍"); + }); + + it("should not update agent status if tool call fails", () => { + const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); + const messageId = "msg1"; + + // Start a stream + aggregator.handleStreamStart({ + type: "stream-start", + workspaceId: "workspace1", + messageId, + model: "test-model", + historySequence: 1, + }); + + // Add a status_set tool call + aggregator.handleToolCallStart({ + type: "tool-call-start", + workspaceId: "workspace1", + messageId, + toolCallId: "tool1", + toolName: "status_set", + args: { emoji: "🔍", message: "Analyzing" }, + tokens: 10, + timestamp: Date.now(), + }); + + // Complete with failure + aggregator.handleToolCallEnd({ + type: "tool-call-end", + workspaceId: "workspace1", + messageId, + toolCallId: "tool1", + toolName: "status_set", + result: { success: false, error: "Something went wrong" }, + }); + + // Status should remain undefined + expect(aggregator.getAgentStatus()).toBeUndefined(); + }); + + it("should clear agent status on stream-start (different from TODO behavior)", () => { + const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); + + // Start first stream and set status + aggregator.handleStreamStart({ + type: "stream-start", + workspaceId: "workspace1", + messageId: "msg1", + model: "test-model", + historySequence: 1, + }); + + aggregator.handleToolCallStart({ + type: "tool-call-start", + workspaceId: "workspace1", + messageId: "msg1", + toolCallId: "tool1", + toolName: "status_set", + args: { emoji: "🔍", message: "First task" }, + tokens: 10, + timestamp: Date.now(), + }); + + aggregator.handleToolCallEnd({ + type: "tool-call-end", + workspaceId: "workspace1", + messageId: "msg1", + toolCallId: "tool1", + toolName: "status_set", + result: { success: true, emoji: "🔍", message: "First task" }, + }); + + expect(aggregator.getAgentStatus()?.message).toBe("First task"); + + // End first stream + aggregator.handleStreamEnd({ + type: "stream-end", + workspaceId: "workspace1", + messageId: "msg1", + metadata: { model: "test-model" }, + parts: [], + }); + + // Status persists after stream ends + expect(aggregator.getAgentStatus()?.message).toBe("First task"); + + // Start a NEW stream - status should be cleared + aggregator.handleStreamStart({ + type: "stream-start", + workspaceId: "workspace1", + messageId: "msg2", + model: "test-model", + historySequence: 2, + }); + + // Status should be cleared on new stream start + expect(aggregator.getAgentStatus()).toBeUndefined(); + }); +}); diff --git a/src/utils/messages/StreamingMessageAggregator.ts b/src/utils/messages/StreamingMessageAggregator.ts index 5446b49e7a..a235c2d111 100644 --- a/src/utils/messages/StreamingMessageAggregator.ts +++ b/src/utils/messages/StreamingMessageAggregator.ts @@ -51,6 +51,10 @@ export class StreamingMessageAggregator { // Current TODO list (updated when todo_write succeeds) private currentTodos: TodoItem[] = []; + // Current agent status (updated when status_set is called) + // Unlike todos, this persists after stream completion to show last activity + private agentStatus: { emoji: string; message: string } | undefined = undefined; + // Workspace init hook state (ephemeral, not persisted to history) private initState: { status: "running" | "success" | "error"; @@ -116,6 +120,15 @@ export class StreamingMessageAggregator { return this.currentTodos; } + /** + * Get the current agent status. + * Updated whenever status_set is called. + * Persists after stream completion (unlike todos). + */ + getAgentStatus(): { emoji: string; message: string } | undefined { + return this.agentStatus; + } + /** * Extract compaction summary text from a completed assistant message. * Used when a compaction stream completes to get the summary for history replacement. @@ -139,6 +152,8 @@ export class StreamingMessageAggregator { */ private cleanupStreamState(messageId: string): void { this.currentTodos = []; + // NOTE: agentStatus is NOT cleared here - it persists after stream completion + // to show the last activity. This is different from todos which are stream-scoped. this.activeStreams.delete(messageId); } @@ -267,6 +282,11 @@ export class StreamingMessageAggregator { // Clear pending stream start timestamp - stream has started this.setPendingStreamStartTime(null); + // Clear agent status on stream start (unlike todos which persist across streams). + // Rationale: Status represents current activity, so it should be cleared and reset + // for each new stream. Todos represent pending work, so they persist until completion. + this.agentStatus = undefined; + // Detect if this stream is compacting by checking if last user message is a compaction-request const messages = this.getAllMessages(); const lastUserMsg = [...messages].reverse().find((m) => m.role === "user"); @@ -481,6 +501,18 @@ export class StreamingMessageAggregator { this.currentTodos = args.todos; } } + + // Update agent status if this was a successful status_set + if ( + data.toolName === "status_set" && + typeof data.result === "object" && + data.result !== null && + "success" in data.result && + data.result.success + ) { + const args = toolPart.input as { emoji: string; message: string }; + this.agentStatus = { emoji: args.emoji, message: args.message }; + } } this.invalidateCache(); } diff --git a/src/utils/tools/toolDefinitions.ts b/src/utils/tools/toolDefinitions.ts index edbe57245c..5906cfeded 100644 --- a/src/utils/tools/toolDefinitions.ts +++ b/src/utils/tools/toolDefinitions.ts @@ -181,6 +181,23 @@ export const TOOL_DEFINITIONS = { description: "Read the current todo list", schema: z.object({}), }, + status_set: { + description: + "Set a status indicator to show what the agent is currently doing. " + + "The emoji appears left of the streaming indicator, and the message shows on hover. " + + "IMPORTANT: Always set a status at the start of each response and update it as your work progresses. " + + "The status is cleared when a new stream starts, so you must set it again for each response. " + + "Use this to communicate ongoing activities (e.g., '🔍 Analyzing code', '📝 Writing tests', '🔧 Refactoring logic').", + schema: z + .object({ + emoji: z.string().describe("A single emoji character representing the current activity"), + message: z + .string() + .max(40) + .describe("A brief description of the current activity (max 40 characters)"), + }) + .strict(), + }, } as const; /** @@ -220,6 +237,7 @@ export function getAvailableTools(modelString: string): string[] { "propose_plan", "todo_write", "todo_read", + "status_set", ]; // Add provider-specific tools diff --git a/src/utils/tools/tools.ts b/src/utils/tools/tools.ts index c9aa0aeda9..a48be74b11 100644 --- a/src/utils/tools/tools.ts +++ b/src/utils/tools/tools.ts @@ -6,6 +6,7 @@ import { createFileEditReplaceStringTool } from "@/services/tools/file_edit_repl import { createFileEditInsertTool } from "@/services/tools/file_edit_insert"; import { createProposePlanTool } from "@/services/tools/propose_plan"; import { createTodoWriteTool, createTodoReadTool } from "@/services/tools/todo"; +import { createStatusSetTool } from "@/services/tools/status_set"; import { wrapWithInitWait } from "@/services/tools/wrapWithInitWait"; import { log } from "@/services/log"; @@ -77,6 +78,7 @@ export async function getToolsForModel( propose_plan: createProposePlanTool(config), todo_write: createTodoWriteTool(config), todo_read: createTodoReadTool(config), + status_set: createStatusSetTool(config), }; // Base tools available for all models diff --git a/src/utils/ui/statusTooltip.tsx b/src/utils/ui/statusTooltip.tsx new file mode 100644 index 0000000000..2f07a1cbcd --- /dev/null +++ b/src/utils/ui/statusTooltip.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import { ModelDisplay } from "@/components/Messages/ModelDisplay"; +import { formatRelativeTime } from "@/utils/ui/dateTime"; + +/** + * Compute tooltip content for StatusIndicator based on workspace state. + * Handles both sidebar (with unread/recency) and header (simpler) cases. + */ +export function getStatusTooltip(options: { + isStreaming: boolean; + streamingModel: string | null; + agentStatus?: { emoji: string; message: string }; + isUnread?: boolean; + recencyTimestamp?: number | null; +}): React.ReactNode { + const { isStreaming, streamingModel, agentStatus, isUnread, recencyTimestamp } = options; + + // If agent status is set, always show that message + if (agentStatus) { + return agentStatus.message; + } + + // Otherwise show streaming/idle status + if (isStreaming && streamingModel) { + return ( + + is responding + + ); + } + + if (isStreaming) { + return "Assistant is responding"; + } + + // Only show unread if explicitly provided (sidebar only) + if (isUnread) { + return "Unread messages"; + } + + // Show recency if available (sidebar only) + if (recencyTimestamp) { + return `Idle • Last used ${formatRelativeTime(recencyTimestamp)}`; + } + + return "Idle"; +}