feat: improve UI components with consistent styling and enhanced copy functionality
This commit is contained in:
+3
-1
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user