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