Add Deep Agents UI, pipeline fixes, and agent improvements.

Includes deep-agents-ui integration, rework detection via Gitea,
tool-call sanitization fixes, and startup scripts.
This commit is contained in:
dapa46
2026-06-18 19:33:26 +03:00
parent ba83d0cfe4
commit 0f38d8d588
83 changed files with 22561 additions and 179 deletions
@@ -0,0 +1,545 @@
"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]
);
// 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}
/>
);
})}
</>
)}
</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"
)}
>
{(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-[#2F6868] 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-[#2F6868] 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}
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";
@@ -0,0 +1,200 @@
"use client";
import React, { useMemo, useState, useCallback } from "react";
import { SubAgentIndicator } from "@/app/components/SubAgentIndicator";
import { ToolCallBox } from "@/app/components/ToolCallBox";
import { MarkdownContent } from "@/app/components/MarkdownContent";
import type {
SubAgent,
ToolCall,
ActionRequest,
ReviewConfig,
} from "@/app/types/types";
import { Message } from "@langchain/langgraph-sdk";
import {
extractSubAgentContent,
extractStringFromMessageContent,
} from "@/app/utils/utils";
import { cn } from "@/lib/utils";
interface ChatMessageProps {
message: Message;
toolCalls: ToolCall[];
isLoading?: boolean;
actionRequestsMap?: Map<string, ActionRequest>;
reviewConfigsMap?: Map<string, ReviewConfig>;
ui?: any[];
stream?: any;
onResumeInterrupt?: (value: any) => void;
graphId?: string;
}
export const ChatMessage = React.memo<ChatMessageProps>(
({
message,
toolCalls,
isLoading,
actionRequestsMap,
reviewConfigsMap,
ui,
stream,
onResumeInterrupt,
graphId,
}) => {
const isUser = message.type === "human";
const messageContent = extractStringFromMessageContent(message);
const hasContent = messageContent && messageContent.trim() !== "";
const hasToolCalls = toolCalls.length > 0;
const subAgents = useMemo(() => {
return toolCalls
.filter((toolCall: ToolCall) => {
return (
toolCall.name === "task" &&
toolCall.args["subagent_type"] &&
toolCall.args["subagent_type"] !== "" &&
toolCall.args["subagent_type"] !== null
);
})
.map((toolCall: ToolCall) => {
const subagentType = (toolCall.args as Record<string, unknown>)[
"subagent_type"
] as string;
return {
id: toolCall.id,
name: toolCall.name,
subAgentName: subagentType,
input: toolCall.args,
output: toolCall.result ? { result: toolCall.result } : undefined,
status: toolCall.status,
} as SubAgent;
});
}, [toolCalls]);
const [expandedSubAgents, setExpandedSubAgents] = useState<
Record<string, boolean>
>({});
const isSubAgentExpanded = useCallback(
(id: string) => expandedSubAgents[id] ?? true,
[expandedSubAgents]
);
const toggleSubAgent = useCallback((id: string) => {
setExpandedSubAgents((prev) => ({
...prev,
[id]: prev[id] === undefined ? false : !prev[id],
}));
}, []);
return (
<div
className={cn(
"flex w-full max-w-full overflow-x-hidden",
isUser && "flex-row-reverse"
)}
>
<div
className={cn(
"min-w-0 max-w-full",
isUser ? "max-w-[70%]" : "w-full"
)}
>
{hasContent && (
<div className={cn("relative flex items-end gap-0")}>
<div
className={cn(
"mt-4 overflow-hidden break-words text-sm font-normal leading-[150%]",
isUser
? "rounded-xl rounded-br-none border border-border px-3 py-2 text-foreground"
: "text-primary"
)}
style={
isUser
? { backgroundColor: "var(--color-user-message-bg)" }
: undefined
}
>
{isUser ? (
<p className="m-0 whitespace-pre-wrap break-words text-sm leading-relaxed">
{messageContent}
</p>
) : hasContent ? (
<MarkdownContent content={messageContent} />
) : null}
</div>
</div>
)}
{hasToolCalls && (
<div className="mt-4 flex w-full flex-col">
{toolCalls.map((toolCall: ToolCall) => {
if (toolCall.name === "task") return null;
const toolCallGenUiComponent = ui?.find(
(u) => u.metadata?.tool_call_id === toolCall.id
);
const actionRequest = actionRequestsMap?.get(toolCall.name);
const reviewConfig = reviewConfigsMap?.get(toolCall.name);
return (
<ToolCallBox
key={toolCall.id}
toolCall={toolCall}
uiComponent={toolCallGenUiComponent}
stream={stream}
graphId={graphId}
actionRequest={actionRequest}
reviewConfig={reviewConfig}
onResume={onResumeInterrupt}
isLoading={isLoading}
/>
);
})}
</div>
)}
{!isUser && subAgents.length > 0 && (
<div className="flex w-fit max-w-full flex-col gap-4">
{subAgents.map((subAgent) => (
<div
key={subAgent.id}
className="flex w-full flex-col gap-2"
>
<div className="flex items-end gap-2">
<div className="w-[calc(100%-100px)]">
<SubAgentIndicator
subAgent={subAgent}
onClick={() => toggleSubAgent(subAgent.id)}
isExpanded={isSubAgentExpanded(subAgent.id)}
/>
</div>
</div>
{isSubAgentExpanded(subAgent.id) && (
<div className="w-full max-w-full">
<div className="bg-surface border-border-light rounded-md border p-4">
<h4 className="text-primary/70 mb-2 text-xs font-semibold uppercase tracking-wider">
Input
</h4>
<div className="mb-4">
<MarkdownContent
content={extractSubAgentContent(subAgent.input)}
/>
</div>
{subAgent.output && (
<>
<h4 className="text-primary/70 mb-2 text-xs font-semibold uppercase tracking-wider">
Output
</h4>
<MarkdownContent
content={extractSubAgentContent(subAgent.output)}
/>
</>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
);
ChatMessage.displayName = "ChatMessage";
@@ -0,0 +1,124 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { StandaloneConfig } from "@/lib/config";
interface ConfigDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (config: StandaloneConfig) => void;
initialConfig?: StandaloneConfig;
}
export function ConfigDialog({
open,
onOpenChange,
onSave,
initialConfig,
}: ConfigDialogProps) {
const [deploymentUrl, setDeploymentUrl] = useState(
initialConfig?.deploymentUrl ||
process.env.NEXT_PUBLIC_DEPLOYMENT_URL ||
""
);
const [assistantId, setAssistantId] = useState(
initialConfig?.assistantId || process.env.NEXT_PUBLIC_ASSISTANT_ID || ""
);
const [langsmithApiKey, setLangsmithApiKey] = useState(
initialConfig?.langsmithApiKey ||
process.env.NEXT_PUBLIC_LANGSMITH_API_KEY ||
""
);
useEffect(() => {
if (open && initialConfig) {
setDeploymentUrl(initialConfig.deploymentUrl);
setAssistantId(initialConfig.assistantId);
setLangsmithApiKey(initialConfig.langsmithApiKey || "");
}
}, [open, initialConfig]);
const handleSave = () => {
if (!deploymentUrl || !assistantId) {
alert("Please fill in all required fields");
return;
}
onSave({
deploymentUrl,
assistantId,
langsmithApiKey: langsmithApiKey || undefined,
});
onOpenChange(false);
};
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
>
<DialogContent className="sm:max-w-[525px]">
<DialogHeader>
<DialogTitle>Configuration</DialogTitle>
<DialogDescription>
Configure your LangGraph deployment settings. These settings are
saved in your browser&apos;s local storage.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="deploymentUrl">Deployment URL</Label>
<Input
id="deploymentUrl"
placeholder="https://<deployment-url>"
value={deploymentUrl}
onChange={(e) => setDeploymentUrl(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="assistantId">Assistant ID</Label>
<Input
id="assistantId"
placeholder="<assistant-id>"
value={assistantId}
onChange={(e) => setAssistantId(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="langsmithApiKey">
LangSmith API Key{" "}
<span className="text-muted-foreground">(Optional)</span>
</Label>
<Input
id="langsmithApiKey"
type="password"
placeholder="lsv2_pt_..."
value={langsmithApiKey}
onChange={(e) => setLangsmithApiKey(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button onClick={handleSave}>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,296 @@
"use client";
import React, { useMemo, useCallback, useState, useEffect } from "react";
import { FileText, Copy, Download, Edit, Save, X, Loader2 } from "lucide-react";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { toast } from "sonner";
import { MarkdownContent } from "@/app/components/MarkdownContent";
import type { FileItem } from "@/app/types/types";
import useSWRMutation from "swr/mutation";
const LANGUAGE_MAP: Record<string, string> = {
js: "javascript",
jsx: "javascript",
ts: "typescript",
tsx: "typescript",
py: "python",
rb: "ruby",
go: "go",
rs: "rust",
java: "java",
cpp: "cpp",
c: "c",
cs: "csharp",
php: "php",
swift: "swift",
kt: "kotlin",
scala: "scala",
sh: "bash",
bash: "bash",
zsh: "bash",
json: "json",
xml: "xml",
html: "html",
css: "css",
scss: "scss",
sass: "sass",
less: "less",
sql: "sql",
yaml: "yaml",
yml: "yaml",
toml: "toml",
ini: "ini",
dockerfile: "dockerfile",
makefile: "makefile",
};
export const FileViewDialog = React.memo<{
file: FileItem | null;
onSaveFile: (fileName: string, content: string) => Promise<void>;
onClose: () => void;
editDisabled: boolean;
}>(({ file, onSaveFile, onClose, editDisabled }) => {
const [isEditingMode, setIsEditingMode] = useState(file === null);
const [fileName, setFileName] = useState(String(file?.path || ""));
const [fileContent, setFileContent] = useState(String(file?.content || ""));
const fileUpdate = useSWRMutation(
{ kind: "files-update", fileName, fileContent },
async ({ fileName, fileContent }) => {
if (!fileName || !fileContent) return;
return await onSaveFile(fileName, fileContent);
},
{
onSuccess: () => setIsEditingMode(false),
onError: (error) => toast.error(`Failed to save file: ${error}`),
}
);
useEffect(() => {
setFileName(String(file?.path || ""));
setFileContent(String(file?.content || ""));
setIsEditingMode(file === null);
}, [file]);
const fileExtension = useMemo(() => {
const fileNameStr = String(fileName || "");
return fileNameStr.split(".").pop()?.toLowerCase() || "";
}, [fileName]);
const isMarkdown = useMemo(() => {
return fileExtension === "md" || fileExtension === "markdown";
}, [fileExtension]);
const language = useMemo(() => {
return LANGUAGE_MAP[fileExtension] || "text";
}, [fileExtension]);
const handleCopy = useCallback(() => {
if (fileContent) {
navigator.clipboard.writeText(fileContent);
}
}, [fileContent]);
const handleDownload = useCallback(() => {
if (fileContent && fileName) {
const blob = new Blob([fileContent], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}, [fileContent, fileName]);
const handleEdit = useCallback(() => {
setIsEditingMode(true);
}, []);
const handleCancel = useCallback(() => {
if (file === null) {
onClose();
} else {
setFileName(String(file.path));
setFileContent(String(file.content));
setIsEditingMode(false);
}
}, [file, onClose]);
const fileNameIsValid = useMemo(() => {
return (
fileName.trim() !== "" &&
!fileName.includes("/") &&
!fileName.includes(" ")
);
}, [fileName]);
return (
<Dialog
open={true}
onOpenChange={onClose}
>
<DialogContent className="flex h-[80vh] max-h-[80vh] min-w-[60vw] flex-col p-6">
<DialogTitle className="sr-only">
{file?.path || "New File"}
</DialogTitle>
<div className="mb-4 flex items-center justify-between border-b border-border pb-4">
<div className="flex min-w-0 items-center gap-2">
<FileText className="text-primary/50 h-5 w-5 shrink-0" />
{isEditingMode && file === null ? (
<Input
value={fileName}
onChange={(e) => setFileName(e.target.value)}
placeholder="Enter filename..."
className="text-base font-medium"
aria-invalid={!fileNameIsValid}
/>
) : (
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-base font-medium text-primary">
{file?.path}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{!isEditingMode && (
<>
<Button
onClick={handleEdit}
variant="ghost"
size="sm"
className="h-8 px-2"
disabled={editDisabled}
>
<Edit
size={16}
className="mr-1"
/>
Edit
</Button>
<Button
onClick={handleCopy}
variant="ghost"
size="sm"
className="h-8 px-2"
>
<Copy
size={16}
className="mr-1"
/>
Copy
</Button>
<Button
onClick={handleDownload}
variant="ghost"
size="sm"
className="h-8 px-2"
>
<Download
size={16}
className="mr-1"
/>
Download
</Button>
</>
)}
</div>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
{isEditingMode ? (
<Textarea
value={fileContent}
onChange={(e) => setFileContent(e.target.value)}
placeholder="Enter file content..."
className="h-full min-h-[400px] resize-none font-mono text-sm"
/>
) : (
<ScrollArea className="bg-surface h-full rounded-md">
<div className="p-4">
{fileContent ? (
isMarkdown ? (
<div className="rounded-md p-6">
<MarkdownContent content={fileContent} />
</div>
) : (
<SyntaxHighlighter
language={language}
style={oneDark}
customStyle={{
margin: 0,
borderRadius: "0.5rem",
fontSize: "0.875rem",
}}
showLineNumbers
wrapLines={true}
lineProps={{
style: {
whiteSpace: "pre-wrap",
},
}}
>
{fileContent}
</SyntaxHighlighter>
)
) : (
<div className="flex items-center justify-center p-12">
<p className="text-sm text-muted-foreground">
File is empty
</p>
</div>
)}
</div>
</ScrollArea>
)}
</div>
{isEditingMode && (
<div className="mt-4 flex justify-end gap-2 border-t border-border pt-4">
<Button
onClick={handleCancel}
variant="outline"
size="sm"
>
<X
size={16}
className="mr-1"
/>
Cancel
</Button>
<Button
onClick={() => fileUpdate.trigger()}
size="sm"
disabled={
fileUpdate.isMutating ||
!fileName.trim() ||
!fileContent.trim() ||
!fileNameIsValid
}
>
{fileUpdate.isMutating ? (
<Loader2
size={16}
className="mr-1 animate-spin"
/>
) : (
<Save
size={16}
className="mr-1"
/>
)}
Save
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
});
FileViewDialog.displayName = "FileViewDialog";
@@ -0,0 +1,135 @@
"use client";
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { cn } from "@/lib/utils";
interface MarkdownContentProps {
content: string;
className?: string;
}
export const MarkdownContent = React.memo<MarkdownContentProps>(
({ content, className = "" }) => {
return (
<div
className={cn(
"prose min-w-0 max-w-full overflow-hidden break-words text-sm leading-relaxed text-inherit [&_h1:first-child]:mt-0 [&_h1]:mb-4 [&_h1]:mt-6 [&_h1]:font-semibold [&_h2:first-child]:mt-0 [&_h2]:mb-4 [&_h2]:mt-6 [&_h2]:font-semibold [&_h3:first-child]:mt-0 [&_h3]:mb-4 [&_h3]:mt-6 [&_h3]:font-semibold [&_h4:first-child]:mt-0 [&_h4]:mb-4 [&_h4]:mt-6 [&_h4]:font-semibold [&_h5:first-child]:mt-0 [&_h5]:mb-4 [&_h5]:mt-6 [&_h5]:font-semibold [&_h6:first-child]:mt-0 [&_h6]:mb-4 [&_h6]:mt-6 [&_h6]:font-semibold [&_p:last-child]:mb-0 [&_p]:mb-4",
className
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({
inline,
className,
children,
...props
}: {
inline?: boolean;
className?: string;
children?: React.ReactNode;
}) {
const match = /language-(\w+)/.exec(className || "");
return !inline && match ? (
<SyntaxHighlighter
style={oneDark}
language={match[1]}
PreTag="div"
className="max-w-full rounded-md text-sm"
wrapLines={true}
wrapLongLines={true}
lineProps={{
style: {
wordBreak: "break-all",
whiteSpace: "pre-wrap",
overflowWrap: "break-word",
},
}}
customStyle={{
margin: 0,
maxWidth: "100%",
overflowX: "auto",
fontSize: "0.875rem",
}}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code
className="bg-surface rounded-sm px-1 py-0.5 font-mono text-[0.9em]"
{...props}
>
{children}
</code>
);
},
pre({ children }: { children?: React.ReactNode }) {
return (
<div className="my-4 max-w-full overflow-hidden last:mb-0">
{children}
</div>
);
},
a({
href,
children,
}: {
href?: string;
children?: React.ReactNode;
}) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary no-underline hover:underline"
>
{children}
</a>
);
},
blockquote({ children }: { children?: React.ReactNode }) {
return (
<blockquote className="text-primary/50 my-4 border-l-4 border-border pl-4 italic">
{children}
</blockquote>
);
},
ul({ children }: { children?: React.ReactNode }) {
return (
<ul className="my-4 pl-6 [&>li:last-child]:mb-0 [&>li]:mb-1">
{children}
</ul>
);
},
ol({ children }: { children?: React.ReactNode }) {
return (
<ol className="my-4 pl-6 [&>li:last-child]:mb-0 [&>li]:mb-1">
{children}
</ol>
);
},
table({ children }: { children?: React.ReactNode }) {
return (
<div className="my-4 overflow-x-auto">
<table className="[&_th]:bg-surface w-full border-collapse [&_td]:border [&_td]:border-border [&_td]:p-2 [&_th]:border [&_th]:border-border [&_th]:p-2 [&_th]:text-left [&_th]:font-semibold">
{children}
</table>
</div>
);
},
}}
>
{content}
</ReactMarkdown>
</div>
);
}
);
MarkdownContent.displayName = "MarkdownContent";
@@ -0,0 +1,48 @@
"use client";
import React from "react";
import { Button } from "@/components/ui/button";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { SubAgent } from "@/app/types/types";
interface SubAgentIndicatorProps {
subAgent: SubAgent;
onClick: () => void;
isExpanded?: boolean;
}
export const SubAgentIndicator = React.memo<SubAgentIndicatorProps>(
({ subAgent, onClick, isExpanded = true }) => {
return (
<div className="w-fit max-w-[70vw] overflow-hidden rounded-lg border-none bg-card shadow-none outline-none">
<Button
variant="ghost"
size="sm"
onClick={onClick}
className="flex w-full items-center justify-between gap-2 border-none px-4 py-2 text-left shadow-none outline-none transition-colors duration-200"
>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="font-sans text-[15px] font-bold leading-[140%] tracking-[-0.6px] text-[#3F3F46]">
{subAgent.subAgentName}
</span>
</div>
{isExpanded ? (
<ChevronUp
size={14}
className="shrink-0 text-[#70707B]"
/>
) : (
<ChevronDown
size={14}
className="shrink-0 text-[#70707B]"
/>
)}
</div>
</Button>
</div>
);
}
);
SubAgentIndicator.displayName = "SubAgentIndicator";
@@ -0,0 +1,266 @@
"use client";
import React, {
useMemo,
useCallback,
useState,
useEffect,
useRef,
} from "react";
import {
FileText,
CheckCircle,
Circle,
Clock,
ChevronDown,
} from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { TodoItem, FileItem } from "@/app/types/types";
import { useChatContext } from "@/providers/ChatProvider";
import { cn } from "@/lib/utils";
import { FileViewDialog } from "@/app/components/FileViewDialog";
export function FilesPopover({
files,
setFiles,
editDisabled,
}: {
files: Record<string, string>;
setFiles: (files: Record<string, string>) => Promise<void>;
editDisabled: boolean;
}) {
const [selectedFile, setSelectedFile] = useState<FileItem | null>(null);
const handleSaveFile = useCallback(
async (fileName: string, content: string) => {
await setFiles({ ...files, [fileName]: content });
setSelectedFile({ path: fileName, content: content });
},
[files, setFiles]
);
return (
<>
{Object.keys(files).length === 0 ? (
<div className="flex h-full items-center justify-center p-4 text-center">
<p className="text-xs text-muted-foreground">No files created yet</p>
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(256px,1fr))] gap-2">
{Object.keys(files).map((file) => {
const filePath = String(file);
const rawContent = files[file];
let fileContent: string;
if (
typeof rawContent === "object" &&
rawContent !== null &&
"content" in rawContent
) {
const contentArray = (rawContent as { content: unknown }).content;
if (Array.isArray(contentArray)) {
fileContent = contentArray.join("\n");
} else {
fileContent = String(contentArray || "");
}
} else {
fileContent = String(rawContent || "");
}
return (
<button
key={filePath}
type="button"
onClick={() =>
setSelectedFile({ path: filePath, content: fileContent })
}
className="cursor-pointer space-y-1 truncate rounded-md border border-border px-2 py-3 shadow-sm transition-colors"
style={{
backgroundColor: "var(--color-file-button)",
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor =
"var(--color-file-button-hover)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor =
"var(--color-file-button)";
}}
>
<FileText
size={24}
className="mx-auto text-muted-foreground"
/>
<span className="mx-auto block w-full truncate break-words text-center text-sm leading-relaxed text-foreground">
{filePath}
</span>
</button>
);
})}
</div>
)}
{selectedFile && (
<FileViewDialog
file={selectedFile}
onSaveFile={handleSaveFile}
onClose={() => setSelectedFile(null)}
editDisabled={editDisabled}
/>
)}
</>
);
}
export const TasksFilesSidebar = React.memo<{
todos: TodoItem[];
files: Record<string, string>;
setFiles: (files: Record<string, string>) => Promise<void>;
}>(({ todos, files, setFiles }) => {
const { isLoading, interrupt } = useChatContext();
const [tasksOpen, setTasksOpen] = useState(false);
const [filesOpen, setFilesOpen] = useState(false);
// Track previous counts to detect when content goes from empty to having items
const prevTodosCount = useRef(todos.length);
const prevFilesCount = useRef(Object.keys(files).length);
// Auto-expand when todos go from empty to having content
useEffect(() => {
if (prevTodosCount.current === 0 && todos.length > 0) {
setTasksOpen(true);
}
prevTodosCount.current = todos.length;
}, [todos.length]);
// Auto-expand when files go from empty to having content
const filesCount = Object.keys(files).length;
useEffect(() => {
if (prevFilesCount.current === 0 && filesCount > 0) {
setFilesOpen(true);
}
prevFilesCount.current = filesCount;
}, [filesCount]);
const getStatusIcon = useCallback((status: TodoItem["status"]) => {
switch (status) {
case "completed":
return (
<CheckCircle
size={12}
className="text-success/80"
/>
);
case "in_progress":
return (
<Clock
size={12}
className="text-warning/80"
/>
);
default:
return (
<Circle
size={10}
className="text-tertiary/70"
/>
);
}
}, []);
const groupedTodos = useMemo(() => {
return {
pending: todos.filter((t) => t.status === "pending"),
in_progress: todos.filter((t) => t.status === "in_progress"),
completed: todos.filter((t) => t.status === "completed"),
};
}, [todos]);
const groupedLabels = {
pending: "Pending",
in_progress: "In Progress",
completed: "Completed",
};
return (
<div className="min-h-0 w-full flex-1">
<div className="font-inter flex h-full w-full flex-col p-0">
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
<div className="flex items-center justify-between px-3 pb-1.5 pt-2">
<span className="text-xs font-semibold tracking-wide text-zinc-600">
AGENT TASKS
</span>
<button
onClick={() => setTasksOpen((v) => !v)}
className={cn(
"flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-transform duration-200 hover:bg-muted",
tasksOpen ? "rotate-180" : "rotate-0"
)}
aria-label="Toggle tasks panel"
>
<ChevronDown size={14} />
</button>
</div>
{tasksOpen && (
<div className="bg-muted-secondary rounded-xl px-3 pb-2">
<ScrollArea className="h-full">
{todos.length === 0 ? (
<div className="flex h-full items-center justify-center p-4 text-center">
<p className="text-xs text-muted-foreground">
No tasks created yet
</p>
</div>
) : (
<div className="ml-1 p-0.5">
{Object.entries(groupedTodos).map(([status, todos]) => (
<div className="mb-4">
<h3 className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-tertiary">
{groupedLabels[status as keyof typeof groupedLabels]}
</h3>
{todos.map((todo, index) => (
<div
key={`${status}_${todo.id}_${index}`}
className="mb-1.5 flex items-start gap-2 rounded-sm p-1 text-sm"
>
{getStatusIcon(todo.status)}
<span className="flex-1 break-words leading-relaxed text-inherit">
{todo.content}
</span>
</div>
))}
</div>
))}
</div>
)}
</ScrollArea>
</div>
)}
<div className="flex items-center justify-between px-3 pb-1.5 pt-2">
<span className="text-xs font-semibold tracking-wide text-zinc-600">
FILE SYSTEM
</span>
<button
onClick={() => setFilesOpen((v) => !v)}
className={cn(
"flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-transform duration-200 hover:bg-muted",
filesOpen ? "rotate-180" : "rotate-0"
)}
aria-label="Toggle files panel"
>
<ChevronDown size={14} />
</button>
</div>
{filesOpen && (
<FilesPopover
files={files}
setFiles={setFiles}
editDisabled={isLoading === true || interrupt !== undefined}
/>
)}
</div>
</div>
</div>
);
});
TasksFilesSidebar.displayName = "TasksFilesSidebar";
@@ -0,0 +1,369 @@
"use client";
import { useEffect, useMemo, useState, useRef, useCallback } from "react";
import { format } from "date-fns";
import { Loader2, MessageSquare, X } from "lucide-react";
import { useQueryState } from "nuqs";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select,
SelectContent,
SelectGroup,
SelectLabel,
SelectItem,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type { ThreadItem } from "@/app/hooks/useThreads";
import { useThreads } from "@/app/hooks/useThreads";
type StatusFilter = "all" | "idle" | "busy" | "interrupted" | "error";
const GROUP_LABELS = {
interrupted: "Requiring Attention",
today: "Today",
yesterday: "Yesterday",
week: "This Week",
older: "Older",
} as const;
const STATUS_COLORS: Record<ThreadItem["status"], string> = {
idle: "bg-green-500",
busy: "bg-blue-500",
interrupted: "bg-orange-500",
error: "bg-red-600",
};
function getThreadColor(status: ThreadItem["status"]): string {
return STATUS_COLORS[status] ?? "bg-gray-400";
}
function formatTime(date: Date, now = new Date()): string {
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return format(date, "HH:mm");
if (days === 1) return "Yesterday";
if (days < 7) return format(date, "EEEE");
return format(date, "MM/dd");
}
function StatusFilterItem({
status,
label,
badge,
}: {
status: ThreadItem["status"];
label: string;
badge?: number;
}) {
return (
<span className="inline-flex items-center gap-2">
<span
className={cn(
"inline-block size-2 rounded-full",
getThreadColor(status)
)}
/>
{label}
{badge !== undefined && badge > 0 && (
<span className="ml-1 inline-flex items-center justify-center rounded-full bg-red-600 px-1.5 py-0.5 text-xs font-bold leading-none text-white">
{badge}
</span>
)}
</span>
);
}
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="mt-1 text-xs text-muted-foreground">{message}</p>
</div>
);
}
function LoadingState() {
return (
<div className="space-y-2 p-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton
key={i}
className="h-16 w-full"
/>
))}
</div>
);
}
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>
</div>
);
}
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 [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const threads = useThreads({
status: statusFilter === "all" ? undefined : statusFilter,
limit: 20,
});
const flattened = useMemo(() => {
return threads.data?.flat() ?? [];
}, [threads.data]);
const isLoadingMore =
threads.size > 0 && threads.data?.[threads.size - 1] == null;
const isEmpty = threads.data?.at(0)?.length === 0;
const isReachingEnd = isEmpty || (threads.data?.at(-1)?.length ?? 0) < 20;
// Group threads by time and status
const grouped = useMemo(() => {
const now = new Date();
const groups: Record<keyof typeof GROUP_LABELS, ThreadItem[]> = {
interrupted: [],
today: [],
yesterday: [],
week: [],
older: [],
};
flattened.forEach((thread) => {
if (thread.status === "interrupted") {
groups.interrupted.push(thread);
return;
}
const diff = now.getTime() - thread.updatedAt.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
groups.today.push(thread);
} else if (days === 1) {
groups.yesterday.push(thread);
} else if (days < 7) {
groups.week.push(thread);
} else {
groups.older.push(thread);
}
});
return groups;
}, [flattened]);
const interruptedCount = useMemo(() => {
return flattened.filter((t) => t.status === "interrupted").length;
}, [flattened]);
// Expose thread list revalidation to parent component
// Use refs to create a stable callback that always calls the latest mutate function
const onMutateReadyRef = useRef(onMutateReady);
const mutateRef = useRef(threads.mutate);
useEffect(() => {
onMutateReadyRef.current = onMutateReady;
}, [onMutateReady]);
useEffect(() => {
mutateRef.current = threads.mutate;
}, [threads.mutate]);
const mutateFn = useCallback(() => {
mutateRef.current();
}, []);
useEffect(() => {
onMutateReadyRef.current?.(mutateFn);
// Only run once on mount to avoid infinite loops
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Notify parent of interrupt count changes
useEffect(() => {
onInterruptCountChange?.(interruptedCount);
}, [interruptedCount, onInterruptCountChange]);
return (
<div className="absolute inset-0 flex flex-col">
{/* Header with title, filter, and close button */}
<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>
<div className="flex items-center gap-2">
<Select
value={statusFilter}
onValueChange={(v) => setStatusFilter(v as StatusFilter)}
>
<SelectTrigger className="w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="all">All statuses</SelectItem>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Active</SelectLabel>
<SelectItem value="idle">
<StatusFilterItem
status="idle"
label="Idle"
/>
</SelectItem>
<SelectItem value="busy">
<StatusFilterItem
status="busy"
label="Busy"
/>
</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Attention</SelectLabel>
<SelectItem value="interrupted">
<StatusFilterItem
status="interrupted"
label="Interrupted"
badge={interruptedCount}
/>
</SelectItem>
<SelectItem value="error">
<StatusFilterItem
status="error"
label="Error"
/>
</SelectItem>
</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>
<ScrollArea className="h-0 flex-1">
{threads.error && <ErrorState message={threads.error.message} />}
{!threads.error && !threads.data && threads.isLoading && (
<LoadingState />
)}
{!threads.error && !threads.isLoading && isEmpty && <EmptyState />}
{!threads.error && !isEmpty && (
<div className="box-border w-full max-w-full overflow-hidden p-2">
{(
Object.keys(GROUP_LABELS) as Array<keyof typeof GROUP_LABELS>
).map((group) => {
const groupThreads = grouped[group];
if (groupThreads.length === 0) return null;
return (
<div
key={group}
className="mb-4"
>
<h4 className="m-0 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{GROUP_LABELS[group]}
</h4>
<div className="flex flex-col gap-1">
{groupThreads.map((thread) => (
<button
key={thread.id}
type="button"
onClick={() => onThreadSelect(thread.id)}
className={cn(
"grid w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-3 text-left transition-colors duration-200",
"hover:bg-accent",
currentThreadId === thread.id
? "border border-primary bg-accent hover:bg-accent"
: "border border-transparent bg-transparent"
)}
aria-current={currentThreadId === thread.id}
>
<div className="min-w-0 flex-1">
{/* Title + Timestamp Row */}
<div className="mb-1 flex items-center justify-between">
<h3 className="truncate text-sm font-semibold">
{thread.title}
</h3>
<span className="ml-2 flex-shrink-0 text-xs text-muted-foreground">
{formatTime(thread.updatedAt)}
</span>
</div>
{/* Description + Status Row */}
<div className="flex items-center justify-between">
<p className="flex-1 truncate text-sm text-muted-foreground">
{thread.description}
</p>
<div className="ml-2 flex-shrink-0">
<div
className={cn(
"h-2 w-2 rounded-full",
getThreadColor(thread.status)
)}
/>
</div>
</div>
</div>
</button>
))}
</div>
</div>
);
})}
{!isReachingEnd && (
<div className="flex justify-center py-4">
<Button
variant="outline"
size="sm"
onClick={() => threads.setSize(threads.size + 1)}
disabled={isLoadingMore}
>
{isLoadingMore ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading...
</>
) : (
"Load More"
)}
</Button>
</div>
)}
</div>
)}
</ScrollArea>
</div>
);
}
@@ -0,0 +1,287 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { AlertCircle, Check, X, Pencil } from "lucide-react";
import type { ActionRequest, ReviewConfig } from "@/app/types/types";
import { cn } from "@/lib/utils";
interface ToolApprovalInterruptProps {
actionRequest: ActionRequest;
reviewConfig?: ReviewConfig;
onResume: (value: any) => void;
isLoading?: boolean;
}
export function ToolApprovalInterrupt({
actionRequest,
reviewConfig,
onResume,
isLoading,
}: ToolApprovalInterruptProps) {
const [rejectionMessage, setRejectionMessage] = useState("");
const [isEditing, setIsEditing] = useState(false);
const [editedArgs, setEditedArgs] = useState<Record<string, unknown>>({});
const [showRejectionInput, setShowRejectionInput] = useState(false);
const allowedDecisions = reviewConfig?.allowedDecisions ?? [
"approve",
"reject",
"edit",
];
const handleApprove = () => {
onResume({
decisions: [{ type: "approve" }],
});
};
const handleReject = () => {
if (showRejectionInput) {
onResume({
decisions: [
{
type: "reject",
message: rejectionMessage.trim(),
},
],
});
} else {
setShowRejectionInput(true);
}
};
const handleRejectConfirm = () => {
onResume({
decisions: [
{
type: "reject",
message: rejectionMessage.trim(),
},
],
});
};
const handleEdit = () => {
if (isEditing) {
onResume({
decisions: [
{
type: "edit",
edited_action: {
name: actionRequest.name,
args: editedArgs,
},
},
],
});
setIsEditing(false);
setEditedArgs({});
}
};
const startEditing = () => {
setIsEditing(true);
setEditedArgs(JSON.parse(JSON.stringify(actionRequest.args)));
setShowRejectionInput(false);
};
const cancelEditing = () => {
setIsEditing(false);
setEditedArgs({});
};
const updateEditedArg = (key: string, value: string) => {
try {
const parsedValue =
value.trim().startsWith("{") || value.trim().startsWith("[")
? JSON.parse(value)
: value;
setEditedArgs((prev) => ({ ...prev, [key]: parsedValue }));
} catch {
setEditedArgs((prev) => ({ ...prev, [key]: value }));
}
};
return (
<div className="w-full rounded-md border border-border bg-muted/30 p-4">
{/* Header */}
<div className="mb-3 flex items-center gap-2 text-foreground">
<AlertCircle
size={16}
className="text-yellow-600 dark:text-yellow-400"
/>
<span className="text-xs font-semibold uppercase tracking-wider">
Approval Required
</span>
</div>
{/* Description */}
{actionRequest.description && (
<p className="mb-3 text-sm text-muted-foreground">
{actionRequest.description}
</p>
)}
{/* Tool Info Card */}
<div className="mb-4 rounded-sm border border-border bg-background p-3">
<div className="mb-2">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Tool
</span>
<p className="mt-1 font-mono text-sm font-medium text-foreground">
{actionRequest.name}
</p>
</div>
{isEditing ? (
<div>
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Edit Arguments
</span>
<div className="mt-2 space-y-3">
{Object.entries(actionRequest.args).map(([key, value]) => (
<div key={key}>
<label className="mb-1 block text-xs font-medium text-foreground">
{key}
</label>
<Textarea
value={
editedArgs[key] !== undefined
? typeof editedArgs[key] === "string"
? (editedArgs[key] as string)
: JSON.stringify(editedArgs[key], null, 2)
: typeof value === "string"
? value
: JSON.stringify(value, null, 2)
}
onChange={(e) => updateEditedArg(key, e.target.value)}
className="font-mono text-xs"
rows={
typeof value === "string" && value.length < 100 ? 2 : 4
}
disabled={isLoading}
/>
</div>
))}
</div>
</div>
) : (
<div>
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Arguments
</span>
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-all rounded-sm border border-border bg-muted/40 p-2 font-mono text-xs text-foreground">
{JSON.stringify(actionRequest.args, null, 2)}
</pre>
</div>
)}
</div>
{/* Rejection Message Input */}
{showRejectionInput && !isEditing && (
<div className="mb-4">
<label className="mb-2 block text-xs font-medium text-foreground">
Rejection Message (optional)
</label>
<Textarea
value={rejectionMessage}
onChange={(e) => setRejectionMessage(e.target.value)}
placeholder="Explain why you're rejecting this action..."
className="text-sm"
rows={2}
disabled={isLoading}
/>
</div>
)}
{/* Actions */}
<div className="flex flex-wrap gap-2">
{isEditing ? (
<>
<Button
variant="outline"
size="sm"
onClick={cancelEditing}
disabled={isLoading}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleEdit}
disabled={isLoading}
className="bg-green-600 text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700"
>
<Check size={14} />
{isLoading ? "Saving..." : "Save & Approve"}
</Button>
</>
) : showRejectionInput ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => {
setShowRejectionInput(false);
setRejectionMessage("");
}}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleRejectConfirm}
disabled={isLoading}
>
{isLoading ? "Rejecting..." : "Confirm Reject"}
</Button>
</>
) : (
<>
{allowedDecisions.includes("reject") && (
<Button
variant="outline"
size="sm"
onClick={handleReject}
disabled={isLoading}
className="text-destructive hover:bg-destructive/10"
>
<X size={14} />
Reject
</Button>
)}
{allowedDecisions.includes("edit") && (
<Button
variant="outline"
size="sm"
onClick={startEditing}
disabled={isLoading}
>
<Pencil size={14} />
Edit
</Button>
)}
{allowedDecisions.includes("approve") && (
<Button
size="sm"
onClick={handleApprove}
disabled={isLoading}
className={cn(
"bg-green-600 text-white hover:bg-green-700",
"dark:bg-green-600 dark:hover:bg-green-700"
)}
>
<Check size={14} />
{isLoading ? "Approving..." : "Approve"}
</Button>
)}
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,230 @@
"use client";
import React, { useState, useMemo, useCallback } from "react";
import {
ChevronDown,
ChevronUp,
Terminal,
AlertCircle,
Loader2,
CircleCheckBigIcon,
StopCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { ToolCall, ActionRequest, ReviewConfig } from "@/app/types/types";
import { cn } from "@/lib/utils";
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
import { ToolApprovalInterrupt } from "@/app/components/ToolApprovalInterrupt";
interface ToolCallBoxProps {
toolCall: ToolCall;
uiComponent?: any;
stream?: any;
graphId?: string;
actionRequest?: ActionRequest;
reviewConfig?: ReviewConfig;
onResume?: (value: any) => void;
isLoading?: boolean;
}
export const ToolCallBox = React.memo<ToolCallBoxProps>(
({
toolCall,
uiComponent,
stream,
graphId,
actionRequest,
reviewConfig,
onResume,
isLoading,
}) => {
const [isExpanded, setIsExpanded] = useState(
() => !!uiComponent || !!actionRequest
);
const [expandedArgs, setExpandedArgs] = useState<Record<string, boolean>>(
{}
);
const { name, args, result, status } = useMemo(() => {
return {
name: toolCall.name || "Unknown Tool",
args: toolCall.args || {},
result: toolCall.result,
status: toolCall.status || "completed",
};
}, [toolCall]);
const statusIcon = useMemo(() => {
switch (status) {
case "completed":
return <CircleCheckBigIcon />;
case "error":
return (
<AlertCircle
size={14}
className="text-destructive"
/>
);
case "pending":
return (
<Loader2
size={14}
className="animate-spin"
/>
);
case "interrupted":
return (
<StopCircle
size={14}
className="text-orange-500"
/>
);
default:
return (
<Terminal
size={14}
className="text-muted-foreground"
/>
);
}
}, [status]);
const toggleExpanded = useCallback(() => {
setIsExpanded((prev) => !prev);
}, []);
const toggleArgExpanded = useCallback((argKey: string) => {
setExpandedArgs((prev) => ({
...prev,
[argKey]: !prev[argKey],
}));
}, []);
const hasContent = result || Object.keys(args).length > 0;
return (
<div
className={cn(
"w-full overflow-hidden rounded-lg border-none shadow-none outline-none transition-colors duration-200 hover:bg-accent",
isExpanded && hasContent && "bg-accent"
)}
>
<Button
variant="ghost"
size="sm"
onClick={toggleExpanded}
className={cn(
"flex w-full items-center justify-between gap-2 border-none px-2 py-2 text-left shadow-none outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-default"
)}
disabled={!hasContent}
>
<div className="flex w-full items-center justify-between gap-2">
<div className="flex items-center gap-2">
{statusIcon}
<span className="text-[15px] font-medium tracking-[-0.6px] text-foreground">
{name}
</span>
</div>
{hasContent &&
(isExpanded ? (
<ChevronUp
size={14}
className="shrink-0 text-muted-foreground"
/>
) : (
<ChevronDown
size={14}
className="shrink-0 text-muted-foreground"
/>
))}
</div>
</Button>
{isExpanded && hasContent && (
<div className="px-4 pb-4">
{uiComponent && stream && graphId ? (
<div className="mt-4">
<LoadExternalComponent
key={uiComponent.id}
stream={stream}
message={uiComponent}
namespace={graphId}
meta={{ status, args, result: result ?? "No Result Yet" }}
/>
</div>
) : actionRequest && onResume ? (
// Show tool approval UI when there's an action request but no GenUI
<div className="mt-4">
<ToolApprovalInterrupt
actionRequest={actionRequest}
reviewConfig={reviewConfig}
onResume={onResume}
isLoading={isLoading}
/>
</div>
) : (
<>
{Object.keys(args).length > 0 && (
<div className="mt-4">
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Arguments
</h4>
<div className="space-y-2">
{Object.entries(args).map(([key, value]) => (
<div
key={key}
className="rounded-sm border border-border"
>
<button
onClick={() => toggleArgExpanded(key)}
className="flex w-full items-center justify-between bg-muted/30 p-2 text-left text-xs font-medium transition-colors hover:bg-muted/50"
>
<span className="font-mono">{key}</span>
{expandedArgs[key] ? (
<ChevronUp
size={12}
className="text-muted-foreground"
/>
) : (
<ChevronDown
size={12}
className="text-muted-foreground"
/>
)}
</button>
{expandedArgs[key] && (
<div className="border-t border-border bg-muted/20 p-2">
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs leading-6 text-foreground">
{typeof value === "string"
? value
: JSON.stringify(value, null, 2)}
</pre>
</div>
)}
</div>
))}
</div>
</div>
)}
{result && (
<div className="mt-4">
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Result
</h4>
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-all rounded-sm border border-border bg-muted/40 p-2 font-mono text-xs leading-7 text-foreground">
{typeof result === "string"
? result
: JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</>
)}
</div>
)}
</div>
);
}
);
ToolCallBox.displayName = "ToolCallBox";
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+395
View File
@@ -0,0 +1,395 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
/* Remove default focus box-shadows */
input:focus,
textarea:focus,
select:focus {
box-shadow: none !important;
}
/* Set default outline color to match brand instead of browser blue */
* {
outline-color: hsl(var(--primary));
}
:root {
/* App-specific color variables */
--color-primary: #1c3c3c;
--color-user-message: #076699;
--color-user-message-bg: #e8f4f8;
--color-avatar-bg: #e8ebeb;
--color-secondary: #1c3c3c;
--color-success: #10b981;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-background: #f9f9f9;
--color-subagent-hover: #bbc4c4;
--color-surface: #f9fafb;
--color-border: #e5e7eb;
--color-border-light: #f3f4f6;
--color-text-primary: #111827;
--color-text-secondary: #6b7280;
--color-text-tertiary: #9ca3af;
--color-file-button: #ffffff;
--color-file-button-hover: #e5e7eb;
/* Dark theme colors */
--color-primary-dark: #2dd4bf;
--color-user-message-dark: #076699;
--color-avatar-bg-dark: #bcb2fd;
--color-secondary-dark: #2dd4bf;
--color-success-dark: #34d399;
--color-warning-dark: #fbbf24;
--color-error-dark: #f87171;
--color-background-dark: #0f0f0f;
--color-subagent-hover-dark: #d0c9fe;
--color-surface-dark: #1a1a1a;
--color-border-dark: #2d2d2d;
--color-border-light-dark: #232323;
--color-text-primary-dark: #f3f4f6;
--color-text-secondary-dark: #9ca3af;
--color-text-tertiary-dark: #6b7280;
/* Spacing variables */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
/* Font family variables */
--font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
--font-family-mono: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono",
Consolas, "Courier New", monospace;
/* Font size variables */
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 1.875rem;
/* Font weight variables */
--font-weight-medium: 500;
--font-weight-semibold: 600;
/* Line height variables */
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.75;
/* Border radius variables */
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-full: 9999px;
/* Shadow variables */
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
/* Transition variables */
--transition-base: 200ms ease;
/* Layout variables */
--sidebar-width: 320px;
--sidebar-collapsed-width: 60px;
--header-height: 64px;
--panel-width: 40vw;
--chat-max-width: 900px;
/* Tailwind/Radix UI component variables */
--radius: 0.5rem;
--background: 0 0% 98%;
--foreground: 220 13% 13%;
--card: 0 0% 100%;
--card-foreground: 220 13% 13%;
--popover: 0 0% 100%;
--popover-foreground: 220 13% 13%;
--primary: 180 35% 17%;
--primary-foreground: 0 0% 100%;
--secondary: 220 13% 91%;
--secondary-foreground: 220 13% 13%;
--muted: 220 13% 95%;
--muted-foreground: 220 9% 46%;
--accent: 220 13% 95%;
--accent-foreground: 220 13% 13%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 180 35% 17%;
--sidebar: 220 13% 95%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
@media (prefers-color-scheme: dark) {
:root {
/* App-specific color variables */
--color-primary: #1c3c3c;
--color-user-message: #065a8a;
--color-user-message-bg: #2d2d2d;
--color-avatar-bg: #1c3c3c;
--color-secondary: #bbc4c4;
--color-success: #34d399;
--color-warning: #fbbf24;
--color-error: #f87171;
--color-background: #202020;
--color-subagent-hover: #1e3f3f;
--color-surface: #2a2a2a;
--color-border: #404040;
--color-border-light: #353535;
--color-text-primary: #f3f4f6;
--color-text-secondary: #9ca3af;
--color-text-tertiary: #6b7280;
--color-file-button: #2a2a2a;
--color-file-button-hover: #353535;
/* Tailwind/Radix UI component variables for dark mode */
--radius: 0.5rem;
--background: 0 0% 13%;
--foreground: 220 13% 95%;
--card: 0 0% 18%;
--card-foreground: 220 13% 95%;
--popover: 0 0% 18%;
--popover-foreground: 220 13% 95%;
--primary: 174 72% 56%;
--primary-foreground: 0 0% 13%;
--secondary: 0 0% 25%;
--secondary-foreground: 220 13% 95%;
--muted: 0 0% 22%;
--muted-foreground: 220 9% 70%;
--accent: 0 0% 22%;
--accent-foreground: 220 13% 95%;
--destructive: 0 63% 71%;
--destructive-foreground: 0 0% 13%;
--border: 0 0% 28%;
--input: 0 0% 28%;
--ring: 174 72% 56%;
--sidebar: 0 0% 18%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
font-size: 1rem;
line-height: 1.5;
color: var(--color-text-primary);
background-color: var(--color-background);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 600;
line-height: 1.25;
color: var(--color-text-primary);
}
h1 {
font-size: 1.875rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
h4 {
font-size: 1.125rem;
}
h5 {
font-size: 1rem;
}
h6 {
font-size: 0.875rem;
}
p {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
a {
color: var(--color-primary);
text-decoration: none;
transition: opacity 200ms ease;
&:hover {
opacity: 0.8;
}
}
code {
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
"Courier New", monospace;
font-size: 0.9em;
padding: 0.125em 0.25em;
background-color: var(--color-surface);
border-radius: 0.25rem;
}
pre {
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
"Courier New", monospace;
font-size: 0.875rem;
line-height: 1.75;
padding: 1rem;
background-color: var(--color-surface);
border-radius: 0.375rem;
overflow-x: auto;
code {
padding: 0;
background-color: transparent;
}
}
ul,
ol {
padding-left: 1.5rem;
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
li {
margin-bottom: 0.25rem;
&:last-child {
margin-bottom: 0;
}
}
input,
textarea,
select {
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button {
font-family: inherit;
font-size: inherit;
line-height: inherit;
border: none;
background: none;
cursor: pointer;
&:disabled {
cursor: not-allowed;
}
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* Optimization Window animations */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: scale(0.9) translateY(20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* Custom scrollbar styles */
.scrollbar-pretty {
overflow-y: scroll;
}
.scrollbar-pretty::-webkit-scrollbar {
width: 6px;
}
.scrollbar-pretty::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-pretty::-webkit-scrollbar-thumb {
background-color: #d1d5db;
border-radius: 9999px;
}
.scrollbar-pretty::-webkit-scrollbar-thumb:hover {
background-color: #9ca3af;
}
/* Global diff highlighting styles */
.word-added {
background-color: rgba(46, 160, 67, 0.4);
color: #ffffff;
padding: 2px 4px;
border-radius: 3px;
font-weight: 600;
}
.word-removed {
background-color: rgba(248, 81, 73, 0.4);
color: #ffffff;
padding: 2px 4px;
border-radius: 3px;
font-weight: 600;
text-decoration: line-through;
text-decoration-color: #f85149;
}
+167
View File
@@ -0,0 +1,167 @@
"use client";
import { useCallback } from "react";
import { useStream } from "@langchain/langgraph-sdk/react";
import {
type Message,
type Assistant,
type Checkpoint,
} from "@langchain/langgraph-sdk";
import { v4 as uuidv4 } from "uuid";
import type { UseStreamThread } from "@langchain/langgraph-sdk/react";
import type { TodoItem } from "@/app/types/types";
import { useClient } from "@/providers/ClientProvider";
import { useQueryState } from "nuqs";
export type StateType = {
messages: Message[];
todos: TodoItem[];
files: Record<string, string>;
email?: {
id?: string;
subject?: string;
page_content?: string;
};
ui?: any;
};
export function useChat({
activeAssistant,
onHistoryRevalidate,
thread,
}: {
activeAssistant: Assistant | null;
onHistoryRevalidate?: () => void;
thread?: UseStreamThread<StateType>;
}) {
const [threadId, setThreadId] = useQueryState("threadId");
const client = useClient();
const stream = useStream<StateType>({
assistantId: activeAssistant?.assistant_id || "",
client: client ?? undefined,
reconnectOnMount: true,
threadId: threadId ?? null,
onThreadId: setThreadId,
defaultHeaders: { "x-auth-scheme": "langsmith" },
// Enable fetching state history when switching to existing threads
fetchStateHistory: true,
// Revalidate thread list when stream finishes, errors, or creates new thread
onFinish: onHistoryRevalidate,
onError: onHistoryRevalidate,
onCreated: onHistoryRevalidate,
experimental_thread: thread,
});
const sendMessage = useCallback(
(content: string) => {
const newMessage: Message = { id: uuidv4(), type: "human", content };
stream.submit(
{ messages: [newMessage] },
{
optimisticValues: (prev) => ({
messages: [...(prev.messages ?? []), newMessage],
}),
config: { ...(activeAssistant?.config ?? {}), recursion_limit: 100 },
}
);
// Update thread list immediately when sending a message
onHistoryRevalidate?.();
},
[stream, activeAssistant?.config, onHistoryRevalidate]
);
const runSingleStep = useCallback(
(
messages: Message[],
checkpoint?: Checkpoint,
isRerunningSubagent?: boolean,
optimisticMessages?: Message[]
) => {
if (checkpoint) {
stream.submit(undefined, {
...(optimisticMessages
? { optimisticValues: { messages: optimisticMessages } }
: {}),
config: activeAssistant?.config,
checkpoint: checkpoint,
...(isRerunningSubagent
? { interruptAfter: ["tools"] }
: { interruptBefore: ["tools"] }),
});
} else {
stream.submit(
{ messages },
{ config: activeAssistant?.config, interruptBefore: ["tools"] }
);
}
},
[stream, activeAssistant?.config]
);
const setFiles = useCallback(
async (files: Record<string, string>) => {
if (!threadId) return;
// TODO: missing a way how to revalidate the internal state
// I think we do want to have the ability to externally manage the state
await client.threads.updateState(threadId, { values: { files } });
},
[client, threadId]
);
const continueStream = useCallback(
(hasTaskToolCall?: boolean) => {
stream.submit(undefined, {
config: {
...(activeAssistant?.config || {}),
recursion_limit: 100,
},
...(hasTaskToolCall
? { interruptAfter: ["tools"] }
: { interruptBefore: ["tools"] }),
});
// Update thread list when continuing stream
onHistoryRevalidate?.();
},
[stream, activeAssistant?.config, onHistoryRevalidate]
);
const markCurrentThreadAsResolved = useCallback(() => {
stream.submit(null, { command: { goto: "__end__", update: null } });
// Update thread list when marking thread as resolved
onHistoryRevalidate?.();
}, [stream, onHistoryRevalidate]);
const resumeInterrupt = useCallback(
(value: any) => {
stream.submit(null, { command: { resume: value } });
// Update thread list when resuming from interrupt
onHistoryRevalidate?.();
},
[stream, onHistoryRevalidate]
);
const stopStream = useCallback(() => {
stream.stop();
}, [stream]);
return {
stream,
todos: stream.values.todos ?? [],
files: stream.values.files ?? {},
email: stream.values.email,
ui: stream.values.ui,
setFiles,
messages: stream.messages,
isLoading: stream.isLoading,
isThreadLoading: stream.isThreadLoading,
interrupt: stream.interrupt,
getMessagesMetadata: stream.getMessagesMetadata,
sendMessage,
runSingleStep,
continueStream,
stopStream,
markCurrentThreadAsResolved,
resumeInterrupt,
};
}
+136
View File
@@ -0,0 +1,136 @@
import useSWRInfinite from "swr/infinite";
import type { Thread } from "@langchain/langgraph-sdk";
import { Client } from "@langchain/langgraph-sdk";
import { getConfig } from "@/lib/config";
export interface ThreadItem {
id: string;
updatedAt: Date;
status: Thread["status"];
title: string;
description: string;
assistantId?: string;
}
const DEFAULT_PAGE_SIZE = 20;
export function useThreads(props: {
status?: Thread["status"];
limit?: number;
}) {
const pageSize = props.limit || DEFAULT_PAGE_SIZE;
return useSWRInfinite(
(pageIndex: number, previousPageData: ThreadItem[] | null) => {
const config = getConfig();
const apiKey =
config?.langsmithApiKey ||
process.env.NEXT_PUBLIC_LANGSMITH_API_KEY ||
"";
if (!config) {
return null;
}
// If the previous page returned no items, we've reached the end
if (previousPageData && previousPageData.length === 0) {
return null;
}
return {
kind: "threads" as const,
pageIndex,
pageSize,
deploymentUrl: config.deploymentUrl,
assistantId: config.assistantId,
apiKey,
status: props?.status,
};
},
async ({
deploymentUrl,
assistantId,
apiKey,
status,
pageIndex,
pageSize,
}: {
kind: "threads";
pageIndex: number;
pageSize: number;
deploymentUrl: string;
assistantId: string;
apiKey: string;
status?: Thread["status"];
}) => {
const client = new Client({
apiUrl: deploymentUrl,
defaultHeaders: apiKey ? { "X-Api-Key": apiKey } : {},
});
// Check if assistantId is a UUID (deployed) or graph name (local)
const isUUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
assistantId
);
const threads = await client.threads.search({
limit: pageSize,
offset: pageIndex * pageSize,
sortBy: "updated_at" as const,
sortOrder: "desc" as const,
status,
// Only filter by assistant_id metadata for deployed graphs (UUIDs)
// Local dev graphs don't set this metadata
...(isUUID ? { metadata: { assistant_id: assistantId } } : {}),
});
return threads.map((thread): ThreadItem => {
let title = "Untitled Thread";
let description = "";
try {
if (thread.values && typeof thread.values === "object") {
const values = thread.values as any;
const firstHumanMessage = values.messages.find(
(m: any) => m.type === "human"
);
if (firstHumanMessage?.content) {
const content =
typeof firstHumanMessage.content === "string"
? firstHumanMessage.content
: firstHumanMessage.content[0]?.text || "";
title = content.slice(0, 50) + (content.length > 50 ? "..." : "");
}
const firstAiMessage = values.messages.find(
(m: any) => m.type === "ai"
);
if (firstAiMessage?.content) {
const content =
typeof firstAiMessage.content === "string"
? firstAiMessage.content
: firstAiMessage.content[0]?.text || "";
description = content.slice(0, 100);
}
}
} catch {
// Fallback to thread ID
title = `Thread ${thread.thread_id.slice(0, 8)}`;
}
return {
id: thread.thread_id,
updatedAt: new Date(thread.updated_at),
status: thread.status,
title,
description,
assistantId,
};
});
},
{
revalidateFirstPage: true,
revalidateOnFocus: true,
}
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Inter } from "next/font/google";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { Toaster } from "sonner";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html
lang="en"
suppressHydrationWarning
>
<body
className={inter.className}
suppressHydrationWarning
>
<NuqsAdapter>{children}</NuqsAdapter>
<Toaster />
</body>
</html>
);
}
+293
View File
@@ -0,0 +1,293 @@
"use client";
import React, { useState, useEffect, useCallback, Suspense } from "react";
import { useQueryState } from "nuqs";
import { getConfig, saveConfig, StandaloneConfig } from "@/lib/config";
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 {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { ThreadList } from "@/app/components/ThreadList";
import { ChatProvider } from "@/providers/ChatProvider";
import { ChatInterface } from "@/app/components/ChatInterface";
interface HomePageInnerProps {
config: StandaloneConfig;
configDialogOpen: boolean;
setConfigDialogOpen: (open: boolean) => void;
handleSaveConfig: (config: StandaloneConfig) => void;
}
function HomePageInner({
config,
configDialogOpen,
setConfigDialogOpen,
handleSaveConfig,
}: HomePageInnerProps) {
const client = useClient();
const [threadId, setThreadId] = useQueryState("threadId");
const [sidebar, setSidebar] = useQueryState("sidebar");
const [mutateThreads, setMutateThreads] = useState<(() => void) | null>(null);
const [interruptCount, setInterruptCount] = useState(0);
const [assistant, setAssistant] = useState<Assistant | null>(null);
const fetchAssistant = useCallback(async () => {
const isUUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
config.assistantId
);
if (isUUID) {
// We should try to fetch the assistant directly with this UUID
try {
const data = await client.assistants.get(config.assistantId);
setAssistant(data);
} catch (error) {
console.error("Failed to fetch assistant:", error);
setAssistant({
assistant_id: config.assistantId,
graph_id: config.assistantId,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
config: {},
metadata: {},
version: 1,
name: "Assistant",
context: {},
});
}
} else {
try {
// We should try to list out the assistants for this graph, and then use the default one.
// TODO: Paginate this search, but 100 should be enough for graph name
const assistants = await client.assistants.search({
graphId: config.assistantId,
limit: 100,
});
const defaultAssistant = assistants.find(
(assistant) => assistant.metadata?.["created_by"] === "system"
);
if (defaultAssistant === undefined) {
throw new Error("No default assistant found");
}
setAssistant(defaultAssistant);
} catch (error) {
console.error(
"Failed to find default assistant from graph_id: try setting the assistant_id directly:",
error
);
setAssistant({
assistant_id: config.assistantId,
graph_id: config.assistantId,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
config: {},
metadata: {},
version: 1,
name: config.assistantId,
context: {},
});
}
}
}, [client, config.assistantId]);
useEffect(() => {
fetchAssistant();
}, [fetchAssistant]);
return (
<>
<ConfigDialog
open={configDialogOpen}
onOpenChange={setConfigDialogOpen}
onSave={handleSaveConfig}
initialConfig={config}
/>
<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">
<h1 className="text-xl font-semibold">BroJS Agent</h1>
{!sidebar && (
<Button
variant="ghost"
size="sm"
onClick={() => setSidebar("1")}
className="rounded-md border border-border bg-card p-3 text-foreground hover:bg-accent"
>
<MessagesSquare className="mr-2 h-4 w-4" />
Threads
{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">
{interruptCount}
</span>
)}
</Button>
)}
</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"
onClick={() => setConfigDialogOpen(true)}
>
<Settings className="mr-2 h-4 w-4" />
Settings
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setThreadId(null)}
disabled={!threadId}
className="border-[#2F6868] bg-[#2F6868] text-white hover:bg-[#2F6868]/80"
>
<SquarePen className="mr-2 h-4 w-4" />
New Thread
</Button>
</div>
</header>
<div className="flex-1 overflow-hidden">
<ResizablePanelGroup
direction="horizontal"
autoSaveId="standalone-chat"
>
{sidebar && (
<>
<ResizablePanel
id="thread-history"
order={1}
defaultSize={25}
minSize={20}
className="relative min-w-[380px]"
>
<ThreadList
onThreadSelect={async (id) => {
await setThreadId(id);
}}
onMutateReady={(fn) => setMutateThreads(() => fn)}
onClose={() => setSidebar(null)}
onInterruptCountChange={setInterruptCount}
/>
</ResizablePanel>
<ResizableHandle />
</>
)}
<ResizablePanel
id="chat"
className="relative flex flex-col"
order={2}
>
<ChatProvider
activeAssistant={assistant}
onHistoryRevalidate={() => mutateThreads?.()}
>
<ChatInterface assistant={assistant} />
</ChatProvider>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
</>
);
}
function HomePageContent() {
const [config, setConfig] = useState<StandaloneConfig | null>(null);
const [configDialogOpen, setConfigDialogOpen] = useState(false);
const [assistantId, setAssistantId] = useQueryState("assistantId");
// On mount, check for saved config, otherwise show config dialog
useEffect(() => {
const savedConfig = getConfig();
if (savedConfig) {
setConfig(savedConfig);
if (!assistantId) {
setAssistantId(savedConfig.assistantId);
}
} else {
setConfigDialogOpen(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// If config changes, update the assistantId
useEffect(() => {
if (config && !assistantId) {
setAssistantId(config.assistantId);
}
}, [config, assistantId, setAssistantId]);
const handleSaveConfig = useCallback((newConfig: StandaloneConfig) => {
saveConfig(newConfig);
setConfig(newConfig);
}, []);
const langsmithApiKey =
config?.langsmithApiKey || process.env.NEXT_PUBLIC_LANGSMITH_API_KEY || "";
if (!config) {
return (
<>
<ConfigDialog
open={configDialogOpen}
onOpenChange={setConfigDialogOpen}
onSave={handleSaveConfig}
/>
<div className="flex h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold">Welcome to Standalone Chat</h1>
<p className="mt-2 text-muted-foreground">
Configure your deployment to get started
</p>
<Button
onClick={() => setConfigDialogOpen(true)}
className="mt-4"
>
Open Configuration
</Button>
</div>
</div>
</>
);
}
return (
<ClientProvider
deploymentUrl={config.deploymentUrl}
apiKey={langsmithApiKey}
>
<HomePageInner
config={config}
configDialogOpen={configDialogOpen}
setConfigDialogOpen={setConfigDialogOpen}
handleSaveConfig={handleSaveConfig}
/>
</ClientProvider>
);
}
export default function HomePage() {
return (
<Suspense
fallback={
<div className="flex h-screen items-center justify-center">
<p className="text-muted-foreground">Loading...</p>
</div>
}
>
<HomePageContent />
</Suspense>
);
}
+57
View File
@@ -0,0 +1,57 @@
export interface ToolCall {
id: string;
name: string;
args: Record<string, unknown>;
result?: string;
status: "pending" | "completed" | "error" | "interrupted";
}
export interface SubAgent {
id: string;
name: string;
subAgentName: string;
input: Record<string, unknown>;
output?: Record<string, unknown>;
status: "pending" | "active" | "completed" | "error";
}
export interface FileItem {
path: string;
content: string;
}
export interface TodoItem {
id: string;
content: string;
status: "pending" | "in_progress" | "completed";
updatedAt?: Date;
}
export interface Thread {
id: string;
title: string;
createdAt: Date;
updatedAt: Date;
}
export interface InterruptData {
value: any;
ns?: string[];
scope?: string;
}
export interface ActionRequest {
name: string;
args: Record<string, unknown>;
description?: string;
}
export interface ReviewConfig {
actionName: string;
allowedDecisions?: string[];
}
export interface ToolApprovalInterruptData {
action_requests: ActionRequest[];
review_configs?: ReviewConfig[];
}
+157
View File
@@ -0,0 +1,157 @@
import { Message } from "@langchain/langgraph-sdk";
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function extractStringFromMessageContent(message: Message): string {
return typeof message.content === "string"
? message.content
: Array.isArray(message.content)
? message.content
.filter(
(c: unknown) =>
(typeof c === "object" &&
c !== null &&
"type" in c &&
(c as { type: string }).type === "text") ||
typeof c === "string"
)
.map((c: unknown) =>
typeof c === "string"
? c
: typeof c === "object" && c !== null && "text" in c
? (c as { text?: string }).text || ""
: ""
)
.join("")
: "";
}
export function extractSubAgentContent(data: unknown): string {
if (typeof data === "string") {
return data;
}
if (data && typeof data === "object") {
const dataObj = data as Record<string, unknown>;
// Try to extract description first
if (dataObj.description && typeof dataObj.description === "string") {
return dataObj.description;
}
// Then try prompt
if (dataObj.prompt && typeof dataObj.prompt === "string") {
return dataObj.prompt;
}
// For output objects, try result
if (dataObj.result && typeof dataObj.result === "string") {
return dataObj.result;
}
// Fallback to JSON stringification
return JSON.stringify(data, null, 2);
}
// Fallback for any other type
return JSON.stringify(data, null, 2);
}
export function isPreparingToCallTaskTool(messages: Message[]): boolean {
const lastMessage = messages[messages.length - 1];
return (
(lastMessage.type === "ai" &&
lastMessage.tool_calls?.some(
(call: { name?: string }) => call.name === "task"
)) ||
false
);
}
export function formatMessageForLLM(message: Message): string {
let role: string;
if (message.type === "human") {
role = "Human";
} else if (message.type === "ai") {
role = "Assistant";
} else if (message.type === "tool") {
role = `Tool Result`;
} else {
role = message.type || "Unknown";
}
const timestamp = message.id ? ` (${message.id.slice(0, 8)})` : "";
let contentText = "";
// Extract content text
if (typeof message.content === "string") {
contentText = message.content;
} else if (Array.isArray(message.content)) {
const textParts: string[] = [];
message.content.forEach((part: any) => {
if (typeof part === "string") {
textParts.push(part);
} else if (part && typeof part === "object" && part.type === "text") {
textParts.push(part.text || "");
}
// Ignore other types like tool_use in content - we handle tool calls separately
});
contentText = textParts.join("\n\n").trim();
}
// For tool messages, include additional tool metadata
if (message.type === "tool") {
const toolName = (message as any).name || "unknown_tool";
const toolCallId = (message as any).tool_call_id || "";
role = `Tool Result [${toolName}]`;
if (toolCallId) {
role += ` (call_id: ${toolCallId.slice(0, 8)})`;
}
}
// Handle tool calls from .tool_calls property (for AI messages)
const toolCallsText: string[] = [];
if (
message.type === "ai" &&
message.tool_calls &&
Array.isArray(message.tool_calls) &&
message.tool_calls.length > 0
) {
message.tool_calls.forEach((call: any) => {
const toolName = call.name || "unknown_tool";
const toolArgs = call.args ? JSON.stringify(call.args, null, 2) : "{}";
toolCallsText.push(`[Tool Call: ${toolName}]\nArguments: ${toolArgs}`);
});
}
// Combine content and tool calls
const parts: string[] = [];
if (contentText) {
parts.push(contentText);
}
if (toolCallsText.length > 0) {
parts.push(...toolCallsText);
}
if (parts.length === 0) {
return `${role}${timestamp}: [Empty message]`;
}
if (parts.length === 1) {
return `${role}${timestamp}: ${parts[0]}`;
}
return `${role}${timestamp}:\n${parts.join("\n\n")}`;
}
export function formatConversationForLLM(messages: Message[]): string {
const formattedMessages = messages.map(formatMessageForLLM);
return formattedMessages.join("\n\n---\n\n");
}
@@ -0,0 +1,59 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
+163
View File
@@ -0,0 +1,163 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return (
<DialogPrimitive.Root
data-slot="dialog"
{...props}
/>
);
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return (
<DialogPrimitive.Trigger
data-slot="dialog-trigger"
{...props}
/>
);
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return (
<DialogPrimitive.Portal
data-slot="dialog-portal"
{...props}
/>
);
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return (
<DialogPrimitive.Close
data-slot="dialog-close"
{...props}
/>
);
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="focus:outline-hidden absolute right-4 top-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg font-semibold leading-none", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] selection:bg-blue-200 selection:text-gray-900 file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30 dark:selection:bg-blue-600 dark:selection:text-white md:text-sm",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
);
}
export { Input };
@@ -0,0 +1,26 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
@@ -0,0 +1,45 @@
"use client";
import { GripVertical } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
);
const ResizablePanel = ResizablePrimitive.Panel;
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
@@ -0,0 +1,58 @@
"use client";
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none select-none p-px transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };
+160
View File
@@ -0,0 +1,160 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] origin-[--radix-select-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils";
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
}
export { Skeleton };
@@ -0,0 +1,55 @@
"use client";
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
style={{
display: "inline-flex",
height: "20px",
width: "36px",
alignItems: "center",
borderRadius: "9999px",
border: "1px solid #d1d5db",
backgroundColor: "var(--color-border)",
cursor: "pointer",
transition: "background-color 0.2s",
}}
data-state-styles={{
checked: {
backgroundColor: "var(--color-primary)",
},
}}
className={cn(
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:!bg-[var(--color-primary)]",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
style={{
display: "block",
width: "16px",
height: "16px",
borderRadius: "9999px",
backgroundColor: "white",
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.2)",
transition: "transform 0.2s",
transform: "translateX(1px)",
}}
className="data-[state=checked]:!translate-x-[17px]"
/>
</SwitchPrimitive.Root>
);
}
export { Switch };
+66
View File
@@ -0,0 +1,66 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground",
className
)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium text-foreground transition-[color,box-shadow] focus-visible:border-ring focus-visible:outline-1 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:shadow-sm dark:text-muted-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };
@@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "@/lib/utils";
type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = "Textarea";
export { Textarea };
@@ -0,0 +1,42 @@
import React from "react";
import { Button } from "./button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "./tooltip";
interface TooltipIconButtonProps {
icon: React.ReactNode;
onClick: () => void;
tooltip: string;
disabled?: boolean;
}
export function TooltipIconButton({
icon,
onClick,
tooltip,
disabled,
}: TooltipIconButtonProps) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onClick}
disabled={disabled}
>
{icon}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -0,0 +1,69 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root
data-slot="tooltip"
{...props}
/>
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return (
<TooltipPrimitive.Trigger
data-slot="tooltip-trigger"
{...props}
/>
);
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"text-primary-foreground origin-(--radix-tooltip-content-transform-origin) z-50 w-fit text-balance rounded-md bg-primary px-3 py-1.5 text-xs animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-primary fill-primary" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+40
View File
@@ -0,0 +1,40 @@
export interface StandaloneConfig {
deploymentUrl: string;
assistantId: string;
langsmithApiKey?: string;
}
const CONFIG_KEY = "deep-agent-config";
export function getEnvConfig(): StandaloneConfig | null {
const deploymentUrl = process.env.NEXT_PUBLIC_DEPLOYMENT_URL;
const assistantId = process.env.NEXT_PUBLIC_ASSISTANT_ID;
if (!deploymentUrl || !assistantId) return null;
const langsmithApiKey = process.env.NEXT_PUBLIC_LANGSMITH_API_KEY;
return {
deploymentUrl,
assistantId,
langsmithApiKey: langsmithApiKey || undefined,
};
}
export function getConfig(): StandaloneConfig | null {
if (typeof window === "undefined") return null;
const stored = localStorage.getItem(CONFIG_KEY);
if (stored) {
try {
return JSON.parse(stored);
} catch {
return null;
}
}
return getEnvConfig();
}
export function saveConfig(config: StandaloneConfig): void {
if (typeof window === "undefined") return;
localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
@@ -0,0 +1,37 @@
"use client";
import { ReactNode, createContext, useContext } from "react";
import { Assistant } from "@langchain/langgraph-sdk";
import { type StateType, useChat } from "@/app/hooks/useChat";
import type { UseStreamThread } from "@langchain/langgraph-sdk/react";
interface ChatProviderProps {
children: ReactNode;
activeAssistant: Assistant | null;
onHistoryRevalidate?: () => void;
thread?: UseStreamThread<StateType>;
}
export function ChatProvider({
children,
activeAssistant,
onHistoryRevalidate,
thread,
}: ChatProviderProps) {
const chat = useChat({ activeAssistant, onHistoryRevalidate, thread });
return <ChatContext.Provider value={chat}>{children}</ChatContext.Provider>;
}
export type ChatContextType = ReturnType<typeof useChat>;
export const ChatContext = createContext<ChatContextType | undefined>(
undefined
);
export function useChatContext() {
const context = useContext(ChatContext);
if (context === undefined) {
throw new Error("useChatContext must be used within a ChatProvider");
}
return context;
}
@@ -0,0 +1,47 @@
"use client";
import { createContext, useContext, useMemo, ReactNode } from "react";
import { Client } from "@langchain/langgraph-sdk";
interface ClientContextValue {
client: Client;
}
const ClientContext = createContext<ClientContextValue | null>(null);
interface ClientProviderProps {
children: ReactNode;
deploymentUrl: string;
apiKey: string;
}
export function ClientProvider({
children,
deploymentUrl,
apiKey,
}: ClientProviderProps) {
const client = useMemo(() => {
return new Client({
apiUrl: deploymentUrl,
defaultHeaders: {
"Content-Type": "application/json",
"X-Api-Key": apiKey,
},
});
}, [deploymentUrl, apiKey]);
const value = useMemo(() => ({ client }), [client]);
return (
<ClientContext.Provider value={value}>{children}</ClientContext.Provider>
);
}
export function useClient(): Client {
const context = useContext(ClientContext);
if (!context) {
throw new Error("useClient must be used within a ClientProvider");
}
return context.client;
}