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:
parent
707a277273
commit
2bd61ec4b3
17 changed files with 1292 additions and 166 deletions
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
205
saf/surface/04-agent-pane.md
Normal file
205
saf/surface/04-agent-pane.md
Normal 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.
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
173
surfaces/quickshell/modules/souveraine/agent/AgentPaneMenu.qml
Normal file
173
surfaces/quickshell/modules/souveraine/agent/AgentPaneMenu.qml
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
219
surfaces/quickshell/modules/souveraine/agent/ItineraryRibbon.qml
Normal file
219
surfaces/quickshell/modules/souveraine/agent/ItineraryRibbon.qml
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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`,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue