diff --git a/ui/src/components/ActiveAgentsPanel.tsx b/ui/src/components/ActiveAgentsPanel.tsx index c0883192..26c969e3 100644 --- a/ui/src/components/ActiveAgentsPanel.tsx +++ b/ui/src/components/ActiveAgentsPanel.tsx @@ -3,13 +3,13 @@ import { Link } from "@/lib/router"; import { useQuery } from "@tanstack/react-query"; import type { Issue } from "@paperclipai/shared"; import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats"; -import { issuesApi } from "../api/issues"; import type { TranscriptEntry } from "../adapters"; +import { issuesApi } from "../api/issues"; import { queryKeys } from "../lib/queryKeys"; import { cn, relativeTime } from "../lib/utils"; import { ExternalLink } from "lucide-react"; import { Identity } from "./Identity"; -import { RunTranscriptView } from "./transcript/RunTranscriptView"; +import { RunChatSurface } from "./RunChatSurface"; import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts"; const MIN_DASHBOARD_RUNS = 4; @@ -63,6 +63,7 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) { {runs.map((run) => (
-
diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 63b7bc67..f292646a 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -139,6 +139,39 @@ describe("IssueChatThread", () => { }); }); + it("supports the embedded read-only variant without the jump control", () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + showComposer={false} + showJumpToLatest={false} + variant="embedded" + emptyMessage="No run output captured." + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + expect(container.textContent).toContain("No run output captured."); + expect(container.textContent).not.toContain("Jump to latest"); + + const viewport = container.querySelector('[data-testid="thread-viewport"]') as HTMLDivElement | null; + expect(viewport?.className).toContain("space-y-3"); + + act(() => { + root.unmount(); + }); + }); + it("stores and restores the composer draft per issue key", () => { vi.useFakeTimers(); const root = createRoot(container); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index e2d87f4c..3650aa81 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -175,9 +175,13 @@ interface IssueChatThreadProps { mentions?: MentionOption[]; composerDisabledReason?: string | null; showComposer?: boolean; + showJumpToLatest?: boolean; + emptyMessage?: string; + variant?: "full" | "embedded"; enableLiveTranscriptPolling?: boolean; transcriptsByRunId?: ReadonlyMap; hasOutputForRun?: (runId: string) => boolean; + includeSucceededRunsWithoutOutput?: boolean; onInterruptQueued?: (runId: string) => Promise; interruptingQueuedRunId?: string | null; } @@ -1591,9 +1595,13 @@ export function IssueChatThread({ mentions = [], composerDisabledReason = null, showComposer = true, + showJumpToLatest, + emptyMessage, + variant = "full", enableLiveTranscriptPolling = true, transcriptsByRunId, hasOutputForRun: hasOutputForRunOverride, + includeSucceededRunsWithoutOutput = false, onInterruptQueued, interruptingQueuedRunId = null, }: IssueChatThreadProps) { @@ -1659,6 +1667,7 @@ export function IssueChatThread({ activeRun, transcriptsByRunId: resolvedTranscriptByRun, hasOutputForRun: resolvedHasOutputForRun, + includeSucceededRunsWithoutOutput, companyId, projectId, agentMap, @@ -1672,6 +1681,7 @@ export function IssueChatThread({ activeRun, resolvedTranscriptByRun, resolvedHasOutputForRun, + includeSucceededRunsWithoutOutput, companyId, projectId, agentMap, @@ -1743,25 +1753,38 @@ export function IssueChatThread({ [], ); + const resolvedShowJumpToLatest = showJumpToLatest ?? variant === "full"; + const resolvedEmptyMessage = emptyMessage + ?? (variant === "embedded" + ? "No run output yet." + : "This issue conversation is empty. Start with a message below."); + return ( -
-
- -
+
+ {resolvedShowJumpToLatest ? ( +
+ +
+ ) : null} - + -
- This issue conversation is empty. Start with a message below. +
+ {resolvedEmptyMessage}
diff --git a/ui/src/components/LiveRunWidget.tsx b/ui/src/components/LiveRunWidget.tsx index 2c0f702e..e9c2fe7b 100644 --- a/ui/src/components/LiveRunWidget.tsx +++ b/ui/src/components/LiveRunWidget.tsx @@ -6,8 +6,8 @@ import { queryKeys } from "../lib/queryKeys"; import { formatDateTime } from "../lib/utils"; import { ExternalLink, Square } from "lucide-react"; import { Identity } from "./Identity"; +import { RunChatSurface } from "./RunChatSurface"; import { StatusBadge } from "./StatusBadge"; -import { RunTranscriptView } from "./transcript/RunTranscriptView"; import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts"; interface LiveRunWidgetProps { @@ -93,7 +93,7 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) { Live Runs
- Streamed with the same transcript UI used on the full run detail page. + Uses the shared chat-style run surface from issue activity.
@@ -142,13 +142,11 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
-
diff --git a/ui/src/components/RunChatSurface.tsx b/ui/src/components/RunChatSurface.tsx new file mode 100644 index 00000000..71f6b2c1 --- /dev/null +++ b/ui/src/components/RunChatSurface.tsx @@ -0,0 +1,64 @@ +import { useMemo } from "react"; +import type { TranscriptEntry } from "../adapters"; +import type { LiveRunForIssue } from "../api/heartbeats"; +import { IssueChatThread } from "./IssueChatThread"; +import type { IssueChatLinkedRun } from "../lib/issue-chat-messages"; + +function isRunActive(run: LiveRunForIssue) { + return run.status === "queued" || run.status === "running"; +} + +interface RunChatSurfaceProps { + run: LiveRunForIssue; + transcript: TranscriptEntry[]; + hasOutput: boolean; + companyId?: string | null; +} + +export function RunChatSurface({ + run, + transcript, + hasOutput, + companyId, +}: RunChatSurfaceProps) { + const active = isRunActive(run); + const liveRuns = active ? [run] : []; + const linkedRuns = useMemo( + () => + active + ? [] + : [{ + runId: run.id, + status: run.status, + agentId: run.agentId, + agentName: run.agentName, + createdAt: run.createdAt, + startedAt: run.startedAt, + finishedAt: run.finishedAt, + }], + [active, run], + ); + const transcriptsByRunId = useMemo( + () => new Map([[run.id, transcript as readonly TranscriptEntry[]]]), + [run.id, transcript], + ); + + return ( + {}} + showComposer={false} + showJumpToLatest={false} + variant="embedded" + emptyMessage={active ? "Waiting for run output..." : "No run output captured."} + enableLiveTranscriptPolling={false} + transcriptsByRunId={transcriptsByRunId} + hasOutputForRun={(runId) => runId === run.id && hasOutput} + includeSucceededRunsWithoutOutput + /> + ); +} diff --git a/ui/src/lib/issue-chat-messages.test.ts b/ui/src/lib/issue-chat-messages.test.ts index cc08d33b..f3ae87d7 100644 --- a/ui/src/lib/issue-chat-messages.test.ts +++ b/ui/src/lib/issue-chat-messages.test.ts @@ -332,4 +332,39 @@ describe("buildIssueChatMessages", () => { { type: "text", text: "Updated the thread renderer." }, ]); }); + + it("can keep succeeded runs without transcript output for embedded run feeds", () => { + const messages = buildIssueChatMessages({ + comments: [], + timelineEvents: [], + linkedRuns: [ + { + runId: "run-history-2", + status: "succeeded", + agentId: "agent-1", + agentName: "CodexCoder", + createdAt: new Date("2026-04-06T12:01:00.000Z"), + startedAt: new Date("2026-04-06T12:01:00.000Z"), + finishedAt: new Date("2026-04-06T12:03:00.000Z"), + }, + ], + liveRuns: [], + includeSucceededRunsWithoutOutput: true, + currentUserId: "user-1", + }); + + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + id: "run:run-history-2", + role: "system", + metadata: { + custom: { + kind: "run", + runId: "run-history-2", + runAgentName: "CodexCoder", + runStatus: "succeeded", + }, + }, + }); + }); }); diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts index 280ef7ff..09c615f6 100644 --- a/ui/src/lib/issue-chat-messages.ts +++ b/ui/src/lib/issue-chat-messages.ts @@ -32,6 +32,7 @@ export interface IssueChatLinkedRun { runId: string; status: string; agentId: string; + agentName?: string; createdAt: Date | string; startedAt: Date | string | null; finishedAt?: Date | string | null; @@ -372,7 +373,7 @@ function runDurationLabel(run: { } function createHistoricalRunMessage(run: IssueChatLinkedRun, agentMap?: Map) { - const agentName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8); + const agentName = run.agentName ?? agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8); const message: ThreadSystemMessage = { id: `run:${run.runId}`, role: "system", @@ -399,7 +400,7 @@ function createHistoricalTranscriptMessage(args: { agentMap?: Map; }) { const { run, transcript, hasOutput, agentMap } = args; - const agentName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8); + const agentName = run.agentName ?? agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8); const { parts, notices, segments } = buildAssistantPartsFromTranscript(transcript); const waitingText = hasOutput ? "" : "Run finished"; const content = parts.length > 0 @@ -639,6 +640,7 @@ export function buildIssueChatMessages(args: { activeRun?: ActiveRunForIssue | null; transcriptsByRunId?: ReadonlyMap; hasOutputForRun?: (runId: string) => boolean; + includeSucceededRunsWithoutOutput?: boolean; issueId?: string; companyId?: string | null; projectId?: string | null; @@ -653,6 +655,7 @@ export function buildIssueChatMessages(args: { activeRun, transcriptsByRunId, hasOutputForRun, + includeSucceededRunsWithoutOutput = false, issueId, companyId, projectId, @@ -694,7 +697,7 @@ export function buildIssueChatMessages(args: { }); continue; } - if (run.status === "succeeded") continue; + if (run.status === "succeeded" && !includeSucceededRunsWithoutOutput) continue; orderedMessages.push({ createdAtMs: toTimestamp(runTimestamp(run)), order: 2,