From e984bf20ac511d12e9c930d056dd2f66696597d1 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Mon, 10 Aug 2026 09:07:42 +0100 Subject: [PATCH] feat: dashboard UI/UX overhaul from full review Shared foundation: - New Modal component on native .showModal() (focus trap, inert background, Escape, focus restore, scroll lock); all eight dialogs and both globalThis.confirm calls migrated to it - Design tokens for success/warning/danger surfaces; pill danger variant; .sr-only utility; toast layering above dialog backdrops; not-allowed cursor for validity-disabled buttons Conversations/attention: auto-scroll with jump-to-latest, per-conversation draft persistence, suggestion undo, 30s auto-refresh with refresh controls, unread emphasis and counts, blur-consistent share dialog, empty-accounts guidance, collapsed user lookup, relative timestamps, danger pills for critical items Media/library/outreach: click-blurred-to-reveal, lightbox with arrow navigation and captions, dirty-form preservation across refetches, upload validation with thumbnail preview, save feedback, result counts with load-more and clear-filters, ticking countdowns from a single clock Overview/performance/operations/settings: mutation errors inside the confirm dialog, live sending-window countdown, offline-aware loading states, per-panel loading/error with retry, 7/30/90-day range toggle, console scroll yielding with shown/retained counts, risky-save matching by subreddit name instead of index, useBlocker unsaved-changes guard, single-pass validation with focus-to-first-error, access panel JSON validation and input reset Tests: 806 -> 844 passing; both typechecks clean. Co-Authored-By: Claude Fable 5 --- apps/dashboard/src/api/queries.ts | 2 +- .../src/components/confirmation-dialog.tsx | 45 ++-- apps/dashboard/src/components/modal.tsx | 79 +++++++ apps/dashboard/src/components/origin-post.tsx | 92 +++----- .../src/features/attention/attention-page.tsx | 65 +++++- .../src/features/attention/attention.css | 6 + .../features/conversations/composer-drafts.ts | 19 ++ .../conversations/conversation-detail.tsx | 219 +++++++++++++----- .../conversations/conversation-queries.ts | 12 +- .../conversations/conversations-page.tsx | 51 +++- .../features/conversations/conversations.css | 35 ++- .../media-share-confirmation-dialog.tsx | 67 ++++-- .../conversations/processed-user-lookup.tsx | 30 ++- .../send-confirmation-dialog.tsx | 48 ++-- .../conversations/start-conversation-form.tsx | 25 +- .../media-library/media-library-page.tsx | 194 ++++++++++++++-- .../features/media-library/media-library.css | 10 +- apps/dashboard/src/features/media/filters.ts | 6 + .../src/features/media/full-sync-dialog.tsx | 39 ++-- .../src/features/media/media-card.tsx | 17 +- .../src/features/media/media-page.tsx | 126 +++++++--- .../features/media/media-preview-dialog.tsx | 124 ++++++++-- apps/dashboard/src/features/media/media.css | 23 +- .../features/operations/operations-page.tsx | 117 +++++++++- .../src/features/operations/operations.css | 6 +- .../src/features/outreach/outreach-page.tsx | 68 +++++- .../src/features/outreach/outreach.css | 7 +- .../src/features/overview/overview-page.tsx | 57 ++++- .../src/features/overview/recent-messages.tsx | 17 +- .../features/performance/performance-page.tsx | 143 +++++++++--- .../src/features/performance/performance.css | 9 +- .../src/features/settings/access-panel.tsx | 43 +++- .../src/features/settings/risk-dialog.tsx | 34 ++- .../src/features/settings/settings-editor.tsx | 122 +++++++++- .../src/features/settings/settings-model.ts | 70 +++++- .../features/settings/settings-sections.tsx | 6 +- .../src/features/settings/settings.css | 11 +- apps/dashboard/src/styles.css | 45 ++-- package-lock.json | 1 + tests/dashboard/app.test.tsx | 59 ++++- tests/dashboard/attention.test.tsx | 108 +++++++++ tests/dashboard/conversations.test.tsx | 137 ++++++++++- tests/dashboard/media-library.test.tsx | 152 +++++++++++- tests/dashboard/media.test.tsx | 149 +++++++++++- .../dashboard/performance-operations.test.tsx | 106 ++++++++- tests/dashboard/settings.test.tsx | 156 ++++++++++++- 46 files changed, 2462 insertions(+), 495 deletions(-) create mode 100644 apps/dashboard/src/components/modal.tsx create mode 100644 apps/dashboard/src/features/conversations/composer-drafts.ts create mode 100644 tests/dashboard/attention.test.tsx diff --git a/apps/dashboard/src/api/queries.ts b/apps/dashboard/src/api/queries.ts index 22c9804..5893e95 100644 --- a/apps/dashboard/src/api/queries.ts +++ b/apps/dashboard/src/api/queries.ts @@ -16,7 +16,7 @@ export function useStatsQuery() { } export function useTimersQuery() { - return useQuery({ queryKey: dashboardQueryKeys.timers, queryFn: dashboardApi.getTimers }); + return useQuery({ queryKey: dashboardQueryKeys.timers, queryFn: dashboardApi.getTimers, refetchInterval: 15_000 }); } export function useSetRunningMutation() { diff --git a/apps/dashboard/src/components/confirmation-dialog.tsx b/apps/dashboard/src/components/confirmation-dialog.tsx index ad71ef1..60fcbdb 100644 --- a/apps/dashboard/src/components/confirmation-dialog.tsx +++ b/apps/dashboard/src/components/confirmation-dialog.tsx @@ -1,39 +1,34 @@ -import { useEffect, useRef } from 'react'; +import { Modal } from './modal.js'; interface ConfirmationDialogProps { action: 'start' | 'stop'; + errorMessage?: string | null; isPending: boolean; onCancel(): void; onConfirm(): void; } -export function ConfirmationDialog({ action, isPending, onCancel, onConfirm }: ConfirmationDialogProps) { - const cancelButtonRef = useRef(null); +export function ConfirmationDialog({ action, errorMessage = null, isPending, onCancel, onConfirm }: ConfirmationDialogProps) { const isStarting = action === 'start'; - useEffect(() => { - cancelButtonRef.current?.focus(); - }, []); - return ( -
-
- Confirm action -

{isStarting ? 'Start bot?' : 'Stop bot?'}

-

- {isStarting - ? 'The bot will resume automated processing.' - : 'The bot will pause after its current operation.'} -

-
- - -
+ + Confirm action +

{isStarting ? 'Start bot?' : 'Stop bot?'}

+

+ {isStarting + ? 'The bot will resume automated processing.' + : 'The bot will pause after its current operation.'} +

+ {errorMessage ?

{errorMessage}

: null} +
+ +
-
+ ); } diff --git a/apps/dashboard/src/components/modal.tsx b/apps/dashboard/src/components/modal.tsx new file mode 100644 index 0000000..69e1c15 --- /dev/null +++ b/apps/dashboard/src/components/modal.tsx @@ -0,0 +1,79 @@ +import { useEffect, useRef, useState, type PropsWithChildren, type ReactNode } from 'react'; + +interface ModalProps { + /** Class applied to the element itself (it is the visible card). */ + className?: string; + /** Blocks Escape/backdrop dismissal while a mutation is in flight. */ + isDismissLocked?: boolean; + /** Close when the backdrop (the ::backdrop area of the dialog) is pressed. */ + closeOnBackdrop?: boolean; + label?: string; + labelledBy?: string; + onClose(): void; +} + +/** + * Shared modal built on native .showModal(): the browser provides + * focus trapping, inert background, and Escape handling. Render conditionally; + * mounting opens the dialog and unmounting restores focus to the opener. + */ +export function Modal({ + children, + className, + closeOnBackdrop = false, + isDismissLocked = false, + label, + labelledBy, + onClose, +}: PropsWithChildren): ReactNode { + const dialogRef = useRef(null); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + const dismissLockedRef = useRef(isDismissLocked); + dismissLockedRef.current = isDismissLocked; + + // Captured at first render, before autoFocus moves focus into the dialog. + const [previouslyFocused] = useState(() => document.activeElement); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (!dialog.open) { + if (typeof dialog.showModal === 'function') dialog.showModal(); + else dialog.setAttribute('open', ''); + } + return () => { + if (previouslyFocused instanceof HTMLElement && previouslyFocused.isConnected) { + previouslyFocused.focus(); + } + }; + }, [previouslyFocused]); + + function requestClose(): void { + if (!dismissLockedRef.current) onCloseRef.current(); + } + + return ( + { + event.preventDefault(); + requestClose(); + }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault(); + requestClose(); + } + }} + onMouseDown={(event) => { + if (closeOnBackdrop && event.target === event.currentTarget) requestClose(); + }} + ref={dialogRef} + > + {children} + + ); +} diff --git a/apps/dashboard/src/components/origin-post.tsx b/apps/dashboard/src/components/origin-post.tsx index fb1fe61..06d93ec 100644 --- a/apps/dashboard/src/components/origin-post.tsx +++ b/apps/dashboard/src/components/origin-post.tsx @@ -1,4 +1,5 @@ -import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; +import { useState, type ReactNode } from 'react'; +import { Modal } from './modal.js'; export interface OriginPost { title: string; @@ -36,27 +37,6 @@ function PostOriginMeta({ post }: { post: OriginPost }) { export function OriginPostCard({ post }: { post: OriginPost }) { const [isImageRevealed, setIsImageRevealed] = useState(false); const [isImagePreviewOpen, setIsImagePreviewOpen] = useState(false); - const previewTriggerRef = useRef(null); - const previewCloseRef = useRef(null); - - const closeImagePreview = useCallback(() => { - setIsImagePreviewOpen(false); - queueMicrotask(() => previewTriggerRef.current?.focus()); - }, []); - - useEffect(() => { - if (!isImagePreviewOpen) return; - previewCloseRef.current?.focus(); - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') closeImagePreview(); - if (event.key === 'Tab') { - event.preventDefault(); - previewCloseRef.current?.focus(); - } - }; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [closeImagePreview, isImagePreviewOpen]); return ( <> @@ -64,11 +44,14 @@ export function OriginPostCard({ post }: { post: OriginPost }) { {post.imageUrl ? (
{isImagePreviewOpen && post.imageUrl ? ( -
{ if (event.target === event.currentTarget) closeImagePreview(); }} - role="dialog" + setIsImagePreviewOpen(false)} >
{`Source - +
-
+ ) : null} ); @@ -109,50 +91,34 @@ export function OriginPostCard({ post }: { post: OriginPost }) { export function OriginPostButton({ children, post }: { children: ReactNode; post: OriginPost }) { const [isOpen, setIsOpen] = useState(false); - const triggerRef = useRef(null); - const closeRef = useRef(null); - - useEffect(() => { - if (!isOpen) return; - closeRef.current?.focus(); - const closeOnEscape = (event: KeyboardEvent) => { - if (event.key === 'Escape') setIsOpen(false); - }; - document.addEventListener('keydown', closeOnEscape); - return () => document.removeEventListener('keydown', closeOnEscape); - }, [isOpen]); - - function close() { - setIsOpen(false); - queueMicrotask(() => triggerRef.current?.focus()); - } return ( <> - {isOpen ? ( -
{ if (event.target === event.currentTarget) close(); }} - role="dialog" + setIsOpen(false)} > -
+ ) : null} ); diff --git a/apps/dashboard/src/features/attention/attention-page.tsx b/apps/dashboard/src/features/attention/attention-page.tsx index d6c182b..9901aa9 100644 --- a/apps/dashboard/src/features/attention/attention-page.tsx +++ b/apps/dashboard/src/features/attention/attention-page.tsx @@ -2,15 +2,31 @@ import { useQuery } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import type { AttentionItem } from '../../../../../packages/api-contracts/src/index.js'; import { AccountOwnerBadge } from '../../components/account-owner-badge.js'; +import { formatExactTime, formatShortRelativeTime } from '../conversations/relative-time.js'; import { attentionApi } from './attention-api.js'; import './attention.css'; -function formatTime(timestamp: number): string { - return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp); +function relativeTime(timestamp: number, now: number) { + return ( + + ); +} + +function actionHint(kind: AttentionItem['kind']): string { + switch (kind) { + case 'account-login-required': + return 'No linked conversation — check Settings.'; + case 'runtime-failed': + case 'invitation-cooldown': + default: + return 'No linked conversation — check Operations.'; + } } function itemAction(item: AttentionItem) { - if (!item.conversationId) return null; + if (!item.conversationId) return {actionHint(item.kind)}; return Open conversation; } @@ -18,14 +34,51 @@ export function AttentionPage() { const query = useQuery({ queryKey: ['attention'], queryFn: attentionApi.getAttention, refetchInterval: 30_000 }); if (query.isPending) return
Loading attention inbox…
; if (query.isError) return
Attention inbox unavailable{query.error.message}
; + const now = Date.now(); const critical = query.data.items.filter((item) => item.severity === 'critical').length; return (
-
Operator queue

Needs attention

Boundaries, human handoffs, delivery failures, account problems, and Reddit cooldowns in one actionable inbox.

-
0 ? 'pill warning' : 'pill success'}>{critical} criticalUpdated {formatTime(query.data.generatedAt)}
+
+
+ Operator queue +

Needs attention

+

Boundaries, human handoffs, delivery failures, account problems, and Reddit cooldowns in one actionable inbox.

+
+
+ + Auto-refreshes every 30s +
+
+
+
+
+ 0 ? 'pill danger' : 'pill success'}>{critical} critical + Updated {relativeTime(query.data.generatedAt, now)} +
{query.data.items.length === 0 ?
Nothing needs attention.Automation and account health have no outstanding operator items.
: (
    {query.data.items.map((item) => ( -
  1. {item.kind.replaceAll('-', ' ')}

    {item.title}

    {item.detail}

    {item.owner ? : null}{itemAction(item)}
  2. +
  3. +
    +
    + {item.kind.replaceAll('-', ' ')} +

    {item.title}

    +
    + {relativeTime(item.occurredAt, now)} +
    +

    {item.detail}

    + {item.owner ? : null} + {itemAction(item)} +
  4. ))}
)}
diff --git a/apps/dashboard/src/features/attention/attention.css b/apps/dashboard/src/features/attention/attention.css index 694522f..e141314 100644 --- a/apps/dashboard/src/features/attention/attention.css +++ b/apps/dashboard/src/features/attention/attention.css @@ -6,3 +6,9 @@ .attention-item-heading time { color: var(--muted); font-size: .8rem; text-align: right; } .attention-item p { margin: 0; color: var(--muted); } .attention-item .button { justify-self: start; text-decoration: none; } +.attention-item-hint { justify-self: start; color: var(--muted); font-size: .82rem; font-style: italic; } +.attention-refresh { display: grid; gap: .3rem; justify-items: end; } +.attention-refresh-note { color: var(--muted); font-size: .72rem; } +.attention-status-dot { width: .7rem; height: .7rem; border-radius: 50%; } +.attention-status-dot.is-clear { background: var(--success-text); box-shadow: 0 0 0 .35rem rgba(170, 240, 196, .12); } +.attention-status-dot.is-open { background: var(--danger); box-shadow: 0 0 0 .35rem rgba(255, 138, 132, .1); } diff --git a/apps/dashboard/src/features/conversations/composer-drafts.ts b/apps/dashboard/src/features/conversations/composer-drafts.ts new file mode 100644 index 0000000..2c71a9e --- /dev/null +++ b/apps/dashboard/src/features/conversations/composer-drafts.ts @@ -0,0 +1,19 @@ +/** + * In-memory store of composer drafts keyed by conversation id, so switching + * conversations and back restores what the operator was typing. + */ +const drafts = new Map(); + +export function getStoredComposerDraft(conversationId: string): string { + return drafts.get(conversationId) ?? ''; +} + +export function storeComposerDraft(conversationId: string, draft: string): void { + if (draft.length === 0) drafts.delete(conversationId); + else drafts.set(conversationId, draft); +} + +/** Test helper: clear all stored drafts between test cases. */ +export function resetComposerDrafts(): void { + drafts.clear(); +} diff --git a/apps/dashboard/src/features/conversations/conversation-detail.tsx b/apps/dashboard/src/features/conversations/conversation-detail.tsx index c6f8947..0fe8c32 100644 --- a/apps/dashboard/src/features/conversations/conversation-detail.tsx +++ b/apps/dashboard/src/features/conversations/conversation-detail.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { ConversationDetailResponse, MediaLibraryAsset } from '../../../../../packages/api-contracts/src/index.js'; import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js'; import { OriginPostCard } from '../../components/origin-post.js'; @@ -13,84 +13,132 @@ import { import { SendConfirmationDialog } from './send-confirmation-dialog.js'; import { MediaShareConfirmationDialog } from './media-share-confirmation-dialog.js'; import { AccountOwnerBadge } from '../../components/account-owner-badge.js'; +import { formatExactTime } from './relative-time.js'; +import { getStoredComposerDraft, storeComposerDraft } from './composer-drafts.js'; + +/** Reddit chat rejects messages longer than roughly this many characters. */ +export const REDDIT_CHAT_MAX_MESSAGE_LENGTH = 10_000; +const CHARACTER_WARNING_MARGIN = 500; +const SUCCESS_NOTICE_DISMISS_MS = 5_000; +const AT_BOTTOM_THRESHOLD_PX = 48; interface ConversationDetailProps { conversationId: string; onClose(): void; } -function formatMessageTime(timestamp: number): string { - return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp); -} - function mediaPath(localPath: string): string { return `/media/${localPath.split(/[\\/]/).map(encodeURIComponent).join('/')}`; } +/** Auto-dismisses a success flag after a few seconds. */ +function useAutoDismiss(isVisible: boolean, dismiss: () => void) { + useEffect(() => { + if (!isVisible) return; + const timer = window.setTimeout(dismiss, SUCCESS_NOTICE_DISMISS_MS); + return () => window.clearTimeout(timer); + }, [isVisible, dismiss]); +} + function MessageHistory({ conversation }: { conversation: ConversationDetailResponse }) { const [revealedMedia, setRevealedMedia] = useState>(() => new Set()); + const listRef = useRef(null); + const isAtBottomRef = useRef(true); + const [isAtBottom, setIsAtBottom] = useState(true); + const messageCount = conversation.messages?.length ?? 0; + + const scrollToBottom = useCallback(() => { + const list = listRef.current; + if (!list) return; + list.scrollTop = list.scrollHeight; + isAtBottomRef.current = true; + setIsAtBottom(true); + }, []); + + // Stick to the latest message on mount and when new messages arrive, + // unless the operator has scrolled up to read history. + useEffect(() => { + if (isAtBottomRef.current) scrollToBottom(); + }, [messageCount, scrollToBottom]); + + function handleScroll() { + const list = listRef.current; + if (!list) return; + const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight <= AT_BOTTOM_THRESHOLD_PX; + isAtBottomRef.current = atBottom; + setIsAtBottom(atBottom); + } if (!conversation.messages || conversation.messages.length === 0) { return
No messages have been loaded for this conversation.
; } return ( -
    - {conversation.messages.map((message) => ( -
  1. -
    - {message.isSelf - ? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}` - : } - {message.isAi ? AI : null} - -
    -

    {message.text}

    - {message.media?.length ? ( -
    - {message.media.map((media) => { - const mediaKey = `${message.timestamp}-${media.localPath}`; - const sender = message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`; - const isRevealed = revealedMedia.has(mediaKey); - - return ( -
    - {`Image - -
    - ); - })} +
    +
      + {conversation.messages.map((message) => ( +
    1. +
      + {message.isSelf + ? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}` + : } + {message.isAi ? AI : null} +
      - ) : null} -
    2. - ))} -
    +

    {message.text}

    + {message.media?.length ? ( +
    + {message.media.map((media) => { + const mediaKey = `${message.timestamp}-${media.localPath}`; + const sender = message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`; + const isRevealed = revealedMedia.has(mediaKey); + + return ( +
    + {`Image + +
    + ); + })} +
    + ) : null} +
  2. + ))} +
+ {!isAtBottom ? ( + + ) : null} +
); } function ConversationComposer({ conversation, conversationId }: { conversation: ConversationDetailResponse; conversationId: string }) { - const [draft, setDraft] = useState(''); + const [draft, setDraft] = useState(() => getStoredComposerDraft(conversationId)); + const [restorableDraft, setRestorableDraft] = useState(null); const [isConfirming, setIsConfirming] = useState(false); const [wasSent, setWasSent] = useState(false); const [selectedAssetId, setSelectedAssetId] = useState(null); const [areSavedImagesRevealed, setAreSavedImagesRevealed] = useState(false); + const [isMediaShareOpen, setIsMediaShareOpen] = useState(false); const [isConfirmingMedia, setIsConfirmingMedia] = useState(false); const [wasMediaShared, setWasMediaShared] = useState(false); const sendButtonRef = useRef(null); @@ -98,12 +146,23 @@ function ConversationComposer({ conversation, conversationId }: { conversation: const suggestionMutation = useSuggestReplyMutation(conversationId); const mediaAskMutation = useSuggestMediaAskMutation(conversationId); const sendMutation = useSendReplyMutation(conversationId); - const mediaOptionsQuery = useConversationMediaShareOptionsQuery(conversationId); + const mediaOptionsQuery = useConversationMediaShareOptionsQuery(conversationId, isMediaShareOpen); const mediaShareMutation = useShareConversationMediaMutation(conversationId); const mediaOptions = mediaOptionsQuery.data?.assets ?? []; const selectedAsset: MediaLibraryAsset | null = mediaOptions.find((asset) => asset.id === selectedAssetId) ?? null; const canSend = draft.trim().length > 0 && !sendMutation.isPending; const automationActive = conversation.automation.status === 'active' && conversation.automation.remainingTurns > 0; + const isNearCharacterLimit = draft.length >= REDDIT_CHAT_MAX_MESSAGE_LENGTH - CHARACTER_WARNING_MARGIN; + + const updateDraft = useCallback((next: string) => { + setDraft(next); + storeComposerDraft(conversationId, next); + }, [conversationId]); + + const dismissSent = useCallback(() => setWasSent(false), []); + const dismissMediaShared = useCallback(() => setWasMediaShared(false), []); + useAutoDismiss(wasSent, dismissSent); + useAutoDismiss(wasMediaShared, dismissMediaShared); const closeConfirmation = useCallback(() => { setIsConfirming(false); @@ -115,21 +174,29 @@ function ConversationComposer({ conversation, conversationId }: { conversation: queueMicrotask(() => mediaShareSummaryRef.current?.focus()); }, []); + function applySuggestion(suggestion: string) { + const priorDraft = draft; + updateDraft(suggestion); + if (priorDraft.trim().length > 0 && priorDraft !== suggestion) setRestorableDraft(priorDraft); + else setRestorableDraft(null); + } + function suggestReply() { setWasSent(false); - suggestionMutation.mutate(undefined, { onSuccess: (suggestion) => setDraft(suggestion) }); + suggestionMutation.mutate(undefined, { onSuccess: applySuggestion }); } function suggestMediaAsk() { setWasSent(false); - mediaAskMutation.mutate(undefined, { onSuccess: (suggestion) => setDraft(suggestion) }); + mediaAskMutation.mutate(undefined, { onSuccess: applySuggestion }); } function sendReply() { if (!canSend) return; sendMutation.mutate(draft, { onSuccess() { - setDraft(''); + updateDraft(''); + setRestorableDraft(null); setWasSent(true); }, onSettled: closeConfirmation, @@ -168,24 +235,48 @@ function ConversationComposer({ conversation, conversationId }: { conversation: