Add appearance metadata to thread sections (#37898)

## What changed

- Add optional `icon` and `color` appearance fields to custom thread sections and expose them through the app-server protocol.
- Persist appearance metadata in SQLite and include it when listing sections or returning threads.
- Let `threadSection/update` preserve an omitted appearance, clear it with `null`, or replace it with a new value.
- Reject appearance fields larger than 64 bytes.

## Testing

- Cover protocol compatibility, persistence across restart, update and clear behavior, and field-length validation.

GitOrigin-RevId: 8f55ce4f42f8bd17aa5416848a85845f681953df
This commit is contained in:
joeytrasatti-openai authored and copyberry committed 2026-08-10 23:32:42 +00:00
1 parent 92912d6d84
commit 1549756b78
54 files changed
+939 -47

No files matched your search

@@ -255,6 +255,7 @@ fn thread_resume_response_round_trips_initial_turns_page() {
section: Some(ThreadSection {
id: "01984de2-8f74-7c91-a3b2-5c5e937cf318".to_string(),
name: "Pinned".to_string(),
appearance: None,
}),
section_entered_at: Some(1),
history_mode: Default::default(),
@@ -302,6 +303,7 @@ fn thread_resume_response_round_trips_initial_turns_page() {
json!({
"id": "01984de2-8f74-7c91-a3b2-5c5e937cf318",
"name": "Pinned",
"appearance": null,
})
);
assert_eq!(value["thread"]["sectionEnteredAt"], json!(1));
@@ -496,6 +498,13 @@ fn thread_list_params_accepts_section_id_filter() {
#[test]
fn thread_section_list_params_and_response_round_trip() {
let legacy = serde_json::from_value::<ThreadSection>(json!({
"id": "legacy",
"name": "Legacy",
}))
.expect("legacy sections without appearance should deserialize");
assert_eq!(legacy.appearance, None);
let params = serde_json::from_value::<ThreadSectionListParams>(json!({
"cursor": "section-cursor",
"limit": 25,
@@ -513,6 +522,7 @@ fn thread_section_list_params_and_response_round_trip() {
data: vec![ThreadSection {
id: "01984de2-8f74-7c91-a3b2-5c5e937cf318".to_string(),
name: "Pinned".to_string(),
appearance: None,
}],
next_cursor: None,
};
@@ -523,6 +533,7 @@ fn thread_section_list_params_and_response_round_trip() {
"data": [{
"id": "01984de2-8f74-7c91-a3b2-5c5e937cf318",
"name": "Pinned",
"appearance": null,
}],
"nextCursor": null,
})
@@ -534,6 +545,21 @@ fn thread_section_list_params_and_response_round_trip() {
);
}
#[test]
fn thread_section_updates_distinguish_omitted_and_cleared_appearance() {
for (value, appearance) in [
(json!({ "sectionId": "section", "name": "Work" }), None),
(
json!({ "sectionId": "section", "name": "Work", "appearance": null }),
Some(None),
),
] {
let params = serde_json::from_value::<ThreadSectionUpdateParams>(value)
.expect("section update should deserialize");
assert_eq!(params.appearance, appearance);
}
}
#[test]
fn collab_agent_state_maps_interrupted_status() {
assert_eq!(
@@ -7,6 +7,7 @@ use super::Thread;
use super::ThreadHistoryMode;
use super::ThreadItem;
use super::ThreadSection;
use super::ThreadSectionAppearance;
use super::ThreadSource;
use super::Turn;
use super::TurnEnvironmentParams;
@@ -1146,6 +1147,9 @@ pub struct ThreadSectionListResponse {
pub struct ThreadSectionCreateParams {
/// The user-visible name of the section.
pub name: String,
#[serde(default)]
#[ts(optional = nullable)]
pub appearance: Option<ThreadSectionAppearance>,
}
/// The independently persisted section created by the server.
@@ -1165,6 +1169,16 @@ pub struct ThreadSectionUpdateParams {
pub section_id: String,
/// The updated user-visible name of the section.
pub name: String,
/// Omit to preserve appearance, use `null` to clear it, or provide a replacement.
#[serde(
default,
skip_serializing_if = "Option::is_none",
serialize_with = "crate::protocol::serde_helpers::serialize_double_option",
deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option"
)]
#[schemars(with = "Option<ThreadSectionAppearance>")]
#[ts(optional = nullable, as = "Option<ThreadSectionAppearance>")]
pub appearance: Option<Option<ThreadSectionAppearance>>,
}
/// The independently persisted section after its name is updated.
@@ -176,6 +176,18 @@ pub struct ThreadSection {
pub id: String,
/// The current user-visible section name.
pub name: String,
/// Optional appearance synchronized across clients.
#[serde(default)]
pub appearance: Option<ThreadSectionAppearance>,
}
/// Extensible visual presentation for a custom thread section.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadSectionAppearance {
pub icon: Option<String>,
pub color: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)]