Files
Bolt-Console/bolt_console/gui/chat_list_window.py
T
themajesticmagicianandClaude Opus 5.5 3977804ae3 Bolt Console: desktop app with chat list / new chat, dictation, file tools
Add a chat list and server-minted new chats on top of the desk API's
GET/POST /desk/chats:

- server_client.list_chats() / create_chat(), same shape and error
  handling as list_outbox_files().
- Switching is config.set_session(id): a plain reassignment of the
  SESSION_ID every call reads, persisted to session.json. The window
  refuses to switch mid-turn rather than locking.
- gui/chat_list_window.py: a Chats dialog (Ctrl+K) with a QListWidget,
  populated on open, with Refresh and New Chat. Network calls run as
  QRunnables on the global QThreadPool and come back through Signals,
  never from a slot. The header New chat (Ctrl+N) uses the same path.
- Switching clears the local transcript; there is no endpoint for a
  past chat's messages. Bolt's memory is per user, so it carries over.
- Terminal: /chats, /chat <n|id>, and /new (server-minted id, local
  fallback).

This commit also carries the uncommitted work since 54030a7 that turned
this Bolt-Pet fork into Bolt Console: the pet, wake word, TTS and screen
features removed; a Claude Code-style terminal client; the PySide6
window with tool cards, approvals and diffs; mic dictation through the
server's /desk/stt relay; filectl/JSON repairs; and detection of
invented tool results.

Build output (build/, dist/, Release/, *.spec) is now ignored.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 19:50:10 -06:00

236 lines
9.6 KiB
Python

"""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)