(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 && (
)}
{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 && (
)}
);
}
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 && (
{t("chatTitle")}
{t(IS_DEMO ? "demoChatDescription" : "chatDescription")}
{suggestions}
)}
{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));
}}
>
{t(IS_DEMO ? "demoChatHint" : "composerHint")}
);
}
function Workspace() {
const { language, setLanguage, t } = useI18n();
const hydrateLanguage = useLanguageStore((state) => state.hydrate);
const hydrateTheme = useThemeStore((state) => state.hydrate);
const [navOpen, setNavOpen] = useState(true);
const [navigatorWidth, setNavigatorWidth] = useState(260);
const [resizingNavigator, setResizingNavigator] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const { tabs, activeTabId, openAgent, hydrateMarkdown, failMarkdown } =
useWorkspaceStore();
const active = tabs.find((tab) => tab.id === activeTabId);
useEffect(() => {
let mounted = true;
void Promise.resolve(useWorkspaceStore.persist.rehydrate()).then(
async () => {
if (IS_DEMO && !useWorkspaceStore.getState().tabs.length && mounted) {
await openWorkspaceFile("START_HERE.md");
}
const restoredFiles = useWorkspaceStore
.getState()
.tabs.filter(
(tab): tab is Extract =>
tab.type === "markdown" && Boolean(tab.loading),
);
await Promise.all(
restoredFiles.map(async (tab) => {
try {
const file = await readWorkspaceFile(tab.path);
if (mounted)
hydrateMarkdown(tab.id, file.content, file.stat.mtime);
} catch (error) {
if (mounted)
failMarkdown(
tab.id,
error instanceof Error
? error.message
: "Failed to read file",
);
}
}),
);
},
);
return () => {
mounted = false;
};
}, [failMarkdown, hydrateMarkdown]);
useEffect(() => {
hydrateLanguage();
}, [hydrateLanguage]);
useEffect(() => {
hydrateTheme();
}, [hydrateTheme]);
useEffect(() => {
const saved = Number(localStorage.getItem("reme-navigator-width"));
if (!Number.isFinite(saved) || saved <= 0) return;
const frame = requestAnimationFrame(() =>
setNavigatorWidth(clampNavigatorWidth(saved, window.innerWidth)),
);
return () => cancelAnimationFrame(frame);
}, []);
const resizeNavigator = (event: React.PointerEvent) => {
event.preventDefault();
const navigator = event.currentTarget.parentElement;
const surface = navigator?.parentElement;
if (!navigator || !surface) return;
setResizingNavigator(true);
const startX = event.clientX;
const initialWidth = navigator.getBoundingClientRect().width;
const containerWidth = surface.getBoundingClientRect().width;
const move = (nextEvent: PointerEvent) => {
setNavigatorWidth(
clampNavigatorWidth(
initialWidth + nextEvent.clientX - startX,
containerWidth,
),
);
};
const stop = (nextEvent: PointerEvent) => {
const finalWidth = clampNavigatorWidth(
initialWidth + nextEvent.clientX - startX,
containerWidth,
);
setNavigatorWidth(finalWidth);
localStorage.setItem("reme-navigator-width", String(finalWidth));
setResizingNavigator(false);
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", stop);
window.removeEventListener("pointercancel", stop);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", stop);
window.addEventListener("pointercancel", stop);
};
const resizeNavigatorWithKeyboard = (
event: React.KeyboardEvent,
) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
const containerWidth =
event.currentTarget.parentElement?.parentElement?.getBoundingClientRect()
.width ?? window.innerWidth;
setNavigatorWidth((current) => {
const next = clampNavigatorWidth(
current + (event.key === "ArrowRight" ? 24 : -24),
containerWidth,
);
localStorage.setItem("reme-navigator-width", String(next));
return next;
});
};
return (
{DemoBar && }
{!active && (
Studio
{t("welcomeDescription")}
{t("localFiles")}
)}
{active?.type === "markdown" && (
)}
{active?.type === "agent" &&
}
{active?.type === "graph" && (
)}
{SettingsCenter && (
setSettingsOpen(false)}
/>
)}
);
}
export function ReMeWorkspace() {
return ;
}