mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
## Thinking Path > - Paperclip helps people manage AI agents and their work. > - Agent chat shows run commentary and the agent's reply in one thread. > - The runner commentary text starts 4px left of the following reply. > - The runner activity group omits the gutter that the reply bubble uses. > - This pull request gives runner commentary the same token-based gutter. > - Both text blocks now share one left edge. ## Linked Issues or Issue Description **What happened?** In agent chat, the first commentary line of a completed run starts about 4px left of the following agent reply. This is visible in the Codie conversation. **Expected behavior** Runner commentary and ordinary agent reply text should have the same left edge. **Steps to reproduce** 1. Open an agent chat with a completed run that has commentary and an agent reply. 2. Compare the first commentary line with the following reply at the left edge. 3. Inspect the wrappers. Before this change, `task-chat-phase-interstitial` has zero left padding and `task-chat-agent-bubble` has 4px. ## What Changed - Add the existing `px-1` spacing token to runner commentary. - Add a regression test that compares the runner commentary gutter with the ordinary agent reply gutter. ## Verification - `pnpm check:token-gates` - `pnpm --filter @paperclipai/ui exec vitest run src/components/task-chat/TaskChatRunnerActivityGroup.test.tsx src/components/task-chat/TaskChatTurn.test.tsx src/components/task-chat/TaskChatBubble.test.tsx` - `pnpm --filter @paperclipai/ui typecheck` - `pnpm --filter @paperclipai/ui build` - A full repository typecheck and build passed earlier on this branch. All latest-commit CI gates passed, including the server shard after a transient preview readiness failure was rerun. - Browser render: runner commentary and reply paragraphs both start at x=32px after the change. ## Risks Low risk. This changes only the horizontal padding of runner commentary. Long commentary lines may wrap 8px sooner. The full local test suite was stopped after the code changed during its run; the focused UI tests and latest-commit CI suite passed. > I checked `ROADMAP.md`. This bug fix does not duplicate planned core work. ## Model Used OpenAI GPT-6 in Codex. The runtime did not expose a more specific model ID or context window size. The agent used reasoning, shell tools, and read-only browser inspection to diagnose the layout and verify the change. ## 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>
406 lines
12 KiB
TypeScript
406 lines
12 KiB
TypeScript
import { useId, useState } from "react";
|
||
import { completedActivitySummary } from "./completed-activity-summary";
|
||
import {
|
||
Brain,
|
||
ChevronDown,
|
||
ChevronRight,
|
||
CirclePause,
|
||
Gauge,
|
||
} from "lucide-react";
|
||
import { useReducedMotion } from "motion/react";
|
||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||
import { cn } from "@/lib/utils";
|
||
import { useTaskChatExpansion } from "./expansion-state";
|
||
import type { TaskChatActivityPhaseItem } from "./task-chat-model";
|
||
import {
|
||
protocolActivityIsRunning,
|
||
protocolActivityLabel,
|
||
protocolActivityPresentation,
|
||
} from "./task-chat-activity-presentation";
|
||
import {
|
||
TaskChatProtocolActivityDetails,
|
||
hasTaskChatProtocolActivityDetails,
|
||
} from "./TaskChatProtocolActivityRow";
|
||
import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
|
||
import { toolActivityPresentation } from "./tool-taxonomy";
|
||
|
||
type Activity = TaskChatActivityPhaseItem["items"][number];
|
||
|
||
function presentation(item: Activity, active: boolean) {
|
||
if (item.kind === "tool") {
|
||
const tool = toolActivityPresentation({
|
||
name: item.rawName ?? item.name,
|
||
target: item.target,
|
||
});
|
||
const running =
|
||
active && (item.status === "pending" || item.status === "in_progress");
|
||
return {
|
||
icon: tool.icon,
|
||
label:
|
||
item.status === "failed"
|
||
? tool.failedLabel
|
||
: item.status === "interrupted"
|
||
? tool.interruptedLabel
|
||
: running
|
||
? tool.runningLabel
|
||
: tool.completedLabel,
|
||
target: item.target,
|
||
mono: true,
|
||
running,
|
||
};
|
||
}
|
||
if (item.kind === "thinking") {
|
||
const running = active && Boolean(item.streaming);
|
||
return {
|
||
icon: Brain,
|
||
label: running ? "Thinking" : "Thought",
|
||
target: item.lines
|
||
.filter((line) => line.trim())
|
||
.at(-1)
|
||
?.trim(),
|
||
mono: false,
|
||
running,
|
||
};
|
||
}
|
||
if (item.kind === "protocol") {
|
||
const protocol = protocolActivityPresentation(item);
|
||
if (!protocol) return null;
|
||
return {
|
||
icon: protocol.icon,
|
||
label: protocolActivityLabel(item, protocol),
|
||
target: protocol.detail,
|
||
mono: true,
|
||
running: active && protocolActivityIsRunning(item),
|
||
};
|
||
}
|
||
if (item.kind === "marker")
|
||
return {
|
||
icon: CirclePause,
|
||
label: item.label,
|
||
target: item.detail,
|
||
mono: false,
|
||
running: false,
|
||
};
|
||
const { used, size, inputTokens, outputTokens, costUsd } = item.usage;
|
||
const usage = [
|
||
size > 0
|
||
? `${used.toLocaleString()}/${size.toLocaleString()} ctx`
|
||
: undefined,
|
||
inputTokens != null || outputTokens != null
|
||
? `↑${(inputTokens ?? 0).toLocaleString()} ↓${(outputTokens ?? 0).toLocaleString()}`
|
||
: undefined,
|
||
costUsd != null ? `$${costUsd.toFixed(4)}` : undefined,
|
||
]
|
||
.filter(Boolean)
|
||
.join(" · ");
|
||
return {
|
||
icon: Gauge,
|
||
label: item.label ?? "Token usage",
|
||
target: usage || item.detail,
|
||
mono: false,
|
||
running: false,
|
||
};
|
||
}
|
||
|
||
function ActivityContent({
|
||
item,
|
||
active,
|
||
}: {
|
||
item: Activity;
|
||
active: boolean;
|
||
}) {
|
||
const row = presentation(item, active);
|
||
if (!row) return null;
|
||
const Icon = row.icon;
|
||
return (
|
||
<span
|
||
className="flex min-w-0 flex-1 items-center gap-2"
|
||
data-activity-row={item.id}
|
||
data-activity-family={
|
||
item.kind === "protocol"
|
||
? item.surface === "provider_activity"
|
||
? item.family
|
||
: item.surface
|
||
: undefined
|
||
}
|
||
>
|
||
<span
|
||
className="flex size-5 shrink-0 items-center justify-center"
|
||
data-activity-icon
|
||
>
|
||
<Icon className="size-3.5" aria-hidden="true" />
|
||
</span>
|
||
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden whitespace-nowrap">
|
||
<span
|
||
className={cn(
|
||
"max-w-full shrink-0 truncate text-xs",
|
||
row.running && "text-foreground",
|
||
)}
|
||
>
|
||
{row.label}
|
||
</span>
|
||
{row.target ? (
|
||
<span
|
||
title={row.target}
|
||
className={cn(
|
||
"truncate text-xs text-muted-foreground",
|
||
row.mono && "font-mono",
|
||
)}
|
||
>
|
||
{row.target}
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** Only a new logical activity moves; token and status updates stay mounted. */
|
||
function RollingActivity({
|
||
item,
|
||
active,
|
||
}: {
|
||
item: Activity;
|
||
active: boolean;
|
||
}) {
|
||
const reducedMotion = useReducedMotion();
|
||
const [frame, setFrame] = useState({
|
||
current: item,
|
||
previous: null as Activity | null,
|
||
});
|
||
if (frame.current.id !== item.id)
|
||
setFrame({ current: item, previous: reducedMotion ? null : frame.current });
|
||
else if (frame.current !== item) setFrame({ ...frame, current: item });
|
||
return (
|
||
<span
|
||
className="relative flex h-8 min-w-0 flex-1 items-center overflow-hidden"
|
||
aria-live="polite"
|
||
aria-atomic="true"
|
||
data-testid="task-chat-activity-viewport"
|
||
>
|
||
{frame.previous && !reducedMotion ? (
|
||
<span
|
||
key={`exit-${item.id}`}
|
||
className="runner-activity-roll-out absolute inset-0 flex items-center"
|
||
aria-hidden="true"
|
||
onAnimationEnd={() =>
|
||
setFrame((current) =>
|
||
current.current.id === item.id
|
||
? { ...current, previous: null }
|
||
: current,
|
||
)
|
||
}
|
||
>
|
||
<ActivityContent item={frame.previous} active={false} />
|
||
</span>
|
||
) : null}
|
||
<span
|
||
key={item.id}
|
||
className={cn(
|
||
"relative flex w-full min-w-0 items-center",
|
||
frame.previous && !reducedMotion && "runner-activity-roll-in",
|
||
)}
|
||
>
|
||
<ActivityContent item={item} active={active} />
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function ActivityDetails({ item }: { item: Activity }) {
|
||
if (item.kind === "thinking")
|
||
return <MarkdownBody softBreaks>{item.lines.join("\n")}</MarkdownBody>;
|
||
if (item.kind === "usage") return <TaskChatUsageReadout item={item} />;
|
||
if (item.kind === "protocol")
|
||
return <TaskChatProtocolActivityDetails item={item} neutral />;
|
||
if (item.kind === "marker")
|
||
return (
|
||
<p className="whitespace-pre-wrap break-words">
|
||
{item.detail ?? item.label}
|
||
</p>
|
||
);
|
||
return (
|
||
<>
|
||
{item.target ? (
|
||
<p className="break-all font-mono">{item.target}</p>
|
||
) : null}
|
||
{item.detail ? (
|
||
<pre className="whitespace-pre-wrap break-words font-mono">
|
||
{item.detail}
|
||
</pre>
|
||
) : null}
|
||
{item.decision ? <p>Permission {item.decision}</p> : null}
|
||
{item.diff ? (
|
||
<p className="break-all font-mono">
|
||
{item.diff.path} · +{item.diff.added} −{item.diff.removed}
|
||
</p>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function hasActivityDetails(item: Activity): boolean {
|
||
if (item.kind === "protocol") return hasTaskChatProtocolActivityDetails(item);
|
||
if (item.kind === "thinking") return item.lines.some((line) => line.trim());
|
||
if (item.kind === "tool")
|
||
return Boolean(
|
||
item.target?.trim() || item.detail?.trim() || item.diff || item.decision,
|
||
);
|
||
if (item.kind === "marker") return Boolean(item.detail?.trim());
|
||
return Boolean(
|
||
item.detail ||
|
||
item.usage.size > 0 ||
|
||
item.usage.inputTokens != null ||
|
||
item.usage.outputTokens != null ||
|
||
item.usage.costUsd != null,
|
||
);
|
||
}
|
||
|
||
function ExpandedActivity({
|
||
item,
|
||
active,
|
||
}: {
|
||
item: Activity;
|
||
active: boolean;
|
||
}) {
|
||
const [open, setOpen] = useTaskChatExpansion(
|
||
`runner-detail:${item.id}`,
|
||
false,
|
||
);
|
||
const detailId = useId();
|
||
const expandable = hasActivityDetails(item);
|
||
const content = <ActivityContent item={item} active={active} />;
|
||
return (
|
||
<li className="min-w-0" data-activity-item-id={item.id}>
|
||
{expandable ? (
|
||
<button
|
||
type="button"
|
||
className="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm text-left text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
onClick={() => setOpen(!open)}
|
||
aria-expanded={open}
|
||
aria-controls={open ? detailId : undefined}
|
||
>
|
||
{content}
|
||
<ChevronRight
|
||
className={cn("size-3.5 shrink-0", open && "rotate-90")}
|
||
aria-hidden="true"
|
||
/>
|
||
</button>
|
||
) : (
|
||
<div className="flex h-8 w-full min-w-0 items-center gap-2 text-muted-foreground">
|
||
{content}
|
||
</div>
|
||
)}
|
||
{expandable && open ? (
|
||
<div
|
||
id={detailId}
|
||
className="flex min-w-0 flex-col gap-2 overflow-hidden rounded-md bg-muted/40 p-3 text-xs leading-relaxed text-muted-foreground"
|
||
data-testid="task-chat-runner-activity-detail"
|
||
>
|
||
<ActivityDetails item={item} />
|
||
</div>
|
||
) : null}
|
||
</li>
|
||
);
|
||
}
|
||
|
||
/** One rolling activity between commentary messages, with persistent optional history. */
|
||
export function TaskChatRunnerActivityGroup({
|
||
item,
|
||
defaultExpanded = false,
|
||
}: {
|
||
item: TaskChatActivityPhaseItem;
|
||
defaultExpanded?: boolean;
|
||
}) {
|
||
const [expanded, setExpanded] = useTaskChatExpansion(
|
||
item.id,
|
||
defaultExpanded,
|
||
);
|
||
const historyId = useId();
|
||
const activities = item.items.filter(
|
||
(activity) => presentation(activity, false) !== null,
|
||
);
|
||
const latest = activities.at(-1);
|
||
const summary = completedActivitySummary(activities);
|
||
const SummaryIcon = summary.icon;
|
||
const countLabel = `${activities.length} ${activities.length === 1 ? "activity" : "activities"}`;
|
||
return (
|
||
<section
|
||
className="flex min-w-0 flex-col gap-2"
|
||
data-testid="task-chat-activity-phase"
|
||
data-activity-group={item.id}
|
||
data-expanded={expanded}
|
||
>
|
||
{item.interstitial ? (
|
||
<div
|
||
className="min-w-0 px-1 text-sm text-foreground/90"
|
||
data-testid="task-chat-phase-interstitial"
|
||
>
|
||
<MarkdownBody softBreaks linkIssueReferences>
|
||
{item.interstitial.text}
|
||
</MarkdownBody>
|
||
</div>
|
||
) : null}
|
||
{latest ? (
|
||
<div className="min-w-0">
|
||
<button
|
||
type="button"
|
||
className="flex min-h-8 w-full min-w-0 items-center gap-2 rounded-sm text-left text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||
data-testid="task-chat-activity-phase-toggle"
|
||
onClick={() => setExpanded(!expanded)}
|
||
aria-expanded={expanded}
|
||
aria-controls={expanded ? historyId : undefined}
|
||
aria-label={`${expanded ? "Collapse" : "Expand"} ${item.active ? countLabel : `${summary.fullLabel.toLowerCase()} (${countLabel})`}`}
|
||
>
|
||
{!item.active ? (
|
||
<span
|
||
className="flex h-8 min-w-0 flex-1 items-center gap-2 text-xs"
|
||
data-completed-summary
|
||
>
|
||
<span className="flex size-5 shrink-0 items-center justify-center">
|
||
<SummaryIcon className="size-3.5" aria-hidden="true" />
|
||
</span>
|
||
<span className="truncate" title={summary.fullLabel}>
|
||
{summary.label}
|
||
</span>
|
||
</span>
|
||
) : expanded ? (
|
||
<span className="flex min-h-8 flex-1 items-center gap-2 text-xs">
|
||
<span className="flex size-5 shrink-0 items-center justify-center">
|
||
<ChevronDown className="size-3.5" aria-hidden="true" />
|
||
</span>
|
||
<span>{countLabel}</span>
|
||
</span>
|
||
) : (
|
||
<RollingActivity item={latest} active={item.active} />
|
||
)}
|
||
<span className="flex shrink-0 items-center gap-1 text-xs">
|
||
{expanded ? countLabel : item.active ? activities.length : null}
|
||
<ChevronRight
|
||
className={cn("size-3.5", expanded && "rotate-90")}
|
||
aria-hidden="true"
|
||
/>
|
||
</span>
|
||
</button>
|
||
{expanded ? (
|
||
<ol
|
||
id={historyId}
|
||
className="flex min-w-0 flex-col gap-1"
|
||
aria-label="Activity history"
|
||
data-testid="task-chat-runner-activity-list"
|
||
>
|
||
{activities.map((activity, index) => (
|
||
<ExpandedActivity
|
||
key={activity.id}
|
||
item={activity}
|
||
active={item.active && index === activities.length - 1}
|
||
/>
|
||
))}
|
||
</ol>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|