diff --git a/apps/web/e2e/orchestration.spec.ts b/apps/web/e2e/orchestration.spec.ts index f110d2d2..366c2108 100644 --- a/apps/web/e2e/orchestration.spec.ts +++ b/apps/web/e2e/orchestration.spec.ts @@ -59,7 +59,7 @@ const pickChat = (page: Page) => const workspaceRow = (page: Page, workspaceId: string) => page.locator(`[data-testid="overview-workspace"][data-workspace-id="${workspaceId}"]`); -/** A finished worker's row under the overview's Done today. */ +/** A finished worker's row under the overview's Done. */ const doneRow = (page: Page, threadId: string) => page.locator(`[data-testid="overview-done"][data-thread-id="${threadId}"]`); @@ -67,8 +67,8 @@ const doneRow = (page: Page, threadId: string) => const soloRow = (page: Page, threadId: string) => page.locator(`[data-testid="overview-solo"][data-thread-id="${threadId}"]`); -/** Done today, which starts folded, opened. */ -const unfoldDoneToday = async (page: Page) => { +/** Done, which starts folded, opened. */ +const unfoldDone = async (page: Page) => { const toggle = page.getByTestId("overview-section-doneToday").getByRole("button").first(); await expect(toggle).toHaveAttribute("aria-expanded", "false"); await toggle.click(); @@ -96,11 +96,11 @@ test("an orchestrator and its worker are marked, and their transcripts show the const made = await simulate(page); // Their home is the workspace's line under Orchestrators, by its name; the worker, reported - // done, is under Done today, which names its workspace. Solo threads lists neither. + // done, is under Done, which names its workspace. Solo threads lists neither. const workspace = workspaceRow(page, made.workspaceId); await expect(workspace.getByTestId("overview-workspace-row")).toHaveAccessibleName(/^api, /); await expect(workspace).toContainText("No workers now."); - await unfoldDoneToday(page); + await unfoldDone(page); await expect(doneRow(page, made.workerThreadId)).toHaveAccessibleName( /^Return rate-limit headers, api · done, /, ); @@ -230,9 +230,9 @@ test("a worker's menu goes to its orchestrator, removing makes it a plain thread await page.goto("/t/t-flaky"); await expect(threadRow(page, "t-flaky")).toBeVisible(); const made = await simulate(page); - // The worker reported done, so its row is under Done today, and its menu is its thread's More…. + // The worker reported done, so its row is under Done, and its menu is its thread's More…. const workerRow = doneRow(page, made.workerThreadId); - await unfoldDoneToday(page); + await unfoldDone(page); await workerRow.click(); await expect(page).toHaveURL(new RegExp(`/t/${made.workerThreadId}$`)); @@ -762,10 +762,10 @@ test("an orchestrator shows no PR of its own, and its PR tab points at the Board await expect(page.getByTestId("pr-row")).toBeVisible(); }); -test("finished work is under a folded Done today, and a merged worker offers its cleanup", async ({ +test("finished work is under a folded Done, and a merged worker offers its cleanup", async ({ page, }) => { - // Midday, so the work the mock finished hours ago is still today's whatever the hour it runs. + // Midday, so the work the mock finished hours ago is in the week whatever the hour it runs. await page.clock.install({ time: new Date("2026-10-21T12:00:00") }); await page.goto("/t/t-flaky"); await expect(threadRow(page, "t-flaky")).toBeVisible(); @@ -776,9 +776,9 @@ test("finished work is under a folded Done today, and a merged worker offers its }); const [merged, , , shipped] = made.merged; - // Finished work is under Done today, which starts folded: the delivered worker, and the merged + // Finished work is under Done, which starts folded: the delivered worker, and the merged // one of `mail-trigger`, a feature still going. - await unfoldDoneToday(page); + await unfoldDone(page); await expect(doneRow(page, made.delivered)).toHaveAccessibleName( /^Changelog lint script, agent-platform · done, /, ); diff --git a/apps/web/e2e/overview-extras.spec.ts b/apps/web/e2e/overview-extras.spec.ts index 1ed7f980..9e554e0f 100644 --- a/apps/web/e2e/overview-extras.spec.ts +++ b/apps/web/e2e/overview-extras.spec.ts @@ -3,11 +3,11 @@ import { expect, test, type Page } from "@playwright/test"; import type { MockOneThreadSimulation } from "../src/api/mock-asks"; import { pressFirstWorkspaceKey } from "./thread-row"; -// The overview sidebar's extras: Pull requests and Done -// today, folded until opened, the usage meters and worker slots at its foot, off by default, ⌘J's jump into its -// workers, and Settings → General's Sidebar sections. Each is checked against what the right -// panel's PR lists, ⌘J's list, the title bar's meter and the ledger say. Mock state lives in memory, so every -// step after the seed stays inside the app, bar the reload the settings are kept across. +// The overview sidebar's extras: Pull requests and Done, folded until opened, the usage meters and +// worker slots at its foot, off by default, ⌘J's jump into its workers, and Settings → General's +// Sidebar sections. Each is checked against what the right panel's PR lists, ⌘J's list, the title +// bar's meter and the ledger say. Mock state lives in memory, so every step after the seed stays +// inside the app, bar the reload the settings are kept across. const MOCK_SETTINGS_KEY = "gram.mock.settings"; @@ -189,15 +189,21 @@ test("Pull requests stays folded until opened, kept across a reload, and leaves await expect(kinds.and(page.locator('[data-kind="yours"]')).first()).toBeVisible(); }); -test("Done today starts folded and has ⌘J's Done today rows, opening one as ⌘J does", async ({ +test("Done starts folded and has the week of ⌘J's Done rows by day, opening one as ⌘J does", async ({ page, }) => { const made = await start(page); const title = sectionTitle(page, "doneToday"); await expect(title).toHaveAttribute("aria-expanded", "false"); + await expect(title).toHaveAccessibleName(/^Done, \d+$/); await title.click(); const rows = section(page, "doneToday").getByTestId("overview-done"); await expect(rows.first()).toBeVisible(); + // Under their days, today's first. + const days = section(page, "doneToday").getByTestId("overview-done-day"); + await expect(days.first()).toHaveText("Today"); + for (const day of await days.allTextContents()) + expect(day).toMatch(/^(Today|Yesterday|\w{3} \d{1,2} \w{3})$/); // ⌘J's own list, with the overview hidden so ⌘J opens it. await page.keyboard.press("ControlOrMeta+KeyB"); @@ -206,7 +212,7 @@ test("Done today starts folded and has ⌘J's Done today rows, opening one as const list = page.getByTestId("workers-list"); await expect(list).toBeVisible(); const fromList = await list - .getByTestId("workers-section-doneToday") + .getByTestId("workers-section-done") .getByTestId("worker-row") .evaluateAll((els) => els.map((el) => el.getAttribute("data-thread-id"))); expect(fromList).toContain(made.doneToday); @@ -215,19 +221,26 @@ test("Done today starts folded and has ⌘J's Done today rows, opening one as await page.keyboard.press("ControlOrMeta+KeyB"); await expect(overview(page)).toBeVisible(); + // The overview keeps the last seven days of them, in ⌘J's order; ⌘J has the older ones too. const here = rows.and(page.locator(`[data-workspace-id="${made.workspaceId}"]`)); - await expect(here).toHaveCount(fromList.length); const fromOverview = await here.evaluateAll((els) => els.map((el) => el.getAttribute("data-thread-id")), ); - expect(fromOverview.toSorted()).toEqual(fromList.toSorted()); + expect(fromOverview).toEqual(fromList.filter((id) => fromOverview.includes(id))); + expect(fromOverview).toContain(made.doneToday); + expect(fromOverview).toContain(made.earlier[0]); + expect(fromList).toEqual(expect.arrayContaining([...made.earlier])); + expect(fromOverview).not.toContain(made.earlier[1]); + expect(fromOverview).not.toContain(made.earlier[2]); const merged = rows.and(page.locator(`[data-thread-id="${made.doneToday}"]`)); await expect(merged).toHaveAttribute("data-merged", "true"); await expect(merged.getByTestId("overview-done-time")).toHaveText(/^\d{2}:\d{2}$/); // One line, grey until hovered: what became of it is on its hover and in its name. await expect(merged).toHaveAttribute("title", /^agent-platform · merged api#\d+$/); - await expect(merged).toHaveAccessibleName(/, agent-platform · merged api#\d+, \d{2}:\d{2}$/); + await expect(merged).toHaveAccessibleName( + /, agent-platform · merged api#\d+, Today \d{2}:\d{2}$/, + ); await expect(merged).toHaveCSS("filter", /grayscale\(1\)/); await merged.hover(); await expect(merged).not.toHaveCSS("filter", /grayscale\(1\)/); @@ -235,6 +248,32 @@ test("Done today starts folded and has ⌘J's Done today rows, opening one as await expect(page).toHaveURL(new RegExp(`/t/${made.doneToday}$`)); }); +test("Done shows its first ten rows, and Show all opens ⌘J on its Done", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/t/t-flaky"); + await page.getByLabel("Message", { exact: true }).waitFor(); + // Archived groups: more finished this week than the column shows. + await page.evaluate(() => window.gramMock!.simulateArchivedGroups()); + await pressFirstWorkspaceKey(page); + const title = sectionTitle(page, "doneToday"); + await expect(title).toHaveAccessibleName(/^Done, \d+$/); + const count = Number((await title.getAttribute("aria-label"))!.split(", ")[1]); + expect(count).toBeGreaterThan(10); + // Folded, it says how many it holds; open, it shows ten of them and the way to the rest. + await expect(title).toContainText(String(count)); + await title.click(); + const done = section(page, "doneToday"); + await expect(done.getByTestId("overview-done")).toHaveCount(10); + const more = done.getByTestId("overview-done-more"); + await expect(more).toHaveText(/^Show all in Workers \(.+\)$/); + await more.click(); + const list = page.getByTestId("workers-list"); + await expect(list).toBeVisible(); + const listed = list.getByTestId("workers-section-done"); + await expect(listed.getByTestId("workers-day").first()).toBeInViewport(); + await expect(listed.getByTestId("worker-row").first()).toHaveAttribute("aria-selected", "true"); +}); + test("the foot is off by default, and the orchestrator's line keeps the slots on its hover", async ({ page, }) => { @@ -378,7 +417,7 @@ test("Settings' Sidebar sections hides and shows each section, kept across a rel const labels = { solo: "Solo threads", pullRequests: "Pull requests", - doneToday: "Done today", + doneToday: "Done", usage: "Usage and slots", } as const; await expect(group.getByRole("checkbox")).toHaveCount(4); diff --git a/apps/web/e2e/overview-sidebar.spec.ts b/apps/web/e2e/overview-sidebar.spec.ts index 5261f2ac..b77a4cc0 100644 --- a/apps/web/e2e/overview-sidebar.spec.ts +++ b/apps/web/e2e/overview-sidebar.spec.ts @@ -435,9 +435,9 @@ test("arrows and End skip a folded section's rows", async ({ page }) => { overview(page).getByTestId(`overview-section-${id}`).getByRole("button", { name }); const orchestrators = head("orchestrators", /Orchestrators/); const soloHead = head("solo", /Solo threads/); - // Pull requests and Done today come after Solo threads, folded by default. + // Pull requests and Done come after Solo threads, folded by default. const prsHead = head("pullRequests", /Pull requests/); - const doneHead = head("doneToday", /Done today/); + const doneHead = head("doneToday", /^Done, /); await expect(doneHead).toHaveAttribute("aria-expanded", "false"); await orchestrators.click(); await expect(orchestrators).toHaveAttribute("aria-expanded", "false"); diff --git a/apps/web/e2e/perf-views.spec.ts b/apps/web/e2e/perf-views.spec.ts index c34d5a8d..b1c04b88 100644 --- a/apps/web/e2e/perf-views.spec.ts +++ b/apps/web/e2e/perf-views.spec.ts @@ -32,7 +32,7 @@ const TRACKED = [ "BoardHeader", "AskStack", "WorkersButton", - "OverviewDoneToday", + "OverviewDone", "NeedsYouShortcut", "NeedsYou", "AwayNotifications", diff --git a/apps/web/e2e/workers-list.spec.ts b/apps/web/e2e/workers-list.spec.ts index 3fe2bfb3..04601102 100644 --- a/apps/web/e2e/workers-list.spec.ts +++ b/apps/web/e2e/workers-list.spec.ts @@ -66,14 +66,20 @@ test("⌘J lists every worker by section and feature, and opens one in two steps .filter({ has: page.locator(`[data-thread-id="${made.doneToday}"]`) }), ).toHaveCount(0); - // Done today and Earlier are sections of the same list, a click away without a tab. - const doneToday = page.getByTestId("workers-section-doneToday"); + // Done is a section of the same list, a click away without a tab, by the day each finished: + // today's first, and every earlier day ⌘J still has, past the overview's week too. + const done = page.getByTestId("workers-section-done"); await expect( - doneToday.locator(`[data-thread-id="${made.doneToday}"]`).getByTestId("worker-now"), + done.locator(`[data-thread-id="${made.doneToday}"]`).getByTestId("worker-now"), ).toHaveText(/^merged \d{2}:\d{2}$/); - const earlier = page.getByTestId("workers-section-earlier"); for (const id of made.earlier) - await expect(earlier.locator(`[data-thread-id="${id}"]`)).toBeAttached(); + await expect(done.locator(`[data-thread-id="${id}"]`)).toBeAttached(); + const days = done.getByTestId("workers-day"); + await expect(days.first()).toHaveText("Today"); + // Three earlier days at least: 3, 9 and 12 days back. + await expect.poll(() => days.count()).toBeGreaterThanOrEqual(4); + for (const day of await days.allTextContents()) + expect(day).toMatch(/^(Today|Yesterday|\w{3} \d{1,2} \w{3})$/); // The search box filters every section. const search = page.getByTestId("workers-search"); diff --git a/apps/web/pr-shots/shots.ts b/apps/web/pr-shots/shots.ts index ce122d14..6d363354 100644 --- a/apps/web/pr-shots/shots.ts +++ b/apps/web/pr-shots/shots.ts @@ -1951,7 +1951,7 @@ export const SHOTS: readonly Shot[] = [ }, { name: "overview-sidebar-extras", - title: "The overview at 1440 px with Pull requests and Done today opened", + title: "The overview at 1440 px with Pull requests and Done opened", open: async (page) => { await openOverviewHome(page, 1440, { panel: false }); for (const id of ["pullRequests", "doneToday"]) { @@ -1962,13 +1962,30 @@ export const SHOTS: readonly Shot[] = [ if ((await title.getAttribute("aria-expanded")) === "false") await title.click(); } await page.getByTestId("overview-done").first().waitFor(); - // Done today sits last in the column. + // Done sits last in the column. await page.getByTestId("overview-done").last().scrollIntoViewIfNeeded(); // Off the column, so no row shows its hover. await page.mouse.move(1200, 400); await page.waitForTimeout(400); }, }, + { + name: "overview-done-week", + title: "The overview's Done opened on a busy week: its days, ten rows and Show all", + open: async (page) => { + await seedMockSettings(page, { panel: { open: false } }); + await openOrchestratorOf(page, () => window.gramMock!.simulateArchivedGroups()); + const title = page + .getByTestId("overview-section-doneToday") + .getByRole("heading") + .getByRole("button"); + if ((await title.getAttribute("aria-expanded")) === "false") await title.click(); + await page.getByTestId("overview-done").last().scrollIntoViewIfNeeded(); + await page.mouse.move(1200, 400); + await page.waitForTimeout(400); + }, + element: (page) => page.getByTestId("overview-sidebar"), + }, { name: "overview-sidebar-hover", title: "A worker's hover card in the overview: title, station, now line, first PR and time", @@ -2146,12 +2163,15 @@ export const SHOTS: readonly Shot[] = [ }, { name: "workers-list-earlier", - title: "⌘J scrolled to Earlier, with its search", + title: "⌘J scrolled to its earlier days (Earlier before Done took it in), with its search", modes: ["dark"], open: async (page) => { await openWorkersScenario(page); await pressWorkersList(page); - await page.getByTestId("workers-section-earlier").scrollIntoViewIfNeeded(); + // The last day's heading in Done; before it, the Earlier section. + const days = page.getByTestId("workers-day"); + if ((await days.count()) > 0) await days.last().scrollIntoViewIfNeeded(); + else await page.getByTestId("workers-section-earlier").scrollIntoViewIfNeeded(); await page.getByTestId("workers-search").waitFor(); }, element: (page) => page.getByTestId("workers-list"), @@ -2241,12 +2261,12 @@ export const SHOTS: readonly Shot[] = [ }, { name: "workers-list-done-today", - title: "⌘J's Done today, with a worker whose worktree was cleaned up", + title: "⌘J's Done (Done today before), with a worker whose worktree was cleaned up", modes: ["dark"], open: async (page) => { await openReachingScenario(page); await pressWorkersList(page); - await page.getByTestId("workers-section-doneToday").scrollIntoViewIfNeeded(); + await page.getByTestId(/^workers-section-(done|doneToday)$/).scrollIntoViewIfNeeded(); }, element: (page) => page.getByTestId("workers-list"), }, diff --git a/apps/web/src/api/mock-workers.ts b/apps/web/src/api/mock-workers.ts index dc43c4bf..3481ade5 100644 --- a/apps/web/src/api/mock-workers.ts +++ b/apps/web/src/api/mock-workers.ts @@ -12,7 +12,7 @@ import { import type { MockFinishedSimulation, MockOrchestrationHost } from "./mock-orchestration"; -// The ⌘J Workers list scenario: the finished-work board with workers in every tab the list shows. +// The ⌘J Workers list scenario: the finished-work board with workers in both of the list's sections. // A worker running live with a long transcript to scroll, one that asked a question, one merged // today, and three finished on earlier days. Two of them carry reports the user hasn't seen, one // with the helper's catch-up armed and one without, which falls back to its report's summary. @@ -34,7 +34,7 @@ export interface MockWorkersSimulation extends MockFinishedSimulation { readonly live: ThreadId; /** Asked the orchestrator a question it is waiting on; its unseen report has no catch-up. */ readonly asking: ThreadId; - /** Merged within the last hour, so in Done today whatever the time. */ + /** Merged within the last hour, so under Done's Today whatever the time. */ readonly doneToday: ThreadId; /** Merged or archived days ago. */ readonly earlier: readonly ThreadId[]; @@ -210,7 +210,7 @@ export async function simulateWorkersList( at: now - 20 * MINUTE, }); - // Done today: merged within the hour, or just after midnight when it is earlier than that. + // Today: merged within the hour, or just after midnight when it is earlier than that. const midnight = new Date(now); midnight.setHours(0, 0, 0, 0); const todayAt = Math.max(now - 40 * MINUTE, midnight.getTime() + MINUTE); @@ -223,7 +223,7 @@ export async function simulateWorkersList( at: Math.min(todayAt, now) - 5 * MINUTE, }); - // Earlier: finished on other days. + // Earlier days: one in the overview's week, two before it that only ⌘J lists. const uv = await spawn("tooling", "Migrate to uv", "migrate-uv"); merged(uv, 377, now - 3 * DAY); const py39 = await spawn("tooling", "Drop Python 3.9", "drop-py39"); diff --git a/apps/web/src/orchestration/narrow-subscriptions.test.tsx b/apps/web/src/orchestration/narrow-subscriptions.test.tsx index 9da0f523..bd1ad7f9 100644 --- a/apps/web/src/orchestration/narrow-subscriptions.test.tsx +++ b/apps/web/src/orchestration/narrow-subscriptions.test.tsx @@ -14,14 +14,14 @@ import { useThreadsStore } from "@/stores/threads"; import { useBoardWaiting } from "./board-waiting"; import { useRailFacts } from "./feature-sheet"; import { resetDerivedAsks } from "./needs-you-store"; -import { OverviewDoneToday } from "./overview-extras"; +import { OverviewDone } from "./overview-extras"; import { NeedsYouSource } from "./use-needs-you"; import { useWorkspaces } from "./use-orchestration"; import { WorkersButton } from "./workers-list"; // The always-mounted views that derive a little from the whole thread map re-render only when what // they derive changes, not on every change to any thread: the title bar's Workers button, the -// overview's Done today, the Board's chips and its Waiting on you. +// overview's Done, the Board's chips and its Waiting on you. const route = vi.hoisted(() => ({ workspaceId: "" })); vi.mock("@tanstack/react-router", async (importOriginal) => ({ @@ -60,7 +60,7 @@ function Window() { <> {counted("workers-button", )} - {counted("done-today", )} + {counted("done", )} ); diff --git a/apps/web/src/orchestration/overview-extras-model.test.ts b/apps/web/src/orchestration/overview-extras-model.test.ts index 869ff2e5..7d22b498 100644 --- a/apps/web/src/orchestration/overview-extras-model.test.ts +++ b/apps/web/src/orchestration/overview-extras-model.test.ts @@ -15,14 +15,15 @@ import { describe, expect, it } from "vitest"; import type { UsageLimits } from "@/stores/usage-limits"; import { - doneTodayRows, + DONE_DAYS, + doneRows, footMeters, MAX_SLOT_PIPS, overviewPrRows, prSubLine, workerSlots, } from "./overview-extras-model"; -import { workersByTab } from "./workers-model"; +import { daysBefore, workersByTab } from "./workers-model"; // Wednesday 15 October 2026, 14:40 local time. const NOW = new Date(2026, 9, 15, 14, 40).getTime(); @@ -132,7 +133,8 @@ const closed = (id: string, closedAt: number, merged: boolean): FinishedWorker = kept: "closed", }); -describe("Done today across the workspaces", () => { +describe("Done across the workspaces", () => { + const DAY = 24 * 60 * 60_000; const ledger = { features: [ { @@ -142,6 +144,13 @@ describe("Done today across the workspaces", () => { worker("t-done", "delivered"), worker("t-live", "working", { status: "running" }), worker("t-old", "merged"), + // Archived by the orchestrator once its PR merged: finished all the same. + worker("t-archived", "archived", { + stateBase: { state: "merged", parked: true }, + pr: { repo: "example/api", number: 400 }, + }), + worker("t-week", "delivered"), + worker("t-gone", "merged"), ], }, ], @@ -149,7 +158,11 @@ describe("Done today across the workspaces", () => { const byId = { "t-merged": summary("t-merged", { completedAt: at(13, 5) }), "t-done": summary("t-done", { completedAt: at(9, 30) }), - "t-old": summary("t-old", { completedAt: at(9) - 24 * 60 * 60_000 }), + "t-old": summary("t-old", { completedAt: at(9) - DAY }), + "t-archived": summary("t-archived", { completedAt: at(16) - 3 * DAY }), + // The first minute of the week's first day, and the last of the day before it. + "t-week": summary("t-week", { completedAt: daysBefore(NOW, DONE_DAYS - 1) }), + "t-gone": summary("t-gone", { completedAt: daysBefore(NOW, DONE_DAYS - 1) - 60_000 }), }; const facts = { byId, statusDetail: {}, now: NOW }; const sources = [ @@ -163,38 +176,62 @@ describe("Done today across the workspaces", () => { workspaceId: "w2" as WorkspaceId, name: "infra", ledger: undefined, - finished: [closed("t-closed", at(11, 45), true), closed("t-swept", at(8), false)], + finished: [ + closed("t-closed", at(11, 45), true), + closed("t-swept", at(8), false), + closed("t-swept-old", at(8) - 10 * DAY, false), + ], }, ]; + const ids = ["t-merged", "t-closed", "t-done", "t-swept", "t-old", "t-archived", "t-week"]; - it("has the rows ⌘J's Done today has for each workspace, latest first across them", () => { - const rows = doneTodayRows(sources, facts); - expect(rows.map((row) => row.threadId)).toEqual(["t-merged", "t-closed", "t-done", "t-swept"]); + it("has the rows of the last seven days ⌘J's Done has for each workspace, latest first", () => { + const rows = doneRows(sources, facts); + expect(rows.map((row) => row.threadId)).toEqual(ids); + const since = daysBefore(NOW, DONE_DAYS - 1); for (const source of sources) { - const listed = workersByTab(source.ledger, facts, source.finished).doneToday.flatMap((f) => - f.rows.map((row) => row.threadId), - ); + const listed = workersByTab(source.ledger, facts, source.finished) + .done.flatMap((f) => f.rows) + .filter((row) => (row.finishedAt ?? 0) >= since) + .map((row) => row.threadId); expect( rows.filter((row) => row.workspaceId === source.workspaceId).map((row) => row.threadId), - ).toEqual(expect.arrayContaining(listed)); - expect(rows.filter((row) => row.workspaceId === source.workspaceId)).toHaveLength( - listed.length, - ); + ).toEqual(listed); } }); - it("gives each its time on a 24-hour clock and its workspace and outcome", () => { - const rows = doneTodayRows(sources, facts); - expect(rows.map((row) => [row.time, row.merged, row.sub])).toEqual([ - ["13:05", true, "agent-platform · merged api#412"], - ["11:45", true, "infra · merged infra#88"], - ["09:30", false, "agent-platform · done"], - ["08:00", false, "infra · cleaned up"], + it("stops at the week's first midnight: ⌘J keeps what is older", () => { + const rows = doneRows(sources, facts).map((row) => row.threadId); + expect(rows).toContain("t-week"); + expect(rows).not.toContain("t-gone"); + expect(rows).not.toContain("t-swept-old"); + const all = sources.flatMap((source) => + workersByTab(source.ledger, facts, source.finished).done.flatMap((f) => + f.rows.map((row) => row.threadId), + ), + ); + expect(all).toEqual(expect.arrayContaining(["t-gone", "t-swept-old"])); + }); + + it("gives each its day, its time on a 24-hour clock, and its workspace and outcome", () => { + const rows = doneRows(sources, facts); + expect(rows.map((row) => [row.day.label, row.time, row.merged, row.sub])).toEqual([ + ["Today", "13:05", true, "agent-platform · merged api#412"], + ["Today", "11:45", true, "infra · merged infra#88"], + ["Today", "09:30", false, "agent-platform · done"], + ["Today", "08:00", false, "infra · cleaned up"], + ["Yesterday", "09:00", true, "agent-platform · merged"], + ["Mon 12 Oct", "16:00", true, "agent-platform · merged api#400"], + ["Fri 9 Oct", "00:00", false, "agent-platform · done"], ]); + expect(rows[0]!.day.start).toBe(daysBefore(NOW, 0)); }); - it("is empty with nothing finished today", () => { - expect(doneTodayRows([], facts)).toEqual([]); + it("is empty with nothing finished this week", () => { + expect(doneRows([], facts)).toEqual([]); + expect( + doneRows([{ ...sources[1]!, finished: [closed("x", at(8) - 8 * DAY, true)] }], facts), + ).toEqual([]); }); }); diff --git a/apps/web/src/orchestration/overview-extras-model.ts b/apps/web/src/orchestration/overview-extras-model.ts index 50072386..2d3cede1 100644 --- a/apps/web/src/orchestration/overview-extras-model.ts +++ b/apps/web/src/orchestration/overview-extras-model.ts @@ -23,11 +23,19 @@ import { } from "@/stores/usage-limits"; import { limitPercent, PROVIDER_SHORT, resetText } from "@/usage/limit-format"; -import { finishedWhen, workersByTab, type WorkerLiveFacts, type WorkerRow } from "./workers-model"; +import { + daysBefore, + doneDay, + clockTime, + workersByTab, + type DoneDay, + type WorkerLiveFacts, + type WorkerRow, +} from "./workers-model"; -// The overview sidebar's extras: its Pull requests and -// Done today sections and the usage meters and worker slots at its foot. Pure, so the column says -// what the lists, ⌘J and the title bar's meter say, and is tested without a browser. +// The overview sidebar's extras: its Pull requests and Done sections and the usage meters and +// worker slots at its foot. Pure, so the column says what the lists, ⌘J and the title bar's meter +// say, and is tested without a browser. /** A pull request as the overview's compact row has it. */ export interface OverviewPrRow { @@ -95,16 +103,22 @@ export const PR_KIND_LABEL: Record = { yours: "Your PRs", }; -/** One workspace's facts for Done today. */ -export interface DoneTodaySource { +/** The days the overview's Done keeps: today and the six before it. */ +export const DONE_DAYS = 7; + +/** The rows Done shows before it hands the rest to ⌘J: a glance, not an archive. */ +export const DONE_SHOWN = 10; + +/** One workspace's facts for Done. */ +export interface DoneSource { readonly workspaceId: WorkspaceId; readonly name: string; readonly ledger: Pick | undefined; readonly finished: readonly FinishedWorker[] | undefined; } -/** A worker that finished today, as the overview's Done today row has it. */ -export interface DoneTodayRow { +/** A worker that finished in the last `DONE_DAYS` days, as the overview's Done row has it. */ +export interface DoneRow { readonly threadId: ThreadId; readonly workspaceId: WorkspaceId; readonly title: string; @@ -112,6 +126,8 @@ export interface DoneTodayRow { readonly merged: boolean; /** Epoch ms its work ended. */ readonly finishedAt: number; + /** The day it is listed under. */ + readonly day: DoneDay; /** `14:12`, on a 24-hour clock. */ readonly time: string; /** `agent-platform · merged api#412`. */ @@ -137,40 +153,44 @@ const outcome = ( }; /** - * Every workspace's Done today, as ⌘J lists each (`workersByTab`), in one list, latest first; each - * row says which workspace it is in and what became of it. + * Every workspace's Done of the last `DONE_DAYS` days, as ⌘J lists each (`workersByTab`), in one + * list, latest first: merged, delivered and done workers, archived or closed with their worktree + * or not. Each row says its day, which workspace it is in and what became of it. */ -export const doneTodayRows = ( - sources: readonly DoneTodaySource[], - facts: WorkerLiveFacts, -): DoneTodayRow[] => - sources +export const doneRows = (sources: readonly DoneSource[], facts: WorkerLiveFacts): DoneRow[] => { + const since = daysBefore(facts.now, DONE_DAYS - 1); + return sources .flatMap((source) => { const tabs = workersByTab(source.ledger, facts, source.finished ?? []); const entries = new Map( source.ledger?.features.flatMap((f) => f.workers.map((w) => [w.threadId, w] as const)), ); const finished = new Map(source.finished?.map((w) => [w.threadId, w] as const)); - return tabs.doneToday.flatMap((feature) => - feature.rows.map((row): DoneTodayRow => { - const at = row.finishedAt ?? facts.now; - return { - threadId: row.threadId, - workspaceId: source.workspaceId, - title: row.title, - merged: row.glyph === "merged", - finishedAt: at, - time: finishedWhen(at, facts.now), - sub: `${source.name} · ${outcome( - row, - row.cleanedUp === true ? undefined : entries.get(row.threadId), - row.cleanedUp === true ? finished.get(row.threadId) : undefined, - )}`, - }; + return tabs.done.flatMap((feature) => + feature.rows.flatMap((row): DoneRow[] => { + const at = row.finishedAt; + if (at === undefined || at < since) return []; + return [ + { + threadId: row.threadId, + workspaceId: source.workspaceId, + title: row.title, + merged: row.glyph === "merged", + finishedAt: at, + day: feature.day ?? doneDay(at, facts.now), + time: clockTime(at), + sub: `${source.name} · ${outcome( + row, + row.cleanedUp === true ? undefined : entries.get(row.threadId), + row.cleanedUp === true ? finished.get(row.threadId) : undefined, + )}`, + }, + ]; }), ); }) .toSorted((a, b) => b.finishedAt - a.finishedAt); +}; /** A provider's 5-hour meter at the overview's foot. */ export interface FootMeter { diff --git a/apps/web/src/orchestration/overview-extras.tsx b/apps/web/src/orchestration/overview-extras.tsx index bc2fcd0b..40de601b 100644 --- a/apps/web/src/orchestration/overview-extras.tsx +++ b/apps/web/src/orchestration/overview-extras.tsx @@ -5,6 +5,7 @@ import { Fragment, useMemo } from "react"; import { useApi } from "@/api/context"; import { openExternalUrl } from "@/api/open-external"; import { PrMarkIcon } from "@/components/pr-marks"; +import { useShortcutHint, withHint } from "@/keyboard/use-shortcut-hint"; import { prMarksOf, prTooltipOf, worstPrMark } from "@/components/pr-marks-model"; import { useNow } from "@/lib/use-now"; import { cn } from "@/lib/utils"; @@ -19,22 +20,23 @@ import { useUsageLimits } from "@/stores/usage-limits"; import { useViewWorkspace } from "@/views/view-workspace"; import { - doneTodayRows, + DONE_SHOWN, + doneRows, footMeters, overviewPrRows, PR_KIND_LABEL, prSubLine, workerSlots, - type DoneTodayRow, + type DoneRow, type OverviewPrRow, } from "./overview-extras-model"; import { ROW, Section, SETTLED, STOP } from "./overview-section"; import { useOrchestratorLedger, useOrchestratorLedgers, useWorkspaces } from "./use-orchestration"; +import { useWorkersListStore } from "./workers-list"; -// The overview sidebar's extras: Pull requests and Done -// today, folded until opened and gone with nothing in them, and the usage meters and worker slots -// at its foot, off by default. Each reads what ⌘J and the title bar's meter read, so they all say -// the same. +// The overview sidebar's extras: Pull requests and Done, folded until opened and gone with nothing +// in them, and the usage meters and worker slots at its foot, off by default. Each reads what ⌘J +// and the title bar's meter read, so they all say the same. /** * The review requests and the user's open PRs in one-line rows, under a sub-heading each when both @@ -129,15 +131,16 @@ function useFinishedWorkersOf(ids: readonly WorkspaceId[]) { } /** - * The workers that finished today in every workspace, as ⌘J's Done today lists each, latest first, - * one line each; a click opens one as ⌘J does. With none, the section stays away. + * The workers that finished in the last week in every workspace, as ⌘J's Done lists each, latest + * first under their day, one line each; a click opens one as ⌘J does. The first `DONE_SHOWN` + * show, and ⌘J has the rest. With none, the section stays away. */ -export function OverviewDoneToday() { +export function OverviewDone() { const workspaces = useWorkspaces(); const ids = useMemo(() => workspaces.map((state) => state.workspace.id), [workspaces]); const ledgers = useOrchestratorLedgers(ids); const finished = useFinishedWorkersOf(ids); - // Minute resolution is enough for "today" and the times rows show. + // Minute resolution is enough for the days and the times rows show. const now = useNow(true, undefined, 60_000); const sources = useMemo( () => @@ -151,20 +154,48 @@ export function OverviewDoneToday() { ); // Only the rows: a thread's change that leaves them reading the same leaves the section still. const rows = useThreadsStore( - useSame((s) => doneTodayRows(sources, { byId: s.byId, statusDetail: s.statusDetail, now })), + useSame((s) => doneRows(sources, { byId: s.byId, statusDetail: s.statusDetail, now })), ); if (rows.length === 0) return null; + const shown = rows.slice(0, DONE_SHOWN); return ( -
- {rows.map((row) => ( - +
+ {shown.map((row, i) => ( + + {shown[i - 1]?.day.start !== row.day.start && ( +

+ {row.day.label} +

+ )} + +
))} + {rows.length > shown.length && }
); } +/** The quiet way to the rest: ⌘J, opened on its Done. */ +function ShowAllDone() { + const hint = useShortcutHint("workers.open"); + return ( + + ); +} + /** A finished worker: grey until hovered, which says its workspace and what became of it. */ -function DoneRow({ row }: { row: DoneTodayRow }) { +function OverviewDoneRow({ row }: { row: DoneRow }) { const openThread = useOpenThread(); return (
))} diff --git a/apps/web/src/orchestration/workers-model.test.ts b/apps/web/src/orchestration/workers-model.test.ts index 85588bfb..75049597 100644 --- a/apps/web/src/orchestration/workers-model.test.ts +++ b/apps/web/src/orchestration/workers-model.test.ts @@ -16,6 +16,8 @@ import { describe, expect, it } from "vitest"; import { activityGist, capWords, + clockTime, + daysBefore, finishedRow, finishedWhen, NOW_LINE_WORDS, @@ -224,57 +226,108 @@ describe("the tabs", () => { spike: summary("spike", "idle", { lastActivityAt: NOW - 12 * 24 * HOUR }), }); - it("deals workers into Now, Done today and Earlier, grouped by feature", () => { + it("deals workers into Now and Done, Done by the day each finished, then by feature", () => { const tabs = workersByTab(ledger, live); expect(tabs.now.map((f) => [f.feature, f.rows.map((r) => r.threadId)])).toEqual([ ["mail-trigger", ["queue"]], ]); - expect(tabs.doneToday.map((f) => [f.feature, f.rows.map((r) => r.threadId)])).toEqual([ - ["mail-trigger", ["schema"]], + // Most recently finished first, each under its day; Done keeps every day Gram still has. + expect(tabs.done.map((f) => [f.day?.label, f.feature, f.rows.map((r) => r.threadId)])).toEqual([ + ["Today", "mail-trigger", ["schema"]], + ["Mon 12 Oct", "tooling", ["uv"]], + ["Tue 6 Oct", "tooling", ["py39"]], + ["Sat 3 Oct", "spike", ["spike"]], ]); - // Most recently finished first. - expect(tabs.earlier.map((f) => [f.feature, f.rows.map((r) => r.threadId)])).toEqual([ - ["tooling", ["uv", "py39"]], - ["spike", ["spike"]], - ]); - expect(tabCount(tabs.earlier)).toBe(3); - expect(tabs.earlier[0]!.rows[0]!.now).toBe("merged 12 Oct"); + expect(tabCount(tabs.done)).toBe(4); + expect(tabs.done[1]!.rows[0]!.now).toBe("merged 12 Oct"); }); - it("searches finished workers by name, feature or repo", () => { + it("searches finished workers by name, feature or repo, keeping their days", () => { const tabs = workersByTab(ledger, live); - expect(searchWorkers(tabs.earlier, "web").map((f) => f.rows.map((r) => r.threadId))).toEqual([ - ["py39"], - ]); - expect(searchWorkers(tabs.earlier, "SPIKE")).toHaveLength(1); - expect(searchWorkers(tabs.earlier, "nothing like it")).toEqual([]); - expect(searchWorkers(tabs.earlier, " ")).toEqual(tabs.earlier); + expect( + searchWorkers(tabs.done, "web").map((f) => [f.day?.label, f.rows.map((r) => r.threadId)]), + ).toEqual([["Tue 6 Oct", ["py39"]]]); + expect(searchWorkers(tabs.done, "SPIKE")).toHaveLength(1); + expect(searchWorkers(tabs.done, "nothing like it")).toEqual([]); + expect(searchWorkers(tabs.done, " ")).toEqual(tabs.done); }); - it("opens on Done today only when nothing is going on", () => { + it("opens on Done only when nothing is going on", () => { const tabs = workersByTab(ledger, live); expect(openingTab(tabs)).toBe("now"); - expect(openingTab({ ...tabs, now: [] })).toBe("doneToday"); + expect(openingTab({ ...tabs, now: [] })).toBe("done"); expect(openingTab(workersByTab(undefined, live))).toBe("now"); }); it("says when a finished worker finished in 24-hour time, or its day", () => { expect(finishedWhen(startOfDay(NOW) + 9 * HOUR + 5 * 60_000, NOW)).toBe("09:05"); expect(finishedWhen(NOW - 2 * 24 * HOUR, NOW)).toBe("13 Oct"); + expect(clockTime(NOW - 2 * 24 * HOUR)).toBe("14:40"); + }); + + it("heads Done's days at local midnight: Today, Yesterday, then the weekday and date", () => { + const midnight = startOfDay(NOW); + const days = { + features: [ + { + feature: "f", + workers: [ + worker("first", "f", "delivered"), + worker("midnight", "f", "done"), + worker("late", "f", "merged"), + worker("parked", "f", "archived", { stateBase: { state: "merged", parked: true } }), + worker("week", "f", "delivered"), + ], + } as unknown as FeatureLedgerEntry, + ], + }; + const tabs = workersByTab( + days, + facts({ + first: summary("first", "idle", { completedAt: midnight + 60_000 }), + midnight: summary("midnight", "idle", { completedAt: midnight }), + late: summary("late", "idle", { completedAt: midnight - 60_000 }), + parked: summary("parked", "idle", { completedAt: daysBefore(NOW, 2) + 10 * HOUR }), + week: summary("week", "idle", { completedAt: daysBefore(NOW, 6) + HOUR }), + }), + ); + expect(tabs.done.map((f) => [f.day?.label, f.rows.map((r) => r.threadId)])).toEqual([ + ["Today", ["first", "midnight"]], + ["Yesterday", ["late"]], + ["Tue 13 Oct", ["parked"]], + ["Fri 9 Oct", ["week"]], + ]); + expect(tabs.done[0]!.day?.start).toBe(midnight); + expect(tabs.done[1]!.day?.start).toBe(daysBefore(NOW, 1)); + // Archived by the orchestrator once its PR merged, it is still in Done, and still merged. + expect(tabs.done[2]!.rows[0]).toMatchObject({ + state: "archived", + glyph: "merged", + now: "merged 13 Oct", + }); + }); + + it("counts days by the calendar, so the week before starts at a midnight", () => { + expect(daysBefore(NOW, 0)).toBe(startOfDay(NOW)); + expect(daysBefore(NOW, 6)).toBe(new Date(2026, 9, 9).getTime()); + // Across the end of summer time in Europe (25 October 2026), a day is 25 hours long. + expect(daysBefore(new Date(2026, 9, 26, 12).getTime(), 1)).toBe( + new Date(2026, 9, 25).getTime(), + ); }); it("lists a worker closed with its worktree as a done row, beside the ledger's", () => { const today = closed("c1", startOfDay(NOW) + 11 * HOUR + 40 * 60_000, true); const old = closed("c2", NOW - 3 * 24 * HOUR, false); expect(finishedRow(today, NOW)).toMatchObject({ - tab: "doneToday", + tab: "done", glyph: "merged", now: "merged 11:40", repo: "api", cleanedUp: true, held: false, }); - expect(finishedRow(old, NOW)).toMatchObject({ tab: "earlier", glyph: "archived" }); + expect(finishedRow(old, NOW)).toMatchObject({ tab: "done", glyph: "archived" }); expect(finishedRow(old, NOW).now.split(" ").length).toBeLessThanOrEqual(NOW_LINE_WORDS); const withLive = { @@ -286,11 +339,15 @@ describe("the tabs", () => { ], }; const tabs = workersByTab(withLive, facts(), [today, old, closed("live", NOW, true)]); - // The reopened worker's ledger row wins; the closed ones join its feature's group. - const all = [...tabs.doneToday, ...tabs.earlier].flatMap((f) => f.rows); + // The reopened worker's ledger row wins; the closed ones join its feature on their days. With + // no thread to say when it finished, it goes last, under Earlier. + const all = tabs.done.flatMap((f) => f.rows); expect(all.filter((row) => row.threadId === "live")).toHaveLength(1); expect(all.find((row) => row.threadId === "live")?.cleanedUp).toBeUndefined(); - expect(tabs.earlier.flatMap((f) => f.rows).map((row) => row.threadId)).toContain("c2"); - expect(tabs.doneToday.filter((f) => f.feature === "mail-retry")).toHaveLength(1); + expect(tabs.done.map((f) => [f.day?.label, f.rows.map((r) => r.threadId)])).toEqual([ + ["Today", ["c1"]], + ["Mon 12 Oct", ["c2"]], + ["Earlier", ["live"]], + ]); }); }); diff --git a/apps/web/src/orchestration/workers-model.ts b/apps/web/src/orchestration/workers-model.ts index 6a3786c4..83261141 100644 --- a/apps/web/src/orchestration/workers-model.ts +++ b/apps/web/src/orchestration/workers-model.ts @@ -17,20 +17,21 @@ import { import { dateTimeFormat } from "@/lib/intl"; import { liveWorkerEntry, workerTitle } from "./board-model"; +import { dayLabel } from "./outputs-model"; import { workerTone, type RailTone } from "./rail-model"; import { threadWorking } from "./tree-model"; import { workerActivity, workerWait, type WorkerActivity, type WorkerBusy } from "./wait-model"; -// The ⌘J Workers list: every worker of a workspace in three tabs, grouped by feature, each with a -// "now" line of four words or fewer. Pure and deterministic (no helper model), so it is tested -// without a browser and two windows always say the same thing. +// The ⌘J Workers list: every worker of a workspace in Now and Done, grouped by feature (and in +// Done, by the day it finished), each with a "now" line of four words or fewer. Pure and +// deterministic (no helper model), so it is tested without a browser and two windows always say +// the same thing. -export type WorkersTab = "now" | "doneToday" | "earlier"; +export type WorkersTab = "now" | "done"; export const WORKERS_TABS: readonly { readonly id: WorkersTab; readonly label: string }[] = [ { id: "now", label: "Now" }, - { id: "doneToday", label: "Done today" }, - { id: "earlier", label: "Earlier" }, + { id: "done", label: "Done" }, ]; export { NOW_LINE_WORDS } from "@gram/contracts"; @@ -69,9 +70,18 @@ export interface WorkerRow { readonly cleanedUp?: true | undefined; } +/** A local day finished work is listed under: its midnight, and its heading. */ +export interface DoneDay { + readonly start: number; + /** `Today`, `Yesterday`, else `Thu 30 Oct`. */ + readonly label: string; +} + export interface WorkerFeature { readonly feature: string; readonly rows: readonly WorkerRow[]; + /** In Done, the day its rows finished on: a feature that finished over two days is in both. */ + readonly day?: DoneDay | undefined; } export type WorkersByTab = Readonly>; @@ -87,22 +97,41 @@ export interface WorkerLiveFacts { readonly asking?: ReadonlySet; } -/** Local midnight of the day `now` falls on. */ -export const startOfDay = (now: number): number => { +/** Local midnight `days` days before the day `now` falls on, by the calendar (so across DST too). */ +export const daysBefore = (now: number, days: number): number => { const day = new Date(now); day.setHours(0, 0, 0, 0); + day.setDate(day.getDate() - days); return day.getTime(); }; +/** Local midnight of the day `now` falls on. */ +export const startOfDay = (now: number): number => daysBefore(now, 0); + +/** Where finished work with no time to go by is listed: after every day. */ +const UNDATED: DoneDay = { start: 0, label: "Earlier" }; + +/** The day finished work at `at` is listed under, headed as the outputs' days are. */ +export const doneDay = (at: number | undefined, now: number): DoneDay => { + if (at === undefined) return UNDATED; + const start = startOfDay(at); + return { start, label: dayLabel(start, now) }; +}; + const pad = (n: number) => String(n).padStart(2, "0"); -/** `14:12` for today, `12 Oct` before; 24-hour, as the product's times are. */ -export const finishedWhen = (at: number, now: number): string => { +/** `14:12`, on the 24-hour clock the product's times are on. */ +export const clockTime = (at: number): string => { const date = new Date(at); - if (at >= startOfDay(now)) return `${pad(date.getHours())}:${pad(date.getMinutes())}`; - return dateTimeFormat("en-GB", { day: "numeric", month: "short" }).format(date); + return `${pad(date.getHours())}:${pad(date.getMinutes())}`; }; +/** `14:12` for today, `12 Oct` before: a now line, which is read outside Done's days too. */ +export const finishedWhen = (at: number, now: number): string => + at >= startOfDay(now) + ? clockTime(at) + : dateTimeFormat("en-GB", { day: "numeric", month: "short" }).format(new Date(at)); + // The now line's words are the feature cards' (contracts `feature-cards.ts`), so a worker reads the // same in the Workers list and on its card. export { activityGist, capWords, reportGist, requestGist }; @@ -253,11 +282,7 @@ export const workerRow = ( const busy = liveActivity(live, summary, statusDetail[worker.threadId], ownedCi); const done = wait.bucket === "done"; const at = done ? finishedAt(live, summary) : undefined; - const tab: WorkersTab = !done - ? "now" - : at !== undefined && at >= startOfDay(now) - ? "doneToday" - : "earlier"; + const tab: WorkersTab = done ? "done" : "now"; const tone = liveWorkerTone(worker, { summary, detail: statusDetail[worker.threadId], @@ -293,7 +318,12 @@ export const workerRow = ( if (live.state === "error") return row(undefined, "stopped on an error"); if (done) { const when = at === undefined ? "" : ` ${finishedWhen(at, now)}`; - if (live.state === "merged") return row("merged", `merged${when}`); + // Archived once merged, as the orchestrator does after a merge, it still reads merged. + if ( + live.state === "merged" || + (live.state === "archived" && live.stateBase?.state === "merged") + ) + return row("merged", `merged${when}`); if (live.state === "archived") return row("archived", "archived"); return row(FINISHED_GLYPH[live.state] ?? "delivered", `done${when}`); } @@ -333,7 +363,7 @@ export const finishedRow = (worker: FinishedWorker, now: number): WorkerRow => { title: worker.title, feature: worker.feature, repo: finishedRepo(worker), - tab: worker.closedAt >= startOfDay(now) ? "doneToday" : "earlier", + tab: "done", state: merged ? "merged" : "archived", status: "idle", glyph: merged ? "merged" : "archived", @@ -348,15 +378,16 @@ export const finishedRow = (worker: FinishedWorker, now: number): WorkerRow => { /** * Every worker of the workspace by tab, grouped by feature. Now keeps the ledger's feature order - * and its spawn order within a feature, so rows don't jump as workers change state; the done tabs - * show the most recently finished feature first. + * and its spawn order within a feature, so rows don't jump as workers change state. Done is by the + * day each finished, latest first, and within a day by feature, the most recently finished first; + * archived workers that finished are there too, and every closed one Gram still lists. */ export const workersByTab = ( ledger: Pick | undefined, facts: WorkerLiveFacts, finished: readonly FinishedWorker[] = [], ): WorkersByTab => { - const tabs: Record = { now: [], doneToday: [], earlier: [] }; + const tabs: Record = { now: [], done: [] }; const add = (tab: WorkersTab, feature: string, rows: readonly WorkerRow[]) => { const at = tabs[tab].findIndex((each) => each.feature === feature); if (at === -1) tabs[tab].push({ feature, rows }); @@ -381,14 +412,15 @@ export const workersByTab = ( const row = finishedRow(worker, facts.now); add(row.tab, row.feature, [row]); } - const latest = (feature: WorkerFeature) => - Math.max(...feature.rows.map((row) => row.finishedAt ?? 0)); - for (const id of ["doneToday", "earlier"] as const) { - tabs[id].sort((a, b) => latest(b) - latest(a)); - tabs[id] = tabs[id].map((feature) => ({ - ...feature, - rows: feature.rows.toSorted((a, b) => (b.finishedAt ?? 0) - (a.finishedAt ?? 0)), - })); + const done = tabs.done + .flatMap((feature) => feature.rows) + .toSorted((a, b) => (b.finishedAt ?? 0) - (a.finishedAt ?? 0)); + tabs.done = []; + for (const row of done) { + const day = doneDay(row.finishedAt, facts.now); + const at = tabs.done.findIndex((f) => f.day?.start === day.start && f.feature === row.feature); + if (at === -1) tabs.done.push({ feature: row.feature, day, rows: [row] }); + else tabs.done[at] = { ...tabs.done[at]!, rows: [...tabs.done[at]!.rows, row] }; } return tabs; }; @@ -430,13 +462,13 @@ export const searchWorkers = ( const text = `${row.title} ${row.feature} ${row.repo}`.toLowerCase(); return words.every((word) => text.includes(word)); }); - return rows.length === 0 ? [] : [{ feature: feature.feature, rows }]; + return rows.length === 0 ? [] : [{ ...feature, rows }]; }); }; -/** The tab the list opens on: Now, unless nothing is going on and something finished today. */ +/** The tab the list opens on: Now, unless nothing is going on and something has finished. */ export const openingTab = (tabs: WorkersByTab): WorkersTab => - tabs.now.length === 0 && tabs.doneToday.length > 0 ? "doneToday" : "now"; + tabs.now.length === 0 && tabs.done.length > 0 ? "done" : "now"; /** A ledger feature's row for one worker, for the worker view's compact status. */ export const workerEntryOf = ( diff --git a/apps/web/src/settings/general-section.tsx b/apps/web/src/settings/general-section.tsx index 6be5c426..dba04dac 100644 --- a/apps/web/src/settings/general-section.tsx +++ b/apps/web/src/settings/general-section.tsx @@ -56,7 +56,7 @@ const OPEN_AT_LAUNCH = [ const OVERVIEW_SECTION_LABELS: Readonly> = { solo: "Solo threads", pullRequests: "Pull requests", - doneToday: "Done today", + doneToday: "Done", usage: "Usage and slots", }; @@ -148,7 +148,7 @@ export function GeneralSection() { { expect(sanitizeOverviewView({ overviewBeforeHide: "hidden" })).toEqual({}); }); - it("starts Pull requests and Done today folded, and remembers them opened", () => { + it("starts Pull requests and Done folded, and remembers them opened", () => { const folded = (section: OverviewSection) => overviewSectionFolded(s(), section); expect(OVERVIEW_FOLDED_BY_DEFAULT).toEqual(["pullRequests", "doneToday"]); expect(folded("pullRequests")).toBe(true); diff --git a/apps/web/src/views/quiet-view.tsx b/apps/web/src/views/quiet-view.tsx index 03634b80..b47472fa 100644 --- a/apps/web/src/views/quiet-view.tsx +++ b/apps/web/src/views/quiet-view.tsx @@ -33,7 +33,7 @@ import { useAskStackStore } from "@/orchestration/ask-stack-store"; import { useDerivedAsks } from "@/orchestration/needs-you-store"; import { allWorkspacesPaused, setAllWorkspacesPaused } from "@/orchestration/pause-workspace"; import { useOrchestratorLedgers, useWorkspaces } from "@/orchestration/use-orchestration"; -import { workersByTab, type WorkerRow } from "@/orchestration/workers-model"; +import { startOfDay, workersByTab, type WorkerRow } from "@/orchestration/workers-model"; import { useServerSettings } from "@/settings-sync/store"; import { UsageMeter } from "@/shell/usage-meter"; import { useThreadsStore } from "@/stores/threads"; @@ -107,9 +107,10 @@ export function useQuietStrip(all: readonly WorkspaceAsks[]): StripWorkspace[] { orchestratorThreadId: w.orchestratorThreadId, workers: tabs.now.flatMap((feature) => feature.rows), asks: w.asks.length, - mergedToday: tabs.doneToday + mergedToday: tabs.done .flatMap((feature) => feature.rows) - .filter((row) => row.glyph === "merged").length, + .filter((row) => row.glyph === "merged" && (row.finishedAt ?? 0) >= startOfDay(now)) + .length, }; }), ),