feat(dashboard): close legacy parity gaps
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user