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,209 @@
|
||||
"""The Chats dialog, the header New chat, /chats in the terminal, and the
|
||||
row helpers. No network: list/create are injected fakes, and they still run
|
||||
on the real QThreadPool, so the off-UI-thread hand-off is exercised."""
|
||||
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from bolt_console import chats, config, console, server_client
|
||||
from bolt_console.tools import Tools
|
||||
from bolt_console.ui import Terminal
|
||||
|
||||
CHATS = [
|
||||
{"session_id": "chat-new1", "label": "Fix the build",
|
||||
"last_message": "try again [Bolt Console: a text terminal on Windows 10; working directory C:/x. To act",
|
||||
"last_active": "2026-09-25T23:46:31+00:00"},
|
||||
{"session_id": "pet-console-test", "label": "console DESKTOP",
|
||||
"last_message": "what is in this folder", "last_active": "2026-09-25T21:26:20+00:00"},
|
||||
{"session_id": "android-SM-S928U", "label": "android SM S928U",
|
||||
"last_message": "wifi networks", "last_active": "2026-09-20T22:33:34+00:00"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _session(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(config, "SESSION_ID", "pet-console-test")
|
||||
monkeypatch.setattr(config, "STATE_FILE", tmp_path / "session.json")
|
||||
monkeypatch.setattr(config, "SERVER_URL", "http://127.0.0.1:9")
|
||||
monkeypatch.setattr(config, "API_KEY", "k")
|
||||
monkeypatch.delenv("DESK_SESSION_ID", raising=False)
|
||||
|
||||
|
||||
class Calls:
|
||||
"""Fake list/create that record which thread they ran on."""
|
||||
|
||||
def __init__(self, chat_list=CHATS, new_id="chat-fresh", fail=None):
|
||||
self.chat_list, self.new_id, self.fail, self.threads = chat_list, new_id, fail, []
|
||||
|
||||
def list(self):
|
||||
self.threads.append(threading.current_thread())
|
||||
if self.fail:
|
||||
raise server_client.ServerError(self.fail)
|
||||
return self.chat_list
|
||||
|
||||
def create(self):
|
||||
self.threads.append(threading.current_thread())
|
||||
if self.fail:
|
||||
raise server_client.ServerError(self.fail)
|
||||
return self.new_id
|
||||
|
||||
|
||||
# ── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_preview_drops_client_context_notes_even_when_cut_off():
|
||||
assert chats.clean_preview(CHATS[0]["last_message"]) == "try again"
|
||||
assert chats.clean_preview("hi [on screen right now: VS Code] there") == "hi there"
|
||||
assert chats.clean_preview("x" * 200, 20).endswith("…")
|
||||
|
||||
|
||||
def test_relative_time():
|
||||
now = datetime(2026, 9, 26, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
def ago(**delta):
|
||||
return (now - timedelta(**delta)).isoformat()
|
||||
|
||||
assert chats.relative_time(ago(seconds=10), now) == "just now"
|
||||
assert chats.relative_time(ago(minutes=12), now) == "12 min ago"
|
||||
assert chats.relative_time(ago(hours=3), now) == "3 h ago"
|
||||
assert chats.relative_time(ago(days=1, hours=2), now) == "yesterday"
|
||||
assert chats.relative_time(ago(days=4), now) == "4 days ago"
|
||||
assert chats.relative_time("garbage", now) == "garbage"
|
||||
|
||||
|
||||
# ── terminal ────────────────────────────────────────────────────────────────
|
||||
|
||||
def terminal_session(calls):
|
||||
out = io.StringIO()
|
||||
ui = Terminal(out=out, color=False, input_fn=lambda _p: "", animate=False)
|
||||
session = console.Session(ui, tools=Tools(ui), list_chats=calls.list,
|
||||
create_chat=calls.create, list_files=lambda: [])
|
||||
return session, out
|
||||
|
||||
|
||||
def test_chats_command_lists_and_marks_the_current_one():
|
||||
session, out = terminal_session(Calls())
|
||||
session.handle("/chats")
|
||||
text = out.getvalue()
|
||||
assert "Fix the build" in text and "try again" in text and "Bolt Console:" not in text
|
||||
assert "* 2 console DESKTOP" in text # the current chat is starred
|
||||
|
||||
|
||||
def test_chat_command_switches_by_number_and_remembers_it():
|
||||
session, _ = terminal_session(Calls())
|
||||
session.handle("/chats")
|
||||
session.handle("/chat 3")
|
||||
assert config.SESSION_ID == "android-SM-S928U"
|
||||
assert config._remembered_session() == "android-SM-S928U"
|
||||
|
||||
|
||||
def test_new_uses_the_server_minted_id_and_falls_back_locally():
|
||||
session, _ = terminal_session(Calls(new_id="chat-abc123"))
|
||||
session.handle("/new")
|
||||
assert config.SESSION_ID == "chat-abc123"
|
||||
session, out = terminal_session(Calls(fail="HTTP 401"))
|
||||
session.handle("/new")
|
||||
assert config.SESSION_ID.startswith("pet-console-") and "made locally" in out.getvalue()
|
||||
|
||||
|
||||
# ── window ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def pump(app, predicate, timeout=5.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
app.processEvents()
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def window():
|
||||
from bolt_console.gui.window import MainWindow, make_app
|
||||
|
||||
app = make_app(["test"])
|
||||
calls = Calls()
|
||||
win = MainWindow(list_chats=calls.list, create_chat=calls.create)
|
||||
win.calls, win.app = calls, app
|
||||
yield win
|
||||
win.close()
|
||||
|
||||
|
||||
def test_dialog_lists_chats_off_the_ui_thread(window):
|
||||
window.open_chats()
|
||||
dialog = window.chat_dialog
|
||||
assert pump(window.app, lambda: dialog.list.count() == 3)
|
||||
assert window.calls.threads and all(t is not threading.main_thread() for t in window.calls.threads)
|
||||
assert dialog.list.currentItem().data(Qt.UserRole) == "pet-console-test" # current one selected
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_picking_a_chat_switches_session_and_clears_the_transcript(window):
|
||||
from bolt_console.gui.widgets import UserMessage
|
||||
|
||||
window._add(UserMessage("old conversation"))
|
||||
window.open_chats()
|
||||
dialog = window.chat_dialog
|
||||
assert pump(window.app, lambda: dialog.list.count() == 3)
|
||||
dialog.list.setCurrentRow(0)
|
||||
dialog.open_button.click()
|
||||
window.app.processEvents()
|
||||
assert config.SESSION_ID == "chat-new1"
|
||||
assert not window.empty.isHidden()
|
||||
assert "Fix the build" in window.hint.text()
|
||||
|
||||
|
||||
def test_new_chat_in_the_dialog_switches_to_the_minted_id(window):
|
||||
window.open_chats()
|
||||
dialog = window.chat_dialog
|
||||
assert pump(window.app, lambda: dialog.list.count() == 3)
|
||||
dialog.new_button.click()
|
||||
assert pump(window.app, lambda: config.SESSION_ID == "chat-fresh")
|
||||
assert not dialog.isVisible()
|
||||
|
||||
|
||||
def test_header_new_chat_mints_off_thread_and_clears(window):
|
||||
from bolt_console.gui.widgets import UserMessage
|
||||
|
||||
window._add(UserMessage("old"))
|
||||
window.start_new_chat()
|
||||
assert pump(window.app, lambda: config.SESSION_ID == "chat-fresh")
|
||||
assert window.calls.threads[0] is not threading.main_thread()
|
||||
assert not window.empty.isHidden() and window.new_chat.isEnabled()
|
||||
|
||||
|
||||
def test_header_new_chat_failure_keeps_the_current_chat(window):
|
||||
window.calls.fail = "HTTP 401 unauthorized"
|
||||
window.start_new_chat()
|
||||
assert pump(window.app, lambda: "Couldn't start a new chat" in window.hint.text())
|
||||
assert config.SESSION_ID == "pet-console-test"
|
||||
assert window.new_chat.isEnabled()
|
||||
|
||||
|
||||
def test_dialog_shows_a_load_failure(window):
|
||||
window.calls.fail = "HTTP 401"
|
||||
window.open_chats()
|
||||
dialog = window.chat_dialog
|
||||
assert pump(window.app, lambda: "Couldn't load chats" in dialog.status.text())
|
||||
assert dialog.refresh_button.isEnabled()
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_switching_is_refused_while_bolt_is_working(window):
|
||||
window._set_busy(True)
|
||||
window.open_chats()
|
||||
assert "Wait for Bolt" in window.hint.text()
|
||||
window.switch_chat("chat-other", "Other")
|
||||
assert config.SESSION_ID == "pet-console-test"
|
||||
window._set_busy(False)
|
||||
Reference in New Issue
Block a user