diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index c7835f61db7..bd9b05649ab 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -716,6 +716,8 @@ export function HomeScreen(props: HomeScreenProps) { )?.driver ?? null } environmentLabel={ + // Multi-server: always label the host. Matches classic list + web + // recency rows so cross-project cards stay attributable. Object.keys(props.savedConnectionsById).length > 1 ? (props.savedConnectionsById[item.thread.environmentId]?.environmentLabel ?? null) : null @@ -809,7 +811,12 @@ export function HomeScreen(props: HomeScreenProps) { thread={thread} projectTitle={item.projectTitle} environmentLabel={ - props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null + // Prefer showing server when multi-env OR recency/flat grouping + // so threads from different hosts aren't ambiguous. + Object.keys(props.savedConnectionsById).length > 1 || + usesFlatThreadGrouping(props.threadGrouping) + ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) + : null } projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? @@ -838,6 +845,12 @@ export function HomeScreen(props: HomeScreenProps) { onGroupAction={updateGroupDisplay} /> ); + default: { + // Exhaustiveness guard: unknown item types must not throw on open. + const _exhaustive: never = item; + void _exhaustive; + return null; + } } }, [ @@ -853,6 +866,7 @@ export function HomeScreen(props: HomeScreenProps) { props.onSelectPendingTask, props.onSelectThread, props.savedConnectionsById, + props.threadGrouping, settledThreadKeys, settlementEnvironmentIds, updateGroupDisplay, @@ -1135,6 +1149,10 @@ export function HomeScreen(props: HomeScreenProps) { data={listLayout.items} renderItem={renderItem} keyExtractor={keyExtractor} + // Mixed item types (headers, section-headers, threads, show-more) + // must not share recycle pools — missing this crashes / blanks rows + // once shells load (sidebar already passes getItemType). + getItemType={(item) => item.type} itemsAreEqual={homeListItemsAreEqual} drawDistance={500} estimatedItemSize={ESTIMATED_THREAD_ROW_HEIGHT} diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts index 06eddb9d86f..a4a171d12a0 100644 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ b/apps/mobile/src/features/home/homeListItems.test.ts @@ -254,15 +254,16 @@ describe("buildHomeListLayout", () => { expect(layout.stickyHeaderIndices).toEqual([]); }); - it("builds recency sections with Today / Older headers", () => { - const now = new Date(2026, 2, 15, 12, 0, 0); - const startToday = new Date(2026, 2, 15).getTime(); - const todayIso = new Date(startToday + 3_600_000).toISOString(); - const olderIso = new Date(startToday - 40 * 24 * 60 * 60 * 1000).toISOString(); - const todayThread = { - ...makeThread("t-today", ProjectId.make("alpha")), - updatedAt: todayIso, - latestUserMessageAt: todayIso, + it("builds recency sections with Last Hour / Older headers when multiple buckets", () => { + const now = new Date(2026, 2, 15, 14, 30, 0); + const lastHourIso = new Date(now.getTime() - 5 * 60_000).toISOString(); + const olderIso = new Date( + new Date(2026, 2, 15).getTime() - 40 * 24 * 60 * 60 * 1000, + ).toISOString(); + const lastHourThread = { + ...makeThread("t-hour", ProjectId.make("alpha")), + updatedAt: lastHourIso, + latestUserMessageAt: lastHourIso, }; const olderThread = { ...makeThread("t-older", ProjectId.make("beta")), @@ -272,7 +273,7 @@ describe("buildHomeListLayout", () => { const layout = buildHomeRecentListLayout({ pendingTasks: [], entries: [ - { thread: todayThread, projectTitle: "Alpha" }, + { thread: lastHourThread, projectTitle: "Alpha" }, { thread: olderThread, projectTitle: "Beta" }, ], groupByRecency: true, @@ -284,8 +285,38 @@ describe("buildHomeListLayout", () => { "section-header", "thread", ]); - expect(layout.items[0]).toMatchObject({ type: "section-header", title: "Today" }); + expect(layout.items[0]).toMatchObject({ type: "section-header", title: "Last Hour" }); expect(layout.items[2]).toMatchObject({ type: "section-header", title: "Older" }); expect(layout.stickyHeaderIndices).toEqual([0, 2]); }); + + it("omits recency section headers when all threads share one bucket", () => { + const now = new Date(2026, 2, 15, 14, 30, 0); + const lastHourIso = new Date(now.getTime() - 5 * 60_000).toISOString(); + const layout = buildHomeRecentListLayout({ + pendingTasks: [], + entries: [ + { + thread: { + ...makeThread("t1", ProjectId.make("alpha")), + updatedAt: lastHourIso, + latestUserMessageAt: lastHourIso, + }, + projectTitle: "Alpha", + }, + { + thread: { + ...makeThread("t2", ProjectId.make("beta")), + updatedAt: lastHourIso, + latestUserMessageAt: lastHourIso, + }, + projectTitle: "Beta", + }, + ], + groupByRecency: true, + now, + }); + expect(itemTypes(layout.items)).toEqual(["thread", "thread"]); + expect(layout.stickyHeaderIndices).toEqual([]); + }); }); diff --git a/apps/mobile/src/features/home/homeListItems.ts b/apps/mobile/src/features/home/homeListItems.ts index 68264a9bc5d..f3b7ce65f3b 100644 --- a/apps/mobile/src/features/home/homeListItems.ts +++ b/apps/mobile/src/features/home/homeListItems.ts @@ -1,5 +1,8 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { groupSortedThreadsByRecency } from "@t3tools/client-runtime/state/thread-recency-groups"; +import { + groupSortedThreadsByRecency, + shouldShowRecencySectionHeaders, +} from "@t3tools/client-runtime/state/thread-recency-groups"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import type { HomeThreadGroup } from "./homeThreadList"; @@ -230,7 +233,9 @@ export function buildHomeListLayout(input: { * Each thread row can carry a project title for multi-project context. * Callers apply hide-settled / project filters before building entries. * - * When `groupByRecency` is true, inserts Today / Yesterday / … section headers. + * When `groupByRecency` is true and more than one non-empty bucket has threads, + * inserts Last Hour / Earlier Today / Yesterday / … section headers. A single + * bucket renders flat (headers would only repeat the obvious). */ export function buildHomeRecentListLayout(input: { readonly pendingTasks: ReadonlyArray; @@ -244,6 +249,20 @@ export function buildHomeRecentListLayout(input: { const items: HomeListItem[] = []; const stickyHeaderIndices: number[] = []; + const appendFlatThreads = () => { + const total = input.pendingTasks.length + input.entries.length; + for (const [index, entry] of input.entries.entries()) { + const absoluteIndex = input.pendingTasks.length + index; + items.push({ + type: "thread", + key: `thread:${entry.thread.environmentId}:${entry.thread.id}`, + thread: entry.thread, + projectTitle: entry.projectTitle, + isLast: absoluteIndex === total - 1, + }); + } + }; + for (const [index, pendingTask] of input.pendingTasks.entries()) { items.push({ type: "pending-task", @@ -257,17 +276,7 @@ export function buildHomeRecentListLayout(input: { } if (input.groupByRecency !== true) { - const total = input.pendingTasks.length + input.entries.length; - for (const [index, entry] of input.entries.entries()) { - const absoluteIndex = input.pendingTasks.length + index; - items.push({ - type: "thread", - key: `thread:${entry.thread.environmentId}:${entry.thread.id}`, - thread: entry.thread, - projectTitle: entry.projectTitle, - isLast: absoluteIndex === total - 1, - }); - } + appendFlatThreads(); return { items, stickyHeaderIndices: [] }; } @@ -282,6 +291,23 @@ export function buildHomeRecentListLayout(input: { input.now, ); + // One non-empty bucket (or none): no section headers. + if (!shouldShowRecencySectionHeaders(recencyGroups)) { + // Pending isLast was computed assuming multi-bucket; fix for flat path. + if (input.pendingTasks.length > 0) { + const lastPendingIndex = input.pendingTasks.length - 1; + const lastPending = items[lastPendingIndex]; + if (lastPending?.type === "pending-task") { + items[lastPendingIndex] = { + ...lastPending, + isLast: input.entries.length === 0, + }; + } + } + appendFlatThreads(); + return { items, stickyHeaderIndices: [] }; + } + for (const [groupIndex, group] of recencyGroups.entries()) { stickyHeaderIndices.push(items.length); items.push({ diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 5e1bcd05690..5228aa83a2b 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1086,7 +1086,9 @@ function ThreadNavigationSidebarPane( thread={thread} projectTitle={item.projectTitle} environmentLabel={ - savedConnectionsById[thread.environmentId]?.environmentLabel ?? null + Object.keys(savedConnectionsById).length > 1 || showFlatThreadList + ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) + : null } projectCwd={ projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? @@ -1120,6 +1122,8 @@ function ThreadNavigationSidebarPane( onGroupAction={updateGroupDisplay} /> ); + default: + return null; } }, [ @@ -1135,6 +1139,7 @@ function ThreadNavigationSidebarPane( projectCwdByKey, projectTitleByProjectKey, props.onNewThreadInProject, + showFlatThreadList, props.selectedThreadKey, props.width, savedConnectionsById, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 62793422e4e..6877fbb7266 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -6,11 +6,15 @@ import { CloudIcon, ContainerIcon, EllipsisVerticalIcon, + FolderIcon, FolderPlusIcon, Globe2Icon, + LayersIcon, + ListFilterIcon, LoaderIcon, PinIcon, SearchIcon, + ServerIcon, SettingsIcon, SquarePenIcon, TerminalIcon, @@ -162,7 +166,16 @@ import { DialogTitle, } from "./ui/dialog"; import { Input } from "./ui/input"; -import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; +import { + Menu, + MenuCheckboxItem, + MenuGroup, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuTrigger, +} from "./ui/menu"; import { NumberField, NumberFieldDecrement, @@ -213,7 +226,6 @@ import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useNowMinute } from "~/hooks/useNowMinute"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; -import { ListEnvironmentFilterControl } from "./ListEnvironmentFilterControl"; import { DEFAULT_HIDE_SETTLED_PROJECTS, DEFAULT_HIDE_SETTLED_RECENT, @@ -237,16 +249,22 @@ import { WebListModeSchema, WebThreadGroupingSchema, defaultThreadGroupingFromLegacyModeStorage, + isAllEnvironmentsSelected, + isEnvironmentSelected, isWebListMode, isWebThreadGrouping, matchesEnvironmentFilter, resolveSelectedEnvironmentIds, + toggleEnvironmentId, usesFlatThreadGrouping, usesProjectThreadGrouping, type WebListMode, type WebThreadGrouping, } from "./listEnvironmentFilter"; -import { groupSortedThreadsByRecency } from "@t3tools/client-runtime/state/thread-recency-groups"; +import { + groupSortedThreadsByRecency, + shouldShowRecencySectionHeaders, +} from "@t3tools/client-runtime/state/thread-recency-groups"; import { Toggle, ToggleGroup } from "./ui/toggle-group"; import { primaryServerKeybindingsAtom } from "../state/server"; import { @@ -3403,8 +3421,26 @@ const SidebarRecentThreadRow = memo(function SidebarRecentThreadRow(props: { {thread.title} )} - - {project.displayName} + {/* Cross-project recency rows: project · server, matching mobile + + Sidebar V2's environment context (icon when remote). */} + + {project.displayName} + {environment?.label ? ( + <> + + · + + + {isRemoteThread ? ( + + ) : null} + {environment.label} + + + ) : null} @@ -3530,7 +3566,9 @@ const SidebarRecentThreadRow = memo(function SidebarRecentThreadRow(props: { ) : null}
- {isRemoteThread && !isDesktopLocalThread ? ( + {/* Trailing remote cue kept for parity with project-thread rows; + subtitle already names the server when the label is available. */} + {isRemoteThread && !isDesktopLocalThread && !environment?.label ? ( - {environment?.label ?? "Remote"} + Remote ) : null} void; @@ -3660,6 +3701,7 @@ const SidebarRecentThreads = memo(function SidebarRecentThreads(props: { const recencyGroups = groupSortedThreadsByRecency( props.recentThreads.map((entry) => entry.thread), ); + const showSectionHeaders = shouldShowRecencySectionHeaders(recencyGroups); const entryByThreadKey = new Map( props.recentThreads.map((entry) => [ scopedThreadKey(scopeThreadRef(entry.thread.environmentId, entry.thread.id)), @@ -3667,6 +3709,17 @@ const SidebarRecentThreads = memo(function SidebarRecentThreads(props: { ]), ); + // Single non-empty bucket: skip headers (e.g. everything is "Last Hour"). + if (!showSectionHeaders) { + return ( + + + {props.recentThreads.map(renderThreadRow)} + + + ); + } + return ( <> {recencyGroups.map((group) => ( @@ -3749,27 +3802,22 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( const showProjectGroups = showThreadListChrome && usesProjectThreadGrouping(threadGrouping); const showFlatOrRecencyList = showThreadListChrome && usesFlatThreadGrouping(threadGrouping); - const projectFilterItems = useMemo( - () => [ - { value: LIST_PROJECT_FILTER_ALL, label: "All projects" }, - ...projectFilterOptions.map((project) => ({ - value: project.projectKey, - label: project.displayName, - })), - ], - [projectFilterOptions], - ); const selectedProjectFilterValue = selectedProjectFilterKey !== null && projectFilterOptions.some((project) => project.projectKey === selectedProjectFilterKey) ? selectedProjectFilterKey : LIST_PROJECT_FILTER_ALL; - const selectedProjectFilterSnapshot = - selectedProjectFilterValue === LIST_PROJECT_FILTER_ALL - ? null - : (projectFilterOptions.find( - (project) => project.projectKey === selectedProjectFilterValue, - ) ?? null); + + // Dot on the filter button when anything is non-default (active filters / + // non-default grouping or hide-settled). Matches Sidebar V2 “scoped” cues. + const defaultHideSettled = usesProjectThreadGrouping(threadGrouping) + ? DEFAULT_HIDE_SETTLED_PROJECTS + : DEFAULT_HIDE_SETTLED_RECENT; + const listOptionsActive = + !isAllEnvironmentsSelected(selectedEnvironmentIds) || + selectedProjectFilterKey !== null || + threadGrouping !== DEFAULT_WEB_THREAD_GROUPING || + hideSettledThreads !== defaultHideSettled; const handleProjectSortOrderChange = useCallback( (sortOrder: SidebarProjectSortOrder) => { @@ -3814,9 +3862,11 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( -
+ {/* Compact chrome (Sidebar V2–style): one slim row instead of stacked + full-width filters that ate vertical space. */} +
))} - {showThreadListChrome ? ( - <> - - + + + } > - {WEB_THREAD_GROUPING_LABELS[threadGrouping]} - - - - -
- Group threads -
- { - if (isWebThreadGrouping(value)) { - onThreadGroupingChange(value); - } - }} - > - {WEB_THREAD_GROUPINGS.map((grouping) => ( + + {listOptionsActive ? ( + + ) : null} + + View & filters + + + +
+ Group threads +
+ { + if (isWebThreadGrouping(value)) { + onThreadGroupingChange(value); + } + }} + > + {WEB_THREAD_GROUPINGS.map((grouping) => ( + + + {grouping === "recency" ? ( + + ) : grouping === "project" ? ( + + ) : ( + + )} + {WEB_THREAD_GROUPING_LABELS[grouping]} + + + ))} + +
+ + {projectFilterOptions.length > 0 ? ( + <> + + +
+ Project +
+ { + onSelectedProjectFilterKeyChange( + value === LIST_PROJECT_FILTER_ALL ? null : (value as string), + ); + }} + > - {WEB_THREAD_GROUPING_LABELS[grouping]} + + + All projects + + {projectFilterOptions.map((project) => ( + + + + {project.displayName} + + + ))} + +
+ + ) : null} + + {environmentFilterOptions.length > 1 ? ( + <> + + +
+ Environment +
+ onSelectedEnvironmentIdsChange([])} + > + All environments + + {environmentFilterOptions.map((environment) => ( + { + onSelectedEnvironmentIdsChange( + toggleEnvironmentId( + selectedEnvironmentIds, + environment.environmentId, + ), + ); + }} + > + {environment.label} + ))} -
-
-
-
- {projectFilterOptions.length > 0 ? ( - - ) : null} - - + Hide settled + + + ) : null}
diff --git a/packages/client-runtime/src/state/threadRecencyGroups.test.ts b/packages/client-runtime/src/state/threadRecencyGroups.test.ts index 5f208476432..805efebdbe1 100644 --- a/packages/client-runtime/src/state/threadRecencyGroups.test.ts +++ b/packages/client-runtime/src/state/threadRecencyGroups.test.ts @@ -4,35 +4,26 @@ import { getThreadRecencyBucketId, groupSortedThreadsByRecency, groupThreadsByRecency, + shouldShowRecencySectionHeaders, startOfLocalDay, THREAD_RECENCY_BUCKET_LABELS, } from "./threadRecencyGroups.ts"; -/** Local calendar fixture; Date APIs are intentional for bucket tests. */ -function localDate( - year: number, - monthIndex: number, - day: number, - hours = 0, - minutes = 0, - seconds = 0, -): Date { - // @effect-diagnostics-next-line globalDate:off - return new Date(year, monthIndex, day, hours, minutes, seconds); -} - -function addMs(date: Date, ms: number): Date { - // @effect-diagnostics-next-line globalDate:off - return new Date(date.getTime() + ms); -} - describe("getThreadRecencyBucketId", () => { - // Fixed local morning so calendar math is stable across CI timezones. - const now = localDate(2026, 2, 15, 14, 30, 0); // 2026-03-15 local + // Fixed local afternoon so last-hour and earlier-today both fit in the day. + const now = new Date(2026, 2, 15, 14, 30, 0); // 2026-03-15 14:30 local + + it("splits today into last hour vs earlier today", () => { + const startToday = startOfLocalDay(now).getTime(); + const nowMs = now.getTime(); + expect(getThreadRecencyBucketId(nowMs - 5 * 60_000, now)).toBe("last_hour"); + expect(getThreadRecencyBucketId(nowMs - 59 * 60_000, now)).toBe("last_hour"); + expect(getThreadRecencyBucketId(nowMs - 61 * 60_000, now)).toBe("earlier_today"); + expect(getThreadRecencyBucketId(startToday + 60_000, now)).toBe("earlier_today"); + }); - it("classifies today, yesterday, previous 7, previous 30, and older", () => { + it("classifies yesterday, previous 7, previous 30, and older", () => { const startToday = startOfLocalDay(now).getTime(); - expect(getThreadRecencyBucketId(startToday + 60_000, now)).toBe("today"); expect(getThreadRecencyBucketId(startToday - 60_000, now)).toBe("yesterday"); expect(getThreadRecencyBucketId(startToday - 3 * 24 * 60 * 60 * 1000, now)).toBe( "previous_7_days", @@ -49,18 +40,19 @@ describe("getThreadRecencyBucketId", () => { }); describe("groupThreadsByRecency", () => { - const now = localDate(2026, 2, 15, 12, 0, 0); + const now = new Date(2026, 2, 15, 14, 30, 0); const startToday = startOfLocalDay(now).getTime(); + const nowMs = now.getTime(); it("returns only non-empty buckets in order with labels", () => { const threads = [ - { id: "t1", at: startToday + 1_000 }, - { id: "t2", at: startToday - 1_000 }, + { id: "t1", at: nowMs - 10 * 60_000 }, + { id: "t2", at: startToday + 60_000 }, { id: "t3", at: startToday - 40 * 24 * 60 * 60 * 1000 }, ]; const groups = groupThreadsByRecency(threads, (t) => t.at, now); - expect(groups.map((g) => g.id)).toEqual(["today", "yesterday", "older"]); - expect(groups[0]?.label).toBe(THREAD_RECENCY_BUCKET_LABELS.today); + expect(groups.map((g) => g.id)).toEqual(["last_hour", "earlier_today", "older"]); + expect(groups[0]?.label).toBe(THREAD_RECENCY_BUCKET_LABELS.last_hour); expect(groups[0]?.threads.map((t) => t.id)).toEqual(["t1"]); expect(groups[1]?.threads.map((t) => t.id)).toEqual(["t2"]); expect(groups[2]?.threads.map((t) => t.id)).toEqual(["t3"]); @@ -68,29 +60,62 @@ describe("groupThreadsByRecency", () => { it("preserves input order within a bucket", () => { const threads = [ - { id: "newer", at: startToday + 5_000 }, - { id: "older-today", at: startToday + 1_000 }, + { id: "newer", at: nowMs - 1_000 }, + { id: "older-hour", at: nowMs - 10 * 60_000 }, ]; const groups = groupThreadsByRecency(threads, (t) => t.at, now); expect(groups).toHaveLength(1); - expect(groups[0]?.threads.map((t) => t.id)).toEqual(["newer", "older-today"]); + expect(groups[0]?.id).toBe("last_hour"); + expect(groups[0]?.threads.map((t) => t.id)).toEqual(["newer", "older-hour"]); + }); + + it("omits empty buckets", () => { + const groups = groupThreadsByRecency( + [{ id: "only", at: nowMs - 2 * 60_000 }], + (t) => t.at, + now, + ); + expect(groups.map((g) => g.id)).toEqual(["last_hour"]); + }); +}); + +describe("shouldShowRecencySectionHeaders", () => { + it("is false for a single non-empty bucket", () => { + expect( + shouldShowRecencySectionHeaders([ + { id: "last_hour", label: "Last Hour", threads: [{ id: "a" }] }, + ]), + ).toBe(false); + }); + + it("is true when two or more buckets have threads", () => { + expect( + shouldShowRecencySectionHeaders([ + { id: "last_hour", label: "Last Hour", threads: [{ id: "a" }] }, + { id: "yesterday", label: "Yesterday", threads: [{ id: "b" }] }, + ]), + ).toBe(true); + }); + + it("is false for an empty groups array", () => { + expect(shouldShowRecencySectionHeaders([])).toBe(false); }); }); describe("groupSortedThreadsByRecency", () => { it("groups using activity timestamps from ThreadSortInput", () => { - const now = localDate(2026, 2, 15, 12, 0, 0); + const now = new Date(2026, 2, 15, 14, 30, 0); const startToday = startOfLocalDay(now); - const todayIso = addMs(startToday, 3_600_000).toISOString(); - const olderIso = addMs(startToday, -40 * 24 * 60 * 60 * 1000).toISOString(); + const lastHourIso = new Date(now.getTime() - 5 * 60_000).toISOString(); + const olderIso = new Date(startToday.getTime() - 40 * 24 * 60 * 60 * 1000).toISOString(); const groups = groupSortedThreadsByRecency( [ { id: "a", - createdAt: todayIso, - updatedAt: todayIso, - latestUserMessageAt: todayIso, + createdAt: lastHourIso, + updatedAt: lastHourIso, + latestUserMessageAt: lastHourIso, }, { id: "b", @@ -102,7 +127,7 @@ describe("groupSortedThreadsByRecency", () => { now, ); - expect(groups.map((g) => g.id)).toEqual(["today", "older"]); + expect(groups.map((g) => g.id)).toEqual(["last_hour", "older"]); expect(groups[0]?.threads[0]?.id).toBe("a"); expect(groups[1]?.threads[0]?.id).toBe("b"); }); diff --git a/packages/client-runtime/src/state/threadRecencyGroups.ts b/packages/client-runtime/src/state/threadRecencyGroups.ts index d6a8037b77b..548ac83a053 100644 --- a/packages/client-runtime/src/state/threadRecencyGroups.ts +++ b/packages/client-runtime/src/state/threadRecencyGroups.ts @@ -1,18 +1,20 @@ import { getThreadSortTimestamp, type ThreadSortInput } from "./threadSort.ts"; /** - * Calendar buckets for cross-project thread lists grouped by recency - * (Today / Yesterday / Previous 7 Days / …), matching common chat-session UX. + * Calendar / activity buckets for cross-project thread lists grouped by recency. + * "Today" is split so busy days stay scannable (Last hour vs Earlier today). */ export type ThreadRecencyBucketId = - | "today" + | "last_hour" + | "earlier_today" | "yesterday" | "previous_7_days" | "previous_30_days" | "older"; export const THREAD_RECENCY_BUCKET_ORDER = [ - "today", + "last_hour", + "earlier_today", "yesterday", "previous_7_days", "previous_30_days", @@ -20,49 +22,42 @@ export const THREAD_RECENCY_BUCKET_ORDER = [ ] as const satisfies readonly ThreadRecencyBucketId[]; export const THREAD_RECENCY_BUCKET_LABELS: Record = { - today: "Today", + last_hour: "Last Hour", + earlier_today: "Earlier Today", yesterday: "Yesterday", previous_7_days: "Previous 7 Days", previous_30_days: "Previous 30 Days", older: "Older", }; -const MS_PER_DAY = 24 * 60 * 60 * 1000; - -function makeDateFromEpochMs(ms: number): Date { - // @effect-diagnostics-next-line globalDate:off - return new Date(ms); -} - -function makeLocalDate(year: number, monthIndex: number, day: number): Date { - // @effect-diagnostics-next-line globalDate:off - return new Date(year, monthIndex, day); -} - -function makeNow(): Date { - // @effect-diagnostics-next-line globalDate:off - return new Date(); -} +const MS_PER_HOUR = 60 * 60 * 1000; +const MS_PER_DAY = 24 * MS_PER_HOUR; export function startOfLocalDay(date: Date): Date { - return makeLocalDate(date.getFullYear(), date.getMonth(), date.getDate()); + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); } /** - * Classify an activity timestamp into a recency bucket using local calendar days. + * Classify an activity timestamp into a recency bucket using local calendar + * days plus a rolling last-hour window for dense "today" lists. * `timestampMs` should be a finite epoch millis (activity / updated time). */ export function getThreadRecencyBucketId( timestampMs: number, - now: Date = makeNow(), + now: Date = new Date(), ): ThreadRecencyBucketId { if (!Number.isFinite(timestampMs)) { return "older"; } + const nowMs = now.getTime(); const startToday = startOfLocalDay(now).getTime(); + if (timestampMs >= startToday) { - return "today"; + if (timestampMs >= nowMs - MS_PER_HOUR) { + return "last_hour"; + } + return "earlier_today"; } const startYesterday = startToday - MS_PER_DAY; @@ -89,14 +84,26 @@ export interface ThreadRecencyGroup { readonly threads: readonly T[]; } +/** + * Whether recency section headers should render. Empty buckets are already + * omitted from `groups`; a single remaining bucket is still noise (e.g. every + * thread is "Last Hour"), so callers should render a flat list in that case. + */ +export function shouldShowRecencySectionHeaders( + groups: ReadonlyArray>, +): boolean { + return groups.length > 1; +} + /** * Partition already-sorted threads into non-empty recency groups. * Preserves input order within each bucket (callers should sort first). + * Empty buckets are never returned. */ export function groupThreadsByRecency( threads: readonly T[], getTimestampMs: (thread: T) => number, - now: Date = makeNow(), + now: Date = new Date(), ): ReadonlyArray> { const buckets = new Map(); for (const id of THREAD_RECENCY_BUCKET_ORDER) { @@ -127,7 +134,7 @@ export function groupThreadsByRecency( */ export function groupSortedThreadsByRecency( threads: readonly T[], - now: Date = makeNow(), + now: Date = new Date(), ): ReadonlyArray> { return groupThreadsByRecency( threads,