Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions docs/wire-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions render/composite.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
86 changes: 86 additions & 0 deletions render/composite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading