feat(R08): finalize Chat UI Hub components, AudioRecorderWidget, and integration tests (100% PASS)
This commit is contained in:
@@ -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 | `____-__-__ __:__` | `____-__-__ __:__` | [ ] |
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"]
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user