feat: add auto-approve feature and search functionality in ChatInterface and ThreadList
This commit is contained in:
@@ -4,6 +4,7 @@ import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
FormEvent,
|
||||
Fragment,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
Clock,
|
||||
Circle,
|
||||
FileIcon,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { ChatMessage } from "@/app/components/ChatMessage";
|
||||
import type {
|
||||
@@ -67,6 +69,8 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const [autoApprove, setAutoApprove] = useState(false);
|
||||
const autoApprovedRef = useRef<unknown>(null);
|
||||
const { scrollRef, contentRef } = useStickToBottom();
|
||||
|
||||
const {
|
||||
@@ -123,6 +127,24 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Auto-approve: when enabled, approve any pending tool-execution interrupt
|
||||
// for the rest of this conversation (each interrupt is handled once).
|
||||
useEffect(() => {
|
||||
if (!autoApprove) return;
|
||||
const ir = interrupt;
|
||||
const actionRequests =
|
||||
ir?.value && ((ir.value as any)["action_requests"] as unknown[]);
|
||||
if (!ir || !Array.isArray(actionRequests) || actionRequests.length === 0) {
|
||||
autoApprovedRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (autoApprovedRef.current === ir) return;
|
||||
autoApprovedRef.current = ir;
|
||||
resumeInterrupt({
|
||||
decisions: actionRequests.map(() => ({ type: "approve" })),
|
||||
});
|
||||
}, [autoApprove, interrupt, resumeInterrupt]);
|
||||
|
||||
// TODO: can we make this part of the hook?
|
||||
const processedMessages = useMemo(() => {
|
||||
/*
|
||||
@@ -529,7 +551,25 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
|
||||
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 items-center justify-between gap-2 p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAutoApprove((v) => !v)}
|
||||
aria-pressed={autoApprove}
|
||||
title="Auto-approve all tool actions in this conversation"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium transition-colors",
|
||||
autoApprove
|
||||
? "bg-[var(--brand)] text-[var(--brand-foreground)]"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<ShieldCheck
|
||||
className="size-3.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Auto-approve
|
||||
</button>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type={isLoading ? "button" : "submit"}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { Loader2, MessageSquare, X } from "lucide-react";
|
||||
import { Loader2, MessageSquare, Search, SquarePen } from "lucide-react";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -82,7 +82,7 @@ function StatusFilterItem({
|
||||
function ErrorState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<p className="text-sm text-red-600">Failed to load threads</p>
|
||||
<p className="text-sm text-red-600">Failed to load research</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -105,7 +105,7 @@ function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<MessageSquare className="mb-2 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm text-muted-foreground">No threads found</p>
|
||||
<p className="text-sm text-muted-foreground">No research yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -113,18 +113,17 @@ function EmptyState() {
|
||||
interface ThreadListProps {
|
||||
onThreadSelect: (id: string) => void;
|
||||
onMutateReady?: (mutate: () => void) => void;
|
||||
onClose?: () => void;
|
||||
onInterruptCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
export function ThreadList({
|
||||
onThreadSelect,
|
||||
onMutateReady,
|
||||
onClose,
|
||||
onInterruptCountChange,
|
||||
}: ThreadListProps) {
|
||||
const [currentThreadId] = useQueryState("threadId");
|
||||
const [currentThreadId, setThreadId] = useQueryState("threadId");
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const threads = useThreads({
|
||||
status: statusFilter === "all" ? undefined : statusFilter,
|
||||
@@ -135,6 +134,13 @@ export function ThreadList({
|
||||
return threads.data?.flat() ?? [];
|
||||
}, [threads.data]);
|
||||
|
||||
// Client-side filter of the loaded threads by title.
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return flattened;
|
||||
return flattened.filter((t) => t.title.toLowerCase().includes(q));
|
||||
}, [flattened, search]);
|
||||
|
||||
const isLoadingMore =
|
||||
threads.size > 0 && threads.data?.[threads.size - 1] == null;
|
||||
const isEmpty = threads.data?.at(0)?.length === 0;
|
||||
@@ -151,7 +157,7 @@ export function ThreadList({
|
||||
older: [],
|
||||
};
|
||||
|
||||
flattened.forEach((thread) => {
|
||||
filtered.forEach((thread) => {
|
||||
if (thread.status === "interrupted") {
|
||||
groups.interrupted.push(thread);
|
||||
return;
|
||||
@@ -172,7 +178,7 @@ export function ThreadList({
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [flattened]);
|
||||
}, [filtered]);
|
||||
|
||||
const interruptedCount = useMemo(() => {
|
||||
return flattened.filter((t) => t.status === "interrupted").length;
|
||||
@@ -208,9 +214,37 @@ export function ThreadList({
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
{/* Header with title, filter, and close button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setThreadId(null)}
|
||||
className="flex flex-shrink-0 items-center gap-3 border-b border-border p-4 text-left text-sm font-medium transition-colors hover:bg-accent"
|
||||
>
|
||||
<SquarePen
|
||||
className="size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
New Chat
|
||||
</button>
|
||||
<div className="flex-shrink-0 border-b border-border p-3">
|
||||
<div className="relative">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search research…"
|
||||
aria-label="Search research"
|
||||
spellCheck={false}
|
||||
className="w-full rounded-md border border-border bg-background py-1.5 pl-8 pr-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Header with title and status filter */}
|
||||
<div className="grid flex-shrink-0 grid-cols-[1fr_auto] items-center gap-3 border-b border-border p-4">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Threads</h2>
|
||||
<h2 className="text-lg font-semibold tracking-tight">Research</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={statusFilter}
|
||||
@@ -256,17 +290,6 @@ export function ThreadList({
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8"
|
||||
aria-label="Close threads sidebar"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -279,7 +302,18 @@ export function ThreadList({
|
||||
|
||||
{!threads.error && !threads.isLoading && isEmpty && <EmptyState />}
|
||||
|
||||
{!threads.error && !isEmpty && (
|
||||
{!threads.error &&
|
||||
!isEmpty &&
|
||||
search.trim() &&
|
||||
filtered.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No research matches your search.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!threads.error && !isEmpty && filtered.length > 0 && (
|
||||
<div className="box-border w-full max-w-full overflow-hidden p-2">
|
||||
{(
|
||||
Object.keys(GROUP_LABELS) as Array<keyof typeof GROUP_LABELS>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useCallback } from "react";
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
@@ -45,6 +45,12 @@ export const ToolCallBox = React.memo<ToolCallBoxProps>(
|
||||
{}
|
||||
);
|
||||
|
||||
// Approval interrupts arrive after the tool box has already rendered, so
|
||||
// auto-expand once an action request appears (or generative UI loads).
|
||||
useEffect(() => {
|
||||
if (actionRequest || uiComponent) setIsExpanded(true);
|
||||
}, [actionRequest, uiComponent]);
|
||||
|
||||
const { name, args, result, status } = useMemo(() => {
|
||||
return {
|
||||
name: toolCall.name || "Unknown Tool",
|
||||
|
||||
+34
-32
@@ -8,7 +8,7 @@ import { ConfigDialog } from "@/app/components/ConfigDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Assistant } from "@langchain/langgraph-sdk";
|
||||
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
||||
import { Settings, MessagesSquare, SquarePen } from "lucide-react";
|
||||
import { Settings, SquarePen, PanelLeft, PanelLeftClose } from "lucide-react";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
@@ -32,7 +32,7 @@ function HomePageInner({
|
||||
handleSaveConfig,
|
||||
}: HomePageInnerProps) {
|
||||
const client = useClient();
|
||||
const [threadId, setThreadId] = useQueryState("threadId");
|
||||
const [, setThreadId] = useQueryState("threadId");
|
||||
const [sidebar, setSidebar] = useQueryState("sidebar");
|
||||
|
||||
const [mutateThreads, setMutateThreads] = useState<(() => void) | null>(null);
|
||||
@@ -113,7 +113,7 @@ function HomePageInner({
|
||||
/>
|
||||
<div className="flex h-screen flex-col">
|
||||
<header className="flex h-16 items-center justify-between border-b border-border px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image
|
||||
src="/evoscientist-logo.png"
|
||||
@@ -124,31 +124,47 @@ function HomePageInner({
|
||||
/>
|
||||
<h1 className="text-xl font-semibold">EvoScientist</h1>
|
||||
</div>
|
||||
{!sidebar && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSidebar("1")}
|
||||
className="rounded-md border border-border bg-card p-3 text-foreground hover:bg-accent"
|
||||
size="icon"
|
||||
onClick={() => setSidebar(sidebar ? null : "1")}
|
||||
aria-label={sidebar ? "Hide research" : "Show research"}
|
||||
className="relative"
|
||||
>
|
||||
<MessagesSquare
|
||||
className="mr-2 h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Threads
|
||||
{sidebar ? (
|
||||
<PanelLeftClose
|
||||
className="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<PanelLeft
|
||||
className="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{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">
|
||||
<span className="absolute right-0 top-0 inline-flex min-h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] text-destructive-foreground">
|
||||
{interruptCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{!sidebar && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setThreadId(null)}
|
||||
aria-label="New chat"
|
||||
>
|
||||
<SquarePen
|
||||
className="size-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">Assistant:</span>{" "}
|
||||
{config.assistantId}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -160,19 +176,6 @@ function HomePageInner({
|
||||
/>
|
||||
Settings
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setThreadId(null)}
|
||||
disabled={!threadId}
|
||||
className="border-[var(--brand)] bg-[var(--brand)] text-[var(--brand-foreground)] hover:bg-[var(--brand-hover)]"
|
||||
>
|
||||
<SquarePen
|
||||
className="mr-2 h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
New Thread
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -195,7 +198,6 @@ function HomePageInner({
|
||||
await setThreadId(id);
|
||||
}}
|
||||
onMutateReady={(fn) => setMutateThreads(() => fn)}
|
||||
onClose={() => setSidebar(null)}
|
||||
onInterruptCountChange={setInterruptCount}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
Reference in New Issue
Block a user