"""The Chats dialog: every conversation this user has with Bolt, across the console, the pet and the phone, newest first, and a New Chat button. Network calls never run on the UI thread. Each one is a `ChatTask` on the global QThreadPool that calls server_client synchronously and hands the result back through `ChatSignals`, the same signal-based hand-off the turn worker uses. The list is fetched when the dialog opens and on Refresh; there is no polling. Picking or creating a chat doesn't switch anything here. The dialog emits `chosen(session_id, label)` and the window does the switch, because the window owns the transcript that has to be cleared along with it. """ from __future__ import annotations from typing import Callable, Optional from PySide6.QtCore import QObject, QRunnable, QSize, Qt, QThreadPool, Signal from PySide6.QtWidgets import ( QDialog, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, QWidget, ) from .. import config, server_client from ..chats import clean_preview, local_time, relative_time from . import icons, theme # ── off-thread calls ──────────────────────────────────────────────────────── class ChatSignals(QObject): loaded = Signal(list) # chats, newest first created = Signal(str) # the new session_id failed = Signal(str, str) # operation ("list" / "create"), message class ChatTask(QRunnable): """Runs one server_client call on the thread pool and reports back.""" def __init__(self, operation: str, call: Callable, signals: ChatSignals): super().__init__() self.operation, self.call, self.signals = operation, call, signals def run(self) -> None: try: result = self.call() except Exception as exc: # ServerError, or anything unexpected self.signals.failed.emit(self.operation, str(exc)) return if self.operation == "list": self.signals.loaded.emit(list(result)) else: self.signals.created.emit(str(result)) def run_in_pool(operation: str, call: Callable, signals: ChatSignals) -> None: QThreadPool.globalInstance().start(ChatTask(operation, call, signals)) # ── the dialog ────────────────────────────────────────────────────────────── class ChatRow(QWidget): """Label and time on the first line, the last message under it.""" def __init__(self, chat: dict, current: bool, parent=None): super().__init__(parent) outer = QVBoxLayout(self) outer.setContentsMargins(14, 9, 14, 9) outer.setSpacing(2) top = QHBoxLayout() top.setSpacing(8) title = QLabel(str(chat.get("label") or chat.get("session_id") or "Untitled")) title.setObjectName("ChatTitle") top.addWidget(title, 1) if current: badge = QLabel("Current") badge.setObjectName("Pill") badge.setProperty("kind", "waiting") top.addWidget(badge) when = QLabel(relative_time(chat.get("last_active", ""))) when.setObjectName("ChatTime") top.addWidget(when) outer.addLayout(top) preview = QLabel(clean_preview(chat.get("last_message", "")) or "—") preview.setObjectName("ChatPreview") outer.addWidget(preview) for widget in (title, when, preview): widget.setAttribute(Qt.WA_TransparentForMouseEvents) class ChatListDialog(QDialog): """`chosen(session_id, label)` fires for a picked chat and for a newly created one (label "New chat"); the window does the actual switch.""" chosen = Signal(str, str) def __init__(self, parent=None, list_chats: Callable = None, create_chat: Callable = None): super().__init__(parent) self.setObjectName("ChatDialog") self.setWindowTitle("Chats") self.setModal(True) self.resize(560, 580) self._list_chats = list_chats or server_client.list_chats self._create_chat = create_chat or server_client.create_chat self.signals = ChatSignals() self.signals.loaded.connect(self._populate) self.signals.created.connect(self._created) self.signals.failed.connect(self._failed) col = QVBoxLayout(self) col.setContentsMargins(18, 18, 18, 16) col.setSpacing(12) head = QHBoxLayout() head.setSpacing(10) head.addWidget(icons.IconLabel("bolt", theme.ACCENT_INK, 24, tile=theme.ACCENT)) heading = QLabel("Chats") heading.setObjectName("Wordmark") head.addWidget(heading) head.addStretch(1) self.status = QLabel("") self.status.setObjectName("Hint") head.addWidget(self.status) col.addLayout(head) sub = QLabel("Every conversation with Bolt, from any device. A new chat starts with " "fresh history; Bolt still remembers you.") sub.setObjectName("HeroSub") sub.setWordWrap(True) col.addWidget(sub) self.list = QListWidget() self.list.setObjectName("ChatList") self.list.setUniformItemSizes(False) self.list.setVerticalScrollMode(QListWidget.ScrollPerPixel) self.list.itemActivated.connect(self._open_item) # Enter / double-click self.list.currentItemChanged.connect(lambda *_: self._sync_buttons()) col.addWidget(self.list, 1) buttons = QHBoxLayout() buttons.setSpacing(8) self.refresh_button = QPushButton("Refresh") self.refresh_button.setObjectName("Ghost") self.refresh_button.clicked.connect(self.refresh) buttons.addWidget(self.refresh_button) buttons.addStretch(1) self.new_button = QPushButton("New Chat") self.new_button.setObjectName("Secondary") self.new_button.clicked.connect(self.new_chat) buttons.addWidget(self.new_button) self.open_button = QPushButton("Open") self.open_button.setObjectName("Primary") self.open_button.clicked.connect(lambda: self._open_item(self.list.currentItem())) buttons.addWidget(self.open_button) col.addLayout(buttons) for button in (self.refresh_button, self.new_button, self.open_button): button.setCursor(Qt.PointingHandCursor) self._busy = False self._sync_buttons() # ── loading ────────────────────────────────────────────────────────── def showEvent(self, event) -> None: super().showEvent(event) theme.dark_title_bar(self) # same caption colour as the main window on Windows self.refresh() def refresh(self) -> None: if self._busy: return self._set_busy(True, "Loading…") run_in_pool("list", self._list_chats, self.signals) def _populate(self, chats: list) -> None: self._set_busy(False, "") self.list.clear() current = None for chat in chats: session_id = str(chat.get("session_id") or "") if not session_id: continue is_current = session_id == config.SESSION_ID item = QListWidgetItem() item.setData(Qt.UserRole, session_id) item.setData(Qt.UserRole + 1, str(chat.get("label") or session_id)) item.setToolTip(f"{session_id}\nLast active {local_time(chat.get('last_active', ''))}") row = ChatRow(chat, is_current) item.setSizeHint(QSize(0, row.sizeHint().height())) self.list.addItem(item) self.list.setItemWidget(item, row) if is_current: current = item if self.list.count() == 0: self.status.setText("No chats yet") self.list.setCurrentItem(current or self.list.item(0)) self._sync_buttons() def _failed(self, operation: str, message: str) -> None: self._set_busy(False, "") what = "load chats" if operation == "list" else "start a new chat" self.status.setText(f"Couldn't {what}") self.status.setToolTip(message) self.status.setStyleSheet(f"color: {theme.RED};") # ── actions ────────────────────────────────────────────────────────── def new_chat(self) -> None: if self._busy: return self._set_busy(True, "Starting a new chat…") run_in_pool("create", self._create_chat, self.signals) def _created(self, session_id: str) -> None: # A new chat isn't listed until it's used, so there's nothing to show: # hand it to the window and close. self._set_busy(False, "") self.chosen.emit(session_id, "New chat") self.accept() def _open_item(self, item: Optional[QListWidgetItem]) -> None: if item is None or self._busy: return self.chosen.emit(item.data(Qt.UserRole), item.data(Qt.UserRole + 1)) self.accept() def _set_busy(self, busy: bool, status: str) -> None: self._busy = busy self.status.setText(status) self.status.setToolTip("") self.status.setStyleSheet("") self._sync_buttons() def _sync_buttons(self) -> None: self.refresh_button.setEnabled(not self._busy) self.new_button.setEnabled(not self._busy) self.open_button.setEnabled(not self._busy and self.list.currentItem() is not None)