diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 65ab4e31a32..b32a7faae74 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -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; @@ -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 ( -
-
+
+
{items.length === 0 ? (
{emptyMessage}
) : ( @@ -253,7 +258,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { )}
{showComposer ? ( -
+
{composerAccessory} +
{!isHuman && item.authorName ? ( - + ) : null}
- {item.text} + + {item.text} + {item.streaming ? ( ) : null} @@ -64,7 +71,9 @@ export function TaskChatBubble({ item }: TaskChatBubbleProps) { {item.optimistic === "queued" ? "Queued" : "Sending…"} ) : item.timestamp ? ( - {item.timestamp} + + {item.timestamp} + ) : null}
); diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx index f8a4928f42b..e61c88700d7 100644 --- a/ui/src/components/task-chat/TaskChatComposer.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.tsx @@ -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> = { 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 (
@@ -343,7 +357,7 @@ export function TaskChatComposer({ data-testid="task-chat-composer-mode" data-pending-work-mode={pendingMode} > - {modeMeta.shortLabel} + {modeMeta.label} @@ -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" > - - {submitting ? "Sending…" : "Send"} + {submitting ? ( + + ) : ( + + )}
diff --git a/ui/src/components/task-chat/TaskChatStatusPill.tsx b/ui/src/components/task-chat/TaskChatStatusPill.tsx index 17c81b27a4f..cb5c6a20a0e 100644 --- a/ui/src/components/task-chat/TaskChatStatusPill.tsx +++ b/ui/src/components/task-chat/TaskChatStatusPill.tsx @@ -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 ( +
+ + {item.label}… + {item.detail ? ( + {item.detail} + ) : null} + + {elapsed ? {elapsed} : null} + {item.tokens ? ( + + {item.tokens.used.toLocaleString()}/{item.tokens.size.toLocaleString()} ctx + + ) : null} + +
+ ); + } + return (
+
{open ? ( -
+
{item.lines.map((line, i) => (

{line} diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx index 6581096ae68..b989a2051f8 100644 --- a/ui/src/components/task-chat/TaskChatThreadView.tsx +++ b/ui/src/components/task-chat/TaskChatThreadView.tsx @@ -67,7 +67,7 @@ export function TaskChatThreadView({ scroll = true, }: TaskChatThreadViewProps) { const body = ( -

+
{items.map((item) => (
{renderItem(item, onApprovalDecision)}
))} diff --git a/ui/src/components/task-chat/TaskChatToolCard.tsx b/ui/src/components/task-chat/TaskChatToolCard.tsx index ca8d45b706e..20081a6df6b 100644 --- a/ui/src/components/task-chat/TaskChatToolCard.tsx +++ b/ui/src/components/task-chat/TaskChatToolCard.tsx @@ -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 ( -
-
- - {item.name} - {item.decision ? ( - - {item.decision === "allowed" ? : } - {item.decision} - +
+
- {item.detail ?

{item.detail}

: null} + + {item.decision ? ( + + {item.decision === "allowed" ? : } + {item.decision} + + ) : null} + {expandable ? ( + + ) : null} + + + + {expandable && showDetail ? ( +
+          {item.detail}
+        
+ ) : null} {item.diff ? ( -
+
{item.diff.path ? {item.diff.path} : null} - - +{item.diff.added}{" "} - −{item.diff.removed} + + +{item.diff.added} −{item.diff.removed}
{item.diff.lines?.length ? ( -
+            
               {item.diff.lines.map((l, i) => (
                 
{ 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"); }); diff --git a/ui/src/components/task-chat/TaskChatTurn.tsx b/ui/src/components/task-chat/TaskChatTurn.tsx index be5d720d3c3..f871c0b1694 100644 --- a/ui/src/components/task-chat/TaskChatTurn.tsx +++ b/ui/src/components/task-chat/TaskChatTurn.tsx @@ -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" > - {turnSummaryText(item.summary)} - + {item.summary.failed ? "Stopped" : "Worked"} + {turnSummaryMetrics(item.summary) ? ( + {turnSummaryMetrics(item.summary)} + ) : null} + ) : null}
-
+
{item.items.map((child) => (
{renderChild(child)}
))} diff --git a/ui/src/components/task-chat/TaskMessageScroller.tsx b/ui/src/components/task-chat/TaskMessageScroller.tsx index eb839e0e450..4ece3b85a2c 100644 --- a/ui/src/components/task-chat/TaskMessageScroller.tsx +++ b/ui/src/components/task-chat/TaskMessageScroller.tsx @@ -140,7 +140,10 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
{children} diff --git a/ui/src/components/task-chat/task-chat-adapter.ts b/ui/src/components/task-chat/task-chat-adapter.ts index 5313adb8884..4f0fd09cad8 100644 --- a/ui/src/components/task-chat/task-chat-adapter.ts +++ b/ui/src/components/task-chat/task-chat-adapter.ts @@ -15,6 +15,12 @@ export interface TaskChatAdapterContext { agentMap?: Map; userLabelMap?: ReadonlyMap | 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; diff --git a/ui/src/components/task-chat/task-chat-fixtures.ts b/ui/src/components/task-chat/task-chat-fixtures.ts index 797c4990f67..311f7cc28d1 100644 --- a/ui/src/components/task-chat/task-chat-fixtures.ts +++ b/ui/src/components/task-chat/task-chat-fixtures.ts @@ -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" }, diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index 7d9cde4a60e..740ecb15960 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -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. */ diff --git a/ui/src/components/task-chat/task-chat-render.test.tsx b/ui/src/components/task-chat/task-chat-render.test.tsx index c78471d9da5..5807c6c632c 100644 --- a/ui/src/components/task-chat/task-chat-render.test.tsx +++ b/ui/src/components/task-chat/task-chat-render.test.tsx @@ -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 ? ( - - ) : ( - - ), + // ThemeProvider: bubbles render markdown via MarkdownBody, which + // reads the theme — mirror the real app's provider tree. + + {scenario.surface === "plan" && scenario.plan ? ( + + ) : ( + + )} + , ); }); }).not.toThrow(); diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index c60572f6588..9e35e8e6496 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -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; + 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(); + const thinkingStartTs = new Map(); 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" }; diff --git a/ui/src/index.css b/ui/src/index.css index 0b6be5653fa..fe35598f69c 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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; diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index e9b6705a940..119c1a6fe22 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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"} /> - 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 : ( + 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); + }} + /> + )}
- {showRichSubIssuesSection ? ( + {taskChatShellEnabled ? null : showRichSubIssuesSection ? (

Sub-tasks

@@ -4603,7 +4611,7 @@ export function IssueDetail() {
)} - {showPlanDecompositionsSection ? ( + {!taskChatShellEnabled && showPlanDecompositionsSection ? ( ) : null} + {/* Flag ON: attachments/work products/workspace live in the properties + pane (Artifacts tab) — the center column belongs to the thread. */} + {taskChatShellEnabled ? null : ( + )} + {taskChatShellEnabled ? null : ( { @@ -4653,8 +4666,9 @@ export function IssueDetail() { setGalleryOpen(true); }} /> + )} - {attachmentsInitialLoading ? ( + {taskChatShellEnabled ? null : attachmentsInitialLoading ? ( ) : hasAttachments ? ( + {taskChatShellEnabled ? null : ( 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); diff --git a/ui/src/pages/TaskChatLab.tsx b/ui/src/pages/TaskChatLab.tsx index 9e06430df9a..ab7e3f16b8d 100644 --- a/ui/src/pages/TaskChatLab.tsx +++ b/ui/src/pages/TaskChatLab.tsx @@ -152,13 +152,13 @@ export function TaskChatLab() { {meta.protocol}
-
+
{scenario.surface === "plan" && scenario.plan ? (
) : ( -
+
)}