quickshell: resume agent conversations from server
This commit is contained in:
parent
a2731b418e
commit
6e21c50dcc
9 changed files with 338 additions and 127 deletions
|
|
@ -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()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue