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(
val key: String,
val label: String,
val group: String = "",
val type: String,
val secret: Boolean = false,
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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
@ -22,6 +24,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@ -150,7 +153,21 @@ fun ConfigSectionDetailScreen(
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
var lastGroup: String? = null
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(
field = field,
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(
val key: String,
val label: String,
val group: String = "",
val type: String,
val secret: Boolean,
val value: String,

View file

@ -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,

View file

@ -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,

View file

@ -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)
}
}
}