diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx
index 7ec2298b658..3e7209e8120 100644
--- a/ui/src/components/issue-properties/IssueProperties.tsx
+++ b/ui/src/components/issue-properties/IssueProperties.tsx
@@ -2456,14 +2456,14 @@ export function IssueProperties({
// Flag OFF (or non-redesign hosts): today's stacked pane, byte-for-byte.
if (!taskChatRedesignEnabled) return propertiesBody;
- // Flag ON: wrap the same body in a Properties | Plans | Artifacts tab shell.
- // Plans/Artifacts get their real content in a later step; the shell itself is
- // the deliverable here, and the Properties tab is unchanged.
+ // Flag ON: wrap the same body in a Properties | Plan | Artifacts tab shell
+ // (v5 decision: singular "Plan", Docs merged into Artifacts). The Properties
+ // tab is unchanged.
return (
Properties
- Plans
+ PlanArtifacts{propertiesBody}
diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx
index 454de31084b..1c8e82a319e 100644
--- a/ui/src/components/task-chat/TaskChatBubble.tsx
+++ b/ui/src/components/task-chat/TaskChatBubble.tsx
@@ -5,10 +5,21 @@ interface TaskChatBubbleProps {
item: TaskChatMessageItem;
}
+/** Brand agent-gradient count (mirrors AgentCapsule's token pairs in index.css). */
+const GRADIENT_COUNT = 10;
+
+function capsuleGradient(index: number | undefined): string {
+ const n = Math.trunc(index ?? 1);
+ const idx = ((((n - 1) % GRADIENT_COUNT) + GRADIENT_COUNT) % GRADIENT_COUNT) + 1;
+ return `linear-gradient(to bottom, var(--agent-${idx}a), var(--agent-${idx}b))`;
+}
+
/**
* Author-typed message row — the primary legibility signal. Human messages sit
* right in a solid accent bubble; agent messages sit left in a neutral card
- * bubble with an author header; system notices are centered and recede.
+ * bubble with a capsule-avatar author header (textless vertical brand-gradient
+ * capsule + name · mode chip, per the v3/v6 prototype decisions); system
+ * notices are centered and recede.
*/
export function TaskChatBubble({ item }: TaskChatBubbleProps) {
if (item.author === "system") {
@@ -23,7 +34,16 @@ export function TaskChatBubble({ item }: TaskChatBubbleProps) {
return (
}
- renderChild={(c) => {c.id}}
- />,
- );
- });
- const lead = container.querySelector('[data-testid="turn-lead"]');
- expect(lead).not.toBeNull();
- // It sits beside the summary button on the header row — NOT inside the
- // expandable fold — so expanding the tool history can't drag it down.
- expect(fold()?.contains(lead!)).toBe(false);
- expect(summaryBtn()).not.toBeNull();
- // Expanding still works and the leading slot stays put outside the fold.
- flushSync(() => summaryBtn()!.click());
- expect(fold()?.getAttribute("data-folded")).toBe("false");
- expect(fold()?.contains(lead!)).toBe(false);
- });
-
- it("leads the settled summary with the bubble timestamp when attached (round 9)", () => {
- flushSync(() => {
- root.render(
- {c.id}} />,
- );
- });
- // "2:34 PM · ✓ Worked · …" — timestamp first, always visible, and the
- // expand affordance still works from the same line.
- expect(summaryBtn()?.textContent).toMatch(/^2:34 PM·Worked/);
- flushSync(() => summaryBtn()!.click());
- expect(fold()?.getAttribute("data-folded")).toBe("false");
- });
});
diff --git a/ui/src/components/task-chat/TaskChatTurn.tsx b/ui/src/components/task-chat/TaskChatTurn.tsx
index d66426a7431..be5d720d3c3 100644
--- a/ui/src/components/task-chat/TaskChatTurn.tsx
+++ b/ui/src/components/task-chat/TaskChatTurn.tsx
@@ -1,30 +1,16 @@
import { useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
-import { Check, ChevronRight, X } from "lucide-react";
+import { Check, ChevronDown, X } from "lucide-react";
import type { TaskChatTurnItem, TaskChatTurnChildItem } from "./task-chat-model";
-import { TaskChatStatusPill } from "./TaskChatStatusPill";
interface TaskChatTurnProps {
item: TaskChatTurnItem;
renderChild: (child: TaskChatTurnChildItem) => ReactNode;
- /**
- * When the settled turn is attached to its reply bubble (round 9), the
- * bubble's timestamp leads the summary line — "2:34 PM · ✓ Worked · 38s" —
- * always visible, in the slot the hover-only timestamp used to occupy.
- */
- timestampPrefix?: string;
- /**
- * Content rendered on the header row, leading the summary line (PAP-413: the
- * copy/👍/👎 action cluster). It sits beside the summary button — NOT inside
- * the expandable fold — so it stays anchored to the summary line when the
- * tool history expands beneath it, instead of drifting to the fold's center.
- */
- leading?: ReactNode;
}
-/** Metric segments after the label: "38s · 3 tools · +34 −3 · 12.3k tokens". */
-export function turnSummaryMetrics(summary: TaskChatTurnItem["summary"]): string {
- const parts: string[] = [];
+/** "✓ Worked · 38s · 3 tools · +34 −3 · 12.3k tokens" (parts omitted when unknown). */
+export function turnSummaryText(summary: TaskChatTurnItem["summary"]): string {
+ const parts: string[] = [summary.failed ? "Stopped" : "Worked"];
if (summary.durationLabel) parts.push(summary.durationLabel);
if (summary.toolCount > 0) parts.push(`${summary.toolCount} tool${summary.toolCount === 1 ? "" : "s"}`);
if (summary.added > 0 || summary.removed > 0) parts.push(`+${summary.added} −${summary.removed}`);
@@ -32,103 +18,51 @@ export function turnSummaryMetrics(summary: TaskChatTurnItem["summary"]): string
return parts.join(" · ");
}
-/** "✓ Worked · 38s · 3 tools · +34 −3 · 12.3k tokens" (parts omitted when unknown). */
-export function turnSummaryText(summary: TaskChatTurnItem["summary"]): string {
- const metrics = turnSummaryMetrics(summary);
- const label = summary.failed ? "Stopped" : "Worked";
- return metrics ? `${label} · ${metrics}` : label;
-}
-
/**
- * One agent turn's activity behind a single expandable header row.
+ * One agent turn's activity, foldable to a one-line summary once settled.
*
- * While the turn is live and carries `liveStatus`, that status line (whimsy or
- * taxonomy gerund + elapsed + tokens) IS the turn: one parent row, collapsed
- * by default, owning all activity (PAP-354). Expanding nests the chronological
- * tool/thinking history underneath — each row keeping its own expand — and new
- * rows append live while open. On settle the header morphs in place into the
- * "✓ Worked · …" summary, preserving the expand state, so the running line
- * reads as being replaced by the summary in the same slot.
- *
- * Both directions of the fold reuse the .tc-turn-fold grid-rows motion
- * (--motion-turn-fold, zeroed under prefers-reduced-motion). The fold class
- * only transitions on state CHANGE, so first paint in the folded state (e.g. a
- * turn that loads already settled) never animates. A live turn without
- * `liveStatus` (harness fixtures) renders its children expanded with no header
- * and folds when it settles.
+ * While the turn is live it renders fully interleaved with no summary line.
+ * When it settles the activity folds behind a "✓ Worked · …" line — animated
+ * (grid-rows via .tc-turn-fold, --motion-turn-fold) when the settle happens
+ * on-screen, instant for turns that load already settled (the fold class only
+ * transitions on state CHANGE, so first paint in the folded state never
+ * animates). Clicking the summary folds/unfolds with the same motion.
+ * prefers-reduced-motion zeroes the transition in CSS.
*/
-export function TaskChatTurn({ item, renderChild, timestampPrefix, leading }: TaskChatTurnProps) {
- const parentRow = !item.settled && item.liveStatus != null;
- // Parent-row live turns and settled turns start as their one-line header;
- // only the headerless legacy live turn starts expanded.
- const [open, setOpen] = useState(() => !item.settled && item.liveStatus == null);
+export function TaskChatTurn({ item, renderChild }: TaskChatTurnProps) {
+ const [open, setOpen] = useState(!item.settled);
const [prevSettled, setPrevSettled] = useState(item.settled);
- const [wasParentRow, setWasParentRow] = useState(parentRow);
- // Render-adjusted state (not effects) so transitions land in the same
- // commit — no flash of the wrong fold state.
- if (parentRow && !wasParentRow) setWasParentRow(true);
- // Live → settled while mounted: a parent-row turn keeps its expand state
- // (the header morphs in place); a headerless expanded turn folds (animated
- // via CSS unless reduced motion).
+ // Live → settled transition observed while mounted: fold (animated via CSS
+ // unless reduced motion). Adjusted during render (not in an effect) so the
+ // fold state lands in the same commit — no unfolded flash. Turns mounted
+ // already-settled start folded via the useState initializers.
if (item.settled !== prevSettled) {
setPrevSettled(item.settled);
- if (item.settled && !wasParentRow) setOpen(false);
+ if (item.settled) setOpen(false);
}
- const expandable = item.items.length > 0;
- const folded = (item.settled || parentRow) && !open;
+ const folded = item.settled && !open;
const SummaryIcon = item.summary.failed ? X : Check;
- const header = item.settled ? (
-
- ) : parentRow ? (
- // The pill renders the expand button itself, wrapped around only the
- // gerund status line — the interstitial row above stays outside the
- // hover/click target (PAP-376). Without activity there is no
- // chevron/button.
- setOpen((o) => !o) : undefined}
- />
- ) : null;
-
return (
- {leading ? (
- // Actions ride the header row (items-center matches them to the summary
- // line), and the fold below is a separate sibling — so expanding the
- // tool history never moves the actions off the summary line (PAP-413).
-
))}
diff --git a/ui/src/components/task-chat/TaskMessageScroller.tsx b/ui/src/components/task-chat/TaskMessageScroller.tsx
index 629b08ce078..eb839e0e450 100644
--- a/ui/src/components/task-chat/TaskMessageScroller.tsx
+++ b/ui/src/components/task-chat/TaskMessageScroller.tsx
@@ -4,6 +4,19 @@ import { ArrowDown } from "lucide-react";
const PIN_THRESHOLD_PX = 48;
+/** Visibility lifecycle of the scroll-to-latest pill. */
+type PillPhase = "hidden" | "in" | "out";
+
+/**
+ * True when animations are disabled (prefers-reduced-motion, or environments
+ * without matchMedia such as jsdom). In that case the pill's exit animation
+ * never fires animationend, so we must unmount immediately.
+ */
+function motionDisabled(): boolean {
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return true;
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+}
+
interface TaskMessageScrollerProps {
children: ReactNode;
/** Value that changes whenever content that could grow the thread updates. */
@@ -15,16 +28,33 @@ interface TaskMessageScrollerProps {
* Scroll container that owns the redesign's auto-follow vs. hold-position rule.
*
* Explicit rule: while the viewport is pinned to the bottom (within a small
- * threshold) new content auto-follows; the moment the user scrolls up we hold
- * their position and surface a "Jump to latest" affordance instead of yanking
- * them down. Scrolling is INSTANT (not smooth) so a scroll listener cannot
- * unpin mid-animation — a known stick-to-bottom failure mode. No reflow/jump is
- * introduced during streaming because we only ever scroll when already pinned.
+ * threshold) new content auto-follows with INSTANT scroll; the moment the user
+ * scrolls up we hold their position and surface a scroll-to-latest pill
+ * instead of yanking them down.
+ *
+ * Re-follow is easing-aware: clicking the pill glides down smoothly, and while
+ * that glide is in flight (`easingRef`) the scroll handler must not unpin —
+ * the smooth scroll fires intermediate scroll events that would otherwise
+ * re-show the pill mid-glide (the known stick-to-bottom failure mode). Arrival
+ * within the pin threshold re-pins and hides the pill; a wheel/touch during
+ * the glide cancels it and treats the user as unpinned. Content-driven follow
+ * while pinned stays instant, so no reflow/jump happens during streaming.
*/
export function TaskMessageScroller({ children, contentKey, className }: TaskMessageScrollerProps) {
const ref = useRef(null);
const pinnedRef = useRef(true);
- const [showJump, setShowJump] = useState(false);
+ const easingRef = useRef(false);
+ const [pillPhase, setPillPhase] = useState("hidden");
+
+ const showPill = useCallback(() => {
+ setPillPhase("in");
+ }, []);
+
+ const hidePill = useCallback(() => {
+ // Keep rendering through the exit animation; unmount on animationend.
+ // Without animations (reduced motion / no matchMedia) unmount immediately.
+ setPillPhase((phase) => (phase === "hidden" ? phase : motionDisabled() ? "hidden" : "out"));
+ }, []);
const isPinned = useCallback(() => {
const el = ref.current;
@@ -40,9 +70,60 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
const handleScroll = useCallback(() => {
const pinned = isPinned();
+ if (easingRef.current) {
+ // Smooth re-follow in flight: intermediate scroll events must not
+ // unpin/re-show the pill. Only act once we arrive within the threshold.
+ if (pinned) {
+ easingRef.current = false;
+ pinnedRef.current = true;
+ hidePill();
+ }
+ return;
+ }
pinnedRef.current = pinned;
- setShowJump(!pinned);
- }, [isPinned]);
+ if (pinned) hidePill();
+ else showPill();
+ }, [isPinned, hidePill, showPill]);
+
+ const handleJumpToLatest = useCallback(() => {
+ const el = ref.current;
+ if (!el) return;
+ if (isPinned()) {
+ // Already at (or within threshold of) the bottom: no glide needed.
+ pinnedRef.current = true;
+ hidePill();
+ return;
+ }
+ easingRef.current = true;
+ if (typeof el.scrollTo === "function") {
+ el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
+ } else {
+ // Environments without scrollTo (older jsdom): fall back to instant.
+ el.scrollTop = el.scrollHeight;
+ easingRef.current = false;
+ pinnedRef.current = true;
+ hidePill();
+ }
+ }, [isPinned, hidePill]);
+
+ // A user gesture during the smooth glide cancels the re-follow: stop
+ // treating scroll events as easing and consider the user unpinned.
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ const cancelEasing = () => {
+ if (!easingRef.current) return;
+ easingRef.current = false;
+ pinnedRef.current = false;
+ showPill();
+ };
+ el.addEventListener("wheel", cancelEasing, { passive: true });
+ el.addEventListener("touchstart", cancelEasing, { passive: true });
+ return () => {
+ el.removeEventListener("wheel", cancelEasing);
+ el.removeEventListener("touchstart", cancelEasing);
+ };
+ }, [showPill]);
// Follow new content only when already pinned; otherwise hold position.
useLayoutEffect(() => {
@@ -64,18 +145,22 @@ export function TaskMessageScroller({ children, contentKey, className }: TaskMes
>
{children}