From bc8708a2ae7c7df5de33caa01fa5d978b9dbbd51 Mon Sep 17 00:00:00 2001 From: chathura-de-silva Date: Wed, 30 Sep 2026 18:35:35 +0530 Subject: [PATCH 1/8] feat(taskflow): pass consumer-owned feedback field for display messages through on handles --- taskflow/renderer/zoneview/renderer_test.go | 36 +++++++++++++++++++++ taskflow/renderer/zoneview/zoneview.go | 11 ++++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/taskflow/renderer/zoneview/renderer_test.go b/taskflow/renderer/zoneview/renderer_test.go index 6979cb95..6dab0953 100644 --- a/taskflow/renderer/zoneview/renderer_test.go +++ b/taskflow/renderer/zoneview/renderer_test.go @@ -189,6 +189,42 @@ func TestRender_CarriesTitleAndLegalHandles(t *testing.T) { } } +// A legal handle carries its messages through, whatever +// their shape, and a handle that declares none emits no "messages" key. +func TestRender_PassesHandleMessagesThrough(t *testing.T) { + r := newTestRenderer(t) + const config = `{ + "id": "test:render", + "sections": { + "user_form": { + "templateId": "t", "title": "User Form", "projector": "MARKDOWN", + "handles": [ + { "command": "save_as_draft", "label": "Save as Draft" }, + { "command": "submit", "label": "Submit", + "messages": { "QUEUED_EXTERNALLY": { "text": "Sent for review.", "variant": "success" } } } + ] + } + }, + "states": { + "PENDING_USER": { "actions": [{ "command": "save_as_draft" }, { "command": "submit" }] } + } + }` + + view := decodeView(t, render(t, r, config, tfrenderer.Facts{State: "PENDING_USER"})) + if len(view) != 1 { + t.Fatalf("got %d components %v, want 1", len(view), view) + } + got, err := json.Marshal(view[0].Handles) + if err != nil { + t.Fatalf("marshal handles: %v", err) + } + const want = `[{"command":"save_as_draft","label":"Save as Draft"},` + + `{"command":"submit","label":"Submit","messages":{"QUEUED_EXTERNALLY":{"text":"Sent for review.","variant":"success"}}}]` + if string(got) != want { + t.Errorf("got handles %s, want %s", got, want) + } +} + // With no render config there is nothing to show: an empty list func TestRender_EmptyConfigReturnsEmptyList(t *testing.T) { r := newTestRenderer(t) diff --git a/taskflow/renderer/zoneview/zoneview.go b/taskflow/renderer/zoneview/zoneview.go index 3c3a4880..1bd51eaf 100644 --- a/taskflow/renderer/zoneview/zoneview.go +++ b/taskflow/renderer/zoneview/zoneview.go @@ -20,15 +20,18 @@ type Action struct { // screen element. Command joins this claim to a state-level Action; Label is // the user-facing text; Element is a free-form identifier owned by the // section's renderer (e.g. "primary_action", "secondary_action" for a FORM -// projector) — the data layer does not interpret it. +// projector) — the data layer does not interpret it. Messages is the feedback +// the consumer shows once the command has been dispatched; its shape is owned +// by the consumer and it is passed through uninterpreted. // // "Claim" here means a section laying claim to a command. It is unrelated to // the authorization claims in renderer.Facts.Claims, which decide whether a // section is rendered at all. type HandleClaim struct { - Command string `json:"command"` - Label string `json:"label"` - Element string `json:"element,omitempty"` + Command string `json:"command"` + Label string `json:"label"` + Element string `json:"element,omitempty"` + Messages json.RawMessage `json:"messages,omitempty"` } // SectionView is the per-zone trader-app metadata read from render.json From 16b0f418660426c343197a762d02e1bf9a498b5b Mon Sep 17 00:00:00 2001 From: chathura-de-silva Date: Wed, 30 Sep 2026 18:39:08 +0530 Subject: [PATCH 2/8] docs(taskflow): document the pass-through messages field on handles --- taskflow/docs/frontend-guide.md | 1 + taskflow/docs/template-reference.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/taskflow/docs/frontend-guide.md b/taskflow/docs/frontend-guide.md index be5b92b3..096aa93e 100644 --- a/taskflow/docs/frontend-guide.md +++ b/taskflow/docs/frontend-guide.md @@ -122,6 +122,7 @@ The `zoneview` renderer (see [template reference](template-reference.md#the-zone - `id` is the section's key in the render config. It is unique within the view. - `title` is omitted when the section has none. - `handles` lists the buttons legal in the current state; it is omitted when there are none, which means the entry is read-only. +- A handle's `messages` is present only when the render config declares one, and arrives exactly as written there. Its shape is defined by your frontend, not by the renderer: it's the place to say what to tell the user once the command has been dispatched. - An empty view is `[]`. The frontend renders the entries top to bottom, picking a widget by `type`. diff --git a/taskflow/docs/template-reference.md b/taskflow/docs/template-reference.md index 09b0d7e9..7ea09ea3 100644 --- a/taskflow/docs/template-reference.md +++ b/taskflow/docs/template-reference.md @@ -230,7 +230,7 @@ The interface (`Renderer.Render(context.Context, json.RawMessage, Facts) (json.R } ``` -- **`sections`** — what can be shown. `visibleWhen` decides whether a section is visible for the current state, data and claims; `handles` are the buttons it offers. +- **`sections`** — what can be shown. `visibleWhen` decides whether a section is visible for the current state, data and claims; `handles` are the buttons it offers. A handle has a `command`, a `label`, an optional `element` owned by the section's renderer, and an optional `messages`: the feedback the consumer shows once the command has been dispatched. `messages` is passed through to the view exactly as written. - **`layouts`** — named orderings. Each is a permutation of **all** section keys and expresses relative order only, never visibility. States that agree on the relative order of the sections they show can share one layout. - **`states`** — per state, `actions` lists the commands legal in it (a section's handle is sent only if its command is listed), and `order` references the layout to render in. The reference must have the form `#/layouts/` and name a defined layout, or rendering fails. From 23a808dc37c7eebef6112af2fb92fbbd4a8d74c7 Mon Sep 17 00:00:00 2001 From: chathura-de-silva Date: Wed, 30 Sep 2026 18:35:35 +0530 Subject: [PATCH 3/8] feat(taskflow): pass consumer-owned feedback field for display messages through on handles --- taskflow/renderer/zoneview/renderer_test.go | 36 +++++++++++++++++++++ taskflow/renderer/zoneview/zoneview.go | 11 ++++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/taskflow/renderer/zoneview/renderer_test.go b/taskflow/renderer/zoneview/renderer_test.go index 6979cb95..6dab0953 100644 --- a/taskflow/renderer/zoneview/renderer_test.go +++ b/taskflow/renderer/zoneview/renderer_test.go @@ -189,6 +189,42 @@ func TestRender_CarriesTitleAndLegalHandles(t *testing.T) { } } +// A legal handle carries its messages through, whatever +// their shape, and a handle that declares none emits no "messages" key. +func TestRender_PassesHandleMessagesThrough(t *testing.T) { + r := newTestRenderer(t) + const config = `{ + "id": "test:render", + "sections": { + "user_form": { + "templateId": "t", "title": "User Form", "projector": "MARKDOWN", + "handles": [ + { "command": "save_as_draft", "label": "Save as Draft" }, + { "command": "submit", "label": "Submit", + "messages": { "QUEUED_EXTERNALLY": { "text": "Sent for review.", "variant": "success" } } } + ] + } + }, + "states": { + "PENDING_USER": { "actions": [{ "command": "save_as_draft" }, { "command": "submit" }] } + } + }` + + view := decodeView(t, render(t, r, config, tfrenderer.Facts{State: "PENDING_USER"})) + if len(view) != 1 { + t.Fatalf("got %d components %v, want 1", len(view), view) + } + got, err := json.Marshal(view[0].Handles) + if err != nil { + t.Fatalf("marshal handles: %v", err) + } + const want = `[{"command":"save_as_draft","label":"Save as Draft"},` + + `{"command":"submit","label":"Submit","messages":{"QUEUED_EXTERNALLY":{"text":"Sent for review.","variant":"success"}}}]` + if string(got) != want { + t.Errorf("got handles %s, want %s", got, want) + } +} + // With no render config there is nothing to show: an empty list func TestRender_EmptyConfigReturnsEmptyList(t *testing.T) { r := newTestRenderer(t) diff --git a/taskflow/renderer/zoneview/zoneview.go b/taskflow/renderer/zoneview/zoneview.go index 81cd5cf9..e1de509e 100644 --- a/taskflow/renderer/zoneview/zoneview.go +++ b/taskflow/renderer/zoneview/zoneview.go @@ -20,15 +20,18 @@ type Action struct { // screen element. Command joins this claim to a state-level Action; Label is // the user-facing text; Element is a free-form identifier owned by the // section's renderer (e.g. "primary_action", "secondary_action" for a FORM -// projector) — the data layer does not interpret it. +// projector) — the data layer does not interpret it. Messages is the feedback +// the consumer shows once the command has been dispatched; its shape is owned +// by the consumer and it is passed through uninterpreted. // // "Claim" here means a section laying claim to a command. It is unrelated to // the authorization claims in renderer.Facts.Claims, which decide whether a // section is rendered at all. type HandleClaim struct { - Command string `json:"command"` - Label string `json:"label"` - Element string `json:"element,omitempty"` + Command string `json:"command"` + Label string `json:"label"` + Element string `json:"element,omitempty"` + Messages json.RawMessage `json:"messages,omitempty"` } // SectionView is the per-zone trader-app metadata read from render.json From 49f6d35f99fd1303a3ac934fb8c0ab1d3822c332 Mon Sep 17 00:00:00 2001 From: chathura-de-silva Date: Wed, 30 Sep 2026 18:39:08 +0530 Subject: [PATCH 4/8] docs(taskflow): document the pass-through messages field on handles --- taskflow/docs/frontend-guide.md | 1 + taskflow/docs/template-reference.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/taskflow/docs/frontend-guide.md b/taskflow/docs/frontend-guide.md index a71bcd1d..fe2f33c0 100644 --- a/taskflow/docs/frontend-guide.md +++ b/taskflow/docs/frontend-guide.md @@ -128,6 +128,7 @@ The `zoneview` renderer (see [template reference](template-reference.md#the-zone - `id` is the section's key in the render config. It is unique within the view. - `title` is omitted when the section has none. - `handles` lists the buttons legal in the current state; it is omitted when there are none, which means the entry is read-only. +- A handle's `messages` is present only when the render config declares one, and arrives exactly as written there. Its shape is defined by your frontend, not by the renderer: it's the place to say what to tell the user once the command has been dispatched. - An empty view is `[]`. The frontend renders the entries top to bottom, picking a widget by `type`. diff --git a/taskflow/docs/template-reference.md b/taskflow/docs/template-reference.md index 5040ccb9..e51c0b09 100644 --- a/taskflow/docs/template-reference.md +++ b/taskflow/docs/template-reference.md @@ -230,7 +230,7 @@ The interface (`Renderer.Render(context.Context, json.RawMessage, Facts) (json.R } ``` -- **`sections`** — what can be shown. `visibleWhen` decides whether a section is visible for the current state, data and claims; `handles` are the buttons it offers. +- **`sections`** — what can be shown. `visibleWhen` decides whether a section is visible for the current state, data and claims; `handles` are the buttons it offers. A handle has a `command`, a `label`, an optional `element` owned by the section's renderer, and an optional `messages`: the feedback the consumer shows once the command has been dispatched. `messages` is passed through to the view exactly as written. - **`layouts`** — named orderings. Each is a permutation of **all** section keys and expresses relative order only, never visibility. States that agree on the relative order of the sections they show can share one layout. - **`states`** — per state, `actions` lists the commands legal in it (a section's handle is sent only if its command is listed), and `order` references the layout to render in. The reference must have the form `#/layouts/` and name a defined layout, or rendering fails. From 3ddb31a9a5c390ceb222e87b8906c737abb57b44 Mon Sep 17 00:00:00 2001 From: chathura-de-silva Date: Tue, 6 Oct 2026 10:39:02 +0530 Subject: [PATCH 5/8] docs(taskflow): say messages keeps its JSON value, not its exact bytes The docs said a handle's messages reaches the view exactly as written. The renderer marshals the view with encoding/json, which compacts whitespace and escapes <, > and & in a RawMessage, so only the parsed value is preserved. Both docs now say that. Co-Authored-By: Claude Opus 5.5 --- taskflow/docs/frontend-guide.md | 2 +- taskflow/docs/template-reference.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/taskflow/docs/frontend-guide.md b/taskflow/docs/frontend-guide.md index fe2f33c0..a73d35df 100644 --- a/taskflow/docs/frontend-guide.md +++ b/taskflow/docs/frontend-guide.md @@ -128,7 +128,7 @@ The `zoneview` renderer (see [template reference](template-reference.md#the-zone - `id` is the section's key in the render config. It is unique within the view. - `title` is omitted when the section has none. - `handles` lists the buttons legal in the current state; it is omitted when there are none, which means the entry is read-only. -- A handle's `messages` is present only when the render config declares one, and arrives exactly as written there. Its shape is defined by your frontend, not by the renderer: it's the place to say what to tell the user once the command has been dispatched. +- A handle's `messages` is present only when the render config declares one, and its JSON value arrives unchanged, though encoding may compact whitespace and escape `<`, `>` and `&`. Its shape is defined by your frontend, not by the renderer: it's the place to say what to tell the user once the command has been dispatched. - An empty view is `[]`. The frontend renders the entries top to bottom, picking a widget by `type`. diff --git a/taskflow/docs/template-reference.md b/taskflow/docs/template-reference.md index e51c0b09..d7e9a8df 100644 --- a/taskflow/docs/template-reference.md +++ b/taskflow/docs/template-reference.md @@ -230,7 +230,7 @@ The interface (`Renderer.Render(context.Context, json.RawMessage, Facts) (json.R } ``` -- **`sections`** — what can be shown. `visibleWhen` decides whether a section is visible for the current state, data and claims; `handles` are the buttons it offers. A handle has a `command`, a `label`, an optional `element` owned by the section's renderer, and an optional `messages`: the feedback the consumer shows once the command has been dispatched. `messages` is passed through to the view exactly as written. +- **`sections`** — what can be shown. `visibleWhen` decides whether a section is visible for the current state, data and claims; `handles` are the buttons it offers. A handle has a `command`, a `label`, an optional `element` owned by the section's renderer, and an optional `messages`: the feedback the consumer shows once the command has been dispatched. `messages` is passed through to the view as the same JSON value, though encoding may compact whitespace and escape `<`, `>` and `&`. - **`layouts`** — named orderings. Each is a permutation of **all** section keys and expresses relative order only, never visibility. States that agree on the relative order of the sections they show can share one layout. - **`states`** — per state, `actions` lists the commands legal in it (a section's handle is sent only if its command is listed), and `order` references the layout to render in. The reference must have the form `#/layouts/` and name a defined layout, or rendering fails. From 21d88016b0c53551451e79cdc9a7168866caac00 Mon Sep 17 00:00:00 2001 From: chathura-de-silva Date: Wed, 30 Sep 2026 18:35:35 +0530 Subject: [PATCH 6/8] feat(taskflow): pass consumer-owned feedback field for display messages through on handles --- taskflow/renderer/zoneview/renderer_test.go | 36 +++++++++++++++++++++ taskflow/renderer/zoneview/zoneview.go | 11 ++++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/taskflow/renderer/zoneview/renderer_test.go b/taskflow/renderer/zoneview/renderer_test.go index 6979cb95..6dab0953 100644 --- a/taskflow/renderer/zoneview/renderer_test.go +++ b/taskflow/renderer/zoneview/renderer_test.go @@ -189,6 +189,42 @@ func TestRender_CarriesTitleAndLegalHandles(t *testing.T) { } } +// A legal handle carries its messages through, whatever +// their shape, and a handle that declares none emits no "messages" key. +func TestRender_PassesHandleMessagesThrough(t *testing.T) { + r := newTestRenderer(t) + const config = `{ + "id": "test:render", + "sections": { + "user_form": { + "templateId": "t", "title": "User Form", "projector": "MARKDOWN", + "handles": [ + { "command": "save_as_draft", "label": "Save as Draft" }, + { "command": "submit", "label": "Submit", + "messages": { "QUEUED_EXTERNALLY": { "text": "Sent for review.", "variant": "success" } } } + ] + } + }, + "states": { + "PENDING_USER": { "actions": [{ "command": "save_as_draft" }, { "command": "submit" }] } + } + }` + + view := decodeView(t, render(t, r, config, tfrenderer.Facts{State: "PENDING_USER"})) + if len(view) != 1 { + t.Fatalf("got %d components %v, want 1", len(view), view) + } + got, err := json.Marshal(view[0].Handles) + if err != nil { + t.Fatalf("marshal handles: %v", err) + } + const want = `[{"command":"save_as_draft","label":"Save as Draft"},` + + `{"command":"submit","label":"Submit","messages":{"QUEUED_EXTERNALLY":{"text":"Sent for review.","variant":"success"}}}]` + if string(got) != want { + t.Errorf("got handles %s, want %s", got, want) + } +} + // With no render config there is nothing to show: an empty list func TestRender_EmptyConfigReturnsEmptyList(t *testing.T) { r := newTestRenderer(t) diff --git a/taskflow/renderer/zoneview/zoneview.go b/taskflow/renderer/zoneview/zoneview.go index 81cd5cf9..e1de509e 100644 --- a/taskflow/renderer/zoneview/zoneview.go +++ b/taskflow/renderer/zoneview/zoneview.go @@ -20,15 +20,18 @@ type Action struct { // screen element. Command joins this claim to a state-level Action; Label is // the user-facing text; Element is a free-form identifier owned by the // section's renderer (e.g. "primary_action", "secondary_action" for a FORM -// projector) — the data layer does not interpret it. +// projector) — the data layer does not interpret it. Messages is the feedback +// the consumer shows once the command has been dispatched; its shape is owned +// by the consumer and it is passed through uninterpreted. // // "Claim" here means a section laying claim to a command. It is unrelated to // the authorization claims in renderer.Facts.Claims, which decide whether a // section is rendered at all. type HandleClaim struct { - Command string `json:"command"` - Label string `json:"label"` - Element string `json:"element,omitempty"` + Command string `json:"command"` + Label string `json:"label"` + Element string `json:"element,omitempty"` + Messages json.RawMessage `json:"messages,omitempty"` } // SectionView is the per-zone trader-app metadata read from render.json From 32407f54b61242765ee7e744621656e326ced2a4 Mon Sep 17 00:00:00 2001 From: chathura-de-silva Date: Wed, 30 Sep 2026 18:39:08 +0530 Subject: [PATCH 7/8] docs(taskflow): document the pass-through messages field on handles --- taskflow/docs/frontend-guide.md | 1 + taskflow/docs/template-reference.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/taskflow/docs/frontend-guide.md b/taskflow/docs/frontend-guide.md index a71bcd1d..fe2f33c0 100644 --- a/taskflow/docs/frontend-guide.md +++ b/taskflow/docs/frontend-guide.md @@ -128,6 +128,7 @@ The `zoneview` renderer (see [template reference](template-reference.md#the-zone - `id` is the section's key in the render config. It is unique within the view. - `title` is omitted when the section has none. - `handles` lists the buttons legal in the current state; it is omitted when there are none, which means the entry is read-only. +- A handle's `messages` is present only when the render config declares one, and arrives exactly as written there. Its shape is defined by your frontend, not by the renderer: it's the place to say what to tell the user once the command has been dispatched. - An empty view is `[]`. The frontend renders the entries top to bottom, picking a widget by `type`. diff --git a/taskflow/docs/template-reference.md b/taskflow/docs/template-reference.md index 5040ccb9..e51c0b09 100644 --- a/taskflow/docs/template-reference.md +++ b/taskflow/docs/template-reference.md @@ -230,7 +230,7 @@ The interface (`Renderer.Render(context.Context, json.RawMessage, Facts) (json.R } ``` -- **`sections`** — what can be shown. `visibleWhen` decides whether a section is visible for the current state, data and claims; `handles` are the buttons it offers. +- **`sections`** — what can be shown. `visibleWhen` decides whether a section is visible for the current state, data and claims; `handles` are the buttons it offers. A handle has a `command`, a `label`, an optional `element` owned by the section's renderer, and an optional `messages`: the feedback the consumer shows once the command has been dispatched. `messages` is passed through to the view exactly as written. - **`layouts`** — named orderings. Each is a permutation of **all** section keys and expresses relative order only, never visibility. States that agree on the relative order of the sections they show can share one layout. - **`states`** — per state, `actions` lists the commands legal in it (a section's handle is sent only if its command is listed), and `order` references the layout to render in. The reference must have the form `#/layouts/` and name a defined layout, or rendering fails. From 44492911d00863dce8f9c3bd1a2ca01488133add Mon Sep 17 00:00:00 2001 From: chathura-de-silva Date: Tue, 6 Oct 2026 10:39:02 +0530 Subject: [PATCH 8/8] docs(taskflow): say messages keeps its JSON value, not its exact bytes The docs said a handle's messages reaches the view exactly as written. The renderer marshals the view with encoding/json, which compacts whitespace and escapes <, > and & in a RawMessage, so only the parsed value is preserved. Both docs now say that. Co-Authored-By: Claude Opus 5.5 --- taskflow/docs/frontend-guide.md | 2 +- taskflow/docs/template-reference.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/taskflow/docs/frontend-guide.md b/taskflow/docs/frontend-guide.md index fe2f33c0..a73d35df 100644 --- a/taskflow/docs/frontend-guide.md +++ b/taskflow/docs/frontend-guide.md @@ -128,7 +128,7 @@ The `zoneview` renderer (see [template reference](template-reference.md#the-zone - `id` is the section's key in the render config. It is unique within the view. - `title` is omitted when the section has none. - `handles` lists the buttons legal in the current state; it is omitted when there are none, which means the entry is read-only. -- A handle's `messages` is present only when the render config declares one, and arrives exactly as written there. Its shape is defined by your frontend, not by the renderer: it's the place to say what to tell the user once the command has been dispatched. +- A handle's `messages` is present only when the render config declares one, and its JSON value arrives unchanged, though encoding may compact whitespace and escape `<`, `>` and `&`. Its shape is defined by your frontend, not by the renderer: it's the place to say what to tell the user once the command has been dispatched. - An empty view is `[]`. The frontend renders the entries top to bottom, picking a widget by `type`. diff --git a/taskflow/docs/template-reference.md b/taskflow/docs/template-reference.md index e51c0b09..d7e9a8df 100644 --- a/taskflow/docs/template-reference.md +++ b/taskflow/docs/template-reference.md @@ -230,7 +230,7 @@ The interface (`Renderer.Render(context.Context, json.RawMessage, Facts) (json.R } ``` -- **`sections`** — what can be shown. `visibleWhen` decides whether a section is visible for the current state, data and claims; `handles` are the buttons it offers. A handle has a `command`, a `label`, an optional `element` owned by the section's renderer, and an optional `messages`: the feedback the consumer shows once the command has been dispatched. `messages` is passed through to the view exactly as written. +- **`sections`** — what can be shown. `visibleWhen` decides whether a section is visible for the current state, data and claims; `handles` are the buttons it offers. A handle has a `command`, a `label`, an optional `element` owned by the section's renderer, and an optional `messages`: the feedback the consumer shows once the command has been dispatched. `messages` is passed through to the view as the same JSON value, though encoding may compact whitespace and escape `<`, `>` and `&`. - **`layouts`** — named orderings. Each is a permutation of **all** section keys and expresses relative order only, never visibility. States that agree on the relative order of the sections they show can share one layout. - **`states`** — per state, `actions` lists the commands legal in it (a section's handle is sent only if its command is listed), and `order` references the layout to render in. The reference must have the form `#/layouts/` and name a defined layout, or rendering fails.