Skip to content

Commit a0a5a63

Browse files
dougborgclaude
andauthored
chore: align with katana-openapi-client safety + MCP fixes (#60)
* chore: add pre-push hook blocking direct pushes to main from non-main branches When a feature branch is created via ``git checkout -b <name> origin/main``, git sets the new local branch's upstream to ``origin/main``. A subsequent ``git push -u origin <name>`` then resolves to the tracked upstream and pushes straight to main — bypassing PR review entirely. Our sister repo (katana-openapi-client commit 30f3fd86) hit exactly this in production: a non-PR push to main triggered semantic-release and published an unintended PyPI build before the pipeline could be cancelled. Statuspro has no equivalent guard. Adds the same mechanical line of defense katana ported in #434: - ``scripts/pre-push-guard.sh`` — pre-commit-compatible pre-push hook that refuses pushes where ``remote_ref == refs/heads/main`` and ``local_ref != refs/heads/main``. Suggests the safe form (``git push -u origin HEAD:refs/heads/<branch>``) in the rejection message. - ``.pre-commit-config.yaml`` — adds ``default_install_hook_types: [pre-commit, pre-push]`` and registers ``block-push-to-main`` in the ``pre-push`` stage. Existing ``pre-commit install`` invocations now install both hook types. Existing collaborators must re-run ``uv run pre-commit install`` once for the pre-push hook to take effect; the README quick-start handles fresh clones automatically. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(harness): adopt /open-pr push-refspec safety + worktree note Pairs with the pre-push guard added in the previous commit. Documents the push-refspec trap in two places that don't read CLAUDE.md mid-flow: - ``.claude/skills/open-pr/SKILL.md`` CRITICAL block + Phase 5 — mandates ``git push -u origin HEAD:refs/heads/<branch>`` over the bare-branch form. The bare form resolves to the local branch's tracked upstream; if a branch was created via ``git checkout -b <name> origin/main`` the upstream is ``origin/main`` and the bare push targets main. - ``CLAUDE.md`` Known Pitfalls — same explanation, plus a worktree note pointing out that pre-commit hooks (including the new pre-push guard) aren't shared across worktrees and require ``pre-commit install`` per worktree. Lifted from katana-openapi-client's harness retro (PR #441) where the same trap bit twice during PR development. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(mcp): mock time.perf_counter in observability timing tests Both ``test_observe_service_timing_accuracy`` and ``test_observe_tool_timing_accuracy`` were measuring real time after ``await asyncio.sleep(...)`` and asserting the duration fell within loose-lower-bound / tight-upper-bound brackets: - tool variant: ``50 <= duration_ms < 200`` after a 100ms sleep - service variant: ``50 <= duration_ms < 500`` after a 50ms sleep The tool variant's ``< 200`` upper bound is the same flake pattern that broke katana-openapi-client CI on Python 3.14 (235.7ms < 100ms in their analogous service test, fixed in their #450 sweep). asyncio scheduler + GIL contention under CI runner load routinely pushes the measured value well above tight upper bounds. Both tests now mock ``time.perf_counter`` (the module-level binding the decorator imports as ``time.perf_counter`` in ``statuspro_mcp.logging``) with ``side_effect=[start, end]``. The computed duration is exact and deterministic — no real sleep, no scheduler variance, exact-equality assertion. The tests' name claims "timing accuracy" and now actually tests that: the decorator computes ``(end - start) * 1000`` correctly. Real asyncio scheduling latency was never the right thing to assert. Test runtime drops from ~150ms (sleep-bound) to ~1ms (deterministic). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(mcp): use ShowToast for Cancel buttons (drop SendMessage round-trip) Four Cancel buttons in Prefab preview UIs were sending fake user messages back through the LLM ("Cancel the status change" / "Cancel the comment" / "Cancel the due date change" / "Cancel the bulk update"). The model would acknowledge in chat for what is functionally a "do nothing" action, cluttering the conversation with a synthetic user message + LLM response. Replaced with ``ShowToast`` (client-side only, no server trip per the prefab_ui actions docstring): the user gets visible "Cancelled" feedback in the iframe overlay, no chat message is appended, no LLM round-trip happens, no tool is invoked. Sites: - ``build_status_change_preview_ui`` → "Status change cancelled" - ``build_comment_preview_ui`` → "Comment cancelled" - ``build_due_date_change_preview_ui`` → "Due date change cancelled" - ``build_bulk_status_change_preview_ui`` → "Bulk update cancelled" The two remaining ``SendMessage`` sites in ``prefab_ui.py`` (lines 232, 266) are intentional action triggers ("Add a comment to order...", etc.) that prompt the LLM to follow up — those stay as is. Ported from katana-openapi-client #440. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(mcp): coerce LLM-mistyped list inputs back into Python lists LLMs occasionally serialize list-typed tool arguments as a single string instead of a JSON array. Two shapes are observed in the wild: 1. CSV: ``order_ids='20486,20487,20488'`` 2. JSON-stringified: ``order_ids='[20486, 20487, 20488]'`` Both make pydantic raise ``Input should be a valid list [type=list_type, input_type=str]`` and the tool call aborts. The recovery is mechanical and lossless — split on commas (or parse as JSON), strip whitespace, hand pydantic a real list. So we do it. Adds: - ``statuspro_mcp/tools/list_coercion.py`` — a single ``coerce_str_list_input`` BeforeValidator that handles both shapes. Lists pass through unchanged; non-string non-list inputs fall through to pydantic's normal type error so genuinely malformed input still surfaces loudly. Six type aliases (``CoercedStrList`` / ``CoercedIntList`` / ``CoercedStrIntList`` and their ``Opt`` variants) collapse the per-field ``Annotated[list[X] | None, BeforeValidator(coerce_str_list_input)]`` boilerplate at call sites. - 19 unit tests covering passthrough, CSV, JSON-array string, whitespace, empty inputs, non-string inputs, mixed-type lists, and the ``min_length`` interaction. Applied to seven LLM-facing list parameters in ``orders.py``: - ``list_orders``: tags, tags_any, financial_status, fulfillment_status - ``get_orders_batch``: order_ids - ``lookup_orders_batch``: order_numbers - ``bulk_update_order_status``: order_ids Internal/response-side list fields don't need it — pydantic-on-pydantic round-trips already use real lists. Ported from katana-openapi-client #428 (mechanical port of the alias-based collapse already merged there). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(mcp): tighten coerce_str_list_input per /simplify pass Two micro-simplifications surfaced by a /simplify review of the freshly- landed list_coercion module: - ``json.JSONDecodeError`` is a subclass of ``ValueError`` (verified at runtime). Drop the redundant tuple in the except clause; catch ``ValueError`` alone. - The CSV fallback comprehension called ``item.strip()`` twice per item (once in the filter, once in the projection). Use a walrus assignment so the strip happens once: ``[stripped for item in s.split(",") if (stripped := item.strip())]``. Behavior identical; all 19 unit tests still pass. Diverges from the katana-openapi-client upstream by these two lines — re-sync if/when katana picks up the same cleanup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 983beef commit a0a5a63

10 files changed

Lines changed: 422 additions & 35 deletions

File tree

.claude/skills/open-pr/SKILL.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Ship a feature branch end-to-end: validate, self-review, push, create PR, wait f
2424
- **Use polling scripts for CI and review state** — never check review comments with `gh pr view --json`. That endpoint only returns top-level PR comments, not inline review comments attached to code lines. Use `poll-review.sh` which calls the correct API (`gh api repos/.../pulls/.../comments`).
2525
- **Never merge with unaddressed review comments** — every comment gets fixed, deferred with a tracked issue, or discussed. CI green does not override review feedback.
2626
- **No `--no-verify`** — never bypass commit hooks, type checkers, or linters. If a check fails, fix the cause.
27+
- **Push with the safe refspec, never the bare branch name** — use `git push -u origin HEAD:refs/heads/<branch-name>`, **not** `git push -u origin <branch-name>`. The bare form resolves the destination via the local branch's *upstream*. When a feature branch is created with `git checkout -b <name> origin/main`, its upstream is `origin/main` — and `git push -u origin <name>` then pushes straight to **`main`**, not to a new remote `<name>` ref. The pre-push guard at `scripts/pre-push-guard.sh` exists for this reason but does not fire from worktrees that haven't installed the repo hooks via `uv run pre-commit install`. Same rule applies to subsequent pushes (`git push --force-with-lease` after a rebase).
2728

2829
## STANDARD PATH
2930

@@ -140,10 +141,11 @@ Note: This phase is optional and relies on manual review or the `/simplify` skil
140141
141142
## Phase 5: Push and create PR
142143
143-
1. Push:
144+
1. Push with the explicit-destination refspec (see CRITICAL — bare-branch form
145+
can push to `main` when the local branch's upstream is `origin/main`):
144146
145147
```bash
146-
git push -u origin <branch>
148+
git push -u origin HEAD:refs/heads/<branch>
147149
```
148150
149151
2. Create PR with HEREDOC body:

.pre-commit-config.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
#
88
# See issue #134 for background on why we use local hooks.
99

10+
# Make `pre-commit install` set up the pre-push hook too. Without this, the
11+
# pre-push hook would silently not run for contributors who only ran the bare
12+
# `pre-commit install` from the README. See block-push-to-main below.
13+
default_install_hook_types: [pre-commit, pre-push]
14+
1015
repos:
1116
- repo: local
1217
hooks:
@@ -55,3 +60,17 @@ repos:
5560
language: system
5661
pass_filenames: false
5762
always_run: true
63+
64+
# Refuse pushes that would land non-main commits on the `main` ref.
65+
# See CLAUDE.md "Known Pitfalls" — git's tracked-upstream resolution can
66+
# silently route `git push -u origin <branch>` to main when the local
67+
# branch was created from origin/main. Mechanical guardrail, ported
68+
# from katana-openapi-client (#434) after a real release-pipeline
69+
# incident there.
70+
- id: block-push-to-main
71+
name: block direct push to main from a non-main branch
72+
entry: scripts/pre-push-guard.sh
73+
language: system
74+
stages: [pre-push]
75+
pass_filenames: false
76+
always_run: true

CLAUDE.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,15 @@ Guidance for Claude Code working with this repository.
66

77
```bash
88
uv sync --all-extras # Install dependencies
9-
uv run pre-commit install # Setup hooks
9+
uv run pre-commit install # Setup hooks (incl. pre-push guard)
1010
cp .env.example .env # Add STATUSPRO_API_KEY
1111
```
1212

13+
> **Worktree note**: pre-commit hooks aren't shared across git worktrees. Fresh
14+
> worktrees only have whatever hooks were installed at clone time — re-run
15+
> `uv run pre-commit install` after creating a new worktree, otherwise the pre-push
16+
> guard won't fire.
17+
1318
## Essential Commands
1419

1520
| Command | Time | When to Use |
@@ -92,6 +97,13 @@ Common mistakes to avoid:
9297
- **None-to-UNSET conversion** — When building attrs API request models from optional
9398
fields, use `to_unset(value)` from `statuspro_public_api_client.domain.converters`
9499
instead of `value if value is not None else UNSET`.
100+
- **Push-refspec trap**`git checkout -b <name> origin/main` sets the new branch's
101+
upstream to `origin/main`. A subsequent `git push -u origin <name>` then resolves to
102+
the tracked upstream and pushes straight to **`main`**, bypassing PR review. Always
103+
use the explicit-destination form: `git push -u origin HEAD:refs/heads/<name>`. The
104+
pre-push guard at `scripts/pre-push-guard.sh` catches this mistake — but only fires if
105+
`pre-commit install` has run in the current worktree (hooks aren't shared across
106+
worktrees). Bypassing the guard with `--no-verify` is forbidden by project policy.
95107

96108
## Using the LSP tool
97109

scripts/pre-push-guard.sh

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
#!/usr/bin/env bash
2+
# Pre-push guard: refuse pushes that land non-main commits on the `main` ref.
3+
#
4+
# Why this exists: when a feature branch is created via
5+
# git checkout -b <name> origin/main
6+
# git sets the new local branch's upstream to origin/main. A subsequent
7+
# git push -u origin <name>
8+
# resolves <name> to the tracked upstream and pushes straight to main. This
9+
# pattern caused a real release-pipeline incident in our sister repo
10+
# (katana-openapi-client commit 30f3fd86) before the guard was added there.
11+
#
12+
# pre-commit invokes this with the remote name as $1, the URL as $2, and
13+
# `<local-ref> <local-sha> <remote-ref> <remote-sha>` lines on stdin.
14+
#
15+
# Bypass via --no-verify is forbidden by project policy (see CLAUDE.md). The
16+
# right form for first-time feature-branch pushes is:
17+
# git push -u origin HEAD:refs/heads/<branch-name>
18+
19+
set -euo pipefail
20+
21+
EXIT_CODE=0
22+
23+
while read -r local_ref _local_sha remote_ref _remote_sha; do
24+
# Skip deletions (local_ref is empty when a remote ref is being deleted).
25+
[ -z "$local_ref" ] && continue
26+
27+
# Only guard pushes that target main.
28+
case "$remote_ref" in
29+
refs/heads/main) ;;
30+
*) continue ;;
31+
esac
32+
33+
# The local ref-name pushed to main must itself be `main`. Refuse anything else.
34+
case "$local_ref" in
35+
refs/heads/main)
36+
# ok — main → main is the legitimate path for admins/release automation
37+
;;
38+
refs/heads/*)
39+
# Branch other than main → main: the common upstream-tracking mistake.
40+
# Suggest the canonical safe form with the actual branch name.
41+
local_short="${local_ref#refs/heads/}"
42+
cat >&2 <<EOF
43+
ERROR: refusing to push '$local_short' to remote 'main'.
44+
45+
This is almost always git's tracked-upstream resolution biting you — the
46+
local branch was probably created via 'git checkout -b $local_short origin/main',
47+
so its upstream is origin/main and a bare 'git push -u origin $local_short' targets main.
48+
49+
The fix: use an explicit destination ref so the remote branch name matches:
50+
51+
git push -u origin HEAD:refs/heads/$local_short
52+
53+
If you genuinely need to push to main from a non-main branch (rare), do it
54+
through a PR. Bypassing this hook with --no-verify is forbidden by project policy
55+
— see CLAUDE.md 'Known Pitfalls'.
56+
EOF
57+
EXIT_CODE=1
58+
;;
59+
*)
60+
# Detached HEAD push, tag-to-main push, or some other unusual ref shape.
61+
# Don't try to construct a branch name from a ref we don't understand —
62+
# print a generic safe form instead.
63+
cat >&2 <<EOF
64+
ERROR: refusing to push '$local_ref' to remote 'main'.
65+
66+
The local ref isn't a normal branch (got: $local_ref). Pushes to main from
67+
anything other than the local 'main' branch are blocked.
68+
69+
If you intended to push your current work to a new feature branch:
70+
71+
git push -u origin HEAD:refs/heads/<branch-name>
72+
73+
Then open a PR. Bypassing this hook with --no-verify is forbidden by project
74+
policy — see CLAUDE.md 'Known Pitfalls'.
75+
EOF
76+
EXIT_CODE=1
77+
;;
78+
esac
79+
done
80+
81+
exit "$EXIT_CODE"

statuspro_mcp_server/src/statuspro_mcp/resources/help.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,20 @@
4747
update_order_status(…, confirm=True) # apply
4848
```
4949
50+
## Input handling
51+
52+
List-typed parameters (`tags`, `tags_any`, `financial_status`,
53+
`fulfillment_status`, `order_ids`, `order_numbers`) accept three input
54+
shapes — pass whichever is most natural for the call:
55+
56+
- A real list: `["20486", "20487"]`
57+
- A JSON-stringified array: `'["20486", "20487"]'`
58+
- A comma-separated string: `"20486,20487"`
59+
60+
The CSV and JSON-string forms are normalized to a list before validation.
61+
Empty / whitespace-only strings yield `[]`. Anything else (e.g. a bare
62+
integer for a `list[int]` field) raises a normal pydantic type error.
63+
5064
## Rate limits
5165
5266
StatusPro documents rate limits per-endpoint in its OpenAPI description (not
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Coerce LLM-mistyped list inputs back into Python lists.
2+
3+
LLMs occasionally send list-typed tool arguments as a single string instead
4+
of a JSON array. Two shapes are observed in the wild:
5+
6+
1. **Comma-separated values**: ``"20486,20487,20488"``
7+
2. **JSON-stringified array**: ``'["20486", "20487"]'``
8+
9+
When this happens, pydantic raises ``Input should be a valid list
10+
[type=list_type, input_type=str]``, the tool call fails, and the user has
11+
to retry. The recovery is mechanical and lossless — split on commas (or
12+
parse as JSON), strip whitespace, hand pydantic a real list. So we do it.
13+
14+
Usage on an LLM-facing tool parameter — prefer the prebuilt aliases::
15+
16+
from pydantic import Field
17+
from statuspro_mcp.tools.list_coercion import CoercedStrListOpt
18+
19+
async def list_orders(
20+
...,
21+
tags: Annotated[CoercedStrListOpt, Field(description="...")] = None,
22+
) -> ...:
23+
24+
Required (non-Optional) variants and the heterogeneous ``str | int`` shape
25+
have aliases too — see below. Use the raw ``coerce_str_list_input``
26+
validator directly only for one-off types that don't fit the aliases.
27+
28+
Apply only to **LLM-facing** tool parameters and request-model fields.
29+
Internal/response-side list fields don't need it — pydantic-on-pydantic
30+
round-trips already use real lists.
31+
32+
Ported from katana-openapi-client (#428).
33+
"""
34+
35+
from __future__ import annotations
36+
37+
import json
38+
from typing import Annotated, Any
39+
40+
from pydantic import BeforeValidator
41+
42+
43+
def coerce_str_list_input(value: Any) -> Any:
44+
"""Best-effort recovery of LLM-mistyped list arguments.
45+
46+
- List input → returned unchanged.
47+
- String input → parsed as JSON if it looks like an array, otherwise
48+
split on commas with whitespace stripped. Empty strings yield ``[]``.
49+
- Anything else → returned unchanged so pydantic raises its normal
50+
type error (don't mask genuinely malformed input).
51+
"""
52+
if not isinstance(value, str):
53+
return value
54+
55+
s = value.strip()
56+
if not s:
57+
return []
58+
59+
# JSON-stringified array: '[...]' — trust it if it parses to a list.
60+
# ``json.JSONDecodeError`` is a subclass of ``ValueError``; catching the
61+
# parent covers both.
62+
if s.startswith("["):
63+
try:
64+
parsed = json.loads(s)
65+
except ValueError:
66+
pass
67+
else:
68+
if isinstance(parsed, list):
69+
return parsed
70+
71+
# Fall back to CSV: split, strip, drop empty fragments.
72+
return [stripped for item in s.split(",") if (stripped := item.strip())]
73+
74+
75+
# Type aliases — collapse the per-field
76+
# ``Annotated[list[X] | None, BeforeValidator(coerce_str_list_input)]`` boilerplate
77+
# into a single readable token at the call site. ``Opt`` suffix marks the
78+
# Optional/None-default variant; bare names are required (non-Optional).
79+
80+
CoercedStrList = Annotated[list[str], BeforeValidator(coerce_str_list_input)]
81+
CoercedStrListOpt = Annotated[list[str] | None, BeforeValidator(coerce_str_list_input)]
82+
CoercedIntList = Annotated[list[int], BeforeValidator(coerce_str_list_input)]
83+
CoercedIntListOpt = Annotated[list[int] | None, BeforeValidator(coerce_str_list_input)]
84+
CoercedStrIntList = Annotated[list[str | int], BeforeValidator(coerce_str_list_input)]
85+
CoercedStrIntListOpt = Annotated[
86+
list[str | int] | None, BeforeValidator(coerce_str_list_input)
87+
]

statuspro_mcp_server/src/statuspro_mcp/tools/orders.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@
3838
from pydantic import Field
3939

4040
from statuspro_mcp.services import get_services
41+
from statuspro_mcp.tools.list_coercion import (
42+
CoercedIntList,
43+
CoercedStrList,
44+
CoercedStrListOpt,
45+
)
4146
from statuspro_mcp.tools.prefab_ui import (
4247
build_bulk_status_change_preview_ui,
4348
build_comment_preview_ui,
@@ -377,13 +382,19 @@ async def list_orders(
377382
str | None, Field(description="Filter to one status code")
378383
] = None,
379384
tags: Annotated[
380-
list[str] | None, Field(description="Must match all tags")
385+
CoercedStrListOpt, Field(description="Must match all tags")
381386
] = None,
382387
tags_any: Annotated[
383-
list[str] | None, Field(description="Match any of these tags")
388+
CoercedStrListOpt, Field(description="Match any of these tags")
389+
] = None,
390+
financial_status: Annotated[
391+
CoercedStrListOpt,
392+
Field(description="Filter by financial status (e.g. paid, pending)"),
393+
] = None,
394+
fulfillment_status: Annotated[
395+
CoercedStrListOpt,
396+
Field(description="Filter by fulfillment status (e.g. fulfilled, partial)"),
384397
] = None,
385-
financial_status: list[str] | None = None,
386-
fulfillment_status: list[str] | None = None,
387398
exclude_cancelled: Annotated[
388399
bool | None, Field(description="Exclude cancelled orders")
389400
] = None,
@@ -584,7 +595,7 @@ async def get_order(
584595
async def get_orders_batch(
585596
context: Context,
586597
order_ids: Annotated[
587-
list[int],
598+
CoercedIntList,
588599
Field(
589600
description="Order ids to fetch (1-50 items).",
590601
min_length=1,
@@ -635,7 +646,7 @@ async def get_orders_batch(
635646
async def lookup_orders_batch(
636647
context: Context,
637648
order_numbers: Annotated[
638-
list[str],
649+
CoercedStrList,
639650
Field(
640651
description=(
641652
"Order numbers to resolve (1-50 items). Both '20486' "
@@ -1029,7 +1040,7 @@ async def update_order_due_date(
10291040
async def bulk_update_order_status(
10301041
context: Context,
10311042
order_ids: Annotated[
1032-
list[int],
1043+
CoercedIntList,
10331044
Field(description="Order ids (1-50 items)", min_length=1, max_length=50),
10341045
],
10351046
status_code: str,

statuspro_mcp_server/src/statuspro_mcp/tools/prefab_ui.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from datetime import UTC, datetime
2525
from typing import Any, Literal
2626

27+
from prefab_ui.actions import ShowToast
2728
from prefab_ui.actions.mcp import CallTool, SendMessage
2829
from prefab_ui.app import PrefabApp
2930
from prefab_ui.components import (
@@ -377,7 +378,7 @@ def build_status_change_preview_ui(
377378
Button(
378379
label="Cancel",
379380
variant="outline",
380-
on_click=SendMessage("Cancel the status change"),
381+
on_click=ShowToast(message="Status change cancelled", variant="info"),
381382
)
382383
return app
383384

@@ -436,7 +437,7 @@ def build_comment_preview_ui(preview: dict[str, Any]) -> PrefabApp:
436437
Button(
437438
label="Cancel",
438439
variant="outline",
439-
on_click=SendMessage("Cancel the comment"),
440+
on_click=ShowToast(message="Comment cancelled", variant="info"),
440441
)
441442
return app
442443

@@ -491,7 +492,7 @@ def build_due_date_change_preview_ui(preview: dict[str, Any]) -> PrefabApp:
491492
Button(
492493
label="Cancel",
493494
variant="outline",
494-
on_click=SendMessage("Cancel the due date change"),
495+
on_click=ShowToast(message="Due date change cancelled", variant="info"),
495496
)
496497
return app
497498

@@ -566,7 +567,7 @@ def build_bulk_status_change_preview_ui(preview: dict[str, Any]) -> PrefabApp:
566567
Button(
567568
label="Cancel",
568569
variant="outline",
569-
on_click=SendMessage("Cancel the bulk update"),
570+
on_click=ShowToast(message="Bulk update cancelled", variant="info"),
570571
)
571572
return app
572573

0 commit comments

Comments
 (0)