Files
EvoScientist-WebUI/src/app/components/ChatInterface.tsx
T
Xi Zhang 0e7fa57ed2 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
2026-06-01 00:17:57 +01:00

562 lines
20 KiB
TypeScript

"use client";
import React, {
useState,
useRef,
useCallback,
useMemo,
FormEvent,
Fragment,
} from "react";
import { Button } from "@/components/ui/button";
import {
Square,
ArrowUp,
CheckCircle,
Clock,
Circle,
FileIcon,
} from "lucide-react";
import { ChatMessage } from "@/app/components/ChatMessage";
import type {
TodoItem,
ToolCall,
ActionRequest,
ReviewConfig,
} from "@/app/types/types";
import { Assistant, Message } from "@langchain/langgraph-sdk";
import { extractStringFromMessageContent } from "@/app/utils/utils";
import { useChatContext } from "@/providers/ChatProvider";
import { cn } from "@/lib/utils";
import { useStickToBottom } from "use-stick-to-bottom";
import { FilesPopover } from "@/app/components/TasksFilesSidebar";
interface ChatInterfaceProps {
assistant: Assistant | null;
}
const getStatusIcon = (status: TodoItem["status"], className?: string) => {
switch (status) {
case "completed":
return (
<CheckCircle
size={16}
className={cn("text-success/80", className)}
/>
);
case "in_progress":
return (
<Clock
size={16}
className={cn("text-warning/80", className)}
/>
);
default:
return (
<Circle
size={16}
className={cn("text-tertiary/70", className)}
/>
);
}
};
export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
const [metaOpen, setMetaOpen] = useState<"tasks" | "files" | null>(null);
const tasksContainerRef = useRef<HTMLDivElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const [input, setInput] = useState("");
const { scrollRef, contentRef } = useStickToBottom();
const {
stream,
messages,
todos,
files,
ui,
setFiles,
isLoading,
isThreadLoading,
interrupt,
sendMessage,
stopStream,
resumeInterrupt,
} = useChatContext();
const submitDisabled = isLoading || !assistant;
const handleSubmit = useCallback(
(e?: FormEvent) => {
if (e) {
e.preventDefault();
}
const messageText = input.trim();
if (!messageText || isLoading || submitDisabled) return;
sendMessage(messageText);
setInput("");
},
[input, isLoading, sendMessage, setInput, submitDisabled]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (submitDisabled) return;
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
},
[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(() => {
/*
1. Loop through all messages
2. For each AI message, add the AI message, and any tool calls to the messageMap
3. For each tool message, find the corresponding tool call in the messageMap and update the status and output
*/
const messageMap = new Map<
string,
{ message: Message; toolCalls: ToolCall[] }
>();
messages.forEach((message: Message) => {
if (message.type === "ai") {
const toolCallsInMessage: Array<{
id?: string;
function?: { name?: string; arguments?: unknown };
name?: string;
type?: string;
args?: unknown;
input?: unknown;
}> = [];
if (
message.additional_kwargs?.tool_calls &&
Array.isArray(message.additional_kwargs.tool_calls)
) {
toolCallsInMessage.push(...message.additional_kwargs.tool_calls);
} else if (message.tool_calls && Array.isArray(message.tool_calls)) {
toolCallsInMessage.push(
...message.tool_calls.filter(
(toolCall: { name?: string }) => toolCall.name !== ""
)
);
} else if (Array.isArray(message.content)) {
const toolUseBlocks = message.content.filter(
(block: { type?: string }) => block.type === "tool_use"
);
toolCallsInMessage.push(...toolUseBlocks);
}
const toolCallsWithStatus = toolCallsInMessage.map(
(toolCall: {
id?: string;
function?: { name?: string; arguments?: unknown };
name?: string;
type?: string;
args?: unknown;
input?: unknown;
}) => {
const name =
toolCall.function?.name ||
toolCall.name ||
toolCall.type ||
"unknown";
const args =
toolCall.function?.arguments ||
toolCall.args ||
toolCall.input ||
{};
return {
id: toolCall.id || `tool-${Math.random()}`,
name,
args,
status: interrupt ? "interrupted" : ("pending" as const),
} as ToolCall;
}
);
messageMap.set(message.id!, {
message,
toolCalls: toolCallsWithStatus,
});
} else if (message.type === "tool") {
const toolCallId = message.tool_call_id;
if (!toolCallId) {
return;
}
for (const [, data] of messageMap.entries()) {
const toolCallIndex = data.toolCalls.findIndex(
(tc: ToolCall) => tc.id === toolCallId
);
if (toolCallIndex === -1) {
continue;
}
data.toolCalls[toolCallIndex] = {
...data.toolCalls[toolCallIndex],
status: "completed" as const,
result: extractStringFromMessageContent(message),
};
break;
}
} else if (message.type === "human") {
messageMap.set(message.id!, {
message,
toolCalls: [],
});
}
});
const processedArray = Array.from(messageMap.values());
return processedArray.map((data, index) => {
const prevMessage = index > 0 ? processedArray[index - 1].message : null;
return {
...data,
showAvatar: data.message.type !== prevMessage?.type,
};
});
}, [messages, interrupt]);
const groupedTodos = {
in_progress: todos.filter((t) => t.status === "in_progress"),
pending: todos.filter((t) => t.status === "pending"),
completed: todos.filter((t) => t.status === "completed"),
};
const hasTasks = todos.length > 0;
const hasFiles = Object.keys(files).length > 0;
// Parse out any action requests or review configs from the interrupt
const actionRequestsMap: Map<string, ActionRequest> | null = useMemo(() => {
const actionRequests =
interrupt?.value && (interrupt.value as any)["action_requests"];
if (!actionRequests) return new Map<string, ActionRequest>();
return new Map(actionRequests.map((ar: ActionRequest) => [ar.name, ar]));
}, [interrupt]);
const reviewConfigsMap: Map<string, ReviewConfig> | null = useMemo(() => {
const reviewConfigs =
interrupt?.value && (interrupt.value as any)["review_configs"];
if (!reviewConfigs) return new Map<string, ReviewConfig>();
return new Map(
reviewConfigs.map((rc: ReviewConfig) => [rc.actionName, rc])
);
}, [interrupt]);
return (
<div className="flex flex-1 flex-col overflow-hidden">
<div
className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain"
ref={scrollRef}
>
<div
className="mx-auto w-full max-w-[1024px] px-6 pb-6 pt-4"
ref={contentRef}
>
{isThreadLoading ? (
<div className="flex items-center justify-center p-8">
<p className="text-muted-foreground">Loading…</p>
</div>
) : (
<>
{processedMessages.map((data, index) => {
const messageUi = ui?.filter(
(u: any) => u.metadata?.message_id === data.message.id
);
const isLastMessage = index === processedMessages.length - 1;
return (
<ChatMessage
key={data.message.id}
message={data.message}
toolCalls={data.toolCalls}
isLoading={isLoading}
actionRequestsMap={
isLastMessage ? actionRequestsMap : undefined
}
reviewConfigsMap={
isLastMessage ? reviewConfigsMap : undefined
}
ui={messageUi}
stream={stream}
onResumeInterrupt={resumeInterrupt}
graphId={assistant?.graph_id}
onEditMessage={handleEditMessage}
/>
);
})}
</>
)}
</div>
</div>
<div className="flex-shrink-0 bg-background">
<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",
"focus-within:ring-2 focus-within:ring-ring"
)}
>
{(hasTasks || hasFiles) && (
<div className="flex max-h-72 flex-col overflow-y-auto border-b border-border bg-sidebar empty:hidden">
{!metaOpen && (
<>
{(() => {
const activeTask = todos.find(
(t) => t.status === "in_progress"
);
const totalTasks = todos.length;
const remainingTasks =
totalTasks - groupedTodos.pending.length;
const isCompleted = totalTasks === remainingTasks;
const tasksTrigger = (() => {
if (!hasTasks) return null;
return (
<button
type="button"
onClick={() =>
setMetaOpen((prev) =>
prev === "tasks" ? null : "tasks"
)
}
className="grid w-full cursor-pointer grid-cols-[auto_auto_1fr] items-center gap-3 px-[18px] py-3 text-left"
aria-expanded={metaOpen === "tasks"}
>
{(() => {
if (isCompleted) {
return [
<CheckCircle
key="icon"
size={16}
className="text-success/80"
/>,
<span
key="label"
className="ml-[1px] min-w-0 truncate text-sm"
>
All tasks completed
</span>,
];
}
if (activeTask != null) {
return [
<div key="icon">
{getStatusIcon(activeTask.status)}
</div>,
<span
key="label"
className="ml-[1px] min-w-0 truncate text-sm"
>
Task{" "}
{totalTasks - groupedTodos.pending.length} of{" "}
{totalTasks}
</span>,
<span
key="content"
className="min-w-0 gap-2 truncate text-sm text-muted-foreground"
>
{activeTask.content}
</span>,
];
}
return [
<Circle
key="icon"
size={16}
className="text-tertiary/70"
/>,
<span
key="label"
className="ml-[1px] min-w-0 truncate text-sm"
>
Task {totalTasks - groupedTodos.pending.length}{" "}
of {totalTasks}
</span>,
];
})()}
</button>
);
})();
const filesTrigger = (() => {
if (!hasFiles) return null;
return (
<button
type="button"
onClick={() =>
setMetaOpen((prev) =>
prev === "files" ? null : "files"
)
}
className="flex flex-shrink-0 cursor-pointer items-center gap-2 px-[18px] py-3 text-left text-sm"
aria-expanded={metaOpen === "files"}
>
<FileIcon size={16} />
Files (State)
<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>
);
})();
return (
<div className="grid grid-cols-[1fr_auto_auto] items-center">
{tasksTrigger}
{filesTrigger}
</div>
);
})()}
</>
)}
{metaOpen && (
<>
<div className="sticky top-0 flex items-stretch bg-sidebar text-sm">
{hasTasks && (
<button
type="button"
className="py-3 pr-4 first:pl-[18px] aria-expanded:font-semibold"
onClick={() =>
setMetaOpen((prev) =>
prev === "tasks" ? null : "tasks"
)
}
aria-expanded={metaOpen === "tasks"}
>
Tasks
</button>
)}
{hasFiles && (
<button
type="button"
className="inline-flex items-center gap-2 py-3 pr-4 first:pl-[18px] aria-expanded:font-semibold"
onClick={() =>
setMetaOpen((prev) =>
prev === "files" ? null : "files"
)
}
aria-expanded={metaOpen === "files"}
>
Files (State)
<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>
)}
<button
aria-label="Close"
className="flex-1"
onClick={() => setMetaOpen(null)}
/>
</div>
<div
ref={tasksContainerRef}
className="px-[18px]"
>
{metaOpen === "tasks" &&
Object.entries(groupedTodos)
.filter(([_, todos]) => todos.length > 0)
.map(([status, todos]) => (
<div
key={status}
className="mb-4"
>
<h3 className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-tertiary">
{
{
pending: "Pending",
in_progress: "In Progress",
completed: "Completed",
}[status]
}
</h3>
<div className="grid grid-cols-[auto_1fr] gap-3 rounded-sm p-1 pl-0 text-sm">
{todos.map((todo, index) => (
<Fragment key={`${status}_${todo.id}_${index}`}>
{getStatusIcon(todo.status, "mt-0.5")}
<span className="break-words text-inherit">
{todo.content}
</span>
</Fragment>
))}
</div>
</div>
))}
{metaOpen === "files" && (
<div className="mb-6">
<FilesPopover
files={files}
setFiles={setFiles}
editDisabled={
isLoading === true || interrupt !== undefined
}
/>
</div>
)}
</div>
</>
)}
</div>
)}
<form
onSubmit={handleSubmit}
className="flex flex-col"
>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
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}
/>
<div className="flex justify-between gap-2 p-3">
<div className="flex justify-end gap-2">
<Button
type={isLoading ? "button" : "submit"}
variant={isLoading ? "destructive" : "default"}
onClick={isLoading ? stopStream : handleSubmit}
disabled={!isLoading && (submitDisabled || !input.trim())}
>
{isLoading ? (
<>
<Square size={14} />
<span>Stop</span>
</>
) : (
<>
<ArrowUp size={18} />
<span>Send</span>
</>
)}
</Button>
</div>
</div>
</form>
</div>
</div>
</div>
);
});
ChatInterface.displayName = "ChatInterface";