diff --git a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigApiClient.kt b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigApiClient.kt index 375bd817f..ac59b5acf 100644 --- a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigApiClient.kt +++ b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigApiClient.kt @@ -31,6 +31,7 @@ data class SchemaSection(val key: String, val label: String, val fields: List + if (field.group != lastGroup) { + lastGroup = field.group + if (field.group.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider(color = GlassBorder) + Text( + text = field.group, + style = MaterialTheme.typography.titleSmall, + color = NeonCyan, + modifier = Modifier.padding(top = 8.dp), + ) + } + } ConfigField( field = field, onValueChanged = { viewModel.onFieldValueChanged(field.key, it) }, diff --git a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigUiState.kt b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigUiState.kt index a4e1c48f3..9d1110378 100644 --- a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigUiState.kt +++ b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigUiState.kt @@ -23,6 +23,7 @@ data class SectionSummary(val key: String, val label: String, val fieldCount: In data class FieldState( val key: String, val label: String, + val group: String = "", val type: String, val secret: Boolean, val value: String, diff --git a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigViewModel.kt b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigViewModel.kt index ac85162c3..fdae7f138 100644 --- a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigViewModel.kt +++ b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigViewModel.kt @@ -60,6 +60,7 @@ class ConfigViewModel(private val apiClient: ConfigApiClient) : ViewModel() { FieldState( key = field.key, label = field.label, + group = field.group, type = field.type, secret = field.secret, value = display, diff --git a/pkg/gateway/schema.go b/pkg/gateway/schema.go index a41e334ae..268bc3660 100644 --- a/pkg/gateway/schema.go +++ b/pkg/gateway/schema.go @@ -11,6 +11,7 @@ import ( type SchemaField struct { Key string `json:"key"` Label string `json:"label"` + Group string `json:"group,omitempty"` Type string `json:"type"` Secret bool `json:"secret"` Default interface{} `json:"default"` @@ -70,7 +71,21 @@ func BuildSchema(defaultCfg *config.Config) SchemaResponse { } fieldVal := cfgVal.Field(i) - section.Fields = buildFields(field.Type, fieldVal, "") + section.Fields = buildFields(field.Type, fieldVal, "", "") + + // If every field shares the same single group, the header is redundant — clear it. + groups := map[string]bool{} + for _, f := range section.Fields { + if f.Group != "" { + groups[f.Group] = true + } + } + if len(groups) <= 1 { + for j := range section.Fields { + section.Fields[j].Group = "" + } + } + sections = append(sections, section) } @@ -78,8 +93,9 @@ func BuildSchema(defaultCfg *config.Config) SchemaResponse { } // buildFields recursively collects fields from a struct type, flattening nested structs -// with dot-separated key prefixes. -func buildFields(t reflect.Type, v reflect.Value, prefix string) []SchemaField { +// with dot-separated key prefixes. The group parameter propagates the label of the +// enclosing struct so that leaf fields can be grouped under a header in the UI. +func buildFields(t reflect.Type, v reflect.Value, prefix string, group string) []SchemaField { var fields []SchemaField if t.Kind() == reflect.Ptr { @@ -122,8 +138,13 @@ func buildFields(t reflect.Type, v reflect.Value, prefix string) []SchemaField { schemaType := goTypeToSchema(ft) if schemaType == "object" { - // Nested struct: recurse and flatten - fields = append(fields, buildFields(ft, fieldVal, fullKey)...) + // Nested struct: recurse and flatten. + // Use the nested struct's label tag as group; fall back to current group. + childGroup := labelTag(sf) + if childGroup == "" { + childGroup = group + } + fields = append(fields, buildFields(ft, fieldVal, fullKey, childGroup)...) continue } @@ -140,6 +161,7 @@ func buildFields(t reflect.Type, v reflect.Value, prefix string) []SchemaField { fields = append(fields, SchemaField{ Key: fullKey, Label: labelTag(sf), + Group: group, Type: schemaType, Secret: secretKeys[jk], Default: defVal, diff --git a/pkg/gateway/server_test.go b/pkg/gateway/server_test.go index 4dbfd7d0c..bf5440fb7 100644 --- a/pkg/gateway/server_test.go +++ b/pkg/gateway/server_test.go @@ -2435,3 +2435,51 @@ func lastDot(s string) int { } return -1 } + +func TestBuildSchema_FieldGroups(t *testing.T) { + schema := BuildSchema(config.DefaultConfig()) + + // Build a map from "section.fieldKey" → group + fieldGroup := map[string]string{} + for _, sec := range schema.Sections { + for _, f := range sec.Fields { + fieldGroup[sec.Key+"."+f.Key] = f.Group + } + } + + tests := []struct { + fullKey string + wantGroup string + }{ + // channels: each sub-struct label becomes group + {"channels.whatsapp.enabled", "WhatsApp"}, + {"channels.discord.token", "Discord"}, + {"channels.line.channel_secret", "LINE"}, + {"channels.telegram.token", "Telegram"}, + {"channels.slack.bot_token", "Slack"}, + {"channels.websocket.enabled", "WebSocket"}, + // tools: deeper nesting uses the innermost struct label + {"tools.web.brave.api_key", "Brave Search"}, + {"tools.web.brave.enabled", "Brave Search"}, + {"tools.web.duckduckgo.enabled", "DuckDuckGo"}, + {"tools.exec.enabled", "Shell Exec"}, + {"tools.android.enabled", "Android"}, + {"tools.memory.enabled", "Memory"}, + // llm: flat fields have no group + {"llm.model", ""}, + {"llm.api_key", ""}, + // agents: single intermediate struct — group suppressed + {"agents.defaults.max_tokens", ""}, + } + + for _, tt := range tests { + got, ok := fieldGroup[tt.fullKey] + if !ok { + t.Errorf("field %q not found in schema", tt.fullKey) + continue + } + if got != tt.wantGroup { + t.Errorf("field %q group = %q, want %q", tt.fullKey, got, tt.wantGroup) + } + } +}