diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index b88fbf67..392dc72f 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -60,6 +60,13 @@ jobs: python -m pip install --upgrade pip pip install -e . pip install pyinstaller uvicorn + - name: Set frontend package version + run: | + VERSION="${{ env.RELEASE_VERSION }}" + VERSION_CLEAN="${VERSION#v}" + python -c "import json; p='src/frontend/package.json'; d=json.load(open(p)); d['version']='${VERSION_CLEAN}'; json.dump(d, open(p, 'w'), indent=2)" + - name: Build Frontend + run: | cd src/frontend npm ci npm run build @@ -151,6 +158,13 @@ jobs: python -m pip install --upgrade pip pip install -e . pip install pyinstaller uvicorn + - name: Set frontend package version + run: | + VERSION="${{ env.RELEASE_VERSION }}" + VERSION_CLEAN="${VERSION#v}" + python -c "import json; p='src/frontend/package.json'; d=json.load(open(p)); d['version']='${VERSION_CLEAN}'; json.dump(d, open(p, 'w'), indent=2)" + - name: Build Frontend + run: | cd src/frontend npm ci npm run build diff --git a/resources/config/instructions.json b/resources/config/instructions.json index 1c40184f..df5e1649 100644 --- a/resources/config/instructions.json +++ b/resources/config/instructions.json @@ -139,9 +139,9 @@ "- After major milestones, it is acceptable to pause and ask whether the user wants to continue unless the user asked for mostly autonomous progress.", "", "CORE TOOLS:", - "- call_writing_llm: Use this whenever new story prose, new scene text, or any other fresh narrative content must be written. This tool is stateless: never assume the WRITING LLM knows chapter order, chapter IDs, or current chapter content unless you pass that data in this call. Set write_mode='append' to add to the end of a chapter, 'replace' to overwrite, 'insert_at_marker' to insert at ~~~, or omit write_mode to get text without writing. For append continuity, provide preceding_content with the prose immediately before the insertion point when available; if omitted, the backend auto-fills it from the target chapter tail. Provide only the parameters shown in the active function schema.", + "- call_writing_llm: Use this whenever new story prose, new scene text, or any other fresh narrative content must be written. This tool is stateless: never assume the WRITING LLM knows chapter order, chapter IDs, or current chapter content unless you pass that data in this call. Set write_mode='append' to add to the end of a chapter, 'replace' to swap out a specific passage (provide replace_target with the exact text to replace — first occurrence only), 'replace_all' to REPLACE THE COMPLETE EXISTING CONTENT of the chapter/draft (all prior text is permanently overwritten — use only for full rewrites), 'insert_at_marker' to insert at ~~~, or omit write_mode to get text without writing. For append continuity, provide preceding_content with the prose immediately before the insertion point when available; if omitted, the backend auto-fills it from the target chapter tail. Provide only the parameters shown in the active function schema.", "- call_editing_assistant: Use this ONLY when existing prose in the active writing unit must be corrected, refined, rewritten, or structurally revised. The task argument must always reference actual stored project text.", - "- get_project_overview: List the current structure with IDs for the active project. This overview omits per-unit notes by default; request them when needed.", + "- get_project_overview: List the current structure with IDs for the active project. This overview includes per-unit notes by default; omit them only when the overview must stay brief.", "- get_story_metadata / update_story_metadata: Maintain story-level metadata (title, summary, tags, notes, conflicts when present). Prefer *_patch arguments (summary_patch, notes_patch, tags_patch, conflicts_patch) for partial edits so existing content is preserved unless explicit full replacement is intended. These are the only story-metadata tools; get_story_summary and set_story_tags no longer exist as separate tools.", "- get_chapter_metadata / update_chapter_metadata / get_chapter_summaries / write_chapter_summary / sync_summary: Maintain chapter-level metadata for projects that support chapters. Prefer *_patch arguments (summary_patch, notes_patch, conflicts_patch) for partial edits. Conflicts are first class here; prefer conflict updates over generic notes updates.", "- get_chapter_content: Inspect existing prose in projects that support chapters when deciding what to do next.", @@ -150,6 +150,7 @@ "- search_in_project (scope='sourcebook') / list_sourcebook_entries / get_sourcebook_entry / create_sourcebook_entry / update_sourcebook_entry / delete_sourcebook_entry / add_sourcebook_relation / remove_sourcebook_relation: Maintain world knowledge. For update_sourcebook_entry prefer description_patch/synonyms_patch/images_patch when changing only part of a field. Use add_sourcebook_relation/remove_sourcebook_relation for relation-only edits instead of replacing relation sets.", "- Keep metadata patch calls compact: send only the smallest single-field operation needed (avoid multi-field replacement payloads unless required).", "- sync_summary: Auto-generate a summary from the current project prose context.", + "- undo_last_tool_changes: Undo the most recent LLM tool call's project changes (scope='last_call'), or roll back all project changes made by LLM tools since the last user prompt (scope='all_this_turn'). For 'all_this_turn', pass the batch_ids from all tool results in this turn. Use this to recover from unintended modifications before the user reviews them.", "- create_new_chapter / write_chapter_heading: Maintain chapter structure in projects that support chapters. Chapter and book reordering is done via the GUI or REST API, not via LLM tools.", "- list_images / generate_image_description / create_image_placeholder / set_image_metadata: Manage project visuals.", "- read_scratchpad / write_scratchpad: State management for per-chat, temporary planning state. Avoid storing permanent facts here; use story notes or sourcebook entries for durable world model updates.", @@ -163,7 +164,7 @@ "IMPORTANT:", "- Do NOT write story prose yourself in the chat response. Use call_writing_llm instead.", "- Do NOT perform prose editing yourself in the chat response. Use call_editing_assistant instead.", - "- call_writing_llm can write directly to the project when you specify write_mode. Use write_mode='append' to continue the story, 'replace' for full rewrites, 'insert_at_marker' for targeted insertion at ~~~, or omit write_mode to review the text before deciding. For chapter-based projects (novel/series), provide the chap_id. For short-story projects, chap_id is optional and will auto-detect. Important: call_writing_llm is stateless, so always pass the exact chapter/content context it must use in this request. For append, include preceding_content when you have a precise local anchor; otherwise the backend uses the last paragraphs of the target chapter. Without write_mode, it only returns text that you can then integrate via call_editing_assistant or other tools.", + "- call_writing_llm can write directly to the project when you specify write_mode. Use write_mode='append' to continue the story, 'replace' to swap a specific existing passage with the generated text (set replace_target to the exact passage to replace — first occurrence only), 'replace_all' to REPLACE THE COMPLETE EXISTING CONTENT of the chapter/draft with newly generated text (WARNING: all prior text is permanently overwritten — only use when a full replacement is explicitly intended), 'insert_at_marker' for targeted insertion at ~~~, or omit write_mode to review the text before deciding. For chapter-based projects (novel/series), provide the chap_id. For short-story projects, chap_id is optional and will auto-detect. Important: call_writing_llm is stateless, so always pass the exact chapter/content context it must use in this request. For append, include preceding_content when you have a precise local anchor; otherwise the backend uses the last paragraphs of the target chapter. Without write_mode, it only returns text that you can then integrate via call_editing_assistant or other tools.", "- The call_editing_assistant is a prose editor ONLY. It operates exclusively on existing project prose already stored in the active writing unit. NEVER use it for character analysis, psychological insights, world-building advice, brainstorming, answering questions, or any task that does not directly edit specific stored prose. Answer those requests yourself.", "- When returning text that may be used as tool arguments (e.g. JSON parameter values), use typographic quotation marks (“ ”) instead of straight double quotes (\") for string content to avoid breaking JSON formatting.", "- You may update metadata directly when appropriate because that is part of your role.", @@ -485,7 +486,8 @@ "{instruction}", "", "Context materials:", - "{context}" + "{context}", + "{sourcebook_entries}" ] }, "call_writing_llm_preceding_anchor": { @@ -497,6 +499,30 @@ "{preceding_content}" ] }, + "sourcebook_entries_block": { + "_use": "WRITING", + "en": ["Relevant sourcebook entries:"] + }, + "sourcebook_entry_summary": { + "_use": "WRITING", + "en": ["- {name} ({category}): {description}"] + }, + "sourcebook_entry_relations": { + "_use": "WRITING", + "en": [" Relations: {relation_text}"] + }, + "sourcebook_entry_relations_none": { + "_use": "WRITING", + "en": ["None"] + }, + "sourcebook_entry_unknown_category": { + "_use": "WRITING", + "en": ["Unknown"] + }, + "sourcebook_entry_missing_description": { + "_use": "WRITING", + "en": ["No description available."] + }, "call_editing_assistant_request": { "_use": "EDITING", "en": [ 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/services/chat/chat_tools/__init__.py b/src/augmentedquill/services/chat/chat_tools/__init__.py index 637ee4d7..e3189b76 100644 --- a/src/augmentedquill/services/chat/chat_tools/__init__.py +++ b/src/augmentedquill/services/chat/chat_tools/__init__.py @@ -23,6 +23,7 @@ from augmentedquill.services.chat.chat_tools import story_tools # noqa: F401 from augmentedquill.services.chat.chat_tools import web_search_tools # noqa: F401 from augmentedquill.services.chat.chat_tools import search_tools # noqa: F401 +from augmentedquill.services.chat.chat_tools import undo_tools # noqa: F401 __all__ = [ "chapter_tools", @@ -34,4 +35,5 @@ "story_tools", "web_search_tools", "search_tools", + "undo_tools", ] 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 a38876cb..638a51f3 100644 --- a/src/augmentedquill/services/chat/chat_tools/chapter_prose_tools.py +++ b/src/augmentedquill/services/chat/chat_tools/chapter_prose_tools.py @@ -13,6 +13,7 @@ from pydantic import AliasChoices, BaseModel, Field from augmentedquill.core.config import load_story_config +from augmentedquill.core.prompts import get_user_prompt from augmentedquill.utils.json_repair import apply_typographic_quotes from augmentedquill.services.chapters.chapter_helpers import _chapter_by_id_or_404 from augmentedquill.services.chat.chat_tool_decorator import ( @@ -72,6 +73,82 @@ def _extract_tail_paragraphs(text: str, max_paragraphs: int = 3) -> str: return "\n\n".join(paragraphs[-max_paragraphs:]) +def _format_sourcebook_entry_prompt(entry: dict, language: str) -> str: + """Format a sourcebook entry compactly for the WRITING LLM prompt.""" + name = entry.get("name", "") + category = entry.get("category") or get_user_prompt( + "sourcebook_entry_unknown_category", language=language + ) + description = entry.get("description", "").strip() + if not description: + description = get_user_prompt( + "sourcebook_entry_missing_description", + language=language, + ) + + relations = entry.get("relations") or [] + relation_lines: list[str] = [] + for relation in relations: + relation_type = relation.get("relation", "") + target_id = relation.get("target_id", "") + direction = relation.get("direction", "forward") + if relation_type and target_id: + relation_lines.append(f"{relation_type} ({direction}) -> {target_id}") + relation_text = ( + "; ".join(relation_lines) + if relation_lines + else get_user_prompt( + "sourcebook_entry_relations_none", + language=language, + ) + ) + + summary = get_user_prompt( + "sourcebook_entry_summary", + language=language, + name=name, + category=category, + description=description, + ) + relations_line = get_user_prompt( + "sourcebook_entry_relations", + language=language, + relation_text=relation_text, + ) + + return f"{summary}\n{relations_line}" + + +def _build_sourcebook_entries_context(entry_names: list[str], language: str) -> str: + """Build a compact sourcebook context block for the writing prompt.""" + from augmentedquill.services.sourcebook.sourcebook_helpers import ( + sourcebook_get_entry, + ) + + seen_ids: set[str] = set() + lines: list[str] = [] + for name_or_synonym in entry_names: + if not isinstance(name_or_synonym, str) or not name_or_synonym.strip(): + continue + entry = sourcebook_get_entry(name_or_synonym) + if not entry: + continue + entry_id = str(entry.get("id") or "") + if entry_id in seen_ids: + continue + seen_ids.add(entry_id) + lines.append(_format_sourcebook_entry_prompt(entry, language=language)) + + if not lines: + return "" + + return ( + get_user_prompt("sourcebook_entries_block", language=language) + + "\n" + + "\n".join(lines) + ) + + # ============================================================================ # call_writing_llm # ============================================================================ @@ -93,9 +170,17 @@ class CallWritingLlmParams(BaseModel): validation_alias=AliasChoices("preceding_content", "preceeding_content"), description="Optional prose immediately preceding the insertion point. In append mode, if omitted, the system auto-fills this with the last paragraphs of the target chapter.", ) + sourcebook_entries: list[str] | None = Field( + None, + description="Optional list of sourcebook entry names or synonyms to include in the WRITING LLM prompt. Matching entries are resolved by name or synonym and added compactly with category, description, and relations.", + ) write_mode: str | None = Field( None, - description="How to persist output: 'append' (add to end of chapter), 'replace' (overwrite entire chapter), 'insert_at_marker' (insert at ~~~ marker), or None (return text without writing).", + description="How to persist output: 'append' (add to end of chapter), 'replace' (replace the single occurrence of replace_target in the chapter with the generated text — replace_target is required), 'replace_all' (REPLACES THE COMPLETE EXISTING CONTENT of the chapter/draft with the newly generated text — all prior content is lost), 'insert_at_marker' (insert at ~~~ marker), or None (return text without writing).", + ) + replace_target: str | None = Field( + None, + description="Required when write_mode='replace': the exact text passage to search for in the chapter. The first occurrence is replaced with the generated text. Must match the existing text exactly.", ) chap_id: int | None = Field( None, @@ -104,7 +189,7 @@ class CallWritingLlmParams(BaseModel): @chat_tool( - description="Delegate a creative writing or rewriting task to the WRITING LLM. Stateless behavior: it only sees instruction/context provided in this call, so include all required chapter context and exact IDs explicitly. Can optionally write the output directly to a chapter with write_mode: 'append' adds to end, 'replace' overwrites all, 'insert_at_marker' inserts at ~~~ marker. Without write_mode, just returns generated text.", + description="Delegate a creative writing or rewriting task to the WRITING LLM. Stateless behavior: it only sees instruction/context provided in this call, so include all required chapter context and exact IDs explicitly. Can optionally write the output directly to a chapter with write_mode: 'append' adds to end, 'replace' substitutes a specific passage (set replace_target to the exact text to swap out — only the first occurrence is replaced), 'replace_all' REPLACES THE COMPLETE EXISTING CONTENT of the chapter/draft (all prior text is permanently overwritten — use only for full rewrites), 'insert_at_marker' inserts at ~~~ marker. Without write_mode, just returns generated text.", allowed_roles=(CHAT_ROLE, EDITING_ROLE), capability="delegation", project_types=("short-story", "novel", "series"), @@ -188,6 +273,18 @@ async def call_writing_llm( instruction=params.instruction, context=params.context, ) + + sourcebook_context = _build_sourcebook_entries_context( + params.sourcebook_entries or [], project_lang + ) + user_content = get_user_prompt( + "call_writing_llm_request", + language=project_lang, + instruction=params.instruction, + context=params.context, + sourcebook_entries=sourcebook_context, + ) + if preceding_content: user_content += get_user_prompt( "call_writing_llm_preceding_anchor", @@ -307,8 +404,20 @@ async def call_writing_llm( } elif params.write_mode == "replace": - # Replace entire chapter content - _write_chapter_content(chap_id, generated_text) + # Replace first occurrence of replace_target with generated text. + if not params.replace_target: + raise BadRequestError( + "replace_target is required when write_mode='replace'. " + "Provide the exact text passage to search for and replace." + ) + existing = path.read_text(encoding="utf-8") + if params.replace_target not in existing: + raise BadRequestError( + f"replace_target not found in chapter {chap_id}. " + "Ensure the text matches the chapter content exactly." + ) + new_content = existing.replace(params.replace_target, generated_text, 1) + _write_chapter_content(chap_id, new_content) mutations["story_changed"] = True return { "generated_text": generated_text, @@ -317,6 +426,19 @@ async def call_writing_llm( "chap_id": chap_id, } + elif params.write_mode == "replace_all": + # Replace entire chapter content — ALL prior content is overwritten. + _write_chapter_content(chap_id, generated_text) + mutations["story_changed"] = True + return { + "generated_text": generated_text, + "written": True, + "write_mode": "replace_all", + "replaced_complete_content": True, + "chap_id": chap_id, + "status": "Complete chapter content has been replaced with the newly generated text.", + } + elif params.write_mode == "insert_at_marker": # Insert at ~~~ marker (replace marker with text) existing = path.read_text(encoding="utf-8") @@ -343,7 +465,7 @@ async def call_writing_llm( else: raise BadRequestError( f"Invalid write_mode: {params.write_mode}. " - "Use 'append', 'replace', 'insert_at_marker', or omit for return-only." + "Use 'append', 'replace', 'replace_all', 'insert_at_marker', or omit for return-only." ) # Default: just return the generated text without writing diff --git a/src/augmentedquill/services/chat/chat_tools/project_tools.py b/src/augmentedquill/services/chat/chat_tools/project_tools.py index d97f521e..ebc75824 100644 --- a/src/augmentedquill/services/chat/chat_tools/project_tools.py +++ b/src/augmentedquill/services/chat/chat_tools/project_tools.py @@ -34,8 +34,8 @@ class GetProjectOverviewParams(BaseModel): """Parameters for get_project_overview.""" include_notes: bool = Field( - False, - description="If true, include per-chapter notes in the overview output (default false).", + True, + description="If true, include per-chapter notes in the overview output (default true).", ) diff --git a/src/augmentedquill/services/chat/chat_tools/undo_tools.py b/src/augmentedquill/services/chat/chat_tools/undo_tools.py new file mode 100644 index 00000000..7de88639 --- /dev/null +++ b/src/augmentedquill/services/chat/chat_tools/undo_tools.py @@ -0,0 +1,175 @@ +# 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. + +"""Defines the undo tools unit so this responsibility stays isolated, testable, and easy to evolve. + +LLM-callable tool that lets the CHAT LLM undo its own recent project modifications +without requiring user intervention via the frontend undo button. +""" + +import json +import re +from pathlib import Path +from typing import Any + +from pydantic import BaseModel, Field + +from augmentedquill.services.chat.chat_tool_decorator import ( + CHAT_ROLE, + EDITING_ROLE, + chat_tool, +) +from augmentedquill.services.projects.project_snapshots import restore_project_snapshot +from augmentedquill.services.projects.projects import get_active_project_dir + +# Must match _CHAT_TOOL_BATCH_DIR in augmentedquill.api.v1.chat +_BATCH_DIR = ".aq_history/chat_tool_batches" +_BATCH_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,80}$") + + +def _load_batch(project_dir: Path, batch_id: str) -> dict[str, Any]: + """Load a stored batch snapshot, raising BadRequestError on invalid input.""" + from augmentedquill.services.exceptions import BadRequestError + + if not _BATCH_ID_RE.fullmatch(batch_id): + raise BadRequestError(f"Invalid batch id: {batch_id!r}") + batch_file = project_dir / _BATCH_DIR / batch_id / "batch.json" + if not batch_file.is_file(): + raise BadRequestError(f"No snapshot found for batch: {batch_id!r}") + return json.loads(batch_file.read_text(encoding="utf-8")) + + +def _find_most_recent_batch(project_dir: Path) -> dict[str, Any] | None: + """Return the most recently created batch snapshot for the project, or None.""" + batches_dir = project_dir / _BATCH_DIR + if not batches_dir.is_dir(): + return None + + entries: list[dict[str, Any]] = [] + for batch_file in batches_dir.glob("*/batch.json"): + try: + data = json.loads(batch_file.read_text(encoding="utf-8")) + if isinstance(data, dict) and data.get("batch_id"): + entries.append(data) + except (OSError, json.JSONDecodeError): + pass + + if not entries: + return None + + entries.sort(key=lambda e: str(e.get("created_at") or ""), reverse=True) + return entries[0] + + +# ============================================================================ +# undo_last_tool_changes +# ============================================================================ + + +class UndoLastToolChangesParams(BaseModel): + """Parameters for the undo_last_tool_changes tool.""" + + scope: str = Field( + ..., + description=( + "'last_call' undoes the project changes made by the single most recent LLM" + " tool call (usually a smaller delta than one full undo-stack entry)." + " 'all_this_turn' undoes ALL project changes made by LLM tools since the" + " last user prompt, effectively reverting the project to the state it was in" + " before the current LLM turn began." + " Pass 'last_call' to fix the most recent mistake; pass 'all_this_turn'" + " when you want to start the current turn's work over from scratch." + ), + ) + batch_ids: list[str] | None = Field( + None, + description=( + "For scope='all_this_turn': provide ALL batch_id values received in tool" + " results for this turn, oldest first, so every batch can be reversed in" + " correct order. For scope='last_call': omit this (the system auto-detects" + " the most recent batch) or pass just the single batch_id to undo." + ), + ) + + +@chat_tool( + description=( + "Undo recent LLM tool call project changes without waiting for the user to" + " press the undo button." + " scope='last_call' reverses only the most recent tool call's project" + " modifications (granular undo — usually much less than one full undo-stack" + " entry)." + " scope='all_this_turn' reverses ALL project modifications made by LLM tools" + " since the last user prompt, returning the project to its pre-turn state" + " (equivalent to popping the current undo-stack entry)." + " After a successful undo the tool reports which batches were restored so you" + " can confirm the rollback and decide how to proceed." + ), + allowed_roles=(CHAT_ROLE, EDITING_ROLE), + capability="undo", + project_types=("short-story", "novel", "series"), +) +async def undo_last_tool_changes( + params: UndoLastToolChangesParams, payload: dict, mutations: dict +) -> Any: + """Restore project content to the state before one or more recent tool call batches.""" + from augmentedquill.services.exceptions import BadRequestError + + if params.scope not in ("last_call", "all_this_turn"): + raise BadRequestError( + f"Invalid scope: {params.scope!r}. Use 'last_call' or 'all_this_turn'." + ) + + project_dir = get_active_project_dir() + if project_dir is None: + raise BadRequestError("No active project. Cannot undo.") + + batches_to_undo: list[dict[str, Any]] = [] + + if params.scope == "last_call": + if params.batch_ids: + # Use the last provided batch_id as the single target. + batch = _load_batch(project_dir, params.batch_ids[-1]) + batches_to_undo = [batch] + else: + batch = _find_most_recent_batch(project_dir) + if batch is None: + raise BadRequestError( + "No tool call batches found for the active project." + ) + batches_to_undo = [batch] + + else: # all_this_turn + if not params.batch_ids: + raise BadRequestError( + "scope='all_this_turn' requires batch_ids: provide all batch_id values" + " from tool results in this turn, oldest first." + ) + for bid in params.batch_ids: + batches_to_undo.append(_load_batch(project_dir, bid)) + + # Restore batches in reverse order so we unwind the most recent change first. + restored_ids: list[str] = [] + for batch in reversed(batches_to_undo): + before_snapshot = batch.get("before") + if not isinstance(before_snapshot, dict): + raise BadRequestError( + f"Batch {batch.get('batch_id')!r} has an invalid snapshot." + ) + restore_project_snapshot(project_dir, before_snapshot) + restored_ids.append(str(batch.get("batch_id") or "")) + + mutations["story_changed"] = True + return { + "undone": True, + "scope": params.scope, + "restored_batches": restored_ids, + "status": ( + f"Reverted {len(restored_ids)} batch(es): {', '.join(restored_ids)}." + " Project content has been restored to its prior state." + ), + } 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/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/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/app/useAppChatRuntime.ts b/src/frontend/features/app/useAppChatRuntime.ts index 8de9bfec..1b9e5c07 100644 --- a/src/frontend/features/app/useAppChatRuntime.ts +++ b/src/frontend/features/app/useAppChatRuntime.ts @@ -254,6 +254,10 @@ export function useAppChatRuntime({ refreshStory, onProseChunk: useCallback( (chapterId: number, writeMode: string, accumulated: string): void => { + if (!useChatStore.getState().isChatLoading) { + return; + } + const currentStory = storyRef.current; const unit = currentStory.projectType === 'short-story' && currentStory.draft 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/Editor.tsx b/src/frontend/features/editor/Editor.tsx index ae8b491d..866faa63 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. @@ -156,30 +159,33 @@ export const Editor = React.memo( useEffect((): void => { const isChapterSwitch = chapter.id !== lastChapterIdRef.current; - if (!isChapterSwitch) return; - - lastChapterIdRef.current = chapter.id; - prevBaselineRef.current = baselineContent; - setLocalBaseline(baselineContent); - if (baselineContent !== undefined && baselineContent !== chapter.content) { - savedBaselineRef.current = baselineContent; - } else if (baselineContent === undefined) { - savedBaselineRef.current = undefined; + if (isChapterSwitch) { + lastChapterIdRef.current = chapter.id; + prevBaselineRef.current = baselineContent; + setLocalBaseline(baselineContent); + if (baselineContent !== undefined && baselineContent !== chapter.content) { + savedBaselineRef.current = baselineContent; + } else if (baselineContent === undefined) { + savedBaselineRef.current = undefined; + } + return; } - }, [chapter.id, baselineContent, chapter.content]); - if (baselineContent !== prevBaselineRef.current) { - prevBaselineRef.current = baselineContent; - setLocalBaseline(baselineContent); - // Only preserve as the real AI baseline when baselineContent differs from - // chapter.content. When isUserEdit=true, pushState sets baselineContent - // equal to chapter.content (no diff), so we must not overwrite the saved - // AI baseline with the user-edited value — otherwise Ctrl+Z would restore - // that wrong baseline instead of the original AI-written baseline. - if (baselineContent !== undefined && baselineContent !== chapter.content) { - savedBaselineRef.current = baselineContent; + if (baselineContent !== prevBaselineRef.current) { + prevBaselineRef.current = baselineContent; + setLocalBaseline(baselineContent); + // Only preserve as the real AI baseline when baselineContent differs from + // chapter.content. When isUserEdit=true, pushState sets baselineContent + // equal to chapter.content (no diff), so we must not overwrite the saved + // AI baseline with the user-edited value — otherwise Ctrl+Z would restore + // that wrong baseline instead of the original AI-written baseline. + if (baselineContent !== undefined && baselineContent !== chapter.content) { + savedBaselineRef.current = baselineContent; + } else if (baselineContent === undefined) { + savedBaselineRef.current = undefined; + } } - } + }, [chapter.id, baselineContent, chapter.content]); const isChatStreaming = useChatStore( (s: ChatStoreState): boolean => s.isProseStreamingFromChat @@ -216,6 +222,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 +238,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 +260,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 +321,7 @@ export const Editor = React.memo( distanceFromBottomRef, } = useEditorScroll({ localContent, - isProseStreaming, + isProseStreaming: proseStreamingActive, isReplaceStreaming, chapterId: chapter.id, }); @@ -668,7 +693,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 +708,7 @@ export const Editor = React.memo( continuations, isAiLoading, isSuggesting, - isProseStreaming, + proseStreamingActive, hasContinuationOptions, scrollMainContentToBottom, ]); 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/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..d2e638d3 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 { @@ -25,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. */ @@ -54,11 +51,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 +67,330 @@ 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 isNearBottom = useCallback((container: HTMLDivElement): boolean => { + return scrollDistanceFromBottom(container) <= FOLLOW_ATTACH_DISTANCE; + }, []); + + const isWithinReattachRange = useCallback((container: HTMLDivElement): boolean => { + return scrollDistanceFromBottom(container) <= FOLLOW_REATTACH_DISTANCE; + }, []); + + const updateDistanceFromContainer = useCallback((container: HTMLDivElement): void => { + distanceFromBottomRef.current = scrollDistanceFromBottom(container); + }, []); + + const syncDetachedFlag = useCallback((): void => { + isDetachedFromBottomRef.current = !shouldAutoFollowRef.current; + }, []); - distanceFromBottomRef.current = distanceFromBottom; + 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(); + } + + 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 = scrollDistanceFromBottom(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; + } + if (!shouldAutoFollowRef.current) { + return; + } + const liveDistance = scrollDistanceFromBottom(container); + if (liveDistance <= FOLLOW_WRITE_EPSILON_PX) { + updateDistanceFromContainer(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; + 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; + }); + }, [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 = scrollDistanceFromBottom(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(); + }, [ + 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, diff --git a/src/frontend/features/layout/AppHeader.tsx b/src/frontend/features/layout/AppHeader.tsx index 6b15c952..2ba82648 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,40 +273,45 @@ const HeaderLeftControls: React.FC = ({