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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user