From 05874e289ae12e1251eb0cdd4b94bf4d92fd728f Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:00:54 +0100 Subject: [PATCH] Keep relative timestamps current in agent, schedule, and import-session rows (#5340) * Keep relative timestamps current in agent, schedule, and import-session rows Agent list rows, schedule rows, and import-session rows formatted their "5m ago" labels once at render, so an idle row kept its first label until something else re-rendered it. They now use useTimeAgo, which rides the same shared ticker the sidebar and desktop tabs already use. Closes #4625 * Say "just now" for the whole first minute so fresh labels stay honest The shared ticker wakes once a minute, so a seconds label could only go stale. The prose and compact formatters now share one age description, and the schedule spec anchors its clock to the schedule's creation time. * Run every cleanup in the schedule relative-time spec and name its steps * Keep relative labels current with focused subscriptions * Update usage card after relative-time hook rename * Anchor import-session age spec to fixture time --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Mohamed Boudra --- .../e2e/browser/agent-relative-time.spec.ts | 51 ++++++++ .../app/e2e/browser/commit-diff-panel.spec.ts | 24 +++- .../e2e/browser/import-session-flow.spec.ts | 22 +++- .../browser/provider-settings-refresh.spec.ts | 19 +++ .../browser/schedules-relative-time.spec.ts | 118 ++++++++++++++++++ packages/app/src/components/agent-list.tsx | 18 ++- .../src/components/import-session-sheet.tsx | 16 ++- .../components/provider-diagnostic-sheet.tsx | 49 ++++---- .../src/components/schedules/schedule-row.tsx | 52 ++++++-- .../sidebar/workspace-trailing/index.tsx | 2 +- .../src/git/commits-section/commit-row.tsx | 11 +- .../git/commits-section/commits-section.tsx | 24 +--- ...se-compact-time-ago.ts => use-time-ago.ts} | 22 +++- .../workspace/workspace-desktop-tabs-row.tsx | 2 +- packages/app/src/usage/card.tsx | 2 +- packages/app/src/utils/time.test.ts | 18 ++- packages/app/src/utils/time.ts | 102 ++++++++------- 17 files changed, 410 insertions(+), 142 deletions(-) create mode 100644 packages/app/e2e/browser/agent-relative-time.spec.ts create mode 100644 packages/app/e2e/browser/schedules-relative-time.spec.ts rename packages/app/src/hooks/{use-compact-time-ago.ts => use-time-ago.ts} (75%) diff --git a/packages/app/e2e/browser/agent-relative-time.spec.ts b/packages/app/e2e/browser/agent-relative-time.spec.ts new file mode 100644 index 0000000000..17dae013ec --- /dev/null +++ b/packages/app/e2e/browser/agent-relative-time.spec.ts @@ -0,0 +1,51 @@ +import { expect, test } from "../support/fixtures"; +import { openCommandCenter } from "../support/helpers/command-center"; +import { getServerId } from "../support/helpers/server-id"; +import { seedMockAgentWorkspace } from "../support/helpers/mock-agent"; +import { openAgentRoute } from "../support/helpers/mock-agent"; +import { resetSeededPageState, openSessions } from "../support/helpers/archive-tab"; +import { closeSidebarDisplayPreferences, openSidebarDisplayPage } from "../support/helpers/sidebar"; + +test("agent list age advances and command center says just now for a fresh agent", async ({ + page, +}) => { + const session = await seedMockAgentWorkspace({ + repoPrefix: "agent-relative-time-", + title: "Relative time agent", + }); + try { + await session.client.waitForAgentUpsert(session.agentId, (agent) => agent.status === "idle"); + await resetSeededPageState(page); + await page.clock.install({ time: Date.now() }); + await openSessions(page); + + const row = page.getByTestId(`agent-row-${getServerId()}-${session.agentId}`); + await expect(row).toContainText("just now", { timeout: 30_000 }); + + const commandCenter = await openCommandCenter(page); + await commandCenter.getByTestId("command-center-input").fill("Relative time agent"); + const agentResult = commandCenter.getByTestId( + `command-center-agent-${getServerId()}:${session.agentId}`, + ); + await expect(agentResult.getByTestId("command-center-agent-subtitle")).toContainText( + "just now", + ); + await page.keyboard.press("Escape"); + + await page.clock.fastForward("03:00"); + await expect(row).toContainText("3m ago"); + + await openAgentRoute(page, session); + await page.getByTestId(`workspace-tab-agent_${session.agentId}`).first().hover(); + await expect(page.getByTestId(`workspace-tab-tooltip-agent_${session.agentId}`)).toContainText( + "3m ago", + ); + + await openSidebarDisplayPage(page, "sidebar-display-show"); + await page.getByRole("menuitem", { name: "Last activity", exact: true }).click(); + await closeSidebarDisplayPreferences(page); + await expect(page.getByTestId("sidebar-workspace-timestamp").first()).toHaveText("3m"); + } finally { + await session.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/commit-diff-panel.spec.ts b/packages/app/e2e/browser/commit-diff-panel.spec.ts index d29e566f80..75387cbdfa 100644 --- a/packages/app/e2e/browser/commit-diff-panel.spec.ts +++ b/packages/app/e2e/browser/commit-diff-panel.spec.ts @@ -87,7 +87,25 @@ test("commit history shows dates and shares diff layout preferences", async ({ await expect(panel.getByTestId("git-diff-canvas")).toBeVisible(); }); -async function createFeatureCommit(repoPath: string): Promise { +test("a fresh commit says just now and its age advances", async ({ page, withWorkspace }) => { + const workspace = await withWorkspace({ prefix: "commit-relative-time-" }); + const committedAt = new Date(); + await createFeatureCommit(workspace.repoPath, committedAt.toISOString()); + await page.clock.install({ time: committedAt }); + await workspace.navigateTo(); + + await openChangesTreePanel(page); + await page.getByRole("button", { name: /Commits/i }).click(); + const row = page.locator('[data-testid^="commit-row-"]').filter({ hasText: COMMIT_SUBJECT }); + await expect(row).toContainText("just now", { timeout: 30_000 }); + await page.clock.fastForward("03:00"); + await expect(row).toContainText("3m ago"); +}); + +async function createFeatureCommit( + repoPath: string, + authorDate = "2020-01-15T12:00:00Z", +): Promise { execFileSync("git", ["checkout", "-b", "feature"], { cwd: repoPath, stdio: "ignore" }); await writeFile(path.join(repoPath, "feature.txt"), "before\nafter\n"); execFileSync("git", ["add", "feature.txt"], { cwd: repoPath, stdio: "ignore" }); @@ -96,8 +114,8 @@ async function createFeatureCommit(repoPath: string): Promise { stdio: "ignore", env: { ...process.env, - GIT_AUTHOR_DATE: "2020-01-15T12:00:00Z", - GIT_COMMITTER_DATE: "2020-01-15T12:00:00Z", + GIT_AUTHOR_DATE: authorDate, + GIT_COMMITTER_DATE: authorDate, }, }); } diff --git a/packages/app/e2e/browser/import-session-flow.spec.ts b/packages/app/e2e/browser/import-session-flow.spec.ts index 00322c86a3..592d31c330 100644 --- a/packages/app/e2e/browser/import-session-flow.spec.ts +++ b/packages/app/e2e/browser/import-session-flow.spec.ts @@ -4,7 +4,7 @@ import { copyFile, mkdir, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import type { TestInfo } from "@playwright/test"; -import { test, type Page } from "../support/fixtures"; +import { expect, test, type Page } from "../support/fixtures"; import { ImportSessionFlow } from "../support/helpers/import-session"; import { connectNewWorkspaceDaemonClient, @@ -49,6 +49,7 @@ interface ImportFlowScenario { worktreeDirectory: string; unrelatedDirectory: string; importSessionId: string; + importSessionTimestamp: number; repoCleanup(): Promise; unrelatedCleanup(): Promise; } @@ -94,7 +95,7 @@ test.beforeAll(async () => { }; const reuseTarget = await openProjectViaDaemon(client, unrelated.path); const importSessionId = "fixture-custom-title"; - await seedClaudeSessions({ + const importSessionTimestamp = await seedClaudeSessions({ projectRoot: repo.path, worktreeDirectory, unrelatedDirectory: unrelated.path, @@ -108,6 +109,7 @@ test.beforeAll(async () => { worktreeDirectory, unrelatedDirectory: unrelated.path, importSessionId, + importSessionTimestamp, repoCleanup: repo.cleanup, unrelatedCleanup: unrelated.cleanup, }; @@ -122,6 +124,19 @@ test.afterAll(async () => { await rm(claudeConfigDirectory, { recursive: true, force: true }); }); +test("an open Import Session row keeps its age current", async ({ page }) => { + await page.clock.install({ time: scenario.importSessionTimestamp + 60_000 }); + const flow = new ImportSessionFlow(page); + await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 }); + await flow.revealMobileEntryPoint(); + await flow.openGlobally(); + + const row = page.getByTestId(`import-session-session-claude-${scenario.importSessionId}`); + await expect(row).toContainText("1m ago"); + await page.clock.fastForward("03:00"); + await expect(row).toContainText("4m ago"); +}); + test("captures the compact import-session journey", async ({ page }, testInfo) => { const flow = new ImportSessionFlow(page); await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 }); @@ -225,7 +240,7 @@ async function seedClaudeSessions(input: { worktreeDirectory: string; unrelatedDirectory: string; importSessionId: string; -}): Promise { +}): Promise { const sessions = [ { cwd: input.projectRoot, @@ -290,4 +305,5 @@ async function seedClaudeSessions(input: { const timestamp = new Date(newest - index * 60_000); await utimes(sessionPath, timestamp, timestamp); } + return newest; } diff --git a/packages/app/e2e/browser/provider-settings-refresh.spec.ts b/packages/app/e2e/browser/provider-settings-refresh.spec.ts index 5f3555504f..efc4fc7f88 100644 --- a/packages/app/e2e/browser/provider-settings-refresh.spec.ts +++ b/packages/app/e2e/browser/provider-settings-refresh.spec.ts @@ -119,6 +119,25 @@ async function exerciseProviderSettingsStack(page: Page) { } test.describe("provider settings overlay stack", () => { + test("provider model update age says just now", async ({ page }) => { + const session = await seedMockAgentWorkspace({ + repoPrefix: "provider-relative-time-", + title: "Provider relative time", + }); + try { + await page.clock.install({ time: Date.now() }); + await page.setViewportSize(MOBILE_VIEWPORT); + await openAgentRoute(page, session); + await expectComposerVisible(page); + await openProviderSettingsFromModelSelector(page); + await expect(page.getByText("Updated just now", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + } finally { + await session.cleanup(); + } + }); + test("provider settings covers the desktop model selector without closing it", async ({ page, }) => { diff --git a/packages/app/e2e/browser/schedules-relative-time.spec.ts b/packages/app/e2e/browser/schedules-relative-time.spec.ts new file mode 100644 index 0000000000..4c20f54042 --- /dev/null +++ b/packages/app/e2e/browser/schedules-relative-time.spec.ts @@ -0,0 +1,118 @@ +import { expect, test, type Page } from "../support/fixtures"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { buildSchedulesRoute } from "../../src/utils/host-routes"; + +interface ScheduleSeedClient { + scheduleCreate(input: { + prompt: string; + name?: string; + cadence: { type: "cron"; expression: string }; + target: { + type: "new-agent"; + config: { + provider: "mock"; + cwd: string; + model: string; + modeId: string; + title: string; + }; + }; + runOnCreate: boolean; + }): Promise<{ schedule: { id: string; createdAt: string } | null; error: string | null }>; + schedulePause(input: { id: string }): Promise<{ error: string | null }>; + scheduleDelete(input: { id: string }): Promise<{ error: string | null }>; +} + +interface SeededSchedule { + id: string; + createdAt: number; +} + +async function seedPausedSchedule( + workspace: SeededWorkspace, + name: string, +): Promise { + const client = workspace.client as unknown as ScheduleSeedClient; + const result = await client.scheduleCreate({ + prompt: "Say hello from the scheduled agent.", + name, + cadence: { type: "cron", expression: "0 9 * * *" }, + target: { + type: "new-agent", + config: { + provider: "mock", + cwd: workspace.repoPath, + model: "ten-second-stream", + modeId: "load-test", + title: name, + }, + }, + runOnCreate: false, + }); + if (!result.schedule) { + throw new Error(result.error ?? "Failed to seed schedule"); + } + const { id, createdAt } = result.schedule; + const paused = await client.schedulePause({ id }); + if (paused.error) { + throw new Error(paused.error); + } + return { id, createdAt: Date.parse(createdAt) }; +} + +async function deleteSeededSchedule(workspace: SeededWorkspace, id: string): Promise { + const client = workspace.client as unknown as ScheduleSeedClient; + const result = await client.scheduleDelete({ id }); + if (result.error) { + throw new Error(result.error); + } +} + +/** Opens Schedules with the browser clock frozen at `time`, so the page cannot age it. */ +async function openSchedulesAt(page: Page, time: number): Promise { + await page.clock.install({ time }); + await page.goto(buildSchedulesRoute()); +} + +async function letTimePass(page: Page, duration: string): Promise { + await page.clock.fastForward(duration); +} + +async function expectScheduleRowText(page: Page, scheduleId: string, text: string): Promise { + await expect(page.getByTestId(`schedule-row-${scheduleId}`)).toContainText(text, { + timeout: 30_000, + }); +} + +test.describe("Schedule relative timestamps", () => { + const cleanupTasks: Array<() => Promise> = []; + + test.afterEach(async () => { + const failures: unknown[] = []; + for (const cleanup of cleanupTasks.toReversed()) { + try { + await cleanup(); + } catch (error) { + failures.push(error); + } + } + cleanupTasks.length = 0; + if (failures.length > 0) { + throw new AggregateError(failures, "Test cleanup failed"); + } + }); + + test("an idle schedule row keeps its created age current", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "schedule-relative-time-", git: false }); + cleanupTasks.push(() => workspace.cleanup()); + const schedule = await seedPausedSchedule(workspace, `Relative time ${Date.now()}`); + cleanupTasks.push(() => deleteSeededSchedule(workspace, schedule.id)); + + await openSchedulesAt(page, schedule.createdAt); + await expectScheduleRowText(page, schedule.id, "Created just now"); + + await letTimePass(page, "03:00"); + + await expectScheduleRowText(page, schedule.id, "Created 3m ago"); + }); +}); diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 0ff450739d..406b498bfe 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -14,7 +14,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { formatTimeAgo } from "@/utils/time"; +import { useTimeAgo } from "@/hooks/use-time-ago"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useSessionStore } from "@/stores/session-store"; import { Archive, ChevronRight } from "lucide-react-native"; @@ -181,7 +181,6 @@ function SessionRow({ }) { const { theme } = useUnistyles(); const { t } = useTranslation(); - const timeAgo = formatTimeAgo(agent.lastActivityAt); const agentKey = `${agent.serverId}:${agent.id}`; const isSelected = selectedAgentId === agentKey; const projectName = agent.projectPlacement?.projectName ?? ""; @@ -283,7 +282,7 @@ function SessionRow({ testID={`agent-row-branch-${agent.serverId}-${agent.id}`} /> · - {timeAgo} + {showHostColumn && agent.serverLabel ? ( <> · @@ -316,9 +315,7 @@ function SessionRow({ numberOfLines={1} testID={`agent-row-branch-${agent.serverId}-${agent.id}`} /> - - {timeAgo} - + ) : null} + {label} + + ); +} + export function AgentList({ agents, isRefreshing = false, diff --git a/packages/app/src/components/import-session-sheet.tsx b/packages/app/src/components/import-session-sheet.tsx index ebfabd08f5..c6d13d1a28 100644 --- a/packages/app/src/components/import-session-sheet.tsx +++ b/packages/app/src/components/import-session-sheet.tsx @@ -14,7 +14,7 @@ import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { getProviderIcon } from "@/components/provider-icons"; -import { formatTimeAgo } from "@/utils/time"; +import { useTimeAgo } from "@/hooks/use-time-ago"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { useHostProjects } from "@/projects/host-projects"; @@ -309,7 +309,6 @@ function ImportSessionSheetRow({ const { t } = useTranslation(); const title = getSessionTitle(entry); const promptPreview = getPromptPreview(entry); - const lastActivity = formatTimeAgo(new Date(entry.lastActivityAt)); const ProviderIcon = getProviderIcon(entry.providerId, serverId); const accessibilityState = useMemo( () => (disabled ? DISABLED_ACCESSIBILITY_STATE : undefined), @@ -344,9 +343,11 @@ function ImportSessionSheetRow({ {title} - - {importing ? t("importSession.row.importing") : lastActivity} - + {importing ? ( + {t("importSession.row.importing")} + ) : ( + + )} {promptPreview} @@ -365,6 +366,11 @@ function ImportSessionSheetRow({ ); } +function ImportSessionActivityTime({ date }: { date: string }) { + const label = useTimeAgo(new Date(date)); + return {label}; +} + function SessionRows({ serverId, entries, diff --git a/packages/app/src/components/provider-diagnostic-sheet.tsx b/packages/app/src/components/provider-diagnostic-sheet.tsx index 7babccd240..bf2d1c1b95 100644 --- a/packages/app/src/components/provider-diagnostic-sheet.tsx +++ b/packages/app/src/components/provider-diagnostic-sheet.tsx @@ -19,10 +19,10 @@ import { useToast } from "@/contexts/toast-context"; import { CODE_SURFACE_DATASET } from "@/styles/code-surface"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; +import { useTimeAgo } from "@/hooks/use-time-ago"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { settingsStyles } from "@/styles/settings"; import { resolveProviderLabel } from "@/utils/provider-definitions"; -import { formatTimeAgo } from "@/utils/time"; import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match"; import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types"; import type { ProviderProfileModel } from "@getpaseo/protocol/provider-config"; @@ -409,7 +409,7 @@ interface ProviderModalBodyProps { } interface ProviderSheetFooterInput { - fetchedAtLabel: string | null; + fetchedAt: string | null; isCompact: boolean; modelsRefreshing: boolean; t: TFunction; @@ -419,7 +419,7 @@ interface ProviderSheetFooterInput { } function renderProviderSheetFooter({ - fetchedAtLabel, + fetchedAt, isCompact, modelsRefreshing, t, @@ -430,16 +430,10 @@ function renderProviderSheetFooter({ const contentStyle = isCompact ? sheetStyles.compactFooterContent : sheetStyles.footerContent; const actionsStyle = isCompact ? sheetStyles.compactFooterActions : sheetStyles.footerActions; const buttonStyle = isCompact ? sheetStyles.compactFooterButton : null; - const metaStyle = isCompact - ? [sheetStyles.footerMeta, sheetStyles.compactFooterMeta] - : sheetStyles.footerMeta; - return ( - {fetchedAtLabel || !isCompact ? ( - - {fetchedAtLabel ? t("settings.providers.models.updated", { time: fetchedAtLabel }) : ""} - + {fetchedAt || !isCompact ? ( + ) : null}