diff --git a/apps/web/src/performance.bench.ts b/apps/web/src/performance.bench.ts new file mode 100644 index 000000000000..7b07099cee2f --- /dev/null +++ b/apps/web/src/performance.bench.ts @@ -0,0 +1,65 @@ +import { EventId, ProjectId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts"; +import { + getLatestThreadForProject, + sortActiveThreadsByOrderKey, + sortPinnedThreadsByOrderKey, + sortThreads, +} from "@t3tools/client-runtime/state/thread-sort"; +import { formatHourShort, formatRelativeHourShort } from "@t3tools/shared/usageFormat"; +import { bench, describe } from "vite-plus/test"; + +import { deriveActivePlanState } from "./session-logic"; + +const projectId = ProjectId.make("benchmark-project"); +const turnId = TurnId.make("benchmark-turn"); +const start = Date.parse("2026-08-11T00:00:00.000Z"); +const threads = Array.from({ length: 1_000 }, (_, index) => { + const timestamp = new Date(start + ((index * 997) % 1_000) * 60_000).toISOString(); + return { + id: `thread-${index}`, + projectId, + archivedAt: null, + createdAt: timestamp, + updatedAt: timestamp, + latestUserMessageAt: timestamp, + unsettledAt: null, + }; +}); +const activities: OrchestrationThreadActivity[] = Array.from({ length: 500 }, (_, index) => ({ + id: EventId.make(`activity-${index}`), + turnId, + sequence: index, + createdAt: new Date(start + index * 1_000).toISOString(), + kind: index % 100 === 0 ? "turn.plan.updated" : "tool.completed", + summary: "Benchmark activity", + tone: "info", + payload: index % 100 === 0 ? { plan: [{ step: "Run checks", status: "inProgress" }] } : {}, +})); +const hours = Array.from({ length: 24 }, (_, index) => + new Date(start + index * 3_600_000).toISOString(), +); +const referenceTime = "2026-08-12T00:00:00.000Z"; + +describe("client performance", () => { + bench("sort 1000 threads by recent activity", () => { + sortThreads(threads, "updated_at"); + }); + bench("sort 1000 active threads", () => { + sortActiveThreadsByOrderKey(threads); + }); + bench("sort 1000 keyless pinned threads", () => { + sortPinnedThreadsByOrderKey(threads); + }); + bench("select latest project thread from 1000 threads", () => { + getLatestThreadForProject(threads, projectId, "updated_at"); + }); + bench("derive plan from 500 activities with 5 plan updates", () => { + deriveActivePlanState(activities, turnId); + }); + bench("format 24 hourly usage labels and tooltips", () => { + hours.map((hour) => [ + formatHourShort(hour, "America/New_York"), + formatRelativeHourShort(hour, referenceTime, "America/New_York"), + ]); + }); +}); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 401934bce5ba..904688f49ab5 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -62,6 +62,33 @@ function makeActivity(overrides: { } describe("deriveActivePlanState", () => { + it("orders plan snapshots by sequence while ignoring unrelated activities", () => { + const activities = Object.freeze([ + makeActivity({ + id: "completed", + kind: "turn.plan.updated", + turnId: "turn-1", + sequence: 3, + createdAt: "2026-02-23T00:00:05.000Z", + payload: { plan: [{ step: "Check", status: "completed" }] }, + }), + makeActivity({ sequence: 4, kind: "tool.completed" }), + makeActivity({ + id: "started", + kind: "turn.plan.updated", + turnId: "turn-1", + sequence: 1, + payload: { plan: [{ step: "Check", status: "inProgress" }] }, + }), + makeActivity({ sequence: 2, kind: "context-window.updated" }), + ]); + expect(deriveActivePlanState(activities, TurnId.make("turn-1"))?.steps).toEqual([ + { step: "Check", status: "completed", durationMs: 5_000 }, + ]); + expect(activities[0]?.id).toBe("completed"); + expect(deriveActivePlanState([makeActivity({ kind: "tool.completed" })], undefined)).toBeNull(); + }); + it("returns the latest plan update for the active turn", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 0ffcc7fdd7c6..d3d81dde9c45 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -326,8 +326,9 @@ export function deriveActivePlanState( activities: ReadonlyArray, latestTurnId: TurnId | undefined, ): ActivePlanState | null { - const ordered = [...activities].toSorted(compareActivitiesByOrder); - const allPlanActivities = ordered.filter((activity) => activity.kind === "turn.plan.updated"); + const allPlanActivities = activities + .filter((activity) => activity.kind === "turn.plan.updated") + .sort(compareActivitiesByOrder); // Prefer plan from the current turn; fall back to the most recent plan from any turn // so that TodoWrite tasks persist across follow-up messages. const latest = Option.firstSomeOf([ diff --git a/packages/client-runtime/src/state/threadSort.test.ts b/packages/client-runtime/src/state/threadSort.test.ts index d9a2c124ee7d..0e1fba746cf3 100644 --- a/packages/client-runtime/src/state/threadSort.test.ts +++ b/packages/client-runtime/src/state/threadSort.test.ts @@ -1,7 +1,9 @@ +import { ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { generateSpreadPinOrderKeys, + getLatestThreadForProject, pinOrderKeyBetween, planPinnedMove, planPinnedReorder, @@ -58,6 +60,22 @@ describe("resolveSettledThreadTimestamp", () => { }); describe("sortThreads", () => { + it.each(["created_at", "updated_at"] as const)( + "preserves references, input order and descending id ties for %s", + (sortOrder) => { + const threads = Object.freeze([ + makeThread({ id: "a" }), + makeThread({ id: "z" }), + makeThread({ id: "invalid-a", createdAt: "invalid", updatedAt: "invalid" }), + makeThread({ id: "invalid-z", createdAt: "invalid", updatedAt: "invalid" }), + ]); + const sorted = sortThreads(threads, sortOrder); + expect(sorted).toEqual([threads[1], threads[0], threads[3], threads[2]]); + expect(sorted[0]).toBe(threads[1]); + expect(threads[0]?.id).toBe("a"); + }, + ); + it("falls back to updatedAt and createdAt when latestUserMessageAt is invalid and there are no messages", () => { const sorted = sortThreads( [ @@ -112,6 +130,33 @@ describe("sortThreads", () => { }); }); +describe("getLatestThreadForProject", () => { + it.each(["created_at", "updated_at"] as const)( + "matches the first sorted eligible thread for %s", + (sortOrder) => { + const projectId = ProjectId.make("project"); + const threads = [ + { ...makeThread({ id: "a" }), projectId, archivedAt: null }, + { ...makeThread({ id: "z" }), projectId, archivedAt: null }, + { ...makeThread({ id: "zz" }), projectId, archivedAt: "2026-03-10T00:00:00Z" }, + { ...makeThread({ id: "zzz" }), projectId: ProjectId.make("other"), archivedAt: null }, + ]; + expect(getLatestThreadForProject(threads, projectId, sortOrder)).toBe(threads[1]); + expect(getLatestThreadForProject([], projectId, sortOrder)).toBeNull(); + expect(getLatestThreadForProject(threads, ProjectId.make("missing"), sortOrder)).toBeNull(); + const invalid = threads.slice(0, 2).map((thread) => ({ + ...thread, + createdAt: "invalid", + updatedAt: "invalid", + })); + expect(getLatestThreadForProject(invalid, projectId, sortOrder)).toBe(invalid[1]); + expect( + getLatestThreadForProject([threads[1]!, { ...threads[1]! }], projectId, sortOrder), + ).toBe(threads[1]); + }, + ); +}); + describe("planPinnedReorder with hidden rows", () => { it("keeps hidden slots available when inserting between visible neighbors", () => { const midpoint = pinOrderKeyBetween("f", "t")!; diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index 3a4a9d284a18..93878310839f 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -1,7 +1,5 @@ import type { OrchestrationThreadShell, ProjectId } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; -import * as Arr from "effect/Array"; -import * as Order from "effect/Order"; export interface ThreadSortInput { readonly createdAt: string; @@ -121,19 +119,15 @@ export function sortThreads threads: readonly T[], sortOrder: SidebarThreadSortOrder, ): T[] { - return Arr.sort( - threads, - Order.mapInput( - Order.Struct({ - timestamp: Order.flip(Order.Number), - id: Order.flip(Order.String), - }), - (thread: T) => ({ - timestamp: getThreadSortTimestamp(thread, sortOrder), - id: thread.id, - }), - ), - ); + if (threads.length < 2) return [...threads]; + return threads + .map((thread) => ({ thread, timestamp: getThreadSortTimestamp(thread, sortOrder) })) + .sort( + (left, right) => + right.timestamp - left.timestamp || + (left.thread.id < right.thread.id ? 1 : left.thread.id > right.thread.id ? -1 : 0), + ) + .map(({ thread }) => thread); } export function getLatestThreadForProject< @@ -143,12 +137,21 @@ export function getLatestThreadForProject< readonly archivedAt: string | null; } & ThreadSortInput, >(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null { - return ( - sortThreads( - threads.filter((thread) => thread.projectId === projectId && thread.archivedAt === null), - sortOrder, - )[0] ?? null - ); + let latest: T | null = null; + let latestTimestamp = Number.NEGATIVE_INFINITY; + for (const thread of threads) { + if (thread.projectId !== projectId || thread.archivedAt !== null) continue; + const timestamp = getThreadSortTimestamp(thread, sortOrder); + if ( + latest === null || + timestamp > latestTimestamp || + (timestamp === latestTimestamp && thread.id > latest.id) + ) { + latest = thread; + latestTimestamp = timestamp; + } + } + return latest; } // ── Pinned reorder: fractional index keys ────────────────────────────── @@ -290,6 +293,7 @@ export function sortPinnedThreadsByOrderKey< readonly environmentId?: string | undefined; }, >(threads: readonly T[]): T[] { + if (threads.length < 2) return [...threads]; const keyed: T[] = []; const keyless: T[] = []; for (const thread of threads) { @@ -303,14 +307,13 @@ export function sortPinnedThreadsByOrderKey< const rightKey = right.pinOrderKey!; return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : identityTiebreak(left, right); }); - keyless.sort((left, right) => { - const leftMs = Date.parse(left.createdAt); - const rightMs = Date.parse(right.createdAt); - return ( - (Number.isNaN(rightMs) ? 0 : rightMs) - (Number.isNaN(leftMs) ? 0 : leftMs) || - identityTiebreak(left, right) - ); - }); + const timestamps = new Map( + keyless.map((thread) => [thread, toSortableTimestamp(thread.createdAt) ?? 0]), + ); + keyless.sort( + (left, right) => + timestamps.get(right)! - timestamps.get(left)! || identityTiebreak(left, right), + ); return [...keyed, ...keyless]; } @@ -325,6 +328,13 @@ export function sortActiveThreadsByOrderKey< readonly environmentId?: string | undefined; }, >(threads: readonly T[]): T[] { + if (threads.length < 2) return [...threads]; + const timestamps = new Map(); + for (const thread of threads) { + if (thread.activeOrderKey == null) { + timestamps.set(thread, activeThreadAnchorTimestampMs(thread)); + } + } return [...threads].sort((left, right) => { const leftKey = left.activeOrderKey; const rightKey = right.activeOrderKey; @@ -334,7 +344,7 @@ export function sortActiveThreadsByOrderKey< if (leftKey != null && rightKey != null) { order = leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; } else { - order = activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left); + order = timestamps.get(right)! - timestamps.get(left)!; } return ( order || diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts index fb231fbacb20..3e92d0b3cb85 100644 --- a/packages/shared/src/usageFormat.test.ts +++ b/packages/shared/src/usageFormat.test.ts @@ -10,6 +10,30 @@ import { } from "./usageFormat.ts"; describe("hourly usage formatting", () => { + it("keeps requested zones separate when formatting repeated calls", () => { + const instant = "2026-08-11T12:37:00.000Z"; + for (const zone of ["UTC", "America/New_York", "Asia/Kathmandu", "UTC"]) { + expect(formatHourShort(instant, zone)).toBe( + new Intl.DateTimeFormat("en-US", { timeZone: zone, hour: "numeric" }).format( + new Date(instant), + ), + ); + } + expect(() => formatHourShort(instant, "Etc/Unknown")).toThrow(RangeError); + expect(formatHourShort("invalid", "UTC")).toBe("invalid"); + }); + + it("uses the current system zone when no zone is supplied", () => { + try { + vi.stubEnv("TZ", "UTC"); + expect(formatHourShort("2026-08-11T12:37:00.000Z")).toBe("12 PM"); + vi.stubEnv("TZ", "America/New_York"); + expect(formatHourShort("2026-08-11T12:37:00.000Z")).toBe("8 AM"); + } finally { + vi.unstubAllEnvs(); + } + }); + it("enumerates 24 fixed buckets across a rolling window", () => { const hours = enumerateHourStarts("2026-08-10T12:37:00.000Z", "2026-08-11T12:37:00.000Z"); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index bd751829dd87..442687308323 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -82,6 +82,23 @@ export function enumerateDays(sinceDay: string, untilDay: string): readonly stri const HOUR_MS = 60 * 60 * 1000; +const dateTimeFormatters = new Map(); + +function dateTimeFormatter( + locale: string, + options: Intl.DateTimeFormatOptions, +): Intl.DateTimeFormat { + if (options.timeZone === undefined) return new Intl.DateTimeFormat(locale, options); + const key = JSON.stringify([locale, options]); + let formatter = dateTimeFormatters.get(key); + if (formatter === undefined) { + formatter = new Intl.DateTimeFormat(locale, options); + if (dateTimeFormatters.size >= 16) dateTimeFormatters.clear(); + dateTimeFormatters.set(key, formatter); + } + return formatter; +} + /** Every fixed-duration bucket start in an hourly rolling window. */ export function enumerateHourStarts(sinceTime: string, untilTime: string): readonly string[] { const starts: string[] = []; @@ -105,11 +122,11 @@ export function formatHourShort(hourStart: string, timeZone?: string): string { const instant = new Date(hourStart); if (Number.isNaN(instant.getTime())) return hourStart; const options = timeZone === undefined ? {} : { timeZone }; - const hourFormat = new Intl.DateTimeFormat("en-US", { + const hourFormat = dateTimeFormatter("en-US", { ...options, hour: "numeric", }); - const wallHourFormat = new Intl.DateTimeFormat("en-CA", { + const wallHourFormat = dateTimeFormatter("en-CA", { ...options, year: "numeric", month: "2-digit", @@ -123,7 +140,7 @@ export function formatHourShort(hourStart: string, timeZone?: string): string { ); if (!isRepeatedHour) return hourFormat.format(instant); - return new Intl.DateTimeFormat("en-US", { + return dateTimeFormatter("en-US", { ...(timeZone === undefined ? {} : { timeZone }), hour: "numeric", timeZoneName: "short", @@ -134,7 +151,7 @@ export function formatHourShort(hourStart: string, timeZone?: string): string { export function formatDateTimeShort(instant: string, timeZone?: string): string { const date = new Date(instant); if (Number.isNaN(date.getTime())) return instant; - return new Intl.DateTimeFormat("en-US", { + return dateTimeFormatter("en-US", { ...(timeZone === undefined ? {} : { timeZone }), month: "short", day: "numeric", @@ -154,7 +171,7 @@ export function formatRelativeHourShort( return formatDateTimeShort(hourStart, timeZone); } - const dayFormat = new Intl.DateTimeFormat("en-CA", { + const dayFormat = dateTimeFormatter("en-CA", { ...(timeZone === undefined ? {} : { timeZone }), year: "numeric", month: "2-digit",