From b221fcf7cde0228ff1afaf93feff45db64e94a88 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 31 Aug 2026 11:27:42 -0700 Subject: [PATCH 1/4] feat(web): show repository index job runtime --- packages/shared/src/bullmqClient.test.ts | 4 + packages/shared/src/bullmqClient.ts | 4 + .../repos/components/reposTable.test.tsx | 8 ++ .../app/(app)/repos/components/reposTable.tsx | 13 ++- .../repos/components/syncingPopover.test.tsx | 46 ++++++++++ .../(app)/repos/components/syncingPopover.tsx | 91 +++++++++++++++++++ .../components/connectionsTable.test.tsx | 9 ++ .../components/connectionsTable.tsx | 1 + .../connectionSyncCounts.server.test.ts | 1 + 9 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 packages/web/src/app/(app)/repos/components/syncingPopover.test.tsx create mode 100644 packages/web/src/app/(app)/repos/components/syncingPopover.tsx diff --git a/packages/shared/src/bullmqClient.test.ts b/packages/shared/src/bullmqClient.test.ts index 72ed1918e..dcedeca91 100644 --- a/packages/shared/src/bullmqClient.test.ts +++ b/packages/shared/src/bullmqClient.test.ts @@ -50,6 +50,7 @@ describe("BullMQClient", () => { return { id: jobId, data: { connectionId: 1 }, + processedOn: 900_000, failedReason: "", returnvalue: null, getState: vi.fn(async () => "active"), @@ -75,6 +76,7 @@ describe("BullMQClient", () => { id: "job-1", data: { connectionId: 1 }, status: "IN_PROGRESS", + startedAt: 900_000, errorMessage: null, result: null, }], @@ -83,6 +85,7 @@ describe("BullMQClient", () => { id: "job-2", data: { connectionId: 2 }, status: "COMPLETED", + startedAt: null, errorMessage: null, result: { outcome: "SUCCESS" }, }], @@ -108,6 +111,7 @@ describe("BullMQClient", () => { id: "job-1", data: { connectionId: 1 }, status: "COMPLETED", + startedAt: null, errorMessage: null, result: null, }); diff --git a/packages/shared/src/bullmqClient.ts b/packages/shared/src/bullmqClient.ts index 0562543ad..cf6f687d1 100644 --- a/packages/shared/src/bullmqClient.ts +++ b/packages/shared/src/bullmqClient.ts @@ -24,6 +24,7 @@ export interface WorkloadJob { id: string; data: DataOf; status: WorkloadJobStatus; + startedAt: number | null; errorMessage: string | null; result: ResultOf | null; } @@ -100,6 +101,9 @@ export class BullMQClient { id: job.id ?? jobId, data: job.data as DataOf, status, + startedAt: typeof job.processedOn === "number" + ? job.processedOn + : null, errorMessage: status === "FAILED" ? job.failedReason || null : null, result, }; diff --git a/packages/web/src/app/(app)/repos/components/reposTable.test.tsx b/packages/web/src/app/(app)/repos/components/reposTable.test.tsx index 5ed70c23c..3bdd97724 100644 --- a/packages/web/src/app/(app)/repos/components/reposTable.test.tsx +++ b/packages/web/src/app/(app)/repos/components/reposTable.test.tsx @@ -42,6 +42,7 @@ const repos: Repo[] = [ id: "job-1", data: { repoId: 1 }, status: "IN_PROGRESS", + startedAt: null, errorMessage: null, result: null, }, @@ -222,6 +223,7 @@ describe("ReposTable", () => { id: "completed-job", data: { repoId: 2 }, status: "COMPLETED", + startedAt: null, errorMessage: null, result: null, }, @@ -311,6 +313,7 @@ describe("ReposTable", () => { id: "active-reindex-job", data: { repoId: repos[1].id }, status: "IN_PROGRESS", + startedAt: null, errorMessage: null, result: null, }, @@ -348,6 +351,7 @@ describe("ReposTable", () => { id: "first-interactive-job", data: { repoId: 1 }, status: "COMPLETED", + startedAt: null, errorMessage: null, result: null, }, @@ -392,6 +396,7 @@ describe("ReposTable", () => { id: "warning-job", data: { repoId: 2 }, status: "FAILED", + startedAt: null, errorMessage: "The remote repository could not be reached", result: null, }, @@ -518,6 +523,7 @@ describe("ReposTable", () => { id: "job-1", data: { repoId: 1 }, status: "FAILED", + startedAt: null, errorMessage: "Authentication failed while cloning", result: null, }, @@ -570,6 +576,7 @@ describe("ReposTable", () => { id: "job-1", data: { repoId: 1 }, status: "COMPLETED", + startedAt: null, errorMessage: null, result: null, }, @@ -610,6 +617,7 @@ describe("ReposTable", () => { id: "job-1", data: { repoId: 1 }, status: "FAILED", + startedAt: null, errorMessage: "Indexing failed", result: null, }, diff --git a/packages/web/src/app/(app)/repos/components/reposTable.tsx b/packages/web/src/app/(app)/repos/components/reposTable.tsx index 3a0cfb358..492c403fb 100644 --- a/packages/web/src/app/(app)/repos/components/reposTable.tsx +++ b/packages/web/src/app/(app)/repos/components/reposTable.tsx @@ -51,6 +51,7 @@ import { getBrowsePath } from "../../browse/hooks/utils"; import type { RepoIndexingStatusesResponse } from "../types"; import { RepoActionsMenu } from "./repoActionsMenu"; import { SyncIssuePopover } from "./syncIssuePopover"; +import { SyncingPopover } from "./syncingPopover"; const POLL_INTERVAL_MS = 5_000; const COMPLETED_BADGE_VISIBLE_MS = 5_000; @@ -191,10 +192,12 @@ const SyncAnnotationBadge = ({ ); case "SYNCING": return ( - - - Syncing - + ); case "WARNING": return ( @@ -571,6 +574,7 @@ export const ReposTable = ({ id: jobId, data: { repoId }, status: "PENDING", + startedAt: null, errorMessage: null, result: null, }); @@ -585,6 +589,7 @@ export const ReposTable = ({ id: jobId, data: { repoId }, status: "PENDING", + startedAt: null, errorMessage: null, result: null, }); diff --git a/packages/web/src/app/(app)/repos/components/syncingPopover.test.tsx b/packages/web/src/app/(app)/repos/components/syncingPopover.test.tsx new file mode 100644 index 000000000..62ace53fd --- /dev/null +++ b/packages/web/src/app/(app)/repos/components/syncingPopover.test.tsx @@ -0,0 +1,46 @@ +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { SyncingPopover } from "./syncingPopover"; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe("SyncingPopover", () => { + test("shows a live job duration while open", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-31T12:00:00.000Z")); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { + name: "View sync details for acme/sourcebot", + })); + + expect(screen.getByText("1m 30s")).toBeTruthy(); + + act(() => vi.advanceTimersByTime(1_000)); + + expect(screen.getByText("1m 31s")).toBeTruthy(); + }); + + test("shows when the indexing job is waiting to start", () => { + render( + , + ); + + fireEvent.click(screen.getByRole("button", { + name: "View sync details for acme/sourcebot", + })); + + expect(screen.getByText("Waiting to start")).toBeTruthy(); + }); +}); diff --git a/packages/web/src/app/(app)/repos/components/syncingPopover.tsx b/packages/web/src/app/(app)/repos/components/syncingPopover.tsx new file mode 100644 index 000000000..ac895ad23 --- /dev/null +++ b/packages/web/src/app/(app)/repos/components/syncingPopover.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { badgeVariants } from "@/components/ui/badge"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { Info, Loader2 } from "lucide-react"; +import { useEffect, useState } from "react"; + +const DURATION_UPDATE_INTERVAL_MS = 1_000; + +const formatJobDuration = (durationMs: number) => { + const totalSeconds = Math.max(0, Math.floor(durationMs / 1_000)); + const days = Math.floor(totalSeconds / 86_400); + const hours = Math.floor(totalSeconds / 3_600) % 24; + const minutes = Math.floor(totalSeconds / 60) % 60; + const seconds = totalSeconds % 60; + + return [ + days > 0 ? `${days}d` : null, + days > 0 || hours > 0 ? `${hours}h` : null, + days > 0 || hours > 0 || minutes > 0 ? `${minutes}m` : null, + `${seconds}s`, + ].filter(Boolean).join(" "); +}; + +type SyncingPopoverProps = { + repoDisplayName: string; + startedAt: number | null; +}; + +export const SyncingPopover = ({ + repoDisplayName, + startedAt, +}: SyncingPopoverProps) => { + const [isOpen, setIsOpen] = useState(false); + const [currentTime, setCurrentTime] = useState(() => Date.now()); + + useEffect(() => { + if (!isOpen || startedAt === null) { + return; + } + + const interval = window.setInterval(() => { + setCurrentTime(Date.now()); + }, DURATION_UPDATE_INTERVAL_MS); + return () => window.clearInterval(interval); + }, [isOpen, startedAt]); + + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + if (open) { + setCurrentTime(Date.now()); + } + }; + + return ( + + + + + +
+

Repository syncing

+
+
Job duration
+
+ {startedAt === null + ? "Waiting to start" + : formatJobDuration(currentTime - startedAt)} +
+
+
+
+
+ ); +}; diff --git a/packages/web/src/app/(app)/settings/connections/components/connectionsTable.test.tsx b/packages/web/src/app/(app)/settings/connections/components/connectionsTable.test.tsx index a1fb02e92..5849980fd 100644 --- a/packages/web/src/app/(app)/settings/connections/components/connectionsTable.test.tsx +++ b/packages/web/src/app/(app)/settings/connections/components/connectionsTable.test.tsx @@ -239,6 +239,7 @@ describe("ConnectionsTable", () => { id: "active-job", data: { connectionId: connections[0].id }, status: "IN_PROGRESS", + startedAt: null, errorMessage: null, result: null, }, @@ -293,6 +294,7 @@ describe("ConnectionsTable", () => { id: "first-job", data: { connectionId: connection.id }, status: "COMPLETED", + startedAt: null, errorMessage: null, result: { outcome: "SUCCESS" }, }, @@ -333,6 +335,7 @@ describe("ConnectionsTable", () => { id: "job-1", data: { connectionId: connections[0].id }, status, + startedAt: null, errorMessage: null, result: null, }, @@ -351,6 +354,7 @@ describe("ConnectionsTable", () => { id: "failed-job", data: { connectionId: connections[0].id }, status: "FAILED", + startedAt: null, errorMessage: "Authentication failed", result: null, }, @@ -371,6 +375,7 @@ describe("ConnectionsTable", () => { id: "warning-job", data: { connectionId: connections[0].id }, status: "FAILED", + startedAt: null, errorMessage: "Authentication failed", result: null, }, @@ -396,6 +401,7 @@ describe("ConnectionsTable", () => { id: "partial-job", data: { connectionId: connections[0].id }, status: "COMPLETED", + startedAt: null, errorMessage: null, result: { outcome: "PARTIAL_SUCCESS", @@ -438,6 +444,7 @@ describe("ConnectionsTable", () => { id: "failed-job", data: { connectionId: connections[0].id }, status: "FAILED", + startedAt: null, errorMessage: "Authentication failed while discovering repositories", result: null, }, @@ -505,6 +512,7 @@ describe("ConnectionsTable", () => { id: "failed-job", data: { connectionId: connections[0].id }, status: "FAILED", + startedAt: null, errorMessage: "fetch failed", result: null, }, @@ -544,6 +552,7 @@ describe("ConnectionsTable", () => { id: "job-1", data: { connectionId: connections[0].id }, status: "COMPLETED", + startedAt: null, errorMessage: null, result: { outcome: "SUCCESS" }, }, diff --git a/packages/web/src/app/(app)/settings/connections/components/connectionsTable.tsx b/packages/web/src/app/(app)/settings/connections/components/connectionsTable.tsx index f901f3065..52fc9aee9 100644 --- a/packages/web/src/app/(app)/settings/connections/components/connectionsTable.tsx +++ b/packages/web/src/app/(app)/settings/connections/components/connectionsTable.tsx @@ -318,6 +318,7 @@ export const ConnectionsTable = ({ id: jobId, data: { connectionId }, status: "PENDING", + startedAt: null, errorMessage: null, result: null, }); diff --git a/packages/web/src/features/connections/connectionSyncCounts.server.test.ts b/packages/web/src/features/connections/connectionSyncCounts.server.test.ts index c5c40bbc0..5fab08b9a 100644 --- a/packages/web/src/features/connections/connectionSyncCounts.server.test.ts +++ b/packages/web/src/features/connections/connectionSyncCounts.server.test.ts @@ -39,6 +39,7 @@ const job = ( id, data: { connectionId }, status, + startedAt: null, errorMessage: status === "FAILED" ? "sync failed" : null, result, }); From 6ce596b05fe221c7427f06d4a2cec6eac99a2e82 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 31 Aug 2026 11:28:29 -0700 Subject: [PATCH 2/4] chore: update changelog for #1623 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46c363645..11f2531a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Added a live repository indexing job runtime popover to the syncing badge. [#1623](https://github.com/sourcebot-dev/sourcebot/pull/1623) + ## [5.1.10] - 2026-08-27 ### Fixed From 02f695c718dde758d2468cb3b84d3de6431c570f Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 31 Aug 2026 11:46:58 -0700 Subject: [PATCH 3/4] feat(web): show index runtime inline --- CHANGELOG.md | 2 +- .../repos/components/reposTable.test.tsx | 14 +-- .../app/(app)/repos/components/reposTable.tsx | 5 +- .../repos/components/syncingBadge.test.tsx | 33 +++++++ .../(app)/repos/components/syncingBadge.tsx | 59 ++++++++++++ .../repos/components/syncingPopover.test.tsx | 46 ---------- .../(app)/repos/components/syncingPopover.tsx | 91 ------------------- 7 files changed, 102 insertions(+), 148 deletions(-) create mode 100644 packages/web/src/app/(app)/repos/components/syncingBadge.test.tsx create mode 100644 packages/web/src/app/(app)/repos/components/syncingBadge.tsx delete mode 100644 packages/web/src/app/(app)/repos/components/syncingPopover.test.tsx delete mode 100644 packages/web/src/app/(app)/repos/components/syncingPopover.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 11f2531a4..1f20fd50c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- Added a live repository indexing job runtime popover to the syncing badge. [#1623](https://github.com/sourcebot-dev/sourcebot/pull/1623) +- Added a live repository indexing job runtime to the syncing badge. [#1623](https://github.com/sourcebot-dev/sourcebot/pull/1623) ## [5.1.10] - 2026-08-27 diff --git a/packages/web/src/app/(app)/repos/components/reposTable.test.tsx b/packages/web/src/app/(app)/repos/components/reposTable.test.tsx index 3bdd97724..050ec36e7 100644 --- a/packages/web/src/app/(app)/repos/components/reposTable.test.tsx +++ b/packages/web/src/app/(app)/repos/components/reposTable.test.tsx @@ -42,7 +42,7 @@ const repos: Repo[] = [ id: "job-1", data: { repoId: 1 }, status: "IN_PROGRESS", - startedAt: null, + startedAt: Date.now() - 30_000, errorMessage: null, result: null, }, @@ -290,7 +290,7 @@ describe("ReposTable", () => { await waitFor(() => { expect(reposActions.indexRepo).toHaveBeenCalledWith(2); - expect(screen.getByText("Syncing")).toBeTruthy(); + expect(screen.getByText("Pending")).toBeTruthy(); expect(fetch).toHaveBeenCalledOnce(); }); @@ -313,7 +313,7 @@ describe("ReposTable", () => { id: "active-reindex-job", data: { repoId: repos[1].id }, status: "IN_PROGRESS", - startedAt: null, + startedAt: Date.now() - 30_000, errorMessage: null, result: null, }, @@ -546,7 +546,7 @@ describe("ReposTable", () => { await waitFor(() => { expect(reposActions.indexRepo).toHaveBeenCalledWith(1); - expect(screen.getByText("Syncing")).toBeTruthy(); + expect(screen.getByText("Pending")).toBeTruthy(); expect(fetch).toHaveBeenCalledOnce(); }); expect(screen.queryByText("Repository sync failed")).toBeNull(); @@ -607,7 +607,7 @@ describe("ReposTable", () => { expect(navigation.refresh).not.toHaveBeenCalled(); }); - test("polls a syncing repository whose latest job is missing", async () => { + test("polls a pending repository whose latest job is missing", async () => { const response: RepoIndexingStatusesResponse = { repositories: [{ repoId: 1, @@ -627,10 +627,10 @@ describe("ReposTable", () => { renderTable([{ ...repos[0], latestJob: null }]); - expect(screen.getByText("Syncing")).toBeTruthy(); + expect(screen.getByText("Pending")).toBeTruthy(); await waitFor(() => expect(fetch).toHaveBeenCalledOnce()); await waitFor(() => expect(screen.getByText("Failed")).toBeTruthy()); - expect(screen.queryByText("Syncing")).toBeNull(); + expect(screen.queryByText("Pending")).toBeNull(); expect(navigation.refresh).not.toHaveBeenCalled(); }); }); diff --git a/packages/web/src/app/(app)/repos/components/reposTable.tsx b/packages/web/src/app/(app)/repos/components/reposTable.tsx index 492c403fb..3a8e667df 100644 --- a/packages/web/src/app/(app)/repos/components/reposTable.tsx +++ b/packages/web/src/app/(app)/repos/components/reposTable.tsx @@ -51,7 +51,7 @@ import { getBrowsePath } from "../../browse/hooks/utils"; import type { RepoIndexingStatusesResponse } from "../types"; import { RepoActionsMenu } from "./repoActionsMenu"; import { SyncIssuePopover } from "./syncIssuePopover"; -import { SyncingPopover } from "./syncingPopover"; +import { SyncingBadge } from "./syncingBadge"; const POLL_INTERVAL_MS = 5_000; const COMPLETED_BADGE_VISIBLE_MS = 5_000; @@ -192,8 +192,7 @@ const SyncAnnotationBadge = ({ ); case "SYNCING": return ( - { + cleanup(); + vi.useRealTimers(); +}); + +describe("SyncingBadge", () => { + test("shows a live job duration", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-31T12:00:00.000Z")); + render( + , + ); + + expect(screen.getByText("Syncing")).toBeTruthy(); + expect(screen.getByText("1m 30s")).toBeTruthy(); + + act(() => vi.advanceTimersByTime(1_000)); + + expect(screen.getByText("1m 31s")).toBeTruthy(); + }); + + test("shows pending while the indexing job is waiting to start", () => { + render(); + + expect(screen.getByText("Pending")).toBeTruthy(); + expect(screen.queryByText("Syncing")).toBeNull(); + expect(screen.queryByText(/\d+s/)).toBeNull(); + }); +}); diff --git a/packages/web/src/app/(app)/repos/components/syncingBadge.tsx b/packages/web/src/app/(app)/repos/components/syncingBadge.tsx new file mode 100644 index 000000000..3d6ceb26e --- /dev/null +++ b/packages/web/src/app/(app)/repos/components/syncingBadge.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Loader2 } from "lucide-react"; +import { useEffect, useState } from "react"; + +const DURATION_UPDATE_INTERVAL_MS = 1_000; + +const formatJobDuration = (durationMs: number) => { + const totalSeconds = Math.max(0, Math.floor(durationMs / 1_000)); + const days = Math.floor(totalSeconds / 86_400); + const hours = Math.floor(totalSeconds / 3_600) % 24; + const minutes = Math.floor(totalSeconds / 60) % 60; + const seconds = totalSeconds % 60; + + return [ + days > 0 ? `${days}d` : null, + days > 0 || hours > 0 ? `${hours}h` : null, + days > 0 || hours > 0 || minutes > 0 ? `${minutes}m` : null, + `${seconds}s`, + ].filter(Boolean).join(" "); +}; + +type SyncingBadgeProps = { + startedAt: number | null; +}; + +export const SyncingBadge = ({ startedAt }: SyncingBadgeProps) => { + const [currentTime, setCurrentTime] = useState(() => Date.now()); + + useEffect(() => { + if (startedAt === null) { + return; + } + + setCurrentTime(Date.now()); + const interval = window.setInterval(() => { + setCurrentTime(Date.now()); + }, DURATION_UPDATE_INTERVAL_MS); + return () => window.clearInterval(interval); + }, [startedAt]); + + return ( + + + {startedAt === null + ? Pending + : ( + <> + Syncing + + + {formatJobDuration(currentTime - startedAt)} + + + )} + + ); +}; diff --git a/packages/web/src/app/(app)/repos/components/syncingPopover.test.tsx b/packages/web/src/app/(app)/repos/components/syncingPopover.test.tsx deleted file mode 100644 index 62ace53fd..000000000 --- a/packages/web/src/app/(app)/repos/components/syncingPopover.test.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, test, vi } from "vitest"; -import { SyncingPopover } from "./syncingPopover"; - -afterEach(() => { - cleanup(); - vi.useRealTimers(); -}); - -describe("SyncingPopover", () => { - test("shows a live job duration while open", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-08-31T12:00:00.000Z")); - render( - , - ); - - fireEvent.click(screen.getByRole("button", { - name: "View sync details for acme/sourcebot", - })); - - expect(screen.getByText("1m 30s")).toBeTruthy(); - - act(() => vi.advanceTimersByTime(1_000)); - - expect(screen.getByText("1m 31s")).toBeTruthy(); - }); - - test("shows when the indexing job is waiting to start", () => { - render( - , - ); - - fireEvent.click(screen.getByRole("button", { - name: "View sync details for acme/sourcebot", - })); - - expect(screen.getByText("Waiting to start")).toBeTruthy(); - }); -}); diff --git a/packages/web/src/app/(app)/repos/components/syncingPopover.tsx b/packages/web/src/app/(app)/repos/components/syncingPopover.tsx deleted file mode 100644 index ac895ad23..000000000 --- a/packages/web/src/app/(app)/repos/components/syncingPopover.tsx +++ /dev/null @@ -1,91 +0,0 @@ -"use client"; - -import { badgeVariants } from "@/components/ui/badge"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { cn } from "@/lib/utils"; -import { Info, Loader2 } from "lucide-react"; -import { useEffect, useState } from "react"; - -const DURATION_UPDATE_INTERVAL_MS = 1_000; - -const formatJobDuration = (durationMs: number) => { - const totalSeconds = Math.max(0, Math.floor(durationMs / 1_000)); - const days = Math.floor(totalSeconds / 86_400); - const hours = Math.floor(totalSeconds / 3_600) % 24; - const minutes = Math.floor(totalSeconds / 60) % 60; - const seconds = totalSeconds % 60; - - return [ - days > 0 ? `${days}d` : null, - days > 0 || hours > 0 ? `${hours}h` : null, - days > 0 || hours > 0 || minutes > 0 ? `${minutes}m` : null, - `${seconds}s`, - ].filter(Boolean).join(" "); -}; - -type SyncingPopoverProps = { - repoDisplayName: string; - startedAt: number | null; -}; - -export const SyncingPopover = ({ - repoDisplayName, - startedAt, -}: SyncingPopoverProps) => { - const [isOpen, setIsOpen] = useState(false); - const [currentTime, setCurrentTime] = useState(() => Date.now()); - - useEffect(() => { - if (!isOpen || startedAt === null) { - return; - } - - const interval = window.setInterval(() => { - setCurrentTime(Date.now()); - }, DURATION_UPDATE_INTERVAL_MS); - return () => window.clearInterval(interval); - }, [isOpen, startedAt]); - - const handleOpenChange = (open: boolean) => { - setIsOpen(open); - if (open) { - setCurrentTime(Date.now()); - } - }; - - return ( - - - - - -
-

Repository syncing

-
-
Job duration
-
- {startedAt === null - ? "Waiting to start" - : formatJobDuration(currentTime - startedAt)} -
-
-
-
-
- ); -}; From 251e8407d67552b21836f19f401f8b3d17f664f6 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 31 Aug 2026 11:51:55 -0700 Subject: [PATCH 4/4] fix(shared): guard workload job start times --- packages/shared/src/bullmqClient.test.ts | 24 ++++++++++++++++++++++++ packages/shared/src/bullmqClient.ts | 3 ++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/bullmqClient.test.ts b/packages/shared/src/bullmqClient.test.ts index dcedeca91..f373cf5b5 100644 --- a/packages/shared/src/bullmqClient.test.ts +++ b/packages/shared/src/bullmqClient.test.ts @@ -60,6 +60,7 @@ describe("BullMQClient", () => { return { id: jobId, data: { connectionId: 2 }, + processedOn: 800_000, failedReason: "", returnvalue: { outcome: "SUCCESS" }, getState: vi.fn(async () => "completed"), @@ -92,6 +93,29 @@ describe("BullMQClient", () => { ])); }); + test("does not expose a stale start time for a pending job", async () => { + mocks.getJob.mockResolvedValue({ + id: "job-1", + data: { connectionId: 1 }, + processedOn: 900_000, + failedReason: "", + returnvalue: null, + getState: vi.fn(async () => "waiting"), + }); + const client = new BullMQClient({} as Redis); + + await expect( + client.getJob(CONNECTION_QUEUE, "job-1"), + ).resolves.toEqual({ + id: "job-1", + data: { connectionId: 1 }, + status: "PENDING", + startedAt: null, + errorMessage: null, + result: null, + }); + }); + test("returns null for an unrecognized legacy connection result", async () => { mocks.getJob.mockResolvedValue({ id: "job-1", diff --git a/packages/shared/src/bullmqClient.ts b/packages/shared/src/bullmqClient.ts index cf6f687d1..19178f752 100644 --- a/packages/shared/src/bullmqClient.ts +++ b/packages/shared/src/bullmqClient.ts @@ -101,7 +101,8 @@ export class BullMQClient { id: job.id ?? jobId, data: job.data as DataOf, status, - startedAt: typeof job.processedOn === "number" + startedAt: status === "IN_PROGRESS" + && typeof job.processedOn === "number" ? job.processedOn : null, errorMessage: status === "FAILED" ? job.failedReason || null : null,