@neeter/react
Advanced tools
@@ -10,2 +10,3 @@ import type { CustomEvent } from "@neeter/types"; | ||
| endpoint?: string; | ||
| resumeSessionId?: string; | ||
| onCustomEvent?: (event: CustomEvent) => void; | ||
@@ -12,0 +13,0 @@ children: ReactNode; |
@@ -15,4 +15,5 @@ import { jsx as _jsx } from "react/jsx-runtime"; | ||
| endpoint: props.endpoint, | ||
| resumeSessionId: props.resumeSessionId, | ||
| onCustomEvent: props.onCustomEvent, | ||
| }), [props.endpoint, props.onCustomEvent]); | ||
| }), [props.endpoint, props.resumeSessionId, props.onCustomEvent]); | ||
| const agent = useAgent(store, agentConfig); | ||
@@ -19,0 +20,0 @@ const value = useMemo(() => ({ ...agent, store }), [agent, store]); |
+2
-2
@@ -12,3 +12,3 @@ import "./widgets/AskUserQuestionWidget.js"; | ||
| import "./widgets/WriteWidget.js"; | ||
| export type { ChatMessage, CustomEvent, PermissionRequest, PermissionResponse, SSEEvent, ToolApprovalRequest, ToolApprovalResponse, ToolCallInfo, ToolCallPhase, UserQuestion, UserQuestionOption, UserQuestionRequest, UserQuestionResponse, } from "@neeter/types"; | ||
| export type { ChatMessage, CustomEvent, PermissionRequest, PermissionResponse, SessionHistoryEntry, SessionInitEvent, SSEEvent, ToolApprovalRequest, ToolApprovalResponse, ToolCallInfo, ToolCallPhase, UserQuestion, UserQuestionOption, UserQuestionRequest, UserQuestionResponse, } from "@neeter/types"; | ||
| export { AgentProvider, useAgentContext, useChatStore } from "./AgentProvider.js"; | ||
@@ -22,3 +22,3 @@ export { ChatInput } from "./ChatInput.js"; | ||
| export { StatusDot } from "./StatusDot.js"; | ||
| export { type ChatStore, type ChatStoreShape, createChatStore } from "./store.js"; | ||
| export { type ChatStore, type ChatStoreShape, createChatStore, replayEvents } from "./store.js"; | ||
| export { TextMessage } from "./TextMessage.js"; | ||
@@ -25,0 +25,0 @@ export { ThinkingBlock } from "./ThinkingBlock.js"; |
+1
-1
@@ -21,3 +21,3 @@ // Built-in widgets (side-effect: auto-registers) | ||
| export { StatusDot } from "./StatusDot.js"; | ||
| export { createChatStore } from "./store.js"; | ||
| export { createChatStore, replayEvents } from "./store.js"; | ||
| export { TextMessage } from "./TextMessage.js"; | ||
@@ -24,0 +24,0 @@ export { ThinkingBlock } from "./ThinkingBlock.js"; |
+4
-1
@@ -1,5 +0,6 @@ | ||
| import type { ChatMessage, PermissionRequest } from "@neeter/types"; | ||
| import type { ChatMessage, PermissionRequest, SSEEvent } from "@neeter/types"; | ||
| import { type StoreApi } from "zustand/vanilla"; | ||
| interface ChatStoreState { | ||
| sessionId: string | null; | ||
| sdkSessionId: string | null; | ||
| messages: ChatMessage[]; | ||
@@ -16,2 +17,3 @@ isStreaming: boolean; | ||
| setSessionId: (id: string) => void; | ||
| setSdkSessionId: (id: string) => void; | ||
| addUserMessage: (text: string) => void; | ||
@@ -39,2 +41,3 @@ appendStreamingText: (text: string) => void; | ||
| export declare function createChatStore(): ChatStore; | ||
| export declare function replayEvents(store: ChatStore, events: SSEEvent[]): void; | ||
| export {}; |
+48
-0
@@ -18,2 +18,3 @@ import { immer } from "zustand/middleware/immer"; | ||
| sessionId: null, | ||
| sdkSessionId: null, | ||
| messages: [], | ||
@@ -30,2 +31,5 @@ isStreaming: false, | ||
| }), | ||
| setSdkSessionId: (id) => set((s) => { | ||
| s.sdkSessionId = id; | ||
| }), | ||
| addUserMessage: (text) => set((s) => { | ||
@@ -155,2 +159,3 @@ s.messages.push({ | ||
| s.sessionId = null; | ||
| s.sdkSessionId = null; | ||
| s.messages = []; | ||
@@ -167,1 +172,44 @@ s.isStreaming = false; | ||
| } | ||
| export function replayEvents(store, events) { | ||
| const s = store.getState(); | ||
| for (const evt of events) { | ||
| const data = JSON.parse(evt.data); | ||
| switch (evt.event) { | ||
| case "user_message": | ||
| s.addUserMessage(data.text); | ||
| break; | ||
| case "session_init": | ||
| s.setSdkSessionId(data.sdkSessionId); | ||
| 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": | ||
| s.completeToolCall(data.toolUseId, data.result); | ||
| break; | ||
| case "turn_complete": | ||
| s.flushStreamingThinking(); | ||
| s.flushStreamingText(); | ||
| s.addCost(data.cost ?? 0, data.numTurns ?? 0); | ||
| break; | ||
| case "session_error": | ||
| s.flushStreamingThinking(); | ||
| s.flushStreamingText(); | ||
| s.addSystemMessage(`Session ended: ${data.subtype}`); | ||
| break; | ||
| } | ||
| } | ||
| } |
+11
-2
@@ -1,5 +0,6 @@ | ||
| import type { CustomEvent, PermissionResponse } from "@neeter/types"; | ||
| import type { ChatStore } from "./store.js"; | ||
| import type { CustomEvent, PermissionResponse, SessionHistoryEntry } from "@neeter/types"; | ||
| import { type ChatStore } from "./store.js"; | ||
| export interface UseAgentConfig { | ||
| endpoint?: string; | ||
| resumeSessionId?: string; | ||
| onCustomEvent?: (event: CustomEvent) => void; | ||
@@ -9,6 +10,14 @@ } | ||
| sessionId: string | null; | ||
| sdkSessionId: string | null; | ||
| sessionHistory: SessionHistoryEntry[]; | ||
| sendMessage: (text: string) => Promise<void>; | ||
| stopSession: () => Promise<void>; | ||
| respondToPermission: (response: PermissionResponse) => Promise<void>; | ||
| resumeSession: (options?: { | ||
| fork?: boolean; | ||
| sdkSessionId?: string; | ||
| }) => Promise<void>; | ||
| newSession: () => Promise<void>; | ||
| refreshHistory: () => Promise<void>; | ||
| } | ||
| export declare function useAgent(store: ChatStore, config?: UseAgentConfig): UseAgentReturn; |
+92
-4
@@ -1,4 +0,6 @@ | ||
| import { useCallback, useEffect, useRef, useSyncExternalStore } from "react"; | ||
| import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; | ||
| import { replayEvents } from "./store.js"; | ||
| export function useAgent(store, config) { | ||
| const endpoint = config?.endpoint ?? "/api"; | ||
| const resumeSessionId = config?.resumeSessionId; | ||
| const onCustomEvent = config?.onCustomEvent; | ||
@@ -8,6 +10,28 @@ const eventSourceRef = useRef(null); | ||
| const sessionId = useSyncExternalStore(store.subscribe, () => store.getState().sessionId); | ||
| const sdkSessionId = useSyncExternalStore(store.subscribe, () => store.getState().sdkSessionId); | ||
| const [sessionHistory, setSessionHistory] = useState([]); | ||
| useEffect(() => { | ||
| let cancelled = false; | ||
| async function init() { | ||
| const res = await fetch(`${endpoint}/sessions`, { method: "POST" }); | ||
| 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 }), | ||
| }); | ||
| } | ||
| else { | ||
| res = await fetch(`${endpoint}/sessions`, { method: "POST" }); | ||
| } | ||
| const data = await res.json(); | ||
@@ -21,3 +45,3 @@ if (!cancelled) | ||
| }; | ||
| }, [endpoint, store]); | ||
| }, [endpoint, resumeSessionId, store]); | ||
| useEffect(() => { | ||
@@ -29,2 +53,6 @@ if (!sessionId) | ||
| abortedRef.current = false; | ||
| es.addEventListener("session_init", (e) => { | ||
| const { sdkSessionId: id } = JSON.parse(e.data); | ||
| store.getState().setSdkSessionId(id); | ||
| }); | ||
| es.addEventListener("message_start", () => { | ||
@@ -156,3 +184,63 @@ store.getState().setThinking(true); | ||
| }, [sessionId, endpoint, store]); | ||
| return { sessionId, sendMessage, stopSession, respondToPermission }; | ||
| 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); | ||
| } | ||
| } | ||
| 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, | ||
| }), | ||
| }); | ||
| const data = await res.json(); | ||
| store.getState().setSessionId(data.sessionId); | ||
| }, [endpoint, store]); | ||
| const refreshHistory = useCallback(async () => { | ||
| try { | ||
| const res = await fetch(`${endpoint}/sessions/history`); | ||
| if (res.ok) | ||
| setSessionHistory(await res.json()); | ||
| } | ||
| catch { | ||
| /* ignore */ | ||
| } | ||
| }, [endpoint]); | ||
| 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]); | ||
| return { | ||
| sessionId, | ||
| sdkSessionId, | ||
| sessionHistory, | ||
| sendMessage, | ||
| stopSession, | ||
| respondToPermission, | ||
| resumeSession, | ||
| newSession, | ||
| refreshHistory, | ||
| }; | ||
| } |
+8
-4
| { | ||
| "name": "@neeter/react", | ||
| "version": "0.9.0", | ||
| "version": "0.10.0", | ||
| "description": "React components and hooks for building chat UIs on top of the Claude Agent SDK", | ||
@@ -29,14 +29,18 @@ "license": "MIT", | ||
| "tailwind-merge": "^3.4.0", | ||
| "@neeter/types": "0.9.0" | ||
| "@neeter/types": "0.10.0" | ||
| }, | ||
| "peerDependencies": { | ||
| "immer": ">=10.0.0", | ||
| "react": ">=18.0.0", | ||
| "react-markdown": ">=10.0.0", | ||
| "zustand": ">=5.0.0", | ||
| "immer": ">=10.0.0" | ||
| "zustand": ">=5.0.0" | ||
| }, | ||
| "devDependencies": { | ||
| "@testing-library/react": "^16.3.2", | ||
| "@types/react": "^19.2.7", | ||
| "@types/react-dom": "^19.2.3", | ||
| "immer": "^11.1.4", | ||
| "jsdom": "^28.1.0", | ||
| "react": "^19.2.0", | ||
| "react-dom": "^19.2.4", | ||
| "zustand": "^5.0.11" | ||
@@ -43,0 +47,0 @@ }, |
+1
-0
@@ -74,2 +74,3 @@ # @neeter/react | ||
| - **Extended thinking** — Collapsible thinking blocks with streaming text. | ||
| - **Session resume** — Resume past sessions with `resumeSession()`, start fresh with `newSession()`, and browse history with `refreshHistory()`. `replayEvents` reconstructs the chat UI from persisted events. | ||
| - **Custom events** — Handle app-specific events from `onToolResult` via `AgentProvider`'s `onCustomEvent` prop. | ||
@@ -76,0 +77,0 @@ - **Abort** — Stop the agent mid-turn with `stopSession()` from `useAgentContext()`. |
99845
6.34%1891
8.68%94
1.08%8
100%10
150%+ Added
- Removed
Updated