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>
);
}
+15
View File
@@ -18,6 +18,20 @@ export const SentMessageSchema = z.strictObject({
});
export const MessagesResponseSchema = z.array(SentMessageSchema);
export const ProcessedPostSchema = z.strictObject({
id: z.string(),
subreddit: z.string(),
author: z.string(),
title: z.string(),
imageUrl: z.string().nullable(),
imageAnalysis: z.string().nullable(),
generatedMessage: z.string().nullable(),
processedAt: z.number(),
messageSent: z.boolean(),
error: z.string().nullable(),
});
export const ProcessedPostsResponseSchema = z.array(ProcessedPostSchema);
export const SuccessfulExampleSchema = z.strictObject({
id: z.number(),
sentMessageId: z.number().nullable(),
@@ -31,4 +45,5 @@ export const SuccessfulExampleSchema = z.strictObject({
export const SuccessfulExamplesResponseSchema = z.array(SuccessfulExampleSchema);
export type SentMessage = z.infer<typeof SentMessageSchema>;
export type ProcessedPost = z.infer<typeof ProcessedPostSchema>;
export type SuccessfulExample = z.infer<typeof SuccessfulExampleSchema>;
@@ -17,6 +17,7 @@ import {
MediaResponseSchema,
MediaSyncStatusSchema,
MessagesResponseSchema,
ProcessedPostsResponseSchema,
StatsResponseSchema,
StrategyPerformanceResponseSchema,
SubredditPerformanceResponseSchema,
@@ -103,6 +104,13 @@ describe('dashboard endpoint contracts', () => {
expect(TimersResponseSchema.safeParse({ hourlyResetAt: 'later' }).success).toBe(false);
expect(ActivityResponseSchema.safeParse([{ ...activity, type: 'bogus' }]).success).toBe(false);
expect(MessagesResponseSchema.safeParse([{ id: '1' }]).success).toBe(false);
const processedPost = {
id: 'post-1', subreddit: 'example', author: 'alice', title: 'Example',
imageUrl: null, imageAnalysis: null, generatedMessage: 'Hello',
processedAt: 1_700_000_000_000, messageSent: true, error: null,
};
expect(ProcessedPostsResponseSchema.safeParse([processedPost]).success).toBe(true);
expect(ProcessedPostsResponseSchema.safeParse([{ ...processedPost, messageSent: 'yes' }]).success).toBe(false);
});
it('validates each analytics response shape', () => {
+7
View File
@@ -45,6 +45,7 @@ function installSuccessfulApi(overrides: TestStatsOverrides = {}) {
messagesRemaining: 6,
}),
),
http.get('/api/messages', () => HttpResponse.json([])),
);
}
@@ -106,6 +107,10 @@ describe('dashboard foundation', () => {
messagesRemaining: 6,
}),
),
http.get('/api/messages', () => HttpResponse.json([{
id: 1, postId: 'post-1', recipient: 'alice', subject: 'Hello', message: 'A recent opener',
sentAt: Date.now(), gotResponse: true, responseCheckedAt: Date.now(), strategyId: null, chatRoomId: null,
}])),
);
renderDashboard();
@@ -118,6 +123,8 @@ describe('dashboard foundation', () => {
expect(screen.getByText('6 messages remaining')).toBeInTheDocument();
expect(screen.getByText('No recent activity yet.')).toBeInTheDocument();
expect(screen.getByText('Dry run')).toBeInTheDocument();
expect(await screen.findByRole('list', { name: 'Recent sent messages' })).toHaveTextContent('A recent opener');
expect(screen.getByRole('list', { name: 'Recent sent messages' })).toHaveTextContent('Responded');
});
it('keeps stats visible when timers fail and retries only the failed panel', async () => {
+21
View File
@@ -178,6 +178,27 @@ describe('ConversationsPage', () => {
expect(sendRequests).toBe(2);
});
it('generates a media ask and looks up durable processed-user history', async () => {
installConversationList();
server.use(
http.post('/api/conversations/room-alice/suggest-media-ask', () => HttpResponse.json({ suggestedReply: 'Would you like to share a photo?' })),
http.get('/api/posts/by-author/alice', () => HttpResponse.json([{
id: 'post-1', subreddit: 'askuk', author: 'alice', title: 'A processed post',
imageUrl: null, imageAnalysis: null, generatedMessage: 'Hello',
processedAt: Date.now(), messageSent: true, error: null,
}])),
);
const user = userEvent.setup();
renderConversations();
await user.type(screen.getByLabelText('Lookup Reddit username'), 'u/alice');
await user.click(screen.getByRole('button', { name: 'Look up user' }));
expect(await screen.findByRole('list', { name: 'Processed posts for u/alice' })).toHaveTextContent('A processed post');
await user.click(screen.getByRole('button', { name: /u\/alice/ }));
await user.click(await screen.findByRole('button', { name: 'Suggest media ask' }));
expect(await screen.findByRole('textbox', { name: 'Reply to u/alice' })).toHaveValue('Would you like to share a photo?');
});
it('starts a conversation and preserves both fields after a recoverable failure', async () => {
installConversationList();
let startRequests = 0;
@@ -10,6 +10,22 @@ function entry(id: string, level: 'debug' | 'info' | 'warn' | 'error', message:
}
describe('live console state', () => {
it('clears retained and paused entries without changing local controls', () => {
const initial = {
...createLiveConsoleState(5),
entries: [entry('one', 'info', 'one')],
pendingEntries: [entry('two', 'warn', 'two')],
isPaused: true,
search: 'needle',
};
expect(reduceLiveConsoleState(initial, { type: 'clear' })).toEqual({
...initial,
entries: [],
pendingEntries: [],
});
});
it('bounds entries, rejects duplicate IDs, and filters by level and search', () => {
let state = createLiveConsoleState(2);
state = reduceLiveConsoleState(state, { type: 'append', entry: entry('1', 'info', 'Sync started') });
@@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import type { PropsWithChildren, ReactElement } from 'react';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { OperationsPage } from '../../apps/dashboard/src/features/operations/operations-page.js';
import { PerformancePage } from '../../apps/dashboard/src/features/performance/performance-page.js';
@@ -139,5 +139,35 @@ describe('OperationsPage', () => {
expect(consoleRegion).toHaveTextContent('No logs match the current filters.');
expect(within(consoleRegion).getByRole('button', { name: 'Pause console' })).toHaveAttribute('aria-pressed', 'false');
expect(within(consoleRegion).getByLabelText('Auto-scroll')).toBeChecked();
await user.clear(within(consoleRegion).getByLabelText('Search logs'));
await user.click(within(consoleRegion).getByRole('button', { name: 'Clear local console' }));
expect(consoleRegion).toHaveTextContent('Server logs were not deleted');
});
it('starts delta sync directly and confirms expensive chat cache operations', async () => {
let deltaCalls = 0;
let fullCalls = 0;
server.use(
http.get('/api/activity', () => HttpResponse.json([])),
http.get('/api/logs', () => HttpResponse.json([])),
http.get('/api/chats/sync', () => HttpResponse.json({ syncing: false, syncingKind: null, lastFullSyncAt: null, lastDeltaSyncAt: null, hasEverFullSynced: false, cachedChats: 2, progress: null })),
http.post('/api/chats/sync/delta', () => { deltaCalls += 1; return HttpResponse.json({ success: true, kind: 'delta' }); }),
http.post('/api/chats/sync/full', () => { fullCalls += 1; return HttpResponse.json({ success: true, kind: 'full' }); }),
http.post('/api/chats/fetch-all', () => HttpResponse.json({ success: true, count: 7 })),
);
const confirmSpy = vi.spyOn(globalThis, 'confirm').mockReturnValue(false);
const user = userEvent.setup();
renderPage(<OperationsPage realtimeEnabled={false} />);
await user.click(await screen.findByRole('button', { name: 'Sync latest chats' }));
expect(await screen.findByText(/Delta sync started/)).toBeInTheDocument();
expect(deltaCalls).toBe(1);
await user.click(screen.getByRole('button', { name: 'Full cache sync' }));
expect(fullCalls).toBe(0);
confirmSpy.mockReturnValue(true);
await user.click(screen.getByRole('button', { name: 'Full cache sync' }));
expect(await screen.findByText(/Full sync started/)).toBeInTheDocument();
expect(fullCalls).toBe(1);
await user.click(screen.getByRole('button', { name: 'Fetch all chats' }));
expect(await screen.findByText('Fetched 7 chats.')).toBeInTheDocument();
});
});
+55
View File
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import type { ReactNode } from 'react';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { ConversationRoute } from '../../apps/dashboard/src/features/conversations/conversation-route.js';
import { MediaRoute } from '../../apps/dashboard/src/features/media/media-route.js';
const server = setupServer();
function LocationProbe() {
const location = useLocation();
return <output aria-label="Current URL">{location.pathname}{location.search}</output>;
}
function renderRoute(path: string, element: ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[path]}><LocationProbe /><Routes><Route path="*" element={element} /></Routes></MemoryRouter></QueryClientProvider>);
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => { cleanup(); server.resetHandlers(); });
afterAll(() => server.close());
describe('route-friendly filters', () => {
it('hydrates and writes conversation search and status filters', async () => {
server.use(http.get('/api/conversations', () => HttpResponse.json([])));
const user = userEvent.setup();
renderRoute('/conversations?q=alice&filter=unread', <ConversationRoute />);
const search = await screen.findByRole('searchbox', { name: 'Search conversations' });
expect(search).toHaveValue('alice');
expect(screen.getByRole('combobox', { name: 'Filter conversations' })).toHaveValue('unread');
await user.clear(search);
await user.type(search, 'bob');
expect(screen.getByLabelText('Current URL')).toHaveTextContent('q=bob');
});
it('hydrates and writes media filters', async () => {
server.use(
http.get('/api/media', () => HttpResponse.json([{ id: 'one', chatUsername: 'alice', chatRoomId: 'room-a', sender: 'alice', timestamp: Date.now(), originalUrl: 'https://example.test/a.gif', fileName: 'a.gif', relativePath: 'a.gif', createdAt: Date.now(), urlPath: '/media/a.gif' }])),
http.get('/api/media/sync', () => HttpResponse.json({ isSyncing: false, lastSyncAt: null, lastSyncDurationMs: null, lastSyncChats: null, lastError: null, nextSyncAt: null })),
);
const user = userEvent.setup();
renderRoute('/media?conversation=alice&type=gif', <MediaRoute />);
expect(await screen.findByRole('combobox', { name: 'Conversation' })).toHaveValue('alice');
await user.selectOptions(screen.getByRole('combobox', { name: 'Date range' }), '7d');
expect(screen.getByLabelText('Current URL')).toHaveTextContent('range=7d');
});
});