Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 4 additions & 10 deletions webviews/src/agent-session/acpmux/conversation/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -726,11 +726,8 @@ export type FreshTail = { steps: Reveal[]; text: string; now: number };
/// and a negative animation delay, so a re-render never restarts its fade. Only plain trailing
/// text fades by step; a suffix with Markdown syntax or a line break draws as usual.
function renderFresh(text: string, tail: FreshTail, opts: InlineOptions): ReactNode[] {
// The live edge: a soft bar after the newest character, out of the text's layout (no reflow
// when it leaves); it pulses only while the reveal waits for more (`.cv-md.is-waiting`).
const caret = <span key="caret" className="cv-caret" aria-hidden="true" />;
const fresh = tail.text.trimEnd();
if (!fresh || !text.endsWith(fresh) || /[*`_~[\]()$\\\n]/.test(fresh)) return [...renderInline(text, opts), caret];
if (!fresh || !text.endsWith(fresh) || /[*`_~[\]()$\\\n]/.test(fresh)) return renderInline(text, opts);
const out = renderInline(text.slice(0, text.length - fresh.length), opts);
let end = fresh.length;
const spans: ReactNode[] = [];
Expand All @@ -745,7 +742,7 @@ function renderFresh(text: string, tail: FreshTail, opts: InlineOptions): ReactN
end = start;
}
if (end > 0) out.push(fresh.slice(0, end));
return [...out, ...spans, caret];
return [...out, ...spans];
}

/// A top-level block that renders again only when its parsed block changes: a streaming reply's
Expand All @@ -761,8 +758,6 @@ export type MarkdownProps = InlineOptions & {
/** The newest revealed steps (RevealedMarkdown), which fade in at the end of the last block. */
fresh?: Reveal[];
now?: number;
/** The reveal has shown everything so far and waits for more: the live edge pulses. */
waiting?: boolean;
/** Fences draw as plain lines, never a highlighted diff card (review comments inside a diff). */
plainCode?: boolean;
};
Expand All @@ -779,7 +774,6 @@ export function Markdown({
streaming = false,
fresh,
now = 0,
waiting = false,
githubRepository,
plainCode = false,
}: MarkdownProps) {
Expand Down Expand Up @@ -816,7 +810,7 @@ export function Markdown({
// Fences this reply drew open: they hand over to the highlighted card once, when they close.
const streamedFences = useRef(new Set<string>());
const freshChars = fresh?.reduce((sum, step) => sum + step.count, 0) ?? 0;
// Only a revealed reply (RevealedMarkdown passes `fresh`) draws the live edge.
// Only a revealed reply (RevealedMarkdown passes `fresh`) fades its newest text in.
const freshTail: FreshTail | undefined =
streaming && fresh
? { steps: fresh, text: freshChars ? children.slice(children.length - freshChars) : "", now }
Expand Down Expand Up @@ -852,7 +846,7 @@ export function Markdown({
.filter(({ entry }) => entry.block.type === "footnote")
.sort((a, b) => noteRank(a.entry.block, opts) - noteRank(b.entry.block, opts));
return (
<div className={`cv-md selectable ${className}${streaming && waiting ? " is-waiting" : ""}`}>
<div className={`cv-md selectable ${className}`}>
{blocks.map((entry, index) => (entry.block.type === "footnote" ? null : draw(entry, index)))}
{notes.length > 0 && (
<section className="cv-footnotes" role="doc-endnotes">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,31 +21,25 @@ export type Reveal = { id: number; count: number; born: number };
/// How long newly revealed text fades in (acp-streaming.md "Reveal animation").
export const FADE_MS = 160;

type Shown = { length: number; fresh: Reveal[]; now: number; settled: boolean };
type Shown = { length: number; fresh: Reveal[]; now: number };
/// React commits at most this often while revealing; fades run every display frame on the
/// compositor, so 60 commits a second read the same as 120 at half the main-thread cost.
const COMMIT_MS = 1000 / 60;

/// The part of `text` to show this frame, and the steps still fading in.
export function useStreamReveal(
text: string,
streaming: boolean,
): { visible: string; fresh: Reveal[]; now: number; settled: boolean } {
export function useStreamReveal(text: string, streaming: boolean): { visible: string; fresh: Reveal[]; now: number } {
const reveal = useRef<StreamReveal | null>(null);
const reduce = useRef<boolean | null>(null);
reduce.current ??= reducedMotion();
// Text there when the row mounts shows at once; only what arrives after it flows in.
reveal.current ??= new StreamReveal({ initial: text, reduceMotion: reduce.current });
const [shown, setShown] = useState<Shown>(() => ({ length: text.length, fresh: [], now: 0, settled: true }));
const [shown, setShown] = useState<Shown>(() => ({ length: text.length, fresh: [], now: 0 }));
const latest = useRef({ text, streaming, shown });
latest.current = { text, streaming, shown };
const nextId = useRef(0);
useLayoutEffect(() => {
const current = reveal.current!;
if (current.settled && shown.length >= text.length && !shown.fresh.length) {
if (!shown.settled) setShown({ ...shown, settled: true });
return;
}
if (current.settled && shown.length >= text.length && !shown.fresh.length) return;
let handle = 0;
let lastCommit: number | undefined;
const tick = (now: number) => {
Expand All @@ -70,7 +64,7 @@ export function useStreamReveal(
const fresh = away ? [] : before.fresh.filter((step) => now - step.born < FADE_MS);
if (length > before.length && !reduce.current && !away)
fresh.push({ id: (nextId.current += 1), count: length - before.length, born: now });
const next = { length, fresh, now, settled: current.settled };
const next = { length, fresh, now };
latest.current.shown = next;
setShown(next);
if (!current.settled || fresh.length) handle = revealFrames.request(tick);
Expand All @@ -84,7 +78,6 @@ export function useStreamReveal(
visible: text.slice(0, Math.min(shown.length, text.length)),
fresh: shown.fresh,
now: shown.now,
settled: shown.settled && shown.length >= text.length,
};
}

Expand Down Expand Up @@ -115,9 +108,9 @@ function Revealed({
streaming: boolean;
githubRepository?: string;
}) {
const { visible, fresh, now, settled } = useStreamReveal(text, streaming);
const { visible, fresh, now } = useStreamReveal(text, streaming);
return (
<Markdown streaming={streaming} fresh={fresh} now={now} waiting={settled} githubRepository={githubRepository}>
<Markdown streaming={streaming} fresh={fresh} now={now} githubRepository={githubRepository}>
{visible}
</Markdown>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// l10n-allow-file: test fixture reply text, not shipped UI.
// A streaming reply ends at its newest character: no caret or bar after it, while the reveal
// writes or while it waits for more (Leo 2026-10-20: the blinking caret goes).
import { expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { Markdown } from "./Markdown";

const fresh = [{ id: 1, count: 5, born: 0 }];

test("a streaming reply draws no caret after its text", () => {
// Mid-reveal (the newest step fading in) and caught up (nothing fading, waiting for more).
for (const steps of [fresh, []]) {
const html = renderToStaticMarkup(
<Markdown streaming fresh={steps} now={100}>
{"Hello there world"}
</Markdown>,
);
expect(html).toContain("world");
expect(html).not.toContain("cv-caret");
}
});
34 changes: 0 additions & 34 deletions webviews/src/agent-session/acpmux/conversation/conversation.css
Original file line number Diff line number Diff line change
Expand Up @@ -1861,40 +1861,6 @@ pre.cv-math-source {
animation: cv-enter-fade var(--agent-motion-in, 180ms) var(--agent-ease-out, ease-out) both;
}

/* R104: the live edge, a soft bar after the newest character while a reply streams. It takes no
* width (the bar is absolutely placed), so the text never reflows when it leaves. It pulses only
* while the reveal has caught up and waits for more. */
.cv-caret {
position: relative;
display: inline-block;
width: 0;
height: 1em;
vertical-align: -0.12em;
}
.cv-caret::after {
content: "";
position: absolute;
left: 2px;
top: 0.05em;
width: 0.5em;
height: 0.9em;
border-radius: 2px;
background: color-mix(in srgb, var(--cv-text) 55%, transparent);
}
@keyframes cv-caret-wait {
50% {
opacity: 0.35;
}
}
@media (prefers-reduced-motion: no-preference) {
.cv-md.is-waiting .cv-caret::after {
animation: cv-caret-wait 1s ease-in-out infinite;
}
}
:root[data-status-indicators="static"] .cv-md.is-waiting .cv-caret::after {
animation: none;
}

/* Footnotes (Markdown.tsx): a superscript number that scrolls to its note, and the notes
under a rule at the end of the reply, in a smaller, quieter type. */
.cv-fnref {
Expand Down
7 changes: 3 additions & 4 deletions webviews/test/static-status-loops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
// payload (`statusIndicators`), which this test runs as the app does, and under it, or under Reduce
// Motion, no web loop may run: the mic's wait pulse, the model picker and harness spinners, the
// floating chat's breathing dot, the Thinking sweep, the running line's working dots, the todo
// list's spinner, the activity line's dots, the reply caret's wait pulse, the Jump to
// Latest dots and Home's "writing" pulse. The diff viewer does not get the pane's theme, so its
// spinners stop under Reduce Motion alone. Static also gives the color-only marks a shape each.
// list's spinner, the activity line's dots, the Jump to Latest dots and Home's "writing" pulse.
// The diff viewer does not get the pane's theme, so its spinners stop under Reduce Motion alone.
// Static also gives the color-only marks a shape each.
// This loads the real stylesheets in Chromium and lists the animations that never end.
import { readFileSync } from "node:fs";
import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
Expand Down Expand Up @@ -48,7 +48,6 @@ await requireBrowserLane("static-status-loops.test.ts", async () => {
`<span class="acpmux-floating-status" data-status="streaming"></span>` +
`<div class="cv-md"><span class="cv-thinking">Thinking</span></div>` +
`<div class="cv-worked"><span class="cv-working-mark"><span></span><span></span><span></span></span></div>` +
`<div class="cv-md is-waiting"><span class="cv-caret"></span></div>` +
`<button class="acpmux-jump-latest" data-shown data-working><span class="acpmux-jump-latest__dots"><i></i><i></i><i></i></span></button>` +
`<ul class="cv-plan"><li class="cv-plan__item" data-status="in_progress"><span class="cv-plan__mark"></span></li></ul>` +
`<div class="cv-activity"><span class="cv-activity__dots"><span></span><span></span><span></span></span></div>` +
Expand Down