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
6 changes: 5 additions & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ import { useLiveDeviceState } from "../../state/device";
import { threadDevicePreviews } from "../devices/threadDevicePreviews";
import type { QueuedThreadMessage } from "../../state/thread-outbox-model";
import { scopedThreadKey } from "../../lib/scopedEntities";
import { useDelayedStatus } from "../../lib/useDelayedStatus";
import type {
PendingApproval,
PendingUserInput,
Expand Down Expand Up @@ -487,7 +488,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
// The raw sync status enters "synchronizing" on every full fetch, cached or
// not. Whether messages are already on screen decides the pill label: no
// data yet → "Loading messages", cached data reconciling → "Syncing".
const threadSyncLabel = (() => {
const realThreadSyncLabel = (() => {
switch (props.threadSyncStatus) {
case "empty":
case "cached":
Expand All @@ -500,6 +501,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
return null;
}
})();
// Opening a running thread resyncs for a few frames. The pill shows the
// sync label only when the sync lasts, so it does not flash before the timer.
const threadSyncLabel = useDelayedStatus(selectedThreadKey, realThreadSyncLabel);
// One floating pill above the composer: it reads the connection phase while
// disconnected, the sync state while messages load, then the working timer
// once the feed is settled.
Expand Down
8 changes: 2 additions & 6 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
import {
sortActiveThreadsByOrderKey,
resolveSettledThreadTimestamp,
sortPinnedThreadsByOrderKey,
sortSettledThreads,
} from "@t3tools/client-runtime/state/thread-sort";
import type { EnvironmentId, ProjectId } from "@t3tools/contracts";

Expand Down Expand Up @@ -483,11 +483,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
18 changes: 18 additions & 0 deletions apps/mobile/src/lib/useDelayedStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { createDelayedStatus, type ShownStatus } from "@t3tools/client-runtime/delayed-status";
import { useEffect, useState } from "react";

/**
* Returns `value` only once it has lasted past the show delay, then holds it
* for a minimum time, so a short status never flashes. `key` is what the
* status belongs to (for example a thread). A new key drops it at once.
* Web has the same hook.
*/
export function useDelayedStatus<A>(key: string, value: A | null): A | null {
const [shown, setShown] = useState<ShownStatus<A> | null>(null);
const [status] = useState(() => createDelayedStatus<A>(setShown));
useEffect(() => () => status.dispose(), [status]);
useEffect(() => {
status.update(key, value);
}, [status, key, value]);
return shown?.key === key ? shown.value : null;
}
4 changes: 2 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10542,8 +10542,8 @@ export default function ChatView(props: ChatViewProps) {
addFolders: (folders, unresolvedCount) =>
composerRef.current?.addDroppedFolders(folders, unresolvedCount),
});
const externalComposerDrawerAttached =
composerBannerItems.length > 0 || Boolean(threadSyncPhase && !activeEnvironmentUnavailable);
// The composer adds its own shown sync row to this; see `shownSyncPhase` there.
const externalComposerDrawerAttached = composerBannerItems.length > 0;

return (
<div className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
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 @@ -34,7 +34,6 @@ import {
shouldClearThreadSelectionOnMouseDown,
shouldRecedeSidebarThread,
sortLogicalProjectsForSidebar,
sortSettledThreadsForSidebar,
resolveSidebarDropTarget,
pinOrderKeyBetween,
planPinnedReorder,
Expand All @@ -53,6 +52,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 @@ -1773,12 +1773,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 @@ -1793,7 +1793,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 @@ -1903,74 +1903,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 @@ -2541,6 +2473,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 @@ -21,10 +21,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 @@ -1035,20 +1033,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 @@ -1251,13 +1235,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 @@ -23,7 +23,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 @@ -189,7 +192,6 @@ import {
sidebarMarkerId,
sortLogicalProjectsForSidebar,
sortPinnedThreadsForSidebar,
sortSettledThreadsForSidebar,
sortThreadsForSidebar,
useRetainedValue,
useSidebarRowSubscriptionLease,
Expand Down Expand Up @@ -3083,7 +3085,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 @@ -3927,7 +3929,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
Loading
Loading