feat: thinking display, copy/edit actions, and UI polish

- Show collapsible "Thinking" (reasoning_content) on AI messages
  - Copy button on AI replies; hover copy + edit on user messages
    (edit refills the composer with cursor at end)
  - Accessibility/UX: input focus ring + aria-label, url input type,
    inline config validation (replaces alert), ellipsis, theme-color
  - Brand color unified into a single --brand token
  - Harden getConfig: empty deployment URL falls back to config dialog
This commit is contained in:
Xi Zhang
2026-06-01 00:17:57 +01:00
parent 047da86b14
commit 0e7fa57ed2
7 changed files with 197 additions and 18 deletions
+21 -5
View File
@@ -110,6 +110,19 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
[handleSubmit, submitDisabled]
);
// Pull a previous user message back into the composer to edit/resend it,
// placing the cursor at the end so the user can keep typing.
const handleEditMessage = useCallback((content: string) => {
setInput(content);
requestAnimationFrame(() => {
const el = textareaRef.current;
if (el) {
el.focus();
el.setSelectionRange(content.length, content.length);
}
});
}, []);
// TODO: can we make this part of the hook?
const processedMessages = useMemo(() => {
/*
@@ -253,7 +266,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
>
{isThreadLoading ? (
<div className="flex items-center justify-center p-8">
<p className="text-muted-foreground">Loading...</p>
<p className="text-muted-foreground">Loading…</p>
</div>
) : (
<>
@@ -278,6 +291,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
stream={stream}
onResumeInterrupt={resumeInterrupt}
graphId={assistant?.graph_id}
onEditMessage={handleEditMessage}
/>
);
})}
@@ -290,7 +304,8 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
<div
className={cn(
"mx-4 mb-6 flex flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-background",
"mx-auto w-[calc(100%-32px)] max-w-[1024px] transition-colors duration-200 ease-in-out"
"mx-auto w-[calc(100%-32px)] max-w-[1024px] transition-colors duration-200 ease-in-out",
"focus-within:ring-2 focus-within:ring-ring"
)}
>
{(hasTasks || hasFiles) && (
@@ -393,7 +408,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
>
<FileIcon size={16} />
Files (State)
<span className="h-4 min-w-4 rounded-full bg-[#2F6868] px-0.5 text-center text-[10px] leading-[16px] text-white">
<span className="h-4 min-w-4 rounded-full bg-[var(--brand)] px-0.5 text-center text-[10px] leading-[16px] text-white">
{Object.keys(files).length}
</span>
</button>
@@ -439,7 +454,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
aria-expanded={metaOpen === "files"}
>
Files (State)
<span className="h-4 min-w-4 rounded-full bg-[#2F6868] px-0.5 text-center text-[10px] leading-[16px] text-white">
<span className="h-4 min-w-4 rounded-full bg-[var(--brand)] px-0.5 text-center text-[10px] leading-[16px] text-white">
{Object.keys(files).length}
</span>
</button>
@@ -509,7 +524,8 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={isLoading ? "Running..." : "Write your message..."}
aria-label="Message"
placeholder={isLoading ? "Running…" : "Write your message…"}
className="font-inherit field-sizing-content flex-1 resize-none border-0 bg-transparent px-[18px] pb-[13px] pt-[14px] text-sm leading-7 text-primary outline-none placeholder:text-tertiary"
rows={1}
/>
+103 -1
View File
@@ -11,6 +11,7 @@ import type {
ReviewConfig,
} from "@/app/types/types";
import { Message } from "@langchain/langgraph-sdk";
import { Brain, Check, ChevronRight, Copy, Pencil } from "lucide-react";
import {
extractSubAgentContent,
extractStringFromMessageContent,
@@ -27,6 +28,7 @@ interface ChatMessageProps {
stream?: any;
onResumeInterrupt?: (value: any) => void;
graphId?: string;
onEditMessage?: (content: string) => void;
}
export const ChatMessage = React.memo<ChatMessageProps>(
@@ -40,11 +42,19 @@ export const ChatMessage = React.memo<ChatMessageProps>(
stream,
onResumeInterrupt,
graphId,
onEditMessage,
}) => {
const isUser = message.type === "human";
const messageContent = extractStringFromMessageContent(message);
const hasContent = messageContent && messageContent.trim() !== "";
const hasToolCalls = toolCalls.length > 0;
// Extended-thinking / reasoning text (Anthropic & friends store it here).
const reasoning = useMemo(() => {
const r = (
message.additional_kwargs as Record<string, unknown> | undefined
)?.reasoning_content;
return typeof r === "string" && r.trim() ? r.trim() : null;
}, [message.additional_kwargs]);
const subAgents = useMemo(() => {
return toolCalls
.filter((toolCall: ToolCall) => {
@@ -70,6 +80,15 @@ export const ChatMessage = React.memo<ChatMessageProps>(
});
}, [toolCalls]);
const [thinkingOpen, setThinkingOpen] = useState(false);
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
if (!messageContent) return;
navigator.clipboard?.writeText(messageContent).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}, [messageContent]);
const [expandedSubAgents, setExpandedSubAgents] = useState<
Record<string, boolean>
>({});
@@ -87,7 +106,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
return (
<div
className={cn(
"flex w-full max-w-full overflow-x-hidden",
"group flex w-full max-w-full overflow-x-hidden",
isUser && "flex-row-reverse"
)}
>
@@ -97,6 +116,34 @@ export const ChatMessage = React.memo<ChatMessageProps>(
isUser ? "max-w-[70%]" : "w-full"
)}
>
{!isUser && reasoning && (
<div className="mt-4">
<button
type="button"
onClick={() => setThinkingOpen((v) => !v)}
aria-expanded={thinkingOpen}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground"
>
<ChevronRight
className={cn(
"h-3.5 w-3.5 transition-transform",
thinkingOpen && "rotate-90"
)}
aria-hidden="true"
/>
<Brain
className="h-3.5 w-3.5"
aria-hidden="true"
/>
Thinking
</button>
{thinkingOpen && (
<div className="mt-2 whitespace-pre-wrap break-words border-l-2 border-border pl-3 text-sm leading-relaxed text-muted-foreground">
{reasoning}
</div>
)}
</div>
)}
{hasContent && (
<div className={cn("relative flex items-end gap-0")}>
<div
@@ -122,6 +169,61 @@ export const ChatMessage = React.memo<ChatMessageProps>(
</div>
</div>
)}
{!isUser && hasContent && (
<div className="mt-1">
<button
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"
>
{copied ? (
<Check
className="h-4 w-4"
aria-hidden="true"
/>
) : (
<Copy
className="h-4 w-4"
aria-hidden="true"
/>
)}
</button>
</div>
)}
{isUser && hasContent && (
<div className="mt-1 flex justify-end gap-1 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
<button
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"
>
{copied ? (
<Check
className="h-4 w-4"
aria-hidden="true"
/>
) : (
<Copy
className="h-4 w-4"
aria-hidden="true"
/>
)}
</button>
<button
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"
>
<Pencil
className="h-4 w-4"
aria-hidden="true"
/>
</button>
</div>
)}
{hasToolCalls && (
<div className="mt-4 flex w-full flex-col">
{toolCalls.map((toolCall: ToolCall) => {
+44 -7
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import {
Dialog,
DialogContent,
@@ -30,21 +30,33 @@ export function ConfigDialog({
const [deploymentUrl, setDeploymentUrl] = useState(
initialConfig?.deploymentUrl || "http://127.0.0.1:6174"
);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (open && initialConfig) {
setDeploymentUrl(initialConfig.deploymentUrl);
if (open) {
setError(null);
if (initialConfig) setDeploymentUrl(initialConfig.deploymentUrl);
}
}, [open, initialConfig]);
const handleSave = () => {
if (!deploymentUrl) {
alert("Please enter the deployment URL");
const url = deploymentUrl.trim();
if (!url) {
setError("Enter your deployment URL to continue.");
inputRef.current?.focus();
return;
}
try {
new URL(url);
} catch {
setError("Enter a valid URL, e.g. http://127.0.0.1:6174");
inputRef.current?.focus();
return;
}
onSave({
deploymentUrl,
deploymentUrl: url,
// Fixed to the EvoScientist main agent (see DEFAULT_ASSISTANT_ID).
assistantId: DEFAULT_ASSISTANT_ID,
});
@@ -69,11 +81,36 @@ export function ConfigDialog({
<div className="grid gap-2">
<Label htmlFor="deploymentUrl">Deployment URL</Label>
<Input
ref={inputRef}
id="deploymentUrl"
name="deploymentUrl"
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
autoFocus
placeholder="http://127.0.0.1:6174"
value={deploymentUrl}
onChange={(e) => setDeploymentUrl(e.target.value)}
onChange={(e) => {
setDeploymentUrl(e.target.value);
if (error) setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") handleSave();
}}
aria-invalid={error ? true : undefined}
aria-describedby={error ? "deploymentUrl-error" : undefined}
/>
{error && (
<p
id="deploymentUrl-error"
role="alert"
aria-live="polite"
className="text-sm text-destructive"
>
{error}
</p>
)}
</div>
</div>
<DialogFooter>
+6
View File
@@ -36,6 +36,12 @@
--color-file-button: #ffffff;
--color-file-button-hover: #e5e7eb;
/* Brand accent — single source of truth. Change this to re-theme
(e.g. EvoScientist blue) in one place. */
--brand: #2f6868;
--brand-hover: #275757;
--brand-foreground: #ffffff;
/* Dark theme colors */
--color-primary-dark: #2dd4bf;
--color-user-message-dark: #076699;
+6 -1
View File
@@ -1,4 +1,4 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { Toaster } from "sonner";
@@ -12,6 +12,11 @@ export const metadata: Metadata = {
"Web UI for EvoScientist — a self-evolving AI scientist built on DeepAgents/LangGraph.",
};
export const viewport: Viewport = {
themeColor: "#f9f9f9",
colorScheme: "light",
};
export default function RootLayout({
children,
}: {
+13 -4
View File
@@ -131,7 +131,10 @@ function HomePageInner({
onClick={() => setSidebar("1")}
className="rounded-md border border-border bg-card p-3 text-foreground hover:bg-accent"
>
<MessagesSquare className="mr-2 h-4 w-4" />
<MessagesSquare
className="mr-2 h-4 w-4"
aria-hidden="true"
/>
Threads
{interruptCount > 0 && (
<span className="ml-2 inline-flex min-h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] text-destructive-foreground">
@@ -151,7 +154,10 @@ function HomePageInner({
size="sm"
onClick={() => setConfigDialogOpen(true)}
>
<Settings className="mr-2 h-4 w-4" />
<Settings
className="mr-2 h-4 w-4"
aria-hidden="true"
/>
Settings
</Button>
<Button
@@ -159,9 +165,12 @@ function HomePageInner({
size="sm"
onClick={() => setThreadId(null)}
disabled={!threadId}
className="border-[#2F6868] bg-[#2F6868] text-white hover:bg-[#2F6868]/80"
className="border-[var(--brand)] bg-[var(--brand)] text-[var(--brand-foreground)] hover:bg-[var(--brand-hover)]"
>
<SquarePen className="mr-2 h-4 w-4" />
<SquarePen
className="mr-2 h-4 w-4"
aria-hidden="true"
/>
New Thread
</Button>
</div>
+4
View File
@@ -19,6 +19,10 @@ export function getConfig(): DeploymentConfig | null {
try {
const parsed = JSON.parse(stored) as DeploymentConfig;
// A stored config with no usable deployment URL would make the SDK send
// requests to the app's own origin (404s). Treat it as unconfigured so
// the config dialog reappears instead.
if (!parsed.deploymentUrl?.trim()) return null;
// Always pin the assistant to the EvoScientist main agent.
return { ...parsed, assistantId: DEFAULT_ASSISTANT_ID };
} catch {