diff --git a/src/vtk_prompt/controllers/conversation.py b/src/vtk_prompt/controllers/conversation.py index b782f4b..d2f9b33 100644 --- a/src/vtk_prompt/controllers/conversation.py +++ b/src/vtk_prompt/controllers/conversation.py @@ -57,6 +57,70 @@ def on_conversation_file_data_change( app.generate_code() +def _checkpoints(app: Any) -> list: + """Turn index -> position on the single per-conversation code timeline. + + 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 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: + """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: @@ -86,6 +150,54 @@ 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 + app.state.conversation_index = target_index + _process_conversation_pair(app, target_index) + _update_navigation_state(app) + + +def start_new_conversation(app: Any) -> None: + """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_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 + _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: @@ -106,31 +218,121 @@ 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 _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: - pairs.append({"user": current_user, "assistant": message}) - 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) @@ -170,23 +372,49 @@ 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) - - # 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.current_prompt = query_text + + # 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): + 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 .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 - app.state.query_text = query_text + if app.state.generated_code: + app._execute_with_renderer(app.state.generated_code) def _process_loaded_conversation( diff --git a/src/vtk_prompt/controllers/generation.py b/src/vtk_prompt/controllers/generation.py index 876d8d6..6e1fc65 100644 --- a/src/vtk_prompt/controllers/generation.py +++ b/src/vtk_prompt/controllers/generation.py @@ -64,11 +64,23 @@ 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: 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, @@ -119,12 +131,19 @@ 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) + + 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) @@ -152,7 +171,16 @@ 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) + + 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): @@ -176,6 +204,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() @@ -195,53 +226,56 @@ 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 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: diff --git a/src/vtk_prompt/controllers/sessions.py b/src/vtk_prompt/controllers/sessions.py new file mode 100644 index 0000000..de764a0 --- /dev/null +++ b/src/vtk_prompt/controllers/sessions.py @@ -0,0 +1,346 @@ +"""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 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).""" + 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 []) + _maybe_title(app, sess) + _persist_session(sess) + + +def refresh_sessions_list(app: Any) -> None: + """Rebuild the drawer-visible list: pinned first, then by the sort order.""" + sort_order = getattr(app.state, "history_sort_order", "newest") or "newest" + 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 = [ + { + "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.current_prompt = "" + app.state.code_history = [] + app.state.code_history_labels = [] + app.state.code_history_pos = -1 + + +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 + 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 "" + 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: + 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: bump recency, capture state, and refresh the list.""" + current_session(app)["updated"] = time.time() + 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"] + _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) + + +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 ede3cd4..63a198c 100644 --- a/src/vtk_prompt/state/initializer.py +++ b/src/vtk_prompt/state/initializer.py @@ -26,9 +26,14 @@ 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 = "" # 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 = "" @@ -51,6 +56,19 @@ 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" + + # 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/__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/content.py b/src/vtk_prompt/ui/layout/content.py index 6f0e450..e4ec1c3 100644 --- a/src/vtk_prompt/ui/layout/content.py +++ b/src/vtk_prompt/ui/layout/content.py @@ -24,117 +24,8 @@ 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): - # Prompt input - with vuetify.VCard(classes="h-25"): - with vuetify.VCardText(classes="h-100"): - with html.Div(classes="d-flex"): - # Cloud models chip - vuetify.VChip( - "☁️ {{ provider }}/{{ model }}", - small=True, - color="blue", - text_color="white", - label=True, - classes="mb-2", - v_show="use_cloud_models", - ) - # Local models chip - vuetify.VChip( - ( - "🏠 " - "{{ local_base_url.replace('http://', '')" - ".replace('https://', '') }}/" - "{{ local_model }}" - ), - small=True, - color="green", - text_color="white", - label=True, - classes="mb-2", - v_show="!use_cloud_models", - ) - vuetify.VSpacer() - # API token warning chip - vuetify.VChip( - "API token is required for cloud models.", - small=True, - color="error", - text_color="white", - label=True, - classes="mb-2", - v_show="use_cloud_models && !api_token.trim()", - prepend_icon="mdi-alert", - ) - - 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", - v_model=("query_text", ""), - rows=4, - variant="outlined", - placeholder=("e.g., Create a red sphere with lighting"), - persistent_placeholder=True, - 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", - color="primary", - block=True, - loading=("is_loading", False), - click=app.ctrl.generate_code, - classes="my-2", - v_show="!use_cloud_models || api_token.trim()", - ) - vuetify.VBtn( - "Set API Key", - color="error", - block=True, - click=( - "advanced_settings_open = true;" - + " active_settings_tab = 'model';" - ), - classes="mb-2", - v_show="use_cloud_models && !api_token.trim()", - ) - # Generated code panel (editable + re-runnable) - with vuetify.VCard(classes="h-75 mt-2"): + with vuetify.VCard(classes="h-75"): with vuetify.VCardTitle( "Generated Code", classes="d-flex align-center" ): @@ -176,7 +67,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( @@ -206,6 +100,89 @@ def build_content(layout: Any, app: Any) -> None: style="height: 100%; width: 100%;", ) + # Prompt input + with vuetify.VCard(classes="h-25 mt-2"): + with vuetify.VCardText(classes="h-100"): + with html.Div(classes="d-flex"): + # Cloud models chip + vuetify.VChip( + "☁️ {{ provider }}/{{ model }}", + small=True, + color="blue", + text_color="white", + label=True, + classes="mb-2", + v_show="use_cloud_models", + ) + # Local models chip + vuetify.VChip( + ( + "🏠 " + "{{ local_base_url.replace('http://', '')" + ".replace('https://', '') }}/" + "{{ local_model }}" + ), + small=True, + color="green", + text_color="white", + label=True, + classes="mb-2", + v_show="!use_cloud_models", + ) + vuetify.VSpacer() + # API token warning chip + vuetify.VChip( + "API token is required for cloud models.", + small=True, + color="error", + text_color="white", + label=True, + classes="mb-2", + v_show="use_cloud_models && !api_token.trim()", + prepend_icon="mdi-alert", + ) + + 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. + with vuetify.VTextarea( + label="Describe VTK visualization", + v_model=("query_text", ""), + rows=4, + variant="outlined", + placeholder=("e.g., Create a red sphere with lighting"), + persistent_placeholder=True, + hide_details=True, + no_resize=True, + ): + 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", + ) + vuetify.VBtn( + "Set API Key", + color="error", + block=True, + click=( + "advanced_settings_open = true;" + + " active_settings_tab = 'model';" + ), + classes="mb-2", + v_show="use_cloud_models && !api_token.trim()", + ) + # Right column - VTK viewer and prompt with vuetify.VCol(cols=6): with vuetify.VRow(no_gutters=True, classes="fill-height"): @@ -278,22 +255,117 @@ 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") - 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;", + vuetify.VCardTitle("Conversation", classes="text-h6") + with vuetify.VCardText( + classes="overflow-y-auto", + style="height: calc(100% - 50px);", + ): + html.Div( + "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", + classes="mb-2 pl-2", + style=( + "'border-left: 3px solid '" + + " + (conversation_index === idx" + + " ? 'rgb(var(--v-theme-primary))' : 'transparent')", + "", + ), + ): + 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", + 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;", + ) + # 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", + 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 new file mode 100644 index 0000000..0a30be2 --- /dev/null +++ b/src/vtk_prompt/ui/layout/conversation_history.py @@ -0,0 +1,142 @@ +"""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 + + +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", + ) + + +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") + + +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!", + type="info", + variant="tonal", + v_show="sessions_list.length === 0", + ) + 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", + ): + with html.Div(classes="d-flex align-center w-100"): + vuetify.VIcon( + "mdi-pin", + size="x-small", + color="primary", + classes="mr-1", + v_show="s.pinned", + ) + html.Span( + "{{ s.title }}", + click=(app.ctrl.switch_session, "[s.id]"), + classes="flex-grow-1 text-truncate", + style="cursor: pointer;", + ) + _row_menu(app) + _dialogs(app) 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..a62a9a8 100644 --- a/src/vtk_prompt/vtk_prompt_ui.py +++ b/src/vtk_prompt/vtk_prompt_ui.py @@ -16,27 +16,63 @@ >>> vtk-prompt-ui --port 9090 """ +import asyncio import sys from typing import Any 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 trame.widgets import client 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, ) 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__) +# 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.""" @@ -71,6 +107,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 @@ -85,6 +122,10 @@ def __init__(self, server: Any | None = None, custom_prompt_file: str | None = N # Initialize application state self._initialize_state() + # 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: self._load_custom_prompt_file() @@ -254,6 +295,43 @@ 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) + + @controller.set("start_new_conversation") + def start_new_conversation(self) -> None: + """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) + + @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.""" @@ -269,15 +347,62 @@ def _on_provider_change(self, provider, **kwargs) -> None: """Handle provider selection change.""" configuration.on_provider_change(self, provider, **kwargs) + @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") + 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, label="Manual edit") + 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 SinglePageLayout( + with SinglePageWithDrawerLayout( 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 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 + # 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) # Build UI sections using layout modules build_toolbar(layout, self)