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:
Dotta 2026-09-11 21:26:44 -05:00 committed by GitHub
parent 30aa3740ba
commit eb9f954bae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 212 additions and 31 deletions

View File

@ -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" }),
);
});

View File

@ -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…");

View File

@ -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)"

View File

@ -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();

View File

@ -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),
});
});

View File

@ -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(

View File

@ -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 />,
};