diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index f06f649793b14fa6c5fba63c51793329882a12ad..f7327706ef575b1f14648c5f10ad7aaac86f1ef1 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -15,6 +15,7 @@ import { createInboxReturnTracker, isThreadWorking, sortInboxThreadsByReturn, + sortWorkingThreadsBySend, } from "@t3tools/client-runtime/state/thread-inbox"; import { sortActiveThreadsByOrderKey, @@ -728,8 +729,8 @@ export function buildThreadListV2Items(input: { const orderedActive = workingShelfEnabled ? sortInboxThreadsByReturn(active, input.inboxReturnAt) : applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending); - // Newest work first, by the same clock as the inbox. - const orderedWorking = sortInboxThreadsByReturn(working); + // Newest send first; finishing and waking again do not move a row. + const orderedWorking = sortWorkingThreadsBySend(working); const orderedSnoozed = [...snoozed].sort( (left, right) => parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""), diff --git a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts index 1e8297c362c2812a7a9ae6fef5f997a45059d15a..4028b4986e88cad58da6f1839039d3f8d7c45b54 100644 --- a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts @@ -343,6 +343,14 @@ it.effect.each([ DateTime.formatIso(candidate.latestUserAuthoredMessageAt!), DateTime.formatIso(written), ); + // The shell carries the same stamp, which orders the Working section. + const shellThread = (yield* store.getShellSnapshot()).threads.find( + (thread) => thread.id === threadId, + ); + assert.equal( + DateTime.formatIso(shellThread!.latestUserAuthoredMessageAt!), + DateTime.formatIso(written), + ); }).pipe(Effect.provide(testLayer)), ); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index a1b2e0f061f1c1068c63007230192875666f5398..93f1e1e90de20ce8d9c260d9b68bb53b6a394b6f 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -161,10 +161,7 @@ export type ProjectionThreadPullRequests = Pick< /** * Thread activity needed by settlement, without transcript or fork history. - * `latestUserAuthoredMessageAt` is the last message the user wrote. Agent, - * provider, and server notifications also use the user role, so - * `latestUserMessageAt` moves when background work or a PR watch wakes the - * agent. + * Settlement always loads `latestUserAuthoredMessageAt`, so it is required here. */ export type ProjectionSettlementCandidate = Pick< OrchestrationV2ThreadShell, @@ -917,6 +914,7 @@ type ShellThreadRow = { readonly blocking_failure_payload_json: string | null; readonly pending_request_payload_json: string | null; readonly latest_user_message_at: string | null; + readonly latest_user_authored_message_at: string | null; readonly has_actionable_proposed_plan: number; readonly item_count: number; readonly runless_item_count: number; @@ -938,7 +936,8 @@ type SettlementThreadRow = Pick< | "latest_run_started_at" | "latest_run_completed_at" | "latest_user_message_at" -> & { readonly latest_user_authored_message_at: string | null }; + | "latest_user_authored_message_at" +>; type ShellRunItemCountRow = { readonly thread_id: string; @@ -1329,13 +1328,13 @@ export function threadShellFromProjection( (left, right) => DateTime.toEpochMillis(right.createdAt) - DateTime.toEpochMillis(left.createdAt), )[0] ?? null; - const latestUserMessage = - projection.messages - .filter((message) => message.role === "user") - .toSorted( - (left, right) => - DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt), - )[0] ?? null; + const userMessages = projection.messages + .filter((message) => message.role === "user") + .toSorted( + (left, right) => + DateTime.toEpochMillis(right.updatedAt) - DateTime.toEpochMillis(left.updatedAt), + ); + const latestUserMessage = userMessages[0] ?? null; const pendingBackgroundTasks = derivePendingBackgroundWork({ latestRun, providerThreads: projection.providerThreads, @@ -1396,6 +1395,8 @@ export function threadShellFromProjection( // initial hydration and streaming updates independent of transcript size. latestVisibleMessage: null, latestUserMessageAt: latestUserMessage?.updatedAt ?? null, + latestUserAuthoredMessageAt: + userMessages.find((message) => message.createdBy === "user")?.updatedAt ?? null, hasActionableProposedPlan: projection.plans.some( (plan) => plan.kind === "proposed_plan" && plan.status === "active", ), @@ -1480,6 +1481,7 @@ type ShellThreadState = { readonly usageLimitResetAt: OrchestrationV2ThreadShell["usageLimitResetAt"]; readonly pendingRuntimeRequest: OrchestrationV2ThreadProjection["runtimeRequests"][number] | null; readonly latestUserMessageAt: DateTime.Utc | null; + readonly latestUserAuthoredMessageAt: DateTime.Utc | null; readonly hasActionableProposedPlan: boolean; readonly pendingBackgroundTasks: OrchestrationV2ThreadShell["pendingBackgroundTasks"]; readonly providerInstanceHistory: OrchestrationV2ThreadShell["providerInstanceHistory"]; @@ -1625,6 +1627,7 @@ function shellFromState(input: { }, latestVisibleMessage: null, latestUserMessageAt: input.state.latestUserMessageAt, + latestUserAuthoredMessageAt: input.state.latestUserAuthoredMessageAt, hasActionableProposedPlan: input.state.hasActionableProposedPlan, pendingBackgroundTasks: input.state.pendingBackgroundTasks, providerInstanceHistory: input.state.providerInstanceHistory, @@ -4887,6 +4890,15 @@ export const layer: Layer.Layer = ORDER BY message.updated_at DESC, message.message_id DESC LIMIT 1 ) AS latest_user_message_at, + ( + SELECT message.updated_at + FROM orchestration_v2_projection_messages message + WHERE message.thread_id = t.thread_id + AND message.role = 'user' + AND json_extract(message.payload_json, '$.createdBy') = 'user' + ORDER BY message.updated_at DESC, message.message_id DESC + LIMIT 1 + ) AS latest_user_authored_message_at, EXISTS ( SELECT 1 FROM orchestration_v2_projection_plans plan @@ -5325,6 +5337,10 @@ export const layer: Layer.Layer = row.latest_user_message_at === null ? null : DateTime.makeUnsafe(row.latest_user_message_at), + latestUserAuthoredMessageAt: + row.latest_user_authored_message_at === null + ? null + : DateTime.makeUnsafe(row.latest_user_authored_message_at), hasActionableProposedPlan: row.has_actionable_proposed_plan === 1, pendingBackgroundTasks, providerInstanceHistory: providerInstanceHistoryForShell({ @@ -5630,16 +5646,13 @@ export const layerMemory: Layer.Layer = Layer.effect( !runs.some(isActivityRunForShell) && !runtimeRequests.some((request) => request.status === "pending"), ) - .map((projection) => ({ - ...threadShellFromProjection(projection), - latestUserAuthoredMessageAt: - projection.messages - .filter((message) => message.role === "user" && message.createdBy === "user") - .map((message) => message.updatedAt) - .toSorted( - (left, right) => DateTime.toEpochMillis(right) - DateTime.toEpochMillis(left), - )[0] ?? null, - })) + .map((projection) => { + const shell = threadShellFromProjection(projection); + return { + ...shell, + latestUserAuthoredMessageAt: shell.latestUserAuthoredMessageAt ?? null, + }; + }) .toSorted( (left, right) => DateTime.toEpochMillis(left.updatedAt) - DateTime.toEpochMillis(right.updatedAt) || diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 3bd900ac4c67b954afc61222d2bf124a4063832d..3ad4ac09bc314bc3cbf42803f083a8262a7d0a66 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1055,6 +1055,7 @@ export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/c export { isThreadWorking as isSidebarThreadWorking, sortInboxThreadsByReturn, + sortWorkingThreadsBySend, } from "@t3tools/client-runtime/state/thread-inbox"; // Pinned-reorder key math and the keyed sort live in client-runtime diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f0d818a042e3a8d2c622fb81d892b1e5bc3c2ee6..3ee0f867cae31c23d99a8a8c604402818ea5942e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -209,6 +209,7 @@ import { sortPinnedThreadsForSidebar, sortSidebarV2ProjectGroups, sortThreadsForSidebar, + sortWorkingThreadsBySend, useThreadJumpHintVisibility, useRetainedValue, useSidebarRowSubscriptionLease, @@ -2794,8 +2795,8 @@ export default function Sidebar() { preferredIds: optimisticDrop.order, getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), }), - // Newest work first, by the same clock as the inbox. - workingThreads: sortInboxThreadsByReturn(working), + // Newest send first; finishing and waking again do not move a row. + workingThreads: sortWorkingThreadsBySend(working), // Soonest wake first: "what comes back next" is the shelf's question. snoozedThreads: snoozed.toSorted( (left, right) => diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index e294e9fdc0a6c0fdc1a322339c5d8615ec779a0b..eb7c4e6a9ffdbe79b55944d1fe9665f16984c31a 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -117,8 +117,9 @@ open. Turn on **Settings → General → Working section (beta)** on web and desktop, or **Settings → Thread behavior → Working section** on iOS and Android, to move threads that are working or monitoring into a collapsed **Working** section below the active list. A thread returns to the top -of the active list when it finishes, fails, or needs an approval or answer. Pinned threads stay in -the pinned section. Each device keeps its own choice. +of the active list when it finishes, fails, or needs an approval or answer. The Working section +lists the thread you last sent work to first. Pinned threads stay in the pinned section. 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. diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index dc74aff6ff587a8ed85dbf17355d840f64cebd38..5b319eb10627758c1041dd9194995964e202f8d8 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -102,6 +102,8 @@ export interface EnvironmentThreadShell { readonly latestRun: ThreadRunSummary | null; readonly runtime: ThreadRuntimeSummary | null; readonly latestUserMessageAt: string | null; + /** The last message the user wrote. `undefined` means the server predates it. */ + readonly latestUserAuthoredMessageAt?: string | null; readonly hasPendingApprovals: boolean; readonly hasPendingUserInput: boolean; readonly hasActionableProposedPlan: boolean; @@ -241,6 +243,9 @@ export function presentThreadShell( latestRun, runtime: shellRuntime(thread), latestUserMessageAt: nullableIso(thread.latestUserMessageAt), + ...(thread.latestUserAuthoredMessageAt === undefined + ? {} + : { latestUserAuthoredMessageAt: nullableIso(thread.latestUserAuthoredMessageAt) }), 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 aade72fc85b9a632622141da485ff9c9ab3a9b92..2ae5670933c78757dcac09f241acb4509bece6f1 100644 --- a/packages/client-runtime/src/state/threadInbox.test.ts +++ b/packages/client-runtime/src/state/threadInbox.test.ts @@ -1,7 +1,7 @@ -import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { createInboxReturnTracker } from "./threadInbox.ts"; +import { createInboxReturnTracker, sortWorkingThreadsBySend } from "./threadInbox.ts"; const environmentId = EnvironmentId.make("environment-1"); @@ -57,3 +57,30 @@ describe("createInboxReturnTracker", () => { expect(tracker.returnedAt(thread("b", false))).toBeDefined(); }); }); + +describe("sortWorkingThreadsBySend", () => { + it("orders by the last message the user sent, not by later runs", () => { + const sentFirst = { + ...thread("sent-first", true), + latestUserAuthoredMessageAt: "2026-06-01T01:00:00.000Z", + // A wake run requested after the other thread's send. + latestRun: { + runId: RunId.make("run:wake"), + status: "running" as const, + requestedAt: "2026-06-01T04:00:00.000Z", + startedAt: "2026-06-01T04:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }, + }; + const sentLast = { + ...thread("sent-last", true), + latestUserAuthoredMessageAt: "2026-06-01T02:00:00.000Z", + }; + // Launched by an agent: no user message, so creation time is the send. + const launched = { ...thread("launched", true), latestUserAuthoredMessageAt: null }; + expect( + sortWorkingThreadsBySend([launched, sentFirst, sentLast]).map((thread) => thread.id), + ).toEqual(["sent-last", "sent-first", "launched"]); + }); +}); diff --git a/packages/client-runtime/src/state/threadInbox.ts b/packages/client-runtime/src/state/threadInbox.ts index 1a71bc189ec7892d9980791c6333681a976b8f87..0a7ed0918afe639231490ca0a56c27571679ed7b 100644 --- a/packages/client-runtime/src/state/threadInbox.ts +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -58,6 +58,40 @@ export function sortInboxThreadsByReturn( ), ]), ); + return sortNewestFirst(threads, timestamps); +} + +type WorkingSortInput = Pick< + EnvironmentThreadShell, + "id" | "environmentId" | "createdAt" | "latestRun" | "latestUserAuthoredMessageAt" +>; + +/** The Working section lists threads newest first by the last message the + user sent. Runs ending and wakes (background results, delegated results, + PR watches) do not move a row, so the order stays put while agents finish + and resume. Servers without the authored stamp fall back to the latest + run's request time. */ +export function sortWorkingThreadsBySend(threads: readonly T[]): T[] { + const timestamps = new Map( + threads.map((thread) => [ + thread, + Math.max( + toSortableTimestamp(thread.createdAt) ?? 0, + toSortableTimestamp( + (thread.latestUserAuthoredMessageAt === undefined + ? thread.latestRun?.requestedAt + : thread.latestUserAuthoredMessageAt) ?? undefined, + ) ?? 0, + ), + ]), + ); + return sortNewestFirst(threads, timestamps); +} + +function sortNewestFirst>( + threads: readonly T[], + timestamps: ReadonlyMap, +): T[] { return [...threads].sort( (left, right) => timestamps.get(right)! - timestamps.get(left)! || diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index a2cdc71368eb963c3097b36fbd8bbe8ebf5a899e..c6e7f3ff014045e16545e75232bdf84533cef9bf 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1747,6 +1747,11 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ pendingRuntimeRequest: Schema.NullOr(OrchestrationV2PendingRuntimeRequestSummary), latestVisibleMessage: Schema.NullOr(OrchestrationV2LatestVisibleMessageSummary), latestUserMessageAt: Schema.NullOr(Schema.DateTimeUtc), + /** + * The last message the user wrote. Wakes and agent messages also use the + * user role, so they move latestUserMessageAt but not this. + */ + latestUserAuthoredMessageAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), hasActionableProposedPlan: Schema.Boolean, // Normalized post-settlement background work for sidebar Waiting pills. // Empty when the latest root run is still active or no pending work remains. @@ -2269,6 +2274,7 @@ export const OrchestrationV2ThreadShellJson = OrchestrationV2ThreadShell.mapFiel pendingRuntimeRequest: Schema.NullOr(OrchestrationV2PendingRuntimeRequestSummaryJson), latestVisibleMessage: Schema.NullOr(OrchestrationV2LatestVisibleMessageSummaryJson), latestUserMessageAt: Schema.NullOr(Schema.DateTimeUtcFromString), + latestUserAuthoredMessageAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), createdAt: Schema.DateTimeUtcFromString, updatedAt: Schema.DateTimeUtcFromString, archivedAt: Schema.NullOr(Schema.DateTimeUtcFromString),