diff --git a/graphlink_app/graphlink_composer.py b/graphlink_app/graphlink_composer.py new file mode 100644 index 0000000..52d1c06 --- /dev/null +++ b/graphlink_app/graphlink_composer.py @@ -0,0 +1,229 @@ +"""Composer domain state and request lifecycle primitives. + +The Composer used to be represented by a handful of widget fields plus flags on +ChatWindow. These small value objects provide one stable contract for the UI, +request preparation, and future streaming transports without coupling the +domain to graph widgets or a provider SDK. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from enum import Enum +from uuid import uuid4 + +from PySide6.QtCore import QObject, Signal + + +class ComposerRequestState(str, Enum): + IDLE = "idle" + PREPARING = "preparing" + UPLOADING = "uploading" + WAITING = "waiting" + GENERATING = "generating" + FINALIZING = "finalizing" + CANCELED = "canceled" + FAILED = "failed" + SUCCEEDED = "succeeded" + + +@dataclass +class ComposerAttachment: + attachment_id: str + path: str + name: str + kind: str + preparation_state: str = "ready" + error: str = "" + token_estimate: int = 0 + byte_size: int = 0 + context_label: str = "" + is_temp: bool = False + + @classmethod + def from_mapping(cls, item: dict) -> "ComposerAttachment": + return cls( + attachment_id=str(item.get("attachment_id") or uuid4().hex), + path=str(item.get("path") or ""), + name=str(item.get("name") or "Attachment"), + kind=str(item.get("kind") or "document"), + preparation_state=str(item.get("preparation_state") or "ready"), + error=str(item.get("error") or ""), + token_estimate=int(item.get("token_count") or item.get("token_estimate") or 0), + byte_size=int(item.get("byte_size") or 0), + context_label=str(item.get("context_label") or ""), + is_temp=bool(item.get("is_temp", False)), + ) + + def to_mapping(self) -> dict: + result = asdict(self) + result["token_count"] = result.pop("token_estimate") + return result + + +@dataclass +class ComposerDraft: + draft_id: str = field(default_factory=lambda: uuid4().hex) + text: str = "" + branch_anchor_id: str = "" + context_mode: str = "branch" + context_refs: list[str] = field(default_factory=list) + attachments: list[ComposerAttachment] = field(default_factory=list) + send_mode: str = "enter_to_send" + updated_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) + restored: bool = False + + def to_mapping(self) -> dict: + return { + "draft_id": self.draft_id, + "text": self.text, + "branch_anchor_id": self.branch_anchor_id, + "context_mode": self.context_mode, + "context_refs": list(self.context_refs), + "attachments": [item.to_mapping() for item in self.attachments], + "send_mode": self.send_mode, + "updated_at": self.updated_at, + "restored": self.restored, + } + + +@dataclass(frozen=True) +class ComposerRequestSnapshot: + request_id: str + draft_id: str + text: str + branch_anchor_id: str + context_mode: str + attachment_paths: tuple[str, ...] + created_at: str + + +class ComposerController(QObject): + """Owns draft identity and request transitions for one Composer surface.""" + + draftChanged = Signal(object) + stateChanged = Signal(str, str) + requestStarted = Signal(str) + requestFinished = Signal(str, str) + requestFailed = Signal(str, str) + requestCancelled = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self.draft = ComposerDraft() + self.state = ComposerRequestState.IDLE + self.state_message = "" + self.active_snapshot: ComposerRequestSnapshot | None = None + + @property + def active_request_id(self) -> str | None: + return self.active_snapshot.request_id if self.active_snapshot else None + + def update_text(self, text: str): + self.draft.text = str(text or "") + self._touch_draft() + + def set_branch(self, anchor_id: str = "", context_mode: str = "branch"): + self.draft.branch_anchor_id = str(anchor_id or "") + self.draft.context_mode = str(context_mode or "branch") + self._touch_draft() + + def set_context_refs(self, refs): + self.draft.context_refs = [str(ref) for ref in (refs or []) if str(ref)] + self._touch_draft() + + def set_attachments(self, attachments): + self.draft.attachments = [ + item if isinstance(item, ComposerAttachment) else ComposerAttachment.from_mapping(item) + for item in (attachments or []) + ] + self._touch_draft() + + def begin_request(self, *, text: str, attachments: list[dict] | None = None) -> str: + self.update_text(text) + if attachments is not None: + self.set_attachments(attachments) + request_id = uuid4().hex + self.active_snapshot = ComposerRequestSnapshot( + request_id=request_id, + draft_id=self.draft.draft_id, + text=self.draft.text, + branch_anchor_id=self.draft.branch_anchor_id, + context_mode=self.draft.context_mode, + attachment_paths=tuple(item.path for item in self.draft.attachments), + created_at=datetime.now(timezone.utc).isoformat(), + ) + self.set_state(ComposerRequestState.PREPARING, "Preparing context") + self.requestStarted.emit(request_id) + return request_id + + def mark_started(self, request_id: str, message: str = "Waiting for model") -> bool: + if request_id != self.active_request_id: + return False + self.set_state(ComposerRequestState.WAITING, message) + return True + + def is_current(self, request_id: str | None) -> bool: + return bool(request_id and request_id == self.active_request_id) + + def complete(self, request_id: str, message: str = "") -> bool: + if not self.is_current(request_id): + return False + self.set_state(ComposerRequestState.SUCCEEDED, message) + self.requestFinished.emit(request_id, message) + self.active_snapshot = None + self.set_state(ComposerRequestState.IDLE, "") + return True + + def fail(self, request_id: str | None, message: str) -> bool: + if request_id and not self.is_current(request_id): + return False + active_id = request_id or self.active_request_id or "" + self.set_state(ComposerRequestState.FAILED, message) + self.requestFailed.emit(active_id, message) + self.active_snapshot = None + return True + + def cancel(self, request_id: str | None) -> bool: + if request_id and not self.is_current(request_id): + return False + active_id = request_id or self.active_request_id or "" + self.set_state(ComposerRequestState.CANCELED, "Request canceled") + self.requestCancelled.emit(active_id) + self.active_snapshot = None + return True + + def clear_after_success(self): + self.draft.text = "" + self.draft.attachments = [] + self.draft.restored = False + self._touch_draft() + + def serialize_draft(self) -> dict: + return self.draft.to_mapping() + + def restore_draft(self, payload: dict | None) -> ComposerDraft: + payload = payload if isinstance(payload, dict) else {} + self.draft = ComposerDraft( + draft_id=str(payload.get("draft_id") or uuid4().hex), + text=str(payload.get("text") or ""), + branch_anchor_id=str(payload.get("branch_anchor_id") or ""), + context_mode=str(payload.get("context_mode") or "branch"), + context_refs=[str(ref) for ref in payload.get("context_refs", []) if str(ref)], + attachments=[ComposerAttachment.from_mapping(item) for item in payload.get("attachments", [])], + send_mode=str(payload.get("send_mode") or "enter_to_send"), + updated_at=str(payload.get("updated_at") or datetime.now(timezone.utc).isoformat()), + restored=bool(payload.get("text") or payload.get("attachments")), + ) + self.draftChanged.emit(self.draft) + return self.draft + + def set_state(self, state: ComposerRequestState, message: str = ""): + self.state = ComposerRequestState(state) + self.state_message = str(message or "") + self.stateChanged.emit(self.state.value, self.state_message) + + def _touch_draft(self): + self.draft.updated_at = datetime.now(timezone.utc).isoformat() + self.draftChanged.emit(self.draft) diff --git a/graphlink_app/graphlink_widgets/__init__.py b/graphlink_app/graphlink_widgets/__init__.py index c050c24..614281a 100644 --- a/graphlink_app/graphlink_widgets/__init__.py +++ b/graphlink_app/graphlink_widgets/__init__.py @@ -1,6 +1,7 @@ """Widget package for reusable Graphlink UI components.""" from .controls import FontControl, GridControl +from .composer import ComposerWidget from .overlays import GhostNodePreview, LoadingAnimation, SearchOverlay from .pins import NavigationPin, PinOverlay from .scrolling import CustomScrollArea, CustomScrollBar, ScrollBar, ScrollHandle @@ -16,6 +17,7 @@ 'CustomScrollArea', 'CustomScrollBar', 'CustomTooltip', + 'ComposerWidget', 'FontControl', 'GhostNodePreview', 'GridControl', diff --git a/graphlink_app/graphlink_widgets/composer.py b/graphlink_app/graphlink_widgets/composer.py new file mode 100644 index 0000000..2061192 --- /dev/null +++ b/graphlink_app/graphlink_widgets/composer.py @@ -0,0 +1,260 @@ +"""Unified, accessible Composer shell for graph conversations.""" + +from __future__ import annotations + +import os + +import qtawesome as qta +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from graphlink_config import get_current_palette +from .text_inputs import ChatInputTextEdit + + +class ComposerWidget(QFrame): + """The single interaction surface for composing a graph request. + + The widget deliberately keeps the existing ``ChatInputTextEdit`` contract + so the window can migrate incrementally, while making context, routing, + request state, and recovery affordances visible in one place. + """ + + sendRequested = Signal() + textChanged = Signal(str) + attachRequested = Signal() + filesDropped = Signal(list) + textDropped = Signal(str) + attachmentRemoved = Signal(str) + largePasteDetected = Signal(str) + composerHeightChanged = Signal(int) + contextReviewRequested = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("composerShell") + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self._context_anchor = None + self._request_active = False + self._request_message = "" + self._attachments = [] + + root = QVBoxLayout(self) + root.setContentsMargins(12, 8, 12, 8) + root.setSpacing(6) + + header = QHBoxLayout() + header.setContentsMargins(2, 0, 2, 0) + header.setSpacing(8) + self.context_label = QLabel("New graph request", self) + self.context_label.setObjectName("composerContextLabel") + self.context_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.context_label.setAccessibleName("Graph context") + header.addWidget(self.context_label) + + self.context_review_button = QPushButton("Review context", self) + self.context_review_button.setObjectName("composerSecondaryButton") + self.context_review_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.context_review_button.setAccessibleName("Review graph context") + self.context_review_button.setToolTip("Review which graph context will be sent") + self.context_review_button.clicked.connect(self.contextReviewRequested.emit) + header.addWidget(self.context_review_button) + root.addLayout(header) + + self.message_input = ChatInputTextEdit(self) + self.message_input.setPlaceholderText("Ask about this graph…") + self.message_input.sendRequested.connect(self.sendRequested.emit) + self.message_input.largePasteDetected.connect(self.largePasteDetected.emit) + self.message_input.filesDropped.connect(self.filesDropped.emit) + self.message_input.textDropped.connect(self.textDropped.emit) + self.message_input.attachmentRemoved.connect(self.attachmentRemoved.emit) + self.message_input.editor.textChanged.connect(lambda: self.textChanged.emit(self.text())) + self.message_input.composerHeightChanged.connect(self._sync_height) + self.message_input.setAccessibleName("Message composer") + root.addWidget(self.message_input) + + action_row = QHBoxLayout() + action_row.setContentsMargins(0, 0, 0, 0) + action_row.setSpacing(8) + self.attach_file_btn = QPushButton(self) + self.attach_file_btn.setObjectName("composerAttachButton") + self.attach_file_btn.setIcon(qta.icon("fa5s.paperclip", color="#cfd6de")) + self.attach_file_btn.setFixedHeight(34) + self.attach_file_btn.setMinimumWidth(34) + self.attach_file_btn.setAccessibleName("Attach context") + self.attach_file_btn.setToolTip("Attach images, audio, or readable files") + self.attach_file_btn.clicked.connect(self.attachRequested.emit) + action_row.addWidget(self.attach_file_btn) + + self.context_summary = QLabel("No attachments", self) + self.context_summary.setObjectName("composerMetaLabel") + self.context_summary.setAccessibleName("Attachment summary") + action_row.addWidget(self.context_summary) + + action_row.addStretch(1) + self.provider_status = QLabel("Active provider route", self) + self.provider_status.setObjectName("composerMetaLabel") + self.provider_status.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + self.provider_status.setAccessibleName("Model route") + action_row.addWidget(self.provider_status) + + self.send_button = QPushButton(self) + self.send_button.setObjectName("composerSendButton") + self.send_button.setIcon(qta.icon("fa5s.paper-plane", color="#ffffff")) + self.send_button.setFixedSize(42, 34) + self.send_button.setAccessibleName("Send message") + self.send_button.setToolTip("Send message (Enter)") + self.send_button.clicked.connect(self.sendRequested.emit) + action_row.addWidget(self.send_button) + root.addLayout(action_row) + + self.status_label = QLabel(self) + self.status_label.setObjectName("composerStatusLabel") + self.status_label.setVisible(False) + self.status_label.setAccessibleName("Request status") + root.addWidget(self.status_label) + + self._apply_styles() + self._sync_height() + + # Compatibility surface used by the existing ChatWindow and actions mixin. + def text(self): + return self.message_input.text() + + def setText(self, text): + self.message_input.setText(text) + + def clear(self): + self.message_input.clear() + + def insertPlainText(self, text): + self.message_input.insertPlainText(text) + + def setPlaceholderText(self, text): + self.message_input.setPlaceholderText(text) + + def setFocus(self): + self.message_input.setFocus() + + def focusWidget(self): + return self.message_input.focusWidget() + + def on_theme_changed(self): + self.message_input.on_theme_changed() + self._apply_styles() + + def set_context_items(self, items): + self._attachments = list(items or []) + self.message_input.set_context_items(self._attachments) + self._update_context_summary() + + def set_context_anchor(self, node): + self._context_anchor = node + if node is None: + label = "New graph request" + else: + label = getattr(node, "title", None) or getattr(node, "text", None) or type(node).__name__ + label = " ".join(str(label).split()) + if len(label) > 46: + label = f"{label[:43]}…" + label = f"Responding to {label}" + self.context_label.setText(label) + self.context_review_button.setEnabled(node is not None or bool(self._attachments)) + + def set_provider_status(self, text, tooltip=""): + self.provider_status.setText(str(text or "Active provider route")) + self.provider_status.setToolTip(str(tooltip or text or "")) + + def set_request_state(self, active=False, cancel_pending=False, message=""): + self._request_active = bool(active) + self._request_message = str(message or "") + self.send_button.setEnabled(not cancel_pending) + self.attach_file_btn.setEnabled(not active) + self.message_input.setEnabled(not active) + self.status_label.setVisible(bool(active or message)) + self.status_label.setText(self._request_message) + self.status_label.setProperty("requestActive", bool(active)) + self._apply_styles() + + def set_editor_enabled(self, enabled): + self.message_input.setEnabled(bool(enabled)) + + def setEnabled(self, enabled): + # Preserve QWidget semantics for callers that intentionally disable the + # whole shell, while request lifecycle code should use set_editor_enabled. + super().setEnabled(enabled) + + def _update_context_summary(self): + count = len(self._attachments) + if not count: + self.context_summary.setText("No attachments") + elif count == 1: + item = self._attachments[0] + self.context_summary.setText(f"1 attachment · {item.get('name', 'file')}") + else: + self.context_summary.setText(f"{count} attachments ready") + self.context_review_button.setEnabled(self._context_anchor is not None or count > 0) + + def _sync_height(self, *_): + self.adjustSize() + self.updateGeometry() + self.composerHeightChanged.emit(self.sizeHint().height()) + + def _apply_styles(self): + palette = get_current_palette() + selection = palette.SELECTION.name() + self.setStyleSheet(f""" + QFrame#composerShell {{ + background-color: rgba(30, 34, 40, 245); + border: 1px solid rgba(132, 145, 160, 0.34); + border-radius: 14px; + }} + QLabel#composerContextLabel {{ + color: #f1f5f9; + font-size: 12px; + font-weight: 600; + }} + QLabel#composerMetaLabel {{ + color: #99a6b4; + font-size: 11px; + }} + QLabel#composerStatusLabel {{ + color: #a9d5ff; + font-size: 11px; + padding: 2px 4px; + }} + QPushButton#composerSecondaryButton, QPushButton#composerAttachButton {{ + color: #cdd6df; + background: transparent; + border: 1px solid rgba(150, 164, 180, 0.34); + border-radius: 8px; + padding: 5px 9px; + }} + QPushButton#composerSecondaryButton:hover, QPushButton#composerAttachButton:hover {{ + color: #ffffff; + border-color: {selection}; + background: rgba(255, 255, 255, 0.06); + }} + QPushButton#composerSendButton {{ + color: #ffffff; + background: {selection}; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 9px; + padding: 5px; + }} + QPushButton#composerSendButton:hover {{ + background: {QColor(palette.SELECTION).lighter(115).name()}; + }} + QPushButton#composerSendButton:disabled {{ + background: rgba(100, 110, 122, 0.55); + }} + """) diff --git a/graphlink_app/graphlink_window.py b/graphlink_app/graphlink_window.py index 9b83e98..1ef86e6 100644 --- a/graphlink_app/graphlink_window.py +++ b/graphlink_app/graphlink_window.py @@ -10,7 +10,7 @@ import tempfile from datetime import datetime -from graphlink_widgets import PinOverlay, SearchOverlay, TokenCounterWidget, TokenEstimator, ChatInputTextEdit +from graphlink_widgets import PinOverlay, SearchOverlay, TokenCounterWidget, TokenEstimator, ComposerWidget from graphlink_ui_components import NotificationBanner, DocumentViewerPanel from graphlink_canvas_items import Note, Frame, Container from graphlink_node import ChatNode, CodeNode, ThinkingNode @@ -48,6 +48,7 @@ from graphlink_update import APP_VERSION, UpdateCheckWorker from graphlink_paths import asset_path from graphlink_crash import mark_clean_exit +from graphlink_composer import ComposerController class ChatWindow(QMainWindow, WindowActionsMixin, WindowNavigationMixin): def __init__(self, settings_manager): @@ -88,6 +89,7 @@ def __init__(self, settings_manager): self._main_request_active = False self._main_request_cancel_pending = False self._main_request_cancel_callback = None + self.composer_controller = ComposerController(self) self.container = QWidget() container_layout = QVBoxLayout(self.container) @@ -133,49 +135,32 @@ def __init__(self, settings_manager): container_layout.addWidget(content_widget) - self.input_widget = QWidget() - self.input_widget.setObjectName("chatInputRow") - self.input_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.input_widget.setStyleSheet(""" - QWidget#chatInputRow { - background: transparent; - border: none; - } - """) - input_layout = QHBoxLayout(self.input_widget) - input_layout.setContentsMargins(8, 10, 8, 10) - input_layout.setSpacing(8) - + self.composer = ComposerWidget(self) + self.input_widget = self.composer self.pending_attachments = [] - self.attach_file_btn = QPushButton() - self.attach_file_btn.setIcon(qta.icon('fa5s.paperclip', color='#cccccc')) - self.attach_file_btn.setFixedSize(40, 40) - self.attach_file_btn.clicked.connect(self.attach_file) - - self.message_input = ChatInputTextEdit() - self.message_input.setPlaceholderText("Type your message...") + self.attach_file_btn = self.composer.attach_file_btn + self.message_input = self.composer self.message_input.sendRequested.connect(self.send_message) self.message_input.largePasteDetected.connect(self._handle_large_paste_from_input) self.message_input.filesDropped.connect(self._handle_input_files_dropped) self.message_input.textDropped.connect(self._handle_input_text_dropped) self.message_input.attachmentRemoved.connect(self._handle_attachment_pill_removed) self.message_input.composerHeightChanged.connect(self._sync_footer_height) - - self.send_button = QPushButton(); self.send_button.setFixedSize(40, 40) - input_layout.addWidget(self.attach_file_btn); input_layout.addWidget(self.message_input); input_layout.addWidget(self.send_button) - input_layout.setAlignment(self.attach_file_btn, Qt.AlignmentFlag.AlignBottom) - input_layout.setAlignment(self.send_button, Qt.AlignmentFlag.AlignBottom) + self.send_button = self.composer.send_button self.bottom_container = QWidget() self.bottom_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.bottom_container.setMinimumHeight(68) bottom_layout = QVBoxLayout(self.bottom_container) - bottom_layout.setContentsMargins(0,0,0,0); bottom_layout.setSpacing(0); bottom_layout.addWidget(self.input_widget) + bottom_layout.setContentsMargins(8, 0, 8, 8); bottom_layout.setSpacing(0); bottom_layout.addWidget(self.input_widget) container_layout.addWidget(self.bottom_container) self.setCentralWidget(self.container) self._update_themed_styles() self.send_button.clicked.connect(self._handle_send_button_click) + self.composer.textChanged.connect(self.composer_controller.update_text) + self.composer_controller.draftChanged.connect(self._handle_composer_draft_changed) + self.composer_controller.stateChanged.connect(self._handle_composer_state_changed) self._sync_footer_height() self.current_node = None @@ -302,7 +287,10 @@ def _set_main_request_state(self, *, active: bool, cancel_callback=None, cancel_ self._main_request_cancel_callback = cancel_callback if active else None if active: self.send_button.setEnabled(not self._main_request_cancel_pending) - self.message_input.setEnabled(False) + if hasattr(self.message_input, 'set_editor_enabled'): + self.message_input.set_editor_enabled(False) + else: + self.message_input.setEnabled(False) self.attach_file_btn.setEnabled(False) else: self.send_button.setEnabled(True) @@ -318,6 +306,22 @@ def on_settings_changed(self): self.token_counter_widget.setVisible(self.settings_manager.get_show_token_counter()) self._update_overlay_positions() self.reinitialize_agent() + self._refresh_composer_provider_status() + + def _refresh_composer_provider_status(self): + if not hasattr(self, 'composer'): + return + mode = self.settings_manager.get_current_mode() or "Active provider route" + if mode == config.MODE_API_ENDPOINT: + provider = self.settings_manager.get_api_provider() or "Cloud API" + label = f"Cloud · {provider}" + elif mode == config.MODE_LLAMACPP_LOCAL: + label = "Local · llama.cpp" + elif mode == config.MODE_OLLAMA_LOCAL: + label = "Local · Ollama" + else: + label = str(mode) + self.composer.set_provider_status(label, f"Requests follow the active mode: {mode}") def start_with_prompt(self, prompt: str): if prompt: @@ -570,6 +574,19 @@ def _sync_footer_height(self, *_): if self.container.layout(): self.container.layout().activate() self._schedule_overlay_update() + + def _handle_composer_draft_changed(self, draft): + """Keep the legacy attachment list and new draft model in sync.""" + if draft is None: + return + if self.composer.text() != draft.text: + self.composer.setText(draft.text) + + def _handle_composer_state_changed(self, state, message): + if not hasattr(self, 'composer'): + return + active_states = {"preparing", "uploading", "waiting", "generating", "finalizing"} + self.composer.set_request_state(state in active_states, self._main_request_cancel_pending, message) def show_search_overlay(self): if not self.search_overlay: @@ -905,6 +922,7 @@ def on_mode_changed(self, index): self._initialize_mode(mode_text, show_dialogs=True) self.settings_manager.set_current_mode(mode_text) self.reinitialize_agent() + self._refresh_composer_provider_status() if mode_text == config.MODE_LLAMACPP_LOCAL: self.notification_banner.show_message( "Llama.cpp is configured. The GGUF will load on the first request instead of blocking startup or mode switching.", @@ -944,9 +962,12 @@ def _initialize_saved_mode_on_startup(self): self.settings_manager.set_current_mode(fallback_mode) api_provider.initialize_local_provider(config.LOCAL_PROVIDER_OLLAMA) self.reinitialize_agent() + self._refresh_composer_provider_status() def setCurrentNode(self, node): self.current_node = node; text_content = "" + if hasattr(self, 'composer'): + self.composer.set_context_anchor(node) if isinstance(node, ChatNode): text_content = node.text if node.text else "[Attachment/Content Node]" elif isinstance(node, PyCoderNode): text_content = "Py-Coder Analysis" elif isinstance(node, CodeSandboxNode): text_content = "Execution Sandbox" @@ -1067,6 +1088,8 @@ def _refresh_attachment_button(self): self.attach_file_btn.setToolTip("Attach images, audio, or readable files") if hasattr(self, 'message_input'): self.message_input.set_context_items([]) + if hasattr(self, 'composer_controller'): + self.composer_controller.set_attachments([]) return palette = get_current_palette() @@ -1093,6 +1116,8 @@ def _refresh_attachment_button(self): self.attach_file_btn.setToolTip("\n".join(tooltip_lines)) if hasattr(self, 'message_input'): self.message_input.set_context_items(self.pending_attachments) + if hasattr(self, 'composer_controller'): + self.composer_controller.set_attachments(self.pending_attachments) def clear_attachment(self): for item in self.pending_attachments: @@ -1108,20 +1133,11 @@ def clear_attachment(self): self._refresh_attachment_button() def _handle_large_paste_from_input(self, pasted_text): - stage_result, _ = self._stage_large_paste_as_attachment(pasted_text) - if stage_result == "added": - self.notification_banner.show_message( - "Large paste captured as an attachment. Add instructions and send.", - 4500, - "success", - ) - return - self.message_input.insertPlainText(pasted_text) self.notification_banner.show_message( - "Could not stage large paste as an attachment. Inserted into input instead.", - 6000, - "warning", + "Large paste kept in the draft. Attach it explicitly when it should be treated as context.", + 5000, + "info", ) def _stage_large_paste_as_attachment(self, pasted_text): @@ -1151,22 +1167,13 @@ def _handle_input_files_dropped(self, file_paths): self.message_input.setFocus() def _handle_input_text_dropped(self, dropped_text): - stage_result, _ = self._stage_text_context_attachment(dropped_text) - if stage_result == "added": - self.notification_banner.show_message( - "Context staged from drop. Add instructions and send.", - 4000, - "success", - ) - self.message_input.setFocus() - return - self.message_input.insertPlainText(dropped_text) self.notification_banner.show_message( - "Could not stage dropped text as context. Inserted into input instead.", + "Dropped text inserted into the draft. Attach it explicitly when it should be sent as context.", 5000, - "warning", + "info", ) + self.message_input.setFocus() def _handle_attachment_pill_removed(self, attachment_path): if not attachment_path: @@ -1289,7 +1296,7 @@ def handle_error(self, error_message): self._clear_loading_animation() self._clear_pending_response_preview() self.notification_banner.show_message(f"An error occurred:\n{error_message}", 15000, "error") - self.message_input.setEnabled(True); self.send_button.setEnabled(True); self.attach_file_btn.setEnabled(True); self.clear_attachment() + self.message_input.setEnabled(True); self.send_button.setEnabled(True); self.attach_file_btn.setEnabled(True) def _get_single_selected_node(self): selected_items = self.chat_view.scene().selectedItems(); valid_types = (ChatNode, PyCoderNode, CodeSandboxNode, WebNode, ConversationNode, HtmlViewNode, GitlinkNode) @@ -1311,5 +1318,5 @@ def new_chat(self, parent_for_dialog=None): self._clear_loading_animation() self._clear_pending_response_preview() if hasattr(self, 'pin_overlay') and self.pin_overlay: self.pin_overlay.clear_pins() - self.session_manager.mark_context_switch(); self.session_manager.current_chat_id = None; scene.clear(); self.current_node = None; self.message_input.setPlaceholderText("Type your message..."); self.update_title_bar(); self.reset_token_counter(); return True + self.session_manager.mark_context_switch(); self.session_manager.current_chat_id = None; scene.clear(); self.current_node = None; self.message_input.clear(); self.clear_attachment(); self.message_input.set_context_anchor(None); self.message_input.setPlaceholderText("Type your message..."); self.update_title_bar(); self.reset_token_counter(); return True return False diff --git a/graphlink_app/graphlink_window_actions.py b/graphlink_app/graphlink_window_actions.py index 2547c4f..b1fb0fa 100644 --- a/graphlink_app/graphlink_window_actions.py +++ b/graphlink_app/graphlink_window_actions.py @@ -7,6 +7,7 @@ import api_provider from graphlink_prompts import _TokenBytesEncoder from graphlink_widgets import GhostNodePreview, LoadingAnimation +from graphlink_composer import ComposerRequestState from graphlink_node import ChatNode, CodeNode from graphlink_canvas_items import Note from graphlink_connections import GroupSummaryConnectionItem @@ -162,7 +163,9 @@ def send_message(self): if not message and not attachments: return - self.message_input.setEnabled(False) + request_id = self.composer_controller.begin_request(text=message, attachments=attachments) + + self.message_input.set_editor_enabled(False) if hasattr(self.message_input, 'set_editor_enabled') else self.message_input.setEnabled(False) self.send_button.setEnabled(False) self.attach_file_btn.setEnabled(False) @@ -180,6 +183,7 @@ def send_message(self): conversation_history=history ) if user_node is None: + self.composer_controller.fail(request_id, "Unable to add the message node to the scene.") self.handle_error("Unable to add the message node to the scene.") return @@ -221,6 +225,7 @@ def send_message(self): if doc_content is None: doc_content, error = self.file_handler.read_file(attachment_path) if error: + self.composer_controller.fail(request_id, error) self.handle_error(error) user_node.scene().delete_chat_node(user_node) return @@ -239,6 +244,7 @@ def send_message(self): 'text': self._wrap_attachment_xml(attachment, doc_content), }) except IOError as e: + self.composer_controller.fail(request_id, f"Could not read attachment '{attachment_path}': {e}") self.handle_error(f"Could not read attachment '{attachment_path}': {e}") user_node.scene().delete_chat_node(user_node) return @@ -280,21 +286,24 @@ def send_message(self): active=True, cancel_callback=lambda thread=worker_thread: self._cancel_main_chat_request(thread), ) + self.composer_controller.mark_started(request_id) worker_thread.finished.connect( - lambda new_message, node=user_node, history=history_for_worker, tokens=input_tokens, thread=worker_thread: - self.handle_response(new_message, node, history, tokens, thread) + lambda new_message, node=user_node, history=history_for_worker, tokens=input_tokens, thread=worker_thread, rid=request_id: + self.handle_response(new_message, node, history, tokens, thread, rid) ) worker_thread.status.connect(self._handle_chat_worker_status) - worker_thread.error.connect(lambda error_message, thread=worker_thread: self._handle_main_chat_error(error_message, thread)) - worker_thread.cancelled.connect(lambda thread=worker_thread: self._handle_main_chat_cancelled(thread)) + worker_thread.error.connect(lambda error_message, thread=worker_thread, rid=request_id: self._handle_main_chat_error(error_message, thread, rid)) + worker_thread.cancelled.connect(lambda thread=worker_thread, rid=request_id: self._handle_main_chat_cancelled(thread, rid)) worker_thread.finished.connect(lambda _message, thread=worker_thread: self._cleanup_main_chat_thread(thread)) worker_thread.error.connect(lambda _error, thread=worker_thread: self._cleanup_main_chat_thread(thread)) worker_thread.cancelled.connect(lambda thread=worker_thread: self._cleanup_main_chat_thread(thread)) worker_thread.start() - def handle_response(self, new_assistant_message, user_node, history_before_assistant, input_tokens, worker_thread=None): + def handle_response(self, new_assistant_message, user_node, history_before_assistant, input_tokens, worker_thread=None, request_id=None): if worker_thread is not None and self.chat_thread is not worker_thread: return + if request_id and not self.composer_controller.is_current(request_id): + return scene = self.chat_view.scene() if not user_node or user_node.scene() is None or user_node.scene() is not scene: @@ -353,15 +362,20 @@ def handle_response(self, new_assistant_message, user_node, history_before_assis self.current_node = last_created_node if last_created_node else user_node self.chat_view.reveal_item(self.current_node) self.message_input.clear() - self.message_input.setEnabled(True) + self.message_input.set_editor_enabled(True) if hasattr(self.message_input, 'set_editor_enabled') else self.message_input.setEnabled(True) self.send_button.setEnabled(True) self.attach_file_btn.setEnabled(True) self.clear_attachment() + if request_id: + self.composer_controller.complete(request_id, "Response ready") + self.composer_controller.clear_after_success() self.save_chat() def _handle_chat_worker_status(self, message): if not message: return + if getattr(self, 'composer_controller', None) and self.composer_controller.active_request_id: + self.composer_controller.set_state(ComposerRequestState.GENERATING, message) self.notification_banner.show_message(message, 7000, "info") def _parse_response(self, response_text): @@ -408,7 +422,7 @@ def regenerate_node(self, node_to_regenerate): return history_for_worker = get_node_history(node_to_regenerate.parent_node) - self.message_input.setEnabled(False) + self.message_input.set_editor_enabled(False) if hasattr(self.message_input, 'set_editor_enabled') else self.message_input.setEnabled(False) self.send_button.setEnabled(False) self.attach_file_btn.setEnabled(False) self._show_loading_animation(anchor_node=node_to_regenerate) @@ -472,7 +486,7 @@ def handle_regenerated_response(self, new_assistant_message, old_node, parent_hi self.handle_error(f"An error occurred during regeneration: {str(e)}") finally: self._clear_loading_animation() - self.message_input.setEnabled(True) + self.message_input.set_editor_enabled(True) if hasattr(self.message_input, 'set_editor_enabled') else self.message_input.setEnabled(True) self.send_button.setEnabled(True) self.attach_file_btn.setEnabled(True) @@ -1278,19 +1292,27 @@ def _cancel_main_chat_request(self, worker_thread): return worker_thread.cancel() - def _handle_main_chat_error(self, error_message, worker_thread): + def _handle_main_chat_error(self, error_message, worker_thread, request_id=None): if worker_thread is not self.chat_thread: return + if request_id and not self.composer_controller.is_current(request_id): + return + if request_id: + self.composer_controller.fail(request_id, error_message) self._set_main_request_state(active=False) self.handle_error(error_message) - def _handle_main_chat_cancelled(self, worker_thread): + def _handle_main_chat_cancelled(self, worker_thread, request_id=None): if worker_thread is not self.chat_thread: return + if request_id and not self.composer_controller.is_current(request_id): + return + if request_id: + self.composer_controller.cancel(request_id) self._set_main_request_state(active=False) self._clear_loading_animation() self._clear_pending_response_preview() - self.message_input.setEnabled(True) + self.message_input.set_editor_enabled(True) if hasattr(self.message_input, 'set_editor_enabled') else self.message_input.setEnabled(True) self.send_button.setEnabled(True) self.attach_file_btn.setEnabled(True) self.save_chat() @@ -1301,7 +1323,7 @@ def _handle_regeneration_cancelled(self, worker_thread): return self._set_main_request_state(active=False) self._clear_loading_animation() - self.message_input.setEnabled(True) + self.message_input.set_editor_enabled(True) if hasattr(self.message_input, 'set_editor_enabled') else self.message_input.setEnabled(True) self.send_button.setEnabled(True) self.attach_file_btn.setEnabled(True) self.notification_banner.show_message("Regeneration cancelled.", 3000, "info") diff --git a/graphlink_app/tests/test_composer.py b/graphlink_app/tests/test_composer.py new file mode 100644 index 0000000..9fd34a9 --- /dev/null +++ b/graphlink_app/tests/test_composer.py @@ -0,0 +1,78 @@ +"""Regression coverage for Composer draft and request lifecycle contracts.""" + +from PySide6.QtTest import QSignalSpy + +from graphlink_composer import ComposerAttachment, ComposerController, ComposerRequestState +from graphlink_widgets import ComposerWidget + + +def test_controller_request_snapshot_is_immutable_and_tracks_attachments(): + controller = ComposerController() + attachment = {"path": "C:/notes.md", "name": "notes.md", "kind": "document"} + + request_id = controller.begin_request(text="Summarize this", attachments=[attachment]) + + assert controller.state is ComposerRequestState.PREPARING + assert controller.active_snapshot.text == "Summarize this" + assert controller.active_snapshot.attachment_paths == ("C:/notes.md",) + assert controller.is_current(request_id) + + +def test_controller_ignores_stale_completion_and_preserves_failed_draft(): + controller = ComposerController() + controller.update_text("Keep this if the request fails") + controller.set_attachments([ComposerAttachment("a", "a.txt", "a.txt", "document")]) + request_id = controller.begin_request(text=controller.draft.text) + + assert not controller.complete("stale", "wrong request") + assert controller.fail(request_id, "Provider unavailable") + assert controller.state is ComposerRequestState.FAILED + assert controller.draft.text == "Keep this if the request fails" + assert len(controller.draft.attachments) == 1 + + +def test_controller_round_trips_restored_draft_and_clears_only_after_success(): + controller = ComposerController() + controller.restore_draft({ + "draft_id": "saved-draft", + "text": "Continue from here", + "context_mode": "selection", + "context_refs": ["node-1"], + "attachments": [{"path": "a.py", "name": "a.py", "kind": "document", "token_count": 12}], + }) + + assert controller.draft.restored + assert controller.draft.draft_id == "saved-draft" + assert controller.draft.context_refs == ["node-1"] + assert controller.serialize_draft()["attachments"][0]["token_count"] == 12 + + request_id = controller.begin_request(text=controller.draft.text) + assert controller.complete(request_id) + assert controller.draft.text == "Continue from here" + controller.clear_after_success() + assert controller.draft.text == "" + assert controller.draft.attachments == [] + + +def test_composer_exposes_visible_context_and_accessible_actions(): + composer = ComposerWidget() + composer.set_context_anchor(type("Node", (), {"title": "Chart analysis"})()) + composer.set_context_items([{"path": "chart.csv", "name": "chart.csv", "kind": "document"}]) + + assert composer.context_label.text() == "Responding to Chart analysis" + assert "1 attachment" in composer.context_summary.text() + assert composer.send_button.accessibleName() == "Send message" + assert composer.attach_file_btn.accessibleName() == "Attach context" + assert composer.context_review_button.isEnabled() + + +def test_composer_forwards_send_and_preserves_text_on_clear_boundary(): + composer = ComposerWidget() + spy = QSignalSpy(composer.sendRequested) + composer.setText("hello graph") + composer.send_button.click() + + assert spy.count() == 1 + assert composer.text() == "hello graph" + composer.clear() + assert composer.text() == "" diff --git a/pyproject.toml b/pyproject.toml index 6da9f80..4453c10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,7 @@ py-modules = [ "graphlink_canvas_items", "graphlink_canvas_note_items", "graphlink_command_palette", + "graphlink_composer", "graphlink_config", "graphlink_connections", "graphlink_conversation_node",