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)
This commit is contained in:
Levi Neely 2026-10-09 14:53:36 +02:00
parent 483a4f0d4d
commit 2f45509af3
6 changed files with 53 additions and 31 deletions

View File

@ -18,15 +18,13 @@ Column {
required property var blockSegments required property var blockSegments
required property string blockRenderedHtml required property string blockRenderedHtml
required property int index required property int index
required property string blockId
// Block index for bookmarking
property int blockIndex: index
// Bookmark functions (connected from parent) // Bookmark functions (connected from parent)
property var addBookmark: function(sid, sn, aid, an, bi, bt, bn, p) { return false } property var addBookmark: function(sid, sn, aid, an, bid, bt, bn, p) { return false }
property var removeBookmark: function(sid, aid, bi) { return false } property var removeBookmark: function(sid, aid, bid) { return false }
property var checkBookmarked: function(sid, aid, bi) { return false } property var checkBookmarked: function(sid, aid, bid) { return false }
property bool isBookmarked: checkBookmarked(ollie.activeSessionId, ollie.activeAgentId, blockIndex) property bool isBookmarked: checkBookmarked(ollie.activeSessionId, ollie.activeAgentId, blockId)
// Expand state based on block type // Expand state based on block type
property bool blockExpanded: { property bool blockExpanded: {
@ -130,11 +128,9 @@ Column {
opacity: 0.9 opacity: 0.9
} }
// Action buttons (visible on hover) — Acme-style tag line // Action buttons
Row { Row {
spacing: 2 spacing: 2
visible: root.hovered
opacity: 0.8
// Copy block content // Copy block content
ToolButton { ToolButton {
@ -200,7 +196,7 @@ Column {
ToolTip.text: root.isBookmarked ? "Remove bookmark" : "Bookmark" ToolTip.text: root.isBookmarked ? "Remove bookmark" : "Bookmark"
onClicked: { onClicked: {
if (root.isBookmarked) { if (root.isBookmarked) {
root.removeBookmark(ollie.activeSessionId, ollie.activeAgentId, root.blockIndex) root.removeBookmark(ollie.activeSessionId, ollie.activeAgentId, root.blockId)
} else { } else {
var sessionName = ollie.activeSessionName || ollie.activeSessionId var sessionName = ollie.activeSessionName || ollie.activeSessionId
var agentName = ollie.activeAgentName || ollie.activeAgentId var agentName = ollie.activeAgentName || ollie.activeAgentId
@ -210,7 +206,7 @@ Column {
sessionName, sessionName,
ollie.activeAgentId, ollie.activeAgentId,
agentName, agentName,
root.blockIndex, root.blockId,
blockType, blockType,
blockName, blockName,
preview preview

View File

@ -42,6 +42,12 @@ Item {
chatView.positionViewAtIndex(blockIndex, ListView.Center) chatView.positionViewAtIndex(blockIndex, ListView.Center)
} }
} }
function scrollToBlockId(blockId) {
var idx = chatModel.indexForBlockId(blockId)
if (idx >= 0) {
chatView.positionViewAtIndex(idx, ListView.Center)
}
}
function trimVisibleBlocks() { function trimVisibleBlocks() {
if (chatModel.count > chatPane.maxVisibleBlocks) if (chatModel.count > chatPane.maxVisibleBlocks)
chatModel.trimFront(chatPane.maxVisibleBlocks) chatModel.trimFront(chatPane.maxVisibleBlocks)

View File

@ -421,7 +421,7 @@ Item {
width: bookmarksList.width width: bookmarksList.width
height: theme.fontSize > 0 ? theme.fontSize * 3 : 36 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 { RowLayout {
anchors.fill: parent anchors.fill: parent
@ -464,7 +464,7 @@ Item {
font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 11 font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 11
implicitWidth: theme.fontSize > 0 ? theme.fontSize * 2 : 28 implicitWidth: theme.fontSize > 0 ? theme.fontSize * 2 : 28
implicitHeight: theme.fontSize > 0 ? theme.fontSize * 1.8 : 24 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.visible: hovered
ToolTip.delay: 500 ToolTip.delay: 500
ToolTip.text: "Remove bookmark" ToolTip.text: "Remove bookmark"
@ -474,8 +474,7 @@ Item {
onClicked: { onClicked: {
// Switch to the session/agent and scroll to block // Switch to the session/agent and scroll to block
ollie.switchAgent(sessionId, agentId) ollie.switchAgent(sessionId, agentId)
// Signal to ChatPane to scroll to blockIndex chatPane.scrollToBlockId(blockId)
chatPane.scrollToBlock(blockIndex)
} }
} }
} }

View File

@ -159,6 +159,8 @@ QVariant ChatBlockModel::data(const QModelIndex &index, int role) const
return b.renderedHtml; return b.renderedHtml;
case SegmentsRole: case SegmentsRole:
return segmentsToVariant(b.segments); return segmentsToVariant(b.segments);
case BlockIdRole:
return b.blockId;
} }
return {}; return {};
} }
@ -191,6 +193,7 @@ QHash<int, QByteArray> ChatBlockModel::roleNames() const
{ContentRole, "blockContent"}, {ContentRole, "blockContent"},
{RenderedHtmlRole, "blockRenderedHtml"}, {RenderedHtmlRole, "blockRenderedHtml"},
{SegmentsRole, "blockSegments"}, {SegmentsRole, "blockSegments"},
{BlockIdRole, "blockId"},
}; };
} }
@ -258,6 +261,15 @@ void ChatBlockModel::trimFront(int keepCount)
emit countChanged(); 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 QString ChatBlockModel::getDefaultTextColor() const
{ {
const auto textColor = QApplication::palette().color(QPalette::Text); const auto textColor = QApplication::palette().color(QPalette::Text);
@ -808,6 +820,11 @@ void ChatBlockModel::processLine(const QString &line)
ChatBlock newBlock; ChatBlock newBlock;
newBlock.type = blockTypeFromString(blockName.section(':', 0, 0)); newBlock.type = blockTypeFromString(blockName.section(':', 0, 0));
newBlock.name = blockName.section(':', 1); 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); m_blocks.append(newBlock);
endInsertRows(); endInsertRows();
emit countChanged(); emit countChanged();

View File

@ -65,6 +65,7 @@ struct ChatBlock {
QString renderedHtml; // Legacy: full HTML (kept for compatibility) QString renderedHtml; // Legacy: full HTML (kept for compatibility)
QList<BlockSegment> segments; // Structured segments for interactive rendering QList<BlockSegment> segments; // Structured segments for interactive rendering
BlockRenderState renderState; // Incremental rendering state BlockRenderState renderState; // Incremental rendering state
QString blockId; // Stable identifier (hash of type+name+first N chars of content)
}; };
class ChatBlockModel : public QAbstractListModel class ChatBlockModel : public QAbstractListModel
@ -85,6 +86,7 @@ public:
ContentRole, ContentRole,
RenderedHtmlRole, RenderedHtmlRole,
SegmentsRole, // QVariantList of segment maps SegmentsRole, // QVariantList of segment maps
BlockIdRole, // Stable block identifier
}; };
explicit ChatBlockModel(QObject *parent = nullptr); explicit ChatBlockModel(QObject *parent = nullptr);
@ -97,6 +99,7 @@ public:
Q_INVOKABLE void appendData(const QString &text); Q_INVOKABLE void appendData(const QString &text);
Q_INVOKABLE void reset(); Q_INVOKABLE void reset();
Q_INVOKABLE void trimFront(int keepCount); Q_INVOKABLE void trimFront(int keepCount);
Q_INVOKABLE int indexForBlockId(const QString &blockId) const;
signals: signals:
void countChanged(); void countChanged();
@ -138,6 +141,7 @@ private:
QList<ChatBlock> m_blocks; QList<ChatBlock> m_blocks;
QString m_lineBuffer; QString m_lineBuffer;
quint64 m_blockIdCounter = 0; // Monotonic counter for stable block IDs
// Context blocks are injected into prompts but hidden from chat rendering. // Context blocks are injected into prompts but hidden from chat rendering.
bool m_inContext = false; bool m_inContext = false;

View File

@ -14,16 +14,16 @@ ApplicationWindow {
font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize
// Bookmarks model (non-persistent, GUI state only) // 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 { ListModel {
id: bookmarksModel 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 // Check for duplicate
for (var i = 0; i < bookmarksModel.count; i++) { for (var i = 0; i < bookmarksModel.count; i++) {
var b = bookmarksModel.get(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 return false
} }
} }
@ -32,7 +32,7 @@ ApplicationWindow {
sessionName: sessionName, sessionName: sessionName,
agentId: agentId, agentId: agentId,
agentName: agentName, agentName: agentName,
blockIndex: blockIndex, blockId: blockId,
blockType: blockType, blockType: blockType,
blockName: blockName, blockName: blockName,
preview: preview.substring(0, 80) + (preview.length > 80 ? "…" : "") preview: preview.substring(0, 80) + (preview.length > 80 ? "…" : "")
@ -40,10 +40,10 @@ ApplicationWindow {
return true return true
} }
function removeBookmark(sessionId, agentId, blockIndex) { function removeBookmark(sessionId, agentId, blockId) {
for (var i = 0; i < bookmarksModel.count; i++) { for (var i = 0; i < bookmarksModel.count; i++) {
var b = bookmarksModel.get(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) bookmarksModel.remove(i)
return true return true
} }
@ -51,10 +51,10 @@ ApplicationWindow {
return false return false
} }
function isBookmarked(sessionId, agentId, blockIndex) { function isBookmarked(sessionId, agentId, blockId) {
for (var i = 0; i < bookmarksModel.count; i++) { for (var i = 0; i < bookmarksModel.count; i++) {
var b = bookmarksModel.get(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 return true
} }
} }
@ -62,17 +62,17 @@ ApplicationWindow {
} }
// Handle plumb messages from the ollie port // Handle plumb messages from the ollie port
// URL format: ollie://session/agent#block // URL format: ollie://session/agent#blockId
function handleOllieMessage(url) { function handleOllieMessage(url) {
// Parse ollie://session/agent#block // Parse ollie://session/agent#blockId
var match = url.match(/^ollie:\/\/([^\/]+)(?:\/([^#]+))?(?:#(\d+))?$/) var match = url.match(/^ollie:\/\/([^\/]+)(?:\/([^#]+))?(?:#([a-f0-9]+))?$/)
if (!match) { if (!match) {
console.log("Invalid ollie URL:", url) console.log("Invalid ollie URL:", url)
return return
} }
var sessionName = decodeURIComponent(match[1]) var sessionName = decodeURIComponent(match[1])
var agentName = match[2] ? decodeURIComponent(match[2]) : "" 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 // Find session by name and switch to it
var sessions = ollie.sessions var sessions = ollie.sessions
@ -92,10 +92,10 @@ ApplicationWindow {
} }
} }
// If block specified, scroll to it after a short delay // If blockId specified, scroll to it after a short delay
if (blockIndex >= 0) { if (blockId) {
Qt.callLater(function() { Qt.callLater(function() {
chatPane.scrollToBlock(blockIndex) chatPane.scrollToBlockId(blockId)
}) })
} }