From cae436a2a1cb7cde3125c00a2cf439ace8f6d6dd Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sun, 15 Feb 2026 15:45:57 +0000 Subject: [PATCH 01/96] feat(01-01): add blocks to message types and wire through data pipeline - Add blocks?: unknown[] field to SlackMessage interface - Add message_update to SimulatorEvent type union - chatPostMessage: accept blocks, auto-generate block_ids, allow text or blocks - chatUpdate: accept blocks, update on message, emit message_update SSE event --- packages/slack/src/server/types.ts | 2 ++ packages/slack/src/server/web-api.ts | 47 +++++++++++++++++++++++----- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/slack/src/server/types.ts b/packages/slack/src/server/types.ts index b779ca8..01a9807 100644 --- a/packages/slack/src/server/types.ts +++ b/packages/slack/src/server/types.ts @@ -63,6 +63,7 @@ export interface SlackMessage { mimetype?: string url_private?: string }> + blocks?: unknown[] } // ============================================================================= @@ -382,6 +383,7 @@ export interface SimulatorEvent { | 'view_update' | 'view_close' | 'file_shared' + | 'message_update' | 'bot_connecting' // WebSocket connected, waiting for config registration | 'bot_connected' | 'bot_disconnected' diff --git a/packages/slack/src/server/web-api.ts b/packages/slack/src/server/web-api.ts index 614edb8..7957522 100644 --- a/packages/slack/src/server/web-api.ts +++ b/packages/slack/src/server/web-api.ts @@ -280,13 +280,14 @@ export class SlackWebAPI { body: ChatPostMessageRequest, token: string | null ): Promise { - const { channel, text, thread_ts } = body + const { channel, text, thread_ts, blocks } = body webApiLogger.debug({ body, channel, text }, 'chat.postMessage request') - if (!channel || !text) { + // Slack allows either text or blocks (or both) + if (!channel || (!text && !blocks)) { webApiLogger.error( - { channel, text, hasChannel: !!channel, hasText: !!text }, + { channel, text, hasChannel: !!channel, hasText: !!text, hasBlocks: !!blocks }, 'chat.postMessage missing required argument' ) return Response.json( @@ -297,14 +298,26 @@ export class SlackWebAPI { const botInfo = this.getBotInfoFromToken(token) const ts = this.state.generateTimestamp() + const messageText = text || '' + + // Auto-generate block_id for blocks missing one + const processedBlocks = blocks + ? (blocks as Array>).map((block, index) => { + if (!block.block_id) { + return { ...block, block_id: `block_${index}` } + } + return block + }) + : undefined const message: SlackMessage = { type: 'message', channel, user: botInfo.id, - text, + text: messageText, ts, thread_ts, + blocks: processedBlocks, } // Store the message @@ -316,13 +329,13 @@ export class SlackWebAPI { ts, message: { type: 'message', - text, + text: messageText, user: botInfo.id, ts, }, } - webApiLogger.debug(`chat.postMessage: ${text.substring(0, 50)}...`) + webApiLogger.debug(`chat.postMessage: ${messageText.substring(0, 50)}...`) return Response.json(response, { headers: corsHeaders() }) } @@ -330,7 +343,7 @@ export class SlackWebAPI { body: ChatPostMessageRequest & { ts: string }, _token: string | null ): Promise { - const { channel, text, ts } = body + const { channel, text, ts, blocks } = body if (!channel || !ts) { return Response.json( @@ -352,6 +365,26 @@ export class SlackWebAPI { message.text = text } + // Update blocks if provided + if (blocks !== undefined) { + if (blocks) { + // Auto-generate block_id for blocks missing one + message.blocks = (blocks as Array>).map( + (block, index) => { + if (!block.block_id) { + return { ...block, block_id: `block_${index}` } + } + return block + } + ) + } else { + delete message.blocks + } + } + + // Emit message_update event so the UI can re-render + this.state.emitEvent({ type: 'message_update', message, channel }) + return Response.json( { ok: true, channel, ts, text: message.text }, { headers: corsHeaders() } From bebb7028be14684b93b511a7206299bd4026e8c5 Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sun, 15 Feb 2026 15:47:47 +0000 Subject: [PATCH 02/96] feat(01-01): fix block action payloads and view submission type discriminators - handleSimulatorBlockAction supports modal (view_id) and message (message_ts + channel_id) contexts - Action objects include element-specific type, block_id, and value fields - Container field included in block_actions payloads for both contexts - dispatchInteractive accepts container, channel, message in payload type - addTypeDiscriminators adds per-element type to state.values in view submissions --- packages/slack/src/server/socket-mode.ts | 3 + packages/slack/src/server/web-api.ts | 215 +++++++++++++++++++---- 2 files changed, 180 insertions(+), 38 deletions(-) diff --git a/packages/slack/src/server/socket-mode.ts b/packages/slack/src/server/socket-mode.ts index bb7758d..26a7622 100644 --- a/packages/slack/src/server/socket-mode.ts +++ b/packages/slack/src/server/socket-mode.ts @@ -544,6 +544,9 @@ export class SocketModeServer { async dispatchInteractive(payload: { type: 'view_submission' | 'view_closed' | 'block_actions' view?: unknown + container?: unknown + channel?: unknown + message?: unknown user?: { id: string; username: string } actions?: unknown[] trigger_id?: string diff --git a/packages/slack/src/server/web-api.ts b/packages/slack/src/server/web-api.ts index 7957522..684fb7f 100644 --- a/packages/slack/src/server/web-api.ts +++ b/packages/slack/src/server/web-api.ts @@ -1404,13 +1404,17 @@ export class SlackWebAPI { // Convert files with dataUrl to stored files with IDs const processedValues = await this.processFileUploadsInValues(values) + // Add type discriminators to state.values based on element types in the view's blocks + const viewBlocks = viewState.view.blocks || [] + const typedValues = this.addTypeDiscriminators(processedValues, viewBlocks) + // Dispatch view_submission to bot await this.socketMode.dispatchInteractive({ type: 'view_submission', view: { ...viewState.view, id: view_id, - state: { values: processedValues }, + state: { values: typedValues }, private_metadata: viewState.view.private_metadata, callback_id: viewState.view.callback_id, }, @@ -1517,6 +1521,58 @@ export class SlackWebAPI { return processed } + /** + * Add type discriminators to state.values based on the element types in the view's blocks. + * Slack includes a `type` field in each value object matching the element type + * (e.g., "plain_text_input", "static_select", "checkboxes", "file_input"). + */ + private addTypeDiscriminators( + values: Record>, + blocks: unknown[] + ): Record> { + // Build a lookup of block_id -> element type from the view's blocks + const elementTypeMap = new Map>() + + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i] as Record + if (block.type !== 'input') continue + + const blockId = + (block.block_id as string) || `block-${i}` + const element = block.element as Record | undefined + if (!element?.type) continue + + const actionId = (element.action_id as string) || '' + if (!elementTypeMap.has(blockId)) { + elementTypeMap.set(blockId, new Map()) + } + elementTypeMap.get(blockId)!.set(actionId, element.type as string) + } + + // Add type discriminator to each value + const result: Record> = {} + for (const [blockId, actionValues] of Object.entries(values)) { + result[blockId] = {} + for (const [actionId, value] of Object.entries(actionValues)) { + if (value && typeof value === 'object') { + const elementType = elementTypeMap.get(blockId)?.get(actionId) + if (elementType) { + result[blockId][actionId] = { + ...(value as Record), + type: elementType, + } + } else { + result[blockId][actionId] = value + } + } else { + result[blockId][actionId] = value + } + } + } + + return result + } + handleSimulatorViewClose(body: { view_id: string }): Response { const { view_id } = body @@ -1539,57 +1595,140 @@ export class SlackWebAPI { } async handleSimulatorBlockAction(body: { - view_id: string + // Modal context (existing) + view_id?: string + // Message context (new) + message_ts?: string + channel_id?: string + // Action fields action_id: string - value: string + block_id?: string + element_type?: string // "button", "static_select", "checkboxes", etc. + // Element-specific values (UI sends the one that matches element_type) + value?: string + selected_option?: { text: { type: string; text: string }; value: string } + selected_options?: Array<{ + text: { type: string; text: string } + value: string + }> user: string }): Promise { - const { view_id, action_id, value, user } = body + const { action_id, user } = body - if (!view_id || !action_id) { + if (!action_id) { return Response.json( { ok: false, error: 'missing_argument' }, { headers: corsHeaders() } ) } - const viewState = this.state.getView(view_id) - if (!viewState) { - return Response.json( - { ok: false, error: 'view_not_found' }, - { headers: corsHeaders() } - ) - } - // Generate a new trigger_id for the action (bot may need it to update view) const triggerId = this.state.generateTriggerId() - this.state.storeTriggerContext(triggerId, { - userId: user, - channelId: viewState.channelId ?? '', - }) - // Dispatch block_actions to bot - await this.socketMode.dispatchInteractive({ - type: 'block_actions', - view: { - ...viewState.view, - id: view_id, - private_metadata: viewState.view.private_metadata, - }, - user: { - id: user, - username: 'simulator_user', - }, - actions: [ - { - type: 'button', - action_id, - value, - block_id: 'command_block', + // Build element-specific action object + const elementType = body.element_type || 'button' + const action: Record = { + type: elementType, + action_id, + block_id: body.block_id || 'command_block', + action_ts: String(Date.now() / 1000), + } + + // Include element-specific value fields + if (elementType === 'button') { + action.value = body.value + } else if (elementType === 'static_select') { + action.selected_option = body.selected_option + } else if (elementType === 'checkboxes') { + action.selected_options = body.selected_options + } else { + // For other types, include whichever value fields are present + if (body.value !== undefined) action.value = body.value + if (body.selected_option !== undefined) + action.selected_option = body.selected_option + if (body.selected_options !== undefined) + action.selected_options = body.selected_options + } + + if (body.view_id) { + // Modal context path + const viewState = this.state.getView(body.view_id) + if (!viewState) { + return Response.json( + { ok: false, error: 'view_not_found' }, + { headers: corsHeaders() } + ) + } + + this.state.storeTriggerContext(triggerId, { + userId: user, + channelId: viewState.channelId ?? '', + }) + + // Dispatch block_actions with modal context + await this.socketMode.dispatchInteractive({ + type: 'block_actions', + container: { type: 'view', view_id: body.view_id }, + view: { + ...viewState.view, + id: body.view_id, + private_metadata: viewState.view.private_metadata, }, - ], - trigger_id: triggerId, - }) + user: { + id: user, + username: 'simulator_user', + }, + actions: [action], + trigger_id: triggerId, + }) + } else if (body.message_ts && body.channel_id) { + // Message context path + const msg = this.state.getMessage(body.channel_id, body.message_ts) + if (!msg) { + return Response.json( + { ok: false, error: 'message_not_found' }, + { headers: corsHeaders() } + ) + } + + const channelInfo = this.state.getChannel(body.channel_id) + const channelName = channelInfo?.name || body.channel_id + + this.state.storeTriggerContext(triggerId, { + userId: user, + channelId: body.channel_id, + }) + + // Dispatch block_actions with message context + await this.socketMode.dispatchInteractive({ + type: 'block_actions', + container: { + type: 'message', + message_ts: body.message_ts, + channel_id: body.channel_id, + is_ephemeral: false, + }, + channel: { id: body.channel_id, name: channelName }, + message: { + type: 'message', + text: msg.text, + user: msg.user, + ts: msg.ts, + blocks: msg.blocks, + }, + user: { + id: user, + username: 'simulator_user', + }, + actions: [action], + trigger_id: triggerId, + }) + } else { + return Response.json( + { ok: false, error: 'missing_argument' }, + { headers: corsHeaders() } + ) + } return Response.json({ ok: true }, { headers: corsHeaders() }) } From 506c1e29244db8783280ba267cc7d9ca045fc97a Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sun, 15 Feb 2026 15:52:10 +0000 Subject: [PATCH 03/96] feat(01-02): add blocks to UI types, state, and SSE handler - Add blocks field to SimulatorMessage interface - Add updateMessage function for message_update SSE events - Wire blocks through message and file_shared SSE handlers - Add message_update SSE case to handle chat.update events - Add sendMessageBlockAction for message-context block actions --- apps/ui/src/lib/dispatcher.svelte.ts | 64 ++++++++++++++++++++++++++++ apps/ui/src/lib/state.svelte.ts | 14 ++++++ apps/ui/src/lib/types.ts | 1 + 3 files changed, 79 insertions(+) diff --git a/apps/ui/src/lib/dispatcher.svelte.ts b/apps/ui/src/lib/dispatcher.svelte.ts index 2b1dccb..0262d2a 100644 --- a/apps/ui/src/lib/dispatcher.svelte.ts +++ b/apps/ui/src/lib/dispatcher.svelte.ts @@ -6,6 +6,7 @@ import { simulatorState, addMessage, + updateMessage, deleteMessageFromState, clearChannelMessagesFromState, addReactionToMessage, @@ -28,6 +29,7 @@ import type { SlackView, SlackFile, SlackAppConfig, + SlackBlock, ConnectedBotInfo, } from './types' import { INTERNAL_SIMULATED_USER_ID } from './settings-store' @@ -268,6 +270,7 @@ function handleSSEEvent(event: { text: string ts: string thread_ts?: string + blocks?: unknown[] } channel?: string item_ts?: string @@ -309,6 +312,7 @@ function handleSSEEvent(event: { text: msg.text, thread_ts: msg.thread_ts, channel: msg.channel, + blocks: msg.blocks as SlackBlock[] | undefined, }) } } @@ -334,6 +338,17 @@ function handleSSEEvent(event: { thread_ts: msg.thread_ts, channel: msg.channel, file, + blocks: msg.blocks as SlackBlock[] | undefined, + }) + } + break + + case 'message_update': + if (event.message) { + const msg = event.message + updateMessage(msg.channel, msg.ts, { + text: msg.text, + blocks: msg.blocks as SlackBlock[] | undefined, }) } break @@ -715,6 +730,55 @@ export async function sendBlockAction( } } +/** + * Send a block action from within a message (e.g., button click in message blocks) + * Unlike sendBlockAction (for modals), this sends message_ts + channel_id context + */ +export async function sendMessageBlockAction( + messageTs: string, + channelId: string, + actionId: string, + blockId: string, + elementType: string, + actionValue: { + value?: string + selected_option?: { text: { type: string; text: string }; value: string } + selected_options?: Array<{ + text: { type: string; text: string } + value: string + }> + } +): Promise { + try { + const response = await fetch( + `${EMULATOR_API_URL}/api/simulator/block-action`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message_ts: messageTs, + channel_id: channelId, + action_id: actionId, + block_id: blockId, + element_type: elementType, + ...actionValue, + user: simulatorState.simulatedUserId, + }), + } + ) + + if (!response.ok) { + throw new Error(`API error: ${response.status}`) + } + + dispatcherLogger.info(`Message block action sent: ${actionId}`) + return true + } catch (error) { + dispatcherLogger.error('Failed to send message block action:', error) + return false + } +} + /** * Update file expanded state */ diff --git a/apps/ui/src/lib/state.svelte.ts b/apps/ui/src/lib/state.svelte.ts index b9ff170..d0b7715 100644 --- a/apps/ui/src/lib/state.svelte.ts +++ b/apps/ui/src/lib/state.svelte.ts @@ -82,6 +82,20 @@ export function addMessage( return fullMessage } +// Action: Update an existing message (e.g., chat.update with new blocks/text) +export function updateMessage( + channel: string, + ts: string, + updates: Partial> +): void { + const channelMsgs = simulatorState.messages.get(channel) + const msg = channelMsgs?.get(ts) + if (msg && channelMsgs) { + // Create new object reference to trigger Svelte reactivity (same pattern as addReactionToMessage) + channelMsgs.set(ts, { ...msg, ...updates }) + } +} + // Action: Add reaction to a message export function addReactionToMessage( channel: string, diff --git a/apps/ui/src/lib/types.ts b/apps/ui/src/lib/types.ts index 8051764..acaf0a6 100644 --- a/apps/ui/src/lib/types.ts +++ b/apps/ui/src/lib/types.ts @@ -21,6 +21,7 @@ export interface SimulatorMessage { channel: string reactions: Map file?: SlackFile + blocks?: SlackBlock[] } export interface Channel { From 1f024c69da2e09d6b8522201bab558405adefba1 Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sun, 15 Feb 2026 15:53:24 +0000 Subject: [PATCH 04/96] feat(01-02): render BlockKit in messages and wire block action callbacks - Conditionally render BlockKitRenderer when message has blocks - Show text as muted fallback when blocks are present - Add resolveActionFromBlocks helper to identify element type and build payload - Wire handleMessageBlockAction to sendMessageBlockAction dispatcher - Add static_select accessory support to SectionBlock --- apps/ui/src/components/Message.svelte | 138 +++++++++++++++++- .../blockkit/blocks/SectionBlock.svelte | 11 ++ 2 files changed, 142 insertions(+), 7 deletions(-) diff --git a/apps/ui/src/components/Message.svelte b/apps/ui/src/components/Message.svelte index 1ee3d12..12a55db 100644 --- a/apps/ui/src/components/Message.svelte +++ b/apps/ui/src/components/Message.svelte @@ -8,14 +8,24 @@ ChevronRight, ImageIcon, } from '@lucide/svelte' - import type { SimulatorMessage } from '../lib/types' + import type { + SimulatorMessage, + SlackBlock, + SlackOption, + SlackSectionBlock, + SlackActionsBlock, + } from '../lib/types' import { getMessageShortcut, simulatorState, isBotUserId, getBotByUserId, } from '../lib/state.svelte' - import { updateFileExpanded } from '../lib/dispatcher.svelte' + import { + updateFileExpanded, + sendMessageBlockAction, + } from '../lib/dispatcher.svelte' + import BlockKitRenderer from './blockkit/BlockKitRenderer.svelte' import { formatTimestamp, formatRelativeTime } from '../lib/time' import * as ContextMenu from '$lib/components/ui/context-menu' @@ -102,6 +112,104 @@ }) }) + let hasBlocks = $derived(message.blocks && message.blocks.length > 0) + + function buildActionValue( + blockId: string, + element: { type: string; action_id: string; options?: SlackOption[] }, + value: string + ) { + const elementType = element.type + + if (elementType === 'static_select' && element.options) { + const opt = element.options.find((o) => o.value === value) + return { + blockId, + elementType, + actionValue: opt + ? { selected_option: { text: opt.text, value: opt.value } } + : { value }, + } + } + + // For buttons and other types, use value as-is + return { blockId, elementType, actionValue: { value } } + } + + function resolveActionFromBlocks( + blocks: SlackBlock[], + actionId: string, + value: string + ): { + blockId: string + elementType: string + actionValue: { + value?: string + selected_option?: { + text: { type: string; text: string } + value: string + } + selected_options?: Array<{ + text: { type: string; text: string } + value: string + }> + } + } { + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i] + const blockId = block.block_id || `block_${i}` + + // Check section accessory + if (block.type === 'section') { + const sectionBlock = block as SlackSectionBlock + if (sectionBlock.accessory) { + const el = sectionBlock.accessory + if ('action_id' in el && el.action_id === actionId) { + return buildActionValue( + blockId, + el as { type: string; action_id: string; options?: SlackOption[] }, + value + ) + } + } + } + + // Check actions block elements + if (block.type === 'actions') { + const actionsBlock = block as SlackActionsBlock + for (const el of actionsBlock.elements) { + if ('action_id' in el && el.action_id === actionId) { + return buildActionValue( + blockId, + el as { type: string; action_id: string; options?: SlackOption[] }, + value + ) + } + } + } + } + + // Fallback: treat as button + return { blockId: 'unknown', elementType: 'button', actionValue: { value } } + } + + function handleMessageBlockAction(actionId: string, value: string) { + const { blockId, elementType, actionValue } = resolveActionFromBlocks( + message.blocks || [], + actionId, + value + ) + + sendMessageBlockAction( + message.ts, + message.channel, + actionId, + blockId, + elementType, + actionValue + ) + } + function toggleMenu(e: MouseEvent) { e.stopPropagation() menuOpen = !menuOpen @@ -183,11 +291,27 @@ {/if} {timestamp} -
- {@html formattedText} -
+ {#if hasBlocks} +
+ +
+ {#if message.text} +
+ {@html formattedText} +
+ {/if} + {:else} +
+ {@html formattedText} +
+ {/if} {#if message.file}
{#if message.file.mimetype.startsWith('image/')} diff --git a/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte b/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte index a707336..07772b0 100644 --- a/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte +++ b/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte @@ -2,9 +2,11 @@ import type { SlackSectionBlock, SlackButtonElement, + SlackStaticSelectElement, } from '../../../lib/types' import { renderMrkdwn } from '../context' import Button from '../elements/Button.svelte' + import StaticSelect from '../elements/StaticSelect.svelte' import ImageElement from '../elements/ImageElement.svelte' interface Props { @@ -40,6 +42,15 @@ onClick={onAction} />
+ {:else if block.accessory.type === 'static_select'} + {@const sel = block.accessory as SlackStaticSelectElement} +
+ onAction?.(sel.action_id, value)} + /> +
{:else if block.accessory.type === 'image'} Date: Sun, 15 Feb 2026 16:35:05 +0000 Subject: [PATCH 05/96] fix(01): parse JSON-encoded form fields and fix static_select view_submission format parseBody now JSON-parses stringified arrays/objects in form-urlencoded data, fixing blocks and view payloads sent by Slack's Web API client. addTypeDiscriminators restructures static_select values into selected_option format matching real Slack's view_submission payloads. --- packages/slack/src/server/web-api.ts | 70 ++++++++++++++++++++++------ 1 file changed, 55 insertions(+), 15 deletions(-) diff --git a/packages/slack/src/server/web-api.ts b/packages/slack/src/server/web-api.ts index 684fb7f..c7b66ca 100644 --- a/packages/slack/src/server/web-api.ts +++ b/packages/slack/src/server/web-api.ts @@ -95,9 +95,22 @@ export class SlackWebAPI { if (contentType.includes('application/x-www-form-urlencoded')) { const text = await req.text() const params = new URLSearchParams(text) - const result: Record = {} + const result: Record = {} for (const [key, value] of params) { - result[key] = value + // Slack clients send JSON-encoded fields (blocks, attachments, etc.) + // in form data — parse them back into objects/arrays + if ( + (value.startsWith('[') && value.endsWith(']')) || + (value.startsWith('{') && value.endsWith('}')) + ) { + try { + result[key] = JSON.parse(value) + } catch { + result[key] = value + } + } else { + result[key] = value + } } return result as T } @@ -1530,36 +1543,63 @@ export class SlackWebAPI { values: Record>, blocks: unknown[] ): Record> { - // Build a lookup of block_id -> element type from the view's blocks - const elementTypeMap = new Map>() + // Build a lookup of block_id -> element info from the view's blocks + const elementInfoMap = new Map< + string, + Map> }> + >() for (let i = 0; i < blocks.length; i++) { const block = blocks[i] as Record if (block.type !== 'input') continue - const blockId = - (block.block_id as string) || `block-${i}` + const blockId = (block.block_id as string) || `block-${i}` const element = block.element as Record | undefined if (!element?.type) continue const actionId = (element.action_id as string) || '' - if (!elementTypeMap.has(blockId)) { - elementTypeMap.set(blockId, new Map()) + if (!elementInfoMap.has(blockId)) { + elementInfoMap.set(blockId, new Map()) } - elementTypeMap.get(blockId)!.set(actionId, element.type as string) + elementInfoMap.get(blockId)!.set(actionId, { + type: element.type as string, + options: element.options as + | Array> + | undefined, + }) } - // Add type discriminator to each value + // Add type discriminator and restructure values to match Slack's format const result: Record> = {} for (const [blockId, actionValues] of Object.entries(values)) { result[blockId] = {} for (const [actionId, value] of Object.entries(actionValues)) { if (value && typeof value === 'object') { - const elementType = elementTypeMap.get(blockId)?.get(actionId) - if (elementType) { - result[blockId][actionId] = { - ...(value as Record), - type: elementType, + const elementInfo = elementInfoMap.get(blockId)?.get(actionId) + if (elementInfo) { + const val = value as Record + // Restructure static_select: { value: "x" } -> { selected_option: {...}, type } + if ( + elementInfo.type === 'static_select' && + 'value' in val && + !('selected_option' in val) + ) { + const selectedValue = val.value as string + const option = elementInfo.options?.find( + (o) => o.value === selectedValue + ) + result[blockId][actionId] = { + selected_option: option || { + text: { type: 'plain_text', text: selectedValue }, + value: selectedValue, + }, + type: elementInfo.type, + } + } else { + result[blockId][actionId] = { + ...val, + type: elementInfo.type, + } } } else { result[blockId][actionId] = value From 2e23306debaab26f448903a170b45ebfe01ed4fc Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sun, 15 Feb 2026 16:52:28 +0000 Subject: [PATCH 06/96] feat(02-01): rewrite renderMrkdwn with slack-markdown + DOMPurify and add shared mrkdwn CSS - Replace hand-rolled bold-only regex with slack-markdown toHTML() + DOMPurify.sanitize() - Add plain_text vs mrkdwn type discrimination (plain_text is escaped, not parsed) - Define SANITIZE_CONFIG with allowed tags for all mrkdwn output elements - Add shared .mrkdwn CSS class in app.css for code, pre, links, blockquotes, mentions, emoji --- apps/ui/src/app.css | 55 ++++++++++++++++++++++ apps/ui/src/components/blockkit/context.ts | 44 +++++++++++++---- 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/apps/ui/src/app.css b/apps/ui/src/app.css index f43d722..b5bf712 100644 --- a/apps/ui/src/app.css +++ b/apps/ui/src/app.css @@ -295,3 +295,58 @@ } } } + +/* Mrkdwn rendered text styles (shared across messages, blocks, modals) */ +.mrkdwn code { + background: #8881; + border: 1px solid #8883; + border-radius: 3px; + padding: 2px 4px; + font-family: var(--font-mono); + font-size: 12px; + color: #e6902c; +} + +.mrkdwn pre { + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 4px; + padding: 8px 12px; + margin: 4px 0; + overflow-x: auto; + white-space: pre; +} + +.mrkdwn pre code { + background: none; + border: none; + padding: 0; + color: var(--text-primary); +} + +.mrkdwn a { + color: #1d9bd1; + text-decoration: none; +} + +.mrkdwn a:hover { + text-decoration: underline; +} + +.mrkdwn blockquote { + border-left: 4px solid #ddd; + margin: 4px 0; + padding-left: 12px; + color: var(--text-secondary); +} + +.mrkdwn .s-mention { + background: rgba(232, 171, 76, 0.2); + color: #e8ab4c; + padding: 0 2px; + border-radius: 3px; +} + +.mrkdwn .s-emoji { + font-style: normal; +} diff --git a/apps/ui/src/components/blockkit/context.ts b/apps/ui/src/components/blockkit/context.ts index 14e3d5b..99fee99 100644 --- a/apps/ui/src/components/blockkit/context.ts +++ b/apps/ui/src/components/blockkit/context.ts @@ -1,3 +1,5 @@ +import { toHTML } from 'slack-markdown' +import DOMPurify from 'dompurify' import type { SlackViewTextObject, SlackOption, @@ -8,6 +10,23 @@ import type { * Shared utilities and types for BlockKit components */ +const SANITIZE_CONFIG = { + ALLOWED_TAGS: [ + 'a', + 'b', + 'blockquote', + 'br', + 'code', + 'del', + 'em', + 'i', + 'pre', + 'span', + 'strong', + ], + ALLOWED_ATTR: ['href', 'target', 'class'], +} + /** * Render plain text from a Slack text object */ @@ -18,18 +37,27 @@ export function renderText(textObj: SlackViewTextObject | undefined): string { /** * Render text with mrkdwn formatting support. - * Returns HTML string that should be rendered with {@html}. + * Returns sanitized HTML string that should be rendered with {@html}. + * Respects the text object type: plain_text is escaped, mrkdwn is parsed. */ export function renderMrkdwn(textObj: SlackViewTextObject | undefined): string { if (!textObj) return '' - let text = textObj.text - // Escape HTML entities first to prevent XSS - text = text.replace(/&/g, '&').replace(//g, '>') - // Parse mrkdwn bold (*text*) if type is mrkdwn - if (textObj.type === 'mrkdwn') { - text = text.replace(/\*([^*]+)\*/g, '$1') + + // Plain text: escape HTML entities, no mrkdwn parsing + if (textObj.type === 'plain_text') { + return textObj.text + .replace(/&/g, '&') + .replace(//g, '>') } - return text + + // mrkdwn type: parse with slack-markdown, sanitize output + const html = toHTML(textObj.text, { + escapeHTML: true, + hrefTarget: '_blank', + }) + + return DOMPurify.sanitize(html, SANITIZE_CONFIG) } /** From 72602f2b95bd679da97560e338e019f61f1efab9 Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sun, 15 Feb 2026 16:54:18 +0000 Subject: [PATCH 07/96] refactor(02-01): unify all mrkdwn consumers to use shared renderMrkdwn - Add .mrkdwn wrapper class to SectionBlock text and fields elements - Add .mrkdwn wrapper class to ContextBlock text elements - Switch Checkboxes from renderText to renderMrkdwn with .mrkdwn wrapper - Refactor Message.svelte to use renderMrkdwn instead of direct toHTML + DOMPurify - Remove duplicate mrkdwn CSS from Message.svelte (now in app.css), keep message-specific styles --- apps/ui/src/components/Message.svelte | 96 ++++--------------- .../blockkit/blocks/ContextBlock.svelte | 2 +- .../blockkit/blocks/SectionBlock.svelte | 4 +- .../blockkit/elements/Checkboxes.svelte | 6 +- 4 files changed, 26 insertions(+), 82 deletions(-) diff --git a/apps/ui/src/components/Message.svelte b/apps/ui/src/components/Message.svelte index 12a55db..46de4e2 100644 --- a/apps/ui/src/components/Message.svelte +++ b/apps/ui/src/components/Message.svelte @@ -1,6 +1,4 @@ + + + +
+ + {#if isOpen} +
+ {#each element.options as option (option.value)} + + {/each} +
+ {/if} +
diff --git a/apps/ui/src/components/blockkit/elements/RadioButtonGroup.svelte b/apps/ui/src/components/blockkit/elements/RadioButtonGroup.svelte new file mode 100644 index 0000000..c918d2a --- /dev/null +++ b/apps/ui/src/components/blockkit/elements/RadioButtonGroup.svelte @@ -0,0 +1,40 @@ + + +
+ {#each element.options as option (option.value)} + + {/each} +
diff --git a/packages/slack/src/server/web-api.ts b/packages/slack/src/server/web-api.ts index c7b66ca..f3eec77 100644 --- a/packages/slack/src/server/web-api.ts +++ b/packages/slack/src/server/web-api.ts @@ -1679,6 +1679,10 @@ export class SlackWebAPI { action.value = body.value } else if (elementType === 'static_select') { action.selected_option = body.selected_option + } else if (elementType === 'overflow') { + action.selected_option = body.selected_option + } else if (elementType === 'radio_buttons') { + action.selected_option = body.selected_option } else if (elementType === 'checkboxes') { action.selected_options = body.selected_options } else { From 36fb884357a9b15a7c46856aebded63b2d77c402 Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sun, 15 Feb 2026 19:24:24 +0000 Subject: [PATCH 20/96] feat(03-02): add NumberInput, EmailInput, and UrlInput components - NumberInput uses native with step/min/max from element props - EmailInput defaults placeholder to "name@example.com" when bot provides none - UrlInput defaults placeholder to "https://example.com" when bot provides none - All three use PlainTextInput styling for visual consistency --- .../blockkit/elements/EmailInput.svelte | 23 +++++++++++++++++ .../blockkit/elements/NumberInput.svelte | 25 +++++++++++++++++++ .../blockkit/elements/UrlInput.svelte | 23 +++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 apps/ui/src/components/blockkit/elements/EmailInput.svelte create mode 100644 apps/ui/src/components/blockkit/elements/NumberInput.svelte create mode 100644 apps/ui/src/components/blockkit/elements/UrlInput.svelte diff --git a/apps/ui/src/components/blockkit/elements/EmailInput.svelte b/apps/ui/src/components/blockkit/elements/EmailInput.svelte new file mode 100644 index 0000000..a88c9ea --- /dev/null +++ b/apps/ui/src/components/blockkit/elements/EmailInput.svelte @@ -0,0 +1,23 @@ + + + onChange?.(e.currentTarget.value)} +/> diff --git a/apps/ui/src/components/blockkit/elements/NumberInput.svelte b/apps/ui/src/components/blockkit/elements/NumberInput.svelte new file mode 100644 index 0000000..3c9d233 --- /dev/null +++ b/apps/ui/src/components/blockkit/elements/NumberInput.svelte @@ -0,0 +1,25 @@ + + + onChange?.(e.currentTarget.value)} +/> diff --git a/apps/ui/src/components/blockkit/elements/UrlInput.svelte b/apps/ui/src/components/blockkit/elements/UrlInput.svelte new file mode 100644 index 0000000..4144786 --- /dev/null +++ b/apps/ui/src/components/blockkit/elements/UrlInput.svelte @@ -0,0 +1,23 @@ + + + onChange?.(e.currentTarget.value)} +/> From 10df3e080e3ab59d130fa953f3f1736535bff14e Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sun, 15 Feb 2026 19:25:58 +0000 Subject: [PATCH 21/96] feat(03-02): wire typed inputs and radio buttons into modal form flow - InputBlock renders number_input, email_text_input, url_text_input, radio_buttons - BlockKitRenderer passes onRadioChange prop through to InputBlock - ModalOverlay extracts initial values and tracks state for all new types - Backend addTypeDiscriminators restructures radio_buttons to selected_option format --- apps/ui/src/components/ModalOverlay.svelte | 27 ++++++++++++ .../blockkit/BlockKitRenderer.svelte | 7 ++++ .../blockkit/blocks/InputBlock.svelte | 42 +++++++++++++++++++ packages/slack/src/server/web-api.ts | 17 ++++++++ 4 files changed, 93 insertions(+) diff --git a/apps/ui/src/components/ModalOverlay.svelte b/apps/ui/src/components/ModalOverlay.svelte index b91444e..41e577b 100644 --- a/apps/ui/src/components/ModalOverlay.svelte +++ b/apps/ui/src/components/ModalOverlay.svelte @@ -12,6 +12,7 @@ SlackBlock, SlackInputBlock, SlackCheckboxesElement, + SlackRadioButtonsElement, UploadedFile, } from '../lib/types' @@ -89,6 +90,20 @@ values[blockId][element.action_id] = { selected_options: checkboxElement.initial_options ?? [], } + } else if ( + element.type === 'number_input' || + element.type === 'email_text_input' || + element.type === 'url_text_input' + ) { + values[blockId][element.action_id] = { + value: ('initial_value' in element ? (element as { initial_value?: string }).initial_value : undefined) ?? '', + } + } else if (element.type === 'radio_buttons') { + const radioElement = element as SlackRadioButtonsElement + values[blockId][element.action_id] = { + selected_option: radioElement.initial_option, + value: radioElement.initial_option?.value, + } } } } @@ -133,6 +148,17 @@ formValues[blockId][actionId] = { selected_options: selectedOptions } } + function handleRadioChange( + blockId: string, + actionId: string, + option: SlackOption + ) { + if (!formValues[blockId]) { + formValues[blockId] = {} + } + formValues[blockId][actionId] = { selected_option: option, value: option.value } + } + async function handleAction(actionId: string, value: string) { if (!simulatorState.activeModal) return await sendBlockAction(simulatorState.activeModal.viewId, actionId, value) @@ -220,6 +246,7 @@ onInputChange={handleInputChange} onFileChange={handleFileChange} onCheckboxChange={handleCheckboxChange} + onRadioChange={handleRadioChange} /> diff --git a/apps/ui/src/components/blockkit/BlockKitRenderer.svelte b/apps/ui/src/components/blockkit/BlockKitRenderer.svelte index 0e46e0d..d3633f1 100644 --- a/apps/ui/src/components/blockkit/BlockKitRenderer.svelte +++ b/apps/ui/src/components/blockkit/BlockKitRenderer.svelte @@ -37,6 +37,11 @@ actionId: string, selectedOptions: SlackOption[] ) => void + onRadioChange?: ( + blockId: string, + actionId: string, + option: SlackOption + ) => void } let { @@ -47,6 +52,7 @@ onInputChange, onFileChange, onCheckboxChange, + onRadioChange, }: Props = $props() function getBlockId(block: SlackBlock, index: number): string { @@ -67,6 +73,7 @@ {onInputChange} {onFileChange} {onCheckboxChange} + {onRadioChange} /> {:else if block.type === 'actions'} diff --git a/apps/ui/src/components/blockkit/blocks/InputBlock.svelte b/apps/ui/src/components/blockkit/blocks/InputBlock.svelte index 07a3d13..8b8d662 100644 --- a/apps/ui/src/components/blockkit/blocks/InputBlock.svelte +++ b/apps/ui/src/components/blockkit/blocks/InputBlock.svelte @@ -5,6 +5,10 @@ SlackStaticSelectElement, SlackFileInputElement, SlackCheckboxesElement, + SlackNumberInputElement, + SlackEmailInputElement, + SlackUrlInputElement, + SlackRadioButtonsElement, SlackOption, UploadedFile, } from '../../../lib/types' @@ -13,6 +17,10 @@ import StaticSelect from '../elements/StaticSelect.svelte' import FileInput from '../elements/FileInput.svelte' import Checkboxes from '../elements/Checkboxes.svelte' + import NumberInput from '../elements/NumberInput.svelte' + import EmailInput from '../elements/EmailInput.svelte' + import UrlInput from '../elements/UrlInput.svelte' + import RadioButtonGroup from '../elements/RadioButtonGroup.svelte' interface Props { block: SlackInputBlock @@ -30,6 +38,11 @@ actionId: string, selectedOptions: SlackOption[] ) => void + onRadioChange?: ( + blockId: string, + actionId: string, + option: SlackOption + ) => void } let { @@ -40,6 +53,7 @@ onInputChange, onFileChange, onCheckboxChange, + onRadioChange, }: Props = $props() function getInputValue(actionId: string): string { @@ -100,6 +114,34 @@ selectedOptions={getSelectedOptions(el.action_id)} onChange={(options) => onCheckboxChange?.(blockId, el.action_id, options)} /> + {:else if block.element.type === 'number_input'} + {@const el = block.element as SlackNumberInputElement} + onInputChange?.(blockId, el.action_id, value)} + /> + {:else if block.element.type === 'email_text_input'} + {@const el = block.element as SlackEmailInputElement} + onInputChange?.(blockId, el.action_id, value)} + /> + {:else if block.element.type === 'url_text_input'} + {@const el = block.element as SlackUrlInputElement} + onInputChange?.(blockId, el.action_id, value)} + /> + {:else if block.element.type === 'radio_buttons'} + {@const el = block.element as SlackRadioButtonsElement} + onRadioChange?.(blockId, el.action_id, option)} + /> {/if} {#if block.hint} diff --git a/packages/slack/src/server/web-api.ts b/packages/slack/src/server/web-api.ts index f3eec77..5421a9c 100644 --- a/packages/slack/src/server/web-api.ts +++ b/packages/slack/src/server/web-api.ts @@ -1595,6 +1595,23 @@ export class SlackWebAPI { }, type: elementInfo.type, } + } else if ( + elementInfo.type === 'radio_buttons' && + 'value' in val && + !('selected_option' in val) + ) { + // Restructure radio_buttons: { value: "x" } -> { selected_option: {...}, type } + const selectedValue = val.value as string + const option = elementInfo.options?.find( + (o) => o.value === selectedValue + ) + result[blockId][actionId] = { + selected_option: option || { + text: { type: 'plain_text', text: selectedValue }, + value: selectedValue, + }, + type: elementInfo.type, + } } else { result[blockId][actionId] = { ...val, From 1fe03f0b3774d7ddc189ef4f7b2e2afaace1a7a5 Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sun, 15 Feb 2026 19:28:27 +0000 Subject: [PATCH 22/96] feat(03-03): add ConfirmDialog composition object component - Centered modal overlay with backdrop dismiss and Escape key handling - Title, mrkdwn-capable text body, styled confirm/deny buttons - Supports style property (danger=red, primary/default=green) --- .../blockkit/elements/ConfirmDialog.svelte | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 apps/ui/src/components/blockkit/elements/ConfirmDialog.svelte diff --git a/apps/ui/src/components/blockkit/elements/ConfirmDialog.svelte b/apps/ui/src/components/blockkit/elements/ConfirmDialog.svelte new file mode 100644 index 0000000..1749f27 --- /dev/null +++ b/apps/ui/src/components/blockkit/elements/ConfirmDialog.svelte @@ -0,0 +1,60 @@ + + + + + +
+
+

+ {renderText(dialog.title)} +

+
+ {@html renderMrkdwn(dialog.text)} +
+
+ + +
+
+
From ec04c7be4d10eaa9590c7743ab281ae3ab9c2264 Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sun, 15 Feb 2026 19:30:14 +0000 Subject: [PATCH 23/96] feat(03-03): retrofit confirm dialog onto all interactive elements - Button: intercepts click, shows confirm before dispatching action - StaticSelect: intercepts change, shows confirm before dispatching - Checkboxes: intercepts toggle, shows confirm before dispatching - OverflowMenu: closes dropdown first, shows confirm before action/URL - RadioButtonGroup: intercepts change, shows confirm before dispatching - All use shared pendingAction/showingConfirm pattern --- .../blockkit/elements/Button.svelte | 30 +++++++++++++++- .../blockkit/elements/Checkboxes.svelte | 27 ++++++++++++++- .../blockkit/elements/ConfirmDialog.svelte | 2 +- .../blockkit/elements/OverflowMenu.svelte | 34 +++++++++++++++++-- .../blockkit/elements/RadioButtonGroup.svelte | 26 +++++++++++++- .../blockkit/elements/StaticSelect.svelte | 30 +++++++++++++++- 6 files changed, 141 insertions(+), 8 deletions(-) diff --git a/apps/ui/src/components/blockkit/elements/Button.svelte b/apps/ui/src/components/blockkit/elements/Button.svelte index a4a7318..f214c80 100644 --- a/apps/ui/src/components/blockkit/elements/Button.svelte +++ b/apps/ui/src/components/blockkit/elements/Button.svelte @@ -1,6 +1,7 @@ + +{#if showingConfirm && element.confirm} + +{/if} diff --git a/apps/ui/src/components/blockkit/elements/Checkboxes.svelte b/apps/ui/src/components/blockkit/elements/Checkboxes.svelte index 23bc77f..b4a74a5 100644 --- a/apps/ui/src/components/blockkit/elements/Checkboxes.svelte +++ b/apps/ui/src/components/blockkit/elements/Checkboxes.svelte @@ -1,6 +1,7 @@ @@ -58,3 +79,7 @@ {/each} + +{#if showingConfirm && element.confirm} + +{/if} diff --git a/apps/ui/src/components/blockkit/elements/ConfirmDialog.svelte b/apps/ui/src/components/blockkit/elements/ConfirmDialog.svelte index 1749f27..f088c9d 100644 --- a/apps/ui/src/components/blockkit/elements/ConfirmDialog.svelte +++ b/apps/ui/src/components/blockkit/elements/ConfirmDialog.svelte @@ -25,7 +25,7 @@ - +
void) | null = $state(null) let containerRef: HTMLDivElement function toggle(e: MouseEvent) { @@ -23,10 +26,31 @@ function handleSelect(option: SlackOverflowOption) { isOpen = false - if (option.url) { - window.open(option.url, '_blank') + if (element.confirm) { + pendingAction = () => { + if (option.url) { + window.open(option.url, '_blank') + } + onSelect?.(option) + } + showingConfirm = true + } else { + if (option.url) { + window.open(option.url, '_blank') + } + onSelect?.(option) } - onSelect?.(option) + } + + function handleConfirm() { + pendingAction?.() + pendingAction = null + showingConfirm = false + } + + function handleDeny() { + pendingAction = null + showingConfirm = false } function handleClickOutside(event: MouseEvent) { @@ -74,3 +98,7 @@
{/if} + +{#if showingConfirm && element.confirm} + +{/if} diff --git a/apps/ui/src/components/blockkit/elements/RadioButtonGroup.svelte b/apps/ui/src/components/blockkit/elements/RadioButtonGroup.svelte index c918d2a..046568f 100644 --- a/apps/ui/src/components/blockkit/elements/RadioButtonGroup.svelte +++ b/apps/ui/src/components/blockkit/elements/RadioButtonGroup.svelte @@ -1,6 +1,7 @@ @@ -38,3 +58,7 @@ {/each} + +{#if showingConfirm && element.confirm} + +{/if} diff --git a/apps/ui/src/components/blockkit/elements/StaticSelect.svelte b/apps/ui/src/components/blockkit/elements/StaticSelect.svelte index ae65960..0e3d311 100644 --- a/apps/ui/src/components/blockkit/elements/StaticSelect.svelte +++ b/apps/ui/src/components/blockkit/elements/StaticSelect.svelte @@ -4,6 +4,7 @@ SlackOption, } from '../../../lib/types' import { renderText } from '../context' + import ConfirmDialog from './ConfirmDialog.svelte' interface Props { element: SlackStaticSelectElement @@ -18,13 +19,36 @@ const selectedValue = $derived( value?.value ?? element.initial_option?.value ?? '' ) + + let showingConfirm = $state(false) + let pendingAction: (() => void) | null = $state(null) + + function handleChange(newValue: string) { + if (element.confirm) { + pendingAction = () => onChange?.(newValue) + showingConfirm = true + } else { + onChange?.(newValue) + } + } + + function handleConfirm() { + pendingAction?.() + pendingAction = null + showingConfirm = false + } + + function handleDeny() { + pendingAction = null + showingConfirm = false + } + +{#if showingConfirm && element.confirm} + +{/if} From 0e75a130457e39b71b6085f31fd4ba7e737a6d11 Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sun, 15 Feb 2026 20:38:03 +0000 Subject: [PATCH 24/96] fix(03-01): use fixed positioning for overflow dropdown to prevent clipping Dropdown was getting clipped by ancestor overflow:hidden containers. Now uses fixed positioning calculated from trigger button's viewport rect, with auto-detection for opening above vs below. --- .../blockkit/elements/OverflowMenu.svelte | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/ui/src/components/blockkit/elements/OverflowMenu.svelte b/apps/ui/src/components/blockkit/elements/OverflowMenu.svelte index 6c7a20f..c136ea1 100644 --- a/apps/ui/src/components/blockkit/elements/OverflowMenu.svelte +++ b/apps/ui/src/components/blockkit/elements/OverflowMenu.svelte @@ -17,11 +17,30 @@ let isOpen = $state(false) let showingConfirm = $state(false) let pendingAction: (() => void) | null = $state(null) + let triggerRef: HTMLButtonElement let containerRef: HTMLDivElement + let menuStyle = $state('') function toggle(e: MouseEvent) { e.stopPropagation() isOpen = !isOpen + if (isOpen) { + positionMenu() + } + } + + function positionMenu() { + if (!triggerRef) return + const rect = triggerRef.getBoundingClientRect() + const menuHeight = element.options.length * 36 + 2 // approximate: ~36px per option + border + const spaceBelow = window.innerHeight - rect.bottom + const openAbove = spaceBelow < menuHeight + 8 + + if (openAbove) { + menuStyle = `position:fixed; bottom:${window.innerHeight - rect.top + 4}px; right:${window.innerWidth - rect.right}px;` + } else { + menuStyle = `position:fixed; top:${rect.bottom + 4}px; right:${window.innerWidth - rect.right}px;` + } } function handleSelect(option: SlackOverflowOption) { @@ -72,11 +91,12 @@ -
+
{#if isOpen}
{#each element.options as option (option.value)} + {#if isOpen} +
+ + {#snippet children({ months, weekdays })} + + + + + + + + + + {#each months as month (month.value.toString())} + + + + {#each weekdays as weekday (weekday)} + + {weekday} + + {/each} + + + + {#each month.weeks as weekDates (weekDates.map(d => d.toString()).join('-'))} + + {#each weekDates as date (date.toString())} + + {#snippet children({ selected, disabled })} + + {date.day} + + {/snippet} + + {/each} + + {/each} + + + {/each} + {/snippet} + +
+ {/if} +
+ +{#if showingConfirm && element.confirm} + +{/if} diff --git a/apps/ui/src/components/blockkit/elements/TimePicker.svelte b/apps/ui/src/components/blockkit/elements/TimePicker.svelte new file mode 100644 index 0000000..3c4ad73 --- /dev/null +++ b/apps/ui/src/components/blockkit/elements/TimePicker.svelte @@ -0,0 +1,88 @@ + + +
+ + + {#snippet children({ segments })} + {#each segments as { part, value: segValue } (part)} + {#if part === 'literal'} + {segValue} + {:else} + + {segValue} + + {/if} + {/each} + {/snippet} + + +
+ +{#if showingConfirm && element.confirm} + +{/if} diff --git a/apps/ui/src/lib/types.ts b/apps/ui/src/lib/types.ts index c454a28..e57209f 100644 --- a/apps/ui/src/lib/types.ts +++ b/apps/ui/src/lib/types.ts @@ -160,6 +160,8 @@ export type SlackBlockElement = | SlackStaticSelectElement | SlackOverflowElement | SlackRadioButtonsElement + | SlackDatePickerElement + | SlackTimePickerElement export interface SlackButtonElement { type: 'button' @@ -217,6 +219,9 @@ export type SlackInputElement = | SlackNumberInputElement | SlackEmailInputElement | SlackUrlInputElement + | SlackDatePickerElement + | SlackTimePickerElement + | SlackDateTimePickerElement export interface SlackPlainTextInputElement { type: 'plain_text_input' @@ -288,6 +293,33 @@ export interface SlackUrlInputElement { focus_on_load?: boolean } +export interface SlackDatePickerElement { + type: 'datepicker' + action_id: string + initial_date?: string // YYYY-MM-DD + placeholder?: SlackViewTextObject + confirm?: SlackConfirmDialog + focus_on_load?: boolean +} + +export interface SlackTimePickerElement { + type: 'timepicker' + action_id: string + initial_time?: string // HH:mm (24-hour) + placeholder?: SlackViewTextObject + confirm?: SlackConfirmDialog + focus_on_load?: boolean + timezone?: string +} + +export interface SlackDateTimePickerElement { + type: 'datetimepicker' + action_id: string + initial_date_time?: number // UNIX timestamp in seconds + confirm?: SlackConfirmDialog + focus_on_load?: boolean +} + // Uploaded file representation for form values export interface UploadedFile { id: string From 3cba353a26917f162626dfa07baa03c871da0cce Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sun, 15 Feb 2026 21:14:39 +0000 Subject: [PATCH 26/96] feat(04-01): wire date/time pickers into blocks, modal, dispatch, and backend - Wire DatePicker and TimePicker into ActionsBlock, SectionBlock, InputBlock - Add extractInitialValues for datepicker, timepicker, datetimepicker in ModalOverlay - Add selected_date/selected_time action value branches in Message.svelte - Extend sendMessageBlockAction actionValue type with picker fields in dispatcher - Add datepicker/timepicker/datetimepicker handling in handleSimulatorBlockAction - Add picker type discriminators in addTypeDiscriminators for view_submission --- apps/ui/src/components/Message.svelte | 18 +++++++++++++ apps/ui/src/components/ModalOverlay.svelte | 13 ++++++++++ .../blockkit/blocks/ActionsBlock.svelte | 18 +++++++++++++ .../blockkit/blocks/InputBlock.svelte | 18 +++++++++++++ .../blockkit/blocks/SectionBlock.svelte | 22 ++++++++++++++++ apps/ui/src/lib/dispatcher.svelte.ts | 3 +++ packages/slack/src/server/web-api.ts | 25 +++++++++++++++++++ 7 files changed, 117 insertions(+) diff --git a/apps/ui/src/components/Message.svelte b/apps/ui/src/components/Message.svelte index 3926dd4..4870c95 100644 --- a/apps/ui/src/components/Message.svelte +++ b/apps/ui/src/components/Message.svelte @@ -140,6 +140,22 @@ } } + if (elementType === 'datepicker') { + return { + blockId, + elementType, + actionValue: { selected_date: value }, + } + } + + if (elementType === 'timepicker') { + return { + blockId, + elementType, + actionValue: { selected_time: value }, + } + } + // For buttons and other types, use value as-is return { blockId, elementType, actionValue: { value } } } @@ -161,6 +177,8 @@ text: { type: string; text: string } value: string }> + selected_date?: string + selected_time?: string } } { for (let i = 0; i < blocks.length; i++) { diff --git a/apps/ui/src/components/ModalOverlay.svelte b/apps/ui/src/components/ModalOverlay.svelte index 41e577b..fa787a9 100644 --- a/apps/ui/src/components/ModalOverlay.svelte +++ b/apps/ui/src/components/ModalOverlay.svelte @@ -104,6 +104,19 @@ selected_option: radioElement.initial_option, value: radioElement.initial_option?.value, } + } else if (element.type === 'datepicker') { + values[blockId][element.action_id] = { + value: ('initial_date' in element ? (element as { initial_date?: string }).initial_date : undefined) ?? '', + } + } else if (element.type === 'timepicker') { + values[blockId][element.action_id] = { + value: ('initial_time' in element ? (element as { initial_time?: string }).initial_time : undefined) ?? '', + } + } else if (element.type === 'datetimepicker') { + const initialTimestamp = 'initial_date_time' in element ? (element as { initial_date_time?: number }).initial_date_time : undefined + values[blockId][element.action_id] = { + value: initialTimestamp != null ? String(initialTimestamp) : '', + } } } } diff --git a/apps/ui/src/components/blockkit/blocks/ActionsBlock.svelte b/apps/ui/src/components/blockkit/blocks/ActionsBlock.svelte index 35e8f4a..a692c15 100644 --- a/apps/ui/src/components/blockkit/blocks/ActionsBlock.svelte +++ b/apps/ui/src/components/blockkit/blocks/ActionsBlock.svelte @@ -5,11 +5,15 @@ SlackStaticSelectElement, SlackOverflowElement, SlackRadioButtonsElement, + SlackDatePickerElement, + SlackTimePickerElement, } from '../../../lib/types' import Button from '../elements/Button.svelte' import StaticSelect from '../elements/StaticSelect.svelte' import OverflowMenu from '../elements/OverflowMenu.svelte' import RadioButtonGroup from '../elements/RadioButtonGroup.svelte' + import DatePicker from '../elements/DatePicker.svelte' + import TimePicker from '../elements/TimePicker.svelte' interface Props { block: SlackActionsBlock @@ -42,6 +46,20 @@ element={radio} onChange={(option) => onAction?.(radio.action_id, option.value)} /> + {:else if element.type === 'datepicker'} + {@const dp = element as SlackDatePickerElement} + onAction?.(dp.action_id, val)} + /> + {:else if element.type === 'timepicker'} + {@const tp = element as SlackTimePickerElement} + onAction?.(tp.action_id, val)} + /> {/if} {/each}
diff --git a/apps/ui/src/components/blockkit/blocks/InputBlock.svelte b/apps/ui/src/components/blockkit/blocks/InputBlock.svelte index 8b8d662..01a36c4 100644 --- a/apps/ui/src/components/blockkit/blocks/InputBlock.svelte +++ b/apps/ui/src/components/blockkit/blocks/InputBlock.svelte @@ -9,6 +9,8 @@ SlackEmailInputElement, SlackUrlInputElement, SlackRadioButtonsElement, + SlackDatePickerElement, + SlackTimePickerElement, SlackOption, UploadedFile, } from '../../../lib/types' @@ -21,6 +23,8 @@ import EmailInput from '../elements/EmailInput.svelte' import UrlInput from '../elements/UrlInput.svelte' import RadioButtonGroup from '../elements/RadioButtonGroup.svelte' + import DatePicker from '../elements/DatePicker.svelte' + import TimePicker from '../elements/TimePicker.svelte' interface Props { block: SlackInputBlock @@ -142,6 +146,20 @@ selectedOption={getSelectedOption(el.action_id)} onChange={(option) => onRadioChange?.(blockId, el.action_id, option)} /> + {:else if block.element.type === 'datepicker'} + {@const el = block.element as SlackDatePickerElement} + onInputChange?.(blockId, el.action_id, val)} + /> + {:else if block.element.type === 'timepicker'} + {@const el = block.element as SlackTimePickerElement} + onInputChange?.(blockId, el.action_id, val)} + /> {/if} {#if block.hint} diff --git a/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte b/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte index 3cc501d..061c827 100644 --- a/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte +++ b/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte @@ -5,6 +5,8 @@ SlackStaticSelectElement, SlackOverflowElement, SlackRadioButtonsElement, + SlackDatePickerElement, + SlackTimePickerElement, } from '../../../lib/types' import { renderMrkdwn } from '../context' import Button from '../elements/Button.svelte' @@ -12,6 +14,8 @@ import ImageElement from '../elements/ImageElement.svelte' import OverflowMenu from '../elements/OverflowMenu.svelte' import RadioButtonGroup from '../elements/RadioButtonGroup.svelte' + import DatePicker from '../elements/DatePicker.svelte' + import TimePicker from '../elements/TimePicker.svelte' interface Props { block: SlackSectionBlock @@ -71,6 +75,24 @@ onChange={(option) => onAction?.(radio.action_id, option.value)} />
+ {:else if block.accessory.type === 'datepicker'} + {@const dp = block.accessory as SlackDatePickerElement} +
+ onAction?.(dp.action_id, val)} + /> +
+ {:else if block.accessory.type === 'timepicker'} + {@const tp = block.accessory as SlackTimePickerElement} +
+ onAction?.(tp.action_id, val)} + /> +
{:else if block.accessory.type === 'image'} + selected_date?: string + selected_time?: string + selected_date_time?: number } ): Promise { try { diff --git a/packages/slack/src/server/web-api.ts b/packages/slack/src/server/web-api.ts index 5421a9c..0e1462a 100644 --- a/packages/slack/src/server/web-api.ts +++ b/packages/slack/src/server/web-api.ts @@ -1612,6 +1612,21 @@ export class SlackWebAPI { }, type: elementInfo.type, } + } else if (elementInfo.type === 'datepicker' && 'value' in val) { + result[blockId][actionId] = { + selected_date: (val.value as string) || null, + type: elementInfo.type, + } + } else if (elementInfo.type === 'timepicker' && 'value' in val) { + result[blockId][actionId] = { + selected_time: (val.value as string) || null, + type: elementInfo.type, + } + } else if (elementInfo.type === 'datetimepicker' && 'value' in val) { + result[blockId][actionId] = { + selected_date_time: val.value ? Number(val.value) : null, + type: elementInfo.type, + } } else { result[blockId][actionId] = { ...val, @@ -1668,6 +1683,10 @@ export class SlackWebAPI { text: { type: string; text: string } value: string }> + // Picker-specific values + selected_date?: string + selected_time?: string + selected_date_time?: number user: string }): Promise { const { action_id, user } = body @@ -1702,6 +1721,12 @@ export class SlackWebAPI { action.selected_option = body.selected_option } else if (elementType === 'checkboxes') { action.selected_options = body.selected_options + } else if (elementType === 'datepicker') { + action.selected_date = body.selected_date + } else if (elementType === 'timepicker') { + action.selected_time = body.selected_time + } else if (elementType === 'datetimepicker') { + action.selected_date_time = body.selected_date_time } else { // For other types, include whichever value fields are present if (body.value !== undefined) action.value = body.value From 8822f02dc5d8a8fb40c59559746ae791a22a20f7 Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sun, 15 Feb 2026 21:18:49 +0000 Subject: [PATCH 27/96] feat(04-02): add DateTimePicker composed Calendar + TimeField component - Compose bits-ui Calendar and TimeField in a single dropdown panel - Parse UNIX timestamps to CalendarDate + Time, combine on Done click - Fixed-position dropdown with click-outside/Escape dismissal - Support initial_date_time, confirm dialog, compact mode --- .../blockkit/elements/DateTimePicker.svelte | 291 ++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 apps/ui/src/components/blockkit/elements/DateTimePicker.svelte diff --git a/apps/ui/src/components/blockkit/elements/DateTimePicker.svelte b/apps/ui/src/components/blockkit/elements/DateTimePicker.svelte new file mode 100644 index 0000000..4b1c09e --- /dev/null +++ b/apps/ui/src/components/blockkit/elements/DateTimePicker.svelte @@ -0,0 +1,291 @@ + + + + +
+ + {#if isOpen} +
+
+ +
+ + {#snippet children({ months, weekdays })} + + + + + + + + + + {#each months as month (month.value.toString())} + + + + {#each weekdays as weekday (weekday)} + + {weekday} + + {/each} + + + + {#each month.weeks as weekDates (weekDates.map(d => d.toString()).join('-'))} + + {#each weekDates as date (date.toString())} + + {#snippet children({ selected, disabled })} + + {date.day} + + {/snippet} + + {/each} + + {/each} + + + {/each} + {/snippet} + +
+ + +
+ + +
+
Time
+ + + {#snippet children({ segments })} + {#each segments as { part, value: segValue } (part)} + {#if part === 'literal'} + {segValue} + {:else} + + {segValue} + + {/if} + {/each} + {/snippet} + + +
+ + +
+ +
+
+
+ {/if} +
+ +{#if showingConfirm && element.confirm} + +{/if} From 02354380afe3fd316768673abbd350483f3af45b Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sun, 15 Feb 2026 21:20:06 +0000 Subject: [PATCH 28/96] feat(04-02): wire DateTimePicker into actions blocks, input blocks, and message dispatch - Add datetimepicker branch to ActionsBlock and InputBlock - Add selected_date_time (number) to Message.svelte buildActionValue - Update resolveActionFromBlocks return type with selected_date_time - Add SlackDateTimePickerElement to SlackBlockElement union type --- apps/ui/src/components/Message.svelte | 9 +++++++++ .../src/components/blockkit/blocks/ActionsBlock.svelte | 9 +++++++++ apps/ui/src/components/blockkit/blocks/InputBlock.svelte | 9 +++++++++ apps/ui/src/lib/types.ts | 1 + 4 files changed, 28 insertions(+) diff --git a/apps/ui/src/components/Message.svelte b/apps/ui/src/components/Message.svelte index 4870c95..843c660 100644 --- a/apps/ui/src/components/Message.svelte +++ b/apps/ui/src/components/Message.svelte @@ -156,6 +156,14 @@ } } + if (elementType === 'datetimepicker') { + return { + blockId, + elementType, + actionValue: { selected_date_time: Number(value) }, + } + } + // For buttons and other types, use value as-is return { blockId, elementType, actionValue: { value } } } @@ -179,6 +187,7 @@ }> selected_date?: string selected_time?: string + selected_date_time?: number } } { for (let i = 0; i < blocks.length; i++) { diff --git a/apps/ui/src/components/blockkit/blocks/ActionsBlock.svelte b/apps/ui/src/components/blockkit/blocks/ActionsBlock.svelte index a692c15..523d7e3 100644 --- a/apps/ui/src/components/blockkit/blocks/ActionsBlock.svelte +++ b/apps/ui/src/components/blockkit/blocks/ActionsBlock.svelte @@ -7,6 +7,7 @@ SlackRadioButtonsElement, SlackDatePickerElement, SlackTimePickerElement, + SlackDateTimePickerElement, } from '../../../lib/types' import Button from '../elements/Button.svelte' import StaticSelect from '../elements/StaticSelect.svelte' @@ -14,6 +15,7 @@ import RadioButtonGroup from '../elements/RadioButtonGroup.svelte' import DatePicker from '../elements/DatePicker.svelte' import TimePicker from '../elements/TimePicker.svelte' + import DateTimePicker from '../elements/DateTimePicker.svelte' interface Props { block: SlackActionsBlock @@ -60,6 +62,13 @@ compact onChange={(val) => onAction?.(tp.action_id, val)} /> + {:else if element.type === 'datetimepicker'} + {@const dtp = element as SlackDateTimePickerElement} + onAction?.(dtp.action_id, val)} + /> {/if} {/each} diff --git a/apps/ui/src/components/blockkit/blocks/InputBlock.svelte b/apps/ui/src/components/blockkit/blocks/InputBlock.svelte index 01a36c4..cf50e4d 100644 --- a/apps/ui/src/components/blockkit/blocks/InputBlock.svelte +++ b/apps/ui/src/components/blockkit/blocks/InputBlock.svelte @@ -11,6 +11,7 @@ SlackRadioButtonsElement, SlackDatePickerElement, SlackTimePickerElement, + SlackDateTimePickerElement, SlackOption, UploadedFile, } from '../../../lib/types' @@ -25,6 +26,7 @@ import RadioButtonGroup from '../elements/RadioButtonGroup.svelte' import DatePicker from '../elements/DatePicker.svelte' import TimePicker from '../elements/TimePicker.svelte' + import DateTimePicker from '../elements/DateTimePicker.svelte' interface Props { block: SlackInputBlock @@ -160,6 +162,13 @@ value={getInputValue(el.action_id)} onChange={(val) => onInputChange?.(blockId, el.action_id, val)} /> + {:else if block.element.type === 'datetimepicker'} + {@const el = block.element as SlackDateTimePickerElement} + onInputChange?.(blockId, el.action_id, val)} + /> {/if} {#if block.hint} diff --git a/apps/ui/src/lib/types.ts b/apps/ui/src/lib/types.ts index e57209f..d6ceb97 100644 --- a/apps/ui/src/lib/types.ts +++ b/apps/ui/src/lib/types.ts @@ -162,6 +162,7 @@ export type SlackBlockElement = | SlackRadioButtonsElement | SlackDatePickerElement | SlackTimePickerElement + | SlackDateTimePickerElement export interface SlackButtonElement { type: 'button' From d0479c58e1c9df6523a7ff708746679e65518b5b Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sun, 15 Feb 2026 22:18:16 +0000 Subject: [PATCH 29/96] fix(04-03): fix calendar dropdown clipping and add compact sizing - Add explicit width and horizontal overflow detection to DatePicker positionMenu() - Add explicit width and horizontal overflow detection to DateTimePicker positionMenu() - Add max-w-[250px] to StaticSelect in compact mode - Add items-start alignment to ActionsBlock flex container --- .../components/blockkit/blocks/ActionsBlock.svelte | 2 +- .../components/blockkit/elements/DatePicker.svelte | 12 ++++++++++-- .../blockkit/elements/DateTimePicker.svelte | 14 +++++++++++--- .../blockkit/elements/StaticSelect.svelte | 2 +- 4 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/ui/src/components/blockkit/blocks/ActionsBlock.svelte b/apps/ui/src/components/blockkit/blocks/ActionsBlock.svelte index 523d7e3..9e61896 100644 --- a/apps/ui/src/components/blockkit/blocks/ActionsBlock.svelte +++ b/apps/ui/src/components/blockkit/blocks/ActionsBlock.svelte @@ -25,7 +25,7 @@ let { block, onAction }: Props = $props() -
+
{#each block.elements as element, i (i)} {#if element.type === 'button'}
{#if showingConfirm && element.confirm} - + {/if} diff --git a/apps/ui/src/components/blockkit/elements/OverflowMenu.svelte b/apps/ui/src/components/blockkit/elements/OverflowMenu.svelte index c136ea1..00786d1 100644 --- a/apps/ui/src/components/blockkit/elements/OverflowMenu.svelte +++ b/apps/ui/src/components/blockkit/elements/OverflowMenu.svelte @@ -1,5 +1,5 @@ {#if showingConfirm && element.confirm} - + {/if} From 8972e9ca8ccfd2cbc0a90c79ebd7b722c4d28be4 Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Wed, 18 Feb 2026 12:14:57 +0000 Subject: [PATCH 54/96] feat: add kitchen sink showcase message and fix newsletter action_ids Add 10-kitchen-sink.json with mixed rich text, actions, selects, and feedback elements. Add missing action_ids to newsletter template buttons to prevent Slack validation errors. --- .../blocks/09-template-newsletter.json | 15 +- .../src/messages/blocks/10-kitchen-sink.json | 467 ++++++++++++++++++ 2 files changed, 477 insertions(+), 5 deletions(-) create mode 100644 apps/showcase-bot/src/messages/blocks/10-kitchen-sink.json diff --git a/apps/showcase-bot/src/messages/blocks/09-template-newsletter.json b/apps/showcase-bot/src/messages/blocks/09-template-newsletter.json index ac6f9e1..9959349 100644 --- a/apps/showcase-bot/src/messages/blocks/09-template-newsletter.json +++ b/apps/showcase-bot/src/messages/blocks/09-template-newsletter.json @@ -38,7 +38,8 @@ "type": "plain_text", "text": "Watch Now", "emoji": true - } + }, + "action_id": "showcase_newsletter_watch" } }, { @@ -70,7 +71,8 @@ "type": "plain_text", "text": "RSVP", "emoji": true - } + }, + "action_id": "showcase_newsletter_rsvp_retreat" } }, { @@ -85,7 +87,8 @@ "type": "plain_text", "text": "Learn More", "emoji": true - } + }, + "action_id": "showcase_newsletter_learn_more" } }, { @@ -100,7 +103,8 @@ "type": "plain_text", "text": "RSVP", "emoji": true - } + }, + "action_id": "showcase_newsletter_rsvp_pretzel" } }, { @@ -125,7 +129,8 @@ "type": "plain_text", "text": "Watch Recording", "emoji": true - } + }, + "action_id": "showcase_newsletter_recording" } }, { diff --git a/apps/showcase-bot/src/messages/blocks/10-kitchen-sink.json b/apps/showcase-bot/src/messages/blocks/10-kitchen-sink.json new file mode 100644 index 0000000..3fdbb43 --- /dev/null +++ b/apps/showcase-bot/src/messages/blocks/10-kitchen-sink.json @@ -0,0 +1,467 @@ +{ + "blocks": [ + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Hello there, I am a basic rich text block!" + } + ] + } + ] + }, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Hello there, " + }, + { + "type": "text", + "text": "I am a bold rich text block!", + "style": { + "bold": true + } + } + ] + } + ] + }, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Hello there, " + }, + { + "type": "text", + "text": "I am a strikethrough rich text block!", + "style": { + "strike": true + } + } + ] + } + ] + }, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "emoji", + "name": "basketball" + }, + { + "type": "text", + "text": " " + }, + { + "type": "emoji", + "name": "snowboarder" + }, + { + "type": "text", + "text": " " + }, + { + "type": "emoji", + "name": "checkered_flag" + } + ] + } + ] + }, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Basic bullet list with rich elements\n" + } + ] + }, + { + "type": "rich_text_list", + "style": "bullet", + "indent": 0, + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "item 1: " + }, + { + "type": "emoji", + "name": "basketball" + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "item 2: " + }, + { + "type": "text", + "text": "this is a list item" + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "item 3: " + }, + { + "type": "link", + "url": "https://example.com/", + "text": "with a link", + "style": { + "bold": true + } + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "item 4: " + }, + { + "type": "text", + "text": "we are near the end" + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "item 5: " + }, + { + "type": "text", + "text": "this is the end" + } + ] + } + ] + } + ] + }, + { + "type": "rich_text", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "Check out these different block types with paragraph breaks between them:\n\n" + } + ] + }, + { + "type": "rich_text_preformatted", + "elements": [ + { + "type": "text", + "text": "Hello there, I am preformatted block!\n\nI can have multiple paragraph breaks within the block." + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "\nI am rich text with a paragraph break following preformatted text. \n\nI can have multiple paragraph breaks within the block.\n\n" + } + ] + }, + { + "type": "rich_text_quote", + "elements": [ + { + "type": "text", + "text": "I am a basic rich text quote, \n\nI can have multiple paragraph breaks within the block." + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "\nI am rich text with a paragraph after the quote block\n\n" + } + ] + }, + { + "type": "rich_text_quote", + "elements": [ + { + "type": "text", + "text": "I am a basic quote block following rich text" + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "\n" + } + ] + }, + { + "type": "rich_text_preformatted", + "elements": [ + { + "type": "text", + "text": "I am more preformatted text following a quote block" + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "\n" + } + ] + }, + { + "type": "rich_text_quote", + "elements": [ + { + "type": "text", + "text": "I am a basic quote block following preformatted text" + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "\n" + } + ] + }, + { + "type": "rich_text_list", + "style": "bullet", + "indent": 0, + "elements": [ + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "list item one" + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "list item two" + } + ] + } + ] + }, + { + "type": "rich_text_section", + "elements": [ + { + "type": "text", + "text": "\nI am rich text with a paragraph break after a list" + } + ] + } + ] + }, + { + "type": "context_actions", + "elements": [ + { + "type": "feedback_buttons", + "action_id": "showcase_feedback", + "positive_button": { + "text": { + "type": "plain_text", + "text": "Good Response" + }, + "value": "positive" + }, + "negative_button": { + "text": { + "type": "plain_text", + "text": "Bad Response" + }, + "value": "negative" + } + }, + { + "type": "icon_button", + "action_id": "showcase_remove", + "icon": "trash", + "text": { + "type": "plain_text", + "text": "Remove" + } + } + ] + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "Click Me", + "emoji": true + }, + "value": "click_me_123", + "action_id": "showcase_kitchen_sink_0" + } + ] + }, + { + "type": "actions", + "elements": [ + { + "type": "conversations_select", + "placeholder": { + "type": "plain_text", + "text": "Select a conversation", + "emoji": true + }, + "initial_conversation": "G12345678", + "action_id": "showcase_kitchen_sink_0" + }, + { + "type": "users_select", + "placeholder": { + "type": "plain_text", + "text": "Select a user", + "emoji": true + }, + "initial_user": "U12345678", + "action_id": "showcase_kitchen_sink_1" + }, + { + "type": "channels_select", + "placeholder": { + "type": "plain_text", + "text": "Select a channel", + "emoji": true + }, + "initial_channel": "C12345678", + "action_id": "showcase_kitchen_sink_2" + } + ] + }, + { + "type": "actions", + "elements": [ + { + "type": "conversations_select", + "placeholder": { + "type": "plain_text", + "text": "Select a conversation", + "emoji": true + }, + "action_id": "showcase_kitchen_sink_0" + }, + { + "type": "channels_select", + "placeholder": { + "type": "plain_text", + "text": "Select a channel", + "emoji": true + }, + "action_id": "showcase_kitchen_sink_1" + }, + { + "type": "users_select", + "placeholder": { + "type": "plain_text", + "text": "Select a user", + "emoji": true + }, + "action_id": "showcase_kitchen_sink_2" + }, + { + "type": "static_select", + "placeholder": { + "type": "plain_text", + "text": "Select an item", + "emoji": true + }, + "options": [ + { + "text": { + "type": "plain_text", + "text": "*plain_text option 0*", + "emoji": true + }, + "value": "value-0" + }, + { + "text": { + "type": "plain_text", + "text": "*plain_text option 1*", + "emoji": true + }, + "value": "value-1" + }, + { + "text": { + "type": "plain_text", + "text": "*plain_text option 2*", + "emoji": true + }, + "value": "value-2" + } + ], + "action_id": "showcase_kitchen_sink_3" + } + ] + } + ] +} From b91fb5652a1a74906bb6cd15d267b67eda0bbe11 Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Wed, 18 Feb 2026 12:15:13 +0000 Subject: [PATCH 55/96] feat: send help text to bot DM on startup and add showcase-bot README Post HELP_TEXT to the bot's DM channel on simulator startup so the conversation isn't empty on first open. Add comprehensive README documenting block samples, slash commands, and project structure. --- apps/showcase-bot/README.md | 95 +++++++++++++++++++ apps/showcase-bot/src/app.ts | 13 ++- .../src/listeners/commands/showcase.ts | 2 +- 3 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 apps/showcase-bot/README.md diff --git a/apps/showcase-bot/README.md b/apps/showcase-bot/README.md new file mode 100644 index 0000000..acd009a --- /dev/null +++ b/apps/showcase-bot/README.md @@ -0,0 +1,95 @@ +# Showcase Bot + +A Slack bot that populates a channel with [Block Kit](https://api.slack.com/block-kit) examples and echoes back interactive element payloads. Built with [@slack/bolt](https://slack.dev/bolt-js/) and [Bun](https://bun.sh). + +## How it works + +On startup (in simulator mode), the bot automatically posts a series of Block Kit messages to the `#showcase` channel. These messages demonstrate various block types and interactive elements. When a user interacts with any element, the bot responds with the raw action payload so you can inspect exactly what Slack sends. + +### Block Kit samples + +Message definitions live in `src/messages/blocks/` as numbered JSON files, loaded and posted in order: + +| # | File | What it shows | +| --- | ---------------------------- | -------------------------------------------------------- | +| 01 | `text-and-layout.json` | Headers, sections, dividers, context, images | +| 02 | `button-variations.json` | Primary, danger, and default buttons | +| 03 | `selection-elements.json` | Static selects, multi-selects, overflow menus | +| 04 | `radio-and-checkboxes.json` | Radio buttons and checkbox groups | +| 05 | `date-and-time-pickers.json` | Date pickers, time pickers, datetime pickers | +| 06 | `section-accessories.json` | Buttons, selects, and overflows as section accessories | +| 07 | `combined-actions.json` | Multiple interactive elements in a single actions block | +| 08 | `rich-text.json` | Rich text with formatting, lists, quotes, code blocks | +| 09 | `template-newsletter.json` | A realistic newsletter-style message template | +| 10 | `kitchen-sink.json` | Mixed rich text, actions, selects, and feedback elements | + +JSON files support template variables (`{{TODAY_DATE}}`, `{{TODAY_NOON_UNIX}}`) that are resolved at load time. + +You can try pasting examples from Slack's [Block Kit Builder](https://app.slack.com/block-kit-builder) to show them in the emulator. + +### Action responses + +All interactive elements use action IDs prefixed with `showcase_`. A single catch-all handler matches this prefix: + +``` +app.action(/^showcase_/, ...) +``` + +When triggered, the handler acknowledges the action and posts a reply containing the action type, action ID, and the full JSON payload. This makes it easy to see exactly what data each interactive element produces. + +Modal submissions (callback ID `showcase_modal`) are handled the same way -- the bot posts the `view.state.values` back to the channel. + +### Slash command + +The bot registers a `/showcase` command with the following subcommands: + +| Subcommand | Description | +| ---------- | -------------------------------------------------------- | +| `generate` | Clear and re-populate `#showcase` with all block samples | +| `clear` | Remove all messages from `#showcase` | +| `modal` | Open a modal with various input elements | +| `help` | Show available subcommands | + +## Running + +```sh +# With the Botarium simulator +bun run dev:local + +# With real Slack credentials +SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... bun run dev +``` + +### Environment variables + +| Variable | Required | Description | +| ---------------------- | ---------------- | --------------------------------------------------------------- | +| `SLACK_BOT_TOKEN` | Yes (production) | Bot user OAuth token | +| `SLACK_APP_TOKEN` | Yes (production) | App-level token for Socket Mode | +| `SLACK_SIGNING_SECRET` | Yes (production) | Signing secret for request verification | +| `SLACK_API_URL` | No | Set automatically in simulator mode | +| `BOT_NAME` | No | Display name (default: `Showcase Bot`) | +| `PORT` | No | Server port (default: `3000`) | +| `LOG_LEVEL` | No | `silent`, `debug`, `info`, `warn`, or `error` (default: `info`) | + +In simulator mode, token and secret values are generated automatically. + +## Project structure + +``` +src/ + app.ts # Entry point, Bolt app setup, startup sequence + settings.ts # Environment variable validation (zod) + config/ + loader.ts # Bot configuration loader + http-server.ts # Config server for simulator registration + listeners/ + index.ts # Registers commands and actions + commands/showcase.ts # /showcase command and message posting + actions/showcase-actions.ts # block_actions and view_submission handlers + messages/ + showcase-messages.ts # Loads and templates block JSON files + blocks/*.json # Block Kit message definitions + utils/ + logger.ts # Pino logger setup +``` diff --git a/apps/showcase-bot/src/app.ts b/apps/showcase-bot/src/app.ts index dd22f8c..b9de977 100644 --- a/apps/showcase-bot/src/app.ts +++ b/apps/showcase-bot/src/app.ts @@ -1,6 +1,6 @@ import { App, LogLevel } from '@slack/bolt' import { registerListeners } from './listeners/index' -import { sendShowcaseMessages } from './listeners/commands/showcase' +import { sendShowcaseMessages, HELP_TEXT } from './listeners/commands/showcase' import { settings, isSimulatorMode } from './settings' import { startConfigServer } from './config/http-server' import { slackConfig, config } from './config/loader' @@ -226,6 +226,17 @@ async function main() { } catch (err) { slackLogger.error({ err }, 'Failed to populate showcase channel') } + + // Send help text to bot DM so it's not empty on first open + const dmChannel = `D_${config.simulator.id}` + try { + await app.client.chat.postMessage({ + channel: dmChannel, + text: HELP_TEXT, + }) + } catch (err) { + slackLogger.error({ err }, 'Failed to send DM help message') + } } } diff --git a/apps/showcase-bot/src/listeners/commands/showcase.ts b/apps/showcase-bot/src/listeners/commands/showcase.ts index db7026f..28ca157 100644 --- a/apps/showcase-bot/src/listeners/commands/showcase.ts +++ b/apps/showcase-bot/src/listeners/commands/showcase.ts @@ -53,7 +53,7 @@ export async function sendShowcaseMessages(client: App['client']) { ) } -const HELP_TEXT = [ +export const HELP_TEXT = [ '*/showcase* commands:', '- `/showcase generate` -- Populate #showcase with Block Kit examples', '- `/showcase clear` -- Clear all messages from #showcase', From a80f79b9aeca964cbf8b92cc63eebb0ff167b080 Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Wed, 18 Feb 2026 12:23:50 +0000 Subject: [PATCH 56/96] feat(quick-4): thread onImagePreview callback through Block Kit components - Add onImagePreview prop to ImageElement, ImageBlock, SectionBlock, ContextBlock, BlockKitRenderer - Wrap lg/accessory images in clickable button with cursor-zoom-in - Pass callback from Message through BlockKitRenderer to all image-containing blocks --- apps/ui/src/components/Message.svelte | 1 + .../blockkit/BlockKitRenderer.svelte | 8 +++--- .../blockkit/blocks/ContextBlock.svelte | 5 ++-- .../blockkit/blocks/ImageBlock.svelte | 27 ++++++++++++++----- .../blockkit/blocks/SectionBlock.svelte | 4 ++- .../blockkit/elements/ImageElement.svelte | 19 +++++++++++-- 6 files changed, 50 insertions(+), 14 deletions(-) diff --git a/apps/ui/src/components/Message.svelte b/apps/ui/src/components/Message.svelte index 1f06e4c..f64763b 100644 --- a/apps/ui/src/components/Message.svelte +++ b/apps/ui/src/components/Message.svelte @@ -368,6 +368,7 @@
{:else} diff --git a/apps/ui/src/components/blockkit/BlockKitRenderer.svelte b/apps/ui/src/components/blockkit/BlockKitRenderer.svelte index 16797dc..b4fc52d 100644 --- a/apps/ui/src/components/blockkit/BlockKitRenderer.svelte +++ b/apps/ui/src/components/blockkit/BlockKitRenderer.svelte @@ -44,6 +44,7 @@ actionId: string, option: SlackOption ) => void + onImagePreview?: (imageUrl: string, imageAlt: string) => void } let { @@ -55,6 +56,7 @@ onFileChange, onCheckboxChange, onRadioChange, + onImagePreview, }: Props = $props() function getBlockId(block: SlackBlock, index: number): string { @@ -65,7 +67,7 @@
{#each blocks as block, index (getBlockId(block, index))} {#if block.type === 'section'} - + {:else if block.type === 'input'} {:else if block.type === 'context'} - + {:else if block.type === 'image'} - + {:else if block.type === 'header'} {:else if block.type === 'rich_text'} diff --git a/apps/ui/src/components/blockkit/blocks/ContextBlock.svelte b/apps/ui/src/components/blockkit/blocks/ContextBlock.svelte index 8cd27a1..c9f83df 100644 --- a/apps/ui/src/components/blockkit/blocks/ContextBlock.svelte +++ b/apps/ui/src/components/blockkit/blocks/ContextBlock.svelte @@ -8,9 +8,10 @@ interface Props { block: SlackContextBlock + onImagePreview?: (imageUrl: string, imageAlt: string) => void } - let { block }: Props = $props() + let { block, onImagePreview }: Props = $props()
{@html renderMrkdwn(el as SlackViewTextObject)} {:else if el.type === 'image'} - + {/if} {/each}
diff --git a/apps/ui/src/components/blockkit/blocks/ImageBlock.svelte b/apps/ui/src/components/blockkit/blocks/ImageBlock.svelte index f7e05c4..ff6db5b 100644 --- a/apps/ui/src/components/blockkit/blocks/ImageBlock.svelte +++ b/apps/ui/src/components/blockkit/blocks/ImageBlock.svelte @@ -5,9 +5,10 @@ interface Props { block: SlackImageBlock + onImagePreview?: (imageUrl: string, imageAlt: string) => void } - let { block }: Props = $props() + let { block, onImagePreview }: Props = $props() let collapsed = $state(false) let imageSize = $state(null) @@ -46,10 +47,24 @@ {#if !collapsed} - {block.alt_text} + {#if onImagePreview} + + {:else} + {block.alt_text} + {/if} {/if}
diff --git a/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte b/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte index 202e7e1..63c4574 100644 --- a/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte +++ b/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte @@ -22,9 +22,10 @@ interface Props { block: SlackSectionBlock onAction?: (actionId: string, value: string) => void + onImagePreview?: (imageUrl: string, imageAlt: string) => void } - let { block, onAction }: Props = $props() + let { block, onAction, onImagePreview }: Props = $props() const isStackedAccessory = $derived( block.accessory?.type === 'radio_buttons' || @@ -98,6 +99,7 @@ imageUrl={block.accessory.image_url} altText={block.accessory.alt_text} size="accessory" + {onImagePreview} /> {/if} {/if} diff --git a/apps/ui/src/components/blockkit/elements/ImageElement.svelte b/apps/ui/src/components/blockkit/elements/ImageElement.svelte index 0bcd028..87abef4 100644 --- a/apps/ui/src/components/blockkit/elements/ImageElement.svelte +++ b/apps/ui/src/components/blockkit/elements/ImageElement.svelte @@ -4,9 +4,10 @@ altText: string /** Size variant: sm (16px), md (40px), lg (full width) */ size?: 'sm' | 'md' | 'accessory' | 'lg' + onImagePreview?: (imageUrl: string, imageAlt: string) => void } - let { imageUrl, altText, size = 'lg' }: Props = $props() + let { imageUrl, altText, size = 'lg', onImagePreview }: Props = $props() const sizeClasses = { sm: 'size-4 rounded', @@ -14,6 +15,20 @@ accessory: 'rounded object-cover size-[90px]', lg: 'max-w-full rounded-lg', } + + const isClickable = $derived( + onImagePreview && (size === 'lg' || size === 'accessory') + ) -{altText} +{#if isClickable} + +{:else} + {altText} +{/if} From f90a4ff2143e948f36240d234518073ff51b73c9 Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Wed, 18 Feb 2026 12:24:56 +0000 Subject: [PATCH 57/96] feat(quick-4): add user name to image preview modal and pass through callback chain - Expand previewImage state and handleImagePreview to carry userName - Display poster name in top-left corner of ImagePreviewModal - Wrap BlockKitRenderer onImagePreview to inject displayName from message - Update onImagePreview type signature across MessagePanel and ThreadPanel --- apps/ui/src/components/App.svelte | 7 ++++--- apps/ui/src/components/ImagePreviewModal.svelte | 10 +++++++++- apps/ui/src/components/Message.svelte | 7 ++++--- apps/ui/src/components/MessagePanel.svelte | 2 +- apps/ui/src/components/ThreadPanel.svelte | 2 +- 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/apps/ui/src/components/App.svelte b/apps/ui/src/components/App.svelte index 83ba0a8..7dc6fa8 100644 --- a/apps/ui/src/components/App.svelte +++ b/apps/ui/src/components/App.svelte @@ -45,7 +45,7 @@ let logPanelVisible = $state(false) let rhsTab = $state<'thread' | 'logs'>('thread') let mainInputValue = $state('') - let previewImage = $state<{ url: string; alt: string } | null>(null) + let previewImage = $state<{ url: string; alt: string; userName?: string } | null>(null) // Derive active thread from centralized state (synced with URL) let activeThreadTs = $derived(simulatorState.currentThreadTs) @@ -176,8 +176,8 @@ rhsTab = tabId as 'thread' | 'logs' } - function handleImagePreview(url: string, alt: string) { - previewImage = { url, alt } + function handleImagePreview(url: string, alt: string, userName?: string) { + previewImage = { url, alt, userName } } function handleCloseImagePreview() { @@ -320,6 +320,7 @@ {/if} diff --git a/apps/ui/src/components/ImagePreviewModal.svelte b/apps/ui/src/components/ImagePreviewModal.svelte index 16315c9..5917643 100644 --- a/apps/ui/src/components/ImagePreviewModal.svelte +++ b/apps/ui/src/components/ImagePreviewModal.svelte @@ -8,10 +8,11 @@ interface Props { imageUrl: string imageAlt?: string + userName?: string onClose: () => void } - let { imageUrl, imageAlt = '', onClose }: Props = $props() + let { imageUrl, imageAlt = '', userName, onClose }: Props = $props() let isZoomed = $state(false) let panPosition = $state({ x: 0, y: 0 }) @@ -181,6 +182,13 @@ onwheel={handleWheel} aria-modal="true" > + + {#if userName} +
+ {userName} +
+ {/if} +