feat(ui): chat-style task view behind an experimental flag (#10606)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Operators spend most of their time on the issue detail page. They
talk to the assigned agent there through comments.
> - The current page reads as a ticket form. The thread sits below
properties, the composer sits mid-page, and live agent activity renders
as dense transcript logs.
> - Talking to an agent is a conversation. A chat-first layout matches
that mental model better than a ticket form.
> - A layout change this large must not disrupt current users. It needs
a safe opt-in path and full parity with the existing thread features.
> - This pull request adds a chat-style task view behind a new
"Chat-Style Tasks" experiment toggle. The flag is off by default and the
existing page is unchanged when it is off.
> - The benefit is a focused, readable conversation with the agent: live
tool activity folds into compact summaries, the composer stays at the
bottom, and properties, plan, and artifacts move into header tabs.

## Linked Issues or Issue Description

Refs #49 (chat with agents is a much-wanted feature).

Related PRs found in the dedup search:
- #4489 — an earlier, closed attempt to promote the conversation to the
primary surface on issue detail. This PR is a fresh, flag-gated take on
the same goal.
- #8837 — an open PR that proposes a two-column task layout. It
restructures the same page but keeps the ticket paradigm; this PR is
orthogonal because it is opt-in and chat-first.

**Subsystem affected**

UI (issue detail page).

**Problem or motivation**

The issue detail page presents agent conversations as a ticket:
properties first, thread below, composer in the middle of the page, and
raw transcript noise during live runs. Users who mainly converse with
their agents must scroll past chrome to follow the conversation, and
live activity is hard to read.

**Proposed solution**

An opt-in chat-style view of the issue detail page, gated by a new
"Chat-Style Tasks" experiment toggle in Settings → Experimental. With
the flag on, the thread fills the center pane, the composer docks to the
bottom of the viewport, Properties / Plan / Artifacts become header
tabs, live turns show a status pill with the current tool action and
elapsed time, and settled turns collapse to a "Worked · N tools" summary
that expands into per-tool rows. With the flag off, nothing changes.

**Alternatives considered**

Restyling the existing layout in place (rejected: too disruptive without
an opt-out), and a separate chat page beside the issue page (rejected:
splits the task's single source of truth). A per-request lab page
(`/task-chat-lab`, dev-only) was kept for design iteration instead.

**Roadmap alignment**

ROADMAP.md "CEO Chat" wants lighter conversations that still resolve to
real work objects. This PR keeps the core task-and-comments model — it
only changes presentation, opt-in — so it does not duplicate that
planned work.

## What Changed

- New `enableTaskChatRedesign` instance setting, exposed as a
"Chat-Style Tasks" experiment card in Settings → Experimental (shared
feature catalog, validators, server instance-settings service, and UI
settings page).
- New `ui/src/components/task-chat/` component family: chat thread with
turn grouping, agent reply bubbles, live status pill, collapsible turn
summaries with per-tool rows, plan tab with a sticky CTA action bar,
inline interaction cards, per-request mode chips, and a bottom-docked
composer.
- A shared tool taxonomy (`tool-taxonomy.ts`) maps tool names to verbs
and icons; the status pill, tool rows, and the classic transcript view
all use it.
- A transcript adapter converts stored run logs into chat turns; it
dedupes tool-call updates by `toolUseId` so tool counts match the
expanded rows, and it keeps a tool row's first real name when later
generic updates arrive.
- Composer: posts on Cmd/Ctrl+Enter, supports image paste with
object-URL thumbnail previews (revoked on clear/unmount), and uploads
through the issue attachments route.
- `IssueDetail.tsx`: with the flag on, pane tabs move to the header bar,
the header is not sticky, and the chat fills the center; with the flag
off, the previous layout renders unchanged.
- Motion tokens for the new animations live in `ui/src/index.css` with a
`motion-tokens.ts` catalog and a test that keeps the two in sync (the
catalog now also covers the shared enter/exit/swap tokens that the
decision/quicklook block declares).
- A dev-only `/task-chat-lab` page with fixtures and a tweak panel for
motion tuning.

## Verification

- `pnpm typecheck` — clean across the workspace.
- `pnpm check:token-gates` — 3/3 CLEAN.
- `cd ui && pnpm vitest run` — 3,344 of 3,345 tests pass locally. The
one failure is `IssueProperties.test.tsx` monitor-row time formatting,
which is timezone-sensitive: it also fails on unmodified `origin/master`
in a non-UTC timezone and passes with `TZ=UTC`. It is not related to
this change.
- `cd server && pnpm vitest run
src/__tests__/instance-settings-service.test.ts` — 21/21 pass (covers
the new setting).
- Manual: start the dev server, open Settings → Experimental, enable
"Chat-Style Tasks", and open any issue. The thread fills the page, the
composer docks to the bottom, and Properties / Plan / Artifacts appear
as header tabs. Assign an agent and comment to watch a live run: the
status pill shows the current tool action with elapsed time, and the
finished turn folds into a "Worked · N tools" summary. Disable the
toggle and confirm the classic page is unchanged.
- Visual snapshot baselines are intentionally not updated: per
`doc/design/DECISION-SHEET.md`, "Per-change snapshot verification
demoted to dormant (Jul 13 2026)".

## Risks

- The flag-off path goes through the same `IssueDetail.tsx` file, so a
regression there would affect current users. Mitigation: the classic
markup renders through the same components as before behind explicit
flag conditionals, and the full UI suite passes.
- The transcript adapter interprets stored run-log formats, including
legacy entries without `toolUseId`. Malformed logs degrade to generic
tool rows rather than crashing.
- The new view changes no server behavior other than one additive
instance setting; it is additive and default-off. Overall risk with the
flag off is low.

## Model Used

- Claude (Anthropic), model id `claude-fable-5` (Claude Fable 5),
extended thinking enabled, agentic tool use (file editing, shell, test
execution) via Claude Code / Claude Agent SDK.

## 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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Fable 5 authored and GitHub committed 2026-08-01 02:26:47 -07:00
1 parent 6401f4f78c
commit c185e64b77
60 files changed
+6744 -172

No files matched your search

@@ -0,0 +1,80 @@
import { cn } from "@/lib/utils";
import { MarkdownBody } from "@/components/MarkdownBody";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { AgentIcon } from "@/components/AgentIconPicker";
import type { TaskChatMessageItem } from "./task-chat-model";
interface TaskChatBubbleProps {
item: TaskChatMessageItem;
}
function initialsForName(name: string) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
/**
* Author-typed message row — the primary legibility signal. Human messages sit
* right in a solid accent bubble; agent messages sit left in a neutral card
* bubble with an avatar author header (the agent's assigned icon + name · mode
* chip); system notices are centered and recede.
*/
export function TaskChatBubble({ item }: TaskChatBubbleProps) {
if (item.author === "system") {
return (
<div className="tc-enter-bubble flex justify-center py-1">
<p className="max-w-(--pct-85) text-center text-xs text-muted-foreground">{item.text}</p>
</div>
);
}
const isHuman = item.author === "human";
return (
<div className={cn("tc-enter-bubble group flex w-full flex-col gap-1", isHuman ? "items-end" : "items-start")}>
{!isHuman && item.authorName ? (
<span className="flex items-center gap-2 px-1">
<Avatar size="sm" className="shrink-0" data-testid="task-chat-agent-avatar">
{item.agentIcon ? (
<AvatarFallback>
<AgentIcon icon={item.agentIcon} className="h-3.5 w-3.5" />
</AvatarFallback>
) : (
<AvatarFallback>{initialsForName(item.authorName)}</AvatarFallback>
)}
</Avatar>
<span className="text-sm font-semibold text-foreground">{item.authorName}</span>
{item.modeLabel ? (
<span className="rounded-full border border-border px-2 py-px text-(length:--text-micro) font-medium text-muted-foreground">
{item.modeLabel}
</span>
) : null}
</span>
) : null}
<div
className={cn(
"max-w-(--pct-85) break-words px-3.5 py-2 text-sm",
isHuman
? "rounded-2xl rounded-br-sm bg-(--liveness-blue) text-white"
: "rounded-2xl rounded-bl-sm bg-(--bubble-agent) text-foreground",
item.optimistic ? "opacity-80" : null,
)}
>
<MarkdownBody softBreaks linkIssueReferences>
{item.text}
</MarkdownBody>
</div>
{item.optimistic ? (
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
{item.optimistic === "queued" ? "Queued" : "Sending…"}
</span>
) : item.timestamp ? (
<span className="px-1 text-(length:--text-micro) text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100">
{item.timestamp}
</span>
) : null}
</div>
);
}
@@ -0,0 +1,258 @@
// @vitest-environment jsdom
import type { ReactElement } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TaskChatComposer } from "./TaskChatComposer";
let container: HTMLDivElement;
let root: Root | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
// jsdom has no object-URL support; the composer uses it for image previews.
URL.createObjectURL = vi.fn(() => "blob:mock-preview");
URL.revokeObjectURL = vi.fn();
});
afterEach(() => {
flushSync(() => root?.unmount());
root = null;
container.remove();
});
function render(ui: ReactElement) {
flushSync(() => root!.render(ui));
}
function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
)!.set!;
flushSync(() => {
nativeSetter.call(textarea, value);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
}
function pressEnter(
textarea: HTMLTextAreaElement,
modifiers: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean } = {},
) {
flushSync(() => {
textarea.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", ...modifiers, bubbles: true, cancelable: true }),
);
});
}
function pressCmdEnter(textarea: HTMLTextAreaElement) {
pressEnter(textarea, { metaKey: true });
}
async function flushAsync() {
await new Promise((resolve) => setTimeout(resolve, 0));
}
function input() {
return container.querySelector<HTMLTextAreaElement>('[data-testid="task-chat-composer-input"]')!;
}
function sendButton() {
return container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-send"]')!;
}
describe("TaskChatComposer", () => {
it("submits the trimmed body on Cmd+Enter and clears the draft", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
render(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
expect(sendButton().disabled).toBe(true);
setTextareaValue(input(), " hello there ");
expect(sendButton().disabled).toBe(false);
pressCmdEnter(input());
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("hello there", undefined, undefined);
expect(input().value).toBe("");
});
it("submits on Ctrl+Enter", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
render(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
setTextareaValue(input(), "hello");
pressEnter(input(), { ctrlKey: true });
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("hello", undefined, undefined);
});
it("does not submit on plain Enter or Shift+Enter (newline stays in the draft)", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
render(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
setTextareaValue(input(), "line one");
pressEnter(input());
pressEnter(input(), { shiftKey: true });
await flushAsync();
expect(onAdd).not.toHaveBeenCalled();
expect(input().value).toBe("line one");
});
it("cycles the pending mode with Shift+Tab and applies it on submit", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
const onWorkModeChange = vi.fn().mockResolvedValue(undefined);
render(
<TaskChatComposer onAdd={onAdd} workMode="standard" onWorkModeChange={onWorkModeChange} />,
);
const chip = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-mode"]')!;
expect(chip.getAttribute("data-pending-work-mode")).toBe("standard");
expect(chip.textContent).toContain("Agent");
flushSync(() => {
input().dispatchEvent(
new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true }),
);
});
expect(chip.getAttribute("data-pending-work-mode")).toBe("planning");
expect(chip.textContent).toContain("Plan");
setTextareaValue(input(), "do the plan");
pressCmdEnter(input());
await flushAsync();
expect(onWorkModeChange).toHaveBeenCalledWith("planning");
expect(onAdd).toHaveBeenCalledWith("do the plan", undefined, undefined);
});
it("passes reopen=true when the issue resumes-to-todo and the assignee is an agent", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
render(
<TaskChatComposer
onAdd={onAdd}
workMode="standard"
issueStatus="done"
currentAssigneeValue="agent:a1"
/>,
);
setTextareaValue(input(), "wake up");
pressCmdEnter(input());
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("wake up", true, undefined);
});
it("hides the attach button without an upload handler and shows it with one", () => {
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" />);
expect(container.querySelector('[data-testid="task-chat-composer-attach"]')).toBeNull();
render(
<TaskChatComposer
onAdd={vi.fn()}
workMode="standard"
onAttachImage={vi.fn().mockResolvedValue(undefined)}
/>,
);
expect(container.querySelector('[data-testid="task-chat-composer-attach"]')).not.toBeNull();
});
it("uploads pasted files via onAttachImage and inserts the reference into the draft", async () => {
const onAttachImage = vi.fn().mockResolvedValue({
contentPath: "/attachments/shot.png",
originalFilename: "shot.png",
});
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" onAttachImage={onAttachImage} />);
const file = new File(["png-bytes"], "shot.png", { type: "image/png" });
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: { files: [file] } });
flushSync(() => {
input().dispatchEvent(paste);
});
await flushAsync();
expect(onAttachImage).toHaveBeenCalledWith(file);
expect(input().value).toContain("![shot.png](/attachments/shot.png)");
const chips = container.querySelector('[data-testid="task-chat-composer-attachments"]');
expect(chips?.textContent).toContain("shot.png");
});
it("shows an object-URL thumbnail for a pasted image attachment", async () => {
const onAttachImage = vi.fn().mockResolvedValue({
contentPath: "/attachments/shot.png",
originalFilename: "shot.png",
});
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" onAttachImage={onAttachImage} />);
const file = new File(["png-bytes"], "shot.png", { type: "image/png" });
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: { files: [file] } });
flushSync(() => {
input().dispatchEvent(paste);
});
await flushAsync();
expect(URL.createObjectURL).toHaveBeenCalledWith(file);
const thumb = container.querySelector<HTMLImageElement>(
'[data-testid="task-chat-composer-attachments"] img',
);
expect(thumb?.src).toBe("blob:mock-preview");
expect(thumb?.alt).toBe("");
});
it("renders no thumbnail for non-image attachments and revokes previews on unmount", async () => {
const onAttachImage = vi.fn().mockResolvedValue({
contentPath: "/attachments/notes.txt",
originalFilename: "notes.txt",
});
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" onAttachImage={onAttachImage} />);
const image = new File(["png-bytes"], "shot.png", { type: "image/png" });
const text = new File(["plain"], "notes.txt", { type: "text/plain" });
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: { files: [image, text] } });
flushSync(() => {
input().dispatchEvent(paste);
});
await flushAsync();
// Only the image chip carries a thumbnail; the text chip stays text-only.
const chips = container.querySelector('[data-testid="task-chat-composer-attachments"]')!;
expect(chips.textContent).toContain("notes.txt");
expect(chips.querySelectorAll("img")).toHaveLength(1);
expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
flushSync(() => root!.unmount());
root = null;
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-preview");
});
it("shows the assignee combobox only when reassign is enabled, with the current label", () => {
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" />);
expect(container.querySelector('[data-testid="task-chat-composer-assignee"]')).toBeNull();
render(
<TaskChatComposer
onAdd={vi.fn()}
workMode="standard"
enableReassign
reassignOptions={[
{ id: "agent:a1", label: "Clippy" },
{ id: "user:u1", label: "Sam" },
]}
currentAssigneeValue="user:u1"
/>,
);
const trigger = container.querySelector('[data-testid="task-chat-composer-assignee"]');
expect(trigger?.textContent).toContain("Sam");
});
});
@@ -0,0 +1,475 @@
import {
useEffect,
useRef,
useState,
type ChangeEvent,
type ClipboardEvent as ReactClipboardEvent,
type CSSProperties,
type DragEvent as ReactDragEvent,
} from "react";
import { cn } from "@/lib/utils";
import { AlertTriangle, ArrowUp, Check, ChevronDown, Loader2, Plus } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { nextWorkMode, workModeMetaFor, workModeMetaList } from "@/lib/work-mode-meta";
import type { InlineEntityOption } from "@/components/InlineEntitySelector";
import type { IssueAttachment, IssueWorkMode } from "@paperclipai/shared";
/** Structurally identical to IssueChatThread's module-private CommentReassignment. */
interface CommentReassignment {
assigneeAgentId: string | null;
assigneeUserId: string | null;
}
interface TaskChatComposerProps {
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void> | void;
workMode: IssueWorkMode;
onWorkModeChange?: (mode: IssueWorkMode) => Promise<void> | void;
disabled?: boolean;
disabledReason?: string | null;
placeholder?: string;
/** Preferred upload path: attaches the file to the task (mirrors legacy). */
onAttachImage?: (file: File) => Promise<IssueAttachment | void>;
/** Fallback upload path: returns a URL for inline image markdown. */
onImageUpload?: (file: File) => Promise<string>;
enableReassign?: boolean;
reassignOptions?: InlineEntityOption[];
currentAssigneeValue?: string;
issueStatus?: string;
}
/** Per-mode hue token (see ui/src/index.css `--tc-mode-*`). */
const MODE_HUE: Partial<Record<IssueWorkMode, string>> = {
standard: "var(--tc-mode-agent)",
planning: "var(--tc-mode-plan)",
ask: "var(--tc-mode-ask)",
};
function modeHue(mode: IssueWorkMode): string {
return MODE_HUE[mode] ?? "var(--tc-mode-agent)";
}
const MODE_DESCRIPTION: Partial<Record<IssueWorkMode, string>> = {
standard: "Make changes and run work",
planning: "Draft a plan before acting",
ask: "Answer questions only, no changes",
};
/** v7 per-mode placeholder copy; `{agent}` is the pending assignee's name. */
function modePlaceholder(mode: IssueWorkMode, agentName: string): string {
switch (mode) {
case "planning":
return `Plan with ${agentName} — shapes the plan doc, no code changes…`;
case "ask":
return `Ask ${agentName} a question — read-only, nothing runs…`;
default:
return `Message ${agentName} — describe what you want done…`;
}
}
type ComposerAttachment = {
id: string;
name: string;
status: "uploading" | "attached" | "error";
error?: string;
/** Object URL for an immediate thumbnail while (and after) uploading. */
previewUrl?: string;
isImage?: boolean;
};
/** Local duplicate of IssueChatThread's module-private helper (same rule). */
function shouldImplicitlyReopenComment(issueStatus: string | undefined, assigneeValue: string) {
const resumesToTodo = issueStatus === "done" || issueStatus === "cancelled" || issueStatus === "blocked";
return resumesToTodo && assigneeValue.startsWith("agent:");
}
function parseAssigneeValue(value: string): CommentReassignment | undefined {
if (value.startsWith("agent:")) {
const id = value.slice("agent:".length);
return id ? { assigneeAgentId: id, assigneeUserId: null } : undefined;
}
if (value.startsWith("user:")) {
const id = value.slice("user:".length);
return id ? { assigneeAgentId: null, assigneeUserId: id } : undefined;
}
return undefined;
}
function hasFilePayload(evt: ReactDragEvent<HTMLDivElement>) {
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
}
/**
* Composer for the redesigned thread (v7 spec): textarea over a 32px comp-bar
* of [attach] [mode chip] … [assignee] [send]. The mode chip is a status-chip
* rectangle carrying the pending mode's hue; the composer chrome itself stays
* neutral. Shift+Tab cycles modes; the picked mode is applied on submit.
* Attachments upload via `onAttachImage` (or the inline `onImageUpload`
* fallback) from the + button, paste, or drag-drop.
*/
export function TaskChatComposer({
onAdd,
workMode,
onWorkModeChange,
disabled = false,
disabledReason,
placeholder,
onAttachImage,
onImageUpload,
enableReassign = false,
reassignOptions,
currentAssigneeValue = "",
issueStatus,
}: TaskChatComposerProps) {
const [body, setBody] = useState("");
const [submitting, setSubmitting] = useState(false);
const [pendingMode, setPendingMode] = useState<IssueWorkMode>(workMode);
const [pendingAssignee, setPendingAssignee] = useState<string | null>(null);
const [attachments, setAttachments] = useState<ComposerAttachment[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const dragDepthRef = useRef(0);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const previewUrlsRef = useRef<Set<string>>(new Set());
// Object URLs created for image previews must be revoked or they leak the
// underlying blobs for the page's lifetime.
useEffect(() => {
const urls = previewUrlsRef.current;
return () => {
for (const url of urls) URL.revokeObjectURL(url);
urls.clear();
};
}, []);
function releasePreview(url: string | undefined) {
if (!url) return;
URL.revokeObjectURL(url);
previewUrlsRef.current.delete(url);
}
const modeMeta = workModeMetaFor(pendingMode);
const canAcceptFiles = Boolean(onAttachImage || onImageUpload);
const showAssignee = Boolean(enableReassign && reassignOptions && reassignOptions.length > 0);
const assigneeValue = pendingAssignee ?? currentAssigneeValue;
const assigneeLabel =
reassignOptions?.find((o) => o.id === assigneeValue)?.label ?? "Unassigned";
const assigneeName = assigneeLabel === "Unassigned" ? "the agent" : assigneeLabel;
const effectivePlaceholder = placeholder ?? modePlaceholder(pendingMode, assigneeName);
function insertReference(name: string, url: string, asImage: boolean) {
const safeName = name.replace(/[[\]]/g, "\\$&");
const markdown = asImage ? `![${safeName}](${url})` : `[${safeName}](${url})`;
setBody((prev) => (prev ? `${prev}\n\n${markdown}` : markdown));
}
async function attachFile(file: File) {
const id = `${file.name}:${file.size}:${file.lastModified}:${Math.random().toString(36).slice(2)}`;
const isImage = file.type.startsWith("image/");
// Create the preview up front so the thumbnail appears while uploading.
const previewUrl =
isImage && typeof URL.createObjectURL === "function" ? URL.createObjectURL(file) : undefined;
if (previewUrl) previewUrlsRef.current.add(previewUrl);
setAttachments((prev) => [
...prev,
{ id, name: file.name, status: "uploading", previewUrl, isImage: isImage || undefined },
]);
try {
if (onAttachImage) {
const attachment = await onAttachImage(file);
const name = attachment?.originalFilename ?? file.name;
if (attachment?.contentPath) {
insertReference(name, attachment.contentPath, file.type.startsWith("image/"));
}
setAttachments((prev) =>
prev.map((item) => (item.id === id ? { ...item, name, status: "attached" } : item)),
);
} else if (onImageUpload && file.type.startsWith("image/")) {
const url = await onImageUpload(file);
insertReference(file.name, url, true);
setAttachments((prev) =>
prev.map((item) => (item.id === id ? { ...item, status: "attached" } : item)),
);
} else {
setAttachments((prev) =>
prev.map((item) =>
item.id === id
? { ...item, status: "error", error: "This file type cannot be attached here" }
: item,
),
);
}
} catch (err) {
setAttachments((prev) =>
prev.map((item) =>
item.id === id
? { ...item, status: "error", error: err instanceof Error ? err.message : "Upload failed" }
: item,
),
);
}
}
async function attachFiles(files: Iterable<File>) {
for (const file of files) {
await attachFile(file);
}
}
function handleFileInputChange(evt: ChangeEvent<HTMLInputElement>) {
const files = evt.target.files;
if (files && files.length > 0) void attachFiles(Array.from(files));
evt.target.value = "";
}
function handlePaste(evt: ReactClipboardEvent<HTMLTextAreaElement>) {
if (!canAcceptFiles) return;
const files = Array.from(evt.clipboardData?.files ?? []);
if (files.length === 0) return;
evt.preventDefault();
void attachFiles(files);
}
function handleFileDragEnter(evt: ReactDragEvent<HTMLDivElement>) {
if (!canAcceptFiles || !hasFilePayload(evt)) return;
evt.preventDefault();
dragDepthRef.current += 1;
setIsDragOver(true);
}
function handleFileDragOver(evt: ReactDragEvent<HTMLDivElement>) {
if (!canAcceptFiles || !hasFilePayload(evt)) return;
evt.preventDefault();
evt.dataTransfer.dropEffect = "copy";
}
function handleFileDragLeave(evt: ReactDragEvent<HTMLDivElement>) {
if (!canAcceptFiles || !hasFilePayload(evt)) return;
evt.preventDefault();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setIsDragOver(false);
}
function handleFileDrop(evt: ReactDragEvent<HTMLDivElement>) {
if (!canAcceptFiles || !hasFilePayload(evt)) return;
evt.preventDefault();
dragDepthRef.current = 0;
setIsDragOver(false);
const files = evt.dataTransfer?.files;
if (files && files.length > 0) void attachFiles(Array.from(files));
}
async function submit() {
const trimmed = body.trim();
if (!trimmed || submitting || disabled) return;
const hasReassignment = showAssignee && assigneeValue !== currentAssigneeValue;
const reassignment = hasReassignment ? parseAssigneeValue(assigneeValue) : undefined;
const reopen = shouldImplicitlyReopenComment(issueStatus, assigneeValue) ? true : undefined;
setSubmitting(true);
setBody("");
try {
if (pendingMode !== workMode && onWorkModeChange) {
await onWorkModeChange(pendingMode);
}
await onAdd(trimmed, reopen, reassignment);
setAttachments((prev) => {
for (const item of prev) releasePreview(item.previewUrl);
return [];
});
setPendingAssignee(null);
} catch {
setBody(trimmed); // restore on failure
} finally {
setSubmitting(false);
}
}
return (
<div
className={cn(
"rounded-xl border border-input bg-card p-2 shadow-(--shadow-extract-7) transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/15",
isDragOver && "ring-2 ring-primary/40",
)}
onDragEnter={handleFileDragEnter}
onDragOver={handleFileDragOver}
onDragLeave={handleFileDragLeave}
onDrop={handleFileDrop}
>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={(e) => {
// Cmd/Ctrl+Enter posts; plain Enter inserts a newline (Enter alone
// was too easy to trip while drafting).
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
void submit();
return;
}
if (e.key === "Tab" && e.shiftKey) {
e.preventDefault();
setPendingMode((mode) => nextWorkMode(mode));
}
}}
onPaste={handlePaste}
disabled={disabled}
placeholder={disabled ? (disabledReason ?? "Composer disabled") : effectivePlaceholder}
rows={2}
className="w-full resize-none bg-transparent px-1 py-1 text-sm outline-none placeholder:text-muted-foreground disabled:opacity-60"
data-testid="task-chat-composer-input"
/>
{attachments.length > 0 ? (
<div className="mb-1 flex flex-wrap gap-1 px-1" data-testid="task-chat-composer-attachments">
{attachments.map((attachment) => (
<span
key={attachment.id}
className={cn(
"inline-flex min-w-0 items-center gap-1 rounded-md border px-2 py-0.5 text-xs",
attachment.status === "error"
? "border-destructive/50 bg-destructive/10 text-destructive"
: "border-border bg-muted/30 text-muted-foreground",
)}
>
{attachment.isImage && attachment.previewUrl ? (
<img
src={attachment.previewUrl}
alt=""
className="size-10 shrink-0 rounded object-cover"
/>
) : null}
{attachment.status === "uploading" ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin" aria-hidden />
) : attachment.status === "error" ? (
<AlertTriangle className="h-3 w-3 shrink-0" aria-hidden />
) : (
<Check className="h-3 w-3 shrink-0" aria-hidden />
)}
<span className="max-w-40 truncate font-medium text-foreground">{attachment.name}</span>
<span className="shrink-0">
·{" "}
{attachment.status === "uploading"
? "Uploading…"
: attachment.status === "error"
? (attachment.error ?? "Upload failed")
: "Attached"}
</span>
</span>
))}
</div>
) : null}
<div className="mt-1 flex items-center gap-2">
{canAcceptFiles ? (
<>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileInputChange}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={disabled}
title="Attach file"
aria-label="Attach file"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
data-testid="task-chat-composer-attach"
>
<Plus className="h-4 w-4" aria-hidden />
</button>
</>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={disabled || !onWorkModeChange}
className="status-chip flex h-8 shrink-0 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium transition-colors disabled:opacity-50"
style={{ "--sc": modeHue(pendingMode) } as CSSProperties}
data-testid="task-chat-composer-mode"
data-pending-work-mode={pendingMode}
>
{modeMeta.label}
<ChevronDown className="h-3 w-3" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60">
{workModeMetaList().map((m) => {
const Icon = m.icon;
const selected = m.value === pendingMode;
return (
<DropdownMenuItem
key={m.value}
onSelect={() => setPendingMode(m.value)}
style={
selected
? { backgroundColor: `color-mix(in srgb, ${modeHue(m.value)} 12%, transparent)` }
: undefined
}
>
<Icon className="h-4 w-4 shrink-0" style={{ color: modeHue(m.value) }} aria-hidden />
<span className="flex min-w-0 flex-1 flex-col">
<span className="font-medium">{m.label}</span>
<span className="text-xs text-muted-foreground">{MODE_DESCRIPTION[m.value] ?? ""}</span>
</span>
{selected ? <Check className="h-4 w-4 shrink-0" aria-hidden /> : null}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<div className="flex-1" />
{showAssignee ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={disabled}
className="flex h-8 min-w-0 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium text-foreground transition-colors hover:bg-accent disabled:opacity-50"
data-testid="task-chat-composer-assignee"
>
<span className="max-w-40 truncate">{assigneeLabel}</span>
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{(reassignOptions ?? []).map((option) => (
<DropdownMenuItem key={option.id} onSelect={() => setPendingAssignee(option.id)}>
<span className="min-w-0 flex-1 truncate">{option.label}</span>
{option.id === assigneeValue ? <Check className="h-4 w-4 shrink-0" aria-hidden /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<button
type="button"
onClick={() => void submit()}
disabled={disabled || submitting || body.trim().length === 0}
title="Send (⌘+Enter)"
aria-label="Send"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground transition-transform hover:scale-105 disabled:scale-100 disabled:bg-muted disabled:text-muted-foreground"
data-testid="task-chat-composer-send"
>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
) : (
<ArrowUp className="h-4 w-4" aria-hidden />
)}
</button>
</div>
</div>
);
}
@@ -0,0 +1,162 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { RequestConfirmationInteraction } from "@/lib/issue-thread-interactions";
import { ThemeProvider } from "@/context/ThemeContext";
import { TooltipProvider } from "@/components/ui/tooltip";
import { TaskChatInteractionCard } from "./TaskChatInteractionCard";
import { TaskChatThreadView } from "./TaskChatThreadView";
import type { TaskChatInteractionItem } from "./task-chat-model";
function createRequestConfirmation(
overrides: Partial<RequestConfirmationInteraction> = {},
): RequestConfirmationInteraction {
return {
id: "confirmation-1",
companyId: "company-1",
issueId: "issue-1",
kind: "request_confirmation",
title: "Approve the plan",
summary: "Review and approve the latest plan.",
status: "pending",
continuationPolicy: "wake_assignee",
createdByAgentId: "agent-1",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
createdAt: new Date("2026-04-06T12:01:00.000Z"),
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
resolvedAt: null,
payload: {
version: 1,
prompt: "Approve the plan?",
},
result: null,
...overrides,
};
}
function interactionItem(
interaction: RequestConfirmationInteraction,
): TaskChatInteractionItem {
return { id: `interaction:${interaction.id}`, kind: "interaction", interaction };
}
describe("TaskChatInteractionCard", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
});
it("renders a pending confirmation as a full interaction card", () => {
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard item={interactionItem(createRequestConfirmation())} />
</ThemeProvider>
</TooltipProvider>,
);
});
const card = container.querySelector('[data-testid="task-chat-interaction"]');
expect(card).not.toBeNull();
expect(card?.id).toBe("interaction-confirmation-1");
expect(container.textContent).toContain("Approve the plan");
});
it("puts the primary CTA on the right via a reversed action row", () => {
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard item={interactionItem(createRequestConfirmation())} />
</ThemeProvider>
</TooltipProvider>,
);
});
const buttons = Array.from(container.querySelectorAll("button"));
const confirm = buttons.find((button) => button.textContent === "Confirm");
const decline = buttons.find((button) => button.textContent === "Decline");
expect(confirm).not.toBeUndefined();
expect(decline).not.toBeUndefined();
const row = confirm?.parentElement;
expect(row).toBe(decline?.parentElement);
expect(row?.className).toContain("flex-row-reverse");
});
it("demotes an expired confirmation to a marker row", () => {
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard
item={interactionItem(createRequestConfirmation({ status: "expired" }))}
/>
</ThemeProvider>
</TooltipProvider>,
);
});
expect(container.querySelector('[data-testid="task-chat-interaction"]')).toBeNull();
expect(container.textContent).toContain("Approve the plan");
expect(container.textContent).toContain("expired");
});
});
describe("TaskChatThreadView interaction items", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
});
it("renders interaction items through renderInteraction", () => {
const item = interactionItem(createRequestConfirmation());
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatThreadView
items={[item]}
scroll={false}
renderInteraction={(it) => <TaskChatInteractionCard item={it} />}
/>
</ThemeProvider>
</TooltipProvider>,
);
});
expect(container.querySelector('[data-testid="task-chat-interaction"]')).not.toBeNull();
});
it("renders nothing for interaction items without renderInteraction", () => {
const item = interactionItem(createRequestConfirmation());
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatThreadView items={[item]} scroll={false} />
</ThemeProvider>
</TooltipProvider>,
);
});
expect(container.querySelector('[data-testid="task-chat-interaction"]')).toBeNull();
});
});
@@ -0,0 +1,44 @@
import type { ComponentProps } from "react";
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
import { TaskChatMarker } from "./TaskChatMarker";
import type { TaskChatInteractionItem } from "./task-chat-model";
type InteractionCardProps = Omit<ComponentProps<typeof IssueThreadInteractionCard>, "interaction">;
export interface TaskChatInteractionCardProps extends InteractionCardProps {
item: TaskChatInteractionItem;
}
/**
* v7-grammar wrapper for issue-thread interactions (plan confirmation,
* question card, suggested tasks…) inside the redesigned thread: cards are the
* only rows that get bubble-level emphasis, so the shared
* IssueThreadInteractionCard renders full-width with the standard bubble
* entrance. Expired confirmations demote to a marker row — superseded asks are
* history, not calls to action (legacy-thread parity).
*/
export function TaskChatInteractionCard({ item, ...cardProps }: TaskChatInteractionCardProps) {
const interaction = item.interaction;
if (interaction.kind === "request_confirmation" && interaction.status === "expired") {
return (
<TaskChatMarker
item={{
id: item.id,
kind: "marker",
variant: "turn_boundary",
label: interaction.title ?? "Confirmation",
detail: "expired",
}}
/>
);
}
return (
<div
id={`interaction-${interaction.id}`}
className="tc-enter-bubble w-full"
data-testid="task-chat-interaction"
>
<IssueThreadInteractionCard interaction={interaction} primaryActionOnRight {...cardProps} />
</div>
);
}
@@ -0,0 +1,30 @@
import { cn } from "@/lib/utils";
import { CircleDot, OctagonX, Flag } from "lucide-react";
import type { TaskChatMarkerItem } from "./task-chat-model";
const VARIANT_ICON = {
session_start: CircleDot,
interrupted: OctagonX,
turn_boundary: Flag,
} as const;
/**
* Lifecycle divider — renders a state boundary (session start, interruption,
* turn boundary) as a recessed rule, never a bubble. Interruptions use a dashed
* destructive rule to read as a distinct state.
*/
export function TaskChatMarker({ item }: { item: TaskChatMarkerItem }) {
const Icon = VARIANT_ICON[item.variant];
const interrupted = item.variant === "interrupted";
return (
<div className="tc-enter-marker flex items-center gap-2 py-1 text-xs text-muted-foreground">
<span className={cn("h-px flex-1", interrupted ? "border-t border-dashed border-destructive/50" : "bg-border")} />
<span className={cn("flex items-center gap-1.5", interrupted && "text-destructive")}>
<Icon className="h-3.5 w-3.5" />
<span className="font-medium">{item.label}</span>
{item.detail ? <span className="text-muted-foreground">· {item.detail}</span> : null}
</span>
<span className={cn("h-px flex-1", interrupted ? "border-t border-dashed border-destructive/50" : "bg-border")} />
</div>
);
}
@@ -0,0 +1,55 @@
import { cn } from "@/lib/utils";
import { Check, Circle, Loader2 } from "lucide-react";
import type { TaskChatPlan, TaskChatPlanEntryStatus } from "./task-chat-model";
const STATUS_ICON: Record<TaskChatPlanEntryStatus, { Icon: typeof Check; spin: boolean; tone: string }> = {
completed: { Icon: Check, spin: false, tone: "text-(--status-task-icon-done)" },
in_progress: { Icon: Loader2, spin: true, tone: "text-(--status-agent-running)" },
pending: { Icon: Circle, spin: false, tone: "text-muted-foreground" },
};
const PRIORITY_TONE = {
high: "text-destructive",
medium: "text-(--status-agent-paused)",
low: "text-muted-foreground",
} as const;
/**
* Structured plan/todo viewer (ACP Plan / PlanEntry). Used both by the harness
* "plan-todo" state and by the properties-pane Plans tab. Renders a live
* checklist with per-entry status and a revision label; entries stagger in.
*/
export function TaskChatPlanView({ plan }: { plan: TaskChatPlan }) {
const done = plan.entries.filter((e) => e.status === "completed").length;
return (
<div className="flex flex-col gap-2 py-2">
<div className="flex items-center gap-2 px-1 text-xs text-muted-foreground">
<span className="font-medium text-foreground">Plan</span>
<span>· rev {plan.revision}</span>
<span className="ml-auto">
{done}/{plan.entries.length} done
</span>
</div>
<ul className="flex flex-col gap-1">
{plan.entries.map((entry, i) => {
const { Icon, spin, tone } = STATUS_ICON[entry.status];
return (
<li
key={entry.id}
className="tc-enter-plan-entry flex items-start gap-2 rounded-md border border-border bg-card/50 px-2.5 py-1.5 text-sm"
style={{ animationDelay: `calc(var(--motion-plan-entry-stagger) * ${i})` }}
>
<Icon className={cn("mt-0.5 h-4 w-4 shrink-0", tone, spin && "animate-spin")} />
<span className={cn("min-w-0 flex-1", entry.status === "completed" && "text-muted-foreground line-through")}>
{entry.content}
</span>
<span className={cn("shrink-0 text-(length:--text-nano) uppercase tracking-wide", PRIORITY_TONE[entry.priority])}>
{entry.priority}
</span>
</li>
);
})}
</ul>
</div>
);
}
@@ -0,0 +1,137 @@
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { Loader2, ShieldQuestion, OctagonX, Ban, Scissors } from "lucide-react";
import type { TaskChatStatusItem } from "./task-chat-model";
import { toolTaxonomy } from "./tool-taxonomy";
function elapsedLabel(ms?: number): string | null {
if (ms == null) return null;
const s = Math.round(ms / 1000);
if (s < 60) return `${s}s`;
return `${Math.floor(s / 60)}m ${s % 60}s`;
}
/** Tenths-precision elapsed ("24.3s", "1m 24.3s") so the readout visibly moves. */
function liveElapsedLabel(ms?: number): string | null {
if (ms == null) return null;
const s = Math.max(0, ms) / 1000;
if (s < 60) return `${s.toFixed(1)}s`;
return `${Math.floor(s / 60)}m ${(s % 60).toFixed(1)}s`;
}
/** Elapsed ms since `startedAtMs`, ticking every 100ms while `live`. */
function useLiveElapsedMs(startedAtMs: number | undefined, live: boolean): number | undefined {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (startedAtMs == null || !live) return;
const id = window.setInterval(() => setNow(Date.now()), 100);
return () => window.clearInterval(id);
}, [startedAtMs, live]);
return startedAtMs == null ? undefined : Math.max(0, now - startedAtMs);
}
const CONFIG = {
running: { Icon: Loader2, spin: true, tone: "text-(--status-agent-running)" },
working: { Icon: Loader2, spin: true, tone: "text-(--status-agent-running)" },
awaiting_approval: { Icon: ShieldQuestion, spin: false, tone: "text-primary" },
interrupted: { Icon: OctagonX, spin: false, tone: "text-destructive" },
refused: { Icon: Ban, spin: false, tone: "text-destructive" },
truncated: { Icon: Scissors, spin: false, tone: "text-(--status-agent-paused)" },
} as const;
interface TaskChatStatusPillProps {
item: TaskChatStatusItem;
onApprovalDecision?: (optionId: string) => void;
}
/**
* Lifecycle state as a status affordance (never a bubble), with a defined
* enter transition. Live running/working states render the v7 flat statusline
* (pulse dot + shimmering label + mono meta); Tier-B attention states keep
* card chrome — awaiting-approval elevates with an attention pulse and
* exposes the ACP permission options as actions.
*/
export function TaskChatStatusPill({ item, onApprovalDecision }: TaskChatStatusPillProps) {
const { Icon, spin, tone } = CONFIG[item.status];
const awaiting = item.status === "awaiting_approval";
const live = item.status === "running" || item.status === "working";
const liveElapsedMs = useLiveElapsedMs(item.startedAtMs, live);
const elapsed = elapsedLabel(item.elapsedMs);
if (live) {
const liveElapsed = liveElapsedLabel(liveElapsedMs ?? item.elapsedMs);
const ToolIcon = item.toolName ? toolTaxonomy(item.toolName).icon : null;
return (
<div className="tc-enter-status flex items-center gap-2 py-0.5 text-xs text-muted-foreground">
<span
aria-hidden
className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-(--status-agent-running)"
/>
{ToolIcon ? <ToolIcon className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
{/* Text cells share a baseline row so the smaller mono readouts don't
float above the label's baseline. */}
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="shimmer-text shimmer-text-muted shrink-0 font-medium">{item.label}…</span>
{liveElapsed ? (
<span className="shrink-0 font-mono tabular-nums text-(length:--text-micro)">
{liveElapsed}
</span>
) : null}
{item.detail ? (
<span className="min-w-0 truncate font-mono text-(length:--text-micro)">{item.detail}</span>
) : null}
{item.tokens ? (
<span className="ml-auto shrink-0 font-mono text-(length:--text-micro)">
{item.tokens.used.toLocaleString()}/{item.tokens.size.toLocaleString()} ctx
</span>
) : null}
</span>
</div>
);
}
return (
<div
className={cn(
"tc-enter-status flex flex-col gap-2 rounded-lg border px-3 py-2 text-sm",
awaiting ? "tc-approval border-primary/40 bg-primary/5" : "border-border bg-muted/40",
)}
>
<div className="flex min-w-0 items-center gap-2">
<Icon className={cn("h-4 w-4 shrink-0", tone, spin && "animate-spin")} />
<span className="min-w-0 truncate font-medium">{item.label}</span>
<span className="ml-auto flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
{elapsed ? <span>{elapsed}</span> : null}
{item.tokens ? (
<span>
{item.tokens.used.toLocaleString()}/{item.tokens.size.toLocaleString()} ctx
</span>
) : null}
</span>
</div>
{item.detail ? <p className="text-xs text-muted-foreground">{item.detail}</p> : null}
{awaiting && item.approval ? (
<div className="flex flex-wrap justify-end gap-2 pt-1">
{item.approval.options.map((opt) => {
const reject = opt.kind.startsWith("reject");
return (
<button
key={opt.id}
type="button"
onClick={() => onApprovalDecision?.(opt.id)}
className={cn(
"rounded-md border px-2.5 py-1 text-xs font-medium transition-colors",
reject
? "border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground"
: "border-primary bg-primary text-primary-foreground hover:bg-primary/90",
)}
>
{opt.label}
</button>
);
})}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,50 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
import { ChevronRight } from "lucide-react";
import type { TaskChatThinkingItem } from "./task-chat-model";
/**
* Chain-of-thought (ACP agent_thought_chunk) as a flat left-railed block (v7):
* a chevron header whose label shimmers while streaming ("Thinking…") and
* settles to "Thought for Ns", over a recessed pre-wrap body. Streams open;
* settled history arrives collapsed and re-expands on click. No card chrome —
* thinking is activity metadata, subordinate to real content.
*/
export function TaskChatThinkingBlock({ item }: { item: TaskChatThinkingItem }) {
const [open, setOpen] = useState(!item.collapsed);
const settledLabel = item.summaryLabel ?? "Thought process";
return (
<div className="border-l-2 border-border pl-3 text-xs text-muted-foreground">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex items-center gap-1.5 py-0.5 transition-colors hover:text-foreground"
>
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", open ? "rotate-90" : null)} aria-hidden />
{item.streaming ? (
<span className="shimmer-text shimmer-text-muted font-medium">Thinking…</span>
) : (
<span className="font-medium">{settledLabel}</span>
)}
</button>
{open ? (
<div className="space-y-1 pb-1 pt-0.5">
{item.lines.map((line, i) => (
<p
key={i}
className="tc-enter-cot-line whitespace-pre-wrap"
style={{ animationDelay: `calc(var(--motion-cot-line-stagger) * ${i})` }}
>
{line}
</p>
))}
{item.streaming ? (
<span className="tc-cursor inline-block h-3 w-1.5 bg-muted-foreground align-baseline" />
) : null}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,116 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import type { TaskChatInteractionItem, TaskChatItem } from "./task-chat-model";
import { TaskChatTurn } from "./TaskChatTurn";
import { TaskChatBubble } from "./TaskChatBubble";
import { TaskChatMarker } from "./TaskChatMarker";
import { TaskChatStatusPill } from "./TaskChatStatusPill";
import { TaskChatToolCard } from "./TaskChatToolCard";
import { TaskChatThinkingBlock } from "./TaskChatThinkingBlock";
import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
import { TaskMessageScroller } from "./TaskMessageScroller";
interface TaskChatThreadViewProps {
items: TaskChatItem[];
/**
* Content rendered above the first message INSIDE the scroll viewport (the
* issue header when hosted by the live thread) — it scrolls away with the
* messages instead of staying pinned above the thread.
*/
header?: ReactNode;
onApprovalDecision?: (statusItemId: string, optionId: string) => void;
/**
* Renders an interleaved issue-thread interaction (the live thread supplies
* TaskChatInteractionCard bound to its accept/reject handlers). Interaction
* items render nothing without it — the harness has no control plane.
*/
renderInteraction?: (item: TaskChatInteractionItem) => ReactNode;
className?: string;
/** When false, render the list without the scroll container (e.g. previews). */
scroll?: boolean;
}
function renderItem(
item: TaskChatItem,
onApprovalDecision?: (statusItemId: string, optionId: string) => void,
renderInteraction?: (item: TaskChatInteractionItem) => ReactNode,
) {
switch (item.kind) {
case "message":
return <TaskChatBubble item={item} />;
case "marker":
return <TaskChatMarker item={item} />;
case "thinking":
return <TaskChatThinkingBlock item={item} />;
case "tool":
return <TaskChatToolCard item={item} />;
case "status":
return (
<TaskChatStatusPill
item={item}
onApprovalDecision={(optionId) => onApprovalDecision?.(item.id, optionId)}
/>
);
case "usage":
return <TaskChatUsageReadout item={item} />;
case "interaction":
return renderInteraction ? renderInteraction(item) : null;
case "turn":
return (
<TaskChatTurn
item={item}
renderChild={(child) => renderItem(child, onApprovalDecision)}
/>
);
default: {
// Exhaustiveness guard: a new item kind must add a branch above.
const _never: never = item;
return _never;
}
}
}
/**
* Presentational render layer for the redesigned task thread. Consumed by both
* the live thread (adapter over comment/run props) and the dev harness
* (synthetic fixtures). Owns no data fetching — it maps a normalized
* TaskChatItem[] onto the primitives inside the auto-follow scroller.
*/
export function TaskChatThreadView({
items,
header,
onApprovalDecision,
renderInteraction,
className,
scroll = true,
}: TaskChatThreadViewProps) {
const body = (
<div className={cn("mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col gap-3 px-4 py-4", className)}>
{header ? (
<div className="flex flex-col gap-6 pb-2" data-testid="task-chat-thread-header">
{header}
</div>
) : null}
{items.map((item) => (
<div key={item.id}>{renderItem(item, onApprovalDecision, renderInteraction)}</div>
))}
</div>
);
if (!scroll) return body;
// Include a cheap content signature so streaming growth (text lengthening
// without the item count changing) still advances the auto-follow key.
const signatureOf = (it: TaskChatItem): number => {
if (it.kind === "message") return it.text.length;
if (it.kind === "thinking") return it.lines.reduce((n, l) => n + l.length, 0);
if (it.kind === "tool") return (it.diff?.lines?.length ?? 0) + (it.status === "completed" ? 1 : 0);
if (it.kind === "turn") {
return it.settled ? 1 : it.items.reduce((n, child) => n + signatureOf(child), it.items.length);
}
return 1;
};
const contentKey = items.reduce((acc, it) => acc + signatureOf(it), items.length);
return <TaskMessageScroller contentKey={contentKey}>{body}</TaskMessageScroller>;
}
@@ -0,0 +1,106 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
import {
Check,
ChevronRight,
Loader2,
ShieldCheck,
ShieldX,
X,
} from "lucide-react";
import type { TaskChatToolItem } from "./task-chat-model";
import { toolTaxonomy } from "./tool-taxonomy";
const STATUS_ICON = {
pending: { Icon: Loader2, spin: false, tone: "text-muted-foreground" },
in_progress: { Icon: Loader2, spin: true, tone: "text-(--status-agent-running)" },
completed: { Icon: Check, spin: false, tone: "text-muted-foreground" },
failed: { Icon: X, spin: false, tone: "text-destructive" },
} as const;
/**
* Tool invocation as a flat activity row (v7): [glyph] [name] [mono target] …
* [hover chevron] [status]. No card chrome — activity is metadata, not
* content. Clicking toggles the mono result/detail block (left-rail inset);
* diffs render as an inset panel below the row.
*/
export function TaskChatToolCard({ item }: { item: TaskChatToolItem }) {
const { Icon, spin, tone } = STATUS_ICON[item.status];
const RowIcon = toolTaxonomy(item.rawName ?? item.name).icon;
const [showDetail, setShowDetail] = useState(false);
const expandable = Boolean(item.detail);
return (
<div className="tc-enter-tool flex flex-col text-xs">
<button
type="button"
onClick={expandable ? () => setShowDetail((v) => !v) : undefined}
aria-expanded={expandable ? showDetail : undefined}
className={cn(
"group/tool -mx-1.5 flex min-w-0 items-center gap-2 rounded-sm px-1.5 py-0.5 text-left text-muted-foreground",
expandable ? "cursor-pointer transition-colors hover:bg-muted/60 hover:text-foreground" : "cursor-default",
)}
>
<RowIcon className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="shrink-0 font-medium">{item.name}</span>
{item.target ? (
<span className="min-w-0 truncate font-mono text-(length:--text-micro)">{item.target}</span>
) : null}
<span className="ml-auto flex shrink-0 items-center gap-2">
{item.decision ? (
<span
className={cn(
"flex items-center gap-1 text-(length:--text-micro)",
item.decision === "allowed" ? "text-(--status-task-icon-done)" : "text-destructive",
)}
>
{item.decision === "allowed" ? <ShieldCheck className="h-3 w-3" /> : <ShieldX className="h-3 w-3" />}
{item.decision}
</span>
) : null}
{expandable ? (
<ChevronRight
className={cn(
"h-3 w-3 opacity-0 transition-[opacity,transform] group-hover/tool:opacity-80",
showDetail ? "rotate-90" : null,
)}
aria-hidden
/>
) : null}
<Icon className={cn("h-3.5 w-3.5", tone, spin && "animate-spin")} aria-hidden />
</span>
</button>
{expandable && showDetail ? (
<pre className="ml-2.5 mt-0.5 overflow-x-auto whitespace-pre-wrap border-l-2 border-border py-1 pl-2.5 font-mono text-(length:--text-micro) leading-relaxed text-muted-foreground">
{item.detail}
</pre>
) : null}
{item.diff ? (
<div className="tc-reveal-diff ml-2.5 mt-1 border-l-2 border-border pl-2.5">
<div className="mb-1 flex items-center gap-2 text-(length:--text-micro) text-muted-foreground">
{item.diff.path ? <span className="truncate font-mono">{item.diff.path}</span> : null}
<span className="ml-auto shrink-0 font-mono font-semibold">
+{item.diff.added} −{item.diff.removed}
</span>
</div>
{item.diff.lines?.length ? (
<pre className="overflow-x-auto rounded-sm bg-muted/50 p-2 text-(length:--text-micro) leading-relaxed">
{item.diff.lines.map((l, i) => (
<div
key={i}
className={cn(
l.kind === "add" && "text-(--status-task-icon-done)",
l.kind === "remove" && "text-destructive",
l.kind === "context" && "text-muted-foreground",
)}
>
{l.kind === "add" ? "+" : l.kind === "remove" ? "−" : " "} {l.text}
</div>
))}
</pre>
) : null}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { TaskChatTurn, turnSummaryText } from "./TaskChatTurn";
import { buildTurnSummary } from "./transcript-adapter";
import type { TranscriptEntry } from "@/adapters";
import type { TaskChatTurnItem } from "./task-chat-model";
const SETTLED: TaskChatTurnItem = {
id: "t1",
kind: "turn",
settled: true,
summary: { durationLabel: "38s", toolCount: 3, added: 34, removed: 3, tokensLabel: "12.3k tokens" },
items: [{ id: "c1", kind: "tool", name: "read auth.ts", status: "completed" }],
};
describe("turnSummaryText", () => {
it("joins the known parts with dots", () => {
expect(turnSummaryText(SETTLED.summary)).toBe("Worked · 38s · 3 tools · +34 −3 · 12.3k tokens");
});
it("omits unknown parts and singularizes one tool", () => {
expect(turnSummaryText({ toolCount: 1, added: 0, removed: 0 })).toBe("Worked · 1 tool");
});
it("labels failed turns Stopped", () => {
expect(turnSummaryText({ toolCount: 0, added: 0, removed: 0, failed: true })).toBe("Stopped");
});
});
describe("buildTurnSummary", () => {
it("counts tools, diff lines and result tokens from a transcript", () => {
const entries: TranscriptEntry[] = [
{ kind: "tool_call", ts: "2026-07-29T10:00:00Z", name: "edit", input: {} },
{ kind: "diff", ts: "2026-07-29T10:00:05Z", changeType: "add", text: "+a" },
{ kind: "diff", ts: "2026-07-29T10:00:05Z", changeType: "remove", text: "-b" },
{
kind: "result", ts: "2026-07-29T10:00:38Z", text: "done", inputTokens: 12000,
outputTokens: 300, cachedTokens: 0, costUsd: 0.01, subtype: "success", isError: false, errors: [],
},
];
const summary = buildTurnSummary(entries);
expect(summary.toolCount).toBe(1);
expect(summary.added).toBe(1);
expect(summary.removed).toBe(1);
expect(summary.tokensLabel).toBe("12.3k tokens");
expect(summary.durationLabel).toBe("38s");
});
it("prefers an explicit duration and flags failure", () => {
const summary = buildTurnSummary([], { durationMs: 95_000, failed: true });
expect(summary.durationLabel).toBe("1m 35s");
expect(summary.failed).toBe(true);
});
});
describe("TaskChatTurn", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
});
const renderTurn = (item: TaskChatTurnItem) => {
flushSync(() => {
root.render(<TaskChatTurn item={item} renderChild={(c) => <span>{c.id}</span>} />);
});
};
const fold = () => container.querySelector(".tc-turn-fold");
const summaryBtn = () =>
container.querySelector<HTMLButtonElement>('[data-testid="task-chat-turn-summary"]');
it("renders a settled turn folded with its summary line", () => {
renderTurn(SETTLED);
// Label and mono metrics are adjacent spans (v7 runsum grammar).
expect(summaryBtn()?.textContent).toContain("Worked");
expect(summaryBtn()?.textContent).toContain("38s · 3 tools · +34 −3 · 12.3k tokens");
expect(fold()?.getAttribute("data-folded")).toBe("true");
});
it("toggles open on summary click", () => {
renderTurn(SETTLED);
flushSync(() => summaryBtn()!.click());
expect(fold()?.getAttribute("data-folded")).toBe("false");
});
it("renders a live turn expanded with no summary line", () => {
renderTurn({ ...SETTLED, settled: false });
expect(summaryBtn()).toBeNull();
expect(fold()?.getAttribute("data-folded")).toBe("false");
});
it("folds when the item settles while mounted", () => {
renderTurn({ ...SETTLED, settled: false });
renderTurn({ ...SETTLED, animateFold: true });
expect(fold()?.getAttribute("data-folded")).toBe("true");
});
});
@@ -0,0 +1,84 @@
import { useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { Check, ChevronRight, X } from "lucide-react";
import type { TaskChatTurnItem, TaskChatTurnChildItem } from "./task-chat-model";
interface TaskChatTurnProps {
item: TaskChatTurnItem;
renderChild: (child: TaskChatTurnChildItem) => ReactNode;
}
/** Metric segments after the label: "38s · 3 tools · +34 −3 · 12.3k tokens". */
export function turnSummaryMetrics(summary: TaskChatTurnItem["summary"]): string {
const parts: string[] = [];
if (summary.durationLabel) parts.push(summary.durationLabel);
if (summary.toolCount > 0) parts.push(`${summary.toolCount} tool${summary.toolCount === 1 ? "" : "s"}`);
if (summary.added > 0 || summary.removed > 0) parts.push(`+${summary.added} −${summary.removed}`);
if (summary.tokensLabel) parts.push(summary.tokensLabel);
return parts.join(" · ");
}
/** "✓ Worked · 38s · 3 tools · +34 −3 · 12.3k tokens" (parts omitted when unknown). */
export function turnSummaryText(summary: TaskChatTurnItem["summary"]): string {
const metrics = turnSummaryMetrics(summary);
const label = summary.failed ? "Stopped" : "Worked";
return metrics ? `${label} · ${metrics}` : label;
}
/**
* One agent turn's activity, foldable to a one-line summary once settled.
*
* While the turn is live it renders fully interleaved with no summary line.
* When it settles the activity folds behind a "✓ Worked · …" line — animated
* (grid-rows via .tc-turn-fold, --motion-turn-fold) when the settle happens
* on-screen, instant for turns that load already settled (the fold class only
* transitions on state CHANGE, so first paint in the folded state never
* animates). Clicking the summary folds/unfolds with the same motion.
* prefers-reduced-motion zeroes the transition in CSS.
*/
export function TaskChatTurn({ item, renderChild }: TaskChatTurnProps) {
const [open, setOpen] = useState(!item.settled);
const [prevSettled, setPrevSettled] = useState(item.settled);
// Live → settled transition observed while mounted: fold (animated via CSS
// unless reduced motion). Adjusted during render (not in an effect) so the
// fold state lands in the same commit — no unfolded flash. Turns mounted
// already-settled start folded via the useState initializers.
if (item.settled !== prevSettled) {
setPrevSettled(item.settled);
if (item.settled) setOpen(false);
}
const folded = item.settled && !open;
const SummaryIcon = item.summary.failed ? X : Check;
return (
<div data-testid="task-chat-turn" data-settled={item.settled ? "true" : "false"}>
{item.settled ? (
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="group flex items-center gap-2 px-1 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
data-testid="task-chat-turn-summary"
>
<SummaryIcon className="h-3.5 w-3.5 shrink-0" />
<span>{item.summary.failed ? "Stopped" : "Worked"}</span>
{turnSummaryMetrics(item.summary) ? (
<span className="font-mono text-(length:--text-micro)">{turnSummaryMetrics(item.summary)}</span>
) : null}
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", open ? "rotate-90" : null)} />
</button>
) : null}
<div className="tc-turn-fold" data-folded={folded ? "true" : "false"} aria-hidden={folded}>
<div>
<div className="flex flex-col gap-2 pt-1">
{item.items.map((child) => (
<div key={child.id}>{renderChild(child)}</div>
))}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,35 @@
import { cn } from "@/lib/utils";
import { Gauge } from "lucide-react";
import type { TaskChatUsageItem } from "./task-chat-model";
/**
* Second-tier live token/cost readout (ACP UsageUpdate). Concrete progress —
* context fill %, tokens, and cost — rather than an indeterminate spinner.
* Recedes to metadata weight so it never competes with message content.
*/
export function TaskChatUsageReadout({ item }: { item: TaskChatUsageItem }) {
const { used, size, inputTokens, outputTokens, costUsd } = item.usage;
const pct = size > 0 ? Math.min(100, Math.round((used / size) * 100)) : 0;
return (
<div className="flex flex-col gap-1 px-1 py-1 text-(length:--text-micro) text-muted-foreground">
<div className="flex items-center gap-1.5">
<Gauge className="h-3 w-3" />
<span>
{used.toLocaleString()}/{size.toLocaleString()} ctx ({pct}%)
</span>
{inputTokens != null || outputTokens != null ? (
<span>
· ↑{(inputTokens ?? 0).toLocaleString()} ↓{(outputTokens ?? 0).toLocaleString()}
</span>
) : null}
{costUsd != null ? <span>· ${costUsd.toFixed(4)}</span> : null}
</div>
<div className="h-1 w-full overflow-hidden rounded-full bg-border">
<div
className={cn("h-full rounded-full bg-(--status-agent-running)")}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
@@ -0,0 +1,215 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TaskMessageScroller } from "./TaskMessageScroller";
const PILL_SELECTOR = 'button[aria-label="Scroll to latest"]';
/**
* jsdom has no layout, so scroll geometry is faked per element: scrollHeight /
* clientHeight are pinned and scrollTop is backed by a plain variable.
*/
function fakeGeometry(el: HTMLElement, { scrollHeight = 1000, clientHeight = 400 } = {}) {
let scrollTop = 0;
Object.defineProperty(el, "scrollHeight", { value: scrollHeight, configurable: true });
Object.defineProperty(el, "clientHeight", { value: clientHeight, configurable: true });
Object.defineProperty(el, "scrollTop", {
get: () => scrollTop,
set: (v: number) => {
scrollTop = v;
},
configurable: true,
});
}
/**
* Scroll/wheel are continuous-priority events, so React flushes the resulting
* state updates asynchronously — wait a macrotask after dispatching.
*/
function flushEvents() {
return new Promise<void>((resolve) => {
setTimeout(resolve);
});
}
describe("TaskMessageScroller", () => {
let container: HTMLDivElement;
let root: Root;
function render(contentKey: unknown = 0) {
flushSync(() => {
root.render(
<TaskMessageScroller contentKey={contentKey}>
<div>messages</div>
</TaskMessageScroller>,
);
});
}
function scroller(): HTMLDivElement {
return container.querySelector<HTMLDivElement>('[data-testid="task-chat-scroller"]')!;
}
function pill(): HTMLButtonElement | null {
return container.querySelector<HTMLButtonElement>(PILL_SELECTOR);
}
/** Set scrollTop and fire a scroll event, like a user or the browser would. */
async function scrollTo(el: HTMLElement, top: number) {
el.scrollTop = top;
el.dispatchEvent(new Event("scroll", { bubbles: true }));
await flushEvents();
}
async function dispatch(el: HTMLElement, event: Event) {
el.dispatchEvent(event);
await flushEvents();
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
vi.unstubAllGlobals();
});
it("renders children inside the scroll container, pill hidden, scrolled to bottom on mount", () => {
render();
const el = scroller();
expect(el.textContent).toContain("messages");
expect(pill()).toBeNull();
// Mount effect pins to the bottom.
expect(el.scrollTop).toBe(el.scrollHeight);
});
it("auto-follows content instantly while pinned", async () => {
render(1);
const el = scroller();
fakeGeometry(el);
await scrollTo(el, 600); // bottom: 1000 - 600 - 400 = 0 → pinned
render(2);
expect(el.scrollTop).toBe(1000);
expect(pill()).toBeNull();
});
it("holds position and shows the icon-only pill when scrolled up", async () => {
render(1);
const el = scroller();
fakeGeometry(el);
await scrollTo(el, 100); // 500px from bottom → unpinned
const btn = pill();
expect(btn).not.toBeNull();
expect(btn!.className).toContain("tc-scroll-pill-in");
expect(btn!.className).toContain("size-8");
expect(btn!.className).not.toContain("-translate-x-1/2");
// Icon-only: no visible text.
expect(btn!.textContent).toBe("");
// New content must not yank the held position.
render(2);
expect(el.scrollTop).toBe(100);
});
it("small drifts within the pin threshold do not show the pill", async () => {
render();
const el = scroller();
fakeGeometry(el);
await scrollTo(el, 560); // 40px from bottom, threshold is 48
expect(pill()).toBeNull();
});
it("clicking the pill smooth-scrolls, ignores intermediate scroll events, re-pins on arrival", async () => {
render(1);
const el = scroller();
fakeGeometry(el);
const scrollToSpy = vi.fn();
el.scrollTo = scrollToSpy as unknown as typeof el.scrollTo;
await scrollTo(el, 100);
const btn = pill()!;
btn.click();
await flushEvents();
expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, behavior: "smooth" });
// Intermediate glide positions fire scroll events; they must not unpin
// (i.e. the easing flag swallows them and the pill state stays put).
await scrollTo(el, 250);
await scrollTo(el, 400);
expect(pill()).not.toBeNull();
// Arrival within the threshold re-pins and hides the pill (immediately
// here: no matchMedia in jsdom → reduced-motion/unmount-now path).
await scrollTo(el, 600);
expect(pill()).toBeNull();
// Re-pinned: content growth follows instantly again.
render(2);
expect(el.scrollTop).toBe(1000);
});
it("a wheel gesture during the glide cancels easing and stays unpinned", async () => {
render(1);
const el = scroller();
fakeGeometry(el);
el.scrollTo = vi.fn() as unknown as typeof el.scrollTo;
await scrollTo(el, 100);
pill()!.click();
await flushEvents();
await dispatch(el, new Event("wheel", { bubbles: true }));
// Easing cancelled → user is unpinned, pill visible, content holds.
expect(pill()).not.toBeNull();
render(2);
expect(el.scrollTop).toBe(100);
// Scroll events far from the bottom now behave normally (still unpinned).
await scrollTo(el, 200);
expect(pill()).not.toBeNull();
});
it("plays the exit animation before unmounting when motion is enabled", async () => {
vi.stubGlobal(
"matchMedia",
vi.fn().mockReturnValue({ matches: false }), // motion allowed
);
render();
const el = scroller();
fakeGeometry(el);
await scrollTo(el, 100);
expect(pill()!.className).toContain("tc-scroll-pill-in");
// Scrolling back to the bottom starts the exit animation but keeps the
// pill mounted until animationend.
await scrollTo(el, 600);
const exiting = pill();
expect(exiting).not.toBeNull();
expect(exiting!.className).toContain("tc-scroll-pill-out");
// jsdom has no window.AnimationEvent, so React's vendor-prefix detection
// maps onAnimationEnd to "webkitAnimationEnd" here (real browsers get the
// unprefixed event).
await dispatch(exiting!, new Event("webkitAnimationEnd", { bubbles: true }));
expect(pill()).toBeNull();
});
it("unmounts immediately on hide under prefers-reduced-motion", async () => {
vi.stubGlobal(
"matchMedia",
vi.fn().mockReturnValue({ matches: true }), // reduce
);
render();
const el = scroller();
fakeGeometry(el);
await scrollTo(el, 100);
expect(pill()).not.toBeNull();
await scrollTo(el, 600);
expect(pill()).toBeNull(); // no animationend needed
});
});
@@ -0,0 +1,171 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { ArrowDown } from "lucide-react";
const PIN_THRESHOLD_PX = 48;
/** Visibility lifecycle of the scroll-to-latest pill. */
type PillPhase = "hidden" | "in" | "out";
/**
* True when animations are disabled (prefers-reduced-motion, or environments
* without matchMedia such as jsdom). In that case the pill's exit animation
* never fires animationend, so we must unmount immediately.
*/
function motionDisabled(): boolean {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return true;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
interface TaskMessageScrollerProps {
children: ReactNode;
/** Value that changes whenever content that could grow the thread updates. */
contentKey: unknown;
className?: string;
}
/**
* Scroll container that owns the redesign's auto-follow vs. hold-position rule.
*
* Explicit rule: while the viewport is pinned to the bottom (within a small
* threshold) new content auto-follows with INSTANT scroll; the moment the user
* scrolls up we hold their position and surface a scroll-to-latest pill
* instead of yanking them down.
*
* Re-follow is easing-aware: clicking the pill glides down smoothly, and while
* that glide is in flight (`easingRef`) the scroll handler must not unpin —
* the smooth scroll fires intermediate scroll events that would otherwise
* re-show the pill mid-glide (the known stick-to-bottom failure mode). Arrival
* within the pin threshold re-pins and hides the pill; a wheel/touch during
* the glide cancels it and treats the user as unpinned. Content-driven follow
* while pinned stays instant, so no reflow/jump happens during streaming.
*/
export function TaskMessageScroller({ children, contentKey, className }: TaskMessageScrollerProps) {
const ref = useRef<HTMLDivElement>(null);
const pinnedRef = useRef(true);
const easingRef = useRef(false);
const [pillPhase, setPillPhase] = useState<PillPhase>("hidden");
const showPill = useCallback(() => {
setPillPhase("in");
}, []);
const hidePill = useCallback(() => {
// Keep rendering through the exit animation; unmount on animationend.
// Without animations (reduced motion / no matchMedia) unmount immediately.
setPillPhase((phase) => (phase === "hidden" ? phase : motionDisabled() ? "hidden" : "out"));
}, []);
const isPinned = useCallback(() => {
const el = ref.current;
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight <= PIN_THRESHOLD_PX;
}, []);
const scrollToBottom = useCallback(() => {
const el = ref.current;
if (!el) return;
el.scrollTop = el.scrollHeight; // instant, never smooth
}, []);
const handleScroll = useCallback(() => {
const pinned = isPinned();
if (easingRef.current) {
// Smooth re-follow in flight: intermediate scroll events must not
// unpin/re-show the pill. Only act once we arrive within the threshold.
if (pinned) {
easingRef.current = false;
pinnedRef.current = true;
hidePill();
}
return;
}
pinnedRef.current = pinned;
if (pinned) hidePill();
else showPill();
}, [isPinned, hidePill, showPill]);
const handleJumpToLatest = useCallback(() => {
const el = ref.current;
if (!el) return;
if (isPinned()) {
// Already at (or within threshold of) the bottom: no glide needed.
pinnedRef.current = true;
hidePill();
return;
}
easingRef.current = true;
if (typeof el.scrollTo === "function") {
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
} else {
// Environments without scrollTo (older jsdom): fall back to instant.
el.scrollTop = el.scrollHeight;
easingRef.current = false;
pinnedRef.current = true;
hidePill();
}
}, [isPinned, hidePill]);
// A user gesture during the smooth glide cancels the re-follow: stop
// treating scroll events as easing and consider the user unpinned.
useEffect(() => {
const el = ref.current;
if (!el) return;
const cancelEasing = () => {
if (!easingRef.current) return;
easingRef.current = false;
pinnedRef.current = false;
showPill();
};
el.addEventListener("wheel", cancelEasing, { passive: true });
el.addEventListener("touchstart", cancelEasing, { passive: true });
return () => {
el.removeEventListener("wheel", cancelEasing);
el.removeEventListener("touchstart", cancelEasing);
};
}, [showPill]);
// Follow new content only when already pinned; otherwise hold position.
useLayoutEffect(() => {
if (pinnedRef.current) scrollToBottom();
}, [contentKey, scrollToBottom]);
useEffect(() => {
scrollToBottom();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="relative min-h-0 flex-1">
<div
ref={ref}
onScroll={handleScroll}
// absolute inset-0 (not h-full): the viewport must equal the flex-sized
// wrapper exactly — percentage heights don't reliably resolve against
// flex-determined block heights, which let the thread overflow the page.
className={cn("absolute inset-0 overflow-y-auto", className)}
data-testid="task-chat-scroller"
>
{children}
</div>
{pillPhase !== "hidden" ? (
<button
type="button"
aria-label="Scroll to latest"
onClick={handleJumpToLatest}
onAnimationEnd={() => {
if (pillPhase === "out") setPillPhase("hidden");
}}
// The tc-scroll-pill-* keyframes carry the translate(-50%) X-centering
// (fill: both keeps it after the animation) — no -translate-x-1/2 here.
className={cn(
"absolute bottom-3 left-1/2 flex size-8 items-center justify-center rounded-full border border-border bg-background shadow-sm hover:bg-muted",
pillPhase === "out" ? "tc-scroll-pill-out" : "tc-scroll-pill-in",
)}
>
<ArrowDown className="h-4 w-4" />
</button>
) : null}
</div>
);
}
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { TweakPanel } from "./TweakPanel";
import { MOTION_TOKENS } from "./motion-tokens";
describe("TweakPanel", () => {
let container: HTMLDivElement;
let root: Root | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
flushSync(() => root!.render(<TweakPanel />));
});
afterEach(() => {
flushSync(() => root?.unmount());
root = null;
container.remove();
document.documentElement.removeAttribute("style");
sessionStorage.clear();
});
it("lists a control for every motion token in the catalog", () => {
const rendered = new Set(
Array.from(container.querySelectorAll("[data-token]")).map((el) => el.getAttribute("data-token")),
);
for (const t of MOTION_TOKENS) {
expect(rendered.has(t.name), `panel is missing a control for ${t.name}`).toBe(true);
}
expect(rendered.size).toBe(MOTION_TOKENS.length);
});
it("writes a token change live to the CSS custom property", () => {
const token = MOTION_TOKENS.find((t) => t.kind === "time")!;
const input = container.querySelector<HTMLInputElement>(`input[aria-label="${token.name}"]`);
expect(input).not.toBeNull();
// Use the native value setter so React's value tracker registers the change
// and fires onChange (setting `.value` directly is swallowed by React).
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)!.set!;
flushSync(() => {
nativeSetter.call(input, "500");
input!.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(document.documentElement.style.getPropertyValue(token.name)).toBe("500ms");
});
});
+241
View File
@@ -0,0 +1,241 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { Copy, GripHorizontal, Minus, Plus, RotateCcw } from "lucide-react";
import {
EASING_PRESETS,
MOTION_TOKENS,
MOTION_TOKEN_EXPORT_FALLBACK,
MOTION_TOKEN_GROUPS,
parseCssTimeMs,
type MotionTokenDef,
} from "./motion-tokens";
const POS_KEY = "tc-tweak-pos";
const OVERRIDES_KEY = "tc-tweak-overrides";
function readComputed(name: string): string {
if (typeof document === "undefined") return "";
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
function loadSession<T>(key: string, fallback: T): T {
try {
const raw = sessionStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : fallback;
} catch {
return fallback;
}
}
function saveSession(key: string, value: unknown) {
try {
sessionStorage.setItem(key, JSON.stringify(value));
} catch {
/* session storage unavailable — panel still works, just not persisted */
}
}
/**
* Hand-rolled motion tweak panel (dev/flag only). Floating, draggable,
* minimizable overlay that lists every motion token, writes changes live to the
* CSS custom properties (no reload), exports a paste-ready :root block, and
* persists position + overrides for the session only. No third-party GUI lib —
* one file, one dev guard, trivial to strip out later.
*/
export function TweakPanel() {
const [pos, setPos] = useState<{ x: number; y: number }>(() =>
loadSession(POS_KEY, { x: 24, y: 24 }),
);
const [minimized, setMinimized] = useState(false);
const [values, setValues] = useState<Record<string, string>>({});
const [overrides, setOverrides] = useState<Record<string, string>>(() =>
loadSession(OVERRIDES_KEY, {}),
);
const [exportText, setExportText] = useState<string | null>(null);
const dragRef = useRef<{ dx: number; dy: number } | null>(null);
// Initialize display values from computed styles, then re-apply any saved
// session overrides so a reload-free session keeps its tuning.
useEffect(() => {
const initial: Record<string, string> = {};
for (const t of MOTION_TOKENS) initial[t.name] = readComputed(t.name);
for (const [name, value] of Object.entries(overrides)) {
document.documentElement.style.setProperty(name, value);
initial[name] = value;
}
setValues(initial);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const setToken = useCallback((name: string, value: string) => {
document.documentElement.style.setProperty(name, value);
setValues((v) => ({ ...v, [name]: value }));
setOverrides((o) => {
const next = { ...o, [name]: value };
saveSession(OVERRIDES_KEY, next);
return next;
});
}, []);
const resetAll = useCallback(() => {
for (const name of Object.keys(overrides)) {
document.documentElement.style.removeProperty(name);
}
setOverrides({});
saveSession(OVERRIDES_KEY, {});
const refreshed: Record<string, string> = {};
for (const t of MOTION_TOKENS) refreshed[t.name] = readComputed(t.name);
setValues(refreshed);
}, [overrides]);
const onHeaderPointerDown = useCallback(
(e: React.PointerEvent) => {
(e.target as HTMLElement).setPointerCapture(e.pointerId);
dragRef.current = { dx: e.clientX - pos.x, dy: e.clientY - pos.y };
},
[pos],
);
const onHeaderPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragRef.current) return;
const next = { x: e.clientX - dragRef.current.dx, y: e.clientY - dragRef.current.dy };
setPos(next);
}, []);
const onHeaderPointerUp = useCallback(() => {
if (dragRef.current) {
dragRef.current = null;
saveSession(POS_KEY, pos);
}
}, [pos]);
const buildExport = useCallback(() => {
const lines = MOTION_TOKENS.map((t) => {
const val = values[t.name] || readComputed(t.name);
return ` ${t.name}: ${val};`;
});
setExportText(`:root {\n${lines.join("\n")}\n}\n`);
}, [values]);
const grouped = useMemo(() => {
const map: Record<string, MotionTokenDef[]> = {};
for (const g of MOTION_TOKEN_GROUPS) map[g] = [];
for (const t of MOTION_TOKENS) map[t.group].push(t);
return map;
}, []);
return (
<div
className="fixed z-50 w-72 select-none rounded-lg border border-border bg-card text-card-foreground shadow-lg"
style={{ left: pos.x, top: pos.y }}
data-testid="task-chat-tweak-panel"
>
<div
className="flex cursor-grab items-center gap-2 rounded-t-lg border-b border-border bg-muted/50 px-2 py-1.5 text-xs font-medium active:cursor-grabbing"
onPointerDown={onHeaderPointerDown}
onPointerMove={onHeaderPointerMove}
onPointerUp={onHeaderPointerUp}
>
<GripHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
<span>Motion tweak panel</span>
<div className="ml-auto flex items-center gap-1">
<button type="button" title="Reset all" onClick={resetAll} className="rounded p-0.5 hover:bg-accent">
<RotateCcw className="h-3.5 w-3.5" />
</button>
<button
type="button"
title={minimized ? "Expand" : "Minimize"}
onClick={() => setMinimized((m) => !m)}
className="rounded p-0.5 hover:bg-accent"
>
{minimized ? <Plus className="h-3.5 w-3.5" /> : <Minus className="h-3.5 w-3.5" />}
</button>
</div>
</div>
{minimized ? null : (
<div className="max-h-(--sz-tweak-panel-max) overflow-y-auto p-2">
{MOTION_TOKEN_GROUPS.map((group) => (
<div key={group} className="mb-3">
<p className="mb-1 text-(length:--text-nano) font-semibold uppercase tracking-wide text-muted-foreground">{group}</p>
<div className="flex flex-col gap-2">
{grouped[group].map((t) => (
<div key={t.name} data-token={t.name} className="flex flex-col gap-1">
<div className="flex items-center justify-between text-(length:--text-micro)">
<span className="truncate font-mono text-muted-foreground">{t.name.replace("--motion-", "")}</span>
<span className="ml-2 shrink-0 tabular-nums">
{t.kind === "time" ? `${Math.round(parseCssTimeMs(values[t.name] ?? "0"))}ms` : ""}
</span>
</div>
{t.kind === "time" ? (
<input
type="range"
aria-label={t.name}
min={t.min ?? 0}
max={t.max ?? 1000}
step={t.step ?? 10}
value={Math.round(parseCssTimeMs(values[t.name] ?? "0"))}
onChange={(e) => setToken(t.name, `${e.target.value}ms`)}
className="h-1.5 w-full"
/>
) : (
<select
aria-label={t.name}
value={values[t.name] ?? ""}
onChange={(e) => setToken(t.name, e.target.value)}
className="w-full rounded border border-border bg-background px-1 py-0.5 text-(length:--text-micro)"
>
{EASING_PRESETS.every((p) => p.value !== (values[t.name] ?? "")) && values[t.name] ? (
<option value={values[t.name]}>{values[t.name]}</option>
) : null}
{EASING_PRESETS.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
)}
</div>
))}
</div>
</div>
))}
<div className="mt-1 flex gap-2">
<button
type="button"
onClick={buildExport}
className="flex flex-1 items-center justify-center gap-1 rounded border border-border px-2 py-1 text-xs hover:bg-accent"
>
<Copy className="h-3.5 w-3.5" />
Copy as @theme
</button>
</div>
{exportText ? (
<textarea
readOnly
data-testid="task-chat-tweak-export"
className="mt-2 h-32 w-full resize-none rounded border border-border bg-muted/40 p-2 font-mono text-(length:--text-nano)"
value={exportText}
onFocus={(e) => {
e.currentTarget.select();
void navigator.clipboard?.writeText(exportText).catch(() => {});
}}
/>
) : null}
</div>
)}
</div>
);
}
/**
* Build the :root export string from arbitrary token values. Exposed for the
* finish-line test that asserts export produces valid CSS. Kept in sync with
* the panel's own export.
*/
export function buildTweakExport(values: Record<string, string>): string {
const lines = MOTION_TOKENS.map((t) => ` ${t.name}: ${values[t.name] ?? MOTION_TOKEN_EXPORT_FALLBACK};`);
return `:root {\n${lines.join("\n")}\n}\n`;
}
@@ -0,0 +1,45 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { MOTION_TOKENS } from "./motion-tokens";
import { buildTweakExport } from "./TweakPanel";
/** Collect declared --motion-* custom properties from index.css (deduped). */
function declaredMotionTokens(): Set<string> {
const css = readFileSync(new URL("../../index.css", import.meta.url), "utf8");
const names = new Set<string>();
const re = /(--motion-[a-z0-9-]+)\s*:/g;
let m: RegExpExecArray | null;
while ((m = re.exec(css)) !== null) names.add(m[1]);
return names;
}
describe("motion token catalog", () => {
it("is 1:1 with the --motion-* tokens declared in index.css", () => {
const declared = declaredMotionTokens();
const catalog = new Set(MOTION_TOKENS.map((t) => t.name));
const missingFromCatalog = [...declared].filter((n) => !catalog.has(n));
const missingFromCss = [...catalog].filter((n) => !declared.has(n));
expect(missingFromCatalog, "tokens in index.css but not in the catalog").toEqual([]);
expect(missingFromCss, "tokens in the catalog but not in index.css").toEqual([]);
expect(catalog.size).toBe(declared.size);
});
});
describe("tweak panel export", () => {
it("emits a valid :root block containing every motion token", () => {
const values: Record<string, string> = {};
for (const t of MOTION_TOKENS) values[t.name] = t.kind === "time" ? "123ms" : "linear";
const out = buildTweakExport(values);
expect(out.startsWith(":root {")).toBe(true);
expect(out.trimEnd().endsWith("}")).toBe(true);
// Balanced braces.
expect((out.match(/{/g) ?? []).length).toBe((out.match(/}/g) ?? []).length);
// Every token present as a declaration.
for (const t of MOTION_TOKENS) {
expect(out).toContain(`${t.name}: ${values[t.name]};`);
}
});
});
@@ -0,0 +1,90 @@
/**
* Catalog of the redesign's motion tokens. This mirrors the `--motion-*`
* custom properties declared in ui/src/index.css :root, and is the list the dev
* tweak panel renders controls from.
*
* A finish-line test parses index.css for `--motion-*` declarations and asserts
* this catalog is 1:1 with them, so the two can never silently drift: add a
* token to index.css and you must add it here (and vice-versa).
*/
export type MotionTokenKind = "time" | "easing";
export interface MotionTokenDef {
/** CSS custom property name, including leading `--`. */
name: string;
/** Display group in the tweak panel. */
group: string;
kind: MotionTokenKind;
/** Slider bounds for time tokens (ms). */
min?: number;
max?: number;
step?: number;
}
export const MOTION_TOKENS: MotionTokenDef[] = [
// Easing primitives
{ name: "--motion-ease-out-expo", group: "Easing", kind: "easing" },
{ name: "--motion-ease-standard", group: "Easing", kind: "easing" },
{ name: "--motion-ease-out", group: "Easing", kind: "easing" },
{ name: "--motion-ease-scroll-pill-in", group: "Easing", kind: "easing" },
{ name: "--motion-ease-scroll-pill-out", group: "Easing", kind: "easing" },
{ name: "--motion-ease-in", group: "Easing", kind: "easing" },
// Duration primitives
{ name: "--motion-duration-instant", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 },
{ name: "--motion-duration-fast", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 },
{ name: "--motion-duration-base", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 },
{ name: "--motion-duration-slow", group: "Durations", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-duration-deliberate", group: "Durations", kind: "time", min: 0, max: 2000, step: 10 },
// Shared enter/exit/swap primitives owned by the decision/quicklook motion
// block in index.css.
{ name: "--motion-duration-enter", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 },
{ name: "--motion-duration-exit", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 },
{ name: "--motion-duration-swap", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 },
// State/component-scoped
{ name: "--motion-marker-enter", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-bubble-enter", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-cot-line-stagger", group: "States", kind: "time", min: 0, max: 300, step: 5 },
{ name: "--motion-cot-collapse", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-tool-enter", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-diff-reveal", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-status-enter", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-status-exit", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-approval-pulse", group: "States", kind: "time", min: 0, max: 3000, step: 20 },
{ name: "--motion-plan-entry-stagger", group: "States", kind: "time", min: 0, max: 300, step: 5 },
{ name: "--motion-plan-check", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-count-tween", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-streaming-cursor-blink", group: "States", kind: "time", min: 0, max: 3000, step: 20 },
{ name: "--motion-turn-fold", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-scroll-pill-enter", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-scroll-pill-exit", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-pane-glide", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
];
/** Common easing presets offered by the tweak panel's easing picker. */
export const EASING_PRESETS: { label: string; value: string }[] = [
{ label: "ease-out-expo (house)", value: "cubic-bezier(0.16, 1, 0.3, 1)" },
{ label: "standard (house)", value: "cubic-bezier(0.4, 0, 0.2, 1)" },
{ label: "ease-out", value: "cubic-bezier(0, 0, 0.2, 1)" },
{ label: "ease-in-out", value: "cubic-bezier(0.42, 0, 0.58, 1)" },
{ label: "linear", value: "linear" },
];
/** Ordered list of the panel's groups. */
export const MOTION_TOKEN_GROUPS: string[] = Array.from(
new Set(MOTION_TOKENS.map((t) => t.group)),
);
/** Fallback emitted by the tweak-panel export when a token has no value. */
export const MOTION_TOKEN_EXPORT_FALLBACK = "0ms";
/** Parse a CSS time value ("240ms" / "1.6s") to milliseconds. */
export function parseCssTimeMs(value: string): number {
const v = value.trim();
if (v.endsWith("ms")) return parseFloat(v);
if (v.endsWith("s")) return parseFloat(v) * 1000;
const n = parseFloat(v);
return Number.isFinite(n) ? n : 0;
}
@@ -0,0 +1,81 @@
/**
* Live adapter: normalize the existing IssueChatComment stream (including
* optimistic echoes) into the redesign's TaskChatItem[] model. This is the
* seam that lets the new render layer show live task data without touching the
* comment data pipeline. Rich agent-run streaming (thinking/tool/diff) is
* demonstrated in the harness and layered onto this adapter as a later step;
* the baseline live thread renders the author-typed message history + optimistic
* echo, which is the core legibility win.
*/
import type { Agent } from "@paperclipai/shared";
import type { IssueChatComment } from "@/lib/issue-chat-messages";
import type { TaskChatAuthorKind, TaskChatItem } from "./task-chat-model";
export interface TaskChatAdapterContext {
agentMap?: Map<string, Agent>;
userLabelMap?: ReadonlyMap<string, string> | null;
currentUserId?: string | null;
/**
* Capitalized mode chip for agent-authored bubbles ("Agent mode" / "Plan
* mode" / "Ask mode") — resolved per comment, so each reply is tagged with
* the mode its request actually ran under (not the issue's current mode).
*/
agentModeLabelFor?: (comment: IssueChatComment) => string | undefined;
}
function effectiveAgentId(comment: IssueChatComment): string | null {
return comment.authorAgentId ?? comment.derivedAuthorAgentId ?? null;
}
function authorKind(comment: IssueChatComment): TaskChatAuthorKind {
if (effectiveAgentId(comment)) return "agent";
if (comment.authorType === "user") return "human";
if (comment.authorType === "agent") return "agent";
return "system";
}
function formatTimestamp(value: unknown): string | undefined {
if (!value) return undefined;
const d = value instanceof Date ? value : new Date(value as string);
if (Number.isNaN(d.getTime())) return undefined;
return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
}
export function commentsToTaskChatItems(
comments: IssueChatComment[],
ctx: TaskChatAdapterContext = {},
): TaskChatItem[] {
const items: TaskChatItem[] = [];
for (const comment of comments) {
if (comment.deletedAt) continue;
const kind = authorKind(comment);
let authorName: string | undefined;
let agentIcon: string | null | undefined;
if (kind === "agent") {
const agentId = effectiveAgentId(comment);
authorName = (agentId && ctx.agentMap?.get(agentId)?.name) || "Agent";
agentIcon = agentId ? ctx.agentMap?.get(agentId)?.icon : undefined;
} else if (kind === "human") {
authorName =
(comment.authorUserId && ctx.userLabelMap?.get(comment.authorUserId)) || undefined;
}
const optimistic =
comment.clientStatus === "queued"
? "queued"
: comment.clientStatus === "pending"
? "pending"
: undefined;
items.push({
id: comment.id || comment.clientId || `${comment.createdAt}`,
kind: "message",
author: kind,
authorName,
text: comment.body,
timestamp: formatTimestamp(comment.createdAt),
optimistic,
agentIcon,
modeLabel: kind === "agent" ? ctx.agentModeLabelFor?.(comment) : undefined,
});
}
return items;
}
@@ -0,0 +1,185 @@
/**
* Synthetic fixtures for the Task Chat Redesign dev harness. No live agent is
* required: every state in the inventory maps to a deterministic scenario the
* harness renders and the finish-line test iterates. Tier-B states are driven
* entirely from here (live protocol wiring is a flagged dependency).
*/
import type { TaskChatItem, TaskChatPlan } from "./task-chat-model";
import type { TaskChatStateId } from "./task-chat-states";
export interface TaskChatScenario {
surface: "thread" | "plan";
items: TaskChatItem[];
plan?: TaskChatPlan;
}
const AGENT = "Atlas";
/** A short human→agent exchange used as context in several scenarios. */
function exchangePrefix(): TaskChatItem[] {
return [
{ id: "m-user-1", kind: "message", author: "human", text: "Add a rate limiter to the login route.", timestamp: "2:31 PM" },
];
}
const SAMPLE_PLAN: TaskChatPlan = {
revision: 2,
updatedAt: "2:33 PM",
entries: [
{ id: "p1", content: "Read the login route and existing middleware", status: "completed", priority: "medium" },
{ id: "p2", content: "Add a token-bucket rate limiter util", status: "in_progress", priority: "high" },
{ id: "p3", content: "Wire the limiter into POST /login", status: "pending", priority: "high" },
{ id: "p4", content: "Add tests for the limit + reset window", status: "pending", priority: "low" },
],
};
export function buildScenario(id: TaskChatStateId): TaskChatScenario {
switch (id) {
case "session-start":
return {
surface: "thread",
items: [
{ id: "mk-start", kind: "marker", variant: "session_start", label: "Session started", detail: "claude · Agent mode" },
...exchangePrefix(),
],
};
case "human-message":
return { surface: "thread", items: exchangePrefix() };
case "agent-message":
return {
surface: "thread",
items: [
...exchangePrefix(),
{ id: "m-agent-1", kind: "message", author: "agent", authorName: AGENT, agentIcon: "bot", modeLabel: "Agent mode", text: "On it — I'll add a token-bucket limiter and wire it into the login route.", timestamp: "2:31 PM" },
],
};
case "thinking":
return {
surface: "thread",
items: [
...exchangePrefix(),
{ id: "th-1", kind: "thinking", streaming: true, lines: ["The login route is in server/src/routes/auth.ts.", "There's already an ipRateLimit helper I can reuse.", "I'll add a per-account bucket keyed on the email."] },
],
};
case "responding":
return {
surface: "thread",
items: [
...exchangePrefix(),
{ id: "m-agent-stream", kind: "message", author: "agent", authorName: AGENT, streaming: true, text: "I found an existing ipRateLimit helper, so I'll extend it with a per-account bucket and" },
],
};
case "tool-call":
return {
surface: "thread",
items: [
{ id: "tool-1", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "in_progress" },
],
};
case "diff":
return {
surface: "thread",
items: [
{
id: "tool-diff", kind: "tool", name: "Edit", target: "server/src/routes/auth.ts", toolKind: "edit", status: "completed", decision: "allowed",
diff: {
path: "server/src/routes/auth.ts", added: 3, removed: 1,
lines: [
{ kind: "context", text: "router.post('/login', async (req, res) => {" },
{ kind: "remove", text: " const ok = await checkPassword(req.body);" },
{ kind: "add", text: " await rateLimiter.consume(req.body.email);" },
{ kind: "add", text: " const ok = await checkPassword(req.body);" },
{ kind: "add", text: " if (!ok) return res.status(401).end();" },
],
},
},
],
};
case "working":
return {
surface: "thread",
items: [
{ id: "st-working", kind: "status", status: "working", label: "Editing files", detail: "Edit · server/src/routes/auth.ts", toolName: "Edit", startedAtMs: Date.now() - 4200 },
],
};
case "running":
return {
surface: "thread",
items: [
{ id: "st-running", kind: "status", status: "running", label: "Running", detail: "no output for 3s — still running", startedAtMs: Date.now() - 12000, tokens: { used: 18240, size: 200000 } },
],
};
case "completed":
return {
surface: "thread",
items: [
...exchangePrefix(),
{
id: "turn-done",
kind: "turn",
settled: true,
summary: { durationLabel: "38s", toolCount: 3, added: 34, removed: 3, tokensLabel: "12.3k tokens" },
items: [
{ id: "th-done", kind: "thinking", lines: ["Read auth.ts", "Added rate-limiter util", "Wired into POST /login"] },
{ id: "tool-done-1", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "completed" },
{ id: "tool-done-2", kind: "tool", name: "Edit", target: "server/src/routes/auth.ts", toolKind: "edit", status: "completed", diff: { path: "server/src/routes/auth.ts", added: 34, removed: 3 } },
],
},
{ id: "m-done", kind: "message", author: "agent", authorName: AGENT, agentIcon: "bot", modeLabel: "Agent mode", text: "Done — added a per-account token-bucket limiter and wired it into the login route. Tests pass.", timestamp: "2:34 PM" },
],
};
case "awaiting-approval":
return {
surface: "thread",
items: [
{
id: "st-approval", kind: "status", status: "awaiting_approval", label: "Approve running a command?",
detail: "npm run migrate — modifies the database",
approval: {
toolName: "execute",
options: [
{ id: "reject", label: "Deny", kind: "reject_once" },
{ id: "allow-always", label: "Always allow", kind: "allow_always" },
{ id: "allow", label: "Allow once", kind: "allow_once" },
],
},
},
],
};
case "plan-todo":
return { surface: "plan", items: [], plan: SAMPLE_PLAN };
case "interrupted":
return {
surface: "thread",
items: [
{ id: "m-int", kind: "message", author: "agent", authorName: AGENT, text: "Starting the migration now…" },
{ id: "mk-int", kind: "marker", variant: "interrupted", label: "Interrupted", detail: "stopped by you at 2:35 PM" },
],
};
case "refused":
return {
surface: "thread",
items: [
{ id: "st-refused", kind: "status", status: "refused", label: "Turn ended: refusal", detail: "The agent declined to complete this request." },
],
};
case "truncated":
return {
surface: "thread",
items: [
{ id: "st-trunc", kind: "status", status: "truncated", label: "Turn ended: max tokens", detail: "Output was cut off — continue to resume.", tokens: { used: 199120, size: 200000 } },
],
};
case "live-token-cost":
return {
surface: "thread",
items: [
{ id: "usage-1", kind: "usage", usage: { used: 42800, size: 200000, inputTokens: 38200, outputTokens: 4600, costUsd: 0.1284 } },
],
};
default: {
const _never: never = id;
return _never;
}
}
}
@@ -0,0 +1,228 @@
/**
* Normalized presentation model for the Task Chat Redesign (flag:
* enableTaskChatRedesign).
*
* This is a deliberately small, protocol-agnostic model that the new render
* layer consumes. Two producers feed it:
* 1. The dev harness, via synthetic fixtures (task-chat-fixtures.ts).
* 2. The live thread, via an adapter over the existing comment/run props.
*
* Every field traces to a real agent-protocol concept (ACP SessionUpdate /
* acpx AcpRuntimeEvent / TranscriptEntry). See DESIGN.md and the plan's state
* inventory for the mapping. No timing/motion values live here — those are
* CSS motion tokens in ui/src/index.css.
*/
import type { IssueThreadInteraction } from "@/lib/issue-thread-interactions";
/** Who authored a thread row — the primary legibility signal. */
export type TaskChatAuthorKind = "human" | "agent" | "system";
/** ACP ToolCallStatus. */
export type TaskChatToolStatus = "pending" | "in_progress" | "completed" | "failed";
/** ACP PermissionOptionKind. */
export type TaskChatApprovalOptionKind =
| "allow_once"
| "allow_always"
| "reject_once"
| "reject_always";
/** ACP PlanEntryStatus. */
export type TaskChatPlanEntryStatus = "pending" | "in_progress" | "completed";
/** ACP PlanEntryPriority. */
export type TaskChatPlanEntryPriority = "high" | "medium" | "low";
export interface TaskChatApprovalOption {
id: string;
label: string;
kind: TaskChatApprovalOptionKind;
}
export interface TaskChatDiff {
path?: string;
added: number;
removed: number;
/** Optional unified-diff-ish lines for display only. */
lines?: Array<{ kind: "add" | "remove" | "context"; text: string }>;
}
export interface TaskChatTokenUsage {
/** ACP UsageUpdate.used — tokens in context. */
used: number;
/** ACP UsageUpdate.size — context window size. */
size: number;
inputTokens?: number;
outputTokens?: number;
costUsd?: number;
}
/** A human/agent/system message bubble. */
export interface TaskChatMessageItem {
id: string;
kind: "message";
author: TaskChatAuthorKind;
authorName?: string;
text: string;
timestamp?: string;
/** Show a streaming cursor and suppress collapse while true. */
streaming?: boolean;
/** Optimistic local echo state (matches IssueChatComment.clientStatus). */
optimistic?: "pending" | "queued";
/**
* Per-message mode tag ("Agent mode" / "Plan mode" / "Ask mode"). Shown as a
* chip in the agent header and under a sent human bubble (v6 decision).
*/
modeLabel?: string;
/** Assigned agent icon name (AgentIconName) for the avatar header. */
agentIcon?: string | null;
}
/** Collapsed chain-of-thought (ACP agent_thought_chunk). */
export interface TaskChatThinkingItem {
id: string;
kind: "thinking";
lines: string[];
streaming?: boolean;
/** When true, render collapsed ("Worked for N") with an expand affordance. */
collapsed?: boolean;
/** Human-readable elapsed label for the collapsed header. */
summaryLabel?: string;
}
/** A tool invocation row (ACP tool_call / tool_call_update). */
export interface TaskChatToolItem {
id: string;
kind: "tool";
name: string;
/** Raw ACP tool name pre-collapse ("mcp__server__tool") for taxonomy lookup. */
rawName?: string;
/** ACP ToolKind. */
toolKind?: string;
status: TaskChatToolStatus;
/**
* Summarized primary argument (file path, command, pattern…) rendered mono
* next to the tool name — the v7 toolrow "target".
*/
target?: string;
detail?: string;
diff?: TaskChatDiff;
/** Resolved permission decision badge, when one applied. */
decision?: "allowed" | "rejected";
}
/**
* A lifecycle state rendered as a status affordance — never a bubble.
* Covers both LIVE (running/working) and Tier-B (awaiting_approval,
* interrupted, refused, truncated) states.
*/
export interface TaskChatStatusItem {
id: string;
kind: "status";
status:
| "running"
| "working"
| "awaiting_approval"
| "interrupted"
| "refused"
| "truncated";
label: string;
detail?: string;
/** Raw tool name of the in-flight tail tool_call (drives the pill's icon). */
toolName?: string;
elapsedMs?: number;
/** Run start epoch ms; live states tick their own elapsed from this. */
startedAtMs?: number;
tokens?: TaskChatTokenUsage;
/** Present for awaiting_approval (ACP RequestPermissionRequest). */
approval?: { toolName: string; options: TaskChatApprovalOption[] };
}
/** A lifecycle divider (session start, interruption, turn boundary). */
export interface TaskChatMarkerItem {
id: string;
kind: "marker";
variant: "session_start" | "interrupted" | "turn_boundary";
label: string;
detail?: string;
}
/** A second-tier live token/cost readout (ACP UsageUpdate). */
export interface TaskChatUsageItem {
id: string;
kind: "usage";
usage: TaskChatTokenUsage;
}
/**
* An issue-thread interaction (plan confirmation, question card, suggested
* tasks…) interleaved chronologically into the thread. The payload is the
* existing control-plane IssueThreadInteraction — the render layer wraps the
* shared IssueThreadInteractionCard rather than re-modeling the five kinds.
*/
export interface TaskChatInteractionItem {
id: string;
kind: "interaction";
interaction: IssueThreadInteraction;
}
/** Items a turn can group — everything except another turn. */
export type TaskChatTurnChildItem =
| TaskChatMessageItem
| TaskChatThinkingItem
| TaskChatToolItem
| TaskChatStatusItem
| TaskChatMarkerItem
| TaskChatUsageItem;
/**
* One agent turn's activity (thinking/tools/diffs) grouped so a finished turn
* can fold into a one-line expandable summary ("✓ Worked · 38s · 3 tools ·
* +34 −3"). While `settled` is false the children render fully interleaved;
* on the live → settled transition the fold animates (~ --motion-turn-fold),
* while turns that load already-settled collapse instantly.
*/
export interface TaskChatTurnItem {
id: string;
kind: "turn";
items: TaskChatTurnChildItem[];
settled: boolean;
/** Animate the fold when settling (false = collapse instantly, e.g. history). */
animateFold?: boolean;
summary: {
/** e.g. "38s" — omitted when unknown. */
durationLabel?: string;
toolCount: number;
added: number;
removed: number;
/** e.g. "12.3k tokens" — omitted when unknown. */
tokensLabel?: string;
/** Failed/interrupted turns get a ✗ affordance instead of ✓. */
failed?: boolean;
};
}
export type TaskChatItem =
| TaskChatMessageItem
| TaskChatThinkingItem
| TaskChatToolItem
| TaskChatStatusItem
| TaskChatMarkerItem
| TaskChatUsageItem
| TaskChatInteractionItem
| TaskChatTurnItem;
/** A structured plan entry (ACP PlanEntry) for the Plans tab. */
export interface TaskChatPlanEntry {
id: string;
content: string;
status: TaskChatPlanEntryStatus;
priority: TaskChatPlanEntryPriority;
}
export interface TaskChatPlan {
/** Revision index (1-based); higher supersedes. */
revision: number;
entries: TaskChatPlanEntry[];
updatedAt?: string;
}
@@ -0,0 +1,52 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { TASK_CHAT_STATES } from "./task-chat-states";
import { buildScenario } from "./task-chat-fixtures";
import { TaskChatThreadView } from "./TaskChatThreadView";
import { TaskChatPlanView } from "./TaskChatPlanView";
import { ThemeProvider } from "@/context/ThemeContext";
/**
* Finish-line clause C: every state id in the inventory renders without error.
* Iterates the canonical enum so a new state can never be added without a
* fixture + a passing render here.
*/
describe("Task chat state inventory renders", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
});
for (const id of TASK_CHAT_STATES) {
it(`renders state "${id}" without error`, () => {
const scenario = buildScenario(id);
const root = createRoot(container);
expect(() => {
flushSync(() => {
root.render(
// ThemeProvider: bubbles render markdown via MarkdownBody, which
// reads the theme — mirror the real app's provider tree.
<ThemeProvider>
{scenario.surface === "plan" && scenario.plan ? (
<TaskChatPlanView plan={scenario.plan} />
) : (
<TaskChatThreadView items={scenario.items} scroll={false} />
)}
</ThemeProvider>,
);
});
}).not.toThrow();
expect(container.textContent && container.textContent.length).toBeTruthy();
flushSync(() => root.unmount());
});
}
});
@@ -0,0 +1,165 @@
/**
* Canonical state inventory for the Task Chat Redesign (flag:
* enableTaskChatRedesign).
*
* This list is the single source of truth for:
* - the dev harness state switcher (/dev/task-chat-lab), and
* - the finish-line test that asserts every state renders without error.
*
* Each id traces to a real agent-protocol state (plan Deliverable 1). `tier`
* marks whether the state already streams live ("live") or is emitted upstream
* but dropped by acpx today ("tier-b", driven by synthetic events in the
* harness, live wiring flagged). `surface` says where the state renders.
*/
export const TASK_CHAT_STATES = [
"session-start",
"human-message",
"agent-message",
"thinking",
"responding",
"tool-call",
"diff",
"working",
"running",
"completed",
"awaiting-approval",
"plan-todo",
"interrupted",
"refused",
"truncated",
"live-token-cost",
] as const;
export type TaskChatStateId = (typeof TASK_CHAT_STATES)[number];
export type TaskChatStateTier = "live" | "tier-b";
export type TaskChatStateSurface = "thread" | "plan";
export interface TaskChatStateMeta {
id: TaskChatStateId;
label: string;
tier: TaskChatStateTier;
surface: TaskChatStateSurface;
/** Real protocol source, quoted for the harness inspector. */
protocol: string;
}
export const TASK_CHAT_STATE_META: Record<TaskChatStateId, TaskChatStateMeta> = {
"session-start": {
id: "session-start",
label: "Session start",
tier: "live",
surface: "thread",
protocol: 'acpx.session → TranscriptEntry kind:"init"',
},
"human-message": {
id: "human-message",
label: "Human message",
tier: "live",
surface: "thread",
protocol: 'IssueComment authorType:"user"',
},
"agent-message": {
id: "agent-message",
label: "Agent message",
tier: "live",
surface: "thread",
protocol: "text_delta stream:output (ACP agent_message_chunk)",
},
thinking: {
id: "thinking",
label: "Thinking",
tier: "live",
surface: "thread",
protocol: "text_delta stream:thought (ACP agent_thought_chunk)",
},
responding: {
id: "responding",
label: "Responding (streaming)",
tier: "live",
surface: "thread",
protocol: "text_delta stream:output, streaming",
},
"tool-call": {
id: "tool-call",
label: "Tool call",
tier: "live",
surface: "thread",
protocol: "acpx.tool_call (ACP tool_call / tool_call_update)",
},
diff: {
id: "diff",
label: "Diff",
tier: "live",
surface: "thread",
protocol: 'ToolCallContent type:"diff" → TranscriptEntry kind:"diff"',
},
working: {
id: "working",
label: "Working",
tier: "live",
surface: "thread",
protocol: "heartbeat.run.progress + acpx.status",
},
running: {
id: "running",
label: "Running",
tier: "live",
surface: "thread",
protocol: 'message.status.type === "running"',
},
completed: {
id: "completed",
label: "Completed (collapsed)",
tier: "live",
surface: "thread",
protocol: "acpx.result (StopReason in subtype)",
},
"awaiting-approval": {
id: "awaiting-approval",
label: "Awaiting approval",
tier: "tier-b",
surface: "thread",
protocol: "ACP RequestPermissionRequest + PermissionOptionKind",
},
"plan-todo": {
id: "plan-todo",
label: "Plan / todo",
tier: "tier-b",
surface: "plan",
protocol: "ACP Plan { entries: PlanEntry[] }, PlanEntryStatus",
},
interrupted: {
id: "interrupted",
label: "Interrupted",
tier: "tier-b",
surface: "thread",
protocol: 'AcpRuntimeTurnResult.status:"cancelled" / StopReason "cancelled"',
},
refused: {
id: "refused",
label: "Refused",
tier: "tier-b",
surface: "thread",
protocol: 'StopReason "refusal"',
},
truncated: {
id: "truncated",
label: "Truncated",
tier: "tier-b",
surface: "thread",
protocol: 'StopReason "max_tokens" | "max_turn_requests"',
},
"live-token-cost": {
id: "live-token-cost",
label: "Live token / cost",
tier: "tier-b",
surface: "thread",
protocol: "ACP UsageUpdate { used, size, cost }",
},
};
export const TASK_CHAT_STATE_LIST: TaskChatStateMeta[] = TASK_CHAT_STATES.map(
(id) => TASK_CHAT_STATE_META[id],
);
@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import {
BookOpen,
Bot,
FileSearch,
Globe,
Pencil,
Plug,
SquareTerminal,
Wrench,
} from "lucide-react";
import { isGenericToolName, mcpToolSegment, toolTaxonomy } from "./tool-taxonomy";
describe("toolTaxonomy", () => {
it("maps each family to its icon and verb", () => {
expect(toolTaxonomy("Bash")).toEqual({
family: "terminal",
icon: SquareTerminal,
verbLabel: "Running a command",
});
expect(toolTaxonomy("Shell").family).toBe("terminal");
expect(toolTaxonomy("Grep")).toEqual({
family: "search",
icon: FileSearch,
verbLabel: "Searching",
});
expect(toolTaxonomy("Glob").family).toBe("search");
expect(toolTaxonomy("WebSearch").family).toBe("search");
expect(toolTaxonomy("Read")).toEqual({
family: "read",
icon: BookOpen,
verbLabel: "Reading files",
});
expect(toolTaxonomy("NotebookRead").family).toBe("read");
expect(toolTaxonomy("Edit")).toEqual({
family: "edit",
icon: Pencil,
verbLabel: "Editing files",
});
expect(toolTaxonomy("Write").family).toBe("edit");
expect(toolTaxonomy("MultiEdit").family).toBe("edit");
expect(toolTaxonomy("NotebookEdit").family).toBe("edit");
expect(toolTaxonomy("WebFetch")).toEqual({
family: "web",
icon: Globe,
verbLabel: "Fetching the web",
});
expect(toolTaxonomy("Task")).toEqual({
family: "agent",
icon: Bot,
verbLabel: "Delegating",
});
expect(toolTaxonomy("Agent").family).toBe("agent");
});
it("collapses mcp__ names to a Plug entry with the tool segment verb", () => {
const entry = toolTaxonomy("mcp__linear-server__search_issues");
expect(entry.family).toBe("mcp");
expect(entry.icon).toBe(Plug);
expect(entry.verbLabel).toBe("Using Search_issues");
});
it("falls back to the Wrench for unknown or empty names", () => {
expect(toolTaxonomy("SomethingNovel")).toEqual({
family: "other",
icon: Wrench,
verbLabel: "Working",
});
expect(toolTaxonomy("")).toEqual({ family: "other", icon: Wrench, verbLabel: "Working" });
expect(toolTaxonomy(undefined).icon).toBe(Wrench);
expect(toolTaxonomy(null).icon).toBe(Wrench);
});
});
describe("toolTaxonomy multi-word ACP titles", () => {
it("classifies by the first word", () => {
expect(toolTaxonomy("Read File").icon).toBe(BookOpen);
expect(toolTaxonomy("Edit File").icon).toBe(Pencil);
expect(toolTaxonomy("Write File").icon).toBe(Pencil);
expect(toolTaxonomy("Terminal").icon).toBe(SquareTerminal);
});
});
describe("isGenericToolName", () => {
it("flags acpx placeholder names, including status-suffixed variants", () => {
expect(isGenericToolName("tool call")).toBe(true);
expect(isGenericToolName("tool call (completed)")).toBe(true);
expect(isGenericToolName("Tool Call (failed)")).toBe(true);
expect(isGenericToolName("acp_tool")).toBe(true);
expect(isGenericToolName("tool")).toBe(true);
expect(isGenericToolName("")).toBe(true);
expect(isGenericToolName(undefined)).toBe(true);
});
it("keeps real names", () => {
expect(isGenericToolName("Terminal")).toBe(false);
expect(isGenericToolName("Read")).toBe(false);
expect(isGenericToolName("mcp__linear__search_issues")).toBe(false);
});
});
describe("mcpToolSegment", () => {
it("extracts and capitalizes the tool segment", () => {
expect(mcpToolSegment("mcp__linear-server__search_issues")).toBe("Search_issues");
expect(mcpToolSegment("mcp__gh__create_pr")).toBe("Create_pr");
});
it("returns null for non-mcp names", () => {
expect(mcpToolSegment("Bash")).toBeNull();
expect(mcpToolSegment("mcpish")).toBeNull();
});
});
@@ -0,0 +1,100 @@
/**
* Shared tool taxonomy: raw ACP tool_call names ("Bash", "Grep",
* "mcp__<server>__<tool>", …) → { family, icon, verbLabel } so the live status
* pill, the chat tool rows, and the classic transcript all agree on which
* glyph and verb a tool gets. The Wrench is reserved for genuinely unknown
* tools.
*/
import {
BookOpen,
Bot,
FileSearch,
Globe,
Pencil,
Plug,
SquareTerminal,
Wrench,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
export type ToolFamily =
| "terminal"
| "search"
| "read"
| "edit"
| "web"
| "agent"
| "mcp"
| "other";
export interface ToolTaxonomyEntry {
family: ToolFamily;
icon: LucideIcon;
/** Progressive verb for the status pill, without the trailing ellipsis. */
verbLabel: string;
}
/**
* Placeholder names that carry no tool identity. acpx fills a tool_call_update
* whose ACP payload omits `title` with the literal "tool call", and older
* stored logs carry "tool call (completed)" / "acp_tool" variants — none of
* these should ever displace a real name like "Terminal" or "Read".
*/
const GENERIC_TOOL_NAMES = new Set(["tool", "tool call", "tool_call", "acp_tool"]);
export function isGenericToolName(name: string | undefined | null): boolean {
const raw = (name ?? "")
.trim()
.toLowerCase()
.replace(/\s*\((?:pending|in_progress|completed|failed|cancelled)\)$/, "");
return !raw || GENERIC_TOOL_NAMES.has(raw);
}
/**
* Collapse an MCP tool name ("mcp__server__tool") to its tool segment,
* capitalized — the same rule toolDisplayName() applies. Returns null for
* non-MCP names.
*/
export function mcpToolSegment(name: string): string | null {
const mcp = name.match(/^mcp__[^_]+(?:_[^_]+)*__(.+)$/);
if (!mcp) return null;
const base = mcp[1];
return base.charAt(0).toUpperCase() + base.slice(1);
}
const FAMILY_META: Record<Exclude<ToolFamily, "mcp">, Omit<ToolTaxonomyEntry, "family">> = {
terminal: { icon: SquareTerminal, verbLabel: "Running a command" },
search: { icon: FileSearch, verbLabel: "Searching" },
read: { icon: BookOpen, verbLabel: "Reading files" },
edit: { icon: Pencil, verbLabel: "Editing files" },
web: { icon: Globe, verbLabel: "Fetching the web" },
agent: { icon: Bot, verbLabel: "Delegating" },
other: { icon: Wrench, verbLabel: "Working" },
};
function classify(n: string): Exclude<ToolFamily, "mcp"> {
// ACP titles are often multi-word ("Read File", "Edit File") — the first
// word carries the family.
const first = n.split(/[\s_:-]+/, 1)[0] ?? "";
if (first === "read" || n === "notebookread") return "read";
if (first === "edit" || first === "write" || n === "multiedit" || n === "notebookedit") {
return "edit";
}
if (first === "bash" || first === "shell" || first === "run" || n.includes("terminal")) {
return "terminal";
}
if (first === "grep" || first === "glob" || n.includes("search")) return "search";
if (n.includes("fetch") || n.includes("web") || n.includes("http")) return "web";
if (first === "task" || first === "agent") return "agent";
return "other";
}
/** Map a raw tool name to its taxonomy entry; unknown/empty names → Wrench. */
export function toolTaxonomy(name: string | undefined | null): ToolTaxonomyEntry {
const raw = (name ?? "").trim();
if (!raw) return { family: "other", ...FAMILY_META.other };
const mcpTool = mcpToolSegment(raw);
if (mcpTool) return { family: "mcp", icon: Plug, verbLabel: `Using ${mcpTool}` };
const family = classify(raw.toLowerCase());
return { family, ...FAMILY_META[family] };
}
@@ -0,0 +1,210 @@
import { describe, expect, it } from "vitest";
import type { TranscriptEntry } from "@/adapters";
import {
buildTurnSummary,
deriveRunStatusLabel,
toolDisplayName,
transcriptToTaskChatItems,
} from "./transcript-adapter";
const TS = "2026-07-31T12:00:00.000Z";
function toolCall(name: string, input?: unknown): TranscriptEntry {
return { kind: "tool_call", ts: TS, name, toolUseId: `tool-${name}`, input } as TranscriptEntry;
}
describe("deriveRunStatusLabel", () => {
it("labels a tail tool_call with the taxonomy verb and tool · target detail", () => {
const status = deriveRunStatusLabel([toolCall("Grep", { pattern: "ui/src/components" })]);
expect(status.label).toBe("Searching");
expect(status.detail).toBe("Grep · ui/src/components");
expect(status.toolName).toBe("Grep");
});
it("uses family verbs per tool", () => {
expect(deriveRunStatusLabel([toolCall("Bash", { command: "pnpm test" })]).label).toBe(
"Running a command",
);
expect(deriveRunStatusLabel([toolCall("Edit", { file_path: "a.ts" })]).label).toBe(
"Editing files",
);
expect(deriveRunStatusLabel([toolCall("mcp__linear__search_issues")]).label).toBe(
"Using Search_issues",
);
});
it("omits the target from the detail when the input has none", () => {
const status = deriveRunStatusLabel([toolCall("Read")]);
expect(status.detail).toBe("Read");
});
it("keeps Thinking / Responding / Running fallbacks", () => {
expect(
deriveRunStatusLabel([{ kind: "thinking", ts: TS, text: "hmm" } as TranscriptEntry]).label,
).toBe("Thinking");
expect(
deriveRunStatusLabel([{ kind: "assistant", ts: TS, text: "done" } as TranscriptEntry]).label,
).toBe("Responding");
expect(deriveRunStatusLabel([]).label).toBe("Running");
// A settled tool (tool_result at the tail) means "between tools" → Running.
expect(
deriveRunStatusLabel([
toolCall("Bash", { command: "ls" }),
{ kind: "tool_result", ts: TS, toolUseId: "tool-Bash", content: "ok" } as TranscriptEntry,
]).label,
).toBe("Running");
});
});
describe("toolDisplayName", () => {
it("collapses mcp names the same way the taxonomy does", () => {
expect(toolDisplayName("mcp__linear-server__search_issues")).toBe("Search_issues");
expect(toolDisplayName("bash")).toBe("Bash");
expect(toolDisplayName("")).toBe("Tool");
expect(toolDisplayName("tool")).toBe("Tool");
});
it("maps acpx placeholder names to the generic Tool label", () => {
expect(toolDisplayName("tool call")).toBe("Tool");
expect(toolDisplayName("tool call (completed)")).toBe("Tool");
expect(toolDisplayName("acp_tool")).toBe("Tool");
});
});
describe("transcriptToTaskChatItems tool_call updates", () => {
const opts = { runId: "run-1", running: false };
function update(toolUseId: string, status: string): TranscriptEntry {
// Mirrors what a persisted acpx tool_call_update line parses to: the
// literal placeholder name plus a synthesized { text, status } input.
return {
kind: "tool_call",
ts: TS,
name: "tool call",
toolUseId,
input: { text: `tool call (${status})`, status },
} as TranscriptEntry;
}
it("keeps the initial real name and target when a generic update arrives", () => {
const items = transcriptToTaskChatItems(
[
toolCall("Terminal", { command: "pnpm test" }),
update("tool-Terminal", "completed"),
{
kind: "tool_result",
ts: TS,
toolUseId: "tool-Terminal",
toolName: "tool call",
content: "ok",
} as TranscriptEntry,
],
opts,
);
expect(items).toHaveLength(1);
const tool = items[0];
expect(tool.kind).toBe("tool");
if (tool.kind !== "tool") return;
expect(tool.name).toBe("Terminal");
expect(tool.rawName).toBe("Terminal");
expect(tool.target).toBe("pnpm test");
expect(tool.status).toBe("completed");
});
it("keeps identity on retitle and uses the invocation as the target", () => {
// Real stored sequence: "Terminal" (pending) → retitle to the command →
// generic "tool call" completion updates.
const items = transcriptToTaskChatItems(
[
{ kind: "tool_call", ts: TS, name: "Terminal", toolUseId: "tc-2" } as TranscriptEntry,
{ kind: "tool_call", ts: TS, name: "ls -la", toolUseId: "tc-2" } as TranscriptEntry,
update("tc-2", "completed"),
],
opts,
);
expect(items).toHaveLength(1);
if (items[0].kind !== "tool") return;
expect(items[0].name).toBe("Terminal");
expect(items[0].rawName).toBe("Terminal");
expect(items[0].target).toBe("ls -la");
});
it("never renders the synthesized { text, status } input as a target", () => {
const items = transcriptToTaskChatItems([update("tc-orphan", "pending")], opts);
expect(items).toHaveLength(1);
if (items[0].kind !== "tool") return;
expect(items[0].target).toBeUndefined();
});
it("upgrades a generic-named call when a later entry carries the real name", () => {
const items = transcriptToTaskChatItems(
[
{ kind: "tool_call", ts: TS, name: "tool call", toolUseId: "tc-1" } as TranscriptEntry,
{ kind: "tool_call", ts: TS, name: "Read", toolUseId: "tc-1" } as TranscriptEntry,
],
opts,
);
expect(items).toHaveLength(1);
if (items[0].kind !== "tool") return;
expect(items[0].name).toBe("Read");
});
});
describe("buildTurnSummary tool counting", () => {
function statusEntry(toolUseId: string | undefined, status: string): TranscriptEntry {
return {
kind: "tool_call",
ts: TS,
name: "Bash",
toolUseId,
input: { text: `tool call (${status})`, status },
} as TranscriptEntry;
}
it("counts unique tool calls, not per-status transcript entries", () => {
// 4 real calls × 4 status changes each = 16 entries; the summary must
// match the 4 rows the expanded list renders.
const entries = ["tc-1", "tc-2", "tc-3", "tc-4"].flatMap((id) =>
["pending", "in_progress", "in_progress", "completed"].map((status) =>
statusEntry(id, status),
),
);
expect(entries).toHaveLength(16);
expect(buildTurnSummary(entries).toolCount).toBe(4);
});
it("counts id-less legacy entries once each", () => {
const entries = [
statusEntry(undefined, "completed"),
statusEntry(undefined, "completed"),
toolCall("Read"),
];
expect(buildTurnSummary(entries).toolCount).toBe(3);
});
});
describe("deriveRunStatusLabel with generic tail updates", () => {
it("recovers the real tool name from the call's initial entry", () => {
const status = deriveRunStatusLabel([
toolCall("Terminal", { command: "ls -la" }),
{
kind: "tool_call",
ts: TS,
name: "tool call",
toolUseId: "tool-Terminal",
} as TranscriptEntry,
]);
expect(status.label).toBe("Running a command");
expect(status.toolName).toBe("Terminal");
});
it("treats a tail retitle as the invocation, not the identity", () => {
const status = deriveRunStatusLabel([
{ kind: "tool_call", ts: TS, name: "Terminal", toolUseId: "tc-3" } as TranscriptEntry,
{ kind: "tool_call", ts: TS, name: "pnpm test", toolUseId: "tc-3" } as TranscriptEntry,
]);
expect(status.label).toBe("Running a command");
expect(status.toolName).toBe("Terminal");
expect(status.detail).toBe("Terminal · pnpm test");
});
});
@@ -0,0 +1,389 @@
/**
* Live adapter: map a run's streaming TranscriptEntry[] (from
* useLiveRunTranscripts — the same source the current thread consumes) into the
* redesign's TaskChatItem[]. This is what lets a real task stream
* thinking → tool → diff → responding while a run is in flight, rather than
* only showing the final message once the turn produces a comment.
*/
import type { TranscriptEntry } from "@/adapters";
import type {
TaskChatDiff,
TaskChatItem,
TaskChatToolItem,
TaskChatTurnItem,
} from "./task-chat-model";
import { isGenericToolName, mcpToolSegment, toolTaxonomy } from "./tool-taxonomy";
const TERMINAL_STATUSES = new Set([
"failed",
"timed_out",
"cancelled",
"interrupted",
"succeeded",
]);
export function isTerminalRunStatus(status: string | undefined | null): boolean {
return status != null && TERMINAL_STATUSES.has(status);
}
function diffKind(changeType: string): "add" | "remove" | "context" {
if (changeType === "add") return "add";
if (changeType === "remove") return "remove";
return "context";
}
/** Param keys probed (in order) for a tool call's one-line mono "target". */
const TARGET_KEYS = [
"file_path",
"path",
"notebook_path",
"command",
"pattern",
"query",
"url",
"prompt",
"description",
"skill",
"subject",
] as const;
const TARGET_MAX = 96;
function clip(text: string, max: number): string {
const oneLine = text.replace(/\s+/g, " ").trim();
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine;
}
/**
* Summarize a tool call's input into the v7 toolrow target: the most
* identifying parameter when the input is a keyed object, else a clipped
* rendering of the raw input. Returns undefined when there is nothing useful.
*/
export function summarizeToolInput(input: unknown): string | undefined {
if (input == null) return undefined;
if (typeof input === "string") return input.trim() ? clip(input, TARGET_MAX) : undefined;
if (typeof input !== "object") return clip(String(input), TARGET_MAX);
const record = input as Record<string, unknown>;
for (const key of TARGET_KEYS) {
const value = record[key];
if (typeof value === "string" && value.trim()) return clip(value, TARGET_MAX);
}
// The acpx log parser synthesizes { text, status } onto tool inputs from the
// event's summary line — presentation noise, not call parameters.
const entries = Object.entries(record).filter(
([k, v]) =>
(typeof v === "string" || typeof v === "number" || typeof v === "boolean") &&
k !== "text" &&
k !== "status",
);
if (entries.length === 0) return undefined;
return clip(entries.map(([k, v]) => `${k}: ${String(v)}`).join(", "), TARGET_MAX);
}
/**
* Display name for a tool call. Live acpx `tool_call` events carry real names
* ("Read", "Bash", mcp__server__tool); legacy stored logs may not — those fall
* back to a generic "Tool" row. MCP names collapse to their tool segment.
*/
export function toolDisplayName(name: string | undefined | null): string {
const raw = (name ?? "").trim();
if (isGenericToolName(raw)) return "Tool";
const mcp = mcpToolSegment(raw);
if (mcp) return mcp;
return raw.charAt(0).toUpperCase() + raw.slice(1);
}
/** "Thought for Ns" once a coalesced thinking group spans ≥1s. */
function thoughtDurationLabel(startTs: string | undefined, endTs: string): string | undefined {
if (!startTs) return undefined;
const start = Date.parse(startTs);
const end = Date.parse(endTs);
if (!Number.isFinite(start) || !Number.isFinite(end)) return undefined;
const secs = Math.round((end - start) / 1000);
if (secs < 1) return undefined;
return secs < 60 ? `Thought for ${secs}s` : `Thought for ${Math.floor(secs / 60)}m ${secs % 60}s`;
}
const DETAIL_MAX = 600;
/** Result content → the expandable mono detail block (clipped, trimmed). */
function formatToolResultDetail(content: unknown): string | undefined {
if (content == null) return undefined;
const text = typeof content === "string" ? content : String(content);
const trimmed = text.trim();
if (!trimmed) return undefined;
return trimmed.length > DETAIL_MAX ? `${trimmed.slice(0, DETAIL_MAX)}\n…` : trimmed;
}
interface TranscriptAdapterOptions {
runId: string;
agentName?: string;
/** True while the run is still in flight (drives streaming cursors). */
running: boolean;
}
/**
* Reduce a transcript into ordered items, coalescing consecutive thinking and
* assistant deltas and threading tool_result/diff onto their tool_call. Mirrors
* buildAssistantPartsFromTranscript's grouping, emitting our presentation model.
*/
export function transcriptToTaskChatItems(
entries: readonly TranscriptEntry[],
{ runId, agentName, running }: TranscriptAdapterOptions,
): TaskChatItem[] {
const items: TaskChatItem[] = [];
const toolIndexById = new Map<string, number>();
const thinkingStartTs = new Map<number, string>();
let lastToolIndex = -1;
let thinkingIndex = -1;
let messageIndex = -1;
const resetInline = () => {
thinkingIndex = -1;
messageIndex = -1;
};
for (const [i, entry] of entries.entries()) {
switch (entry.kind) {
case "thinking": {
if (!entry.text) break;
if (thinkingIndex >= 0) {
const it = items[thinkingIndex];
if (it.kind === "thinking") {
it.lines.push(...entry.text.split("\n"));
const startTs = thinkingStartTs.get(thinkingIndex);
const label = thoughtDurationLabel(startTs, entry.ts);
if (label) it.summaryLabel = label;
}
} else {
items.push({
id: `${runId}:think:${i}`,
kind: "thinking",
lines: entry.text.split("\n"),
streaming: running,
// Settled history folds its thinking behind the header (v7);
// the in-flight run streams it expanded.
collapsed: !running,
});
thinkingIndex = items.length - 1;
thinkingStartTs.set(thinkingIndex, entry.ts);
messageIndex = -1;
}
break;
}
case "assistant": {
if (!entry.text) break;
if (messageIndex >= 0) {
const it = items[messageIndex];
if (it.kind === "message") it.text += entry.text;
} else {
items.push({
id: `${runId}:msg:${i}`,
kind: "message",
author: "agent",
authorName: agentName,
text: entry.text,
streaming: running,
});
messageIndex = items.length - 1;
thinkingIndex = -1;
}
break;
}
case "tool_call": {
const toolCallId = entry.toolUseId || `tool-${i}`;
const existingIndex = toolIndexById.get(toolCallId);
const existing = existingIndex != null ? items[existingIndex] : undefined;
if (existing?.kind === "tool") {
// tool_call_update for a call already in the list: merge. Updates
// often omit the title (acpx fills in a literal "tool call"), so a
// generic name must never displace the initial call's real one. ACP
// also retitles a call to its invocation once known ("Terminal" →
// "ls -la"); that is detail for the target slot, not a new identity.
if (!isGenericToolName(entry.name)) {
if (isGenericToolName(existing.rawName)) {
existing.name = toolDisplayName(entry.name);
existing.rawName = entry.name ?? undefined;
} else if (!existing.target && entry.name !== existing.rawName) {
existing.target = clip(entry.name!, TARGET_MAX);
}
}
lastToolIndex = existingIndex!;
} else {
items.push({
id: `${runId}:tool:${toolCallId}`,
kind: "tool",
name: toolDisplayName(entry.name),
rawName: entry.name ?? undefined,
target: summarizeToolInput(entry.input),
status: "in_progress",
});
toolIndexById.set(toolCallId, items.length - 1);
lastToolIndex = items.length - 1;
}
resetInline();
break;
}
case "tool_result": {
const toolCallId = entry.toolUseId || `tool-result-${i}`;
const idx = toolIndexById.get(toolCallId);
if (idx != null) {
const existing = items[idx];
if (existing.kind === "tool") {
existing.status = entry.isError ? "failed" : "completed";
if (isGenericToolName(existing.rawName) && !isGenericToolName(entry.toolName)) {
existing.name = toolDisplayName(entry.toolName);
existing.rawName = entry.toolName;
}
if (!existing.detail) {
const detail = formatToolResultDetail(entry.content);
if (detail) existing.detail = detail;
}
}
}
resetInline();
break;
}
case "diff": {
const line = { kind: diffKind(entry.changeType), text: entry.text ?? "" };
if (lastToolIndex >= 0 && items[lastToolIndex].kind === "tool") {
const tool = items[lastToolIndex] as TaskChatToolItem;
const diff: TaskChatDiff = tool.diff ?? { added: 0, removed: 0, lines: [] };
diff.lines = diff.lines ?? [];
diff.lines.push(line);
if (line.kind === "add") diff.added += 1;
if (line.kind === "remove") diff.removed += 1;
tool.diff = diff;
} else {
items.push({
id: `${runId}:diff:${i}`,
kind: "tool",
name: "Edit",
status: "completed",
diff: {
added: line.kind === "add" ? 1 : 0,
removed: line.kind === "remove" ? 1 : 0,
lines: [line],
},
});
lastToolIndex = items.length - 1;
}
resetInline();
break;
}
// init / result / stderr / stdout / system / user carry no thread-visible
// content in the live turn (status is rendered separately).
default:
break;
}
}
return items;
}
function formatDurationLabel(ms: number): string | undefined {
if (!Number.isFinite(ms) || ms <= 0) return undefined;
const totalSec = Math.round(ms / 1000);
if (totalSec < 1) return "1s";
if (totalSec < 60) return `${totalSec}s`;
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
return sec === 0 ? `${min}m` : `${min}m ${sec}s`;
}
function formatTokensLabel(tokens: number): string | undefined {
if (!Number.isFinite(tokens) || tokens <= 0) return undefined;
const label = tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : `${tokens}`;
return `${label} tokens`;
}
/**
* Aggregate a turn's transcript into the folded one-line summary
* ("✓ Worked · 38s · 3 tools · +34 −3 · 12.3k tokens"). Duration prefers the
* caller-supplied run duration and falls back to the transcript's ts span.
*/
export function buildTurnSummary(
entries: readonly TranscriptEntry[],
opts: { durationMs?: number; failed?: boolean } = {},
): TaskChatTurnItem["summary"] {
const toolIds = new Set<string>();
let added = 0;
let removed = 0;
let tokens = 0;
for (const [i, entry] of entries.entries()) {
// Each status change of a call logs its own tool_call entry sharing the
// toolUseId; count unique calls so the folded summary matches the rows the
// expanded list renders (same `tool-${i}` fallback as the parser above).
if (entry.kind === "tool_call") toolIds.add(entry.toolUseId || `tool-${i}`);
else if (entry.kind === "diff") {
if (entry.changeType === "add") added += 1;
else if (entry.changeType === "remove") removed += 1;
} else if (entry.kind === "result") {
tokens += (entry.inputTokens || 0) + (entry.outputTokens || 0);
}
}
let durationMs = opts.durationMs;
if (durationMs == null && entries.length >= 2) {
const first = Date.parse(entries[0].ts);
const last = Date.parse(entries[entries.length - 1].ts);
if (Number.isFinite(first) && Number.isFinite(last)) durationMs = last - first;
}
return {
durationLabel: durationMs != null ? formatDurationLabel(durationMs) : undefined,
toolCount: toolIds.size,
added,
removed,
tokensLabel: formatTokensLabel(tokens),
failed: opts.failed || undefined,
};
}
/**
* Human-readable label for the live status pill from the tail of a transcript.
* A tail tool_call yields the taxonomy verb ("Searching", "Running a command")
* with tool + target as detail; `toolName` lets the pill show the family icon.
*/
export function deriveRunStatusLabel(entries: readonly TranscriptEntry[]): {
label: string;
detail?: string;
toolName?: string;
} {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry.kind === "tool_call") {
// A tail update may carry the generic placeholder name or a retitle to
// the invocation ("Terminal" → "ls -la"): the call's FIRST real name is
// its identity, and a differing later title is the invocation detail.
let name = entry.name;
let invocation: string | undefined;
if (entry.toolUseId) {
for (const prev of entries) {
if (prev === entry) break;
if (
prev.kind === "tool_call" &&
prev.toolUseId === entry.toolUseId &&
!isGenericToolName(prev.name)
) {
if (!isGenericToolName(entry.name) && entry.name !== prev.name) {
invocation = entry.name;
}
name = prev.name;
break;
}
}
}
const target =
summarizeToolInput(entry.input) ?? (invocation ? clip(invocation, TARGET_MAX) : undefined);
const display = toolDisplayName(name);
return {
label: toolTaxonomy(name).verbLabel,
detail: target ? `${display} · ${target}` : display,
toolName: name ?? undefined,
};
}
if (entry.kind === "tool_result") break;
if (entry.kind === "assistant") return { label: "Responding" };
if (entry.kind === "thinking") return { label: "Thinking" };
}
return { label: "Running" };
}