Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
sortActiveThreadsByOrderKey,
resolveSettledThreadTimestamp,
sortPinnedThreadsByOrderKey,
sortSettledThreads,
} from "@t3tools/client-runtime/state/thread-sort";
import type { EnvironmentId, ProjectId } from "@t3tools/contracts";

Expand Down Expand Up @@ -606,11 +607,7 @@ export function buildThreadListV2Items(input: {
: orderedSnoozed.filter(
(thread) => `${thread.environmentId}:${thread.id}` === selectedThreadKey,
);
const orderedSettled = [...settled].sort(
(left, right) =>
parseTimestampMs(resolveSettledThreadTimestamp(right) ?? "") -
parseTimestampMs(resolveSettledThreadTimestamp(left) ?? ""),
);
const orderedSettled = sortSettledThreads(settled);
const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY;
const pagedSettled =
orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled;
Expand Down
121 changes: 48 additions & 73 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import {
shouldClearThreadSelectionOnMouseDown,
shouldRecedeSidebarThread,
sortLogicalProjectsForSidebar,
sortSettledThreadsForSidebar,
resolveSidebarDropTarget,
pinOrderKeyBetween,
planPinnedReorder,
Expand All @@ -51,6 +50,7 @@ import {
resolveSidebarDropVerb,
} from "./Sidebar.logic";
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort";
import {
EnvironmentId,
OrchestrationLatestTurn,
Expand Down Expand Up @@ -1690,12 +1690,12 @@ describe("applySidebarThreadDrop", () => {
};
const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt };
expect(preview).toEqual({ ...final, settledAt: now });
expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual([
expect(sortSettledThreads([existing, preview]).map((row) => row.id)).toEqual([
"dragged",
"newer",
]);
expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual(
sortSettledThreadsForSidebar([existing, final]).map((row) => row.id),
expect(sortSettledThreads([existing, preview]).map((row) => row.id)).toEqual(
sortSettledThreads([existing, final]).map((row) => row.id),
);
});

Expand All @@ -1710,7 +1710,7 @@ describe("applySidebarThreadDrop", () => {
const final = { ...source, snoozedAt: null, snoozedUntil: null };
const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt };
expect(preview).toEqual(final);
expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual([
expect(sortSettledThreads([existing, preview]).map((row) => row.id)).toEqual([
"newer",
"dragged",
]);
Expand Down Expand Up @@ -1820,74 +1820,6 @@ describe("sortPinnedThreadsForSidebar", () => {
});
});

describe("sortSettledThreadsForSidebar", () => {
const settled = (input: {
id: string;
settledAt?: string | null;
latestUserMessageAt?: string | null;
latestTurn?: OrchestrationLatestTurn | null;
updatedAt?: string;
}) => ({
id: input.id,
settledAt: input.settledAt ?? null,
latestUserMessageAt: input.latestUserMessageAt ?? null,
latestTurn: input.latestTurn ?? null,
updatedAt: input.updatedAt ?? "2026-03-09T09:00:00.000Z",
});

it("orders by settle time, most recently settled first", () => {
const sorted = sortSettledThreadsForSidebar([
settled({
id: "settled-first",
settledAt: "2026-03-09T10:00:00.000Z",
// Created/active later than the other thread: settle time must win.
latestUserMessageAt: "2026-03-09T09:59:00.000Z",
}),
settled({
id: "settled-last",
settledAt: "2026-03-09T12:00:00.000Z",
latestUserMessageAt: "2026-03-09T08:00:00.000Z",
}),
]);

expect(sorted.map((thread) => thread.id)).toEqual(["settled-last", "settled-first"]);
});

it("falls back to last activity for auto-settled threads without a settledAt stamp", () => {
const sorted = sortSettledThreadsForSidebar([
settled({ id: "auto-old", latestUserMessageAt: "2026-03-09T08:00:00.000Z" }),
settled({ id: "explicit", settledAt: "2026-03-09T10:00:00.000Z" }),
settled({ id: "auto-recent", latestUserMessageAt: "2026-03-09T11:00:00.000Z" }),
]);

expect(sorted.map((thread) => thread.id)).toEqual(["auto-recent", "explicit", "auto-old"]);
});

it("counts a turn completion as activity for auto-settled threads", () => {
// The message came in before the other thread's, but its turn finished
// after: completion time is the real "work ended" moment.
const sorted = sortSettledThreadsForSidebar([
settled({ id: "message-only", latestUserMessageAt: "2026-03-09T10:04:00.000Z" }),
settled({
id: "completed-later",
latestUserMessageAt: "2026-03-09T10:00:00.000Z",
latestTurn: makeLatestTurn({ completedAt: "2026-03-09T10:30:00.000Z" }),
}),
]);

expect(sorted.map((thread) => thread.id)).toEqual(["completed-later", "message-only"]);
});

it("breaks timestamp ties by id so the order is stable", () => {
const sorted = sortSettledThreadsForSidebar([
settled({ id: "b", settledAt: "2026-03-09T10:00:00.000Z" }),
settled({ id: "a", settledAt: "2026-03-09T10:00:00.000Z" }),
]);

expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]);
});
});

describe("resolveWorkingStartedAt", () => {
const session = {
threadId: ThreadId.make("thread-1"),
Expand Down Expand Up @@ -2375,6 +2307,49 @@ describe("sortProjectsForSidebar", () => {
]);
});

it.each(["updated_at", "created_at"] as const)(
"matches the per-comparison %s order on a shuffled list with ties",
(sortOrder) => {
const minute = (value: number) => `2026-03-09T10:0${value}:00.000Z`;
// (index * 7) % 24 scrambles the input order. Titles repeat, and
// projects 16-23 have no threads, so they use their own stamps.
const projects = Array.from({ length: 24 }, (_, index) => {
const n = (index * 7) % 24;
return makeProject({
id: ProjectId.make(`project-${n}`),
title: n % 2 === 0 ? "Alpha" : "Beta",
createdAt: minute(n % 3),
updatedAt: n % 5 === 0 ? "invalid" : minute(n % 2),
});
});
const threads = Array.from({ length: 48 }, (_, n) => ({
projectId: ProjectId.make(`project-${n % 16}`),
createdAt: minute(n % 6),
updatedAt: minute(n % 3),
latestUserMessageAt: n % 4 === 0 ? null : minute(n % 5),
}));
// The comparator this sort replaced: it walked each project's threads
// on every call.
const timestamp = (project: Project) =>
getProjectSortTimestamp(
project,
threads.filter((thread) => thread.projectId === project.id),
sortOrder,
);
const expected = projects.toSorted((left, right) => {
const rightTimestamp = timestamp(right);
const leftTimestamp = timestamp(left);
const byTimestamp =
rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1;
return (
byTimestamp || left.title.localeCompare(right.title) || left.id.localeCompare(right.id)
);
});

expect(sortProjectsForSidebar(projects, threads, sortOrder)).toEqual(expected);
},
);

it("returns the project timestamp when no threads are present", () => {
const timestamp = getProjectSortTimestamp(
makeProject({ updatedAt: "2026-03-09T10:10:00.000Z" }),
Expand Down
36 changes: 13 additions & 23 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,8 @@ import {
} from "@t3tools/client-runtime/state/thread-settled";
import {
getThreadSortTimestamp,
resolveSettledThreadTimestamp,
sortThreads,
toSortableTimestamp,
type SettledThreadTimestampInput,
type ThreadSortInput,
} from "../lib/threadSort";
import type { SidebarThreadSummary, Thread } from "../types";
Expand Down Expand Up @@ -937,20 +935,6 @@ export function reduceSidebarProjectScopeMenuState(
}
}

// Settled rows are history, so they order by when the work ENDED, not when
// the thread was created or last touched.
export function sortSettledThreadsForSidebar<
T extends SettledThreadTimestampInput & { readonly id: string },
>(threads: readonly T[]): T[] {
const timestampMs = (thread: T) => {
const timestamp = resolveSettledThreadTimestamp(thread);
return timestamp === null ? 0 : Date.parse(timestamp);
};
return [...threads].toSorted(
(left, right) => timestampMs(right) - timestampMs(left) || left.id.localeCompare(right.id),
);
}

/** The timestamp a working thread's elapsed label counts from: the running
turn's start (request time until adoption), falling back to the session's
last transition when the turn projection lags behind. Malformed
Expand Down Expand Up @@ -1136,13 +1120,19 @@ function sortProjectsByActivity<TProject extends SidebarProject>(
return [...projects];
}

return [...projects].toSorted((left, right) => {
const rightTimestamp = getProjectSortTimestamp(right, getProjectThreads(right), sortOrder);
const leftTimestamp = getProjectSortTimestamp(left, getProjectThreads(left), sortOrder);
const byTimestamp =
rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1;
return byTimestamp || compareTies(left, right);
});
// Each project's timestamp walks all of its threads, so compute it once
// per project instead of once per comparison.
return projects
.map((project) => ({
project,
timestamp: getProjectSortTimestamp(project, getProjectThreads(project), sortOrder),
}))
.sort((left, right) => {
const byTimestamp =
right.timestamp === left.timestamp ? 0 : right.timestamp > left.timestamp ? 1 : -1;
return byTimestamp || compareTies(left.project, right.project);
})
.map(({ project }) => project);
}

export function sortProjectsForSidebar<
Expand Down
10 changes: 6 additions & 4 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ import {
effectiveSnoozed,
threadWokeAt,
} from "@t3tools/client-runtime/state/thread-settled";
import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";
import {
resolveSettledThreadTimestamp,
sortSettledThreads,
} from "@t3tools/client-runtime/state/thread-sort";
import {
threadSearchMatchKey,
type EnvironmentThreadSearchMatch,
Expand Down Expand Up @@ -181,7 +184,6 @@ import {
sidebarMarkerId,
sortLogicalProjectsForSidebar,
sortPinnedThreadsForSidebar,
sortSettledThreadsForSidebar,
sortThreadsForSidebar,
useRetainedValue,
useSidebarRowSubscriptionLease,
Expand Down Expand Up @@ -2626,7 +2628,7 @@ export default function Sidebar() {
firstValidTimestampMs(left.snoozedUntil ?? null) -
firstValidTimestampMs(right.snoozedUntil ?? null),
),
settledThreads: sortSettledThreadsForSidebar(settled),
settledThreads: sortSettledThreads(settled),
snoozeNow: preciseNow,
};
}, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]);
Expand Down Expand Up @@ -3452,7 +3454,7 @@ export default function Sidebar() {
if (dragState === null || thread === undefined) return [];
const key = (candidate: EnvironmentThreadShell) =>
scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id));
return sortSettledThreadsForSidebar([
return sortSettledThreads([
...settledThreads.filter((candidate) => key(candidate) !== dragState.activeKey),
applySidebarThreadDrop(thread, "settled", dragState.occurredAt),
]).map(key);
Expand Down
2 changes: 0 additions & 2 deletions apps/web/src/lib/threadSort.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
export {
getLatestThreadForProject,
getThreadSortTimestamp,
resolveSettledThreadTimestamp,
sortThreads,
toSortableTimestamp,
type SettledThreadTimestampInput,
type ThreadSortInput,
} from "@t3tools/client-runtime/state/thread-sort";
Loading
Loading