"use client"; import { useEffect, useRef, useState } from "react"; import dynamic from "next/dynamic"; import WorkspaceMarkdown from "./files-workspace/WorkspaceMarkdown"; import { IS_DEMO } from "./studio-mode"; import { openWorkspaceFile } from "./open-file"; import { SparkMenuExpandLine, SparkMenuFoldLine } from "@agentscope-ai/icons"; import { BookOpenText, Bot, Check, CircleAlert, FileText, LoaderCircle, Monitor, Moon, Network, Send, Settings, Sparkles, Sun, SunMoon, X, } from "lucide-react"; import { readWorkspaceFile, streamChat } from "./api"; import { chatStreamError, formatStreamPayloads } from "./chat-stream"; import FilesNavigator from "./files-workspace/FilesNavigator"; import MemoryGraphView from "./files-workspace/MemoryGraphView"; import { clampNavigatorWidth } from "./files-workspace/panel-resize"; import { useI18n, useLanguageStore } from "./i18n"; import { useWorkspaceStore } from "./store"; import { type ThemePreference, useThemeStore } from "./theme"; import type { ChatBlock, ChatMessage, DetailBlock, WorkspaceTab, } from "./types"; import { appendWorkspaceFileReference, WORKSPACE_FILE_DRAG_TYPE, } from "./workspace-drag"; const SettingsCenter = IS_DEMO ? null : dynamic(() => import("./settings-center"), { ssr: false }); const DemoBar = IS_DEMO ? dynamic(() => import("../demo/DemoBar"), { ssr: false }) : null; import { hasUnsavedChanges, unsavedTabsClosedBy } from "./tab-close"; import packageJson from "../package.json"; import { scenarios } from "../demo/scenarios"; const TabbedEditor = dynamic(() => import("./files-workspace/TabbedEditor"), { ssr: false, }); function GitHubIcon({ size = 18 }: { size?: number }) { return ( ); } function ThemeMenu() { const { t } = useI18n(); const menuRef = useRef(null); const preference = useThemeStore((state) => state.preference); const setPreference = useThemeStore((state) => state.setPreference); useEffect(() => { const close = (event: PointerEvent) => { if (!menuRef.current?.contains(event.target as Node)) menuRef.current?.removeAttribute("open"); }; document.addEventListener("pointerdown", close); return () => document.removeEventListener("pointerdown", close); }, []); const options: Array<[ThemePreference, string, React.ReactNode]> = [ ["light", t("lightTheme"), ], ["dark", t("darkTheme"), ], ["system", t("systemTheme"), ], ]; return (
{options.map(([value, label, icon]) => ( ))}
); } function Tabs() { const { t } = useI18n(); const { tabs, activeTabId, setActiveTab, closeTab, closeOtherTabs } = useWorkspaceStore(); const [contextMenu, setContextMenu] = useState<{ tabId: string; left: number; top: number; }>(); const menuRef = useRef(null); useEffect(() => { if (!contextMenu) return; const close = (event: PointerEvent) => { if (!menuRef.current?.contains(event.target as Node)) setContextMenu(undefined); }; const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") setContextMenu(undefined); }; const closeOnViewportChange = () => setContextMenu(undefined); document.addEventListener("pointerdown", close); document.addEventListener("keydown", closeOnEscape); window.addEventListener("resize", closeOnViewportChange); window.addEventListener("scroll", closeOnViewportChange, true); return () => { document.removeEventListener("pointerdown", close); document.removeEventListener("keydown", closeOnEscape); window.removeEventListener("resize", closeOnViewportChange); window.removeEventListener("scroll", closeOnViewportChange, true); }; }, [contextMenu]); const confirmDiscard = (tabId: string, closeOthers: boolean) => { const unsaved = unsavedTabsClosedBy(tabs, tabId, closeOthers); return ( !unsaved.length || window.confirm( t("discardUnsavedConfirm", { count: String(unsaved.length) }), ) ); }; return ( <>
{tabs.map((tab) => { const dirty = hasUnsavedChanges(tab); return ( ); })}
{contextMenu && (
)} ); } function MarkdownView({ content }: { content: string }) { return (
); } function ChunkStatus({ active, error = false, }: { active: boolean; error?: boolean; }) { if (error) return ; return active ? ( ) : ( ); } function detailLabel( block: DetailBlock, t: ReturnType["t"], ): string { if (block.type === "think") return t("thinking"); if (block.type === "data") return block.mediaType ? `${t("dataChunk")} · ${block.mediaType}` : t("dataChunk"); if (block.type === "approval") return t("approvalChunk"); if (block.type === "usage") return t("usageChunk"); return t("unknownChunk", { type: block.sourceType }); } function ChatBlockView({ block, onToggle, }: { block: ChatBlock; onToggle: (blockId: string, expanded: boolean) => void; }) { const { t } = useI18n(); if (block.type === "content") return ; if (block.type === "error") return (
{block.text}
); if (block.type === "usage") return null; const active = block.status === "streaming" || block.status === "calling" || block.status === "running"; const failed = block.status === "error"; if (block.type === "tool") { const call = formatStreamPayloads(block.callPayloads); const result = formatStreamPayloads(block.resultPayloads); return (
{ if (event.currentTarget.open !== block.expanded) onToggle(block.id, event.currentTarget.open); }} > {block.name} {active ? t("streaming") : t("completed")}
{call && (
{t("toolCall")}
{call}
)} {result && (
{t("toolResult")}
{result}
)}
); } const detail = formatStreamPayloads(block.payloads); return (
{ if (event.currentTarget.open !== block.expanded) onToggle(block.id, event.currentTarget.open); }} > {detailLabel(block, t)} {active ? t("streaming") : t("completed")} {detail && (
{detail}
)}
); } function Chat({ tab }: { tab: Extract }) { const { language, t } = useI18n(); const [input, setInput] = useState(""); const [fileDragOver, setFileDragOver] = useState(false); const endRef = useRef(null); const controller = useRef(null); const { addChatTurn, applyChatChunk, toggleChatBlock, finishChat } = useWorkspaceStore(); useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth" }); }, [tab.messages]); useEffect(() => () => controller.current?.abort(), []); const suggestions = (
{(IS_DEMO ? scenarios[language].map((scenario) => scenario.question) : [t("promptRecent"), t("promptTasks"), t("promptIdeas")] ).map((question) => ( ))}
); const send = async () => { const query = input.trim(); if (!query || tab.streaming) return; setInput(""); const user: ChatMessage = { id: crypto.randomUUID(), role: "user", content: query, }; const assistant: ChatMessage = { id: crypto.randomUUID(), role: "assistant", content: "", blocks: [], }; addChatTurn(tab.id, user, assistant); const requestController = new AbortController(); controller.current = requestController; const error = await chatStreamError( () => streamChat(query, tab.sessionId, requestController.signal, (chunk) => applyChatChunk(tab.id, assistant.id, chunk), ), requestController.signal, t("chatFailed"), ); finishChat(tab.id, assistant.id, error); if (controller.current === requestController) controller.current = null; }; return (
{!tab.messages.length && (
)} {tab.messages.map((message) => (
{message.role === "user" ? ( t("you") ) : ( )}
{message.role === "user" &&

{message.content}

} {message.role === "assistant" && message.blocks?.map((block) => ( toggleChatBlock(tab.id, message.id, blockId, expanded) } /> ))} {message.role === "assistant" && tab.streaming && !message.blocks?.some((block) => block.type !== "usage") && ( )}
))} {IS_DEMO && tab.messages.length > 0 && suggestions}
{ if (!event.dataTransfer.types.includes(WORKSPACE_FILE_DRAG_TYPE)) return; event.preventDefault(); event.dataTransfer.dropEffect = "copy"; setFileDragOver(true); }} onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFileDragOver(false); }} onDrop={(event) => { const path = event.dataTransfer.getData(WORKSPACE_FILE_DRAG_TYPE); if (!path) return; event.preventDefault(); setFileDragOver(false); setInput((current) => appendWorkspaceFileReference(current, path)); }} >