mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
feat(ui): interstitial updates on the live line — F10 line-scroll revived (LiveSelfTalkLine, .tc-line-scroll, --motion-line-scroll), ephemeral messages, settled turns slim to tool rows only (thinking rows removed, TaskChatThinkingBlock deleted)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ad1ed3cca3
commit
39e30b51ab
14 files changed
+313
-270
No files matched your search
@@ -51,12 +51,12 @@ export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread>;
|
||||
* Run activity is grouped into TaskChatTurnItem: the in-flight run renders as
|
||||
* ONE expandable parent row — its status line (whimsy gerund or in-flight
|
||||
* tool state + elapsed + tokens) is the turn's single visible line, with the
|
||||
* chronological tool/thinking activity nested behind an expand (PAP-354).
|
||||
* Mid-run agent text (interstitial self-talk) is ambient signal only
|
||||
* (PAP-357): it never renders as text in this view — while it streams the
|
||||
* parent row's status word glints (tc-shimmer-narrate) and the pulse dot
|
||||
* swaps to the Responding icon; the words themselves stay in the run log and
|
||||
* the classic transcript. When the run terminates the
|
||||
* chronological tool activity nested behind an expand (PAP-354). Mid-run
|
||||
* agent text (interstitial updates between tool calls) is ephemeral
|
||||
* (PAP-361): while one streams it takes the parent row's line inside a
|
||||
* one-line line-scroll viewport, and when it finishes the line returns to the
|
||||
* gerund/tool rotation — nothing persists; the run log and the classic
|
||||
* transcript remain the archive. When the run terminates the
|
||||
* same turn id flips settled, so the header morphs in place into the one-line
|
||||
* "✓ Worked · …" summary (expand state preserved; runs already terminal at
|
||||
* mount collapse instantly). Settled turns interleave after the run's last
|
||||
@@ -246,9 +246,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
|
||||
const items = useMemo<TaskChatItem[]>(() => {
|
||||
// Settled turns for every terminal run whose transcript we have. Messages
|
||||
// are excluded entirely (PAP-357): the final reply already landed as the
|
||||
// run's comment bubble, and interstitial self-talk is ambient — it stays
|
||||
// in the run log / classic transcript, never in this thread.
|
||||
// and thinking are excluded entirely (PAP-361): the final reply already
|
||||
// landed as the run's comment bubble, interstitial updates are ephemeral
|
||||
// (live-line only), and thinking stays in the run log / classic
|
||||
// transcript — "Worked · N tools" expands to exactly the tool rows.
|
||||
const settledTurns: { turn: TaskChatTurnItem; anchorCommentId: string | null; order: number }[] = [];
|
||||
for (const source of runs) {
|
||||
if (!isTerminalRunStatus(source.status)) continue;
|
||||
@@ -315,15 +316,15 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
running: true,
|
||||
});
|
||||
// Parent-row model (PAP-354): the run's status line IS the live turn —
|
||||
// one expandable row owning the activity. Only tool/thinking/usage rows
|
||||
// nest inside it; mid-run self-talk is suppressed (PAP-357) and signals
|
||||
// through the header's narrating glint instead. The parent row sits
|
||||
// last — the slot its settled summary takes over in place.
|
||||
// one expandable row owning the activity. Only tool/usage rows nest
|
||||
// inside it; a streaming interstitial update takes the header's own line
|
||||
// (liveStatus.selfTalk, PAP-361) and vanishes when it completes. The
|
||||
// parent row sits last — the slot its settled summary takes over in place.
|
||||
const children = parsed.filter(isNestableLiveChild);
|
||||
const startedAt = liveRun.startedAt ? new Date(liveRun.startedAt).getTime() : null;
|
||||
const queued = liveRun.status === "queued";
|
||||
const status = queued
|
||||
? { label: "Queued", detail: "Waiting to start", toolName: undefined, narrating: undefined }
|
||||
? { label: "Queued", detail: "Waiting to start", toolName: undefined, selfTalk: undefined }
|
||||
: deriveRunStatusLabel(entries);
|
||||
out.push({
|
||||
id: `${liveRun.id}:turn`,
|
||||
@@ -338,7 +339,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
label: status.label,
|
||||
detail: status.detail,
|
||||
toolName: status.toolName,
|
||||
narrating: status.narrating,
|
||||
selfTalk: status.selfTalk,
|
||||
startedAtMs: startedAt ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -34,9 +34,10 @@ function initialsForName(name: string) {
|
||||
*/
|
||||
export function TaskChatBubble({ item }: TaskChatBubbleProps) {
|
||||
if (item.interstitial) {
|
||||
// Mid-run self-talk is ambient signal, not content (PAP-357): the parent
|
||||
// row's status word glints while it streams, and the text stays in the
|
||||
// run log / classic transcript. Never rendered in this view.
|
||||
// Interstitial updates are ephemeral (PAP-361): while streaming the text
|
||||
// lives on the live parent row's line (TaskChatStatusItem.selfTalk), and
|
||||
// once finished it renders nowhere — the run log / classic transcript
|
||||
// remain the archive. Never rendered as a bubble.
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TaskChatStatusPill } from "./TaskChatStatusPill";
|
||||
import { lineScrollOffset, TaskChatStatusPill } from "./TaskChatStatusPill";
|
||||
import { whimsyWord, WHIMSY_ROTATE_MS } from "./status-whimsy";
|
||||
import type { TaskChatStatusItem } from "./task-chat-model";
|
||||
|
||||
@@ -90,36 +90,52 @@ describe("TaskChatStatusPill whimsy", () => {
|
||||
expect(container.textContent).toContain("18,240/200,000 ctx");
|
||||
});
|
||||
|
||||
it("keeps the gerund and glints while self-talk streams (PAP-357)", () => {
|
||||
it("hands the line to streaming interstitial text, keeping elapsed · tokens meta (PAP-361)", () => {
|
||||
const item = liveStatus({
|
||||
label: "Responding",
|
||||
narrating: true,
|
||||
selfTalk: "I'll extend the ipRateLimit helper with a per-account bucket.",
|
||||
tokens: { used: 18240, size: 200000 },
|
||||
});
|
||||
render(item);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1200);
|
||||
});
|
||||
// Nothing new to read: the whimsy gerund holds the line (no streamed text,
|
||||
// no "Responding…" copy) and the right-side meta stays put.
|
||||
expect(container.textContent).toContain(`${whimsyWord(item.id, 1200)}…`);
|
||||
const line = container.querySelector('[data-testid="task-chat-live-self-talk"]');
|
||||
expect(line).not.toBeNull();
|
||||
expect(line?.textContent).toBe("I'll extend the ipRateLimit helper with a per-account bucket.");
|
||||
// The gerund/whimsy label yields the line; the right-side meta stays put,
|
||||
// and the pulse dot renders as ever.
|
||||
expect(container.textContent).not.toContain("Responding…");
|
||||
expect(container.textContent).not.toContain(`${whimsyWord(item.id, 1200)}…`);
|
||||
expect(container.textContent).toContain("1.2s");
|
||||
expect(container.textContent).toContain("18,240/200,000 ctx");
|
||||
// The status word glints via the narration shimmer class…
|
||||
const word = container.querySelector(".tc-shimmer-narrate");
|
||||
expect(word).not.toBeNull();
|
||||
expect(word?.textContent).toBe(`${whimsyWord(item.id, 1200)}…`);
|
||||
// …and the pulse dot stays put — the glint is the sole narration signal
|
||||
// (PAP-359: no icon swap).
|
||||
expect(container.querySelector('[data-testid="task-chat-narrating-icon"]')).toBeNull();
|
||||
expect(container.querySelector(".animate-pulse")).not.toBeNull();
|
||||
// Viewport + transition classes: 1lh clip outside, transform-only inner.
|
||||
expect(line?.className).toContain("tc-line-scroll");
|
||||
expect(line?.firstElementChild?.className).toContain("tc-line-scroll-inner");
|
||||
});
|
||||
|
||||
it("carries no glint when not narrating, dot always present", () => {
|
||||
render(liveStatus({ tokens: { used: 18240, size: 200000 } }));
|
||||
expect(container.querySelector(".tc-shimmer-narrate")).toBeNull();
|
||||
expect(container.querySelector('[data-testid="task-chat-narrating-icon"]')).toBeNull();
|
||||
it("returns the line to the gerund once the interstitial completes", () => {
|
||||
const item = liveStatus({ tokens: { used: 18240, size: 200000 } });
|
||||
render(liveStatus({ label: "Responding", selfTalk: "Almost there." }));
|
||||
render(item);
|
||||
// selfTalk gone → no live line, whimsy gerund back, dot still present.
|
||||
expect(container.querySelector('[data-testid="task-chat-live-self-talk"]')).toBeNull();
|
||||
expect(container.textContent).toContain(`${whimsyWord(item.id, 0)}…`);
|
||||
expect(container.querySelector(".animate-pulse")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("lineScrollOffset", () => {
|
||||
it("translates up one line-height per wrapped line", () => {
|
||||
expect(lineScrollOffset(16, 16)).toBe(0); // one line — no shift
|
||||
expect(lineScrollOffset(32, 16)).toBe(1);
|
||||
expect(lineScrollOffset(48.5, 16)).toBe(2); // sub-pixel scrollHeight rounds
|
||||
});
|
||||
|
||||
it("never goes negative and guards bad measurements", () => {
|
||||
expect(lineScrollOffset(0, 16)).toBe(0);
|
||||
expect(lineScrollOffset(32, 0)).toBe(0);
|
||||
expect(lineScrollOffset(Number.NaN, 16)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronRight, Loader2, ShieldQuestion, OctagonX, Ban, Scissors } from "lucide-react";
|
||||
import type { TaskChatStatusItem } from "./task-chat-model";
|
||||
@@ -31,6 +31,55 @@ function useLiveElapsedMs(startedAtMs: number | undefined, live: boolean): numbe
|
||||
return startedAtMs == null ? undefined : Math.max(0, now - startedAtMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many line-heights the streaming interstitial block is shifted up so only
|
||||
* its LAST line shows in the 1lh viewport: lines − 1 (never negative).
|
||||
*/
|
||||
export function lineScrollOffset(scrollHeight: number, lineHeightPx: number): number {
|
||||
if (!Number.isFinite(scrollHeight) || !Number.isFinite(lineHeightPx) || lineHeightPx <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Math.round(scrollHeight / lineHeightPx) - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* A streaming interstitial update on the parent row's line: the text streams
|
||||
* inside a one-line-height clipped viewport; when it wraps, the inner block
|
||||
* translates up by whole line-heights (--motion-line-scroll, transform-only,
|
||||
* snaps under reduced motion) so the completed line slides out the top while
|
||||
* the stream continues on the fresh line — the same motion grammar as tool
|
||||
* rows animating in. Line count is measured from scrollHeight / line-height,
|
||||
* re-checked on resize.
|
||||
*/
|
||||
function LiveSelfTalkLine({ text }: { text: string }) {
|
||||
const innerRef = useRef<HTMLSpanElement | null>(null);
|
||||
const [offset, setOffset] = useState(0);
|
||||
useLayoutEffect(() => {
|
||||
const el = innerRef.current;
|
||||
if (!el) return;
|
||||
const measure = () => {
|
||||
const lineHeight = parseFloat(window.getComputedStyle(el).lineHeight);
|
||||
setOffset(lineScrollOffset(el.scrollHeight, lineHeight));
|
||||
};
|
||||
measure();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [text]);
|
||||
return (
|
||||
<span className="tc-line-scroll min-w-0 flex-1" data-testid="task-chat-live-self-talk">
|
||||
<span
|
||||
ref={innerRef}
|
||||
className="tc-line-scroll-inner block"
|
||||
style={offset > 0 ? { transform: `translateY(calc(${-offset} * 1lh))` } : undefined}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const CONFIG = {
|
||||
running: { Icon: Loader2, spin: true, tone: "text-(--status-agent-running)" },
|
||||
working: { Icon: Loader2, spin: true, tone: "text-(--status-agent-running)" },
|
||||
@@ -65,21 +114,51 @@ export function TaskChatStatusPill({ item, onApprovalDecision, chevronOpen }: Ta
|
||||
const liveElapsedMs = useLiveElapsedMs(item.startedAtMs, live);
|
||||
const elapsed = elapsedLabel(item.elapsedMs);
|
||||
|
||||
if (live && item.selfTalk) {
|
||||
// A streaming interstitial update takes the line (replacing the gerund) in
|
||||
// the muted tool-row style; right-side meta (elapsed · tokens) stays put.
|
||||
// The row alternates: whimsy gerund ↔ in-flight tool ↔ interstitial text.
|
||||
const liveElapsed = liveElapsedLabel(liveElapsedMs ?? item.elapsedMs);
|
||||
const SelfTalkIcon = statusLabelIcon("Responding");
|
||||
return (
|
||||
<div className="tc-enter-status flex items-center gap-2 py-0.5 text-xs text-muted-foreground">
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 animate-pulse rounded-full bg-(--status-agent-running)"
|
||||
/>
|
||||
</span>
|
||||
{SelfTalkIcon ? <SelfTalkIcon className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
|
||||
<LiveSelfTalkLine text={item.selfTalk} />
|
||||
{liveElapsed ? (
|
||||
<span className="shrink-0 font-mono tabular-nums text-(length:--text-micro)">
|
||||
{liveElapsed}
|
||||
</span>
|
||||
) : null}
|
||||
{item.tokens ? (
|
||||
<span className="shrink-0 font-mono text-(length:--text-micro)">
|
||||
{item.tokens.used.toLocaleString()}/{item.tokens.size.toLocaleString()} ctx
|
||||
</span>
|
||||
) : null}
|
||||
{chevronOpen !== undefined ? (
|
||||
<ChevronRight
|
||||
className={cn("h-3 w-3 shrink-0 transition-transform", chevronOpen ? "rotate-90" : null)}
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (live) {
|
||||
const liveElapsed = liveElapsedLabel(liveElapsedMs ?? item.elapsedMs);
|
||||
// Ambient self-talk (PAP-357/PAP-359): while an interstitial streams,
|
||||
// nothing new appears to read — the gerund holds the line and the word
|
||||
// glints (tc-shimmer-narrate); the pulse dot stays put throughout.
|
||||
const narrating = item.narrating === true;
|
||||
const ToolIcon = item.toolName
|
||||
? toolTaxonomy(item.toolName).icon
|
||||
: statusLabelIcon(item.label);
|
||||
// Whimsy fills gaps, never replaces signal: only the generic
|
||||
// "Running"/"Working" labels — and the narrating state, whose "Responding"
|
||||
// is signalled by the glint instead of copy — swap for a deterministic
|
||||
// whimsical gerund (seeded by the run-scoped item id, rotating ~10s via
|
||||
// the live tick).
|
||||
const label = narrating || isGenericStatusLabel(item.label)
|
||||
// "Running"/"Working" labels swap for a deterministic whimsical gerund
|
||||
// (seeded by the run-scoped item id, rotating ~10s via the live tick).
|
||||
const label = isGenericStatusLabel(item.label)
|
||||
? whimsyWord(item.id, liveElapsedMs ?? item.elapsedMs ?? 0)
|
||||
: item.label;
|
||||
return (
|
||||
@@ -92,18 +171,11 @@ export function TaskChatStatusPill({ item, onApprovalDecision, chevronOpen }: Ta
|
||||
className="h-2 w-2 animate-pulse rounded-full bg-(--status-agent-running)"
|
||||
/>
|
||||
</span>
|
||||
{!narrating && ToolIcon ? (
|
||||
<ToolIcon className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
) : null}
|
||||
{ToolIcon ? <ToolIcon className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
|
||||
{/* Text cells share a baseline row so the smaller mono readouts don't
|
||||
float above the label's baseline. */}
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"shimmer-text shimmer-text-muted shrink-0 font-medium",
|
||||
narrating && "tc-shimmer-narrate",
|
||||
)}
|
||||
>
|
||||
<span className="shimmer-text shimmer-text-muted shrink-0 font-medium">
|
||||
{label}…
|
||||
</span>
|
||||
{liveElapsed ? (
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { TaskChatThinkingBlock } from "./TaskChatThinkingBlock";
|
||||
import type { TaskChatThinkingItem } from "./task-chat-model";
|
||||
|
||||
function thinking(overrides: Partial<TaskChatThinkingItem> = {}): TaskChatThinkingItem {
|
||||
return {
|
||||
id: "run-42:thought-1",
|
||||
kind: "thinking",
|
||||
lines: ["First, look at the failing test.", "The mock is stale."],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TaskChatThinkingBlock (PAP-359 tool-row grammar)", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
function render(item: TaskChatThinkingItem) {
|
||||
act(() => {
|
||||
root.render(<TaskChatThinkingBlock item={item} />);
|
||||
});
|
||||
}
|
||||
|
||||
it("leads the header with the Brain icon in the tool-icon slot, no rail on the header", () => {
|
||||
render(thinking({ collapsed: true, summaryLabel: "Thought for 4s" }));
|
||||
const header = container.querySelector("button");
|
||||
expect(header).not.toBeNull();
|
||||
// Brain icon fills the same h-3.5/w-3.5 lead slot as tool-row icons.
|
||||
const icon = header?.querySelector("svg.lucide-brain");
|
||||
expect(icon).not.toBeNull();
|
||||
expect(icon?.getAttribute("class")).toContain("h-3.5");
|
||||
// The old left rail is gone from the header level.
|
||||
expect(header?.className).not.toContain("border-l-2");
|
||||
expect(container.firstElementChild?.className).not.toContain("border-l-2");
|
||||
expect(container.textContent).toContain("Thought for 4s");
|
||||
});
|
||||
|
||||
it("streams open with the shimmering Thinking… label and rails only the body", () => {
|
||||
render(thinking({ streaming: true }));
|
||||
const shimmer = container.querySelector(".shimmer-text");
|
||||
expect(shimmer?.textContent).toBe("Thinking…");
|
||||
// Body is expanded and keeps the recessed inset rail.
|
||||
expect(container.textContent).toContain("First, look at the failing test.");
|
||||
const body = container.querySelector("div.border-l-2");
|
||||
expect(body).not.toBeNull();
|
||||
expect(body?.contains(container.querySelector("button"))).toBe(false);
|
||||
});
|
||||
|
||||
it("settled history arrives collapsed and toggles on click with aria-expanded", () => {
|
||||
render(thinking({ collapsed: true, summaryLabel: "Thought for 4s" }));
|
||||
const header = container.querySelector("button")!;
|
||||
expect(header.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(container.textContent).not.toContain("The mock is stale.");
|
||||
act(() => {
|
||||
header.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(header.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(container.textContent).toContain("The mock is stale.");
|
||||
act(() => {
|
||||
header.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(header.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(container.textContent).not.toContain("The mock is stale.");
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { TaskChatThinkingItem } from "./task-chat-model";
|
||||
import { statusLabelIcon } from "./tool-taxonomy";
|
||||
|
||||
/**
|
||||
* Chain-of-thought (ACP agent_thought_chunk) in nested tool-row grammar
|
||||
* (PAP-359): a Brain-led header at the same level as tool rows — no rail or
|
||||
* extra indent on the header itself — whose label shimmers while streaming
|
||||
* ("Thinking…") and settles to "Thought for Ns". Clicking toggles the recessed
|
||||
* pre-wrap thought body (left-rail inset, like tool detail). Streams open;
|
||||
* settled history arrives collapsed and re-expands on click. No card chrome —
|
||||
* thinking is activity metadata, subordinate to real content.
|
||||
*/
|
||||
export function TaskChatThinkingBlock({ item }: { item: TaskChatThinkingItem }) {
|
||||
const [open, setOpen] = useState(!item.collapsed);
|
||||
const settledLabel = item.summaryLabel ?? "Thought process";
|
||||
const BrainIcon = statusLabelIcon("Thinking");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col text-xs text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="group/think -mx-1.5 flex min-w-0 items-center gap-2 rounded-sm px-1.5 py-0.5 text-left transition-colors hover:bg-muted/60 hover:text-foreground"
|
||||
>
|
||||
{BrainIcon ? <BrainIcon className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
|
||||
{item.streaming ? (
|
||||
<span className="shimmer-text shimmer-text-muted font-medium">Thinking…</span>
|
||||
) : (
|
||||
<span className="font-medium">{settledLabel}</span>
|
||||
)}
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"ml-auto h-3 w-3 shrink-0 opacity-0 transition-[opacity,transform] group-hover/think:opacity-80",
|
||||
open ? "rotate-90 opacity-80" : null,
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="ml-2.5 mt-0.5 space-y-1 border-l-2 border-border py-1 pl-2.5">
|
||||
{item.lines.map((line, i) => (
|
||||
<p
|
||||
key={i}
|
||||
className="tc-enter-cot-line whitespace-pre-wrap"
|
||||
style={{ animationDelay: `calc(var(--motion-cot-line-stagger) * ${i})` }}
|
||||
>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
{item.streaming ? (
|
||||
<span className="tc-cursor inline-block h-3 w-1.5 bg-muted-foreground align-baseline" />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { TaskChatBubble } from "./TaskChatBubble";
|
||||
import { TaskChatMarker } from "./TaskChatMarker";
|
||||
import { TaskChatStatusPill } from "./TaskChatStatusPill";
|
||||
import { TaskChatToolCard } from "./TaskChatToolCard";
|
||||
import { TaskChatThinkingBlock } from "./TaskChatThinkingBlock";
|
||||
import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
|
||||
import { TaskMessageScroller } from "./TaskMessageScroller";
|
||||
|
||||
@@ -41,7 +40,11 @@ function renderItem(
|
||||
case "marker":
|
||||
return <TaskChatMarker item={item} />;
|
||||
case "thinking":
|
||||
return <TaskChatThinkingBlock item={item} />;
|
||||
// Thinking never renders as a row (PAP-361): its live signal is the
|
||||
// pill's "Thinking…" state, and the text stays in the run log / classic
|
||||
// transcript. The kind survives in the model because the transcript
|
||||
// parser still emits it (both nesting rules filter it out).
|
||||
return null;
|
||||
case "tool":
|
||||
return <TaskChatToolCard item={item} />;
|
||||
case "status":
|
||||
@@ -111,10 +114,13 @@ function signatureOf(it: TaskChatItem): number {
|
||||
if (it.kind === "tool") return (it.diff?.lines?.length ?? 0) + (it.status === "completed" ? 1 : 0);
|
||||
if (it.kind === "turn") {
|
||||
if (it.settled) return 1;
|
||||
// The live parent row's header changes (gerund ↔ tool-state flashes)
|
||||
// count too, so the collapsed single-line turn still advances the key.
|
||||
// The live parent row's header changes (gerund ↔ tool-state flashes,
|
||||
// streaming interstitial text growing) count too, so the collapsed
|
||||
// single-line turn still advances the key.
|
||||
const headerSig = it.liveStatus
|
||||
? it.liveStatus.label.length + (it.liveStatus.detail?.length ?? 0)
|
||||
? it.liveStatus.label.length +
|
||||
(it.liveStatus.detail?.length ?? 0) +
|
||||
(it.liveStatus.selfTalk?.length ?? 0)
|
||||
: 0;
|
||||
return it.items.reduce((n, child) => n + signatureOf(child), it.items.length + headerSig);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export const MOTION_TOKENS: MotionTokenDef[] = [
|
||||
{ name: "--motion-count-tween", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-streaming-cursor-blink", group: "States", kind: "time", min: 0, max: 3000, step: 20 },
|
||||
{ name: "--motion-turn-fold", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-shimmer", group: "States", kind: "time", min: 0, max: 5000, step: 50 },
|
||||
{ name: "--motion-line-scroll", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-scroll-pill-enter", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-scroll-pill-exit", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
{ name: "--motion-pane-glide", group: "States", kind: "time", min: 0, max: 1500, step: 10 },
|
||||
|
||||
@@ -54,17 +54,30 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario {
|
||||
],
|
||||
};
|
||||
case "thinking":
|
||||
// The surviving thinking signal (PAP-361): the live line's "Thinking…"
|
||||
// state — Brain icon + shimmer on the pill. Thinking rows no longer
|
||||
// render in the thread or nest under turns.
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
...exchangePrefix(),
|
||||
{ id: "th-1", kind: "thinking", streaming: true, lines: ["The login route is in server/src/routes/auth.ts.", "There's already an ipRateLimit helper I can reuse.", "I'll add a per-account bucket keyed on the email."] },
|
||||
{
|
||||
id: "turn-thinking",
|
||||
kind: "turn",
|
||||
settled: false,
|
||||
summary: { toolCount: 1, added: 0, removed: 0 },
|
||||
liveStatus: { id: "st-thinking", kind: "status", status: "running", label: "Thinking", startedAtMs: Date.now() - 6100, tokens: { used: 18240, size: 200000 } },
|
||||
items: [
|
||||
{ id: "th-grep", kind: "tool", name: "Grep", target: "rateLimit", toolKind: "search", status: "completed" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
case "responding":
|
||||
// Ambient self-talk (PAP-357): while narration streams, the parent row
|
||||
// keeps its gerund — the status word glints (--motion-shimmer) and the
|
||||
// pulse dot swaps to the Responding icon. Nothing new to read.
|
||||
// A streaming interstitial update takes the parent row's line (PAP-361):
|
||||
// it streams where the gerund sat and wraps into the 1lh viewport
|
||||
// line-scroll. Ephemeral — when it finishes, the line returns to the
|
||||
// gerund/tool rotation and the text renders nowhere.
|
||||
return {
|
||||
surface: "thread",
|
||||
items: [
|
||||
@@ -74,7 +87,11 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario {
|
||||
kind: "turn",
|
||||
settled: false,
|
||||
summary: { toolCount: 1, added: 0, removed: 0 },
|
||||
liveStatus: { id: "st-responding", kind: "status", status: "running", label: "Responding", narrating: true, startedAtMs: Date.now() - 9300, tokens: { used: 18240, size: 200000 } },
|
||||
liveStatus: {
|
||||
id: "st-responding", kind: "status", status: "running", label: "Responding", startedAtMs: Date.now() - 9300, tokens: { used: 18240, size: 200000 },
|
||||
selfTalk:
|
||||
"I found an existing ipRateLimit helper, so I'll extend it with a per-account token bucket keyed on the email address instead of adding a second limiter. The bucket refills at six requests a minute, matching the lockout policy the auth spec documents, and failed attempts drain it twice as fast so brute-force runs hit the ceiling quickly while a fat-fingered password barely registers.",
|
||||
},
|
||||
items: [
|
||||
{ id: "resp-read", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "completed" },
|
||||
],
|
||||
@@ -140,7 +157,6 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario {
|
||||
summary: { toolCount: 1, added: 0, removed: 0 },
|
||||
liveStatus: { id: "st-running", kind: "status", status: "running", label: "Running", detail: "no output for 3s — still running", startedAtMs: Date.now() - 12000, tokens: { used: 18240, size: 200000 } },
|
||||
items: [
|
||||
{ id: "r-think", kind: "thinking", lines: ["The login route is in server/src/routes/auth.ts.", "Check for an existing limiter before writing one."] },
|
||||
{ id: "r-grep", kind: "tool", name: "Grep", target: "rateLimit", toolKind: "search", status: "completed" },
|
||||
],
|
||||
},
|
||||
@@ -155,10 +171,10 @@ export function buildScenario(id: TaskChatStateId): TaskChatScenario {
|
||||
id: "turn-done",
|
||||
kind: "turn",
|
||||
settled: true,
|
||||
// toolCount matches the nested tool rows exactly (PAP-357 parity).
|
||||
// "Worked · N tools" expands to exactly the tool rows (PAP-361):
|
||||
// toolCount matches the nested rows, no thinking row.
|
||||
summary: { durationLabel: "38s", toolCount: 2, added: 34, removed: 3, tokensLabel: "12.3k tokens" },
|
||||
items: [
|
||||
{ id: "th-done", kind: "thinking", lines: ["Read auth.ts", "Added rate-limiter util", "Wired into POST /login"] },
|
||||
{ id: "tool-done-1", kind: "tool", name: "Read", target: "server/src/routes/auth.ts", toolKind: "read", status: "completed" },
|
||||
{ id: "tool-done-2", kind: "tool", name: "Edit", target: "server/src/routes/auth.ts", toolKind: "edit", status: "completed", diff: { path: "server/src/routes/auth.ts", added: 34, removed: 3 } },
|
||||
],
|
||||
|
||||
@@ -77,11 +77,11 @@ export interface TaskChatMessageItem {
|
||||
/** Assigned agent icon name (AgentIconName) for the avatar header. */
|
||||
agentIcon?: string | null;
|
||||
/**
|
||||
* Agent self-talk streamed inside a run turn (interstitial narration between
|
||||
* tool calls). Suppressed in the redesigned view (PAP-357) — it only
|
||||
* indicates activity via the parent row's narrating glint, and the text
|
||||
* stays in the run log / classic transcript. Tagged for live and settled
|
||||
* transcripts alike.
|
||||
* Agent text streamed inside a run turn (interstitial updates between tool
|
||||
* calls). Ephemeral in the redesigned view (PAP-361): while streaming it
|
||||
* takes the live parent row's line (TaskChatStatusItem.selfTalk); once
|
||||
* finished it renders nowhere — the run log / classic transcript remain the
|
||||
* archive. Tagged for live and settled transcripts alike.
|
||||
*/
|
||||
interstitial?: boolean;
|
||||
/** Epoch ms of the message's first streamed chunk. */
|
||||
@@ -141,12 +141,13 @@ export interface TaskChatStatusItem {
|
||||
/** Raw tool name of the in-flight tail tool_call (drives the pill's icon). */
|
||||
toolName?: string;
|
||||
/**
|
||||
* True while interstitial self-talk streams at the run's tail (PAP-357).
|
||||
* Ambient signal only: the status word keeps its gerund but glints
|
||||
* (--motion-shimmer) and the pulse dot swaps to the Responding icon. The
|
||||
* streamed text is never shown on the line.
|
||||
* Flattened plain text of the interstitial update currently streaming
|
||||
* (PAP-361): it takes over the live parent row's line (replacing the gerund)
|
||||
* inside a one-line viewport that scrolls completed lines out the top.
|
||||
* Ephemeral — when the message finishes the line returns to the gerund/tool
|
||||
* rotation, and nothing persists in the thread.
|
||||
*/
|
||||
narrating?: boolean;
|
||||
selfTalk?: string;
|
||||
elapsedMs?: number;
|
||||
/** Run start epoch ms; live states tick their own elapsed from this. */
|
||||
startedAtMs?: number;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TranscriptEntry } from "@/adapters";
|
||||
import {
|
||||
buildTurnSummary,
|
||||
deriveRunStatusLabel,
|
||||
flattenSelfTalk,
|
||||
isNestableLiveChild,
|
||||
settledRunChildren,
|
||||
toolDisplayName,
|
||||
@@ -223,18 +224,19 @@ describe("isNestableLiveChild", () => {
|
||||
{ id: "i", kind: "interaction", interaction: {} as never },
|
||||
];
|
||||
|
||||
it("nests only tool, thinking and usage rows inside the live parent row", () => {
|
||||
it("nests only tool and usage rows inside the live parent row (PAP-361)", () => {
|
||||
expect(items.filter(isNestableLiveChild).map((it) => it.kind)).toEqual([
|
||||
"tool",
|
||||
"thinking",
|
||||
"usage",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps messages, markers, statuses and interactions out (PAP-357)", () => {
|
||||
// Interstitial self-talk — finished or streaming — never nests: it is
|
||||
// ambient signal suppressed in this view. The final reply keeps its bubble.
|
||||
for (const kind of ["message", "marker", "status", "interaction"]) {
|
||||
it("keeps messages, thinking, markers, statuses and interactions out", () => {
|
||||
// Interstitial updates — finished or streaming — never nest: they are
|
||||
// ephemeral, living on the live line only while streaming. Thinking never
|
||||
// nests either (its live signal is the pill's "Thinking…" state), and the
|
||||
// final reply keeps its bubble.
|
||||
for (const kind of ["message", "thinking", "marker", "status", "interaction"]) {
|
||||
const item = items.find((it) => it.kind === kind)!;
|
||||
expect(isNestableLiveChild(item)).toBe(false);
|
||||
}
|
||||
@@ -313,27 +315,49 @@ describe("interstitial self-talk classification (PAP-355)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveRunStatusLabel narrating flag (PAP-357)", () => {
|
||||
it("flags a tail assistant message as narrating — no streamed text surfaced", () => {
|
||||
describe("flattenSelfTalk", () => {
|
||||
it("strips markdown markers to one plain line", () => {
|
||||
expect(
|
||||
flattenSelfTalk("## Plan\n\nI'll **extend** the `ipRateLimit` helper:\n- read it\n- patch it"),
|
||||
).toBe("Plan I'll extend the ipRateLimit helper: read it patch it");
|
||||
});
|
||||
|
||||
it("keeps link text, drops the url, and is stream-safe on unclosed markers", () => {
|
||||
expect(flattenSelfTalk("see [the docs](https://x) for **bol")).toBe("see the docs for bol");
|
||||
expect(flattenSelfTalk("```ts\nconst a = 1;")).toBe("const a = 1;");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveRunStatusLabel streaming interstitial text (PAP-361)", () => {
|
||||
it("returns the trailing message's flattened text with the Responding label", () => {
|
||||
const status = deriveRunStatusLabel([
|
||||
{ kind: "assistant", ts: TS, text: "I'll **extend** " } as TranscriptEntry,
|
||||
{ kind: "assistant", ts: TS, text: "the `ipRateLimit` helper." } as TranscriptEntry,
|
||||
]);
|
||||
expect(status.label).toBe("Responding");
|
||||
expect(status.narrating).toBe(true);
|
||||
expect(status.selfTalk).toBe("I'll extend the ipRateLimit helper.");
|
||||
expect(status.detail).toBeUndefined();
|
||||
});
|
||||
|
||||
it("carries no narrating flag for tool or thinking tails", () => {
|
||||
expect(deriveRunStatusLabel([toolCall("Read")]).narrating).toBeUndefined();
|
||||
it("only accumulates the trailing message, not one closed by a tool call", () => {
|
||||
const status = deriveRunStatusLabel([
|
||||
{ kind: "assistant", ts: TS, text: "Earlier note." } as TranscriptEntry,
|
||||
toolCall("Read", { file_path: "a.ts" }),
|
||||
{ kind: "assistant", ts: TS, text: "Fresh line." } as TranscriptEntry,
|
||||
]);
|
||||
expect(status.selfTalk).toBe("Fresh line.");
|
||||
});
|
||||
|
||||
it("carries no selfTalk for tool or thinking tails", () => {
|
||||
expect(deriveRunStatusLabel([toolCall("Read")]).selfTalk).toBeUndefined();
|
||||
expect(
|
||||
deriveRunStatusLabel([{ kind: "thinking", ts: TS, text: "hm" } as TranscriptEntry]).narrating,
|
||||
deriveRunStatusLabel([{ kind: "thinking", ts: TS, text: "hm" } as TranscriptEntry]).selfTalk,
|
||||
).toBeUndefined();
|
||||
expect(deriveRunStatusLabel([]).narrating).toBeUndefined();
|
||||
expect(deriveRunStatusLabel([]).selfTalk).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("settledRunChildren (PAP-357)", () => {
|
||||
describe("settledRunChildren (PAP-361)", () => {
|
||||
const transcript: TranscriptEntry[] = [
|
||||
{ kind: "assistant", ts: TS, text: "Checking the adapter first." } as TranscriptEntry,
|
||||
toolCall("Read", { file_path: "a.ts" }),
|
||||
@@ -343,9 +367,9 @@ describe("settledRunChildren (PAP-357)", () => {
|
||||
];
|
||||
const parsed = transcriptToTaskChatItems(transcript, { runId: "run-1", running: false });
|
||||
|
||||
it("keeps only activity rows — every message is excluded", () => {
|
||||
it("keeps exactly the tool rows — messages AND thinking are excluded", () => {
|
||||
const children = settledRunChildren(parsed);
|
||||
expect(children.map((c) => c.kind)).toEqual(["tool", "thinking", "tool"]);
|
||||
expect(children.map((c) => c.kind)).toEqual(["tool", "tool"]);
|
||||
});
|
||||
|
||||
it("matches the folded summary's tool count exactly (row-count parity)", () => {
|
||||
|
||||
@@ -28,15 +28,35 @@ export function isTerminalRunStatus(status: string | undefined | null): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The live parent row's nesting rule (PAP-354): only activity rows — tool
|
||||
* calls, thinking blocks, usage readouts — nest inside the expandable live
|
||||
* turn. Messages never nest (PAP-357): interstitial self-talk is ambient
|
||||
* signal suppressed in this view (it stays in the run log and the classic
|
||||
* transcript), and the run's final reply lands as its posted comment bubble.
|
||||
* Markers, statuses and interaction cards stay in the thread outside.
|
||||
* The live parent row's nesting rule (PAP-354, narrowed by PAP-361): only tool
|
||||
* calls and usage readouts nest inside the expandable live turn. Messages
|
||||
* never nest — an interstitial update streams on the parent row's own line
|
||||
* (TaskChatStatusItem.selfTalk) and vanishes when it completes, and the run's
|
||||
* final reply lands as its posted comment bubble. Thinking never nests either:
|
||||
* its live signal is the pill's "Thinking…" state, and the text stays in the
|
||||
* run log / classic transcript. Markers, statuses and interaction cards stay
|
||||
* in the thread outside.
|
||||
*/
|
||||
export function isNestableLiveChild(item: TaskChatItem): item is TaskChatTurnChildItem {
|
||||
return item.kind === "tool" || item.kind === "thinking" || item.kind === "usage";
|
||||
return item.kind === "tool" || item.kind === "usage";
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten markdown-ish interstitial text to one plain line for the live parent
|
||||
* row. Stream-safe: markers are stripped without requiring pairs.
|
||||
*/
|
||||
export function flattenSelfTalk(text: string): string {
|
||||
return text
|
||||
.replace(/```[a-z]*\n?/gi, " ")
|
||||
.replace(/`/g, "")
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
.replace(/^\s*[-*+]\s+/gm, "")
|
||||
.replace(/^\s*>\s?/gm, "")
|
||||
.replace(/!?\[([^\]]*)\]\(([^)]*)\)/g, "$1")
|
||||
.replace(/\*+/g, "")
|
||||
.replace(/__/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function diffKind(changeType: string): "add" | "remove" | "context" {
|
||||
@@ -308,15 +328,17 @@ export function transcriptToTaskChatItems(
|
||||
}
|
||||
|
||||
/**
|
||||
* A settled run's nested children (PAP-357): the activity rows — tools,
|
||||
* thinking, usage — in transcript order, under the "✓ Worked ·" summary.
|
||||
* Messages are excluded entirely: the final reply already landed as the run's
|
||||
* posted comment bubble, and interstitial self-talk is ambient signal that
|
||||
* stays in the run log / classic transcript rather than rendering here.
|
||||
* A settled run's nested children (PAP-361): exactly the tool rows (plus usage
|
||||
* readouts) in transcript order, under the "✓ Worked ·" summary — parity with
|
||||
* the summary's tool count. Messages are excluded: the final reply already
|
||||
* landed as the run's posted comment bubble, and interstitial updates are
|
||||
* ephemeral (they take the live line while streaming, then vanish). Thinking
|
||||
* is excluded too — the run log / classic transcript remain its archive.
|
||||
*/
|
||||
export function settledRunChildren(parsed: readonly TaskChatItem[]): TaskChatTurnChildItem[] {
|
||||
return parsed.filter(
|
||||
(it): it is TaskChatTurnChildItem => it.kind !== "turn" && it.kind !== "message",
|
||||
(it): it is TaskChatTurnChildItem =>
|
||||
it.kind !== "turn" && it.kind !== "message" && it.kind !== "thinking",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -381,15 +403,15 @@ export function buildTurnSummary(
|
||||
* Human-readable label for the live status pill from the tail of a transcript.
|
||||
* A tail tool_call yields the taxonomy verb ("Searching", "Running a command")
|
||||
* with tool + target as detail; `toolName` lets the pill show the family icon.
|
||||
* A tail assistant message yields "Responding" plus `narrating` — the ambient
|
||||
* flag the pill turns into a glint on the status word (PAP-357); the streamed
|
||||
* text itself is never surfaced on the line.
|
||||
* A tail assistant message yields "Responding" plus `selfTalk` — the flattened
|
||||
* text of the interstitial update streamed so far, which takes over the parent
|
||||
* row's line while it streams (PAP-361).
|
||||
*/
|
||||
export function deriveRunStatusLabel(entries: readonly TranscriptEntry[]): {
|
||||
label: string;
|
||||
detail?: string;
|
||||
toolName?: string;
|
||||
narrating?: boolean;
|
||||
selfTalk?: string;
|
||||
} {
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
@@ -425,7 +447,29 @@ export function deriveRunStatusLabel(entries: readonly TranscriptEntry[]): {
|
||||
};
|
||||
}
|
||||
if (entry.kind === "tool_result") break;
|
||||
if (entry.kind === "assistant") return { label: "Responding", narrating: true };
|
||||
if (entry.kind === "assistant") {
|
||||
// Accumulate the trailing message's deltas (the same coalescing run the
|
||||
// parser groups into one item: broken by tool/thinking/diff, not by
|
||||
// status-only entries like stdout).
|
||||
const parts: string[] = [];
|
||||
for (let j = i; j >= 0; j--) {
|
||||
const prev = entries[j];
|
||||
if (prev.kind === "assistant") {
|
||||
if (prev.text) parts.unshift(prev.text);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
prev.kind === "tool_call" ||
|
||||
prev.kind === "tool_result" ||
|
||||
prev.kind === "thinking" ||
|
||||
prev.kind === "diff"
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const selfTalk = flattenSelfTalk(parts.join(""));
|
||||
return { label: "Responding", selfTalk: selfTalk || undefined };
|
||||
}
|
||||
if (entry.kind === "thinking") return { label: "Thinking" };
|
||||
}
|
||||
return { label: "Running" };
|
||||
|
||||
+10
-13
@@ -236,7 +236,7 @@
|
||||
--motion-count-tween: var(--motion-duration-slow);
|
||||
--motion-streaming-cursor-blink: 1.1s;
|
||||
--motion-turn-fold: 380ms; /* finished-turn activity folding into its summary line */
|
||||
--motion-shimmer: 2000ms; /* narration glint sweeping the status word while self-talk streams */
|
||||
--motion-line-scroll: 240ms; /* completed interstitial line sliding out of the 1lh live-line viewport */
|
||||
--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);
|
||||
@@ -536,7 +536,7 @@
|
||||
--motion-plan-entry-stagger: 0ms;
|
||||
--motion-streaming-cursor-blink: 0ms;
|
||||
--motion-turn-fold: 0ms;
|
||||
--motion-shimmer: 0ms;
|
||||
--motion-line-scroll: 0ms;
|
||||
--motion-scroll-pill-enter: 0ms;
|
||||
--motion-scroll-pill-exit: 0ms;
|
||||
--motion-pane-glide: 0ms;
|
||||
@@ -581,6 +581,13 @@
|
||||
.tc-approval { animation: tc-approval-pulse var(--motion-approval-pulse) var(--motion-ease-standard) infinite; }
|
||||
.tc-cursor { animation: tc-cursor-blink var(--motion-streaming-cursor-blink) step-end infinite; }
|
||||
.tc-enter-plan-entry { animation: tc-fade-rise var(--motion-plan-check) var(--motion-ease-out-expo) both; }
|
||||
/* Live-line viewport (PAP-361): exactly one line-height tall, clipping the
|
||||
wrapped interstitial block. On wrap, the inner block translates up whole
|
||||
line-heights (set inline as translateY(-(n−1)·1lh)) so the completed line
|
||||
slides out the top — transform-only, same motion grammar as tool rows
|
||||
animating in. */
|
||||
.tc-line-scroll { display: block; height: 1lh; overflow: hidden; }
|
||||
.tc-line-scroll-inner { transition: transform var(--motion-line-scroll) var(--motion-ease-standard); will-change: transform; }
|
||||
|
||||
/* Scroll-to-latest pill (stock shadcn MessageScrollerButton motion, tokenized).
|
||||
The pill is centered with translateX(-50%), so the keyframes carry the X
|
||||
@@ -622,6 +629,7 @@
|
||||
.tc-scroll-pill-out,
|
||||
.tc-enter-plan-entry { animation: none; }
|
||||
.tc-turn-fold,
|
||||
.tc-line-scroll-inner,
|
||||
.tc-pane-glide { transition: none; }
|
||||
/* The pill keyframes carry the X-centering; with animation:none restore it. */
|
||||
.tc-scroll-pill-in,
|
||||
@@ -738,17 +746,6 @@
|
||||
--shimmer-highlight: color-mix(in oklch, var(--muted-foreground) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Narration glint (PAP-357): while interstitial self-talk streams, the status
|
||||
word's sweep brightens and quickens (--motion-shimmer vs the ambient 2.5s)
|
||||
— an activity signal with nothing new to read. Applied on top of
|
||||
.shimmer-text/.shimmer-text-muted; declared after them so the overrides win
|
||||
at equal specificity, and the .shimmer-text reduced-motion rule below
|
||||
already renders it static. */
|
||||
.tc-shimmer-narrate {
|
||||
--shimmer-highlight: color-mix(in oklch, var(--muted-foreground) 10%, transparent);
|
||||
animation-duration: var(--motion-shimmer);
|
||||
}
|
||||
|
||||
/* Utilities expected by shadcn base components (ui/attachment.tsx). Declared
|
||||
with @utility so variant forms (e.g. group-data-[state=uploading]:shimmer)
|
||||
compile under Tailwind v4. */
|
||||
|
||||
@@ -11,7 +11,7 @@ import { TaskChatPlanView } from "@/components/task-chat/TaskChatPlanView";
|
||||
import { TweakPanel } from "@/components/task-chat/TweakPanel";
|
||||
import type { TaskChatItem } from "@/components/task-chat/task-chat-model";
|
||||
|
||||
/** Progressively reveal any streaming message/thinking text at `speed`. */
|
||||
/** Progressively reveal any streaming message / live-line interstitial text at `speed`. */
|
||||
function useStreamingReplay(
|
||||
baseItems: TaskChatItem[],
|
||||
speed: number,
|
||||
@@ -19,13 +19,15 @@ function useStreamingReplay(
|
||||
): TaskChatItem[] {
|
||||
const [chars, setChars] = useState<number>(Number.MAX_SAFE_INTEGER);
|
||||
const streamingIndex = baseItems.findIndex(
|
||||
(i) => (i.kind === "message" && i.streaming) || (i.kind === "thinking" && i.streaming),
|
||||
(i) =>
|
||||
(i.kind === "message" && i.streaming) ||
|
||||
(i.kind === "turn" && !i.settled && i.liveStatus?.selfTalk != null),
|
||||
);
|
||||
const fullText = useMemo(() => {
|
||||
const it = baseItems[streamingIndex];
|
||||
if (!it) return "";
|
||||
if (it.kind === "message") return it.text;
|
||||
if (it.kind === "thinking") return it.lines.join("\n");
|
||||
if (it.kind === "turn") return it.liveStatus?.selfTalk ?? "";
|
||||
return "";
|
||||
}, [baseItems, streamingIndex]);
|
||||
|
||||
@@ -51,7 +53,11 @@ function useStreamingReplay(
|
||||
const shown = fullText.slice(0, chars);
|
||||
const done = chars >= fullText.length;
|
||||
if (it.kind === "message") return { ...it, text: shown, streaming: !done };
|
||||
if (it.kind === "thinking") return { ...it, lines: shown.split("\n"), streaming: !done };
|
||||
// Parent-row interstitial (PAP-361): reveal the live line's text; the y
|
||||
// line-scroll kicks in as the revealed text wraps past one line.
|
||||
if (it.kind === "turn" && it.liveStatus) {
|
||||
return { ...it, liveStatus: { ...it.liveStatus, selfTalk: shown } };
|
||||
}
|
||||
return it;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user