feat(dashboard): close legacy parity gaps

This commit is contained in:
jjbrunton committed 2026-08-08 15:41:34 +01:00
1 parent 92287c0a9a
commit 4b3f00729f
21 files changed
+397 -28

No files matched your search

@@ -3,6 +3,7 @@ import {
ConversationsResponseSchema,
ConversationDetailResponseSchema,
ErrorResponseSchema,
ProcessedPostsResponseSchema,
SendReplyRequestSchema,
StartConversationRequestSchema,
StartConversationResponseSchema,
@@ -10,6 +11,7 @@ import {
SuggestedReplyResponseSchema,
type ActiveChat,
type ConversationDetailResponse,
type ProcessedPost,
type SuccessResponse,
} from '../../../../../packages/api-contracts/src/index.js';
@@ -30,6 +32,8 @@ export interface ConversationApi {
list(): Promise<ActiveChat[]>;
detail(id: string): Promise<ConversationDetailResponse>;
suggestReply(id: string): Promise<string>;
suggestMediaAsk(id: string): Promise<string>;
processedPosts(username: string): Promise<ProcessedPost[]>;
sendReply(id: string, message: string): Promise<SuccessResponse>;
startConversation(username: string, message: string): Promise<ActiveChat>;
}
@@ -53,6 +57,19 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve
);
return response.suggestedReply;
},
async suggestMediaAsk(id) {
const response = await parseResponse(
await request(`${conversationPath(id)}/suggest-media-ask`, { method: 'POST' }),
SuggestedReplyResponseSchema,
);
return response.suggestedReply;
},
async processedPosts(username) {
return parseResponse(
await request(`/api/posts/by-author/${encodeURIComponent(username.trim().replace(/^u\//i, ''))}`),
ProcessedPostsResponseSchema,
);
},
async sendReply(id, message) {
const body = SendReplyRequestSchema.parse({ message: message.trim() });
return parseResponse(
@@ -1,6 +1,6 @@
import { useCallback, useRef, useState } from 'react';
import type { ConversationDetailResponse } from '../../../../../packages/api-contracts/src/index.js';
import { useConversationDetailQuery, useSendReplyMutation, useSuggestReplyMutation } from './conversation-queries.js';
import { useConversationDetailQuery, useSendReplyMutation, useSuggestMediaAskMutation, useSuggestReplyMutation } from './conversation-queries.js';
import { SendConfirmationDialog } from './send-confirmation-dialog.js';
interface ConversationDetailProps {
@@ -38,6 +38,7 @@ function ConversationComposer({ conversation }: { conversation: ConversationDeta
const [wasSent, setWasSent] = useState(false);
const sendButtonRef = useRef<HTMLButtonElement>(null);
const suggestionMutation = useSuggestReplyMutation(conversation.roomId ?? conversation.username);
const mediaAskMutation = useSuggestMediaAskMutation(conversation.roomId ?? conversation.username);
const sendMutation = useSendReplyMutation(conversation.roomId ?? conversation.username);
const canSend = draft.trim().length > 0 && !sendMutation.isPending;
@@ -51,6 +52,11 @@ function ConversationComposer({ conversation }: { conversation: ConversationDeta
suggestionMutation.mutate(undefined, { onSuccess: (suggestion) => setDraft(suggestion) });
}
function suggestMediaAsk() {
setWasSent(false);
mediaAskMutation.mutate(undefined, { onSuccess: (suggestion) => setDraft(suggestion) });
}
function sendReply() {
if (!canSend) return;
sendMutation.mutate(draft, {
@@ -69,6 +75,9 @@ function ConversationComposer({ conversation }: { conversation: ConversationDeta
<button disabled={suggestionMutation.isPending} onClick={suggestReply} type="button">
{suggestionMutation.isPending ? 'Generating…' : 'Suggest reply'}
</button>
<button disabled={mediaAskMutation.isPending} onClick={suggestMediaAsk} type="button">
{mediaAskMutation.isPending ? 'Generating media ask…' : 'Suggest media ask'}
</button>
</div>
<label className="sr-only" htmlFor="conversation-reply">Reply to u/{conversation.username}</label>
<textarea
@@ -82,6 +91,7 @@ function ConversationComposer({ conversation }: { conversation: ConversationDeta
value={draft}
/>
{suggestionMutation.isError ? <div className="conversation-inline-error" role="alert">{suggestionMutation.error.message}</div> : null}
{mediaAskMutation.isError ? <div className="conversation-inline-error" role="alert">{mediaAskMutation.error.message}</div> : null}
{sendMutation.isError ? <div className="conversation-inline-error" role="alert">{sendMutation.error.message}</div> : null}
{wasSent ? (
<div aria-label="Reply sent" className="conversation-inline-success" role="status">Reply sent to u/{conversation.username}</div>
@@ -4,6 +4,7 @@ import { conversationApi } from './conversation-api.js';
export const conversationQueryKeys = {
all: ['conversations'] as const,
detail: (id: string) => ['conversations', 'detail', id] as const,
processedPosts: (username: string) => ['conversations', 'processed-posts', username] as const,
};
export function useConversationsQuery() {
@@ -22,6 +23,18 @@ export function useSuggestReplyMutation(id: string) {
return useMutation({ mutationFn: () => conversationApi.suggestReply(id) });
}
export function useSuggestMediaAskMutation(id: string) {
return useMutation({ mutationFn: () => conversationApi.suggestMediaAsk(id) });
}
export function useProcessedPostsQuery(username: string) {
return useQuery({
queryKey: conversationQueryKeys.processedPosts(username),
queryFn: () => conversationApi.processedPosts(username),
enabled: username.length > 0,
});
}
export function useSendReplyMutation(id: string) {
const queryClient = useQueryClient();
return useMutation({
@@ -1,22 +1,38 @@
import { useNavigate, useParams } from 'react-router-dom';
import { ConversationsPage } from './conversations-page.js';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ConversationsPage, type ConversationFilters } from './conversations-page.js';
export function ConversationRoute() {
const navigate = useNavigate();
const { conversationId } = useParams<{ conversationId: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const filterValue = searchParams.get('filter');
const filters: ConversationFilters = {
search: searchParams.get('q') ?? '',
filter: filterValue === 'unread' || filterValue === 'awaiting-reply' ? filterValue : 'all',
};
function selectConversation(nextConversationId: string | null) {
const suffix = searchParams.size > 0 ? `?${searchParams.toString()}` : '';
void navigate(
nextConversationId
? `/conversations/${encodeURIComponent(nextConversationId)}`
: '/conversations',
? `/conversations/${encodeURIComponent(nextConversationId)}${suffix}`
: `/conversations${suffix}`,
);
}
function updateFilters(next: ConversationFilters) {
const params = new URLSearchParams(searchParams);
if (next.search) params.set('q', next.search); else params.delete('q');
if (next.filter !== 'all') params.set('filter', next.filter); else params.delete('filter');
setSearchParams(params, { replace: true });
}
return (
<ConversationsPage
selectedConversationId={conversationId ?? null}
onSelectedConversationChange={selectConversation}
filters={filters}
onFiltersChange={updateFilters}
/>
);
}
@@ -3,13 +3,21 @@ import type { ActiveChat } from '../../../../../packages/api-contracts/src/index
import { ConversationDetail } from './conversation-detail.js';
import { useConversationsQuery } from './conversation-queries.js';
import { StartConversationForm } from './start-conversation-form.js';
import { ProcessedUserLookup } from './processed-user-lookup.js';
import './conversations.css';
type ConversationFilter = 'all' | 'unread' | 'awaiting-reply';
export type ConversationFilter = 'all' | 'unread' | 'awaiting-reply';
export interface ConversationFilters {
search: string;
filter: ConversationFilter;
}
export interface ConversationsPageProps {
selectedConversationId?: string | null;
onSelectedConversationChange?(conversationId: string | null): void;
filters?: ConversationFilters;
onFiltersChange?(filters: ConversationFilters): void;
}
function conversationId(conversation: ActiveChat): string {
@@ -44,23 +52,28 @@ function ConversationListItem({ conversation, isSelected, onSelect }: {
);
}
export function ConversationsPage({ selectedConversationId, onSelectedConversationChange }: ConversationsPageProps) {
export function ConversationsPage({ selectedConversationId, onSelectedConversationChange, filters, onFiltersChange }: ConversationsPageProps) {
const conversationsQuery = useConversationsQuery();
const [internalSelectedId, setInternalSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<ConversationFilter>('all');
const [localFilters, setLocalFilters] = useState<ConversationFilters>({ search: '', filter: 'all' });
const [isStartingConversation, setIsStartingConversation] = useState(false);
const activeConversationId = selectedConversationId !== undefined ? selectedConversationId : internalSelectedId;
const activeFilters = filters ?? localFilters;
function updateFilters(next: ConversationFilters) {
if (filters === undefined) setLocalFilters(next);
onFiltersChange?.(next);
}
const visibleConversations = useMemo(() => {
const normalizedSearch = search.trim().toLowerCase();
const normalizedSearch = activeFilters.search.trim().toLowerCase();
return (conversationsQuery.data ?? []).filter((conversation) => {
const matchesSearch = normalizedSearch.length === 0
|| conversation.username.toLowerCase().includes(normalizedSearch)
|| conversation.lastMessageSnippet.toLowerCase().includes(normalizedSearch);
return matchesSearch && matchesFilter(conversation, filter);
return matchesSearch && matchesFilter(conversation, activeFilters.filter);
});
}, [conversationsQuery.data, filter, search]);
}, [activeFilters, conversationsQuery.data]);
function selectConversation(id: string | null) {
if (selectedConversationId === undefined) setInternalSelectedId(id);
@@ -86,6 +99,7 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
<StartConversationForm onStarted={(id) => selectConversation(id)} />
</section>
) : null}
<ProcessedUserLookup />
<div className="conversation-workspace">
<section aria-label="Conversation inbox" className="conversation-master">
@@ -93,16 +107,16 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
<label className="sr-only" htmlFor="conversation-search">Search conversations</label>
<input
id="conversation-search"
onChange={(event) => setSearch(event.target.value)}
onChange={(event) => updateFilters({ ...activeFilters, search: event.target.value })}
placeholder="Search conversations"
type="search"
value={search}
value={activeFilters.search}
/>
<label className="sr-only" htmlFor="conversation-filter">Filter conversations</label>
<select
id="conversation-filter"
onChange={(event) => setFilter(event.target.value as ConversationFilter)}
value={filter}
onChange={(event) => updateFilters({ ...activeFilters, filter: event.target.value as ConversationFilter })}
value={activeFilters.filter}
>
<option value="all">All</option>
<option value="unread">Unread</option>
@@ -0,0 +1,28 @@
import { useState } from 'react';
import { useProcessedPostsQuery } from './conversation-queries.js';
export function ProcessedUserLookup() {
const [input, setInput] = useState('');
const [username, setUsername] = useState('');
const postsQuery = useProcessedPostsQuery(username);
return (
<section aria-labelledby="processed-user-heading" className="new-conversation-panel">
<h2 id="processed-user-heading">Processed user lookup</h2>
<form onSubmit={(event) => { event.preventDefault(); setUsername(input.trim().replace(/^u\//i, '')); }}>
<label htmlFor="processed-user">Lookup Reddit username</label>
<input id="processed-user" onChange={(event) => setInput(event.target.value)} value={input} />
<button disabled={input.trim().length === 0} type="submit">Look up user</button>
</form>
{postsQuery.isPending && username ? <p role="status">Looking up processed posts…</p> : null}
{postsQuery.isError ? <p role="alert">{postsQuery.error.message}</p> : null}
{postsQuery.data ? (
postsQuery.data.length === 0 ? <p>No processed posts found for u/{username}.</p> : (
<ol aria-label={`Processed posts for u/${username}`}>
{postsQuery.data.map((post) => <li key={post.id}><strong>{post.title}</strong><span>r/{post.subreddit} · {post.messageSent ? 'Message sent' : post.error ?? 'Processed'}</span></li>)}
</ol>
)
) : null}
</section>
);
}
@@ -5,7 +5,7 @@ import { MediaCard } from './media-card.js';
import { useMediaQuery, useMediaSyncStatusQuery, useTriggerMediaSyncMutation } from './queries.js';
import './media.css';
const initialFilters: MediaFilters = {
export const initialMediaFilters: MediaFilters = {
conversation: 'all',
sender: 'all',
type: 'all',
@@ -20,11 +20,17 @@ function formatLastSync(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
}
export function MediaPage() {
export interface MediaPageProps {
filters?: MediaFilters;
onFiltersChange?(filters: MediaFilters): void;
}
export function MediaPage({ filters: controlledFilters, onFiltersChange }: MediaPageProps) {
const mediaQuery = useMediaQuery();
const statusQuery = useMediaSyncStatusQuery();
const syncMutation = useTriggerMediaSyncMutation();
const [filters, setFilters] = useState<MediaFilters>(initialFilters);
const [localFilters, setLocalFilters] = useState<MediaFilters>(initialMediaFilters);
const filters = controlledFilters ?? localFilters;
const [revealedIds, setRevealedIds] = useState<Set<string>>(() => new Set());
const [isRevealAll, setIsRevealAll] = useState(false);
const [isFullSyncDialogOpen, setIsFullSyncDialogOpen] = useState(false);
@@ -42,7 +48,9 @@ export function MediaPage() {
const isSyncBusy = statusQuery.data?.isSyncing === true || syncMutation.isPending;
function updateFilter<Key extends keyof MediaFilters>(key: Key, value: MediaFilters[Key]) {
setFilters((current) => ({ ...current, [key]: value }));
const next = { ...filters, [key]: value };
if (controlledFilters === undefined) setLocalFilters(next);
onFiltersChange?.(next);
}
function toggleItem(itemId: string) {
@@ -0,0 +1,32 @@
import { useSearchParams } from 'react-router-dom';
import type { MediaFilters } from './filters.js';
import { initialMediaFilters, MediaPage } from './media-page.js';
function parseType(value: string | null): MediaFilters['type'] {
return value === 'image' || value === 'gif' ? value : 'all';
}
function parseDateRange(value: string | null): MediaFilters['dateRange'] {
return value === '24h' || value === '7d' || value === '30d' ? value : 'all';
}
export function MediaRoute() {
const [searchParams, setSearchParams] = useSearchParams();
const filters: MediaFilters = {
conversation: searchParams.get('conversation') ?? initialMediaFilters.conversation,
sender: searchParams.get('sender') ?? initialMediaFilters.sender,
type: parseType(searchParams.get('type')),
dateRange: parseDateRange(searchParams.get('range')),
};
function updateFilters(next: MediaFilters) {
const params = new URLSearchParams();
if (next.conversation !== 'all') params.set('conversation', next.conversation);
if (next.sender !== 'all') params.set('sender', next.sender);
if (next.type !== 'all') params.set('type', next.type);
if (next.dateRange !== 'all') params.set('range', next.dateRange);
setSearchParams(params, { replace: true });
}
return <MediaPage filters={filters} onFiltersChange={updateFilters} />;
}
@@ -24,7 +24,8 @@ export type LiveConsoleAction =
| { type: 'set-paused'; paused: boolean }
| { type: 'set-auto-scroll'; enabled: boolean }
| { type: 'set-level'; level: ConsoleLevelFilter }
| { type: 'set-search'; search: string };
| { type: 'set-search'; search: string }
| { type: 'clear' };
function boundedAppend(
entries: readonly ConsoleEntry[],
@@ -89,6 +90,8 @@ export function reduceLiveConsoleState(
return { ...state, level: action.level };
case 'set-search':
return { ...state, search: action.search };
case 'clear':
return { ...state, entries: [], pendingEntries: [] };
}
}
@@ -2,6 +2,8 @@ import { z } from 'zod';
import {
ActivityResponseSchema,
ChatSyncStatusSchema,
ChatSyncTriggerResponseSchema,
FetchAllChatsResponseSchema,
} from '../../../../../packages/api-contracts/src/index.js';
export const RawLogEntrySchema = z.strictObject({
@@ -32,8 +34,8 @@ async function parseResponse<T>(
}
export function createOperationsApi(fetchImplementation?: typeof fetch) {
function request(path: string): Promise<Response> {
return (fetchImplementation ?? globalThis.fetch)(path);
function request(path: string, init?: RequestInit): Promise<Response> {
return (fetchImplementation ?? globalThis.fetch)(path, init);
}
return {
async getActivity() {
@@ -48,6 +50,18 @@ export function createOperationsApi(fetchImplementation?: typeof fetch) {
async getSyncStatus() {
return parseResponse(await request('/api/chats/sync'), ChatSyncStatusSchema);
},
async triggerSync(kind: 'delta' | 'full') {
const result = await parseResponse(
await request(`/api/chats/sync/${kind}`, { method: 'POST' }),
ChatSyncTriggerResponseSchema,
);
if (!('success' in result)) throw new Error(result.error);
if (!result.success) throw new Error(result.reason);
return result;
},
async fetchAllChats() {
return parseResponse(await request('/api/chats/fetch-all', { method: 'POST' }), FetchAllChatsResponseSchema);
},
};
}
@@ -8,7 +8,7 @@ import {
type ConsoleEntry,
type ConsoleLevelFilter,
} from './live-console-model.js';
import { useActivityQuery, useLogsQuery, useSyncStatusQuery } from './operations-queries.js';
import { useActivityQuery, useFetchAllChatsMutation, useLogsQuery, useSyncStatusQuery, useTriggerChatSyncMutation } from './operations-queries.js';
import {
createBrowserEventSource,
createDashboardRealtimeClient,
@@ -77,6 +77,9 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
const activityQuery = useActivityQuery();
const logsQuery = useLogsQuery();
const syncQuery = useSyncStatusQuery();
const syncMutation = useTriggerChatSyncMutation();
const fetchAllMutation = useFetchAllChatsMutation();
const [consoleClearNotice, setConsoleClearNotice] = useState(false);
const [consoleState, dispatchConsole] = useReducer(
reduceLiveConsoleState,
createLiveConsoleState(500),
@@ -132,6 +135,15 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
}, [queryClient, realtimeEnabled]);
const visibleEntries = selectVisibleConsoleEntries(consoleState);
const syncBusy = syncQuery.data?.syncing === true || syncMutation.isPending || fetchAllMutation.isPending;
function confirmAndRun(kind: 'full' | 'fetch-all') {
const confirmed = globalThis.confirm(kind === 'full'
? 'Run a full chat cache sync? This can take significantly longer than a delta sync.'
: 'Fetch every chat now? This is a legacy blocking operation and may take a while.');
if (!confirmed) return;
if (kind === 'full') syncMutation.mutate('full'); else fetchAllMutation.mutate();
}
useEffect(() => {
if (
consoleState.isAutoScrollEnabled &&
@@ -156,17 +168,23 @@ export function OperationsPage({ realtimeEnabled = true }: OperationsPageProps)
</section>
<section aria-labelledby="sync-jobs-heading" className="panel feature-panel" role="region">
<div className="panel-heading"><div><span className="eyebrow">Background work</span><h2 id="sync-jobs-heading">Sync jobs</h2></div></div>
{syncQuery.isPending ? <p role="status">Loading sync state…</p> : syncQuery.isError ? <p role="alert">{syncQuery.error.message}</p> : <SyncSummary status={syncQuery.data} />}
{syncQuery.isPending ? <p role="status">Loading sync state…</p> : syncQuery.isError ? <p role="alert">{syncQuery.error.message}</p> : <><SyncSummary status={syncQuery.data} /><div className="header-actions"><button disabled={syncBusy} onClick={() => syncMutation.mutate('delta')} type="button">Sync latest chats</button><button disabled={syncBusy} onClick={() => confirmAndRun('full')} type="button">Full cache sync</button><button disabled={syncBusy} onClick={() => confirmAndRun('fetch-all')} type="button">Fetch all chats</button></div></>}
{syncMutation.isSuccess ? <p role="status">{syncMutation.data.kind === 'delta' ? 'Delta' : 'Full'} sync started. Progress is shown from server status.</p> : null}
{fetchAllMutation.isSuccess ? <p role="status">Fetched {fetchAllMutation.data.count} chats.</p> : null}
{syncMutation.isError ? <p role="alert">{syncMutation.error.message}</p> : null}
{fetchAllMutation.isError ? <p role="alert">{fetchAllMutation.error.message}</p> : null}
</section>
</div>
<section aria-labelledby="live-console-heading" className="panel console-panel" role="region">
<div className="panel-heading"><div><span className="eyebrow">Raw diagnostics</span><h2 id="live-console-heading">Live console</h2></div><span className="console-count">{consoleState.entries.length} retained</span></div>
<div className="console-controls">
<button aria-pressed={consoleState.isPaused} className="button secondary" onClick={() => dispatchConsole({ type: 'set-paused', paused: !consoleState.isPaused })} type="button">{consoleState.isPaused ? `Resume console (${consoleState.pendingEntries.length})` : 'Pause console'}</button>
<button className="button secondary" onClick={() => { dispatchConsole({ type: 'clear' }); setConsoleClearNotice(true); }} type="button">Clear local console</button>
<label>Log level<select aria-label="Log level" onChange={(event) => dispatchConsole({ type: 'set-level', level: event.target.value as ConsoleLevelFilter })} value={consoleState.level}><option value="all">All levels</option><option value="debug">Debug</option><option value="info">Info</option><option value="warn">Warning</option><option value="error">Error</option></select></label>
<label>Search logs<input aria-label="Search logs" onChange={(event) => dispatchConsole({ type: 'set-search', search: event.target.value })} placeholder="Message or metadata" type="search" value={consoleState.search} /></label>
<label className="checkbox-control"><input checked={consoleState.isAutoScrollEnabled} onChange={(event) => dispatchConsole({ type: 'set-auto-scroll', enabled: event.target.checked })} type="checkbox" />Auto-scroll</label>
</div>
{consoleClearNotice ? <p role="status">Cleared from this browser view. Server logs were not deleted.</p> : null}
<div aria-live="polite" className="live-console" role="log">
{logsQuery.isPending ? <p>Loading logs…</p> : visibleEntries.length === 0 ? <p>No logs match the current filters.</p> : visibleEntries.map((entry) => (
<article className={`console-entry ${entry.level}`} key={entry.id}><time dateTime={entry.occurredAt}>{formatTimestamp(entry.occurredAt)}</time><strong>{entry.level.toUpperCase()}</strong><span>{entry.message}</span></article>
@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { operationsApi } from './operations-api.js';
export const operationsQueryKeys = {
@@ -16,5 +16,15 @@ export function useLogsQuery() {
}
export function useSyncStatusQuery() {
return useQuery({ queryKey: operationsQueryKeys.syncStatus, queryFn: operationsApi.getSyncStatus });
return useQuery({ queryKey: operationsQueryKeys.syncStatus, queryFn: operationsApi.getSyncStatus, refetchInterval: (query) => query.state.data?.syncing ? 2_000 : false });
}
export function useTriggerChatSyncMutation() {
const queryClient = useQueryClient();
return useMutation({ mutationFn: (kind: 'delta' | 'full') => operationsApi.triggerSync(kind), onSettled: () => queryClient.invalidateQueries({ queryKey: operationsQueryKeys.syncStatus }) });
}
export function useFetchAllChatsMutation() {
const queryClient = useQueryClient();
return useMutation({ mutationFn: operationsApi.fetchAllChats, onSettled: () => queryClient.invalidateQueries({ queryKey: operationsQueryKeys.syncStatus }) });
}
@@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { dashboardQueryKeys, useSetRunningMutation, useStatsQuery, useTimersQuery } from '../../api/queries.js';
import type { BotStats, Timers } from '../../api/contracts.js';
import { ConfirmationDialog } from '../../components/confirmation-dialog.js';
import { RecentMessagesPanel } from './recent-messages.js';
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
@@ -184,6 +185,7 @@ export function OverviewPage() {
)}
<ActivityPanel stats={stats} />
</div>
<RecentMessagesPanel />
{controlMutation.isError ? <div className="toast error" role="alert">{errorMessage(controlMutation.error)}</div> : null}
{confirmationAction ? (
@@ -0,0 +1,32 @@
import { useQuery } from '@tanstack/react-query';
import { MessagesResponseSchema, type SentMessage } from '../../../../../packages/api-contracts/src/index.js';
async function loadRecentMessages(): Promise<SentMessage[]> {
const response = await fetch('/api/messages');
const payload: unknown = await response.json();
if (!response.ok) throw new Error(`Could not load recent messages (${response.status})`);
return MessagesResponseSchema.parse(payload);
}
function formatSentAt(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
}
export function RecentMessagesPanel() {
const query = useQuery({ queryKey: ['overview', 'recent-messages'], queryFn: loadRecentMessages });
return (
<section aria-labelledby="recent-messages-heading" className="panel">
<div className="panel-heading"><div><span className="eyebrow">Durable records</span><h2 id="recent-messages-heading">Recent sent messages</h2></div></div>
{query.isPending ? <p role="status">Loading recent messages…</p> : null}
{query.isError ? <div role="alert"><span>{query.error.message}</span><button onClick={() => void query.refetch()} type="button">Retry messages</button></div> : null}
{query.data?.length === 0 ? <p>No sent messages recorded yet.</p> : null}
{query.data && query.data.length > 0 ? (
<ol aria-label="Recent sent messages" className="activity-list">
{query.data.map((message) => (
<li key={message.id}><div><strong>u/{message.recipient}</strong><span>{message.message}</span><small>{formatSentAt(message.sentAt)} · {message.gotResponse ? 'Responded' : 'No response recorded'}</small></div></li>
))}
</ol>
) : null}
</section>
);
}