Skip to content

Commit 3c1d0d2

Browse files
authored
fix #50: classifying invalid interrupts: showing raw JSON (#70)
2 parents e13fb18 + b0a1f9a commit 3c1d0d2

16 files changed

+1369
-460
lines changed

src/components/agent-inbox/components/breadcrumb.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ChevronRight } from "lucide-react";
77
import { useQueryParams } from "../hooks/use-query-params";
88
import {
99
AGENT_INBOX_PARAM,
10+
IMPROPER_SCHEMA,
1011
INBOX_PARAM,
1112
VIEW_STATE_THREAD_QUERY_PARAM,
1213
} from "../constants";
@@ -54,7 +55,11 @@ export function BreadCrumb({ className }: { className?: string }) {
5455
selectedThread?.interrupts as HumanInterrupt[] | undefined
5556
)?.[0]?.action_request?.action;
5657
if (selectedThreadAction) {
57-
setSelectedThreadActionLabel(prettifyText(selectedThreadAction));
58+
if (selectedThreadAction === IMPROPER_SCHEMA) {
59+
setSelectedThreadActionLabel("Interrupt");
60+
} else {
61+
setSelectedThreadActionLabel(prettifyText(selectedThreadAction));
62+
}
5863
} else {
5964
setSelectedThreadActionLabel(undefined);
6065
}

src/components/agent-inbox/components/generic-inbox-item.tsx

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,39 @@
11
import { cn } from "@/lib/utils";
22
import { Thread } from "@langchain/langgraph-sdk";
3-
import React from "react";
43
import { ThreadIdCopyable } from "./thread-id";
54
import { InboxItemStatuses } from "./statuses";
65
import { format } from "date-fns";
7-
import { useToast } from "@/hooks/use-toast";
8-
import { constructOpenInStudioURL } from "../utils";
9-
import { Button } from "@/components/ui/button";
10-
import { useThreadsContext } from "../contexts/ThreadContext";
116
import { useQueryParams } from "../hooks/use-query-params";
127
import {
138
STUDIO_NOT_WORKING_TROUBLESHOOTING_URL,
149
VIEW_STATE_THREAD_QUERY_PARAM,
1510
} from "../constants";
11+
import { GenericThreadData } from "../types";
12+
import { useToast } from "@/hooks/use-toast";
13+
import { Button } from "@/components/ui/button";
14+
import { useThreadsContext } from "../contexts/ThreadContext";
15+
16+
import { constructOpenInStudioURL } from "../utils";
1617

1718
interface GenericInboxItemProps<
1819
ThreadValues extends Record<string, any> = Record<string, any>,
1920
> {
20-
threadData: {
21-
thread: Thread<ThreadValues>;
22-
status: "idle" | "busy" | "error" | "interrupted";
23-
interrupts?: never | undefined;
24-
};
21+
threadData:
22+
| GenericThreadData<ThreadValues>
23+
| {
24+
thread: Thread<ThreadValues>;
25+
status: "interrupted";
26+
interrupts?: undefined;
27+
};
2528
isLast: boolean;
2629
}
2730

2831
export function GenericInboxItem<
2932
ThreadValues extends Record<string, any> = Record<string, any>,
3033
>({ threadData, isLast }: GenericInboxItemProps<ThreadValues>) {
31-
const { agentInboxes } = useThreadsContext<ThreadValues>();
32-
const { toast } = useToast();
3334
const { updateQueryParams } = useQueryParams();
35+
const { toast } = useToast();
36+
const { agentInboxes } = useThreadsContext();
3437

3538
const selectedInbox = agentInboxes.find((i) => i.selected);
3639

@@ -93,22 +96,26 @@ export function GenericInboxItem<
9396
)
9497
}
9598
className={cn(
96-
"grid grid-cols-12 w-full p-7 items-center cursor-pointer hover:bg-gray-50/90 transition-colors ease-in-out",
99+
"grid grid-cols-12 w-full p-4 py-4.5 cursor-pointer hover:bg-gray-50/90 transition-colors ease-in-out h-[71px]",
97100
!isLast && "border-b-[1px] border-gray-200"
98101
)}
99102
>
103+
<div className="col-span-1 flex justify-center items-center">
104+
{/* Empty space for alignment with interrupted items */}
105+
</div>
106+
100107
<div
101108
className={cn(
102-
"flex items-center justify-start gap-2",
103-
selectedInbox ? "col-span-7" : "col-span-9"
109+
"col-span-6 flex items-center justify-start gap-2",
110+
!selectedInbox && "col-span-9"
104111
)}
105112
>
106-
<p className="text-black text-sm font-semibold">Thread ID:</p>
113+
<p className="text-sm font-semibold text-black">Thread ID:</p>
107114
<ThreadIdCopyable showUUID threadId={threadData.thread.thread_id} />
108115
</div>
109116

110117
{selectedInbox && (
111-
<div className="col-span-2">
118+
<div className="col-span-2 flex items-center">
112119
<Button
113120
size="sm"
114121
variant="outline"
@@ -120,16 +127,16 @@ export function GenericInboxItem<
120127
</div>
121128
)}
122129

123-
<div className={cn("col-span-2", !selectedInbox && "col-start-10")}>
124-
<InboxItemStatuses status={threadData.status} />
125-
</div>
126-
127-
<p
130+
<div
128131
className={cn(
129-
"col-span-1 text-gray-600 font-light text-sm",
130-
!selectedInbox && "col-start-12"
132+
"col-span-2 flex items-center",
133+
!selectedInbox && "col-start-10"
131134
)}
132135
>
136+
<InboxItemStatuses status={threadData.status} />
137+
</div>
138+
139+
<p className="col-span-1 text-right text-sm text-gray-600 font-light pt-2">
133140
{updatedAtDateString}
134141
</p>
135142
</div>
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import { useState } from "react";
2+
import { motion, AnimatePresence } from "framer-motion";
3+
import { ChevronDown, ChevronUp } from "lucide-react";
4+
import { ThreadIdCopyable } from "./thread-id";
5+
6+
// Helper to check for complex types (Array or Object)
7+
function isComplexValue(value: any): boolean {
8+
return Array.isArray(value) || (typeof value === "object" && value !== null);
9+
}
10+
11+
// Helper to truncate long strings
12+
const truncateString = (str: string, maxLength: number = 100): string => {
13+
return str.length > maxLength ? str.substring(0, maxLength) + "..." : str;
14+
};
15+
16+
// Helper to render simple values or truncated complex values for the collapsed view
17+
const renderCollapsedValue = (
18+
value: any,
19+
isComplex: boolean
20+
): React.ReactNode => {
21+
if (value === null) {
22+
return <span className="text-gray-500">null</span>;
23+
}
24+
if (typeof value === "boolean") {
25+
return <span className="text-blue-600">{String(value)}</span>;
26+
}
27+
if (typeof value === "number") {
28+
return <span className="text-green-600">{String(value)}</span>;
29+
}
30+
if (typeof value === "string") {
31+
// Apply truncation only to strings directly, not stringified complex types here
32+
return (
33+
<span className="text-purple-600">
34+
&quot;{truncateString(value)}&quot;
35+
</span>
36+
);
37+
}
38+
if (isComplex) {
39+
// Render truncated complex value for collapsed view
40+
try {
41+
// Show limited items/keys for preview
42+
let previewValue: any;
43+
if (Array.isArray(value)) {
44+
previewValue = value.slice(0, 3); // Show first 3 items
45+
if (value.length > 3) previewValue.push("...");
46+
} else {
47+
const keys = Object.keys(value);
48+
previewValue = {};
49+
keys.slice(0, 3).forEach((key) => {
50+
previewValue[key] = value[key]; // Show first 3 keys/values
51+
});
52+
if (keys.length > 3) previewValue["..."] = "...";
53+
}
54+
const strValue = JSON.stringify(previewValue, null, 2); // Pretty print preview
55+
return (
56+
<code className="rounded bg-gray-50 px-2 py-1 font-mono text-sm whitespace-pre-wrap block">
57+
{/* Truncate the stringified preview if it's still too long */}
58+
{truncateString(strValue, 200)}
59+
</code>
60+
);
61+
} catch (_) {
62+
return <span className="text-red-500">Error creating preview</span>;
63+
}
64+
}
65+
// Fallback for other unexpected types
66+
return String(value);
67+
};
68+
69+
// Helper to render the value within a table cell, stringifying complex types as needed
70+
const renderTableCellValue = (value: any): React.ReactNode => {
71+
if (isComplexValue(value)) {
72+
try {
73+
// Stringify nested objects/arrays within the table
74+
return (
75+
<code className="rounded bg-gray-50 px-2 py-1 font-mono text-sm whitespace-pre-wrap block">
76+
{JSON.stringify(value, null, 2)}
77+
</code>
78+
);
79+
} catch (_) {
80+
return <span className="text-red-500">Error stringifying</span>;
81+
}
82+
}
83+
// Use renderCollapsedValue logic for primitive types for consistent styling
84+
return renderCollapsedValue(value, false);
85+
};
86+
87+
export function GenericInterruptValue({
88+
interrupt,
89+
id,
90+
}: {
91+
interrupt: unknown;
92+
id: string;
93+
}) {
94+
const [isExpanded, setIsExpanded] = useState(false);
95+
const complex = isComplexValue(interrupt);
96+
97+
// Determine if the expand button should be shown (only for complex types)
98+
let shouldShowExpandButton = false;
99+
if (complex) {
100+
try {
101+
const numEntries = Array.isArray(interrupt)
102+
? interrupt.length
103+
: typeof interrupt === "object" && interrupt !== null
104+
? Object.keys(interrupt).length
105+
: 0; // Default to 0 if not array or object
106+
// Show expand if more than 3 entries (as preview shows 3) or if it's non-empty
107+
shouldShowExpandButton = numEntries > 3;
108+
// Alternative: check string length if preferred
109+
// const contentStr = JSON.stringify(interrupt);
110+
// shouldShowExpandButton = contentStr.length > 200;
111+
} catch (_) {
112+
shouldShowExpandButton = false; // Don't show button if error
113+
}
114+
}
115+
116+
// Process entries for table view
117+
const processEntries = () => {
118+
if (Array.isArray(interrupt)) {
119+
return interrupt.map((item, index) => [index.toString(), item]);
120+
} else if (typeof interrupt === "object" && interrupt !== null) {
121+
return Object.entries(interrupt);
122+
}
123+
return [];
124+
};
125+
126+
const displayEntries = complex ? processEntries() : [];
127+
128+
return (
129+
<div className="overflow-hidden rounded-lg border border-gray-200">
130+
{/* Header */}
131+
<div className="border-b border-gray-200 bg-gray-50 px-4 py-2">
132+
<div className="flex flex-wrap items-center justify-between gap-2">
133+
<h3 className="font-medium text-gray-900 flex flex-wrap items-center justify-center gap-2">
134+
Interrupt <ThreadIdCopyable showUUID threadId={id} />
135+
</h3>
136+
{/* Simple Toggle Button in Header */}
137+
{complex && shouldShowExpandButton && (
138+
<button
139+
onClick={() => setIsExpanded(!isExpanded)}
140+
className="text-gray-500 hover:text-gray-700 p-1 rounded hover:bg-gray-200"
141+
aria-label={isExpanded ? "Collapse details" : "Expand details"}
142+
>
143+
{isExpanded ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
144+
</button>
145+
)}
146+
</div>
147+
</div>
148+
149+
{/* Body Content */}
150+
<motion.div
151+
className="bg-gray-100"
152+
initial={false}
153+
animate={{ height: "auto" }} // Let content dictate height
154+
transition={{ duration: 0.3, ease: "easeInOut" }}
155+
>
156+
<AnimatePresence mode="wait" initial={false}>
157+
{
158+
// Determine rendering mode
159+
(() => {
160+
const showTable =
161+
complex && (!shouldShowExpandButton || isExpanded);
162+
const showCollapsedPreview =
163+
complex && shouldShowExpandButton && !isExpanded;
164+
const showSimpleValue = !complex;
165+
166+
return (
167+
<motion.div
168+
key={showTable ? "table" : "preview"} // Key based on what's visible
169+
initial={{ opacity: 0, height: 0 }}
170+
animate={{ opacity: 1, height: "auto" }}
171+
exit={{ opacity: 0, height: 0 }}
172+
transition={{ duration: 0.2, ease: "easeInOut" }}
173+
style={{ overflow: "hidden" }} // Important for height animation
174+
>
175+
{showSimpleValue && (
176+
<div className="px-4 py-2 text-sm">
177+
{renderCollapsedValue(interrupt, false)}
178+
</div>
179+
)}
180+
{showCollapsedPreview && (
181+
<div className="px-4 py-2 text-sm">
182+
{renderCollapsedValue(interrupt, true)}{" "}
183+
{/* Render the preview */}
184+
</div>
185+
)}
186+
{showTable && (
187+
// Render expanded table
188+
<div
189+
className="overflow-x-auto"
190+
style={{
191+
maxHeight:
192+
"500px" /* Limit height for very long tables */,
193+
}}
194+
>
195+
<table className="min-w-full divide-y divide-gray-200">
196+
<thead className="bg-gray-50 sticky top-0 z-10">
197+
{" "}
198+
{/* Sticky header */}
199+
<tr>
200+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
201+
{Array.isArray(interrupt) ? "Index" : "Key"}
202+
</th>
203+
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
204+
Value
205+
</th>
206+
</tr>
207+
</thead>
208+
<tbody className="divide-y divide-gray-200 bg-white">
209+
{displayEntries.length === 0 && (
210+
<tr>
211+
<td
212+
colSpan={2}
213+
className="px-4 py-4 text-center text-sm text-gray-500"
214+
>
215+
{Array.isArray(interrupt)
216+
? "Array is empty"
217+
: "Object is empty"}
218+
</td>
219+
</tr>
220+
)}
221+
{displayEntries.map(([key, value]) => (
222+
<tr key={key}>
223+
<td className="px-4 py-2 text-sm font-medium whitespace-nowrap text-gray-900 align-top">
224+
{key}
225+
</td>
226+
<td className="px-4 py-2 text-sm text-gray-500 align-top">
227+
{/* Render cell value using the helper */}
228+
{renderTableCellValue(value)}
229+
</td>
230+
</tr>
231+
))}
232+
</tbody>
233+
</table>
234+
</div>
235+
)}
236+
</motion.div>
237+
);
238+
})()
239+
}
240+
</AnimatePresence>
241+
</motion.div>
242+
</div>
243+
);
244+
}

0 commit comments

Comments
 (0)