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
103 changes: 100 additions & 3 deletions apps/mobile/src/features/home/homeListItems.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,18 +57,31 @@

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<HomeListItem>): string[] {
return items.map((item) => item.type);
}
Expand Down Expand Up @@ -190,4 +203,88 @@
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);

Check failure on line 217 in apps/mobile/src/features/home/homeListItems.test.ts

View workflow job for this annotation

GitHub Actions / Test

src/features/home/homeListItems.test.ts > buildHomeListLayout > uses the group's recent-thread count as the default baseline when smaller than the page size

AssertionError: expected [ { type: 'thread', …(3) }, …(5) ] to have a length of 2 but got 6 - Expected + Received - 2 + 6 ❯ src/features/home/homeListItems.test.ts:217:25
expect(layout.items.at(-1)).toMatchObject({
type: "show-more",
groupKey: "alpha",
hiddenCount: 8,
canShowLess: false,
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Layout tests ignore recentThreads

Medium Severity

New buildHomeListLayout cases assume a recency baseline from recentThreads (e.g. two visible threads and show-less back to two), but buildHomeListLayout only slices group.threads with HOME_INITIAL_VISIBLE_THREADS and nextGroupDisplayState show-less still resets to six.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b987aae. Configure here.


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);

Check failure on line 287 in apps/mobile/src/features/home/homeListItems.test.ts

View workflow job for this annotation

GitHub Actions / Test

src/features/home/homeListItems.test.ts > buildHomeListLayout > resets an expanded group back to the recent-thread baseline on show-less

AssertionError: expected [ { type: 'thread', …(3) }, …(5) ] to have a length of 2 but got 6 - Expected + Received - 2 + 6 ❯ src/features/home/homeListItems.test.ts:287:25
expect(layout.items.at(-1)).toMatchObject({ type: "show-more", hiddenCount: 8 });
});
});
175 changes: 174 additions & 1 deletion apps/mobile/src/features/home/homeThreadList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@
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(
Expand Down Expand Up @@ -44,6 +52,33 @@
};
}

function makePendingTask(
input: Pick<EnvironmentProject, "environmentId" | "id" | "title" | "workspaceRoot">,
): 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<EnvironmentProject>,
threads: ReadonlyArray<EnvironmentThreadShell>,
Expand All @@ -57,6 +92,7 @@
projectSortOrder: "updated_at",
threadSortOrder: "updated_at",
projectGroupingMode: "repository",
now: NOW,
...overrides,
});
}
Expand Down Expand Up @@ -220,4 +256,141 @@
);
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"]);

Check failure on line 293 in apps/mobile/src/features/home/homeThreadList.test.ts

View workflow job for this annotation

GitHub Actions / Test

src/features/home/homeThreadList.test.ts > buildHomeThreadGroups > default view shows only threads from the last 5 days

TypeError: Cannot read properties of undefined (reading 'map') ❯ src/features/home/homeThreadList.test.ts:293:19

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests map undefined recentThreads

High Severity

The new buildHomeThreadGroups tests are failing with undefined.map() errors. It appears the function's return object is missing the recentThreads property that the tests expect.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b987aae. Configure here.

// ...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([

Check failure on line 317 in apps/mobile/src/features/home/homeThreadList.test.ts

View workflow job for this annotation

GitHub Actions / Test

src/features/home/homeThreadList.test.ts > buildHomeThreadGroups > falls back to the most recent 3 threads when none are within 5 days

TypeError: Cannot read properties of undefined (reading 'map') ❯ src/features/home/homeThreadList.test.ts:317:19
"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"]);

Check failure on line 352 in apps/mobile/src/features/home/homeThreadList.test.ts

View workflow job for this annotation

GitHub Actions / Test

src/features/home/homeThreadList.test.ts > buildHomeThreadGroups > includes a thread exactly at the 5-day cutoff

TypeError: Cannot read properties of undefined (reading 'map') ❯ src/features/home/homeThreadList.test.ts:352:19
});

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([]);

Check failure on line 368 in apps/mobile/src/features/home/homeThreadList.test.ts

View workflow job for this annotation

GitHub Actions / Test

src/features/home/homeThreadList.test.ts > buildHomeThreadGroups > returns an empty recentThreads list for a group with no threads

AssertionError: expected undefined to deeply equal [] - Expected: [] + Received: undefined ❯ src/features/home/homeThreadList.test.ts:368:34
});

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),
);
});
});
49 changes: 49 additions & 0 deletions apps/mobile/src/lib/time.test.ts
Original file line number Diff line number Diff line change
@@ -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");

Check failure on line 18 in apps/mobile/src/lib/time.test.ts

View workflow job for this annotation

GitHub Actions / Test

src/lib/time.test.ts > relativeTime > returns '<1m' for unparseable input

AssertionError: expected 'now' to be '<1m' // Object.is equality Expected: "<1m" Received: "now" ❯ src/lib/time.test.ts:18:40
});

it("returns '<1m' for a timestamp that is right now", () => {
expect(relativeTime(new Date(NOW).toISOString())).toBe("<1m");

Check failure on line 22 in apps/mobile/src/lib/time.test.ts

View workflow job for this annotation

GitHub Actions / Test

src/lib/time.test.ts > relativeTime > returns '<1m' for a timestamp that is right now

AssertionError: expected 'now' to be '<1m' // Object.is equality Expected: "<1m" Received: "now" ❯ src/lib/time.test.ts:22:55
});

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");

Check failure on line 26 in apps/mobile/src/lib/time.test.ts

View workflow job for this annotation

GitHub Actions / Test

src/lib/time.test.ts > relativeTime > returns '<1m' for anything under a minute old, instead of a live seconds count

AssertionError: expected 'now' to be '<1m' // Object.is equality Expected: "<1m" Received: "now" ❯ src/lib/time.test.ts:26:63
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");

Check failure on line 31 in apps/mobile/src/lib/time.test.ts

View workflow job for this annotation

GitHub Actions / Test

src/lib/time.test.ts > relativeTime > clamps timestamps in the future to '<1m' rather than a negative duration

AssertionError: expected 'now' to be '<1m' // Object.is equality Expected: "<1m" Received: "now" ❯ src/lib/time.test.ts:31:64
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

relativeTime tests expect wrong strings

Medium Severity

The relativeTime function in apps/mobile/src/lib/time.ts returns 'now' for invalid inputs and timestamps under 10s, and 'Ns' for 10-59s deltas. This conflicts with the new tests, which expect '<1m>' for invalid, current, sub-minute, and future timestamps.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b987aae. Configure here.


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");
});
});
Loading