From 8e2dfd0f27a0514eb683fa49a28c6600dd85d7f1 Mon Sep 17 00:00:00 2001 From: Pamela Fox Date: Thu, 11 Sep 2025 16:06:04 -0700 Subject: [PATCH] Make UI improvements --- README.md | 6 + package.json | 1 + src/app/globals.css | 39 +++++ .../components/inbox-item-input.tsx | 131 +++++++++++++---- .../components/settings-popover.tsx | 25 +++- .../views/InterruptedDescriptionView.tsx | 2 +- .../agent-inbox/contexts/ThreadContext.tsx | 54 ++++++- .../hooks/use-interrupted-actions.tsx | 35 +++-- src/components/agent-inbox/inbox-view.tsx | 9 +- src/components/ui/tag-input.tsx | 136 ++++++++++++++++++ src/components/ui/textarea.tsx | 30 +++- yarn.lock | 9 ++ 12 files changed, 425 insertions(+), 52 deletions(-) create mode 100644 src/components/ui/tag-input.tsx diff --git a/README.md b/README.md index 4e2c878..d2bf36d 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,12 @@ cd agent-inbox yarn install ``` +Start up the web server: + +```bash +yarn dev +``` + ## Configuration Once up and running, you'll need to take two actions so that the Agent Inbox can connect to your LangGraph deployment. diff --git a/package.json b/package.json index 6155dc8..dc81a19 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "react-json-view": "^1.21.3", "react-markdown": "^9.0.1", "react-syntax-highlighter": "^15.5.0", + "react-textarea-autosize": "^8.5.9", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.0", diff --git a/src/app/globals.css b/src/app/globals.css index 744b7b0..d7fa9b7 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -151,6 +151,26 @@ a { body { @apply bg-background text-foreground; } + + /* Reintroduce some native element spacing & list styles while keeping Tailwind utilities. + This softens the effect of Preflight without disabling it entirely. */ + h1, h2, h3, h4, h5, h6, + p, + ul, ol, + blockquote { + margin: revert; /* Restore default top/bottom margins */ + } + + ul, ol { + padding: revert; /* Restore left padding */ + list-style: revert; /* Bring back bullets / numbers */ + } + + blockquote { + all: revert; /* Restore default quotation styling */ + font-style: italic; /* Keep a bit of emphasis */ + padding: 0 1rem; /* Gentle horizontal inset */ + } } /* Change highlight color */ @@ -185,3 +205,22 @@ a { tr { height: var(--row-height); } + +/* Global table borders */ +@layer base { + table { + border-collapse: collapse; /* Ensure single borders */ + width: 100%; + border: 1px solid hsl(var(--border)); + } + th, td { + border: 1px solid hsl(var(--border)); + padding: 0.5rem 0.75rem; + vertical-align: top; + } + th { + background: hsl(var(--muted)); + font-weight: 600; + text-align: left; + } +} diff --git a/src/components/agent-inbox/components/inbox-item-input.tsx b/src/components/agent-inbox/components/inbox-item-input.tsx index 3768878..a0b2324 100644 --- a/src/components/agent-inbox/components/inbox-item-input.tsx +++ b/src/components/agent-inbox/components/inbox-item-input.tsx @@ -6,6 +6,7 @@ import { SubmitType, } from "../types"; import { Textarea } from "@/components/ui/textarea"; +import { TagInput } from "@/components/ui/tag-input"; import React from "react"; import { haveArgsChanged, prettifyText } from "../utils"; import { MarkdownText } from "@/components/ui/markdown-text"; @@ -45,7 +46,7 @@ function ArgsRenderer({ args }: { args: Record }) { {prettifyText(k)}:

- + {value} @@ -136,7 +137,8 @@ function ResponseComponent({ value={res.args} onChange={(e) => onResponseChange(e.target.value, res)} onKeyDown={handleKeyDown} - rows={4} + minRows={4} + maxRows={20} placeholder="Your response here..." /> @@ -200,7 +202,19 @@ function EditAndOrAcceptComponent({ e: React.MouseEvent | React.KeyboardEvent ) => Promise; }) { + // Legacy sizing ref no longer needed with autosizing textarea, retained if future per-field control desired const defaultRows = React.useRef>({}); + // Capture original boolean keys from the interrupt so we can keep rendering radios + const originalBooleanKeys = React.useMemo(() => { + const keys: Record = {}; + const args = interruptValue?.action_request?.args || {}; + Object.entries(args).forEach(([k, v]) => { + if (typeof v === "boolean") { + keys[k] = true; + } + }); + return keys; + }, [interruptValue]); const editResponse = humanResponse.find((r) => r.type === "edit"); const acceptResponse = humanResponse.find((r) => r.type === "accept"); if ( @@ -267,21 +281,73 @@ function EditAndOrAcceptComponent({ {Object.entries(editResponse.args.args).map(([k, v], idx) => { - const value = ["string", "number"].includes(typeof v) - ? v - : JSON.stringify(v, null); - // Calculate the default number of rows by the total length of the initial value divided by 30 - // or 8, whichever is greater. Stored in a ref to prevent re-rendering. - if ( - defaultRows.current[k as keyof typeof defaultRows.current] === - undefined - ) { - defaultRows.current[k as keyof typeof defaultRows.current] = !v.length - ? 3 - : Math.max(v.length / 30, 7); + // Determine if this key started as a boolean OR remains a primitive boolean + const isBoolean = + typeof v === "boolean" || + (originalBooleanKeys[k] && (v === "true" || v === "false")); + // Normalize to string for consistent state shape when editing + const value = ["string", "number", "boolean"].includes(typeof v) + ? String(v) + : isBoolean + ? String(v) + : JSON.stringify(v, null); + const isStringArray = Array.isArray(v) && v.every((i) => typeof i === "string"); + // Autosize handles vertical sizing, we keep minRows via prop on Textarea. + + if (isBoolean) { + return ( +
+
+

{prettifyText(k)}

+
+ + +
+
+
+ ); + } + + if (isStringArray) { + return ( +
+
+

{prettifyText(k)}

+ onEditChange(next, editResponse, k)} + /> +
+
+ ); } - const numRows = - defaultRows.current[k as keyof typeof defaultRows.current] || 8; return (
onEditChange(e.target.value, editResponse, k)} onKeyDown={handleKeyDown} - rows={numRows} + minRows={2} + maxRows={18} />
@@ -346,10 +413,12 @@ export function InboxItemInput({ response: HumanResponseWithEdits, key: string | string[] ) => { - if ( - (Array.isArray(change) && !Array.isArray(key)) || - (!Array.isArray(change) && Array.isArray(key)) - ) { + // Allow three shapes: + // 1. change: string, key: string – update single value + // 2. change: string[], key: string – update a single arg whose value is an array (TagInput case) + // 3. change: string[], key: string[] – batch update multiple keys (reset flow) + // Any other combination is unexpected. + if (!Array.isArray(change) && Array.isArray(key)) { toast({ title: "Error", description: "Something went wrong", @@ -363,14 +432,17 @@ export function InboxItemInput({ const updatedArgs = { ...(response.args?.args || {}) }; if (Array.isArray(change) && Array.isArray(key)) { - // Handle array inputs by mapping corresponding values + // Batch update: map each change[i] to key[i] change.forEach((value, index) => { if (index < key.length) { updatedArgs[key[index]] = value; } }); + } else if (Array.isArray(change) && !Array.isArray(key)) { + // Single field holds an array value + updatedArgs[key as string] = change; } else { - // Handle single value case + // Standard single value update updatedArgs[key as string] = change as string; } @@ -410,10 +482,15 @@ export function InboxItemInput({ ...response.args.args, ...Object.fromEntries(key.map((k, i) => [k, change[i]])), } - : { - ...response.args.args, - [key as string]: change as string, - }, + : Array.isArray(change) && !Array.isArray(key) + ? { + ...response.args.args, + [key as string]: change, + } + : { + ...response.args.args, + [key as string]: change as string, + }, }, }; if ( diff --git a/src/components/agent-inbox/components/settings-popover.tsx b/src/components/agent-inbox/components/settings-popover.tsx index 2837d97..de66d78 100644 --- a/src/components/agent-inbox/components/settings-popover.tsx +++ b/src/components/agent-inbox/components/settings-popover.tsx @@ -27,7 +27,8 @@ export function SettingsPopover() { const [langchainApiKey, setLangchainApiKey] = React.useState(""); const { getItem, setItem } = useLocalStorage(); const { getSearchParam } = useQueryParams(); - const { fetchThreads } = useThreadsContext(); + const { fetchThreads, autoRefreshEnabled, toggleAutoRefresh } = + useThreadsContext(); const [isRunningBackfill, setIsRunningBackfill] = React.useState(false); const [backfillCompleted, setBackfillCompleted] = React.useState(true); const { toast } = useToast(); @@ -51,6 +52,10 @@ export function SettingsPopover() { } }, [langchainApiKey]); + const handleToggleAutoRefresh = (e: React.ChangeEvent) => { + toggleAutoRefresh(e.target.checked); + }; + const handleChangeLangChainApiKey = ( e: React.ChangeEvent ) => { @@ -147,6 +152,24 @@ export function SettingsPopover() { onChange={handleChangeLangChainApiKey} /> +
+
+ +

Automatically poll the selected inbox for new threads.

+
+
+ + + {autoRefreshEnabled ? "Enabled" : "Disabled"} + +
+
{!backfillCompleted && (
diff --git a/src/components/agent-inbox/components/views/InterruptedDescriptionView.tsx b/src/components/agent-inbox/components/views/InterruptedDescriptionView.tsx index 671db84..7d117b9 100644 --- a/src/components/agent-inbox/components/views/InterruptedDescriptionView.tsx +++ b/src/components/agent-inbox/components/views/InterruptedDescriptionView.tsx @@ -9,7 +9,7 @@ export function InterruptedDescriptionView({ }: InterruptedDescriptionViewProps) { return (
- + {description || "No description provided"}
diff --git a/src/components/agent-inbox/contexts/ThreadContext.tsx b/src/components/agent-inbox/contexts/ThreadContext.tsx index 8fb9d4c..ba60d55 100644 --- a/src/components/agent-inbox/contexts/ThreadContext.tsx +++ b/src/components/agent-inbox/contexts/ThreadContext.tsx @@ -17,7 +17,7 @@ import { LIMIT_PARAM, OFFSET_PARAM, LANGCHAIN_API_KEY_LOCAL_STORAGE_KEY, - IMPROPER_SCHEMA, + IMPROPER_SCHEMA } from "../constants"; import { getInterruptFromThread, @@ -36,6 +36,10 @@ type ThreadContentType< threadData: ThreadData[]; hasMoreThreads: boolean; agentInboxes: AgentInbox[]; + /** Number of times a fetchThreads call has been attempted this session */ + fetchAttemptCount: number; + autoRefreshEnabled: boolean; // whether polling is enabled + toggleAutoRefresh: (value?: boolean) => void; // toggle or explicitly set deleteAgentInbox: (id: string) => void; changeAgentInbox: (graphId: string, replaceAll?: boolean) => void; addAgentInbox: (agentInbox: AgentInbox) => void; @@ -123,6 +127,11 @@ export function ThreadsProvider< ThreadData[] >([]); const [hasMoreThreads, setHasMoreThreads] = React.useState(true); + const [fetchAttemptCount, setFetchAttemptCount] = React.useState(0); + // auto-refresh state + const [autoRefreshEnabled, setAutoRefreshEnabled] = React.useState( + true + ); const { agentInboxes, @@ -157,6 +166,7 @@ export function ThreadsProvider< const fetchThreads = React.useCallback( async (inbox: ThreadStatusWithAll) => { setLoading(true); + setFetchAttemptCount((c) => c + 1); const client = getClient({ agentInboxes, @@ -306,6 +316,45 @@ export function ThreadsProvider< [agentInboxes, getItem, getSearchParam, toast] ); + // Toggle / set auto-refresh preference + const toggleAutoRefresh = React.useCallback((value?: boolean) => { + setAutoRefreshEnabled((prev) => (value === undefined ? !prev : value)); + }, []); + + // Polling effect: refetch current inbox periodically + React.useEffect(() => { + if (typeof window === "undefined") return; + if (!autoRefreshEnabled) return; + if (!agentInboxes.length) return; // nothing selected yet + const inboxSearchParam = getSearchParam(INBOX_PARAM) as + | ThreadStatusWithAll + | undefined; + if (!inboxSearchParam) return; + + // Do not start another interval if currently loading; we'll still schedule next tick + let aborted = false; + let intervalId: number | undefined; + + const run = async () => { + if (aborted) return; + if (!loading) { + try { + await fetchThreads(inboxSearchParam); + } catch (e) { + logger.error("Polling fetchThreads error", e); + } + } + }; + + // Kick off immediate fetch (debounced by loading flag) and then schedule + run(); + intervalId = window.setInterval(run, 15_000); + return () => { + aborted = true; + if (intervalId) window.clearInterval(intervalId); + }; + }, [autoRefreshEnabled, agentInboxes, fetchThreads, loading, getSearchParam]); + const fetchSingleThread = React.useCallback( async (threadId: string): Promise | undefined> => { const client = getClient({ @@ -471,6 +520,9 @@ export function ThreadsProvider< threadData, hasMoreThreads, agentInboxes, + fetchAttemptCount, + autoRefreshEnabled, + toggleAutoRefresh, deleteAgentInbox, changeAgentInbox, addAgentInbox, diff --git a/src/components/agent-inbox/hooks/use-interrupted-actions.tsx b/src/components/agent-inbox/hooks/use-interrupted-actions.tsx index 24e2cdc..a6fc591 100644 --- a/src/components/agent-inbox/hooks/use-interrupted-actions.tsx +++ b/src/components/agent-inbox/hooks/use-interrupted-actions.tsx @@ -93,32 +93,41 @@ export default function useInterruptedActions< // Whether or not the user has added a response. const [hasAddedResponse, setHasAddedResponse] = React.useState(false); const [acceptAllowed, setAcceptAllowed] = React.useState(false); + // Track last thread ID we initialized for, so we don't clobber local edits + const lastInitializedThreadId = React.useRef(null); React.useEffect(() => { try { - if ( - !threadData || - !threadData.interrupts || - threadData.interrupts.length === 0 - ) + if (!threadData || !threadData.interrupts || threadData.interrupts.length === 0) { return; - const { responses, defaultSubmitType, hasAccept } = - createDefaultHumanResponse( + } + const currentThreadId = threadData.thread.thread_id; + const isSameThread = lastInitializedThreadId.current === currentThreadId; + // If we already initialized this thread and user has edits, skip reinitialization + if (isSameThread && humanResponse.length > 0 && hasEdited) { + return; + } + // Only reinitialize if new thread OR we have no existing responses (e.g., first load) + if (!isSameThread || humanResponse.length === 0) { + const { responses, defaultSubmitType, hasAccept } = createDefaultHumanResponse( threadData.interrupts, - initialHumanInterruptEditValue + initialHumanInterruptEditValue ); - setSelectedSubmitType(defaultSubmitType); - setHumanResponse(responses); - setAcceptAllowed(hasAccept); + lastInitializedThreadId.current = currentThreadId; + setSelectedSubmitType(defaultSubmitType); + setHumanResponse(responses); + setAcceptAllowed(hasAccept); + } } catch (e) { console.error("Error formatting and setting human response state", e); - // Set fallback values for invalid interrupts setHumanResponse([{ type: "ignore", args: null }]); setSelectedSubmitType(undefined); setAcceptAllowed(false); logger.error("Error formatting and setting human response state", e); } - }, [threadData?.interrupts]); + // We intentionally include hasEdited & humanResponse length for guarding logic, but avoid triggering on every content change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [threadData?.interrupts, threadData?.thread.thread_id, hasEdited]); const handleSubmit = async ( e: React.MouseEvent | React.KeyboardEvent diff --git a/src/components/agent-inbox/inbox-view.tsx b/src/components/agent-inbox/inbox-view.tsx index 3b082ee..5a3e8ad 100644 --- a/src/components/agent-inbox/inbox-view.tsx +++ b/src/components/agent-inbox/inbox-view.tsx @@ -22,7 +22,7 @@ export function AgentInboxView< ThreadValues extends Record = Record, >({ saveScrollPosition, containerRef }: AgentInboxViewProps) { const { searchParams, updateQueryParams, getSearchParam } = useQueryParams(); - const { loading, threadData, agentInboxes, clearThreadData } = + const { loading, threadData, agentInboxes, clearThreadData, fetchAttemptCount } = useThreadsContext(); const selectedInbox = (getSearchParam(INBOX_PARAM) || "interrupted") as ThreadStatusWithAll; @@ -152,6 +152,9 @@ export function AgentInboxView< [selectedInbox, threadData] ); const noThreadsFound = !threadDataToRender.length; + // We only want to show the pure loading empty state before the first fetch attempt has completed. + // After at least one attempt, if there are still no threads, show the empty state instead of a spinner. + const showInitialLoadingEmpty = noThreadsFound && loading && fetchAttemptCount === 0; // Correct way to save scroll position before navigation const handleThreadClick = () => { @@ -205,7 +208,7 @@ export function AgentInboxView< /> ); })} - {noThreadsFound && !loading && ( + {noThreadsFound && (!loading || fetchAttemptCount > 0) && (
@@ -213,7 +216,7 @@ export function AgentInboxView<
)} - {noThreadsFound && loading && ( + {showInitialLoadingEmpty && (

Loading

diff --git a/src/components/ui/tag-input.tsx b/src/components/ui/tag-input.tsx new file mode 100644 index 0000000..e299547 --- /dev/null +++ b/src/components/ui/tag-input.tsx @@ -0,0 +1,136 @@ +import * as React from "react"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { X } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export interface TagInputProps { + value: string[]; + onChange: (next: string[]) => void; + placeholder?: string; + disabled?: boolean; + allowDuplicates?: boolean; + className?: string; + maxItems?: number; + validateItem?: (item: string) => string | null; // return error message or null +} + +/** + * A lightweight controlled component for editing a list of strings. + * - Type and press Enter or comma to add + * - Backspace with empty input removes last item + * - Click x to remove specific item + */ +export const TagInput: React.FC = ({ + value, + onChange, + placeholder = "Add item…", + disabled, + allowDuplicates = false, + className, + maxItems, + validateItem, +}) => { + const [draft, setDraft] = React.useState(""); + const [error, setError] = React.useState(null); + const inputRef = React.useRef(null); + + const add = (raw: string) => { + const tag = raw.trim(); + if (!tag) return; + if (maxItems && value.length >= maxItems) return; + if (!allowDuplicates && value.includes(tag)) { + setDraft(""); + return; + } + if (validateItem) { + const err = validateItem(tag); + if (err) { + setError(err); + return; + } + } + setError(null); + onChange([...value, tag]); + setDraft(""); + }; + + const remove = (idx: number) => { + setError(null); + const next = value.filter((_, i) => i !== idx); + onChange(next); + }; + + const onKeyDown: React.KeyboardEventHandler = (e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + add(draft); + } else if (e.key === "Backspace" && draft === "" && value.length > 0) { + remove(value.length - 1); + } + }; + + const onPaste: React.ClipboardEventHandler = (e) => { + const text = e.clipboardData.getData("text"); + if (text.includes("\n") || text.includes(",")) { + e.preventDefault(); + text + .split(/[\n,]/) + .map((t) => t.trim()) + .filter(Boolean) + .forEach(add); + } + }; + + return ( +
+
inputRef.current?.focus()} + > + {value.map((tag, i) => ( + + + {tag} + + + + ))} + = maxItems)} + value={draft} + placeholder={value.length === 0 ? placeholder : ""} + onChange={(e) => { + setError(null); + setDraft(e.target.value); + }} + onKeyDown={onKeyDown} + onPaste={onPaste} + className="flex-1 min-w-[120px] border-0 shadow-none px-0 py-0 h-auto focus-visible:ring-0 focus-visible:outline-none text-sm" + /> +
+ {error &&

{error}

} +
+ ); +}; diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx index 2fb9690..fb8388a 100644 --- a/src/components/ui/textarea.tsx +++ b/src/components/ui/textarea.tsx @@ -1,20 +1,38 @@ import * as React from "react"; - import { cn } from "@/lib/utils"; +import TextareaAutosize, { + type TextareaAutosizeProps, +} from "react-textarea-autosize"; export interface TextareaProps - extends React.TextareaHTMLAttributes {} + extends Omit { + minRows?: number; + maxRows?: number; +} +// We keep the same exported name so existing imports continue working. const Textarea = React.forwardRef( - ({ className, ...props }, ref) => { + ( + { + className, + minRows: incomingMinRows, + maxRows: incomingMaxRows, + ...rest + }, + ref + ) => { + const minRows = incomingMinRows ?? 2; // default starting height + const maxRows = incomingMaxRows ?? 16; return ( -