feat(ui): interstitial polish — hover only on gerund line, no leading icon, held line slides away after dwell

- Expand button moves inside TaskChatStatusPill wrapping only the status
  line (new onToggle prop); the interstitial row sits outside the hover/
  click target
- Drop the Responding icon prepending the interstitial text (lead spacer
  keeps column alignment)
- useHeldSelfTalk: a finished update now slides out of the 1lh viewport
  after --motion-interstitial-dwell (~4s) with no successor, instead of
  sitting until pushed out; same .tc-line-scroll transition, unmounts to
  the reserved empty slot

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
scotttongandClaude Fable 5 committed 2026-08-07 16:57:14 -07:00
1 parent 96037fec6e
commit 9445f4fbbe
12 files changed
+324 -69

No files matched your search

+15 -2
View File
@@ -10,10 +10,12 @@ import {
deriveRunStatusLabel,
isNestableLiveChild,
isTerminalRunStatus,
prependIssueBrief,
settledRunChildren,
transcriptToTaskChatItems,
type SettledTurnMergeMeta,
} from "@/components/task-chat/transcript-adapter";
import { TaskChatDescriptionBubble } from "@/components/task-chat/TaskChatDescriptionBubble";
import type {
TaskChatInteractionItem,
TaskChatItem,
@@ -104,6 +106,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
externalReferences,
threadHeader,
workModeChanges,
issueBrief,
} = props;
const linkedRunMetaById = useMemo(() => {
@@ -250,6 +253,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
);
}, [comments, commentItems, interactions, timelineEvents, linkedRuns, liveRuns, planDocument]);
// Boolean gate (stable across the host's per-render brief objects) so the
// heavy assembly memo doesn't recompute on every parent render.
const hasBrief = Boolean(issueBrief);
const items = useMemo<TaskChatItem[]>(() => {
// Settled turns for every terminal run whose transcript we have. Messages
// and thinking are excluded entirely (PAP-361): the final reply already
@@ -366,8 +373,13 @@ export function TaskChatThread(props: TaskChatThreadProps) {
// Round 9: a settled turn directly following its own agent's reply bubble
// then attaches to that bubble — the "Worked · …" summary renders on the
// bubble's always-visible timestamp line instead of as a standalone row.
return attachSettledTurns(coalesceSettledTurns(out, turnMergeMetaById), turnMergeMetaById);
}, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun]);
// PAP-375: the description-as-first-bubble placeholder prepends LAST, after
// every assembly/merge pass, so nothing can ever sort above it.
return prependIssueBrief(
attachSettledTurns(coalesceSettledTurns(out, turnMergeMetaById), turnMergeMetaById),
hasBrief,
);
}, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun, hasBrief]);
const renderInteraction = useCallback(
(item: TaskChatInteractionItem) => (
@@ -430,6 +442,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
items={items}
header={threadHeader}
renderInteraction={renderInteraction}
renderBrief={issueBrief ? () => <TaskChatDescriptionBubble brief={issueBrief} /> : undefined}
scroll={!isMobile}
/>
)}
@@ -33,6 +33,7 @@ describe("TaskChatStatusPill whimsy", () => {
act(() => root.unmount());
container.remove();
document.documentElement.style.removeProperty("--motion-interstitial-dwell");
document.documentElement.style.removeProperty("--motion-line-scroll");
vi.useRealTimers();
});
@@ -132,25 +133,65 @@ describe("TaskChatStatusPill whimsy", () => {
expect(line?.firstElementChild?.className).toContain("tc-line-scroll-inner");
});
it("holds the finished update — static, ellipsized, never fading — until superseded (PAP-368)", () => {
const item = liveStatus({ tokens: { used: 18240, size: 200000 } });
it("holds the finished update through the dwell, then slides it away (PAP-376)", () => {
document.documentElement.style.setProperty("--motion-interstitial-dwell", "4000ms");
document.documentElement.style.setProperty("--motion-line-scroll", "240ms");
render(liveStatus({ label: "Responding", selfTalk: "Almost there." }));
expect(container.querySelector('[data-testid="task-chat-live-self-talk"]')).not.toBeNull();
render(item);
// selfTalk gone → the text STAYS as a held single-line readout: no
// slide-out, no fade, even a minute later; the reserved row keeps its
// place and the gerund + dot carry on below.
render(liveStatus({}));
// selfTalk gone → held as a static single-line readout while the dwell
// runs.
act(() => {
vi.advanceTimersByTime(60_000);
vi.advanceTimersByTime(3900);
});
const line = container.querySelector('[data-testid="task-chat-live-self-talk"]');
let line = container.querySelector('[data-testid="task-chat-live-self-talk"]');
expect(line?.textContent).toBe("Almost there.");
expect(line?.getAttribute("data-streaming")).toBeNull();
expect(line?.getAttribute("data-leaving")).toBeNull();
expect(line?.firstElementChild?.className).toContain("truncate");
expect(container.textContent).toContain(`${whimsyWord(item.id, 60_000)}…`);
// Dwell expired with no successor: the line slides up out of the 1lh
// viewport with the transition re-enabled…
act(() => {
vi.advanceTimersByTime(200);
});
line = container.querySelector('[data-testid="task-chat-live-self-talk"]');
expect(line?.getAttribute("data-leaving")).toBe("true");
const inner = line?.firstElementChild as HTMLElement;
expect(inner.style.transform).toContain("-1");
expect(inner.style.transition).toBe("");
// …and unmounts back to the reserved empty slot; gerund + dot carry on.
act(() => {
vi.advanceTimersByTime(300);
});
expect(container.querySelector('[data-testid="task-chat-live-self-talk"]')).toBeNull();
const row = container.querySelector('[data-testid="task-chat-interstitial-row"]');
expect(row).not.toBeNull();
expect(row?.querySelector(".tc-line-scroll")).not.toBeNull();
expect(container.querySelector(".animate-pulse")).not.toBeNull();
});
it("swaps a new update straight in after the held line has slid away", () => {
document.documentElement.style.setProperty("--motion-interstitial-dwell", "4000ms");
render(liveStatus({ label: "Responding", selfTalk: "First." }));
render(liveStatus({}));
act(() => {
vi.advanceTimersByTime(4100);
});
expect(container.querySelector('[data-testid="task-chat-live-self-talk"]')).toBeNull();
render(liveStatus({ label: "Responding", selfTalk: "Second." }));
const line = container.querySelector('[data-testid="task-chat-live-self-talk"]');
expect(line?.textContent).toBe("Second.");
expect(line?.getAttribute("data-streaming")).toBe("true");
});
it("renders the interstitial text without a leading icon (PAP-376)", () => {
render(liveStatus({ label: "Responding", selfTalk: "No icon on this row." }));
const row = container.querySelector('[data-testid="task-chat-interstitial-row"]');
expect(row?.textContent).toBe("No icon on this row.");
expect(row?.querySelector("svg")).toBeNull();
// The empty lead slot still column-aligns the text with the status label.
expect(row?.firstElementChild?.getAttribute("aria-hidden")).toBe("true");
});
it("streams the tail line without a transition once the enter slide settles", () => {
render(liveStatus({ label: "Responding", selfTalk: "Streaming along nicely." }));
act(() => {
@@ -61,10 +61,19 @@ function motionTokenMs(name: string): number {
* only the tail line shows, but with the transition off the shift SNAPS — no
* per-wrap glide keeping the row in constant motion. Once the update finishes
* (`streaming` false) the held text renders single-line ellipsized, static.
* Line count is measured from scrollHeight / line-height, re-checked on
* resize.
* When `leaving` (held past the dwell with no successor, PAP-376), the
* transition re-enables and the line continues up out of the clip. Line count
* is measured from scrollHeight / line-height, re-checked on resize.
*/
function LiveSelfTalkLine({ text, streaming }: { text: string; streaming: boolean }) {
function LiveSelfTalkLine({
text,
streaming,
leaving,
}: {
text: string;
streaming: boolean;
leaving?: boolean;
}) {
const innerRef = useRef<HTMLSpanElement | null>(null);
const [offset, setOffset] = useState(0);
const [entered, setEntered] = useState(false);
@@ -90,19 +99,20 @@ function LiveSelfTalkLine({ text, streaming }: { text: string; streaming: boolea
window.clearTimeout(timer);
};
}, []);
const shift = !entered ? 1 : streaming ? -offset : 0;
const shift = !entered ? 1 : leaving ? -1 : streaming ? -offset : 0;
return (
<span
className="tc-line-scroll min-w-0 flex-1"
data-testid="task-chat-live-self-talk"
data-streaming={streaming || undefined}
data-leaving={leaving || undefined}
>
<span
ref={innerRef}
className={cn("tc-line-scroll-inner block", !streaming && "truncate")}
style={{
transform: shift !== 0 ? `translateY(calc(${shift} * 1lh))` : undefined,
transition: settled ? "none" : undefined,
transition: settled && !leaving ? "none" : undefined,
}}
>
{text}
@@ -117,15 +127,18 @@ interface HeldSelfTalk {
streaming: boolean;
/** Increments at each swap — keys LiveSelfTalkLine so the slide replays. */
swapKey: number;
/** Sliding out after sitting held for the full dwell (PAP-376). */
leaving?: boolean;
}
/**
* Hold-until-superseded interstitial state (PAP-368): a finished update STAYS
* visible, static, until the next update replaces it or the turn ends — never
* fades. Swaps are paced by --motion-interstitial-dwell (~4s minimum between
* swaps) so visibility tracks reading time, not streaming speed. Updates that
* arrive inside the dwell window collapse latest-wins (a queue of one: only
* the newest pending update swaps in when the dwell expires).
* Held interstitial state (PAP-368, amended by PAP-376): a finished update
* stays visible, static, for --motion-interstitial-dwell (~4s) — then, if
* nothing superseded it, slides away rather than sitting until the next
* update happens to push it out. The same dwell paces swaps (~4s minimum
* between them) so visibility tracks reading time, not streaming speed.
* Updates that arrive inside the dwell window collapse latest-wins (a queue
* of one: only the newest pending update swaps in when the dwell expires).
*
* Message boundaries are the `selfTalk` gaps: the transcript tail clears it
* between assistant messages (tool calls / thinking in between), so a
@@ -138,31 +151,64 @@ function useHeldSelfTalk(selfTalk: string | undefined, live: boolean): HeldSelfT
const tailRef = useRef<string | undefined>(selfTalk);
const lastSwapAtRef = useRef(0);
const timerRef = useRef<number | null>(null);
const leaveTimerRef = useRef<number | null>(null);
const exitTimerRef = useRef<number | null>(null);
tailRef.current = selfTalk;
useEffect(() => {
const clear = (ref: { current: number | null }) => {
if (ref.current != null) {
window.clearTimeout(ref.current);
ref.current = null;
}
};
const show = (next: HeldSelfTalk | null) => {
lineRef.current = next;
setLine(next);
};
// Held past the dwell with nothing queued: slide the line out of the 1lh
// viewport (--motion-line-scroll) and unmount it back to the empty slot.
const scheduleLeave = () => {
clear(leaveTimerRef);
clear(exitTimerRef);
leaveTimerRef.current = window.setTimeout(() => {
leaveTimerRef.current = null;
// A queued update's swap timer fires no later than this — let the
// swap replace the line instead of playing the exit first.
if (pendingRef.current != null) return;
const cur = lineRef.current;
if (cur == null || cur.streaming) return;
show({ ...cur, leaving: true });
exitTimerRef.current = window.setTimeout(() => {
exitTimerRef.current = null;
if (lineRef.current?.leaving) show(null);
}, motionTokenMs("--motion-line-scroll"));
}, motionTokenMs("--motion-interstitial-dwell"));
};
const swapIn = (text: string, streaming: boolean) => {
clear(leaveTimerRef);
clear(exitTimerRef);
lastSwapAtRef.current = Date.now();
show({ text, streaming, swapKey: (lineRef.current?.swapKey ?? 0) + 1 });
// Swapped in with its stream already over: its idle clock starts now.
if (!streaming) scheduleLeave();
};
if (!live) {
// Turn end clears the row (the reserved slot itself unmounts with it).
if (timerRef.current != null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
clear(timerRef);
clear(leaveTimerRef);
clear(exitTimerRef);
pendingRef.current = null;
if (lineRef.current) show(null);
return;
}
const cur = lineRef.current;
if (selfTalk == null) {
// Stream ended: the displayed update becomes held — it does NOT leave.
if (cur?.streaming) show({ ...cur, streaming: false });
// Stream ended: the update becomes held and its idle clock starts.
if (cur?.streaming) {
show({ ...cur, streaming: false });
scheduleLeave();
}
return;
}
if (cur == null) {
@@ -174,8 +220,8 @@ function useHeldSelfTalk(selfTalk: string | undefined, live: boolean): HeldSelfT
if (cur.text !== selfTalk) show({ ...cur, text: selfTalk });
return;
}
// A new update while one is held: queue it latest-wins and swap when the
// dwell since the last swap has elapsed.
// A new update while one is held (or leaving): queue it latest-wins and
// swap when the dwell since the last swap has elapsed.
pendingRef.current = selfTalk;
if (timerRef.current == null) {
const dwellMs = motionTokenMs("--motion-interstitial-dwell");
@@ -194,6 +240,8 @@ function useHeldSelfTalk(selfTalk: string | undefined, live: boolean): HeldSelfT
useEffect(
() => () => {
if (timerRef.current != null) window.clearTimeout(timerRef.current);
if (leaveTimerRef.current != null) window.clearTimeout(leaveTimerRef.current);
if (exitTimerRef.current != null) window.clearTimeout(exitTimerRef.current);
},
[],
);
@@ -219,6 +267,13 @@ interface TaskChatStatusPillProps {
* the same expand grammar as tool rows and the settled "Worked ·" line.
*/
chevronOpen?: boolean;
/**
* Expand toggle for the parent row. The button (hover + click target) wraps
* ONLY the gerund status line — the interstitial row above it stays outside
* (PAP-376): its text vanishes on expand, so hovering it as part of the
* header read as covering two unrelated lines.
*/
onToggle?: () => void;
}
/**
@@ -228,7 +283,12 @@ interface TaskChatStatusPillProps {
* card chrome — awaiting-approval elevates with an attention pulse and
* exposes the ACP permission options as actions.
*/
export function TaskChatStatusPill({ item, onApprovalDecision, chevronOpen }: TaskChatStatusPillProps) {
export function TaskChatStatusPill({
item,
onApprovalDecision,
chevronOpen,
onToggle,
}: TaskChatStatusPillProps) {
const { Icon, spin, tone } = CONFIG[item.status];
const awaiting = item.status === "awaiting_approval";
const live = item.status === "running" || item.status === "working";
@@ -255,7 +315,6 @@ export function TaskChatStatusPill({ item, onApprovalDecision, chevronOpen }: Ta
const label = streamingInterstitial || isGenericStatusLabel(item.label)
? whimsyWord(item.id, liveElapsedMs ?? item.elapsedMs ?? 0)
: item.label;
const SelfTalkIcon = statusLabelIcon("Responding");
const statusLine = (
<div className="tc-enter-status flex items-center gap-2 py-0.5 text-xs text-muted-foreground">
{/* Fixed-size lead slot keeps the label from moving as tool icons
@@ -297,14 +356,15 @@ export function TaskChatStatusPill({ item, onApprovalDecision, chevronOpen }: Ta
);
// The interstitial row is PERMANENTLY RESERVED while the turn is live
// (round 9): the layout above never jumps to make room. A streaming
// interstitial gets this bubble-less single-line row DIRECTLY ABOVE the
// status line — the two never share a line. The latest update is held
// until superseded (PAP-368): it stays as a static one-line readout after
// its stream ends, and the .tc-line-scroll slide plays only when a new
// update swaps in (keyed remount), paced by --motion-interstitial-dwell.
// Before the first update the row is an empty one-line slot of identical
// height; the empty lead slot keeps the text column-aligned with the
// status label.
// interstitial gets this bubble-less, icon-less single-line row DIRECTLY
// ABOVE the status line — the two never share a line. The latest update is
// held after its stream ends (PAP-368) and slides away once it has sat
// for the full --motion-interstitial-dwell without a successor (PAP-376);
// the .tc-line-scroll slide otherwise plays only when a new update swaps
// in (keyed remount). Before the first update the row is an empty one-line
// slot of identical height; the empty lead slot keeps the text
// column-aligned with the status label. The expand button wraps ONLY the
// status line, so the interstitial row never picks up its hover.
return (
<div className="flex flex-col">
<div
@@ -313,19 +373,29 @@ export function TaskChatStatusPill({ item, onApprovalDecision, chevronOpen }: Ta
>
<span aria-hidden className="h-3.5 w-3.5 shrink-0" />
{heldSelfTalk ? (
<>
{SelfTalkIcon ? <SelfTalkIcon className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
<LiveSelfTalkLine
key={heldSelfTalk.swapKey}
text={heldSelfTalk.text}
streaming={heldSelfTalk.streaming}
/>
</>
<LiveSelfTalkLine
key={heldSelfTalk.swapKey}
text={heldSelfTalk.text}
streaming={heldSelfTalk.streaming}
leaving={heldSelfTalk.leaving}
/>
) : (
<span aria-hidden className="tc-line-scroll min-w-0 flex-1" />
)}
</div>
{statusLine}
{onToggle ? (
<button
type="button"
onClick={onToggle}
aria-expanded={chevronOpen}
className="-mx-1.5 block w-full rounded-sm px-1.5 text-left transition-colors hover:bg-muted/60"
data-testid="task-chat-live-turn"
>
{statusLine}
</button>
) : (
statusLine
)}
</div>
);
}
@@ -24,6 +24,12 @@ interface TaskChatThreadViewProps {
* items render nothing without it — the harness has no control plane.
*/
renderInteraction?: (item: TaskChatInteractionItem) => ReactNode;
/**
* Renders the description-as-first-bubble placeholder (PAP-375). The live
* thread binds TaskChatDescriptionBubble to the issue; brief items render
* nothing without it.
*/
renderBrief?: () => ReactNode;
className?: string;
/** When false, render the list without the scroll container (e.g. previews). */
scroll?: boolean;
@@ -33,6 +39,7 @@ function renderItem(
item: TaskChatItem,
onApprovalDecision?: (statusItemId: string, optionId: string) => void,
renderInteraction?: (item: TaskChatInteractionItem) => ReactNode,
renderBrief?: () => ReactNode,
) {
switch (item.kind) {
case "message":
@@ -71,6 +78,8 @@ function renderItem(
return <TaskChatUsageReadout item={item} />;
case "interaction":
return renderInteraction ? renderInteraction(item) : null;
case "brief":
return renderBrief ? renderBrief() : null;
case "turn":
return (
<TaskChatTurn
@@ -97,6 +106,7 @@ export function TaskChatThreadView({
header,
onApprovalDecision,
renderInteraction,
renderBrief,
className,
scroll = true,
}: TaskChatThreadViewProps) {
@@ -108,7 +118,7 @@ export function TaskChatThreadView({
</div>
) : null}
{items.map((item) => (
<div key={item.id}>{renderItem(item, onApprovalDecision, renderInteraction)}</div>
<div key={item.id}>{renderItem(item, onApprovalDecision, renderInteraction, renderBrief)}</div>
))}
</div>
);
@@ -1,5 +1,6 @@
// @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";
@@ -182,6 +183,27 @@ describe("TaskChatTurn", () => {
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();
+9 -14
View File
@@ -97,20 +97,15 @@ export function TaskChatTurn({ item, renderChild, timestampPrefix }: TaskChatTur
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", open ? "rotate-90" : null)} />
</button>
) : parentRow ? (
expandable ? (
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="-mx-1.5 block w-full rounded-sm px-1.5 text-left transition-colors hover:bg-muted/60"
data-testid="task-chat-live-turn"
>
<TaskChatStatusPill item={item.liveStatus!} chevronOpen={open} />
</button>
) : (
// Nothing to expand yet: the status line alone, no chevron/button.
<TaskChatStatusPill item={item.liveStatus!} />
)
// 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}
<div className="tc-turn-fold" data-folded={folded ? "true" : "false"} aria-hidden={folded}>
<div>
@@ -34,7 +34,8 @@ function authorKind(comment: IssueChatComment): TaskChatAuthorKind {
return "system";
}
function formatTimestamp(value: unknown): string | undefined {
/** Shared bubble-footer time format ("2:34 PM") — also used by the description bubble (PAP-375). */
export function formatTaskChatTimestamp(value: unknown): string | undefined {
if (!value) return undefined;
const d = value instanceof Date ? value : new Date(value as string);
if (Number.isNaN(d.getTime())) return undefined;
@@ -71,7 +72,7 @@ export function commentsToTaskChatItems(
author: kind,
authorName,
text: comment.body,
timestamp: formatTimestamp(comment.createdAt),
timestamp: formatTaskChatTimestamp(comment.createdAt),
optimistic,
agentIcon,
modeLabel: kind === "agent" ? ctx.agentModeLabelFor?.(comment) : undefined,
+14 -1
View File
@@ -183,6 +183,18 @@ export interface TaskChatUsageItem {
usage: TaskChatTokenUsage;
}
/**
* The task description rendered as the requester's first chat bubble
* (PAP-375). A placeholder kind only — the host supplies the render
* (TaskChatThread binds TaskChatDescriptionBubble to the live issue), mirroring
* how interaction items defer to renderInteraction. Always prepended AFTER
* thread assembly, so no backbone entry or settled turn can sort above it.
*/
export interface TaskChatBriefItem {
id: string;
kind: "brief";
}
/**
* An issue-thread interaction (plan confirmation, question card, suggested
* tasks…) interleaved chronologically into the thread. The payload is the
@@ -247,7 +259,8 @@ export type TaskChatItem =
| TaskChatMarkerItem
| TaskChatUsageItem
| TaskChatInteractionItem
| TaskChatTurnItem;
| TaskChatTurnItem
| TaskChatBriefItem;
/** A structured plan entry (ACP PlanEntry) for the Plans tab. */
export interface TaskChatPlanEntry {
@@ -9,6 +9,8 @@ import {
deriveRunStatusLabel,
flattenSelfTalk,
isNestableLiveChild,
ISSUE_BRIEF_ITEM_ID,
prependIssueBrief,
settledRunChildren,
toolDisplayName,
transcriptToTaskChatItems,
@@ -652,3 +654,41 @@ describe("assembleThreadItems (PAP-367)", () => {
expect(out.every((i) => i.kind !== "message" || (i as Extract<TaskChatItem, { kind: "message" }>).attachedTurn == null)).toBe(true);
});
});
describe("prependIssueBrief (PAP-375)", () => {
function entry(id: string, ms: number): ThreadBackboneEntry {
return {
ms,
id,
item: { id, kind: "message", author: "human", text: `msg ${id}` } as TaskChatItem,
};
}
function settledTurn(id: string): TaskChatTurnItem {
return {
id,
kind: "turn",
settled: true,
items: [{ id: `${id}:tool:0`, kind: "tool", name: "Read", status: "completed" }],
summary: buildTurnSummary([toolCall("Read")], { durationMs: 34_000 }),
};
}
it("keeps the description bubble above an unanchored turn that predates every backbone entry (F15)", () => {
// A stopped run whose startMs (0) is EARLIER than the oldest comment: it
// sorts to the very top of the assembled thread — the brief must still
// render above it, because the prepend runs after assembly.
const backbone = [entry("u1", 1_000), entry("u2", 3_000)];
const assembled = assembleThreadItems(backbone, new Map(), [
{ turn: settledTurn("run-x:turn"), startMs: 0 },
]);
const out = prependIssueBrief(assembled, true);
expect(out.map((i) => i.id)).toEqual([ISSUE_BRIEF_ITEM_ID, "run-x:turn", "u1", "u2"]);
expect(out[0].kind).toBe("brief");
});
it("is the first item of an otherwise empty thread, and a no-op without a brief", () => {
expect(prependIssueBrief([], true).map((i) => i.kind)).toEqual(["brief"]);
const items = [entry("u1", 1_000).item];
expect(prependIssueBrief(items, false)).toBe(items);
});
});
@@ -474,6 +474,21 @@ export function assembleThreadItems(
return out;
}
/** Stable id of the description-as-first-bubble item (PAP-375). */
export const ISSUE_BRIEF_ITEM_ID = "issue-brief";
/**
* Prepend the issue-brief placeholder (PAP-375) to the fully assembled thread.
* Running AFTER assembleThreadItems/coalesce/attach makes the ordering
* guarantee structural rather than data-dependent: even an unanchored settled
* turn whose startMs predates every backbone entry (F15) lands below the
* description bubble.
*/
export function prependIssueBrief(items: TaskChatItem[], hasBrief: boolean): TaskChatItem[] {
if (!hasBrief) return items;
return [{ id: ISSUE_BRIEF_ITEM_ID, kind: "brief" }, ...items];
}
/** Per-turn identity + raw summary inputs for coalescing (keyed by turn item id). */
export interface SettledTurnMergeMeta {
/** Stable agent identity (agentId); empty/unknown turns never merge. */
+1 -1
View File
@@ -237,7 +237,7 @@
--motion-streaming-cursor-blink: 1.1s;
--motion-turn-fold: 380ms; /* finished-turn activity folding into its summary line */
--motion-line-scroll: 240ms; /* completed interstitial line sliding out of the 1lh live-line viewport */
--motion-interstitial-dwell: 4000ms; /* minimum hold on an interstitial update before the next one may swap in */
--motion-interstitial-dwell: 4000ms; /* interstitial update hold: minimum before the next swaps in, and how long a finished update sits before sliding away */
--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);
+35
View File
@@ -89,6 +89,7 @@ import {
type IssueChatRunFinalizationAction,
} from "../components/IssueChatThread";
import { TaskChatThread } from "../components/TaskChatThread";
import type { TaskChatIssueBrief } from "../components/task-chat/TaskChatDescriptionBubble";
import { useTaskChatRedesignEnabled } from "../hooks/useTaskChatRedesignEnabled";
import { workModeMetaFor } from "../lib/work-mode-meta";
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
@@ -909,6 +910,11 @@ type IssueDetailChatTabProps = {
* messages (flag: enableTaskChatRedesign). Ignored by the legacy thread.
*/
threadHeader?: ReactNode;
/**
* The task description rendered as the requester's first chat bubble in the
* redesigned thread (PAP-375). Ignored by the legacy thread.
*/
issueBrief?: TaskChatIssueBrief;
footer?: ReactNode;
feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference: "allowed" | "not_allowed" | "prompt";
@@ -998,6 +1004,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
composerRef,
composerAccessory,
threadHeader,
issueBrief,
footer,
feedbackVotes,
feedbackDataSharingPreference,
@@ -1211,6 +1218,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
</>
) : undefined
}
issueBrief={issueBrief}
comments={commentsWithRunMeta}
interactions={interactions}
feedbackVotes={feedbackVotes}
@@ -4869,6 +4877,33 @@ export function IssueDetail() {
{resolvedDetailTab === "chat" ? (
<IssueDetailChatTab
threadHeader={taskChatThreadHeader}
issueBrief={
taskChatShellEnabled
? {
description: issue.description ?? "",
author: issue.createdByAgentId ? "agent" : "human",
authorName: issue.createdByAgentId
? agentMap.get(issue.createdByAgentId)?.name ?? "Agent"
: undefined,
agentIcon: issue.createdByAgentId
? agentMap.get(issue.createdByAgentId)?.icon
: undefined,
createdAt: issue.createdAt,
onSave: (description) => updateIssue.mutateAsync({ description }),
mentions: mentionOptions,
externalReferences: externalObjectsState.isEnabled
? externalObjectsState.markdownReferences
: undefined,
imageUploadHandler: async (file) => {
const attachment = await uploadAttachment.mutateAsync(file);
return attachment.contentPath;
},
onDropFile: async (file) => {
await uploadAttachment.mutateAsync(file);
},
}
: undefined
}
issueId={issue.id}
companyId={issue.companyId}
projectId={issue.projectId ?? null}