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>
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user