mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 11:40:39 +09:00
Polish issue chat transcript presentation
This commit is contained in:
parent
34589ad457
commit
92f142f7f8
6 changed files with 765 additions and 271 deletions
|
|
@ -5,7 +5,7 @@ import type { ReactNode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { IssueChatThread } from "./IssueChatThread";
|
import { IssueChatThread, resolveAssistantMessageFoldedState } from "./IssueChatThread";
|
||||||
|
|
||||||
vi.mock("@assistant-ui/react", () => ({
|
vi.mock("@assistant-ui/react", () => ({
|
||||||
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
|
@ -206,4 +206,24 @@ describe("IssueChatThread", () => {
|
||||||
remount.unmount();
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import {
|
import {
|
||||||
AssistantRuntimeProvider,
|
AssistantRuntimeProvider,
|
||||||
ActionBarPrimitive,
|
ActionBarPrimitive,
|
||||||
ChainOfThoughtPrimitive,
|
|
||||||
MessagePrimitive,
|
MessagePrimitive,
|
||||||
ThreadPrimitive,
|
ThreadPrimitive,
|
||||||
useAui,
|
useAui,
|
||||||
|
useAuiState,
|
||||||
useMessage,
|
useMessage,
|
||||||
} from "@assistant-ui/react";
|
} from "@assistant-ui/react";
|
||||||
|
import type { ToolCallMessagePart } from "@assistant-ui/react";
|
||||||
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||||
import { Link, useLocation } from "@/lib/router";
|
import { Link, useLocation } from "@/lib/router";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -20,9 +21,11 @@ import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
|
||||||
import { usePaperclipIssueRuntime, type PaperclipIssueRuntimeReassignment } from "../hooks/usePaperclipIssueRuntime";
|
import { usePaperclipIssueRuntime, type PaperclipIssueRuntimeReassignment } from "../hooks/usePaperclipIssueRuntime";
|
||||||
import {
|
import {
|
||||||
buildIssueChatMessages,
|
buildIssueChatMessages,
|
||||||
|
formatDurationWords,
|
||||||
type IssueChatComment,
|
type IssueChatComment,
|
||||||
type IssueChatLinkedRun,
|
type IssueChatLinkedRun,
|
||||||
type IssueChatTranscriptEntry,
|
type IssueChatTranscriptEntry,
|
||||||
|
type SegmentTiming,
|
||||||
} from "../lib/issue-chat-messages";
|
} from "../lib/issue-chat-messages";
|
||||||
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
|
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -53,6 +56,7 @@ import {
|
||||||
describeToolInput,
|
describeToolInput,
|
||||||
displayToolName,
|
displayToolName,
|
||||||
formatToolPayload,
|
formatToolPayload,
|
||||||
|
isCommandTool,
|
||||||
parseToolPayload,
|
parseToolPayload,
|
||||||
summarizeToolInput,
|
summarizeToolInput,
|
||||||
summarizeToolResult,
|
summarizeToolResult,
|
||||||
|
|
@ -61,7 +65,7 @@ import { cn, formatDateTime, formatShortDate } from "../lib/utils";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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 {
|
interface IssueChatMessageContext {
|
||||||
feedbackVoteByTargetId: Map<string, FeedbackVoteValue>;
|
feedbackVoteByTargetId: Map<string, FeedbackVoteValue>;
|
||||||
|
|
@ -84,6 +88,57 @@ const IssueChatCtx = createContext<IssueChatMessageContext>({
|
||||||
feedbackTermsUrl: null,
|
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 {
|
interface CommentReassignment {
|
||||||
assigneeAgentId: string | null;
|
assigneeAgentId: string | null;
|
||||||
assigneeUserId: string | null;
|
assigneeUserId: string | null;
|
||||||
|
|
@ -186,8 +241,12 @@ function commentDateLabel(date: Date | string | undefined): string {
|
||||||
return formatShortDate(date);
|
return formatShortDate(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
function IssueChatTextPart({ text }: { text: string }) {
|
function IssueChatTextPart({ text, recessed }: { text: string; recessed?: boolean }) {
|
||||||
return <MarkdownBody className="text-sm leading-6">{text}</MarkdownBody>;
|
return (
|
||||||
|
<MarkdownBody className="text-sm leading-6" style={recessed ? { opacity: 0.55 } : undefined}>
|
||||||
|
{text}
|
||||||
|
</MarkdownBody>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function humanizeValue(value: string | null) {
|
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() {
|
function IssueChatChainOfThought() {
|
||||||
|
const { agentMap } = useContext(IssueChatCtx);
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
const custom = message.metadata.custom as Record<string, unknown>;
|
const custom = message.metadata.custom as Record<string, unknown>;
|
||||||
const customLabel = typeof custom.chainOfThoughtLabel === "string" && custom.chainOfThoughtLabel.trim().length > 0
|
const runAgentId = typeof custom.runAgentId === "string" ? custom.runAgentId : null;
|
||||||
? custom.chainOfThoughtLabel
|
const authorAgentId = typeof custom.authorAgentId === "string" ? custom.authorAgentId : null;
|
||||||
: null;
|
const agentId = authorAgentId ?? runAgentId;
|
||||||
const label = customLabel
|
const agentIcon = agentId ? agentMap?.get(agentId)?.icon : undefined;
|
||||||
? customLabel
|
const isMessageRunning = message.role === "assistant" && message.status?.type === "running";
|
||||||
: "Chain of thought";
|
|
||||||
|
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 (
|
return (
|
||||||
<ChainOfThoughtPrimitive.Root className="overflow-hidden rounded-2xl border border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.92),rgba(248,250,252,0.82))] shadow-[0_10px_30px_rgba(15,23,42,0.04)] dark:bg-[linear-gradient(180deg,rgba(15,23,42,0.62),rgba(15,23,42,0.4))]">
|
<div>
|
||||||
<ChainOfThoughtPrimitive.AccordionTrigger className="group flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/10">
|
<button
|
||||||
<span className="inline-flex flex-col items-start gap-0.5">
|
type="button"
|
||||||
<span className={cn(customLabel ? "text-sm font-medium normal-case tracking-normal text-foreground/90" : "uppercase tracking-[0.14em]")}>
|
className="group flex w-full items-center gap-2.5 rounded-lg px-1 py-2 text-left transition-colors hover:bg-accent/5"
|
||||||
{label}
|
onClick={() => hasContent && setExpanded((v) => !v)}
|
||||||
</span>
|
>
|
||||||
{customLabel ? (
|
<span className="inline-flex items-center gap-2 text-sm font-medium text-foreground/80">
|
||||||
<span className="text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
|
{agentIcon ? (
|
||||||
Chain of thought
|
<AgentIcon icon={agentIcon} className="h-4 w-4 shrink-0" />
|
||||||
|
) : isActive ? (
|
||||||
|
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500/70" />
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
)}
|
||||||
|
{isActive ? (
|
||||||
|
<span className="shimmer-text">{headerVerb}</span>
|
||||||
|
) : (
|
||||||
|
headerVerb
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="inline-flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
|
{headerSuffix ? (
|
||||||
Details
|
<span className="text-xs text-muted-foreground/60">{headerSuffix}</span>
|
||||||
<ChevronDown className="h-4 w-4 transition-transform group-data-[state=open]:rotate-180" />
|
) : null}
|
||||||
</span>
|
{toolSummary ? (
|
||||||
</ChainOfThoughtPrimitive.AccordionTrigger>
|
<span className="text-xs text-muted-foreground/40">· {toolSummary}</span>
|
||||||
<div className="border-t border-border/60 bg-background/35 px-4 py-3">
|
) : null}
|
||||||
<ChainOfThoughtPrimitive.Parts
|
{hasContent ? (
|
||||||
components={{
|
<ChevronDown className={cn("ml-auto h-4 w-4 shrink-0 text-muted-foreground/50 transition-transform", expanded && "rotate-180")} />
|
||||||
Reasoning: ({ text }) => <IssueChatReasoningPart text={text} />,
|
) : null}
|
||||||
tools: {
|
</button>
|
||||||
Fallback: ({ toolName, args, argsText, result, isError }) => (
|
{expanded && hasContent ? (
|
||||||
|
<div className="space-y-1 py-1">
|
||||||
|
{isActive ? (
|
||||||
|
<>
|
||||||
|
{allReasoningText ? <IssueChatReasoningPart text={allReasoningText} /> : null}
|
||||||
|
{toolParts.length > 0 ? <IssueChatRollingToolPart toolParts={toolParts} /> : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{allReasoningText ? <IssueChatReasoningPart text={allReasoningText} /> : null}
|
||||||
|
{toolParts.map((tool) => (
|
||||||
<IssueChatToolPart
|
<IssueChatToolPart
|
||||||
toolName={toolName}
|
key={tool.toolCallId}
|
||||||
args={args}
|
toolName={tool.toolName}
|
||||||
argsText={argsText}
|
args={tool.args}
|
||||||
result={result}
|
argsText={tool.argsText}
|
||||||
isError={isError}
|
result={tool.result}
|
||||||
|
isError={false}
|
||||||
/>
|
/>
|
||||||
),
|
))}
|
||||||
},
|
</>
|
||||||
Layout: ({ children }) => <div className="space-y-2.5">{children}</div>,
|
)}
|
||||||
}}
|
</div>
|
||||||
/>
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</ChainOfThoughtPrimitive.Root>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function IssueChatReasoningPart({ text }: { text: string }) {
|
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 (
|
return (
|
||||||
<div className="rounded-xl border border-border/60 bg-background/70 px-3.5 py-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.45)]">
|
<div className="flex gap-2 px-1">
|
||||||
<div className="mb-2 inline-flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
<div className="flex flex-col items-center pt-0.5">
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-cyan-500/70" />
|
<Brain className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
|
||||||
Reasoning
|
</div>
|
||||||
|
<div className="relative h-5 min-w-0 flex-1 overflow-hidden">
|
||||||
|
{ticker.exiting !== null && (
|
||||||
|
<span
|
||||||
|
key={`out-${ticker.key}`}
|
||||||
|
className="cot-line-exit absolute inset-x-0 truncate text-[13px] italic leading-5 text-muted-foreground/70"
|
||||||
|
onAnimationEnd={() => setTicker((t) => ({ ...t, exiting: null }))}
|
||||||
|
>
|
||||||
|
{ticker.exiting}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
key={`in-${ticker.key}`}
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-x-0 truncate text-[13px] italic leading-5 text-muted-foreground/70",
|
||||||
|
ticker.key > 0 && "cot-line-enter",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ticker.current}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<MarkdownBody className="text-sm leading-6 text-foreground/88">{text}</MarkdownBody>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex gap-2 px-1">
|
||||||
|
<div className="flex flex-col items-center pt-0.5">
|
||||||
|
{isRunning ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground/50" />
|
||||||
|
) : (
|
||||||
|
<ToolIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative h-5 min-w-0 flex-1 overflow-hidden">
|
||||||
|
{ticker.exiting !== null && (
|
||||||
|
<span
|
||||||
|
key={`out-${ticker.key}`}
|
||||||
|
className="cot-line-exit absolute inset-x-0 truncate text-[13px] leading-5 text-muted-foreground/70"
|
||||||
|
onAnimationEnd={() => setTicker((t) => ({ ...t, exiting: null }))}
|
||||||
|
>
|
||||||
|
{ticker.exiting}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
key={`in-${ticker.key}`}
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-x-0 truncate text-[13px] leading-5 text-muted-foreground/70",
|
||||||
|
ticker.key > 0 && "cot-line-enter",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ticker.current}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyablePreBlock({ children, className }: { children: string; className?: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className="group/pre relative">
|
||||||
|
<pre className={className}>{children}</pre>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"absolute right-1.5 top-1.5 inline-flex h-6 w-6 items-center justify-center rounded-md bg-background/80 text-muted-foreground opacity-0 backdrop-blur-sm transition-opacity hover:text-foreground group-hover/pre:opacity-100",
|
||||||
|
copied && "opacity-100",
|
||||||
|
)}
|
||||||
|
title="Copy"
|
||||||
|
aria-label="Copy"
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard.writeText(children).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOL_ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
|
// 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({
|
function IssueChatToolPart({
|
||||||
toolName,
|
toolName,
|
||||||
args,
|
args,
|
||||||
|
|
@ -322,7 +597,7 @@ function IssueChatToolPart({
|
||||||
result?: unknown;
|
result?: unknown;
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(Boolean(isError));
|
const [open, setOpen] = useState(false);
|
||||||
const rawArgsText = argsText ?? "";
|
const rawArgsText = argsText ?? "";
|
||||||
const parsedArgs = args ?? parseToolPayload(rawArgsText);
|
const parsedArgs = args ?? parseToolPayload(rawArgsText);
|
||||||
const resultText =
|
const resultText =
|
||||||
|
|
@ -333,90 +608,80 @@ function IssueChatToolPart({
|
||||||
: formatToolPayload(result);
|
: formatToolPayload(result);
|
||||||
const inputDetails = describeToolInput(toolName, parsedArgs);
|
const inputDetails = describeToolInput(toolName, parsedArgs);
|
||||||
const displayName = displayToolName(toolName, parsedArgs);
|
const displayName = displayToolName(toolName, parsedArgs);
|
||||||
const summary =
|
const isCommand = isCommandTool(toolName, parsedArgs);
|
||||||
result === undefined
|
const summary = isCommand
|
||||||
|
? null
|
||||||
|
: result === undefined
|
||||||
? summarizeToolInput(toolName, parsedArgs)
|
? 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 (
|
return (
|
||||||
<div
|
<div className="flex gap-2 px-1">
|
||||||
className={cn(
|
<div className="flex flex-col items-center pt-1">
|
||||||
"rounded-xl border px-3.5 py-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.35)]",
|
<ToolIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
|
||||||
isError
|
{open ? <div className="mt-1 w-px flex-1 bg-border/40" /> : null}
|
||||||
? "border-red-300/70 bg-[linear-gradient(180deg,rgba(254,242,242,0.95),rgba(254,242,242,0.72))] dark:border-red-500/40 dark:bg-red-500/10"
|
</div>
|
||||||
: "border-border/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.94),rgba(248,250,252,0.78))] dark:bg-background/70",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-start justify-between gap-3 text-left"
|
|
||||||
onClick={() => setOpen((current) => !current)}
|
|
||||||
>
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="inline-flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
|
||||||
<span className={cn("h-1.5 w-1.5 rounded-full", result === undefined ? "bg-cyan-500/75" : isError ? "bg-red-500/75" : "bg-emerald-500/75")} />
|
|
||||||
Tool call
|
|
||||||
</span>
|
|
||||||
<span className="mt-1 block text-sm font-medium text-foreground">{displayName}</span>
|
|
||||||
<span className="mt-1.5 block text-sm leading-6 text-foreground/72">{summary}</span>
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 flex items-center gap-2 pt-0.5">
|
|
||||||
{result === undefined ? (
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full border border-cyan-400/35 bg-cyan-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-cyan-700 dark:text-cyan-200">
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
Running
|
|
||||||
</span>
|
|
||||||
) : isError ? (
|
|
||||||
<span className="inline-flex items-center rounded-full border border-red-400/45 bg-red-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-red-700 dark:text-red-200">
|
|
||||||
Error
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center rounded-full border border-emerald-400/45 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-emerald-700 dark:text-emerald-200">
|
|
||||||
Complete
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform", open && "rotate-180")} />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{open ? (
|
<div className="min-w-0 flex-1">
|
||||||
<div className="mt-3 space-y-3 border-t border-border/60 pt-3">
|
<button
|
||||||
{inputDetails.length > 0 ? (
|
type="button"
|
||||||
<div>
|
className="flex w-full items-center gap-2 rounded-md py-0.5 text-left transition-colors hover:bg-accent/5"
|
||||||
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
onClick={() => setOpen((current) => !current)}
|
||||||
Input
|
>
|
||||||
</div>
|
<span className="min-w-0 flex-1 truncate text-[13px] text-muted-foreground/80">
|
||||||
<dl className="space-y-2">
|
{title}
|
||||||
{inputDetails.map((detail) => (
|
{!intentDetail && summary ? <span className="ml-1.5 text-muted-foreground/50">{summary}</span> : null}
|
||||||
<div key={`${detail.label}:${detail.value}`} className="rounded-xl border border-border/60 bg-background/70 px-3 py-2.5">
|
</span>
|
||||||
<dt className="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
{result === undefined ? (
|
||||||
{detail.label}
|
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground/50" />
|
||||||
</dt>
|
|
||||||
<dd className={cn("mt-1 text-sm leading-6 text-foreground/85", detail.tone === "code" && "font-mono text-[13px] leading-5")}>
|
|
||||||
{detail.value}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
) : rawArgsText ? (
|
|
||||||
<div>
|
|
||||||
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
|
||||||
Input
|
|
||||||
</div>
|
|
||||||
<pre className="overflow-x-auto rounded-xl border border-border/60 bg-background/70 p-2.5 text-xs leading-5 text-foreground/85">{rawArgsText}</pre>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
{result !== undefined ? (
|
<ChevronDown className={cn("h-3.5 w-3.5 shrink-0 text-muted-foreground/40 transition-transform", open && "rotate-180")} />
|
||||||
<div>
|
</button>
|
||||||
<div className="mb-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
|
||||||
Result
|
{open ? (
|
||||||
|
<div className="mt-1 space-y-2 pb-1">
|
||||||
|
{nonIntentDetails.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/60">
|
||||||
|
Input
|
||||||
|
</div>
|
||||||
|
<dl className="space-y-1.5">
|
||||||
|
{nonIntentDetails.map((detail) => (
|
||||||
|
<div key={`${detail.label}:${detail.value}`}>
|
||||||
|
<dt className="text-[10px] font-medium text-muted-foreground/60">
|
||||||
|
{detail.label}
|
||||||
|
</dt>
|
||||||
|
<dd className={cn("text-xs leading-5 text-foreground/70", detail.tone === "code" && "font-mono text-[11px]")}>
|
||||||
|
{detail.value}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
<pre className="overflow-x-auto rounded-xl border border-border/60 bg-background/70 p-2.5 text-xs leading-5 text-foreground/85">{resultText}</pre>
|
) : rawArgsText ? (
|
||||||
</div>
|
<div>
|
||||||
) : null}
|
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/60">
|
||||||
</div>
|
Input
|
||||||
) : null}
|
</div>
|
||||||
|
<CopyablePreBlock className="overflow-x-auto rounded-md bg-accent/30 p-2 text-[11px] leading-4 text-foreground/70">{rawArgsText}</CopyablePreBlock>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{result !== undefined ? (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground/60">
|
||||||
|
Result
|
||||||
|
</div>
|
||||||
|
<CopyablePreBlock className="overflow-x-auto rounded-md bg-accent/30 p-2 text-[11px] leading-4 text-foreground/70">{resultText}</CopyablePreBlock>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -437,7 +702,7 @@ function IssueChatUserMessage() {
|
||||||
<div className="flex min-w-0 max-w-[85%] flex-col items-end">
|
<div className="flex min-w-0 max-w-[85%] flex-col items-end">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-w-0 overflow-hidden rounded-2xl px-4 py-2.5",
|
"min-w-0 break-all rounded-2xl px-4 py-2.5",
|
||||||
queued
|
queued
|
||||||
? "bg-amber-50/80 dark:bg-amber-500/10"
|
? "bg-amber-50/80 dark:bg-amber-500/10"
|
||||||
: "bg-muted",
|
: "bg-muted",
|
||||||
|
|
@ -544,6 +809,27 @@ function IssueChatAssistantMessage() {
|
||||||
const waitingText = typeof custom.waitingText === "string" ? custom.waitingText : "";
|
const waitingText = typeof custom.waitingText === "string" ? custom.waitingText : "";
|
||||||
const isRunning = message.role === "assistant" && message.status?.type === "running";
|
const isRunning = message.role === "assistant" && message.status?.type === "running";
|
||||||
const runHref = runId && runAgentId ? `/agents/${runAgentId}/runs/${runId}` : null;
|
const runHref = runId && runAgentId ? `/agents/${runAgentId}/runs/${runId}` : null;
|
||||||
|
const chainOfThoughtLabel = typeof custom.chainOfThoughtLabel === "string" ? custom.chainOfThoughtLabel : null;
|
||||||
|
const hasCoT = message.content.some((p) => p.type === "reasoning" || p.type === "tool-call");
|
||||||
|
const isFoldable = !isRunning && hasCoT && !!chainOfThoughtLabel;
|
||||||
|
const [folded, setFolded] = useState(isFoldable);
|
||||||
|
const previousMessageIdRef = useRef<string | null>(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 (
|
const handleVote = async (
|
||||||
vote: FeedbackVoteValue,
|
vote: FeedbackVoteValue,
|
||||||
|
|
@ -567,109 +853,132 @@ function IssueChatAssistantMessage() {
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="mb-1.5 flex items-center gap-2">
|
{isFoldable ? (
|
||||||
<span className="text-sm font-medium text-foreground">{authorName}</span>
|
<button
|
||||||
{isRunning ? (
|
type="button"
|
||||||
<span className="inline-flex items-center gap-1 rounded-full border border-cyan-400/40 bg-cyan-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-cyan-700 dark:text-cyan-200">
|
className="group flex w-full items-center gap-2 py-0.5 text-left"
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
onClick={() => setFolded((v) => !v)}
|
||||||
Running
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<MessagePrimitive.Parts
|
|
||||||
components={{
|
|
||||||
Text: ({ text }) => <IssueChatTextPart text={text} />,
|
|
||||||
ChainOfThought: IssueChatChainOfThought,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{message.content.length === 0 && waitingText ? (
|
|
||||||
<div className="rounded-sm bg-accent/20 px-3 py-2 text-sm text-muted-foreground">
|
|
||||||
{waitingText}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{notices.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{notices.map((notice, index) => (
|
|
||||||
<div
|
|
||||||
key={`${message.id}:notice:${index}`}
|
|
||||||
className="rounded-sm border border-border/60 bg-accent/20 px-3 py-2 text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
{notice}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 flex items-center gap-1">
|
|
||||||
<ActionBarPrimitive.Copy
|
|
||||||
copiedDuration={2000}
|
|
||||||
className="group inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground data-[copied=true]:text-foreground"
|
|
||||||
title="Copy message"
|
|
||||||
aria-label="Copy message"
|
|
||||||
>
|
>
|
||||||
<Copy className="h-3.5 w-3.5 group-data-[copied=true]:hidden" />
|
<span className="text-sm font-medium text-foreground">{authorName}</span>
|
||||||
<Check className="hidden h-3.5 w-3.5 group-data-[copied=true]:block" />
|
<span className="text-xs text-muted-foreground/60">{chainOfThoughtLabel?.toLowerCase()}</span>
|
||||||
</ActionBarPrimitive.Copy>
|
<span className="ml-auto flex items-center gap-1.5">
|
||||||
{commentId && onVote ? (
|
{message.createdAt ? (
|
||||||
<IssueChatFeedbackButtons
|
<span className="text-[11px] text-muted-foreground/50">
|
||||||
activeVote={activeVote}
|
{commentDateLabel(message.createdAt)}
|
||||||
sharingPreference={feedbackDataSharingPreference}
|
</span>
|
||||||
termsUrl={feedbackTermsUrl ?? null}
|
|
||||||
onVote={handleVote}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<a
|
|
||||||
href={anchorId ? `#${anchorId}` : undefined}
|
|
||||||
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
|
|
||||||
>
|
|
||||||
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
|
|
||||||
</a>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" className="text-xs">
|
|
||||||
{message.createdAt ? formatDateTime(message.createdAt) : ""}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-xs"
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
title="More actions"
|
|
||||||
aria-label="More actions"
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
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 className="mr-2 h-3.5 w-3.5" />
|
|
||||||
Copy message
|
|
||||||
</DropdownMenuItem>
|
|
||||||
{runHref ? (
|
|
||||||
<DropdownMenuItem asChild>
|
|
||||||
<Link to={runHref} target="_blank" rel="noreferrer noopener">
|
|
||||||
<Search className="mr-2 h-3.5 w-3.5" />
|
|
||||||
View run
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
) : null}
|
) : null}
|
||||||
</DropdownMenuContent>
|
<ChevronDown className={cn("h-3.5 w-3.5 text-muted-foreground/40 transition-transform", !folded && "rotate-180")} />
|
||||||
</DropdownMenu>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="mb-1.5 flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-foreground">{authorName}</span>
|
||||||
|
{isRunning ? (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border border-cyan-400/40 bg-cyan-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-cyan-700 dark:text-cyan-200">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
Running
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!folded ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<MessagePrimitive.Parts
|
||||||
|
components={{
|
||||||
|
Text: ({ text }) => <IssueChatTextPart text={text} recessed={hasCoT} />,
|
||||||
|
ChainOfThought: IssueChatChainOfThought,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{message.content.length === 0 && waitingText ? (
|
||||||
|
<div className="rounded-sm bg-accent/20 px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
{waitingText}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{notices.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{notices.map((notice, index) => (
|
||||||
|
<div
|
||||||
|
key={`${message.id}:notice:${index}`}
|
||||||
|
className="rounded-sm border border-border/60 bg-accent/20 px-3 py-2 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{notice}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center gap-1">
|
||||||
|
<ActionBarPrimitive.Copy
|
||||||
|
copiedDuration={2000}
|
||||||
|
className="group inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground data-[copied=true]:text-foreground"
|
||||||
|
title="Copy message"
|
||||||
|
aria-label="Copy message"
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5 group-data-[copied=true]:hidden" />
|
||||||
|
<Check className="hidden h-3.5 w-3.5 group-data-[copied=true]:block" />
|
||||||
|
</ActionBarPrimitive.Copy>
|
||||||
|
{commentId && onVote ? (
|
||||||
|
<IssueChatFeedbackButtons
|
||||||
|
activeVote={activeVote}
|
||||||
|
sharingPreference={feedbackDataSharingPreference}
|
||||||
|
termsUrl={feedbackTermsUrl ?? null}
|
||||||
|
onVote={handleVote}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<a
|
||||||
|
href={anchorId ? `#${anchorId}` : undefined}
|
||||||
|
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
{message.createdAt ? commentDateLabel(message.createdAt) : ""}
|
||||||
|
</a>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="text-xs">
|
||||||
|
{message.createdAt ? formatDateTime(message.createdAt) : ""}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
title="More actions"
|
||||||
|
aria-label="More actions"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
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 className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Copy message
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{runHref ? (
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link to={runHref} target="_blank" rel="noreferrer noopener">
|
||||||
|
<Search className="mr-2 h-3.5 w-3.5" />
|
||||||
|
View run
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MessagePrimitive.Root>
|
</MessagePrimitive.Root>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chi
|
||||||
interface MarkdownBodyProps {
|
interface MarkdownBodyProps {
|
||||||
children: string;
|
children: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
/** Optional resolver for relative image paths (e.g. within export packages) */
|
/** Optional resolver for relative image paths (e.g. within export packages) */
|
||||||
resolveImageSrc?: (src: string) => string | null;
|
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 { theme } = useTheme();
|
||||||
const components: Components = {
|
const components: Components = {
|
||||||
pre: ({ node: _node, children: preChildren, ...preProps }) => {
|
pre: ({ node: _node, children: preChildren, ...preProps }) => {
|
||||||
|
|
@ -145,6 +146,7 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB
|
||||||
theme === "dark" && "prose-invert",
|
theme === "dark" && "prose-invert",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
style={style}
|
||||||
>
|
>
|
||||||
<Markdown remarkPlugins={[remarkGfm]} components={components} urlTransform={(url) => url}>
|
<Markdown remarkPlugins={[remarkGfm]} components={components} urlTransform={(url) => url}>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
/* MDXEditor theme integration */
|
||||||
.paperclip-mdxeditor-scope,
|
.paperclip-mdxeditor-scope,
|
||||||
.paperclip-mdxeditor {
|
.paperclip-mdxeditor {
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,6 @@ import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
|
||||||
import { formatAssigneeUserLabel } from "./assignees";
|
import { formatAssigneeUserLabel } from "./assignees";
|
||||||
import type { IssueTimelineEvent } from "./issue-timeline-events";
|
import type { IssueTimelineEvent } from "./issue-timeline-events";
|
||||||
import {
|
import {
|
||||||
parseSystemActivity,
|
|
||||||
shouldHideNiceModeStderr,
|
|
||||||
summarizeNotice,
|
summarizeNotice,
|
||||||
} from "./transcriptPresentation";
|
} from "./transcriptPresentation";
|
||||||
|
|
||||||
|
|
@ -281,7 +279,47 @@ function runTimestamp(run: IssueChatLinkedRun) {
|
||||||
return run.finishedAt ?? run.startedAt ?? run.createdAt;
|
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;
|
if (ms === null || !Number.isFinite(ms) || ms <= 0) return null;
|
||||||
const totalSeconds = Math.max(1, Math.round(ms / 1000));
|
const totalSeconds = Math.max(1, Math.round(ms / 1000));
|
||||||
if (totalSeconds < 60) {
|
if (totalSeconds < 60) {
|
||||||
|
|
@ -357,7 +395,7 @@ function createHistoricalTranscriptMessage(args: {
|
||||||
}) {
|
}) {
|
||||||
const { run, transcript, hasOutput, agentMap } = args;
|
const { run, transcript, hasOutput, agentMap } = args;
|
||||||
const agentName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
|
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 waitingText = hasOutput ? "" : "Run finished";
|
||||||
const content = parts.length > 0
|
const content = parts.length > 0
|
||||||
? parts
|
? parts
|
||||||
|
|
@ -381,12 +419,17 @@ function createHistoricalTranscriptMessage(args: {
|
||||||
notices,
|
notices,
|
||||||
waitingText,
|
waitingText,
|
||||||
chainOfThoughtLabel: runDurationLabel(run),
|
chainOfThoughtLabel: runDurationLabel(run),
|
||||||
|
chainOfThoughtSegments: segments,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAssistantPartsFromTranscript(entries: readonly IssueChatTranscriptEntry[]) {
|
export function buildAssistantPartsFromTranscript(entries: readonly IssueChatTranscriptEntry[]): {
|
||||||
|
parts: Array<TextMessagePart | ReasoningMessagePart | ToolCallMessagePart<JsonObject, unknown>>;
|
||||||
|
notices: string[];
|
||||||
|
segments: SegmentTiming[];
|
||||||
|
} {
|
||||||
const orderedParts: Array<TextMessagePart | ReasoningMessagePart | ToolCallMessagePart<JsonObject, unknown>> = [];
|
const orderedParts: Array<TextMessagePart | ReasoningMessagePart | ToolCallMessagePart<JsonObject, unknown>> = [];
|
||||||
const toolParts = new Map<string, ToolCallMessagePart<JsonObject, unknown>>();
|
const toolParts = new Map<string, ToolCallMessagePart<JsonObject, unknown>>();
|
||||||
const toolIndices = new Map<string, number>();
|
const toolIndices = new Map<string, number>();
|
||||||
|
|
@ -446,33 +489,10 @@ export function buildAssistantPartsFromTranscript(entries: readonly IssueChatTra
|
||||||
toolParts.set(toolCallId, nextPart);
|
toolParts.set(toolCallId, nextPart);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (entry.kind === "init" && entry.model) {
|
if (entry.kind === "init") continue;
|
||||||
const sessionSuffix = entry.sessionId ? ` session ${entry.sessionId}` : "";
|
if (entry.kind === "stderr") continue;
|
||||||
orderedParts.push({ type: "reasoning", text: `Started ${entry.model}${sessionSuffix}.` });
|
if (entry.kind === "stdout") continue;
|
||||||
continue;
|
if (entry.kind === "system") 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 === "result") {
|
if (entry.kind === "result") {
|
||||||
if (entry.isError && entry.errors?.length) {
|
if (entry.isError && entry.errors?.length) {
|
||||||
for (const error of entry.errors) {
|
for (const error of entry.errors) {
|
||||||
|
|
@ -483,15 +503,11 @@ export function buildAssistantPartsFromTranscript(entries: readonly IssueChatTra
|
||||||
type: "reasoning",
|
type: "reasoning",
|
||||||
text: entry.isError
|
text: entry.isError
|
||||||
? `Run error: ${summarizeNotice(entry.text)}`
|
? `Run error: ${summarizeNotice(entry.text)}`
|
||||||
: `Run update: ${summarizeNotice(entry.text)}`,
|
: summarizeNotice(entry.text),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (entry.kind === "stdout" && entry.text) {
|
|
||||||
orderedParts.push({ type: "reasoning", text: `Log: ${summarizeNotice(entry.text)}` });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergedParts: Array<TextMessagePart | ReasoningMessagePart | ToolCallMessagePart<JsonObject, unknown>> = [];
|
const mergedParts: Array<TextMessagePart | ReasoningMessagePart | ToolCallMessagePart<JsonObject, unknown>> = [];
|
||||||
|
|
@ -514,6 +530,7 @@ export function buildAssistantPartsFromTranscript(entries: readonly IssueChatTra
|
||||||
return {
|
return {
|
||||||
parts: mergedParts,
|
parts: mergedParts,
|
||||||
notices,
|
notices,
|
||||||
|
segments: computeSegmentTimings(entries),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -550,7 +567,7 @@ function createLiveRunMessage(args: {
|
||||||
hasOutput: boolean;
|
hasOutput: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { run, transcript, hasOutput } = args;
|
const { run, transcript, hasOutput } = args;
|
||||||
const { parts, notices } = buildAssistantPartsFromTranscript(transcript);
|
const { parts, notices, segments } = buildAssistantPartsFromTranscript(transcript);
|
||||||
const waitingText =
|
const waitingText =
|
||||||
run.status === "queued"
|
run.status === "queued"
|
||||||
? "Queued..."
|
? "Queued..."
|
||||||
|
|
@ -580,6 +597,7 @@ function createLiveRunMessage(args: {
|
||||||
notices,
|
notices,
|
||||||
waitingText,
|
waitingText,
|
||||||
chainOfThoughtLabel: runDurationLabel(run),
|
chainOfThoughtLabel: runDurationLabel(run),
|
||||||
|
chainOfThoughtSegments: segments,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
return message;
|
return message;
|
||||||
|
|
|
||||||
|
|
@ -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 { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
@ -17,7 +17,7 @@ import {
|
||||||
issueChatUxTranscriptsByRunId,
|
issueChatUxTranscriptsByRunId,
|
||||||
} from "../fixtures/issueChatUxFixtures";
|
} from "../fixtures/issueChatUxFixtures";
|
||||||
import { cn } from "../lib/utils";
|
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 () => {};
|
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 (
|
||||||
|
<div className="flex gap-2 px-1">
|
||||||
|
<div className="flex flex-col items-center pt-0.5">
|
||||||
|
<Brain className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
<div className="relative h-5 min-w-0 flex-1 overflow-hidden">
|
||||||
|
{ticker.exiting !== null && (
|
||||||
|
<span
|
||||||
|
key={`out-${ticker.key}`}
|
||||||
|
className="cot-line-exit absolute inset-x-0 truncate text-[13px] italic leading-5 text-muted-foreground/70"
|
||||||
|
onAnimationEnd={() => setTicker((t) => ({ ...t, exiting: null }))}
|
||||||
|
>
|
||||||
|
{ticker.exiting}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
key={`in-${ticker.key}`}
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-x-0 truncate text-[13px] italic leading-5 text-muted-foreground/70",
|
||||||
|
ticker.key > 0 && "cot-line-enter",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ticker.current}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function IssueChatUxLab() {
|
export function IssueChatUxLab() {
|
||||||
const [showComposer, setShowComposer] = useState(true);
|
const [showComposer, setShowComposer] = useState(true);
|
||||||
|
|
||||||
|
|
@ -129,6 +193,29 @@ export function IssueChatUxLab() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LabSection
|
||||||
|
id="rotating-text"
|
||||||
|
eyebrow="Animation demo"
|
||||||
|
title="Rotating reasoning text"
|
||||||
|
description="Isolated ticker that cycles sample reasoning lines on a timer. The outgoing line slides up and fades out while the incoming line slides up from below. Runs in a loop so you can tune timing and easing without needing a live stream."
|
||||||
|
accentClassName="bg-[linear-gradient(180deg,rgba(168,85,247,0.06),transparent_28%),var(--background)]"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-xl border border-border/60 bg-accent/10 p-4">
|
||||||
|
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Default interval (2.2s)
|
||||||
|
</div>
|
||||||
|
<RotatingReasoningDemo />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-border/60 bg-accent/10 p-4">
|
||||||
|
<div className="mb-2 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
|
Fast interval (1s) — stress test
|
||||||
|
</div>
|
||||||
|
<RotatingReasoningDemo intervalMs={1000} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</LabSection>
|
||||||
|
|
||||||
<LabSection
|
<LabSection
|
||||||
id="live-execution"
|
id="live-execution"
|
||||||
eyebrow="Primary preview"
|
eyebrow="Primary preview"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue