ollie/kde/gui/SessionTree.qml

507 lines
24 KiB
QML

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) + "#" + blockIndex
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, blockIndex)
ToolTip.visible: hovered
ToolTip.delay: 500
ToolTip.text: "Remove bookmark"
}
}
onClicked: {
// Switch to the session/agent and scroll to block
ollie.switchAgent(sessionId, agentId)
// Signal to ChatPane to scroll to blockIndex
chatPane.scrollToBlock(blockIndex)
}
}
}
}
}
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 = "" }
}