feat(webui): polish agent output and app discovery
This commit is contained in:
@@ -61,7 +61,7 @@ export function AttachmentTile({ attachment, className, inline = false, variant
|
||||
<video
|
||||
src={attachment.url}
|
||||
controls
|
||||
preload="auto"
|
||||
preload="metadata"
|
||||
className={cn(
|
||||
"block w-full bg-black",
|
||||
variant === "compact" ? "max-h-40" : "max-h-[26rem]",
|
||||
|
||||
@@ -3,12 +3,7 @@ import {
|
||||
Suspense,
|
||||
lazy,
|
||||
memo,
|
||||
startTransition,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
@@ -28,17 +23,20 @@ const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({
|
||||
source,
|
||||
className,
|
||||
highlightCode,
|
||||
streaming,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
source: string;
|
||||
className?: string;
|
||||
highlightCode: boolean;
|
||||
streaming: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<LazyMarkdownRenderer
|
||||
className={className}
|
||||
highlightCode={highlightCode}
|
||||
streaming={streaming}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
>
|
||||
{source}
|
||||
@@ -46,13 +44,8 @@ const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({
|
||||
);
|
||||
});
|
||||
|
||||
const SHORT_STREAM_COMMIT_MS = 80;
|
||||
const MEDIUM_STREAM_COMMIT_MS = 140;
|
||||
const LONG_STREAM_COMMIT_MS = 220;
|
||||
const STREAMING_HIGHLIGHT_CHAR_LIMIT = 16_000;
|
||||
|
||||
class MarkdownRendererBoundary extends Component<
|
||||
{ children: ReactNode; fallback: ReactNode },
|
||||
{ children: ReactNode; fallback: ReactNode; resetKey: string },
|
||||
{ failed: boolean }
|
||||
> {
|
||||
state = { failed: false };
|
||||
@@ -61,39 +54,41 @@ class MarkdownRendererBoundary extends Component<
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
componentDidUpdate(previous: Readonly<{ resetKey: string }>) {
|
||||
if (this.state.failed && previous.resetKey !== this.props.resetKey) {
|
||||
this.setState({ failed: false });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.state.failed ? this.props.fallback : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export function preloadMarkdownText(): void {
|
||||
void loadMarkdownRenderer();
|
||||
export function preloadMarkdownText(): Promise<void> {
|
||||
return loadMarkdownRenderer().then(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight markdown renderer mirroring agent-chat-ui: GFM + math via
|
||||
* ``remark-math`` / ``rehype-katex``, and fenced code blocks delegated to
|
||||
* ``CodeBlock`` for copy-to-clipboard and syntax highlighting.
|
||||
*/
|
||||
/** Lazy boundary for the heavier GFM, math, and code renderer. */
|
||||
export function MarkdownText({
|
||||
children,
|
||||
className,
|
||||
streaming = false,
|
||||
onOpenFilePreview,
|
||||
}: MarkdownTextProps) {
|
||||
const renderedSource = useStreamingMarkdownSource(children, streaming);
|
||||
const highlightCode = streaming
|
||||
? renderedSource.length <= STREAMING_HIGHLIGHT_CHAR_LIMIT
|
||||
: renderedSource === children;
|
||||
const renderedSource = children;
|
||||
const renderPhase = streaming ? "streaming" : "complete";
|
||||
const highlightCode = !streaming;
|
||||
|
||||
useEffect(() => {
|
||||
if (streaming) preloadMarkdownText();
|
||||
if (streaming) void preloadMarkdownText();
|
||||
}, [streaming]);
|
||||
|
||||
const plainFallback = (
|
||||
<div
|
||||
className={cn(
|
||||
"whitespace-pre-wrap break-words leading-relaxed text-foreground/92",
|
||||
streaming && "streaming-text-fallback",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -102,73 +97,16 @@ export function MarkdownText({
|
||||
);
|
||||
|
||||
return (
|
||||
<MarkdownRendererBoundary fallback={plainFallback}>
|
||||
<MarkdownRendererBoundary resetKey={renderPhase} fallback={plainFallback}>
|
||||
<Suspense fallback={plainFallback}>
|
||||
<MemoizedMarkdownRenderer
|
||||
source={renderedSource}
|
||||
className={className}
|
||||
highlightCode={highlightCode}
|
||||
streaming={streaming}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
</Suspense>
|
||||
</MarkdownRendererBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function useStreamingMarkdownSource(source: string, streaming: boolean): string {
|
||||
const [renderedSource, setRenderedSource] = useState(source);
|
||||
const latestSourceRef = useRef(source);
|
||||
const renderedSourceRef = useRef(source);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
const clearPendingCommit = useCallback(() => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const commitSource = useCallback((next: string, urgent: boolean) => {
|
||||
if (renderedSourceRef.current === next) return;
|
||||
renderedSourceRef.current = next;
|
||||
if (urgent) {
|
||||
setRenderedSource(next);
|
||||
return;
|
||||
}
|
||||
startTransition(() => setRenderedSource(next));
|
||||
}, []);
|
||||
|
||||
const scheduleCommit = useCallback(() => {
|
||||
if (timerRef.current !== null) return;
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
commitSource(latestSourceRef.current, false);
|
||||
}, streamingCommitDelay(latestSourceRef.current.length));
|
||||
}, [commitSource]);
|
||||
|
||||
latestSourceRef.current = source;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
latestSourceRef.current = source;
|
||||
if (!streaming) {
|
||||
clearPendingCommit();
|
||||
commitSource(source, true);
|
||||
}
|
||||
}, [clearPendingCommit, commitSource, source, streaming]);
|
||||
|
||||
useEffect(() => {
|
||||
latestSourceRef.current = source;
|
||||
if (!streaming) return;
|
||||
scheduleCommit();
|
||||
}, [scheduleCommit, source, streaming]);
|
||||
|
||||
useEffect(() => clearPendingCommit, [clearPendingCommit]);
|
||||
|
||||
return renderedSource;
|
||||
}
|
||||
|
||||
function streamingCommitDelay(length: number): number {
|
||||
if (length > 24_000) return LONG_STREAM_COMMIT_MS;
|
||||
if (length > 8_000) return MEDIUM_STREAM_COMMIT_MS;
|
||||
return SHORT_STREAM_COMMIT_MS;
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { Components, Options as ReactMarkdownOptions } from "react-markdown";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import { Check, Globe2 } from "lucide-react";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import { Streamdown, type Components, type StreamdownProps } from "streamdown";
|
||||
|
||||
import { AttachmentTile } from "@/components/AttachmentTile";
|
||||
import { CodeBlock } from "@/components/CodeBlock";
|
||||
@@ -27,16 +27,18 @@ import {
|
||||
} from "@/components/FileReferenceChip";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { inferMediaKind } from "@/lib/media";
|
||||
import { faviconUrls } from "@/lib/provider-brand";
|
||||
import { browserSafeFaviconUrls } from "@/lib/provider-brand";
|
||||
import { remarkTexMath } from "@/lib/remark-tex-math";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import "katex/dist/katex.min.css";
|
||||
import "streamdown/styles.css";
|
||||
|
||||
interface MarkdownTextRendererProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
highlightCode?: boolean;
|
||||
streaming?: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}
|
||||
|
||||
@@ -235,18 +237,45 @@ function remarkSafeHtmlSubset() {
|
||||
};
|
||||
}
|
||||
|
||||
const remarkPlugins: NonNullable<ReactMarkdownOptions["remarkPlugins"]> = [
|
||||
const remarkPlugins: NonNullable<StreamdownProps["remarkPlugins"]> = [
|
||||
remarkBreaks,
|
||||
remarkGfm,
|
||||
[remarkMath, { singleDollarTextMath: false }],
|
||||
remarkTexMath,
|
||||
remarkSafeHtmlSubset,
|
||||
];
|
||||
const rehypePlugins: NonNullable<ReactMarkdownOptions["rehypePlugins"]> = [rehypeKatex];
|
||||
const rehypePlugins: NonNullable<StreamdownProps["rehypePlugins"]> = [rehypeKatex];
|
||||
|
||||
const DIRECT_LINKS = { enabled: false } as const;
|
||||
const SAFE_MARKDOWN_PROTOCOL = /^(https?|ircs?|mailto|xmpp)$/i;
|
||||
const STREAMING_ANIMATION = {
|
||||
animation: "fadeIn",
|
||||
duration: 180,
|
||||
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
sep: "word",
|
||||
stagger: 18,
|
||||
} as const;
|
||||
|
||||
/** Preserve react-markdown's URL policy when rendering through Streamdown. */
|
||||
const safeMarkdownUrl: NonNullable<StreamdownProps["urlTransform"]> = (url) => {
|
||||
const colon = url.indexOf(":");
|
||||
const questionMark = url.indexOf("?");
|
||||
const hash = url.indexOf("#");
|
||||
const slash = url.indexOf("/");
|
||||
const relative = colon === -1
|
||||
|| (slash !== -1 && colon > slash)
|
||||
|| (questionMark !== -1 && colon > questionMark)
|
||||
|| (hash !== -1 && colon > hash);
|
||||
return relative || SAFE_MARKDOWN_PROTOCOL.test(url.slice(0, colon)) ? url : "";
|
||||
};
|
||||
|
||||
function nodeText(value: ReactNode): string {
|
||||
return Children.toArray(value)
|
||||
.map((child) => (typeof child === "string" || typeof child === "number" ? String(child) : ""))
|
||||
.map((child) => {
|
||||
if (typeof child === "string" || typeof child === "number") return String(child);
|
||||
if (!isValidElement<{ children?: ReactNode }>(child)) return "";
|
||||
return nodeText(child.props.children);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
@@ -272,7 +301,7 @@ function cleanFileReferenceTarget(value: string): string {
|
||||
function isPreviewableFileTarget(value: string): boolean {
|
||||
if (isFilePatternReference(value)) return false;
|
||||
if (isLikelyFilePath(value)) return true;
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return false;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return false;
|
||||
if (/[\\/]/.test(value)) return false;
|
||||
return /^[^?#]+\.[a-z0-9][a-z0-9_-]{0,12}$/i.test(value);
|
||||
}
|
||||
@@ -382,6 +411,8 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
|
||||
className="h-3 w-3 rounded-[2px] object-contain"
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
draggable={false}
|
||||
onLoad={onFaviconLoad}
|
||||
onError={onFaviconError}
|
||||
/>
|
||||
@@ -397,7 +428,7 @@ function InlineLinkPreviewRow({ link }: { link: InlineLinkPreview }) {
|
||||
}
|
||||
|
||||
function useFaviconFallback(host: string) {
|
||||
const faviconCandidates = useMemo(() => faviconUrls(host), [host]);
|
||||
const faviconCandidates = useMemo(() => browserSafeFaviconUrls(host), [host]);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(faviconCandidates);
|
||||
|
||||
return {
|
||||
@@ -433,11 +464,14 @@ export default function MarkdownTextRenderer({
|
||||
children,
|
||||
className,
|
||||
highlightCode = true,
|
||||
streaming = false,
|
||||
onOpenFilePreview,
|
||||
}: MarkdownTextRendererProps) {
|
||||
const { t } = useTranslation();
|
||||
const components = useMemo<Components>(
|
||||
() => ({
|
||||
code({ className: cls, children: kids, ...props }) {
|
||||
code({ className: cls, children: kids, node: _node, ...props }) {
|
||||
void _node;
|
||||
const match = /language-(\w+)/.exec(cls || "");
|
||||
if (match) {
|
||||
const code = String(kids).replace(/\n$/, "");
|
||||
@@ -447,6 +481,7 @@ export default function MarkdownTextRenderer({
|
||||
code={code}
|
||||
className="my-3"
|
||||
highlight={highlightCode}
|
||||
showLineNumbers={code.includes("\n")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -502,6 +537,7 @@ export default function MarkdownTextRenderer({
|
||||
code={fence.code}
|
||||
className="my-3"
|
||||
highlight={highlightCode}
|
||||
showLineNumbers={fence.code.includes("\n")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -517,7 +553,14 @@ export default function MarkdownTextRenderer({
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
a({ href, children: markdownChildren, ...props }) {
|
||||
a({ href, children: markdownChildren, node: _node, ...props }) {
|
||||
void _node;
|
||||
if (!href) {
|
||||
return <>{markdownChildren}</>;
|
||||
}
|
||||
if (href === "streamdown:incomplete-link") {
|
||||
return <>{markdownChildren}</>;
|
||||
}
|
||||
const filePath = fileReferenceFromLink(href);
|
||||
if (filePath) {
|
||||
const label = nodeText(markdownChildren).trim();
|
||||
@@ -545,15 +588,49 @@ export default function MarkdownTextRenderer({
|
||||
</a>
|
||||
);
|
||||
},
|
||||
table({ children, ...props }) {
|
||||
// Wrap wide markdown tables in a horizontal-scroll container (the
|
||||
// pattern used by DeepSeek/others) so a 6+ column table scrolls inside
|
||||
// the conversation column instead of forcing the page wider than 100vw.
|
||||
// min-w-max keeps natural column widths; w-full stretches narrow tables.
|
||||
// Streamdown decorates emphasis with spans by default. Preserve native
|
||||
// semantics for accessibility and predictable typography.
|
||||
strong({ children: markdownChildren, node: _node, ...props }) {
|
||||
void _node;
|
||||
return <strong {...props}>{markdownChildren}</strong>;
|
||||
},
|
||||
em({ children: markdownChildren, node: _node, ...props }) {
|
||||
void _node;
|
||||
return <em {...props}>{markdownChildren}</em>;
|
||||
},
|
||||
del({ children: markdownChildren, node: _node, ...props }) {
|
||||
void _node;
|
||||
return <del {...props}>{markdownChildren}</del>;
|
||||
},
|
||||
table({ children: tableChildren, node: _node, ...props }) {
|
||||
void _node;
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<table className="w-full min-w-max" {...props}>
|
||||
{children}
|
||||
<div
|
||||
data-testid="markdown-data-table"
|
||||
data-table-kind="data"
|
||||
role="region"
|
||||
tabIndex={0}
|
||||
aria-label={t("message.dataTable", { defaultValue: "Data table" })}
|
||||
className={cn(
|
||||
"not-prose mb-5 mt-3 w-full max-w-full overflow-x-auto rounded-lg",
|
||||
"border border-border/65 bg-muted/20",
|
||||
"overscroll-x-contain focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
<table
|
||||
className={cn(
|
||||
"w-full min-w-max border-collapse text-[13px] leading-5",
|
||||
"[&_thead]:bg-muted/45 [&_thead]:text-muted-foreground",
|
||||
"[&_th]:border-b [&_th]:border-border/65 [&_th]:px-3 [&_th]:py-2",
|
||||
"[&_th]:text-left [&_th]:font-medium",
|
||||
"[&_td]:border-b [&_td]:border-border/55 [&_td]:px-3 [&_td]:py-2",
|
||||
"[&_th:not(:last-child)]:border-r [&_th:not(:last-child)]:border-border/45",
|
||||
"[&_td:not(:last-child)]:border-r [&_td:not(:last-child)]:border-border/45",
|
||||
"[&_tbody_tr:last-child_td]:border-b-0",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{tableChildren}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
@@ -567,8 +644,14 @@ export default function MarkdownTextRenderer({
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const taskItem = itemClassName?.includes("task-list-item");
|
||||
return (
|
||||
<li className={itemClassName}>
|
||||
<li
|
||||
className={cn(
|
||||
itemClassName,
|
||||
taskItem && "flex min-w-0 items-start gap-2 text-[13px] leading-5 [&>p]:m-0",
|
||||
)}
|
||||
>
|
||||
{markdownChildren}
|
||||
</li>
|
||||
);
|
||||
@@ -579,10 +662,11 @@ export default function MarkdownTextRenderer({
|
||||
<span
|
||||
aria-hidden
|
||||
data-testid="markdown-task-checkbox"
|
||||
data-task-checked={checked ? "true" : "false"}
|
||||
className={cn(
|
||||
"mr-2 inline-grid h-4 w-4 translate-y-[2px] place-items-center rounded-[4px]",
|
||||
"border border-border/70 bg-muted/55 text-background",
|
||||
checked && "border-foreground/55 bg-foreground/65",
|
||||
"mt-0.5 inline-grid h-4 w-4 shrink-0 place-items-center rounded-full",
|
||||
"border border-dashed border-muted-foreground/55 bg-background text-background",
|
||||
checked && "border-solid border-emerald-500 bg-emerald-500 text-white",
|
||||
)}
|
||||
>
|
||||
{checked ? <Check className="h-3 w-3 stroke-[3]" /> : null}
|
||||
@@ -636,11 +720,21 @@ export default function MarkdownTextRenderer({
|
||||
);
|
||||
},
|
||||
}),
|
||||
[highlightCode, onOpenFilePreview],
|
||||
[highlightCode, onOpenFilePreview, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
<Streamdown
|
||||
mode={streaming ? "streaming" : "static"}
|
||||
parseIncompleteMarkdown
|
||||
isAnimating={streaming}
|
||||
animated={streaming ? STREAMING_ANIMATION : false}
|
||||
caret={streaming ? "block" : undefined}
|
||||
linkSafety={DIRECT_LINKS}
|
||||
urlTransform={safeMarkdownUrl}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
className={cn(
|
||||
"markdown-content prose max-w-none dark:prose-invert",
|
||||
"prose-headings:mt-4 prose-headings:mb-2 prose-headings:font-semibold prose-headings:tracking-tight",
|
||||
@@ -653,18 +747,10 @@ export default function MarkdownTextRenderer({
|
||||
"prose-hr:my-6",
|
||||
"prose-pre:my-0 prose-pre:bg-transparent prose-pre:p-0",
|
||||
"prose-code:before:content-none prose-code:after:content-none prose-code:font-normal",
|
||||
"prose-table:my-3 prose-th:text-left prose-th:font-medium",
|
||||
className,
|
||||
)}
|
||||
style={{ lineHeight: "var(--cjk-line-height)" }}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={components}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
{children}
|
||||
</Streamdown>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,15 +12,15 @@ import {
|
||||
Clock3,
|
||||
Copy,
|
||||
ImageIcon,
|
||||
Sparkles,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AttachmentTile } from "@/components/AttachmentTile";
|
||||
import { ImageLightbox } from "@/components/ImageLightbox";
|
||||
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||
import { MarkdownText } from "@/components/MarkdownText";
|
||||
import { SlashCommandText } from "@/components/SlashCommandText";
|
||||
import { ReasoningRow } from "@/components/thread/activity/ReasoningRow";
|
||||
import { UserMessageText } from "@/components/UserMessageText";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -111,7 +111,7 @@ function MessageCopyButton({ content }: { content: string }) {
|
||||
onClick={onCopy}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
"touch-target inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
@@ -128,15 +128,7 @@ function MessageCopyButton({ content }: { content: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single message. Following agent-chat-ui: user turns are a rounded
|
||||
* "pill" right-aligned with a muted fill; assistant turns render as bare
|
||||
* markdown so prose/code read like a document rather than a chat bubble.
|
||||
* Each turn fades+slides in for a touch of motion polish.
|
||||
*
|
||||
* Trace rows (tool-call hints, progress breadcrumbs) render as a subdued
|
||||
* collapsible group so intermediate steps never masquerade as replies.
|
||||
*/
|
||||
/** Render user turns as compact bubbles and assistant turns as document-like prose. */
|
||||
export function MessageBubble({
|
||||
message,
|
||||
showCopyAction = true,
|
||||
@@ -250,11 +242,10 @@ export function MessageBubble({
|
||||
text={reasoning}
|
||||
streaming={reasoningStreaming}
|
||||
hasBodyBelow={!empty}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
) : null}
|
||||
{empty && message.isStreaming && !hasReasoning ? (
|
||||
<TypingDots />
|
||||
<ThinkingState />
|
||||
) : empty && message.isStreaming ? null : (
|
||||
<>
|
||||
{automationSourceLabel ? (
|
||||
@@ -263,12 +254,14 @@ export function MessageBubble({
|
||||
triggerLabel={automationTriggeredLabel}
|
||||
/>
|
||||
) : null}
|
||||
<MarkdownText
|
||||
streaming={!!message.isStreaming}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
>
|
||||
{message.content}
|
||||
</MarkdownText>
|
||||
<div data-assistant-selectable={message.isStreaming ? undefined : "true"}>
|
||||
<MarkdownText
|
||||
streaming={!!message.isStreaming}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
>
|
||||
{message.content}
|
||||
</MarkdownText>
|
||||
</div>
|
||||
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
|
||||
{showAssistantFooterRow ? (
|
||||
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
|
||||
@@ -284,7 +277,7 @@ export function MessageBubble({
|
||||
onClick={onForkFromHere}
|
||||
aria-label={forkLabel}
|
||||
className={cn(
|
||||
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
"touch-target inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
"transition-colors hover:bg-muted/55 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
@@ -433,10 +426,6 @@ function MessageMedia({
|
||||
/**
|
||||
* Right-aligned preview row for images attached to a user turn.
|
||||
*
|
||||
* Visual follows agent-chat-ui: a single wrapping row of fixed-size square
|
||||
* thumbnails that stay modest next to the text pill regardless of how many
|
||||
* images are attached.
|
||||
*
|
||||
* The URL is expected to be a self-contained ``data:`` URL (the Composer
|
||||
* hands the normalized base64 payload to the optimistic bubble so that the
|
||||
* preview survives React StrictMode double-mount — blob URLs would be
|
||||
@@ -570,33 +559,21 @@ function UserImageCell({
|
||||
);
|
||||
}
|
||||
|
||||
/** Pre-token-arrival placeholder: three bouncing dots. */
|
||||
function TypingDots() {
|
||||
/** Quiet pre-token state that occupies a stable line in the answer column. */
|
||||
function ThinkingState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
aria-label={t("message.assistantTyping")}
|
||||
className="inline-flex items-center gap-1 py-1"
|
||||
className="inline-flex min-h-7 items-center py-1 text-[13px]"
|
||||
>
|
||||
<Dot delay="0ms" />
|
||||
<Dot delay="150ms" />
|
||||
<Dot delay="300ms" />
|
||||
<StreamingLabelSheen active>
|
||||
{t("message.reasoningStreaming", { defaultValue: "Thinking…" })}
|
||||
</StreamingLabelSheen>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Dot({ delay }: { delay: string }) {
|
||||
return (
|
||||
<span
|
||||
style={{ animationDelay: delay }}
|
||||
className={cn(
|
||||
"inline-block h-1.5 w-1.5 rounded-full bg-muted-foreground/60",
|
||||
"animate-bounce",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** L→R sheen on the glyphs themselves; inactive labels stay solid muted text. */
|
||||
export function StreamingLabelSheen({
|
||||
children,
|
||||
@@ -630,105 +607,22 @@ interface ReasoningBubbleProps {
|
||||
text: string;
|
||||
streaming: boolean;
|
||||
hasBodyBelow: boolean;
|
||||
/** When true, skip the slide-in wrapper (used inside ``AgentActivityCluster``). */
|
||||
embeddedInCluster?: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subordinate "thinking" trace shown above an assistant turn.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - While ``streaming`` is true (``reasoning_delta`` frames still arriving),
|
||||
* the bubble defaults to open and the header shows a sheen + pulse so
|
||||
* the user sees the model "thinking out loud" in real time.
|
||||
* - Expanded reasoning uses the same Markdown pipeline as assistant replies
|
||||
* (deferred while streaming to reduce parser thrash), so headings and
|
||||
* emphasis render instead of leaking raw ``###`` / ``**``.
|
||||
* - On ``reasoning_end`` the bubble auto-collapses for prose density —
|
||||
* the user can re-expand to inspect the chain of thought. The local
|
||||
* toggle persists once the user interacts.
|
||||
*/
|
||||
export function ReasoningBubble({
|
||||
text,
|
||||
streaming,
|
||||
hasBodyBelow,
|
||||
embeddedInCluster = false,
|
||||
onOpenFilePreview,
|
||||
}: ReasoningBubbleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [userToggled, setUserToggled] = useState(false);
|
||||
const [openLocal, setOpenLocal] = useState(true);
|
||||
const open = userToggled ? openLocal : streaming;
|
||||
const onToggle = () => {
|
||||
setUserToggled(true);
|
||||
setOpenLocal((v) => (userToggled ? !v : !open));
|
||||
};
|
||||
useEffect(() => {
|
||||
if (open && text.length > 0) {
|
||||
preloadMarkdownText();
|
||||
}
|
||||
}, [open, text.length]);
|
||||
return (
|
||||
<div
|
||||
<ReasoningRow
|
||||
text={text}
|
||||
streaming={streaming}
|
||||
className={cn(
|
||||
"w-full",
|
||||
!embeddedInCluster && "animate-in fade-in-0 slide-in-from-top-1 duration-200",
|
||||
"animate-in fade-in-0 slide-in-from-top-1 duration-200",
|
||||
hasBodyBelow && "mb-2",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-2 rounded-md px-2 py-1.5",
|
||||
"text-xs text-muted-foreground transition-colors hover:bg-muted/45",
|
||||
)}
|
||||
aria-expanded={open}
|
||||
aria-live={streaming ? "polite" : undefined}
|
||||
>
|
||||
<Sparkles
|
||||
className={cn("h-3.5 w-3.5", streaming && "animate-pulse")}
|
||||
aria-hidden
|
||||
/>
|
||||
<StreamingLabelSheen active={streaming} className="min-w-0 flex-1 text-left">
|
||||
{streaming
|
||||
? t("message.reasoningStreaming", { defaultValue: "Thinking…" })
|
||||
: t("message.reasoning", { defaultValue: "Thinking" })}
|
||||
</StreamingLabelSheen>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"ml-auto h-3.5 w-3.5 transition-transform duration-200",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{open && text.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 min-w-0 border-l border-muted-foreground/20 pl-3",
|
||||
!embeddedInCluster && "animate-in fade-in-0 slide-in-from-top-1 duration-200",
|
||||
)}
|
||||
>
|
||||
<MarkdownText
|
||||
streaming={streaming}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
className={cn(
|
||||
"text-[12.5px] italic text-muted-foreground/88",
|
||||
"prose-p:my-1.5 prose-li:my-0.5",
|
||||
"prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium",
|
||||
"prose-headings:text-muted-foreground/92 prose-strong:text-muted-foreground",
|
||||
"prose-h1:text-[15px] prose-h2:text-[13.5px] prose-h3:text-[12.5px] prose-h4:text-[12px]",
|
||||
"prose-a:text-blue-500 prose-a:underline hover:prose-a:text-blue-600 dark:prose-a:text-blue-300 dark:hover:prose-a:text-blue-200",
|
||||
"prose-code:text-[0.92em]",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</MarkdownText>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ interface SidebarProps {
|
||||
onOpenApps: () => void;
|
||||
onOpenSkills: () => void;
|
||||
onOpenAutomations: () => void;
|
||||
onSettingsIntent?: () => void;
|
||||
onOpenSearch: () => void;
|
||||
activeUtility?: "apps" | "skills" | "automations" | null;
|
||||
onToggleArchived: () => void;
|
||||
@@ -156,6 +157,7 @@ export function Sidebar(props: SidebarProps) {
|
||||
collapsed={collapsed}
|
||||
label={t("sidebar.apps")}
|
||||
onClick={props.onOpenApps}
|
||||
onIntent={props.onSettingsIntent}
|
||||
active={props.activeUtility === "apps"}
|
||||
icon={<Blocks className="h-4 w-4" />}
|
||||
/>
|
||||
@@ -163,6 +165,7 @@ export function Sidebar(props: SidebarProps) {
|
||||
collapsed={collapsed}
|
||||
label={t("sidebar.skills.title")}
|
||||
onClick={props.onOpenSkills}
|
||||
onIntent={props.onSettingsIntent}
|
||||
active={props.activeUtility === "skills"}
|
||||
icon={<Brain className="h-4 w-4" />}
|
||||
/>
|
||||
@@ -170,6 +173,7 @@ export function Sidebar(props: SidebarProps) {
|
||||
collapsed={collapsed}
|
||||
label={t("sidebar.automations", { defaultValue: "Automations" })}
|
||||
onClick={props.onOpenAutomations}
|
||||
onIntent={props.onSettingsIntent}
|
||||
active={props.activeUtility === "automations"}
|
||||
icon={<CalendarClock className="h-4 w-4" />}
|
||||
/>
|
||||
@@ -231,6 +235,7 @@ export function Sidebar(props: SidebarProps) {
|
||||
collapsed={collapsed}
|
||||
label={t("sidebar.settings")}
|
||||
onClick={props.onOpenSettings}
|
||||
onIntent={props.onSettingsIntent}
|
||||
className={collapsed ? undefined : "flex-1"}
|
||||
icon={<Settings className="h-4 w-4" />}
|
||||
/>
|
||||
@@ -249,6 +254,7 @@ function SidebarActionButton({
|
||||
className,
|
||||
shortcut,
|
||||
ariaKeyShortcuts,
|
||||
onIntent,
|
||||
}: {
|
||||
collapsed: boolean;
|
||||
label: string;
|
||||
@@ -258,6 +264,7 @@ function SidebarActionButton({
|
||||
className?: string;
|
||||
shortcut?: string;
|
||||
ariaKeyShortcuts?: string;
|
||||
onIntent?: () => void;
|
||||
}) {
|
||||
const title = shortcut ? `${label} (${shortcut})` : collapsed ? label : undefined;
|
||||
|
||||
@@ -270,8 +277,10 @@ function SidebarActionButton({
|
||||
aria-keyshortcuts={ariaKeyShortcuts}
|
||||
title={title}
|
||||
onClick={() => onClick()}
|
||||
onFocus={onIntent}
|
||||
onPointerEnter={onIntent}
|
||||
className={cn(
|
||||
"group h-8 min-w-0 gap-2 overflow-hidden rounded-full font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
|
||||
"touch-target group h-8 min-w-0 gap-2 overflow-hidden rounded-full font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
|
||||
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out",
|
||||
collapsed
|
||||
? "w-9 justify-center gap-0 rounded-xl px-0"
|
||||
|
||||
@@ -143,7 +143,9 @@ import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events";
|
||||
import { fmtDateTime, relativeTime } from "@/lib/format";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
||||
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||
import {
|
||||
isGenericRepositoryLogoUrl,
|
||||
logoFallbackUrls,
|
||||
providerBrand,
|
||||
providerDisplayLabel,
|
||||
@@ -538,6 +540,7 @@ export function SettingsView({
|
||||
}: SettingsViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = useClient();
|
||||
const pageVisible = usePageVisibility();
|
||||
const [settings, setSettings] = useState<SettingsPayload | null>(() => initialSettings);
|
||||
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
|
||||
const [nanobotFeatures, setNanobotFeatures] = useState<NanobotFeaturesPayload | null>(null);
|
||||
@@ -584,7 +587,7 @@ export function SettingsView({
|
||||
const [cliAppsError, setCliAppsError] = useState<string | null>(null);
|
||||
const [nanobotFeaturesError, setNanobotFeaturesError] = useState<string | null>(null);
|
||||
const [cliAppsFocusName, setCliAppsFocusName] = useState<string | null>(null);
|
||||
const [appsKindFilter, setAppsKindFilter] = useState<AppsKindFilter>("ready");
|
||||
const [appsKindFilter, setAppsKindFilter] = useState<AppsKindFilter>("cli");
|
||||
const [mcpMessage, setMcpMessage] = useState<string | null>(null);
|
||||
const [mcpError, setMcpError] = useState<string | null>(null);
|
||||
const [automationsError, setAutomationsError] = useState<string | null>(null);
|
||||
@@ -685,7 +688,7 @@ export function SettingsView({
|
||||
|
||||
const hasSettings = settings !== null;
|
||||
useEffect(() => {
|
||||
if (activeSection !== "overview" || !hasSettings) return;
|
||||
if (activeSection !== "overview" || !hasSettings || !pageVisible) return;
|
||||
let cancelled = false;
|
||||
const refresh = () => {
|
||||
fetchSettingsUsage(token)
|
||||
@@ -698,18 +701,13 @@ export function SettingsView({
|
||||
void refresh();
|
||||
const interval = window.setInterval(refresh, 5000);
|
||||
const onFocus = () => refresh();
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") refresh();
|
||||
};
|
||||
window.addEventListener("focus", onFocus);
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
}, [activeSection, hasSettings, token]);
|
||||
}, [activeSection, hasSettings, pageVisible, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection !== "apps") return;
|
||||
@@ -844,7 +842,7 @@ export function SettingsView({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection !== "automations") return;
|
||||
if (activeSection !== "automations" || !pageVisible) return;
|
||||
let cancelled = false;
|
||||
const refresh = async (showLoading = false) => {
|
||||
if (cancelled) return;
|
||||
@@ -862,18 +860,14 @@ export function SettingsView({
|
||||
};
|
||||
void refresh(true);
|
||||
const interval = window.setInterval(() => void refresh(false), 5000);
|
||||
const refreshOnFocus = () => {
|
||||
if (document.visibilityState !== "hidden") void refresh(false);
|
||||
};
|
||||
const refreshOnFocus = () => void refresh(false);
|
||||
window.addEventListener("focus", refreshOnFocus);
|
||||
document.addEventListener("visibilitychange", refreshOnFocus);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener("focus", refreshOnFocus);
|
||||
document.removeEventListener("visibilitychange", refreshOnFocus);
|
||||
};
|
||||
}, [activeSection, token]);
|
||||
}, [activeSection, pageVisible, token]);
|
||||
|
||||
useEffect(() => {
|
||||
writeLocalPreferences(localPrefs);
|
||||
@@ -1969,7 +1963,7 @@ export function SettingsView({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackToChat}
|
||||
className="mb-4 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:hidden"
|
||||
className="touch-target mb-4 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:hidden"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("settings.backToChat")}
|
||||
@@ -2052,6 +2046,24 @@ function SettingsSidebar({
|
||||
hostChromeInset?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navRef = useRef<HTMLElement>(null);
|
||||
const activeItemRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nav = navRef.current;
|
||||
const activeItem = activeItemRef.current;
|
||||
if (!nav || !activeItem || nav.scrollWidth <= nav.clientWidth) return;
|
||||
const navRect = nav.getBoundingClientRect();
|
||||
const itemRect = activeItem.getBoundingClientRect();
|
||||
const itemCenter = itemRect.left - navRect.left + nav.scrollLeft + itemRect.width / 2;
|
||||
const targetLeft = Math.max(
|
||||
0,
|
||||
Math.min(nav.scrollWidth - nav.clientWidth, itemCenter - nav.clientWidth / 2),
|
||||
);
|
||||
const reducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
||||
nav.scrollTo({ left: targetLeft, behavior: reducedMotion ? "auto" : "smooth" });
|
||||
}, [activeSection]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
@@ -2062,7 +2074,7 @@ function SettingsSidebar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackToChat}
|
||||
className="mb-2 inline-flex w-fit items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:mb-3"
|
||||
className="touch-target mb-2 inline-flex w-fit items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:mb-3"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("settings.backToChat")}
|
||||
@@ -2074,19 +2086,21 @@ function SettingsSidebar({
|
||||
</div>
|
||||
|
||||
<nav
|
||||
ref={navRef}
|
||||
aria-label={t("settings.sidebar.ariaLabel")}
|
||||
className="-mx-1 flex snap-x gap-2 overflow-x-auto px-1 pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden lg:mx-0 lg:block lg:space-y-1 lg:overflow-visible lg:px-0 lg:pb-0"
|
||||
className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden lg:mx-0 lg:block lg:space-y-1 lg:overflow-visible lg:px-0 lg:pb-0"
|
||||
>
|
||||
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
|
||||
const active = key === activeSection;
|
||||
return (
|
||||
<button
|
||||
ref={active ? activeItemRef : undefined}
|
||||
key={key}
|
||||
type="button"
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={() => onSelectSection(key)}
|
||||
className={cn(
|
||||
"flex h-9 w-auto shrink-0 snap-start items-center gap-2 rounded-full px-3 text-left text-[13px] font-medium transition-colors lg:w-full lg:rounded-[10px] lg:px-2.5",
|
||||
"touch-target flex h-9 w-auto shrink-0 items-center gap-2 rounded-full px-3 text-left text-[13px] font-medium transition-colors lg:w-full lg:rounded-[10px] lg:px-2.5",
|
||||
active
|
||||
? "bg-sidebar-accent text-foreground"
|
||||
: "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground",
|
||||
@@ -5833,7 +5847,7 @@ function CliAppsCatalogRow({
|
||||
const description = app.description || app.requires || app.entry_point || app.name;
|
||||
|
||||
return (
|
||||
<article className="group flex min-w-0 items-center gap-3 rounded-[14px] px-3 py-3 transition-colors hover:bg-muted/45">
|
||||
<article className="apps-catalog-row group flex min-w-0 items-center gap-3 rounded-[14px] px-3 py-3 transition-colors hover:bg-muted/45">
|
||||
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
@@ -6624,9 +6638,11 @@ function CliAppReadyPanel({
|
||||
}
|
||||
|
||||
function CliAppLogo({ app, showBrandLogos }: { app: CliAppInfo; showBrandLogos: boolean }) {
|
||||
const bg = app.brand_color || "hsl(var(--muted))";
|
||||
const logoUrls = useMemo(() => logoFallbackUrls(app.logo_url), [app.logo_url]);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
const logoUrls = useMemo(
|
||||
() => (isGenericRepositoryLogoUrl(app.logo_url) ? [] : logoFallbackUrls(app.logo_url)),
|
||||
[app.logo_url],
|
||||
);
|
||||
const { logoUrl, logoLoaded, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
||||
const initials = app.display_name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
@@ -6634,30 +6650,41 @@ function CliAppLogo({ app, showBrandLogos }: { app: CliAppInfo; showBrandLogos:
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join("") || app.name.slice(0, 2).toUpperCase();
|
||||
|
||||
if (showBrandLogos && logoUrl) {
|
||||
return (
|
||||
const showRemoteLogo = showBrandLogos && Boolean(logoUrl);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="relative grid h-11 w-11 shrink-0 place-items-center overflow-hidden rounded-[8px] border border-border/45 bg-muted text-[13px] font-semibold"
|
||||
style={{
|
||||
color: app.brand_color || "hsl(var(--muted-foreground))",
|
||||
boxShadow: `inset 0 0 0 1px ${app.brand_color ?? "transparent"}18`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] border border-border/45 bg-background"
|
||||
style={{ boxShadow: `inset 0 0 0 1px ${app.brand_color ?? "transparent"}22` }}
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"transition-opacity duration-150 motion-reduce:transition-none",
|
||||
showRemoteLogo && logoLoaded ? "opacity-0" : "opacity-100",
|
||||
)}
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
{showRemoteLogo ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
className="h-6 w-6 object-contain"
|
||||
referrerPolicy="no-referrer"
|
||||
draggable={false}
|
||||
className={cn(
|
||||
"absolute h-6 w-6 object-contain transition-opacity duration-150 motion-reduce:transition-none",
|
||||
logoLoaded ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] text-[13px] font-semibold text-white"
|
||||
style={{ backgroundColor: bg }}
|
||||
>
|
||||
{initials}
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -8494,6 +8521,7 @@ function SegmentedControl({
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={value === option.value}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 transition-colors",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Check, Loader2, Network, RotateCcw } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||
import {
|
||||
cancelChannelConnect,
|
||||
pollChannelConnect,
|
||||
@@ -52,6 +53,7 @@ export function ChannelQrConnectFlow({
|
||||
labels: ChannelQrConnectLabels;
|
||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||
}) {
|
||||
const pageVisible = usePageVisibility();
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const [connect, setConnect] = useState<ChannelConnectPayload | null>(null);
|
||||
@@ -92,7 +94,7 @@ export function ChannelQrConnectFlow({
|
||||
}, [connect?.qr_url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connect?.session_id || connect.status !== "pending") return;
|
||||
if (!connect?.session_id || connect.status !== "pending" || !pageVisible) return;
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
if (pollInFlight.current) return;
|
||||
@@ -127,7 +129,7 @@ export function ChannelQrConnectFlow({
|
||||
window.clearTimeout(initial);
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [channelName, connect?.interval_ms, connect?.session_id, connect?.status, onFeaturesUpdate, token]);
|
||||
}, [channelName, connect?.interval_ms, connect?.session_id, connect?.status, onFeaturesUpdate, pageVisible, token]);
|
||||
|
||||
const start = useCallback(async (force = false) => {
|
||||
setBusy(true);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { MessageCircleMore } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const MAX_QUOTED_CONTEXT_CHARS = 4_000;
|
||||
|
||||
interface SelectionActionState {
|
||||
text: string;
|
||||
left: number;
|
||||
top: number;
|
||||
above: boolean;
|
||||
}
|
||||
|
||||
interface AssistantSelectionActionProps {
|
||||
containerRef: RefObject<HTMLElement | null>;
|
||||
onQuoteSelection?: (text: string) => void;
|
||||
}
|
||||
|
||||
function selectableAncestor(node: Node | null, container: HTMLElement): HTMLElement | null {
|
||||
const element = node instanceof Element ? node : node?.parentElement;
|
||||
const selectable = element?.closest<HTMLElement>("[data-assistant-selectable='true']") ?? null;
|
||||
return selectable && container.contains(selectable) ? selectable : null;
|
||||
}
|
||||
|
||||
function selectedRangeRect(range: Range): DOMRect | null {
|
||||
const rect = range.getBoundingClientRect();
|
||||
if (rect.width > 0 || rect.height > 0) return rect;
|
||||
return range.getClientRects()[0] ?? null;
|
||||
}
|
||||
|
||||
function normalizedSelectionText(selection: Selection): string {
|
||||
return selection
|
||||
.toString()
|
||||
.replace(/\u00a0/g, " ")
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.trim()
|
||||
.slice(0, MAX_QUOTED_CONTEXT_CHARS);
|
||||
}
|
||||
|
||||
export function AssistantSelectionAction({
|
||||
containerRef,
|
||||
onQuoteSelection,
|
||||
}: AssistantSelectionActionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [action, setAction] = useState<SelectionActionState | null>(null);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const actionRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = actionRef.current;
|
||||
if (!action || !element) return;
|
||||
const viewport = window.visualViewport;
|
||||
const viewportLeft = viewport?.offsetLeft ?? 0;
|
||||
const viewportTop = viewport?.offsetTop ?? 0;
|
||||
const viewportRight = viewportLeft + (viewport?.width ?? window.innerWidth);
|
||||
const viewportBottom = viewportTop + (viewport?.height ?? window.innerHeight);
|
||||
const rect = element.getBoundingClientRect();
|
||||
const padding = 12;
|
||||
const shiftX = rect.left < viewportLeft + padding
|
||||
? viewportLeft + padding - rect.left
|
||||
: rect.right > viewportRight - padding
|
||||
? viewportRight - padding - rect.right
|
||||
: 0;
|
||||
const shiftY = rect.top < viewportTop + padding
|
||||
? viewportTop + padding - rect.top
|
||||
: rect.bottom > viewportBottom - padding
|
||||
? viewportBottom - padding - rect.bottom
|
||||
: 0;
|
||||
element.style.translate = `${shiftX}px ${shiftY}px`;
|
||||
}, [action]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onQuoteSelection) return;
|
||||
|
||||
const updateFromSelection = () => {
|
||||
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = null;
|
||||
const container = containerRef.current;
|
||||
const selection = window.getSelection();
|
||||
if (!container || !selection || selection.isCollapsed || selection.rangeCount === 0) {
|
||||
setAction(null);
|
||||
return;
|
||||
}
|
||||
const range = selection.getRangeAt(0);
|
||||
const start = selectableAncestor(range.startContainer, container);
|
||||
const end = selectableAncestor(range.endContainer, container);
|
||||
const text = normalizedSelectionText(selection);
|
||||
const rect = selectedRangeRect(range);
|
||||
if (!start || start !== end || !text || !rect) {
|
||||
setAction(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const viewport = window.visualViewport;
|
||||
const viewportTop = viewport?.offsetTop ?? 0;
|
||||
const viewportBottom = viewportTop + (viewport?.height ?? window.innerHeight);
|
||||
const above = rect.bottom + 52 > viewportBottom;
|
||||
setAction({
|
||||
text,
|
||||
left: rect.left + rect.width / 2,
|
||||
top: above ? rect.top - 8 : rect.bottom + 8,
|
||||
above,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const dismiss = () => setAction(null);
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (target instanceof Element && target.closest("[data-selection-follow-up='true']")) return;
|
||||
dismiss();
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") dismiss();
|
||||
};
|
||||
|
||||
document.addEventListener("selectionchange", updateFromSelection);
|
||||
document.addEventListener("pointerdown", onPointerDown, true);
|
||||
document.addEventListener("scroll", dismiss, true);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("resize", dismiss);
|
||||
window.visualViewport?.addEventListener("resize", dismiss);
|
||||
window.visualViewport?.addEventListener("scroll", dismiss);
|
||||
return () => {
|
||||
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
|
||||
document.removeEventListener("selectionchange", updateFromSelection);
|
||||
document.removeEventListener("pointerdown", onPointerDown, true);
|
||||
document.removeEventListener("scroll", dismiss, true);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("resize", dismiss);
|
||||
window.visualViewport?.removeEventListener("resize", dismiss);
|
||||
window.visualViewport?.removeEventListener("scroll", dismiss);
|
||||
};
|
||||
}, [containerRef, onQuoteSelection]);
|
||||
|
||||
if (!action || typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
<button
|
||||
ref={actionRef}
|
||||
type="button"
|
||||
data-selection-follow-up="true"
|
||||
className="fixed z-[80] inline-flex h-9 max-w-[calc(100vw-24px)] items-center gap-1.5 rounded-full border border-border/80 bg-popover px-3 text-[13px] font-medium text-popover-foreground shadow-lg shadow-black/10 transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:shadow-black/35"
|
||||
style={{
|
||||
left: action.left,
|
||||
top: action.top,
|
||||
transform: action.above ? "translate(-50%, -100%)" : "translateX(-50%)",
|
||||
}}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
onQuoteSelection?.(action.text);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
setAction(null);
|
||||
}}
|
||||
>
|
||||
<MessageCircleMore className="h-3.5 w-3.5" aria-hidden />
|
||||
<span className="truncate">{t("message.askAboutSelection")}</span>
|
||||
</button>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export function PromptRail({
|
||||
scrollRef,
|
||||
}: PromptRailProps) {
|
||||
const railRef = useRef<HTMLDivElement>(null);
|
||||
const measuredPromptsRef = useRef<MeasuredPrompt[]>([]);
|
||||
const promptAnchors = useMemo(() => userPromptAnchors(messages), [messages]);
|
||||
const [markers, setMarkers] = useState<PromptMarker[]>([]);
|
||||
const [activePromptId, setActivePromptId] = useState<string | null>(null);
|
||||
@@ -59,6 +60,7 @@ export function PromptRail({
|
||||
const nextRailHeight = railRef.current?.clientHeight ?? 0;
|
||||
|
||||
if (!scrollEl || promptAnchors.length < MIN_PROMPTS_FOR_RAIL) {
|
||||
measuredPromptsRef.current = [];
|
||||
setMarkers([]);
|
||||
setActivePromptId(null);
|
||||
return;
|
||||
@@ -66,17 +68,26 @@ export function PromptRail({
|
||||
|
||||
const scrollRange = scrollEl.scrollHeight - scrollEl.clientHeight;
|
||||
if (scrollRange < RAIL_MIN_SCROLL_RANGE_PX) {
|
||||
measuredPromptsRef.current = [];
|
||||
setMarkers([]);
|
||||
setActivePromptId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const measured = measurePrompts(scrollEl, promptAnchors, scrollRange);
|
||||
measuredPromptsRef.current = measured;
|
||||
const grouped = groupPromptMarkers(measured, nextRailHeight);
|
||||
setMarkers(distributeMarkerPositions(grouped, nextRailHeight));
|
||||
setActivePromptId(activePromptForScroll(measured, scrollEl.scrollTop));
|
||||
}, [promptAnchors, scrollRef]);
|
||||
|
||||
const updateActivePrompt = useCallback(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl) return;
|
||||
const next = activePromptForScroll(measuredPromptsRef.current, scrollEl.scrollTop);
|
||||
setActivePromptId((current) => current === next ? current : next);
|
||||
}, [scrollRef]);
|
||||
|
||||
useEffect(() => {
|
||||
let frame = 0;
|
||||
let remainingFrames = MEASURE_RETRY_FRAMES;
|
||||
@@ -95,20 +106,26 @@ export function PromptRail({
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl) return undefined;
|
||||
|
||||
let frame = 0;
|
||||
const schedule = () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
frame = window.requestAnimationFrame(updateMarkers);
|
||||
let scrollFrame = 0;
|
||||
let resizeFrame = 0;
|
||||
const scheduleActivePrompt = () => {
|
||||
window.cancelAnimationFrame(scrollFrame);
|
||||
scrollFrame = window.requestAnimationFrame(updateActivePrompt);
|
||||
};
|
||||
const scheduleMeasurement = () => {
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
resizeFrame = window.requestAnimationFrame(updateMarkers);
|
||||
};
|
||||
|
||||
scrollEl.addEventListener("scroll", schedule, { passive: true });
|
||||
window.addEventListener("resize", schedule);
|
||||
scrollEl.addEventListener("scroll", scheduleActivePrompt, { passive: true });
|
||||
window.addEventListener("resize", scheduleMeasurement);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
scrollEl.removeEventListener("scroll", schedule);
|
||||
window.removeEventListener("resize", schedule);
|
||||
window.cancelAnimationFrame(scrollFrame);
|
||||
window.cancelAnimationFrame(resizeFrame);
|
||||
scrollEl.removeEventListener("scroll", scheduleActivePrompt);
|
||||
window.removeEventListener("resize", scheduleMeasurement);
|
||||
};
|
||||
}, [scrollRef, updateMarkers]);
|
||||
}, [scrollRef, updateActivePrompt, updateMarkers]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
@@ -309,16 +326,20 @@ function activePromptForScroll(
|
||||
scrollTop: number,
|
||||
): string | null {
|
||||
if (measured.length === 0) return null;
|
||||
let active = measured[0];
|
||||
const cursor = scrollTop + 96;
|
||||
for (const prompt of measured) {
|
||||
if (prompt.top <= cursor) {
|
||||
active = prompt;
|
||||
continue;
|
||||
let lower = 0;
|
||||
let upper = measured.length - 1;
|
||||
let activeIndex = 0;
|
||||
while (lower <= upper) {
|
||||
const middle = Math.floor((lower + upper) / 2);
|
||||
if (measured[middle].top <= cursor) {
|
||||
activeIndex = middle;
|
||||
lower = middle + 1;
|
||||
} else {
|
||||
upper = middle - 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return active.id;
|
||||
return measured[activeIndex].id;
|
||||
}
|
||||
|
||||
function groupedPromptLabel(count: number, latestLabel: string): string {
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
Loader2,
|
||||
Mic,
|
||||
Plus,
|
||||
Quote,
|
||||
RotateCw,
|
||||
Shield,
|
||||
Sparkles,
|
||||
@@ -71,6 +72,7 @@ import {
|
||||
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream";
|
||||
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
|
||||
import type {
|
||||
CliAppInfo,
|
||||
@@ -189,6 +191,9 @@ interface ThreadComposerProps {
|
||||
pendingQueueKey?: string | null;
|
||||
transcriptionProvider?: string | null;
|
||||
ingressLimits?: WebUIIngressLimits | null;
|
||||
quotedContext?: string | null;
|
||||
focusRequest?: number;
|
||||
onQuotedContextChange?: (text: string | null) => void;
|
||||
}
|
||||
|
||||
const COMMAND_ICONS: Record<string, LucideIcon> = {
|
||||
@@ -265,6 +270,7 @@ interface QueuedPrompt {
|
||||
id: string;
|
||||
text: string;
|
||||
images?: QueuedPromptImage[];
|
||||
quotedContext?: string;
|
||||
}
|
||||
|
||||
interface QueuedPromptImage {
|
||||
@@ -355,11 +361,19 @@ function normalizeQueuedPrompt(item: unknown, index: number): QueuedPrompt | nul
|
||||
}];
|
||||
}).slice(0, MAX_ATTACHMENTS_PER_MESSAGE)
|
||||
: [];
|
||||
const quotedContext = typeof record.quotedContext === "string"
|
||||
? record.quotedContext.trim().slice(0, QUEUED_PROMPT_MAX_CHARS)
|
||||
: "";
|
||||
if (!text && images.length === 0) return null;
|
||||
const id = typeof record.id === "string" && record.id.trim()
|
||||
? record.id
|
||||
: `queued-prompt-restored-${index}`;
|
||||
return { id, text, ...(images.length > 0 ? { images } : {}) };
|
||||
return {
|
||||
id,
|
||||
text,
|
||||
...(images.length > 0 ? { images } : {}),
|
||||
...(quotedContext ? { quotedContext } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readQueuedPrompts(storageKey: string): QueuedPrompt[] {
|
||||
@@ -391,6 +405,7 @@ function storeQueuedPrompts(storageKey: string, prompts: QueuedPrompt[]): void {
|
||||
id: prompt.id,
|
||||
text: prompt.text.slice(0, QUEUED_PROMPT_MAX_CHARS),
|
||||
...(prompt.images?.length ? { images: prompt.images.slice(0, MAX_ATTACHMENTS_PER_MESSAGE) } : {}),
|
||||
...(prompt.quotedContext ? { quotedContext: prompt.quotedContext } : {}),
|
||||
})),
|
||||
),
|
||||
);
|
||||
@@ -555,6 +570,7 @@ function RunElapsedStrip({
|
||||
goalState?: GoalStateWsPayload;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pageVisible = usePageVisibility();
|
||||
const [goalPanelOpen, setGoalPanelOpen] = useState(false);
|
||||
const showTimer = startedAt != null;
|
||||
const stripLabel = goalStateStripPreview(goalState, t);
|
||||
@@ -594,10 +610,11 @@ function RunElapsedStrip({
|
||||
}, [active, renderStrip]);
|
||||
|
||||
useEffect(() => {
|
||||
if (startedAt == null) return;
|
||||
if (startedAt == null || !pageVisible) return;
|
||||
setTick((n) => n + 1);
|
||||
const id = window.setInterval(() => setTick((n) => n + 1), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [startedAt]);
|
||||
}, [pageVisible, startedAt]);
|
||||
|
||||
const display = active
|
||||
? { startedAt, goalState, stripLabel }
|
||||
@@ -629,7 +646,7 @@ function RunElapsedStrip({
|
||||
|
||||
relayout();
|
||||
|
||||
preloadMarkdownText();
|
||||
void preloadMarkdownText();
|
||||
const ro =
|
||||
typeof ResizeObserver !== "undefined"
|
||||
? new ResizeObserver(() => relayout())
|
||||
@@ -817,6 +834,9 @@ export function ThreadComposer({
|
||||
pendingQueueKey = null,
|
||||
transcriptionProvider = null,
|
||||
ingressLimits = null,
|
||||
quotedContext = null,
|
||||
focusRequest = 0,
|
||||
onQuotedContextChange,
|
||||
}: ThreadComposerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState("");
|
||||
@@ -938,6 +958,14 @@ export function ThreadComposer({
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [disabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusRequest || disabled) return;
|
||||
const id = requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [disabled, focusRequest]);
|
||||
|
||||
const normalizedQuotedContext = quotedContext?.trim().slice(0, QUEUED_PROMPT_MAX_CHARS) || null;
|
||||
|
||||
const readyImages = useMemo(
|
||||
() => images.filter((img): img is AttachedImage & { dataUrl: string } =>
|
||||
img.status === "ready" && typeof img.dataUrl === "string",
|
||||
@@ -1450,11 +1478,23 @@ export function ThreadComposer({
|
||||
id,
|
||||
text,
|
||||
...(queuedImages.length > 0 ? { images: queuedImages } : {}),
|
||||
...(normalizedQuotedContext ? { quotedContext: normalizedQuotedContext } : {}),
|
||||
},
|
||||
]);
|
||||
clear();
|
||||
clearComposerText();
|
||||
}, [canQueueGuidance, clear, clearComposerText, maxTextBytes, readyImages, textTooLargeMessage, value]);
|
||||
onQuotedContextChange?.(null);
|
||||
}, [
|
||||
canQueueGuidance,
|
||||
clear,
|
||||
clearComposerText,
|
||||
maxTextBytes,
|
||||
normalizedQuotedContext,
|
||||
onQuotedContextChange,
|
||||
readyImages,
|
||||
textTooLargeMessage,
|
||||
value,
|
||||
]);
|
||||
|
||||
const removeQueuedPrompt = useCallback((id: string) => {
|
||||
secondEnterPromptIdRef.current = null;
|
||||
@@ -1470,6 +1510,7 @@ export function ThreadComposer({
|
||||
setSlashMenuDismissed(false);
|
||||
setCliAppMenuDismissed(false);
|
||||
setCursorPosition(prompt.text.length);
|
||||
onQuotedContextChange?.(prompt.quotedContext ?? null);
|
||||
if (prompt.images?.length) {
|
||||
restoreReadyImages(prompt.images as RestoredReadyImage[]);
|
||||
} else {
|
||||
@@ -1482,7 +1523,7 @@ export function ThreadComposer({
|
||||
el.focus();
|
||||
el.setSelectionRange(prompt.text.length, prompt.text.length);
|
||||
});
|
||||
}, [clear, resizeTextarea, restoreReadyImages]);
|
||||
}, [clear, onQuotedContextChange, resizeTextarea, restoreReadyImages]);
|
||||
|
||||
const moveQueuedPrompt = useCallback((dragId: string, targetId: string) => {
|
||||
if (dragId === targetId) return;
|
||||
@@ -1505,12 +1546,17 @@ export function ThreadComposer({
|
||||
const queuedImages = queuedImagesToSendImages(prompt.images);
|
||||
setQueuedPrompts((items) => items.filter((item) => item.id !== prompt.id));
|
||||
if (text || queuedImages?.length) {
|
||||
if (queuedImages?.length) onSend(text, queuedImages);
|
||||
else onSend(text);
|
||||
const options: SendOptions | undefined = prompt.quotedContext || isStreaming
|
||||
? {
|
||||
...(prompt.quotedContext ? { quotedContext: prompt.quotedContext } : {}),
|
||||
...(isStreaming ? { continueActiveTurn: true } : {}),
|
||||
}
|
||||
: undefined;
|
||||
onSend(text, queuedImages, options);
|
||||
}
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
},
|
||||
[onSend],
|
||||
[isStreaming, onSend],
|
||||
);
|
||||
|
||||
const sendNextQueuedPrompt = useCallback(() => {
|
||||
@@ -1522,7 +1568,12 @@ export function ThreadComposer({
|
||||
}
|
||||
setQueuedPrompts((items) => items.filter((item) => item.id !== nextPrompt.id));
|
||||
const queuedImages = queuedImagesToSendImages(nextPrompt.images);
|
||||
if (queuedImages?.length) onSend(nextPrompt.text.trim(), queuedImages);
|
||||
const options = nextPrompt.quotedContext
|
||||
? { quotedContext: nextPrompt.quotedContext }
|
||||
: undefined;
|
||||
if (queuedImages?.length && options) onSend(nextPrompt.text.trim(), queuedImages, options);
|
||||
else if (queuedImages?.length) onSend(nextPrompt.text.trim(), queuedImages);
|
||||
else if (options) onSend(nextPrompt.text.trim(), undefined, options);
|
||||
else onSend(nextPrompt.text.trim());
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}, [onSend, queuedPrompts]);
|
||||
@@ -1576,10 +1627,11 @@ export function ThreadComposer({
|
||||
const attachedCliApps = activeCliMentionApps.map(cliAppMentionPayload);
|
||||
const attachedMcpPresets = activeMcpPresetMentions.map(mcpPresetMentionPayload);
|
||||
const options: SendOptions | undefined =
|
||||
attachedCliApps.length > 0 || attachedMcpPresets.length > 0
|
||||
attachedCliApps.length > 0 || attachedMcpPresets.length > 0 || normalizedQuotedContext
|
||||
? {
|
||||
...(attachedCliApps.length > 0 ? { cliApps: attachedCliApps } : {}),
|
||||
...(attachedMcpPresets.length > 0 ? { mcpPresets: attachedMcpPresets } : {}),
|
||||
...(normalizedQuotedContext ? { quotedContext: normalizedQuotedContext } : {}),
|
||||
}
|
||||
: undefined;
|
||||
const hasPlainTextCommandPayload =
|
||||
@@ -1598,6 +1650,7 @@ export function ThreadComposer({
|
||||
setQueuedPrompts([]);
|
||||
clear();
|
||||
clearComposerText();
|
||||
onQuotedContextChange?.(null);
|
||||
return;
|
||||
}
|
||||
const isSlashSideChannel = isSideChannelLifecycle(slashLifecycle);
|
||||
@@ -1619,6 +1672,7 @@ export function ThreadComposer({
|
||||
// preview here without affecting the rendered message.
|
||||
clear();
|
||||
clearComposerText();
|
||||
onQuotedContextChange?.(null);
|
||||
}, [
|
||||
activeCliMentionApps,
|
||||
activeMcpPresetMentions,
|
||||
@@ -1632,6 +1686,8 @@ export function ThreadComposer({
|
||||
onModelBadgeClick,
|
||||
onSend,
|
||||
onStop,
|
||||
onQuotedContextChange,
|
||||
normalizedQuotedContext,
|
||||
readyImages,
|
||||
slashCommands,
|
||||
textTooLargeMessage,
|
||||
@@ -1884,6 +1940,28 @@ export function ThreadComposer({
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{normalizedQuotedContext ? (
|
||||
<div
|
||||
className="mx-3 mt-3 flex min-w-0 items-start gap-2 border-l-2 border-muted-foreground/25 pl-3 pr-1 text-muted-foreground"
|
||||
aria-label={t("thread.composer.quotedContext")}
|
||||
>
|
||||
<Quote className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<p className="line-clamp-2 min-w-0 flex-1 text-[13px]/[1.45]">
|
||||
{normalizedQuotedContext}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="touch-target -mr-1 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors hover:bg-muted/70 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={t("thread.composer.removeQuotedContext")}
|
||||
onClick={() => {
|
||||
onQuotedContextChange?.(null);
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
|
||||
<div className="relative">
|
||||
{hasMentionDecorations ? (
|
||||
@@ -1962,7 +2040,7 @@ export function ThreadComposer({
|
||||
aria-label={t("thread.composer.attachImage")}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
"rounded-full text-muted-foreground hover:text-foreground",
|
||||
"touch-target rounded-full text-muted-foreground hover:text-foreground",
|
||||
isHero
|
||||
? "h-8 w-8 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card"
|
||||
: "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
|
||||
@@ -2016,7 +2094,7 @@ export function ThreadComposer({
|
||||
onPointerCancel={voiceRecorder.endPress}
|
||||
onClick={voiceRecorder.handleClick}
|
||||
className={cn(
|
||||
"rounded-full border border-transparent text-muted-foreground hover:bg-muted/65 hover:text-foreground",
|
||||
"touch-target rounded-full border border-transparent text-muted-foreground hover:bg-muted/65 hover:text-foreground",
|
||||
isHero ? "h-8 w-8" : "h-9 w-9",
|
||||
voiceRecorder.isRecording &&
|
||||
"bg-red-500 text-white shadow-[0_8px_20px_rgba(239,68,68,0.22)] hover:bg-red-500 hover:text-white",
|
||||
@@ -2059,7 +2137,7 @@ export function ThreadComposer({
|
||||
}
|
||||
onClick={showStopButton ? handleStop : modelNeedsSetup ? onModelBadgeClick : undefined}
|
||||
className={cn(
|
||||
"rounded-full transition-transform",
|
||||
"touch-target rounded-full transition-transform",
|
||||
showStopButton
|
||||
? "border border-border/70 bg-card text-foreground/85 shadow-[0_3px_10px_rgba(15,23,42,0.08)] hover:bg-muted/65 hover:text-foreground disabled:text-muted-foreground/50"
|
||||
: isHero
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { memo, useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MessageBubble } from "@/components/MessageBubble";
|
||||
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
|
||||
import { AssistantSelectionAction } from "@/components/thread/AssistantSelectionAction";
|
||||
import { normalizeActivityTimeline, type TurnUnit } from "@/lib/activity-timeline";
|
||||
import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/types";
|
||||
|
||||
@@ -16,6 +17,7 @@ interface ThreadMessagesProps {
|
||||
forkBoundaryMessageCount?: number | null;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
onForkFromMessage?: (beforeUserIndex: number) => void;
|
||||
onQuoteSelection?: (text: string) => void;
|
||||
}
|
||||
|
||||
export type DisplayUnit = TurnUnit;
|
||||
@@ -56,8 +58,10 @@ export function ThreadMessages({
|
||||
forkBoundaryMessageCount = null,
|
||||
onOpenFilePreview,
|
||||
onForkFromMessage,
|
||||
onQuoteSelection,
|
||||
}: ThreadMessagesProps) {
|
||||
const { t } = useTranslation();
|
||||
const messageListRef = useRef<HTMLDivElement>(null);
|
||||
const units = useMemo(() => buildDisplayUnits(messages, isStreaming), [isStreaming, messages]);
|
||||
const forkBoundaryAfterUnitIndex = useMemo(
|
||||
() => unitIndexAfterMessageCount(units, forkBoundaryMessageCount),
|
||||
@@ -72,7 +76,11 @@ export function ThreadMessages({
|
||||
let nextUserIndex = hiddenUserMessageCount;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
<div ref={messageListRef} className="flex w-full flex-col">
|
||||
<AssistantSelectionAction
|
||||
containerRef={messageListRef}
|
||||
onQuoteSelection={onQuoteSelection}
|
||||
/>
|
||||
{units.map((unit, index) => {
|
||||
const prev = units[index - 1];
|
||||
const marginTop =
|
||||
@@ -96,44 +104,143 @@ export function ThreadMessages({
|
||||
if (unit.type === "message" && unit.message.role === "user") nextUserIndex += 1;
|
||||
|
||||
return (
|
||||
<Fragment key={unitKeys[index]}>
|
||||
<div className={marginTop} data-user-prompt-id={userPromptId}>
|
||||
{unit.type === "activity" ? (
|
||||
<AgentActivityCluster
|
||||
messages={unit.messages}
|
||||
isTurnStreaming={liveActivityClusterIndices.has(index)}
|
||||
hasBodyBelow={hasBodyBelow}
|
||||
turnLatencyMs={unit.turnLatencyMs}
|
||||
startedAtMs={unit.startedAtMs}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
) : (
|
||||
<MessageBubble
|
||||
message={unit.message}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
slashCommands={slashCommands}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
onForkFromHere={
|
||||
onForkFromMessage && forkIndex !== undefined
|
||||
? () => onForkFromMessage(forkIndex)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{index === forkBoundaryAfterUnitIndex ? (
|
||||
<ForkBoundaryDivider label={t("thread.forkedFromHistory")} />
|
||||
) : null}
|
||||
</Fragment>
|
||||
<ThreadDisplayUnit
|
||||
key={unitKeys[index]}
|
||||
unit={unit}
|
||||
marginTop={marginTop}
|
||||
userPromptId={userPromptId}
|
||||
hasBodyBelow={hasBodyBelow}
|
||||
isTurnStreaming={liveActivityClusterIndices.has(index)}
|
||||
forkIndex={forkIndex}
|
||||
showForkBoundary={index === forkBoundaryAfterUnitIndex}
|
||||
forkBoundaryLabel={t("thread.forkedFromHistory")}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
slashCommands={slashCommands}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
onForkFromMessage={onForkFromMessage}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ThreadDisplayUnitProps {
|
||||
unit: DisplayUnit;
|
||||
marginTop: string;
|
||||
userPromptId?: string;
|
||||
hasBodyBelow: boolean;
|
||||
isTurnStreaming: boolean;
|
||||
forkIndex?: number;
|
||||
showForkBoundary: boolean;
|
||||
forkBoundaryLabel: string;
|
||||
cliApps: CliAppInfo[];
|
||||
mcpPresets: McpPresetInfo[];
|
||||
slashCommands: SlashCommand[];
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
onForkFromMessage?: (beforeUserIndex: number) => void;
|
||||
}
|
||||
|
||||
const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
||||
unit,
|
||||
marginTop,
|
||||
userPromptId,
|
||||
hasBodyBelow,
|
||||
isTurnStreaming,
|
||||
forkIndex,
|
||||
showForkBoundary,
|
||||
forkBoundaryLabel,
|
||||
cliApps,
|
||||
mcpPresets,
|
||||
slashCommands,
|
||||
onOpenFilePreview,
|
||||
onForkFromMessage,
|
||||
}: ThreadDisplayUnitProps) {
|
||||
const onForkFromHere = useCallback(() => {
|
||||
if (forkIndex !== undefined) onForkFromMessage?.(forkIndex);
|
||||
}, [forkIndex, onForkFromMessage]);
|
||||
const deferOffscreenRender = unit.type === "activity"
|
||||
? !isTurnStreaming
|
||||
: unit.message.role === "assistant" && !unit.message.isStreaming;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`${marginTop}${deferOffscreenRender ? " thread-render-unit" : ""}`}
|
||||
data-user-prompt-id={userPromptId}
|
||||
>
|
||||
{unit.type === "activity" ? (
|
||||
<AgentActivityCluster
|
||||
messages={unit.messages}
|
||||
isTurnStreaming={isTurnStreaming}
|
||||
hasBodyBelow={hasBodyBelow}
|
||||
turnLatencyMs={unit.turnLatencyMs}
|
||||
startedAtMs={unit.startedAtMs}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
) : (
|
||||
<MessageBubble
|
||||
message={unit.message}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
slashCommands={slashCommands}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
onForkFromHere={forkIndex !== undefined ? onForkFromHere : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showForkBoundary ? <ForkBoundaryDivider label={forkBoundaryLabel} /> : null}
|
||||
</>
|
||||
);
|
||||
}, threadDisplayUnitPropsEqual);
|
||||
|
||||
function threadDisplayUnitPropsEqual(
|
||||
previous: ThreadDisplayUnitProps,
|
||||
next: ThreadDisplayUnitProps,
|
||||
): boolean {
|
||||
return (
|
||||
displayUnitsEqual(previous.unit, next.unit)
|
||||
&& previous.marginTop === next.marginTop
|
||||
&& previous.userPromptId === next.userPromptId
|
||||
&& previous.hasBodyBelow === next.hasBodyBelow
|
||||
&& previous.isTurnStreaming === next.isTurnStreaming
|
||||
&& previous.forkIndex === next.forkIndex
|
||||
&& previous.showForkBoundary === next.showForkBoundary
|
||||
&& previous.forkBoundaryLabel === next.forkBoundaryLabel
|
||||
&& previous.cliApps === next.cliApps
|
||||
&& previous.mcpPresets === next.mcpPresets
|
||||
&& previous.slashCommands === next.slashCommands
|
||||
&& previous.onOpenFilePreview === next.onOpenFilePreview
|
||||
&& previous.onForkFromMessage === next.onForkFromMessage
|
||||
);
|
||||
}
|
||||
|
||||
function displayUnitsEqual(previous: DisplayUnit, next: DisplayUnit): boolean {
|
||||
if (previous.type !== next.type) return false;
|
||||
if (previous.type === "message" && next.type === "message") {
|
||||
return shallowMessageEqual(previous.message, next.message);
|
||||
}
|
||||
if (previous.type !== "activity" || next.type !== "activity") return false;
|
||||
return (
|
||||
previous.turnLatencyMs === next.turnLatencyMs
|
||||
&& previous.startedAtMs === next.startedAtMs
|
||||
&& previous.messages.length === next.messages.length
|
||||
&& previous.messages.every((message, index) =>
|
||||
shallowMessageEqual(message, next.messages[index]))
|
||||
);
|
||||
}
|
||||
|
||||
function shallowMessageEqual(previous: UIMessage, next: UIMessage): boolean {
|
||||
if (previous === next) return true;
|
||||
const previousKeys = Object.keys(previous) as Array<keyof UIMessage>;
|
||||
const nextKeys = Object.keys(next) as Array<keyof UIMessage>;
|
||||
return previousKeys.length === nextKeys.length
|
||||
&& previousKeys.every((key) => previous[key] === next[key]);
|
||||
}
|
||||
|
||||
function unitIndexAfterMessageCount(
|
||||
units: DisplayUnit[],
|
||||
messageCount: number | null | undefined,
|
||||
|
||||
@@ -344,6 +344,8 @@ export function ThreadShell({
|
||||
const [filePreviewPath, setFilePreviewPath] = useState<string | null>(null);
|
||||
const [filePreviewClosing, setFilePreviewClosing] = useState(false);
|
||||
const [filePreviewWidth, setFilePreviewWidth] = useState(FILE_PREVIEW_DEFAULT_WIDTH);
|
||||
const [quotedContext, setQuotedContext] = useState<string | null>(null);
|
||||
const [composerFocusSignal, setComposerFocusSignal] = useState(0);
|
||||
const shellRef = useRef<HTMLElement | null>(null);
|
||||
const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH);
|
||||
const filePreviewCloseTimerRef = useRef<number | null>(null);
|
||||
@@ -395,8 +397,14 @@ export function ThreadShell({
|
||||
}
|
||||
setFilePreviewClosing(false);
|
||||
setFilePreviewPath(null);
|
||||
setQuotedContext(null);
|
||||
}, [historyKey]);
|
||||
|
||||
const handleQuoteSelection = useCallback((text: string) => {
|
||||
setQuotedContext(text);
|
||||
setComposerFocusSignal((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (filePreviewCloseTimerRef.current !== null) {
|
||||
@@ -806,6 +814,9 @@ export function ThreadShell({
|
||||
pendingQueueKey={chatId}
|
||||
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
||||
ingressLimits={ingressLimits}
|
||||
quotedContext={quotedContext}
|
||||
focusRequest={composerFocusSignal}
|
||||
onQuotedContextChange={setQuotedContext}
|
||||
/>
|
||||
) : (
|
||||
<ThreadComposer
|
||||
@@ -904,6 +915,7 @@ export function ThreadShell({
|
||||
onLoadOlder={loadOlder}
|
||||
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
|
||||
onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}
|
||||
onQuoteSelection={session ? handleQuoteSelection : undefined}
|
||||
/>
|
||||
</FilePreviewAvailabilityProvider>
|
||||
</div>
|
||||
|
||||
@@ -48,6 +48,7 @@ interface ThreadViewportProps {
|
||||
onLoadOlder?: () => Promise<void> | void;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
onForkFromMessage?: (beforeUserIndex: number) => void;
|
||||
onQuoteSelection?: (text: string) => void;
|
||||
}
|
||||
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
@@ -120,6 +121,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
onLoadOlder,
|
||||
onOpenFilePreview,
|
||||
onForkFromMessage,
|
||||
onQuoteSelection,
|
||||
}, ref) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -508,7 +510,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
const programmaticPromptTop = programmaticPromptScrollTopRef.current;
|
||||
const programmatic =
|
||||
programmaticPromptTop !== null && Math.abs(el.scrollTop - programmaticPromptTop) < 2;
|
||||
setAtBottom(near);
|
||||
setAtBottom((current) => current === near ? current : near);
|
||||
if (programmatic) {
|
||||
programmaticPromptScrollTopRef.current = null;
|
||||
if (near) userReadingHistoryRef.current = false;
|
||||
@@ -557,6 +559,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
forkBoundaryMessageCount={visibleForkBoundaryMessageCount}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
onForkFromMessage={onForkFromMessage}
|
||||
onQuoteSelection={onQuoteSelection}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -254,7 +254,7 @@ export function WorkspaceAccessMenu({
|
||||
variant="ghost"
|
||||
aria-label={t("thread.composer.workspace.accessAria")}
|
||||
className={cn(
|
||||
"min-w-0 max-w-[min(7rem,30vw)] whitespace-nowrap rounded-[10px] border border-transparent font-semibold shadow-none sm:max-w-[min(12.5rem,42vw)]",
|
||||
"touch-target min-w-0 max-w-[min(7rem,30vw)] whitespace-nowrap rounded-[10px] border border-transparent font-semibold shadow-none sm:max-w-[min(12.5rem,42vw)]",
|
||||
isHero ? "h-8 px-2.5 text-[12px]" : "h-9 px-3 text-[12.5px]",
|
||||
isFull
|
||||
? "bg-transparent text-orange-600 hover:bg-orange-500/8 dark:text-orange-300 dark:hover:bg-orange-400/10"
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { AttachmentTile } from "@/components/AttachmentTile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ActivityEvidence } from "@/lib/activity-timeline";
|
||||
|
||||
interface ActivityEvidencePreviewProps {
|
||||
evidence: ActivityEvidence[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ActivityEvidencePreview({ evidence, className }: ActivityEvidencePreviewProps) {
|
||||
if (evidence.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
data-testid="activity-evidence-preview"
|
||||
className={cn(
|
||||
"flex max-w-full flex-wrap items-start gap-2 pt-0.5",
|
||||
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 motion-safe:duration-200",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{evidence.slice(0, 4).map((item) => (
|
||||
<AttachmentTile
|
||||
key={item.id}
|
||||
attachment={item.attachment}
|
||||
variant="compact"
|
||||
className={cn(
|
||||
item.attachment.kind === "image" || item.attachment.kind === "video"
|
||||
? "max-w-[min(100%,20rem)]"
|
||||
: "max-w-[14rem]",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ActivityGroupProps {
|
||||
title: string;
|
||||
icon?: LucideIcon;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ActivityGroup({ title, icon: Icon, children, className }: ActivityGroupProps) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"min-w-0 py-1 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1 motion-safe:duration-200",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mb-1 flex min-w-0 items-center gap-1.5 pl-0.5 text-[12px] font-medium text-muted-foreground/70">
|
||||
{Icon ? <Icon className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
|
||||
<span className="min-w-0 truncate">{title}</span>
|
||||
</div>
|
||||
<div className="min-w-0">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -7,51 +7,45 @@ import { cn } from "@/lib/utils";
|
||||
export type ActivityStepTone = "neutral" | "active" | "success" | "error";
|
||||
|
||||
export interface ActivityStepProps {
|
||||
as?: "div" | "li";
|
||||
icon?: LucideIcon;
|
||||
marker?: ReactNode;
|
||||
label: ReactNode;
|
||||
detail?: ReactNode;
|
||||
aside?: ReactNode;
|
||||
children?: ReactNode;
|
||||
ariaLabel?: string;
|
||||
active?: boolean;
|
||||
tone?: ActivityStepTone;
|
||||
title?: string;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
labelClassName?: string;
|
||||
markerClassName?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function ActivityStep({
|
||||
as: Component = "div",
|
||||
icon: Icon,
|
||||
marker,
|
||||
label,
|
||||
detail,
|
||||
aside,
|
||||
children,
|
||||
ariaLabel,
|
||||
active = false,
|
||||
tone = active ? "active" : "neutral",
|
||||
title,
|
||||
className,
|
||||
contentClassName,
|
||||
labelClassName,
|
||||
markerClassName,
|
||||
style,
|
||||
}: ActivityStepProps) {
|
||||
return (
|
||||
<Component
|
||||
<div
|
||||
data-testid="activity-step"
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"group/activity-step relative grid min-w-0 grid-cols-[1.125rem_minmax(0,1fr)] gap-2 py-0.5 text-[13px] leading-5",
|
||||
"relative grid min-w-0 grid-cols-[1.125rem_minmax(0,1fr)] gap-2 py-0.5 text-[13px] leading-5",
|
||||
className,
|
||||
)}
|
||||
title={title}
|
||||
style={style}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"relative flex h-5 w-[1.125rem] shrink-0 items-start justify-center pt-[3px]",
|
||||
"after:absolute after:left-1/2 after:top-[1.25rem] after:h-[calc(100%+0.375rem)] after:w-px after:-translate-x-1/2 after:bg-muted-foreground/14 group-last/activity-step:after:hidden",
|
||||
"flex h-5 w-[1.125rem] shrink-0 items-start justify-center pt-[3px]",
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
@@ -71,25 +65,23 @@ export function ActivityStep({
|
||||
)}
|
||||
</span>
|
||||
<div className={cn("min-w-0", contentClassName)}>
|
||||
<div className="flex min-w-0 items-baseline gap-1.5">
|
||||
<div
|
||||
data-testid="activity-line"
|
||||
title={typeof label === "string" ? label : undefined}
|
||||
className="flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap"
|
||||
>
|
||||
<StreamingLabelSheen
|
||||
active={active}
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 font-medium",
|
||||
"min-w-0 flex-1 truncate font-medium",
|
||||
tone === "error" ? "text-destructive/78" : "text-muted-foreground/85",
|
||||
labelClassName,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</StreamingLabelSheen>
|
||||
{detail ? (
|
||||
<span className="min-w-0 break-words text-foreground/82">
|
||||
{detail}
|
||||
</span>
|
||||
) : null}
|
||||
{aside ? <span className="ml-auto shrink-0">{aside}</span> : null}
|
||||
</div>
|
||||
{children ? <div className="mt-1 min-w-0">{children}</div> : null}
|
||||
</div>
|
||||
</Component>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
CircleDashed,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FileReferenceChip } from "@/components/FileReferenceChip";
|
||||
import {
|
||||
hasRenderableFileDiff,
|
||||
parseRenderableFileDiff,
|
||||
type RenderableFileDiff,
|
||||
type RenderableFileDiffHunk,
|
||||
} from "@/lib/file-diff";
|
||||
import { codeLanguageFromPath } from "@/lib/code-language";
|
||||
import type { FileEditDisplayMode } from "@/lib/local-preferences";
|
||||
import type { UIFileDiff, UIFileEdit } from "@/lib/types";
|
||||
import type { UIFileEdit } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ActivityStep } from "./ActivityStep";
|
||||
import { DiffPair } from "./DiffPair";
|
||||
import { DiffSyntaxHighlight } from "./DiffSyntaxHighlight";
|
||||
|
||||
const INITIAL_VISIBLE_DIFF_LINES = 160;
|
||||
const AUTO_COLLAPSE_DIFF_LINES = INITIAL_VISIBLE_DIFF_LINES;
|
||||
|
||||
type DiffFileEditDisplayMode = Exclude<FileEditDisplayMode, "summary">;
|
||||
|
||||
interface VisibleDiffHunk {
|
||||
hunk: RenderableFileDiffHunk;
|
||||
skippedBefore: number;
|
||||
}
|
||||
|
||||
interface VisibleDiff {
|
||||
hunks: VisibleDiffHunk[];
|
||||
hiddenLineCount: number;
|
||||
}
|
||||
|
||||
const EMPTY_VISIBLE_DIFF: VisibleDiff = { hunks: [], hiddenLineCount: 0 };
|
||||
|
||||
export interface FileEditSummary {
|
||||
key: string;
|
||||
@@ -55,102 +24,41 @@ export interface FileEditSummary {
|
||||
operation?: UIFileEdit["operation"];
|
||||
pending: boolean;
|
||||
error?: string;
|
||||
diff?: UIFileDiff;
|
||||
}
|
||||
|
||||
export function FileEditGroup({
|
||||
edits,
|
||||
displayMode,
|
||||
onOpenFilePreview,
|
||||
density = "default",
|
||||
}: {
|
||||
edits: FileEditSummary[];
|
||||
displayMode: FileEditDisplayMode;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
density?: "default" | "diff-only";
|
||||
}) {
|
||||
if (edits.length === 0) return null;
|
||||
return (
|
||||
<ul className="space-y-1">
|
||||
{edits.map((edit) => {
|
||||
if (density === "diff-only" && canRenderDiff(edit, displayMode)) {
|
||||
return (
|
||||
<FileEditDiffOnly
|
||||
key={edit.key}
|
||||
edit={edit}
|
||||
displayMode={displayMode}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FileEditRow
|
||||
key={edit.key}
|
||||
edit={edit}
|
||||
displayMode={displayMode}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function canRenderDiff(
|
||||
edit: FileEditSummary,
|
||||
displayMode: FileEditDisplayMode,
|
||||
): displayMode is DiffFileEditDisplayMode {
|
||||
return (
|
||||
displayMode !== "summary"
|
||||
&& edit.status !== "editing"
|
||||
&& edit.status !== "error"
|
||||
&& hasRenderableFileDiff(edit.diff)
|
||||
);
|
||||
}
|
||||
|
||||
function FileEditDiffOnly({
|
||||
edit,
|
||||
displayMode,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
edit: FileEditSummary;
|
||||
displayMode: DiffFileEditDisplayMode;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<li className="min-w-0 py-0.5">
|
||||
<FileUnifiedDiff
|
||||
diff={edit.diff!}
|
||||
collapsed={displayMode === "collapsed_diff"}
|
||||
added={edit.added}
|
||||
deleted={edit.deleted}
|
||||
showCollapsedStats={false}
|
||||
previewPath={edit.absolute_path || edit.path}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
</li>
|
||||
<>
|
||||
{edits.map((edit) => (
|
||||
<FileEditRow
|
||||
key={edit.key}
|
||||
edit={edit}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FileEditRow({
|
||||
edit,
|
||||
displayMode,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
edit: FileEditSummary;
|
||||
displayMode: FileEditDisplayMode;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const editing = edit.status === "editing";
|
||||
const failed = edit.status === "error";
|
||||
const action = fileEditAction(edit, editing, failed);
|
||||
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
|
||||
const showDiff = canRenderDiff(edit, displayMode);
|
||||
const rawFailureDetail = failed ? cleanFileEditError(edit.error) : "";
|
||||
const failureDetail = failed
|
||||
? formatFileEditError(edit.error)
|
||||
|| t("message.fileEditFailedFallback", { defaultValue: "File change was not applied." })
|
||||
: "";
|
||||
const statusIcon = failed ? (
|
||||
<AlertCircle className="h-3 w-3" aria-hidden />
|
||||
) : editing ? (
|
||||
@@ -158,9 +66,9 @@ function FileEditRow({
|
||||
) : (
|
||||
<CheckCircle2 className="h-3 w-3" aria-hidden />
|
||||
);
|
||||
|
||||
return (
|
||||
<ActivityStep
|
||||
as="li"
|
||||
marker={(
|
||||
<span
|
||||
className={cn(
|
||||
@@ -176,42 +84,26 @@ function FileEditRow({
|
||||
active={editing}
|
||||
tone={failed ? "error" : editing ? "active" : "success"}
|
||||
className="text-xs"
|
||||
contentClassName={failed || showDiff ? "min-w-0" : "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3"}
|
||||
title={rawFailureDetail || edit.absolute_path || edit.path}
|
||||
ariaLabel={edit.path ? `${action} ${edit.path}` : action}
|
||||
label={edit.pending && !edit.path
|
||||
? t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })
|
||||
: (
|
||||
<FileReferenceChip
|
||||
path={edit.path}
|
||||
tooltipPath={edit.absolute_path}
|
||||
previewPath={edit.absolute_path || edit.path}
|
||||
onOpen={onOpenFilePreview}
|
||||
display="path"
|
||||
active={editing}
|
||||
className="min-w-0"
|
||||
textClassName="text-[12px]"
|
||||
testId="activity-file-reference"
|
||||
/>
|
||||
<span className="flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap">
|
||||
<span className="shrink-0">{action}</span>
|
||||
<FileReferenceChip
|
||||
path={edit.path}
|
||||
previewPath={edit.absolute_path || edit.path}
|
||||
onOpen={onOpenFilePreview}
|
||||
display="path"
|
||||
active={editing}
|
||||
className="min-w-0"
|
||||
textClassName="truncate text-[12px]"
|
||||
testId="activity-file-reference"
|
||||
/>
|
||||
{hasCountedDiff ? <DiffPair added={edit.added} deleted={edit.deleted} /> : null}
|
||||
</span>
|
||||
)}
|
||||
detail={null}
|
||||
aside={hasCountedDiff ? <DiffPair added={edit.added} deleted={edit.deleted} /> : null}
|
||||
>
|
||||
{failed ? (
|
||||
<span className="block max-w-[42rem] truncate text-[11px] leading-4 text-destructive/75">
|
||||
{failureDetail}
|
||||
</span>
|
||||
) : null}
|
||||
{showDiff ? (
|
||||
<FileUnifiedDiff
|
||||
diff={edit.diff!}
|
||||
collapsed={displayMode === "collapsed_diff"}
|
||||
added={edit.added}
|
||||
deleted={edit.deleted}
|
||||
previewPath={edit.absolute_path || edit.path}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
) : null}
|
||||
</ActivityStep>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,262 +111,9 @@ export function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "delet
|
||||
return edit.added > 0 || edit.deleted > 0;
|
||||
}
|
||||
|
||||
function cleanFileEditError(error?: string): string {
|
||||
const firstLine = (error || "").replace(/\s+/g, " ").trim();
|
||||
if (!firstLine) return "";
|
||||
return firstLine
|
||||
.replace(/^Error applying patch:\s*/i, "")
|
||||
.replace(/^Error writing file:\s*/i, "")
|
||||
.replace(/^Error editing file:\s*/i, "")
|
||||
.replace(/^Error:\s*/i, "");
|
||||
}
|
||||
|
||||
function formatFileEditError(error?: string): string {
|
||||
const cleaned = cleanFileEditError(error);
|
||||
if (!cleaned) return "";
|
||||
|
||||
if (/\bpermission denied\b/i.test(cleaned) || /\boperation not permitted\b/i.test(cleaned)) {
|
||||
return "No permission to change this location.";
|
||||
}
|
||||
|
||||
return cleaned
|
||||
.replace(/^old_text not found in (.+)$/i, "Target text was not found in $1.")
|
||||
.replace(/^old_text appears multiple times in (.+)$/i, "Target text matched multiple places in $1.")
|
||||
.replace(/^file to (?:update|delete) does not exist: (.+)$/i, "File does not exist: $1.")
|
||||
.replace(/^path to (?:update|delete) is not a file: (.+)$/i, "Path is not a file: $1.")
|
||||
.slice(0, 180);
|
||||
}
|
||||
|
||||
function FileUnifiedDiff({
|
||||
diff,
|
||||
collapsed,
|
||||
added,
|
||||
deleted,
|
||||
showCollapsedStats = true,
|
||||
previewPath,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
diff: UIFileDiff;
|
||||
collapsed: boolean;
|
||||
added: number;
|
||||
deleted: number;
|
||||
showCollapsedStats?: boolean;
|
||||
previewPath?: string;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||
const [open, setOpen] = useState(false);
|
||||
const [expandedLines, setExpandedLines] = useState(false);
|
||||
const renderableDiff = useMemo(() => parseRenderableFileDiff(diff), [diff]);
|
||||
const language = useMemo(() => codeLanguageFromPath(previewPath), [previewPath]);
|
||||
const totalLineCount = useMemo(() => countDiffLines(renderableDiff), [renderableDiff]);
|
||||
const shouldAutoCollapse = totalLineCount > AUTO_COLLAPSE_DIFF_LINES || !!diff.truncated;
|
||||
const startsCollapsed = collapsed || shouldAutoCollapse;
|
||||
const shouldRenderBody = !startsCollapsed || open;
|
||||
const shouldLimitLines = totalLineCount > INITIAL_VISIBLE_DIFF_LINES;
|
||||
const lineLimit = expandedLines || !shouldLimitLines
|
||||
? totalLineCount
|
||||
: INITIAL_VISIBLE_DIFF_LINES;
|
||||
const visibleDiff = useMemo(
|
||||
() => shouldRenderBody
|
||||
? selectVisibleDiffLines(renderableDiff, lineLimit, totalLineCount)
|
||||
: EMPTY_VISIBLE_DIFF,
|
||||
[lineLimit, renderableDiff, shouldRenderBody, totalLineCount],
|
||||
);
|
||||
const lineCountLabel = t("message.fileEditDiffLineCount", {
|
||||
count: diff.truncated ? `${totalLineCount}+` : totalLineCount,
|
||||
defaultValue: "{{count}} lines",
|
||||
});
|
||||
const viewDiffLabel = shouldAutoCollapse
|
||||
? tx("message.fileEditViewLargeDiff", "View large diff")
|
||||
: tx("message.fileEditViewDiff", "View diff");
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
setExpandedLines(false);
|
||||
}, [diff]);
|
||||
|
||||
const handleToggleOpen = () => {
|
||||
if (open) setExpandedLines(false);
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
if (totalLineCount === 0) return null;
|
||||
|
||||
const renderBody = () => (
|
||||
<div
|
||||
className="mt-1 overflow-hidden rounded-md border border-border/55 bg-background/80 shadow-[0_1px_0_rgba(15,23,42,0.03)]"
|
||||
data-testid="file-edit-diff"
|
||||
>
|
||||
{visibleDiff.hunks.map(({ hunk, skippedBefore }, index) => (
|
||||
<div
|
||||
key={`${hunk.old_start}-${hunk.new_start}-${index}`}
|
||||
className={cn("min-w-0", index > 0 && "border-t border-border/45")}
|
||||
>
|
||||
{skippedBefore > 0 ? <DiffHunkGap lineCount={skippedBefore} /> : null}
|
||||
<div className="overflow-x-auto">
|
||||
<DiffSyntaxHighlight language={language} lines={hunk.lines} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{visibleDiff.hiddenLineCount > 0 ? (
|
||||
<div className="border-t border-border/45 bg-muted/30 px-2 py-1">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded px-1 py-0.5 text-[11px] font-medium",
|
||||
"text-muted-foreground transition-colors hover:bg-muted/65 hover:text-foreground",
|
||||
)}
|
||||
data-testid="file-edit-diff-expand-lines"
|
||||
onClick={() => setExpandedLines(true)}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" aria-hidden />
|
||||
{t("message.fileEditShowMoreLines", {
|
||||
count: visibleDiff.hiddenLineCount,
|
||||
defaultValue: "Show {{count}} more lines",
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
) : expandedLines && shouldLimitLines ? (
|
||||
<div className="border-t border-border/45 bg-muted/30 px-2 py-1">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded px-1 py-0.5 text-[11px] font-medium",
|
||||
"text-muted-foreground transition-colors hover:bg-muted/65 hover:text-foreground",
|
||||
)}
|
||||
data-testid="file-edit-diff-collapse-lines"
|
||||
onClick={() => setExpandedLines(false)}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" aria-hidden />
|
||||
{tx("message.fileEditShowFewerLines", "Show fewer lines")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{diff.truncated ? (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border/45 bg-muted/35 px-2 py-1 text-[11px] text-muted-foreground"
|
||||
data-testid="file-edit-diff-truncated"
|
||||
>
|
||||
<span>
|
||||
{tx("message.fileEditDiffTruncated", "Diff truncated. Open the file for the full change.")}
|
||||
</span>
|
||||
{previewPath && onOpenFilePreview ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded px-1 py-0.5 font-medium",
|
||||
"text-muted-foreground transition-colors hover:bg-muted/65 hover:text-foreground",
|
||||
)}
|
||||
data-testid="file-edit-diff-open-file"
|
||||
onClick={() => onOpenFilePreview(previewPath)}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" aria-hidden />
|
||||
{tx("message.fileEditOpenFile", "Open file")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!startsCollapsed) return renderBody();
|
||||
|
||||
return (
|
||||
<div className="mt-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
data-testid="file-edit-diff-toggle"
|
||||
onClick={handleToggleOpen}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-2 rounded-md border border-border/45 bg-muted/35 px-2 py-1 text-left",
|
||||
"text-[11px] font-medium text-muted-foreground transition-colors hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("h-3 w-3 shrink-0 transition-transform", open && "rotate-90")}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1">{viewDiffLabel}</span>
|
||||
<span className="shrink-0 text-muted-foreground/65">{lineCountLabel}</span>
|
||||
{showCollapsedStats ? <DiffPair added={added} deleted={deleted} /> : null}
|
||||
</button>
|
||||
{open ? renderBody() : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function countDiffLines(diff: RenderableFileDiff): number {
|
||||
return diff.hunks.reduce((total, hunk) => total + hunk.lines.length, 0);
|
||||
}
|
||||
|
||||
function selectVisibleDiffLines(
|
||||
diff: RenderableFileDiff,
|
||||
lineLimit: number,
|
||||
totalLineCount: number,
|
||||
): VisibleDiff {
|
||||
if (lineLimit >= totalLineCount) {
|
||||
return {
|
||||
hunks: diff.hunks.map((hunk, index) => ({
|
||||
hunk,
|
||||
skippedBefore: index > 0 ? countSkippedUnchangedLines(diff.hunks[index - 1], hunk) : 0,
|
||||
})),
|
||||
hiddenLineCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let remaining = Math.max(0, lineLimit);
|
||||
const hunks: VisibleDiffHunk[] = [];
|
||||
let previousHunk: RenderableFileDiffHunk | null = null;
|
||||
for (const hunk of diff.hunks) {
|
||||
if (remaining <= 0) break;
|
||||
const skippedBefore = previousHunk ? countSkippedUnchangedLines(previousHunk, hunk) : 0;
|
||||
if (hunk.lines.length <= remaining) {
|
||||
hunks.push({ hunk, skippedBefore });
|
||||
remaining -= hunk.lines.length;
|
||||
previousHunk = hunk;
|
||||
continue;
|
||||
}
|
||||
hunks.push({ hunk: { ...hunk, lines: hunk.lines.slice(0, remaining) }, skippedBefore });
|
||||
remaining = 0;
|
||||
previousHunk = hunk;
|
||||
}
|
||||
return {
|
||||
hunks,
|
||||
hiddenLineCount: Math.max(0, totalLineCount - lineLimit),
|
||||
};
|
||||
}
|
||||
|
||||
function countSkippedUnchangedLines(
|
||||
previous: RenderableFileDiffHunk,
|
||||
current: RenderableFileDiffHunk,
|
||||
): number {
|
||||
const oldGap = current.old_start - (previous.old_start + previous.old_lines);
|
||||
const newGap = current.new_start - (previous.new_start + previous.new_lines);
|
||||
return Math.max(0, oldGap, newGap);
|
||||
}
|
||||
|
||||
function DiffHunkGap({ lineCount }: { lineCount: number }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 bg-muted/35 px-2 py-1 text-[11px] text-muted-foreground"
|
||||
data-testid="file-edit-diff-hunk-gap"
|
||||
>
|
||||
<span
|
||||
className="select-none rounded border border-border/45 bg-background/70 px-1 font-mono text-muted-foreground/70"
|
||||
aria-hidden
|
||||
>
|
||||
...
|
||||
</span>
|
||||
<span>
|
||||
{t("message.fileEditUnchangedLinesHidden", {
|
||||
count: lineCount,
|
||||
defaultValue: "{{count}} unchanged lines hidden",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
function fileEditAction(edit: FileEditSummary, editing: boolean, failed: boolean): string {
|
||||
const deleting = edit.operation === "delete";
|
||||
if (failed) return deleting ? "Could not delete" : "Could not edit";
|
||||
if (editing) return deleting ? "Deleting" : "Editing";
|
||||
return deleting ? "Deleted" : "Edited";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
FileSearch,
|
||||
FolderOpen,
|
||||
ListTree,
|
||||
MemoryStick,
|
||||
Play,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { ActivityStep } from "@/components/thread/activity/ActivityStep";
|
||||
import {
|
||||
describeGenericToolRun,
|
||||
type GenericToolRunItem,
|
||||
type GenericToolStatus,
|
||||
type ToolFamily,
|
||||
} from "@/components/thread/activity/generic-tool-model";
|
||||
|
||||
interface GenericToolRunModel {
|
||||
status: GenericToolStatus;
|
||||
label: string;
|
||||
detail: string;
|
||||
aside: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
export function GenericToolRun({ items }: { items: GenericToolRunItem[] }) {
|
||||
const model = useMemo(() => buildModel(items), [items]);
|
||||
const action = [model.label, model.detail].filter(Boolean).join(" ");
|
||||
const label = model.aside ? `${action} · ${model.aside}` : action;
|
||||
|
||||
return (
|
||||
<ActivityStep
|
||||
icon={model.status === "error" ? AlertCircle : model.icon}
|
||||
active={model.status === "running"}
|
||||
tone={model.status === "error" ? "error" : model.status === "done" ? "success" : "active"}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function buildModel(items: GenericToolRunItem[]): GenericToolRunModel {
|
||||
const family = items[0]?.trace.family ?? "generic";
|
||||
const presentation = describeGenericToolRun(items);
|
||||
return {
|
||||
...presentation,
|
||||
icon: activityIcon(family),
|
||||
};
|
||||
}
|
||||
|
||||
function activityIcon(family: ToolFamily): LucideIcon {
|
||||
if (family === "content-search" || family === "file-search") return FileSearch;
|
||||
if (family === "list") return ListTree;
|
||||
if (family === "read") return FolderOpen;
|
||||
if (family === "memory") return MemoryStick;
|
||||
return Play;
|
||||
}
|
||||
@@ -2,51 +2,35 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Check, CircleDashed } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ActivityStep } from "./ActivityStep";
|
||||
import { compactReasoningPreview } from "./reasoning-preview";
|
||||
|
||||
export function ReasoningRow({
|
||||
text,
|
||||
streaming,
|
||||
onOpenFilePreview,
|
||||
className,
|
||||
}: {
|
||||
text: string;
|
||||
streaming: boolean;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
useEffect(() => {
|
||||
if (text.length > 0) preloadMarkdownText();
|
||||
}, [text.length]);
|
||||
const fallback = streaming
|
||||
? t("message.reasoningStreaming", { defaultValue: "Thinking…" })
|
||||
: t("message.reasoning", { defaultValue: "Thinking" });
|
||||
const preview = compactReasoningPreview(text) || fallback;
|
||||
return (
|
||||
<ActivityStep
|
||||
marker={<ReasoningMarker streaming={streaming} />}
|
||||
active={streaming}
|
||||
tone={streaming ? "active" : "success"}
|
||||
label={streaming
|
||||
? t("message.reasoningStreaming", { defaultValue: "Thinking…" })
|
||||
: t("message.reasoning", { defaultValue: "Thinking" })}
|
||||
>
|
||||
{text.trim() ? (
|
||||
<MarkdownText
|
||||
streaming={streaming}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
className={cn(
|
||||
"min-w-0 text-[12.5px] italic text-muted-foreground/78",
|
||||
"prose-p:my-1 prose-li:my-0.5",
|
||||
"prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-medium",
|
||||
"prose-headings:text-muted-foreground/88 prose-strong:text-muted-foreground",
|
||||
"prose-h1:text-[15px] prose-h2:text-[13.5px] prose-h3:text-[12.5px] prose-h4:text-[12px]",
|
||||
"prose-a:text-blue-500 prose-a:underline hover:prose-a:text-blue-600 dark:prose-a:text-blue-300 dark:hover:prose-a:text-blue-200",
|
||||
"prose-code:text-[0.92em]",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</MarkdownText>
|
||||
) : null}
|
||||
</ActivityStep>
|
||||
label={preview}
|
||||
labelClassName="italic text-muted-foreground/78"
|
||||
contentClassName="overflow-hidden"
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import type { ReactNode, Ref } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ThinkingReasoningShellProps {
|
||||
active: boolean;
|
||||
expanded: boolean;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
viewportRef: Ref<HTMLDivElement>;
|
||||
contentRef: Ref<HTMLDivElement>;
|
||||
onToggle: () => void;
|
||||
onScroll: () => void;
|
||||
}
|
||||
|
||||
export function ThinkingReasoningShell({
|
||||
active,
|
||||
expanded,
|
||||
label,
|
||||
children,
|
||||
viewportRef,
|
||||
contentRef,
|
||||
onToggle,
|
||||
onScroll,
|
||||
}: ThinkingReasoningShellProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex w-full max-w-[45rem] animate-in flex-col fade-in duration-300 motion-reduce:animate-none"
|
||||
data-state={active ? "thinking" : "done"}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex min-h-5 items-center self-start gap-1.5 bg-transparent p-0"
|
||||
onClick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
aria-label={label}
|
||||
aria-live={active ? "polite" : undefined}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-[13px] font-medium leading-[18px] text-muted-foreground/70",
|
||||
active && "animate-pulse motion-reduce:animate-none",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-3 w-3 shrink-0 text-muted-foreground/60 transition-[transform,color] duration-200",
|
||||
"group-hover:text-muted-foreground motion-reduce:transition-none",
|
||||
expanded && "rotate-180",
|
||||
)}
|
||||
strokeWidth={1.8}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows,opacity] duration-300 motion-reduce:transition-none",
|
||||
expanded
|
||||
? "grid-rows-[1fr] opacity-100"
|
||||
: "pointer-events-none grid-rows-[0fr] opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div
|
||||
ref={viewportRef}
|
||||
data-testid={expanded ? "agent-activity-scroll" : undefined}
|
||||
onScroll={onScroll}
|
||||
className="mt-1.5 max-h-[180px] overflow-y-auto pr-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
aria-hidden={!expanded}
|
||||
>
|
||||
<div ref={contentRef} className="flex flex-col gap-0.5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Globe2 } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { ActivityStep, type ActivityStepTone } from "@/components/thread/activity/ActivityStep";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { browserSafeFaviconUrls } from "@/lib/provider-brand";
|
||||
|
||||
interface WebActivityRowProps {
|
||||
title: string;
|
||||
href: string;
|
||||
host: string;
|
||||
displayUrl: string;
|
||||
active?: boolean;
|
||||
tone?: ActivityStepTone;
|
||||
}
|
||||
|
||||
export function WebActivityRow({
|
||||
title,
|
||||
href,
|
||||
host,
|
||||
displayUrl,
|
||||
active = false,
|
||||
tone = active ? "active" : "neutral",
|
||||
}: WebActivityRowProps) {
|
||||
return (
|
||||
<ActivityStep
|
||||
marker={<WebFavicon host={host} active={active} />}
|
||||
active={active}
|
||||
tone={tone}
|
||||
label={(
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
aria-label={`${title} · ${displayUrl}`}
|
||||
className="flex min-w-0 items-center gap-2 overflow-hidden text-foreground/82 hover:text-foreground"
|
||||
>
|
||||
<span className="min-w-0 truncate font-medium">{title}</span>
|
||||
<span
|
||||
className="max-w-[9rem] shrink truncate rounded-full bg-muted/65 px-2 py-0.5 font-mono text-[10px] leading-4 text-muted-foreground/72 sm:max-w-[18rem]"
|
||||
data-testid="activity-web-url"
|
||||
>
|
||||
{displayUrl}
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
contentClassName="overflow-hidden"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function WebFavicon({ host, active }: { host: string; active: boolean }) {
|
||||
const candidates = useMemo(() => browserSafeFaviconUrls(host), [host]);
|
||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(candidates);
|
||||
|
||||
if (!logoUrl) {
|
||||
return <Globe2 className="h-4 w-4 shrink-0 text-muted-foreground/52" aria-hidden />;
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
className={`h-4 w-4 shrink-0 rounded-[3px] object-contain${active ? " animate-pulse" : ""}`}
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
draggable={false}
|
||||
onLoad={onLogoLoad}
|
||||
onError={onLogoError}
|
||||
data-testid={`activity-web-favicon-${host}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { AlertCircle, Search } from "lucide-react";
|
||||
|
||||
import { ActivityStep } from "@/components/thread/activity/ActivityStep";
|
||||
import { WebActivityRow } from "@/components/thread/activity/WebActivityRow";
|
||||
import {
|
||||
presentWebSearchAction,
|
||||
type WebSearchRunModel,
|
||||
} from "@/components/thread/activity/web-search-model";
|
||||
|
||||
export function WebSearchRun({ run, turnActive }: { run: WebSearchRunModel; turnActive: boolean }) {
|
||||
const active = run.status === "running" && turnActive;
|
||||
const status = run.status === "running" && !turnActive ? "done" : run.status;
|
||||
const label = presentWebSearchAction(run.query, status);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActivityStep
|
||||
icon={status === "error" ? AlertCircle : Search}
|
||||
active={active}
|
||||
tone={status === "error" ? "error" : status === "done" ? "success" : "active"}
|
||||
label={label}
|
||||
/>
|
||||
{run.sources.map((source) => (
|
||||
<WebActivityRow
|
||||
key={source.href}
|
||||
title={source.title}
|
||||
href={source.href}
|
||||
host={source.host}
|
||||
displayUrl={source.displayUrl}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import {
|
||||
canonicalToolTrace,
|
||||
mergeToolProgressEvents,
|
||||
mergeUniqueToolTraceLines,
|
||||
} from "@/lib/tool-traces";
|
||||
import type { UIMediaAttachment, UIMessage } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Live tool progress is already folded into one trace message. Persisted
|
||||
* transcripts can contain the same progress as adjacent start/end rows, so
|
||||
* normalize both paths before rendering the activity timeline.
|
||||
*/
|
||||
export function coalesceActivityMessages(messages: UIMessage[]): UIMessage[] {
|
||||
const normalized: UIMessage[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const targetIndex = findMergeTarget(normalized, message);
|
||||
if (targetIndex < 0) {
|
||||
normalized.push(message);
|
||||
continue;
|
||||
}
|
||||
normalized[targetIndex] = mergeTraceMessages(normalized[targetIndex], message);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function findMergeTarget(messages: UIMessage[], incoming: UIMessage): number {
|
||||
if (incoming.kind !== "trace") return -1;
|
||||
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const previous = messages[index];
|
||||
if (previous.kind !== "trace") continue;
|
||||
if (hasSharedToolCall(previous, incoming) && sameTurn(previous, incoming)) return index;
|
||||
}
|
||||
|
||||
const adjacentIndex = messages.length - 1;
|
||||
const adjacent = messages[adjacentIndex];
|
||||
return canMergeAdjacentProgress(adjacent, incoming) ? adjacentIndex : -1;
|
||||
}
|
||||
|
||||
function canMergeAdjacentProgress(
|
||||
previous: UIMessage | undefined,
|
||||
incoming: UIMessage,
|
||||
): previous is UIMessage {
|
||||
if (!previous || previous.kind !== "trace") return false;
|
||||
if (!sameTurn(previous, incoming)) return false;
|
||||
if (
|
||||
previous.activitySegmentId
|
||||
&& incoming.activitySegmentId
|
||||
&& previous.activitySegmentId === incoming.activitySegmentId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return hasSharedTrace(previous, incoming) && completesPreviousProgress(previous, incoming);
|
||||
}
|
||||
|
||||
function mergeTraceMessages(previous: UIMessage, incoming: UIMessage): UIMessage {
|
||||
const traces = mergeUniqueToolTraceLines(messageTraces(previous), messageTraces(incoming)).traces;
|
||||
const toolEvents = mergeToolProgressEvents(previous.toolEvents, incoming.toolEvents ?? []);
|
||||
const fileEdits = [...(previous.fileEdits ?? []), ...(incoming.fileEdits ?? [])];
|
||||
const media = uniqueMedia([...(previous.media ?? []), ...(incoming.media ?? [])]);
|
||||
|
||||
return {
|
||||
...previous,
|
||||
content: traces[traces.length - 1] ?? incoming.content ?? previous.content,
|
||||
traces,
|
||||
...(toolEvents.length ? { toolEvents } : { toolEvents: undefined }),
|
||||
...(fileEdits.length ? { fileEdits } : { fileEdits: undefined }),
|
||||
...(media.length ? { media } : { media: undefined }),
|
||||
isStreaming: incoming.isStreaming,
|
||||
turnPhase: incoming.turnPhase ?? previous.turnPhase,
|
||||
turnSeq: incoming.turnSeq ?? previous.turnSeq,
|
||||
};
|
||||
}
|
||||
|
||||
function messageTraces(message: UIMessage): string[] {
|
||||
if (message.traces?.length) return message.traces;
|
||||
return message.content.trim() ? [message.content] : [];
|
||||
}
|
||||
|
||||
function hasSharedToolCall(previous: UIMessage, incoming: UIMessage): boolean {
|
||||
const previousCallIds = new Set(
|
||||
(previous.toolEvents ?? []).map((event) => event.call_id).filter(Boolean),
|
||||
);
|
||||
return (incoming.toolEvents ?? []).some((event) => (
|
||||
!!event.call_id && previousCallIds.has(event.call_id)
|
||||
));
|
||||
}
|
||||
|
||||
function hasSharedTrace(previous: UIMessage, incoming: UIMessage): boolean {
|
||||
const previousTraces = new Set(messageTraces(previous).map(canonicalToolTrace));
|
||||
return messageTraces(incoming).some((trace) => previousTraces.has(canonicalToolTrace(trace)));
|
||||
}
|
||||
|
||||
function completesPreviousProgress(previous: UIMessage, incoming: UIMessage): boolean {
|
||||
const previousPhases = new Set((previous.toolEvents ?? []).map((event) => event.phase));
|
||||
const incomingPhases = new Set((incoming.toolEvents ?? []).map((event) => event.phase));
|
||||
return previousPhases.has("start") && (incomingPhases.has("end") || incomingPhases.has("error"));
|
||||
}
|
||||
|
||||
function sameTurn(previous: UIMessage, incoming: UIMessage): boolean {
|
||||
return !previous.turnId || !incoming.turnId || previous.turnId === incoming.turnId;
|
||||
}
|
||||
|
||||
function uniqueMedia(media: UIMediaAttachment[]): UIMediaAttachment[] {
|
||||
const seen = new Set<string>();
|
||||
return media.filter((item) => {
|
||||
const key = `${item.kind}:${item.url ?? ""}:${item.name ?? ""}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
export function redactActivityText(value: string): string {
|
||||
return value
|
||||
.replace(/(https?:\/\/)[^/@\s]+@/gi, "$1<redacted>@")
|
||||
.replace(/\b(Bearer)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 <redacted>")
|
||||
.replace(
|
||||
/(^|[\s;])((?:[A-Z0-9_]*)(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|PASS|AUTH)(?:[A-Z0-9_]*))=(?:"[^"]*"|'[^']*'|[^\s]+)/gim,
|
||||
"$1$2=<redacted>",
|
||||
)
|
||||
.replace(
|
||||
/(--(?:api-?key|access-?token|token|secret|password)(?:=|\s+))(?:"[^"]*"|'[^']*'|[^\s]+)/gi,
|
||||
"$1<redacted>",
|
||||
)
|
||||
.replace(/([?&](?:api_?key|access_?token|token|secret|password)=)[^&\s]+/gi, "$1<redacted>")
|
||||
.replace(
|
||||
/(["']?authorization["']?\s*[:=]\s*["']?)[^"'\r\n,;}]+/gi,
|
||||
"$1<redacted>",
|
||||
)
|
||||
.replace(
|
||||
/(["']?(?:api[_-]?key|access[_-]?token|token|secret|password)["']?\s*[:=]\s*)["']?[^"'\s,&;}]+["']?/gi,
|
||||
"$1<redacted>",
|
||||
)
|
||||
.replace(/\b(?:sk(?:-proj)?|xox[baprs]?|xapp)[-_][A-Za-z0-9._-]{8,}\b/gi, "<redacted>")
|
||||
.replace(/\bgh[pousr]_[A-Za-z0-9]{12,}\b/g, "<redacted>")
|
||||
.replace(/\bAKIA[A-Z0-9]{16}\b/g, "<redacted>")
|
||||
.replace(/\b\d{6,12}:[A-Za-z0-9_-]{20,}\b/g, "<redacted>");
|
||||
}
|
||||
|
||||
export function redactShellCommand(command: string): string {
|
||||
return redactActivityText(command).replaceAll("<redacted>", "••••");
|
||||
}
|
||||
|
||||
export function compactActivityPath(value: string): string {
|
||||
return value
|
||||
.replace(/\/Users\/[^/\s"']+/g, "~")
|
||||
.replace(/\/home\/[^/\s"']+/g, "~")
|
||||
.replace(/\/private\/tmp\/[^\s"']+/g, "/tmp/…")
|
||||
.replace(/\/var\/folders\/[^\s"']+/g, "/var/folders/…");
|
||||
}
|
||||
|
||||
export function safeActivityDetail(value: string, maxLength = 96): string {
|
||||
return truncateMiddle(
|
||||
compactActivityPath(redactActivityText(value))
|
||||
.replace(/\/\.nanobot\/tool-results\/[^\s"']+/g, "/.nanobot/tool-results/…")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/^["']|["']$/g, "")
|
||||
.trim(),
|
||||
maxLength,
|
||||
);
|
||||
}
|
||||
|
||||
export function summarizeShellCommand(command: string): string {
|
||||
const lines = redactShellCommand(command.replace(/\r\n/g, "\n"))
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const firstLine = compactActivityPath(lines[0] || "command");
|
||||
const firstPreview = truncateMiddle(firstLine, 92);
|
||||
return lines.length <= 1
|
||||
? firstPreview
|
||||
: `${firstPreview} · script, ${lines.length} lines`;
|
||||
}
|
||||
|
||||
function truncateMiddle(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) return value;
|
||||
const head = Math.ceil((maxLength - 1) * 0.62);
|
||||
const tail = Math.floor((maxLength - 1) * 0.38);
|
||||
return `${value.slice(0, head)}…${value.slice(-tail)}`;
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { compactActivityPath, redactActivityText } from "./activity-text";
|
||||
|
||||
export type GenericToolStatus = "running" | "done" | "error";
|
||||
export type ToolFamily = "content-search" | "file-search" | "list" | "read" | "memory" | "generic";
|
||||
|
||||
export interface ToolField {
|
||||
key:
|
||||
| "query"
|
||||
| "pattern"
|
||||
| "glob"
|
||||
| "path"
|
||||
| "file_path"
|
||||
| "url"
|
||||
| "action"
|
||||
| "key"
|
||||
| "label"
|
||||
| "name"
|
||||
| "channel"
|
||||
| "chat_id"
|
||||
| "session_id"
|
||||
| "ui_summary";
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface GenericToolTrace {
|
||||
name: string;
|
||||
family: ToolFamily;
|
||||
groupKey: string;
|
||||
fields: ToolField[];
|
||||
collectedSource: boolean;
|
||||
}
|
||||
|
||||
export interface GenericToolRunItem {
|
||||
trace: GenericToolTrace;
|
||||
status: GenericToolStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface GenericToolPresentation {
|
||||
status: GenericToolStatus;
|
||||
label: string;
|
||||
detail: string;
|
||||
aside: string;
|
||||
}
|
||||
|
||||
const CONTENT_SEARCH_TOOLS = new Set([
|
||||
"grep",
|
||||
"rg",
|
||||
"ripgrep",
|
||||
"search_code",
|
||||
"search_content",
|
||||
"search_files_content",
|
||||
"find_text",
|
||||
]);
|
||||
const FILE_SEARCH_TOOLS = new Set([
|
||||
"find",
|
||||
"find_file",
|
||||
"find_files",
|
||||
"glob",
|
||||
"search_files",
|
||||
]);
|
||||
const LIST_TOOLS = new Set(["list_dir", "list_directory", "list_files", "ls"]);
|
||||
const READ_TOOLS = new Set(["read", "read_file", "read_text_file"]);
|
||||
const MEMORY_TOOLS = new Set(["memory_search", "search_memory", "recall_memory"]);
|
||||
const EXCLUDED_TOOL_PREFIXES = ["mcp_"];
|
||||
const EXCLUDED_TOOLS = new Set([
|
||||
"apply_patch",
|
||||
"cli_anything_run",
|
||||
"edit_file",
|
||||
"exec",
|
||||
"exec_command",
|
||||
"execute_command",
|
||||
"run_cli_app",
|
||||
"run_command",
|
||||
"run_shell",
|
||||
"shell",
|
||||
"terminal",
|
||||
"web_fetch",
|
||||
"web_search",
|
||||
"write_file",
|
||||
]);
|
||||
|
||||
export function parseGenericToolTrace(line: string): GenericToolTrace | null {
|
||||
const call = parseCall(line);
|
||||
if (!call || isExcludedTool(call.name)) return null;
|
||||
const family = toolFamily(call.name);
|
||||
const fields = safeFields(call.args);
|
||||
const collectedSource = fields.some((field) => isCollectedSourcePath(field.value));
|
||||
return {
|
||||
name: call.name,
|
||||
family,
|
||||
groupKey: family === "generic"
|
||||
? `${family}:${call.name}`
|
||||
: `${family}:${collectedSource ? "collected" : "workspace"}`,
|
||||
fields,
|
||||
collectedSource,
|
||||
};
|
||||
}
|
||||
|
||||
export function canGroupGenericToolRuns(previous: GenericToolRunItem, next: GenericToolRunItem): boolean {
|
||||
return previous.trace.groupKey === next.trace.groupKey;
|
||||
}
|
||||
|
||||
function compactGenericToolPath(value: string): string {
|
||||
const normalized = redactActivityText(value).replace(/\\/g, "/");
|
||||
if (isCollectedSourcePath(normalized)) {
|
||||
return truncateMiddle(normalized.split("/").pop() || "collected source", 64);
|
||||
}
|
||||
return compactActivityPath(normalized);
|
||||
}
|
||||
|
||||
export function describeGenericToolRun(items: GenericToolRunItem[]): GenericToolPresentation {
|
||||
const status = aggregateStatus(items);
|
||||
const family = items[0]?.trace.family ?? "generic";
|
||||
const name = items[0]?.trace.name ?? "tool";
|
||||
const collected = items.length > 0 && items.every((item) => item.trace.collectedSource);
|
||||
return {
|
||||
status,
|
||||
label: activityLabel(family, status, collected, name, items),
|
||||
detail: activityDetail(items, family, name),
|
||||
aside: activityAside(items, family),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCall(line: string): { name: string; args: unknown } | null {
|
||||
const match = /^([a-zA-Z0-9_.-]+)\((.*)\)$/.exec(line.trim());
|
||||
if (!match) return null;
|
||||
const name = compactToolName(match[1]);
|
||||
let args: unknown;
|
||||
try {
|
||||
args = match[2].trim() ? JSON.parse(match[2]) : {};
|
||||
} catch {
|
||||
args = {};
|
||||
}
|
||||
return { name, args };
|
||||
}
|
||||
|
||||
function compactToolName(name: string): string {
|
||||
return name.toLowerCase().split(".").pop() || name.toLowerCase();
|
||||
}
|
||||
|
||||
function isExcludedTool(name: string): boolean {
|
||||
return EXCLUDED_TOOLS.has(name) || EXCLUDED_TOOL_PREFIXES.some((prefix) => name.startsWith(prefix));
|
||||
}
|
||||
|
||||
function toolFamily(name: string): ToolFamily {
|
||||
if (CONTENT_SEARCH_TOOLS.has(name)) return "content-search";
|
||||
if (FILE_SEARCH_TOOLS.has(name)) return "file-search";
|
||||
if (LIST_TOOLS.has(name)) return "list";
|
||||
if (READ_TOOLS.has(name)) return "read";
|
||||
if (MEMORY_TOOLS.has(name)) return "memory";
|
||||
return "generic";
|
||||
}
|
||||
|
||||
function safeFields(args: unknown): ToolField[] {
|
||||
if (!args || typeof args !== "object" || Array.isArray(args)) return [];
|
||||
const record = args as Record<string, unknown>;
|
||||
const fields: ToolField[] = [];
|
||||
for (const key of [
|
||||
"query",
|
||||
"pattern",
|
||||
"glob",
|
||||
"path",
|
||||
"file_path",
|
||||
"url",
|
||||
"action",
|
||||
"key",
|
||||
"label",
|
||||
"name",
|
||||
"channel",
|
||||
"chat_id",
|
||||
"session_id",
|
||||
"ui_summary",
|
||||
] as const) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
fields.push({ key, value: value.trim() });
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function aggregateStatus(items: GenericToolRunItem[]): GenericToolStatus {
|
||||
if (items.some((item) => item.status === "error")) return "error";
|
||||
if (items.some((item) => item.status === "running")) return "running";
|
||||
return "done";
|
||||
}
|
||||
|
||||
function activityLabel(
|
||||
family: ToolFamily,
|
||||
status: GenericToolStatus,
|
||||
collected: boolean,
|
||||
name: string,
|
||||
items: GenericToolRunItem[],
|
||||
): string {
|
||||
if (family === "content-search") {
|
||||
return statusCopy(
|
||||
status,
|
||||
collected ? "Reviewing sources" : "Searching files",
|
||||
collected ? "Reviewed sources" : "Searched files",
|
||||
collected ? "Could not review sources" : "Could not search files",
|
||||
);
|
||||
}
|
||||
if (family === "file-search") {
|
||||
return statusCopy(status, "Finding files", "Found files", "Could not find files");
|
||||
}
|
||||
if (family === "list") {
|
||||
return statusCopy(status, "Listing files", "Listed files", "Could not list files");
|
||||
}
|
||||
if (family === "read") {
|
||||
return statusCopy(
|
||||
status,
|
||||
collected ? "Reading source" : "Reading file",
|
||||
collected ? "Read source" : "Read file",
|
||||
collected ? "Could not read source" : "Could not read file",
|
||||
);
|
||||
}
|
||||
if (family === "memory") {
|
||||
return statusCopy(status, "Searching memory", "Searched memory", "Could not search memory");
|
||||
}
|
||||
|
||||
const action = fieldValue(items[0]?.trace, "action").toLowerCase();
|
||||
switch (name) {
|
||||
case "generate_image":
|
||||
return statusCopy(status, "Generating image", "Generated image", "Could not generate image");
|
||||
case "spawn":
|
||||
return statusCopy(status, "Delegating task", "Delegated task", "Could not delegate task");
|
||||
case "message":
|
||||
return statusCopy(status, "Sending message", "Sent message", "Could not send message");
|
||||
case "my":
|
||||
return action === "set" || action === "modify"
|
||||
? statusCopy(status, "Updating agent settings", "Updated agent settings", "Could not update agent settings")
|
||||
: statusCopy(status, "Checking agent settings", "Checked agent settings", "Could not check agent settings");
|
||||
case "cron":
|
||||
if (action === "add") return statusCopy(status, "Scheduling automation", "Scheduled automation", "Could not schedule automation");
|
||||
if (action === "remove") return statusCopy(status, "Removing automation", "Removed automation", "Could not remove automation");
|
||||
return statusCopy(status, "Checking automations", "Checked automations", "Could not check automations");
|
||||
case "create_goal":
|
||||
return statusCopy(status, "Starting long task", "Started long task", "Could not start long task");
|
||||
case "update_goal":
|
||||
return statusCopy(status, "Updating long task", "Updated long task", "Could not update long task");
|
||||
case "write_stdin":
|
||||
return statusCopy(status, "Continuing command", "Continued command", "Could not continue command");
|
||||
case "list_exec_sessions":
|
||||
return statusCopy(status, "Checking running commands", "Checked running commands", "Could not check running commands");
|
||||
case "screenshot":
|
||||
case "capture_screenshot":
|
||||
return statusCopy(status, "Capturing screenshot", "Captured screenshot", "Could not capture screenshot");
|
||||
default: {
|
||||
const humanName = humanizeToolName(name);
|
||||
return statusCopy(
|
||||
status,
|
||||
`Running ${humanName}`,
|
||||
`Completed ${humanName}`,
|
||||
`Could not complete ${humanName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function activityDetail(items: GenericToolRunItem[], family: ToolFamily, name: string): string {
|
||||
if (items.length !== 1) return "";
|
||||
const trace = items[0].trace;
|
||||
if (family === "content-search") {
|
||||
return quote(fieldValue(trace, "query") || fieldValue(trace, "pattern"));
|
||||
}
|
||||
if (family === "file-search") {
|
||||
return compactDetail(
|
||||
fieldValue(trace, "glob")
|
||||
|| fieldValue(trace, "query")
|
||||
|| fieldValue(trace, "pattern")
|
||||
|| fieldValue(trace, "path"),
|
||||
);
|
||||
}
|
||||
if (family === "list" || family === "read") {
|
||||
return compactDetail(fieldValue(trace, "path") || fieldValue(trace, "file_path"));
|
||||
}
|
||||
if (family === "memory") return quote(fieldValue(trace, "query"));
|
||||
|
||||
switch (name) {
|
||||
case "spawn":
|
||||
return safeText(fieldValue(trace, "label"));
|
||||
case "message":
|
||||
return safeText(fieldValue(trace, "channel"));
|
||||
case "my":
|
||||
return safeText(fieldValue(trace, "key"));
|
||||
case "cron":
|
||||
return safeText(fieldValue(trace, "name"));
|
||||
case "create_goal":
|
||||
return safeText(fieldValue(trace, "ui_summary"));
|
||||
case "update_goal":
|
||||
return safeText(fieldValue(trace, "action"));
|
||||
case "write_stdin":
|
||||
return compactIdentifier(fieldValue(trace, "session_id"));
|
||||
case "screenshot":
|
||||
case "capture_screenshot":
|
||||
return "";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function activityAside(items: GenericToolRunItem[], family: ToolFamily): string {
|
||||
const pathCount = uniqueValues(items, ["path", "file_path"]).length;
|
||||
if (pathCount > 1) return `${pathCount} files`;
|
||||
if (items.length <= 1) return "";
|
||||
if (family === "content-search" || family === "file-search" || family === "memory") {
|
||||
return `${items.length} searches`;
|
||||
}
|
||||
return `${items.length} actions`;
|
||||
}
|
||||
|
||||
function fieldValue(trace: GenericToolTrace | undefined, key: ToolField["key"]): string {
|
||||
return trace?.fields.find((field) => field.key === key)?.value ?? "";
|
||||
}
|
||||
|
||||
function uniqueValues(items: GenericToolRunItem[], keys: ToolField["key"][]): string[] {
|
||||
const values = items.flatMap((item) => item.trace.fields)
|
||||
.filter((field) => keys.includes(field.key))
|
||||
.map((field) => field.value);
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
function statusCopy(status: GenericToolStatus, running: string, done: string, failed: string): string {
|
||||
return status === "running" ? running : status === "error" ? failed : done;
|
||||
}
|
||||
|
||||
function compactDetail(value: string): string {
|
||||
return value ? truncateMiddle(compactGenericToolPath(value), 88) : "";
|
||||
}
|
||||
|
||||
function safeText(value: string): string {
|
||||
return value ? truncateMiddle(redactActivityText(value).replace(/\s+/g, " ").trim(), 88) : "";
|
||||
}
|
||||
|
||||
function quote(value: string): string {
|
||||
const safe = safeText(value);
|
||||
return safe ? `“${safe}”` : "";
|
||||
}
|
||||
|
||||
function compactIdentifier(value: string): string {
|
||||
const safe = safeText(value);
|
||||
if (safe.length <= 16) return safe;
|
||||
return `${safe.slice(0, 7)}…${safe.slice(-5)}`;
|
||||
}
|
||||
|
||||
function humanizeToolName(name: string): string {
|
||||
const words = name
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.replace(/[._-]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return words ? `${words[0].toUpperCase()}${words.slice(1)}` : "tool action";
|
||||
}
|
||||
|
||||
function isCollectedSourcePath(value: string): boolean {
|
||||
const normalized = value.replace(/\\/g, "/");
|
||||
return normalized.includes("/.nanobot/tool-results/") || normalized.includes("/nanobot/tool-results/");
|
||||
}
|
||||
|
||||
function truncateMiddle(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) return value;
|
||||
const head = Math.ceil((maxLength - 1) * 0.62);
|
||||
const tail = Math.floor((maxLength - 1) * 0.38);
|
||||
return `${value.slice(0, head)}…${value.slice(-tail)}`;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { safeActivityDetail } from "./activity-text";
|
||||
import { formatCompactWebUrl, parseSafeActivityHttpUrl } from "./web-url";
|
||||
|
||||
export type McpActivityStatus = "running" | "done" | "error";
|
||||
|
||||
export interface McpActivityDescription {
|
||||
action: string;
|
||||
target?: string;
|
||||
}
|
||||
|
||||
export function describeMcpActivity(
|
||||
toolName: string,
|
||||
args: unknown,
|
||||
status: McpActivityStatus,
|
||||
): McpActivityDescription {
|
||||
const name = toolName.toLowerCase();
|
||||
|
||||
if (matches(name, "navigate", "goto", "open_url", "visit")) {
|
||||
return describe(status, "Opening", "Opened", "Could not open", value(args, ["url"]));
|
||||
}
|
||||
if (matches(name, "click", "tap")) {
|
||||
return describe(status, "Clicking", "Clicked", "Could not click", elementTarget(args));
|
||||
}
|
||||
if (matches(name, "type", "fill", "enter_text", "insert_text")) {
|
||||
const target = value(args, ["element", "selector", "ref", "name"]);
|
||||
return describe(status, "Entering text", "Entered text", "Could not enter text", target && `in ${target}`);
|
||||
}
|
||||
if (matches(name, "press_key", "keypress")) {
|
||||
return describe(status, "Pressing", "Pressed", "Could not press", value(args, ["key"]));
|
||||
}
|
||||
if (matches(name, "hover")) {
|
||||
return describe(status, "Hovering over", "Hovered over", "Could not hover over", elementTarget(args));
|
||||
}
|
||||
if (matches(name, "select", "select_option")) {
|
||||
return describe(status, "Selecting", "Selected", "Could not select", elementTarget(args));
|
||||
}
|
||||
if (matches(name, "snapshot", "inspect", "get_page_content", "page_content")) {
|
||||
return describe(status, "Inspecting page", "Inspected page", "Could not inspect page");
|
||||
}
|
||||
if (matches(name, "screenshot", "capture_screenshot")) {
|
||||
return describe(status, "Capturing screenshot", "Captured screenshot", "Could not capture screenshot");
|
||||
}
|
||||
if (matches(name, "wait", "wait_for")) {
|
||||
return describe(status, "Waiting for page", "Waited for page", "Page did not become ready");
|
||||
}
|
||||
if (matches(name, "search", "web_search")) {
|
||||
return describe(status, "Searching", "Searched", "Could not search", value(args, ["query", "q"]));
|
||||
}
|
||||
|
||||
const action = humanizeToolName(toolName);
|
||||
if (status === "running") return { action: `Running ${action}` };
|
||||
if (status === "error") return { action: `${action} failed` };
|
||||
return { action: `${action} completed` };
|
||||
}
|
||||
|
||||
function describe(
|
||||
status: McpActivityStatus,
|
||||
running: string,
|
||||
done: string,
|
||||
failed: string,
|
||||
target?: string,
|
||||
): McpActivityDescription {
|
||||
return {
|
||||
action: status === "running" ? running : status === "error" ? failed : done,
|
||||
target: target ? compactUrl(target) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function matches(name: string, ...actions: string[]): boolean {
|
||||
return actions.some((action) => name === action || name.endsWith(`_${action}`));
|
||||
}
|
||||
|
||||
function elementTarget(args: unknown): string | undefined {
|
||||
return value(args, ["element", "selector", "ref", "name", "text"]);
|
||||
}
|
||||
|
||||
function value(args: unknown, keys: string[]): string | undefined {
|
||||
if (!args || typeof args !== "object" || Array.isArray(args)) return undefined;
|
||||
const record = args as Record<string, unknown>;
|
||||
for (const key of keys) {
|
||||
const candidate = record[key];
|
||||
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
||||
if (typeof candidate === "number" || typeof candidate === "boolean") return String(candidate);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function compactUrl(value: string): string {
|
||||
const url = parseSafeActivityHttpUrl(value);
|
||||
if (url) return formatCompactWebUrl(url);
|
||||
if (/^https?:\/\//i.test(value.trim())) return "Private address";
|
||||
return safeActivityDetail(value, 80);
|
||||
}
|
||||
|
||||
function humanizeToolName(value: string): string {
|
||||
const words = value
|
||||
.replace(/^(?:browser|page|playwright)[_.-]+/i, "")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.replace(/[_.-]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return words ? `${words[0].toUpperCase()}${words.slice(1)}` : "Tool call";
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function compactReasoningPreview(value: string): string {
|
||||
return value
|
||||
.replace(/\[([^\]]+)]\([^)]+\)/g, "$1")
|
||||
.replace(/[*_#`~]+/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import type { GenericToolStatus } from "./generic-tool-model";
|
||||
import { safeActivityDetail, summarizeShellCommand } from "./activity-text";
|
||||
import { presentWebSearchAction } from "./web-search-model";
|
||||
import { displayWebHost, formatCompactWebUrl, parseSafeActivityHttpUrl } from "./web-url";
|
||||
|
||||
export interface TraceDescription {
|
||||
kind: "search" | "tool" | "done" | "trace";
|
||||
label: string;
|
||||
detail: string;
|
||||
icon?: "clock";
|
||||
url?: string;
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export function describeTraceLine(
|
||||
line: string,
|
||||
status: GenericToolStatus,
|
||||
result?: unknown,
|
||||
): TraceDescription {
|
||||
const trimmed = line.trim();
|
||||
const functionMatch = /^([a-zA-Z0-9_.-]+)\((.*)\)$/.exec(trimmed);
|
||||
const name = (functionMatch?.[1] ?? "").toLowerCase().split(".").pop() || "";
|
||||
const args = functionMatch?.[2] ?? "";
|
||||
const parsedUrl = traceUrlFromArgs(args, trimmed);
|
||||
const webDetail = parsedUrl ? formatCompactWebUrl(parsedUrl) : "";
|
||||
const plainWebReadTrace =
|
||||
!!parsedUrl && /\b(fetch(?:ing|ed)?|read(?:ing)?|opened?|opening)\b/i.test(trimmed);
|
||||
|
||||
if (/search/i.test(name)) {
|
||||
const query = traceFieldFromArgs(args, ["query", "q", "text"]) || args || trimmed;
|
||||
return {
|
||||
kind: "search",
|
||||
label: presentWebSearchAction(query, status),
|
||||
detail: "",
|
||||
};
|
||||
}
|
||||
if (/fetch|read|open/i.test(name) || plainWebReadTrace) {
|
||||
const rawTarget = traceFieldFromArgs(args, ["path", "file_path", "url"]) || args || trimmed;
|
||||
const pageTitle = parsedUrl ? webPageTitle(result) : "";
|
||||
return {
|
||||
kind: "tool",
|
||||
label: pageTitle || statusCopy(status, "Reading", "Read", "Could not read"),
|
||||
detail: webDetail || (/^https?:\/\//i.test(rawTarget.trim())
|
||||
? "Private address"
|
||||
: safeActivityDetail(rawTarget)),
|
||||
url: parsedUrl?.href,
|
||||
host: parsedUrl ? displayWebHost(parsedUrl.hostname) : undefined,
|
||||
};
|
||||
}
|
||||
if (isShellTraceName(name)) return describeShellTrace(args, trimmed, status);
|
||||
if (name === "write_file") {
|
||||
return describeFileMutationTrace(args, status, "Writing file", "Wrote file", "Could not write file");
|
||||
}
|
||||
if (name === "edit_file" || name === "apply_patch") {
|
||||
return describeFileMutationTrace(args, status, "Editing file", "Edited file", "Could not edit file");
|
||||
}
|
||||
if (name) {
|
||||
const action = humanizeTraceToolName(name);
|
||||
return {
|
||||
kind: "tool",
|
||||
label: statusCopy(
|
||||
status,
|
||||
`Running ${action}`,
|
||||
`Completed ${action}`,
|
||||
`Could not complete ${action}`,
|
||||
),
|
||||
detail: "",
|
||||
};
|
||||
}
|
||||
if (/done|complete|success/i.test(trimmed)) {
|
||||
return { kind: "done", label: "Completed step", detail: safeActivityDetail(trimmed) };
|
||||
}
|
||||
return {
|
||||
kind: status === "done" ? "done" : "trace",
|
||||
label: statusCopy(status, "Working", "Completed step", "Step failed"),
|
||||
detail: safeActivityDetail(trimmed),
|
||||
};
|
||||
}
|
||||
|
||||
function webPageTitle(result: unknown): string {
|
||||
if (result && typeof result === "object" && !Array.isArray(result)) {
|
||||
const title = (result as Record<string, unknown>).title;
|
||||
if (typeof title === "string") return safeActivityDetail(title);
|
||||
}
|
||||
if (typeof result !== "string") return "";
|
||||
const heading = result.match(/^#\s+(.+)$/m)?.[1]?.trim();
|
||||
return heading ? safeActivityDetail(heading) : "";
|
||||
}
|
||||
|
||||
function describeShellTrace(
|
||||
args: string,
|
||||
fallback: string,
|
||||
status: GenericToolStatus,
|
||||
): TraceDescription {
|
||||
const command = shellCommandFromArgs(args) || fallback;
|
||||
if (/^(?:\/(?:usr\/)?bin\/)?date(?:\s|$)/i.test(command.trim())) {
|
||||
return {
|
||||
kind: "tool",
|
||||
label: statusCopy(
|
||||
status,
|
||||
"Checking current time",
|
||||
"Checked current time",
|
||||
"Could not check current time",
|
||||
),
|
||||
detail: "",
|
||||
icon: "clock",
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "tool",
|
||||
label: statusCopy(status, "Running command", "Ran command", "Command failed"),
|
||||
detail: summarizeShellCommand(command),
|
||||
};
|
||||
}
|
||||
|
||||
function describeFileMutationTrace(
|
||||
args: string,
|
||||
status: GenericToolStatus,
|
||||
running: string,
|
||||
done: string,
|
||||
failed: string,
|
||||
): TraceDescription {
|
||||
const path = traceFieldFromArgs(args, ["path", "file_path"]);
|
||||
return {
|
||||
kind: "tool",
|
||||
label: statusCopy(status, running, done, failed),
|
||||
detail: path ? safeActivityDetail(path) : "",
|
||||
};
|
||||
}
|
||||
|
||||
function statusCopy(
|
||||
status: GenericToolStatus,
|
||||
running: string,
|
||||
done: string,
|
||||
failed: string,
|
||||
): string {
|
||||
return status === "running" ? running : status === "error" ? failed : done;
|
||||
}
|
||||
|
||||
function traceFieldFromArgs(args: string, keys: string[]): string {
|
||||
const compactArgs = args.trim();
|
||||
if (!compactArgs) return "";
|
||||
try {
|
||||
const parsed = JSON.parse(compactArgs) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "";
|
||||
const record = parsed as Record<string, unknown>;
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function isShellTraceName(name: string): boolean {
|
||||
return [
|
||||
"exec",
|
||||
"exec_command",
|
||||
"execute_command",
|
||||
"run_command",
|
||||
"run_shell",
|
||||
"shell",
|
||||
"terminal",
|
||||
"bash",
|
||||
"sh",
|
||||
].includes(name.toLowerCase().split(".").pop() || name.toLowerCase());
|
||||
}
|
||||
|
||||
function shellCommandFromArgs(args: string): string {
|
||||
const compactArgs = args.trim();
|
||||
if (!compactArgs) return "";
|
||||
try {
|
||||
const parsed = JSON.parse(compactArgs) as unknown;
|
||||
if (typeof parsed === "string") return parsed;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return "";
|
||||
const record = parsed as Record<string, unknown>;
|
||||
for (const key of ["command", "cmd", "script", "input"]) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) return value;
|
||||
}
|
||||
} catch {
|
||||
return compactArgs.replace(/^["']|["']$/g, "");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function humanizeTraceToolName(name: string): string {
|
||||
const words = name
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.replace(/[._-]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return words ? `${words[0].toUpperCase()}${words.slice(1)}` : "tool action";
|
||||
}
|
||||
|
||||
function traceUrlFromArgs(args: string, fallback: string): URL | null {
|
||||
const candidates: string[] = [];
|
||||
const compactArgs = args.trim();
|
||||
if (compactArgs) {
|
||||
try {
|
||||
collectUrlCandidates(JSON.parse(compactArgs), candidates);
|
||||
} catch {
|
||||
candidates.push(compactArgs.replace(/^["']|["']$/g, ""));
|
||||
}
|
||||
}
|
||||
candidates.push(fallback);
|
||||
for (const candidate of candidates) {
|
||||
const url = parseSafeActivityHttpUrl(candidate);
|
||||
if (url) return url;
|
||||
const embedded = candidate.match(/https?:\/\/[^\s"'<>),]+/i)?.[0];
|
||||
if (embedded) {
|
||||
const embeddedUrl = parseSafeActivityHttpUrl(embedded);
|
||||
if (embeddedUrl) return embeddedUrl;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectUrlCandidates(value: unknown, candidates: string[]) {
|
||||
if (typeof value === "string") {
|
||||
candidates.push(value);
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== "object") return;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value.slice(0, 6)) collectUrlCandidates(item, candidates);
|
||||
return;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const key of ["url", "uri", "href", "link"]) {
|
||||
if (typeof record[key] === "string") candidates.push(record[key]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { canonicalToolTrace, formatToolCallTrace } from "@/lib/tool-traces";
|
||||
import type { ToolProgressEvent } from "@/lib/types";
|
||||
|
||||
import { redactActivityText, safeActivityDetail } from "./activity-text";
|
||||
import { displayWebHost, formatCompactWebUrl, parseSafeActivityHttpUrl } from "./web-url";
|
||||
|
||||
export type WebSearchStatus = "running" | "done" | "error";
|
||||
|
||||
export interface WebSearchSource {
|
||||
title: string;
|
||||
href: string;
|
||||
host: string;
|
||||
displayUrl: string;
|
||||
}
|
||||
|
||||
export interface WebSearchRunModel {
|
||||
key: string;
|
||||
query: string;
|
||||
status: WebSearchStatus;
|
||||
sources: WebSearchSource[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface WebSearchQueryPresentation {
|
||||
query: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
const WEB_SEARCH_STATUS_RANK: Record<WebSearchStatus, number> = {
|
||||
running: 1,
|
||||
done: 2,
|
||||
error: 3,
|
||||
};
|
||||
const MAX_VISIBLE_SOURCES = 8;
|
||||
|
||||
export function webSearchRunsByTraceLine(
|
||||
events: ToolProgressEvent[],
|
||||
): Map<string, WebSearchRunModel> {
|
||||
const runs = new Map<string, WebSearchRunModel>();
|
||||
for (const event of events) {
|
||||
const run = webSearchRunFromEvent(event);
|
||||
const line = run ? formatToolCallTrace(event) : null;
|
||||
if (!run || !line) continue;
|
||||
const key = canonicalToolTrace(line);
|
||||
runs.set(key, mergeWebSearchRun(runs.get(key), run));
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
function webSearchRunFromEvent(event: ToolProgressEvent): WebSearchRunModel | null {
|
||||
const name = compactToolName(toolEventName(event));
|
||||
if (name !== "web_search") return null;
|
||||
|
||||
const args = toolEventArguments(event);
|
||||
const query = stringField(args, ["query", "q", "text"]);
|
||||
const status: WebSearchStatus = event.phase === "error"
|
||||
? "error"
|
||||
: event.phase === "end"
|
||||
? "done"
|
||||
: "running";
|
||||
|
||||
return {
|
||||
key: event.call_id ? `call:${event.call_id}` : formatToolCallTrace(event) ?? `web_search:${query}`,
|
||||
query,
|
||||
status,
|
||||
sources: status === "done" ? webSearchSources(event.result) : [],
|
||||
error: status === "error" ? readableError(event.error) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function presentWebSearchQuery(query: string): WebSearchQueryPresentation {
|
||||
const scopes: string[] = [];
|
||||
const safeQuery = redactActivityText(query);
|
||||
const cleanQuery = safeQuery
|
||||
.replace(/(?:^|\s)site:([^\s]+)/gi, (_match, rawSite: string) => {
|
||||
const scope = webSearchScope(rawSite);
|
||||
if (scope && !scopes.includes(scope)) scopes.push(scope);
|
||||
return " ";
|
||||
})
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
return {
|
||||
query: cleanQuery || safeQuery.trim(),
|
||||
...(scopes.length === 1 ? { scope: scopes[0] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function presentWebSearchAction(
|
||||
query: string,
|
||||
status: WebSearchStatus,
|
||||
): string {
|
||||
const presentation = presentWebSearchQuery(query);
|
||||
const verb = status === "error"
|
||||
? "Could not search"
|
||||
: status === "running"
|
||||
? "Searching"
|
||||
: "Searched";
|
||||
const target = [presentation.scope, presentation.query].filter(Boolean).join(" · ");
|
||||
return target ? `${verb} ${target}` : verb;
|
||||
}
|
||||
|
||||
function mergeWebSearchRun(
|
||||
existing: WebSearchRunModel | undefined,
|
||||
incoming: WebSearchRunModel,
|
||||
): WebSearchRunModel {
|
||||
if (!existing) return incoming;
|
||||
if (WEB_SEARCH_STATUS_RANK[incoming.status] < WEB_SEARCH_STATUS_RANK[existing.status]) {
|
||||
return existing;
|
||||
}
|
||||
return {
|
||||
...existing,
|
||||
...incoming,
|
||||
query: incoming.query || existing.query,
|
||||
sources: incoming.sources.length ? incoming.sources : existing.sources,
|
||||
};
|
||||
}
|
||||
|
||||
function webSearchSources(result: unknown): WebSearchSource[] {
|
||||
const candidates = structuredCandidates(result);
|
||||
if (typeof result === "string") candidates.push(...textCandidates(result));
|
||||
if (result && typeof result === "object" && !Array.isArray(result)) {
|
||||
const record = result as Record<string, unknown>;
|
||||
for (const key of ["content", "text", "result"]) {
|
||||
if (typeof record[key] === "string") candidates.push(...textCandidates(record[key]));
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const sources: WebSearchSource[] = [];
|
||||
for (const candidate of candidates) {
|
||||
const url = parseSafeActivityHttpUrl(candidate.url);
|
||||
if (!url || seen.has(url.href)) continue;
|
||||
seen.add(url.href);
|
||||
sources.push({
|
||||
title: cleanTitle(candidate.title) || displayWebHost(url.hostname),
|
||||
href: url.href,
|
||||
host: displayWebHost(url.hostname),
|
||||
displayUrl: formatCompactWebUrl(url),
|
||||
});
|
||||
if (sources.length >= MAX_VISIBLE_SOURCES) break;
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
function structuredCandidates(value: unknown): Array<{ title: string; url: string }> {
|
||||
const items: unknown[] = [];
|
||||
if (Array.isArray(value)) items.push(...value);
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const key of ["results", "items", "sources", "data"]) {
|
||||
if (Array.isArray(record[key])) items.push(...record[key]);
|
||||
}
|
||||
}
|
||||
|
||||
return items.flatMap((item) => {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) return [];
|
||||
const record = item as Record<string, unknown>;
|
||||
const title = stringField(record, ["title", "name", "label"]);
|
||||
const url = stringField(record, ["url", "href", "link", "uri"]);
|
||||
return url ? [{ title, url }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function textCandidates(text: string): Array<{ title: string; url: string }> {
|
||||
const lines = text.split(/\r?\n/).map((line) => line.trim());
|
||||
const candidates: Array<{ title: string; url: string }> = [];
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
if (!line) continue;
|
||||
|
||||
const markdownLink = /^\s*(?:\d+[.)]\s*)?\[([^\]]+)]\((https?:\/\/[^)]+)\)\s*$/.exec(line);
|
||||
if (markdownLink) {
|
||||
candidates.push({ title: markdownLink[1], url: markdownLink[2] });
|
||||
continue;
|
||||
}
|
||||
|
||||
const numberedTitle = /^\d+[.)]\s+(.+)$/.exec(line);
|
||||
if (!numberedTitle) continue;
|
||||
|
||||
const inlineUrl = firstHttpUrl(numberedTitle[1]);
|
||||
if (inlineUrl) {
|
||||
candidates.push({
|
||||
title: numberedTitle[1].replace(inlineUrl, "").replace(/[\s:|\-–—]+$/, ""),
|
||||
url: inlineUrl,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let next = index + 1; next < lines.length; next += 1) {
|
||||
if (/^\d+[.)]\s+/.test(lines[next])) break;
|
||||
const url = firstHttpUrl(lines[next]);
|
||||
if (!url) continue;
|
||||
candidates.push({ title: numberedTitle[1], url });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function firstHttpUrl(value: string): string {
|
||||
return value.match(/https?:\/\/[^\s<>"']+/i)?.[0]?.replace(/[),.;\]}]+$/, "") ?? "";
|
||||
}
|
||||
|
||||
function cleanTitle(value: string): string {
|
||||
return redactActivityText(value)
|
||||
.replace(/^#+\s*/, "")
|
||||
.replace(/^\*\*(.*)\*\*$/, "$1")
|
||||
.replace(/^__(.*)__$/, "$1")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function compactToolName(name: string): string {
|
||||
return name.toLowerCase().split(".").pop() || name.toLowerCase();
|
||||
}
|
||||
|
||||
function webSearchScope(rawSite: string): string | undefined {
|
||||
const candidate = rawSite.replace(/^https?:\/\//i, "").replace(/^www\./i, "");
|
||||
let host = candidate.split("/")[0]?.toLowerCase();
|
||||
if (!host) return undefined;
|
||||
if (host.startsWith("www.")) host = host.slice(4);
|
||||
|
||||
const knownScope = WEB_SEARCH_SCOPE_NAMES[host];
|
||||
return knownScope ?? displayWebHost(host);
|
||||
}
|
||||
|
||||
const WEB_SEARCH_SCOPE_NAMES: Record<string, string> = {
|
||||
"anthropic.com": "Anthropic",
|
||||
"crunchbase.com": "Crunchbase",
|
||||
"github.com": "GitHub",
|
||||
"linkedin.com": "LinkedIn",
|
||||
"openai.com": "OpenAI",
|
||||
"reddit.com": "Reddit",
|
||||
"x.com": "X",
|
||||
"youtube.com": "YouTube",
|
||||
};
|
||||
|
||||
function toolEventName(event: ToolProgressEvent): string {
|
||||
const functionName = (event as { function?: { name?: unknown } }).function?.name;
|
||||
if (typeof functionName === "string") return functionName;
|
||||
return typeof event.name === "string" ? event.name : "";
|
||||
}
|
||||
|
||||
function toolEventArguments(event: ToolProgressEvent): unknown {
|
||||
const functionArgs = (event as { function?: { arguments?: unknown } }).function?.arguments;
|
||||
const raw = functionArgs ?? event.arguments;
|
||||
if (typeof raw !== "string") return raw ?? {};
|
||||
try {
|
||||
return raw.trim() ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function stringField(value: unknown, keys: string[]): string {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const key of keys) {
|
||||
const field = record[key];
|
||||
if (typeof field === "string" && field.trim()) return field.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function readableError(error: unknown): string | undefined {
|
||||
if (typeof error === "string" && error.trim()) return safeErrorText(error);
|
||||
if (!error) return undefined;
|
||||
try {
|
||||
return safeErrorText(JSON.stringify(error));
|
||||
} catch {
|
||||
return "Web search failed";
|
||||
}
|
||||
}
|
||||
|
||||
function safeErrorText(value: string): string {
|
||||
return safeActivityDetail(value, 240);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
export function parsePublicHttpUrl(value: string): URL | null {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
if (url.username || url.password) return null;
|
||||
if (isPrivateHostname(url.hostname)) return null;
|
||||
return url;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Public URL normalized for timeline display, with credentials and request-specific noise removed. */
|
||||
export function parseSafeActivityHttpUrl(value: string): URL | null {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
if (isPrivateHostname(url.hostname)) return null;
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function displayWebHost(hostname: string): string {
|
||||
return hostname.replace(/^www\./i, "").toLowerCase();
|
||||
}
|
||||
|
||||
export function formatCompactWebUrl(url: URL): string {
|
||||
const host = displayWebHost(url.hostname);
|
||||
const path = url.pathname && url.pathname !== "/" ? url.pathname.replace(/\/$/, "") : "";
|
||||
return `${host}${path}`;
|
||||
}
|
||||
|
||||
function isPrivateHostname(hostname: string): boolean {
|
||||
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
if (
|
||||
!host
|
||||
|| host === "localhost"
|
||||
|| [".local", ".localhost", ".internal", ".home", ".lan"].some((suffix) => host.endsWith(suffix))
|
||||
) return true;
|
||||
if (!host.includes(".") && !host.includes(":")) return true;
|
||||
|
||||
const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
||||
if (ipv4) {
|
||||
const [, aText, bText] = ipv4;
|
||||
const a = Number(aText);
|
||||
const b = Number(bText);
|
||||
return (
|
||||
a === 0 ||
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
(a === 100 && b >= 64 && b <= 127) ||
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 168)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
host === "::"
|
||||
|| host === "::1"
|
||||
|| host.startsWith("::ffff:")
|
||||
|| host.startsWith("fc")
|
||||
|| host.startsWith("fd")
|
||||
|| host.startsWith("fe80:")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user