authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-04 00:28:10-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 00:28:10-07:00
log1302ccacbd6d3e957f2f95239556adb6eae79a48
tree9fd1adf41b7d4bf7390d3e5ae12274a2f4023b7f
parente19a48d220a18ef198178b48d12aa1a034596489
signature Signed by PGP key B5690EEEBB952194

fix(clients): Working section keeps its order while agents finish and wake (#15418)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

10 files changed, 127 insertions(+), 30 deletions(-)

apps/mobile/src/features/threads/threadListV2.ts+3-2
......@@ -15,6 +15,7 @@ import {
1515 createInboxReturnTracker,
1616 isThreadWorking,
1717 sortInboxThreadsByReturn,
18 sortWorkingThreadsBySend,
1819} from "@t3tools/client-runtime/state/thread-inbox";
1920import {
2021 sortActiveThreadsByOrderKey,
......@@ -728,8 +729,8 @@ export function buildThreadListV2Items(input: {
728729 const orderedActive = workingShelfEnabled
729730 ? sortInboxThreadsByReturn(active, input.inboxReturnAt)
730731 : applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending);
731 // Newest work first, by the same clock as the inbox.
732 const orderedWorking = sortInboxThreadsByReturn(working);
732 // Newest send first; finishing and waking again do not move a row.
733 const orderedWorking = sortWorkingThreadsBySend(working);
733734 const orderedSnoozed = [...snoozed].sort(
734735 (left, right) =>
735736 parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""),
apps/server/src/orchestration-v2/ProjectionSettlement.test.ts+8
......@@ -343,6 +343,14 @@ it.effect.each([
343343 DateTime.formatIso(candidate.latestUserAuthoredMessageAt!),
344344 DateTime.formatIso(written),
345345 );
346 // The shell carries the same stamp, which orders the Working section.
347 const shellThread = (yield* store.getShellSnapshot()).threads.find(
348 (thread) => thread.id === threadId,
349 );
350 assert.equal(
351 DateTime.formatIso(shellThread!.latestUserAuthoredMessageAt!),
352 DateTime.formatIso(written),
353 );
346354 }).pipe(Effect.provide(testLayer)),
347355);
348356
apps/server/src/orchestration-v2/ProjectionStore.ts+35-22
......@@ -161,10 +161,7 @@ export type ProjectionThreadPullRequests = Pick<
161161
162162/**
163163 * Thread activity needed by settlement, without transcript or fork history.
164 * `latestUserAuthoredMessageAt` is the last message the user wrote. Agent,
165 * provider, and server notifications also use the user role, so
166 * `latestUserMessageAt` moves when background work or a PR watch wakes the
167 * agent.
164 * Settlement always loads `latestUserAuthoredMessageAt`, so it is required here.
168165 */
169166export type ProjectionSettlementCandidate = Pick<
170167 OrchestrationV2ThreadShell,
......@@ -917,6 +914,7 @@ type ShellThreadRow = {
917914 readonly blocking_failure_payload_json: string | null;
918915 readonly pending_request_payload_json: string | null;
919916 readonly latest_user_message_at: string | null;
917 readonly latest_user_authored_message_at: string | null;
920918 readonly has_actionable_proposed_plan: number;
921919 readonly item_count: number;
922920 readonly runless_item_count: number;
......@@ -938,7 +936,8 @@ type SettlementThreadRow = Pick<
938936 | "latest_run_started_at"
939937 | "latest_run_completed_at"
940938 | "latest_user_message_at"
941> & { readonly latest_user_authored_message_at: string | null };
939 | "latest_user_authored_message_at"
940>;
942941
943942type ShellRunItemCountRow = {
944943 readonly thread_id: string;
......@@ -1329,13 +1328,13 @@ export function threadShellFromProjection(
13291328 (left, right) =>
13301329 DateTime.toEpochMillis(right.createdAt) - DateTime.toEpochMillis(left.createdAt),
13311330 )[0] ?? null;
1332 const latestUserMessage =
1333 projection.messages
1334 .filter((message) => message.role === "user")
1335 .toSorted(
1336 (left, right) =>
1337 DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt),
1338 )[0] ?? null;
1331 const userMessages = projection.messages
1332 .filter((message) => message.role === "user")
1333 .toSorted(
1334 (left, right) =>
1335 DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt),
1336 );
1337 const latestUserMessage = userMessages[0] ?? null;
13391338 const pendingBackgroundTasks = derivePendingBackgroundWork({
13401339 latestRun,
13411340 providerThreads: projection.providerThreads,
......@@ -1396,6 +1395,8 @@ export function threadShellFromProjection(
13961395 // initial hydration and streaming updates independent of transcript size.
13971396 latestVisibleMessage: null,
13981397 latestUserMessageAt: latestUserMessage?.updatedAt ?? null,
1398 latestUserAuthoredMessageAt:
1399 userMessages.find((message) => message.createdBy === "user")?.updatedAt ?? null,
13991400 hasActionableProposedPlan: projection.plans.some(
14001401 (plan) => plan.kind === "proposed_plan" && plan.status === "active",
14011402 ),
......@@ -1480,6 +1481,7 @@ type ShellThreadState = {
14801481 readonly usageLimitResetAt: OrchestrationV2ThreadShell["usageLimitResetAt"];
14811482 readonly pendingRuntimeRequest: OrchestrationV2ThreadProjection["runtimeRequests"][number] | null;
14821483 readonly latestUserMessageAt: DateTime.Utc | null;
1484 readonly latestUserAuthoredMessageAt: DateTime.Utc | null;
14831485 readonly hasActionableProposedPlan: boolean;
14841486 readonly pendingBackgroundTasks: OrchestrationV2ThreadShell["pendingBackgroundTasks"];
14851487 readonly providerInstanceHistory: OrchestrationV2ThreadShell["providerInstanceHistory"];
......@@ -1625,6 +1627,7 @@ function shellFromState(input: {
16251627 },
16261628 latestVisibleMessage: null,
16271629 latestUserMessageAt: input.state.latestUserMessageAt,
1630 latestUserAuthoredMessageAt: input.state.latestUserAuthoredMessageAt,
16281631 hasActionableProposedPlan: input.state.hasActionableProposedPlan,
16291632 pendingBackgroundTasks: input.state.pendingBackgroundTasks,
16301633 providerInstanceHistory: input.state.providerInstanceHistory,
......@@ -4887,6 +4890,15 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
48874890 ORDER BY message.updated_at DESC, message.message_id DESC
48884891 LIMIT 1
48894892 ) AS latest_user_message_at,
4893 (
4894 SELECT message.updated_at
4895 FROM orchestration_v2_projection_messages message
4896 WHERE message.thread_id = t.thread_id
4897 AND message.role = 'user'
4898 AND json_extract(message.payload_json, '$.createdBy') = 'user'
4899 ORDER BY message.updated_at DESC, message.message_id DESC
4900 LIMIT 1
4901 ) AS latest_user_authored_message_at,
48904902 EXISTS (
48914903 SELECT 1
48924904 FROM orchestration_v2_projection_plans plan
......@@ -5325,6 +5337,10 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
53255337 row.latest_user_message_at === null
53265338 ? null
53275339 : DateTime.makeUnsafe(row.latest_user_message_at),
5340 latestUserAuthoredMessageAt:
5341 row.latest_user_authored_message_at === null
5342 ? null
5343 : DateTime.makeUnsafe(row.latest_user_authored_message_at),
53285344 hasActionableProposedPlan: row.has_actionable_proposed_plan === 1,
53295345 pendingBackgroundTasks,
53305346 providerInstanceHistory: providerInstanceHistoryForShell({
......@@ -5630,16 +5646,13 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect(
56305646 !runs.some(isActivityRunForShell) &&
56315647 !runtimeRequests.some((request) => request.status === "pending"),
56325648 )
5633 .map((projection) => ({
5634 ...threadShellFromProjection(projection),
5635 latestUserAuthoredMessageAt:
5636 projection.messages
5637 .filter((message) => message.role === "user" && message.createdBy === "user")
5638 .map((message) => message.updatedAt)
5639 .toSorted(
5640 (left, right) => DateTime.toEpochMillis(right) - DateTime.toEpochMillis(left),
5641 )[0] ?? null,
5642 }))
5649 .map((projection) => {
5650 const shell = threadShellFromProjection(projection);
5651 return {
5652 ...shell,
5653 latestUserAuthoredMessageAt: shell.latestUserAuthoredMessageAt ?? null,
5654 };
5655 })
56435656 .toSorted(
56445657 (left, right) =>
56455658 DateTime.toEpochMillis(left.updatedAt) - DateTime.toEpochMillis(right.updatedAt) ||
apps/web/src/components/Sidebar.logic.ts+1
......@@ -1055,6 +1055,7 @@ export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/c
10551055export {
10561056 isThreadWorking as isSidebarThreadWorking,
10571057 sortInboxThreadsByReturn,
1058 sortWorkingThreadsBySend,
10581059} from "@t3tools/client-runtime/state/thread-inbox";
10591060
10601061// Pinned-reorder key math and the keyed sort live in client-runtime
apps/web/src/components/Sidebar.tsx+3-2
......@@ -209,6 +209,7 @@ import {
209209 sortPinnedThreadsForSidebar,
210210 sortSidebarV2ProjectGroups,
211211 sortThreadsForSidebar,
212 sortWorkingThreadsBySend,
212213 useThreadJumpHintVisibility,
213214 useRetainedValue,
214215 useSidebarRowSubscriptionLease,
......@@ -2794,8 +2795,8 @@ export default function Sidebar() {
27942795 preferredIds: optimisticDrop.order,
27952796 getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
27962797 }),
2797 // Newest work first, by the same clock as the inbox.
2798 workingThreads: sortInboxThreadsByReturn(working),
2798 // Newest send first; finishing and waking again do not move a row.
2799 workingThreads: sortWorkingThreadsBySend(working),
27992800 // Soonest wake first: "what comes back next" is the shelf's question.
28002801 snoozedThreads: snoozed.toSorted(
28012802 (left, right) =>
docs/user/thread-sidebar.md+3-2
......@@ -117,8 +117,9 @@ open.
117117Turn on **Settings → General → Working section (beta)** on web and desktop, or **Settings →
118118Thread behavior → Working section** on iOS and Android, to move threads that are working or
119119monitoring into a collapsed **Working** section below the active list. A thread returns to the top
120of the active list when it finishes, fails, or needs an approval or answer. Pinned threads stay in
121the pinned section. Each device keeps its own choice.
120of the active list when it finishes, fails, or needs an approval or answer. The Working section
121lists the thread you last sent work to first. Pinned threads stay in the pinned section. Each
122device keeps its own choice.
122123
123124While this is on, the active list is ordered by when each thread last came back to you, so you
124125cannot drag or move threads within it. Your saved order returns when you turn it off.
packages/client-runtime/src/state/models.ts+5
......@@ -102,6 +102,8 @@ export interface EnvironmentThreadShell {
102102 readonly latestRun: ThreadRunSummary | null;
103103 readonly runtime: ThreadRuntimeSummary | null;
104104 readonly latestUserMessageAt: string | null;
105 /** The last message the user wrote. `undefined` means the server predates it. */
106 readonly latestUserAuthoredMessageAt?: string | null;
105107 readonly hasPendingApprovals: boolean;
106108 readonly hasPendingUserInput: boolean;
107109 readonly hasActionableProposedPlan: boolean;
......@@ -241,6 +243,9 @@ export function presentThreadShell(
241243 latestRun,
242244 runtime: shellRuntime(thread),
243245 latestUserMessageAt: nullableIso(thread.latestUserMessageAt),
246 ...(thread.latestUserAuthoredMessageAt === undefined
247 ? {}
248 : { latestUserAuthoredMessageAt: nullableIso(thread.latestUserAuthoredMessageAt) }),
244249 hasPendingApprovals:
245250 thread.pendingRuntimeRequest !== null &&
246251 thread.pendingRuntimeRequest.kind !== "user_input" &&
packages/client-runtime/src/state/threadInbox.test.ts+29-2
......@@ -1,7 +1,7 @@
1import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
1import { EnvironmentId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts";
22import { describe, expect, it } from "vite-plus/test";
33
4import { createInboxReturnTracker } from "./threadInbox.ts";
4import { createInboxReturnTracker, sortWorkingThreadsBySend } from "./threadInbox.ts";
55
66const environmentId = EnvironmentId.make("environment-1");
77
......@@ -57,3 +57,30 @@ describe("createInboxReturnTracker", () => {
5757 expect(tracker.returnedAt(thread("b", false))).toBeDefined();
5858 });
5959});
60
61describe("sortWorkingThreadsBySend", () => {
62 it("orders by the last message the user sent, not by later runs", () => {
63 const sentFirst = {
64 ...thread("sent-first", true),
65 latestUserAuthoredMessageAt: "2026-06-01T01:00:00.000Z",
66 // A wake run requested after the other thread's send.
67 latestRun: {
68 runId: RunId.make("run:wake"),
69 status: "running" as const,
70 requestedAt: "2026-06-01T04:00:00.000Z",
71 startedAt: "2026-06-01T04:00:00.000Z",
72 completedAt: null,
73 assistantMessageId: null,
74 },
75 };
76 const sentLast = {
77 ...thread("sent-last", true),
78 latestUserAuthoredMessageAt: "2026-06-01T02:00:00.000Z",
79 };
80 // Launched by an agent: no user message, so creation time is the send.
81 const launched = { ...thread("launched", true), latestUserAuthoredMessageAt: null };
82 expect(
83 sortWorkingThreadsBySend([launched, sentFirst, sentLast]).map((thread) => thread.id),
84 ).toEqual(["sent-last", "sent-first", "launched"]);
85 });
86});
packages/client-runtime/src/state/threadInbox.ts+34
......@@ -58,6 +58,40 @@ export function sortInboxThreadsByReturn<T extends InboxThreadInput>(
5858 ),
5959 ]),
6060 );
61 return sortNewestFirst(threads, timestamps);
62}
63
64type WorkingSortInput = Pick<
65 EnvironmentThreadShell,
66 "id" | "environmentId" | "createdAt" | "latestRun" | "latestUserAuthoredMessageAt"
67>;
68
69/** The Working section lists threads newest first by the last message the
70 user sent. Runs ending and wakes (background results, delegated results,
71 PR watches) do not move a row, so the order stays put while agents finish
72 and resume. Servers without the authored stamp fall back to the latest
73 run's request time. */
74export function sortWorkingThreadsBySend<T extends WorkingSortInput>(threads: readonly T[]): T[] {
75 const timestamps = new Map(
76 threads.map((thread) => [
77 thread,
78 Math.max(
79 toSortableTimestamp(thread.createdAt) ?? 0,
80 toSortableTimestamp(
81 (thread.latestUserAuthoredMessageAt === undefined
82 ? thread.latestRun?.requestedAt
83 : thread.latestUserAuthoredMessageAt) ?? undefined,
84 ) ?? 0,
85 ),
86 ]),
87 );
88 return sortNewestFirst(threads, timestamps);
89}
90
91function sortNewestFirst<T extends Pick<EnvironmentThreadShell, "id" | "environmentId">>(
92 threads: readonly T[],
93 timestamps: ReadonlyMap<T, number>,
94): T[] {
6195 return [...threads].sort(
6296 (left, right) =>
6397 timestamps.get(right)! - timestamps.get(left)! ||
packages/contracts/src/orchestrationV2.ts+6
......@@ -1747,6 +1747,11 @@ export const OrchestrationV2ThreadShell = Schema.Struct({
17471747 pendingRuntimeRequest: Schema.NullOr(OrchestrationV2PendingRuntimeRequestSummary),
17481748 latestVisibleMessage: Schema.NullOr(OrchestrationV2LatestVisibleMessageSummary),
17491749 latestUserMessageAt: Schema.NullOr(Schema.DateTimeUtc),
1750 /**
1751 * The last message the user wrote. Wakes and agent messages also use the
1752 * user role, so they move latestUserMessageAt but not this.
1753 */
1754 latestUserAuthoredMessageAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)),
17501755 hasActionableProposedPlan: Schema.Boolean,
17511756 // Normalized post-settlement background work for sidebar Waiting pills.
17521757 // Empty when the latest root run is still active or no pending work remains.
......@@ -2269,6 +2274,7 @@ export const OrchestrationV2ThreadShellJson = OrchestrationV2ThreadShell.mapFiel
22692274 pendingRuntimeRequest: Schema.NullOr(OrchestrationV2PendingRuntimeRequestSummaryJson),
22702275 latestVisibleMessage: Schema.NullOr(OrchestrationV2LatestVisibleMessageSummaryJson),
22712276 latestUserMessageAt: Schema.NullOr(Schema.DateTimeUtcFromString),
2277 latestUserAuthoredMessageAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)),
22722278 createdAt: Schema.DateTimeUtcFromString,
22732279 updatedAt: Schema.DateTimeUtcFromString,
22742280 archivedAt: Schema.NullOr(Schema.DateTimeUtcFromString),