From 92f142f7f8e7b895cb1ae7492a7b1cc99e047c94 Mon Sep 17 00:00:00 2001 From: dotta Date: Tue, 7 Apr 2026 17:02:48 -0500 Subject: [PATCH] Polish issue chat transcript presentation --- ui/src/components/IssueChatThread.test.tsx | 22 +- ui/src/components/IssueChatThread.tsx | 767 +++++++++++++++------ ui/src/components/MarkdownBody.tsx | 4 +- ui/src/index.css | 58 ++ ui/src/lib/issue-chat-messages.ts | 94 ++- ui/src/pages/IssueChatUxLab.tsx | 91 ++- 6 files changed, 765 insertions(+), 271 deletions(-) diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 1765a65f..63b7bc67 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -5,7 +5,7 @@ import type { ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { IssueChatThread } from "./IssueChatThread"; +import { IssueChatThread, resolveAssistantMessageFoldedState } from "./IssueChatThread"; vi.mock("@assistant-ui/react", () => ({ AssistantRuntimeProvider: ({ children }: { children: ReactNode }) =>
{children}
, @@ -206,4 +206,24 @@ describe("IssueChatThread", () => { remount.unmount(); }); }); + + it("folds chain-of-thought when the same message transitions from running to complete", () => { + expect(resolveAssistantMessageFoldedState({ + messageId: "message-1", + currentFolded: false, + isFoldable: true, + previousMessageId: "message-1", + previousIsFoldable: false, + })).toBe(true); + }); + + it("preserves a manually opened completed message across rerenders", () => { + expect(resolveAssistantMessageFoldedState({ + messageId: "message-1", + currentFolded: false, + isFoldable: true, + previousMessageId: "message-1", + previousIsFoldable: true, + })).toBe(false); + }); }); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index ee6ab4fa..e2d87f4c 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -1,12 +1,13 @@ import { AssistantRuntimeProvider, ActionBarPrimitive, - ChainOfThoughtPrimitive, MessagePrimitive, ThreadPrimitive, useAui, + useAuiState, useMessage, } from "@assistant-ui/react"; +import type { ToolCallMessagePart } from "@assistant-ui/react"; import { createContext, useContext, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react"; import { Link, useLocation } from "@/lib/router"; import type { @@ -20,9 +21,11 @@ import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts"; import { usePaperclipIssueRuntime, type PaperclipIssueRuntimeReassignment } from "../hooks/usePaperclipIssueRuntime"; import { buildIssueChatMessages, + formatDurationWords, type IssueChatComment, type IssueChatLinkedRun, type IssueChatTranscriptEntry, + type SegmentTiming, } from "../lib/issue-chat-messages"; import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events"; import { Button } from "@/components/ui/button"; @@ -53,6 +56,7 @@ import { describeToolInput, displayToolName, formatToolPayload, + isCommandTool, parseToolPayload, summarizeToolInput, summarizeToolResult, @@ -61,7 +65,7 @@ import { cn, formatDateTime, formatShortDate } from "../lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Textarea } from "@/components/ui/textarea"; -import { ArrowRight, Check, ChevronDown, Copy, Loader2, MoreHorizontal, Paperclip, Search, ThumbsDown, ThumbsUp } from "lucide-react"; +import { ArrowRight, Brain, Check, ChevronDown, Copy, Hammer, Loader2, MoreHorizontal, Paperclip, Search, ThumbsDown, ThumbsUp } from "lucide-react"; interface IssueChatMessageContext { feedbackVoteByTargetId: Map; @@ -84,6 +88,57 @@ const IssueChatCtx = createContext({ feedbackTermsUrl: null, }); +export function resolveAssistantMessageFoldedState(args: { + messageId: string; + currentFolded: boolean; + isFoldable: boolean; + previousMessageId: string | null; + previousIsFoldable: boolean; +}) { + const { + messageId, + currentFolded, + isFoldable, + previousMessageId, + previousIsFoldable, + } = args; + + if (messageId !== previousMessageId) return isFoldable; + if (!isFoldable) return false; + if (!previousIsFoldable) return true; + return currentFolded; +} + +function findCoTSegmentIndex( + messageParts: ReadonlyArray<{ type: string }>, + cotParts: ReadonlyArray<{ type: string }>, +): number { + if (cotParts.length === 0) return -1; + const firstPart = cotParts[0]; + let segIdx = -1; + let inCoT = false; + for (const part of messageParts) { + if (part.type === "reasoning" || part.type === "tool-call") { + if (!inCoT) { segIdx++; inCoT = true; } + if (part === firstPart) return segIdx; + } else { + inCoT = false; + } + } + return -1; +} + +function useLiveElapsed(startMs: number | null | undefined, active: boolean): string | null { + const [, rerender] = useState(0); + useEffect(() => { + if (!active || !startMs) return; + const interval = setInterval(() => rerender((n) => n + 1), 1000); + return () => clearInterval(interval); + }, [active, startMs]); + if (!active || !startMs) return null; + return formatDurationWords(Date.now() - startMs); +} + interface CommentReassignment { assigneeAgentId: string | null; assigneeUserId: string | null; @@ -186,8 +241,12 @@ function commentDateLabel(date: Date | string | undefined): string { return formatShortDate(date); } -function IssueChatTextPart({ text }: { text: string }) { - return {text}; +function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) { + return ( + + {text} + + ); } function humanizeValue(value: string | null) { @@ -247,68 +306,284 @@ function runStatusClass(status: string) { } } +function toolCountSummary(toolParts: ToolCallMessagePart[]): string | null { + if (toolParts.length === 0) return null; + let commands = 0; + let other = 0; + for (const tool of toolParts) { + if (isCommandTool(tool.toolName, tool.args)) commands++; + else other++; + } + const parts: string[] = []; + if (commands > 0) parts.push(`ran ${commands} command${commands === 1 ? "" : "s"}`); + if (other > 0) parts.push(`called ${other} tool${other === 1 ? "" : "s"}`); + return parts.join(", "); +} + +function cleanToolDisplayText(tool: ToolCallMessagePart): string { + const name = displayToolName(tool.toolName, tool.args); + if (isCommandTool(tool.toolName, tool.args)) return name; + const summary = tool.result === undefined + ? summarizeToolInput(tool.toolName, tool.args) + : null; + return summary ? `${name} ${summary}` : name; +} + function IssueChatChainOfThought() { + const { agentMap } = useContext(IssueChatCtx); const message = useMessage(); const custom = message.metadata.custom as Record; - const customLabel = typeof custom.chainOfThoughtLabel === "string" && custom.chainOfThoughtLabel.trim().length > 0 - ? custom.chainOfThoughtLabel - : null; - const label = customLabel - ? customLabel - : "Chain of thought"; + const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null; + const authorAgentId = typeof custom.authorAgentId === "string" ? custom.authorAgentId : null; + const agentId = authorAgentId ?? runAgentId; + const agentIcon = agentId ? agentMap?.get(agentId)?.icon : undefined; + const isMessageRunning = message.role === "assistant" && message.status?.type === "running"; + + const cotParts = useAuiState((s) => s.chainOfThought?.parts ?? []) as ReadonlyArray<{ type: string; text?: string; toolName?: string; toolCallId?: string; args?: unknown; argsText?: string; result?: unknown; isError?: boolean }>; + + const myIndex = useMemo( + () => findCoTSegmentIndex(message.content, cotParts), + [message.content, cotParts], + ); + + const allReasoningText = cotParts + .filter((p): p is { type: "reasoning"; text: string } => p.type === "reasoning" && !!p.text) + .map((p) => p.text) + .join("\n"); + const toolParts = cotParts.filter( + (p): p is ToolCallMessagePart => p.type === "tool-call", + ); + + const hasActiveTool = toolParts.some((t) => t.result === undefined); + const isActive = isMessageRunning && hasActiveTool; + const [expanded, setExpanded] = useState(isActive); + + const rawSegments = Array.isArray(custom.chainOfThoughtSegments) + ? (custom.chainOfThoughtSegments as SegmentTiming[]) + : []; + const segmentTiming = myIndex >= 0 ? rawSegments[myIndex] ?? null : null; + const liveElapsed = useLiveElapsed(segmentTiming?.startMs, isActive); + + useEffect(() => { + if (isActive) setExpanded(true); + }, [isActive]); + + let headerVerb: string; + let headerSuffix: string | null = null; + if (isActive) { + headerVerb = "Working"; + if (liveElapsed) headerSuffix = `for ${liveElapsed}`; + } else if (segmentTiming) { + const durationMs = segmentTiming.endMs - segmentTiming.startMs; + const durationText = formatDurationWords(durationMs); + headerVerb = "Worked"; + if (durationText) headerSuffix = `for ${durationText}`; + } else { + headerVerb = "Worked"; + } + + const toolSummary = toolCountSummary(toolParts); + const hasContent = allReasoningText.trim().length > 0 || toolParts.length > 0; + return ( - - - - - {label} - - {customLabel ? ( - - Chain of thought +
+ + {expanded && hasContent ? ( +
+ {isActive ? ( + <> + {allReasoningText ? : null} + {toolParts.length > 0 ? : null} + + ) : ( + <> + {allReasoningText ? : null} + {toolParts.map((tool) => ( - ), - }, - Layout: ({ children }) =>
{children}
, - }} - /> -
- + ))} + + )} +
+ ) : null} + ); } function IssueChatReasoningPart({ text }: { text: string }) { + const lines = text.split("\n").filter((l) => l.trim()); + const lastLine = lines[lines.length - 1] ?? text.slice(-200); + const prevRef = useRef(lastLine); + const [ticker, setTicker] = useState<{ + key: number; + current: string; + exiting: string | null; + }>({ key: 0, current: lastLine, exiting: null }); + + useEffect(() => { + if (lastLine !== prevRef.current) { + const prev = prevRef.current; + prevRef.current = lastLine; + setTicker((t) => ({ key: t.key + 1, current: lastLine, exiting: prev })); + } + }, [lastLine]); + return ( -
-
- - Reasoning +
+
+ +
+
+ {ticker.exiting !== null && ( + setTicker((t) => ({ ...t, exiting: null }))} + > + {ticker.exiting} + + )} + 0 && "cot-line-enter", + )} + > + {ticker.current} +
- {text}
); } +function IssueChatRollingToolPart({ toolParts }: { toolParts: ToolCallMessagePart[] }) { + const latest = toolParts[toolParts.length - 1]; + if (!latest) return null; + + const fullText = cleanToolDisplayText(latest); + + const prevRef = useRef(fullText); + const [ticker, setTicker] = useState<{ + key: number; + current: string; + exiting: string | null; + }>({ key: 0, current: fullText, exiting: null }); + + useEffect(() => { + if (fullText !== prevRef.current) { + const prev = prevRef.current; + prevRef.current = fullText; + setTicker((t) => ({ key: t.key + 1, current: fullText, exiting: prev })); + } + }, [fullText]); + + const ToolIcon = getToolIcon(latest.toolName); + const isRunning = latest.result === undefined; + + return ( +
+
+ {isRunning ? ( + + ) : ( + + )} +
+
+ {ticker.exiting !== null && ( + setTicker((t) => ({ ...t, exiting: null }))} + > + {ticker.exiting} + + )} + 0 && "cot-line-enter", + )} + > + {ticker.current} + +
+
+ ); +} + +function CopyablePreBlock({ children, className }: { children: string; className?: string }) { + const [copied, setCopied] = useState(false); + return ( +
+
{children}
+ +
+ ); +} + +const TOOL_ICON_MAP: Record> = { + // Extend with specific tool icons as they become known +}; + +function getToolIcon(toolName: string): React.ComponentType<{ className?: string }> { + return TOOL_ICON_MAP[toolName] ?? Hammer; +} + function IssueChatToolPart({ toolName, args, @@ -322,7 +597,7 @@ function IssueChatToolPart({ result?: unknown; isError?: boolean; }) { - const [open, setOpen] = useState(Boolean(isError)); + const [open, setOpen] = useState(false); const rawArgsText = argsText ?? ""; const parsedArgs = args ?? parseToolPayload(rawArgsText); const resultText = @@ -333,90 +608,80 @@ function IssueChatToolPart({ : formatToolPayload(result); const inputDetails = describeToolInput(toolName, parsedArgs); const displayName = displayToolName(toolName, parsedArgs); - const summary = - result === undefined + const isCommand = isCommandTool(toolName, parsedArgs); + const summary = isCommand + ? null + : result === undefined ? summarizeToolInput(toolName, parsedArgs) - : summarizeToolResult(resultText, isError); + : summarizeToolResult(resultText, false); + const ToolIcon = getToolIcon(toolName); + + const intentDetail = inputDetails.find((d) => d.label === "Intent"); + const title = intentDetail?.value ?? displayName; + const nonIntentDetails = inputDetails.filter((d) => d.label !== "Intent"); return ( -
- +
+
+ + {open ?
: null} +
- {open ? ( -
- {inputDetails.length > 0 ? ( -
-
- Input -
-
- {inputDetails.map((detail) => ( -
-
- {detail.label} -
-
- {detail.value} -
-
- ))} -
-
- ) : rawArgsText ? ( -
-
- Input -
-
{rawArgsText}
-
+
+ + + {open ? ( +
+ {nonIntentDetails.length > 0 ? ( +
+
+ Input +
+
+ {nonIntentDetails.map((detail) => ( +
+
+ {detail.label} +
+
+ {detail.value} +
+
+ ))} +
-
{resultText}
-
- ) : null} -
- ) : null} + ) : rawArgsText ? ( +
+
+ Input +
+ {rawArgsText} +
+ ) : null} + {result !== undefined ? ( +
+
+ Result +
+ {resultText} +
+ ) : null} +
+ ) : null} +
); } @@ -437,7 +702,7 @@ function IssueChatUserMessage() {
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() {
+ +
+
+
+ Default interval (2.2s) +
+ +
+
+
+ Fast interval (1s) — stress test +
+ +
+
+
+