Gitea (and any case-sensitive filesystem) was rendering the [Top]
nav links in /docs as broken because of two bugs:
1. Case-sensitivity: 22 links used '../README.md' (all-uppercase)
but the actual file is 'docs/Readme.md' (capital R, lowercase
rest). 21 guide_*.md nav bars were affected, plus 1 internal
cross-link in Readme.md itself. Works on Windows (case-
insensitive) but broken on Linux/Gitea.
Fix: 22 occurrences across 22 files changed
'../README.md' -> '../Readme.md'
2. Wrong relative-path level: 16 links used '../../conductor/...'
from 'docs/guide_*.md' to reach 'conductor/'. This goes up 2
levels to 'projects/', which doesn't exist. The correct path
from 'docs/guide_*.md' to 'conductor/' is 1 level up
('../conductor/...'). 12 unique patterns across 10 files
affected.
Fix: 16 occurrences across 10 files changed
'../../conductor/' -> '../conductor/'
3. Bonus: 1 planned-guide link in guide_context_curation.md
referenced a never-written 'guide_context_presets.md'. The
ContextPreset schema is now fully covered in the new
'guide_context_aggregation.md' (per the 2026-06-08 docs
refresh). Fix: link target updated.
No content was changed, only link paths. 24 files, 37 link
replacements, 37 deletions.
Verification:
- All .md links in docs/ now resolve to existing files
(validated by path-resolution check from each file's directory)
- The 3 new guides from the previous docs refresh commit
(guide_discussions.md, guide_state_lifecycle.md,
guide_context_aggregation.md) had the case bug inherited from
guide_architecture.md's existing nav pattern; their top-of-file
nav bars are now correct
- The 21 pre-existing guide nav bars that had the same bug
(all 21 of them, except the 3 that used the correct case:
guide_mma.md, guide_simulations.md, guide_tools.md) are now
also fixed
- Inter-guide links (e.g. [Discussions](guide_discussions.md))
were not affected; they were always correct because both the
link text and the actual filename are lowercase
This is a docs-only fix. No code modified.
24 KiB
State Lifecycle: Undo/Redo, Reset, and State Delegation
Top | App Controller | Discussions | GUI Main
Overview
Manual Slop's state lifecycle has three load-bearing concerns:
- Undo/Redo via
HistoryManager+UISnapshot— the non-provider history system - Reset via
_handle_reset_session— the "throw away everything" flow - State delegation via
App.__getattr__/__setattr__— the App is a thin proxy over the Controller
This guide covers the implementation of all three. The design philosophy is documented in conductor/tracks/nagent_review_20260608/nagent_takeaways_20260608.md §1 (state visibility) and §9 (edit-the-input, not the output).
Domain classification. All three concerns are Application-domain. The Hook API (which exposes state) is also Application, but crosses into Meta-Tooling via the bridge scripts. See
guide_meta_boundary.md.
1. Undo/Redo: HistoryManager + UISnapshot
Why a Non-Provider History?
Manual Slop's history is not the same as the provider-side conversation history. The provider (ai_client._anthropic_history, _deepseek_history, etc.) tracks the exact bytes sent to and received from the LLM. The Manual Slop history (app.history + UISnapshot) tracks the user's UI state — text inputs, sliders, file lists, discussions.
This separation is intentional. It means:
- The user can
Ctrl+Zto undo a typo in the AI input box, even if the previous LLM call's history is preserved on the provider side. - The provider's history is the authoritative transcript for the LLM; the Manual Slop history is the user's working state.
- A reset of the Manual Slop state does not clear the provider's history (and vice versa) — see §"Reset" below.
This is exactly the kind of "edit the input, not the output" pattern nagent uses; see nagent takeaways §9.
UISnapshot — The Serializable State
src/history.py:8 UISnapshot is a frozen-shaped dataclass capturing the 13 user-mutable fields:
| Field | Type | Source |
|---|---|---|
ai_input |
str |
self.ui_ai_input |
project_system_prompt |
str |
self.ui_project_system_prompt |
global_system_prompt |
str |
self.ui_global_system_prompt |
base_system_prompt |
str |
self.ui_base_system_prompt |
use_default_base_prompt |
bool |
self.ui_use_default_base_prompt |
temperature |
float |
self.temperature |
top_p |
float |
self.top_p |
max_tokens |
int |
self.max_tokens |
auto_add_history |
bool |
self.ui_auto_add_history |
disc_entries |
list[dict] |
copy.deepcopy(self.disc_entries) |
files |
list[dict] |
[f.to_dict() if hasattr(f, 'to_dict') else f for f in self.files] |
context_files |
list[dict] |
[f.to_dict() if hasattr(f, 'to_dict') else f for f in self.context_files] |
screenshots |
list[str] |
list(self.screenshots) |
to_dict() / from_dict() are explicit serializers, used by the Hook API for the /api/session endpoint. The dataclass is not auto-serialized; explicit to_dict is required so the schema is documented.
HistoryManager — The Undo/Redo Stack
src/history.py:71 HistoryManager is a 100-snapshot capacity stack:
class HistoryManager:
def __init__(self, max_capacity: int = 100): ...
def push(self, state: typing.Any, description: str) -> None: ...
def undo(self, current_state: typing.Any, current_description: str = "Current State") -> typing.Optional[HistoryEntry]: ...
def redo(self, current_state: typing.Any, current_description: str = "Current State") -> typing.Optional[HistoryEntry]: ...
def jump_to_undo(self, index: int, current_state: typing.Any, current_description: str = "Before Jump") -> typing.Optional[HistoryEntry]: ...
@property
def can_undo(self) -> bool: ...
@property
def can_redo(self) -> bool: ...
def get_history(self) -> typing.List[typing.Dict[str, typing.Any]]: ...
pushappends a new entry; clears the redo stack; pops the oldest if capacity exceeded.undomoves the current state to the redo stack and returns the top of the undo stack.redois the inverse.jump_to_undoallows time-traveling to any past snapshot, moving subsequent states to the redo stack.get_historyreturns[{description, timestamp}, ...]for the History List view in the GUI.
The max_capacity=100 is the default and is sufficient for a 5-second window of rapid typing or a longer session of infrequent edits.
The Push Trigger — gui_2.py:1140-1170
The undo stack is not pushed on every keystroke. It's pushed via debounced change-detection at the start of every render frame:
current = self._take_snapshot()
if self._last_ui_snapshot is None:
self._last_ui_snapshot = current
return
changed = (
current.ai_input != self._last_ui_snapshot.ai_input or
current.project_system_prompt != self._last_ui_snapshot.project_system_prompt or
# ... 10 more field comparisons ...
len(current.disc_entries) != len(self._last_ui_snapshot.disc_entries) or
len(current.files) != len(self._last_ui_snapshot.files) or
len(current.context_files) != len(self._last_ui_snapshot.context_files) or
len(current.screenshots) != len(self._last_ui_snapshot.screenshots)
)
if not changed and len(current.disc_entries) > 0:
if current.disc_entries[-1].get('content') != self._last_ui_snapshot.disc_entries[-1].get('content'):
changed = True
if changed:
self.history.push(current, description="<auto>")
self._last_ui_snapshot = current
The change detector compares:
- 7 scalar fields directly (
!=) - 2 float fields with epsilon (
abs(...) > 1e-5) - 4 list fields by length
- The
disc_entries[-1]["content"]separately (because streaming AI responses can change the last entry's content without changing the length)
Performance: The check is at the start of every render frame. copy.deepcopy(self.disc_entries) (line 748) is the most expensive part — O(N) where N is the entry count. For a 100-entry discussion, this is microseconds. The full snapshot push only happens when a change is detected.
The Apply Trigger — gui_2.py:819 _apply_undo / _apply_redo / _apply_jump
Three wrapper methods invoke _apply_snapshot(entry.state) with the right description:
_apply_undo(...)— pops from undo, pushes current to redo, applies popped_apply_redo(...)— pops from redo, pushes current to undo, applies popped_apply_jump(index, ...)— invokesjump_to_undo(index, current_state), applies the result
_apply_snapshot is the single restore function (gui_2.py:754-789):
def _apply_snapshot(self, snapshot: history.UISnapshot) -> None:
self._is_applying_snapshot = True
try:
self.ui_ai_input = snapshot.ai_input
self.ui_project_system_prompt = snapshot.project_system_prompt
# ... 10 more assignments ...
self.disc_entries = snapshot.disc_entries
# Restore files as FileItem objects
from src import models
self.files = []
for f in snapshot.files:
if isinstance(f, dict):
self.files.append(models.FileItem.from_dict(f))
else:
self.files.append(models.FileItem(path=str(f)))
# ... similar for context_files, screenshots ...
finally:
self._is_applying_snapshot = False
The _is_applying_snapshot flag is set during the restore to prevent re-pushing a new snapshot from the changes made by the restore itself. (Without this, pressing Ctrl+Z would push a new snapshot identical to the restored one, which would clear the redo stack — making Ctrl+Y a no-op.)
2. State Delegation: App.__getattr__ / __setattr__
The Pattern
App (src/gui_2.py:264+) is a thin wrapper around AppController (src/app_controller.py:772+). The wrapper exists because:
- The Controller is the headless orchestrator (no ImGui dependencies).
- The App is the render side (ImGui calls).
- Render functions take
app: Appand read state viaapp.<field>.
The simplest design would be: copy every Controller field to the App. But that's brittle (every new Controller field requires an App copy). The actual pattern uses Python's __getattr__/__setattr__ for transparent delegation.
App.__getattr__ — Read Fallthrough
gui_2.py:666-669:
def __getattr__(self, name: str) -> Any:
if name == 'controller':
raise AttributeError(name)
return getattr(self.controller, name)
When app.foo is accessed and foo is not an instance attribute of App, Python falls through to __getattr__, which reads from self.controller. This means app.disc_entries, app.history, app.temperature all read from the Controller transparently.
The 'controller' exception prevents infinite recursion if the Controller is not yet set (during early __init__).
App.__setattr__ — Write Fallthrough
gui_2.py:671-675:
def __setattr__(self, name: str, value: Any) -> None:
if name != 'controller' and hasattr(self, 'controller') and hasattr(self.controller, name):
setattr(self.controller, name, value)
else:
object.__setattr__(self, name, value)
When app.foo = bar is assigned and the Controller has a foo attribute, the write goes through to the Controller. Otherwise it stores on the App instance.
Why This Matters
- No data duplication. There is one source of truth (the Controller). The App never has its own copy of
disc_entries,temperature, etc. - No boilerplate. New Controller fields are automatically available via the App without code changes.
- Backward-compatible. Existing App fields (e.g.
app._is_applying_snapshot) work becauseobject.__setattr__is the fallback when the Controller doesn't have the field.
The 4 Edge Cases (per the guide_gui_2.md Known Issues)
app.controlleris itself an App attribute — it must not be delegated, hence the explicitif name == 'controller'guard.- App instance attributes shadow Controller attributes — the
object.__setattr__fallback stores on the App; the next__getattr__call returns the App's value (because Python's normal attribute lookup finds the instance attribute first). This is sometimes intentional (e.g.app._is_applying_snapshotis App-local). - Fields in
app._app(not on the Controller) are stored on the App. The_appfield is the Controller's back-reference to the App (set atgui_2.py:264). - Underscore-prefixed App-specific fields like
app._mma_approval_open,app._pending_ask_dialogare stored on the App because the Controller doesn't have them. They appear in the Controller's__getattr__mirror viahasattrcheck.
Known Fragility
guide_gui_2.md notes: ui_separate_context_preview, ui_separate_message_panel, ui_separate_response_panel, ui_separate_tool_calls_panel, ui_separate_external_tools, ui_discussion_split_h are NOT in the Controller's _settable_fields, so __setattr__ falls through to object.__setattr__ and stores them on the App. This is intentional for window-separator flags (they're render-only and shouldn't pollute the Controller), but it does mean they don't survive a hot-reload of App.
3. Reset: _handle_reset_session
src/app_controller.py:3286-3356 _handle_reset_session is the nuclear reset, called from the "Reset Session" button in the message panel.
What It Clears
| Group | What | Why |
|---|---|---|
| AI client | ai_client.reset_session() + clear_comms_log() |
Clears provider-side history. |
| Tool stats | _tool_log.clear(), _tool_stats.clear(), _comms_log.clear() |
Clears the in-memory activity logs. |
| Discussion | self.disc_entries.clear(); for each discussion in project: discussions[d_name]["history"] = [] |
Empties the current take and all takes across all discussions. |
| Files | self.files.clear(), self.context_files.clear() |
Drops the FileItem lists. |
| Tracks | self.tracks.clear() |
Drops the loaded tracks. |
| Project dict (full replacement) | self.project = project_manager.default_project(...) |
The project is replaced with a fresh default, not mutated. |
| Project paths | self.project_paths = [] |
Clears the recent-projects list. |
| Project switch state | _project_switch_in_progress = False etc. |
Resets the in-flight switch state machine. |
| AI status | ai_status = "session reset", ai_response = "" |
Status bar update. |
| UI inputs | ui_ai_input = "", ui_manual_approve = False, ui_auto_add_history = False |
Empties the message box. |
| MMA | active_track = None, active_tier = None, mma_status = "idle", proposed_tracks = [], active_tickets = [], engines.clear(), mma_streams.clear(), _worker_status.clear() |
Drops all MMA state. |
| Provider/model | _current_provider = "gemini", _current_model = "gemini-2.5-flash-lite", ai_client.set_provider(...) |
Resets to defaults. |
| Locks + queues | _pending_history_adds.clear(), _api_event_queue.clear(), _pending_gui_tasks.clear() |
Drains all queues under their locks. |
| Prompts | ui_use_default_base_prompt = True, all 3 system prompts = '' |
Resets to default base prompt. |
| Persona/tool settings | ui_active_persona = '', ui_active_tool_preset = None, ui_active_bias_profile = None |
Drops active persona. |
| Generation params | temperature = 0.0, top_p = 1.0, max_tokens = 8192 |
Defaults. |
What It Does NOT Touch
| Field | Why preserved |
|---|---|
self.active_project_path |
_do_project_switch writes to this path; clearing it would cause OSError on next switch and an infinite re-switch loop. The 2026-06-08 regression test test_context_sim_live documents this. |
self.history (the HistoryManager) |
The undo stack survives a reset. Ctrl+Z after a reset can restore the pre-reset state. This may be a bug or a feature. |
The on-disk manual_slop.toml |
The saved project TOML is not deleted or rewritten. Switching projects after reset reloads from disk. |
self.discussion_sent_markdown / discussion_sent_system_prompt |
These are cleared (set to ""). They're not in the preserve list. |
The _is_applying_snapshot Guard During Reset
_handle_reset_session does not set self._is_applying_snapshot. This means the change-detection logic will push a new snapshot to the undo stack after the reset. The snapshot will contain the post-reset state. To get the pre-reset state, the user must Ctrl+Z twice (once to push the post-reset snapshot, once to restore the pre-reset snapshot).
This is a known papercut. The fix is to set self._is_applying_snapshot = True during the reset and clear it at the end. See tests/test_session_logger_reset.py:test_reset_session for the current behavior.
The 2026-06-08 Regression
app_controller.py:3307-3312 documents a regression that was caught by test_context_sim_live:
The test's
client.click("btn_reset")resets the AI session but does not reset the project (see_handle_reset_sessionat line 3244 — it clears files, context_files, disc_entries, etc. but not self.project or self.active_project_path).
The fix was to not clear self.active_project_path. The project dict is replaced (self.project = project_manager.default_project(...)), but the path is preserved. This is the right behavior: the user is resetting the session, not abandoning the project.
4. State Synchronization Across Threads
The state lifecycle has 4 distinct threads of access:
- Render thread (60 FPS) — reads
app.<field>to render ImGui widgets - AI response callback thread (background) — appends to
app.disc_entriesunder_disc_entries_lock - Hook API thread (HTTP server on
127.0.0.1:8999) — reads via_gettable_fieldsand writes via_predefined_callbacks - MMA worker thread(s) — write to
_api_event_queue,_pending_gui_tasks,_pending_history_adds
The synchronization primitives:
_disc_entries_lock: threading.Lock— protectsdisc_entries(read by render, written by AI callback and Truncate)_pending_history_adds_lock— protects the queue of pending discussion entries_api_event_queue_lock— protects the Hook API event stream_pending_gui_tasks_lock— protects the queue of GUI tasks scheduled from background threads_project_switch_lock— protects the project-switch state machineai_client._send_lock: threading.Lock— serializes allai_client.send()calls (the global lock perguide_ai_client.md)ai_client._<provider>_history_lock— per-provider history lock (one per provider)
Invariant: the render thread never blocks. It reads lock-free. All locks are acquired by the writer (AI callback, Truncate button, Hook API), held for the minimum critical section, and released before the writer returns.
See docs/reports/MUTATION_MATRIX_PHASE5.md for the full matrix of state mutations × lock × thread.
5. State Persistence
Three layers of state persistence:
- In-memory only (lost on crash):
ai_client._<provider>_history,app.disc_entries,app.history(undo stack) - Project TOML (persisted on Save / switch discussion):
project.discussion.discussions[*].history,project.discussion.discussions[*].context_snapshot,project.discussion.discussions[*].sent_markdown,project.discussion.discussions[*].sent_system_prompt - Config TOML (persisted on
save_config()):config.disc_roles,config.use_default_base_system_prompt,config.ui_global_system_prompt, etc.
The auto-save flow is:
_flush_to_project()→ writes project TOML_flush_to_config()→ writes in-memory config toAppController.configsave_config()→ callsmodels.save_config(config)to write to disk
These three calls are manual (not automatic). The user must click Save, or trigger a state change that flushes (switch discussion, branch, etc.). The reset path explicitly does not save (per the "What reset does NOT touch" section above).
The comms log is its own persistence layer. ai_client._comms_log and app._comms_log are in-memory, but every entry is also written to logs/sessions/<session_id>/comms.log (JSON-L) via the _on_comms_entry callback. The reset clears the in-memory log; the on-disk log survives. This is intentional — the comms log is an audit trail, not a working state.
6. Hot Reload Integration
src/gui_2.py is hot-reloadable. The HotReloader (covered in guide_hot_reload.md) swaps module references at runtime. The state lifecycle interacts with hot-reload in 3 ways:
HistoryManagersurvives hot-reload because it lives on the Controller, not the App. Thegui_2.pymodule's functions can be reloaded without losing the undo stack.UISnapshotschema is the contract — if a hot-reload changes the fields captured byUISnapshot, the old snapshots in the undo stack may have different shapes. Thefrom_dictmethod handles missing fields via.get(..., default), so old snapshots degrade gracefully._appback-reference is set inApp.__init__(line 264). After a hot-reload ofgui_2.py, the Controller'sself._appstill points to the (now reloaded) App instance. Render functions that capturedappin closures may still hold the old App — but the App is a thin wrapper, so the new App is functionally equivalent.
See guide_hot_reload.md §"What can/cannot be safely reloaded" for the full list.
7. Hook API Surface
The state lifecycle is exposed to the Hook API via two registries on the Controller (src/app_controller.py:296-326 in App.__init__):
_predefined_callbacks: dict[str, Callable]— name → function. The Hook API exposes each as acustom_callbackaction. Includes:save_context_preset,load_context_preset,delete_context_presetset_ui_file_paths,set_ui_screenshot_pathsset_context_files_for_test,set_screenshots_for_test_toggle_command_paletteget_app_debug_info,save_context_preset_forceset_ui_attr(k, v),set_context_filessimulate_save_preset
_gettable_fields: dict[str, str]— public name → internal field name. The Hook API exposes each as a readable state field. Includesshow_command_palette,app_debug_info, and 50+ other UI/state fields.
The Hook API does not directly expose disc_entries mutation; it goes through /api/session POST which replaces the full list.
/api/session is the most state-relevant endpoint:
GET /api/session→{"session": {"entries": [...]}}POST /api/sessionwith{"session": {"entries": [...]}}→{"status": "updated"}
See guide_tools.md §"Hook API" and guide_api_hooks.md for the full surface.
8. Tests
tests/test_history.py—test_undo_redo,test_jump_to_undo,test_max_capacity,test_redo_cleared_on_push,test_push_state,test_initial_statetests/test_history_manager.py—TestHistoryManagerclass with:test_snapshot_roundtrip,test_push_and_undo,test_push_clears_redo_stack,test_undo_and_redo,test_undo_no_history_returns_none,test_redo_no_history_returns_none,test_get_history_returns_descriptions,test_jump_to_undotests/test_session_logger_reset.py—test_reset_sessiontests/test_state_inventory.py— validates the state inventory is up-to-datetests/test_state_delegation.py— validatesApp.__getattr__/__setattr__behaviortests/test_live_gui_state_sync.py— validates that Hook API state reads are consistent with the live GUI statetests/test_gui_fast_render.py—test_render_discussion_panel_fast(the change-detection path)tests/conftest.py:app_instance,tests/conftest.py:mock_app— the App fixtures used by these tests
9. Known Limitations
- Per-edit save is not debounced to disk. See
guide_discussions.md §"Known Limitations"for the related issue. The fix is to hook the change-detection ingui_2.py:1140-1170to also call_flush_disc_entries_to_projectafter a debounce. _handle_reset_sessionpushes a new snapshot to the undo stack. The pre-reset state is two Ctrl+Z presses away, not one. Fix: setself._is_applying_snapshot = Trueduring the reset.- Provider-side history and Manual Slop state can diverge. When the user edits an entry's
contentvia the discussion UI, the provider'sai_client._<provider>_historystill has the original. This is Pitfall #4 inconductor/tracks/nagent_review_20260608/report.mdand Decision candidate #3. - Undo stack capacity is 100. For long sessions with infrequent edits, this is plenty. For a 5-second window of rapid typing, you can fill it. Capacity is set in
app.history = HistoryManager(max_capacity=100)inApp.__init__and is not configurable. - Hook API state writes are not undoable. A
POST /api/sessionthat replaces the discussion list is a single change; if the change-detection logic pushes a snapshot, it's pushed as one entry. The user can Ctrl+Z to revert it, but the API caller has no way to know the change is undoable.
Cross-References
- Undo/redo core:
src/history.py:8 UISnapshot,src/history.py:71 HistoryManager - App-side wiring:
src/gui_2.py:735 _take_snapshot,src/gui_2.py:754 _apply_snapshot,src/gui_2.py:819 _apply_undo,src/gui_2.py:825 _apply_redo,src/gui_2.py:832 _apply_jump,src/gui_2.py:1140-1170 change_detection,src/gui_2.py:666 __getattr__,src/gui_2.py:671 __setattr__ - Controller-side reset:
src/app_controller.py:3286 _handle_reset_session,src/app_controller.py:3357 _handle_compress_discussion - Hook API registries:
src/gui_2.py:296-326(the_predefined_callbacks/_gettable_fieldsassignments inApp.__init__) - State inventory:
docs/reports/STATE_INVENTORY_PHASE5.md,docs/reports/MUTATION_MATRIX_PHASE5.md - Discussion integration:
guide_discussions.md - Actionable patterns:
conductor/tracks/nagent_review_20260608/nagent_takeaways_20260608.md §1(state visibility), §9 (edit-the-input) - Future-track candidate for stateless LLMClient:
conductor/tracks/nagent_review_20260608/decisions.mdcandidate #3