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
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
11 changes: 11 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,15 @@ 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.
onNewThread={
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
item.group.projects.length === 1 && !item.group.key.startsWith("pending-project:")
? props.onNewThreadInProject
: undefined
}
project={item.group.representative}
threadCount={item.group.threads.length + item.group.pendingTasks.length}
title={item.group.title}
Expand Down Expand Up @@ -308,6 +318,7 @@ export function HomeScreen(props: HomeScreenProps) {
props.onArchiveThread,
props.onDeletePendingTask,
props.onDeleteThread,
props.onNewThreadInProject,
props.onSelectPendingTask,
props.onSelectThread,
props.savedConnectionsById,
Expand Down
51 changes: 48 additions & 3 deletions apps/mobile/src/features/home/homeListItems.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,19 @@ 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,
};
}

Expand Down Expand Up @@ -153,6 +157,47 @@ 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),
};

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
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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
94 changes: 94 additions & 0 deletions apps/mobile/src/features/home/homeThreadList.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ function makeThread(
};
}

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

function buildGroups(
projects: ReadonlyArray<EnvironmentProject>,
threads: ReadonlyArray<EnvironmentThreadShell>,
Expand All @@ -57,6 +59,7 @@ function buildGroups(
projectSortOrder: "updated_at",
threadSortOrder: "updated_at",
projectGroupingMode: "repository",
now: NOW,
...overrides,
});
}
Expand Down Expand Up @@ -220,4 +223,95 @@ 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),
);
});
});
43 changes: 42 additions & 1 deletion apps/mobile/src/features/home/homeThreadList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,24 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";

export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">;

/**
* Default home view only surfaces threads active within this window, to keep the
* screen compact while keeping recent work visible.
*/
const RECENT_THREAD_WINDOW_MS = 5 * 24 * 60 * 60 * 1000;
/** Fallback when a project has no threads inside the recency window. */
const RECENT_THREAD_FALLBACK_COUNT = 3;

export interface HomeThreadGroup {
readonly key: string;
readonly title: string;
readonly representative: EnvironmentProject;
readonly projects: ReadonlyArray<EnvironmentProject>;
readonly pendingTasks: ReadonlyArray<PendingNewTask>;
/** Full sorted thread history for the group (revealed when expanded / searching). */
readonly threads: ReadonlyArray<EnvironmentThreadShell>;
/** Subset shown by default: threads from the last few days, or the most recent few. */
readonly recentThreads: ReadonlyArray<EnvironmentThreadShell>;
}

interface MutableHomeThreadGroup {
Expand All @@ -48,6 +59,24 @@ function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOr
}, latestThread);
}

/**
* Trims a group's threads to recent activity for the default home view.
* `sortedThreads` must already be ordered newest-first for `threadSortOrder`.
* Keeps threads within {@link RECENT_THREAD_WINDOW_MS}; when none qualify, keeps
* the most recent {@link RECENT_THREAD_FALLBACK_COUNT} so a project never vanishes.
*/
function selectRecentThreads(
sortedThreads: ReadonlyArray<EnvironmentThreadShell>,
threadSortOrder: SidebarThreadSortOrder,
now: number,
): ReadonlyArray<EnvironmentThreadShell> {
const cutoff = now - RECENT_THREAD_WINDOW_MS;
const recent = sortedThreads.filter(
(thread) => getThreadSortTimestamp(thread, threadSortOrder) >= cutoff,
);
return recent.length > 0 ? recent : sortedThreads.slice(0, RECENT_THREAD_FALLBACK_COUNT);
}

export function buildHomeThreadGroups(input: {
readonly projects: ReadonlyArray<EnvironmentProject>;
readonly threads: ReadonlyArray<EnvironmentThreadShell>;
Expand All @@ -57,7 +86,10 @@ export function buildHomeThreadGroups(input: {
readonly projectSortOrder: HomeProjectSortOrder;
readonly threadSortOrder: SidebarThreadSortOrder;
readonly projectGroupingMode: SidebarProjectGroupingMode;
/** Current time used for the recency window; defaults to now. Injectable for tests. */
readonly now?: number;
}): ReadonlyArray<HomeThreadGroup> {
const now = input.now ?? Date.now();
const groups = new Map<string, MutableHomeThreadGroup>();
const groupKeyByProjectKey = new Map<string, string>();

Expand Down Expand Up @@ -165,13 +197,22 @@ export function buildHomeThreadGroups(input: {
continue;
}

const sortedThreads = sortThreads(matchingThreads, input.threadSortOrder);
// An active search should reach the full history, so the recency window
// only trims the default (no-query) view.
const recentThreads =
query.length === 0
? selectRecentThreads(sortedThreads, input.threadSortOrder, now)
: sortedThreads;

result.push({
key: group.key,
title,
representative,
projects: group.projects,
pendingTasks: matchingPendingTasks,
threads: sortThreads(matchingThreads, input.threadSortOrder),
threads: sortedThreads,
recentThreads,
});
}

Expand Down
Loading
Loading