Watch
1
0
Fork
You've already forked souveraine
0

quickshell: resume agent conversations from server

This commit is contained in:
Fimeg 2026-07-12 16:31:55 -04:00
commit 6e21c50dcc
9 changed files with 338 additions and 127 deletions

View file

@ -86,15 +86,39 @@ pub async fn list_conversations(
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Result<Json<Vec<Conversation>>, ApiError> {
let conversations = if let Some(agent_id) = params.get("agent_id") {
// Conversations are persisted per agent, but a freshly restarted
// server has not populated its in-memory session index yet. Hydrate
// before listing so every surface can derive resume state from the
// server instead of carrying its own agent → conversation map.
server
.sessions
.load_persisted(agent_id)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: "conversation_load_failed".to_string(),
message: e.to_string(),
}),
)
})?;
let session_ids = server.sessions.list_for_agent(agent_id);
session_ids.into_iter()
.map(|id| Conversation {
id,
agent_id: agent_id.clone(),
created_at: chrono::Utc::now(),
updated_at: None,
let mut conversations: Vec<_> = session_ids
.into_iter()
.filter_map(|id| {
server.sessions.get(&id).map(|session| Conversation {
id,
agent_id: session.agent_id.clone(),
created_at: session.created_at,
updated_at: Some(session.updated_at),
})
})
.collect()
.collect();
conversations.sort_by_key(|conversation| {
std::cmp::Reverse(conversation.updated_at.unwrap_or(conversation.created_at))
});
conversations
} else {
Vec::new()
};

View file

@ -274,3 +274,40 @@ impl SessionManager {
Ok(forked_id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn list_hydration_restores_persisted_agent_conversations() {
let temp = tempfile::tempdir().unwrap();
let agent_id = "agent-phone";
let conversation_id = "conversation-resume";
let store = ConversationStore::new(&temp.path().join(agent_id));
let record = ConversationRecord::new(conversation_id.into(), agent_id.into());
store.save_metadata(&record).await.unwrap();
store
.save_messages(
conversation_id,
&[ConversationMessage::user_text("resume me")],
)
.await
.unwrap();
let sessions = SessionManager::with_persistence(temp.path().to_path_buf());
let records = sessions.load_persisted(agent_id).await.unwrap();
assert_eq!(records.len(), 1);
assert_eq!(sessions.list_for_agent(agent_id), vec![conversation_id]);
let session = sessions.get(conversation_id).unwrap();
assert_eq!(session.messages.len(), 1);
assert_eq!(session.messages[0].role, crate::core::session::MessageRole::User);
assert_eq!(
session.messages[0].blocks,
vec![crate::core::session::ContentBlock::Text {
text: "resume me".to_string(),
}]
);
}
}

View file

@ -1,10 +1,9 @@
// Souveraine patch to ii's stock GlobalStates.qml.
//
// Adds dockRevealPulse: a self-clearing "show the dock for a few seconds"
// trigger, driven by the gesture pill (pill/shell.qml) via the dock IPC
// while the on-screen keyboard has suppressed the dock (see Dock.qml's
// computeDockState). Everything else in this file is unchanged stock ii
// diff against upstream before re-applying this patch if ii updates.
// Adds dockRevealed: the explicit, persistent dock state driven by the
// gesture pill (pill/shell.qml). Everything else in this file is unchanged
// stock ii diff against upstream before re-applying this patch if ii
// updates.
import qs.modules.common
import qs.services
import QtQuick
@ -40,18 +39,10 @@ Singleton {
property real superLastReleaseTime: 0
property bool wallpaperSelectorOpen: false
property bool workspaceShowNumbers: false
property bool dockRevealPulse: false
function pulseDockReveal() {
root.dockRevealPulse = true;
dockRevealPulseTimer.restart();
}
Timer {
id: dockRevealPulseTimer
interval: 3000
onTriggered: root.dockRevealPulse = false
}
// In fullscreen this is the only way the dock becomes visible:
// pill swipe up sets it; pill swipe down clears it. It deliberately has
// no timer or secondary state.
property bool dockRevealed: false
function superPressDuration() {
const now = Date.now();

View file

@ -8,26 +8,30 @@ Edit here → `./deploy.sh --phone` → restart. Deployed via symlink; live edit
- **Always visible.** `WlrLayer.Overlay` + `ExclusionMode.Ignore` + `margins.bottom:0`. Survives fullscreen, dock, OSK. Never lower the layer.
- **Must sit ABOVE the dock in z-order** (both on Overlay; later-created wins). Restart pill LAST, or it gets buried and stops taking touch.
- Gestures (MouseArea — pointer handlers don't get touch here):
- **double-tap** → fullscreen active app, mode 0 (whole display, no border/gaps). Routes via `dock` IPC `fullscreen()`, which targets `Hyprland.activeToplevel.address` — NOT hyprctl on "active window" (the tap focuses the shell).
- **swipe up**`dock` IPC `swipeUp` (pulse dock; again = overview).
- **double-tap** → toggles the active app's named `fullscreen` mode (whole
display, no border/gaps). Routes via `dock` IPC `fullscreen()`, which
targets `Hyprland.activeToplevel.address` — NOT hyprctl on "active window"
(the tap focuses the shell).
- **swipe up**`dock` IPC `swipeUp` (reveals the dock above the pill).
- **swipe down**`dock` IPC `swipeDown` (dismiss).
- **No keyboard on the pill.** OSK = 3-finger hyprgrass swipe only.
## Dock (`modules/ii/dock/Dock.qml`)
- **On `WlrLayer.Overlay`** so the swipe-pulse reaches it over fullscreen apps.
- **On `WlrLayer.Overlay`** so the pill can reveal it over fullscreen apps.
- **Hidden = layer unmounted** (`visible:false`), not just tucked — else it paints over fullscreen.
- **Visibility** (`computeDockState`, first match wins):
1. fullscreen app on focused monitor → **Hidden** (unless mid-pulse)
1. fullscreen app on focused monitor → **Hidden** (unless pill-revealed)
2. pinned → **Pinned** (only state that reserves exclusive zone)
3. pulse or preview-hover → **Peek**
4. empty desktop / no focused app → **Peek**
3. preview-hover → **Shown**
4. empty desktop / no focused app → **Shown**
5. else (normal app focused) → **Hidden**
- **Reserves 32px pill strip** at the bottom so it never covers the pill.
- **Reserves a 32px pill strip** at the bottom visually *and in its
layer-shell input mask*, so the dock cannot intercept pill touches.
- **Bar height is content-driven**: the window sizes itself to the button row (64px buttons) + row margin + pill strip; `Config.options.dock.height` is only a floor. Don't tune the config height to "fix" icon clipping.
- **App list width is capped** to the screen (`DockApps.maxWidth`); past that the icon row scrolls horizontally by touch (flick is only enabled when overflowing, so drag-to-combine keeps working when everything fits).
- **DockStack renders with the same content block as DockAppButton** (icon + half-reserved dot strip, centered as one unit). Keep them structurally identical or they drift apart on the bar.
- Depends on **`GlobalStates.dockRevealPulse` + `pulseDockReveal()`** — lives in the surface tree (`GlobalStates.qml`, stock ii + pulse patch) and the deploy manifest. If ii updates its GlobalStates, re-diff and re-apply the patch.
- Depends on **`GlobalStates.dockRevealed`** — lives in the surface tree (`GlobalStates.qml`, stock ii + patch) and the deploy manifest. If ii updates its GlobalStates, re-diff and re-apply the patch.
## souveraine-settings (planned, not built)
@ -42,8 +46,8 @@ Both are STUBS — they log and pulse the dock, nothing opens. That's deliberate
the IPC name stays stable so the settings app can take it over without touching
the dock.
## Fullscreen modes (Hyprland 0.55, lua dispatch)
## Fullscreen API (Hyprland 0.55, Lua dispatch)
- Dispatch: `hl.dsp.window.fullscreen({ window="address:0x…", mode=N })`. Classic `fullscreen 1` errors.
- **mode 0** = app-mode: whole display, no border/gaps, bar+pill stay (Overlay outranks it). ← the pill uses this.
- **mode 1** = maximize but keeps 20px gaps + 2px border.
- Dispatch: `hl.dsp.window.fullscreen({ window="address:0x…", mode="fullscreen", action="toggle" })`.
- **`fullscreen`** = whole display, no borders or gaps. ← the pill uses this.
- **`maximized`** = keeps the normal workspace layout margins.

View file

@ -10,10 +10,8 @@
// keyboard-side math account for wherever the dock happens to be.
//
// GlobalStates.oskOpen suppresses both reveal-when-idle and the pinned
// exclusive zone. Double-tapping the pill (see
// overlays/quickshell-pill/shell.qml) still pulses the dock visible for a
// few seconds via GlobalStates.dockRevealPulse, for reaching a dock app
// without first closing the keyboard.
// exclusive zone. In fullscreen the pill explicitly reveals or hides the
// dock; it never times out or opens another surface.
import qs
import qs.services
import qs.modules.common
@ -33,18 +31,17 @@ Scope { // Scope
id: root
property bool pinned: Config.options?.dock.pinnedOnStartup ?? false
// Dock visibility as one explicit state instead of five OR'd booleans
// (Phosh's lesson: a small enum beats an overlapping-boolean knot).
// Dock visibility as one explicit state instead of overlapping booleans.
// HIDDEN - fully tucked below the edge
// PEEK - hover strip / empty-desktop / pill-pulse showing it briefly
// SHOWN - visible but not claiming exclusive space
// SHOWN - visible without claiming exclusive space
// PINNED - visible AND reserving an exclusive zone
// OSK-open and previewPopup-hover are *inputs* to this, not states.
enum DockState { Hidden, Peek, Shown, Pinned }
enum DockState { Hidden, Shown, Pinned }
// The real pin, suppressed while the OSK is open unless the user is mid
// pill-pulse. Kept as a helper the state below reads.
property bool effectivePinned: root.pinned && (!GlobalStates.oskOpen || GlobalStates.dockRevealPulse)
// The normal dock pin is suppressed while the OSK is open. Fullscreen
// always takes precedence, so fullscreen remains genuinely edge-to-edge
// until the pill explicitly reveals the dock.
property bool effectivePinned: root.pinned && !GlobalStates.oskOpen
// requestDockShow (previewPopup hover) is threaded up from DockApps via
// this alias so the state computation can see it in one place.
@ -53,7 +50,7 @@ Scope { // Scope
// App mode: any fullscreen window on the focused monitor owns the
// display, so the dock hides. ii's own screenCorners/Background use this
// same scan (activeToplevel.wayland.fullscreen is NOT reliable); mirror
// it verbatim. dockRevealPulse still overrides so swipe-up can flash it.
// it verbatim.
readonly property var focusedWorkspaces: Hyprland.workspaces.values.filter(
ws => ws.monitor && ws.monitor.name === Hyprland.focusedMonitor?.name)
readonly property var activeFullscreenWorkspace: root.focusedWorkspaces.filter(
@ -61,55 +58,36 @@ Scope { // Scope
readonly property bool activeMonitorHasFullscreen: root.activeFullscreenWorkspace !== undefined
function computeDockState() {
if (root.activeMonitorHasFullscreen && !GlobalStates.dockRevealPulse)
return Dock.DockState.Hidden;
if (root.activeMonitorHasFullscreen)
return GlobalStates.dockRevealed ? Dock.DockState.Shown : Dock.DockState.Hidden;
if (root.effectivePinned)
return Dock.DockState.Pinned;
if (GlobalStates.dockRevealPulse || root.previewShowing)
return Dock.DockState.Peek;
if (root.previewShowing)
return Dock.DockState.Shown;
// Empty desktop (nothing focused) reveals the dock, unless the OSK
// took the bottom edge.
if (!GlobalStates.oskOpen && !ToplevelManager.activeToplevel?.activated)
return Dock.DockState.Peek;
return Dock.DockState.Shown;
return Dock.DockState.Hidden;
}
property int dockState: computeDockState()
// Two-stage bottom-edge swipe (hyprgrass edge:d:u -> `qs -c ii ipc call
// dock swipeUp`): first swipe pulses the dock visible for a few
// seconds; swiping again while it's showing (or when it's pinned
// anyway) escalates to the overview. Swipe with the overview open
// closes it again.
// The pill's dock contract is intentionally just two operations:
// swipe up reveals the dock; swipe down hides it. No timer, no overview.
IpcHandler {
target: "dock"
function swipeUp(): void {
if (GlobalStates.overviewOpen) {
GlobalStates.overviewOpen = false;
return;
}
// Dock already visible (Peek/Shown/Pinned) -> escalate to overview.
if (root.dockState !== Dock.DockState.Hidden) {
GlobalStates.dockRevealPulse = false;
GlobalStates.overviewOpen = true;
} else {
GlobalStates.pulseDockReveal();
}
GlobalStates.dockRevealed = true;
}
// Swipe down while the dock is showing dismisses it: clears the
// pulse and, if the overview is up, closes that instead.
function swipeDown(): void {
if (GlobalStates.overviewOpen) {
GlobalStates.overviewOpen = false;
return;
}
GlobalStates.dockRevealPulse = false;
GlobalStates.dockRevealed = false;
}
function reveal(): void {
GlobalStates.pulseDockReveal();
GlobalStates.dockRevealed = true;
}
// Toggle app-mode fullscreen on the REAL active window. The pill can't
@ -121,7 +99,10 @@ Scope { // Scope
// setFullscreen). Same ii-side-sees-the-real-window trick the dock
// buttons use with .activate().
function fullscreen(): void {
// App-mode fullscreen = mode 0 (whole display, no gaps/border).
// Use Hyprland 0.55's named fullscreen API. Numeric modes select
// legacy/fake fullscreen behavior on this Lua dispatcher.
// Clear a revealed dock before either direction of the toggle.
GlobalStates.dockRevealed = false;
// Hyprland.activeToplevel is Hyprland's real active APP window and
// its .address is a stable window handle a layer-shell pill tap
// never becomes a Hyprland toplevel, so this stays the app even
@ -133,7 +114,7 @@ Scope { // Scope
// one. Selector "address:0x..." is verified working on this fork.
const addr = raw.startsWith("0x") ? raw : "0x" + raw;
Quickshell.execDetached(["hyprctl", "dispatch",
`hl.dsp.window.fullscreen({ window = "address:${addr}", mode = 0 })`]);
`hl.dsp.window.fullscreen({ window = "address:${addr}", mode = "fullscreen", action = "toggle" })`]);
}
}
@ -147,12 +128,12 @@ Scope { // Scope
function openApp(appId: string): void {
console.log("[dockSettings] openApp stub for", appId,
"- souveraine-settings not yet installed");
GlobalStates.pulseDockReveal();
GlobalStates.dockRevealed = true;
}
function open(): void {
console.log("[dockSettings] open stub - souveraine-settings not yet installed");
GlobalStates.pulseDockReveal();
GlobalStates.dockRevealed = true;
}
}
@ -174,6 +155,10 @@ Scope { // Scope
// keeps the strip alive for desktop pointer use.
property bool reveal: root.dockState !== Dock.DockState.Hidden
|| (Config.options?.dock.hoverToReveal && dockMouseArea.containsMouse)
// This space belongs to the always-on pill. It is visually empty
// and must also be absent from the dock's *input* region; otherwise
// the dock receives touches before the pill can see them.
readonly property int pillStripHeight: 32
visible: !GlobalStates.screenLocked && reveal
anchors {
@ -202,11 +187,22 @@ Scope { // Scope
// made the bar overshoot (content then top-aligned with a dead
// band underneath).
implicitHeight: Math.max(Config.options?.dock.height ?? 70,
overviewButton.implicitHeight + 8 + 32)
overviewButton.implicitHeight + 8 + pillStripHeight)
+ Appearance.sizes.elevationMargin + Appearance.sizes.hyprlandGapsOut
mask: Region {
item: dockMouseArea
item: dockInputRegion
}
// Deliberately smaller than dockMouseArea. The full MouseArea is
// still useful for laying out and hovering dock content, while the
// layer-shell only advertises the bar itself as touchable.
Item {
id: dockInputRegion
anchors.top: parent.top
anchors.horizontalCenter: parent.horizontalCenter
width: dockMouseArea.width
height: Math.max(0, dockMouseArea.height - dockRoot.pillStripHeight)
}
MouseArea {
@ -237,12 +233,12 @@ Scope { // Scope
// the window's bottom by thePillZone so the dock
// floats above the pill and never covers it.
bottom: parent.bottom
bottomMargin: 32
bottomMargin: dockRoot.pillStripHeight
horizontalCenter: parent.horizontalCenter
}
implicitWidth: dockRow.implicitWidth + 5 * 2
height: parent.height - Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut - 32
height: parent.height - Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut - dockRoot.pillStripHeight
StyledRectangularShadow {
target: dockVisualBackground

View file

@ -57,6 +57,20 @@ Item {
Ai.setModel(args[0]);
}
},
{
name: "agent",
description: Translation.tr("Choose Souveraine agent"),
execute: args => {
Ai.setModel(args[0]);
}
},
{
name: "resume",
description: Translation.tr("Resume the selected agent's latest conversation"),
execute: () => {
Ai.resumeConversation();
}
},
{
name: "tool",
description: Translation.tr("Set the tool to use for the model."),
@ -527,7 +541,7 @@ Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\)
root.suggestionQuery = "";
root.suggestionList = [];
return;
} else if (messageInputField.text.startsWith(`${root.commandPrefix}model`)) {
} else if (messageInputField.text.startsWith(`${root.commandPrefix}model`) || messageInputField.text.startsWith(`${root.commandPrefix}agent`)) {
root.suggestionQuery = messageInputField.text.split(" ")[1] ?? "";
const modelResults = Fuzzy.go(root.suggestionQuery, Ai.modelList.map(model => {
return {
@ -540,7 +554,7 @@ Inline w/ backslash and round brackets \\(e^{i\\pi} + 1 = 0\\)
});
root.suggestionList = modelResults.map(model => {
return {
name: `${messageInputField.text.trim().split(" ").length == 1 ? (root.commandPrefix + "model ") : ""}${model.target}`,
name: `${messageInputField.text.trim().split(" ").length == 1 ? (messageInputField.text.startsWith(`${root.commandPrefix}agent`) ? (root.commandPrefix + "agent ") : (root.commandPrefix + "model ")) : ""}${model.target}`,
displayName: `${Ai.models[model.target].name}`,
description: `${Ai.models[model.target].description}`
};

View file

@ -1,13 +1,10 @@
// pill Phosh-style gesture bar for the Pixel 3.
// Gesture map (2026-07-12):
// double-tap : toggle app-mode (fullscreen maximize, mode 1) of active win
// swipe up : pulse the dock (ii "dock" swipeUp IPC) same gesture as
// the bottom-edge hyprgrass bind, so there's one dock gesture
// regardless of where you swipe from
// swipe down : dismiss the dock (ii "dock" swipeDown IPC)
// double-tap : toggle true app fullscreen (whole display) of active win
// swipe up : reveal the dock above the pill
// swipe down : hide the revealed dock
// Keyboard is NOT on the pill. The OSK lives on the 3-finger-swipe-up
// hyprgrass bind alone until the grip sensor arrives. Fullscreen mode 1
// (maximize/borderless), not mode 0 reversible, phone-friendly.
// hyprgrass bind alone until the grip sensor arrives.
import Quickshell
import Quickshell.Wayland
import QtQuick
@ -40,41 +37,54 @@ ShellRoot {
Behavior on width { NumberAnimation { duration: 120 } }
}
// MouseArea, not pointer handlers: every reference Quickshell shell
// (dots-hyprland, belanasaikiran's dock) drives touch through
// MouseArea TapHandler/DragHandler in a layer-shell window did not
// receive touch here at all (no grow, no fire). onDoubleClicked works
// on this touch stack; the fullscreen dispatch string below is
// verified to maximize the active window (fullscreen 0->1).
// Keep gesture arbitration in one place. Qt's onDoubleClicked is too
// eager to treat the small drift in a touchscreen double-tap as a
// drag, so a double tap is recognized from two released tap events.
MouseArea {
anchors.fill: parent
property real startY: 0
property bool fired: false
property real lastTapAt: -1
readonly property int tapSlop: 24
readonly property int swipeDistance: 48
readonly property int doubleTapInterval: 350
onPressed: (mouse) => { startY = mouse.y; fired = false; pill.width = 170 }
onReleased: pill.width = 150
onCanceled: pill.width = 150
onPositionChanged: (mouse) => {
if (fired) return
if (startY - mouse.y > 35) {
fired = true
// Pulse the dock (up for a few seconds, or escalate to
// overview if already showing). Mirrors the bottom-edge
// hyprgrass swipe-up so there's one dock gesture from
// either edge.
onPressed: (mouse) => {
startY = mouse.y
pill.width = 170
}
onReleased: (mouse) => {
pill.width = 150
const deltaY = mouse.y - startY
if (deltaY <= -swipeDistance) {
lastTapAt = -1
Quickshell.execDetached(["qs", "-c", "ii", "ipc", "call", "dock", "swipeUp"])
} else if (mouse.y - startY > 35) {
fired = true
return
}
if (deltaY >= swipeDistance) {
lastTapAt = -1
Quickshell.execDetached(["qs", "-c", "ii", "ipc", "call", "dock", "swipeDown"])
return
}
// A small move is still a tap; intermediate moves are neither
// a tap nor a swipe, which prevents accidental actions.
if (Math.abs(deltaY) > tapSlop) {
lastTapAt = -1
return
}
const now = Date.now()
if (lastTapAt > 0 && now - lastTapAt <= doubleTapInterval) {
lastTapAt = -1
Quickshell.execDetached(["qs", "-c", "ii", "ipc", "call", "dock", "fullscreen"])
} else {
lastTapAt = now
}
}
onDoubleClicked: {
fired = true
// App-mode fullscreen = mode 0. Whole tiled area, no gaps,
// no border. Route through ii's "dock" IPC (not hyprctl):
// tapping the pill focuses the shell, so hyprctl would act on
// the pill; ii's fullscreen() targets the real app toplevel.
Quickshell.execDetached(["qs", "-c", "ii", "ipc", "call", "dock", "fullscreen"])
onCanceled: {
pill.width = 150
lastTapAt = -1
}
}
}

View file

@ -84,13 +84,21 @@ Singleton {
});
root.models = map;
root.modelList = Object.keys(map);
// Restore persisted agent choice if it exists server-side
// Restore the selected agent, then derive its latest conversation
// from the server. No per-agent conversation map lives in the UI.
const persisted = Persistent.states?.ai?.model ?? "";
if (persisted.length > 0 && Souveraine.agents[persisted]) {
Souveraine.selectAgent(persisted);
} else if (Souveraine.currentAgentId.length > 0) {
Souveraine.selectAgent(Souveraine.currentAgentId);
}
}
function onConversationResumed(agentId, conversationId, messages) {
if (agentId !== Souveraine.currentAgentId) return;
root.restoreServerConversation(messages);
}
function onServerUnreachable() {
root.addMessage(
Translation.tr("Souveraine server unreachable at %1\n\nStart it with:\n```bash\nsouveraine server\n```").arg(Souveraine.serverBase),
@ -261,6 +269,38 @@ Singleton {
Souveraine.newConversation();
}
// Render the server's canonical transcript after /agent or /resume.
// This replaces ii's old local snapshot behaviour: the next send remains
// on the same live Souveraine conversation rather than silently forking.
function restoreServerConversation(messages) {
root.messageIDs = [];
root.messageByID = ({});
root.streamingMessage = null;
root.inThinkBlock = false;
root.subconsciousBuffer = "";
root.tokenCount.input = -1;
root.tokenCount.output = -1;
root.tokenCount.total = -1;
messages.forEach(message => {
const content = (message.blocks ?? []).map(block => {
switch (block.type) {
case "text": return block.text ?? "";
case "reasoning": return `<think>\n${block.reasoning ?? ""}\n</think>`;
case "tool_use": return `sensor: ${block.name ?? "?"}(${block.input ?? ""})`;
case "tool_result": return `[${block.tool_name ?? "tool"}] ${block.output ?? ""}`;
case "image": return "[image]";
default: return "";
}
}).filter(Boolean).join("\n");
if (content.length === 0) return;
const role = message.role === "user"
? "user"
: message.role === "assistant" ? "assistant" : root.interfaceRole;
root.addMessage(content, role);
});
}
function sendUserMessage(message) {
if (message.length === 0) return;
root.addMessage(message, "user");
@ -301,9 +341,18 @@ Singleton {
modelId = match;
}
if (setPersistentState) Persistent.states.ai.model = modelId;
Souveraine.selectAgent(modelId);
if (!Souveraine.selectAgent(modelId)) {
if (feedback) root.addMessage(Translation.tr("Finish or cancel the current turn before switching agents."), root.interfaceRole);
return;
}
root.currentModel = models[modelId];
if (feedback) root.addMessage(Translation.tr("Agent set to %1").arg(models[modelId].name), root.interfaceRole);
if (feedback) root.addMessage(Translation.tr("Switched to %1 — resuming her latest conversation.").arg(models[modelId].name), root.interfaceRole);
}
function resumeConversation() {
if (!Souveraine.resumeLatestConversation()) {
root.addMessage(Translation.tr("Finish or cancel the current turn before resuming."), root.interfaceRole);
}
}
// Souveraine-owned settings: advice instead of local state

View file

@ -18,7 +18,7 @@ import QtQuick
*
* Responsibilities:
* - agent inventory (GET /v1/agents)
* - conversation lifecycle (create; resume/fork verbs to come)
* - conversation lifecycle (create and server-derived resume)
* - the SSE turn stream raw events re-emitted via streamEvent(var)
* - the backchannel: cancelTurn() and interject(text)
* - the desktop sensorium: every send carries ambient context (active
@ -53,6 +53,9 @@ Singleton {
signal streamClosed(int exitCode)
signal agentsRefreshed()
signal serverUnreachable()
// Emitted after a server-owned conversation has been selected and its
// persisted transcript loaded for the active surface.
signal conversationResumed(string agentId, string conversationId, var messages)
// Agent inventory
Process {
@ -127,8 +130,12 @@ Singleton {
function selectAgent(agentId) {
if (!root.agents[agentId]) return false;
// A live turn belongs to the current conversation. Switching beneath
// it would render one agent's response in another agent's surface.
if (root.turnActive) return false;
root.currentAgentId = agentId;
root.conversationId = ""; // new agent, new conversation
root.conversationId = "";
root.resumeLatestConversation();
return true;
}
@ -136,6 +143,85 @@ Singleton {
root.conversationId = "";
}
// Server-derived resume
// The GUI keeps no per-agent conversation map. The server is the source
// of truth: it persists conversations under each agent and this query
// hydrates them after a server restart before returning the latest one.
Process {
id: listConversations
property string agentId: ""
stdout: StdioCollector {
onStreamFinished: {
let conversations = [];
try {
conversations = text.length > 0 ? JSON.parse(text) : [];
} catch (e) {
console.log("[Souveraine] Could not parse conversation list:", e);
}
if (listConversations.agentId !== root.currentAgentId) return;
if (conversations.length === 0) {
root.conversationId = "";
root.conversationResumed(root.currentAgentId, "", []);
return;
}
root._loadConversation(listConversations.agentId, conversations[0].id);
}
}
onExited: exitCode => {
if (exitCode !== 0 && listConversations.agentId === root.currentAgentId) {
root.conversationId = "";
root.conversationResumed(root.currentAgentId, "", []);
}
}
}
Process {
id: loadConversation
property string agentId: ""
property string requestedConversationId: ""
stdout: StdioCollector {
onStreamFinished: {
try {
const messages = text.length > 0 ? JSON.parse(text) : [];
if (loadConversation.agentId !== root.currentAgentId) return;
root.conversationId = loadConversation.requestedConversationId;
root.conversationResumed(root.currentAgentId, root.conversationId, messages);
} catch (e) {
console.log("[Souveraine] Could not parse conversation transcript:", e);
root.conversationResumed(root.currentAgentId, "", []);
}
}
}
onExited: exitCode => {
if (exitCode !== 0 && loadConversation.agentId === root.currentAgentId) {
root.conversationId = "";
root.conversationResumed(root.currentAgentId, "", []);
}
}
}
function resumeLatestConversation() {
if (!root.serverUp || root.currentAgentId.length === 0 || root.turnActive) return false;
listConversations.agentId = root.currentAgentId;
listConversations.command = [
"curl", "-sf", "--max-time", "5",
`${root.serverBase}/v1/conversations?agent_id=${encodeURIComponent(root.currentAgentId)}`
];
listConversations.running = true;
return true;
}
function _loadConversation(agentId, conversationId) {
loadConversation.agentId = agentId;
loadConversation.requestedConversationId = conversationId;
loadConversation.command = ["bash", "-c",
root._tokenReadLine(agentId)
+ `curl -sf --max-time 10 "${root.serverBase}/v1/conversations/${conversationId}/messages"`
+ ` -H "Authorization: Bearer $TOKEN"`
];
loadConversation.running = true;
}
// Ambient sensorium
// What the desktop feels like at the moment of speaking. Cheap,
// synchronous reads here; the cursor needs a hyprctl round-trip and is