feat: add actor behavior editor to world

Add actor-local temporary behavior editing, reload-from-source, and confirmed updates to the shared source while guarding unsaved drafts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-04 06:38:47 +01:00
parent 2f9d500ca3
commit c697010765
4 changed files with 259 additions and 12 deletions

View File

@ -13,6 +13,8 @@ All notable changes to this project will be documented in this file.
source commits, and explicit image-event collection.
- Add committed source history browsing, revision preview, and confirmed
rollback to the graphical source editor.
- Add actor-local temporary behavior editing and guarded shared-source updates
to the graphical actor inspector.
### Changed

View File

@ -90,6 +90,9 @@ execution-stack inspection, and retained logs. Its source view supports
editing image sources with optimistic conflict checks, compilation diagnostics,
explicit history commits, and revision preview/rollback. Rollback requires
confirmation and creates a new current source version without deleting history.
The actor inspector can also load and edit an actor-local temporary behavior,
save it with source-identity checks, reload the behavior from the image source,
or explicitly confirm updating the shared source from the temporary version.
The events view has a manual collection action:
collecting events consumes the image's shared event queue, so other clients do
not receive events collected by the world client. Rune frame locals and

View File

@ -51,6 +51,13 @@ enum WorkerUpdate {
submitted_source: String,
},
SourceDiagnostics(Vec<SourceDiagnostic>),
ActorBehavior {
actor_ref: String,
path: String,
source_id: String,
source: String,
submitted_source: Option<String>,
},
SourceHistory {
path: String,
history: Vec<SourceCommitDto>,
@ -74,6 +81,7 @@ enum WorldView {
struct WorkerSnapshot {
actors: Vec<ActorSnapshotDto>,
sources: Vec<String>,
source_ids: BTreeMap<String, String>,
actor: Option<ActorSnapshotDto>,
execution: Option<ExecutionInspectionDto>,
logs: Vec<LogEntry>,
@ -87,6 +95,7 @@ struct WorldApp {
notice: String,
actors: Vec<ActorSnapshotDto>,
sources: Vec<String>,
source_ids: BTreeMap<String, String>,
selected_actor: Option<String>,
actor: Option<ActorSnapshotDto>,
execution: Option<ExecutionInspectionDto>,
@ -110,6 +119,10 @@ struct WorldApp {
revision_preview: Option<(String, String)>,
rollback_confirmation: Option<(String, String)>,
events: Vec<EventDto>,
actor_behavior: Option<SourceFileDto>,
actor_behavior_draft: String,
actor_behavior_dirty: bool,
update_behavior_confirmation: bool,
}
impl WorldApp {
@ -129,6 +142,7 @@ impl WorldApp {
notice: String::new(),
actors: Vec::new(),
sources: Vec::new(),
source_ids: BTreeMap::new(),
selected_actor: None,
actor: None,
execution: None,
@ -152,6 +166,10 @@ impl WorldApp {
revision_preview: None,
rollback_confirmation: None,
events: Vec::new(),
actor_behavior: None,
actor_behavior_draft: String::new(),
actor_behavior_dirty: false,
update_behavior_confirmation: false,
}
}
@ -163,11 +181,20 @@ impl WorldApp {
fn choose_actor(&mut self, actor_ref: Option<String>) {
if self.selected_actor != actor_ref {
if self.actor_behavior_dirty {
self.notice =
"Save or discard the current actor behavior draft before changing actors"
.to_owned();
return;
}
self.selected_actor = actor_ref.clone();
self.actor = None;
self.execution = None;
self.logs.clear();
self.breakpoints.clear();
self.actor_behavior = None;
self.actor_behavior_draft.clear();
self.actor_behavior_dirty = false;
let _ = self.requests.send(WorkerRequest::Select(actor_ref));
}
}
@ -202,6 +229,7 @@ impl WorldApp {
self.choose_actor(None);
self.actors.clear();
self.sources.clear();
self.source_ids.clear();
self.actor = None;
self.execution = None;
self.logs.clear();
@ -213,6 +241,9 @@ impl WorldApp {
self.selected_revision = None;
self.revision_preview = None;
self.events.clear();
self.actor_behavior = None;
self.actor_behavior_draft.clear();
self.actor_behavior_dirty = false;
}
Ok(WorkerUpdate::SourceLoaded(source)) => {
if self.loading_source.as_deref() == Some(source.path.as_str()) {
@ -239,6 +270,28 @@ impl WorldApp {
Ok(WorkerUpdate::SourceDiagnostics(diagnostics)) => {
self.source_diagnostics = diagnostics;
}
Ok(WorkerUpdate::ActorBehavior {
actor_ref,
path,
source_id,
source,
submitted_source,
}) => {
if self.selected_actor.as_deref() == Some(&actor_ref) {
if submitted_source
.as_ref()
.is_none_or(|submitted| self.actor_behavior_draft == *submitted)
{
self.actor_behavior_draft.clone_from(&source);
}
self.actor_behavior_dirty = self.actor_behavior_draft != source;
self.actor_behavior = Some(SourceFileDto {
path,
source_id,
source,
});
}
}
Ok(WorkerUpdate::SourceHistory { path, history }) => {
if self.source_path() != Some(path.as_str()) {
continue;
@ -272,8 +325,20 @@ impl WorldApp {
{
self.choose_actor(Some(first.actor_ref.clone()));
}
if let (Some(previous), Some(current)) = (&self.actor, &snapshot.actor)
&& previous.actor_ref == current.actor_ref
&& previous.temporary_behavior
&& !current.temporary_behavior
{
let _ =
self.requests
.send(WorkerRequest::Command(Command::ActorBehavior {
actor_ref: current.actor_ref.clone(),
}));
}
self.actors = snapshot.actors;
self.sources = snapshot.sources;
self.source_ids = snapshot.source_ids;
self.actor = snapshot.actor;
self.execution = snapshot.execution;
self.logs = snapshot.logs;
@ -306,9 +371,10 @@ impl WorldApp {
ui.label("Image");
ui.text_edit_singleline(&mut self.endpoint);
if ui.button("Connect").clicked() {
if self.source_dirty {
if self.source_dirty || self.actor_behavior_dirty {
self.notice =
"Save or discard the current source draft before reconnecting".to_owned();
"Save or discard the current source and behavior drafts before reconnecting"
.to_owned();
} else {
match self.endpoint.parse() {
Ok(address) => {
@ -479,11 +545,136 @@ impl WorldApp {
}
});
self.draw_message_sender(ui, &actor.actor_ref);
self.draw_actor_behavior_editor(ui, &actor);
self.draw_debugger(ui, &actor);
self.draw_logs(ui, &actor.actor_ref);
});
}
fn draw_actor_behavior_editor(&mut self, ui: &mut egui::Ui, actor: &ActorSnapshotDto) {
ui.collapsing("Actor behavior", |ui| {
ui.horizontal(|ui| {
ui.label(actor.behavior_path.as_deref().unwrap_or("No source path"));
if ui.button("Load active behavior").clicked() {
self.submit(Command::ActorBehavior {
actor_ref: actor.actor_ref.clone(),
});
}
if ui
.add_enabled(
!self.actor_behavior_dirty,
egui::Button::new("Reload from source"),
)
.clicked()
{
self.submit(Command::System {
actor_ref: actor.actor_ref.clone(),
message: SystemMessageDto::Reload,
});
}
});
if self.actor_behavior_dirty {
ui.colored_label(egui::Color32::YELLOW, "Unsaved temporary draft");
if ui.button("Discard draft").clicked()
&& let Some(source) = &self.actor_behavior
{
self.actor_behavior_draft.clone_from(&source.source);
self.actor_behavior_dirty = false;
}
}
ui.horizontal(|ui| {
if ui
.add_enabled(
self.actor_behavior.is_some() && self.actor_behavior_dirty,
egui::Button::new("Save as temporary behavior"),
)
.clicked()
&& let Some(source) = &self.actor_behavior
{
self.submit(Command::WriteActorBehavior {
actor_ref: actor.actor_ref.clone(),
expected_source_id: source.source_id.clone(),
source: self.actor_behavior_draft.clone(),
});
}
if ui
.add_enabled(
actor.temporary_behavior && !self.actor_behavior_dirty,
egui::Button::new("Update shared source"),
)
.clicked()
{
self.update_behavior_confirmation = true;
}
});
if self.actor_behavior.is_none() {
ui.label(
"Load the actor's current behavior to edit an actor-local temporary copy.",
);
return;
}
let mut layouter = |ui: &egui::Ui, source: &str, wrap_width: f32| {
let mut job = rune_layout_job(source);
job.wrap.max_width = wrap_width;
ui.fonts(|fonts| fonts.layout_job(job))
};
let response = ui.add(
egui::TextEdit::multiline(&mut self.actor_behavior_draft)
.code_editor()
.layouter(&mut layouter)
.desired_width(f32::INFINITY)
.desired_rows(12),
);
if response.changed() {
self.actor_behavior_dirty = self
.actor_behavior
.as_ref()
.is_some_and(|source| self.actor_behavior_draft != source.source);
}
});
if self.update_behavior_confirmation {
egui::Window::new("Confirm shared behavior update")
.collapsible(false)
.resizable(false)
.show(ui.ctx(), |ui| {
ui.label(format!(
"Overwrite `{}` in the image source tree with this actor's temporary behavior?",
self.actor_behavior
.as_ref()
.map_or("unknown source", |source| source.path.as_str())
));
ui.horizontal(|ui| {
if ui.button("Update source").clicked() {
let Some(temporary) = &self.actor_behavior else {
self.update_behavior_confirmation = false;
return;
};
let expected_source_id = self.source_ids.get(&temporary.path).cloned();
match expected_source_id {
Some(expected_source_id) => self.submit(
Command::UpdateBehaviorSource {
actor_ref: actor.actor_ref.clone(),
path: temporary.path.clone(),
expected_source_id,
temporary_source_id: temporary.source_id.clone(),
},
),
None => {
self.notice =
"Could not determine the current source identity; refresh and try again"
.to_owned()
}
}
self.update_behavior_confirmation = false;
}
if ui.button("Cancel").clicked() {
self.update_behavior_confirmation = false;
}
});
});
}
}
fn draw_message_sender(&mut self, ui: &mut egui::Ui, actor_ref: &str) {
ui.collapsing("Send message", |ui| {
ui.horizontal(|ui| {
@ -864,6 +1055,7 @@ fn request_reply(client: &mut WebSocketClient, command: Command) -> Result<Reply
fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate>) {
let mut client = None;
let mut selected_actor = None;
let mut selected_behavior_path = None;
let mut log_sequence = 0;
let mut retained_logs = Vec::new();
let mut next_poll = Instant::now();
@ -873,6 +1065,7 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
Ok(connected) => {
client = Some(connected);
selected_actor = None;
selected_behavior_path = None;
log_sequence = 0;
retained_logs.clear();
next_poll = Instant::now();
@ -887,6 +1080,7 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
},
Ok(WorkerRequest::Select(actor_ref)) => {
selected_actor = actor_ref;
selected_behavior_path = None;
log_sequence = 0;
retained_logs.clear();
next_poll = Instant::now();
@ -907,6 +1101,9 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
let operation = match &command {
Command::ReadSource { .. } => "read_source",
Command::WriteSource { .. } => "write_source",
Command::ActorBehavior { .. } => "actor_behavior",
Command::WriteActorBehavior { .. } => "write_actor_behavior",
Command::UpdateBehaviorSource { .. } => "update_behavior_source",
Command::CheckSource { .. } => "check_source",
Command::GetEvents => "get_events",
Command::CommitSource { .. } => "commit_source",
@ -916,7 +1113,8 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
_ => "command",
};
let submitted_source = match &command {
Command::WriteSource { source, .. } => Some(source.clone()),
Command::WriteSource { source, .. }
| Command::WriteActorBehavior { source, .. } => Some(source.clone()),
_ => None,
};
let revision = match &command {
@ -924,13 +1122,19 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
| Command::RollbackSource { revision, .. } => Some(revision.clone()),
_ => None,
};
let requested_actor_ref = match &command {
Command::ActorBehavior { actor_ref }
| Command::WriteActorBehavior { actor_ref, .. } => Some(actor_ref.clone()),
_ => None,
};
let source_path = match &command {
Command::ReadSource { path }
| Command::WriteSource { path, .. }
| Command::CommitSource { path }
| Command::SourceHistory { path }
| Command::RevisionSource { path, .. }
| Command::RollbackSource { path, .. } => Some(path.clone()),
| Command::RollbackSource { path, .. }
| Command::UpdateBehaviorSource { path, .. } => Some(path.clone()),
_ => None,
};
match request_reply(connected, command) {
@ -946,6 +1150,25 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
.expect("write operation includes its submitted source"),
})
}
("actor_behavior", Reply::ActorBehavior { source_id, source }) => {
Some(WorkerUpdate::ActorBehavior {
actor_ref: requested_actor_ref.unwrap_or_default(),
path: selected_behavior_path.clone().unwrap_or_default(),
source_id,
source,
submitted_source: None,
})
}
(
"write_actor_behavior",
Reply::ActorBehavior { source_id, source },
) => Some(WorkerUpdate::ActorBehavior {
actor_ref: requested_actor_ref.unwrap_or_default(),
path: selected_behavior_path.clone().unwrap_or_default(),
source_id,
source,
submitted_source,
}),
("check_source", Reply::SourceChecked { diagnostics }) => {
Some(WorkerUpdate::SourceDiagnostics(diagnostics))
}
@ -979,6 +1202,12 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
submitted_source,
})
}
("update_behavior_source", Reply::Source(source)) => {
Some(WorkerUpdate::SourceSaved {
source: source.clone(),
submitted_source: source.source,
})
}
(_, reply) => Some(WorkerUpdate::Notice(format!(
"Completed with unexpected response: {reply:?}"
))),
@ -1027,9 +1256,18 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
continue;
}
};
let sources = match request_reply(connected, Command::ListSources) {
Ok(Reply::Sources(sources)) => sources.into_iter().map(|source| source.path).collect(),
Ok(_) => Vec::new(),
let (sources, source_ids) = match request_reply(connected, Command::ListSources) {
Ok(Reply::Sources(sources)) => {
let source_ids = sources
.iter()
.map(|source| (source.path.clone(), source.source_id.clone()))
.collect();
(
sources.into_iter().map(|source| source.path).collect(),
source_ids,
)
}
Ok(_) => (Vec::new(), BTreeMap::new()),
Err(error) => {
report_call_error(error, &updates, &mut client);
continue;
@ -1041,6 +1279,7 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
.find(|actor| &actor.actor_ref == selected)
.cloned()
});
selected_behavior_path = actor.as_ref().and_then(|actor| actor.behavior_path.clone());
let (execution, logs) = if let Some(actor) = &actor {
let execution = match request_reply(
connected,
@ -1088,6 +1327,7 @@ fn client_worker(requests: Receiver<WorkerRequest>, updates: Sender<WorkerUpdate
let _ = updates.send(WorkerUpdate::Snapshot(Box::new(WorkerSnapshot {
actors,
sources,
source_ids,
actor,
execution,
logs,

View File

@ -224,11 +224,13 @@ loopback image over the shared WebSocket protocol. It currently provides an
actor browser and inspector with message, lifecycle, log, breakpoint, bounded
step, and execution-stack controls, plus a source editor with compilation
diagnostics, explicit source commits, committed-revision previews, and
confirmed rollback. Image events can be collected manually; this operation
drains the shared queue and therefore consumes events for other clients too.
Network requests run on a worker thread, not the UI frame loop. This is a
client foundation, not yet a Rune-composed graphical world; frame locals and
expression evaluation remain unavailable.
confirmed rollback. The actor inspector can edit an actor-local temporary
behavior and explicitly update its shared source or reload from that source.
Image events can be collected manually; this operation drains the shared queue
and therefore consumes events for other clients too. Network requests run on a
worker thread, not the UI frame loop. This is a client foundation, not yet a
Rune-composed graphical world; frame locals and expression evaluation remain
unavailable.
## 8. Unified Programmatic Image API