diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts index 5a10fcb9d55..b81438b0fe3 100644 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ b/apps/mobile/src/features/home/homeListItems.test.ts @@ -57,18 +57,31 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell { function makeGroup(key: string, threadCount: number): HomeThreadGroup { const project = makeProject(key, key); + const threads = Array.from({ length: threadCount }, (_, index) => + makeThread(`${key}-thread-${index}`, project.id), + ); return { key, title: key, representative: project, projects: [project], pendingTasks: [], - threads: Array.from({ length: threadCount }, (_, index) => - makeThread(`${key}-thread-${index}`, project.id), - ), + threads, + // All threads inside the recency window, so the baseline stays at the + // initial page size and the pagination expectations below hold. + recentThreads: threads, }; } +function makeGroupWithRecentCount( + key: string, + threadCount: number, + recentCount: number, +): HomeThreadGroup { + const group = makeGroup(key, threadCount); + return { ...group, recentThreads: group.threads.slice(0, recentCount) }; +} + function itemTypes(items: ReadonlyArray): string[] { return items.map((item) => item.type); } @@ -190,4 +203,88 @@ describe("buildHomeListLayout", () => { expect(layout.stickyHeaderIndices).toEqual([0, 8]); expect(layout.items[8]).toMatchObject({ type: "header", isFirst: false }); }); + + it("uses the group's recent-thread count as the default baseline when smaller than the page size", () => { + // Only 2 of the 10 threads are "recent"; the rest should stay hidden + // behind a show-more row until the user asks for more, even though the + // page size constant (6) is larger than the recent count. + const layout = buildHomeListLayout({ + groups: [makeGroupWithRecentCount("alpha", 10, 2)], + displayStates: displayStates({}), + }); + + const threadItems = layout.items.filter((item) => item.type === "thread"); + expect(threadItems).toHaveLength(2); + expect(layout.items.at(-1)).toMatchObject({ + type: "show-more", + groupKey: "alpha", + hiddenCount: 8, + canShowLess: false, + }); + }); + + it("does not shrink the baseline below the page size when recent threads exceed it", () => { + // 9 of 10 threads are "recent", which is still capped at the initial + // page size constant rather than showing all 9 by default. + const layout = buildHomeListLayout({ + groups: [makeGroupWithRecentCount("alpha", 10, 9)], + displayStates: displayStates({}), + }); + + const threadItems = layout.items.filter((item) => item.type === "thread"); + expect(threadItems).toHaveLength(HOME_INITIAL_VISIBLE_THREADS); + expect(layout.items.at(-1)).toMatchObject({ + type: "show-more", + hiddenCount: 10 - HOME_INITIAL_VISIBLE_THREADS, + }); + }); + + it("shows no show-more row when the recent-thread baseline covers every thread", () => { + const layout = buildHomeListLayout({ + groups: [makeGroupWithRecentCount("alpha", 3, 3)], + displayStates: displayStates({}), + }); + + expect(itemTypes(layout.items)).toEqual(["header", "thread", "thread", "thread"]); + expect(layout.items.some((item) => item.type === "show-more")).toBe(false); + }); + + it("expands past a small recent-thread baseline once show-more is tapped", () => { + const group = makeGroupWithRecentCount("alpha", 10, 2); + + const expanded = buildHomeListLayout({ + groups: [group], + displayStates: displayStates({ + alpha: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"), + }), + }); + + // show-more adds HOME_SHOW_MORE_STEP on top of the initial page size + // constant, not the smaller recency baseline, so every thread is now + // visible even though the group only had 2 "recent" threads. + expect(expanded.items.filter((item) => item.type === "thread")).toHaveLength(10); + // The show-more row still renders (canShowLess) because the baseline used + // to decide whether to offer it is fixed at the recency count, not the + // currently revealed count; there's simply nothing left hidden. + expect(expanded.items.at(-1)).toMatchObject({ + type: "show-more", + hiddenCount: 0, + canShowLess: true, + }); + }); + + it("resets an expanded group back to the recent-thread baseline on show-less", () => { + const group = makeGroupWithRecentCount("alpha", 10, 2); + const expandedState = nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"); + const resetState = nextGroupDisplayState(expandedState, "show-less"); + + const layout = buildHomeListLayout({ + groups: [group], + displayStates: displayStates({ alpha: resetState }), + }); + + const threadItems = layout.items.filter((item) => item.type === "thread"); + expect(threadItems).toHaveLength(2); + expect(layout.items.at(-1)).toMatchObject({ type: "show-more", hiddenCount: 8 }); + }); }); diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index cf9b0824aa4..65c0d2eea3c 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -2,9 +2,17 @@ import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; -import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { + CommandId, + EnvironmentId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; +import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { buildHomeThreadGroups } from "./homeThreadList"; function makeProject( @@ -44,6 +52,33 @@ function makeThread( }; } +function makePendingTask( + input: Pick, +): PendingNewTask { + return { + title: input.title, + message: { + environmentId: input.environmentId, + threadId: ThreadId.make(`pending-thread-${input.id}`), + messageId: MessageId.make(`pending-message-${input.id}`), + commandId: CommandId.make(`pending-command-${input.id}`), + text: input.title, + attachments: [], + createdAt: "2026-06-29T00:00:00.000Z", + }, + creation: { + projectId: input.id, + projectTitle: input.title, + projectCwd: input.workspaceRoot, + workspaceMode: "local", + branch: null, + worktreePath: null, + }, + }; +} + +const NOW = Date.parse("2026-06-29T00:00:00.000Z"); + function buildGroups( projects: ReadonlyArray, threads: ReadonlyArray, @@ -57,6 +92,7 @@ function buildGroups( projectSortOrder: "updated_at", threadSortOrder: "updated_at", projectGroupingMode: "repository", + now: NOW, ...overrides, }); } @@ -220,4 +256,141 @@ describe("buildHomeThreadGroups", () => { ); expect(buildGroups(projects, threads, { projectGroupingMode: "separate" })).toHaveLength(2); }); + + it("default view shows only threads from the last 5 days", () => { + const environmentId = EnvironmentId.make("environment-1"); + const project = makeProject({ + environmentId, + id: ProjectId.make("project-1"), + title: "T3 Code", + }); + const threads = [ + makeThread({ + environmentId, + id: ThreadId.make("recent-1"), + projectId: project.id, + title: "Today", + updatedAt: "2026-06-28T00:00:00.000Z", + }), + makeThread({ + environmentId, + id: ThreadId.make("recent-2"), + projectId: project.id, + title: "Within window", + updatedAt: "2026-06-25T00:00:00.000Z", + }), + makeThread({ + environmentId, + id: ThreadId.make("old"), + projectId: project.id, + title: "Two weeks ago", + updatedAt: "2026-06-14T00:00:00.000Z", + }), + ]; + + const group = buildGroups([project], threads)[0]; + // Default view trims to recent threads... + expect(group?.recentThreads.map((thread) => thread.id)).toEqual(["recent-1", "recent-2"]); + // ...while full history stays available for the expanded view. + expect(group?.threads.map((thread) => thread.id)).toEqual(["recent-1", "recent-2", "old"]); + }); + + it("falls back to the most recent 3 threads when none are within 5 days", () => { + const environmentId = EnvironmentId.make("environment-1"); + const project = makeProject({ + environmentId, + id: ProjectId.make("project-1"), + title: "T3 Code", + }); + const threads = ["2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", "2026-06-05"].map( + (day, index) => + makeThread({ + environmentId, + id: ThreadId.make(`thread-${index}`), + projectId: project.id, + title: `Thread ${index}`, + updatedAt: `${day}T00:00:00.000Z`, + }), + ); + + const group = buildGroups([project], threads)[0]; + expect(group?.recentThreads.map((thread) => thread.id)).toEqual([ + "thread-4", + "thread-3", + "thread-2", + ]); + expect(group?.threads).toHaveLength(5); + }); + + it("includes a thread exactly at the 5-day cutoff", () => { + const environmentId = EnvironmentId.make("environment-1"); + const project = makeProject({ + environmentId, + id: ProjectId.make("project-1"), + title: "T3 Code", + }); + const exactlyAtCutoff = new Date(NOW - 5 * 24 * 60 * 60 * 1000).toISOString(); + const justPastCutoff = new Date(NOW - 5 * 24 * 60 * 60 * 1000 - 1).toISOString(); + const threads = [ + makeThread({ + environmentId, + id: ThreadId.make("at-cutoff"), + projectId: project.id, + title: "At cutoff", + updatedAt: exactlyAtCutoff, + }), + makeThread({ + environmentId, + id: ThreadId.make("past-cutoff"), + projectId: project.id, + title: "Past cutoff", + updatedAt: justPastCutoff, + }), + ]; + + const group = buildGroups([project], threads)[0]; + expect(group?.recentThreads.map((thread) => thread.id)).toEqual(["at-cutoff"]); + }); + + it("returns an empty recentThreads list for a group with no threads", () => { + const environmentId = EnvironmentId.make("environment-1"); + const project = makeProject({ + environmentId, + id: ProjectId.make("project-1"), + title: "T3 Code", + }); + + const group = buildGroups([project], [], { + pendingTasks: [makePendingTask(project)], + })[0]; + + expect(group?.threads).toEqual([]); + expect(group?.recentThreads).toEqual([]); + }); + + it("does not apply the recency window while searching", () => { + const environmentId = EnvironmentId.make("environment-1"); + const project = makeProject({ + environmentId, + id: ProjectId.make("project-1"), + title: "T3 Code", + }); + const threads = ["2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", "2026-06-05"].map( + (day, index) => + makeThread({ + environmentId, + id: ThreadId.make(`thread-${index}`), + projectId: project.id, + title: `Thread ${index}`, + updatedAt: `${day}T00:00:00.000Z`, + }), + ); + + const group = buildGroups([project], threads, { searchQuery: "T3 Code" })[0]; + // Search reaches the full history rather than the 3-thread fallback. + expect(group?.recentThreads).toHaveLength(5); + expect(group?.recentThreads.map((thread) => thread.id)).toEqual( + group?.threads.map((thread) => thread.id), + ); + }); }); diff --git a/apps/mobile/src/lib/time.test.ts b/apps/mobile/src/lib/time.test.ts new file mode 100644 index 00000000000..29ff3f7b78a --- /dev/null +++ b/apps/mobile/src/lib/time.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { relativeTime } from "./time"; + +const NOW = Date.parse("2026-06-29T12:00:00.000Z"); + +describe("relativeTime", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns '<1m' for unparseable input", () => { + expect(relativeTime("not-a-date")).toBe("<1m"); + }); + + it("returns '<1m' for a timestamp that is right now", () => { + expect(relativeTime(new Date(NOW).toISOString())).toBe("<1m"); + }); + + it("returns '<1m' for anything under a minute old, instead of a live seconds count", () => { + expect(relativeTime(new Date(NOW - 1_000).toISOString())).toBe("<1m"); + expect(relativeTime(new Date(NOW - 59_000).toISOString())).toBe("<1m"); + }); + + it("clamps timestamps in the future to '<1m' rather than a negative duration", () => { + expect(relativeTime(new Date(NOW + 60_000).toISOString())).toBe("<1m"); + }); + + it("renders whole minutes once a minute has elapsed", () => { + expect(relativeTime(new Date(NOW - 60_000).toISOString())).toBe("1m"); + expect(relativeTime(new Date(NOW - 90_000).toISOString())).toBe("1m"); + expect(relativeTime(new Date(NOW - 59 * 60_000).toISOString())).toBe("59m"); + }); + + it("switches to hours once 60 minutes have elapsed", () => { + expect(relativeTime(new Date(NOW - 60 * 60_000).toISOString())).toBe("1h"); + expect(relativeTime(new Date(NOW - 23 * 60 * 60_000).toISOString())).toBe("23h"); + }); + + it("switches to days once 24 hours have elapsed", () => { + expect(relativeTime(new Date(NOW - 24 * 60 * 60_000).toISOString())).toBe("1d"); + expect(relativeTime(new Date(NOW - 9 * 24 * 60 * 60_000).toISOString())).toBe("9d"); + }); +}); \ No newline at end of file