p.type === "reasoning" || p.type === "tool-call");
+ const isFoldable = !isRunning && hasCoT && !!chainOfThoughtLabel;
+ const [folded, setFolded] = useState(isFoldable);
+ const previousMessageIdRef = useRef
(message.id);
+ const previousIsFoldableRef = useRef(isFoldable);
+
+ useEffect(() => {
+ const nextFolded = resolveAssistantMessageFoldedState({
+ messageId: message.id,
+ currentFolded: folded,
+ isFoldable,
+ previousMessageId: previousMessageIdRef.current,
+ previousIsFoldable: previousIsFoldableRef.current,
+ });
+ previousMessageIdRef.current = message.id;
+ previousIsFoldableRef.current = isFoldable;
+ if (nextFolded !== folded) {
+ setFolded(nextFolded);
+ }
+ }, [folded, isFoldable, message.id]);
const handleVote = async (
vote: FeedbackVoteValue,
@@ -567,109 +853,132 @@ function IssueChatAssistantMessage() {
-
- {authorName}
- {isRunning ? (
-
-
- Running
-
- ) : null}
-
-
-
-
,
- ChainOfThought: IssueChatChainOfThought,
- }}
- />
- {message.content.length === 0 && waitingText ? (
-
- {waitingText}
-
- ) : null}
- {notices.length > 0 ? (
-
- {notices.map((notice, index) => (
-
- {notice}
-
- ))}
-
- ) : null}
-
-
-
-
setFolded((v) => !v)}
>
-
-
-
- {commentId && onVote ? (
-
- ) : null}
-
-
-
- {message.createdAt ? commentDateLabel(message.createdAt) : ""}
-
-
-
- {message.createdAt ? formatDateTime(message.createdAt) : ""}
-
-
-
-
-
-
-
- {
- const text = message.content
- .filter((p): p is { type: "text"; text: string } => p.type === "text")
- .map((p) => p.text)
- .join("\n\n");
- void navigator.clipboard.writeText(text);
- }}
- >
-
- Copy message
-
- {runHref ? (
-
-
-
- View run
-
-
+ {authorName}
+ {chainOfThoughtLabel?.toLowerCase()}
+
+ {message.createdAt ? (
+
+ {commentDateLabel(message.createdAt)}
+
) : null}
-
-
-
+
+
+
+ ) : (
+
+ {authorName}
+ {isRunning ? (
+
+
+ Running
+
+ ) : null}
+
+ )}
+
+ {!folded ? (
+ <>
+
+
,
+ ChainOfThought: IssueChatChainOfThought,
+ }}
+ />
+ {message.content.length === 0 && waitingText ? (
+
+ {waitingText}
+
+ ) : null}
+ {notices.length > 0 ? (
+
+ {notices.map((notice, index) => (
+
+ {notice}
+
+ ))}
+
+ ) : null}
+
+
+
+
+
+
+
+ {commentId && onVote ? (
+
+ ) : null}
+
+
+
+ {message.createdAt ? commentDateLabel(message.createdAt) : ""}
+
+
+
+ {message.createdAt ? formatDateTime(message.createdAt) : ""}
+
+
+
+
+
+
+
+ {
+ const text = message.content
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
+ .map((p) => p.text)
+ .join("\n\n");
+ void navigator.clipboard.writeText(text);
+ }}
+ >
+
+ Copy message
+
+ {runHref ? (
+
+
+
+ View run
+
+
+ ) : null}
+
+
+
+ >
+ ) : null}
diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx
index 0a933c66..b5cc12d7 100644
--- a/ui/src/components/MarkdownBody.tsx
+++ b/ui/src/components/MarkdownBody.tsx
@@ -8,6 +8,7 @@ import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chi
interface MarkdownBodyProps {
children: string;
className?: string;
+ style?: React.CSSProperties;
/** Optional resolver for relative image paths (e.g. within export packages) */
resolveImageSrc?: (src: string) => string | null;
}
@@ -91,7 +92,7 @@ function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: b
);
}
-export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownBodyProps) {
+export function MarkdownBody({ children, className, style, resolveImageSrc }: MarkdownBodyProps) {
const { theme } = useTheme();
const components: Components = {
pre: ({ node: _node, children: preChildren, ...preProps }) => {
@@ -145,6 +146,7 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB
theme === "dark" && "prose-invert",
className,
)}
+ style={style}
>
url}>
{children}
diff --git a/ui/src/index.css b/ui/src/index.css
index f9452a9f..807a1100 100644
--- a/ui/src/index.css
+++ b/ui/src/index.css
@@ -266,6 +266,64 @@
}
}
+/* Chain-of-thought reasoning line ticker animations.
+ Pure translate, no opacity — the overflow-hidden container clips.
+ Both keyframes share the same easing so the two spans move in lockstep. */
+@keyframes cot-line-slide-in {
+ from { transform: translateY(100%); }
+ to { transform: translateY(0); }
+}
+
+@keyframes cot-line-slide-out {
+ from { transform: translateY(0); }
+ to { transform: translateY(-100%); }
+}
+
+.cot-line-enter {
+ animation: cot-line-slide-in 300ms cubic-bezier(0.4, 0, 0.2, 1) both;
+}
+
+.cot-line-exit {
+ animation: cot-line-slide-out 300ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .cot-line-enter,
+ .cot-line-exit {
+ animation: none;
+ }
+}
+
+/* Shimmer text effect for active "Working" state */
+@keyframes shimmer-text-slide {
+ 0% { background-position: 200% center; }
+ 100% { background-position: -200% center; }
+}
+
+.shimmer-text {
+ --shimmer-base: hsl(var(--foreground) / 0.75);
+ --shimmer-highlight: hsl(var(--foreground) / 0.3);
+ background: linear-gradient(
+ 110deg,
+ var(--shimmer-base) 35%,
+ var(--shimmer-highlight) 50%,
+ var(--shimmer-base) 65%
+ );
+ background-size: 250% 100%;
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+ animation: shimmer-text-slide 2.5s ease-in-out infinite;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .shimmer-text {
+ animation: none;
+ -webkit-text-fill-color: unset;
+ background: none;
+ }
+}
+
/* MDXEditor theme integration */
.paperclip-mdxeditor-scope,
.paperclip-mdxeditor {
diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts
index e5d82f71..0e90bd81 100644
--- a/ui/src/lib/issue-chat-messages.ts
+++ b/ui/src/lib/issue-chat-messages.ts
@@ -12,8 +12,6 @@ import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
import { formatAssigneeUserLabel } from "./assignees";
import type { IssueTimelineEvent } from "./issue-timeline-events";
import {
- parseSystemActivity,
- shouldHideNiceModeStderr,
summarizeNotice,
} from "./transcriptPresentation";
@@ -281,7 +279,47 @@ function runTimestamp(run: IssueChatLinkedRun) {
return run.finishedAt ?? run.startedAt ?? run.createdAt;
}
-function formatDurationWords(ms: number | null) {
+export interface SegmentTiming {
+ startMs: number;
+ endMs: number;
+}
+
+function computeSegmentTimings(entries: readonly IssueChatTranscriptEntry[]): SegmentTiming[] {
+ const timings: SegmentTiming[] = [];
+ let inSegment = false;
+ let segStart = 0;
+ let segEnd = 0;
+
+ for (const entry of entries) {
+ const ts = new Date(entry.ts).getTime();
+
+ const isCoT =
+ entry.kind === "thinking" ||
+ entry.kind === "tool_call" ||
+ entry.kind === "tool_result" ||
+ (entry.kind === "result" && ((entry.isError && !!entry.errors?.length) || !!entry.text));
+ const isText = entry.kind === "assistant" && !!entry.text;
+
+ if (isCoT) {
+ if (!inSegment) {
+ inSegment = true;
+ segStart = ts;
+ }
+ segEnd = ts;
+ } else if (isText && inSegment) {
+ timings.push({ startMs: segStart, endMs: segEnd });
+ inSegment = false;
+ }
+ }
+
+ if (inSegment) {
+ timings.push({ startMs: segStart, endMs: segEnd });
+ }
+
+ return timings;
+}
+
+export function formatDurationWords(ms: number | null) {
if (ms === null || !Number.isFinite(ms) || ms <= 0) return null;
const totalSeconds = Math.max(1, Math.round(ms / 1000));
if (totalSeconds < 60) {
@@ -357,7 +395,7 @@ function createHistoricalTranscriptMessage(args: {
}) {
const { run, transcript, hasOutput, agentMap } = args;
const agentName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
- const { parts, notices } = buildAssistantPartsFromTranscript(transcript);
+ const { parts, notices, segments } = buildAssistantPartsFromTranscript(transcript);
const waitingText = hasOutput ? "" : "Run finished";
const content = parts.length > 0
? parts
@@ -381,12 +419,17 @@ function createHistoricalTranscriptMessage(args: {
notices,
waitingText,
chainOfThoughtLabel: runDurationLabel(run),
+ chainOfThoughtSegments: segments,
}),
};
return message;
}
-export function buildAssistantPartsFromTranscript(entries: readonly IssueChatTranscriptEntry[]) {
+export function buildAssistantPartsFromTranscript(entries: readonly IssueChatTranscriptEntry[]): {
+ parts: Array>;
+ notices: string[];
+ segments: SegmentTiming[];
+} {
const orderedParts: Array> = [];
const toolParts = new Map>();
const toolIndices = new Map();
@@ -446,33 +489,10 @@ export function buildAssistantPartsFromTranscript(entries: readonly IssueChatTra
toolParts.set(toolCallId, nextPart);
continue;
}
- if (entry.kind === "init" && entry.model) {
- const sessionSuffix = entry.sessionId ? ` session ${entry.sessionId}` : "";
- orderedParts.push({ type: "reasoning", text: `Started ${entry.model}${sessionSuffix}.` });
- continue;
- }
- if (entry.kind === "stderr" && entry.text) {
- if (!shouldHideNiceModeStderr(entry.text)) {
- orderedParts.push({ type: "reasoning", text: `Background: ${summarizeNotice(entry.text)}` });
- }
- continue;
- }
- if (entry.kind === "system" && entry.text) {
- const normalized = entry.text.trim().toLowerCase();
- if (normalized === "turn started") continue;
- const activity = parseSystemActivity(entry.text);
- if (activity) {
- orderedParts.push({
- type: "reasoning",
- text: activity.status === "running"
- ? `Working on ${activity.name.toLowerCase()}.`
- : `Completed ${activity.name.toLowerCase()}.`,
- });
- } else {
- orderedParts.push({ type: "reasoning", text: `System: ${summarizeNotice(entry.text)}` });
- }
- continue;
- }
+ if (entry.kind === "init") continue;
+ if (entry.kind === "stderr") continue;
+ if (entry.kind === "stdout") continue;
+ if (entry.kind === "system") continue;
if (entry.kind === "result") {
if (entry.isError && entry.errors?.length) {
for (const error of entry.errors) {
@@ -483,15 +503,11 @@ export function buildAssistantPartsFromTranscript(entries: readonly IssueChatTra
type: "reasoning",
text: entry.isError
? `Run error: ${summarizeNotice(entry.text)}`
- : `Run update: ${summarizeNotice(entry.text)}`,
+ : summarizeNotice(entry.text),
});
}
continue;
}
- if (entry.kind === "stdout" && entry.text) {
- orderedParts.push({ type: "reasoning", text: `Log: ${summarizeNotice(entry.text)}` });
- continue;
- }
}
const mergedParts: Array> = [];
@@ -514,6 +530,7 @@ export function buildAssistantPartsFromTranscript(entries: readonly IssueChatTra
return {
parts: mergedParts,
notices,
+ segments: computeSegmentTimings(entries),
};
}
@@ -550,7 +567,7 @@ function createLiveRunMessage(args: {
hasOutput: boolean;
}) {
const { run, transcript, hasOutput } = args;
- const { parts, notices } = buildAssistantPartsFromTranscript(transcript);
+ const { parts, notices, segments } = buildAssistantPartsFromTranscript(transcript);
const waitingText =
run.status === "queued"
? "Queued..."
@@ -580,6 +597,7 @@ function createLiveRunMessage(args: {
notices,
waitingText,
chainOfThoughtLabel: runDurationLabel(run),
+ chainOfThoughtSegments: segments,
}),
};
return message;
diff --git a/ui/src/pages/IssueChatUxLab.tsx b/ui/src/pages/IssueChatUxLab.tsx
index df00abd2..f752fa90 100644
--- a/ui/src/pages/IssueChatUxLab.tsx
+++ b/ui/src/pages/IssueChatUxLab.tsx
@@ -1,4 +1,4 @@
-import { useState, type ReactNode } from "react";
+import { useEffect, useRef, useState, type ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -17,7 +17,7 @@ import {
issueChatUxTranscriptsByRunId,
} from "../fixtures/issueChatUxFixtures";
import { cn } from "../lib/utils";
-import { Bot, FlaskConical, MessagesSquare, Route, Sparkles, WandSparkles } from "lucide-react";
+import { Bot, Brain, FlaskConical, MessagesSquare, Route, Sparkles, WandSparkles } from "lucide-react";
const noop = async () => {};
@@ -65,6 +65,70 @@ function LabSection({
);
}
+const DEMO_REASONING_LINES = [
+ "Analyzing the user's request about the animation smoothness...",
+ "The current implementation unmounts the old span instantly, causing a flash...",
+ "Looking at the CSS keyframes for cot-line-slide-up...",
+ "We need a paired exit animation so the old line slides out while the new one slides in...",
+ "Implementing a two-span ticker: exiting line goes up and out, entering line comes up from below...",
+ "Testing the 280ms cubic-bezier transition timing...",
+];
+
+function RotatingReasoningDemo({ intervalMs = 2200 }: { intervalMs?: number }) {
+ const [index, setIndex] = useState(0);
+ const prevRef = useRef(DEMO_REASONING_LINES[0]);
+ const [ticker, setTicker] = useState<{
+ key: number;
+ current: string;
+ exiting: string | null;
+ }>({ key: 0, current: DEMO_REASONING_LINES[0], exiting: null });
+
+ useEffect(() => {
+ const timer = setInterval(() => {
+ setIndex((i) => (i + 1) % DEMO_REASONING_LINES.length);
+ }, intervalMs);
+ return () => clearInterval(timer);
+ }, [intervalMs]);
+
+ const currentLine = DEMO_REASONING_LINES[index];
+
+ useEffect(() => {
+ if (currentLine !== prevRef.current) {
+ const prev = prevRef.current;
+ prevRef.current = currentLine;
+ setTicker((t) => ({ key: t.key + 1, current: currentLine, exiting: prev }));
+ }
+ }, [currentLine]);
+
+ return (
+
+
+
+
+
+ {ticker.exiting !== null && (
+ setTicker((t) => ({ ...t, exiting: null }))}
+ >
+ {ticker.exiting}
+
+ )}
+ 0 && "cot-line-enter",
+ )}
+ >
+ {ticker.current}
+
+
+
+ );
+}
+
export function IssueChatUxLab() {
const [showComposer, setShowComposer] = useState(true);
@@ -129,6 +193,29 @@ export function IssueChatUxLab() {