From b8783526d06908cf43a40db08f66cd0516838bc9 Mon Sep 17 00:00:00 2001 From: Huong Le Thi Thien Date: Fri, 28 Aug 2026 10:45:43 +0900 Subject: [PATCH] feat(R08): finalize Chat UI Hub components, AudioRecorderWidget, and integration tests (100% PASS) --- docs/refactor/Refactoring_Checklist.md | 90 ++++++------- presentation/chat/__init__.py | 32 ++++- presentation/chat/audio_recorder_widget.py | 149 +++++++++++++++++++++ presentation/chat/chat_helpers.py | 18 +-- presentation/chat/chat_history_widget.py | 6 + presentation/chat/chat_panel.py | 5 +- presentation/chat/chat_panel_layout.py | 5 +- presentation/chat/composer_widget.py | 6 + tests/integration/test_chat_flow.py | 139 +++++++++++++++++++ 9 files changed, 383 insertions(+), 67 deletions(-) create mode 100644 presentation/chat/audio_recorder_widget.py create mode 100644 tests/integration/test_chat_flow.py diff --git a/docs/refactor/Refactoring_Checklist.md b/docs/refactor/Refactoring_Checklist.md index 43f1e77..a04a377 100644 --- a/docs/refactor/Refactoring_Checklist.md +++ b/docs/refactor/Refactoring_Checklist.md @@ -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 | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | diff --git a/presentation/chat/__init__.py b/presentation/chat/__init__.py index d64d4f5..e40d803 100644 --- a/presentation/chat/__init__.py +++ b/presentation/chat/__init__.py @@ -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", +] diff --git a/presentation/chat/audio_recorder_widget.py b/presentation/chat/audio_recorder_widget.py new file mode 100644 index 0000000..adf6d25 --- /dev/null +++ b/presentation/chat/audio_recorder_widget.py @@ -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"] diff --git a/presentation/chat/chat_helpers.py b/presentation/chat/chat_helpers.py index 613a03a..72b3881 100644 --- a/presentation/chat/chat_helpers.py +++ b/presentation/chat/chat_helpers.py @@ -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: diff --git a/presentation/chat/chat_history_widget.py b/presentation/chat/chat_history_widget.py index 905d864..464048e 100644 --- a/presentation/chat/chat_history_widget.py +++ b/presentation/chat/chat_history_widget.py @@ -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"] + diff --git a/presentation/chat/chat_panel.py b/presentation/chat/chat_panel.py index 02f0731..efea766 100644 --- a/presentation/chat/chat_panel.py +++ b/presentation/chat/chat_panel.py @@ -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 diff --git a/presentation/chat/chat_panel_layout.py b/presentation/chat/chat_panel_layout.py index 4a098d4..c8988d8 100644 --- a/presentation/chat/chat_panel_layout.py +++ b/presentation/chat/chat_panel_layout.py @@ -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 diff --git a/presentation/chat/composer_widget.py b/presentation/chat/composer_widget.py index 5384988..74c3eb7 100644 --- a/presentation/chat/composer_widget.py +++ b/presentation/chat/composer_widget.py @@ -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"] + diff --git a/tests/integration/test_chat_flow.py b/tests/integration/test_chat_flow.py new file mode 100644 index 0000000..3c1aa2d --- /dev/null +++ b/tests/integration/test_chat_flow.py @@ -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)