| author | |
| committer | |
| log | 1302ccacbd6d3e957f2f95239556adb6eae79a48 |
| tree | 9fd1adf41b7d4bf7390d3e5ae12274a2f4023b7f |
| parent | e19a48d220a18ef198178b48d12aa1a034596489 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 { |
| 15 | 15 | createInboxReturnTracker, |
| 16 | 16 | isThreadWorking, |
| 17 | 17 | sortInboxThreadsByReturn, |
| 18 | sortWorkingThreadsBySend, | |
| 18 | 19 | } from "@t3tools/client-runtime/state/thread-inbox"; |
| 19 | 20 | import { |
| 20 | 21 | sortActiveThreadsByOrderKey, |
| ... | ... | @@ -728,8 +729,8 @@ export function buildThreadListV2Items(input: { |
| 728 | 729 | const orderedActive = workingShelfEnabled |
| 729 | 730 | ? sortInboxThreadsByReturn(active, input.inboxReturnAt) |
| 730 | 731 | : 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); | |
| 733 | 734 | const orderedSnoozed = [...snoozed].sort( |
| 734 | 735 | (left, right) => |
| 735 | 736 | parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""), |
apps/server/src/orchestration-v2/ProjectionSettlement.test.ts+8| ... | ... | @@ -343,6 +343,14 @@ it.effect.each([ |
| 343 | 343 | DateTime.formatIso(candidate.latestUserAuthoredMessageAt!), |
| 344 | 344 | DateTime.formatIso(written), |
| 345 | 345 | ); |
| 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 | ); | |
| 346 | 354 | }).pipe(Effect.provide(testLayer)), |
| 347 | 355 | ); |
| 348 | 356 |
apps/server/src/orchestration-v2/ProjectionStore.ts+35-22| ... | ... | @@ -161,10 +161,7 @@ export type ProjectionThreadPullRequests = Pick< |
| 161 | 161 | |
| 162 | 162 | /** |
| 163 | 163 | * 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. | |
| 168 | 165 | */ |
| 169 | 166 | export type ProjectionSettlementCandidate = Pick< |
| 170 | 167 | OrchestrationV2ThreadShell, |
| ... | ... | @@ -917,6 +914,7 @@ type ShellThreadRow = { |
| 917 | 914 | readonly blocking_failure_payload_json: string | null; |
| 918 | 915 | readonly pending_request_payload_json: string | null; |
| 919 | 916 | readonly latest_user_message_at: string | null; |
| 917 | readonly latest_user_authored_message_at: string | null; | |
| 920 | 918 | readonly has_actionable_proposed_plan: number; |
| 921 | 919 | readonly item_count: number; |
| 922 | 920 | readonly runless_item_count: number; |
| ... | ... | @@ -938,7 +936,8 @@ type SettlementThreadRow = Pick< |
| 938 | 936 | | "latest_run_started_at" |
| 939 | 937 | | "latest_run_completed_at" |
| 940 | 938 | | "latest_user_message_at" |
| 941 | > & { readonly latest_user_authored_message_at: string | null }; | |
| 939 | | "latest_user_authored_message_at" | |
| 940 | >; | |
| 942 | 941 | |
| 943 | 942 | type ShellRunItemCountRow = { |
| 944 | 943 | readonly thread_id: string; |
| ... | ... | @@ -1329,13 +1328,13 @@ export function threadShellFromProjection( |
| 1329 | 1328 | (left, right) => |
| 1330 | 1329 | DateTime.toEpochMillis(right.createdAt) - DateTime.toEpochMillis(left.createdAt), |
| 1331 | 1330 | )[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; | |
| 1339 | 1338 | const pendingBackgroundTasks = derivePendingBackgroundWork({ |
| 1340 | 1339 | latestRun, |
| 1341 | 1340 | providerThreads: projection.providerThreads, |
| ... | ... | @@ -1396,6 +1395,8 @@ export function threadShellFromProjection( |
| 1396 | 1395 | // initial hydration and streaming updates independent of transcript size. |
| 1397 | 1396 | latestVisibleMessage: null, |
| 1398 | 1397 | latestUserMessageAt: latestUserMessage?.updatedAt ?? null, |
| 1398 | latestUserAuthoredMessageAt: | |
| 1399 | userMessages.find((message) => message.createdBy === "user")?.updatedAt ?? null, | |
| 1399 | 1400 | hasActionableProposedPlan: projection.plans.some( |
| 1400 | 1401 | (plan) => plan.kind === "proposed_plan" && plan.status === "active", |
| 1401 | 1402 | ), |
| ... | ... | @@ -1480,6 +1481,7 @@ type ShellThreadState = { |
| 1480 | 1481 | readonly usageLimitResetAt: OrchestrationV2ThreadShell["usageLimitResetAt"]; |
| 1481 | 1482 | readonly pendingRuntimeRequest: OrchestrationV2ThreadProjection["runtimeRequests"][number] | null; |
| 1482 | 1483 | readonly latestUserMessageAt: DateTime.Utc | null; |
| 1484 | readonly latestUserAuthoredMessageAt: DateTime.Utc | null; | |
| 1483 | 1485 | readonly hasActionableProposedPlan: boolean; |
| 1484 | 1486 | readonly pendingBackgroundTasks: OrchestrationV2ThreadShell["pendingBackgroundTasks"]; |
| 1485 | 1487 | readonly providerInstanceHistory: OrchestrationV2ThreadShell["providerInstanceHistory"]; |
| ... | ... | @@ -1625,6 +1627,7 @@ function shellFromState(input: { |
| 1625 | 1627 | }, |
| 1626 | 1628 | latestVisibleMessage: null, |
| 1627 | 1629 | latestUserMessageAt: input.state.latestUserMessageAt, |
| 1630 | latestUserAuthoredMessageAt: input.state.latestUserAuthoredMessageAt, | |
| 1628 | 1631 | hasActionableProposedPlan: input.state.hasActionableProposedPlan, |
| 1629 | 1632 | pendingBackgroundTasks: input.state.pendingBackgroundTasks, |
| 1630 | 1633 | providerInstanceHistory: input.state.providerInstanceHistory, |
| ... | ... | @@ -4887,6 +4890,15 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = |
| 4887 | 4890 | ORDER BY message.updated_at DESC, message.message_id DESC |
| 4888 | 4891 | LIMIT 1 |
| 4889 | 4892 | ) 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, | |
| 4890 | 4902 | EXISTS ( |
| 4891 | 4903 | SELECT 1 |
| 4892 | 4904 | FROM orchestration_v2_projection_plans plan |
| ... | ... | @@ -5325,6 +5337,10 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = |
| 5325 | 5337 | row.latest_user_message_at === null |
| 5326 | 5338 | ? null |
| 5327 | 5339 | : 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), | |
| 5328 | 5344 | hasActionableProposedPlan: row.has_actionable_proposed_plan === 1, |
| 5329 | 5345 | pendingBackgroundTasks, |
| 5330 | 5346 | providerInstanceHistory: providerInstanceHistoryForShell({ |
| ... | ... | @@ -5630,16 +5646,13 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect( |
| 5630 | 5646 | !runs.some(isActivityRunForShell) && |
| 5631 | 5647 | !runtimeRequests.some((request) => request.status === "pending"), |
| 5632 | 5648 | ) |
| 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 | }) | |
| 5643 | 5656 | .toSorted( |
| 5644 | 5657 | (left, right) => |
| 5645 | 5658 | 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 |
| 1055 | 1055 | export { |
| 1056 | 1056 | isThreadWorking as isSidebarThreadWorking, |
| 1057 | 1057 | sortInboxThreadsByReturn, |
| 1058 | sortWorkingThreadsBySend, | |
| 1058 | 1059 | } from "@t3tools/client-runtime/state/thread-inbox"; |
| 1059 | 1060 | |
| 1060 | 1061 | // 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 { |
| 209 | 209 | sortPinnedThreadsForSidebar, |
| 210 | 210 | sortSidebarV2ProjectGroups, |
| 211 | 211 | sortThreadsForSidebar, |
| 212 | sortWorkingThreadsBySend, | |
| 212 | 213 | useThreadJumpHintVisibility, |
| 213 | 214 | useRetainedValue, |
| 214 | 215 | useSidebarRowSubscriptionLease, |
| ... | ... | @@ -2794,8 +2795,8 @@ export default function Sidebar() { |
| 2794 | 2795 | preferredIds: optimisticDrop.order, |
| 2795 | 2796 | getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), |
| 2796 | 2797 | }), |
| 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), | |
| 2799 | 2800 | // Soonest wake first: "what comes back next" is the shelf's question. |
| 2800 | 2801 | snoozedThreads: snoozed.toSorted( |
| 2801 | 2802 | (left, right) => |
docs/user/thread-sidebar.md+3-2| ... | ... | @@ -117,8 +117,9 @@ open. |
| 117 | 117 | Turn on **Settings → General → Working section (beta)** on web and desktop, or **Settings → |
| 118 | 118 | Thread behavior → Working section** on iOS and Android, to move threads that are working or |
| 119 | 119 | monitoring into a collapsed **Working** section below the active list. A thread returns to the top |
| 120 | of the active list when it finishes, fails, or needs an approval or answer. Pinned threads stay in | |
| 121 | the pinned section. Each device keeps its own choice. | |
| 120 | of the active list when it finishes, fails, or needs an approval or answer. The Working section | |
| 121 | lists the thread you last sent work to first. Pinned threads stay in the pinned section. Each | |
| 122 | device keeps its own choice. | |
| 122 | 123 | |
| 123 | 124 | While this is on, the active list is ordered by when each thread last came back to you, so you |
| 124 | 125 | cannot 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 { |
| 102 | 102 | readonly latestRun: ThreadRunSummary | null; |
| 103 | 103 | readonly runtime: ThreadRuntimeSummary | null; |
| 104 | 104 | readonly latestUserMessageAt: string | null; |
| 105 | /** The last message the user wrote. `undefined` means the server predates it. */ | |
| 106 | readonly latestUserAuthoredMessageAt?: string | null; | |
| 105 | 107 | readonly hasPendingApprovals: boolean; |
| 106 | 108 | readonly hasPendingUserInput: boolean; |
| 107 | 109 | readonly hasActionableProposedPlan: boolean; |
| ... | ... | @@ -241,6 +243,9 @@ export function presentThreadShell( |
| 241 | 243 | latestRun, |
| 242 | 244 | runtime: shellRuntime(thread), |
| 243 | 245 | latestUserMessageAt: nullableIso(thread.latestUserMessageAt), |
| 246 | ...(thread.latestUserAuthoredMessageAt === undefined | |
| 247 | ? {} | |
| 248 | : { latestUserAuthoredMessageAt: nullableIso(thread.latestUserAuthoredMessageAt) }), | |
| 244 | 249 | hasPendingApprovals: |
| 245 | 250 | thread.pendingRuntimeRequest !== null && |
| 246 | 251 | thread.pendingRuntimeRequest.kind !== "user_input" && |
packages/client-runtime/src/state/threadInbox.test.ts+29-2| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 1 | import { EnvironmentId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts"; | |
| 2 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | |
| 4 | import { createInboxReturnTracker } from "./threadInbox.ts"; | |
| 4 | import { createInboxReturnTracker, sortWorkingThreadsBySend } from "./threadInbox.ts"; | |
| 5 | 5 | |
| 6 | 6 | const environmentId = EnvironmentId.make("environment-1"); |
| 7 | 7 | |
| ... | ... | @@ -57,3 +57,30 @@ describe("createInboxReturnTracker", () => { |
| 57 | 57 | expect(tracker.returnedAt(thread("b", false))).toBeDefined(); |
| 58 | 58 | }); |
| 59 | 59 | }); |
| 60 | ||
| 61 | describe("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>( |
| 58 | 58 | ), |
| 59 | 59 | ]), |
| 60 | 60 | ); |
| 61 | return sortNewestFirst(threads, timestamps); | |
| 62 | } | |
| 63 | ||
| 64 | type 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. */ | |
| 74 | export 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 | ||
| 91 | function sortNewestFirst<T extends Pick<EnvironmentThreadShell, "id" | "environmentId">>( | |
| 92 | threads: readonly T[], | |
| 93 | timestamps: ReadonlyMap<T, number>, | |
| 94 | ): T[] { | |
| 61 | 95 | return [...threads].sort( |
| 62 | 96 | (left, right) => |
| 63 | 97 | timestamps.get(right)! - timestamps.get(left)! || |
packages/contracts/src/orchestrationV2.ts+6| ... | ... | @@ -1747,6 +1747,11 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ |
| 1747 | 1747 | pendingRuntimeRequest: Schema.NullOr(OrchestrationV2PendingRuntimeRequestSummary), |
| 1748 | 1748 | latestVisibleMessage: Schema.NullOr(OrchestrationV2LatestVisibleMessageSummary), |
| 1749 | 1749 | 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)), | |
| 1750 | 1755 | hasActionableProposedPlan: Schema.Boolean, |
| 1751 | 1756 | // Normalized post-settlement background work for sidebar Waiting pills. |
| 1752 | 1757 | // Empty when the latest root run is still active or no pending work remains. |
| ... | ... | @@ -2269,6 +2274,7 @@ export const OrchestrationV2ThreadShellJson = OrchestrationV2ThreadShell.mapFiel |
| 2269 | 2274 | pendingRuntimeRequest: Schema.NullOr(OrchestrationV2PendingRuntimeRequestSummaryJson), |
| 2270 | 2275 | latestVisibleMessage: Schema.NullOr(OrchestrationV2LatestVisibleMessageSummaryJson), |
| 2271 | 2276 | latestUserMessageAt: Schema.NullOr(Schema.DateTimeUtcFromString), |
| 2277 | latestUserAuthoredMessageAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), | |
| 2272 | 2278 | createdAt: Schema.DateTimeUtcFromString, |
| 2273 | 2279 | updatedAt: Schema.DateTimeUtcFromString, |
| 2274 | 2280 | archivedAt: Schema.NullOr(Schema.DateTimeUtcFromString), |