diff --git a/dashboard/src/v2/components/chat/InvocationMessageBubble.tsx b/dashboard/src/v2/components/chat/InvocationMessageBubble.tsx index 76c1c36629..f8d157b127 100644 --- a/dashboard/src/v2/components/chat/InvocationMessageBubble.tsx +++ b/dashboard/src/v2/components/chat/InvocationMessageBubble.tsx @@ -14,8 +14,10 @@ import { ExternalReferenceWidget } from "./widgets/ExternalReferenceWidget.js"; import { ToolCallWidget } from "./widgets/ToolCallWidget.js"; import { ReasoningWidget } from "./widgets/ReasoningWidget.js"; import { SelfReflectionWidget } from "./widgets/SelfReflectionWidget.js"; +import { LiveEntityStatusWidget } from "./widgets/LiveEntityStatusWidget.js"; import { ChatAvatar, type AvatarRole } from "./ChatAvatar.js"; import type { ChatWidgetLiveData, ParsedTurnTokens } from "../../lib/chat-widget-view-models.js"; +import type { ChatLiveEntityWidget } from "../../lib/chat-live-entities.js"; import type { AgentAvatarConfig } from "../../types.js"; const asString = (value: unknown): string | null => (typeof value === "string" ? value : null); @@ -42,6 +44,7 @@ export interface InvocationMessageBubbleProps { agentAvatarConfig?: AgentAvatarConfig | null; agentName?: string | null; widgetLiveData?: ChatWidgetLiveData; + liveEntities?: readonly ChatLiveEntityWidget[]; } export const InvocationMessageBubble: FunctionComponent = ({ @@ -49,6 +52,7 @@ export const InvocationMessageBubble: FunctionComponent { const fromUser = message.role === "user"; const fromTool = message.role === "tool"; @@ -120,6 +124,12 @@ export const InvocationMessageBubble: FunctionComponent 0; + const liveEntitySlotClass = hasPrimaryWidget + ? "mt-3" + : widgetData.suppressBodyMarkdown ? "mt-0" : "mt-4 border-t border-white/5 pt-4"; return (
@@ -201,6 +211,11 @@ export const InvocationMessageBubble: FunctionComponent
)} + {hasLiveEntities && ( +
+ +
+ )} diff --git a/dashboard/src/v2/components/chat/__tests__/InvocationMessageBubble.live-entities.test.tsx b/dashboard/src/v2/components/chat/__tests__/InvocationMessageBubble.live-entities.test.tsx new file mode 100644 index 0000000000..5f5d018fd8 --- /dev/null +++ b/dashboard/src/v2/components/chat/__tests__/InvocationMessageBubble.live-entities.test.tsx @@ -0,0 +1,157 @@ +/** + * @vitest-environment jsdom + */ +/// +import { cleanup, render, screen, within } from "@testing-library/preact"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { afterEach, describe, expect, it } from "vitest"; +import { InvocationMessageBubble } from "../InvocationMessageBubble.js"; +import type { ChatLiveSprintWidget, ChatLiveTaskWidget } from "../../../lib/chat-live-entities.js"; +import type { ExecutionInvocationMessageRecord } from "../../../types.js"; + +expect.extend(matchers); + +const createInvocationMessage = ( + overrides: Partial = {}, +): ExecutionInvocationMessageRecord => ({ + id: "msg-inv-live", + invocationId: "inv-1", + role: "assistant", + contentMarkdown: "Working against SPR-7 and TASK-42.", + toolCallsJson: null, + createdAt: "2026-03-10T12:00:00.000Z", + metadata: null, + ...overrides, +}); + +const createSprintEntity = (overrides: Partial = {}): ChatLiveSprintWidget => ({ + kind: "sprint", + recordId: "sprint-7", + displayKey: "SPR-7", + name: "Build payment flow", + status: "running", + href: "/sprints?sprintId=sprint-7&sprintKey=SPR-7", + sprintNumber: 7, + tasksCount: 4, + completedTasks: 2, + completion: 50, + ...overrides, +}); + +const createTaskEntity = (overrides: Partial = {}): ChatLiveTaskWidget => ({ + kind: "task", + recordId: "task-42", + displayKey: "TASK-42", + name: "Wire live entity status cards", + status: "completed", + href: "/tasks?sprintId=sprint-7&taskId=task-42", + sprintId: "sprint-7", + sprintKey: "SPR-7", + sprintName: "Build payment flow", + priority: "critical", + executorType: "docker_cli", + isMerged: false, + mergeIndicator: null, + ...overrides, +}); + +describe("InvocationMessageBubble live entities", () => { + afterEach(() => { + cleanup(); + }); + + it("renders sprint and task live entity widgets in a normal invocation bubble", () => { + const message = createInvocationMessage({ + metadata: { + provider: "codex", + model: "gpt-5", + status: "queued", + response: "accepted", + }, + }); + + render( + , + ); + + expect(screen.getByText("Working against SPR-7 and TASK-42.")).toBeInTheDocument(); + expect(screen.getByText("codex")).toBeInTheDocument(); + expect(screen.getByText("gpt-5")).toBeInTheDocument(); + expect(screen.getByText("processed")).toBeInTheDocument(); + expect(screen.getAllByText(/From Assistant at .*Status: processed\./).length).toBeGreaterThan(0); + + const liveRegion = screen.getByLabelText("Live sprint and task status"); + expect(screen.getByText("Live sprint context")).toBeInTheDocument(); + expect(within(liveRegion).getByText("Build payment flow")).toBeInTheDocument(); + expect(screen.getByRole("link", { + name: "Open sprint SPR-7: Build payment flow. Live status: Running.", + })).toHaveAttribute("href", "/sprints?sprintKey=SPR-7"); + expect(screen.getByRole("link", { + name: "Open task TASK-42: Wire live entity status cards. Live status: Completed.", + })).toHaveAttribute("href", "/tasks?sprintId=sprint-7&taskId=task-42"); + }); + + it("renders planning and live entity widgets together with stable spacing", () => { + const message = createInvocationMessage({ + contentMarkdown: "Planning the sprint.", + metadata: { + routeKind: "virtual", + status: "queued", + }, + }); + + const { container } = render( + , + ); + + expect(screen.getByText("Execution Plan")).toBeInTheDocument(); + expect(screen.getByText("Preparing to plan...")).toBeInTheDocument(); + expect(screen.getByText("Live sprint context")).toBeInTheDocument(); + expect(screen.getByRole("link", { + name: "Open sprint SPR-7: Build payment flow. Live status: Running.", + })).toBeInTheDocument(); + + const liveContext = screen.getByText("Live sprint context"); + const textContent = container.textContent ?? ""; + expect(liveContext.closest(".mt-3")).toBeInTheDocument(); + expect(textContent.indexOf("Execution Plan")).toBeLessThan(textContent.indexOf("Live sprint context")); + }); + + it("keeps tool-call turns rendered as ToolCallWidget without live entity chrome", () => { + const message = createInvocationMessage({ + id: "msg-tool-call", + role: "assistant", + contentMarkdown: "", + toolCallsJson: { + arguments: JSON.stringify({ path: "src/app.ts" }), + }, + metadata: { + kind: "tool_call", + toolName: "read_file", + toolStatus: "completed", + toolCallId: "call-1", + }, + }); + + render( + , + ); + + expect(screen.getByText("read_file")).toBeInTheDocument(); + expect(screen.getByText("src/app.ts")).toBeInTheDocument(); + expect(screen.queryByText("Live sprint context")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Live sprint and task status")).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { + name: /Open sprint SPR-7/, + })).not.toBeInTheDocument(); + }); +});