Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
c1729c3
feat: persist ACP config selection
matt2e Jul 7, 2026
5ca652c
feat(acp): normalize config options
matt2e Jul 7, 2026
1e1d6b6
feat(acp): apply selected config before prompt
matt2e Jul 7, 2026
6a54675
feat(acp): thread config selection through sessions
matt2e Jul 7, 2026
b1cf383
feat(acp): discover picker config selectors
matt2e Jul 7, 2026
d013f80
feat(acp): add provider config picker
matt2e Jul 7, 2026
6296368
feat(acp): wire new-session config selection
matt2e Jul 7, 2026
546d5a7
feat(acp): wire chat follow-up controls
matt2e Jul 7, 2026
fe269d3
test(acp): cover config selection regressions
matt2e Jul 7, 2026
32d15f8
feat(acp): cache config discovery by provider
matt2e Jul 7, 2026
4d1a680
fix(acp): lay out config picker controls in columns
matt2e Jul 7, 2026
27809e3
fix(acp): size picker columns to content
matt2e Jul 7, 2026
85d742a
fix(acp): keep config picker open across selections
matt2e Jul 7, 2026
344dd7a
fix(acp): drop provider text from picker trigger
matt2e Jul 7, 2026
53bf86b
fix(acp): show loading options in picker column
matt2e Jul 7, 2026
c2acc1b
fix(acp): hide provider column for follow-up picker
matt2e Jul 7, 2026
230d8a7
fix(acp): align follow-up picker controls
matt2e Jul 7, 2026
b516491
fix(acp): keep picker keyboard focus in columns
matt2e Jul 7, 2026
e105ffd
fix(acp): close follow-up picker on return
matt2e Jul 7, 2026
ddd5738
fix(acp): commit picker values with arrows
matt2e Jul 8, 2026
3ab9c61
refactor(acp): deduplicate launcher helpers
matt2e Jul 8, 2026
b0673a6
fix(acp): avoid auth during config discovery
matt2e Jul 8, 2026
da88d1f
fix(acp): clear stale config selections on resume
matt2e Jul 8, 2026
45f4145
refactor(acp): extract config picker shell
matt2e Jul 8, 2026
0fc6b4b
fix(acp): refresh effort options by model
matt2e Jul 8, 2026
e6df1be
fix(acp): cache effort options by model
matt2e Jul 8, 2026
04d0680
fix(acp): stabilize picker loading layout
matt2e Jul 8, 2026
cf5b224
fix(acp): calm picker loading transitions
matt2e Jul 8, 2026
736ea05
fix(acp): animate picker trigger width
matt2e Jul 8, 2026
53de356
fix(acp): resolve clippy lints in session commands
matt2e Jul 8, 2026
a7aaec8
test(acp): expect schema version 20 after config migration
matt2e Jul 8, 2026
e37cd9c
fix(acp): resolve config selection review findings
matt2e Jul 8, 2026
4b7e634
fix(acp): isolate picker trigger compositing layer
matt2e Jul 8, 2026
6dab7c8
fix(acp): wrap compact composer controls under the input
matt2e Jul 8, 2026
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 apps/staged/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ src/
├── ProjectHome.svelte # Project dashboard
├── BranchTimeline.svelte # Branch history and commit timeline
├── SessionLauncher.svelte # AI session creation and management
├── AgentSelector.svelte # Agent provider picker
├── AcpConfigPicker.svelte # Agent provider/model/effort picker
├── TopBar.svelte # Navigation and project controls
├── commands.ts # Tauri command bindings
├── types.ts # Shared TypeScript types
Expand Down
1 change: 1 addition & 0 deletions apps/staged/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/staged/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ doctor = { path = "../../../crates/doctor" }
# Actions framework
builderbot-actions = { path = "../../../crates/builderbot-actions" }
acp-client = { path = "../../../crates/acp-client" }
agent-client-protocol = { version = "0.15.1", features = ["unstable"] }
blox-cli = { path = "../../../crates/blox-cli" }

regex = "1"
Expand Down
1 change: 1 addition & 0 deletions apps/staged/src-tauri/examples/acp_stream_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ fn main() -> Result<()> {
&writer,
&cancel_token,
None,
&[],
)
.await;

Expand Down
348 changes: 348 additions & 0 deletions apps/staged/src-tauri/src/acp_config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,348 @@
//! Normalization helpers for ACP session configuration options.

use acp_client::AcpSessionConfigOptionSelection;
use agent_client_protocol::schema::v1::{
SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption,
SessionConfigSelectOptions,
};
use serde::{Deserialize, Serialize};

use crate::store::{AcpConfigSelection, AcpConfigValueSelection};

/// Product-facing ACP configuration selectors.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct NormalizedAcpConfigOptions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) model: Option<NormalizedAcpConfigSelector>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) effort: Option<NormalizedAcpConfigSelector>,
}

/// A normalized select-style ACP configuration option.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct NormalizedAcpConfigSelector {
pub(crate) config_id: String,
pub(crate) label: String,
pub(crate) current_value_id: String,
pub(crate) options: Vec<NormalizedAcpConfigValueOption>,
}

/// One flattened selectable value for an ACP configuration option.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct NormalizedAcpConfigValueOption {
pub(crate) value_id: String,
pub(crate) label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) group_label: Option<String>,
}

/// Extract the model and reasoning-effort selectors from ACP config options.
pub(crate) fn normalize_acp_config_options(
config_options: &[SessionConfigOption],
) -> NormalizedAcpConfigOptions {
NormalizedAcpConfigOptions {
model: normalize_selector_for_category(config_options, &SessionConfigOptionCategory::Model),
effort: normalize_selector_for_category(
config_options,
&SessionConfigOptionCategory::ThoughtLevel,
),
}
}

pub(crate) fn selected_acp_config_options(
selection: Option<&AcpConfigSelection>,
) -> Vec<AcpSessionConfigOptionSelection> {
let Some(selection) = selection else {
return Vec::new();
};

let mut options = Vec::new();
if let Some(model) = &selection.model {
options.push(selected_config_option(
SessionConfigOptionCategory::Model,
model,
));
}
if let Some(effort) = &selection.effort {
options.push(selected_config_option(
SessionConfigOptionCategory::ThoughtLevel,
effort,
));
}
options
}

fn selected_config_option(
category: SessionConfigOptionCategory,
selection: &AcpConfigValueSelection,
) -> AcpSessionConfigOptionSelection {
AcpSessionConfigOptionSelection {
category,
config_id: selection.config_id.clone(),
value_id: selection.value_id.clone(),
}
}

fn normalize_selector_for_category(
config_options: &[SessionConfigOption],
category: &SessionConfigOptionCategory,
) -> Option<NormalizedAcpConfigSelector> {
config_options
.iter()
.filter(|option| option.category.as_ref() == Some(category))
.find_map(normalize_select_option)
}

fn normalize_select_option(
config_option: &SessionConfigOption,
) -> Option<NormalizedAcpConfigSelector> {
let SessionConfigKind::Select(select) = &config_option.kind else {
return None;
};

Some(NormalizedAcpConfigSelector {
config_id: config_option.id.to_string(),
label: config_option.name.clone(),
current_value_id: select.current_value.to_string(),
options: flatten_select_options(&select.options),
})
}

fn flatten_select_options(
options: &SessionConfigSelectOptions,
) -> Vec<NormalizedAcpConfigValueOption> {
match options {
SessionConfigSelectOptions::Ungrouped(options) => options
.iter()
.map(|option| normalize_value(option, None))
.collect(),
SessionConfigSelectOptions::Grouped(groups) => groups
.iter()
.flat_map(|group| {
group
.options
.iter()
.map(|option| normalize_value(option, Some(&group.name)))
})
.collect(),
_ => Vec::new(),
}
}

fn normalize_value(
option: &SessionConfigSelectOption,
group_label: Option<&str>,
) -> NormalizedAcpConfigValueOption {
NormalizedAcpConfigValueOption {
value_id: option.value.to_string(),
label: option.name.clone(),
group_label: group_label.map(str::to_string),
}
}

#[cfg(test)]
mod tests {
use super::*;
use agent_client_protocol::schema::v1::{
SessionConfigBoolean, SessionConfigSelectGroup, SessionConfigSelectOption,
};

#[test]
fn extracts_model_and_effort_selectors() {
let options = vec![
SessionConfigOption::select(
"model",
"Model",
"gpt-5",
vec![
SessionConfigSelectOption::new("gpt-5", "GPT-5"),
SessionConfigSelectOption::new("gpt-5-mini", "GPT-5 mini"),
],
)
.category(SessionConfigOptionCategory::Model),
SessionConfigOption::select(
"reasoning",
"Reasoning",
"high",
vec![
SessionConfigSelectOption::new("low", "Low"),
SessionConfigSelectOption::new("high", "High"),
],
)
.category(SessionConfigOptionCategory::ThoughtLevel),
];

let normalized = normalize_acp_config_options(&options);

let model = normalized.model.expect("model selector");
assert_eq!(model.config_id, "model");
assert_eq!(model.current_value_id, "gpt-5");
assert_eq!(
model.options,
vec![
NormalizedAcpConfigValueOption {
value_id: "gpt-5".to_string(),
label: "GPT-5".to_string(),
group_label: None,
},
NormalizedAcpConfigValueOption {
value_id: "gpt-5-mini".to_string(),
label: "GPT-5 mini".to_string(),
group_label: None,
},
]
);

let effort = normalized.effort.expect("effort selector");
assert_eq!(effort.config_id, "reasoning");
assert_eq!(effort.current_value_id, "high");
assert_eq!(effort.options[1].value_id, "high");
}

#[test]
fn filters_unsupported_options() {
let options = vec![
SessionConfigOption::select(
"mode",
"Mode",
"default",
vec![SessionConfigSelectOption::new("default", "Default")],
)
.category(SessionConfigOptionCategory::Mode),
SessionConfigOption::new(
"model_toggle",
"Model toggle",
SessionConfigKind::Boolean(SessionConfigBoolean::new(false)),
)
.category(SessionConfigOptionCategory::Model),
SessionConfigOption::select(
"model",
"Model",
"opus",
vec![SessionConfigSelectOption::new("opus", "Opus")],
)
.category(SessionConfigOptionCategory::Model),
SessionConfigOption::select(
"effort",
"Effort",
"medium",
vec![SessionConfigSelectOption::new("medium", "Medium")],
)
.category(SessionConfigOptionCategory::ThoughtLevel),
];

let normalized = normalize_acp_config_options(&options);

assert_eq!(
normalized.model.expect("model selector").current_value_id,
"opus"
);
assert!(normalized.effort.is_some());
}

#[test]
fn flattens_grouped_options() {
let options = vec![SessionConfigOption::select(
"model",
"Model",
"sonnet",
vec![
SessionConfigSelectGroup::new(
"fast",
"Fast",
vec![SessionConfigSelectOption::new("haiku", "Haiku")],
),
SessionConfigSelectGroup::new(
"smart",
"Smart",
vec![
SessionConfigSelectOption::new("sonnet", "Sonnet"),
SessionConfigSelectOption::new("opus", "Opus"),
],
),
],
)
.category(SessionConfigOptionCategory::Model)];

let normalized = normalize_acp_config_options(&options);
let model = normalized.model.expect("model selector");

assert_eq!(model.current_value_id, "sonnet");
assert_eq!(
model.options,
vec![
NormalizedAcpConfigValueOption {
value_id: "haiku".to_string(),
label: "Haiku".to_string(),
group_label: Some("Fast".to_string()),
},
NormalizedAcpConfigValueOption {
value_id: "sonnet".to_string(),
label: "Sonnet".to_string(),
group_label: Some("Smart".to_string()),
},
NormalizedAcpConfigValueOption {
value_id: "opus".to_string(),
label: "Opus".to_string(),
group_label: Some("Smart".to_string()),
},
]
);
}

#[test]
fn returns_none_for_missing_categories() {
let options = vec![
SessionConfigOption::select(
"uncategorized_model",
"Model",
"default",
vec![SessionConfigSelectOption::new("default", "Default")],
),
SessionConfigOption::select(
"custom",
"Custom",
"custom",
vec![SessionConfigSelectOption::new("custom", "Custom")],
)
.category(SessionConfigOptionCategory::Other("_custom".to_string())),
];

let normalized = normalize_acp_config_options(&options);

assert!(normalized.model.is_none());
assert!(normalized.effort.is_none());
}

#[test]
fn selected_config_options_preserve_model_then_effort_order() {
let selection = AcpConfigSelection {
model: Some(AcpConfigValueSelection {
config_id: "model".to_string(),
value_id: "sonnet".to_string(),
label: Some("Sonnet".to_string()),
}),
effort: Some(AcpConfigValueSelection {
config_id: "reasoning".to_string(),
value_id: "high".to_string(),
label: Some("High".to_string()),
}),
};

let selected = selected_acp_config_options(Some(&selection));

assert_eq!(selected.len(), 2);
assert_eq!(selected[0].category, SessionConfigOptionCategory::Model);
assert_eq!(selected[0].config_id, "model");
assert_eq!(selected[0].value_id, "sonnet");
assert_eq!(
selected[1].category,
SessionConfigOptionCategory::ThoughtLevel
);
assert_eq!(selected[1].config_id, "reasoning");
assert_eq!(selected[1].value_id, "high");
}
}
2 changes: 2 additions & 0 deletions apps/staged/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//! Tauri commands for the new frontend, built incrementally.
//! See `src-archive/lib.rs` for the previous implementation.

pub(crate) mod acp_config;
pub mod actions;
pub mod agent;
pub mod background_sync;
Expand Down Expand Up @@ -2311,6 +2312,7 @@ pub fn run() {
util_commands::open_in_app,
// Sessions
session_commands::discover_acp_providers,
session_commands::discover_acp_config,
session_commands::get_session,
session_commands::get_session_messages,
session_commands::get_session_messages_since,
Expand Down
Loading