authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-04 14:28:51-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-04 14:28:51-07:00
loge26e55f389fb0e31ee9a0c4570510bdcba8e350d
tree93b7be5ab7ddab9deb1e23d991978a51a531d7d1
parent4ac094fef689b71f475e41a9a2fce167804a5e4e
signature Signed by PGP key B5690EEEBB952194

fix(mobile): combined test branch — scroll, back-swipe, thread lists, computer switching (#3687)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

21 files changed, 653 insertions(+), 694 deletions(-)

apps/mobile/package.json+1-1
...@@ -96,7 +96,7 @@...@@ -96,7 +96,7 @@
96 "react-native": "0.85.3",96 "react-native": "0.85.3",
97 "react-native-gesture-handler": "~2.31.1",97 "react-native-gesture-handler": "~2.31.1",
98 "react-native-image-viewing": "^0.2.2",98 "react-native-image-viewing": "^0.2.2",
99 "react-native-keyboard-controller": "1.21.6",99 "react-native-keyboard-controller": "1.21.13",
100 "react-native-nitro-markdown": "^0.5.0",100 "react-native-nitro-markdown": "^0.5.0",
101 "react-native-nitro-modules": "0.35.9",101 "react-native-nitro-modules": "0.35.9",
102 "react-native-reanimated": "4.3.1",102 "react-native-reanimated": "4.3.1",
apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx+1-1
...@@ -333,7 +333,7 @@ function ArchivedThreadRow(props: {...@@ -333,7 +333,7 @@ function ArchivedThreadRow(props: {
333 </Text>333 </Text>
334 <Text334 <Text
335 className="text-xs text-foreground-tertiary"335 className="text-xs text-foreground-tertiary"
336 style={{ fontVariant: ["tabular-nums"] }}336 style={{ fontVariant: ["tabular-nums"], minWidth: 30, textAlign: "right" }}
337 >337 >
338 {timestamp}338 {timestamp}
339 </Text>339 </Text>
apps/mobile/src/features/home/HomeRouteScreen.tsx+10
...@@ -122,6 +122,16 @@ export function HomeRouteScreen() {...@@ -122,6 +122,16 @@ export function HomeRouteScreen() {
122 }}122 }}
123 onSelectPendingTask={openPendingTask}123 onSelectPendingTask={openPendingTask}
124 onDeletePendingTask={confirmDeletePendingTask}124 onDeletePendingTask={confirmDeletePendingTask}
125 onNewThreadInProject={(project) => {
126 navigation.navigate("NewTaskSheet", {
127 screen: "NewTaskDraft",
128 params: {
129 environmentId: String(project.environmentId),
130 projectId: String(project.id),
131 title: project.title,
132 },
133 });
134 }}
125 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}135 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
126 onThreadSortOrderChange={setThreadSortOrder}136 onThreadSortOrderChange={setThreadSortOrder}
127 pendingTasks={pendingTasks}137 pendingTasks={pendingTasks}
apps/mobile/src/features/home/HomeScreen.tsx+8
...@@ -72,6 +72,7 @@ interface HomeScreenProps {...@@ -72,6 +72,7 @@ interface HomeScreenProps {
72 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;72 readonly onDeleteThread: (thread: EnvironmentThreadShell) => void;
73 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;73 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
74 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;74 readonly onDeletePendingTask: (pendingTask: PendingNewTask) => void;
75 readonly onNewThreadInProject: (project: EnvironmentProject) => void;
75}76}
7677
77/* ─── Layout constants ───────────────────────────────────────────────── */78/* ─── Layout constants ───────────────────────────────────────────────── */
...@@ -248,6 +249,12 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -248,6 +249,12 @@ export function HomeScreen(props: HomeScreenProps) {
248 isFirst={item.isFirst}249 isFirst={item.isFirst}
249 groupKey={item.group.key}250 groupKey={item.group.key}
250 onGroupAction={updateGroupDisplay}251 onGroupAction={updateGroupDisplay}
252 // Aggregated groups (same repo across machines) have no single
253 // target project, and `pending-project:` groups hold a placeholder
254 // built from queued-task metadata rather than a real project shell,
255 // so the quick new-thread button is single-real-project only.
256 newThreadTarget={item.group.newThreadTarget}
257 onNewThread={props.onNewThreadInProject}
251 project={item.group.representative}258 project={item.group.representative}
252 threadCount={item.group.threads.length + item.group.pendingTasks.length}259 threadCount={item.group.threads.length + item.group.pendingTasks.length}
253 title={item.group.title}260 title={item.group.title}
...@@ -308,6 +315,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -308,6 +315,7 @@ export function HomeScreen(props: HomeScreenProps) {
308 props.onArchiveThread,315 props.onArchiveThread,
309 props.onDeletePendingTask,316 props.onDeletePendingTask,
310 props.onDeleteThread,317 props.onDeleteThread,
318 props.onNewThreadInProject,
311 props.onSelectPendingTask,319 props.onSelectPendingTask,
312 props.onSelectThread,320 props.onSelectThread,
313 props.savedConnectionsById,321 props.savedConnectionsById,
apps/mobile/src/features/home/homeListItems.test.ts+50-3
...@@ -57,15 +57,20 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell {...@@ -57,15 +57,20 @@ function makeThread(id: string, projectId: ProjectId): EnvironmentThreadShell {
5757
58function makeGroup(key: string, threadCount: number): HomeThreadGroup {58function makeGroup(key: string, threadCount: number): HomeThreadGroup {
59 const project = makeProject(key, key);59 const project = makeProject(key, key);
60 const threads = Array.from({ length: threadCount }, (_, index) =>
61 makeThread(`${key}-thread-${index}`, project.id),
62 );
60 return {63 return {
61 key,64 key,
62 title: key,65 title: key,
63 representative: project,66 representative: project,
64 projects: [project],67 projects: [project],
65 pendingTasks: [],68 pendingTasks: [],
66 threads: Array.from({ length: threadCount }, (_, index) =>69 threads,
67 makeThread(`${key}-thread-${index}`, project.id),70 // All threads inside the recency window, so the baseline stays at the
68 ),71 // initial page size and the pagination expectations below hold.
72 recentThreads: threads,
73 newThreadTarget: project,
69 };74 };
70}75}
7176
...@@ -153,6 +158,48 @@ describe("buildHomeListLayout", () => {...@@ -153,6 +158,48 @@ describe("buildHomeListLayout", () => {
153 expect(reset.visibleCount).toBe(HOME_INITIAL_VISIBLE_THREADS);158 expect(reset.visibleCount).toBe(HOME_INITIAL_VISIBLE_THREADS);
154 });159 });
155160
161 it("offers show-less after expanding a stale group whose baseline is below the page size", () => {
162 // Stale project: 10 threads total but only 3 within the recency window.
163 const project = makeProject("stale", "stale");
164 const threads = Array.from({ length: 10 }, (_, index) =>
165 makeThread(`stale-thread-${index}`, project.id),
166 );
167 const group: HomeThreadGroup = {
168 key: "stale",
169 title: "stale",
170 representative: project,
171 projects: [project],
172 pendingTasks: [],
173 threads,
174 recentThreads: threads.slice(0, 3),
175 newThreadTarget: project,
176 };
177
178 const collapsedToRecent = buildHomeListLayout({
179 groups: [group],
180 displayStates: displayStates({}),
181 });
182 expect(collapsedToRecent.items.filter((item) => item.type === "thread")).toHaveLength(3);
183 expect(collapsedToRecent.items.at(-1)).toMatchObject({
184 type: "show-more",
185 hiddenCount: 7,
186 canShowLess: false,
187 });
188
189 const expanded = buildHomeListLayout({
190 groups: [group],
191 displayStates: displayStates({
192 stale: nextGroupDisplayState(DEFAULT_GROUP_DISPLAY_STATE, "show-more"),
193 }),
194 });
195 expect(expanded.items.filter((item) => item.type === "thread")).toHaveLength(10);
196 expect(expanded.items.at(-1)).toMatchObject({
197 type: "show-more",
198 hiddenCount: 0,
199 canShowLess: true,
200 });
201 });
202
156 it("hides threads and the show-more row for collapsed groups", () => {203 it("hides threads and the show-more row for collapsed groups", () => {
157 const layout = buildHomeListLayout({204 const layout = buildHomeListLayout({
158 groups: [makeGroup("alpha", 12), makeGroup("beta", 2)],205 groups: [makeGroup("alpha", 12), makeGroup("beta", 2)],
apps/mobile/src/features/home/homeListItems.ts+21-3
...@@ -145,12 +145,27 @@ export function buildHomeListLayout(input: {...@@ -145,12 +145,27 @@ export function buildHomeListLayout(input: {
145 }145 }
146146
147 const totalCount = group.threads.length;147 const totalCount = group.threads.length;
148 // Default to the group's recent-activity window (last few days, or a small
149 // fallback for stale projects), capped at the initial page size. Until the
150 // user taps "Show more", older threads stay hidden to save vertical space;
151 // "Show less" resets visibleCount to the initial constant, which lands back
152 // here at the recency baseline.
153 const baselineCount = Math.min(
154 group.recentThreads.length,
155 HOME_INITIAL_VISIBLE_THREADS,
156 totalCount,
157 );
148 const visibleCount = input.showAllThreads158 const visibleCount = input.showAllThreads
149 ? totalCount159 ? totalCount
150 : Math.min(Math.max(display.visibleCount, HOME_INITIAL_VISIBLE_THREADS), totalCount);160 : Math.min(
161 display.visibleCount > HOME_INITIAL_VISIBLE_THREADS
162 ? display.visibleCount
163 : baselineCount,
164 totalCount,
165 );
151 const visibleThreads = group.threads.slice(0, visibleCount);166 const visibleThreads = group.threads.slice(0, visibleCount);
152 const hiddenCount = totalCount - visibleCount;167 const hiddenCount = totalCount - visibleCount;
153 const hasShowMoreRow = !input.showAllThreads && totalCount > HOME_INITIAL_VISIBLE_THREADS;168 const hasShowMoreRow = !input.showAllThreads && totalCount > baselineCount;
154169
155 // Pending (unsent) tasks lead the group and are never paginated away.170 // Pending (unsent) tasks lead the group and are never paginated away.
156 for (const [pendingIndex, pendingTask] of group.pendingTasks.entries()) {171 for (const [pendingIndex, pendingTask] of group.pendingTasks.entries()) {
...@@ -180,7 +195,10 @@ export function buildHomeListLayout(input: {...@@ -180,7 +195,10 @@ export function buildHomeListLayout(input: {
180 key: `show-more:${group.key}`,195 key: `show-more:${group.key}`,
181 groupKey: group.key,196 groupKey: group.key,
182 hiddenCount,197 hiddenCount,
183 canShowLess: visibleCount > HOME_INITIAL_VISIBLE_THREADS,198 // Compare against the group's own baseline, not the global page size:
199 // stale projects start below HOME_INITIAL_VISIBLE_THREADS, and "Show
200 // less" must be offered as soon as anything beyond the baseline shows.
201 canShowLess: visibleCount > baselineCount,
184 });202 });
185 }203 }
186 }204 }
apps/mobile/src/features/home/homeThreadList.test.ts+143
...@@ -44,6 +44,8 @@ function makeThread(...@@ -44,6 +44,8 @@ function makeThread(
44 };44 };
45}45}
4646
47const NOW = Date.parse("2026-06-29T00:00:00.000Z");
48
47function buildGroups(49function buildGroups(
48 projects: ReadonlyArray<EnvironmentProject>,50 projects: ReadonlyArray<EnvironmentProject>,
49 threads: ReadonlyArray<EnvironmentThreadShell>,51 threads: ReadonlyArray<EnvironmentThreadShell>,
...@@ -57,6 +59,7 @@ function buildGroups(...@@ -57,6 +59,7 @@ function buildGroups(
57 projectSortOrder: "updated_at",59 projectSortOrder: "updated_at",
58 threadSortOrder: "updated_at",60 threadSortOrder: "updated_at",
59 projectGroupingMode: "repository",61 projectGroupingMode: "repository",
62 now: NOW,
60 ...overrides,63 ...overrides,
61 });64 });
62}65}
...@@ -220,4 +223,144 @@ describe("buildHomeThreadGroups", () => {...@@ -220,4 +223,144 @@ describe("buildHomeThreadGroups", () => {
220 );223 );
221 expect(buildGroups(projects, threads, { projectGroupingMode: "separate" })).toHaveLength(2);224 expect(buildGroups(projects, threads, { projectGroupingMode: "separate" })).toHaveLength(2);
222 });225 });
226
227 it("default view shows only threads from the last 5 days", () => {
228 const environmentId = EnvironmentId.make("environment-1");
229 const project = makeProject({
230 environmentId,
231 id: ProjectId.make("project-1"),
232 title: "T3 Code",
233 });
234 const threads = [
235 makeThread({
236 environmentId,
237 id: ThreadId.make("recent-1"),
238 projectId: project.id,
239 title: "Today",
240 updatedAt: "2026-06-28T00:00:00.000Z",
241 }),
242 makeThread({
243 environmentId,
244 id: ThreadId.make("recent-2"),
245 projectId: project.id,
246 title: "Within window",
247 updatedAt: "2026-06-25T00:00:00.000Z",
248 }),
249 makeThread({
250 environmentId,
251 id: ThreadId.make("old"),
252 projectId: project.id,
253 title: "Two weeks ago",
254 updatedAt: "2026-06-14T00:00:00.000Z",
255 }),
256 ];
257
258 const group = buildGroups([project], threads)[0];
259 // Default view trims to recent threads...
260 expect(group?.recentThreads.map((thread) => thread.id)).toEqual(["recent-1", "recent-2"]);
261 // ...while full history stays available for the expanded view.
262 expect(group?.threads.map((thread) => thread.id)).toEqual(["recent-1", "recent-2", "old"]);
263 });
264
265 it("falls back to the most recent 3 threads when none are within 5 days", () => {
266 const environmentId = EnvironmentId.make("environment-1");
267 const project = makeProject({
268 environmentId,
269 id: ProjectId.make("project-1"),
270 title: "T3 Code",
271 });
272 const threads = ["2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", "2026-06-05"].map(
273 (day, index) =>
274 makeThread({
275 environmentId,
276 id: ThreadId.make(`thread-${index}`),
277 projectId: project.id,
278 title: `Thread ${index}`,
279 updatedAt: `${day}T00:00:00.000Z`,
280 }),
281 );
282
283 const group = buildGroups([project], threads)[0];
284 expect(group?.recentThreads.map((thread) => thread.id)).toEqual([
285 "thread-4",
286 "thread-3",
287 "thread-2",
288 ]);
289 expect(group?.threads).toHaveLength(5);
290 });
291
292 it("does not apply the recency window while searching", () => {
293 const environmentId = EnvironmentId.make("environment-1");
294 const project = makeProject({
295 environmentId,
296 id: ProjectId.make("project-1"),
297 title: "T3 Code",
298 });
299 const threads = ["2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04", "2026-06-05"].map(
300 (day, index) =>
301 makeThread({
302 environmentId,
303 id: ThreadId.make(`thread-${index}`),
304 projectId: project.id,
305 title: `Thread ${index}`,
306 updatedAt: `${day}T00:00:00.000Z`,
307 }),
308 );
309
310 const group = buildGroups([project], threads, { searchQuery: "T3 Code" })[0];
311 // Search reaches the full history rather than the 3-thread fallback.
312 expect(group?.recentThreads).toHaveLength(5);
313 expect(group?.recentThreads.map((thread) => thread.id)).toEqual(
314 group?.threads.map((thread) => thread.id),
315 );
316 });
317
318 it("targets quick new threads at the group member with the newest thread", () => {
319 const laptopEnv = EnvironmentId.make("environment-laptop");
320 const desktopEnv = EnvironmentId.make("environment-desktop");
321 const repositoryIdentity = {
322 canonicalKey: "github.com/pingdotgg/t3code",
323 locator: {
324 source: "git-remote" as const,
325 remoteName: "origin",
326 remoteUrl: "git@github.com:pingdotgg/t3code.git",
327 },
328 };
329 const laptopProject = makeProject({
330 environmentId: laptopEnv,
331 id: ProjectId.make("project-laptop"),
332 title: "t3code",
333 repositoryIdentity,
334 });
335 const desktopProject = makeProject({
336 environmentId: desktopEnv,
337 id: ProjectId.make("project-desktop"),
338 title: "t3code",
339 repositoryIdentity,
340 });
341 const threads = [
342 makeThread({
343 environmentId: laptopEnv,
344 id: ThreadId.make("thread-laptop"),
345 projectId: laptopProject.id,
346 title: "Older laptop thread",
347 updatedAt: "2026-06-27T00:00:00.000Z",
348 }),
349 makeThread({
350 environmentId: desktopEnv,
351 id: ThreadId.make("thread-desktop"),
352 projectId: desktopProject.id,
353 title: "Newest desktop thread",
354 updatedAt: "2026-06-28T00:00:00.000Z",
355 }),
356 ];
357
358 // Aggregated into one group by repository; the quick new-thread target
359 // must follow the newest thread (desktop), not the arbitrary first member.
360 const groups = buildGroups([laptopProject, desktopProject], threads);
361 expect(groups).toHaveLength(1);
362 expect(groups[0]?.projects).toHaveLength(2);
363 expect(groups[0]?.newThreadTarget?.environmentId).toBe(desktopEnv);
364 expect(groups[0]?.newThreadTarget?.id).toBe(desktopProject.id);
365 });
223});366});
apps/mobile/src/features/home/homeThreadList.ts+73-1
...@@ -14,6 +14,7 @@ import type {...@@ -14,6 +14,7 @@ import type {
14 SidebarThreadSortOrder,14 SidebarThreadSortOrder,
15} from "@t3tools/contracts";15} from "@t3tools/contracts";
16import * as Arr from "effect/Array";16import * as Arr from "effect/Array";
17import * as Option from "effect/Option";
17import * as Order from "effect/Order";18import * as Order from "effect/Order";
1819
19import { scopedProjectKey } from "../../lib/scopedEntities";20import { scopedProjectKey } from "../../lib/scopedEntities";
...@@ -21,13 +22,33 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";...@@ -21,13 +22,33 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2122
22export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">;23export type HomeProjectSortOrder = Exclude<SidebarProjectSortOrder, "manual">;
2324
25/**
26 * Default home view only surfaces threads active within this window, to keep the
27 * screen compact while keeping recent work visible.
28 */
29const RECENT_THREAD_WINDOW_MS = 5 * 24 * 60 * 60 * 1000;
30/** Fallback when a project has no threads inside the recency window. */
31const RECENT_THREAD_FALLBACK_COUNT = 3;
32
24export interface HomeThreadGroup {33export interface HomeThreadGroup {
25 readonly key: string;34 readonly key: string;
26 readonly title: string;35 readonly title: string;
27 readonly representative: EnvironmentProject;36 readonly representative: EnvironmentProject;
28 readonly projects: ReadonlyArray<EnvironmentProject>;37 readonly projects: ReadonlyArray<EnvironmentProject>;
29 readonly pendingTasks: ReadonlyArray<PendingNewTask>;38 readonly pendingTasks: ReadonlyArray<PendingNewTask>;
39 /** Full sorted thread history for the group (revealed when expanded / searching). */
30 readonly threads: ReadonlyArray<EnvironmentThreadShell>;40 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
41 /** Subset shown by default: threads from the last few days, or the most recent few. */
42 readonly recentThreads: ReadonlyArray<EnvironmentThreadShell>;
43 /**
44 * Where a quick "new thread in this project" should land. For aggregated
45 * groups (same repo on several machines) this is the member that owns the
46 * group's most recent thread — the machine the user last worked on — rather
47 * than the arbitrary first member; the draft's computer picker covers
48 * switching from there. Null only for synthetic pending-project groups,
49 * whose single "project" is a placeholder built from queued-task metadata.
50 */
51 readonly newThreadTarget: EnvironmentProject | null;
31}52}
3253
33interface MutableHomeThreadGroup {54interface MutableHomeThreadGroup {
...@@ -48,6 +69,24 @@ function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOr...@@ -48,6 +69,24 @@ function groupSortTimestamp(group: HomeThreadGroup, sortOrder: HomeProjectSortOr
48 }, latestThread);69 }, latestThread);
49}70}
5071
72/**
73 * Trims a group's threads to recent activity for the default home view.
74 * `sortedThreads` must already be ordered newest-first for `threadSortOrder`.
75 * Keeps threads within {@link RECENT_THREAD_WINDOW_MS}; when none qualify, keeps
76 * the most recent {@link RECENT_THREAD_FALLBACK_COUNT} so a project never vanishes.
77 */
78function selectRecentThreads(
79 sortedThreads: ReadonlyArray<EnvironmentThreadShell>,
80 threadSortOrder: SidebarThreadSortOrder,
81 now: number,
82): ReadonlyArray<EnvironmentThreadShell> {
83 const cutoff = now - RECENT_THREAD_WINDOW_MS;
84 const recent = sortedThreads.filter(
85 (thread) => getThreadSortTimestamp(thread, threadSortOrder) >= cutoff,
86 );
87 return recent.length > 0 ? recent : sortedThreads.slice(0, RECENT_THREAD_FALLBACK_COUNT);
88}
89
51export function buildHomeThreadGroups(input: {90export function buildHomeThreadGroups(input: {
52 readonly projects: ReadonlyArray<EnvironmentProject>;91 readonly projects: ReadonlyArray<EnvironmentProject>;
53 readonly threads: ReadonlyArray<EnvironmentThreadShell>;92 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
...@@ -57,7 +96,10 @@ export function buildHomeThreadGroups(input: {...@@ -57,7 +96,10 @@ export function buildHomeThreadGroups(input: {
57 readonly projectSortOrder: HomeProjectSortOrder;96 readonly projectSortOrder: HomeProjectSortOrder;
58 readonly threadSortOrder: SidebarThreadSortOrder;97 readonly threadSortOrder: SidebarThreadSortOrder;
59 readonly projectGroupingMode: SidebarProjectGroupingMode;98 readonly projectGroupingMode: SidebarProjectGroupingMode;
99 /** Current time used for the recency window; defaults to now. Injectable for tests. */
100 readonly now?: number;
60}): ReadonlyArray<HomeThreadGroup> {101}): ReadonlyArray<HomeThreadGroup> {
102 const now = input.now ?? Date.now();
61 const groups = new Map<string, MutableHomeThreadGroup>();103 const groups = new Map<string, MutableHomeThreadGroup>();
62 const groupKeyByProjectKey = new Map<string, string>();104 const groupKeyByProjectKey = new Map<string, string>();
63105
...@@ -165,13 +207,43 @@ export function buildHomeThreadGroups(input: {...@@ -165,13 +207,43 @@ export function buildHomeThreadGroups(input: {
165 continue;207 continue;
166 }208 }
167209
210 const sortedThreads = sortThreads(matchingThreads, input.threadSortOrder);
211 // An active search should reach the full history, so the recency window
212 // only trims the default (no-query) view.
213 const recentThreads =
214 query.length === 0
215 ? selectRecentThreads(sortedThreads, input.threadSortOrder, now)
216 : sortedThreads;
217
218 // Sorted newest-first, so the first thread whose project is a group member
219 // marks the machine the user last worked on.
220 const lastActiveProject = Arr.findFirst(sortedThreads, (thread) =>
221 group.projects.some(
222 (project) =>
223 project.environmentId === thread.environmentId && project.id === thread.projectId,
224 ),
225 ).pipe(
226 Option.flatMap((thread) =>
227 Arr.findFirst(
228 group.projects,
229 (project) =>
230 project.environmentId === thread.environmentId && project.id === thread.projectId,
231 ),
232 ),
233 Option.getOrNull,
234 );
235
168 result.push({236 result.push({
169 key: group.key,237 key: group.key,
170 title,238 title,
171 representative,239 representative,
172 projects: group.projects,240 projects: group.projects,
173 pendingTasks: matchingPendingTasks,241 pendingTasks: matchingPendingTasks,
174 threads: sortThreads(matchingThreads, input.threadSortOrder),242 threads: sortedThreads,
243 recentThreads,
244 newThreadTarget: group.key.startsWith("pending-project:")
245 ? null
246 : (lastActiveProject ?? representative),
175 });247 });
176 }248 }
177249
apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx+19-1
...@@ -1,4 +1,7 @@...@@ -1,4 +1,7 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";1import type {
2 EnvironmentProject,
3 EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";
2import { EnvironmentId, ThreadId } from "@t3tools/contracts";5import { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import { useFocusEffect } from "@react-navigation/native";6import { useFocusEffect } from "@react-navigation/native";
4import {7import {
...@@ -401,6 +404,20 @@ export function AdaptiveWorkspaceLayout(props: {...@@ -401,6 +404,20 @@ export function AdaptiveWorkspaceLayout(props: {
401 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" });404 navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" });
402 }, [navigation]);405 }, [navigation]);
403406
407 const handleNewThreadInProject = useCallback(
408 (project: EnvironmentProject) => {
409 navigation.navigate("NewTaskSheet", {
410 screen: "NewTaskDraft",
411 params: {
412 environmentId: String(project.environmentId),
413 projectId: String(project.id),
414 title: project.title,
415 },
416 });
417 },
418 [navigation],
419 );
420
404 const renderedSidebarWidth = useSharedValue(421 const renderedSidebarWidth = useSharedValue(
405 panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0,422 panes.primarySidebarVisible ? (layout.listPaneWidth ?? 0) : 0,
406 );423 );
...@@ -477,6 +494,7 @@ export function AdaptiveWorkspaceLayout(props: {...@@ -477,6 +494,7 @@ export function AdaptiveWorkspaceLayout(props: {
477 selectedThreadKey={selectedThreadKey}494 selectedThreadKey={selectedThreadKey}
478 onOpenSettings={handleOpenSettings}495 onOpenSettings={handleOpenSettings}
479 onOpenEnvironmentSettings={handleOpenEnvironmentSettings}496 onOpenEnvironmentSettings={handleOpenEnvironmentSettings}
497 onNewThreadInProject={handleNewThreadInProject}
480 onSelectThread={handleSelectThread}498 onSelectThread={handleSelectThread}
481 onSearchQueryChange={setPrimarySidebarSearchQuery}499 onSearchQueryChange={setPrimarySidebarSearchQuery}
482 searchQuery={primarySidebarSearchQuery}500 searchQuery={primarySidebarSearchQuery}
apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+6-5
...@@ -111,7 +111,7 @@ export function NewTaskDraftScreen(props: {...@@ -111,7 +111,7 @@ export function NewTaskDraftScreen(props: {
111 }, [props.pendingTaskId, cancelEditingPendingTask]);111 }, [props.pendingTaskId, cancelEditingPendingTask]);
112112
113 const borderColor = useThemeColor("--color-border");113 const borderColor = useThemeColor("--color-border");
114 const bodyText = useScaledTextRole("body");114 const headlineText = useScaledTextRole("headline");
115 const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";115 const sheetFadeOpaque = colorScheme === "dark" ? "rgba(14,14,14,0.98)" : "rgba(242,242,247,0.98)";
116 const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)";116 const sheetFadeTransparent = colorScheme === "dark" ? "rgba(14,14,14,0)" : "rgba(242,242,247,0)";
117117
...@@ -129,12 +129,13 @@ export function NewTaskDraftScreen(props: {...@@ -129,12 +129,13 @@ export function NewTaskDraftScreen(props: {
129 lastInitialProjectRefRef.current = props.initialProjectRef;129 lastInitialProjectRefRef.current = props.initialProjectRef;
130 appliedInitialProjectKeyRef.current = null;130 appliedInitialProjectKeyRef.current = null;
131 }131 }
132 if (props.initialProjectRef?.environmentId && props.initialProjectRef?.projectId) {132 const initialEnvironmentId = props.initialProjectRef?.environmentId;
133 const initialProjectId = props.initialProjectRef?.projectId;
134 if (initialEnvironmentId && initialProjectId) {
133 const directProject =135 const directProject =
134 projects.find(136 projects.find(
135 (project) =>137 (project) =>
136 project.environmentId === props.initialProjectRef?.environmentId &&138 project.environmentId === initialEnvironmentId && project.id === initialProjectId,
137 project.id === props.initialProjectRef?.projectId,
138 ) ?? null;139 ) ?? null;
139140
140 if (directProject) {141 if (directProject) {
...@@ -602,7 +603,7 @@ export function NewTaskDraftScreen(props: {...@@ -602,7 +603,7 @@ export function NewTaskDraftScreen(props: {
602 onPasteImages={(uris) => void handleNativePasteImages(uris)}603 onPasteImages={(uris) => void handleNativePasteImages(uris)}
603 placeholder={`Describe a coding task in ${selectedProject.title}`}604 placeholder={`Describe a coding task in ${selectedProject.title}`}
604 style={{ flex: 1, minHeight: 0 }}605 style={{ flex: 1, minHeight: 0 }}
605 textStyle={bodyText}606 textStyle={headlineText}
606 />607 />
607 </View>608 </View>
608609
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+129-143
...@@ -18,10 +18,8 @@ import { formatElapsed } from "@t3tools/shared/orchestrationTiming";...@@ -18,10 +18,8 @@ import { formatElapsed } from "@t3tools/shared/orchestrationTiming";
18import * as Haptics from "expo-haptics";18import * as Haptics from "expo-haptics";
19import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";19import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
20import { View, type GestureResponderEvent } from "react-native";20import { View, type GestureResponderEvent } from "react-native";
21import { Gesture, GestureDetector } from "react-native-gesture-handler";21import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller";
22import { KeyboardStickyView } from "react-native-keyboard-controller";
23import { useSafeAreaInsets } from "react-native-safe-area-context";22import { useSafeAreaInsets } from "react-native-safe-area-context";
24import { runOnJS } from "react-native-reanimated";
2523
26import { AppText as Text } from "../../components/AppText";24import { AppText as Text } from "../../components/AppText";
27import type { ComposerEditorHandle } from "../../components/ComposerEditor";25import type { ComposerEditorHandle } from "../../components/ComposerEditor";
...@@ -73,7 +71,6 @@ export interface ThreadDetailScreenProps {...@@ -73,7 +71,6 @@ export interface ThreadDetailScreenProps {
73 readonly layoutVariant?: LayoutVariant;71 readonly layoutVariant?: LayoutVariant;
74 readonly usesAutomaticContentInsets?: boolean;72 readonly usesAutomaticContentInsets?: boolean;
75 readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;73 readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void;
76 readonly onOpenDrawer: () => void;
77 readonly onOpenConnectionEditor: () => void;74 readonly onOpenConnectionEditor: () => void;
78 readonly onChangeDraftMessage: (value: string) => void;75 readonly onChangeDraftMessage: (value: string) => void;
79 readonly onPickDraftImages: () => Promise<void>;76 readonly onPickDraftImages: () => Promise<void>;
...@@ -208,8 +205,6 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: {...@@ -208,8 +205,6 @@ const WorkingDurationPill = memo(function WorkingDurationPill(props: {
208});205});
209206
210export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {207export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: ThreadDetailScreenProps) {
211 const { onOpenDrawer } = props;
212
213 const insets = useSafeAreaInsets();208 const insets = useSafeAreaInsets();
214 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;209 const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`;
215 const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);210 const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
...@@ -263,28 +258,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -263,28 +258,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
263 [props.serverConfig, selectedInstanceId],258 [props.serverConfig, selectedInstanceId],
264 );259 );
265260
266 const completeDrawerGesture = useCallback(() => {
267 void Haptics.selectionAsync();
268 onOpenDrawer();
269 }, [onOpenDrawer]);
270
271 const drawerGestureThreshold = 80;
272 const headerDrawerGesture = useMemo(
273 () =>
274 Gesture.Pan()
275 .enabled(!isSplitLayout)
276 .hitSlop({ left: 0, width: 40 })
277 .activeOffsetX([10, 999])
278 .failOffsetY([-24, 24])
279 .onEnd((event) => {
280 const translationX = Math.max(event.translationX, 0);
281 if (event.y < drawerGestureThreshold && translationX > 56) {
282 runOnJS(completeDrawerGesture)();
283 }
284 }),
285 [completeDrawerGesture, isSplitLayout],
286 );
287
288 useLayoutEffect(() => {261 useLayoutEffect(() => {
289 selectedThreadKeyRef.current = selectedThreadKey;262 selectedThreadKeyRef.current = selectedThreadKey;
290 }, [selectedThreadKey]);263 }, [selectedThreadKey]);
...@@ -311,16 +284,31 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -311,16 +284,31 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
311 return;284 return;
312 }285 }
313 lastScrolledAnchorMessageIdRef.current = anchorMessageId;286 lastScrolledAnchorMessageIdRef.current = anchorMessageId;
314 void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => {287 // Wait for the keyboard dismissal (started by blur() on send) to finish
315 if (288 // before scrolling: scrollMessageToEnd freezes keyboard-driven inset
316 selectedThreadKeyRef.current !== targetThreadKey ||289 // updates while it runs, and a close event swallowed by that freeze
317 lastScrolledAnchorMessageIdRef.current !== anchorMessageId290 // leaves the keyboard padding permanently applied — overshooting the
318 ) {291 // anchor and leaving a phantom bottom inset once the reply streams in.
319 return;292 void KeyboardController.dismiss()
320 }293 .then(() => {
321 lastScrolledAnchorMessageIdRef.current = null;294 if (
322 freeze.set(false);295 selectedThreadKeyRef.current !== targetThreadKey ||
323 });296 lastScrolledAnchorMessageIdRef.current !== anchorMessageId
297 ) {
298 return;
299 }
300 return scrollMessageToEnd({ animated: true, closeKeyboard: false });
301 })
302 .catch(() => {
303 if (
304 selectedThreadKeyRef.current !== targetThreadKey ||
305 lastScrolledAnchorMessageIdRef.current !== anchorMessageId
306 ) {
307 return;
308 }
309 lastScrolledAnchorMessageIdRef.current = null;
310 freeze.set(false);
311 });
324 });312 });
325 return () => cancelAnimationFrame(frame);313 return () => cancelAnimationFrame(frame);
326 }, [314 }, [
...@@ -379,115 +367,113 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -379,115 +367,113 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
379 }, []);367 }, []);
380368
381 return (369 return (
382 <GestureDetector gesture={headerDrawerGesture}>370 <View className="flex-1">
383 <View className="flex-1">371 {showContent ? (
384 {showContent ? (372 <View
373 style={{ flex: 1 }}
374 onTouchStart={handleFeedTouchStart}
375 onTouchMove={handleFeedTouchMove}
376 onTouchEnd={handleFeedTouchEnd}
377 onTouchCancel={handleFeedTouchCancel}
378 >
379 <ThreadFeed
380 key={props.selectedThread.id}
381 environmentId={props.environmentId}
382 threadId={props.selectedThread.id}
383 workspaceRoot={props.threadCwd}
384 feed={props.selectedThreadFeed}
385 contentPresentation={props.contentPresentation}
386 agentLabel={agentLabel}
387 latestTurn={props.selectedThread.latestTurn}
388 listRef={listRef}
389 freeze={freeze}
390 anchorMessageId={anchorMessageId}
391 contentInsetEndAdjustment={contentInsetEndAdjustment}
392 contentTopInset={0}
393 contentBottomInset={estimatedOverlayHeight}
394 contentMaxWidth={contentMaxWidth}
395 layoutVariant={layoutVariant}
396 usesAutomaticContentInsets={props.usesAutomaticContentInsets}
397 onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}
398 skills={selectedProviderSkills}
399 />
400 </View>
401 ) : (
402 <View style={{ flex: 1 }} />
403 )}
404
405 {/* Floating composer — sticks to keyboard via KeyboardStickyView */}
406 {showContent ? (
407 <KeyboardStickyView
408 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
409 offset={{ closed: 0, opened: 0 }}
410 >
385 <View411 <View
386 style={{ flex: 1 }}412 ref={composerOverlayRef}
387 onTouchStart={handleFeedTouchStart}413 onLayout={onComposerLayout}
388 onTouchMove={handleFeedTouchMove}414 style={{ width: "100%", paddingTop: 8 }}
389 onTouchEnd={handleFeedTouchEnd}
390 onTouchCancel={handleFeedTouchCancel}
391 >415 >
392 <ThreadFeed416 <View style={{ alignSelf: "center", maxWidth: contentMaxWidth, width: "100%" }}>
393 key={props.selectedThread.id}417 {props.activeWorkStartedAt ? (
394 environmentId={props.environmentId}418 <WorkingDurationPill startedAt={props.activeWorkStartedAt} />
395 threadId={props.selectedThread.id}419 ) : null}
396 workspaceRoot={props.threadCwd}420
397 feed={props.selectedThreadFeed}421 {props.activePendingApproval || props.activePendingUserInput ? (
398 contentPresentation={props.contentPresentation}422 <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}>
399 agentLabel={agentLabel}423 {props.activePendingApproval ? (
400 latestTurn={props.selectedThread.latestTurn}424 <PendingApprovalCard
401 listRef={listRef}425 approval={props.activePendingApproval}
402 freeze={freeze}426 respondingApprovalId={props.respondingApprovalId}
403 anchorMessageId={anchorMessageId}427 onRespond={props.onRespondToApproval}
404 contentInsetEndAdjustment={contentInsetEndAdjustment}428 />
405 contentTopInset={0}429 ) : null}
406 contentBottomInset={estimatedOverlayHeight}430 {props.activePendingUserInput ? (
431 <PendingUserInputCard
432 pendingUserInput={props.activePendingUserInput}
433 drafts={props.activePendingUserInputDrafts}
434 answers={props.activePendingUserInputAnswers}
435 respondingUserInputId={props.respondingUserInputId}
436 onSelectOption={props.onSelectUserInputOption}
437 onChangeCustomAnswer={props.onChangeUserInputCustomAnswer}
438 onSubmit={props.onSubmitUserInput}
439 />
440 ) : null}
441 </View>
442 ) : null}
443 </View>
444
445 <ThreadComposer
446 editorRef={composerEditorRef}
447 draftMessage={props.draftMessage}
448 draftAttachments={props.draftAttachments}
449 placeholder="Ask the repo agent, or run a command…"
407 contentMaxWidth={contentMaxWidth}450 contentMaxWidth={contentMaxWidth}
408 layoutVariant={layoutVariant}451 connectionState={props.connectionStateLabel}
409 usesAutomaticContentInsets={props.usesAutomaticContentInsets}452 connectionError={props.connectionError}
410 onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange}453 environmentLabel={props.environmentLabel}
411 skills={selectedProviderSkills}454 threadSyncPhase={threadSyncPhase}
455 selectedThread={props.selectedThread}
456 serverConfig={props.serverConfig}
457 queueCount={props.selectedThreadQueueCount}
458 activeThreadBusy={props.activeThreadBusy}
459 environmentId={props.environmentId}
460 projectCwd={props.projectWorkspaceRoot}
461 bottomInset={composerBottomInset}
462 onChangeDraftMessage={props.onChangeDraftMessage}
463 onPickDraftImages={props.onPickDraftImages}
464 onNativePasteImages={props.onNativePasteImages}
465 onRemoveDraftImage={props.onRemoveDraftImage}
466 onStopThread={props.onStopThread}
467 onSendMessage={handleSendMessage}
468 onReconnectEnvironment={props.onReconnectEnvironment}
469 onUpdateModelSelection={props.onUpdateThreadModelSelection}
470 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
471 onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
472 onExpandedChange={setComposerExpanded}
412 />473 />
413 </View>474 </View>
414 ) : (475 </KeyboardStickyView>
415 <View style={{ flex: 1 }} />476 ) : null}
416 )}477 </View>
417
418 {/* Floating composer — sticks to keyboard via KeyboardStickyView */}
419 {showContent ? (
420 <KeyboardStickyView
421 style={{ position: "absolute", bottom: 0, left: 0, right: 0 }}
422 offset={{ closed: 0, opened: 0 }}
423 >
424 <View
425 ref={composerOverlayRef}
426 onLayout={onComposerLayout}
427 style={{ width: "100%", paddingTop: 8 }}
428 >
429 <View style={{ alignSelf: "center", maxWidth: contentMaxWidth, width: "100%" }}>
430 {props.activeWorkStartedAt ? (
431 <WorkingDurationPill startedAt={props.activeWorkStartedAt} />
432 ) : null}
433
434 {props.activePendingApproval || props.activePendingUserInput ? (
435 <View className="gap-3 px-4 pb-3" style={{ flexShrink: 0 }}>
436 {props.activePendingApproval ? (
437 <PendingApprovalCard
438 approval={props.activePendingApproval}
439 respondingApprovalId={props.respondingApprovalId}
440 onRespond={props.onRespondToApproval}
441 />
442 ) : null}
443 {props.activePendingUserInput ? (
444 <PendingUserInputCard
445 pendingUserInput={props.activePendingUserInput}
446 drafts={props.activePendingUserInputDrafts}
447 answers={props.activePendingUserInputAnswers}
448 respondingUserInputId={props.respondingUserInputId}
449 onSelectOption={props.onSelectUserInputOption}
450 onChangeCustomAnswer={props.onChangeUserInputCustomAnswer}
451 onSubmit={props.onSubmitUserInput}
452 />
453 ) : null}
454 </View>
455 ) : null}
456 </View>
457
458 <ThreadComposer
459 editorRef={composerEditorRef}
460 draftMessage={props.draftMessage}
461 draftAttachments={props.draftAttachments}
462 placeholder="Ask the repo agent, or run a command…"
463 contentMaxWidth={contentMaxWidth}
464 connectionState={props.connectionStateLabel}
465 connectionError={props.connectionError}
466 environmentLabel={props.environmentLabel}
467 threadSyncPhase={threadSyncPhase}
468 selectedThread={props.selectedThread}
469 serverConfig={props.serverConfig}
470 queueCount={props.selectedThreadQueueCount}
471 activeThreadBusy={props.activeThreadBusy}
472 environmentId={props.environmentId}
473 projectCwd={props.projectWorkspaceRoot}
474 bottomInset={composerBottomInset}
475 onChangeDraftMessage={props.onChangeDraftMessage}
476 onPickDraftImages={props.onPickDraftImages}
477 onNativePasteImages={props.onNativePasteImages}
478 onRemoveDraftImage={props.onRemoveDraftImage}
479 onStopThread={props.onStopThread}
480 onSendMessage={handleSendMessage}
481 onReconnectEnvironment={props.onReconnectEnvironment}
482 onUpdateModelSelection={props.onUpdateThreadModelSelection}
483 onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode}
484 onUpdateInteractionMode={props.onUpdateThreadInteractionMode}
485 onExpandedChange={setComposerExpanded}
486 />
487 </View>
488 </KeyboardStickyView>
489 ) : null}
490 </View>
491 </GestureDetector>
492 );478 );
493});479});
apps/mobile/src/features/threads/ThreadFeed.tsx+4
...@@ -1508,6 +1508,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {...@@ -1508,6 +1508,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
1508 // mount positions during attach, where UIKit applies the inset.1508 // mount positions during attach, where UIKit applies the inset.
1509 key={`${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`}1509 key={`${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`}
1510 style={{ flex: 1 }}1510 style={{ flex: 1 }}
1511 // RN 0.81+ drops touches inside the contentInset area
1512 // (facebook/react-native#54123); the anchored end space after a send
1513 // is pure inset, so without this the blank region can't be scrolled.
1514 applyWorkaroundForContentInsetHitTestBug
1511 contentInsetAdjustmentBehavior={usesNativeAutomaticInsets ? "automatic" : "never"}1515 contentInsetAdjustmentBehavior={usesNativeAutomaticInsets ? "automatic" : "never"}
1512 automaticallyAdjustsScrollIndicatorInsets={usesNativeAutomaticInsets}1516 automaticallyAdjustsScrollIndicatorInsets={usesNativeAutomaticInsets}
1513 {...(usesNativeAutomaticInsets1517 {...(usesNativeAutomaticInsets
apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx deleted-255
...@@ -1,255 +0,0 @@
1import { SymbolView } from "expo-symbols";
2import { useCallback, useEffect, useMemo, useState } from "react";
3import {
4 type ColorValue,
5 Modal,
6 Pressable,
7 ScrollView,
8 useWindowDimensions,
9 View,
10} from "react-native";
11import { Gesture, GestureDetector } from "react-native-gesture-handler";
12import { useSafeAreaInsets } from "react-native-safe-area-context";
13import Animated, {
14 runOnJS,
15 useAnimatedStyle,
16 useSharedValue,
17 withTiming,
18} from "react-native-reanimated";
19import { useThemeColor } from "../../lib/useThemeColor";
20
21import { AppText as Text } from "../../components/AppText";
22import { StatusPill } from "../../components/StatusPill";
23import { useProjects, useThreadShells } from "../../state/entities";
24import { scopedThreadKey } from "../../lib/scopedEntities";
25import { relativeTime } from "../../lib/time";
26import { resolveThreadStatus } from "./threadPresentation";
27import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
28import { buildThreadNavigationGroups } from "./thread-navigation-groups";
29
30export function ThreadNavigationDrawer(props: {
31 readonly visible: boolean;
32 readonly selectedThreadKey: string | null;
33 readonly onClose: () => void;
34 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
35 readonly onStartNewTask: () => void;
36}) {
37 const insets = useSafeAreaInsets();
38 const { width } = useWindowDimensions();
39 const drawerWidth = Math.min(width * 0.84, 360);
40 const [mounted, setMounted] = useState(props.visible);
41 const translateX = useSharedValue(-drawerWidth);
42 const overlayOpacity = useSharedValue(0);
43
44 const backdropColor = useThemeColor("--color-backdrop");
45 const drawerBg = useThemeColor("--color-drawer");
46 const drawerShadow = useThemeColor("--color-drawer-shadow");
47 const primaryForeground = useThemeColor("--color-primary-foreground");
48 const borderSubtleColor = useThemeColor("--color-border-subtle");
49
50 useEffect(() => {
51 if (props.visible) {
52 setMounted(true);
53 translateX.value = withTiming(0, { duration: 240 });
54 overlayOpacity.value = withTiming(1, { duration: 220 });
55 return;
56 }
57
58 overlayOpacity.value = withTiming(0, { duration: 180 });
59 translateX.value = withTiming(-drawerWidth, { duration: 220 }, (finished) => {
60 if (finished) {
61 runOnJS(setMounted)(false);
62 }
63 });
64 }, [drawerWidth, overlayOpacity, props.visible, translateX]);
65
66 const closeDrawer = useCallback(() => {
67 props.onClose();
68 }, [props]);
69
70 const panGesture = useMemo(
71 () =>
72 Gesture.Pan()
73 .activeOffsetX([-12, 12])
74 .failOffsetY([-24, 24])
75 .onUpdate((event) => {
76 translateX.value = Math.min(0, event.translationX);
77 })
78 .onEnd((event) => {
79 const shouldClose = event.translationX < -drawerWidth * 0.2 || event.velocityX < -500;
80 if (shouldClose) {
81 runOnJS(closeDrawer)();
82 return;
83 }
84
85 translateX.value = withTiming(0, { duration: 180 });
86 }),
87 [closeDrawer, drawerWidth, translateX],
88 );
89
90 const drawerStyle = useAnimatedStyle(() => ({
91 transform: [{ translateX: translateX.value }],
92 }));
93
94 const backdropStyle = useAnimatedStyle(() => ({
95 opacity: overlayOpacity.value,
96 }));
97
98 if (!mounted) {
99 return null;
100 }
101
102 return (
103 <Modal transparent visible={mounted} onRequestClose={props.onClose} statusBarTranslucent>
104 <View style={{ flex: 1 }}>
105 <Animated.View
106 pointerEvents="none"
107 style={[
108 {
109 position: "absolute",
110 left: 0,
111 right: 0,
112 top: 0,
113 bottom: 0,
114 backgroundColor: backdropColor,
115 },
116 backdropStyle,
117 ]}
118 />
119 <Pressable
120 style={{ position: "absolute", left: 0, right: 0, top: 0, bottom: 0 }}
121 onPress={props.onClose}
122 />
123
124 <GestureDetector gesture={panGesture}>
125 <Animated.View
126 style={[
127 {
128 position: "absolute",
129 left: 0,
130 top: 0,
131 bottom: 0,
132 width: drawerWidth,
133 backgroundColor: drawerBg,
134 paddingTop: insets.top + 10,
135 paddingBottom: Math.max(insets.bottom, 18),
136 boxShadow: `20px 0 36px ${String(drawerShadow)}`,
137 },
138 drawerStyle,
139 ]}
140 >
141 <View className="flex-row items-center justify-between px-4 pb-5">
142 <Text className="text-2xl font-t3-bold">Threads</Text>
143 <Pressable
144 onPress={() => {
145 props.onClose();
146 props.onStartNewTask();
147 }}
148 className="h-11 w-11 items-center justify-center rounded-full bg-primary"
149 >
150 <SymbolView
151 name="square.and.pencil"
152 size={17}
153 tintColor={primaryForeground}
154 type="monochrome"
155 />
156 </Pressable>
157 </View>
158
159 <ThreadNavigationDrawerContent
160 bottomInset={Math.max(insets.bottom, 18)}
161 borderSubtleColor={borderSubtleColor}
162 selectedThreadKey={props.selectedThreadKey}
163 onClose={props.onClose}
164 onSelectThread={props.onSelectThread}
165 />
166 </Animated.View>
167 </GestureDetector>
168 </View>
169 </Modal>
170 );
171}
172
173function ThreadNavigationDrawerContent(props: {
174 readonly bottomInset: number;
175 readonly borderSubtleColor: ColorValue;
176 readonly selectedThreadKey: string | null;
177 readonly onClose: () => void;
178 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
179}) {
180 const projects = useProjects();
181 const threads = useThreadShells();
182 const groupedThreads = useMemo(
183 () => buildThreadNavigationGroups({ projects, threads }),
184 [projects, threads],
185 );
186
187 return (
188 <ScrollView
189 showsVerticalScrollIndicator={false}
190 contentInset={{ bottom: props.bottomInset + 12 }}
191 contentContainerStyle={{
192 gap: 20,
193 paddingHorizontal: 14,
194 }}
195 >
196 {groupedThreads.map((group) => (
197 <View key={group.key} className="gap-3">
198 <Text
199 className="px-1 text-base font-t3-bold text-foreground-muted"
200 style={{ letterSpacing: -0.2 }}
201 >
202 {group.title}
203 </Text>
204
205 <View className="overflow-hidden rounded-[22px] bg-card">
206 {group.threads.length === 0 ? (
207 <View className="px-4 py-4">
208 <Text className="text-sm font-medium text-foreground-tertiary">No threads yet</Text>
209 </View>
210 ) : (
211 group.threads.map((thread, index) => {
212 const threadKey = scopedThreadKey(thread.environmentId, thread.id);
213 const selected = props.selectedThreadKey === threadKey;
214 const status = resolveThreadStatus(thread);
215
216 return (
217 <Pressable
218 key={threadKey}
219 onPress={() => {
220 props.onSelectThread(thread);
221 props.onClose();
222 }}
223 style={{
224 paddingHorizontal: 16,
225 paddingVertical: 15,
226 borderTopWidth: index === 0 ? 0 : 1,
227 borderTopColor: props.borderSubtleColor,
228 backgroundColor: selected ? undefined : "transparent",
229 }}
230 className={selected ? "bg-subtle" : undefined}
231 >
232 <View className="flex-row items-start justify-between gap-3">
233 <View className="flex-1 gap-1">
234 <Text className="text-base font-t3-bold" numberOfLines={1}>
235 {thread.title}
236 </Text>
237 <Text
238 className="text-sm font-medium text-foreground-muted"
239 numberOfLines={1}
240 >
241 {relativeTime(thread.updatedAt ?? thread.createdAt)}
242 </Text>
243 </View>
244 {status ? <StatusPill {...status} /> : null}
245 </View>
246 </Pressable>
247 );
248 })
249 )}
250 </View>
251 </View>
252 ))}
253 </ScrollView>
254 );
255}
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+11-1
...@@ -1,5 +1,8 @@...@@ -1,5 +1,8 @@
1import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";1import { isLiquidGlassSupported, LiquidGlassView } from "@callstack/liquid-glass";
2import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";2import type {
3 EnvironmentProject,
4 EnvironmentThreadShell,
5} from "@t3tools/client-runtime/state/shell";
3import { LegendList } from "@legendapp/list/react-native";6import { LegendList } from "@legendapp/list/react-native";
4import type { MenuAction } from "@react-native-menu/menu";7import type { MenuAction } from "@react-native-menu/menu";
5import { SymbolView } from "expo-symbols";8import { SymbolView } from "expo-symbols";
...@@ -107,6 +110,7 @@ interface ThreadNavigationSidebarProps {...@@ -107,6 +110,7 @@ interface ThreadNavigationSidebarProps {
107 readonly selectedThreadKey: string | null;110 readonly selectedThreadKey: string | null;
108 readonly onOpenSettings: () => void;111 readonly onOpenSettings: () => void;
109 readonly onOpenEnvironmentSettings: () => void;112 readonly onOpenEnvironmentSettings: () => void;
113 readonly onNewThreadInProject: (project: EnvironmentProject) => void;
110 readonly onSearchQueryChange: (query: string) => void;114 readonly onSearchQueryChange: (query: string) => void;
111 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;115 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
112 readonly onRequestVisibility: () => void;116 readonly onRequestVisibility: () => void;
...@@ -413,6 +417,11 @@ function ThreadNavigationSidebarPane(...@@ -413,6 +417,11 @@ function ThreadNavigationSidebarPane(
413 isFirst={item.isFirst}417 isFirst={item.isFirst}
414 groupKey={item.group.key}418 groupKey={item.group.key}
415 onGroupAction={updateGroupDisplay}419 onGroupAction={updateGroupDisplay}
420 // Same gating as the compact Home list: aggregated groups have no
421 // single target project, and pending-project groups hold a
422 // placeholder shell rather than a real project.
423 newThreadTarget={item.group.newThreadTarget}
424 onNewThread={props.onNewThreadInProject}
416 project={item.group.representative}425 project={item.group.representative}
417 threadCount={item.group.threads.length + item.group.pendingTasks.length}426 threadCount={item.group.threads.length + item.group.pendingTasks.length}
418 title={item.group.title}427 title={item.group.title}
...@@ -480,6 +489,7 @@ function ThreadNavigationSidebarPane(...@@ -480,6 +489,7 @@ function ThreadNavigationSidebarPane(
480 handleSwipeableWillOpen,489 handleSwipeableWillOpen,
481 openPendingTask,490 openPendingTask,
482 projectCwdByKey,491 projectCwdByKey,
492 props.onNewThreadInProject,
483 props.selectedThreadKey,493 props.selectedThreadKey,
484 props.width,494 props.width,
485 savedConnectionsById,495 savedConnectionsById,
apps/mobile/src/features/threads/ThreadRouteScreen.tsx+26-36
...@@ -49,7 +49,6 @@ import {...@@ -49,7 +49,6 @@ import {
49 useThreadGitRightHeaderItems,49 useThreadGitRightHeaderItems,
50} from "./ThreadGitControls";50} from "./ThreadGitControls";
51import { GitOverviewSheet } from "./git/GitOverviewSheet";51import { GitOverviewSheet } from "./git/GitOverviewSheet";
52import { ThreadNavigationDrawer } from "./ThreadNavigationDrawer";
53import { useAtomCommand } from "../../state/use-atom-command";52import { useAtomCommand } from "../../state/use-atom-command";
54import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";53import { useSelectedThreadGitActions } from "../../state/use-selected-thread-git-actions";
55import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state";54import { useSelectedThreadGitState } from "../../state/use-selected-thread-git-state";
...@@ -197,7 +196,6 @@ function ThreadRouteContent(...@@ -197,7 +196,6 @@ function ThreadRouteContent(
197 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");196 const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
198 const navigation = useNavigation();197 const navigation = useNavigation();
199 const params = props.route.params;198 const params = props.route.params;
200 const [drawerVisible, setDrawerVisible] = useState(false);
201 const environmentIdRaw = firstRouteParam(params.environmentId);199 const environmentIdRaw = firstRouteParam(params.environmentId);
202 const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;200 const environmentId = environmentIdRaw ? EnvironmentId.make(environmentIdRaw) : null;
203 const threadId = firstRouteParam(params.threadId);201 const threadId = firstRouteParam(params.threadId);
...@@ -326,18 +324,6 @@ function ThreadRouteContent(...@@ -326,18 +324,6 @@ function ThreadRouteContent(
326 );324 );
327 const gitActionProgress = useGitActionProgress(gitActionProgressTarget);325 const gitActionProgress = useGitActionProgress(gitActionProgressTarget);
328326
329 const handleOpenDrawer = useCallback(() => {
330 if (!layout.usesSplitView) {
331 setDrawerVisible(true);
332 }
333 }, [layout.usesSplitView]);
334
335 useEffect(() => {
336 if (layout.usesSplitView) {
337 setDrawerVisible(false);
338 }
339 }, [layout.usesSplitView]);
340
341 const handleOpenGitInspector = useCallback(() => {327 const handleOpenGitInspector = useCallback(() => {
342 setInspectorSelection({ routeThreadIdentity, mode: "git" });328 setInspectorSelection({ routeThreadIdentity, mode: "git" });
343 showAuxiliaryPane("inspector");329 showAuxiliaryPane("inspector");
...@@ -655,6 +641,22 @@ function ThreadRouteContent(...@@ -655,6 +641,22 @@ function ThreadRouteContent(
655 ],641 ],
656 [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar],642 [panes.primarySidebarVisible, props.onReturnToThread, navigation, togglePrimarySidebar],
657 );643 );
644 // Deep links / cold starts land with Thread as the ONLY route, where the
645 // native back button does not render. Provide an explicit Home escape for
646 // that case; when history exists the native back button is used instead.
647 const canGoBack = navigation.canGoBack();
648 const compactHomeHeaderItems = useMemo<NativeHeaderItems>(
649 () => [
650 withNativeGlassHeaderItem({
651 accessibilityLabel: "Go to threads list",
652 icon: { name: "list.bullet", type: "sfSymbol" as const },
653 identifier: "thread-left-home",
654 onPress: () => navigation.dispatch(StackActions.replace("Home")),
655 type: "button" as const,
656 }),
657 ],
658 [navigation],
659 );
658660
659 if (!environmentId || !threadId) {661 if (!environmentId || !threadId) {
660 return <OpeningThreadLoadingScreen />;662 return <OpeningThreadLoadingScreen />;
...@@ -704,7 +706,6 @@ function ThreadRouteContent(...@@ -704,7 +706,6 @@ function ThreadRouteContent(
704 selectedThreadQueueCount={composer.selectedThreadQueueCount}706 selectedThreadQueueCount={composer.selectedThreadQueueCount}
705 layoutVariant={layout.variant}707 layoutVariant={layout.variant}
706 usesAutomaticContentInsets={usesNativeHeaderGlass}708 usesAutomaticContentInsets={usesNativeHeaderGlass}
707 onOpenDrawer={handleOpenDrawer}
708 onOpenConnectionEditor={handleOpenConnectionEditor}709 onOpenConnectionEditor={handleOpenConnectionEditor}
709 onChangeDraftMessage={composer.onChangeDraftMessage}710 onChangeDraftMessage={composer.onChangeDraftMessage}
710 onPickDraftImages={composer.onPickDraftImages}711 onPickDraftImages={composer.onPickDraftImages}
...@@ -722,23 +723,6 @@ function ThreadRouteContent(...@@ -722,23 +723,6 @@ function ThreadRouteContent(
722 onChangeUserInputCustomAnswer={requests.onChangeUserInputCustomAnswer}723 onChangeUserInputCustomAnswer={requests.onChangeUserInputCustomAnswer}
723 onSubmitUserInput={requests.onSubmitUserInput}724 onSubmitUserInput={requests.onSubmitUserInput}
724 />725 />
725
726 {layout.usesSplitView ? null : (
727 <ThreadNavigationDrawer
728 visible={drawerVisible}
729 selectedThreadKey={selectedThreadKey}
730 onClose={() => setDrawerVisible(false)}
731 onSelectThread={(thread) => {
732 navigation.dispatch(
733 StackActions.replace("Thread", {
734 environmentId: String(thread.environmentId),
735 threadId: String(thread.id),
736 }),
737 );
738 }}
739 onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })}
740 />
741 )}
742 </View>726 </View>
743 </>727 </>
744 );728 );
...@@ -757,11 +741,17 @@ function ThreadRouteContent(...@@ -757,11 +741,17 @@ function ThreadRouteContent(
757 : undefined,741 : undefined,
758 title: selectedThread.title,742 title: selectedThread.title,
759 headerBackVisible: !layout.usesSplitView,743 headerBackVisible: !layout.usesSplitView,
760 // Compact uses the NATIVE back button (Thread lives flat in the root744 // Compact uses the NATIVE back button when a previous route exists;
761 // stack now, so a real previous route exists); only split view needs745 // deep links / cold starts get an explicit Home button instead.
762 // custom left items.746 // Split view always uses its custom left items.
763 unstable_headerLeftItems:747 unstable_headerLeftItems:
764 Platform.OS === "ios" && layout.usesSplitView ? () => splitLeftHeaderItems : undefined,748 Platform.OS === "ios"
749 ? layout.usesSplitView
750 ? () => splitLeftHeaderItems
751 : canGoBack
752 ? undefined
753 : () => compactHomeHeaderItems
754 : undefined,
765 // Search lives in the persistent sidebar, so the split header keeps755 // Search lives in the persistent sidebar, so the split header keeps
766 // the git controls on the RIGHT (no center items — center space is756 // the git controls on the RIGHT (no center items — center space is
767 // reserved for future breadcrumbs/status).757 // reserved for future breadcrumbs/status).
apps/mobile/src/features/threads/new-task-flow-provider.tsx+86-38
...@@ -231,40 +231,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -231,40 +231,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
231 }231 }
232 }, []);232 }, []);
233233
234 const environments = useMemo(
235 () =>
236 pipe(
237 [
238 ...new Set(
239 pipe(
240 projects,
241 Arr.map((project) => project.environmentId),
242 ),
243 ),
244 ],
245 Arr.map((environmentId) => {
246 const environment = savedConnectionsById[environmentId];
247 if (!environment) {
248 return null;
249 }
250
251 return {
252 environmentId,
253 environmentLabel: environment.environmentLabel,
254 };
255 }),
256 Arr.filter(
257 (
258 entry,
259 ): entry is {
260 readonly environmentId: EnvironmentId;
261 readonly environmentLabel: string;
262 } => entry !== null,
263 ),
264 ),
265 [projects, savedConnectionsById],
266 );
267
268 const projectsForEnvironment = useMemo(234 const projectsForEnvironment = useMemo(
269 () =>235 () =>
270 pipe(236 pipe(
...@@ -310,6 +276,62 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -310,6 +276,62 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
310 scopedProjectKey(editingPendingProject.environmentId, editingPendingProject.id)276 scopedProjectKey(editingPendingProject.environmentId, editingPendingProject.id)
311 ? editingPendingProject277 ? editingPendingProject
312 : (projectsForEnvironment[0] ?? null));278 : (projectsForEnvironment[0] ?? null));
279
280 // Only offer machines that actually host the currently selected repository, so
281 // switching computers moves the same repo across machines instead of jumping to
282 // whatever unrelated project happens to be first on the other machine. Repository
283 // identity is the primary signal; projects that haven't reported one yet (still
284 // indexing) fall back to workspace basename / title so a valid host isn't hidden.
285 const selectedRepositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null;
286 // `|| null` (not `??`): a pending-task placeholder project can have an empty
287 // workspaceRoot, and an "" basename would reject every real host below.
288 const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null;
289 const selectedProjectTitle = selectedProject?.title ?? null;
290 const environments = useMemo(() => {
291 const seen = new Set<EnvironmentId>();
292 const result: Array<{
293 readonly environmentId: EnvironmentId;
294 readonly environmentLabel: string;
295 }> = [];
296 const hostsSelectedRepository = (project: EnvironmentProject) => {
297 if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) {
298 return true;
299 }
300 const projectKey = project.repositoryIdentity?.canonicalKey ?? null;
301 if (selectedRepositoryKey !== null && projectKey !== null) {
302 return projectKey === selectedRepositoryKey;
303 }
304 return (
305 project.workspaceRoot.split("/").at(-1) === selectedWorkspaceBasename ||
306 (selectedProjectTitle !== null && project.title === selectedProjectTitle)
307 );
308 };
309 for (const project of projects) {
310 if (!hostsSelectedRepository(project)) {
311 continue;
312 }
313 if (seen.has(project.environmentId)) {
314 continue;
315 }
316 const environment = savedConnectionsById[project.environmentId];
317 if (!environment) {
318 continue;
319 }
320 seen.add(project.environmentId);
321 result.push({
322 environmentId: project.environmentId,
323 environmentLabel: environment.environmentLabel,
324 });
325 }
326 return result;
327 }, [
328 projects,
329 savedConnectionsById,
330 selectedRepositoryKey,
331 selectedWorkspaceBasename,
332 selectedProjectTitle,
333 ]);
334
313 const selectedEnvironmentServerConfig = useEnvironmentServerConfig(335 const selectedEnvironmentServerConfig = useEnvironmentServerConfig(
314 selectedProject?.environmentId ?? null,336 selectedProject?.environmentId ?? null,
315 );337 );
...@@ -480,10 +502,36 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {...@@ -480,10 +502,36 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) {
480 setSelectedProjectKey(nextProjectKey);502 setSelectedProjectKey(nextProjectKey);
481 }, []);503 }, []);
482504
483 const selectEnvironment = useCallback((environmentId: EnvironmentId) => {505 const selectEnvironment = useCallback(
484 setSelectedEnvironmentId(environmentId);506 (environmentId: EnvironmentId) => {
485 setSelectedProjectKey(null);507 const projectsOnTarget = projects.filter(
486 }, []);508 (project) => project.environmentId === environmentId,
509 );
510 const repositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null;
511 // Prefer the repository identity; projects without one (e.g. not yet
512 // indexed) fall back to workspace basename, then title, so switching
513 // computers still follows the same repo instead of resetting to
514 // whatever project is first on the target machine.
515 const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null;
516 const match =
517 (repositoryKey !== null
518 ? projectsOnTarget.find(
519 (project) => (project.repositoryIdentity?.canonicalKey ?? null) === repositoryKey,
520 )
521 : undefined) ??
522 (workspaceBasename !== null
523 ? projectsOnTarget.find(
524 (project) => project.workspaceRoot.split("/").at(-1) === workspaceBasename,
525 )
526 : undefined) ??
527 (selectedProject !== null
528 ? projectsOnTarget.find((project) => project.title === selectedProject.title)
529 : undefined);
530 setSelectedEnvironmentId(environmentId);
531 setSelectedProjectKey(match ? scopedProjectKey(match.environmentId, match.id) : null);
532 },
533 [projects, selectedProject],
534 );
487535
488 const setWorkspaceMode = useCallback(536 const setWorkspaceMode = useCallback(
489 (mode: WorkspaceMode) => {537 (mode: WorkspaceMode) => {
apps/mobile/src/features/threads/thread-list-items.tsx+56-23
...@@ -43,31 +43,54 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props:...@@ -43,31 +43,54 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props:
43 readonly isFirst: boolean;43 readonly isFirst: boolean;
44 readonly groupKey: string;44 readonly groupKey: string;
45 readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void;45 readonly onGroupAction: (key: string, action: HomeGroupDisplayAction) => void;
46 /** Project a quick new thread should target; null hides the button. */
47 readonly newThreadTarget?: EnvironmentProject | null;
48 readonly onNewThread?: (project: EnvironmentProject) => void;
46}) {49}) {
47 const iconSubtleColor = useThemeColor("--color-icon-subtle");50 const iconMutedColor = useThemeColor("--color-icon-muted");
48 const { groupKey, onGroupAction } = props;51 const { groupKey, onGroupAction, onNewThread } = props;
52 const newThreadTarget = props.newThreadTarget ?? null;
49 const compact = props.variant === "compact";53 const compact = props.variant === "compact";
50 const handleToggle = useCallback(54 const handleToggle = useCallback(
51 () => onGroupAction(groupKey, "toggle-collapsed"),55 () => onGroupAction(groupKey, "toggle-collapsed"),
52 [groupKey, onGroupAction],56 [groupKey, onGroupAction],
53 );57 );
58 const handleNewThread = useCallback(() => {
59 if (newThreadTarget) {
60 onNewThread?.(newThreadTarget);
61 }
62 }, [newThreadTarget, onNewThread]);
63 const showNewThreadButton = onNewThread !== undefined && newThreadTarget !== null;
5464
65 // The new-thread button is a SIBLING of the collapse toggle, not a child:
66 // nested touchables are unreachable to VoiceOver/TalkBack (the parent
67 // swallows focus). Row padding lives on the container (explicit styles —
68 // dynamic padding classes on Pressable did not apply reliably) so both
69 // children share one centerline; hitSlop restores the padded tap area.
70 const verticalHitSlop = { top: props.isFirst ? 8 : 24, bottom: 12 };
55 return (71 return (
56 <Pressable72 <View
57 accessibilityRole="button"73 className={compact ? "flex-row items-center bg-screen" : "flex-row items-center"}
58 accessibilityState={{ expanded: !props.collapsed }}74 style={{
59 accessibilityLabel={`${props.title}, ${props.threadCount} threads`}75 minHeight: compact ? 44 : 36,
60 accessibilityHint={props.collapsed ? "Expands the project" : "Collapses the project"}76 paddingLeft: compact ? 20 : 12,
61 className={compact ? "bg-screen" : undefined}77 // Compact right padding centers the 20pt plus glyph on the thread
62 onPress={handleToggle}78 // rows' trailing chevron column (18 + 13/2 ≈ 24.5 from the edge).
79 paddingRight: compact ? 14 : 12,
80 paddingBottom: compact ? 12 : 8,
81 paddingTop: props.isFirst ? (compact ? 8 : 4) : compact ? 24 : 20,
82 }}
63 >83 >
64 <View84 <Pressable
85 accessibilityRole="button"
86 accessibilityState={{ expanded: !props.collapsed }}
87 accessibilityLabel={`${props.title}, ${props.threadCount} threads`}
88 accessibilityHint={props.collapsed ? "Expands the project" : "Collapses the project"}
65 className={89 className={
66 compact90 compact ? "flex-1 flex-row items-center gap-2.5" : "flex-1 flex-row items-center gap-2"
67 ? `flex-row items-center gap-2.5 px-5 pb-3 ${props.isFirst ? "pt-2" : "pt-6"}`
68 : `flex-row items-center gap-2 px-3 pb-2 ${props.isFirst ? "pt-1" : "pt-5"}`
69 }91 }
70 style={{ minHeight: compact ? 44 : 36 }}92 hitSlop={{ ...verticalHitSlop, left: compact ? 20 : 12 }}
93 onPress={handleToggle}
71 >94 >
72 <ProjectFavicon95 <ProjectFavicon
73 environmentId={props.project.environmentId}96 environmentId={props.project.environmentId}
...@@ -95,15 +118,25 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props:...@@ -95,15 +118,25 @@ export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props:
95 >118 >
96 {props.threadCount}119 {props.threadCount}
97 </Text>120 </Text>
98 <SymbolView121 </Pressable>
99 name={props.collapsed ? "chevron.right" : "chevron.down"}122 {showNewThreadButton ? (
100 size={compact ? 13 : 11}123 <Pressable
101 tintColor={iconSubtleColor}124 accessibilityLabel={`Create new thread in ${props.title}`}
102 type="monochrome"125 accessibilityRole="button"
103 weight="semibold"126 hitSlop={{ ...verticalHitSlop, left: 10, right: 14 }}
104 />127 onPress={handleNewThread}
105 </View>128 style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, paddingLeft: 12 })}
106 </Pressable>129 >
130 <SymbolView
131 name="plus"
132 size={compact ? 20 : 16}
133 tintColor={iconMutedColor}
134 type="monochrome"
135 weight="medium"
136 />
137 </Pressable>
138 ) : null}
139 </View>
107 );140 );
108});141});
109142
apps/mobile/src/features/threads/thread-navigation-groups.test.ts deleted-111
...@@ -1,111 +0,0 @@
1import type {
2 EnvironmentProject,
3 EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";
5import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
6import { describe, expect, it } from "vite-plus/test";
7
8import { buildThreadNavigationGroups } from "./thread-navigation-groups";
9
10const environmentId = EnvironmentId.make("environment-1");
11
12function makeProject(input: Pick<EnvironmentProject, "id" | "title">): EnvironmentProject {
13 return {
14 environmentId,
15 workspaceRoot: `/workspaces/${input.id}`,
16 repositoryIdentity: null,
17 defaultModelSelection: null,
18 scripts: [],
19 createdAt: "2026-06-01T00:00:00.000Z",
20 updatedAt: "2026-06-01T00:00:00.000Z",
21 ...input,
22 };
23}
24
25function makeThread(
26 input: Pick<EnvironmentThreadShell, "id" | "projectId" | "title"> &
27 Partial<EnvironmentThreadShell>,
28): EnvironmentThreadShell {
29 return {
30 environmentId,
31 modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
32 runtimeMode: "full-access",
33 interactionMode: "default",
34 branch: null,
35 worktreePath: null,
36 latestTurn: null,
37 createdAt: "2026-06-01T00:00:00.000Z",
38 updatedAt: "2026-06-01T00:00:00.000Z",
39 archivedAt: null,
40 session: null,
41 latestUserMessageAt: null,
42 hasPendingApprovals: false,
43 hasPendingUserInput: false,
44 hasActionableProposedPlan: false,
45 ...input,
46 };
47}
48
49describe("buildThreadNavigationGroups", () => {
50 const project = makeProject({ id: ProjectId.make("project-1"), title: "T3 Code" });
51 const threads = [
52 makeThread({
53 id: ThreadId.make("older"),
54 projectId: project.id,
55 title: "Fix reconnect flow",
56 updatedAt: "2026-06-02T00:00:00.000Z",
57 }),
58 makeThread({
59 id: ThreadId.make("newer"),
60 projectId: project.id,
61 title: "Build adaptive sidebar",
62 updatedAt: "2026-06-03T00:00:00.000Z",
63 }),
64 ];
65
66 it("sorts each group by recent activity", () => {
67 expect(
68 buildThreadNavigationGroups({ projects: [project], threads })[0]?.threads.map(
69 (thread) => thread.id,
70 ),
71 ).toEqual(["newer", "older"]);
72 });
73
74 it("matches thread titles without dropping their group", () => {
75 const groups = buildThreadNavigationGroups({
76 projects: [project],
77 threads,
78 searchQuery: "reconnect",
79 });
80
81 expect(groups).toHaveLength(1);
82 expect(groups[0]?.threads.map((thread) => thread.id)).toEqual(["older"]);
83 });
84
85 it("keeps every thread when the project title matches", () => {
86 expect(
87 buildThreadNavigationGroups({
88 projects: [project],
89 threads,
90 searchQuery: "t3 code",
91 })[0]?.threads.map((thread) => thread.id),
92 ).toEqual(["newer", "older"]);
93 });
94
95 it("excludes archived threads from the navigation sidebar", () => {
96 const archived = makeThread({
97 id: ThreadId.make("archived"),
98 projectId: project.id,
99 title: "Archived work",
100 archivedAt: "2026-06-04T00:00:00.000Z",
101 updatedAt: "2026-06-04T00:00:00.000Z",
102 });
103
104 expect(
105 buildThreadNavigationGroups({
106 projects: [project],
107 threads: [...threads, archived],
108 })[0]?.threads.map((thread) => thread.id),
109 ).toEqual(["newer", "older"]);
110 });
111});
apps/mobile/src/features/threads/thread-navigation-groups.ts deleted-64
...@@ -1,64 +0,0 @@
1import type {
2 EnvironmentProject,
3 EnvironmentThreadShell,
4} from "@t3tools/client-runtime/state/shell";
5import * as Arr from "effect/Array";
6import * as Order from "effect/Order";
7
8import { groupProjectsByRepository } from "../../lib/repositoryGroups";
9
10export interface ThreadNavigationGroup {
11 readonly key: string;
12 readonly title: string;
13 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
14}
15
16const threadActivityOrder = Order.mapInput(
17 Order.Struct({
18 activityAt: Order.flip(Order.Number),
19 title: Order.String,
20 }),
21 (thread: EnvironmentThreadShell) => ({
22 activityAt: new Date(thread.updatedAt ?? thread.createdAt).getTime(),
23 title: thread.title,
24 }),
25);
26
27export function buildThreadNavigationGroups(input: {
28 readonly projects: ReadonlyArray<EnvironmentProject>;
29 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
30 readonly searchQuery?: string;
31}): ReadonlyArray<ThreadNavigationGroup> {
32 const query = input.searchQuery?.trim().toLocaleLowerCase() ?? "";
33 const activeThreads = input.threads.filter((thread) => thread.archivedAt === null);
34
35 return groupProjectsByRepository({ ...input, threads: activeThreads }).flatMap((group) => {
36 const threads = Arr.sort(
37 group.projects.flatMap((projectGroup) => projectGroup.threads),
38 threadActivityOrder,
39 );
40 const title = group.projects[0]?.project.title ?? group.title;
41 const groupMatches =
42 query.length === 0 ||
43 title.toLocaleLowerCase().includes(query) ||
44 group.title.toLocaleLowerCase().includes(query) ||
45 group.projects.some((projectGroup) =>
46 projectGroup.project.title.toLocaleLowerCase().includes(query),
47 );
48 const matchingThreads = groupMatches
49 ? threads
50 : threads.filter((thread) => thread.title.toLocaleLowerCase().includes(query));
51
52 if (query.length > 0 && matchingThreads.length === 0) {
53 return [];
54 }
55
56 return [
57 {
58 key: group.key,
59 title,
60 threads: matchingThreads,
61 },
62 ];
63 });
64}
apps/mobile/src/lib/time.ts+4-3
...@@ -1,12 +1,13 @@...@@ -1,12 +1,13 @@
1export function relativeTime(input: string): string {1export function relativeTime(input: string): string {
2 const timestamp = Date.parse(input);2 const timestamp = Date.parse(input);
3 if (Number.isNaN(timestamp)) {3 if (Number.isNaN(timestamp)) {
4 return "now";4 return "<1m";
5 }5 }
66
7 // Anything under a minute renders as "<1m" rather than a live seconds count.
8 // The seconds ticker changed width every second and reflowed the surrounding row.
7 const deltaSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));9 const deltaSeconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1000));
8 if (deltaSeconds < 10) return "now";10 if (deltaSeconds < 60) return "<1m";
9 if (deltaSeconds < 60) return `${deltaSeconds}s`;
1011
11 const deltaMinutes = Math.floor(deltaSeconds / 60);12 const deltaMinutes = Math.floor(deltaSeconds / 60);
12 if (deltaMinutes < 60) return `${deltaMinutes}m`;13 if (deltaMinutes < 60) return `${deltaMinutes}m`;
pnpm-lock.yaml+5-5
...@@ -378,8 +378,8 @@ importers:...@@ -378,8 +378,8 @@ importers:
378 specifier: ^0.2.2378 specifier: ^0.2.2
379 version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)379 version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
380 react-native-keyboard-controller:380 react-native-keyboard-controller:
381 specifier: 1.21.6381 specifier: 1.21.13
382 version: 1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)382 version: 1.21.13(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
383 react-native-nitro-markdown:383 react-native-nitro-markdown:
384 specifier: ^0.5.0384 specifier: ^0.5.0
385 version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)385 version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
...@@ -8814,8 +8814,8 @@ packages:...@@ -8814,8 +8814,8 @@ packages:
8814 react: '*'8814 react: '*'
8815 react-native: '*'8815 react-native: '*'
88168816
8817 react-native-keyboard-controller@1.21.6:8817 react-native-keyboard-controller@1.21.13:
8818 resolution: {integrity: sha512-nAXCmar/W8Gn4iQV7O5fAVuTh57JszCsqTS+cfR95WFOLR/AfbwfPz/+sWyz/q2SOIe2VpyQzq6hzYiwErhqqw==}8818 resolution: {integrity: sha512-FLr0MucraPyCGykRAcPM8Bv0JT5TcG1juQGMI+GLDuuaoOUKUY3SMUnRhHn7IgSM8KlxpcNQmMPNDmGpOw1OcA==}
8819 peerDependencies:8819 peerDependencies:
8820 react: '*'8820 react: '*'
8821 react-native: '*'8821 react-native: '*'
...@@ -18950,7 +18950,7 @@ snapshots:...@@ -18950,7 +18950,7 @@ snapshots:
18950 react: 19.2.318950 react: 19.2.3
18951 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)18951 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1895218952
18953 react-native-keyboard-controller@1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):18953 react-native-keyboard-controller@1.21.13(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
18954 dependencies:18954 dependencies:
18955 react: 19.2.318955 react: 19.2.3
18956 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)18956 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)