From 0fa17c29e8386bffec7837f94d6ba70ca641918b Mon Sep 17 00:00:00 2001 From: sergiiopm Date: Fri, 23 Oct 2026 16:06:26 +0200 Subject: [PATCH 1/5] feat(web): opt-in inbox sort by your last message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds "Sort inbox by last message (beta)" to Settings → General. When on, the active list is ordered newest first by the last message you sent. Agent activity never moves a row; creating or un-settling a thread does, and with the Working section on a returned thread does too. Dragging within the active list is off while it is on, like the Working section. Off by default, client-only, web and desktop. Co-Authored-By: Claude Opus 5.5 --- .../settings/DesktopClientSettings.test.ts | 1 + apps/web/src/components/Sidebar.logic.ts | 1 + apps/web/src/components/Sidebar.tsx | 52 +++++++++++---- .../components/settings/LabsSettingsPanel.tsx | 21 ++++++ .../components/settings/SettingsPanels.tsx | 5 ++ .../src/components/settings/settingsSearch.ts | 6 ++ docs/user/thread-sidebar.md | 11 ++++ .../src/state/threadInbox.test.ts | 66 +++++++++++++++++++ .../client-runtime/src/state/threadInbox.ts | 29 ++++++++ packages/contracts/src/settings.ts | 4 ++ 10 files changed, 184 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 1ab9b3159e1f9..11a7ca212f4d1 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -66,6 +66,7 @@ const clientSettings: ClientSettings = { threadDetailsUsageEnabled: false, threadDetailsItems: {}, sidebarWorkingShelfEnabled: false, + sidebarInboxSortBySendEnabled: false, loadBalancingEnabled: false, loadBalancingWeights: { "environment-1": 75, "environment-2": 0 }, pullRequestMergeMethodOverrides: {}, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ef229d1424c38..04d70ceb9893b 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -991,6 +991,7 @@ export function firstValidTimestampMs( export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort"; export { sortInboxThreadsByReturn, + sortInboxThreadsBySend, sortWorkingThreadsBySend, } from "@t3tools/client-runtime/state/thread-inbox"; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 75b225a8b4351..a180c7043b978 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -206,6 +206,7 @@ import { sidebarListItemId, sidebarThreadKeyAtY, sortInboxThreadsByReturn, + sortInboxThreadsBySend, sortPinnedThreadsForSidebar, sortThreadsForSidebar, sortWorkingThreadsBySend, @@ -356,6 +357,7 @@ interface SidebarThreadPartitionInput { readonly workingShelfEnabled: boolean; /** Boring mode: folders list active threads by the user's latest message. */ readonly sortActiveBySend: boolean; + readonly sendSortEnabled: boolean; // Snooze wakes, the queued-turn grace and the Working shelf's exit grace depend // on the clock, so the minute tick, the precise snooze-wake tick and the inbox // tracker's expiry version are inputs: a new key repartitions. @@ -370,6 +372,21 @@ function threadShellKey(thread: Pick number | undefined; + }, +): EnvironmentThreadShell[] { + const observedReturnAt = order.workingShelfEnabled ? order.returnedAt : undefined; + if (order.sendSortEnabled) return sortInboxThreadsBySend(threads, observedReturnAt); + if (order.workingShelfEnabled) return sortInboxThreadsByReturn(threads, observedReturnAt); + return sortThreadsForSidebar(threads); +} + const NO_DRAGGED_ORDER: readonly string[] = []; const NO_DRAGGED_ORDERS: { readonly settledOrder: readonly string[]; @@ -382,8 +399,10 @@ function resolveDraggedOrders(input: { readonly settledThreads: readonly EnvironmentThreadShell[]; readonly activeThreads: readonly EnvironmentThreadShell[]; readonly workingShelfEnabled: boolean; + readonly sendSortEnabled: boolean; }): typeof NO_DRAGGED_ORDERS { - const { drag, threadByKey, settledThreads, activeThreads, workingShelfEnabled } = input; + const { drag, threadByKey, settledThreads, activeThreads, workingShelfEnabled, sendSortEnabled } = + input; const thread = threadByKey.get(drag.activeKey); if (thread === undefined) return NO_DRAGGED_ORDERS; const others = (list: readonly EnvironmentThreadShell[]) => @@ -393,12 +412,13 @@ function resolveDraggedOrders(input: { ...others(settledThreads), applySidebarThreadDrop(thread, "settled", drag.occurredAt), ]).map(threadShellKey), - activeOrder: workingShelfEnabled - ? sortInboxThreadsByReturn( - [...others(activeThreads), applySidebarThreadDrop(thread, "active", drag.occurredAt)], - inboxReturns.returnedAt, - ).map(threadShellKey) - : undefined, + activeOrder: + workingShelfEnabled || sendSortEnabled + ? sortActiveInbox( + [...others(activeThreads), applySidebarThreadDrop(thread, "active", drag.occurredAt)], + { workingShelfEnabled, sendSortEnabled, returnedAt: inboxReturns.returnedAt }, + ).map(threadShellKey) + : undefined, }; } @@ -441,6 +461,7 @@ function useSidebarThreads(input: { // Folders already separate the work, so boring mode has no Working shelf. const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled) && folderGroups === null; + const sendSortEnabled = useClientSettings((s) => s.sidebarInboxSortBySendEnabled); const inboxReturnsVersion = useSyncExternalStore( inboxReturns.subscribe, inboxReturns.getSnapshot, @@ -485,6 +506,7 @@ function useSidebarThreads(input: { optimisticDrop, workingShelfEnabled, sortActiveBySend: folderGroups !== null, + sendSortEnabled, clockKey: `${nowMinute}:${snoozeWakeClock}:${inboxReturnsVersion}`, }); @@ -565,6 +587,7 @@ function useSidebarThreads(input: { ...partition, threads, workingShelfEnabled, + sendSortEnabled, searchableThreads: [ ...pinnedThreads, ...activeThreads, @@ -620,6 +643,7 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { optimisticDrop, workingShelfEnabled, sortActiveBySend, + sendSortEnabled, } = input; const preciseNow = new Date().toISOString(); const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys, delegatedUnderParent); @@ -679,9 +703,11 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { const sortedPinned = sortPinnedThreadsForSidebar(pinned); const sortedActive = sortActiveBySend ? sortWorkingThreadsBySend(active) - : workingShelfEnabled - ? sortInboxThreadsByReturn(active, inboxObservation.returnedAt) - : sortThreadsForSidebar(active); + : sortActiveInbox(active, { + workingShelfEnabled, + sendSortEnabled, + returnedAt: inboxObservation.returnedAt, + }); return { pinnedThreads: optimisticDrop?.section !== "pinned" || optimisticDrop.order === null @@ -1295,6 +1321,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const { workingShelfEnabled, + sendSortEnabled, pinnedThreads, draggableThreadKeys, activeReorderableThreadKeys, @@ -2031,6 +2058,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve settledThreads, activeThreads, workingShelfEnabled, + sendSortEnabled, }); const sidebarSortingStrategy = createSidebarSortingStrategy({ items: sidebarListItems, @@ -2069,7 +2097,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve activeOrder: activeKeys, activeKeysById, activeReorderableKeys: activeReorderableThreadKeys, - activeTimeOrdered: workingShelfEnabled, + activeTimeOrdered: workingShelfEnabled || sendSortEnabled, }).kind !== "none" ); }, @@ -2103,7 +2131,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve activeOrder: activeKeys, activeKeysById, activeReorderableKeys: activeReorderableThreadKeys, - activeTimeOrdered: workingShelfEnabled, + activeTimeOrdered: workingShelfEnabled || sendSortEnabled, }); if (plan.kind === "none") return; if (plan.kind === "settle" && settlingThreadKeysRef.current.has(activeKey)) return; diff --git a/apps/web/src/components/settings/LabsSettingsPanel.tsx b/apps/web/src/components/settings/LabsSettingsPanel.tsx index 38be7d611518e..470f83ea7787d 100644 --- a/apps/web/src/components/settings/LabsSettingsPanel.tsx +++ b/apps/web/src/components/settings/LabsSettingsPanel.tsx @@ -19,6 +19,7 @@ export function LabsSettingsPanel() { + {/* Clerk is only mounted when this build has T3 Connect. */} {hasCloudPublicConfig() ? : } @@ -75,6 +76,26 @@ function BoringSidebarMultilineRow() { ); } +function InboxSortBySendRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + + updateSettings({ sidebarInboxSortBySendEnabled: Boolean(checked) }) + } + aria-label="Sort inbox by last message" + /> + } + /> + ); +} + /** Signed out of T3 Connect, the row offers to sign in instead of its switch. */ function GuestLinksRowWithSignIn() { const { isSignedIn } = useAuth(); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index a37bf1581990e..a18ac8f1afa67 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -491,6 +491,10 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.sidebarWorkingShelfEnabled !== DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled ? ["Working section"] : []), + ...(settings.sidebarInboxSortBySendEnabled !== + DEFAULT_UNIFIED_SETTINGS.sidebarInboxSortBySendEnabled + ? ["Sort inbox by last message"] + : []), ...(settings.sidebarAutoSettleAfterDays !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? ["Auto-settle inactive threads"] : []), @@ -660,6 +664,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, sidebarProjectSortOrder: DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder, sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled, + sidebarInboxSortBySendEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarInboxSortBySendEnabled, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, sidebarAutoSettleKeepPullRequests: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleKeepPullRequests, sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index f7492035874da..7f76d4eee368c 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -475,6 +475,12 @@ export const SETTINGS_SEARCH_ITEMS = [ targetId: "boring-sidebar", searchTerms: ["beta boring sidebar folder rows multiline branch pull request details"], }, + { + id: "inbox-sort-by-send", + title: "Sort inbox by last message", + to: "/settings/labs", + searchTerms: ["beta order recent newest latest message sent threads inbox sidebar"], + }, { id: "tool-call-visibility", title: "Tool calls in chat", diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 09ce63e2a3e65..bb00fc045fb26 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -159,6 +159,17 @@ Pinned threads stay above the folders, and Snoozed and Settled stay below them. can drag a thread onto the composer to mention it, but not within the sidebar, and the Working section is off. Each device keeps its own choice. +### Sort the inbox by your last message (beta) + +Turn on **Settings → Labs → Sort inbox by last message** on web and desktop to order +the active list by the last message you sent, newest first. Agent activity does not move a +thread, so rows only change place when you send, create, or un-settle a thread. With the Working +section also on, a thread that comes back to you moves to the top as well. Each device keeps its +own choice. + +While this is on, you cannot drag or move threads within the active list. Your saved order +returns when you turn it off. + ## Settle finished work Choose **Settle thread** from its menu to move finished work out of the active list diff --git a/packages/client-runtime/src/state/threadInbox.test.ts b/packages/client-runtime/src/state/threadInbox.test.ts index 35c7fa8355e39..e375d5cc15e3d 100644 --- a/packages/client-runtime/src/state/threadInbox.test.ts +++ b/packages/client-runtime/src/state/threadInbox.test.ts @@ -1,10 +1,12 @@ import { EnvironmentId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { EnvironmentThreadShell } from "./models.ts"; import { createInboxReturnTracker, isThreadWorking, sortInboxThreadsByReturn, + sortInboxThreadsBySend, sortWorkingThreadsBySend, } from "./threadInbox.ts"; @@ -241,3 +243,67 @@ describe("sortWorkingThreadsBySend", () => { ).toEqual(["sent-last", "sent-first", "launched"]); }); }); + +describe("sortInboxThreadsBySend", () => { + const inboxThread = ( + id: string, + overrides: Partial< + Pick< + EnvironmentThreadShell, + "latestRun" | "latestUserMessageAt" | "latestUserAuthoredMessageAt" | "unsettledAt" + > + > = {}, + ) => ({ + ...thread(id, false), + latestUserMessageAt: null, + latestUserAuthoredMessageAt: null, + ...overrides, + }); + + it("orders by the last message the user sent, ignoring agent runs", () => { + const sentEarly = inboxThread("sent-early", { + latestUserAuthoredMessageAt: "2026-06-01T01:00:00.000Z", + latestRun: { + runId: RunId.make("run:late"), + status: "completed" as const, + requestedAt: "2026-06-01T05:00:00.000Z", + startedAt: "2026-06-01T05:00:00.000Z", + completedAt: "2026-06-01T06:00:00.000Z", + assistantMessageId: null, + }, + }); + const sentLate = inboxThread("sent-late", { + latestUserAuthoredMessageAt: "2026-06-01T03:00:00.000Z", + }); + const untouched = inboxThread("untouched"); + expect( + sortInboxThreadsBySend([untouched, sentEarly, sentLate]).map((entry) => entry.id), + ).toEqual(["sent-late", "sent-early", "untouched"]); + }); + + it("falls back to the latest user message when authorship is unknown", () => { + const { latestUserAuthoredMessageAt: _unknown, ...legacy } = inboxThread("legacy", { + latestUserMessageAt: "2026-06-01T04:00:00.000Z", + }); + const sent = inboxThread("sent", { + latestUserAuthoredMessageAt: "2026-06-01T02:00:00.000Z", + }); + expect(sortInboxThreadsBySend([sent, legacy]).map((entry) => entry.id)).toEqual([ + "legacy", + "sent", + ]); + }); + + it("puts un-settled and returned threads back on top", () => { + const sent = inboxThread("sent", { + latestUserAuthoredMessageAt: "2026-06-01T02:00:00.000Z", + }); + const unsettled = inboxThread("unsettled", { unsettledAt: "2026-06-01T03:00:00.000Z" }); + const returned = inboxThread("returned"); + expect( + sortInboxThreadsBySend([sent, unsettled, returned], (entry) => + entry.id === "returned" ? Date.parse("2026-06-01T04:00:00.000Z") : undefined, + ).map((entry) => entry.id), + ).toEqual(["returned", "unsettled", "sent"]); + }); +}); diff --git a/packages/client-runtime/src/state/threadInbox.ts b/packages/client-runtime/src/state/threadInbox.ts index 2669416db42fe..fbd361b1ebc74 100644 --- a/packages/client-runtime/src/state/threadInbox.ts +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -54,6 +54,35 @@ export function sortInboxThreadsByReturn( return sortNewestFirst(threads, timestamps); } +type InboxSendSortInput = InboxThreadInput & + Pick; + +/** + * Newest first by the last message the user sent. Agent activity never moves + * a row; creating, un-settling, or (with the Working section) a return does. + */ +export function sortInboxThreadsBySend( + threads: readonly T[], + observedReturnAt?: (thread: T) => number | undefined, +): T[] { + const timestamps = new Map( + threads.map((thread) => [ + thread, + Math.max( + toSortableTimestamp(thread.createdAt) ?? 0, + toSortableTimestamp(thread.unsettledAt ?? undefined) ?? 0, + toSortableTimestamp( + (thread.latestUserAuthoredMessageAt === undefined + ? thread.latestUserMessageAt + : thread.latestUserAuthoredMessageAt) ?? undefined, + ) ?? 0, + observedReturnAt?.(thread) ?? 0, + ), + ]), + ); + return sortNewestFirst(threads, timestamps); +} + type WorkingSortInput = Pick< EnvironmentThreadShell, "id" | "environmentId" | "createdAt" | "latestRun" | "latestUserAuthoredMessageAt" diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index cd112d84bf9f4..3739f975f1e86 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -525,6 +525,9 @@ export const ClientSettingsSchema = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed({})), ), sidebarWorkingShelfEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + sidebarInboxSortBySendEnabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE)), ), @@ -2032,6 +2035,7 @@ export const ClientSettingsPatch = Schema.Struct({ threadDetailsUsageEnabled: Schema.optionalKey(Schema.Boolean), threadDetailsItems: Schema.optionalKey(Schema.Record(Schema.String, Schema.Boolean)), sidebarWorkingShelfEnabled: Schema.optionalKey(Schema.Boolean), + sidebarInboxSortBySendEnabled: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), sidebarProjectGroupingOverrides: Schema.optionalKey( Schema.Record(TrimmedNonEmptyString, SidebarProjectGroupingMode), From 50b35fbeb8bc8fa7726e8354fdca84b4d60662da Mon Sep 17 00:00:00 2001 From: Sergio Date: Sun, 25 Oct 2026 12:43:26 +0100 Subject: [PATCH 2/5] docs: say when inbox dragging and return order apply The general drag instructions and the Working section text described the active list as always draggable and always ordered by return time. Both now say how Working section and Sort inbox by last message change that. Co-Authored-By: Claude Opus 5.5 --- docs/user/thread-sidebar.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index bb00fc045fb26..5d76368a09215 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -95,7 +95,9 @@ thread is over takes the accent color. Section labels also identify empty sections and a collapsed settled shelf. Drag within the pinned or active section to change its order. Other rows slide aside to show the -spot where the thread will land. Drops into either section keep the position you choose. On +spot where the thread will land. Drops into either section keep the position you choose. On web +and desktop, with **Working section** or **Sort inbox by last message** on, the active list orders +itself and dragging within it is off; pinned reordering still works. On mobile, open a thread's menu and choose **Arrange threads**. Drag a handle within or between **Pinned** and **Active** to reorder, pin, or unpin. Drop onto the **Settled** divider to settle a thread. The dragged card shows the action before you release it. Expand **Snoozed** @@ -144,8 +146,9 @@ This is on by default. Turn it off with **Settings → General → Working secti or **Settings → General → Working section** on iOS and Android. Each device keeps its own choice. -While this is on, the active list is ordered by when each thread last came back to you, so you -cannot drag or move threads within it. Your saved order returns when you turn it off. +While this is on, the active list is ordered by when each thread last came back to you, or by +your last message when **Sort inbox by last message** is also on. Either way, you cannot drag or +move threads within it. Your saved order returns when you turn both off. ### Group threads by project (beta) @@ -162,13 +165,11 @@ section is off. Each device keeps its own choice. ### Sort the inbox by your last message (beta) Turn on **Settings → Labs → Sort inbox by last message** on web and desktop to order -the active list by the last message you sent, newest first. Agent activity does not move a -thread, so rows only change place when you send, create, or un-settle a thread. With the Working -section also on, a thread that comes back to you moves to the top as well. Each device keeps its -own choice. - -While this is on, you cannot drag or move threads within the active list. Your saved order -returns when you turn it off. +the active list by the last message you sent, newest first, instead of your saved order. Agent +activity does not move a thread, so rows only change place when you send, create, or un-settle a +thread, or, with the Working section on, when a thread comes back to you. You cannot drag or move +threads within the active list while this is on. Each device keeps its own choice, and your saved +order returns when you turn it off. ## Settle finished work From 255f9c0a6229cec9ece2aeddafd9c0bd47c42be1 Mon Sep 17 00:00:00 2001 From: Sergio Date: Sun, 25 Oct 2026 12:49:01 +0100 Subject: [PATCH 3/5] docs: turning the inbox sort off keeps return order with Working on Co-Authored-By: Claude Opus 5.5 --- docs/user/thread-sidebar.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 5d76368a09215..9e4c51d74700f 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -168,8 +168,10 @@ Turn on **Settings → Labs → Sort inbox by last message** on web and desktop the active list by the last message you sent, newest first, instead of your saved order. Agent activity does not move a thread, so rows only change place when you send, create, or un-settle a thread, or, with the Working section on, when a thread comes back to you. You cannot drag or move -threads within the active list while this is on. Each device keeps its own choice, and your saved -order returns when you turn it off. +threads within the active list while this is on. The boring sidebar already lists each folder by +your last message, so this setting only changes the list without folders. Each device keeps its own +choice. Turning it off brings back your saved order, or return-time order if the Working section is +still on. ## Settle finished work From f21b5ad4d0a52a05764af68b5cdbbe761af57ffe Mon Sep 17 00:00:00 2001 From: Sergio Date: Wed, 28 Oct 2026 15:16:45 +0100 Subject: [PATCH 4/5] fix(client-runtime): keep inbox return order after a reload With the Working section on, the send sort only knew about returns seen since the page loaded, so a reload could reorder the inbox. Use the finished run's stored time as the return, only when the Working section is on. Co-Authored-By: Claude Opus 5.5 --- .../src/state/threadInbox.test.ts | 27 +++++++++++++++++++ .../client-runtime/src/state/threadInbox.ts | 5 ++++ 2 files changed, 32 insertions(+) diff --git a/packages/client-runtime/src/state/threadInbox.test.ts b/packages/client-runtime/src/state/threadInbox.test.ts index e375d5cc15e3d..de3cb82013705 100644 --- a/packages/client-runtime/src/state/threadInbox.test.ts +++ b/packages/client-runtime/src/state/threadInbox.test.ts @@ -306,4 +306,31 @@ describe("sortInboxThreadsBySend", () => { ).map((entry) => entry.id), ).toEqual(["returned", "unsettled", "sent"]); }); + + it("keeps a finished run's return after a reload, only with the Working section", () => { + const returnedEarlier = inboxThread("returned-earlier", { + latestUserAuthoredMessageAt: "2026-06-01T01:00:00.000Z", + latestRun: { + runId: RunId.make("run:done"), + status: "completed" as const, + requestedAt: "2026-06-01T01:00:00.000Z", + startedAt: "2026-06-01T01:00:00.000Z", + completedAt: "2026-06-01T03:00:00.000Z", + assistantMessageId: null, + }, + }); + const sentLater = inboxThread("sent-later", { + latestUserAuthoredMessageAt: "2026-06-01T02:00:00.000Z", + }); + // A fresh page has no observed returns yet. + expect( + sortInboxThreadsBySend([sentLater, returnedEarlier], () => undefined).map( + (entry) => entry.id, + ), + ).toEqual(["returned-earlier", "sent-later"]); + expect(sortInboxThreadsBySend([sentLater, returnedEarlier]).map((entry) => entry.id)).toEqual([ + "sent-later", + "returned-earlier", + ]); + }); }); diff --git a/packages/client-runtime/src/state/threadInbox.ts b/packages/client-runtime/src/state/threadInbox.ts index fbd361b1ebc74..eeaf17371683a 100644 --- a/packages/client-runtime/src/state/threadInbox.ts +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -60,6 +60,7 @@ type InboxSendSortInput = InboxThreadInput & /** * Newest first by the last message the user sent. Agent activity never moves * a row; creating, un-settling, or (with the Working section) a return does. + * Pass `observedReturnAt` only with the Working section on. */ export function sortInboxThreadsBySend( threads: readonly T[], @@ -76,6 +77,10 @@ export function sortInboxThreadsBySend( ? thread.latestUserMessageAt : thread.latestUserAuthoredMessageAt) ?? undefined, ) ?? 0, + // With the Working section, a finished run is a return; the stored time survives reloads. + observedReturnAt === undefined + ? 0 + : (toSortableTimestamp(thread.latestRun?.completedAt ?? undefined) ?? 0), observedReturnAt?.(thread) ?? 0, ), ]), From 28a8d77b50c7582057514d855bcf090427b42760 Mon Sep 17 00:00:00 2001 From: Sergio Date: Fri, 30 Oct 2026 17:13:56 +0100 Subject: [PATCH 5/5] fix(client-runtime): count only real returns in inbox send order With the Working section on, the send order now also keeps a run that is waiting on you (approval or input) on top after a reload, through the stored request time, as the return order does. It also stops trusting a completion time the server did not report: older servers omit it and the shell falls back to `updatedAt`, so a metadata change could move an idle thread. The shell now keeps the reported value as `latestRunCompletedAt`. Co-Authored-By: Claude Opus 5.5 --- packages/client-runtime/src/state/models.ts | 5 ++ .../src/state/threadInbox.test.ts | 48 ++++++++++++++++++- .../client-runtime/src/state/threadInbox.ts | 16 +++++-- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index 098b73141e3c9..20cd5f166bee7 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -156,6 +156,8 @@ export interface EnvironmentThreadShell { readonly blockedOnThreadWait?: boolean; readonly latestUserMessageAt: string | null; readonly latestUserAuthoredMessageAt?: string | null; + /** Set only when the server reports it; `latestRun.completedAt` can fall back to `updatedAt`. */ + readonly latestRunCompletedAt?: string | null; readonly hasPendingApprovals: boolean; readonly hasPendingUserInput: boolean; readonly pendingUserInputAt?: string | null; @@ -307,6 +309,9 @@ export function presentThreadShell( ...(thread.latestUserAuthoredMessageAt === undefined ? {} : { latestUserAuthoredMessageAt: nullableIso(thread.latestUserAuthoredMessageAt) }), + ...(thread.latestRunCompletedAt === undefined + ? {} + : { latestRunCompletedAt: nullableIso(thread.latestRunCompletedAt) }), hasPendingApprovals: thread.pendingRuntimeRequest !== null && thread.pendingRuntimeRequest.kind !== "user_input" && diff --git a/packages/client-runtime/src/state/threadInbox.test.ts b/packages/client-runtime/src/state/threadInbox.test.ts index de3cb82013705..17fd86b05c190 100644 --- a/packages/client-runtime/src/state/threadInbox.test.ts +++ b/packages/client-runtime/src/state/threadInbox.test.ts @@ -250,7 +250,11 @@ describe("sortInboxThreadsBySend", () => { overrides: Partial< Pick< EnvironmentThreadShell, - "latestRun" | "latestUserMessageAt" | "latestUserAuthoredMessageAt" | "unsettledAt" + | "latestRun" + | "latestRunCompletedAt" + | "latestUserMessageAt" + | "latestUserAuthoredMessageAt" + | "unsettledAt" > > = {}, ) => ({ @@ -318,6 +322,7 @@ describe("sortInboxThreadsBySend", () => { completedAt: "2026-06-01T03:00:00.000Z", assistantMessageId: null, }, + latestRunCompletedAt: "2026-06-01T03:00:00.000Z", }); const sentLater = inboxThread("sent-later", { latestUserAuthoredMessageAt: "2026-06-01T02:00:00.000Z", @@ -333,4 +338,45 @@ describe("sortInboxThreadsBySend", () => { "returned-earlier", ]); }); + + it("keeps a run waiting on you on top after a reload", () => { + const awaiting = inboxThread("awaiting", { + latestUserAuthoredMessageAt: "2026-06-01T01:00:00.000Z", + latestRun: { + runId: RunId.make("run:approval"), + status: "waiting" as const, + requestedAt: "2026-06-01T03:00:00.000Z", + startedAt: "2026-06-01T03:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }, + }); + const sentLater = inboxThread("sent-later", { + latestUserAuthoredMessageAt: "2026-06-01T02:00:00.000Z", + }); + expect( + sortInboxThreadsBySend([sentLater, awaiting], () => undefined).map((entry) => entry.id), + ).toEqual(["awaiting", "sent-later"]); + }); + + it("ignores a completion time the server did not report", () => { + // Older servers omit it, so `latestRun.completedAt` is the thread's `updatedAt`. + const touched = inboxThread("touched", { + latestUserAuthoredMessageAt: "2026-06-01T01:00:00.000Z", + latestRun: { + runId: RunId.make("run:legacy"), + status: "completed" as const, + requestedAt: "2026-06-01T01:00:00.000Z", + startedAt: "2026-06-01T01:00:00.000Z", + completedAt: "2026-06-01T03:00:00.000Z", + assistantMessageId: null, + }, + }); + const sentLater = inboxThread("sent-later", { + latestUserAuthoredMessageAt: "2026-06-01T02:00:00.000Z", + }); + expect( + sortInboxThreadsBySend([sentLater, touched], () => undefined).map((entry) => entry.id), + ).toEqual(["sent-later", "touched"]); + }); }); diff --git a/packages/client-runtime/src/state/threadInbox.ts b/packages/client-runtime/src/state/threadInbox.ts index eeaf17371683a..9c31fbc8b429c 100644 --- a/packages/client-runtime/src/state/threadInbox.ts +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -55,7 +55,10 @@ export function sortInboxThreadsByReturn( } type InboxSendSortInput = InboxThreadInput & - Pick; + Pick< + EnvironmentThreadShell, + "latestUserMessageAt" | "latestUserAuthoredMessageAt" | "latestRunCompletedAt" + >; /** * Newest first by the last message the user sent. Agent activity never moves @@ -77,10 +80,13 @@ export function sortInboxThreadsBySend( ? thread.latestUserMessageAt : thread.latestUserAuthoredMessageAt) ?? undefined, ) ?? 0, - // With the Working section, a finished run is a return; the stored time survives reloads. - observedReturnAt === undefined - ? 0 - : (toSortableTimestamp(thread.latestRun?.completedAt ?? undefined) ?? 0), + // With the Working section, runs mark returns; the stored times survive reloads. + ...(observedReturnAt === undefined + ? [] + : [ + toSortableTimestamp(thread.latestRun?.requestedAt ?? undefined) ?? 0, + toSortableTimestamp(thread.latestRunCompletedAt ?? undefined) ?? 0, + ]), observedReturnAt?.(thread) ?? 0, ), ]),