fix(app): order the inbox Working shelf by when each run started (#60147)

Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot]
2026-10-29 16:03:47 +08:00
committed by GitHub
Unverified
parent bb85c5414b
commit 2999751632
3 changed files with 68 additions and 10 deletions
@@ -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"])
})
@@ -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
}
+20 -10
View File
@@ -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 (
<Dialog fit>
<DialogHeader hideClose>
@@ -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)
return seconds < 60 ? language.plural("sidebar.inbox.elapsed.seconds", seconds) : compact(seconds)
if (since === undefined) return language.t("sidebar.inbox.status.working")
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",
}}
>
<Show
when={status() === "working"}
fallback={language.t(`sidebar.inbox.status.${status() as Exclude<InboxStatus, "ready">}`)}
>
<Show when={status() === "working"} fallback={statusLabel(status())}>
{/* Announce only the status: the ticking duration would be re-read every second. */}
<span class="sr-only">{language.t("sidebar.inbox.status.working")}</span>
{/* 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)]"
>
<TextShimmer text={language.t("sidebar.inbox.status.workingFor", { duration: elapsed() })} active />
<TextShimmer text={working()} active />
</span>
</Show>
</span>