From d965309dd4c2567c457029020e80cd93fba3159e Mon Sep 17 00:00:00 2001 From: StableLlama Date: Sun, 3 May 2026 16:12:16 +0200 Subject: [PATCH 01/14] Remove in get_project_overview the unneeded content_file --- src/augmentedquill/services/projects/project_helpers.py | 2 -- tests/unit/services/test_chat_tool_contracts.py | 9 +++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/augmentedquill/services/projects/project_helpers.py b/src/augmentedquill/services/projects/project_helpers.py index e5f934aa..4f38f6a4 100644 --- a/src/augmentedquill/services/projects/project_helpers.py +++ b/src/augmentedquill/services/projects/project_helpers.py @@ -120,7 +120,6 @@ def _project_overview(include_notes: bool = False) -> dict: } if p_type == "short-story": - fn = story.get("content_file", "content.md") draft = { "title": story.get("project_title") or (active.name if active else ""), "summary": story.get("story_summary") or "", @@ -130,7 +129,6 @@ def _project_overview(include_notes: bool = False) -> dict: return { **base_info, - "content_file": fn, "draft": draft, } diff --git a/tests/unit/services/test_chat_tool_contracts.py b/tests/unit/services/test_chat_tool_contracts.py index ceae3e3b..f66f51f5 100644 --- a/tests/unit/services/test_chat_tool_contracts.py +++ b/tests/unit/services/test_chat_tool_contracts.py @@ -537,16 +537,17 @@ def test_get_project_overview_include_notes_contract(self): def test_get_project_overview_hides_chapter_filenames(self): content = self._call_tool("get_project_overview", {"include_notes": True}) - def _assert_no_filename_keys(value): + def _assert_no_storage_file_keys(value): if isinstance(value, dict): self.assertNotIn("filename", value) + self.assertNotIn("content_file", value) for nested in value.values(): - _assert_no_filename_keys(nested) + _assert_no_storage_file_keys(nested) elif isinstance(value, list): for nested in value: - _assert_no_filename_keys(nested) + _assert_no_storage_file_keys(nested) - _assert_no_filename_keys(content) + _assert_no_storage_file_keys(content) def test_get_chapter_metadata_hides_filename(self): content = self._call_tool("get_chapter_metadata", {"chap_id": 1}) From 8be7bf3e6e31847e292372b801afc93453f0bba0 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Sun, 3 May 2026 17:01:08 +0200 Subject: [PATCH 02/14] Gemma 4 non thinking optimization --- resources/config/model_presets.json | 19 +++++++++++++++++++ src/augmentedquill/utils/llm_parsing.py | 17 +++++++++++++---- tests/unit/services/test_chat_parser.py | 8 ++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/resources/config/model_presets.json b/resources/config/model_presets.json index 6d063dcc..3371a55c 100644 --- a/resources/config/model_presets.json +++ b/resources/config/model_presets.json @@ -114,6 +114,25 @@ "extra_body": "{\"repetition_penalty\": 1.0, \"chat_template_kwargs\": {\"enable_thinking\": false}}" } }, + { + "id": "delta-gemma4-non-thinking", + "name": "Tweak: Gemma 4 Non-Thinking", + "description": "Switches Gemma 4 from thinking mode to direct non-thinking responses.", + "model_id_patterns": ["^gemma4", "^gemma-4"], + "preset_type": "delta", + "parameters": { + "temperature": null, + "top_p": null, + "max_tokens": null, + "presence_penalty": null, + "frequency_penalty": null, + "stop": null, + "seed": null, + "top_k": null, + "min_p": null, + "extra_body": "{\"generation_config\": {\"thinking_config\": {\"thinking_budget\": 0}}, \"chat_template_kwargs\": {\"enable_thinking\": false}}" + } + }, { "id": "delta-creative-writing", "name": "Tweak: Creative Writing", diff --git a/src/augmentedquill/utils/llm_parsing.py b/src/augmentedquill/utils/llm_parsing.py index 15daf2f2..1d0615ce 100644 --- a/src/augmentedquill/utils/llm_parsing.py +++ b/src/augmentedquill/utils/llm_parsing.py @@ -78,9 +78,12 @@ def _sanitize_visible_prose(content: str) -> str: # Remove inline thought/thinking sections (closed and unclosed). cleaned = re.sub( - r"<(thought|thinking)>.*?", "", cleaned, flags=re.IGNORECASE | re.DOTALL + r"<(thought|thinking|think)>.*?", + "", + cleaned, + flags=re.IGNORECASE | re.DOTALL, ) - cleaned = re.sub(r"<(thought|thinking)>.*$", "", cleaned, flags=re.IGNORECASE) + cleaned = re.sub(r"<(thought|thinking|think)>.*$", "", cleaned, flags=re.IGNORECASE) # Remove channel protocol tokens, keep actual prose. cleaned = re.sub(r"<\|?channel\|?>", "", cleaned, flags=re.IGNORECASE) @@ -116,6 +119,7 @@ def _sanitize_visible_prose(content: str) -> str: if cleaned.strip().lower() in { "thought", "thinking", + "think", "analysis", "reasoning", "final", @@ -439,7 +443,7 @@ def extract_thinking_from_content(content: str) -> str: return "" match = re.search( - r"<(thought|thinking)>(.*?)", + r"<(thought|thinking|think)>(.*?)", content, re.DOTALL | re.IGNORECASE, ) @@ -568,7 +572,12 @@ def parse_stream_channel_fragments( cleaned_piece = _sanitize_visible_prose(piece) if cleaned_piece is None or cleaned_piece == "": continue - if cleaned_piece.strip().lower() in {"thought", "thinking", "analysis"}: + if cleaned_piece.strip().lower() in { + "thought", + "thinking", + "think", + "analysis", + }: continue events.append({"content": cleaned_piece}) diff --git a/tests/unit/services/test_chat_parser.py b/tests/unit/services/test_chat_parser.py index 1c76157f..c7622060 100644 --- a/tests/unit/services/test_chat_parser.py +++ b/tests/unit/services/test_chat_parser.py @@ -220,6 +220,14 @@ def test_strip_thinking_tags_removes_inline_thinking_blocks(self): content = "Before hidden After" self.assertEqual(strip_thinking_tags(content), "Before After") + def test_strip_thinking_tags_removes_inline_think_blocks(self): + content = "Before hidden After" + self.assertEqual(strip_thinking_tags(content), "Before After") + + def test_strip_thinking_tags_drops_empty_think_token(self): + content = "" + self.assertEqual(strip_thinking_tags(content), "") + def test_strip_thinking_tags_removes_leaked_channel_marker_noise(self): content = "<|channel>thought\nVisible answer" self.assertEqual(strip_thinking_tags(content), "Visible answer") From c3c07121210aaf329f3b08de418bc52c33d917fa Mon Sep 17 00:00:00 2001 From: StableLlama Date: Sun, 3 May 2026 17:24:10 +0200 Subject: [PATCH 03/14] Fix error message when WRITING is streaming in --- src/frontend/services/apiClients/chat.ts | 39 ++++++++++++++++++------ src/frontend/stores/chatStore.ts | 15 ++++++--- src/frontend/stores/storyStore.ts | 35 +++++++++++++++++++-- 3 files changed, 73 insertions(+), 16 deletions(-) diff --git a/src/frontend/services/apiClients/chat.ts b/src/frontend/services/apiClients/chat.ts index 17eea717..fe361e6d 100644 --- a/src/frontend/services/apiClients/chat.ts +++ b/src/frontend/services/apiClients/chat.ts @@ -131,12 +131,22 @@ interface ProseChunkScheduler { flushPendingProseChunk: () => void; } +const yieldToNextAnimationFrame = async (): Promise => { + if (typeof globalThis.requestAnimationFrame !== 'function') { + return; + } + await new Promise((resolve: () => void) => { + globalThis.requestAnimationFrame((): void => resolve()); + }); +}; + const createProseChunkScheduler = ( onProseChunk?: (chapId: number, writeMode: string, accumulated: string) => void ): ProseChunkScheduler => { let pendingProseChunk: ToolProseChunk | null = null; - let proseFlushHandle: number | ReturnType | null = null; + let proseFlushHandle: number | null = null; let proseFlushUsesRaf = false; + let proseFlushToken = 0; const flushPendingProseChunk = (): void => { proseFlushHandle = null; @@ -149,28 +159,36 @@ const createProseChunkScheduler = ( const scheduleProseChunkFlush = (): void => { if (!onProseChunk || proseFlushHandle !== null) return; + // Coalesce bursts into a single UI-frame flush so we render the latest + // accumulated chunk once per frame without fixed-delay throttling. if (typeof globalThis.requestAnimationFrame === 'function') { proseFlushUsesRaf = true; proseFlushHandle = globalThis.requestAnimationFrame((): void => { flushPendingProseChunk(); }); - } else { - proseFlushUsesRaf = false; - proseFlushHandle = setTimeout((): void => { - flushPendingProseChunk(); - }, 16); + return; } + + proseFlushUsesRaf = false; + const token = ++proseFlushToken; + proseFlushHandle = token; + queueMicrotask((): void => { + if (proseFlushHandle !== token || proseFlushToken !== token) { + return; + } + flushPendingProseChunk(); + }); }; const cancelScheduledProseFlush = (): void => { if (proseFlushHandle === null) return; if (proseFlushUsesRaf && typeof globalThis.cancelAnimationFrame === 'function') { - globalThis.cancelAnimationFrame(proseFlushHandle as number); - } else { - clearTimeout(proseFlushHandle as ReturnType); + globalThis.cancelAnimationFrame(proseFlushHandle); } + proseFlushToken += 1; proseFlushHandle = null; + proseFlushUsesRaf = false; }; const setPendingProseChunk = (chunk: ToolProseChunk): void => { @@ -300,6 +318,9 @@ export const createChatApi = (projectName: string): ChatApi => ({ } else if (event.type === 'result') { proseChunkScheduler.cancelScheduledProseFlush(); proseChunkScheduler.flushPendingProseChunk(); + // Let the browser paint the latest prose preview before control + // returns to the chat loop and potentially starts more async work. + await yieldToNextAnimationFrame(); return { ok: event.ok, appended_messages: event.appended_messages, diff --git a/src/frontend/stores/chatStore.ts b/src/frontend/stores/chatStore.ts index 053e9bfe..13c70d94 100644 --- a/src/frontend/stores/chatStore.ts +++ b/src/frontend/stores/chatStore.ts @@ -115,13 +115,18 @@ export const useChatStore = create()( chatMessages: resolve(v, s.chatMessages), })), setIsChatLoading: (v: boolean) => - set((): { isChatLoading: boolean } => ({ isChatLoading: v })), + set((s: ChatStoreState): ChatStoreState | { isChatLoading: boolean } => + s.isChatLoading === v ? s : { isChatLoading: v } + ), setIsProseStreamingFromChat: (v: boolean) => - set((): { isProseStreamingFromChat: boolean } => ({ - isProseStreamingFromChat: v, - })), + set( + (s: ChatStoreState): ChatStoreState | { isProseStreamingFromChat: boolean } => + s.isProseStreamingFromChat === v ? s : { isProseStreamingFromChat: v } + ), setIsProseStreamingFrozen: (v: boolean) => - set((): { isProseStreamingFrozen: boolean } => ({ isProseStreamingFrozen: v })), + set((s: ChatStoreState): ChatStoreState | { isProseStreamingFrozen: boolean } => + s.isProseStreamingFrozen === v ? s : { isProseStreamingFrozen: v } + ), freezeProseStreaming: () => set( (): { isProseStreamingFromChat: boolean; isProseStreamingFrozen: boolean } => ({ diff --git a/src/frontend/stores/storyStore.ts b/src/frontend/stores/storyStore.ts index aa600932..0ad9f0b8 100644 --- a/src/frontend/stores/storyStore.ts +++ b/src/frontend/stores/storyStore.ts @@ -177,8 +177,39 @@ export const useStoryStore = create()( setIsChapterLoading: (isChapterLoading: boolean) => set({ isChapterLoading }), setStreamingContent: ( - streamingContent: { chapterId: string; content: string } | null - ) => set({ streamingContent }), + streamingContent: { + chapterId: string; + content: string; + writeMode?: string; + } | null + ) => + set( + ( + state: StoryStoreState + ): + | StoryStoreState + | { + streamingContent: { + chapterId: string; + content: string; + writeMode?: string; + } | null; + } => { + const current = state.streamingContent; + if ( + (current === null && streamingContent === null) || + (current !== null && + streamingContent !== null && + current.chapterId === streamingContent.chapterId && + current.content === streamingContent.content && + (current.writeMode ?? 'append') === + (streamingContent.writeMode ?? 'append')) + ) { + return state; + } + return { streamingContent }; + } + ), patchSourcebookEntry: ( entry: SourcebookEntry | null, From 25bfeb6f12887e2a10094795d0411713df2a9dc4 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Sun, 3 May 2026 21:54:08 +0200 Subject: [PATCH 04/14] Fix main editor auto scroll --- src/frontend/features/editor/Editor.tsx | 36 +- .../editor/hooks/useEditorScroll.test.ts | 31 +- .../features/editor/hooks/useEditorScroll.ts | 452 +++++++++++------- 3 files changed, 342 insertions(+), 177 deletions(-) diff --git a/src/frontend/features/editor/Editor.tsx b/src/frontend/features/editor/Editor.tsx index ae8b491d..9171dae4 100644 --- a/src/frontend/features/editor/Editor.tsx +++ b/src/frontend/features/editor/Editor.tsx @@ -45,6 +45,8 @@ import { import { useEditorScroll } from './hooks/useEditorScroll'; import { useEditorFormatting } from './hooks/useEditorFormatting'; +const STREAM_FOLLOW_ATTACH_DISTANCE_PX = 200; + // URL sanitizer — re-exported for backward compat with Editor.url.test.ts export { isSafeImageUrl } from './editorUtils'; import { isSafeImageUrl } from './editorUtils'; @@ -137,6 +139,7 @@ export const Editor = React.memo( // Local content/title state so the editor div always gets the latest // typed value immediately, while the parent onChange (API call) is debounced. const [localContent, setLocalContent] = useState(chapter.content); + const deferredStreamingContentRef = useRef(null); // Ref that always holds the current content without triggering re-renders. // Used in callbacks that need the latest value at call time (e.g. suggestion // hotkeys) so those callbacks don't need localContent in their deps arrays. @@ -216,6 +219,10 @@ export const Editor = React.memo( const isChapterSwitch = chapter.id !== lastChapterIdRef.current; lastChapterIdRef.current = chapter.id; + if (isChapterSwitch) { + deferredStreamingContentRef.current = null; + } + // During active streaming the streaming-slot effect below owns // localContent; skip the chapter.content sync to avoid flashing the // pre-AI baseline content on every chunk. @@ -228,7 +235,7 @@ export const Editor = React.memo( const shouldDeferStreamingSync = proseStreamingActive && isDetachedFromBottomRef.current && - distanceFromBottomRef.current > 120 && + distanceFromBottomRef.current > STREAM_FOLLOW_ATTACH_DISTANCE_PX && !isChapterSwitch; if (isChapterSwitch || (!editorFocused && !shouldDeferStreamingSync)) { @@ -250,19 +257,34 @@ export const Editor = React.memo( const shouldDeferStreamingChunk = proseStreamingActive && isDetachedFromBottomRef.current && - distanceFromBottomRef.current > 120 && + distanceFromBottomRef.current > STREAM_FOLLOW_ATTACH_DISTANCE_PX && !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; + if (shouldDeferStreamingChunk) { + deferredStreamingContentRef.current = streamingContent; + return; + } + deferredStreamingContentRef.current = null; localContentRef.current = streamingContent; setLocalContent(streamingContent); } }, [streamingContent, proseStreamingActive]); + // If the stream ends while a chunk was deferred, flush the latest deferred + // content immediately so the editor doesn't lag until a later model update. + useEffect((): void => { + if (proseStreamingActive) return; + const deferred = deferredStreamingContentRef.current; + if (deferred === null) return; + + deferredStreamingContentRef.current = null; + localContentRef.current = deferred; + setLocalContent(deferred); + }, [proseStreamingActive]); + useEffect((): void => { setLocalTitle(chapter.title); }, [chapter.id, chapter.title]); @@ -296,7 +318,7 @@ export const Editor = React.memo( distanceFromBottomRef, } = useEditorScroll({ localContent, - isProseStreaming, + isProseStreaming: proseStreamingActive, isReplaceStreaming, chapterId: chapter.id, }); @@ -668,7 +690,7 @@ export const Editor = React.memo( const justOpened = !prevHasContinuationRef.current && hasContinuationOptions; prevHasContinuationRef.current = hasContinuationOptions; - if (isProseStreaming) return undefined; + if (proseStreamingActive) return undefined; if (!(isAiLoading || isSuggesting || justOpened)) return undefined; const raf = window.requestAnimationFrame((): void => { @@ -683,7 +705,7 @@ export const Editor = React.memo( continuations, isAiLoading, isSuggesting, - isProseStreaming, + proseStreamingActive, hasContinuationOptions, scrollMainContentToBottom, ]); diff --git a/src/frontend/features/editor/hooks/useEditorScroll.test.ts b/src/frontend/features/editor/hooks/useEditorScroll.test.ts index 758e294c..65f598c6 100644 --- a/src/frontend/features/editor/hooks/useEditorScroll.test.ts +++ b/src/frontend/features/editor/hooks/useEditorScroll.test.ts @@ -47,9 +47,18 @@ const makeContainer = ( beforeEach(() => { vi.useFakeTimers(); + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback): number => { + return window.setTimeout((): void => { + callback(performance.now()); + }, 0); + }); + vi.stubGlobal('cancelAnimationFrame', (id: number): void => { + window.clearTimeout(id); + }); }); afterEach(() => { + vi.unstubAllGlobals(); vi.useRealTimers(); }); @@ -130,7 +139,7 @@ describe('useEditorScroll - detach/reattach intent', () => { expect(hook.result.current.isDetachedFromBottomRef.current).toBe(true); }); - it('reattaches when currently at bottom-near position at chunk time', () => { + it('stays detached when user scrolled up, even if still near bottom at chunk time', () => { const hook: ScrollHookHarness<{ localContent: string }> = renderHook( ({ localContent }: { localContent: string }) => useEditorScroll({ localContent, isProseStreaming: true, chapterId: '1' }), @@ -156,16 +165,16 @@ describe('useEditorScroll - detach/reattach intent', () => { 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. + // Because user scrolled up, auto-follow must remain detached. act(() => { hook.rerender({ localContent: 'chunk1' }); }); expect(container.scrollTop).toBe(1099); - expect(hook.result.current.isDetachedFromBottomRef.current).toBe(false); + expect(hook.result.current.isDetachedFromBottomRef.current).toBe(true); }); - it('reattaches auto-scroll when the user scrolls back down near the bottom', () => { + it('reattaches auto-scroll when the user scrolls back to the bottom', () => { const hook: { result: { current: ScrollHookResult } } = renderHook(() => useEditorScroll({ localContent: '', isProseStreaming: true, chapterId: '1' }) ); @@ -186,7 +195,7 @@ describe('useEditorScroll - detach/reattach intent', () => { }); expect(hook.result.current.isDetachedFromBottomRef.current).toBe(true); - container.scrollTop = 1090; + container.scrollTop = 1100; act(() => { hook.result.current.handleScroll(); vi.runOnlyPendingTimers(); @@ -352,6 +361,9 @@ describe('useEditorScroll - streaming follow behavior', () => { act(() => { hook.rerender({ content: 'chunk1' }); }); + act(() => { + vi.runAllTimers(); + }); // Must stay attached and pinned to the new bottom. expect(container.scrollTop).toBe(1200); @@ -388,6 +400,9 @@ describe('useEditorScroll - streaming follow behavior', () => { act(() => { hook.rerender({ content: 'chunk1' }); }); + act(() => { + vi.runAllTimers(); + }); // Must stay attached and pinned to the new bottom. expect(container.scrollTop).toBe(1200); @@ -425,7 +440,7 @@ describe('useEditorScroll - replace streaming detached behavior', () => { expect(hook.result.current.isDetachedFromBottomRef.current).toBe(true); }); - it('restores detached anchor position after temporary clamp during streaming', () => { + it('does not auto-restore detached position after temporary clamp during streaming', () => { const hook: ScrollHookHarness<{ localContent: string }> = renderHook( ({ localContent }: { localContent: string }) => useEditorScroll({ localContent, isProseStreaming: true, chapterId: '1' }), @@ -456,7 +471,7 @@ describe('useEditorScroll - replace streaming detached behavior', () => { }); expect(container.scrollTop).toBe(660); - // Later chunk grows content again; anchored detached position should restore. + // Later chunk grows content again; detached mode must not auto-move scroll. Object.defineProperty(container, 'scrollHeight', { value: 1300, configurable: true, @@ -465,7 +480,7 @@ describe('useEditorScroll - replace streaming detached behavior', () => { hook.rerender({ localContent: 'chunk2' }); }); - expect(container.scrollTop).toBe(700); + expect(container.scrollTop).toBe(660); expect(hook.result.current.isDetachedFromBottomRef.current).toBe(true); }); diff --git a/src/frontend/features/editor/hooks/useEditorScroll.ts b/src/frontend/features/editor/hooks/useEditorScroll.ts index 7d57f55a..202b6455 100644 --- a/src/frontend/features/editor/hooks/useEditorScroll.ts +++ b/src/frontend/features/editor/hooks/useEditorScroll.ts @@ -11,10 +11,6 @@ * Design goals: * 1. At bottom → auto-scroll to follow new content. * 2. Not at bottom → never programmatically move the user's viewport. - * - * 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 { @@ -54,11 +50,11 @@ 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; +/** Distance from bottom considered attached/following. */ +const FOLLOW_ATTACH_DISTANCE = 24; +const FOLLOW_REATTACH_DISTANCE = 200; +const SCROLL_UP_DETACH_DELTA = 1; +const FOLLOW_WRITE_EPSILON_PX = 1; /** Custom React hook that manages editor scroll. */ export function useEditorScroll({ @@ -70,215 +66,347 @@ export function useEditorScroll({ const scrollContainerRef = useRef(null); const isDetachedFromBottomRef = useRef(false); const distanceFromBottomRef = useRef(0); - const detachedAnchorScrollTopRef = useRef(null); - const prevScrollTopRef = useRef(null); + const pendingFollowRafRef = useRef(null); + const prevScrollTopRef = useRef(0); + const lastKnownMaxScrollTopRef = useRef(0); 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); + const lastUserScrollIntentAtRef = useRef(0); + const prevChapterKeyRef = useRef(String(chapterId)); + const hasMountedRef = useRef(false); + const shouldAutoFollowRef = useRef(true); - // Keep a stable ref so useLayoutEffect can read the current value. - const isProseStreamingRef = useRef(isProseStreaming); + const isProseStreamingRef = useRef(isProseStreaming); isProseStreamingRef.current = isProseStreaming; + const wasProseStreamingRef = useRef(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): void => { - const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight); - if (Math.abs(maxScrollTop - container.scrollTop) > 1) { - isProgrammaticScrollRef.current = true; - container.scrollTop = maxScrollTop; + const clearPendingFollow = useCallback((): void => { + if (pendingFollowRafRef.current !== null) { + window.cancelAnimationFrame(pendingFollowRafRef.current); + pendingFollowRafRef.current = null; } }, []); - const handleScroll = useCallback((): void => { - if (!scrollContainerRef.current) return; - const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; - const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + const distanceFromBottom = useCallback((container: HTMLDivElement): number => { + const rawDistance = + container.scrollHeight - container.scrollTop - container.clientHeight; + // Guard against sub-pixel rounding drift that can produce tiny negatives. + return Math.max(0, rawDistance); + }, []); + + const isNearBottom = useCallback( + (container: HTMLDivElement): boolean => { + return distanceFromBottom(container) <= FOLLOW_ATTACH_DISTANCE; + }, + [distanceFromBottom] + ); + + const isWithinReattachRange = useCallback( + (container: HTMLDivElement): boolean => { + return distanceFromBottom(container) <= FOLLOW_REATTACH_DISTANCE; + }, + [distanceFromBottom] + ); + + const updateDistanceFromContainer = useCallback( + (container: HTMLDivElement): void => { + distanceFromBottomRef.current = distanceFromBottom(container); + }, + [distanceFromBottom] + ); + + const syncDetachedFlag = useCallback((): void => { + isDetachedFromBottomRef.current = !shouldAutoFollowRef.current; + }, []); + + const updateAutoFollowFromScrollPosition = useCallback( + (container: HTMLDivElement): void => { + const currentTop = container.scrollTop; + const previousTop = prevScrollTopRef.current; + const delta = currentTop - previousTop; + const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); + const userIntentRecent = now - lastUserScrollIntentAtRef.current < 500; + let detachedInThisUpdate = false; + + if (delta <= -SCROLL_UP_DETACH_DELTA) { + if (!isProseStreamingRef.current || userIntentRecent) { + shouldAutoFollowRef.current = false; + detachedInThisUpdate = true; + } + } else if ( + !shouldAutoFollowRef.current && + delta > SCROLL_UP_DETACH_DELTA && + userIntentRecent && + isWithinReattachRange(container) + ) { + shouldAutoFollowRef.current = true; + } else if (isNearBottom(container)) { + shouldAutoFollowRef.current = true; + } + + // If detach came from a generic scroll event (e.g. scrollbar drag), + // cancel any already queued follow write to prevent a late jump. + if (detachedInThisUpdate && pendingFollowRafRef.current !== null) { + clearPendingFollow(); + } - distanceFromBottomRef.current = distanceFromBottom; + prevScrollTopRef.current = currentTop; + updateDistanceFromContainer(container); + syncDetachedFlag(); + void delta; + void userIntentRecent; + }, + [ + clearPendingFollow, + isNearBottom, + isWithinReattachRange, + syncDetachedFlag, + updateDistanceFromContainer, + ] + ); + + const scheduleFollowToBottom = useCallback((): void => { + const container = scrollContainerRef.current; + if (!container) return; - // Programmatic scrolls must not influence user-intent detection. - // prevScrollTopRef is intentionally NOT updated here. - if (isProgrammaticScrollRef.current) { - isProgrammaticScrollRef.current = false; + const liveDistance = distanceFromBottom(container); + if (liveDistance <= FOLLOW_WRITE_EPSILON_PX) { + updateDistanceFromContainer(container); 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 maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight); + const alreadyPinned = Math.abs(container.scrollTop - maxScrollTop) <= 1; + const maxScrollTopChanged = + Math.abs(maxScrollTop - lastKnownMaxScrollTopRef.current) > 1; + + // Avoid per-chunk RAF churn when geometry and position are unchanged. + if (alreadyPinned && !maxScrollTopChanged) { + void maxScrollTop; + return; } - }, []); - const handleWheel = useCallback((event: WheelEvent): void => { - // 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; + if (pendingFollowRafRef.current !== null) { + return; + } + pendingFollowRafRef.current = window.requestAnimationFrame((): void => { + pendingFollowRafRef.current = null; + const container = scrollContainerRef.current; + if (!container) { + return; } - } else if (event.deltaY > 0 && scrollContainerRef.current) { - const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; - const distanceFromBottom = scrollHeight - scrollTop - clientHeight; - if (distanceFromBottom < ATTACH_DISTANCE + 80) { - isDetachedFromBottomRef.current = false; - detachedAnchorScrollTopRef.current = null; + if (!shouldAutoFollowRef.current) { + return; } + const liveDistance = distanceFromBottom(container); + if (liveDistance <= FOLLOW_WRITE_EPSILON_PX) { + updateDistanceFromContainer(container); + return; + } + const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight); + const didWrite = Math.abs(container.scrollTop - maxScrollTop) > 1; + if (didWrite) { + container.scrollTop = maxScrollTop; + } + lastKnownMaxScrollTopRef.current = maxScrollTop; + prevScrollTopRef.current = container.scrollTop; + updateDistanceFromContainer(container); + shouldAutoFollowRef.current = true; + syncDetachedFlag(); + void didWrite; + void maxScrollTop; + }); + }, [distanceFromBottom, syncDetachedFlag, updateDistanceFromContainer]); + + /** + * Pin the container to its maximum scroll position. + */ + const pinToBottom = useCallback((container: HTMLDivElement): void => { + const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight); + if (Math.abs(maxScrollTop - container.scrollTop) > 1) { + container.scrollTop = maxScrollTop; } }, []); + const handleScroll = useCallback((): void => { + const container = scrollContainerRef.current; + if (!container) return; + updateAutoFollowFromScrollPosition(container); + }, [updateAutoFollowFromScrollPosition]); + + const handleWheel = useCallback( + (event: WheelEvent): void => { + const container = scrollContainerRef.current; + if (!container) return; + lastUserScrollIntentAtRef.current = + typeof performance !== 'undefined' ? performance.now() : Date.now(); + + // When detached, never keep stale pending follow frames while the user is + // manually wheeling through content. + if (!shouldAutoFollowRef.current && pendingFollowRafRef.current !== null) { + clearPendingFollow(); + } + + // Detach immediately on upward intent so we don't fight user scroll before + // the browser emits the resulting scroll event. + if (event.deltaY < 0) { + shouldAutoFollowRef.current = false; + syncDetachedFlag(); + clearPendingFollow(); + return; + } + + // Reattach decisions are handled in handleScroll from actual geometry + // updates to avoid wheel-vs-stream races. + updateDistanceFromContainer(container); + void event.deltaY; + }, + [clearPendingFollow, syncDetachedFlag, updateDistanceFromContainer] + ); + const handleTouchStart = useCallback((event: TouchEvent): void => { lastTouchYRef.current = event.touches[0]?.clientY ?? null; + lastUserScrollIntentAtRef.current = + typeof performance !== 'undefined' ? performance.now() : Date.now(); }, []); - const handleTouchMove = useCallback((event: TouchEvent): void => { - 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; + const handleTouchMove = useCallback( + (event: TouchEvent): void => { + const currentY = event.touches[0]?.clientY ?? null; + const previousY = lastTouchYRef.current; + lastTouchYRef.current = currentY; + if (previousY === null || currentY === null) return; + + const deltaY = currentY - previousY; + lastUserScrollIntentAtRef.current = + typeof performance !== 'undefined' ? performance.now() : Date.now(); + if (deltaY > 2) { + shouldAutoFollowRef.current = false; + syncDetachedFlag(); + clearPendingFollow(); + return; } - } 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; + if (deltaY < -2 && scrollContainerRef.current) { + updateDistanceFromContainer(scrollContainerRef.current); + if (isNearBottom(scrollContainerRef.current)) { + shouldAutoFollowRef.current = true; + syncDetachedFlag(); + } } - } - }, []); + void deltaY; + }, + [clearPendingFollow, isNearBottom, syncDetachedFlag, updateDistanceFromContainer] + ); const scrollMainContentToBottom = useCallback((): void => { const container = scrollContainerRef.current; if (!container) return; - isProgrammaticScrollRef.current = true; - container.scrollTop = container.scrollHeight; - detachedAnchorScrollTopRef.current = null; - }, []); + shouldAutoFollowRef.current = true; + syncDetachedFlag(); + scheduleFollowToBottom(); + }, [scheduleFollowToBottom, syncDetachedFlag]); - /** - * 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. - */ + /** Auto-scroll during streaming before paint to avoid visual jumps. */ useLayoutEffect((): void => { - if (!isProseStreamingRef.current) return; + if (!isProseStreaming) return; const container = scrollContainerRef.current; if (!container) return; - 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; + // On stream start, initialize follow mode from current viewport position. + const startedStreamingNow = !wasProseStreamingRef.current; + if (startedStreamingNow) { + updateDistanceFromContainer(container); + // Preserve existing attached state when streaming starts so transient + // layout shifts cannot disable auto-follow. + shouldAutoFollowRef.current = + shouldAutoFollowRef.current || isNearBottom(container); + prevScrollTopRef.current = container.scrollTop; + syncDetachedFlag(); + } - if (isDetachedClampCase) { - isDetachedFromBottomRef.current = true; - return; + const userLikelyMovedUpWithoutScrollEvent = + container.scrollTop < prevScrollTopRef.current - SCROLL_UP_DETACH_DELTA; + const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); + const userIntentRecent = now - lastUserScrollIntentAtRef.current < 500; + if (userLikelyMovedUpWithoutScrollEvent) { + if (userIntentRecent) { + shouldAutoFollowRef.current = false; + prevScrollTopRef.current = container.scrollTop; + updateDistanceFromContainer(container); + syncDetachedFlag(); } + } - isDetachedFromBottomRef.current = false; - detachedAnchorScrollTopRef.current = null; - pinToBottom(container); + if (!shouldAutoFollowRef.current) { 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; + const liveDistance = distanceFromBottom(container); + if (liveDistance > FOLLOW_WRITE_EPSILON_PX) { 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; + lastKnownMaxScrollTopRef.current = Math.max( + 0, + container.scrollHeight - container.clientHeight + ); + prevScrollTopRef.current = container.scrollTop; + updateDistanceFromContainer(container); + shouldAutoFollowRef.current = true; + syncDetachedFlag(); + }, [ + distanceFromBottom, + isProseStreaming, + pinToBottom, + localContent, + isNearBottom, + syncDetachedFlag, + updateDistanceFromContainer, + ]); + + useEffect((): void => { + wasProseStreamingRef.current = isProseStreaming; + }, [isProseStreaming]); + + // Chapter switch: reset scroll so the new chapter starts at the top. + useLayoutEffect((): void => { + if (!hasMountedRef.current) { + hasMountedRef.current = true; + prevChapterKeyRef.current = String(chapterId); + return; } - 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; - } + const chapterKey = String(chapterId); + if (prevChapterKeyRef.current === chapterKey) { + return; } - }, [localContent, pinToBottom]); - // Chapter switch: reset scroll so the new chapter starts at the top. - useLayoutEffect((): void => { + prevChapterKeyRef.current = chapterKey; const container = scrollContainerRef.current; if (!container) return; - isProgrammaticScrollRef.current = true; + + clearPendingFollow(); container.scrollTop = 0; - isDetachedFromBottomRef.current = false; - detachedAnchorScrollTopRef.current = null; - prevScrollTopRef.current = 0; + shouldAutoFollowRef.current = true; + syncDetachedFlag(); distanceFromBottomRef.current = 0; - }, [chapterId]); - - // No cleanup needed (no pending animation frames). - useEffect((): undefined => undefined, []); + prevScrollTopRef.current = 0; + lastKnownMaxScrollTopRef.current = Math.max( + 0, + container.scrollHeight - container.clientHeight + ); + }, [chapterId, clearPendingFollow, syncDetachedFlag]); + + useEffect((): (() => void) => { + return (): void => { + clearPendingFollow(); + }; + }, [clearPendingFollow]); return { scrollContainerRef, From 3c423909f58a6167d4fb720226e0766f178e6ee2 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Sun, 3 May 2026 22:02:47 +0200 Subject: [PATCH 05/14] Code clean up --- .../features/chat/hooks/useChatScroll.ts | 11 +++-- .../features/editor/hooks/useEditorScroll.ts | 42 ++++++------------- src/frontend/utils/scrollUtils.ts | 19 +++++++++ 3 files changed, 37 insertions(+), 35 deletions(-) create mode 100644 src/frontend/utils/scrollUtils.ts diff --git a/src/frontend/features/chat/hooks/useChatScroll.ts b/src/frontend/features/chat/hooks/useChatScroll.ts index 447cf8cd..6ba5beec 100644 --- a/src/frontend/features/chat/hooks/useChatScroll.ts +++ b/src/frontend/features/chat/hooks/useChatScroll.ts @@ -17,6 +17,7 @@ import { type TouchEvent, } from 'react'; import { ChatMessage } from '../../../types'; +import { scrollDistanceFromBottom } from '../../../utils/scrollUtils'; interface UseChatScrollDeps { messages: ChatMessage[]; @@ -72,8 +73,8 @@ export function useChatScroll({ const handleScroll = useCallback((): void => { if (!scrollContainerRef.current) return; - const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; - const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + const { scrollTop } = scrollContainerRef.current; + const distanceFromBottom = scrollDistanceFromBottom(scrollContainerRef.current); const isAtBottom = distanceFromBottom < 24; // Skip direction logic for programmatic scrolls. @@ -100,8 +101,7 @@ export function useChatScroll({ 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; + const distanceFromBottom = scrollDistanceFromBottom(scrollContainerRef.current); if (distanceFromBottom < ATTACH_DISTANCE + 80) { isAtBottomRef.current = true; } @@ -123,8 +123,7 @@ export function useChatScroll({ if (deltaY > 2) { isAtBottomRef.current = false; } else if (deltaY < -2 && scrollContainerRef.current) { - const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; - const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + const distanceFromBottom = scrollDistanceFromBottom(scrollContainerRef.current); if (distanceFromBottom < ATTACH_DISTANCE + 80) { isAtBottomRef.current = true; } diff --git a/src/frontend/features/editor/hooks/useEditorScroll.ts b/src/frontend/features/editor/hooks/useEditorScroll.ts index 202b6455..d2e638d3 100644 --- a/src/frontend/features/editor/hooks/useEditorScroll.ts +++ b/src/frontend/features/editor/hooks/useEditorScroll.ts @@ -21,6 +21,7 @@ import { type WheelEvent, type TouchEvent, } from 'react'; +import { scrollDistanceFromBottom } from '../../../utils/scrollUtils'; interface UseEditorScrollOptions { /** Current text content — triggers stream-follow on change. */ @@ -91,33 +92,17 @@ export function useEditorScroll({ } }, []); - const distanceFromBottom = useCallback((container: HTMLDivElement): number => { - const rawDistance = - container.scrollHeight - container.scrollTop - container.clientHeight; - // Guard against sub-pixel rounding drift that can produce tiny negatives. - return Math.max(0, rawDistance); + const isNearBottom = useCallback((container: HTMLDivElement): boolean => { + return scrollDistanceFromBottom(container) <= FOLLOW_ATTACH_DISTANCE; }, []); - const isNearBottom = useCallback( - (container: HTMLDivElement): boolean => { - return distanceFromBottom(container) <= FOLLOW_ATTACH_DISTANCE; - }, - [distanceFromBottom] - ); - - const isWithinReattachRange = useCallback( - (container: HTMLDivElement): boolean => { - return distanceFromBottom(container) <= FOLLOW_REATTACH_DISTANCE; - }, - [distanceFromBottom] - ); + const isWithinReattachRange = useCallback((container: HTMLDivElement): boolean => { + return scrollDistanceFromBottom(container) <= FOLLOW_REATTACH_DISTANCE; + }, []); - const updateDistanceFromContainer = useCallback( - (container: HTMLDivElement): void => { - distanceFromBottomRef.current = distanceFromBottom(container); - }, - [distanceFromBottom] - ); + const updateDistanceFromContainer = useCallback((container: HTMLDivElement): void => { + distanceFromBottomRef.current = scrollDistanceFromBottom(container); + }, []); const syncDetachedFlag = useCallback((): void => { isDetachedFromBottomRef.current = !shouldAutoFollowRef.current; @@ -173,7 +158,7 @@ export function useEditorScroll({ const container = scrollContainerRef.current; if (!container) return; - const liveDistance = distanceFromBottom(container); + const liveDistance = scrollDistanceFromBottom(container); if (liveDistance <= FOLLOW_WRITE_EPSILON_PX) { updateDistanceFromContainer(container); return; @@ -202,7 +187,7 @@ export function useEditorScroll({ if (!shouldAutoFollowRef.current) { return; } - const liveDistance = distanceFromBottom(container); + const liveDistance = scrollDistanceFromBottom(container); if (liveDistance <= FOLLOW_WRITE_EPSILON_PX) { updateDistanceFromContainer(container); return; @@ -220,7 +205,7 @@ export function useEditorScroll({ void didWrite; void maxScrollTop; }); - }, [distanceFromBottom, syncDetachedFlag, updateDistanceFromContainer]); + }, [syncDetachedFlag, updateDistanceFromContainer]); /** * Pin the container to its maximum scroll position. @@ -346,7 +331,7 @@ export function useEditorScroll({ return; } - const liveDistance = distanceFromBottom(container); + const liveDistance = scrollDistanceFromBottom(container); if (liveDistance > FOLLOW_WRITE_EPSILON_PX) { pinToBottom(container); } @@ -360,7 +345,6 @@ export function useEditorScroll({ shouldAutoFollowRef.current = true; syncDetachedFlag(); }, [ - distanceFromBottom, isProseStreaming, pinToBottom, localContent, diff --git a/src/frontend/utils/scrollUtils.ts b/src/frontend/utils/scrollUtils.ts new file mode 100644 index 00000000..faf22194 --- /dev/null +++ b/src/frontend/utils/scrollUtils.ts @@ -0,0 +1,19 @@ +// 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: Shared scroll geometry utilities. + */ + +/** + * Returns the pixel distance between the current scroll position and the + * maximum scroll position (bottom of content). Clamped to 0 to guard against + * sub-pixel rounding that can produce tiny negatives. + */ +export function scrollDistanceFromBottom(el: HTMLElement): number { + return Math.max(0, el.scrollHeight - el.scrollTop - el.clientHeight); +} From 72ed4ccc974dcad339adc3876412b2712e25b1b7 Mon Sep 17 00:00:00 2001 From: StableLlama Date: Sun, 3 May 2026 23:20:08 +0200 Subject: [PATCH 06/14] Optimize toolbar responsiveness --- src/frontend/features/app/locales/de.ts | 2 + src/frontend/features/app/locales/en.ts | 2 + src/frontend/features/app/locales/es.ts | 2 + src/frontend/features/app/locales/fr.ts | 2 + .../editor/EditorMobileToolbar.test.tsx | 153 +++++++ .../features/editor/EditorMobileToolbar.tsx | 294 +++++++++++--- .../editor/HeaderAppearanceControls.tsx | 2 +- src/frontend/features/layout/AppHeader.tsx | 81 ++-- .../layout/header/HeaderCenterControls.tsx | 379 +++++++++++------- 9 files changed, 678 insertions(+), 239 deletions(-) create mode 100644 src/frontend/features/editor/EditorMobileToolbar.test.tsx diff --git a/src/frontend/features/app/locales/de.ts b/src/frontend/features/app/locales/de.ts index 7b5bdd27..d426cfe5 100644 --- a/src/frontend/features/app/locales/de.ts +++ b/src/frontend/features/app/locales/de.ts @@ -465,6 +465,8 @@ export const de = { 'Diese Aktion ist nicht verfügbar, da kein funktionierendes WRITING-Modell konfiguriert ist.', 'Toggle whitespace characters': 'Leerzeichenzeichen umschalten', 'Close menu': 'Menü schließen', + 'More tools': 'Mehr Werkzeuge', + Tools: 'Werkzeuge', Formatting: 'Formatierung', 'Close formatting menu': 'Formatierungsmenü schließen', Format: 'Format', diff --git a/src/frontend/features/app/locales/en.ts b/src/frontend/features/app/locales/en.ts index aa3f0c1b..4355202f 100644 --- a/src/frontend/features/app/locales/en.ts +++ b/src/frontend/features/app/locales/en.ts @@ -465,6 +465,8 @@ export const en = { 'This action is unavailable because no working WRITING model is configured.', 'Toggle whitespace characters': 'Toggle whitespace characters', 'Close menu': 'Close menu', + 'More tools': 'More tools', + Tools: 'Tools', Formatting: 'Formatting', 'Close formatting menu': 'Close formatting menu', Format: 'Format', diff --git a/src/frontend/features/app/locales/es.ts b/src/frontend/features/app/locales/es.ts index b765fec1..d6e889ce 100644 --- a/src/frontend/features/app/locales/es.ts +++ b/src/frontend/features/app/locales/es.ts @@ -465,6 +465,8 @@ export const es = { 'Esta acción no está disponible porque no hay un modelo WRITING funcional configurado.', 'Toggle whitespace characters': 'Alternar caracteres de espacio en blanco', 'Close menu': 'Cerrar menú', + 'More tools': 'Más herramientas', + Tools: 'Herramientas', Formatting: 'Formato', 'Close formatting menu': 'Cerrar menú de formato', Format: 'Formato', diff --git a/src/frontend/features/app/locales/fr.ts b/src/frontend/features/app/locales/fr.ts index 7b464eb8..956f6e48 100644 --- a/src/frontend/features/app/locales/fr.ts +++ b/src/frontend/features/app/locales/fr.ts @@ -472,6 +472,8 @@ export const fr = { 'Cette action est indisponible car aucun modèle WRITING fonctionnel n’est configuré.', 'Toggle whitespace characters': 'Afficher/masquer les caractères d’espacement', 'Close menu': 'Fermer le menu', + 'More tools': 'Plus d’outils', + Tools: 'Outils', Formatting: 'Mise en forme', 'Close formatting menu': 'Fermer le menu de mise en forme', Format: 'Format', diff --git a/src/frontend/features/editor/EditorMobileToolbar.test.tsx b/src/frontend/features/editor/EditorMobileToolbar.test.tsx new file mode 100644 index 00000000..8f1dc5f5 --- /dev/null +++ b/src/frontend/features/editor/EditorMobileToolbar.test.tsx @@ -0,0 +1,153 @@ +// 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. + +/** + * Tests width-priority behavior for editor mobile toolbar overflow handling. + */ + +// @vitest-environment jsdom + +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { EditorProvider, type EditorContextValue } from './EditorContext'; +import { EditorMobileToolbar } from './EditorMobileToolbar'; + +const onAiAction = vi.fn(); + +const baseContext: EditorContextValue = { + theme: 'light', + toolbarBg: 'bg-white', + footerBg: 'bg-white', + textMuted: 'text-brand-gray-600', + chapterScope: 'chapter', + isAiLoading: false, + isWritingAvailable: true, + writingUnavailableReason: 'Unavailable', + isChapterEmpty: false, + onAiAction, + shouldShowContinuationPanel: false, + displayedContinuations: [], + suggestionMode: 'continuation', + onSuggestionModeChange: vi.fn(), + isSuggesting: false, + localContentRef: { current: '' }, + onSuggestionButtonClick: vi.fn(), + onAcceptContinuation: vi.fn(), + onRegenerate: vi.fn(), +}; + +let measuredWidth = 480; + +const rectFromWidth = (width: number): DOMRect => + ({ + width, + height: 56, + top: 0, + left: 0, + bottom: 56, + right: width, + x: 0, + y: 0, + toJSON: (): Record => ({}), + }) as DOMRect; + +const renderToolbar = (): void => { + render( + + + + ); +}; + +describe('EditorMobileToolbar', () => { + beforeEach(() => { + onAiAction.mockReset(); + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation( + (): DOMRect => rectFromWidth(measuredWidth) + ); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('keeps full layout on wide widths', async () => { + measuredWidth = 500; + renderToolbar(); + + await waitFor(() => { + expect(screen.getByText('Chapter AI')).toBeTruthy(); + }); + + expect(screen.getByRole('button', { name: 'Extend' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Rewrite' })).toBeTruthy(); + }); + + it('drops the label before dropping action buttons', async () => { + measuredWidth = 340; + renderToolbar(); + + await waitFor(() => { + expect(screen.queryByText('Chapter AI')).toBeNull(); + }); + + expect(screen.getByRole('button', { name: 'Extend' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Rewrite' })).toBeTruthy(); + }); + + it('moves rewrite into overflow menu at split widths', async () => { + measuredWidth = 280; + renderToolbar(); + + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Rewrite' })).toBeNull(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'More AI actions' })); + + expect(screen.getByRole('button', { name: 'Rewrite' })).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Rewrite' })); + expect(onAiAction).toHaveBeenCalledWith('chapter', 'rewrite'); + }); + + it('keeps both actions accessible in menu-only mode', async () => { + measuredWidth = 220; + renderToolbar(); + + await waitFor(() => { + expect(screen.queryByRole('button', { name: 'Rewrite' })).toBeNull(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'AI' })); + + expect(screen.getByRole('button', { name: 'Extend' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Rewrite' })).toBeTruthy(); + + fireEvent.click(screen.getByRole('button', { name: 'Extend' })); + expect(onAiAction).toHaveBeenCalledWith('chapter', 'extend'); + }); + + it('recomputes layout after resize events when ResizeObserver is unavailable', async () => { + measuredWidth = 500; + renderToolbar(); + + await waitFor(() => { + expect(screen.getByText('Chapter AI')).toBeTruthy(); + }); + + measuredWidth = 220; + fireEvent(window, new Event('resize')); + + await waitFor(() => { + expect(screen.queryByText('Chapter AI')).toBeNull(); + expect(screen.getByRole('button', { name: 'AI' })).toBeTruthy(); + }); + }); +}); diff --git a/src/frontend/features/editor/EditorMobileToolbar.tsx b/src/frontend/features/editor/EditorMobileToolbar.tsx index edc25168..af6d97eb 100644 --- a/src/frontend/features/editor/EditorMobileToolbar.tsx +++ b/src/frontend/features/editor/EditorMobileToolbar.tsx @@ -11,11 +11,43 @@ */ import React from 'react'; -import { Wand2, FileEdit } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ChevronDown, FileEdit, MoreHorizontal, Wand2 } from 'lucide-react'; import { Button } from '../../components/ui/Button'; +import { useClickOutside } from '../../utils/hooks'; import { useEditorContext } from './EditorContext'; +type ToolbarLayoutMode = 'full' | 'compact' | 'split' | 'menu'; + +type MenuAction = { + key: string; + label: string; + icon: React.ReactElement; + onClick: () => void; + disabled: boolean; + title: string; +}; + +const FULL_LAYOUT_MIN_WIDTH = 420; +const COMPACT_LAYOUT_MIN_WIDTH = 330; +const SPLIT_LAYOUT_MIN_WIDTH = 250; + +const getLayoutMode = (width: number): ToolbarLayoutMode => { + if (width >= FULL_LAYOUT_MIN_WIDTH) { + return 'full'; + } + if (width >= COMPACT_LAYOUT_MIN_WIDTH) { + return 'compact'; + } + if (width >= SPLIT_LAYOUT_MIN_WIDTH) { + return 'split'; + } + return 'menu'; +}; + export const EditorMobileToolbar: React.FC = () => { + const { t } = useTranslation(); const { theme, toolbarBg, @@ -28,64 +60,218 @@ export const EditorMobileToolbar: React.FC = () => { onAiAction, } = useEditorContext(); + const rootRef = useRef(null); + const menuRef = useRef(null); + const [menuOpen, setMenuOpen] = useState(false); + const [toolbarWidth, setToolbarWidth] = useState(0); + + useEffect(() => { + const element = rootRef.current; + if (!element) { + return; + } + + const syncWidth = (): void => { + setToolbarWidth(element.getBoundingClientRect().width); + }; + + syncWidth(); + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', syncWidth); + return (): void => window.removeEventListener('resize', syncWidth); + } + + const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => { + const nextWidth = entries[0]?.contentRect.width; + if (typeof nextWidth === 'number') { + setToolbarWidth(nextWidth); + } + }); + observer.observe(element); + + return (): void => observer.disconnect(); + }, []); + + useClickOutside(menuRef, () => setMenuOpen(false), menuOpen); + + const layoutMode = getLayoutMode(toolbarWidth); + const showLabel = layoutMode === 'full'; + const showInlineRewrite = layoutMode === 'full' || layoutMode === 'compact'; + const showInlineExtend = layoutMode !== 'menu'; + const showMenuTrigger = layoutMode === 'split' || layoutMode === 'menu'; + const iconOnlyInlineActions = layoutMode === 'split' && toolbarWidth < 290; + + const aiSectionLabel = chapterScope === 'story' ? t('Story AI') : t('Chapter AI'); + + const extendButtonTitle = !isWritingAvailable + ? writingUnavailableReason + : chapterScope === 'story' + ? t('Extend Story Draft (WRITING model)') + : t('Extend Chapter (WRITING model)'); + + const rewriteButtonTitle = !isWritingAvailable + ? writingUnavailableReason + : isChapterEmpty + ? t('Chapter is empty; cannot rewrite existing text.') + : chapterScope === 'story' + ? t('Rewrite Story Draft (WRITING model)') + : t('Rewrite Chapter (WRITING model)'); + + const menuActions = useMemo( + () => [ + { + key: 'extend', + label: t('Extend'), + icon: , + onClick: (): void => onAiAction('chapter', 'extend'), + disabled: isAiLoading || !isWritingAvailable, + title: extendButtonTitle, + }, + { + key: 'rewrite', + label: t('Rewrite'), + icon: , + onClick: (): void => onAiAction('chapter', 'rewrite'), + disabled: isAiLoading || !isWritingAvailable || isChapterEmpty, + title: rewriteButtonTitle, + }, + ], + [ + extendButtonTitle, + isAiLoading, + isChapterEmpty, + isWritingAvailable, + onAiAction, + rewriteButtonTitle, + t, + ] + ); + + const visibleMenuActions = menuActions.filter((action: MenuAction) => { + if (layoutMode === 'split') { + return action.key === 'rewrite'; + } + if (layoutMode === 'menu') { + return true; + } + return false; + }); + return ( -
-
-
- {/* Mobile Toolbar Left Items */} -
-
+
+
+
- - {chapterScope === 'story' ? 'Story AI' : 'Chapter AI'} - -
- - + {showLabel && ( + <> + + {aiSectionLabel} + +
+ + )} + + {showInlineExtend && ( + + )} + + {showInlineRewrite && ( + + )} + + {showMenuTrigger && ( +
+ + + {menuOpen && ( + <> + + ))} +
+ + )} +
+ )}
diff --git a/src/frontend/features/editor/HeaderAppearanceControls.tsx b/src/frontend/features/editor/HeaderAppearanceControls.tsx index bf5f5b4b..dc7dadef 100644 --- a/src/frontend/features/editor/HeaderAppearanceControls.tsx +++ b/src/frontend/features/editor/HeaderAppearanceControls.tsx @@ -178,7 +178,7 @@ export const HeaderAppearanceControls: React.FC = onClick={(): void => setIsAppearanceOpen(!isAppearanceOpen)} icon={} title={t('Page Appearance')} - className="hidden sm:inline-flex" + className="inline-flex" /> {isAppearanceOpen && ( diff --git a/src/frontend/features/layout/AppHeader.tsx b/src/frontend/features/layout/AppHeader.tsx index 6b15c952..1a5a239c 100644 --- a/src/frontend/features/layout/AppHeader.tsx +++ b/src/frontend/features/layout/AppHeader.tsx @@ -60,8 +60,10 @@ type AppHeaderProps = { interface UndoRedoMenuProps { options: Array<{ id: string; label: string; steps: number }>; label: string; + primaryActionLabel: string; menuContainerClass: string; menuButtonClass: string; + onPrimaryAction: () => void; onStep: (steps: number) => void; t: (key: string) => string; } @@ -69,8 +71,10 @@ interface UndoRedoMenuProps { const UndoRedoMenu: React.FC = ({ options, label, + primaryActionLabel, menuContainerClass, menuButtonClass, + onPrimaryAction, onStep, t, }: UndoRedoMenuProps) => ( @@ -78,6 +82,15 @@ const UndoRedoMenu: React.FC = ({
{t(`${label} Actions`)}
+ {options.map((option: { id: string; label: string; steps: number }) => (
); @@ -209,14 +224,14 @@ const HeaderLeftControls: React.FC = ({ useClickOutside(redoMenuRef, (): void => setIsRedoMenuOpen(false), isRedoMenuOpen); const menuContainerClass = isLight - ? 'absolute left-0 top-full z-[90] mt-1 w-80 rounded-md border border-brand-gray-200 bg-white shadow-lg' - : 'absolute left-0 top-full z-[90] mt-1 w-80 rounded-md border border-brand-gray-700 bg-brand-gray-900 shadow-lg'; + ? 'absolute left-0 top-full z-[90] mt-1 w-64 rounded-md border border-brand-gray-200 bg-white shadow-lg' + : 'absolute left-0 top-full z-[90] mt-1 w-64 rounded-md border border-brand-gray-700 bg-brand-gray-900 shadow-lg'; const menuButtonClass = isLight ? 'w-full px-3 py-2 text-left text-xs text-brand-gray-700 hover:bg-brand-gray-100' : 'w-full px-3 py-2 text-left text-xs text-brand-gray-300 hover:bg-brand-gray-800'; return ( -
+
- + {storyTitle}
@@ -258,31 +273,20 @@ const HeaderLeftControls: React.FC = ({