mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
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:
1 parent
474c4138ac
commit
e76584b95b
18 files changed
+394
-119
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user