diff --git a/www/app/(app)/browse/_components/DagProgressDots.tsx b/www/app/(app)/browse/_components/DagProgressDots.tsx new file mode 100644 index 00000000..f593ccc6 --- /dev/null +++ b/www/app/(app)/browse/_components/DagProgressDots.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { Box, Flex } from "@chakra-ui/react"; +import type { DagTask } from "../../../lib/UserEventsProvider"; + +const pulseKeyframes = ` + @keyframes dagDotPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } + } +`; + +function humanizeTaskName(name: string): string { + return name + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +function dotProps(status: DagTask["status"]): Record { + switch (status) { + case "completed": + return { bg: "green.500" }; + case "running": + return { + bg: "blue.500", + style: { animation: "dagDotPulse 1.5s ease-in-out infinite" }, + }; + case "failed": + return { bg: "red.500" }; + case "cancelled": + return { bg: "gray.400" }; + case "queued": + default: + return { + bg: "transparent", + border: "1px solid", + borderColor: "gray.400", + }; + } +} + +export default function DagProgressDots({ tasks }: { tasks: DagTask[] }) { + return ( + <> + + + {tasks.map((task) => ( + + ))} + + + ); +} diff --git a/www/app/(app)/browse/_components/TranscriptCards.tsx b/www/app/(app)/browse/_components/TranscriptCards.tsx index 8dbc3568..2b717301 100644 --- a/www/app/(app)/browse/_components/TranscriptCards.tsx +++ b/www/app/(app)/browse/_components/TranscriptCards.tsx @@ -19,6 +19,7 @@ import { generateTextFragment, } from "../../../lib/textHighlight"; import type { components } from "../../../reflector-api"; +import type { DagTask } from "../../../lib/UserEventsProvider"; type SearchResult = components["schemas"]["SearchResult"]; type SourceKind = components["schemas"]["SourceKind"]; @@ -29,6 +30,7 @@ interface TranscriptCardsProps { isLoading?: boolean; onDelete: (transcriptId: string) => void; onReprocess: (transcriptId: string) => void; + dagStatusMap?: Map; } function highlightText(text: string, query: string): React.ReactNode { @@ -102,11 +104,13 @@ function TranscriptCard({ query, onDelete, onReprocess, + dagStatusMap, }: { result: SearchResult; query: string; onDelete: (transcriptId: string) => void; onReprocess: (transcriptId: string) => void; + dagStatusMap?: Map; }) { const [isExpanded, setIsExpanded] = useState(false); @@ -137,7 +141,18 @@ function TranscriptCard({ - + ).dag_status as + | { tasks?: DagTask[] } + | undefined + )?.tasks ?? + null + } + /> {/* Title with highlighting and text fragment for deep linking */} @@ -284,6 +299,7 @@ export default function TranscriptCards({ isLoading, onDelete, onReprocess, + dagStatusMap, }: TranscriptCardsProps) { return ( @@ -315,6 +331,7 @@ export default function TranscriptCards({ query={query} onDelete={onDelete} onReprocess={onReprocess} + dagStatusMap={dagStatusMap} /> ))} diff --git a/www/app/(app)/browse/_components/TranscriptStatusIcon.tsx b/www/app/(app)/browse/_components/TranscriptStatusIcon.tsx index 20164993..b217246b 100644 --- a/www/app/(app)/browse/_components/TranscriptStatusIcon.tsx +++ b/www/app/(app)/browse/_components/TranscriptStatusIcon.tsx @@ -8,13 +8,17 @@ import { FaGear, } from "react-icons/fa6"; import { TranscriptStatus } from "../../../lib/transcript"; +import type { DagTask } from "../../../lib/UserEventsProvider"; +import DagProgressDots from "./DagProgressDots"; interface TranscriptStatusIconProps { status: TranscriptStatus; + dagStatus?: DagTask[] | null; } export default function TranscriptStatusIcon({ status, + dagStatus, }: TranscriptStatusIconProps) { switch (status) { case "ended": @@ -36,6 +40,9 @@ export default function TranscriptStatusIcon({ ); case "processing": + if (dagStatus && dagStatus.length > 0) { + return ; + } return ( diff --git a/www/app/(app)/browse/page.tsx b/www/app/(app)/browse/page.tsx index 05d8d5da..a6c3b53c 100644 --- a/www/app/(app)/browse/page.tsx +++ b/www/app/(app)/browse/page.tsx @@ -43,6 +43,7 @@ import DeleteTranscriptDialog from "./_components/DeleteTranscriptDialog"; import { formatLocalDate } from "../../lib/time"; import { RECORD_A_MEETING_URL } from "../../api/urls"; import { useUserName } from "../../lib/useUserName"; +import { useDagStatusMap } from "../../lib/UserEventsProvider"; const SEARCH_FORM_QUERY_INPUT_NAME = "query" as const; @@ -273,6 +274,7 @@ export default function TranscriptBrowser() { }, [JSON.stringify(searchFilters)]); const userName = useUserName(); + const dagStatusMap = useDagStatusMap(); const [deletionLoading, setDeletionLoading] = useState(false); const cancelRef = React.useRef(null); const [transcriptToDeleteId, setTranscriptToDeleteId] = @@ -408,6 +410,7 @@ export default function TranscriptBrowser() { isLoading={searchLoading} onDelete={setTranscriptToDeleteId} onReprocess={handleProcessTranscript} + dagStatusMap={dagStatusMap} /> {!searchLoading && results.length === 0 && ( diff --git a/www/app/lib/UserEventsProvider.tsx b/www/app/lib/UserEventsProvider.tsx index 89ec5a11..c733da44 100644 --- a/www/app/lib/UserEventsProvider.tsx +++ b/www/app/lib/UserEventsProvider.tsx @@ -1,12 +1,38 @@ "use client"; -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { WEBSOCKET_URL } from "./apiClient"; import { useAuth } from "./AuthProvider"; import { z } from "zod"; import { invalidateTranscriptLists, TRANSCRIPT_SEARCH_URL } from "./apiHooks"; +export type DagTaskStatus = + | "queued" + | "running" + | "completed" + | "failed" + | "cancelled"; + +export type DagTask = { + name: string; + status: DagTaskStatus; + started_at: string | null; + finished_at: string | null; + duration_seconds: number | null; + parents: string[]; + error: string | null; + children_total: number | null; + children_completed: number | null; + progress_pct: number | null; +}; + +const DagStatusContext = React.createContext>(new Map()); + +export function useDagStatusMap() { + return React.useContext(DagStatusContext); +} + const UserEvent = z.object({ event: z.string(), }); @@ -95,6 +121,9 @@ export function UserEventsProvider({ const queryClient = useQueryClient(); const tokenRef = useRef(null); const detachRef = useRef<(() => void) | null>(null); + const [dagStatusMap, setDagStatusMap] = useState>( + new Map(), + ); useEffect(() => { // Only tear down when the user is truly unauthenticated @@ -133,20 +162,46 @@ export function UserEventsProvider({ if (!detachRef.current) { const onMessage = (event: MessageEvent) => { try { - const msg = UserEvent.parse(JSON.parse(event.data)); + const fullMsg = JSON.parse(event.data); + const msg = UserEvent.parse(fullMsg); const eventName = msg.event; - const invalidateList = () => invalidateTranscriptLists(queryClient); switch (eventName) { case "TRANSCRIPT_CREATED": case "TRANSCRIPT_DELETED": - case "TRANSCRIPT_STATUS": case "TRANSCRIPT_FINAL_TITLE": case "TRANSCRIPT_DURATION": invalidateList().then(() => {}); break; + case "TRANSCRIPT_STATUS": { + invalidateList().then(() => {}); + const transcriptId = fullMsg.data?.id as string | undefined; + const status = fullMsg.data?.value as string | undefined; + if (transcriptId && status && status !== "processing") { + setDagStatusMap((prev) => { + const next = new Map(prev); + next.delete(transcriptId); + return next; + }); + } + break; + } + + case "TRANSCRIPT_DAG_STATUS": { + const transcriptId = fullMsg.data?.id as string | undefined; + const tasks = fullMsg.data?.tasks as DagTask[] | undefined; + if (transcriptId && tasks) { + setDagStatusMap((prev) => { + const next = new Map(prev); + next.set(transcriptId, tasks); + return next; + }); + } + break; + } + default: // Ignore other content events for list updates break; @@ -176,5 +231,9 @@ export function UserEventsProvider({ }; }, []); - return <>{children}; + return ( + + {children} + + ); }