mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 19:50:38 +09:00
Reuse chat-style run feed on dashboard
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
b5e177df7e
commit
950ea065ae
7 changed files with 191 additions and 35 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
64
ui/src/components/RunChatSurface.tsx
Normal file
64
ui/src/components/RunChatSurface.tsx
Normal 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
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue