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 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

View File

@ -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)

View File

@ -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)
}
}
}

View File

@ -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<int, QByteArray> 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();

View File

@ -65,6 +65,7 @@ struct ChatBlock {
QString renderedHtml; // Legacy: full HTML (kept for compatibility)
QList<BlockSegment> 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<ChatBlock> 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;

View File

@ -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)
})
}