feat: make chat-style tasks the default experience (#11101)

This commit is contained in:
scotttong authored and GitHub committed 2026-08-11 09:06:21 -07:00
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