diff --git a/eslint.config.mjs b/eslint.config.mjs index 46fa29356..070e6c8c6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -368,6 +368,10 @@ export default defineConfig([ }, { // Frontend architectural boundary - prevent services and tokenizer imports + // Note: src/utils/** and src/stores/** are not included because: + // - Some utils are shared between main/renderer (e.g., utils/tools registry) + // - Stores can import from utils/messages which is renderer-safe + // - Type-only imports from services are safe (types live in src/types/) files: ["src/components/**", "src/contexts/**", "src/hooks/**", "src/App.tsx"], rules: { "no-restricted-imports": [ diff --git a/src/components/AgentStatusIndicator.tsx b/src/components/AgentStatusIndicator.tsx index 223bdc364..84097208f 100644 --- a/src/components/AgentStatusIndicator.tsx +++ b/src/components/AgentStatusIndicator.tsx @@ -74,14 +74,29 @@ export const AgentStatusIndicator: React.FC = ({ /> ); + const handleEmojiClick = useCallback( + (e: React.MouseEvent) => { + if (agentStatus?.url) { + e.stopPropagation(); // Prevent workspace selection + window.open(agentStatus.url, "_blank", "noopener,noreferrer"); + } + }, + [agentStatus?.url] + ); + const emoji = agentStatus ? (
{agentStatus.emoji}
diff --git a/src/services/tools/status_set.test.ts b/src/services/tools/status_set.test.ts index e8ea73b50..18e3612cf 100644 --- a/src/services/tools/status_set.test.ts +++ b/src/services/tools/status_set.test.ts @@ -191,4 +191,55 @@ describe("status_set tool validation", () => { expect(result.success).toBe(true); }); }); + + describe("url parameter", () => { + it("should accept valid URLs", async () => { + const tool = createStatusSetTool(mockConfig); + + const validUrls = [ + "https://github.com/owner/repo/pull/123", + "http://example.com", + "https://example.com/path/to/resource?query=param", + ]; + + for (const url of validUrls) { + const result = (await tool.execute!( + { emoji: "🔍", message: "Test", url }, + mockToolCallOptions + )) as { + success: boolean; + url: string; + }; + expect(result.success).toBe(true); + expect(result.url).toBe(url); + } + }); + + it("should work without URL parameter", async () => { + const tool = createStatusSetTool(mockConfig); + + const result = (await tool.execute!( + { emoji: "✅", message: "Test" }, + mockToolCallOptions + )) as { + success: boolean; + url?: string; + }; + expect(result.success).toBe(true); + expect(result.url).toBeUndefined(); + }); + + it("should omit URL from result when undefined", async () => { + const tool = createStatusSetTool(mockConfig); + + const result = (await tool.execute!( + { emoji: "✅", message: "Test", url: undefined }, + mockToolCallOptions + )) as { + success: boolean; + }; + expect(result.success).toBe(true); + expect("url" in result).toBe(false); + }); + }); }); diff --git a/src/services/tools/status_set.ts b/src/services/tools/status_set.ts index ac0ca36a8..954bdb38d 100644 --- a/src/services/tools/status_set.ts +++ b/src/services/tools/status_set.ts @@ -2,20 +2,7 @@ import { tool } from "ai"; import type { ToolFactory } from "@/utils/tools/tools"; import { TOOL_DEFINITIONS } from "@/utils/tools/toolDefinitions"; import { STATUS_MESSAGE_MAX_LENGTH } from "@/constants/toolLimits"; - -/** - * Result type for status_set tool - */ -export type StatusSetToolResult = - | { - success: true; - emoji: string; - message: string; - } - | { - success: false; - error: string; - }; +import type { StatusSetToolResult } from "@/types/tools"; /** * Validates that a string is a single emoji character @@ -65,7 +52,7 @@ export const createStatusSetTool: ToolFactory = () => { return tool({ description: TOOL_DEFINITIONS.status_set.description, inputSchema: TOOL_DEFINITIONS.status_set.schema, - execute: ({ emoji, message }): Promise => { + execute: ({ emoji, message, url }): Promise => { // Validate emoji if (!isValidEmoji(emoji)) { return Promise.resolve({ @@ -83,6 +70,7 @@ export const createStatusSetTool: ToolFactory = () => { success: true, emoji, message: truncatedMessage, + ...(url && { url }), }); }, }); diff --git a/src/stores/WorkspaceStore.ts b/src/stores/WorkspaceStore.ts index 48fcbe49a..1815c86a2 100644 --- a/src/stores/WorkspaceStore.ts +++ b/src/stores/WorkspaceStore.ts @@ -29,7 +29,7 @@ export interface WorkspaceState { currentModel: string | null; recencyTimestamp: number | null; todos: TodoItem[]; - agentStatus: { emoji: string; message: string } | undefined; + agentStatus: { emoji: string; message: string; url?: string } | undefined; pendingStreamStartTime: number | null; } @@ -41,7 +41,7 @@ export interface WorkspaceSidebarState { canInterrupt: boolean; currentModel: string | null; recencyTimestamp: number | null; - agentStatus: { emoji: string; message: string } | undefined; + agentStatus: { emoji: string; message: string; url?: string } | undefined; } /** diff --git a/src/types/tools.ts b/src/types/tools.ts index 5cd5f6c9a..a2ae4c7e4 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -183,6 +183,7 @@ export interface TodoWriteToolResult { export interface StatusSetToolArgs { emoji: string; message: string; + url?: string; } export type StatusSetToolResult = @@ -190,6 +191,7 @@ export type StatusSetToolResult = success: true; emoji: string; message: string; + url?: string; } | { success: false; diff --git a/src/utils/messages/StreamingMessageAggregator.status.test.ts b/src/utils/messages/StreamingMessageAggregator.status.test.ts index 5609c8b2b..37979932c 100644 --- a/src/utils/messages/StreamingMessageAggregator.status.test.ts +++ b/src/utils/messages/StreamingMessageAggregator.status.test.ts @@ -539,4 +539,224 @@ describe("StreamingMessageAggregator - Agent Status", () => { expect(status).toEqual({ emoji: "✅", message: truncatedMessage }); expect(status?.message.length).toBe(60); }); + + it("should store URL when provided in status_set", () => { + 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 with URL + const testUrl = "https://github.com/owner/repo/pull/123"; + aggregator.handleToolCallStart({ + type: "tool-call-start", + workspaceId: "workspace1", + messageId, + toolCallId, + toolName: "status_set", + args: { emoji: "🔗", message: "PR submitted", url: testUrl }, + 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: "PR submitted", url: testUrl }, + }); + + const status = aggregator.getAgentStatus(); + expect(status).toBeDefined(); + expect(status?.emoji).toBe("🔗"); + expect(status?.message).toBe("PR submitted"); + expect(status?.url).toBe(testUrl); + }); + + it("should persist URL across status updates until explicitly replaced", () => { + 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 with URL + const testUrl = "https://github.com/owner/repo/pull/123"; + aggregator.handleToolCallStart({ + type: "tool-call-start", + workspaceId: "workspace1", + messageId, + toolCallId: "tool1", + toolName: "status_set", + args: { emoji: "🔗", message: "PR submitted", url: testUrl }, + tokens: 10, + timestamp: Date.now(), + }); + + aggregator.handleToolCallEnd({ + type: "tool-call-end", + workspaceId: "workspace1", + messageId, + toolCallId: "tool1", + toolName: "status_set", + result: { success: true, emoji: "🔗", message: "PR submitted", url: testUrl }, + }); + + expect(aggregator.getAgentStatus()?.url).toBe(testUrl); + + // Second status without URL - should keep previous URL + aggregator.handleToolCallStart({ + type: "tool-call-start", + workspaceId: "workspace1", + messageId, + toolCallId: "tool2", + toolName: "status_set", + args: { emoji: "✅", message: "Done" }, + tokens: 10, + timestamp: Date.now(), + }); + + aggregator.handleToolCallEnd({ + type: "tool-call-end", + workspaceId: "workspace1", + messageId, + toolCallId: "tool2", + toolName: "status_set", + result: { success: true, emoji: "✅", message: "Done" }, + }); + + const statusAfterUpdate = aggregator.getAgentStatus(); + expect(statusAfterUpdate?.emoji).toBe("✅"); + expect(statusAfterUpdate?.message).toBe("Done"); + expect(statusAfterUpdate?.url).toBe(testUrl); // URL persists + + // Third status with different URL - should replace + const newUrl = "https://github.com/owner/repo/pull/456"; + aggregator.handleToolCallStart({ + type: "tool-call-start", + workspaceId: "workspace1", + messageId, + toolCallId: "tool3", + toolName: "status_set", + args: { emoji: "🔄", message: "New PR", url: newUrl }, + tokens: 10, + timestamp: Date.now(), + }); + + aggregator.handleToolCallEnd({ + type: "tool-call-end", + workspaceId: "workspace1", + messageId, + toolCallId: "tool3", + toolName: "status_set", + result: { success: true, emoji: "🔄", message: "New PR", url: newUrl }, + }); + + const finalStatus = aggregator.getAgentStatus(); + expect(finalStatus?.emoji).toBe("🔄"); + expect(finalStatus?.message).toBe("New PR"); + expect(finalStatus?.url).toBe(newUrl); // URL replaced + }); + + it("should persist URL even after status is cleared by new stream start", () => { + const aggregator = new StreamingMessageAggregator("2024-01-01T00:00:00.000Z"); + const messageId1 = "msg1"; + + // Start first stream + aggregator.handleStreamStart({ + type: "stream-start", + workspaceId: "workspace1", + messageId: messageId1, + model: "test-model", + historySequence: 1, + }); + + // Set status with URL in first stream + const testUrl = "https://github.com/owner/repo/pull/123"; + aggregator.handleToolCallStart({ + type: "tool-call-start", + workspaceId: "workspace1", + messageId: messageId1, + toolCallId: "tool1", + toolName: "status_set", + args: { emoji: "🔗", message: "PR submitted", url: testUrl }, + tokens: 10, + timestamp: Date.now(), + }); + + aggregator.handleToolCallEnd({ + type: "tool-call-end", + workspaceId: "workspace1", + messageId: messageId1, + toolCallId: "tool1", + toolName: "status_set", + result: { success: true, emoji: "🔗", message: "PR submitted", url: testUrl }, + }); + + expect(aggregator.getAgentStatus()?.url).toBe(testUrl); + + // User sends a new message, which clears the status + const userMessage = { + id: "user1", + role: "user" as const, + parts: [{ type: "text" as const, text: "Continue" }], + metadata: { timestamp: Date.now(), historySequence: 2 }, + }; + aggregator.handleMessage(userMessage); + + expect(aggregator.getAgentStatus()).toBeUndefined(); // Status cleared + + // Start second stream + const messageId2 = "msg2"; + aggregator.handleStreamStart({ + type: "stream-start", + workspaceId: "workspace1", + messageId: messageId2, + model: "test-model", + historySequence: 2, + }); + + // Set new status WITHOUT URL - should use the last URL ever seen + aggregator.handleToolCallStart({ + type: "tool-call-start", + workspaceId: "workspace1", + messageId: messageId2, + toolCallId: "tool2", + toolName: "status_set", + args: { emoji: "✅", message: "Tests passed" }, + tokens: 10, + timestamp: Date.now(), + }); + + aggregator.handleToolCallEnd({ + type: "tool-call-end", + workspaceId: "workspace1", + messageId: messageId2, + toolCallId: "tool2", + toolName: "status_set", + result: { success: true, emoji: "✅", message: "Tests passed" }, + }); + + const finalStatus = aggregator.getAgentStatus(); + expect(finalStatus?.emoji).toBe("✅"); + expect(finalStatus?.message).toBe("Tests passed"); + expect(finalStatus?.url).toBe(testUrl); // URL from previous stream persists! + }); }); diff --git a/src/utils/messages/StreamingMessageAggregator.ts b/src/utils/messages/StreamingMessageAggregator.ts index 9954b8361..68f2e3340 100644 --- a/src/utils/messages/StreamingMessageAggregator.ts +++ b/src/utils/messages/StreamingMessageAggregator.ts @@ -11,7 +11,7 @@ import type { ReasoningDeltaEvent, ReasoningEndEvent, } from "@/types/stream"; -import type { TodoItem } from "@/types/tools"; +import type { TodoItem, StatusSetToolResult } from "@/types/tools"; import type { WorkspaceChatMessage, StreamErrorMessage, DeleteMessage } from "@/types/ipc"; import { isInitStart, isInitOutput, isInitEnd, isCmuxMessage } from "@/types/ipc"; @@ -71,7 +71,11 @@ export class StreamingMessageAggregator { // 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; + private agentStatus: { emoji: string; message: string; url?: string } | undefined = undefined; + + // Last URL set via status_set - persists even when agentStatus is cleared + // This ensures URL stays available across stream boundaries + private lastStatusUrl: string | undefined = undefined; // Workspace init hook state (ephemeral, not persisted to history) private initState: { @@ -143,7 +147,7 @@ export class StreamingMessageAggregator { * Updated whenever status_set is called. * Persists after stream completion (unlike todos). */ - getAgentStatus(): { emoji: string; message: string } | undefined { + getAgentStatus(): { emoji: string; message: string; url?: string } | undefined { return this.agentStatus; } @@ -522,8 +526,19 @@ export class StreamingMessageAggregator { // Update agent status if this was a successful status_set // Use output instead of input to get the truncated message if (toolName === "status_set" && hasSuccessResult(output)) { - const result = output as { success: true; emoji: string; message: string }; - this.agentStatus = { emoji: result.emoji, message: result.message }; + const result = output as Extract; + + // Update lastStatusUrl if a new URL is provided + if (result.url) { + this.lastStatusUrl = result.url; + } + + // Use the provided URL, or fall back to the last URL ever set + this.agentStatus = { + emoji: result.emoji, + message: result.message, + url: result.url ?? this.lastStatusUrl, + }; } } diff --git a/src/utils/tools/toolDefinitions.ts b/src/utils/tools/toolDefinitions.ts index 0d8dcd347..c9fea0587 100644 --- a/src/utils/tools/toolDefinitions.ts +++ b/src/utils/tools/toolDefinitions.ts @@ -192,7 +192,12 @@ export const TOOL_DEFINITIONS = { "- DO NOT set status during initial exploration, file reading, or planning phases\n" + "\n" + "The status is cleared when a new user message comes in. Validate your approach is feasible \n" + - "before setting status - failed tool calls after setting status indicate premature commitment.", + "before setting status - failed tool calls after setting status indicate premature commitment.\n" + + "\n" + + "URL PARAMETER:\n" + + "- Optional 'url' parameter links to external resources (e.g., PR URL: 'https://github.com/owner/repo/pull/123')\n" + + "- Prefer stable URLs that don't change often - saving the same URL twice is a no-op\n" + + "- URL persists until replaced by a new status with a different URL", schema: z .object({ emoji: z.string().describe("A single emoji character representing the current activity"), @@ -201,6 +206,13 @@ export const TOOL_DEFINITIONS = { .describe( `A brief description of the current activity (auto-truncated to ${STATUS_MESSAGE_MAX_LENGTH} chars with ellipsis if needed)` ), + url: z + .string() + .url() + .optional() + .describe( + "Optional URL to external resource with more details (e.g., Pull Request URL). The URL persists and is displayed to the user for easy access." + ), }) .strict(), }, diff --git a/src/utils/ui/statusTooltip.tsx b/src/utils/ui/statusTooltip.tsx index 2f07a1cbc..aadd5da4b 100644 --- a/src/utils/ui/statusTooltip.tsx +++ b/src/utils/ui/statusTooltip.tsx @@ -9,14 +9,23 @@ import { formatRelativeTime } from "@/utils/ui/dateTime"; export function getStatusTooltip(options: { isStreaming: boolean; streamingModel: string | null; - agentStatus?: { emoji: string; message: string }; + agentStatus?: { emoji: string; message: string; url?: 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 agent status is set, show message and URL (if available) if (agentStatus) { + if (agentStatus.url) { + return ( + <> + {agentStatus.message} +
+ {agentStatus.url} + + ); + } return agentStatus.message; }