}) {
+ return (
+
+
+
+ {part.partial ? 'Aborted (partial reply)' : 'Aborted'}
+
+
+ );
+}
diff --git a/apps/readest-app/src/services/reedy/ui/parts/TextPart.tsx b/apps/readest-app/src/services/reedy/ui/parts/TextPart.tsx
new file mode 100644
index 00000000..a172baae
--- /dev/null
+++ b/apps/readest-app/src/services/reedy/ui/parts/TextPart.tsx
@@ -0,0 +1,91 @@
+'use client';
+
+import { memo } from 'react';
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+
+/**
+ * User-message text rendering. Bare react-markdown with GFM but no
+ * remark-math / rehype-raw / rehype-katex / harden-react-markdown —
+ * the user message is whatever they typed; we don't expect math or HTML
+ * from them. Inline-styled overrides match the legacy MarkdownText
+ * compact look.
+ *
+ * Memoized on `text` so repeated re-renders of the parent thread don't
+ * re-tokenize unchanged user messages.
+ */
+export const UserTextPart = memo(function UserTextPart({ text }: { text: string }) {
+ return (
+
+
{children} ,
+ a: ({ href, children }) => (
+
+ {children}
+
+ ),
+ code: ({ children }) => (
+
+ {children}
+
+ ),
+ }}
+ >
+ {text}
+
+
+ );
+});
+
+/**
+ * Assistant-message text rendering — same react-markdown stack as
+ * UserTextPart, slightly richer block styling (paragraphs render as
+ * `` instead of inline spans). The original plan called for
+ * Streamdown's streaming-aware fade-in spans, but Streamdown statically
+ * depends on Shiki whose TextMate grammars contain lookbehind regex
+ * `(?<=...)` / `(?
+ (
+
+ {children}
+
+ ),
+ code: ({ children, className }) => {
+ // Inline code: no className. Fenced blocks: get a language-x class.
+ if (!className) {
+ return (
+
+ {children}
+
+ );
+ }
+ return {children};
+ },
+ pre: ({ children }) => (
+
+ {children}
+
+ ),
+ }}
+ >
+ {text}
+
+
+ );
+});
diff --git a/apps/readest-app/src/services/reedy/ui/parts/ToolCallPart.tsx b/apps/readest-app/src/services/reedy/ui/parts/ToolCallPart.tsx
new file mode 100644
index 00000000..f2677290
--- /dev/null
+++ b/apps/readest-app/src/services/reedy/ui/parts/ToolCallPart.tsx
@@ -0,0 +1,88 @@
+'use client';
+
+import { useState } from 'react';
+import {
+ Wrench,
+ ChevronDown,
+ ChevronRight,
+ CheckCircle2,
+ AlertCircle,
+ Loader2,
+} from 'lucide-react';
+import type { ReedyMessagePart } from '../../store/reedyStore';
+
+/**
+ * Renders a tool_call message part as a collapsed pill. Click to expand
+ * the args + result. Pending tools show a spinner; finished tools show
+ * a check (ok) or warning (error) and the duration if available.
+ *
+ * The plan calls for an inline approval UI when permission != 'read';
+ * the runtime's ToolRegistry already enforces approvals via its
+ * requestPermission callback, so that prompt fires at registry-invoke
+ * time. The pill stays informational.
+ */
+export function ToolCallPart({ part }: { part: Extract }) {
+ const [expanded, setExpanded] = useState(false);
+ const statusIcon =
+ part.state === 'pending' ? (
+
+ ) : part.state === 'error' ? (
+
+ ) : (
+
+ );
+
+ return (
+
+
setExpanded((e) => !e)}
+ className='flex w-full items-center gap-1.5 text-start'
+ >
+ {expanded ? : }
+
+ {part.name}
+ {statusIcon}
+ {part.durationMs !== undefined && (
+ {part.durationMs}ms
+ )}
+
+ {expanded && (
+
+
+
args
+
+ {safeStringify(part.args)}
+
+
+ {part.state === 'ok' && part.result !== undefined && (
+
+
result
+
+ {safeStringify(part.result)}
+
+
+ )}
+ {part.state === 'error' && part.error && (
+
+
+ error · {part.error.kind}
+
+
+ {part.error.message}
+
+
+ )}
+
+ )}
+
+ );
+}
+
+function safeStringify(v: unknown): string {
+ try {
+ return JSON.stringify(v, null, 2);
+ } catch {
+ return String(v);
+ }
+}
diff --git a/apps/readest-app/src/services/reedy/ui/useReedyTurn.ts b/apps/readest-app/src/services/reedy/ui/useReedyTurn.ts
new file mode 100644
index 00000000..e0531f90
--- /dev/null
+++ b/apps/readest-app/src/services/reedy/ui/useReedyTurn.ts
@@ -0,0 +1,71 @@
+'use client';
+
+import { useCallback, useRef } from 'react';
+import type { AgentRuntime, RunTurnInput } from '../runtime/AgentRuntime';
+import type { ReedyEvent } from '../runtime/events';
+import { useReedyStore } from '../store/reedyStore';
+
+/**
+ * Drives one assistant turn through the AgentRuntime, dispatching every
+ * ReedyEvent the runtime yields into the Reedy store (Phase 4.1).
+ *
+ * Why a hook and not a method on the store: the runtime is constructed
+ * by the notebook with per-book deps (model, tools, layers). The store
+ * stays runtime-agnostic; the hook is the glue.
+ */
+export function useReedyTurn(runtime: AgentRuntime | null) {
+ const cancelRef = useRef(null);
+
+ const send = useCallback(
+ async (args: { sessionId: string; bookHash: string; userMessage: string }): Promise => {
+ if (!runtime) return;
+ // Cancel any in-flight turn before starting a new one so we never
+ // have two AgentRuntime streams mutating the same active message.
+ cancelRef.current?.abort();
+ const controller = new AbortController();
+ cancelRef.current = controller;
+
+ const store = useReedyStore.getState();
+ store.startUserTurn(args.userMessage);
+
+ const turnInput: RunTurnInput = {
+ sessionId: args.sessionId,
+ bookHash: args.bookHash,
+ userMessage: args.userMessage,
+ signal: controller.signal,
+ };
+
+ // The first event the runtime yields is `turn_start` carrying the
+ // assistantMessageId. Wait for it before pushing the assistant
+ // message into the store so the store has a stable id to mutate.
+ let assistantStarted = false;
+ try {
+ for await (const event of runtime.runTurn(turnInput) as AsyncIterable) {
+ if (!assistantStarted && event.type === 'turn_start') {
+ store.startAssistantTurn(event.assistantMessageId, controller);
+ assistantStarted = true;
+ continue;
+ }
+ if (!assistantStarted) {
+ // Defensive: the runtime contract guarantees turn_start first,
+ // but if something upstream changes don't drop subsequent events.
+ store.startAssistantTurn('msg-fallback', controller);
+ assistantStarted = true;
+ }
+ store.applyEvent(event);
+ if (event.type === 'done') break;
+ }
+ } finally {
+ store.finishTurn();
+ if (cancelRef.current === controller) cancelRef.current = null;
+ }
+ },
+ [runtime],
+ );
+
+ const abort = useCallback(() => {
+ cancelRef.current?.abort();
+ }, []);
+
+ return { send, abort };
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bb37fdf2..74c68cf5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -351,6 +351,9 @@ importers:
react-icons:
specifier: ^5.4.0
version: 5.6.0(react@19.2.5)
+ react-markdown:
+ specifier: ^10.1.0
+ version: 10.1.0(@types/react@19.2.14)(react@19.2.5)
react-responsive:
specifier: ^10.0.0
version: 10.0.1(react@19.2.5)
@@ -387,6 +390,9 @@ importers:
uuid:
specifier: '>=14.0.0'
version: 14.0.0
+ virtua:
+ specifier: ^0.49.1
+ version: 0.49.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
ws:
specifier: 8.20.1
version: 8.20.1
@@ -8489,6 +8495,26 @@ packages:
resolution: {integrity: sha512-0QwqXteBNXgnLCdWdvPQBX6FXRHtIH3VhJPTd5Lwn28tJXc34YqSCWUmkOvtJHBmB3gGoPtrOKk3Ts8/kEZ9aA==}
engines: {node: '>=10.13.0'}
+ virtua@0.49.1:
+ resolution: {integrity: sha512-6f79msqg3jzNFdqJiS0FSzhRN1EHlDhR7EvW7emp6z5qQ22VdsReiDHflkpMEMhoAyUuYr69nwT0aagiM7NrUg==}
+ peerDependencies:
+ react: '>=16.14.0'
+ react-dom: '>=16.14.0'
+ solid-js: '>=1.0'
+ svelte: '>=5.0'
+ vue: '>=3.2'
+ peerDependenciesMeta:
+ react:
+ optional: true
+ react-dom:
+ optional: true
+ solid-js:
+ optional: true
+ svelte:
+ optional: true
+ vue:
+ optional: true
+
vite-plugin-commonjs@0.10.4:
resolution: {integrity: sha512-eWQuvQKCcx0QYB5e5xfxBNjQKyrjEWZIR9UOkOV6JAgxVhtbZvCOF+FNC2ZijBJ3U3Px04ZMMyyMyFBVWIJ5+g==}
@@ -17801,6 +17827,11 @@ snapshots:
- bare-abort-controller
- react-native-b4a
+ virtua@0.49.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
+ optionalDependencies:
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+
vite-plugin-commonjs@0.10.4:
dependencies:
acorn: 8.16.0