panel: put Souveraine controls at the hand
Move agent and conversation authority into truthful footer controls, keep external sessions observed-only, and give recent server conversations an explicit picker.\n\nProject itinerary state into a persistent ribbon, invalidate it on every itinerary mutation, and keep failures in the transcript while successful route chatter yields to the ribbon. Long tool returns now scroll in place.\n\nThe SAF now owns the full Panel contract and parity boundary; the abandoned green observer card is removed.
This commit is contained in:
parent
707a277273
commit
2bd61ec4b3
17 changed files with 1292 additions and 166 deletions
|
|
@ -113,12 +113,14 @@ services/AgentSessions.qml souveraine/services/AgentSessions.qml
|
|||
scripts/agent/agent-sessions.sh souveraine/scripts/agent/agent-sessions.sh
|
||||
modules/souveraine/island/Island.qml souveraine/modules/souveraine/island/Island.qml
|
||||
modules/souveraine/island/IslandExpansion.qml souveraine/modules/souveraine/island/IslandExpansion.qml
|
||||
modules/souveraine/island/AgentSessionPanel.qml souveraine/modules/souveraine/island/AgentSessionPanel.qml
|
||||
modules/souveraine/island/qmldir souveraine/modules/souveraine/island/qmldir
|
||||
modules/souveraine/agent/ToolVocabulary.qml souveraine/modules/souveraine/agent/ToolVocabulary.qml
|
||||
modules/souveraine/agent/ToolCard.qml souveraine/modules/souveraine/agent/ToolCard.qml
|
||||
modules/souveraine/agent/ThinkingCard.qml souveraine/modules/souveraine/agent/ThinkingCard.qml
|
||||
modules/souveraine/agent/AgentMessage.qml souveraine/modules/souveraine/agent/AgentMessage.qml
|
||||
modules/souveraine/agent/AgentPaneMenu.qml souveraine/modules/souveraine/agent/AgentPaneMenu.qml
|
||||
modules/souveraine/agent/ConversationMenu.qml souveraine/modules/souveraine/agent/ConversationMenu.qml
|
||||
modules/souveraine/agent/ItineraryRibbon.qml souveraine/modules/souveraine/agent/ItineraryRibbon.qml
|
||||
modules/souveraine/agent/qmldir souveraine/modules/souveraine/agent/qmldir
|
||||
services/SessionEvents.qml souveraine/services/SessionEvents.qml
|
||||
services/SessiondBridge.qml souveraine/services/SessiondBridge.qml
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import qs.modules.common.widgets
|
|||
import qs.modules.common.functions
|
||||
import qs.modules.souveraine.subconscious
|
||||
import qs.modules.ii.sidebarLeft.aiChat
|
||||
import qs.modules.souveraine.island
|
||||
import qs.modules.souveraine.agent
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
|
|
@ -22,6 +21,8 @@ Item {
|
|||
|
||||
property var suggestionQuery: ""
|
||||
property var suggestionList: []
|
||||
property bool agentMenuOpen: false
|
||||
property bool conversationMenuOpen: false
|
||||
|
||||
// Turn clock formatting. A local 9B can take minutes on a cold prompt, so
|
||||
// the readout has to stay legible from milliseconds to tens of minutes.
|
||||
|
|
@ -331,10 +332,6 @@ Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\)
|
|||
}
|
||||
spacing: root.padding
|
||||
|
||||
AgentSessionPanel {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Item {
|
||||
// Messages
|
||||
Layout.fillWidth: true
|
||||
|
|
@ -565,6 +562,34 @@ Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\)
|
|||
onRequestOpenPanel: root.subconsciousPanelOpen = true
|
||||
}
|
||||
|
||||
ItineraryRibbon {
|
||||
Layout.fillWidth: true
|
||||
visible: Souveraine.itinerary?.exists ?? false
|
||||
Layout.preferredHeight: visible ? implicitHeight : 0
|
||||
itinerary: Souveraine.itinerary
|
||||
stale: Souveraine.itineraryStale
|
||||
}
|
||||
|
||||
AgentPaneMenu {
|
||||
Layout.fillWidth: true
|
||||
visible: root.agentMenuOpen
|
||||
Layout.preferredHeight: visible ? implicitHeight : 0
|
||||
onPicked: {
|
||||
root.agentMenuOpen = false;
|
||||
messageInputField.forceActiveFocus();
|
||||
}
|
||||
}
|
||||
|
||||
ConversationMenu {
|
||||
Layout.fillWidth: true
|
||||
visible: root.conversationMenuOpen
|
||||
Layout.preferredHeight: visible ? implicitHeight : 0
|
||||
onPicked: {
|
||||
root.conversationMenuOpen = false;
|
||||
messageInputField.forceActiveFocus();
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle { // Input area
|
||||
id: inputWrapper
|
||||
property real spacing: 5
|
||||
|
|
@ -864,27 +889,52 @@ Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\)
|
|||
name: "",
|
||||
sendDirectly: false,
|
||||
dontAddSpace: true
|
||||
},
|
||||
{
|
||||
name: "new",
|
||||
sendDirectly: true
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
ApiInputBoxIndicator {
|
||||
// Model indicator. Souveraine's Ai service has no
|
||||
// hardcoded models — getModel() is undefined until the
|
||||
// server's /v1/models lands, so guard every access.
|
||||
icon: "api"
|
||||
text: Ai.getModel()?.name ?? Translation.tr("No model")
|
||||
tooltipText: Translation.tr("Current model: %1\nSet it with %2model MODEL").arg(Ai.getModel()?.name ?? Translation.tr("none")).arg(root?.commandPrefix ?? "/")
|
||||
// This is the pane's agent control, not a provider/model
|
||||
// observer. The menu it opens keeps external Codex and
|
||||
// Claude sessions explicitly read-only.
|
||||
icon: "neurology"
|
||||
text: Ai.getModel()?.name ?? Translation.tr("No agent")
|
||||
tooltipText: Souveraine.turnActive
|
||||
? Translation.tr("%1 is running. Finish or cancel the turn before switching.").arg(Ai.getModel()?.name ?? Translation.tr("Agent"))
|
||||
: Translation.tr("Agent controlling this conversation: %1").arg(Ai.getModel()?.name ?? Translation.tr("none"))
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: false
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
root.conversationMenuOpen = false;
|
||||
root.agentMenuOpen = !root.agentMenuOpen;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ApiInputBoxIndicator {
|
||||
// Tool indicator
|
||||
icon: "service_toolbox"
|
||||
text: (Ai.currentTool ?? "").charAt(0).toUpperCase() + (Ai.currentTool ?? "").slice(1)
|
||||
tooltipText: Translation.tr("Current tool: %1\nSet it with %2tool TOOL").arg(Ai.currentTool ?? "").arg(root?.commandPrefix ?? "/")
|
||||
icon: "forum"
|
||||
text: Souveraine.offeredConversationId.length > 0
|
||||
? Translation.tr("Continue")
|
||||
: Souveraine.conversationId.length > 0
|
||||
? Translation.tr("Thread")
|
||||
: Translation.tr("New")
|
||||
tooltipText: Souveraine.conversationId.length > 0
|
||||
? Translation.tr("Choose or start a conversation")
|
||||
: Souveraine.offeredConversationId.length > 0
|
||||
? Translation.tr("A recent conversation is ready to continue")
|
||||
: Translation.tr("Start or resume a conversation")
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: false
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
root.agentMenuOpen = false;
|
||||
root.conversationMenuOpen = !root.conversationMenuOpen;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ApiInputBoxIndicator {
|
||||
|
|
@ -929,9 +979,6 @@ Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\)
|
|||
messageInputField.cursorPosition = messageInputField.text.length;
|
||||
messageInputField.forceActiveFocus();
|
||||
}
|
||||
if (modelData.name === "new") {
|
||||
messageInputField.text = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
173
surfaces/quickshell/modules/souveraine/agent/AgentPaneMenu.qml
Normal file
173
surfaces/quickshell/modules/souveraine/agent/AgentPaneMenu.qml
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
/*
|
||||
* The footer's agent menu.
|
||||
*
|
||||
* The first section controls this pane and therefore lists only Souveraine
|
||||
* agents. AgentSessions' Claude/Codex records are observations, not selectable
|
||||
* backends for this conversation; active external sessions remain visible in
|
||||
* a separately labelled read-only section so status cannot masquerade as a
|
||||
* control again.
|
||||
*/
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
signal picked
|
||||
|
||||
readonly property var externalSessions: AgentSessions.sessions.filter(session =>
|
||||
session.provider !== "souveraine" && session.state === "active")
|
||||
|
||||
implicitHeight: content.implicitHeight + 14
|
||||
radius: Appearance.rounding.normal
|
||||
color: Appearance.colors.colLayer2
|
||||
border.width: 1
|
||||
border.color: Appearance.colors.colLayer0Border
|
||||
clip: true
|
||||
|
||||
ColumnLayout {
|
||||
id: content
|
||||
anchors.fill: parent
|
||||
anchors.margins: 7
|
||||
spacing: 4
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
|
||||
MaterialSymbol {
|
||||
text: "neurology"
|
||||
iconSize: Appearance.font.pixelSize.normal
|
||||
color: Appearance.colors.colPrimary
|
||||
}
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: Translation.tr("This conversation")
|
||||
color: Appearance.colors.colOnLayer2
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
font.bold: true
|
||||
}
|
||||
StyledText {
|
||||
visible: Souveraine.turnActive
|
||||
text: Translation.tr("turn running")
|
||||
color: Appearance.colors.colPrimary
|
||||
font.pixelSize: Appearance.font.pixelSize.smallest
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Ai.modelList
|
||||
|
||||
delegate: Rectangle {
|
||||
id: agentRow
|
||||
required property var modelData
|
||||
readonly property var agent: Ai.models[modelData] ?? null
|
||||
readonly property bool selected: modelData === Souveraine.currentAgentId
|
||||
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 34
|
||||
radius: Appearance.rounding.small
|
||||
color: selected ? Appearance.colors.colSecondaryContainer
|
||||
: picker.containsMouse ? Appearance.colors.colLayer2Hover
|
||||
: Appearance.colors.colLayer2
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 8
|
||||
spacing: 7
|
||||
|
||||
Rectangle {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
implicitWidth: 7
|
||||
implicitHeight: 7
|
||||
radius: 4
|
||||
color: agentRow.selected && Souveraine.turnActive
|
||||
? Appearance.colors.colPrimary
|
||||
: agentRow.selected
|
||||
? Appearance.colors.colOnSecondaryContainer
|
||||
: Appearance.colors.colOutlineVariant
|
||||
}
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: agentRow.agent?.name ?? agentRow.modelData
|
||||
color: agentRow.selected
|
||||
? Appearance.colors.colOnSecondaryContainer
|
||||
: Appearance.colors.colOnLayer2
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
font.bold: agentRow.selected
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
StyledText {
|
||||
text: agentRow.selected ? Translation.tr("selected") : ""
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smallest
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: picker
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: !Souveraine.turnActive && !agentRow.selected
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: {
|
||||
Ai.setModel(agentRow.modelData, false);
|
||||
root.picked();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 1
|
||||
visible: root.externalSessions.length > 0
|
||||
color: Appearance.colors.colLayer0Border
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
visible: root.externalSessions.length > 0
|
||||
text: Translation.tr("Running elsewhere · observed only")
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smallest
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.externalSessions
|
||||
|
||||
delegate: RowLayout {
|
||||
id: externalRow
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 8
|
||||
Layout.rightMargin: 8
|
||||
spacing: 7
|
||||
|
||||
MaterialSymbol {
|
||||
text: AgentSessions.providerIcon(externalRow.modelData.provider)
|
||||
iconSize: Appearance.font.pixelSize.smaller
|
||||
color: Appearance.colors.colSubtext
|
||||
}
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: AgentSessions.sessionLabel(externalRow.modelData)
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smallest
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
StyledText {
|
||||
text: AgentSessions.providerLabel(externalRow.modelData.provider)
|
||||
color: Appearance.colors.colOutlineVariant
|
||||
font.pixelSize: Appearance.font.pixelSize.smallest
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,312 @@
|
|||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
/*
|
||||
* Server-owned conversation control opened by the footer chip.
|
||||
*
|
||||
* A bubble array is not a thread. Every row here names a server conversation
|
||||
* and selecting it backfills that transcript before the next send. New and
|
||||
* offered-resume are explicit acts; opening the menu never attaches by itself.
|
||||
*/
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
signal picked
|
||||
|
||||
readonly property string query: searchField.text.trim().toLowerCase()
|
||||
readonly property var filteredConversations: Souveraine.conversations
|
||||
.filter(conversation => {
|
||||
if (root.query.length === 0) return true;
|
||||
const date = conversation.updated_at ?? conversation.created_at ?? "";
|
||||
return String(conversation.id).toLowerCase().includes(root.query)
|
||||
|| String(date).toLowerCase().includes(root.query);
|
||||
})
|
||||
.slice(0, 50)
|
||||
|
||||
function shortId(id) {
|
||||
const value = String(id ?? "");
|
||||
return value.length > 12 ? value.slice(0, 12) : value;
|
||||
}
|
||||
|
||||
function when(conversation) {
|
||||
const raw = conversation.updated_at ?? conversation.created_at ?? "";
|
||||
if (raw.length === 0) return Translation.tr("date unknown");
|
||||
const date = new Date(raw);
|
||||
if (isNaN(date.getTime())) return raw;
|
||||
return date.toLocaleString(Qt.locale(), "MMM d · HH:mm");
|
||||
}
|
||||
|
||||
onVisibleChanged: {
|
||||
if (!visible) return;
|
||||
searchField.text = "";
|
||||
Souveraine.refreshConversations();
|
||||
}
|
||||
|
||||
implicitHeight: content.implicitHeight + 14
|
||||
radius: Appearance.rounding.normal
|
||||
color: Appearance.colors.colLayer2
|
||||
border.width: 1
|
||||
border.color: Appearance.colors.colLayer0Border
|
||||
clip: true
|
||||
|
||||
ColumnLayout {
|
||||
id: content
|
||||
anchors.fill: parent
|
||||
anchors.margins: 7
|
||||
spacing: 5
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
|
||||
MaterialSymbol {
|
||||
text: "forum"
|
||||
iconSize: Appearance.font.pixelSize.normal
|
||||
color: Appearance.colors.colPrimary
|
||||
}
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: Translation.tr("Conversations")
|
||||
color: Appearance.colors.colOnLayer2
|
||||
font.pixelSize: Appearance.font.pixelSize.small
|
||||
font.bold: true
|
||||
}
|
||||
MaterialSymbol {
|
||||
visible: Souveraine.conversationsLoading
|
||||
text: "sync"
|
||||
iconSize: Appearance.font.pixelSize.normal
|
||||
color: Appearance.colors.colPrimary
|
||||
RotationAnimation on rotation {
|
||||
running: Souveraine.conversationsLoading
|
||||
from: 0
|
||||
to: 360
|
||||
duration: 900
|
||||
loops: Animation.Infinite
|
||||
}
|
||||
}
|
||||
MaterialSymbol {
|
||||
visible: Souveraine.conversationsStale
|
||||
text: "sync_problem"
|
||||
iconSize: Appearance.font.pixelSize.normal
|
||||
color: Appearance.colors.colError
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
visible: Souveraine.offeredConversationId.length > 0
|
||||
Layout.preferredHeight: visible ? 38 : 0
|
||||
radius: Appearance.rounding.small
|
||||
color: Appearance.colors.colSecondaryContainer
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 6
|
||||
spacing: 7
|
||||
|
||||
MaterialSymbol {
|
||||
text: "history"
|
||||
iconSize: Appearance.font.pixelSize.normal
|
||||
color: Appearance.colors.colOnSecondaryContainer
|
||||
}
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: Translation.tr("Continue latest")
|
||||
color: Appearance.colors.colOnSecondaryContainer
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
font.bold: true
|
||||
}
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: root.shortId(Souveraine.offeredConversationId)
|
||||
color: Appearance.colors.colOnSecondaryContainer
|
||||
opacity: 0.72
|
||||
font.pixelSize: Appearance.font.pixelSize.smallest
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
StyledText {
|
||||
text: Translation.tr("dismiss")
|
||||
color: Appearance.colors.colOnSecondaryContainer
|
||||
opacity: dismissOffer.containsMouse ? 1 : 0.65
|
||||
font.pixelSize: Appearance.font.pixelSize.smallest
|
||||
|
||||
MouseArea {
|
||||
id: dismissOffer
|
||||
anchors.fill: parent
|
||||
anchors.margins: -6
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: Souveraine.dismissOfferedResume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
anchors.rightMargin: 58
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: if (Souveraine.acceptOfferedResume()) root.picked()
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 34
|
||||
radius: Appearance.rounding.small
|
||||
color: newThread.containsMouse
|
||||
? Appearance.colors.colLayer2Hover
|
||||
: Appearance.colors.colLayer2
|
||||
border.width: 1
|
||||
border.color: Appearance.colors.colOutlineVariant
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 8
|
||||
spacing: 7
|
||||
|
||||
MaterialSymbol {
|
||||
text: "add_comment"
|
||||
iconSize: Appearance.font.pixelSize.normal
|
||||
color: Appearance.colors.colPrimary
|
||||
}
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: Translation.tr("New conversation")
|
||||
color: Appearance.colors.colOnLayer2
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: newThread
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: !Souveraine.turnActive
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: {
|
||||
Ai.clearMessages();
|
||||
root.picked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: searchField
|
||||
Layout.fillWidth: true
|
||||
visible: Souveraine.conversations.length > 5
|
||||
Layout.preferredHeight: visible ? 32 : 0
|
||||
placeholderText: Translation.tr("Filter by id or date")
|
||||
color: Appearance.colors.colOnLayer2
|
||||
placeholderTextColor: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
selectByMouse: true
|
||||
leftPadding: 9
|
||||
rightPadding: 9
|
||||
background: Rectangle {
|
||||
radius: Appearance.rounding.small
|
||||
color: Appearance.colors.colLayer1
|
||||
border.width: searchField.activeFocus ? 1 : 0
|
||||
border.color: Appearance.colors.colPrimary
|
||||
}
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: conversationList
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Math.min(contentHeight, 210)
|
||||
clip: true
|
||||
spacing: 3
|
||||
model: root.filteredConversations
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
delegate: Rectangle {
|
||||
id: conversationRow
|
||||
required property var modelData
|
||||
readonly property bool selected: modelData.id === Souveraine.conversationId
|
||||
|
||||
width: ListView.view.width
|
||||
implicitHeight: 38
|
||||
radius: Appearance.rounding.small
|
||||
color: selected ? Appearance.colors.colSecondaryContainer
|
||||
: chooseThread.containsMouse ? Appearance.colors.colLayer2Hover
|
||||
: Appearance.colors.colLayer2
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 8
|
||||
spacing: 7
|
||||
|
||||
MaterialSymbol {
|
||||
text: conversationRow.selected ? "chat" : "chat_bubble_outline"
|
||||
iconSize: Appearance.font.pixelSize.smaller
|
||||
color: conversationRow.selected
|
||||
? Appearance.colors.colOnSecondaryContainer
|
||||
: Appearance.colors.colSubtext
|
||||
}
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: root.when(conversationRow.modelData)
|
||||
color: conversationRow.selected
|
||||
? Appearance.colors.colOnSecondaryContainer
|
||||
: Appearance.colors.colOnLayer2
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: root.shortId(conversationRow.modelData.id)
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smallest
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
StyledText {
|
||||
visible: conversationRow.selected
|
||||
text: Translation.tr("attached")
|
||||
color: Appearance.colors.colOnSecondaryContainer
|
||||
font.pixelSize: Appearance.font.pixelSize.smallest
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: chooseThread
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: !Souveraine.turnActive && !conversationRow.selected
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: if (Souveraine.loadConversationById(conversationRow.modelData.id)) root.picked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
visible: !Souveraine.conversationsLoading
|
||||
&& root.filteredConversations.length === 0
|
||||
text: root.query.length > 0
|
||||
? Translation.tr("No matching conversations")
|
||||
: Translation.tr("No saved conversations")
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
219
surfaces/quickshell/modules/souveraine/agent/ItineraryRibbon.qml
Normal file
219
surfaces/quickshell/modules/souveraine/agent/ItineraryRibbon.qml
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
/*
|
||||
* The itinerary's persistent surface.
|
||||
*
|
||||
* A tool card says an itinerary verb happened. This ribbon says where the
|
||||
* agent is now. It consumes the substrate's structured read-only projection,
|
||||
* remains beside the composer while chat scrolls, and opens to reveal linked
|
||||
* todo stops without becoming a second commitment store.
|
||||
*/
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property var itinerary: ({})
|
||||
property bool stale: false
|
||||
property bool expanded: false
|
||||
|
||||
readonly property var stops: root.itinerary?.stops ?? []
|
||||
readonly property int currentIndex: Number(root.itinerary?.current ?? 0)
|
||||
readonly property var currentStop: currentIndex >= 0 && currentIndex < stops.length
|
||||
? stops[currentIndex] : null
|
||||
readonly property int doneCount: {
|
||||
let count = 0;
|
||||
for (const stop of stops) if (stop.status === "done") count++;
|
||||
return count;
|
||||
}
|
||||
readonly property string phase: stops.length < 1 ? ""
|
||||
: root.itinerary?.active
|
||||
? `${Math.min(currentIndex + 1, stops.length)}/${stops.length}`
|
||||
: `${stops.length}/${stops.length}`
|
||||
|
||||
implicitHeight: layout.implicitHeight + 2
|
||||
radius: Appearance.rounding.normal
|
||||
color: Appearance.colors.colSecondaryContainer
|
||||
border.width: 1
|
||||
border.color: Appearance.colors.colSecondaryContainerActive
|
||||
clip: true
|
||||
|
||||
ColumnLayout {
|
||||
id: layout
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: 1
|
||||
spacing: 0
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 34
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 9
|
||||
anchors.rightMargin: 7
|
||||
spacing: 7
|
||||
|
||||
MaterialSymbol {
|
||||
text: root.itinerary?.active ? "route" : "task_alt"
|
||||
iconSize: Appearance.font.pixelSize.normal
|
||||
color: Appearance.colors.colOnSecondaryContainer
|
||||
}
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: root.itinerary?.title ?? Translation.tr("Itinerary")
|
||||
color: Appearance.colors.colOnSecondaryContainer
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
font.bold: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: root.currentStop?.name
|
||||
?? (root.itinerary?.active ? Translation.tr("In progress") : Translation.tr("Route complete"))
|
||||
color: Appearance.colors.colOnSecondaryContainer
|
||||
opacity: 0.72
|
||||
font.pixelSize: Appearance.font.pixelSize.smallest
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
StyledText {
|
||||
text: root.phase
|
||||
color: Appearance.colors.colOnSecondaryContainer
|
||||
font.pixelSize: Appearance.font.pixelSize.smallest
|
||||
font.bold: true
|
||||
}
|
||||
MaterialSymbol {
|
||||
visible: root.stale
|
||||
text: "sync_problem"
|
||||
iconSize: Appearance.font.pixelSize.smaller
|
||||
color: Appearance.colors.colError
|
||||
}
|
||||
MaterialSymbol {
|
||||
text: root.expanded ? "expand_less" : "expand_more"
|
||||
iconSize: Appearance.font.pixelSize.normal
|
||||
color: Appearance.colors.colOnSecondaryContainer
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.expanded = !root.expanded
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 8
|
||||
Layout.rightMargin: 8
|
||||
implicitHeight: 2
|
||||
radius: 1
|
||||
color: Appearance.colors.colLayer2
|
||||
|
||||
Rectangle {
|
||||
width: parent.width * (root.stops.length > 0 ? root.doneCount / root.stops.length : 0)
|
||||
height: parent.height
|
||||
radius: parent.radius
|
||||
color: Appearance.colors.colPrimary
|
||||
}
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: root.expanded ? Math.min(stopList.contentHeight, 190) : 0
|
||||
visible: root.expanded
|
||||
clip: true
|
||||
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
||||
|
||||
ListView {
|
||||
id: stopList
|
||||
model: root.stops
|
||||
spacing: 3
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
delegate: Rectangle {
|
||||
id: stopCard
|
||||
required property var modelData
|
||||
width: stopList.width
|
||||
implicitHeight: stopRow.implicitHeight + 10
|
||||
radius: Appearance.rounding.small
|
||||
color: stopCard.modelData.status === "current"
|
||||
? Appearance.colors.colSecondaryContainerActive
|
||||
: Appearance.colors.colLayer2
|
||||
|
||||
RowLayout {
|
||||
id: stopRow
|
||||
anchors.fill: parent
|
||||
anchors.margins: 5
|
||||
spacing: 7
|
||||
|
||||
MaterialSymbol {
|
||||
text: stopCard.modelData.status === "done" ? "check_circle"
|
||||
: stopCard.modelData.status === "current" ? "radio_button_checked"
|
||||
: "radio_button_unchecked"
|
||||
iconSize: Appearance.font.pixelSize.smaller
|
||||
color: stopCard.modelData.status === "current"
|
||||
? Appearance.colors.colPrimary
|
||||
: Appearance.colors.colSubtext
|
||||
}
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: stopCard.modelData.name ?? ""
|
||||
color: Appearance.colors.colOnLayer2
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
font.strikeout: stopCard.modelData.status === "done"
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
visible: (stopCard.modelData.description?.length ?? 0) > 0
|
||||
text: stopCard.modelData.description ?? ""
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smallest
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
MaterialSymbol {
|
||||
visible: (stopCard.modelData.todo_id?.length ?? 0) > 0
|
||||
text: "checklist"
|
||||
iconSize: Appearance.font.pixelSize.smaller
|
||||
color: Appearance.colors.colSubtext
|
||||
}
|
||||
StyledText {
|
||||
visible: (stopCard.modelData.nature?.length ?? 0) > 0
|
||||
text: stopCard.modelData.nature ?? ""
|
||||
color: Appearance.colors.colSubtext
|
||||
font.pixelSize: Appearance.font.pixelSize.smallest
|
||||
}
|
||||
MaterialSymbol {
|
||||
visible: stopCard.modelData.energy === "generative"
|
||||
text: "bolt"
|
||||
iconSize: Appearance.font.pixelSize.smaller
|
||||
color: Appearance.colors.colPrimary
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on implicitHeight {
|
||||
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +57,11 @@ Item {
|
|||
property bool expanded: root.running || root.failed
|
||||
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: card.implicitHeight
|
||||
// The itinerary ribbon owns successful route state persistently beside
|
||||
// the composer. Keep failures in the transcript: they are evidence, not
|
||||
// duplicate chrome.
|
||||
visible: root.name !== "itinerary" || root.failed
|
||||
implicitHeight: visible ? card.implicitHeight : 0
|
||||
|
||||
function statusLabel() {
|
||||
if (root.running) return Translation.tr("running");
|
||||
|
|
@ -155,21 +159,33 @@ Item {
|
|||
}
|
||||
}
|
||||
|
||||
TextArea {
|
||||
// A height-capped TextArea is clipped, not scrollable. Put it in a
|
||||
// real ScrollView so a long grep/build result can be inspected in
|
||||
// place without expanding one card over the whole conversation.
|
||||
ScrollView {
|
||||
id: outputScroll
|
||||
Layout.fillWidth: true
|
||||
visible: root.expanded && root.output.length > 0
|
||||
implicitHeight: visible ? Math.min(contentHeight + topPadding + bottomPadding, 240) : 0
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
wrapMode: TextArea.Wrap
|
||||
textFormat: TextEdit.PlainText
|
||||
text: root.output
|
||||
font.family: Appearance.font.family.monospace
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
color: root.failed ? Appearance.colors.colOnErrorContainer : Appearance.colors.colOnLayer2
|
||||
background: Rectangle {
|
||||
radius: Appearance.rounding.small / 2
|
||||
color: Appearance.colors.colLayer1
|
||||
Layout.preferredHeight: visible ? Math.min(outputEditor.implicitHeight, 240) : 0
|
||||
clip: true
|
||||
ScrollBar.vertical.policy: ScrollBar.AsNeeded
|
||||
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
|
||||
|
||||
TextArea {
|
||||
id: outputEditor
|
||||
width: outputScroll.availableWidth
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
wrapMode: TextEdit.WrapAnywhere
|
||||
textFormat: TextEdit.PlainText
|
||||
text: root.output
|
||||
font.family: Appearance.font.family.monospace
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
color: root.failed ? Appearance.colors.colOnErrorContainer : Appearance.colors.colOnLayer2
|
||||
background: Rectangle {
|
||||
radius: Appearance.rounding.small / 2
|
||||
color: Appearance.colors.colLayer1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,3 +2,6 @@ singleton ToolVocabulary 1.0 ToolVocabulary.qml
|
|||
ToolCard 1.0 ToolCard.qml
|
||||
ThinkingCard 1.0 ThinkingCard.qml
|
||||
AgentMessage 1.0 AgentMessage.qml
|
||||
AgentPaneMenu 1.0 AgentPaneMenu.qml
|
||||
ConversationMenu 1.0 ConversationMenu.qml
|
||||
ItineraryRibbon 1.0 ItineraryRibbon.qml
|
||||
|
|
|
|||
|
|
@ -1,121 +0,0 @@
|
|||
import qs
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
// Agent activity belongs beside the conversation, not between the phone's
|
||||
// clock, battery, radios, and workspace controls. TASK-70's collector remains
|
||||
// the single authority; this is only its AI-panel rendering.
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property bool expanded: false
|
||||
readonly property var primary: AgentSessions.primarySession
|
||||
|
||||
implicitHeight: contents.implicitHeight + 12
|
||||
radius: Appearance.rounding.small
|
||||
color: Appearance.colors.colLayer2
|
||||
border.width: 1
|
||||
border.color: Appearance.colors.colLayer0Border
|
||||
clip: true
|
||||
|
||||
ColumnLayout {
|
||||
id: contents
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: 6
|
||||
}
|
||||
spacing: 4
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 32
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 7
|
||||
|
||||
Rectangle {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
implicitWidth: 8
|
||||
implicitHeight: 8
|
||||
radius: 4
|
||||
color: AgentSessions.stale
|
||||
? Appearance.colors.colOutlineVariant
|
||||
: AgentSessions.anyActive
|
||||
? Appearance.colors.colPrimary
|
||||
: Appearance.colors.colOnLayer0
|
||||
|
||||
SequentialAnimation on opacity {
|
||||
running: AgentSessions.anyActive && !AgentSessions.stale
|
||||
loops: Animation.Infinite
|
||||
NumberAnimation { to: 0.35; duration: 650 }
|
||||
NumberAnimation { to: 1.0; duration: 650 }
|
||||
}
|
||||
}
|
||||
|
||||
MaterialSymbol {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
text: AgentSessions.providerIcon(root.primary?.provider ?? "")
|
||||
iconSize: Appearance.font.pixelSize.normal
|
||||
color: Appearance.colors.colOnLayer0
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: root.primary
|
||||
? AgentSessions.sessionLabel(root.primary)
|
||||
: qsTr("Agent sessions")
|
||||
color: Appearance.colors.colOnLayer0
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
font.weight: Font.DemiBold
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: AgentSessions.stale
|
||||
? qsTr("status stale")
|
||||
: AgentSessions.anyActive
|
||||
? qsTr("%1 active").arg(AgentSessions.activeCount)
|
||||
: AgentSessions.available
|
||||
? qsTr("quiet")
|
||||
: qsTr("collecting…")
|
||||
color: Appearance.colors.colOutlineVariant
|
||||
font.pixelSize: Appearance.font.pixelSize.smallest
|
||||
}
|
||||
}
|
||||
|
||||
MaterialSymbol {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
text: root.expanded ? "expand_less" : "expand_more"
|
||||
iconSize: Appearance.font.pixelSize.normal
|
||||
color: Appearance.colors.colOutlineVariant
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.expanded = !root.expanded
|
||||
}
|
||||
}
|
||||
|
||||
IslandExpansion {
|
||||
Layout.fillWidth: true
|
||||
visible: root.expanded
|
||||
Layout.preferredHeight: visible ? implicitHeight : 0
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on implicitHeight {
|
||||
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,2 @@
|
|||
Island 1.0 Island.qml
|
||||
IslandExpansion 1.0 IslandExpansion.qml
|
||||
AgentSessionPanel 1.0 AgentSessionPanel.qml
|
||||
|
|
|
|||
|
|
@ -613,10 +613,15 @@ Singleton {
|
|||
break;
|
||||
case "atmosphere":
|
||||
case "outfit":
|
||||
case "itinerary":
|
||||
// Shell chrome hooks — their modules subscribe to
|
||||
// Souveraine.streamEvent directly; nothing to do here.
|
||||
break;
|
||||
case "itinerary":
|
||||
// The route string is an invalidation edge, including empty on
|
||||
// clear. The ribbon reads the full structured projection from the
|
||||
// substrate so a shell reload cannot erase it.
|
||||
Souveraine.refreshItinerary();
|
||||
break;
|
||||
case "primary_complete":
|
||||
// Primary yields; subconscious presses on behind this.
|
||||
root.finishStreaming();
|
||||
|
|
|
|||
|
|
@ -44,6 +44,19 @@ Singleton {
|
|||
property var agents: ({})
|
||||
property var agentList: Object.keys(agents)
|
||||
property string currentAgentId: ""
|
||||
// Structured read-only projection of the current agent's canonical
|
||||
// itinerary. The memfs file remains the owner; this survives a pane/shell
|
||||
// reload by asking the substrate instead of keeping a ribbon-local copy.
|
||||
property string itineraryAgentId: ""
|
||||
property var itinerary: ({
|
||||
"exists": false,
|
||||
"active": false,
|
||||
"title": "",
|
||||
"current": 0,
|
||||
"route": "",
|
||||
"stops": []
|
||||
})
|
||||
property bool itineraryStale: false
|
||||
// On agent (re)establishment — shell start, reboot, agent switch — we look
|
||||
// up her latest server-persisted conversation. What we do with it depends
|
||||
// on autoResume.
|
||||
|
|
@ -69,13 +82,24 @@ Singleton {
|
|||
// agentId/conversationId identify it; the rest is for drawing the offer.
|
||||
signal resumeOffered(string agentId, string conversationId, string title, string updatedAt)
|
||||
property string offeredConversationId: ""
|
||||
property string offeredConversationTitle: ""
|
||||
property string offeredConversationUpdatedAt: ""
|
||||
property var conversations: []
|
||||
property bool conversationsLoading: false
|
||||
property bool conversationsStale: false
|
||||
|
||||
onCurrentAgentIdChanged: {
|
||||
if (root.currentAgentId.length > 0 && root.serverUp
|
||||
&& root.conversationId.length === 0 && !root.turnActive) {
|
||||
root.resumeLatestConversation(!root.autoResume);
|
||||
}
|
||||
if (root.currentAgentId.length > 0 && root.serverUp)
|
||||
Qt.callLater(root.refreshItinerary);
|
||||
else
|
||||
root._clearItinerary();
|
||||
}
|
||||
onServerUpChanged: if (root.serverUp && root.currentAgentId.length > 0)
|
||||
Qt.callLater(root.refreshItinerary)
|
||||
property string conversationId: ""
|
||||
property bool turnActive: false
|
||||
|
||||
|
|
@ -150,6 +174,67 @@ Singleton {
|
|||
getAgents.running = true;
|
||||
}
|
||||
|
||||
// ── Persistent itinerary projection ────────────────────────────────
|
||||
Process {
|
||||
id: getItinerary
|
||||
property string agentId: ""
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
if (getItinerary.agentId !== root.currentAgentId) return;
|
||||
if (text.length === 0) {
|
||||
root.itineraryStale = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
root.itinerary = JSON.parse(text);
|
||||
root.itineraryAgentId = getItinerary.agentId;
|
||||
root.itineraryStale = false;
|
||||
} catch (e) {
|
||||
root.itineraryStale = true;
|
||||
console.log("[Souveraine] Could not parse itinerary:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onExited: exitCode => {
|
||||
if (exitCode !== 0 && getItinerary.agentId === root.currentAgentId)
|
||||
root.itineraryStale = true;
|
||||
}
|
||||
}
|
||||
|
||||
function _clearItinerary() {
|
||||
root.itineraryAgentId = root.currentAgentId;
|
||||
root.itinerary = ({
|
||||
"exists": false,
|
||||
"active": false,
|
||||
"title": "",
|
||||
"current": 0,
|
||||
"route": "",
|
||||
"stops": []
|
||||
});
|
||||
root.itineraryStale = false;
|
||||
}
|
||||
|
||||
function refreshItinerary() {
|
||||
const agentId = root.currentAgentId;
|
||||
if (!root.serverUp || agentId.length === 0) {
|
||||
root._clearItinerary();
|
||||
return false;
|
||||
}
|
||||
if (root.itineraryAgentId !== agentId)
|
||||
root._clearItinerary();
|
||||
getItinerary.running = false;
|
||||
getItinerary.agentId = agentId;
|
||||
getItinerary.command = ["bash", "-c",
|
||||
root._tokenReadLine(agentId)
|
||||
+ `curl -sf --max-time 5 "${root.serverBase}/v1/agents/${agentId}/itinerary"`
|
||||
+ ` -H "Authorization: Bearer $TOKEN"`
|
||||
];
|
||||
getItinerary.running = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// The inventory used to be fetched exactly once, at shell start, so an
|
||||
// agent created afterwards stayed invisible until the whole shell was
|
||||
// reloaded. One curl a minute is cheaper than that surprise. Skipped
|
||||
|
|
@ -217,13 +302,18 @@ Singleton {
|
|||
// Clear before the id changes, so onCurrentAgentIdChanged observes an
|
||||
// empty conversation and re-attaches the incoming agent's own latest
|
||||
// thread rather than leaving her on a blank one.
|
||||
listConversations.running = false;
|
||||
root.conversationId = "";
|
||||
root.dismissOfferedResume();
|
||||
root.conversations = [];
|
||||
root.conversationsStale = false;
|
||||
root.currentAgentId = agentId;
|
||||
return true;
|
||||
}
|
||||
|
||||
function newConversation() {
|
||||
root.conversationId = "";
|
||||
root.dismissOfferedResume();
|
||||
}
|
||||
|
||||
// ── Server-derived resume ───────────────────────────────────────────
|
||||
|
|
@ -236,6 +326,9 @@ Singleton {
|
|||
// Set by resumeLatestConversation(true): announce the thread rather
|
||||
// than loading it.
|
||||
property bool offerOnly: false
|
||||
// Set by refreshConversations(): update the footer picker without
|
||||
// attaching to or offering any thread.
|
||||
property bool listOnly: false
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
if (listConversations.agentId !== root.currentAgentId) return;
|
||||
|
|
@ -245,6 +338,7 @@ Singleton {
|
|||
// render, so one transient hiccup wipes the visible thread and
|
||||
// orphans the live one. Only a parsed response is authoritative.
|
||||
if (text.length === 0) {
|
||||
root.conversationsStale = true;
|
||||
console.log("[Souveraine] empty conversation list response — leaving current conversation in place");
|
||||
return;
|
||||
}
|
||||
|
|
@ -252,9 +346,13 @@ Singleton {
|
|||
try {
|
||||
conversations = JSON.parse(text);
|
||||
} catch (e) {
|
||||
root.conversationsStale = true;
|
||||
console.log("[Souveraine] Could not parse conversation list:", e);
|
||||
return;
|
||||
}
|
||||
root.conversations = conversations;
|
||||
root.conversationsStale = false;
|
||||
if (listConversations.listOnly) return;
|
||||
if (conversations.length === 0) {
|
||||
// The server answered, and the answer is "none yet".
|
||||
root.conversationId = "";
|
||||
|
|
@ -267,6 +365,8 @@ Singleton {
|
|||
// Announce, don't attach. conversationId stays empty, so a
|
||||
// send without accepting mints a fresh thread on purpose.
|
||||
root.offeredConversationId = latest.id;
|
||||
root.offeredConversationTitle = latest.title ?? "";
|
||||
root.offeredConversationUpdatedAt = latest.updated_at ?? "";
|
||||
root.resumeOffered(listConversations.agentId, latest.id,
|
||||
latest.title ?? "", latest.updated_at ?? "");
|
||||
return;
|
||||
|
|
@ -275,11 +375,13 @@ Singleton {
|
|||
}
|
||||
}
|
||||
onExited: exitCode => {
|
||||
root.conversationsLoading = false;
|
||||
// A failed list fetch is transient (server busy, network blip).
|
||||
// Do NOT clear conversationId — that orphans the live thread and
|
||||
// forces the next send to mint a fresh conversation. Log and leave
|
||||
// state alone; the next refresh or /resume retries.
|
||||
if (exitCode !== 0 && listConversations.agentId === root.currentAgentId) {
|
||||
root.conversationsStale = true;
|
||||
console.log("[Souveraine] conversation list fetch failed (exit " + exitCode + ") — leaving current conversation in place");
|
||||
}
|
||||
}
|
||||
|
|
@ -326,13 +428,31 @@ Singleton {
|
|||
// offerOnly: fetch the latest thread but announce it instead of attaching.
|
||||
// Defaults to false so every existing caller keeps its old behaviour.
|
||||
function resumeLatestConversation(offerOnly) {
|
||||
if (!root.serverUp || root.currentAgentId.length === 0 || root.turnActive) return false;
|
||||
if (!root.serverUp || root.currentAgentId.length === 0 || root.turnActive
|
||||
|| listConversations.running) return false;
|
||||
listConversations.offerOnly = (offerOnly === true);
|
||||
listConversations.listOnly = false;
|
||||
listConversations.agentId = root.currentAgentId;
|
||||
listConversations.command = [
|
||||
"curl", "-sf", "--max-time", "5",
|
||||
`${root.serverBase}/v1/conversations?agent_id=${encodeURIComponent(root.currentAgentId)}`
|
||||
];
|
||||
root.conversationsLoading = true;
|
||||
listConversations.running = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function refreshConversations() {
|
||||
if (!root.serverUp || root.currentAgentId.length === 0
|
||||
|| listConversations.running) return false;
|
||||
listConversations.offerOnly = false;
|
||||
listConversations.listOnly = true;
|
||||
listConversations.agentId = root.currentAgentId;
|
||||
listConversations.command = [
|
||||
"curl", "-sf", "--max-time", "5",
|
||||
`${root.serverBase}/v1/conversations?agent_id=${encodeURIComponent(root.currentAgentId)}`
|
||||
];
|
||||
root.conversationsLoading = true;
|
||||
listConversations.running = true;
|
||||
return true;
|
||||
}
|
||||
|
|
@ -343,13 +463,23 @@ Singleton {
|
|||
if (root.offeredConversationId.length === 0 || root.turnActive) return false;
|
||||
if (root.conversationId.length > 0) return false;
|
||||
root._loadConversation(root.currentAgentId, root.offeredConversationId);
|
||||
root.offeredConversationId = "";
|
||||
root.dismissOfferedResume();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Decline. The thread stays on the server; we simply start fresh.
|
||||
function dismissOfferedResume() {
|
||||
root.offeredConversationId = "";
|
||||
root.offeredConversationTitle = "";
|
||||
root.offeredConversationUpdatedAt = "";
|
||||
}
|
||||
|
||||
function loadConversationById(conversationId) {
|
||||
if (!root.serverUp || root.currentAgentId.length === 0 || root.turnActive
|
||||
|| conversationId.length === 0) return false;
|
||||
root.dismissOfferedResume();
|
||||
root._loadConversation(root.currentAgentId, conversationId);
|
||||
return true;
|
||||
}
|
||||
|
||||
function _loadConversation(agentId, conversationId) {
|
||||
|
|
@ -536,6 +666,10 @@ Singleton {
|
|||
root._makeRequest();
|
||||
return;
|
||||
}
|
||||
// Speaking instead of accepting the offered thread is an explicit
|
||||
// fresh start. Retire the offer before minting the new conversation so
|
||||
// the footer cannot keep advertising "Continue" over an active one.
|
||||
root.dismissOfferedResume();
|
||||
createConversation.command = [
|
||||
"curl", "-sf", "-X", "POST",
|
||||
`${root.serverBase}/v1/conversations`,
|
||||
|
|
|
|||
Loading…
Reference in a new issue