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
2 changes: 1 addition & 1 deletion crates/core/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ pub struct SessionMeta {
pub parent_session_id: Option<String>,
/// Set when this session mirrors a provider-native subagent transcript.
/// Value is the parent-timeline Subagent item id (reattach key). Mirror
/// sessions are read-only: no composer, no provider process.
/// sessions are read-only: disabled composer, no provider process.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub native_subagent: Option<String>,
/// Whether this session receives the tcode_orchestrate MCP registration.
Expand Down
36 changes: 35 additions & 1 deletion crates/runtime/src/app/subagents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ impl AppState {
agent_type,
description,
status,
model,
effort,
..
} = &item.content
else {
Expand All @@ -60,7 +62,34 @@ impl AppState {
if title_changed {
mirror.meta.title = title;
}
if !in_progress || title_changed {
let mut settings_changed = false;
if let Some(model) = model
&& mirror.meta.model.as_ref() != Some(model)
{
mirror.meta.model = Some(model.clone());
settings_changed = true;
}
if let Some(effort) = effort {
let value = serde_json::Value::String(effort.clone());
if let Some(selection) = mirror
.meta
.option_selections
.iter_mut()
.find(|selection| selection.id == "reasoningEffort")
{
if selection.value != value {
selection.value = value;
settings_changed = true;
}
} else {
mirror.meta.option_selections.push(OptionSelection {
id: "reasoningEffort".into(),
value,
});
settings_changed = true;
}
}
if !in_progress || title_changed || settings_changed {
mirror.meta.updated_at = now_secs();
Some(mirror.meta.clone())
} else {
Expand Down Expand Up @@ -154,6 +183,11 @@ impl AppState {
let mut meta = SessionMeta::new(parent.provider, parent.cwd.clone(), parent.model.clone());
meta.project_id = parent.project_id.clone();
meta.profile_id = parent.profile_id.clone();
// Native children inherit the launch settings until the provider reports
// a child-specific model or effort on its Subagent item.
meta.approval_mode = parent.approval_mode;
meta.interaction_mode = parent.interaction_mode;
meta.option_selections = parent.option_selections.clone();
meta.acp_agent_id = parent.acp_agent_id.clone();
meta.parent_session_id = Some(parent.id.clone());
meta.native_subagent = Some(subagent_item_id.to_string());
Expand Down
51 changes: 51 additions & 0 deletions crates/runtime/src/app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ fn provider_native_subagent_events_create_and_feed_read_only_mirror_session() {
);
parent_meta.id = "parent".into();
parent_meta.project_id = Some("project".into());
parent_meta.approval_mode = ApprovalMode::ReadOnly;
parent_meta.interaction_mode = InteractionMode::Plan;
parent_meta.option_selections = vec![OptionSelection {
id: "reasoningEffort".into(),
value: serde_json::json!("high"),
}];
state.sessions.push(parent_meta.clone());
state.install_selected(ActiveSession::new(parent_meta, false, Vec::new()));

Expand Down Expand Up @@ -63,6 +69,37 @@ fn provider_native_subagent_events_create_and_feed_read_only_mirror_session() {
.expect("native subagent mirror metadata");
assert_eq!(mirror.parent_session_id.as_deref(), Some("parent"));
assert_eq!(mirror.title, "explorer: Inspect event routing");
assert_eq!(mirror.approval_mode, ApprovalMode::ReadOnly);
assert_eq!(mirror.interaction_mode, InteractionMode::Plan);
assert_eq!(mirror.option_selections[0].value, serde_json::json!("high"));
assert_eq!(mirror.model.as_deref(), Some("gpt-test"));

state.on_event(
"parent",
AgentEvent::ItemUpdated(ThreadItem {
id: "spawn-1".into(),
parent_item_id: None,
content: ItemContent::Subagent {
agent_type: "explorer".into(),
description: "Inspect event routing".into(),
status: ItemStatus::InProgress,
summary: None,
model: Some("child-model".into()),
effort: Some("low".into()),
},
}),
cx,
);
let status = state.session_status_snapshot(&mirror.id).unwrap();
assert_eq!(status.requested_model.as_deref(), Some("child-model"));
assert_eq!(
status.provider_option_selections[0].value,
serde_json::json!("low")
);
assert_eq!(
state.resident("parent").unwrap().meta.model.as_deref(),
Some("gpt-test")
);
assert!(state.resident(&mirror.id).unwrap().has_work());
assert!(state.resident(&mirror.id).unwrap().timeline.turn_running);

Expand Down Expand Up @@ -126,6 +163,20 @@ fn provider_native_subagent_events_create_and_feed_read_only_mirror_session() {
.iter()
.find(|meta| meta.native_subagent.as_deref() == Some("spawn-1"))
.unwrap();
// A completion without model/effort must not erase the last observation,
// and reopening the persisted mirror must retain it.
let persisted = state
.store
.load_index()
.into_iter()
.find(|meta| meta.id == mirror.id)
.unwrap();
assert_eq!(persisted.model.as_deref(), Some("child-model"));
assert_eq!(
persisted.option_selections[0].value,
serde_json::json!("low")
);
assert_eq!(persisted.interaction_mode, InteractionMode::Plan);
let mirror_events = state.store.read_events(&mirror.id);
assert!(mirror_events.iter().any(|stored| matches!(
&stored.event,
Expand Down
22 changes: 1 addition & 21 deletions crates/ui/src/chat/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2635,15 +2635,6 @@ impl Render for ChatView {
.child(self.render_empty_state(window, cx));
};

let active_session_id = self.workspace_store.read(cx).active_session_id();
let native_subagent_readonly = active_session_id.as_deref().is_some_and(|active_id| {
self.workspace_store
.read(cx)
.sidebar_sessions()
.iter()
.any(|meta| meta.id == active_id && meta.native_subagent.is_some())
});

let title = if is_draft { None } else { Some(title) };
let header = self.render_header(title, is_draft, Some(cwd.clone()), window, cx);
let panel = self.workspace_store.read(cx).panel_state();
Expand Down Expand Up @@ -2792,18 +2783,7 @@ impl Render for ChatView {
.into_any_element()
};

let composer: AnyElement = if native_subagent_readonly {
div()
.w_full()
.py_3()
.text_center()
.text_size(px(12.))
.text_color(cx.theme().muted_foreground)
.child(crate::tr!("chat.subagent_readonly"))
.into_any_element()
} else {
self.composer.clone().into_any_element()
};
let composer = self.composer.clone().into_any_element();
let deliveries = self.workspace_store.read(cx).delivery_messages();
let waiting = *self.workspace_store.read(cx).connection_state()
!= tcode_client::ConnectionState::Connected;
Expand Down
45 changes: 41 additions & 4 deletions crates/ui/src/composer/components/pickers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ impl Composer {

let trigger = Button::new("model-picker")
.debug_selector(|| "model-picker".into())
.when(self.compact, |button| {
.when(self.compact || store.native_subagent_readonly(), |button| {
button.w_full().max_w(px(160.)).min_w_0().overflow_hidden()
})
.ghost()
Expand All @@ -212,14 +212,18 @@ impl Composer {
.rounded(crate::material::radius_input())
.child(
h_flex()
.when(self.compact, |el| el.w_full().min_w_0().overflow_hidden())
.when(self.compact || store.native_subagent_readonly(), |el| {
el.w_full().min_w_0().overflow_hidden()
})
.gap_1p5()
.items_center()
.text_size(px(13.))
.child(tinted_provider_glyph(provider, store).small())
.child(
div()
.when(self.compact, |el| el.min_w_0().truncate())
.when(self.compact || store.native_subagent_readonly(), |el| {
el.min_w_0().truncate()
})
.font_medium()
.child(display),
)
Expand All @@ -236,7 +240,7 @@ impl Composer {
.when(self.compact, |popover| {
popover.bottom_sheet(crate::tr!("mobile.model"))
})
.default_open(self.model_picker_token > 0)
.default_open(self.model_picker_token > 0 && self.interactive(cx))
.trigger(trigger)
.content(move |_state, _window, cx| {
let rows = rows.clone();
Expand Down Expand Up @@ -273,6 +277,39 @@ impl Composer {
// agents push theirs over the wire (`AgentEvent::ProviderOptions`). Both
// arrive as `OptionDescriptor`s and render through this one picker.
let descriptors = composer.active_option_descriptors.clone();
if store.native_subagent_readonly() {
let effort =
option_selection_str(&composer.active_option_selections, "reasoningEffort");
let descriptor = descriptors.iter().find(|option| {
matches!(option, OptionDescriptor::Select { id, .. } if id == "reasoningEffort")
});
let label = if let Some(OptionDescriptor::Select {
options,
default_value,
..
}) = descriptor
{
let value = effort.or(default_value.as_deref());
value.map(|value| {
options
.iter()
.find(|option| option.value == value)
.map_or_else(|| value.to_owned(), |option| option.label.clone())
})
} else {
effort.map(str::to_owned)
}
.unwrap_or_else(|| crate::tr!("composer.context_unknown").into_owned());
return Button::new("traits-chip")
.debug_selector(|| "traits-chip".into())
.ghost()
.compact()
.h(px(28.))
.when(self.compact, |button| button.min_h(px(44.)).min_w(px(44.)))
.disabled(true)
.label(label)
.into_any_element();
}
if descriptors.is_empty() {
return div().into_any_element();
}
Expand Down
Loading