diff --git a/docs/wire-format.md b/docs/wire-format.md index 1cfb634..2300a45 100644 --- a/docs/wire-format.md +++ b/docs/wire-format.md @@ -62,13 +62,18 @@ Also implemented since earlier revisions of this doc: surface. - `ActionEvent.Context` is populated from the surface's input component values, so typed `TextField` edits round-trip to the agent. +- `createSurface` is an explicit, documented no-op (a2tea issue #47): a + surface is established by its first `updateComponents`, so the message + carries nothing a2tea needs. Its `theme` hints (`primaryColor`, `iconUrl`, + `agentDisplayName`) are ignored in favor of host theming via `WithStyles` — + chrome is deliberately monochrome so the host theme wins — and its + `catalogId` is ignored because a2tea's component catalog is the compiled-in + one, by design. `Apply` handles the message with an explicit no-op case + rather than silently falling through. **Not yet** (tracked as follow-ups) - `ChildList` templates: children resolve from explicit ID lists only; the dynamic template form is not expanded. -- `createSurface` theming/catalog: the message is ignored — a surface is - established by its first `updateComponents`, and theme/catalog payloads are - not applied. - Tab switching: tabs are not focusable, so the first tab is always active. - Modal content: a modal renders only its trigger; its content stays hidden. - Editing beyond `TextField`: `CheckBox`, `ChoicePicker`, `Slider`, and diff --git a/render/composite.go b/render/composite.go index c9aa764..1870142 100644 --- a/render/composite.go +++ b/render/composite.go @@ -18,6 +18,13 @@ import ( // destructive, it fires only on an exact SurfaceID match, never on an empty // one. // +// createSurface is deliberately a no-op. a2tea treats the first +// updateComponents as implicit surface creation, and the hints createSurface +// carries have no honorable mapping here: theme (primaryColor, iconUrl, +// agentDisplayName) is superseded by host theming via WithStyles — component +// chrome stays monochrome so the host theme wins — and catalogId is ignored +// because a2tea's catalog is the compiled-in one, by design. +// // deleteSurface clears all surface state (components, data model, edits, // focus) but processing continues: a later updateComponents in the same batch // legally re-creates the surface. @@ -28,6 +35,11 @@ import ( func (s *Surface) Apply(msgs []a2ui.ServerMessage) bool { for _, m := range msgs { switch { + case m.CreateSurface != nil: + // Deliberate no-op: surface creation is implied by the first + // updateComponents, host WithStyles owns theming, and the + // component catalog is compiled in. See the Apply doc comment + // and docs/wire-format.md. case m.UpdateComponents != nil: if !s.targetsThisSurface(m.UpdateComponents.SurfaceID) { continue diff --git a/render/composite_test.go b/render/composite_test.go index 81aeb61..988f5cd 100644 --- a/render/composite_test.go +++ b/render/composite_test.go @@ -382,3 +382,89 @@ func TestApplyDeleteClearsDataModelAndEdits(t *testing.T) { t.Errorf("data model survived delete; view = %q", view) } } + +// TestApplyCreateSurfaceIsNoOp verifies the documented stance on +// createSurface: the message is a deliberate no-op that neither errors nor +// mutates surface state — components, data model, and focus all survive, and +// its theme hints are not applied (chrome stays whatever the host configured). +func TestApplyCreateSurfaceIsNoOp(t *testing.T) { + comps := []a2ui.Component{ + {ID: "root", Column: &a2ui.ColumnComponent{Children: a2ui.ChildList{IDs: []string{"btn1", "btn2", "txt"}}}}, + {ID: "btn1", Button: &a2ui.ButtonComponent{Child: "l1"}}, + {ID: "btn2", Button: &a2ui.ButtonComponent{Child: "l2"}}, + {ID: "l1", Text: &a2ui.TextComponent{Text: a2ui.StringLiteral("First")}}, + {ID: "l2", Text: &a2ui.TextComponent{Text: a2ui.StringLiteral("Second")}}, + {ID: "txt", Text: &a2ui.TextComponent{Text: a2ui.StringBinding("/status")}}, + } + s := render.NewSurface("s1", comps) + s.Focus() + + // Tab to btn2 and set a data-model value so there is state to preserve. + s.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + s.Apply([]a2ui.ServerMessage{ + {UpdateDataModel: &a2ui.UpdateDataModel{SurfaceID: "s1", Path: "/status", Value: "online"}}, + }) + before := s.View().Content + + alive := s.Apply([]a2ui.ServerMessage{ + {CreateSurface: &a2ui.CreateSurface{ + SurfaceID: "s1", + CatalogID: "https://example.com/some-catalog.json", + Theme: &a2ui.Theme{PrimaryColor: "#ff00ff", AgentDisplayName: "Themed Agent"}, + }}, + }) + if !alive { + t.Fatal("surface should stay alive across a createSurface no-op") + } + + after := s.View().Content + if after != before { + t.Errorf("createSurface mutated the rendered view:\nbefore:\n%q\nafter:\n%q", before, after) + } + if strings.Contains(after, "Themed Agent") { + t.Errorf("createSurface theme leaked into the view; view = %q", after) + } + + // Focus must survive: Enter still activates btn2. + _, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("enter after createSurface produced nil cmd") + } + msg := cmd() + ev, ok := msg.(event.ButtonClicked) + if !ok { + t.Fatalf("cmd produced %T, want event.ButtonClicked", msg) + } + if ev.ID != "btn2" { + t.Fatalf("focus not preserved across createSurface: activated %q, want btn2", ev.ID) + } +} + +// TestApplyCreateSurfaceDoesNotCreateState verifies that createSurface alone +// establishes nothing — a surface only becomes renderable via +// updateComponents, which may follow in the same batch. +func TestApplyCreateSurfaceDoesNotCreateState(t *testing.T) { + s := render.NewSurface("s1", nil) + + alive := s.Apply([]a2ui.ServerMessage{ + {CreateSurface: &a2ui.CreateSurface{SurfaceID: "s1", CatalogID: "core"}}, + }) + if alive { + t.Fatal("createSurface alone must not make the surface renderable") + } + + // The same batch shape agents actually send: createSurface then + // updateComponents. The latter is what establishes the surface. + alive = s.Apply([]a2ui.ServerMessage{ + {CreateSurface: &a2ui.CreateSurface{SurfaceID: "s1", CatalogID: "core"}}, + {UpdateComponents: &a2ui.UpdateComponents{SurfaceID: "s1", Components: []a2ui.Component{ + {ID: "root", Text: &a2ui.TextComponent{Text: a2ui.StringLiteral("Hello")}}, + }}}, + }) + if !alive { + t.Fatal("createSurface followed by updateComponents should establish the surface") + } + if view := s.View().Content; !strings.Contains(view, "Hello") { + t.Errorf("content from the establishing updateComponents missing; view = %q", view) + } +}