Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

27 changes: 13 additions & 14 deletions assets/composer/index.html
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1a1a1a" />
<title>Graphlink Composer</title>
<script type="module" crossorigin src="./assets/index-oTmdXyMk.js"></script>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#1a1a1a" />
<title>Graphlink Composer</title>
<script type="module" crossorigin src="./assets/index-BM0Y10OU.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-C2IuMzFh.css">
</head>
<body>
<div id="root"></div>

</body>
</html>
</head>
<body>
<div id="root"></div>
</body>
</html>
Expand Down
17 changes: 17 additions & 0 deletions composer_ui/src/ComposerApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { ComposerState, initialComposerState } from "./bridgeTypes";
import { ComposerBridge, createComposerBridge } from "./bridge";
import { isBusy, requestLabel } from "./state";

const LARGE_PASTE_CHAR_THRESHOLD = 1400;
const LARGE_PASTE_LINE_THRESHOLD = 24;

function Icon({ name }: { name: "attach" | "send" | "stop" | "chevron" }) {
const paths: Record<string, string> = {
attach: "M12 5.5 6.4 11.1a3.6 3.6 0 0 0 5.1 5.1l6-6a2.5 2.5 0 0 0-3.5-3.5l-6.1 6.1a1.35 1.35 0 0 0 1.9 1.9l5.5-5.5",
Expand Down Expand Up @@ -84,6 +87,19 @@ function ComposerApp() {
}
}

function onPaste(event: React.ClipboardEvent<HTMLTextAreaElement>) {
if (isRequestBusy || event.clipboardData.files.length > 0) return;

const pastedText = event.clipboardData.getData("text/plain");
const isLargePaste =
pastedText.length >= LARGE_PASTE_CHAR_THRESHOLD ||
pastedText.split("\n").length >= LARGE_PASTE_LINE_THRESHOLD;
if (!pastedText.trim() || !isLargePaste) return;

event.preventDefault();
bridgeRef.current?.stageTextAttachment(pastedText);
}

return (
<main
ref={shellRef}
Expand All @@ -96,6 +112,7 @@ function ComposerApp() {
value={state.draft.text}
onChange={(event) => bridgeRef.current?.updateDraft(event.target.value)}
onKeyDown={onKeyDown}
onPaste={onPaste}
placeholder={"Ask about this graph\u2026"}
aria-label="Message composer"
rows={1}
Expand Down
28 changes: 28 additions & 0 deletions composer_ui/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface QtComposerObject {
cancel: (requestId?: string) => void;
reviewContext: () => void;
requestAttachment: () => void;
stageTextAttachment: (text: string) => void;
removeContextItem: (itemId: string) => void;
selectModel: (modelId: string) => void;
setReasoningLevel: (level: string) => void;
Expand All @@ -39,6 +40,7 @@ export interface ComposerBridge {
cancel(requestId?: string): void;
reviewContext(): void;
requestAttachment(): void;
stageTextAttachment(text: string): void;
removeContextItem(itemId: string): void;
selectModel(modelId: string): void;
setReasoningLevel(level: string): void;
Expand Down Expand Up @@ -119,6 +121,31 @@ class MockComposerBridge implements ComposerBridge {

reviewContext(): void {}
requestAttachment(): void {}
stageTextAttachment(text: string): void {
const normalized = String(text || "");
if (!normalized.trim()) return;
const lineCount = normalized.split("\n").length;
const attachmentId = `paste-${this.state.revision + 1}`;
const attachment = {
id: attachmentId,
name: `Pasted Text (${lineCount} lines).txt`,
kind: "document",
tokenCount: 0,
preparationState: "ready",
contextLabel: "Text",
};
this.state = {
...this.state,
revision: this.state.revision + 1,
context: {
...this.state.context,
items: [...this.state.context.items, attachment],
reviewAvailable: true,
},
request: { ...this.state.request, canSend: true },
};
this.listener(this.state);
}
removeContextItem(): void {}
selectModel(modelId: string): void {
const option = this.state.route.modelOptions.find((item) => item.id === modelId);
Expand Down Expand Up @@ -203,6 +230,7 @@ export function createComposerBridge(listener: StateListener): ComposerBridge {
cancel: (requestId) => call("cancel", requestId),
reviewContext: () => call("reviewContext"),
requestAttachment: () => call("requestAttachment"),
stageTextAttachment: (text) => call("stageTextAttachment", text),
removeContextItem: (itemId) => call("removeContextItem", itemId),
selectModel: (modelId) => call("selectModel", modelId),
setReasoningLevel: (level) => call("setReasoningLevel", level),
Expand Down
8 changes: 8 additions & 0 deletions graphlink_app/graphlink_composer_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,14 @@ def requestAttachment(self):
if callable(attach_file):
attach_file()

@Slot(str)
def stageTextAttachment(self, text: str):
"""Turn a large pasted text payload into a native context attachment."""
stage_paste = getattr(self.window, "_handle_large_paste_from_input", None)
if callable(stage_paste):
stage_paste(str(text or ""))
self._publish()

@Slot(str)
def removeContextItem(self, item_id: str):
path = self._attachment_paths.get(str(item_id or ""))
Expand Down
107 changes: 69 additions & 38 deletions graphlink_app/graphlink_composer_popups.py
Original file line number Diff line number Diff line change
Expand Up @@ -471,11 +471,16 @@ def _request_reposition(self):
QTimer.singleShot(0, self._reposition_owner)

def _reposition_owner(self):
if not self.isVisible():
try:
if not self.isVisible():
return
reposition = getattr(self.parentWidget(), "_position_composer_picker", None)
if callable(reposition):
reposition()
except (AttributeError, RuntimeError, SystemError, TypeError):
# A queued reposition can run after the native popup has begun
# closing. Never call back into a partially destroyed Qt wrapper.
return
reposition = getattr(self.parentWidget(), "_position_composer_picker", None)
if callable(reposition):
reposition()

def _commit_item(self, item: QListWidgetItem | None):
if item is None or not (item.flags() & Qt.ItemFlag.ItemIsEnabled):
Expand All @@ -494,20 +499,27 @@ def _request_settings(self):
self.close()

def eventFilter(self, watched, event):
if event.type() == QEvent.Type.MouseButtonPress:
global_position = event.globalPosition().toPoint()
if not self.rect().contains(self.mapFromGlobal(global_position)):
self.close()
return False
if watched is self.search and event.type() == QEvent.Type.KeyPress:
if event.key() in (Qt.Key.Key_Down, Qt.Key.Key_Up):
self.list_widget.setFocus()
return True
return super().eventFilter(watched, event)
try:
if event.type() == QEvent.Type.MouseButtonPress:
global_position = event.globalPosition().toPoint()
if not self.rect().contains(self.mapFromGlobal(global_position)):
self.close()
return False
search = getattr(self, "search", None)
if search is not None and watched is search and event.type() == QEvent.Type.KeyPress:
if event.key() in (Qt.Key.Key_Down, Qt.Key.Key_Up):
self.list_widget.setFocus()
return True
return super().eventFilter(watched, event)
except (AttributeError, RuntimeError, SystemError, TypeError):
return False

def closeEvent(self, event):
if self._application is not None and self._event_filter_installed:
self._application.removeEventFilter(self)
try:
if self._application is not None and self._event_filter_installed:
self._application.removeEventFilter(self)
self._event_filter_installed = False
except (AttributeError, RuntimeError, SystemError, TypeError):
self._event_filter_installed = False
super().closeEvent(event)

Expand All @@ -518,15 +530,22 @@ def showEvent(self, event):
QTimer.singleShot(0, self._focus_default)

def _install_event_filter(self):
if self.isVisible() and self._application is not None and not self._event_filter_installed:
self._application.installEventFilter(self)
self._event_filter_installed = True
try:
if self.isVisible() and self._application is not None and not self._event_filter_installed:
self._application.installEventFilter(self)
self._event_filter_installed = True
except (AttributeError, RuntimeError, SystemError, TypeError):
self._event_filter_installed = False

def _focus_default(self):
if self.search is not None:
self.search.setFocus(Qt.FocusReason.PopupFocusReason)
else:
self.list_widget.setFocus(Qt.FocusReason.PopupFocusReason)
try:
search = getattr(self, "search", None)
if search is not None:
search.setFocus(Qt.FocusReason.PopupFocusReason)
else:
self.list_widget.setFocus(Qt.FocusReason.PopupFocusReason)
except (AttributeError, RuntimeError, SystemError, TypeError):
return

def keyPressEvent(self, event):
if event.key() == Qt.Key.Key_Escape:
Expand Down Expand Up @@ -781,11 +800,14 @@ def _request_reposition(self):
QTimer.singleShot(0, self._reposition_owner)

def _reposition_owner(self):
if not self.isVisible():
try:
if not self.isVisible():
return
reposition = getattr(self.parentWidget(), "_position_composer_context_popup", None)
if callable(reposition):
reposition()
except (AttributeError, RuntimeError, SystemError, TypeError):
return
reposition = getattr(self.parentWidget(), "_position_composer_context_popup", None)
if callable(reposition):
reposition()

def showEvent(self, event):
super().showEvent(event)
Expand All @@ -794,21 +816,30 @@ def showEvent(self, event):
QTimer.singleShot(0, self.setFocus)

def _install_event_filter(self):
if self.isVisible() and self._application is not None and not self._event_filter_installed:
self._application.installEventFilter(self)
self._event_filter_installed = True
try:
if self.isVisible() and self._application is not None and not self._event_filter_installed:
self._application.installEventFilter(self)
self._event_filter_installed = True
except (AttributeError, RuntimeError, SystemError, TypeError):
self._event_filter_installed = False

def eventFilter(self, watched, event):
if event.type() == QEvent.Type.MouseButtonPress:
global_position = event.globalPosition().toPoint()
if not self.rect().contains(self.mapFromGlobal(global_position)):
self.close()
return False
return super().eventFilter(watched, event)
try:
if event.type() == QEvent.Type.MouseButtonPress:
global_position = event.globalPosition().toPoint()
if not self.rect().contains(self.mapFromGlobal(global_position)):
self.close()
return False
return super().eventFilter(watched, event)
except (AttributeError, RuntimeError, SystemError, TypeError):
return False

def closeEvent(self, event):
if self._application is not None and self._event_filter_installed:
self._application.removeEventFilter(self)
try:
if self._application is not None and self._event_filter_installed:
self._application.removeEventFilter(self)
self._event_filter_installed = False
except (AttributeError, RuntimeError, SystemError, TypeError):
self._event_filter_installed = False
super().closeEvent(event)

Expand Down
29 changes: 29 additions & 0 deletions graphlink_app/graphlink_composer_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from PySide6.QtCore import QRect, QUrl, Qt, Signal
from PySide6.QtGui import QColor, QRegion
from PySide6.QtWidgets import (
QApplication,
QFrame,
QLabel,
QPushButton,
Expand Down Expand Up @@ -154,6 +155,7 @@ def __init__(self, window, controller: ComposerController | None = None, parent=
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self._corner_radius = 14
self._placeholder = "Ask about this graph…"
self._shutdown_started = False

# These hidden controls preserve the existing ChatWindow styling and
# request-state hooks while all visible interaction belongs to React.
Expand Down Expand Up @@ -193,6 +195,10 @@ def __init__(self, window, controller: ComposerController | None = None, parent=

self._apply_native_mask()

app = QApplication.instance()
if app is not None:
app.aboutToQuit.connect(self.prepare_for_shutdown)

def resizeEvent(self, event):
super().resizeEvent(event)
self._apply_native_mask()
Expand Down Expand Up @@ -249,6 +255,29 @@ def set_editor_enabled(self, enabled):
def on_theme_changed(self):
self.bridge._publish()

def prepare_for_shutdown(self):
"""Stop web content callbacks before Qt tears down the application."""
if self._shutdown_started:
return
self._shutdown_started = True
try:
self.bridge.dispose()
except (AttributeError, RuntimeError, SystemError, TypeError):
pass
web_view = self.web_view
if web_view is None:
return
try:
web_view.stop()
web_view.setUpdatesEnabled(False)
web_view.hide()
except (AttributeError, RuntimeError, SystemError, TypeError):
return

def closeEvent(self, event):
self.prepare_for_shutdown()
super().closeEvent(event)

def _apply_requested_height(self, height: int):
bounded = max(COMPOSER_MIN_HEIGHT, min(COMPOSER_MAX_HEIGHT, int(height)))
if self.height() == bounded:
Expand Down
Loading
Loading