Private
Public Access
0
0
Files
manual_slop/docs/guide_discussions.md
T
conductor-tier2 ba05168493 docs(refresh): 3 new guides + cross-links from nagent_review
Per the docs Refresh Protocol (conductor/workflow.md), after a
reference/analysis track ships, the affected guides must be updated
to reflect new module structure or new conventions. The nagent_review
track (9cc51ca9) produced a deep-dive + 10 actionable takeaways that
named 3 documentation gaps in /docs. This commit fills them.

3 new guides (1,122 lines total):

1. guide_discussions.md (353 lines) — The Discussion system
   - 23-operation matrix: A1-A7 per-entry + B1-B11 discussion-level
     + C1-C5 undo/redo
   - Take naming convention (<base>_take_<n>), branching, promotion
   - User-managed role list (app.disc_roles)
   - Per-role filter linked to MMA persona focus
   - _disc_entries_lock thread-safety contract
   - Hook API session endpoints
   - Persistence: _flush_to_project, _flush_disc_entries_to_project,
     context_snapshot
   - 9 file:line refs into gui_2.py:3770-4260 + history.py

2. guide_state_lifecycle.md (375 lines) — Undo/redo + reset + state
   delegation
   - HistoryManager + UISnapshot (13 captured fields, 100-snapshot
     capacity, debounced change-detection at render frame)
   - _handle_reset_session (clears 30+ fields, replaces project,
     preserves active_project_path per the 2026-06-08 regression fix)
   - App.__getattr__/__setattr__ state delegation to Controller
   - 4-thread access pattern with 7 lock-protected regions
   - State persistence: in-memory vs project TOML vs config TOML
   - Hot-reload integration
   - Hook API registries (_predefined_callbacks, _gettable_fields)
   - 14 file:line refs into gui_2.py:1140-1170, history.py,
     app_controller.py:3286-3356

3. guide_context_aggregation.md (394 lines) — The aggregate.py
   pipeline
   - 3 aggregation strategies (auto, summarize, full)
   - 7 per-file view modes (full, summary, skeleton, outline,
     masked, custom, none)
   - Full FileItem schema (9 fields + __post_init__ normalizer)
     at models.py:510-559
   - ContextPreset schema and ContextPresetManager
   - Tier 3 worker variant (build_tier3_context with FuzzyAnchor
     re-resolution and focus-file handling)
   - force_full / auto_aggregate short-circuits
   - Cache strategy (static prefix + dynamic history)
   - 23 file:line refs into aggregate.py:36-518 + models.py:909-937

8 existing guides cross-linked to the 3 new guides and to the
nagent_review track:

- guide_gui_2.md           (+ See Also entries for discussions,
                           state lifecycle, context aggregation,
                           nagent_review report)
- guide_app_controller.md  (+ See Also entries for discussions,
                           state lifecycle, context aggregation,
                           nagent_review report)
- guide_context_curation.md (+ new See Also section pointing to
                            context aggregation + nagent_review)
- guide_architecture.md    (+ new See Also section listing all 10
                           guides + nagent_review report)
- guide_ai_client.md       (+ See Also entries for state lifecycle,
                           context aggregation, nagent_review
                           pitfalls #2 and #4)
- guide_mma.md             (+ new See Also section pointing to
                           context aggregation, discussions,
                           nagent_review report §9 + takeaways §3/§10
                           for SubConversationRunner priority)
- guide_models.md          (+ See Also entries for context
                           aggregation, discussions, nagent_review
                           report §6 on FileItem as strongest
                           curation dimension)
- Readme.md                (+ 3 new guide entries in the index
                           table, with one-line summaries)

No code modified. This is documentation only.

Why these 3 guides specifically:

- guide_discussions.md: The discussion system is the user's most
  edited surface. nagent_review's report §3 enumerated 23 operations
  (A1-C5) that previously existed only as scattered file:line refs
  across gui_2.py. A dedicated guide makes the operation matrix
  discoverable.

- guide_state_lifecycle.md: The undo/redo + reset + state delegation
  machinery is architecturally load-bearing but scattered across 4
  files. After nagent_review identified the provider-side history
  divergence as Pitfall #4, the relationship between Manual Slop's
  state and the provider's state needs explicit documentation.

- guide_context_aggregation.md: aggregate.py (518 lines) is the
  most-touched module after ai_client.py but had no dedicated
  guide. nagent_review confirmed it's Manual Slop's strongest
  curation dimension. A dedicated guide makes the 7 view modes
  and 3 strategies discoverable.

The 3 new guides total 1,122 lines and follow the existing
per-source-file deep-dive style (architectural, data-oriented,
state-management-focused).
2026-06-08 19:26:08 -04:00

26 KiB

Discussions: Takes, Branching, and Per-Entry Editing

Top | App Controller | GUI Main | Models


Overview

A Discussion is Manual Slop's first-class unit of conversation. Every prompt the user types, every AI response, every tool result, every per-entry edit lives in a Discussion. Discussions are persisted to the project's TOML as a typed list of entries; they can be branched into multiple Takes, switched between, renamed, deleted, and (most importantly) edited at the entry level by the user in the GUI.

The discussion system is one of the most-edited surfaces in Manual Slop. The user can:

  • Edit any entry's text in place (full multi-line edit, not just inline)
  • Insert new entries at any position
  • Delete any entry by position
  • Change the role of any entry
  • Branch at any entry to create a new Take
  • Undo/redo every edit (Ctrl+Z / Ctrl+Y)
  • Promote a Take to a top-level discussion

This is a deliberate design choice. Manual Slop treats the discussion as user-editable working state, not as opaque chat history. The full operation matrix and the rationale are in conductor/tracks/nagent_review_20260608/report.md §3; this guide covers the implementation.

Domain classification. The discussion system is purely Application-domain. It owns no Meta-Tooling concerns; it does not call into scripts/mma_exec.py; it is consumed by the GUI and the headless controller, and projected to the AI client. See guide_meta_boundary.md for the Application vs Meta-Tooling split.


Data Model

The Entry Dict

The smallest unit of a discussion is the entry, a dict[str, Any] with this shape (src/models.py:parse_history_entries builds it; src/gui_2.py:render_discussion_entry reads it):

Field Type Source Purpose
role str parse_history_entries The speaker. Defaults to one of ["User", "AI", "Vendor API", "System"] (set in models.py:208), but disc_roles is user-editable so this can be any string.
content str user input / LLM response The entry's text. Fully editable in the GUI.
collapsed bool GUI render state Whether the entry is collapsed to a 60-char preview. Defaults True.
ts str project_manager.now_ts() ISO timestamp, prefixed with @ in the persisted form.
thinking_segments list[dict] src/thinking_parser.py AI entries with <thinking> blocks have the blocks parsed out into collapsible segments.
usage dict ai_client.send() Token accounting: {"input_tokens": N, "output_tokens": N, "cache_read_input_tokens": N}.
read_mode bool GUI render state If True, render as Markdown; if False (default), render as editable text input.

An entry dict is open: extra keys are allowed and ignored by the renderer. This is intentional — the user can add custom metadata via the Hook API or by editing the project TOML directly.

The Discussion Dict

A Discussion is a dict[str, Any] under project.discussion.discussions[<name>]:

{
    "history":              [str, ...]   # legacy: list of "Role: content" strings
                                   #      OR
                                   # list of entry dicts (new format)
    "git_commit":           str,         # git SHA at the time the discussion was last updated
    "last_updated":         str,         # ISO timestamp
    "context_snapshot":     [dict, ...], # list of FileItem.to_dict() at send time
    "sent_markdown":        str,         # the actual markdown sent to the AI on the last send
    "sent_system_prompt":   str,         # the system prompt that was active at send time
}

The project_manager.default_discussion() factory returns a fresh dict with empty history and the standard keys. app_controller._switch_discussion reads the dict, parses history via models.parse_history_entries(history_strings, self.disc_roles), and writes the live disc_entries list.

The Take Naming Convention

Takes are encoded in the discussion name. A Take's name has the shape <base>_take_<n>. Example: a discussion named refactor_auth can have takes refactor_auth_take_1, refactor_auth_take_2, etc. The _get_discussion_names accessor groups by base name (name.split("_take_")[0]) so the GUI can render them as nested tabs.

The _branch_discussion(index) method (in app_controller.py:3503) generates a unique Take name by incrementing <base>_take_<counter> until it finds an unused name, then calls project_manager.branch_discussion(self.project, self.active_discussion, new_name, index).


Per-Entry Operations (the A1-A7 matrix)

This is the operation set the user has per individual entry. Renderer: src/gui_2.py:3770 render_discussion_entry(app, entry, index).

# Operation GUI control Source code What it does
A1 Edit content in place imgui.input_text_multiline on the entry body gui_2.py:3841 entry["content"] is a fully editable multi-line text input. The user can rewrite an AI's response, fix a typo in their own prompt, paste in code from another source, etc.
A2 Toggle read/edit mode [Edit] / [Read] button gui_2.py:3799 When in [Read] mode, the content is rendered as Markdown with syntax highlighting (render_discussion_entry_read_mode at gui_2.py:3855). When in [Edit] mode, the multi-line text input is shown.
A3 Toggle collapsed/expanded +/- button per entry gui_2.py:3789 Collapsed entries show a 60-char preview (line 3822-3824). Expanded entries show full content.
A4 Change role Combo box from app.disc_roles gui_2.py:3793-3796 The entry's role field is editable. The list app.disc_roles is itself user-managed (see §"Role Management" below).
A5 Insert entry before this one Ins button gui_2.py:3813 app.disc_entries.insert(index, {"role": "User", "content": "", "collapsed": True, "ts": project_manager.now_ts()})
A6 Delete this entry Del button gui_2.py:3815-3816 if entry in app.disc_entries: app.disc_entries.remove(entry). The membership check matters — ImGui can re-render stale state, so the check guards against double-delete.
A7 Branch at this entry Branch button gui_2.py:3821app._branch_discussion(index)app_controller._branch_discussion:3503project_manager.branch_discussion:429 Creates a new Take named <base>_take_<n> and copies the history up to and including index into the new Take. The user is then switched to the new Take.

Why this matrix is load-bearing. Every entry is independently editable. There is no "edit the whole discussion as one operation." This is the design difference vs. most chat UIs: when an AI's response is wrong, the user can fix the response text without losing the entry's role, timestamp, usage accounting, or thinking segments. The AI on the next turn sees the corrected response (because the entry's content is the source for build_discussion_section in aggregate.py:109).


Discussion-Level Operations (the B1-B11 matrix)

These are the second-tier controls, rendered at src/gui_2.py:4239 render_discussion_entry_controls(...) and the discussion selector at gui_2.py:4330 render_discussion_selector(...).

# Operation GUI control Source code What it does
B1 Append new entry + Entry button gui_2.py:4240 app.disc_entries.append({...}) with the default role from app.disc_roles[0].
B2 Collapse all / Expand all -All / +All buttons gui_2.py:4242-4246 Bulk-set collapsed flag on every entry.
B3 Clear all Clear All button gui_2.py:4248 app.disc_entries.clear(). Note: this clears the current take, not all takes.
B4 Save (flush to project TOML) Save button gui_2.py:4250 app._flush_to_project(); app._flush_to_config(); app.save_config().
B5 Add/remove roles Add / X buttons under "Roles" gui_2.py:4317-4328 app.disc_roles.append(r) / app.disc_roles.pop(i).
B6 Switch active discussion Discussion combo + Take tabs gui_2.py:4197, 4344, 4354 app._switch_discussion(name). Takes group by base name and render as nested tabs.
B7 Rename / Delete discussion Rename / Delete buttons gui_2.py:4291, 4293 app._rename_discussion(...) / app._delete_discussion(...). Cannot delete the last discussion (guarded at app_controller.py:3543).
B8 Promote Take to top-level Promote button in takes panel gui_2.py:4364 project_manager.promote_take(app.project, app.active_discussion, new_name) — renames a Take (e.g. T0_take_2) to a fresh top-level discussion name.
B9 Per-role filter ui_focus_agent selector (system-wide) gui_2.py:4230-4234 display_entries = [e for e in app.disc_entries if e.get("role") == persona_name or e.get("role") == "User"]. The filter follows the MMA persona focus.
B10 Truncate to N pairs Truncate button + drag_int gui_2.py:4254-4260 truncate_entries(app.disc_entries, app.ui_disc_truncate_pairs) keeps the last N User/AI pairs (per gui_2.py:175 truncate_entries(...)).
B11 Compress (AI summarization) Compress button gui_2.py:4252app_controller._handle_compress_discussion:3357 Calls ai_client.run_discussion_compression(disc_text) and replaces the discussion with the LLM's compressed version.

Role Management

app.disc_roles: list[str] is the master list of valid role strings. It's:

  • Populated from models.parse_history_entries's default ["User", "AI", "Vendor API", "System"] (models.py:208)
  • Persisted as manual_slop.toml [discussion].disc_roles (or a project TOML equivalent)
  • Loaded by app_controller.init_state from the project dict

The user can add or remove roles at runtime via gui_2.py:4317-4328 render_discussion_roles. The Add button takes app.ui_disc_new_role_input, strips it, and appends if not already present. The X button pops by index.

A role can be any string — Manual Slop doesn't enforce a vocabulary. Typical custom roles include Context, Tool, CodeBlock, Error, Warning, or per-project names like Architect vs Implementer.

The default role for new entries is app.disc_roles[0] if app.disc_roles else "User". If the role list is empty, the system falls back to "User". This is intentionally permissive — empty role list is never an error.


Take Lifecycle

Branch

app_controller._branch_discussion(index) (app_controller.py:3503-3519):

  1. Flush current disc_entries to project TOML via _flush_disc_entries_to_project (so we don't lose unsaved edits).
  2. Compute the base name: self.active_discussion.split("_take_")[0].
  3. Generate a unique take name: <base>_take_<counter> incremented until unused.
  4. Call project_manager.branch_discussion(self.project, self.active_discussion, new_name, index).
  5. Switch the active discussion to the new take via _switch_discussion(new_name).

project_manager.branch_discussion (project_manager.py:429) does the actual copy:

  • Reads the source discussion
  • Creates a fresh discussion dict with default_discussion()
  • Copies the source's git_commit (so the new take is anchored to the same code state)
  • Copies source_disc["history"][:message_index + 1] — i.e. all entries up to and including index
  • Sets the new take as active

Why "up to and including"? Branching at entry N means "the future starts from entry N's state." The user is saying "from here, what if I had asked a different follow-up?" The AI sees entries 0..N as the prior conversation; entries N+1..end are discarded (in this take — they're still in the parent take, accessible via the Take tabs).

Promote

project_manager.promote_take (project_manager.py:447):

  • Renames a take to a fresh top-level name
  • Updates the active pointer if the renamed take was active
  • Use case: a Take that turned out to be the "real" conversation gets renamed away from the _take_<n> suffix to become a first-class discussion

Switch

app_controller._switch_discussion(name) (app_controller.py:3199):

  1. Flush the current disc_entries to the project TOML.
  2. Look up the new discussion in self.project["discussion"]["discussions"].
  3. Set self.active_discussion = name and self._track_discussion_active = False.
  4. Atomically (under _disc_entries_lock) replace self.disc_entries[:] = models.parse_history_entries(disc_data.get("history", []), self.disc_roles).
  5. Restore the context snapshot from disc_data["context_snapshot"] if present.
  6. Update ai_status = f"discussion: {name}".

The atomic slice-replacement is critical: a renderer that reads self.disc_entries mid-update would see a half-empty list. The lock ensures the renderer only sees the old list (before) or the new list (after), never an in-between state.

Rename / Delete

_rename_discussion(old, new) (app_controller.py:3521):

  • discussions[new_name] = discussions.pop(old_name) — atomically swaps the key
  • Updates active_discussion and the active pointer if the renamed discussion was active
  • Rejects the rename if new_name is already in use (ai_status = f"discussion '{new_name}' already exists")

_delete_discussion(name) (app_controller.py:3537):

  • Refuses to delete the last remaining discussion (guarded at line 3543)
  • Removes the discussion from the dict
  • If the deleted discussion was active, switches to the first remaining sorted-by-name discussion

gui_2.py:4227-4237 render_discussion_entries filters the entry list when app.ui_focus_agent is set:

if app.ui_focus_agent:
    tier_usage = app.mma_tier_usage.get(app.ui_focus_agent)
    if tier_usage:
        persona_name = tier_usage.get("persona")
        if persona_name:
            display_entries = [e for e in app.disc_entries
                              if e.get("role") == persona_name or e.get("role") == "User"]

When the user clicks "Focus on Tier 3 Worker A" in the MMA dashboard, the Discussion Hub filters to only show entries whose role matches the focused worker's persona name plus User entries. This is a read-only filter — the underlying disc_entries is unchanged. The app._render_message_panel (or whoever sent the entries) is unaffected.


Persistence

app._flush_to_project (called from B4 Save, and from _switch_discussion)

gui_2.py:1046-1047 and app_controller.py:2558:

app._flush_to_project()       # serializes self.project to <project_root>/<project_name>.toml
app._flush_to_config()         # serializes self.config to <user_config>/config.toml
app.save_config()              # write config.toml to disk

_flush_to_project calls project_manager.save_project(self.project, self.active_project_path), which serializes the full project dict (including all discussions) to the project TOML.

_flush_disc_entries_to_project (called from _switch_discussion and _branch_discussion)

app_controller.py:3225-3240:

def _flush_disc_entries_to_project(self) -> None:
    history_strings = [project_manager.entry_to_str(e) for e in self.disc_entries]
    if self.active_track and self._track_discussion_active:
        project_manager.save_track_history(self.active_track.id, history_strings, self.active_project_root)
        return
    disc_sec = self.project.setdefault("discussion", {})
    discussions = disc_sec.setdefault("discussions", {})
    disc_data = discussions.setdefault(self.active_discussion, project_manager.default_discussion())
    disc_data["history"] = history_strings
    disc_data["last_updated"] = project_manager.now_ts()
    disc_data["context_snapshot"] = [f.to_dict() if hasattr(f, "to_dict") else {"path": str(f)} for f in self.context_files]
    disc_data["sent_markdown"] = getattr(self, "discussion_sent_markdown", "")
    disc_data["sent_system_prompt"] = getattr(self, "discussion_sent_system_prompt", "")

Two paths:

  • If a track discussion is active (self.active_track and self._track_discussion_active): persist to conductor/tracks/<id>/track_history via save_track_history.
  • Otherwise: persist to the project's discussion.discussions[<active>] dict.

entry_to_str(e) converts an entry dict to a Role: content string for the legacy history field. parse_history_entries (in models.py:196) reverses the conversion when loading.

The context_snapshot is the FileItem list at send time. Restoring a discussion restores the file list (per _switch_discussion:3218-3222). This is the mechanism for "I sent this discussion with these files in context; if I switch away and back, the files come back."

When is the save triggered?

  • Explicit: B4 Save button.
  • Implicit (and risky): _switch_discussion and _branch_discussion both flush before switching. But the per-entry edit operations (A1-A7) do not flush on their own. The user is expected to either Save explicitly or rely on the next _switch_discussion / _branch_discussion to flush.

This is a known design tension. See the "Known Limitations" section below.


Threading & Locking

self._disc_entries_lock: threading.Lock is a threading.Lock owned by app_controller. It is acquired in:

  • _switch_discussion (app_controller.py:3214-3215) — to atomically replace disc_entries[:]
  • app._process_pending_gui_tasks (called from render loop) — to read entries safely while a background thread appends an AI response
  • truncate_entries (via the panel-level Truncate button) — to atomically replace disc_entries with the truncated list
  • gui_2.py:4060, 4223-4224 — the AI response callback appends a new entry under the lock
  • gui_2.py:4359 (in render_discussion_selector when track-discussion is toggled) — flushes under the lock

Invariant: the lock is never held across a render call. The lock is acquired, disc_entries[:] = ... is done, the lock is released. The ImGui renderer reads disc_entries lock-free; it sees either the old list or the new list but never a half-updated one.

Cross-thread append pattern (the AI response callback at gui_2.py:4060):

with app._disc_entries_lock:
    app.disc_entries.append({"role": "user", "content": prompt, "collapsed": False, "ts": project_manager.now_ts()})

The background thread (e.g. _bg_task) appends; the render thread reads. The lock is the only synchronization primitive — there is no event loop, no message queue, no signal. The render thread polls at frame rate (60 FPS nominal); if the background thread appends between frames, the next frame sees the new entry.


Undo/Redo Integration

The discussion system is integrated with HistoryManager + UISnapshot for full undo/redo. See guide_state_lifecycle.md for the full architecture. The relevant details for discussions:

  • UISnapshot.disc_entries: list[dict] (src/history.py:19) captures the full entry list via copy.deepcopy(self.disc_entries) (gui_2.py:748).
  • The change-detection logic at gui_2.py:1160, 1166-1167 checks if disc_entries length or last-entry content changed; if so, a new snapshot is pushed to the undo stack.
  • Ctrl+Z restores the previous disc_entries via gui_2.py:754 _apply_snapshot.

Per-edit granularity. A snapshot is pushed per render frame that detects a change. The 100-snapshot cap means you can rewind up to ~100 edits. For a 5-second window of rapid typing, that's a lot. For long sessions with infrequent edits, the history can span hours.


Reset (Destroying the Discussion)

app_controller._handle_reset_session (app_controller.py:3286-3356) is the nuclear reset:

  • self.disc_entries.clear() — empties the current take
  • for d_name in discussions: discussions[d_name]["history"] = [] — empties ALL takes and ALL discussions
  • Resets discussion_sent_markdown and discussion_sent_system_prompt to ""
  • Resets the entire project dict to default_project(...) — this is a new empty project, not the user's saved one

The reset is intentionally aggressive. The 2026-06-08 _handle_reset_session regression (documented in the comments at app_controller.py:3307-3312) was caused by an early version that also cleared self.active_project_path, leading to an infinite re-switch loop. The fix is to leave active_project_path alone.

What reset does NOT touch:

  • self.project is replaced, but the user's saved project TOML on disk is untouched. Switching projects after reset reloads from disk.
  • app.history (the HistoryManager) is not cleared. The undo stack survives a reset — Ctrl+Z after a reset can restore the pre-reset discussion state. This may be a bug or a feature depending on user expectation.
  • self.active_project_path is preserved.

Hook API Surface

The discussion system is exposed to the Hook API via two endpoints (per guide_tools.md):

Method Endpoint Behavior
GET /api/session Direct read {"session": {"entries": [...]}} from app.disc_entries
POST /api/session {"session": {"entries": [...]}} {"status": "updated"} — sets app.disc_entries

The POST endpoint allows external automation to replace the entire discussion. Per-entry inserts/deletes are not currently exposed via the Hook API (only full-replacement). This is a known gap.

api_hook_client.py exposes get_session() and set_session(entries) as the Python-side wrappers.


Tests

  • tests/test_discussion_takes.pyTestDiscussionTakes covers branch_discussion (creates a new Take) and promote_take (renames a Take to top-level).
  • tests/test_gui_discussion_tabs.pytest_discussion_tabs_rendered covers the discussion selector and Take tabs.
  • tests/test_discussion_takes_gui.pytest_render_discussion_tabs and test_switching_discussion_via_tabs cover the GUI flow.
  • tests/test_history.pytest_undo_redo, test_jump_to_undo, test_max_capacity, test_redo_cleared_on_push, test_push_state cover the undo/redo integration.
  • tests/test_history_manager.pyTestHistoryManager covers snapshot_roundtrip, push_and_undo, push_clears_redo_stack, undo_and_redo, undo_no_history_returns_none, redo_no_history_returns_none, get_history_returns_descriptions, jump_to_undo.
  • tests/test_session_logger_reset.pytest_reset_session covers the reset path.
  • tests/test_gui_fast_render.pytest_render_discussion_panel_fast covers the render path.
  • tests/test_gui_phase4.pytest_track_discussion_toggle covers the track-discussion toggle.
  • tests/test_gui_symbol_navigation.pytest_render_discussion_panel_symbol_lookup covers the @Symbol lookup integration.

Known Limitations

  1. Per-edit save is implicit. The per-entry edit operations (A1-A7) do not flush to TOML on every edit. The save happens on the next _switch_discussion, _branch_discussion, or explicit B4 Save. A crash between edit and save loses the edit. Fix: hook the change-detection logic in gui_2.py:1160, 1166-1167 to also call _flush_disc_entries_to_project after a debounce.
  2. Provider-side history diverges from disc_entries. When the user edits an entry's content via A1, the displayed text is corrected but the provider-side ai_client._anthropic_history (and siblings) still contains the original. The next LLM call may replay the original tool results. This is Pitfall #4 in conductor/tracks/nagent_review_20260608/report.md and the corresponding Decision candidate #3 (Stateless LLMClient).
  3. Hook API is full-replacement only. No per-entry insert/delete via the API. The user could POST /api/session with a new list, but partial edits require the full list.
  4. Truncate is destructive. The Truncate button (B10) is not undoable as a single operation — it's a list replacement, so the undo stack pushes the new (truncated) list, not the pre-truncate list. Actually, it is pushed (per the change-detection logic), so Ctrl+Z restores the pre-truncate list. Confirmed working in tests/test_history.py.

Cross-References

  • Discussion data model: src/models.py:196 parse_history_entries, src/models.py:909 ContextPreset, src/models.py:510 FileItem
  • Discussion persistence: src/project_manager.py:429 branch_discussion, src/project_manager.py:447 promote_take, src/project_manager.py:396 calculate_track_progress
  • Discussion switching/management: src/app_controller.py:3199 _switch_discussion, src/app_controller.py:3225 _flush_disc_entries_to_project, src/app_controller.py:3286 _handle_reset_session, src/app_controller.py:3357 _handle_compress_discussion, src/app_controller.py:3503 _branch_discussion, src/app_controller.py:3521 _rename_discussion, src/app_controller.py:3537 _delete_discussion
  • GUI render functions: src/gui_2.py:175 truncate_entries, src/gui_2.py:735 _take_snapshot, src/gui_2.py:754 _apply_snapshot, src/gui_2.py:3770 render_discussion_entry, src/gui_2.py:4227 render_discussion_entries, src/gui_2.py:4239 render_discussion_entry_controls, src/gui_2.py:4317 render_discussion_roles, src/gui_2.py:4330 render_discussion_selector
  • Undo/redo integration: src/history.py:8 UISnapshot, src/history.py:71 HistoryManager
  • Deep-dive on the design philosophy: conductor/tracks/nagent_review_20260608/report.md §3 (the 23-operation matrix A1-C5)
  • Actionable patterns for sub-agents in 1:1 discussions: conductor/tracks/nagent_review_20260608/nagent_takeaways_20260608.md §3 and §10
  • Future-track candidate for raw-transcript persistence: conductor/tracks/nagent_review_20260608/decisions.md candidate #10