From 2f45509af308d8d0547fae473b291eca27babbdc Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Fri, 9 Oct 2026 14:53:36 +0200 Subject: [PATCH] gui: use stable blockId instead of index for bookmarks/plumb - Add blockId field to ChatBlock (12-char MD5 hash of counter+name) - Bookmarks now use blockId instead of volatile list index - Plumb URLs use blockId: ollie://session/agent#abc123def456 - Add indexForBlockId() to look up current index from stable ID - Action buttons always visible (no hover flicker) --- kde/gui/ChatBlockDelegate.qml | 20 ++++++++------------ kde/gui/ChatPane.qml | 6 ++++++ kde/gui/SessionTree.qml | 7 +++---- kde/gui/chatblockmodel.cpp | 17 +++++++++++++++++ kde/gui/chatblockmodel.h | 4 ++++ kde/gui/main.qml | 30 +++++++++++++++--------------- 6 files changed, 53 insertions(+), 31 deletions(-) diff --git a/kde/gui/ChatBlockDelegate.qml b/kde/gui/ChatBlockDelegate.qml index d0c3a63..cfd2adf 100644 --- a/kde/gui/ChatBlockDelegate.qml +++ b/kde/gui/ChatBlockDelegate.qml @@ -18,15 +18,13 @@ Column { required property var blockSegments required property string blockRenderedHtml required property int index - - // Block index for bookmarking - property int blockIndex: index + required property string blockId // Bookmark functions (connected from parent) - property var addBookmark: function(sid, sn, aid, an, bi, bt, bn, p) { return false } - property var removeBookmark: function(sid, aid, bi) { return false } - property var checkBookmarked: function(sid, aid, bi) { return false } - property bool isBookmarked: checkBookmarked(ollie.activeSessionId, ollie.activeAgentId, blockIndex) + property var addBookmark: function(sid, sn, aid, an, bid, bt, bn, p) { return false } + property var removeBookmark: function(sid, aid, bid) { return false } + property var checkBookmarked: function(sid, aid, bid) { return false } + property bool isBookmarked: checkBookmarked(ollie.activeSessionId, ollie.activeAgentId, blockId) // Expand state based on block type property bool blockExpanded: { @@ -130,11 +128,9 @@ Column { opacity: 0.9 } - // Action buttons (visible on hover) — Acme-style tag line + // Action buttons Row { spacing: 2 - visible: root.hovered - opacity: 0.8 // Copy block content ToolButton { @@ -200,7 +196,7 @@ Column { ToolTip.text: root.isBookmarked ? "Remove bookmark" : "Bookmark" onClicked: { if (root.isBookmarked) { - root.removeBookmark(ollie.activeSessionId, ollie.activeAgentId, root.blockIndex) + root.removeBookmark(ollie.activeSessionId, ollie.activeAgentId, root.blockId) } else { var sessionName = ollie.activeSessionName || ollie.activeSessionId var agentName = ollie.activeAgentName || ollie.activeAgentId @@ -210,7 +206,7 @@ Column { sessionName, ollie.activeAgentId, agentName, - root.blockIndex, + root.blockId, blockType, blockName, preview diff --git a/kde/gui/ChatPane.qml b/kde/gui/ChatPane.qml index 05f623f..a4b2072 100644 --- a/kde/gui/ChatPane.qml +++ b/kde/gui/ChatPane.qml @@ -42,6 +42,12 @@ Item { chatView.positionViewAtIndex(blockIndex, ListView.Center) } } + function scrollToBlockId(blockId) { + var idx = chatModel.indexForBlockId(blockId) + if (idx >= 0) { + chatView.positionViewAtIndex(idx, ListView.Center) + } + } function trimVisibleBlocks() { if (chatModel.count > chatPane.maxVisibleBlocks) chatModel.trimFront(chatPane.maxVisibleBlocks) diff --git a/kde/gui/SessionTree.qml b/kde/gui/SessionTree.qml index 2676102..a37175e 100644 --- a/kde/gui/SessionTree.qml +++ b/kde/gui/SessionTree.qml @@ -421,7 +421,7 @@ Item { width: bookmarksList.width height: theme.fontSize > 0 ? theme.fontSize * 3 : 36 - property string ollieUrl: "ollie://" + encodeURIComponent(sessionName) + "/" + encodeURIComponent(agentName) + "#" + blockIndex + property string ollieUrl: "ollie://" + encodeURIComponent(sessionName) + "/" + encodeURIComponent(agentName) + "#" + blockId RowLayout { anchors.fill: parent @@ -464,7 +464,7 @@ Item { font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 11 implicitWidth: theme.fontSize > 0 ? theme.fontSize * 2 : 28 implicitHeight: theme.fontSize > 0 ? theme.fontSize * 1.8 : 24 - onClicked: root.removeBookmark(sessionId, agentId, blockIndex) + onClicked: root.removeBookmark(sessionId, agentId, blockId) ToolTip.visible: hovered ToolTip.delay: 500 ToolTip.text: "Remove bookmark" @@ -474,8 +474,7 @@ Item { onClicked: { // Switch to the session/agent and scroll to block ollie.switchAgent(sessionId, agentId) - // Signal to ChatPane to scroll to blockIndex - chatPane.scrollToBlock(blockIndex) + chatPane.scrollToBlockId(blockId) } } } diff --git a/kde/gui/chatblockmodel.cpp b/kde/gui/chatblockmodel.cpp index f060e53..3964bfc 100644 --- a/kde/gui/chatblockmodel.cpp +++ b/kde/gui/chatblockmodel.cpp @@ -159,6 +159,8 @@ QVariant ChatBlockModel::data(const QModelIndex &index, int role) const return b.renderedHtml; case SegmentsRole: return segmentsToVariant(b.segments); + case BlockIdRole: + return b.blockId; } return {}; } @@ -191,6 +193,7 @@ QHash ChatBlockModel::roleNames() const {ContentRole, "blockContent"}, {RenderedHtmlRole, "blockRenderedHtml"}, {SegmentsRole, "blockSegments"}, + {BlockIdRole, "blockId"}, }; } @@ -258,6 +261,15 @@ void ChatBlockModel::trimFront(int keepCount) emit countChanged(); } +int ChatBlockModel::indexForBlockId(const QString &blockId) const +{ + for (int i = 0; i < m_blocks.size(); ++i) { + if (m_blocks[i].blockId == blockId) + return i; + } + return -1; +} + QString ChatBlockModel::getDefaultTextColor() const { const auto textColor = QApplication::palette().color(QPalette::Text); @@ -808,6 +820,11 @@ void ChatBlockModel::processLine(const QString &line) ChatBlock newBlock; newBlock.type = blockTypeFromString(blockName.section(':', 0, 0)); newBlock.name = blockName.section(':', 1); + // Generate stable block ID: sequential counter + type + name hash + // The counter ensures uniqueness even for identical type/name pairs + QString idSource = QString::number(m_blockIdCounter++) + blockName; + newBlock.blockId = QString::fromLatin1( + QCryptographicHash::hash(idSource.toUtf8(), QCryptographicHash::Md5).toHex().left(12)); m_blocks.append(newBlock); endInsertRows(); emit countChanged(); diff --git a/kde/gui/chatblockmodel.h b/kde/gui/chatblockmodel.h index 3d23eb0..dc2f702 100644 --- a/kde/gui/chatblockmodel.h +++ b/kde/gui/chatblockmodel.h @@ -65,6 +65,7 @@ struct ChatBlock { QString renderedHtml; // Legacy: full HTML (kept for compatibility) QList segments; // Structured segments for interactive rendering BlockRenderState renderState; // Incremental rendering state + QString blockId; // Stable identifier (hash of type+name+first N chars of content) }; class ChatBlockModel : public QAbstractListModel @@ -85,6 +86,7 @@ public: ContentRole, RenderedHtmlRole, SegmentsRole, // QVariantList of segment maps + BlockIdRole, // Stable block identifier }; explicit ChatBlockModel(QObject *parent = nullptr); @@ -97,6 +99,7 @@ public: Q_INVOKABLE void appendData(const QString &text); Q_INVOKABLE void reset(); Q_INVOKABLE void trimFront(int keepCount); + Q_INVOKABLE int indexForBlockId(const QString &blockId) const; signals: void countChanged(); @@ -138,6 +141,7 @@ private: QList m_blocks; QString m_lineBuffer; + quint64 m_blockIdCounter = 0; // Monotonic counter for stable block IDs // Context blocks are injected into prompts but hidden from chat rendering. bool m_inContext = false; diff --git a/kde/gui/main.qml b/kde/gui/main.qml index 6f13327..4e6b7a2 100644 --- a/kde/gui/main.qml +++ b/kde/gui/main.qml @@ -14,16 +14,16 @@ ApplicationWindow { font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize // Bookmarks model (non-persistent, GUI state only) - // Each entry: { sessionId, sessionName, agentId, agentName, blockIndex, blockType, blockName, preview } + // Each entry: { sessionId, sessionName, agentId, agentName, blockId, blockType, blockName, preview } ListModel { id: bookmarksModel } - function addBookmark(sessionId, sessionName, agentId, agentName, blockIndex, blockType, blockName, preview) { + function addBookmark(sessionId, sessionName, agentId, agentName, blockId, blockType, blockName, preview) { // Check for duplicate for (var i = 0; i < bookmarksModel.count; i++) { var b = bookmarksModel.get(i) - if (b.sessionId === sessionId && b.agentId === agentId && b.blockIndex === blockIndex) { + if (b.sessionId === sessionId && b.agentId === agentId && b.blockId === blockId) { return false } } @@ -32,7 +32,7 @@ ApplicationWindow { sessionName: sessionName, agentId: agentId, agentName: agentName, - blockIndex: blockIndex, + blockId: blockId, blockType: blockType, blockName: blockName, preview: preview.substring(0, 80) + (preview.length > 80 ? "…" : "") @@ -40,10 +40,10 @@ ApplicationWindow { return true } - function removeBookmark(sessionId, agentId, blockIndex) { + function removeBookmark(sessionId, agentId, blockId) { for (var i = 0; i < bookmarksModel.count; i++) { var b = bookmarksModel.get(i) - if (b.sessionId === sessionId && b.agentId === agentId && b.blockIndex === blockIndex) { + if (b.sessionId === sessionId && b.agentId === agentId && b.blockId === blockId) { bookmarksModel.remove(i) return true } @@ -51,10 +51,10 @@ ApplicationWindow { return false } - function isBookmarked(sessionId, agentId, blockIndex) { + function isBookmarked(sessionId, agentId, blockId) { for (var i = 0; i < bookmarksModel.count; i++) { var b = bookmarksModel.get(i) - if (b.sessionId === sessionId && b.agentId === agentId && b.blockIndex === blockIndex) { + if (b.sessionId === sessionId && b.agentId === agentId && b.blockId === blockId) { return true } } @@ -62,17 +62,17 @@ ApplicationWindow { } // Handle plumb messages from the ollie port - // URL format: ollie://session/agent#block + // URL format: ollie://session/agent#blockId function handleOllieMessage(url) { - // Parse ollie://session/agent#block - var match = url.match(/^ollie:\/\/([^\/]+)(?:\/([^#]+))?(?:#(\d+))?$/) + // Parse ollie://session/agent#blockId + var match = url.match(/^ollie:\/\/([^\/]+)(?:\/([^#]+))?(?:#([a-f0-9]+))?$/) if (!match) { console.log("Invalid ollie URL:", url) return } var sessionName = decodeURIComponent(match[1]) var agentName = match[2] ? decodeURIComponent(match[2]) : "" - var blockIndex = match[3] ? parseInt(match[3]) : -1 + var blockId = match[3] || "" // Find session by name and switch to it var sessions = ollie.sessions @@ -92,10 +92,10 @@ ApplicationWindow { } } - // If block specified, scroll to it after a short delay - if (blockIndex >= 0) { + // If blockId specified, scroll to it after a short delay + if (blockId) { Qt.callLater(function() { - chatPane.scrollToBlock(blockIndex) + chatPane.scrollToBlockId(blockId) }) }