diff --git a/packages/app/src/shell/titlebar/inbox-working.test.ts b/packages/app/src/shell/titlebar/inbox-working.test.ts new file mode 100644 index 000000000000..dc30dd0aa8ce --- /dev/null +++ b/packages/app/src/shell/titlebar/inbox-working.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test" +import { compareWorking, workingSince } from "./inbox-working" + +test("uses the current run's start", () => { + expect(workingSince({ time: { busy: 20 } })).toBe(20) + expect(workingSince({ time: { busy: 20, idle: 10 } })).toBe(20) +}) + +test("has no start while only an earlier run's start is known", () => { + expect(workingSince({ time: { busy: 10, idle: 20 } })).toBeUndefined() + expect(workingSince({ time: { idle: 20 } })).toBeUndefined() + expect(workingSince({ time: {} })).toBeUndefined() +}) + +test("orders by run start, then puts runs with no known start last", () => { + const entry = (key: string, time: { busy?: number; idle?: number }) => ({ key, session: { time } }) + + const sorted = [ + entry("d", {}), + entry("b", { busy: 30 }), + entry("c", { busy: 5, idle: 10 }), + entry("a", { busy: 20, idle: 10 }), + ].toSorted(compareWorking) + + expect(sorted.map((item) => item.key)).toEqual(["a", "b", "c", "d"]) +}) diff --git a/packages/app/src/shell/titlebar/inbox-working.ts b/packages/app/src/shell/titlebar/inbox-working.ts new file mode 100644 index 000000000000..db62002b592f --- /dev/null +++ b/packages/app/src/shell/titlebar/inbox-working.ts @@ -0,0 +1,22 @@ +type SessionTime = { time: { busy?: number; idle?: number } } + +/** + * When the session's current run started. Undefined while the client still holds an earlier run's start (it ended at + * `time.idle`, after `time.busy`) or the server does not record `time.busy`. + */ +export function workingSince(session: SessionTime) { + const { busy, idle } = session.time + + if (busy === undefined || (idle !== undefined && idle > busy)) return undefined + + return busy +} + +/** Earliest run start first, so a session that starts working joins the end. Runs with no known start go last. */ +export function compareWorking(a: { key: string; session: SessionTime }, b: { key: string; session: SessionTime }) { + const start = (workingSince(a.session) ?? Infinity) - (workingSince(b.session) ?? Infinity) + + if (start) return start + + return a.key < b.key ? -1 : a.key > b.key ? 1 : 0 +} diff --git a/packages/app/src/shell/titlebar/inbox.tsx b/packages/app/src/shell/titlebar/inbox.tsx index 7c97331778f9..a67e6dbf3843 100644 --- a/packages/app/src/shell/titlebar/inbox.tsx +++ b/packages/app/src/shell/titlebar/inbox.tsx @@ -16,6 +16,7 @@ import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar" import { sessionTabTitle } from "./tab-title" import { sessionTitle } from "@/session/title" import { createSessionInbox, inboxArchived, inboxBusy, type InboxEntry, type InboxStatus } from "./inbox-model" +import { compareWorking, workingSince } from "./inbox-working" // `since` is when the inbox was first used; Recent only lists sessions that finished a run after it. const InboxState = Persistence.struct({ @@ -35,6 +36,7 @@ export function SidebarInbox() { const sections = createMemo(() => { const list = inbox.entries() + // The most recently finished session comes first. const finished = list .filter((entry) => { @@ -48,7 +50,8 @@ export function SidebarInbox() { needs: list.filter((entry) => entry.status === "approval" || entry.status === "input"), inbox: finished.filter((entry) => !inboxArchived(entry)), archived: finished.filter(inboxArchived), - working: list.filter((entry) => entry.status === "working"), + // `time.updated` moves with every message of a run, so order by when each run started to keep rows in place. + working: list.filter((entry) => entry.status === "working").toSorted(compareWorking), } }) @@ -77,6 +80,7 @@ export function SidebarInbox() { await inbox.remove(props.entry) dialog.close() } + return ( @@ -251,13 +255,22 @@ function InboxRow(props: { return language.plural("sidebar.inbox.elapsed.days", Math.floor(seconds / 86_400)) } - const elapsed = () => { - // `time.updated` moves when a prompt is enqueued, so it marks when the current run was requested. - const seconds = Math.max(0, Math.floor((props.now - props.entry.session.time.updated) / 1000)) + // Without the current run's start yet, say only that the session is working. + const working = () => { + const since = workingSince(props.entry.session) + + if (since === undefined) return language.t("sidebar.inbox.status.working") - return seconds < 60 ? language.plural("sidebar.inbox.elapsed.seconds", seconds) : compact(seconds) + const seconds = Math.max(0, Math.floor((props.now - since) / 1000)) + const duration = seconds < 60 ? language.plural("sidebar.inbox.elapsed.seconds", seconds) : compact(seconds) + + return language.t("sidebar.inbox.status.workingFor", { duration }) } + // Ready rows show their age instead of a status. + const statusLabel = (value: InboxStatus) => + value === "ready" ? undefined : language.t(`sidebar.inbox.status.${value}`) + const age = () => { const seconds = Math.max( 0, @@ -304,10 +317,7 @@ function InboxRow(props: { "text-v2-state-fg-success": status() === "done", }} > - }`)} - > + {/* Announce only the status: the ticking duration would be re-read every second. */} {language.t("sidebar.inbox.status.working")} {/* Keep the status colour under the sweep. */} @@ -315,7 +325,7 @@ function InboxRow(props: { aria-hidden="true" class="[&_[data-component=text-shimmer]]:[--text-shimmer-base-color:var(--v2-state-fg-info)]" > - +