1import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
2import { resolveScratchProjectScopeKey } from "@t3tools/client-runtime/state/project-grouping";
3import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
4import { computeThreadMoveAvailability } from "./threadOrder";
5import type {
6 EnvironmentProject,
7 EnvironmentThreadShell,
8} from "@t3tools/client-runtime/state/shell";
9import {
10 threadSearchMatchKey,
11 type EnvironmentThreadSearchMatch,
12} from "@t3tools/client-runtime/state/thread-search";
13import { LegendList } from "@legendapp/list/react-native";
14import type { MenuAction } from "@react-native-menu/menu";
15import { useAtomValue } from "@effect/atom-react";
16import { useCallback, useEffect, useMemo, useRef, useState } from "react";
17import type { LayoutChangeEvent, TextInputInstance } from "react-native";
18import { Platform, StyleSheet, TextInput, View } from "react-native";
19import { GestureDetector, useNativeGesture } from "react-native-gesture-handler";
20import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
21import { useSafeAreaInsets } from "react-native-safe-area-context";
22import type { SearchBarCommands } from "react-native-screens";
23
24import { AppText as Text } from "../../components/AppText";
25import { CompactBrandTitle } from "../../components/CompactBrandTitle";
26import { ControlPillMenu } from "../../components/ControlPill";
27import { SymbolView } from "../../components/AppSymbol";
28import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
29import { NativeStackScreenOptions } from "../../native/StackHeader";
30import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities";
31import { useProjects, useNavigationThreadShells } from "../../state/entities";
32import { useThreadSearch } from "../../state/queries";
33import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
34import { usePendingThreadOrder } from "../../state/thread-order";
35import { threadListEnvironmentsAtom } from "../../state/server";
36import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
37import { useQueuedThreadKeys } from "../../state/use-thread-outbox";
38import { useWorkspaceState } from "../../state/workspace";
39import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
40import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands";
41import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts";
42import { useHomeListOptions } from "../home/home-list-options";
43import { buildHomeListFilterMenu } from "../home/home-list-filter-menu";
44import { buildHomeProjectScopes } from "../home/homeThreadList";
45import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions";
46import { usePendingTaskListActions } from "../home/usePendingTaskListActions";
47import { useThreadListActions } from "../home/useThreadListActions";
48import {
49 getConnectionAwareBrandHeaderOptions,
50 WorkspaceConnectionTitle,
51} from "../home/WorkspaceConnectionTitle";
52import { SidebarHeaderActions } from "./sidebar-header-actions";
53import { MaterialThreadListToolbar } from "../home/MaterialThreadListToolbar";
54import { useMaterialToolbarLayout } from "../../components/useMaterialToolbarLayout";
55import { useMaterialFabScroll } from "../home/MaterialFabScrollContext";
56import { SidebarFilterButton } from "./sidebar-filter-button";
57import { createSidebarHeaderItems } from "./sidebar-native-header-items";
58import { SidebarNavigationShell } from "./sidebar-navigation-shell";
59import {
60 ThreadListV2PendingRow,
61 ThreadListV2Row,
62 ThreadListV2SettledShelfHeader,
63 ThreadListV2ShowMoreRow,
64 ThreadListV2SnoozedShelfHeader,
65 ThreadListV2WorkingShelfHeader,
66} from "./thread-list-v2-items";
67import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance";
68import {
69 buildThreadListV2Items,
70 getThreadListV2OrderedSection,
71 buildThreadListV2ListItems,
72 isThreadListV2ListItem,
73 threadListV2ListItemsAreEqual,
74 threadListInboxReturns,
75 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
76 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
77 type ThreadListV2ListItem,
78} from "./threadListV2";
79
80/** The sidebar list: flat v2 rows with queued tasks spliced in, plus a
81 settled "Show more" pager row. */
82type SidebarListItem =
83 | ThreadListV2ListItem
84 | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number };
85
86const SIDEBAR_STICKY_HEADER_HEIGHT = 106;
87
88interface ThreadNavigationSidebarProps {
89 readonly width: number;
90 readonly visible: boolean;
91 readonly selectedThreadKey: string | null;
92 readonly onOpenSettings: () => void;
93 readonly onOpenEnvironmentSettings: () => void;
94 readonly onNewThreadOnBranch: (thread: EnvironmentThreadShell) => void;
95 readonly onNewThreadInProject: (project: EnvironmentProject) => void;
96 readonly onSearchQueryChange: (query: string) => void;
97 readonly onSelectThread: (thread: EnvironmentThreadShell) => void;
98 readonly onRequestVisibility: () => void;
99 readonly searchQuery: string;
100}
101
102/**
103 * iPad/large-width sidebar column.
104 *
105 * On iOS the pane is hosted inside its own navigation-inert single-screen
106 * native stack (SidebarNavigationShell) so the header is a real
107 * UINavigationBar: large title, native bar-button items, and a
108 * UISearchController search field — the same chrome a UISplitViewController
109 * column gets. Other platforms keep the custom header chrome.
110 */
111export function ThreadNavigationSidebar(props: ThreadNavigationSidebarProps) {
112 if (Platform.OS !== "ios") {
113 return <ThreadNavigationSidebarPane {...props} nativeChrome={false} />;
114 }
115 return <NativeSidebarContainer {...props} />;
116}
117
118function NativeSidebarContainer(props: ThreadNavigationSidebarProps) {
119 return (
120 <View
121 testID="thread-navigation-sidebar"
122 className="flex-1 border-border bg-drawer"
123 style={{ borderRightWidth: StyleSheet.hairlineWidth, width: props.width }}
124 >
125 <SidebarNavigationShell>
126 <ThreadNavigationSidebarPane {...props} nativeChrome />
127 </SidebarNavigationShell>
128 </View>
129 );
130}
131
132function ThreadNavigationSidebarPane(
133 props: ThreadNavigationSidebarProps & { readonly nativeChrome: boolean },
134) {
135 const { themeVariables: materialTheme } = useAppearancePreferences();
136 const drawerColor = materialTheme["--color-drawer"];
137
138 const insets = useSafeAreaInsets();
139 const { fabClearance } = useAndroidControlSizing();
140 const projects = useProjects();
141 const threads = useNavigationThreadShells();
142 const { environments: workspaceEnvironments, state: catalogState } = useWorkspaceState();
143 const { savedConnectionsById } = useSavedRemoteConnections();
144 const searchInputRef = useRef<TextInputInstance>(null);
145 const searchBarRef = useRef<SearchBarCommands>(null);
146 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
147 const sidebarScrollGesture = useNativeGesture();
148 const {
149 archiveThread,
150 confirmDeleteThread,
151 settleThread,
152 snoozeThread,
153 unsnoozeThread,
154 unsettleThread,
155 pinThread,
156 unpinThread,
157 setThreadAutoSettle,
158 moveThread,
159 renameThread,
160 regenerateThreadTitle,
161 } = useThreadListActions();
162 const pendingTasks = usePendingNewTasks();
163 const queuedThreadKeys = useQueuedThreadKeys();
164 const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions();
165 const environments = useMemo(
166 () =>
167 Object.values(savedConnectionsById)
168 .map((connection) => ({
169 environmentId: connection.environmentId,
170 label: connection.environmentLabel,
171 }))
172 .sort((left, right) => left.label.localeCompare(right.label)),
173 [savedConnectionsById],
174 );
175 const availableEnvironmentIds = useMemo(
176 () => new Set(environments.map((environment) => environment.environmentId)),
177 [environments],
178 );
179 const { options, setSelectedEnvironmentId, setSelectedProjectKey } =
180 useHomeListOptions(availableEnvironmentIds);
181 const searchEnvironmentIds = useMemo(
182 () =>
183 options.selectedEnvironmentId === null
184 ? workspaceEnvironments
185 .filter((environment) => environment.connectionState === "connected")
186 .map((environment) => environment.environmentId)
187 : workspaceEnvironments.some(
188 (environment) =>
189 environment.environmentId === options.selectedEnvironmentId &&
190 environment.connectionState === "connected",
191 )
192 ? [options.selectedEnvironmentId]
193 : [],
194 [options.selectedEnvironmentId, workspaceEnvironments],
195 );
196 const threadSearch = useThreadSearch(searchEnvironmentIds, props.searchQuery);
197 const threadSearchMatchByKey = useMemo(() => {
198 const matches = new Map<string, EnvironmentThreadSearchMatch>();
199 for (const match of threadSearch.matches) {
200 if (match.source === "user" || match.source === "assistant") {
201 matches.set(threadSearchMatchKey(match), match);
202 }
203 }
204 return matches;
205 }, [threadSearch.matches]);
206 const matchedThreadKeys = useMemo(
207 () => new Set(threadSearch.matches.map(threadSearchMatchKey)),
208 [threadSearch.matches],
209 );
210 const projectScopeKey = options.selectedProjectKey;
211 const resolvedProjectKey = resolveScratchProjectScopeKey(projectScopeKey, projects, {
212 sidebarProjectGroupingMode: options.projectGroupingMode,
213 sidebarProjectGroupingOverrides: {},
214 });
215 useEffect(() => {
216 if (resolvedProjectKey !== projectScopeKey) {
217 setSelectedProjectKey(resolvedProjectKey);
218 }
219 }, [projectScopeKey, resolvedProjectKey, setSelectedProjectKey]);
220 const projectScopes = useMemo(
221 () =>
222 buildHomeProjectScopes({
223 projects,
224 environmentId: options.selectedEnvironmentId,
225 projectGroupingMode: options.projectGroupingMode,
226 }),
227 [options.projectGroupingMode, options.selectedEnvironmentId, projects],
228 );
229 const projectFilterOptions = useMemo(
230 () =>
231 projectScopes.map((scope) => ({
232 key: scope.key,
233 label: scope.title,
234 })),
235 [projectScopes],
236 );
237 const projectTitleByProjectKey = useMemo(
238 () =>
239 new Map(
240 projectScopes.flatMap((scope) =>
241 scope.projectRefs.map(
242 (projectRef) =>
243 [
244 scopedProjectKey(projectRef.environmentId, projectRef.projectId),
245 scope.title,
246 ] as const,
247 ),
248 ),
249 ),
250 [projectScopes],
251 );
252 const selectedProjectScope = useMemo(
253 () => projectScopes.find((scope) => scope.key === resolvedProjectKey) ?? null,
254 [projectScopes, resolvedProjectKey],
255 );
256 // A saved project stays selected while its environment is offline or filtered out.
257 const selectedProjectKey = selectedProjectScope?.key ?? null;
258 const selectedProjectRefs = useMemo(
259 () =>
260 selectedProjectScope === null
261 ? null
262 : new Set(
263 selectedProjectScope.projectRefs.map((projectRef) =>
264 scopedProjectKey(projectRef.environmentId, projectRef.projectId),
265 ),
266 ),
267 [selectedProjectScope],
268 );
269 const projectByKey = useMemo(() => {
270 const map = new Map<string, EnvironmentProject>();
271 for (const project of projects) {
272 map.set(scopedProjectKey(project.environmentId, project.id), project);
273 }
274 return map;
275 }, [projects]);
276
277 // Thread List v2 (beta) support — same model as the compact Home list
278 // (HomeScreen.tsx): flat creation-order card block + settled recency tail.
279 // The settled tail renders in pages; expansion resets when the filter
280 // context changes so environment/search flips never inherit a deep page.
281 const [settledVisibleCount, setSettledVisibleCount] = useState(
282 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
283 );
284 const settledResetKey = `${options.selectedEnvironmentId ?? "all"}:${selectedProjectKey ?? "all"}:${props.searchQuery.trim()}`;
285 const lastSettledResetKeyRef = useRef(settledResetKey);
286 if (lastSettledResetKeyRef.current !== settledResetKey) {
287 lastSettledResetKeyRef.current = settledResetKey;
288 setSettledVisibleCount(THREAD_LIST_V2_SETTLED_INITIAL_COUNT);
289 }
290 const showMoreSettled = useCallback(
291 () => setSettledVisibleCount((count) => count + THREAD_LIST_V2_SETTLED_PAGE_COUNT),
292 [],
293 );
294 const {
295 loaded: shelfPreferencesLoaded,
296 settledShelfExpanded,
297 snoozedShelfExpanded,
298 workingShelfEnabled,
299 workingShelfExpanded,
300 toggleSettledShelf,
301 toggleSnoozedShelf,
302 toggleWorkingShelf,
303 } = useThreadListV2ShelfPreferences();
304 // The queued-start and snooze helpers need a clock while the pane stays open.
305 const [nowMinute, setNowMinute] = useState(() => new Date().toISOString().slice(0, 16));
306 // Snooze wake times are second-precise; a counter bumped exactly at the
307 // next wake boundary re-runs the partition with a fresh clock so a woken
308 // thread reappears immediately instead of on the next minute tick.
309 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
310 useEffect(() => {
311 // Refresh immediately because the mount-time value can be hours old.
312 setNowMinute(new Date().toISOString().slice(0, 16));
313 const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);
314 return () => clearInterval(id);
315 }, []);
316 // Threads on servers without the settlement capability never classify as
317 // settled (the user could neither un-settle nor pin them).
318 const listEnvironments = useAtomValue(threadListEnvironmentsAtom);
319 const {
320 providersByEnvironmentId,
321 machineByEnvironmentId,
322 settlementEnvironmentIds,
323 snoozeEnvironmentIds,
324 pinningEnvironmentIds,
325 autoSettleOptOutEnvironmentIds,
326 pinReorderEnvironmentIds,
327 activeReorderEnvironmentIds,
328 titleRegenerationEnvironmentIds,
329 } = listEnvironments;
330 const resolveProviderInstance = useThreadRowProviderInstanceResolver(providersByEnvironmentId);
331 const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
332 // Up/down menu availability for every card, computed once per section per
333 // rebuild (see computeThreadMoveAvailability): per-thread planner calls made
334 // list construction quadratic, and this list rebuilds on every minute tick.
335 const threadMoveAvailability = useMemo(() => {
336 const sectionAvailability = (section: "pinned" | "active") =>
337 computeThreadMoveAvailability({
338 allThreads: threads,
339 section,
340 pendingOrder,
341 reorderableEnvironmentIds:
342 section === "pinned" ? pinReorderEnvironmentIds : activeReorderEnvironmentIds,
343 ordered: getThreadListV2OrderedSection({
344 threads,
345 section,
346 pendingOrder,
347 now: new Date().toISOString(),
348 settlementEnvironmentIds,
349 snoozeEnvironmentIds,
350 queuedThreadKeys,
351 }),
352 });
353 // The Working beta orders the inbox by time, so only pins can move.
354 return new Map([
355 ...sectionAvailability("pinned"),
356 ...(workingShelfEnabled ? [] : sectionAvailability("active")),
357 ]);
358 }, [
359 workingShelfEnabled,
360 pinReorderEnvironmentIds,
361 activeReorderEnvironmentIds,
362 threads,
363 pendingOrder,
364 queuedThreadKeys,
365 settlementEnvironmentIds,
366 snoozeEnvironmentIds,
367 nowMinute,
368 snoozeWakeTick,
369 ]);
370 const threadListV2Layout = useMemo(() => {
371 threadListInboxReturns.observe(workingShelfEnabled ? threads : null);
372 return buildThreadListV2Items({
373 pendingOrder,
374 threads: threads.filter((thread) => thread.archivedAt === null),
375 environmentId: options.selectedEnvironmentId,
376 projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs,
377 searchQuery: props.searchQuery,
378 matchedThreadKeys,
379 settlementEnvironmentIds,
380 snoozeEnvironmentIds,
381 queuedThreadKeys,
382 settledLimit: settledVisibleCount,
383 now: new Date().toISOString(),
384 workingShelfEnabled,
385 workingShelfExpanded,
386 inboxReturnAt: threadListInboxReturns.returnedAt,
387 snoozedShelfExpanded,
388 settledShelfExpanded,
389 selectedThreadKey: props.selectedThreadKey ?? null,
390 });
391 }, [
392 workingShelfEnabled,
393 workingShelfExpanded,
394 pendingOrder,
395 queuedThreadKeys,
396 nowMinute,
397 snoozeWakeTick,
398 snoozedShelfExpanded,
399 settledShelfExpanded,
400 props.selectedThreadKey,
401 options.selectedEnvironmentId,
402 props.searchQuery,
403 matchedThreadKeys,
404 settledVisibleCount,
405 settlementEnvironmentIds,
406 snoozeEnvironmentIds,
407 threads,
408 selectedProjectScope,
409 ]);
410 // Re-partition the moment the earliest snooze expires (clamped to the
411 // signed-32-bit setTimeout range; far-future wakes re-arm at the clamp).
412 const nextSnoozeWakeAt = threadListV2Layout.nextSnoozeWakeAt;
413 useEffect(() => {
414 if (nextSnoozeWakeAt === null) return;
415 const wakeAtMs = Date.parse(nextSnoozeWakeAt);
416 if (Number.isNaN(wakeAtMs)) return;
417 const delayMs = Math.min(Math.max(0, wakeAtMs - Date.now()) + 50, 2_147_483_647);
418 const id = setTimeout(() => bumpSnoozeWakeTick((tick) => tick + 1), delayMs);
419 return () => clearTimeout(id);
420 // snoozeWakeTick must re-arm the timer even when nextSnoozeWakeAt is
421 // unchanged: after a clamped fire (wake beyond the 32-bit setTimeout
422 // range) the boundary string is identical and the chain would die.
423 }, [nextSnoozeWakeAt, snoozeWakeTick]);
424 const listItems = useMemo<readonly SidebarListItem[]>(() => {
425 // Queued offline tasks are not thread shells, so the v2 item builder
426 // never sees them; the shared splice puts them below the active block
427 // (mirrors the compact Home v2 list) where they stay visible and
428 // deletable while their environment is offline. Same environment scope
429 // and search filter as the list.
430 const v2SearchQuery = props.searchQuery.trim().toLocaleLowerCase();
431 const v2PendingTasks = pendingTasks.filter(
432 (pendingTask) =>
433 (options.selectedEnvironmentId === null ||
434 pendingTask.environmentId === options.selectedEnvironmentId) &&
435 (selectedProjectRefs === null ||
436 selectedProjectRefs.has(
437 scopedProjectKey(pendingTask.environmentId, pendingTask.projectId),
438 )) &&
439 (v2SearchQuery.length === 0 ||
440 pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)),
441 );
442 const items: SidebarListItem[] = buildThreadListV2ListItems({
443 items: threadListV2Layout.items,
444 pendingTasks: v2PendingTasks,
445 workingCount: threadListV2Layout.workingCount,
446 workingShelfExpanded,
447 workingShelfHeaderIndex: threadListV2Layout.workingShelfHeaderIndex,
448 snoozedCount: threadListV2Layout.snoozedCount,
449 snoozedShelfExpanded,
450 snoozedShelfHeaderIndex: threadListV2Layout.snoozedShelfHeaderIndex,
451 settledCount: threadListV2Layout.settledCount,
452 settledShelfExpanded,
453 settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex,
454 snoozeLabelNow: `${nowMinute}:00.000Z`,
455 snoozeEnvironmentIds,
456 queuedThreadKeys,
457 moveAvailability: threadMoveAvailability,
458 shelfPreferencesLoading: !shelfPreferencesLoaded,
459 });
460 if (settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0) {
461 items.push({
462 type: "v2-show-more",
463 key: "v2-show-more",
464 hiddenCount: threadListV2Layout.hiddenSettledCount,
465 });
466 }
467 return items;
468 }, [
469 nowMinute,
470 options.selectedEnvironmentId,
471 pendingTasks,
472 props.searchQuery,
473 queuedThreadKeys,
474 threadMoveAvailability,
475 selectedProjectRefs,
476 settledShelfExpanded,
477 shelfPreferencesLoaded,
478 snoozedShelfExpanded,
479 snoozeEnvironmentIds,
480 threadListV2Layout,
481 workingShelfExpanded,
482 ]);
483 const listMenuActions = useMemo<MenuAction[]>(
484 () => [
485 {
486 id: "environment",
487 title: "Environment",
488 subactions: [
489 {
490 id: "environment:all",
491 title: "All environments",
492 subtitle: "Show threads from every environment",
493 state: options.selectedEnvironmentId === null ? "on" : "off",
494 },
495 ...environments.map((environment) => ({
496 id: `environment:${environment.environmentId}`,
497 title: environment.label,
498 state:
499 options.selectedEnvironmentId === environment.environmentId
500 ? ("on" as const)
501 : ("off" as const),
502 })),
503 ],
504 },
505 ...(projectFilterOptions.length === 0
506 ? []
507 : ([
508 {
509 id: "project",
510 title: "Project",
511 subactions: [
512 {
513 id: "project:all",
514 title: "All projects",
515 subtitle: "Show threads from every project",
516 state: selectedProjectKey === null ? "on" : "off",
517 },
518 ...projectFilterOptions.map((project) => ({
519 id: `project:${project.key}`,
520 title: project.label,
521 state: selectedProjectKey === project.key ? ("on" as const) : ("off" as const),
522 })),
523 ],
524 },
525 ] satisfies MenuAction[])),
526 ],
527 [environments, options, projectFilterOptions, selectedProjectKey],
528 );
529 const handleListMenuAction = useCallback(
530 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
531 const event = nativeEvent.event;
532 if (event === "environment:all") {
533 setSelectedEnvironmentId(null);
534 return;
535 }
536 if (event.startsWith("environment:")) {
537 const environment = environments.find(
538 (candidate) => String(candidate.environmentId) === event.slice("environment:".length),
539 );
540 if (environment) setSelectedEnvironmentId(environment.environmentId);
541 return;
542 }
543 if (event === "project:all") {
544 setSelectedProjectKey(null);
545 return;
546 }
547 if (event.startsWith("project:")) {
548 const projectKey = event.slice("project:".length);
549 if (projectFilterOptions.some((project) => project.key === projectKey)) {
550 setSelectedProjectKey(projectKey);
551 }
552 return;
553 }
554 },
555 [environments, projectFilterOptions, setSelectedEnvironmentId, setSelectedProjectKey],
556 );
557
558 const [measuredHeaderHeight, setMeasuredHeaderHeight] = useState<number | null>(null);
559 const { height, paddingTop, paddingBottom } = useMaterialToolbarLayout();
560 // The sticky header (title row, search field, optional connection status)
561 // is measured so the list inset always matches its real height — no
562 // hardcoded per-variant constants.
563 const stickyHeaderHeight =
564 measuredHeaderHeight ??
565 (Platform.OS === "android"
566 ? paddingTop + height + paddingBottom
567 : insets.top + SIDEBAR_STICKY_HEADER_HEIGHT);
568 const topListInset = stickyHeaderHeight + 6;
569 const handleStickyHeaderLayout = useCallback((event: LayoutChangeEvent) => {
570 const nextHeight = event.nativeEvent.layout.height;
571 setMeasuredHeaderHeight((current) => (current === nextHeight ? current : nextHeight));
572 }, []);
573 const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {
574 if (openSwipeableRef.current !== methods) {
575 openSwipeableRef.current?.close();
576 openSwipeableRef.current = methods;
577 }
578 }, []);
579 const handleSwipeableClose = useCallback((methods: SwipeableMethods) => {
580 if (openSwipeableRef.current === methods) {
581 openSwipeableRef.current = null;
582 }
583 }, []);
584 const handleSelectThread = useCallback(
585 (thread: EnvironmentThreadShell) => {
586 props.onSelectThread(thread);
587 openSwipeableRef.current?.close();
588 },
589 [props.onSelectThread],
590 );
591 const handleScrollBeginDrag = useCallback(() => {
592 openSwipeableRef.current?.close();
593 }, []);
594 const onMaterialFabScroll = useMaterialFabScroll();
595 const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({
596 onScroll: onMaterialFabScroll,
597 onScrollBeginDrag: handleScrollBeginDrag,
598 });
599 // The sticky header's project shells and search maps feed row props, so
600 // they have to bust the recycler's memoization — otherwise a row keeps the
601 // blank favicon and fallback title it was first rendered with. The minute
602 // clock deliberately stays out: its per-row text lives on the items, so a
603 // tick only re-renders rows whose displayed text actually moved.
604 const listExtraData = useMemo(
605 () => ({
606 selectedThreadKey: props.selectedThreadKey ?? "",
607 projectByKey,
608 projectTitleByProjectKey,
609 savedConnectionsById,
610 listEnvironments,
611 threadSearchMatchByKey,
612 // Rows read it for their reorder menu items.
613 workingShelfEnabled,
614 }),
615 [
616 props.selectedThreadKey,
617 projectByKey,
618 projectTitleByProjectKey,
619 savedConnectionsById,
620 listEnvironments,
621 threadSearchMatchByKey,
622 workingShelfEnabled,
623 ],
624 );
625 useThreadJumpShortcuts(listItems, handleSelectThread);
626 const sidebarItemsAreEqual = useCallback(
627 (previous: SidebarListItem, item: SidebarListItem): boolean => {
628 if (isThreadListV2ListItem(previous) && isThreadListV2ListItem(item)) {
629 return threadListV2ListItemsAreEqual(previous, item);
630 }
631 if (previous.type === "v2-show-more" && item.type === "v2-show-more") {
632 return previous.hiddenCount === item.hiddenCount;
633 }
634 return false;
635 },
636 [],
637 );
638 const focusSearch = useCallback(() => {
639 if (Platform.OS === "android") return false;
640 const focus = () => {
641 if (props.nativeChrome) {
642 searchBarRef.current?.focus();
643 return;
644 }
645 searchInputRef.current?.focus();
646 };
647 if (!props.visible) {
648 props.onRequestVisibility();
649 setTimeout(focus, 240);
650 } else {
651 focus();
652 }
653 return true;
654 }, [props.nativeChrome, props.onRequestVisibility, props.visible]);
655 useHardwareKeyboardCommand("focusSearch", focusSearch);
656 const renderListItem = useCallback(
657 ({ item }: { readonly item: SidebarListItem }) => {
658 switch (item.type) {
659 case "v2-pending": {
660 const pendingScopeKey = scopedProjectKey(
661 item.pendingTask.environmentId,
662 item.pendingTask.projectId,
663 );
664 return (
665 <ThreadListV2PendingRow
666 pendingTask={item.pendingTask}
667 project={projectByKey.get(pendingScopeKey) ?? null}
668 projectTitle={projectTitleByProjectKey.get(pendingScopeKey)}
669 environmentLabel={
670 Object.keys(savedConnectionsById).length > 1
671 ? (savedConnectionsById[item.pendingTask.environmentId]?.environmentLabel ?? null)
672 : null
673 }
674 environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
675 pane="sidebar"
676 showPendingDivider={item.showPendingDivider}
677 onSelectPendingTask={openPendingTask}
678 onDeletePendingTask={confirmDeletePendingTask}
679 />
680 );
681 }
682 case "v2-thread": {
683 const thread = item.item.thread;
684 const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId);
685 // Intentional difference from Home: the sidebar never passes
686 // `showTrailingDivider` because its rows render no Home-style row
687 // hairline at all — card rows carry tonal containers in this pane
688 // (the hairline branch is !sidebarPane-only) and slim rows have no
689 // hairline branch. The stamp still rides the shared list items
690 // because Home's boundary suppression consumes it; the sidebar's
691 // only cost is the occasional divider-only equality invalidation,
692 // which re-renders identically.
693 return (
694 <ThreadListV2Row
695 onNewThreadOnBranch={props.onNewThreadOnBranch}
696 thread={thread}
697 variant={item.item.variant}
698 hasQueuedMessages={item.hasQueuedMessages}
699 snoozed={item.item.snoozed}
700 pinned={item.item.pinned}
701 snoozePresetMinute={item.snoozePresetMinute ?? ""}
702 snoozeWakeLabelText={item.snoozeWakeLabelText}
703 timeLabel={item.timeLabel}
704 project={projectByKey.get(scopeKey) ?? null}
705 projectTitle={projectTitleByProjectKey.get(scopeKey)}
706 providerInstance={resolveProviderInstance(thread)}
707 providers={providersByEnvironmentId.get(thread.environmentId)}
708 environmentLabel={
709 Object.keys(savedConnectionsById).length > 1
710 ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
711 : null
712 }
713 environmentMachine={machineByEnvironmentId.get(thread.environmentId)}
714 searchMatch={threadSearchMatchByKey.get(
715 threadSearchMatchKey({
716 environmentId: thread.environmentId,
717 threadId: thread.id,
718 }),
719 )}
720 searchQuery={props.searchQuery}
721 pane="sidebar"
722 selected={
723 scopedThreadKey(thread.environmentId, thread.id) === props.selectedThreadKey
724 }
725 fullSwipeWidth={props.width - 20}
726 onSelectThread={handleSelectThread}
727 onDeleteThread={confirmDeleteThread}
728 onArchiveThread={archiveThread}
729 onRenameThread={renameThread}
730 onRegenerateThreadTitle={regenerateThreadTitle}
731 titleRegenerationSupported={titleRegenerationEnvironmentIds.has(thread.environmentId)}
732 settlementSupported={settlementEnvironmentIds.has(thread.environmentId)}
733 onSettleThread={settleThread}
734 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
735 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
736 autoSettleOptOutSupported={autoSettleOptOutEnvironmentIds.has(thread.environmentId)}
737 reorderSupported={
738 item.item.pinned
739 ? pinReorderEnvironmentIds.has(thread.environmentId)
740 : !workingShelfEnabled && activeReorderEnvironmentIds.has(thread.environmentId)
741 }
742 canMoveUp={item.canMoveUp}
743 canMoveDown={item.canMoveDown}
744 onSnoozeThread={snoozeThread}
745 onUnsnoozeThread={unsnoozeThread}
746 onUnsettleThread={unsettleThread}
747 onPinThread={pinThread}
748 onUnpinThread={unpinThread}
749 onSetThreadAutoSettle={setThreadAutoSettle}
750 onMoveThread={moveThread}
751 onSwipeableClose={handleSwipeableClose}
752 onSwipeableWillOpen={handleSwipeableWillOpen}
753 simultaneousSwipeGesture={sidebarScrollGesture}
754 />
755 );
756 }
757 case "v2-working-shelf":
758 return (
759 <ThreadListV2WorkingShelfHeader
760 count={item.count}
761 disabled={item.disabled}
762 expanded={item.expanded}
763 onToggle={toggleWorkingShelf}
764 pane="sidebar"
765 />
766 );
767 case "v2-snoozed-shelf":
768 return (
769 <ThreadListV2SnoozedShelfHeader
770 count={item.count}
771 disabled={item.disabled}
772 expanded={item.expanded}
773 onToggle={toggleSnoozedShelf}
774 pane="sidebar"
775 />
776 );
777 case "v2-settled-shelf":
778 return (
779 <ThreadListV2SettledShelfHeader
780 count={item.count}
781 disabled={item.disabled}
782 expanded={item.expanded}
783 onToggle={toggleSettledShelf}
784 pane="sidebar"
785 />
786 );
787 case "v2-show-more":
788 return (
789 <ThreadListV2ShowMoreRow
790 pane="sidebar"
791 hiddenCount={item.hiddenCount}
792 onPress={showMoreSettled}
793 />
794 );
795 }
796 },
797 [
798 archiveThread,
799 activeReorderEnvironmentIds,
800 confirmDeletePendingTask,
801 confirmDeleteThread,
802 handleSelectThread,
803 handleSwipeableClose,
804 handleSwipeableWillOpen,
805 machineByEnvironmentId,
806 moveThread,
807 openPendingTask,
808 pinReorderEnvironmentIds,
809 pinThread,
810 pinningEnvironmentIds,
811 autoSettleOptOutEnvironmentIds,
812 autoSettleOptOutEnvironmentIds,
813 setThreadAutoSettle,
814 projectByKey,
815 projectTitleByProjectKey,
816 regenerateThreadTitle,
817 renameThread,
818 props.onNewThreadOnBranch,
819 props.searchQuery,
820 props.selectedThreadKey,
821 props.width,
822 savedConnectionsById,
823 resolveProviderInstance,
824 providersByEnvironmentId,
825 threadSearchMatchByKey,
826 titleRegenerationEnvironmentIds,
827 settleThread,
828 settlementEnvironmentIds,
829 showMoreSettled,
830 sidebarScrollGesture,
831 snoozeEnvironmentIds,
832 snoozeThread,
833 toggleSettledShelf,
834 toggleSnoozedShelf,
835 toggleWorkingShelf,
836 unpinThread,
837 unsettleThread,
838 unsnoozeThread,
839 workingShelfEnabled,
840 ],
841 );
842 // The list ignores sort/group options, so only the environment and project
843 // filters can light the "customized" state.
844 const filterCustomized = options.selectedEnvironmentId !== null || selectedProjectKey !== null;
845 const filterIcon = filterCustomized
846 ? "line.3.horizontal.decrease.circle.fill"
847 : "line.3.horizontal.decrease.circle";
848 const filterMenu = useMemo(
849 () =>
850 buildHomeListFilterMenu({
851 environments,
852 projects: projectFilterOptions,
853 selectedEnvironmentId: options.selectedEnvironmentId,
854 selectedProjectKey,
855 onEnvironmentChange: setSelectedEnvironmentId,
856 onProjectChange: setSelectedProjectKey,
857 }),
858 [
859 environments,
860 options,
861 projectFilterOptions,
862 selectedProjectKey,
863 setSelectedEnvironmentId,
864 setSelectedProjectKey,
865 ],
866 );
867 const nativeHeaderItems = useMemo(
868 () =>
869 createSidebarHeaderItems({
870 filterIcon,
871 filterMenu,
872 onOpenSettings: props.onOpenSettings,
873 }),
874 [filterIcon, filterMenu, props.onOpenSettings],
875 );
876 // Snoozed threads need no special case: the shelf header is a list row
877 // even while collapsed.
878 const listEmpty = (
879 <Text
880 className={
881 Platform.OS === "android"
882 ? "px-4 py-4 text-center text-sm text-drawer-foreground-muted"
883 : "px-2 py-4 text-sm text-drawer-foreground-muted"
884 }
885 >
886 {catalogState.isLoadingConnections
887 ? "Loading threads…"
888 : Platform.OS === "android" && !catalogState.hasConnections
889 ? "No environments connected"
890 : props.searchQuery.trim().length > 0
891 ? threadSearch.isPending
892 ? "Searching thread messages…"
893 : "No matching threads"
894 : selectedProjectScope !== null
895 ? `No threads in ${selectedProjectScope.title}`
896 : "No threads yet"}
897 </Text>
898 );
899
900 if (props.nativeChrome) {
901 return (
902 <>
903 <NativeStackScreenOptions
904 optionsVersion={[nativeHeaderItems, props.width]}
905 options={{
906 // Re-applies the shell's static brand slot with the
907 // connection-status swap so reconnects surface in the header
908 // instead of shifting the list.
909 ...getConnectionAwareBrandHeaderOptions({
910 headerWidth: props.width,
911 trailingItemCount: nativeHeaderItems.length,
912 onOpenEnvironments: props.onOpenEnvironmentSettings,
913 fallbackTitleStyle: { fontSize: 18, fontWeight: "800" },
914 }),
915 headerSearchBarOptions: {
916 ref: searchBarRef,
917 autoCapitalize: "none",
918 hideNavigationBar: false,
919 // Keep the search bar pinned under the title — UIKit's default
920 // hidesSearchBarWhenScrolling collapses it on scroll.
921 hideWhenScrolling: false,
922 obscureBackground: false,
923 placeholder: "Search",
924 placement: "stacked",
925 onCancelButtonPress: () => {
926 props.onSearchQueryChange("");
927 },
928 onChangeText: (event) => {
929 props.onSearchQueryChange(event.nativeEvent.text);
930 },
931 },
932 unstable_headerRightItems: () => nativeHeaderItems,
933 }}
934 />
935 <View className="flex-1">
936 <SwipeableScrollGateProvider enabled={swipeEnabled}>
937 <GestureDetector gesture={sidebarScrollGesture}>
938 <LegendList
939 data={listItems}
940 drawDistance={500}
941 estimatedItemSize={64}
942 extraData={listExtraData}
943 getItemType={(item) => item.type}
944 itemsAreEqual={sidebarItemsAreEqual}
945 keyExtractor={(item) => item.key}
946 renderItem={renderListItem}
947 automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED}
948 contentInsetAdjustmentBehavior={
949 NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"
950 }
951 contentContainerStyle={[
952 styles.threadListContent,
953 Platform.OS === "android" ? { paddingHorizontal: 0 } : null,
954 {
955 paddingBottom: Math.max(insets.bottom, 16) + 16,
956 paddingTop: 6,
957 },
958 ]}
959 keyboardDismissMode="on-drag"
960 keyboardShouldPersistTaps="handled"
961 {...scrollGateHandlers}
962 recycleItems
963 scrollEventThrottle={16}
964 showsVerticalScrollIndicator={false}
965 style={styles.threadList}
966 ListEmptyComponent={listEmpty}
967 />
968 </GestureDetector>
969 </SwipeableScrollGateProvider>
970 </View>
971 </>
972 );
973 }
974
975 return (
976 <View
977 testID="thread-navigation-sidebar"
978 className={
979 Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 border-r border-border bg-drawer"
980 }
981 style={{ width: props.width }}
982 >
983 <View
984 className="flex-1"
985 style={
986 Platform.OS === "android"
987 ? {
988 marginTop: stickyHeaderHeight,
989 marginHorizontal: 4,
990 paddingBottom: insets.bottom,
991 backgroundColor: drawerColor,
992 borderTopLeftRadius: 28,
993 borderTopRightRadius: 28,
994 overflow: "hidden",
995 }
996 : { paddingBottom: insets.bottom }
997 }
998 >
999 {Platform.OS === "android" && listItems.length === 0 ? (
1000 <View className="flex-1 items-center justify-center">{listEmpty}</View>
1001 ) : (
1002 <SwipeableScrollGateProvider enabled={swipeEnabled}>
1003 <GestureDetector gesture={sidebarScrollGesture}>
1004 <LegendList
1005 data={listItems}
1006 drawDistance={500}
1007 estimatedItemSize={64}
1008 extraData={listExtraData}
1009 getItemType={(item) => item.type}
1010 itemsAreEqual={sidebarItemsAreEqual}
1011 keyExtractor={(item) => item.key}
1012 renderItem={renderListItem}
1013 contentContainerStyle={[
1014 styles.threadListContent,
1015 Platform.OS === "android" ? { paddingHorizontal: 0 } : null,
1016 {
1017 paddingBottom:
1018 Platform.OS === "android"
1019 ? Math.max(insets.bottom, 16) + fabClearance - insets.bottom
1020 : 16 + insets.bottom,
1021 paddingTop: Platform.OS === "android" ? 6 : topListInset,
1022 },
1023 ]}
1024 keyboardDismissMode="on-drag"
1025 keyboardShouldPersistTaps="handled"
1026 {...scrollGateHandlers}
1027 recycleItems
1028 scrollEventThrottle={16}
1029 showsVerticalScrollIndicator={false}
1030 style={styles.threadList}
1031 ListEmptyComponent={listEmpty}
1032 />
1033 </GestureDetector>
1034 </SwipeableScrollGateProvider>
1035 )}
1036 </View>
1037
1038 {Platform.OS === "android" ? (
1039 <MaterialThreadListToolbar
1040 sidebar
1041 onLayout={handleStickyHeaderLayout}
1042 searchQuery={props.searchQuery}
1043 onSearchQueryChange={props.onSearchQueryChange}
1044 filterActions={listMenuActions}
1045 filterCustomized={filterCustomized}
1046 onFilterAction={handleListMenuAction}
1047 onOpenSettings={props.onOpenSettings}
1048 onOpenEnvironments={props.onOpenEnvironmentSettings}
1049 onRequestVisibility={props.onRequestVisibility}
1050 />
1051 ) : (
1052 <View
1053 className="absolute inset-x-0 top-0 z-[4] bg-drawer"
1054 collapsable={false}
1055 onLayout={handleStickyHeaderLayout}
1056 pointerEvents="auto"
1057 style={{ paddingTop: insets.top }}
1058 >
1059 <View className="h-[50px] flex-row items-end gap-0.5 pr-2 pl-5">
1060 {/* Title slot doubles as the connection status surface: while an
1061 environment reconnects, the brand fades to a status label in
1062 place (no layout shift in the list below). */}
1063 <WorkspaceConnectionTitle
1064 grow
1065 onPress={props.onOpenEnvironmentSettings}
1066 size="pageTitle"
1067 brand={
1068 <View className="h-11 flex-1 justify-center">
1069 <CompactBrandTitle allowFontScaling={false} />
1070 </View>
1071 }
1072 />
1073 <View className="flex-row items-center gap-2.5">
1074 <ControlPillMenu actions={listMenuActions} onPressAction={handleListMenuAction}>
1075 <SidebarFilterButton accessibilityLabel="Filter threads" icon={filterIcon} />
1076 </ControlPillMenu>
1077 <SidebarHeaderActions onOpenSettings={props.onOpenSettings} />
1078 </View>
1079 </View>
1080
1081 <View className="mx-4 mt-[9px] h-[38px] flex-row items-center gap-1.5 rounded-xl bg-sidebar-search pr-2.5 pl-[11px]">
1082 <SymbolView
1083 name="magnifyingglass"
1084 size={15}
1085 tintColorClassName="accent-drawer-foreground-muted"
1086 type="monochrome"
1087 />
1088 <TextInput
1089 ref={searchInputRef}
1090 accessibilityLabel="Search threads"
1091 autoCapitalize="none"
1092 autoCorrect={false}
1093 clearButtonMode="while-editing"
1094 onChangeText={props.onSearchQueryChange}
1095 placeholder="Search"
1096 placeholderTextColorClassName="accent-placeholder"
1097 selectionColorClassName={undefined}
1098 cursorColorClassName={undefined}
1099 selectionHandleColorClassName={undefined}
1100 returnKeyType="search"
1101 className="h-[34px] flex-1 px-0 py-0 font-sans text-base text-drawer-foreground"
1102 value={props.searchQuery}
1103 />
1104 </View>
1105 </View>
1106 )}
1107 </View>
1108 );
1109}
1110
1111const styles = StyleSheet.create({
1112 threadList: {
1113 flex: 1,
1114 },
1115 threadListContent: {
1116 paddingHorizontal: 8,
1117 },
1118});