@neeter/react
Advanced tools
| import type { RewindFilesResult } from "@neeter/types"; | ||
| export declare function RollbackButton({ checkpointId, isFirstCheckpoint, onFilesRewound, }: { | ||
| checkpointId: string; | ||
| isFirstCheckpoint: boolean; | ||
| onFilesRewound?: (result: RewindFilesResult) => void; | ||
| }): import("react/jsx-runtime").JSX.Element; |
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import { useEffect, useRef, useState } from "react"; | ||
| import { useAgentContext, useChatStore } from "./AgentProvider.js"; | ||
| export function RollbackButton({ checkpointId, isFirstCheckpoint, onFilesRewound, }) { | ||
| const { resumeSession, rewindSession, newSession } = useAgentContext(); | ||
| const isStreaming = useChatStore((s) => s.isStreaming); | ||
| const fileCheckpointing = useChatStore((s) => s.fileCheckpointing); | ||
| const [open, setOpen] = useState(false); | ||
| const [feedback, setFeedback] = useState(null); | ||
| const ref = useRef(null); | ||
| useEffect(() => { | ||
| if (!open) | ||
| return; | ||
| function onClickOutside(e) { | ||
| if (ref.current && !ref.current.contains(e.target)) | ||
| setOpen(false); | ||
| } | ||
| document.addEventListener("mousedown", onClickOutside); | ||
| return () => document.removeEventListener("mousedown", onClickOutside); | ||
| }, [open]); | ||
| const withClose = (fn) => async () => { | ||
| setOpen(false); | ||
| await fn(); | ||
| }; | ||
| const handleRewind = withClose(async () => { | ||
| if (isFirstCheckpoint) { | ||
| if (fileCheckpointing) | ||
| await rewindSession(checkpointId); | ||
| await newSession(); | ||
| } | ||
| else { | ||
| await resumeSession({ resumeSessionAt: checkpointId }); | ||
| } | ||
| }); | ||
| const handleFilesOnly = withClose(async () => { | ||
| const result = await rewindSession(checkpointId); | ||
| if (result.canRewind) { | ||
| onFilesRewound?.(result); | ||
| const n = result.filesChanged?.length ?? 0; | ||
| setFeedback(`${n} file${n !== 1 ? "s" : ""} restored`); | ||
| setTimeout(() => setFeedback(null), 2500); | ||
| } | ||
| else { | ||
| setFeedback(result.error ?? "Cannot rewind"); | ||
| setTimeout(() => setFeedback(null), 2500); | ||
| } | ||
| }); | ||
| return (_jsxs("div", { ref: ref, className: "relative", children: [_jsxs("div", { className: "flex items-center gap-1.5 -mt-1 mb-1 opacity-0 group-hover:opacity-100 transition-opacity", children: [_jsx("div", { className: "flex-1 border-t border-dashed border-muted-foreground/20" }), feedback && (_jsx("span", { className: "whitespace-nowrap text-[10px] text-emerald-600 dark:text-emerald-400 animate-pulse", children: feedback })), _jsx("button", { type: "button", disabled: isStreaming, onClick: () => setOpen((v) => !v), className: "rounded p-0.5 text-muted-foreground/40 hover:text-muted-foreground disabled:opacity-30 transition-colors", title: "Rollback to just prior to this message", children: _jsxs("svg", { "aria-hidden": "true", className: "size-3", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [_jsx("path", { d: "M1 4v6h6" }), _jsx("path", { d: "M3.51 15a9 9 0 1 0 2.13-9.36L1 10" })] }) })] }), open && (_jsxs("div", { className: "absolute right-0 top-7 z-20 flex flex-col rounded-md border bg-popover p-1 shadow-md min-w-[200px]", children: [_jsxs("button", { type: "button", onClick: handleRewind, className: "flex items-center gap-2 rounded px-2 py-1.5 text-xs text-popover-foreground hover:bg-muted text-left", children: [_jsxs("svg", { "aria-hidden": "true", className: "size-3 shrink-0", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [_jsx("path", { d: "M1 4v6h6" }), _jsx("path", { d: "M3.51 15a9 9 0 1 0 2.13-9.36L1 10" })] }), fileCheckpointing ? "Rewind conversation and files" : "Rewind conversation"] }), fileCheckpointing && (_jsxs("button", { type: "button", onClick: handleFilesOnly, className: "flex items-center gap-2 rounded px-2 py-1.5 text-xs text-popover-foreground hover:bg-muted text-left", children: [_jsxs("svg", { "aria-hidden": "true", className: "size-3 shrink-0", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [_jsx("path", { d: "M9 14 4 9l5-5" }), _jsx("path", { d: "M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11" })] }), "Rewind files only"] }))] }))] })); | ||
| } |
+1
-0
@@ -19,2 +19,3 @@ import "./widgets/AskUserQuestionWidget.js"; | ||
| export { PendingPermissions } from "./PendingPermissions.js"; | ||
| export { RollbackButton } from "./RollbackButton.js"; | ||
| export { getWidget, registerWidget, stripMcpPrefix } from "./registry.js"; | ||
@@ -21,0 +22,0 @@ export { StatusDot } from "./StatusDot.js"; |
+1
-0
@@ -19,2 +19,3 @@ // Built-in widgets (side-effect: auto-registers) | ||
| export { PendingPermissions } from "./PendingPermissions.js"; | ||
| export { RollbackButton } from "./RollbackButton.js"; | ||
| export { getWidget, registerWidget, stripMcpPrefix } from "./registry.js"; | ||
@@ -21,0 +22,0 @@ export { StatusDot } from "./StatusDot.js"; |
@@ -1,3 +0,5 @@ | ||
| export declare function MessageList({ className }: { | ||
| import type { RewindFilesResult } from "@neeter/types"; | ||
| export declare function MessageList({ className, onFilesRewound, }: { | ||
| className?: string; | ||
| onFilesRewound?: (result: RewindFilesResult) => void; | ||
| }): import("react/jsx-runtime").JSX.Element; |
@@ -6,2 +6,3 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import { PendingPermissions } from "./PendingPermissions.js"; | ||
| import { RollbackButton } from "./RollbackButton.js"; | ||
| import { TextMessage } from "./TextMessage.js"; | ||
@@ -11,4 +12,5 @@ import { ThinkingBlock } from "./ThinkingBlock.js"; | ||
| import { ToolCallCard } from "./ToolCallCard.js"; | ||
| export function MessageList({ className }) { | ||
| export function MessageList({ className, onFilesRewound, }) { | ||
| const messages = useChatStore((s) => s.messages); | ||
| const checkpoints = useChatStore((s) => s.checkpoints); | ||
| const streamingText = useChatStore((s) => s.streamingText); | ||
@@ -22,2 +24,4 @@ const streamingThinking = useChatStore((s) => s.streamingThinking); | ||
| }, [messages, streamingText, streamingThinking, isThinking]); | ||
| // Map nth user message → checkpoints[n] | ||
| let userMsgIndex = 0; | ||
| return (_jsx("div", { className: cn("flex-1 overflow-y-auto", className), children: _jsxs("div", { className: "flex flex-col gap-3 p-4 text-sm", children: [messages.map((msg) => { | ||
@@ -30,2 +34,6 @@ if (msg.toolCalls?.length) { | ||
| } | ||
| if (msg.role === "user" && msg.content) { | ||
| const cpId = checkpoints[userMsgIndex++]; | ||
| return (_jsxs("div", { className: "group", children: [cpId && (_jsx(RollbackButton, { checkpointId: cpId, isFirstCheckpoint: cpId === checkpoints[0], onFilesRewound: onFilesRewound })), _jsx("div", { className: "flex justify-end", children: _jsx(TextMessage, { role: "user", content: msg.content }) })] }, msg.id)); | ||
| } | ||
| if (msg.content || msg.thinking) { | ||
@@ -32,0 +40,0 @@ return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [msg.thinking && _jsx(ThinkingBlock, { thinking: msg.thinking }), msg.content && (_jsx(TextMessage, { role: msg.role, content: msg.content }))] }, msg.id)); |
+2
-0
@@ -14,2 +14,3 @@ import type { ChatMessage, McpServerStatus, PermissionRequest, SSEEvent } from "@neeter/types"; | ||
| checkpoints: string[]; | ||
| fileCheckpointing: boolean; | ||
| totalCost: number; | ||
@@ -38,2 +39,3 @@ totalTurns: number; | ||
| addCheckpoint: (uuid: string) => void; | ||
| setFileCheckpointing: (v: boolean) => void; | ||
| addCost: (cost: number, turns: number) => void; | ||
@@ -40,0 +42,0 @@ cancelInflightToolCalls: () => void; |
+25
-8
@@ -31,2 +31,3 @@ import { immer } from "zustand/middleware/immer"; | ||
| checkpoints: [], | ||
| fileCheckpointing: false, | ||
| totalCost: 0, | ||
@@ -150,2 +151,5 @@ totalTurns: 0, | ||
| }), | ||
| setFileCheckpointing: (v) => set((s) => { | ||
| s.fileCheckpointing = v; | ||
| }), | ||
| addCost: (cost, turns) => set((s) => { | ||
@@ -180,2 +184,3 @@ s.totalCost += cost; | ||
| s.checkpoints = []; | ||
| s.fileCheckpointing = false; | ||
| s.totalCost = 0; | ||
@@ -194,4 +199,20 @@ s.totalTurns = 0; | ||
| const stopAt = options?.stopAtCheckpoint; | ||
| let foundTarget = false; | ||
| for (const evt of events) { | ||
| // "Before" semantics: truncate events before the target checkpoint's | ||
| // user message. This matches rewindFiles() which restores files to | ||
| // their state before that message's turn. | ||
| let replayable = events; | ||
| if (stopAt) { | ||
| const cpIdx = events.findIndex((e) => e.event === "checkpoint" && JSON.parse(e.data).userMessageUuid === stopAt); | ||
| if (cpIdx >= 0) { | ||
| let cutIdx = cpIdx; | ||
| for (let i = cpIdx - 1; i >= 0; i--) { | ||
| if (events[i].event === "user_message") { | ||
| cutIdx = i; | ||
| break; | ||
| } | ||
| } | ||
| replayable = events.slice(0, cutIdx); | ||
| } | ||
| } | ||
| for (const evt of replayable) { | ||
| const data = JSON.parse(evt.data); | ||
@@ -206,2 +227,4 @@ switch (evt.event) { | ||
| s.setMcpServers(data.mcpServers); | ||
| if (data.fileCheckpointing) | ||
| s.setFileCheckpointing(true); | ||
| break; | ||
@@ -234,4 +257,2 @@ case "thinking_delta": | ||
| s.addCheckpoint(data.userMessageUuid); | ||
| if (stopAt && data.userMessageUuid === stopAt) | ||
| foundTarget = true; | ||
| break; | ||
@@ -242,4 +263,2 @@ case "turn_complete": | ||
| s.addCost(data.cost ?? 0, data.numTurns ?? 0); | ||
| if (foundTarget) | ||
| return; | ||
| break; | ||
@@ -250,4 +269,2 @@ case "session_error": | ||
| s.addSystemMessage(`Session ended: ${data.subtype}`); | ||
| if (foundTarget) | ||
| return; | ||
| break; | ||
@@ -254,0 +271,0 @@ } |
@@ -57,6 +57,8 @@ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; | ||
| es.addEventListener("session_init", (e) => { | ||
| const { sdkSessionId: id, mcpServers } = JSON.parse(e.data); | ||
| const { sdkSessionId: id, mcpServers, fileCheckpointing } = JSON.parse(e.data); | ||
| store.getState().setSdkSessionId(id); | ||
| if (mcpServers) | ||
| store.getState().setMcpServers(mcpServers); | ||
| if (fileCheckpointing) | ||
| store.getState().setFileCheckpointing(true); | ||
| }); | ||
@@ -63,0 +65,0 @@ es.addEventListener("message_start", () => { |
+2
-2
| { | ||
| "name": "@neeter/react", | ||
| "version": "0.12.0", | ||
| "version": "0.13.0", | ||
| "description": "React components and hooks for building chat UIs on top of the Claude Agent SDK", | ||
@@ -29,3 +29,3 @@ "license": "MIT", | ||
| "tailwind-merge": "^3.5.0", | ||
| "@neeter/types": "0.12.0" | ||
| "@neeter/types": "0.13.0" | ||
| }, | ||
@@ -32,0 +32,0 @@ "peerDependencies": { |
+1
-1
@@ -72,3 +72,3 @@ # @neeter/react | ||
| | [basic-chat](https://github.com/quantumleeps/neeter/tree/main/examples/basic-chat) | Minimal component setup | | ||
| | [live-preview](https://github.com/quantumleeps/neeter/tree/main/examples/live-preview) | Custom events, widgets, split-pane preview | | ||
| | [code-workbench](https://github.com/quantumleeps/neeter/tree/main/examples/code-workbench) | Custom events, widgets, split-pane preview, file checkpointing | | ||
@@ -75,0 +75,0 @@ ## Documentation |
110563
5.95%72
2.86%2106
4.78%+ Added
- Removed
Updated