Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 40 additions & 6 deletions crates/cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5131,7 +5131,11 @@ async fn run_with_socket_initial_selection(
let sessions = client.list().await.unwrap_or_default();
let groups = client.list_projects().await.unwrap_or_default();
let mut services = client.list_services().await.unwrap_or_default();
services.sort_by(|a, b| a.name.cmp(&b.name));
services.sort_by(|a, b| {
a.position
.cmp(&b.position)
.then_with(|| a.name.cmp(&b.name))
});
let mut service_channel_catalog = client
.list_service_channel_catalog()
.await
Expand Down Expand Up @@ -8120,11 +8124,16 @@ impl App {
let mut out: Vec<ListItem> = Vec::new();

// Services are ordinary top-level list rows rather than a separate
// sidebar section. Keep their order stable even if a notification
// arrives with an unsorted service vector. Their routed sessions are
// inserted below each row after the session-tree indexes exist.
// sidebar section. Their persisted positions order the section; name
// keeps legacy equal-position definitions deterministic until moved.
// Routed sessions are inserted below each service row after the
// session-tree indexes exist.
let mut services = self.services.clone();
services.sort_by(|a, b| a.name.cmp(&b.name));
services.sort_by(|a, b| {
a.position
.cmp(&b.position)
.then_with(|| a.name.cmp(&b.name))
});

let orch_id = self.orchestrator_id.as_deref();
let mut subagents_by_parent: HashMap<&str, Vec<&SessionSummary>> = HashMap::new();
Expand Down Expand Up @@ -8975,7 +8984,10 @@ impl App {
self.set_status(format!("move failed: {e}"));
}
}
Selection::Service(_) => {}
Selection::Service(name) => match self.client.move_service(&name, dir).await {
Ok(()) => self.refresh_services().await,
Err(e) => self.set_status(format!("move failed: {e}")),
},
Selection::None => self.set_status("nothing selected".into()),
// The "N archived" disclosure row isn't reorderable.
Selection::ArchivedRow(_) => {}
Expand Down Expand Up @@ -41590,6 +41602,7 @@ mod tests {
fn service_summary_for_test(name: &str) -> construct_protocol::ServiceSummary {
construct_protocol::ServiceSummary {
name: name.to_string(),
position: 0,
instruction: "Answer briefly.".to_string(),
harness: "smith".to_string(),
model: Some("test-model".to_string()),
Expand Down Expand Up @@ -41643,6 +41656,27 @@ mod tests {
server.abort();
}

#[tokio::test]
async fn services_render_in_persisted_position_order() {
let (mut app, _dir, server) = test_app_with_lineage().await;
let mut later = service_summary_for_test("alpha");
later.position = 2;
let mut earlier = service_summary_for_test("zulu");
earlier.position = 1;
app.services = vec![later, earlier];

let items = app.list_items();
assert!(matches!(
&items[0],
ListItem::Service { summary, .. } if summary.name == "zulu"
));
assert!(matches!(
&items[1],
ListItem::Service { summary, .. } if summary.name == "alpha"
));
server.abort();
}

#[tokio::test]
async fn service_view_title_bar_includes_the_service_glyph() {
let (mut app, _dir, server) = captured_app().await;
Expand Down
13 changes: 12 additions & 1 deletion crates/cli/src/app/service_dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,13 @@ fn default_service(app: &App, suggested: String) -> ServiceSummary {
let selected = app.selected_session();
ServiceSummary {
name: suggested,
position: app
.services
.iter()
.map(|service| service.position)
.max()
.map(|position| position.saturating_add(1))
.unwrap_or_default(),
instruction: String::new(),
harness: selected
.map(|session| session.harness.clone())
Expand Down Expand Up @@ -502,7 +509,11 @@ impl App {
pub async fn refresh_services(&mut self) {
match self.client.list_services().await {
Ok(mut services) => {
services.sort_by(|a, b| a.name.cmp(&b.name));
services.sort_by(|a, b| {
a.position
.cmp(&b.position)
.then_with(|| a.name.cmp(&b.name))
});
self.services = services;
}
Err(error) => self.set_status(format!("services refresh failed: {error}")),
Expand Down
10 changes: 10 additions & 0 deletions crates/client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,16 @@ impl Client {
)
.await
}
pub async fn move_service(&self, name: &str, direction: MoveDirection) -> Result<()> {
self.request(
ipc_method::SERVICE_MOVE,
&construct_protocol::ServiceMoveParams {
name: name.to_string(),
direction,
},
)
.await
}
pub async fn list_service_channels(
&self,
service_name: &str,
Expand Down
72 changes: 57 additions & 15 deletions crates/daemon/assets/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7099,7 +7099,10 @@ <h2 id="serviceViewTitle"></h2>
const service = { ...state.serviceDraft, model: state.serviceDraft.model || null };
try {
const result = await rpc("service.put", { service });
state.services = state.services.filter((item) => item.name !== service.name).concat(result.service).sort((a, b) => a.name.localeCompare(b.name));
state.services = state.services
.filter((item) => item.name !== service.name)
.concat(result.service)
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0) || a.name.localeCompare(b.name));
if (previousName && previousName !== result.service.name) {
renameServiceInLayout(previousName, result.service.name);
}
Expand Down Expand Up @@ -7317,7 +7320,7 @@ <h2 id="serviceViewTitle"></h2>
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
const services = state.services
.slice()
.sort((a, b) => String(a.name || "").localeCompare(String(b.name || "")));
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0) || String(a.name || "").localeCompare(String(b.name || "")));
const items = [];
if (operator) items.push({ kind: "session", session: operator, isOperator: true, depth: 0 });
for (const service of services) items.push({ kind: "service", service, depth: 0 });
Expand Down Expand Up @@ -7541,11 +7544,10 @@ <h2 id="serviceViewTitle"></h2>
//
// Rows reorder by direct manipulation: grab with the mouse, or
// press-and-hold on touch (so plain swipes keep scrolling the list),
// then drop on the target position. The daemon's reorder primitive is
// single-step `session.move` — which enforces region boundaries
// (pinned block, fork sibling runs, group membership) — so a drop
// replays one step per row crossed and stops early at a region edge,
// exactly like tapping the old ↑/↓ buttons repeatedly.
// then drop on the target position. The daemon's reorder primitives are
// single-step moves within each row kind, so a drop replays one step per row
// crossed and respects the same session, service, and project boundaries as
// the keyboard commands.
const listDrag = {
armed: false, // pointer is down on a draggable row
active: false, // drag visuals engaged
Expand All @@ -7556,6 +7558,7 @@ <h2 id="serviceViewTitle"></h2>
holdTimer: null, // touch press-and-hold timer
sourceId: null,
sourceGroupId: null,
sourceServiceName: null,
targetEl: null,
dropAfter: false,
suppressClick: false, // swallow the click that follows a drag
Expand Down Expand Up @@ -7638,6 +7641,7 @@ <h2 id="serviceViewTitle"></h2>
const steps = key === "ArrowDown" ? 1 : -1;
if (sessionId) moveSessionSteps(sessionId, steps);
else if (groupId) moveGroupSteps(groupId, steps);
else if (serviceName) moveServiceSteps(serviceName, steps);
else return;
ev.preventDefault();
return;
Expand Down Expand Up @@ -7697,6 +7701,9 @@ <h2 id="serviceViewTitle"></h2>
if (listDrag.sourceGroupId) {
return sessionListEl.querySelector(`.group-header[data-group-id="${CSS.escape(listDrag.sourceGroupId)}"]`);
}
if (listDrag.sourceServiceName) {
return sessionListEl.querySelector(`.item[data-service-name="${CSS.escape(listDrag.sourceServiceName)}"]`);
}
return null;
}

Expand Down Expand Up @@ -7726,9 +7733,16 @@ <h2 id="serviceViewTitle"></h2>
listDragClearIndicator();
const under = document.elementFromPoint(x, y);
const isGroup = !!listDrag.sourceGroupId;
const row = under && under.closest ? under.closest(isGroup ? ".session-list .group-header[data-group-id]" : ".session-list .item[data-id]") : null;
const isService = !!listDrag.sourceServiceName;
const selector = isGroup
? ".session-list .group-header[data-group-id]"
: isService
? ".session-list .item[data-service-name]"
: ".session-list .item[data-id]";
const row = under && under.closest ? under.closest(selector) : null;
if (!row || !sessionListEl.contains(row)) return;
if (isGroup && row.dataset.groupId === listDrag.sourceGroupId) return;
if (isService && row.dataset.serviceName === listDrag.sourceServiceName) return;
if (!isGroup && (row.dataset.id === listDrag.sourceId || row.classList.contains("is-operator"))) return;
const r = row.getBoundingClientRect();
listDrag.dropAfter = y > r.top + r.height / 2;
Expand Down Expand Up @@ -7765,37 +7779,61 @@ <h2 id="serviceViewTitle"></h2>
}
}

async function moveServiceSteps(serviceName, steps) {
const direction = steps > 0 ? "down" : "up";
for (let i = 0; i < Math.abs(steps); i += 1) {
try {
await rpc("service.move", { name: serviceName, direction });
} catch (e) {
appendError(`reorder failed: ${e.message}`);
break;
}
}
await refreshSessions();
refocusListRow(serviceName, true);
}

function listDragFinish(commit) {
clearTimeout(listDrag.holdTimer);
listDrag.holdTimer = null;
const wasActive = listDrag.active;
const sourceId = listDrag.sourceId;
const sourceGroupId = listDrag.sourceGroupId;
const sourceServiceName = listDrag.sourceServiceName;
const targetEl = listDrag.targetEl;
const dropAfter = listDrag.dropAfter;
listDrag.armed = false;
listDrag.active = false;
listDrag.pointerId = null;
listDrag.sourceId = null;
listDrag.sourceGroupId = null;
listDrag.sourceServiceName = null;
sessionListEl.classList.remove("drag-active");
const src = sourceId
? sessionListEl.querySelector(`.item[data-id="${CSS.escape(sourceId)}"]`)
: sourceGroupId
? sessionListEl.querySelector(`.group-header[data-group-id="${CSS.escape(sourceGroupId)}"]`)
: null;
: sourceServiceName
? sessionListEl.querySelector(`.item[data-service-name="${CSS.escape(sourceServiceName)}"]`)
: null;
if (src) src.classList.remove("drag-source");
listDragClearIndicator();
if (!wasActive) return;
listDrag.suppressClick = true;
if (commit && (sourceId || sourceGroupId) && targetEl) {
if (commit && (sourceId || sourceGroupId || sourceServiceName) && targetEl) {
const isGroup = !!sourceGroupId;
const isService = !!sourceServiceName;
const selector = isGroup
? ".group-header[data-group-id]"
: isService
? ".item[data-service-name]"
: ".item[data-id]:not(.is-operator)";
const ids = Array.from(
sessionListEl.querySelectorAll(isGroup ? ".group-header[data-group-id]" : ".item[data-id]:not(.is-operator)")
).map((el) => isGroup ? el.dataset.groupId : el.dataset.id);
const idToMove = isGroup ? sourceGroupId : sourceId;
sessionListEl.querySelectorAll(selector)
).map((el) => isGroup ? el.dataset.groupId : isService ? el.dataset.serviceName : el.dataset.id);
const idToMove = isGroup ? sourceGroupId : isService ? sourceServiceName : sourceId;
const from = ids.indexOf(idToMove);
const targetId = isGroup ? targetEl.dataset.groupId : targetEl.dataset.id;
const targetId = isGroup ? targetEl.dataset.groupId : isService ? targetEl.dataset.serviceName : targetEl.dataset.id;
const slot = ids.indexOf(targetId) + (dropAfter ? 1 : 0);
if (from >= 0 && slot >= 0) {
// Removing the row from its old index shifts every later slot
Expand All @@ -7805,6 +7843,8 @@ <h2 id="serviceViewTitle"></h2>
if (steps !== 0) {
if (isGroup) {
moveGroupSteps(idToMove, steps);
} else if (isService) {
moveServiceSteps(idToMove, steps);
} else {
moveSessionSteps(idToMove, steps);
}
Expand All @@ -7822,7 +7862,7 @@ <h2 id="serviceViewTitle"></h2>
sessionListEl.__dragBound = true;
sessionListEl.addEventListener("pointerdown", (ev) => {
if (ev.button !== 0 || listDrag.armed) return;
const row = ev.target.closest(".item[data-id], .group-header[data-group-id]");
const row = ev.target.closest(".item[data-id], .item[data-service-name], .group-header[data-group-id]");
if (!row || row.classList.contains("is-operator")) return;
if (ev.target.closest(".disclosure.can-toggle")) return;
listDrag.armed = true;
Expand All @@ -7832,6 +7872,8 @@ <h2 id="serviceViewTitle"></h2>
listDrag.startY = ev.clientY;
if (row.classList.contains("group-header")) {
listDrag.sourceGroupId = row.dataset.groupId;
} else if (row.dataset.serviceName) {
listDrag.sourceServiceName = row.dataset.serviceName;
} else {
listDrag.sourceId = row.dataset.id;
}
Expand Down
11 changes: 11 additions & 0 deletions crates/daemon/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1460,6 +1460,17 @@ pub(crate) async fn dispatch(
Err(e) => Response::err(req.id.clone(), ErrorObject::invalid_params(e.to_string())),
}
});
dispatch_entry!(ipc_method::SERVICE_MOVE, {
let p = params!(req, construct_protocol::ServiceMoveParams);
match crate::service::move_definition(
&construct_protocol::paths::Paths::discover().services_dir(),
&p.name,
p.direction,
) {
Ok(()) => Response::ok(req.id.clone(), serde_json::Value::Null),
Err(e) => Response::err(req.id.clone(), ErrorObject::invalid_params(e.to_string())),
}
});
dispatch_entry!(ipc_method::SERVICE_CHANNEL_LIST, {
let p = params!(req, construct_protocol::ServiceNameParams);
match crate::service::list_channel_summaries(
Expand Down
Loading
Loading