feat(ui): task-chat phase 2b — v7 pixel parity + live streaming

Close the gaps between the flagged redesign and the accepted v7 prototype,
verified against the v7 artifact side-by-side and a REAL streaming agent run:

- Bubbles: human = --liveness-blue #2563eb (was bg-primary near-black) white
  text right; agent comment = borderless gray --bubble-agent bubble (new token,
  light+dark) with v7 header (capsule avatar · bold name · one capitalized
  mode chip). Bubble bodies now render markdown via MarkdownBody (code chips,
  links, mentions) instead of raw text.
- Composer: per-mode v7 placeholders ("Message {agent} — describe what you
  want done…"), neutral focus-within ring, borderless + attach iconbtn,
  full "Agent/Plan/Ask mode" label on the status-chip trigger, icon-only
  32px ArrowUp send with hover scale.
- Full-page chat shell: with enableTaskChatRedesign ON, IssueDetail gates the
  legacy title (compact single-line editor instead) / description / sub-tasks
  table / plan decompositions / Documents (pinned plan) / Output / attachments
  wall / workspace card / artifact chips — thread owns the center column,
  bounded by new --tc-thread-max-h so TaskMessageScroller owns scrolling with
  the composer pinned (scroll viewport = absolute inset-0; percentage heights
  don't resolve against flex-sized block parents). Flag OFF proven legacy:
  0 redesign testids, all legacy sections present.
- Tool rows (v7 flat grammar): [glyph · name · mono target · hover chevron ·
  status]; transcript adapter now emits real tool names (toolDisplayName —
  mcp__ collapse + capitalization, tool_result.toolName backfill), summarized
  primary params (summarizeToolInput probes file_path/command/pattern/…),
  clipped mono result detail, generic "Tool" fallback for legacy stored logs.
- Streaming states: shimmer (.shimmer-text-muted variant) on live Thinking…
  header + flat pulse-dot statusline (running/working no longer card chrome);
  thinking settles to "Thought for Ns" (ts-span derived) and arrives collapsed
  in settled history; turn summary label + mono metrics per v7 runsum.

Verified: motion gate (24 files, seams), token gates, workspace tsc, 177 ui
tests incl. task-chat/PropertiesPanel/IssueChatThread/IssueDetail; live
streaming captured on a real claude_local run on the worktree instance
(:3093 scratch task) — thinking/text deltas, live statusline with elapsed,
auto-follow, and fold-to-summary on completion; flag-off isolation screenshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Fable 5 committed 2026-08-07 16:55:27 -07:00
1 parent 474c4138ac
commit e76584b95b
18 files changed
+394 -119

No files matched your search

+10 -5
View File
@@ -15,6 +15,7 @@ import type {
} from "@/components/task-chat/task-chat-model";
import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView";
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
import { workModeMetaFor } from "@/lib/work-mode-meta";
export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread>;
@@ -66,9 +67,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
issueStatus,
} = props;
const agentModeLabel = workModeMetaFor(issueWorkMode).label;
const commentItems = useMemo(
() => commentsToTaskChatItems(comments, { agentMap, userLabelMap, currentUserId }),
[comments, agentMap, userLabelMap, currentUserId],
() => commentsToTaskChatItems(comments, { agentMap, userLabelMap, currentUserId, agentModeLabel }),
[comments, agentMap, userLabelMap, currentUserId, agentModeLabel],
);
// Every run we might need a transcript for (history + live), deduped by id.
@@ -244,8 +246,11 @@ export function TaskChatThread(props: TaskChatThreadProps) {
}, [commentItems, runs, liveRun, transcriptByRun, linkedRunMetaById, firstCommentIdByRun, tick]);
return (
<div className="flex min-h-0 flex-1 flex-col" data-testid="task-chat-thread">
<div className="min-h-0 flex-1">
<div
className="flex h-(--tc-thread-max-h) min-h-0 flex-1 flex-col"
data-testid="task-chat-thread"
>
<div className="flex min-h-0 flex-1 flex-col">
{items.length === 0 ? (
<div className="px-3 py-10 text-center text-sm text-muted-foreground">{emptyMessage}</div>
) : (
@@ -253,7 +258,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
)}
</div>
{showComposer ? (
<div className="sticky bottom-0 z-10 flex flex-col gap-2 bg-background/80 px-1 pb-2 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="sticky bottom-0 z-10 mx-auto flex w-full max-w-3xl flex-col gap-2 bg-background/80 px-1 pb-2 pt-1 backdrop-blur supports-[backdrop-filter]:bg-background/60">
{composerAccessory}
<TaskChatComposer
onAdd={onAdd}
+19 -10
View File
@@ -1,4 +1,5 @@
import { cn } from "@/lib/utils";
import { MarkdownBody } from "@/components/MarkdownBody";
import type { TaskChatMessageItem } from "./task-chat-model";
interface TaskChatBubbleProps {
@@ -32,29 +33,35 @@ export function TaskChatBubble({ item }: TaskChatBubbleProps) {
const isHuman = item.author === "human";
return (
<div className={cn("tc-enter-bubble flex w-full flex-col gap-1", isHuman ? "items-end" : "items-start")}>
<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-1.5 px-1 text-xs text-muted-foreground">
<span className="flex items-center gap-2 px-1">
<span
aria-hidden="true"
data-testid="task-chat-agent-capsule"
className="inline-block h-5 w-2.5 shrink-0 rounded-full"
className="inline-block h-6 w-2.5 shrink-0 rounded-full"
style={{ background: capsuleGradient(item.agentGradient) }}
/>
<span className="font-medium">{item.authorName}</span>
{item.modeLabel ? <span className="font-normal">· {item.modeLabel}</span> : null}
<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) whitespace-pre-wrap break-words px-3 py-2 text-sm",
"max-w-(--pct-85) break-words px-3.5 py-2 text-sm",
isHuman
? "rounded-2xl rounded-br-sm bg-primary text-primary-foreground"
: "rounded-2xl rounded-bl-sm border border-border bg-card text-foreground",
? "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,
)}
>
{item.text}
<MarkdownBody softBreaks linkIssueReferences>
{item.text}
</MarkdownBody>
{item.streaming ? (
<span className="tc-cursor ml-0.5 inline-block h-3.5 w-1.5 translate-y-0.5 bg-current align-baseline" />
) : null}
@@ -64,7 +71,9 @@ export function TaskChatBubble({ item }: TaskChatBubbleProps) {
{item.optimistic === "queued" ? "Queued" : "Sending…"}
</span>
) : item.timestamp ? (
<span className="px-1 text-(length:--text-micro) text-muted-foreground">{item.timestamp}</span>
<span className="px-1 text-(length:--text-micro) text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100">
{item.timestamp}
</span>
) : null}
</div>
);
@@ -7,7 +7,7 @@ import {
type DragEvent as ReactDragEvent,
} from "react";
import { cn } from "@/lib/utils";
import { AlertTriangle, Check, ChevronDown, Loader2, Plus, Send } from "lucide-react";
import { AlertTriangle, ArrowUp, Check, ChevronDown, Loader2, Plus } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
@@ -58,6 +58,18 @@ const MODE_DESCRIPTION: Partial<Record<IssueWorkMode, string>> = {
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;
@@ -101,7 +113,7 @@ export function TaskChatComposer({
onWorkModeChange,
disabled = false,
disabledReason,
placeholder = "Reply",
placeholder,
onAttachImage,
onImageUpload,
enableReassign = false,
@@ -124,6 +136,8 @@ export function TaskChatComposer({
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, "\\$&");
@@ -246,7 +260,7 @@ export function TaskChatComposer({
return (
<div
className={cn(
"rounded-xl border border-border bg-card p-2 shadow-(--shadow-extract-7)",
"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}
@@ -270,7 +284,7 @@ export function TaskChatComposer({
}}
onPaste={handlePaste}
disabled={disabled}
placeholder={disabled ? (disabledReason ?? "Composer disabled") : placeholder}
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"
@@ -325,7 +339,7 @@ export function TaskChatComposer({
disabled={disabled}
title="Attach file"
aria-label="Attach file"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
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 />
@@ -343,7 +357,7 @@ export function TaskChatComposer({
data-testid="task-chat-composer-mode"
data-pending-work-mode={pendingMode}
>
{modeMeta.shortLabel}
{modeMeta.label}
<ChevronDown className="h-3 w-3" aria-hidden />
</button>
</DropdownMenuTrigger>
@@ -403,11 +417,16 @@ export function TaskChatComposer({
type="button"
onClick={() => void submit()}
disabled={disabled || submitting || body.trim().length === 0}
className="flex h-8 shrink-0 items-center gap-1.5 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50"
title="Send"
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"
>
<Send className="h-3.5 w-3.5" aria-hidden />
{submitting ? "Sending…" : "Send"}
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
) : (
<ArrowUp className="h-4 w-4" aria-hidden />
)}
</button>
</div>
</div>
@@ -25,7 +25,9 @@ interface TaskChatStatusPillProps {
/**
* Lifecycle state as a status affordance (never a bubble), with a defined
* enter transition. Awaiting-approval elevates with an attention pulse and
* 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) {
@@ -33,6 +35,29 @@ export function TaskChatStatusPill({ item, onApprovalDecision }: TaskChatStatusP
const awaiting = item.status === "awaiting_approval";
const elapsed = elapsedLabel(item.elapsedMs);
if (item.status === "running" || item.status === "working") {
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)"
/>
<span className="shimmer-text shimmer-text-muted font-medium">{item.label}…</span>
{item.detail ? (
<span className="min-w-0 truncate font-mono text-(length:--text-micro)">{item.detail}</span>
) : null}
<span className="ml-auto flex shrink-0 items-center gap-2 font-mono text-(length:--text-micro)">
{elapsed ? <span>{elapsed}</span> : null}
{item.tokens ? (
<span>
{item.tokens.used.toLocaleString()}/{item.tokens.size.toLocaleString()} ctx
</span>
) : null}
</span>
</div>
);
}
return (
<div
className={cn(
@@ -1,37 +1,40 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Brain, ChevronDown, ChevronRight } from "lucide-react";
import { ChevronRight } from "lucide-react";
import type { TaskChatThinkingItem } from "./task-chat-model";
/**
* Chain-of-thought (ACP agent_thought_chunk). While streaming it renders the
* live lines with a staggered enter; when the item reports `collapsed` it folds
* to a "Worked for N" header that can be re-expanded. Recessed styling keeps
* thinking visually subordinate to agent output.
* 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 collapsedByDefault = item.collapsed === true;
const settledLabel = item.summaryLabel ?? "Thought process";
return (
<div className="rounded-lg border border-dashed border-border/70 bg-muted/20 text-sm">
<div className="border-l-2 border-border pl-3 text-xs text-muted-foreground">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground"
aria-expanded={open}
className="flex items-center gap-1.5 py-0.5 transition-colors hover:text-foreground"
>
<Brain className="h-3.5 w-3.5" />
<span className="font-medium">
{collapsedByDefault ? (item.summaryLabel ?? "Thought process") : item.streaming ? "Thinking…" : "Thought process"}
</span>
{open ? <ChevronDown className="ml-auto h-3.5 w-3.5" /> : <ChevronRight className="ml-auto h-3.5 w-3.5" />}
<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 px-3 pb-2">
<div className="space-y-1 pb-1 pt-0.5">
{item.lines.map((line, i) => (
<p
key={i}
className={cn("tc-enter-cot-line text-xs italic text-muted-foreground")}
className="tc-enter-cot-line whitespace-pre-wrap"
style={{ animationDelay: `calc(var(--motion-cot-line-stagger) * ${i})` }}
>
{line}
@@ -67,7 +67,7 @@ export function TaskChatThreadView({
scroll = true,
}: TaskChatThreadViewProps) {
const body = (
<div className={cn("flex flex-col gap-3 px-3 py-4", className)}>
<div className={cn("mx-auto flex w-full max-w-3xl flex-col gap-3 px-4 py-4", className)}>
{items.map((item) => (
<div key={item.id}>{renderItem(item, onApprovalDecision)}</div>
))}
@@ -1,50 +1,107 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Check, Loader2, X, Wrench, ShieldCheck, ShieldX } from "lucide-react";
import {
Check,
ChevronRight,
FileSearch,
Globe,
Loader2,
Pencil,
ShieldCheck,
ShieldX,
SquareTerminal,
BookOpen,
Wrench,
X,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { TaskChatToolItem } from "./task-chat-model";
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-(--status-task-icon-done)" },
completed: { Icon: Check, spin: false, tone: "text-muted-foreground" },
failed: { Icon: X, spin: false, tone: "text-destructive" },
} as const;
/** Tool name → row glyph (v7 grammar: read/search/terminal/edit families). */
function toolIcon(name: string): LucideIcon {
const n = name.toLowerCase();
if (n === "read" || n === "notebookread") return BookOpen;
if (n === "grep" || n === "glob" || n === "search" || n.includes("search")) return FileSearch;
if (n === "bash" || n === "shell" || n.includes("terminal") || n === "run") return SquareTerminal;
if (n === "edit" || n === "write" || n === "notebookedit" || n === "multiedit") return Pencil;
if (n.includes("fetch") || n.includes("web") || n.includes("http")) return Globe;
return Wrench;
}
/**
* Tool invocation card. Renders the tool name + live status glyph, an optional
* resolved permission-decision badge, and an inline diff when present.
* 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 = toolIcon(item.name);
const [showDetail, setShowDetail] = useState(false);
const expandable = Boolean(item.detail);
return (
<div className="tc-enter-tool rounded-lg border border-border bg-card/60 text-sm">
<div className="flex min-w-0 items-center gap-2 px-3 py-2">
<Wrench className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 truncate font-mono text-xs">{item.name}</span>
{item.decision ? (
<span
className={cn(
"flex shrink-0 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>
<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}
<Icon className={cn("ml-auto h-4 w-4 shrink-0", tone, spin && "animate-spin")} />
</div>
{item.detail ? <p className="px-3 pb-2 text-xs text-muted-foreground">{item.detail}</p> : 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 border-t border-border px-3 py-2">
<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">
<span className="text-(--status-task-icon-done)">+{item.diff.added}</span>{" "}
<span className="text-destructive">−{item.diff.removed}</span>
<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 bg-muted/50 p-2 text-(length:--text-micro) leading-relaxed">
<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}
@@ -83,7 +83,9 @@ describe("TaskChatTurn", () => {
it("renders a settled turn folded with its summary line", () => {
renderTurn(SETTLED);
expect(summaryBtn()?.textContent).toContain("Worked · 38s");
// 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");
});
+18 -8
View File
@@ -1,6 +1,6 @@
import { useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { Check, ChevronDown, X } from "lucide-react";
import { Check, ChevronRight, X } from "lucide-react";
import type { TaskChatTurnItem, TaskChatTurnChildItem } from "./task-chat-model";
interface TaskChatTurnProps {
@@ -8,9 +8,9 @@ interface TaskChatTurnProps {
renderChild: (child: TaskChatTurnChildItem) => ReactNode;
}
/** "✓ Worked · 38s · 3 tools · +34 −3 · 12.3k tokens" (parts omitted when unknown). */
export function turnSummaryText(summary: TaskChatTurnItem["summary"]): string {
const parts: string[] = [summary.failed ? "Stopped" : "Worked"];
/** 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}`);
@@ -18,6 +18,13 @@ export function turnSummaryText(summary: TaskChatTurnItem["summary"]): string {
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.
*
@@ -52,17 +59,20 @@ export function TaskChatTurn({ item, renderChild }: TaskChatTurnProps) {
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="group flex items-center gap-1.5 px-1 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
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>{turnSummaryText(item.summary)}</span>
<ChevronDown className={cn("h-3 w-3 shrink-0 transition-transform", open ? "rotate-180" : null)} />
<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-3 pt-1">
<div className="flex flex-col gap-2 pt-1">
{item.items.map((child) => (
<div key={child.id}>{renderChild(child)}</div>
))}
@@ -140,7 +140,10 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
<div
ref={ref}
onScroll={handleScroll}
className={cn("h-full overflow-y-auto", className)}
// 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}
@@ -15,6 +15,12 @@ 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") — the issue's work mode at render time (per-comment
* mode attribution is Tier-B, out of scope here).
*/
agentModeLabel?: string;
}
function effectiveAgentId(comment: IssueChatComment): string | null {
@@ -78,6 +84,7 @@ export function commentsToTaskChatItems(
timestamp: formatTimestamp(comment.createdAt),
optimistic,
agentGradient,
modeLabel: kind === "agent" ? ctx.agentModeLabel : undefined,
});
}
return items;
@@ -73,7 +73,7 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario {
return {
surface: "thread",
items: [
{ id: "tool-1", kind: "tool", name: "read server/src/routes/auth.ts", toolKind: "read", status: "in_progress" },
{ id: "tool-1", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "in_progress" },
],
};
case "diff":
@@ -81,7 +81,7 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario {
surface: "thread",
items: [
{
id: "tool-diff", kind: "tool", name: "edit server/src/routes/auth.ts", toolKind: "edit", status: "completed", decision: "allowed",
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: [
@@ -121,8 +121,8 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario {
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 server/src/routes/auth.ts", toolKind: "read", status: "completed" },
{ id: "tool-done-2", kind: "tool", name: "edit server/src/routes/auth.ts", toolKind: "edit", status: "completed", diff: { path: "server/src/routes/auth.ts", added: 34, removed: 3 } },
{ 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, agentGradient: 3, 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" },
@@ -89,7 +89,7 @@ export interface TaskChatThinkingItem {
summaryLabel?: string;
}
/** A tool invocation card (ACP tool_call / tool_call_update). */
/** A tool invocation row (ACP tool_call / tool_call_update). */
export interface TaskChatToolItem {
id: string;
kind: "tool";
@@ -97,6 +97,11 @@ export interface TaskChatToolItem {
/** 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. */
@@ -7,6 +7,7 @@ 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.
@@ -32,11 +33,15 @@ describe("Task chat state inventory renders", () => {
expect(() => {
flushSync(() => {
root.render(
scenario.surface === "plan" && scenario.plan ? (
<TaskChatPlanView plan={scenario.plan} />
) : (
<TaskChatThreadView items={scenario.items} scroll={false} />
),
// 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();
@@ -31,6 +31,84 @@ function diffKind(changeType: string): "add" | "remove" | "context" {
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);
}
const entries = Object.entries(record).filter(
([, v]) => typeof v === "string" || typeof v === "number" || typeof v === "boolean",
);
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 (!raw || raw === "tool") return "Tool";
const mcp = raw.match(/^mcp__[^_]+(?:_[^_]+)*__(.+)$/);
const base = mcp?.[1] ?? raw;
return base.charAt(0).toUpperCase() + base.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;
@@ -49,6 +127,7 @@ export function transcriptToTaskChatItems(
): 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;
@@ -64,16 +143,24 @@ export function transcriptToTaskChatItems(
if (!entry.text) break;
if (thinkingIndex >= 0) {
const it = items[thinkingIndex];
if (it.kind === "thinking") it.lines.push(...entry.text.split("\n"));
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,
collapsed: false,
// 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;
@@ -102,7 +189,8 @@ export function transcriptToTaskChatItems(
const item: TaskChatToolItem = {
id: `${runId}:tool:${toolCallId}`,
kind: "tool",
name: entry.name || "tool",
name: toolDisplayName(entry.name),
target: summarizeToolInput(entry.input),
status: "in_progress",
};
if (toolIndexById.has(toolCallId)) {
@@ -123,8 +211,12 @@ export function transcriptToTaskChatItems(
const existing = items[idx];
if (existing.kind === "tool") {
existing.status = entry.isError ? "failed" : "completed";
if (entry.content && !existing.detail) {
existing.detail = String(entry.content).slice(0, 400);
if (existing.name === "Tool" && entry.toolName) {
existing.name = toolDisplayName(entry.toolName);
}
if (!existing.detail) {
const detail = formatToolResultDetail(entry.content);
if (detail) existing.detail = detail;
}
}
}
@@ -145,7 +237,7 @@ export function transcriptToTaskChatItems(
items.push({
id: `${runId}:diff:${i}`,
kind: "tool",
name: "edit",
name: "Edit",
status: "completed",
diff: {
added: line.kind === "add" ? 1 : 0,
@@ -230,7 +322,7 @@ export function deriveRunStatusLabel(entries: readonly TranscriptEntry[]): {
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry.kind === "tool_call") {
return { label: "Working", detail: `Using ${entry.name || "tool"}` };
return { label: "Working", detail: toolDisplayName(entry.name) };
}
if (entry.kind === "tool_result") break;
if (entry.kind === "assistant") return { label: "Responding" };
+17
View File
@@ -248,6 +248,14 @@
--tc-mode-agent: var(--status-task-backlog);
--tc-mode-plan: var(--status-task-todo);
--tc-mode-ask: #3b82f6;
/* Agent-comment bubble fill (v7 grammar): quiet gray card, no border. */
--bubble-agent: oklch(0.955 0 0);
/* Redesigned thread viewport bound: lets the thread own its scroll (auto-
follow + pinned composer) inside the page flow. 21rem ≈ chrome above the
thread (topbar + title row + tabs) plus page padding. */
--tc-thread-max-h: calc(100dvh - 21rem);
}
.dark {
@@ -259,6 +267,7 @@
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--bubble-agent: oklch(0.24 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
@@ -714,6 +723,14 @@
animation: shimmer-text-slide 2.5s linear infinite;
}
/* Muted variant (task-chat v7 activity rows): the sweep runs over the
muted-foreground tier so live "Thinking…"/"Working…" labels stay subordinate
to real content. */
.shimmer-text-muted {
--shimmer-base: var(--muted-foreground);
--shimmer-highlight: color-mix(in oklch, var(--muted-foreground) 30%, transparent);
}
@media (prefers-reduced-motion: reduce) {
.shimmer-text {
animation: none;
+39 -23
View File
@@ -1530,6 +1530,12 @@ function IssueDetailActivityTab({
export function IssueDetail() {
const { issueId } = useParams<{ issueId: string }>();
const { selectedCompanyId } = useCompany();
// Task Chat Redesign (flag: enableTaskChatRedesign): with the flag ON the
// thread owns the center column — the legacy title/description block,
// sub-tasks table, plan decompositions and Documents section are gated off
// (plan lives in the properties-pane Plan tab). Flag OFF renders the legacy
// page byte-identically.
const { enabled: taskChatShellEnabled } = useTaskChatRedesignEnabled();
const { openNewIssue } = useDialogActions();
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
const { setBreadcrumbs, setMobileToolbar } = useBreadcrumbs();
@@ -4498,7 +4504,7 @@ export function IssueDetail() {
value={issue.title}
onSave={(title) => updateIssue.mutateAsync({ title })}
as="h2"
className="text-xl font-bold"
className={taskChatShellEnabled ? "text-base font-semibold" : "text-xl font-bold"}
/>
<IssueMonitorBanner
@@ -4507,24 +4513,26 @@ export function IssueDetail() {
checkingNow={checkIssueMonitorNow.isPending}
/>
<InlineEditor
value={issue.description ?? ""}
onSave={(description) => updateIssue.mutateAsync({ description })}
as="p"
className="text-sm leading-7 text-foreground"
placeholder="Add a description..."
multiline
foldable
mentions={mentionOptions}
externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined}
imageUploadHandler={async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
}}
onDropFile={async (file) => {
await uploadAttachment.mutateAsync(file);
}}
/>
{taskChatShellEnabled ? null : (
<InlineEditor
value={issue.description ?? ""}
onSave={(description) => updateIssue.mutateAsync({ description })}
as="p"
className="text-sm leading-7 text-foreground"
placeholder="Add a description..."
multiline
foldable
mentions={mentionOptions}
externalReferences={externalObjectsState.isEnabled ? externalObjectsState.markdownReferences : undefined}
imageUploadHandler={async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
}}
onDropFile={async (file) => {
await uploadAttachment.mutateAsync(file);
}}
/>
)}
</div>
<PluginSlotOutlet
@@ -4568,7 +4576,7 @@ export function IssueDetail() {
missingBehavior="placeholder"
/>
{showRichSubIssuesSection ? (
{taskChatShellEnabled ? null : showRichSubIssuesSection ? (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium text-muted-foreground">Sub-tasks</h3>
@@ -4603,7 +4611,7 @@ export function IssueDetail() {
</div>
)}
{showPlanDecompositionsSection ? (
{!taskChatShellEnabled && showPlanDecompositionsSection ? (
<IssuePlanDecompositionsSection
issueId={issue.id}
issueIdentifier={issue.identifier}
@@ -4611,6 +4619,9 @@ export function IssueDetail() {
/>
) : null}
{/* Flag ON: attachments/work products/workspace live in the properties
pane (Artifacts tab) — the center column belongs to the thread. */}
{taskChatShellEnabled ? null : (
<IssueDocumentsSection
issue={issue}
canDeleteDocuments={Boolean(session?.user?.id)}
@@ -4638,7 +4649,9 @@ export function IssueDetail() {
agentMap={agentMap}
userProfileMap={userProfileMap}
/>
)}
{taskChatShellEnabled ? null : (
<IssueOutputSection
workProducts={workProducts}
onMediaClick={(item) => {
@@ -4653,8 +4666,9 @@ export function IssueDetail() {
setGalleryOpen(true);
}}
/>
)}
{attachmentsInitialLoading ? (
{taskChatShellEnabled ? null : attachmentsInitialLoading ? (
<IssueSectionSkeleton titleWidth="w-24" rows={2} />
) : hasAttachments ? (
<IssueAttachmentsSection
@@ -4692,6 +4706,7 @@ export function IssueDetail() {
onOpenChange={setGalleryOpen}
/>
{taskChatShellEnabled ? null : (
<IssueWorkspaceCard
issue={issue}
project={resolvedProject}
@@ -4699,8 +4714,9 @@ export function IssueDetail() {
onBrowseFiles={fileViewerEnabled ? () => setFileViewerPromptOpen(true) : undefined}
onOpenFileByPath={fileViewerEnabled ? () => setFileViewerPromptOpen(true) : undefined}
/>
)}
{fileViewerEnabled && issue.workProducts && issue.workProducts.length > 0 && (() => {
{!taskChatShellEnabled && fileViewerEnabled && issue.workProducts && issue.workProducts.length > 0 && (() => {
const workProductsWithFileRefs = issue.workProducts
.map((product) => ({ product, fileRef: extractWorkspaceFileRefFromWorkProduct(product) }))
.filter(({ fileRef }) => fileRef !== null);
+2 -2
View File
@@ -152,13 +152,13 @@ export function TaskChatLab() {
<span className="ml-auto font-mono text-(length:--text-micro) text-muted-foreground">{meta.protocol}</span>
</div>
<div ref={targetRef} className="min-h-0 flex-1" data-testid="task-chat-stage">
<div ref={targetRef} className="flex min-h-0 flex-1 flex-col" data-testid="task-chat-stage">
{scenario.surface === "plan" && scenario.plan ? (
<div className="mx-auto max-w-2xl px-4">
<TaskChatPlanView plan={scenario.plan} />
</div>
) : (
<div className="mx-auto h-full max-w-2xl">
<div className="mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col">
<TaskChatThreadView items={items} />
</div>
)}