fix(ui): stabilize steered chat activity presentation (#13246)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The task chat shows live agent work and user steering. > - Steered messages showed a second timestamp that did not match normal chat messages. > - Folded live work also changed height as new commands arrived. > - Status carets and dots did not use the same vertical alignment. > - This pull request gives these states one stable presentation. > - The benefit is a task chat that is easier to read while an agent works. ## Linked Issues or Issue Description **What happened?** Steered chat messages showed a separate queued or steered timestamp. Folded live activity could grow and shrink. Some carets and status dots did not align vertically. **Expected behavior** Steered messages use the normal timestamp. Folded live activity shows one latest line until the user opens it. Carets and dots use the same vertical center. **Steps to reproduce** 1. Open a task chat with a running agent. 2. Steer the chat and inspect the message timestamp. 3. Keep live activity folded while new commands arrive. 4. Compare the caret with the status dot in the Reconnecting state. **Paperclip version or commit** Current `master` before this change. **Deployment mode** Local development mode. ## What Changed - Use the normal timestamp for steered chat messages. - Keep folded live activity to one line and replace it with the latest activity. - Keep the full activity timeline available after explicit expansion. - Vertically align carets and status dots, including Reconnecting. - Add focused component tests and Storybook review states. ## Verification - `pnpm --filter @paperclipai/ui exec vitest run src/components/task-chat/TaskChatRunnerTurn.test.tsx src/components/task-chat/TaskChatStatusPill.test.tsx src/components/task-chat/task-chat-adapter.test.ts` (63 tests passed) - `pnpm --filter @paperclipai/ui typecheck` - `pnpm check:token-gates` - `pnpm --filter @paperclipai/ui build-storybook` ## Risks - Low risk. The change is limited to task chat presentation and its tests. - A live activity item can replace the folded text. The expanded timeline keeps the full history. > This fix does not overlap with planned work in `ROADMAP.md`. ## Model Used - OpenAI Codex. The runtime did not expose the exact model ID or context window. The agent used reasoning, tool use, 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 - [ ] All Paperclip CI gates are green - [ ] 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
30aa3740ba
commit
eb9f954bae
|
|
@ -1552,7 +1552,7 @@ describe("TaskChatThread runtime transcript selection", () => {
|
|||
?.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" })}`,
|
||||
new Date("2026-08-25T17:59:32.000Z").toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }),
|
||||
);
|
||||
const turnHeaders = Array.from(
|
||||
container.querySelectorAll('[data-testid="task-chat-turn-summary"]'),
|
||||
|
|
@ -1630,7 +1630,7 @@ describe("TaskChatThread runtime transcript selection", () => {
|
|||
?.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" })}`,
|
||||
new Date("2026-08-25T17:59:32.000Z").toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -77,6 +77,30 @@ describe("TaskChatStatusPill whimsy", () => {
|
|||
expect(container.textContent).toContain("Terminal · ls -la");
|
||||
});
|
||||
|
||||
it("centers the reconnecting caret and status dot in equal lead slots", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<TaskChatStatusPill
|
||||
item={liveStatus({ label: "Reconnecting" })}
|
||||
chevronOpen={false}
|
||||
onToggle={() => undefined}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const caret = container.querySelector(
|
||||
'[data-testid="task-chat-status-caret-slot"]',
|
||||
);
|
||||
const dot = container.querySelector(
|
||||
'[data-testid="task-chat-status-dot-slot"]',
|
||||
);
|
||||
for (const slot of [caret, dot]) {
|
||||
expect(slot?.classList.contains("h-3.5")).toBe(true);
|
||||
expect(slot?.classList.contains("w-3.5")).toBe(true);
|
||||
expect(slot?.classList.contains("items-center")).toBe(true);
|
||||
expect(slot?.classList.contains("justify-center")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Queued copy untouched", () => {
|
||||
render(liveStatus({ label: "Queued", detail: "Waiting to start" }));
|
||||
expect(container.textContent).toContain("Queued…");
|
||||
|
|
|
|||
|
|
@ -318,14 +318,22 @@ export function TaskChatStatusPill({
|
|||
const statusLine = (
|
||||
<div className="tc-enter-status flex items-center gap-2 py-0.5 text-xs text-muted-foreground">
|
||||
{chevronOpen !== undefined ? (
|
||||
<ChevronRight
|
||||
className={cn("h-3 w-3 shrink-0 transition-transform", chevronOpen ? "rotate-90" : null)}
|
||||
aria-hidden
|
||||
/>
|
||||
<span
|
||||
className="flex h-3.5 w-3.5 shrink-0 items-center justify-center"
|
||||
data-testid="task-chat-status-caret-slot"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("h-3 w-3 transition-transform", chevronOpen ? "rotate-90" : null)}
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
{/* Fixed-size lead slot keeps the label from moving as tool icons
|
||||
come and go; the pulse dot renders unconditionally. */}
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<span
|
||||
className="flex h-3.5 w-3.5 shrink-0 items-center justify-center"
|
||||
data-testid="task-chat-status-dot-slot"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 animate-pulse rounded-full bg-(--status-agent-running)"
|
||||
|
|
|
|||
|
|
@ -385,7 +385,10 @@ describe("TaskChatTurn", () => {
|
|||
expect(header?.textContent).toContain("Editing files…");
|
||||
expect(header?.textContent).toContain("Edit · server/src/routes/auth.ts");
|
||||
expect(header?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(header?.firstElementChild?.firstElementChild?.tagName).toBe("svg");
|
||||
expect(
|
||||
header?.querySelector('[data-testid="task-chat-status-caret-slot"] svg')
|
||||
?.tagName,
|
||||
).toBe("svg");
|
||||
// All activity is folded behind it — no rows visible, no summary line.
|
||||
expect(fold()?.getAttribute("data-folded")).toBe("true");
|
||||
expect(summaryBtn()).toBeNull();
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ describe("commentsToTaskChatItems", () => {
|
|||
expect(agent.createdAtIso).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows both queue and steer times for a causally repositioned follow-up", () => {
|
||||
it("keeps the regular comment time for a causally repositioned steered follow-up", () => {
|
||||
const createdAt = "2026-09-04T14:09:33.000Z";
|
||||
const conversationAnchorAt = "2026-09-04T14:10:14.000Z";
|
||||
const [item] = commentsToTaskChatItems([
|
||||
|
|
@ -149,11 +149,11 @@ describe("commentsToTaskChatItems", () => {
|
|||
|
||||
expect(item).toMatchObject({
|
||||
kind: "message",
|
||||
timestamp: `Queued ${formatTaskChatTimestamp(createdAt)} · Steered ${formatTaskChatTimestamp(conversationAnchorAt)}`,
|
||||
timestamp: formatTaskChatTimestamp(createdAt),
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the successor-run delivery time for a queued follow-up", () => {
|
||||
it("keeps the regular comment time for a successor-run follow-up", () => {
|
||||
const createdAt = "2026-09-04T14:09:33.000Z";
|
||||
const conversationAnchorAt = "2026-09-04T14:10:35.000Z";
|
||||
const [item] = commentsToTaskChatItems([
|
||||
|
|
@ -172,7 +172,7 @@ describe("commentsToTaskChatItems", () => {
|
|||
|
||||
expect(item).toMatchObject({
|
||||
kind: "message",
|
||||
timestamp: `Queued ${formatTaskChatTimestamp(createdAt)} · Delivered ${formatTaskChatTimestamp(conversationAnchorAt)}`,
|
||||
timestamp: formatTaskChatTimestamp(createdAt),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -54,28 +54,12 @@ 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.
|
||||
*/
|
||||
/** Keep every comment footer on the same compact, user-visible timestamp. */
|
||||
export function formatTaskChatCommentTimestamp(
|
||||
comment: IssueChatComment,
|
||||
kind: TaskChatAuthorKind,
|
||||
_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}`;
|
||||
return formatTaskChatTimestamp(comment.createdAt);
|
||||
}
|
||||
|
||||
export function commentsToTaskChatItems(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,162 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { useState } from "react";
|
||||
import { TaskChatBubble } from "@/components/task-chat/TaskChatBubble";
|
||||
import { TaskChatRunnerTurn } from "@/components/task-chat/TaskChatRunnerTurn";
|
||||
import { TaskChatStatusPill } from "@/components/task-chat/TaskChatStatusPill";
|
||||
import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter";
|
||||
import type { TaskChatItem, TaskChatMessageItem } from "@/components/task-chat/task-chat-model";
|
||||
import type { IssueChatComment } from "@/lib/issue-chat-messages";
|
||||
|
||||
const runningItemSteps: TaskChatItem[][] = [
|
||||
[
|
||||
{
|
||||
id: "reasoning-current",
|
||||
kind: "thinking",
|
||||
lines: ["Inspecting the task chat layout."],
|
||||
streaming: true,
|
||||
channel: "summary",
|
||||
transcriptIndex: 1,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: "reasoning-current",
|
||||
kind: "thinking",
|
||||
lines: ["Inspecting the task chat layout."],
|
||||
streaming: false,
|
||||
channel: "summary",
|
||||
transcriptIndex: 1,
|
||||
},
|
||||
{
|
||||
id: "tool-read",
|
||||
kind: "tool",
|
||||
name: "Read",
|
||||
rawName: "read_file",
|
||||
target: "ui/src/components/task-chat/TaskChatRunnerTurn.tsx",
|
||||
status: "completed",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
id: "reasoning-current",
|
||||
kind: "thinking",
|
||||
lines: ["Inspecting the task chat layout."],
|
||||
streaming: false,
|
||||
channel: "summary",
|
||||
transcriptIndex: 1,
|
||||
},
|
||||
{
|
||||
id: "tool-read",
|
||||
kind: "tool",
|
||||
name: "Read",
|
||||
rawName: "read_file",
|
||||
target: "ui/src/components/task-chat/TaskChatRunnerTurn.tsx",
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
id: "tool-test",
|
||||
kind: "tool",
|
||||
name: "Bash",
|
||||
rawName: "bash",
|
||||
target: "pnpm exec vitest run ui/src/components/task-chat/TaskChatRunnerTurn.test.tsx --runInBand",
|
||||
status: "in_progress",
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
function ChainOfThoughtReview() {
|
||||
const [step, setStep] = useState(0);
|
||||
return (
|
||||
<div className="flex max-w-xl flex-col gap-3">
|
||||
<div className="rounded-lg border border-border bg-background p-4">
|
||||
<TaskChatRunnerTurn
|
||||
runId="storybook-live-run"
|
||||
agentName="CodexRunner"
|
||||
items={runningItemSteps[step] ?? runningItemSteps[0]}
|
||||
status="running"
|
||||
startedAtMs={Date.now() - 12_000}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-border bg-background px-3 py-1.5 text-foreground hover:bg-muted"
|
||||
onClick={() =>
|
||||
setStep((value) => (value + 1) % runningItemSteps.length)
|
||||
}
|
||||
data-testid="advance-running-activity"
|
||||
>
|
||||
Show next activity
|
||||
</button>
|
||||
<span aria-live="polite">
|
||||
Update {step + 1} of {runningItemSteps.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const steeredComment: IssueChatComment = {
|
||||
id: "steered-comment",
|
||||
companyId: "storybook-company",
|
||||
issueId: "storybook-issue",
|
||||
authorAgentId: null,
|
||||
authorUserId: "storybook-user",
|
||||
authorType: "user",
|
||||
body: "Keep the regular timestamp after steering this follow-up.",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-09-10T21:09:33.000Z"),
|
||||
updatedAt: new Date("2026-09-10T21:09:33.000Z"),
|
||||
conversationAnchorAt: "2026-09-10T21:10:14.000Z",
|
||||
consumedByRunId: "run-live",
|
||||
followUpRequested: true,
|
||||
steeredIntoRunId: "run-live",
|
||||
};
|
||||
|
||||
function TimestampReview() {
|
||||
const [item] = commentsToTaskChatItems([steeredComment]);
|
||||
return (
|
||||
<div className="max-w-xl rounded-lg border border-border bg-background p-4">
|
||||
<TaskChatBubble item={item as TaskChatMessageItem} animateEntry={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReconnectingAlignmentReview() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="max-w-xl rounded-lg border border-border bg-background p-4">
|
||||
<TaskChatStatusPill
|
||||
item={{
|
||||
id: "reconnecting-status",
|
||||
kind: "status",
|
||||
status: "running",
|
||||
label: "Reconnecting",
|
||||
startedAtMs: Date.now() - 12_000,
|
||||
}}
|
||||
chevronOpen={open}
|
||||
onToggle={() => setOpen((value) => !value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Tasks/Task chat review fixes",
|
||||
component: ChainOfThoughtReview,
|
||||
parameters: { layout: "padded" },
|
||||
} satisfies Meta<typeof ChainOfThoughtReview>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const CollapsedRunningChainOfThought: Story = {};
|
||||
|
||||
export const RegularTimestampAfterSteering: Story = {
|
||||
render: () => <TimestampReview />,
|
||||
};
|
||||
|
||||
export const ReconnectingCaretAndDotAlignment: Story = {
|
||||
render: () => <ReconnectingAlignmentReview />,
|
||||
};
|
||||
Loading…
Reference in New Issue