From e0ce6c8c229648ca9fbbba7b81617cf21a4e7c8b Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Tue, 26 May 2026 14:55:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(reedy):=20Appendix=20A=20=C2=B7=20Phase=20?= =?UTF-8?q?4=20=E2=80=94=20custom=20thread=20UI=20on=20AgentRuntime=20(#43?= =?UTF-8?q?08)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/readest-app/package.json | 2 + .../src/__tests__/reedy/reedyStore.test.ts | 203 ++++++++++++++ .../components/notebook/AIAssistant.tsx | 80 ++++++ .../src/components/settings/AIPanel.tsx | 24 +- apps/readest-app/src/services/ai/types.ts | 8 + .../src/services/reedy/store/reedyStore.ts | 239 +++++++++++++++++ .../src/services/reedy/ui/AgentThread.tsx | 65 +++++ .../src/services/reedy/ui/Composer.tsx | 134 ++++++++++ .../src/services/reedy/ui/IndexingStatus.tsx | 114 ++++++++ .../src/services/reedy/ui/MessageCard.tsx | 87 ++++++ .../src/services/reedy/ui/ReedyAssistant.tsx | 247 ++++++++++++++++++ .../services/reedy/ui/parts/CitationPart.tsx | 45 ++++ .../services/reedy/ui/parts/StatusParts.tsx | 27 ++ .../src/services/reedy/ui/parts/TextPart.tsx | 91 +++++++ .../services/reedy/ui/parts/ToolCallPart.tsx | 88 +++++++ .../src/services/reedy/ui/useReedyTurn.ts | 71 +++++ pnpm-lock.yaml | 31 +++ 17 files changed, 1555 insertions(+), 1 deletion(-) create mode 100644 apps/readest-app/src/__tests__/reedy/reedyStore.test.ts create mode 100644 apps/readest-app/src/services/reedy/store/reedyStore.ts create mode 100644 apps/readest-app/src/services/reedy/ui/AgentThread.tsx create mode 100644 apps/readest-app/src/services/reedy/ui/Composer.tsx create mode 100644 apps/readest-app/src/services/reedy/ui/IndexingStatus.tsx create mode 100644 apps/readest-app/src/services/reedy/ui/MessageCard.tsx create mode 100644 apps/readest-app/src/services/reedy/ui/ReedyAssistant.tsx create mode 100644 apps/readest-app/src/services/reedy/ui/parts/CitationPart.tsx create mode 100644 apps/readest-app/src/services/reedy/ui/parts/StatusParts.tsx create mode 100644 apps/readest-app/src/services/reedy/ui/parts/TextPart.tsx create mode 100644 apps/readest-app/src/services/reedy/ui/parts/ToolCallPart.tsx create mode 100644 apps/readest-app/src/services/reedy/ui/useReedyTurn.ts diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index 67710b44..575f8a28 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -182,6 +182,7 @@ "react-dom": "19.2.5", "react-i18next": "^15.2.0", "react-icons": "^5.4.0", + "react-markdown": "^10.1.0", "react-responsive": "^10.0.0", "react-virtuoso": "^4.17.0", "react-window": "^1.8.11", @@ -194,6 +195,7 @@ "tauri-plugin-device-info-api": "^1.0.1", "tinycolor2": "^1.6.0", "uuid": "^14.0.0", + "virtua": "^0.49.1", "ws": "^8.18.3", "zod": "^4.0.8", "zustand": "5.0.10" diff --git a/apps/readest-app/src/__tests__/reedy/reedyStore.test.ts b/apps/readest-app/src/__tests__/reedy/reedyStore.test.ts new file mode 100644 index 00000000..acc28e39 --- /dev/null +++ b/apps/readest-app/src/__tests__/reedy/reedyStore.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect } from 'vitest'; +import { + applyEventToMessages, + type ReedyAssistantMessage, + type ReedyMessage, +} from '@/services/reedy/store/reedyStore'; +import { makeToolError } from '@/services/reedy/runtime/errors'; +import type { ReedyEvent } from '@/services/reedy/runtime/events'; + +function baseAssistant(id: string): ReedyAssistantMessage { + return { id, role: 'assistant', parts: [], createdAt: 0 }; +} + +describe('reedyStore — applyEventToMessages reducer', () => { + it('returns the input array unchanged when no matching assistant message exists', () => { + const before: ReedyMessage[] = [{ id: 'u1', role: 'user', text: 'hi', createdAt: 0 }]; + const after = applyEventToMessages(before, 'missing', { + type: 'text_delta', + delta: 'x', + }); + expect(after).toBe(before); + }); + + it('coalesces consecutive text_delta events into one text part', () => { + let msgs: ReedyMessage[] = [baseAssistant('a1')]; + msgs = applyEventToMessages(msgs, 'a1', { type: 'text_delta', delta: 'Hello, ' }); + msgs = applyEventToMessages(msgs, 'a1', { type: 'text_delta', delta: 'world.' }); + const assistant = msgs[0] as ReedyAssistantMessage; + expect(assistant.parts).toHaveLength(1); + expect(assistant.parts[0]).toEqual({ type: 'text', text: 'Hello, world.' }); + }); + + it('skips zero-length text_delta', () => { + let msgs: ReedyMessage[] = [baseAssistant('a1')]; + msgs = applyEventToMessages(msgs, 'a1', { type: 'text_delta', delta: '' }); + const assistant = msgs[0] as ReedyAssistantMessage; + expect(assistant.parts).toEqual([]); + }); + + it('appends a tool_call part in pending state', () => { + let msgs: ReedyMessage[] = [baseAssistant('a1')]; + msgs = applyEventToMessages(msgs, 'a1', { + type: 'tool_call', + id: 'tc1', + name: 'lookupPassage', + args: { query: 'alice' }, + permission: 'read', + }); + const part = (msgs[0] as ReedyAssistantMessage).parts[0]!; + expect(part.type).toBe('tool_call'); + if (part.type === 'tool_call') { + expect(part.state).toBe('pending'); + expect(part.name).toBe('lookupPassage'); + } + }); + + it('tool_result ok transitions the matching tool_call to state=ok with result', () => { + let msgs: ReedyMessage[] = [baseAssistant('a1')]; + msgs = applyEventToMessages(msgs, 'a1', { + type: 'tool_call', + id: 'tc1', + name: 'find', + args: { q: 'x' }, + permission: 'read', + }); + msgs = applyEventToMessages(msgs, 'a1', { + type: 'tool_result', + id: 'tc1', + name: 'find', + ok: true, + result: { hit: 'x' }, + durationMs: 42, + }); + const part = (msgs[0] as ReedyAssistantMessage).parts[0]!; + if (part.type === 'tool_call') { + expect(part.state).toBe('ok'); + expect(part.result).toEqual({ hit: 'x' }); + expect(part.durationMs).toBe(42); + } + }); + + it('tool_result error transitions the matching tool_call to state=error', () => { + const err = makeToolError('tool_runtime_error', 'find', 'boom'); + let msgs: ReedyMessage[] = [baseAssistant('a1')]; + msgs = applyEventToMessages(msgs, 'a1', { + type: 'tool_call', + id: 'tc2', + name: 'find', + args: {}, + permission: 'read', + }); + msgs = applyEventToMessages(msgs, 'a1', { + type: 'tool_result', + id: 'tc2', + name: 'find', + ok: false, + error: err, + }); + const part = (msgs[0] as ReedyAssistantMessage).parts[0]!; + if (part.type === 'tool_call') { + expect(part.state).toBe('error'); + expect(part.error?.kind).toBe('tool_runtime_error'); + } + }); + + it('tool_result with no matching tool_call leaves parts untouched', () => { + let msgs: ReedyMessage[] = [baseAssistant('a1')]; + msgs = applyEventToMessages(msgs, 'a1', { + type: 'tool_result', + id: 'orphan', + name: 'find', + ok: true, + result: {}, + durationMs: 0, + }); + expect((msgs[0] as ReedyAssistantMessage).parts).toEqual([]); + }); + + it('appends citation parts in arrival order', () => { + let msgs: ReedyMessage[] = [baseAssistant('a1')]; + msgs = applyEventToMessages(msgs, 'a1', { + type: 'citation', + cfi: 'epubcfi(/6/2)', + sectionIndex: 0, + chapterTitle: 'Ch1', + snippet: 'first', + }); + msgs = applyEventToMessages(msgs, 'a1', { + type: 'citation', + cfi: 'epubcfi(/6/4)', + sectionIndex: 1, + snippet: 'second', + }); + const parts = (msgs[0] as ReedyAssistantMessage).parts; + expect(parts).toHaveLength(2); + expect(parts.every((p) => p.type === 'citation')).toBe(true); + }); + + it('error event appends an error part without dropping prior content', () => { + let msgs: ReedyMessage[] = [baseAssistant('a1')]; + msgs = applyEventToMessages(msgs, 'a1', { type: 'text_delta', delta: 'partial' }); + msgs = applyEventToMessages(msgs, 'a1', { + type: 'error', + kind: 'model_error', + message: 'upstream 500', + retryable: true, + }); + const parts = (msgs[0] as ReedyAssistantMessage).parts; + expect(parts).toHaveLength(2); + expect(parts[0]!.type).toBe('text'); + expect(parts[1]!.type).toBe('error'); + }); + + it('abort event appends an abort part with the partial flag', () => { + let msgs: ReedyMessage[] = [baseAssistant('a1')]; + msgs = applyEventToMessages(msgs, 'a1', { type: 'abort', partial: true }); + const part = (msgs[0] as ReedyAssistantMessage).parts[0]!; + expect(part).toEqual({ type: 'abort', partial: true }); + }); + + it('done event captures finishReason + usage on the assistant message', () => { + let msgs: ReedyMessage[] = [baseAssistant('a1')]; + const ev: ReedyEvent = { + type: 'done', + output: { + sessionId: 's1', + assistantMessageId: 'a1', + finishReason: 'stop', + usage: { promptTokens: 100, completionTokens: 50 }, + }, + }; + msgs = applyEventToMessages(msgs, 'a1', ev); + const assistant = msgs[0] as ReedyAssistantMessage; + expect(assistant.finishReason).toBe('stop'); + expect(assistant.usage).toEqual({ promptTokens: 100, completionTokens: 50 }); + }); + + it('non-structural events (turn_start, step_finish, usage, memory_write) leave parts unchanged', () => { + let msgs: ReedyMessage[] = [baseAssistant('a1')]; + msgs = applyEventToMessages(msgs, 'a1', { + type: 'turn_start', + sessionId: 's1', + assistantMessageId: 'a1', + }); + msgs = applyEventToMessages(msgs, 'a1', { + type: 'step_finish', + step: 0, + reason: 'stop', + }); + msgs = applyEventToMessages(msgs, 'a1', { + type: 'usage', + promptTokens: 10, + completionTokens: 5, + }); + msgs = applyEventToMessages(msgs, 'a1', { + type: 'memory_write', + scope: 'book', + key: 'k', + summary: 's', + }); + expect((msgs[0] as ReedyAssistantMessage).parts).toEqual([]); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/notebook/AIAssistant.tsx b/apps/readest-app/src/app/reader/components/notebook/AIAssistant.tsx index ab32da77..f03f2e54 100644 --- a/apps/readest-app/src/app/reader/components/notebook/AIAssistant.tsx +++ b/apps/readest-app/src/app/reader/components/notebook/AIAssistant.tsx @@ -28,6 +28,8 @@ import type { RetrievedChunk } from '@/services/reedy/retrieval/BookRetriever'; import { useEnv } from '@/context/EnvContext'; import { isTauriAppPlatform } from '@/services/environment'; import type { AppService } from '@/types/system'; +import { ReedyAssistant } from '@/services/reedy/ui/ReedyAssistant'; +import type { ReadingContextSnapshot } from '@/services/reedy/tools/builtins/types'; import { Button } from '@/components/ui/button'; import { Loader2Icon, BookOpenIcon } from 'lucide-react'; @@ -276,7 +278,34 @@ const ThreadWrapper = ({ ); }; +/** + * Phase 4.3 router. Switches between the legacy / Reedy-MVP path + * (LegacyAIAssistant) and the Phase 4 agent-runtime path + * (ReedyAgentAssistantBridge) based on aiSettings.reedy.runtime. + * + * The split is at component boundary rather than inside one component + * so hooks always run in stable order on whichever path is rendered. + */ const AIAssistant = ({ bookKey }: AIAssistantProps) => { + const { appService } = useEnv(); + const { settings } = useSettingsStore(); + const { getBookData } = useBookDataStore(); + const bookData = getBookData(bookKey); + + const reedyRuntime = settings?.aiSettings?.reedy?.runtime ?? 'mvp'; + const useAgentRuntime = + settings?.aiSettings?.enabled === true && + settings?.aiSettings?.reedy?.enabled === true && + reedyRuntime === 'agent' && + !!appService && + isTauriAppPlatform() && + !!bookData?.bookDoc; + + if (useAgentRuntime) return ; + return ; +}; + +const LegacyAIAssistant = ({ bookKey }: AIAssistantProps) => { const _ = useTranslation(); const { appService } = useEnv(); const { settings } = useSettingsStore(); @@ -436,4 +465,55 @@ const AIAssistant = ({ bookKey }: AIAssistantProps) => { ); }; +/** + * Bridge from the notebook AI tab into the Phase 4 ReedyAssistant. + * + * Kept separate from AIAssistant so legacy props/state don't leak in + * and we don't pay the cost of constructing the agent runtime when the + * user is on the MVP path. The flag check in AIAssistant guarantees this + * only renders when aiSettings.reedy.runtime === 'agent'. + */ +const ReedyAgentAssistantBridge = ({ bookKey }: AIAssistantProps) => { + const { appService } = useEnv(); + const { settings } = useSettingsStore(); + const { getBookData } = useBookDataStore(); + const { getProgress, getView } = useReaderStore(); + const bookData = getBookData(bookKey); + const progress = getProgress(bookKey); + + const bookHash = bookKey.split('-')[0] || ''; + const aiSettings = settings?.aiSettings; + + const readingContext = useMemo( + () => ({ + cfi: progress?.location ?? null, + sectionIndex: progress?.section?.current ?? 0, + chapterTitle: progress?.sectionLabel ?? null, + pageNumber: progress?.pageinfo?.current ?? 0, + }), + [progress], + ); + + const handleNavigate = useCallback( + (cfi: string) => { + getView(bookKey)?.goTo(cfi); + }, + [bookKey, getView], + ); + + if (!aiSettings || !appService || !bookData?.bookDoc) return null; + + return ( + + ); +}; + export default AIAssistant; diff --git a/apps/readest-app/src/components/settings/AIPanel.tsx b/apps/readest-app/src/components/settings/AIPanel.tsx index d878c4f6..66fa7abe 100644 --- a/apps/readest-app/src/components/settings/AIPanel.tsx +++ b/apps/readest-app/src/components/settings/AIPanel.tsx @@ -76,6 +76,9 @@ const AIPanel: React.FC = () => { const [enabled, setEnabled] = useState(aiSettings.enabled); const [reedyEnabled, setReedyEnabled] = useState(aiSettings.reedy?.enabled ?? false); + const [reedyAgentRuntime, setReedyAgentRuntime] = useState( + (aiSettings.reedy?.runtime ?? 'mvp') === 'agent', + ); const [provider, setProvider] = useState(aiSettings.provider); const [ollamaUrl, setOllamaUrl] = useState(aiSettings.ollamaBaseUrl); const [ollamaModel, setOllamaModel] = useState(aiSettings.ollamaModel); @@ -760,7 +763,26 @@ const AIPanel: React.FC = () => { onChange={() => { const next = !reedyEnabled; setReedyEnabled(next); - saveAiSetting('reedy', { enabled: next }); + saveAiSetting('reedy', { + enabled: next, + runtime: reedyAgentRuntime ? 'agent' : 'mvp', + }); + }} + /> + { + const next = !reedyAgentRuntime; + setReedyAgentRuntime(next); + saveAiSetting('reedy', { + enabled: reedyEnabled, + runtime: next ? 'agent' : 'mvp', + }); }} />
diff --git a/apps/readest-app/src/services/ai/types.ts b/apps/readest-app/src/services/ai/types.ts index 85398b31..5e5c8880 100644 --- a/apps/readest-app/src/services/ai/types.ts +++ b/apps/readest-app/src/services/ai/types.ts @@ -45,6 +45,14 @@ export interface AISettings { */ reedy?: { enabled: boolean; + /** + * 'mvp' (default) keeps the Phase 1B path: lookupPassage tool wired + * through @assistant-ui/react's adapter. 'agent' switches the + * notebook AI tab to the Phase 4 ReedyAssistant (custom AgentRuntime + * + thread UI). Requires `reedy.enabled && isTauri() && + * runtime === 'agent'` to engage. + */ + runtime?: 'mvp' | 'agent'; }; } diff --git a/apps/readest-app/src/services/reedy/store/reedyStore.ts b/apps/readest-app/src/services/reedy/store/reedyStore.ts new file mode 100644 index 00000000..e7362777 --- /dev/null +++ b/apps/readest-app/src/services/reedy/store/reedyStore.ts @@ -0,0 +1,239 @@ +import { create } from 'zustand'; +import type { ReedyEvent, ReedyTurnOutput } from '../runtime/events'; +import type { ReedyToolError } from '../runtime/errors'; + +/** + * Reedy chat store (Phase 4.1). + * + * Holds the per-session message log the agent UI renders. Distinct from + * the MVP `useAIChatStore` so the two paths never share state — switching + * `aiSettings.reedy.runtime` mid-session shouldn't corrupt either log. + * + * Messages are stored as ordered arrays of structured parts produced by + * the AgentRuntime's ReedyEvent stream. The store has no opinions about + * persistence — sessions today live in-memory only; Phase 6+ wires up + * disk storage if the agent path graduates beyond the measurement plan. + */ + +export type ReedyMessagePart = + | { type: 'text'; text: string } + | { + type: 'tool_call'; + id: string; + name: string; + args: unknown; + permission: 'read' | 'navigate' | 'write'; + state: 'pending' | 'ok' | 'error'; + result?: unknown; + error?: ReedyToolError; + durationMs?: number; + } + | { + type: 'citation'; + cfi: string; + sectionIndex: number; + chapterTitle?: string; + snippet: string; + } + | { type: 'error'; message: string; kind: string } + | { type: 'abort'; partial: boolean }; + +export interface ReedyUserMessage { + id: string; + role: 'user'; + text: string; + createdAt: number; +} + +export interface ReedyAssistantMessage { + id: string; + role: 'assistant'; + parts: ReedyMessagePart[]; + createdAt: number; + /** Set once the runtime emits `done`. */ + finishReason?: ReedyTurnOutput['finishReason']; + usage?: ReedyTurnOutput['usage']; +} + +export type ReedyMessage = ReedyUserMessage | ReedyAssistantMessage; + +export interface ReedyStoreState { + messages: ReedyMessage[]; + /** True while a turn is being processed by the runtime. */ + isRunning: boolean; + /** The active turn's assistantMessageId so we know which message to mutate on each event. */ + activeAssistantMessageId: string | null; + /** AbortController for the active turn; the Composer's Stop button calls .abort(). */ + abortController: AbortController | null; + + // Mutations + startUserTurn: (text: string) => void; + startAssistantTurn: (assistantMessageId: string, controller: AbortController) => void; + applyEvent: (event: ReedyEvent) => void; + finishTurn: () => void; + reset: () => void; +} + +export const useReedyStore = create((set, get) => ({ + messages: [], + isRunning: false, + activeAssistantMessageId: null, + abortController: null, + + startUserTurn(text) { + const msg: ReedyUserMessage = { + id: randomId('user'), + role: 'user', + text, + createdAt: Date.now(), + }; + set((s) => ({ messages: [...s.messages, msg], isRunning: true })); + }, + + startAssistantTurn(assistantMessageId, controller) { + const msg: ReedyAssistantMessage = { + id: assistantMessageId, + role: 'assistant', + parts: [], + createdAt: Date.now(), + }; + set((s) => ({ + messages: [...s.messages, msg], + activeAssistantMessageId: assistantMessageId, + abortController: controller, + })); + }, + + applyEvent(event) { + const id = get().activeAssistantMessageId; + if (!id) return; + set((s) => ({ messages: applyEventToMessages(s.messages, id, event) })); + }, + + finishTurn() { + set({ isRunning: false, activeAssistantMessageId: null, abortController: null }); + }, + + reset() { + set({ + messages: [], + isRunning: false, + activeAssistantMessageId: null, + abortController: null, + }); + }, +})); + +// --------------------------------------------------------------------------- +// pure helpers — exported so tests can target the reducer without React. +// --------------------------------------------------------------------------- + +export function applyEventToMessages( + messages: ReedyMessage[], + assistantMessageId: string, + event: ReedyEvent, +): ReedyMessage[] { + const idx = messages.findIndex((m) => m.id === assistantMessageId && m.role === 'assistant'); + if (idx === -1) return messages; + const current = messages[idx] as ReedyAssistantMessage; + const next = applyEventToAssistant(current, event); + if (next === current) return messages; + const out = messages.slice(); + out[idx] = next; + return out; +} + +function applyEventToAssistant( + msg: ReedyAssistantMessage, + event: ReedyEvent, +): ReedyAssistantMessage { + switch (event.type) { + case 'text_delta': + return appendOrExtendText(msg, event.delta); + case 'tool_call': + return { + ...msg, + parts: [ + ...msg.parts, + { + type: 'tool_call', + id: event.id, + name: event.name, + args: event.args, + permission: event.permission, + state: 'pending', + }, + ], + }; + case 'tool_result': + return { + ...msg, + parts: msg.parts.map((p) => { + if (p.type !== 'tool_call' || p.id !== event.id) return p; + if (event.ok) { + return { + ...p, + state: 'ok', + result: event.result, + durationMs: event.durationMs, + }; + } + return { ...p, state: 'error', error: event.error }; + }), + }; + case 'citation': + return { + ...msg, + parts: [ + ...msg.parts, + { + type: 'citation', + cfi: event.cfi, + sectionIndex: event.sectionIndex, + chapterTitle: event.chapterTitle, + snippet: event.snippet, + }, + ], + }; + case 'error': + return { + ...msg, + parts: [...msg.parts, { type: 'error', message: event.message, kind: event.kind }], + }; + case 'abort': + return { + ...msg, + parts: [...msg.parts, { type: 'abort', partial: event.partial }], + }; + case 'done': + return { ...msg, finishReason: event.output.finishReason, usage: event.output.usage }; + case 'turn_start': + case 'step_finish': + case 'usage': + case 'memory_write': + // No structural-part mutation today. Phase 4 follow-up may want a + // separate ReasoningPart or MemoryNotePart; the store would gain + // a new branch then. + return msg; + default: + return msg; + } +} + +function appendOrExtendText(msg: ReedyAssistantMessage, delta: string): ReedyAssistantMessage { + if (delta.length === 0) return msg; + const last = msg.parts.at(-1); + // Coalesce consecutive text deltas into one text part so the UI + // re-renders the same React node instead of inflating the parts array. + if (last && last.type === 'text') { + const updated = { ...last, text: last.text + delta }; + return { ...msg, parts: [...msg.parts.slice(0, -1), updated] }; + } + return { ...msg, parts: [...msg.parts, { type: 'text', text: delta }] }; +} + +function randomId(prefix: string): string { + if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) + return `${prefix}-${crypto.randomUUID()}`; + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} diff --git a/apps/readest-app/src/services/reedy/ui/AgentThread.tsx b/apps/readest-app/src/services/reedy/ui/AgentThread.tsx new file mode 100644 index 00000000..32a10a66 --- /dev/null +++ b/apps/readest-app/src/services/reedy/ui/AgentThread.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { VList, type VListHandle } from 'virtua'; +import type { ReedyMessage } from '../store/reedyStore'; +import { MessageCard } from './MessageCard'; + +/** + * Virtua-virtualized thread scroller (Phase 4.2.b). + * + * Owns auto-scroll engagement: pinned to the bottom on initial mount + * and after every store update while the user hasn't actively scrolled + * away. Pointer/wheel/touch interactions disengage; scrolling back to + * the bottom (or sending a new message) re-engages. + */ +export function AgentThread({ + messages, + isRunning, + onSourceClick, + emptyState, +}: { + messages: ReedyMessage[]; + isRunning: boolean; + onSourceClick?: (cfi: string) => void; + emptyState?: React.ReactNode; +}) { + const ref = useRef(null); + const [autoScroll, setAutoScroll] = useState(true); + + // Pin to the bottom whenever the message count grows (new turn, new + // assistant message, new structural part) AND we're in autoScroll mode. + useEffect(() => { + if (!autoScroll || messages.length === 0) return; + const lastIndex = messages.length - 1; + requestAnimationFrame(() => { + ref.current?.scrollToIndex(lastIndex, { align: 'end' }); + }); + }, [messages.length, autoScroll, messages]); + + // Disengage auto-scroll when the user actively scrolls; re-engage when + // they reach the bottom again. + const handleScroll = (offset: number): void => { + const handle = ref.current; + if (!handle) return; + const total = handle.scrollSize; + const view = handle.viewportSize; + const atBottom = total - (offset + view) < 24; + setAutoScroll(atBottom); + }; + + if (messages.length === 0 && emptyState) { + return
{emptyState}
; + } + + return ( + + {messages.map((m) => ( + + ))} + {isRunning && messages.length > 0 && ( +
Reedy is thinking…
+ )} +
+ ); +} diff --git a/apps/readest-app/src/services/reedy/ui/Composer.tsx b/apps/readest-app/src/services/reedy/ui/Composer.tsx new file mode 100644 index 00000000..5fe15bb6 --- /dev/null +++ b/apps/readest-app/src/services/reedy/ui/Composer.tsx @@ -0,0 +1,134 @@ +'use client'; + +import { useCallback, useState, type KeyboardEvent } from 'react'; +import { Send, Square, WandSparkles } from 'lucide-react'; + +/** + * Minimal Skill shape the composer's chip row renders. The full Skill + * type (Phase 5 — separate PR) is a strict superset; the composer only + * touches these fields, so we declare locally to avoid a cross-branch + * dependency. + */ +export interface ComposerSkill { + id: string; + name: string; + description: string; +} +type Skill = ComposerSkill; + +/** + * Multi-line input + send/abort button + skill chip row (Phase 4.2.h). + * + * Keyboard: + * - Cmd/Ctrl + Enter → send + * - Esc → abort if a turn is running, otherwise blur + * - Enter alone → newline (per the plan's UX) + */ +export function Composer({ + isRunning, + onSend, + onAbort, + disabled, + skills, + activeSkillId, + onSkillSelect, +}: { + isRunning: boolean; + onSend: (text: string) => void; + onAbort: () => void; + disabled?: boolean; + skills?: Skill[]; + activeSkillId?: string | null; + onSkillSelect?: (id: string | null) => void; +}) { + const [text, setText] = useState(''); + + const send = useCallback(() => { + const trimmed = text.trim(); + if (trimmed.length === 0) return; + onSend(trimmed); + setText(''); + }, [text, onSend]); + + const handleKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + if (!disabled && !isRunning) send(); + } else if (e.key === 'Escape') { + if (isRunning) { + e.preventDefault(); + onAbort(); + } + } + }; + + return ( +
+ {skills && skills.length > 0 && ( +
+ Skill + + {skills.map((s) => ( + + ))} +
+ )} + +
+