feat: expand intelligent investigation workspace
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent ddd03dbff6
commit c0e4231b83
223 changed files with 13155 additions and 5304 deletions
-559
View File
@@ -1,559 +0,0 @@
import { Button } from "@/shared/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import { cn } from "@/shared/ui/cn";
import { CheckIcon, CopyIcon } from "lucide-react";
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type {
BundledLanguage,
BundledTheme,
HighlighterGeneric,
ThemedToken,
} from "shiki";
import { createHighlighter } from "shiki";
// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
// oxlint-disable-next-line eslint(no-bitwise)
const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;
// oxlint-disable-next-line eslint(no-bitwise)
const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;
const isUnderline = (fontStyle: number | undefined) =>
// oxlint-disable-next-line eslint(no-bitwise)
fontStyle && fontStyle & 4;
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
interface KeyedToken {
token: ThemedToken;
key: string;
}
interface KeyedLine {
tokens: KeyedToken[];
key: string;
}
const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
lines.map((line, lineIdx) => ({
key: `line-${lineIdx}`,
tokens: line.map((token, tokenIdx) => ({
key: `line-${lineIdx}-${tokenIdx}`,
token,
})),
}));
// Token rendering component
const TokenSpan = ({ token }: { token: ThemedToken }) => (
<span
className="dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)]"
style={
{
backgroundColor: token.bgColor,
color: token.color,
fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
...token.htmlStyle,
} as CSSProperties
}
>
{token.content}
</span>
);
// Line number styles using CSS counters
const LINE_NUMBER_CLASSES = cn(
"block",
"before:content-[counter(line)]",
"before:inline-block",
"before:[counter-increment:line]",
"before:w-8",
"before:mr-4",
"before:text-right",
"before:text-muted-foreground/50",
"before:font-mono",
"before:select-none"
);
// Line rendering component
const LineSpan = ({
keyedLine,
showLineNumbers,
}: {
keyedLine: KeyedLine;
showLineNumbers: boolean;
}) => (
<span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
{keyedLine.tokens.length === 0
? "\n"
: keyedLine.tokens.map(({ token, key }) => (
<TokenSpan key={key} token={token} />
))}
</span>
);
// Types
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
};
interface TokenizedCode {
tokens: ThemedToken[][];
fg: string;
bg: string;
}
interface CodeBlockContextType {
code: string;
}
// Context
const CodeBlockContext = createContext<CodeBlockContextType>({
code: "",
});
// Highlighter cache (singleton per language)
const highlighterCache = new Map<
string,
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
>();
// Token cache
const tokensCache = new Map<string, TokenizedCode>();
// Subscribers for async token updates
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
const start = code.slice(0, 100);
const end = code.length > 100 ? code.slice(-100) : "";
return `${language}:${code.length}:${start}:${end}`;
};
const getHighlighter = (
language: BundledLanguage
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
const cached = highlighterCache.get(language);
if (cached) {
return cached;
}
const highlighterPromise = createHighlighter({
langs: [language],
themes: ["github-light", "github-dark"],
});
highlighterCache.set(language, highlighterPromise);
return highlighterPromise;
};
// Create raw tokens for immediate display while highlighting loads
const createRawTokens = (code: string): TokenizedCode => ({
bg: "transparent",
fg: "inherit",
tokens: code.split("\n").map((line) =>
line === ""
? []
: [
{
color: "inherit",
content: line,
} as ThemedToken,
]
),
});
// Synchronous highlight with callback for async results
const highlightCode = (
code: string,
language: BundledLanguage,
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
callback?: (result: TokenizedCode) => void
): TokenizedCode | null => {
const tokensCacheKey = getTokensCacheKey(code, language);
// Return cached result if available
const cached = tokensCache.get(tokensCacheKey);
if (cached) {
return cached;
}
// Subscribe callback if provided
if (callback) {
if (!subscribers.has(tokensCacheKey)) {
subscribers.set(tokensCacheKey, new Set());
}
subscribers.get(tokensCacheKey)?.add(callback);
}
// Start highlighting in background - fire-and-forget async pattern
getHighlighter(language)
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
.then((highlighter) => {
const availableLangs = highlighter.getLoadedLanguages();
const langToUse = availableLangs.includes(language) ? language : "text";
const result = highlighter.codeToTokens(code, {
lang: langToUse,
themes: {
dark: "github-dark",
light: "github-light",
},
});
const tokenized: TokenizedCode = {
bg: result.bg ?? "transparent",
fg: result.fg ?? "inherit",
tokens: result.tokens,
};
// Cache the result
tokensCache.set(tokensCacheKey, tokenized);
// Notify all subscribers
const subs = subscribers.get(tokensCacheKey);
if (subs) {
for (const sub of subs) {
sub(tokenized);
}
subscribers.delete(tokensCacheKey);
}
})
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks)
.catch((error) => {
console.error("Failed to highlight code:", error);
subscribers.delete(tokensCacheKey);
});
return null;
};
const CodeBlockBody = memo(
({
tokenized,
showLineNumbers,
className,
}: {
tokenized: TokenizedCode;
showLineNumbers: boolean;
className?: string;
}) => {
const preStyle = useMemo(
() => ({
backgroundColor: tokenized.bg,
color: tokenized.fg,
}),
[tokenized.bg, tokenized.fg]
);
const keyedLines = useMemo(
() => addKeysToTokens(tokenized.tokens),
[tokenized.tokens]
);
return (
<pre
className={cn(
"dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)] m-0 p-4 text-sm",
className
)}
style={preStyle}
>
<code
className={cn(
"font-mono text-sm",
showLineNumbers && "[counter-increment:line_0] [counter-reset:line]"
)}
>
{keyedLines.map((keyedLine) => (
<LineSpan
key={keyedLine.key}
keyedLine={keyedLine}
showLineNumbers={showLineNumbers}
/>
))}
</code>
</pre>
);
},
(prevProps, nextProps) =>
prevProps.tokenized === nextProps.tokenized &&
prevProps.showLineNumbers === nextProps.showLineNumbers &&
prevProps.className === nextProps.className
);
CodeBlockBody.displayName = "CodeBlockBody";
export const CodeBlockContainer = ({
className,
language,
style,
...props
}: HTMLAttributes<HTMLDivElement> & { language: string }) => (
<div
className={cn(
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
className
)}
data-language={language}
style={{
containIntrinsicSize: "auto 200px",
contentVisibility: "auto",
...style,
}}
{...props}
/>
);
export const CodeBlockHeader = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs",
className
)}
{...props}
>
{children}
</div>
);
export const CodeBlockTitle = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex items-center gap-2", className)} {...props}>
{children}
</div>
);
export const CodeBlockFilename = ({
children,
className,
...props
}: HTMLAttributes<HTMLSpanElement>) => (
<span className={cn("font-mono", className)} {...props}>
{children}
</span>
);
export const CodeBlockActions = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("-my-1 -mr-1 flex items-center gap-2", className)}
{...props}
>
{children}
</div>
);
export const CodeBlockContent = ({
code,
language,
showLineNumbers = false,
}: {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
}) => {
// Memoized raw tokens for immediate display
const rawTokens = useMemo(() => createRawTokens(code), [code]);
// Synchronous cache lookup — avoids setState in effect for cached results
const syncTokens = useMemo(
() => highlightCode(code, language) ?? rawTokens,
[code, language, rawTokens]
);
// Async highlighting result (populated after shiki loads)
const [asyncTokens, setAsyncTokens] = useState<TokenizedCode | null>(null);
const asyncKeyRef = useRef({ code, language });
// Invalidate stale async tokens synchronously during render
if (
asyncKeyRef.current.code !== code ||
asyncKeyRef.current.language !== language
) {
asyncKeyRef.current = { code, language };
setAsyncTokens(null);
}
useEffect(() => {
let cancelled = false;
highlightCode(code, language, (result) => {
if (!cancelled) {
setAsyncTokens(result);
}
});
return () => {
cancelled = true;
};
}, [code, language]);
const tokenized = asyncTokens ?? syncTokens;
return (
<div className="relative overflow-auto">
<CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
</div>
);
};
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => {
const contextValue = useMemo(() => ({ code }), [code]);
return (
<CodeBlockContext.Provider value={contextValue}>
<CodeBlockContainer className={className} language={language} {...props}>
{children}
<CodeBlockContent
code={code}
language={language}
showLineNumbers={showLineNumbers}
/>
</CodeBlockContainer>
</CodeBlockContext.Provider>
);
};
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const CodeBlockCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
const { code } = useContext(CodeBlockContext);
const copyToClipboard = useCallback(async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
if (!isCopied) {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
timeoutRef.current = window.setTimeout(
() => setIsCopied(false),
timeout
);
}
} catch (error) {
onError?.(error as Error);
}
}, [code, onCopy, onError, timeout, isCopied]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[]
);
const Icon = isCopied ? CheckIcon : CopyIcon;
return (
<Button
className={cn("shrink-0", className)}
onClick={copyToClipboard}
size="icon"
variant="ghost"
{...props}
>
{children ?? <Icon size={14} />}
</Button>
);
};
export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
export const CodeBlockLanguageSelector = (
props: CodeBlockLanguageSelectorProps
) => <Select {...props} />;
export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
typeof SelectTrigger
>;
export const CodeBlockLanguageSelectorTrigger = ({
className,
...props
}: CodeBlockLanguageSelectorTriggerProps) => (
<SelectTrigger
className={cn(
"form-field-inset h-7 border-none bg-transparent px-2 text-xs shadow-none",
className
)}
{...props}
/>
);
export type CodeBlockLanguageSelectorValueProps = ComponentProps<
typeof SelectValue
>;
export const CodeBlockLanguageSelectorValue = (
props: CodeBlockLanguageSelectorValueProps
) => <SelectValue {...props} />;
export type CodeBlockLanguageSelectorContentProps = ComponentProps<
typeof SelectContent
>;
export const CodeBlockLanguageSelectorContent = ({
align = "end",
...props
}: CodeBlockLanguageSelectorContentProps) => (
<SelectContent align={align} {...props} />
);
export type CodeBlockLanguageSelectorItemProps = ComponentProps<
typeof SelectItem
>;
export const CodeBlockLanguageSelectorItem = (
props: CodeBlockLanguageSelectorItemProps
) => <SelectItem {...props} />;
@@ -0,0 +1,54 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ReadableMarkdownDiagram, ReadableMarkdownTable } from "./markdown-reading";
afterEach(() => vi.restoreAllMocks());
describe("readable Markdown tables", () => {
const rows = <><thead><tr><th>对象</th><th>压力(MPa)</th></tr></thead><tbody><tr><td>北区入口</td><td id="pressure-value">0.2130</td></tr></tbody></>;
it("keeps the original table visible while expanded, avoids duplicate IDs and returns keyboard focus", async () => {
const user = userEvent.setup();
const { container } = render(<ReadableMarkdownTable>{rows}</ReadableMarkdownTable>);
const original = within(container).getByRole("table");
const trigger = screen.getByRole("button", { name: "放大查看表格" });
expect(trigger).toHaveTextContent("");
await user.click(trigger);
const dialog = screen.getByRole("dialog", { name: "表格" });
expect(within(dialog).getByRole("table")).toHaveTextContent("0.2130");
expect(original).toBeVisible();
expect(original).toHaveTextContent("0.2130");
expect(document.querySelectorAll("#pressure-value")).toHaveLength(1);
await user.click(within(dialog).getByRole("button", { name: "复制表格" }));
expect(await navigator.clipboard.readText()).toBe("对象\t压力(MPa)\n北区入口\t0.2130");
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
expect(original).toBeVisible();
});
it("downloads the displayed values without converting their precision", async () => {
const user = userEvent.setup();
const urls = { createObjectURL: URL.createObjectURL, revokeObjectURL: URL.revokeObjectURL };
let saved: Blob | undefined;
URL.createObjectURL = vi.fn((blob: Blob) => { saved = blob; return "blob:table-test"; });
URL.revokeObjectURL = vi.fn();
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
try {
render(<ReadableMarkdownTable>{rows}</ReadableMarkdownTable>);
await user.click(screen.getByRole("button", { name: "下载表格" }));
expect(click).toHaveBeenCalledTimes(1);
expect(saved).toBeDefined();
// Blob keeps the original textual readout, including trailing zeroes.
const reader = new FileReader();
const content = await new Promise<string>((resolve) => { reader.onload = () => resolve(String(reader.result)); reader.readAsText(saved!); });
expect(content).toContain("北区入口,0.2130");
} finally { URL.createObjectURL = urls.createObjectURL; URL.revokeObjectURL = urls.revokeObjectURL; }
});
});
it("defers incomplete Mermaid source and provides an accessible icon-only expansion entry", () => {
render(<ReadableMarkdownDiagram code="flowchart TD\nA[未完成" isIncomplete language="mermaid" />);
expect(screen.getByText("图示仍在生成…")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "放大查看图示" })).toHaveTextContent("");
expect(screen.queryByRole("img")).not.toBeInTheDocument();
});
+121
View File
@@ -0,0 +1,121 @@
import { createPortal } from "react-dom";
import { Children, cloneElement, isValidElement, useCallback, useContext, useRef, useState, type ComponentProps, type ReactNode } from "react";
import { Streamdown, StreamdownContext, extractTableDataFromElement, tableDataToCSV, tableDataToTSV, type CustomRendererProps } from "streamdown";
import { mermaid } from "@streamdown/mermaid";
import { Check, Copy, Download, Maximize2, Table2, Workflow, X } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "@/shared/ui/dialog";
import { createReadingPreview } from "@/shared/ui/reading-preview";
import { copySelectedText } from "./paragraph-copy";
/** Move the existing renderer into a bounded reader. Pan, selection and DOM state survive. */
function MarkdownReading({ title, kind, children, expandedChildren, toolbar }: {
title: string;
kind: "table" | "diagram";
children: ReactNode;
expandedChildren?: ReactNode;
toolbar?: (host: HTMLDivElement) => ReactNode;
}) {
const [open, setOpen] = useState(false);
const [host] = useState(() => document.createElement("div"));
const inline = useRef<HTMLDivElement | null>(null);
const snapshot = useRef<HTMLElement | null>(null);
const { isAnimating } = useContext(StreamdownContext);
const attachInline = useCallback((slot: HTMLDivElement | null) => {
inline.current = slot;
if (slot && !host.isConnected) slot.append(host);
}, [host]);
const attachExpanded = useCallback((slot: HTMLDivElement | null) => {
if (!inline.current) return;
if (slot) {
if (kind === "diagram") {
snapshot.current?.remove();
snapshot.current = createReadingPreview(host);
inline.current.append(snapshot.current);
}
slot.append(host);
} else {
snapshot.current?.remove();
snapshot.current = null;
inline.current.append(host);
}
}, [host, kind]);
const Icon = kind === "table" ? Table2 : Workflow;
return <Dialog open={open} onOpenChange={setOpen}>
<section className="markdown-reading-block" data-streamdown={kind === "table" ? "table-wrapper" : undefined} data-markdown-diagram={kind === "diagram" || undefined}>
<header className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-3 py-2">
<span className="flex items-center gap-2 text-xs font-medium text-muted-foreground"><Icon size={16} aria-hidden />{title}</span>
<div className="flex items-center gap-1">
{toolbar?.(host)}
<DialogTrigger asChild><Button variant="ghost" size="icon-sm" disabled={isAnimating} aria-label={`放大查看${title}`} title={`放大查看${title}`}><Maximize2 size={15} aria-hidden /></Button></DialogTrigger>
</div>
</header>
{kind === "table" ? <div className="min-w-0">{children}</div> : <div ref={attachInline} className="min-w-0" />}
</section>
{createPortal(expandedChildren ?? children, host)}
<DialogContent onCopy={copySelectedText} className="markdown-reading-window motion-reduce:animate-none" data-reading-kind={kind} overlayClassName="markdown-reading-backdrop motion-reduce:animate-none" data-agent-theme="chat" showCloseButton={false}
{...(kind === "table" ? { "aria-describedby": undefined } : {})}
onEscapeKeyDown={(event) => { event.preventDefault(); event.stopPropagation(); setOpen(false); }}>
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
<div className="min-w-0"><DialogTitle className="text-base leading-6 tracking-normal">{title}</DialogTitle>
{kind === "diagram" ? <DialogDescription className="mt-0.5 text-xs">可缩放和平移查看图中关系。</DialogDescription> : null}
</div>
<div className="flex shrink-0 items-center gap-1">{toolbar?.(host)}<DialogClose asChild><Button variant="ghost" size="icon-sm" aria-label="关闭放大查看"><X size={17} aria-hidden /></Button></DialogClose></div>
</header>
<div className="markdown-reading-scroll min-h-0 flex-1 overflow-auto overscroll-contain p-3 md:p-5" data-markdown-reading-scroll>
<div ref={kind === "table" ? (slot) => { if (slot) slot.append(host); } : attachExpanded} className="min-w-0" />
</div>
</DialogContent>
</Dialog>;
}
function MarkdownTableActions({ host }: { host: HTMLDivElement }) {
const [copied, setCopied] = useState(false);
const [error, setError] = useState(false);
const getData = () => {
const table = host.querySelector("table");
return table ? extractTableDataFromElement(table) : null;
};
return <><Button type="button" variant="ghost" size="icon-sm" aria-label={copied ? "表格已复制" : "复制表格"} onClick={async () => {
const data = getData();
if (!data) return;
try { await navigator.clipboard.writeText(tableDataToTSV(data)); setCopied(true); setError(false); }
catch { setError(true); }
}}>{copied ? <Check size={15} aria-hidden /> : <Copy size={15} aria-hidden />}</Button>
<Button type="button" variant="ghost" size="icon-sm" aria-label="下载表格" onClick={() => {
const data = getData();
if (!data) return;
const url = URL.createObjectURL(new Blob(["\uFEFF", tableDataToCSV(data)], { type: "text/csv;charset=utf-8" }));
const anchor = document.createElement("a"); anchor.href = url; anchor.download = "table.csv"; anchor.click();
const revoke = URL.revokeObjectURL.bind(URL);
setTimeout(() => revoke(url), 1000);
}}><Download size={15} aria-hidden /></Button>
{error ? <span role="status" className="text-xs text-destructive">复制失败,请重试</span> : null}
</>;
}
export function ReadableMarkdownTable({ children, className, node: _node, ...props }: ComponentProps<"table"> & { node?: unknown }) {
const table = <div className="markdown-table-scroll" role="region" aria-label="表格数据" tabIndex={0}>
<table {...props} className={className} data-streamdown="table">{children}</table>
</div>;
return <MarkdownReading title="表格" kind="table" expandedChildren={withoutExplicitIds(table)} toolbar={(host) => <MarkdownTableActions host={host} />}>
{table}
</MarkdownReading>;
}
function withoutExplicitIds(children: ReactNode): ReactNode {
return Children.map(children, (child) => {
if (!isValidElement<{ id?: string; children?: ReactNode }>(child)) return child;
return cloneElement(child, { id: undefined, ...(child.props.children !== undefined ? { children: withoutExplicitIds(child.props.children) } : {}) });
});
}
export function ReadableMarkdownDiagram({ code, isIncomplete }: CustomRendererProps) {
return <MarkdownReading title="图示" kind="diagram">
{isIncomplete ? <p className="p-4 text-sm text-muted-foreground">图示仍在生成…</p> : <Streamdown
className="markdown-diagram-renderer" plugins={{ mermaid }}
mermaid={{ config: { securityLevel: "strict", theme: "neutral", fontFamily: '"Noto Sans SC Variable", "PingFang SC", sans-serif', themeVariables: { fontSize: "18px" }, flowchart: { useMaxWidth: false, padding: 20 } } }}
controls={{ mermaid: { fullscreen: false, panZoom: true, copy: true, download: true } }}
>{`\`\`\`mermaid\n${code}\n\`\`\``}</Streamdown>}
</MarkdownReading>;
}
+27 -11
View File
@@ -1,3 +1,4 @@
import { copySelectedParagraph, copySelectedText } from "./paragraph-copy";
import { Button } from "@/shared/ui/button";
import {
ButtonGroup,
@@ -16,7 +17,7 @@ import { createMathPlugin } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import type { ClipboardEvent, ComponentProps, HTMLAttributes, ReactElement } from "react";
import {
createContext,
memo,
@@ -326,17 +327,32 @@ const streamdownPlugins = {
mermaid,
};
function copyResponseParagraph(event: ClipboardEvent<HTMLElement>) {
const selection = event.currentTarget.ownerDocument.defaultView?.getSelection();
const anchor = selection?.rangeCount ? selection.getRangeAt(0).startContainer : null;
const element = anchor instanceof Element ? anchor : anchor?.parentElement;
const paragraph = element?.closest("p");
// Keep native serialization for multi-block content, hard breaks, tables,
// diagrams and code, whose visible text differs from DOM Range text.
if (!paragraph || !event.currentTarget.contains(paragraph) ||
paragraph.closest("pre, table, [data-streamdown=mermaid]") ||
paragraph.querySelector("br, img, math, svg")) return;
copySelectedParagraph(event, paragraph, true);
}
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
animated
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
plugins={streamdownPlugins}
{...props}
/>
({ className, plugins, ...props }: MessageResponseProps) => (
<div className="contents" onCopy={(event) => { copyResponseParagraph(event); copySelectedText(event); }}>
<Streamdown
animated
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
plugins={{ ...streamdownPlugins, ...plugins }}
{...props}
/>
</div>
),
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
-145
View File
@@ -1,145 +0,0 @@
import { Button } from "@/shared/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/shared/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/shared/ui/collapsible";
import { cn } from "@/shared/ui/cn";
import { ChevronsUpDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { createContext, useContext, useMemo } from "react";
import { Shimmer } from "./shimmer";
interface PlanContextValue {
isStreaming: boolean;
}
const PlanContext = createContext<PlanContextValue | null>(null);
const usePlan = () => {
const context = useContext(PlanContext);
if (!context) {
throw new Error("Plan components must be used within Plan");
}
return context;
};
export type PlanProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean;
};
export const Plan = ({
className,
isStreaming = false,
children,
...props
}: PlanProps) => {
const contextValue = useMemo(() => ({ isStreaming }), [isStreaming]);
return (
<PlanContext.Provider value={contextValue}>
<Collapsible asChild data-slot="plan" {...props}>
<Card className={cn("shadow-none", className)}>{children}</Card>
</Collapsible>
</PlanContext.Provider>
);
};
export type PlanHeaderProps = ComponentProps<typeof CardHeader>;
export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
<CardHeader
className={cn("flex items-start justify-between", className)}
data-slot="plan-header"
{...props}
/>
);
export type PlanTitleProps = Omit<
ComponentProps<typeof CardTitle>,
"children"
> & {
children: string;
};
export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
const { isStreaming } = usePlan();
return (
<CardTitle data-slot="plan-title" {...props}>
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
</CardTitle>
);
};
export type PlanDescriptionProps = Omit<
ComponentProps<typeof CardDescription>,
"children"
> & {
children: string;
};
export const PlanDescription = ({
className,
children,
...props
}: PlanDescriptionProps) => {
const { isStreaming } = usePlan();
return (
<CardDescription
className={cn("text-balance", className)}
data-slot="plan-description"
{...props}
>
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
</CardDescription>
);
};
export type PlanActionProps = ComponentProps<typeof CardAction>;
export const PlanAction = (props: PlanActionProps) => (
<CardAction data-slot="plan-action" {...props} />
);
export type PlanContentProps = ComponentProps<typeof CardContent>;
export const PlanContent = (props: PlanContentProps) => (
<CollapsibleContent asChild>
<CardContent data-slot="plan-content" {...props} />
</CollapsibleContent>
);
export type PlanFooterProps = ComponentProps<"div">;
export const PlanFooter = (props: PlanFooterProps) => (
<CardFooter data-slot="plan-footer" {...props} />
);
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
<CollapsibleTrigger asChild>
<Button
className={cn("size-8", className)}
data-slot="plan-trigger"
size="icon"
variant="ghost"
{...props}
>
<ChevronsUpDownIcon className="size-4" />
<span className="sr-only">Toggle plan</span>
</Button>
</CollapsibleTrigger>
);
@@ -0,0 +1,55 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { PromptInput, PromptInputBody, PromptInputSubmit, PromptInputTextarea } from "./prompt-input";
function composer(onSubmit = vi.fn(), disabled = false) {
render(<PromptInput onSubmit={onSubmit}>
<PromptInputBody><PromptInputTextarea aria-label="输入问题" /></PromptInputBody>
<PromptInputSubmit disabled={disabled} />
</PromptInput>);
return { input: screen.getByRole("textbox", { name: "输入问题" }), onSubmit };
}
describe("text prompt composition", () => {
it("waits for IME composition to finish before Enter can submit the complete text", async () => {
const { input, onSubmit } = composer();
fireEvent.compositionStart(input);
fireEvent.change(input, { target: { value: "核查节点压力" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onSubmit).not.toHaveBeenCalled();
expect(input).toHaveValue("核查节点压力");
fireEvent.compositionEnd(input);
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ text: "核查节点压力" }, expect.anything()));
});
it("keeps Shift+Enter and disabled submissions from sending a message", () => {
const { input, onSubmit } = composer(vi.fn(), true);
fireEvent.change(input, { target: { value: "保留草稿" } });
expect(fireEvent.keyDown(input, { key: "Enter", shiftKey: true })).toBe(true);
fireEvent.keyDown(input, { key: "Enter" });
expect(onSubmit).not.toHaveBeenCalled();
expect(input).toHaveValue("保留草稿");
});
it("lets the owning panel intercept keyboard submission", () => {
const onSubmit = vi.fn();
render(<PromptInput onSubmit={onSubmit}>
<PromptInputTextarea aria-label="输入问题" onKeyDown={(event) => event.preventDefault()} />
<PromptInputSubmit />
</PromptInput>);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "保留给宿主处理" } });
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
expect(onSubmit).not.toHaveBeenCalled();
});
it("stops a running generation without submitting a second prompt", () => {
const onSubmit = vi.fn(), onStop = vi.fn();
render(<PromptInput onSubmit={onSubmit}>
<PromptInputTextarea /><PromptInputSubmit status="streaming" onStop={onStop} />
</PromptInput>);
fireEvent.click(screen.getByRole("button", { name: "Stop" }));
expect(onStop).toHaveBeenCalledOnce();
expect(onSubmit).not.toHaveBeenCalled();
});
});
File diff suppressed because it is too large Load Diff
-75
View File
@@ -1,75 +0,0 @@
import { cn } from "@/shared/ui/cn";
import type { MotionProps } from "motion/react";
import { motion } from "motion/react";
import type { CSSProperties, ElementType, JSX } from "react";
import { memo, useMemo } from "react";
type MotionHTMLProps = MotionProps & Record<string, unknown>;
// Cache motion components at module level to avoid creating during render
const motionComponentCache = new Map<
keyof JSX.IntrinsicElements,
React.ComponentType<MotionHTMLProps>
>();
const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
let component = motionComponentCache.get(element);
if (!component) {
component = motion.create(element);
motionComponentCache.set(element, component);
}
return component;
};
export interface TextShimmerProps {
children: string;
as?: ElementType;
className?: string;
duration?: number;
spread?: number;
}
const ShimmerComponent = ({
children,
as: Component = "p",
className,
duration = 2,
spread = 2,
}: TextShimmerProps) => {
const MotionComponent = getMotionComponent(
Component as keyof JSX.IntrinsicElements
);
const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread,
[children, spread]
);
return (
<MotionComponent
animate={{ backgroundPosition: "0% center" }}
className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className
)}
initial={{ backgroundPosition: "100% center" }}
style={
{
"--spread": `${dynamicSpread}px`,
backgroundImage:
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
} as CSSProperties
}
transition={{
duration,
ease: "linear",
repeat: Number.POSITIVE_INFINITY,
}}
>
{children}
</MotionComponent>
);
};
export const Shimmer = memo(ShimmerComponent);