feat(ui): task-chat redesign phase 2 — v5–v7 reconciliation + live-thread completeness

Reconcile the baseline with the accepted v5–v7 prototype decisions and finish
the live-thread work items (, plan §5), all behind enableTaskChatRedesign:

- Composer: v7 comp-bar (attach + far left, rectangle status-chip mode trigger
  via --tc-mode-* hues incl. ask-blue #3b82f6, assignee combobox left of send,
  all controls 32px, no shortcut hints), per-message mode with ⇧⇥ cycling,
  attachments via button/paste/drag-drop, reassign-on-send with implicit reopen.
- Scroll pill: stock shadcn MessageScrollerButton (32px round, arrow-only) with
  tokenized entry/exit motion and easing-aware re-follow (smooth glide cannot
  unpin the follow state; wheel/touch cancels).
- Turn fold: run activity grouped into TaskChatTurn — live turns stream
  expanded, settling folds to a one-line '✓ Worked · 38s · 3 tools · +34 −3'
  summary (--motion-turn-fold 380ms, instant for settled history, reduced-motion
  honored), settled turns interleave before their run's reply comment.
- Bubbles: agent header gains the textless brand-gradient capsule avatar +
  name · mode chip (deterministic agent→gradient hash).
- Properties pane: tab label Plans→Plan; drag-resizable (clamp ≥260px,
  dbl-click reset 322px, width remembered) + maximizable (320ms glide, spacer
  holds flex slot, 840px content cap) — flag-off DOM untouched.
- Motion: new tokens (turn-fold, scroll-pill enter/exit + easings, pane-glide)
  in index.css :root + reduced-motion zeroing + tweak-panel catalog entries.

Verified: workspace tsc clean; check-task-chat-motion + token gates clean;
53 task-chat/PropertiesPanel/gate tests, 29 experimental-settings tests and
21 server instance-settings tests pass.

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 d270ee47fd
commit 474c4138ac
17 files changed
+889 -846

No files matched your search

+1
View File
@@ -27,6 +27,7 @@ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const SCAN_DIRS = ["ui/src/components/task-chat"];
const SCAN_FILES = [
"ui/src/components/TaskChatThread.tsx",
"ui/src/components/PropertiesPanel.tsx",
"ui/src/components/TaskChatRedesignGate.tsx",
"ui/src/pages/TaskChatLab.tsx",
"ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx",
+3 -25
View File
@@ -47,19 +47,6 @@ export function PropertiesPanel() {
* variant. Everything below renders only when the flag is ON.
* ------------------------------------------------------------------------- */
/**
* Portal target in the redesigned pane's header bar: hosted content (the
* Properties | Plan | Artifacts tab strip) renders here, left of the window
* controls. See IssueProperties' flag-ON shell.
*/
export const PROPERTIES_PANE_HEADER_SLOT_ID = "properties-pane-header-slot";
/**
* Portal target pinned below the pane's scroll area: hosted content (the plan
* confirmation action bar) renders here so it stays visible while the pane
* body scrolls.
*/
export const PROPERTIES_PANE_FOOTER_SLOT_ID = "properties-pane-footer-slot";
const WIDTH_STORAGE_KEY = "taskChatRedesign.propertiesPaneWidth";
const DEFAULT_PANE_WIDTH = 322;
const MIN_PANE_WIDTH = 260;
@@ -353,17 +340,9 @@ function ResizablePropertiesPanel({
className={cn("flex-1 flex flex-col min-h-0", isFixed && "w-full")}
style={isFixed ? undefined : { width, minWidth: width }}
>
{/* The slot hosts whatever IssueProperties portals in — the tab strip
when Plan/Artifacts have content, else a plain "Properties" title;
window controls sit right. Vertical padding lives on the controls
cluster, not the bar, so the tab strip can stretch to the border
and its active underline hugs the header's bottom line. */}
<div className="flex items-center justify-between gap-2 px-4 border-b border-border">
<div
id={PROPERTIES_PANE_HEADER_SLOT_ID}
className="flex min-w-0 flex-1 items-center self-stretch"
/>
<div className="flex items-center gap-1 py-2">
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
<span className="text-sm font-medium">Properties</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-xs"
@@ -391,7 +370,6 @@ function ResizablePropertiesPanel({
{panelContent}
</div>
</ScrollArea>
<div id={PROPERTIES_PANE_FOOTER_SLOT_ID} className="shrink-0" />
</div>
</aside>
</>
+145 -25
View File
@@ -1,13 +1,18 @@
import { useEffect, useMemo, useState, type ComponentProps } from "react";
import { useEffect, useMemo, useRef, useState, type ComponentProps } from "react";
import { IssueChatThread } from "@/components/IssueChatThread";
import { useLiveRunTranscripts, type RunTranscriptSource } from "@/components/transcript/useLiveRunTranscripts";
import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter";
import {
buildTurnSummary,
deriveRunStatusLabel,
isTerminalRunStatus,
transcriptToTaskChatItems,
} from "@/components/task-chat/transcript-adapter";
import type { TaskChatItem } from "@/components/task-chat/task-chat-model";
import type {
TaskChatItem,
TaskChatTurnChildItem,
TaskChatTurnItem,
} from "@/components/task-chat/task-chat-model";
import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView";
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
@@ -25,8 +30,15 @@ export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread>;
* - the live run transcript (useLiveRunTranscripts, the same poll+websocket
* source the current thread uses) → the in-flight turn streams
* thinking → tool → diff → responding, capped by a live "running" status
* pill. Only the non-terminal run streams; once it finishes its output
* lands as a comment. flag-OFF remains byte-for-byte IssueChatThread.
* pill.
*
* Run activity is grouped into TaskChatTurnItem: the in-flight run renders as
* an unsettled (expanded) turn; when it terminates the same turn id flips
* settled, so TaskChatTurn plays the ~--motion-turn-fold collapse down to the
* one-line "✓ Worked · …" summary (runs already terminal at mount collapse
* instantly). Settled turns interleave before the run's first comment
* (comment.runId linkage) — activity above, the agent's reply bubble below.
* flag-OFF remains byte-for-byte IssueChatThread.
*/
export function TaskChatThread(props: TaskChatThreadProps) {
const {
@@ -46,6 +58,12 @@ export function TaskChatThread(props: TaskChatThreadProps) {
linkedRuns,
liveRuns,
activeRun,
onAttachImage,
imageUploadHandler,
enableReassign,
reassignOptions,
currentAssigneeValue,
issueStatus,
} = props;
const commentItems = useMemo(
@@ -104,30 +122,126 @@ export function TaskChatThread(props: TaskChatThreadProps) {
return () => window.clearInterval(id);
}, [liveRun]);
// Runs observed non-terminal while mounted: their turns ANIMATE the fold when
// they settle. Runs already terminal at mount collapse instantly.
const liveSeenRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (liveRun) liveSeenRef.current.add(liveRun.id);
}, [liveRun]);
// Each terminal run's turn anchors immediately before the run's first
// comment (its reply bubble), via the comment.runId linkage.
const firstCommentIdByRun = useMemo(() => {
const map = new Map<string, string>();
for (const comment of comments) {
if (comment.deletedAt || !comment.runId || !comment.id) continue;
if (!map.has(comment.runId)) map.set(comment.runId, comment.id);
}
return map;
}, [comments]);
const linkedRunMetaById = useMemo(() => {
const map = new Map<string, NonNullable<TaskChatThreadProps["linkedRuns"]>[number]>();
for (const run of linkedRuns ?? []) map.set(run.runId, run);
return map;
}, [linkedRuns]);
const items = useMemo<TaskChatItem[]>(() => {
if (!liveRun) return commentItems;
const entries = transcriptByRun.get(liveRun.id) ?? [];
const turn = transcriptToTaskChatItems(entries, {
runId: liveRun.id,
agentName: liveRun.agentName,
running: true,
});
const startedAt = liveRun.startedAt ? new Date(liveRun.startedAt).getTime() : null;
const elapsedMs = startedAt != null ? Math.max(0, Date.now() - startedAt) : undefined;
const queued = liveRun.status === "queued";
const status = queued ? { label: "Queued", detail: "Waiting to start" } : deriveRunStatusLabel(entries);
const statusItem: TaskChatItem = {
id: `${liveRun.id}:status`,
kind: "status",
status: "running",
label: status.label,
detail: status.detail,
elapsedMs,
};
return [...commentItems, ...turn, statusItem];
// Settled turns for every terminal run whose transcript we have. The
// transcript's assistant text is excluded — it already landed as the run's
// comment bubble; the turn holds the activity (thinking/tools/diffs).
const settledTurns: { turn: TaskChatTurnItem; anchorCommentId: string | null; order: number }[] = [];
for (const source of runs) {
if (!isTerminalRunStatus(source.status)) continue;
if (liveRun && source.id === liveRun.id) continue;
const entries = transcriptByRun.get(source.id) ?? [];
if (entries.length === 0) continue;
const meta = linkedRunMetaById.get(source.id);
const started = meta?.startedAt ? new Date(meta.startedAt).getTime() : NaN;
const finished = meta?.finishedAt ? new Date(meta.finishedAt).getTime() : NaN;
const durationMs =
Number.isFinite(started) && Number.isFinite(finished)
? Math.max(0, finished - started)
: undefined;
const children = transcriptToTaskChatItems(entries, {
runId: source.id,
agentName: meta?.agentName,
running: false,
}).filter((it): it is TaskChatTurnChildItem => it.kind !== "turn" && it.kind !== "message");
if (children.length === 0) continue;
settledTurns.push({
turn: {
id: `${source.id}:turn`,
kind: "turn",
settled: true,
animateFold: liveSeenRef.current.has(source.id),
items: children,
summary: buildTurnSummary(entries, {
durationMs,
failed: source.status !== "succeeded",
}),
},
anchorCommentId: firstCommentIdByRun.get(source.id) ?? null,
order: meta?.createdAt ? new Date(meta.createdAt).getTime() : 0,
});
}
settledTurns.sort((a, b) => a.order - b.order);
const turnsByAnchor = new Map<string, TaskChatTurnItem[]>();
const unanchored: TaskChatTurnItem[] = [];
for (const { turn, anchorCommentId } of settledTurns) {
if (anchorCommentId) {
const list = turnsByAnchor.get(anchorCommentId) ?? [];
list.push(turn);
turnsByAnchor.set(anchorCommentId, list);
} else {
unanchored.push(turn);
}
}
const out: TaskChatItem[] = [];
for (const item of commentItems) {
const preceding = turnsByAnchor.get(item.id);
if (preceding) out.push(...preceding);
out.push(item);
}
out.push(...unanchored);
if (liveRun) {
const entries = transcriptByRun.get(liveRun.id) ?? [];
const children = transcriptToTaskChatItems(entries, {
runId: liveRun.id,
agentName: liveRun.agentName,
running: true,
}).filter((it): it is TaskChatTurnChildItem => it.kind !== "turn");
if (children.length > 0) {
out.push({
id: `${liveRun.id}:turn`,
kind: "turn",
settled: false,
items: children,
summary: buildTurnSummary(entries),
});
}
const startedAt = liveRun.startedAt ? new Date(liveRun.startedAt).getTime() : null;
const elapsedMs = startedAt != null ? Math.max(0, Date.now() - startedAt) : undefined;
const queued = liveRun.status === "queued";
const status = queued
? { label: "Queued", detail: "Waiting to start" }
: deriveRunStatusLabel(entries);
out.push({
id: `${liveRun.id}:status`,
kind: "status",
status: "running",
label: status.label,
detail: status.detail,
elapsedMs,
});
}
return out;
// `tick` is intentionally a dependency so elapsed advances each second.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [commentItems, liveRun, transcriptByRun, tick]);
}, [commentItems, runs, liveRun, transcriptByRun, linkedRunMetaById, firstCommentIdByRun, tick]);
return (
<div className="flex min-h-0 flex-1 flex-col" data-testid="task-chat-thread">
@@ -147,6 +261,12 @@ export function TaskChatThread(props: TaskChatThreadProps) {
onWorkModeChange={onWorkModeChange}
disabled={Boolean(composerDisabledReason)}
disabledReason={composerDisabledReason}
onAttachImage={onAttachImage}
onImageUpload={imageUploadHandler}
enableReassign={enableReassign}
reassignOptions={reassignOptions}
currentAssigneeValue={currentAssigneeValue}
issueStatus={issueStatus}
/>
{footer}
</div>
@@ -2456,14 +2456,14 @@ export function IssueProperties({
// Flag OFF (or non-redesign hosts): today's stacked pane, byte-for-byte.
if (!taskChatRedesignEnabled) return propertiesBody;
// Flag ON: wrap the same body in a Properties | Plans | Artifacts tab shell.
// Plans/Artifacts get their real content in a later step; the shell itself is
// the deliverable here, and the Properties tab is unchanged.
// Flag ON: wrap the same body in a Properties | Plan | Artifacts tab shell
// (v5 decision: singular "Plan", Docs merged into Artifacts). The Properties
// tab is unchanged.
return (
<Tabs defaultValue="properties" className="flex min-h-0 flex-col gap-3">
<TabsList variant="line" className="w-full justify-start gap-1">
<TabsTrigger value="properties">Properties</TabsTrigger>
<TabsTrigger value="plans">Plans</TabsTrigger>
<TabsTrigger value="plans">Plan</TabsTrigger>
<TabsTrigger value="artifacts">Artifacts</TabsTrigger>
</TabsList>
<TabsContent value="properties">{propertiesBody}</TabsContent>
+22 -2
View File
@@ -5,10 +5,21 @@ interface TaskChatBubbleProps {
item: TaskChatMessageItem;
}
/** Brand agent-gradient count (mirrors AgentCapsule's token pairs in index.css). */
const GRADIENT_COUNT = 10;
function capsuleGradient(index: number | undefined): string {
const n = Math.trunc(index ?? 1);
const idx = ((((n - 1) % GRADIENT_COUNT) + GRADIENT_COUNT) % GRADIENT_COUNT) + 1;
return `linear-gradient(to bottom, var(--agent-${idx}a), var(--agent-${idx}b))`;
}
/**
* Author-typed message row — the primary legibility signal. Human messages sit
* right in a solid accent bubble; agent messages sit left in a neutral card
* bubble with an author header; system notices are centered and recede.
* bubble with a capsule-avatar author header (textless vertical brand-gradient
* capsule + name · mode chip, per the v3/v6 prototype decisions); system
* notices are centered and recede.
*/
export function TaskChatBubble({ item }: TaskChatBubbleProps) {
if (item.author === "system") {
@@ -23,7 +34,16 @@ export function TaskChatBubble({ item }: TaskChatBubbleProps) {
return (
<div className={cn("tc-enter-bubble flex w-full flex-col gap-1", isHuman ? "items-end" : "items-start")}>
{!isHuman && item.authorName ? (
<span className="px-1 text-xs font-medium text-muted-foreground">{item.authorName}</span>
<span className="flex items-center gap-1.5 px-1 text-xs text-muted-foreground">
<span
aria-hidden="true"
data-testid="task-chat-agent-capsule"
className="inline-block h-5 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>
) : null}
<div
className={cn(
@@ -4,281 +4,72 @@ import type { ReactElement } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildAgentMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
import { TaskChatComposer } from "./TaskChatComposer";
/**
* MDXEditor-in-jsdom weight (mirrors MarkdownEditor.test.tsx): the real editor
* is mocked with a contenteditable bridge — typing is simulated by setting
* textContent and dispatching an input event, imperative setMarkdown /
* insertMarkdown mutate the same content, and imagePlugin's config is captured
* so the inline upload handler can be exercised directly.
*/
const mdxEditorMockState = vi.hoisted(() => ({
imagePluginOptions: null as { imageUploadHandler?: (file: File) => Promise<string> } | null,
}));
vi.mock("@mdxeditor/editor", async () => {
const React = await import("react");
function setForwardedRef<T>(ref: React.ForwardedRef<T | null>, value: T | null) {
if (typeof ref === "function") {
ref(value);
return;
}
if (ref) {
(ref as React.MutableRefObject<T | null>).current = value;
}
}
interface MockHandle {
setMarkdown: (value: string) => void;
insertMarkdown: (value: string) => void;
focus: (callback?: () => void) => void;
}
const MDXEditor = React.forwardRef(function MockMDXEditor(
{
markdown,
onChange,
readOnly,
}: {
markdown: string;
onChange?: (value: string) => void;
readOnly?: boolean;
},
forwardedRef: React.ForwardedRef<MockHandle | null>,
) {
const editableRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef(markdown);
const onChangeRef = React.useRef(onChange);
onChangeRef.current = onChange;
const handle = React.useMemo<MockHandle>(() => ({
setMarkdown: (value: string) => {
contentRef.current = value;
if (editableRef.current) editableRef.current.textContent = value;
},
insertMarkdown: (value: string) => {
const next = contentRef.current ? `${contentRef.current}${value}` : value;
contentRef.current = next;
if (editableRef.current) editableRef.current.textContent = next;
onChangeRef.current?.(next);
},
focus: (callback?: () => void) => {
editableRef.current?.focus();
callback?.();
},
}), []);
React.useEffect(() => {
if (editableRef.current && contentRef.current) {
editableRef.current.textContent = contentRef.current;
}
setForwardedRef(forwardedRef, handle);
return () => setForwardedRef(forwardedRef, null);
}, [forwardedRef, handle]);
return (
<div
ref={editableRef}
data-testid="mdx-editor"
contentEditable={!readOnly}
suppressContentEditableWarning
onInput={(e) => {
const next = e.currentTarget.textContent ?? "";
contentRef.current = next;
onChangeRef.current?.(next);
}}
/>
);
});
return {
CodeMirrorEditor: () => null,
MDXEditor,
codeBlockPlugin: () => ({}),
codeMirrorPlugin: () => ({}),
createRootEditorSubscription$: Symbol("createRootEditorSubscription$"),
headingsPlugin: () => ({}),
imagePlugin: (options: { imageUploadHandler?: (file: File) => Promise<string> }) => {
mdxEditorMockState.imagePluginOptions = options;
return {};
},
linkDialogPlugin: () => ({}),
linkPlugin: () => ({}),
listsPlugin: () => ({}),
markdownShortcutPlugin: () => ({}),
quotePlugin: () => ({}),
realmPlugin: (plugin: unknown) => plugin,
tablePlugin: () => ({}),
thematicBreakPlugin: () => ({}),
};
});
vi.mock("../../lib/mention-deletion", () => ({
mentionDeletionPlugin: () => ({}),
}));
vi.mock("../../lib/paste-normalization", () => ({
pasteNormalizationPlugin: () => ({}),
}));
const SLASH_HREF = buildSkillMentionHref("skill-1", "deploy");
vi.mock("../../context/EditorAutocompleteContext", () => ({
useEditorAutocomplete: () => ({
slashCommands: [
{
id: "skill:skill-1",
kind: "skill",
skillId: "skill-1",
key: "deploy",
name: "Deploy",
slug: "deploy",
description: null,
href: SLASH_HREF,
aliases: ["deploy", "Deploy"],
},
],
}),
}));
let container: HTMLDivElement;
let root: Root | null = null;
let originalRangeRect: typeof Range.prototype.getBoundingClientRect;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
mdxEditorMockState.imagePluginOptions = null;
// jsdom ranges have zero-size rects; the mention menu measures the caret.
originalRangeRect = Range.prototype.getBoundingClientRect;
Range.prototype.getBoundingClientRect = () => ({
x: 32, y: 24, width: 12, height: 18, top: 24, right: 44, bottom: 42, left: 32,
toJSON: () => ({}),
});
});
afterEach(() => {
flushSync(() => root?.unmount());
root = null;
container.remove();
Range.prototype.getBoundingClientRect = originalRangeRect;
window.getSelection()?.removeAllRanges();
});
function render(ui: ReactElement) {
flushSync(() => root!.render(ui));
}
function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
)!.set!;
flushSync(() => {
nativeSetter.call(textarea, value);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
}
function pressEnter(textarea: HTMLTextAreaElement) {
flushSync(() => {
textarea.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
);
});
}
async function flushAsync() {
await new Promise((resolve) => setTimeout(resolve, 0));
}
function editable() {
return container.querySelector<HTMLDivElement>('[data-testid="mdx-editor"]')!;
function input() {
return container.querySelector<HTMLTextAreaElement>('[data-testid="task-chat-composer-input"]')!;
}
function sendButton() {
return container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-send"]')!;
}
/** Simulate typing: set the contenteditable's text and fire an input event. */
function typeText(value: string) {
const el = editable();
flushSync(() => {
el.textContent = value;
el.dispatchEvent(new Event("input", { bubbles: true }));
});
}
function pressKey(
key: string,
modifiers: { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean } = {},
) {
flushSync(() => {
editable().dispatchEvent(
new KeyboardEvent("keydown", { key, ...modifiers, bubbles: true, cancelable: true }),
);
});
}
function pasteFiles(files: File[]) {
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: { files, types: ["Files"] } });
flushSync(() => {
editable().dispatchEvent(paste);
});
return paste;
}
/** Place the caret at the end of the editable's first text node and announce it. */
async function placeCaretAtEnd() {
const textNode = editable().firstChild;
expect(textNode?.nodeType).toBe(Node.TEXT_NODE);
const selection = window.getSelection();
const range = document.createRange();
range.setStart(textNode!, textNode!.textContent!.length);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
flushSync(() => {
document.dispatchEvent(new Event("selectionchange"));
});
// Mention detection defers via requestAnimationFrame after input events.
await flushAsync();
await flushAsync();
}
function autocompleteOption(matchText: string) {
const menu = document.body.querySelector('[data-testid="mention-autocomplete-menu"]');
expect(menu).toBeTruthy();
const option = Array.from(menu!.querySelectorAll<HTMLButtonElement>('button[type="button"]'))
.find((node) => node.textContent?.includes(matchText));
expect(option).toBeTruthy();
return option!;
}
describe("TaskChatComposer", () => {
it("submits the trimmed body on Cmd+Enter and clears the draft", async () => {
it("submits the trimmed body on Enter and clears the draft", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
render(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
expect(sendButton().disabled).toBe(true);
typeText(" hello there ");
setTextareaValue(input(), " hello there ");
expect(sendButton().disabled).toBe(false);
pressKey("Enter", { metaKey: true });
pressEnter(input());
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("hello there", undefined, undefined);
expect(editable().textContent).toBe("");
});
it("submits on Ctrl+Enter", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
render(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
typeText("hello");
pressKey("Enter", { ctrlKey: true });
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("hello", undefined, undefined);
});
it("does not submit on plain Enter or Shift+Enter (newline stays with the editor)", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
render(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
typeText("line one");
pressKey("Enter");
pressKey("Enter", { shiftKey: true });
await flushAsync();
expect(onAdd).not.toHaveBeenCalled();
expect(editable().textContent).toBe("line one");
expect(input().value).toBe("");
});
it("cycles the pending mode with Shift+Tab and applies it on submit", async () => {
@@ -292,12 +83,16 @@ describe("TaskChatComposer", () => {
expect(chip.getAttribute("data-pending-work-mode")).toBe("standard");
expect(chip.textContent).toContain("Agent");
pressKey("Tab", { shiftKey: true });
flushSync(() => {
input().dispatchEvent(
new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true }),
);
});
expect(chip.getAttribute("data-pending-work-mode")).toBe("planning");
expect(chip.textContent).toContain("Plan");
typeText("do the plan");
pressKey("Enter", { metaKey: true });
setTextareaValue(input(), "do the plan");
pressEnter(input());
await flushAsync();
expect(onWorkModeChange).toHaveBeenCalledWith("planning");
@@ -315,8 +110,8 @@ describe("TaskChatComposer", () => {
/>,
);
typeText("wake up");
pressKey("Enter", { metaKey: true });
setTextareaValue(input(), "wake up");
pressEnter(input());
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("wake up", true, undefined);
@@ -336,234 +131,25 @@ describe("TaskChatComposer", () => {
expect(container.querySelector('[data-testid="task-chat-composer-attach"]')).not.toBeNull();
});
it("wires the editor's inline image upload to onAttachImage and returns the attachment URL", async () => {
it("uploads pasted files via onAttachImage and inserts the reference into the draft", async () => {
const onAttachImage = vi.fn().mockResolvedValue({
contentPath: "/attachments/shot.png",
originalFilename: "shot.png",
});
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" onAttachImage={onAttachImage} />);
const handler = mdxEditorMockState.imagePluginOptions?.imageUploadHandler;
expect(handler).toBeTypeOf("function");
const file = new File(["png-bytes"], "shot.png", { type: "image/png" });
await expect(handler!(file)).resolves.toBe("/attachments/shot.png");
expect(onAttachImage).toHaveBeenCalledWith(file);
// Inline images do not go through the attachment chip row.
expect(container.querySelector('[data-testid="task-chat-composer-attachments"]')).toBeNull();
});
it("does not register the image plugin without an upload handler", () => {
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" />);
expect(mdxEditorMockState.imagePluginOptions).toBeNull();
});
it("attaches pasted non-image files to the chip row and posts a link reference", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
const onAttachImage = vi.fn().mockResolvedValue({
contentPath: "/attachments/notes.txt",
originalFilename: "notes.txt",
});
render(<TaskChatComposer onAdd={onAdd} workMode="standard" onAttachImage={onAttachImage} />);
const file = new File(["plain"], "notes.txt", { type: "text/plain" });
const paste = pasteFiles([file]);
await flushAsync();
// The all-non-image paste is swallowed before the editor sees it.
expect(paste.defaultPrevented).toBe(true);
expect(onAttachImage).toHaveBeenCalledWith(file);
const chips = container.querySelector('[data-testid="task-chat-composer-attachments"]');
expect(chips?.textContent).toContain("notes.txt");
// base/attachment chip: kind · size description and a settled state.
expect(chips?.textContent).toContain("Text · 5 B");
expect(chips?.querySelector('[data-slot="attachment"]')?.getAttribute("data-state")).toBe("done");
// The editor stays prose-only; the reference rides along at submit time,
// and an attached chip alone is enough to enable send.
expect(editable().textContent ?? "").not.toContain("notes.txt");
const send = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-send"]')!;
expect(send.disabled).toBe(false);
flushSync(() => send.click());
await flushAsync();
expect(onAdd).toHaveBeenCalledWith(
"[notes.txt](/attachments/notes.txt)",
undefined,
undefined,
);
// Chips clear once the message posts.
expect(container.querySelector('[data-testid="task-chat-composer-attachments"]')).toBeNull();
});
it("appends file references after typed prose on submit", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
const onAttachImage = vi.fn().mockResolvedValue({
contentPath: "/attachments/notes.txt",
originalFilename: "notes.txt",
});
render(<TaskChatComposer onAdd={onAdd} workMode="standard" onAttachImage={onAttachImage} />);
typeText("Please review this.");
pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]);
await flushAsync();
const send = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-send"]')!;
flushSync(() => send.click());
await flushAsync();
expect(onAdd).toHaveBeenCalledWith(
"Please review this.\n\n[notes.txt](/attachments/notes.txt)",
undefined,
undefined,
);
});
it("blocks send while a file upload is pending, then includes the file once it lands", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
let resolveUpload!: (value: { contentPath: string; originalFilename: string }) => void;
const onAttachImage = vi.fn().mockReturnValue(
new Promise<{ contentPath: string; originalFilename: string }>((resolve) => {
resolveUpload = resolve;
}),
);
render(<TaskChatComposer onAdd={onAdd} workMode="standard" onAttachImage={onAttachImage} />);
typeText("Here is the file.");
pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]);
await flushAsync();
// Text alone would enable send, but the in-flight upload must hold it —
// otherwise the comment posts without the file the user selected.
expect(sendButton().disabled).toBe(true);
pressKey("Enter", { metaKey: true });
await flushAsync();
expect(onAdd).not.toHaveBeenCalled();
resolveUpload({ contentPath: "/attachments/notes.txt", originalFilename: "notes.txt" });
await flushAsync();
expect(sendButton().disabled).toBe(false);
flushSync(() => sendButton().click());
await flushAsync();
expect(onAdd).toHaveBeenCalledWith(
"Here is the file.\n\n[notes.txt](/attachments/notes.txt)",
undefined,
undefined,
);
});
it("blocks send while a failed attachment chip remains, then sends after it is removed", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
const onAttachImage = vi.fn().mockRejectedValue(new Error("Too large"));
render(<TaskChatComposer onAdd={onAdd} workMode="standard" onAttachImage={onAttachImage} />);
typeText("Here is the file.");
pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]);
await flushAsync();
// Text alone would enable send, but the failed chip must hold it —
// otherwise the comment posts without the file and the error chip clears.
expect(sendButton().disabled).toBe(true);
pressKey("Enter", { metaKey: true });
await flushAsync();
expect(onAdd).not.toHaveBeenCalled();
const remove = container.querySelector<HTMLButtonElement>('button[aria-label="Remove notes.txt"]');
flushSync(() => remove!.click());
expect(sendButton().disabled).toBe(false);
flushSync(() => sendButton().click());
await flushAsync();
expect(onAdd).toHaveBeenCalledWith("Here is the file.", undefined, undefined);
});
it("removes an attachment chip via its remove button", async () => {
const onAttachImage = vi.fn().mockResolvedValue({
contentPath: "/attachments/notes.txt",
originalFilename: "notes.txt",
});
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" onAttachImage={onAttachImage} />);
pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]);
await flushAsync();
const remove = container.querySelector<HTMLButtonElement>('button[aria-label="Remove notes.txt"]');
expect(remove).not.toBeNull();
flushSync(() => remove!.click());
expect(container.querySelector('[data-testid="task-chat-composer-attachments"]')).toBeNull();
});
it("shows an error-state chip when a non-image upload fails", async () => {
const onAttachImage = vi.fn().mockRejectedValue(new Error("Too large"));
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" onAttachImage={onAttachImage} />);
pasteFiles([new File(["plain"], "notes.txt", { type: "text/plain" })]);
await flushAsync();
const chips = container.querySelector('[data-testid="task-chat-composer-attachments"]');
expect(chips?.querySelector('[data-slot="attachment"]')?.getAttribute("data-state")).toBe("error");
expect(chips?.textContent).toContain("Too large");
});
it("leaves pasted images to the editor's image plugin (no chip, paste not swallowed)", async () => {
const onAttachImage = vi.fn().mockResolvedValue({
contentPath: "/attachments/notes.txt",
originalFilename: "notes.txt",
});
render(<TaskChatComposer onAdd={vi.fn()} workMode="standard" onAttachImage={onAttachImage} />);
const image = new File(["png-bytes"], "shot.png", { type: "image/png" });
const text = new File(["plain"], "notes.txt", { type: "text/plain" });
const paste = pasteFiles([image, text]);
await flushAsync();
// Mixed paste: the non-image is chipped, the image flows through untouched.
expect(paste.defaultPrevented).toBe(false);
const chips = container.querySelector('[data-testid="task-chat-composer-attachments"]')!;
expect(chips.textContent).toContain("notes.txt");
expect(chips.textContent).not.toContain("shot.png");
expect(onAttachImage).toHaveBeenCalledTimes(1);
expect(onAttachImage).toHaveBeenCalledWith(text);
});
it("inserts an @-mention from the autocomplete menu and posts it", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
render(
<TaskChatComposer
onAdd={onAdd}
workMode="standard"
mentions={[{ id: "agent:a1", kind: "agent", name: "Clippy", agentId: "a1" }]}
/>,
);
typeText("@Cl");
await placeCaretAtEnd();
const option = autocompleteOption("Clippy");
const paste = new Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", { value: { files: [file] } });
flushSync(() => {
option.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
input().dispatchEvent(paste);
});
await flushAsync();
const expected = `[@Clippy](${buildAgentMentionHref("a1", null)}) `;
expect(editable().textContent).toBe(expected);
pressKey("Enter", { metaKey: true });
await flushAsync();
expect(onAdd).toHaveBeenCalledWith(expected.trim(), undefined, undefined);
});
it("inserts a /-command from the autocomplete menu", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
render(<TaskChatComposer onAdd={onAdd} workMode="standard" />);
typeText("/dep");
await placeCaretAtEnd();
const option = autocompleteOption("/deploy");
flushSync(() => {
option.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
});
await flushAsync();
expect(editable().textContent).toBe(`[/deploy](${SLASH_HREF}) `);
expect(onAttachImage).toHaveBeenCalledWith(file);
expect(input().value).toContain("![shot.png](/attachments/shot.png)");
const chips = container.querySelector('[data-testid="task-chat-composer-attachments"]');
expect(chips?.textContent).toContain("shot.png");
});
it("shows the assignee combobox only when reassign is enabled, with the current label", () => {
+329 -48
View File
@@ -1,27 +1,99 @@
import { useState } from "react";
import {
useRef,
useState,
type ChangeEvent,
type ClipboardEvent as ReactClipboardEvent,
type CSSProperties,
type DragEvent as ReactDragEvent,
} from "react";
import { cn } from "@/lib/utils";
import { Check, ChevronDown, Send } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { workModeMetaFor, workModeMetaList } from "@/lib/work-mode-meta";
import type { IssueWorkMode } from "@paperclipai/shared";
import { AlertTriangle, Check, ChevronDown, Loader2, Plus, Send } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { nextWorkMode, workModeMetaFor, workModeMetaList } from "@/lib/work-mode-meta";
import type { InlineEntityOption } from "@/components/InlineEntitySelector";
import type { IssueAttachment, IssueWorkMode } from "@paperclipai/shared";
/** Structurally identical to IssueChatThread's module-private CommentReassignment. */
interface CommentReassignment {
assigneeAgentId: string | null;
assigneeUserId: string | null;
}
interface TaskChatComposerProps {
onAdd: (body: string) => Promise<void> | void;
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void> | void;
workMode: IssueWorkMode;
onWorkModeChange?: (mode: IssueWorkMode) => Promise<void> | void;
disabled?: boolean;
disabledReason?: string | null;
placeholder?: string;
/** Preferred upload path: attaches the file to the task (mirrors legacy). */
onAttachImage?: (file: File) => Promise<IssueAttachment | void>;
/** Fallback upload path: returns a URL for inline image markdown. */
onImageUpload?: (file: File) => Promise<string>;
enableReassign?: boolean;
reassignOptions?: InlineEntityOption[];
currentAssigneeValue?: string;
issueStatus?: string;
}
/** Per-mode hue token (see ui/src/index.css `--tc-mode-*`). */
const MODE_HUE: Partial<Record<IssueWorkMode, string>> = {
standard: "var(--tc-mode-agent)",
planning: "var(--tc-mode-plan)",
ask: "var(--tc-mode-ask)",
};
function modeHue(mode: IssueWorkMode): string {
return MODE_HUE[mode] ?? "var(--tc-mode-agent)";
}
const MODE_DESCRIPTION: Partial<Record<IssueWorkMode, string>> = {
standard: "Make changes and run work",
planning: "Draft a plan before acting",
ask: "Answer questions only, no changes",
};
type ComposerAttachment = {
id: string;
name: string;
status: "uploading" | "attached" | "error";
error?: string;
};
/** Local duplicate of IssueChatThread's module-private helper (same rule). */
function shouldImplicitlyReopenComment(issueStatus: string | undefined, assigneeValue: string) {
const resumesToTodo = issueStatus === "done" || issueStatus === "cancelled" || issueStatus === "blocked";
return resumesToTodo && assigneeValue.startsWith("agent:");
}
function parseAssigneeValue(value: string): CommentReassignment | undefined {
if (value.startsWith("agent:")) {
const id = value.slice("agent:".length);
return id ? { assigneeAgentId: id, assigneeUserId: null } : undefined;
}
if (value.startsWith("user:")) {
const id = value.slice("user:".length);
return id ? { assigneeAgentId: null, assigneeUserId: id } : undefined;
}
return undefined;
}
function hasFilePayload(evt: ReactDragEvent<HTMLDivElement>) {
return Array.from(evt.dataTransfer?.types ?? []).includes("Files");
}
/**
* Lightweight composer for the redesigned thread. Covers the baseline
* essentials: optimistic send (delegates to the existing onAdd mutation, which
* already echoes locally) and the three modes — Agent / Plan / Ask — reusing
* the app's work-mode metadata. The selected mode is applied on submit, mirroring
* the existing composer's semantics. Advanced affordances (attachments, reassign,
* mentions, drafts) are deferred; they're demonstrated on the legacy composer and
* layered on post-baseline.
* Composer for the redesigned thread (v7 spec): textarea over a 32px comp-bar
* of [attach] [mode chip] … [assignee] [send]. The mode chip is a status-chip
* rectangle carrying the pending mode's hue; the composer chrome itself stays
* neutral. Shift+Tab cycles modes; the picked mode is applied on submit.
* Attachments upload via `onAttachImage` (or the inline `onImageUpload`
* fallback) from the + button, paste, or drag-drop.
*/
export function TaskChatComposer({
onAdd,
@@ -30,24 +102,140 @@ export function TaskChatComposer({
disabled = false,
disabledReason,
placeholder = "Reply",
onAttachImage,
onImageUpload,
enableReassign = false,
reassignOptions,
currentAssigneeValue = "",
issueStatus,
}: TaskChatComposerProps) {
const [body, setBody] = useState("");
const [submitting, setSubmitting] = useState(false);
const [pendingMode, setPendingMode] = useState<IssueWorkMode>(workMode);
const [menuOpen, setMenuOpen] = useState(false);
const [pendingAssignee, setPendingAssignee] = useState<string | null>(null);
const [attachments, setAttachments] = useState<ComposerAttachment[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const dragDepthRef = useRef(0);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const modeMeta = workModeMetaFor(pendingMode);
const ModeIcon = modeMeta.icon;
const canAcceptFiles = Boolean(onAttachImage || onImageUpload);
const showAssignee = Boolean(enableReassign && reassignOptions && reassignOptions.length > 0);
const assigneeValue = pendingAssignee ?? currentAssigneeValue;
const assigneeLabel =
reassignOptions?.find((o) => o.id === assigneeValue)?.label ?? "Unassigned";
function insertReference(name: string, url: string, asImage: boolean) {
const safeName = name.replace(/[[\]]/g, "\\$&");
const markdown = asImage ? `![${safeName}](${url})` : `[${safeName}](${url})`;
setBody((prev) => (prev ? `${prev}\n\n${markdown}` : markdown));
}
async function attachFile(file: File) {
const id = `${file.name}:${file.size}:${file.lastModified}:${Math.random().toString(36).slice(2)}`;
setAttachments((prev) => [...prev, { id, name: file.name, status: "uploading" }]);
try {
if (onAttachImage) {
const attachment = await onAttachImage(file);
const name = attachment?.originalFilename ?? file.name;
if (attachment?.contentPath) {
insertReference(name, attachment.contentPath, file.type.startsWith("image/"));
}
setAttachments((prev) =>
prev.map((item) => (item.id === id ? { ...item, name, status: "attached" } : item)),
);
} else if (onImageUpload && file.type.startsWith("image/")) {
const url = await onImageUpload(file);
insertReference(file.name, url, true);
setAttachments((prev) =>
prev.map((item) => (item.id === id ? { ...item, status: "attached" } : item)),
);
} else {
setAttachments((prev) =>
prev.map((item) =>
item.id === id
? { ...item, status: "error", error: "This file type cannot be attached here" }
: item,
),
);
}
} catch (err) {
setAttachments((prev) =>
prev.map((item) =>
item.id === id
? { ...item, status: "error", error: err instanceof Error ? err.message : "Upload failed" }
: item,
),
);
}
}
async function attachFiles(files: Iterable<File>) {
for (const file of files) {
await attachFile(file);
}
}
function handleFileInputChange(evt: ChangeEvent<HTMLInputElement>) {
const files = evt.target.files;
if (files && files.length > 0) void attachFiles(Array.from(files));
evt.target.value = "";
}
function handlePaste(evt: ReactClipboardEvent<HTMLTextAreaElement>) {
if (!canAcceptFiles) return;
const files = Array.from(evt.clipboardData?.files ?? []);
if (files.length === 0) return;
evt.preventDefault();
void attachFiles(files);
}
function handleFileDragEnter(evt: ReactDragEvent<HTMLDivElement>) {
if (!canAcceptFiles || !hasFilePayload(evt)) return;
evt.preventDefault();
dragDepthRef.current += 1;
setIsDragOver(true);
}
function handleFileDragOver(evt: ReactDragEvent<HTMLDivElement>) {
if (!canAcceptFiles || !hasFilePayload(evt)) return;
evt.preventDefault();
evt.dataTransfer.dropEffect = "copy";
}
function handleFileDragLeave(evt: ReactDragEvent<HTMLDivElement>) {
if (!canAcceptFiles || !hasFilePayload(evt)) return;
evt.preventDefault();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setIsDragOver(false);
}
function handleFileDrop(evt: ReactDragEvent<HTMLDivElement>) {
if (!canAcceptFiles || !hasFilePayload(evt)) return;
evt.preventDefault();
dragDepthRef.current = 0;
setIsDragOver(false);
const files = evt.dataTransfer?.files;
if (files && files.length > 0) void attachFiles(Array.from(files));
}
async function submit() {
const trimmed = body.trim();
if (!trimmed || submitting || disabled) return;
const hasReassignment = showAssignee && assigneeValue !== currentAssigneeValue;
const reassignment = hasReassignment ? parseAssigneeValue(assigneeValue) : undefined;
const reopen = shouldImplicitlyReopenComment(issueStatus, assigneeValue) ? true : undefined;
setSubmitting(true);
setBody("");
try {
if (pendingMode !== workMode && onWorkModeChange) {
await onWorkModeChange(pendingMode);
}
await onAdd(trimmed);
await onAdd(trimmed, reopen, reassignment);
setAttachments([]);
setPendingAssignee(null);
} catch {
setBody(trimmed); // restore on failure
} finally {
@@ -58,9 +246,13 @@ export function TaskChatComposer({
return (
<div
className={cn(
"rounded-xl border bg-card p-2 shadow-(--shadow-extract-7)",
modeMeta.classes.container || "border-border",
"rounded-xl border border-border bg-card p-2 shadow-(--shadow-extract-7)",
isDragOver && "ring-2 ring-primary/40",
)}
onDragEnter={handleFileDragEnter}
onDragOver={handleFileDragOver}
onDragLeave={handleFileDragLeave}
onDrop={handleFileDrop}
>
<textarea
value={body}
@@ -69,63 +261,152 @@ export function TaskChatComposer({
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void submit();
return;
}
if (e.key === "Tab" && e.shiftKey) {
e.preventDefault();
setPendingMode((mode) => nextWorkMode(mode));
}
}}
onPaste={handlePaste}
disabled={disabled}
placeholder={disabled ? (disabledReason ?? "Composer disabled") : placeholder}
rows={2}
className="w-full resize-none bg-transparent px-1 py-1 text-sm outline-none placeholder:text-muted-foreground disabled:opacity-60"
data-testid="task-chat-composer-input"
/>
{attachments.length > 0 ? (
<div className="mb-1 flex flex-wrap gap-1 px-1" data-testid="task-chat-composer-attachments">
{attachments.map((attachment) => (
<span
key={attachment.id}
className={cn(
"inline-flex min-w-0 items-center gap-1 rounded-md border px-2 py-0.5 text-xs",
attachment.status === "error"
? "border-destructive/50 bg-destructive/10 text-destructive"
: "border-border bg-muted/30 text-muted-foreground",
)}
>
{attachment.status === "uploading" ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin" aria-hidden />
) : attachment.status === "error" ? (
<AlertTriangle className="h-3 w-3 shrink-0" aria-hidden />
) : (
<Check className="h-3 w-3 shrink-0" aria-hidden />
)}
<span className="max-w-40 truncate font-medium text-foreground">{attachment.name}</span>
<span className="shrink-0">
·{" "}
{attachment.status === "uploading"
? "Uploading…"
: attachment.status === "error"
? (attachment.error ?? "Upload failed")
: "Attached"}
</span>
</span>
))}
</div>
) : null}
<div className="mt-1 flex items-center gap-2">
<Popover open={menuOpen} onOpenChange={setMenuOpen}>
<PopoverTrigger asChild>
{canAcceptFiles ? (
<>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileInputChange}
/>
<button
type="button"
className={cn(
"flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-medium transition-colors",
modeMeta.classes.chip,
)}
disabled={!onWorkModeChange}
onClick={() => fileInputRef.current?.click()}
disabled={disabled}
title="Attach file"
aria-label="Attach file"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
data-testid="task-chat-composer-attach"
>
<ModeIcon className="h-3.5 w-3.5" />
{modeMeta.shortLabel}
<ChevronDown className="h-3 w-3" />
<Plus className="h-4 w-4" aria-hidden />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-44 p-1">
</>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={disabled || !onWorkModeChange}
className="status-chip flex h-8 shrink-0 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium transition-colors disabled:opacity-50"
style={{ "--sc": modeHue(pendingMode) } as CSSProperties}
data-testid="task-chat-composer-mode"
data-pending-work-mode={pendingMode}
>
{modeMeta.shortLabel}
<ChevronDown className="h-3 w-3" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-60">
{workModeMetaList().map((m) => {
const Icon = m.icon;
const selected = m.value === pendingMode;
return (
<button
<DropdownMenuItem
key={m.value}
type="button"
onClick={() => {
setPendingMode(m.value);
setMenuOpen(false);
}}
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-accent",
m.classes.menuItem,
)}
onSelect={() => setPendingMode(m.value)}
style={
selected
? { backgroundColor: `color-mix(in srgb, ${modeHue(m.value)} 12%, transparent)` }
: undefined
}
>
<Icon className="h-4 w-4" />
<span className="flex-1">{m.label}</span>
{m.value === pendingMode ? <Check className="h-3.5 w-3.5" /> : null}
</button>
<Icon className="h-4 w-4 shrink-0" style={{ color: modeHue(m.value) }} aria-hidden />
<span className="flex min-w-0 flex-1 flex-col">
<span className="font-medium">{m.label}</span>
<span className="text-xs text-muted-foreground">{MODE_DESCRIPTION[m.value] ?? ""}</span>
</span>
{selected ? <Check className="h-4 w-4 shrink-0" aria-hidden /> : null}
</DropdownMenuItem>
);
})}
</PopoverContent>
</Popover>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex-1" />
{showAssignee ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={disabled}
className="flex h-8 min-w-0 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium text-foreground transition-colors hover:bg-accent disabled:opacity-50"
data-testid="task-chat-composer-assignee"
>
<span className="max-w-40 truncate">{assigneeLabel}</span>
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{(reassignOptions ?? []).map((option) => (
<DropdownMenuItem key={option.id} onSelect={() => setPendingAssignee(option.id)}>
<span className="min-w-0 flex-1 truncate">{option.label}</span>
{option.id === assigneeValue ? <Check className="h-4 w-4 shrink-0" aria-hidden /> : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<button
type="button"
onClick={() => void submit()}
disabled={disabled || submitting || body.trim().length === 0}
className="ml-auto flex items-center gap-1 rounded-md bg-primary px-3 py-1 text-xs font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
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"
data-testid="task-chat-composer-send"
>
<Send className="h-3.5 w-3.5" />
<Send className="h-3.5 w-3.5" aria-hidden />
{submitting ? "Sending…" : "Send"}
</button>
</div>
@@ -1,5 +1,6 @@
import { cn } from "@/lib/utils";
import type { TaskChatItem } from "./task-chat-model";
import { TaskChatTurn } from "./TaskChatTurn";
import { TaskChatBubble } from "./TaskChatBubble";
import { TaskChatMarker } from "./TaskChatMarker";
import { TaskChatStatusPill } from "./TaskChatStatusPill";
@@ -38,6 +39,13 @@ function renderItem(
);
case "usage":
return <TaskChatUsageReadout item={item} />;
case "turn":
return (
<TaskChatTurn
item={item}
renderChild={(child) => renderItem(child, onApprovalDecision)}
/>
);
default: {
// Exhaustiveness guard: a new item kind must add a branch above.
const _never: never = item;
@@ -70,12 +78,16 @@ export function TaskChatThreadView({
// Include a cheap content signature so streaming growth (text lengthening
// without the item count changing) still advances the auto-follow key.
const contentKey = items.reduce((acc, it) => {
if (it.kind === "message") return acc + it.text.length;
if (it.kind === "thinking") return acc + it.lines.reduce((n, l) => n + l.length, 0);
if (it.kind === "tool") return acc + (it.diff?.lines?.length ?? 0) + (it.status === "completed" ? 1 : 0);
return acc + 1;
}, items.length);
const signatureOf = (it: TaskChatItem): number => {
if (it.kind === "message") return it.text.length;
if (it.kind === "thinking") return it.lines.reduce((n, l) => n + l.length, 0);
if (it.kind === "tool") return (it.diff?.lines?.length ?? 0) + (it.status === "completed" ? 1 : 0);
if (it.kind === "turn") {
return it.settled ? 1 : it.items.reduce((n, child) => n + signatureOf(child), it.items.length);
}
return 1;
};
const contentKey = items.reduce((acc, it) => acc + signatureOf(it), items.length);
return <TaskMessageScroller contentKey={contentKey}>{body}</TaskMessageScroller>;
}
@@ -1,25 +1,12 @@
// @vitest-environment jsdom
import { act } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { TaskChatTurn, turnSummaryText } from "./TaskChatTurn";
import { buildTurnSummary } from "./transcript-adapter";
import type { TranscriptEntry } from "@/adapters";
import type {
TaskChatStatusItem,
TaskChatToolStatus,
TaskChatTurnChildItem,
TaskChatTurnItem,
} from "./task-chat-model";
const tool = (id: string, status: TaskChatToolStatus = "completed"): TaskChatTurnChildItem => ({
id,
kind: "tool",
name: "Read",
status,
});
import type { TaskChatTurnItem } from "./task-chat-model";
const SETTLED: TaskChatTurnItem = {
id: "t1",
@@ -29,26 +16,6 @@ const SETTLED: TaskChatTurnItem = {
items: [{ id: "c1", kind: "tool", name: "read auth.ts", status: "completed" }],
};
// Non-generic label so the header shows it verbatim (no whimsy substitution).
const LIVE_STATUS: TaskChatStatusItem = {
id: "t1:status",
kind: "status",
status: "working",
label: "Editing files",
detail: "Edit · server/src/routes/auth.ts",
toolName: "Edit",
};
const parentRowTurn = (
children: TaskChatTurnChildItem[],
status: TaskChatStatusItem = LIVE_STATUS,
): TaskChatTurnItem => ({
...SETTLED,
settled: false,
liveStatus: status,
items: children,
});
describe("turnSummaryText", () => {
it("joins the known parts with dots", () => {
expect(turnSummaryText(SETTLED.summary)).toBe("Worked · 38s · 3 tools · +34 −3 · 12.3k tokens");
@@ -113,14 +80,10 @@ describe("TaskChatTurn", () => {
const fold = () => container.querySelector(".tc-turn-fold");
const summaryBtn = () =>
container.querySelector<HTMLButtonElement>('[data-testid="task-chat-turn-summary"]');
const liveHeaderBtn = () =>
container.querySelector<HTMLButtonElement>('[data-testid="task-chat-live-turn"]');
it("renders a settled turn folded with its summary line", () => {
renderTurn(SETTLED);
// Label and mono metrics are adjacent spans (v7 runsum grammar).
expect(summaryBtn()?.textContent).toContain("Worked");
expect(summaryBtn()?.textContent).toContain("38s · 3 tools · +34 −3 · 12.3k tokens");
expect(summaryBtn()?.textContent).toContain("Worked · 38s");
expect(fold()?.getAttribute("data-folded")).toBe("true");
});
@@ -130,134 +93,15 @@ describe("TaskChatTurn", () => {
expect(fold()?.getAttribute("data-folded")).toBe("false");
});
it("a headerless live turn renders expanded with no summary line", () => {
it("renders a live turn expanded with no summary line", () => {
renderTurn({ ...SETTLED, settled: false });
expect(summaryBtn()).toBeNull();
expect(liveHeaderBtn()).toBeNull();
expect(fold()?.getAttribute("data-folded")).toBe("false");
});
it("a headerless live turn folds when it settles while mounted", () => {
it("folds when the item settles while mounted", () => {
renderTurn({ ...SETTLED, settled: false });
renderTurn({ ...SETTLED, animateFold: true });
expect(fold()?.getAttribute("data-folded")).toBe("true");
});
it("collapses a live parent-row turn to its single status line", () => {
renderTurn(parentRowTurn([tool("a"), tool("b"), tool("c", "in_progress")]));
const header = liveHeaderBtn();
expect(header?.textContent).toContain("Editing files…");
expect(header?.textContent).toContain("Edit · server/src/routes/auth.ts");
expect(header?.getAttribute("aria-expanded")).toBe("false");
// All activity is folded behind it — no rows visible, no summary line.
expect(fold()?.getAttribute("data-folded")).toBe("true");
expect(summaryBtn()).toBeNull();
});
it("flashes the in-flight tool state through the header", () => {
renderTurn(parentRowTurn([tool("a")]));
expect(liveHeaderBtn()?.textContent).toContain("Editing files…");
renderTurn(
parentRowTurn([tool("a")], { ...LIVE_STATUS, label: "Reading a file", detail: "Read · auth.ts", toolName: "Read" }),
);
expect(liveHeaderBtn()?.textContent).toContain("Reading a file…");
expect(liveHeaderBtn()?.textContent).toContain("Read · auth.ts");
});
it("expands to the nested chronological history on click", () => {
renderTurn(parentRowTurn([tool("a"), tool("b")]));
flushSync(() => liveHeaderBtn()!.click());
expect(liveHeaderBtn()?.getAttribute("aria-expanded")).toBe("true");
expect(fold()?.getAttribute("data-folded")).toBe("false");
expect(fold()?.textContent).toContain("a");
expect(fold()?.textContent).toContain("b");
});
it("appends new rows live while expanded and re-collapses on click", () => {
renderTurn(parentRowTurn([tool("a")]));
flushSync(() => liveHeaderBtn()!.click());
renderTurn(parentRowTurn([tool("a"), tool("b", "in_progress")]));
expect(fold()?.getAttribute("data-folded")).toBe("false");
expect(fold()?.textContent).toContain("b");
flushSync(() => liveHeaderBtn()!.click());
expect(fold()?.getAttribute("data-folded")).toBe("true");
});
it("keeps the interstitial row outside the expand button's hover target (PAP-376)", () => {
// act() so the held-line effect state (useHeldSelfTalk) flushes.
act(() => {
renderTurn(
parentRowTurn([tool("a")], { ...LIVE_STATUS, selfTalk: "Streaming an update." }),
);
});
const btn = liveHeaderBtn();
expect(btn).not.toBeNull();
// The button (hover + click target) wraps ONLY the gerund status line;
// the interstitial text renders above it, outside the button.
expect(btn?.querySelector('[data-testid="task-chat-interstitial-row"]')).toBeNull();
expect(btn?.textContent).not.toContain("Streaming an update.");
const row = container.querySelector('[data-testid="task-chat-interstitial-row"]');
expect(row?.textContent).toContain("Streaming an update.");
// Expanding still works from the status line alone.
flushSync(() => btn!.click());
expect(liveHeaderBtn()?.getAttribute("aria-expanded")).toBe("true");
expect(fold()?.getAttribute("data-folded")).toBe("false");
});
it("renders the status line without an expand affordance while there is no activity", () => {
renderTurn(parentRowTurn([]));
expect(liveHeaderBtn()).toBeNull();
expect(container.textContent).toContain("Editing files…");
});
it("morphs a collapsed parent row into the settled summary in place", () => {
renderTurn(parentRowTurn([tool("a")]));
renderTurn({ ...SETTLED, animateFold: true, items: [tool("a")] });
expect(liveHeaderBtn()).toBeNull();
expect(summaryBtn()?.textContent).toContain("Worked");
expect(fold()?.getAttribute("data-folded")).toBe("true");
});
it("keeps the expand state across the settle morph", () => {
renderTurn(parentRowTurn([tool("a")]));
flushSync(() => liveHeaderBtn()!.click());
renderTurn({ ...SETTLED, animateFold: true, items: [tool("a")] });
expect(summaryBtn()?.getAttribute("aria-expanded")).toBe("true");
expect(fold()?.getAttribute("data-folded")).toBe("false");
});
it("rides the `leading` slot on the header row, anchored above the fold (PAP-413)", () => {
flushSync(() => {
root.render(
<TaskChatTurn
item={SETTLED}
leading={<div data-testid="turn-lead">actions</div>}
renderChild={(c) => <span>{c.id}</span>}
/>,
);
});
const lead = container.querySelector('[data-testid="turn-lead"]');
expect(lead).not.toBeNull();
// It sits beside the summary button on the header row — NOT inside the
// expandable fold — so expanding the tool history can't drag it down.
expect(fold()?.contains(lead!)).toBe(false);
expect(summaryBtn()).not.toBeNull();
// Expanding still works and the leading slot stays put outside the fold.
flushSync(() => summaryBtn()!.click());
expect(fold()?.getAttribute("data-folded")).toBe("false");
expect(fold()?.contains(lead!)).toBe(false);
});
it("leads the settled summary with the bubble timestamp when attached (round 9)", () => {
flushSync(() => {
root.render(
<TaskChatTurn item={SETTLED} timestampPrefix="2:34 PM" renderChild={(c) => <span>{c.id}</span>} />,
);
});
// "2:34 PM · ✓ Worked · …" — timestamp first, always visible, and the
// expand affordance still works from the same line.
expect(summaryBtn()?.textContent).toMatch(/^2:34 PM·Worked/);
flushSync(() => summaryBtn()!.click());
expect(fold()?.getAttribute("data-folded")).toBe("false");
});
});
+34 -100
View File
@@ -1,30 +1,16 @@
import { useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { Check, ChevronRight, X } from "lucide-react";
import { Check, ChevronDown, X } from "lucide-react";
import type { TaskChatTurnItem, TaskChatTurnChildItem } from "./task-chat-model";
import { TaskChatStatusPill } from "./TaskChatStatusPill";
interface TaskChatTurnProps {
item: TaskChatTurnItem;
renderChild: (child: TaskChatTurnChildItem) => ReactNode;
/**
* When the settled turn is attached to its reply bubble (round 9), the
* bubble's timestamp leads the summary line — "2:34 PM · ✓ Worked · 38s" —
* always visible, in the slot the hover-only timestamp used to occupy.
*/
timestampPrefix?: string;
/**
* Content rendered on the header row, leading the summary line (PAP-413: the
* copy/👍/👎 action cluster). It sits beside the summary button — NOT inside
* the expandable fold — so it stays anchored to the summary line when the
* tool history expands beneath it, instead of drifting to the fold's center.
*/
leading?: ReactNode;
}
/** Metric segments after the label: "38s · 3 tools · +34 −3 · 12.3k tokens". */
export function turnSummaryMetrics(summary: TaskChatTurnItem["summary"]): string {
const parts: string[] = [];
/** "✓ 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"];
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}`);
@@ -32,103 +18,51 @@ export function turnSummaryMetrics(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 behind a single expandable header row.
* One agent turn's activity, foldable to a one-line summary once settled.
*
* While the turn is live and carries `liveStatus`, that status line (whimsy or
* taxonomy gerund + elapsed + tokens) IS the turn: one parent row, collapsed
* by default, owning all activity (PAP-354). Expanding nests the chronological
* tool/thinking history underneath — each row keeping its own expand — and new
* rows append live while open. On settle the header morphs in place into the
* "✓ Worked · …" summary, preserving the expand state, so the running line
* reads as being replaced by the summary in the same slot.
*
* Both directions of the fold reuse the .tc-turn-fold grid-rows motion
* (--motion-turn-fold, zeroed under prefers-reduced-motion). The fold class
* only transitions on state CHANGE, so first paint in the folded state (e.g. a
* turn that loads already settled) never animates. A live turn without
* `liveStatus` (harness fixtures) renders its children expanded with no header
* and folds when it settles.
* While the turn is live it renders fully interleaved with no summary line.
* When it settles the activity folds behind a "✓ Worked · …" line — animated
* (grid-rows via .tc-turn-fold, --motion-turn-fold) when the settle happens
* on-screen, instant for turns that load already settled (the fold class only
* transitions on state CHANGE, so first paint in the folded state never
* animates). Clicking the summary folds/unfolds with the same motion.
* prefers-reduced-motion zeroes the transition in CSS.
*/
export function TaskChatTurn({ item, renderChild, timestampPrefix, leading }: TaskChatTurnProps) {
const parentRow = !item.settled && item.liveStatus != null;
// Parent-row live turns and settled turns start as their one-line header;
// only the headerless legacy live turn starts expanded.
const [open, setOpen] = useState(() => !item.settled && item.liveStatus == null);
export function TaskChatTurn({ item, renderChild }: TaskChatTurnProps) {
const [open, setOpen] = useState(!item.settled);
const [prevSettled, setPrevSettled] = useState(item.settled);
const [wasParentRow, setWasParentRow] = useState(parentRow);
// Render-adjusted state (not effects) so transitions land in the same
// commit — no flash of the wrong fold state.
if (parentRow && !wasParentRow) setWasParentRow(true);
// Live → settled while mounted: a parent-row turn keeps its expand state
// (the header morphs in place); a headerless expanded turn folds (animated
// via CSS unless reduced motion).
// Live → settled transition observed while mounted: fold (animated via CSS
// unless reduced motion). Adjusted during render (not in an effect) so the
// fold state lands in the same commit — no unfolded flash. Turns mounted
// already-settled start folded via the useState initializers.
if (item.settled !== prevSettled) {
setPrevSettled(item.settled);
if (item.settled && !wasParentRow) setOpen(false);
if (item.settled) setOpen(false);
}
const expandable = item.items.length > 0;
const folded = (item.settled || parentRow) && !open;
const folded = item.settled && !open;
const SummaryIcon = item.summary.failed ? X : Check;
const header = item.settled ? (
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="group flex items-center gap-2 px-1 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
data-testid="task-chat-turn-summary"
>
{timestampPrefix ? (
<>
<span className="text-(length:--text-micro)">{timestampPrefix}</span>
<span aria-hidden className="text-(length:--text-micro)">·</span>
</>
) : null}
<SummaryIcon className="h-3.5 w-3.5 shrink-0" />
<span>{item.summary.failed ? "Stopped" : "Worked"}</span>
{turnSummaryMetrics(item.summary) ? (
<span className="font-mono text-(length:--text-micro)">{turnSummaryMetrics(item.summary)}</span>
) : null}
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", open ? "rotate-90" : null)} />
</button>
) : parentRow ? (
// The pill renders the expand button itself, wrapped around only the
// gerund status line — the interstitial row above stays outside the
// hover/click target (PAP-376). Without activity there is no
// chevron/button.
<TaskChatStatusPill
item={item.liveStatus!}
chevronOpen={expandable ? open : undefined}
onToggle={expandable ? () => setOpen((o) => !o) : undefined}
/>
) : null;
return (
<div data-testid="task-chat-turn" data-settled={item.settled ? "true" : "false"}>
{leading ? (
// Actions ride the header row (items-center matches them to the summary
// line), and the fold below is a separate sibling — so expanding the
// tool history never moves the actions off the summary line (PAP-413).
<div className="flex items-center gap-1">
{leading}
{header}
</div>
) : (
header
)}
{item.settled ? (
<button
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"
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)} />
</button>
) : null}
<div className="tc-turn-fold" data-folded={folded ? "true" : "false"} aria-hidden={folded}>
<div>
<div className="flex flex-col gap-2 pt-1">
<div className="flex flex-col gap-3 pt-1">
{item.items.map((child) => (
<div key={child.id}>{renderChild(child)}</div>
))}
@@ -4,6 +4,19 @@ import { ArrowDown } from "lucide-react";
const PIN_THRESHOLD_PX = 48;
/** Visibility lifecycle of the scroll-to-latest pill. */
type PillPhase = "hidden" | "in" | "out";
/**
* True when animations are disabled (prefers-reduced-motion, or environments
* without matchMedia such as jsdom). In that case the pill's exit animation
* never fires animationend, so we must unmount immediately.
*/
function motionDisabled(): boolean {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return true;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
interface TaskMessageScrollerProps {
children: ReactNode;
/** Value that changes whenever content that could grow the thread updates. */
@@ -15,16 +28,33 @@ interface TaskMessageScrollerProps {
* Scroll container that owns the redesign's auto-follow vs. hold-position rule.
*
* Explicit rule: while the viewport is pinned to the bottom (within a small
* threshold) new content auto-follows; the moment the user scrolls up we hold
* their position and surface a "Jump to latest" affordance instead of yanking
* them down. Scrolling is INSTANT (not smooth) so a scroll listener cannot
* unpin mid-animation — a known stick-to-bottom failure mode. No reflow/jump is
* introduced during streaming because we only ever scroll when already pinned.
* threshold) new content auto-follows with INSTANT scroll; the moment the user
* scrolls up we hold their position and surface a scroll-to-latest pill
* instead of yanking them down.
*
* Re-follow is easing-aware: clicking the pill glides down smoothly, and while
* that glide is in flight (`easingRef`) the scroll handler must not unpin —
* the smooth scroll fires intermediate scroll events that would otherwise
* re-show the pill mid-glide (the known stick-to-bottom failure mode). Arrival
* within the pin threshold re-pins and hides the pill; a wheel/touch during
* the glide cancels it and treats the user as unpinned. Content-driven follow
* while pinned stays instant, so no reflow/jump happens during streaming.
*/
export function TaskMessageScroller({ children, contentKey, className }: TaskMessageScrollerProps) {
const ref = useRef<HTMLDivElement>(null);
const pinnedRef = useRef(true);
const [showJump, setShowJump] = useState(false);
const easingRef = useRef(false);
const [pillPhase, setPillPhase] = useState<PillPhase>("hidden");
const showPill = useCallback(() => {
setPillPhase("in");
}, []);
const hidePill = useCallback(() => {
// Keep rendering through the exit animation; unmount on animationend.
// Without animations (reduced motion / no matchMedia) unmount immediately.
setPillPhase((phase) => (phase === "hidden" ? phase : motionDisabled() ? "hidden" : "out"));
}, []);
const isPinned = useCallback(() => {
const el = ref.current;
@@ -40,9 +70,60 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
const handleScroll = useCallback(() => {
const pinned = isPinned();
if (easingRef.current) {
// Smooth re-follow in flight: intermediate scroll events must not
// unpin/re-show the pill. Only act once we arrive within the threshold.
if (pinned) {
easingRef.current = false;
pinnedRef.current = true;
hidePill();
}
return;
}
pinnedRef.current = pinned;
setShowJump(!pinned);
}, [isPinned]);
if (pinned) hidePill();
else showPill();
}, [isPinned, hidePill, showPill]);
const handleJumpToLatest = useCallback(() => {
const el = ref.current;
if (!el) return;
if (isPinned()) {
// Already at (or within threshold of) the bottom: no glide needed.
pinnedRef.current = true;
hidePill();
return;
}
easingRef.current = true;
if (typeof el.scrollTo === "function") {
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
} else {
// Environments without scrollTo (older jsdom): fall back to instant.
el.scrollTop = el.scrollHeight;
easingRef.current = false;
pinnedRef.current = true;
hidePill();
}
}, [isPinned, hidePill]);
// A user gesture during the smooth glide cancels the re-follow: stop
// treating scroll events as easing and consider the user unpinned.
useEffect(() => {
const el = ref.current;
if (!el) return;
const cancelEasing = () => {
if (!easingRef.current) return;
easingRef.current = false;
pinnedRef.current = false;
showPill();
};
el.addEventListener("wheel", cancelEasing, { passive: true });
el.addEventListener("touchstart", cancelEasing, { passive: true });
return () => {
el.removeEventListener("wheel", cancelEasing);
el.removeEventListener("touchstart", cancelEasing);
};
}, [showPill]);
// Follow new content only when already pinned; otherwise hold position.
useLayoutEffect(() => {
@@ -64,18 +145,22 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
>
{children}
</div>
{showJump ? (
{pillPhase !== "hidden" ? (
<button
type="button"
onClick={() => {
scrollToBottom();
pinnedRef.current = true;
setShowJump(false);
aria-label="Scroll to latest"
onClick={handleJumpToLatest}
onAnimationEnd={() => {
if (pillPhase === "out") setPillPhase("hidden");
}}
className="tc-enter-status absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card px-3 py-1 text-xs shadow-sm hover:bg-accent/50"
// The tc-scroll-pill-* keyframes carry the translate(-50%) X-centering
// (fill: both keeps it after the animation) — no -translate-x-1/2 here.
className={cn(
"absolute bottom-3 left-1/2 flex size-8 items-center justify-center rounded-full border border-border bg-background shadow-sm hover:bg-muted",
pillPhase === "out" ? "tc-scroll-pill-out" : "tc-scroll-pill-in",
)}
>
<ArrowDown className="h-3.5 w-3.5" />
Jump to latest
<ArrowDown className="h-4 w-4" />
</button>
) : null}
</div>
@@ -27,6 +27,8 @@ export const MOTION_TOKENS: MotionTokenDef[] = [
{ name: "--motion-ease-out-expo", group: "Easing", kind: "easing" },
{ name: "--motion-ease-standard", group: "Easing", kind: "easing" },
{ name: "--motion-ease-out", group: "Easing", kind: "easing" },
{ name: "--motion-ease-scroll-pill-in", group: "Easing", kind: "easing" },
{ name: "--motion-ease-scroll-pill-out", group: "Easing", kind: "easing" },
// Duration primitives
{ name: "--motion-duration-instant", group: "Durations", kind: "time", min: 0, max: 1000, step: 10 },
@@ -49,6 +51,10 @@ export const MOTION_TOKENS: MotionTokenDef[] = [
{ name: "--motion-plan-check", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-count-tween", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-streaming-cursor-blink", group: "States", kind: "time", min: 0, max: 3000, step: 20 },
{ name: "--motion-turn-fold", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-scroll-pill-enter", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-scroll-pill-exit", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
{ name: "--motion-pane-glide", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
];
/** Common easing presets offered by the tweak panel's easing picker. */
@@ -21,6 +21,16 @@ function effectiveAgentId(comment: IssueChatComment): string | null {
return comment.authorAgentId ?? comment.derivedAuthorAgentId ?? null;
}
/**
* Deterministic agent-id → brand gradient index (1–10) for the capsule avatar,
* so a given agent always renders the same capsule colors.
*/
export function agentGradientIndex(agentId: string): number {
let hash = 0;
for (let i = 0; i < agentId.length; i++) hash = (hash * 31 + agentId.charCodeAt(i)) | 0;
return (Math.abs(hash) % 10) + 1;
}
function authorKind(comment: IssueChatComment): TaskChatAuthorKind {
if (effectiveAgentId(comment)) return "agent";
if (comment.authorType === "user") return "human";
@@ -44,9 +54,11 @@ export function commentsToTaskChatItems(
if (comment.deletedAt) continue;
const kind = authorKind(comment);
let authorName: string | undefined;
let agentGradient: number | undefined;
if (kind === "agent") {
const agentId = effectiveAgentId(comment);
authorName = (agentId && ctx.agentMap?.get(agentId)?.name) || "Agent";
agentGradient = agentId ? agentGradientIndex(agentId) : undefined;
} else if (kind === "human") {
authorName =
(comment.authorUserId && ctx.userLabelMap?.get(comment.authorUserId)) || undefined;
@@ -65,6 +77,7 @@ export function commentsToTaskChatItems(
text: comment.body,
timestamp: formatTimestamp(comment.createdAt),
optimistic,
agentGradient,
});
}
return items;
@@ -50,7 +50,7 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario {
surface: "thread",
items: [
...exchangePrefix(),
{ id: "m-agent-1", kind: "message", author: "agent", authorName: AGENT, text: "On it — I'll add a token-bucket limiter and wire it into the login route.", timestamp: "2:31 PM" },
{ id: "m-agent-1", kind: "message", author: "agent", authorName: AGENT, agentGradient: 3, modeLabel: "Agent mode", text: "On it — I'll add a token-bucket limiter and wire it into the login route.", timestamp: "2:31 PM" },
],
};
case "thinking":
@@ -113,8 +113,19 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario {
return {
surface: "thread",
items: [
{ id: "th-done", kind: "thinking", collapsed: true, summaryLabel: "Worked for 38s · 3 tools · +34 −3", lines: ["Read auth.ts", "Added rate-limiter util", "Wired into POST /login"] },
{ id: "m-done", kind: "message", author: "agent", authorName: AGENT, text: "Done — added a per-account token-bucket limiter and wired it into the login route. Tests pass.", timestamp: "2:34 PM" },
...exchangePrefix(),
{
id: "turn-done",
kind: "turn",
settled: true,
summary: { durationLabel: "38s", toolCount: 3, added: 34, removed: 3, tokensLabel: "12.3k tokens" },
items: [
{ id: "th-done", kind: "thinking", lines: ["Read auth.ts", "Added rate-limiter util", "Wired into POST /login"] },
{ id: "tool-done-1", kind: "tool", name: "read 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: "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" },
],
};
case "awaiting-approval":
+45 -1
View File
@@ -68,6 +68,13 @@ export interface TaskChatMessageItem {
streaming?: boolean;
/** Optimistic local echo state (matches IssueChatComment.clientStatus). */
optimistic?: "pending" | "queued";
/**
* Per-message mode tag ("Agent mode" / "Plan mode" / "Ask mode"). Shown as a
* chip in the agent header and under a sent human bubble (v6 decision).
*/
modeLabel?: string;
/** Brand agent-gradient index (1–10) for the capsule avatar header. */
agentGradient?: number;
}
/** Collapsed chain-of-thought (ACP agent_thought_chunk). */
@@ -135,7 +142,8 @@ export interface TaskChatUsageItem {
usage: TaskChatTokenUsage;
}
export type TaskChatItem =
/** Items a turn can group — everything except another turn. */
export type TaskChatTurnChildItem =
| TaskChatMessageItem
| TaskChatThinkingItem
| TaskChatToolItem
@@ -143,6 +151,42 @@ export type TaskChatItem =
| TaskChatMarkerItem
| TaskChatUsageItem;
/**
* One agent turn's activity (thinking/tools/diffs) grouped so a finished turn
* can fold into a one-line expandable summary ("✓ Worked · 38s · 3 tools ·
* +34 −3"). While `settled` is false the children render fully interleaved;
* on the live → settled transition the fold animates (~ --motion-turn-fold),
* while turns that load already-settled collapse instantly.
*/
export interface TaskChatTurnItem {
id: string;
kind: "turn";
items: TaskChatTurnChildItem[];
settled: boolean;
/** Animate the fold when settling (false = collapse instantly, e.g. history). */
animateFold?: boolean;
summary: {
/** e.g. "38s" — omitted when unknown. */
durationLabel?: string;
toolCount: number;
added: number;
removed: number;
/** e.g. "12.3k tokens" — omitted when unknown. */
tokensLabel?: string;
/** Failed/interrupted turns get a ✗ affordance instead of ✓. */
failed?: boolean;
};
}
export type TaskChatItem =
| TaskChatMessageItem
| TaskChatThinkingItem
| TaskChatToolItem
| TaskChatStatusItem
| TaskChatMarkerItem
| TaskChatUsageItem
| TaskChatTurnItem;
/** A structured plan entry (ACP PlanEntry) for the Plans tab. */
export interface TaskChatPlanEntry {
id: string;
@@ -10,6 +10,7 @@ import type {
TaskChatDiff,
TaskChatItem,
TaskChatToolItem,
TaskChatTurnItem,
} from "./task-chat-model";
const TERMINAL_STATUSES = new Set([
@@ -167,6 +168,60 @@ export function transcriptToTaskChatItems(
return items;
}
function formatDurationLabel(ms: number): string | undefined {
if (!Number.isFinite(ms) || ms <= 0) return undefined;
const totalSec = Math.round(ms / 1000);
if (totalSec < 1) return "1s";
if (totalSec < 60) return `${totalSec}s`;
const min = Math.floor(totalSec / 60);
const sec = totalSec % 60;
return sec === 0 ? `${min}m` : `${min}m ${sec}s`;
}
function formatTokensLabel(tokens: number): string | undefined {
if (!Number.isFinite(tokens) || tokens <= 0) return undefined;
const label = tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : `${tokens}`;
return `${label} tokens`;
}
/**
* Aggregate a turn's transcript into the folded one-line summary
* ("✓ Worked · 38s · 3 tools · +34 −3 · 12.3k tokens"). Duration prefers the
* caller-supplied run duration and falls back to the transcript's ts span.
*/
export function buildTurnSummary(
entries: readonly TranscriptEntry[],
opts: { durationMs?: number; failed?: boolean } = {},
): TaskChatTurnItem["summary"] {
let toolCount = 0;
let added = 0;
let removed = 0;
let tokens = 0;
for (const entry of entries) {
if (entry.kind === "tool_call") toolCount += 1;
else if (entry.kind === "diff") {
if (entry.changeType === "add") added += 1;
else if (entry.changeType === "remove") removed += 1;
} else if (entry.kind === "result") {
tokens += (entry.inputTokens || 0) + (entry.outputTokens || 0);
}
}
let durationMs = opts.durationMs;
if (durationMs == null && entries.length >= 2) {
const first = Date.parse(entries[0].ts);
const last = Date.parse(entries[entries.length - 1].ts);
if (Number.isFinite(first) && Number.isFinite(last)) durationMs = last - first;
}
return {
durationLabel: durationMs != null ? formatDurationLabel(durationMs) : undefined,
toolCount,
added,
removed,
tokensLabel: formatTokensLabel(tokens),
failed: opts.failed || undefined,
};
}
/** Human-readable label for the live status pill from the tail of a transcript. */
export function deriveRunStatusLabel(entries: readonly TranscriptEntry[]): {
label: string;
+53
View File
@@ -235,6 +235,19 @@
--motion-plan-check: var(--motion-duration-fast);
--motion-count-tween: var(--motion-duration-slow);
--motion-streaming-cursor-blink: 1.1s;
--motion-turn-fold: 380ms; /* finished-turn activity folding into its summary line */
--motion-scroll-pill-enter: 200ms; /* scroll-to-latest pill entry */
--motion-scroll-pill-exit: 400ms; /* scroll-to-latest pill exit */
--motion-ease-scroll-pill-in: cubic-bezier(0.23, 1, 0.32, 1);
--motion-ease-scroll-pill-out: cubic-bezier(0.7, 0, 0.84, 0);
--motion-pane-glide: 320ms; /* properties-pane maximize/restore glide */
/* Composer mode-chip hues (v5–v7 decisions): agent + plan reuse the task
status hues; ask is the v5 blue. Consumed as `--sc` seeds through the
.status-chip color-mix recipe. */
--tc-mode-agent: var(--status-task-backlog);
--tc-mode-plan: var(--status-task-todo);
--tc-mode-ask: #3b82f6;
}
.dark {
@@ -506,6 +519,10 @@
--motion-approval-pulse: 0ms;
--motion-plan-entry-stagger: 0ms;
--motion-streaming-cursor-blink: 0ms;
--motion-turn-fold: 0ms;
--motion-scroll-pill-enter: 0ms;
--motion-scroll-pill-exit: 0ms;
--motion-pane-glide: 0ms;
}
}
@@ -549,6 +566,35 @@
.tc-cursor { animation: tc-cursor-blink var(--motion-streaming-cursor-blink) step-end infinite; }
.tc-enter-plan-entry { animation: tc-fade-rise var(--motion-plan-check) var(--motion-ease-out-expo) both; }
/* Scroll-to-latest pill (stock shadcn MessageScrollerButton motion, tokenized).
The pill is centered with translateX(-50%), so the keyframes carry the X
offset through the whole transform. */
@keyframes tc-pill-in {
from { opacity: 0; transform: translate(-50%, 8px) scale(0.9); }
to { opacity: 1; transform: translate(-50%, 0) scale(1); }
}
@keyframes tc-pill-out {
from { opacity: 1; transform: translate(-50%, 0) scale(1); }
to { opacity: 0; transform: translate(-50%, 8px) scale(0.9); }
}
.tc-scroll-pill-in { animation: tc-pill-in var(--motion-scroll-pill-enter) var(--motion-ease-scroll-pill-in) both; }
.tc-scroll-pill-out { animation: tc-pill-out var(--motion-scroll-pill-exit) var(--motion-ease-scroll-pill-out) both; }
/* Finished-turn fold: grid-rows 1fr→0fr animates height without measuring;
the single grid child must be overflow-hidden for 0fr to clip. */
.tc-turn-fold {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows var(--motion-turn-fold) var(--motion-ease-standard),
opacity var(--motion-turn-fold) var(--motion-ease-standard);
}
.tc-turn-fold[data-folded="true"] { grid-template-rows: 0fr; opacity: 0; }
.tc-turn-fold > * { overflow: hidden; min-height: 0; }
/* Properties-pane maximize/restore glide (left animates between docked and
sidebar-flush while the pane is position:fixed). */
.tc-pane-glide { transition: left var(--motion-pane-glide) var(--motion-ease-standard); }
@media (prefers-reduced-motion: reduce) {
.tc-enter-bubble,
.tc-enter-marker,
@@ -556,7 +602,14 @@
.tc-enter-status,
.tc-enter-cot-line,
.tc-reveal-diff,
.tc-scroll-pill-in,
.tc-scroll-pill-out,
.tc-enter-plan-entry { animation: none; }
.tc-turn-fold,
.tc-pane-glide { transition: none; }
/* The pill keyframes carry the X-centering; with animation:none restore it. */
.tc-scroll-pill-in,
.tc-scroll-pill-out { transform: translate(-50%, 0); }
.tc-approval { animation: none; box-shadow: 0 0 0 2px hsl(var(--primary) / 0.18); }
.tc-cursor { animation: none; opacity: 1; }
}