mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
feat: make chat-style tasks the default experience (#11101)
This commit is contained in:
1 parent
2da6a248c3
commit
815e49bb7c
80 files changed
+3858
-1497
No files matched your search
@@ -1,11 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import type {
|
||||
FeedbackDataSharingPreference,
|
||||
FeedbackVoteValue,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
BubbleCopyButton,
|
||||
IssueChatFeedbackButtons,
|
||||
} from "@/components/AgentBubbleActionRow";
|
||||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||||
import { IssueChatFeedbackButtons } from "@/components/AgentBubbleActionRow";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
|
||||
/** Feedback-vote wiring for an agent bubble, resolved per comment by the host. */
|
||||
export interface TaskChatBubbleFeedback {
|
||||
@@ -22,10 +22,9 @@ export interface TaskChatBubbleFeedback {
|
||||
* Compact copy · 👍 · 👎 cluster prepended to an agent bubble's footer line
|
||||
* (PAP-413), leading the "✓ Worked · …" turn summary (or the bare timestamp
|
||||
* when the reply had no run activity). It reuses the shared
|
||||
* {@link BubbleCopyButton} and {@link IssueChatFeedbackButtons} so the
|
||||
* redesigned task thread speaks the same footer language as the conference
|
||||
* room's {@link AgentBubbleActionRow} without re-declaring their markup; the
|
||||
* timestamp stays owned by the summary/bubble, so it is not duplicated here.
|
||||
* {@link IssueChatFeedbackButtons} so the redesigned task thread speaks the
|
||||
* same feedback language as the conference room's {@link AgentBubbleActionRow};
|
||||
* the timestamp stays owned by the summary/bubble, so it is not duplicated here.
|
||||
*/
|
||||
export function TaskChatBubbleActions({
|
||||
copyText,
|
||||
@@ -34,9 +33,26 @@ export function TaskChatBubbleActions({
|
||||
copyText: string;
|
||||
feedback?: TaskChatBubbleFeedback | null;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5" data-testid="task-chat-bubble-actions">
|
||||
<BubbleCopyButton copyText={copyText} />
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
title="Copy message"
|
||||
aria-label="Copy message"
|
||||
onClick={() => {
|
||||
void copyTextToClipboard(copyText)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
})
|
||||
.catch(() => {});
|
||||
}}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
{feedback ? (
|
||||
<IssueChatFeedbackButtons
|
||||
activeVote={feedback.activeVote}
|
||||
|
||||
@@ -89,12 +89,12 @@ describe("TaskChatInteractionCard", () => {
|
||||
);
|
||||
});
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
const confirm = buttons.find((button) => button.textContent === "Confirm");
|
||||
const decline = buttons.find((button) => button.textContent === "Decline");
|
||||
expect(confirm).not.toBeUndefined();
|
||||
expect(decline).not.toBeUndefined();
|
||||
const row = confirm?.parentElement;
|
||||
expect(row).toBe(decline?.parentElement);
|
||||
const approve = buttons.find((button) => button.textContent === "Approve");
|
||||
const reject = buttons.find((button) => button.textContent === "Reject");
|
||||
expect(approve).not.toBeUndefined();
|
||||
expect(reject).not.toBeUndefined();
|
||||
const row = approve?.parentElement;
|
||||
expect(row).toBe(reject?.parentElement);
|
||||
expect(row?.className).toContain("flex-row-reverse");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { TranscriptEntry } from "../../adapters";
|
||||
import { TaskChatLiveRunPill, toolCountSummaryFromEntries } from "./TaskChatLiveRunPill";
|
||||
|
||||
function toolCall(overrides: Partial<Extract<TranscriptEntry, { kind: "tool_call" }>>): TranscriptEntry {
|
||||
return { kind: "tool_call", ts: "2026-08-08T00:00:00.000Z", name: "read_file", input: {}, ...overrides };
|
||||
}
|
||||
|
||||
describe("toolCountSummaryFromEntries", () => {
|
||||
it("returns null when there are no tool calls", () => {
|
||||
expect(toolCountSummaryFromEntries([])).toBeNull();
|
||||
expect(
|
||||
toolCountSummaryFromEntries([{ kind: "assistant", ts: "t", text: "hi" } as TranscriptEntry]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("counts commands and other tools separately with pluralization", () => {
|
||||
const entries: TranscriptEntry[] = [
|
||||
toolCall({ name: "bash", input: { command: "ls" }, toolUseId: "c1" }),
|
||||
toolCall({ name: "command_execution", input: { command: "pwd" }, toolUseId: "c2" }),
|
||||
toolCall({ name: "read_file", input: { path: "a.ts" }, toolUseId: "t1" }),
|
||||
];
|
||||
expect(toolCountSummaryFromEntries(entries)).toBe("ran 2 commands, called 1 tool");
|
||||
});
|
||||
|
||||
it("dedupes re-emitted tool_calls that share a toolUseId", () => {
|
||||
const entries: TranscriptEntry[] = [
|
||||
toolCall({ name: "read_file", input: {}, toolUseId: "t1" }),
|
||||
toolCall({ name: "read_file", input: { path: "a.ts" }, toolUseId: "t1" }),
|
||||
toolCall({ name: "read_file", input: { path: "a.ts" }, toolUseId: "t1" }),
|
||||
];
|
||||
expect(toolCountSummaryFromEntries(entries)).toBe("called 1 tool");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskChatLiveRunPill", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("shimmers 'Working' with elapsed + tool summary while streaming", () => {
|
||||
const startedAtMs = Date.now() - 65_000; // ~1 minute ago
|
||||
act(() => {
|
||||
root.render(
|
||||
<TaskChatLiveRunPill
|
||||
status="running"
|
||||
startedAtMs={startedAtMs}
|
||||
toolSummary="called 3 tools"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const pill = container.querySelector('[data-testid="task-chat-live-run-pill"]');
|
||||
expect(pill).not.toBeNull();
|
||||
const shimmer = container.querySelector(".shimmer-text");
|
||||
expect(shimmer?.textContent).toBe("Working");
|
||||
expect(pill?.textContent).toContain("for 1 minute");
|
||||
expect(pill?.textContent).toContain("called 3 tools");
|
||||
});
|
||||
|
||||
it("settles to a static 'Worked' summary once the run is terminal", () => {
|
||||
const startedAtMs = 1_000;
|
||||
act(() => {
|
||||
root.render(
|
||||
<TaskChatLiveRunPill
|
||||
status="succeeded"
|
||||
startedAtMs={startedAtMs}
|
||||
finishedAtMs={startedAtMs + 42_000}
|
||||
toolSummary="ran 1 command"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const pill = container.querySelector('[data-testid="task-chat-live-run-pill"]');
|
||||
expect(container.querySelector(".shimmer-text")).toBeNull();
|
||||
expect(pill?.textContent).toContain("Worked");
|
||||
expect(pill?.textContent).toContain("for 42 seconds");
|
||||
expect(pill?.textContent).toContain("ran 1 command");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { TranscriptEntry } from "../../adapters";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSecondTick } from "@/hooks/useSecondTick";
|
||||
import { formatDurationWords } from "@/lib/issue-chat-messages";
|
||||
import { isCommandTool } from "@/lib/transcriptPresentation";
|
||||
import { isTerminalRunStatus } from "@/components/task-chat/transcript-adapter";
|
||||
|
||||
/**
|
||||
* "ran N commands, called M tools" for the live tail's status pill, counted off
|
||||
* the streamed transcript entries. Mirrors IssueChatThread's `toolCountSummary`
|
||||
* (which counts a settled message's tool-call parts) so the experimental live
|
||||
* tail reads the same as the default view. Streaming runtimes re-emit a
|
||||
* tool_call as its status progresses, so calls carrying a `toolUseId` are
|
||||
* counted once.
|
||||
*/
|
||||
export function toolCountSummaryFromEntries(entries: readonly TranscriptEntry[]): string | null {
|
||||
const seen = new Set<string>();
|
||||
let commands = 0;
|
||||
let other = 0;
|
||||
for (const entry of entries) {
|
||||
if (entry.kind !== "tool_call") continue;
|
||||
if (entry.toolUseId) {
|
||||
if (seen.has(entry.toolUseId)) continue;
|
||||
seen.add(entry.toolUseId);
|
||||
}
|
||||
if (isCommandTool(entry.name, entry.input)) commands += 1;
|
||||
else other += 1;
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (commands > 0) parts.push(`ran ${commands} command${commands === 1 ? "" : "s"}`);
|
||||
if (other > 0) parts.push(`called ${other} tool${other === 1 ? "" : "s"}`);
|
||||
return parts.length > 0 ? parts.join(", ") : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status pill for the experimental chat-style view's live tail (PAP-461, A1).
|
||||
*
|
||||
* Byte-for-byte the same affordance the default view shows above a run's chain
|
||||
* of thought (IssueChatThread's CoT header): a spinner + shimmering "Working"
|
||||
* verb + elapsed words + "· called N tools" while the run streams, settling to
|
||||
* a static emerald dot + "Worked" summary once it terminates. The verb shimmers
|
||||
* via `.shimmer-text` (reduced-motion aware) exactly as the default view does.
|
||||
*/
|
||||
export function TaskChatLiveRunPill({
|
||||
status,
|
||||
startedAtMs,
|
||||
finishedAtMs,
|
||||
toolSummary,
|
||||
}: {
|
||||
status: string;
|
||||
/** Run start (startedAt, falling back to createdAt) in ms, or null if unknown. */
|
||||
startedAtMs: number | null;
|
||||
/** Run finish in ms once terminal; drives the settled elapsed readout. */
|
||||
finishedAtMs?: number | null;
|
||||
toolSummary: string | null;
|
||||
}) {
|
||||
const active = !isTerminalRunStatus(status);
|
||||
// One shared page-wide ticker drives the live elapsed readout, matching the
|
||||
// default view's `useLiveElapsed`.
|
||||
useSecondTick(active && startedAtMs != null);
|
||||
|
||||
const elapsedMs =
|
||||
startedAtMs == null ? null : (active ? Date.now() : finishedAtMs ?? Date.now()) - startedAtMs;
|
||||
const elapsed = elapsedMs != null ? formatDurationWords(elapsedMs) : null;
|
||||
const verb = active ? "Working" : "Worked";
|
||||
const suffix = elapsed ? `for ${elapsed}` : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-2.5 px-1 py-2"
|
||||
data-testid="task-chat-live-run-pill"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 text-sm font-medium text-foreground/80">
|
||||
{active ? (
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500/70" />
|
||||
</span>
|
||||
)}
|
||||
{active ? <span className={cn("shimmer-text")}>{verb}</span> : verb}
|
||||
</span>
|
||||
{suffix ? <span className="text-xs text-muted-foreground/60">{suffix}</span> : null}
|
||||
{toolSummary ? <span className="text-xs text-muted-foreground/40">· {toolSummary}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { TranscriptEntry } from "@/adapters";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { TaskChatLiveTail } from "./TaskChatLiveTail";
|
||||
import { transcriptToTaskChatItems } from "./transcript-adapter";
|
||||
import type { TaskChatItem } from "./task-chat-model";
|
||||
|
||||
const TS = "2026-08-08T12:00:00.000Z";
|
||||
|
||||
describe("TaskChatLiveTail", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
root = null;
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function render(items: TaskChatItem[], emptyMessage?: string) {
|
||||
flushSync(() =>
|
||||
root!.render(
|
||||
<ThemeProvider>
|
||||
<TaskChatLiveTail items={items} emptyMessage={emptyMessage} />
|
||||
</ThemeProvider>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function parse(entries: TranscriptEntry[], running = true) {
|
||||
return transcriptToTaskChatItems(entries, { runId: "run-1", running });
|
||||
}
|
||||
|
||||
it("renders streamed reply markdown and tool cards from a live transcript", () => {
|
||||
const items = parse([
|
||||
{ kind: "assistant", ts: TS, text: "Looking into the failing test." },
|
||||
{ kind: "tool_call", ts: TS, name: "Read", toolUseId: "t1", input: { file_path: "src/app.ts" } },
|
||||
{ kind: "tool_result", ts: TS, toolUseId: "t1", content: "ok", isError: false },
|
||||
]);
|
||||
render(items);
|
||||
|
||||
expect(container.querySelector('[data-testid="task-chat-live-text"]')?.textContent).toContain(
|
||||
"Looking into the failing test.",
|
||||
);
|
||||
// Tool row renders with its name + mono target.
|
||||
expect(container.textContent).toContain("Read");
|
||||
expect(container.textContent).toContain("src/app.ts");
|
||||
});
|
||||
|
||||
it("renders a tool's diff inset", () => {
|
||||
const items = parse([
|
||||
{ kind: "tool_call", ts: TS, name: "Edit", toolUseId: "t1", input: { file_path: "a.ts" } },
|
||||
{ kind: "diff", ts: TS, changeType: "add", text: "const x = 1;" },
|
||||
{ kind: "diff", ts: TS, changeType: "remove", text: "const x = 0;" },
|
||||
]);
|
||||
render(items);
|
||||
|
||||
expect(container.textContent).toContain("const x = 1;");
|
||||
expect(container.textContent).toContain("+1 −1");
|
||||
});
|
||||
|
||||
it("drops the debug plumbing kinds RunTranscriptView surfaced", () => {
|
||||
// Feed the exact noise the board flagged: init row, stdout/stderr/system
|
||||
// dumps, and a result line — interleaved with real content. None of the
|
||||
// noise may reach the DOM; only the assistant text + tool row survive.
|
||||
const items = parse([
|
||||
{ kind: "init", ts: TS, model: "claude-opus", sessionId: "sess-INITMARKER" },
|
||||
{ kind: "system", ts: TS, text: "SYSTEMNOISE hint about the environment" },
|
||||
{ kind: "stdout", ts: TS, text: "STDOUTNOISE raw log line" },
|
||||
{ kind: "stderr", ts: TS, text: "STDERRNOISE warning" },
|
||||
{ kind: "assistant", ts: TS, text: "Here is the real reply." },
|
||||
{ kind: "tool_call", ts: TS, name: "Bash", toolUseId: "t1", input: { command: "pnpm test" } },
|
||||
{
|
||||
kind: "result",
|
||||
ts: TS,
|
||||
text: "RESULTNOISE",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cachedTokens: 0,
|
||||
costUsd: 0.01,
|
||||
subtype: "success",
|
||||
isError: false,
|
||||
errors: [],
|
||||
},
|
||||
]);
|
||||
render(items);
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toContain("Here is the real reply.");
|
||||
expect(text).toContain("pnpm test");
|
||||
// No debug plumbing, no RunTranscriptView chrome.
|
||||
for (const noise of [
|
||||
"INITMARKER",
|
||||
"SYSTEMNOISE",
|
||||
"STDOUTNOISE",
|
||||
"STDERRNOISE",
|
||||
"RESULTNOISE",
|
||||
"Streaming",
|
||||
"LOG LINES",
|
||||
"SYSTEM MESSAGES",
|
||||
]) {
|
||||
expect(text).not.toContain(noise);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not render a thinking row (its signal is the status pill)", () => {
|
||||
const items = parse([
|
||||
{ kind: "thinking", ts: TS, text: "SECRET internal reasoning" },
|
||||
{ kind: "assistant", ts: TS, text: "Visible answer." },
|
||||
]);
|
||||
render(items);
|
||||
|
||||
expect(container.textContent).toContain("Visible answer.");
|
||||
expect(container.textContent).not.toContain("SECRET internal reasoning");
|
||||
});
|
||||
|
||||
it("shows the empty message when nothing renderable has streamed yet", () => {
|
||||
render([], "Waiting to start...");
|
||||
expect(container.textContent).toContain("Waiting to start...");
|
||||
});
|
||||
|
||||
it("renders nothing (not even the empty message) once content exists", () => {
|
||||
const items = parse([{ kind: "assistant", ts: TS, text: "streaming…" }]);
|
||||
render(items, "Waiting to start...");
|
||||
expect(container.textContent).not.toContain("Waiting to start...");
|
||||
expect(container.textContent).toContain("streaming…");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import type { TaskChatItem } from "./task-chat-model";
|
||||
import { TaskChatToolCard } from "./TaskChatToolCard";
|
||||
import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
|
||||
|
||||
/**
|
||||
* Live-tail body for the experimental chat-style view (PAP-463, Workstream C1
|
||||
* of PAP-458).
|
||||
*
|
||||
* Renders the in-flight run's streaming transcript as the SAME clean rows the
|
||||
* settled thread uses — tool cards (with diffs) and the streamed reply markdown
|
||||
* — instead of the verbatim `RunTranscriptView` debug viewer that the live tail
|
||||
* used since `e4f3d7733`. The items come from `transcriptToTaskChatItems`, which
|
||||
* already drops the debug plumbing (init / stdout / stderr / system / user /
|
||||
* result), so none of `RunTranscriptView`'s noise can reach the thread: no INIT
|
||||
* row, no "N LOG LINES" / "N SYSTEM MESSAGES" banners, no raw stdout/JSON dumps,
|
||||
* no "Streaming" chip, no uppercase "USED TERMINAL" cards. The status pill above
|
||||
* this body (`TaskChatLiveRunPill`) owns the run-status affordance.
|
||||
*
|
||||
* Live and settle-gap render identically — both feed their parsed items here
|
||||
* (`running: true` while in flight, `false` through the settle gap) — so the
|
||||
* tail never restyles when a run finishes; the hand-off to the folded settled
|
||||
* turn is the only visible transition.
|
||||
*/
|
||||
export function TaskChatLiveTail({
|
||||
items,
|
||||
emptyMessage,
|
||||
}: {
|
||||
items: readonly TaskChatItem[];
|
||||
/** Shown when nothing renderable has streamed yet (queued / pre-first-token). */
|
||||
emptyMessage?: string;
|
||||
}) {
|
||||
const rows = items
|
||||
.map((item) => renderTailRow(item))
|
||||
.filter((row): row is ReactElement => row != null);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return emptyMessage ? (
|
||||
<div className="px-1 py-1 text-xs text-muted-foreground/70">{emptyMessage}</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
return <div className="flex flex-col gap-2">{rows}</div>;
|
||||
}
|
||||
|
||||
function renderTailRow(item: TaskChatItem): ReactElement | null {
|
||||
switch (item.kind) {
|
||||
case "message": {
|
||||
// Streamed reply text (always interstitial from the transcript adapter).
|
||||
// Rendered as plain markdown — the settled thread later replaces it with
|
||||
// the posted comment bubble, so no author header/bubble chrome here.
|
||||
const text = item.text.trim();
|
||||
if (!text) return null;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="px-1 text-sm text-foreground/90"
|
||||
data-testid="task-chat-live-text"
|
||||
>
|
||||
<MarkdownBody softBreaks linkIssueReferences>
|
||||
{item.text}
|
||||
</MarkdownBody>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "tool":
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<TaskChatToolCard item={item} />
|
||||
</div>
|
||||
);
|
||||
case "usage":
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<TaskChatUsageReadout item={item} />
|
||||
</div>
|
||||
);
|
||||
// Thinking never renders as a row (PAP-361): its live signal is the status
|
||||
// pill, and the text stays in the run log / classic transcript. Every other
|
||||
// kind (markers, interactions, briefs, statuses, turns, and the dropped
|
||||
// debug kinds) cannot appear in a parsed live transcript.
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,10 @@ interface TaskChatThreadViewProps {
|
||||
* fixtures omit it and the bubbles render actionless.
|
||||
*/
|
||||
renderMessageActions?: (item: TaskChatMessageItem) => ReactNode;
|
||||
/** Content appended inside the transcript scroller after the settled thread. */
|
||||
tail?: ReactNode;
|
||||
/** Optional streaming-aware key when `tail` changes without changing `items`. */
|
||||
contentKey?: number;
|
||||
className?: string;
|
||||
/** When false, render the list without the scroll container (e.g. previews). */
|
||||
scroll?: boolean;
|
||||
@@ -128,6 +132,8 @@ export function TaskChatThreadView({
|
||||
renderInteraction,
|
||||
renderBrief,
|
||||
renderMessageActions,
|
||||
tail,
|
||||
contentKey,
|
||||
className,
|
||||
scroll = true,
|
||||
}: TaskChatThreadViewProps) {
|
||||
@@ -143,12 +149,17 @@ export function TaskChatThreadView({
|
||||
{renderItem(item, onApprovalDecision, renderInteraction, renderBrief, renderMessageActions)}
|
||||
</div>
|
||||
))}
|
||||
{tail}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!scroll) return body;
|
||||
|
||||
return <TaskMessageScroller contentKey={taskChatContentKey(items)}>{body}</TaskMessageScroller>;
|
||||
return (
|
||||
<TaskMessageScroller contentKey={contentKey ?? taskChatContentKey(items)}>
|
||||
{body}
|
||||
</TaskMessageScroller>
|
||||
);
|
||||
}
|
||||
|
||||
// Cheap content signature so streaming growth (text lengthening without the
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
RequestConfirmationInteraction,
|
||||
RequestConfirmationResult,
|
||||
} from "@/lib/issue-thread-interactions";
|
||||
import {
|
||||
interactionThreadAnchorMs,
|
||||
isSuppressedThreadInteraction,
|
||||
} from "./interaction-thread-order";
|
||||
|
||||
function confirmation(
|
||||
overrides: Partial<RequestConfirmationInteraction> = {},
|
||||
): RequestConfirmationInteraction {
|
||||
return {
|
||||
id: "confirmation-1",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
kind: "request_confirmation",
|
||||
title: "Approve the plan",
|
||||
summary: null,
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
resolverPolicy: "board_only",
|
||||
requestedResolverPolicy: "board_only",
|
||||
effectiveResolverPolicy: "board_only",
|
||||
createdByAgentId: "agent-1",
|
||||
createdByUserId: null,
|
||||
resolvedByAgentId: null,
|
||||
resolvedByUserId: null,
|
||||
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
resolvedAt: null,
|
||||
payload: { version: 1, prompt: "Approve?" },
|
||||
result: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function result(outcome: RequestConfirmationResult["outcome"]): RequestConfirmationResult {
|
||||
return { version: 1, outcome };
|
||||
}
|
||||
|
||||
const CREATED_MS = new Date("2026-04-06T12:00:00.000Z").getTime();
|
||||
const RESOLVED_MS = new Date("2026-04-06T12:05:00.000Z").getTime();
|
||||
|
||||
describe("isSuppressedThreadInteraction", () => {
|
||||
it("hides withdrawn confirmations", () => {
|
||||
expect(
|
||||
isSuppressedThreadInteraction(
|
||||
confirmation({ status: "cancelled", result: result("withdrawn") }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("hides confirmations superseded by a comment or a newer request", () => {
|
||||
expect(
|
||||
isSuppressedThreadInteraction(
|
||||
confirmation({ status: "expired", result: result("superseded_by_comment") }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSuppressedThreadInteraction(
|
||||
confirmation({ status: "expired", result: result("superseded_by_newer_request") }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps accepted, rejected, and still-pending confirmations", () => {
|
||||
expect(isSuppressedThreadInteraction(confirmation())).toBe(false);
|
||||
expect(
|
||||
isSuppressedThreadInteraction(
|
||||
confirmation({ status: "accepted", result: result("accepted") }),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSuppressedThreadInteraction(
|
||||
confirmation({ status: "rejected", result: result("rejected") }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("only suppresses confirmation-family kinds, not questions or suggestions", () => {
|
||||
const superseded = { version: 1, outcome: "superseded_by_comment" } as never;
|
||||
expect(
|
||||
isSuppressedThreadInteraction(
|
||||
confirmation({ kind: "ask_user_questions", result: superseded } as never),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSuppressedThreadInteraction(
|
||||
confirmation({ kind: "suggest_tasks", result: superseded } as never),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interactionThreadAnchorMs", () => {
|
||||
it("keeps a pending card at its request-time fallback", () => {
|
||||
expect(interactionThreadAnchorMs(confirmation(), CREATED_MS)).toBe(CREATED_MS);
|
||||
});
|
||||
|
||||
it("re-anchors a resolved card to its resolution time", () => {
|
||||
const resolved = confirmation({
|
||||
status: "accepted",
|
||||
result: result("accepted"),
|
||||
resolvedAt: new Date(RESOLVED_MS),
|
||||
});
|
||||
expect(interactionThreadAnchorMs(resolved, CREATED_MS)).toBe(RESOLVED_MS);
|
||||
});
|
||||
|
||||
it("never drags a resolved card above its own request slot", () => {
|
||||
// Clock skew: resolvedAt reads earlier than the handoff/created fallback.
|
||||
const resolved = confirmation({
|
||||
status: "accepted",
|
||||
result: result("accepted"),
|
||||
resolvedAt: new Date(CREATED_MS - 60_000),
|
||||
});
|
||||
expect(interactionThreadAnchorMs(resolved, CREATED_MS)).toBe(CREATED_MS);
|
||||
});
|
||||
|
||||
it("falls back when a resolved card has no resolvedAt yet", () => {
|
||||
const resolved = confirmation({ status: "accepted", result: result("accepted") });
|
||||
expect(interactionThreadAnchorMs(resolved, CREATED_MS)).toBe(CREATED_MS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { IssueThreadInteraction } from "@/lib/issue-thread-interactions";
|
||||
|
||||
/**
|
||||
* Thread-ordering + visibility rules for issue-thread interaction cards
|
||||
* (PAP-416, Phase A of PAP-412).
|
||||
*
|
||||
* Two problems this fixes:
|
||||
*
|
||||
* 1. Cards were pinned to `createdAt`, but the user answers them later and the
|
||||
* agent's follow-up lands later still — so a card you just resolved could
|
||||
* sit ABOVE a message written before you answered, reading backwards. We
|
||||
* settle a RESOLVED card at its resolution time (`resolvedAt`) so it never
|
||||
* floats above an earlier message. Pending cards stay where they were asked.
|
||||
*
|
||||
* 2. Withdrawn / superseded confirmation cards lingered in the thread, stacking
|
||||
* a dead card above the accepted one. We suppress those from the backbone
|
||||
* entirely — a retracted or superseded confirmation is not a call to action
|
||||
* and reads as noise next to the outcome that replaced it.
|
||||
*/
|
||||
|
||||
// The confirmation-family kinds: cards that are a call to act on a proposal.
|
||||
// ask_user_questions / suggest_tasks keep their superseded notices (legacy
|
||||
// parity) — only confirmations get fully hidden when withdrawn/superseded.
|
||||
const CONFIRMATION_KINDS = new Set([
|
||||
"request_confirmation",
|
||||
"request_checkbox_confirmation",
|
||||
"request_item_verdicts",
|
||||
]);
|
||||
|
||||
// Terminal outcomes that mean "this confirmation was retracted or replaced": it
|
||||
// never got an accept/reject decision the reader needs to see. `withdrawn` is an
|
||||
// agent/board retraction; the two `superseded_by_*` outcomes fire when a later
|
||||
// comment or a fresh request took its place.
|
||||
const SUPPRESSED_CONFIRMATION_OUTCOMES = new Set([
|
||||
"withdrawn",
|
||||
"superseded_by_comment",
|
||||
"superseded_by_newer_request",
|
||||
]);
|
||||
|
||||
function interactionOutcome(interaction: IssueThreadInteraction): string | null {
|
||||
const result = interaction.result;
|
||||
return result && "outcome" in result && typeof result.outcome === "string"
|
||||
? result.outcome
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A confirmation card that was withdrawn or superseded — hide it from the thread
|
||||
* so it never stacks above the confirmation that replaced it.
|
||||
*/
|
||||
export function isSuppressedThreadInteraction(interaction: IssueThreadInteraction): boolean {
|
||||
if (!CONFIRMATION_KINDS.has(interaction.kind)) return false;
|
||||
const outcome = interactionOutcome(interaction);
|
||||
return outcome != null && SUPPRESSED_CONFIRMATION_OUTCOMES.has(outcome);
|
||||
}
|
||||
|
||||
function toMs(value: Date | string | null | undefined): number {
|
||||
if (!value) return 0;
|
||||
const ms = new Date(value).getTime();
|
||||
return Number.isNaN(ms) ? 0 : ms;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chronological slot for an interaction card.
|
||||
*
|
||||
* `fallbackMs` is the caller's existing anchor (the same-run handoff shift when
|
||||
* present, else `createdAt`). A resolved card re-anchors to its resolution time
|
||||
* so it lands next to the answer, never above an earlier message; we take the
|
||||
* later of the two so clock skew can't drag it back above its own request. A
|
||||
* still-pending card keeps the request-time slot.
|
||||
*/
|
||||
export function interactionThreadAnchorMs(
|
||||
interaction: IssueThreadInteraction,
|
||||
fallbackMs: number,
|
||||
): number {
|
||||
if (interaction.status === "pending") return fallbackMs;
|
||||
const resolvedMs = toMs(interaction.resolvedAt);
|
||||
return resolvedMs > 0 ? Math.max(resolvedMs, fallbackMs) : fallbackMs;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Normalized presentation model for the Task Chat Redesign (flag:
|
||||
* enableTaskChatRedesign).
|
||||
* Normalized presentation model for the chat-style task thread (the default
|
||||
* task view; the classic legacy view sits behind enableClassicTaskInterface).
|
||||
*
|
||||
* This is a deliberately small, protocol-agnostic model that the new render
|
||||
* layer consumes. Two producers feed it:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Canonical state inventory for the Task Chat Redesign (flag:
|
||||
* enableTaskChatRedesign).
|
||||
* Canonical state inventory for the chat-style task thread (the default task
|
||||
* view; the classic legacy view sits behind enableClassicTaskInterface).
|
||||
*
|
||||
* This list is the single source of truth for:
|
||||
* - the dev harness state switcher (/dev/task-chat-lab), and
|
||||
|
||||
Reference in new issue
Block a user