import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 Item { id: tree property bool visiblePane: true property int renamingIndex: -1 property string renamingText: "" property string killTargetSessionId: "" property string killTargetSessionName: "" property string killTargetAgentSessionId: "" property string killTargetAgentId: "" signal newSessionRequested() signal settingsRequested() signal sessionSettingsRequested(string sessionName) signal newAgentRequested(string sessionId) signal agentSettingsRequested(string sessionId, string agentId) signal goalRequested(string sessionId, string sessionName) signal renameFailed(string itemType, string attemptedName) ColumnLayout { anchors.fill: parent id: sidebarPane visible: tree.visiblePane spacing: 0 ToolBar { Layout.fillWidth: true RowLayout { anchors.fill: parent Label { text: "Sessions"; Layout.fillWidth: true; font.bold: true; leftPadding: 8 } ToolButton { text: "\u2795" onClicked: tree.newSessionRequested() ToolTip.text: "New Session" ToolTip.visible: hovered } ToolButton { text: "\u21BB" onClicked: ollie.refreshSessions() } ToolButton { text: "\u2699" onClicked: tree.settingsRequested() ToolTip.text: "Settings" ToolTip.visible: hovered } } } ListView { id: sessionList Layout.fillWidth: true Layout.fillHeight: true Layout.minimumHeight: 100 model: sessionModel clip: true focus: false activeFocusOnTab: false // Show placeholder when empty or disconnected Label { anchors.centerIn: parent text: ollie.daemonConnected ? "(no sessions)" : "(not connected)" color: theme.midColor font.italic: true font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 12 visible: sessionList.count === 0 || !ollie.daemonConnected activeFocusOnTab: false } delegate: ItemDelegate { width: sessionList.width focusPolicy: Qt.NoFocus activeFocusOnTab: false // Guard against undefined during initial render height: { if (!type) return 0 if (type === "header") return 24 if (type === "agent" && !parentExpanded) return 0 return 28 } visible: height > 0 property int modelIndex: index property bool isHeader: type === "header" // Headers are not interactive enabled: !isHeader MouseArea { anchors.fill: parent acceptedButtons: Qt.LeftButton | Qt.RightButton hoverEnabled: false enabled: !parent.isHeader onClicked: function(mouse) { if (mouse.button === Qt.RightButton) { if (type === "session") { var item = sessionModel.get(parent.modelIndex) if (item) { tree.killTargetSessionId = item.id tree.killTargetSessionName = item.baseName } sessionCtxMenu.popup() } else if (type === "agent") { var agentItem = sessionModel.get(parent.modelIndex) if (agentItem) { tree.killTargetAgentSessionId = agentItem.parentId tree.killTargetAgentId = agentItem.agentId } agentCtxMenu.popup() } return } if (renamingIndex >= 0) return if (type === "session") { // Find first agent in this session and select it var foundAgent = null for (var i = 0; i < sessionModel.count; ++i) { var item = sessionModel.get(i) if (item.type === "agent" && item.parentId === id) { foundAgent = item break } } if (foundAgent) { // Select the first agent ollie.switchAgent(id, foundAgent.agentId) } else { // No agents - just select session (clears agent) ollie.setActiveSessionId(id) } sessionModel.setExpanded(parent.modelIndex, !expanded) } else if (type === "agent") { ollie.switchAgent(parentId, agentId) // Toggle expansion if agent has children if (hasChildren) { sessionModel.setExpanded(parent.modelIndex, !expanded) } } } onDoubleClicked: { if (type === "session" || type === "agent") { startRename(parent.modelIndex) } } Menu { id: sessionCtxMenu MenuItem { text: "Goal..." onClicked: { tree.goalRequested(tree.killTargetSessionId, tree.killTargetSessionName) } } MenuItem { text: "Settings..." onClicked: { tree.sessionSettingsRequested(tree.killTargetSessionName) } } MenuItem { text: "Open Workdir" onClicked: { if (tree.killTargetSessionName) { var cfg = ollie.readSessionConfig(tree.killTargetSessionName) if (cfg.cwd) ollie.openDirectory(cfg.cwd) } } } MenuItem { text: "New Agent..." onClicked: { tree.newAgentRequested(tree.killTargetSessionId) } } MenuItem { text: ollie.isSessionPaused(tree.killTargetSessionId) ? "Resume" : "Pause" onClicked: { if (tree.killTargetSessionId) { if (ollie.isSessionPaused(tree.killTargetSessionId)) ollie.resumeSession(tree.killTargetSessionId) else ollie.pauseSession(tree.killTargetSessionId) } } } MenuItem { text: "Kill Session" onClicked: { if (tree.killTargetSessionId) ollie.killSession(tree.killTargetSessionId) ollie.refreshSessions() } } } Menu { id: agentCtxMenu MenuItem { text: "Settings..." onClicked: { if (tree.killTargetAgentSessionId && tree.killTargetAgentId) tree.agentSettingsRequested(tree.killTargetAgentSessionId, tree.killTargetAgentId) } } MenuItem { text: "Open Workdir" onClicked: { if (tree.killTargetAgentSessionId && tree.killTargetAgentId) { var cfg = ollie.readAgentConfig(tree.killTargetAgentSessionId, tree.killTargetAgentId) if (cfg.cwd) ollie.openDirectory(cfg.cwd) } } } MenuItem { text: "Kill" onClicked: { if (tree.killTargetAgentSessionId && tree.killTargetAgentId) ollie.killAgent(tree.killTargetAgentSessionId, tree.killTargetAgentId) ollie.refreshSessions() } } } } contentItem: RowLayout { anchors.fill: parent spacing: 0 // Indent + expand/collapse indicator Item { Layout.preferredWidth: type === "header" ? 8 : (level || 0) * 16 + 16 Layout.preferredHeight: type === "header" ? 24 : 28 Label { anchors.centerIn: parent text: (type === "session" || hasChildren) ? (expanded ? "▼" : "▶") : "" font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.7 : 10 color: theme.textColor opacity: 0.6 visible: type !== "header" } } // Connection status indicator (session rows only) Rectangle { Layout.preferredWidth: 8 Layout.preferredHeight: 8 Layout.alignment: Qt.AlignVCenter Layout.rightMargin: 6 radius: 4 color: connectionColor || "gray" visible: type === "session" } // Pending bypass indicator (agent rows only) Label { id: bypassIndicator Layout.alignment: Qt.AlignVCenter Layout.rightMargin: 4 text: "⚠" color: "#e6a700" font.pixelSize: theme.fontSize > 0 ? theme.fontSize : 12 visible: type === "agent" && ollie.pendingBypassCount(parentId || "", agentId || "") > 0 Connections { target: ollie function onPendingBypassCountChanged(sid, aid) { if (type === "agent" && sid === parentId && aid === agentId) { bypassIndicator.visible = ollie.pendingBypassCount(parentId || "", agentId || "") > 0 } } } } // Name area Item { Layout.fillWidth: true Layout.preferredHeight: type === "header" ? 24 : 28 TextField { anchors.fill: parent text: (modelIndex === renamingIndex) ? renamingText : "" visible: modelIndex === renamingIndex verticalAlignment: Text.AlignVCenter font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize color: theme.textColor background: Rectangle { color: theme.baseColor border.color: theme.highlightColor radius: 2 } onTextChanged: { if (modelIndex === renamingIndex) { renamingText = text } } Keys.onReturnPressed: commitRename() Keys.onEscapePressed: cancelRename() onAccepted: commitRename() onVisibleChanged: { if (visible) selectAll() } } Label { anchors.fill: parent text: displayName || "" color: type === "header" ? theme.midColor : theme.textColor font.pointSize: theme.fontSize > 0 ? theme.fontSize * (type === "header" ? 0.85 : 1) : Qt.application.font.pointSize font.bold: type === "header" font.capitalization: type === "header" ? Font.AllUppercase : Font.MixedCase verticalAlignment: Text.AlignVCenter visible: modelIndex !== renamingIndex } } // Agent state indicator (agent rows only) Rectangle { id: stateIndicator Layout.preferredWidth: height Layout.preferredHeight: parent.height * 0.3 Layout.alignment: Qt.AlignVCenter Layout.leftMargin: parent.height * 0.15 Layout.rightMargin: parent.height * 0.25 radius: height / 2 visible: type === "agent" color: { // Session paused overrides agent state if (ollie.isSessionPaused(parentId || "")) return "#808080" // gray - session paused var state = ollie.agentState(parentId || "", agentId || "") if (state.startsWith("calling")) return "#f0a030" // orange - calling tool if (state === "thinking") return "#3080f0" // blue - thinking if (state === "paused") return "#808080" // gray - paused return "#40c040" // green - idle } Connections { target: ollie function onAgentStateChanged(sid, aid, state) { if (type === "agent" && sid === parentId && aid === agentId) { stateIndicator.color = stateIndicator.computeColor() } } function onSessionsChanged() { if (type === "agent") { stateIndicator.color = stateIndicator.computeColor() } } } function computeColor() { if (ollie.isSessionPaused(parentId || "")) return "#808080" var state = ollie.agentState(parentId || "", agentId || "") if (state.startsWith("calling")) return "#f0a030" else if (state === "thinking") return "#3080f0" else if (state === "paused") return "#808080" else return "#40c040" } } } highlighted: (type === "agent" && agentId === ollie.activeAgentId && parentId === ollie.activeSessionId) } } // --- Bookmarks Section --- Rectangle { Layout.fillWidth: true height: 1 color: theme.midColor visible: bookmarksSection.visible } ColumnLayout { id: bookmarksSection Layout.fillWidth: true Layout.preferredHeight: bookmarksExpanded ? Math.min(150, bookmarksList.contentHeight + 30) : 30 visible: bookmarksModel.count > 0 spacing: 0 property bool bookmarksExpanded: true ToolBar { Layout.fillWidth: true RowLayout { anchors.fill: parent ToolButton { text: bookmarksSection.bookmarksExpanded ? "▼" : "▶" implicitWidth: 24 onClicked: bookmarksSection.bookmarksExpanded = !bookmarksSection.bookmarksExpanded } Label { text: "Bookmarks (" + bookmarksModel.count + ")" Layout.fillWidth: true font.bold: true } ToolButton { text: "✕" implicitWidth: 24 onClicked: bookmarksModel.clear() ToolTip.text: "Clear all bookmarks" ToolTip.visible: hovered } } } ListView { id: bookmarksList Layout.fillWidth: true Layout.fillHeight: true model: bookmarksModel clip: true visible: bookmarksSection.bookmarksExpanded delegate: ItemDelegate { width: bookmarksList.width height: theme.fontSize > 0 ? theme.fontSize * 3 : 36 property string ollieUrl: "ollie://" + encodeURIComponent(sessionName) + "/" + encodeURIComponent(agentName) + "#" + blockId RowLayout { anchors.fill: parent anchors.leftMargin: theme.fontSize > 0 ? theme.fontSize * 0.6 : 8 anchors.rightMargin: theme.fontSize > 0 ? theme.fontSize * 0.3 : 4 spacing: theme.fontSize > 0 ? theme.fontSize * 0.3 : 4 Column { Layout.fillWidth: true spacing: 0 Label { text: sessionName + "/" + agentName font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.75 : 9 color: theme.midColor elide: Text.ElideRight width: parent.width } Label { text: "[" + blockType + "] " + preview font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.85 : 11 elide: Text.ElideRight width: parent.width } } ToolButton { text: "📋" 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: clipboard.setText(ollieUrl) ToolTip.visible: hovered ToolTip.delay: 500 ToolTip.text: "Copy ollie:// link" } ToolButton { text: "✕" 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, blockId) ToolTip.visible: hovered ToolTip.delay: 500 ToolTip.text: "Remove bookmark" } } onClicked: { // Switch to the session/agent and scroll to block ollie.switchAgent(sessionId, agentId) chatPane.scrollToBlockId(blockId) } } } } } function startRename(index) { var item = sessionModel.get(index) if (item.type === "session" || item.type === "agent") { tree.renamingIndex = index tree.renamingText = item.type === "session" ? (item.baseName || item.displayName) : item.displayName } } function commitRename() { if (tree.renamingIndex < 0) return var item = sessionModel.get(tree.renamingIndex) var name = tree.renamingText.trim() if (name !== "") { var ok = item.type === "session" ? ollie.renameSession(item.id, name) : ollie.renameAgent(item.parentId, item.agentId, name) if (!ok) tree.renameFailed(item.type, name) } tree.renamingIndex = -1 tree.renamingText = "" } function cancelRename() { tree.renamingIndex = -1; tree.renamingText = "" } }