feat: add group field to SchemaField for grouped display in Android UI

Propagate enclosing struct label tags as a group field on leaf
SchemaFields so the Android Detail screen can render group headers
with dividers (e.g. "WhatsApp", "Discord" under channels).
Suppress redundant single-group headers (e.g. agents/Defaults).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-28 01:56:57 +09:00
parent 598971e282
commit 76d669efb5
6 changed files with 95 additions and 5 deletions

View file

@ -31,6 +31,7 @@ data class SchemaSection(val key: String, val label: String, val fields: List<Sc
data class SchemaField( data class SchemaField(
val key: String, val key: String,
val label: String, val label: String,
val group: String = "",
val type: String, val type: String,
val secret: Boolean = false, val secret: Boolean = false,
val default: JsonElement = Json.parseToJsonElement("null"), val default: JsonElement = Json.parseToJsonElement("null"),

View file

@ -9,8 +9,10 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@ -22,6 +24,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -150,7 +153,21 @@ fun ConfigSectionDetailScreen(
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
var lastGroup: String? = null
detail.fields.forEach { field -> detail.fields.forEach { field ->
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( ConfigField(
field = field, field = field,
onValueChanged = { viewModel.onFieldValueChanged(field.key, it) }, onValueChanged = { viewModel.onFieldValueChanged(field.key, it) },

View file

@ -23,6 +23,7 @@ data class SectionSummary(val key: String, val label: String, val fieldCount: In
data class FieldState( data class FieldState(
val key: String, val key: String,
val label: String, val label: String,
val group: String = "",
val type: String, val type: String,
val secret: Boolean, val secret: Boolean,
val value: String, val value: String,

View file

@ -60,6 +60,7 @@ class ConfigViewModel(private val apiClient: ConfigApiClient) : ViewModel() {
FieldState( FieldState(
key = field.key, key = field.key,
label = field.label, label = field.label,
group = field.group,
type = field.type, type = field.type,
secret = field.secret, secret = field.secret,
value = display, value = display,

View file

@ -11,6 +11,7 @@ import (
type SchemaField struct { type SchemaField struct {
Key string `json:"key"` Key string `json:"key"`
Label string `json:"label"` Label string `json:"label"`
Group string `json:"group,omitempty"`
Type string `json:"type"` Type string `json:"type"`
Secret bool `json:"secret"` Secret bool `json:"secret"`
Default interface{} `json:"default"` Default interface{} `json:"default"`
@ -70,7 +71,21 @@ func BuildSchema(defaultCfg *config.Config) SchemaResponse {
} }
fieldVal := cfgVal.Field(i) 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) 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 // buildFields recursively collects fields from a struct type, flattening nested structs
// with dot-separated key prefixes. // with dot-separated key prefixes. The group parameter propagates the label of the
func buildFields(t reflect.Type, v reflect.Value, prefix string) []SchemaField { // 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 var fields []SchemaField
if t.Kind() == reflect.Ptr { if t.Kind() == reflect.Ptr {
@ -122,8 +138,13 @@ func buildFields(t reflect.Type, v reflect.Value, prefix string) []SchemaField {
schemaType := goTypeToSchema(ft) schemaType := goTypeToSchema(ft)
if schemaType == "object" { if schemaType == "object" {
// Nested struct: recurse and flatten // Nested struct: recurse and flatten.
fields = append(fields, buildFields(ft, fieldVal, fullKey)...) // 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 continue
} }
@ -140,6 +161,7 @@ func buildFields(t reflect.Type, v reflect.Value, prefix string) []SchemaField {
fields = append(fields, SchemaField{ fields = append(fields, SchemaField{
Key: fullKey, Key: fullKey,
Label: labelTag(sf), Label: labelTag(sf),
Group: group,
Type: schemaType, Type: schemaType,
Secret: secretKeys[jk], Secret: secretKeys[jk],
Default: defVal, Default: defVal,

View file

@ -2435,3 +2435,51 @@ func lastDot(s string) int {
} }
return -1 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)
}
}
}