mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
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:
1 parent
b84964e5a2
commit
bf95a7eae2
20 files changed
+3202
-1187
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user