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
2 changes: 1 addition & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@
"react-native": "0.85.3",
"react-native-gesture-handler": "~2.31.1",
"react-native-image-viewing": "^0.2.2",
"react-native-keyboard-controller": "1.21.6",
"react-native-keyboard-controller": "1.21.13",
"react-native-nitro-markdown": "^0.5.0",
"react-native-nitro-modules": "0.35.9",
"react-native-reanimated": "4.3.1",
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ function ArchivedThreadRow(props: {
</Text>
<Text
className="text-xs text-foreground-tertiary"
style={{ fontVariant: ["tabular-nums"] }}
style={{ fontVariant: ["tabular-nums"], minWidth: 30, textAlign: "right" }}
>
{timestamp}
</Text>
Expand Down
10 changes: 10 additions & 0 deletions apps/mobile/src/features/home/HomeRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,16 @@ export function HomeRouteScreen() {
}}
onSelectPendingTask={openPendingTask}
onDeletePendingTask={confirmDeletePendingTask}
onNewThreadInProject={(project) => {
navigation.navigate("NewTaskSheet", {
screen: "NewTaskDraft",
params: {
environmentId: String(project.environmentId),
projectId: String(project.id),
title: project.title,
},
});
}}
onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
onThreadSortOrderChange={setThreadSortOrder}
pendingTasks={pendingTasks}
Expand Down
8 changes: 8 additions & 0 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ interface HomeScreenProps {
readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
readonly onNewThreadInProject: (project: EnvironmentProject) => void;
}

/* ─── Layout constants ───────────────────────────────────────────────── */
Expand Down Expand Up @@ -248,6 +249,12 @@ export function HomeScreen(props: HomeScreenProps) {
isFirst={item.isFirst}
groupKey={item.group.key}
onGroupAction={updateGroupDisplay}
// Aggregated groups (same repo across machines) have no single
// target project, and `pending-project:` groups hold a placeholder
// built from queued-task metadata rather than a real project shell,
// so the quick new-thread button is single-real-project only.
newThreadTarget={item.group.newThreadTarget}
onNewThread={props.onNewThreadInProject}
project={item.group.representative}
threadCount={item.group.threads.length + item.group.pendingTasks.length}
title={item.group.title}
Expand Down Expand Up @@ -308,6 +315,7 @@ export function HomeScreen(props: HomeScreenProps) {
props.onArchiveThread,
props.onDeletePendingTask,
props.onDeleteThread,
props.onNewThreadInProject,
props.onSelectPendingTask,
props.onSelectThread,
props.savedConnectionsById,
Expand Down
53 changes: 50 additions & 3 deletions apps/mobile/src/features/home/homeListItems.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,20 @@ 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,
newThreadTarget: project,
};
}

Expand Down Expand Up @@ -154,6 +159,48 @@ describe("buildHomeListLayout", () => {
expect(reset.visibleCount).toBe(HOME_INITIAL_VISIBLE_THREADS);
});

it("offers show-less after expanding a stale group whose baseline is below the page size", () => {
// Stale project: 10 threads total but only 3 within the recency window.
const project = makeProject("stale", "stale");
const threads = Array.from({ length: 10 }, (_, index) =>
makeThread(`stale-thread-${index}`, project.id),
);
const group: HomeThreadGroup = {
key: "stale",
title: "stale",
representative: project,
projects: [project],
pendingTasks: [],
threads,
recentThreads: threads.slice(0, 3),
newThreadTarget: project,
};

const collapsedToRecent = buildHomeListLayout({
groups: [group],
displayStates: displayStates({}),
});
expect(collapsedToRecent.items.filter((item) => item.type === "thread")).toHaveLength(3);
expect(collapsedToRecent.items.at(-1)).toMatchObject({
type: "show-more",
hiddenCount: 7,
canShowLess: false,
});

const expanded = buildHomeListLayout({
groups: [group],
displayStates: displayStates({
stale: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"),
}),
});
expect(expanded.items.filter((item) => item.type === "thread")).toHaveLength(10);
expect(expanded.items.at(-1)).toMatchObject({
type: "show-more",
hiddenCount: 0,
canShowLess: true,
});
});

it("hides threads and the show-more row for collapsed groups", () => {
const layout = buildHomeListLayout({
groups: [makeGroup("alpha", 12), makeGroup("beta", 2)],
Expand Down
24 changes: 21 additions & 3 deletions apps/mobile/src/features/home/homeListItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,27 @@ export function buildHomeListLayout(input: {
}

const totalCount = group.threads.length;
// Default to the group's recent-activity window (last few days, or a small
// fallback for stale projects), capped at the initial page size. Until the
// user taps "Show more", older threads stay hidden to save vertical space;
// "Show less" resets visibleCount to the initial constant, which lands back
// here at the recency baseline.
const baselineCount = Math.min(
group.recentThreads.length,
HOME_INITIAL_VISIBLE_THREADS,
totalCount,
);
const visibleCount = input.showAllThreads
? totalCount
: Math.min(Math.max(display.visibleCount, HOME_INITIAL_VISIBLE_THREADS), totalCount);
: Math.min(
display.visibleCount > HOME_INITIAL_VISIBLE_THREADS
? display.visibleCount
: baselineCount,
totalCount,
);
const visibleThreads = group.threads.slice(0, visibleCount);
const hiddenCount = totalCount - visibleCount;
const hasShowMoreRow = !input.showAllThreads && totalCount > HOME_INITIAL_VISIBLE_THREADS;
const hasShowMoreRow = !input.showAllThreads && totalCount > baselineCount;

// Pending (unsent) tasks lead the group and are never paginated away.
for (const [pendingIndex, pendingTask] of group.pendingTasks.entries()) {
Expand Down Expand Up @@ -180,7 +195,10 @@ export function buildHomeListLayout(input: {
key: `show-more:${group.key}`,
groupKey: group.key,
hiddenCount,
canShowLess: visibleCount > HOME_INITIAL_VISIBLE_THREADS,
// Compare against the group's own baseline, not the global page size:
// stale projects start below HOME_INITIAL_VISIBLE_THREADS, and "Show
// less" must be offered as soon as anything beyond the baseline shows.
canShowLess: visibleCount > baselineCount,
});
}
}
Expand Down
143 changes: 143 additions & 0 deletions apps/mobile/src/features/home/homeThreadList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ function makeThread(
};
}

const NOW = Date.parse("2026-06-29T00:00:00.000Z");

function buildGroups(
projects: ReadonlyArray<EnvironmentProject>,
threads: ReadonlyArray<EnvironmentThreadShell>,
Expand All @@ -58,6 +60,7 @@ function buildGroups(
projectSortOrder: "updated_at",
threadSortOrder: "updated_at",
projectGroupingMode: "repository",
now: NOW,
...overrides,
});
}
Expand Down Expand Up @@ -221,4 +224,144 @@ 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("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),
);
});

it("targets quick new threads at the group member with the newest thread", () => {
const laptopEnv = EnvironmentId.make("environment-laptop");
const desktopEnv = EnvironmentId.make("environment-desktop");
const repositoryIdentity = {
canonicalKey: "github.com/pingdotgg/t3code",
locator: {
source: "git-remote" as const,
remoteName: "origin",
remoteUrl: "git@github.com:pingdotgg/t3code.git",
},
};
const laptopProject = makeProject({
environmentId: laptopEnv,
id: ProjectId.make("project-laptop"),
title: "t3code",
repositoryIdentity,
});
const desktopProject = makeProject({
environmentId: desktopEnv,
id: ProjectId.make("project-desktop"),
title: "t3code",
repositoryIdentity,
});
const threads = [
makeThread({
environmentId: laptopEnv,
id: ThreadId.make("thread-laptop"),
projectId: laptopProject.id,
title: "Older laptop thread",
updatedAt: "2026-06-27T00:00:00.000Z",
}),
makeThread({
environmentId: desktopEnv,
id: ThreadId.make("thread-desktop"),
projectId: desktopProject.id,
title: "Newest desktop thread",
updatedAt: "2026-06-28T00:00:00.000Z",
}),
];

// Aggregated into one group by repository; the quick new-thread target
// must follow the newest thread (desktop), not the arbitrary first member.
const groups = buildGroups([laptopProject, desktopProject], threads);
expect(groups).toHaveLength(1);
expect(groups[0]?.projects).toHaveLength(2);
expect(groups[0]?.newThreadTarget?.environmentId).toBe(desktopEnv);
expect(groups[0]?.newThreadTarget?.id).toBe(desktopProject.id);
});
});
Loading
Loading