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: