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 (
+
+ );
+ }
+
+ 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 (
-
);
}
diff --git a/yarn.lock b/yarn.lock
index fd90151..36a977f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6572,6 +6572,15 @@ react-textarea-autosize@^8.3.2, react-textarea-autosize@^8.5.4:
use-composed-ref "^1.3.0"
use-latest "^1.2.1"
+react-textarea-autosize@^8.5.9:
+ version "8.5.9"
+ resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz#ab8627b09aa04d8a2f45d5b5cd94c84d1d4a8893"
+ integrity sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==
+ dependencies:
+ "@babel/runtime" "^7.20.13"
+ use-composed-ref "^1.3.0"
+ use-latest "^1.2.1"
+
react@^18:
version "18.3.1"
resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891"