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

@ -23,6 +23,7 @@ How Souveraine is built, and why. For whoever reads it, human or agent. When thi
- [motion](surface/01-motion.md). One clock, two strategies, and why the shell never transforms a window. - [motion](surface/01-motion.md). One clock, two strategies, and why the shell never transforms a window.
- [session start](surface/02-session-start.md). One package-owned dependency graph from compositor socket to authority to shell. - [session start](surface/02-session-start.md). One package-owned dependency graph from compositor socket to authority to shell.
- [USB Hands](surface/03-usb-hands.md). An armed wire, an optional trackpad, and one acknowledged hand into the attached host. - [USB Hands](surface/03-usb-hands.md). An armed wire, an optional trackpad, and one acknowledged hand into the attached host.
- [agent panel](surface/04-agent-pane.md). One Souveraine conversation, typed thought and action, and truthful controls at the hand.
Coming: the rest of identity (the memory filesystem, the ledgers), the nervous system (schedules, the event log), the consciousness cycle, the runtime, federation, a glossary. Coming: the rest of identity (the memory filesystem, the ledgers), the nervous system (schedules, the event log), the consciousness cycle, the runtime, federation, a glossary.

View file

@ -0,0 +1,205 @@
# The agent panel
The Panel is Souveraine's close conversation surface on the desktop. It is not
a generic provider picker laid over somebody else's chat client. The agent in
its footer is the agent this pane can address; the transcript is the substrate's
typed conversation; thought, action, itinerary, subconscious work, and context
pressure remain distinct even when they share a narrow column.
The present shell code is split across
`surfaces/quickshell/services/Souveraine.qml`, the `Ai.qml` compatibility
adapter, and `modules/souveraine/agent/`. The outer list and composer still live
in the borrowed `modules/ii/sidebarLeft/AiChat.qml`. That is a migration seam,
not permission for the borrowed component to become a second owner of state or
product meaning.
## The anatomy
The vertical order is deliberate:
1. the conversation occupies the primary, scrollable field;
2. transient subconscious activity may touch the lower edge of that field;
3. the itinerary remains as a thin persistent ribbon above the composer while
a route exists;
4. the composer owns text entry, send/cancel/interject behavior, attachment
state, and commands;
5. the footer holds compact controls for the agent and the few secondary
surfaces that actually work.
The agent menu opens from its footer chip. It does not live above the
conversation as permanent green furniture. The itinerary does remain above the
composer because it answers a different question: not which process happens to
be alive, but where the current agent is in the work Casey asked her to carry.
Every footer chip must either expose a state the pane owns or perform the act it
advertises. A decorative tool selector is worse than no selector when tools are
configured in the agent's sensorium and cannot be switched here. Remove it.
Commands may still name unavailable work in documentation; they do not earn a
button on the live surface until their path exists.
## One controlled agent, other sessions observed
`Souveraine.currentAgentId` is the controlled position. The selector is
populated from `/v1/agents` through the Souveraine service and may switch only
between those agents. A running turn pins that selection until it finishes or
is cancelled, because changing the face while a response is arriving would
mislabel both transcript and authority.
Codex and Claude processes discovered by `AgentSessions` are not models behind
this pane. Their status may appear under a separate **observed only** heading in
the opened footer menu, with provider and session label, but those rows do not
accept selection and never imply that the composer can steer them. Observation
and control can share a menu only when the boundary is visible in words and in
behavior.
The Panel eventually needs richer active-agent status: substrate reachability,
conversation attachment, primary/subconscious phase, queued interjections, and
the last terminal event. Those facts belong to the Souveraine connection, not
to heuristics over terminal processes.
## A transcript is typed
The substrate emits typed segments. The Panel preserves them:
- assistant speech is ordinary message content;
- reasoning is Thinking and renders in `ThinkingCard`;
- tool invocation and return are one correlated `ToolCard`;
- interstitial text is visibly provisional rather than final speech;
- system/interface notices are not forged as the agent's voice.
Reasoning may be retained for audit, reflection, and context accounting without
being replayed to a generic model as `[Reasoning]:` assistant prose. Storage,
model replay, and visible rendering are three projections of the same typed
record. None may flatten Thinking into speech merely because a provider lacks a
native reasoning input block.
Tool cards collapse after success, stay open while running or failed, and show
status without requiring expansion. Their payload is selectable and scrolls
inside a capped body; a long grep or build result must not be clipped at 240
pixels or stretch one message across the entire conversation. The inner tool
body consumes scrolling while it has overflow. Outside it, the transcript
keeps the wheel.
Tool output is evidence. It may be summarized in the header, but the original
return remains available until transcript retention or compaction explicitly
changes it.
## The itinerary is state, not chat debris
`memory/system/dynamic/itinerary.md` is the canonical route. The Panel does not
parse that file and does not keep a second QML itinerary that dies on reload.
The authenticated `GET /v1/agents/:id/itinerary` endpoint projects its title,
current stop, statuses, descriptions, linked todo ids, nature, and energy.
An itinerary stream event is an invalidation edge. On set, advance, or clear,
the service refreshes that projection. Empty is meaningful: it removes the
ribbon. A shell reload or agent switch asks the substrate again, so current
work survives the surface without turning surface memory into authority.
The compact ribbon shows the title, current stop, phase, progress, and stale
state. Opening it reveals a bounded scrollable list of stops and the todo
metadata the substrate supplied. A successful itinerary tool card is hidden
from the transcript because the persistent ribbon already carries that state;
an itinerary failure remains visible because failed action is evidence.
The itinerary is not the todo ledger. It may reference a todo, but clearing a
route does not erase a commitment and completing a route does not silently
invent one.
## Conversation life
Conversation identity lives on the server. A shell reload may offer the latest
conversation but must not silently attach it unless the configured policy says
so. New, resume, list, filter, and fork all act on server conversation ids; a
local array of bubbles is a projection, never the canonical thread.
The complete Panel needs these conversation acts on glass:
- a clearly named new conversation;
- an explicit resume offer with enough metadata to distinguish the thread;
- a filterable recent-conversation list, with indexed content search as the
larger corpus demands it;
- fork from a selected message or turn boundary;
- visible attachment to a conversation id without exposing token material;
- honest recovery when the shell reattaches to an active stream.
The current adapter carries server resume and active-stream reattachment. The
footer conversation menu now exposes new, continue, recent-thread browsing,
and a local id/date filter. Indexed content search and fork remain parity work
until their owned Panel components and server paths are connected. A slash
command is not the finished interaction merely because it reaches the same
function.
## Composer and input
Send creates a user turn only when no turn is active. During a turn the same
space must make cancel and queued interjection legible rather than accepting
text whose delivery phase is unknowable. `202 Accepted` for an interjection
means queued, not read; the surface keeps that distinction until the next
round boundary or terminal event.
Paste and attachment parity requires a typed input envelope: text, image or
file identity, media type, size, provenance, and any transformation performed
before send. A filename chip connected only to an old Gemini path is not
Souveraine multimodality. Until the substrate accepts and persists the input,
the Panel must call the feature unavailable rather than imply delivery.
Keyboard, touch, and pointer must all reach the same acts. Focus returns to the
composer after a menu choice; Page Up and Page Down move the transcript; tool
payload scrolling and text selection do not accidentally send or collapse a
card. Small icon-only hit targets need tooltips and at least the shell's normal
touch target.
## Context, reflection, and parity
Feature parity means parity with Souveraine's domain, not a pixel copy of the
TUI and not inherited buttons from ii. Both surfaces should be able to expose:
- actual used tokens, model limit, and pressure as separate values;
- the memory budget and the compaction that changed it;
- primary completion followed by subconscious activity;
- reflection/audit records without leaking them into agent speech;
- the current itinerary and commitment links;
- correlated tool calls, returns, duration, and failure;
- server health, cancellation, interjection, and reconnection;
- conversation creation, discovery, resume, and fork;
- typed multimodal inputs once the substrate owns them.
These may open as drawers, menus, expanded cards, or a dedicated secondary
Panel. They must consume the same server facts. The TUI is useful evidence of a
working domain action; it is not a second specification and its screen-local
state does not become the QML contract.
## Ownership migration
The borrowed `AiChat.qml` currently owns list lifecycle, focus, and composer
geometry. Souveraine-owned `AgentMessage`, `ThinkingCard`, `ToolCard`,
`AgentPaneMenu`, and `ItineraryRibbon` already own the semantics drawn inside
it. The next structural cut is an owned Panel host and conversation viewport
that consume the same service without copying the adapter's state.
That cut should be rehearsed beside the live pane, then swapped once. A QML
write-reload loop over the only working chat surface turns each syntax fault
into a session-wide interruption and can abandon a live turn. Candidate and
live panes may share a server and a conversation only if one is read-only;
they must never become concurrent writers disguised as visual testing.
## Proof boundary
Panel work has distinct proof layers:
1. Source: Rust formatting, QML parsing, lint, manifest coverage, and focused
projection tests are clean.
2. CI: Gitea compiles and tests the Rust/QML package path on ArchDev and emits
the versioned artifact.
3. Installed: the package or development compose points at the intended source
revision and QuickShell loads it without binding or component errors.
4. On glass: the footer menu controls only Souveraine agents; observer rows are
inert; a long tool body scrolls; set/advance/clear updates the ribbon; a
failed itinerary remains in chat; switching, resume, cancel, and interject
have the states their labels claim.
A parsed QML file is not on-glass proof. A green Rust job is not an installed
surface. A hot reload that leaves the previous pane drawn is not success. Keep
those borders sharp; the Panel is where hidden ambiguity becomes something
Casey has to touch.

View file

@ -74,6 +74,34 @@ pub async fn get_agent(
Ok(Json(agent)) Ok(Json(agent))
} }
/// GET /v1/agents/:id/itinerary — the agent's current route, projected from
/// its canonical memfs file. Absence is a valid empty state, not a 404: an
/// agent exists before it lays out a route and after it clears one.
pub async fn get_agent_itinerary(
State(server): State<Arc<SouveraineServer>>,
Path(id): Path<String>,
) -> Result<Json<ItineraryView>, ApiError> {
server.agents.get(&id).await.map_err(|e| {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "agent_not_found".to_string(),
message: e.to_string(),
}),
)
})?;
let dynamic = server
.agents
.memory_root(&id)
.join("system")
.join("dynamic");
let itinerary = crate::core::tools::itinerary::load(&dynamic)
.map(ItineraryView::from)
.unwrap_or_default();
Ok(Json(itinerary))
}
pub async fn update_agent( pub async fn update_agent(
State(server): State<Arc<SouveraineServer>>, State(server): State<Arc<SouveraineServer>>,
Path(id): Path<String>, Path(id): Path<String>,

View file

@ -43,6 +43,10 @@ pub fn create_routes(state: Arc<SouveraineServer>) -> Router {
.patch(handlers::update_agent) .patch(handlers::update_agent)
.delete(handlers::delete_agent), .delete(handlers::delete_agent),
) )
.route(
"/v1/agents/:id/itinerary",
get(handlers::get_agent_itinerary),
)
.route_layer(middleware::from_fn_with_state( .route_layer(middleware::from_fn_with_state(
state.clone(), state.clone(),
auth::require_agent_token, auth::require_agent_token,

View file

@ -42,6 +42,67 @@ pub struct AgentState {
pub souveraine: SouveraineConfig, pub souveraine: SouveraineConfig,
} }
/// The itinerary as a surface can render it.
///
/// The canonical copy remains `system/dynamic/itinerary.md` in the agent's
/// memfs. This is a read-only projection: the Panel must not parse that file
/// or invent a second itinerary store merely to keep a ribbon alive across a
/// shell reload.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ItineraryView {
pub exists: bool,
pub active: bool,
pub title: String,
pub current: usize,
pub route: String,
pub stops: Vec<ItineraryStopView>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ItineraryStopView {
pub name: String,
pub description: Option<String>,
pub todo_id: Option<String>,
pub status: String,
pub nature: Option<String>,
pub energy: Option<String>,
}
impl From<crate::core::tools::itinerary::Itinerary> for ItineraryView {
fn from(itinerary: crate::core::tools::itinerary::Itinerary) -> Self {
use crate::core::tools::itinerary::StopStatus;
let route = itinerary.route_line();
let active = itinerary.is_active();
let stops = itinerary
.stops
.into_iter()
.map(|stop| ItineraryStopView {
name: stop.name,
description: stop.description,
todo_id: stop.todo_id,
status: match stop.status {
StopStatus::Pending => "pending",
StopStatus::Current => "current",
StopStatus::Done => "done",
}
.to_string(),
nature: stop.nature,
energy: stop.energy,
})
.collect();
Self {
exists: true,
active,
title: itinerary.title,
current: itinerary.current,
route,
stops,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmConfig { pub struct LlmConfig {
pub model: String, pub model: String,
@ -854,4 +915,40 @@ mod tests {
other => panic!("expected ContextPressure, got {other:?}"), other => panic!("expected ContextPressure, got {other:?}"),
} }
} }
#[test]
fn itinerary_projection_keeps_status_and_linked_todo_shape() {
use crate::core::tools::itinerary::{Itinerary, Stop, StopStatus};
let view = ItineraryView::from(Itinerary {
title: "Panel parity".into(),
current: 1,
stops: vec![
Stop {
name: "Read the owners".into(),
description: None,
todo_id: None,
status: StopStatus::Done,
nature: None,
energy: None,
},
Stop {
name: "Build the ribbon".into(),
description: Some("one persistent surface".into()),
todo_id: Some("panel-ribbon".into()),
status: StopStatus::Current,
nature: Some("desire".into()),
energy: Some("generative".into()),
},
],
});
assert!(view.exists);
assert!(view.active);
assert_eq!(view.current, 1);
assert_eq!(view.stops[0].status, "done");
assert_eq!(view.stops[1].status, "current");
assert_eq!(view.stops[1].todo_id.as_deref(), Some("panel-ribbon"));
assert!(view.route.contains("● Build the ribbon"));
}
} }

View file

@ -717,10 +717,12 @@ pub(crate) async fn run_turn(
}) })
.map(|ity| ity.route_line()) .map(|ity| ity.route_line())
.unwrap_or_default(); .unwrap_or_default();
if !route.is_empty() { // Empty is meaningful: `itinerary clear` removes the file and
// the persistent Panel ribbon must disappear immediately.
// The event is an invalidation edge; surfaces then refresh the
// structured read-only itinerary projection.
let _ = tx.send(Ok(BackendEvent::Itinerary(route))).await; let _ = tx.send(Ok(BackendEvent::Itinerary(route))).await;
} }
}
// Bind tool result to its call by id (OpenAI tool-use schema). // Bind tool result to its call by id (OpenAI tool-use schema).
messages.push(BifrostMessage::tool_result(&tc.id, &tc.name, output)); messages.push(BifrostMessage::tool_result(&tc.id, &tc.name, output));

View file

@ -113,12 +113,14 @@ services/AgentSessions.qml souveraine/services/AgentSessions.qml
scripts/agent/agent-sessions.sh souveraine/scripts/agent/agent-sessions.sh 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/Island.qml souveraine/modules/souveraine/island/Island.qml
modules/souveraine/island/IslandExpansion.qml souveraine/modules/souveraine/island/IslandExpansion.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/island/qmldir souveraine/modules/souveraine/island/qmldir
modules/souveraine/agent/ToolVocabulary.qml souveraine/modules/souveraine/agent/ToolVocabulary.qml 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/ToolCard.qml souveraine/modules/souveraine/agent/ToolCard.qml
modules/souveraine/agent/ThinkingCard.qml souveraine/modules/souveraine/agent/ThinkingCard.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/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 modules/souveraine/agent/qmldir souveraine/modules/souveraine/agent/qmldir
services/SessionEvents.qml souveraine/services/SessionEvents.qml services/SessionEvents.qml souveraine/services/SessionEvents.qml
services/SessiondBridge.qml souveraine/services/SessiondBridge.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.common.functions
import qs.modules.souveraine.subconscious import qs.modules.souveraine.subconscious
import qs.modules.ii.sidebarLeft.aiChat import qs.modules.ii.sidebarLeft.aiChat
import qs.modules.souveraine.island
import qs.modules.souveraine.agent import qs.modules.souveraine.agent
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
@ -22,6 +21,8 @@ Item {
property var suggestionQuery: "" property var suggestionQuery: ""
property var suggestionList: [] 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 // 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. // 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 spacing: root.padding
AgentSessionPanel {
Layout.fillWidth: true
}
Item { Item {
// Messages // Messages
Layout.fillWidth: true Layout.fillWidth: true
@ -565,6 +562,34 @@ Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\)
onRequestOpenPanel: root.subconsciousPanelOpen = true 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 Rectangle { // Input area
id: inputWrapper id: inputWrapper
property real spacing: 5 property real spacing: 5
@ -864,27 +889,52 @@ Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\)
name: "", name: "",
sendDirectly: false, sendDirectly: false,
dontAddSpace: true dontAddSpace: true
}, }
{
name: "new",
sendDirectly: true
},
] ]
ApiInputBoxIndicator { ApiInputBoxIndicator {
// Model indicator. Souveraine's Ai service has no // This is the pane's agent control, not a provider/model
// hardcoded models getModel() is undefined until the // observer. The menu it opens keeps external Codex and
// server's /v1/models lands, so guard every access. // Claude sessions explicitly read-only.
icon: "api" icon: "neurology"
text: Ai.getModel()?.name ?? Translation.tr("No model") text: Ai.getModel()?.name ?? Translation.tr("No agent")
tooltipText: Translation.tr("Current model: %1\nSet it with %2model MODEL").arg(Ai.getModel()?.name ?? Translation.tr("none")).arg(root?.commandPrefix ?? "/") 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 { ApiInputBoxIndicator {
// Tool indicator icon: "forum"
icon: "service_toolbox" text: Souveraine.offeredConversationId.length > 0
text: (Ai.currentTool ?? "").charAt(0).toUpperCase() + (Ai.currentTool ?? "").slice(1) ? Translation.tr("Continue")
tooltipText: Translation.tr("Current tool: %1\nSet it with %2tool TOOL").arg(Ai.currentTool ?? "").arg(root?.commandPrefix ?? "/") : 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 { ApiInputBoxIndicator {
@ -929,9 +979,6 @@ Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\)
messageInputField.cursorPosition = messageInputField.text.length; messageInputField.cursorPosition = messageInputField.text.length;
messageInputField.forceActiveFocus(); 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 property bool expanded: root.running || root.failed
Layout.fillWidth: true 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() { function statusLabel() {
if (root.running) return Translation.tr("running"); if (root.running) return Translation.tr("running");
@ -155,13 +159,24 @@ 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 Layout.fillWidth: true
visible: root.expanded && root.output.length > 0 visible: root.expanded && root.output.length > 0
implicitHeight: visible ? Math.min(contentHeight + topPadding + bottomPadding, 240) : 0 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 readOnly: true
selectByMouse: true selectByMouse: true
wrapMode: TextArea.Wrap wrapMode: TextEdit.WrapAnywhere
textFormat: TextEdit.PlainText textFormat: TextEdit.PlainText
text: root.output text: root.output
font.family: Appearance.font.family.monospace font.family: Appearance.font.family.monospace
@ -174,4 +189,5 @@ Item {
} }
} }
} }
}
} }

View file

@ -2,3 +2,6 @@ singleton ToolVocabulary 1.0 ToolVocabulary.qml
ToolCard 1.0 ToolCard.qml ToolCard 1.0 ToolCard.qml
ThinkingCard 1.0 ThinkingCard.qml ThinkingCard 1.0 ThinkingCard.qml
AgentMessage 1.0 AgentMessage.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 Island 1.0 Island.qml
IslandExpansion 1.0 IslandExpansion.qml IslandExpansion 1.0 IslandExpansion.qml
AgentSessionPanel 1.0 AgentSessionPanel.qml

View file

@ -613,10 +613,15 @@ Singleton {
break; break;
case "atmosphere": case "atmosphere":
case "outfit": case "outfit":
case "itinerary":
// Shell chrome hooks their modules subscribe to // Shell chrome hooks their modules subscribe to
// Souveraine.streamEvent directly; nothing to do here. // Souveraine.streamEvent directly; nothing to do here.
break; 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": case "primary_complete":
// Primary yields; subconscious presses on behind this. // Primary yields; subconscious presses on behind this.
root.finishStreaming(); root.finishStreaming();

View file

@ -44,6 +44,19 @@ Singleton {
property var agents: ({}) property var agents: ({})
property var agentList: Object.keys(agents) property var agentList: Object.keys(agents)
property string currentAgentId: "" 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 // On agent (re)establishment shell start, reboot, agent switch we look
// up her latest server-persisted conversation. What we do with it depends // up her latest server-persisted conversation. What we do with it depends
// on autoResume. // on autoResume.
@ -69,13 +82,24 @@ Singleton {
// agentId/conversationId identify it; the rest is for drawing the offer. // agentId/conversationId identify it; the rest is for drawing the offer.
signal resumeOffered(string agentId, string conversationId, string title, string updatedAt) signal resumeOffered(string agentId, string conversationId, string title, string updatedAt)
property string offeredConversationId: "" property string offeredConversationId: ""
property string offeredConversationTitle: ""
property string offeredConversationUpdatedAt: ""
property var conversations: []
property bool conversationsLoading: false
property bool conversationsStale: false
onCurrentAgentIdChanged: { onCurrentAgentIdChanged: {
if (root.currentAgentId.length > 0 && root.serverUp if (root.currentAgentId.length > 0 && root.serverUp
&& root.conversationId.length === 0 && !root.turnActive) { && root.conversationId.length === 0 && !root.turnActive) {
root.resumeLatestConversation(!root.autoResume); 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 string conversationId: ""
property bool turnActive: false property bool turnActive: false
@ -150,6 +174,67 @@ Singleton {
getAgents.running = true; 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 // The inventory used to be fetched exactly once, at shell start, so an
// agent created afterwards stayed invisible until the whole shell was // agent created afterwards stayed invisible until the whole shell was
// reloaded. One curl a minute is cheaper than that surprise. Skipped // 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 // Clear before the id changes, so onCurrentAgentIdChanged observes an
// empty conversation and re-attaches the incoming agent's own latest // empty conversation and re-attaches the incoming agent's own latest
// thread rather than leaving her on a blank one. // thread rather than leaving her on a blank one.
listConversations.running = false;
root.conversationId = ""; root.conversationId = "";
root.dismissOfferedResume();
root.conversations = [];
root.conversationsStale = false;
root.currentAgentId = agentId; root.currentAgentId = agentId;
return true; return true;
} }
function newConversation() { function newConversation() {
root.conversationId = ""; root.conversationId = "";
root.dismissOfferedResume();
} }
// Server-derived resume // Server-derived resume
@ -236,6 +326,9 @@ Singleton {
// Set by resumeLatestConversation(true): announce the thread rather // Set by resumeLatestConversation(true): announce the thread rather
// than loading it. // than loading it.
property bool offerOnly: false property bool offerOnly: false
// Set by refreshConversations(): update the footer picker without
// attaching to or offering any thread.
property bool listOnly: false
stdout: StdioCollector { stdout: StdioCollector {
onStreamFinished: { onStreamFinished: {
if (listConversations.agentId !== root.currentAgentId) return; if (listConversations.agentId !== root.currentAgentId) return;
@ -245,6 +338,7 @@ Singleton {
// render, so one transient hiccup wipes the visible thread and // render, so one transient hiccup wipes the visible thread and
// orphans the live one. Only a parsed response is authoritative. // orphans the live one. Only a parsed response is authoritative.
if (text.length === 0) { if (text.length === 0) {
root.conversationsStale = true;
console.log("[Souveraine] empty conversation list response — leaving current conversation in place"); console.log("[Souveraine] empty conversation list response — leaving current conversation in place");
return; return;
} }
@ -252,9 +346,13 @@ Singleton {
try { try {
conversations = JSON.parse(text); conversations = JSON.parse(text);
} catch (e) { } catch (e) {
root.conversationsStale = true;
console.log("[Souveraine] Could not parse conversation list:", e); console.log("[Souveraine] Could not parse conversation list:", e);
return; return;
} }
root.conversations = conversations;
root.conversationsStale = false;
if (listConversations.listOnly) return;
if (conversations.length === 0) { if (conversations.length === 0) {
// The server answered, and the answer is "none yet". // The server answered, and the answer is "none yet".
root.conversationId = ""; root.conversationId = "";
@ -267,6 +365,8 @@ Singleton {
// Announce, don't attach. conversationId stays empty, so a // Announce, don't attach. conversationId stays empty, so a
// send without accepting mints a fresh thread on purpose. // send without accepting mints a fresh thread on purpose.
root.offeredConversationId = latest.id; root.offeredConversationId = latest.id;
root.offeredConversationTitle = latest.title ?? "";
root.offeredConversationUpdatedAt = latest.updated_at ?? "";
root.resumeOffered(listConversations.agentId, latest.id, root.resumeOffered(listConversations.agentId, latest.id,
latest.title ?? "", latest.updated_at ?? ""); latest.title ?? "", latest.updated_at ?? "");
return; return;
@ -275,11 +375,13 @@ Singleton {
} }
} }
onExited: exitCode => { onExited: exitCode => {
root.conversationsLoading = false;
// A failed list fetch is transient (server busy, network blip). // A failed list fetch is transient (server busy, network blip).
// Do NOT clear conversationId that orphans the live thread and // Do NOT clear conversationId that orphans the live thread and
// forces the next send to mint a fresh conversation. Log and leave // forces the next send to mint a fresh conversation. Log and leave
// state alone; the next refresh or /resume retries. // state alone; the next refresh or /resume retries.
if (exitCode !== 0 && listConversations.agentId === root.currentAgentId) { if (exitCode !== 0 && listConversations.agentId === root.currentAgentId) {
root.conversationsStale = true;
console.log("[Souveraine] conversation list fetch failed (exit " + exitCode + ") — leaving current conversation in place"); 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. // offerOnly: fetch the latest thread but announce it instead of attaching.
// Defaults to false so every existing caller keeps its old behaviour. // Defaults to false so every existing caller keeps its old behaviour.
function resumeLatestConversation(offerOnly) { 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.offerOnly = (offerOnly === true);
listConversations.listOnly = false;
listConversations.agentId = root.currentAgentId; listConversations.agentId = root.currentAgentId;
listConversations.command = [ listConversations.command = [
"curl", "-sf", "--max-time", "5", "curl", "-sf", "--max-time", "5",
`${root.serverBase}/v1/conversations?agent_id=${encodeURIComponent(root.currentAgentId)}` `${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; listConversations.running = true;
return true; return true;
} }
@ -343,13 +463,23 @@ Singleton {
if (root.offeredConversationId.length === 0 || root.turnActive) return false; if (root.offeredConversationId.length === 0 || root.turnActive) return false;
if (root.conversationId.length > 0) return false; if (root.conversationId.length > 0) return false;
root._loadConversation(root.currentAgentId, root.offeredConversationId); root._loadConversation(root.currentAgentId, root.offeredConversationId);
root.offeredConversationId = ""; root.dismissOfferedResume();
return true; return true;
} }
// Decline. The thread stays on the server; we simply start fresh. // Decline. The thread stays on the server; we simply start fresh.
function dismissOfferedResume() { function dismissOfferedResume() {
root.offeredConversationId = ""; 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) { function _loadConversation(agentId, conversationId) {
@ -536,6 +666,10 @@ Singleton {
root._makeRequest(); root._makeRequest();
return; 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 = [ createConversation.command = [
"curl", "-sf", "-X", "POST", "curl", "-sf", "-X", "POST",
`${root.serverBase}/v1/conversations`, `${root.serverBase}/v1/conversations`,