mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
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:
1 parent
d270ee47fd
commit
474c4138ac
17 files changed
+889
-846
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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("");
|
||||
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", () => {
|
||||
|
||||
@@ -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})`;
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user