From e2141d22b7ea7ae1bd06a2af6fa3788c41d7d576 Mon Sep 17 00:00:00 2001 From: Tryanks Date: Wed, 16 Sep 2026 16:17:14 +0800 Subject: [PATCH] Keep native subagent composers visible and disabled --- crates/core/src/project.rs | 2 +- crates/runtime/src/app/subagents.rs | 36 ++++- crates/runtime/src/app/tests.rs | 51 +++++++ crates/ui/src/chat/mod.rs | 22 +--- crates/ui/src/composer/components/pickers.rs | 45 ++++++- crates/ui/src/composer/mod.rs | 84 +++++++----- crates/ui/src/shell.rs | 132 +++++++++++++++++++ crates/ui/src/store/mod.rs | 9 ++ docs/DESIGN.md | 10 ++ 9 files changed, 334 insertions(+), 57 deletions(-) diff --git a/crates/core/src/project.rs b/crates/core/src/project.rs index 8448d709..f751bb46 100644 --- a/crates/core/src/project.rs +++ b/crates/core/src/project.rs @@ -119,7 +119,7 @@ pub struct SessionMeta { pub parent_session_id: Option, /// 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, /// Whether this session receives the tcode_orchestrate MCP registration. diff --git a/crates/runtime/src/app/subagents.rs b/crates/runtime/src/app/subagents.rs index 17b9685e..0b19f4fa 100644 --- a/crates/runtime/src/app/subagents.rs +++ b/crates/runtime/src/app/subagents.rs @@ -44,6 +44,8 @@ impl AppState { agent_type, description, status, + model, + effort, .. } = &item.content else { @@ -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 { @@ -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()); diff --git a/crates/runtime/src/app/tests.rs b/crates/runtime/src/app/tests.rs index 89715f2d..0ba6dc41 100644 --- a/crates/runtime/src/app/tests.rs +++ b/crates/runtime/src/app/tests.rs @@ -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())); @@ -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); @@ -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, diff --git a/crates/ui/src/chat/mod.rs b/crates/ui/src/chat/mod.rs index eaec4adf..87ac5d16 100644 --- a/crates/ui/src/chat/mod.rs +++ b/crates/ui/src/chat/mod.rs @@ -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(); @@ -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; diff --git a/crates/ui/src/composer/components/pickers.rs b/crates/ui/src/composer/components/pickers.rs index c3d24c93..30066c15 100644 --- a/crates/ui/src/composer/components/pickers.rs +++ b/crates/ui/src/composer/components/pickers.rs @@ -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() @@ -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), ) @@ -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(); @@ -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(); } diff --git a/crates/ui/src/composer/mod.rs b/crates/ui/src/composer/mod.rs index 0d5f4305..dc63c301 100644 --- a/crates/ui/src/composer/mod.rs +++ b/crates/ui/src/composer/mod.rs @@ -190,12 +190,13 @@ impl EventEmitter for Composer {} impl Composer { fn interactive(&self, cx: &App) -> bool { - !matches!(self.workspace_store.read(cx).connection_state(), + !self.workspace_store.read(cx).native_subagent_readonly() + && !matches!(self.workspace_store.read(cx).connection_state(), tcode_client::ConnectionState::Offline { reason } if reason.is_terminal()) } pub fn focus(&mut self, window: &mut Window, cx: &mut Context) { - if !self.compact && !crate::window_seam::uses_soft_keyboard(cx) { + if self.interactive(cx) && !self.compact && !crate::window_seam::uses_soft_keyboard(cx) { self.input.update(cx, |input, cx| input.focus(window, cx)); } } @@ -286,6 +287,7 @@ impl Composer { &workspace_store, &[ TopicKind::ActiveSession, + TopicKind::Index, TopicKind::SessionStatus, TopicKind::SessionEvents, TopicKind::Settings, @@ -522,6 +524,9 @@ impl Composer { window: &mut Window, cx: &mut Context, ) { + if self.workspace_store.read(cx).native_subagent_readonly() { + return; + } if self.compact && *self.workspace_store.read(cx).connection_state() != tcode_client::ConnectionState::Connected @@ -920,6 +925,17 @@ impl Composer { /// The composer's primary control: the stop button while a turn runs, the /// Refine / Implement (split) controls in the plan-ready state, else send. fn render_primary_action(&self, turn_running: bool, cx: &mut Context) -> AnyElement { + if self.workspace_store.read(cx).native_subagent_readonly() { + return Button::new("send-message") + .debug_selector(|| "send-message".into()) + .ghost() + .compact() + .disabled(true) + .aria_label(crate::tr!("composer.send").into_owned()) + .size(px(if self.compact { 44. } else { 28. })) + .child(Icon::new(IconName::ArrowUp).small()) + .into_any_element(); + } if turn_running { return self.render_send_or_stop(true, cx); } @@ -958,6 +974,7 @@ impl Render for Composer { self.sync_native_rewind_prefill(window, cx); self.sync_fallback_review_draft(window, cx); let composer_state = self.workspace_store.read(cx).composer_state(); + let readonly = self.workspace_store.read(cx).native_subagent_readonly(); let turn_running = composer_state.turn_running; let approval = composer_state.pending_approval; let approval_count = composer_state.pending_approval_count; @@ -995,7 +1012,7 @@ impl Render for Composer { .items_center(); #[cfg(all(feature = "voice", target_os = "macos"))] - let mic = if self.compact { + let mic = if self.compact || readonly { None } else { self.render_mic_button(cx) @@ -1003,7 +1020,7 @@ impl Render for Composer { #[cfg(not(all(feature = "voice", target_os = "macos")))] let mic: Option = None; - let control_row = if self.compact { + let control_row = if self.compact || (readonly && compact) { control_row_base .child(div().flex_1().min_w_0().child(self.render_model_picker(cx))) .child(self.render_traits_picker(cx)) @@ -1056,7 +1073,9 @@ impl Render for Composer { }); // Only Plan mode refines: in Build a typed message is an ordinary build // turn, so promising refinement there would misdescribe what Enter does. - let desired_placeholder = if plan_ready_title.is_some() && self.refines_the_plan(cx) { + let desired_placeholder = if readonly { + crate::tr!("chat.subagent_readonly").into_owned() + } else if plan_ready_title.is_some() && self.refines_the_plan(cx) { crate::tr!("plan.refine_placeholder").into_owned() } else if self.compact && !self.interactive(cx) { crate::tr!("mobile.offline_message").into_owned() @@ -1145,7 +1164,7 @@ impl Render for Composer { // Focus swaps the hairline to primary in one frame. Geometry stays // fixed: focus never changes border width, radius, or layout. - let composer_focused = self.input.read(cx).focus_handle(cx).is_focused(window); + let composer_focused = !readonly && self.input.read(cx).focus_handle(cx).is_focused(window); let card = v_flex() .debug_selector(|| "composer-card".into()) .w_full() @@ -1170,7 +1189,7 @@ impl Render for Composer { // the Paste *action* in the capture phase. Swallow it only when the // clipboard held an image; text paste propagates to the editor. .capture_action(cx.listener(|this, _: &Paste, window, cx| { - if !this.compact && this.paste_clipboard_image(window, cx) { + if this.interactive(cx) && !this.compact && this.paste_clipboard_image(window, cx) { cx.stop_propagation(); } })) @@ -1215,7 +1234,7 @@ impl Render for Composer { move |paths: &ExternalPaths, window: &mut Window, cx: &mut App| { let paths: Vec = paths.paths().to_vec(); composer.update(cx, |this, cx| { - if this.compact { + if this.compact || !this.interactive(cx) { return; } for path in paths { @@ -1234,6 +1253,7 @@ impl Render for Composer { // Match sent-message typography while composing. .child( Textarea::new(&self.input) + .disabled(readonly) .appearance(false) .text_size(px(13.5)) .line_height(px(21.)), @@ -1256,7 +1276,8 @@ impl Render for Composer { })) .pb_2() .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, _, cx| { - if ev.keystroke.key == "tab" && ev.keystroke.modifiers.shift { + if this.interactive(cx) && ev.keystroke.key == "tab" && ev.keystroke.modifiers.shift + { this.workspace_store .update(cx, |store, _cx| store.toggle_interaction_mode()); cx.notify(); @@ -1281,27 +1302,30 @@ impl Render for Composer { }) .children(self.render_trigger_menu(cx)) .children(self.render_queue_strip(cx)) - .child(v_flex().w_full().child(card).when(self.compact, |el| { - el.child( - h_flex() - .debug_selector(|| "composer-settings-drawer".into()) - .mx_2() - .px_1() - .min_w_0() - .gap_1() - .items_center() - .rounded_b(px(12.)) - .border_1() - .border_t_0() - .border_color(cx.theme().border) - .bg(cx.theme().muted) - .child(self.render_permission_picker(cx)) - .child(self.render_mode_chip(cx)) - .child(div().flex_1()) - .child(self.render_context_meter(cx)), - ) - })) - .when(!self.compact, |el| { + .child(v_flex().w_full().child(card).when( + self.compact || (readonly && compact), + |el| { + el.child( + h_flex() + .debug_selector(|| "composer-settings-drawer".into()) + .mx_2() + .px_1() + .min_w_0() + .gap_1() + .items_center() + .rounded_b(px(12.)) + .border_1() + .border_t_0() + .border_color(cx.theme().border) + .bg(cx.theme().muted) + .child(self.render_permission_picker(cx)) + .child(self.render_mode_chip(cx)) + .child(div().flex_1()) + .child(self.render_context_meter(cx)), + ) + }, + )) + .when(!self.compact && !readonly, |el| { el.children(self.render_checkout_row(cx)) }), ) diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index 309f1688..617857e3 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -3083,6 +3083,138 @@ mod tests { cx.update(|window, _| assert!(focus.is_focused(window))); } + #[gpui::test] + fn native_subagent_composer_is_visible_inert_and_restores_parent_draft( + cx: &mut TestAppContext, + ) { + cx.update(crate::theme::init); + let (shell, host, _, cx) = + mount_restored_at_width(cx, &["hosts", "threads", "thread"], true, 1200., "plan"); + restore_index(&shell, &host, true, cx); + restore_status(&shell, &host, cx); + let store = store_of(&shell, cx); + let composer = shell.read_with(cx, |shell, cx| { + shell.attachment.as_ref().unwrap().chat.read(cx).composer() + }); + let deliver = |session: &str, readonly: bool, cx: &mut VisualTestContext| { + let mut meta = tcode_core::project::SessionMeta::new( + agent::ProviderKind::Codex, + "/project".into(), + Some("child-model".into()), + ); + meta.id = session.into(); + meta.native_subagent = readonly.then(|| "spawn-1".into()); + let mut status = session_status(session, std::path::Path::new("/project")); + status.requested_model = meta.model.clone(); + status.interaction_mode = agent::InteractionMode::Plan; + status.turn_running = readonly; + status.provider_option_selections = vec![agent::OptionSelection { + id: "reasoningEffort".into(), + value: serde_json::json!("high"), + }]; + store.update(cx, |store, _| store.select_session(session.into())); + for (topic, event) in [ + ( + Topic::Settings, + ServerEvent::SettingsSnapshot(Default::default()), + ), + (Topic::Index, ServerEvent::IndexUpsertSession(meta)), + ( + Topic::SessionStatus { + session_id: session.into(), + }, + ServerEvent::SessionStatusReplaced(status), + ), + ( + Topic::SessionEvents { + session_id: session.into(), + }, + ServerEvent::SessionSnapshot { + total: 0, + total_turns: 0, + truncated: false, + from: 0, + records: vec![], + }, + ), + ] { + host.incoming + .try_send( + encode_line(&HostMessage::Event(EventEnvelope { + request_id: None, + topic, + event, + })) + .unwrap(), + ) + .unwrap(); + } + await_restore_update(&shell, cx, |store| !store.chat_loading()); + }; + deliver("thread-a", false, cx); + cx.update(|window, cx| { + composer.update(cx, |composer, cx| { + composer.set_draft("parent draft", window, cx); + }) + }); + draw(cx); + deliver("child", true, cx); + for width in [1200., 760., 393.] { + resize(cx, width); + shell.update(cx, |shell, cx| shell.go(Destination::Thread, cx)); + cx.executor().advance_clock(Duration::from_millis(250)); + draw(cx); + sent(&host); + assert!(cx.debug_bounds("composer-card").is_some()); + for selector in [ + "model-picker", + "traits-chip", + "mode-chip", + "permission-chip", + "send-message", + ] { + let bounds = cx.debug_bounds(selector).expect(selector); + assert!(bounds.size.width > px(0.) && bounds.size.height > px(0.)); + cx.simulate_click(bounds.center(), gpui::Modifiers::default()); + draw(cx); + } + let card = cx.debug_bounds("composer-card").unwrap(); + cx.simulate_click( + card.origin + gpui::point(px(20.), px(20.)), + gpui::Modifiers::default(), + ); + cx.simulate_keystrokes("x shift-tab enter"); + draw(cx); + assert_eq!( + composer.read_with(cx, |composer, cx| composer.draft(cx)), + "" + ); + assert!( + sent(&host) + .iter() + .all(|payload| !matches!(payload, ClientPayload::Command(_))), + "disabled composer must not send commands" + ); + assert_eq!( + store.read_with(cx, |store, _| store.composer_state().interaction_mode), + agent::InteractionMode::Plan + ); + } + deliver("thread-a", false, cx); + resize(cx, 1200.); + assert_eq!( + composer.read_with(cx, |composer, cx| composer.draft(cx)), + "parent draft" + ); + cx.update(|window, cx| composer.update(cx, |composer, cx| composer.focus(window, cx))); + cx.simulate_keystrokes("end x"); + draw(cx); + assert_eq!( + composer.read_with(cx, |composer, cx| composer.draft(cx)), + "parent draftx" + ); + } + #[gpui::test] fn software_keyboard_restored_terminal_does_not_raise_keyboard(cx: &mut TestAppContext) { cx.update(|cx| crate::window_seam::override_soft_keyboard_for_test(cx, true)); diff --git a/crates/ui/src/store/mod.rs b/crates/ui/src/store/mod.rs index 535dc7f6..d4c6a16d 100644 --- a/crates/ui/src/store/mod.rs +++ b/crates/ui/src/store/mod.rs @@ -2418,6 +2418,15 @@ impl WorkspaceStore { }) } + pub(crate) fn native_subagent_readonly(&self) -> bool { + self.session_status_replica.as_ref().is_some_and(|status| { + self.index_replica + .0 + .iter() + .any(|meta| meta.id == status.session_id && meta.native_subagent.is_some()) + }) + } + pub(crate) fn composer_state(&self) -> ComposerState { snapshots::composer_state( self.session_status_replica.as_ref(), diff --git a/docs/DESIGN.md b/docs/DESIGN.md index e265fa4d..93a73091 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -803,6 +803,16 @@ targets; access and context open their details sheets. The model name may truncate when space is tight, while the effort value stays fully visible. The card and drawer fit at 360pt in English and Simplified Chinese. +Provider-native subagent threads retain the composer as a disabled card. The +empty text field shows the existing “Read-only subagent thread” hint in muted +text. Model, reasoning effort, access and Build/Plan controls remain visible +and inert; narrow cards put access and Build/Plan in the lower drawer instead +of hiding them in a disabled overflow menu. Typing, paste, drops, dictation, +send/stop and the mode shortcut cannot change the child. Switching back restores +the ordinary thread's draft and editable controls. Mirrors inherit their parent's +settings at creation; provider-reported child model and effort replace those +values and remain available when the thread is reopened. + The traits popover (the effort chip's details) lists each model parameter as a section of selectable rows, except fast mode, which a lightning-bolt button pinned to the pane's top-right corner owns alone: filled amber