@tightknitai/block-kitchen 0.9.10 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -110,9 +110,10 @@ export function MyBuilderPage() {
110
110
  | `loadSendAsUserStatus` | `() => Promise<{ canSendAsUser: boolean; oauthUrl?: string }>` | grouped\* | Whether the current user has a Slack user-token and can post as themselves. If `canSendAsUser` is false, `oauthUrl` is shown as a "Sign in with Slack" link. |
111
111
  | `onSend` | `(payload) => Promise<{ ok: boolean; error?: string }>` | grouped\* | Called when the user submits the send dialog. Payload is `{ channelId, blocks, sendAsUser, extras? }` (`extras` only when `renderSendExtras` is wired). |
112
112
  | `renderSendExtras` | `(ctx: SendExtrasContext) => ReactNode` | no | Renders host-defined fields inside the built-in send dialog, below the channel and identity pickers. Collect values with `ctx.setExtras(patch)`; they arrive on `onSend` as `payload.extras`. Requires the send trio. See [Extending the send dialog](#extending-the-send-dialog-custom-fields). |
113
- | `primaryAction` | `{ label, onClick, disableWhenInvalid? }` | no | Compose-only mode only: a host-owned button in the toolbar slot where "Review & send" normally sits. `onClick` receives `{ blocks, validation }` — the current draft plus the same verdict `onValidationChange` reports. See [Keeping the CTA in the toolbar](#keeping-the-cta-in-the-toolbar-primaryaction). |
114
- | `editing` | `{ onLoadMessage, onUpdate, loadRecentMessages? }` | no | Opt-in edit mode. When present, the toolbar exposes "Edit message": the user pastes a Slack message link, `onLoadMessage({ link })` returns a host-computed [editability verdict](#editing-an-existing-message-opt-in), and a successful load flips the primary action to "Update message" wired to `onUpdate`. Pass `loadRecentMessages` to add a "recent messages from this app" picker beside the paste input; the user picks a channel first (reusing `loadChannels`) and the lookup is scoped to it. Omit `editing` to keep send-only behavior. |
115
- | `loadButtonLabel` | `string` | no | Label + accessible name for the toolbar button that opens the load-message dialog (the edit-mode entry point). Defaults to `'Load message'`. Only shown when `editing` is set and no message is loaded. |
113
+ | `primaryAction` | `{ label, onClick, disableWhenInvalid? }` | no | Compose-only mode only: a host-owned button in the toolbar slot where "Review & send" normally sits. `onClick` receives `{ blocks, validation, loadedMessage }` — the current draft, the same verdict `onValidationChange` reports, and the currently [loaded message](#loading-an-existing-message-opt-in) (`null` unless `loading` is configured and one is loaded). See [Keeping the CTA in the toolbar](#keeping-the-cta-in-the-toolbar-primaryaction). |
114
+ | `loading` | `{ onLoadMessage, loadRecentMessages?, loadChannels?, initialTarget?, onLoadedMessageChange? }` | no | Opt-in: [load an existing message](#loading-an-existing-message-opt-in) into the editor. Adds a "Find message" toolbar entry — the user pastes a Slack permalink (or picks a recent message) and the draft is hydrated with its blocks. Loading is a *composition* concern, so it works in **both** send mode and compose-only mode. `onLoadedMessageChange` reports the loaded target (`null` on exit) so a host-owned commitment step can stay in sync. |
115
+ | `onUpdate` | `(payload) => Promise<{ ok: boolean; error?: string }>` | no | Opt-in update-in-place mode (send mode only); sibling to `onSend`, but the payload carries the source `channel + ts`. With `loading` configured, a loaded message flips the primary action to a "Review & update" split button wired to it (`chat.update`). |
116
+ | `loadButtonLabel` | `string` | no | Label + accessible name for the toolbar button that opens the load-message dialog. Defaults to `'Find message'`. Only shown when `loading` is configured. |
116
117
  | `updateButtonLabel` | `string` | no | Label for the toolbar's primary button while a message is loaded for editing. It's a split button: clicking it updates the message in place; the menu beside it also offers "Send as a new message" (post the current blocks as new). Defaults to `'Review & update'`. |
117
118
  | `confirmUpdateLabel` | `string` | no | Label for the update dialog's final confirm button. Defaults to `'Update message'` (shows `'Updating…'` while in flight). |
118
119
  | `previewHooks` | `PreviewHooks` | no | Hooks forwarded to `slack-blocks-to-jsx`'s `<Message>` for resolving user / channel / emoji directives. |
@@ -134,9 +135,10 @@ export function MyBuilderPage() {
134
135
  all-or-nothing: provide all three for the built-in send flow, or omit all
135
136
  three for [compose-only mode](#compose-only-mode-bring-your-own-send-flow)
136
137
  (no send button; your app owns the send flow). Wiring only some of the three
137
- is a type error. `editing` and `renderSendExtras` require the trio;
138
+ is a type error. `onUpdate` and `renderSendExtras` require the trio;
138
139
  `primaryAction` requires its absence (the built-in Send/Update flow owns the
139
- toolbar's primary slot otherwise).
140
+ toolbar's primary slot otherwise). `loading` is mode-agnostic — it works
141
+ with or without the trio.
140
142
 
141
143
  ## Compose-only mode (bring your own send flow)
142
144
 
@@ -186,9 +188,15 @@ Notes:
186
188
  - `onValidationChange` reports the same verdict the issues sheet displays
187
189
  (validated against the `message` surface, like Send), so your CTA and the
188
190
  in-builder issue count can never disagree.
189
- - `editing` is unavailable in compose-only mode: updating an existing message
190
- is inherently bound to its channel + timestamp, so the update flow only
191
- exists alongside the send integration.
191
+ - [Loading an existing message](#loading-an-existing-message-opt-in) works
192
+ here too — pass `loading` and the "Find message" entry appears. The loaded
193
+ target (channel + ts + editability verdict) reaches your app via
194
+ `primaryAction`'s `loadedMessage` context and
195
+ `loading.onLoadedMessageChange`, so your own commitment step can offer
196
+ "update in place" alongside "post as new".
197
+ - The built-in *update* flow (`onUpdate`) is unavailable in compose-only
198
+ mode: dispatching a `chat.update` is inherently bound to a channel +
199
+ timestamp + token, so it only exists alongside the send integration.
192
200
 
193
201
  ### Keeping the CTA in the toolbar (`primaryAction`)
194
202
 
@@ -287,17 +295,20 @@ Notes:
287
295
  (identities, channels), prefer proposing a first-class prop over routing
288
296
  them through extras; the slot is for genuinely host-specific concerns.
289
297
 
290
- ## Editing an existing message (opt-in)
298
+ ## Loading an existing message (opt-in)
291
299
 
292
- By default the builder is send-only. Pass `editing` to let users load an
293
- already-posted message, edit its blocks, and dispatch a `chat.update`. The
300
+ By default the builder starts from a blank canvas. Pass `loading` to let
301
+ users load an already-posted message's blocks into the editor — paste a
302
+ permalink, or pick from a "recent messages from this app" list. Loading is a
303
+ *composition* concern, so it works in **both** send mode and
304
+ [compose-only mode](#compose-only-mode-bring-your-own-send-flow). The
294
305
  package stays integration-agnostic: it makes no Slack calls and computes
295
306
  nothing about who can edit; the host does both.
296
307
 
297
308
  ```tsx
298
309
  <BlockKitchen
299
- /* …send-only props… */
300
- editing={{
310
+ /* …send trio, or nothing (compose-only)… */
311
+ loading={{
301
312
  // Host parses the pasted permalink, fetches the message, and returns a
302
313
  // verdict. `chat.update` only edits a message authored by the calling
303
314
  // token, so the host decides: bot message → 'bot', the user's own
@@ -315,17 +326,11 @@ nothing about who can edit; the host does both.
315
326
  return { ok: true, channelId: msg.channel, channelName: msg.channelName, ts: msg.ts, blocks: msg.blocks, editableVia: 'user', ...author };
316
327
  return { ok: false, reason: 'Only messages your app or you posted can be edited.', blocks: msg.blocks };
317
328
  },
318
- // Sibling to onSend; carries the source channel + ts. `asUser` follows
319
- // the verdict's `editableVia`.
320
- onUpdate: async ({ channelId, ts, blocks, asUser }) => {
321
- await chatUpdate({ channel: channelId, ts, blocks, asUser }); // your code
322
- return { ok: true };
323
- },
324
329
  // Optional: adds a "recent messages from this app" picker beside the paste
325
- // input. The user first picks a channel (reusing `loadChannels`), then this
326
- // is called with that `channelId` so the lookup scans only one channel.
327
- // These are editable-by-construction (the app authored them), so picking one
328
- // loads it straight into edit mode (no verdict needed).
330
+ // input. The user first picks a channel, then this is called with that
331
+ // `channelId` so the lookup scans only one channel. These are
332
+ // editable-by-construction (the app authored them), so picking one loads
333
+ // it directly (no verdict needed).
329
334
  loadRecentMessages: async (channelId) => {
330
335
  const msgs = await fetchRecentAppMessages(channelId); // your code
331
336
  return msgs.map((m) => ({
@@ -337,17 +342,68 @@ nothing about who can edit; the host does both.
337
342
  label: m.preview // one-line preview shown in the picker row
338
343
  }));
339
344
  }
345
+ // In send mode the recent picker's channel list reuses the send trio's
346
+ // `loadChannels`. In compose-only mode there is no trio — pass
347
+ // `loadChannels` here too, or the picker is hidden.
340
348
  }}
341
349
  />
342
350
  ```
343
351
 
344
- - On `ok`, the builder hydrates with `blocks`, shows an edit-mode badge, locks
345
- the destination to the source channel, and fixes the post-as identity to
346
- `editableVia` (the `'user'` path reuses `loadSendAsUserStatus` for the "Sign
347
- in with Slack" gate).
352
+ - On `ok`, the builder hydrates with `blocks` and shows a loaded banner
353
+ ("References an existing message in #channel"), with the author's
354
+ name/avatar in the preview header.
348
355
  - On `{ ok: false, reason }`, the load dialog renders the reason inline and
349
356
  offers **Open as a new message instead**. Pass `blocks` on the failure
350
357
  result to hydrate the draft for that fallback.
358
+ - `loading.onLoadedMessageChange` reports the loaded target — and `null`
359
+ when the user exits it — so host state stays in sync (most useful in
360
+ compose-only mode, where your app owns what happens next). It can
361
+ **re-fire for the same `channelId` + `ts`** when the host's verdict or
362
+ metadata changed (e.g. `editableVia` flipping after a Slack sign-in), so
363
+ key host state on the full target, not `ts` alone.
364
+ - The banner's **"Switch to a new message"** exit is mode-aware: in send
365
+ mode it clears the canvas and reopens the load dialog (the edit-centric
366
+ "pick a different message" flow); in compose-only mode it only detaches
367
+ the reference and **keeps the draft** — loading seeded a composition, and
368
+ a misclick must not destroy it.
369
+
370
+ ### Updating it in place (send mode)
371
+
372
+ In send mode, pair `loading` with `onUpdate` to dispatch a `chat.update` for
373
+ the loaded message. The primary action flips to a "Review & update" split
374
+ button (the menu also offers "Send as a new message"), the destination is
375
+ locked to the source channel, and the post-as identity is fixed to the
376
+ verdict's `editableVia` (the `'user'` path reuses `loadSendAsUserStatus` for
377
+ the "Sign in with Slack" gate).
378
+
379
+ ```tsx
380
+ <BlockKitchen
381
+ /* …send trio… */
382
+ loading={{ onLoadMessage }}
383
+ // Sibling to onSend; carries the source channel + ts. `asUser` follows
384
+ // the verdict's `editableVia`.
385
+ onUpdate={async ({ channelId, ts, blocks, asUser }) => {
386
+ await chatUpdate({ channel: channelId, ts, blocks, asUser }); // your code
387
+ return { ok: true };
388
+ }}
389
+ />
390
+ ```
391
+
392
+ With `loading` alone (no `onUpdate`), a loaded message can still be posted
393
+ as a brand-new message via the regular Send flow — there's just no in-place
394
+ update. In compose-only mode `onUpdate` doesn't exist; implement your own
395
+ update against the target from `loadedMessage` / `onLoadedMessageChange`.
396
+
397
+ > **Migrating from the pre-split `editing` bundle (≤ 0.9.x):** the load
398
+ > fields move to `loading` unchanged, and `onUpdate` becomes a top-level
399
+ > prop (the `editing` wrapper is gone).
400
+ >
401
+ > ```tsx
402
+ > // Before
403
+ > <BlockKitchen editing={{ onLoadMessage, onUpdate, loadRecentMessages }} />
404
+ > // After
405
+ > <BlockKitchen loading={{ onLoadMessage, loadRecentMessages }} onUpdate={onUpdate} />
406
+ > ```
351
407
 
352
408
  ## Customizing the palette
353
409
 
@@ -405,7 +461,9 @@ import type {
405
461
  SupportedBlockType,
406
462
  BlockKitchenProps,
407
463
  BlockKitchenBaseProps, // shared props
408
- BlockKitchenSendProps, // the send trio + `editing` + `renderSendExtras`
464
+ BlockKitchenSendProps, // the send trio + `onUpdate` + `renderSendExtras`
465
+ LoadingConfig, // load an existing message (works in both modes)
466
+ LoadedMessage, // the loaded target: channel + ts + verdict
409
467
  BlockKitchenComposeOnlyProps, // the trio explicitly absent + `primaryAction`
410
468
  PaletteSection,
411
469
  PaletteVariant,
@@ -1,5 +1,5 @@
1
- import { Z as SupportedBlock } from './palette-Duj901T6.cjs';
2
- export { _ as SupportedBlockType } from './palette-Duj901T6.cjs';
1
+ import { _ as SupportedBlock } from './palette-C8sPJuXx.cjs';
2
+ export { $ as SupportedBlockType } from './palette-C8sPJuXx.cjs';
3
3
  import 'react';
4
4
  import 'slack-web-api-client';
5
5
 
package/dist/helpers.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { Z as SupportedBlock } from './palette-Duj901T6.js';
2
- export { _ as SupportedBlockType } from './palette-Duj901T6.js';
1
+ import { _ as SupportedBlock } from './palette-C8sPJuXx.js';
2
+ export { $ as SupportedBlockType } from './palette-C8sPJuXx.js';
3
3
  import 'react';
4
4
  import 'slack-web-api-client';
5
5
 
package/dist/index.cjs CHANGED
@@ -2256,6 +2256,29 @@ function useBlockKitchenState({
2256
2256
  replaceAll
2257
2257
  };
2258
2258
  }
2259
+ function useDevWarning(condition, message) {
2260
+ const warnedRef = React.useRef(false);
2261
+ React.useEffect(() => {
2262
+ if (condition && !warnedRef.current) {
2263
+ warnedRef.current = true;
2264
+ console.warn(message);
2265
+ }
2266
+ }, [condition, message]);
2267
+ }
2268
+ function useNotifyOnChange(value, toKey, callback) {
2269
+ const lastNotifiedRef = React.useRef(null);
2270
+ React.useEffect(() => {
2271
+ if (!callback) {
2272
+ return;
2273
+ }
2274
+ const key = toKey(value);
2275
+ if (lastNotifiedRef.current === key) {
2276
+ return;
2277
+ }
2278
+ lastNotifiedRef.current = key;
2279
+ callback(value);
2280
+ }, [value, toKey, callback]);
2281
+ }
2259
2282
 
2260
2283
  // src/lib/brand-theme.ts
2261
2284
  var TOKEN_TO_CSS_VAR = {
@@ -3053,7 +3076,7 @@ function LoadMessageDialog({
3053
3076
  loadRecentMessagesRef.current = loadRecentMessages;
3054
3077
  loadChannelsRef.current = loadChannels;
3055
3078
  });
3056
- const hasRecent = !!loadRecentMessages;
3079
+ const hasRecent = !!loadRecentMessages && !!loadChannels;
3057
3080
  React.useEffect(() => {
3058
3081
  if (!open) {
3059
3082
  return;
@@ -3064,7 +3087,10 @@ function LoadMessageDialog({
3064
3087
  setRecent(null);
3065
3088
  setRecentError(null);
3066
3089
  setSelectedRecent(null);
3067
- if (!loadRecentMessagesRef.current) {
3090
+ }, [open]);
3091
+ React.useEffect(() => {
3092
+ const load = loadChannelsRef.current;
3093
+ if (!open || !hasRecent || !load) {
3068
3094
  setChannels([]);
3069
3095
  setChannelsError(null);
3070
3096
  return;
@@ -3072,7 +3098,7 @@ function LoadMessageDialog({
3072
3098
  setChannels(null);
3073
3099
  setChannelsError(null);
3074
3100
  let cancelled = false;
3075
- loadChannelsRef.current().then((list) => {
3101
+ load().then((list) => {
3076
3102
  if (!cancelled) {
3077
3103
  setChannels(list);
3078
3104
  }
@@ -3084,7 +3110,7 @@ function LoadMessageDialog({
3084
3110
  return () => {
3085
3111
  cancelled = true;
3086
3112
  };
3087
- }, [open]);
3113
+ }, [open, hasRecent]);
3088
3114
  React.useEffect(() => {
3089
3115
  if (!open || !loadRecentMessagesRef.current || !channelId) {
3090
3116
  return;
@@ -9444,7 +9470,8 @@ function Toolbar2({
9444
9470
  showSend = true,
9445
9471
  primaryAction,
9446
9472
  sendButtonLabel = "Review & send",
9447
- editingEnabled = false,
9473
+ loadEnabled = false,
9474
+ updateEnabled = false,
9448
9475
  editBadge,
9449
9476
  onOpenLoad,
9450
9477
  onOpenUpdate,
@@ -9464,7 +9491,7 @@ function Toolbar2({
9464
9491
  return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
9465
9492
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-wrap items-center justify-between gap-x-2 gap-y-1 border-b bg-background px-2 py-1.5 sm:px-3 sm:py-2", children: [
9466
9493
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex min-w-0 flex-1 items-center gap-1 sm:gap-2", children: [
9467
- editingEnabled ? /* @__PURE__ */ jsxRuntime.jsxs(Button, { type: "button", size: "sm", onClick: onOpenLoad, "aria-label": loadButtonLabel, children: [
9494
+ loadEnabled ? /* @__PURE__ */ jsxRuntime.jsxs(Button, { type: "button", size: "sm", onClick: onOpenLoad, "aria-label": loadButtonLabel, children: [
9468
9495
  /* @__PURE__ */ jsxRuntime.jsx(lucideReact.MailSearch, { className: "h-3.5 w-3.5" }),
9469
9496
  /* @__PURE__ */ jsxRuntime.jsx("span", { className: "hidden md:inline", children: loadButtonLabel })
9470
9497
  ] }) : null,
@@ -9594,9 +9621,12 @@ function Toolbar2({
9594
9621
  children: /* @__PURE__ */ jsxRuntime.jsx("span", { children: primaryAction.label })
9595
9622
  }
9596
9623
  )
9597
- ) : null : editBadge ? (
9598
- // A message is loaded: split button. Main action updates it in
9599
- // place; the menu also offers posting the blocks as a new message.
9624
+ ) : null : editBadge && updateEnabled ? (
9625
+ // A message is loaded and update-in-place is wired: split button.
9626
+ // Main action updates it in place; the menu also offers posting
9627
+ // the blocks as a new message. Without `updateEnabled` (loading
9628
+ // configured but no `editing.onUpdate`), the plain send button
9629
+ // below stays — the loaded message can only be posted as new.
9600
9630
  /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-stretch", children: [
9601
9631
  /* @__PURE__ */ jsxRuntime.jsxs(
9602
9632
  Button,
@@ -9669,7 +9699,7 @@ function Toolbar2({
9669
9699
  ] })
9670
9700
  ] })
9671
9701
  ] }),
9672
- editingEnabled && editBadge ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-wrap items-center gap-x-3 gap-y-1 border-b bg-muted px-2 py-2 text-foreground sm:px-3", children: [
9702
+ loadEnabled && editBadge ? /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex flex-wrap items-center gap-x-3 gap-y-1 border-b bg-muted px-2 py-2 text-foreground sm:px-3", children: [
9673
9703
  /* @__PURE__ */ jsxRuntime.jsx(lucideReact.Pencil, { className: "h-4 w-4 shrink-0 text-primary" }),
9674
9704
  /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "min-w-0 flex-1 text-sm", children: [
9675
9705
  "References an existing message in ",
@@ -9832,6 +9862,19 @@ function UpdateDialog({
9832
9862
  ] })
9833
9863
  ] }) });
9834
9864
  }
9865
+ function toLoadedMessage({
9866
+ ok: _ok,
9867
+ blocks: _blocks,
9868
+ ...loadedMessage
9869
+ }) {
9870
+ return loadedMessage;
9871
+ }
9872
+ function loadedMessageKey(message) {
9873
+ return message ? JSON.stringify(message) : null;
9874
+ }
9875
+ function validationSummaryKey(summary) {
9876
+ return JSON.stringify([summary.valid, summary.errorCount, ...summary.errors]);
9877
+ }
9835
9878
  function BlockKitchen(props) {
9836
9879
  const {
9837
9880
  workspaceName,
@@ -9844,7 +9887,8 @@ function BlockKitchen(props) {
9844
9887
  loadSendAsUserStatus,
9845
9888
  onSend,
9846
9889
  renderSendExtras,
9847
- editing,
9890
+ loading,
9891
+ onUpdate: onUpdateProp,
9848
9892
  primaryAction,
9849
9893
  loadButtonLabel,
9850
9894
  updateButtonLabel,
@@ -9888,36 +9932,39 @@ function BlockKitchen(props) {
9888
9932
  const variantById = React.useMemo(() => buildVariantById(paletteSections), [paletteSections]);
9889
9933
  const allowedSurfaces = allowedSurfacesProp && allowedSurfacesProp.length > 0 ? allowedSurfacesProp : ["message"];
9890
9934
  const sendEnabled = Boolean(loadChannels && loadSendAsUserStatus && onSend);
9891
- const editingConfig = sendEnabled ? editing : void 0;
9892
- const seededBlocks = editingConfig?.initialTarget?.blocks ?? initialBlocks;
9893
- React.useEffect(() => {
9894
- if (editingConfig?.initialTarget && initialBlocks) {
9895
- console.warn(
9896
- "[BlockKitchen] Both `initialBlocks` and `editing.initialTarget` were provided; using the target\u2019s blocks and ignoring `initialBlocks`."
9897
- );
9898
- }
9899
- const sendPropsProvided = [loadChannels, loadSendAsUserStatus, onSend].filter(Boolean).length;
9900
- if (sendPropsProvided > 0 && sendPropsProvided < 3) {
9901
- console.warn(
9902
- "[BlockKitchen] Partial send wiring: `loadChannels`, `loadSendAsUserStatus`, and `onSend` are all-or-nothing. The send button is hidden until all three are provided."
9903
- );
9904
- }
9905
- if (editing && !sendEnabled) {
9906
- console.warn(
9907
- "[BlockKitchen] `editing` requires the send integration (`loadChannels`, `loadSendAsUserStatus`, `onSend`); ignoring `editing`."
9908
- );
9909
- }
9910
- if (renderSendExtras && !sendEnabled) {
9911
- console.warn(
9912
- "[BlockKitchen] `renderSendExtras` extends the built-in send dialog, which requires the send integration (`loadChannels`, `loadSendAsUserStatus`, `onSend`); ignoring `renderSendExtras`."
9913
- );
9914
- }
9915
- if (primaryAction && sendEnabled) {
9916
- console.warn(
9917
- "[BlockKitchen] `primaryAction` is only available in compose-only mode \u2014 with the send integration wired, the built-in Send/Update flow owns the toolbar\u2019s primary action; ignoring `primaryAction`."
9918
- );
9919
- }
9920
- }, []);
9935
+ const loadEnabled = Boolean(loading);
9936
+ const recentChannelSource = loading?.loadChannels ?? (sendEnabled ? loadChannels : void 0);
9937
+ const onUpdate = sendEnabled ? onUpdateProp : void 0;
9938
+ const seededBlocks = loading?.initialTarget?.blocks ?? initialBlocks;
9939
+ const sendPropsProvided = [loadChannels, loadSendAsUserStatus, onSend].filter(Boolean).length;
9940
+ useDevWarning(
9941
+ Boolean(loading?.initialTarget && initialBlocks),
9942
+ "[BlockKitchen] Both `initialBlocks` and an `initialTarget` were provided; using the target\u2019s blocks and ignoring `initialBlocks`."
9943
+ );
9944
+ useDevWarning(
9945
+ sendPropsProvided > 0 && sendPropsProvided < 3,
9946
+ "[BlockKitchen] Partial send wiring: `loadChannels`, `loadSendAsUserStatus`, and `onSend` are all-or-nothing. The send button is hidden until all three are provided."
9947
+ );
9948
+ useDevWarning(
9949
+ Boolean(onUpdateProp && !sendEnabled),
9950
+ "[BlockKitchen] `onUpdate` requires the send integration (`loadChannels`, `loadSendAsUserStatus`, `onSend`); ignoring `onUpdate`. To load an existing message without the send integration, use the `loading` prop instead."
9951
+ );
9952
+ useDevWarning(
9953
+ Boolean(sendEnabled && onUpdateProp && !loading),
9954
+ "[BlockKitchen] `onUpdate` needs a loaded message, but no load source is configured \u2014 provide the `loading` prop. The update flow is unreachable."
9955
+ );
9956
+ useDevWarning(
9957
+ Boolean(loading?.loadRecentMessages && !recentChannelSource),
9958
+ "[BlockKitchen] `loading.loadRecentMessages` needs a channel list to scope its lookup \u2014 provide `loading.loadChannels` (compose-only mode has no send integration to reuse). Hiding the recent-messages picker."
9959
+ );
9960
+ useDevWarning(
9961
+ Boolean(renderSendExtras && !sendEnabled),
9962
+ "[BlockKitchen] `renderSendExtras` extends the built-in send dialog, which requires the send integration (`loadChannels`, `loadSendAsUserStatus`, `onSend`); ignoring `renderSendExtras`."
9963
+ );
9964
+ useDevWarning(
9965
+ Boolean(primaryAction && sendEnabled),
9966
+ "[BlockKitchen] `primaryAction` is only available in compose-only mode \u2014 with the send integration wired, the built-in Send/Update flow owns the toolbar\u2019s primary action; ignoring `primaryAction`."
9967
+ );
9921
9968
  const { blocks, addBlock, addChild, updateBlock, removeBlock, duplicateBlock, reorderBlock, moveBlock, replaceAll } = useBlockKitchenState({ initialBlocks: seededBlocks, onChange });
9922
9969
  const childParentById = React.useMemo(() => {
9923
9970
  const map = /* @__PURE__ */ new Map();
@@ -9934,8 +9981,11 @@ function BlockKitchen(props) {
9934
9981
  const [sendOpen, setSendOpen] = React.useState(false);
9935
9982
  const [updateOpen, setUpdateOpen] = React.useState(false);
9936
9983
  const [loadOpen, setLoadOpen] = React.useState(false);
9937
- const [editTarget, setEditTarget] = React.useState(() => editingConfig?.initialTarget ?? null);
9938
- const activeEditTarget = editingConfig ? editTarget : null;
9984
+ const [editTarget, setEditTarget] = React.useState(
9985
+ () => loading?.initialTarget ? toLoadedMessage(loading.initialTarget) : null
9986
+ );
9987
+ const activeEditTarget = loadEnabled ? editTarget : null;
9988
+ useNotifyOnChange(activeEditTarget, loadedMessageKey, loading?.onLoadedMessageChange);
9939
9989
  const [issuesOpen, setIssuesOpen] = React.useState(false);
9940
9990
  const [paletteOpen, setPaletteOpen] = React.useState(false);
9941
9991
  const [openBlockId, setOpenBlockId] = React.useState(null);
@@ -9957,18 +10007,7 @@ function BlockKitchen(props) {
9957
10007
  () => ({ valid: validation.valid, errorCount: validation.total, errors: validation.errors }),
9958
10008
  [validation]
9959
10009
  );
9960
- const lastNotifiedValidationRef = React.useRef(null);
9961
- React.useEffect(() => {
9962
- if (!onValidationChange) {
9963
- return;
9964
- }
9965
- const key = JSON.stringify([validationSummary.valid, validationSummary.errorCount, ...validationSummary.errors]);
9966
- if (lastNotifiedValidationRef.current === key) {
9967
- return;
9968
- }
9969
- lastNotifiedValidationRef.current = key;
9970
- onValidationChange(validationSummary);
9971
- }, [validationSummary, onValidationChange]);
10010
+ useNotifyOnChange(validationSummary, validationSummaryKey, onValidationChange);
9972
10011
  const sensors = core.useSensors(
9973
10012
  core.useSensor(core.PointerSensor, { activationConstraint: { distance: 4 } }),
9974
10013
  core.useSensor(core.TouchSensor, { activationConstraint: { delay: 150, tolerance: 8 } }),
@@ -10075,11 +10114,16 @@ function BlockKitchen(props) {
10075
10114
  showSend: sendEnabled,
10076
10115
  primaryAction: !sendEnabled && primaryAction ? {
10077
10116
  label: primaryAction.label,
10078
- onClick: () => primaryAction.onClick({ blocks: blockPayloads, validation: validationSummary }),
10117
+ onClick: () => primaryAction.onClick({
10118
+ blocks: blockPayloads,
10119
+ validation: validationSummary,
10120
+ loadedMessage: activeEditTarget
10121
+ }),
10079
10122
  disabled: primaryAction.disableWhenInvalid ? !validationSummary.valid : false
10080
10123
  } : null,
10081
10124
  sendButtonLabel,
10082
- editingEnabled: !!editingConfig,
10125
+ loadEnabled,
10126
+ updateEnabled: Boolean(onUpdate),
10083
10127
  editBadge: activeEditTarget ? {
10084
10128
  channelLabel: activeEditTarget.channelName ? `#${activeEditTarget.channelName}` : activeEditTarget.channelId,
10085
10129
  ts: activeEditTarget.ts
@@ -10087,9 +10131,13 @@ function BlockKitchen(props) {
10087
10131
  onOpenLoad: () => setLoadOpen(true),
10088
10132
  onOpenUpdate: () => setUpdateOpen(true),
10089
10133
  onExitEdit: () => {
10134
+ if (sendEnabled) {
10135
+ setEditTarget(null);
10136
+ replaceAll([]);
10137
+ setLoadOpen(true);
10138
+ return;
10139
+ }
10090
10140
  setEditTarget(null);
10091
- replaceAll([]);
10092
- setLoadOpen(true);
10093
10141
  },
10094
10142
  loadButtonLabel,
10095
10143
  updateButtonLabel
@@ -10179,7 +10227,7 @@ function BlockKitchen(props) {
10179
10227
  }
10180
10228
  }
10181
10229
  ) : null,
10182
- activeEditTarget && editingConfig && loadSendAsUserStatus ? /* @__PURE__ */ jsxRuntime.jsx(
10230
+ activeEditTarget && onUpdate && loadSendAsUserStatus ? /* @__PURE__ */ jsxRuntime.jsx(
10183
10231
  UpdateDialog,
10184
10232
  {
10185
10233
  open: updateOpen,
@@ -10187,7 +10235,7 @@ function BlockKitchen(props) {
10187
10235
  target: activeEditTarget,
10188
10236
  blocks: blockPayloads,
10189
10237
  loadSendAsUserStatus,
10190
- onUpdate: editingConfig.onUpdate,
10238
+ onUpdate,
10191
10239
  confirmUpdateLabel,
10192
10240
  errorCount: validation.total,
10193
10241
  onShowIssues: () => {
@@ -10196,25 +10244,17 @@ function BlockKitchen(props) {
10196
10244
  }
10197
10245
  }
10198
10246
  ) : null,
10199
- editingConfig && loadChannels ? /* @__PURE__ */ jsxRuntime.jsx(
10247
+ loading ? /* @__PURE__ */ jsxRuntime.jsx(
10200
10248
  LoadMessageDialog,
10201
10249
  {
10202
10250
  open: loadOpen,
10203
10251
  onOpenChange: setLoadOpen,
10204
- onLoadMessage: editingConfig.onLoadMessage,
10205
- loadRecentMessages: editingConfig.loadRecentMessages,
10206
- loadChannels,
10252
+ onLoadMessage: loading.onLoadMessage,
10253
+ loadRecentMessages: loading.loadRecentMessages,
10254
+ loadChannels: recentChannelSource,
10207
10255
  onLoaded: (result) => {
10208
10256
  replaceAll(result.blocks);
10209
- setEditTarget({
10210
- channelId: result.channelId,
10211
- channelName: result.channelName,
10212
- ts: result.ts,
10213
- editableVia: result.editableVia,
10214
- workspaceName: result.workspaceName,
10215
- username: result.username,
10216
- iconUrl: result.iconUrl
10217
- });
10257
+ setEditTarget(toLoadedMessage(result));
10218
10258
  setLoadOpen(false);
10219
10259
  },
10220
10260
  onOpenAsNew: (loadedBlocks) => {