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
20 changes: 19 additions & 1 deletion apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)) ??
Expand Down Expand Up @@ -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;
}
}
},
[
Expand All @@ -853,6 +866,7 @@ export function HomeScreen(props: HomeScreenProps) {
props.onSelectPendingTask,
props.onSelectThread,
props.savedConnectionsById,
props.threadGrouping,
settledThreadKeys,
settlementEnvironmentIds,
updateGroupDisplay,
Expand Down Expand Up @@ -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}
Expand Down
53 changes: 42 additions & 11 deletions apps/mobile/src/features/home/homeListItems.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand All @@ -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,
Expand All @@ -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([]);
});
});
52 changes: 39 additions & 13 deletions apps/mobile/src/features/home/homeListItems.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<PendingNewTask>;
Expand All @@ -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",
Expand All @@ -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: [] };
}

Expand All @@ -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({
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)) ??
Expand Down Expand Up @@ -1120,6 +1122,8 @@ function ThreadNavigationSidebarPane(
onGroupAction={updateGroupDisplay}
/>
);
default:
return null;
}
},
[
Expand All @@ -1135,6 +1139,7 @@ function ThreadNavigationSidebarPane(
projectCwdByKey,
projectTitleByProjectKey,
props.onNewThreadInProject,
showFlatThreadList,
props.selectedThreadKey,
props.width,
savedConnectionsById,
Expand Down
Loading
Loading