From 9445f4fbbe248a1547ca665319d4e40157df3a73 Mon Sep 17 00:00:00 2001 From: scotttong Date: Sun, 2 Aug 2026 13:09:21 -0700 Subject: [PATCH] =?UTF-8?q?feat(ui):=20interstitial=20polish=20=E2=80=94?= =?UTF-8?q?=20hover=20only=20on=20gerund=20line,=20no=20leading=20icon,=20?= =?UTF-8?q?held=20line=20slides=20away=20after=20dwell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ui/src/components/TaskChatThread.tsx | 17 +- .../task-chat/TaskChatStatusPill.test.tsx | 61 ++++++-- .../task-chat/TaskChatStatusPill.tsx | 146 +++++++++++++----- .../task-chat/TaskChatThreadView.tsx | 12 +- .../task-chat/TaskChatTurn.test.tsx | 22 +++ ui/src/components/task-chat/TaskChatTurn.tsx | 23 ++- .../components/task-chat/task-chat-adapter.ts | 5 +- .../components/task-chat/task-chat-model.ts | 15 +- .../task-chat/transcript-adapter.test.ts | 40 +++++ .../task-chat/transcript-adapter.ts | 15 ++ ui/src/index.css | 2 +- ui/src/pages/IssueDetail.tsx | 35 +++++ 12 files changed, 324 insertions(+), 69 deletions(-) diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 7be8b7c3317..a0575b3ba01 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -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(() => { // 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 ? () => : undefined} scroll={!isMobile} /> )} diff --git a/ui/src/components/task-chat/TaskChatStatusPill.test.tsx b/ui/src/components/task-chat/TaskChatStatusPill.test.tsx index 871660d4671..4ef908e3bfe 100644 --- a/ui/src/components/task-chat/TaskChatStatusPill.test.tsx +++ b/ui/src/components/task-chat/TaskChatStatusPill.test.tsx @@ -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(() => { diff --git a/ui/src/components/task-chat/TaskChatStatusPill.tsx b/ui/src/components/task-chat/TaskChatStatusPill.tsx index d166516f534..b52cc044e5b 100644 --- a/ui/src/components/task-chat/TaskChatStatusPill.tsx +++ b/ui/src/components/task-chat/TaskChatStatusPill.tsx @@ -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(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 ( {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(selfTalk); const lastSwapAtRef = useRef(0); const timerRef = useRef(null); + const leaveTimerRef = useRef(null); + const exitTimerRef = useRef(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 = (
{/* 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 (
{heldSelfTalk ? ( - <> - {SelfTalkIcon ? : null} - - + ) : ( )}
- {statusLine} + {onToggle ? ( + + ) : ( + statusLine + )}
); } diff --git a/ui/src/components/task-chat/TaskChatThreadView.tsx b/ui/src/components/task-chat/TaskChatThreadView.tsx index 2fa4bb81761..9916837b3c0 100644 --- a/ui/src/components/task-chat/TaskChatThreadView.tsx +++ b/ui/src/components/task-chat/TaskChatThreadView.tsx @@ -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 ; case "interaction": return renderInteraction ? renderInteraction(item) : null; + case "brief": + return renderBrief ? renderBrief() : null; case "turn": return ( ) : null} {items.map((item) => ( -
{renderItem(item, onApprovalDecision, renderInteraction)}
+
{renderItem(item, onApprovalDecision, renderInteraction, renderBrief)}
))}
); diff --git a/ui/src/components/task-chat/TaskChatTurn.test.tsx b/ui/src/components/task-chat/TaskChatTurn.test.tsx index b5da2346494..eec4c9e0833 100644 --- a/ui/src/components/task-chat/TaskChatTurn.test.tsx +++ b/ui/src/components/task-chat/TaskChatTurn.test.tsx @@ -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(); diff --git a/ui/src/components/task-chat/TaskChatTurn.tsx b/ui/src/components/task-chat/TaskChatTurn.tsx index 038c18410a6..314396e5830 100644 --- a/ui/src/components/task-chat/TaskChatTurn.tsx +++ b/ui/src/components/task-chat/TaskChatTurn.tsx @@ -97,20 +97,15 @@ export function TaskChatTurn({ item, renderChild, timestampPrefix }: TaskChatTur ) : parentRow ? ( - expandable ? ( - - ) : ( - // Nothing to expand yet: the status line alone, no chevron/button. - - ) + // 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. + setOpen((o) => !o) : undefined} + /> ) : null}
diff --git a/ui/src/components/task-chat/task-chat-adapter.ts b/ui/src/components/task-chat/task-chat-adapter.ts index e77036ffd07..cc1bfb635ee 100644 --- a/ui/src/components/task-chat/task-chat-adapter.ts +++ b/ui/src/components/task-chat/task-chat-adapter.ts @@ -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, diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index 286ba1c10b6..00d99f35ad4 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -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 { diff --git a/ui/src/components/task-chat/transcript-adapter.test.ts b/ui/src/components/task-chat/transcript-adapter.test.ts index 369ec43f8ac..674656eb2d0 100644 --- a/ui/src/components/task-chat/transcript-adapter.test.ts +++ b/ui/src/components/task-chat/transcript-adapter.test.ts @@ -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).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); + }); +}); diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index 59f62cf8d8b..1745e8aa162 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -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. */ diff --git a/ui/src/index.css b/ui/src/index.css index 6217203adf4..7a75d5d21b6 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 675fc379aca..6d45955e222 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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" ? ( 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}