mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
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:
1 parent
96037fec6e
commit
9445f4fbbe
12 files changed
+324
-69
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user