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. * **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): #### 🔵 Team Duy (Chat UI Hub):
- [ ] **R08-T01**: Tách `ui/chat_panel.py` thành `presentation/chat/chat_history_widget.py` - [x] **R08-T01**: Tách `ui/chat_panel.py` thành `presentation/chat/chat_history_widget.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-25 09:00` | End: `2026-08-25 11:30`*
- [ ] **R08-T02**: Tách Composer & input box ➔ `presentation/chat/composer_widget.py` - [x] **R08-T02**: Tách Composer & input box ➔ `presentation/chat/composer_widget.py` (+ `chat_input_box.py`)
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-25 11:30` | End: `2026-08-25 14:15`*
- [ ] **R08-T03**: Tách Picker file đính kèm ➔ `presentation/chat/attachment_picker.py` - [x] **R08-T03**: Tách Picker file đính kèm ➔ `presentation/chat/attachment_picker.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-25 14:15` | End: `2026-08-25 15:45`*
- [ ] **R08-T04**: Tách Voice/Audio recording ➔ `presentation/chat/audio_recorder_widget.py` - [x] **R08-T04**: Tách Voice/Audio recording ➔ `presentation/chat/audio_recorder_widget.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-25 15:45` | End: `2026-08-25 17:00`*
- [ ] **R08-T05**: Tách Output panel & file watcher ➔ `presentation/chat/chat_output_panel.py` - [x] **R08-T05**: Tách Output panel & file watcher ➔ `presentation/chat/chat_output_panel.py`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-26 09:00` | End: `2026-08-26 11:00`*
- [ ] **R08-T06**: Lắp ráp container `presentation/chat/chat_panel.py` và tối ưu `Floating HelpAgent` - [x] **R08-T06**: Lắp ráp container `presentation/chat/chat_panel.py` và tối ưu `Floating HelpAgent`
*Start: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-26 11:00` | End: `2026-08-26 17:30`*
#### 🟣 Team Nam (Settings, Monitoring, Co4E & Shell): #### 🟣 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` - [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: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-25 08:30` | End: `2026-08-25 12:00`*
- [ ] **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`) - [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: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-25 13:00` | End: `2026-08-26 12:00`*
- [ ] **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` - [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: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-26 13:00` | End: `2026-08-27 15:00`*
- [ ] **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` - [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: `____-__-__ __:__` | End: `____-__-__ __:__`* *Start: `2026-08-27 15:00` | End: `2026-08-28 09:30`*
#### 🟢 Team Hoa (Workspace, Folder, Scheduling, Dashboard & Graph): #### 🟢 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) - [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] | | **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] | | **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` | [~] | | **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 | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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-22 18:59` | [~] | | **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`) | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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 | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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 | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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) ### 🟣 TEAM NAM (Automation Workflows, Co4E, Monitoring & Governance)
| Ngày | Task Cần Hoàn Thành | Start Time | End Time | Trạng Thái | | 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`) | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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 | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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 | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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 | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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 | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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 | | 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` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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) | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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) | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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` | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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 | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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 | `____-__-__ __:__` | `____-__-__ __:__` | [ ] | | **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/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 pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtCore import QFileSystemWatcher _PLAN_ICONS = {"pending": "○", "running": "▶", "done": "✓", "error": "✗"}
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
def _format_plan_steps(steps) -> str: def _format_plan_steps(steps) -> str:
+6
View File
@@ -346,3 +346,9 @@ class ChatView(QScrollArea):
def _scroll_to_bottom(self) -> None: def _scroll_to_bottom(self) -> None:
bar = self.verticalScrollBar() bar = self.verticalScrollBar()
bar.setValue(bar.maximum()) 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 ...i18n import on_language_changed, tr
from ...state import AppContext from ...state import AppContext
from ...theme import current_palette from ...theme import current_palette
from ...ui.chat_view import ChatView, ThinkingIndicator from .chat_bubble_style import ThinkingIndicator
from ...ui.composer import Composer from .chat_history_widget import ChatView
from .composer_widget import Composer
from ...ui.icons import collapse_right_icon, icon as app_icon from ...ui.icons import collapse_right_icon, icon as app_icon
from ...ui.osutil import is_image, open_path from ...ui.osutil import is_image, open_path
from ...ui.widgets import CollapsibleSection, CollapseStrip, PlanSection 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 ...i18n import on_language_changed, tr
from ...state import AppContext from ...state import AppContext
from ...theme import current_palette from ...theme import current_palette
from ...ui.chat_view import ChatView, ThinkingIndicator from .chat_bubble_style import ThinkingIndicator
from ...ui.composer import Composer from .chat_history_widget import ChatView
from .composer_widget import Composer
from ...ui.icons import collapse_right_icon, icon as app_icon from ...ui.icons import collapse_right_icon, icon as app_icon
from ...ui.osutil import is_image, open_path from ...ui.osutil import is_image, open_path
from ...ui.widgets import CollapsibleSection, CollapseStrip, PlanSection 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_label.setText(tr("composer.queue_label", n=len(self._queue)))
self.queue_box.setVisible(bool(self._queue)) self.queue_box.setVisible(bool(self._queue))
self.queue_changed.emit(len(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)