From 0265b19232ab1f61c9738346d06c605d5d465c7b Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Thu, 25 Jun 2026 09:23:11 -0400 Subject: [PATCH 01/24] Add conversation history drawer and per-conversation code state History panel (cherry-picked and adapted from PR #35): - New ui/layout/conversation_history.py: a browsable list of past conversation pairs with newest/oldest sort and a favorites filter. Clicking a card jumps to that conversation; a heart toggles favorite. - Switch the app to SinglePageWithDrawerLayout and host the panel in the left drawer (bound to existing main_drawer, collapsed by default); the toolbar nav icon now toggles it instead of being hidden. - Controllers navigate_to_conversation and toggle_favorite_conversation, plus history_sort_order / history_filter_mode / favorited_conversations state. Per-conversation code state (new): - generated_code, code_history and code_history_pos were global singletons, so editing code in one conversation and navigating away discarded the edit and bled undo/redo history across conversations. - Add a server-side store keyed by a content hash of each pair. Navigating away saves the active (possibly hand-edited) code and history; arriving restores it, seeding from the original generated code on first visit. Keying by content hash is robust to index shifts and conversation reloads. --- src/vtk_prompt/controllers/conversation.py | 81 ++++++++++- src/vtk_prompt/state/initializer.py | 3 + src/vtk_prompt/ui/layout/__init__.py | 2 + .../ui/layout/conversation_history.py | 130 ++++++++++++++++++ src/vtk_prompt/ui/layout/toolbar.py | 6 +- src/vtk_prompt/vtk_prompt_ui.py | 27 +++- 6 files changed, 237 insertions(+), 12 deletions(-) create mode 100644 src/vtk_prompt/ui/layout/conversation_history.py diff --git a/src/vtk_prompt/controllers/conversation.py b/src/vtk_prompt/controllers/conversation.py index b782f4b..e3820b8 100644 --- a/src/vtk_prompt/controllers/conversation.py +++ b/src/vtk_prompt/controllers/conversation.py @@ -5,6 +5,7 @@ and conversation state management in the VTK Prompt UI. """ +import hashlib import json import re from pathlib import Path @@ -57,11 +58,39 @@ def on_conversation_file_data_change( app.generate_code() +def _code_states(app: Any) -> dict: + """Per-conversation code-state store (server-side backing data, not UI state).""" + if not hasattr(app, "_conversation_code_states"): + app._conversation_code_states = {} + return app._conversation_code_states + + +def _pair_key(pair: dict) -> str: + """Stable content-hash key for a pair (robust to index shifts and reloads).""" + user = pair.get("user", {}).get("content", "") + assistant = pair.get("assistant", {}).get("content", "") + return hashlib.sha256((user + "\x00" + assistant).encode("utf-8")).hexdigest() + + +def save_current_code_state(app: Any) -> None: + """Persist the active editor code + undo/redo history for the current pair.""" + nav = app.state.conversation_navigation + idx = app.state.conversation_index + if not nav or idx < 0 or idx >= len(nav): + return # new-entry mode or empty: nothing to persist + _code_states(app)[_pair_key(nav[idx])] = { + "code": app.state.generated_code, + "history": list(app.state.code_history or []), + "pos": app.state.code_history_pos, + } + + def navigate_conversation_left(app: Any) -> None: """Navigate to previous conversation pair.""" if not app.state.conversation_navigation: return + save_current_code_state(app) if app.state.conversation_index >= 0: app.state.conversation_index -= 1 if app.state.conversation_index >= 0: @@ -74,6 +103,7 @@ def navigate_conversation_right(app: Any) -> None: if not app.state.conversation_navigation: return + save_current_code_state(app) nav_length = len(app.state.conversation_navigation) if app.state.conversation_index < nav_length: app.state.conversation_index += 1 @@ -86,6 +116,29 @@ def navigate_conversation_right(app: Any) -> None: _update_navigation_state(app) +def navigate_to_conversation(app: Any, target_index: int) -> None: + """Navigate directly to a specific conversation pair by index.""" + if not app.state.conversation_navigation: + return + if target_index < 0 or target_index >= len(app.state.conversation_navigation): + return + save_current_code_state(app) + app.state.conversation_index = target_index + _process_conversation_pair(app, target_index) + _update_navigation_state(app) + + +def toggle_favorite_conversation(app: Any, conversation_index: int) -> None: + """Toggle whether a conversation pair (by index) is favorited.""" + favorites = list(getattr(app.state, "favorited_conversations", None) or []) + if conversation_index in favorites: + favorites.remove(conversation_index) + else: + favorites.append(conversation_index) + # Replace the whole list so trame detects the state change. + app.state.favorited_conversations = favorites + + def save_conversation(app: Any) -> str: """Save current conversation history as JSON string.""" if hasattr(app, "prompt_client") and app.prompt_client is not None: @@ -170,13 +223,31 @@ def _process_conversation_pair(app: Any, pair_index: int | None = None) -> None: assistant_content = pair["assistant"].get("content", "") explanation, code = _parse_assistant_content(assistant_content) - if explanation and code: - # Set explanation and code in UI state + if explanation: app.state.generated_explanation = explanation - app.state.generated_code = EXPLAIN_RENDERER + "\n" + code - # Execute the code to display visualization - app._execute_with_renderer(code) + # Code is tracked per conversation: restore this pair's saved (possibly + # hand-edited) code and its undo/redo history if present, otherwise seed + # from the original generated code on first visit. + states = _code_states(app) + key = _pair_key(pair) + if key in states: + slot = states[key] + app.state.generated_code = slot["code"] + app.state.code_history = list(slot["history"]) + app.state.code_history_pos = slot["pos"] + if app.state.generated_code: + app._execute_with_renderer(app.state.generated_code) + elif explanation and code: + app.state.generated_code = EXPLAIN_RENDERER + "\n" + code + app.state.code_history = [app.state.generated_code] + app.state.code_history_pos = 0 + states[key] = { + "code": app.state.generated_code, + "history": list(app.state.code_history), + "pos": 0, + } + app._execute_with_renderer(app.state.generated_code) # Process user message for query text user_content = pair["user"].get("content", "").strip() diff --git a/src/vtk_prompt/state/initializer.py b/src/vtk_prompt/state/initializer.py index ede3cd4..e2cf2a1 100644 --- a/src/vtk_prompt/state/initializer.py +++ b/src/vtk_prompt/state/initializer.py @@ -51,6 +51,9 @@ def initialize_state(app: Any) -> None: app.state.can_navigate_left = False app.state.can_navigate_right = False app.state.is_viewing_history = False + app.state.history_sort_order = "newest" # "newest" or "oldest" + app.state.favorited_conversations = [] # indices into conversation_navigation + app.state.history_filter_mode = "all" # "all" or "favorites" # Prompt file state variables app.state.prompt_object = None diff --git a/src/vtk_prompt/ui/layout/__init__.py b/src/vtk_prompt/ui/layout/__init__.py index ce7816a..089ba50 100644 --- a/src/vtk_prompt/ui/layout/__init__.py +++ b/src/vtk_prompt/ui/layout/__init__.py @@ -6,11 +6,13 @@ """ from .content import build_content +from .conversation_history import build_conversation_history from .settings_dialog import build_settings_dialog from .toolbar import build_toolbar __all__ = [ "build_toolbar", "build_content", + "build_conversation_history", "build_settings_dialog", ] diff --git a/src/vtk_prompt/ui/layout/conversation_history.py b/src/vtk_prompt/ui/layout/conversation_history.py new file mode 100644 index 0000000..00e9f93 --- /dev/null +++ b/src/vtk_prompt/ui/layout/conversation_history.py @@ -0,0 +1,130 @@ +"""Conversation history panel: browsable, sortable, favoritable past prompts.""" + +from typing import Any + +from trame.widgets import html +from trame.widgets import vuetify3 as vuetify + + +def build_conversation_history(app: Any) -> None: + """Build the conversation history component with clickable conversation cards.""" + # Bottom: Conversation History + with vuetify.VCard(classes="h-100 w-100", flat=True): + with vuetify.VCardTitle("Conversation History", classes="d-flex align-center"): + vuetify.VSpacer() + + # Sort toggle button + with vuetify.VTooltip(text="Toggle sort order", location="bottom"): + with vuetify.Template(v_slot_activator="{ props }"): + vuetify.VBtn( + icon=( + "history_sort_order === 'newest'" + + " ? 'mdi-sort-descending' : 'mdi-sort-ascending'", + "mdi-sort-descending", + ), + click=( + "history_sort_order = " + + "(history_sort_order === 'newest')" + + " ? 'oldest' : 'newest'" + ), + variant="text", + density="compact", + color="primary", + disabled=("conversation_navigation.length === 0", False), + v_bind="props", + ) + + # Filter toggle button + with vuetify.VTooltip(text="Toggle favorites filter", location="bottom"): + with vuetify.Template(v_slot_activator="{ props }"): + vuetify.VBtn( + icon=( + "history_filter_mode === 'favorites'" + + " ? 'mdi-heart' : 'mdi-heart-off'", + "mdi-format-list-bulleted", + ), + click=( + "history_filter_mode = (history_filter_mode === 'all')" + + " ? 'favorites' : 'all'" + ), + variant="text", + density="compact", + color=( + "history_filter_mode === 'favorites' ? 'red' : 'primary'", + "primary", + ), + disabled=( + "conversation_navigation.length === 0" + + " || favorited_conversations.length === 0", + False, + ), + v_bind="props", + ) + + with vuetify.VCardText(style="height: calc(100% - 50px); overflow-y: auto;"): + # Show message when no history + vuetify.VAlert( + text="No conversation history yet." + " Start by generating some VTK code!", + type="info", + variant="tonal", + v_show="conversation_navigation.length === 0", + ) + + # Conversation history list + with vuetify.VCard( + v_for=( + "item in (history_sort_order === 'newest'" + + " ? conversation_navigation.slice().reverse()" + + ".map((pair, idx) => ({pair, originalIndex: " + + "conversation_navigation.length - 1 - idx}))" + + " : conversation_navigation.map((pair, idx) => " + + "({pair, originalIndex: idx})))" + + ".filter(item => history_filter_mode === 'all' || " + + "favorited_conversations.includes(item.originalIndex))" + ), + key="item.originalIndex", + density="compact", + v_show="conversation_navigation.length > 0", + color=( + "conversation_index === item.originalIndex" + " ? 'primary' : 'secondary'", + "secondary", + ), + variant=( + "conversation_index === item.originalIndex" + " ? 'outlined' : 'default'", + "default", + ), + ): + # Track favorited prompts + with vuetify.VCardTitle(classes="text-end"): + vuetify.VIcon( + click=( + app.ctrl.toggle_favorite_conversation, + "[item.originalIndex]", + ), + icon=( + "favorited_conversations.includes(item.originalIndex) ? " + + "'mdi-heart' : 'mdi-heart-outline'", + "mdi-heart-outline", + ), + size="small", + color=( + "favorited_conversations.includes(item.originalIndex) ? " + + "'red' : 'grey'", + "grey", + ), + ) + with vuetify.VCardText( + click=( + app.ctrl.navigate_to_conversation, + "[item.originalIndex]", + ), + rounded=True, + classes="mb-2", + ): + # User query preview + html.Span( + "{{ (item.pair.user.content.includes('')" + + " ? item.pair.user.content.split('')[1]" + + " : item.pair.user.content)" + + ".trim().replace(/^Request:\\s*/i, '') }}" + ) diff --git a/src/vtk_prompt/ui/layout/toolbar.py b/src/vtk_prompt/ui/layout/toolbar.py index 031f505..e8d7f5e 100644 --- a/src/vtk_prompt/ui/layout/toolbar.py +++ b/src/vtk_prompt/ui/layout/toolbar.py @@ -12,10 +12,8 @@ def build_toolbar(layout: Any, app: Any) -> None: """Build the toolbar layout with file controls and settings.""" - with layout.toolbar as toolbar: - drawer_icon = toolbar.children[0] - drawer_icon.hide() - + with layout.toolbar: + # The drawer toggle (nav icon) opens the conversation history panel. vuetify.VSpacer() # Settings buttons diff --git a/src/vtk_prompt/vtk_prompt_ui.py b/src/vtk_prompt/vtk_prompt_ui.py index 3b4383a..a41c2af 100644 --- a/src/vtk_prompt/vtk_prompt_ui.py +++ b/src/vtk_prompt/vtk_prompt_ui.py @@ -22,7 +22,7 @@ import vtk from trame.app import TrameApp from trame.decorators import change, controller, trigger -from trame.ui.vuetify3 import SinglePageLayout +from trame.ui.vuetify3 import SinglePageWithDrawerLayout from vtkmodules.vtkInteractionStyle import vtkInteractorStyleSwitch # noqa from . import get_logger @@ -32,7 +32,12 @@ setup_vtk_renderer, ) from .state import config_state, config_validator, initializer -from .ui.layout import build_content, build_settings_dialog, build_toolbar +from .ui.layout import ( + build_content, + build_conversation_history, + build_settings_dialog, + build_toolbar, +) from .utils import file_handlers, prompt_loader logger = get_logger(__name__) @@ -254,6 +259,16 @@ def navigate_conversation_right(self) -> None: """Navigate to next conversation pair.""" conversation.navigate_conversation_right(self) + @controller.set("navigate_to_conversation") + def navigate_to_conversation(self, target_index: int) -> None: + """Jump directly to a conversation pair from the history panel.""" + conversation.navigate_to_conversation(self, target_index) + + @controller.set("toggle_favorite_conversation") + def toggle_favorite_conversation(self, conversation_index: int) -> None: + """Toggle a conversation's favorite status from the history panel.""" + conversation.toggle_favorite_conversation(self, conversation_index) + @trigger("save_conversation") def save_conversation(self) -> str: """Save current conversation history as JSON string.""" @@ -274,11 +289,17 @@ def _build_ui(self) -> None: # Initialize drawer state as collapsed self.state.main_drawer = False - with SinglePageLayout( + with SinglePageWithDrawerLayout( self.server, theme=("theme_mode", "light"), style="max-height: 100vh;" ) as layout: layout.title.set_text("VTK Prompt UI") + # Left drawer: browsable conversation history (toggled by the + # toolbar nav icon; bound to main_drawer, collapsed by default). + with layout.drawer: + layout.drawer.width = 320 + build_conversation_history(self) + # Build UI sections using layout modules build_toolbar(layout, self) build_content(layout, self) From e3dc15ae5cc51b6a6ad42e623f0232d90d99e71c Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Thu, 25 Jun 2026 09:49:31 -0400 Subject: [PATCH 02/24] Snapshot hand edits into undo/redo history (debounced) Previously code_history only recorded generations and runs, so undo/redo could not step through manual edits made in the editor between runs. Add a debounced @change("generated_code") handler: after a short typing pause it records the current code as a snapshot, coalescing a burst of keystrokes into a single undo step. A content guard pushes a snapshot only when generated_code differs from the active snapshot (code_history[pos]), so programmatic updates (generate, run, undo/redo, per-conversation restore) are ignored and cannot create a feedback loop. Loads are skipped via the existing _conversation_loading flag. Snapshots land in the active conversation's code_history, which is already persisted per pair on navigate-away, so edit-level undo is per-conversation for free. --- src/vtk_prompt/vtk_prompt_ui.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/vtk_prompt/vtk_prompt_ui.py b/src/vtk_prompt/vtk_prompt_ui.py index a41c2af..fe77158 100644 --- a/src/vtk_prompt/vtk_prompt_ui.py +++ b/src/vtk_prompt/vtk_prompt_ui.py @@ -16,6 +16,7 @@ >>> vtk-prompt-ui --port 9090 """ +import asyncio import sys from typing import Any @@ -76,6 +77,7 @@ def __init__(self, server: Any | None = None, custom_prompt_file: str | None = N # Initialize VTK components for trame self.renderer, self.render_window, self.render_window_interactor = setup_vtk_renderer() self._conversation_loading = False + self._snapshot_task: asyncio.Task | None = None add_default_scene(self.renderer) # Expose the live renderer/render_window to editor completion + hover, so @@ -284,6 +286,37 @@ def _on_provider_change(self, provider, **kwargs) -> None: """Handle provider selection change.""" configuration.on_provider_change(self, provider, **kwargs) + @change("generated_code") + def _on_generated_code_change(self, **_: Any) -> None: + """Debounce-snapshot manual edits so undo/redo can step through them.""" + if self._snapshot_task is not None and not self._snapshot_task.done(): + self._snapshot_task.cancel() + try: + self._snapshot_task = asyncio.ensure_future(self._debounced_code_snapshot()) + except RuntimeError: + # No running event loop yet (e.g. during construction); nothing to do. + self._snapshot_task = None + + async def _debounced_code_snapshot(self) -> None: + """Record the current code as a history snapshot after a typing pause. + + Only fires for genuine hand edits: programmatic updates (generate, run, + undo/redo, conversation restore) leave generated_code equal to the active + snapshot, so the content comparison below skips them. + """ + try: + await asyncio.sleep(0.7) + except asyncio.CancelledError: + return + if self._conversation_loading: + return + history = self.state.code_history or [] + pos = self.state.code_history_pos + active = history[pos] if 0 <= pos < len(history) else None + if self.state.generated_code and self.state.generated_code != active: + with self.state: + generation.push_code_snapshot(self, self.state.generated_code) + def _build_ui(self) -> None: """Build a simplified Vuetify UI.""" # Initialize drawer state as collapsed From 8f53de675e4f7ebd31e58b53ffdecb774896a2ae Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Thu, 25 Jun 2026 10:32:35 -0400 Subject: [PATCH 03/24] Make undo/redo editor-only (do not replay the script) undo_code/redo_code re-executed the restored version through execute_with_renderer on every step, so each undo/redo replayed the script and rebuilt the scene. Step the editor through code versions only; Run remains the way to execute. After undo/redo generated_code equals the active snapshot, so the debounced edit-snapshot guard correctly skips it. --- src/vtk_prompt/controllers/generation.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/vtk_prompt/controllers/generation.py b/src/vtk_prompt/controllers/generation.py index 876d8d6..cc779d7 100644 --- a/src/vtk_prompt/controllers/generation.py +++ b/src/vtk_prompt/controllers/generation.py @@ -223,25 +223,23 @@ def push_code_snapshot(app: Any, code_string: str) -> None: def undo_code(app: Any) -> None: - """Step back to the previous code version and re-render it.""" + """Step the editor back to the previous code version (does not re-run).""" history = app.state.code_history or [] pos = app.state.code_history_pos if pos > 0: pos -= 1 app.state.code_history_pos = pos app.state.generated_code = history[pos] - execute_with_renderer(app, app.state.generated_code) def redo_code(app: Any) -> None: - """Step forward to the next code version and re-render it.""" + """Step the editor forward to the next code version (does not re-run).""" history = app.state.code_history or [] pos = app.state.code_history_pos if pos < len(history) - 1: pos += 1 app.state.code_history_pos = pos app.state.generated_code = history[pos] - execute_with_renderer(app, app.state.generated_code) def clear_scene(app: Any) -> None: From 1af8f15f389f9e950c844f08d33b2bbae0f6f31f Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Thu, 25 Jun 2026 10:38:40 -0400 Subject: [PATCH 04/24] Disable Run when the editor matches the rendered scene Track rendered_code (the code shown in the 3D view) and set it on each successful execute_with_renderer. The Run button is disabled while generated_code equals rendered_code, so it lights up only when the editor has something new to apply. Pairs with editor-only undo/redo: stepping to a version that differs from the rendered scene now re-enables Run. Failed runs leave rendered_code unchanged so the user can retry. --- src/vtk_prompt/controllers/generation.py | 3 +++ src/vtk_prompt/state/initializer.py | 3 +++ src/vtk_prompt/ui/layout/content.py | 5 ++++- 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/vtk_prompt/controllers/generation.py b/src/vtk_prompt/controllers/generation.py index cc779d7..263c270 100644 --- a/src/vtk_prompt/controllers/generation.py +++ b/src/vtk_prompt/controllers/generation.py @@ -176,6 +176,9 @@ def execute_with_renderer(app: Any, code_string: str) -> tuple[bool, str | None] if not success and error_message: app.state.error_message = error_message + if success: + app.state.rendered_code = code_string + # Always update view try: app.ctrl.view_update() diff --git a/src/vtk_prompt/state/initializer.py b/src/vtk_prompt/state/initializer.py index e2cf2a1..fff8128 100644 --- a/src/vtk_prompt/state/initializer.py +++ b/src/vtk_prompt/state/initializer.py @@ -26,6 +26,9 @@ def initialize_state(app: Any) -> None: app.state.query_text = "" app.state.generated_code = "" app.state.generated_explanation = "" + # Code currently shown in the 3D view (last successful render); used to + # disable Run when the editor already matches what is rendered. + app.state.rendered_code = "" # Version history for the editable code panel (undo/redo across generations, # runs, and manual edits). code_history_pos indexes the active snapshot. app.state.code_history = [] diff --git a/src/vtk_prompt/ui/layout/content.py b/src/vtk_prompt/ui/layout/content.py index 6f0e450..f674e28 100644 --- a/src/vtk_prompt/ui/layout/content.py +++ b/src/vtk_prompt/ui/layout/content.py @@ -176,7 +176,10 @@ def build_content(layout: Any, app: Any) -> None: size="small", color="primary", variant="flat", - disabled=("is_loading || !generated_code",), + disabled=( + "is_loading || !generated_code" + " || generated_code === rendered_code", + ), ) with vuetify.VCardText(style="height: calc(100% - 50px);"): code.Editor( From b9db7b47d39e9e1a22dacbbe5bd38945235d7918 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Thu, 25 Jun 2026 11:55:01 -0400 Subject: [PATCH 05/24] Make the history drawer a temporary overlay Set the conversation history drawer to temporary so it overlays the scene instead of pushing and resizing the render view. This removes the resize churn (and the benign ResizeObserver console notice) that the persistent drawer caused when toggled. The drawer still binds to main_drawer and is toggled by the toolbar nav icon; selecting a conversation now dismisses the overlay. --- src/vtk_prompt/controllers/conversation.py | 2 ++ src/vtk_prompt/vtk_prompt_ui.py | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/vtk_prompt/controllers/conversation.py b/src/vtk_prompt/controllers/conversation.py index e3820b8..30a4712 100644 --- a/src/vtk_prompt/controllers/conversation.py +++ b/src/vtk_prompt/controllers/conversation.py @@ -126,6 +126,8 @@ def navigate_to_conversation(app: Any, target_index: int) -> None: app.state.conversation_index = target_index _process_conversation_pair(app, target_index) _update_navigation_state(app) + # The history drawer overlays the scene; dismiss it after a selection. + app.state.main_drawer = False def toggle_favorite_conversation(app: Any, conversation_index: int) -> None: diff --git a/src/vtk_prompt/vtk_prompt_ui.py b/src/vtk_prompt/vtk_prompt_ui.py index fe77158..69c4081 100644 --- a/src/vtk_prompt/vtk_prompt_ui.py +++ b/src/vtk_prompt/vtk_prompt_ui.py @@ -327,10 +327,12 @@ def _build_ui(self) -> None: ) as layout: layout.title.set_text("VTK Prompt UI") - # Left drawer: browsable conversation history (toggled by the - # toolbar nav icon; bound to main_drawer, collapsed by default). + # Left drawer: browsable conversation history. Overlays the scene + # (temporary) so toggling it does not resize the render view; bound + # to main_drawer and collapsed by default. with layout.drawer: layout.drawer.width = 320 + layout.drawer.temporary = True build_conversation_history(self) # Build UI sections using layout modules From a028b0530e08371a7860495346b0822741abae6d Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Thu, 25 Jun 2026 12:05:29 -0400 Subject: [PATCH 06/24] Fix history drawer to truly overlay (clear permanent) The drawer from SinglePageWithDrawerLayout defaults to permanent=True, so adding temporary left both flags set and permanent won, the drawer kept reserving layout space and resizing the render view. Clear permanent so the drawer renders as a true temporary overlay (:permanent="false" temporary) and toggling it no longer resizes the view. --- src/vtk_prompt/vtk_prompt_ui.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/vtk_prompt/vtk_prompt_ui.py b/src/vtk_prompt/vtk_prompt_ui.py index 69c4081..14915c2 100644 --- a/src/vtk_prompt/vtk_prompt_ui.py +++ b/src/vtk_prompt/vtk_prompt_ui.py @@ -332,6 +332,9 @@ def _build_ui(self) -> None: # to main_drawer and collapsed by default. with layout.drawer: layout.drawer.width = 320 + # Overlay the scene instead of pushing it: temporary needs + # permanent cleared, else permanent wins and resizes the view. + layout.drawer.permanent = False layout.drawer.temporary = True build_conversation_history(self) From 8be37aa52e37f97572f2fa003d3ad91559ddc4af Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Thu, 25 Jun 2026 12:05:29 -0400 Subject: [PATCH 07/24] Move the prompt box below the generated code (Claude-style) Reorder the left column so the generated-code panel is on top and the prompt input (model chips, query box, navigation, Generate) sits at the bottom, matching the familiar bottom-input chat layout. Heights are unchanged (code h-75, prompt h-25); only the order and top margins swap. --- src/vtk_prompt/ui/layout/content.py | 154 ++++++++++++++-------------- 1 file changed, 77 insertions(+), 77 deletions(-) diff --git a/src/vtk_prompt/ui/layout/content.py b/src/vtk_prompt/ui/layout/content.py index f674e28..96f52e6 100644 --- a/src/vtk_prompt/ui/layout/content.py +++ b/src/vtk_prompt/ui/layout/content.py @@ -24,8 +24,84 @@ def build_content(layout: Any, app: Any) -> None: with vuetify.VRow(rows=12, classes="fill-height px-4 pt-1 pb-1"): # Left column - Generated code view with vuetify.VCol(cols=6): + # Generated code panel (editable + re-runnable) + with vuetify.VCard(classes="h-75"): + with vuetify.VCardTitle( + "Generated Code", classes="d-flex align-center" + ): + vuetify.VSpacer() + # Undo across code versions (generations, runs, edits) + with vuetify.VTooltip(text="Undo code change", location="bottom"): + with vuetify.Template(v_slot_activator="{ props }"): + with vuetify.VBtn( + click=app.ctrl.undo_code, + icon=True, + variant="text", + density="compact", + color="secondary", + classes="mr-1", + v_bind="props", + disabled=("code_history_pos < 1",), + ): + vuetify.VIcon("mdi-undo") + with vuetify.VTooltip(text="Redo code change", location="bottom"): + with vuetify.Template(v_slot_activator="{ props }"): + with vuetify.VBtn( + click=app.ctrl.redo_code, + icon=True, + variant="text", + density="compact", + color="secondary", + classes="mr-2", + v_bind="props", + disabled=( + "code_history_pos >= code_history.length - 1", + ), + ): + vuetify.VIcon("mdi-redo") + # Run the (possibly edited) code without the LLM + vuetify.VBtn( + "Run", + click=app.ctrl.run_current_code, + prepend_icon="mdi-play", + size="small", + color="primary", + variant="flat", + disabled=( + "is_loading || !generated_code" + " || generated_code === rendered_code", + ), + ) + with vuetify.VCardText(style="height: calc(100% - 50px);"): + code.Editor( + v_model=("generated_code", ""), + language="python", + theme="vs", + textmate=("code_textmate", PYTHON_TEXTMATE), + completion=app.jedi_complete, + hover=app.jedi_hover, + options=( + "code_editor_options", + { + "automaticLayout": True, + "minimap": {"enabled": False}, + "fontSize": 13, + "scrollBeyondLastLine": False, + "lineNumbers": "on", + "tabSize": 4, + # Render hover/suggest widgets at the + # document body so the surrounding + # VCard overflow does not clip them; + # sticky lets long docstrings scroll. + "fixedOverflowWidgets": True, + "hover": {"enabled": True, "sticky": True}, + }, + ), + style="height: 100%; width: 100%;", + ) + # Prompt input - with vuetify.VCard(classes="h-25"): + with vuetify.VCard(classes="h-25 mt-2"): with vuetify.VCardText(classes="h-100"): with html.Div(classes="d-flex"): # Cloud models chip @@ -133,82 +209,6 @@ def build_content(layout: Any, app: Any) -> None: v_show="use_cloud_models && !api_token.trim()", ) - # Generated code panel (editable + re-runnable) - with vuetify.VCard(classes="h-75 mt-2"): - with vuetify.VCardTitle( - "Generated Code", classes="d-flex align-center" - ): - vuetify.VSpacer() - # Undo across code versions (generations, runs, edits) - with vuetify.VTooltip(text="Undo code change", location="bottom"): - with vuetify.Template(v_slot_activator="{ props }"): - with vuetify.VBtn( - click=app.ctrl.undo_code, - icon=True, - variant="text", - density="compact", - color="secondary", - classes="mr-1", - v_bind="props", - disabled=("code_history_pos < 1",), - ): - vuetify.VIcon("mdi-undo") - with vuetify.VTooltip(text="Redo code change", location="bottom"): - with vuetify.Template(v_slot_activator="{ props }"): - with vuetify.VBtn( - click=app.ctrl.redo_code, - icon=True, - variant="text", - density="compact", - color="secondary", - classes="mr-2", - v_bind="props", - disabled=( - "code_history_pos >= code_history.length - 1", - ), - ): - vuetify.VIcon("mdi-redo") - # Run the (possibly edited) code without the LLM - vuetify.VBtn( - "Run", - click=app.ctrl.run_current_code, - prepend_icon="mdi-play", - size="small", - color="primary", - variant="flat", - disabled=( - "is_loading || !generated_code" - " || generated_code === rendered_code", - ), - ) - with vuetify.VCardText(style="height: calc(100% - 50px);"): - code.Editor( - v_model=("generated_code", ""), - language="python", - theme="vs", - textmate=("code_textmate", PYTHON_TEXTMATE), - completion=app.jedi_complete, - hover=app.jedi_hover, - options=( - "code_editor_options", - { - "automaticLayout": True, - "minimap": {"enabled": False}, - "fontSize": 13, - "scrollBeyondLastLine": False, - "lineNumbers": "on", - "tabSize": 4, - # Render hover/suggest widgets at the - # document body so the surrounding - # VCard overflow does not clip them; - # sticky lets long docstrings scroll. - "fixedOverflowWidgets": True, - "hover": {"enabled": True, "sticky": True}, - }, - ), - style="height: 100%; width: 100%;", - ) - # Right column - VTK viewer and prompt with vuetify.VCol(cols=6): with vuetify.VRow(no_gutters=True, classes="fill-height"): From 8e3eb24a0d8240ccf037d90b3c8bdc5dc933ef92 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Thu, 25 Jun 2026 12:40:24 -0400 Subject: [PATCH 08/24] Add New conversation to drawer top; drop prompt nav arrows Put a New conversation button at the top of the history drawer (Claude-style). It starts a fresh prompt entry: clears the query, editor, explanation, and undo history, enters new-entry mode, and dismisses the overlay, while leaving the conversation history intact. Remove the prev/next arrows that flanked the query box. Browsing now lives in the history drawer (click any turn to jump), and the old right arrow's "new entry" role is replaced by the drawer's New conversation button. The prompt area is now just the query field and Generate, a cleaner chat-like input. The navigate_conversation_left/right controllers remain available for future keyboard shortcuts. --- src/vtk_prompt/controllers/conversation.py | 15 +++++++++ src/vtk_prompt/ui/layout/content.py | 33 ------------------- .../ui/layout/conversation_history.py | 15 +++++++-- src/vtk_prompt/vtk_prompt_ui.py | 5 +++ 4 files changed, 32 insertions(+), 36 deletions(-) diff --git a/src/vtk_prompt/controllers/conversation.py b/src/vtk_prompt/controllers/conversation.py index 30a4712..ed23945 100644 --- a/src/vtk_prompt/controllers/conversation.py +++ b/src/vtk_prompt/controllers/conversation.py @@ -130,6 +130,21 @@ def navigate_to_conversation(app: Any, target_index: int) -> None: app.state.main_drawer = False +def start_new_conversation(app: Any) -> None: + """Start a fresh prompt entry (Claude-style New): clear inputs, keep history.""" + save_current_code_state(app) + nav_length = len(app.state.conversation_navigation or []) + app.state.conversation_index = nav_length # "new entry" mode, past the last pair + app.state.query_text = "" + app.state.generated_code = "" + app.state.generated_explanation = "" + app.state.code_history = [] + app.state.code_history_pos = -1 + _update_navigation_state(app) + # Dismiss the temporary history drawer after starting a new conversation. + app.state.main_drawer = False + + def toggle_favorite_conversation(app: Any, conversation_index: int) -> None: """Toggle whether a conversation pair (by index) is favorited.""" favorites = list(getattr(app.state, "favorited_conversations", None) or []) diff --git a/src/vtk_prompt/ui/layout/content.py b/src/vtk_prompt/ui/layout/content.py index 96f52e6..5614961 100644 --- a/src/vtk_prompt/ui/layout/content.py +++ b/src/vtk_prompt/ui/layout/content.py @@ -143,15 +143,6 @@ def build_content(layout: Any, app: Any) -> None: ) with html.Div(classes="d-flex", style="height: calc(100% - 75px);"): - with vuetify.VBtn( - variant="tonal", - icon=True, - rounded="0", - disabled=("!can_navigate_left",), - classes="h-auto mr-1", - click=app.ctrl.navigate_conversation_left, - ): - vuetify.VIcon("mdi-arrow-left-circle") # Query input vuetify.VTextarea( label="Describe VTK visualization", @@ -163,30 +154,6 @@ def build_content(layout: Any, app: Any) -> None: hide_details=True, no_resize=True, ) - with vuetify.VBtn( - color=( - "conversation_index ===" - + " conversation_navigation.length - 1" - + " ? 'success' : 'default'", - "default", - ), - variant="tonal", - icon=True, - rounded="0", - disabled=("!can_navigate_right",), - click=app.ctrl.navigate_conversation_right, - ): - vuetify.VIcon( - "mdi-arrow-right-circle", - v_show="conversation_index <" - + " conversation_navigation.length - 1", - ) - vuetify.VIcon( - "mdi-message-plus", - v_show="conversation_index ===" - + " conversation_navigation.length - 1", - ) - # Generate button vuetify.VBtn( "Generate Code", diff --git a/src/vtk_prompt/ui/layout/conversation_history.py b/src/vtk_prompt/ui/layout/conversation_history.py index 00e9f93..009a22a 100644 --- a/src/vtk_prompt/ui/layout/conversation_history.py +++ b/src/vtk_prompt/ui/layout/conversation_history.py @@ -8,8 +8,17 @@ def build_conversation_history(app: Any) -> None: """Build the conversation history component with clickable conversation cards.""" - # Bottom: Conversation History - with vuetify.VCard(classes="h-100 w-100", flat=True): + with vuetify.VCard(classes="h-100 w-100 d-flex flex-column", flat=True): + # New conversation: start a fresh prompt (Claude-style, at the top). + vuetify.VBtn( + "New conversation", + prepend_icon="mdi-plus", + block=True, + color="primary", + variant="flat", + classes="ma-2", + click=app.ctrl.start_new_conversation, + ) with vuetify.VCardTitle("Conversation History", classes="d-flex align-center"): vuetify.VSpacer() @@ -61,7 +70,7 @@ def build_conversation_history(app: Any) -> None: v_bind="props", ) - with vuetify.VCardText(style="height: calc(100% - 50px); overflow-y: auto;"): + with vuetify.VCardText(style="flex: 1 1 auto; min-height: 0; overflow-y: auto;"): # Show message when no history vuetify.VAlert( text="No conversation history yet." + " Start by generating some VTK code!", diff --git a/src/vtk_prompt/vtk_prompt_ui.py b/src/vtk_prompt/vtk_prompt_ui.py index 14915c2..e2c6ad5 100644 --- a/src/vtk_prompt/vtk_prompt_ui.py +++ b/src/vtk_prompt/vtk_prompt_ui.py @@ -271,6 +271,11 @@ def toggle_favorite_conversation(self, conversation_index: int) -> None: """Toggle a conversation's favorite status from the history panel.""" conversation.toggle_favorite_conversation(self, conversation_index) + @controller.set("start_new_conversation") + def start_new_conversation(self) -> None: + """Start a fresh prompt entry from the history drawer.""" + conversation.start_new_conversation(self) + @trigger("save_conversation") def save_conversation(self) -> str: """Save current conversation history as JSON string.""" From c0e46ebcd3a3726cc243f6c8217bd19c05ba0534 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Thu, 25 Jun 2026 12:53:57 -0400 Subject: [PATCH 09/24] User-controlled history drawer, smaller New button, silence ResizeObserver - Drawer is no longer temporary: it is a user-controlled persistent drawer toggled by the toolbar nav icon, staying open until toggled, and no longer auto-closes on selection or New. - Shrink the New conversation button (small, tonal) so it is less bulky. - Silence the benign Chrome "ResizeObserver loop completed with undelivered notifications" notice via a targeted client.Script that ignores only that specific message (and the older "loop limit exceeded") on the error event and console.error, leaving all other errors intact. This notice comes from a ResizeObserver callback (render view / Vuetify components sizing on connect and layout) deferring work to the next frame, not from the drawer, which is why it appeared even with the overlay. --- src/vtk_prompt/controllers/conversation.py | 4 -- .../ui/layout/conversation_history.py | 3 +- src/vtk_prompt/vtk_prompt_ui.py | 37 +++++++++++++++++-- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/vtk_prompt/controllers/conversation.py b/src/vtk_prompt/controllers/conversation.py index ed23945..0cd2e59 100644 --- a/src/vtk_prompt/controllers/conversation.py +++ b/src/vtk_prompt/controllers/conversation.py @@ -126,8 +126,6 @@ def navigate_to_conversation(app: Any, target_index: int) -> None: app.state.conversation_index = target_index _process_conversation_pair(app, target_index) _update_navigation_state(app) - # The history drawer overlays the scene; dismiss it after a selection. - app.state.main_drawer = False def start_new_conversation(app: Any) -> None: @@ -141,8 +139,6 @@ def start_new_conversation(app: Any) -> None: app.state.code_history = [] app.state.code_history_pos = -1 _update_navigation_state(app) - # Dismiss the temporary history drawer after starting a new conversation. - app.state.main_drawer = False def toggle_favorite_conversation(app: Any, conversation_index: int) -> None: diff --git a/src/vtk_prompt/ui/layout/conversation_history.py b/src/vtk_prompt/ui/layout/conversation_history.py index 009a22a..739f118 100644 --- a/src/vtk_prompt/ui/layout/conversation_history.py +++ b/src/vtk_prompt/ui/layout/conversation_history.py @@ -14,8 +14,9 @@ def build_conversation_history(app: Any) -> None: "New conversation", prepend_icon="mdi-plus", block=True, + size="small", color="primary", - variant="flat", + variant="tonal", classes="ma-2", click=app.ctrl.start_new_conversation, ) diff --git a/src/vtk_prompt/vtk_prompt_ui.py b/src/vtk_prompt/vtk_prompt_ui.py index e2c6ad5..234e958 100644 --- a/src/vtk_prompt/vtk_prompt_ui.py +++ b/src/vtk_prompt/vtk_prompt_ui.py @@ -24,6 +24,7 @@ from trame.app import TrameApp from trame.decorators import change, controller, trigger from trame.ui.vuetify3 import SinglePageWithDrawerLayout +from trame.widgets import client from vtkmodules.vtkInteractionStyle import vtkInteractorStyleSwitch # noqa from . import get_logger @@ -43,6 +44,35 @@ logger = get_logger(__name__) +# Chrome surfaces a benign "ResizeObserver loop completed with undelivered +# notifications" message whenever a ResizeObserver callback (the render view or +# Vuetify components sizing themselves on connect/layout) defers work to the +# next frame. Nothing is lost; this silences only that specific notice. +_RESIZE_OBSERVER_SILENCER = """ +(function () { + var RO_MSGS = [ + 'ResizeObserver loop completed with undelivered notifications', + 'ResizeObserver loop limit exceeded' + ]; + function isRO(msg) { + return typeof msg === 'string' && RO_MSGS.some(function (m) { + return msg.indexOf(m) !== -1; + }); + } + window.addEventListener('error', function (e) { + if (isRO(e && e.message)) { + e.stopImmediatePropagation(); + e.preventDefault(); + } + }); + var origError = window.console.error; + window.console.error = function () { + if (arguments.length && isRO(arguments[0])) { return; } + return origError.apply(this, arguments); + }; +})(); +""" + class VTKPromptApp(TrameApp): """VTK Prompt interactive application with 3D visualization and AI chat interface.""" @@ -331,16 +361,17 @@ def _build_ui(self) -> None: self.server, theme=("theme_mode", "light"), style="max-height: 100vh;" ) as layout: layout.title.set_text("VTK Prompt UI") + client.Script(_RESIZE_OBSERVER_SILENCER) # Left drawer: browsable conversation history. Overlays the scene # (temporary) so toggling it does not resize the render view; bound # to main_drawer and collapsed by default. with layout.drawer: layout.drawer.width = 320 - # Overlay the scene instead of pushing it: temporary needs - # permanent cleared, else permanent wins and resizes the view. + # User-controlled drawer: toggled by the toolbar nav icon and + # stays open until toggled (not temporary). permanent is cleared + # so the toggle works; collapsed by default via main_drawer. layout.drawer.permanent = False - layout.drawer.temporary = True build_conversation_history(self) # Build UI sections using layout modules From b51ea374533554edbb4a3b505010e4992167f1ee Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Thu, 25 Jun 2026 13:03:10 -0400 Subject: [PATCH 10/24] Slim down the New conversation button The filled tonal block rendered as a large blue bar. Make it a subtle compact text button (small, density compact, text variant, left-aligned, normal case) so it reads like a quiet sidebar action rather than a banner. Also drop the h-100/flex-column forcing on the panel so its contents sit top-aligned naturally and the list sizes to content (drawer scrolls). --- src/vtk_prompt/ui/layout/conversation_history.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/vtk_prompt/ui/layout/conversation_history.py b/src/vtk_prompt/ui/layout/conversation_history.py index 739f118..462d73c 100644 --- a/src/vtk_prompt/ui/layout/conversation_history.py +++ b/src/vtk_prompt/ui/layout/conversation_history.py @@ -8,16 +8,17 @@ def build_conversation_history(app: Any) -> None: """Build the conversation history component with clickable conversation cards.""" - with vuetify.VCard(classes="h-100 w-100 d-flex flex-column", flat=True): + with vuetify.VCard(classes="w-100", flat=True): # New conversation: start a fresh prompt (Claude-style, at the top). vuetify.VBtn( "New conversation", prepend_icon="mdi-plus", block=True, size="small", + density="compact", + variant="text", color="primary", - variant="tonal", - classes="ma-2", + classes="justify-start text-none my-1", click=app.ctrl.start_new_conversation, ) with vuetify.VCardTitle("Conversation History", classes="d-flex align-center"): @@ -71,7 +72,7 @@ def build_conversation_history(app: Any) -> None: v_bind="props", ) - with vuetify.VCardText(style="flex: 1 1 auto; min-height: 0; overflow-y: auto;"): + with vuetify.VCardText(style="overflow-y: auto;"): # Show message when no history vuetify.VAlert( text="No conversation history yet." + " Start by generating some VTK code!", From 6eab23e302dfcdb21300cdb88b52d39aaf218b16 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Thu, 25 Jun 2026 13:12:01 -0400 Subject: [PATCH 11/24] Move New to a + icon in the Recents header Replace the standalone New conversation button with a compact + icon button sitting alongside the sort and favorites toggles in the panel header, and rename "Conversation History" to "Recents". Same start_new_conversation action, less vertical space, cleaner header. --- .../ui/layout/conversation_history.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/vtk_prompt/ui/layout/conversation_history.py b/src/vtk_prompt/ui/layout/conversation_history.py index 462d73c..819dd4e 100644 --- a/src/vtk_prompt/ui/layout/conversation_history.py +++ b/src/vtk_prompt/ui/layout/conversation_history.py @@ -9,21 +9,21 @@ def build_conversation_history(app: Any) -> None: """Build the conversation history component with clickable conversation cards.""" with vuetify.VCard(classes="w-100", flat=True): - # New conversation: start a fresh prompt (Claude-style, at the top). - vuetify.VBtn( - "New conversation", - prepend_icon="mdi-plus", - block=True, - size="small", - density="compact", - variant="text", - color="primary", - classes="justify-start text-none my-1", - click=app.ctrl.start_new_conversation, - ) - with vuetify.VCardTitle("Conversation History", classes="d-flex align-center"): + with vuetify.VCardTitle("Recents", classes="d-flex align-center"): vuetify.VSpacer() + # New conversation button + with vuetify.VTooltip(text="New conversation", location="bottom"): + with vuetify.Template(v_slot_activator="{ props }"): + vuetify.VBtn( + icon="mdi-plus", + click=app.ctrl.start_new_conversation, + variant="text", + density="compact", + color="primary", + v_bind="props", + ) + # Sort toggle button with vuetify.VTooltip(text="Toggle sort order", location="bottom"): with vuetify.Template(v_slot_activator="{ props }"): From 9421e03050acca840e92b34fab8f07221a5cc1b7 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Thu, 25 Jun 2026 13:28:31 -0400 Subject: [PATCH 12/24] Show Recents by default; disable New on a fresh entry - Open the Recents drawer by default (main_drawer = True); still toggled by the toolbar nav icon. - Disable the New (+) button when already on a fresh new entry (conversation_index >= conversation_navigation.length), which includes the first-time empty state. New is enabled once you are viewing a generated conversation, so it can branch to a new one; clicking it when already on a blank, ungenerated entry would be a no-op. --- src/vtk_prompt/ui/layout/conversation_history.py | 6 ++++++ src/vtk_prompt/vtk_prompt_ui.py | 15 +++++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/vtk_prompt/ui/layout/conversation_history.py b/src/vtk_prompt/ui/layout/conversation_history.py index 819dd4e..349f351 100644 --- a/src/vtk_prompt/ui/layout/conversation_history.py +++ b/src/vtk_prompt/ui/layout/conversation_history.py @@ -21,6 +21,12 @@ def build_conversation_history(app: Any) -> None: variant="text", density="compact", color="primary", + # Already on a fresh, ungenerated new entry -> nothing to do. + disabled=( + "conversation_index" + + " >= conversation_navigation.length", + True, + ), v_bind="props", ) diff --git a/src/vtk_prompt/vtk_prompt_ui.py b/src/vtk_prompt/vtk_prompt_ui.py index 234e958..ebd9451 100644 --- a/src/vtk_prompt/vtk_prompt_ui.py +++ b/src/vtk_prompt/vtk_prompt_ui.py @@ -354,8 +354,8 @@ async def _debounced_code_snapshot(self) -> None: def _build_ui(self) -> None: """Build a simplified Vuetify UI.""" - # Initialize drawer state as collapsed - self.state.main_drawer = False + # Show the Recents drawer by default; user can toggle it closed. + self.state.main_drawer = True with SinglePageWithDrawerLayout( self.server, theme=("theme_mode", "light"), style="max-height: 100vh;" @@ -363,14 +363,13 @@ def _build_ui(self) -> None: layout.title.set_text("VTK Prompt UI") client.Script(_RESIZE_OBSERVER_SILENCER) - # Left drawer: browsable conversation history. Overlays the scene - # (temporary) so toggling it does not resize the render view; bound - # to main_drawer and collapsed by default. + # Left drawer: browsable Recents (conversation history). A + # user-controlled persistent drawer bound to main_drawer, shown by + # default and toggled by the toolbar nav icon. with layout.drawer: layout.drawer.width = 320 - # User-controlled drawer: toggled by the toolbar nav icon and - # stays open until toggled (not temporary). permanent is cleared - # so the toggle works; collapsed by default via main_drawer. + # permanent is cleared so the v-model toggle works; open state + # is driven by main_drawer (shown by default). layout.drawer.permanent = False build_conversation_history(self) From b13d8a5d161b738ce9abcb60371521171d0a0a0f Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Thu, 25 Jun 2026 18:53:59 -0400 Subject: [PATCH 13/24] Refine the current editor code, not the model's last output Refinement prompts previously regenerated from the model's own previous output, so manual edits in the editor were invisible to the next generation and got discarded (it replaced rather than mutated). Before each refinement query, sync the live editor code (minus the display-only renderer banner) into the block of the most recent assistant turn, so the model mutates what is actually on screen, including hand edits. Guarded to the most recent turn only (not when viewing history or on a new entry), and a no-op when there is no conversation yet. --- src/vtk_prompt/controllers/conversation.py | 39 ++++++++++++++++++++++ src/vtk_prompt/controllers/generation.py | 6 ++++ 2 files changed, 45 insertions(+) diff --git a/src/vtk_prompt/controllers/conversation.py b/src/vtk_prompt/controllers/conversation.py index 0cd2e59..745bbed 100644 --- a/src/vtk_prompt/controllers/conversation.py +++ b/src/vtk_prompt/controllers/conversation.py @@ -85,6 +85,45 @@ def save_current_code_state(app: Any) -> None: } +def sync_editor_code_into_conversation(app: Any) -> None: + """Make the latest assistant turn reflect the current editor code. + + A refinement prompt should mutate the code that is actually on screen, + including manual edits, rather than the model's previous output. Rewrite the + block of the most recent assistant message to the current editor + contents (minus the display-only renderer banner) so the model refines from + there. Only applies when viewing the most recent turn. + """ + convo = getattr(app.prompt_client, "conversation", None) + if not convo: + return + nav = app.state.conversation_navigation or [] + # Only sync when refining the most recent turn (not history, not new entry). + if not nav or app.state.conversation_index != len(nav) - 1: + return + code = app.state.generated_code or "" + prefix = EXPLAIN_RENDERER + "\n" + if code.startswith(prefix): + code = code[len(prefix):] + code = code.strip() + if not code: + return + for msg in reversed(convo): + if msg.get("role") == "assistant": + content = msg.get("content", "") + if "" in content and "" in content: + msg["content"] = re.sub( + r".*?", + lambda _m: "" + code + "", + content, + count=1, + flags=re.DOTALL, + ) + else: + msg["content"] = content + "\n" + code + "" + return + + def navigate_conversation_left(app: Any) -> None: """Navigate to previous conversation pair.""" if not app.state.conversation_navigation: diff --git a/src/vtk_prompt/controllers/generation.py b/src/vtk_prompt/controllers/generation.py index 263c270..50b3ac4 100644 --- a/src/vtk_prompt/controllers/generation.py +++ b/src/vtk_prompt/controllers/generation.py @@ -69,6 +69,12 @@ async def generate_and_execute_code(app: Any) -> None: if hasattr(app.state, "error_message") and app.state.error_message: return + # Refine the CURRENT editor code (including manual edits), not the + # model's previous output, so generation mutates what is on screen. + from .conversation import sync_editor_code_into_conversation + + sync_editor_code_into_conversation(app) + result = await asyncio.to_thread( app.prompt_client.query, enhanced_query, From 0ea02f05e42f107ef5d5b703cc5437c16b0857aa Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Thu, 25 Jun 2026 19:20:03 -0400 Subject: [PATCH 14/24] Reset the model context when starting a new conversation New conversation cleared the UI but left app.prompt_client.conversation intact, so a "new" conversation silently continued the previous LLM context. Clear the client conversation and its file pointer (otherwise the next query reloads the old file into the fresh conversation), plus the displayed thread, per-conversation code state, and editor, so the new conversation truly starts unrelated to the old one. The prior conversation is discarded from the UI; preserving multiple conversations is the separate sessions model. --- src/vtk_prompt/controllers/conversation.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/vtk_prompt/controllers/conversation.py b/src/vtk_prompt/controllers/conversation.py index 745bbed..064e934 100644 --- a/src/vtk_prompt/controllers/conversation.py +++ b/src/vtk_prompt/controllers/conversation.py @@ -168,10 +168,22 @@ def navigate_to_conversation(app: Any, target_index: int) -> None: def start_new_conversation(app: Any) -> None: - """Start a fresh prompt entry (Claude-style New): clear inputs, keep history.""" - save_current_code_state(app) - nav_length = len(app.state.conversation_navigation or []) - app.state.conversation_index = nav_length # "new entry" mode, past the last pair + """Start a brand-new conversation: reset the model context and the thread. + + "New" means unrelated to what came before, so we clear the client's + conversation (the LLM context) and the file pointer (else the next query + would reload it), along with the displayed thread, per-conversation code + state, and the editor. The prior conversation is discarded from the UI; + preserving multiple conversations is what the sessions model would add. + """ + if app.prompt_client: + app.prompt_client.conversation = [] + app.prompt_client.conversation_file = None + app._conversation_code_states = {} + app.state.conversation = [] + app.state.conversation_navigation = [] + app.state.conversation_index = 0 + app.state.conversation_file = None app.state.query_text = "" app.state.generated_code = "" app.state.generated_explanation = "" From fe8fde1083242d88ab106b3ba9940692b30e49a1 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Fri, 26 Jun 2026 11:05:07 -0400 Subject: [PATCH 15/24] Unify code history into one per-conversation timeline Code changes came from two sources (manual edits and LLM generations) and were segmented per turn by a slot layer that swapped code_history on navigation, so the two could clobber each other and edits could be lost. Collapse to a single per-conversation code timeline. Every LLM generation and every manual edit appends a labeled version to one code_history (with a parallel code_history_labels recording origin: the prompt text, "Manual edit", or "Run"). Forward/back (undo/redo) steps through the whole timeline regardless of what produced each version. Turns are anchored to the timeline via per-turn checkpoints (record_turn_checkpoint), so jumping to a turn moves to the version that turn produced; edits made afterward remain further forward on the same timeline and are never discarded. Removes the per-turn slot machinery (_conversation_code_states, _code_states, _pair_key, save_current_code_state) and the save-on-navigate calls. Loaded conversations seed their versions on first visit. --- src/vtk_prompt/controllers/conversation.py | 97 ++++++++++------------ src/vtk_prompt/controllers/generation.py | 29 +++++-- src/vtk_prompt/state/initializer.py | 1 + src/vtk_prompt/vtk_prompt_ui.py | 2 +- 4 files changed, 68 insertions(+), 61 deletions(-) diff --git a/src/vtk_prompt/controllers/conversation.py b/src/vtk_prompt/controllers/conversation.py index 064e934..9fe256c 100644 --- a/src/vtk_prompt/controllers/conversation.py +++ b/src/vtk_prompt/controllers/conversation.py @@ -5,7 +5,6 @@ and conversation state management in the VTK Prompt UI. """ -import hashlib import json import re from pathlib import Path @@ -58,31 +57,29 @@ def on_conversation_file_data_change( app.generate_code() -def _code_states(app: Any) -> dict: - """Per-conversation code-state store (server-side backing data, not UI state).""" - if not hasattr(app, "_conversation_code_states"): - app._conversation_code_states = {} - return app._conversation_code_states +def _checkpoints(app: Any) -> list: + """Turn index -> position on the single per-conversation code timeline. - -def _pair_key(pair: dict) -> str: - """Stable content-hash key for a pair (robust to index shifts and reloads).""" - user = pair.get("user", {}).get("content", "") - assistant = pair.get("assistant", {}).get("content", "") - return hashlib.sha256((user + "\x00" + assistant).encode("utf-8")).hexdigest() + Server-side backing data (not UI state). Each conversation has one linear + code history covering both LLM generations and manual edits; a checkpoint + anchors each turn to the version that turn produced. + """ + if not hasattr(app, "_conversation_checkpoints"): + app._conversation_checkpoints = [] + return app._conversation_checkpoints -def save_current_code_state(app: Any) -> None: - """Persist the active editor code + undo/redo history for the current pair.""" - nav = app.state.conversation_navigation - idx = app.state.conversation_index - if not nav or idx < 0 or idx >= len(nav): - return # new-entry mode or empty: nothing to persist - _code_states(app)[_pair_key(nav[idx])] = { - "code": app.state.generated_code, - "history": list(app.state.code_history or []), - "pos": app.state.code_history_pos, - } +def record_turn_checkpoint(app: Any) -> None: + """Anchor the newest turn to the current code version (one per turn).""" + nav = app.state.conversation_navigation or [] + cps = _checkpoints(app) + pos = app.state.code_history_pos + if len(cps) > len(nav): + del cps[len(nav):] + while len(cps) < len(nav): + cps.append(pos) + if nav: + cps[-1] = pos def sync_editor_code_into_conversation(app: Any) -> None: @@ -129,7 +126,6 @@ def navigate_conversation_left(app: Any) -> None: if not app.state.conversation_navigation: return - save_current_code_state(app) if app.state.conversation_index >= 0: app.state.conversation_index -= 1 if app.state.conversation_index >= 0: @@ -142,7 +138,6 @@ def navigate_conversation_right(app: Any) -> None: if not app.state.conversation_navigation: return - save_current_code_state(app) nav_length = len(app.state.conversation_navigation) if app.state.conversation_index < nav_length: app.state.conversation_index += 1 @@ -161,7 +156,6 @@ def navigate_to_conversation(app: Any, target_index: int) -> None: return if target_index < 0 or target_index >= len(app.state.conversation_navigation): return - save_current_code_state(app) app.state.conversation_index = target_index _process_conversation_pair(app, target_index) _update_navigation_state(app) @@ -179,7 +173,7 @@ def start_new_conversation(app: Any) -> None: if app.prompt_client: app.prompt_client.conversation = [] app.prompt_client.conversation_file = None - app._conversation_code_states = {} + app._conversation_checkpoints = [] app.state.conversation = [] app.state.conversation_navigation = [] app.state.conversation_index = 0 @@ -188,6 +182,7 @@ def start_new_conversation(app: Any) -> None: app.state.generated_code = "" app.state.generated_explanation = "" app.state.code_history = [] + app.state.code_history_labels = [] app.state.code_history_pos = -1 _update_navigation_state(app) @@ -290,39 +285,37 @@ def _process_conversation_pair(app: Any, pair_index: int | None = None) -> None: if explanation: app.state.generated_explanation = explanation - # Code is tracked per conversation: restore this pair's saved (possibly - # hand-edited) code and its undo/redo history if present, otherwise seed - # from the original generated code on first visit. - states = _code_states(app) - key = _pair_key(pair) - if key in states: - slot = states[key] - app.state.generated_code = slot["code"] - app.state.code_history = list(slot["history"]) - app.state.code_history_pos = slot["pos"] - if app.state.generated_code: - app._execute_with_renderer(app.state.generated_code) - elif explanation and code: - app.state.generated_code = EXPLAIN_RENDERER + "\n" + code - app.state.code_history = [app.state.generated_code] - app.state.code_history_pos = 0 - states[key] = { - "code": app.state.generated_code, - "history": list(app.state.code_history), - "pos": 0, - } - app._execute_with_renderer(app.state.generated_code) - - # Process user message for query text + # Process user message for query text (also used as a timeline label). user_content = pair["user"].get("content", "").strip() if EXTRA_INSTRUCTIONS_TAG in user_content: parts = user_content.split(EXTRA_INSTRUCTIONS_TAG, 1) query_text = parts[1].strip() if len(parts) > 1 else user_content else: query_text = user_content - app.state.query_text = query_text + # Jump the single per-conversation code timeline to this turn's anchored + # version. Manual edits made after that generation live further forward on + # the same timeline (reachable with redo), so they are never discarded. + cps = _checkpoints(app) + history = app.state.code_history or [] + if pair_index < len(cps) and 0 <= cps[pair_index] < len(history): + app.state.code_history_pos = cps[pair_index] + app.state.generated_code = history[app.state.code_history_pos] + elif code: + # No anchored version yet (e.g. a freshly loaded conversation): seed one + # on the timeline and anchor this turn to it. + from .generation import push_code_snapshot + + app.state.generated_code = EXPLAIN_RENDERER + "\n" + code + push_code_snapshot(app, app.state.generated_code, label=query_text) + while len(cps) <= pair_index: + cps.append(app.state.code_history_pos) + cps[pair_index] = app.state.code_history_pos + + if app.state.generated_code: + app._execute_with_renderer(app.state.generated_code) + def _process_loaded_conversation( app: Any, conversation_object: dict[str, Any] | None = None diff --git a/src/vtk_prompt/controllers/generation.py b/src/vtk_prompt/controllers/generation.py index 50b3ac4..26e58b1 100644 --- a/src/vtk_prompt/controllers/generation.py +++ b/src/vtk_prompt/controllers/generation.py @@ -125,12 +125,15 @@ async def generate_and_execute_code(app: Any) -> None: app.state.generated_explanation = generated_explanation app.state.generated_code = EXPLAIN_RENDERER + "\n" + generated_code - push_code_snapshot(app, app.state.generated_code) + push_code_snapshot( + app, app.state.generated_code, label=app.state.query_text or "Generated" + ) # Update navigation after new conversation entry - from .conversation import build_conversation_navigation + from .conversation import build_conversation_navigation, record_turn_checkpoint build_conversation_navigation(app) + record_turn_checkpoint(app) app._conversation_loading = False success, exec_error = execute_with_renderer(app, app.state.generated_code) @@ -158,7 +161,12 @@ async def generate_and_execute_code(app: Any) -> None: _, retry_code = retry_result[0], retry_result[1] if retry_code: app.state.generated_code = EXPLAIN_RENDERER + "\n" + retry_code - push_code_snapshot(app, app.state.generated_code) + from .conversation import record_turn_checkpoint + + push_code_snapshot( + app, app.state.generated_code, label=app.state.query_text or "Generated" + ) + record_turn_checkpoint(app) execute_with_renderer(app, app.state.generated_code) except ValueError as e: if "max_tokens" in str(e): @@ -204,30 +212,35 @@ def run_current_code(app: Any) -> None: app.state.error_message = "" app.state.is_loading = True try: - push_code_snapshot(app, app.state.generated_code) + push_code_snapshot(app, app.state.generated_code, label="Run") execute_with_renderer(app, app.state.generated_code) finally: app.state.is_loading = False -def push_code_snapshot(app: Any, code_string: str) -> None: - """Record a code version on the history stack (drops any redo tail). +def push_code_snapshot(app: Any, code_string: str, label: str = "") -> None: + """Record a labeled code version on the single per-conversation timeline. - No-op when the snapshot is identical to the current position, so repeated - runs of unchanged code do not bloat the history. + Drops any redo tail, and is a no-op when identical to the current position so + repeated runs of unchanged code do not bloat the history. The parallel label + list records what produced each version (a prompt, or "Manual edit"). """ history = list(app.state.code_history or []) + labels = list(app.state.code_history_labels or []) pos = app.state.code_history_pos # If we branched off after an undo, discard the now-stale redo tail. if 0 <= pos < len(history) - 1: history = history[: pos + 1] + labels = labels[: pos + 1] if history and history[-1] == code_string: return # nothing changed history.append(code_string) + labels.append(label) app.state.code_history = history + app.state.code_history_labels = labels app.state.code_history_pos = len(history) - 1 diff --git a/src/vtk_prompt/state/initializer.py b/src/vtk_prompt/state/initializer.py index fff8128..7c41088 100644 --- a/src/vtk_prompt/state/initializer.py +++ b/src/vtk_prompt/state/initializer.py @@ -32,6 +32,7 @@ def initialize_state(app: Any) -> None: # Version history for the editable code panel (undo/redo across generations, # runs, and manual edits). code_history_pos indexes the active snapshot. app.state.code_history = [] + app.state.code_history_labels = [] # origin of each version app.state.code_history_pos = -1 app.state.is_loading = False app.state.mcp_url = "" diff --git a/src/vtk_prompt/vtk_prompt_ui.py b/src/vtk_prompt/vtk_prompt_ui.py index ebd9451..eb36342 100644 --- a/src/vtk_prompt/vtk_prompt_ui.py +++ b/src/vtk_prompt/vtk_prompt_ui.py @@ -350,7 +350,7 @@ async def _debounced_code_snapshot(self) -> None: active = history[pos] if 0 <= pos < len(history) else None if self.state.generated_code and self.state.generated_code != active: with self.state: - generation.push_code_snapshot(self, self.state.generated_code) + generation.push_code_snapshot(self, self.state.generated_code, label="Manual edit") def _build_ui(self) -> None: """Build a simplified Vuetify UI.""" From ac8ce2a7add0732175981a53621060ef60eb1b14 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Fri, 26 Jun 2026 14:48:05 -0400 Subject: [PATCH 16/24] Add sessions model: multiple conversations with non-destructive New Introduce a sessions layer so the app holds several conversations the user can switch between, instead of one live thread that New wiped. A session bundles a conversation's full state: the LLM message context, the unified code-version timeline (history + labels + position), the per-turn checkpoints, and metadata (title, timestamps, pinned). Exactly one session is active; switching captures the live state into the current session and loads the target, and New archives the current session and starts a fresh one so the rest of the list is preserved. - New controllers/sessions.py: store, capture/restore, new_session, switch_session, touch_current_session, toggle_pin_session, plus ensure_session/refresh_sessions_list. Title derives from the first user prompt; the drawer list sorts pinned-first then most-recent. - initializer.py seeds current_session_id and sessions_list. - vtk_prompt_ui.py creates the initial session at startup, repoints the "+" button to sessions.new_session (archive instead of wipe), and registers switch_session and toggle_pin_session. - generation.py calls touch_current_session after each turn (main and mcp-retry paths) so titles and timestamps update. - conversation_history.py drawer now lists conversations (pin toggle, clickable title, active highlight) rather than the turns of one thread. In-memory only; disk persistence and rename/delete follow. --- src/vtk_prompt/controllers/generation.py | 8 + src/vtk_prompt/controllers/sessions.py | 211 ++++++++++++++++++ src/vtk_prompt/state/initializer.py | 4 + .../ui/layout/conversation_history.py | 150 +++---------- src/vtk_prompt/vtk_prompt_ui.py | 20 +- 5 files changed, 273 insertions(+), 120 deletions(-) create mode 100644 src/vtk_prompt/controllers/sessions.py diff --git a/src/vtk_prompt/controllers/generation.py b/src/vtk_prompt/controllers/generation.py index 26e58b1..9b5ee65 100644 --- a/src/vtk_prompt/controllers/generation.py +++ b/src/vtk_prompt/controllers/generation.py @@ -135,6 +135,10 @@ async def generate_and_execute_code(app: Any) -> None: build_conversation_navigation(app) record_turn_checkpoint(app) + from .sessions import touch_current_session + + touch_current_session(app) + app._conversation_loading = False success, exec_error = execute_with_renderer(app, app.state.generated_code) @@ -167,6 +171,10 @@ async def generate_and_execute_code(app: Any) -> None: app, app.state.generated_code, label=app.state.query_text or "Generated" ) record_turn_checkpoint(app) + + from .sessions import touch_current_session + + touch_current_session(app) execute_with_renderer(app, app.state.generated_code) except ValueError as e: if "max_tokens" in str(e): diff --git a/src/vtk_prompt/controllers/sessions.py b/src/vtk_prompt/controllers/sessions.py new file mode 100644 index 0000000..5ffe9c3 --- /dev/null +++ b/src/vtk_prompt/controllers/sessions.py @@ -0,0 +1,211 @@ +"""Session management: multiple conversations the user can switch between. + +A session bundles one conversation's full state: the LLM message context, the +single code-version timeline (history + labels + position), the per-turn +checkpoints, plus metadata (title, timestamps, pinned). Exactly one session is +active at a time. Switching captures the live app state into the current session +and loads the target; "New" archives the current session and starts a fresh one, +so the rest of the list is preserved. +""" + +import time +import uuid +from typing import Any + + +def _sessions(app: Any) -> dict: + """Backing store: id -> session dict (a plain attribute, not trame state).""" + if not hasattr(app, "_session_store"): + app._session_store = {} + return app._session_store + + +def _new_session() -> dict: + now = time.time() + return { + "id": uuid.uuid4().hex, + "title": "New conversation", + "created": now, + "updated": now, + "pinned": False, + "messages": [], + "code_history": [], + "code_history_labels": [], + "code_history_pos": -1, + "checkpoints": [], + } + + +def ensure_session(app: Any) -> dict: + """Guarantee a current session exists; create the first one if needed.""" + sessions = _sessions(app) + cur = getattr(app.state, "current_session_id", "") or "" + if cur and cur in sessions: + return sessions[cur] + sess = _new_session() + sessions[sess["id"]] = sess + app.state.current_session_id = sess["id"] + return sess + + +def current_session(app: Any) -> dict: + """Return the active session object (creating one if needed).""" + return ensure_session(app) + + +def _truncate(text: str, limit: int = 60) -> str: + text = (text or "").strip() + return text[:limit] + ("..." if len(text) > limit else "") + + +def _maybe_title(app: Any, sess: dict) -> None: + """Set a session's title from its first user prompt (once it has one).""" + if sess["title"] not in ("", "New conversation"): + return + nav = app.state.conversation_navigation or [] + if not nav: + return + from .conversation import EXTRA_INSTRUCTIONS_TAG + + content = (nav[0].get("user", {}).get("content", "") or "").strip() + if EXTRA_INSTRUCTIONS_TAG in content: + content = content.split(EXTRA_INSTRUCTIONS_TAG, 1)[-1].strip() + content = content.replace("Request:", "").strip() + if content: + sess["title"] = _truncate(content) + + +def capture_current_session(app: Any) -> None: + """Snapshot the live app state into the current session object.""" + sess = current_session(app) + client = getattr(app, "prompt_client", None) + messages = list(getattr(client, "conversation", None) or app.state.conversation or []) + sess["messages"] = messages + sess["code_history"] = list(app.state.code_history or []) + sess["code_history_labels"] = list(app.state.code_history_labels or []) + sess["code_history_pos"] = app.state.code_history_pos + sess["checkpoints"] = list(getattr(app, "_conversation_checkpoints", None) or []) + sess["updated"] = time.time() + _maybe_title(app, sess) + + +def refresh_sessions_list(app: Any) -> None: + """Rebuild the drawer-visible list (pinned first, then most recently updated).""" + ordered = sorted( + _sessions(app).values(), + key=lambda s: (not s["pinned"], -s["updated"]), + ) + cur = getattr(app.state, "current_session_id", "") or "" + app.state.sessions_list = [ + { + "id": s["id"], + "title": s["title"] or "New conversation", + "pinned": s["pinned"], + "active": s["id"] == cur, + } + for s in ordered + ] + + +def _reset_live(app: Any) -> None: + """Clear all live conversation/code state (the fresh-conversation hinge).""" + client = getattr(app, "prompt_client", None) + if client: + client.conversation = [] + client.conversation_file = None + app._conversation_checkpoints = [] + app.state.conversation = [] + app.state.conversation_navigation = [] + app.state.conversation_index = 0 + app.state.conversation_file = None + app.state.query_text = "" + app.state.generated_code = "" + app.state.generated_explanation = "" + app.state.code_history = [] + app.state.code_history_labels = [] + app.state.code_history_pos = -1 + + +def load_session(app: Any, session_id: str) -> None: + """Restore a session's saved state into the live app and render it.""" + sessions = _sessions(app) + if session_id not in sessions: + return + sess = sessions[session_id] + app.state.current_session_id = session_id + + client = getattr(app, "prompt_client", None) + if client: + client.conversation = list(sess["messages"]) + client.conversation_file = None + app.state.conversation = list(sess["messages"]) + app.state.conversation_file = None + app.state.code_history = list(sess["code_history"]) + app.state.code_history_labels = list(sess["code_history_labels"]) + app.state.code_history_pos = sess["code_history_pos"] + app._conversation_checkpoints = list(sess["checkpoints"]) + + from .conversation import ( + _parse_assistant_content, + _update_navigation_state, + build_conversation_navigation, + ) + + build_conversation_navigation(app) + _update_navigation_state(app) + + history = app.state.code_history or [] + pos = app.state.code_history_pos + if history and 0 <= pos < len(history): + app.state.generated_code = history[pos] + else: + app.state.generated_code = "" + + nav = app.state.conversation_navigation or [] + if nav: + explanation, _ = _parse_assistant_content( + nav[-1].get("assistant", {}).get("content", "") + ) + app.state.generated_explanation = explanation or "" + else: + app.state.generated_explanation = "" + app.state.query_text = "" + + if app.state.generated_code: + app._execute_with_renderer(app.state.generated_code) + + +def switch_session(app: Any, session_id: str) -> None: + """Capture the current session, then load the requested one.""" + if session_id == (getattr(app.state, "current_session_id", "") or ""): + return + capture_current_session(app) + load_session(app, session_id) + refresh_sessions_list(app) + + +def new_session(app: Any) -> None: + """Archive the current session and start a fresh empty one (keep the rest).""" + capture_current_session(app) + sess = _new_session() + _sessions(app)[sess["id"]] = sess + app.state.current_session_id = sess["id"] + _reset_live(app) + from .conversation import _update_navigation_state + + _update_navigation_state(app) + refresh_sessions_list(app) + + +def touch_current_session(app: Any) -> None: + """After a generation: capture state, set the title, and refresh the list.""" + capture_current_session(app) + refresh_sessions_list(app) + + +def toggle_pin_session(app: Any, session_id: str) -> None: + """Pin or unpin a session so it sorts to the top of the Recents list.""" + sessions = _sessions(app) + if session_id in sessions: + sessions[session_id]["pinned"] = not sessions[session_id]["pinned"] + refresh_sessions_list(app) diff --git a/src/vtk_prompt/state/initializer.py b/src/vtk_prompt/state/initializer.py index 7c41088..a8abb96 100644 --- a/src/vtk_prompt/state/initializer.py +++ b/src/vtk_prompt/state/initializer.py @@ -59,6 +59,10 @@ def initialize_state(app: Any) -> None: app.state.favorited_conversations = [] # indices into conversation_navigation app.state.history_filter_mode = "all" # "all" or "favorites" + # Sessions: multiple conversations the user can switch between. + app.state.current_session_id = "" # active session id + app.state.sessions_list = [] # drawer-visible [{id,title,pinned,active}] + # Prompt file state variables app.state.prompt_object = None app.state.prompt_file = None diff --git a/src/vtk_prompt/ui/layout/conversation_history.py b/src/vtk_prompt/ui/layout/conversation_history.py index 349f351..8d028ff 100644 --- a/src/vtk_prompt/ui/layout/conversation_history.py +++ b/src/vtk_prompt/ui/layout/conversation_history.py @@ -1,4 +1,4 @@ -"""Conversation history panel: browsable, sortable, favoritable past prompts.""" +"""Recents drawer: the list of conversations (sessions) the user can switch between.""" from typing import Any @@ -7,12 +7,12 @@ def build_conversation_history(app: Any) -> None: - """Build the conversation history component with clickable conversation cards.""" + """Build the Recents drawer: one row per conversation, pinned/newest first.""" with vuetify.VCard(classes="w-100", flat=True): with vuetify.VCardTitle("Recents", classes="d-flex align-center"): vuetify.VSpacer() - # New conversation button + # New conversation: archive the current one and start a fresh thread. with vuetify.VTooltip(text="New conversation", location="bottom"): with vuetify.Template(v_slot_activator="{ props }"): vuetify.VBtn( @@ -21,127 +21,43 @@ def build_conversation_history(app: Any) -> None: variant="text", density="compact", color="primary", - # Already on a fresh, ungenerated new entry -> nothing to do. - disabled=( - "conversation_index" - + " >= conversation_navigation.length", - True, - ), - v_bind="props", - ) - - # Sort toggle button - with vuetify.VTooltip(text="Toggle sort order", location="bottom"): - with vuetify.Template(v_slot_activator="{ props }"): - vuetify.VBtn( - icon=( - "history_sort_order === 'newest'" - + " ? 'mdi-sort-descending' : 'mdi-sort-ascending'", - "mdi-sort-descending", - ), - click=( - "history_sort_order = " - + "(history_sort_order === 'newest')" - + " ? 'oldest' : 'newest'" - ), - variant="text", - density="compact", - color="primary", - disabled=("conversation_navigation.length === 0", False), - v_bind="props", - ) - - # Filter toggle button - with vuetify.VTooltip(text="Toggle favorites filter", location="bottom"): - with vuetify.Template(v_slot_activator="{ props }"): - vuetify.VBtn( - icon=( - "history_filter_mode === 'favorites'" - + " ? 'mdi-heart' : 'mdi-heart-off'", - "mdi-format-list-bulleted", - ), - click=( - "history_filter_mode = (history_filter_mode === 'all')" - + " ? 'favorites' : 'all'" - ), - variant="text", - density="compact", - color=( - "history_filter_mode === 'favorites' ? 'red' : 'primary'", - "primary", - ), - disabled=( - "conversation_navigation.length === 0" - + " || favorited_conversations.length === 0", - False, - ), + # Already on a fresh, empty conversation -> nothing to add. + disabled=("conversation_navigation.length === 0", True), v_bind="props", ) with vuetify.VCardText(style="overflow-y: auto;"): - # Show message when no history vuetify.VAlert( - text="No conversation history yet." + " Start by generating some VTK code!", + text="No conversations yet. Start by generating some VTK code!", type="info", variant="tonal", - v_show="conversation_navigation.length === 0", + v_show="sessions_list.length === 0", ) - # Conversation history list - with vuetify.VCard( - v_for=( - "item in (history_sort_order === 'newest'" - + " ? conversation_navigation.slice().reverse()" - + ".map((pair, idx) => ({pair, originalIndex: " - + "conversation_navigation.length - 1 - idx}))" - + " : conversation_navigation.map((pair, idx) => " - + "({pair, originalIndex: idx})))" - + ".filter(item => history_filter_mode === 'all' || " - + "favorited_conversations.includes(item.originalIndex))" - ), - key="item.originalIndex", - density="compact", - v_show="conversation_navigation.length > 0", - color=( - "conversation_index === item.originalIndex" + " ? 'primary' : 'secondary'", - "secondary", - ), - variant=( - "conversation_index === item.originalIndex" + " ? 'outlined' : 'default'", - "default", - ), - ): - # Track favorited prompts - with vuetify.VCardTitle(classes="text-end"): - vuetify.VIcon( - click=( - app.ctrl.toggle_favorite_conversation, - "[item.originalIndex]", - ), - icon=( - "favorited_conversations.includes(item.originalIndex) ? " - + "'mdi-heart' : 'mdi-heart-outline'", - "mdi-heart-outline", - ), - size="small", - color=( - "favorited_conversations.includes(item.originalIndex) ? " - + "'red' : 'grey'", - "grey", - ), - ) - with vuetify.VCardText( - click=( - app.ctrl.navigate_to_conversation, - "[item.originalIndex]", - ), - rounded=True, - classes="mb-2", + with vuetify.VList(density="compact", nav=True): + with vuetify.VListItem( + v_for="s in sessions_list", + key="s.id", + active=("s.active", False), + color="primary", ): - # User query preview - html.Span( - "{{ (item.pair.user.content.includes('')" - + " ? item.pair.user.content.split('')[1]" - + " : item.pair.user.content)" - + ".trim().replace(/^Request:\\s*/i, '') }}" - ) + with html.Div(classes="d-flex align-center w-100"): + # Pin / unpin this conversation (its own click; no switch). + vuetify.VBtn( + icon=( + "s.pinned ? 'mdi-pin' : 'mdi-pin-outline'", + "mdi-pin-outline", + ), + click=(app.ctrl.toggle_pin_session, "[s.id]"), + size="x-small", + variant="text", + color=("s.pinned ? 'primary' : 'grey'", "grey"), + classes="mr-1", + ) + # Title: click to switch to this conversation. + html.Span( + "{{ s.title }}", + click=(app.ctrl.switch_session, "[s.id]"), + classes="flex-grow-1 text-truncate", + style="cursor: pointer;", + ) diff --git a/src/vtk_prompt/vtk_prompt_ui.py b/src/vtk_prompt/vtk_prompt_ui.py index eb36342..65e4421 100644 --- a/src/vtk_prompt/vtk_prompt_ui.py +++ b/src/vtk_prompt/vtk_prompt_ui.py @@ -28,7 +28,7 @@ from vtkmodules.vtkInteractionStyle import vtkInteractorStyleSwitch # noqa from . import get_logger -from .controllers import configuration, conversation, generation +from .controllers import configuration, conversation, generation, sessions from .rendering import ( add_default_scene, setup_vtk_renderer, @@ -122,6 +122,10 @@ def __init__(self, server: Any | None = None, custom_prompt_file: str | None = N # Initialize application state self._initialize_state() + # Create the initial (empty) session so the Recents drawer has an entry. + sessions.ensure_session(self) + sessions.refresh_sessions_list(self) + # Load custom prompt file after VTK initialization if custom_prompt_file: self._load_custom_prompt_file() @@ -303,8 +307,18 @@ def toggle_favorite_conversation(self, conversation_index: int) -> None: @controller.set("start_new_conversation") def start_new_conversation(self) -> None: - """Start a fresh prompt entry from the history drawer.""" - conversation.start_new_conversation(self) + """Archive the current conversation and start a fresh one.""" + sessions.new_session(self) + + @controller.set("switch_session") + def switch_session(self, session_id: str) -> None: + """Load a different conversation from the Recents drawer.""" + sessions.switch_session(self, session_id) + + @controller.set("toggle_pin_session") + def toggle_pin_session(self, session_id: str) -> None: + """Pin or unpin a conversation in the Recents drawer.""" + sessions.toggle_pin_session(self, session_id) @trigger("save_conversation") def save_conversation(self) -> str: From dbab5f30bc57881dfd6993cf87996e5608405e0b Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Fri, 26 Jun 2026 14:58:45 -0400 Subject: [PATCH 17/24] Replace "Generate Code" button with an inline send arrow Move the send action into the prompt field as a small circular arrow (Claude-style) instead of a full-width button below it. The arrow lights up only when there is a prompt to send and is disabled while loading or when a cloud model lacks an API key. The textarea now fills the prompt zone since the button no longer sits beneath it. --- src/vtk_prompt/ui/layout/content.py | 36 ++++++++++++++++++----------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/src/vtk_prompt/ui/layout/content.py b/src/vtk_prompt/ui/layout/content.py index 5614961..4bb473b 100644 --- a/src/vtk_prompt/ui/layout/content.py +++ b/src/vtk_prompt/ui/layout/content.py @@ -142,9 +142,11 @@ def build_content(layout: Any, app: Any) -> None: prepend_icon="mdi-alert", ) - with html.Div(classes="d-flex", style="height: calc(100% - 75px);"): - # Query input - vuetify.VTextarea( + with html.Div(classes="d-flex", style="height: 100%;"): + # Query input with an inline send arrow (Claude-style): + # the arrow lives in the field and lights up only when + # there is a prompt to send. + with vuetify.VTextarea( label="Describe VTK visualization", v_model=("query_text", ""), rows=4, @@ -153,17 +155,23 @@ def build_content(layout: Any, app: Any) -> None: persistent_placeholder=True, hide_details=True, no_resize=True, - ) - # Generate button - vuetify.VBtn( - "Generate Code", - color="primary", - block=True, - loading=("is_loading", False), - click=app.ctrl.generate_code, - classes="my-2", - v_show="!use_cloud_models || api_token.trim()", - ) + ): + with vuetify.Template(v_slot_append_inner=True): + vuetify.VBtn( + icon="mdi-arrow-up", + click=app.ctrl.generate_code, + loading=("is_loading", False), + disabled=( + "is_loading || !query_text.trim()" + + " || (use_cloud_models && !api_token.trim())", + True, + ), + color="primary", + size="small", + variant="flat", + rounded="circle", + classes="align-self-end", + ) vuetify.VBtn( "Set API Key", color="error", From 5b3aeb5ecfb51ee15c4e5a1c308a183b416a203a Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Fri, 26 Jun 2026 15:30:05 -0400 Subject: [PATCH 18/24] Persist sessions to disk and reopen the most recent on startup Each conversation with content is written as a JSON file under the per-user config dir (~/.config/vtk-prompt/sessions). On startup the app loads all saved sessions, repopulates the Recents drawer, and reopens the most recently updated conversation (state only, no render, since the window is not ready yet). Empty conversations are not written. --- src/vtk_prompt/controllers/sessions.py | 92 +++++++++++++++++++++++++- src/vtk_prompt/vtk_prompt_ui.py | 6 +- 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/src/vtk_prompt/controllers/sessions.py b/src/vtk_prompt/controllers/sessions.py index 5ffe9c3..1a9d3b0 100644 --- a/src/vtk_prompt/controllers/sessions.py +++ b/src/vtk_prompt/controllers/sessions.py @@ -8,10 +8,23 @@ so the rest of the list is preserved. """ +import json +import logging import time import uuid +from pathlib import Path from typing import Any +from ..utils.env_config import _config_home + +logger = logging.getLogger(__name__) + +# Keys written to / read from each session's JSON file. +_PERSIST_KEYS = ( + "id", "title", "created", "updated", "pinned", "messages", + "code_history", "code_history_labels", "code_history_pos", "checkpoints", +) + def _sessions(app: Any) -> dict: """Backing store: id -> session dict (a plain attribute, not trame state).""" @@ -87,6 +100,7 @@ def capture_current_session(app: Any) -> None: sess["checkpoints"] = list(getattr(app, "_conversation_checkpoints", None) or []) sess["updated"] = time.time() _maybe_title(app, sess) + _persist_session(sess) def refresh_sessions_list(app: Any) -> None: @@ -126,8 +140,12 @@ def _reset_live(app: Any) -> None: app.state.code_history_pos = -1 -def load_session(app: Any, session_id: str) -> None: - """Restore a session's saved state into the live app and render it.""" +def load_session(app: Any, session_id: str, execute: bool = True) -> None: + """Restore a session's saved state into the live app and render it. + + ``execute=False`` restores state without running the code (used at startup, + before the render window and client are ready). + """ sessions = _sessions(app) if session_id not in sessions: return @@ -171,7 +189,7 @@ def load_session(app: Any, session_id: str) -> None: app.state.generated_explanation = "" app.state.query_text = "" - if app.state.generated_code: + if execute and app.state.generated_code: app._execute_with_renderer(app.state.generated_code) @@ -208,4 +226,72 @@ def toggle_pin_session(app: Any, session_id: str) -> None: sessions = _sessions(app) if session_id in sessions: sessions[session_id]["pinned"] = not sessions[session_id]["pinned"] + _persist_session(sessions[session_id]) refresh_sessions_list(app) + + +def _sessions_dir() -> Path: + """Directory holding one JSON file per persisted session.""" + directory = _config_home() / "sessions" + directory.mkdir(parents=True, exist_ok=True) + return directory + + +def _session_path(session_id: str) -> Path: + return _sessions_dir() / f"{session_id}.json" + + +def _persist_session(sess: dict) -> None: + """Write a session to disk (skip empty ones so we do not litter files).""" + if not sess.get("messages"): + return + try: + data = {key: sess.get(key) for key in _PERSIST_KEYS} + _session_path(sess["id"]).write_text(json.dumps(data), encoding="utf-8") + except OSError as exc: + logger.warning("Could not persist session %s: %s", sess.get("id"), exc) + + +def _delete_session_file(session_id: str) -> None: + try: + path = _session_path(session_id) + if path.exists(): + path.unlink() + except OSError as exc: + logger.warning("Could not delete session file %s: %s", session_id, exc) + + +def load_persisted_sessions(app: Any) -> None: + """Load saved sessions from disk and open the most recently updated one. + + Called once at startup. Restores state only (no render); if nothing is + saved, falls back to creating a fresh empty session. + """ + store = _sessions(app) + try: + files = sorted(_sessions_dir().glob("*.json")) + except OSError: + files = [] + for path in files: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + logger.warning("Skipping unreadable session file %s: %s", path.name, exc) + continue + session_id = data.get("id") + if not session_id: + continue + base = _new_session() + for key in _PERSIST_KEYS: + if key in data and data[key] is not None: + base[key] = data[key] + base["id"] = session_id + store[session_id] = base + + if store: + most_recent = max(store.values(), key=lambda s: s.get("updated", 0)) + app.state.current_session_id = most_recent["id"] + load_session(app, most_recent["id"], execute=False) + else: + ensure_session(app) + refresh_sessions_list(app) diff --git a/src/vtk_prompt/vtk_prompt_ui.py b/src/vtk_prompt/vtk_prompt_ui.py index 65e4421..2f6631f 100644 --- a/src/vtk_prompt/vtk_prompt_ui.py +++ b/src/vtk_prompt/vtk_prompt_ui.py @@ -122,9 +122,9 @@ def __init__(self, server: Any | None = None, custom_prompt_file: str | None = N # Initialize application state self._initialize_state() - # Create the initial (empty) session so the Recents drawer has an entry. - sessions.ensure_session(self) - sessions.refresh_sessions_list(self) + # Load any persisted conversations (or create the first one) so the + # Recents drawer is populated and the most recent conversation reopens. + sessions.load_persisted_sessions(self) # Load custom prompt file after VTK initialization if custom_prompt_file: From a3785b57b5da6f51d8d528c0bd4bb3bc83861300 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Fri, 26 Jun 2026 15:51:11 -0400 Subject: [PATCH 19/24] Recents: show a conversation's turns, restore sort/favorites, stop reorder-on-select Address four issues with the sessions drawer: - Show the active conversation's follow-up prompts: each turn renders as a clickable sub-item under the active conversation, jumping to that point in the thread (was invisible after the drawer switched to listing conversations rather than turns). - Selecting a conversation no longer reorders the list: capture no longer bumps "updated"; only an actual new turn does, so recency reflects use rather than mere viewing. - Restore the sort-order toggle (newest/oldest) and favorites filter, now operating on conversations; refresh_sessions_list honors both, wired via a @change handler. - Fix the send arrow being clipped: the prompt wrapper forced height 100%, overflowing the short card past the chips row and cutting off the arrow. Let the textarea size naturally and center the arrow. --- src/vtk_prompt/controllers/sessions.py | 13 ++- src/vtk_prompt/ui/layout/content.py | 3 +- .../ui/layout/conversation_history.py | 86 +++++++++++++++++-- src/vtk_prompt/vtk_prompt_ui.py | 5 ++ 4 files changed, 95 insertions(+), 12 deletions(-) diff --git a/src/vtk_prompt/controllers/sessions.py b/src/vtk_prompt/controllers/sessions.py index 1a9d3b0..ec39431 100644 --- a/src/vtk_prompt/controllers/sessions.py +++ b/src/vtk_prompt/controllers/sessions.py @@ -98,17 +98,21 @@ def capture_current_session(app: Any) -> None: sess["code_history_labels"] = list(app.state.code_history_labels or []) sess["code_history_pos"] = app.state.code_history_pos sess["checkpoints"] = list(getattr(app, "_conversation_checkpoints", None) or []) - sess["updated"] = time.time() _maybe_title(app, sess) _persist_session(sess) def refresh_sessions_list(app: Any) -> None: - """Rebuild the drawer-visible list (pinned first, then most recently updated).""" + """Rebuild the drawer-visible list, honoring the sort order and favorites filter.""" + sort_order = getattr(app.state, "history_sort_order", "newest") or "newest" + filter_mode = getattr(app.state, "history_filter_mode", "all") or "all" ordered = sorted( _sessions(app).values(), - key=lambda s: (not s["pinned"], -s["updated"]), + key=lambda s: s.get("updated", 0), + reverse=(sort_order != "oldest"), ) + if filter_mode == "favorites": + ordered = [s for s in ordered if s.get("pinned")] cur = getattr(app.state, "current_session_id", "") or "" app.state.sessions_list = [ { @@ -216,7 +220,8 @@ def new_session(app: Any) -> None: def touch_current_session(app: Any) -> None: - """After a generation: capture state, set the title, and refresh the list.""" + """After a generation: bump recency, capture state, and refresh the list.""" + current_session(app)["updated"] = time.time() capture_current_session(app) refresh_sessions_list(app) diff --git a/src/vtk_prompt/ui/layout/content.py b/src/vtk_prompt/ui/layout/content.py index 4bb473b..4053f4d 100644 --- a/src/vtk_prompt/ui/layout/content.py +++ b/src/vtk_prompt/ui/layout/content.py @@ -142,7 +142,7 @@ def build_content(layout: Any, app: Any) -> None: prepend_icon="mdi-alert", ) - with html.Div(classes="d-flex", style="height: 100%;"): + with html.Div(classes="d-flex"): # Query input with an inline send arrow (Claude-style): # the arrow lives in the field and lights up only when # there is a prompt to send. @@ -170,7 +170,6 @@ def build_content(layout: Any, app: Any) -> None: size="small", variant="flat", rounded="circle", - classes="align-self-end", ) vuetify.VBtn( "Set API Key", diff --git a/src/vtk_prompt/ui/layout/conversation_history.py b/src/vtk_prompt/ui/layout/conversation_history.py index 8d028ff..443170d 100644 --- a/src/vtk_prompt/ui/layout/conversation_history.py +++ b/src/vtk_prompt/ui/layout/conversation_history.py @@ -1,13 +1,20 @@ -"""Recents drawer: the list of conversations (sessions) the user can switch between.""" +"""Recents drawer: conversations to switch between, with the active one's turns.""" from typing import Any from trame.widgets import html from trame.widgets import vuetify3 as vuetify +# Strip the extra-instructions prefix and a leading "Request:" for a clean preview. +_PROMPT_PREVIEW = ( + "(pair.user.content.includes('')" + " ? pair.user.content.split('')[1]" + " : pair.user.content).trim().replace(/^Request:\\s*/i, '')" +) + def build_conversation_history(app: Any) -> None: - """Build the Recents drawer: one row per conversation, pinned/newest first.""" + """Build the Recents drawer: conversations, plus the active one's prompts.""" with vuetify.VCard(classes="w-100", flat=True): with vuetify.VCardTitle("Recents", classes="d-flex align-center"): vuetify.VSpacer() @@ -26,6 +33,48 @@ def build_conversation_history(app: Any) -> None: v_bind="props", ) + # Sort order (newest / oldest). + with vuetify.VTooltip(text="Toggle sort order", location="bottom"): + with vuetify.Template(v_slot_activator="{ props }"): + vuetify.VBtn( + icon=( + "history_sort_order === 'newest'" + + " ? 'mdi-sort-descending' : 'mdi-sort-ascending'", + "mdi-sort-descending", + ), + click=( + "history_sort_order = " + + "(history_sort_order === 'newest') ? 'oldest' : 'newest'" + ), + variant="text", + density="compact", + color="primary", + disabled=("sessions_list.length === 0", False), + v_bind="props", + ) + + # Favorites filter (show only hearted conversations). + with vuetify.VTooltip(text="Toggle favorites filter", location="bottom"): + with vuetify.Template(v_slot_activator="{ props }"): + vuetify.VBtn( + icon=( + "history_filter_mode === 'favorites'" + + " ? 'mdi-heart' : 'mdi-heart-off'", + "mdi-format-list-bulleted", + ), + click=( + "history_filter_mode = " + + "(history_filter_mode === 'all') ? 'favorites' : 'all'" + ), + variant="text", + density="compact", + color=( + "history_filter_mode === 'favorites' ? 'red' : 'primary'", + "primary", + ), + v_bind="props", + ) + with vuetify.VCardText(style="overflow-y: auto;"): vuetify.VAlert( text="No conversations yet. Start by generating some VTK code!", @@ -42,16 +91,16 @@ def build_conversation_history(app: Any) -> None: color="primary", ): with html.Div(classes="d-flex align-center w-100"): - # Pin / unpin this conversation (its own click; no switch). + # Favorite (heart) toggle; its own click, does not switch. vuetify.VBtn( icon=( - "s.pinned ? 'mdi-pin' : 'mdi-pin-outline'", - "mdi-pin-outline", + "s.pinned ? 'mdi-heart' : 'mdi-heart-outline'", + "mdi-heart-outline", ), click=(app.ctrl.toggle_pin_session, "[s.id]"), size="x-small", variant="text", - color=("s.pinned ? 'primary' : 'grey'", "grey"), + color=("s.pinned ? 'red' : 'grey'", "grey"), classes="mr-1", ) # Title: click to switch to this conversation. @@ -61,3 +110,28 @@ def build_conversation_history(app: Any) -> None: classes="flex-grow-1 text-truncate", style="cursor: pointer;", ) + + # The active conversation's follow-up prompts (its turns), + # clickable to jump to that point in the thread. + with html.Div( + v_if="s.active && conversation_navigation.length > 0", + classes="ml-6 mt-1 mb-1", + ): + with html.Div( + v_for="(pair, idx) in conversation_navigation", + key="'turn-' + idx", + click=(app.ctrl.navigate_to_conversation, "[idx]"), + classes=( + "(conversation_index === idx" + + " ? 'text-primary font-weight-medium' : 'text-medium-emphasis')" + + " + ' text-caption text-truncate py-1 px-2'", + "text-caption", + ), + style=( + "'cursor: pointer; border-left: 2px solid '" + + " + (conversation_index === idx" + + " ? 'rgb(var(--v-theme-primary))' : 'transparent')", + "cursor: pointer;", + ), + ): + html.Span("{{ idx + 1 }}. {{ " + _PROMPT_PREVIEW + " }}") diff --git a/src/vtk_prompt/vtk_prompt_ui.py b/src/vtk_prompt/vtk_prompt_ui.py index 2f6631f..58ce8fc 100644 --- a/src/vtk_prompt/vtk_prompt_ui.py +++ b/src/vtk_prompt/vtk_prompt_ui.py @@ -335,6 +335,11 @@ def _on_provider_change(self, provider, **kwargs) -> None: """Handle provider selection change.""" configuration.on_provider_change(self, provider, **kwargs) + @change("history_sort_order", "history_filter_mode") + def _on_sessions_view_change(self, **_: Any) -> None: + """Re-render the Recents list when its sort order or filter changes.""" + sessions.refresh_sessions_list(self) + @change("generated_code") def _on_generated_code_change(self, **_: Any) -> None: """Debounce-snapshot manual edits so undo/redo can step through them.""" From 9454d11a3321368f95b5660fd778a5fdf9f95d45 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Fri, 26 Jun 2026 16:10:47 -0400 Subject: [PATCH 20/24] Sessions: pin-to-top, rename, and delete via a per-row menu Replace the favorites flag+filter with a pin that floats conversations to the top (pinned first, then by sort order), which keeps important conversations reachable without a separate filter mode. Each conversation row gets a "..." menu with Pin/Unpin, Rename, and Delete; the favorites filter button is removed and the sort toggle stays. - sessions.py: refresh sorts pinned-first; add rename_session (ignores an empty title, persists) and delete_session (removes the file and, if the deleted one was active, opens the next most recent or a fresh session). - Rename and Delete each use a small dialog; controllers confirm_rename_session and confirm_delete_session apply them from dialog state. - initializer.py: dialog state. vtk_prompt_ui.py: the view-change handler now watches only the sort order. --- src/vtk_prompt/controllers/sessions.py | 54 +++++- src/vtk_prompt/state/initializer.py | 6 + .../ui/layout/conversation_history.py | 180 +++++++++++------- src/vtk_prompt/vtk_prompt_ui.py | 18 +- 4 files changed, 174 insertions(+), 84 deletions(-) diff --git a/src/vtk_prompt/controllers/sessions.py b/src/vtk_prompt/controllers/sessions.py index ec39431..b5d0ac2 100644 --- a/src/vtk_prompt/controllers/sessions.py +++ b/src/vtk_prompt/controllers/sessions.py @@ -103,16 +103,15 @@ def capture_current_session(app: Any) -> None: def refresh_sessions_list(app: Any) -> None: - """Rebuild the drawer-visible list, honoring the sort order and favorites filter.""" + """Rebuild the drawer-visible list: pinned first, then by the sort order.""" sort_order = getattr(app.state, "history_sort_order", "newest") or "newest" - filter_mode = getattr(app.state, "history_filter_mode", "all") or "all" - ordered = sorted( - _sessions(app).values(), - key=lambda s: s.get("updated", 0), - reverse=(sort_order != "oldest"), - ) - if filter_mode == "favorites": - ordered = [s for s in ordered if s.get("pinned")] + recent_first = sort_order != "oldest" + + def _key(s: dict): + updated = s.get("updated", 0) + return (0 if s.get("pinned") else 1, -updated if recent_first else updated) + + ordered = sorted(_sessions(app).values(), key=_key) cur = getattr(app.state, "current_session_id", "") or "" app.state.sessions_list = [ { @@ -300,3 +299,40 @@ def load_persisted_sessions(app: Any) -> None: else: ensure_session(app) refresh_sessions_list(app) + + +def rename_session(app: Any, session_id: str, title: str) -> None: + """Rename a conversation; ignore an all-whitespace title.""" + sessions = _sessions(app) + if session_id not in sessions: + return + new_title = (title or "").strip()[:80] + if not new_title: + return + sessions[session_id]["title"] = new_title + _persist_session(sessions[session_id]) + refresh_sessions_list(app) + + +def delete_session(app: Any, session_id: str) -> None: + """Delete a conversation; if it was active, open the next most recent.""" + sessions = _sessions(app) + if session_id not in sessions: + return + was_current = session_id == (getattr(app.state, "current_session_id", "") or "") + del sessions[session_id] + _delete_session_file(session_id) + + if was_current: + if sessions: + most_recent = max(sessions.values(), key=lambda s: s.get("updated", 0)) + load_session(app, most_recent["id"]) + else: + sess = _new_session() + sessions[sess["id"]] = sess + app.state.current_session_id = sess["id"] + _reset_live(app) + from .conversation import _update_navigation_state + + _update_navigation_state(app) + refresh_sessions_list(app) diff --git a/src/vtk_prompt/state/initializer.py b/src/vtk_prompt/state/initializer.py index a8abb96..0474118 100644 --- a/src/vtk_prompt/state/initializer.py +++ b/src/vtk_prompt/state/initializer.py @@ -62,6 +62,12 @@ def initialize_state(app: Any) -> None: # Sessions: multiple conversations the user can switch between. app.state.current_session_id = "" # active session id app.state.sessions_list = [] # drawer-visible [{id,title,pinned,active}] + app.state.rename_dialog = False + app.state.rename_text = "" + app.state.rename_target_id = "" + app.state.delete_dialog = False + app.state.delete_target_id = "" + app.state.delete_target_title = "" # Prompt file state variables app.state.prompt_object = None diff --git a/src/vtk_prompt/ui/layout/conversation_history.py b/src/vtk_prompt/ui/layout/conversation_history.py index 443170d..8c808b3 100644 --- a/src/vtk_prompt/ui/layout/conversation_history.py +++ b/src/vtk_prompt/ui/layout/conversation_history.py @@ -13,68 +13,110 @@ ) -def build_conversation_history(app: Any) -> None: - """Build the Recents drawer: conversations, plus the active one's prompts.""" - with vuetify.VCard(classes="w-100", flat=True): - with vuetify.VCardTitle("Recents", classes="d-flex align-center"): - vuetify.VSpacer() +def _header(app: Any) -> None: + with vuetify.VCardTitle("Recents", classes="d-flex align-center"): + vuetify.VSpacer() + with vuetify.VTooltip(text="New conversation", location="bottom"): + with vuetify.Template(v_slot_activator="{ props }"): + vuetify.VBtn( + icon="mdi-plus", + click=app.ctrl.start_new_conversation, + variant="text", + density="compact", + color="primary", + disabled=("conversation_navigation.length === 0", True), + v_bind="props", + ) + with vuetify.VTooltip(text="Toggle sort order", location="bottom"): + with vuetify.Template(v_slot_activator="{ props }"): + vuetify.VBtn( + icon=( + "history_sort_order === 'newest'" + + " ? 'mdi-sort-descending' : 'mdi-sort-ascending'", + "mdi-sort-descending", + ), + click=( + "history_sort_order = " + + "(history_sort_order === 'newest') ? 'oldest' : 'newest'" + ), + variant="text", + density="compact", + color="primary", + disabled=("sessions_list.length === 0", False), + v_bind="props", + ) - # New conversation: archive the current one and start a fresh thread. - with vuetify.VTooltip(text="New conversation", location="bottom"): - with vuetify.Template(v_slot_activator="{ props }"): - vuetify.VBtn( - icon="mdi-plus", - click=app.ctrl.start_new_conversation, - variant="text", - density="compact", - color="primary", - # Already on a fresh, empty conversation -> nothing to add. - disabled=("conversation_navigation.length === 0", True), - v_bind="props", - ) - # Sort order (newest / oldest). - with vuetify.VTooltip(text="Toggle sort order", location="bottom"): - with vuetify.Template(v_slot_activator="{ props }"): - vuetify.VBtn( - icon=( - "history_sort_order === 'newest'" - + " ? 'mdi-sort-descending' : 'mdi-sort-ascending'", - "mdi-sort-descending", - ), - click=( - "history_sort_order = " - + "(history_sort_order === 'newest') ? 'oldest' : 'newest'" - ), - variant="text", - density="compact", - color="primary", - disabled=("sessions_list.length === 0", False), - v_bind="props", - ) +def _row_menu(app: Any) -> None: + with vuetify.VMenu(location="bottom end"): + with vuetify.Template(v_slot_activator="{ props }"): + vuetify.VBtn( + icon="mdi-dots-vertical", + size="x-small", + variant="text", + color="grey", + v_bind="props", + ) + with vuetify.VList(density="compact"): + with vuetify.VListItem(click=(app.ctrl.toggle_pin_session, "[s.id]")): + vuetify.VListItemTitle("{{ s.pinned ? 'Unpin' : 'Pin' }}") + with vuetify.VListItem( + click="rename_target_id = s.id; rename_text = s.title; rename_dialog = true" + ): + vuetify.VListItemTitle("Rename") + with vuetify.VListItem( + click=( + "delete_target_id = s.id; delete_target_title = s.title;" + + " delete_dialog = true" + ) + ): + vuetify.VListItemTitle("Delete") - # Favorites filter (show only hearted conversations). - with vuetify.VTooltip(text="Toggle favorites filter", location="bottom"): - with vuetify.Template(v_slot_activator="{ props }"): - vuetify.VBtn( - icon=( - "history_filter_mode === 'favorites'" - + " ? 'mdi-heart' : 'mdi-heart-off'", - "mdi-format-list-bulleted", - ), - click=( - "history_filter_mode = " - + "(history_filter_mode === 'all') ? 'favorites' : 'all'" - ), - variant="text", - density="compact", - color=( - "history_filter_mode === 'favorites' ? 'red' : 'primary'", - "primary", - ), - v_bind="props", - ) +def _dialogs(app: Any) -> None: + # Rename + with vuetify.VDialog(v_model=("rename_dialog", False), max_width="420"): + with vuetify.VCard(): + vuetify.VCardTitle("Rename conversation") + with vuetify.VCardText(): + vuetify.VTextField( + v_model=("rename_text", ""), + label="Title", + autofocus=True, + hide_details=True, + keydown_enter=app.ctrl.confirm_rename_session, + ) + with vuetify.VCardActions(): + vuetify.VSpacer() + vuetify.VBtn("Cancel", click="rename_dialog = false", variant="text") + vuetify.VBtn( + "Save", + click=app.ctrl.confirm_rename_session, + color="primary", + variant="text", + ) + # Delete + with vuetify.VDialog(v_model=("delete_dialog", False), max_width="420"): + with vuetify.VCard(): + vuetify.VCardTitle("Delete conversation") + vuetify.VCardText( + "Delete \u201c{{ delete_target_title }}\u201d? This cannot be undone." + ) + with vuetify.VCardActions(): + vuetify.VSpacer() + vuetify.VBtn("Cancel", click="delete_dialog = false", variant="text") + vuetify.VBtn( + "Delete", + click=app.ctrl.confirm_delete_session, + color="error", + variant="text", + ) + + +def build_conversation_history(app: Any) -> None: + """Build the Recents drawer: conversations, plus the active one's prompts.""" + with vuetify.VCard(classes="w-100", flat=True): + _header(app) with vuetify.VCardText(style="overflow-y: auto;"): vuetify.VAlert( text="No conversations yet. Start by generating some VTK code!", @@ -82,7 +124,6 @@ def build_conversation_history(app: Any) -> None: variant="tonal", v_show="sessions_list.length === 0", ) - with vuetify.VList(density="compact", nav=True): with vuetify.VListItem( v_for="s in sessions_list", @@ -91,31 +132,25 @@ def build_conversation_history(app: Any) -> None: color="primary", ): with html.Div(classes="d-flex align-center w-100"): - # Favorite (heart) toggle; its own click, does not switch. - vuetify.VBtn( - icon=( - "s.pinned ? 'mdi-heart' : 'mdi-heart-outline'", - "mdi-heart-outline", - ), - click=(app.ctrl.toggle_pin_session, "[s.id]"), + vuetify.VIcon( + "mdi-pin", size="x-small", - variant="text", - color=("s.pinned ? 'red' : 'grey'", "grey"), + color="primary", classes="mr-1", + v_show="s.pinned", ) - # Title: click to switch to this conversation. html.Span( "{{ s.title }}", click=(app.ctrl.switch_session, "[s.id]"), classes="flex-grow-1 text-truncate", style="cursor: pointer;", ) + _row_menu(app) - # The active conversation's follow-up prompts (its turns), - # clickable to jump to that point in the thread. + # The active conversation's follow-up prompts (its turns). with html.Div( v_if="s.active && conversation_navigation.length > 0", - classes="ml-6 mt-1 mb-1", + classes="ml-4 mt-1 mb-1", ): with html.Div( v_for="(pair, idx) in conversation_navigation", @@ -135,3 +170,4 @@ def build_conversation_history(app: Any) -> None: ), ): html.Span("{{ idx + 1 }}. {{ " + _PROMPT_PREVIEW + " }}") + _dialogs(app) diff --git a/src/vtk_prompt/vtk_prompt_ui.py b/src/vtk_prompt/vtk_prompt_ui.py index 58ce8fc..a62a9a8 100644 --- a/src/vtk_prompt/vtk_prompt_ui.py +++ b/src/vtk_prompt/vtk_prompt_ui.py @@ -320,6 +320,18 @@ def toggle_pin_session(self, session_id: str) -> None: """Pin or unpin a conversation in the Recents drawer.""" sessions.toggle_pin_session(self, session_id) + @controller.set("confirm_rename_session") + def confirm_rename_session(self) -> None: + """Apply the rename from the rename dialog.""" + sessions.rename_session(self, self.state.rename_target_id, self.state.rename_text) + self.state.rename_dialog = False + + @controller.set("confirm_delete_session") + def confirm_delete_session(self) -> None: + """Apply the delete from the confirm dialog.""" + sessions.delete_session(self, self.state.delete_target_id) + self.state.delete_dialog = False + @trigger("save_conversation") def save_conversation(self) -> str: """Save current conversation history as JSON string.""" @@ -335,9 +347,9 @@ def _on_provider_change(self, provider, **kwargs) -> None: """Handle provider selection change.""" configuration.on_provider_change(self, provider, **kwargs) - @change("history_sort_order", "history_filter_mode") - def _on_sessions_view_change(self, **_: Any) -> None: - """Re-render the Recents list when its sort order or filter changes.""" + @change("history_sort_order") + def _on_sessions_sort_change(self, **_: Any) -> None: + """Re-render the Recents list when its sort order changes.""" sessions.refresh_sessions_list(self) @change("generated_code") From 0a0378f424f9f677507ac3c53629dad20025bc18 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Fri, 26 Jun 2026 16:41:20 -0400 Subject: [PATCH 21/24] Clear the prompt box on send; show the prompt inline with the explanation The sent prompt used to linger in the input box, and browsing turns refilled the box with that turn's prompt. Separate the two concerns: the input box (query_text) clears on send, while a new current_prompt holds the prompt to display inline above the explanation, Claude-style. - generation.py captures the prompt into current_prompt and clears query_text as soon as the prompt is captured (before the network call). - conversation._process_conversation_pair sets current_prompt for the viewed turn instead of refilling the input box. - sessions reset clears current_prompt; load_session restores it from the last turn alongside the explanation. - The Explanation panel now renders the prompt (with an account icon) above the explanation text rather than a single read-only textarea. --- src/vtk_prompt/controllers/conversation.py | 2 +- src/vtk_prompt/controllers/generation.py | 6 ++++ src/vtk_prompt/controllers/sessions.py | 8 +++++ src/vtk_prompt/state/initializer.py | 1 + src/vtk_prompt/ui/layout/content.py | 41 +++++++++++++++------- 5 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/vtk_prompt/controllers/conversation.py b/src/vtk_prompt/controllers/conversation.py index 9fe256c..aec38aa 100644 --- a/src/vtk_prompt/controllers/conversation.py +++ b/src/vtk_prompt/controllers/conversation.py @@ -292,7 +292,7 @@ def _process_conversation_pair(app: Any, pair_index: int | None = None) -> None: query_text = parts[1].strip() if len(parts) > 1 else user_content else: query_text = user_content - app.state.query_text = query_text + app.state.current_prompt = query_text # Jump the single per-conversation code timeline to this turn's anchored # version. Manual edits made after that generation live further forward on diff --git a/src/vtk_prompt/controllers/generation.py b/src/vtk_prompt/controllers/generation.py index 9b5ee65..6e1fc65 100644 --- a/src/vtk_prompt/controllers/generation.py +++ b/src/vtk_prompt/controllers/generation.py @@ -64,6 +64,12 @@ async def generate_and_execute_code(app: Any) -> None: enhanced_query = app.state.query_text logger.debug("Using UI mode - client will select appropriate prompt") + # Capture the prompt for inline display, then clear the input box so + # the sent text does not linger (Claude-style). + app.state.current_prompt = enhanced_query + app.state.query_text = "" + app.state.flush() + # Reinitialize client with current settings app._init_prompt_client() if hasattr(app.state, "error_message") and app.state.error_message: diff --git a/src/vtk_prompt/controllers/sessions.py b/src/vtk_prompt/controllers/sessions.py index b5d0ac2..de764a0 100644 --- a/src/vtk_prompt/controllers/sessions.py +++ b/src/vtk_prompt/controllers/sessions.py @@ -138,6 +138,7 @@ def _reset_live(app: Any) -> None: app.state.query_text = "" app.state.generated_code = "" app.state.generated_explanation = "" + app.state.current_prompt = "" app.state.code_history = [] app.state.code_history_labels = [] app.state.code_history_pos = -1 @@ -188,8 +189,15 @@ def load_session(app: Any, session_id: str, execute: bool = True) -> None: nav[-1].get("assistant", {}).get("content", "") ) app.state.generated_explanation = explanation or "" + from .conversation import EXTRA_INSTRUCTIONS_TAG + + last_user = (nav[-1].get("user", {}).get("content", "") or "").strip() + if EXTRA_INSTRUCTIONS_TAG in last_user: + last_user = last_user.split(EXTRA_INSTRUCTIONS_TAG, 1)[-1].strip() + app.state.current_prompt = last_user else: app.state.generated_explanation = "" + app.state.current_prompt = "" app.state.query_text = "" if execute and app.state.generated_code: diff --git a/src/vtk_prompt/state/initializer.py b/src/vtk_prompt/state/initializer.py index 0474118..63a198c 100644 --- a/src/vtk_prompt/state/initializer.py +++ b/src/vtk_prompt/state/initializer.py @@ -26,6 +26,7 @@ def initialize_state(app: Any) -> None: app.state.query_text = "" app.state.generated_code = "" app.state.generated_explanation = "" + app.state.current_prompt = "" # the sent prompt shown inline with the explanation # Code currently shown in the 3D view (last successful render); used to # disable Run when the editor already matches what is rendered. app.state.rendered_code = "" diff --git a/src/vtk_prompt/ui/layout/content.py b/src/vtk_prompt/ui/layout/content.py index 4053f4d..036e978 100644 --- a/src/vtk_prompt/ui/layout/content.py +++ b/src/vtk_prompt/ui/layout/content.py @@ -258,18 +258,35 @@ def build_content(layout: Any, app: Any) -> None: # Explanation panel with vuetify.VCard(classes="h-25 w-100 mt-2"): vuetify.VCardTitle("Explanation", classes="text-h6") - with vuetify.VCardText(style="height: calc(100% - 50px);"): - vuetify.VTextarea( - v_model=("generated_explanation", ""), - readonly=True, - solo=True, - hide_details=True, - no_resize=True, - classes="overflow-y-auto fill-height", - placeholder="Explanation will appear here...", - auto_grow=True, - density="compact", - style="overflow-y: auto;", + with vuetify.VCardText( + classes="overflow-y-auto", + style="height: calc(100% - 50px);", + ): + # Your prompt, shown inline above the explanation. + with html.Div( + v_show="current_prompt", + classes="d-flex align-start mb-2", + ): + vuetify.VIcon( + "mdi-account-circle", + size="small", + color="primary", + classes="mr-2", + ) + html.Span( + "{{ current_prompt }}", + classes="text-body-2 font-weight-medium", + ) + html.Div( + "{{ generated_explanation }}", + v_show="generated_explanation", + classes="text-body-2 text-medium-emphasis", + style="white-space: pre-wrap;", + ) + html.Div( + "Explanation will appear here...", + v_show="!current_prompt && !generated_explanation", + classes="text-medium-emphasis text-body-2", ) vuetify.VAlert( From d27b063441aa05712966f5a774c4177e55de5f83 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Fri, 26 Jun 2026 17:05:29 -0400 Subject: [PATCH 22/24] Turn the Explanation pane into a clickable Conversation transcript Replace the single-turn "Explanation" pane with a running "Conversation" transcript that lists every turn (prompt + response) for the active conversation. The current turn is highlighted, and clicking a turn revisits that step (jumps the code timeline and re-renders), which is more intuitive than the per-turn list that previously lived in the Recents drawer. - build_conversation_navigation enriches each pair with a cleaned prompt and the parsed explanation, so the transcript template stays simple. - content.py: the bottom-right pane is now the transcript (with a pending turn shown while a response generates). - conversation_history.py: the Recents drawer lists conversations only; the per-turn sub-list moved into the transcript. --- src/vtk_prompt/controllers/conversation.py | 18 +++- src/vtk_prompt/ui/layout/content.py | 93 ++++++++++++++----- .../ui/layout/conversation_history.py | 33 +------ 3 files changed, 86 insertions(+), 58 deletions(-) diff --git a/src/vtk_prompt/controllers/conversation.py b/src/vtk_prompt/controllers/conversation.py index aec38aa..afbb91d 100644 --- a/src/vtk_prompt/controllers/conversation.py +++ b/src/vtk_prompt/controllers/conversation.py @@ -218,6 +218,16 @@ def _parse_assistant_content(content: str) -> tuple[str | None, str | None]: return None, None +def _clean_prompt(user_content: str) -> str: + """Strip the extra-instructions wrapper and a leading 'Request:' for display.""" + content = (user_content or "").strip() + if EXTRA_INSTRUCTIONS_TAG in content: + content = content.split(EXTRA_INSTRUCTIONS_TAG, 1)[-1].strip() + if content[:8].lower() == "request:": + content = content[8:].strip() + return content + + def build_conversation_navigation(app: Any) -> None: """Build list of conversation pairs (user message + assistant response) for navigation.""" if not app.state.conversation: @@ -233,7 +243,13 @@ def build_conversation_navigation(app: Any) -> None: if message.get("role") == "user": current_user = message elif message.get("role") == "assistant" and current_user: - pairs.append({"user": current_user, "assistant": message}) + explanation, _ = _parse_assistant_content(message.get("content", "")) + pairs.append({ + "user": current_user, + "assistant": message, + "prompt": _clean_prompt(current_user.get("content", "")), + "explanation": explanation or "", + }) current_user = None app.state.conversation_navigation = pairs diff --git a/src/vtk_prompt/ui/layout/content.py b/src/vtk_prompt/ui/layout/content.py index 036e978..5470154 100644 --- a/src/vtk_prompt/ui/layout/content.py +++ b/src/vtk_prompt/ui/layout/content.py @@ -255,39 +255,82 @@ def build_content(layout: Any, app: Any) -> None: # Ensure initial render view.update() - # Explanation panel + # Conversation transcript: prompts and responses for the + # active conversation. Click a turn to revisit that step. with vuetify.VCard(classes="h-25 w-100 mt-2"): - vuetify.VCardTitle("Explanation", classes="text-h6") + vuetify.VCardTitle("Conversation", classes="text-h6") with vuetify.VCardText( classes="overflow-y-auto", style="height: calc(100% - 50px);", ): - # Your prompt, shown inline above the explanation. - with html.Div( - v_show="current_prompt", - classes="d-flex align-start mb-2", - ): - vuetify.VIcon( - "mdi-account-circle", - size="small", - color="primary", - classes="mr-2", - ) - html.Span( - "{{ current_prompt }}", - classes="text-body-2 font-weight-medium", - ) - html.Div( - "{{ generated_explanation }}", - v_show="generated_explanation", - classes="text-body-2 text-medium-emphasis", - style="white-space: pre-wrap;", - ) html.Div( - "Explanation will appear here...", - v_show="!current_prompt && !generated_explanation", + "Your conversation will appear here...", + v_show=( + "conversation_navigation.length === 0 && !is_loading" + ), classes="text-medium-emphasis text-body-2", ) + with html.Div( + v_for="(pair, idx) in conversation_navigation", + key="'turn-' + idx", + click=(app.ctrl.navigate_to_conversation, "[idx]"), + classes="mb-2 pl-2", + style=( + "'cursor: pointer; border-left: 3px solid '" + + " + (conversation_index === idx" + + " ? 'rgb(var(--v-theme-primary))' : 'transparent')", + "cursor: pointer;", + ), + ): + with html.Div(classes="d-flex align-start"): + vuetify.VIcon( + "mdi-account-circle", + size="small", + color="primary", + classes="mr-2", + ) + html.Span( + "{{ pair.prompt }}", + classes=( + "conversation_index === idx" + + " ? 'text-body-2 font-weight-medium'" + + " : 'text-body-2'", + "text-body-2", + ), + ) + html.Div( + "{{ pair.explanation }}", + v_show="pair.explanation", + classes="text-body-2 text-medium-emphasis ml-6", + style="white-space: pre-wrap;", + ) + # Pending turn while a response is generating. + with html.Div( + v_show="is_loading && current_prompt", + classes="mb-2 pl-2", + ): + with html.Div(classes="d-flex align-start"): + vuetify.VIcon( + "mdi-account-circle", + size="small", + color="primary", + classes="mr-2", + ) + html.Span( + "{{ current_prompt }}", + classes="text-body-2 font-weight-medium", + ) + with html.Div(classes="d-flex align-center ml-6 mt-1"): + vuetify.VProgressCircular( + indeterminate=True, + size="14", + width="2", + classes="mr-2", + ) + html.Span( + "Generating...", + classes="text-body-2 text-medium-emphasis", + ) vuetify.VAlert( closable=True, diff --git a/src/vtk_prompt/ui/layout/conversation_history.py b/src/vtk_prompt/ui/layout/conversation_history.py index 8c808b3..0a30be2 100644 --- a/src/vtk_prompt/ui/layout/conversation_history.py +++ b/src/vtk_prompt/ui/layout/conversation_history.py @@ -1,17 +1,10 @@ -"""Recents drawer: conversations to switch between, with the active one's turns.""" +"""Recents drawer: the list of conversations to switch between.""" from typing import Any from trame.widgets import html from trame.widgets import vuetify3 as vuetify -# Strip the extra-instructions prefix and a leading "Request:" for a clean preview. -_PROMPT_PREVIEW = ( - "(pair.user.content.includes('')" - " ? pair.user.content.split('')[1]" - " : pair.user.content).trim().replace(/^Request:\\s*/i, '')" -) - def _header(app: Any) -> None: with vuetify.VCardTitle("Recents", classes="d-flex align-center"): @@ -146,28 +139,4 @@ def build_conversation_history(app: Any) -> None: style="cursor: pointer;", ) _row_menu(app) - - # The active conversation's follow-up prompts (its turns). - with html.Div( - v_if="s.active && conversation_navigation.length > 0", - classes="ml-4 mt-1 mb-1", - ): - with html.Div( - v_for="(pair, idx) in conversation_navigation", - key="'turn-' + idx", - click=(app.ctrl.navigate_to_conversation, "[idx]"), - classes=( - "(conversation_index === idx" - + " ? 'text-primary font-weight-medium' : 'text-medium-emphasis')" - + " + ' text-caption text-truncate py-1 px-2'", - "text-caption", - ), - style=( - "'cursor: pointer; border-left: 2px solid '" - + " + (conversation_index === idx" - + " ? 'rgb(var(--v-theme-primary))' : 'transparent')", - "cursor: pointer;", - ), - ): - html.Span("{{ idx + 1 }}. {{ " + _PROMPT_PREVIEW + " }}") _dialogs(app) From a97876f380bd2e07a4121471e224a17184211329 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Fri, 26 Jun 2026 19:37:35 -0400 Subject: [PATCH 23/24] Revisiting a turn lands on its latest code, not the original generation A turn's checkpoint anchored the code version at generation time, so clicking a turn reverted to its first iteration and manual edits made afterward (which live further along the shared timeline) had to be reached by stepping forward. Jump instead to the last version belonging to the turn: the position just before the next turn's anchor, or the end of history for the most recent turn. Earlier turns still show their own version; the latest turn now restores its newest edit. --- src/vtk_prompt/controllers/conversation.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/vtk_prompt/controllers/conversation.py b/src/vtk_prompt/controllers/conversation.py index afbb91d..4f6833c 100644 --- a/src/vtk_prompt/controllers/conversation.py +++ b/src/vtk_prompt/controllers/conversation.py @@ -310,14 +310,24 @@ def _process_conversation_pair(app: Any, pair_index: int | None = None) -> None: query_text = user_content app.state.current_prompt = query_text - # Jump the single per-conversation code timeline to this turn's anchored - # version. Manual edits made after that generation live further forward on - # the same timeline (reachable with redo), so they are never discarded. + # Jump the single per-conversation code timeline to this turn. Land on the + # latest version belonging to the turn: its generation plus any manual edits + # made while on it. That is the last position before the next turn's anchor + # (or the end of history for the most recent turn), so revisiting a turn no + # longer reverts to its original generated code. cps = _checkpoints(app) history = app.state.code_history or [] if pair_index < len(cps) and 0 <= cps[pair_index] < len(history): - app.state.code_history_pos = cps[pair_index] - app.state.generated_code = history[app.state.code_history_pos] + start = cps[pair_index] + target = len(history) - 1 + for later in cps[pair_index + 1:]: + if later > start: + target = later - 1 + break + if target < start: + target = start + app.state.code_history_pos = target + app.state.generated_code = history[target] elif code: # No anchored version yet (e.g. a freshly loaded conversation): seed one # on the timeline and anchor this turn to it. From 965089376c5a82fd48433b2649173b8fbb784e29 Mon Sep 17 00:00:00 2001 From: Jeff Lee Date: Sat, 27 Jun 2026 14:04:18 -0400 Subject: [PATCH 24/24] Show the model's working steps as a collapsible trace per turn The stored conversation interleaves a real prompt with the model's tool calls, tool results, and any retry/format messages before the final answer. Rework build_conversation_navigation to delimit turns by real prompts, pair each with its final response, and collect everything in between as that turn's trace. This also fixes latent mis-pairing whenever tools or retries were used (previously a turn could pair to an empty intermediate message or fragment across retries). Each turn in the Conversation transcript gets a collapsed "Show work" panel (tool name + arguments + result, plus any retries/attempts) when it has one; turns without tool use show nothing extra. The prompt row remains the click target for revisiting a turn. --- src/vtk_prompt/controllers/conversation.py | 118 +++++++++++++++++---- src/vtk_prompt/ui/layout/content.py | 43 +++++++- 2 files changed, 135 insertions(+), 26 deletions(-) diff --git a/src/vtk_prompt/controllers/conversation.py b/src/vtk_prompt/controllers/conversation.py index 4f6833c..d2f9b33 100644 --- a/src/vtk_prompt/controllers/conversation.py +++ b/src/vtk_prompt/controllers/conversation.py @@ -228,37 +228,111 @@ def _clean_prompt(user_content: str) -> str: return content +def _is_real_prompt(content: str) -> bool: + """Return True for a real user turn (not a synthetic retry/error message).""" + content = (content or "").strip() + return (EXTRA_INSTRUCTIONS_TAG in content) or content[:8].lower() == "request:" + + +def _summarize(text: str, limit: int = 160) -> str: + text = " ".join((text or "").split()) + return text if len(text) <= limit else text[:limit] + "..." + + +def _build_trace(body: list, final_idx: int | None) -> list: + """Collect the model's working steps for a turn: tool calls, results, retries. + + ``body`` is the messages between a real user prompt and the next one; + ``final_idx`` is the index in ``body`` of the turn's final response, which is + excluded (it is shown as the explanation, not part of the trace). + """ + results: dict[str, str] = {} + for message in body: + if message.get("role") == "tool" and message.get("tool_call_id"): + results[message["tool_call_id"]] = message.get("content", "") + + steps: list[dict] = [] + for i, message in enumerate(body): + role = message.get("role") + if role == "assistant" and message.get("tool_calls"): + for call in message["tool_calls"]: + fn = call.get("function", {}) + steps.append({ + "kind": "tool", + "name": fn.get("name", "tool"), + "detail": _summarize(fn.get("arguments", ""), 120), + "result": _summarize(results.get(call.get("id", ""), ""), 200), + }) + elif role == "user": + steps.append({ + "kind": "note", "name": "Retry", + "detail": _summarize(message.get("content", "")), "result": "", + }) + elif role == "assistant" and i != final_idx: + explanation, _ = _parse_assistant_content(message.get("content", "")) + text = explanation or re.sub( + r"", "", message.get("content", "") + ) + if text.strip(): + steps.append({ + "kind": "note", "name": "Attempt", + "detail": _summarize(text), "result": "", + }) + return steps + + def build_conversation_navigation(app: Any) -> None: - """Build list of conversation pairs (user message + assistant response) for navigation.""" - if not app.state.conversation: + """Build per-turn navigation pairs, each with its prompt, explanation, and trace. + + The stored conversation interleaves a real prompt with the model's tool calls, + tool results, and any retry messages before the final response. Turns are + delimited by real prompts; everything in between becomes that turn's trace, + and the last response in the span is the turn's answer. + """ + msgs = app.state.conversation or [] + if not msgs: app.state.conversation_navigation = [] app.state.conversation_index = 0 _update_navigation_state(app) return - pairs = [] - current_user = None + starts = [ + i for i, m in enumerate(msgs) + if m.get("role") == "user" and _is_real_prompt(m.get("content", "")) + ] + if not starts: # plain conversations without wrapper markers: every user msg + starts = [i for i, m in enumerate(msgs) if m.get("role") == "user"] - for message in app.state.conversation: - if message.get("role") == "user": - current_user = message - elif message.get("role") == "assistant" and current_user: - explanation, _ = _parse_assistant_content(message.get("content", "")) - pairs.append({ - "user": current_user, - "assistant": message, - "prompt": _clean_prompt(current_user.get("content", "")), - "explanation": explanation or "", - }) - current_user = None + pairs = [] + for si, start in enumerate(starts): + end = starts[si + 1] if si + 1 < len(starts) else len(msgs) + user_msg = msgs[start] + body = msgs[start + 1:end] + + final_idx = None + for j in range(len(body) - 1, -1, -1): + if body[j].get("role") == "assistant" and not body[j].get("tool_calls"): + final_idx = j + break + if final_idx is None: + for j in range(len(body) - 1, -1, -1): + if body[j].get("role") == "assistant": + final_idx = j + break + final_msg = ( + body[final_idx] if final_idx is not None else {"role": "assistant", "content": ""} + ) + explanation, _ = _parse_assistant_content(final_msg.get("content", "")) + pairs.append({ + "user": user_msg, + "assistant": final_msg, + "prompt": _clean_prompt(user_msg.get("content", "")), + "explanation": explanation or "", + "trace": _build_trace(body, final_idx), + }) app.state.conversation_navigation = pairs - # Reset index to last pair if we have pairs - if pairs: - app.state.conversation_index = len(pairs) - 1 - else: - app.state.conversation_index = 0 - + app.state.conversation_index = len(pairs) - 1 if pairs else 0 _update_navigation_state(app) diff --git a/src/vtk_prompt/ui/layout/content.py b/src/vtk_prompt/ui/layout/content.py index 5470154..e4ec1c3 100644 --- a/src/vtk_prompt/ui/layout/content.py +++ b/src/vtk_prompt/ui/layout/content.py @@ -273,16 +273,19 @@ def build_content(layout: Any, app: Any) -> None: with html.Div( v_for="(pair, idx) in conversation_navigation", key="'turn-' + idx", - click=(app.ctrl.navigate_to_conversation, "[idx]"), classes="mb-2 pl-2", style=( - "'cursor: pointer; border-left: 3px solid '" + "'border-left: 3px solid '" + " + (conversation_index === idx" + " ? 'rgb(var(--v-theme-primary))' : 'transparent')", - "cursor: pointer;", + "", ), ): - with html.Div(classes="d-flex align-start"): + with html.Div( + classes="d-flex align-start", + click=(app.ctrl.navigate_to_conversation, "[idx]"), + style="cursor: pointer;", + ): vuetify.VIcon( "mdi-account-circle", size="small", @@ -304,6 +307,38 @@ def build_content(layout: Any, app: Any) -> None: classes="text-body-2 text-medium-emphasis ml-6", style="white-space: pre-wrap;", ) + # Collapsible trace: the model's tool calls and + # retries for this turn, when present. + with vuetify.VExpansionPanels( + v_show="pair.trace && pair.trace.length", + variant="accordion", + flat=True, + classes="ml-6 mt-1", + ): + with vuetify.VExpansionPanel(): + vuetify.VExpansionPanelTitle( + "Show work ({{ pair.trace.length }})", + classes="text-caption pa-2", + style="min-height: 0;", + ) + with vuetify.VExpansionPanelText(): + with html.Div( + v_for="(step, si) in pair.trace", + key="'step-' + idx + '-' + si", + classes="mb-2", + ): + html.Div( + "{{ step.name }}{{ step.detail" + + " ? ': ' + step.detail : '' }}", + classes="text-caption font-weight-medium", + ) + html.Div( + "{{ step.result }}", + v_show="step.result", + classes="text-caption" + + " text-medium-emphasis", + style="white-space: pre-wrap;", + ) # Pending turn while a response is generating. with html.Div( v_show="is_loading && current_prompt",