From 8f1c9adc4c90aa0a859bb1d1fdac4593503f10f2 Mon Sep 17 00:00:00 2001 From: Amrutha Date: Mon, 21 Sep 2026 11:17:15 -0700 Subject: [PATCH] feat: keep the tool trace visible after a run completes --- .../http/RuntaCloudAgentsClient.test.ts | 24 +++++++++++ src/clients/http/RuntaCloudAgentsClient.ts | 19 +++++---- src/domain/types.ts | 2 +- src/state/useCrewController.test.tsx | 42 +++++++++++++++++++ src/state/useCrewController.ts | 21 ++++++---- src/ui/components/Conversation.test.tsx | 25 +++++++++++ src/ui/components/Conversation.tsx | 12 +++++- src/ui/styles.css | 1 + 8 files changed, 129 insertions(+), 17 deletions(-) diff --git a/src/clients/http/RuntaCloudAgentsClient.test.ts b/src/clients/http/RuntaCloudAgentsClient.test.ts index 0e1791b..7643cc0 100644 --- a/src/clients/http/RuntaCloudAgentsClient.test.ts +++ b/src/clients/http/RuntaCloudAgentsClient.test.ts @@ -470,4 +470,28 @@ describe("RuntaCloudAgentsClient", () => { expect(JSON.stringify(result.messages)).not.toContain("Checking."); expect(JSON.stringify(result.messages)).not.toContain("giant aggregate"); }); + + it("replays historical tool activity onto the completed agent message", async () => { + const request = vi.fn(async ({ path }: CloudRequest) => ({ status: 200, body: path.includes("/artifacts?") ? [] : [ + { id: "run-plain", agent_id: "agent-1", status: "finished", prompt: "Just reply", result: "No tools used", error: null, created_at: "2026-08-26T02:00:00Z", updated_at: "2026-08-26T02:00:01Z" }, + { id: "run-tools", agent_id: "agent-1", status: "finished", prompt: "Trace it", result: "All done", error: null, created_at: "2026-08-26T01:00:00Z", updated_at: "2026-08-26T01:00:01Z" }, + ] })); + const subscribe = vi.fn((path: string, listener: (event: CloudStreamEvent) => void) => { + queueMicrotask(() => { + if (path.includes("run-tools")) { + listener({ event: "pi.event", id: "1", data: { type: "tool_execution_start", toolCallId: "tool-1", toolName: "read", args: { path: "README.md" } } }); + listener({ event: "pi.event", id: "2", data: { type: "tool_execution_end", toolCallId: "tool-1", toolName: "read", args: { path: "README.md" }, result: { content: [{ type: "text", text: "README contents" }] }, isError: false } }); + listener({ event: "pi.event", id: "3", data: { type: "message_update", message: { id: "assistant-1" }, assistantMessageEvent: { type: "text_delta", delta: "All done" } } }); + } + listener({ event: "stream.closed" }); + }); + return () => undefined; + }); + window.runtaCrew = { cloud: { request, subscribe } } as unknown as DesktopBridge; + + const { messages } = await new RuntaCloudAgentsClient().getConversation("conversation-agent-1"); + + expect(messages.find((message) => message.id.startsWith("run-tools:agent"))).toMatchObject({ role: "agent", parts: [{ type: "text", text: "All done" }], activities: [expect.objectContaining({ id: "tool:tool-1", kind: "file", title: "read README.md", output: "README contents", status: "completed" })] }); + expect(messages.find((message) => message.id.startsWith("run-plain:agent"))?.activities).toBeUndefined(); + }); }); diff --git a/src/clients/http/RuntaCloudAgentsClient.ts b/src/clients/http/RuntaCloudAgentsClient.ts index e052bdd..e8b32f7 100644 --- a/src/clients/http/RuntaCloudAgentsClient.ts +++ b/src/clients/http/RuntaCloudAgentsClient.ts @@ -246,21 +246,23 @@ export class RuntaCloudAgentsClient implements CloudAgentsClient { let sawAssistant = false; let settled = false; let unsubscribe: () => void = () => undefined; + const toolActivities = new Map(); + const withActivities = (messages: Message[]): Message[] => toolActivities.size === 0 ? messages : messages.map((message) => message.role === "agent" ? { ...message, activities: [...toolActivities.values()] } : message); const finish = (incomplete = false) => { if (settled) return; settled = true; window.clearTimeout(timeout); signal?.removeEventListener("abort", aborted); unsubscribe(); - if (run.status === "finished" && run.result?.trim() && (incomplete || replyError)) { resolve(fallback); return; } - if (replyError) { resolve(runMessages(run, id, replyError)); return; } - if (!sawAssistant) { resolve(fallback); return; } + if (run.status === "finished" && run.result?.trim() && (incomplete || replyError)) { resolve(withActivities(fallback)); return; } + if (replyError) { resolve(withActivities(runMessages(run, id, replyError))); return; } + if (!sawAssistant) { resolve(withActivities(fallback)); return; } const recoveredReply = current?.parts.some((part) => part.type === "text" && part.text.trim()); - resolve([ + resolve(withActivities([ ...fallback.filter((message) => message.role === "user"), ...(current ? [current] : []), ...fallback.filter((message) => message.role === "system" && !(recoveredReply && run.status === "finished" && !run.error?.trim())), - ]); + ])); }; const aborted = () => finish(true); const timeout = window.setTimeout(() => finish(true), 10_000); @@ -281,8 +283,11 @@ export class RuntaCloudAgentsClient implements CloudAgentsClient { if (failure) { replyError = failure; current = undefined; } else if (assistantSucceeded(event)) replyError = undefined; if (event.event === "pi.event" && event.data && typeof event.data === "object") { - const payload = event.data as { type?: string }; - if (payload.type === "tool_execution_start") { current = undefined; return; } + const update = event.data as Record; + const activityId = stringValue(update.toolCallId); + const activity = activityFromPiEvent(update, id, activityId ? toolActivities.get(`tool:${activityId}`) : undefined); + if (activity) toolActivities.set(activity.id, activity); + if (update.type === "tool_execution_start") { current = undefined; return; } } const chunk = assistantChunk(event); if (!chunk) return; diff --git a/src/domain/types.ts b/src/domain/types.ts index d2bff3e..724fec3 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -11,7 +11,7 @@ export interface ActivityPart { type: "activity"; activityId: string } export interface Attachment { id: string; name: string; size: number; mediaType: string; source: "local-selection" | "cloud"; agentId?: string } export interface AttachmentPart { type: "attachment"; attachment: Attachment } export type MessagePart = TextPart | ActivityPart | AttachmentPart; -export interface Message { id: string; conversationId: string; role: MessageRole; parts: MessagePart[]; createdAt: string; streaming?: boolean; interrupted?: boolean } +export interface Message { id: string; conversationId: string; role: MessageRole; parts: MessagePart[]; createdAt: string; streaming?: boolean; interrupted?: boolean; activities?: ActivityEvent[] } export interface Conversation { id: string; agentId: string; title: string; updatedAt: string } export type ActivityStatus = "running" | "completed" | "failed"; export interface ActivityEvent { id: string; conversationId: string; kind: "browser" | "terminal" | "file" | "handoff" | "status"; title: string; detail: string; output?: string; status: ActivityStatus; createdAt: string; updatedAt?: string } diff --git a/src/state/useCrewController.test.tsx b/src/state/useCrewController.test.tsx index b582a28..939009d 100644 --- a/src/state/useCrewController.test.tsx +++ b/src/state/useCrewController.test.tsx @@ -251,4 +251,46 @@ describe("useCrewController", () => { await waitFor(() => expect(result.current.messages[0]?.parts[0]).toEqual({ type: "text", text: "Server reply" })); expect(getConversation).toHaveBeenCalledTimes(2); }); + + it("keeps the tool trace on the completed message and clears the live activity feed", async () => { + const listeners = new Map void>(); + const client: CloudAgentsClient = { + listModelProviders: async () => ({ organizationId: "org-test", providers: [] }), listAgents: async () => [agent("atlas", "Atlas")], + getAgent: async (id) => agent(id, id), createAgent: async () => agent("new", "New"), updateAgent: async (id) => agent(id, id), deleteAgent: async () => undefined, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), + listConversations: async (id) => [{ id: `conversation-${id}`, agentId: id, title: id, updatedAt: new Date(0).toISOString() }], getConversation: async (id) => ({ conversation: { id, agentId: "atlas", title: "Atlas", updatedAt: new Date(0).toISOString() }, messages: [] }), + sendMessage: async () => { throw new Error("unused"); }, subscribeToConversationEvents: (id, listener) => { listeners.set(id, listener); return { unsubscribe: () => undefined }; }, listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test", protocols: ["binary", "vnc-ticket.test"] }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test", protocols: ["binary", "vnc-ticket.test"] }), reconnect: async () => undefined, + }; + const { result } = renderHook(() => useCrewController(client)); + await waitFor(() => expect(listeners.has("conversation-atlas")).toBe(true)); + act(() => listeners.get("conversation-atlas")?.({ type: "message.created", message: { id: "run-1:agent", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "Working on it" }], createdAt: new Date(0).toISOString(), streaming: true } })); + act(() => listeners.get("conversation-atlas")?.({ type: "activity.updated", activity: { id: "tool:read", conversationId: "conversation-atlas", kind: "file", title: "read README.md", detail: "read README.md", output: "README contents", status: "completed", createdAt: new Date(0).toISOString() } })); + expect(result.current.activities).toHaveLength(1); + act(() => listeners.get("conversation-atlas")?.({ type: "message.completed", messageId: "run-1:agent", notify: true })); + expect(result.current.messages.at(-1)).toMatchObject({ id: "run-1:agent", streaming: false, activities: [expect.objectContaining({ id: "tool:read", output: "README contents" })] }); + expect(result.current.activities).toEqual([]); + }); + + it("does not leak the live trace into another agent's conversation", async () => { + const listeners = new Map void>(); + const client: CloudAgentsClient = { + listModelProviders: async () => ({ organizationId: "org-test", providers: [] }), listAgents: async () => [agent("atlas", "Atlas"), agent("scout", "Scout")], + getAgent: async (id) => agent(id, id), createAgent: async () => agent("new", "New"), updateAgent: async (id) => agent(id, id), deleteAgent: async () => undefined, duplicateAgent: async (id) => agent(`${id}-copy`, id), setAgentUnread: async (id) => agent(id, id), + listConversations: async (id) => [{ id: `conversation-${id}`, agentId: id, title: id, updatedAt: new Date(0).toISOString() }], getConversation: async (id) => ({ conversation: { id, agentId: id.endsWith("atlas") ? "atlas" : "scout", title: id, updatedAt: new Date(0).toISOString() }, messages: [] }), + sendMessage: async () => { throw new Error("unused"); }, subscribeToConversationEvents: (id, listener) => { listeners.set(id, listener); return { unsubscribe: () => undefined }; }, listApprovalRequests: async () => [], respondToApproval: async () => { throw new Error("unused"); }, getComputer: async (id) => ({ id, agentId: id, runtimeName: id, status: "online", capabilities: ["open"] }), openComputer: async () => ({ mode: "remote", url: "https://example.test", protocols: ["binary", "vnc-ticket.test"] }), takeOverComputer: async () => ({ mode: "remote", url: "https://example.test", protocols: ["binary", "vnc-ticket.test"] }), reconnect: async () => undefined, + }; + const { result } = renderHook(() => useCrewController(client)); + await waitFor(() => expect(listeners.has("conversation-atlas")).toBe(true)); + act(() => listeners.get("conversation-atlas")?.({ type: "message.created", message: { id: "run-1:agent", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "" }], createdAt: new Date(0).toISOString(), streaming: true } })); + act(() => listeners.get("conversation-atlas")?.({ type: "activity.updated", activity: { id: "tool:live", conversationId: "conversation-atlas", kind: "terminal", title: "npm test", detail: "npm test", status: "running", createdAt: new Date(0).toISOString() } })); + act(() => listeners.get("conversation-atlas")?.({ type: "message.completed", messageId: "run-1:agent", notify: true })); + act(() => result.current.setSelectedAgentId("scout")); + await waitFor(() => expect(result.current.selectedAgentId).toBe("scout")); + expect(result.current.activities).toEqual([]); + await waitFor(() => expect(result.current.conversationLoading).toBe(false)); + expect(result.current.messages.every((message) => !message.activities)).toBe(true); + act(() => result.current.setSelectedAgentId("atlas")); + await waitFor(() => expect(result.current.messages.at(-1)?.id).toBe("run-1:agent")); + expect(result.current.messages.at(-1)?.activities).toEqual([expect.objectContaining({ id: "tool:live" })]); + expect(result.current.activities).toEqual([]); + }); }); diff --git a/src/state/useCrewController.ts b/src/state/useCrewController.ts index 4a60efd..c745401 100644 --- a/src/state/useCrewController.ts +++ b/src/state/useCrewController.ts @@ -19,7 +19,7 @@ function mergeVisualMessage(canonical: Message, visual?: Message): Message { if (!visual) return canonical; const canonicalAttachmentIds = new Set(canonical.parts.filter((part) => part.type === "attachment").map((part) => part.attachment.id)); const retainedAttachments = visual.parts.filter((part) => part.type === "attachment" && !canonicalAttachmentIds.has(part.attachment.id)); - return { ...canonical, id: visual.id, createdAt: visual.createdAt, parts: [...canonical.parts, ...retainedAttachments] }; + return { ...canonical, id: visual.id, createdAt: visual.createdAt, parts: [...canonical.parts, ...retainedAttachments], activities: canonical.activities ?? visual.activities }; } interface AgentSnapshot { messages: Message[]; approvals: ApprovalRequest[]; computer?: CloudComputer; cachedAt: number } @@ -38,7 +38,7 @@ export function useCrewController(client: CloudAgentsClient, enabled = true) { const [loading, setLoading] = useState(enabled); const [error, setError] = useState(); const [loadedAgentIds, setLoadedAgentIds] = useState>(() => new Set()); const [liveAgentId, setLiveAgentId] = useState(""); const [revalidateVersion, setRevalidateVersion] = useState(0); - const snapshots = useRef(new Map()); const selectedAgentIdRef = useRef(selectedAgentId); const deletingAgentIds = useRef(new Set()); const sendingAgentRequests = useRef(new Map()); const visualMessageIds = useRef(new Map()); + const snapshots = useRef(new Map()); const selectedAgentIdRef = useRef(selectedAgentId); const deletingAgentIds = useRef(new Set()); const sendingAgentRequests = useRef(new Map()); const visualMessageIds = useRef(new Map()); const liveActivities = useRef([]); const selectedAgent = useMemo(() => agents.find((agent) => agent.id === selectedAgentId), [agents, selectedAgentId]); const conversationId = selectedAgentId ? `conversation-${selectedAgentId}` : ""; const conversationLoading = Boolean(selectedAgentId && !loadedAgentIds.has(selectedAgentId)); @@ -68,7 +68,7 @@ export function useCrewController(client: CloudAgentsClient, enabled = true) { return catalog.providers; }, [client]); useEffect(() => { - if (!enabled) { setAgents([]); setModelProviders([]); setOrganizationId(""); setSelectedAgentId(""); setMessages([]); setActivities([]); setApprovals([]); setComputer(undefined); setConnection("disconnected"); setLoading(false); setError(undefined); return; } + if (!enabled) { setAgents([]); setModelProviders([]); setOrganizationId(""); setSelectedAgentId(""); setMessages([]); setActivities([]); liveActivities.current = []; setApprovals([]); setComputer(undefined); setConnection("disconnected"); setLoading(false); setError(undefined); return; } let alive = true; setLoading(true); void Promise.all([refreshAgents(), refreshModelProviders()]).then(() => { if (alive) { setConnection("connected"); setLoading(false); } }).catch((reason: unknown) => { if (alive) { setConnection("error"); setError(reason instanceof Error ? reason.message : "Could not load agents"); setLoading(false); } }); return () => { alive = false; }; @@ -87,7 +87,7 @@ export function useCrewController(client: CloudAgentsClient, enabled = true) { useEffect(() => { if (!enabled) return; const cached = snapshots.current.get(selectedAgentId); - setActivities([]); + setActivities([]); liveActivities.current = []; if (cached) { setMessages(cached.messages); setApprovals(cached.approvals); setComputer(cached.computer); } else { setMessages([]); setApprovals([]); setComputer(undefined); } setLiveAgentId(""); @@ -198,8 +198,10 @@ export function useCrewController(client: CloudAgentsClient, enabled = true) { return [{ ...message, parts: message.parts.map((part) => part.type === "text" ? { ...part, text: "" } : part), streaming: true }]; })); } else { + const settledActivities = liveActivities.current; + if (settledActivities.length) { liveActivities.current = []; setActivities([]); } updateMessages((current) => { - const completed = current.filter((message) => message.id === visualId || message.role !== "agent" || !message.streaming).map((message) => message.id === visualId ? { ...message, streaming: false } : message); + const completed = current.filter((message) => message.id === visualId || message.role !== "agent" || !message.streaming).map((message) => message.id === visualId ? { ...message, streaming: false, ...(message.role === "agent" && message.streaming && settledActivities.length ? { activities: settledActivities } : {}) } : message); const completedPreview = latestCompletedAgentPreview(completed); if (completedPreview) setAgents((agents) => agents.map((agent) => agent.id === selectedAgentId ? { ...agent, lastMessagePreview: completedPreview } : agent)); return completed; @@ -208,11 +210,13 @@ export function useCrewController(client: CloudAgentsClient, enabled = true) { } } if (event.type === "message.updated") { - updateMessages((current) => { let visualId = visualMessageIds.current.get(event.message.id); if (!visualId && event.message.role === "agent") { const claimedVisualIds = new Set(visualMessageIds.current.values()); const optimistic = current.find((message) => message.id.startsWith(OPTIMISTIC_AGENT_PREFIX) && !claimedVisualIds.has(message.id)); if (optimistic) { visualId = optimistic.id; visualMessageIds.current.set(event.message.id, visualId); } } let nextMessage = visualId ? { ...event.message, id: visualId } : event.message; const visualMessage = current.find((message) => message.id === nextMessage.id); if (visualMessage) nextMessage = mergeVisualMessage(nextMessage, visualMessage); const settled = event.message.role === "agent" && !event.message.streaming ? current.filter((message) => message.id === nextMessage.id || message.role !== "agent" || !message.streaming) : current; return visualMessage ? settled.map((message) => message.id === nextMessage.id ? nextMessage : message) : [...settled, nextMessage]; }); + const settledActivities = event.message.role === "agent" && !event.message.streaming ? liveActivities.current : []; + if (settledActivities.length) { liveActivities.current = []; setActivities([]); } + updateMessages((current) => { let visualId = visualMessageIds.current.get(event.message.id); if (!visualId && event.message.role === "agent") { const claimedVisualIds = new Set(visualMessageIds.current.values()); const optimistic = current.find((message) => message.id.startsWith(OPTIMISTIC_AGENT_PREFIX) && !claimedVisualIds.has(message.id)); if (optimistic) { visualId = optimistic.id; visualMessageIds.current.set(event.message.id, visualId); } } let nextMessage = visualId ? { ...event.message, id: visualId } : event.message; const visualMessage = current.find((message) => message.id === nextMessage.id); if (visualMessage) nextMessage = mergeVisualMessage(nextMessage, visualMessage); if (settledActivities.length && nextMessage.role === "agent" && !nextMessage.activities) nextMessage = { ...nextMessage, activities: settledActivities }; const settled = event.message.role === "agent" && !event.message.streaming ? current.filter((message) => message.id === nextMessage.id || message.role !== "agent" || !message.streaming) : current; return visualMessage ? settled.map((message) => message.id === nextMessage.id ? nextMessage : message) : [...settled, nextMessage]; }); if (event.message.role === "agent" && !event.message.streaming) setAgents((current) => current.map((agent) => agent.id === selectedAgentId ? { ...agent, lastMessagePreview: agentMessagePreview(event.message) || undefined } : agent)); } if (event.type === "approval.updated") { setApprovals((current) => { const next = current.map((approval) => approval.id === event.approval.id ? event.approval : approval); const snapshot = snapshots.current.get(selectedAgentId); snapshots.current.set(selectedAgentId, { messages: snapshot?.messages ?? [], approvals: next, computer: snapshot?.computer, cachedAt: Date.now() }); return next; }); if (event.approval.status === "pending") void window.runtaCrew?.notifications.show({ title: `${selectedAgent?.name ?? "Agent"} needs approval`, body: event.approval.title }); } - if (event.type === "activity.updated") setActivities((current) => current.some((activity) => activity.id === event.activity.id) ? current.map((activity) => activity.id === event.activity.id ? event.activity : activity) : [...current, event.activity]); + if (event.type === "activity.updated") { const currentActivities = liveActivities.current; liveActivities.current = currentActivities.some((activity) => activity.id === event.activity.id) ? currentActivities.map((activity) => activity.id === event.activity.id ? event.activity : activity) : [...currentActivities, event.activity]; setActivities(liveActivities.current); } if (event.type === "connection.changed") setConnection(event.state); }); return () => { active = false; subscription.unsubscribe(); }; }, [client, conversationId, enabled, liveAgentId, selectedAgent?.name, selectedAgentId]); @@ -254,8 +258,9 @@ export function useCrewController(client: CloudAgentsClient, enabled = true) { const optimisticAgent: Message = { id: optimisticAgentId, conversationId: targetConversationId, role: "agent", parts: [{ type: "text", text: "" }], createdAt, streaming: true }; const snapshot = snapshots.current.get(targetAgentId) ?? { messages: [], approvals: [], cachedAt: Date.now() }; const existingWorking = [...snapshot.messages].reverse().find((message) => message.role === "agent" && message.streaming); - const interruptedMessages = existingWorking ? snapshot.messages.map((message) => message.id === existingWorking.id ? { ...message, streaming: false, interrupted: true } : message) : snapshot.messages; + const interruptedMessages = existingWorking ? snapshot.messages.map((message) => message.id === existingWorking.id ? { ...message, streaming: false, interrupted: true, ...(liveActivities.current.length ? { activities: liveActivities.current } : {}) } : message) : snapshot.messages; const optimisticMessages = [...interruptedMessages, optimisticUser, optimisticAgent]; + liveActivities.current = []; if (selectedAgentIdRef.current === targetAgentId) setActivities([]); snapshots.current.set(targetAgentId, { ...snapshot, messages: optimisticMessages, cachedAt: Date.now() }); if (selectedAgentIdRef.current === targetAgentId) { setMessages(optimisticMessages); setLiveAgentId(targetAgentId); } diff --git a/src/ui/components/Conversation.test.tsx b/src/ui/components/Conversation.test.tsx index 851b6f5..56b321e 100644 --- a/src/ui/components/Conversation.test.tsx +++ b/src/ui/components/Conversation.test.tsx @@ -223,6 +223,31 @@ describe("Conversation states", () => { expect(summary.closest("details")).toHaveAttribute("open"); }); + it("keeps the tool trace visible as a collapsed summary after the run completes", async () => { + const message: Message = { id: "run-done:agent", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "All wrapped up" }], createdAt: new Date().toISOString(), streaming: false, activities: [ + { id: "tool:install", conversationId: "conversation-atlas", kind: "terminal", title: "apt-get install chromium", detail: "apt-get install chromium", output: "Chromium installed", status: "completed", createdAt: new Date(0).toISOString() }, + { id: "tool:read", conversationId: "conversation-atlas", kind: "file", title: "read README.md", detail: "read README.md", status: "completed", createdAt: new Date(0).toISOString() }, + ] }; + render(); + + expect(await screen.findByText("All wrapped up")).toBeInTheDocument(); + const summary = screen.getByText("Worked · 2 steps"); + expect(summary.closest("details")).not.toHaveAttribute("open"); + expect(screen.queryByText(/Working for/)).not.toBeInTheDocument(); + fireEvent.click(summary); + expect(summary.closest("details")).toHaveAttribute("open"); + expect(screen.getByText("apt-get install chromium")).toBeInTheDocument(); + expect(screen.getByText("Chromium installed")).toBeInTheDocument(); + }); + + it("renders no trace summary for a completed run without tool activity", async () => { + const message: Message = { id: "run-quiet:agent", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "text", text: "Quick answer" }], createdAt: new Date().toISOString(), streaming: false }; + render(); + + expect(await screen.findByText("Quick answer")).toBeInTheDocument(); + expect(screen.queryByText(/Worked ·/)).not.toBeInTheDocument(); + }); + it("opens a safe text attachment preview", async () => { window.runtaCrew = { attachments: { choose: async () => [], read: async () => ({ name: "report.txt", mediaType: "text/plain", base64: btoa("hello preview") }) } } as unknown as typeof window.runtaCrew; const message: Message = { id: "artifact-message", conversationId: "conversation-atlas", role: "agent", parts: [{ type: "attachment", attachment: { id: "file-1", name: "report.txt", size: 13, mediaType: "text/plain", source: "local-selection" } }], createdAt: new Date().toISOString() }; diff --git a/src/ui/components/Conversation.tsx b/src/ui/components/Conversation.tsx index eb83bda..6523293 100644 --- a/src/ui/components/Conversation.tsx +++ b/src/ui/components/Conversation.tsx @@ -23,10 +23,18 @@ function openExternalLink(event: MouseEvent) { void window.runtaCrew?.openExternal(url.toString()); } catch { /* Ignore malformed Agent output instead of navigating the webview. */ } } +function ActivityToolList({ activities }: { activities: ActivityEvent[] }) { + return
{activities.map((activity) => { const Icon = activityIcon[activity.kind]; return
{activity.title}
{activity.output ?? (activity.status === "running" ? "Waiting for result…" : "No output")}
; })}
; +} function WorkingActivity({ agent, label, activities, startedAt }: { agent?: Pick; label: string; activities: ActivityEvent[]; startedAt: string }) { const [now, setNow] = useState(0); const [open, setOpen] = useState(false); useEffect(() => { setNow(Date.now()); const timer = window.setInterval(() => setNow(Date.now()), 1_000); return () => window.clearInterval(timer); }, []); - return
{ event.preventDefault(); setOpen((value) => !value); }}>Working for {elapsedLabel(now - Date.parse(startedAt))}{activities.length > 0 &&
{activities.map((activity) => { const Icon = activityIcon[activity.kind]; return
{activity.title}
{activity.output ?? (activity.status === "running" ? "Waiting for result…" : "No output")}
; })}
}
; + return
{ event.preventDefault(); setOpen((value) => !value); }}>Working for {elapsedLabel(now - Date.parse(startedAt))}{activities.length > 0 && }
; +} +function CompletedActivity({ agent, activities }: { agent?: Pick; activities: ActivityEvent[] }) { + const [open, setOpen] = useState(false); + const label = `${activities.length} ${activities.length === 1 ? "step" : "steps"}`; + return
{ event.preventDefault(); setOpen((value) => !value); }}>Worked · {label}
; } export function MessageView({ message, agent, activities, entering = false }: { message: Message; agent?: Pick; activities: ActivityEvent[]; entering?: boolean }) { const text = textOf(message); @@ -36,6 +44,7 @@ export function MessageView({ message, agent, activities, entering = false }: { const latestActivity = conversationActivities.at(-1); const activeActivity = [...conversationActivities].reverse().find((activity) => activity.status === "running") ?? latestActivity; const agentWorking = message.role === "agent" && Boolean(message.streaming); + const settledActivities = message.role === "agent" && !message.streaming ? message.activities ?? [] : []; const workingLabel = text.trim() || activeActivity?.title || "Working"; const attachments = message.parts.flatMap((part) => part.type === "attachment" ? [part.attachment] : []); useLayoutEffect(() => { @@ -49,6 +58,7 @@ export function MessageView({ message, agent, activities, entering = false }: { return
{message.interrupted &&
Interrupted
} {message.role === "user" && attachments.length > 0 &&
} + {settledActivities.length > 0 && } {!agentWorking && (text || message.streaming) &&
{message.role === "agent" ? {text}}>{text} : text}
} {agentWorking && } {message.role !== "user" && attachments.length > 0 &&
} diff --git a/src/ui/styles.css b/src/ui/styles.css index 0a8b7d1..a42922b 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -112,6 +112,7 @@ button:not(:disabled):hover,a[href]:hover,summary:hover,[role="button"]:hover{ba @media (max-width:1030px) and (min-width:761px){.app-shell.details-open{grid-template-columns:238px minmax(0,1fr) min(var(--detail-panel-width,340px),340px)}.detail-panel{position:relative;width:min(var(--detail-panel-width,340px),340px)}} .agent-working-details>summary:hover{background:transparent!important} .agent-tool-detail>summary:hover{background:transparent!important;color:var(--muted)!important} +.agent-worked-details>summary .agent-working-progress{background:none;animation:none;color:var(--muted)} @keyframes message-enter-unified{from{opacity:0;transform:translate3d(0,8px,0)}to{opacity:1;transform:translate3d(0,0,0)}} .message-entering.user,.message-entering.agent,.message-entering.system{animation:message-enter-unified .28s cubic-bezier(.22,1,.36,1) both}.message-entering.user .message-body{animation:none;transform:none} @media (prefers-reduced-motion:reduce){.message-entering.user,.message-entering.agent,.message-entering.system{animation:none;opacity:1;transform:none}}