diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f81eb9a..b9ebdbef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,7 +14,7 @@ Chats can be made scene-aware, and each question carries a small snapshot of wha
A new timeline dialog gives scene history a single home, replacing the old Restore from Backup dialog. Drag a slider across the scene's automatic version history to preview the message history at any revision, then fork that revision into a new save — the scene you are playing is never modified, and browsing the history changes nothing on disk.
-Forking is the only action the timeline applies in this version. Rolling a scene back in place and opening a revision directly are disabled while they are being reworked, and will return in a future version.
+Forking is the only action the timeline applies. Rolling a scene back in place and opening a revision directly are disabled in this version.
### Pi Bridge Client
diff --git a/CHANGELOG.yaml b/CHANGELOG.yaml
index e6c21cb7..9e386342 100644
--- a/CHANGELOG.yaml
+++ b/CHANGELOG.yaml
@@ -33,11 +33,12 @@
- "Scene Browser Landing Page: The Quick Load cards now pop out slightly on mouse hover, without shifting the cards around them."
- "Prompt Finalization: UX polish for the post-processing actions editor — action fields lay out on a single balanced row with per-mode visibility (flags only show for exact and regex modes), each action card gained a compact header showing its mode and target plus tooltips on all controls, disabled actions dim their fields, an empty action list shows a hint instead of blank space."
changes:
- - "Timeline: Forking a revision to a new save is the only action the timeline applies in this release. Rolling a scene back in place and opening a revision directly are disabled while they are being reworked and will return in a future version — their websocket routes are gone, so no timeline action can write over an existing scene file. Browsing and previewing revisions are unaffected, and forking now refuses a save name that already exists instead of writing over it."
+ - "Timeline: Forking a revision to a new save is the only action the timeline applies in this release. Rolling a scene back in place and opening a revision directly are disabled in this version — their websocket routes are gone, so no timeline action can write over an existing scene file. Browsing and previewing revisions are unaffected, and forking now refuses a save name that already exists instead of writing over it."
- "Dependencies: Refreshed the dependency lock across the board, including a setuptools bump to 83.0.0 which carries a fix for a source-distribution file-exclusion vulnerability (GHSA-h35f-9h28-mq5c). No dependency required code migrations."
- "OpenRouter Client: The default model for newly created OpenRouter clients is now google/gemini-3.6-flash."
- "OpenRouter Client: New OpenRouter clients now have reasoning enabled by default, with a budget of 2048 reasoning tokens, so the default model works out of the box. With reasoning off Talemate pre-fills the start of the response to steer it, and some providers — Google and Anthropic among them — reject requests that do that."
fixes:
+ - "Scene Forking: Forking from a message now refuses a save name that is not a valid filename or that an existing save already uses, instead of writing over that save or outside the scene directory. The timeline fork already refused both."
- "Prompt Finalization: The fuzzy match threshold slider's always-visible value bubble no longer overlaps the note above it."
- "Frontend: A backend websocket URL configured with the host 0.0.0.0 now connects — the URL was used literally, which most browsers refuse, so the app stayed on 'backend not connected'. The host is resolved to the hostname the UI itself was loaded from."
- "Help Chat: The warning shown while the Help agent has no configured client no longer stretches to fill the entire help panel — it renders as a compact notice above the chat."
diff --git a/docs/user-guide/restoring-scenes.md b/docs/user-guide/restoring-scenes.md
index 238e2be9..46940cd4 100644
--- a/docs/user-guide/restoring-scenes.md
+++ b/docs/user-guide/restoring-scenes.md
@@ -6,7 +6,7 @@ Talemate provides two ways to return a scene to a previous state:
- **Restore from Restore Point** — reset to a specific save file you've designated as a baseline in the [scene settings](/talemate/user-guide/world-editor/scene/settings#restoration-settings)
!!! warning "The timeline only forks in this version"
- Rolling a scene back in place and opening a revision directly are both disabled while they are being reworked, and will return in a future version. Forking a revision into a new save is the one action the timeline applies — it writes a new save alongside the scene and leaves every existing save untouched.
+ Rolling a scene back in place and opening a revision directly are both disabled in this version. Forking a revision into a new save is the one action the timeline applies — it writes a new save alongside the scene and leaves every existing save untouched.
## The timeline
diff --git a/src/talemate/agents/creator/assistant.py b/src/talemate/agents/creator/assistant.py
index 36d87ab8..2f346a29 100644
--- a/src/talemate/agents/creator/assistant.py
+++ b/src/talemate/agents/creator/assistant.py
@@ -1,5 +1,6 @@
import json
import re
+from datetime import datetime, timezone
from typing import TYPE_CHECKING, Tuple
import traceback
import uuid
@@ -12,6 +13,7 @@ from talemate.emit import emit
from talemate.instance import get_agent
from talemate.prompts import Prompt
from talemate.prompts.base import StripMode
+from talemate.util.path import is_safe_relative_filename
from talemate.util.response import extract_list
from talemate.scene_message import CharacterMessage
from talemate.world_state.templates import (
@@ -808,12 +810,44 @@ class AssistantMixin:
This properly creates a new scene file without modifying the current scene,
then signals the frontend to load the new scene.
+
+ The fork always writes a save of its own: a name that is unsafe as a
+ filename, or that would land on an existing file, is refused rather
+ than escaping the save directory or overwriting a save. Without a
+ name it writes a timestamped one beside the scene, slugged so the
+ generated name cannot be the thing that gets refused.
"""
try:
emit("status", "Preparing to fork scene...", status="busy")
if not save_name:
- save_name = self.scene.generate_name()
+ # new scenes before their first save and restored scenes have
+ # no filename
+ base = (
+ os.path.splitext(self.scene.filename)[0]
+ if self.scene.filename
+ else self.scene.project_name
+ )
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
+ save_name = f"{util.slugify(base) or 'scene'}_fork_{stamp}"
+
+ fork_filename = f"{save_name}.json"
+
+ if not is_safe_relative_filename(fork_filename, suffix=".json"):
+ emit(
+ "status",
+ f"'{save_name}' is not a valid save name",
+ status="error",
+ )
+ return
+
+ if os.path.exists(os.path.join(self.scene.save_dir, fork_filename)):
+ emit(
+ "status",
+ f"A save named '{save_name}' already exists — pick a different name",
+ status="error",
+ )
+ return
# Find the message to fork from
message = self.scene.get_message(message_id)
@@ -889,7 +923,7 @@ class AssistantMixin:
scene_data["shared_context"] = ""
# Write the fork file
- fork_file_path = os.path.join(self.scene.save_dir, f"{save_name}.json")
+ fork_file_path = os.path.join(self.scene.save_dir, fork_filename)
with open(fork_file_path, "w") as f:
json.dump(scene_data, f, indent=2, cls=SceneEncoder)
diff --git a/src/talemate/agents/help/docs-index.yaml b/src/talemate/agents/help/docs-index.yaml
index 7a812f96..38c30898 100644
--- a/src/talemate/agents/help/docs-index.yaml
+++ b/src/talemate/agents/help/docs-index.yaml
@@ -998,8 +998,7 @@
history to preview any revision, then fork that revision into a new save; browsing and previewing write nothing to disk,
and a save name an existing save already uses is refused) and restore from a designated restore point save file. Opened
from the scene tools Save menu, a message's Fork button, or the scene card's three-dot menu. Forking is the timeline's
- only action in this version — rolling a scene back in place and opening a revision directly are both disabled while they
- are being reworked.
+ only action in this version — rolling a scene back in place and opening a revision directly are both disabled.
- path: user-guide/saving.md
title: Saving
summary: How scene saving works, .json save files in the project directory, automatic changelog version history, restore
diff --git a/talemate_frontend/src/components/SceneMessages.vue b/talemate_frontend/src/components/SceneMessages.vue
index 514bd5ea..c35a5358 100644
--- a/talemate_frontend/src/components/SceneMessages.vue
+++ b/talemate_frontend/src/components/SceneMessages.vue
@@ -410,6 +410,7 @@ import { isVisualAgentReady, VIS_TYPE } from '@/constants/visual';
import { isKnownSceneCharacter } from '@/utils/entityActions';
import { parseCharacterMessage } from '@/utils/characterMessage.js';
import { primaryModifierLabel } from '@/utils/keyboardModifiers';
+import { base64ToObjectUrl } from '@/utils/objectUrl.js';
import {
getMessageColor as resolveMessageColor,
getMessageStyle as resolveMessageStyle,
@@ -616,6 +617,7 @@ export default {
// cache (the asset may still be in flight when the dialog opens)
assetViewShow: false,
assetViewAssetId: null,
+ sceneBackdropObjectUrl: null,
}
},
computed: {
@@ -651,8 +653,9 @@ export default {
sceneBackdropAssetId() {
return this.sceneBackdrop?.assetId || null;
},
- sceneBackdropSrc() {
- return this.assetDataUrl(this.sceneBackdropAssetId);
+ sceneBackdropAsset() {
+ const assetId = this.sceneBackdropAssetId;
+ return assetId ? this.assetCache[assetId] || null : null;
},
// Most recent message-attached scene background the scene-tools
// "Immersive" chip could promote to a backdrop when none is set yet
@@ -1336,6 +1339,26 @@ export default {
return cached ? `data:${cached.mediaType};base64,${cached.base64}` : null;
},
+ requestSceneBackdropAsset() {
+ const assetId = this.sceneBackdropAssetId;
+ if (assetId && this.requestSceneAssets && !this.assetCache[assetId]) {
+ this.requestSceneAssets([assetId]);
+ }
+ },
+
+ /**
+ * Swap in the backdrop's object URL, revoking the one it replaces.
+ * The backdrop reaches the DOM as a CSS custom property, and chromium
+ * silently drops custom property values over 2 MiB — which a scene
+ * image as a base64 data URL almost always exceeds.
+ */
+ setSceneBackdropObjectUrl(url) {
+ if (this.sceneBackdropObjectUrl) {
+ URL.revokeObjectURL(this.sceneBackdropObjectUrl);
+ }
+ this.sceneBackdropObjectUrl = url;
+ },
+
showAssetMenu(event, context) {
// Store the context (asset_id, asset_type, character, etc.)
this.assetMenu.context = { ...context };
@@ -2170,14 +2193,29 @@ export default {
},
deep: true,
},
- sceneBackdropAssetId(assetId) {
- if (assetId && this.requestSceneAssets && !this.assetCache[assetId]) {
- this.requestSceneAssets([assetId]);
- }
+ sceneBackdropAssetId: {
+ immediate: true,
+ handler() {
+ this.requestSceneBackdropAsset();
+ },
+ },
+ // Scene load empties the asset cache without changing the backdrop id,
+ // so the id watcher alone would leave a reloaded scene without its
+ // backdrop image.
+ sceneBackdropAsset: {
+ immediate: true,
+ handler(asset) {
+ this.setSceneBackdropObjectUrl(
+ asset ? base64ToObjectUrl(asset.base64, asset.mediaType) : null
+ );
+ if (!asset) {
+ this.requestSceneBackdropAsset();
+ }
+ },
},
// The backdrop is painted by TalemateApp behind the whole scene
// column, so hand the resolved image up
- sceneBackdropSrc: {
+ sceneBackdropObjectUrl: {
immediate: true,
handler(src) {
this.$emit('scene-backdrop', src);
@@ -2201,6 +2239,7 @@ export default {
clearTimeout(this._reapplyDebounceTimer);
this._reapplyDebounceTimer = null;
}
+ this.setSceneBackdropObjectUrl(null);
},
}
diff --git a/talemate_frontend/src/components/SceneTimeline.vue b/talemate_frontend/src/components/SceneTimeline.vue
index 3faebef2..3cb2e4c1 100644
--- a/talemate_frontend/src/components/SceneTimeline.vue
+++ b/talemate_frontend/src/components/SceneTimeline.vue
@@ -85,7 +85,7 @@
- Rolling a scene back in place and opening a revision directly are disabled in this version while they are being reworked, and will return in a future version. Fork the revision into a new save instead — it is written alongside the scene and leaves every existing save untouched.
+ Rolling a scene back in place and opening a revision directly are disabled in this version. Fork the revision into a new save instead — it is written alongside the scene and leaves every existing save untouched.
diff --git a/talemate_frontend/src/components/TalemateApp.vue b/talemate_frontend/src/components/TalemateApp.vue
index b47872e1..8985983e 100644
--- a/talemate_frontend/src/components/TalemateApp.vue
+++ b/talemate_frontend/src/components/TalemateApp.vue
@@ -531,7 +531,7 @@ export default {
data() {
return {
appearancePreview: null, // Preview config while editing settings (null = use saved config)
- // data-url of the scene illustration acting as the scene backdrop
+ // object URL of the scene illustration acting as the scene backdrop
// (scene.assets.backdrop), reported up by SceneMessages which owns
// the asset cache
sceneBackdropSrc: null,
diff --git a/talemate_frontend/src/components/WhatsNew.vue b/talemate_frontend/src/components/WhatsNew.vue
index ef2bf953..f8c8ab23 100644
--- a/talemate_frontend/src/components/WhatsNew.vue
+++ b/talemate_frontend/src/components/WhatsNew.vue
@@ -106,7 +106,7 @@ export default {
},
{
"title": "Timeline",
- "description": "A new timeline dialog gives scene history a single home, replacing the old Restore from Backup dialog. Drag a slider across the scene's automatic version history to preview the message history at any revision, then fork that revision into a new save — the scene you are playing is never modified, and browsing the history changes nothing on disk.\n\nForking is the only action the timeline applies in this version. Rolling a scene back in place and opening a revision directly are disabled while they are being reworked, and will return in a future version."
+ "description": "A new timeline dialog gives scene history a single home, replacing the old Restore from Backup dialog. Drag a slider across the scene's automatic version history to preview the message history at any revision, then fork that revision into a new save — the scene you are playing is never modified, and browsing the history changes nothing on disk.\n\nForking is the only action the timeline applies. Rolling a scene back in place and opening a revision directly are disabled in this version."
},
{
"title": "Pi Bridge Client",
diff --git a/talemate_frontend/src/utils/objectUrl.js b/talemate_frontend/src/utils/objectUrl.js
new file mode 100644
index 00000000..fb9d4eb1
--- /dev/null
+++ b/talemate_frontend/src/utils/objectUrl.js
@@ -0,0 +1,14 @@
+/**
+ * Turn base64 asset data into an object URL. Callers own the returned URL and
+ * must revoke it. Asset data is fine as a data URL on an
, but not in
+ * CSS: chromium silently drops custom property values over 2 MiB, which scene
+ * images exceed as base64.
+ */
+export function base64ToObjectUrl(base64, mediaType = 'image/png') {
+ const binary = window.atob(base64);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ return URL.createObjectURL(new Blob([bytes], { type: mediaType }));
+}
diff --git a/tests/test_creator_fork_scene.py b/tests/test_creator_fork_scene.py
new file mode 100644
index 00000000..57bfb381
--- /dev/null
+++ b/tests/test_creator_fork_scene.py
@@ -0,0 +1,153 @@
+"""
+Tests for the save-name guard on ``creator.fork_scene``.
+
+Forking from a message reaches this path with a user-typed name. Like the
+timeline fork, it must refuse a name that is unsafe as a filename or that
+would land on an existing save, rather than escaping the save directory or
+overwriting a save with forked data.
+"""
+
+import os
+import re
+
+import pytest
+
+import talemate.instance as instance
+from talemate.scene_message import NarratorMessage
+
+from conftest import MockClient, MockScene, bootstrap_engine
+
+
+class _ForkScene(MockScene):
+ """Real ``Scene`` with ``save_dir`` pointed at a tmp dir.
+
+ The parent exposes ``save_dir`` as a ``@property`` that joins the scenes
+ directory with the project name; shadowing it with a class-level plain
+ attribute lets the instance own it.
+ """
+
+ save_dir = None # type: ignore[assignment]
+
+ def __init__(self, save_dir: str, **kwargs):
+ super().__init__(**kwargs)
+ self.save_dir = save_dir
+
+
+@pytest.fixture
+def creator(tmp_path):
+ bootstrap_engine()
+ scene = _ForkScene(save_dir=str(tmp_path))
+ # distinct from the name, so a test can tell which one a generated save
+ # name was built from
+ scene.filename = "test_scene.json"
+ scene.name = "Project Scene"
+ scene.history.append(NarratorMessage("the scene begins"))
+ agent = instance.get_agent("creator")
+ agent.client = MockClient("test_client")
+ agent.scene = scene
+ return agent
+
+
+@pytest.fixture
+def emitted(monkeypatch):
+ """Capture the agent's status emissions as ``(typ, message, kwargs)``."""
+ calls = []
+ monkeypatch.setattr(
+ "talemate.agents.creator.assistant.emit",
+ lambda typ, message=None, **kwargs: calls.append((typ, message, kwargs)),
+ )
+ return calls
+
+
+def assert_refused(emitted, message):
+ assert ("status", message, {"status": "error"}) in emitted
+
+
+@pytest.fixture
+def message_id(creator):
+ return creator.scene.history[0].id
+
+
+async def test_fork_writes_a_new_save(creator, message_id, tmp_path):
+ fork_path = await creator.fork_scene(message_id, save_name="forked")
+
+ assert fork_path == os.path.join(str(tmp_path), "forked.json")
+ assert os.path.exists(fork_path)
+
+
+@pytest.mark.parametrize("save_name", [None, ""])
+async def test_fork_without_a_name_writes_a_timestamped_save(
+ creator, message_id, tmp_path, save_name
+):
+ fork_path = await creator.fork_scene(message_id, save_name=save_name)
+
+ assert fork_path is not None
+ assert os.path.exists(fork_path)
+ assert re.fullmatch(
+ r"test-scene_fork_\d{8}T\d{6}Z\.json", os.path.basename(fork_path)
+ )
+
+
+async def test_fork_without_a_name_falls_back_to_the_project_name(
+ creator, message_id, tmp_path
+):
+ """New scenes before their first save, and restored scenes, have no
+ filename."""
+ creator.scene.filename = ""
+
+ fork_path = await creator.fork_scene(message_id)
+
+ assert fork_path is not None
+ assert os.path.exists(fork_path)
+ assert os.path.basename(fork_path).startswith("project-scene_fork_")
+
+
+@pytest.mark.parametrize(
+ "scene_name,expected_base",
+ [
+ ("Fate/Stay Night", "fate-stay-night"),
+ ("..", "scene"),
+ ],
+)
+async def test_fork_without_a_name_slugs_the_base(
+ creator, message_id, tmp_path, scene_name, expected_base
+):
+ """A scene name the guard would refuse must not make the no-name fork
+ impossible — the caller supplied nothing, so there is nothing to correct."""
+ creator.scene.filename = ""
+ creator.scene.name = scene_name
+
+ fork_path = await creator.fork_scene(message_id)
+
+ assert fork_path is not None
+ assert os.path.exists(fork_path)
+ assert re.fullmatch(
+ rf"{expected_base}_fork_\d{{8}}T\d{{6}}Z\.json", os.path.basename(fork_path)
+ )
+
+
+async def test_fork_refuses_a_name_that_escapes_the_save_dir(
+ creator, message_id, tmp_path, emitted
+):
+ outside = tmp_path.parent / "escaped.json"
+
+ assert await creator.fork_scene(message_id, save_name="../escaped") is None
+ assert not outside.exists()
+ assert_refused(emitted, "'../escaped' is not a valid save name")
+
+
+@pytest.mark.parametrize("save_name", ["nested/fork", "..", "with\x00nul"])
+async def test_fork_refuses_unsafe_names(creator, message_id, tmp_path, save_name):
+ assert await creator.fork_scene(message_id, save_name=save_name) is None
+ assert list(tmp_path.iterdir()) == []
+
+
+async def test_fork_refuses_an_existing_save(creator, message_id, tmp_path, emitted):
+ existing = tmp_path / "taken.json"
+ existing.write_text("original")
+
+ assert await creator.fork_scene(message_id, save_name="taken") is None
+ assert existing.read_text() == "original"
+ assert_refused(
+ emitted, "A save named 'taken' already exists — pick a different name"
+ )