Skip to content
Open
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
5 changes: 4 additions & 1 deletion apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/shell";
import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
import { resolveSettledTimestamp } from "@t3tools/client-runtime/state/thread-sort";
import type { MenuAction } from "@react-native-menu/menu";
import { memo, useCallback, useEffect, useMemo, type ComponentProps } from "react";
import { Platform, Pressable, useWindowDimensions, View } from "react-native";
Expand Down Expand Up @@ -555,7 +556,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
)}
style={{ fontFamily: MONO_FONT }}
>
{relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt)}
{/* Settled rows read "how long ago did this wrap up", matching
their sort key so label and order can't disagree. */}
{relativeTime(resolveSettledTimestamp(thread) ?? thread.createdAt)}
</Text>
</View>
</Pressable>
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,9 @@ describe("buildThreadListV2Items settled paging", () => {
id: ThreadId.make(`settled-${index}`),
title: `Settled ${index}`,
settledOverride: "settled",
settledAt: NOW,
// Explicit settle stamps order the tail (shared resolver): most
// recently settled first.
settledAt: `2026-06-01T1${index}:00:00.000Z`,
latestUserMessageAt: `2026-06-01T0${index}:00:00.000Z`,
// A turn adopted the message (same requestedAt): without it the
// thread reads as a queued turn start, which never settles.
Expand Down
51 changes: 19 additions & 32 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import {
effectiveSnoozed,
threadWokeAt,
} from "@t3tools/client-runtime/state/thread-settled";
import { sortThreadsForListV2 } from "@t3tools/client-runtime/state/thread-sort";
import {
sortSettledThreadsForListV2,
sortThreadsForListV2,
threadListV2Priority,
} from "@t3tools/client-runtime/state/thread-sort";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
Expand Down Expand Up @@ -71,17 +75,6 @@ function parseTimestampMs(isoDate: string): number {
return Number.isNaN(parsed) ? 0 : parsed;
}

/** First VALID timestamp wins: a present-yet-malformed string falls through
to the next candidate rather than sinking the row to the epoch. */
function firstValidTimestampMs(...candidates: ReadonlyArray<string | null | undefined>): number {
for (const candidate of candidates) {
if (candidate == null) continue;
const parsed = Date.parse(candidate);
if (!Number.isNaN(parsed)) return parsed;
}
return 0;
}

export interface ThreadListV2Item {
readonly thread: EnvironmentThreadShell;
readonly variant: "card" | "slim";
Expand Down Expand Up @@ -203,7 +196,7 @@ export function buildThreadListV2Items(input: {

const active: EnvironmentThreadShell[] = [];
const settled: EnvironmentThreadShell[] = [];
const wokeThreadKeys = new Set<string>();
const wokeAtByThreadKey = new Map<string, string>();
let snoozedCount = 0;
let nextSnoozeWakeAt: string | null = null;
for (const thread of input.threads) {
Expand Down Expand Up @@ -231,11 +224,12 @@ export function buildThreadListV2Items(input: {
input.changeRequestStateByKey?.get(`${thread.environmentId}:${thread.id}`) ?? null;
const wokeAt = supportsSnooze ? threadWokeAt(thread, { now: snoozeNow }) : null;
if (wokeAt !== null) {
wokeThreadKeys.add(
wokeAtByThreadKey.set(
threadSearchMatchKey({
environmentId: thread.environmentId,
threadId: thread.id,
}),
wokeAt,
);
}
// Visibility parity with web: a snoozed thread leaves the list until it
Expand Down Expand Up @@ -267,25 +261,18 @@ export function buildThreadListV2Items(input: {
}
}

const orderedActive = sortThreadsForListV2(active, (thread) => {
if (
wokeThreadKeys.has(
threadSearchMatchKey({
environmentId: thread.environmentId,
threadId: thread.id,
}),
)
) {
return "woke";
}
if (thread.settledOverride === "active") return "unsettled";
return "default";
});
const orderedSettled = [...settled].sort(
(left, right) =>
firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) -
firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt),
const orderedActive = sortThreadsForListV2(active, (thread) =>
threadListV2Priority(thread, {
wokeAt:
wokeAtByThreadKey.get(
threadSearchMatchKey({
environmentId: thread.environmentId,
threadId: thread.id,
}),
) ?? null,
}),
);
const orderedSettled = sortSettledThreadsForListV2(settled);
const settledLimit = input.settledLimit ?? Number.POSITIVE_INFINITY;
const visibleSettled =
orderedSettled.length > settledLimit ? orderedSettled.slice(0, settledLimit) : orderedSettled;
Expand Down
69 changes: 0 additions & 69 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import {
shouldNavigateAfterProjectRemoval,
shouldClearThreadSelectionOnMouseDown,
sortLogicalProjectsForSidebar,
sortSettledThreadsForSidebarV2,
sortProjectsForSidebar,
sortScopedProjectsForSidebar,
THREAD_JUMP_HINT_SHOW_DELAY_MS,
Expand Down Expand Up @@ -711,74 +710,6 @@ describe("isSidebarV2ThreadWoke", () => {
});
});

describe("sortSettledThreadsForSidebarV2", () => {
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 = sortSettledThreadsForSidebarV2([
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 = sortSettledThreadsForSidebarV2([
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 = sortSettledThreadsForSidebarV2([
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 = sortSettledThreadsForSidebarV2([
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
45 changes: 0 additions & 45 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,51 +490,6 @@ export function isSidebarV2ThreadWoke(input: {
return Number.isNaN(lastVisitedAtMs) || lastVisitedAtMs < wokeAtMs;
}

type SettledTimestampInput = Pick<
SidebarThreadSummary,
"settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt"
>;

/** The timestamp a settled row sorts and labels by: settledAt when stamped
(explicit settles), otherwise last activity — the same candidates
threadLastActivityAt feeds the auto-settle window (user message plus all
latestTurn stamps), so a thread whose last activity was a turn completion
doesn't sort by an older message time. updatedAt is the final net. */
export function resolveSettledTimestamp(thread: SettledTimestampInput): string | null {
const settledAt = firstValidTimestamp(thread.settledAt);
if (settledAt !== null) return settledAt;
let latest: string | null = null;
let latestMs = Number.NEGATIVE_INFINITY;
for (const candidate of [
thread.latestUserMessageAt,
thread.latestTurn?.requestedAt,
thread.latestTurn?.startedAt,
thread.latestTurn?.completedAt,
]) {
if (candidate == null) continue;
const parsed = Date.parse(candidate);
if (!Number.isNaN(parsed) && parsed > latestMs) {
latest = candidate;
latestMs = parsed;
}
}
return latest ?? firstValidTimestamp(thread.updatedAt);
}

// Settled rows are history, so they order by when the work ENDED, not when
// the thread was created or last touched.
export function sortSettledThreadsForSidebarV2<
T extends SettledTimestampInput & { readonly id: string },
>(threads: readonly T[]): T[] {
const timestampMs = (thread: T) => {
const timestamp = resolveSettledTimestamp(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
29 changes: 17 additions & 12 deletions apps/web/src/components/SidebarV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import {
effectiveSnoozed,
threadWokeAt,
} from "@t3tools/client-runtime/state/thread-settled";
import { sortThreadsForListV2 } from "@t3tools/client-runtime/state/thread-sort";
import {
resolveSettledTimestamp,
sortSettledThreadsForListV2,
sortThreadsForListV2,
threadListV2Priority,
} from "@t3tools/client-runtime/state/thread-sort";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models";
import {
scopeProjectRef,
Expand Down Expand Up @@ -115,12 +120,10 @@ import {
isTrailingDoubleClick,
orderItemsByPreferredIds,
resolveAdjacentThreadId,
resolveSettledTimestamp,
resolveSidebarV2Status,
resolveWorkingStartedAt,
shouldNavigateAfterProjectRemoval,
sortLogicalProjectsForSidebar,
sortSettledThreadsForSidebarV2,
} from "./Sidebar.logic";
import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic";
import {
Expand Down Expand Up @@ -1473,7 +1476,7 @@ export default function SidebarV2() {
const active: EnvironmentThreadShell[] = [];
const snoozed: EnvironmentThreadShell[] = [];
const settled: EnvironmentThreadShell[] = [];
const wokeThreadKeys = new Set<string>();
const wokeAtByThreadKey = new Map<string, string>();
for (const thread of visible) {
// Threads on servers without the settlement capability (old server,
// or descriptor not loaded yet) never classify as settled: the user
Expand All @@ -1486,7 +1489,7 @@ export default function SidebarV2() {
const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
const changeRequestState = changeRequestStateByKey.get(threadKey) ?? null;
const wokeAt = supportsSnooze ? threadWokeAt(thread, { now: preciseNow }) : null;
if (wokeAt !== null) wokeThreadKeys.add(threadKey);
if (wokeAt !== null) wokeAtByThreadKey.set(threadKey, wokeAt);
// Snooze outranks settled classification: an explicitly snoozed thread
// belongs to the shelf even if it would also auto-settle (the shelf's
// wake time is a stronger statement about when it matters again).
Expand All @@ -1507,19 +1510,21 @@ export default function SidebarV2() {
}
}
return {
activeThreads: sortThreadsForListV2(active, (thread) => {
const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
if (wokeThreadKeys.has(threadKey)) return "woke";
if (thread.settledOverride === "active") return "unsettled";
return "default";
}),
activeThreads: sortThreadsForListV2(active, (thread) =>
threadListV2Priority(thread, {
wokeAt:
wokeAtByThreadKey.get(
scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
) ?? null,
}),
),
// Soonest wake first: "what comes back next" is the shelf's question.
snoozedThreads: snoozed.toSorted(
(left, right) =>
firstValidTimestampMs(left.snoozedUntil ?? null) -
firstValidTimestampMs(right.snoozedUntil ?? null),
),
settledThreads: sortSettledThreadsForSidebarV2(settled),
settledThreads: sortSettledThreadsForListV2(settled),
snoozeNow: preciseNow,
};
}, [
Expand Down
Loading
Loading