From f9b565cfdae9840ee0693373ff482d998fd66347 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Tue, 21 Apr 2026 18:01:04 +0200 Subject: [PATCH] fix(ui): rendre les vues tickets et notifications live --- src/components/layout/NotificationCenter.tsx | 18 +- src/components/tickets/TicketDetail.tsx | 111 +++++++--- src/components/tickets/TicketList.tsx | 70 +++--- src/lib/useLiveRefresh.ts | 222 +++++++++++++++++++ 4 files changed, 352 insertions(+), 69 deletions(-) create mode 100644 src/lib/useLiveRefresh.ts diff --git a/src/components/layout/NotificationCenter.tsx b/src/components/layout/NotificationCenter.tsx index 3ecdf70..a319d83 100644 --- a/src/components/layout/NotificationCenter.tsx +++ b/src/components/layout/NotificationCenter.tsx @@ -4,7 +4,7 @@ import { requestPermission, sendNotification, } from "@tauri-apps/plugin-notification"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { listNotifications, @@ -12,6 +12,7 @@ import { markNotificationRead, } from "../../lib/api"; import type { OrchaiNotification } from "../../lib/types"; +import { useLiveRefresh } from "../../lib/useLiveRefresh"; import { buttonClass, cardClass, pillClass } from "../ui/primitives"; type NewNotificationEvent = { @@ -61,7 +62,7 @@ export default function NotificationCenter() { "all" ); - async function loadNotifications() { + const loadNotifications = useCallback(async () => { if (!projectId) { setNotifications([]); return; @@ -73,11 +74,18 @@ export default function NotificationCenter() { } catch { // Ignore load errors in layout chrome } - } + }, [projectId]); useEffect(() => { - loadNotifications(); - }, [projectId]); + void loadNotifications(); + }, [loadNotifications]); + + useLiveRefresh({ + enabled: Boolean(projectId), + projectId, + refresh: loadNotifications, + fallbackIntervalMs: 15_000, + }); useEffect(() => { let cancelled = false; diff --git a/src/components/tickets/TicketDetail.tsx b/src/components/tickets/TicketDetail.tsx index 67fda53..1ae6800 100644 --- a/src/components/tickets/TicketDetail.tsx +++ b/src/components/tickets/TicketDetail.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import Markdown from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -19,6 +19,7 @@ import { type TicketResourceConfig, } from "../../lib/ticketResource"; import type { ProcessedTicket, Worktree } from "../../lib/types"; +import { useLiveRefresh } from "../../lib/useLiveRefresh"; import ConfirmModal from "../ui/ConfirmModal"; import TicketStatusBadge from "../ui/TicketStatusBadge"; import { @@ -76,6 +77,24 @@ export default function TicketDetail() { const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [isDeleteWorktreeModalOpen, setIsDeleteWorktreeModalOpen] = useState(false); + const latestTicketIdRef = useRef(ticketId); + const worktreeSignatureRef = useRef(""); + + useEffect(() => { + latestTicketIdRef.current = ticketId; + }, [ticketId]); + + const resetWorktreeUi = useCallback(() => { + setDiff(null); + setDiffError(""); + setDiffLoading(false); + setAvailableBranches([]); + setBranchInputMode("select"); + setTargetBranch(""); + setBranchesError(""); + setBranchesLoading(false); + setBranchesLoadedForWorktreeId(null); + }, []); async function loadBranchOptions(worktreeId: string) { setBranchesLoading(true); @@ -103,41 +122,63 @@ export default function TicketDetail() { } } - async function loadData() { - if (!ticketId) return; + const loadData = useCallback(async () => { + if (!ticketId) { + setTicket(null); + setWorktree(null); + setResourceConfig(DEFAULT_TICKET_RESOURCE_CONFIG); + resetWorktreeUi(); + return; + } + setError(""); try { const result = await getTicketResult(ticketId); - setTicket(result.ticket); - try { - const config = await fetchTicketResourceConfig(result.ticket.project_id); - setResourceConfig(config); - } catch { - setResourceConfig(DEFAULT_TICKET_RESOURCE_CONFIG); - } - setWorktree(result.worktree); - setTab("info"); - setDiff(null); - setDiffError(""); - setDiffLoading(false); - setAvailableBranches([]); - setBranchInputMode("select"); - setTargetBranch(""); - setBranchesError(""); - setBranchesLoading(false); - setBranchesLoadedForWorktreeId(null); - if (result.worktree && result.worktree.status === "Active") { + if (latestTicketIdRef.current !== ticketId) { return; } + + setTicket(result.ticket); + + const config = await fetchTicketResourceConfig(result.ticket.project_id).catch( + () => DEFAULT_TICKET_RESOURCE_CONFIG + ); + if (latestTicketIdRef.current !== ticketId) { + return; + } + setResourceConfig(config); + + const worktreeSignature = result.worktree + ? `${result.worktree.id}:${result.worktree.status}` + : "none"; + if (worktreeSignatureRef.current !== worktreeSignature) { + worktreeSignatureRef.current = worktreeSignature; + resetWorktreeUi(); + } + + setWorktree(result.worktree); } catch (err) { - setError(getErrorMessage(err)); + if (latestTicketIdRef.current === ticketId) { + setError(getErrorMessage(err)); + } } - } + }, [resetWorktreeUi, ticketId]); useEffect(() => { + worktreeSignatureRef.current = ""; + setTab("info"); + resetWorktreeUi(); void loadData(); - }, [ticketId]); + }, [loadData, resetWorktreeUi]); + + useLiveRefresh({ + enabled: Boolean(ticketId), + projectId: ticket?.project_id, + ticketId, + refresh: loadData, + fallbackIntervalMs: 7_000, + }); useEffect(() => { if (tab !== "info") return; @@ -199,8 +240,9 @@ export default function TicketDetail() { await loadData(); } catch (err) { setError(getErrorMessage(err)); + } finally { + setLoading(false); } - setLoading(false); } async function handleCancel() { @@ -211,8 +253,9 @@ export default function TicketDetail() { await loadData(); } catch (err) { setError(getErrorMessage(err)); + } finally { + setLoading(false); } - setLoading(false); } async function handleApplyFix() { @@ -240,8 +283,9 @@ export default function TicketDetail() { await loadData(); } catch (err) { setError(getErrorMessage(err)); + } finally { + setLoading(false); } - setLoading(false); } async function handleDeleteWorktree() { @@ -250,17 +294,12 @@ export default function TicketDetail() { setLoading(true); try { await deleteWorktreeCmd(worktree.id); - setWorktree(null); - setDiff(null); - setAvailableBranches([]); - setTargetBranch(""); - setBranchInputMode("select"); - setBranchesError(""); - setBranchesLoading(false); + await loadData(); } catch (err) { setError(getErrorMessage(err)); + } finally { + setLoading(false); } - setLoading(false); } if (!ticket) { diff --git a/src/components/tickets/TicketList.tsx b/src/components/tickets/TicketList.tsx index 47142b7..5446c9d 100644 --- a/src/components/tickets/TicketList.tsx +++ b/src/components/tickets/TicketList.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { getProject, listProcessedTickets } from "../../lib/api"; import { @@ -8,6 +8,7 @@ import { type TicketResourceConfig, } from "../../lib/ticketResource"; import type { ProcessedTicket, Project } from "../../lib/types"; +import { useLiveRefresh } from "../../lib/useLiveRefresh"; import TicketStatusBadge from "../ui/TicketStatusBadge"; import { cardContentClass, @@ -24,37 +25,50 @@ export default function TicketList() { DEFAULT_TICKET_RESOURCE_CONFIG ); const [filter, setFilter] = useState("all"); + const latestProjectIdRef = useRef(projectId); useEffect(() => { - if (!projectId) return; - let cancelled = false; - Promise.all([getProject(projectId), listProcessedTickets(projectId)]) - .then(([proj, tkts]) => { - if (cancelled) return; - setProject(proj); - setTickets(tkts); - }) - .catch((error: unknown) => { - console.error("Failed to load ticket list", error); - }); - - void fetchTicketResourceConfig(projectId) - .then((config) => { - if (!cancelled) { - setResourceConfig(config); - } - }) - .catch(() => { - if (!cancelled) { - setResourceConfig(DEFAULT_TICKET_RESOURCE_CONFIG); - } - }); - - return () => { - cancelled = true; - }; + latestProjectIdRef.current = projectId; }, [projectId]); + const loadData = useCallback(async () => { + if (!projectId) { + setProject(null); + setTickets([]); + setResourceConfig(DEFAULT_TICKET_RESOURCE_CONFIG); + return; + } + + try { + const [proj, tkts, config] = await Promise.all([ + getProject(projectId), + listProcessedTickets(projectId), + fetchTicketResourceConfig(projectId).catch(() => DEFAULT_TICKET_RESOURCE_CONFIG), + ]); + + if (latestProjectIdRef.current !== projectId) { + return; + } + + setProject(proj); + setTickets(tkts); + setResourceConfig(config); + } catch (error: unknown) { + console.error("Failed to load ticket list", error); + } + }, [projectId]); + + useEffect(() => { + void loadData(); + }, [loadData]); + + useLiveRefresh({ + enabled: Boolean(projectId), + projectId, + refresh: loadData, + fallbackIntervalMs: 8_000, + }); + const filtered = filter === "all" ? tickets : tickets.filter((t) => t.status === filter); return ( diff --git a/src/lib/useLiveRefresh.ts b/src/lib/useLiveRefresh.ts new file mode 100644 index 0000000..b51a241 --- /dev/null +++ b/src/lib/useLiveRefresh.ts @@ -0,0 +1,222 @@ +import { listen } from "@tauri-apps/api/event"; +import { useCallback, useEffect, useRef } from "react"; + +interface Scope { + projectId?: string | null; + ticketId?: string | null; +} + +interface UseLiveRefreshOptions extends Scope { + enabled?: boolean; + refresh: () => Promise | void; + fallbackIntervalMs?: number; + debounceMs?: number; +} + +interface EventScope { + projectId?: string; + ticketId?: string; +} + +interface EventDescriptor { + name: string; + extractScope: (payload: unknown) => EventScope; +} + +const LIVE_EVENTS: EventDescriptor[] = [ + { name: "polling-started", extractScope: extractProjectScope }, + { name: "polling-finished", extractScope: extractProjectScope }, + { name: "polling-error", extractScope: extractProjectScope }, + { name: "new-tickets-detected", extractScope: extractProjectScope }, + { name: "ticket-processing-started", extractScope: extractTicketScope }, + { name: "ticket-processing-done", extractScope: extractTicketScope }, + { name: "ticket-processing-error", extractScope: extractTicketScope }, + { name: "graylog-polling-started", extractScope: extractProjectScope }, + { name: "graylog-polling-finished", extractScope: extractProjectScope }, + { name: "graylog-polling-error", extractScope: extractProjectScope }, + { name: "graylog-subject-triggered", extractScope: extractProjectScope }, + { name: "new-notification", extractScope: extractNotificationScope }, +]; + +const DEFAULT_FALLBACK_INTERVAL_MS = 12_000; +const DEFAULT_DEBOUNCE_MS = 300; + +function asRecord(value: unknown): Record | null { + if (typeof value === "object" && value !== null) { + return value as Record; + } + return null; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function extractProjectScope(payload: unknown): EventScope { + const raw = asRecord(payload); + return { + projectId: asString(raw?.project_id), + }; +} + +function extractTicketScope(payload: unknown): EventScope { + const raw = asRecord(payload); + return { + projectId: asString(raw?.project_id), + ticketId: asString(raw?.ticket_id), + }; +} + +function extractNotificationScope(payload: unknown): EventScope { + const raw = asRecord(payload); + const notification = asRecord(raw?.notification); + return { + projectId: asString(notification?.project_id), + ticketId: asString(notification?.ticket_id), + }; +} + +function matchesScope(scope: Scope, eventScope: EventScope): boolean { + if (scope.ticketId) { + if (eventScope.ticketId === scope.ticketId) { + return true; + } + + if (scope.projectId && eventScope.projectId === scope.projectId) { + return true; + } + + return false; + } + + if (scope.projectId) { + return eventScope.projectId === scope.projectId; + } + + return true; +} + +export function useLiveRefresh({ + enabled = true, + refresh, + projectId, + ticketId, + fallbackIntervalMs = DEFAULT_FALLBACK_INTERVAL_MS, + debounceMs = DEFAULT_DEBOUNCE_MS, +}: UseLiveRefreshOptions): void { + const refreshRef = useRef(refresh); + const inFlightRef = useRef(false); + const pendingRefreshRef = useRef(false); + const timeoutRef = useRef(null); + + useEffect(() => { + refreshRef.current = refresh; + }, [refresh]); + + const runRefresh = useCallback(() => { + if (!enabled) { + return; + } + + if (inFlightRef.current) { + pendingRefreshRef.current = true; + return; + } + + inFlightRef.current = true; + void Promise.resolve(refreshRef.current()) + .catch((error: unknown) => { + console.error("Live refresh failed", error); + }) + .finally(() => { + inFlightRef.current = false; + if (pendingRefreshRef.current) { + pendingRefreshRef.current = false; + runRefresh(); + } + }); + }, [enabled]); + + const scheduleRefresh = useCallback( + (delayMs = debounceMs) => { + if (!enabled) { + return; + } + + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + + timeoutRef.current = window.setTimeout(() => { + timeoutRef.current = null; + runRefresh(); + }, delayMs); + }, + [debounceMs, enabled, runRefresh] + ); + + useEffect(() => { + if (!enabled) { + return; + } + + let cancelled = false; + let unlistenFns: Array<() => void> = []; + + const setup = async () => { + try { + const listeners = await Promise.all( + LIVE_EVENTS.map((descriptor) => + listen(descriptor.name, (event) => { + if ( + !matchesScope({ projectId, ticketId }, descriptor.extractScope(event.payload)) + ) { + return; + } + scheduleRefresh(); + }) + ) + ); + + if (cancelled) { + listeners.forEach((unlisten) => unlisten()); + return; + } + + unlistenFns = listeners; + } catch (error: unknown) { + console.error("Failed to subscribe to live refresh events", error); + } + }; + + void setup(); + + return () => { + cancelled = true; + unlistenFns.forEach((unlisten) => unlisten()); + }; + }, [enabled, projectId, scheduleRefresh, ticketId]); + + useEffect(() => { + if (!enabled || fallbackIntervalMs <= 0) { + return; + } + + const intervalId = window.setInterval(() => { + runRefresh(); + }, fallbackIntervalMs); + + return () => { + window.clearInterval(intervalId); + }; + }, [enabled, fallbackIntervalMs, runRefresh]); + + useEffect( + () => () => { + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + }, + [] + ); +}