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>
236 lines
9.6 KiB
Python
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)
|