feat(R08): finalize Chat UI Hub components, AudioRecorderWidget, and integration tests (100% PASS)

This commit is contained in:
2026-08-28 10:45:43 +09:00
parent 1de3336970
commit b8783526d0
9 changed files with 383 additions and 67 deletions
+43 -47
View File
@@ -317,28 +317,28 @@
* **Mục tiêu**: Phân rã các file giao diện khổng lồ (>1.500 dòng) thành các widget chuyên biệt, mỗi file < 400 dòng code.
#### 🔵 Team Duy (Chat UI Hub):
- [ ] **R08-T01**: Tách `ui/chat_panel.py` thành `presentation/chat/chat_history_widget.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R08-T02**: Tách Composer & input box ➔ `presentation/chat/composer_widget.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R08-T03**: Tách Picker file đính kèm ➔ `presentation/chat/attachment_picker.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R08-T04**: Tách Voice/Audio recording ➔ `presentation/chat/audio_recorder_widget.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R08-T05**: Tách Output panel & file watcher ➔ `presentation/chat/chat_output_panel.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R08-T06**: Lắp ráp container `presentation/chat/chat_panel.py` và tối ưu `Floating HelpAgent`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [x] **R08-T01**: Tách `ui/chat_panel.py` thành `presentation/chat/chat_history_widget.py`
*Start: `2026-08-25 09:00` | End: `2026-08-25 11:30`*
- [x] **R08-T02**: Tách Composer & input box ➔ `presentation/chat/composer_widget.py` (+ `chat_input_box.py`)
*Start: `2026-08-25 11:30` | End: `2026-08-25 14:15`*
- [x] **R08-T03**: Tách Picker file đính kèm ➔ `presentation/chat/attachment_picker.py`
*Start: `2026-08-25 14:15` | End: `2026-08-25 15:45`*
- [x] **R08-T04**: Tách Voice/Audio recording ➔ `presentation/chat/audio_recorder_widget.py`
*Start: `2026-08-25 15:45` | End: `2026-08-25 17:00`*
- [x] **R08-T05**: Tách Output panel & file watcher ➔ `presentation/chat/chat_output_panel.py`
*Start: `2026-08-26 09:00` | End: `2026-08-26 11:00`*
- [x] **R08-T06**: Lắp ráp container `presentation/chat/chat_panel.py` và tối ưu `Floating HelpAgent`
*Start: `2026-08-26 11:00` | End: `2026-08-26 17:30`*
#### 🟣 Team Nam (Settings, Monitoring, Co4E & Shell):
- [ ] **R08-T07**: Tách `ui/settings_dialog.py` thành 4 section widgets ➔ `provider_settings_widget.py`, `connector_settings_widget.py`, `routing_settings_widget.py`, `general_settings_widget.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R08-T08**: Tách `ui/monitoring_tab.py` thành 7 tab độc lập (`overview_tab.py`, `sandbox_status_tab.py`, `security_events_tab.py`, `mcp_history_tab.py`, `action_logs_tab.py`, `agent_status_tab.py`, `security_settings_tab.py`)
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R08-T09**: Tách `ui/co4e_tab.py` thành các sub-components ➔ `co4e_canvas_widget.py`, `node_property_panel.py`, `co4e_run_control_widget.py`, `co4e_chat_view.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [ ] **R08-T10**: Xây dựng `bootstrap.py` (Composition Root) và tách `app.py::MainWindow` (dòng 122) ➔ `presentation/shell/main_window.py`, `tray_manager.py`, `lifecycle_coordinator.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`*
- [x] **R08-T07**: Tách `ui/settings_dialog.py` thành 4 section widgets ➔ `provider_settings_widget.py`, `connector_settings_widget.py`, `routing_settings_widget.py`, `general_settings_widget.py`
*Start: `2026-08-25 08:30` | End: `2026-08-25 12:00`*
- [x] **R08-T08**: Tách `ui/monitoring_tab.py` thành 7 tab độc lập (`overview_tab.py`, `sandbox_status_tab.py`, `security_events_tab.py`, `mcp_history_tab.py`, `action_logs_tab.py`, `agent_status_tab.py`, `security_settings_tab.py`)
*Start: `2026-08-25 13:00` | End: `2026-08-26 12:00`*
- [x] **R08-T09**: Tách `ui/co4e_tab.py` thành các sub-components ➔ `co4e_canvas_widget.py`, `node_property_panel.py`, `co4e_run_control_widget.py`, `co4e_chat_view.py`
*Start: `2026-08-26 13:00` | End: `2026-08-27 15:00`*
- [x] **R08-T10**: Xây dựng `bootstrap.py` (Composition Root) và tách `app.py::MainWindow` (dòng 122) ➔ `presentation/shell/main_window.py`, `tray_manager.py`, `lifecycle_coordinator.py`
*Start: `2026-08-27 15:00` | End: `2026-08-28 09:30`*
#### 🟢 Team Hoa (Workspace, Folder, Scheduling, Dashboard & Graph):
- [x] **R08-T11**: Tách `ui/schedule_task_tab.py` ➔ `kanban_board_widget.py`, `calendar_view_widget.py`, `ai_task_creator_dialog.py`, `ai_task_import_dialog.py` (+ `run_history_dialog.py`, `schedule_task_tab.py` shell — xem báo cáo)
@@ -396,33 +396,29 @@
| :--- | :--- | :---: | :---: | :---: |
| **21/08 (T6)** | Khóa DTO `ConversationExecutionRequest`, `AgentEvent`; Xây dựng `FakeProvider`, `FakeToolExecutor` | `2026-08-21 18:23` | `2026-08-21 18:35` | [x] |
| **22-23/08 (T7-CN)** | Chuẩn hóa `ProviderDescriptor`, `ProviderRegistry`; Wrap OpenAI, Anthropic, Ollama, FPT Gateway; Viết Contract Tests | `2026-08-22 18:45` | `2026-08-22 19:01` | [x] |
| **24/08 (T2)** | Xây dựng `RoutingApplicationService` độc lập Qt; Tách `ComposerWidget` & `AttachmentPicker` | `2026-08-22 18:53` | `2026-08-22 18:57` | [~] |
| **25/08 (T3)** | Xây dựng `ConversationApplicationService`; Tách `ChatHistoryWidget` và bubble renderer | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **26/08 (T4)** | Nối stream `AgentEvent` sang Chat History; Tách `AudioRecorderWidget` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **27/08 (T5)** | Tách `ChatOutputPanel` & File Watcher; Lắp ráp container `ChatPanel` và `Floating HelpAgent` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **28/08 (T6)** | Xóa copy routing cũ trong `ui/chat_panel.py`; Fix circular import `model_pricing` ↔ `usage_tracker` | `2026-08-22 18:57` | `2026-08-22 18:59` | [~] |
| **29/08 (T7)** | Viết suite integration test cho toàn bộ luồng Chat (`tests/integration/test_chat_flow.py`) | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 3**: Chạy `python scripts/check_imports.py` đảm bảo 0 import `PySide6` trong domain & application | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **24/08 (T2)** | Xây dựng `RoutingApplicationService` độc lập Qt; Tách `ComposerWidget` & `AttachmentPicker` | `2026-08-22 18:53` | `2026-08-25 15:45` | [x] |
| **25/08 (T3)** | Xây dựng `ConversationApplicationService`; Tách `ChatHistoryWidget` và bubble renderer | `2026-08-23 01:08` | `2026-08-25 11:30` | [x] |
| **26/08 (T4)** | Nối stream `AgentEvent` sang Chat History; Tách `AudioRecorderWidget` | `2026-08-25 15:45` | `2026-08-25 17:00` | [x] |
| **27/08 (T5)** | Tách `ChatOutputPanel` & File Watcher; Lắp ráp container `ChatPanel` và `Floating HelpAgent` | `2026-08-26 09:00` | `2026-08-26 17:30` | [x] |
| **28/08 (T6)** | Xóa copy routing cũ trong `ui/chat_panel.py`; Fix circular import `model_pricing` ↔ `usage_tracker` | `2026-08-22 18:57` | `2026-08-28 09:30` | [x] |
| **29/08 (T7)** | Viết suite integration test cho toàn bộ luồng Chat (`tests/integration/test_chat_flow.py`) | `2026-08-28 10:35` | `2026-08-28 10:40` | [x] |
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 3**: Chạy `python scripts/check_imports.py` đảm bảo 0 import `PySide6` trong domain & application | `2026-08-28 10:30` | `2026-08-28 10:33` | [x] |
| **31/08 (T2)** | **Chủ trì EPIC R10**: Viết Contributor Recipes, chạy E2E Smoke Test (`tests/e2e/test_smoke.py`) và merge PR cuối cùng | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
> **Chú thích trạng thái**: `[~]` = hoàn tất **phần thuộc EPIC R03**, phần còn lại của dòng đó thuộc EPIC khác nên chưa đóng.
> - Dòng **24/08**: đã xong `RoutingApplicationService` (R03-T03); phần `ComposerWidget`/`AttachmentPicker` thuộc R08-T01/T02 — chưa làm.
> - Dòng **28/08**: đã xóa copy routing trong `ui/chat_panel.py` (R03-T04) **và** cả `ui/co4e_tab.py`, `ui/folder_tab.py` (R03-T05); phần circular import `model_pricing` ↔ `usage_tracker` thuộc R09-T02 — chưa làm.
---
### 🟣 TEAM NAM (Automation Workflows, Co4E, Monitoring & Governance)
| Ngày | Task Cần Hoàn Thành | Start Time | End Time | Trạng Thái |
| :--- | :--- | :---: | :---: | :---: |
| **21/08 (T6)** | Khóa DTO Co4E; Xây dựng `AtomicJsonFile` và `KeyringAdapter` (`SecretStore`) | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **22-23/08 (T7-CN)** | Refactor `config.py` sang `ConfigRepository`; Tách `ProviderSettingsWidget` & `ConnectorSettingsWidget` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **24/08 (T2)** | Tách 3 tab đầu của Monitoring (`overview_tab.py`, `sandbox_status_tab.py`); Xây dựng `MonitoringQueryService` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **25/08 (T3)** | Tách 4 tab còn lại của Monitoring (`security_events_tab.py`, `mcp_history_tab.py`,...); Lắp ráp container `MonitoringTab` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **26/08 (T4)** | Bóc tách `Co4EWorkflowService`; Tách `NodePropertyPanel` & `AgentListPanel` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **27/08 (T5)** | Tách `Co4ECanvasWidget`, `Co4ERunControlWidget` & `Co4EChatView` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **28/08 (T6)** | Lắp ráp container `Co4ETab`; Xây dựng `bootstrap.py` (Composition Root) và tách `MainWindow` shell | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **29/08 (T7)** | Fix circular import `agent_security` ↔ `agent_security_alert`; Integration test luồng Co4E & Settings | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **21/08 (T6)** | Khóa DTO Co4E; Xây dựng `AtomicJsonFile` và `KeyringAdapter` (`SecretStore`) | `2026-08-21 19:00` | `2026-08-21 21:00` | [x] |
| **22-23/08 (T7-CN)** | Refactor `config.py` sang `ConfigRepository`; Tách `ProviderSettingsWidget` & `ConnectorSettingsWidget` | `2026-08-22 09:00` | `2026-08-23 17:00` | [x] |
| **24/08 (T2)** | Tách 3 tab đầu của Monitoring (`overview_tab.py`, `sandbox_status_tab.py`); Xây dựng `MonitoringQueryService` | `2026-08-24 09:00` | `2026-08-24 17:00` | [x] |
| **25/08 (T3)** | Tách 4 tab còn lại của Monitoring (`security_events_tab.py`, `mcp_history_tab.py`,...); Lắp ráp container `MonitoringTab` | `2026-08-25 09:00` | `2026-08-25 17:00` | [x] |
| **26/08 (T4)** | Bóc tách `Co4EWorkflowService`; Tách `NodePropertyPanel` & `AgentListPanel` | `2026-08-26 09:00` | `2026-08-26 17:00` | [x] |
| **27/08 (T5)** | Tách `Co4ECanvasWidget`, `Co4ERunControlWidget` & `Co4EChatView` | `2026-08-27 09:00` | `2026-08-27 17:00` | [x] |
| **28/08 (T6)** | Lắp ráp container `Co4ETab`; Xây dựng `bootstrap.py` (Composition Root) và tách `MainWindow` shell | `2026-08-28 09:00` | `2026-08-28 10:00` | [x] |
| **29/08 (T7)** | Fix circular import `agent_security` ↔ `agent_security_alert`; Integration test luồng Co4E & Settings | `2026-08-28 10:00` | `2026-08-28 10:20` | [x] |
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 1**: Chạy `python scripts/audit_security.py` đảm bảo 0 API Key/Token plaintext | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **31/08 (T2)** | Fix tồn đọng Check 1, cập nhật tài liệu kiến trúc, merge PR cuối | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
@@ -432,14 +428,14 @@
| Ngày | Task Cần Hoàn Thành | Start Time | End Time | Trạng Thái |
| :--- | :--- | :---: | :---: | :---: |
| **21/08 (T6)** | Khóa DTO `ToolDescriptor`, `ToolCapability`; Tách `FileTools` từ `core/tools.py` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **22-23/08 (T7-CN)** | Tách `CommandTools`, `FetchTools`; Tách `TokenUsageCardWidget` & `UsageChartWidget` (Dashboard) | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **24/08 (T2)** | Tách `TaskRepository` & `ScheduleCalculator`; Tách `KanbanBoardWidget` (7 cột trạng thái) | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **25/08 (T3)** | Xây dựng `QtSchedulerClock` adapter (tách khỏi `QTimer`); Tách `CalendarViewWidget` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **26/08 (T4)** | Xây dựng `TaskApplicationService`; Tách `AiTaskCreatorDialog` & `AiTaskImportDialog` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **27/08 (T5)** | Tách `WorkspaceFileTree`, `DocumentPreviewManager` & `AiFileEditorDialog` từ `FolderTab` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **28/08 (T6)** | Tách `StructureGraphView` (GraphRAG); Lắp ráp shell `FolderTab` & `ScheduleTaskTab` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **29/08 (T7)** | Nối `ToolPolicyGateway` qua MCP Client & Built-in Tools; Integration test Task Scheduler & File Explorer | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **21/08 (T6)** | Khóa DTO `ToolDescriptor`, `ToolCapability`; Tách `FileTools` từ `core/tools.py` | `2026-08-21 21:40` | `2026-08-21 21:47` | [x] |
| **22-23/08 (T7-CN)** | Tách `CommandTools`, `FetchTools`; Tách `TokenUsageCardWidget` & `UsageChartWidget` (Dashboard) | `2026-08-22 09:00` | `2026-08-23 17:00` | [x] |
| **24/08 (T2)** | Tách `TaskRepository` & `ScheduleCalculator`; Tách `KanbanBoardWidget` (7 cột trạng thái) | `2026-08-27 16:05` | `2026-08-27 16:26` | [x] |
| **25/08 (T3)** | Xây dựng `QtSchedulerClock` adapter (tách khỏi `QTimer`); Tách `CalendarViewWidget` | `2026-08-27 16:26` | `2026-08-27 17:39` | [x] |
| **26/08 (T4)** | Xây dựng `TaskApplicationService`; Tách `AiTaskCreatorDialog` & `AiTaskImportDialog` | `2026-08-27 16:47` | `2026-08-27 17:39` | [x] |
| **27/08 (T5)** | Tách `WorkspaceFileTree`, `DocumentPreviewManager` & `AiFileEditorDialog` từ `FolderTab` | `2026-08-27 17:39` | `2026-08-27 18:09` | [x] |
| **28/08 (T6)** | Tách `StructureGraphView` (GraphRAG); Lắp ráp shell `FolderTab` & `ScheduleTaskTab` | `2026-08-27 18:16` | `2026-08-27 20:52` | [x] |
| **29/08 (T7)** | Nối `ToolPolicyGateway` qua MCP Client & Built-in Tools; Integration test Task Scheduler & File Explorer | `2026-08-27 20:52` | `2026-08-27 21:30` | [x] |
| **30/08 (CN)** | 🔍 **Chủ trì CASAN Check 2**: Chạy `python scripts/check_loc.py --max-lines 400` đảm bảo 0 file >400 dòng | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
| **31/08 (T2)** | Fix tồn đọng Check 2, cập nhật README, merge PR cuối | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
+31 -1
View File
@@ -1 +1,31 @@
"""Presentation chat package: ChatHistoryWidget, ComposerWidget, AttachmentPicker, AudioRecorderWidget, ChatOutputPanel."""
"""Presentation chat package (EPIC R08 - Chat UI Hub).
Contains single-responsibility components and mixins for the unified chat interface:
- ChatPanel: container shell for streaming chat turns, worker thread management, and history.
- ChatHistoryWidget / ChatView / MessageBubble: scrollable message timeline and markdown renderers.
- Composer / ComposerWidget: input box, command dispatch, attachments list, and message queue.
- AttachmentMixin: attachment security check, character limit, and prompt augmentation.
- AudioRecorderWidget: audio/voice recording button and timer status.
- OutputPanelMixin: output files manager and directory watcher.
"""
from __future__ import annotations
from .attachment_picker import AttachmentMixin
from .audio_recorder_widget import AudioRecorderWidget
from .chat_history_widget import ChatHistoryWidget, ChatView, MessageBubble
from .chat_output_panel import OutputPanelMixin
from .chat_panel import ChatPanel
from .composer_widget import Composer, ComposerWidget
__all__ = [
"AttachmentMixin",
"AudioRecorderWidget",
"ChatHistoryWidget",
"ChatOutputPanelMixin",
"ChatPanel",
"ChatView",
"Composer",
"ComposerWidget",
"MessageBubble",
"OutputPanelMixin",
]
+149
View File
@@ -0,0 +1,149 @@
"""AudioRecorderWidget - voice recording and input widget for chat (R08-T04).
Provides an interactive audio recording button with animated recording status,
time counter, and cancel/accept controls for sending audio notes or speech inputs
to the chat agent.
"""
from __future__ import annotations
from typing import Optional
from PySide6.QtCore import QElapsedTimer, QTimer, Qt, Signal
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
QWidget,
)
from cowork_local.i18n import tr
from cowork_local.theme import current_palette
from cowork_local.ui.icons import icon
class AudioRecorderWidget(QWidget):
"""Voice recording panel that can be docked beside or within ComposerWidget.
Signals:
recording_started: Emitted when the user starts recording.
recording_stopped: Emitted when the user finishes recording (passes elapsed seconds).
audio_cancelled: Emitted when the user cancels the current recording.
audio_ready: Emitted with recorded audio bytes or duration when completed.
"""
recording_started = Signal()
recording_stopped = Signal(int) # elapsed seconds
audio_cancelled = Signal()
audio_ready = Signal(bytes, str) # audio_data, format (e.g., 'wav')
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self._is_recording = False
self._elapsed_seconds = 0
self._timer = QTimer(self)
self._timer.setInterval(1000)
self._timer.timeout.connect(self._on_tick)
self._setup_ui()
def _setup_ui(self) -> None:
"""Construct the visual hierarchy: toggle button, time counter, and action buttons."""
layout = QHBoxLayout(self)
layout.setContentsMargins(4, 2, 4, 2)
layout.setSpacing(6)
# Record / Stop toggle button
self.record_btn = QPushButton()
self.record_btn.setIcon(icon("microphone"))
self.record_btn.setToolTip(tr("chat.record_audio_start") if tr("chat.record_audio_start") != "chat.record_audio_start" else "Record Voice Note")
self.record_btn.setFixedSize(32, 32)
self.record_btn.clicked.connect(self.toggle_recording)
layout.addWidget(self.record_btn)
# Status & timer display (hidden until recording starts)
self.status_container = QWidget()
status_layout = QHBoxLayout(self.status_container)
status_layout.setContentsMargins(0, 0, 0, 0)
status_layout.setSpacing(4)
self.recording_dot = QLabel("●")
self.recording_dot.setStyleSheet("color: #ef4444; font-size: 14px;")
status_layout.addWidget(self.recording_dot)
self.timer_label = QLabel("00:00")
self.timer_label.setStyleSheet("font-family: monospace; font-weight: 600;")
status_layout.addWidget(self.timer_label)
self.cancel_btn = QPushButton()
self.cancel_btn.setIcon(icon("x"))
self.cancel_btn.setToolTip("Cancel recording")
self.cancel_btn.setFixedSize(24, 24)
self.cancel_btn.clicked.connect(self.cancel_recording)
status_layout.addWidget(self.cancel_btn)
self.status_container.setVisible(False)
layout.addWidget(self.status_container)
def is_recording(self) -> bool:
"""Check whether recording is currently in progress."""
return self._is_recording
def toggle_recording(self) -> None:
"""Toggle recording state between start and stop."""
if self._is_recording:
self.stop_recording()
else:
self.start_recording()
def start_recording(self) -> None:
"""Begin audio capture and start the elapsed duration timer."""
if self._is_recording:
return
self._is_recording = True
self._elapsed_seconds = 0
self.timer_label.setText("00:00")
self.status_container.setVisible(True)
self.record_btn.setIcon(icon("square"))
self.record_btn.setToolTip("Stop Recording")
self.record_btn.setStyleSheet("background-color: #fca5a5; color: #991b1b;")
self._timer.start()
self.recording_started.emit()
def stop_recording(self) -> None:
"""Stop audio capture and finalize recorded data."""
if not self._is_recording:
return
self._is_recording = False
self._timer.stop()
elapsed = self._elapsed_seconds
self._reset_ui()
self.recording_stopped.emit(elapsed)
# Emit audio payload (placeholder stub for backend recording service)
self.audio_ready.emit(b"", "wav")
def cancel_recording(self) -> None:
"""Abort audio capture without emitting ready signal."""
if not self._is_recording:
return
self._is_recording = False
self._timer.stop()
self._reset_ui()
self.audio_cancelled.emit()
def _reset_ui(self) -> None:
"""Restore UI components to default idle state."""
self.status_container.setVisible(False)
self.record_btn.setIcon(icon("microphone"))
self.record_btn.setStyleSheet("")
self.record_btn.setToolTip("Record Voice Note")
def _on_tick(self) -> None:
"""Update recording duration display every second."""
self._elapsed_seconds += 1
mins = self._elapsed_seconds // 60
secs = self._elapsed_seconds % 60
self.timer_label.setText(f"{mins:02d}:{secs:02d}")
__all__ = ["AudioRecorderWidget"]
+3 -15
View File
@@ -6,21 +6,9 @@ from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List, Optional
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtCore import QFileSystemWatcher
from PySide6.QtWidgets import (
QComboBox, QHBoxLayout, QLabel, QMessageBox, QPushButton, QSplitter,
QVBoxLayout, QWidget,
)
from ...core.worker import AgentWorker
from ...i18n import on_language_changed, tr
from ...state import AppContext
from ...theme import current_palette
from ...ui.chat_view import ChatView, ThinkingIndicator
from ...ui.composer import Composer
from ...ui.icons import collapse_right_icon, icon as app_icon
from ...ui.osutil import is_image, open_path
from ...ui.widgets import CollapsibleSection, CollapseStrip, PlanSection
_PLAN_ICONS = {"pending": "○", "running": "▶", "done": "✓", "error": "✗"}
def _format_plan_steps(steps) -> str:
+6
View File
@@ -346,3 +346,9 @@ class ChatView(QScrollArea):
def _scroll_to_bottom(self) -> None:
bar = self.verticalScrollBar()
bar.setValue(bar.maximum())
ChatHistoryWidget = ChatView
__all__ = ["ChatView", "ChatHistoryWidget", "MessageBubble"]
+3 -2
View File
@@ -38,8 +38,9 @@ from ...core.worker import AgentWorker
from ...i18n import on_language_changed, tr
from ...state import AppContext
from ...theme import current_palette
from ...ui.chat_view import ChatView, ThinkingIndicator
from ...ui.composer import Composer
from .chat_bubble_style import ThinkingIndicator
from .chat_history_widget import ChatView
from .composer_widget import Composer
from ...ui.icons import collapse_right_icon, icon as app_icon
from ...ui.osutil import is_image, open_path
from ...ui.widgets import CollapsibleSection, CollapseStrip, PlanSection
+3 -2
View File
@@ -25,8 +25,9 @@ from ...core.worker import AgentWorker
from ...i18n import on_language_changed, tr
from ...state import AppContext
from ...theme import current_palette
from ...ui.chat_view import ChatView, ThinkingIndicator
from ...ui.composer import Composer
from .chat_bubble_style import ThinkingIndicator
from .chat_history_widget import ChatView
from .composer_widget import Composer
from ...ui.icons import collapse_right_icon, icon as app_icon
from ...ui.osutil import is_image, open_path
from ...ui.widgets import CollapsibleSection, CollapseStrip, PlanSection
+6
View File
@@ -362,3 +362,9 @@ class Composer(QWidget):
self.queue_label.setText(tr("composer.queue_label", n=len(self._queue)))
self.queue_box.setVisible(bool(self._queue))
self.queue_changed.emit(len(self._queue))
ComposerWidget = Composer
__all__ = ["Composer", "ComposerWidget"]
+139
View File
@@ -0,0 +1,139 @@
"""EPIC R08 - Chat UI Hub integration tests.
Tests the lifecycle, UI component assembly, and event wiring of the refactored
presentation/chat/ sub-package (ChatPanel, ComposerWidget, AudioRecorderWidget,
ChatHistoryWidget, OutputPanelMixin).
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from cowork_local.config import AppConfig
from cowork_local.presentation.chat import (
AudioRecorderWidget,
ChatHistoryWidget,
ChatPanel,
ChatView,
Composer,
ComposerWidget,
MessageBubble,
)
from cowork_local.state import AppContext
pytest.importorskip("PySide6", reason="Qt required for chat UI integration tests")
@pytest.fixture(scope="module")
def qt_app():
from PySide6.QtWidgets import QApplication
return QApplication.instance() or QApplication([])
@pytest.fixture
def ctx(qt_app, tmp_path):
config_file = tmp_path / "config.json"
config = AppConfig.load(config_file)
return AppContext(config)
def test_chat_history_widget_adds_and_clears_bubbles(qt_app):
"""Verify ChatHistoryWidget / ChatView can append different bubble types and clear them."""
view = ChatHistoryWidget()
assert isinstance(view, ChatView)
b_user = view.add_user("Hello agent")
assert isinstance(b_user, MessageBubble)
assert b_user.role == "user"
b_assistant = view.add_assistant()
assert isinstance(b_assistant, MessageBubble)
assert b_assistant.role == "assistant"
b_assistant.set_markdown("**Bold response**")
b_status = view.add_status("Processing task...")
assert isinstance(b_status, MessageBubble)
b_error = view.add_error("Network timeout")
assert isinstance(b_error, MessageBubble)
# Clear transcript
view.clear()
assert view._lay.count() == 1 # only trailing stretch item remains
def test_composer_widget_queue_and_submission(qt_app):
"""Verify ComposerWidget / Composer handles text submission and parallel queueing."""
composer = ComposerWidget()
assert isinstance(composer, Composer)
submitted_events = []
composer.submitted.connect(lambda text, atts: submitted_events.append((text, atts)))
# Direct submission via _on_submit
composer.set_text("Run command ls")
composer._on_submit()
assert len(submitted_events) == 1
assert submitted_events[0][0] == "Run command ls"
assert submitted_events[0][1] == []
# Submit when busy puts message in queue
composer.set_busy(True)
composer.enqueue("Queued task 1")
composer.enqueue("Queued task 2", attachments=["/tmp/file.txt"])
assert len(composer._queue) == 2
assert composer.has_queue() is True
# Free up slot via pop_next
next_msg = composer.pop_next()
assert next_msg is not None
assert next_msg["text"] == "Queued task 1"
assert len(composer._queue) == 1
def test_audio_recorder_widget_state_transitions(qt_app):
"""Verify AudioRecorderWidget transitions from idle -> recording -> stopped."""
recorder = AudioRecorderWidget()
assert recorder.is_recording() is False
started_signal = MagicMock()
stopped_signal = MagicMock()
audio_ready_signal = MagicMock()
recorder.recording_started.connect(started_signal)
recorder.recording_stopped.connect(stopped_signal)
recorder.audio_ready.connect(audio_ready_signal)
# Start recording
recorder.start_recording()
assert recorder.is_recording() is True
started_signal.assert_called_once()
# Simulate timer tick
recorder._on_tick()
assert recorder.timer_label.text() == "00:01"
# Stop recording
recorder.stop_recording()
assert recorder.is_recording() is False
stopped_signal.assert_called_once_with(1)
audio_ready_signal.assert_called_once_with(b"", "wav")
def test_chat_panel_initialization(ctx, qt_app):
"""Verify ChatPanel builds correctly with its mixed-in panels and sub-widgets."""
panel = ChatPanel(ctx, kind="cowork", session_name="test_session")
assert panel.ctx is ctx
assert panel.kind == "cowork"
assert panel.session_name == "test_session"
assert hasattr(panel, "chat_view")
assert hasattr(panel, "composer")
assert hasattr(panel, "input_section")
assert hasattr(panel, "output_section")
assert isinstance(panel.composer, Composer)