fix(ui): stabilize active-run steering queue (#12834)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The issue detail page shows a live agent run and accepts follow-up instructions. > - A follow-up must stay in a stable queue until the user sends, reorders, or removes it. > - Native runners can receive a steering event in the active run. > - Legacy runners must interrupt the active run and start a follow-up run. > - The current UI moved comments between the queue and the transcript and could show duplicate text or ambiguous chronology. > - This pull request makes the queue projection durable, keeps each message in one clear place, and labels when queued input was actually steered or delivered. > - The benefit is predictable steering with stable ordering, no duplicate messages, and visible causal timing. ## Linked Issues or Issue Description Refs #11374. Refs #12591. **What happened?** During an active run, a new follow-up could first appear as a transcript bubble and then move into the steering queue. After a steer or remove action, it could appear again. Progress text could also repeat the final response text. Once consumed, a queued bubble displayed only its original submission time even though it moved to its later causal slot, and a native run split by steering looked like two unrelated runs. **Expected behavior** An active-run follow-up must appear in the queue immediately. A native steer must move it once into the active run. A legacy interrupt must move it once into the follow-up run. A removed item must stay removed. Progress text that is identical to the final response must appear once. Consumed follow-ups must show both queue and steer/delivery times, and post-steer native segments must identify themselves as continuations of the same run. **Steps to reproduce** 1. Start a long-running task. 2. Send two or more follow-up messages while the agent is active. 3. Reorder the messages and remove one message. 4. Send the first queued message as steering. 5. Observe the queue and transcript during and after both runs. **Paperclip version or commit** The problem reproduced on commit `da1e40302`. **Deployment mode** Local development with the embedded database. ## What Changed - Project queued comments into the steering well for native and legacy live runners. - Send native steering to the active run and use interrupt-and-follow-up for legacy runners. - Keep optimistic queue order stable across refreshes and roll back failed actions. - Remove discarded comments from the transcript cache and keep them removed when the queue becomes empty. - Collapse only the final progress occurrence matching the durable response, including across steered transcript segments. - Show `Queued … · Steered …` for same-run input and `Queued … · Delivered …` for successor-run input at their causal positions. - Label settled and live post-steer segments `Continued after steering` and time them from the steer boundary. - Add regression tests for queue display, steering, fallback interrupt, reorder, remove, rollback, duplicate text, causal timestamps, and live/settled continuation headers. ## Verification - Ran the final focused steering/chronology UI suite with 233 passing tests. - Ran the activity-service regression suite with 5 passing tests. - Ran the broader queue-focused UI suite with 298 passing tests before the final chronology refinement. - Ran `pnpm -r typecheck` successfully. - Ran `pnpm build` successfully. - Ran `pnpm check:token-gates` successfully. - Tested native steering in a real browser with a 90-second baseline wait and a three-second steering correction. - Confirmed that the old final response did not appear before the steered response. - Tested three queued messages in a real browser. - Confirmed that reorder changed delivery order and that the removed message was never sent or shown again. - Tested a legacy runner in a real browser. - Confirmed that it used the interrupt fallback and showed the follow-up once. - Reloaded a saved mixed-steer/successor-run thread and confirmed the causal timestamps and continuation header render in the correct positions. - The complete macOS suite reaches five unrelated platform assertions in workspace-runtime tests. Two compare `/var` with `/private/var`. Three require Linux `/proc` listener data. GitHub Actions provides the authoritative Linux run. ## Risks - Low risk. The change is limited to issue-chat queue projection and transcript presentation. - The server run-history API adds only a read-only `contextIssueId` projection; the database schema does not change. - Optimistic actions restore the prior UI state when a request fails. ## Model Used - OpenAI Codex with GPT-5, extended reasoning, browser automation, shell tools, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
b84964e5a2
commit
bf95a7eae2
|
|
@ -180,6 +180,7 @@ describeEmbeddedPostgres("activity service", () => {
|
|||
runId,
|
||||
agentId,
|
||||
invocationSource: "assignment",
|
||||
contextIssueId: issueId,
|
||||
});
|
||||
expect(runs[0]?.usageJson).toEqual({
|
||||
inputTokens: 11,
|
||||
|
|
|
|||
|
|
@ -406,6 +406,7 @@ export function activityService(db: Db) {
|
|||
wakeCommentIds: sql<string[] | null>`${heartbeatRuns.contextSnapshot} -> 'wakeCommentIds'`,
|
||||
wakeCommentId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`,
|
||||
contextCommentId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'commentId'`,
|
||||
contextIssueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.innerJoin(
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export interface RunForIssue {
|
|||
wakeCommentIds?: string[] | null;
|
||||
wakeCommentId?: string | null;
|
||||
contextCommentId?: string | null;
|
||||
contextIssueId?: string | null;
|
||||
contextSnapshot?: Record<string, unknown> | null;
|
||||
environment?: {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -63,7 +63,10 @@ vi.mock("@/hooks/useIssuePlanDocument", () => ({
|
|||
useIssuePlanDocument: () => planState,
|
||||
}));
|
||||
vi.mock("@/hooks/useStreamlinedUiEnabled", () => ({
|
||||
useStreamlinedUiEnabled: () => ({ enabled: streamlinedState.enabled, loaded: true }),
|
||||
useStreamlinedUiEnabled: () => ({
|
||||
enabled: streamlinedState.enabled,
|
||||
loaded: true,
|
||||
}),
|
||||
}));
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({
|
||||
|
|
@ -1152,6 +1155,255 @@ describe("TaskChatThread runtime transcript selection", () => {
|
|||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not repeat progress when the persisted completion comment owns the same text", () => {
|
||||
const repeated = "BASELINE-DONE";
|
||||
nativeTranscriptState.transcriptByRun.set("native-repeated-final", [
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-08-25T18:00:01.000Z",
|
||||
text: repeated,
|
||||
channel: "progress",
|
||||
},
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-08-25T18:00:02.000Z",
|
||||
text: repeated,
|
||||
channel: "final",
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<TaskChatThread
|
||||
comments={[
|
||||
{
|
||||
id: "native-repeated-final-comment",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorType: "agent",
|
||||
authorAgentId: "agent-1",
|
||||
authorUserId: null,
|
||||
body: repeated,
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
runId: "native-repeated-final",
|
||||
createdAt: new Date("2026-08-25T18:00:03.000Z"),
|
||||
updatedAt: new Date("2026-08-25T18:00:03.000Z"),
|
||||
},
|
||||
]}
|
||||
onAdd={async () => {}}
|
||||
linkedRuns={[
|
||||
{
|
||||
runId: "native-repeated-final",
|
||||
runtimeMode: "native",
|
||||
status: "succeeded",
|
||||
agentId: "agent-1",
|
||||
agentName: "Runner",
|
||||
adapterType: "paperclip_runner",
|
||||
createdAt: "2026-08-25T18:00:00.000Z",
|
||||
startedAt: "2026-08-25T18:00:00.000Z",
|
||||
finishedAt: "2026-08-25T18:00:03.000Z",
|
||||
resultJson: {
|
||||
presentationDecision: {
|
||||
schema: "paperclip.run_presentation_decision.v1",
|
||||
chosenSource: "final_agent_message",
|
||||
commentId: "native-repeated-final-comment",
|
||||
},
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.textContent?.split(repeated)).toHaveLength(2);
|
||||
expect(
|
||||
Array.from(
|
||||
container.querySelectorAll(
|
||||
'[data-testid="task-chat-phase-interstitial"]',
|
||||
),
|
||||
).some((element) => element.textContent?.includes(repeated)),
|
||||
).toBe(false);
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-agent-bubble"]')
|
||||
?.textContent,
|
||||
).toContain(repeated);
|
||||
});
|
||||
|
||||
it("removes only the final repeated progress across steering segments", () => {
|
||||
const repeated = "SAME-BEFORE-AND-AFTER";
|
||||
nativeTranscriptState.transcriptByRun.set("native-steered-repetition", [
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-08-25T18:00:01.000Z",
|
||||
text: repeated,
|
||||
channel: "progress",
|
||||
},
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-08-25T18:00:03.000Z",
|
||||
text: repeated,
|
||||
channel: "progress",
|
||||
},
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-08-25T18:00:04.000Z",
|
||||
text: repeated,
|
||||
channel: "final",
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<TaskChatThread
|
||||
comments={[
|
||||
{
|
||||
id: "steering-comment",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
body: "Steer this run.",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
runId: null,
|
||||
followUpRequested: true,
|
||||
consumedByRunId: "native-steered-repetition",
|
||||
steeredIntoRunId: "native-steered-repetition",
|
||||
conversationAnchorAt: new Date("2026-08-25T18:00:02.000Z"),
|
||||
createdAt: new Date("2026-08-25T17:59:32.000Z"),
|
||||
updatedAt: new Date("2026-08-25T18:00:02.000Z"),
|
||||
},
|
||||
{
|
||||
id: "native-steered-repetition-final",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorType: "agent",
|
||||
authorAgentId: "agent-1",
|
||||
authorUserId: null,
|
||||
body: repeated,
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
runId: "native-steered-repetition",
|
||||
createdAt: new Date("2026-08-25T18:00:05.000Z"),
|
||||
updatedAt: new Date("2026-08-25T18:00:05.000Z"),
|
||||
},
|
||||
]}
|
||||
onAdd={async () => {}}
|
||||
linkedRuns={[
|
||||
{
|
||||
runId: "native-steered-repetition",
|
||||
runtimeMode: "native",
|
||||
status: "succeeded",
|
||||
agentId: "agent-1",
|
||||
agentName: "Runner",
|
||||
adapterType: "paperclip_runner",
|
||||
createdAt: "2026-08-25T18:00:00.000Z",
|
||||
startedAt: "2026-08-25T18:00:00.000Z",
|
||||
finishedAt: "2026-08-25T18:00:05.000Z",
|
||||
resultJson: {
|
||||
presentationDecision: {
|
||||
schema: "paperclip.run_presentation_decision.v1",
|
||||
chosenSource: "final_agent_message",
|
||||
commentId: "native-steered-repetition-final",
|
||||
},
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const matchingPhases = Array.from(
|
||||
container.querySelectorAll(
|
||||
'[data-testid="task-chat-phase-interstitial"]',
|
||||
),
|
||||
).filter((element) => element.textContent?.includes(repeated));
|
||||
expect(matchingPhases).toHaveLength(1);
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-agent-bubble"]')
|
||||
?.textContent,
|
||||
).toContain(repeated);
|
||||
expect(container.textContent).toContain(
|
||||
`Queued ${new Date("2026-08-25T17:59:32.000Z").toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })} · Steered ${new Date("2026-08-25T18:00:02.000Z").toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`,
|
||||
);
|
||||
const turnHeaders = Array.from(
|
||||
container.querySelectorAll('[data-testid="task-chat-turn-summary"]'),
|
||||
);
|
||||
expect(turnHeaders).toHaveLength(2);
|
||||
expect(turnHeaders[0]?.textContent).not.toContain(
|
||||
"Continued after steering",
|
||||
);
|
||||
expect(turnHeaders[1]?.textContent).toContain(
|
||||
"Continued after steering · Worked for",
|
||||
);
|
||||
});
|
||||
|
||||
it("labels the live tail as a continuation after the steering bubble", () => {
|
||||
nativeTranscriptState.transcriptByRun.set("native-live-steered", [
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-08-25T18:00:01.000Z",
|
||||
text: "Work before steering.",
|
||||
channel: "progress",
|
||||
},
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-08-25T18:00:03.000Z",
|
||||
text: "Work after steering.",
|
||||
channel: "progress",
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<TaskChatThread
|
||||
comments={[
|
||||
{
|
||||
id: "live-steering-comment",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
body: "Change course now.",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
runId: null,
|
||||
consumedByRunId: "native-live-steered",
|
||||
steeredIntoRunId: "native-live-steered",
|
||||
conversationAnchorAt: new Date("2026-08-25T18:00:02.000Z"),
|
||||
createdAt: new Date("2026-08-25T17:59:32.000Z"),
|
||||
updatedAt: new Date("2026-08-25T18:00:02.000Z"),
|
||||
},
|
||||
]}
|
||||
onAdd={async () => {}}
|
||||
issueStatus="in_progress"
|
||||
activeRun={{
|
||||
id: "native-live-steered",
|
||||
runtimeMode: "native",
|
||||
status: "running",
|
||||
invocationSource: "issue",
|
||||
triggerDetail: null,
|
||||
startedAt: "2026-08-25T18:00:00.000Z",
|
||||
finishedAt: null,
|
||||
createdAt: "2026-08-25T18:00:00.000Z",
|
||||
agentId: "agent-1",
|
||||
agentName: "Runner",
|
||||
adapterType: "paperclip_runner",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-turn-summary"]')
|
||||
?.textContent,
|
||||
).not.toContain("Continued after steering");
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-turn-status-header"]')
|
||||
?.textContent,
|
||||
).toContain("Continued after steering · Working for");
|
||||
expect(container.textContent).toContain(
|
||||
`Queued ${new Date("2026-08-25T17:59:32.000Z").toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })} · Steered ${new Date("2026-08-25T18:00:02.000Z").toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a yielded question turn out of the final-response slot", () => {
|
||||
nativeTranscriptState.transcriptByRun.set("native-question", [
|
||||
{
|
||||
|
|
@ -1507,9 +1759,13 @@ describe("TaskChatThread composer alignment", () => {
|
|||
streamlinedState.enabled = false;
|
||||
render(<TaskChatThread comments={[]} onAdd={async () => {}} />);
|
||||
|
||||
const dock = container.querySelector('[data-testid="task-chat-composer-dock"]');
|
||||
const dock = container.querySelector(
|
||||
'[data-testid="task-chat-composer-dock"]',
|
||||
);
|
||||
const composer = container.querySelector(".paperclip-task-chat-composer");
|
||||
const send = container.querySelector('[data-testid="task-chat-composer-send"]');
|
||||
const send = container.querySelector(
|
||||
'[data-testid="task-chat-composer-send"]',
|
||||
);
|
||||
|
||||
expect(dock?.classList).not.toContain("md:px-0");
|
||||
expect(dock?.classList).not.toContain("md:pb-4");
|
||||
|
|
@ -1630,10 +1886,16 @@ describe("TaskChatThread blocker links", () => {
|
|||
expect(notices).toHaveLength(2);
|
||||
expect(notices[0]?.getAttribute("data-placement")).toBe("top");
|
||||
expect(notices[1]?.getAttribute("data-placement")).toBe("bottom");
|
||||
expect(notices[0]?.textContent).toContain("Blocked byPAP-600Waiting in review");
|
||||
expect(notices[0]?.textContent).toContain(
|
||||
"Blocked byPAP-600Waiting in review",
|
||||
);
|
||||
expect(notices[0]?.textContent).toContain("Root blockerPAP-777Actual work");
|
||||
expect(notices[1]?.textContent).toContain("Still blocked byPAP-600Waiting in review");
|
||||
expect(notices[1]?.textContent).toContain("Root blocker remainsPAP-777Actual work");
|
||||
expect(notices[1]?.textContent).toContain(
|
||||
"Still blocked byPAP-600Waiting in review",
|
||||
);
|
||||
expect(notices[1]?.textContent).toContain(
|
||||
"Root blocker remainsPAP-777Actual work",
|
||||
);
|
||||
for (const notice of notices) {
|
||||
expect(notice.querySelector('a[href="/issues/PAP-600"]')).not.toBeNull();
|
||||
expect(notice.querySelector('a[href="/issues/PAP-777"]')).not.toBeNull();
|
||||
|
|
@ -1717,17 +1979,28 @@ describe("TaskChatThread blocker links", () => {
|
|||
expect(notices[0]?.textContent).toContain("Waiting on live work");
|
||||
expect(notices[1]?.textContent).toContain("Still waiting on live work");
|
||||
for (const notice of notices) {
|
||||
const orderedLinks = [...notice.querySelectorAll('[data-testid="task-chat-live-work-step"] a')]
|
||||
.map((link) => link.textContent);
|
||||
const orderedLinks = [
|
||||
...notice.querySelectorAll(
|
||||
'[data-testid="task-chat-live-work-step"] a',
|
||||
),
|
||||
].map((link) => link.textContent);
|
||||
expect(orderedLinks).toEqual([
|
||||
"PAP-17424Run the guarded cutover",
|
||||
"PAP-17425Verify the live projection",
|
||||
"PAP-17427Verify the completed projection",
|
||||
]);
|
||||
expect(notice.textContent).toContain("Now runningPAP-17426Restore live alias projection");
|
||||
expect(notice.querySelector('a[href="/issues/PAP-17426"]')).not.toBeNull();
|
||||
expect(notice.querySelector('[data-testid="task-chat-live-work-step"] > a')).not.toBeNull();
|
||||
expect(notice.querySelector('[data-testid="task-chat-live-work-step"] > span')).toBeNull();
|
||||
expect(notice.textContent).toContain(
|
||||
"Now runningPAP-17426Restore live alias projection",
|
||||
);
|
||||
expect(
|
||||
notice.querySelector('a[href="/issues/PAP-17426"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
notice.querySelector('[data-testid="task-chat-live-work-step"] > a'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
notice.querySelector('[data-testid="task-chat-live-work-step"] > span'),
|
||||
).toBeNull();
|
||||
}
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-blocker-links"]'),
|
||||
|
|
@ -1864,11 +2137,21 @@ describe("TaskChatThread blocker links", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
const notices = container.querySelectorAll('[data-testid="task-chat-blocker-links"]');
|
||||
expect(notices[0]?.textContent).toContain("Blocked byPAP-600Selected dependency");
|
||||
expect(notices[0]?.textContent).toContain("Root blockerPAP-650Stalled intermediate review");
|
||||
expect(notices[1]?.textContent).toContain("Still blocked byPAP-600Selected dependency");
|
||||
expect(notices[1]?.textContent).toContain("Root blocker remainsPAP-650Stalled intermediate review");
|
||||
const notices = container.querySelectorAll(
|
||||
'[data-testid="task-chat-blocker-links"]',
|
||||
);
|
||||
expect(notices[0]?.textContent).toContain(
|
||||
"Blocked byPAP-600Selected dependency",
|
||||
);
|
||||
expect(notices[0]?.textContent).toContain(
|
||||
"Root blockerPAP-650Stalled intermediate review",
|
||||
);
|
||||
expect(notices[1]?.textContent).toContain(
|
||||
"Still blocked byPAP-600Selected dependency",
|
||||
);
|
||||
expect(notices[1]?.textContent).toContain(
|
||||
"Root blocker remainsPAP-650Stalled intermediate review",
|
||||
);
|
||||
expect(container.textContent).not.toContain("Unrelated dependency");
|
||||
expect(container.textContent).not.toContain("Deeper structural leaf");
|
||||
});
|
||||
|
|
@ -1906,19 +2189,23 @@ describe("TaskChatThread blocker links", () => {
|
|||
comments={createLongThreadComments().slice(0, 1)}
|
||||
onAdd={async () => {}}
|
||||
issueStatus="blocked"
|
||||
blockedBy={[{
|
||||
id: "direct-1",
|
||||
identifier: "PAP-500",
|
||||
title: "Direct dependency",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
}]}
|
||||
blockedBy={[
|
||||
{
|
||||
id: "direct-1",
|
||||
identifier: "PAP-500",
|
||||
title: "Direct dependency",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const notices = container.querySelectorAll('[data-testid="task-chat-blocker-links"]');
|
||||
const notices = container.querySelectorAll(
|
||||
'[data-testid="task-chat-blocker-links"]',
|
||||
);
|
||||
expect(notices).toHaveLength(1);
|
||||
expect(notices[0]?.getAttribute("data-placement")).toBe("top");
|
||||
});
|
||||
|
|
@ -2096,6 +2383,92 @@ describe("TaskChatThread Paperclip Runner queue", () => {
|
|||
).toBeNull();
|
||||
expect(occurrenceCount(queuedComment.body)).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps legacy follow-ups in the composer queue with an interrupt fallback", () => {
|
||||
const onInterruptQueued = vi.fn(async () => {});
|
||||
render(
|
||||
<TaskChatThread
|
||||
comments={[
|
||||
{
|
||||
...queuedComment,
|
||||
clientStatus: "queued",
|
||||
queueTargetRunId: "run-1",
|
||||
},
|
||||
]}
|
||||
onAdd={async () => {}}
|
||||
onInterruptQueued={onInterruptQueued}
|
||||
queuedCommentQueue={{
|
||||
...queue,
|
||||
protocol: "legacy",
|
||||
steeringDisposition: "unsupported",
|
||||
}}
|
||||
onEditQueuedComment={async () => {}}
|
||||
onReorderQueuedComments={async () => {}}
|
||||
onSteerQueuedComment={async () => {}}
|
||||
onDiscardQueuedComment={async () => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[data-testid="task-chat-queued-message-queued-prp-1"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(occurrenceCount(queuedComment.body)).toBe(1);
|
||||
expect(container.textContent).not.toContain("QueuedInterrupt");
|
||||
|
||||
const interrupt = container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="task-chat-queued-interrupt-queued-prp-1"]',
|
||||
);
|
||||
expect(interrupt).not.toBeNull();
|
||||
flushSync(() => interrupt!.click());
|
||||
expect(onInterruptQueued).toHaveBeenCalledWith("run-1");
|
||||
});
|
||||
|
||||
it("cancels an optimistic queued row locally before server acknowledgement", async () => {
|
||||
const onCancelQueued = vi.fn();
|
||||
render(
|
||||
<TaskChatThread
|
||||
comments={[]}
|
||||
onAdd={async () => {}}
|
||||
onCancelQueued={onCancelQueued}
|
||||
queuedCommentQueue={{
|
||||
...queue,
|
||||
queueId: null,
|
||||
entries: [
|
||||
{
|
||||
...queue.entries[0],
|
||||
comment: {
|
||||
...queuedComment,
|
||||
id: "optimistic-local-1",
|
||||
body: "Delete before acknowledgement",
|
||||
},
|
||||
canEdit: false,
|
||||
},
|
||||
],
|
||||
}}
|
||||
onEditQueuedComment={async () => {}}
|
||||
onReorderQueuedComments={async () => {}}
|
||||
onSteerQueuedComment={async () => {}}
|
||||
onDiscardQueuedComment={async () => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="task-chat-queued-discard-optimistic-local-1"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
|
||||
expect(onCancelQueued).toHaveBeenCalledWith("optimistic-local-1");
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[data-testid="task-chat-queued-message-optimistic-local-1"]',
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskChatThread mobile composer dock (PAP-495)", () => {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
coalesceSettledTurns,
|
||||
isTerminalRunStatus,
|
||||
embedPlanDocumentAtWriteBoundary,
|
||||
omitProgressRepeatedByResponseAcrossSegments,
|
||||
paperclipRunnerFinalResponse,
|
||||
paperclipRunnerTimelineItems,
|
||||
prependIssueBrief,
|
||||
|
|
@ -217,11 +218,12 @@ const EMPTY_LIVE_ISSUE_IDS: ReadonlySet<string> = new Set<string>();
|
|||
const LONG_THREAD_BLOCKER_REPEAT_COUNT = 4;
|
||||
|
||||
export function shouldRepeatTaskChatBlockers(items: TaskChatItem[]): boolean {
|
||||
const conversationItems = items.filter((item) => (
|
||||
item.kind === "brief"
|
||||
|| item.kind === "interaction"
|
||||
|| (item.kind === "message" && !item.interstitial)
|
||||
));
|
||||
const conversationItems = items.filter(
|
||||
(item) =>
|
||||
item.kind === "brief" ||
|
||||
item.kind === "interaction" ||
|
||||
(item.kind === "message" && !item.interstitial),
|
||||
);
|
||||
return conversationItems.length >= LONG_THREAD_BLOCKER_REPEAT_COUNT;
|
||||
}
|
||||
|
||||
|
|
@ -492,6 +494,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
onVote,
|
||||
draftKey,
|
||||
onInterruptQueued,
|
||||
onCancelQueued,
|
||||
interruptingQueuedRunId,
|
||||
onTryAgainNoLiveExecutionPath,
|
||||
tryAgainNoLiveExecutionPathPending = false,
|
||||
|
|
@ -509,14 +512,16 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
resumeAssigneePending = false,
|
||||
} = props;
|
||||
|
||||
const paperclipQueue =
|
||||
queuedCommentQueue?.protocol === "paperclip_runner_v1"
|
||||
const queuedMessageQueue =
|
||||
queuedCommentQueue && queuedCommentQueue.entries.length > 0
|
||||
? queuedCommentQueue
|
||||
: null;
|
||||
const queuedCommentIds = useMemo(
|
||||
() =>
|
||||
new Set(paperclipQueue?.entries.map((entry) => entry.comment.id) ?? []),
|
||||
[paperclipQueue],
|
||||
new Set(
|
||||
queuedMessageQueue?.entries.map((entry) => entry.comment.id) ?? [],
|
||||
),
|
||||
[queuedMessageQueue],
|
||||
);
|
||||
const [queuedEdit, setQueuedEdit] = useState<{
|
||||
commentId: string;
|
||||
|
|
@ -527,17 +532,17 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
|
||||
const beginQueuedEdit = useCallback(
|
||||
(commentId: string) => {
|
||||
const entry = paperclipQueue?.entries.find(
|
||||
const entry = queuedMessageQueue?.entries.find(
|
||||
(candidate) => candidate.comment.id === commentId,
|
||||
);
|
||||
if (!entry?.canEdit || !paperclipQueue) return;
|
||||
if (!entry?.canEdit || !queuedMessageQueue) return;
|
||||
setQueuedEdit({
|
||||
commentId,
|
||||
body: entry.comment.body,
|
||||
revision: paperclipQueue.revision,
|
||||
revision: queuedMessageQueue.revision,
|
||||
});
|
||||
},
|
||||
[paperclipQueue],
|
||||
[queuedMessageQueue],
|
||||
);
|
||||
|
||||
const saveQueuedEdit = useCallback(
|
||||
|
|
@ -592,7 +597,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
|
||||
useEffect(() => {
|
||||
if (!queuedEdit || queuedEdit.stale) return;
|
||||
const targetStillQueued = paperclipQueue?.entries.some(
|
||||
const targetStillQueued = queuedMessageQueue?.entries.some(
|
||||
(entry) => entry.comment.id === queuedEdit.commentId,
|
||||
);
|
||||
if (!targetStillQueued) {
|
||||
|
|
@ -600,16 +605,18 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
current ? { ...current, stale: true } : current,
|
||||
);
|
||||
} else if (
|
||||
paperclipQueue &&
|
||||
queuedEdit.revision !== paperclipQueue.revision
|
||||
queuedMessageQueue &&
|
||||
queuedEdit.revision !== queuedMessageQueue.revision
|
||||
) {
|
||||
// A concurrent reorder/edit refreshes the optimistic-lock token without
|
||||
// replacing the Markdown currently in the editor.
|
||||
setQueuedEdit((current) =>
|
||||
current ? { ...current, revision: paperclipQueue.revision } : current,
|
||||
current
|
||||
? { ...current, revision: queuedMessageQueue.revision }
|
||||
: current,
|
||||
);
|
||||
}
|
||||
}, [paperclipQueue, queuedEdit]);
|
||||
}, [queuedEdit, queuedMessageQueue]);
|
||||
|
||||
const liveWorkLinks = useMemo(
|
||||
() =>
|
||||
|
|
@ -1316,6 +1323,11 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
? decidedCommentId
|
||||
: null;
|
||||
const sourceHasPresentationComment = sourcePresentationCommentId !== null;
|
||||
const sourcePresentationText = sourcePresentationCommentId
|
||||
? (comments.find(
|
||||
(comment) => comment.id === sourcePresentationCommentId,
|
||||
)?.body ?? null)
|
||||
: null;
|
||||
const sourceHasNativeResponse =
|
||||
sourceIsPaperclipRunner &&
|
||||
!sourceYielded &&
|
||||
|
|
@ -1524,8 +1536,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
startSlotMs,
|
||||
timelineAnchors,
|
||||
);
|
||||
for (const [segmentIndex, segment] of segments.entries()) {
|
||||
if (segment.entries.length === 0) continue;
|
||||
const projectedSegments = segments.map((segment) => {
|
||||
const parsedTranscript = transcriptToTaskChatItems(segment.entries, {
|
||||
runId: source.id,
|
||||
agentName: meta?.agentName,
|
||||
|
|
@ -1535,11 +1546,46 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
source.id === planDocumentSourceRunId && planTurnItem
|
||||
? embedPlanDocumentAtWriteBoundary(parsedTranscript, planTurnItem)
|
||||
: parsedTranscript;
|
||||
const children = settledRunChildren(
|
||||
sourceIsPaperclipRunner
|
||||
return {
|
||||
segment,
|
||||
parsed,
|
||||
timelineItems: sourceIsPaperclipRunner
|
||||
? paperclipRunnerTimelineItems(parsed)
|
||||
: parsed,
|
||||
);
|
||||
};
|
||||
});
|
||||
const sourceResponseText =
|
||||
sourceIsPaperclipRunner && !sourceYielded
|
||||
? (sourcePresentationText ??
|
||||
paperclipRunnerFinalResponse(
|
||||
transcriptToTaskChatItems(entries, {
|
||||
runId: source.id,
|
||||
agentName: meta?.agentName,
|
||||
running: false,
|
||||
}),
|
||||
{
|
||||
runId: source.id,
|
||||
agentName: meta?.agentName,
|
||||
fallbackSummary: acceptedSummary,
|
||||
},
|
||||
)?.text)
|
||||
: undefined;
|
||||
const timelineItemsBySegment = sourceIsPaperclipRunner
|
||||
? omitProgressRepeatedByResponseAcrossSegments(
|
||||
projectedSegments.map(({ timelineItems }) => timelineItems),
|
||||
sourceResponseText,
|
||||
)
|
||||
: projectedSegments.map(({ timelineItems }) => timelineItems);
|
||||
let lastPopulatedSegmentIndex = -1;
|
||||
for (let index = projectedSegments.length - 1; index >= 0; index -= 1) {
|
||||
if ((projectedSegments[index]?.segment.entries.length ?? 0) > 0) {
|
||||
lastPopulatedSegmentIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const [segmentIndex, projected] of projectedSegments.entries()) {
|
||||
const { segment, parsed } = projected;
|
||||
if (segment.entries.length === 0) continue;
|
||||
const finalResponse =
|
||||
sourceIsPaperclipRunner &&
|
||||
!sourceYielded &&
|
||||
|
|
@ -1547,9 +1593,15 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
? paperclipRunnerFinalResponse(parsed, {
|
||||
runId: source.id,
|
||||
agentName: meta?.agentName,
|
||||
fallbackSummary: acceptedSummary,
|
||||
fallbackSummary:
|
||||
segmentIndex === lastPopulatedSegmentIndex
|
||||
? acceptedSummary
|
||||
: undefined,
|
||||
})
|
||||
: undefined;
|
||||
const children = settledRunChildren(
|
||||
timelineItemsBySegment[segmentIndex] ?? [],
|
||||
);
|
||||
if (children.length === 0 && !finalResponse && !sourceIsPaperclipRunner)
|
||||
continue;
|
||||
settledRunIds.add(source.id);
|
||||
|
|
@ -1583,6 +1635,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
? agentMap?.get(meta.agentId)?.icon
|
||||
: undefined,
|
||||
standaloneHeader: sourceIsPaperclipRunner,
|
||||
continuedAfterSteering: sourceIsPaperclipRunner && segmentIndex > 0,
|
||||
animateFold: liveSeenRef.current.has(source.id),
|
||||
items: children,
|
||||
finalResponse,
|
||||
|
|
@ -1672,6 +1725,8 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
? agentMap?.get(liveRun.agentId)?.icon
|
||||
: undefined,
|
||||
standaloneHeader: isNativePaperclipRunnerRun(liveRun),
|
||||
continuedAfterSteering:
|
||||
isNativePaperclipRunnerRun(liveRun) && segmentIndex > 0,
|
||||
items: children,
|
||||
summary: buildTurnSummary(segment.entries, {
|
||||
durationMs: segmentDurationMs,
|
||||
|
|
@ -1870,18 +1925,19 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
? `${tailPlanItem.id}:${tailPlanItem.document.body.length}`
|
||||
: "";
|
||||
const threadContentKey = `${taskChatContentKey(items)}:${tailContentKey}:${tailPlanContentKey}:${blockerContentKey}`;
|
||||
const repeatBlockersAtBottom = !streamlinedUiEnabled || shouldRepeatTaskChatBlockers(items);
|
||||
const bottomBlockerLinks = repeatBlockersAtBottom
|
||||
? liveWorkLinks ? (
|
||||
<TaskChatLiveWorkLinks liveWork={liveWorkLinks} placement="bottom" />
|
||||
) : blockerLinks ? (
|
||||
<TaskChatBlockerLinks
|
||||
directBlocker={blockerLinks.directBlocker}
|
||||
ultimateBlocker={blockerLinks.ultimateBlocker}
|
||||
placement="bottom"
|
||||
/>
|
||||
) : null
|
||||
: null;
|
||||
const repeatBlockersAtBottom =
|
||||
!streamlinedUiEnabled || shouldRepeatTaskChatBlockers(items);
|
||||
const bottomBlockerLinks = repeatBlockersAtBottom ? (
|
||||
liveWorkLinks ? (
|
||||
<TaskChatLiveWorkLinks liveWork={liveWorkLinks} placement="bottom" />
|
||||
) : blockerLinks ? (
|
||||
<TaskChatBlockerLinks
|
||||
directBlocker={blockerLinks.directBlocker}
|
||||
ultimateBlocker={blockerLinks.ultimateBlocker}
|
||||
placement="bottom"
|
||||
/>
|
||||
) : null
|
||||
) : null;
|
||||
|
||||
// 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
|
||||
|
|
@ -1895,11 +1951,12 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
const tailStatus = liveRun
|
||||
? liveRun.status
|
||||
: (runs.find((run) => run.id === settlingRun?.id)?.status ?? "succeeded");
|
||||
const tailStartedAtMs = liveRun
|
||||
? (tailSegmentStartMs ??
|
||||
(liveRun.startedAt ? toMs(liveRun.startedAt) : null) ??
|
||||
toMs(liveRun.createdAt))
|
||||
: (settlingRun?.startedAtMs ?? null);
|
||||
const tailStartedAtMs =
|
||||
tailSegmentStartMs ??
|
||||
(liveRun
|
||||
? ((liveRun.startedAt ? toMs(liveRun.startedAt) : null) ??
|
||||
toMs(liveRun.createdAt))
|
||||
: (settlingRun?.startedAtMs ?? null));
|
||||
const tailFinishedAtMs = liveRun
|
||||
? liveRun.finishedAt
|
||||
? toMs(liveRun.finishedAt)
|
||||
|
|
@ -2182,7 +2239,6 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
|
||||
const renderQueuedAction = useCallback(
|
||||
(item: TaskChatMessageItem) => {
|
||||
if (paperclipQueue) return null;
|
||||
const runId = item.queueTargetRunId;
|
||||
if (item.optimistic !== "queued" || !runId || !onInterruptQueued)
|
||||
return null;
|
||||
|
|
@ -2200,7 +2256,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
</Button>
|
||||
);
|
||||
},
|
||||
[interruptingQueuedRunId, onInterruptQueued, paperclipQueue],
|
||||
[interruptingQueuedRunId, onInterruptQueued],
|
||||
);
|
||||
|
||||
const renderInteraction = useCallback(
|
||||
|
|
@ -2326,221 +2382,247 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
|||
useWindowAutoFollow(isMobile ? autoFollowContentKey : 0, isMobile);
|
||||
|
||||
return (
|
||||
<TaskChatPresentationProvider mode={streamlinedUiEnabled ? "streamlined" : "production"}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
!isMobile && "h-(--tc-thread-max-h) min-h-0 flex-1",
|
||||
)}
|
||||
data-testid="task-chat-thread"
|
||||
<TaskChatPresentationProvider
|
||||
mode={streamlinedUiEnabled ? "streamlined" : "production"}
|
||||
>
|
||||
<div className={cn("flex flex-col", !isMobile && "min-h-0 flex-1")}>
|
||||
{items.length === 0 && !tailRunId ? (
|
||||
<div
|
||||
className={isMobile ? undefined : "min-h-0 flex-1 overflow-y-auto"}
|
||||
>
|
||||
{threadHeaderWithBlockers ? (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-6 px-4",
|
||||
isMobile ? "pt-4" : "pt-3",
|
||||
streamlinedUiEnabled && "md:px-0",
|
||||
)}
|
||||
data-testid="task-chat-thread-header"
|
||||
>
|
||||
{threadHeaderWithBlockers}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
!isMobile && "h-(--tc-thread-max-h) min-h-0 flex-1",
|
||||
)}
|
||||
data-testid="task-chat-thread"
|
||||
>
|
||||
<div className={cn("flex flex-col", !isMobile && "min-h-0 flex-1")}>
|
||||
{items.length === 0 && !tailRunId ? (
|
||||
<div
|
||||
className={
|
||||
isMobile ? undefined : "min-h-0 flex-1 overflow-y-auto"
|
||||
}
|
||||
>
|
||||
{threadHeaderWithBlockers ? (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-6 px-4",
|
||||
isMobile ? "pt-4" : "pt-3",
|
||||
streamlinedUiEnabled && "md:px-0",
|
||||
)}
|
||||
data-testid="task-chat-thread-header"
|
||||
>
|
||||
{threadHeaderWithBlockers}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="px-3 py-10 text-center text-sm text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="px-3 py-10 text-center text-sm text-muted-foreground">
|
||||
{emptyMessage}
|
||||
{bottomBlockerLinks ? (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full max-w-(--tc-shell-max-w) px-4 pb-4",
|
||||
streamlinedUiEnabled && "md:px-0",
|
||||
)}
|
||||
>
|
||||
{bottomBlockerLinks}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{bottomBlockerLinks ? (
|
||||
<div className={cn(
|
||||
"mx-auto w-full max-w-(--tc-shell-max-w) px-4 pb-4",
|
||||
streamlinedUiEnabled && "md:px-0",
|
||||
)}>
|
||||
{bottomBlockerLinks}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<TaskChatThreadView
|
||||
items={items}
|
||||
attachments={attachments}
|
||||
header={threadHeaderWithBlockers}
|
||||
renderInteraction={renderInteraction}
|
||||
renderBrief={
|
||||
issueBrief
|
||||
? () => <TaskChatDescriptionBubble brief={issueBrief} />
|
||||
: undefined
|
||||
}
|
||||
renderMessageActions={renderMessageActions}
|
||||
renderQueuedAction={renderQueuedAction}
|
||||
onTryAgainNoLiveExecutionPath={
|
||||
issueStatus === "blocked"
|
||||
? onTryAgainNoLiveExecutionPath
|
||||
: undefined
|
||||
}
|
||||
tryAgainNoLiveExecutionPathPending={
|
||||
tryAgainNoLiveExecutionPathPending
|
||||
}
|
||||
onRetryFailedRun={onRetryFailedRun}
|
||||
retryFailedRunId={retryFailedRunId}
|
||||
tail={
|
||||
tailRunId || optimisticRunnerStartup || bottomBlockerLinks ? (
|
||||
<>
|
||||
{tailRunId || optimisticRunnerStartup ? (
|
||||
<div data-testid="task-chat-live-transcript">
|
||||
{paperclipRunnerTail || optimisticRunnerStartup ? (
|
||||
<TaskChatRunnerTurn
|
||||
runId={tailRunId}
|
||||
agentName={visibleTailAgentName}
|
||||
agentIcon={visibleTailAgentIcon}
|
||||
items={tailItems}
|
||||
status={
|
||||
optimisticRunnerStartup ? "queued" : tailStatus
|
||||
}
|
||||
startedAtMs={tailStartedAtMs}
|
||||
finishedAtMs={tailFinishedAtMs}
|
||||
activityUnavailable={tailActivityUnavailable}
|
||||
suppressFinal={suppressPaperclipRunnerTailFinal}
|
||||
onRuntimeRequestDecision={
|
||||
handleRuntimeRequestDecision
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<TaskChatLiveRunPill
|
||||
status={tailStatus}
|
||||
) : (
|
||||
<TaskChatThreadView
|
||||
items={items}
|
||||
attachments={attachments}
|
||||
header={threadHeaderWithBlockers}
|
||||
renderInteraction={renderInteraction}
|
||||
renderBrief={
|
||||
issueBrief
|
||||
? () => <TaskChatDescriptionBubble brief={issueBrief} />
|
||||
: undefined
|
||||
}
|
||||
renderMessageActions={renderMessageActions}
|
||||
renderQueuedAction={renderQueuedAction}
|
||||
onTryAgainNoLiveExecutionPath={
|
||||
issueStatus === "blocked"
|
||||
? onTryAgainNoLiveExecutionPath
|
||||
: undefined
|
||||
}
|
||||
tryAgainNoLiveExecutionPathPending={
|
||||
tryAgainNoLiveExecutionPathPending
|
||||
}
|
||||
onRetryFailedRun={onRetryFailedRun}
|
||||
retryFailedRunId={retryFailedRunId}
|
||||
tail={
|
||||
tailRunId || optimisticRunnerStartup || bottomBlockerLinks ? (
|
||||
<>
|
||||
{tailRunId || optimisticRunnerStartup ? (
|
||||
<div data-testid="task-chat-live-transcript">
|
||||
{paperclipRunnerTail || optimisticRunnerStartup ? (
|
||||
<TaskChatRunnerTurn
|
||||
runId={tailRunId}
|
||||
agentName={visibleTailAgentName}
|
||||
agentIcon={visibleTailAgentIcon}
|
||||
items={tailItems}
|
||||
status={
|
||||
optimisticRunnerStartup ? "queued" : tailStatus
|
||||
}
|
||||
startedAtMs={tailStartedAtMs}
|
||||
finishedAtMs={tailFinishedAtMs}
|
||||
toolSummary={tailToolSummary}
|
||||
/>
|
||||
<TaskChatLiveTail
|
||||
items={tailItems}
|
||||
emptyMessage={
|
||||
tailStatus === "queued"
|
||||
? "Waiting to start..."
|
||||
: (liveRun && liveRun.id === tailRunId
|
||||
? liveRun.currentStatusMessage
|
||||
: null) || "Waiting for transcript..."
|
||||
activityUnavailable={tailActivityUnavailable}
|
||||
suppressFinal={suppressPaperclipRunnerTailFinal}
|
||||
continuedAfterSteering={
|
||||
paperclipRunnerTail &&
|
||||
tailTimelineAnchors.length > 0
|
||||
}
|
||||
onRuntimeRequestDecision={
|
||||
handleRuntimeRequestDecision
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{bottomBlockerLinks}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
contentKey={autoFollowContentKey}
|
||||
className={isMobile ? undefined : "pt-3"}
|
||||
scroll={!isMobile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{assignedAgentForNotice?.status === "paused" ? (
|
||||
<div className="mx-auto w-full max-w-(--tc-shell-max-w) px-4 pt-2">
|
||||
<IssueAssigneePausedNotice
|
||||
agent={assignedAgentForNotice}
|
||||
onResume={onResumeAssignee}
|
||||
resuming={resumeAssigneePending}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{showComposer ? (
|
||||
<div
|
||||
data-testid="task-chat-composer-dock"
|
||||
className={cn(
|
||||
"sticky",
|
||||
// Mobile mirrors the flag-off thread's dock: lifted above the
|
||||
// safe-area inset and clear of the auto-hiding bottom nav, above
|
||||
// page content in the document-flow stacking context. The bottom
|
||||
// offset (--tc-composer-bottom) tracks the nav: Layout raises it to
|
||||
// the nav height while the nav is visible so the composer's action
|
||||
// row is never occluded, and drops it back to the safe-area dock
|
||||
// when the nav auto-hides (PAP-495). transition-[bottom] rides the
|
||||
// nav's own 200ms slide; the offset only changes on nav toggles, so
|
||||
// it never animates mid-scroll.
|
||||
isMobile
|
||||
? "bottom-(--tc-composer-bottom) z-20 transition-[bottom] duration-200 ease-out"
|
||||
: "bottom-0 z-10",
|
||||
"mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-2 px-4 pb-2",
|
||||
streamlinedUiEnabled && "md:px-0 md:pb-4",
|
||||
streamlinedUiEnabled && !isMobile
|
||||
? "-mt-(--radius-task-composer)"
|
||||
: "bg-background/80 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60",
|
||||
) : (
|
||||
<>
|
||||
<TaskChatLiveRunPill
|
||||
status={tailStatus}
|
||||
startedAtMs={tailStartedAtMs}
|
||||
finishedAtMs={tailFinishedAtMs}
|
||||
toolSummary={tailToolSummary}
|
||||
/>
|
||||
<TaskChatLiveTail
|
||||
items={tailItems}
|
||||
emptyMessage={
|
||||
tailStatus === "queued"
|
||||
? "Waiting to start..."
|
||||
: (liveRun && liveRun.id === tailRunId
|
||||
? liveRun.currentStatusMessage
|
||||
: null) || "Waiting for transcript..."
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{bottomBlockerLinks}
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
contentKey={autoFollowContentKey}
|
||||
className={isMobile ? undefined : "pt-3"}
|
||||
scroll={!isMobile}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{composerAccessory}
|
||||
{tailTurnStatus ? (
|
||||
<TaskChatTurnStatusIsland model={tailTurnStatus} />
|
||||
) : null}
|
||||
<div
|
||||
className="relative isolate flex flex-col"
|
||||
data-testid="task-chat-composer-stack"
|
||||
>
|
||||
{paperclipQueue && paperclipQueue.entries.length > 0 ? (
|
||||
<TaskChatQueuedMessages
|
||||
queue={paperclipQueue}
|
||||
onEdit={beginQueuedEdit}
|
||||
onReorder={async (orderedCommentIds, revision) => {
|
||||
if (!onReorderQueuedComments)
|
||||
throw new Error("Queue reordering is unavailable.");
|
||||
await onReorderQueuedComments(orderedCommentIds, revision);
|
||||
}}
|
||||
onSteer={async (commentId, revision) => {
|
||||
if (!onSteerQueuedComment)
|
||||
throw new Error("Steering is unavailable.");
|
||||
await onSteerQueuedComment(commentId, revision);
|
||||
}}
|
||||
onDiscard={async (commentId, revision) => {
|
||||
if (!onDiscardQueuedComment)
|
||||
throw new Error("Discard is unavailable.");
|
||||
await onDiscardQueuedComment(commentId, revision);
|
||||
if (queuedEdit?.commentId === commentId) setQueuedEdit(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative z-10">
|
||||
<TaskChatComposer
|
||||
onAdd={handleThreadAdd}
|
||||
workMode={issueWorkMode}
|
||||
onWorkModeChange={onWorkModeChange}
|
||||
disabled={Boolean(runtimeComposerDisabledReason)}
|
||||
disabledReason={runtimeComposerDisabledReason}
|
||||
onAttachImage={onAttachImage}
|
||||
onImageUpload={imageUploadHandler}
|
||||
mentions={mentions}
|
||||
enableReassign={enableReassign}
|
||||
reassignOptions={reassignOptions}
|
||||
agentMap={agentMap}
|
||||
userProfileMap={userProfileMap}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
issueStatus={issueStatus}
|
||||
mobile={isMobile}
|
||||
draftKey={draftKey}
|
||||
queuedEdit={queuedEdit}
|
||||
onSaveQueuedEdit={saveQueuedEdit}
|
||||
onCancelQueuedEdit={() => setQueuedEdit(null)}
|
||||
takeover={composerTakeover}
|
||||
pendingTakeover={
|
||||
pendingComposerInputs.length > 0
|
||||
? {
|
||||
count: pendingComposerInputs.length,
|
||||
label: `${pendingComposerInputs.length} pending input${pendingComposerInputs.length === 1 ? "" : "s"}`,
|
||||
onOpen: openPendingTakeover,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{assignedAgentForNotice?.status === "paused" ? (
|
||||
<div className="mx-auto w-full max-w-(--tc-shell-max-w) px-4 pt-2">
|
||||
<IssueAssigneePausedNotice
|
||||
agent={assignedAgentForNotice}
|
||||
onResume={onResumeAssignee}
|
||||
resuming={resumeAssigneePending}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{showComposer ? (
|
||||
<div
|
||||
data-testid="task-chat-composer-dock"
|
||||
className={cn(
|
||||
"sticky",
|
||||
// Mobile mirrors the flag-off thread's dock: lifted above the
|
||||
// safe-area inset and clear of the auto-hiding bottom nav, above
|
||||
// page content in the document-flow stacking context. The bottom
|
||||
// offset (--tc-composer-bottom) tracks the nav: Layout raises it to
|
||||
// the nav height while the nav is visible so the composer's action
|
||||
// row is never occluded, and drops it back to the safe-area dock
|
||||
// when the nav auto-hides (PAP-495). transition-[bottom] rides the
|
||||
// nav's own 200ms slide; the offset only changes on nav toggles, so
|
||||
// it never animates mid-scroll.
|
||||
isMobile
|
||||
? "bottom-(--tc-composer-bottom) z-20 transition-[bottom] duration-200 ease-out"
|
||||
: "bottom-0 z-10",
|
||||
"mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-2 px-4 pb-2",
|
||||
streamlinedUiEnabled && "md:px-0 md:pb-4",
|
||||
streamlinedUiEnabled && !isMobile
|
||||
? "-mt-(--radius-task-composer)"
|
||||
: "bg-background/80 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60",
|
||||
)}
|
||||
>
|
||||
{composerAccessory}
|
||||
{tailTurnStatus ? (
|
||||
<TaskChatTurnStatusIsland model={tailTurnStatus} />
|
||||
) : null}
|
||||
<div
|
||||
className="relative isolate flex flex-col"
|
||||
data-testid="task-chat-composer-stack"
|
||||
>
|
||||
{queuedMessageQueue ? (
|
||||
<TaskChatQueuedMessages
|
||||
queue={queuedMessageQueue}
|
||||
onEdit={beginQueuedEdit}
|
||||
onReorder={async (orderedCommentIds, revision) => {
|
||||
if (!onReorderQueuedComments)
|
||||
throw new Error("Queue reordering is unavailable.");
|
||||
await onReorderQueuedComments(orderedCommentIds, revision);
|
||||
}}
|
||||
onSteer={async (commentId, revision) => {
|
||||
if (!onSteerQueuedComment)
|
||||
throw new Error("Steering is unavailable.");
|
||||
await onSteerQueuedComment(commentId, revision);
|
||||
}}
|
||||
onInterrupt={
|
||||
onInterruptQueued && queuedMessageQueue.targetRunId
|
||||
? async () => {
|
||||
await onInterruptQueued(
|
||||
queuedMessageQueue.targetRunId!,
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDiscard={async (commentId, revision) => {
|
||||
if (commentId.startsWith("optimistic-")) {
|
||||
if (!onCancelQueued)
|
||||
throw new Error("Discard is unavailable.");
|
||||
onCancelQueued(commentId);
|
||||
return;
|
||||
}
|
||||
if (!onDiscardQueuedComment)
|
||||
throw new Error("Discard is unavailable.");
|
||||
await onDiscardQueuedComment(commentId, revision);
|
||||
if (queuedEdit?.commentId === commentId)
|
||||
setQueuedEdit(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative z-10">
|
||||
<TaskChatComposer
|
||||
onAdd={handleThreadAdd}
|
||||
workMode={issueWorkMode}
|
||||
onWorkModeChange={onWorkModeChange}
|
||||
disabled={Boolean(runtimeComposerDisabledReason)}
|
||||
disabledReason={runtimeComposerDisabledReason}
|
||||
onAttachImage={onAttachImage}
|
||||
onImageUpload={imageUploadHandler}
|
||||
mentions={mentions}
|
||||
enableReassign={enableReassign}
|
||||
reassignOptions={reassignOptions}
|
||||
agentMap={agentMap}
|
||||
userProfileMap={userProfileMap}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
issueStatus={issueStatus}
|
||||
mobile={isMobile}
|
||||
draftKey={draftKey}
|
||||
queuedEdit={queuedEdit}
|
||||
onSaveQueuedEdit={saveQueuedEdit}
|
||||
onCancelQueuedEdit={() => setQueuedEdit(null)}
|
||||
takeover={composerTakeover}
|
||||
pendingTakeover={
|
||||
pendingComposerInputs.length > 0
|
||||
? {
|
||||
count: pendingComposerInputs.length,
|
||||
label: `${pendingComposerInputs.length} pending input${pendingComposerInputs.length === 1 ? "" : "s"}`,
|
||||
onOpen: openPendingTakeover,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TaskChatPresentationProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import { flushSync } from "react-dom";
|
|||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { IssueQueuedCommentQueue } from "@paperclipai/shared";
|
||||
import { TaskChatQueuedMessages } from "./TaskChatQueuedMessages";
|
||||
import {
|
||||
reorderQueuedMessageEntries,
|
||||
TaskChatQueuedMessages,
|
||||
} from "./TaskChatQueuedMessages";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
|
|
@ -23,24 +26,26 @@ const queue: IssueQueuedCommentQueue = {
|
|||
revision: "rev-1",
|
||||
protocol: "paperclip_runner_v1",
|
||||
steeringDisposition: "available",
|
||||
entries: ["First queued message", "Second queued message"].map((body, position) => ({
|
||||
comment: {
|
||||
id: `comment-${position + 1}`,
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
body,
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
position,
|
||||
canEdit: true,
|
||||
canDiscard: true,
|
||||
})),
|
||||
entries: ["First queued message", "Second queued message"].map(
|
||||
(body, position) => ({
|
||||
comment: {
|
||||
id: `comment-${position + 1}`,
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorType: "user",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
body,
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
position,
|
||||
canEdit: true,
|
||||
canDiscard: true,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
describe("TaskChatQueuedMessages", () => {
|
||||
|
|
@ -58,7 +63,9 @@ describe("TaskChatQueuedMessages", () => {
|
|||
container.remove();
|
||||
});
|
||||
|
||||
function render(overrides: Partial<ComponentProps<typeof TaskChatQueuedMessages>> = {}) {
|
||||
function render(
|
||||
overrides: Partial<ComponentProps<typeof TaskChatQueuedMessages>> = {},
|
||||
) {
|
||||
const props = {
|
||||
queue,
|
||||
onEdit: vi.fn(),
|
||||
|
|
@ -73,56 +80,122 @@ describe("TaskChatQueuedMessages", () => {
|
|||
|
||||
it("renders each queued message once as a compact one-line row", () => {
|
||||
render();
|
||||
const pane = container.querySelector('[data-testid="task-chat-queued-messages"]');
|
||||
const pane = container.querySelector(
|
||||
'[data-testid="task-chat-queued-messages"]',
|
||||
);
|
||||
expect(pane?.classList).toContain("mx-3");
|
||||
expect(pane?.classList).toContain("rounded-b-none");
|
||||
expect(pane?.classList).toContain("border-b-0");
|
||||
expect(pane?.classList).toContain("-mb-px");
|
||||
expect(container.querySelectorAll('[data-testid^="task-chat-queued-message-"]')).toHaveLength(2);
|
||||
expect(
|
||||
container.querySelectorAll('[data-testid^="task-chat-queued-message-"]'),
|
||||
).toHaveLength(2);
|
||||
expect(container.textContent).toContain("First queued message");
|
||||
expect(container.textContent).toContain("Second queued message");
|
||||
});
|
||||
|
||||
it("removes only the acknowledged steering row", async () => {
|
||||
const props = render();
|
||||
it("reorders the complete queue and rewrites contiguous positions", () => {
|
||||
const next = reorderQueuedMessageEntries(
|
||||
queue.entries,
|
||||
"comment-2",
|
||||
"comment-1",
|
||||
);
|
||||
|
||||
expect(next?.map((entry) => entry.comment.id)).toEqual([
|
||||
"comment-2",
|
||||
"comment-1",
|
||||
]);
|
||||
expect(next?.map((entry) => entry.position)).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
it("promotes only the selected steering row immediately", async () => {
|
||||
const acknowledgement = deferred<void>();
|
||||
const props = render({
|
||||
onSteer: vi.fn().mockReturnValue(acknowledgement.promise),
|
||||
});
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>('[data-testid="task-chat-queued-steer-comment-1"]')?.click();
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="task-chat-queued-steer-comment-1"]',
|
||||
)
|
||||
?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(props.onSteer).toHaveBeenCalledWith("comment-1", "rev-1");
|
||||
expect(container.querySelector('[data-testid="task-chat-queued-message-comment-1"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-queued-message-comment-2"]')).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[data-testid="task-chat-queued-message-comment-1"]',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[data-testid="task-chat-queued-message-comment-2"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
acknowledgement.resolve();
|
||||
await acknowledgement.promise;
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a row queued when steering fails and announces the retryable state", async () => {
|
||||
render({ onSteer: vi.fn().mockRejectedValue(new Error("steering_timeout")) });
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>('[data-testid="task-chat-queued-steer-comment-1"]')?.click();
|
||||
render({
|
||||
onSteer: vi.fn().mockRejectedValue(new Error("steering_timeout")),
|
||||
});
|
||||
expect(container.querySelector('[data-testid="task-chat-queued-message-comment-1"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain("Couldn’t steer. Message is still queued.");
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="task-chat-queued-steer-comment-1"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[data-testid="task-chat-queued-message-comment-1"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(container.textContent).toContain(
|
||||
"Couldn’t steer. Message is still queued.",
|
||||
);
|
||||
});
|
||||
|
||||
it("disables steering when the provider does not advertise it", () => {
|
||||
render({ queue: { ...queue, steeringDisposition: "unsupported" } });
|
||||
expect(container.querySelector<HTMLButtonElement>('[data-testid="task-chat-queued-steer-comment-1"]')?.disabled)
|
||||
.toBe(true);
|
||||
expect(
|
||||
container.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="task-chat-queued-steer-comment-1"]',
|
||||
)?.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("waits for authoritative discard acknowledgement before removing the row", async () => {
|
||||
const acknowledgement = deferred<void>();
|
||||
render({ onDiscard: vi.fn().mockReturnValue(acknowledgement.promise) });
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>('[data-testid="task-chat-queued-discard-comment-1"]')?.click();
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="task-chat-queued-discard-comment-1"]',
|
||||
)
|
||||
?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(container.querySelector('[data-testid="task-chat-queued-message-comment-1"]')).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[data-testid="task-chat-queued-message-comment-1"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(container.textContent).not.toContain("Queued message discarded.");
|
||||
|
||||
await act(async () => {
|
||||
acknowledgement.resolve();
|
||||
await acknowledgement.promise;
|
||||
});
|
||||
expect(container.querySelector('[data-testid="task-chat-queued-message-comment-1"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[data-testid="task-chat-queued-message-comment-1"]',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(container.textContent).toContain("Queued message discarded.");
|
||||
});
|
||||
|
||||
|
|
@ -133,10 +206,20 @@ describe("TaskChatQueuedMessages", () => {
|
|||
}),
|
||||
});
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>('[data-testid="task-chat-queued-discard-comment-1"]')?.click();
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="task-chat-queued-discard-comment-1"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
expect(container.querySelector('[data-testid="task-chat-queued-message-comment-1"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain("Too late to discard: this message is already being sent.");
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[data-testid="task-chat-queued-message-comment-1"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(container.textContent).toContain(
|
||||
"Too late to discard: this message is already being sent.",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows stale revisions without announcing a discard", async () => {
|
||||
|
|
@ -146,9 +229,15 @@ describe("TaskChatQueuedMessages", () => {
|
|||
}),
|
||||
});
|
||||
await act(async () => {
|
||||
container.querySelector<HTMLButtonElement>('[data-testid="task-chat-queued-discard-comment-1"]')?.click();
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="task-chat-queued-discard-comment-1"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
expect(container.textContent).toContain("The queue changed in another session. Review it and try again.");
|
||||
expect(container.textContent).toContain(
|
||||
"The queue changed in another session. Review it and try again.",
|
||||
);
|
||||
expect(container.textContent).not.toContain("Queued message discarded.");
|
||||
});
|
||||
|
||||
|
|
@ -162,4 +251,71 @@ describe("TaskChatQueuedMessages", () => {
|
|||
expect(props.onDiscard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can discard a local optimistic row before the queue id is acknowledged", async () => {
|
||||
const optimisticQueue = {
|
||||
...queue,
|
||||
queueId: null,
|
||||
state: "deferred" as const,
|
||||
entries: [
|
||||
{
|
||||
...queue.entries[0],
|
||||
comment: {
|
||||
...queue.entries[0].comment,
|
||||
id: "optimistic-local-1",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const props = render({ queue: optimisticQueue });
|
||||
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="task-chat-queued-discard-optimistic-local-1"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
|
||||
expect(props.onDiscard).toHaveBeenCalledWith("optimistic-local-1", "rev-1");
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[data-testid="task-chat-queued-message-optimistic-local-1"]',
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("uses interrupt instead of steer for legacy runners and keeps the row queued", async () => {
|
||||
const onInterrupt = vi.fn().mockResolvedValue(undefined);
|
||||
render({
|
||||
queue: {
|
||||
...queue,
|
||||
protocol: "legacy",
|
||||
steeringDisposition: "unsupported",
|
||||
},
|
||||
onInterrupt,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
container
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'[data-testid="task-chat-queued-interrupt-comment-1"]',
|
||||
)
|
||||
?.click();
|
||||
});
|
||||
|
||||
expect(onInterrupt).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[data-testid="task-chat-queued-steer-comment-1"]',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[data-testid="task-chat-queued-message-comment-1"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(container.textContent).toContain(
|
||||
"Active turn interrupted. Message remains queued.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -36,7 +36,21 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
type QueueAction = "steer" | "discard" | null;
|
||||
type QueueAction = "steer" | "interrupt" | "discard" | null;
|
||||
|
||||
export function reorderQueuedMessageEntries(
|
||||
entries: IssueQueuedCommentEntry[],
|
||||
activeId: string,
|
||||
overId: string,
|
||||
) {
|
||||
const from = entries.findIndex((entry) => entry.comment.id === activeId);
|
||||
const to = entries.findIndex((entry) => entry.comment.id === overId);
|
||||
if (from < 0 || to < 0 || from === to) return null;
|
||||
return arrayMove(entries, from, to).map((entry, position) => ({
|
||||
...entry,
|
||||
position,
|
||||
}));
|
||||
}
|
||||
|
||||
function queueActionErrorCode(error: unknown): string | null {
|
||||
if (typeof error !== "object" || error === null) return null;
|
||||
|
|
@ -55,32 +69,41 @@ export interface TaskChatQueuedMessagesProps {
|
|||
onEdit: (commentId: string) => void;
|
||||
onReorder: (orderedCommentIds: string[], revision: string) => Promise<void>;
|
||||
onSteer: (commentId: string, revision: string) => Promise<void>;
|
||||
onInterrupt?: () => Promise<void>;
|
||||
onDiscard: (commentId: string, revision: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function SortableQueuedMessage({
|
||||
entry,
|
||||
queue,
|
||||
disabled,
|
||||
busy,
|
||||
queueMutationDisabled,
|
||||
action,
|
||||
onEdit,
|
||||
onSteer,
|
||||
onInterrupt,
|
||||
onDiscard,
|
||||
}: {
|
||||
entry: IssueQueuedCommentEntry;
|
||||
queue: IssueQueuedCommentQueue;
|
||||
disabled: boolean;
|
||||
busy: boolean;
|
||||
queueMutationDisabled: boolean;
|
||||
action: QueueAction;
|
||||
onEdit: () => void;
|
||||
onSteer: () => void;
|
||||
onInterrupt?: () => void;
|
||||
onDiscard: () => void;
|
||||
}) {
|
||||
const sortable = useSortable({ id: entry.comment.id, disabled });
|
||||
const sortable = useSortable({
|
||||
id: entry.comment.id,
|
||||
disabled: queueMutationDisabled,
|
||||
});
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(sortable.transform),
|
||||
transition: sortable.transition,
|
||||
};
|
||||
const steerDisabled = disabled || queue.steeringDisposition !== "available";
|
||||
const steerDisabled =
|
||||
queueMutationDisabled || queue.steeringDisposition !== "available";
|
||||
const steerTitle =
|
||||
queue.steeringDisposition === "unsupported"
|
||||
? "This runner does not support steering"
|
||||
|
|
@ -94,7 +117,8 @@ function SortableQueuedMessage({
|
|||
style={style}
|
||||
className={cn(
|
||||
"group flex h-11 min-w-0 items-center gap-1 border-b border-border/55 bg-card/95 px-2.5 text-sm last:border-b-0",
|
||||
sortable.isDragging && "relative z-20 rounded-lg border border-border shadow-lg",
|
||||
sortable.isDragging &&
|
||||
"relative z-20 rounded-lg border border-border shadow-lg",
|
||||
)}
|
||||
data-testid={`task-chat-queued-message-${entry.comment.id}`}
|
||||
>
|
||||
|
|
@ -103,38 +127,63 @@ function SortableQueuedMessage({
|
|||
ref={sortable.setActivatorNodeRef}
|
||||
{...sortable.attributes}
|
||||
{...sortable.listeners}
|
||||
disabled={disabled}
|
||||
disabled={queueMutationDisabled}
|
||||
aria-label={`Reorder queued message: ${entry.comment.body}`}
|
||||
className="flex h-7 w-7 shrink-0 cursor-grab items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-accent hover:text-foreground active:cursor-grabbing disabled:cursor-default disabled:opacity-40"
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" aria-hidden />
|
||||
</button>
|
||||
|
||||
<CornerDownRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<CornerDownRight
|
||||
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate px-1" title={entry.comment.body}>
|
||||
{entry.comment.body}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSteer}
|
||||
disabled={steerDisabled}
|
||||
title={steerTitle}
|
||||
className="flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
data-testid={`task-chat-queued-steer-${entry.comment.id}`}
|
||||
>
|
||||
{action === "steer" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<CornerDownRight className="h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
Steer
|
||||
</button>
|
||||
{queue.protocol === "legacy" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onInterrupt}
|
||||
disabled={busy || !queue.targetRunId || !onInterrupt}
|
||||
title="Interrupt the active turn; this message stays queued"
|
||||
className="flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
data-testid={`task-chat-queued-interrupt-${entry.comment.id}`}
|
||||
>
|
||||
{action === "interrupt" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<CornerDownRight className="h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
Interrupt
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSteer}
|
||||
disabled={steerDisabled}
|
||||
title={steerTitle}
|
||||
className="flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
data-testid={`task-chat-queued-steer-${entry.comment.id}`}
|
||||
>
|
||||
{action === "steer" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
|
||||
) : (
|
||||
<CornerDownRight className="h-3.5 w-3.5" aria-hidden />
|
||||
)}
|
||||
Steer
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDiscard}
|
||||
disabled={disabled || !entry.canDiscard}
|
||||
disabled={
|
||||
busy ||
|
||||
(!queue.queueId && !entry.comment.id.startsWith("optimistic-")) ||
|
||||
!entry.canDiscard
|
||||
}
|
||||
title="Discard queued message"
|
||||
aria-label={`Discard queued message: ${entry.comment.body}`}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-40"
|
||||
|
|
@ -151,7 +200,7 @@ function SortableQueuedMessage({
|
|||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
disabled={queueMutationDisabled}
|
||||
title="Queued message actions"
|
||||
aria-label={`Queued message actions: ${entry.comment.body}`}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
|
|
@ -176,38 +225,59 @@ export function TaskChatQueuedMessages({
|
|||
onEdit,
|
||||
onReorder,
|
||||
onSteer,
|
||||
onInterrupt,
|
||||
onDiscard,
|
||||
}: TaskChatQueuedMessagesProps) {
|
||||
const [entries, setEntries] = useState(queue.entries);
|
||||
const [pending, setPending] = useState<{ commentId: string; action: Exclude<QueueAction, null> } | null>(null);
|
||||
const [pending, setPending] = useState<{
|
||||
commentId: string;
|
||||
action: Exclude<QueueAction, null>;
|
||||
} | null>(null);
|
||||
const [reordering, setReordering] = useState(false);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const [visibleError, setVisibleError] = useState<string | null>(null);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setEntries(queue.entries);
|
||||
}, [queue.entries, queue.revision]);
|
||||
|
||||
const ids = useMemo(() => entries.map((entry) => entry.comment.id), [entries]);
|
||||
const ids = useMemo(
|
||||
() => entries.map((entry) => entry.comment.id),
|
||||
[entries],
|
||||
);
|
||||
|
||||
async function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!queue.queueId || !over || active.id === over.id || reordering || pending) return;
|
||||
const from = ids.indexOf(String(active.id));
|
||||
const to = ids.indexOf(String(over.id));
|
||||
if (from < 0 || to < 0) return;
|
||||
|
||||
if (
|
||||
!queue.queueId ||
|
||||
!over ||
|
||||
active.id === over.id ||
|
||||
reordering ||
|
||||
pending
|
||||
)
|
||||
return;
|
||||
const previous = entries;
|
||||
const next = arrayMove(entries, from, to).map((entry, position) => ({ ...entry, position }));
|
||||
const next = reorderQueuedMessageEntries(
|
||||
entries,
|
||||
String(active.id),
|
||||
String(over.id),
|
||||
);
|
||||
if (!next) return;
|
||||
const orderedIds = next.map((entry) => entry.comment.id);
|
||||
const activeCommentId = String(active.id);
|
||||
const to = next.findIndex((entry) => entry.comment.id === activeCommentId);
|
||||
setEntries(next);
|
||||
setReordering(true);
|
||||
setVisibleError(null);
|
||||
setAnnouncement(`Moved queued message to position ${to + 1} of ${next.length}.`);
|
||||
setAnnouncement(
|
||||
`Moved queued message to position ${to + 1} of ${next.length}.`,
|
||||
);
|
||||
try {
|
||||
await onReorder(orderedIds, queue.revision);
|
||||
} catch (error) {
|
||||
|
|
@ -223,17 +293,52 @@ export function TaskChatQueuedMessages({
|
|||
}
|
||||
}
|
||||
|
||||
async function runRowAction(commentId: string, action: Exclude<QueueAction, null>) {
|
||||
if (!queue.queueId || pending || reordering) return;
|
||||
async function runRowAction(
|
||||
commentId: string,
|
||||
action: Exclude<QueueAction, null>,
|
||||
) {
|
||||
const locallyDiscardable =
|
||||
action === "discard" && commentId.startsWith("optimistic-");
|
||||
if (
|
||||
pending ||
|
||||
reordering ||
|
||||
(!queue.queueId && action !== "interrupt" && !locallyDiscardable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const previous = entries;
|
||||
setPending({ commentId, action });
|
||||
setVisibleError(null);
|
||||
setAnnouncement(action === "steer" ? "Steering queued message." : "Discarding queued message.");
|
||||
setAnnouncement(
|
||||
action === "steer"
|
||||
? "Steering queued message."
|
||||
: action === "interrupt"
|
||||
? "Interrupting the active turn."
|
||||
: "Discarding queued message.",
|
||||
);
|
||||
if (action === "steer") {
|
||||
setEntries((current) =>
|
||||
current.filter((entry) => entry.comment.id !== commentId),
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (action === "steer") await onSteer(commentId, queue.revision);
|
||||
else if (action === "interrupt") await onInterrupt?.();
|
||||
else await onDiscard(commentId, queue.revision);
|
||||
setEntries((current) => current.filter((entry) => entry.comment.id !== commentId));
|
||||
setAnnouncement(action === "steer" ? "Message steered into the active turn." : "Queued message discarded.");
|
||||
if (action === "discard") {
|
||||
setEntries((current) =>
|
||||
current.filter((entry) => entry.comment.id !== commentId),
|
||||
);
|
||||
}
|
||||
setAnnouncement(
|
||||
action === "steer"
|
||||
? "Message steered into the active turn."
|
||||
: action === "interrupt"
|
||||
? "Active turn interrupted. Message remains queued."
|
||||
: "Queued message discarded.",
|
||||
);
|
||||
} catch (error) {
|
||||
if (action === "steer") setEntries(previous);
|
||||
setAnnouncement("");
|
||||
const code = queueActionErrorCode(error);
|
||||
setVisibleError(
|
||||
|
|
@ -241,9 +346,11 @@ export function TaskChatQueuedMessages({
|
|||
? "Too late to discard: this message is already being sent."
|
||||
: action === "steer"
|
||||
? "Couldn’t steer. Message is still queued."
|
||||
: code === "queued_comment_revision_conflict"
|
||||
? "The queue changed in another session. Review it and try again."
|
||||
: "Couldn’t discard. Message is still queued.",
|
||||
: action === "interrupt"
|
||||
? "Couldn’t interrupt. Message is still queued."
|
||||
: code === "queued_comment_revision_conflict"
|
||||
? "The queue changed in another session. Review it and try again."
|
||||
: "Couldn’t discard. Message is still queued.",
|
||||
);
|
||||
} finally {
|
||||
setPending(null);
|
||||
|
|
@ -258,20 +365,34 @@ export function TaskChatQueuedMessages({
|
|||
data-testid="task-chat-queued-messages"
|
||||
aria-label="Queued messages"
|
||||
>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={(event) => void handleDragEnd(event)}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => void handleDragEnd(event)}
|
||||
>
|
||||
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||
{entries.map((entry) => {
|
||||
const action = pending?.commentId === entry.comment.id ? pending.action : null;
|
||||
const disabled = Boolean(!queue.queueId || pending || reordering);
|
||||
const action =
|
||||
pending?.commentId === entry.comment.id ? pending.action : null;
|
||||
const busy = Boolean(pending || reordering);
|
||||
const queueMutationDisabled = Boolean(
|
||||
!queue.queueId || pending || reordering,
|
||||
);
|
||||
return (
|
||||
<SortableQueuedMessage
|
||||
key={entry.comment.id}
|
||||
entry={entry}
|
||||
queue={queue}
|
||||
disabled={disabled}
|
||||
busy={busy}
|
||||
queueMutationDisabled={queueMutationDisabled}
|
||||
action={action}
|
||||
onEdit={() => onEdit(entry.comment.id)}
|
||||
onSteer={() => void runRowAction(entry.comment.id, "steer")}
|
||||
onInterrupt={
|
||||
onInterrupt
|
||||
? () => void runRowAction(entry.comment.id, "interrupt")
|
||||
: undefined
|
||||
}
|
||||
onDiscard={() => void runRowAction(entry.comment.id, "discard")}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import { ThemeProvider } from "@/context/ThemeContext";
|
||||
import { MemoryRouter } from "@/lib/router";
|
||||
import { TaskChatRunnerTurn } from "./TaskChatRunnerTurn";
|
||||
import { transcriptToTaskChatItems } from "./transcript-adapter";
|
||||
import type {
|
||||
TaskChatItem,
|
||||
TaskChatProviderActivityFamily,
|
||||
|
|
@ -38,6 +39,7 @@ describe("TaskChatRunnerTurn", () => {
|
|||
decision: TaskChatRuntimeRequestDecision,
|
||||
) => void,
|
||||
suppressFinal = false,
|
||||
continuedAfterSteering = false,
|
||||
) =>
|
||||
act(() =>
|
||||
root.render(
|
||||
|
|
@ -50,6 +52,7 @@ describe("TaskChatRunnerTurn", () => {
|
|||
status={status}
|
||||
startedAtMs={Date.now() - 2_000}
|
||||
suppressFinal={suppressFinal}
|
||||
continuedAfterSteering={continuedAfterSteering}
|
||||
onRuntimeRequestDecision={onRuntimeRequestDecision}
|
||||
/>
|
||||
</ThemeProvider>
|
||||
|
|
@ -138,6 +141,63 @@ describe("TaskChatRunnerTurn", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("labels the streaming tail as a continuation after steering", () => {
|
||||
render([], "running", "run-1", undefined, false, true);
|
||||
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-turn-status-header"]')
|
||||
?.textContent,
|
||||
).toContain("Continued after steering · Working for");
|
||||
});
|
||||
|
||||
it("renders completed progress exactly once when the live run returns to Thinking", () => {
|
||||
const text = "Understood—I’ll use a two-second wait instead.";
|
||||
const items = transcriptToTaskChatItems(
|
||||
[
|
||||
{
|
||||
kind: "thinking",
|
||||
ts: "2026-09-04T13:39:20.000Z",
|
||||
text: "",
|
||||
lifecycle: "started",
|
||||
channel: "summary",
|
||||
itemId: "reasoning-1",
|
||||
},
|
||||
{
|
||||
kind: "thinking",
|
||||
ts: "2026-09-04T13:39:20.100Z",
|
||||
text: "",
|
||||
lifecycle: "completed",
|
||||
channel: "summary",
|
||||
itemId: "reasoning-1",
|
||||
},
|
||||
{
|
||||
kind: "assistant",
|
||||
ts: "2026-09-04T13:39:21.100Z",
|
||||
text,
|
||||
channel: "progress",
|
||||
itemId: "message-1",
|
||||
},
|
||||
],
|
||||
{ runId: "run-1", agentName: "Runner", running: true },
|
||||
);
|
||||
|
||||
render(items);
|
||||
|
||||
expect(container.textContent?.split(text)).toHaveLength(2);
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-phase-interstitial"]')
|
||||
?.textContent,
|
||||
).toContain(text);
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-live-narration"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector(
|
||||
'[data-testid="task-chat-current-activity-label"]',
|
||||
)?.textContent,
|
||||
).toBe("Thinking");
|
||||
});
|
||||
|
||||
it("keeps progress mounted above its grouped current command", () => {
|
||||
render([
|
||||
{
|
||||
|
|
@ -934,6 +994,45 @@ describe("TaskChatRunnerTurn", () => {
|
|||
).toContain("Checking.");
|
||||
});
|
||||
|
||||
it("collapses progress text repeated verbatim by the final response", () => {
|
||||
const repeated = "BASELINE-DONE";
|
||||
render(
|
||||
[
|
||||
{
|
||||
id: "progress",
|
||||
kind: "message",
|
||||
author: "agent",
|
||||
text: repeated,
|
||||
interstitial: true,
|
||||
channel: "progress",
|
||||
},
|
||||
{
|
||||
id: "tool",
|
||||
kind: "tool",
|
||||
name: "Command",
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
id: "final",
|
||||
kind: "message",
|
||||
author: "agent",
|
||||
text: repeated,
|
||||
channel: "final",
|
||||
},
|
||||
],
|
||||
"succeeded",
|
||||
);
|
||||
|
||||
expect(container.textContent?.split(repeated)).toHaveLength(2);
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-phase-interstitial"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-final-response"]')
|
||||
?.textContent,
|
||||
).toContain(repeated);
|
||||
});
|
||||
|
||||
it("clears provider wait prose when the run is accepted as yielded", () => {
|
||||
const providerWait: TaskChatItem = {
|
||||
id: "provider-wait",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
import {
|
||||
buildTurnTimelineRows,
|
||||
isTerminalRunStatus,
|
||||
omitProgressRepeatedByResponse,
|
||||
paperclipRunnerFinalResponse,
|
||||
paperclipRunnerTimelineItems,
|
||||
} from "./transcript-adapter";
|
||||
|
|
@ -186,25 +187,17 @@ function FoldedReasoningTicker({
|
|||
);
|
||||
}
|
||||
|
||||
function FoldedLiveNarration({ narration }: { narration: FoldedNarration }) {
|
||||
if (narration.kind === "reasoning") {
|
||||
if (!narration.line) return null;
|
||||
return (
|
||||
<FoldedReasoningTicker
|
||||
logicalKey={`${narration.item.id}:${narration.lineIndex}`}
|
||||
text={narration.line}
|
||||
/>
|
||||
);
|
||||
}
|
||||
function FoldedLiveNarration({
|
||||
narration,
|
||||
}: {
|
||||
narration: Extract<FoldedNarration, { kind: "reasoning" }>;
|
||||
}) {
|
||||
if (!narration.line) return null;
|
||||
return (
|
||||
<div
|
||||
className="tc-enter-cot-line min-w-0 px-1 py-1.5 text-sm text-foreground/90"
|
||||
data-testid="task-chat-progress-update"
|
||||
>
|
||||
<MarkdownBody softBreaks linkIssueReferences>
|
||||
{narration.item.text}
|
||||
</MarkdownBody>
|
||||
</div>
|
||||
<FoldedReasoningTicker
|
||||
logicalKey={`${narration.item.id}:${narration.lineIndex}`}
|
||||
text={narration.line}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -298,10 +291,12 @@ function RunnerTurnStatus({
|
|||
status,
|
||||
startedAtMs,
|
||||
finishedAtMs,
|
||||
continuedAfterSteering = false,
|
||||
}: {
|
||||
status: string;
|
||||
startedAtMs: number | null;
|
||||
finishedAtMs?: number | null;
|
||||
continuedAfterSteering?: boolean;
|
||||
}) {
|
||||
const terminal = isTerminalRunStatus(status);
|
||||
useSecondTick(!terminal && startedAtMs != null);
|
||||
|
|
@ -321,6 +316,9 @@ function RunnerTurnStatus({
|
|||
? `${label} ${failed ? "after" : "for"} ${elapsed}`
|
||||
: label
|
||||
: `${label} for ${elapsed ?? "0s"}`;
|
||||
const visibleLabel = continuedAfterSteering
|
||||
? `Continued after steering · ${semanticLabel}`
|
||||
: semanticLabel;
|
||||
|
||||
return (
|
||||
<span
|
||||
|
|
@ -330,7 +328,7 @@ function RunnerTurnStatus({
|
|||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{semanticLabel}
|
||||
{visibleLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -431,6 +429,7 @@ export function TaskChatRunnerTurn({
|
|||
finishedAtMs,
|
||||
activityUnavailable = false,
|
||||
suppressFinal = false,
|
||||
continuedAfterSteering = false,
|
||||
onRuntimeRequestDecision,
|
||||
}: {
|
||||
/** Stable identity used to clear replay-latched final text for the next turn. */
|
||||
|
|
@ -444,6 +443,8 @@ export function TaskChatRunnerTurn({
|
|||
activityUnavailable?: boolean;
|
||||
/** Accepted wait/interaction authority overrides an early provider final. */
|
||||
suppressFinal?: boolean;
|
||||
/** The visible tail resumes the same native run after an accepted steer. */
|
||||
continuedAfterSteering?: boolean;
|
||||
onRuntimeRequestDecision?: (
|
||||
item: TaskChatRuntimeRequestItem,
|
||||
decision: TaskChatRuntimeRequestDecision,
|
||||
|
|
@ -451,10 +452,6 @@ export function TaskChatRunnerTurn({
|
|||
}) {
|
||||
const terminal = isTerminalRunStatus(status);
|
||||
const narration = latestFoldedNarration(items);
|
||||
const timelineRows = buildTurnTimelineRows(
|
||||
paperclipRunnerTimelineItems(items),
|
||||
!terminal,
|
||||
);
|
||||
const currentActivityItems = currentActivityStatusItems(items);
|
||||
const yielded = items.some(
|
||||
(item) =>
|
||||
|
|
@ -501,6 +498,11 @@ export function TaskChatRunnerTurn({
|
|||
finalRef.current.providerText = observedProviderText;
|
||||
}
|
||||
const final = finalRef.current.item;
|
||||
const timelineItems = paperclipRunnerTimelineItems(items);
|
||||
const timelineRows = buildTurnTimelineRows(
|
||||
omitProgressRepeatedByResponse(timelineItems, final?.text),
|
||||
!terminal,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -522,9 +524,10 @@ export function TaskChatRunnerTurn({
|
|||
status={status}
|
||||
startedAtMs={startedAtMs}
|
||||
finishedAtMs={finishedAtMs}
|
||||
continuedAfterSteering={continuedAfterSteering}
|
||||
/>
|
||||
</div>
|
||||
{!terminal && narration && !final ? (
|
||||
{!terminal && narration?.kind === "reasoning" && !final ? (
|
||||
<div
|
||||
className="flex min-w-0 flex-col py-1"
|
||||
data-testid="task-chat-live-narration"
|
||||
|
|
|
|||
|
|
@ -214,6 +214,19 @@ describe("TaskChatTurn", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("labels a later segment as a continuation of the steered run", () => {
|
||||
renderTurn({
|
||||
...SETTLED,
|
||||
standaloneHeader: true,
|
||||
continuedAfterSteering: true,
|
||||
agentName: "Codex",
|
||||
});
|
||||
|
||||
expect(summaryBtn()?.textContent).toContain(
|
||||
"Continued after steering · Worked for 38s",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a yielded runner summary after the settled timeline", () => {
|
||||
renderTurn({
|
||||
...SETTLED,
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ export function TaskChatTurn({
|
|||
/>
|
||||
) : null}
|
||||
<span className="min-w-0 truncate">
|
||||
{item.continuedAfterSteering ? "Continued after steering · " : ""}
|
||||
{item.summary.durationLabel
|
||||
? `${item.summary.failed ? "Stopped" : "Worked"} for ${item.summary.durationLabel}`
|
||||
: item.summary.failed
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { IssueChatComment } from "@/lib/issue-chat-messages";
|
||||
import { commentsToTaskChatItems } from "./task-chat-adapter";
|
||||
import {
|
||||
commentsToTaskChatItems,
|
||||
formatTaskChatTimestamp,
|
||||
} from "./task-chat-adapter";
|
||||
|
||||
describe("commentsToTaskChatItems", () => {
|
||||
it("classifies a recovered local-board comment as an agent bubble", () => {
|
||||
|
|
@ -125,4 +128,72 @@ describe("commentsToTaskChatItems", () => {
|
|||
expect(agent.runAgentId).toBeUndefined();
|
||||
expect(agent.createdAtIso).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows both queue and steer times for a causally repositioned follow-up", () => {
|
||||
const createdAt = "2026-09-04T14:09:33.000Z";
|
||||
const conversationAnchorAt = "2026-09-04T14:10:14.000Z";
|
||||
const [item] = commentsToTaskChatItems([
|
||||
{
|
||||
id: "c-steered",
|
||||
body: "Use three seconds instead.",
|
||||
authorType: "user",
|
||||
authorUserId: "user-1",
|
||||
authorAgentId: null,
|
||||
followUpRequested: true,
|
||||
consumedByRunId: "run-1",
|
||||
steeredIntoRunId: "run-1",
|
||||
conversationAnchorAt,
|
||||
createdAt,
|
||||
} as unknown as IssueChatComment,
|
||||
]);
|
||||
|
||||
expect(item).toMatchObject({
|
||||
kind: "message",
|
||||
timestamp: `Queued ${formatTaskChatTimestamp(createdAt)} · Steered ${formatTaskChatTimestamp(conversationAnchorAt)}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the successor-run delivery time for a queued follow-up", () => {
|
||||
const createdAt = "2026-09-04T14:09:33.000Z";
|
||||
const conversationAnchorAt = "2026-09-04T14:10:35.000Z";
|
||||
const [item] = commentsToTaskChatItems([
|
||||
{
|
||||
id: "c-delivered",
|
||||
body: "Use three seconds instead.",
|
||||
authorType: "user",
|
||||
authorUserId: "user-1",
|
||||
authorAgentId: null,
|
||||
followUpRequested: true,
|
||||
consumedByRunId: "run-2",
|
||||
conversationAnchorAt,
|
||||
createdAt,
|
||||
} as unknown as IssueChatComment,
|
||||
]);
|
||||
|
||||
expect(item).toMatchObject({
|
||||
kind: "message",
|
||||
timestamp: `Queued ${formatTaskChatTimestamp(createdAt)} · Delivered ${formatTaskChatTimestamp(conversationAnchorAt)}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an ordinary first message on the compact timestamp", () => {
|
||||
const createdAt = "2026-09-04T14:09:33.000Z";
|
||||
const [item] = commentsToTaskChatItems([
|
||||
{
|
||||
id: "c-initial",
|
||||
body: "Start the task.",
|
||||
authorType: "user",
|
||||
authorUserId: "user-1",
|
||||
authorAgentId: null,
|
||||
consumedByRunId: "run-1",
|
||||
conversationAnchorAt: "2026-09-04T14:10:35.000Z",
|
||||
createdAt,
|
||||
} as unknown as IssueChatComment,
|
||||
]);
|
||||
|
||||
expect(item).toMatchObject({
|
||||
kind: "message",
|
||||
timestamp: formatTaskChatTimestamp(createdAt),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -46,6 +46,30 @@ export function formatTaskChatTimestamp(value: unknown): string | undefined {
|
|||
return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow-up inputs render at the causal slot where a runner consumed them.
|
||||
* Keep their original submission time visible as well so the reordered bubble
|
||||
* cannot look like it travelled backwards in the conversation.
|
||||
*/
|
||||
export function formatTaskChatCommentTimestamp(
|
||||
comment: IssueChatComment,
|
||||
kind: TaskChatAuthorKind,
|
||||
): string | undefined {
|
||||
const queuedAt = formatTaskChatTimestamp(comment.createdAt);
|
||||
const deliveredAt = formatTaskChatTimestamp(comment.conversationAnchorAt);
|
||||
const isDeliveredFollowUp = Boolean(
|
||||
kind === "human" &&
|
||||
comment.conversationAnchorAt &&
|
||||
comment.consumedByRunId &&
|
||||
(comment.followUpRequested || comment.steeredIntoRunId),
|
||||
);
|
||||
if (!isDeliveredFollowUp) return queuedAt;
|
||||
|
||||
if (!queuedAt || !deliveredAt) return queuedAt ?? deliveredAt;
|
||||
const action = comment.steeredIntoRunId ? "Steered" : "Delivered";
|
||||
return `Queued ${queuedAt} · ${action} ${deliveredAt}`;
|
||||
}
|
||||
|
||||
export function commentsToTaskChatItems(
|
||||
comments: IssueChatComment[],
|
||||
ctx: TaskChatAdapterContext = {},
|
||||
|
|
@ -99,7 +123,7 @@ export function commentsToTaskChatItems(
|
|||
author: kind,
|
||||
authorName,
|
||||
text: comment.body,
|
||||
timestamp: formatTaskChatTimestamp(comment.createdAt),
|
||||
timestamp: formatTaskChatCommentTimestamp(comment, kind),
|
||||
optimistic,
|
||||
queueTargetRunId: queued ? comment.queueTargetRunId ?? null : null,
|
||||
verificationCaveats: sourceRunId
|
||||
|
|
|
|||
|
|
@ -505,6 +505,8 @@ export interface TaskChatTurnItem {
|
|||
animateFold?: boolean;
|
||||
/** New-runner turns keep Worked/Stopped fixed above their ordered timeline. */
|
||||
standaloneHeader?: boolean;
|
||||
/** This segment resumes the same native run after a steering input. */
|
||||
continuedAfterSteering?: boolean;
|
||||
/** Durable response shown after the ordered Paperclip Runner timeline. */
|
||||
finalResponse?: TaskChatMessageItem;
|
||||
summary: {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
flattenSelfTalk,
|
||||
isNestableLiveChild,
|
||||
ISSUE_BRIEF_ITEM_ID,
|
||||
omitProgressRepeatedByResponseAcrossSegments,
|
||||
paperclipRunnerActivityItems,
|
||||
paperclipRunnerFinalResponse,
|
||||
paperclipRunnerHistoryItems,
|
||||
|
|
@ -34,6 +35,34 @@ import { providerActivityPresentation } from "./task-chat-activity-presentation"
|
|||
|
||||
const TS = "2026-07-31T12:00:00.000Z";
|
||||
|
||||
describe("omitProgressRepeatedByResponseAcrossSegments", () => {
|
||||
const progress = (id: string, text: string): TaskChatItem => ({
|
||||
id,
|
||||
kind: "message",
|
||||
author: "agent",
|
||||
text,
|
||||
channel: "progress",
|
||||
interstitial: true,
|
||||
});
|
||||
|
||||
it("removes only the final matching progress item across a steered run", () => {
|
||||
const first = progress("first", "Repeated answer");
|
||||
const second = progress("second", "Repeated answer");
|
||||
const segments = [[first], [second]];
|
||||
|
||||
expect(
|
||||
omitProgressRepeatedByResponseAcrossSegments(segments, "Repeated answer"),
|
||||
).toEqual([[first], []]);
|
||||
});
|
||||
|
||||
it("preserves segment identity when there is no durable-response match", () => {
|
||||
const segments = [[progress("first", "Progress only")]];
|
||||
expect(
|
||||
omitProgressRepeatedByResponseAcrossSegments(segments, "Final answer"),
|
||||
).toBe(segments);
|
||||
});
|
||||
});
|
||||
|
||||
function toolCall(name: string, input?: unknown): TranscriptEntry {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
|
|
|
|||
|
|
@ -1054,6 +1054,65 @@ export function settledRunChildren(
|
|||
return buildTurnTimelineRows(parsed, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider can emit the same user-facing text first as progress and then as
|
||||
* its durable response. Keep the chronological progress item while the answer
|
||||
* is still unknown, but once that response exists let its dedicated bubble own
|
||||
* the text. Only the last exact match is removed so intentional earlier
|
||||
* repetition remains visible.
|
||||
*/
|
||||
export function omitProgressRepeatedByResponse(
|
||||
items: readonly TaskChatItem[],
|
||||
responseText: string | null | undefined,
|
||||
): readonly TaskChatItem[] {
|
||||
return (
|
||||
omitProgressRepeatedByResponseAcrossSegments([items], responseText)[0] ??
|
||||
items
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run-wide form of omitProgressRepeatedByResponse for steered transcripts.
|
||||
* Segmentation is a presentation concern, so it must not turn one dedupe into
|
||||
* one removal per segment. Preserve all intentional earlier repetitions and
|
||||
* remove only the final progress item matching the durable response.
|
||||
*/
|
||||
export function omitProgressRepeatedByResponseAcrossSegments(
|
||||
segments: readonly (readonly TaskChatItem[])[],
|
||||
responseText: string | null | undefined,
|
||||
): readonly (readonly TaskChatItem[])[] {
|
||||
const normalizedResponse = responseText?.trim();
|
||||
if (!normalizedResponse) return segments;
|
||||
let redundantSegmentIndex = -1;
|
||||
let redundantIndex = -1;
|
||||
findRedundant: for (
|
||||
let segmentIndex = segments.length - 1;
|
||||
segmentIndex >= 0;
|
||||
segmentIndex -= 1
|
||||
) {
|
||||
const items = segments[segmentIndex];
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
const item = items[index];
|
||||
if (
|
||||
item.kind === "message" &&
|
||||
item.interstitial &&
|
||||
item.channel === "progress" &&
|
||||
item.text.trim() === normalizedResponse
|
||||
) {
|
||||
redundantSegmentIndex = segmentIndex;
|
||||
redundantIndex = index;
|
||||
break findRedundant;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (redundantSegmentIndex < 0) return segments;
|
||||
return segments.map((items, segmentIndex) =>
|
||||
segmentIndex === redundantSegmentIndex
|
||||
? items.filter((_, index) => index !== redundantIndex)
|
||||
: items,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the paperclip runner's expanded activity history focused on work the
|
||||
* user can act on. The normalized transcript remains lossless; this is only a
|
||||
|
|
|
|||
|
|
@ -1,25 +1,66 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeIssueQueuedCommentQueue } from "./issue-queued-comment-queue";
|
||||
import {
|
||||
mergePendingIssueQueuedComments,
|
||||
normalizeIssueQueuedCommentQueue,
|
||||
} from "./issue-queued-comment-queue";
|
||||
|
||||
function comment(id: string, body: string) {
|
||||
const now = new Date("2026-09-04T12:00:00.000Z");
|
||||
return {
|
||||
id,
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorType: "user" as const,
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
body,
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
describe("normalizeIssueQueuedCommentQueue", () => {
|
||||
it("sorts, deduplicates, and drops malformed queue entries", () => {
|
||||
const queue = normalizeIssueQueuedCommentQueue({
|
||||
issueId: "issue-1",
|
||||
queueId: "wake-1",
|
||||
state: "deferred",
|
||||
targetRunId: "run-1",
|
||||
revision: "rev-1",
|
||||
protocol: "paperclip_runner_v1",
|
||||
steeringDisposition: "available",
|
||||
entries: [
|
||||
{ comment: { id: "second", body: "Second" }, position: 2, canEdit: true, canDiscard: true },
|
||||
{ comment: { id: "first", body: "First" }, position: 0, canEdit: false, canDiscard: true },
|
||||
{ comment: { id: "first", body: "Duplicate" }, position: 1, canEdit: true, canDiscard: true },
|
||||
{ comment: { body: "Missing id" }, position: 3 },
|
||||
],
|
||||
}, "fallback");
|
||||
const queue = normalizeIssueQueuedCommentQueue(
|
||||
{
|
||||
issueId: "issue-1",
|
||||
queueId: "wake-1",
|
||||
state: "deferred",
|
||||
targetRunId: "run-1",
|
||||
revision: "rev-1",
|
||||
protocol: "paperclip_runner_v1",
|
||||
steeringDisposition: "available",
|
||||
entries: [
|
||||
{
|
||||
comment: { id: "second", body: "Second" },
|
||||
position: 2,
|
||||
canEdit: true,
|
||||
canDiscard: true,
|
||||
},
|
||||
{
|
||||
comment: { id: "first", body: "First" },
|
||||
position: 0,
|
||||
canEdit: false,
|
||||
canDiscard: true,
|
||||
},
|
||||
{
|
||||
comment: { id: "first", body: "Duplicate" },
|
||||
position: 1,
|
||||
canEdit: true,
|
||||
canDiscard: true,
|
||||
},
|
||||
{ comment: { body: "Missing id" }, position: 3 },
|
||||
],
|
||||
},
|
||||
"fallback",
|
||||
);
|
||||
|
||||
expect(queue.entries.map((entry) => entry.comment.id)).toEqual(["first", "second"]);
|
||||
expect(queue.entries.map((entry) => entry.comment.id)).toEqual([
|
||||
"first",
|
||||
"second",
|
||||
]);
|
||||
expect(queue.entries.map((entry) => entry.position)).toEqual([0, 1]);
|
||||
expect(queue.protocol).toBe("paperclip_runner_v1");
|
||||
expect(queue.queueId).toBe("wake-1");
|
||||
|
|
@ -28,7 +69,10 @@ describe("normalizeIssueQueuedCommentQueue", () => {
|
|||
});
|
||||
|
||||
it("fails closed for malformed protocol and steering data", () => {
|
||||
const queue = normalizeIssueQueuedCommentQueue({ entries: "nope" }, "issue-fallback");
|
||||
const queue = normalizeIssueQueuedCommentQueue(
|
||||
{ entries: "nope" },
|
||||
"issue-fallback",
|
||||
);
|
||||
expect(queue).toMatchObject({
|
||||
issueId: "issue-fallback",
|
||||
queueId: null,
|
||||
|
|
@ -40,4 +84,67 @@ describe("normalizeIssueQueuedCommentQueue", () => {
|
|||
entries: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("projects a new follow-up into an inert queue before server acknowledgement", () => {
|
||||
const pending = comment("optimistic-1", "Use three seconds instead");
|
||||
const queue = mergePendingIssueQueuedComments({
|
||||
issueId: "issue-1",
|
||||
authoritativeQueue: null,
|
||||
pendingComments: [{ comment: pending, targetRunId: "run-1" }],
|
||||
fallbackProtocol: "paperclip_runner_v1",
|
||||
});
|
||||
|
||||
expect(queue).toMatchObject({
|
||||
queueId: null,
|
||||
state: "deferred",
|
||||
targetRunId: "run-1",
|
||||
protocol: "paperclip_runner_v1",
|
||||
steeringDisposition: "temporarily_unavailable",
|
||||
entries: [
|
||||
{
|
||||
comment: { id: "optimistic-1", body: "Use three seconds instead" },
|
||||
position: 0,
|
||||
canEdit: false,
|
||||
canDiscard: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("deduplicates acknowledged entries and restores the authoritative queue identity", () => {
|
||||
const pending = comment("comment-1", "Use three seconds instead");
|
||||
const authoritativeQueue = normalizeIssueQueuedCommentQueue(
|
||||
{
|
||||
issueId: "issue-1",
|
||||
queueId: "wake-1",
|
||||
state: "deferred",
|
||||
targetRunId: "run-1",
|
||||
revision: "rev-1",
|
||||
protocol: "paperclip_runner_v1",
|
||||
steeringDisposition: "available",
|
||||
entries: [
|
||||
{
|
||||
comment: pending,
|
||||
position: 0,
|
||||
canEdit: true,
|
||||
canDiscard: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
"issue-1",
|
||||
);
|
||||
|
||||
const queue = mergePendingIssueQueuedComments({
|
||||
issueId: "issue-1",
|
||||
authoritativeQueue,
|
||||
pendingComments: [{ comment: pending, targetRunId: "run-1" }],
|
||||
fallbackProtocol: "legacy",
|
||||
});
|
||||
|
||||
expect(queue?.queueId).toBe("wake-1");
|
||||
expect(queue?.steeringDisposition).toBe("available");
|
||||
expect(queue?.entries.map((entry) => entry.comment.id)).toEqual([
|
||||
"comment-1",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type {
|
||||
IssueComment,
|
||||
IssueQueuedCommentProtocol,
|
||||
IssueQueuedCommentQueue,
|
||||
IssueQueuedCommentQueueState,
|
||||
IssueQueuedCommentSteeringDisposition,
|
||||
|
|
@ -7,7 +8,7 @@ import type {
|
|||
|
||||
function record(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
|
|
@ -16,7 +17,10 @@ const STEERING_DISPOSITIONS = new Set<IssueQueuedCommentSteeringDisposition>([
|
|||
"unsupported",
|
||||
"temporarily_unavailable",
|
||||
]);
|
||||
const QUEUE_STATES = new Set<IssueQueuedCommentQueueState>(["deferred", "queued"]);
|
||||
const QUEUE_STATES = new Set<IssueQueuedCommentQueueState>([
|
||||
"deferred",
|
||||
"queued",
|
||||
]);
|
||||
|
||||
/** Defensive boundary shared by IssueDetail and Storybook queue fixtures. */
|
||||
export function normalizeIssueQueuedCommentQueue(
|
||||
|
|
@ -33,15 +37,18 @@ export function normalizeIssueQueuedCommentQueue(
|
|||
const body = typeof comment?.body === "string" ? comment.body : null;
|
||||
if (!id || body === null || seen.has(id)) return [];
|
||||
seen.add(id);
|
||||
const position = typeof entry?.position === "number" && Number.isFinite(entry.position)
|
||||
? entry.position
|
||||
: sourcePosition;
|
||||
return [{
|
||||
comment: comment as unknown as IssueComment,
|
||||
position,
|
||||
canEdit: entry?.canEdit === true,
|
||||
canDiscard: entry?.canDiscard === true,
|
||||
}];
|
||||
const position =
|
||||
typeof entry?.position === "number" && Number.isFinite(entry.position)
|
||||
? entry.position
|
||||
: sourcePosition;
|
||||
return [
|
||||
{
|
||||
comment: comment as unknown as IssueComment,
|
||||
position,
|
||||
canEdit: entry?.canEdit === true,
|
||||
canDiscard: entry?.canDiscard === true,
|
||||
},
|
||||
];
|
||||
})
|
||||
.sort((left, right) => left.position - right.position)
|
||||
.map((entry, position) => ({ ...entry, position }));
|
||||
|
|
@ -49,20 +56,94 @@ export function normalizeIssueQueuedCommentQueue(
|
|||
const state = source?.state;
|
||||
|
||||
return {
|
||||
issueId: typeof source?.issueId === "string" ? source.issueId : fallbackIssueId,
|
||||
issueId:
|
||||
typeof source?.issueId === "string" ? source.issueId : fallbackIssueId,
|
||||
queueId: typeof source?.queueId === "string" ? source.queueId : null,
|
||||
state:
|
||||
typeof state === "string" && QUEUE_STATES.has(state as IssueQueuedCommentQueueState)
|
||||
? state as IssueQueuedCommentQueueState
|
||||
typeof state === "string" &&
|
||||
QUEUE_STATES.has(state as IssueQueuedCommentQueueState)
|
||||
? (state as IssueQueuedCommentQueueState)
|
||||
: null,
|
||||
targetRunId: typeof source?.targetRunId === "string" ? source.targetRunId : null,
|
||||
revision: typeof source?.revision === "string" ? source.revision : "unavailable",
|
||||
protocol: source?.protocol === "paperclip_runner_v1" ? "paperclip_runner_v1" : "legacy",
|
||||
targetRunId:
|
||||
typeof source?.targetRunId === "string" ? source.targetRunId : null,
|
||||
revision:
|
||||
typeof source?.revision === "string" ? source.revision : "unavailable",
|
||||
protocol:
|
||||
source?.protocol === "paperclip_runner_v1"
|
||||
? "paperclip_runner_v1"
|
||||
: "legacy",
|
||||
steeringDisposition:
|
||||
typeof disposition === "string"
|
||||
&& STEERING_DISPOSITIONS.has(disposition as IssueQueuedCommentSteeringDisposition)
|
||||
? disposition as IssueQueuedCommentSteeringDisposition
|
||||
typeof disposition === "string" &&
|
||||
STEERING_DISPOSITIONS.has(
|
||||
disposition as IssueQueuedCommentSteeringDisposition,
|
||||
)
|
||||
? (disposition as IssueQueuedCommentSteeringDisposition)
|
||||
: "unsupported",
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PendingIssueQueuedComment {
|
||||
comment: IssueComment;
|
||||
targetRunId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a just-submitted follow-up in the composer queue while the comment and
|
||||
* queue endpoints acknowledge it independently. If the server has not yet
|
||||
* included every local entry, queue mutations are withheld by clearing the
|
||||
* queue id; local discard remains available through the optimistic comment
|
||||
* cancellation path.
|
||||
*/
|
||||
export function mergePendingIssueQueuedComments(params: {
|
||||
issueId: string;
|
||||
authoritativeQueue: IssueQueuedCommentQueue | null | undefined;
|
||||
pendingComments: PendingIssueQueuedComment[];
|
||||
fallbackProtocol: IssueQueuedCommentProtocol;
|
||||
}): IssueQueuedCommentQueue | null {
|
||||
const authoritativeEntries = params.authoritativeQueue?.entries ?? [];
|
||||
const authoritativeIds = new Set(
|
||||
authoritativeEntries.map((entry) => entry.comment.id),
|
||||
);
|
||||
const pendingEntries = params.pendingComments
|
||||
.filter(({ comment }) => !authoritativeIds.has(comment.id))
|
||||
.map(({ comment }, position) => ({
|
||||
comment,
|
||||
position: authoritativeEntries.length + position,
|
||||
canEdit: false,
|
||||
canDiscard: true,
|
||||
}));
|
||||
const entries = [...authoritativeEntries, ...pendingEntries].map(
|
||||
(entry, position) => ({ ...entry, position }),
|
||||
);
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
const queueAcknowledged = pendingEntries.length === 0;
|
||||
const authoritativeOwnsQueue = Boolean(params.authoritativeQueue?.queueId);
|
||||
const fallbackTargetRunId =
|
||||
params.pendingComments.find((entry) => entry.targetRunId)?.targetRunId ??
|
||||
null;
|
||||
const protocol =
|
||||
params.authoritativeQueue?.protocol ?? params.fallbackProtocol;
|
||||
const targetRunId = authoritativeOwnsQueue
|
||||
? (params.authoritativeQueue?.targetRunId ?? null)
|
||||
: fallbackTargetRunId;
|
||||
|
||||
return {
|
||||
issueId: params.authoritativeQueue?.issueId ?? params.issueId,
|
||||
queueId: queueAcknowledged
|
||||
? (params.authoritativeQueue?.queueId ?? null)
|
||||
: null,
|
||||
state:
|
||||
params.authoritativeQueue?.state ?? (targetRunId ? "deferred" : null),
|
||||
targetRunId,
|
||||
revision: params.authoritativeQueue?.revision ?? "awaiting-server",
|
||||
protocol,
|
||||
steeringDisposition:
|
||||
params.authoritativeQueue?.steeringDisposition ??
|
||||
(protocol === "paperclip_runner_v1" && targetRunId
|
||||
? "temporarily_unavailable"
|
||||
: "unsupported"),
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1404,16 +1404,26 @@ describe("IssueDetail", () => {
|
|||
|
||||
expect(container.textContent).toContain("Issue detail smoke");
|
||||
expect(container.textContent).toContain("Task chat thread");
|
||||
const titleGroup = container.querySelector('[data-slot="task-detail-title"]');
|
||||
const identifier = titleGroup?.querySelector('[data-slot="task-title-identifier"]');
|
||||
const titleGroup = container.querySelector(
|
||||
'[data-slot="task-detail-title"]',
|
||||
);
|
||||
const identifier = titleGroup?.querySelector(
|
||||
'[data-slot="task-title-identifier"]',
|
||||
);
|
||||
const titleRow = titleGroup?.parentElement;
|
||||
const titleActions = container.querySelector('[data-slot="task-title-actions"]');
|
||||
expect(titleGroup?.textContent?.replace(/\s+/g, " ").trim()).toBe("Issue detail smokePAP-1");
|
||||
const titleActions = container.querySelector(
|
||||
'[data-slot="task-title-actions"]',
|
||||
);
|
||||
expect(titleGroup?.textContent?.replace(/\s+/g, " ").trim()).toBe(
|
||||
"Issue detail smokePAP-1",
|
||||
);
|
||||
expect(identifier?.textContent).toBe("PAP-1");
|
||||
expect(titleRow?.className).toContain("pr-8");
|
||||
expect(titleActions?.className).toContain("absolute");
|
||||
expect(titleActions?.className).toContain("top-0");
|
||||
expect(titleActions?.querySelector('button[aria-label="More task actions"]')).not.toBeNull();
|
||||
expect(
|
||||
titleActions?.querySelector('button[aria-label="More task actions"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
consoleErrorSpy.mock.calls.some((call: unknown[]) =>
|
||||
String(call[0]).includes(
|
||||
|
|
@ -1456,7 +1466,9 @@ describe("IssueDetail", () => {
|
|||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).not.toContain("Parent task visible in Properties");
|
||||
expect(container.textContent).not.toContain(
|
||||
"Parent task visible in Properties",
|
||||
);
|
||||
expect(container.textContent).not.toContain("Quick win");
|
||||
expect(container.textContent).toContain("Issue detail smoke");
|
||||
});
|
||||
|
|
@ -1542,7 +1554,9 @@ describe("IssueDetail", () => {
|
|||
);
|
||||
});
|
||||
await flushReact();
|
||||
expect(mockSetBreadcrumbToolbar.mock.calls.every(([node]) => node === null)).toBe(true);
|
||||
expect(
|
||||
mockSetBreadcrumbToolbar.mock.calls.every(([node]) => node === null),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("retains the production breadcrumb side-panel toggle when Streamlined UI is off", async () => {
|
||||
|
|
@ -1598,10 +1612,7 @@ describe("IssueDetail", () => {
|
|||
await flushReact();
|
||||
|
||||
expect(
|
||||
readRecentTasks(
|
||||
getRecentTasksStorageKey("company-1", null),
|
||||
"company-1",
|
||||
),
|
||||
readRecentTasks(getRecentTasksStorageKey("company-1", null), "company-1"),
|
||||
).toEqual([]);
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -1971,7 +1982,8 @@ describe("IssueDetail", () => {
|
|||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const panel = mockOpenPanel.mock.calls.at(-1)?.[0] as { props?: Record<string, unknown> } | undefined;
|
||||
const panel = mockOpenPanel.mock.calls.at(-1)?.[0] as
|
||||
{ props?: Record<string, unknown> } | undefined;
|
||||
expect(panel?.props?.childIssues).toEqual([
|
||||
expect.objectContaining({ id: "child-1", identifier: "PAP-2" }),
|
||||
]);
|
||||
|
|
@ -2189,11 +2201,14 @@ describe("IssueDetail", () => {
|
|||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(mockIssuesApi.resolveRecoveryAction).toHaveBeenCalledWith(issue.identifier, {
|
||||
actionId: activeRecoveryAction.id,
|
||||
outcome: "restored",
|
||||
sourceIssueStatus: "todo",
|
||||
});
|
||||
expect(mockIssuesApi.resolveRecoveryAction).toHaveBeenCalledWith(
|
||||
issue.identifier,
|
||||
{
|
||||
actionId: activeRecoveryAction.id,
|
||||
outcome: "restored",
|
||||
sourceIssueStatus: "todo",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
mockIssuesApi.resolveRecoveryAction.mockReset();
|
||||
|
|
@ -2251,10 +2266,15 @@ describe("IssueDetail", () => {
|
|||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const moreButton = container.querySelector<HTMLButtonElement>('button[aria-label="More task actions"]');
|
||||
const moreButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="More task actions"]',
|
||||
);
|
||||
await act(async () => moreButton!.click());
|
||||
const archiveButton = Array.from(document.body.querySelectorAll<HTMLButtonElement>("button"))
|
||||
.find((button) => button.textContent?.trim() === "Archive from inbox") ?? null;
|
||||
const archiveButton =
|
||||
Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((button) => button.textContent?.trim() === "Archive from inbox") ??
|
||||
null;
|
||||
expect(archiveButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -2376,10 +2396,15 @@ describe("IssueDetail", () => {
|
|||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const moreButton = container.querySelector<HTMLButtonElement>('button[aria-label="More task actions"]');
|
||||
const moreButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="More task actions"]',
|
||||
);
|
||||
await act(async () => moreButton!.click());
|
||||
const archiveButton = Array.from(document.body.querySelectorAll<HTMLButtonElement>("button"))
|
||||
.find((button) => button.textContent?.trim() === "Archive from inbox") ?? null;
|
||||
const archiveButton =
|
||||
Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((button) => button.textContent?.trim() === "Archive from inbox") ??
|
||||
null;
|
||||
expect(archiveButton).not.toBeNull();
|
||||
await act(async () => {
|
||||
archiveButton!.dispatchEvent(
|
||||
|
|
@ -2411,7 +2436,11 @@ describe("IssueDetail", () => {
|
|||
});
|
||||
|
||||
it("keeps inbox archive actions scoped to an inbox-origin task", async () => {
|
||||
mockLocation.state = createIssueDetailLocationState("Tasks", "/issues/all", "issues");
|
||||
mockLocation.state = createIssueDetailLocationState(
|
||||
"Tasks",
|
||||
"/issues/all",
|
||||
"issues",
|
||||
);
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
|
||||
keyboardShortcuts: true,
|
||||
|
|
@ -2428,13 +2457,20 @@ describe("IssueDetail", () => {
|
|||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const moreButton = container.querySelector<HTMLButtonElement>('button[aria-label="More task actions"]');
|
||||
const moreButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="More task actions"]',
|
||||
);
|
||||
await act(async () => moreButton!.click());
|
||||
expect(Array.from(document.body.querySelectorAll("button"))
|
||||
.some((button) => button.textContent?.trim() === "Archive from inbox")).toBe(false);
|
||||
expect(
|
||||
Array.from(document.body.querySelectorAll("button")).some(
|
||||
(button) => button.textContent?.trim() === "Archive from inbox",
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
mockIssuesApi.archiveFromInbox.mockClear();
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "y", bubbles: true }));
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "y", bubbles: true }),
|
||||
);
|
||||
expect(mockIssuesApi.archiveFromInbox).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -2458,13 +2494,18 @@ describe("IssueDetail", () => {
|
|||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const panel = mockOpenPanel.mock.calls.at(-1)?.[0] as { props?: Record<string, unknown> } | undefined;
|
||||
expect(panel?.props?.issueLinkState).toEqual(expect.objectContaining({
|
||||
issueDetailSource: "inbox",
|
||||
issueDetailInboxQuickArchiveArmed: false,
|
||||
}));
|
||||
const panel = mockOpenPanel.mock.calls.at(-1)?.[0] as
|
||||
{ props?: Record<string, unknown> } | undefined;
|
||||
expect(panel?.props?.issueLinkState).toEqual(
|
||||
expect.objectContaining({
|
||||
issueDetailSource: "inbox",
|
||||
issueDetailInboxQuickArchiveArmed: false,
|
||||
}),
|
||||
);
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "y", bubbles: true }));
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "y", bubbles: true }),
|
||||
);
|
||||
await waitForAssertion(() => {
|
||||
expect(mockIssuesApi.archiveFromInbox).toHaveBeenCalledWith("issue-1");
|
||||
});
|
||||
|
|
@ -2472,7 +2513,11 @@ describe("IssueDetail", () => {
|
|||
|
||||
it("uses history Back for a live inbox origin and a route fallback for direct links", async () => {
|
||||
mockSidebarState.isMobile = true;
|
||||
mockLocation.state = createIssueDetailLocationState("Inbox", "/inbox/mine", "inbox");
|
||||
mockLocation.state = createIssueDetailLocationState(
|
||||
"Inbox",
|
||||
"/inbox/mine",
|
||||
"inbox",
|
||||
);
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -2493,9 +2538,13 @@ describe("IssueDetail", () => {
|
|||
document.body.appendChild(toolbarContainer);
|
||||
const toolbarRoot = createRoot(toolbarContainer);
|
||||
flushSync(() => toolbarRoot.render(liveToolbar));
|
||||
const historyLengthSpy = vi.spyOn(window.history, "length", "get").mockReturnValue(2);
|
||||
const historyLengthSpy = vi
|
||||
.spyOn(window.history, "length", "get")
|
||||
.mockReturnValue(2);
|
||||
await act(async () => {
|
||||
toolbarContainer.querySelector<HTMLButtonElement>('button[aria-label="Back to inbox"]')!.click();
|
||||
toolbarContainer
|
||||
.querySelector<HTMLButtonElement>('button[aria-label="Back to inbox"]')!
|
||||
.click();
|
||||
});
|
||||
expect(mockNavigate).toHaveBeenCalledWith(-1);
|
||||
historyLengthSpy.mockRestore();
|
||||
|
|
@ -2527,7 +2576,9 @@ describe("IssueDetail", () => {
|
|||
const directToolbarRoot = createRoot(directToolbarContainer);
|
||||
flushSync(() => directToolbarRoot.render(directToolbar));
|
||||
await act(async () => {
|
||||
directToolbarContainer.querySelector<HTMLButtonElement>('button[aria-label="Back to inbox"]')!.click();
|
||||
directToolbarContainer
|
||||
.querySelector<HTMLButtonElement>('button[aria-label="Back to inbox"]')!
|
||||
.click();
|
||||
});
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/inbox/mine");
|
||||
|
||||
|
|
@ -2770,6 +2821,213 @@ describe("IssueDetail", () => {
|
|||
expect(freshComment?.queueState).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recovers historical follow-up provenance from overlapping run chronology", async () => {
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue({ status: "done" }));
|
||||
mockIssuesApi.listComments.mockResolvedValue([
|
||||
createIssueComment({
|
||||
id: "comment-follow-up",
|
||||
body: "Use three seconds instead.",
|
||||
createdAt: new Date("2026-04-21T00:00:30.000Z"),
|
||||
updatedAt: new Date("2026-04-21T00:00:30.000Z"),
|
||||
}),
|
||||
]);
|
||||
mockActivityApi.runsForIssue.mockResolvedValue([
|
||||
{
|
||||
runId: "run-original",
|
||||
agentId: "agent-1",
|
||||
agentName: "Runner",
|
||||
adapterType: "paperclip_runner",
|
||||
status: "succeeded",
|
||||
createdAt: "2026-04-21T00:00:00.000Z",
|
||||
startedAt: "2026-04-21T00:00:00.000Z",
|
||||
finishedAt: "2026-04-21T00:01:00.000Z",
|
||||
contextIssueId: "issue-1",
|
||||
logBytes: 1,
|
||||
},
|
||||
{
|
||||
runId: "run-successor",
|
||||
agentId: "agent-1",
|
||||
agentName: "Runner",
|
||||
adapterType: "paperclip_runner",
|
||||
status: "succeeded",
|
||||
createdAt: "2026-04-21T00:01:01.000Z",
|
||||
startedAt: "2026-04-21T00:01:01.000Z",
|
||||
finishedAt: "2026-04-21T00:01:04.000Z",
|
||||
wakeCommentId: "comment-follow-up",
|
||||
wakeCommentIds: ["comment-follow-up"],
|
||||
contextCommentId: "comment-follow-up",
|
||||
contextIssueId: "issue-1",
|
||||
logBytes: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
await waitForAssertion(() => {
|
||||
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
|
||||
comments?: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(
|
||||
props.comments?.find((comment) => comment.id === "comment-follow-up"),
|
||||
).toMatchObject({
|
||||
followUpRequested: true,
|
||||
consumedByRunId: "run-successor",
|
||||
conversationAnchorAt: "2026-04-21T00:01:01.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("does not infer follow-up provenance from an unrelated overlapping linked run", async () => {
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue({ status: "done" }));
|
||||
mockIssuesApi.listComments.mockResolvedValue([
|
||||
createIssueComment({
|
||||
id: "comment-ordinary",
|
||||
body: "Start this ordinary run.",
|
||||
createdAt: new Date("2026-04-21T00:00:30.000Z"),
|
||||
updatedAt: new Date("2026-04-21T00:00:30.000Z"),
|
||||
}),
|
||||
]);
|
||||
mockActivityApi.runsForIssue.mockResolvedValue([
|
||||
{
|
||||
runId: "run-unrelated",
|
||||
agentId: "agent-1",
|
||||
agentName: "Runner",
|
||||
adapterType: "paperclip_runner",
|
||||
status: "succeeded",
|
||||
createdAt: "2026-04-21T00:00:00.000Z",
|
||||
startedAt: "2026-04-21T00:00:00.000Z",
|
||||
finishedAt: "2026-04-21T00:01:00.000Z",
|
||||
contextIssueId: "another-issue",
|
||||
logBytes: 1,
|
||||
},
|
||||
{
|
||||
runId: "run-ordinary",
|
||||
agentId: "agent-1",
|
||||
agentName: "Runner",
|
||||
adapterType: "paperclip_runner",
|
||||
status: "succeeded",
|
||||
createdAt: "2026-04-21T00:01:01.000Z",
|
||||
startedAt: "2026-04-21T00:01:01.000Z",
|
||||
finishedAt: "2026-04-21T00:01:04.000Z",
|
||||
wakeCommentId: "comment-ordinary",
|
||||
wakeCommentIds: ["comment-ordinary"],
|
||||
contextCommentId: "comment-ordinary",
|
||||
contextIssueId: "issue-1",
|
||||
logBytes: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
await waitForAssertion(() => {
|
||||
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
|
||||
comments?: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(
|
||||
props.comments?.find((comment) => comment.id === "comment-ordinary"),
|
||||
).toMatchObject({
|
||||
consumedByRunId: "run-ordinary",
|
||||
conversationAnchorAt: "2026-04-21T00:01:01.000Z",
|
||||
});
|
||||
expect(
|
||||
props.comments?.find((comment) => comment.id === "comment-ordinary")
|
||||
?.followUpRequested,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("recovers follow-up provenance across an intervening activity-linked run", async () => {
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue({ status: "done" }));
|
||||
mockIssuesApi.listComments.mockResolvedValue([
|
||||
createIssueComment({
|
||||
id: "comment-follow-up",
|
||||
body: "Deliver this after the active run.",
|
||||
createdAt: new Date("2026-04-21T00:00:30.000Z"),
|
||||
updatedAt: new Date("2026-04-21T00:00:30.000Z"),
|
||||
}),
|
||||
]);
|
||||
mockActivityApi.runsForIssue.mockResolvedValue([
|
||||
{
|
||||
runId: "run-source",
|
||||
agentId: "agent-1",
|
||||
agentName: "Runner",
|
||||
adapterType: "paperclip_runner",
|
||||
status: "succeeded",
|
||||
createdAt: "2026-04-21T00:00:00.000Z",
|
||||
startedAt: "2026-04-21T00:00:00.000Z",
|
||||
finishedAt: "2026-04-21T00:01:00.000Z",
|
||||
contextIssueId: "issue-1",
|
||||
logBytes: 1,
|
||||
},
|
||||
{
|
||||
runId: "run-intervening",
|
||||
agentId: "agent-1",
|
||||
agentName: "Runner",
|
||||
adapterType: "paperclip_runner",
|
||||
status: "succeeded",
|
||||
createdAt: "2026-04-21T00:00:40.000Z",
|
||||
startedAt: "2026-04-21T00:00:40.000Z",
|
||||
finishedAt: "2026-04-21T00:01:10.000Z",
|
||||
contextIssueId: "another-issue",
|
||||
logBytes: 1,
|
||||
},
|
||||
{
|
||||
runId: "run-successor",
|
||||
agentId: "agent-1",
|
||||
agentName: "Runner",
|
||||
adapterType: "paperclip_runner",
|
||||
status: "succeeded",
|
||||
createdAt: "2026-04-21T00:01:11.000Z",
|
||||
startedAt: "2026-04-21T00:01:11.000Z",
|
||||
finishedAt: "2026-04-21T00:01:14.000Z",
|
||||
wakeCommentId: "comment-follow-up",
|
||||
wakeCommentIds: ["comment-follow-up"],
|
||||
contextCommentId: "comment-follow-up",
|
||||
contextIssueId: "issue-1",
|
||||
logBytes: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
await waitForAssertion(() => {
|
||||
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
|
||||
comments?: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(
|
||||
props.comments?.find((comment) => comment.id === "comment-follow-up"),
|
||||
).toMatchObject({
|
||||
followUpRequested: true,
|
||||
consumedByRunId: "run-successor",
|
||||
conversationAnchorAt: "2026-04-21T00:01:11.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps acknowledged question answers at their submission time while recording successor delivery", async () => {
|
||||
mockIssuesApi.get.mockResolvedValue(
|
||||
createIssue({
|
||||
|
|
@ -3011,6 +3269,16 @@ describe("IssueDetail", () => {
|
|||
}),
|
||||
);
|
||||
mockIssuesApi.addComment.mockReturnValue(postedComment.promise);
|
||||
mockIssuesApi.getQueuedComments.mockResolvedValue(
|
||||
createQueuedCommentQueue({
|
||||
queueId: null,
|
||||
state: null,
|
||||
targetRunId: null,
|
||||
protocol: "legacy",
|
||||
steeringDisposition: "unsupported",
|
||||
entries: [],
|
||||
}),
|
||||
);
|
||||
mockHeartbeatsApi.cancel.mockResolvedValue({});
|
||||
mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([
|
||||
{
|
||||
|
|
@ -3056,6 +3324,7 @@ describe("IssueDetail", () => {
|
|||
queueState?: string;
|
||||
queueTargetRunId?: string | null;
|
||||
}>;
|
||||
queuedCommentQueue?: IssueQueuedCommentQueue | null;
|
||||
onInterruptQueued: (runId: string) => Promise<void>;
|
||||
};
|
||||
const optimisticComment = queuedProps.comments?.find(
|
||||
|
|
@ -3066,6 +3335,16 @@ describe("IssueDetail", () => {
|
|||
queueState: "queued",
|
||||
queueTargetRunId: "run-queued",
|
||||
});
|
||||
expect(queuedProps.queuedCommentQueue).toMatchObject({
|
||||
queueId: null,
|
||||
targetRunId: "run-queued",
|
||||
protocol: "legacy",
|
||||
entries: [
|
||||
expect.objectContaining({
|
||||
comment: expect.objectContaining({ body: "Queued run message" }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
postedComment.resolve(createIssueComment({ body: "Queued run message" }));
|
||||
|
|
@ -3079,6 +3358,7 @@ describe("IssueDetail", () => {
|
|||
queueState?: string;
|
||||
queueTargetRunId?: string | null;
|
||||
}>;
|
||||
queuedCommentQueue?: IssueQueuedCommentQueue | null;
|
||||
onInterruptQueued: (runId: string) => Promise<void>;
|
||||
};
|
||||
const persistedComment = persistedProps.comments?.find(
|
||||
|
|
@ -3088,6 +3368,12 @@ describe("IssueDetail", () => {
|
|||
queueState: "queued",
|
||||
queueTargetRunId: "run-queued",
|
||||
});
|
||||
expect(
|
||||
persistedProps.queuedCommentQueue?.entries[0]?.comment,
|
||||
).toMatchObject({
|
||||
id: "comment-1",
|
||||
body: "Queued run message",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await persistedProps.onInterruptQueued(
|
||||
|
|
@ -3099,6 +3385,106 @@ describe("IssueDetail", () => {
|
|||
mockHeartbeatsApi.cancel.mockClear();
|
||||
});
|
||||
|
||||
it("projects a native follow-up into the steering well before the post resolves", async () => {
|
||||
const postedComment = createDeferred<IssueComment>();
|
||||
const activeRun = {
|
||||
id: "run-native",
|
||||
runtimeMode: "native" as const,
|
||||
status: "running",
|
||||
invocationSource: "issue",
|
||||
triggerDetail: null,
|
||||
contextCommentId: null,
|
||||
contextWakeCommentId: null,
|
||||
startedAt: "2026-04-21T00:00:01.000Z",
|
||||
finishedAt: null,
|
||||
createdAt: "2026-04-21T00:00:01.000Z",
|
||||
agentId: "agent-1",
|
||||
agentName: "Runner",
|
||||
adapterType: "paperclip_runner",
|
||||
issueId: "issue-1",
|
||||
};
|
||||
mockIssuesApi.get.mockResolvedValue(
|
||||
createIssue({
|
||||
status: "in_progress",
|
||||
assigneeAgentId: "agent-1",
|
||||
executionRunId: activeRun.id,
|
||||
}),
|
||||
);
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
createAgent({ adapterType: "paperclip_runner" }),
|
||||
]);
|
||||
mockHeartbeatsApi.activeRunForIssue.mockResolvedValue(activeRun);
|
||||
mockHeartbeatsApi.liveRunsForIssue.mockResolvedValue([activeRun]);
|
||||
mockIssuesApi.getQueuedComments.mockResolvedValue(
|
||||
createQueuedCommentQueue({
|
||||
queueId: null,
|
||||
state: null,
|
||||
targetRunId: null,
|
||||
entries: [],
|
||||
steeringDisposition: "temporarily_unavailable",
|
||||
}),
|
||||
);
|
||||
mockIssuesApi.addComment.mockReturnValue(postedComment.promise);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const initialProps = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
|
||||
onAdd: (body: string) => Promise<void>;
|
||||
};
|
||||
await act(async () => {
|
||||
void initialProps.onAdd("Use the newer direction");
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const pendingProps = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
|
||||
queuedCommentQueue?: IssueQueuedCommentQueue | null;
|
||||
};
|
||||
expect(pendingProps.queuedCommentQueue).toMatchObject({
|
||||
queueId: null,
|
||||
targetRunId: "run-native",
|
||||
protocol: "paperclip_runner_v1",
|
||||
entries: [
|
||||
expect.objectContaining({
|
||||
comment: expect.objectContaining({
|
||||
id: expect.stringMatching(/^optimistic-/),
|
||||
body: "Use the newer direction",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
postedComment.resolve(
|
||||
createIssueComment({
|
||||
id: "native-follow-up",
|
||||
body: "Use the newer direction",
|
||||
}),
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const acknowledgedProps = mockIssueChatThreadRender.mock.calls.at(
|
||||
-1,
|
||||
)?.[0] as {
|
||||
queuedCommentQueue?: IssueQueuedCommentQueue | null;
|
||||
};
|
||||
expect(
|
||||
acknowledgedProps.queuedCommentQueue?.entries[0]?.comment,
|
||||
).toMatchObject({
|
||||
id: "native-follow-up",
|
||||
body: "Use the newer direction",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not rebind a queued message when another run becomes live before its request settles", async () => {
|
||||
const postedComment = createDeferred<IssueComment>();
|
||||
mockIssuesApi.get.mockResolvedValue(
|
||||
|
|
@ -3274,11 +3660,13 @@ describe("IssueDetail", () => {
|
|||
|
||||
const postedAt = new Date("2026-04-21T00:00:10.000Z");
|
||||
await act(async () => {
|
||||
postedComment.resolve(createIssueComment({
|
||||
body: "Fresh comment",
|
||||
createdAt: postedAt,
|
||||
updatedAt: postedAt,
|
||||
}));
|
||||
postedComment.resolve(
|
||||
createIssueComment({
|
||||
body: "Fresh comment",
|
||||
createdAt: postedAt,
|
||||
updatedAt: postedAt,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
|
|
@ -3390,10 +3778,12 @@ describe("IssueDetail", () => {
|
|||
enableExperimentalFileViewer: false,
|
||||
enableExternalObjects: false,
|
||||
});
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue({
|
||||
originKind: "manual",
|
||||
workMode: "planning",
|
||||
}));
|
||||
mockIssuesApi.get.mockResolvedValue(
|
||||
createIssue({
|
||||
originKind: "manual",
|
||||
workMode: "planning",
|
||||
}),
|
||||
);
|
||||
mockIssuesApi.getDocument.mockResolvedValue(null);
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -3430,10 +3820,12 @@ describe("IssueDetail", () => {
|
|||
enableExperimentalFileViewer: false,
|
||||
enableExternalObjects: false,
|
||||
});
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue({
|
||||
originKind: "manual",
|
||||
workMode: "planning",
|
||||
}));
|
||||
mockIssuesApi.get.mockResolvedValue(
|
||||
createIssue({
|
||||
originKind: "manual",
|
||||
workMode: "planning",
|
||||
}),
|
||||
);
|
||||
mockIssuesApi.getDocument.mockResolvedValue({ id: "doc-1", key: "plan" });
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -3485,7 +3877,9 @@ describe("IssueDetail", () => {
|
|||
onToggle: () => void;
|
||||
};
|
||||
expect(panelControl).toMatchObject({ open: false });
|
||||
expect(mockSetBreadcrumbToolbar.mock.calls.every(([node]) => node === null)).toBe(true);
|
||||
expect(
|
||||
mockSetBreadcrumbToolbar.mock.calls.every(([node]) => node === null),
|
||||
).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
panelControl.onToggle();
|
||||
|
|
@ -4217,8 +4611,12 @@ describe("IssueDetail", () => {
|
|||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.querySelector('[data-testid="issue-chat-thread"]')).toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-thread"]')).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="issue-chat-thread"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-thread"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("still honors Classic Task Interface when Streamlined UI is off", async () => {
|
||||
|
|
@ -4241,8 +4639,12 @@ describe("IssueDetail", () => {
|
|||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.querySelector('[data-testid="issue-chat-thread"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-thread"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="issue-chat-thread"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="task-chat-thread"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("passes @task mention options to the thread by default", async () => {
|
||||
|
|
@ -4667,7 +5069,7 @@ describe("IssueDetail", () => {
|
|||
});
|
||||
|
||||
it.each(DIRECT_ADAPTER_TYPES)(
|
||||
"does not load runner controls for an active %s run after reassignment",
|
||||
"loads only the shared queue projection for an active %s run after reassignment",
|
||||
async (adapterType) => {
|
||||
mockIssuesApi.get.mockResolvedValue(
|
||||
createIssue({
|
||||
|
|
@ -4709,7 +5111,7 @@ describe("IssueDetail", () => {
|
|||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockIssuesApi.getQueuedComments).not.toHaveBeenCalled();
|
||||
expect(mockIssuesApi.getQueuedComments).toHaveBeenCalledWith("issue-1");
|
||||
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
|
||||
queuedCommentQueue?: IssueQueuedCommentQueue | null;
|
||||
runFinalizationActions?: readonly { id: string; label: string }[];
|
||||
|
|
@ -4722,9 +5124,12 @@ describe("IssueDetail", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it("keeps a steered message queued until its durable timeline position refreshes", async () => {
|
||||
it("promotes a steered message immediately while its durable timeline position refreshes", async () => {
|
||||
const queue = createQueuedCommentQueue();
|
||||
const steeredQueue = createQueuedCommentQueue({
|
||||
queueId: null,
|
||||
state: null,
|
||||
targetRunId: null,
|
||||
entries: [],
|
||||
revision: "queue-revision-2",
|
||||
});
|
||||
|
|
@ -4773,6 +5178,7 @@ describe("IssueDetail", () => {
|
|||
queuedCommentQueue?: IssueQueuedCommentQueue | null;
|
||||
};
|
||||
expect(props.queuedCommentQueue?.entries).toHaveLength(1);
|
||||
expect(props.queuedCommentQueue?.queueId).toBe("wake-queue-1");
|
||||
expect(props.onSteerQueuedComment).toBeTypeOf("function");
|
||||
steer = props.onSteerQueuedComment!;
|
||||
});
|
||||
|
|
@ -4796,9 +5202,22 @@ describe("IssueDetail", () => {
|
|||
const whileRefreshing = mockIssueChatThreadRender.mock.calls.at(
|
||||
-1,
|
||||
)?.[0] as {
|
||||
comments?: Array<{
|
||||
id: string;
|
||||
steeredIntoRunId?: string | null;
|
||||
conversationAnchorAt?: Date | string | null;
|
||||
}>;
|
||||
queuedCommentQueue?: IssueQueuedCommentQueue | null;
|
||||
};
|
||||
expect(whileRefreshing.queuedCommentQueue?.entries).toHaveLength(1);
|
||||
expect(whileRefreshing.queuedCommentQueue).toBeNull();
|
||||
expect(
|
||||
whileRefreshing.comments?.find(
|
||||
(comment) => comment.id === "queued-comment-1",
|
||||
),
|
||||
).toMatchObject({
|
||||
steeredIntoRunId: "run-active-1",
|
||||
conversationAnchorAt: expect.any(String),
|
||||
});
|
||||
|
||||
releaseActivity([
|
||||
{
|
||||
|
|
@ -4827,17 +5246,20 @@ describe("IssueDetail", () => {
|
|||
}>;
|
||||
queuedCommentQueue?: IssueQueuedCommentQueue | null;
|
||||
};
|
||||
expect(props.queuedCommentQueue?.entries).toHaveLength(0);
|
||||
expect(props.queuedCommentQueue).toBeNull();
|
||||
expect(
|
||||
props.comments?.find((comment) => comment.id === "queued-comment-1"),
|
||||
).toMatchObject({
|
||||
steeredIntoRunId: "run-active-1",
|
||||
conversationAnchorAt: "2026-04-21T00:00:06.000Z",
|
||||
});
|
||||
expect(
|
||||
props.comments?.find((comment) => comment.id === "queued-comment-1"),
|
||||
).not.toMatchObject({ clientStatus: "queued", queueState: "queued" });
|
||||
});
|
||||
});
|
||||
|
||||
it("withholds destructive queue controls until the server supplies a queue id", async () => {
|
||||
it("keeps an unacknowledged queue visible while withholding server controls", async () => {
|
||||
mockIssuesApi.get.mockResolvedValue(
|
||||
createIssue({
|
||||
status: "in_progress",
|
||||
|
|
@ -4868,7 +5290,94 @@ describe("IssueDetail", () => {
|
|||
}
|
||||
| undefined;
|
||||
expect(mockIssuesApi.getQueuedComments).toHaveBeenCalled();
|
||||
expect(props?.queuedCommentQueue).toBeNull();
|
||||
expect(props?.queuedCommentQueue).toMatchObject({
|
||||
queueId: null,
|
||||
entries: [
|
||||
expect.objectContaining({
|
||||
comment: expect.objectContaining({ id: "queued-comment-1" }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a discarded queued comment out of the thread when the queue becomes empty", async () => {
|
||||
const queue = createQueuedCommentQueue();
|
||||
const emptyQueue = createQueuedCommentQueue({
|
||||
queueId: null,
|
||||
state: null,
|
||||
targetRunId: null,
|
||||
entries: [],
|
||||
revision: "queue-revision-2",
|
||||
});
|
||||
mockIssuesApi.get.mockResolvedValue(
|
||||
createIssue({
|
||||
status: "in_progress",
|
||||
assigneeAgentId: "agent-1",
|
||||
executionRunId: "run-active-1",
|
||||
}),
|
||||
);
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
createAgent({ adapterType: "paperclip_runner" }),
|
||||
]);
|
||||
// Keep returning the pre-discard page to exercise the local projection
|
||||
// across the queueId -> null transition, as can happen during refetch.
|
||||
mockIssuesApi.listComments.mockResolvedValue([queue.entries[0].comment]);
|
||||
mockIssuesApi.getQueuedComments.mockResolvedValue(queue);
|
||||
mockIssuesApi.discardQueuedComment.mockResolvedValue(emptyQueue);
|
||||
mockHeartbeatsApi.activeRunForIssue.mockResolvedValue({
|
||||
id: "run-active-1",
|
||||
runtimeMode: "native",
|
||||
status: "running",
|
||||
invocationSource: "issue",
|
||||
triggerDetail: null,
|
||||
contextCommentId: null,
|
||||
contextWakeCommentId: null,
|
||||
startedAt: "2026-04-21T00:00:00.000Z",
|
||||
finishedAt: null,
|
||||
createdAt: "2026-04-21T00:00:00.000Z",
|
||||
agentId: "agent-1",
|
||||
agentName: "Runner",
|
||||
adapterType: "paperclip_runner",
|
||||
issueId: "issue-1",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
let discard!: (commentId: string, revision: string) => Promise<void>;
|
||||
await waitForAssertion(() => {
|
||||
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
|
||||
comments?: Array<{ id: string }>;
|
||||
onDiscardQueuedComment?: typeof discard;
|
||||
queuedCommentQueue?: IssueQueuedCommentQueue | null;
|
||||
};
|
||||
expect(
|
||||
props.comments?.some((comment) => comment.id === "queued-comment-1"),
|
||||
).toBe(true);
|
||||
expect(props.queuedCommentQueue?.queueId).toBe("wake-queue-1");
|
||||
expect(props.queuedCommentQueue?.entries).toHaveLength(1);
|
||||
discard = props.onDiscardQueuedComment!;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await discard("queued-comment-1", queue.revision);
|
||||
});
|
||||
|
||||
await waitForAssertion(() => {
|
||||
const props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as {
|
||||
comments?: Array<{ id: string }>;
|
||||
queuedCommentQueue?: IssueQueuedCommentQueue | null;
|
||||
};
|
||||
expect(props.queuedCommentQueue).toBeNull();
|
||||
expect(
|
||||
props.comments?.some((comment) => comment.id === "queued-comment-1"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue