From e4ae6a5f3100fcbd96282ab2975a14610e0e7a37 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Wed, 29 Apr 2026 16:32:36 +0200 Subject: [PATCH 01/48] Remove gating of Debug button --- .../editor/HeaderAppearanceControls.test.tsx | 31 +++++++++++++++++++ .../editor/HeaderAppearanceControls.tsx | 22 ++++++------- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/frontend/features/editor/HeaderAppearanceControls.test.tsx b/src/frontend/features/editor/HeaderAppearanceControls.test.tsx index 4631426e..1cadcdee 100644 --- a/src/frontend/features/editor/HeaderAppearanceControls.test.tsx +++ b/src/frontend/features/editor/HeaderAppearanceControls.test.tsx @@ -113,4 +113,35 @@ describe('HeaderAppearanceControls', () => { expect(sidebarSlider.getAttribute('max')).toBe('600'); }); + + it('renders the debug logs button in the header appearance controls', () => { + const localRef = React.createRef(); + + render( + {}} + isLight={true} + textMain="text-brand-gray-900" + buttonActive="bg-blue-500 text-white" + currentTheme="light" + setAppTheme={() => {}} + editorSettings={{ + brightness: 1, + contrast: 1, + fontSize: 16, + maxWidth: 80, + sidebarWidth: 320, + theme: 'light', + showDiff: true, + }} + setEditorSettings={() => {}} + sliderClass="" + setIsDebugLogsOpen={() => {}} + /> + ); + + expect(screen.getByRole('button', { name: /Debug Logs/i })).toBeTruthy(); + }); }); diff --git a/src/frontend/features/editor/HeaderAppearanceControls.tsx b/src/frontend/features/editor/HeaderAppearanceControls.tsx index b1ba9c1a..3928e482 100644 --- a/src/frontend/features/editor/HeaderAppearanceControls.tsx +++ b/src/frontend/features/editor/HeaderAppearanceControls.tsx @@ -310,18 +310,16 @@ export const HeaderAppearanceControls: React.FC = )} - {import.meta.env.DEV && ( - - )} + ); }; From 5ae75da7e2065bb7308a18241ddf8bec18b75b60 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Wed, 29 Apr 2026 17:16:16 +0200 Subject: [PATCH 02/48] UI improvement: all disabling of Provider API key --- .../features/settings/providerAdapter.test.ts | 17 ++++++++ .../features/settings/providerAdapter.ts | 2 +- .../settings/settings/ProviderConfigForm.tsx | 36 +++++++++++++++-- .../settings/useProviderHealth.test.ts | 40 +++++++++++++++++-- .../features/settings/useProviderHealth.ts | 28 +++++++++---- .../useSettingsDialogProviderValidation.ts | 14 ++++--- src/frontend/types/ui.ts | 2 + 7 files changed, 119 insertions(+), 20 deletions(-) diff --git a/src/frontend/features/settings/providerAdapter.test.ts b/src/frontend/features/settings/providerAdapter.test.ts index f124f057..284553b6 100644 --- a/src/frontend/features/settings/providerAdapter.test.ts +++ b/src/frontend/features/settings/providerAdapter.test.ts @@ -86,4 +86,21 @@ describe('provider mapping roundtrip', () => { expect(back.is_multimodal).toBeUndefined(); expect(back.supports_function_calling).toBeUndefined(); }); + + it('omits api_key when apiKeyEnabled is disabled', () => { + const provider = { + ...DEFAULT_LLM_CONFIG, + id: 'disabled-key', + name: 'Disabled Key', + baseUrl: 'https://api.example.com/v1', + apiKey: 'k', + apiKeyEnabled: false, + timeout: 10000, + modelId: 'gpt-test', + prompts: DEFAULT_LLM_CONFIG.prompts, + }; + + const back = providerToMachineModel(provider); + expect(back.api_key).toBeUndefined(); + }); }); diff --git a/src/frontend/features/settings/providerAdapter.ts b/src/frontend/features/settings/providerAdapter.ts index 02d5d778..15070ea9 100644 --- a/src/frontend/features/settings/providerAdapter.ts +++ b/src/frontend/features/settings/providerAdapter.ts @@ -127,7 +127,7 @@ export const machineModelToProvider = ( export const providerToMachineModel = (provider: LLMConfig): MachineModelConfig => ({ name: (provider.name || '').trim(), base_url: (provider.baseUrl || '').trim(), - api_key: provider.apiKey || '', + api_key: provider.apiKeyEnabled ? provider.apiKey || undefined : undefined, timeout_s: Math.max(1, Math.round((provider.timeout || 10000) / 1000)), model: (provider.modelId || '').trim(), context_window_tokens: provider.contextWindowTokens, diff --git a/src/frontend/features/settings/settings/ProviderConfigForm.tsx b/src/frontend/features/settings/settings/ProviderConfigForm.tsx index 8ebe87ba..f7908e5f 100644 --- a/src/frontend/features/settings/settings/ProviderConfigForm.tsx +++ b/src/frontend/features/settings/settings/ProviderConfigForm.tsx @@ -21,6 +21,7 @@ import { Terminal, Key, ChevronDown, + Check, } from 'lucide-react'; import { AppTheme, LLMConfig } from '../../../types'; import { ModelPresetEntry } from '../../../services/apiTypes'; @@ -478,9 +479,35 @@ export const ProviderConfigForm: React.FC = ({
- +
+ +
+ + Enable API Key + + +
+
= ({ onUpdateProvider(activeProvider.id, { apiKey: e.target.value }) } placeholder="sk... (visible)" - className={`w-full border rounded p-2 text-sm focus:border-brand-500 focus:outline-none ${ + disabled={!activeProvider.apiKeyEnabled} + className={`w-full border rounded p-2 text-sm focus:border-brand-500 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed ${ isLight ? 'bg-brand-gray-50 border-brand-gray-300 text-brand-gray-800' : 'bg-brand-gray-950 border-brand-gray-700 text-brand-gray-300' diff --git a/src/frontend/features/settings/useProviderHealth.test.ts b/src/frontend/features/settings/useProviderHealth.test.ts index 2c907507..bac1d56f 100644 --- a/src/frontend/features/settings/useProviderHealth.test.ts +++ b/src/frontend/features/settings/useProviderHealth.test.ts @@ -46,6 +46,14 @@ const exampleProviders: AppSettings['providers'] = [ modelId: 'foo', timeout: 10000, }, + { + id: 'e', + baseUrl: 'https://api.example.com/v1', + apiKey: 'key1', + apiKeyEnabled: false, + modelId: 'foo', + timeout: 10000, + }, ]; describe('makeProviderKey', () => { @@ -62,11 +70,11 @@ describe('makeProviderKey', () => { describe('groupProviders', () => { it('groups active providers by identical model keys', () => { - const active = new Set(['a', 'b', 'c', 'd']); + const active = new Set(['a', 'b', 'c', 'd', 'e']); const groups = groupProviders(exampleProviders, active); - // there should be three distinct keys: (foo,key1,example), (bar,key1,example), (foo,key2,other) - expect(Object.keys(groups).length).toBe(3); + // there should be four distinct keys: (foo,key1,example), (bar,key1,example), (foo,key2,other), (foo,no-key,example) + expect(Object.keys(groups).length).toBe(4); // providers a and b share the same key const fooKey = makeProviderKey('https://api.example.com/v1', 'key1', 'foo'); @@ -77,6 +85,32 @@ describe('groupProviders', () => { const otherKey = makeProviderKey('https://other.invalid', 'key2', 'foo'); expect(groups[otherKey].ids).toEqual(['d']); + + const noKeyKey = makeProviderKey( + 'https://api.example.com/v1', + undefined, + 'foo', + true + ); + expect(groups[noKeyKey].ids).toEqual(['e']); + }); + + it('treats no-api-key providers as distinct from regular api key providers', () => { + const active = new Set(['a', 'e']); + const groups = groupProviders(exampleProviders, active); + + expect(Object.keys(groups).length).toBe(2); + const regularKey = makeProviderKey('https://api.example.com/v1', 'key1', 'foo'); + const disabledKey = makeProviderKey( + 'https://api.example.com/v1', + undefined, + 'foo', + false + ); + + expect(groups[regularKey].ids).toEqual(['a']); + expect(groups[disabledKey].ids).toEqual(['e']); + expect(groups[disabledKey].payload.api_key).toBeUndefined(); }); it('ignores providers that are not active or that lack a modelId', () => { diff --git a/src/frontend/features/settings/useProviderHealth.ts b/src/frontend/features/settings/useProviderHealth.ts index 20822d05..9c0e1a07 100644 --- a/src/frontend/features/settings/useProviderHealth.ts +++ b/src/frontend/features/settings/useProviderHealth.ts @@ -23,12 +23,14 @@ import { api } from '../../services/api'; export function makeProviderKey( baseUrl: string, apiKey?: string, - modelId?: string + modelId?: string, + apiKeyEnabled?: boolean ): string { const b = (baseUrl || '').trim(); - const k = (apiKey || '').trim(); + const k = apiKeyEnabled ? (apiKey || '').trim() : ''; const m = (modelId || '').trim(); - return `${b}||${k}||${m}`; + const enabled = apiKeyEnabled ? 'enabled' : 'disabled'; + return `${b}||${k}||${m}||${enabled}`; } /** @@ -70,13 +72,19 @@ export function groupProviders( if (!activeIds.has(provider.id)) return; const modelId = (provider.modelId || '').trim(); if (!modelId) return; - const key = makeProviderKey(provider.baseUrl || '', provider.apiKey, modelId); + const apiKey = provider.apiKeyEnabled ? provider.apiKey : undefined; + const key = makeProviderKey( + provider.baseUrl || '', + apiKey, + modelId, + provider.apiKeyEnabled + ); if (!groups[key]) { groups[key] = { ids: [], payload: { base_url: provider.baseUrl, - api_key: provider.apiKey, + api_key: apiKey, timeout_s: Math.round((provider.timeout || 10000) / 1000), model_id: modelId, }, @@ -169,11 +177,17 @@ export function useProviderHealth(appSettings: AppSettings): { appSettings.activeEditingProviderId, ]); const groupedProviders = groupProviders(appSettings.providers, activeIds); - const key = makeProviderKey(provider.baseUrl || '', provider.apiKey, modelId); + const apiKey = provider.apiKeyEnabled ? provider.apiKey : undefined; + const key = makeProviderKey( + provider.baseUrl || '', + apiKey, + modelId, + provider.apiKeyEnabled + ); const relatedProviderIds = groupedProviders[key]?.ids || [provider.id]; const payload = groupedProviders[key]?.payload || { base_url: provider.baseUrl, - api_key: provider.apiKey, + api_key: apiKey, timeout_s: Math.round((provider.timeout || 10000) / 1000), model_id: modelId, }; diff --git a/src/frontend/features/settings/useSettingsDialogProviderValidation.ts b/src/frontend/features/settings/useSettingsDialogProviderValidation.ts index 0206cba1..d4ea39cd 100644 --- a/src/frontend/features/settings/useSettingsDialogProviderValidation.ts +++ b/src/frontend/features/settings/useSettingsDialogProviderValidation.ts @@ -22,9 +22,9 @@ interface UseSettingsDialogProviderValidationParams { const toConnectionTestKey = (provider: LLMConfig): string => { const baseUrl = (provider.baseUrl || '').trim(); - const apiKey = (provider.apiKey || '').trim(); + const apiKey = provider.apiKeyEnabled ? (provider.apiKey || '').trim() : ''; const timeoutS = Math.max(1, Math.round((provider.timeout || 10000) / 1000)); - return `${baseUrl}|${apiKey}|${timeoutS}`; + return `${baseUrl}|${apiKey}|${timeoutS}|${provider.apiKeyEnabled ? 'enabled' : 'disabled'}`; }; /** Custom React hook that manages settings dialog provider validation. */ @@ -68,11 +68,13 @@ export function useSettingsDialogProviderValidation({ providers.forEach((provider: LLMConfig) => { const providerId = provider.id; const baseUrl = (provider.baseUrl || '').trim(); - const apiKey = (provider.apiKey || '').trim(); + const apiKey = provider.apiKeyEnabled + ? (provider.apiKey || '').trim() + : undefined; const timeoutS = Math.max(1, Math.round((provider.timeout || 10000) / 1000)); const testKey = toConnectionTestKey(provider); - if (!baseUrl || !apiKey) { + if (!baseUrl || (provider.apiKeyEnabled && !apiKey)) { setConnectionStatus((state: Record) => ({ ...state, [providerId]: 'idle', @@ -173,7 +175,9 @@ export function useSettingsDialogProviderValidation({ } const baseUrl = (provider.baseUrl || '').trim(); - const apiKey = (provider.apiKey || '').trim(); + const apiKey = provider.apiKeyEnabled + ? (provider.apiKey || '').trim() + : undefined; const timeoutS = Math.max(1, Math.round((provider.timeout || 10000) / 1000)); const run = async () => { diff --git a/src/frontend/types/ui.ts b/src/frontend/types/ui.ts index 5a8562b4..6d041b29 100644 --- a/src/frontend/types/ui.ts +++ b/src/frontend/types/ui.ts @@ -40,6 +40,7 @@ export interface LLMConfig { name: string; baseUrl: string; apiKey: string; + apiKeyEnabled?: boolean; timeout: number; modelId: string; contextWindowTokens?: number; @@ -74,6 +75,7 @@ export const DEFAULT_LLM_CONFIG: LLMConfig = { name: 'OpenAI (Default)', baseUrl: 'https://api.openai.com/v1', apiKey: '', + apiKeyEnabled: false, timeout: 30000, modelId: 'gpt-4o', temperature: 0.7, From df6c239f7cd665dce40b248169ea810bb26e56e8 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Wed, 29 Apr 2026 17:51:04 +0200 Subject: [PATCH 03/48] Remove modification tags when changing chat or using undo --- .../chat/useChatSessionManagement.test.ts | 57 +++++++++++++++++-- .../features/chat/useChatSessionManagement.ts | 6 +- src/frontend/features/story/useStory.test.ts | 32 +++++++++++ src/frontend/features/story/useStory.ts | 2 + 4 files changed, 91 insertions(+), 6 deletions(-) diff --git a/src/frontend/features/chat/useChatSessionManagement.test.ts b/src/frontend/features/chat/useChatSessionManagement.test.ts index ec0f1473..a7868229 100644 --- a/src/frontend/features/chat/useChatSessionManagement.test.ts +++ b/src/frontend/features/chat/useChatSessionManagement.test.ts @@ -56,7 +56,7 @@ describe('useChatSessionManagement', () => { }); it('creates an incognito session with expected defaults', async () => { - const getSystemPrompt = () => 'System Prompt'; + const getSystemPrompt = (): string => 'System Prompt'; const { result } = renderHook(() => useChatSessionManagement({ @@ -80,7 +80,7 @@ describe('useChatSessionManagement', () => { }); it('loads a persisted chat and applies prompt/search settings', async () => { - const getSystemPrompt = () => 'System Prompt'; + const getSystemPrompt = (): string => 'System Prompt'; vi.mocked(api.chat.load).mockResolvedValue({ id: 'chat-1', name: 'Saved Chat', @@ -116,7 +116,7 @@ describe('useChatSessionManagement', () => { }); it('restores incognito scratchpad when selecting an incognito chat', async () => { - const getSystemPrompt = () => 'System Prompt'; + const getSystemPrompt = (): string => 'System Prompt'; useChatStore.setState({ incognitoSessions: [ @@ -149,11 +149,58 @@ describe('useChatSessionManagement', () => { expect(useChatStore.getState().scratchpad).toBe('Incognito memory'); }); + it('clears session mutation tags when a new chat is started', () => { + const getSystemPrompt = (): string => 'System Prompt'; + useChatStore.setState({ + sessionMutations: [{ type: 'chapter', label: 'Updated chapter', targetId: '1' }], + }); + + const { result } = renderHook(() => + useChatSessionManagement({ + storyId: '', + getSystemPrompt, + }) + ); + + act(() => { + result.current.handleNewChat(false); + }); + + expect(useChatStore.getState().sessionMutations).toEqual([]); + }); + + it('clears session mutation tags when selecting a persisted chat', async () => { + const getSystemPrompt = (): string => 'System Prompt'; + useChatStore.setState({ + sessionMutations: [{ type: 'chapter', label: 'Updated chapter', targetId: '1' }], + }); + vi.mocked(api.chat.load).mockResolvedValue({ + id: 'chat-1', + name: 'Saved Chat', + messages: [], + systemPrompt: 'Saved prompt', + allowWebSearch: false, + scratchpad: '', + } as ChatSession); + + const { result } = renderHook(() => + useChatSessionManagement({ + storyId: '', + getSystemPrompt, + }) + ); + + await act(async () => { + await result.current.handleSelectChat('chat-1'); + }); + + expect(useChatStore.getState().sessionMutations).toEqual([]); + }); + it('auto-saves non-incognito scratchpad updates even without messages', async () => { vi.useFakeTimers(); try { - const getSystemPrompt = () => 'System Prompt'; - + const getSystemPrompt = (): string => 'System Prompt'; const { result } = renderHook(() => useChatSessionManagement({ storyId: '', diff --git a/src/frontend/features/chat/useChatSessionManagement.ts b/src/frontend/features/chat/useChatSessionManagement.ts index 65e0a71f..5a4779ce 100644 --- a/src/frontend/features/chat/useChatSessionManagement.ts +++ b/src/frontend/features/chat/useChatSessionManagement.ts @@ -28,6 +28,7 @@ type UseChatSessionManagementParams = { }; /** Custom React hook that manages chat session management. */ +// eslint-disable-next-line max-lines-per-function export function useChatSessionManagement({ storyId, getSystemPrompt, @@ -52,6 +53,7 @@ export function useChatSessionManagement({ setSystemPrompt, setScratchpad, setIncognitoSessions, + setSessionMutations, // Setters are stable — read via getState() to avoid subscribing to every token. } = useChatStore.getState(); @@ -97,6 +99,7 @@ export function useChatSessionManagement({ setScratchpad(''); } setSystemPrompt(getSystemPrompt()); + setSessionMutations([]); }, [ getSystemPrompt, @@ -140,6 +143,7 @@ export function useChatSessionManagement({ } setAllowWebSearch(chat.allowWebSearch || false); }); + setSessionMutations([]); } } catch (error) { console.error('Failed to load chat', error); @@ -225,7 +229,7 @@ export function useChatSessionManagement({ useEffect(() => { const { currentChatId, isIncognito } = useChatStore.getState(); if (storyId && !currentChatId && !isIncognito) { - const loadInitialChats = async () => { + const loadInitialChats = async (): Promise => { try { const chats = await api.chat.list(); startTransition(() => setChatHistoryList(chats)); diff --git a/src/frontend/features/story/useStory.test.ts b/src/frontend/features/story/useStory.test.ts index ae9a1e85..7c1d85b0 100644 --- a/src/frontend/features/story/useStory.test.ts +++ b/src/frontend/features/story/useStory.test.ts @@ -15,6 +15,7 @@ import { act, renderHook } from '@testing-library/react'; import { StoryState } from '../../types'; import { api } from '../../services/api'; import { resetStoryStore, useStoryStore } from '../../stores/storyStore'; +import { useChatStore } from '../../stores/chatStore'; import { buildInitialStoryState, resolveExternalHistorySourceState, @@ -131,6 +132,9 @@ const hookWithStory = async ( // Reset Zustand store between tests to prevent state leaking across test cases. beforeEach(() => { resetStoryStore(); + useChatStore.setState({ + sessionMutations: [], + }); }); describe('resolveExternalHistorySourceState', () => { @@ -162,6 +166,34 @@ describe('resolveExternalHistorySourceState', () => { }); }); +it('clears chat session mutation tags when undo is used', async () => { + vi.mocked(api.projects.list).mockResolvedValue({ + available: [], + current: null, + } as Awaited>); + vi.mocked(api.projects.select).mockResolvedValue({ ok: false } as Awaited< + ReturnType + >); + + const hook = await hookWithStory('initial', [buildChapter('1', 'Hello')]); + useChatStore.setState({ + sessionMutations: [{ type: 'chapter', label: 'Updated chapter', targetId: '1' }], + }); + + await act(async () => { + hook.result.current.pushExternalHistoryEntry({ + label: 'Manual history entry', + forceNewHistory: true, + }); + }); + + await act(async () => { + await hook.result.current.undo(); + }); + + expect(useChatStore.getState().sessionMutations).toEqual([]); +}); + // eslint-disable-next-line max-lines-per-function describe('buildInitialStoryState', () => { it('hydrates story-level notes fields from selected project payload', () => { diff --git a/src/frontend/features/story/useStory.ts b/src/frontend/features/story/useStory.ts index 55328810..e07a5860 100644 --- a/src/frontend/features/story/useStory.ts +++ b/src/frontend/features/story/useStory.ts @@ -36,6 +36,7 @@ import { } from './historyUtils'; import type { StoryHistoryEntry } from './historyUtils'; import { useStoryStore, StoryStoreState } from '../../stores/storyStore'; +import { useChatStore } from '../../stores/chatStore'; /** Maximum number of undo/redo states retained in memory. */ const MAX_HISTORY = 50; @@ -850,6 +851,7 @@ export const useStory = (dialogs: StoryDialogs = defaultDialogs) => { } const prevState = history[targetIndex].state; + useChatStore.getState().setSessionMutations([]); // Mark the re-render as a transition so React can time-slice it, // keeping the main thread responsive (avoids click-handler violations). From cbb2774996de9383196cb6dac14e62a0f14e6488 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Wed, 29 Apr 2026 18:06:00 +0200 Subject: [PATCH 04/48] Optimize startup experience Co-authored-by: Copilot --- src/frontend/stores/uiStore.test.ts | 70 +++++++++++++++++++++++++++++ src/frontend/stores/uiStore.ts | 4 +- 2 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 src/frontend/stores/uiStore.test.ts diff --git a/src/frontend/stores/uiStore.test.ts b/src/frontend/stores/uiStore.test.ts new file mode 100644 index 00000000..bfca8b56 --- /dev/null +++ b/src/frontend/stores/uiStore.test.ts @@ -0,0 +1,70 @@ +// Copyright (C) 2026 StableLlama +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +/** + * Purpose: Verify that first-run UI defaults are visible and guided. + */ + +// @vitest-environment jsdom + +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest'; + +import { useChapterSuggestions } from '../features/chapters/useChapterSuggestions'; +import { DEFAULT_LLM_CONFIG } from '../types'; + +let storage: Record; + +beforeEach(async () => { + storage = {}; + vi.stubGlobal('localStorage', { + getItem: (key: string) => (key in storage ? storage[key] : null), + setItem: (key: string, value: string) => { + storage[key] = value; + }, + removeItem: (key: string) => { + delete storage[key]; + }, + clear: () => { + storage = {}; + }, + }); + vi.resetModules(); +}); + +describe('uiStore', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('opens the AI chat panel by default on first launch', async () => { + const { useUIStore, resetUIStore } = await import('./uiStore'); + + resetUIStore(); + expect(useUIStore.getState().isChatOpen).toBe(true); + }); +}); + +describe('useChapterSuggestions', () => { + it('defaults suggest next paragraph mode to guided for first-run users', () => { + const { result } = renderHook(() => + useChapterSuggestions({ + currentUnit: undefined, + storyTitle: 'Story title', + storySummary: 'Summary', + storyStyleTags: [], + activeWritingConfig: DEFAULT_LLM_CONFIG, + isWritingAvailable: true, + updateChapter: vi.fn().mockResolvedValue(undefined), + viewMode: 'raw', + getErrorMessage: (error: unknown) => String(error), + }) + ); + + expect(result.current.suggestionMode).toBe('guided'); + }); +}); diff --git a/src/frontend/stores/uiStore.ts b/src/frontend/stores/uiStore.ts index e4196132..250706e6 100644 --- a/src/frontend/stores/uiStore.ts +++ b/src/frontend/stores/uiStore.ts @@ -102,7 +102,7 @@ export const useUIStore = create()( _get: StoreApi['getState'] ) => ({ // ── Panel state (persisted) ────────────────────────────────────────── - isChatOpen: false, + isChatOpen: true, isSidebarOpen: false, isAppearanceOpen: false, isSettingsOpen: false, @@ -217,7 +217,7 @@ export function useSourcebookDialog(): SourcebookDialogState { /** Reset the UI store to its initial state. Use in beforeEach in unit tests. */ export function resetUIStore(): void { useUIStore.setState({ - isChatOpen: false, + isChatOpen: true, isSidebarOpen: false, isAppearanceOpen: false, isSettingsOpen: false, From 872458564fbf86bed19e7668c512c4c53d0eff6f Mon Sep 17 00:00:00 2001 From: StableLlama Date: Wed, 29 Apr 2026 18:37:34 +0200 Subject: [PATCH 05/48] fix new line handling --- .../features/editor/CodeMirrorEditor.test.tsx | 16 +- .../features/editor/CodeMirrorEditor.tsx | 3 +- src/frontend/features/editor/Editor.tsx | 2 +- .../features/editor/codeMirrorKeymap.test.ts | 241 ++++++++++++++++++ .../features/editor/codeMirrorKeymap.ts | 24 +- 5 files changed, 263 insertions(+), 23 deletions(-) create mode 100644 src/frontend/features/editor/codeMirrorKeymap.test.ts diff --git a/src/frontend/features/editor/CodeMirrorEditor.test.tsx b/src/frontend/features/editor/CodeMirrorEditor.test.tsx index 59a9179d..b3244143 100644 --- a/src/frontend/features/editor/CodeMirrorEditor.test.tsx +++ b/src/frontend/features/editor/CodeMirrorEditor.test.tsx @@ -503,16 +503,16 @@ describe('CodeMirrorEditor', () => { }); }); - describe('softbreak: Backspace removes line-break " \\n" entirely', () => { + describe('softbreak: Backspace removes line-break " \\n" and joins with single space', () => { // "a \nb" — lb=1; cursor must be > lb to trigger (pos 2..4) it('cursor at lb+1', async () => { - expect(await softbreakKey('a \nb', 2, 'Backspace')).toBe('ab'); + expect(await softbreakKey('a \nb', 2, 'Backspace')).toBe('a b'); }); it('cursor at lb+2', async () => { - expect(await softbreakKey('a \nb', 3, 'Backspace')).toBe('ab'); + expect(await softbreakKey('a \nb', 3, 'Backspace')).toBe('a b'); }); it('cursor at lb+3 (just after \\n)', async () => { - expect(await softbreakKey('a \nb', 4, 'Backspace')).toBe('ab'); + expect(await softbreakKey('a \nb', 4, 'Backspace')).toBe('a b'); }); it('cursor at lb+0 does NOT intercept (let default Backspace run)', async () => { // Cursor before the sequence: default Backspace deletes 'a' @@ -611,16 +611,16 @@ describe('CodeMirrorEditor', () => { }); }); - describe('softbreak: Delete removes line-break " \\n" entirely', () => { + describe('softbreak: Delete removes line-break " \\n" and joins with single space', () => { // "a \nb" — lb=1; cursor must be <= lb+2 to trigger (pos 1..3) it('cursor at lb+0 (before first space)', async () => { - expect(await softbreakKey('a \nb', 1, 'Delete')).toBe('ab'); + expect(await softbreakKey('a \nb', 1, 'Delete')).toBe('a b'); }); it('cursor at lb+1 (between spaces)', async () => { - expect(await softbreakKey('a \nb', 2, 'Delete')).toBe('ab'); + expect(await softbreakKey('a \nb', 2, 'Delete')).toBe('a b'); }); it('cursor at lb+2 (before \\n)', async () => { - expect(await softbreakKey('a \nb', 3, 'Delete')).toBe('ab'); + expect(await softbreakKey('a \nb', 3, 'Delete')).toBe('a b'); }); it('cursor at lb+3 does NOT intercept (let default Delete run)', async () => { // Cursor after the sequence: default Delete removes 'b' diff --git a/src/frontend/features/editor/CodeMirrorEditor.tsx b/src/frontend/features/editor/CodeMirrorEditor.tsx index 3ddef843..fbc1ef13 100644 --- a/src/frontend/features/editor/CodeMirrorEditor.tsx +++ b/src/frontend/features/editor/CodeMirrorEditor.tsx @@ -351,8 +351,7 @@ export const CodeMirrorEditor = React.forwardRef< : (viewModeProp ?? (legacyMode === 'markdown' ? 'markdown' : 'raw')); // Derive enter behavior from viewMode unless explicitly overridden - const enterBehavior = - enterBehaviorProp ?? (viewMode === 'raw' ? 'newline' : 'softbreak'); + const enterBehavior = enterBehaviorProp ?? 'softbreak'; // Derive CodeMirror language mode from viewMode const mode: 'plain' | 'markdown' = viewMode === 'raw' ? 'plain' : 'markdown'; diff --git a/src/frontend/features/editor/Editor.tsx b/src/frontend/features/editor/Editor.tsx index 8c379420..302120a6 100644 --- a/src/frontend/features/editor/Editor.tsx +++ b/src/frontend/features/editor/Editor.tsx @@ -799,7 +799,7 @@ export const Editor = React.memo( streamingMode={proseStreamingActive} baselineValue={localBaseline} searchHighlightRanges={chapterSearchHighlightRanges} - enterBehavior={viewMode === 'raw' ? 'newline' : 'softbreak'} + enterBehavior="softbreak" selectionBg={selectionBg} placeholder={ chapter.scope === 'story' diff --git a/src/frontend/features/editor/codeMirrorKeymap.test.ts b/src/frontend/features/editor/codeMirrorKeymap.test.ts new file mode 100644 index 00000000..f493549b --- /dev/null +++ b/src/frontend/features/editor/codeMirrorKeymap.test.ts @@ -0,0 +1,241 @@ +// Copyright (C) 2026 StableLlama +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +/** + * Purpose: Unit tests for the buildEnterExtension keymap covering the + * softbreak Enter/Backspace/Delete behaviour: + * - Enter inserts " \n" (markdown soft-break / line-break) + * - Second Enter anywhere in " \n" zone upgrades it to "\n\n" (paragraph) + * - Enter inside "\n\n" zone inserts a plain "\n" + * - Backspace inside " \n" zone removes the whole sequence and joins with + * a single space + * - Backspace at "\n\n" (pb+1 or pb+2) downgrades paragraph to " \n" + * - Delete inside " \n" zone (lb+0, lb+1, lb+2) removes it and joins with + * a single space + * - Delete at "\n\n" (pb+0 or pb+1) downgrades paragraph to " \n" + */ + +// @vitest-environment jsdom + +import { EditorState } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { buildEnterExtension } from './codeMirrorKeymap'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeView(content: string, cursor: number): EditorView { + const parent = document.createElement('div'); + document.body.appendChild(parent); + const state = EditorState.create({ + doc: content, + selection: { anchor: cursor }, + extensions: [buildEnterExtension('softbreak')], + }); + return new EditorView({ state, parent }); +} + +function pressKey(view: EditorView, key: string): void { + view.contentDOM.dispatchEvent( + new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }) + ); +} + +function docAndCursor(view: EditorView): { doc: string; cursor: number } { + return { + doc: view.state.doc.toString(), + cursor: view.state.selection.main.from, + }; +} + +const views: EditorView[] = []; +function tracked(content: string, cursor: number): EditorView { + const v = makeView(content, cursor); + views.push(v); + return v; +} + +afterEach(() => { + for (const v of views.splice(0)) v.destroy(); +}); + +// ─── Enter: insert " \n" ───────────────────────────────────────────────────── + +describe('Enter — inserts soft-break " \\n"', () => { + it('inserts " \\n" at end of line', () => { + const view = tracked('hello', 5); + pressKey(view, 'Enter'); + expect(docAndCursor(view)).toEqual({ doc: 'hello \n', cursor: 8 }); + }); + + it('inserts " \\n" mid-line', () => { + const view = tracked('hello world', 5); + pressKey(view, 'Enter'); + // existing space after cursor is stripped so it becomes " \n" + expect(docAndCursor(view)).toEqual({ doc: 'hello \nworld', cursor: 8 }); + }); + + it('strips a trailing space before cursor when inserting', () => { + // e.g. the user typed a trailing space: "hello " — cursor at 6 + const view = tracked('hello world', 6); + pressKey(view, 'Enter'); + // ch(5)=' ', stripBefore=1, ch(6)='w', stripAfter=0 → removes from 5..6 + expect(docAndCursor(view)).toEqual({ doc: 'hello \nworld', cursor: 8 }); + }); +}); + +// ─── Enter: upgrade " \n" → "\n\n" ────────────────────────────────────────── + +describe('Enter — upgrades " \\n" to paragraph break "\\n\\n"', () => { + // doc = "hello \nworld", lb = 5 + it('upgrades when cursor is at lb+0 (before first space)', () => { + const view = tracked('hello \nworld', 5); + pressKey(view, 'Enter'); + expect(docAndCursor(view)).toEqual({ doc: 'hello\n\nworld', cursor: 7 }); + }); + + it('upgrades when cursor is at lb+1 (between spaces)', () => { + const view = tracked('hello \nworld', 6); + pressKey(view, 'Enter'); + expect(docAndCursor(view)).toEqual({ doc: 'hello\n\nworld', cursor: 7 }); + }); + + it('upgrades when cursor is at lb+2 (between 2nd space and \\n)', () => { + const view = tracked('hello \nworld', 7); + pressKey(view, 'Enter'); + expect(docAndCursor(view)).toEqual({ doc: 'hello\n\nworld', cursor: 7 }); + }); + + it('upgrades when cursor is at lb+3 (just after \\n)', () => { + const view = tracked('hello \nworld', 8); + pressKey(view, 'Enter'); + expect(docAndCursor(view)).toEqual({ doc: 'hello\n\nworld', cursor: 7 }); + }); +}); + +// ─── Enter: in "\n\n" zone inserts plain "\n" ───────────────────────────────── + +describe('Enter — inserts plain "\\n" when cursor is in "\\n\\n" zone', () => { + // doc = "hello\n\nworld", pb = 5 + it('inserts \\n when cursor is at pb (first \\n)', () => { + const view = tracked('hello\n\nworld', 5); + pressKey(view, 'Enter'); + // paraBreakAt(doc, 5) → 5; insert '\n' at 5 → "hello\n\n\nworld" + expect(docAndCursor(view)).toEqual({ doc: 'hello\n\n\nworld', cursor: 6 }); + }); + + it('inserts \\n when cursor is at pb+1 (second \\n)', () => { + const view = tracked('hello\n\nworld', 6); + pressKey(view, 'Enter'); + expect(docAndCursor(view)).toEqual({ doc: 'hello\n\n\nworld', cursor: 7 }); + }); + + it('inserts \\n when cursor is at pb+2 (after \\n\\n)', () => { + const view = tracked('hello\n\nworld', 7); + pressKey(view, 'Enter'); + expect(docAndCursor(view)).toEqual({ doc: 'hello\n\n\nworld', cursor: 8 }); + }); +}); + +// ─── Backspace: inside " \n" zone → join with single space ────────────────── + +describe('Backspace — removes " \\n" and joins lines with a single space', () => { + // doc = "hello \nworld", lb = 5 + it('joins with single space when cursor is at lb+1', () => { + const view = tracked('hello \nworld', 6); + pressKey(view, 'Backspace'); + expect(docAndCursor(view)).toEqual({ doc: 'hello world', cursor: 6 }); + }); + + it('joins with single space when cursor is at lb+2', () => { + const view = tracked('hello \nworld', 7); + pressKey(view, 'Backspace'); + expect(docAndCursor(view)).toEqual({ doc: 'hello world', cursor: 6 }); + }); + + it('joins with single space when cursor is at lb+3 (after \\n)', () => { + const view = tracked('hello \nworld', 8); + pressKey(view, 'Backspace'); + expect(docAndCursor(view)).toEqual({ doc: 'hello world', cursor: 6 }); + }); +}); + +// ─── Backspace: on "\n\n" → downgrade to " \n" ─────────────────────────────── + +describe('Backspace — downgrades "\\n\\n" to " \\n"', () => { + // doc = "hello\n\nworld", pb = 5 + it('downgrades when cursor is at pb+1', () => { + const view = tracked('hello\n\nworld', 6); + pressKey(view, 'Backspace'); + expect(docAndCursor(view)).toEqual({ doc: 'hello \nworld', cursor: 8 }); + }); + + it('downgrades when cursor is at pb+2', () => { + const view = tracked('hello\n\nworld', 7); + pressKey(view, 'Backspace'); + expect(docAndCursor(view)).toEqual({ doc: 'hello \nworld', cursor: 8 }); + }); +}); + +// ─── Delete: inside " \n" zone → join with single space ───────────────────── + +describe('Delete — removes " \\n" and joins lines with a single space', () => { + // doc = "hello \nworld", lb = 5 + it('joins with single space when cursor is at lb+0', () => { + const view = tracked('hello \nworld', 5); + pressKey(view, 'Delete'); + expect(docAndCursor(view)).toEqual({ doc: 'hello world', cursor: 6 }); + }); + + it('joins with single space when cursor is at lb+1', () => { + const view = tracked('hello \nworld', 6); + pressKey(view, 'Delete'); + expect(docAndCursor(view)).toEqual({ doc: 'hello world', cursor: 6 }); + }); + + it('joins with single space when cursor is at lb+2', () => { + const view = tracked('hello \nworld', 7); + pressKey(view, 'Delete'); + expect(docAndCursor(view)).toEqual({ doc: 'hello world', cursor: 6 }); + }); +}); + +// ─── Delete: on "\n\n" → downgrade to " \n" ───────────────────────────────── + +describe('Delete — downgrades "\\n\\n" to " \\n"', () => { + // doc = "hello\n\nworld", pb = 5 + it('downgrades when cursor is at pb (first \\n)', () => { + const view = tracked('hello\n\nworld', 5); + pressKey(view, 'Delete'); + expect(docAndCursor(view)).toEqual({ doc: 'hello \nworld', cursor: 8 }); + }); + + it('downgrades when cursor is at pb+1 (second \\n)', () => { + const view = tracked('hello\n\nworld', 6); + pressKey(view, 'Delete'); + expect(docAndCursor(view)).toEqual({ doc: 'hello \nworld', cursor: 8 }); + }); +}); + +// ─── 'ignore' and 'newline' modes ──────────────────────────────────────────── + +describe('buildEnterExtension — "ignore" mode', () => { + it('does not change the document when Enter is pressed', () => { + const parent = document.createElement('div'); + document.body.appendChild(parent); + const state = EditorState.create({ + doc: 'hello', + selection: { anchor: 5 }, + extensions: [buildEnterExtension('ignore')], + }); + const view = new EditorView({ state, parent }); + views.push(view); + pressKey(view, 'Enter'); + expect(view.state.doc.toString()).toBe('hello'); + }); +}); diff --git a/src/frontend/features/editor/codeMirrorKeymap.ts b/src/frontend/features/editor/codeMirrorKeymap.ts index 885d7c76..0b072349 100644 --- a/src/frontend/features/editor/codeMirrorKeymap.ts +++ b/src/frontend/features/editor/codeMirrorKeymap.ts @@ -153,8 +153,8 @@ export const buildEnterExtension = (eb: EnterBehavior): Extension => { ch(doc, from - 1) === '\n' ) { view.dispatch({ - changes: { from: from - 3, to: from, insert: '' }, - selection: { anchor: from - 3 }, + changes: { from: from - 3, to: from, insert: ' ' }, + selection: { anchor: from - 2 }, }); return true; } @@ -165,8 +165,8 @@ export const buildEnterExtension = (eb: EnterBehavior): Extension => { ch(doc, from) === '\n' ) { view.dispatch({ - changes: { from: from - 2, to: from + 1, insert: '' }, - selection: { anchor: from - 2 }, + changes: { from: from - 2, to: from + 1, insert: ' ' }, + selection: { anchor: from - 1 }, }); return true; } @@ -177,8 +177,8 @@ export const buildEnterExtension = (eb: EnterBehavior): Extension => { ch(doc, from + 1) === '\n' ) { view.dispatch({ - changes: { from: from - 1, to: from + 2, insert: '' }, - selection: { anchor: from - 1 }, + changes: { from: from - 1, to: from + 2, insert: ' ' }, + selection: { anchor: from }, }); return true; } @@ -239,8 +239,8 @@ export const buildEnterExtension = (eb: EnterBehavior): Extension => { ch(doc, from + 2) === '\n' ) { view.dispatch({ - changes: { from, to: from + 3, insert: '' }, - selection: { anchor: from }, + changes: { from, to: from + 3, insert: ' ' }, + selection: { anchor: from + 1 }, }); return true; } @@ -251,8 +251,8 @@ export const buildEnterExtension = (eb: EnterBehavior): Extension => { ch(doc, from + 1) === '\n' ) { view.dispatch({ - changes: { from: from - 1, to: from + 2, insert: '' }, - selection: { anchor: from - 1 }, + changes: { from: from - 1, to: from + 2, insert: ' ' }, + selection: { anchor: from }, }); return true; } @@ -263,8 +263,8 @@ export const buildEnterExtension = (eb: EnterBehavior): Extension => { ch(doc, from) === '\n' ) { view.dispatch({ - changes: { from: from - 2, to: from + 1, insert: '' }, - selection: { anchor: from - 2 }, + changes: { from: from - 2, to: from + 1, insert: ' ' }, + selection: { anchor: from - 1 }, }); return true; } From 31e564792e01d8d61ca1853df1525eae70bf4b82 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Wed, 29 Apr 2026 18:57:58 +0200 Subject: [PATCH 06/48] Highlight chat JSON --- src/frontend/components/ui/JsonSyntaxView.tsx | 108 +++++++++++++++ .../chat/components/ChatMessageItem.test.tsx | 127 ++++++++++++++++++ .../chat/components/ChatMessageItem.tsx | 36 +++-- 3 files changed, 259 insertions(+), 12 deletions(-) create mode 100644 src/frontend/components/ui/JsonSyntaxView.tsx create mode 100644 src/frontend/features/chat/components/ChatMessageItem.test.tsx diff --git a/src/frontend/components/ui/JsonSyntaxView.tsx b/src/frontend/components/ui/JsonSyntaxView.tsx new file mode 100644 index 00000000..4dd54e5d --- /dev/null +++ b/src/frontend/components/ui/JsonSyntaxView.tsx @@ -0,0 +1,108 @@ +// Copyright (C) 2026 StableLlama +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version of the License. + +/** + * Purpose: Shared component for readable JSON rendering with lightweight + * syntax colouring for chat and debug payloads. + */ + +import React from 'react'; + +type JsonSyntaxViewProps = { + data: unknown; + className?: string; +}; + +const escapeJsonString = (value: string): string => + value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t'); + +const renderJsonValue = (value: unknown, indent: number = 0): React.ReactNode => { + if (value === null) { + return null; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return []; + } + + return ( + <> + [ + {value.map((item: unknown, index: number) => ( + + {'\n'} + {' '.repeat(indent + 1)} + {renderJsonValue(item, indent + 1)} + {index < value.length - 1 && ,} + + ))} + {'\n'} + {' '.repeat(indent)} + ] + + ); + } + + if (typeof value === 'object') { + const entries = value ? Object.entries(value as Record) : []; + if (entries.length === 0) { + return {'{}'}; + } + + return ( + <> + {'{'} + {entries.map(([key, child]: [string, unknown], index: number) => ( + + {'\n'} + {' '.repeat(indent + 1)} + "{key}" + : + {renderJsonValue(child, indent + 1)} + {index < entries.length - 1 && ( + , + )} + + ))} + {'\n'} + {' '.repeat(indent)} + {'}'} + + ); + } + + if (typeof value === 'string') { + return ( + "{escapeJsonString(value)}" + ); + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return {String(value)}; + } + + return {String(value)}; +}; + +export const JsonSyntaxView: React.FC = ({ + data, + className, +}: JsonSyntaxViewProps) => { + return ( +
+ {renderJsonValue(data)} +
+ ); +}; diff --git a/src/frontend/features/chat/components/ChatMessageItem.test.tsx b/src/frontend/features/chat/components/ChatMessageItem.test.tsx new file mode 100644 index 00000000..fc3b61bd --- /dev/null +++ b/src/frontend/features/chat/components/ChatMessageItem.test.tsx @@ -0,0 +1,127 @@ +// Copyright (C) 2026 StableLlama +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version of the License. + +/** + * Defines tests for the ChatMessageItem component. + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import i18n from '../../app/i18n'; +import { ChatMessageItem } from './ChatMessageItem'; +import type { ChatMessage } from '../../../types'; + +describe('ChatMessageItem', () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('renders function-call args with distinct JSON key/value styling', () => { + const message: ChatMessage = { + id: 'm1', + role: 'model', + text: 'Function call executed', + tool_calls: [ + { + id: 'c1', + name: 'update_chapter_metadata', + args: { + chap_id: 1, + notes: 'Line 1', + }, + }, + ], + }; + + render( + + + + ); + + const toggleButton = screen.getByRole('button', { name: /Tool Call/i }); + fireEvent.click(toggleButton); + + expect(screen.getByText('"chap_id"')).toBeDefined(); + expect(screen.getByText('"Line 1"')).toBeDefined(); + expect(screen.getByText('"chap_id"').className).toContain('text-sky-400'); + expect(screen.getByText('"Line 1"').className).toContain('text-emerald-400'); + }); + + it('renders JSON tool output with highlighting when the tool text is valid JSON', () => { + const message: ChatMessage = { + id: 'm2', + role: 'tool', + name: 'custom_tool', + text: JSON.stringify({ status: 'ok', count: 3 }, null, 2), + }; + + render( + + + + ); + + const toggleButton = screen.getByRole('button', { name: /Tool Result:/i }); + fireEvent.click(toggleButton); + + expect(screen.getByText('"status"')).toBeDefined(); + expect(screen.getByText('"ok"')).toBeDefined(); + expect(screen.getByText('"status"').className).toContain('text-sky-400'); + expect(screen.getByText('"ok"').className).toContain('text-emerald-400'); + }); +}); diff --git a/src/frontend/features/chat/components/ChatMessageItem.tsx b/src/frontend/features/chat/components/ChatMessageItem.tsx index 9a5207a3..4a1a510b 100644 --- a/src/frontend/features/chat/components/ChatMessageItem.tsx +++ b/src/frontend/features/chat/components/ChatMessageItem.tsx @@ -26,6 +26,7 @@ import { FileText, } from 'lucide-react'; import { Button } from '../../../components/ui/Button'; +import { JsonSyntaxView } from '../../../components/ui/JsonSyntaxView'; import { MarkdownView } from '../../editor/MarkdownView'; import { CollapsibleToolSection } from './CollapsibleToolSection'; import { WebSearchResults, VisitPageResult } from './ToolResultViews'; @@ -37,21 +38,27 @@ import { WebSearchResults, VisitPageResult } from './ToolResultViews'; type ToolCallArgumentsProps = { args: unknown }; +const tryParseJson = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return value; + } +}; + const ToolCallArguments = React.memo(function ToolCallArguments({ args, }: ToolCallArgumentsProps) { - const formattedArgs = useMemo(() => { - if (typeof args === 'string') return args; - try { - return JSON.stringify(args, null, 2); - } catch { - return String(args); - } - }, [args]); + const formattedArgs = useMemo(() => tryParseJson(args), [args]); return ( -
- {formattedArgs} +
+ {typeof formattedArgs === 'string' ? ( + formattedArgs + ) : ( + + )}
); }); @@ -90,6 +97,7 @@ export interface ChatMessageItemProps { onThinkingToggle: (id: string, next: boolean) => void; } +// eslint-disable-next-line max-lines-per-function, complexity export const ChatMessageItem = React.memo(function ChatMessageItem({ msg, isLast, @@ -207,7 +215,11 @@ export const ChatMessageItem = React.memo(function ChatMessageItem({ - + {tryParseJson(msg.text) !== msg.text ? ( + + ) : ( + + )} {msg.name === 'create_project' && msg.text.includes('Project created:') && onSwitchProject && ( @@ -224,7 +236,7 @@ export const ChatMessageItem = React.memo(function ChatMessageItem({ const innerMsg = parsed.message || ''; const match = innerMsg.match(/Project created: (.+)/); if (match) projectName = match[1]; - } catch (e) { + } catch { /* ignore */ } if (!projectName) { From 0859a8b3a5ba62a43af4f966b8a914356a81f9b3 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Wed, 29 Apr 2026 20:09:19 +0200 Subject: [PATCH 07/48] Fix stopping of the chat when it calls another LLM Co-authored-by: Copilot --- src/augmentedquill/api/v1/chat.py | 42 ++++++++++++++----- .../features/chat/chatExecutionHelpers.ts | 5 ++- src/frontend/services/apiClients/chat.ts | 12 +++++- src/frontend/services/openaiService.ts | 11 ++++- 4 files changed, 57 insertions(+), 13 deletions(-) diff --git a/src/augmentedquill/api/v1/chat.py b/src/augmentedquill/api/v1/chat.py index 961a8660..4b99cca1 100644 --- a/src/augmentedquill/api/v1/chat.py +++ b/src/augmentedquill/api/v1/chat.py @@ -298,6 +298,9 @@ async def _run_and_signal() -> Any: result_holder.append( (appended_inner, mutations_inner, names_inner, None) ) + except asyncio.CancelledError: + result_holder.append(([], initial_mutations, [], None)) + raise except Exception as exc: # noqa: BLE001 result_holder.append(([], initial_mutations, [], exc)) finally: @@ -305,18 +308,37 @@ async def _run_and_signal() -> Any: task = asyncio.create_task(_run_and_signal()) + # Cancel the tool task if the client disconnects mid-stream. + async def _watch_disconnect() -> None: + """Cancel the running tool task when the HTTP client disconnects.""" + disconnected = await request.is_disconnected() + if disconnected: + task.cancel() + + watcher = asyncio.create_task(_watch_disconnect()) + # Relay prose-streaming events emitted by call_writing_llm. - while True: - item = await stream_queue.get() - if item is None: - break - kind, data = item - if kind == "prose_start": - yield f"data: {_json.dumps({'type': 'prose_start', **data})}\n\n" - elif kind == "prose_chunk": - yield f"data: {_json.dumps({'type': 'prose_chunk', **data})}\n\n" + try: + while True: + try: + item = await stream_queue.get() + except asyncio.CancelledError: + task.cancel() + raise + if item is None: + break + kind, data = item + if kind == "prose_start": + yield f"data: {_json.dumps({'type': 'prose_start', **data})}\n\n" + elif kind == "prose_chunk": + yield f"data: {_json.dumps({'type': 'prose_chunk', **data})}\n\n" + finally: + watcher.cancel() - await task # ensure the background task has fully finished + try: + await task # ensure the background task has fully finished + except asyncio.CancelledError: + return if not result_holder: yield f"data: {_json.dumps({'type': 'error', 'error': 'Tool execution produced no result'})}\n\n" diff --git a/src/frontend/features/chat/chatExecutionHelpers.ts b/src/frontend/features/chat/chatExecutionHelpers.ts index d6fd3573..bdfb7a2a 100644 --- a/src/frontend/features/chat/chatExecutionHelpers.ts +++ b/src/frontend/features/chat/chatExecutionHelpers.ts @@ -387,6 +387,7 @@ const handleToolResponse = async ( { allowWebSearch: context.getAllowWebSearch(), currentChapter: context.currentChapter, + isStopped: () => context.stopSignalRef.current, } ); @@ -462,7 +463,8 @@ const runToolCallLoop = async ( const currentChatId = context.getCurrentChatId(); const toolResponse = await api.chat.executeTools( buildToolPayload(currentHistory, context.currentChapterId, currentChatId), - context.onProseChunk + context.onProseChunk, + () => context.stopSignalRef.current ); if (context.stopSignalRef.current) break; @@ -519,6 +521,7 @@ const executeChatRequestImpl = async ( { allowWebSearch: context.getAllowWebSearch(), currentChapter: context.currentChapter, + isStopped: () => context.stopSignalRef.current, } ); diff --git a/src/frontend/services/apiClients/chat.ts b/src/frontend/services/apiClients/chat.ts index 91e50de6..29e9b740 100644 --- a/src/frontend/services/apiClients/chat.ts +++ b/src/frontend/services/apiClients/chat.ts @@ -74,7 +74,8 @@ export const createChatApi = (projectName: string) => ({ model_name?: string; chat_id?: string; }, - onProseChunk?: (chapId: number, writeMode: string, accumulated: string) => void + onProseChunk?: (chapId: number, writeMode: string, accumulated: string) => void, + isStopped?: () => boolean ): Promise => { const res = await fetch(`/api/v1${projectEndpoint(projectName, '/chat/tools')}`, { method: 'POST', @@ -141,8 +142,17 @@ export const createChatApi = (projectName: string) => ({ try { while (true) { + if (isStopped?.()) { + // User stopped generation — close the stream so the backend disconnects. + reader.cancel().catch(() => undefined); + return { ok: false, appended_messages: [] }; + } const { done, value } = await reader.read(); if (done) break; + if (isStopped?.()) { + reader.cancel().catch(() => undefined); + return { ok: false, appended_messages: [] }; + } buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n\n'); diff --git a/src/frontend/services/openaiService.ts b/src/frontend/services/openaiService.ts index 6656a662..55f57e1d 100644 --- a/src/frontend/services/openaiService.ts +++ b/src/frontend/services/openaiService.ts @@ -202,6 +202,7 @@ export const createChatSession = ( allowWebSearch?: boolean; currentChapter?: { id: string; title: string } | null; onContextUsage?: (usage: ChatContextUsage) => void; + isStopped?: () => boolean; } ): UnifiedChat => { return { @@ -249,6 +250,7 @@ export const createChatSession = ( []; let thinking = ''; let fullText = ''; + const cancelSignal: CancelSignal = { cancelled: false }; const text = await readSSEStream( reader, (calls: ToolCallChunk[]) => { @@ -274,11 +276,18 @@ export const createChatSession = ( (t: string) => { thinking += t; if (onUpdate) onUpdate({ thinking: applySmartQuotes(thinking) }); + if (options?.isStopped?.()) { + cancelSignal.cancelled = true; + } }, (chunk: string) => { fullText += chunk; if (onUpdate) onUpdate({ text: applySmartQuotes(fullText) }); - } + if (options?.isStopped?.()) { + cancelSignal.cancelled = true; + } + }, + cancelSignal ); const functionCalls = toolCallsAccumulator From ebd0ca6a649787dbd6622de6a63ef13043fcfd62 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Wed, 29 Apr 2026 22:16:04 +0200 Subject: [PATCH 08/48] Stop LLM generation when stop is pressed --- .../features/app/useAppChatRuntime.ts | 74 ++++++++++++++++--- .../features/chat/chatExecutionHelpers.ts | 24 +++--- src/frontend/features/editor/Editor.tsx | 11 ++- src/frontend/services/openaiService.ts | 47 +++++++----- src/frontend/stores/chatStore.ts | 15 ++++ 5 files changed, 126 insertions(+), 45 deletions(-) diff --git a/src/frontend/features/app/useAppChatRuntime.ts b/src/frontend/features/app/useAppChatRuntime.ts index d4de2929..8baa1249 100644 --- a/src/frontend/features/app/useAppChatRuntime.ts +++ b/src/frontend/features/app/useAppChatRuntime.ts @@ -17,14 +17,7 @@ import { useChatSessionManagement } from '../chat/useChatSessionManagement'; import { MUTATION_TOOL_REGISTRY } from '../chat/mutationToolRegistry'; import type { SessionMutation } from '../chat'; import { applySmartQuotes } from '../../utils/textUtils'; -import type { - AppSettings, - ChatAttachment, - ChatMessage, - LLMConfig, - MetadataTab, - StoryState, -} from '../../types'; +import type { ChatAttachment, LLMConfig, MetadataTab, StoryState } from '../../types'; import type { PromptsState } from '../settings/usePrompts'; import type { ChatToolExecutionResponse } from '../../services/apiTypes'; import { useChatStore, ChatStoreState } from '../../stores/chatStore'; @@ -88,6 +81,7 @@ type ToolMutationPayload = ChatToolExecutionResponse & { }>; }; +// eslint-disable-next-line max-lines-per-function export function useAppChatRuntime({ storyId, storyRef, @@ -109,8 +103,7 @@ export function useAppChatRuntime({ }: UseAppChatRuntimeParams): UseAppChatRuntimeResult { // Setters are stable function references — use getState() to avoid subscribing // this hook (and its App.tsx caller) to every streaming token update. - const { setChatMessages, setIsChatLoading, setSessionMutations } = - useChatStore.getState(); + const { setChatMessages, setSessionMutations } = useChatStore.getState(); const getSystemPrompt = useCallback( () => prompts.system_messages.chat_llm || '', @@ -128,6 +121,10 @@ export function useAppChatRuntime({ const onChatNewMessageBegin = useCallback(() => { setSessionMutations([]); + // Clear any frozen prose streaming state left over from a previous stop so + // the green diff overlay is dismissed before the new interaction starts. + useChatStore.getState().setIsProseStreamingFrozen(false); + useStoryStore.getState().setStreamingContent(null); advanceBaselineToCurrentStory(); }, [advanceBaselineToCurrentStory, setSessionMutations]); @@ -180,6 +177,15 @@ export function useAppChatRuntime({ > >({}); + // Stable ref so the isChatLoading subscriber below can call updateChapter without + // needing to re-register the subscription every render. + const updateChapterRef = useRef(updateChapter); + updateChapterRef.current = updateChapter; + + // Whether the user explicitly stopped generation while prose was streaming. + // Set by the handleStopChat wrapper below; cleared when loading ends. + const stoppedDuringProseRef = useRef(false); + useEffect(() => { if (!useChatStore.getState().isChatLoading) { prosePreviewStateRef.current = {}; @@ -188,7 +194,44 @@ export function useAppChatRuntime({ const unsubscribe = useChatStore.subscribe( (state: ChatStoreState, prevState: ChatStoreState) => { if (prevState.isChatLoading && !state.isChatLoading) { + const pendingProse = prosePreviewStateRef.current; + const wasStopped = stoppedDuringProseRef.current; + stoppedDuringProseRef.current = false; prosePreviewStateRef.current = {}; + + // If the user stopped while prose was being streamed, the backend write was + // cancelled. Commit the partial content to the story now so the editor keeps + // the streamed text and an undo entry is pushed. + if (wasStopped) { + const writes = Object.entries(pendingProse).filter( + ([, s]: [string, { lastAppliedContent?: string }]) => + s.lastAppliedContent !== undefined + ); + if (writes.length > 0) { + void (async () => { + for (const [streamKey, streamState] of writes) { + const chapId = streamKey.split(':')[0]; + if (chapId && streamState.lastAppliedContent !== undefined) { + await updateChapterRef.current( + chapId, + { content: streamState.lastAppliedContent }, + true, // sync + true, // pushHistory + false // isUserEdit=false keeps old baseline so diff stays green + ); + } + } + // Atomically transition active→frozen so no render frame sees both + // flags false (which would drop the green prefix-diff highlight). + useChatStore.getState().freezeProseStreaming(); + useStoryStore.getState().setStreamingContent(null); + })(); + // Async commit in progress — skip the synchronous clear below so the + // editor keeps showing the streamed preview until the commit resolves. + return; + } + } + // Clear the streaming slot so the editor shows the committed chapter content. useStoryStore.getState().setStreamingContent(null); useChatStore.getState().setIsProseStreamingFromChat(false); @@ -196,7 +239,7 @@ export function useAppChatRuntime({ } ); return unsubscribe; - }, []); + }, [refreshStory]); const { handleSendMessage, handleStopChat, handleRegenerate } = useChatExecution({ getSystemPrompt: () => useChatStore.getState().systemPrompt, @@ -313,7 +356,14 @@ export function useAppChatRuntime({ ...sessionState, onMutationClick, handleSendMessageWithReset, - handleStopChat, + handleStopChat: useCallback(() => { + // Record that a stop was triggered while prose may be streaming, so the + // isChatLoading subscriber can commit the partial text to the story. + if (useChatStore.getState().isProseStreamingFromChat) { + stoppedDuringProseRef.current = true; + } + handleStopChat(); + }, [handleStopChat]), handleRegenerateWithReset, handleEditMessage, handleDeleteMessage, diff --git a/src/frontend/features/chat/chatExecutionHelpers.ts b/src/frontend/features/chat/chatExecutionHelpers.ts index bdfb7a2a..6906bdf7 100644 --- a/src/frontend/features/chat/chatExecutionHelpers.ts +++ b/src/frontend/features/chat/chatExecutionHelpers.ts @@ -592,19 +592,17 @@ const executeChatRequestImpl = async ( }); } - if (!context.stopSignalRef.current) { - const botMessage = context.createAssistantMessage(currentMsgId, { - text: result.text, - thinking: result.thinking, - functionCalls: normalizeFunctionCalls(result.functionCalls), - }); - upsertChatMessage( - context.setChatMessages, - ensureUniqueMessages, - currentMsgId, - botMessage - ); - } + const botMessage = context.createAssistantMessage(currentMsgId, { + text: result.text, + thinking: result.thinking, + functionCalls: normalizeFunctionCalls(result.functionCalls), + }); + upsertChatMessage( + context.setChatMessages, + ensureUniqueMessages, + currentMsgId, + botMessage + ); } catch (error: unknown) { if (error instanceof DOMException && error.name === 'AbortError') { return; diff --git a/src/frontend/features/editor/Editor.tsx b/src/frontend/features/editor/Editor.tsx index 302120a6..8fe2a712 100644 --- a/src/frontend/features/editor/Editor.tsx +++ b/src/frontend/features/editor/Editor.tsx @@ -185,6 +185,12 @@ export const Editor = React.memo( const isChatStreaming = useChatStore( (s: ChatStoreState) => s.isProseStreamingFromChat ); + // True after the user stops chat mid-write: streaming has ended but we + // keep streamingMode=true so the prefix-based green highlight stays visible + // (as it appeared during streaming) rather than switching to LCS diff. + const isChatStreamingFrozen = useChatStore( + (s: ChatStoreState) => s.isProseStreamingFrozen + ); // Subscribe to the ephemeral streaming slot — only this editor instance // re-renders on each chunk, not the entire component tree. const streamingContent = useStoryStore((s: StoryStoreState) => @@ -192,6 +198,9 @@ export const Editor = React.memo( ); const proseStreamingActive = (aiControls.isProseStreaming ?? false) || isChatStreaming; + // streamingModeActive keeps streamingMode=true even after active streaming + // ends (frozen state) so the green prefix-diff stays visible. + const streamingModeActive = proseStreamingActive || isChatStreamingFrozen; // Keep local state in sync when the chapter changes externally (chapter // switch, AI update, undo/redo). Use chapter.id as the primary trigger @@ -796,7 +805,7 @@ export const Editor = React.memo( } showWhitespace={showWhitespace} showDiff={settings.showDiff} - streamingMode={proseStreamingActive} + streamingMode={streamingModeActive} baselineValue={localBaseline} searchHighlightRanges={chapterSearchHighlightRanges} enterBehavior="softbreak" diff --git a/src/frontend/services/openaiService.ts b/src/frontend/services/openaiService.ts index 55f57e1d..6c374b8a 100644 --- a/src/frontend/services/openaiService.ts +++ b/src/frontend/services/openaiService.ts @@ -105,12 +105,14 @@ export type CancelSignal = { }; /** Read ssestream. */ +// eslint-disable-next-line complexity async function readSSEStream( reader: ReadableStreamDefaultReader, onToolCalls?: (toolCalls: ToolCallChunk[]) => void, onThinking?: (thinking: string) => void, onContent?: (content: string) => void, - cancelSignal?: CancelSignal + cancelSignal?: CancelSignal, + isStopped?: () => boolean ): Promise { let text = ''; let buffer = ''; @@ -120,20 +122,31 @@ async function readSSEStream( cancelSignal.reader = reader; } + const shouldStop = (): boolean => cancelSignal?.cancelled || isStopped?.(); + + const cancelAndBreak = async (): Promise => { + try { + await reader.cancel(); + } catch { + // ignore + } + }; + while (true) { - if (cancelSignal?.cancelled) { - // Stop reading further and close the stream. - try { - await reader.cancel(); - } catch { - // ignore - } + if (shouldStop()) { + await cancelAndBreak(); break; } const { done, value } = await reader.read(); if (done) break; + // Check immediately after the read resolves — stop may have fired while we were waiting. + if (shouldStop()) { + await cancelAndBreak(); + break; + } + buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n\n'); buffer = lines.pop() || ''; @@ -155,7 +168,7 @@ async function readSSEStream( tool_calls?: ToolCallChunk[]; }; if (data.error) { - let msg = data.message || data.error; + const msg = data.message || data.error; throw new ChatError(msg, { traceback: data.traceback, status: data.status, @@ -276,18 +289,13 @@ export const createChatSession = ( (t: string) => { thinking += t; if (onUpdate) onUpdate({ thinking: applySmartQuotes(thinking) }); - if (options?.isStopped?.()) { - cancelSignal.cancelled = true; - } }, (chunk: string) => { fullText += chunk; if (onUpdate) onUpdate({ text: applySmartQuotes(fullText) }); - if (options?.isStopped?.()) { - cancelSignal.cancelled = true; - } }, - cancelSignal + cancelSignal, + options?.isStopped ); const functionCalls = toolCallsAccumulator @@ -322,7 +330,7 @@ export const generateSimpleContent = async ( tool_choice?: string; onUpdate?: (partialText: string) => void; } -) => { +): Promise => { const messages = [ { role: 'system', content: systemInstruction }, { role: 'user', content: prompt }, @@ -444,7 +452,8 @@ export const generateContinuations = async ( if (!chapterId) return []; const scope = chapterId === 'story' ? 'story' : 'chapter'; - const fetchSuggestion = async (index: number) => { + // eslint-disable-next-line complexity + const fetchSuggestion = async (index: number): Promise => { try { const body: Record = { scope, @@ -505,7 +514,7 @@ export const generateContinuations = async ( options?.onSuggestionUpdate?.(index, applySmartQuotes(text)); } return applySmartQuotes(text); - } catch (e) { + } catch { return ''; } }; diff --git a/src/frontend/stores/chatStore.ts b/src/frontend/stores/chatStore.ts index 84f36b53..0c81fe62 100644 --- a/src/frontend/stores/chatStore.ts +++ b/src/frontend/stores/chatStore.ts @@ -44,6 +44,12 @@ export interface ChatStoreState { // ── Prose streaming flag (only flips at turn start/end) ────────────────── /** True only while chat is actively writing prose into the editor. */ isProseStreamingFromChat: boolean; + /** + * True after the user stops chat mid-write: streaming is done but the + * editor keeps streamingMode=true (prefix diff) so the green block stays + * visible. Cleared when the next chat interaction begins. + */ + isProseStreamingFrozen: boolean; // ── Session management ──────────────────────────────────────────────────── chatHistoryList: ChatSession[]; @@ -62,6 +68,10 @@ export interface ChatStoreState { ) => void; setIsChatLoading: (v: boolean) => void; setIsProseStreamingFromChat: (v: boolean) => void; + setIsProseStreamingFrozen: (v: boolean) => void; + /** Atomically clears isProseStreamingFromChat and sets isProseStreamingFrozen=true + * so no render frame sees both flags false (which would drop the green highlight). */ + freezeProseStreaming: () => void; setSessionMutations: ( v: SessionMutation[] | ((prev: SessionMutation[]) => SessionMutation[]) ) => void; @@ -87,6 +97,7 @@ export const useChatStore = create()( chatMessages: [], isChatLoading: false, isProseStreamingFromChat: false, + isProseStreamingFrozen: false, sessionMutations: [], chatHistoryList: [], currentChatId: null, @@ -101,6 +112,10 @@ export const useChatStore = create()( setIsChatLoading: (v: boolean) => set(() => ({ isChatLoading: v })), setIsProseStreamingFromChat: (v: boolean) => set(() => ({ isProseStreamingFromChat: v })), + setIsProseStreamingFrozen: (v: boolean) => + set(() => ({ isProseStreamingFrozen: v })), + freezeProseStreaming: () => + set(() => ({ isProseStreamingFromChat: false, isProseStreamingFrozen: true })), setSessionMutations: ( v: SessionMutation[] | ((prev: SessionMutation[]) => SessionMutation[]) ) => From 19a2571347590cd6c3c6726076808b57a1fa1e7b Mon Sep 17 00:00:00 2001 From: StableLlama Date: Wed, 29 Apr 2026 22:39:00 +0200 Subject: [PATCH 09/48] Fix CHAPTER AI to keep text when stopping --- .../features/story/useAiActions.test.tsx | 340 ++++++++++++------ src/frontend/features/story/useAiActions.ts | 185 +++++++--- 2 files changed, 363 insertions(+), 162 deletions(-) diff --git a/src/frontend/features/story/useAiActions.test.tsx b/src/frontend/features/story/useAiActions.test.tsx index 3bba094a..3ce1fd4d 100644 --- a/src/frontend/features/story/useAiActions.test.tsx +++ b/src/frontend/features/story/useAiActions.test.tsx @@ -17,6 +17,9 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; import { useAiActions } from './useAiActions'; import { streamAiAction } from '../../services/openaiService'; +import type { CancelSignal } from '../../services/openaiService'; +import { useStoryStore } from '../../stores/storyStore'; +import { useChatStore } from '../../stores/chatStore'; vi.mock('../../services/openaiService', () => ({ streamAiAction: vi.fn(), @@ -26,87 +29,83 @@ vi.mock('../../services/errorNotifier', () => ({ notifyError: vi.fn(), })); +const baseUnit = { + id: '1', + scope: 'chapter' as const, + title: 'Chapter 1', + summary: '', + content: 'Existing content', +}; + +type StreamAiActionImpl = ( + target: 'summary' | 'chapter' | 'book_summary' | 'story_summary', + action: 'update' | 'rewrite' | 'extend' | 'write', + chapId: string, + currentText: string, + onUpdate: ((fullText: string) => void) | undefined, + onThinking: ((thinking: string) => void) | undefined, + source: 'notes' | 'chapter' | undefined, + checked: string[] | undefined, + cancelSignal: CancelSignal | undefined +) => Promise; + +const makeParams = ( + updateChapter: ReturnType = vi.fn().mockResolvedValue(undefined) +): { + currentUnit: typeof baseUnit; + prompts: { + system_messages: Record; + user_prompts: Record; + }; + isEditingAvailable: boolean; + isWritingAvailable: boolean; + checkedSourcebookIds: string[]; + updateChapter: ReturnType; + getErrorMessage: (error: unknown, fallback: string) => string; +} => ({ + currentUnit: baseUnit, + prompts: { system_messages: {}, user_prompts: {} }, + isEditingAvailable: true, + isWritingAvailable: true, + checkedSourcebookIds: [], + updateChapter, + getErrorMessage: (_error: unknown, _fallback: string): string => 'error', +}); + +const strictWrapper = ({ children }: { children: ReactNode }): ReactNode => ( + {children} +); + describe('useAiActions', () => { beforeEach(() => { vi.clearAllMocks(); + // Reset stores between tests. + useStoryStore.getState().setStreamingContent(null); + useChatStore.getState().setIsProseStreamingFrozen(false); }); it('resets isAiActionLoading after canceling a chapter stream in StrictMode', async () => { const updateChapter = vi.fn().mockResolvedValue(undefined); - const streamDeferred = (() => { let resolve!: (value: string) => void; - const promise = new Promise( - (res: (value: string | PromiseLike) => void) => { - resolve = res; - } - ); + const promise = new Promise((res: (v: string) => void) => { + resolve = res; + }); return { promise, resolve }; })(); - vi.mocked(streamAiAction).mockImplementation( - async ( - _target: 'summary' | 'chapter' | 'book_summary' | 'story_summary', - _action: 'update' | 'rewrite' | 'extend' | 'write', - _chapId: string, - _currentText: string, - _onUpdate: ((fullText: string) => void) | undefined, - _onThinking: ((thinking: string) => void) | undefined, - _source: 'notes' | 'chapter' | undefined, - _checked: string[] | undefined, - cancelSignal: import('../../services/openaiService').CancelSignal | undefined - ) => { - const value = await streamDeferred.promise; - if (cancelSignal?.cancelled) { - return ''; - } - return value; - } - ); - - const wrapper = ({ children }: { children: ReactNode }) => ( - {children} - ); - - const { result } = renderHook( - () => - useAiActions({ - currentUnit: { - id: '1', - scope: 'chapter', - title: 'Chapter 1', - summary: '', - content: 'Existing content', - }, - story: { - id: 'demo', - title: 'Demo', - summary: '', - styleTags: [], - image_style: '', - image_additional_info: '', - chapters: [], - draft: null, - projectType: 'novel', - books: [], - sourcebook: [], - conflicts: [], - currentChapterId: '1', - lastUpdated: 0, - }, - prompts: { - system_messages: {}, - user_prompts: {}, - }, - isEditingAvailable: true, - isWritingAvailable: true, - checkedSourcebookIds: [], - updateChapter, - setChatMessages: vi.fn(), - getErrorMessage: () => 'error', - }), - { wrapper } - ); + vi.mocked(streamAiAction).mockImplementation((async ( + ...args: Parameters + ) => { + const cancelSignal = args[8]; + const value = await streamDeferred.promise; + if (cancelSignal?.cancelled) return ''; + return value; + }) as StreamAiActionImpl); + + const { result } = renderHook(() => useAiActions(makeParams(updateChapter)), { + wrapper: strictWrapper, + }); await act(async () => { void result.current.handleAiAction('chapter', 'extend'); @@ -126,52 +125,15 @@ describe('useAiActions', () => { expect(result.current.isAiActionLoading).toBe(false); }); - // Final chapter sync should not run after cancellation. + // No streaming content → updateChapter must not be called for partial commit. expect(updateChapter).not.toHaveBeenCalled(); }); it('strips imposed chapter heading prefix before saving rewrite content', async () => { const updateChapter = vi.fn().mockResolvedValue(undefined); - vi.mocked(streamAiAction).mockResolvedValue('# Chapter 1\n\nRewritten body text.'); - const { result } = renderHook(() => - useAiActions({ - currentUnit: { - id: '1', - scope: 'chapter', - title: 'Chapter 1', - summary: '', - content: 'Old body text.', - }, - story: { - id: 'demo', - title: 'Demo', - summary: '', - styleTags: [], - image_style: '', - image_additional_info: '', - chapters: [], - draft: null, - projectType: 'novel', - books: [], - sourcebook: [], - conflicts: [], - currentChapterId: '1', - lastUpdated: 0, - }, - prompts: { - system_messages: {}, - user_prompts: {}, - }, - isEditingAvailable: true, - isWritingAvailable: true, - checkedSourcebookIds: [], - updateChapter, - setChatMessages: vi.fn(), - getErrorMessage: () => 'error', - }) - ); + const { result } = renderHook(() => useAiActions(makeParams(updateChapter))); await act(async () => { await result.current.handleAiAction('chapter', 'rewrite'); @@ -181,4 +143,166 @@ describe('useAiActions', () => { content: 'Rewritten body text.', }); }); + + it('commits partial streamed content when cancel is called during extend', async () => { + const updateChapter = vi.fn().mockResolvedValue(undefined); + let capturedOnUpdate: ((text: string) => void) | undefined; + + vi.mocked(streamAiAction).mockImplementation((async ( + ...args: Parameters + ) => { + const onUpdate = args[4]; + const cancelSignal = args[8]; + capturedOnUpdate = onUpdate; + await new Promise((resolve: () => void) => { + const timer = setInterval(() => { + if (cancelSignal?.cancelled) { + clearInterval(timer); + resolve(); + } + }, 10); + }); + return ''; + }) as StreamAiActionImpl); + + const { result } = renderHook(() => useAiActions(makeParams(updateChapter))); + + await act(async () => { + void result.current.handleAiAction('chapter', 'extend'); + }); + + await waitFor(() => expect(result.current.isAiActionLoading).toBe(true)); + + // Simulate SSE chunks arriving — each call to onUpdate is the accumulated text. + act(() => { + capturedOnUpdate?.('Hello'); + }); + // Allow the 150ms throttle to fire. + await act(async () => { + await new Promise((r: (v: void) => void) => setTimeout(r, 200)); + }); + + act(() => { + result.current.cancelAiAction(); + }); + + await waitFor(() => { + expect(result.current.isAiActionLoading).toBe(false); + }); + + // Partial content commit must have been called with the streamed content. + await waitFor(() => { + expect(updateChapter).toHaveBeenCalledWith( + '1', + { content: 'Existing content\n\nHello' }, + true, + true, + false + ); + }); + }); + + it('sets isProseStreamingFrozen when partial content is committed on cancel', async () => { + const updateChapter = vi.fn().mockResolvedValue(undefined); + let capturedOnUpdate: ((text: string) => void) | undefined; + + vi.mocked(streamAiAction).mockImplementation((async ( + ...args: Parameters + ) => { + const onUpdate = args[4]; + const cancelSignal = args[8]; + capturedOnUpdate = onUpdate; + await new Promise((resolve: () => void) => { + const timer = setInterval(() => { + if (cancelSignal?.cancelled) { + clearInterval(timer); + resolve(); + } + }, 10); + }); + return ''; + }) as StreamAiActionImpl); + + const { result } = renderHook(() => useAiActions(makeParams(updateChapter))); + + await act(async () => { + void result.current.handleAiAction('chapter', 'extend'); + }); + + await waitFor(() => expect(result.current.isAiActionLoading).toBe(true)); + + act(() => { + capturedOnUpdate?.('Streamed chunk'); + }); + await act(async () => { + await new Promise((r: (v: void) => void) => setTimeout(r, 200)); + }); + + act(() => { + result.current.cancelAiAction(); + }); + + // isProseStreamingFrozen must be true before setIsAiActionLoading(false) propagates. + expect(useChatStore.getState().isProseStreamingFrozen).toBe(true); + + await waitFor(() => { + expect(result.current.isAiActionLoading).toBe(false); + }); + // Frozen flag stays true until the next AI action clears it. + expect(useChatStore.getState().isProseStreamingFrozen).toBe(true); + }); + + it('clears isProseStreamingFrozen when a new AI action starts', async () => { + // Pre-set the frozen flag as if a previous stop had occurred. + useChatStore.getState().setIsProseStreamingFrozen(true); + + const updateChapter = vi.fn().mockResolvedValue(undefined); + vi.mocked(streamAiAction).mockResolvedValue('New content'); + + const { result } = renderHook(() => useAiActions(makeParams(updateChapter))); + + await act(async () => { + await result.current.handleAiAction('chapter', 'rewrite'); + }); + + expect(useChatStore.getState().isProseStreamingFrozen).toBe(false); + }); + + it('does not commit partial content if cancel is called before any chunks arrive', async () => { + const updateChapter = vi.fn().mockResolvedValue(undefined); + + vi.mocked(streamAiAction).mockImplementation((async ( + ...args: Parameters + ) => { + const cancelSignal = args[8]; + await new Promise((resolve: () => void) => { + const timer = setInterval(() => { + if (cancelSignal?.cancelled) { + clearInterval(timer); + resolve(); + } + }, 10); + }); + return ''; + }) as StreamAiActionImpl); + + const { result } = renderHook(() => useAiActions(makeParams(updateChapter))); + + await act(async () => { + void result.current.handleAiAction('chapter', 'extend'); + }); + + await waitFor(() => expect(result.current.isAiActionLoading).toBe(true)); + + act(() => { + result.current.cancelAiAction(); + }); + + await waitFor(() => { + expect(result.current.isAiActionLoading).toBe(false); + }); + + expect(updateChapter).not.toHaveBeenCalled(); + expect(useChatStore.getState().isProseStreamingFrozen).toBe(false); + }); }); diff --git a/src/frontend/features/story/useAiActions.ts b/src/frontend/features/story/useAiActions.ts index f11807c0..d1fc27f6 100644 --- a/src/frontend/features/story/useAiActions.ts +++ b/src/frontend/features/story/useAiActions.ts @@ -16,6 +16,7 @@ import { streamAiAction } from '../../services/openaiService'; import { notifyError } from '../../services/errorNotifier'; import { setupMountedRefLifecycle } from '../../utils/mountedRef'; import { useStoryStore } from '../../stores/storyStore'; +import { useChatStore } from '../../stores/chatStore'; type PromptsState = { system_messages: Record; @@ -31,7 +32,9 @@ type UseAiActionsParams = { updateChapter: ( id: string, partial: Partial, - sync?: boolean + sync?: boolean, + pushHistory?: boolean, + isUserEdit?: boolean ) => Promise; getErrorMessage: (error: unknown, fallback: string) => string; }; @@ -91,6 +94,86 @@ const getSeparator = ( ? '\n\n' : ''; +/** Extracted to keep useAiActions under the line-per-function limit. */ +type StreamedContent = { chapterId: string; content: string }; + +/** + * Creates a throttled progress pusher for streaming AI chapter content previews. + * 150ms macrotask throttle avoids "Maximum update depth exceeded" in React 19. + */ +function createStreamingPusher( + isChapterStreamingAction: boolean, + baseContent: string, + separator: string, + action: 'update' | 'rewrite' | 'extend', + chapterId: string, + stripPrefillEcho: (text: string) => string, + onLastStreamed: (sc: StreamedContent) => void +): { + pushProgress: (partial: string) => void; + cancelThrottle: () => void; +} { + let pendingPartial: string | null = null; + let throttleHandle: ReturnType | null = null; + + const flushPending = (): void => { + throttleHandle = null; + if (pendingPartial === null) return; + const partial = pendingPartial; + pendingPartial = null; + const normalizedPartial = stripPrefillEcho(partial); + const nextContent = + action === 'extend' + ? `${baseContent}${separator}${normalizedPartial}` + : normalizedPartial; + onLastStreamed({ chapterId, content: nextContent }); + useStoryStore.getState().setStreamingContent({ chapterId, content: nextContent }); + }; + + const pushProgress = (partial: string): void => { + if (!isChapterStreamingAction) return; + pendingPartial = partial; + if (throttleHandle === null) { + throttleHandle = setTimeout(flushPending, 150); + } + }; + + const cancelThrottle = (): void => { + if (throttleHandle !== null) { + clearTimeout(throttleHandle); + throttleHandle = null; + pendingPartial = null; + } + }; + + return { pushProgress, cancelThrottle }; +} + +/** + * Commits partial streamed content after a cancel, freezes the prose streaming + * highlight, and clears the streaming slot once the commit resolves. + * Extracted from useAiActions to keep the hook under the line-length limit. + */ +async function commitCancelledProseContent( + chapterId: string, + content: string, + updateChapter: ( + id: string, + partial: Partial, + sync?: boolean, + pushHistory?: boolean, + isUserEdit?: boolean + ) => Promise, + onDone: () => void +): Promise { + try { + await updateChapter(chapterId, { content }, true, true, false); + useStoryStore.getState().setStreamingContent(null); + } finally { + onDone(); + } +} + /** Custom React hook that manages ai actions. */ export function useAiActions({ currentUnit, @@ -122,6 +205,10 @@ export function useAiActions({ reader?: ReadableStreamDefaultReader; }>({ cancelled: false }); const isMountedRef = useRef(true); + // Tracks the latest content flushed to the streaming slot for cancel-commit. + const lastStreamedContentRef = useRef(null); + // True while a cancel-triggered commit is in progress. + const cancelCommitInProgressRef = useRef(false); // Avoid updating state after the component has unmounted. // This can happen if the user cancels a streaming action while the component is still tearing down. @@ -131,8 +218,26 @@ export function useAiActions({ cancelSignalRef.current.cancelled = true; cancelSignalRef.current.reader?.cancel(); cancelSignalRef.current.reader = undefined; - // Clear streaming slot so the editor reverts to committed chapter content. - useStoryStore.getState().setStreamingContent(null); + + const lastStreamed = lastStreamedContentRef.current; + if (lastStreamed) { + // Set frozen flag BEFORE clearing isAiActionLoading so no render frame + // sees streamingModeActive=false while partial content is still visible. + useChatStore.getState().setIsProseStreamingFrozen(true); + cancelCommitInProgressRef.current = true; + void commitCancelledProseContent( + lastStreamed.chapterId, + lastStreamed.content, + updateChapter, + () => { + cancelCommitInProgressRef.current = false; + lastStreamedContentRef.current = null; + } + ); + } else { + useStoryStore.getState().setStreamingContent(null); + } + if (isMountedRef.current) { setIsAiActionLoading(false); } @@ -149,6 +254,10 @@ export function useAiActions({ setIsAiActionLoading(true); cancelSignalRef.current = { cancelled: false }; + // Reset tracking state and clear any frozen highlight from a prior stop. + lastStreamedContentRef.current = null; + cancelCommitInProgressRef.current = false; + useChatStore.getState().setIsProseStreamingFrozen(false); try { const isChapterStreamingAction = @@ -172,42 +281,17 @@ export function useAiActions({ ); const separator = getSeparator(action, baseContent); - let pendingPartial: string | null = null; - let throttleHandle: ReturnType | null = null; - // Throttle interval for streaming preview updates. Fires via setTimeout - // (macrotask) so it is always outside React's render cycle, avoiding the - // "Maximum update depth exceeded" error that requestAnimationFrame can - // trigger when React 19 concurrent rendering is mid-flight. - const PREVIEW_INTERVAL_MS = 150; - - const flushPending = (): void => { - throttleHandle = null; - if (pendingPartial === null) return; - const partial = pendingPartial; - pendingPartial = null; - const normalizedPartial = stripPrefillEcho(partial); - const nextContent = - action === 'extend' - ? `${baseContent}${separator}${normalizedPartial}` - : normalizedPartial; - // Write into the dedicated streaming slot instead of story.chapters so - // only the editor re-renders; sourcebook, chat, and sidebar are unaffected. - useStoryStore.getState().setStreamingContent({ - chapterId: currentUnit.id, - content: nextContent, - }); - }; - - const pushProgress = (partial: string): void => { - if (!isChapterStreamingAction) return; - // Coalesce all SSE chunks arriving within PREVIEW_INTERVAL_MS into a - // single state update. Storing the latest value means we never render - // a stale intermediate — only the most-recent accumulated text is shown. - pendingPartial = partial; - if (throttleHandle === null) { - throttleHandle = setTimeout(flushPending, PREVIEW_INTERVAL_MS); + const { pushProgress, cancelThrottle } = createStreamingPusher( + isChapterStreamingAction, + baseContent, + separator, + action, + currentUnit.id, + stripPrefillEcho, + (sc: StreamedContent) => { + lastStreamedContentRef.current = sc; } - }; + ); const selectedTarget = getAiActionTarget(target, currentUnit.scope); @@ -223,24 +307,15 @@ export function useAiActions({ cancelSignalRef.current ); - // Cancel any pending throttle flush before applying the final result to - // avoid a stale intermediate state overwriting the completed content. - if (throttleHandle !== null) { - clearTimeout(throttleHandle); - throttleHandle = null; - pendingPartial = null; - } + // Clear pending throttle flush before applying the final result. + cancelThrottle(); - // If the user cancelled the action while it was streaming, avoid - // applying any final updates and exit quickly so the UI can return to - // an idle state. + // If cancelled, bail out before applying any final updates. if (cancelSignalRef.current.cancelled) { return; } - // Clear the streaming slot before committing the final result so the - // editor transitions directly from streaming text → final text without - // a flash back to the pre-AI baseline content. + // Clear streaming slot so the editor transitions directly to final text. useStoryStore.getState().setStreamingContent(null); if (target === 'summary') { @@ -258,9 +333,11 @@ export function useAiActions({ console.error('AI Action Error:', error); notifyError(getErrorMessage(error, 'Failed to perform AI action')); } finally { - // Safety-net: clear the streaming slot in case the try block exited - // via an exception before reaching the explicit clearance above. - useStoryStore.getState().setStreamingContent(null); + // Safety-net: clear the streaming slot unless cancelAiAction is handling + // an async commit (which will clear it after the commit resolves). + if (!cancelCommitInProgressRef.current) { + useStoryStore.getState().setStreamingContent(null); + } if (isMountedRef.current) { setIsAiActionLoading(false); } From 8b04e5d1ff7adcf5f9fe3e78369f892eefb15898 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Wed, 29 Apr 2026 22:49:10 +0200 Subject: [PATCH 10/48] try to fix GH error message --- .../features/settings/useProviderHealth.ts | 21 ++++++++----------- src/frontend/services/openaiService.ts | 2 +- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/frontend/features/settings/useProviderHealth.ts b/src/frontend/features/settings/useProviderHealth.ts index 9c0e1a07..d2159259 100644 --- a/src/frontend/features/settings/useProviderHealth.ts +++ b/src/frontend/features/settings/useProviderHealth.ts @@ -26,11 +26,11 @@ export function makeProviderKey( modelId?: string, apiKeyEnabled?: boolean ): string { + const usesApiKey = apiKeyEnabled !== false; const b = (baseUrl || '').trim(); - const k = apiKeyEnabled ? (apiKey || '').trim() : ''; + const k = usesApiKey ? (apiKey || '').trim() : ''; const m = (modelId || '').trim(); - const enabled = apiKeyEnabled ? 'enabled' : 'disabled'; - return `${b}||${k}||${m}||${enabled}`; + return `${b}||${k}||${m}`; } /** @@ -72,13 +72,9 @@ export function groupProviders( if (!activeIds.has(provider.id)) return; const modelId = (provider.modelId || '').trim(); if (!modelId) return; - const apiKey = provider.apiKeyEnabled ? provider.apiKey : undefined; - const key = makeProviderKey( - provider.baseUrl || '', - apiKey, - modelId, - provider.apiKeyEnabled - ); + const apiKeyEnabled = provider.apiKeyEnabled !== false; + const apiKey = apiKeyEnabled ? provider.apiKey : undefined; + const key = makeProviderKey(provider.baseUrl || '', apiKey, modelId, apiKeyEnabled); if (!groups[key]) { groups[key] = { ids: [], @@ -177,12 +173,13 @@ export function useProviderHealth(appSettings: AppSettings): { appSettings.activeEditingProviderId, ]); const groupedProviders = groupProviders(appSettings.providers, activeIds); - const apiKey = provider.apiKeyEnabled ? provider.apiKey : undefined; + const apiKeyEnabled = provider.apiKeyEnabled !== false; + const apiKey = apiKeyEnabled ? provider.apiKey : undefined; const key = makeProviderKey( provider.baseUrl || '', apiKey, modelId, - provider.apiKeyEnabled + apiKeyEnabled ); const relatedProviderIds = groupedProviders[key]?.ids || [provider.id]; const payload = groupedProviders[key]?.payload || { diff --git a/src/frontend/services/openaiService.ts b/src/frontend/services/openaiService.ts index 6c374b8a..d7d6690b 100644 --- a/src/frontend/services/openaiService.ts +++ b/src/frontend/services/openaiService.ts @@ -122,7 +122,7 @@ async function readSSEStream( cancelSignal.reader = reader; } - const shouldStop = (): boolean => cancelSignal?.cancelled || isStopped?.(); + const shouldStop = (): boolean => Boolean(cancelSignal?.cancelled || isStopped?.()); const cancelAndBreak = async (): Promise => { try { From 5a6b9d39a0e16a58c1eec126d17bc4bb8904c9fa Mon Sep 17 00:00:00 2001 From: StableLlama Date: Wed, 29 Apr 2026 23:32:21 +0200 Subject: [PATCH 11/48] Fix joining of LLM extension of the prose --- .../chat/chat_tools/chapter_prose_tools.py | 39 +++++++- .../features/story/useAiActions.test.tsx | 17 +++- src/frontend/features/story/useAiActions.ts | 19 +--- src/frontend/utils/textUtils.test.ts | 11 ++- src/frontend/utils/textUtils.ts | 17 ++-- tests/unit/services/test_chat_tools.py | 98 ++++++++++++++++++- 6 files changed, 166 insertions(+), 35 deletions(-) diff --git a/src/augmentedquill/services/chat/chat_tools/chapter_prose_tools.py b/src/augmentedquill/services/chat/chat_tools/chapter_prose_tools.py index 06da4bac..19a9d301 100644 --- a/src/augmentedquill/services/chat/chat_tools/chapter_prose_tools.py +++ b/src/augmentedquill/services/chat/chat_tools/chapter_prose_tools.py @@ -26,6 +26,39 @@ ) from augmentedquill.services.chat.chat_tools.chapter_tools import MARKER + +def _count_leading_newlines(text: str) -> int: + count = 0 + for char in text: + if char != "\n": + break + count += 1 + return count + + +def _join_appended_prose(existing: str, generated_text: str) -> str: + if not generated_text: + return existing + + leading_nl = _count_leading_newlines(generated_text) + if leading_nl >= 2: + body = generated_text.lstrip("\n") + return existing.rstrip("\n") + "\n\n" + body + + if leading_nl == 1: + body = generated_text.lstrip("\n") + return existing.rstrip("\n") + "\n" + body + + prefix = existing.rstrip("\n") + if ( + prefix + and not prefix[-1].isspace() + and not generated_text.startswith((" ", "\t", "\n")) + ): + return prefix + " " + generated_text + return prefix + generated_text + + # ============================================================================ # call_writing_llm # ============================================================================ @@ -208,11 +241,7 @@ async def call_writing_llm( if params.write_mode == "append": # Append to end of chapter (like continue_chapter) existing = path.read_text(encoding="utf-8") - new_content = ( - existing - + ("\n" if existing and not existing.endswith("\n") else "") - + generated_text - ) + new_content = _join_appended_prose(existing, generated_text) _write_chapter_content(chap_id, new_content) mutations["story_changed"] = True return { diff --git a/src/frontend/features/story/useAiActions.test.tsx b/src/frontend/features/story/useAiActions.test.tsx index 3ce1fd4d..c48550c9 100644 --- a/src/frontend/features/story/useAiActions.test.tsx +++ b/src/frontend/features/story/useAiActions.test.tsx @@ -194,7 +194,7 @@ describe('useAiActions', () => { await waitFor(() => { expect(updateChapter).toHaveBeenCalledWith( '1', - { content: 'Existing content\n\nHello' }, + { content: 'Existing content Hello' }, true, true, false @@ -202,6 +202,21 @@ describe('useAiActions', () => { }); }); + it('does not force a paragraph break when final extend content continues the current sentence', async () => { + const updateChapter = vi.fn().mockResolvedValue(undefined); + vi.mocked(streamAiAction).mockResolvedValue('continued text.'); + + const { result } = renderHook(() => useAiActions(makeParams(updateChapter))); + + await act(async () => { + await result.current.handleAiAction('chapter', 'extend'); + }); + + expect(updateChapter).toHaveBeenCalledWith('1', { + content: 'Existing content continued text.', + }); + }); + it('sets isProseStreamingFrozen when partial content is committed on cancel', async () => { const updateChapter = vi.fn().mockResolvedValue(undefined); let capturedOnUpdate: ((text: string) => void) | undefined; diff --git a/src/frontend/features/story/useAiActions.ts b/src/frontend/features/story/useAiActions.ts index d1fc27f6..b43cd247 100644 --- a/src/frontend/features/story/useAiActions.ts +++ b/src/frontend/features/story/useAiActions.ts @@ -15,6 +15,7 @@ import { WritingUnit } from '../../types'; import { streamAiAction } from '../../services/openaiService'; import { notifyError } from '../../services/errorNotifier'; import { setupMountedRefLifecycle } from '../../utils/mountedRef'; +import { joinSuggestionToContent } from '../../utils/textUtils'; import { useStoryStore } from '../../stores/storyStore'; import { useChatStore } from '../../stores/chatStore'; @@ -86,17 +87,6 @@ const getAiActionTarget = ( ): 'summary' | 'story_summary' | 'chapter' => target === 'chapter' ? 'chapter' : scope === 'chapter' ? 'summary' : 'story_summary'; -const getSeparator = ( - action: 'update' | 'rewrite' | 'extend', - baseContent: string -): string => - action === 'extend' && baseContent.length > 0 && !baseContent.endsWith('\n') - ? '\n\n' - : ''; - -/** Extracted to keep useAiActions under the line-per-function limit. */ -type StreamedContent = { chapterId: string; content: string }; - /** * Creates a throttled progress pusher for streaming AI chapter content previews. * 150ms macrotask throttle avoids "Maximum update depth exceeded" in React 19. @@ -104,7 +94,6 @@ type StreamedContent = { chapterId: string; content: string }; function createStreamingPusher( isChapterStreamingAction: boolean, baseContent: string, - separator: string, action: 'update' | 'rewrite' | 'extend', chapterId: string, stripPrefillEcho: (text: string) => string, @@ -124,7 +113,7 @@ function createStreamingPusher( const normalizedPartial = stripPrefillEcho(partial); const nextContent = action === 'extend' - ? `${baseContent}${separator}${normalizedPartial}` + ? joinSuggestionToContent(baseContent, normalizedPartial) : normalizedPartial; onLastStreamed({ chapterId, content: nextContent }); useStoryStore.getState().setStreamingContent({ chapterId, content: nextContent }); @@ -279,12 +268,10 @@ export function useAiActions({ imposedActionPrefill, imposedHeadingPrefix ); - const separator = getSeparator(action, baseContent); const { pushProgress, cancelThrottle } = createStreamingPusher( isChapterStreamingAction, baseContent, - separator, action, currentUnit.id, stripPrefillEcho, @@ -322,7 +309,7 @@ export function useAiActions({ await updateChapter(currentUnit.id, { summary: result }); } else if (action === 'extend') { await updateChapter(currentUnit.id, { - content: baseContent + separator + stripPrefillEcho(result), + content: joinSuggestionToContent(baseContent, stripPrefillEcho(result)), }); } else { await updateChapter(currentUnit.id, { diff --git a/src/frontend/utils/textUtils.test.ts b/src/frontend/utils/textUtils.test.ts index 1d062b0a..96f2e109 100644 --- a/src/frontend/utils/textUtils.test.ts +++ b/src/frontend/utils/textUtils.test.ts @@ -141,15 +141,18 @@ describe('joinSuggestionToContent', () => { it('no leading newline in suggestion: inline concatenation, prefix unchanged', () => { expect(joinSuggestionToContent('The door opened.', 'She entered.')).toBe( - 'The door opened.She entered.' + 'The door opened. She entered.' ); }); - it('no leading newline, prefix has single trailing newline: append directly', () => { - // The trailing \n is part of the prefix and is kept as-is. + it('no leading newline, prefix has single trailing newline: strip newline and join inline', () => { expect(joinSuggestionToContent('The door opened.\n', 'She entered.')).toBe( - 'The door opened.\nShe entered.' + 'The door opened. She entered.' ); + expect(joinSuggestionToContent('The door opened.\n ', 'She entered.')).toBe( + 'The door opened. She entered.' + ); + expect(joinSuggestionToContent('Hello\n', ' world')).toBe('Hello world'); }); it('single leading newline in suggestion: markdown hard line break ( \\n)', () => { diff --git a/src/frontend/utils/textUtils.ts b/src/frontend/utils/textUtils.ts index a1b4083c..24d2753d 100644 --- a/src/frontend/utils/textUtils.ts +++ b/src/frontend/utils/textUtils.ts @@ -94,9 +94,11 @@ export function computeContentWithSeparator( * the joined result never has more than two consecutive `\n` at the boundary. */ export function joinSuggestionToContent(prefix: string, suggestion: string): string { - // Count trailing \n characters on the prefix. + // Trim trailing spaces/tabs before counting semantic newline boundaries. + const prefixTrimmed = prefix.replace(/[ \t]+$/, ''); + let trailingNL = 0; - for (let i = prefix.length - 1; i >= 0 && prefix[i] === '\n'; i--) { + for (let i = prefixTrimmed.length - 1; i >= 0 && prefixTrimmed[i] === '\n'; i--) { trailingNL++; } @@ -106,18 +108,21 @@ export function joinSuggestionToContent(prefix: string, suggestion: string): str leadingNL++; } const prose = suggestion.substring(leadingNL); + const base = prefixTrimmed.substring(0, prefixTrimmed.length - trailingNL); if (trailingNL >= 2 || leadingNL >= 2) { // Paragraph break: strip all trailing newlines from prefix, join with \n\n. - const base = prefix.substring(0, prefix.length - trailingNL); return base + '\n\n' + prose; } else if (leadingNL === 1) { // Markdown hard line break: two trailing spaces + newline. - const base = prefix.substring(0, prefix.length - trailingNL); return base + ' \n' + prose; } else { - // No leading newline in suggestion: inline continuation, prefix unchanged. - return prefix + prose; + // No leading newline in suggestion: inline continuation. + // Consume any trailing inline formatting newlines from the prefix so generic + // editor noise does not introduce an unintended hard break. + const needsSpace = + base.length > 0 && !/\s$/.test(base) && prose.length > 0 && !/^\s/.test(prose); + return base + (needsSpace ? ' ' : '') + prose; } } diff --git a/tests/unit/services/test_chat_tools.py b/tests/unit/services/test_chat_tools.py index dced7d7c..7e93f213 100644 --- a/tests/unit/services/test_chat_tools.py +++ b/tests/unit/services/test_chat_tools.py @@ -721,9 +721,9 @@ def test_call_writing_llm_append_mode_with_chap_id(self): }, ) - # Verify content was appended + # Verify content was appended with inline continuation semantics new_content = chapter_file.read_text(encoding="utf-8") - self.assertEqual(new_content, original_content + "\nNew generated content.") + self.assertEqual(new_content, original_content + " New generated content.") # Verify response structure appended = data.get("appended_messages") or [] @@ -738,6 +738,98 @@ def test_call_writing_llm_append_mode_with_chap_id(self): mutations = data.get("mutations") or {} self.assertTrue(mutations.get("story_changed")) + def test_call_writing_llm_append_mode_with_trailing_newline(self): + """Test append mode consumes trailing newline when continuation is inline.""" + self._bootstrap_project() + self._post_single_tool( + "update_story_metadata", + { + "conflicts": [ + { + "id": "c1", + "description": "Test conflict", + "resolution": "Test resolution", + } + ] + }, + ) + + chapter_file = self.projects_root / "demo" / "chapters" / "0001.txt" + chapter_file.write_text("Line one.\n", encoding="utf-8") + + with ( + patch( + "augmentedquill.services.llm.llm.resolve_openai_credentials", + return_value=("http://localhost:11434/v1", None, "dummy", 30, "dummy"), + ), + patch( + "augmentedquill.services.llm.llm.unified_chat_stream", + new=_fake_llm_stream("Second sentence."), + ), + ): + data = self._post_single_tool( + "call_writing_llm", + { + "instruction": "Continue the story", + "context": "Current text", + "write_mode": "append", + "chap_id": 1, + }, + ) + + new_content = chapter_file.read_text(encoding="utf-8") + self.assertEqual(new_content, "Line one. Second sentence.") + + appended = data.get("appended_messages") or [] + self.assertEqual(len(appended), 1) + result = json.loads(appended[0].get("content") or "{}") + self.assertEqual(result.get("generated_text"), "Second sentence.") + self.assertTrue(result.get("written")) + self.assertEqual(result.get("write_mode"), "append") + self.assertEqual(result.get("chap_id"), 1) + + def test_call_writing_llm_append_mode_with_paragraph_break(self): + """Test append mode preserves paragraph boundary when model starts with double newline.""" + self._bootstrap_project() + self._post_single_tool( + "update_story_metadata", + { + "conflicts": [ + { + "id": "c1", + "description": "Test conflict", + "resolution": "Test resolution", + } + ] + }, + ) + + chapter_file = self.projects_root / "demo" / "chapters" / "0001.txt" + chapter_file.write_text("Line one.\n", encoding="utf-8") + + with ( + patch( + "augmentedquill.services.llm.llm.resolve_openai_credentials", + return_value=("http://localhost:11434/v1", None, "dummy", 30, "dummy"), + ), + patch( + "augmentedquill.services.llm.llm.unified_chat_stream", + new=_fake_llm_stream("\n\nSecond paragraph."), + ), + ): + _ = self._post_single_tool( + "call_writing_llm", + { + "instruction": "Continue the story", + "context": "Current text", + "write_mode": "append", + "chap_id": 1, + }, + ) + + new_content = chapter_file.read_text(encoding="utf-8") + self.assertEqual(new_content, "Line one.\n\nSecond paragraph.") + def test_call_writing_llm_append_mode_short_story_auto_detect(self): """Test write_mode='append' auto-detects chap_id=1 for short-story projects.""" self._bootstrap_project() @@ -786,7 +878,7 @@ def test_call_writing_llm_append_mode_short_story_auto_detect(self): # Verify content was appended new_content = content_file.read_text(encoding="utf-8") self.assertEqual( - new_content, "Original short story content.\n Continuation text." + new_content, "Original short story content. Continuation text." ) # Verify response has chap_id=1 set automatically From 9de8999b571dd0026c73b57539f23da6f1040084 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Thu, 30 Apr 2026 11:36:15 +0200 Subject: [PATCH 12/48] Fix auto scroll --- .../features/app/useAppChatRuntime.ts | 1 + src/frontend/features/chat/Chat.tsx | 23 +- .../features/chat/hooks/useChatScroll.test.ts | 214 ++++++++ .../features/chat/hooks/useChatScroll.ts | 119 ++++- src/frontend/features/editor/Editor.tsx | 33 +- .../editor/hooks/useEditorScroll.test.ts | 496 ++++++++++++++++++ .../features/editor/hooks/useEditorScroll.ts | 311 +++++++---- src/frontend/features/story/useAiActions.ts | 10 +- src/frontend/stores/storyStore.ts | 6 +- 9 files changed, 1081 insertions(+), 132 deletions(-) create mode 100644 src/frontend/features/chat/hooks/useChatScroll.test.ts create mode 100644 src/frontend/features/editor/hooks/useEditorScroll.test.ts diff --git a/src/frontend/features/app/useAppChatRuntime.ts b/src/frontend/features/app/useAppChatRuntime.ts index 8baa1249..5fe175f2 100644 --- a/src/frontend/features/app/useAppChatRuntime.ts +++ b/src/frontend/features/app/useAppChatRuntime.ts @@ -301,6 +301,7 @@ export function useAppChatRuntime({ void useStoryStore.getState().setStreamingContent({ chapterId: unit.id, content: newContent, + writeMode, }); useChatStore.getState().setIsProseStreamingFromChat(true); }, diff --git a/src/frontend/features/chat/Chat.tsx b/src/frontend/features/chat/Chat.tsx index e0ba7cce..f9191457 100644 --- a/src/frontend/features/chat/Chat.tsx +++ b/src/frontend/features/chat/Chat.tsx @@ -9,7 +9,7 @@ * Defines the chat unit so this responsibility stays isolated, testable, and easy to evolve. */ -import React, { useState, useRef, useMemo, useCallback } from 'react'; +import React, { useState, useRef, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { ChatAttachment } from '../../types'; import { useThemeClasses } from '../layout/ThemeContext'; @@ -29,7 +29,8 @@ import { useChatEditing } from './hooks/useChatEditing'; import { useChatUIState } from './hooks/useChatUIState'; import { useChatMessages } from './hooks/useChatMessages'; -export const Chat: React.FC = React.memo(() => { +/* eslint-disable max-lines-per-function, complexity */ +function ChatComponent(): JSX.Element { const { messages, isLoading, @@ -86,7 +87,14 @@ export const Chat: React.FC = React.memo(() => { const fileInputRef = useRef(null); const [attachments, setAttachments] = useState([]); - const { scrollContainerRef, handleScroll, scrollToBottom } = useChatScroll({ + const { + scrollContainerRef, + handleScroll, + handleWheel, + handleTouchStart, + handleTouchMove, + scrollToBottom, + } = useChatScroll({ messages, isLoading, editingMessageId, @@ -114,7 +122,7 @@ export const Chat: React.FC = React.memo(() => { ? 'bg-brand-gray-50 border-brand-gray-300 text-brand-gray-900' : 'bg-brand-gray-950 border-brand-gray-800 text-brand-gray-300'; - const handleSubmit = (text: string, files?: ChatAttachment[]) => { + const handleSubmit = (text: string, files?: ChatAttachment[]): void => { if (isLoading || !isModelAvailable) return; onSendMessage(text, files); @@ -206,6 +214,9 @@ export const Chat: React.FC = React.memo(() => {
{
); -}); +} +/* eslint-enable max-lines-per-function, complexity */ +export const Chat: React.FC = React.memo(ChatComponent); Chat.displayName = 'Chat'; diff --git a/src/frontend/features/chat/hooks/useChatScroll.test.ts b/src/frontend/features/chat/hooks/useChatScroll.test.ts new file mode 100644 index 00000000..d2067753 --- /dev/null +++ b/src/frontend/features/chat/hooks/useChatScroll.test.ts @@ -0,0 +1,214 @@ +// Copyright (C) 2026 StableLlama +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +/** + * Purpose: Regression tests for chat scroll reattachment and auto-scroll state. + */ + +// @vitest-environment jsdom + +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useChatScroll } from './useChatScroll'; + +const makeContainer = ( + scrollTop: number, + clientHeight: number, + scrollHeight: number +): HTMLDivElement => { + const el = document.createElement('div'); + Object.defineProperties(el, { + clientHeight: { value: clientHeight, configurable: true }, + scrollHeight: { value: scrollHeight, configurable: true }, + }); + el.scrollTop = scrollTop; + // jsdom does not implement scrollTo; provide a stub that sets scrollTop. + el.scrollTo = ({ top }: ScrollToOptions) => { + if (top !== undefined) el.scrollTop = top; + }; + return el; +}; + +const makeWheelEvent = (deltaY: number): WheelEvent => + ({ deltaY }) as unknown as WheelEvent; + +const makeTouchEvent = (clientY: number): TouchEvent => + ({ touches: [{ clientY }] }) as unknown as TouchEvent; + +describe('useChatScroll', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('deactivates auto-scroll when the user scrolls up even from the bottom', () => { + const { result } = renderHook(() => + useChatScroll({ + messages: [], + isLoading: false, + editingMessageId: null, + currentSessionId: null, + }) + ); + + const container = makeContainer(1100, 100, 1200); + result.current.scrollContainerRef.current = container; + + act(() => { + result.current.handleScroll(); + vi.runOnlyPendingTimers(); + }); + expect(result.current.isAtBottomRef.current).toBe(true); + + container.scrollTop = 1050; + act(() => { + result.current.handleScroll(); + vi.runOnlyPendingTimers(); + }); + expect(result.current.isAtBottomRef.current).toBe(false); + }); + + it('reattaches auto-scroll when the user scrolls down near the bottom', () => { + const { result } = renderHook(() => + useChatScroll({ + messages: [], + isLoading: false, + editingMessageId: null, + currentSessionId: null, + }) + ); + + const container = makeContainer(1100, 100, 1200); + result.current.scrollContainerRef.current = container; + + act(() => { + result.current.handleScroll(); + vi.runOnlyPendingTimers(); + }); + expect(result.current.isAtBottomRef.current).toBe(true); + + container.scrollTop = 1050; + act(() => { + result.current.handleScroll(); + vi.runOnlyPendingTimers(); + }); + expect(result.current.isAtBottomRef.current).toBe(false); + + container.scrollTop = 1090; + act(() => { + result.current.handleScroll(); + vi.runOnlyPendingTimers(); + }); + expect(result.current.isAtBottomRef.current).toBe(true); + }); + + it('deactivates auto-scroll on an upward wheel event', () => { + const { result } = renderHook(() => + useChatScroll({ + messages: [], + isLoading: false, + editingMessageId: null, + currentSessionId: null, + }) + ); + + const container = makeContainer(1100, 100, 1200); + result.current.scrollContainerRef.current = container; + + act(() => { + result.current.handleWheel(makeWheelEvent(-10)); + }); + expect(result.current.isAtBottomRef.current).toBe(false); + }); + + it('deactivates auto-scroll on an upward touch gesture', () => { + const { result } = renderHook(() => + useChatScroll({ + messages: [], + isLoading: false, + editingMessageId: null, + currentSessionId: null, + }) + ); + + const container = makeContainer(1100, 100, 1200); + result.current.scrollContainerRef.current = container; + + act(() => { + result.current.handleTouchStart(makeTouchEvent(400)); + result.current.handleTouchMove(makeTouchEvent(420)); + }); + expect(result.current.isAtBottomRef.current).toBe(false); + }); + + it('does not deactivate auto-scroll when a programmatic scrollToBottom fires its scroll event', () => { + const { result } = renderHook(() => + useChatScroll({ + messages: [], + isLoading: false, + editingMessageId: null, + currentSessionId: null, + }) + ); + + const container = makeContainer(1100, 100, 1200); + result.current.scrollContainerRef.current = container; + + act(() => { + result.current.handleScroll(); + }); + expect(result.current.isAtBottomRef.current).toBe(true); + + // Programmatic scroll to bottom, then the resulting scroll event. + act(() => { + result.current.scrollToBottom('auto'); + container.scrollTop = 1100; + result.current.handleScroll(); + }); + expect(result.current.isAtBottomRef.current).toBe(true); + }); + + it('deactivates when a coalesced scroll event delivers user position after a programmatic scroll', () => { + // Regression: browser coalesces programmatic scroll to 1100 with user's upward + // scroll to 700 into one event at 700. The hook must not treat that as "at bottom". + const { result } = renderHook(() => + useChatScroll({ + messages: [], + isLoading: false, + editingMessageId: null, + currentSessionId: null, + }) + ); + + const container = makeContainer(1100, 100, 1200); + result.current.scrollContainerRef.current = container; + + // Establish state at the bottom. + act(() => { + result.current.handleScroll(); + }); + expect(result.current.isAtBottomRef.current).toBe(true); + + // Programmatic scroll fires, browser coalesces with user's upward scroll → event at 700. + act(() => { + result.current.scrollToBottom('auto'); // sets isProgrammaticScrollRef=true + container.scrollTop = 700; // coalesced position + result.current.handleScroll(); // skipped — prevScrollTopRef stays at 1100 + }); + + // isProgrammaticScrollRef was consumed; next real scroll event at same position + // reveals the actual user position and deactivates auto-scroll. + act(() => { + result.current.handleScroll(); + }); + expect(result.current.isAtBottomRef.current).toBe(false); + }); +}); diff --git a/src/frontend/features/chat/hooks/useChatScroll.ts b/src/frontend/features/chat/hooks/useChatScroll.ts index e2530ae7..152615b3 100644 --- a/src/frontend/features/chat/hooks/useChatScroll.ts +++ b/src/frontend/features/chat/hooks/useChatScroll.ts @@ -9,7 +9,13 @@ * Purpose: Encapsulate chat scroll-to-bottom behavior and MutationObserver tracking. */ -import { useRef, useEffect } from 'react'; +import { + useRef, + useEffect, + useCallback, + type WheelEvent, + type TouchEvent, +} from 'react'; import { ChatMessage } from '../../../types'; interface UseChatScrollDeps { @@ -22,9 +28,19 @@ interface UseChatScrollDeps { interface UseChatScrollResult { scrollContainerRef: React.RefObject; handleScroll: () => void; + handleWheel: (event: WheelEvent) => void; + handleTouchStart: (event: TouchEvent) => void; + handleTouchMove: (event: TouchEvent) => void; scrollToBottom: (behavior?: ScrollBehavior) => void; + isAtBottomRef: React.MutableRefObject; } +/** + * Distance from the bottom (px) at or below which the viewport is considered + * "at the bottom" and auto-scroll re-attaches. + */ +const ATTACH_DISTANCE = 50; + /** Custom React hook that manages chat scroll. */ export function useChatScroll({ messages, @@ -34,21 +50,86 @@ export function useChatScroll({ }: UseChatScrollDeps): UseChatScrollResult { const scrollContainerRef = useRef(null); const isAtBottomRef = useRef(true); + const prevScrollTopRef = useRef(0); + const lastTouchYRef = useRef(null); + /** + * Set to true immediately before a programmatic scrollTo/scrollTop so that + * the resulting scroll event is skipped for user-intent detection. + * prevScrollTopRef is intentionally NOT updated on programmatic scrolls so + * that browser-coalesced user+programmatic events are handled correctly by + * the delta check. + */ + const isProgrammaticScrollRef = useRef(false); - const handleScroll = () => { - if (scrollContainerRef.current) { - const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; - // Consider "at bottom" if within 50px of the actual bottom to handle fast layouts - const isAtBottom = scrollHeight - scrollTop - clientHeight < 50; - isAtBottomRef.current = isAtBottom; - } - }; - - const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => { + const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => { if (!scrollContainerRef.current) return; const { scrollHeight } = scrollContainerRef.current; + if (behavior === 'auto' || behavior === 'instant') { + isProgrammaticScrollRef.current = true; + } scrollContainerRef.current.scrollTo({ top: scrollHeight, behavior }); - }; + }, []); + + const handleScroll = useCallback(() => { + if (!scrollContainerRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + const isAtBottom = distanceFromBottom < 24; + + // Skip direction logic for programmatic scrolls. + // prevScrollTopRef is intentionally NOT updated here. + if (isProgrammaticScrollRef.current) { + isProgrammaticScrollRef.current = false; + isAtBottomRef.current = isAtBottom; + return; + } + + const scrollDelta = scrollTop - prevScrollTopRef.current; + prevScrollTopRef.current = scrollTop; + + // Any upward user scroll immediately detaches auto-scroll. + if (scrollDelta < -2) { + isAtBottomRef.current = false; + } else if (distanceFromBottom < ATTACH_DISTANCE) { + // User scrolled down to near the bottom — re-attach auto-scroll. + isAtBottomRef.current = true; + } + }, []); + + const handleWheel = useCallback((event: WheelEvent) => { + if (event.deltaY < 0) { + isAtBottomRef.current = false; + } else if (event.deltaY > 0 && scrollContainerRef.current) { + const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + if (distanceFromBottom < ATTACH_DISTANCE + 80) { + isAtBottomRef.current = true; + } + } + }, []); + + const handleTouchStart = useCallback((event: TouchEvent) => { + lastTouchYRef.current = event.touches[0]?.clientY ?? null; + }, []); + + const handleTouchMove = useCallback((event: TouchEvent) => { + const currentY = event.touches[0]?.clientY ?? null; + const previousY = lastTouchYRef.current; + lastTouchYRef.current = currentY; + if (previousY === null || currentY === null) return; + + // Positive deltaY = finger moved down = content scrolled up = user wants to see above. + const deltaY = currentY - previousY; + if (deltaY > 2) { + isAtBottomRef.current = false; + } else if (deltaY < -2 && scrollContainerRef.current) { + const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + if (distanceFromBottom < ATTACH_DISTANCE + 80) { + isAtBottomRef.current = true; + } + } + }, []); useEffect(() => { const el = scrollContainerRef.current; @@ -79,13 +160,21 @@ export function useChatScroll({ observer.disconnect(); if (rafId !== null) cancelAnimationFrame(rafId); }; - }, [messages, isLoading, editingMessageId]); + }, [messages, isLoading, editingMessageId, scrollToBottom]); // Always scroll to bottom on session switch useEffect(() => { isAtBottomRef.current = true; scrollToBottom('auto'); - }, [currentSessionId]); + }, [currentSessionId, scrollToBottom]); - return { scrollContainerRef, handleScroll, scrollToBottom }; + return { + scrollContainerRef, + handleScroll, + handleWheel, + handleTouchStart, + handleTouchMove, + scrollToBottom, + isAtBottomRef, + }; } diff --git a/src/frontend/features/editor/Editor.tsx b/src/frontend/features/editor/Editor.tsx index 8fe2a712..8380ef46 100644 --- a/src/frontend/features/editor/Editor.tsx +++ b/src/frontend/features/editor/Editor.tsx @@ -196,8 +196,15 @@ export const Editor = React.memo( const streamingContent = useStoryStore((s: StoryStoreState) => s.streamingContent?.chapterId === chapter.id ? s.streamingContent.content : null ); + const streamingWriteMode = useStoryStore((s: StoryStoreState) => + s.streamingContent?.chapterId === chapter.id + ? (s.streamingContent.writeMode ?? 'append') + : null + ); const proseStreamingActive = (aiControls.isProseStreaming ?? false) || isChatStreaming; + const isReplaceStreaming = + proseStreamingActive && streamingWriteMode === 'replace'; // streamingModeActive keeps streamingMode=true even after active streaming // ends (frozen state) so the green prefix-diff stays visible. const streamingModeActive = proseStreamingActive || isChatStreamingFrozen; @@ -235,10 +242,27 @@ export const Editor = React.memo( // only this component re-renders — story.chapters stays untouched. useEffect(() => { if (streamingContent !== null) { + const container = scrollContainerRef.current; + const liveDistanceFromBottom = container + ? container.scrollHeight - container.scrollTop - container.clientHeight + : Number.POSITIVE_INFINITY; + const isLiveAtBottom = liveDistanceFromBottom <= 50; + + const shouldDeferStreamingChunk = + proseStreamingActive && + isDetachedFromBottomRef.current && + distanceFromBottomRef.current > 120 && + !isLiveAtBottom; + + // While detached from the bottom, freeze chunk-by-chunk updates so + // stream geometry changes cannot pull the viewport unexpectedly. + // Final content still syncs via chapter.content once streaming ends. + if (shouldDeferStreamingChunk) return; + localContentRef.current = streamingContent; setLocalContent(streamingContent); } - }, [streamingContent]); + }, [streamingContent, proseStreamingActive]); useEffect(() => { setLocalTitle(chapter.title); @@ -265,12 +289,16 @@ export const Editor = React.memo( const { scrollContainerRef, handleScroll, + handleWheel, + handleTouchStart, + handleTouchMove, scrollMainContentToBottom, isDetachedFromBottomRef, distanceFromBottomRef, } = useEditorScroll({ localContent, isProseStreaming, + isReplaceStreaming, chapterId: chapter.id, }); @@ -700,6 +728,9 @@ export const Editor = React.memo( className="flex-1 overflow-y-auto px-4 py-6 md:py-8 flex flex-col items-center relative" style={{ overflowAnchor: 'none' }} onScroll={handleScroll} + onWheel={handleWheel} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} > {isDragging && (
diff --git a/src/frontend/features/editor/hooks/useEditorScroll.test.ts b/src/frontend/features/editor/hooks/useEditorScroll.test.ts new file mode 100644 index 00000000..758e294c --- /dev/null +++ b/src/frontend/features/editor/hooks/useEditorScroll.test.ts @@ -0,0 +1,496 @@ +// Copyright (C) 2026 StableLlama +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +/** + * Purpose: Regression tests for editor scroll behavior while prose is streaming. + */ + +// @vitest-environment jsdom + +import { act, renderHook } from '@testing-library/react'; +import type { TouchEvent, WheelEvent } from 'react'; +import { beforeEach, describe, expect, it, vi, afterEach } from 'vitest'; + +import { useEditorScroll } from './useEditorScroll'; + +type ScrollHookResult = ReturnType; +type ScrollHookHarness = { + result: { current: ScrollHookResult }; + rerender: (props: Props) => void; +}; + +const makeWheelEvent = (deltaY: number): WheelEvent => + ({ deltaY }) as unknown as WheelEvent; + +const makeTouchEvent = (clientY: number): TouchEvent => + ({ + touches: [{ clientY }], + }) as unknown as TouchEvent; + +const makeContainer = ( + scrollTop: number, + clientHeight: number, + scrollHeight: number +): HTMLDivElement => { + const el = document.createElement('div'); + Object.defineProperties(el, { + clientHeight: { value: clientHeight, configurable: true }, + scrollHeight: { value: scrollHeight, configurable: true }, + }); + el.scrollTop = scrollTop; + return el; +}; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('useEditorScroll - detach/reattach intent', () => { + it('immediately detaches auto-scroll on an upward scroll event', () => { + const hook: { result: { current: ScrollHookResult } } = renderHook(() => + useEditorScroll({ localContent: '', isProseStreaming: true, chapterId: '1' }) + ); + + const container = makeContainer(1100, 100, 1200); + hook.result.current.scrollContainerRef.current = container; + + act(() => { + hook.result.current.handleScroll(); + vi.runOnlyPendingTimers(); + }); + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(false); + + container.scrollTop = 1050; + act(() => { + hook.result.current.handleScroll(); + vi.runOnlyPendingTimers(); + }); + + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(true); + }); + + it('detaches auto-scroll on an upward wheel interaction', () => { + const hook: { result: { current: ScrollHookResult } } = renderHook(() => + useEditorScroll({ localContent: '', isProseStreaming: true, chapterId: '1' }) + ); + + const container = makeContainer(1100, 100, 1200); + hook.result.current.scrollContainerRef.current = container; + + act(() => { + hook.result.current.handleWheel(makeWheelEvent(-10)); + }); + + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(true); + }); + + it('detaches auto-scroll on an upward touch gesture', () => { + const hook: { result: { current: ScrollHookResult } } = renderHook(() => + useEditorScroll({ localContent: '', isProseStreaming: true, chapterId: '1' }) + ); + + const container = makeContainer(1100, 100, 1200); + hook.result.current.scrollContainerRef.current = container; + + act(() => { + hook.result.current.handleTouchStart(makeTouchEvent(400)); + hook.result.current.handleTouchMove(makeTouchEvent(420)); + }); + + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(true); + }); + + it('detaches auto-scroll when scrolling up slightly from the bottom', () => { + const hook: { result: { current: ScrollHookResult } } = renderHook(() => + useEditorScroll({ localContent: '', isProseStreaming: true, chapterId: '1' }) + ); + + const container = makeContainer(1100, 100, 1200); + hook.result.current.scrollContainerRef.current = container; + + act(() => { + hook.result.current.handleScroll(); + vi.runOnlyPendingTimers(); + }); + + container.scrollTop = 1090; + act(() => { + hook.result.current.handleScroll(); + vi.runOnlyPendingTimers(); + }); + + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(true); + }); + + it('reattaches when currently at bottom-near position at chunk time', () => { + const hook: ScrollHookHarness<{ localContent: string }> = renderHook( + ({ localContent }: { localContent: string }) => + useEditorScroll({ localContent, isProseStreaming: true, chapterId: '1' }), + { + initialProps: { localContent: '' }, + } + ); + + const container = makeContainer(1100, 100, 1200); + hook.result.current.scrollContainerRef.current = container; + + // Establish baseline at bottom. + act(() => { + hook.result.current.handleScroll(); + }); + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(false); + + // User scrolls up very slowly (1px), still within attach distance. + container.scrollTop = 1099; + act(() => { + hook.result.current.handleScroll(); + }); + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(true); + + // New chunk arrives while currently still near the bottom. + // Auto-follow should reattach and keep bottom visibility. + act(() => { + hook.rerender({ localContent: 'chunk1' }); + }); + + expect(container.scrollTop).toBe(1099); + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(false); + }); + + it('reattaches auto-scroll when the user scrolls back down near the bottom', () => { + const hook: { result: { current: ScrollHookResult } } = renderHook(() => + useEditorScroll({ localContent: '', isProseStreaming: true, chapterId: '1' }) + ); + + const container = makeContainer(1100, 100, 1200); + hook.result.current.scrollContainerRef.current = container; + + act(() => { + hook.result.current.handleScroll(); + vi.runOnlyPendingTimers(); + }); + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(false); + + container.scrollTop = 1050; + act(() => { + hook.result.current.handleScroll(); + vi.runOnlyPendingTimers(); + }); + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(true); + + container.scrollTop = 1090; + act(() => { + hook.result.current.handleScroll(); + vi.runOnlyPendingTimers(); + }); + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(false); + }); + + it('keeps auto-scroll detached when content arrives after a user scroll without a pending scroll event', () => { + const hook: ScrollHookHarness<{ localContent: string }> = renderHook( + ({ localContent }: { localContent: string }) => + useEditorScroll({ localContent, isProseStreaming: true, chapterId: '1' }), + { + initialProps: { localContent: '' }, + } + ); + + const container = makeContainer(1100, 100, 1200); + hook.result.current.scrollContainerRef.current = container; + + act(() => { + hook.result.current.handleScroll(); + vi.runOnlyPendingTimers(); + }); + + // User scrolls up well away from the bottom before the scroll event fires. + // The position-based check at chunk time detects this directly. + container.scrollTop = 900; // distanceFromBottom = 200 > ATTACH_DISTANCE(50) + hook.rerender({ localContent: 'new chunk' }); + + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(true); + expect(container.scrollTop).toBe(900); + }); +}); + +describe('useEditorScroll - streaming follow behavior', () => { + it('does not detach auto-scroll when a programmatic scroll fires its scroll event', () => { + const hook: { result: { current: ScrollHookResult } } = renderHook(() => + useEditorScroll({ localContent: '', isProseStreaming: true, chapterId: '1' }) + ); + + const container = makeContainer(1100, 100, 1200); + hook.result.current.scrollContainerRef.current = container; + + // Establish prevScrollTop at the bottom. + act(() => { + hook.result.current.handleScroll(); + }); + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(false); + + // Simulate: auto-scroll pins to bottom (programmatic), then scroll event fires. + act(() => { + // pinToBottom would set isProgrammaticScrollRef; simulate the same. + hook.result.current.scrollMainContentToBottom(); + container.scrollTop = 1100; // already there + hook.result.current.handleScroll(); + }); + + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(false); + }); + + it('detaches when a coalesced scroll event delivers user position after a programmatic scroll', () => { + // Regression test for browser event coalescing: + // Auto-scroll programmatically moves to 1100 (bottom). The browser coalesces + // that with the user's upward scroll and fires ONE event at the user's + // position (700). Because isProgrammaticScrollRef is true the event is + // skipped without updating prevScrollTopRef. When the next chunk arrives, + // the position-based check sees distanceFromBottom = 400 > ATTACH_DISTANCE + // and immediately detaches without needing a scroll event at all. + const hook: ScrollHookHarness<{ content: string }> = renderHook( + ({ content }: { content: string }) => + useEditorScroll({ + localContent: content, + isProseStreaming: true, + chapterId: '1', + }), + { initialProps: { content: 'chunk1' } } + ); + + const container = makeContainer(1100, 100, 1200); + hook.result.current.scrollContainerRef.current = container; + + // Establish state at the bottom. + act(() => { + hook.result.current.handleScroll(); + }); + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(false); + + // Auto-scroll fires (programmatic flag set), but the browser coalesces it + // with a user upward scroll → single event fires at 700, not 1100. + act(() => { + hook.result.current.scrollMainContentToBottom(); // sets isProgrammaticScrollRef=true + container.scrollTop = 700; // coalesced position (user scrolled up) + hook.result.current.handleScroll(); // isProgrammaticScrollRef=true → event skipped + }); + + // Next chunk arrives: distanceFromBottom = 1200 - 700 - 100 = 400 > 50 → detach. + act(() => { + hook.rerender({ content: 'chunk2' }); + vi.runAllTimers(); + }); + + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(true); + expect(container.scrollTop).toBe(700); // viewport must NOT have been moved + }); + + it('does not force scroll to top when replace streaming begins; follows bottom like append mode', () => { + const hook: ScrollHookHarness<{ content: string; isReplace: boolean }> = renderHook( + ({ content, isReplace }: { content: string; isReplace: boolean }) => + useEditorScroll({ + localContent: content, + isProseStreaming: true, + isReplaceStreaming: isReplace, + chapterId: '1', + }), + { initialProps: { content: 'original', isReplace: false } } + ); + + // Container: user is at bottom (distanceFromBottom = 0). + const container = makeContainer(1100, 100, 1200); + hook.result.current.scrollContainerRef.current = container; + + act(() => { + hook.result.current.handleScroll(); + }); + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(false); + + // Replace stream begins — should NOT scroll to top; user was at bottom. + act(() => { + hook.rerender({ content: 'chunk1', isReplace: true }); + }); + // scrollTop must not have been changed to 0 + expect(container.scrollTop).not.toBe(0); + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(false); + }); + + it('keeps auto-scroll attached at bottom when append streaming chunk grows content significantly', () => { + const hook: ScrollHookHarness<{ content: string }> = renderHook( + ({ content }: { content: string }) => + useEditorScroll({ + localContent: content, + isProseStreaming: true, + isReplaceStreaming: false, + chapterId: '1', + }), + { initialProps: { content: 'chunk0' } } + ); + + const container = makeContainer(1100, 100, 1200); + hook.result.current.scrollContainerRef.current = container; + + // Start attached at bottom. + act(() => { + hook.result.current.handleScroll(); + }); + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(false); + + // New chunk increases document height by 100px (> ATTACH_DISTANCE). + Object.defineProperty(container, 'scrollHeight', { + value: 1300, + configurable: true, + }); + + act(() => { + hook.rerender({ content: 'chunk1' }); + }); + + // Must stay attached and pinned to the new bottom. + expect(container.scrollTop).toBe(1200); + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(false); + }); + + it('keeps auto-scroll attached at bottom when rewrite streaming chunk grows content significantly', () => { + const hook: ScrollHookHarness<{ content: string }> = renderHook( + ({ content }: { content: string }) => + useEditorScroll({ + localContent: content, + isProseStreaming: true, + isReplaceStreaming: true, + chapterId: '1', + }), + { initialProps: { content: 'chunk0' } } + ); + + const container = makeContainer(1100, 100, 1200); + hook.result.current.scrollContainerRef.current = container; + + // Start attached at bottom. + act(() => { + hook.result.current.handleScroll(); + }); + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(false); + + // New rewrite chunk increases height by 100px (> ATTACH_DISTANCE). + Object.defineProperty(container, 'scrollHeight', { + value: 1300, + configurable: true, + }); + + act(() => { + hook.rerender({ content: 'chunk1' }); + }); + + // Must stay attached and pinned to the new bottom. + expect(container.scrollTop).toBe(1200); + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(false); + }); +}); + +describe('useEditorScroll - replace streaming detached behavior', () => { + it('stays in place (does not scroll) when replace streaming begins and user is not at bottom', () => { + const hook: ScrollHookHarness<{ content: string; isReplace: boolean }> = renderHook( + ({ content, isReplace }: { content: string; isReplace: boolean }) => + useEditorScroll({ + localContent: content, + isProseStreaming: true, + isReplaceStreaming: isReplace, + chapterId: '1', + }), + { initialProps: { content: 'original', isReplace: false } } + ); + + // User scrolled up — far from bottom. + const container = makeContainer(700, 100, 1200); + hook.result.current.scrollContainerRef.current = container; + + act(() => { + hook.result.current.handleWheel(makeWheelEvent(-10)); + }); + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(true); + + // Replace stream begins — viewport must not move. + act(() => { + hook.rerender({ content: 'chunk1', isReplace: true }); + }); + expect(container.scrollTop).toBe(700); + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(true); + }); + + it('restores detached anchor position after temporary clamp during streaming', () => { + const hook: ScrollHookHarness<{ localContent: string }> = renderHook( + ({ localContent }: { localContent: string }) => + useEditorScroll({ localContent, isProseStreaming: true, chapterId: '1' }), + { + initialProps: { localContent: 'chunk0' }, + } + ); + + const container = makeContainer(700, 100, 1200); + hook.result.current.scrollContainerRef.current = container; + + // User detached in the middle. + act(() => { + hook.result.current.handleWheel(makeWheelEvent(-10)); + hook.result.current.handleScroll(); + }); + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(true); + expect(container.scrollTop).toBe(700); + + // Streaming update with temporarily short content; browser clamp equivalent. + Object.defineProperty(container, 'scrollHeight', { + value: 760, + configurable: true, + }); + container.scrollTop = 660; // max for current geometry + act(() => { + hook.rerender({ localContent: 'chunk1' }); + }); + expect(container.scrollTop).toBe(660); + + // Later chunk grows content again; anchored detached position should restore. + Object.defineProperty(container, 'scrollHeight', { + value: 1300, + configurable: true, + }); + act(() => { + hook.rerender({ localContent: 'chunk2' }); + }); + + expect(container.scrollTop).toBe(700); + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(true); + }); + + it('resets scroll and detach state on chapter switch', () => { + const hook: ScrollHookHarness<{ chapterId: string }> = renderHook( + ({ chapterId }: { chapterId: string }) => + useEditorScroll({ localContent: '', isProseStreaming: false, chapterId }), + { initialProps: { chapterId: '1' } } + ); + + const container = makeContainer(500, 100, 1200); + hook.result.current.scrollContainerRef.current = container; + + // Simulate being detached mid-scroll. + act(() => { + hook.result.current.handleWheel(makeWheelEvent(-10)); + }); + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(true); + + // Switch chapter. + act(() => { + hook.rerender({ chapterId: '2' }); + }); + + expect(container.scrollTop).toBe(0); + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(false); + }); +}); diff --git a/src/frontend/features/editor/hooks/useEditorScroll.ts b/src/frontend/features/editor/hooks/useEditorScroll.ts index 00ab8513..397f0338 100644 --- a/src/frontend/features/editor/hooks/useEditorScroll.ts +++ b/src/frontend/features/editor/hooks/useEditorScroll.ts @@ -11,20 +11,32 @@ * Design goals: * 1. At bottom → auto-scroll to follow new content. * 2. Not at bottom → never programmatically move the user's viewport. - * 3. No synthetic min-height locks (they create temporary blank space). * - * While prose streams and the user is NOT at bottom, we freeze editor text - * syncing (see localContent sync effect in Editor.tsx). This keeps scroll - * geometry stable and avoids jump-to-top/clamp artifacts. + * Auto-scroll decision is made by reading the live scroll position synchronously + * inside useLayoutEffect (before browser paint). This avoids all timing races + * with RAF-deferred scrolls and wheel/touch event coalescing. */ -import { useRef, useEffect, useLayoutEffect, useCallback } from 'react'; +import { + useRef, + useEffect, + useLayoutEffect, + useCallback, + type WheelEvent, + type TouchEvent, +} from 'react'; interface UseEditorScrollOptions { /** Current text content — triggers stream-follow on change. */ localContent: string; /** Whether LLM prose is actively streaming into the editor. */ isProseStreaming: boolean; + /** + * True when streaming is replacing the chapter content from scratch rather + * than appending. Retained for API compatibility but no longer changes scroll + * behaviour — the position-based check handles both modes uniformly. + */ + isReplaceStreaming?: boolean; /** Current chapter id — triggers scroll reset on chapter switch. */ chapterId: string | number; } @@ -32,6 +44,9 @@ interface UseEditorScrollOptions { export interface UseEditorScrollResult { scrollContainerRef: React.RefObject; handleScroll: () => void; + handleWheel: (event: WheelEvent) => void; + handleTouchStart: (event: TouchEvent) => void; + handleTouchMove: (event: TouchEvent) => void; scrollMainContentToBottom: () => void; /** Exposed so callers can check whether the user has scrolled away mid-stream. */ isDetachedFromBottomRef: React.MutableRefObject; @@ -39,158 +54,238 @@ export interface UseEditorScrollResult { distanceFromBottomRef: React.MutableRefObject; } +/** + * Distance from the bottom (px) at or below which the viewport is considered + * "at the bottom" and auto-scroll re-attaches. + */ +const ATTACH_DISTANCE = 50; + /** Custom React hook that manages editor scroll. */ export function useEditorScroll({ localContent, isProseStreaming, + isReplaceStreaming = false, chapterId, }: UseEditorScrollOptions): UseEditorScrollResult { const scrollContainerRef = useRef(null); - const isAtBottomRef = useRef(true); const isDetachedFromBottomRef = useRef(false); const distanceFromBottomRef = useRef(0); - const prevScrollTopRef = useRef(0); - const autoScrollRafRef = useRef(null); - const autoScrollSettleRafRef = useRef(null); - const scrollRafRef = useRef(null); + const detachedAnchorScrollTopRef = useRef(null); + const prevScrollTopRef = useRef(null); + const lastTouchYRef = useRef(null); + /** + * Set to true immediately before a programmatic scrollTop assignment so that + * the resulting scroll event is skipped for user-intent detection. + */ + const isProgrammaticScrollRef = useRef(false); - // Keep a stable ref to isProseStreaming so handleScroll (which has [] deps - // and cannot close over changing props) can read the current value. + // Keep a stable ref so useLayoutEffect can read the current value. const isProseStreamingRef = useRef(isProseStreaming); isProseStreamingRef.current = isProseStreaming; + // isReplaceStreaming is retained in the interface for callers but the hook + // no longer branches on it — the live position check handles both modes. + void isReplaceStreaming; + + /** + * Pin the container to its maximum scroll position. + * Marks the resulting scroll event as programmatic so it is not mistaken for + * a user gesture. + */ + const pinToBottom = useCallback((container: HTMLDivElement) => { + const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight); + if (Math.abs(maxScrollTop - container.scrollTop) > 1) { + isProgrammaticScrollRef.current = true; + container.scrollTop = maxScrollTop; + } + }, []); + const handleScroll = useCallback(() => { - if (scrollRafRef.current !== null) return; - scrollRafRef.current = requestAnimationFrame(() => { - scrollRafRef.current = null; - if (!scrollContainerRef.current) return; + if (!scrollContainerRef.current) return; + const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + + distanceFromBottomRef.current = distanceFromBottom; + + // Programmatic scrolls must not influence user-intent detection. + // prevScrollTopRef is intentionally NOT updated here. + if (isProgrammaticScrollRef.current) { + isProgrammaticScrollRef.current = false; + return; + } + + const prevScrollTop = prevScrollTopRef.current ?? scrollTop; + const scrollDelta = scrollTop - prevScrollTop; + prevScrollTopRef.current = scrollTop; + + if (scrollDelta < 0) { + isDetachedFromBottomRef.current = true; + detachedAnchorScrollTopRef.current = scrollTop; + } else if (scrollDelta > 0 && distanceFromBottom < ATTACH_DISTANCE) { + isDetachedFromBottomRef.current = false; + detachedAnchorScrollTopRef.current = null; + } else if (isDetachedFromBottomRef.current) { + // Keep anchor current while user scrolls in detached mode. + detachedAnchorScrollTopRef.current = scrollTop; + } + }, []); + + const handleWheel = useCallback((event: WheelEvent) => { + // Wheel fires before the DOM scroll updates — earliest possible signal of + // user intent. Primary detach trigger for the "first wheel tick" case where + // scrollTop hasn't changed yet when the next useLayoutEffect runs. + if (event.deltaY < 0) { + isDetachedFromBottomRef.current = true; + if (scrollContainerRef.current) { + detachedAnchorScrollTopRef.current = scrollContainerRef.current.scrollTop; + } + } else if (event.deltaY > 0 && scrollContainerRef.current) { const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; const distanceFromBottom = scrollHeight - scrollTop - clientHeight; - const scrollDelta = scrollTop - prevScrollTopRef.current; - prevScrollTopRef.current = scrollTop; - distanceFromBottomRef.current = distanceFromBottom; - const atBottom = distanceFromBottom < 24; - isAtBottomRef.current = atBottom; - - // Hysteresis prevents accidental detachment caused by tiny geometry - // fluctuations while streaming. Only a clear manual scroll-away should - // pause live content sync. - if (atBottom) { + if (distanceFromBottom < ATTACH_DISTANCE + 80) { isDetachedFromBottomRef.current = false; - } else if (scrollDelta < -2 && distanceFromBottom > 96) { - isDetachedFromBottomRef.current = true; - } else if (scrollDelta > 2 && distanceFromBottom < 240) { - // Reattach early when user scrolls back down near the end so - // streaming resumes before reaching exact bottom. + detachedAnchorScrollTopRef.current = null; + } + } + }, []); + + const handleTouchStart = useCallback((event: TouchEvent) => { + lastTouchYRef.current = event.touches[0]?.clientY ?? null; + }, []); + + const handleTouchMove = useCallback((event: TouchEvent) => { + const currentY = event.touches[0]?.clientY ?? null; + const previousY = lastTouchYRef.current; + lastTouchYRef.current = currentY; + if (previousY === null || currentY === null) return; + + const deltaY = currentY - previousY; + if (deltaY > 2) { + isDetachedFromBottomRef.current = true; + if (scrollContainerRef.current) { + detachedAnchorScrollTopRef.current = scrollContainerRef.current.scrollTop; + } + } else if (deltaY < -2 && scrollContainerRef.current) { + const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + if (distanceFromBottom < ATTACH_DISTANCE + 80) { isDetachedFromBottomRef.current = false; + detachedAnchorScrollTopRef.current = null; } - }); + } }, []); const scrollMainContentToBottom = useCallback(() => { const container = scrollContainerRef.current; if (!container) return; + isProgrammaticScrollRef.current = true; container.scrollTop = container.scrollHeight; + detachedAnchorScrollTopRef.current = null; }, []); - // Follow stream at bottom only. + /** + * Auto-scroll during streaming. + * + * Runs synchronously in the commit phase (before browser paint) so wheel + * events that fired before this render have already updated + * isDetachedFromBottomRef, and the live scrollTop reflects the user's actual + * position. No RAF is used, eliminating the timing window where a RAF could + * move the viewport after the wheel event set the detach flag. + */ useLayoutEffect(() => { - // Only auto-scroll during streaming — not on every user keystroke. if (!isProseStreamingRef.current) return; const container = scrollContainerRef.current; if (!container) return; - if (isDetachedFromBottomRef.current) return; // user intentionally scrolled away - - // At bottom: follow new content, but coalesce writes to one per frame. - if (autoScrollRafRef.current === null) { - autoScrollRafRef.current = window.requestAnimationFrame(() => { - autoScrollRafRef.current = null; - const activeContainer = scrollContainerRef.current; - if (!activeContainer || isDetachedFromBottomRef.current) return; - - const pinToBottom = () => { - const maxScrollTop = Math.max( - 0, - activeContainer.scrollHeight - activeContainer.clientHeight - ); - if (Math.abs(maxScrollTop - activeContainer.scrollTop) > 1) { - activeContainer.scrollTop = maxScrollTop; - } - }; - - pinToBottom(); - - // Paragraph boundaries can change final line-wrapping/height one - // frame later; repin once more to avoid visible down/up jitter. - if (autoScrollSettleRafRef.current !== null) { - window.cancelAnimationFrame(autoScrollSettleRafRef.current); - } - autoScrollSettleRafRef.current = window.requestAnimationFrame(() => { - autoScrollSettleRafRef.current = null; - const settledContainer = scrollContainerRef.current; - if (!settledContainer || isDetachedFromBottomRef.current) return; - const maxScrollTop = Math.max( - 0, - settledContainer.scrollHeight - settledContainer.clientHeight - ); - if (Math.abs(maxScrollTop - settledContainer.scrollTop) > 1) { - settledContainer.scrollTop = maxScrollTop; - } - distanceFromBottomRef.current = - settledContainer.scrollHeight - - settledContainer.scrollTop - - settledContainer.clientHeight; - prevScrollTopRef.current = settledContainer.scrollTop; - }); - - isAtBottomRef.current = true; - isDetachedFromBottomRef.current = false; - }); + const wasDetached = isDetachedFromBottomRef.current; + const previousDistanceFromBottom = distanceFromBottomRef.current; + const previousKnownScrollTop = prevScrollTopRef.current; + + // Primary guard: read the live scroll position right now. + // Content growth can increase this distance even when the user was at + // bottom before this chunk; preserve attached state across that case. + const distanceFromBottom = + container.scrollHeight - container.scrollTop - container.clientHeight; + distanceFromBottomRef.current = distanceFromBottom; + + // If currently at bottom, usually (re)attach and follow new content. + // Exception: detached mode with an anchor beyond the current max means the + // viewport is temporarily clamped by short content during replace streaming. + // Keep detached in that case and restore the anchor when content grows. + if (distanceFromBottom <= ATTACH_DISTANCE) { + const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight); + const anchorTop = detachedAnchorScrollTopRef.current; + const isDetachedClampCase = + wasDetached && anchorTop !== null && anchorTop > maxScrollTop + 1; + + if (isDetachedClampCase) { + isDetachedFromBottomRef.current = true; + return; + } + + isDetachedFromBottomRef.current = false; + detachedAnchorScrollTopRef.current = null; + pinToBottom(container); + return; + } + + // Keep auto-scroll attached across chunk growth if we were attached and + // previously at/near bottom, unless the user has already moved upward + // without a delivered scroll event. + const userLikelyMovedUpWithoutScrollEvent = + previousKnownScrollTop !== null && + container.scrollTop < previousKnownScrollTop - 1; + if ( + !wasDetached && + previousDistanceFromBottom <= ATTACH_DISTANCE && + !userLikelyMovedUpWithoutScrollEvent + ) { + isDetachedFromBottomRef.current = false; + detachedAnchorScrollTopRef.current = null; + pinToBottom(container); + return; + } + + // Detached mode: preserve viewport anchor and restore it when geometry + // temporarily clamps during replace streaming. + isDetachedFromBottomRef.current = true; + if (detachedAnchorScrollTopRef.current === null) { + detachedAnchorScrollTopRef.current = container.scrollTop; + } + + const anchorTop = detachedAnchorScrollTopRef.current; + if (anchorTop !== null) { + const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight); + const targetTop = Math.min(anchorTop, maxScrollTop); + if (Math.abs(container.scrollTop - targetTop) > 1) { + isProgrammaticScrollRef.current = true; + container.scrollTop = targetTop; + } } - }, [localContent]); + }, [localContent, pinToBottom]); // Chapter switch: reset scroll so the new chapter starts at the top. useLayoutEffect(() => { const container = scrollContainerRef.current; if (!container) return; - if (autoScrollRafRef.current !== null) { - window.cancelAnimationFrame(autoScrollRafRef.current); - autoScrollRafRef.current = null; - } - if (autoScrollSettleRafRef.current !== null) { - window.cancelAnimationFrame(autoScrollSettleRafRef.current); - autoScrollSettleRafRef.current = null; - } + isProgrammaticScrollRef.current = true; container.scrollTop = 0; - isAtBottomRef.current = true; isDetachedFromBottomRef.current = false; + detachedAnchorScrollTopRef.current = null; prevScrollTopRef.current = 0; distanceFromBottomRef.current = 0; }, [chapterId]); - // Cleanup all pending animation frames on unmount. - useEffect(() => { - return () => { - if (autoScrollRafRef.current !== null) { - window.cancelAnimationFrame(autoScrollRafRef.current); - autoScrollRafRef.current = null; - } - if (autoScrollSettleRafRef.current !== null) { - window.cancelAnimationFrame(autoScrollSettleRafRef.current); - autoScrollSettleRafRef.current = null; - } - if (scrollRafRef.current !== null) { - cancelAnimationFrame(scrollRafRef.current); - scrollRafRef.current = null; - } - }; - }, []); + // No cleanup needed (no pending animation frames). + useEffect(() => undefined, []); return { scrollContainerRef, handleScroll, + handleWheel, + handleTouchStart, + handleTouchMove, scrollMainContentToBottom, isDetachedFromBottomRef, distanceFromBottomRef, diff --git a/src/frontend/features/story/useAiActions.ts b/src/frontend/features/story/useAiActions.ts index b43cd247..cd692544 100644 --- a/src/frontend/features/story/useAiActions.ts +++ b/src/frontend/features/story/useAiActions.ts @@ -40,6 +40,11 @@ type UseAiActionsParams = { getErrorMessage: (error: unknown, fallback: string) => string; }; +type StreamedContent = { + chapterId: string; + content: string; +}; + const createPrefillStripper = (imposedActionPrefill: string, imposedHeadingPrefix: string) => (text: string): string => { @@ -115,8 +120,11 @@ function createStreamingPusher( action === 'extend' ? joinSuggestionToContent(baseContent, normalizedPartial) : normalizedPartial; + const writeMode = action === 'rewrite' ? 'replace' : 'append'; onLastStreamed({ chapterId, content: nextContent }); - useStoryStore.getState().setStreamingContent({ chapterId, content: nextContent }); + useStoryStore + .getState() + .setStreamingContent({ chapterId, content: nextContent, writeMode }); }; const pushProgress = (partial: string): void => { diff --git a/src/frontend/stores/storyStore.ts b/src/frontend/stores/storyStore.ts index e7ae91f4..490a72d9 100644 --- a/src/frontend/stores/storyStore.ts +++ b/src/frontend/stores/storyStore.ts @@ -88,8 +88,10 @@ export interface StoryStoreState { * story.chapters and triggering a full-app re-render cascade every chunk. * Cleared to null when streaming ends (success, cancel, or error). */ - streamingContent: { chapterId: string; content: string } | null; - setStreamingContent: (data: { chapterId: string; content: string } | null) => void; + streamingContent: { chapterId: string; content: string; writeMode?: string } | null; + setStreamingContent: ( + data: { chapterId: string; content: string; writeMode?: string } | null + ) => void; } // --------------------------------------------------------------------------- From f043c85bab2b64b96d7c3d71316aea50538f2c19 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Thu, 30 Apr 2026 16:06:24 +0200 Subject: [PATCH 13/48] Optimize diff view when the chat does multiple modifiactions --- openapi.json | 50 +++++ src/augmentedquill/models/search.py | 21 ++ .../services/chat/chat_tools/search_tools.py | 5 + .../services/search/replace_service.py | 204 ++++++++++++++---- src/frontend/App.tsx | 15 +- .../features/app/useAppChatRuntime.ts | 21 +- .../features/chapters/ChapterList.tsx | 42 ++-- .../features/chat/chatExecutionHelpers.ts | 27 ++- .../chat/mutationToolRegistry.test.ts | 97 +++++++++ .../features/chat/mutationToolRegistry.ts | 152 +++++++++++++ .../features/chat/useChatExecution.test.ts | 57 +++++ .../features/layout/sidebarIntents.ts | 47 ++-- src/frontend/services/apiTypes.ts | 6 + src/frontend/stores/uiStore.ts | 43 ++++ src/frontend/types/api.generated.ts | 31 +++ tests/unit/services/test_replace_service.py | 57 +++++ 16 files changed, 792 insertions(+), 83 deletions(-) create mode 100644 src/frontend/features/chat/mutationToolRegistry.test.ts diff --git a/openapi.json b/openapi.json index 938fdfaf..535577ff 100644 --- a/openapi.json +++ b/openapi.json @@ -5673,6 +5673,48 @@ "title": "ReplaceAllRequest", "description": "Request to replace all occurrences of a search query." }, + "ReplaceChangeLocation": { + "properties": { + "type": { + "type": "string", + "title": "Type", + "description": "One of: chapter, story, metadata, sourcebook, book" + }, + "target_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target Id", + "description": "Target identifier for the changed section, e.g. chapter ID or sourcebook entry name" + }, + "field": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Field", + "description": "Optional field name or metadata subfield affected by the replacement" + }, + "label": { + "type": "string", + "title": "Label", + "description": "Human-readable label for the changed section" + } + }, + "type": "object", + "required": ["type", "label"], + "title": "ReplaceChangeLocation", + "description": "Structured information about a single replaced section." + }, "ReplaceResponse": { "properties": { "replacements_made": { @@ -5688,6 +5730,14 @@ "type": "array", "title": "Changed Sections", "description": "Human-readable labels for each changed section" + }, + "changed_sections_meta": { + "items": { + "$ref": "#/components/schemas/ReplaceChangeLocation" + }, + "type": "array", + "title": "Changed Sections Meta", + "description": "Structured information for each changed section" } }, "type": "object", diff --git a/src/augmentedquill/models/search.py b/src/augmentedquill/models/search.py index 251f2af9..1bc72129 100644 --- a/src/augmentedquill/models/search.py +++ b/src/augmentedquill/models/search.py @@ -83,6 +83,23 @@ class SearchResultSection(BaseModel): matches: list[SearchMatch] = Field(default_factory=list) +class ReplaceChangeLocation(BaseModel): + """Structured information about a single replaced section.""" + + type: str = Field( + ..., description="One of: chapter, story, metadata, sourcebook, book" + ) + target_id: str | None = Field( + None, + description="Target identifier for the changed section, e.g. chapter ID or sourcebook entry name", + ) + field: str | None = Field( + None, + description="Optional field name or metadata subfield affected by the replacement", + ) + label: str = Field(..., description="Human-readable label for the changed section") + + class SearchResponse(BaseModel): """Top-level response for a search request.""" @@ -138,3 +155,7 @@ class ReplaceResponse(BaseModel): default_factory=list, description="Human-readable labels for each changed section", ) + changed_sections_meta: list[ReplaceChangeLocation] = Field( + default_factory=list, + description="Structured information for each changed section", + ) diff --git a/src/augmentedquill/services/chat/chat_tools/search_tools.py b/src/augmentedquill/services/chat/chat_tools/search_tools.py index 5af710d7..316689a6 100644 --- a/src/augmentedquill/services/chat/chat_tools/search_tools.py +++ b/src/augmentedquill/services/chat/chat_tools/search_tools.py @@ -175,8 +175,13 @@ async def replace_in_project( if result.replacements_made > 0: mutations["story_changed"] = True + if result.changed_sections_meta: + mutations["change_locations"] = [ + loc.dict() for loc in result.changed_sections_meta + ] return { "replacements_made": result.replacements_made, "changed_sections": result.changed_sections, + "change_locations": [loc.dict() for loc in result.changed_sections_meta], } diff --git a/src/augmentedquill/services/search/replace_service.py b/src/augmentedquill/services/search/replace_service.py index 6115bb54..8c8e5b78 100644 --- a/src/augmentedquill/services/search/replace_service.py +++ b/src/augmentedquill/services/search/replace_service.py @@ -19,8 +19,9 @@ from augmentedquill.models.search import ( ReplaceAllRequest, - ReplaceSingleRequest, + ReplaceChangeLocation, ReplaceResponse, + ReplaceSingleRequest, SearchScope, ) from augmentedquill.services.search.search_service import ( @@ -30,6 +31,20 @@ ) +def _make_change_location( + type: str, + target_id: str | None, + field: str | None, + label: str, +) -> ReplaceChangeLocation: + return ReplaceChangeLocation( + type=type, + target_id=target_id, + field=field, + label=label, + ) + + def _apply_replace( text: str, query: str, @@ -136,13 +151,13 @@ def _replace_in_chapter_content( is_regex: bool, is_phonetic: bool, match_index: int | None, -) -> tuple[int, str | None]: - """Replace in a single chapter's prose. Returns (count, label_or_None).""" +) -> tuple[int, str | None, ReplaceChangeLocation | None]: + """Replace in a single chapter's prose. Returns (count, label_or_None, location_or_None).""" from augmentedquill.services.projects.projects import write_chapter_content content = _read_chapter_content(chap_id) if not content: - return 0, None + return 0, None, None if match_index is None: new_content, count = _apply_replace( @@ -161,8 +176,13 @@ def _replace_in_chapter_content( if count > 0: write_chapter_content(chap_id, new_content) - return count, f"Chapter {chap_id} content" - return 0, None + label = f"Chapter {chap_id} content" + return ( + count, + label, + _make_change_location("chapter", str(chap_id), "content", label), + ) + return 0, None, None # ─── Chapter metadata ──────────────────────────────────────────────────────── @@ -179,7 +199,7 @@ def _replace_in_chapter_metadata( target_section_id: str | None = None, target_field: str | None = None, match_index: int | None = None, -) -> tuple[int, list[str]]: +) -> tuple[int, list[str], list[ReplaceChangeLocation]]: """Replace in chapter metadata fields across all (or a targeted) chapter.""" from augmentedquill.core.config import load_story_config, save_story_config @@ -187,7 +207,7 @@ def _replace_in_chapter_metadata( try: story = load_story_config(story_path) or {} except Exception: - return 0, [] + return 0, [], [] p_type = story.get("project_type", "novel") if p_type == "series": @@ -199,6 +219,7 @@ def _replace_in_chapter_metadata( total = 0 changed = [] + change_locations: list[ReplaceChangeLocation] = [] for idx, entry in enumerate(all_chapters): chap_id = chapter_ids[idx] if idx < len(chapter_ids) else idx + 1 @@ -231,7 +252,16 @@ def _replace_in_chapter_metadata( if count > 0: entry[field_key] = new_val total += count - changed.append(f"{title_label} {field_key}") + label = f"{title_label} {field_key}" + changed.append(label) + change_locations.append( + _make_change_location( + "metadata", + str(chap_id), + field_key, + label, + ) + ) for cidx, conflict in enumerate(entry.get("conflicts") or []): for sub_field in ["description", "resolution"]: @@ -258,12 +288,21 @@ def _replace_in_chapter_metadata( if count > 0: conflict[sub_field] = new_val total += count - changed.append(f"{title_label} conflict {cidx + 1} {sub_field}") + label = f"{title_label} conflict {cidx + 1} {sub_field}" + changed.append(label) + change_locations.append( + _make_change_location( + "metadata", + str(chap_id), + full_field, + label, + ) + ) if total > 0: save_story_config(story_path, story) - return total, changed + return total, changed, change_locations # ─── Story metadata ────────────────────────────────────────────────────────── @@ -279,7 +318,7 @@ def _replace_in_story_metadata( target_section_id: str | None = None, target_field: str | None = None, match_index: int | None = None, -) -> tuple[int, list[str]]: +) -> tuple[int, list[str], list[ReplaceChangeLocation]]: """Replace in story metadata.""" from augmentedquill.core.config import load_story_config, save_story_config @@ -287,10 +326,11 @@ def _replace_in_story_metadata( try: story = load_story_config(story_path) or {} except Exception: - return 0, [] + return 0, [], [] total = 0 changed = [] + change_locations: list[ReplaceChangeLocation] = [] story_fields = ["project_title", "story_summary", "notes", "private_notes"] for field_key in story_fields: @@ -318,7 +358,11 @@ def _replace_in_story_metadata( if count > 0: story[field_key] = new_val total += count - changed.append(f"Story {field_key}") + label = f"Story {field_key}" + changed.append(label) + change_locations.append( + _make_change_location("metadata", "story", field_key, label) + ) for cidx, conflict in enumerate(story.get("conflicts") or []): for sub_field in ["description", "resolution"]: @@ -347,7 +391,11 @@ def _replace_in_story_metadata( if count > 0: conflict[sub_field] = new_val total += count - changed.append(f"Story conflict {cidx + 1} {sub_field}") + label = f"Story conflict {cidx + 1} {sub_field}" + changed.append(label) + change_locations.append( + _make_change_location("metadata", "story", full_field, label) + ) # Series books metadata for book in story.get("books", []): @@ -379,12 +427,21 @@ def _replace_in_story_metadata( if count > 0: book[field_key] = new_val total += count - changed.append(f"Book '{book_title}' {field_key}") + label = f"Book '{book_title}' {field_key}" + changed.append(label) + change_locations.append( + _make_change_location( + "book", + str(book_id), + field_key, + label, + ) + ) if total > 0: save_story_config(story_path, story) - return total, changed + return total, changed, change_locations # ─── Sourcebook ────────────────────────────────────────────────────────────── @@ -400,7 +457,7 @@ def _replace_in_sourcebook( target_section_id: str | None = None, target_field: str | None = None, match_index: int | None = None, -) -> tuple[int, list[str]]: +) -> tuple[int, list[str], list[ReplaceChangeLocation]]: """Replace text in sourcebook entries in story.json.""" from augmentedquill.core.config import load_story_config, save_story_config @@ -408,11 +465,11 @@ def _replace_in_sourcebook( try: story = load_story_config(story_path) or {} except Exception: - return 0, [] + return 0, [], [] sourcebook = story.get("sourcebook") or {} if not isinstance(sourcebook, dict): - return 0, [] + return 0, [], [] global_rels = story.get("sourcebook_relations") or [] if not isinstance(global_rels, list): @@ -420,6 +477,7 @@ def _replace_in_sourcebook( total = 0 changed = [] + change_locations: list[ReplaceChangeLocation] = [] rename_map: dict[str, str] = {} for entry_key, entry_data in list(sourcebook.items()): @@ -460,14 +518,29 @@ def _replace_in_sourcebook( if count > 0: total += count - changed.append(f"Sourcebook '{entry_id}' {field_label}") if field_key == "name": new_name = new_val - if new_name != entry_key and new_name not in sourcebook: + label = f"Sourcebook '{new_name}' {field_label}" + target_id = new_name + if new_name != entry_id and new_name not in sourcebook: rename_map[entry_key] = new_name + else: + target_id = entry_id else: + label = f"Sourcebook '{entry_id}' {field_label}" + target_id = entry_id entry_data[field_key] = new_val + changed.append(label) + change_locations.append( + _make_change_location( + "sourcebook", + target_id, + field_key, + label, + ) + ) + if target_field in (None, "synonyms"): synonyms = entry_data.get("synonyms") or [] new_synonyms: list[str] = [] @@ -492,7 +565,16 @@ def _replace_in_sourcebook( if synonym_count > 0: entry_data["synonyms"] = new_synonyms total += synonym_count - changed.append(f"Sourcebook '{entry_id}' synonyms") + label = f"Sourcebook '{entry_id}' synonyms" + changed.append(label) + change_locations.append( + _make_change_location( + "sourcebook", + entry_id, + "synonyms", + label, + ) + ) if target_field in (None, "relations") or ( target_field is not None and target_field.startswith("relations[") @@ -547,7 +629,16 @@ def _replace_in_sourcebook( if count > 0: rel[rel_field] = new_val total += count - changed.append(f"Sourcebook '{entry_id}' relation {rel_label}") + label = f"Sourcebook '{entry_id}' relation {rel_label}" + changed.append(label) + change_locations.append( + _make_change_location( + "sourcebook", + entry_id, + field_path, + label, + ) + ) for old_name, new_name in rename_map.items(): if old_name in sourcebook and new_name not in sourcebook: @@ -557,13 +648,20 @@ def _replace_in_sourcebook( rel["source_id"] = new_name if rel.get("target_id") == old_name: rel["target_id"] = new_name + for location in change_locations: + if location.type == "sourcebook" and location.target_id == old_name: + location.target_id = new_name + location.label = location.label.replace( + f"Sourcebook '{old_name}'", + f"Sourcebook '{new_name}'", + ) if total > 0: story["sourcebook"] = sourcebook story["sourcebook_relations"] = global_rels save_story_config(story_path, story) - return total, changed + return total, changed, change_locations # ─── Public API ────────────────────────────────────────────────────────────── @@ -583,6 +681,7 @@ def replace_all(req: ReplaceAllRequest, active: Path) -> ReplaceResponse: scope = req.scope total = 0 changed: list[str] = [] + changed_locations: list[ReplaceChangeLocation] = [] chapter_ids = _get_all_chapter_ids() @@ -598,27 +697,38 @@ def replace_all(req: ReplaceAllRequest, active: Path) -> ReplaceResponse: else chapter_ids ) for chap_id in ids: - count, label = _replace_in_chapter_content( + count, label, location = _replace_in_chapter_content( chap_id, q, r, cs, rx, ph, match_index=None ) if count > 0 and label: total += count changed.append(label) + if location is not None: + changed_locations.append(location) if scope in (SearchScope.metadata, SearchScope.all): - n, labels = _replace_in_chapter_metadata(active, chapter_ids, q, r, cs, rx, ph) + n, labels, locations = _replace_in_chapter_metadata( + active, chapter_ids, q, r, cs, rx, ph + ) total += n changed.extend(labels) - n, labels = _replace_in_story_metadata(active, q, r, cs, rx, ph) + changed_locations.extend(locations) + n, labels, locations = _replace_in_story_metadata(active, q, r, cs, rx, ph) total += n changed.extend(labels) + changed_locations.extend(locations) if scope in (SearchScope.sourcebook, SearchScope.all): - n, labels = _replace_in_sourcebook(active, q, r, cs, rx, ph) + n, labels, locations = _replace_in_sourcebook(active, q, r, cs, rx, ph) total += n changed.extend(labels) + changed_locations.extend(locations) - return ReplaceResponse(replacements_made=total, changed_sections=changed) + return ReplaceResponse( + replacements_made=total, + changed_sections=changed, + changed_sections_meta=changed_locations, + ) def replace_single(req: ReplaceSingleRequest, active: Path) -> ReplaceResponse: @@ -640,15 +750,17 @@ def replace_single(req: ReplaceSingleRequest, active: Path) -> ReplaceResponse: chap_id = int(sec_id) except ValueError: return ReplaceResponse(replacements_made=0, changed_sections=[]) - count, label = _replace_in_chapter_content( + count, label, location = _replace_in_chapter_content( chap_id, q, r, cs, rx, ph, match_index=idx ) - if count and label: - return ReplaceResponse(replacements_made=count, changed_sections=[label]) - return ReplaceResponse(replacements_made=0, changed_sections=[]) + return ReplaceResponse( + replacements_made=count, + changed_sections=[label] if count and label else [], + changed_sections_meta=[location] if location is not None else [], + ) if sec_type == "chapter_metadata": - n, labels = _replace_in_chapter_metadata( + n, labels, locations = _replace_in_chapter_metadata( active, chapter_ids, q, @@ -660,10 +772,14 @@ def replace_single(req: ReplaceSingleRequest, active: Path) -> ReplaceResponse: target_field=field, match_index=idx, ) - return ReplaceResponse(replacements_made=n, changed_sections=labels) + return ReplaceResponse( + replacements_made=n, + changed_sections=labels, + changed_sections_meta=locations, + ) if sec_type == "story_metadata": - n, labels = _replace_in_story_metadata( + n, labels, locations = _replace_in_story_metadata( active, q, r, @@ -674,10 +790,14 @@ def replace_single(req: ReplaceSingleRequest, active: Path) -> ReplaceResponse: target_field=field, match_index=idx, ) - return ReplaceResponse(replacements_made=n, changed_sections=labels) + return ReplaceResponse( + replacements_made=n, + changed_sections=labels, + changed_sections_meta=locations, + ) if sec_type == "sourcebook": - n, labels = _replace_in_sourcebook( + n, labels, locations = _replace_in_sourcebook( active, q, r, @@ -688,6 +808,10 @@ def replace_single(req: ReplaceSingleRequest, active: Path) -> ReplaceResponse: target_field=field, match_index=idx, ) - return ReplaceResponse(replacements_made=n, changed_sections=labels) + return ReplaceResponse( + replacements_made=n, + changed_sections=labels, + changed_sections_meta=locations, + ) return ReplaceResponse(replacements_made=0, changed_sections=[]) diff --git a/src/frontend/App.tsx b/src/frontend/App.tsx index cd1c6bdf..41494042 100644 --- a/src/frontend/App.tsx +++ b/src/frontend/App.tsx @@ -181,10 +181,14 @@ const App: React.FC = () => { const { editorSettings, setEditorSettings, currentTheme, isLight } = useEditorPreferences(); - const { openAndExpandStory, openSourcebookEntryDialog, openStoryMetadataDialog } = - useSidebarIntents({ - setEditorSettings, - }); + const { + openAndExpandStory, + openSourcebookEntryDialog, + openStoryMetadataDialog, + openChapterMetadataDialog, + } = useSidebarIntents({ + setEditorSettings, + }); // Get Active LLM Configs — memoized so hooks that receive these as params // don't re-run unnecessarily when unrelated appSettings fields change. @@ -232,7 +236,6 @@ const App: React.FC = () => { handleDeleteAllChats, onUpdateScratchpad, onDeleteScratchpad, - refreshChatList, } = useAppChatRuntime({ storyId: story.id, @@ -254,6 +257,7 @@ const App: React.FC = () => { openAndExpandStory, openSourcebookEntryDialog, openStoryMetadataDialog, + openChapterMetadataDialog, }); // sessionMutations changes only when LLM tool calls complete (a few times per @@ -276,7 +280,6 @@ const App: React.FC = () => { setSuggestionMode, isSuggesting, isSuggestionMode, - suggestCursor, handleTriggerSuggestions, handleKeyboardSuggestionAction, handleAcceptContinuation, diff --git a/src/frontend/features/app/useAppChatRuntime.ts b/src/frontend/features/app/useAppChatRuntime.ts index 5fe175f2..ebcc2b35 100644 --- a/src/frontend/features/app/useAppChatRuntime.ts +++ b/src/frontend/features/app/useAppChatRuntime.ts @@ -59,6 +59,7 @@ type UseAppChatRuntimeParams = { openAndExpandStory: () => void; openSourcebookEntryDialog: (entryId: string) => void; openStoryMetadataDialog: (tab?: MetadataTab) => void; + openChapterMetadataDialog: (chapterId: string, initialTab?: MetadataTab) => void; }; type UseAppChatRuntimeResult = ReturnType & { @@ -100,6 +101,7 @@ export function useAppChatRuntime({ openAndExpandStory, openSourcebookEntryDialog, openStoryMetadataDialog, + openChapterMetadataDialog, }: UseAppChatRuntimeParams): UseAppChatRuntimeResult { // Setters are stable function references — use getState() to avoid subscribing // this hook (and its App.tsx caller) to every streaming token update. @@ -332,15 +334,26 @@ export function useAppChatRuntime({ startTransition(() => { requestAnimationFrame(() => { if (mutation.type === 'chapter') { + openAndExpandStory(); handleChapterSelect(mutation.targetId ?? null); } else if (mutation.type === 'story') { + openAndExpandStory(); handleChapterSelect(null); } else if (mutation.type === 'metadata') { - openStoryMetadataDialog(mutation.subType as MetadataTab); - } else if (mutation.type === 'sourcebook') { - openSourcebookEntryDialog(mutation.targetId ?? ''); - } else if (mutation.type === 'book') { openAndExpandStory(); + if (mutation.targetId && mutation.targetId !== 'story') { + handleChapterSelect(mutation.targetId); + openChapterMetadataDialog( + mutation.targetId, + mutation.subType as MetadataTab | undefined + ); + } else { + openStoryMetadataDialog(mutation.subType as MetadataTab | undefined); + } + } else if (mutation.type === 'sourcebook') { + if (mutation.targetId) { + openSourcebookEntryDialog(mutation.targetId); + } } }); }); diff --git a/src/frontend/features/chapters/ChapterList.tsx b/src/frontend/features/chapters/ChapterList.tsx index 1733fdbc..064c0609 100644 --- a/src/frontend/features/chapters/ChapterList.tsx +++ b/src/frontend/features/chapters/ChapterList.tsx @@ -16,6 +16,7 @@ import { MetadataParams } from '../story/metadataSync'; import { useConfirm } from '../layout/ConfirmDialogContext'; import { useThemeClasses } from '../layout/ThemeContext'; import { MetadataEditorDialog } from '../story/MetadataEditorDialog'; +import { useChapterMetadataDialog, useUIStore } from '../../stores/uiStore'; import { api } from '../../services/api'; import { diff_match_patch } from 'diff-match-patch'; import { @@ -43,7 +44,6 @@ interface ChapterListProps { ) => void; onUpdateBook?: (id: string, updates: Partial) => void; onCreate: (bookId?: string) => void; - onBookCreate?: (title: string) => void; onBookDelete?: (id: string) => void; onReorderChapters?: (chapterIds: number[], bookId?: string) => void; onReorderBooks?: (bookIds: string[]) => void; @@ -57,13 +57,14 @@ interface ChapterListProps { ) => Promise; isAiAvailable?: boolean; theme?: AppTheme; - onOpenImages?: () => void; + onBookCreate?: (title: string) => void; languages?: string[]; language?: string; baselineChapters?: Chapter[]; spellCheck?: boolean; } +/* eslint-disable complexity, max-lines-per-function */ export const ChapterList: React.FC = React.memo( ({ chapters, @@ -82,7 +83,6 @@ export const ChapterList: React.FC = React.memo( onAiAction, isAiAvailable = true, theme = 'mixed', - onOpenImages, languages = [], language, baselineChapters = [], @@ -201,24 +201,24 @@ export const ChapterList: React.FC = React.memo( id: string, index: number, bookId?: string - ) => { + ): void => { setDraggedItem({ type, id, bookId, originalIndex: index }); e.dataTransfer.effectAllowed = 'move'; }; - const handleDragEnter = (index: number, bookId?: string) => { + const handleDragEnter = (index: number, bookId?: string): void => { if (dragOverIndex !== index || (bookId && dragOverBookId !== bookId)) { setDragOverIndex(index); if (bookId) setDragOverBookId(bookId); } }; - const handleDragOver = (e: React.DragEvent) => { + const handleDragOver = (e: React.DragEvent): void => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }; - const handleDrop = (e: React.DragEvent) => { + const handleDrop = (e: React.DragEvent): void => { e.preventDefault(); const targetIdx = dragOverIndex; const dragged = draggedItem; @@ -269,13 +269,13 @@ export const ChapterList: React.FC = React.memo( setDraggedItem(null); }; - const handleDragEnd = () => { + const handleDragEnd = (): void => { setDraggedItem(null); setDragOverIndex(null); setDragOverBookId(null); }; - const toggleBook = (id: string) => { + const toggleBook = (id: string): void => { setExpandedBooks((prev: Record) => ({ ...prev, [id]: !prev[id], @@ -323,12 +323,24 @@ export const ChapterList: React.FC = React.memo( } }, [editingMetadata, displayChapters, displayBooks]); - const handleEditChapterMetadata = (e: React.MouseEvent, chapter: Chapter) => { + const chapterMetadataDialog = useChapterMetadataDialog(); + useEffect(() => { + if (!chapterMetadataDialog.isOpen || !chapterMetadataDialog.chapterId) { + return; + } + setEditingMetadata({ type: 'chapter', id: chapterMetadataDialog.chapterId }); + }, [ + chapterMetadataDialog.isOpen, + chapterMetadataDialog.chapterId, + chapterMetadataDialog.version, + ]); + + const handleEditChapterMetadata = (e: React.MouseEvent, chapter: Chapter): void => { e.stopPropagation(); setEditingMetadata({ type: 'chapter', id: chapter.id }); }; - const handleEditBookMetadata = (e: React.MouseEvent, book: Book) => { + const handleEditBookMetadata = (e: React.MouseEvent, book: Book): void => { e.stopPropagation(); setEditingMetadata({ type: 'book', id: book.id }); }; @@ -339,7 +351,7 @@ export const ChapterList: React.FC = React.memo( notes?: string; private_notes?: string; conflicts?: Chapter['conflicts']; - }) => { + }): Promise => { if (!editingMetadata || !activeEditingData) return; try { if (editingMetadata.type === 'chapter') { @@ -374,7 +386,7 @@ export const ChapterList: React.FC = React.memo( } }; - const renderChapter = (chapter: Chapter, index: number) => { + const renderChapter = (chapter: Chapter, index: number): JSX.Element => { const isDragging = draggedItem?.type === 'chapter' && draggedItem.id === chapter.id; @@ -383,7 +395,7 @@ export const ChapterList: React.FC = React.memo( ); const baselineSummary = baselineChapter?.summary || ''; - const renderSummary = () => { + const renderSummary = (): React.ReactNode => { const summary = chapter.summary || t('No summary available...'); if (!baselineSummary || baselineSummary === summary) { return {summary}; @@ -554,6 +566,7 @@ export const ChapterList: React.FC = React.memo( } setPendingMetadataUpdate(null); setEditingMetadata(null); + useUIStore.getState().closeChapterMetadataDialog(); }} theme={theme} aiDisabledReason={ @@ -824,3 +837,4 @@ export const ChapterList: React.FC = React.memo( ); } ); +/* eslint-enable complexity, max-lines-per-function */ diff --git a/src/frontend/features/chat/chatExecutionHelpers.ts b/src/frontend/features/chat/chatExecutionHelpers.ts index 6906bdf7..a991540b 100644 --- a/src/frontend/features/chat/chatExecutionHelpers.ts +++ b/src/frontend/features/chat/chatExecutionHelpers.ts @@ -325,7 +325,8 @@ const handleToolResponse = async ( batch_id: string; label: string; operation_count?: number; - }> + }>, + storyChangedState: { value: boolean } ): Promise<{ currentHistory: ChatMessage[]; currentMsgId: string; @@ -355,8 +356,7 @@ const handleToolResponse = async ( } if (toolResponse.mutations?.story_changed) { - await context.refreshProjects(); - await context.refreshStory(); + storyChangedState.value = true; } if (toolResponse.mutations) { @@ -424,7 +424,8 @@ const runToolCallLoop = async ( batch_id: string; label: string; operation_count?: number; - }> + }>, + storyChangedState: { value: boolean } ): Promise<{ currentHistory: ChatMessage[]; currentMsgId: string; @@ -476,7 +477,8 @@ const runToolCallLoop = async ( assistantMessage, currentHistory, currentMsgId, - accumulatedToolBatches + accumulatedToolBatches, + storyChangedState ); if (!nextState) break; @@ -547,18 +549,25 @@ const executeChatRequestImpl = async ( operation_count?: number; }> = []; + const storyChangedState = { value: false }; const loopResult = await runToolCallLoop( context, currentHistory, currentMsgId, result, - accumulatedToolBatches + accumulatedToolBatches, + storyChangedState ); currentHistory = loopResult.currentHistory; currentMsgId = loopResult.currentMsgId; result = loopResult.result; + if (storyChangedState.value) { + await context.refreshProjects(); + await context.refreshStory(); + } + if (accumulatedToolBatches.length > 0) { const entryLabel = accumulatedToolBatches.length === 1 @@ -575,6 +584,7 @@ const executeChatRequestImpl = async ( await context.pushExternalHistoryEntry?.({ label: entryLabel, + forceNewHistory: true, onUndo: async () => { for (const batch of [...accumulatedToolBatches].reverse()) { await api.chat.undoToolBatch(batch.batch_id); @@ -590,6 +600,11 @@ const executeChatRequestImpl = async ( await context.refreshStory(); }, }); + } else if (storyChangedState.value) { + await context.pushExternalHistoryEntry?.({ + label: 'AI tool changes', + forceNewHistory: true, + }); } const botMessage = context.createAssistantMessage(currentMsgId, { diff --git a/src/frontend/features/chat/mutationToolRegistry.test.ts b/src/frontend/features/chat/mutationToolRegistry.test.ts new file mode 100644 index 00000000..b74d4bc4 --- /dev/null +++ b/src/frontend/features/chat/mutationToolRegistry.test.ts @@ -0,0 +1,97 @@ +// Copyright (C) 2026 StableLlama +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +/** + * Purpose: Validate mutation tool registry mappings for chat tool events. + */ + +import { describe, expect, it } from 'vitest'; + +import { MUTATION_TOOL_REGISTRY } from './mutationToolRegistry'; + +describe('mutationToolRegistry', () => { + it('produces sensible mutations for replace_in_project change_locations', () => { + const factory = MUTATION_TOOL_REGISTRY.replace_in_project; + expect(factory).toBeDefined(); + + const mutations = factory({ + args: {}, + result: { + change_locations: [ + { + type: 'chapter', + target_id: '1', + field: 'summary', + label: 'Chapter 1 summary', + }, + { + type: 'sourcebook', + target_id: 'Fred', + field: 'description', + label: "Sourcebook 'Fred' Description", + }, + { + type: 'metadata', + target_id: 'story', + field: 'story_summary', + label: 'Story summary', + }, + ], + }, + }); + + expect(Array.isArray(mutations)).toBe(true); + expect(mutations).toHaveLength(3); + expect(mutations[0]).toMatchObject({ + type: 'chapter', + targetId: '1', + label: 'Chapter 1 summary', + }); + expect(mutations[1]).toMatchObject({ + type: 'sourcebook', + targetId: 'Fred', + label: "Sourcebook 'Fred' Description", + }); + expect(mutations[2]).toMatchObject({ + type: 'metadata', + label: 'Story summary', + subType: 'summary', + }); + }); + + it('falls back to changed_sections when change_locations are unavailable', () => { + const factory = MUTATION_TOOL_REGISTRY.replace_in_project; + const mutations = factory({ + args: {}, + result: { + changed_sections: [ + 'Chapter 1: The Dusty Discovery summary', + "Sourcebook 'Fred' Description", + 'Story summary', + ], + }, + }); + + expect(Array.isArray(mutations)).toBe(true); + expect(mutations).toHaveLength(3); + expect(mutations[0]).toMatchObject({ + type: 'chapter', + targetId: '1', + label: 'Chapter 1: The Dusty Discovery summary', + }); + expect(mutations[1]).toMatchObject({ + type: 'sourcebook', + targetId: 'Fred', + label: "Sourcebook 'Fred' Description", + }); + expect(mutations[2]).toMatchObject({ + type: 'metadata', + label: 'Story summary', + subType: 'summary', + }); + }); +}); diff --git a/src/frontend/features/chat/mutationToolRegistry.ts b/src/frontend/features/chat/mutationToolRegistry.ts index e6739219..3485e026 100644 --- a/src/frontend/features/chat/mutationToolRegistry.ts +++ b/src/frontend/features/chat/mutationToolRegistry.ts @@ -23,6 +23,13 @@ import { SessionMutation } from './components/MutationTags'; type MutCallResult = { args: Record; result: Record }; type MutFactory = (res: MutCallResult) => SessionMutation | SessionMutation[] | null; +type ReplaceChangeLocation = { + type: string; + target_id?: string; + field?: string; + label: string; +}; + /** Build sourcebook mutation. */ function buildSourcebookMutation( args: Record, @@ -171,4 +178,149 @@ export const MUTATION_TOOL_REGISTRY: Record = { targetId: bookId as string | undefined, }; }, + + replace_in_project: ({ result }: MutCallResult) => { + const changeLocations = Array.isArray(result.change_locations) + ? result.change_locations + : []; + const changedSections = Array.isArray(result.changed_sections) + ? result.changed_sections.map(String) + : []; + + const parseMetadataSubType = ( + field: string + ): SessionMutation['subType'] | undefined => { + const normalized = field.toLowerCase(); + if (normalized.endsWith('summary')) return 'summary'; + if (normalized.endsWith('notes')) return 'notes'; + if (normalized.endsWith('private_notes') || normalized.endsWith('private notes')) + return 'private'; + if (normalized.includes('conflict')) return 'conflicts'; + return undefined; + }; + + const mutations = changeLocations.map((location: ReplaceChangeLocation) => { + const targetId = location.target_id; + switch (location.type) { + case 'chapter': + return { + id: `chap-replace-${targetId ?? 'unknown'}-${Date.now()}-${Math.random()}`, + type: 'chapter' as const, + label: location.label, + targetId: targetId as string | undefined, + }; + case 'sourcebook': + return { + id: `sb-replace-${targetId ?? 'unknown'}-${Date.now()}-${Math.random()}`, + type: 'sourcebook' as const, + label: location.label, + targetId: targetId as string | undefined, + }; + case 'book': + return { + id: `book-replace-${targetId ?? 'unknown'}-${Date.now()}-${Math.random()}`, + type: 'book' as const, + label: location.label, + targetId: targetId as string | undefined, + }; + case 'metadata': { + const subType = location.field + ? parseMetadataSubType(location.field) + : parseMetadataSubType(location.label); + return { + id: `meta-replace-${Date.now()}-${Math.random()}`, + type: 'metadata' as const, + label: location.label, + targetId: location.target_id as string | undefined, + subType, + }; + } + case 'story': + return { + id: `story-replace-${Date.now()}-${Math.random()}`, + type: 'story' as const, + label: location.label, + }; + default: + return { + id: `story-replace-${Date.now()}-${Math.random()}`, + type: 'story' as const, + label: location.label, + }; + } + }); + + if (mutations.length > 0) { + return mutations; + } + + const fallbackMutations = changedSections.map((section: string) => { + const chapterMatch = section.match(/Chapter\s+(\d+)/i); + if (chapterMatch) { + return { + id: `chap-replace-${chapterMatch[1]}-${Date.now()}-${Math.random()}`, + type: 'chapter' as const, + label: section, + targetId: chapterMatch[1], + }; + } + + const sourcebookMatch = section.match(/Sourcebook\s+'([^']+)'/i); + if (sourcebookMatch) { + return { + id: `sb-replace-${sourcebookMatch[1]}-${Date.now()}-${Math.random()}`, + type: 'sourcebook' as const, + label: section, + targetId: sourcebookMatch[1], + }; + } + + const bookMatch = section.match(/Book\s+'([^']+)'/i); + if (bookMatch) { + return { + id: `book-replace-${bookMatch[1]}-${Date.now()}-${Math.random()}`, + type: 'book' as const, + label: section, + targetId: bookMatch[1], + }; + } + + const storyMatch = section.match(/^Story\s+(.*)$/i); + if (storyMatch) { + const subType = parseMetadataSubType(storyMatch[1]); + return { + id: `story-replace-${Date.now()}-${Math.random()}`, + type: subType ? ('metadata' as const) : ('story' as const), + label: section, + subType, + }; + } + + const inferredSubType = parseMetadataSubType(section); + if (inferredSubType) { + return { + id: `story-replace-${Date.now()}-${Math.random()}`, + type: 'metadata' as const, + label: section, + subType: inferredSubType, + }; + } + + return { + id: `story-replace-${Date.now()}-${Math.random()}`, + type: 'story' as const, + label: section, + }; + }); + + return fallbackMutations.length > 0 + ? fallbackMutations + : [ + { + id: `story-replace-${Date.now()}-${Math.random()}`, + type: 'story', + label: 'Project replace', + }, + ]; + }, }; diff --git a/src/frontend/features/chat/useChatExecution.test.ts b/src/frontend/features/chat/useChatExecution.test.ts index 0fa0adbc..3d7b3871 100644 --- a/src/frontend/features/chat/useChatExecution.test.ts +++ b/src/frontend/features/chat/useChatExecution.test.ts @@ -119,6 +119,8 @@ describe('useChatExecution', () => { }); expect(pushExternalHistoryEntry).toHaveBeenCalledTimes(1); + expect(refreshProjects).toHaveBeenCalledTimes(1); + expect(refreshStory).toHaveBeenCalledTimes(1); const entry = pushExternalHistoryEntry.mock.calls[0][0]; expect(entry.label).toContain('AI tools'); @@ -140,6 +142,61 @@ describe('useChatExecution', () => { expect(api.chat.redoToolBatch).toHaveBeenNthCalledWith(2, 'batch2'); }); + it('creates an external history entry for story_changed mutations without a tool_batch', async () => { + const refreshProjects = vi.fn().mockResolvedValue(undefined); + const refreshStory = vi.fn().mockResolvedValue(undefined); + const pushExternalHistoryEntry = vi.fn(); + + const sendMessageMock = vi + .fn() + .mockResolvedValueOnce({ + text: '', + functionCalls: [{ id: 'c1', name: 'write_story_summary', args: {} }], + }) + .mockResolvedValueOnce({ + text: 'Done', + functionCalls: [], + }); + + vi.mocked(createChatSession).mockReturnValue({ + sendMessage: sendMessageMock, + } as UnifiedChat); + + vi.mocked(api.chat.executeTools).mockResolvedValueOnce({ + ok: true, + appended_messages: [ + { content: 'ok', name: 'write_story_summary', tool_call_id: 'c1' }, + ], + mutations: { + story_changed: true, + }, + }); + + const { result } = renderHook(() => + useChatExecution({ + getSystemPrompt: () => 'system', + activeChatConfig: { model: 'test', temperature: 0.5 }, + isChatAvailable: true, + getAllowWebSearch: () => false, + currentChapterId: '1', + getCurrentChatId: () => 'chat-1', + currentChapter: { id: '1', title: 'Intro' }, + refreshProjects, + refreshStory, + pushExternalHistoryEntry, + requestToolCallLoopAccess: vi.fn().mockResolvedValue('unlimited'), + }) + ); + + await act(async () => { + await result.current.handleSendMessage('Update summary'); + }); + + expect(pushExternalHistoryEntry).toHaveBeenCalledTimes(1); + expect(pushExternalHistoryEntry.mock.calls[0][0].label).toBe('AI tool changes'); + expect(pushExternalHistoryEntry.mock.calls[0][0].forceNewHistory).toBe(true); + }); + it('passes attachments through to the chat session payload', async () => { const refreshProjects = vi.fn().mockResolvedValue(undefined); const refreshStory = vi.fn().mockResolvedValue(undefined); diff --git a/src/frontend/features/layout/sidebarIntents.ts b/src/frontend/features/layout/sidebarIntents.ts index 239862ae..905ab84c 100644 --- a/src/frontend/features/layout/sidebarIntents.ts +++ b/src/frontend/features/layout/sidebarIntents.ts @@ -19,35 +19,55 @@ interface UseSidebarIntentsParams { setEditorSettings: Dispatch>; } -export const useSidebarIntents = ({ setEditorSettings }: UseSidebarIntentsParams) => { +export const useSidebarIntents = ({ + setEditorSettings, +}: UseSidebarIntentsParams): { + openAndExpandStory: () => void; + openAndExpandSourcebook: () => void; + openStoryMetadataDialog: (initialTab?: MetadataTab) => void; + openChapterMetadataDialog: (chapterId: string, initialTab?: MetadataTab) => void; + openSourcebookEntryDialog: (entryId: string) => void; +} => { const setIsSidebarOpen = useUIStore((s: UIStoreState) => s.setIsSidebarOpen); - const openAndExpandStory = useCallback(() => { + const openAndExpandStory = useCallback((): void => { setIsSidebarOpen(true); - setEditorSettings((prev: EditorSettings) => ({ - ...prev, - sidebar: { ...prev.sidebar, isStoryCollapsed: false }, - })); + setEditorSettings( + (prev: EditorSettings): EditorSettings => ({ + ...prev, + sidebar: { ...prev.sidebar, isStoryCollapsed: false }, + }) + ); }, [setIsSidebarOpen, setEditorSettings]); - const openAndExpandSourcebook = useCallback(() => { + const openAndExpandSourcebook = useCallback((): void => { setIsSidebarOpen(true); - setEditorSettings((prev: EditorSettings) => ({ - ...prev, - sidebar: { ...prev.sidebar, isSourcebookCollapsed: false }, - })); + setEditorSettings( + (prev: EditorSettings): EditorSettings => ({ + ...prev, + sidebar: { ...prev.sidebar, isSourcebookCollapsed: false }, + }) + ); }, [setIsSidebarOpen, setEditorSettings]); const openStoryMetadataDialog = useCallback( - (initialTab?: MetadataTab) => { + (initialTab?: MetadataTab): void => { openAndExpandStory(); uiStoreActions.openMetadataDialog(initialTab); }, [openAndExpandStory] ); + const openChapterMetadataDialog = useCallback( + (chapterId: string, initialTab?: MetadataTab): void => { + openAndExpandStory(); + uiStoreActions.openChapterMetadataDialog(chapterId, initialTab); + }, + [openAndExpandStory] + ); + const openSourcebookEntryDialog = useCallback( - (entryId: string) => { + (entryId: string): void => { openAndExpandSourcebook(); uiStoreActions.openSourcebookDialog(entryId); }, @@ -58,6 +78,7 @@ export const useSidebarIntents = ({ setEditorSettings }: UseSidebarIntentsParams openAndExpandStory, openAndExpandSourcebook, openStoryMetadataDialog, + openChapterMetadataDialog, openSourcebookEntryDialog, }; }; diff --git a/src/frontend/services/apiTypes.ts b/src/frontend/services/apiTypes.ts index 58803115..7f0bcfa1 100644 --- a/src/frontend/services/apiTypes.ts +++ b/src/frontend/services/apiTypes.ts @@ -117,6 +117,12 @@ export interface ChatToolExecutionResponse { operation_count: number; label: string; }; + change_locations?: Array<{ + type: string; + target_id?: string; + field?: string; + label: string; + }>; }; } diff --git a/src/frontend/stores/uiStore.ts b/src/frontend/stores/uiStore.ts index 250706e6..a4962f78 100644 --- a/src/frontend/stores/uiStore.ts +++ b/src/frontend/stores/uiStore.ts @@ -35,6 +35,13 @@ export interface SourcebookDialogState { entryId: string | null; } +export interface ChapterMetadataDialogState { + isOpen: boolean; + version: number; + chapterId: string | null; + initialTab?: MetadataTab; +} + // --------------------------------------------------------------------------- // Store shape // --------------------------------------------------------------------------- @@ -51,6 +58,7 @@ export interface UIStoreState { // ── Dialog state (replaces trigger-counter pattern) ─────────────────────── metadataDialog: MetadataDialogState; sourcebookDialog: SourcebookDialogState; + chapterMetadataDialog: ChapterMetadataDialogState; // ── Editor UI flags ─────────────────────────────────────────────────────── viewMode: ViewMode; @@ -72,6 +80,8 @@ export interface UIStoreState { closeMetadataDialog: () => void; openSourcebookDialog: (entryId: string) => void; closeSourcebookDialog: () => void; + openChapterMetadataDialog: (chapterId: string, initialTab?: MetadataTab) => void; + closeChapterMetadataDialog: () => void; setViewMode: (mode: ViewMode | ((prev: ViewMode) => ViewMode)) => void; setShowWhitespace: (show: boolean | ((prev: boolean) => boolean)) => void; @@ -112,6 +122,12 @@ export const useUIStore = create()( // ── Dialogs (not persisted – reset on page load) ───────────────────── metadataDialog: { isOpen: false, version: 0 }, sourcebookDialog: { isOpen: false, version: 0, entryId: null }, + chapterMetadataDialog: { + isOpen: false, + version: 0, + chapterId: null, + initialTab: undefined, + }, // ── Editor UI flags (not persisted) ───────────────────────────────── viewMode: 'raw' as ViewMode, @@ -165,6 +181,19 @@ export const useUIStore = create()( set((s: UIStoreState) => ({ sourcebookDialog: { ...s.sourcebookDialog, isOpen: false }, })), + openChapterMetadataDialog: (chapterId: string, initialTab?: MetadataTab) => + set((s: UIStoreState) => ({ + chapterMetadataDialog: { + isOpen: true, + version: s.chapterMetadataDialog.version + 1, + chapterId, + initialTab, + }, + })), + closeChapterMetadataDialog: () => + set((s: UIStoreState) => ({ + chapterMetadataDialog: { ...s.chapterMetadataDialog, isOpen: false }, + })), // ── Editor UI actions ──────────────────────────────────────────────── setViewMode: (v: ViewMode | ((prev: ViewMode) => ViewMode)) => @@ -210,6 +239,11 @@ export function useSourcebookDialog(): SourcebookDialogState { return useUIStore((s: UIStoreState) => s.sourcebookDialog); } +/** Subscribe to chapter metadata dialog state only. */ +export function useChapterMetadataDialog(): ChapterMetadataDialogState { + return useUIStore((s: UIStoreState) => s.chapterMetadataDialog); +} + // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- @@ -225,6 +259,12 @@ export function resetUIStore(): void { isDebugLogsOpen: false, metadataDialog: { isOpen: false, version: 0 }, sourcebookDialog: { isOpen: false, version: 0, entryId: null }, + chapterMetadataDialog: { + isOpen: false, + version: 0, + chapterId: null, + initialTab: undefined, + }, viewMode: 'raw' as ViewMode, showWhitespace: false, activeFormats: [], @@ -246,4 +286,7 @@ export const uiStoreActions = { useUIStore.getState().openMetadataDialog(tab), openSourcebookDialog: (entryId: string) => useUIStore.getState().openSourcebookDialog(entryId), + openChapterMetadataDialog: (chapterId: string, initialTab?: MetadataTab) => + useUIStore.getState().openChapterMetadataDialog(chapterId, initialTab), + closeChapterMetadataDialog: () => useUIStore.getState().closeChapterMetadataDialog(), }; diff --git a/src/frontend/types/api.generated.ts b/src/frontend/types/api.generated.ts index 1333eb14..96f56bf1 100644 --- a/src/frontend/types/api.generated.ts +++ b/src/frontend/types/api.generated.ts @@ -2387,6 +2387,32 @@ export interface components { /** Active Chapter Id */ active_chapter_id?: number | null; }; + /** + * ReplaceChangeLocation + * @description Structured information about a single replaced section. + */ + ReplaceChangeLocation: { + /** + * Type + * @description One of: chapter, story, metadata, sourcebook, book + */ + type: string; + /** + * Target Id + * @description Target identifier for the changed section, e.g. chapter ID or sourcebook entry name + */ + target_id?: string | null; + /** + * Field + * @description Optional field name or metadata subfield affected by the replacement + */ + field?: string | null; + /** + * Label + * @description Human-readable label for the changed section + */ + label: string; + }; /** * ReplaceResponse * @description Result of a replace operation. @@ -2403,6 +2429,11 @@ export interface components { * @description Human-readable labels for each changed section */ changed_sections?: string[]; + /** + * Changed Sections Meta + * @description Structured information for each changed section + */ + changed_sections_meta?: components['schemas']['ReplaceChangeLocation'][]; }; /** * ReplaceSingleRequest diff --git a/tests/unit/services/test_replace_service.py b/tests/unit/services/test_replace_service.py index 2eaf5e06..cf1afab5 100644 --- a/tests/unit/services/test_replace_service.py +++ b/tests/unit/services/test_replace_service.py @@ -402,6 +402,63 @@ def test_replace_all_in_sourcebook_title_updates_relations(self): "Magic Blade", ) + def test_replace_all_in_sourcebook_title_change_location_uses_new_name(self): + active = self._make_and_select_project("replace_test_sourcebook_title_meta") + story_path = active / "story.json" + story = json.loads(story_path.read_text(encoding="utf-8")) + story["sourcebook"] = { + "Magic Sword": { + "description": "A legendary sword of fire.", + "category": "item", + "synonyms": [], + } + } + story_path.write_text(json.dumps(story, indent=2), encoding="utf-8") + + req = ReplaceAllRequest( + query="Sword", + scope=SearchScope.sourcebook, + case_sensitive=False, + is_regex=False, + is_phonetic=False, + active_chapter_id=None, + replacement="Blade", + ) + resp = replace_all(req, active) + self.assertEqual(resp.replacements_made, 2) + self.assertEqual(len(resp.changed_sections_meta), 2) + self.assertEqual( + {loc.target_id for loc in resp.changed_sections_meta}, {"Magic Blade"} + ) + self.assertEqual( + {loc.label for loc in resp.changed_sections_meta}, + {"Sourcebook 'Magic Blade' Name", "Sourcebook 'Magic Blade' Description"}, + ) + + def test_replace_all_in_chapter_summary_returns_chapter_location(self): + active = self._make_and_select_project("replace_test_chapter_summary_meta") + story_path = active / "story.json" + story = json.loads(story_path.read_text(encoding="utf-8")) + story["chapters"][0]["summary"] = "A lonely lighthouse." + story_path.write_text(json.dumps(story, indent=2), encoding="utf-8") + + req = ReplaceAllRequest( + query="lighthouse", + scope=SearchScope.all, + case_sensitive=False, + is_regex=False, + is_phonetic=False, + active_chapter_id=None, + replacement="beacon", + ) + resp = replace_all(req, active) + self.assertEqual(resp.replacements_made, 1) + self.assertEqual(len(resp.changed_sections_meta), 1) + self.assertEqual(resp.changed_sections_meta[0].type, "metadata") + self.assertEqual(resp.changed_sections_meta[0].target_id, "1") + self.assertEqual(resp.changed_sections_meta[0].field, "summary") + self.assertEqual(resp.changed_sections_meta[0].label, "Chapter One summary") + class TestReplaceSingle(TestCase): def setUp(self): From 516b6cef945240a14b8ea036107dbb5d6c7b7620 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Thu, 30 Apr 2026 19:45:12 +0200 Subject: [PATCH 14/48] Fix more display of changes --- openapi.json | 73 +++++++++++++++++++ src/augmentedquill/api/v1/chat.py | 73 ++++++++++++++++++- src/augmentedquill/models/chat.py | 6 ++ .../services/chat/chat_tools/search_tools.py | 4 +- .../services/search/replace_service.py | 10 ++- src/frontend/features/app/locales/de.ts | 8 ++ src/frontend/features/app/locales/en.ts | 8 ++ src/frontend/features/app/locales/es.ts | 8 ++ src/frontend/features/app/locales/fr.ts | 8 ++ .../features/app/useAppChatRuntime.ts | 1 + .../features/chat/chatExecutionHelpers.ts | 31 +++++++- .../features/chat/components/MutationTags.tsx | 4 +- .../chat/mutationToolRegistry.test.ts | 9 ++- src/frontend/features/story/useStory.ts | 50 ++++++++++--- src/frontend/services/api.ts | 3 + src/frontend/services/apiClients/chat.ts | 19 +++++ src/frontend/services/apiTypes.ts | 1 + src/frontend/stores/storyStore.ts | 6 +- src/frontend/types/api.generated.ts | 65 +++++++++++++++++ tests/unit/services/test_replace_service.py | 4 + 20 files changed, 366 insertions(+), 25 deletions(-) diff --git a/openapi.json b/openapi.json index 535577ff..6267aecb 100644 --- a/openapi.json +++ b/openapi.json @@ -2672,6 +2672,67 @@ } } }, + "/api/v1/projects/{project_name}/chat/tools/batches/{batch_id}/chapter-before/{chapter_id}": { + "get": { + "tags": ["Chat"], + "summary": "Api Chat Batch Chapter Before", + "description": "Return the pre-batch content of a chapter for diff-baseline restoration.\n\nUsed by the frontend to reconstruct the baseline state for chapters that\nwere not loaded in memory when an AI tool modified them.", + "operationId": "api_chat_batch_chapter_before_api_v1_projects__project_name__chat_tools_batches__batch_id__chapter_before__chapter_id__get", + "parameters": [ + { + "name": "batch_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Batch Id" + } + }, + { + "name": "chapter_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Chapter Id" + } + }, + { + "name": "project_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "Directory name of the project", + "title": "Project Name" + }, + "description": "Directory name of the project" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChapterBeforeContentResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/projects/{project_name}/chat/stream": { "post": { "tags": ["Chat"], @@ -3628,6 +3689,18 @@ "title": "BooksReorderRequest", "description": "Request body for reordering books." }, + "ChapterBeforeContentResponse": { + "properties": { + "content": { + "type": "string", + "title": "Content" + } + }, + "type": "object", + "required": ["content"], + "title": "ChapterBeforeContentResponse", + "description": "Response body for ``GET /api/v1/chat/tools/batches/{batch_id}/chapter-before/{chapter_id}``." + }, "ChapterContentUpdate": { "properties": { "content": { diff --git a/src/augmentedquill/api/v1/chat.py b/src/augmentedquill/api/v1/chat.py index 4b99cca1..b59499ab 100644 --- a/src/augmentedquill/api/v1/chat.py +++ b/src/augmentedquill/api/v1/chat.py @@ -11,6 +11,7 @@ """ import asyncio +import base64 import datetime import re import augmentedquill.services.llm.llm as llm @@ -61,6 +62,7 @@ from augmentedquill.models.chat import ( ChatInitialStateResponse, ChatToolBatchMutationResponse, + ChapterBeforeContentResponse, ChatListItem, ChatListResponse, ChatDetailResponse, @@ -156,24 +158,49 @@ def _snapshot_storage_dir(project_dir: Path, batch_id: str) -> Path: return _safe_child_path(project_dir, _CHAT_TOOL_BATCH_DIR, safe_batch_id) +def _compute_changed_chapter_ids( + project_dir: Path, + before: Dict[str, str], + after: Dict[str, str], +) -> list[int]: + """Return the virtual chapter IDs whose file content differs between snapshots.""" + from augmentedquill.services.chapters.chapter_helpers import _scan_chapter_files + + changed: list[int] = [] + for vid, abs_path in _scan_chapter_files(project_dir): + rel_path = str(abs_path.relative_to(project_dir)) + if before.get(rel_path) != after.get(rel_path): + changed.append(vid) + return changed + + def _store_chat_tool_batch_snapshot( project_dir: Path, batch_id: str, before_snapshot: Dict[str, str], after_snapshot: Dict[str, str], tool_names: list[str], -) -> Any: - """Persist before/after snapshots for reversible tool-call batches.""" +) -> list[int]: + """Persist before/after snapshots for reversible tool-call batches. + + Returns the list of changed chapter IDs so callers can include them in + the mutations payload without recomputing. + """ target_dir = _snapshot_storage_dir(project_dir, batch_id) target_dir.mkdir(parents=True, exist_ok=True) + changed_chapter_ids = _compute_changed_chapter_ids( + project_dir, before_snapshot, after_snapshot + ) metadata = { "batch_id": batch_id, "created_at": datetime.datetime.now().isoformat(), "tool_names": tool_names, + "changed_chapter_ids": changed_chapter_ids, "before": before_snapshot, "after": after_snapshot, } (target_dir / "batch.json").write_text(_json.dumps(metadata), encoding="utf-8") + return changed_chapter_ids def _load_chat_tool_batch_snapshot(project_dir: Path, batch_id: str) -> Dict[str, Any]: @@ -362,7 +389,7 @@ async def _watch_disconnect() -> None: and mutations.get("story_changed") ): after_snapshot = capture_project_snapshot(active_project_dir) - _store_chat_tool_batch_snapshot( + changed_chapter_ids = _store_chat_tool_batch_snapshot( active_project_dir, batch_id, before_snapshot, @@ -374,6 +401,7 @@ async def _watch_disconnect() -> None: "tool_names": tool_names, "operation_count": len(tool_names), "label": _build_chat_tool_batch_label(tool_names), + "changed_chapter_ids": changed_chapter_ids, } # Log tool execution if there were any @@ -426,6 +454,45 @@ async def api_chat_tools_redo( return ChatToolBatchMutationResponse(ok=True, batch_id=batch_id) +@router.get( + "/projects/{project_name}/chat/tools/batches/{batch_id}/chapter-before/{chapter_id}", + response_model=ChapterBeforeContentResponse, +) +async def api_chat_batch_chapter_before( + batch_id: str, chapter_id: int, project_dir: ProjectDep +) -> ChapterBeforeContentResponse: + """Return the pre-batch content of a chapter for diff-baseline restoration. + + Used by the frontend to reconstruct the baseline state for chapters that + were not loaded in memory when an AI tool modified them. + """ + from augmentedquill.services.chapters.chapter_helpers import _scan_chapter_files + + batch = _load_chat_tool_batch_snapshot(project_dir, batch_id) + before_snapshot: Dict[str, str] = batch.get("before") or {} + + chapter_files = _scan_chapter_files(project_dir) + rel_path: str | None = None + for vid, abs_path in chapter_files: + if vid == chapter_id: + rel_path = str(abs_path.relative_to(project_dir)) + break + + if rel_path is None: + raise HTTPException(status_code=404, detail="Chapter not found in project") + + content_b64 = before_snapshot.get(rel_path) + if content_b64 is None: + raise HTTPException( + status_code=404, detail="Chapter not found in batch before-snapshot" + ) + + content = base64.b64decode(content_b64.encode("ascii")).decode( + "utf-8", errors="replace" + ) + return ChapterBeforeContentResponse(content=content) + + @router.post("/projects/{project_name}/chat/stream") async def api_chat_stream( request: Request, project_dir: ProjectDep diff --git a/src/augmentedquill/models/chat.py b/src/augmentedquill/models/chat.py index 53cad225..821ee00d 100644 --- a/src/augmentedquill/models/chat.py +++ b/src/augmentedquill/models/chat.py @@ -43,6 +43,12 @@ class ChatToolBatchMutationResponse(BaseModel): detail: Optional[str] = None +class ChapterBeforeContentResponse(BaseModel): + """Response body for ``GET /api/v1/chat/tools/batches/{batch_id}/chapter-before/{chapter_id}``.""" + + content: str + + # --------------------------------------------------------------------------- # Chat session list / load # --------------------------------------------------------------------------- diff --git a/src/augmentedquill/services/chat/chat_tools/search_tools.py b/src/augmentedquill/services/chat/chat_tools/search_tools.py index 316689a6..6100c41d 100644 --- a/src/augmentedquill/services/chat/chat_tools/search_tools.py +++ b/src/augmentedquill/services/chat/chat_tools/search_tools.py @@ -177,11 +177,11 @@ async def replace_in_project( mutations["story_changed"] = True if result.changed_sections_meta: mutations["change_locations"] = [ - loc.dict() for loc in result.changed_sections_meta + loc.model_dump() for loc in result.changed_sections_meta ] return { "replacements_made": result.replacements_made, "changed_sections": result.changed_sections, - "change_locations": [loc.dict() for loc in result.changed_sections_meta], + "change_locations": [loc.model_dump() for loc in result.changed_sections_meta], } diff --git a/src/augmentedquill/services/search/replace_service.py b/src/augmentedquill/services/search/replace_service.py index 8c8e5b78..06d90c15 100644 --- a/src/augmentedquill/services/search/replace_service.py +++ b/src/augmentedquill/services/search/replace_service.py @@ -332,7 +332,13 @@ def _replace_in_story_metadata( changed = [] change_locations: list[ReplaceChangeLocation] = [] - story_fields = ["project_title", "story_summary", "notes", "private_notes"] + _story_field_labels: dict[str, str] = { + "project_title": "Story title", + "story_summary": "Story summary", + "notes": "Story notes", + "private_notes": "Story private notes", + } + story_fields = list(_story_field_labels.keys()) for field_key in story_fields: if target_field is not None and field_key != target_field: continue @@ -358,7 +364,7 @@ def _replace_in_story_metadata( if count > 0: story[field_key] = new_val total += count - label = f"Story {field_key}" + label = _story_field_labels[field_key] changed.append(label) change_locations.append( _make_change_location("metadata", "story", field_key, label) diff --git a/src/frontend/features/app/locales/de.ts b/src/frontend/features/app/locales/de.ts index 564ce4f7..7b5bdd27 100644 --- a/src/frontend/features/app/locales/de.ts +++ b/src/frontend/features/app/locales/de.ts @@ -479,5 +479,13 @@ export const de = { 'Model settings': 'Modelleinstellungen', Models: 'Modelle', 'Close model menu': 'Modellmenü schließen', + // MutationTags + 'Story prose': 'Geschichte (Text)', + 'Story title': 'Geschichte (Titel)', + 'Story summary': 'Geschichte (Zusammenfassung)', + 'Story notes': 'Geschichte (Notizen)', + 'Story private notes': 'Geschichte (Private Notizen)', + 'Chapter prose': 'Kapitel (Text)', + 'Project replace': 'Projekt-Ersetzung', }, }; diff --git a/src/frontend/features/app/locales/en.ts b/src/frontend/features/app/locales/en.ts index b773cdab..aa3f0c1b 100644 --- a/src/frontend/features/app/locales/en.ts +++ b/src/frontend/features/app/locales/en.ts @@ -479,5 +479,13 @@ export const en = { 'Model settings': 'Model settings', Models: 'Models', 'Close model menu': 'Close model menu', + // MutationTags + 'Story prose': 'Story prose', + 'Story title': 'Story title', + 'Story summary': 'Story summary', + 'Story notes': 'Story notes', + 'Story private notes': 'Story private notes', + 'Chapter prose': 'Chapter prose', + 'Project replace': 'Project replace', }, }; diff --git a/src/frontend/features/app/locales/es.ts b/src/frontend/features/app/locales/es.ts index fbaa0893..b765fec1 100644 --- a/src/frontend/features/app/locales/es.ts +++ b/src/frontend/features/app/locales/es.ts @@ -479,5 +479,13 @@ export const es = { 'Model settings': 'Configuración de modelos', Models: 'Modelos', 'Close model menu': 'Cerrar menú de modelos', + // MutationTags + 'Story prose': 'Historia (texto)', + 'Story title': 'Historia (título)', + 'Story summary': 'Historia (resumen)', + 'Story notes': 'Historia (notas)', + 'Story private notes': 'Historia (notas privadas)', + 'Chapter prose': 'Capítulo (texto)', + 'Project replace': 'Reemplazo en el proyecto', }, }; diff --git a/src/frontend/features/app/locales/fr.ts b/src/frontend/features/app/locales/fr.ts index 7e3b92f4..7b464eb8 100644 --- a/src/frontend/features/app/locales/fr.ts +++ b/src/frontend/features/app/locales/fr.ts @@ -486,5 +486,13 @@ export const fr = { 'Model settings': 'Paramètres des modèles', Models: 'Modèles', 'Close model menu': 'Fermer le menu des modèles', + // MutationTags + 'Story prose': 'Histoire (texte)', + 'Story title': 'Histoire (titre)', + 'Story summary': 'Histoire (résumé)', + 'Story notes': 'Histoire (notes)', + 'Story private notes': 'Histoire (notes privées)', + 'Chapter prose': 'Chapitre (texte)', + 'Project replace': 'Remplacement dans le projet', }, }; diff --git a/src/frontend/features/app/useAppChatRuntime.ts b/src/frontend/features/app/useAppChatRuntime.ts index ebcc2b35..0c5a11f1 100644 --- a/src/frontend/features/app/useAppChatRuntime.ts +++ b/src/frontend/features/app/useAppChatRuntime.ts @@ -361,6 +361,7 @@ export function useAppChatRuntime({ [ handleChapterSelect, openAndExpandStory, + openChapterMetadataDialog, openSourcebookEntryDialog, openStoryMetadataDialog, ] diff --git a/src/frontend/features/chat/chatExecutionHelpers.ts b/src/frontend/features/chat/chatExecutionHelpers.ts index a991540b..d075b82e 100644 --- a/src/frontend/features/chat/chatExecutionHelpers.ts +++ b/src/frontend/features/chat/chatExecutionHelpers.ts @@ -49,6 +49,7 @@ export type ExecuteChatRequestContext = { onUndo?: () => Promise; onRedo?: () => Promise; forceNewHistory?: boolean; + baselineChapterOverrides?: { id: string; content: string }[]; }) => void; setChatMessages: ( v: ChatMessage[] | ((prev: ChatMessage[]) => ChatMessage[]) @@ -325,6 +326,7 @@ const handleToolResponse = async ( batch_id: string; label: string; operation_count?: number; + changed_chapter_ids?: number[]; }>, storyChangedState: { value: boolean } ): Promise<{ @@ -372,6 +374,9 @@ const handleToolResponse = async ( batch_id: toolBatch.batch_id, label: toolBatch.label || `AI tools (${toolBatch.operation_count})`, operation_count: toolBatch.operation_count, + changed_chapter_ids: Array.isArray(toolBatch.changed_chapter_ids) + ? (toolBatch.changed_chapter_ids as number[]) + : undefined, }); } @@ -424,6 +429,7 @@ const runToolCallLoop = async ( batch_id: string; label: string; operation_count?: number; + changed_chapter_ids?: number[]; }>, storyChangedState: { value: boolean } ): Promise<{ @@ -547,8 +553,8 @@ const executeChatRequestImpl = async ( batch_id: string; label: string; operation_count?: number; + changed_chapter_ids?: number[]; }> = []; - const storyChangedState = { value: false }; const loopResult = await runToolCallLoop( context, @@ -568,6 +574,27 @@ const executeChatRequestImpl = async ( await context.refreshStory(); } + // Pre-fetch "before" content for chapters modified by AI tools so the diff + // baseline is accurate when the user switches to a not-yet-loaded chapter. + const baselineChapterOverrides: { id: string; content: string }[] = []; + if (storyChangedState.value && accumulatedToolBatches.length > 0) { + const seen = new Set(); + for (const batch of accumulatedToolBatches) { + if (!batch.changed_chapter_ids?.length) continue; + for (const chapterId of batch.changed_chapter_ids) { + if (seen.has(chapterId)) continue; + seen.add(chapterId); + const content = await api.chat.getChapterBeforeContent( + batch.batch_id, + chapterId + ); + if (content !== null) { + baselineChapterOverrides.push({ id: String(chapterId), content }); + } + } + } + } + if (accumulatedToolBatches.length > 0) { const entryLabel = accumulatedToolBatches.length === 1 @@ -578,6 +605,7 @@ const executeChatRequestImpl = async ( batch_id: string; label: string; operation_count?: number; + changed_chapter_ids?: number[]; }) => batch.label ) .join(', ')}`; @@ -585,6 +613,7 @@ const executeChatRequestImpl = async ( await context.pushExternalHistoryEntry?.({ label: entryLabel, forceNewHistory: true, + baselineChapterOverrides, onUndo: async () => { for (const batch of [...accumulatedToolBatches].reverse()) { await api.chat.undoToolBatch(batch.batch_id); diff --git a/src/frontend/features/chat/components/MutationTags.tsx b/src/frontend/features/chat/components/MutationTags.tsx index a439cc38..5645187c 100644 --- a/src/frontend/features/chat/components/MutationTags.tsx +++ b/src/frontend/features/chat/components/MutationTags.tsx @@ -10,6 +10,7 @@ */ import React from 'react'; +import { useTranslation } from 'react-i18next'; import { useTheme } from '../../layout/ThemeContext'; import { FileText, Book, Info, ScrollText, User } from 'lucide-react'; @@ -31,6 +32,7 @@ export const MutationTags: React.FC = ({ onMutationClick, }: MutationTagsProps) => { const { isLight } = useTheme(); + const { t } = useTranslation(); if (mutations.length === 0) return null; @@ -69,7 +71,7 @@ export const MutationTags: React.FC = ({ className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full border text-[10px] font-medium transition-colors ${bgClass} ${textClass} ${hoverClass}`} > {getIcon(m.type)} - {m.label} + {t(m.label)} ))}
diff --git a/src/frontend/features/chat/mutationToolRegistry.test.ts b/src/frontend/features/chat/mutationToolRegistry.test.ts index b74d4bc4..0030a98a 100644 --- a/src/frontend/features/chat/mutationToolRegistry.test.ts +++ b/src/frontend/features/chat/mutationToolRegistry.test.ts @@ -23,10 +23,10 @@ describe('mutationToolRegistry', () => { result: { change_locations: [ { - type: 'chapter', + type: 'metadata', target_id: '1', field: 'summary', - label: 'Chapter 1 summary', + label: 'Chapter 1: The Dusty Discovery summary', }, { type: 'sourcebook', @@ -47,9 +47,10 @@ describe('mutationToolRegistry', () => { expect(Array.isArray(mutations)).toBe(true); expect(mutations).toHaveLength(3); expect(mutations[0]).toMatchObject({ - type: 'chapter', + type: 'metadata', targetId: '1', - label: 'Chapter 1 summary', + subType: 'summary', + label: 'Chapter 1: The Dusty Discovery summary', }); expect(mutations[1]).toMatchObject({ type: 'sourcebook', diff --git a/src/frontend/features/story/useStory.ts b/src/frontend/features/story/useStory.ts index e07a5860..eb4fdd34 100644 --- a/src/frontend/features/story/useStory.ts +++ b/src/frontend/features/story/useStory.ts @@ -225,6 +225,11 @@ export const useStory = (dialogs: StoryDialogs = defaultDialogs) => { * (e.g. after patchSourcebook confirmed a diff), avoiding an expensive * full-story JSON.stringify. */ forceNewHistory?: boolean; + /** Pre-fetched "before" content for chapters that were not loaded in + * memory when the AI tool ran. Applied to the baseline so the diff + * view highlights changes in those chapters when the user navigates + * to them for the first time after an AI edit. */ + baselineChapterOverrides?: { id: string; content: string }[]; }) => { const history = historyRef.current; const currentIndex = currentIndexRef.current; @@ -282,6 +287,20 @@ export const useStory = (dialogs: StoryDialogs = defaultDialogs) => { ); const bounded = trimmed.slice(-MAX_HISTORY); const previousBaseline = history[currentIndex]?.state ?? updatedState; + // Apply any pre-fetched "before" chapter content into the baseline so the + // diff view can show accurate highlights for chapters that weren't loaded + // in memory when the AI tool ran. + const patchedBaseline = params.baselineChapterOverrides?.length + ? { + ...previousBaseline, + chapters: previousBaseline.chapters.map((ch: Chapter) => { + const override = params.baselineChapterOverrides!.find( + (o: { id: string; content: string }) => o.id === ch.id + ); + return override ? { ...ch, content: override.content } : ch; + }), + } + : previousBaseline; // Preserve the pre-update baseline for external history entries. This // keeps AI-generated prose and metadata changes highlighted until the // next user action advances the baseline. @@ -289,7 +308,7 @@ export const useStory = (dialogs: StoryDialogs = defaultDialogs) => { story: updatedState, history: bounded, currentIndex: bounded.length - 1, - baselineState: previousBaseline, + baselineState: patchedBaseline, }); latestStoryRef.current = updatedState; useStoryStore @@ -389,8 +408,18 @@ export const useStory = (dialogs: StoryDialogs = defaultDialogs) => { const baselineChapter = state.baselineState.chapters.find( (chapter: Chapter) => chapter.id === currentChapterId ); + // Only advance the baseline when its content is empty AND there is no + // pending AI diff. After an AI tool runs, pushExternalHistoryEntry sets + // history[currentIndex].state to the same object as story, while + // baselineState points to the older pre-AI snapshot. If we advanced the + // baseline here we would silently discard the pending diff for chapters + // that had not been loaded into memory at the time the AI ran. + const isAiDiffPending = + state.history[state.currentIndex]?.state !== state.baselineState; const shouldSyncBaseline = - baselineChapter !== undefined && (baselineChapter.content ?? '') === ''; + !isAiDiffPending && + baselineChapter !== undefined && + (baselineChapter.content ?? '') === ''; const updatedChapters = state.story.chapters.map((c: Chapter) => c.id === currentChapterId ? { @@ -405,6 +434,13 @@ export const useStory = (dialogs: StoryDialogs = defaultDialogs) => { : c ); + // Only back-fill `content` in the history entry: it is the sole + // field that is absent from the chapter-list payload and therefore + // genuinely lazy-loaded. All other fields (summary, notes, + // private_notes, conflicts, title) are already present in the + // history entry from the initial chapter-list load. Writing them + // here would overwrite the pre-AI values with AI-new values from + // the disk response, corrupting the diff baseline. const nextHistory = state.currentIndex === 0 && state.history.length === 1 ? state.history.map((entry: StoryHistoryEntry, idx: number) => @@ -416,15 +452,7 @@ export const useStory = (dialogs: StoryDialogs = defaultDialogs) => { ...entry.state, chapters: entry.state.chapters.map((chapter: Chapter) => chapter.id === currentChapterId - ? { - ...chapter, - content: res.content, - notes: res.notes ?? undefined, - private_notes: res.private_notes ?? undefined, - conflicts: (res.conflicts ?? []) as Conflict[], - title: res.title ?? undefined, - summary: res.summary ?? undefined, - } + ? { ...chapter, content: res.content } : chapter ), }, diff --git a/src/frontend/services/api.ts b/src/frontend/services/api.ts index 08554755..2654b96b 100644 --- a/src/frontend/services/api.ts +++ b/src/frontend/services/api.ts @@ -159,6 +159,9 @@ export const api = { currentProjectApi().chat.undoToolBatch(...args), redoToolBatch: (...args: Parameters) => currentProjectApi().chat.redoToolBatch(...args), + getChapterBeforeContent: ( + ...args: Parameters + ) => currentProjectApi().chat.getChapterBeforeContent(...args), }, sourcebook: { list: (...args: Parameters) => diff --git a/src/frontend/services/apiClients/chat.ts b/src/frontend/services/apiClients/chat.ts index 29e9b740..3fe22ef6 100644 --- a/src/frontend/services/apiClients/chat.ts +++ b/src/frontend/services/apiClients/chat.ts @@ -227,6 +227,25 @@ export const createChatApi = (projectName: string) => ({ 'Failed to redo AI tool batch' ); }, + + getChapterBeforeContent: async ( + batchId: string, + chapterId: number + ): Promise => { + try { + const res = await fetchJson<{ content: string }>( + projectEndpoint( + projectName, + `/chat/tools/batches/${encodeURIComponent(batchId)}/chapter-before/${chapterId}` + ), + undefined, + 'Failed to get chapter before content' + ); + return res.content ?? null; + } catch { + return null; + } + }, }); export const chatApi = createChatApi(''); diff --git a/src/frontend/services/apiTypes.ts b/src/frontend/services/apiTypes.ts index 7f0bcfa1..02308192 100644 --- a/src/frontend/services/apiTypes.ts +++ b/src/frontend/services/apiTypes.ts @@ -116,6 +116,7 @@ export interface ChatToolExecutionResponse { tool_names: string[]; operation_count: number; label: string; + changed_chapter_ids?: number[]; }; change_locations?: Array<{ type: string; diff --git a/src/frontend/stores/storyStore.ts b/src/frontend/stores/storyStore.ts index 490a72d9..717e6007 100644 --- a/src/frontend/stores/storyStore.ts +++ b/src/frontend/stores/storyStore.ts @@ -333,7 +333,11 @@ function chaptersStructuralEqual( if (a.length !== b.length) return false; return a.every( (ch: Chapter, i: number) => - ch.id === b[i].id && ch.title === b[i].title && ch.book_id === b[i].book_id + ch.id === b[i].id && + ch.title === b[i].title && + ch.book_id === b[i].book_id && + ch.summary === b[i].summary && + ch.notes === b[i].notes ); } diff --git a/src/frontend/types/api.generated.ts b/src/frontend/types/api.generated.ts index 96f56bf1..641d6263 100644 --- a/src/frontend/types/api.generated.ts +++ b/src/frontend/types/api.generated.ts @@ -1252,6 +1252,29 @@ export interface paths { patch?: never; trace?: never; }; + '/api/v1/projects/{project_name}/chat/tools/batches/{batch_id}/chapter-before/{chapter_id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Api Chat Batch Chapter Before + * @description Return the pre-batch content of a chapter for diff-baseline restoration. + * + * Used by the frontend to reconstruct the baseline state for chapters that + * were not loaded in memory when an AI tool modified them. + */ + get: operations['api_chat_batch_chapter_before_api_v1_projects__project_name__chat_tools_batches__batch_id__chapter_before__chapter_id__get']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/v1/projects/{project_name}/chat/stream': { parameters: { query?: never; @@ -1624,6 +1647,14 @@ export interface components { /** Book Ids */ book_ids: string[]; }; + /** + * ChapterBeforeContentResponse + * @description Response body for ``GET /api/v1/chat/tools/batches/{batch_id}/chapter-before/{chapter_id}``. + */ + ChapterBeforeContentResponse: { + /** Content */ + content: string; + }; /** * ChapterContentUpdate * @description Request body for updating chapter content. @@ -4915,6 +4946,40 @@ export interface operations { }; }; }; + api_chat_batch_chapter_before_api_v1_projects__project_name__chat_tools_batches__batch_id__chapter_before__chapter_id__get: { + parameters: { + query?: never; + header?: never; + path: { + batch_id: string; + chapter_id: number; + /** @description Directory name of the project */ + project_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ChapterBeforeContentResponse']; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['HTTPValidationError']; + }; + }; + }; + }; api_chat_stream_api_v1_projects__project_name__chat_stream_post: { parameters: { query?: never; diff --git a/tests/unit/services/test_replace_service.py b/tests/unit/services/test_replace_service.py index cf1afab5..5b6d2c78 100644 --- a/tests/unit/services/test_replace_service.py +++ b/tests/unit/services/test_replace_service.py @@ -459,6 +459,10 @@ def test_replace_all_in_chapter_summary_returns_chapter_location(self): self.assertEqual(resp.changed_sections_meta[0].field, "summary") self.assertEqual(resp.changed_sections_meta[0].label, "Chapter One summary") + # Verify the change was actually persisted to disk. + saved = json.loads(story_path.read_text(encoding="utf-8")) + self.assertEqual(saved["chapters"][0]["summary"], "A lonely beacon.") + class TestReplaceSingle(TestCase): def setUp(self): From 9255eb00c4b337b11eac1d017916dbe13ccbbb02 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Thu, 30 Apr 2026 19:54:06 +0200 Subject: [PATCH 15/48] Try to fix GitHub error --- src/frontend/features/chapters/ChapterList.tsx | 4 +++- src/frontend/features/chat/Chat.tsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/frontend/features/chapters/ChapterList.tsx b/src/frontend/features/chapters/ChapterList.tsx index 064c0609..2a6e7dab 100644 --- a/src/frontend/features/chapters/ChapterList.tsx +++ b/src/frontend/features/chapters/ChapterList.tsx @@ -58,6 +58,7 @@ interface ChapterListProps { isAiAvailable?: boolean; theme?: AppTheme; onBookCreate?: (title: string) => void; + onOpenImages?: () => void; languages?: string[]; language?: string; baselineChapters?: Chapter[]; @@ -85,6 +86,7 @@ export const ChapterList: React.FC = React.memo( theme = 'mixed', languages = [], language, + onOpenImages, baselineChapters = [], spellCheck = true, }: ChapterListProps) => { @@ -386,7 +388,7 @@ export const ChapterList: React.FC = React.memo( } }; - const renderChapter = (chapter: Chapter, index: number): JSX.Element => { + const renderChapter = (chapter: Chapter, index: number): React.JSX.Element => { const isDragging = draggedItem?.type === 'chapter' && draggedItem.id === chapter.id; diff --git a/src/frontend/features/chat/Chat.tsx b/src/frontend/features/chat/Chat.tsx index f9191457..c04e9118 100644 --- a/src/frontend/features/chat/Chat.tsx +++ b/src/frontend/features/chat/Chat.tsx @@ -30,7 +30,7 @@ import { useChatUIState } from './hooks/useChatUIState'; import { useChatMessages } from './hooks/useChatMessages'; /* eslint-disable max-lines-per-function, complexity */ -function ChatComponent(): JSX.Element { +function ChatComponent(): React.JSX.Element { const { messages, isLoading, From 53c56711ff4aadae3de78594e65a82a6fa946b5f Mon Sep 17 00:00:00 2001 From: StableLlama Date: Thu, 30 Apr 2026 22:24:18 +0200 Subject: [PATCH 16/48] Fix linter warnings --- src/frontend/App.tsx | 28 +- src/frontend/components/ui/Collapsible.tsx | 4 +- src/frontend/components/ui/Toast.tsx | 16 +- .../features/app/StoryDomainContext.tsx | 2 +- src/frontend/features/app/appSelectors.ts | 15 +- src/frontend/features/app/i18n.ts | 2 +- src/frontend/features/app/machinePayload.ts | 6 +- .../features/app/useAppChatRuntime.ts | 36 +- .../features/app/useAppControlProps.ts | 17 +- .../features/app/useAppSearchNavigation.ts | 18 +- .../features/app/useBrowserHistory.ts | 18 +- src/frontend/features/app/useEditorUIState.ts | 40 +- .../app/useSettingsPersistence.test.tsx | 2 +- .../features/app/useSettingsPersistence.ts | 6 +- src/frontend/features/app/useToolCallGate.ts | 6 +- src/frontend/features/app/useUIPanels.ts | 42 +- .../features/chapters/ChapterList.tsx | 94 +++-- .../chapters/useChapterSuggestions.ts | 84 ++-- src/frontend/features/chat/Chat.tsx | 14 +- src/frontend/features/chat/ModelSelector.tsx | 20 +- .../chat/ToolCallLimitDialog.test.tsx | 2 +- .../features/chat/ToolCallLimitDialog.tsx | 8 +- .../features/chat/chatContextBudget.ts | 299 ++++++++------ .../features/chat/chatExecutionHelpers.ts | 174 ++++---- .../features/chat/components/ChatComposer.tsx | 42 +- .../features/chat/components/ChatHeader.tsx | 120 ++++-- .../chat/components/ChatHistoryPanel.tsx | 8 +- .../chat/components/ChatMessageItem.tsx | 18 +- .../chat/components/ChatScratchpadDialog.tsx | 8 +- .../chat/components/ChatSystemPromptPanel.tsx | 10 +- .../features/chat/components/MutationTags.tsx | 8 +- .../features/chat/hooks/useChatEditing.ts | 6 +- .../features/chat/hooks/useChatMessages.ts | 10 +- .../features/chat/hooks/useChatScroll.ts | 20 +- .../features/chat/hooks/useChatUIState.ts | 12 +- .../features/chat/mutationToolRegistry.ts | 73 +++- .../features/chat/useChatExecution.ts | 2 +- .../features/chat/useChatMessageActions.ts | 15 +- .../features/chat/useChatSessionManagement.ts | 89 ++-- .../checkpoints/CheckpointsMenu.test.tsx | 1 - .../features/checkpoints/CheckpointsMenu.tsx | 33 +- src/frontend/features/debug/DebugLogs.tsx | 39 +- .../features/editor/CodeMirrorEditor.tsx | 35 +- .../features/editor/Editor.sync.test.tsx | 2 +- src/frontend/features/editor/Editor.tsx | 50 +-- .../features/editor/EditorMobileToolbar.tsx | 4 +- .../features/editor/EditorSuggestionPanel.tsx | 9 +- .../editor/HeaderAppearanceControls.tsx | 34 +- src/frontend/features/editor/MarkdownView.tsx | 54 ++- .../features/editor/codeMirrorDiffPlugin.ts | 4 +- .../features/editor/codeMirrorKeymap.ts | 22 +- .../editor/codeMirrorWhitespacePlugin.ts | 7 +- .../editor/hooks/useEditorFormatting.ts | 8 +- .../features/editor/hooks/useEditorScroll.ts | 18 +- .../features/editor/markdownDecorations.ts | 6 +- .../features/editor/markdownToolbarUtils.ts | 33 +- src/frontend/features/editor/turndown.ts | 42 +- .../features/editor/useAppUiActions.ts | 46 +-- .../features/editor/useEditorPreferences.ts | 4 +- src/frontend/features/layout/AppChatPanel.tsx | 4 +- src/frontend/features/layout/AppDialogs.tsx | 38 +- src/frontend/features/layout/AppHeader.tsx | 24 +- src/frontend/features/layout/AppLayout.tsx | 4 +- .../features/layout/AppMainLayout.tsx | 4 +- src/frontend/features/layout/AppSidebar.tsx | 14 +- .../features/layout/CollapsibleSection.tsx | 332 +++++++++------ .../features/layout/ConfirmDialog.test.tsx | 4 +- .../features/layout/ConfirmDialogContext.tsx | 6 +- .../features/layout/ThemeContext.test.tsx | 6 +- .../layout/header/HeaderCenterControls.tsx | 112 +++--- .../features/layout/sidebarIntents.ts | 5 +- .../features/layout/useConfirmDialog.ts | 38 +- .../features/layout/useFocusTrap.test.tsx | 4 +- src/frontend/features/layout/useFocusTrap.ts | 8 +- .../features/projects/CreateProjectDialog.tsx | 22 +- .../features/projects/ProjectImages.tsx | 129 +++--- .../projects/hooks/useImageGeneration.ts | 106 +++-- .../features/projects/hooks/useImageUpload.ts | 37 +- .../features/projects/useProjectManagement.ts | 131 +++--- .../search/SearchHighlightContext.tsx | 12 +- .../features/search/SearchReplaceDialog.tsx | 126 ++++-- .../features/search/useSearchReplace.ts | 45 ++- .../features/settings/SettingsDialog.tsx | 12 +- .../features/settings/providerAdapter.ts | 104 +++-- .../settings/settings/ProviderConfigForm.tsx | 149 ++++--- .../settings/settings/ProviderListPanel.tsx | 6 +- .../settings/settings/SettingsMachine.tsx | 2 +- .../settings/settings/SettingsProjects.tsx | 40 +- .../settings/settings/SettingsPrompts.tsx | 45 ++- .../features/settings/useAppSettings.test.tsx | 2 +- .../features/settings/useAppSettings.ts | 164 ++++---- src/frontend/features/settings/usePrompts.ts | 6 +- .../features/settings/useProviderHealth.ts | 379 +++++++++++------- .../useSettingsDialogProviderState.ts | 53 ++- .../useSettingsDialogProviderValidation.ts | 162 +++++--- .../sourcebook/SourcebookEntryDialog.tsx | 41 +- .../SourcebookEntryDialogSections.tsx | 26 +- .../sourcebook/SourcebookEntryRow.tsx | 8 +- .../sourcebook/SourcebookHoverCard.tsx | 9 +- .../features/sourcebook/SourcebookList.tsx | 40 +- .../sourcebook/SourcebookListView.tsx | 12 +- .../sourcebook/SourcebookRelationDialog.tsx | 49 +-- .../hooks/useSourcebookDialogLifecycle.ts | 22 +- .../hooks/useSourcebookEntryInteractions.ts | 15 +- .../sourcebookExternalEntries.test.ts | 10 +- .../features/sourcebook/sourcebookUtils.ts | 17 +- .../sourcebook/useSourcebookEntryData.ts | 32 +- .../useSourcebookEntryDialogState.ts | 79 +++- .../sourcebook/useSourcebookEntryHistory.ts | 19 +- .../sourcebook/useSourcebookListMutations.ts | 52 ++- .../sourcebook/useSourcebookRelationData.ts | 17 +- .../story/MetadataEditorDialog.test.tsx | 10 +- .../features/story/MetadataEditorDialog.tsx | 10 +- .../story/MetadataEditorDialogView.tsx | 59 +-- src/frontend/features/story/StoryMetadata.tsx | 2 +- src/frontend/features/story/storyMappers.ts | 12 +- src/frontend/features/story/useAiActions.ts | 15 +- .../features/story/useCurrentWritingUnit.ts | 4 +- .../story/useMetadataDialogHistory.ts | 30 +- .../story/useMetadataEditorDialogState.ts | 72 ++-- src/frontend/features/story/useStory.ts | 263 ++++++------ src/frontend/services/api.ts | 132 ++++-- src/frontend/services/apiClients/books.ts | 28 +- src/frontend/services/apiClients/chapters.ts | 58 ++- src/frontend/services/apiClients/chat.ts | 292 ++++++++++---- .../services/apiClients/checkpoints.ts | 9 +- src/frontend/services/apiClients/debug.ts | 2 +- src/frontend/services/apiClients/machine.ts | 10 +- src/frontend/services/apiClients/projects.ts | 33 +- src/frontend/services/apiClients/search.ts | 8 +- src/frontend/services/apiClients/settings.ts | 12 +- .../services/apiClients/sourcebook.ts | 34 +- src/frontend/services/apiClients/story.ts | 52 ++- .../services/apiClients/testSharedMocks.ts | 2 +- src/frontend/services/openaiService.ts | 19 +- src/frontend/stores/chatStore.ts | 44 +- src/frontend/stores/storyStore.ts | 74 ++-- src/frontend/stores/uiStore.ts | 130 ++++-- src/frontend/utils/hooks.ts | 8 +- src/frontend/utils/mountedRef.ts | 2 +- src/frontend/utils/textUtils.test.ts | 1 - src/frontend/utils/textUtils.ts | 6 +- src/frontend/vite.config.ts | 10 +- 143 files changed, 3659 insertions(+), 2412 deletions(-) diff --git a/src/frontend/App.tsx b/src/frontend/App.tsx index 41494042..b175adb3 100644 --- a/src/frontend/App.tsx +++ b/src/frontend/App.tsx @@ -53,8 +53,8 @@ const App: React.FC = () => { useConfirmDialog(); const addToast = useToast(); - useEffect(() => { - setErrorDispatcher((msg: string) => addToast(msg, 'error')); + useEffect((): void => { + setErrorDispatcher((msg: string): void => addToast(msg, 'error')); }, [addToast]); const { @@ -85,7 +85,7 @@ const App: React.FC = () => { advanceBaselineToCurrentStory, patchSourcebook, isChapterLoading, - } = useStory({ confirm, alert: (msg: string) => void alert(msg) }); + } = useStory({ confirm, alert: (msg: string): undefined => void alert(msg) }); // Stable ref to avoid recreating callbacks that read story state during // streaming (e.g. onProseChunk). @@ -134,11 +134,12 @@ const App: React.FC = () => { }); const roleAvailability = useMemo( - () => resolveRoleAvailability(appSettings, modelConnectionStatus), + (): { writing: boolean; editing: boolean; chat: boolean } => + resolveRoleAvailability(appSettings, modelConnectionStatus), [appSettings, modelConnectionStatus] ); const imageActionsAvailable = useMemo( - () => + (): boolean => supportsImageActions(appSettings, detectedCapabilities, modelConnectionStatus), [appSettings, detectedCapabilities, modelConnectionStatus] ); @@ -161,7 +162,10 @@ const App: React.FC = () => { appearanceRef, } = useUIPanels(); - const openImagesDialog = useCallback(() => setIsImagesOpen(true), [setIsImagesOpen]); + const openImagesDialog = useCallback( + (): void => setIsImagesOpen(true), + [setIsImagesOpen] + ); const { viewMode, @@ -246,7 +250,7 @@ const App: React.FC = () => { currentChapterId, currentChapterContext, advanceBaselineToCurrentStory, - refreshProjects: async () => { + refreshProjects: async (): Promise => { await refreshProjectsRef.current?.(); }, refreshStory, @@ -265,11 +269,11 @@ const App: React.FC = () => { // and per the explicit-mutation exception in the architecture decision. const sessionMutations = useChatStore((s: ChatStoreState) => s.sessionMutations); const sourcebookMutationEntryIds = useMemo( - () => + (): Set => new Set( sessionMutations .filter((m: SessionMutation) => m.type === 'sourcebook' && m.targetId) - .map((m: SessionMutation) => m.targetId as string) + .map((m: SessionMutation): string => m.targetId as string) ), [sessionMutations] ); @@ -304,7 +308,7 @@ const App: React.FC = () => { // Stabilize checkedSourcebookIds so useAiActions does not receive a new // array reference on every render when checkedEntries hasn't changed. const checkedSourcebookIdsMemo = useMemo( - () => Array.from(checkedEntries), + (): string[] => Array.from(checkedEntries), [checkedEntries] ); @@ -357,7 +361,7 @@ const App: React.FC = () => { currentChapterId, currentChapterContent: currentChapter?.content, storyLanguage: story.language, - refreshStory: async () => { + refreshStory: async (): Promise => { await refreshStory(); }, handleChapterSelect, @@ -417,7 +421,7 @@ const App: React.FC = () => { currentChapterId, handleChapterSelect, deleteChapter, - updateChapter: (id: string, partial: Record) => + updateChapter: (id: string, partial: Record): Promise => updateChapter(id, partial, true, true, true), updateBook, addChapter, diff --git a/src/frontend/components/ui/Collapsible.tsx b/src/frontend/components/ui/Collapsible.tsx index 6b24dcae..e84a6eee 100644 --- a/src/frontend/components/ui/Collapsible.tsx +++ b/src/frontend/components/ui/Collapsible.tsx @@ -41,14 +41,14 @@ export function useCollapsible( const isControlled = isExpandedProp !== undefined; const isExpanded = isControlled ? (isExpandedProp as boolean) : internalExpanded; - const toggle = useCallback(() => { + const toggle = useCallback((): void => { const next = !isExpanded; if (onExpandedChange) onExpandedChange(next); if (!isControlled) setInternalExpanded(next); }, [isExpanded, isControlled, onExpandedChange]); const setIsExpanded = useCallback( - (expanded: boolean) => { + (expanded: boolean): void => { if (onExpandedChange) onExpandedChange(expanded); if (!isControlled) setInternalExpanded(expanded); }, diff --git a/src/frontend/components/ui/Toast.tsx b/src/frontend/components/ui/Toast.tsx index 2f4d58cc..e9b3b5f6 100644 --- a/src/frontend/components/ui/Toast.tsx +++ b/src/frontend/components/ui/Toast.tsx @@ -24,7 +24,7 @@ export interface Toast { type ToastFn = (message: string, variant?: ToastVariant) => void; -const ToastContext = createContext(() => {}); +const ToastContext = createContext((): void => {}); /** Custom React hook that returns a toast dispatch function. */ export function useToast(): ToastFn { @@ -66,7 +66,7 @@ function ToastItem({ {ICONS[toast.variant]} {toast.message}
setDraft(value)} + onChange={(value: string): void => setDraft(value)} language={storyLanguage} spellCheck={true} mode="markdown" @@ -100,7 +100,7 @@ export const ChatScratchpadDialog: React.FC = ({
@@ -397,7 +416,9 @@ export const ProjectImages: React.FC = ({ value={imageAdditionalInfo} onChange={( e: React.ChangeEvent - ) => onUpdateSettings?.(imageStyle, e.target.value)} + ): void | undefined => + onUpdateSettings?.(imageStyle, e.target.value) + } />
@@ -425,7 +446,7 @@ export const ProjectImages: React.FC = ({