mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +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
@@ -439,10 +439,10 @@ interface IssueChatThreadProps {
|
||||
linkedRuns?: IssueChatLinkedRun[];
|
||||
timelineEvents?: IssueTimelineEvent[];
|
||||
/**
|
||||
* Work-mode switch history from the activity feed. Only the redesigned
|
||||
* TaskChatThread consumes this (flag: enableTaskChatRedesign) to tag each
|
||||
* agent reply with the mode its request ran under; the legacy thread
|
||||
* ignores it.
|
||||
* Work-mode switch history from the activity feed. Only the chat-style
|
||||
* TaskChatThread consumes this to tag each agent reply with the mode its
|
||||
* request ran under; this thread — the classic task view behind
|
||||
* enableClassicTaskInterface — ignores it.
|
||||
*/
|
||||
workModeChanges?: IssueWorkModeChange[];
|
||||
liveRuns?: LiveRunForIssue[];
|
||||
@@ -513,16 +513,15 @@ interface IssueChatThreadProps {
|
||||
footer?: ReactNode;
|
||||
/**
|
||||
* Issue header content (title row, badges, plugin toolbars) rendered INSIDE
|
||||
* the thread's scroll viewport so it scrolls away with the messages. Only the
|
||||
* redesigned TaskChatThread consumes this (flag: enableTaskChatRedesign);
|
||||
* the legacy thread ignores it — its header stays in the page flow.
|
||||
* the thread's scroll viewport so it scrolls away with the messages. Only
|
||||
* the chat-style TaskChatThread consumes this; this thread ignores it — its
|
||||
* header stays in the page flow.
|
||||
*/
|
||||
threadHeader?: ReactNode;
|
||||
/**
|
||||
* The task description rendered as the requester's first chat bubble
|
||||
* (PAP-375). Only the redesigned TaskChatThread consumes it (flag:
|
||||
* enableTaskChatRedesign); the legacy thread ignores it — its description
|
||||
* stays in the page header via InlineEditor.
|
||||
* (PAP-375). Only the chat-style TaskChatThread consumes it; this thread
|
||||
* ignores it — its description stays in the page header via InlineEditor.
|
||||
*/
|
||||
issueBrief?: TaskChatIssueBrief;
|
||||
variant?: "full" | "embedded";
|
||||
|
||||
@@ -481,7 +481,7 @@ describe("IssueProperties", () => {
|
||||
it("keeps the Plan tab visible for a planning-mode issue without a plan document", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableTaskWatchdogs: false,
|
||||
enableTaskChatRedesign: true,
|
||||
enableClassicTaskInterface: false,
|
||||
});
|
||||
mockIssuesApi.listInteractions.mockResolvedValue([
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ThemeProvider } from "../context/ThemeContext";
|
||||
import { TooltipProvider } from "./ui/tooltip";
|
||||
import {
|
||||
pendingAskUserQuestionsInteraction,
|
||||
pendingAskUserQuestionsWithFreeTextOption,
|
||||
commentExpiredAskUserQuestionsInteraction,
|
||||
commentExpiredRequestConfirmationInteraction,
|
||||
declinedToolActionInteraction,
|
||||
@@ -185,6 +186,104 @@ describe("IssueThreadInteractionCard", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reveals an inline field when a free-text option is selected and hides the standalone Other link", async () => {
|
||||
const onSubmitInteractionAnswers = vi.fn(async () => undefined);
|
||||
const host = renderCard({
|
||||
interaction: pendingAskUserQuestionsWithFreeTextOption,
|
||||
onSubmitInteractionAnswers,
|
||||
});
|
||||
|
||||
// A first-class free-text option suppresses the built-in "Other" link.
|
||||
const otherLink = Array.from(host.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Other",
|
||||
);
|
||||
expect(otherLink).toBeUndefined();
|
||||
|
||||
// No text field until the free-text option is selected.
|
||||
expect(host.querySelector("textarea")).toBeNull();
|
||||
|
||||
const describeOption = Array.from(host.querySelectorAll('[role="radio"]')).find(
|
||||
(button) => button.textContent?.includes("I'll describe it"),
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(describeOption).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
describeOption?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(describeOption?.getAttribute("aria-checked")).toBe("true");
|
||||
const textarea = host.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
expect(textarea).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(
|
||||
HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
valueSetter?.call(textarea, "Call it Threads");
|
||||
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
const submitButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Send answers"),
|
||||
);
|
||||
await act(async () => {
|
||||
submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSubmitInteractionAnswers).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "ask_user_questions" }),
|
||||
[
|
||||
{
|
||||
questionId: "surface-name",
|
||||
optionIds: [],
|
||||
otherText: "Call it Threads",
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("renders nothing for a degenerate ask_user_questions card", () => {
|
||||
// A truly unanswerable question: a prompt with no options and no free-text
|
||||
// field, so there is nothing for the user to select or type. Hiding it
|
||||
// strands nothing.
|
||||
const degenerate = {
|
||||
...pendingAskUserQuestionsInteraction,
|
||||
id: "interaction-questions-degenerate",
|
||||
payload: {
|
||||
version: 1 as const,
|
||||
title: "Placeholder",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
prompt: "Anything?",
|
||||
selectionMode: "single" as const,
|
||||
options: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const host = renderCard({
|
||||
interaction: degenerate,
|
||||
onSubmitInteractionAnswers: vi.fn(),
|
||||
});
|
||||
|
||||
// No card wrapper, no title, no controls — the component returns null.
|
||||
expect(host.childElementCount).toBe(0);
|
||||
expect(host.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("still renders a legitimate ask_user_questions card", () => {
|
||||
const host = renderCard({
|
||||
interaction: pendingAskUserQuestionsInteraction,
|
||||
onSubmitInteractionAnswers: vi.fn(),
|
||||
});
|
||||
|
||||
expect(host.childElementCount).toBeGreaterThan(0);
|
||||
expect(host.querySelectorAll('[role="radio"]').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("only shows question cancellation when a cancel handler is wired", () => {
|
||||
const withoutHandler = renderCard({
|
||||
interaction: pendingAskUserQuestionsInteraction,
|
||||
@@ -329,32 +428,37 @@ describe("IssueThreadInteractionCard", () => {
|
||||
expect(host.textContent).toContain("No reason provided.");
|
||||
});
|
||||
|
||||
it("requires a decline reason when the request confirmation payload asks for one", async () => {
|
||||
it("requires a revision note when the request confirmation payload asks for one", async () => {
|
||||
const onRejectInteraction = vi.fn(async () => undefined);
|
||||
const host = renderCard({
|
||||
interaction: pendingRequestConfirmationInteraction,
|
||||
onRejectInteraction,
|
||||
});
|
||||
|
||||
const declineButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Request revisions"),
|
||||
// rejectRequiresReason drops the bare Reject: the only send-back path is Revise…
|
||||
expect(Array.from(host.querySelectorAll("button")).some((button) =>
|
||||
button.textContent?.trim() === "Reject",
|
||||
)).toBe(false);
|
||||
const reviseButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Revise"),
|
||||
);
|
||||
expect(declineButton).toBeTruthy();
|
||||
expect(reviseButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
declineButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
reviseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const saveButton = Array.from(host.querySelectorAll("button")).filter((button) =>
|
||||
button.textContent?.includes("Request revisions"),
|
||||
).at(-1);
|
||||
expect(saveButton?.hasAttribute("disabled")).toBe(false);
|
||||
const sendButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Send revision"),
|
||||
);
|
||||
expect(sendButton?.hasAttribute("disabled")).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
sendButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain("A decline reason is required.");
|
||||
expect(host.textContent).toContain("Add a note describing the changes you want.");
|
||||
expect(onRejectInteraction).not.toHaveBeenCalled();
|
||||
|
||||
const textarea = host.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
expect(textarea).toBeTruthy();
|
||||
@@ -368,12 +472,12 @@ describe("IssueThreadInteractionCard", () => {
|
||||
valueSetter?.call(textarea, "Needs a smaller phase split");
|
||||
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
const enabledSaveButton = Array.from(host.querySelectorAll("button")).filter((button) =>
|
||||
button.textContent?.includes("Request revisions"),
|
||||
).at(-1);
|
||||
expect(enabledSaveButton?.hasAttribute("disabled")).toBe(false);
|
||||
const enabledSendButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Send revision"),
|
||||
);
|
||||
expect(enabledSendButton?.hasAttribute("disabled")).toBe(false);
|
||||
await act(async () => {
|
||||
enabledSaveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
enabledSendButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onRejectInteraction).toHaveBeenCalledWith(
|
||||
@@ -403,6 +507,35 @@ describe("IssueThreadInteractionCard", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("standardizes the bare-reject button to Reject even when the payload carries a legacy rejectLabel", () => {
|
||||
const host = renderCard({
|
||||
interaction: {
|
||||
...pendingRequestConfirmationInteraction,
|
||||
payload: {
|
||||
...pendingRequestConfirmationInteraction.payload,
|
||||
// Onboarding/plan-approval interactions are still seeded with the
|
||||
// legacy "Request changes" reject label; it must not leak into the CTA.
|
||||
rejectLabel: "Request changes",
|
||||
rejectRequiresReason: false,
|
||||
},
|
||||
},
|
||||
onAcceptInteraction: vi.fn(async () => undefined),
|
||||
onRejectInteraction: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
const labels = Array.from(host.querySelectorAll("button")).map((button) =>
|
||||
button.textContent?.trim(),
|
||||
);
|
||||
|
||||
// Canonical plan-approval grammar, right→left: Approve · Revise… · Reject.
|
||||
// "Revise…" already carries the send-back-with-notes path, so a distinct
|
||||
// "Request changes" word is redundant and must not render.
|
||||
expect(labels).toContain("Reject");
|
||||
expect(labels).toContain("Revise…");
|
||||
expect(labels.some((label) => label?.includes("Approve"))).toBe(true);
|
||||
expect(host.textContent).not.toContain("Request changes");
|
||||
});
|
||||
|
||||
it("does not expose continuation wake policy labels in the card header", () => {
|
||||
const host = renderCard({
|
||||
interaction: {
|
||||
@@ -450,8 +583,10 @@ describe("IssueThreadInteractionCard", () => {
|
||||
onRejectInteraction,
|
||||
});
|
||||
|
||||
// The bare-reject button always renders the canonical "Reject", not the
|
||||
// payload's "Keep it" — ConfirmationActionRow no longer honors the override.
|
||||
const declineButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Keep it"),
|
||||
button.textContent?.trim() === "Reject",
|
||||
);
|
||||
expect(declineButton).toBeTruthy();
|
||||
|
||||
@@ -543,11 +678,11 @@ describe("IssueThreadInteractionCard", () => {
|
||||
onUploadImage,
|
||||
});
|
||||
|
||||
const declineButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Request revisions"),
|
||||
const reviseButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Revise"),
|
||||
);
|
||||
await act(async () => {
|
||||
declineButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
reviseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
const attachButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
@@ -570,11 +705,11 @@ describe("IssueThreadInteractionCard", () => {
|
||||
});
|
||||
expect(onUploadImage).toHaveBeenCalledTimes(1);
|
||||
|
||||
const saveButton = Array.from(host.querySelectorAll("button")).filter((button) =>
|
||||
button.textContent?.includes("Request revisions"),
|
||||
).at(-1);
|
||||
const sendButton = Array.from(host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Send revision"),
|
||||
);
|
||||
await act(async () => {
|
||||
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
sendButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onRejectInteraction).toHaveBeenCalledWith(
|
||||
@@ -790,19 +925,19 @@ describe("IssueThreadInteractionCard tool-action card", () => {
|
||||
expect(host.textContent).not.toContain("Technical details");
|
||||
});
|
||||
|
||||
it("renders the agents-may-resolve policy badge and addressee chip", () => {
|
||||
it("renders the addressee chip without the removed policy badge", () => {
|
||||
const host = renderCard({
|
||||
interaction: agentAddressedRequestConfirmationInteraction,
|
||||
});
|
||||
|
||||
const policyBadge = host.querySelector('[data-testid="interaction-policy-badge"]');
|
||||
expect(policyBadge?.textContent).toContain("Agents may resolve");
|
||||
// PAP-440: the "Agents may resolve" policy badge was pure noise — never rendered.
|
||||
expect(host.querySelector('[data-testid="interaction-policy-badge"]')).toBeNull();
|
||||
|
||||
const addresseeBadge = host.querySelector('[data-testid="interaction-addressee-badge"]');
|
||||
expect(addresseeBadge?.textContent).toContain("For ");
|
||||
});
|
||||
|
||||
it("omits the policy and addressee badges for a board-only interaction", () => {
|
||||
it("omits the addressee badge for a board-only interaction", () => {
|
||||
const host = renderCard({
|
||||
interaction: pendingRequestConfirmationInteraction,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { AlertTriangle, ArrowUpRight, Bot, Check, CheckCircle2, ChevronDown, ChevronRight, CircleDashed, Clock, ExternalLink, FileText, GitBranch, ImagePlus, Loader2, MessageSquareQuote, MinusCircle, ShieldAlert, ThumbsUp, TriangleAlert, Users, Wrench, X, XCircle } from "lucide-react";
|
||||
import { AlertTriangle, ArrowUpRight, Bot, Check, CheckCircle2, ChevronDown, ChevronRight, CircleDashed, Clock, ExternalLink, FileText, GitBranch, ImagePlus, Loader2, MessageSquareQuote, MinusCircle, ShieldAlert, ThumbsUp, TriangleAlert, Wrench, X, XCircle } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import {
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getCheckboxConfirmationSelectedLabels,
|
||||
getItemVerdictProgress,
|
||||
getQuestionAnswerLabels,
|
||||
shouldHideInteractionCard,
|
||||
normalizeRequestConfirmationTargetHref,
|
||||
type AskUserQuestionsAnswer,
|
||||
type AskUserQuestionsInteraction,
|
||||
@@ -989,8 +990,16 @@ function AskUserQuestionsCard({
|
||||
),
|
||||
);
|
||||
|
||||
function toggleOption(questionId: string, optionId: string, selectionMode: "single" | "multi") {
|
||||
if (optionId === OTHER_ANSWER_ID) {
|
||||
function toggleOption(
|
||||
questionId: string,
|
||||
optionId: string,
|
||||
selectionMode: "single" | "multi",
|
||||
isFreeText = false,
|
||||
) {
|
||||
// A free-text option is a first-class version of the built-in "Other"
|
||||
// affordance: selecting it reveals the inline text field and its typed
|
||||
// value is submitted as the question's `otherText`.
|
||||
if (optionId === OTHER_ANSWER_ID || isFreeText) {
|
||||
setOtherActiveQuestions((current) => ({
|
||||
...current,
|
||||
[questionId]: !current[questionId],
|
||||
@@ -1064,7 +1073,11 @@ function AskUserQuestionsCard({
|
||||
|
||||
{interaction.status === "pending" ? (
|
||||
<div className="space-y-4">
|
||||
{questions.map((question, index) => (
|
||||
{questions.map((question, index) => {
|
||||
const hasFreeTextOption = question.options.some(
|
||||
(option) => option.freeText === true,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={question.id}
|
||||
className="rounded-2xl border border-border/70 bg-background/82 p-4 shadow-(--shadow-extract-9)"
|
||||
@@ -1099,50 +1112,82 @@ function AskUserQuestionsCard({
|
||||
role={question.selectionMode === "single" ? "radiogroup" : "group"}
|
||||
aria-labelledby={`${interaction.id}-${question.id}-prompt`}
|
||||
>
|
||||
{question.options.map((option) => (
|
||||
<QuestionOptionButton
|
||||
key={option.id}
|
||||
id={`${interaction.id}-${question.id}-${option.id}`}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
selected={(draftAnswers[question.id] ?? []).includes(option.id)}
|
||||
selectionMode={question.selectionMode}
|
||||
onClick={() =>
|
||||
toggleOption(question.id, option.id, question.selectionMode)}
|
||||
/>
|
||||
))}
|
||||
{question.options.map((option) => {
|
||||
const isFreeText = option.freeText === true;
|
||||
const optionSelected = isFreeText
|
||||
? otherActiveQuestions[question.id] === true
|
||||
: (draftAnswers[question.id] ?? []).includes(option.id);
|
||||
return (
|
||||
<div key={option.id} className="space-y-2">
|
||||
<QuestionOptionButton
|
||||
id={`${interaction.id}-${question.id}-${option.id}`}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
selected={optionSelected}
|
||||
selectionMode={question.selectionMode}
|
||||
onClick={() =>
|
||||
toggleOption(question.id, option.id, question.selectionMode, isFreeText)}
|
||||
/>
|
||||
{isFreeText && optionSelected ? (
|
||||
<Textarea
|
||||
aria-label={`Describe your answer for ${question.prompt}`}
|
||||
value={draftOtherAnswers[question.id] ?? ""}
|
||||
onChange={(event) =>
|
||||
setDraftOtherAnswers((current) => ({
|
||||
...current,
|
||||
[question.id]: event.target.value,
|
||||
}))}
|
||||
placeholder="Type your answer"
|
||||
className="min-h-24 bg-background text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
id={`${interaction.id}-${question.id}-other`}
|
||||
aria-expanded={otherActiveQuestions[question.id] === true}
|
||||
className={cn(
|
||||
"text-sm font-medium underline underline-offset-4 transition-colors outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring/50",
|
||||
otherActiveQuestions[question.id]
|
||||
? "text-sky-700 hover:text-sky-800 dark:text-sky-300 dark:hover:text-sky-200"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() =>
|
||||
toggleOption(question.id, OTHER_ANSWER_ID, question.selectionMode)}
|
||||
>
|
||||
Other
|
||||
</button>
|
||||
{otherActiveQuestions[question.id] ? (
|
||||
<Textarea
|
||||
aria-label={`Other answer for ${question.prompt}`}
|
||||
value={draftOtherAnswers[question.id] ?? ""}
|
||||
onChange={(event) =>
|
||||
setDraftOtherAnswers((current) => ({
|
||||
...current,
|
||||
[question.id]: event.target.value,
|
||||
}))}
|
||||
placeholder="Type your answer"
|
||||
className="min-h-24 bg-background text-sm"
|
||||
/>
|
||||
) : null}
|
||||
{/*
|
||||
* The built-in "Other" link is the fallback free-text affordance.
|
||||
* Suppress it when the agent already authored a first-class
|
||||
* free-text option so the card never shows two ways to type an
|
||||
* answer (PAP-419).
|
||||
*/}
|
||||
{hasFreeTextOption ? null : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
id={`${interaction.id}-${question.id}-other`}
|
||||
aria-expanded={otherActiveQuestions[question.id] === true}
|
||||
className={cn(
|
||||
"text-sm font-medium underline underline-offset-4 transition-colors outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring/50",
|
||||
otherActiveQuestions[question.id]
|
||||
? "text-sky-700 hover:text-sky-800 dark:text-sky-300 dark:hover:text-sky-200"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() =>
|
||||
toggleOption(question.id, OTHER_ANSWER_ID, question.selectionMode)}
|
||||
>
|
||||
Other
|
||||
</button>
|
||||
{otherActiveQuestions[question.id] ? (
|
||||
<Textarea
|
||||
aria-label={`Other answer for ${question.prompt}`}
|
||||
value={draftOtherAnswers[question.id] ?? ""}
|
||||
onChange={(event) =>
|
||||
setDraftOtherAnswers((current) => ({
|
||||
...current,
|
||||
[question.id]: event.target.value,
|
||||
}))}
|
||||
placeholder="Type your answer"
|
||||
className="min-h-24 bg-background text-sm"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-border/70 bg-background/75 p-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
@@ -1878,6 +1923,204 @@ function RequestToolActionCard({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The single approval grammar shared by every plan / task-approval card
|
||||
* (PAP-418): **Approve · Revise… · Reject**. "Revise…" reveals an attached text
|
||||
* field for the changes you want (the former "decline with a reason" path);
|
||||
* bare "Reject" sends the work back with no note. These are the default words —
|
||||
* producers may still override the accept/reject labels for domain-specific
|
||||
* confirmations (e.g. "Delete selected"), but the shape stays consistent.
|
||||
*/
|
||||
const CONFIRMATION_APPROVE_LABEL = "Approve";
|
||||
const CONFIRMATION_REVISE_LABEL = "Revise…";
|
||||
const CONFIRMATION_REJECT_LABEL = "Reject";
|
||||
|
||||
/**
|
||||
* The one action control every confirmation card renders (PAP-418), collapsing
|
||||
* what used to be a two-button card plus a separate sticky Plan-pane bar into a
|
||||
* single consistent surface. Producer flags tune which affordances appear:
|
||||
* `allowDeclineReason: false` drops the Revise… path so only Approve/Reject
|
||||
* remain; `rejectRequiresReason: true` drops the bare Reject so every rejection
|
||||
* carries a note. The revise text stays attached to the card.
|
||||
*/
|
||||
function ConfirmationActionRow({
|
||||
resetKey,
|
||||
approveLabel,
|
||||
reviseLabel = CONFIRMATION_REVISE_LABEL,
|
||||
rejectLabel,
|
||||
approveVariant = "default",
|
||||
primaryActionOnRight = false,
|
||||
allowRevise,
|
||||
rejectRequiresReason,
|
||||
reasonPlaceholder,
|
||||
working,
|
||||
actionError,
|
||||
approveDisabled = false,
|
||||
canApprove,
|
||||
canReject,
|
||||
onApprove,
|
||||
onReject,
|
||||
composeReason,
|
||||
extraReasonSatisfied = false,
|
||||
revisePanelChildren,
|
||||
}: {
|
||||
/** Changing this (interaction id + status) collapses the revise panel and
|
||||
* clears its draft text — the row is reused across interaction updates. */
|
||||
resetKey: string;
|
||||
approveLabel: string;
|
||||
reviseLabel?: string;
|
||||
rejectLabel: string;
|
||||
approveVariant?: React.ComponentProps<typeof Button>["variant"];
|
||||
primaryActionOnRight?: boolean;
|
||||
allowRevise: boolean;
|
||||
rejectRequiresReason: boolean;
|
||||
reasonPlaceholder: string;
|
||||
working: "accept" | "reject" | null;
|
||||
actionError: string | null;
|
||||
approveDisabled?: boolean;
|
||||
canApprove: boolean;
|
||||
canReject: boolean;
|
||||
onApprove: () => void;
|
||||
onReject: (reason: string | undefined) => void;
|
||||
/** Compose the final reject reason from the typed text (plan cards append
|
||||
* screenshot markdown here). */
|
||||
composeReason?: (text: string) => string | undefined;
|
||||
/** A required reason is already satisfied by an attachment (e.g. screenshots),
|
||||
* so an empty text box should not block sending the revision. */
|
||||
extraReasonSatisfied?: boolean;
|
||||
/** Extra affordances rendered inside the revise panel (e.g. screenshot attach). */
|
||||
revisePanelChildren?: ReactNode;
|
||||
}) {
|
||||
const [revising, setRevising] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [attempted, setAttempted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setRevising(false);
|
||||
setReason("");
|
||||
setAttempted(false);
|
||||
}, [resetKey]);
|
||||
|
||||
const trimmed = reason.trim();
|
||||
const reasonMissing = rejectRequiresReason && trimmed.length === 0 && !extraReasonSatisfied;
|
||||
|
||||
function submitRevision() {
|
||||
setAttempted(true);
|
||||
if (!canReject || reasonMissing) return;
|
||||
onReject(composeReason ? composeReason(reason) : trimmed || undefined);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap items-center justify-end gap-2",
|
||||
primaryActionOnRight && "flex-row-reverse justify-start",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={revising ? "outline" : approveVariant}
|
||||
disabled={!canApprove || working !== null || approveDisabled}
|
||||
onClick={onApprove}
|
||||
>
|
||||
{working === "accept" ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Approving…
|
||||
</>
|
||||
) : (
|
||||
approveLabel
|
||||
)}
|
||||
</Button>
|
||||
{allowRevise ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!canReject || working !== null}
|
||||
onClick={() => {
|
||||
setAttempted(false);
|
||||
setRevising((current) => !current);
|
||||
}}
|
||||
>
|
||||
{reviseLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
{!rejectRequiresReason ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={!canReject || working !== null}
|
||||
onClick={() => onReject(undefined)}
|
||||
>
|
||||
{working === "reject" && !revising ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Rejecting…
|
||||
</>
|
||||
) : (
|
||||
rejectLabel
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{revising ? (
|
||||
<div className="space-y-3 rounded-sm border border-border/70 bg-background/75 p-3">
|
||||
<Textarea
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder={reasonPlaceholder}
|
||||
aria-invalid={attempted && reasonMissing}
|
||||
className={cn(
|
||||
"min-h-24 bg-background text-sm",
|
||||
attempted && reasonMissing && "border-rose-500 focus-visible:ring-rose-500/25",
|
||||
)}
|
||||
/>
|
||||
{attempted && reasonMissing ? (
|
||||
<p className="text-xs text-destructive">Add a note describing the changes you want.</p>
|
||||
) : null}
|
||||
{revisePanelChildren}
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={working !== null}
|
||||
onClick={() => {
|
||||
setRevising(false);
|
||||
setAttempted(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!canReject || working !== null}
|
||||
onClick={submitRevision}
|
||||
>
|
||||
{working === "reject" ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Sending…
|
||||
</>
|
||||
) : (
|
||||
"Send revision"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{actionError ? (
|
||||
<div className="rounded-sm border border-destructive/60 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{actionError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestConfirmationCard({
|
||||
interaction,
|
||||
isPlan = false,
|
||||
@@ -1900,37 +2143,28 @@ function RequestConfirmationCard({
|
||||
onUploadImage?: (file: File) => Promise<string>;
|
||||
externalReferences?: MarkdownExternalReferenceMap;
|
||||
}) {
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const [working, setWorking] = useState<"accept" | "reject" | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState(interaction.result?.reason ?? "");
|
||||
const [rejectAttempted, setRejectAttempted] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [shots, setShots] = useState<{ name: string; url: string }[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
// Screenshots ride along in the decline reason as markdown image refs so the
|
||||
// Screenshots ride along in the revise note as markdown image refs so the
|
||||
// board can attach images when sending a plan back — no schema change needed.
|
||||
const allowScreenshots = isPlan && Boolean(onUploadImage);
|
||||
const rejectRequiresReason = interaction.payload.rejectRequiresReason === true;
|
||||
const allowDeclineReason = interaction.payload.allowDeclineReason !== false;
|
||||
const trimmedRejectReason = rejectReason.trim();
|
||||
const canReject = !rejectRequiresReason || trimmedRejectReason.length > 0 || shots.length > 0;
|
||||
const declineReasonInvalid = rejectRequiresReason && !canReject;
|
||||
const declineReasonPlaceholder =
|
||||
const allowRevise = interaction.payload.allowDeclineReason !== false;
|
||||
const reasonPlaceholder =
|
||||
interaction.payload.declineReasonPlaceholder
|
||||
?? (interaction.payload.acceptLabel === "Approve plan"
|
||||
? "Optional: what would you like revised?"
|
||||
: "Optional: tell the agent what you'd change.");
|
||||
|
||||
useEffect(() => {
|
||||
setRejectReason(interaction.result?.reason ?? "");
|
||||
setRejectAttempted(false);
|
||||
setActionError(null);
|
||||
setShots([]);
|
||||
setUploadError(null);
|
||||
if (interaction.status !== "pending") {
|
||||
setRejecting(false);
|
||||
setWorking(null);
|
||||
}
|
||||
}, [interaction.id, interaction.result?.reason, interaction.status]);
|
||||
@@ -1954,11 +2188,11 @@ function RequestConfirmationCard({
|
||||
}
|
||||
}
|
||||
|
||||
function composeReason() {
|
||||
const text = trimmedRejectReason;
|
||||
if (shots.length === 0) return text || undefined;
|
||||
function composeReason(text: string) {
|
||||
const trimmed = text.trim();
|
||||
if (shots.length === 0) return trimmed || undefined;
|
||||
const images = shots.map((s) => ``).join("\n");
|
||||
return [text, images].filter(Boolean).join("\n\n");
|
||||
return [trimmed, images].filter(Boolean).join("\n\n");
|
||||
}
|
||||
|
||||
async function handleAccept() {
|
||||
@@ -1974,14 +2208,12 @@ function RequestConfirmationCard({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
setRejectAttempted(true);
|
||||
if (!onRejectInteraction || !canReject) return;
|
||||
async function handleReject(reason: string | undefined) {
|
||||
if (!onRejectInteraction) return;
|
||||
setWorking("reject");
|
||||
setActionError(null);
|
||||
try {
|
||||
await onRejectInteraction(interaction, composeReason());
|
||||
setRejecting(false);
|
||||
await onRejectInteraction(interaction, reason);
|
||||
} catch {
|
||||
setActionError("Try again");
|
||||
} finally {
|
||||
@@ -2009,161 +2241,89 @@ function RequestConfirmationCard({
|
||||
) : null}
|
||||
|
||||
{interaction.status === "pending" ? (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap items-center justify-end gap-2",
|
||||
primaryActionOnRight && "flex-row-reverse justify-start",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={rejecting ? "outline" : isPlan ? "cta" : "default"}
|
||||
disabled={!onAcceptInteraction || working !== null}
|
||||
onClick={() => void handleAccept()}
|
||||
>
|
||||
{working === "accept" ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Confirming...
|
||||
</>
|
||||
) : (
|
||||
interaction.payload.acceptLabel ?? "Confirm"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!onRejectInteraction || working !== null}
|
||||
onClick={() => {
|
||||
if (!allowDeclineReason) {
|
||||
void handleReject();
|
||||
return;
|
||||
}
|
||||
setRejectAttempted(false);
|
||||
setRejecting((current) => !current);
|
||||
}}
|
||||
>
|
||||
{interaction.payload.rejectLabel ?? "Decline"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{rejecting ? (
|
||||
<div className="space-y-3 rounded-sm border border-border/70 bg-background/75 p-3">
|
||||
<Textarea
|
||||
value={rejectReason}
|
||||
onChange={(event) => setRejectReason(event.target.value)}
|
||||
placeholder={declineReasonPlaceholder}
|
||||
aria-invalid={rejectAttempted && declineReasonInvalid}
|
||||
className={cn(
|
||||
"min-h-24 bg-background text-sm",
|
||||
rejectAttempted && declineReasonInvalid
|
||||
&& "border-rose-500 focus-visible:ring-rose-500/25",
|
||||
)}
|
||||
/>
|
||||
{rejectAttempted && declineReasonInvalid ? (
|
||||
<p className="text-xs text-destructive">A decline reason is required.</p>
|
||||
) : null}
|
||||
{allowScreenshots ? (
|
||||
<div className="space-y-2">
|
||||
{shots.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{shots.map((shot, index) => (
|
||||
<div
|
||||
key={`${shot.url}-${index}`}
|
||||
className="group relative h-16 w-16 overflow-hidden rounded-sm border border-border/70"
|
||||
<ConfirmationActionRow
|
||||
resetKey={`${interaction.id}:${interaction.status}`}
|
||||
approveLabel={interaction.payload.acceptLabel ?? CONFIRMATION_APPROVE_LABEL}
|
||||
rejectLabel={CONFIRMATION_REJECT_LABEL}
|
||||
approveVariant={isPlan ? "cta" : "default"}
|
||||
primaryActionOnRight={primaryActionOnRight}
|
||||
allowRevise={allowRevise}
|
||||
rejectRequiresReason={rejectRequiresReason}
|
||||
reasonPlaceholder={reasonPlaceholder}
|
||||
working={working}
|
||||
actionError={actionError}
|
||||
canApprove={Boolean(onAcceptInteraction)}
|
||||
canReject={Boolean(onRejectInteraction)}
|
||||
onApprove={() => void handleAccept()}
|
||||
onReject={(reason) => void handleReject(reason)}
|
||||
composeReason={composeReason}
|
||||
extraReasonSatisfied={shots.length > 0}
|
||||
revisePanelChildren={
|
||||
allowScreenshots ? (
|
||||
<div className="space-y-2">
|
||||
{shots.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{shots.map((shot, index) => (
|
||||
<div
|
||||
key={`${shot.url}-${index}`}
|
||||
className="group relative h-16 w-16 overflow-hidden rounded-sm border border-border/70"
|
||||
>
|
||||
<img
|
||||
src={shot.url}
|
||||
alt={shot.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${shot.name}`}
|
||||
className="absolute right-0.5 top-0.5 rounded-full bg-background/90 p-0.5 text-foreground opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={() =>
|
||||
setShots((current) => current.filter((_, i) => i !== index))
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={shot.url}
|
||||
alt={shot.name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${shot.name}`}
|
||||
className="absolute right-0.5 top-0.5 rounded-full bg-background/90 p-0.5 text-foreground opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={() =>
|
||||
setShots((current) => current.filter((_, i) => i !== index))
|
||||
}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void handleAddScreenshots(event.target.value ? event.target.files : null);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={working !== null || uploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ImagePlus className="mr-2 h-3.5 w-3.5" />
|
||||
Attach screenshots
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{uploadError ? (
|
||||
<p className="text-xs text-destructive">{uploadError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={working !== null}
|
||||
onClick={() => {
|
||||
setRejecting(false);
|
||||
setRejectAttempted(false);
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void handleAddScreenshots(event.target.value ? event.target.files : null);
|
||||
event.target.value = "";
|
||||
}}
|
||||
>
|
||||
Cancel decline
|
||||
</Button>
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!onRejectInteraction || working !== null}
|
||||
onClick={() => void handleReject()}
|
||||
disabled={working !== null || uploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{working === "reject" ? (
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Saving...
|
||||
Uploading...
|
||||
</>
|
||||
) : (
|
||||
interaction.payload.rejectLabel ?? "Decline"
|
||||
<>
|
||||
<ImagePlus className="mr-2 h-3.5 w-3.5" />
|
||||
Attach screenshots
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{uploadError ? (
|
||||
<p className="text-xs text-destructive">{uploadError}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{actionError ? (
|
||||
<div className="rounded-sm border border-destructive/60 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{actionError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<RequestConfirmationResolution interaction={interaction} />
|
||||
)}
|
||||
@@ -2293,11 +2453,13 @@ function CheckboxOptionRow({
|
||||
|
||||
function RequestCheckboxConfirmationCard({
|
||||
interaction,
|
||||
primaryActionOnRight = false,
|
||||
onAcceptInteraction,
|
||||
onRejectInteraction,
|
||||
externalReferences,
|
||||
}: {
|
||||
interaction: RequestCheckboxConfirmationInteraction;
|
||||
primaryActionOnRight?: boolean;
|
||||
onAcceptInteraction?: (
|
||||
interaction: RequestCheckboxConfirmationInteraction,
|
||||
selectedClientKeys: undefined,
|
||||
@@ -2324,10 +2486,7 @@ function RequestCheckboxConfirmationCard({
|
||||
);
|
||||
|
||||
const [selectedOptionIds, setSelectedOptionIds] = useState<Set<string>>(() => new Set(defaultSelected));
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const [working, setWorking] = useState<"accept" | "reject" | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState(interaction.result?.reason ?? "");
|
||||
const [rejectAttempted, setRejectAttempted] = useState(false);
|
||||
const [acceptAttempted, setAcceptAttempted] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
@@ -2335,22 +2494,16 @@ function RequestCheckboxConfirmationCard({
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedOptionIds(new Set(defaultSelected));
|
||||
setRejectReason(interaction.result?.reason ?? "");
|
||||
setRejectAttempted(false);
|
||||
setAcceptAttempted(false);
|
||||
setActionError(null);
|
||||
if (interaction.status !== "pending") {
|
||||
setRejecting(false);
|
||||
setWorking(null);
|
||||
}
|
||||
}, [interaction.id, interaction.status, interaction.result?.reason, defaultSelected, optionSeed]);
|
||||
|
||||
const rejectRequiresReason = interaction.payload.rejectRequiresReason === true;
|
||||
const allowDeclineReason = interaction.payload.allowDeclineReason !== false;
|
||||
const trimmedRejectReason = rejectReason.trim();
|
||||
const canReject = !rejectRequiresReason || trimmedRejectReason.length > 0;
|
||||
const declineReasonInvalid = rejectRequiresReason && !canReject;
|
||||
const declineReasonPlaceholder =
|
||||
const allowRevise = interaction.payload.allowDeclineReason !== false;
|
||||
const reasonPlaceholder =
|
||||
interaction.payload.declineReasonPlaceholder ?? "Optional: tell the agent what you'd change.";
|
||||
|
||||
const selectedCount = selectedOptionIds.size;
|
||||
@@ -2405,14 +2558,12 @@ function RequestCheckboxConfirmationCard({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
setRejectAttempted(true);
|
||||
if (!onRejectInteraction || !canReject) return;
|
||||
async function handleReject(reason: string | undefined) {
|
||||
if (!onRejectInteraction) return;
|
||||
setWorking("reject");
|
||||
setActionError(null);
|
||||
try {
|
||||
await onRejectInteraction(interaction, trimmedRejectReason || undefined);
|
||||
setRejecting(false);
|
||||
await onRejectInteraction(interaction, reason);
|
||||
} catch {
|
||||
setActionError("Try again");
|
||||
} finally {
|
||||
@@ -2503,91 +2654,21 @@ function RequestCheckboxConfirmationCard({
|
||||
<p className="text-xs text-destructive">{validationMessage}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={rejecting ? "outline" : "default"}
|
||||
disabled={!onAcceptInteraction || working !== null}
|
||||
onClick={() => void handleAccept()}
|
||||
>
|
||||
{working === "accept" ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Confirming...
|
||||
</>
|
||||
) : (
|
||||
interaction.payload.acceptLabel ?? "Confirm selected"
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!onRejectInteraction || working !== null}
|
||||
onClick={() => {
|
||||
if (!allowDeclineReason) {
|
||||
void handleReject();
|
||||
return;
|
||||
}
|
||||
setRejectAttempted(false);
|
||||
setRejecting((current) => !current);
|
||||
}}
|
||||
>
|
||||
{interaction.payload.rejectLabel ?? "Request changes"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{rejecting ? (
|
||||
<div className="space-y-3 rounded-sm border border-border/70 bg-background/75 p-3">
|
||||
<Textarea
|
||||
value={rejectReason}
|
||||
onChange={(event) => setRejectReason(event.target.value)}
|
||||
placeholder={declineReasonPlaceholder}
|
||||
aria-invalid={rejectAttempted && declineReasonInvalid}
|
||||
className={cn(
|
||||
"min-h-24 bg-background text-sm",
|
||||
rejectAttempted && declineReasonInvalid
|
||||
&& "border-rose-500 focus-visible:ring-rose-500/25",
|
||||
)}
|
||||
/>
|
||||
{rejectAttempted && declineReasonInvalid ? (
|
||||
<p className="text-xs text-destructive">A reason is required.</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={working !== null}
|
||||
onClick={() => {
|
||||
setRejecting(false);
|
||||
setRejectAttempted(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!onRejectInteraction || working !== null}
|
||||
onClick={() => void handleReject()}
|
||||
>
|
||||
{working === "reject" ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
interaction.payload.rejectLabel ?? "Request changes"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{actionError ? (
|
||||
<div className="rounded-sm border border-destructive/60 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{actionError}
|
||||
</div>
|
||||
) : null}
|
||||
<ConfirmationActionRow
|
||||
resetKey={`${interaction.id}:${interaction.status}`}
|
||||
approveLabel={interaction.payload.acceptLabel ?? CONFIRMATION_APPROVE_LABEL}
|
||||
rejectLabel={CONFIRMATION_REJECT_LABEL}
|
||||
primaryActionOnRight={primaryActionOnRight}
|
||||
allowRevise={allowRevise}
|
||||
rejectRequiresReason={rejectRequiresReason}
|
||||
reasonPlaceholder={reasonPlaceholder}
|
||||
working={working}
|
||||
actionError={actionError}
|
||||
canApprove={Boolean(onAcceptInteraction)}
|
||||
canReject={Boolean(onRejectInteraction)}
|
||||
onApprove={() => void handleAccept()}
|
||||
onReject={(reason) => void handleReject(reason)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -3096,6 +3177,16 @@ export function IssueThreadInteractionCard({
|
||||
onUploadImage,
|
||||
externalReferences,
|
||||
}: IssueThreadInteractionCardProps) {
|
||||
// Single enforcement point (PAP-424, plan from PAP-420; extended by PAP-437):
|
||||
// a card that should never be drawn — a degenerate `ask_user_questions`
|
||||
// (placeholder junk like the onboarding `Test / A` card, no genuine question)
|
||||
// or a stale sibling the server auto-expired when its creator posted a newer
|
||||
// question (`superseded_by_newer_interaction`). Every render site (both thread
|
||||
// backbones + the attention resolver) routes through this component, so
|
||||
// suppressing here suppresses it everywhere at once. The interaction is still
|
||||
// created and stored server-side; only the render is suppressed. Composition
|
||||
// sites additionally filter it so no empty slot lingers.
|
||||
if (shouldHideInteractionCard(interaction)) return null;
|
||||
const isPlan = isPlanConfirmation(interaction);
|
||||
const isToolAction =
|
||||
interaction.kind === "request_confirmation" && isToolActionConfirmation(interaction);
|
||||
@@ -3152,8 +3243,6 @@ export function IssueThreadInteractionCard({
|
||||
: null;
|
||||
// P4: audit-visible distinction between agent and human resolution.
|
||||
const resolvedByAgent = Boolean(interaction.resolvedByAgentId);
|
||||
// P2: agents may resolve when the governance-capped policy allows it.
|
||||
const agentsMayResolve = interaction.effectiveResolverPolicy === "board_or_agents";
|
||||
// P3: interactions directed at a specific agent addressee.
|
||||
const addresseeLabel = interaction.addresseeAgentId
|
||||
? resolveActorLabel({
|
||||
@@ -3183,23 +3272,6 @@ export function IssueThreadInteractionCard({
|
||||
<span className="text-current/60">/</span>
|
||||
{statusText}
|
||||
</span>
|
||||
{agentsMayResolve ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-1 border-indigo-500/50 text-indigo-700 dark:text-indigo-200"
|
||||
data-testid="interaction-policy-badge"
|
||||
>
|
||||
<Users className="h-3 w-3" />
|
||||
Agents may resolve
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs text-xs">
|
||||
Governance allows an assigned agent to resolve this interaction without waiting for the board.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{addresseeLabel ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -3275,6 +3347,7 @@ export function IssueThreadInteractionCard({
|
||||
) : interaction.kind === "request_checkbox_confirmation" ? (
|
||||
<RequestCheckboxConfirmationCard
|
||||
interaction={interaction}
|
||||
primaryActionOnRight={primaryActionOnRight}
|
||||
onAcceptInteraction={onAcceptInteraction}
|
||||
onRejectInteraction={onRejectInteraction}
|
||||
externalReferences={externalReferences}
|
||||
|
||||
@@ -82,12 +82,34 @@ function buildMissionFromQuestionnaire(q1: string, q2: string, q3: string, q4: s
|
||||
}
|
||||
|
||||
const ONBOARDING_STORAGE_KEY = "paperclip-onboarding-state";
|
||||
const DEFAULT_TASK_TITLE = "Hire your first engineer and create a hiring plan";
|
||||
const DEFAULT_TASK_DESCRIPTION = `You are the CEO. You set the direction for the company.
|
||||
const DEFAULT_TASK_TITLE = "Paperclip onboarding";
|
||||
const DEFAULT_TASK_DESCRIPTION = `You are the Paperclip agent. This is your first task. Your job here is to
|
||||
understand what the user wants and turn it into a concrete plan — not to
|
||||
start building yet.
|
||||
|
||||
- hire a founding engineer
|
||||
- write a hiring plan
|
||||
- break the roadmap into concrete tasks and start delegating work`;
|
||||
A greeting has already been posted to the user on your behalf, so don't
|
||||
re-introduce yourself — go straight to the questions.
|
||||
|
||||
This is a user-facing chat. Everything you post here is read by the user, so
|
||||
keep your messages terse and written for them. Only surface things meant for
|
||||
the user: the questions, the plan, the team, next-step options, and short
|
||||
status ("Got your answers — here's the plan."). Never narrate how you work.
|
||||
Don't post your internal steps or thinking into the chat — no "let me probe
|
||||
the schema", "schema learned", "building the questions payload", "orienting
|
||||
myself with the API", or similar play-by-play of your API/tool calls. Do that
|
||||
work silently and post only the result.
|
||||
|
||||
Work in this order:
|
||||
|
||||
1. Ask a few focused, clarifying questions. Use an ask_user_questions interaction to settle on one concrete goal to tackle first— scope, priorities, constraints, and what "done" looks like. Don't guess; ask.
|
||||
|
||||
2. Propose one plan. Once you understand the goal, write a short approach plan to the \`plan\` document. At the bottom, list the agents you'd hire (with their roles) and any follow-up tasks you'd create. Then present the whole thing as a SINGLE request_checkbox_confirmation that targets the \`plan\` document, with each proposed hire and follow-up task as its own checkable option, checked by default. Give each option a stable id you can act on later. Do NOT use suggest_tasks or a separate request_confirmation — one checkbox card is the plan and its approval. In the card's message keep the summary to a line or two and point the user to the full write-up in the plan on the right sidebar (it opens to the Plan there automatically) — don't paste the whole plan into the card, and never say the write-up is "above" or "in the plan doc above"; it lives in the right sidebar.
|
||||
|
||||
3. Wait for approval. Don't hire anyone or create work until the user approves the plan. They can uncheck anything they don't want before approving, and unchecking simply drops it. If they ask for changes, revise the plan document and re-confirm.
|
||||
|
||||
4. On approval, execute only what they kept. Create exactly the checked options — hire the checked agents and create + delegate the checked follow-up tasks, each in its own task. Skip anything the user unchecked.
|
||||
|
||||
Propose, don't decide. Keep it conversational.`;
|
||||
const INCOMPLETE_ONBOARDING_STATE_MESSAGE =
|
||||
"Onboarding state is incomplete. Please restart onboarding and try again.";
|
||||
|
||||
@@ -440,7 +462,8 @@ export function OnboardingWizard() {
|
||||
setCreatedProjectId(projectId);
|
||||
}
|
||||
|
||||
if (!createdIssueRef) {
|
||||
let issueRef = createdIssueRef;
|
||||
if (!issueRef) {
|
||||
const issue = await issuesApi.create(
|
||||
createdCompanyId,
|
||||
buildOnboardingIssuePayload({
|
||||
@@ -451,17 +474,24 @@ export function OnboardingWizard() {
|
||||
goalId
|
||||
})
|
||||
);
|
||||
setCreatedIssueRef(issue.identifier ?? issue.id);
|
||||
issueRef = issue.identifier ?? issue.id;
|
||||
setCreatedIssueRef(issueRef);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.list(createdCompanyId)
|
||||
});
|
||||
}
|
||||
|
||||
const prefix = createdCompanyPrefix;
|
||||
setSelectedCompanyId(createdCompanyId);
|
||||
// Select the new company as a route sync, not a manual switch: the
|
||||
// explicit navigate below is the intended destination, so page-memory's
|
||||
// "restore last page" (which falls back to /dashboard) must not fire and
|
||||
// clobber the first-task URL. See PAP-404.
|
||||
setSelectedCompanyId(createdCompanyId, { source: "route_sync" });
|
||||
reset();
|
||||
closeOnboarding();
|
||||
navigate(prefix ? `/${prefix}/dashboard` : "/dashboard");
|
||||
// Drop the user straight into the first task's detail page (not the
|
||||
// dashboard) so they land on the conversation the agent will start in.
|
||||
navigate(prefix ? `/${prefix}/issues/${issueRef}` : `/issues/${issueRef}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to launch first task");
|
||||
} finally {
|
||||
@@ -820,7 +850,7 @@ export function OnboardingWizard() {
|
||||
morph reads as one capsule coming to life — dashed slot →
|
||||
solid (configured) → liquid fill + blue glow (online). */}
|
||||
{step >= 3 && step <= 5 && (
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="mb-6 space-y-4">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="bg-muted/50 p-2">
|
||||
{step === 5 ? (
|
||||
@@ -832,7 +862,7 @@ export function OnboardingWizard() {
|
||||
<div>
|
||||
<h3 className="font-medium">
|
||||
{step === 3
|
||||
? "Create your team lead"
|
||||
? "Create your first agent"
|
||||
: step === 4
|
||||
? "Connect a model"
|
||||
: "Review"}
|
||||
@@ -840,40 +870,47 @@ export function OnboardingWizard() {
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{step === 3 ? (
|
||||
<>
|
||||
Name your lead. They'll help drive{" "}
|
||||
They'll help drive{" "}
|
||||
<span className="font-medium text-foreground">{companyName}</span>{" "}
|
||||
toward its mission. We default to{" "}
|
||||
<span className="font-medium text-foreground">Chief of staff</span> —
|
||||
rename it to anything you like.
|
||||
<span className="font-medium text-foreground">Chief of staff</span>.
|
||||
Rename it to anything you like.
|
||||
</>
|
||||
) : step === 4 ? (
|
||||
<>Pick the adapter and model your lead will run on, then check the environment.</>
|
||||
) : (
|
||||
<>Everything's set up — your team lead is online and ready to work.</>
|
||||
<>Your first agent is online and ready to work.</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-1.5 py-1 text-center">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center py-1 text-center",
|
||||
step === 5 ? "mt-8 gap-2.5" : "gap-1.5"
|
||||
)}
|
||||
>
|
||||
<AgentCapsule
|
||||
state={step === 3 ? "slot" : step === 4 ? "configured" : "online"}
|
||||
gradient={5}
|
||||
glow="blue"
|
||||
size="md"
|
||||
/>
|
||||
<p className="text-(length:--text-micro) text-muted-foreground">
|
||||
{step === 3 ? (
|
||||
"an empty slot for an agent"
|
||||
) : step === 4 ? (
|
||||
"your team lead, taking shape"
|
||||
) : (
|
||||
<>
|
||||
<span className="font-medium text-foreground">{agentName}</span>{" "}
|
||||
is online and ready to work!
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{step !== 3 && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-muted-foreground",
|
||||
step === 5 ? "text-sm" : "text-(length:--text-micro)"
|
||||
)}
|
||||
>
|
||||
{step === 4 ? (
|
||||
"your team lead, taking shape"
|
||||
) : (
|
||||
<span className="font-medium text-foreground">{agentName}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -973,9 +1010,9 @@ export function OnboardingWizard() {
|
||||
<Building2 className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium">Name your company</h3>
|
||||
<h3 className="font-medium">Name your organization</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
What should we call your company?
|
||||
What should we call your team or company?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -988,7 +1025,7 @@ export function OnboardingWizard() {
|
||||
: "text-muted-foreground group-focus-within:text-foreground"
|
||||
)}
|
||||
>
|
||||
Company name
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||
@@ -1030,7 +1067,7 @@ export function OnboardingWizard() {
|
||||
</div>
|
||||
|
||||
{/* Mission path selector */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 pt-3">
|
||||
<label className="text-xs text-foreground block">
|
||||
How would you like to define your mission?
|
||||
</label>
|
||||
@@ -1202,13 +1239,6 @@ export function OnboardingWizard() {
|
||||
You can always change your mission later in settings.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="text-(length:--text-micro) text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => setStep(1)}
|
||||
>
|
||||
← Change company name
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1590,7 +1620,7 @@ export function OnboardingWizard() {
|
||||
{/* Review checklist — everything that's now set up */}
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ label: "Company name", done: Boolean(companyName.trim()) },
|
||||
{ label: "Organization name", done: Boolean(companyName.trim()) },
|
||||
{ label: "Mission", done: Boolean(companyGoal.trim()) },
|
||||
{ label: "Agent created", done: Boolean(createdAgentId) },
|
||||
{ label: "Model connected", done: Boolean(createdAgentId) },
|
||||
@@ -1612,15 +1642,6 @@ export function OnboardingWizard() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{companyGoal.trim() && (
|
||||
<p className="text-sm text-muted-foreground italic text-center">
|
||||
"{companyGoal}"
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
We'll create the first task for {agentName} and take you to the dashboard.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1695,7 +1716,7 @@ export function OnboardingWizard() {
|
||||
) : (
|
||||
<ArrowRight className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
{loading ? "Bringing to life..." : "Give it a heartbeat"}
|
||||
{loading ? "Connecting..." : "Connect"}
|
||||
</Button>
|
||||
)}
|
||||
{step === 5 && (
|
||||
|
||||
@@ -72,10 +72,10 @@ describe("PropertiesPanel", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("flag off (current behavior)", () => {
|
||||
describe("classic task interface on (legacy panel)", () => {
|
||||
beforeEach(() => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableTaskChatRedesign: false,
|
||||
enableClassicTaskInterface: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,10 +98,10 @@ describe("PropertiesPanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("flag on (task chat redesign)", () => {
|
||||
describe("classic task interface off (default resizable pane)", () => {
|
||||
beforeEach(() => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableTaskChatRedesign: true,
|
||||
enableClassicTaskInterface: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Maximize2, Minimize2, X } from "lucide-react";
|
||||
import { usePanel } from "../context/PanelContext";
|
||||
import { useTaskChatRedesignEnabled } from "../hooks/useTaskChatRedesignEnabled";
|
||||
import { useClassicTaskInterfaceEnabled } from "../hooks/useClassicTaskInterfaceEnabled";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
export function PropertiesPanel() {
|
||||
const { panelContent, panelVisible, setPanelVisible } = usePanel();
|
||||
const { enabled: redesignEnabled } = useTaskChatRedesignEnabled();
|
||||
const { enabled: classicTaskInterfaceEnabled } = useClassicTaskInterfaceEnabled();
|
||||
|
||||
if (!panelContent) return null;
|
||||
|
||||
if (!redesignEnabled) {
|
||||
if (classicTaskInterfaceEnabled) {
|
||||
return (
|
||||
<aside
|
||||
className="hidden md:flex border-l border-border bg-card flex-col shrink-0 overflow-hidden transition-(--tp-width-opacity) duration-200 ease-in-out h-full"
|
||||
@@ -43,8 +43,8 @@ export function PropertiesPanel() {
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* Task Chat Redesign (flag: enableTaskChatRedesign) — resizable/maximizable
|
||||
* variant. Everything below renders only when the flag is ON.
|
||||
* Chat-style (default) resizable/maximizable variant. Everything below
|
||||
* renders only when the Classic Task Interface flag is OFF.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
|
||||
@@ -242,7 +242,7 @@ describe("SidebarCompanyMenu", () => {
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).toContain("Create new company...");
|
||||
expect(document.body.textContent).toContain("Create new organization...");
|
||||
expect(document.body.textContent).not.toContain("Add company...");
|
||||
|
||||
act(() => {
|
||||
@@ -281,9 +281,9 @@ describe("SidebarCompanyMenu", () => {
|
||||
expect(document.body.textContent).toContain("Edit");
|
||||
expect(document.body.textContent).toContain("Strata");
|
||||
expect(document.body.textContent).toContain("ANA");
|
||||
expect(document.body.textContent).toContain("Create new company...");
|
||||
expect(document.body.textContent).toContain("Create new organization...");
|
||||
expect(document.body.textContent).toContain("Invite people to Acme Labs");
|
||||
expect(document.body.textContent).toContain("Company settings");
|
||||
expect(document.body.textContent).not.toContain("Company settings");
|
||||
expect(document.body.textContent).toContain("Sign out");
|
||||
|
||||
const signOutButton = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
@@ -412,7 +412,7 @@ describe("SidebarCompanyMenu", () => {
|
||||
await openMenu("Open Acme Labs company switcher");
|
||||
|
||||
const createItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
.find((element) => element.textContent?.includes("Create new company..."));
|
||||
.find((element) => element.textContent?.includes("Create new organization..."));
|
||||
expect(createItem).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
@@ -494,7 +494,7 @@ describe("SidebarCompanyMenu", () => {
|
||||
|
||||
expect(document.body.textContent).toContain("Switch organization");
|
||||
expect(document.body.textContent).toContain("Create new organization...");
|
||||
expect(document.body.textContent).toContain("Organization settings");
|
||||
expect(document.body.textContent).not.toContain("Organization settings");
|
||||
expect(document.body.textContent).not.toContain("Switch company");
|
||||
expect(document.body.textContent).not.toContain("Create new company...");
|
||||
expect(document.body.textContent).not.toContain("Company settings");
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
GripVertical,
|
||||
LogOut,
|
||||
Plus,
|
||||
Settings,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -309,7 +308,10 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
||||
if (createStackUrl) navigateTopLevel(createStackUrl);
|
||||
return;
|
||||
}
|
||||
openOnboarding();
|
||||
// Skip the front-door "how would you like to get started?" choice and land
|
||||
// directly on "Name your organization" — this entry point is unambiguously
|
||||
// "create a new company" (PAP-431).
|
||||
openOnboarding({ initialStep: 1 });
|
||||
}
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
@@ -451,7 +453,7 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
||||
disabled={isEditingOrder}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
<span>{isCloud ? "Create new organization..." : "Create new company..."}</span>
|
||||
<span>Create new organization...</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
@@ -473,21 +475,6 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
||||
</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild disabled={isEditingOrder}>
|
||||
<Link
|
||||
to="/company/settings"
|
||||
onClick={(event) => {
|
||||
if (isEditingOrder) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
closeNavigationChrome();
|
||||
}}
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
<span>{isCloud ? "Organization settings" : "Company settings"}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{session?.session ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TaskChatRedesignGate } from "./TaskChatRedesignGate";
|
||||
import { useTaskChatRedesignEnabled } from "@/hooks/useTaskChatRedesignEnabled";
|
||||
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Navigate: ({ to, replace }: { to: string; replace?: boolean }) => (
|
||||
<div data-testid="navigate" data-to={to} data-replace={String(replace ?? false)} />
|
||||
),
|
||||
Outlet: () => <div data-testid="outlet">gated content</div>,
|
||||
}));
|
||||
|
||||
async function flushReact() {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
flushSync(() => {});
|
||||
}
|
||||
|
||||
describe("TaskChatRedesignGate", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root | null = null;
|
||||
|
||||
async function renderGate() {
|
||||
root = createRoot(container);
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
flushSync(() => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TaskChatRedesignGate />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
root = null;
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("redirects to the company home when the flag is off (flag-off isolation)", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableTaskChatRedesign: false });
|
||||
await renderGate();
|
||||
const navigate = container.querySelector('[data-testid="navigate"]');
|
||||
expect(navigate?.getAttribute("data-to")).toBe("/dashboard");
|
||||
expect(container.querySelector('[data-testid="outlet"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the gated harness when the flag is on", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableTaskChatRedesign: true });
|
||||
await renderGate();
|
||||
expect(container.querySelector('[data-testid="outlet"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="navigate"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTaskChatRedesignEnabled", () => {
|
||||
it("resolves to flag-off when rendered without a QueryClientProvider", () => {
|
||||
// Rendered detached (no provider) it must default OFF and loaded — this is
|
||||
// what makes flag-off the provable current behavior at every call site.
|
||||
let captured: { enabled: boolean; loaded: boolean } | null = null;
|
||||
function Probe() {
|
||||
captured = useTaskChatRedesignEnabled();
|
||||
return null;
|
||||
}
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
flushSync(() => root.render(<Probe />));
|
||||
flushSync(() => root.unmount());
|
||||
expect(captured).toEqual({ enabled: false, loaded: true });
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Navigate, Outlet } from "@/lib/router";
|
||||
import { useTaskChatRedesignEnabled } from "@/hooks/useTaskChatRedesignEnabled";
|
||||
|
||||
/**
|
||||
* Layout route guard for Task Chat Redesign dev surfaces (e.g. the
|
||||
* /dev/task-chat-lab harness).
|
||||
*
|
||||
* The gated routes stay registered (gating is presentation-only, no 404
|
||||
* flash); when the experimental flag is off the element redirects to the
|
||||
* company home instead of rendering. While the flag is still loading nothing
|
||||
* renders so an enabled user is not bounced away by a premature redirect.
|
||||
*/
|
||||
export function TaskChatRedesignGate() {
|
||||
const { enabled, loaded } = useTaskChatRedesignEnabled();
|
||||
if (!loaded) return null;
|
||||
if (!enabled) return <Navigate to="/dashboard" replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -5,10 +5,13 @@ import { forwardRef, useImperativeHandle, type ForwardedRef } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { TaskChatThread } from "./TaskChatThread";
|
||||
|
||||
const transcriptState = vi.hoisted(() => ({ transcriptByRun: new Map() }));
|
||||
|
||||
vi.mock("@/components/transcript/useLiveRunTranscripts", () => ({
|
||||
useLiveRunTranscripts: () => ({ transcriptByRun: new Map() }),
|
||||
useLiveRunTranscripts: () => transcriptState,
|
||||
}));
|
||||
vi.mock("@/context/SidebarContext", () => ({
|
||||
useSidebar: () => ({ isMobile: false }),
|
||||
@@ -31,6 +34,7 @@ let root: Root | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
transcriptState.transcriptByRun.clear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
@@ -44,7 +48,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
flushSync(() => root!.render(ui));
|
||||
flushSync(() => root!.render(<ThemeProvider>{ui}</ThemeProvider>));
|
||||
}
|
||||
|
||||
describe("TaskChatThread draft pass-through", () => {
|
||||
@@ -63,3 +67,110 @@ describe("TaskChatThread draft pass-through", () => {
|
||||
.toBe("half-written thought");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskChatThread live transcript", () => {
|
||||
it("renders in-flight output through TaskChatLiveTail, dropping the debug plumbing (PAP-463 C1)", () => {
|
||||
// Interleave the exact noise the old RunTranscriptView tail surfaced (init
|
||||
// row, stdout/stderr/system dumps) with real content. Only the streamed
|
||||
// reply markdown and the tool row may reach the thread.
|
||||
transcriptState.transcriptByRun.set("run-1", [
|
||||
{ kind: "init", ts: "2026-08-07T00:00:00.000Z", model: "claude", sessionId: "sess-INITMARKER" },
|
||||
{ kind: "system", ts: "2026-08-07T00:00:00.000Z", text: "SYSTEMNOISE environment hint" },
|
||||
{ kind: "stdout", ts: "2026-08-07T00:00:00.000Z", text: "STDOUTNOISE raw json dump" },
|
||||
{ kind: "stderr", ts: "2026-08-07T00:00:00.000Z", text: "STDERRNOISE adapter timeout note" },
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-08-07T00:00:00.000Z",
|
||||
text: "Streaming through the shared renderer",
|
||||
},
|
||||
{ kind: "tool_call", ts: "2026-08-07T00:00:00.000Z", name: "Read", toolUseId: "t1", input: { file_path: "src/app.ts" } },
|
||||
]);
|
||||
|
||||
render(
|
||||
<TaskChatThread
|
||||
comments={[]}
|
||||
onAdd={async () => {}}
|
||||
issueStatus="in_progress"
|
||||
activeRun={{
|
||||
id: "run-1",
|
||||
status: "running",
|
||||
invocationSource: "issue",
|
||||
triggerDetail: null,
|
||||
startedAt: "2026-08-07T00:00:00.000Z",
|
||||
finishedAt: null,
|
||||
createdAt: "2026-08-07T00:00:00.000Z",
|
||||
agentId: "agent-1",
|
||||
agentName: "Coder",
|
||||
adapterType: "codex_local",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tail = container.querySelector('[data-testid="task-chat-live-transcript"]');
|
||||
expect(tail).not.toBeNull();
|
||||
// Clean content survives: streamed reply markdown + tool row.
|
||||
expect(tail!.textContent).toContain("Streaming through the shared renderer");
|
||||
expect(tail!.textContent).toContain("src/app.ts");
|
||||
// None of the debug plumbing reaches the thread.
|
||||
for (const noise of ["INITMARKER", "SYSTEMNOISE", "STDOUTNOISE", "STDERRNOISE"]) {
|
||||
expect(container.textContent).not.toContain(noise);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the transcript mounted through run settle until the settled turn renders (PAP-462 B4)", () => {
|
||||
transcriptState.transcriptByRun.set("run-1", [
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-08-07T00:00:00.000Z",
|
||||
text: "Last words before the run stops",
|
||||
},
|
||||
]);
|
||||
|
||||
const liveProps = {
|
||||
comments: [] as never[],
|
||||
onAdd: async () => {},
|
||||
issueStatus: "in_progress",
|
||||
activeRun: {
|
||||
id: "run-1",
|
||||
status: "running",
|
||||
invocationSource: "issue" as const,
|
||||
triggerDetail: null,
|
||||
startedAt: "2026-08-07T00:00:00.000Z",
|
||||
finishedAt: null,
|
||||
createdAt: "2026-08-07T00:00:00.000Z",
|
||||
agentId: "agent-1",
|
||||
agentName: "Coder",
|
||||
adapterType: "codex_local",
|
||||
},
|
||||
};
|
||||
|
||||
render(<TaskChatThread {...liveProps} />);
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-live-transcript"]'),
|
||||
).not.toBeNull();
|
||||
|
||||
// The run settles: the issue goes terminal and the run reports succeeded, so
|
||||
// `liveRun` flips to null — but no reply comment has landed yet. The
|
||||
// transcript must NOT vanish; it stays mounted (now as a settled tail) until
|
||||
// its settled turn/comment renders.
|
||||
render(
|
||||
<TaskChatThread
|
||||
{...liveProps}
|
||||
issueStatus="done"
|
||||
activeRun={{
|
||||
...liveProps.activeRun,
|
||||
status: "succeeded",
|
||||
finishedAt: "2026-08-07T00:01:00.000Z",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-live-transcript"]'),
|
||||
).not.toBeNull();
|
||||
expect(container.textContent).toContain("Last words before the run stops");
|
||||
// The pill has settled to its "Worked" state rather than flipping back to a
|
||||
// spinner while it waits for the reply comment.
|
||||
expect(container.textContent).toContain("Worked");
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,16 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, type ComponentProps } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentProps } from "react";
|
||||
import { IssueChatThread } from "@/components/IssueChatThread";
|
||||
import { useLiveRunTranscripts, type RunTranscriptSource } from "@/components/transcript/useLiveRunTranscripts";
|
||||
import {
|
||||
useLiveRunTranscripts,
|
||||
type RunTranscriptSource,
|
||||
} from "@/components/transcript/useLiveRunTranscripts";
|
||||
import { TaskChatLiveTail } from "@/components/task-chat/TaskChatLiveTail";
|
||||
import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter";
|
||||
import {
|
||||
assembleThreadItems,
|
||||
attachSettledTurns,
|
||||
buildTurnSummary,
|
||||
coalesceSettledTurns,
|
||||
deriveRunStatusLabel,
|
||||
isNestableLiveChild,
|
||||
isTerminalRunStatus,
|
||||
prependIssueBrief,
|
||||
settledRunChildren,
|
||||
@@ -16,6 +18,10 @@ import {
|
||||
type SettledTurnMergeMeta,
|
||||
} from "@/components/task-chat/transcript-adapter";
|
||||
import { TaskChatDescriptionBubble } from "@/components/task-chat/TaskChatDescriptionBubble";
|
||||
import {
|
||||
TaskChatLiveRunPill,
|
||||
toolCountSummaryFromEntries,
|
||||
} from "@/components/task-chat/TaskChatLiveRunPill";
|
||||
import type {
|
||||
TaskChatInteractionItem,
|
||||
TaskChatItem,
|
||||
@@ -23,6 +29,11 @@ import type {
|
||||
TaskChatTurnItem,
|
||||
} from "@/components/task-chat/task-chat-model";
|
||||
import { TaskChatInteractionCard } from "@/components/task-chat/TaskChatInteractionCard";
|
||||
import {
|
||||
interactionThreadAnchorMs,
|
||||
isSuppressedThreadInteraction,
|
||||
} from "@/components/task-chat/interaction-thread-order";
|
||||
import { shouldHideInteractionCard } from "@/lib/issue-thread-interactions";
|
||||
import { TaskChatBubbleActions } from "@/components/task-chat/TaskChatBubbleActions";
|
||||
import type { FeedbackVoteValue } from "@paperclipai/shared";
|
||||
import { TaskChatThreadView, taskChatContentKey } from "@/components/task-chat/TaskChatThreadView";
|
||||
@@ -42,32 +53,30 @@ function toMs(value: Date | string | null | undefined): number {
|
||||
return Number.isNaN(ms) ? 0 : ms;
|
||||
}
|
||||
|
||||
// PAP-462 B4: backstop for how long the just-settled run's transcript stays
|
||||
// mounted when neither a settled turn nor a reply comment ever arrives to hand
|
||||
// off to (e.g. a stopped run with no tool activity). Normal completions hand off
|
||||
// well within this as soon as the settled turn/comment lands.
|
||||
const SETTLING_TAIL_MAX_MS = 15_000;
|
||||
|
||||
export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread>;
|
||||
|
||||
/**
|
||||
* Task Chat Redesign thread (experimental flag: `enableTaskChatRedesign`).
|
||||
* Chat-style task thread — the default task detail experience.
|
||||
*
|
||||
* Renders the redesigned, Claude-Code-style thread for the live task. It shares
|
||||
* Renders the Claude-Code-style thread for the live task. It shares
|
||||
* IssueChatThread's exact prop type — so the IssueDetail seam ternary
|
||||
* (`redesign ? TaskChatThread : IssueChatThread`) type-checks with no casts.
|
||||
* (`classic ? IssueChatThread : TaskChatThread`, flag:
|
||||
* `enableClassicTaskInterface`) type-checks with no casts.
|
||||
*
|
||||
* Two data sources feed the render layer, both reused from the existing thread:
|
||||
* - the comment stream (incl. optimistic echoes) → author-typed bubbles, and
|
||||
* - the live run transcript (useLiveRunTranscripts, the same poll+websocket
|
||||
* source the current thread uses) → the in-flight turn streams
|
||||
* thinking → tool → diff → responding, capped by a live "running" status
|
||||
* pill.
|
||||
* source the current thread uses) → clean TaskChatLiveTail rows (tool cards,
|
||||
* diffs, streamed reply markdown) while in flight, via the same
|
||||
* transcriptToTaskChatItems converter the settled turns use (PAP-463).
|
||||
*
|
||||
* Run activity is grouped into TaskChatTurnItem: the in-flight run renders as
|
||||
* ONE expandable parent row — its status line (whimsy gerund or in-flight
|
||||
* tool state + elapsed + tokens) is the turn's single visible line, with the
|
||||
* chronological tool activity nested behind an expand (PAP-354). Mid-run
|
||||
* agent text (interstitial updates between tool calls) is ephemeral
|
||||
* (PAP-361, round 9): while one streams it occupies a dedicated one-line row
|
||||
* PERMANENTLY RESERVED above the status line (so the layout above never
|
||||
* jumps), and when it finishes the text slides out and the slot sits empty —
|
||||
* nothing persists; the run log and the classic transcript remain the
|
||||
* archive. When the run terminates, its settled turn anchors after the run's
|
||||
* Once a run terminates, its settled turn anchors after the run's
|
||||
* last comment (comment.runId linkage) and — when it directly follows that
|
||||
* reply bubble — attaches to it: the "✓ Worked · …" summary renders appended
|
||||
* to the bubble's always-visible timestamp line (round 9), still expandable
|
||||
@@ -229,6 +238,15 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
entries.push({ ms: toMs(comment.createdAt), order: 1, id: item.id, item });
|
||||
});
|
||||
for (const interaction of interactions ?? []) {
|
||||
// Withdrawn/superseded confirmations are retracted calls to action — drop
|
||||
// them so a dead card never stacks above the one that replaced it
|
||||
// (PAP-416).
|
||||
if (isSuppressedThreadInteraction(interaction)) continue;
|
||||
// A never-rendered card — a degenerate `ask_user_questions` (e.g. the
|
||||
// onboarding `Test / A` placeholder) or a stale sibling superseded by a
|
||||
// newer question (PAP-437) — is filtered here so it leaves no empty slot
|
||||
// or gap in the ordered backbone (PAP-424, plan from PAP-420).
|
||||
if (shouldHideInteractionCard(interaction)) continue;
|
||||
const createdAtMs = toMs(interaction.createdAt);
|
||||
const handoffAtMs =
|
||||
interaction.kind === "request_confirmation" && interaction.sourceRunId
|
||||
@@ -243,7 +261,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
: null;
|
||||
const id = `interaction:${interaction.id}`;
|
||||
entries.push({
|
||||
ms: handoffAtMs ?? createdAtMs,
|
||||
// A resolved card settles at its resolution time so it never reads
|
||||
// above a message written before the user answered (PAP-416); pending
|
||||
// cards keep the request-time (handoff/createdAt) slot.
|
||||
ms: interactionThreadAnchorMs(interaction, handoffAtMs ?? createdAtMs),
|
||||
order: 2,
|
||||
id,
|
||||
item: { id, kind: "interaction", interaction },
|
||||
@@ -274,7 +295,12 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// heavy assembly memo doesn't recompute on every parent render.
|
||||
const hasBrief = Boolean(issueBrief);
|
||||
|
||||
const items = useMemo<TaskChatItem[]>(() => {
|
||||
const { items, settledRunIds } = useMemo<{ items: TaskChatItem[]; settledRunIds: Set<string> }>(() => {
|
||||
// Runs whose settled turn made it into the assembled thread — the live tail
|
||||
// hands off to this as its "the settled turn has rendered" signal (PAP-462
|
||||
// B4), so the transcript stays mounted through the settle gap without ever
|
||||
// double-rendering beside its own settled turn.
|
||||
const settledRunIds = new Set<string>();
|
||||
// Settled turns for every terminal run whose transcript we have. Messages
|
||||
// and thinking are excluded entirely (PAP-361): the final reply already
|
||||
// landed as the run's comment bubble, interstitial updates are ephemeral
|
||||
@@ -303,6 +329,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
});
|
||||
const children = settledRunChildren(parsed);
|
||||
if (children.length === 0) continue;
|
||||
settledRunIds.add(source.id);
|
||||
const failed = source.status !== "succeeded";
|
||||
const startSlotRaw = meta?.startedAt ?? meta?.createdAt;
|
||||
turnMergeMetaById.set(`${source.id}:turn`, {
|
||||
@@ -347,57 +374,133 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// (PAP-367).
|
||||
const out = assembleThreadItems(orderedEntries, turnsByAnchor, unanchored);
|
||||
|
||||
if (liveRun) {
|
||||
const entries = transcriptByRun.get(liveRun.id) ?? [];
|
||||
const parsed = transcriptToTaskChatItems(entries, {
|
||||
runId: liveRun.id,
|
||||
agentName: liveRun.agentName,
|
||||
running: true,
|
||||
});
|
||||
// Parent-row model (PAP-354): the run's status line IS the live turn —
|
||||
// one expandable row owning the activity. Only tool/usage rows nest
|
||||
// inside it; a streaming interstitial update renders in the reserved row
|
||||
// above the status line (liveStatus.selfTalk, PAP-361/round 9) and
|
||||
// vanishes when it completes. The parent row sits last; on settle its
|
||||
// summary attaches to the reply bubble's timestamp line.
|
||||
const children = parsed.filter(isNestableLiveChild);
|
||||
const startedAt = liveRun.startedAt ? new Date(liveRun.startedAt).getTime() : null;
|
||||
const queued = liveRun.status === "queued";
|
||||
const status = queued
|
||||
? { label: "Queued", detail: "Waiting to start", toolName: undefined, selfTalk: undefined }
|
||||
: deriveRunStatusLabel(entries);
|
||||
out.push({
|
||||
id: `${liveRun.id}:turn`,
|
||||
kind: "turn",
|
||||
settled: false,
|
||||
items: children,
|
||||
summary: buildTurnSummary(entries),
|
||||
liveStatus: {
|
||||
id: `${liveRun.id}:status`,
|
||||
kind: "status",
|
||||
status: "running",
|
||||
label: status.label,
|
||||
detail: status.detail,
|
||||
toolName: status.toolName,
|
||||
selfTalk: status.selfTalk,
|
||||
startedAtMs: startedAt ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
// PAP-362: two runs replying back-to-back (same agent, nothing but the
|
||||
// agent's own bubbles between) fold into ONE "Worked" row below the last
|
||||
// reply; a user message, interaction, or the live turn keeps them apart.
|
||||
// reply; a user message or interaction keeps them apart.
|
||||
// Round 9: a settled turn directly following its own agent's reply bubble
|
||||
// then attaches to that bubble — the "Worked · …" summary renders on the
|
||||
// bubble's always-visible timestamp line instead of as a standalone row.
|
||||
// PAP-375: the description-as-first-bubble placeholder prepends LAST, after
|
||||
// every assembly/merge pass, so nothing can ever sort above it.
|
||||
return prependIssueBrief(
|
||||
attachSettledTurns(coalesceSettledTurns(out, turnMergeMetaById), turnMergeMetaById),
|
||||
hasBrief,
|
||||
);
|
||||
return {
|
||||
items: prependIssueBrief(
|
||||
attachSettledTurns(coalesceSettledTurns(out, turnMergeMetaById), turnMergeMetaById),
|
||||
hasBrief,
|
||||
),
|
||||
settledRunIds,
|
||||
};
|
||||
}, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun, hasBrief]);
|
||||
|
||||
// PAP-462 B4: the moment a run settles, `liveRun` flips to null — but its
|
||||
// settled turn (or reply comment) can take seconds to arrive on the next
|
||||
// comment refetch. Without a handoff the whole transcript unmounts that frame,
|
||||
// blinking out already-streamed output before its settled form renders. Snapshot
|
||||
// the just-settled run so its now-static transcript stays mounted through the gap.
|
||||
const [settlingRun, setSettlingRun] = useState<{ id: string; startedAtMs: number | null } | null>(null);
|
||||
const prevLiveRunRef = useRef<typeof liveRun>(null);
|
||||
// Layout effect (not passive): snapshot the settled run before the browser
|
||||
// paints the `liveRun === null` frame, so the tail never blinks empty for a
|
||||
// frame between the run stopping and this snapshot committing.
|
||||
useLayoutEffect(() => {
|
||||
if (liveRun) {
|
||||
prevLiveRunRef.current = liveRun;
|
||||
// A fresh live run supersedes any lingering settled remnant.
|
||||
setSettlingRun((current) => (current && current.id !== liveRun.id ? null : current));
|
||||
return;
|
||||
}
|
||||
const prev = prevLiveRunRef.current;
|
||||
prevLiveRunRef.current = null;
|
||||
if (!prev) return;
|
||||
setSettlingRun({
|
||||
id: prev.id,
|
||||
startedAtMs: (prev.startedAt ? toMs(prev.startedAt) : null) ?? toMs(prev.createdAt),
|
||||
});
|
||||
}, [liveRun]);
|
||||
|
||||
// Hand off once the settled turn or its reply comment is in the thread; a
|
||||
// stopped run that yields neither is released by the backstop timeout so the
|
||||
// tail never lingers indefinitely.
|
||||
const settledRunRendered =
|
||||
settlingRun != null &&
|
||||
(settledRunIds.has(settlingRun.id) ||
|
||||
comments.some((comment) => comment.runId === settlingRun.id && !comment.deletedAt));
|
||||
useEffect(() => {
|
||||
if (!settlingRun) return;
|
||||
if (settledRunRendered) {
|
||||
setSettlingRun(null);
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => setSettlingRun(null), SETTLING_TAIL_MAX_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [settlingRun, settledRunRendered]);
|
||||
|
||||
// The tail streams the live run, or — through the settle gap — the last run's
|
||||
// now-static transcript until its settled form renders (PAP-462 B4).
|
||||
const showSettlingTail =
|
||||
!liveRun &&
|
||||
settlingRun != null &&
|
||||
!settledRunRendered &&
|
||||
(transcriptByRun.get(settlingRun.id)?.length ?? 0) > 0;
|
||||
const tailRunId = liveRun ? liveRun.id : showSettlingTail ? settlingRun!.id : null;
|
||||
const tailStreaming = Boolean(liveRun);
|
||||
const tailEntries = tailRunId ? (transcriptByRun.get(tailRunId) ?? []) : [];
|
||||
const tailContentKey = tailEntries.reduce((total, entry) => {
|
||||
if ("text" in entry) return total + entry.text.length;
|
||||
if ("content" in entry) return total + entry.content.length;
|
||||
return total + entry.kind.length;
|
||||
}, tailEntries.length);
|
||||
const threadContentKey = taskChatContentKey(items) + tailContentKey;
|
||||
|
||||
// Status-pill inputs for the tail (PAP-461, A1): the run's start, its finish
|
||||
// (once terminal), and the "called N tools" summary. Memoized on the
|
||||
// transcript content key so the O(n) tool count is not re-walked every render
|
||||
// while the pill's own second-tick keeps the elapsed readout moving. Through
|
||||
// the settle gap the run reads terminal, so the pill lands on its "Worked"
|
||||
// state instead of flipping back to a spinner.
|
||||
const settlingFinishedAt = settlingRun ? linkedRunMetaById.get(settlingRun.id)?.finishedAt : undefined;
|
||||
const tailStatus = liveRun
|
||||
? liveRun.status
|
||||
: runs.find((run) => run.id === settlingRun?.id)?.status ?? "succeeded";
|
||||
const tailStartedAtMs = liveRun
|
||||
? (liveRun.startedAt ? toMs(liveRun.startedAt) : null) ?? toMs(liveRun.createdAt)
|
||||
: settlingRun?.startedAtMs ?? null;
|
||||
const tailFinishedAtMs = liveRun
|
||||
? liveRun.finishedAt
|
||||
? toMs(liveRun.finishedAt)
|
||||
: null
|
||||
: settlingFinishedAt
|
||||
? toMs(settlingFinishedAt)
|
||||
: null;
|
||||
const tailToolSummary = useMemo(
|
||||
() => (tailRunId ? toolCountSummaryFromEntries(tailEntries) : null),
|
||||
// tailEntries is a fresh array each render; tailContentKey tracks its content.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[tailRunId, tailContentKey],
|
||||
);
|
||||
|
||||
// The tail's clean rows (PAP-463 C1): the streaming transcript parsed through
|
||||
// the SAME transcriptToTaskChatItems converter the settled turns use, which
|
||||
// drops the debug plumbing (init/stdout/stderr/system) so RunTranscriptView's
|
||||
// noise can never reach the thread. Memoized on the content key (tailEntries
|
||||
// is a fresh array each render) so the O(n) parse is not re-walked while the
|
||||
// pill's own second-tick keeps the elapsed readout moving. `running` follows
|
||||
// tailStreaming: true while live, false through the settle gap — so the tail
|
||||
// renders identically either side of the run finishing.
|
||||
const tailAgentName = liveRun?.agentName ?? linkedRunMetaById.get(tailRunId ?? "")?.agentName;
|
||||
const tailItems = useMemo(
|
||||
() =>
|
||||
tailRunId
|
||||
? transcriptToTaskChatItems(tailEntries, {
|
||||
runId: tailRunId,
|
||||
agentName: tailAgentName,
|
||||
running: tailStreaming,
|
||||
})
|
||||
: [],
|
||||
// tailEntries is a fresh array each render; tailContentKey tracks its content.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[tailRunId, tailContentKey, tailStreaming, tailAgentName],
|
||||
);
|
||||
|
||||
// Feedback votes keyed by the comment they target (targetType
|
||||
// "issue_comment"), mirroring IssueChatThread — the redesign attaches the
|
||||
// 👍/👎 state to each agent bubble by its comment id (PAP-413).
|
||||
@@ -471,7 +574,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
// in document flow instead (the same scroll={false} path the previews use)
|
||||
// and track auto-follow against window scroll. Desktop stays byte-identical.
|
||||
const { isMobile } = useSidebar();
|
||||
useWindowAutoFollow(isMobile ? taskChatContentKey(items) : 0, isMobile);
|
||||
useWindowAutoFollow(isMobile ? threadContentKey : 0, isMobile);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -479,7 +582,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
data-testid="task-chat-thread"
|
||||
>
|
||||
<div className={cn("flex flex-col", !isMobile && "min-h-0 flex-1")}>
|
||||
{items.length === 0 ? (
|
||||
{items.length === 0 && !tailRunId ? (
|
||||
<div className={isMobile ? undefined : "min-h-0 flex-1 overflow-y-auto"}>
|
||||
{threadHeader ? (
|
||||
<div
|
||||
@@ -498,6 +601,25 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
renderInteraction={renderInteraction}
|
||||
renderBrief={issueBrief ? () => <TaskChatDescriptionBubble brief={issueBrief} /> : undefined}
|
||||
renderMessageActions={renderMessageActions}
|
||||
tail={tailRunId ? (
|
||||
<div data-testid="task-chat-live-transcript">
|
||||
<TaskChatLiveRunPill
|
||||
status={tailStatus}
|
||||
startedAtMs={tailStartedAtMs}
|
||||
finishedAtMs={tailFinishedAtMs}
|
||||
toolSummary={tailToolSummary}
|
||||
/>
|
||||
<TaskChatLiveTail
|
||||
items={tailItems}
|
||||
emptyMessage={
|
||||
tailStatus === "queued"
|
||||
? "Waiting to start..."
|
||||
: "Waiting for transcript..."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
contentKey={threadContentKey}
|
||||
scroll={!isMobile}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { Issue, RequestConfirmationInteraction } from "@paperclipai/shared";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { IssuePropertiesPlansTab } from "./IssuePropertiesPlansTab";
|
||||
import { IssuePlanConfirmationActionBar } from "./IssuePlanConfirmationActionBar";
|
||||
|
||||
const mockIssuesApi = vi.hoisted(() => ({
|
||||
listInteractions: vi.fn(),
|
||||
listAcceptedPlanDecompositions: vi.fn(),
|
||||
acceptInteraction: vi.fn(),
|
||||
rejectInteraction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/issues", () => ({ issuesApi: mockIssuesApi }));
|
||||
vi.mock("@/hooks/useIssuePlanDocument", () => ({
|
||||
useIssuePlanDocument: () => ({ data: undefined, isLoading: false }),
|
||||
}));
|
||||
vi.mock("../PropertiesPanel", () => ({
|
||||
PROPERTIES_PANE_FOOTER_SLOT_ID: "properties-pane-footer-slot",
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const issue = {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1",
|
||||
} as Issue;
|
||||
|
||||
const confirmation = {
|
||||
id: "confirmation-1",
|
||||
companyId: "company-1",
|
||||
issueId: issue.id,
|
||||
kind: "request_confirmation",
|
||||
status: "pending",
|
||||
continuationPolicy: "wake_assignee",
|
||||
resolverPolicy: "board_only",
|
||||
requestedResolverPolicy: "board_only",
|
||||
effectiveResolverPolicy: "board_only",
|
||||
createdAt: "2026-08-05T00:00:00.000Z",
|
||||
updatedAt: "2026-08-05T00:00:00.000Z",
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve this plan?",
|
||||
acceptLabel: "Approve plan",
|
||||
rejectLabel: "Request changes",
|
||||
rejectRequiresReason: true,
|
||||
allowDeclineReason: true,
|
||||
target: { type: "issue_document", key: "plan", revisionId: "rev-1" },
|
||||
},
|
||||
} satisfies RequestConfirmationInteraction;
|
||||
|
||||
let root: ReturnType<typeof createRoot> | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
let client: QueryClient | null = null;
|
||||
|
||||
function render(element: React.ReactElement) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
|
||||
client.setQueryData(queryKeys.issues.interactions(issue.id), [confirmation]);
|
||||
client.setQueryData(queryKeys.issues.acceptedPlanDecompositions(issue.id), []);
|
||||
act(() => root?.render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={client!}>{element}</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
));
|
||||
return container;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (root) act(() => root?.unmount());
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
client?.clear();
|
||||
client = null;
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("IssuePlanConfirmationActionBar", () => {
|
||||
it("renders a pending plan confirmation even before its plan document exists", () => {
|
||||
const rendered = render(<IssuePropertiesPlansTab issue={issue} />);
|
||||
|
||||
expect(rendered.querySelector('[data-testid="plan-pane-action-bar"]')).not.toBeNull();
|
||||
expect(rendered.textContent).toContain("Approve plan");
|
||||
expect(rendered.textContent).toContain("Request changes");
|
||||
});
|
||||
|
||||
it("moves into a footer slot that mounts on the next paint", () => {
|
||||
let resolveNextPaint: FrameRequestCallback | undefined;
|
||||
vi.stubGlobal("requestAnimationFrame", vi.fn((callback: FrameRequestCallback) => {
|
||||
resolveNextPaint = callback;
|
||||
return 1;
|
||||
}));
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
|
||||
const rendered = render(<IssuePlanConfirmationActionBar issue={issue} />);
|
||||
const footer = document.createElement("div");
|
||||
footer.id = "properties-pane-footer-slot";
|
||||
document.body.appendChild(footer);
|
||||
|
||||
act(() => resolveNextPaint?.(0));
|
||||
|
||||
expect(rendered.querySelector('[data-testid="plan-pane-action-bar"]')).toBeNull();
|
||||
expect(footer.querySelector('[data-testid="plan-pane-action-bar"]')).not.toBeNull();
|
||||
footer.remove();
|
||||
});
|
||||
});
|
||||
@@ -1,216 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { Issue, IssueThreadInteraction, RequestConfirmationInteraction } from "@paperclipai/shared";
|
||||
import { issuesApi } from "@/api/issues";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { PROPERTIES_PANE_FOOTER_SLOT_ID } from "../PropertiesPanel";
|
||||
|
||||
/** The pending confirmation that targets the issue's `plan` document, if any. */
|
||||
function findPendingPlanConfirmation(
|
||||
interactions: IssueThreadInteraction[] | undefined,
|
||||
): RequestConfirmationInteraction | null {
|
||||
for (const interaction of interactions ?? []) {
|
||||
if (interaction.kind !== "request_confirmation") continue;
|
||||
if (interaction.status !== "pending") continue;
|
||||
const target = interaction.payload.target;
|
||||
if (target?.type === "issue_document" && target.key === "plan") return interaction;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface IssuePlanConfirmationActionBarProps {
|
||||
issue: Issue;
|
||||
/** Inline hosts (mobile sheet) render the bar in place instead of portaling
|
||||
* it into the pane's pinned footer slot. */
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sticky action bar for the Plan pane (flag: enableTaskChatRedesign): while a
|
||||
* plan confirmation is pending, its CTAs stay pinned below the pane's scroll
|
||||
* area so the board can approve or send back the plan without hunting for the
|
||||
* card in the thread. Mirrors the thread card's semantics (accept/reject labels
|
||||
* and the optional decline reason) against the same interactions API.
|
||||
*/
|
||||
export function IssuePlanConfirmationActionBar({ issue, inline }: IssuePlanConfirmationActionBarProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: interactions } = useQuery({
|
||||
queryKey: queryKeys.issues.interactions(issue.id),
|
||||
queryFn: () => issuesApi.listInteractions(issue.id),
|
||||
});
|
||||
const confirmation = findPendingPlanConfirmation(interactions);
|
||||
|
||||
const [footerSlot, setFooterSlot] = useState<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (inline) {
|
||||
setFooterSlot(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolveFooterSlot = () => {
|
||||
setFooterSlot(document.getElementById(PROPERTIES_PANE_FOOTER_SLOT_ID));
|
||||
};
|
||||
|
||||
// The properties pane and this action bar can mount in either order. Check
|
||||
// once synchronously, then again on the next paint so the footer slot is
|
||||
// found when it is mounted later in the same commit.
|
||||
resolveFooterSlot();
|
||||
const frame = requestAnimationFrame(resolveFooterSlot);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [confirmation?.id, inline]);
|
||||
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState("");
|
||||
const [rejectAttempted, setRejectAttempted] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(issue.id) });
|
||||
};
|
||||
const accept = useMutation({
|
||||
mutationFn: (interactionId: string) => issuesApi.acceptInteraction(issue.id, interactionId),
|
||||
onSuccess: invalidate,
|
||||
onError: () => setActionError("Couldn't confirm — try again."),
|
||||
});
|
||||
const reject = useMutation({
|
||||
mutationFn: ({ interactionId, reason }: { interactionId: string; reason?: string }) =>
|
||||
issuesApi.rejectInteraction(issue.id, interactionId, reason),
|
||||
onSuccess: () => {
|
||||
setRejecting(false);
|
||||
setRejectReason("");
|
||||
invalidate();
|
||||
},
|
||||
onError: () => setActionError("Couldn't send that back — try again."),
|
||||
});
|
||||
|
||||
// Interaction changed under us (resolved elsewhere, superseded): reset.
|
||||
useEffect(() => {
|
||||
setRejecting(false);
|
||||
setRejectReason("");
|
||||
setRejectAttempted(false);
|
||||
setActionError(null);
|
||||
}, [confirmation?.id, confirmation?.status]);
|
||||
|
||||
if (!confirmation) return null;
|
||||
|
||||
const working = accept.isPending ? "accept" : reject.isPending ? "reject" : null;
|
||||
const rejectRequiresReason = confirmation.payload.rejectRequiresReason === true;
|
||||
const allowDeclineReason = confirmation.payload.allowDeclineReason !== false;
|
||||
const trimmedReason = rejectReason.trim();
|
||||
const reasonInvalid = rejectRequiresReason && trimmedReason.length === 0;
|
||||
|
||||
const handleReject = () => {
|
||||
setRejectAttempted(true);
|
||||
if (reasonInvalid) return;
|
||||
setActionError(null);
|
||||
reject.mutate({ interactionId: confirmation.id, reason: trimmedReason || undefined });
|
||||
};
|
||||
|
||||
const bar = (
|
||||
<div
|
||||
data-testid="plan-pane-action-bar"
|
||||
className="space-y-2 border-t border-border bg-card p-3"
|
||||
>
|
||||
{rejecting ? (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={rejectReason}
|
||||
onChange={(event) => setRejectReason(event.target.value)}
|
||||
placeholder={
|
||||
confirmation.payload.declineReasonPlaceholder
|
||||
?? (confirmation.payload.acceptLabel === "Approve plan"
|
||||
? "Optional: what would you like revised?"
|
||||
: "Optional: tell the agent what you'd change.")
|
||||
}
|
||||
aria-invalid={rejectAttempted && reasonInvalid}
|
||||
className={cn(
|
||||
"min-h-20 bg-background text-sm",
|
||||
rejectAttempted && reasonInvalid && "border-rose-500 focus-visible:ring-rose-500/25",
|
||||
)}
|
||||
/>
|
||||
{rejectAttempted && reasonInvalid ? (
|
||||
<p className="text-xs text-destructive">A decline reason is required.</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{actionError ? (
|
||||
<p className="text-xs text-destructive" role="alert">{actionError}</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
{rejecting ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={working !== null}
|
||||
onClick={() => {
|
||||
setRejecting(false);
|
||||
setRejectAttempted(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={working !== null} onClick={handleReject}>
|
||||
{working === "reject" ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Sending back...
|
||||
</>
|
||||
) : (
|
||||
confirmation.payload.rejectLabel ?? "Decline"
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={working !== null}
|
||||
onClick={() => {
|
||||
if (!allowDeclineReason) {
|
||||
handleReject();
|
||||
return;
|
||||
}
|
||||
setRejectAttempted(false);
|
||||
setRejecting(true);
|
||||
}}
|
||||
>
|
||||
{working === "reject" ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Sending back...
|
||||
</>
|
||||
) : (
|
||||
confirmation.payload.rejectLabel ?? "Decline"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="cta"
|
||||
disabled={working !== null}
|
||||
onClick={() => {
|
||||
setActionError(null);
|
||||
accept.mutate(confirmation.id);
|
||||
}}
|
||||
>
|
||||
{working === "accept" ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Confirming...
|
||||
</>
|
||||
) : (
|
||||
confirmation.payload.acceptLabel ?? "Confirm"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return footerSlot ? createPortal(bar, footerSlot) : bar;
|
||||
}
|
||||
@@ -171,38 +171,40 @@ export function IssueProperties({
|
||||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||||
});
|
||||
const taskWatchdogsEnabled = experimentalSettings?.enableTaskWatchdogs === true;
|
||||
// Task Chat Redesign: gate the Properties | Plans | Artifacts tab shell. Flag
|
||||
// OFF renders today's stacked sections verbatim (no Tabs wrapper). This pane
|
||||
// is always task-scoped, so the flag alone is a sufficient gate.
|
||||
const taskChatRedesignEnabled = experimentalSettings?.enableTaskChatRedesign === true;
|
||||
// When hosted by the redesigned PropertiesPanel, the tab strip portals into
|
||||
// Classic Task Interface: gate the Properties | Plans | Artifacts tab shell.
|
||||
// Flag ON renders the legacy stacked sections verbatim (no Tabs wrapper);
|
||||
// flag OFF — including while settings load — renders the chat-style tab
|
||||
// shell. This pane is always task-scoped, so the flag alone is a sufficient
|
||||
// gate.
|
||||
const taskChatShellEnabled = experimentalSettings?.enableClassicTaskInterface !== true;
|
||||
// When hosted by the resizable PropertiesPanel, the tab strip portals into
|
||||
// the pane's header bar (left of the window controls). The slot only exists
|
||||
// once the panel has committed, hence the effect; inline hosts (mobile sheet)
|
||||
// keep the tab strip in place.
|
||||
const [paneHeaderSlot, setPaneHeaderSlot] = useState<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (!taskChatRedesignEnabled || inline) {
|
||||
if (!taskChatShellEnabled || inline) {
|
||||
setPaneHeaderSlot(null);
|
||||
return;
|
||||
}
|
||||
setPaneHeaderSlot(document.getElementById(PROPERTIES_PANE_HEADER_SLOT_ID));
|
||||
}, [taskChatRedesignEnabled, inline]);
|
||||
}, [taskChatShellEnabled, inline]);
|
||||
// Plan earns a tab as soon as an issue is in planning mode, even before the
|
||||
// plan document arrives. This keeps an expected plan surface visible and
|
||||
// lets its diagnostic empty state explain what is missing.
|
||||
// Same query keys as the tab bodies, so these share their cached fetches.
|
||||
const { data: paneTabPlanDocument } = useIssuePlanDocument(
|
||||
taskChatRedesignEnabled ? issue.id : null,
|
||||
taskChatShellEnabled ? issue.id : null,
|
||||
);
|
||||
const { data: paneTabAcceptedPlans } = useQuery({
|
||||
queryKey: queryKeys.issues.acceptedPlanDecompositions(issue.id),
|
||||
queryFn: () => issuesApi.listAcceptedPlanDecompositions(issue.id),
|
||||
enabled: taskChatRedesignEnabled,
|
||||
enabled: taskChatShellEnabled,
|
||||
});
|
||||
const { data: paneTabAttachments } = useQuery({
|
||||
queryKey: queryKeys.issues.attachments(issue.id),
|
||||
queryFn: () => issuesApi.listAttachments(issue.id),
|
||||
enabled: taskChatRedesignEnabled,
|
||||
enabled: taskChatShellEnabled,
|
||||
});
|
||||
const hasPlanTab =
|
||||
Boolean(paneTabPlanDocument)
|
||||
@@ -210,6 +212,20 @@ export function IssueProperties({
|
||||
|| issue.workMode === "planning";
|
||||
const hasArtifactsTab = (paneTabAttachments?.length ?? 0) > 0;
|
||||
const [paneTab, setPaneTab] = useState("properties");
|
||||
// Once a plan document exists, surface it: switch the pane to the Plan tab so
|
||||
// the write-up is exposed alongside the plan-approval card, instead of leaving
|
||||
// the user on Properties. Only auto-switch until the user picks a tab by hand —
|
||||
// after that their choice wins. Ref-guarded so it fires once per mount.
|
||||
const paneTabUserChosenRef = useRef(false);
|
||||
const handlePaneTabChange = useCallback((value: string) => {
|
||||
paneTabUserChosenRef.current = true;
|
||||
setPaneTab(value);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (hasPlanTab && !paneTabUserChosenRef.current) {
|
||||
setPaneTab("plans");
|
||||
}
|
||||
}, [hasPlanTab]);
|
||||
const [assigneeOpen, setAssigneeOpen] = useState(false);
|
||||
const [assigneeSearch, setAssigneeSearch] = useState("");
|
||||
/** When a run is live, a selection is staged here until the operator confirms
|
||||
@@ -2496,10 +2512,10 @@ export function IssueProperties({
|
||||
</div>
|
||||
);
|
||||
|
||||
// Flag OFF (or non-redesign hosts): today's stacked pane, byte-for-byte.
|
||||
if (!taskChatRedesignEnabled) return propertiesBody;
|
||||
// Classic Task Interface ON: the legacy stacked pane, byte-for-byte.
|
||||
if (!taskChatShellEnabled) return propertiesBody;
|
||||
|
||||
// Flag ON with nothing to switch between: no tab strip — the header bar
|
||||
// Chat-style with nothing to switch between: no tab strip — the header bar
|
||||
// shows a plain title and the pane body is just the properties stack.
|
||||
if (!hasPlanTab && !hasArtifactsTab) {
|
||||
return (
|
||||
@@ -2552,7 +2568,7 @@ export function IssueProperties({
|
||||
</TabsList>
|
||||
);
|
||||
return (
|
||||
<Tabs value={activePaneTab} onValueChange={setPaneTab} className="flex min-h-0 flex-col gap-3">
|
||||
<Tabs value={activePaneTab} onValueChange={handlePaneTabChange} className="flex min-h-0 flex-col gap-3">
|
||||
{paneHeaderSlot
|
||||
? createPortal(
|
||||
// Portals keep React context but break the DOM tree the Tailwind
|
||||
|
||||
@@ -15,7 +15,7 @@ function formatBytes(n: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Artifacts tab of the redesigned properties pane (flag: enableTaskChatRedesign).
|
||||
* Artifacts tab of the properties pane.
|
||||
*
|
||||
* A read-only gallery of the task's attachments / work products. Uploads,
|
||||
* previews, and deletes stay on the existing attachment surfaces for the
|
||||
|
||||
@@ -8,12 +8,11 @@ import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "@/components/IssueDocumentAnnotations";
|
||||
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
|
||||
import { useLocation } from "@/lib/router";
|
||||
import { IssuePlanConfirmationActionBar } from "./IssuePlanConfirmationActionBar";
|
||||
|
||||
interface IssuePropertiesPlansTabProps {
|
||||
issue: Issue;
|
||||
/** True when hosted outside the properties panel (mobile sheet) — the plan
|
||||
* confirmation bar then renders in place instead of the pane footer slot. */
|
||||
/** Retained for host parity with the other tabs; the Plans tab no longer
|
||||
* renders its own approval control (PAP-418). */
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
@@ -28,15 +27,15 @@ function hasPendingPlanConfirmation(interactions: IssueThreadInteraction[] | und
|
||||
}
|
||||
|
||||
/**
|
||||
* Plans tab of the redesigned properties pane (flag: enableTaskChatRedesign).
|
||||
* Plans tab of the properties pane.
|
||||
*
|
||||
* Owns the plan surface with the flag ON: the `plan` document itself (formerly
|
||||
* pinned above the tabs via IssueDocumentsSection, which the chat shell gates
|
||||
* off) rendered above the accepted-plan decomposition history. Structured live
|
||||
* Owns the plan surface: the `plan` document itself (formerly pinned above
|
||||
* the tabs via IssueDocumentsSection, which the chat shell gates off)
|
||||
* rendered above the accepted-plan decomposition history. Structured live
|
||||
* PlanEntry/todo streaming is a flagged protocol dependency (demonstrated in
|
||||
* the /dev/task-chat-lab harness).
|
||||
*/
|
||||
export function IssuePropertiesPlansTab({ issue, inline }: IssuePropertiesPlansTabProps) {
|
||||
export function IssuePropertiesPlansTab({ issue }: IssuePropertiesPlansTabProps) {
|
||||
const { data: planDocument, isLoading: planDocumentLoading } = useIssuePlanDocument(issue.id);
|
||||
const location = useLocation();
|
||||
const [annotationPanelOpen, setAnnotationPanelOpen] = useState(false);
|
||||
@@ -53,35 +52,31 @@ export function IssuePropertiesPlansTab({ issue, inline }: IssuePropertiesPlansT
|
||||
|
||||
if (!planDocument && !hasPlans) {
|
||||
return (
|
||||
<>
|
||||
{/* This is deliberately outside the plan-document gate: an interaction
|
||||
can arrive before its plan document query resolves or persists. */}
|
||||
<IssuePlanConfirmationActionBar issue={issue} inline={inline} />
|
||||
<div className="px-1 py-6 text-sm text-muted-foreground">
|
||||
{planDocumentLoading ? (
|
||||
"Loading plan…"
|
||||
) : issue.workMode === "planning" ? (
|
||||
<div className="space-y-2">
|
||||
<p>This task is in plan mode but no plan document has been written yet.</p>
|
||||
{pendingPlanConfirmation ? (
|
||||
<p className="text-amber-foreground">
|
||||
A plan confirmation is pending, but the plan document it should confirm is missing.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
"No plan yet. The plan document, accepted plans, and their revisions will appear here."
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
<div className="px-1 py-6 text-sm text-muted-foreground">
|
||||
{planDocumentLoading ? (
|
||||
"Loading plan…"
|
||||
) : issue.workMode === "planning" ? (
|
||||
<div className="space-y-2">
|
||||
<p>This task is in plan mode but no plan document has been written yet.</p>
|
||||
{pendingPlanConfirmation ? (
|
||||
<p className="text-amber-foreground">
|
||||
A plan confirmation is pending, but the plan document it should confirm is missing.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
"No plan yet. The plan document, accepted plans, and their revisions will appear here."
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Pending plan confirmation: its CTAs pin to the pane's footer slot so
|
||||
they stay visible while the plan scrolls. */}
|
||||
<IssuePlanConfirmationActionBar issue={issue} inline={inline} />
|
||||
{/* Plan approval lives in ONE place — the plan confirmation card in the
|
||||
conversation thread (PAP-418). This tab now only shows the plan itself
|
||||
and its accepted-revision history, so there is no second surface to
|
||||
approve from. */}
|
||||
{planDocument ? (
|
||||
<section data-testid="issue-plan-document" className="space-y-2">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,7 @@ import { parseAcpxStdoutLine } from "@paperclipai/adapter-utils/acpx-engine/ui";
|
||||
import { buildTranscript, type RunLogChunk, type TranscriptEntry } from "../../adapters";
|
||||
import type { ToolRunDecision } from "@paperclipai/shared";
|
||||
import { ThemeProvider } from "../../context/ThemeContext";
|
||||
import { RunTranscriptView, normalizeTranscript } from "./RunTranscriptView";
|
||||
import { RunTranscriptView, keyTranscriptBlocks, normalizeTranscript } from "./RunTranscriptView";
|
||||
|
||||
describe("RunTranscriptView", () => {
|
||||
it("folds repeated tool_call status updates for the same toolUseId into one block", () => {
|
||||
@@ -322,4 +322,48 @@ describe("RunTranscriptView", () => {
|
||||
expect(html).not.toContain("line-250");
|
||||
expect(html).not.toContain("line-499");
|
||||
});
|
||||
|
||||
it("keeps the streaming tail block's key stable as deltas merge in", () => {
|
||||
// A streaming assistant message accumulates deltas; each delta advances the
|
||||
// block's `ts`. The React key must stay anchored to the block's opening
|
||||
// timestamp so the tail does not unmount/remount (restarting its fade).
|
||||
const firstDelta: TranscriptEntry[] = [
|
||||
{ kind: "assistant", ts: "2026-03-12T00:00:00.000Z", text: "Hel", delta: true },
|
||||
];
|
||||
const withMoreDeltas: TranscriptEntry[] = [
|
||||
...firstDelta,
|
||||
{ kind: "assistant", ts: "2026-03-12T00:00:00.400Z", text: "lo the", delta: true },
|
||||
{ kind: "assistant", ts: "2026-03-12T00:00:00.900Z", text: "re", delta: true },
|
||||
];
|
||||
|
||||
const keyOf = (entries: TranscriptEntry[]) => {
|
||||
const keyed = keyTranscriptBlocks(normalizeTranscript(entries, true));
|
||||
return keyed[keyed.length - 1]!.key;
|
||||
};
|
||||
|
||||
// Same opening timestamp → identical key even though `ts` advanced.
|
||||
expect(keyOf(withMoreDeltas)).toBe(keyOf(firstDelta));
|
||||
// And that key is anchored to the opening ts, not the latest one.
|
||||
expect(keyOf(withMoreDeltas)).toContain("2026-03-12T00:00:00.000Z");
|
||||
expect(keyOf(withMoreDeltas)).not.toContain("2026-03-12T00:00:00.900Z");
|
||||
});
|
||||
|
||||
it("assigns unique keys and does not remount earlier blocks when a new tail arrives", () => {
|
||||
const base: TranscriptEntry[] = [
|
||||
{ kind: "assistant", ts: "2026-03-12T00:00:00.000Z", text: "first message", delta: true },
|
||||
{ kind: "thinking", ts: "2026-03-12T00:00:01.000Z", text: "pondering", delta: true },
|
||||
];
|
||||
const withNewTail: TranscriptEntry[] = [
|
||||
...base,
|
||||
{ kind: "assistant", ts: "2026-03-12T00:00:02.000Z", text: "second message", delta: true },
|
||||
];
|
||||
|
||||
const baseKeys = keyTranscriptBlocks(normalizeTranscript(base, true)).map((b) => b.key);
|
||||
const nextKeys = keyTranscriptBlocks(normalizeTranscript(withNewTail, true)).map((b) => b.key);
|
||||
|
||||
// Keys are unique within a render.
|
||||
expect(new Set(nextKeys).size).toBe(nextKeys.length);
|
||||
// Existing blocks keep their identity when a new block appends after them.
|
||||
expect(nextKeys.slice(0, baseKeys.length)).toEqual(baseKeys);
|
||||
});
|
||||
});
|
||||
@@ -48,12 +48,18 @@ type TranscriptBlock =
|
||||
type: "message";
|
||||
role: "assistant" | "user";
|
||||
ts: string;
|
||||
// Timestamp of the first entry that opened this block. `ts` tracks the
|
||||
// latest merged delta and mutates every chunk; `startTs` stays fixed so
|
||||
// the React key is stable and the streaming block does not remount (and
|
||||
// restart its fade) on each delta.
|
||||
startTs: string;
|
||||
text: string;
|
||||
streaming: boolean;
|
||||
}
|
||||
| {
|
||||
type: "thinking";
|
||||
ts: string;
|
||||
startTs: string;
|
||||
text: string;
|
||||
streaming: boolean;
|
||||
}
|
||||
@@ -73,6 +79,7 @@ type TranscriptBlock =
|
||||
| {
|
||||
type: "activity";
|
||||
ts: string;
|
||||
startTs: string;
|
||||
activityId?: string;
|
||||
name: string;
|
||||
status: "running" | "completed";
|
||||
@@ -125,6 +132,7 @@ type TranscriptBlock =
|
||||
| {
|
||||
type: "stdout";
|
||||
ts: string;
|
||||
startTs: string;
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
@@ -550,6 +558,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
||||
type: "message",
|
||||
role: entry.kind,
|
||||
ts: entry.ts,
|
||||
startTs: entry.ts,
|
||||
text: entry.text,
|
||||
streaming: isStreaming,
|
||||
});
|
||||
@@ -567,6 +576,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
||||
blocks.push({
|
||||
type: "thinking",
|
||||
ts: entry.ts,
|
||||
startTs: entry.ts,
|
||||
text: entry.text,
|
||||
streaming: isStreaming,
|
||||
});
|
||||
@@ -691,6 +701,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
||||
const block: Extract<TranscriptBlock, { type: "activity" }> = {
|
||||
type: "activity",
|
||||
ts: entry.ts,
|
||||
startTs: entry.ts,
|
||||
activityId: activity.activityId,
|
||||
name: activity.name,
|
||||
status: activity.status,
|
||||
@@ -758,6 +769,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
||||
blocks.push({
|
||||
type: "stdout",
|
||||
ts: entry.ts,
|
||||
startTs: entry.ts,
|
||||
text: entry.text,
|
||||
});
|
||||
}
|
||||
@@ -766,6 +778,55 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
|
||||
return groupToolBlocks(groupCommandBlocks(blocks));
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable identity for a block's React key. Anchored to the block's opening
|
||||
* timestamp (`startTs`) or a durable id (`toolUseId`, `activityId`) rather than
|
||||
* its latest `ts`, which mutates on every streamed delta. A mutating key
|
||||
* remounts the block, restarting its 300ms fade-in so the text visibly blinks
|
||||
* out and back each chunk; a stable one keeps the streaming tail mounted.
|
||||
*/
|
||||
function transcriptBlockIdentity(block: TranscriptBlock): string {
|
||||
switch (block.type) {
|
||||
case "message":
|
||||
return `message:${block.role}:${block.startTs}`;
|
||||
case "thinking":
|
||||
return `thinking:${block.startTs}`;
|
||||
case "stdout":
|
||||
return `stdout:${block.startTs}`;
|
||||
case "activity":
|
||||
return `activity:${block.activityId ?? block.startTs}`;
|
||||
case "tool":
|
||||
return `tool:${block.toolUseId ?? block.ts}`;
|
||||
case "command_group":
|
||||
return `command_group:${block.ts}`;
|
||||
case "tool_group":
|
||||
return `tool_group:${block.ts}`;
|
||||
case "stderr_group":
|
||||
return `stderr_group:${block.ts}`;
|
||||
case "system_group":
|
||||
return `system_group:${block.ts}`;
|
||||
case "diff_group":
|
||||
return `diff_group:${block.ts}`;
|
||||
case "event":
|
||||
return `event:${block.label}:${block.ts}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign each block a stable, unique React key. Identity is position-independent
|
||||
* so earlier blocks collapsing (truncation) does not remount the survivors; a
|
||||
* per-render occurrence counter disambiguates the rare identity collision.
|
||||
*/
|
||||
export function keyTranscriptBlocks(blocks: TranscriptBlock[]): Array<{ block: TranscriptBlock; key: string }> {
|
||||
const seen = new Map<string, number>();
|
||||
return blocks.map((block) => {
|
||||
const identity = transcriptBlockIdentity(block);
|
||||
const occurrence = seen.get(identity) ?? 0;
|
||||
seen.set(identity, occurrence + 1);
|
||||
return { block, key: occurrence === 0 ? identity : `${identity}#${occurrence}` };
|
||||
});
|
||||
}
|
||||
|
||||
function TranscriptMessageBlock({
|
||||
block,
|
||||
density,
|
||||
@@ -789,7 +850,9 @@ function TranscriptMessageBlock({
|
||||
<MarkdownBody
|
||||
className={cn(
|
||||
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
compact ? "text-xs leading-5 text-foreground/85" : "text-sm",
|
||||
// Match the default view's chat message body (IssueChatThread:
|
||||
// `text-sm leading-6`) so streamed text reads identically (PAP-461, A2).
|
||||
compact ? "text-xs leading-5 text-foreground/85" : "text-sm leading-6",
|
||||
)}
|
||||
externalReferences={externalReferences}
|
||||
>
|
||||
@@ -798,7 +861,7 @@ function TranscriptMessageBlock({
|
||||
{block.streaming && (
|
||||
<div className="mt-2 inline-flex items-center gap-1 text-(length:--text-nano) font-medium italic text-muted-foreground">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-70" />
|
||||
<span className="tc-live-ping absolute inline-flex h-full w-full rounded-full bg-current opacity-70" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
|
||||
</span>
|
||||
Streaming
|
||||
@@ -822,8 +885,11 @@ function TranscriptThinkingBlock({
|
||||
return (
|
||||
<MarkdownBody
|
||||
className={cn(
|
||||
"italic text-foreground/70 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
density === "compact" ? "text-(length:--text-micro) leading-5" : "text-sm leading-6",
|
||||
// Match the default view's chain-of-thought text (IssueChatThread:
|
||||
// `text-(length:--text-compact) italic leading-5 text-muted-foreground/70`)
|
||||
// so streamed thinking reads identically across both views (PAP-461, A2).
|
||||
"italic text-muted-foreground/70 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
density === "compact" ? "text-(length:--text-micro) leading-5" : "text-(length:--text-compact) leading-5",
|
||||
className,
|
||||
)}
|
||||
externalReferences={externalReferences}
|
||||
@@ -1304,7 +1370,7 @@ function TranscriptActivityRow({
|
||||
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-300" />
|
||||
) : (
|
||||
<span className="relative mt-1 flex h-2.5 w-2.5 shrink-0">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-70" />
|
||||
<span className="tc-live-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-70" />
|
||||
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-blue-500" />
|
||||
</span>
|
||||
)}
|
||||
@@ -1716,6 +1782,7 @@ export function RunTranscriptView({
|
||||
[entries, mode, streaming],
|
||||
);
|
||||
const visibleBlocks = limit ? blocks.slice(-limit) : blocks;
|
||||
const keyedBlocks = useMemo(() => keyTranscriptBlocks(visibleBlocks), [visibleBlocks]);
|
||||
const visibleEntries = limit ? entries.slice(-limit) : entries;
|
||||
|
||||
if (entries.length === 0) {
|
||||
@@ -1736,10 +1803,10 @@ export function RunTranscriptView({
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
{visibleBlocks.map((block, index) => (
|
||||
{keyedBlocks.map(({ block, key }, index) => (
|
||||
<div
|
||||
key={`${block.type}-${block.ts}-${index}`}
|
||||
className={cn(index === visibleBlocks.length - 1 && streaming && "animate-in fade-in slide-in-from-bottom-1 duration-300")}
|
||||
key={key}
|
||||
className={cn(index === keyedBlocks.length - 1 && streaming && "tc-stream-block-enter")}
|
||||
>
|
||||
{block.type === "message" && (
|
||||
<TranscriptMessageBlock
|
||||
|
||||
@@ -515,4 +515,112 @@ describe("useLiveRunTranscripts", () => {
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("retains an accumulated buffer through a transient empty poll (PAP-462 B3)", async () => {
|
||||
const ts = "2026-08-08T00:00:00.000Z";
|
||||
const row = JSON.stringify({
|
||||
ts,
|
||||
stream: "stdout",
|
||||
chunk: '{"type":"acpx.text_delta","text":"hello"}\n',
|
||||
seq: 1,
|
||||
});
|
||||
// Serve the buffered chunk on the FIRST persisted-log read only; every later
|
||||
// read (including after the run reappears) returns nothing new. So the chunk
|
||||
// can only be present the second time if the buffer survived the gap.
|
||||
logMock.mockResolvedValue({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 100 });
|
||||
logMock.mockResolvedValueOnce({ runId: "run-1", store: "memory", logRef: "log-1", content: `${row}\n`, nextOffset: 100 });
|
||||
|
||||
const captured: { value: ReturnType<typeof useLiveRunTranscripts> | null } = { value: null };
|
||||
function Harness({ runs }: { runs: Array<{ id: string; status: string; adapterType: string }> }) {
|
||||
captured.value = useLiveRunTranscripts({ companyId: "company-1", runs, enableRealtimeUpdates: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const runList = [{ id: "run-1", status: "running", adapterType: "gemini_local" }];
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness runs={runList} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(captured.value?.transcriptByRun.get("run-1")).toHaveLength(1);
|
||||
|
||||
// Transient empty poll: run momentarily absent from the list.
|
||||
await act(async () => {
|
||||
root.render(<Harness runs={[]} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Run reappears within the grace window — the buffer must survive rather than
|
||||
// re-hydrate from a (now empty) truncated read.
|
||||
await act(async () => {
|
||||
root.render(<Harness runs={runList} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(captured.value?.transcriptByRun.get("run-1")).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("prunes a buffer once a run stays absent past the grace window (PAP-462 B3)", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const ts = "2026-08-08T00:00:00.000Z";
|
||||
const row = JSON.stringify({
|
||||
ts,
|
||||
stream: "stdout",
|
||||
chunk: '{"type":"acpx.text_delta","text":"hello"}\n',
|
||||
seq: 1,
|
||||
});
|
||||
logMock.mockResolvedValue({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 100 });
|
||||
logMock.mockResolvedValueOnce({ runId: "run-1", store: "memory", logRef: "log-1", content: `${row}\n`, nextOffset: 100 });
|
||||
|
||||
const captured: { value: ReturnType<typeof useLiveRunTranscripts> | null } = { value: null };
|
||||
function Harness({ runs }: { runs: Array<{ id: string; status: string; adapterType: string }> }) {
|
||||
captured.value = useLiveRunTranscripts({ companyId: "company-1", runs, enableRealtimeUpdates: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const runList = [{ id: "run-1", status: "running", adapterType: "gemini_local" }];
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness runs={runList} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(captured.value?.transcriptByRun.get("run-1")).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Harness runs={[]} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Stay absent long enough for the grace window to lapse and the deferred
|
||||
// prune to fire.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(25_000);
|
||||
});
|
||||
|
||||
// On reappear the buffer is gone, so the (now empty) read rebuilds nothing.
|
||||
await act(async () => {
|
||||
root.render(<Harness runs={runList} />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(captured.value?.transcriptByRun.get("run-1") ?? []).toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
mergeRunLogChunks,
|
||||
parsePersistedLogContent,
|
||||
readChunkSeq,
|
||||
type ChunkRetentionBudget,
|
||||
} from "../../lib/run-log-chunks";
|
||||
|
||||
// TODO(perf): this whole hook polls the log/runs endpoints on an interval. The
|
||||
@@ -24,6 +25,20 @@ const LOG_READ_LIMIT_BYTES = 256_000;
|
||||
// gaps and reconnects instead of polling every couple of seconds.
|
||||
const REALTIME_FALLBACK_POLL_INTERVAL_MS = 30_000;
|
||||
const EMPTY_RUN_LOG_CHUNKS: RunLogChunk[] = [];
|
||||
// Retained transcript payload budget for full task views. A byte budget (rather
|
||||
// than a tiny chunk count) keeps the whole streamed scrollback intact — a
|
||||
// delta-streaming run emits thousands of one-token chunks in seconds, and the
|
||||
// old 200-chunk cap discarded just-rendered messages off the top irreversibly.
|
||||
// If a run genuinely exceeds this, the oldest output collapses behind a visible
|
||||
// marker instead of vanishing (see `applyRetentionBudget`).
|
||||
const TASK_VIEW_MAX_BYTES_PER_RUN = 2_000_000;
|
||||
// Grace period before an accumulated transcript buffer is pruned for a run that
|
||||
// has vanished from the `runs` list. The parent refetches runs on its own
|
||||
// interval, and a single transient empty/errored poll would otherwise wipe the
|
||||
// buffer — forcing a rehydration that skips to the last `LOG_READ_LIMIT_BYTES`
|
||||
// and silently drops already-rendered scrollback (PAP-462 B3). A run that is
|
||||
// genuinely gone stays absent past this window and is then pruned as before.
|
||||
const RUN_ABSENCE_PRUNE_GRACE_MS = 20_000;
|
||||
|
||||
export interface RunTranscriptSource {
|
||||
id: string;
|
||||
@@ -37,7 +52,13 @@ export interface RunTranscriptSource {
|
||||
interface UseLiveRunTranscriptsOptions {
|
||||
runs: RunTranscriptSource[];
|
||||
companyId?: string | null;
|
||||
/**
|
||||
* Compact chunk-count cap for ticker-style consumers (dashboard). When set,
|
||||
* trimming is silent — the historical behavior. Full task views omit this and
|
||||
* use the byte budget below instead.
|
||||
*/
|
||||
maxChunksPerRun?: number;
|
||||
maxBytesPerRun?: number;
|
||||
logPollIntervalMs?: number;
|
||||
logReadLimitBytes?: number;
|
||||
enableRealtimeUpdates?: boolean;
|
||||
@@ -67,11 +88,21 @@ export function resolveInitialLogOffset(run: RunTranscriptSource, limitBytes: nu
|
||||
export function useLiveRunTranscripts({
|
||||
runs,
|
||||
companyId,
|
||||
maxChunksPerRun = 200,
|
||||
maxChunksPerRun,
|
||||
maxBytesPerRun = TASK_VIEW_MAX_BYTES_PER_RUN,
|
||||
logPollIntervalMs = LOG_POLL_INTERVAL_MS,
|
||||
logReadLimitBytes = LOG_READ_LIMIT_BYTES,
|
||||
enableRealtimeUpdates = true,
|
||||
}: UseLiveRunTranscriptsOptions) {
|
||||
// Ticker consumers opt into the silent chunk-count cap; full task views use a
|
||||
// byte budget that collapses (not discards) the oldest output when exceeded.
|
||||
const retentionBudget: ChunkRetentionBudget = useMemo(
|
||||
() =>
|
||||
typeof maxChunksPerRun === "number"
|
||||
? { maxChunks: maxChunksPerRun }
|
||||
: { maxBytes: maxBytesPerRun, collapseTrimmed: true },
|
||||
[maxChunksPerRun, maxBytesPerRun],
|
||||
);
|
||||
const runsKey = useMemo(
|
||||
() =>
|
||||
runs
|
||||
@@ -95,6 +126,14 @@ export function useLiveRunTranscripts({
|
||||
const pendingLogRowsByRunRef = useRef(new Map<string, string>());
|
||||
const logOffsetByRunRef = useRef(new Map<string, number>());
|
||||
const missingTerminalLogRunIdsRef = useRef(new Set<string>());
|
||||
// PAP-462 B3: buffered runs that dropped out of the `runs` list, mapped to the
|
||||
// wall-clock deadline (ms) after which their buffer may be pruned. A run still
|
||||
// inside its grace window is retained across the empty poll; `pruneTick` fires
|
||||
// the effect again once the nearest deadline elapses so a run that stays gone
|
||||
// is eventually cleaned up even if the `runs` list never changes again.
|
||||
const absenceDeadlineByRunRef = useRef(new Map<string, number>());
|
||||
const prevKnownRunIdsRef = useRef(new Set<string>());
|
||||
const [pruneTick, setPruneTick] = useState(0);
|
||||
const transcriptCacheRef = useRef(new Map<string, {
|
||||
adapterType: string;
|
||||
chunks: RunLogChunk[];
|
||||
@@ -134,7 +173,7 @@ export function useLiveRunTranscripts({
|
||||
seenChunkKeys: seenChunkKeysRef.current,
|
||||
trimmedSeqFloorByRun: trimmedSeqFloorByRunRef.current,
|
||||
},
|
||||
maxChunksPerRun,
|
||||
retentionBudget,
|
||||
);
|
||||
if (!changed) return prev;
|
||||
const next = new Map(prev);
|
||||
@@ -145,10 +184,40 @@ export function useLiveRunTranscripts({
|
||||
|
||||
useEffect(() => {
|
||||
const knownRunIds = new Set(normalizedRuns.map((run) => run.id));
|
||||
const now = Date.now();
|
||||
const deadlines = absenceDeadlineByRunRef.current;
|
||||
|
||||
// PAP-462 B3: a run that just disappeared from the list starts its grace
|
||||
// clock; one that reappeared clears any pending deadline. Comparing against
|
||||
// the previous known set means a transient empty poll only *arms* the timer
|
||||
// rather than pruning the buffer outright.
|
||||
for (const runId of prevKnownRunIdsRef.current) {
|
||||
if (!knownRunIds.has(runId) && !deadlines.has(runId)) {
|
||||
deadlines.set(runId, now + RUN_ABSENCE_PRUNE_GRACE_MS);
|
||||
}
|
||||
}
|
||||
for (const runId of knownRunIds) {
|
||||
deadlines.delete(runId);
|
||||
}
|
||||
prevKnownRunIdsRef.current = knownRunIds;
|
||||
|
||||
// Retain known runs plus any absent run still inside its grace window; only
|
||||
// runs absent past their deadline are actually pruned.
|
||||
const retainedRunIds = new Set(knownRunIds);
|
||||
let soonestExpiryMs = Number.POSITIVE_INFINITY;
|
||||
for (const [runId, deadline] of deadlines) {
|
||||
if (deadline > now) {
|
||||
retainedRunIds.add(runId);
|
||||
soonestExpiryMs = Math.min(soonestExpiryMs, deadline);
|
||||
} else {
|
||||
deadlines.delete(runId);
|
||||
}
|
||||
}
|
||||
|
||||
setChunksByRun((prev) => {
|
||||
const next = new Map<string, RunLogChunk[]>();
|
||||
for (const [runId, chunks] of prev) {
|
||||
if (knownRunIds.has(runId)) {
|
||||
if (retainedRunIds.has(runId)) {
|
||||
next.set(runId, chunks);
|
||||
}
|
||||
}
|
||||
@@ -157,7 +226,7 @@ export function useLiveRunTranscripts({
|
||||
setHydratedRunIds((prev) => {
|
||||
const next = new Set<string>();
|
||||
for (const runId of prev) {
|
||||
if (knownRunIds.has(runId)) {
|
||||
if (retainedRunIds.has(runId)) {
|
||||
next.add(runId);
|
||||
}
|
||||
}
|
||||
@@ -166,31 +235,41 @@ export function useLiveRunTranscripts({
|
||||
|
||||
for (const key of pendingLogRowsByRunRef.current.keys()) {
|
||||
const runId = key.replace(/:records$/, "");
|
||||
if (!knownRunIds.has(runId)) {
|
||||
if (!retainedRunIds.has(runId)) {
|
||||
pendingLogRowsByRunRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
for (const runId of logOffsetByRunRef.current.keys()) {
|
||||
if (!knownRunIds.has(runId)) {
|
||||
if (!retainedRunIds.has(runId)) {
|
||||
logOffsetByRunRef.current.delete(runId);
|
||||
}
|
||||
}
|
||||
for (const runId of trimmedSeqFloorByRunRef.current.keys()) {
|
||||
if (!knownRunIds.has(runId)) {
|
||||
if (!retainedRunIds.has(runId)) {
|
||||
trimmedSeqFloorByRunRef.current.delete(runId);
|
||||
}
|
||||
}
|
||||
for (const runId of missingTerminalLogRunIdsRef.current.keys()) {
|
||||
if (!knownRunIds.has(runId)) {
|
||||
if (!retainedRunIds.has(runId)) {
|
||||
missingTerminalLogRunIdsRef.current.delete(runId);
|
||||
}
|
||||
}
|
||||
for (const runId of transcriptCacheRef.current.keys()) {
|
||||
if (!knownRunIds.has(runId)) {
|
||||
if (!retainedRunIds.has(runId)) {
|
||||
transcriptCacheRef.current.delete(runId);
|
||||
}
|
||||
}
|
||||
}, [normalizedRuns]);
|
||||
|
||||
// Re-run once the nearest grace window elapses so a run that stays gone is
|
||||
// pruned even if `normalizedRuns` never changes again.
|
||||
if (soonestExpiryMs !== Number.POSITIVE_INFINITY) {
|
||||
const timer = window.setTimeout(
|
||||
() => setPruneTick((tick) => tick + 1),
|
||||
Math.max(0, soonestExpiryMs - now) + 50,
|
||||
);
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
}, [normalizedRuns, pruneTick]);
|
||||
|
||||
useEffect(() => {
|
||||
if (normalizedRuns.length === 0) return;
|
||||
|
||||
Reference in new issue
Block a user