feat: improve UI components with consistent styling and enhanced copy functionality

This commit is contained in:
Xi Zhang
2026-06-16 00:32:35 +01:00
parent 6ca85c6192
commit 723fb99b73
8 changed files with 86 additions and 39 deletions
+3 -1
View File
@@ -25,12 +25,14 @@ We appreciate your interest and the time you spend helping improve the EvoScient
## Development setup
1. **Fork and clone** the repository:
```bash
git clone https://github.com/<your-username>/EvoScientist-WebUI.git
cd EvoScientist-WebUI
```
2. **Install dependencies** (package manager is **npm**):
```bash
npm install
```
@@ -48,7 +50,7 @@ We appreciate your interest and the time you spend helping improve the EvoScient
| Command | Description |
| ---------------------- | --------------------------------------- |
| `npm run dev` | Start the dev server on port 4716 |
| `npm run build` | Production build + assemble `dist/` |
| `npm run build` | Production build + assemble `dist/` |
| `npm start` | Serve the production build on port 4716 |
| `npm run start:dist` | Run the assembled standalone `dist/` |
| `npm run lint` | Lint with ESLint |
+2 -6
View File
@@ -750,9 +750,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
// The selector call only survives the filter above while the run is
// actively selecting (!interrupt), so this resolves to a spinner for
// it without a special case.
status: hasPendingAction
? "interrupted"
: ("pending" as const),
status: hasPendingAction ? "interrupted" : ("pending" as const),
} as ToolCall;
});
messageMap.set(message.id!, {
@@ -957,9 +955,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(
message={data.message}
toolCalls={data.toolCalls}
isLoading={isLoading}
isStreaming={
isLoading && isLastMessage && isAssistant
}
isStreaming={isLoading && isLastMessage && isAssistant}
actionRequests={
isLastMessage ? actionRequests : undefined
}
+19 -5
View File
@@ -192,11 +192,25 @@ export const ChatMessage = React.memo<ChatMessageProps>(
const [thinkingOpen, setThinkingOpen] = useState(false);
const [copied, setCopied] = useState(false);
const copyResetTimer = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined
);
useEffect(
() => () => {
if (copyResetTimer.current) clearTimeout(copyResetTimer.current);
},
[]
);
useEffect(() => {
setCopied(false);
if (copyResetTimer.current) clearTimeout(copyResetTimer.current);
}, [messageContent]);
const handleCopy = useCallback(async () => {
if (!messageContent) return;
if (await copyText(messageContent)) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
if (copyResetTimer.current) clearTimeout(copyResetTimer.current);
copyResetTimer.current = setTimeout(() => setCopied(false), 2000);
} else {
toast.error("Couldn't copy to clipboard.");
}
@@ -312,7 +326,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
type="button"
onClick={handleCopy}
aria-label={copied ? "Copied" : "Copy message"}
className="inline-flex items-center rounded p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
className="inline-flex items-center rounded p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{copied ? (
<Check
@@ -334,7 +348,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
type="button"
onClick={handleCopy}
aria-label={copied ? "Copied" : "Copy message"}
className="inline-flex items-center rounded p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
className="inline-flex items-center rounded p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{copied ? (
<Check
@@ -352,7 +366,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
type="button"
onClick={() => onEditMessage?.(messageContent)}
aria-label="Edit message"
className="inline-flex items-center rounded p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
className="inline-flex items-center rounded p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<Pencil
className="h-4 w-4"
@@ -430,7 +444,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
</div>
{isSubAgentExpanded(subAgent.id) && (
<div className="w-full max-w-full">
<div className="bg-surface border-border-light rounded-md border p-4">
<div className="border-border-light rounded-md border bg-[var(--color-surface)] p-4">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">
Input
</h4>
+26 -5
View File
@@ -1,6 +1,6 @@
"use client";
import React, { useCallback, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { Check, Copy } from "lucide-react";
@@ -14,10 +14,25 @@ interface CodeBlockProps {
export const CodeBlock = React.memo<CodeBlockProps>(({ language, value }) => {
const [copied, setCopied] = useState(false);
// Clear the "copied" timer on unmount so we don't setState on a gone component.
const copyResetTimer = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined
);
useEffect(
() => () => {
if (copyResetTimer.current) clearTimeout(copyResetTimer.current);
},
[]
);
useEffect(() => {
setCopied(false);
if (copyResetTimer.current) clearTimeout(copyResetTimer.current);
}, [value]);
const handleCopy = useCallback(async () => {
if (await copyText(value)) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
if (copyResetTimer.current) clearTimeout(copyResetTimer.current);
copyResetTimer.current = setTimeout(() => setCopied(false), 2000);
} else {
toast.error("Couldn't copy to clipboard.");
}
@@ -29,12 +44,18 @@ export const CodeBlock = React.memo<CodeBlockProps>(({ language, value }) => {
type="button"
onClick={handleCopy}
aria-label={copied ? "Copied" : "Copy code"}
className="absolute right-2 top-2 z-10 rounded-md border border-border bg-surface/80 p-1.5 text-[var(--color-text-tertiary)] opacity-0 backdrop-blur-sm transition-opacity hover:text-[var(--color-text-primary)] focus-visible:opacity-100 group-hover:opacity-100"
className="absolute right-2 top-2 z-10 rounded-md border border-border bg-[var(--color-surface)] p-1.5 text-[var(--color-text-tertiary)] opacity-0 backdrop-blur-sm transition-opacity hover:text-[var(--color-text-primary)] focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100"
>
{copied ? (
<Check className="h-3.5 w-3.5" />
<Check
className="h-3.5 w-3.5"
aria-hidden="true"
/>
) : (
<Copy className="h-3.5 w-3.5" />
<Copy
className="h-3.5 w-3.5"
aria-hidden="true"
/>
)}
</button>
<SyntaxHighlighter
+1 -1
View File
@@ -216,7 +216,7 @@ export const FileViewDialog = React.memo<{
className="h-full min-h-[400px] resize-none font-mono text-sm"
/>
) : (
<ScrollArea className="bg-surface h-full rounded-md">
<ScrollArea className="h-full rounded-md bg-[var(--color-surface)]">
<div className="p-4">
{fileContent ? (
isMarkdown ? (
+13 -5
View File
@@ -42,13 +42,21 @@ export const MarkdownContent = React.memo<MarkdownContentProps>(
const match = /language-(\w+)/.exec(className || "");
const code = String(children).replace(/\n$/, "");
if (!inline && match?.[1] === "mermaid") {
return <MermaidDiagram code={code} isStreaming={isStreaming} />;
return (
<MermaidDiagram
code={code}
isStreaming={isStreaming}
/>
);
}
return !inline && match ? (
<CodeBlock language={match[1]} value={code} />
<CodeBlock
language={match[1]}
value={code}
/>
) : (
<code
className="bg-surface rounded-sm px-1 py-0.5 font-mono text-[0.9em]"
className="rounded-sm bg-[var(--color-surface)] px-1 py-0.5 font-mono text-[0.9em]"
{...props}
>
{children}
@@ -105,7 +113,7 @@ export const MarkdownContent = React.memo<MarkdownContentProps>(
return (
<details
{...props}
className="my-4 rounded-md border border-border bg-surface/50 px-3 py-2 [&[open]>summary]:mb-2"
className="my-4 rounded-md border border-border bg-[var(--color-surface)] px-3 py-2 [&[open]>summary]:mb-2"
>
{children}
</details>
@@ -124,7 +132,7 @@ export const MarkdownContent = React.memo<MarkdownContentProps>(
table({ children }: { children?: React.ReactNode }) {
return (
<div className="my-4 overflow-x-auto">
<table className="[&_th]:bg-surface w-full border-collapse [&_td]:border [&_td]:border-border [&_td]:p-2 [&_th]:border [&_th]:border-border [&_th]:p-2 [&_th]:text-left [&_th]:font-semibold">
<table className="w-full border-collapse [&_td]:border [&_td]:border-border [&_td]:p-2 [&_th]:border [&_th]:border-border [&_th]:bg-[var(--color-surface)] [&_th]:p-2 [&_th]:text-left [&_th]:font-semibold">
{children}
</table>
</div>
+20 -14
View File
@@ -5,6 +5,7 @@ import { CodeBlock } from "./CodeBlock";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
@@ -85,13 +86,18 @@ export const MermaidDiagram = React.memo<MermaidDiagramProps>(
// and, multiplied across several diagrams, freezes the main thread.
if (isStreaming) {
return (
<pre className="my-4 max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-md bg-surface p-3 text-xs text-[var(--color-text-tertiary)]">
<pre className="my-4 max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-md bg-[var(--color-surface)] p-3 text-xs text-[var(--color-text-tertiary)]">
<code>{code}</code>
</pre>
);
}
if (!svg) {
return <CodeBlock language="mermaid" value={code} />;
return (
<CodeBlock
language="mermaid"
value={code}
/>
);
}
return <MermaidPreview svg={svg} />;
}
@@ -102,23 +108,23 @@ const MermaidPreview = React.memo<{ svg: string }>(({ svg }) => {
const [open, setOpen] = useState(false);
return (
<>
<div
role="button"
tabIndex={0}
<button
type="button"
aria-label="Open diagram in full view"
aria-haspopup="dialog"
onClick={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setOpen(true);
}
}}
className="my-4 max-w-full cursor-zoom-in overflow-x-auto rounded-md bg-surface p-4 transition-shadow hover:ring-2 hover:ring-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
className="my-4 block max-w-full cursor-zoom-in overflow-x-auto rounded-md bg-[var(--color-surface)] p-4 text-left transition-shadow hover:ring-2 hover:ring-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
dangerouslySetInnerHTML={{ __html: svg }}
/>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[90vh] max-w-[90vw] overflow-auto p-6 sm:max-w-[90vw]">
<Dialog
open={open}
onOpenChange={setOpen}
>
<DialogContent className="max-h-[calc(100svh-1rem)] w-[calc(100vw-1rem)] max-w-[calc(100vw-1rem)] overflow-auto p-4 sm:max-h-[90vh] sm:w-auto sm:max-w-[90vw] sm:p-6">
<DialogTitle className="sr-only">Mermaid diagram</DialogTitle>
<DialogDescription className="sr-only">
Full-size preview of the generated Mermaid diagram.
</DialogDescription>
<div
className="[&_svg]:h-auto [&_svg]:max-w-none"
dangerouslySetInnerHTML={{ __html: svg }}
+2 -2
View File
@@ -387,7 +387,7 @@ export const WorkspaceFileDialog = React.memo<{
placeholder="File is empty…"
/>
) : kind === "image" ? (
<ScrollArea className="bg-surface h-full rounded-md">
<ScrollArea className="h-full rounded-md bg-[var(--color-surface)]">
<div className="flex items-center justify-center p-4">
<img
src={workspaceFileUrl(path)}
@@ -434,7 +434,7 @@ export const WorkspaceFileDialog = React.memo<{
<p className="text-sm text-destructive">{error}</p>
</div>
) : (
<ScrollArea className="bg-surface h-full rounded-md">
<ScrollArea className="h-full rounded-md bg-[var(--color-surface)]">
<div className="p-4">
{content && content.length > 0 ? (
isMarkdown ? (