feat(agent):Supports custom agent and chat experience with refactoring (#957)

* feat: add agent management functionality with creation, editing, and deletion

* feat: enhance agent creation and chat experience

- Added AgentWelcome component to display agent description on new thread creation.
- Improved agent name validation with availability check during agent creation.
- Updated NewAgentPage to handle agent creation flow more effectively, including enhanced error handling and user feedback.
- Refactored chat components to streamline message handling and improve user experience.
- Introduced new bootstrap skill for personalized onboarding conversations, including detailed conversation phases and a structured SOUL.md template.
- Updated localization files to reflect new features and error messages.
- General code cleanup and optimizations across various components and hooks.

* Refactor workspace layout and agent management components

- Updated WorkspaceLayout to use useLayoutEffect for sidebar state initialization.
- Removed unused AgentFormDialog and related edit functionality from AgentCard.
- Introduced ArtifactTrigger component to manage artifact visibility.
- Enhanced ChatBox to handle artifact selection and display.
- Improved message list rendering logic to avoid loading states.
- Updated localization files to remove deprecated keys and add new translations.
- Refined hooks for local settings and thread management to improve performance and clarity.
- Added temporal awareness guidelines to deep research skill documentation.

* feat: refactor chat components and introduce thread management hooks

* feat: improve artifact file detail preview logic and clean up console logs

* feat: refactor lead agent creation logic and improve logging details

* feat: validate agent name format and enhance error handling in agent setup

* feat: simplify thread search query by removing unnecessary metadata

* feat: update query key in useDeleteThread and useRenameThread for consistency

* feat: add isMock parameter to thread and artifact handling for improved testing

* fix: reorder import of setup_agent for consistency in builtins module

* feat: append mock parameter to thread links in CaseStudySection for testing purposes

* fix: update load_agent_soul calls to use cfg.name for improved clarity

* fix: update date format in apply_prompt_template for consistency

* feat: integrate isMock parameter into artifact content loading for enhanced testing

* docs: add license section to SKILL.md for clarity and attribution

* feat(agent): enhance model resolution and agent configuration handling

* chore: remove unused import of _resolve_model_name from agents

* feat(agent): remove unused field

* fix(agent): set default value for requested_model_name in _resolve_model_name function

* feat(agent): update get_available_tools call to handle optional agent_config and improve middleware function signature

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
JeffJiang
2026-03-03 21:32:01 +08:00
committed by GitHub
parent 8342e88534
commit 7de94394d4
61 changed files with 3002 additions and 503 deletions

View File

@@ -0,0 +1,19 @@
"use client";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { SubtasksProvider } from "@/core/tasks/context";
export default function AgentChatLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<SubtasksProvider>
<ArtifactsProvider>
<PromptInputProvider>{children}</PromptInputProvider>
</ArtifactsProvider>
</SubtasksProvider>
);
}

View File

@@ -0,0 +1,166 @@
"use client";
import { BotIcon } from "lucide-react";
import { useParams } from "next/navigation";
import { useCallback } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list";
import { useAgent } from "@/core/agents";
import { useI18n } from "@/core/i18n/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useLocalSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
export default function AgentChatPage() {
const { t } = useI18n();
const [settings, setSettings] = useLocalSettings();
const { agent_name, thread_id: threadIdFromPath } = useParams<{
agent_name: string;
thread_id: string;
}>();
const { agent } = useAgent(agent_name);
const { threadId, isNewThread, setIsNewThread } = useThreadChat();
const { showNotification } = useNotification();
const [thread, sendMessage] = useThreadStream({
threadId: threadIdFromPath !== "new" ? threadIdFromPath : undefined,
context: { ...settings.context, agent_name: agent_name },
onStart: () => {
setIsNewThread(false);
history.replaceState(
null,
"",
`/workspace/agents/${agent_name}/chats/${threadId}`,
);
},
onFinish: (state) => {
if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished";
const lastMessage = state.messages[state.messages.length - 1];
if (lastMessage) {
const textContent = textOfMessage(lastMessage);
if (textContent) {
body =
textContent.length > 200
? textContent.substring(0, 200) + "..."
: textContent;
}
}
showNotification(state.title, { body });
}
},
});
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
void sendMessage(threadId, message, { agent_name });
},
[sendMessage, threadId, agent_name],
);
const handleStop = useCallback(async () => {
await thread.stop();
}, [thread]);
return (
<ThreadContext.Provider value={{ thread }}>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center gap-2 px-4",
isNewThread
? "bg-background/0 backdrop-blur-none"
: "bg-background/80 shadow-xs backdrop-blur",
)}
>
{/* Agent badge */}
<div className="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1">
<BotIcon className="text-primary h-3.5 w-3.5" />
<span className="text-xs font-medium">
{agent?.name ?? agent_name}
</span>
</div>
<div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div>
<ArtifactTrigger />
</div>
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center">
<MessageList
className={cn("size-full", !isNewThread && "pt-10")}
threadId={threadId}
thread={thread}
/>
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn(
"relative w-full",
isNewThread && "-translate-y-[calc(50vh-96px)]",
isNewThread
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
<div className="absolute right-0 bottom-0 left-0">
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
}
/>
</div>
</div>
<InputBox
className={cn("bg-background/5 w-full -translate-y-4")}
isNewThread={isNewThread}
autoFocus={isNewThread}
status={thread.isLoading ? "streaming" : "ready"}
context={settings.context}
extraHeader={
isNewThread && (
<AgentWelcome agent={agent} agentName={agent_name} />
)
}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onContextChange={(context) => setSettings("context", context)}
onSubmit={handleSubmit}
onStop={handleStop}
/>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
</main>
</div>
</ChatBox>
</ThreadContext.Provider>
);
}

View File

@@ -0,0 +1,251 @@
"use client";
import { ArrowLeftIcon, BotIcon, CheckCircleIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import {
PromptInput,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea,
} from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import type { Agent } from "@/core/agents";
import { checkAgentName, getAgent } from "@/core/agents/api";
import { useI18n } from "@/core/i18n/hooks";
import { useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
import { cn } from "@/lib/utils";
type Step = "name" | "chat";
const NAME_RE = /^[A-Za-z0-9-]+$/;
export default function NewAgentPage() {
const { t } = useI18n();
const router = useRouter();
// ── Step 1: name form ──────────────────────────────────────────────────────
const [step, setStep] = useState<Step>("name");
const [nameInput, setNameInput] = useState("");
const [nameError, setNameError] = useState("");
const [isCheckingName, setIsCheckingName] = useState(false);
const [agentName, setAgentName] = useState("");
const [agent, setAgent] = useState<Agent | null>(null);
// ── Step 2: chat ───────────────────────────────────────────────────────────
// Stable thread ID — all turns belong to the same thread
const threadId = useMemo(() => uuid(), []);
const [thread, sendMessage] = useThreadStream({
context: {
mode: "flash",
is_bootstrap: true,
},
onToolEnd({ name }) {
if (name !== "setup_agent" || !agentName) return;
getAgent(agentName)
.then((fetched) => setAgent(fetched))
.catch(() => {
// agent write may not be flushed yet — ignore silently
});
},
});
// ── Handlers ───────────────────────────────────────────────────────────────
const handleConfirmName = useCallback(async () => {
const trimmed = nameInput.trim();
if (!trimmed) return;
if (!NAME_RE.test(trimmed)) {
setNameError(t.agents.nameStepInvalidError);
return;
}
setNameError("");
setIsCheckingName(true);
try {
const result = await checkAgentName(trimmed);
if (!result.available) {
setNameError(t.agents.nameStepAlreadyExistsError);
return;
}
} catch {
setNameError(t.agents.nameStepCheckError);
return;
} finally {
setIsCheckingName(false);
}
setAgentName(trimmed);
setStep("chat");
await sendMessage(threadId, {
text: t.agents.nameStepBootstrapMessage.replace("{name}", trimmed),
files: [],
});
}, [
nameInput,
sendMessage,
threadId,
t.agents.nameStepBootstrapMessage,
t.agents.nameStepInvalidError,
t.agents.nameStepAlreadyExistsError,
t.agents.nameStepCheckError,
]);
const handleNameKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
void handleConfirmName();
}
};
const handleChatSubmit = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed || thread.isLoading) return;
await sendMessage(
threadId,
{ text: trimmed, files: [] },
{ agent_name: agentName },
);
},
[thread.isLoading, sendMessage, threadId, agentName],
);
// ── Shared header ──────────────────────────────────────────────────────────
const header = (
<header className="flex shrink-0 items-center gap-3 border-b px-4 py-3">
<Button
variant="ghost"
size="icon-sm"
onClick={() => router.push("/workspace/agents")}
>
<ArrowLeftIcon className="h-4 w-4" />
</Button>
<h1 className="text-sm font-semibold">{t.agents.createPageTitle}</h1>
</header>
);
// ── Step 1: name form ──────────────────────────────────────────────────────
if (step === "name") {
return (
<div className="flex size-full flex-col">
{header}
<main className="flex flex-1 flex-col items-center justify-center px-4">
<div className="w-full max-w-sm space-y-8">
<div className="space-y-3 text-center">
<div className="bg-primary/10 mx-auto flex h-14 w-14 items-center justify-center rounded-full">
<BotIcon className="text-primary h-7 w-7" />
</div>
<div className="space-y-1">
<h2 className="text-xl font-semibold">
{t.agents.nameStepTitle}
</h2>
<p className="text-muted-foreground text-sm">
{t.agents.nameStepHint}
</p>
</div>
</div>
<div className="space-y-3">
<Input
autoFocus
placeholder={t.agents.nameStepPlaceholder}
value={nameInput}
onChange={(e) => {
setNameInput(e.target.value);
setNameError("");
}}
onKeyDown={handleNameKeyDown}
className={cn(nameError && "border-destructive")}
/>
{nameError && (
<p className="text-destructive text-sm">{nameError}</p>
)}
<Button
className="w-full"
onClick={() => void handleConfirmName()}
disabled={!nameInput.trim() || isCheckingName}
>
{t.agents.nameStepContinue}
</Button>
</div>
</div>
</main>
</div>
);
}
// ── Step 2: chat ───────────────────────────────────────────────────────────
return (
<ThreadContext.Provider value={{ thread }}>
<ArtifactsProvider>
<div className="flex size-full flex-col">
{header}
<main className="flex min-h-0 flex-1 flex-col">
{/* ── Message area ── */}
<div className="flex min-h-0 flex-1 justify-center">
<MessageList
className="size-full pt-10"
threadId={threadId}
thread={thread}
/>
</div>
{/* ── Bottom action area ── */}
<div className="bg-background flex shrink-0 justify-center border-t px-4 py-4">
<div className="w-full max-w-(--container-width-md)">
{agent ? (
// ✅ Success card
<div className="flex flex-col items-center gap-4 rounded-2xl border py-8 text-center">
<CheckCircleIcon className="text-primary h-10 w-10" />
<p className="font-semibold">{t.agents.agentCreated}</p>
<div className="flex gap-2">
<Button
onClick={() =>
router.push(
`/workspace/agents/${agentName}/chats/new`,
)
}
>
{t.agents.startChatting}
</Button>
<Button
variant="outline"
onClick={() => router.push("/workspace/agents")}
>
{t.agents.backToGallery}
</Button>
</div>
</div>
) : (
// 📝 Normal input
<PromptInput
onSubmit={({ text }) => void handleChatSubmit(text)}
>
<PromptInputTextarea
autoFocus
placeholder={t.agents.createPageSubtitle}
disabled={thread.isLoading}
/>
<PromptInputFooter className="justify-end">
<PromptInputSubmit disabled={thread.isLoading} />
</PromptInputFooter>
</PromptInput>
)}
</div>
</div>
</main>
</div>
</ArtifactsProvider>
</ThreadContext.Provider>
);
}

View File

@@ -0,0 +1,5 @@
import { AgentGallery } from "@/components/workspace/agents/agent-gallery";
export default function AgentsPage() {
return <AgentGallery />;
}

View File

@@ -1,366 +1,145 @@
"use client";
import type { Message } from "@langchain/langgraph-sdk";
import type { UseStream } from "@langchain/langgraph-sdk/react";
import { FilesIcon, XIcon } from "lucide-react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback } from "react";
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
import { usePromptInputController } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { useSidebar } from "@/components/ui/sidebar";
import {
ArtifactFileDetail,
ArtifactFileList,
useArtifacts,
} from "@/components/workspace/artifacts";
ChatBox,
useSpecificChatMode,
useThreadChat,
} from "@/components/workspace/chats";
import { InputBox } from "@/components/workspace/input-box";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list";
import { Tooltip } from "@/components/workspace/tooltip";
import { Welcome } from "@/components/workspace/welcome";
import { useI18n } from "@/core/i18n/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useLocalSettings } from "@/core/settings";
import { type AgentThreadState } from "@/core/threads";
import { useSubmitThread, useThreadStream } from "@/core/threads/hooks";
import {
pathOfThread,
textOfMessage,
} from "@/core/threads/utils";
import { uuid } from "@/core/utils/uuid";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env";
import { cn } from "@/lib/utils";
export default function ChatPage() {
const { t } = useI18n();
const router = useRouter();
const [settings, setSettings] = useLocalSettings();
const { setOpen: setSidebarOpen } = useSidebar();
const {
artifacts,
open: artifactsOpen,
setOpen: setArtifactsOpen,
setArtifacts,
select: selectArtifact,
selectedArtifact,
} = useArtifacts();
const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>();
const searchParams = useSearchParams();
const promptInputController = usePromptInputController();
const inputInitialValue = useMemo(() => {
if (threadIdFromPath !== "new" || searchParams.get("mode") !== "skill") {
return undefined;
}
return t.inputBox.createSkillPrompt;
}, [threadIdFromPath, searchParams, t.inputBox.createSkillPrompt]);
const lastInitialValueRef = useRef<string | undefined>(undefined);
const setInputRef = useRef(promptInputController.textInput.setInput);
setInputRef.current = promptInputController.textInput.setInput;
useEffect(() => {
if (inputInitialValue && inputInitialValue !== lastInitialValueRef.current) {
lastInitialValueRef.current = inputInitialValue;
setTimeout(() => {
setInputRef.current(inputInitialValue);
const textarea = document.querySelector("textarea");
if (textarea) {
textarea.focus();
textarea.selectionStart = textarea.value.length;
textarea.selectionEnd = textarea.value.length;
}
}, 100);
}
}, [inputInitialValue]);
const isNewThread = useMemo(
() => threadIdFromPath === "new",
[threadIdFromPath],
);
const [threadId, setThreadId] = useState<string | null>(null);
useEffect(() => {
if (threadIdFromPath !== "new") {
setThreadId(threadIdFromPath);
} else {
setThreadId(uuid());
}
}, [threadIdFromPath]);
const { threadId, isNewThread, setIsNewThread, isMock } = useThreadChat();
useSpecificChatMode();
const { showNotification } = useNotification();
const [finalState, setFinalState] = useState<AgentThreadState | null>(null);
const thread = useThreadStream({
isNewThread,
threadId,
const [thread, sendMessage] = useThreadStream({
threadId: isNewThread ? undefined : threadId,
context: settings.context,
isMock,
onStart: () => {
setIsNewThread(false);
history.replaceState(null, "", `/workspace/chats/${threadId}`);
},
onFinish: (state) => {
setFinalState(state);
if (document.hidden || !document.hasFocus()) {
let body = "Conversation finished";
const lastMessage = state.messages.at(-1);
if (lastMessage) {
const textContent = textOfMessage(lastMessage);
if (textContent) {
if (textContent.length > 200) {
body = textContent.substring(0, 200) + "...";
} else {
body = textContent;
}
body =
textContent.length > 200
? textContent.substring(0, 200) + "..."
: textContent;
}
}
showNotification(state.title, {
body,
});
showNotification(state.title, { body });
}
},
}) as unknown as UseStream<AgentThreadState>;
useEffect(() => {
if (thread.isLoading) setFinalState(null);
}, [thread.isLoading]);
const title = thread.values?.title ?? "Untitled";
useEffect(() => {
const pageTitle = isNewThread
? t.pages.newChat
: thread.isThreadLoading
? "Loading..."
: title === "Untitled" ? t.pages.untitled : title;
document.title = `${pageTitle} - ${t.pages.appName}`;
}, [
isNewThread,
t.pages.newChat,
t.pages.untitled,
t.pages.appName,
title,
thread.isThreadLoading,
]);
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
useEffect(() => {
setArtifacts(thread.values.artifacts);
if (
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
autoSelectFirstArtifact
) {
if (thread?.values?.artifacts?.length > 0) {
setAutoSelectFirstArtifact(false);
selectArtifact(thread.values.artifacts[0]!);
}
}
}, [
autoSelectFirstArtifact,
selectArtifact,
setArtifacts,
thread.values.artifacts,
]);
const artifactPanelOpen = useMemo(() => {
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
return artifactsOpen && artifacts?.length > 0;
}
return artifactsOpen;
}, [artifactsOpen, artifacts]);
const [todoListCollapsed, setTodoListCollapsed] = useState(true);
const handleSubmit = useSubmitThread({
isNewThread,
threadId,
thread,
threadContext: {
...settings.context,
thinking_enabled: settings.context.mode !== "flash",
is_plan_mode:
settings.context.mode === "pro" || settings.context.mode === "ultra",
subagent_enabled: settings.context.mode === "ultra",
reasoning_effort: settings.context.reasoning_effort,
},
afterSubmit() {
router.push(pathOfThread(threadId!));
},
});
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
void sendMessage(threadId, message);
},
[sendMessage, threadId],
);
const handleStop = useCallback(async () => {
await thread.stop();
}, [thread]);
if (!threadId) {
return null;
}
return (
<ThreadContext.Provider value={{ threadId, thread }}>
<ResizablePanelGroup orientation="horizontal">
<ResizablePanel
className="relative"
defaultSize={artifactPanelOpen ? 46 : 100}
minSize={artifactPanelOpen ? 30 : 100}
>
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center px-4",
isNewThread
? "bg-background/0 backdrop-blur-none"
: "bg-background/80 shadow-xs backdrop-blur",
)}
>
<div className="flex w-full items-center text-sm font-medium">
{title !== "Untitled" && (
<ThreadTitle threadId={threadId} threadTitle={title} />
)}
</div>
<div>
{artifacts?.length > 0 && !artifactsOpen && (
<Tooltip content="Show artifacts of this conversation">
<Button
className="text-muted-foreground hover:text-foreground"
variant="ghost"
onClick={() => {
setArtifactsOpen(true);
setSidebarOpen(false);
}}
>
<FilesIcon />
{t.common.artifacts}
</Button>
</Tooltip>
)}
</div>
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center">
<MessageList
className={cn("size-full", !isNewThread && "pt-10")}
threadId={threadId}
thread={thread}
messages={
(finalState?.messages as Message[])
?? thread.messages
}
paddingBottom={todoListCollapsed ? 160 : 280}
/>
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn(
"relative w-full",
isNewThread && "-translate-y-[calc(50vh-96px)]",
isNewThread
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
<div className="absolute right-0 bottom-0 left-0">
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
collapsed={todoListCollapsed}
hidden={
!thread.values.todos ||
thread.values.todos.length === 0
}
onToggle={() =>
setTodoListCollapsed(!todoListCollapsed)
}
/>
</div>
</div>
<InputBox
className={cn("bg-background/5 w-full -translate-y-4")}
isNewThread={isNewThread}
autoFocus={isNewThread}
status={thread.isLoading ? "streaming" : "ready"}
context={settings.context}
extraHeader={
isNewThread && <Welcome mode={settings.context.mode} />
}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onContextChange={(context) =>
setSettings("context", context)
}
onSubmit={handleSubmit}
onStop={handleStop}
/>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
</div>
</main>
</div>
</ResizablePanel>
<ResizableHandle
className={cn(
"opacity-33 hover:opacity-100",
!artifactPanelOpen && "pointer-events-none opacity-0",
)}
/>
<ResizablePanel
className={cn(
"transition-all duration-300 ease-in-out",
!artifactsOpen && "opacity-0",
)}
defaultSize={artifactPanelOpen ? 64 : 0}
minSize={0}
maxSize={artifactPanelOpen ? undefined : 0}
>
<div
<ThreadContext.Provider value={{ thread, isMock }}>
<ChatBox threadId={threadId}>
<div className="relative flex size-full min-h-0 justify-between">
<header
className={cn(
"h-full p-4 transition-transform duration-300 ease-in-out",
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
"absolute top-0 right-0 left-0 z-30 flex h-12 shrink-0 items-center px-4",
isNewThread
? "bg-background/0 backdrop-blur-none"
: "bg-background/80 shadow-xs backdrop-blur",
)}
>
{selectedArtifact ? (
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
<div className="flex w-full items-center text-sm font-medium">
<ThreadTitle threadId={threadId} thread={thread} />
</div>
<div>
<ArtifactTrigger />
</div>
</header>
<main className="flex min-h-0 max-w-full grow flex-col">
<div className="flex size-full justify-center">
<MessageList
className={cn("size-full", !isNewThread && "pt-10")}
threadId={threadId}
thread={thread}
/>
) : (
<div className="relative flex size-full justify-center">
<div className="absolute top-1 right-1 z-30">
<Button
size="icon-sm"
variant="ghost"
onClick={() => {
setArtifactsOpen(false);
}}
>
<XIcon />
</Button>
</div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
<div
className={cn(
"relative w-full",
isNewThread && "-translate-y-[calc(50vh-96px)]",
isNewThread
? "max-w-(--container-width-sm)"
: "max-w-(--container-width-md)",
)}
>
<div className="absolute -top-4 right-0 left-0 z-0">
<div className="absolute right-0 bottom-0 left-0">
<TodoList
className="bg-background/5"
todos={thread.values.todos ?? []}
hidden={
!thread.values.todos || thread.values.todos.length === 0
}
/>
</div>
</div>
{thread.values.artifacts?.length === 0 ? (
<ConversationEmptyState
icon={<FilesIcon />}
title="No artifact selected"
description="Select an artifact to view its details"
/>
) : (
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
<header className="shrink-0">
<h2 className="text-lg font-medium">Artifacts</h2>
</header>
<main className="min-h-0 grow">
<ArtifactFileList
className="max-w-(--container-width-sm) p-4 pt-12"
files={thread.values.artifacts ?? []}
threadId={threadId}
/>
</main>
<InputBox
className={cn("bg-background/5 w-full -translate-y-4")}
isNewThread={isNewThread}
autoFocus={isNewThread}
status={thread.isLoading ? "streaming" : "ready"}
context={settings.context}
extraHeader={
isNewThread && <Welcome mode={settings.context.mode} />
}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
onContextChange={(context) => setSettings("context", context)}
onSubmit={handleSubmit}
onStop={handleStop}
/>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode}
</div>
)}
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</main>
</div>
</ChatBox>
</ThreadContext.Provider>
);
}

View File

@@ -1,12 +1,12 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { Toaster } from "sonner";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
import { useLocalSettings } from "@/core/settings";
import { getLocalSettings, useLocalSettings } from "@/core/settings";
const queryClient = new QueryClient();
@@ -14,7 +14,11 @@ export default function WorkspaceLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
const [settings, setSettings] = useLocalSettings();
const [open, setOpen] = useState(() => !settings.layout.sidebar_collapsed);
const [open, setOpen] = useState(false); // SSR default: open (matches server render)
useLayoutEffect(() => {
// Runs synchronously before first paint on the client — no visual flash
setOpen(!getLocalSettings().layout.sidebar_collapsed);
}, []);
useEffect(() => {
setOpen(!settings.layout.sidebar_collapsed);
}, [settings.layout.sidebar_collapsed]);