forked from 1b-agent/fieldcraft-upstream
The ribbon follows each command to its stage
A command run by a script, the control channel, MCP, the command search or the browser left the ribbon on whatever tab it showed, so an agent's session never showed the stage it was at. `catalogue::stage_tab` is the rule, from the catalogue: a command's entry tab, none for the Tools tab (files, history, the model summary). The app follows it after a command that changed the model or the results display (queries leave the ribbon alone); a script follows its last command with a stage. A ribbon click runs a command of the tab shown, so it never moves the ribbon: a test checks every entry and drop-down command against its tab. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
This commit is contained in:
1 parent
357de3f7a5
commit
34200013a4
3 files changed
+80
-2
No files matched your search
@@ -509,6 +509,15 @@ pub fn entry_of(command: &str) -> Option<&'static Capability> {
|
||||
CATALOGUE.iter().find(|c| c.command() == Some(command)).or_else(|| CATALOGUE.iter().find(|c| c.more.contains(&command)))
|
||||
}
|
||||
|
||||
/// The tab the ribbon follows to when `command` has run from anywhere but the ribbon (a script,
|
||||
/// the control channel, MCP, the command search, the browser), as a person's click on that tab
|
||||
/// would: its entry's tab. `None` for commands on the Tools tab (files, history, the model
|
||||
/// summary: on every workspace and no stage of an analysis) and for unknown commands. A ribbon
|
||||
/// click runs a command of the tab shown, so it never moves the ribbon (a test checks it).
|
||||
pub fn stage_tab(command: &str) -> Option<Tab> {
|
||||
entry_of(command).map(|c| c.tab).filter(|t| *t != Tab::Tools)
|
||||
}
|
||||
|
||||
/// The entries of a tab (one run of the catalogue).
|
||||
pub fn tab_entries(tab: Tab) -> &'static [Capability] {
|
||||
let start = CATALOGUE.iter().position(|c| c.tab == tab).unwrap_or(CATALOGUE.len());
|
||||
@@ -570,6 +579,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ribbon_follows_each_command_to_its_stage() {
|
||||
assert_eq!(stage_tab("mesh.hex.sweep"), Some(Tab::Mesh));
|
||||
assert_eq!(stage_tab("model.material.create"), Some(Tab::Properties));
|
||||
assert_eq!(stage_tab("model.material.edit"), Some(Tab::Properties));
|
||||
assert_eq!(stage_tab("model.load.create"), Some(Tab::Loads));
|
||||
assert_eq!(stage_tab("solve.static"), Some(Tab::Solve));
|
||||
assert_eq!(stage_tab("results.contour"), Some(Tab::Results));
|
||||
assert_eq!(stage_tab("edit.undo"), None);
|
||||
assert_eq!(stage_tab("file.save"), None);
|
||||
assert_eq!(stage_tab("model.info"), None);
|
||||
assert_eq!(stage_tab("no.such"), None);
|
||||
// A ribbon click (an entry's command, or one of its drop-down's) stays on the tab shown.
|
||||
for c in CATALOGUE {
|
||||
for command in c.command().into_iter().chain(c.more.iter().copied()) {
|
||||
assert!(stage_tab(command).is_none_or(|t| t == c.tab), "{}: `{command}` would move the ribbon from {:?}", c.id, c.tab);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_command_is_claimed_and_every_ready_entry_runs_a_command() {
|
||||
let mut claimed: Vec<&str> = Vec::new();
|
||||
|
||||
@@ -369,6 +369,9 @@ impl ModelApp {
|
||||
}
|
||||
// The results display commands reply with what is shown, which the viewport shows.
|
||||
let display = (id.starts_with("results.") && id != "results.reactions") || id == "solve.run";
|
||||
if r.is_ok() && (changed || display) {
|
||||
self.follow_stage(id);
|
||||
}
|
||||
match &r {
|
||||
Ok(v) if v.is_null() || changed || display => self.set_status(format!("{label} done"), false),
|
||||
Ok(v) => self.set_status(format!("{label}: {v}"), false),
|
||||
@@ -377,10 +380,17 @@ impl ModelApp {
|
||||
r
|
||||
}
|
||||
|
||||
/// Run a script (a JSON array of `{command, params}`), stopping at the first error.
|
||||
/// Run a script (a JSON array of `{command, params}`), stopping at the first error. The
|
||||
/// ribbon follows its last command that has a stage.
|
||||
pub fn run_script(&mut self, script: &Value) -> Result<(), String> {
|
||||
let r = self.session.run_script(script).map(|_| ()).map_err(|e| e.to_string());
|
||||
self.model_changed();
|
||||
if r.is_ok() {
|
||||
let commands = script.as_array().into_iter().flatten().filter_map(|step| step.get("command").and_then(Value::as_str));
|
||||
if let Some(id) = commands.rev().find(|id| catalogue::stage_tab(id).is_some()) {
|
||||
self.follow_stage(id);
|
||||
}
|
||||
}
|
||||
match &r {
|
||||
Ok(()) => self.set_status("Script done", false),
|
||||
Err(e) => self.set_status(format!("Script: {e}"), true),
|
||||
@@ -388,6 +398,16 @@ impl ModelApp {
|
||||
r
|
||||
}
|
||||
|
||||
/// The ribbon turns to the tab where `id` lives ([`catalogue::stage_tab`]), as a person's click
|
||||
/// there would, so commands run by scripts, the control channel and MCP show their stage.
|
||||
/// Called after a command changed the model or the results display; queries leave the ribbon
|
||||
/// alone, and a ribbon click never moves it.
|
||||
fn follow_stage(&mut self, id: &str) {
|
||||
if let Some(tab) = catalogue::stage_tab(id) {
|
||||
self.ui.select_tab(tab);
|
||||
}
|
||||
}
|
||||
|
||||
/// What a click on a toolbar entry, a menu row or a palette result does: a command without
|
||||
/// parameters runs, one with parameters opens its form, and the file entries (open, save,
|
||||
/// write a deck, recover) go through the host's file pickers. `id` is a command or a
|
||||
@@ -872,6 +892,30 @@ mod tests {
|
||||
assert!(app.status.as_ref().is_some_and(|s| s.text.contains("\"nodes\":1")));
|
||||
}
|
||||
|
||||
/// Commands run from outside the ribbon bring their tab; queries and Tools commands leave it.
|
||||
#[test]
|
||||
fn the_ribbon_follows_commands_to_their_stage() {
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
app.ui.select_tab(Tab::Results);
|
||||
app.run("mesh.node.add", json!({"label": 1, "xyz": [0, 0, 0]})).unwrap();
|
||||
assert_eq!((app.ui.workspace, app.ui.tab), (Workspace::Prepare, Tab::Mesh));
|
||||
app.run("model.material.create", json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}})).unwrap();
|
||||
assert_eq!((app.ui.workspace, app.ui.tab), (Workspace::Model, Tab::Properties));
|
||||
// A query and a failed command leave it; so does undo (Tools).
|
||||
app.run("mesh.quality", json!({})).ok();
|
||||
let _ = app.run("model.load.create", json!({"name": "bad"}));
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.ui.tab, Tab::Properties);
|
||||
// A script follows its last command with a stage.
|
||||
app.run_script(&json!([{"command": "mesh.node.add", "params": {"label": 2, "xyz": [1, 0, 0]}}, {"command": "model.info", "params": {}}]))
|
||||
.unwrap();
|
||||
assert_eq!(app.ui.tab, Tab::Mesh);
|
||||
// The ribbon's own entries never move it.
|
||||
app.ui.select_tab(Tab::Properties);
|
||||
app.start("model.units").unwrap();
|
||||
assert_eq!(app.ui.tab, Tab::Properties);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starting_a_command_opens_its_form_and_running_it_closes_the_form() {
|
||||
let mut app = ModelApp::new(Session::default());
|
||||
|
||||
@@ -30,7 +30,12 @@ checked and clamped (drag steps, wheel lines, window size); `ui.render` refuses
|
||||
|
||||
Commands never open forms or file pickers when run this way. `fieldcraft-cli commands` prints the
|
||||
same registry. `file.sample {name}` opens a sample (`parts`, `cantilever`, `impact`) and
|
||||
`solve.run {}` solves every step; the Results workspace comes up when a result appears.
|
||||
`solve.run {}` solves every step; the Results workspace comes up when a result appears. The
|
||||
ribbon follows the commands run here, as it does for scripts, MCP, the command search and the
|
||||
browser: a command that changes the model or the results display brings the tab where its
|
||||
toolbar entry lives (Mesh for `mesh.*`, Properties, Steps, Loads & BCs… for `model.*`, Solve,
|
||||
Results), as a click there would. Queries (`model.check`, `mesh.quality`…) and the Tools tab's
|
||||
commands (files, undo, `model.info`) leave the ribbon where it is.
|
||||
|
||||
## UI
|
||||
|
||||
|
||||
Reference in new issue
Block a user