Reuse chat-style run feed on dashboard

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta 2026-04-07 18:17:29 -05:00
parent b5e177df7e
commit 950ea065ae
7 changed files with 191 additions and 35 deletions

View file

@ -3,13 +3,13 @@ import { Link } from "@/lib/router";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { Issue } from "@paperclipai/shared"; import type { Issue } from "@paperclipai/shared";
import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats"; import { heartbeatsApi, type LiveRunForIssue } from "../api/heartbeats";
import { issuesApi } from "../api/issues";
import type { TranscriptEntry } from "../adapters"; import type { TranscriptEntry } from "../adapters";
import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { cn, relativeTime } from "../lib/utils"; import { cn, relativeTime } from "../lib/utils";
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import { Identity } from "./Identity"; import { Identity } from "./Identity";
import { RunTranscriptView } from "./transcript/RunTranscriptView"; import { RunChatSurface } from "./RunChatSurface";
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts"; import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
const MIN_DASHBOARD_RUNS = 4; const MIN_DASHBOARD_RUNS = 4;
@ -63,6 +63,7 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
{runs.map((run) => ( {runs.map((run) => (
<AgentRunCard <AgentRunCard
key={run.id} key={run.id}
companyId={companyId}
run={run} run={run}
issue={run.issueId ? issueById.get(run.issueId) : undefined} issue={run.issueId ? issueById.get(run.issueId) : undefined}
transcript={transcriptByRun.get(run.id) ?? []} transcript={transcriptByRun.get(run.id) ?? []}
@ -77,12 +78,14 @@ export function ActiveAgentsPanel({ companyId }: ActiveAgentsPanelProps) {
} }
function AgentRunCard({ function AgentRunCard({
companyId,
run, run,
issue, issue,
transcript, transcript,
hasOutput, hasOutput,
isActive, isActive,
}: { }: {
companyId: string;
run: LiveRunForIssue; run: LiveRunForIssue;
issue?: Issue; issue?: Issue;
transcript: TranscriptEntry[]; transcript: TranscriptEntry[];
@ -141,14 +144,11 @@ function AgentRunCard({
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto p-3"> <div className="min-h-0 flex-1 overflow-y-auto p-3">
<RunTranscriptView <RunChatSurface
entries={transcript} run={run}
density="compact" transcript={transcript}
limit={5} hasOutput={hasOutput}
streaming={isActive} companyId={companyId}
collapseStdout
thinkingClassName="!text-[10px] !leading-4"
emptyMessage={hasOutput ? "Waiting for transcript parsing..." : isActive ? "Waiting for output..." : "No transcript captured."}
/> />
</div> </div>
</div> </div>

View file

@ -139,6 +139,39 @@ describe("IssueChatThread", () => {
}); });
}); });
it("supports the embedded read-only variant without the jump control", () => {
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<IssueChatThread
comments={[]}
linkedRuns={[]}
timelineEvents={[]}
liveRuns={[]}
onAdd={async () => {}}
showComposer={false}
showJumpToLatest={false}
variant="embedded"
emptyMessage="No run output captured."
enableLiveTranscriptPolling={false}
/>
</MemoryRouter>,
);
});
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", () => { it("stores and restores the composer draft per issue key", () => {
vi.useFakeTimers(); vi.useFakeTimers();
const root = createRoot(container); const root = createRoot(container);

View file

@ -175,9 +175,13 @@ interface IssueChatThreadProps {
mentions?: MentionOption[]; mentions?: MentionOption[];
composerDisabledReason?: string | null; composerDisabledReason?: string | null;
showComposer?: boolean; showComposer?: boolean;
showJumpToLatest?: boolean;
emptyMessage?: string;
variant?: "full" | "embedded";
enableLiveTranscriptPolling?: boolean; enableLiveTranscriptPolling?: boolean;
transcriptsByRunId?: ReadonlyMap<string, readonly IssueChatTranscriptEntry[]>; transcriptsByRunId?: ReadonlyMap<string, readonly IssueChatTranscriptEntry[]>;
hasOutputForRun?: (runId: string) => boolean; hasOutputForRun?: (runId: string) => boolean;
includeSucceededRunsWithoutOutput?: boolean;
onInterruptQueued?: (runId: string) => Promise<void>; onInterruptQueued?: (runId: string) => Promise<void>;
interruptingQueuedRunId?: string | null; interruptingQueuedRunId?: string | null;
} }
@ -1591,9 +1595,13 @@ export function IssueChatThread({
mentions = [], mentions = [],
composerDisabledReason = null, composerDisabledReason = null,
showComposer = true, showComposer = true,
showJumpToLatest,
emptyMessage,
variant = "full",
enableLiveTranscriptPolling = true, enableLiveTranscriptPolling = true,
transcriptsByRunId, transcriptsByRunId,
hasOutputForRun: hasOutputForRunOverride, hasOutputForRun: hasOutputForRunOverride,
includeSucceededRunsWithoutOutput = false,
onInterruptQueued, onInterruptQueued,
interruptingQueuedRunId = null, interruptingQueuedRunId = null,
}: IssueChatThreadProps) { }: IssueChatThreadProps) {
@ -1659,6 +1667,7 @@ export function IssueChatThread({
activeRun, activeRun,
transcriptsByRunId: resolvedTranscriptByRun, transcriptsByRunId: resolvedTranscriptByRun,
hasOutputForRun: resolvedHasOutputForRun, hasOutputForRun: resolvedHasOutputForRun,
includeSucceededRunsWithoutOutput,
companyId, companyId,
projectId, projectId,
agentMap, agentMap,
@ -1672,6 +1681,7 @@ export function IssueChatThread({
activeRun, activeRun,
resolvedTranscriptByRun, resolvedTranscriptByRun,
resolvedHasOutputForRun, resolvedHasOutputForRun,
includeSucceededRunsWithoutOutput,
companyId, companyId,
projectId, projectId,
agentMap, 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 ( return (
<AssistantRuntimeProvider runtime={runtime}> <AssistantRuntimeProvider runtime={runtime}>
<IssueChatCtx.Provider value={chatCtx}> <IssueChatCtx.Provider value={chatCtx}>
<div className="space-y-4"> <div className={cn(variant === "embedded" ? "space-y-3" : "space-y-4")}>
<div className="flex justify-end"> {resolvedShowJumpToLatest ? (
<button <div className="flex justify-end">
type="button" <button
onClick={handleJumpToLatest} type="button"
className="text-xs text-muted-foreground transition-colors hover:text-foreground" onClick={handleJumpToLatest}
> className="text-xs text-muted-foreground transition-colors hover:text-foreground"
Jump to latest >
</button> Jump to latest
</div> </button>
</div>
) : null}
<ThreadPrimitive.Root className=""> <ThreadPrimitive.Root className="">
<ThreadPrimitive.Viewport className="space-y-4"> <ThreadPrimitive.Viewport className={variant === "embedded" ? "space-y-3" : "space-y-4"}>
<ThreadPrimitive.Empty> <ThreadPrimitive.Empty>
<div className="rounded-2xl border border-dashed border-border bg-card px-6 py-10 text-center text-sm text-muted-foreground"> <div className={cn(
This issue conversation is empty. Start with a message below. "text-center text-sm text-muted-foreground",
variant === "embedded"
? "rounded-xl border border-dashed border-border/70 bg-background/60 px-4 py-6"
: "rounded-2xl border border-dashed border-border bg-card px-6 py-10",
)}>
{resolvedEmptyMessage}
</div> </div>
</ThreadPrimitive.Empty> </ThreadPrimitive.Empty>
<ThreadPrimitive.Messages components={components} /> <ThreadPrimitive.Messages components={components} />

View file

@ -6,8 +6,8 @@ import { queryKeys } from "../lib/queryKeys";
import { formatDateTime } from "../lib/utils"; import { formatDateTime } from "../lib/utils";
import { ExternalLink, Square } from "lucide-react"; import { ExternalLink, Square } from "lucide-react";
import { Identity } from "./Identity"; import { Identity } from "./Identity";
import { RunChatSurface } from "./RunChatSurface";
import { StatusBadge } from "./StatusBadge"; import { StatusBadge } from "./StatusBadge";
import { RunTranscriptView } from "./transcript/RunTranscriptView";
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts"; import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
interface LiveRunWidgetProps { interface LiveRunWidgetProps {
@ -93,7 +93,7 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
Live Runs Live Runs
</div> </div>
<div className="mt-1 text-xs text-muted-foreground"> <div className="mt-1 text-xs text-muted-foreground">
Streamed with the same transcript UI used on the full run detail page. Uses the shared chat-style run surface from issue activity.
</div> </div>
</div> </div>
@ -142,13 +142,11 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
</div> </div>
<div className="max-h-[320px] overflow-y-auto pr-1"> <div className="max-h-[320px] overflow-y-auto pr-1">
<RunTranscriptView <RunChatSurface
entries={transcript} run={run}
density="compact" transcript={transcript}
limit={8} hasOutput={hasOutputForRun(run.id)}
streaming={isActive} companyId={companyId}
collapseStdout
emptyMessage={hasOutputForRun(run.id) ? "Waiting for transcript parsing..." : "Waiting for run output..."}
/> />
</div> </div>
</section> </section>

View file

@ -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<IssueChatLinkedRun[]>(
() =>
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 (
<IssueChatThread
comments={[]}
linkedRuns={linkedRuns}
timelineEvents={[]}
liveRuns={liveRuns}
companyId={companyId}
onAdd={async () => {}}
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
/>
);
}

View file

@ -332,4 +332,39 @@ describe("buildIssueChatMessages", () => {
{ type: "text", text: "Updated the thread renderer." }, { 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",
},
},
});
});
}); });

View file

@ -32,6 +32,7 @@ export interface IssueChatLinkedRun {
runId: string; runId: string;
status: string; status: string;
agentId: string; agentId: string;
agentName?: string;
createdAt: Date | string; createdAt: Date | string;
startedAt: Date | string | null; startedAt: Date | string | null;
finishedAt?: Date | string | null; finishedAt?: Date | string | null;
@ -372,7 +373,7 @@ function runDurationLabel(run: {
} }
function createHistoricalRunMessage(run: IssueChatLinkedRun, agentMap?: Map<string, Agent>) { function createHistoricalRunMessage(run: IssueChatLinkedRun, agentMap?: Map<string, Agent>) {
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 = { const message: ThreadSystemMessage = {
id: `run:${run.runId}`, id: `run:${run.runId}`,
role: "system", role: "system",
@ -399,7 +400,7 @@ function createHistoricalTranscriptMessage(args: {
agentMap?: Map<string, Agent>; agentMap?: Map<string, Agent>;
}) { }) {
const { run, transcript, hasOutput, agentMap } = args; 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 { parts, notices, segments } = buildAssistantPartsFromTranscript(transcript);
const waitingText = hasOutput ? "" : "Run finished"; const waitingText = hasOutput ? "" : "Run finished";
const content = parts.length > 0 const content = parts.length > 0
@ -639,6 +640,7 @@ export function buildIssueChatMessages(args: {
activeRun?: ActiveRunForIssue | null; activeRun?: ActiveRunForIssue | null;
transcriptsByRunId?: ReadonlyMap<string, readonly IssueChatTranscriptEntry[]>; transcriptsByRunId?: ReadonlyMap<string, readonly IssueChatTranscriptEntry[]>;
hasOutputForRun?: (runId: string) => boolean; hasOutputForRun?: (runId: string) => boolean;
includeSucceededRunsWithoutOutput?: boolean;
issueId?: string; issueId?: string;
companyId?: string | null; companyId?: string | null;
projectId?: string | null; projectId?: string | null;
@ -653,6 +655,7 @@ export function buildIssueChatMessages(args: {
activeRun, activeRun,
transcriptsByRunId, transcriptsByRunId,
hasOutputForRun, hasOutputForRun,
includeSucceededRunsWithoutOutput = false,
issueId, issueId,
companyId, companyId,
projectId, projectId,
@ -694,7 +697,7 @@ export function buildIssueChatMessages(args: {
}); });
continue; continue;
} }
if (run.status === "succeeded") continue; if (run.status === "succeeded" && !includeSucceededRunsWithoutOutput) continue;
orderedMessages.push({ orderedMessages.push({
createdAtMs: toTimestamp(runTimestamp(run)), createdAtMs: toTimestamp(runTimestamp(run)),
order: 2, order: 2,