@neeter/react
Advanced tools
@@ -0,4 +1,4 @@ | ||
| import { type ChatStore, type ChatStoreShape } from "@neeter/core"; | ||
| import type { CustomEvent } from "@neeter/types"; | ||
| import { type ReactNode } from "react"; | ||
| import { type ChatStore, type ChatStoreShape } from "./store.js"; | ||
| import { type UseAgentReturn } from "./use-agent.js"; | ||
@@ -5,0 +5,0 @@ interface AgentContextValue extends UseAgentReturn { |
| import { jsx as _jsx } from "react/jsx-runtime"; | ||
| import { createChatStore } from "@neeter/core"; | ||
| import { createContext, useContext, useMemo, useRef } from "react"; | ||
| import { useStore } from "zustand"; | ||
| import { createChatStore } from "./store.js"; | ||
| import { useAgent } from "./use-agent.js"; | ||
@@ -6,0 +6,0 @@ const AgentContext = createContext(null); |
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import { cn } from "@neeter/core"; | ||
| import { useEffect, useRef } from "react"; | ||
| import { cn } from "./cn.js"; | ||
| export function ApprovalButtons({ onApprove, onDeny, className, }) { | ||
@@ -5,0 +5,0 @@ const allowRef = useRef(null); |
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import { cn } from "@neeter/core"; | ||
| import { useCallback, useRef, useState } from "react"; | ||
| import { cn } from "./cn.js"; | ||
| import { SendIcon, StopIcon } from "./icons.js"; | ||
@@ -5,0 +5,0 @@ export function ChatInput({ onSend, onStop, isStreaming, placeholder = "Type a message...", disabled, className, }) { |
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import { cn } from "@neeter/core"; | ||
| import { useState } from "react"; | ||
| import { cn } from "./cn.js"; | ||
| import { ChevronIcon } from "./icons.js"; | ||
@@ -5,0 +5,0 @@ import { StatusDot } from "./StatusDot.js"; |
+1
-1
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import { cn } from "./cn.js"; | ||
| import { cn } from "@neeter/core"; | ||
| export function ChevronIcon({ open, className }) { | ||
@@ -4,0 +4,0 @@ return (_jsx("svg", { xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", className: cn("h-3 w-3 transition-transform", open && "rotate-90", className), "aria-hidden": "true", children: _jsx("path", { d: "m9 18 6-6-6-6" }) })); |
+1
-3
@@ -12,2 +12,3 @@ import "./widgets/AskUserQuestionWidget.js"; | ||
| import "./widgets/WriteWidget.js"; | ||
| export { AgentClient, type AgentClientConfig, type ChatStore, type ChatStoreShape, cn, createChatStore, findMatchingApproval, getWidget, isApprovalClaimedByToolCall, type ResumeOptions, type RewindOptions, registerWidget, replayEvents, stripMcpPrefix, } from "@neeter/core"; | ||
| export type { ChatMessage, CustomEvent, ModelUsage, PermissionRequest, PermissionResponse, SessionHistoryEntry, SessionInitEvent, SSEEvent, StopReason, TokenUsage, ToolApprovalRequest, ToolApprovalResponse, ToolCallInfo, ToolCallPhase, TurnCompleteData, UserQuestion, UserQuestionOption, UserQuestionRequest, UserQuestionResponse, } from "@neeter/types"; | ||
@@ -17,9 +18,6 @@ export { AgentProvider, useAgentContext, useChatStore } from "./AgentProvider.js"; | ||
| export { CollapsibleCard } from "./CollapsibleCard.js"; | ||
| export { cn } from "./cn.js"; | ||
| export { MessageList } from "./MessageList.js"; | ||
| export { PendingPermissions } from "./PendingPermissions.js"; | ||
| export { RollbackButton } from "./RollbackButton.js"; | ||
| export { getWidget, registerWidget, stripMcpPrefix } from "./registry.js"; | ||
| export { StatusDot } from "./StatusDot.js"; | ||
| export { type ChatStore, type ChatStoreShape, createChatStore, replayEvents } from "./store.js"; | ||
| export { TextMessage } from "./TextMessage.js"; | ||
@@ -26,0 +24,0 @@ export { ThinkingBlock } from "./ThinkingBlock.js"; |
+2
-3
@@ -13,12 +13,11 @@ // Built-in widgets (side-effect: auto-registers) | ||
| import "./widgets/WriteWidget.js"; | ||
| // Re-export everything from @neeter/core for backward compatibility | ||
| export { AgentClient, cn, createChatStore, findMatchingApproval, getWidget, isApprovalClaimedByToolCall, registerWidget, replayEvents, stripMcpPrefix, } from "@neeter/core"; | ||
| export { AgentProvider, useAgentContext, useChatStore } from "./AgentProvider.js"; | ||
| export { ChatInput } from "./ChatInput.js"; | ||
| export { CollapsibleCard } from "./CollapsibleCard.js"; | ||
| export { cn } from "./cn.js"; | ||
| export { MessageList } from "./MessageList.js"; | ||
| export { PendingPermissions } from "./PendingPermissions.js"; | ||
| export { RollbackButton } from "./RollbackButton.js"; | ||
| export { getWidget, registerWidget, stripMcpPrefix } from "./registry.js"; | ||
| export { StatusDot } from "./StatusDot.js"; | ||
| export { createChatStore, replayEvents } from "./store.js"; | ||
| export { TextMessage } from "./TextMessage.js"; | ||
@@ -25,0 +24,0 @@ export { ThinkingBlock } from "./ThinkingBlock.js"; |
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import { cn } from "@neeter/core"; | ||
| import { useEffect, useRef } from "react"; | ||
| import { useChatStore } from "./AgentProvider.js"; | ||
| import { cn } from "./cn.js"; | ||
| import { PendingPermissions } from "./PendingPermissions.js"; | ||
@@ -6,0 +6,0 @@ import { RollbackButton } from "./RollbackButton.js"; |
| import { jsx as _jsx } from "react/jsx-runtime"; | ||
| import { cn, isApprovalClaimedByToolCall } from "@neeter/core"; | ||
| import { useAgentContext, useChatStore } from "./AgentProvider.js"; | ||
| import { isApprovalClaimedByToolCall } from "./approval-matching.js"; | ||
| import { cn } from "./cn.js"; | ||
| import { ToolApprovalCard } from "./ToolApprovalCard.js"; | ||
@@ -6,0 +5,0 @@ import { UserQuestionCard } from "./UserQuestionCard.js"; |
| import { jsx as _jsx } from "react/jsx-runtime"; | ||
| import { cn } from "./cn.js"; | ||
| import { cn } from "@neeter/core"; | ||
| const phaseClasses = { | ||
@@ -4,0 +4,0 @@ pending: "bg-muted-foreground/40", |
| import { jsx as _jsx } from "react/jsx-runtime"; | ||
| import { cn } from "@neeter/core"; | ||
| import Markdown from "react-markdown"; | ||
| import remarkGfm from "remark-gfm"; | ||
| import { cn } from "./cn.js"; | ||
| import { markdownComponents } from "./markdown-overrides.js"; | ||
@@ -6,0 +6,0 @@ export function TextMessage({ role, content, className, }) { |
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import { cn } from "@neeter/core"; | ||
| import { useCallback, useState } from "react"; | ||
| import Markdown from "react-markdown"; | ||
| import remarkGfm from "remark-gfm"; | ||
| import { cn } from "./cn.js"; | ||
| import { ChevronIcon } from "./icons.js"; | ||
@@ -7,0 +7,0 @@ import { markdownComponents } from "./markdown-overrides.js"; |
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import { cn } from "./cn.js"; | ||
| import { cn } from "@neeter/core"; | ||
| export function ThinkingIndicator({ className }) { | ||
| return (_jsx("div", { className: cn("flex justify-start", className), children: _jsxs("div", { className: "flex items-center gap-1 rounded-lg bg-muted px-3 py-2", children: [_jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:0ms]" }), _jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:150ms]" }), _jsx("span", { className: "h-1.5 w-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:300ms]" })] }) })); | ||
| } |
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import { cn, getWidget, stripMcpPrefix } from "@neeter/core"; | ||
| import { ApprovalButtons } from "./ApprovalButtons.js"; | ||
| import { cn } from "./cn.js"; | ||
| import { getWidget, stripMcpPrefix } from "./registry.js"; | ||
| import { PulsingDot } from "./StatusDot.js"; | ||
@@ -6,0 +5,0 @@ export function ToolApprovalCard({ request, onApprove, onDeny, className, }) { |
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import { cn, findMatchingApproval, getWidget, stripMcpPrefix } from "@neeter/core"; | ||
| import { useAgentContext, useChatStore } from "./AgentProvider.js"; | ||
| import { ApprovalButtons } from "./ApprovalButtons.js"; | ||
| import { findMatchingApproval } from "./approval-matching.js"; | ||
| import { CollapsibleCard } from "./CollapsibleCard.js"; | ||
| import { cn } from "./cn.js"; | ||
| import { getWidget, stripMcpPrefix } from "./registry.js"; | ||
| import { PulsingDot, StatusDot } from "./StatusDot.js"; | ||
@@ -9,0 +7,0 @@ export function ToolCallCard({ toolCall, className, }) { |
+3
-13
@@ -1,15 +0,5 @@ | ||
| import type { ToolCallPhase } from "@neeter/types"; | ||
| import type { WidgetProps as CoreWidgetProps, WidgetRegistration as CoreWidgetRegistration } from "@neeter/core"; | ||
| import type { ComponentType } from "react"; | ||
| export interface WidgetProps<TResult = unknown> { | ||
| phase: ToolCallPhase; | ||
| toolUseId: string; | ||
| input: Record<string, unknown>; | ||
| partialInput?: string; | ||
| result?: TResult; | ||
| error?: string; | ||
| } | ||
| export interface WidgetRegistration<TResult = unknown> { | ||
| toolName: string; | ||
| label: string; | ||
| richLabel?: (result: TResult, input: Record<string, unknown>) => string | null; | ||
| export type WidgetProps<TResult = unknown> = CoreWidgetProps<TResult>; | ||
| export interface WidgetRegistration<TResult = unknown> extends Omit<CoreWidgetRegistration<TResult>, "inputRenderer" | "component"> { | ||
| inputRenderer?: ComponentType<{ | ||
@@ -16,0 +6,0 @@ input: Record<string, unknown>; |
@@ -1,7 +0,7 @@ | ||
| import type { CustomEvent, PermissionResponse, RewindFilesResult, SessionHistoryEntry } from "@neeter/types"; | ||
| import { type ChatStore } from "./store.js"; | ||
| import { type ChatStore } from "@neeter/core"; | ||
| import type { PermissionResponse, RewindFilesResult, SessionHistoryEntry } from "@neeter/types"; | ||
| export interface UseAgentConfig { | ||
| endpoint?: string; | ||
| resumeSessionId?: string; | ||
| onCustomEvent?: (event: CustomEvent) => void; | ||
| onCustomEvent?: (event: import("@neeter/types").CustomEvent) => void; | ||
| } | ||
@@ -27,3 +27,3 @@ export interface UseAgentReturn { | ||
| /** | ||
| * Low-level hook that manages the EventSource lifecycle and feeds SSE events | ||
| * Low-level hook that manages the AgentClient lifecycle and feeds SSE events | ||
| * into the Zustand store. Most apps should use `AgentProvider` instead — | ||
@@ -30,0 +30,0 @@ * this hook is for custom provider implementations. |
+41
-240
@@ -0,5 +1,5 @@ | ||
| import { AgentClient } from "@neeter/core"; | ||
| import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; | ||
| import { replayEvents } from "./store.js"; | ||
| /** | ||
| * Low-level hook that manages the EventSource lifecycle and feeds SSE events | ||
| * Low-level hook that manages the AgentClient lifecycle and feeds SSE events | ||
| * into the Zustand store. Most apps should use `AgentProvider` instead — | ||
@@ -12,260 +12,61 @@ * this hook is for custom provider implementations. | ||
| const onCustomEvent = config?.onCustomEvent; | ||
| const eventSourceRef = useRef(null); | ||
| const abortedRef = useRef(false); | ||
| const clientRef = useRef(null); | ||
| const [sessionHistory, setSessionHistory] = useState([]); | ||
| const sessionId = useSyncExternalStore(store.subscribe, () => store.getState().sessionId); | ||
| const sdkSessionId = useSyncExternalStore(store.subscribe, () => store.getState().sdkSessionId); | ||
| const [sessionHistory, setSessionHistory] = useState([]); | ||
| // Effect 1: Create client, connect session, destroy on cleanup | ||
| // onCustomEvent is NOT a dep — changing it shouldn't create a new session | ||
| useEffect(() => { | ||
| let cancelled = false; | ||
| async function init() { | ||
| let res; | ||
| if (resumeSessionId) { | ||
| try { | ||
| const eventsRes = await fetch(`${endpoint}/sessions/replay/${resumeSessionId}`); | ||
| if (eventsRes.ok && !cancelled) { | ||
| const events = await eventsRes.json(); | ||
| replayEvents(store, events); | ||
| } | ||
| } | ||
| catch { | ||
| /* replay is best-effort */ | ||
| } | ||
| res = await fetch(`${endpoint}/sessions/resume`, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ sdkSessionId: resumeSessionId }), | ||
| }); | ||
| const client = new AgentClient(store, { endpoint }); | ||
| client.onHistoryChange = setSessionHistory; | ||
| clientRef.current = client; | ||
| client.connect(resumeSessionId).catch(() => { | ||
| // Error already surfaced via store.addSystemMessage in AgentClient.connect | ||
| }); | ||
| return () => { | ||
| client.destroy(); | ||
| if (clientRef.current === client) { | ||
| clientRef.current = null; | ||
| } | ||
| else { | ||
| res = await fetch(`${endpoint}/sessions`, { method: "POST" }); | ||
| } | ||
| const data = await res.json(); | ||
| if (!cancelled) | ||
| store.getState().setSessionId(data.sessionId); | ||
| } | ||
| init(); | ||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, [endpoint, resumeSessionId, store]); | ||
| // Effect 2: Attach EventSource with explicit cleanup (Strict Mode safe) | ||
| // endpoint and store are intentional deps — matches the original hook behavior | ||
| // biome-ignore lint/correctness/useExhaustiveDependencies: re-attach ES when endpoint/store change | ||
| useEffect(() => { | ||
| if (!sessionId) | ||
| return; | ||
| const es = new EventSource(`${endpoint}/sessions/${sessionId}/events`); | ||
| eventSourceRef.current = es; | ||
| abortedRef.current = false; | ||
| es.addEventListener("session_init", (e) => { | ||
| 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); | ||
| }); | ||
| es.addEventListener("message_start", () => { | ||
| store.getState().setThinking(true); | ||
| }); | ||
| es.addEventListener("thinking_delta", (e) => { | ||
| const { text } = JSON.parse(e.data); | ||
| store.getState().appendStreamingThinking(text); | ||
| }); | ||
| es.addEventListener("text_delta", (e) => { | ||
| store.getState().setThinking(false); | ||
| store.getState().flushStreamingThinking(); | ||
| const { text } = JSON.parse(e.data); | ||
| store.getState().appendStreamingText(text); | ||
| }); | ||
| es.addEventListener("tool_start", (e) => { | ||
| store.getState().setThinking(false); | ||
| store.getState().flushStreamingThinking(); | ||
| store.getState().flushStreamingText(); | ||
| const { id, name } = JSON.parse(e.data); | ||
| store.getState().startToolCall(id, name); | ||
| }); | ||
| es.addEventListener("tool_input_delta", (e) => { | ||
| const { id, partialJson } = JSON.parse(e.data); | ||
| store.getState().appendToolInput(id, partialJson); | ||
| }); | ||
| es.addEventListener("tool_call", (e) => { | ||
| store.getState().flushStreamingText(); | ||
| const { id, name, input } = JSON.parse(e.data); | ||
| store.getState().finalizeToolCall(id, name, input); | ||
| }); | ||
| es.addEventListener("tool_result", (e) => { | ||
| const { toolUseId, result, isError } = JSON.parse(e.data); | ||
| if (isError) { | ||
| store.getState().errorToolCall(toolUseId, result); | ||
| } | ||
| else { | ||
| store.getState().completeToolCall(toolUseId, result); | ||
| } | ||
| if (store.getState().isStreaming) { | ||
| store.getState().setThinking(true); | ||
| } | ||
| }); | ||
| es.addEventListener("permission_request", (e) => { | ||
| const request = JSON.parse(e.data); | ||
| store.getState().addPermissionRequest(request); | ||
| }); | ||
| es.addEventListener("session_error", (e) => { | ||
| const { subtype, stopReason } = JSON.parse(e.data); | ||
| store.getState().flushStreamingThinking(); | ||
| store.getState().flushStreamingText(); | ||
| store.getState().setThinking(false); | ||
| store.getState().setStreaming(false); | ||
| store.getState().setStopReason(stopReason ?? null); | ||
| if (abortedRef.current) { | ||
| store.getState().cancelInflightToolCalls(); | ||
| store.getState().addSystemMessage("Interrupted"); | ||
| abortedRef.current = false; | ||
| } | ||
| else { | ||
| store.getState().addSystemMessage(`Session ended: ${subtype}`); | ||
| } | ||
| }); | ||
| es.addEventListener("turn_complete", (e) => { | ||
| const data = JSON.parse(e.data); | ||
| store.getState().flushStreamingThinking(); | ||
| store.getState().flushStreamingText(); | ||
| store.getState().setThinking(false); | ||
| store.getState().setStreaming(false); | ||
| store.getState().setStopReason(data.stopReason ?? null); | ||
| store.getState().addCost({ | ||
| cost: data.cost ?? 0, | ||
| numTurns: data.numTurns ?? 0, | ||
| stopReason: data.stopReason ?? null, | ||
| usage: data.usage ?? null, | ||
| modelUsage: data.modelUsage ?? null, | ||
| }); | ||
| if (abortedRef.current) { | ||
| store.getState().cancelInflightToolCalls(); | ||
| store.getState().addSystemMessage("Interrupted"); | ||
| abortedRef.current = false; | ||
| } | ||
| }); | ||
| es.addEventListener("error", () => { | ||
| if (abortedRef.current) { | ||
| store.getState().flushStreamingThinking(); | ||
| store.getState().flushStreamingText(); | ||
| store.getState().cancelInflightToolCalls(); | ||
| store.getState().setThinking(false); | ||
| store.getState().setStreaming(false); | ||
| store.getState().addSystemMessage("Interrupted"); | ||
| abortedRef.current = false; | ||
| es.close(); | ||
| } | ||
| else if (es.readyState === EventSource.CLOSED) { | ||
| store.getState().flushStreamingThinking(); | ||
| store.getState().flushStreamingText(); | ||
| store.getState().setThinking(false); | ||
| store.getState().setStreaming(false); | ||
| } | ||
| }); | ||
| es.addEventListener("checkpoint", (e) => { | ||
| const { userMessageUuid } = JSON.parse(e.data); | ||
| store.getState().addCheckpoint(userMessageUuid); | ||
| }); | ||
| if (onCustomEvent) { | ||
| es.addEventListener("custom", (e) => { | ||
| onCustomEvent(JSON.parse(e.data)); | ||
| }); | ||
| } | ||
| const client = clientRef.current; | ||
| if (!client) | ||
| return; | ||
| client.onCustomEvent = onCustomEvent; | ||
| client.attachEventSource(); | ||
| return () => { | ||
| es.close(); | ||
| eventSourceRef.current = null; | ||
| client.closeEventSource(); | ||
| }; | ||
| }, [sessionId, endpoint, store, onCustomEvent]); | ||
| const sendMessage = useCallback(async (text) => { | ||
| if (!sessionId) | ||
| return; | ||
| store.getState().addUserMessage(text); | ||
| store.getState().setStreaming(true); | ||
| store.getState().setThinking(true); | ||
| await fetch(`${endpoint}/sessions/${sessionId}/messages`, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ text }), | ||
| }); | ||
| }, [sessionId, endpoint, store]); | ||
| await clientRef.current?.sendMessage(text); | ||
| }, []); | ||
| const stopSession = useCallback(async () => { | ||
| if (!sessionId) | ||
| return; | ||
| abortedRef.current = true; | ||
| store.getState().setThinking(false); | ||
| store.getState().setStreaming(false); | ||
| await fetch(`${endpoint}/sessions/${sessionId}/abort`, { method: "POST" }); | ||
| }, [sessionId, endpoint, store]); | ||
| await clientRef.current?.stopSession(); | ||
| }, []); | ||
| const respondToPermission = useCallback(async (response) => { | ||
| if (!sessionId) | ||
| return; | ||
| store.getState().removePermissionRequest(response.requestId); | ||
| await fetch(`${endpoint}/sessions/${sessionId}/permissions`, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify(response), | ||
| }); | ||
| }, [sessionId, endpoint, store]); | ||
| await clientRef.current?.respondToPermission(response); | ||
| }, []); | ||
| const resumeSession = useCallback(async (options) => { | ||
| const targetSdkSessionId = options?.sdkSessionId ?? store.getState().sdkSessionId; | ||
| if (!targetSdkSessionId) | ||
| return; | ||
| if (eventSourceRef.current) { | ||
| eventSourceRef.current.close(); | ||
| eventSourceRef.current = null; | ||
| } | ||
| store.getState().reset(); | ||
| try { | ||
| const eventsRes = await fetch(`${endpoint}/sessions/replay/${targetSdkSessionId}`); | ||
| if (eventsRes.ok) { | ||
| const events = await eventsRes.json(); | ||
| replayEvents(store, events, { | ||
| stopAtCheckpoint: options?.resumeSessionAt, | ||
| }); | ||
| } | ||
| } | ||
| catch { | ||
| /* replay is best-effort */ | ||
| } | ||
| const res = await fetch(`${endpoint}/sessions/resume`, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ | ||
| sdkSessionId: targetSdkSessionId, | ||
| forkSession: options?.fork, | ||
| resumeSessionAt: options?.resumeSessionAt, | ||
| }), | ||
| }); | ||
| const data = await res.json(); | ||
| store.getState().setSessionId(data.sessionId); | ||
| }, [endpoint, store]); | ||
| await clientRef.current?.resumeSession(options); | ||
| }, []); | ||
| const refreshHistory = useCallback(async () => { | ||
| try { | ||
| const res = await fetch(`${endpoint}/sessions/history`); | ||
| if (res.ok) | ||
| setSessionHistory(await res.json()); | ||
| } | ||
| catch { | ||
| /* ignore */ | ||
| } | ||
| }, [endpoint]); | ||
| await clientRef.current?.refreshHistory(); | ||
| }, []); | ||
| const rewindSession = useCallback(async (checkpointId, options) => { | ||
| if (!sessionId) | ||
| const client = clientRef.current; | ||
| if (!client) | ||
| return { canRewind: false, error: "No active session" }; | ||
| const res = await fetch(`${endpoint}/sessions/${sessionId}/rewind`, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ userMessageId: checkpointId, dryRun: options?.dryRun }), | ||
| }); | ||
| return res.json(); | ||
| }, [sessionId, endpoint]); | ||
| return client.rewindSession(checkpointId, options); | ||
| }, []); | ||
| const newSession = useCallback(async () => { | ||
| if (eventSourceRef.current) { | ||
| eventSourceRef.current.close(); | ||
| eventSourceRef.current = null; | ||
| } | ||
| store.getState().reset(); | ||
| const res = await fetch(`${endpoint}/sessions`, { method: "POST" }); | ||
| const data = await res.json(); | ||
| store.getState().setSessionId(data.sessionId); | ||
| }, [endpoint, store]); | ||
| await clientRef.current?.newSession(); | ||
| }, []); | ||
| return { | ||
@@ -272,0 +73,0 @@ sessionId, |
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import { cn } from "@neeter/core"; | ||
| import { useEffect, useRef, useState } from "react"; | ||
| import { cn } from "./cn.js"; | ||
| const OTHER_LABEL = "__other__"; | ||
@@ -5,0 +5,0 @@ export function UserQuestionCard({ request, onSubmit, className, }) { |
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import { registerWidget } from "../registry.js"; | ||
| import { registerWidget } from "@neeter/core"; | ||
| function parseAnswers(result) { | ||
@@ -4,0 +4,0 @@ if (typeof result !== "string") |
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import { registerWidget } from "../registry.js"; | ||
| import { registerWidget } from "@neeter/core"; | ||
| function BashInputRenderer({ input }) { | ||
@@ -4,0 +4,0 @@ const command = typeof input.command === "string" ? input.command : null; |
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import { registerWidget } from "../registry.js"; | ||
| import { registerWidget } from "@neeter/core"; | ||
| function basename(filePath) { | ||
@@ -4,0 +4,0 @@ return filePath.split("/").pop() ?? filePath; |
| import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime"; | ||
| import { registerWidget } from "../registry.js"; | ||
| import { registerWidget } from "@neeter/core"; | ||
| function GlobInputRenderer({ input }) { | ||
@@ -4,0 +4,0 @@ const pattern = typeof input.pattern === "string" ? input.pattern : null; |
| import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime"; | ||
| import { registerWidget } from "../registry.js"; | ||
| import { registerWidget } from "@neeter/core"; | ||
| function GrepInputRenderer({ input }) { | ||
@@ -4,0 +4,0 @@ const pattern = typeof input.pattern === "string" ? input.pattern : null; |
| import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime"; | ||
| import { registerWidget } from "../registry.js"; | ||
| import { registerWidget } from "@neeter/core"; | ||
| function basename(filePath) { | ||
@@ -4,0 +4,0 @@ return filePath.split("/").pop() ?? filePath; |
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import { registerWidget } from "../registry.js"; | ||
| import { registerWidget } from "@neeter/core"; | ||
| /** Show path relative to likely project root, falling back to last 3 segments. */ | ||
@@ -4,0 +4,0 @@ function shortPath(filePath) { |
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import { registerWidget } from "../registry.js"; | ||
| import { registerWidget } from "@neeter/core"; | ||
| function parseTodos(input) { | ||
@@ -4,0 +4,0 @@ if (!Array.isArray(input.todos)) |
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import { registerWidget } from "@neeter/core"; | ||
| import Markdown from "react-markdown"; | ||
| import remarkGfm from "remark-gfm"; | ||
| import { markdownComponents } from "../markdown-overrides.js"; | ||
| import { registerWidget } from "../registry.js"; | ||
| function domain(url) { | ||
@@ -7,0 +7,0 @@ try { |
| import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime"; | ||
| import { registerWidget } from "@neeter/core"; | ||
| import { useState } from "react"; | ||
| import { registerWidget } from "../registry.js"; | ||
| function extractJsonArray(str, start) { | ||
@@ -5,0 +5,0 @@ if (str[start] !== "[") |
| import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; | ||
| import { registerWidget } from "../registry.js"; | ||
| import { registerWidget } from "@neeter/core"; | ||
| function basename(filePath) { | ||
@@ -4,0 +4,0 @@ return filePath.split("/").pop() ?? filePath; |
+3
-4
| { | ||
| "name": "@neeter/react", | ||
| "version": "0.17.4", | ||
| "version": "0.18.0", | ||
| "description": "React components and hooks for building chat UIs on top of the Claude Agent SDK", | ||
@@ -27,5 +27,4 @@ "license": "MIT", | ||
| "dependencies": { | ||
| "clsx": "^2.1.1", | ||
| "tailwind-merge": "^3.5.0", | ||
| "@neeter/types": "0.17.4" | ||
| "@neeter/core": "0.18.0", | ||
| "@neeter/types": "0.18.0" | ||
| }, | ||
@@ -32,0 +31,0 @@ "peerDependencies": { |
+2
-2
| # @neeter/react | ||
| React components and hooks for building chat UIs on top of the [Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk). Connects to an `@neeter/server` backend over SSE with a Zustand store, drop-in components, and a widget system for tool call rendering. | ||
| React components and hooks for building chat UIs on top of the [Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk). Builds on `@neeter/core` for state management and agent lifecycle — with drop-in components and a widget system for tool call rendering. | ||
@@ -10,3 +10,3 @@ Part of the [neeter](https://github.com/quantumleeps/neeter) toolkit. | ||
| ```bash | ||
| pnpm add @neeter/react | ||
| pnpm add @neeter/core @neeter/react | ||
| ``` | ||
@@ -13,0 +13,0 @@ |
| import type { ChatMessage, PermissionRequest, ToolApprovalRequest, ToolCallInfo } from "@neeter/types"; | ||
| /** | ||
| * Find the pending approval that matches a specific tool call. | ||
| * Matches by toolUseId when available, falls back to toolName. | ||
| * Only considers non-terminal tool calls (not complete/error). | ||
| */ | ||
| export declare function findMatchingApproval(pendingPermissions: PermissionRequest[], toolCall: ToolCallInfo): ToolApprovalRequest | undefined; | ||
| /** | ||
| * Check if an approval request is "claimed" by any non-terminal tool call | ||
| * in the message list. Claimed approvals are rendered inline by ToolCallCard | ||
| * and should be skipped by PendingPermissions to avoid duplicate UI. | ||
| */ | ||
| export declare function isApprovalClaimedByToolCall(request: ToolApprovalRequest, messages: ChatMessage[]): boolean; |
| /** | ||
| * Find the pending approval that matches a specific tool call. | ||
| * Matches by toolUseId when available, falls back to toolName. | ||
| * Only considers non-terminal tool calls (not complete/error). | ||
| */ | ||
| export function findMatchingApproval(pendingPermissions, toolCall) { | ||
| if (toolCall.status === "complete" || toolCall.status === "error") | ||
| return undefined; | ||
| return pendingPermissions.find((p) => p.kind === "tool_approval" && | ||
| (p.toolUseId ? p.toolUseId === toolCall.id : p.toolName === toolCall.name)); | ||
| } | ||
| /** | ||
| * Check if an approval request is "claimed" by any non-terminal tool call | ||
| * in the message list. Claimed approvals are rendered inline by ToolCallCard | ||
| * and should be skipped by PendingPermissions to avoid duplicate UI. | ||
| */ | ||
| export function isApprovalClaimedByToolCall(request, messages) { | ||
| for (const msg of messages) { | ||
| if (msg.toolCalls) { | ||
| for (const tc of msg.toolCalls) { | ||
| if (tc.status === "complete" || tc.status === "error") | ||
| continue; | ||
| if (request.toolUseId ? tc.id === request.toolUseId : tc.name === request.toolName) { | ||
| return true; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return false; | ||
| } |
| import { type ClassValue } from "clsx"; | ||
| export declare function cn(...inputs: ClassValue[]): string; |
| import { clsx } from "clsx"; | ||
| import { twMerge } from "tailwind-merge"; | ||
| export function cn(...inputs) { | ||
| return twMerge(clsx(inputs)); | ||
| } |
| import type { WidgetRegistration } from "./types.js"; | ||
| /** | ||
| * Register a component for a tool name. Must run before React renders any | ||
| * tool calls — use a side-effect import at your app's entry point. | ||
| * Later registrations overwrite earlier ones for the same `toolName`. | ||
| */ | ||
| export declare function registerWidget<TResult>(reg: WidgetRegistration<TResult>): void; | ||
| export declare function getWidget(toolName: string): WidgetRegistration | undefined; | ||
| export declare function stripMcpPrefix(name: string): string; |
| const widgets = new Map(); | ||
| /** | ||
| * Register a component for a tool name. Must run before React renders any | ||
| * tool calls — use a side-effect import at your app's entry point. | ||
| * Later registrations overwrite earlier ones for the same `toolName`. | ||
| */ | ||
| export function registerWidget(reg) { | ||
| widgets.set(reg.toolName, reg); | ||
| } | ||
| export function getWidget(toolName) { | ||
| return widgets.get(toolName); | ||
| } | ||
| export function stripMcpPrefix(name) { | ||
| return name.replace(/^mcp__.+?__/, ""); | ||
| } |
| import type { ChatMessage, McpServerStatus, ModelUsage, PermissionRequest, SSEEvent, StopReason, TurnCompleteData } from "@neeter/types"; | ||
| import { type StoreApi } from "zustand/vanilla"; | ||
| interface ChatStoreState { | ||
| sessionId: string | null; | ||
| sdkSessionId: string | null; | ||
| messages: ChatMessage[]; | ||
| isStreaming: boolean; | ||
| isThinking: boolean; | ||
| streamingText: string; | ||
| streamingThinking: string; | ||
| pendingPermissions: PermissionRequest[]; | ||
| mcpServers: McpServerStatus[]; | ||
| checkpoints: string[]; | ||
| fileCheckpointing: boolean; | ||
| totalCost: number; | ||
| totalTurns: number; | ||
| totalInputTokens: number; | ||
| totalOutputTokens: number; | ||
| modelUsage: Record<string, ModelUsage> | null; | ||
| lastStopReason: StopReason; | ||
| } | ||
| interface ChatStoreActions { | ||
| setSessionId: (id: string) => void; | ||
| setSdkSessionId: (id: string) => void; | ||
| addUserMessage: (text: string) => void; | ||
| appendStreamingText: (text: string) => void; | ||
| appendStreamingThinking: (text: string) => void; | ||
| flushStreamingText: () => void; | ||
| flushStreamingThinking: () => void; | ||
| addSystemMessage: (text: string) => void; | ||
| startToolCall: (toolUseId: string, name: string) => void; | ||
| appendToolInput: (toolUseId: string, partialJson: string) => void; | ||
| finalizeToolCall: (toolUseId: string, name: string, input: Record<string, unknown>) => void; | ||
| completeToolCall: (toolUseId: string, result: string) => void; | ||
| errorToolCall: (toolUseId: string, error: string) => void; | ||
| setStreaming: (v: boolean) => void; | ||
| setThinking: (v: boolean) => void; | ||
| addPermissionRequest: (request: PermissionRequest) => void; | ||
| removePermissionRequest: (requestId: string) => void; | ||
| setMcpServers: (servers: McpServerStatus[]) => void; | ||
| addCheckpoint: (uuid: string) => void; | ||
| setFileCheckpointing: (v: boolean) => void; | ||
| addCost: (data: TurnCompleteData) => void; | ||
| setStopReason: (reason: StopReason) => void; | ||
| cancelInflightToolCalls: () => void; | ||
| reset: () => void; | ||
| } | ||
| export type ChatStoreShape = ChatStoreState & ChatStoreActions; | ||
| export type ChatStore = StoreApi<ChatStoreShape>; | ||
| /** | ||
| * Creates a vanilla Zustand store for chat state. `AgentProvider` creates one | ||
| * internally — use this directly only for custom provider implementations. | ||
| */ | ||
| export declare function createChatStore(): ChatStore; | ||
| /** | ||
| * Reconstructs chat store state from persisted SSE events. Uses the same | ||
| * store actions as the live SSE stream, so rendering is identical. | ||
| * Call before connecting the EventSource (e.g. during session resume). | ||
| */ | ||
| export declare function replayEvents(store: ChatStore, events: SSEEvent[], options?: { | ||
| stopAtCheckpoint?: string; | ||
| }): void; | ||
| export {}; |
-310
| import { immer } from "zustand/middleware/immer"; | ||
| import { createStore } from "zustand/vanilla"; | ||
| let nextId = 0; | ||
| function findToolCall(messages, toolUseId) { | ||
| for (let i = messages.length - 1; i >= 0; i--) { | ||
| const tc = messages[i].toolCalls; | ||
| if (tc?.length) { | ||
| const match = tc.find((t) => t.id === toolUseId); | ||
| if (match) | ||
| return match; | ||
| } | ||
| } | ||
| return undefined; | ||
| } | ||
| /** | ||
| * Creates a vanilla Zustand store for chat state. `AgentProvider` creates one | ||
| * internally — use this directly only for custom provider implementations. | ||
| */ | ||
| export function createChatStore() { | ||
| return createStore()(immer((set) => ({ | ||
| sessionId: null, | ||
| sdkSessionId: null, | ||
| messages: [], | ||
| isStreaming: false, | ||
| isThinking: false, | ||
| streamingText: "", | ||
| streamingThinking: "", | ||
| pendingPermissions: [], | ||
| mcpServers: [], | ||
| checkpoints: [], | ||
| fileCheckpointing: false, | ||
| totalCost: 0, | ||
| totalTurns: 0, | ||
| totalInputTokens: 0, | ||
| totalOutputTokens: 0, | ||
| modelUsage: null, | ||
| lastStopReason: null, | ||
| setSessionId: (id) => set((s) => { | ||
| s.sessionId = id; | ||
| }), | ||
| setSdkSessionId: (id) => set((s) => { | ||
| s.sdkSessionId = id; | ||
| }), | ||
| addUserMessage: (text) => set((s) => { | ||
| s.messages.push({ | ||
| id: `msg-${++nextId}`, | ||
| role: "user", | ||
| content: text, | ||
| }); | ||
| }), | ||
| addSystemMessage: (text) => set((s) => { | ||
| s.messages.push({ | ||
| id: `msg-${++nextId}`, | ||
| role: "system", | ||
| content: text, | ||
| }); | ||
| }), | ||
| appendStreamingText: (text) => set((s) => { | ||
| s.streamingText += text; | ||
| }), | ||
| appendStreamingThinking: (text) => set((s) => { | ||
| s.streamingThinking += text; | ||
| }), | ||
| flushStreamingThinking: () => set((s) => { | ||
| if (s.streamingThinking) { | ||
| const last = s.messages[s.messages.length - 1]; | ||
| if (last?.role === "assistant" && !last.toolCalls?.length) { | ||
| last.thinking = (last.thinking ?? "") + s.streamingThinking; | ||
| } | ||
| else { | ||
| s.messages.push({ | ||
| id: `msg-${++nextId}`, | ||
| role: "assistant", | ||
| content: "", | ||
| thinking: s.streamingThinking, | ||
| }); | ||
| } | ||
| s.streamingThinking = ""; | ||
| } | ||
| }), | ||
| flushStreamingText: () => set((s) => { | ||
| if (s.streamingText) { | ||
| const last = s.messages[s.messages.length - 1]; | ||
| if (last?.role === "assistant" && !last.toolCalls?.length) { | ||
| last.content += s.streamingText; | ||
| } | ||
| else { | ||
| s.messages.push({ | ||
| id: `msg-${++nextId}`, | ||
| role: "assistant", | ||
| content: s.streamingText, | ||
| }); | ||
| } | ||
| s.streamingText = ""; | ||
| } | ||
| }), | ||
| startToolCall: (toolUseId, name) => set((s) => { | ||
| s.messages.push({ | ||
| id: `msg-${++nextId}`, | ||
| role: "assistant", | ||
| content: "", | ||
| toolCalls: [{ id: toolUseId, name, input: {}, status: "pending" }], | ||
| }); | ||
| }), | ||
| appendToolInput: (toolUseId, partialJson) => set((s) => { | ||
| const tc = findToolCall(s.messages, toolUseId); | ||
| if (tc) { | ||
| tc.partialInput = (tc.partialInput ?? "") + partialJson; | ||
| tc.status = "streaming_input"; | ||
| } | ||
| }), | ||
| finalizeToolCall: (toolUseId, name, input) => set((s) => { | ||
| const tc = findToolCall(s.messages, toolUseId); | ||
| if (tc) { | ||
| tc.name = name; | ||
| tc.input = input; | ||
| tc.status = "running"; | ||
| } | ||
| }), | ||
| completeToolCall: (toolUseId, result) => set((s) => { | ||
| const tc = findToolCall(s.messages, toolUseId); | ||
| if (tc && tc.status !== "error") { | ||
| tc.result = result; | ||
| tc.status = "complete"; | ||
| } | ||
| }), | ||
| errorToolCall: (toolUseId, error) => set((s) => { | ||
| const tc = findToolCall(s.messages, toolUseId); | ||
| if (tc) { | ||
| tc.error = error; | ||
| tc.status = "error"; | ||
| } | ||
| }), | ||
| setStreaming: (v) => set((s) => { | ||
| s.isStreaming = v; | ||
| }), | ||
| setThinking: (v) => set((s) => { | ||
| s.isThinking = v; | ||
| }), | ||
| addPermissionRequest: (request) => set((s) => { | ||
| if (!s.pendingPermissions.some((p) => p.requestId === request.requestId)) { | ||
| s.pendingPermissions.push(request); | ||
| } | ||
| }), | ||
| removePermissionRequest: (requestId) => set((s) => { | ||
| s.pendingPermissions = s.pendingPermissions.filter((p) => p.requestId !== requestId); | ||
| }), | ||
| setMcpServers: (servers) => set((s) => { | ||
| s.mcpServers = servers; | ||
| }), | ||
| addCheckpoint: (uuid) => set((s) => { | ||
| s.checkpoints.push(uuid); | ||
| }), | ||
| setFileCheckpointing: (v) => set((s) => { | ||
| s.fileCheckpointing = v; | ||
| }), | ||
| addCost: (data) => set((s) => { | ||
| s.totalCost += data.cost; | ||
| s.totalTurns += data.numTurns; | ||
| if (data.usage) { | ||
| s.totalInputTokens += data.usage.inputTokens; | ||
| s.totalOutputTokens += data.usage.outputTokens; | ||
| } | ||
| if (data.modelUsage) { | ||
| if (!s.modelUsage) { | ||
| s.modelUsage = data.modelUsage; | ||
| } | ||
| else { | ||
| for (const [model, usage] of Object.entries(data.modelUsage)) { | ||
| const existing = s.modelUsage[model]; | ||
| if (existing) { | ||
| existing.inputTokens += usage.inputTokens; | ||
| existing.outputTokens += usage.outputTokens; | ||
| existing.cacheCreationInputTokens += usage.cacheCreationInputTokens; | ||
| existing.cacheReadInputTokens += usage.cacheReadInputTokens; | ||
| existing.webSearchRequests += usage.webSearchRequests; | ||
| existing.costUSD += usage.costUSD; | ||
| existing.contextWindow = usage.contextWindow; | ||
| } | ||
| else { | ||
| s.modelUsage[model] = { ...usage }; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| }), | ||
| setStopReason: (reason) => set((s) => { | ||
| s.lastStopReason = reason; | ||
| }), | ||
| cancelInflightToolCalls: () => set((s) => { | ||
| for (const msg of s.messages) { | ||
| if (msg.toolCalls) { | ||
| for (const tc of msg.toolCalls) { | ||
| if (tc.status === "pending" || | ||
| tc.status === "streaming_input" || | ||
| tc.status === "running") { | ||
| tc.status = "error"; | ||
| tc.error = "Interrupted"; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| }), | ||
| reset: () => set((s) => { | ||
| s.sessionId = null; | ||
| s.sdkSessionId = null; | ||
| s.messages = []; | ||
| s.isStreaming = false; | ||
| s.isThinking = false; | ||
| s.streamingText = ""; | ||
| s.streamingThinking = ""; | ||
| s.pendingPermissions = []; | ||
| s.mcpServers = []; | ||
| s.checkpoints = []; | ||
| s.fileCheckpointing = false; | ||
| s.totalCost = 0; | ||
| s.totalTurns = 0; | ||
| s.totalInputTokens = 0; | ||
| s.totalOutputTokens = 0; | ||
| s.modelUsage = null; | ||
| s.lastStopReason = null; | ||
| }), | ||
| }))); | ||
| } | ||
| /** | ||
| * Reconstructs chat store state from persisted SSE events. Uses the same | ||
| * store actions as the live SSE stream, so rendering is identical. | ||
| * Call before connecting the EventSource (e.g. during session resume). | ||
| */ | ||
| export function replayEvents(store, events, options) { | ||
| const s = store.getState(); | ||
| const stopAt = options?.stopAtCheckpoint; | ||
| // "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); | ||
| switch (evt.event) { | ||
| case "user_message": | ||
| s.addUserMessage(data.text); | ||
| break; | ||
| case "session_init": | ||
| s.setSdkSessionId(data.sdkSessionId); | ||
| if (data.mcpServers) | ||
| s.setMcpServers(data.mcpServers); | ||
| if (data.fileCheckpointing) | ||
| s.setFileCheckpointing(true); | ||
| break; | ||
| case "thinking_delta": | ||
| s.appendStreamingThinking(data.text); | ||
| break; | ||
| case "text_delta": | ||
| s.flushStreamingThinking(); | ||
| s.appendStreamingText(data.text); | ||
| break; | ||
| case "tool_start": | ||
| s.flushStreamingThinking(); | ||
| s.flushStreamingText(); | ||
| s.startToolCall(data.id, data.name); | ||
| break; | ||
| case "tool_call": | ||
| s.flushStreamingText(); | ||
| s.finalizeToolCall(data.id, data.name, data.input); | ||
| break; | ||
| case "tool_result": | ||
| if (data.isError) { | ||
| s.errorToolCall(data.toolUseId, data.result); | ||
| } | ||
| else { | ||
| s.completeToolCall(data.toolUseId, data.result); | ||
| } | ||
| break; | ||
| case "checkpoint": | ||
| s.addCheckpoint(data.userMessageUuid); | ||
| break; | ||
| case "turn_complete": | ||
| s.flushStreamingThinking(); | ||
| s.flushStreamingText(); | ||
| s.setStopReason(data.stopReason ?? null); | ||
| s.addCost({ | ||
| cost: data.cost ?? 0, | ||
| numTurns: data.numTurns ?? 0, | ||
| stopReason: data.stopReason ?? null, | ||
| usage: data.usage ?? null, | ||
| modelUsage: data.modelUsage ?? null, | ||
| }); | ||
| break; | ||
| case "session_error": | ||
| s.flushStreamingThinking(); | ||
| s.flushStreamingText(); | ||
| s.setStopReason(data.stopReason ?? null); | ||
| s.addSystemMessage(`Session ended: ${data.subtype}`); | ||
| break; | ||
| } | ||
| } | ||
| } |
Network access
Supply chain riskThis module accesses the network.
7
-12.5%0
-100%87399
-22.75%64
-11.11%1500
-30.65%+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
Updated