Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions packages/app/src/shell/titlebar/inbox-working.test.ts
Original file line number Diff line number Diff line change
@@ -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"])
})
22 changes: 22 additions & 0 deletions packages/app/src/shell/titlebar/inbox-working.ts
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The data store only refetches on session.execution.started when the session is already in store.session.info or being synced (packages/client/src/solid/data.ts). Some inbox rows come only from the inbox-sessions list query (staleTime: 30_000, refetched on mount/reconnect), for example an older session rerun from the TUI or another window. For those, time.busy stays at the previous run's value for the whole new run, so the row shows plain "Working" and sorts last until it ends. That's no worse than the old stale time.updated timer. If you want those rows to get a timer too, one option is to refresh the inbox entry (or invalidate the inbox query) when such a session starts executing.


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
}
30 changes: 20 additions & 10 deletions packages/app/src/shell/titlebar/inbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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) => {
Expand All @@ -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),
}
})

Expand Down Expand Up @@ -77,6 +80,7 @@ export function SidebarInbox() {
await inbox.remove(props.entry)
dialog.close()
}

return (
<Dialog fit>
<DialogHeader hideClose>
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -304,18 +317,15 @@ 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. */}
<span
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>
Expand Down
Loading