| author | |
| committer | |
| log | ca6416ec2d1d194738bc78a81df4b82a75cb72e2 |
| tree | 34e10d2ed9956c4088506ba654cb86ca2e38a39b |
| parent | 4a4c6dd2adc350a68ba18bb28b24b5a7e4660dab |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com>7 files changed, 226 insertions(+), 37 deletions(-)
apps/web/src/performance.bench.ts created+65| ... | ... | @@ -0,0 +1,65 @@ |
| 1 | import { EventId, ProjectId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts"; | |
| 2 | import { | |
| 3 | getLatestThreadForProject, | |
| 4 | sortActiveThreadsByOrderKey, | |
| 5 | sortPinnedThreadsByOrderKey, | |
| 6 | sortThreads, | |
| 7 | } from "@t3tools/client-runtime/state/thread-sort"; | |
| 8 | import { formatHourShort, formatRelativeHourShort } from "@t3tools/shared/usageFormat"; | |
| 9 | import { bench, describe } from "vite-plus/test"; | |
| 10 | ||
| 11 | import { deriveActivePlanState } from "./session-logic"; | |
| 12 | ||
| 13 | const projectId = ProjectId.make("benchmark-project"); | |
| 14 | const turnId = TurnId.make("benchmark-turn"); | |
| 15 | const start = Date.parse("2026-08-11T00:00:00.000Z"); | |
| 16 | const threads = Array.from({ length: 1_000 }, (_, index) => { | |
| 17 | const timestamp = new Date(start + ((index * 997) % 1_000) * 60_000).toISOString(); | |
| 18 | return { | |
| 19 | id: `thread-${index}`, | |
| 20 | projectId, | |
| 21 | archivedAt: null, | |
| 22 | createdAt: timestamp, | |
| 23 | updatedAt: timestamp, | |
| 24 | latestUserMessageAt: timestamp, | |
| 25 | unsettledAt: null, | |
| 26 | }; | |
| 27 | }); | |
| 28 | const activities: OrchestrationThreadActivity[] = Array.from({ length: 500 }, (_, index) => ({ | |
| 29 | id: EventId.make(`activity-${index}`), | |
| 30 | turnId, | |
| 31 | sequence: index, | |
| 32 | createdAt: new Date(start + index * 1_000).toISOString(), | |
| 33 | kind: index % 100 === 0 ? "turn.plan.updated" : "tool.completed", | |
| 34 | summary: "Benchmark activity", | |
| 35 | tone: "info", | |
| 36 | payload: index % 100 === 0 ? { plan: [{ step: "Run checks", status: "inProgress" }] } : {}, | |
| 37 | })); | |
| 38 | const hours = Array.from({ length: 24 }, (_, index) => | |
| 39 | new Date(start + index * 3_600_000).toISOString(), | |
| 40 | ); | |
| 41 | const referenceTime = "2026-08-12T00:00:00.000Z"; | |
| 42 | ||
| 43 | describe("client performance", () => { | |
| 44 | bench("sort 1000 threads by recent activity", () => { | |
| 45 | sortThreads(threads, "updated_at"); | |
| 46 | }); | |
| 47 | bench("sort 1000 active threads", () => { | |
| 48 | sortActiveThreadsByOrderKey(threads); | |
| 49 | }); | |
| 50 | bench("sort 1000 keyless pinned threads", () => { | |
| 51 | sortPinnedThreadsByOrderKey(threads); | |
| 52 | }); | |
| 53 | bench("select latest project thread from 1000 threads", () => { | |
| 54 | getLatestThreadForProject(threads, projectId, "updated_at"); | |
| 55 | }); | |
| 56 | bench("derive plan from 500 activities with 5 plan updates", () => { | |
| 57 | deriveActivePlanState(activities, turnId); | |
| 58 | }); | |
| 59 | bench("format 24 hourly usage labels and tooltips", () => { | |
| 60 | hours.map((hour) => [ | |
| 61 | formatHourShort(hour, "America/New_York"), | |
| 62 | formatRelativeHourShort(hour, referenceTime, "America/New_York"), | |
| 63 | ]); | |
| 64 | }); | |
| 65 | }); |
apps/web/src/session-logic.test.ts+27| ... | ... | @@ -62,6 +62,33 @@ function makeActivity(overrides: { |
| 62 | 62 | } |
| 63 | 63 | |
| 64 | 64 | describe("deriveActivePlanState", () => { |
| 65 | it("orders plan snapshots by sequence while ignoring unrelated activities", () => { | |
| 66 | const activities = Object.freeze([ | |
| 67 | makeActivity({ | |
| 68 | id: "completed", | |
| 69 | kind: "turn.plan.updated", | |
| 70 | turnId: "turn-1", | |
| 71 | sequence: 3, | |
| 72 | createdAt: "2026-02-23T00:00:05.000Z", | |
| 73 | payload: { plan: [{ step: "Check", status: "completed" }] }, | |
| 74 | }), | |
| 75 | makeActivity({ sequence: 4, kind: "tool.completed" }), | |
| 76 | makeActivity({ | |
| 77 | id: "started", | |
| 78 | kind: "turn.plan.updated", | |
| 79 | turnId: "turn-1", | |
| 80 | sequence: 1, | |
| 81 | payload: { plan: [{ step: "Check", status: "inProgress" }] }, | |
| 82 | }), | |
| 83 | makeActivity({ sequence: 2, kind: "context-window.updated" }), | |
| 84 | ]); | |
| 85 | expect(deriveActivePlanState(activities, TurnId.make("turn-1"))?.steps).toEqual([ | |
| 86 | { step: "Check", status: "completed", durationMs: 5_000 }, | |
| 87 | ]); | |
| 88 | expect(activities[0]?.id).toBe("completed"); | |
| 89 | expect(deriveActivePlanState([makeActivity({ kind: "tool.completed" })], undefined)).toBeNull(); | |
| 90 | }); | |
| 91 | ||
| 65 | 92 | it("returns the latest plan update for the active turn", () => { |
| 66 | 93 | const activities: OrchestrationThreadActivity[] = [ |
| 67 | 94 | makeActivity({ |
apps/web/src/session-logic.ts+3-2| ... | ... | @@ -326,8 +326,9 @@ export function deriveActivePlanState( |
| 326 | 326 | activities: ReadonlyArray<OrchestrationThreadActivity>, |
| 327 | 327 | latestTurnId: TurnId | undefined, |
| 328 | 328 | ): ActivePlanState | null { |
| 329 | const ordered = [...activities].toSorted(compareActivitiesByOrder); | |
| 330 | const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated"); | |
| 329 | const allPlanActivities = activities | |
| 330 | .filter((activity) => activity.kind === "turn.plan.updated") | |
| 331 | .sort(compareActivitiesByOrder); | |
| 331 | 332 | // Prefer plan from the current turn; fall back to the most recent plan from any turn |
| 332 | 333 | // so that TodoWrite tasks persist across follow-up messages. |
| 333 | 334 | const latest = Option.firstSomeOf([ |
packages/client-runtime/src/state/threadSort.test.ts+45| ... | ... | @@ -1,7 +1,9 @@ |
| 1 | import { ProjectId } from "@t3tools/contracts"; | |
| 1 | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 3 | |
| 3 | 4 | import { |
| 4 | 5 | generateSpreadPinOrderKeys, |
| 6 | getLatestThreadForProject, | |
| 5 | 7 | pinOrderKeyBetween, |
| 6 | 8 | planPinnedMove, |
| 7 | 9 | planPinnedReorder, |
| ... | ... | @@ -58,6 +60,22 @@ describe("resolveSettledThreadTimestamp", () => { |
| 58 | 60 | }); |
| 59 | 61 | |
| 60 | 62 | describe("sortThreads", () => { |
| 63 | it.each(["created_at", "updated_at"] as const)( | |
| 64 | "preserves references, input order and descending id ties for %s", | |
| 65 | (sortOrder) => { | |
| 66 | const threads = Object.freeze([ | |
| 67 | makeThread({ id: "a" }), | |
| 68 | makeThread({ id: "z" }), | |
| 69 | makeThread({ id: "invalid-a", createdAt: "invalid", updatedAt: "invalid" }), | |
| 70 | makeThread({ id: "invalid-z", createdAt: "invalid", updatedAt: "invalid" }), | |
| 71 | ]); | |
| 72 | const sorted = sortThreads(threads, sortOrder); | |
| 73 | expect(sorted).toEqual([threads[1], threads[0], threads[3], threads[2]]); | |
| 74 | expect(sorted[0]).toBe(threads[1]); | |
| 75 | expect(threads[0]?.id).toBe("a"); | |
| 76 | }, | |
| 77 | ); | |
| 78 | ||
| 61 | 79 | it("falls back to updatedAt and createdAt when latestUserMessageAt is invalid and there are no messages", () => { |
| 62 | 80 | const sorted = sortThreads( |
| 63 | 81 | [ |
| ... | ... | @@ -112,6 +130,33 @@ describe("sortThreads", () => { |
| 112 | 130 | }); |
| 113 | 131 | }); |
| 114 | 132 | |
| 133 | describe("getLatestThreadForProject", () => { | |
| 134 | it.each(["created_at", "updated_at"] as const)( | |
| 135 | "matches the first sorted eligible thread for %s", | |
| 136 | (sortOrder) => { | |
| 137 | const projectId = ProjectId.make("project"); | |
| 138 | const threads = [ | |
| 139 | { ...makeThread({ id: "a" }), projectId, archivedAt: null }, | |
| 140 | { ...makeThread({ id: "z" }), projectId, archivedAt: null }, | |
| 141 | { ...makeThread({ id: "zz" }), projectId, archivedAt: "2026-03-10T00:00:00Z" }, | |
| 142 | { ...makeThread({ id: "zzz" }), projectId: ProjectId.make("other"), archivedAt: null }, | |
| 143 | ]; | |
| 144 | expect(getLatestThreadForProject(threads, projectId, sortOrder)).toBe(threads[1]); | |
| 145 | expect(getLatestThreadForProject([], projectId, sortOrder)).toBeNull(); | |
| 146 | expect(getLatestThreadForProject(threads, ProjectId.make("missing"), sortOrder)).toBeNull(); | |
| 147 | const invalid = threads.slice(0, 2).map((thread) => ({ | |
| 148 | ...thread, | |
| 149 | createdAt: "invalid", | |
| 150 | updatedAt: "invalid", | |
| 151 | })); | |
| 152 | expect(getLatestThreadForProject(invalid, projectId, sortOrder)).toBe(invalid[1]); | |
| 153 | expect( | |
| 154 | getLatestThreadForProject([threads[1]!, { ...threads[1]! }], projectId, sortOrder), | |
| 155 | ).toBe(threads[1]); | |
| 156 | }, | |
| 157 | ); | |
| 158 | }); | |
| 159 | ||
| 115 | 160 | describe("planPinnedReorder with hidden rows", () => { |
| 116 | 161 | it("keeps hidden slots available when inserting between visible neighbors", () => { |
| 117 | 162 | const midpoint = pinOrderKeyBetween("f", "t")!; |
packages/client-runtime/src/state/threadSort.ts+40-30| ... | ... | @@ -1,7 +1,5 @@ |
| 1 | 1 | import type { OrchestrationThreadShell, ProjectId } from "@t3tools/contracts"; |
| 2 | 2 | import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; |
| 3 | import * as Arr from "effect/Array"; | |
| 4 | import * as Order from "effect/Order"; | |
| 5 | 3 | |
| 6 | 4 | export interface ThreadSortInput { |
| 7 | 5 | readonly createdAt: string; |
| ... | ... | @@ -121,19 +119,15 @@ export function sortThreads<T extends { readonly id: string } & ThreadSortInput> |
| 121 | 119 | threads: readonly T[], |
| 122 | 120 | sortOrder: SidebarThreadSortOrder, |
| 123 | 121 | ): T[] { |
| 124 | return Arr.sort( | |
| 125 | threads, | |
| 126 | Order.mapInput( | |
| 127 | Order.Struct({ | |
| 128 | timestamp: Order.flip(Order.Number), | |
| 129 | id: Order.flip(Order.String), | |
| 130 | }), | |
| 131 | (thread: T) => ({ | |
| 132 | timestamp: getThreadSortTimestamp(thread, sortOrder), | |
| 133 | id: thread.id, | |
| 134 | }), | |
| 135 | ), | |
| 136 | ); | |
| 122 | if (threads.length < 2) return [...threads]; | |
| 123 | return threads | |
| 124 | .map((thread) => ({ thread, timestamp: getThreadSortTimestamp(thread, sortOrder) })) | |
| 125 | .sort( | |
| 126 | (left, right) => | |
| 127 | right.timestamp - left.timestamp || | |
| 128 | (left.thread.id < right.thread.id ? 1 : left.thread.id > right.thread.id ? -1 : 0), | |
| 129 | ) | |
| 130 | .map(({ thread }) => thread); | |
| 137 | 131 | } |
| 138 | 132 | |
| 139 | 133 | export function getLatestThreadForProject< |
| ... | ... | @@ -143,12 +137,21 @@ export function getLatestThreadForProject< |
| 143 | 137 | readonly archivedAt: string | null; |
| 144 | 138 | } & ThreadSortInput, |
| 145 | 139 | >(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null { |
| 146 | return ( | |
| 147 | sortThreads( | |
| 148 | threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null), | |
| 149 | sortOrder, | |
| 150 | )[0] ?? null | |
| 151 | ); | |
| 140 | let latest: T | null = null; | |
| 141 | let latestTimestamp = Number.NEGATIVE_INFINITY; | |
| 142 | for (const thread of threads) { | |
| 143 | if (thread.projectId !== projectId || thread.archivedAt !== null) continue; | |
| 144 | const timestamp = getThreadSortTimestamp(thread, sortOrder); | |
| 145 | if ( | |
| 146 | latest === null || | |
| 147 | timestamp > latestTimestamp || | |
| 148 | (timestamp === latestTimestamp && thread.id > latest.id) | |
| 149 | ) { | |
| 150 | latest = thread; | |
| 151 | latestTimestamp = timestamp; | |
| 152 | } | |
| 153 | } | |
| 154 | return latest; | |
| 152 | 155 | } |
| 153 | 156 | |
| 154 | 157 | // ── Pinned reorder: fractional index keys ────────────────────────────── |
| ... | ... | @@ -290,6 +293,7 @@ export function sortPinnedThreadsByOrderKey< |
| 290 | 293 | readonly environmentId?: string | undefined; |
| 291 | 294 | }, |
| 292 | 295 | >(threads: readonly T[]): T[] { |
| 296 | if (threads.length < 2) return [...threads]; | |
| 293 | 297 | const keyed: T[] = []; |
| 294 | 298 | const keyless: T[] = []; |
| 295 | 299 | for (const thread of threads) { |
| ... | ... | @@ -303,14 +307,13 @@ export function sortPinnedThreadsByOrderKey< |
| 303 | 307 | const rightKey = right.pinOrderKey!; |
| 304 | 308 | return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : identityTiebreak(left, right); |
| 305 | 309 | }); |
| 306 | keyless.sort((left, right) => { | |
| 307 | const leftMs = Date.parse(left.createdAt); | |
| 308 | const rightMs = Date.parse(right.createdAt); | |
| 309 | return ( | |
| 310 | (Number.isNaN(rightMs) ? 0 : rightMs) - (Number.isNaN(leftMs) ? 0 : leftMs) || | |
| 311 | identityTiebreak(left, right) | |
| 312 | ); | |
| 313 | }); | |
| 310 | const timestamps = new Map( | |
| 311 | keyless.map((thread) => [thread, toSortableTimestamp(thread.createdAt) ?? 0]), | |
| 312 | ); | |
| 313 | keyless.sort( | |
| 314 | (left, right) => | |
| 315 | timestamps.get(right)! - timestamps.get(left)! || identityTiebreak(left, right), | |
| 316 | ); | |
| 314 | 317 | return [...keyed, ...keyless]; |
| 315 | 318 | } |
| 316 | 319 | |
| ... | ... | @@ -325,6 +328,13 @@ export function sortActiveThreadsByOrderKey< |
| 325 | 328 | readonly environmentId?: string | undefined; |
| 326 | 329 | }, |
| 327 | 330 | >(threads: readonly T[]): T[] { |
| 331 | if (threads.length < 2) return [...threads]; | |
| 332 | const timestamps = new Map<T, number>(); | |
| 333 | for (const thread of threads) { | |
| 334 | if (thread.activeOrderKey == null) { | |
| 335 | timestamps.set(thread, activeThreadAnchorTimestampMs(thread)); | |
| 336 | } | |
| 337 | } | |
| 328 | 338 | return [...threads].sort((left, right) => { |
| 329 | 339 | const leftKey = left.activeOrderKey; |
| 330 | 340 | const rightKey = right.activeOrderKey; |
| ... | ... | @@ -334,7 +344,7 @@ export function sortActiveThreadsByOrderKey< |
| 334 | 344 | if (leftKey != null && rightKey != null) { |
| 335 | 345 | order = leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; |
| 336 | 346 | } else { |
| 337 | order = activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left); | |
| 347 | order = timestamps.get(right)! - timestamps.get(left)!; | |
| 338 | 348 | } |
| 339 | 349 | return ( |
| 340 | 350 | order || |
packages/shared/src/usageFormat.test.ts+24| ... | ... | @@ -10,6 +10,30 @@ import { |
| 10 | 10 | } from "./usageFormat.ts"; |
| 11 | 11 | |
| 12 | 12 | describe("hourly usage formatting", () => { |
| 13 | it("keeps requested zones separate when formatting repeated calls", () => { | |
| 14 | const instant = "2026-08-11T12:37:00.000Z"; | |
| 15 | for (const zone of ["UTC", "America/New_York", "Asia/Kathmandu", "UTC"]) { | |
| 16 | expect(formatHourShort(instant, zone)).toBe( | |
| 17 | new Intl.DateTimeFormat("en-US", { timeZone: zone, hour: "numeric" }).format( | |
| 18 | new Date(instant), | |
| 19 | ), | |
| 20 | ); | |
| 21 | } | |
| 22 | expect(() => formatHourShort(instant, "Etc/Unknown")).toThrow(RangeError); | |
| 23 | expect(formatHourShort("invalid", "UTC")).toBe("invalid"); | |
| 24 | }); | |
| 25 | ||
| 26 | it("uses the current system zone when no zone is supplied", () => { | |
| 27 | try { | |
| 28 | vi.stubEnv("TZ", "UTC"); | |
| 29 | expect(formatHourShort("2026-08-11T12:37:00.000Z")).toBe("12 PM"); | |
| 30 | vi.stubEnv("TZ", "America/New_York"); | |
| 31 | expect(formatHourShort("2026-08-11T12:37:00.000Z")).toBe("8 AM"); | |
| 32 | } finally { | |
| 33 | vi.unstubAllEnvs(); | |
| 34 | } | |
| 35 | }); | |
| 36 | ||
| 13 | 37 | it("enumerates 24 fixed buckets across a rolling window", () => { |
| 14 | 38 | const hours = enumerateHourStarts("2026-08-10T12:37:00.000Z", "2026-08-11T12:37:00.000Z"); |
| 15 | 39 |
packages/shared/src/usageFormat.ts+22-5| ... | ... | @@ -82,6 +82,23 @@ export function enumerateDays(sinceDay: string, untilDay: string): readonly stri |
| 82 | 82 | |
| 83 | 83 | const HOUR_MS = 60 * 60 * 1000; |
| 84 | 84 | |
| 85 | const dateTimeFormatters = new Map<string, Intl.DateTimeFormat>(); | |
| 86 | ||
| 87 | function dateTimeFormatter( | |
| 88 | locale: string, | |
| 89 | options: Intl.DateTimeFormatOptions, | |
| 90 | ): Intl.DateTimeFormat { | |
| 91 | if (options.timeZone === undefined) return new Intl.DateTimeFormat(locale, options); | |
| 92 | const key = JSON.stringify([locale, options]); | |
| 93 | let formatter = dateTimeFormatters.get(key); | |
| 94 | if (formatter === undefined) { | |
| 95 | formatter = new Intl.DateTimeFormat(locale, options); | |
| 96 | if (dateTimeFormatters.size >= 16) dateTimeFormatters.clear(); | |
| 97 | dateTimeFormatters.set(key, formatter); | |
| 98 | } | |
| 99 | return formatter; | |
| 100 | } | |
| 101 | ||
| 85 | 102 | /** Every fixed-duration bucket start in an hourly rolling window. */ |
| 86 | 103 | export function enumerateHourStarts(sinceTime: string, untilTime: string): readonly string[] { |
| 87 | 104 | const starts: string[] = []; |
| ... | ... | @@ -105,11 +122,11 @@ export function formatHourShort(hourStart: string, timeZone?: string): string { |
| 105 | 122 | const instant = new Date(hourStart); |
| 106 | 123 | if (Number.isNaN(instant.getTime())) return hourStart; |
| 107 | 124 | const options = timeZone === undefined ? {} : { timeZone }; |
| 108 | const hourFormat = new Intl.DateTimeFormat("en-US", { | |
| 125 | const hourFormat = dateTimeFormatter("en-US", { | |
| 109 | 126 | ...options, |
| 110 | 127 | hour: "numeric", |
| 111 | 128 | }); |
| 112 | const wallHourFormat = new Intl.DateTimeFormat("en-CA", { | |
| 129 | const wallHourFormat = dateTimeFormatter("en-CA", { | |
| 113 | 130 | ...options, |
| 114 | 131 | year: "numeric", |
| 115 | 132 | month: "2-digit", |
| ... | ... | @@ -123,7 +140,7 @@ export function formatHourShort(hourStart: string, timeZone?: string): string { |
| 123 | 140 | ); |
| 124 | 141 | |
| 125 | 142 | if (!isRepeatedHour) return hourFormat.format(instant); |
| 126 | return new Intl.DateTimeFormat("en-US", { | |
| 143 | return dateTimeFormatter("en-US", { | |
| 127 | 144 | ...(timeZone === undefined ? {} : { timeZone }), |
| 128 | 145 | hour: "numeric", |
| 129 | 146 | timeZoneName: "short", |
| ... | ... | @@ -134,7 +151,7 @@ export function formatHourShort(hourStart: string, timeZone?: string): string { |
| 134 | 151 | export function formatDateTimeShort(instant: string, timeZone?: string): string { |
| 135 | 152 | const date = new Date(instant); |
| 136 | 153 | if (Number.isNaN(date.getTime())) return instant; |
| 137 | return new Intl.DateTimeFormat("en-US", { | |
| 154 | return dateTimeFormatter("en-US", { | |
| 138 | 155 | ...(timeZone === undefined ? {} : { timeZone }), |
| 139 | 156 | month: "short", |
| 140 | 157 | day: "numeric", |
| ... | ... | @@ -154,7 +171,7 @@ export function formatRelativeHourShort( |
| 154 | 171 | return formatDateTimeShort(hourStart, timeZone); |
| 155 | 172 | } |
| 156 | 173 | |
| 157 | const dayFormat = new Intl.DateTimeFormat("en-CA", { | |
| 174 | const dayFormat = dateTimeFormatter("en-CA", { | |
| 158 | 175 | ...(timeZone === undefined ? {} : { timeZone }), |
| 159 | 176 | year: "numeric", |
| 160 | 177 | month: "2-digit", |