Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-08-17 11:51:43 -04:00
commit 2bd61ec4b3
17 changed files with 1292 additions and 166 deletions

View file

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

View file

@ -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 = "";
}
}
}
}

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

View file

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

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,2 @@
Island 1.0 Island.qml
IslandExpansion 1.0 IslandExpansion.qml
AgentSessionPanel 1.0 AgentSessionPanel.qml

View file

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

View file

@ -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`,