From e67f0daf2c2ded776bb5fa7cdcca985d8e22f0f3 Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sat, 21 Feb 2026 17:57:38 +0000 Subject: [PATCH 01/22] feat(06-01): add icon fields to SlackAppConfig and getBotForShortcut to state - Add optional icon_emoji and icon_url fields to SlackAppConfig.app - Add getBotForShortcut(callbackId) method to EmulatorState for resolving shortcut ownership --- packages/slack/src/server/state.ts | 13 +++++++++++++ packages/slack/src/server/types.ts | 2 ++ 2 files changed, 15 insertions(+) diff --git a/packages/slack/src/server/state.ts b/packages/slack/src/server/state.ts index 8d0f699..3b30f28 100644 --- a/packages/slack/src/server/state.ts +++ b/packages/slack/src/server/state.ts @@ -925,6 +925,19 @@ export class EmulatorState { return undefined } + /** + * Find the connected bot that registered a given shortcut by callback_id + */ + getBotForShortcut(callbackId: string): ConnectedBot | undefined { + for (const bot of this.connectedBots.values()) { + if (bot.status !== 'connected') continue + if (bot.appConfig.shortcuts?.some((s) => s.callback_id === callbackId)) { + return bot + } + } + return undefined + } + // ========================================================================== // View / Modal Operations // ========================================================================== diff --git a/packages/slack/src/server/types.ts b/packages/slack/src/server/types.ts index 8330729..c90f579 100644 --- a/packages/slack/src/server/types.ts +++ b/packages/slack/src/server/types.ts @@ -89,6 +89,8 @@ export interface SlackAppConfig { description?: string id?: string // Bot identifier in simulator (from config.yaml simulator.id) configPort?: number // Port for bot's config HTTP server (usually bot port + 1) + icon_emoji?: string // Bot icon as emoji (e.g., ':robot_face:') + icon_url?: string // Bot icon as image URL } commands: SlashCommandDefinition[] shortcuts: ShortcutDefinition[] From 04178e6c1074b9471f79bf118936bd3d80178ceb Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sat, 21 Feb 2026 17:58:53 +0000 Subject: [PATCH 02/22] feat(06-01): extend getConnectedBots API and make dispatchShortcut targeted - Return full shortcut arrays and icon fields (iconEmoji, iconUrl) per bot in getConnectedBots - Resolve owning bot by callback_id via getBotForShortcut before dispatching shortcuts - Add optional targetBotId parameter to dispatchShortcut with broadcast fallback --- packages/slack/src/server/socket-mode.ts | 27 ++++++++++++++++-- packages/slack/src/server/web-api.ts | 36 +++++++++++++++--------- 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/packages/slack/src/server/socket-mode.ts b/packages/slack/src/server/socket-mode.ts index f9b4279..2075570 100644 --- a/packages/slack/src/server/socket-mode.ts +++ b/packages/slack/src/server/socket-mode.ts @@ -677,7 +677,10 @@ export class SocketModeServer { } } - async dispatchShortcut(payload: MessageShortcutPayload): Promise { + async dispatchShortcut( + payload: MessageShortcutPayload, + targetBotId?: string + ): Promise { if (this.connections.size === 0) { socketModeLogger.warn( `No bots connected, shortcut not dispatched: ${payload.callback_id}` @@ -699,7 +702,27 @@ export class SocketModeServer { const message = JSON.stringify(envelope) - // Send to all connected bots + // Targeted dispatch: send only to the bot that owns the shortcut + if (targetBotId) { + const bot = this.state.getBot(targetBotId) + if (!bot || bot.status !== 'connected') { + socketModeLogger.warn( + { targetBotId }, + 'Target bot not found or not connected for shortcut dispatch' + ) + return + } + const conn = this.connections.get(bot.connectionId) + if (conn) { + await this.sendWithAck(conn, envelope.envelope_id, message) + socketModeLogger.debug( + `Shortcut dispatched to bot ${targetBotId}: ${payload.callback_id}` + ) + } + return + } + + // Fallback: broadcast to all connected bots const sendPromises: Promise[] = [] for (const conn of this.connections.values()) { sendPromises.push(this.sendWithAck(conn, envelope.envelope_id, message)) diff --git a/packages/slack/src/server/web-api.ts b/packages/slack/src/server/web-api.ts index 495370a..120c2f7 100644 --- a/packages/slack/src/server/web-api.ts +++ b/packages/slack/src/server/web-api.ts @@ -1407,7 +1407,9 @@ export class SlackWebAPI { connectedAt: bot.connectedAt.toISOString(), status: bot.status, commands: bot.appConfig.commands?.length ?? 0, - shortcuts: bot.appConfig.shortcuts?.length ?? 0, + shortcuts: bot.appConfig.shortcuts ?? [], + iconEmoji: bot.appConfig.app.icon_emoji, + iconUrl: bot.appConfig.app.icon_url, configPort: bot.appConfig.app.configPort, })) return Response.json({ ok: true, bots }, { headers: corsHeaders() }) @@ -1955,24 +1957,30 @@ export class SlackWebAPI { userName: user_name, }) + // Resolve the owning bot by callback_id for targeted dispatch + const targetBot = this.state.getBotForShortcut(callback_id) + webApiLogger.info( - { callback_id, triggerId, channel }, + { callback_id, triggerId, channel, targetBot: targetBot?.id }, 'Dispatching message shortcut' ) - // Dispatch shortcut to connected bots - await this.socketMode.dispatchShortcut({ - type: 'shortcut', - callback_id, - trigger_id: triggerId, - message: { - ts: message.ts, - text: message.text, - files: message.files, + // Dispatch shortcut to the owning bot (or broadcast as fallback) + await this.socketMode.dispatchShortcut( + { + type: 'shortcut', + callback_id, + trigger_id: triggerId, + message: { + ts: message.ts, + text: message.text, + files: message.files, + }, + channel: { id: channel }, + user: { id: user, username: user_name || 'simulator_user' }, }, - channel: { id: channel }, - user: { id: user, username: user_name || 'simulator_user' }, - }) + targetBot?.id + ) return Response.json( { ok: true, trigger_id: triggerId }, From dd5ccc51b10148549ad200491ad2bd5e064df449 Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sat, 21 Feb 2026 18:03:18 +0000 Subject: [PATCH 03/22] feat(06-02): extend types and add shortcut aggregation for multi-bot context menu - Change ConnectedBotInfo.shortcuts from number to Shortcut[] with iconEmoji/iconUrl fields - Replace getMessageShortcut() with getAllMessageShortcuts() grouped by bot - Export BotShortcutGroup type from barrel file - Update SSE handler and bot type to pass full shortcut data and icon fields --- apps/ui/src/lib/dispatcher.svelte.ts | 18 +++++++++++--- apps/ui/src/lib/state.svelte.ts | 4 ++-- apps/ui/src/lib/state/bots.svelte.ts | 35 ++++++++++++++++++---------- apps/ui/src/lib/types.ts | 4 +++- 4 files changed, 43 insertions(+), 18 deletions(-) diff --git a/apps/ui/src/lib/dispatcher.svelte.ts b/apps/ui/src/lib/dispatcher.svelte.ts index d7cded0..e0ce796 100644 --- a/apps/ui/src/lib/dispatcher.svelte.ts +++ b/apps/ui/src/lib/dispatcher.svelte.ts @@ -291,9 +291,19 @@ function handleSSEEvent(event: { bot?: { id: string appConfig: { - app: { name: string; description?: string } + app: { + name: string + description?: string + icon_emoji?: string + icon_url?: string + } commands?: Array<{ command: string; description: string }> - shortcuts?: Array<{ callback_id: string; name: string }> + shortcuts?: Array<{ + callback_id: string + name: string + description: string + type: 'message' | 'global' + }> } connectedAt: string status: 'connecting' | 'connected' | 'disconnected' @@ -420,7 +430,9 @@ function handleSSEEvent(event: { connectedAt: event.bot.connectedAt, status: event.bot.status, commands: event.bot.appConfig.commands?.length ?? 0, - shortcuts: event.bot.appConfig.shortcuts?.length ?? 0, + shortcuts: event.bot.appConfig.shortcuts ?? [], + iconEmoji: event.bot.appConfig.app.icon_emoji, + iconUrl: event.bot.appConfig.app.icon_url, } addConnectedBot(botInfo) // Also update app config and commands when a bot connects diff --git a/apps/ui/src/lib/state.svelte.ts b/apps/ui/src/lib/state.svelte.ts index 315636b..7a8af4c 100644 --- a/apps/ui/src/lib/state.svelte.ts +++ b/apps/ui/src/lib/state.svelte.ts @@ -103,8 +103,7 @@ export { setAvailableCommands, getFilteredCommands, setAppConfig, - getShortcut, - getMessageShortcut, + getAllMessageShortcuts, showModal, updateModal, closeModal, @@ -114,3 +113,4 @@ export { isBotUserId, getBotByUserId, } from './state/bots.svelte' +export type { BotShortcutGroup } from './state/bots.svelte' diff --git a/apps/ui/src/lib/state/bots.svelte.ts b/apps/ui/src/lib/state/bots.svelte.ts index 23c2e80..7e5b8de 100644 --- a/apps/ui/src/lib/state/bots.svelte.ts +++ b/apps/ui/src/lib/state/bots.svelte.ts @@ -5,6 +5,7 @@ import type { SlashCommand, + Shortcut, SlackView, SlackAppConfig, ConnectedBotInfo, @@ -44,20 +45,30 @@ export function setAppConfig(config: SlackAppConfig | null): void { } } -// Get shortcut by callback_id -export function getShortcut( - callbackId: string -): SlackAppConfig['shortcuts'][number] | undefined { - return simulatorState.appConfig?.shortcuts.find( - (s) => s.callback_id === callbackId - ) +// Shortcut group for context menu display +export interface BotShortcutGroup { + botId: string + botName: string + botIcon?: string + shortcuts: Shortcut[] } -// Get first message shortcut (for context menu) -export function getMessageShortcut(): - | SlackAppConfig['shortcuts'][number] - | undefined { - return simulatorState.appConfig?.shortcuts.find((s) => s.type === 'message') +// Get all message shortcuts from connected bots, grouped by bot +export function getAllMessageShortcuts(): BotShortcutGroup[] { + const groups: BotShortcutGroup[] = [] + for (const bot of simulatorState.connectedBots.values()) { + if (bot.status !== 'connected') continue + const messageShortcuts = bot.shortcuts.filter((s) => s.type === 'message') + if (messageShortcuts.length > 0) { + groups.push({ + botId: bot.id, + botName: bot.name, + botIcon: bot.iconEmoji || bot.iconUrl, + shortcuts: messageShortcuts, + }) + } + } + return groups } // ============================================================================= diff --git a/apps/ui/src/lib/types.ts b/apps/ui/src/lib/types.ts index 9be8191..88fbdfc 100644 --- a/apps/ui/src/lib/types.ts +++ b/apps/ui/src/lib/types.ts @@ -580,5 +580,7 @@ export interface ConnectedBotInfo { connectedAt: string status: 'connecting' | 'connected' | 'disconnected' commands: number - shortcuts: number + shortcuts: Shortcut[] + iconEmoji?: string + iconUrl?: string } From f5231af346008d4731ccd5813b7153c6bf1dc7c8 Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sat, 21 Feb 2026 18:05:51 +0000 Subject: [PATCH 04/22] feat(06-02): restructure context menu with Connect to apps submenu - Replace hardcoded Generate Image menu item with grouped multi-bot submenu - Remove onGenerateImage prop chain from Message, MessagePanel, and ThreadPanel - Shortcuts dispatched directly from Message via triggerMessageShortcut - Context menu works on all messages, not just image messages - Each submenu item shows bot icon + shortcut name (bold) + app name (muted) --- apps/ui/src/components/Message.svelte | 52 ++++++++++++++++------ apps/ui/src/components/MessagePanel.svelte | 19 +------- apps/ui/src/components/ThreadPanel.svelte | 26 +---------- 3 files changed, 41 insertions(+), 56 deletions(-) diff --git a/apps/ui/src/components/Message.svelte b/apps/ui/src/components/Message.svelte index 9d5b17e..c2657c4 100644 --- a/apps/ui/src/components/Message.svelte +++ b/apps/ui/src/components/Message.svelte @@ -4,11 +4,11 @@ Trash2, Sparkles, ChevronRight, - ImageIcon, Eye, } from '@lucide/svelte' import type { SimulatorMessage, + Shortcut, SlackBlock, SlackOption, SlackSectionBlock, @@ -17,7 +17,7 @@ SlackInputBlock, } from '../lib/types' import { - getMessageShortcut, + getAllMessageShortcuts, getChannelDisplayName, simulatorState, isBotUserId, @@ -26,6 +26,7 @@ import { updateFileExpanded, sendMessageBlockAction, + triggerMessageShortcut, } from '../lib/dispatcher.svelte' import { resolveEmoji } from '@botarium/mrkdwn' import BlockKitRenderer from './blockkit/BlockKitRenderer.svelte' @@ -43,7 +44,6 @@ hasDraft?: boolean onOpenThread?: (ts: string) => void onDelete?: (ts: string) => void - onGenerateImage?: (message: SimulatorMessage) => void onImagePreview?: ( imageUrl: string, imageAlt: string, @@ -60,7 +60,6 @@ hasDraft = false, onOpenThread, onDelete, - onGenerateImage, onImagePreview, }: Props = $props() @@ -70,8 +69,8 @@ let isBot = $derived(isBotUserId(message.user)) let isEphemeral = $derived(message.subtype === 'ephemeral') - let hasImage = $derived(message.file?.mimetype?.startsWith('image/') ?? false) - let messageShortcut = $derived(getMessageShortcut()) + let shortcutGroups = $derived(getAllMessageShortcuts()) + let hasShortcuts = $derived(shortcutGroups.length > 0) let displayName = $derived.by(() => { if (!isBot) return simulatorState.simulatedUserName || 'You' // Get bot name from connected bots @@ -326,8 +325,12 @@ onDelete?.(message.ts) } - function handleGenerateImage() { - onGenerateImage?.(message) + function handleShortcut(shortcut: Shortcut) { + triggerMessageShortcut(shortcut.callback_id, { + ts: message.ts, + text: message.text, + file: message.file, + }) } function handleClickOutside(e: MouseEvent) { @@ -533,13 +536,34 @@ - {#if onDelete || (hasImage && onGenerateImage && messageShortcut)} + {#if onDelete || hasShortcuts} - {#if hasImage && onGenerateImage && messageShortcut} - - - {messageShortcut.name} - + {#if hasShortcuts} + + Connect to apps + + {#each shortcutGroups as group, i (group.botId)} + {#if i > 0} + + {/if} + {#each group.shortcuts as shortcut (shortcut.callback_id)} + handleShortcut(shortcut)}> + + {group.botIcon || + group.botName.charAt(0).toUpperCase()} + {shortcut.name} + {group.botName} + + + {/each} + {/each} + + + {/if} {#if onDelete} diff --git a/apps/ui/src/components/MessagePanel.svelte b/apps/ui/src/components/MessagePanel.svelte index 99a6a9d..447a6cc 100644 --- a/apps/ui/src/components/MessagePanel.svelte +++ b/apps/ui/src/components/MessagePanel.svelte @@ -47,16 +47,10 @@ Trash2, } from '@lucide/svelte' import { tick } from 'svelte' - import { - clearChannelMessages, - deleteMessage, - triggerMessageShortcut, - } from '../lib/dispatcher.svelte' - import type { SimulatorMessage } from '../lib/types' + import { clearChannelMessages, deleteMessage } from '../lib/dispatcher.svelte' import { getChannelDisplayName, getChannelMessages, - getMessageShortcut, getParentMessages, getReplyCount, hasThreadDraft, @@ -97,16 +91,6 @@ deleteMessage(simulatorState.currentChannel, ts) } - function handleGenerateImage(message: SimulatorMessage) { - const shortcut = getMessageShortcut() - if (!shortcut) return - triggerMessageShortcut(shortcut.callback_id, { - ts: message.ts, - text: message.text, - file: message.file, - }) - } - function toggleMenu(e: MouseEvent) { e.stopPropagation() menuOpen = !menuOpen @@ -289,7 +273,6 @@ hasDraft={activeThreadTs !== message.ts && hasThreadDraft(message.ts)} {onOpenThread} onDelete={handleDeleteMessage} - onGenerateImage={handleGenerateImage} {onImagePreview} /> {/each} diff --git a/apps/ui/src/components/ThreadPanel.svelte b/apps/ui/src/components/ThreadPanel.svelte index 9d068a2..c68a99b 100644 --- a/apps/ui/src/components/ThreadPanel.svelte +++ b/apps/ui/src/components/ThreadPanel.svelte @@ -1,14 +1,8 @@
@@ -94,7 +78,6 @@
@@ -108,12 +91,7 @@ {/if} {#each replies as message (message.ts)} - + {/each}
From c72b7c4b5cb6a9f0b4722b4baab8ffaf653ecf3a Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sat, 21 Feb 2026 18:37:43 +0000 Subject: [PATCH 05/22] feat: add showcase message shortcut and fix shortcut payload type Add "Showcase message context" message shortcut to the showcase bot that opens a modal showing message details including images from both file attachments and Block Kit blocks. Fix shortcut payload type from 'shortcut' to 'message_action' to match Bolt's expected type for message shortcuts. Enrich shortcut payload with full message data (text, files, blocks) from emulator state. --- apps/showcase-bot/config.yaml | 6 +- apps/showcase-bot/src/listeners/index.ts | 2 + .../listeners/shortcuts/showcase-shortcuts.ts | 81 +++++++++++++++++++ packages/slack/src/server/types.ts | 3 +- packages/slack/src/server/web-api.ts | 20 ++++- 5 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 apps/showcase-bot/src/listeners/shortcuts/showcase-shortcuts.ts diff --git a/apps/showcase-bot/config.yaml b/apps/showcase-bot/config.yaml index 221ec87..454030c 100644 --- a/apps/showcase-bot/config.yaml +++ b/apps/showcase-bot/config.yaml @@ -11,7 +11,11 @@ slack: - command: /showcase description: Block Kit showcase commands usage_hint: '[generate|clear|modal|help]' - shortcuts: [] + shortcuts: + - callback_id: showcase_message_context + name: Showcase message context + description: Show message details in a modal + type: message actions: {} modals: showcase_modal: showcase_modal diff --git a/apps/showcase-bot/src/listeners/index.ts b/apps/showcase-bot/src/listeners/index.ts index 03b189c..4b31564 100644 --- a/apps/showcase-bot/src/listeners/index.ts +++ b/apps/showcase-bot/src/listeners/index.ts @@ -1,8 +1,10 @@ import type { App } from '@slack/bolt' import * as commands from './commands/showcase' import * as actions from './actions/showcase-actions' +import * as shortcuts from './shortcuts/showcase-shortcuts' export function registerListeners(app: App) { commands.register(app) actions.register(app) + shortcuts.register(app) } diff --git a/apps/showcase-bot/src/listeners/shortcuts/showcase-shortcuts.ts b/apps/showcase-bot/src/listeners/shortcuts/showcase-shortcuts.ts new file mode 100644 index 0000000..a9328d9 --- /dev/null +++ b/apps/showcase-bot/src/listeners/shortcuts/showcase-shortcuts.ts @@ -0,0 +1,81 @@ +import type { App, MessageShortcut } from '@slack/bolt' +import type { KnownBlock } from '@slack/types' +import { slackLogger } from '../../utils/logger' + +export function register(app: App) { + app.shortcut( + { callback_id: 'showcase_message_context', type: 'message_action' }, + async ({ shortcut, ack, client }) => { + await ack() + + const { message, channel, user } = shortcut + + const fields = [ + ['Timestamp', message.ts], + ['Channel', channel.id], + ['User', user.id], + ['Text', message.text ? `\`\`\`${message.text}\`\`\`` : '_empty_'], + ] + + const blocks: KnownBlock[] = fields.map(([label, value]) => ({ + type: 'section' as const, + text: { + type: 'mrkdwn' as const, + text: `*${label}*\n${value}`, + }, + })) + + // Show attached files + const files = message.files as + | Array<{ url_private?: string; mimetype?: string }> + | undefined + if (files?.length) { + for (const file of files) { + if (file.url_private && file.mimetype?.startsWith('image/')) { + blocks.push({ + type: 'image', + image_url: file.url_private, + alt_text: 'Attached image', + }) + } + } + } + + // Show images from Block Kit blocks + const msgBlocks = message.blocks as + | Array<{ + type: string + image_url?: string + alt_text?: string + title?: { text?: string } + }> + | undefined + if (msgBlocks?.length) { + for (const block of msgBlocks) { + if (block.type === 'image' && block.image_url) { + blocks.push({ + type: 'image', + image_url: block.image_url, + alt_text: block.alt_text || block.title?.text || 'Image', + }) + } + } + } + + try { + await client.views.open({ + trigger_id: shortcut.trigger_id, + view: { + type: 'modal', + title: { type: 'plain_text', text: 'Message Context' }, + close: { type: 'plain_text', text: 'Close' }, + blocks, + }, + }) + slackLogger.info('Opened message context modal') + } catch (err) { + slackLogger.error({ err }, 'Failed to open message context modal') + } + } + ) +} diff --git a/packages/slack/src/server/types.ts b/packages/slack/src/server/types.ts index c90f579..c6e3286 100644 --- a/packages/slack/src/server/types.ts +++ b/packages/slack/src/server/types.ts @@ -123,7 +123,7 @@ export interface SlashCommandPayload { // ============================================================================= export interface MessageShortcutPayload { - type: 'shortcut' + type: 'message_action' callback_id: string trigger_id: string message: { @@ -133,6 +133,7 @@ export interface MessageShortcutPayload { mimetype?: string url_private?: string }> + blocks?: unknown[] } channel: { id: string } user: { id: string; username: string } diff --git a/packages/slack/src/server/web-api.ts b/packages/slack/src/server/web-api.ts index 120c2f7..f57a622 100644 --- a/packages/slack/src/server/web-api.ts +++ b/packages/slack/src/server/web-api.ts @@ -1957,6 +1957,19 @@ export class SlackWebAPI { userName: user_name, }) + // Look up the full message from state for complete data (text, files) + const storedMessage = this.state.getMessage(channel, message.ts) + + // Build files array from stored message file (has full metadata) or fallback to request + const files = storedMessage?.file + ? [ + { + mimetype: storedMessage.file.mimetype, + url_private: storedMessage.file.url_private, + }, + ] + : message.files + // Resolve the owning bot by callback_id for targeted dispatch const targetBot = this.state.getBotForShortcut(callback_id) @@ -1968,13 +1981,14 @@ export class SlackWebAPI { // Dispatch shortcut to the owning bot (or broadcast as fallback) await this.socketMode.dispatchShortcut( { - type: 'shortcut', + type: 'message_action', callback_id, trigger_id: triggerId, message: { ts: message.ts, - text: message.text, - files: message.files, + text: storedMessage?.text || message.text, + files, + blocks: storedMessage?.blocks, }, channel: { id: channel }, user: { id: user, username: user_name || 'simulator_user' }, From 00f64c745ebad1907142d1ad8d65abe148fc5c04 Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sat, 21 Feb 2026 22:56:52 +0000 Subject: [PATCH 06/22] fix: prevent Electron drag region from blocking image preview close button Add no-drag to ImagePreviewModal overlay so -webkit-app-region: drag on headers behind the modal doesn't intercept mouse events. --- apps/ui/src/components/ImagePreviewModal.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ui/src/components/ImagePreviewModal.svelte b/apps/ui/src/components/ImagePreviewModal.svelte index b0b5819..538a611 100644 --- a/apps/ui/src/components/ImagePreviewModal.svelte +++ b/apps/ui/src/components/ImagePreviewModal.svelte @@ -199,7 +199,7 @@
Date: Sat, 21 Feb 2026 23:13:59 +0000 Subject: [PATCH 07/22] feat: add Connect to apps submenu to three-dot message menu Replace custom dropdown with shadcn-svelte DropdownMenu so the hover menu matches the right-click context menu. Also fix submenu close animation flash in both dropdown and context menu sub-content. --- apps/ui/src/components/Message.svelte | 95 +++++++++++-------- .../context-menu-sub-content.svelte | 2 +- .../dropdown-menu-checkbox-group.svelte | 16 ++++ .../dropdown-menu-checkbox-item.svelte | 43 +++++++++ .../dropdown-menu-content.svelte | 29 ++++++ .../dropdown-menu-group-heading.svelte | 22 +++++ .../dropdown-menu/dropdown-menu-group.svelte | 7 ++ .../dropdown-menu/dropdown-menu-item.svelte | 27 ++++++ .../dropdown-menu/dropdown-menu-label.svelte | 24 +++++ .../dropdown-menu/dropdown-menu-portal.svelte | 7 ++ .../dropdown-menu-radio-group.svelte | 16 ++++ .../dropdown-menu-radio-item.svelte | 33 +++++++ .../dropdown-menu-separator.svelte | 17 ++++ .../dropdown-menu-shortcut.svelte | 20 ++++ .../dropdown-menu-sub-content.svelte | 20 ++++ .../dropdown-menu-sub-trigger.svelte | 29 ++++++ .../ui/dropdown-menu/dropdown-menu-sub.svelte | 7 ++ .../dropdown-menu-trigger.svelte | 7 ++ .../ui/dropdown-menu/dropdown-menu.svelte | 7 ++ .../lib/components/ui/dropdown-menu/index.ts | 54 +++++++++++ 20 files changed, 443 insertions(+), 39 deletions(-) create mode 100644 apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte create mode 100644 apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte create mode 100644 apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte create mode 100644 apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte create mode 100644 apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte create mode 100644 apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte create mode 100644 apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte create mode 100644 apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte create mode 100644 apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte create mode 100644 apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte create mode 100644 apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte create mode 100644 apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte create mode 100644 apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte create mode 100644 apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte create mode 100644 apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte create mode 100644 apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte create mode 100644 apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte create mode 100644 apps/ui/src/lib/components/ui/dropdown-menu/index.ts diff --git a/apps/ui/src/components/Message.svelte b/apps/ui/src/components/Message.svelte index c2657c4..461bbbd 100644 --- a/apps/ui/src/components/Message.svelte +++ b/apps/ui/src/components/Message.svelte @@ -24,12 +24,12 @@ getBotByUserId, } from '../lib/state.svelte' import { - updateFileExpanded, sendMessageBlockAction, triggerMessageShortcut, } from '../lib/dispatcher.svelte' import { resolveEmoji } from '@botarium/mrkdwn' import BlockKitRenderer from './blockkit/BlockKitRenderer.svelte' + import ImageBlock from './blockkit/blocks/ImageBlock.svelte' import { renderMrkdwn } from './blockkit/context' import { formatTimestamp, @@ -37,6 +37,7 @@ formatFullDate, } from '../lib/time' import * as ContextMenu from '$lib/components/ui/context-menu' + import * as DropdownMenu from '$lib/components/ui/dropdown-menu' interface Props { message: SimulatorMessage @@ -63,8 +64,6 @@ onImagePreview, }: Props = $props() - let menuOpen = $state(false) - let menuButton = $state(null) let imageExpanded = $derived(message.file?.isExpanded ?? true) let isBot = $derived(isBotUserId(message.user)) @@ -315,13 +314,7 @@ ) } - function toggleMenu(e: MouseEvent) { - e.stopPropagation() - menuOpen = !menuOpen - } - function handleDelete() { - menuOpen = false onDelete?.(message.ts) } @@ -333,19 +326,11 @@ }) } - function handleClickOutside(e: MouseEvent) { - if (menuButton && !menuButton.contains(e.target as Node)) { - menuOpen = false - } - } - function getEmoji(name: string): string { return resolveEmoji(name) ?? `:${name}:` } - -
{/if}
- {#if onDelete} + {#if onDelete || hasShortcuts}
- - {#if menuOpen} -
+ - -
- {/if} + + + + {#if hasShortcuts} + + Connect to apps + + {#each shortcutGroups as group, i (group.botId)} + {#if i > 0} + + {/if} + {#each group.shortcuts as shortcut (shortcut.callback_id)} + handleShortcut(shortcut)} + > + + {group.botIcon || + group.botName + .charAt(0) + .toUpperCase()} + {shortcut.name} + {group.botName} + + + {/each} + {/each} + + + + {/if} + {#if onDelete} + + + Delete message + + {/if} + +
{/if}
+ import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui"; + + let { + ref = $bindable(null), + value = $bindable([]), + ...restProps + }: DropdownMenuPrimitive.CheckboxGroupProps = $props(); + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte new file mode 100644 index 0000000..6d9ef85 --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -0,0 +1,43 @@ + + + + {#snippet children({ checked, indeterminate })} + + {#if indeterminate} + + {:else} + + {/if} + + {@render childrenProp?.()} + {/snippet} + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte new file mode 100644 index 0000000..1e96782 --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte @@ -0,0 +1,29 @@ + + + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte new file mode 100644 index 0000000..433540f --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte @@ -0,0 +1,22 @@ + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte new file mode 100644 index 0000000..aca1f7b --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte new file mode 100644 index 0000000..04cd110 --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte @@ -0,0 +1,27 @@ + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte new file mode 100644 index 0000000..9681c2b --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte new file mode 100644 index 0000000..274cfef --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte new file mode 100644 index 0000000..189aef4 --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte @@ -0,0 +1,16 @@ + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte new file mode 100644 index 0000000..ce2ad09 --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte @@ -0,0 +1,33 @@ + + + + {#snippet children({ checked })} + + {#if checked} + + {/if} + + {@render childrenProp?.({ checked })} + {/snippet} + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte new file mode 100644 index 0000000..90f1b6f --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte @@ -0,0 +1,17 @@ + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte new file mode 100644 index 0000000..7c6e9c6 --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte new file mode 100644 index 0000000..d480ed8 --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte @@ -0,0 +1,20 @@ + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte new file mode 100644 index 0000000..5f49d01 --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte new file mode 100644 index 0000000..f044581 --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte new file mode 100644 index 0000000..cb05344 --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte new file mode 100644 index 0000000..cb4bc62 --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte @@ -0,0 +1,7 @@ + + + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/index.ts b/apps/ui/src/lib/components/ui/dropdown-menu/index.ts new file mode 100644 index 0000000..7850c6a --- /dev/null +++ b/apps/ui/src/lib/components/ui/dropdown-menu/index.ts @@ -0,0 +1,54 @@ +import Root from "./dropdown-menu.svelte"; +import Sub from "./dropdown-menu-sub.svelte"; +import CheckboxGroup from "./dropdown-menu-checkbox-group.svelte"; +import CheckboxItem from "./dropdown-menu-checkbox-item.svelte"; +import Content from "./dropdown-menu-content.svelte"; +import Group from "./dropdown-menu-group.svelte"; +import Item from "./dropdown-menu-item.svelte"; +import Label from "./dropdown-menu-label.svelte"; +import RadioGroup from "./dropdown-menu-radio-group.svelte"; +import RadioItem from "./dropdown-menu-radio-item.svelte"; +import Separator from "./dropdown-menu-separator.svelte"; +import Shortcut from "./dropdown-menu-shortcut.svelte"; +import Trigger from "./dropdown-menu-trigger.svelte"; +import SubContent from "./dropdown-menu-sub-content.svelte"; +import SubTrigger from "./dropdown-menu-sub-trigger.svelte"; +import GroupHeading from "./dropdown-menu-group-heading.svelte"; +import Portal from "./dropdown-menu-portal.svelte"; + +export { + CheckboxGroup, + CheckboxItem, + Content, + Portal, + Root as DropdownMenu, + CheckboxGroup as DropdownMenuCheckboxGroup, + CheckboxItem as DropdownMenuCheckboxItem, + Content as DropdownMenuContent, + Portal as DropdownMenuPortal, + Group as DropdownMenuGroup, + Item as DropdownMenuItem, + Label as DropdownMenuLabel, + RadioGroup as DropdownMenuRadioGroup, + RadioItem as DropdownMenuRadioItem, + Separator as DropdownMenuSeparator, + Shortcut as DropdownMenuShortcut, + Sub as DropdownMenuSub, + SubContent as DropdownMenuSubContent, + SubTrigger as DropdownMenuSubTrigger, + Trigger as DropdownMenuTrigger, + GroupHeading as DropdownMenuGroupHeading, + Group, + GroupHeading, + Item, + Label, + RadioGroup, + RadioItem, + Root, + Separator, + Shortcut, + Sub, + SubContent, + SubTrigger, + Trigger, +}; From b89398818f7b10c4fe6fa3f25b84657c9771bb1b Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sat, 21 Feb 2026 23:38:54 +0000 Subject: [PATCH 08/22] fix: unify file image rendering with ImageBlock and fix shortcut image detection - Reuse ImageBlock component for file-attached images instead of duplicate rendering code, giving consistent collapse triangle styling and size display - Constrain file image thumbnails to max-height 360px to match real Slack - Extract image URLs from Block Kit image blocks into files array when dispatching shortcuts so connected bots can detect them - Fix image-size endpoint returning wrong size (35 B) for local files by supporting HEAD requests on file serving route and checking response status - Add no-store cache headers to image-size responses --- apps/ui/src/components/Message.svelte | 54 ++++++++------------------- packages/slack/src/server/index.ts | 19 ++++++++-- packages/slack/src/server/web-api.ts | 27 ++++++++++---- 3 files changed, 49 insertions(+), 51 deletions(-) diff --git a/apps/ui/src/components/Message.svelte b/apps/ui/src/components/Message.svelte index 461bbbd..83e3fff 100644 --- a/apps/ui/src/components/Message.svelte +++ b/apps/ui/src/components/Message.svelte @@ -64,8 +64,6 @@ onImagePreview, }: Props = $props() - let imageExpanded = $derived(message.file?.isExpanded ?? true) - let isBot = $derived(isBotUserId(message.user)) let isEphemeral = $derived(message.subtype === 'ephemeral') let shortcutGroups = $derived(getAllMessageShortcuts()) @@ -456,49 +454,27 @@ {#if message.file}
{#if message.file.mimetype.startsWith('image/')} - {#if message.file.title} -
- {message.file.title} - -
- {/if} - {#if imageExpanded} - - {/if} + /> +
{:else}
= + storedMessage?.file + ? [ + { + mimetype: storedMessage.file.mimetype, + url_private: storedMessage.file.url_private, + }, + ] + : (message.files ?? []) + + // Also extract images from Block Kit image blocks so bots see them as files + if (storedMessage?.blocks) { + for (const block of storedMessage.blocks) { + const b = block as { type?: string; image_url?: string } + if (b.type === 'image' && b.image_url) { + files.push({ mimetype: 'image/png', url_private: b.image_url }) + } + } + } // Resolve the owning bot by callback_id for targeted dispatch const targetBot = this.state.getBotForShortcut(callback_id) From 4c233447b93f95c1fd3bf71a637d85e2c0314404 Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sat, 21 Feb 2026 23:45:34 +0000 Subject: [PATCH 09/22] fix: hide image collapse toggle in modals to match real Slack --- apps/ui/src/components/ModalOverlay.svelte | 1 + .../blockkit/BlockKitRenderer.svelte | 4 +- .../blockkit/blocks/ImageBlock.svelte | 44 +++++++++++-------- 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/apps/ui/src/components/ModalOverlay.svelte b/apps/ui/src/components/ModalOverlay.svelte index 65f4f2c..2bf7a34 100644 --- a/apps/ui/src/components/ModalOverlay.svelte +++ b/apps/ui/src/components/ModalOverlay.svelte @@ -297,6 +297,7 @@ onFileChange={handleFileChange} onCheckboxChange={handleCheckboxChange} onRadioChange={handleRadioChange} + imageCollapsible={false} />
diff --git a/apps/ui/src/components/blockkit/BlockKitRenderer.svelte b/apps/ui/src/components/blockkit/BlockKitRenderer.svelte index 77d6696..dfec6a7 100644 --- a/apps/ui/src/components/blockkit/BlockKitRenderer.svelte +++ b/apps/ui/src/components/blockkit/BlockKitRenderer.svelte @@ -49,6 +49,7 @@ option: SlackOption ) => void onImagePreview?: (imageUrl: string, imageAlt: string) => void + imageCollapsible?: boolean } let { @@ -61,6 +62,7 @@ onCheckboxChange, onRadioChange, onImagePreview, + imageCollapsible, }: Props = $props() function getBlockId(block: SlackBlock, index: number): string { @@ -91,7 +93,7 @@ {: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/ImageBlock.svelte b/apps/ui/src/components/blockkit/blocks/ImageBlock.svelte index 67ce498..cf38c76 100644 --- a/apps/ui/src/components/blockkit/blocks/ImageBlock.svelte +++ b/apps/ui/src/components/blockkit/blocks/ImageBlock.svelte @@ -6,9 +6,10 @@ interface Props { block: SlackImageBlock onImagePreview?: (imageUrl: string, imageAlt: string) => void + collapsible?: boolean } - let { block, onImagePreview }: Props = $props() + let { block, onImagePreview, collapsible = true }: Props = $props() let collapsed = $state(false) let imageSize = $state(null) @@ -19,6 +20,7 @@ } $effect(() => { + if (!collapsible) return const controller = new AbortController() imageSize = null const url = `${EMULATOR_API_URL}/api/simulator/image-size?url=${encodeURIComponent(block.image_url)}` @@ -39,24 +41,28 @@
- - {#if block.title}{renderText(block.title)}{/if} - {#if imageSize}({imageSize}){/if} - - - {#if !collapsed} + {#if block.title || (collapsible && imageSize)} + + {#if block.title}{renderText(block.title)}{/if} + {#if collapsible} + {#if imageSize}({imageSize}){/if} + + {/if} + + {/if} + {#if !collapsed || !collapsible} {#if onImagePreview}
+ + From 84db29cb2270d7d36980e0c8aa179ea04065a43f Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sat, 21 Feb 2026 23:58:37 +0000 Subject: [PATCH 12/22] fix: match real Slack modal width (520px) --- apps/ui/src/components/ModalOverlay.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ui/src/components/ModalOverlay.svelte b/apps/ui/src/components/ModalOverlay.svelte index 9b03e95..0f9f992 100644 --- a/apps/ui/src/components/ModalOverlay.svelte +++ b/apps/ui/src/components/ModalOverlay.svelte @@ -320,7 +320,7 @@ tabindex="-1" >
Date: Sun, 22 Feb 2026 00:07:01 +0000 Subject: [PATCH 13/22] Format the code --- apps/ui/src/components/Message.svelte | 8 +- .../blockkit/BlockKitRenderer.svelte | 6 +- .../dropdown-menu-checkbox-group.svelte | 20 ++-- .../dropdown-menu-checkbox-item.svelte | 72 ++++++------ .../dropdown-menu-content.svelte | 48 ++++---- .../dropdown-menu-group-heading.svelte | 32 +++--- .../dropdown-menu/dropdown-menu-group.svelte | 13 ++- .../dropdown-menu/dropdown-menu-item.svelte | 42 +++---- .../dropdown-menu/dropdown-menu-label.svelte | 34 +++--- .../dropdown-menu/dropdown-menu-portal.svelte | 4 +- .../dropdown-menu-radio-group.svelte | 20 ++-- .../dropdown-menu-radio-item.svelte | 52 ++++----- .../dropdown-menu-separator.svelte | 22 ++-- .../dropdown-menu-shortcut.svelte | 26 ++--- .../dropdown-menu-sub-content.svelte | 28 ++--- .../dropdown-menu-sub-trigger.svelte | 44 ++++---- .../ui/dropdown-menu/dropdown-menu-sub.svelte | 7 +- .../dropdown-menu-trigger.svelte | 13 ++- .../ui/dropdown-menu/dropdown-menu.svelte | 7 +- .../lib/components/ui/dropdown-menu/index.ts | 104 +++++++++--------- 20 files changed, 312 insertions(+), 290 deletions(-) diff --git a/apps/ui/src/components/Message.svelte b/apps/ui/src/components/Message.svelte index 83e3fff..30cc17e 100644 --- a/apps/ui/src/components/Message.svelte +++ b/apps/ui/src/components/Message.svelte @@ -372,13 +372,9 @@ {group.botIcon || - group.botName - .charAt(0) - .toUpperCase()} - {shortcut.name} + {shortcut.name} {group.botName} {:else if block.type === 'image'} - + {:else if block.type === 'header'} {:else if block.type === 'rich_text'} diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte index e0e1971..3ece1fe 100644 --- a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte @@ -1,16 +1,16 @@ diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte index 6d9ef85..09356c0 100644 --- a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -1,43 +1,43 @@ - {#snippet children({ checked, indeterminate })} - - {#if indeterminate} - - {:else} - - {/if} - - {@render childrenProp?.()} - {/snippet} + {#snippet children({ checked, indeterminate })} + + {#if indeterminate} + + {:else} + + {/if} + + {@render childrenProp?.()} + {/snippet} diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte index 1e96782..31eb51e 100644 --- a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte @@ -1,29 +1,31 @@ - + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte index 433540f..850e171 100644 --- a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte @@ -1,22 +1,22 @@ diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte index aca1f7b..f2d49ea 100644 --- a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte @@ -1,7 +1,14 @@ - + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte index 04cd110..f916d57 100644 --- a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte @@ -1,27 +1,27 @@ diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte index 9681c2b..7c8e68c 100644 --- a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte @@ -1,24 +1,24 @@
- {@render children?.()} + {@render children?.()}
diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte index 274cfef..692de5b 100644 --- a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte @@ -1,7 +1,7 @@ diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte index 189aef4..0780bc2 100644 --- a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte @@ -1,16 +1,16 @@ diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte index ce2ad09..98030d4 100644 --- a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte @@ -1,33 +1,33 @@ - {#snippet children({ checked })} - - {#if checked} - - {/if} - - {@render childrenProp?.({ checked })} - {/snippet} + {#snippet children({ checked })} + + {#if checked} + + {/if} + + {@render childrenProp?.({ checked })} + {/snippet} diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte index 90f1b6f..c06bb76 100644 --- a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte @@ -1,17 +1,17 @@ diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte index 7c6e9c6..e559c82 100644 --- a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte @@ -1,20 +1,20 @@ - {@render children?.()} + {@render children?.()} diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte index d480ed8..5edcdb5 100644 --- a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte @@ -1,20 +1,20 @@ diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte index 5f49d01..253ef59 100644 --- a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte @@ -1,29 +1,29 @@ - {@render children?.()} - + {@render children?.()} + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte index f044581..62fc013 100644 --- a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte @@ -1,7 +1,10 @@ diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte index cb05344..f8c9dcd 100644 --- a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte @@ -1,7 +1,14 @@ - + diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte index cb4bc62..f2e1d2d 100644 --- a/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte +++ b/apps/ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte @@ -1,7 +1,10 @@ diff --git a/apps/ui/src/lib/components/ui/dropdown-menu/index.ts b/apps/ui/src/lib/components/ui/dropdown-menu/index.ts index 7850c6a..1df559b 100644 --- a/apps/ui/src/lib/components/ui/dropdown-menu/index.ts +++ b/apps/ui/src/lib/components/ui/dropdown-menu/index.ts @@ -1,54 +1,54 @@ -import Root from "./dropdown-menu.svelte"; -import Sub from "./dropdown-menu-sub.svelte"; -import CheckboxGroup from "./dropdown-menu-checkbox-group.svelte"; -import CheckboxItem from "./dropdown-menu-checkbox-item.svelte"; -import Content from "./dropdown-menu-content.svelte"; -import Group from "./dropdown-menu-group.svelte"; -import Item from "./dropdown-menu-item.svelte"; -import Label from "./dropdown-menu-label.svelte"; -import RadioGroup from "./dropdown-menu-radio-group.svelte"; -import RadioItem from "./dropdown-menu-radio-item.svelte"; -import Separator from "./dropdown-menu-separator.svelte"; -import Shortcut from "./dropdown-menu-shortcut.svelte"; -import Trigger from "./dropdown-menu-trigger.svelte"; -import SubContent from "./dropdown-menu-sub-content.svelte"; -import SubTrigger from "./dropdown-menu-sub-trigger.svelte"; -import GroupHeading from "./dropdown-menu-group-heading.svelte"; -import Portal from "./dropdown-menu-portal.svelte"; +import Root from './dropdown-menu.svelte' +import Sub from './dropdown-menu-sub.svelte' +import CheckboxGroup from './dropdown-menu-checkbox-group.svelte' +import CheckboxItem from './dropdown-menu-checkbox-item.svelte' +import Content from './dropdown-menu-content.svelte' +import Group from './dropdown-menu-group.svelte' +import Item from './dropdown-menu-item.svelte' +import Label from './dropdown-menu-label.svelte' +import RadioGroup from './dropdown-menu-radio-group.svelte' +import RadioItem from './dropdown-menu-radio-item.svelte' +import Separator from './dropdown-menu-separator.svelte' +import Shortcut from './dropdown-menu-shortcut.svelte' +import Trigger from './dropdown-menu-trigger.svelte' +import SubContent from './dropdown-menu-sub-content.svelte' +import SubTrigger from './dropdown-menu-sub-trigger.svelte' +import GroupHeading from './dropdown-menu-group-heading.svelte' +import Portal from './dropdown-menu-portal.svelte' export { - CheckboxGroup, - CheckboxItem, - Content, - Portal, - Root as DropdownMenu, - CheckboxGroup as DropdownMenuCheckboxGroup, - CheckboxItem as DropdownMenuCheckboxItem, - Content as DropdownMenuContent, - Portal as DropdownMenuPortal, - Group as DropdownMenuGroup, - Item as DropdownMenuItem, - Label as DropdownMenuLabel, - RadioGroup as DropdownMenuRadioGroup, - RadioItem as DropdownMenuRadioItem, - Separator as DropdownMenuSeparator, - Shortcut as DropdownMenuShortcut, - Sub as DropdownMenuSub, - SubContent as DropdownMenuSubContent, - SubTrigger as DropdownMenuSubTrigger, - Trigger as DropdownMenuTrigger, - GroupHeading as DropdownMenuGroupHeading, - Group, - GroupHeading, - Item, - Label, - RadioGroup, - RadioItem, - Root, - Separator, - Shortcut, - Sub, - SubContent, - SubTrigger, - Trigger, -}; + CheckboxGroup, + CheckboxItem, + Content, + Portal, + Root as DropdownMenu, + CheckboxGroup as DropdownMenuCheckboxGroup, + CheckboxItem as DropdownMenuCheckboxItem, + Content as DropdownMenuContent, + Portal as DropdownMenuPortal, + Group as DropdownMenuGroup, + Item as DropdownMenuItem, + Label as DropdownMenuLabel, + RadioGroup as DropdownMenuRadioGroup, + RadioItem as DropdownMenuRadioItem, + Separator as DropdownMenuSeparator, + Shortcut as DropdownMenuShortcut, + Sub as DropdownMenuSub, + SubContent as DropdownMenuSubContent, + SubTrigger as DropdownMenuSubTrigger, + Trigger as DropdownMenuTrigger, + GroupHeading as DropdownMenuGroupHeading, + Group, + GroupHeading, + Item, + Label, + RadioGroup, + RadioItem, + Root, + Separator, + Shortcut, + Sub, + SubContent, + SubTrigger, + Trigger, +} From b5c128fbfa96c514dc8868fa201195f95f41d1ed Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sun, 22 Feb 2026 00:21:10 +0000 Subject: [PATCH 14/22] feat: display bot icon from registration config Show icon_url in message avatars, sidebar, and DM header instead of the hardcoded Sparkles icon. Falls back to Sparkles when no icon is provided. --- apps/ui/src/components/BotAboutHeader.svelte | 16 ++++++++++------ apps/ui/src/components/Message.svelte | 15 +++++++++------ apps/ui/src/components/Sidebar.svelte | 14 +++++++++----- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/apps/ui/src/components/BotAboutHeader.svelte b/apps/ui/src/components/BotAboutHeader.svelte index 3e840b0..c2c1d64 100644 --- a/apps/ui/src/components/BotAboutHeader.svelte +++ b/apps/ui/src/components/BotAboutHeader.svelte @@ -10,12 +10,16 @@
-
- - - +
+ {#if bot.iconUrl} + {bot.name} + {:else} + + + + {/if}
diff --git a/apps/ui/src/components/Message.svelte b/apps/ui/src/components/Message.svelte index 30cc17e..3298f1e 100644 --- a/apps/ui/src/components/Message.svelte +++ b/apps/ui/src/components/Message.svelte @@ -68,11 +68,10 @@ let isEphemeral = $derived(message.subtype === 'ephemeral') let shortcutGroups = $derived(getAllMessageShortcuts()) let hasShortcuts = $derived(shortcutGroups.length > 0) + let botInfo = $derived(isBot ? getBotByUserId(message.user) : undefined) let displayName = $derived.by(() => { if (!isBot) return simulatorState.simulatedUserName || 'You' - // Get bot name from connected bots - const bot = getBotByUserId(message.user) - return bot?.name ?? simulatorState.botName + return botInfo?.name ?? simulatorState.botName }) let avatarLetter = $derived(displayName.charAt(0).toUpperCase()) let timestamp = $derived(formatTimestamp(message.ts)) @@ -401,11 +400,15 @@
{/if}
- {#if isBot} + {#if isBot && botInfo?.iconUrl} + {displayName} + {:else if isBot} {:else} {avatarLetter} diff --git a/apps/ui/src/components/Sidebar.svelte b/apps/ui/src/components/Sidebar.svelte index a6feb07..7a5fd29 100644 --- a/apps/ui/src/components/Sidebar.svelte +++ b/apps/ui/src/components/Sidebar.svelte @@ -205,11 +205,15 @@ } }} > - - - + {#if bot.iconUrl} + {bot.name} + {:else} + + + + {/if} From 57509634ba214e12922a080c52acd09d70fccc0a Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sun, 22 Feb 2026 00:28:24 +0000 Subject: [PATCH 15/22] feat: group sequential messages by same author MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consecutive messages from the same user within 10 minutes are visually grouped — hiding the avatar and name, reducing vertical padding, and showing a 24h timestamp on hover in the avatar gutter. --- apps/ui/src/components/Message.svelte | 76 ++++++++++++++-------- apps/ui/src/components/MessagePanel.svelte | 11 +++- apps/ui/src/components/ThreadPanel.svelte | 13 +++- apps/ui/src/lib/time.ts | 25 +++++++ 4 files changed, 94 insertions(+), 31 deletions(-) diff --git a/apps/ui/src/components/Message.svelte b/apps/ui/src/components/Message.svelte index 3298f1e..42815d4 100644 --- a/apps/ui/src/components/Message.svelte +++ b/apps/ui/src/components/Message.svelte @@ -33,6 +33,7 @@ import { renderMrkdwn } from './blockkit/context' import { formatTimestamp, + formatTimestampShort, formatRelativeTime, formatFullDate, } from '../lib/time' @@ -43,6 +44,7 @@ message: SimulatorMessage replyCount?: number hasDraft?: boolean + isGrouped?: boolean onOpenThread?: (ts: string) => void onDelete?: (ts: string) => void onImagePreview?: ( @@ -59,6 +61,7 @@ message, replyCount = 0, hasDraft = false, + isGrouped = false, onOpenThread, onDelete, onImagePreview, @@ -75,6 +78,7 @@ }) let avatarLetter = $derived(displayName.charAt(0).toUpperCase()) let timestamp = $derived(formatTimestamp(message.ts)) + let timestampShort = $derived(formatTimestampShort(message.ts)) let fullDate = $derived(formatFullDate(message.ts)) let formattedText = $derived.by(() => { // Replace internal user IDs BEFORE markdown processing (handles <@userId> format) @@ -331,7 +335,9 @@
{#if isEphemeral}
{/if} -
- {#if isBot && botInfo?.iconUrl} - {displayName} - {:else if isBot} - - {:else} - {avatarLetter} - {/if} -
-
-
- {displayName} - {#if isBot} - APP - {/if} - {timestamp} + {timestampShort}
+ {:else} +
+ {#if isBot && botInfo?.iconUrl} + {displayName} + {:else if isBot} + + {:else} + {avatarLetter} + {/if} +
+ {/if} +
+ {#if !isGrouped} +
+ {displayName} + {#if isBot} + APP + {/if} + {timestamp} +
+ {/if} {#if hasBlocks}
{:else if messages.length > 0} {#each messages as message, i (message.ts)} - {#if i === 0 || getDateKey(message.ts) !== getDateKey(messages[i - 1]!.ts)} + {@const prevMessage = messages[i - 1]} + {@const isDaySeparator = + i === 0 || getDateKey(message.ts) !== getDateKey(prevMessage!.ts)} + {#if isDaySeparator} {/if} {/if} - {#each replies as message (message.ts)} - + {#each replies as message, i (message.ts)} + {@const prevMessage = i === 0 ? parentMessage : replies[i - 1]} + {/each}
diff --git a/apps/ui/src/lib/time.ts b/apps/ui/src/lib/time.ts index a32ec40..10a6b36 100644 --- a/apps/ui/src/lib/time.ts +++ b/apps/ui/src/lib/time.ts @@ -66,6 +66,31 @@ export function formatFullDate(ts: string): string { }) } +/** + * Format a Slack timestamp to a short time string without AM/PM (e.g., "12:34") + */ +export function formatTimestampShort(ts: string): string { + const seconds = parseFloat(ts) + const date = new Date(seconds * 1000) + return date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: false, + }) +} + +/** + * Check if two Slack timestamps are within a given number of minutes + */ +export function isWithinMinutes( + ts1: string, + ts2: string, + minutes: number +): boolean { + const diff = Math.abs(parseFloat(ts1) - parseFloat(ts2)) + return diff < minutes * 60 +} + /** * Format a Slack timestamp to a relative time string (e.g., "5 min ago") */ From 983619cd9065ddc52bca65d6d9b79475327b1552 Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sun, 22 Feb 2026 00:28:36 +0000 Subject: [PATCH 16/22] Format code --- apps/ui/src/components/BotAboutHeader.svelte | 6 +++++- apps/ui/src/components/Sidebar.svelte | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/ui/src/components/BotAboutHeader.svelte b/apps/ui/src/components/BotAboutHeader.svelte index c2c1d64..f153f07 100644 --- a/apps/ui/src/components/BotAboutHeader.svelte +++ b/apps/ui/src/components/BotAboutHeader.svelte @@ -12,7 +12,11 @@
{#if bot.iconUrl} - {bot.name} + {bot.name} {:else} {#if bot.iconUrl} - {bot.name} + {bot.name} {:else} Date: Sun, 22 Feb 2026 00:31:48 +0000 Subject: [PATCH 17/22] fix: address review findings across shortcuts and UI - Truncate message text to stay within Slack's 3000-char mrkdwn limit in showcase shortcut modal - Gate menu separators on onDelete to prevent orphaned dividers - Add warning log when bot socket is missing during shortcut dispatch - Infer image mimetype from URL extension instead of hard-coding png --- .../listeners/shortcuts/showcase-shortcuts.ts | 10 +++++++++- apps/ui/src/components/Message.svelte | 8 ++++++-- packages/slack/src/server/socket-mode.ts | 10 ++++++++++ packages/slack/src/server/web-api.ts | 16 +++++++++++++++- 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/apps/showcase-bot/src/listeners/shortcuts/showcase-shortcuts.ts b/apps/showcase-bot/src/listeners/shortcuts/showcase-shortcuts.ts index a9328d9..bb6a600 100644 --- a/apps/showcase-bot/src/listeners/shortcuts/showcase-shortcuts.ts +++ b/apps/showcase-bot/src/listeners/shortcuts/showcase-shortcuts.ts @@ -10,11 +10,19 @@ export function register(app: App) { const { message, channel, user } = shortcut + // Truncate text to stay within Slack's 3000-char mrkdwn limit + const maxTextLen = 3000 - '```\n\n```'.length - '*Text*\n'.length + const rawText = message.text || '' + const truncatedText = + rawText.length > maxTextLen + ? rawText.slice(0, maxTextLen - 3) + '...' + : rawText + const fields = [ ['Timestamp', message.ts], ['Channel', channel.id], ['User', user.id], - ['Text', message.text ? `\`\`\`${message.text}\`\`\`` : '_empty_'], + ['Text', truncatedText ? `\`\`\`${truncatedText}\`\`\`` : '_empty_'], ] const blocks: KnownBlock[] = fields.map(([label, value]) => ({ diff --git a/apps/ui/src/components/Message.svelte b/apps/ui/src/components/Message.svelte index 42815d4..6eb0945 100644 --- a/apps/ui/src/components/Message.svelte +++ b/apps/ui/src/components/Message.svelte @@ -390,7 +390,9 @@ {/each} - + {#if onDelete} + + {/if} {/if} {#if onDelete} - + {#if onDelete} + + {/if} {/if} {#if onDelete} diff --git a/packages/slack/src/server/socket-mode.ts b/packages/slack/src/server/socket-mode.ts index 2075570..fe91981 100644 --- a/packages/slack/src/server/socket-mode.ts +++ b/packages/slack/src/server/socket-mode.ts @@ -718,6 +718,16 @@ export class SocketModeServer { socketModeLogger.debug( `Shortcut dispatched to bot ${targetBotId}: ${payload.callback_id}` ) + } else { + socketModeLogger.warn( + { + targetBotId, + connectionId: bot.connectionId, + envelopeId: envelope.envelope_id, + callbackId: payload.callback_id, + }, + 'Bot is connected but active socket not found, shortcut dropped' + ) } return } diff --git a/packages/slack/src/server/web-api.ts b/packages/slack/src/server/web-api.ts index 444b5a9..b1d9bb6 100644 --- a/packages/slack/src/server/web-api.ts +++ b/packages/slack/src/server/web-api.ts @@ -1976,7 +1976,21 @@ export class SlackWebAPI { for (const block of storedMessage.blocks) { const b = block as { type?: string; image_url?: string } if (b.type === 'image' && b.image_url) { - files.push({ mimetype: 'image/png', url_private: b.image_url }) + const ext = b.image_url + ?.split('.') + .pop() + ?.split('?')[0] + ?.toLowerCase() + const mimeByExt: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + } + const mimetype = ext && mimeByExt[ext] ? mimeByExt[ext] : undefined + files.push({ mimetype, url_private: b.image_url }) } } } From 4a0e6bd22737cfbdecd4aa39f88c03793f5df635 Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sun, 22 Feb 2026 01:09:56 +0000 Subject: [PATCH 18/22] fix: support bare domain links in mrkdwn and use compact line breaks in section blocks Mrkdwn links without protocol (e.g. ) were rendered as raw text instead of clickable links. Section blocks now use
for line breaks matching real Slack spacing. Added three new Block Kit template samples with action_ids. --- .../messages/blocks/13-template-approval.json | 109 ++++++++++++++ .../blocks/14-template-notification.json | 109 ++++++++++++++ .../src/messages/blocks/15-template-vote.json | 137 ++++++++++++++++++ .../blockkit/blocks/SectionBlock.svelte | 6 +- apps/ui/src/components/blockkit/context.ts | 7 +- packages/mrkdwn/src/mrkdwn-to-html.test.ts | 18 +++ packages/mrkdwn/src/mrkdwn-to-html.ts | 21 ++- 7 files changed, 398 insertions(+), 9 deletions(-) create mode 100644 apps/showcase-bot/src/messages/blocks/13-template-approval.json create mode 100644 apps/showcase-bot/src/messages/blocks/14-template-notification.json create mode 100644 apps/showcase-bot/src/messages/blocks/15-template-vote.json diff --git a/apps/showcase-bot/src/messages/blocks/13-template-approval.json b/apps/showcase-bot/src/messages/blocks/13-template-approval.json new file mode 100644 index 0000000..d14d804 --- /dev/null +++ b/apps/showcase-bot/src/messages/blocks/13-template-approval.json @@ -0,0 +1,109 @@ +{ + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "You have a new request:\n**" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Type:*\nPaid time off\n*When:*\nAug 10-Aug 13\n*Hours:* 16.0 (2 days)\n*Remaining balance:* 32.0 hours (4 days)\n*Comments:* \"Family in town, going camping!\"" + }, + "accessory": { + "type": "image", + "image_url": "https://api.slack.com/img/blocks/bkb_template_images/approvalsNewDevice.png", + "alt_text": "computer thumbnail" + } + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Approve" + }, + "style": "primary", + "action_id": "showcase_approval_approve_pto", + "value": "click_me_123" + }, + { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Deny" + }, + "style": "danger", + "action_id": "showcase_approval_deny_pto", + "value": "click_me_123" + } + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "You have a new request:\n**" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Type:*\nComputer (laptop)" + }, + { + "type": "mrkdwn", + "text": "*When:*\nSubmitted Aut 10" + }, + { + "type": "mrkdwn", + "text": "*Last Update:*\nMar 10, 2015 (3 years, 5 months)" + }, + { + "type": "mrkdwn", + "text": "*Reason:*\nAll vowel keys aren't working." + }, + { + "type": "mrkdwn", + "text": "*Specs:*\n\"Cheetah Pro 15\" - Fast, really fast\"" + } + ] + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Approve" + }, + "style": "primary", + "action_id": "showcase_approval_approve_device", + "value": "click_me_123" + }, + { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Deny" + }, + "style": "danger", + "action_id": "showcase_approval_deny_device", + "value": "click_me_123" + } + ] + } + ] +} diff --git a/apps/showcase-bot/src/messages/blocks/14-template-notification.json b/apps/showcase-bot/src/messages/blocks/14-template-notification.json new file mode 100644 index 0000000..10053a4 --- /dev/null +++ b/apps/showcase-bot/src/messages/blocks/14-template-notification.json @@ -0,0 +1,109 @@ +{ + "blocks": [ + { + "type": "section", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Looks like you have a scheduling conflict with this event:" + } + }, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "**\nTuesday, January 21 4:00-4:30pm\nBuilding 2 - Havarti Cheese (3)\n2 guests" + }, + "accessory": { + "type": "image", + "image_url": "https://api.slack.com/img/blocks/bkb_template_images/notifications.png", + "alt_text": "calendar thumbnail" + } + }, + { + "type": "context", + "elements": [ + { + "type": "image", + "image_url": "https://api.slack.com/img/blocks/bkb_template_images/notificationsWarningIcon.png", + "alt_text": "notifications warning icon" + }, + { + "type": "mrkdwn", + "text": "*Conflicts with Team Huddle: 4:15-4:30pm*" + } + ] + }, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Propose a new time:*" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Today - 4:30-5pm*\nEveryone is available: @iris, @zelda" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Choose" + }, + "action_id": "showcase_notification_choose_today", + "value": "click_me_123" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Tomorrow - 4-4:30pm*\nEveryone is available: @iris, @zelda" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Choose" + }, + "action_id": "showcase_notification_choose_tomorrow_4", + "value": "click_me_123" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Tomorrow - 6-6:30pm*\nSome people aren't available: @iris, ~@zelda~" + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Choose" + }, + "action_id": "showcase_notification_choose_tomorrow_6", + "value": "click_me_123" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "**" + } + } + ] +} diff --git a/apps/showcase-bot/src/messages/blocks/15-template-vote.json b/apps/showcase-bot/src/messages/blocks/15-template-vote.json new file mode 100644 index 0000000..10a6a74 --- /dev/null +++ b/apps/showcase-bot/src/messages/blocks/15-template-vote.json @@ -0,0 +1,137 @@ +{ + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Where should we order lunch from?* Poll by " + } + }, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":sushi: *Ace Wasabi Rock-n-Roll Sushi Bar*\nThe best landlocked sushi restaurant." + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Vote" + }, + "action_id": "showcase_vote_sushi", + "value": "click_me_123" + } + }, + { + "type": "context", + "elements": [ + { + "type": "image", + "image_url": "https://api.slack.com/img/blocks/bkb_template_images/profile_1.png", + "alt_text": "Michael Scott" + }, + { + "type": "image", + "image_url": "https://api.slack.com/img/blocks/bkb_template_images/profile_2.png", + "alt_text": "Dwight Schrute" + }, + { + "type": "image", + "image_url": "https://api.slack.com/img/blocks/bkb_template_images/profile_3.png", + "alt_text": "Pam Beasely" + }, + { + "type": "plain_text", + "emoji": true, + "text": "3 votes" + } + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":hamburger: *Super Hungryman Hamburgers*\nOnly for the hungriest of the hungry." + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Vote" + }, + "action_id": "showcase_vote_hamburger", + "value": "click_me_123" + } + }, + { + "type": "context", + "elements": [ + { + "type": "image", + "image_url": "https://api.slack.com/img/blocks/bkb_template_images/profile_4.png", + "alt_text": "Angela" + }, + { + "type": "image", + "image_url": "https://api.slack.com/img/blocks/bkb_template_images/profile_2.png", + "alt_text": "Dwight Schrute" + }, + { + "type": "plain_text", + "emoji": true, + "text": "2 votes" + } + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ":ramen: *Kagawa-Ya Udon Noodle Shop*\nDo you like to shop for noodles? We have noodles." + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Vote" + }, + "action_id": "showcase_vote_ramen", + "value": "click_me_123" + } + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "No votes" + } + ] + }, + { + "type": "divider" + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": "Add a suggestion" + }, + "action_id": "showcase_vote_add_suggestion", + "value": "click_me_123" + } + ] + } + ] +} diff --git a/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte b/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte index fe85b33..aba3b24 100644 --- a/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte +++ b/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte @@ -37,14 +37,14 @@ {#snippet textContent()} {#if block.text}
- {@html renderMrkdwn(block.text)} + {@html renderMrkdwn(block.text, { useBr: true })}
{/if} {#if block.fields}
{#each block.fields as field, i (i)} -
- {@html renderMrkdwn(field)} +
+ {@html renderMrkdwn(field, { useBr: true })}
{/each}
diff --git a/apps/ui/src/components/blockkit/context.ts b/apps/ui/src/components/blockkit/context.ts index 62e11d4..9585c65 100644 --- a/apps/ui/src/components/blockkit/context.ts +++ b/apps/ui/src/components/blockkit/context.ts @@ -45,7 +45,10 @@ export function renderText(textObj: SlackViewTextObject | undefined): string { * 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 { +export function renderMrkdwn( + textObj: SlackViewTextObject | undefined, + options?: { useBr?: boolean } +): string { if (!textObj) return '' // Plain text: escape HTML entities, no mrkdwn parsing @@ -57,7 +60,7 @@ export function renderMrkdwn(textObj: SlackViewTextObject | undefined): string { } // mrkdwn type: parse with @botarium/mrkdwn, sanitize output - const html = mrkdwnToHtml(textObj.text) + const html = mrkdwnToHtml(textObj.text, options) return DOMPurify.sanitize(html, SANITIZE_CONFIG) } diff --git a/packages/mrkdwn/src/mrkdwn-to-html.test.ts b/packages/mrkdwn/src/mrkdwn-to-html.test.ts index b77bd8e..4a10083 100644 --- a/packages/mrkdwn/src/mrkdwn-to-html.test.ts +++ b/packages/mrkdwn/src/mrkdwn-to-html.test.ts @@ -44,6 +44,24 @@ describe('mrkdwnToHtml', () => { ) }) + it('renders link without protocol (with label)', () => { + expect(mrkdwnToHtml('')).toBe( + 'Fred Enriquez' + ) + }) + + it('renders link without protocol (without label)', () => { + expect(mrkdwnToHtml('')).toBe( + 'example.com' + ) + }) + + it('renders bold link without protocol', () => { + expect(mrkdwnToHtml('**')).toBe( + 'Fred Enriquez' + ) + }) + it('renders mailto link', () => { expect(mrkdwnToHtml('')).toBe( 'Email' diff --git a/packages/mrkdwn/src/mrkdwn-to-html.ts b/packages/mrkdwn/src/mrkdwn-to-html.ts index 43d2bf0..401cbc7 100644 --- a/packages/mrkdwn/src/mrkdwn-to-html.ts +++ b/packages/mrkdwn/src/mrkdwn-to-html.ts @@ -24,7 +24,7 @@ function formatInline(text: string): string { // HTML-escape remaining text text = escapeHtml(text) - // Links: and + // Links: and (with protocol) text = text.replace( /<((?:https?|mailto):.*?)\|(.*?)>/g, '$2' @@ -34,6 +34,16 @@ function formatInline(text: string): string { '$1' ) + // Links without protocol: and + text = text.replace( + /<([^@#!][^|]*?)\|(.*?)>/g, + '$2' + ) + text = text.replace( + /<([^@#!][^|]*?\.[^|]*?)>/g, + '$1' + ) + // User mentions: <@U123> text = text.replace( /<@([A-Z0-9]+)>/g, @@ -89,7 +99,10 @@ function formatInline(text: string): string { * Processes block-level elements (code blocks, blockquotes, lists) line-by-line, * then applies inline formatting within each block. */ -export function mrkdwnToHtml(text: string): string { +export function mrkdwnToHtml( + text: string, + options?: { useBr?: boolean } +): string { if (!text) return '' // Phase 1: Extract code blocks (``` ... ```) and replace with placeholders @@ -102,8 +115,8 @@ export function mrkdwnToHtml(text: string): string { return `\uE000CB${idx}\uE000` }) - // Line-break span used instead of
for CSS-controllable spacing - const BR = '' + // Line-break element:
for compact blocks, styled span for messages + const BR = options?.useBr ? '
' : '' // Phase 2: Process line-by-line for block-level elements const lines = text.split('\n') From 542f30a716ac0031da2f7f66b222f410f62849b5 Mon Sep 17 00:00:00 2001 From: Tyom Semonov Date: Sun, 22 Feb 2026 01:13:54 +0000 Subject: [PATCH 19/22] fix: use alias for app module imports in components --- apps/ui/src/components/Message.svelte | 8 ++++---- apps/ui/src/components/MessagePanel.svelte | 8 ++++---- apps/ui/src/components/ThreadPanel.svelte | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/ui/src/components/Message.svelte b/apps/ui/src/components/Message.svelte index 6eb0945..391f77e 100644 --- a/apps/ui/src/components/Message.svelte +++ b/apps/ui/src/components/Message.svelte @@ -15,18 +15,18 @@ SlackActionsBlock, SlackContextActionsBlock, SlackInputBlock, - } from '../lib/types' + } from '$lib/types' import { getAllMessageShortcuts, getChannelDisplayName, simulatorState, isBotUserId, getBotByUserId, - } from '../lib/state.svelte' + } from '$lib/state.svelte' import { sendMessageBlockAction, triggerMessageShortcut, - } from '../lib/dispatcher.svelte' + } from '$lib/dispatcher.svelte' import { resolveEmoji } from '@botarium/mrkdwn' import BlockKitRenderer from './blockkit/BlockKitRenderer.svelte' import ImageBlock from './blockkit/blocks/ImageBlock.svelte' @@ -36,7 +36,7 @@ formatTimestampShort, formatRelativeTime, formatFullDate, - } from '../lib/time' + } from '$lib/time' import * as ContextMenu from '$lib/components/ui/context-menu' import * as DropdownMenu from '$lib/components/ui/dropdown-menu' diff --git a/apps/ui/src/components/MessagePanel.svelte b/apps/ui/src/components/MessagePanel.svelte index 757c69e..cf0242b 100644 --- a/apps/ui/src/components/MessagePanel.svelte +++ b/apps/ui/src/components/MessagePanel.svelte @@ -47,7 +47,7 @@ Trash2, } from '@lucide/svelte' import { tick } from 'svelte' - import { clearChannelMessages, deleteMessage } from '../lib/dispatcher.svelte' + import { clearChannelMessages, deleteMessage } from '$lib/dispatcher.svelte' import { getChannelDisplayName, getChannelMessages, @@ -55,9 +55,9 @@ getReplyCount, hasThreadDraft, simulatorState, - } from '../lib/state.svelte' - import type { Channel } from '../lib/types' - import { formatDateLabel, getDateKey, isWithinMinutes } from '../lib/time' + } from '$lib/state.svelte' + import type { Channel } from '$lib/types' + import { formatDateLabel, getDateKey, isWithinMinutes } from '$lib/time' import BotAboutHeader from './BotAboutHeader.svelte' import DaySeparator from './DaySeparator.svelte' import Message from './Message.svelte' diff --git a/apps/ui/src/components/ThreadPanel.svelte b/apps/ui/src/components/ThreadPanel.svelte index 48be3bd..f9ce12f 100644 --- a/apps/ui/src/components/ThreadPanel.svelte +++ b/apps/ui/src/components/ThreadPanel.svelte @@ -1,15 +1,15 @@ +{#snippet shortcutItemContent(group: BotShortcutGroup, shortcut: Shortcut)} + + {#if group.botIcon?.startsWith('http')} + + {:else} + {group.botIcon || group.botName.charAt(0).toUpperCase()} + {/if} + {shortcut.name} + {group.botName} + +{/snippet} +
handleShortcut(shortcut)} > - - {#if group.botIcon?.startsWith('http')} - - {:else} - {group.botIcon || - group.botName.charAt(0).toUpperCase()} - {/if} - {shortcut.name} - {group.botName} - + {@render shortcutItemContent(group, shortcut)} {/each} {/each} @@ -574,24 +575,7 @@ {/if} {#each group.shortcuts as shortcut (shortcut.callback_id)} handleShortcut(shortcut)}> - - {#if group.botIcon?.startsWith('http')} - - {:else} - {group.botIcon || - group.botName.charAt(0).toUpperCase()} - {/if} - {shortcut.name} - {group.botName} - + {@render shortcutItemContent(group, shortcut)} {/each} {/each} diff --git a/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte b/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte index aba3b24..b0452f9 100644 --- a/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte +++ b/apps/ui/src/components/blockkit/blocks/SectionBlock.svelte @@ -9,7 +9,7 @@ SlackDatePickerElement, SlackTimePickerElement, SlackWorkspaceSelectElement, - } from '../../../lib/types' + } from '$lib/types' import { renderMrkdwn } from '../context' import Button from '../elements/Button.svelte' import StaticSelect from '../elements/StaticSelect.svelte' diff --git a/packages/mrkdwn/src/mrkdwn-to-html.ts b/packages/mrkdwn/src/mrkdwn-to-html.ts index 401cbc7..41e9db2 100644 --- a/packages/mrkdwn/src/mrkdwn-to-html.ts +++ b/packages/mrkdwn/src/mrkdwn-to-html.ts @@ -36,7 +36,7 @@ function formatInline(text: string): string { // Links without protocol: and text = text.replace( - /<([^@#!][^|]*?)\|(.*?)>/g, + /<([^@#!][^|]*?\.[^|]*?)\|(.*?)>/g, '$2' ) text = text.replace(