From a907b00f534b5e6d16661ef221c28b3dfebbf9a8 Mon Sep 17 00:00:00 2001 From: Stephen Date: Mon, 29 Jun 2026 14:11:31 -0700 Subject: [PATCH 01/24] feat(editing): opt-in message edit mode (load existing + update) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an opt-in `editing` prop to BlockKitchen so a host can load an existing Slack message into the editor and dispatch an update, instead of only sending new messages. Default off — existing send-only consumers are unaffected. The package stays integration-agnostic: no network calls, no token knowledge; the host brokers all I/O and computes the editability verdict. - `editing: { onLoadMessage, onUpdate, loadRecentMessages? }` — presence enables edit mode. - Two entry points in the load dialog: paste a Slack permalink, or pick a "recent message from this app". Each recent row shows the identity it was posted as (BOT/YOU) via `editableVia`, which also routes the update through the correct token. - Toolbar gains an "Edit message" entry, an edit-mode badge with switch-back, and flips the primary action Send → Update message. - UpdateDialog locks the channel to the source and fixes the post-as identity to the verdict's `editableVia` (the user path reuses `loadSendAsUserStatus` for the sign-in gate). - Not-editable verdicts render the host's reason inline with an "Open as a new message instead" fallback. Extends the mocked Playground to exercise every branch in-memory: a message store, mock load/update/recent hooks computing the real verdict, and knobs for `editing` on/off, `canSendAsUser`, and `oauthUrl`. The demo models conservative host behavior by hiding user-authored recent messages when there's no user token. Co-Authored-By: Claude Opus 4.8 --- README.md | 59 ++++ demo/src/App.tsx | 379 +++++++++++++++++++++++-- src/components/block-kitchen.tsx | 102 ++++++- src/components/load-message-dialog.tsx | 250 ++++++++++++++++ src/components/toolbar.stories.tsx | 28 ++ src/components/toolbar.tsx | 58 +++- src/components/update-dialog.tsx | 202 +++++++++++++ src/index.ts | 7 + src/types.ts | 124 ++++++++ 9 files changed, 1171 insertions(+), 38 deletions(-) create mode 100644 src/components/load-message-dialog.tsx create mode 100644 src/components/update-dialog.tsx diff --git a/README.md b/README.md index ee9c145..598c227 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,8 @@ export function MyBuilderPage() { | `loadChannels` | `() => Promise<{ id: string; name: string }[]>` | yes | Returns channels available to send to. The package never makes Slack API calls itself. | | `loadSendAsUserStatus` | `() => Promise<{ canSendAsUser: boolean; oauthUrl?: string }>` | yes | 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. | | `onSend` | `(payload) => Promise<{ ok: boolean; error?: string }>` | yes | Called when the user submits the send dialog. Payload is `{ channelId, blocks, sendAsUser }`. | +| `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. Omit `editing` to keep send-only behavior. | +| `updateButtonLabel` | `string` | no | Label for the primary button while a message is loaded for editing. Defaults to `'Update message'`. | | `previewHooks` | `PreviewHooks` | no | Hooks forwarded to `slack-blocks-to-jsx`'s `` for resolving user / channel / emoji directives. | | `customEmojis` | `CustomEmoji[]` | no | Workspace custom emoji (`{ name, url, alias }`) the preview resolves. Entries with a `url` render `:name:` as the workspace image; alias entries (`url: null`) fall back to their target emoji. Render-only — never serialized into the emitted Block Kit JSON. A caller-supplied `previewHooks.emoji` takes precedence. | | `palette` | `PaletteSection[]` | no | The left-hand palette of draggable variants. Defaults to `defaultPalette`. Spread it to filter, reorder, or add your own pre-configured variants — see [Customizing the palette](#customizing-the-palette). | @@ -123,6 +125,63 @@ export function MyBuilderPage() { | `confirmSendLabel` | `string` | no | Label for the send dialog's final confirm button. Defaults to `'Send'` (shows `'Sending…'` while in flight). | | `theme` | `BrandTheme \| BrandPreset` | no | Branding tokens applied to the builder chrome (toolbar, palette, popovers, dialogs). Accepts a `Partial` map and optional `light`/`dark` overrides. See [Styling](#styling) below. | +## Editing an existing message (opt-in) + +By default the builder is send-only. Pass `editing` to let users load an +already-posted message, edit its blocks, and dispatch a `chat.update`. The +package stays integration-agnostic: it makes no Slack calls and computes +nothing about who can edit — the host does both. + +```tsx + { + const msg = await fetchMessageFromPermalink(link); // your code + if (!msg) return { ok: false, reason: "Couldn't find that message." }; + if (!msg.blocks?.length) return { ok: false, reason: 'This message has no editable blocks.' }; + if (msg.appId === MY_APP_ID) + return { ok: true, channelId: msg.channel, channelName: msg.channelName, ts: msg.ts, blocks: msg.blocks, editableVia: 'bot' }; + if (msg.userId === currentUserId) + return { ok: true, channelId: msg.channel, channelName: msg.channelName, ts: msg.ts, blocks: msg.blocks, editableVia: 'user' }; + return { ok: false, reason: 'Only messages your app or you posted can be edited.', blocks: msg.blocks }; + }, + // Sibling to onSend; carries the source channel + ts. `asUser` follows + // the verdict's `editableVia`. + onUpdate: async ({ channelId, ts, blocks, asUser }) => { + await chatUpdate({ channel: channelId, ts, blocks, asUser }); // your code + return { ok: true }; + }, + // Optional: adds a "recent messages from this app" picker beside the paste + // input. These are editable-by-construction (the app authored them), so + // picking one loads it straight into edit mode (no verdict needed). + loadRecentMessages: async () => { + const msgs = await fetchRecentAppMessages(); // your code + return msgs.map((m) => ({ + channelId: m.channel, + channelName: m.channelName, + ts: m.ts, + blocks: m.blocks, + editableVia: 'bot', // defaults to 'bot' if omitted + label: m.preview // one-line preview shown in the picker row + })); + } + }} +/> +``` + +- On `ok`, the builder hydrates with `blocks`, shows an edit-mode badge, locks + the destination to the source channel, and fixes the post-as identity to + `editableVia` (the `'user'` path reuses `loadSendAsUserStatus` for the "Sign + in with Slack" gate). +- On `{ ok: false, reason }`, the load dialog renders the reason inline and + offers **Open as a new message instead** — pass `blocks` on the failure + result to hydrate the draft for that fallback. + ## Customizing the palette The default palette ships with curated presets for every supported block type. To narrow what's available, or add your own pre-configured variants (e.g. a "Help footer" section), pass a `palette` array. Define it at module scope (or wrap in `useMemo`) so it stays referentially stable across renders. diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 39787a7..a40b98e 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -2,17 +2,24 @@ import { BlockKitchen, type BrandPreset, type ChannelOption, + type LoadMessageInput, + type LoadResult, + type RecentMessage, type SendAsUserStatus, type SendPayload, type SendResult, type SupportedBlock, type Template, - TemplatePicker + TemplatePicker, + type UpdatePayload, + type UpdateResult } from '@tightknitai/block-kitchen'; import { type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, + useCallback, useEffect, + useRef, useState } from 'react'; import { demoTemplates } from './templates'; @@ -34,6 +41,105 @@ const MOCK_CHANNELS: ChannelOption[] = [ { id: 'C0005', name: 'product' } ]; +// --- Edit-mode demo: in-memory "already-posted" message store --------------- +// +// Everything below mocks the host side of the package's opt-in edit mode so +// every editability branch is demonstrable with no backend. A real host would +// parse the pasted permalink, call `conversations.replies`, and compute the +// same verdict; here we look the message up in this store instead. + +type MessageAuthor = 'bot' | 'you' | 'someoneElse'; + +interface StoredMessage { + ts: string; + channelId: string; + channelName: string; + author: MessageAuthor; + // `non-block` / `edit-window-closed` reproduce the "sharp edges": a message + // that doesn't round-trip through the editor, or one past Slack's edit window. + kind: 'normal' | 'non-block' | 'edit-window-closed'; + blocks: SupportedBlock[]; +} + +const WORKSPACE_NAME = 'Acme Inc.'; + +// Slack's "Copy link" permalink shape: …/archives//p. +function permalinkFor(msg: Pick): string { + return `https://acme.slack.com/archives/${msg.channelId}/p${msg.ts.replace('.', '')}`; +} + +// Best-effort one-line preview for the recent-messages picker: first block +// that carries text wins. +function previewOf(blocks: SupportedBlock[]): string { + for (const b of blocks) { + if ((b.type === 'header' || b.type === 'section') && 'text' in b && b.text && 'text' in b.text) { + return b.text.text; + } + } + return `${blocks.length} block${blocks.length === 1 ? '' : 's'}`; +} + +function sampleBlocks(text: string): SupportedBlock[] { + return [ + { type: 'header', text: { type: 'plain_text', text } }, + { type: 'section', text: { type: 'mrkdwn', text: `Loaded from the store at *${text}*. Edit me and update.` } } + ]; +} + +// One fixture per outcome the verdict logic can produce. +const SEED_MESSAGES: StoredMessage[] = [ + { + ts: '1718000042.000100', + channelId: 'C0003', + channelName: 'engineering', + author: 'bot', + kind: 'normal', + blocks: sampleBlocks('Bot message') + }, + { + ts: '1718000099.000200', + channelId: 'C0001', + channelName: 'general', + author: 'you', + kind: 'normal', + blocks: sampleBlocks('Your message') + }, + { + ts: '1718000150.000300', + channelId: 'C0002', + channelName: 'random', + author: 'someoneElse', + kind: 'normal', + blocks: sampleBlocks("Someone else's message") + }, + { + ts: '1718000200.000400', + channelId: 'C0004', + channelName: 'design', + author: 'bot', + kind: 'non-block', + blocks: [] + }, + { + ts: '1718000250.000500', + channelId: 'C0005', + channelName: 'product', + author: 'you', + kind: 'edit-window-closed', + blocks: sampleBlocks('Old message') + } +]; + +// Match a pasted permalink to a stored message. Tolerant of extra query/thread +// params: compare on the `p` segment, falling back to a digit match. +function findMessageByLink(store: StoredMessage[], link: string): StoredMessage | undefined { + const digits = link.replace(/\D/g, ''); + return store.find((m) => { + const tsDigits = m.ts.replace('.', ''); + return digits.includes(tsDigits) || link.trim() === permalinkFor(m); + }); +} + const INITIAL_BLOCKS: SupportedBlock[] = [ { type: 'header', @@ -54,23 +160,6 @@ async function loadChannels(): Promise { return MOCK_CHANNELS; } -async function loadSendAsUserStatus(): Promise { - await new Promise((r) => setTimeout(r, 150)); - return { canSendAsUser: true }; -} - -async function onSend(payload: SendPayload): Promise { - await new Promise((r) => setTimeout(r, 400)); - console.log('[demo] onSend called with', payload); - const channel = MOCK_CHANNELS.find((c) => c.id === payload.channelId); - window.alert( - `Mock send to #${channel?.name ?? payload.channelId}\n` + - `${payload.blocks.length} block${payload.blocks.length === 1 ? '' : 's'} — ` + - `sendAsUser=${payload.sendAsUser}\n\nSee console for full payload.` - ); - return { ok: true }; -} - const ASIDE_MIN = 280; const ASIDE_MAX = 640; const ASIDE_DEFAULT = 380; @@ -112,6 +201,115 @@ export function App() { setBuilderKey((n) => n + 1); }; + // --- Edit-mode demo knobs + in-memory store ------------------------------ + const [editingEnabled, setEditingEnabled] = useState(true); + const [canSendAsUser, setCanSendAsUser] = useState(true); + const [includeOauthUrl, setIncludeOauthUrl] = useState(true); + const [store, setStore] = useState(SEED_MESSAGES); + + // Read the latest store from inside the stable `onLoadMessage` callback + // without making it a dependency (the package captures it directly). + const storeRef = useRef(store); + useEffect(() => { + storeRef.current = store; + }); + + const loadSendAsUserStatus = useCallback(async (): Promise => { + await new Promise((r) => setTimeout(r, 150)); + if (canSendAsUser) { + return { canSendAsUser: true }; + } + return { + canSendAsUser: false, + oauthUrl: includeOauthUrl ? 'https://slack.com/oauth/v2/authorize?mock=1' : undefined + }; + }, [canSendAsUser, includeOauthUrl]); + + const onSend = useCallback(async (payload: SendPayload): Promise => { + await new Promise((r) => setTimeout(r, 300)); + const channel = MOCK_CHANNELS.find((c) => c.id === payload.channelId); + setStore((prev) => { + const ts = `${Math.floor(Date.now() / 1000)}.${String(prev.length).padStart(6, '0')}`; + const posted: StoredMessage = { + ts, + channelId: payload.channelId, + channelName: channel?.name ?? payload.channelId, + author: payload.sendAsUser ? 'you' : 'bot', + kind: 'normal', + blocks: payload.blocks + }; + return [...prev, posted]; + }); + return { ok: true }; + }, []); + + // Mirrors the verdict a real host computes from `conversations.replies`. + const onLoadMessage = useCallback(async ({ link }: LoadMessageInput): Promise => { + await new Promise((r) => setTimeout(r, 250)); + const msg = findMessageByLink(storeRef.current, link); + if (!msg) { + return { ok: false, reason: 'No message matched that link. Copy a link from the store on the right.' }; + } + if (msg.kind === 'non-block') { + return { + ok: false, + reason: "This message has no editable blocks (it may be attachment-only), so it can't be opened in the editor." + }; + } + if (msg.kind === 'edit-window-closed') { + return { ok: false, reason: "This message is past Slack's edit window and can no longer be edited." }; + } + if (msg.author === 'someoneElse') { + return { + ok: false, + reason: "This message was posted by someone else, so it can't be edited. You can repost its content as a new message.", + blocks: msg.blocks + }; + } + const base = { + channelId: msg.channelId, + channelName: msg.channelName, + ts: msg.ts, + blocks: msg.blocks, + workspaceName: WORKSPACE_NAME + } as const; + return msg.author === 'bot' + ? { ok: true, ...base, editableVia: 'bot' } + : { ok: true, ...base, editableVia: 'user' }; + }, []); + + const onUpdate = useCallback(async ({ ts, blocks: updated }: UpdatePayload): Promise => { + await new Promise((r) => setTimeout(r, 300)); + setStore((prev) => prev.map((m) => (m.ts === ts ? { ...m, blocks: updated } : m))); + return { ok: true }; + }, []); + + // "Recent messages from this app" — round-trippable fixtures the app + // authored, whether posted as the bot or as the current user (plus anything + // sent during the session). Each carries the identity it was posted as via + // `editableVia`, which the picker surfaces and the update uses to pick the + // token. Messages by someone else (or that don't round-trip) are excluded. + // Conservative host behavior: drop the user's own messages when there's no + // user token, rather than offer an edit that can't complete without re-auth. + const loadRecentMessages = useCallback(async (): Promise => { + await new Promise((r) => setTimeout(r, 200)); + return storeRef.current + .filter((m) => { + if (m.kind !== 'normal' || m.blocks.length === 0) return false; + if (m.author === 'bot') return true; + return m.author === 'you' && canSendAsUser; + }) + .map((m): RecentMessage => ({ + channelId: m.channelId, + channelName: m.channelName, + ts: m.ts, + blocks: m.blocks, + editableVia: m.author === 'you' ? 'user' : 'bot', + label: previewOf(m.blocks), + workspaceName: WORKSPACE_NAME + })); + }, [canSendAsUser]); + const [asideWidth, setAsideWidth] = useState(ASIDE_DEFAULT); // Collapse state. `narrow` follows the viewport; user clicks override @@ -282,16 +480,26 @@ export function App() { +
); } + +const AUTHOR_LABEL: Record = { + bot: 'this app (bot)', + you: 'you', + someoneElse: 'someone else' +}; + +const KIND_NOTE: Record = { + normal: '', + 'non-block': ' · non-block', + 'edit-window-closed': ' · edit window closed' +}; + +/** + * Mocked-host control panel for the package's edit mode. Toggles prove + * configurability (`editing` on/off, the user-token gate) and the message + * store makes the load → update round-trip observable: copy a message's link, + * paste it into the builder's "Edit message" dialog, update, and watch the + * stored blocks change here. + */ +function EditModePanel({ + editingEnabled, + onEditingEnabledChange, + canSendAsUser, + onCanSendAsUserChange, + includeOauthUrl, + onIncludeOauthUrlChange, + store +}: { + editingEnabled: boolean; + onEditingEnabledChange: (v: boolean) => void; + canSendAsUser: boolean; + onCanSendAsUserChange: (v: boolean) => void; + includeOauthUrl: boolean; + onIncludeOauthUrlChange: (v: boolean) => void; + store: StoredMessage[]; +}) { + const [copiedTs, setCopiedTs] = useState(null); + + const copyLink = (msg: StoredMessage) => { + const link = permalinkFor(msg); + navigator.clipboard?.writeText(link).catch(() => {}); + setCopiedTs(msg.ts); + window.setTimeout(() => setCopiedTs((cur) => (cur === msg.ts ? null : cur)), 1200); + }; + + const checkbox = (label: string, checked: boolean, onChange: (v: boolean) => void, disabled?: boolean) => ( + + ); + + return ( +
+ + Edit-mode demo (mocked host) — copy a link, then use “Edit message” in the toolbar + +
+
+ {checkbox('editing enabled', editingEnabled, onEditingEnabledChange)} + {checkbox('canSendAsUser', canSendAsUser, onCanSendAsUserChange)} + {checkbox('include oauthUrl', includeOauthUrl, onIncludeOauthUrlChange, canSendAsUser)} + + Turn “editing enabled” off to confirm the builder falls back to send-only. + +
+
+
Message store
+ {store.map((m) => ( +
+
+
+ {m.ts} +
+
+ #{m.channelName} · {AUTHOR_LABEL[m.author]} + {KIND_NOTE[m.kind]} · {m.blocks.length} block{m.blocks.length === 1 ? '' : 's'} +
+
+ +
+ ))} +
+
+
+ ); +} diff --git a/src/components/block-kitchen.tsx b/src/components/block-kitchen.tsx index 25f22a0..b36cedd 100644 --- a/src/components/block-kitchen.tsx +++ b/src/components/block-kitchen.tsx @@ -28,10 +28,12 @@ import type { BlockKitchenProps, PreviewSurface, PreviewTheme } from '../types'; import { BrandThemeScope } from './brand-theme-scope'; import { IssuesSheet } from './issues-sheet'; import { JsonDrawer } from './json-drawer'; +import { LoadMessageDialog } from './load-message-dialog'; import { Palette, parsePaletteDragId } from './palette'; import { SendDialog } from './send-dialog'; import { SURFACE_DROPPABLE_ID, Surface } from './surface'; import { Toolbar } from './toolbar'; +import { type EditTarget, UpdateDialog } from './update-dialog'; /** * Top-level Slack Block Kit builder component. @@ -51,6 +53,8 @@ export function BlockKitchen(props: BlockKitchenProps) { loadChannels, loadSendAsUserStatus, onSend, + editing, + updateButtonLabel, palette, disabledBlockTypes, showPaletteSearch, @@ -113,6 +117,14 @@ export function BlockKitchen(props: BlockKitchenProps) { const [jsonOpen, setJsonOpen] = useState(false); const [sendOpen, setSendOpen] = useState(false); + // Edit mode (opt-in via `editing`). `editTarget` is the loaded message; + // when set, the primary action updates it instead of sending a new message. + const [loadOpen, setLoadOpen] = useState(false); + const [editTarget, setEditTarget] = useState(null); + // Edit mode only counts as active while `editing` is configured. If the host + // toggles `editing` off mid-session, fall back to send-only without losing + // the loaded target (it reactivates if `editing` returns). + const activeEditTarget = editing ? editTarget : null; const [issuesOpen, setIssuesOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false); const [openBlockId, setOpenBlockId] = useState(null); @@ -281,6 +293,20 @@ export function BlockKitchen(props: BlockKitchenProps) { showThemeControl={isPreviewThemeControlled ? false : showThemeControl} docsLink={docsLink} sendButtonLabel={sendButtonLabel} + editingEnabled={!!editing} + editBadge={ + activeEditTarget + ? { + channelLabel: activeEditTarget.channelName + ? `#${activeEditTarget.channelName}` + : activeEditTarget.channelId, + ts: activeEditTarget.ts + } + : null + } + onOpenLoad={() => setLoadOpen(true)} + onExitEdit={() => setEditTarget(null)} + updateButtonLabel={updateButtonLabel} />
{/* Desktop: persistent left aside. Mobile: collapsed to the @@ -349,20 +375,68 @@ export function BlockKitchen(props: BlockKitchenProps) { - { - setSendOpen(false); - setIssuesOpen(true); - }} - /> + {/* One primary-action dialog at a time: Update when a message is + loaded for editing, Send otherwise. Both gate on `sendOpen`. */} + {activeEditTarget && editing ? ( + { + setSendOpen(false); + setIssuesOpen(true); + }} + /> + ) : ( + { + setSendOpen(false); + setIssuesOpen(true); + }} + /> + )} + {editing ? ( + { + replaceAll(result.blocks); + setEditTarget({ + channelId: result.channelId, + channelName: result.channelName, + ts: result.ts, + editableVia: result.editableVia, + workspaceName: result.workspaceName + }); + setLoadOpen(false); + }} + onOpenAsNew={(loadedBlocks) => { + // Fallback for a not-editable verdict: drop edit mode and + // hydrate the draft (when the host supplied blocks) so the + // user can repost it as a brand-new message. + if (loadedBlocks) { + replaceAll(loadedBlocks); + } + setEditTarget(null); + setLoadOpen(false); + }} + /> + ) : null} { + return { + ok: true, + channelId: msg.channelId, + channelName: msg.channelName, + ts: msg.ts, + blocks: msg.blocks, + editableVia: msg.editableVia ?? 'bot', + workspaceName: msg.workspaceName + }; +} + +type LoadStatus = + | { kind: 'idle' } + | { kind: 'loading' } + | { kind: 'error'; error: string } + | { kind: 'not-editable'; reason: string; blocks?: SupportedBlock[] }; + +/** + * Edit-mode entry point. Collects a Slack message permalink and hands it to + * the host's `onLoadMessage`. On a successful load the parent flips into + * edit mode (`onLoaded`); on a not-editable verdict the dialog renders the + * host's `reason` inline and offers "Open as a new message instead". + * + * The package never parses the permalink — the host extracts `channel + ts`. + * @param props - dialog props + * @param props.open - whether the dialog is open + * @param props.onOpenChange - notified when the user closes the dialog + * @param props.onLoadMessage - host loader returning an editability verdict + * @param props.loadRecentMessages - optional loader for the "recent messages" picker + * @param props.onLoaded - called with the `ok` result so the parent enters edit mode + * @param props.onOpenAsNew - called with optional blocks for the "open as new" fallback + * @returns the rendered load-message dialog + */ +export function LoadMessageDialog({ + open, + onOpenChange, + onLoadMessage, + loadRecentMessages, + onLoaded, + onOpenAsNew +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onLoadMessage: (input: { link: string }) => Promise; + loadRecentMessages?: () => Promise; + onLoaded: (result: Extract) => void; + onOpenAsNew: (blocks?: SupportedBlock[]) => void; +}) { + const [link, setLink] = useState(''); + const [status, setStatus] = useState({ kind: 'idle' }); + // Recent-messages picker (only loaded when `loadRecentMessages` is given). + // `null` means "loading / not loaded yet". + const [recent, setRecent] = useState(null); + const [recentError, setRecentError] = useState(null); + + // Hold the latest loaders in refs so they can change identity between renders + // (consumers often pass a fresh arrow) without us needing them as deps. + const onLoadMessageRef = useRef(onLoadMessage); + const loadRecentMessagesRef = useRef(loadRecentMessages); + useEffect(() => { + onLoadMessageRef.current = onLoadMessage; + loadRecentMessagesRef.current = loadRecentMessages; + }); + + const hasRecent = !!loadRecentMessages; + + // Reset to a clean slate each time the dialog opens, and (re)load the recent + // list so a fresh open reflects any messages posted since. + useEffect(() => { + if (!open) { + return; + } + setLink(''); + setStatus({ kind: 'idle' }); + if (!loadRecentMessagesRef.current) { + setRecent([]); + setRecentError(null); + return; + } + setRecent(null); + setRecentError(null); + let cancelled = false; + loadRecentMessagesRef + .current() + .then((list) => { + if (!cancelled) { + setRecent(list); + } + }) + .catch((e) => { + if (!cancelled) { + setRecentError(e instanceof Error ? e.message : 'Failed to load recent messages.'); + } + }); + return () => { + cancelled = true; + }; + }, [open]); + + const handleLoad = async () => { + const trimmed = link.trim(); + if (!trimmed) { + setStatus({ kind: 'error', error: 'Paste a Slack message link first.' }); + return; + } + setStatus({ kind: 'loading' }); + try { + const result = await onLoadMessageRef.current({ link: trimmed }); + if (result.ok) { + onLoaded(result); + return; + } + setStatus({ kind: 'not-editable', reason: result.reason, blocks: result.blocks }); + } catch (e) { + setStatus({ kind: 'error', error: e instanceof Error ? e.message : 'Failed to load message.' }); + } + }; + + const notEditable = status.kind === 'not-editable' ? status : null; + + return ( + + + + Edit an existing message + + {hasRecent + ? 'Paste a Slack message link, or pick a recent message your app posted.' + : 'Paste a Slack message link (Slack\'s "Copy link") to load its blocks for editing.'} + + + +
+
+ + { + setLink(e.target.value); + if (status.kind !== 'idle' && status.kind !== 'loading') { + setStatus({ kind: 'idle' }); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && status.kind !== 'loading') { + e.preventDefault(); + handleLoad(); + } + }} + placeholder="https://your-workspace.slack.com/archives/C…/p…" + autoComplete="off" + spellCheck={false} + /> +
+ + {status.kind === 'error' && ( +

+ {status.error} +

+ )} + + {notEditable && ( +
+
+ + {notEditable.reason} +
+ +
+ )} + + {hasRecent && ( +
+
+ + or pick a recent message + +
+ {recent === null && !recentError && ( +

Loading recent messages…

+ )} + {recentError &&

{recentError}

} + {recent && recent.length === 0 && !recentError && ( +

No recent messages from this app.

+ )} + {recent && recent.length > 0 && ( +
+ {recent.map((m) => { + // Which identity the message was posted as — drives both + // this badge and (on load) the token the update uses. + const asUser = (m.editableVia ?? 'bot') === 'user'; + return ( + + ); + })} +
+ )} +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/src/components/toolbar.stories.tsx b/src/components/toolbar.stories.tsx index b2dcf6a..e5413c2 100644 --- a/src/components/toolbar.stories.tsx +++ b/src/components/toolbar.stories.tsx @@ -85,3 +85,31 @@ export const ClickSendInvokesHandler: Story = { await expect(args.onOpenSend).toHaveBeenCalledOnce(); } }; + +// Edit mode configured but no message loaded yet: the "Edit message" entry +// appears and the primary action stays "Send". +export const EditingEnabled: Story = { + args: { editingEnabled: true, onOpenLoad: fn() }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.click(await canvas.findByRole('button', { name: 'Edit an existing message' })); + await expect(args.onOpenLoad).toHaveBeenCalledOnce(); + await expect(await canvas.findByRole('button', { name: 'Send' })).toBeInTheDocument(); + } +}; + +// A message is loaded for editing: the badge shows and the primary action +// flips to "Update message". +export const EditingActive: Story = { + args: { + editingEnabled: true, + editBadge: { channelLabel: '#engineering', ts: '1718000042.000100' }, + onExitEdit: fn() + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await expect(await canvas.findByRole('button', { name: 'Update message' })).toBeInTheDocument(); + await userEvent.click(await canvas.findByRole('button', { name: 'Switch back to a new message' })); + await expect(args.onExitEdit).toHaveBeenCalledOnce(); + } +}; diff --git a/src/components/toolbar.tsx b/src/components/toolbar.tsx index 0a2d0cb..a189005 100644 --- a/src/components/toolbar.tsx +++ b/src/components/toolbar.tsx @@ -10,10 +10,12 @@ import { MessageSquare, Moon, MoreHorizontal, + Pencil, Plus, Send, Sun, - Trash2 + Trash2, + X } from 'lucide-react'; import type { ComponentType, KeyboardEvent, ReactNode } from 'react'; import { useRef, useState } from 'react'; @@ -86,7 +88,12 @@ export function Toolbar({ showThemeControl = true, docsLink, errorCount, - sendButtonLabel = 'Send' + sendButtonLabel = 'Send', + editingEnabled = false, + editBadge, + onOpenLoad, + onExitEdit, + updateButtonLabel = 'Update message' }: { onClear: () => void; onOpenJson: () => void; @@ -104,6 +111,16 @@ export function Toolbar({ docsLink?: false | { href?: string; label?: string }; errorCount: number; sendButtonLabel?: string; + /** Whether edit mode is configured (shows the "Edit existing message" entry). */ + editingEnabled?: boolean; + /** When set, a message is loaded for editing: renders the edit-mode badge. */ + editBadge?: { channelLabel: string; ts: string } | null; + /** Opens the load-message dialog (edit-mode entry point). */ + onOpenLoad?: () => void; + /** Switches back to a new message, clearing the loaded edit target. */ + onExitEdit?: () => void; + /** Label for the primary button while editing. Defaults to `'Update message'`. */ + updateButtonLabel?: string; }) { const activeTheme = THEME_OPTIONS.find((t) => t.value === previewTheme) ?? THEME_OPTIONS[0]; const activeSurface = SURFACE_OPTIONS.find((s) => s.value === previewSurface) ?? SURFACE_OPTIONS[0]; @@ -194,6 +211,31 @@ export function Toolbar({ ) : null} + {editingEnabled && editBadge ? ( + + + + Editing in {editBadge.channelLabel} · {editBadge.ts} + + + + ) : editingEnabled ? ( + + ) : null}
{errorCount > 0 ? ( @@ -289,9 +331,15 @@ export function Toolbar({
-
diff --git a/src/components/update-dialog.tsx b/src/components/update-dialog.tsx new file mode 100644 index 0000000..e3d7c0a --- /dev/null +++ b/src/components/update-dialog.tsx @@ -0,0 +1,202 @@ +import { AlertTriangle, ExternalLink } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { toSlackBlocks } from '../lib/to-slack-blocks'; +import { Button } from '../lib/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../lib/ui/dialog'; +import { Label } from '../lib/ui/label'; +import { isSafeHref } from '../lib/url-safety'; +import type { EditableVia, SendAsUserStatus, SupportedBlock, UpdatePayload, UpdateResult } from '../types'; + +/** The loaded message being edited. Destination + identity are fixed by the host's verdict. */ +export interface EditTarget { + channelId: string; + channelName?: string; + ts: string; + editableVia: EditableVia; + workspaceName?: string; +} + +type UpdateStatus = { kind: 'idle' } | { kind: 'updating' } | { kind: 'error'; error: string }; + +/** + * Modal dialog confirming an update to an already-posted message. Unlike the + * send dialog, the destination channel is locked to the source and the + * post-as identity is fixed by the host's `editableVia` verdict — there is no + * channel or identity picker. When the verdict is `'user'` and the user has no + * token yet, the dialog reuses the "Sign in with Slack" flow before allowing + * the update. + * @param props - dialog props + * @param props.open - whether the dialog is open + * @param props.onOpenChange - notified when the user closes the dialog + * @param props.target - the loaded message (channel + ts + editability verdict) + * @param props.blocks - the edited draft blocks to write back + * @param props.loadSendAsUserStatus - returns user-token status + OAuth URL (user-token path only) + * @param props.onUpdate - terminal action; should return `{ ok }` or `{ ok: false, error }` + * @param props.updateButtonLabel - label for the confirm button. Defaults to `'Update message'`. + * @param props.errorCount - total validation errors against the current draft + * @param props.onShowIssues - called when the user opens the global issues panel + * @returns the rendered update dialog + */ +export function UpdateDialog({ + open, + onOpenChange, + target, + blocks, + loadSendAsUserStatus, + onUpdate, + updateButtonLabel = 'Update message', + errorCount, + onShowIssues +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + target: EditTarget; + blocks: SupportedBlock[]; + loadSendAsUserStatus: () => Promise; + onUpdate: (payload: UpdatePayload) => Promise; + updateButtonLabel?: string; + errorCount: number; + onShowIssues?: () => void; +}) { + const asUser = target.editableVia === 'user'; + const [userStatus, setUserStatus] = useState(null); + const [status, setStatus] = useState({ kind: 'idle' }); + + const loadSendAsUserStatusRef = useRef(loadSendAsUserStatus); + useEffect(() => { + loadSendAsUserStatusRef.current = loadSendAsUserStatus; + }); + + const refreshSendAsUser = useCallback(() => { + loadSendAsUserStatusRef + .current() + .then(setUserStatus) + .catch(() => setUserStatus({ canSendAsUser: false })); + }, []); + + // Only the user-token path needs a token check; the bot path can always edit + // its own message. + useEffect(() => { + if (!open) { + return; + } + setStatus({ kind: 'idle' }); + if (asUser) { + setUserStatus(null); + refreshSendAsUser(); + } + }, [open, asUser, refreshSendAsUser]); + + // Pick up a completed OAuth round-trip when the window regains focus. + useEffect(() => { + if (!open || !asUser) { + return; + } + const handler = () => refreshSendAsUser(); + window.addEventListener('focus', handler); + return () => window.removeEventListener('focus', handler); + }, [open, asUser, refreshSendAsUser]); + + // Editing as the user requires a usable user token. The bot path never gates. + const needsSignIn = asUser && userStatus !== null && !userStatus.canSendAsUser; + + const handleSubmit = async () => { + setStatus({ kind: 'updating' }); + try { + const result = await onUpdate({ + channelId: target.channelId, + ts: target.ts, + blocks: toSlackBlocks(blocks), + asUser + }); + if (result.ok) { + setStatus({ kind: 'idle' }); + onOpenChange(false); + return; + } + setStatus({ kind: 'error', error: result.error ?? 'Update failed.' }); + } catch (e) { + setStatus({ kind: 'error', error: e instanceof Error ? e.message : 'Update failed.' }); + } + }; + + const channelLabel = target.channelName ? `#${target.channelName}` : target.channelId; + + return ( + + + + Update message + + Re-send the edited blocks to the original message. The channel is locked. + + + +
+
+ +

+ {channelLabel} +

+
+ +
+ +

+ {asUser ? 'Your account' : 'App bot'} +

+ {needsSignIn && userStatus?.oauthUrl && isSafeHref(userStatus.oauthUrl) && ( +

+ + Sign in with Slack + {' '} + to update your own message. +

+ )} + {needsSignIn && !userStatus?.oauthUrl && ( +

Sign in with Slack to update your own message.

+ )} +
+ + {errorCount > 0 ? ( + + ) : null} + + {status.kind === 'error' && ( +

+ {status.error} +

+ )} +
+ + + + + +
+
+ ); +} diff --git a/src/index.ts b/src/index.ts index 7ac8e4b..b421125 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,12 +32,16 @@ export type { ContextActionsElement, CustomEmoji, DataVisualizationBlock, + EditableVia, + EditingConfig, FeedbackButtonSubobject, FeedbackButtonsElement, HeaderLevel, IconButtonElement, IconButtonIcon, InputBlock, + LoadMessageInput, + LoadResult, MarkdownBlock, PieChart, PieChartSegment, @@ -45,6 +49,7 @@ export type { PreviewHooks, PreviewSurface, PreviewTheme, + RecentMessage, SendAsUserStatus, SendPayload, SendResult, @@ -57,6 +62,8 @@ export type { TaskCardBlock, TaskCardStatus, Template, + UpdatePayload, + UpdateResult, UrlSourceElement, VideoBlock } from './types'; diff --git a/src/types.ts b/src/types.ts index 4af6a3a..a744bfa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -497,6 +497,116 @@ export interface SendResult { error?: string; } +/** + * Which token can edit a loaded message, as computed by the host. + * `chat.update` only edits a message authored by the calling token: + * - `'bot'` — the message was posted by this app; edit via the bot token. + * - `'user'` — the message was posted by the current user; edit via their + * user token (gated behind {@link BlockKitchenProps.loadSendAsUserStatus}). + */ +export type EditableVia = 'bot' | 'user'; + +/** + * Input passed to {@link EditingConfig.onLoadMessage}. The user pastes a + * Slack message permalink (Slack's "Copy link"); the host parses + * `channel + ts` out of it — the package never touches raw timestamps. + */ +export interface LoadMessageInput { + link: string; +} + +/** + * Result of loading an existing message for editing, computed by the host. + * + * On `ok`, the package enters edit mode: it hydrates the editor with + * `blocks`, locks the destination to `channelId`, and constrains the + * post-as identity to `editableVia`. + * + * On `!ok`, the package renders the host's `reason` inline and offers + * "Open as a new message instead". If the message round-trips through the + * block editor (e.g. someone else's block message), the host may include + * `blocks` so that fallback can hydrate the draft; for non-block or + * attachment-only messages, omit it. + */ +export type LoadResult = + | { + ok: true; + channelId: string; + /** Channel name for the edit-mode badge (display only). Falls back to `channelId`. */ + channelName?: string; + ts: string; + blocks: SupportedBlock[]; + editableVia: EditableVia; + workspaceName?: string; + } + | { ok: false; reason: string; blocks?: SupportedBlock[] }; + +/** + * Payload passed to {@link EditingConfig.onUpdate}. Sibling to + * {@link SendPayload} but carries the source `channel + ts`. `chat.update` + * requires re-sending the full blocks payload (no partial update). + */ +export interface UpdatePayload { + channelId: string; + ts: string; + blocks: SupportedBlock[]; + asUser: boolean; +} + +/** + * Result returned from {@link EditingConfig.onUpdate}. Mirrors + * {@link SendResult}; `error` is rendered as-is. + */ +export interface UpdateResult { + ok: boolean; + error?: string; +} + +/** + * One entry in the "recent messages from this app" picker, returned by + * {@link EditingConfig.loadRecentMessages}. These are editable-by-construction + * — the app authored them — so they load straight into edit mode without a + * separate verdict round-trip. `editableVia` defaults to `'bot'` when omitted. + */ +export interface RecentMessage { + channelId: string; + /** Channel name for display + the edit-mode badge. Falls back to `channelId`. */ + channelName?: string; + ts: string; + blocks: SupportedBlock[]; + editableVia?: EditableVia; + /** Short preview shown in the picker row (e.g. the first line of the message). */ + label?: string; + workspaceName?: string; +} + +/** + * Opt-in edit-mode configuration. Presence of {@link BlockKitchenProps.editing} + * enables loading an existing message and dispatching an update; omit it to + * keep the send-only behavior. The package stays integration-agnostic — it + * makes no network calls and has no Slack-token knowledge; the host brokers + * I/O and computes the editability verdict. + */ +export interface EditingConfig { + /** + * Host parses the pasted permalink, fetches the message, and returns a + * host-computed editability verdict plus blocks. See {@link LoadResult}. + */ + onLoadMessage: (input: LoadMessageInput) => Promise; + /** + * Dispatches the update for the loaded message. Sibling to + * {@link BlockKitchenProps.onSend}; carries `channel + ts`. + */ + onUpdate: (payload: UpdatePayload) => Promise; + /** + * Optional. When provided, the load dialog adds a "recent messages from this + * app" picker alongside the paste-a-link input. Returns messages the app + * authored (editable-by-construction); picking one loads it straight into + * edit mode. Omit to offer the paste-link entry only. + */ + loadRecentMessages?: () => Promise; +} + /** * A workspace custom emoji as configured in Slack. Mirrors the shape Slack's * `emoji.list` API returns once normalized: @@ -625,6 +735,20 @@ export interface BlockKitchenProps { * `{ ok: true }` on success or `{ ok: false, error }` on failure. */ onSend: (payload: SendPayload) => Promise; + /** + * Opt-in edit mode. When provided, the toolbar exposes an "Edit existing + * message" entry: the user pastes a Slack message link, the host loads it + * and returns an editability verdict ({@link EditingConfig.onLoadMessage}), + * and a successful load flips the primary action from Send to "Update + * message" ({@link EditingConfig.onUpdate}). Omit to keep today's send-only + * behavior. The user-token path reuses {@link BlockKitchenProps.loadSendAsUserStatus}. + */ + editing?: EditingConfig; + /** + * Label for the primary button when a message is loaded for editing. + * Defaults to `'Update message'` (and shows `'Updating…'` while in flight). + */ + updateButtonLabel?: string; /** * The palette shown on the left-hand side. When omitted, the built-in * `defaultPalette` is used. Pass a custom array (typically built by From aa2565022e11d0f94269274f8c321dcf67172353 Mon Sep 17 00:00:00 2001 From: Stephen Date: Mon, 29 Jun 2026 14:20:20 -0700 Subject: [PATCH 02/24] chore(demo): clearer edit-mode panel labels, collapsed by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Collapse the edit-mode demo panel by default so it doesn't crowd the builder on load. - Move the "copy a link…" instruction out of the title into a subtitle under "Edit-mode demo (mocked host)". - Rewrite the knob checkbox labels in plain English (prop name in parens). Co-Authored-By: Claude Opus 4.8 --- demo/src/App.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/demo/src/App.tsx b/demo/src/App.tsx index a40b98e..2473f1d 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -687,7 +687,6 @@ function EditModePanel({ return (
- - Edit-mode demo (mocked host) — copy a link, then use “Edit message” in the toolbar + + Edit-mode demo (mocked host) +
+ Copy a link, then use “Edit message” in the toolbar — or pick a recent message. +
- {checkbox('editing enabled', editingEnabled, onEditingEnabledChange)} - {checkbox('canSendAsUser', canSendAsUser, onCanSendAsUserChange)} - {checkbox('include oauthUrl', includeOauthUrl, onIncludeOauthUrlChange, canSendAsUser)} - - Turn “editing enabled” off to confirm the builder falls back to send-only. + {checkbox('Enable edit mode (editing prop)', editingEnabled, onEditingEnabledChange)} + {checkbox('User can edit their own messages (canSendAsUser)', canSendAsUser, onCanSendAsUserChange)} + {checkbox('Offer Slack sign-in link (oauthUrl)', includeOauthUrl, onIncludeOauthUrlChange, canSendAsUser)} + + Turn edit mode off to confirm the builder falls back to send-only.
From 2120d24d2b2b4e73dab9331916305ce53a1195b3 Mon Sep 17 00:00:00 2001 From: Stephen Date: Mon, 29 Jun 2026 14:24:52 -0700 Subject: [PATCH 03/24] feat(editing): clearer update copy, configurable load button + labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite the confusing update-dialog description ("Re-send the edited blocks… The channel is locked") to plain language. - Split the edit-mode button labels to mirror send: the toolbar primary defaults to "Review & update" (opens the dialog), the dialog confirm defaults to "Update message" (commits). Add `updateButtonLabel` / `confirmUpdateLabel`. - Make the load-message entry button configurable via `loadButtonLabel` (default "Load message") and give it the primary color. Co-Authored-By: Claude Opus 4.8 --- README.md | 4 +++- demo/src/App.tsx | 2 +- src/components/block-kitchen.tsx | 5 ++++- src/components/toolbar.stories.tsx | 8 ++++---- src/components/toolbar.tsx | 11 +++++++---- src/components/update-dialog.tsx | 10 +++++----- src/types.ts | 17 +++++++++++++++-- 7 files changed, 39 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 598c227..27bb750 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,9 @@ export function MyBuilderPage() { | `loadSendAsUserStatus` | `() => Promise<{ canSendAsUser: boolean; oauthUrl?: string }>` | yes | 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. | | `onSend` | `(payload) => Promise<{ ok: boolean; error?: string }>` | yes | Called when the user submits the send dialog. Payload is `{ channelId, blocks, sendAsUser }`. | | `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. Omit `editing` to keep send-only behavior. | -| `updateButtonLabel` | `string` | no | Label for the primary button while a message is loaded for editing. Defaults to `'Update message'`. | +| `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. | +| `updateButtonLabel` | `string` | no | Label for the toolbar's primary button while a message is loaded for editing (it opens the update dialog). Defaults to `'Review & update'`. | +| `confirmUpdateLabel` | `string` | no | Label for the update dialog's final confirm button. Defaults to `'Update message'` (shows `'Updating…'` while in flight). | | `previewHooks` | `PreviewHooks` | no | Hooks forwarded to `slack-blocks-to-jsx`'s `` for resolving user / channel / emoji directives. | | `customEmojis` | `CustomEmoji[]` | no | Workspace custom emoji (`{ name, url, alias }`) the preview resolves. Entries with a `url` render `:name:` as the workspace image; alias entries (`url: null`) fall back to their target emoji. Render-only — never serialized into the emitted Block Kit JSON. A caller-supplied `previewHooks.emoji` takes precedence. | | `palette` | `PaletteSection[]` | no | The left-hand palette of draggable variants. Defaults to `defaultPalette`. Spread it to filter, reorder, or add your own pre-configured variants — see [Customizing the palette](#customizing-the-palette). | diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 2473f1d..d02519e 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -700,7 +700,7 @@ function EditModePanel({ Edit-mode demo (mocked host)
- Copy a link, then use “Edit message” in the toolbar — or pick a recent message. + Copy a link, then use “Load message” in the toolbar — or pick a recent message.
diff --git a/src/components/block-kitchen.tsx b/src/components/block-kitchen.tsx index b36cedd..10b40a8 100644 --- a/src/components/block-kitchen.tsx +++ b/src/components/block-kitchen.tsx @@ -54,7 +54,9 @@ export function BlockKitchen(props: BlockKitchenProps) { loadSendAsUserStatus, onSend, editing, + loadButtonLabel, updateButtonLabel, + confirmUpdateLabel, palette, disabledBlockTypes, showPaletteSearch, @@ -306,6 +308,7 @@ export function BlockKitchen(props: BlockKitchenProps) { } onOpenLoad={() => setLoadOpen(true)} onExitEdit={() => setEditTarget(null)} + loadButtonLabel={loadButtonLabel} updateButtonLabel={updateButtonLabel} />
@@ -385,7 +388,7 @@ export function BlockKitchen(props: BlockKitchenProps) { blocks={blockPayloads} loadSendAsUserStatus={loadSendAsUserStatus} onUpdate={editing.onUpdate} - updateButtonLabel={updateButtonLabel} + confirmUpdateLabel={confirmUpdateLabel} errorCount={validation.total} onShowIssues={() => { setSendOpen(false); diff --git a/src/components/toolbar.stories.tsx b/src/components/toolbar.stories.tsx index e5413c2..e3e1d4c 100644 --- a/src/components/toolbar.stories.tsx +++ b/src/components/toolbar.stories.tsx @@ -86,20 +86,20 @@ export const ClickSendInvokesHandler: Story = { } }; -// Edit mode configured but no message loaded yet: the "Edit message" entry +// Edit mode configured but no message loaded yet: the "Load message" entry // appears and the primary action stays "Send". export const EditingEnabled: Story = { args: { editingEnabled: true, onOpenLoad: fn() }, play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); - await userEvent.click(await canvas.findByRole('button', { name: 'Edit an existing message' })); + await userEvent.click(await canvas.findByRole('button', { name: 'Load message' })); await expect(args.onOpenLoad).toHaveBeenCalledOnce(); await expect(await canvas.findByRole('button', { name: 'Send' })).toBeInTheDocument(); } }; // A message is loaded for editing: the badge shows and the primary action -// flips to "Update message". +// flips to "Review & update". export const EditingActive: Story = { args: { editingEnabled: true, @@ -108,7 +108,7 @@ export const EditingActive: Story = { }, play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); - await expect(await canvas.findByRole('button', { name: 'Update message' })).toBeInTheDocument(); + await expect(await canvas.findByRole('button', { name: 'Review & update' })).toBeInTheDocument(); await userEvent.click(await canvas.findByRole('button', { name: 'Switch back to a new message' })); await expect(args.onExitEdit).toHaveBeenCalledOnce(); } diff --git a/src/components/toolbar.tsx b/src/components/toolbar.tsx index a189005..9417837 100644 --- a/src/components/toolbar.tsx +++ b/src/components/toolbar.tsx @@ -93,7 +93,8 @@ export function Toolbar({ editBadge, onOpenLoad, onExitEdit, - updateButtonLabel = 'Update message' + loadButtonLabel = 'Load message', + updateButtonLabel = 'Review & update' }: { onClear: () => void; onOpenJson: () => void; @@ -119,7 +120,9 @@ export function Toolbar({ onOpenLoad?: () => void; /** Switches back to a new message, clearing the loaded edit target. */ onExitEdit?: () => void; - /** Label for the primary button while editing. Defaults to `'Update message'`. */ + /** Label for the load-message entry button. Defaults to `'Load message'`. */ + loadButtonLabel?: string; + /** Label for the primary button while editing. Defaults to `'Review & update'`. */ updateButtonLabel?: string; }) { const activeTheme = THEME_OPTIONS.find((t) => t.value === previewTheme) ?? THEME_OPTIONS[0]; @@ -231,9 +234,9 @@ export function Toolbar({ ) : editingEnabled ? ( - ) : null}
diff --git a/src/components/update-dialog.tsx b/src/components/update-dialog.tsx index e3d7c0a..8dc426e 100644 --- a/src/components/update-dialog.tsx +++ b/src/components/update-dialog.tsx @@ -32,7 +32,7 @@ type UpdateStatus = { kind: 'idle' } | { kind: 'updating' } | { kind: 'error'; e * @param props.blocks - the edited draft blocks to write back * @param props.loadSendAsUserStatus - returns user-token status + OAuth URL (user-token path only) * @param props.onUpdate - terminal action; should return `{ ok }` or `{ ok: false, error }` - * @param props.updateButtonLabel - label for the confirm button. Defaults to `'Update message'`. + * @param props.confirmUpdateLabel - label for the confirm button. Defaults to `'Update message'`. * @param props.errorCount - total validation errors against the current draft * @param props.onShowIssues - called when the user opens the global issues panel * @returns the rendered update dialog @@ -44,7 +44,7 @@ export function UpdateDialog({ blocks, loadSendAsUserStatus, onUpdate, - updateButtonLabel = 'Update message', + confirmUpdateLabel = 'Update message', errorCount, onShowIssues }: { @@ -54,7 +54,7 @@ export function UpdateDialog({ blocks: SupportedBlock[]; loadSendAsUserStatus: () => Promise; onUpdate: (payload: UpdatePayload) => Promise; - updateButtonLabel?: string; + confirmUpdateLabel?: string; errorCount: number; onShowIssues?: () => void; }) { @@ -128,7 +128,7 @@ export function UpdateDialog({ Update message - Re-send the edited blocks to the original message. The channel is locked. + This replaces the message that's already posted — it stays in the same channel and can't be moved. @@ -193,7 +193,7 @@ export function UpdateDialog({ onClick={handleSubmit} disabled={status.kind === 'updating' || blocks.length === 0 || errorCount > 0 || needsSignIn} > - {status.kind === 'updating' ? 'Updating…' : updateButtonLabel} + {status.kind === 'updating' ? 'Updating…' : confirmUpdateLabel} diff --git a/src/types.ts b/src/types.ts index a744bfa..2d2da65 100644 --- a/src/types.ts +++ b/src/types.ts @@ -745,10 +745,23 @@ export interface BlockKitchenProps { */ editing?: EditingConfig; /** - * Label for the primary button when a message is loaded for editing. - * Defaults to `'Update message'` (and shows `'Updating…'` while in flight). + * 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 {@link BlockKitchenProps.editing} is set + * and no message is currently loaded. + */ + loadButtonLabel?: string; + /** + * Label for the toolbar's primary button when a message is loaded for + * editing — it opens the update dialog. Defaults to `'Review & update'`. */ updateButtonLabel?: string; + /** + * Label for the update dialog's final confirm button, which dispatches the + * update via {@link EditingConfig.onUpdate}. Defaults to `'Update message'` + * (and shows `'Updating…'` while in flight). + */ + confirmUpdateLabel?: string; /** * The palette shown on the left-hand side. When omitted, the built-in * `defaultPalette` is used. Pass a custom array (typically built by From bd65bf5e3dad19d726648424718c81ebfa3adfab Mon Sep 17 00:00:00 2001 From: Stephen Date: Mon, 29 Jun 2026 14:28:40 -0700 Subject: [PATCH 04/24] fix(editing): move load-message button + edit badge to the toolbar's left edge The edit-mode entry button (and the editing badge that replaces it) now render as the leftmost item in the toolbar, ahead of the surface/theme/docs controls, rather than after them. Co-Authored-By: Claude Opus 4.8 --- src/components/toolbar.tsx | 50 +++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/src/components/toolbar.tsx b/src/components/toolbar.tsx index 9417837..f6a5056 100644 --- a/src/components/toolbar.tsx +++ b/src/components/toolbar.tsx @@ -144,6 +144,31 @@ export function Toolbar({ return (
+ {editingEnabled && editBadge ? ( + + + + Editing in {editBadge.channelLabel} · {editBadge.ts} + + + + ) : editingEnabled ? ( + + ) : null} {onOpenPalette ? ( - - ) : editingEnabled ? ( - - ) : null}
{errorCount > 0 ? ( From b7477f9fb23bc84a05ae8b00d160e8723b1e53d2 Mon Sep 17 00:00:00 2001 From: Stephen Date: Mon, 29 Jun 2026 14:34:34 -0700 Subject: [PATCH 05/24] refactor(toolbar): show secondary controls inline as icons on small screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface picker, Docs, Clear, and View JSON now render icon-only on small screens instead of collapsing Clear/View JSON/Docs into a "⋯" overflow menu (and the surface picker drops its caret). Labels return at `sm+`. Removes the now-unused overflow menu and its helper components. Co-Authored-By: Claude Opus 4.8 --- src/components/toolbar.tsx | 147 +++---------------------------------- 1 file changed, 9 insertions(+), 138 deletions(-) diff --git a/src/components/toolbar.tsx b/src/components/toolbar.tsx index f6a5056..02c3cdf 100644 --- a/src/components/toolbar.tsx +++ b/src/components/toolbar.tsx @@ -9,7 +9,6 @@ import { Home, MessageSquare, Moon, - MoreHorizontal, Pencil, Plus, Send, @@ -17,7 +16,7 @@ import { Trash2, X } from 'lucide-react'; -import type { ComponentType, KeyboardEvent, ReactNode } from 'react'; +import type { ComponentType, KeyboardEvent } from 'react'; import { useRef, useState } from 'react'; import { cn } from '../lib/cn'; import { Button } from '../lib/ui/button'; @@ -136,10 +135,6 @@ export function Toolbar({ // `role="menuitemradio"` semantics imply activation dismisses the menu. const [surfaceMenuOpen, setSurfaceMenuOpen] = useState(false); const [themeMenuOpen, setThemeMenuOpen] = useState(false); - // Mobile-only overflow menu containing secondary actions (Clear, View - // JSON, Docs). At `sm+` those render inline; below `sm` the trigger - // button collapses them into a single `⋯` popover. - const [moreMenuOpen, setMoreMenuOpen] = useState(false); return (
@@ -188,7 +183,7 @@ export function Toolbar({ @@ -231,12 +226,12 @@ export function Toolbar({ href={docsHref} target="_blank" rel="noreferrer noopener" - className="hidden h-8 items-center gap-1.5 rounded-md px-2 py-1 text-sm text-muted-foreground no-underline transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring sm:inline-flex" + className="inline-flex h-8 items-center gap-1.5 rounded-md px-2 py-1 text-sm text-muted-foreground no-underline transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" aria-label={`${docsLabel} (opens in a new tab)`} > - {docsLabel} - + {docsLabel} + ) : null}
@@ -269,71 +264,16 @@ export function Toolbar({ size="sm" onClick={onClear} disabled={!canClear} - className="hidden hover:bg-destructive/10 hover:text-destructive sm:inline-flex" + className="hover:bg-destructive/10 hover:text-destructive" aria-label="Clear all blocks" > - Clear + Clear - - - - - - -
- } - onSelect={() => { - setMoreMenuOpen(false); - onClear(); - }} - disabled={!canClear} - tone="destructive" - > - Clear - - } - onSelect={() => { - setMoreMenuOpen(false); - onOpenJson(); - }} - > - View JSON - - {docsHref ? ( - } - trailingIcon={} - onSelect={() => setMoreMenuOpen(false)} - > - {docsLabel} - - ) : null} -
-
-
); } - -const ACTION_ITEM_BASE = - 'flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-left text-sm text-muted-foreground no-underline transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50'; - -/** - * One row in the mobile overflow menu. Action items don't carry - * selection state, so they're plain `role="menuitem"` rather than - * `menuitemradio` — clicking dispatches and the parent closes the menu. - */ -function ActionMenuItem({ - icon, - children, - onSelect, - disabled, - tone -}: { - icon: ReactNode; - children: ReactNode; - onSelect: () => void; - disabled?: boolean; - tone?: 'destructive'; -}) { - return ( - - ); -} - -/** - * External-link variant of {@link ActionMenuItem}. Used for Docs in the - * mobile overflow menu so the link still opens in a new tab while - * matching the surrounding items visually. - */ -function ActionMenuLink({ - href, - icon, - trailingIcon, - children, - onSelect -}: { - href: string; - icon: ReactNode; - trailingIcon?: ReactNode; - children: ReactNode; - onSelect?: () => void; -}) { - return ( - - {icon} - {children} - {trailingIcon} - - ); -} From 0b656761a6444b7d9692244c67d1cf98dd7af693 Mon Sep 17 00:00:00 2001 From: Stephen Date: Mon, 29 Jun 2026 14:40:22 -0700 Subject: [PATCH 06/24] feat(editing): link help tooltip; demo editing-mode dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add an info tooltip next to the load dialog's description explaining how to get a message link in Slack (hover → More actions / right-click → Copy link). - Demo: replace the "enable edit mode" checkbox with an "Editing" dropdown (Write-only / Read & Write) in the header, left of the Theme picker. "Read & Write" exposes the existing settings + message-store panel; "Write-only" hides it and falls back to send-only. Co-Authored-By: Claude Opus 4.8 --- demo/src/App.tsx | 70 +++++++++++++++++--------- src/components/load-message-dialog.tsx | 30 ++++++++--- 2 files changed, 71 insertions(+), 29 deletions(-) diff --git a/demo/src/App.tsx b/demo/src/App.tsx index d02519e..6f2a5fd 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -426,6 +426,37 @@ export function App() {
+
- + {editingEnabled ? ( + + ) : null}
= { }; /** - * Mocked-host control panel for the package's edit mode. Toggles prove - * configurability (`editing` on/off, the user-token gate) and the message - * store makes the load → update round-trip observable: copy a message's link, - * paste it into the builder's "Edit message" dialog, update, and watch the - * stored blocks change here. + * Mocked-host control panel for the package's edit mode, shown when the + * header's Editing dropdown is set to "Read & Write". The token knobs prove + * the user-token gate, and the message store makes the load → update + * round-trip observable: copy a message's link, paste it into the builder's + * "Load message" dialog, update, and watch the stored blocks change here. */ function EditModePanel({ - editingEnabled, - onEditingEnabledChange, canSendAsUser, onCanSendAsUserChange, includeOauthUrl, onIncludeOauthUrlChange, store }: { - editingEnabled: boolean; - onEditingEnabledChange: (v: boolean) => void; canSendAsUser: boolean; onCanSendAsUserChange: (v: boolean) => void; includeOauthUrl: boolean; @@ -700,17 +727,14 @@ function EditModePanel({ Edit-mode demo (mocked host)
- Copy a link, then use “Load message” in the toolbar — or pick a recent message. + Copy a link, then use “Load message” in the toolbar — or pick a recent message. Switch Editing to + “Write-only” to fall back to send-only.
- {checkbox('Enable edit mode (editing prop)', editingEnabled, onEditingEnabledChange)} {checkbox('User can edit their own messages (canSendAsUser)', canSendAsUser, onCanSendAsUserChange)} {checkbox('Offer Slack sign-in link (oauthUrl)', includeOauthUrl, onIncludeOauthUrlChange, canSendAsUser)} - - Turn edit mode off to confirm the builder falls back to send-only. -
Message store
diff --git a/src/components/load-message-dialog.tsx b/src/components/load-message-dialog.tsx index ae57f60..8a60142 100644 --- a/src/components/load-message-dialog.tsx +++ b/src/components/load-message-dialog.tsx @@ -1,9 +1,10 @@ -import { AlertTriangle } from 'lucide-react'; +import { AlertTriangle, Info } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { Button } from '../lib/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../lib/ui/dialog'; import { Input } from '../lib/ui/input'; import { Label } from '../lib/ui/label'; +import { Tooltip, TooltipContent, TooltipTrigger } from '../lib/ui/tooltip'; import type { LoadResult, RecentMessage, SupportedBlock } from '../types'; /** Map a {@link RecentMessage} onto the `ok` verdict so it reuses the load path. */ @@ -133,11 +134,28 @@ export function LoadMessageDialog({ Edit an existing message - - {hasRecent - ? 'Paste a Slack message link, or pick a recent message your app posted.' - : 'Paste a Slack message link (Slack\'s "Copy link") to load its blocks for editing.'} - +
+ + {hasRecent + ? 'Paste a Slack message link, or pick a recent message your app posted.' + : 'Paste a Slack message link (Slack\'s "Copy link") to load its blocks for editing.'} + + + + + + + In Slack, hover over the message and click the ⋮ More actions button (or right-click + the message), then choose Copy link. + + +
From d78f97921ee5156e16e6392881e178f3c44c92b4 Mon Sep 17 00:00:00 2001 From: Stephen Date: Mon, 29 Jun 2026 14:53:33 -0700 Subject: [PATCH 07/24] refactor(demo): edit-mode controls in a modal with mode tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the on-page edit-mode panel with a header button ("Editing: …", left of Theme) that opens a modal. The modal has Write-only / Read & Write tabs; the Read & Write tab holds the user-token settings and the message store, so none of it lives on the page. Co-Authored-By: Claude Opus 4.8 --- demo/src/App.tsx | 333 +++++++++++++++++++++++++++++++---------------- 1 file changed, 219 insertions(+), 114 deletions(-) diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 6f2a5fd..d22887c 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -22,6 +22,7 @@ import { useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { demoTemplates } from './templates'; const PRESET_OPTIONS: { value: BrandPreset; label: string }[] = [ @@ -426,37 +427,15 @@ export function App() {
- +
- {editingEnabled ? ( - - ) : null}
= { }; /** - * Mocked-host control panel for the package's edit mode, shown when the - * header's Editing dropdown is set to "Read & Write". The token knobs prove - * the user-token gate, and the message store makes the load → update - * round-trip observable: copy a message's link, paste it into the builder's - * "Load message" dialog, update, and watch the stored blocks change here. + * Header button that opens a modal owning all the mocked-host edit-mode + * controls — the mode choice (Write-only vs Read & Write), the user-token + * knobs, and the message store — so none of it lives on the page. The store + * makes the load → update round-trip observable: copy a message's link, load + * it in the builder, update, and watch the blocks change. */ -function EditModePanel({ +function EditingMenu({ + editingEnabled, + onEditingEnabledChange, canSendAsUser, onCanSendAsUserChange, includeOauthUrl, onIncludeOauthUrlChange, store }: { + editingEnabled: boolean; + onEditingEnabledChange: (v: boolean) => void; canSendAsUser: boolean; onCanSendAsUserChange: (v: boolean) => void; includeOauthUrl: boolean; onIncludeOauthUrlChange: (v: boolean) => void; store: StoredMessage[]; }) { + const [open, setOpen] = useState(false); const [copiedTs, setCopiedTs] = useState(null); + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [open]); + const copyLink = (msg: StoredMessage) => { - const link = permalinkFor(msg); - navigator.clipboard?.writeText(link).catch(() => {}); + navigator.clipboard?.writeText(permalinkFor(msg)).catch(() => {}); setCopiedTs(msg.ts); window.setTimeout(() => setCopiedTs((cur) => (cur === msg.ts ? null : cur)), 1200); }; - const checkbox = (label: string, checked: boolean, onChange: (v: boolean) => void, disabled?: boolean) => ( -
, + document.body + )} + ); } From decf998ce5ee427868e9d893288a83a28bf52e60 Mon Sep 17 00:00:00 2001 From: Stephen Date: Mon, 29 Jun 2026 15:01:35 -0700 Subject: [PATCH 08/24] docs(copy): remove em dashes from edit-mode copy Replace em dashes with periods/commas in the update dialog description, the demo edit-mode modal text, and the README editing section. Co-Authored-By: Claude Opus 4.8 --- README.md | 4 ++-- demo/src/App.tsx | 4 ++-- src/components/update-dialog.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 27bb750..fc17b19 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ export function MyBuilderPage() { By default the builder is send-only. Pass `editing` to let users load an already-posted message, edit its blocks, and dispatch a `chat.update`. The package stays integration-agnostic: it makes no Slack calls and computes -nothing about who can edit — the host does both. +nothing about who can edit; the host does both. ```tsx
- Copy a link below, then use “Load message” in the toolbar — or pick a recent message. + Copy a link below, then use “Load message” in the toolbar, or pick a recent message.
User-token settings
@@ -875,7 +875,7 @@ function EditingMenu({ ) : (
- The editing prop is omitted, so the builder is a plain composer — “Load message” is + The editing prop is omitted, so the builder is a plain composer. “Load message” is hidden and the primary action stays “Send”.
)} diff --git a/src/components/update-dialog.tsx b/src/components/update-dialog.tsx index 8dc426e..9c0e0aa 100644 --- a/src/components/update-dialog.tsx +++ b/src/components/update-dialog.tsx @@ -128,7 +128,7 @@ export function UpdateDialog({ Update message - This replaces the message that's already posted — it stays in the same channel and can't be moved. + This replaces the message that's already posted. It stays in the same channel and can't be moved. From d739333a50defdb2f92716a652965ef94a0c3201 Mon Sep 17 00:00:00 2001 From: Stephen Date: Mon, 29 Jun 2026 15:12:57 -0700 Subject: [PATCH 09/24] feat(editing): edit-state banner row; demo icon-only theme toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move the edit-mode indicator out of the toolbar control row into its own full-width banner beneath it ("Editing an existing message in #channel …" with a "Switch to a new message" action), so the editing state is front and center. Uses high-contrast foreground text on a muted surface (passes the a11y color-contrast check). - Demo: make the light/dark toggle an icon-only button (☀️/🌙). Co-Authored-By: Claude Opus 4.8 --- demo/src/App.tsx | 16 +- src/components/toolbar.stories.tsx | 2 +- src/components/toolbar.tsx | 266 ++++++++++++++--------------- 3 files changed, 142 insertions(+), 142 deletions(-) diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 21c0868..c34e9eb 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -474,19 +474,23 @@ export function App() { type="button" onClick={() => setTheme((t) => (t === 'light' ? 'dark' : 'light'))} aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode (current: ${theme})`} + title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`} style={{ - fontSize: 12, - padding: '6px 10px', + fontSize: 14, + lineHeight: 1, + width: 30, + height: 30, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', borderRadius: 6, border: '1px solid hsl(var(--border))', background: 'hsl(var(--background))', color: 'hsl(var(--foreground))', - cursor: 'pointer', - whiteSpace: 'nowrap' + cursor: 'pointer' }} > - Mode: - {theme} +
diff --git a/src/components/toolbar.stories.tsx b/src/components/toolbar.stories.tsx index e3e1d4c..0f20dba 100644 --- a/src/components/toolbar.stories.tsx +++ b/src/components/toolbar.stories.tsx @@ -109,7 +109,7 @@ export const EditingActive: Story = { play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); await expect(await canvas.findByRole('button', { name: 'Review & update' })).toBeInTheDocument(); - await userEvent.click(await canvas.findByRole('button', { name: 'Switch back to a new message' })); + await userEvent.click(await canvas.findByRole('button', { name: 'Switch to a new message' })); await expect(args.onExitEdit).toHaveBeenCalledOnce(); } }; diff --git a/src/components/toolbar.tsx b/src/components/toolbar.tsx index 02c3cdf..2ac3be7 100644 --- a/src/components/toolbar.tsx +++ b/src/components/toolbar.tsx @@ -137,155 +137,151 @@ export function Toolbar({ const [themeMenuOpen, setThemeMenuOpen] = useState(false); return ( -
-
- {editingEnabled && editBadge ? ( - - - - Editing in {editBadge.channelLabel} · {editBadge.ts} - - + ) : null} + {onOpenPalette ? ( + - - ) : editingEnabled ? ( - - ) : null} - {onOpenPalette ? ( + + Blocks + + ) : null} + {showSurfaceControl ? ( + + + + + + + ariaLabel="Preview surface" + options={surfaceOptions} + value={previewSurface} + onChange={(next) => { + onPreviewSurfaceChange(next); + setSurfaceMenuOpen(false); + }} + /> + + + ) : null} + {showThemeControl ? ( + + + + + + + ariaLabel="Preview theme" + options={THEME_OPTIONS} + value={previewTheme} + onChange={(next) => { + onPreviewThemeChange(next); + setThemeMenuOpen(false); + }} + /> + + + ) : null} + {docsHref ? ( + + + {docsLabel} + + + ) : null} +
+
+ {errorCount > 0 ? ( + + ) : null} + - ) : null} - {showSurfaceControl ? ( - - - - - - - ariaLabel="Preview surface" - options={surfaceOptions} - value={previewSurface} - onChange={(next) => { - onPreviewSurfaceChange(next); - setSurfaceMenuOpen(false); - }} - /> - - - ) : null} - {showThemeControl ? ( - - - - - - - ariaLabel="Preview theme" - options={THEME_OPTIONS} - value={previewTheme} - onChange={(next) => { - onPreviewThemeChange(next); - setThemeMenuOpen(false); - }} - /> - - - ) : null} - {docsHref ? ( - - - {docsLabel} - - - ) : null} -
-
- {errorCount > 0 ? ( - ) : null} - - - +
-
+ {editingEnabled && editBadge ? ( +
+ + + Editing an existing message in {editBadge.channelLabel} + {editBadge.ts} + + +
+ ) : null} + ); } From c2056cd355106a65023803f7dbca4a505e24cd99 Mon Sep 17 00:00:00 2001 From: Stephen Date: Mon, 29 Jun 2026 15:18:55 -0700 Subject: [PATCH 10/24] style(demo): segmented-button switcher for edit-mode tabs Restyle the Write-only / Read & Write mode tabs as a segmented control (muted track, raised active segment) so they read as a switcher choice rather than underline tabs. Co-Authored-By: Claude Opus 4.8 --- demo/src/App.tsx | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/demo/src/App.tsx b/demo/src/App.tsx index c34e9eb..7ff6955 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -683,6 +683,8 @@ function EditingMenu({ window.setTimeout(() => setCopiedTs((cur) => (cur === msg.ts ? null : cur)), 1200); }; + // Segmented switcher: a muted track with the active segment raised on a + // solid background, so it reads as a button-style mode toggle. const tab = (label: string, active: boolean, onSelect: () => void) => ( - ) : null} {onOpenPalette ? ( @@ -186,7 +185,7 @@ export function Toolbar({ @@ -212,8 +211,8 @@ export function Toolbar({ aria-label={`${docsLabel} (opens in a new tab)`} > - {docsLabel} - + {docsLabel} + ) : null}
@@ -236,7 +235,7 @@ export function Toolbar({ > - {errorCount} {errorCount === 1 ? 'issue' : 'issues'} + {errorCount} {errorCount === 1 ? 'issue' : 'issues'} ) : null} @@ -250,22 +249,73 @@ export function Toolbar({ aria-label="Clear all blocks" > - Clear + Clear - + {editingEnabled ? ( +
+ + + + + + +
+ + +
+
+
+
+ ) : ( + + )}
{editingEnabled && editBadge ? ( diff --git a/src/types.ts b/src/types.ts index 2d2da65..11e6984 100644 --- a/src/types.ts +++ b/src/types.ts @@ -744,16 +744,11 @@ export interface BlockKitchenProps { * behavior. The user-token path reuses {@link BlockKitchenProps.loadSendAsUserStatus}. */ editing?: EditingConfig; - /** - * 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 {@link BlockKitchenProps.editing} is set - * and no message is currently loaded. - */ - loadButtonLabel?: string; /** * Label for the toolbar's primary button when a message is loaded for - * editing — it opens the update dialog. Defaults to `'Review & update'`. + * editing; it opens the update dialog. Defaults to `'Review & update'`. + * When edit mode is enabled but no message is loaded, the button instead + * reads `'Review & send'`, with a split menu to edit a message or send as new. */ updateButtonLabel?: string; /** From de2d601fd3b9de674ce5b8160194122404e2d00c Mon Sep 17 00:00:00 2001 From: Stephen Date: Mon, 29 Jun 2026 17:21:15 -0700 Subject: [PATCH 13/24] feat(editing): match update feedback to send; reword edit banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Demo: onUpdate now shows a confirmation alert like onSend, so the playground responds the same way for both actions. - Reword the edit-mode banner from "Editing an existing message in …" to "References an existing message in …". Co-Authored-By: Claude Opus 4.8 --- demo/src/App.tsx | 22 ++++++++++++++++------ src/components/toolbar.tsx | 2 +- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/demo/src/App.tsx b/demo/src/App.tsx index 5b50865..d0a217e 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -229,18 +229,21 @@ export function App() { const onSend = useCallback(async (payload: SendPayload): Promise => { await new Promise((r) => setTimeout(r, 300)); const channel = MOCK_CHANNELS.find((c) => c.id === payload.channelId); + const name = channel?.name ?? payload.channelId; setStore((prev) => { const ts = `${Math.floor(Date.now() / 1000)}.${String(prev.length).padStart(6, '0')}`; const posted: StoredMessage = { ts, channelId: payload.channelId, - channelName: channel?.name ?? payload.channelId, + channelName: name, author: payload.sendAsUser ? 'you' : 'bot', kind: 'normal', blocks: payload.blocks }; return [...prev, posted]; }); + const count = `${payload.blocks.length} block${payload.blocks.length === 1 ? '' : 's'}`; + window.alert(`Sent a new message to #${name} (${count}, as ${payload.sendAsUser ? 'you' : 'the bot'}).`); return { ok: true }; }, []); @@ -279,11 +282,18 @@ export function App() { : { ok: true, ...base, editableVia: 'user' }; }, []); - const onUpdate = useCallback(async ({ ts, blocks: updated }: UpdatePayload): Promise => { - await new Promise((r) => setTimeout(r, 300)); - setStore((prev) => prev.map((m) => (m.ts === ts ? { ...m, blocks: updated } : m))); - return { ok: true }; - }, []); + const onUpdate = useCallback( + async ({ channelId, ts, blocks: updated, asUser }: UpdatePayload): Promise => { + await new Promise((r) => setTimeout(r, 300)); + const channel = MOCK_CHANNELS.find((c) => c.id === channelId); + const name = channel?.name ?? channelId; + setStore((prev) => prev.map((m) => (m.ts === ts ? { ...m, blocks: updated } : m))); + const count = `${updated.length} block${updated.length === 1 ? '' : 's'}`; + window.alert(`Updated the message in #${name} (${count}, as ${asUser ? 'you' : 'the bot'}).`); + return { ok: true }; + }, + [] + ); // "Recent messages from this app" — round-trippable fixtures the app // authored, whether posted as the bot or as the current user (plus anything diff --git a/src/components/toolbar.tsx b/src/components/toolbar.tsx index 5955e6f..076ce5b 100644 --- a/src/components/toolbar.tsx +++ b/src/components/toolbar.tsx @@ -322,7 +322,7 @@ export function Toolbar({
- Editing an existing message in {editBadge.channelLabel} + References an existing message in {editBadge.channelLabel} {editBadge.ts} From fe286160b242a47360edd5b10a8892ea5c9f3a8e Mon Sep 17 00:00:00 2001 From: Stephen Date: Mon, 29 Jun 2026 17:29:11 -0700 Subject: [PATCH 15/24] style(demo): custom theme picker so the label only prefixes the closed control Replace the native theme setPreset(e.target.value as BrandPreset)} - style={{ - fontSize: 12, - padding: '6px 8px', - borderRadius: 6, - border: '1px solid hsl(var(--border))', - background: 'hsl(var(--background))', - color: 'hsl(var(--foreground))', - cursor: 'pointer' - }} - > - {PRESET_OPTIONS.map(({ value, label }) => ( - - ))} - + options={PRESET_OPTIONS} + onChange={setPreset} + />