agent sessions: one collector, one island
Collector emits a single JSON envelope for every agent session (Souveraine, Claude Code, Codex) and always exits 0; a failing provider reports available:false rather than taking the envelope down. AgentSessions.qml owns the cadence and projects the envelope. Transport is not the contract: when the TASK-69 daemon lands, only the source changes. Island is a morph host for the bar - dot on a cramped bar, pill where there is room, derived from the same threshold BarContent uses. Inert until registered in services/qmldir and mounted; see patches/0005.
This commit is contained in:
parent
928bd8d143
commit
47ce2d1970
5 changed files with 742 additions and 0 deletions
188
surfaces/quickshell/modules/souveraine/island/Island.qml
Normal file
188
surfaces/quickshell/modules/souveraine/island/Island.qml
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import qs
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
// Island — the agent surface for the top bar. TASK-70, fed by TASK-69.
|
||||
//
|
||||
// A morph host: one element that changes shape with what the agents are doing,
|
||||
// rather than a fixed widget that is mostly empty. States, in order of weight:
|
||||
//
|
||||
// hidden no sessions at all — occupy nothing, not a placeholder
|
||||
// dot something is active; a pulse and nothing else
|
||||
// pill the primary session: provider glyph, label, state
|
||||
// expanded tapped open — every session, grouped
|
||||
//
|
||||
// DEVICE TYPES ARE A REAL DIFFERENCE, NOT A SETTING
|
||||
// The Pixel 3 bar is ~1080px with a carrier readout already competing for the
|
||||
// left side, and ii-phone/BarContent drops the entire leftCenterGroup for that
|
||||
// reason. So the island's *resting* state is device-dependent: `dot` on a
|
||||
// narrow bar, `pill` where there is room. That is derived from the same
|
||||
// threshold the rest of the bar uses (Appearance.sizes.barShorten...), so the
|
||||
// island narrows exactly when its neighbours do. One authority for "is this
|
||||
// bar cramped"; the island is a rendering of it, not a second opinion.
|
||||
//
|
||||
// HOST-AGNOSTIC, like SubconsciousTicker
|
||||
// This owns no overlay state and reaches into no manager. Expansion is a local
|
||||
// state change; anything larger is a signal for whoever mounted it to handle.
|
||||
// That is what lets the same file serve the desktop bar, the phone bar, and
|
||||
// later a viewtop node without a fork.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
// The host may pin a form; otherwise it is derived from the bar's own
|
||||
// cramped-ness. Values: "auto" | "dot" | "pill".
|
||||
property string restingForm: "auto"
|
||||
|
||||
property var screen: root.QsWindow.window?.screen
|
||||
|
||||
// Same test BarContent uses, so island and neighbours narrow together.
|
||||
readonly property bool narrowBar: (Appearance.sizes.barShortenScreenWidthThreshold >= (screen?.width ?? 99999))
|
||||
|
||||
readonly property string form: {
|
||||
if (root.restingForm !== "auto")
|
||||
return root.restingForm;
|
||||
return root.narrowBar ? "dot" : "pill";
|
||||
}
|
||||
|
||||
// Nothing to say → occupy nothing. An always-present empty chip trains the
|
||||
// eye to ignore the spot, which costs us the one thing the island is for.
|
||||
readonly property bool hasContent: AgentSessions.sessions.length > 0
|
||||
|
||||
property bool expanded: false
|
||||
|
||||
// For a host that wants to put the full session list somewhere better than
|
||||
// an inline popout (a sidebar, a sheet, a notch overlay). If nobody
|
||||
// connects it, the inline expansion below is the fallback — the component
|
||||
// is useful alone and better when hosted.
|
||||
signal requestOpenPanel
|
||||
|
||||
visible: hasContent
|
||||
implicitWidth: visible ? content.implicitWidth : 0
|
||||
implicitHeight: Appearance.sizes.baseBarHeight
|
||||
|
||||
readonly property var primary: AgentSessions.primarySession
|
||||
|
||||
// Colour carries the state, so the island reads pre-attentively — you know
|
||||
// something is running before you read a word of it.
|
||||
readonly property color stateColor: {
|
||||
if (AgentSessions.stale)
|
||||
return Appearance.colors.colOutlineVariant;
|
||||
if (AgentSessions.anyActive)
|
||||
return Appearance.colors.colPrimary;
|
||||
if (root.primary?.state === "recent")
|
||||
return Appearance.colors.colOnLayer0;
|
||||
return Appearance.colors.colOutlineVariant;
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: content
|
||||
anchors.centerIn: parent
|
||||
implicitWidth: row.implicitWidth + 16
|
||||
implicitHeight: Math.max(20, Appearance.sizes.baseBarHeight - 10)
|
||||
radius: height / 2
|
||||
color: root.expanded ? Appearance.colors.colLayer2
|
||||
: mouse.containsMouse ? Appearance.colors.colLayer1
|
||||
: "transparent"
|
||||
|
||||
Behavior on implicitWidth {
|
||||
NumberAnimation { duration: 180; easing.type: Easing.OutCubic }
|
||||
}
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: 120 }
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
id: row
|
||||
anchors.centerIn: parent
|
||||
spacing: 6
|
||||
|
||||
// The pulse. Present in every form — in `dot` it IS the island.
|
||||
Rectangle {
|
||||
id: pulse
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
implicitWidth: 8
|
||||
implicitHeight: 8
|
||||
radius: 4
|
||||
color: root.stateColor
|
||||
|
||||
// Only animate while something is genuinely active. A dot that
|
||||
// always breathes is decoration; a dot that breathes only when
|
||||
// an agent is working is information.
|
||||
SequentialAnimation on opacity {
|
||||
running: AgentSessions.anyActive && !AgentSessions.stale
|
||||
loops: Animation.Infinite
|
||||
NumberAnimation { to: 0.35; duration: 900; easing.type: Easing.InOutSine }
|
||||
NumberAnimation { to: 1.0; duration: 900; easing.type: Easing.InOutSine }
|
||||
}
|
||||
// Leaving the loop mid-fade would strand it dim.
|
||||
onOpacityChanged: if (!AgentSessions.anyActive && opacity !== 1) opacity = 1
|
||||
}
|
||||
|
||||
// Multiple agents at once is the case the old per-provider widgets
|
||||
// could not show at all. A count is the cheapest honest summary.
|
||||
StyledText {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
visible: AgentSessions.activeCount > 1
|
||||
text: AgentSessions.activeCount
|
||||
color: root.stateColor
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
MaterialSymbol {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
visible: root.form === "pill" && root.primary !== null
|
||||
text: AgentSessions.providerIcon(root.primary?.provider ?? "")
|
||||
iconSize: Appearance.font.pixelSize.normal
|
||||
color: root.stateColor
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
visible: root.form === "pill" && root.primary !== null
|
||||
text: AgentSessions.sessionLabel(root.primary)
|
||||
color: Appearance.colors.colOnLayer0
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
elide: Text.ElideRight
|
||||
Layout.maximumWidth: 110
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onClicked: (ev) => {
|
||||
if (ev.button === Qt.RightButton) {
|
||||
root.requestOpenPanel();
|
||||
return;
|
||||
}
|
||||
root.expanded = !root.expanded;
|
||||
}
|
||||
}
|
||||
|
||||
StyledToolTip {
|
||||
// Stale is worth saying out loud rather than only dimming: a dim
|
||||
// island and a quiet one look identical at a glance.
|
||||
content: AgentSessions.stale
|
||||
? qsTr("Agent sessions — stale (%1)").arg(AgentSessions.lastError)
|
||||
: AgentSessions.available
|
||||
? qsTr("%1 agent session(s), %2 active").arg(AgentSessions.sessions.length).arg(AgentSessions.activeCount)
|
||||
: qsTr("Agent sessions — collecting…")
|
||||
extraVisibleCondition: mouse.containsMouse && !root.expanded
|
||||
}
|
||||
}
|
||||
|
||||
IslandExpansion {
|
||||
id: expansion
|
||||
anchors.top: content.bottom
|
||||
anchors.topMargin: 6
|
||||
anchors.horizontalCenter: content.horizontalCenter
|
||||
visible: root.expanded
|
||||
onDismissed: root.expanded = false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
import qs
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
// The island opened: every session the collector sees, newest first.
|
||||
//
|
||||
// Deliberately a flat list rather than provider-grouped tabs. The question this
|
||||
// answers is "what is running right now", and that is chronological, not
|
||||
// taxonomic — grouping by provider would bury a live Codex run under three idle
|
||||
// Souveraine threads. The provider is a glyph on each row instead.
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
signal dismissed
|
||||
|
||||
implicitWidth: 320
|
||||
implicitHeight: Math.min(column.implicitHeight + 16, 360)
|
||||
radius: Appearance.rounding.small
|
||||
color: Appearance.colors.colLayer2
|
||||
border.width: 1
|
||||
border.color: Appearance.colors.colLayer0Border
|
||||
|
||||
opacity: visible ? 1 : 0
|
||||
Behavior on opacity { NumberAnimation { duration: 140 } }
|
||||
|
||||
ColumnLayout {
|
||||
id: column
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
spacing: 4
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
text: qsTr("Agent sessions")
|
||||
color: Appearance.colors.colOnLayer0
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
// Provider health belongs here, not on a row: a provider that is
|
||||
// unavailable has no rows to hang the message on, and "no sessions"
|
||||
// must never be confused with "not looking". Same family as every
|
||||
// empty-result bug this project has hit.
|
||||
Repeater {
|
||||
model: ["souveraine", "claude", "codex"]
|
||||
delegate: MaterialSymbol {
|
||||
required property string modelData
|
||||
readonly property var p: AgentSessions.providers?.[modelData] ?? null
|
||||
visible: p !== null && p.available === false
|
||||
text: AgentSessions.providerIcon(modelData)
|
||||
iconSize: Appearance.font.pixelSize.smaller
|
||||
color: Appearance.colors.colOutlineVariant
|
||||
StyledToolTip {
|
||||
content: qsTr("%1 unavailable: %2")
|
||||
.arg(AgentSessions.providerLabel(parent.modelData))
|
||||
.arg(parent.p?.error ?? "unknown")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
visible: AgentSessions.stale
|
||||
text: qsTr("stale — %1").arg(AgentSessions.lastError)
|
||||
color: Appearance.colors.colOutlineVariant
|
||||
font.pixelSize: Appearance.font.pixelSize.smallest
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
ListView {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.preferredHeight: contentHeight
|
||||
clip: true
|
||||
spacing: 2
|
||||
model: AgentSessions.sessions
|
||||
|
||||
delegate: Item {
|
||||
required property var modelData
|
||||
width: ListView.view.width
|
||||
implicitHeight: 30
|
||||
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 4
|
||||
anchors.rightMargin: 4
|
||||
spacing: 6
|
||||
|
||||
Rectangle {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
implicitWidth: 6
|
||||
implicitHeight: 6
|
||||
radius: 3
|
||||
color: modelData.state === "active" ? Appearance.colors.colPrimary
|
||||
: modelData.state === "recent" ? Appearance.colors.colOnLayer0
|
||||
: Appearance.colors.colOutlineVariant
|
||||
}
|
||||
|
||||
MaterialSymbol {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
text: AgentSessions.providerIcon(modelData.provider)
|
||||
iconSize: Appearance.font.pixelSize.smaller
|
||||
color: Appearance.colors.colOnLayer0
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
text: AgentSessions.sessionLabel(modelData)
|
||||
color: Appearance.colors.colOnLayer0
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// "—" where a provider does not report tokens. Rendering a
|
||||
// 0 would be a measurement claim we cannot back: the
|
||||
// Souveraine substrate records TokenUsage but nothing calls
|
||||
// assistant_with_usage yet (TASK-67). An em dash is the
|
||||
// honest glyph for "not measured".
|
||||
StyledText {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
readonly property int tok: AgentSessions.sessionTokens(modelData)
|
||||
text: tok < 0 ? "—"
|
||||
: tok > 1000 ? (Math.round(tok / 100) / 10) + "k"
|
||||
: String(tok)
|
||||
color: Appearance.colors.colOutlineVariant
|
||||
font.pixelSize: Appearance.font.pixelSize.smallest
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
Layout.fillWidth: true
|
||||
visible: AgentSessions.sessions.length === 0
|
||||
text: AgentSessions.available ? qsTr("No recent sessions")
|
||||
: qsTr("Collecting…")
|
||||
color: Appearance.colors.colOutlineVariant
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
2
surfaces/quickshell/modules/souveraine/island/qmldir
Normal file
2
surfaces/quickshell/modules/souveraine/island/qmldir
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
Island 1.0 Island.qml
|
||||
IslandExpansion 1.0 IslandExpansion.qml
|
||||
Loading…
Reference in a new issue