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..9e4c51d74700f 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) @@ -159,6 +162,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, 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. 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 Choose **Settle thread** from its menu to move finished work out of the active list 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 35c7fa8355e39..17fd86b05c190 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,140 @@ describe("sortWorkingThreadsBySend", () => { ).toEqual(["sent-last", "sent-first", "launched"]); }); }); + +describe("sortInboxThreadsBySend", () => { + const inboxThread = ( + id: string, + overrides: Partial< + Pick< + EnvironmentThreadShell, + | "latestRun" + | "latestRunCompletedAt" + | "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"]); + }); + + 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, + }, + latestRunCompletedAt: "2026-06-01T03:00:00.000Z", + }); + 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", + ]); + }); + + 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 2669416db42fe..9c31fbc8b429c 100644 --- a/packages/client-runtime/src/state/threadInbox.ts +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -54,6 +54,46 @@ export function sortInboxThreadsByReturn( return sortNewestFirst(threads, timestamps); } +type InboxSendSortInput = InboxThreadInput & + Pick< + EnvironmentThreadShell, + "latestUserMessageAt" | "latestUserAuthoredMessageAt" | "latestRunCompletedAt" + >; + +/** + * 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[], + 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, + // 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, + ), + ]), + ); + 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),