Open History to see the last commit
1import type { NavigationState } from "@react-navigation/native";
2import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import * as Linking from "expo-linking";
4import { AppState, Dimensions, Platform } from "react-native";
5
6import { environmentCatalog } from "../../../connection/catalog";
7import { deriveLayout } from "../../../lib/layout";
8import {
9 NATIVE_METRICS_TIMEOUT_MS,
10 nativeWorkspaceColumnsSupportedFor,
11} from "../../../native/NativeWorkspaceColumns";
12import {
13 currentNativeLayoutMetrics,
14 firstNativeLayoutMetrics,
15} from "../../../native/native-layout-metrics";
16import { loadPreferences, savePreferencesPatch } from "../../../persistence/imperative";
17import { rootStackState, type RootStackNavigation } from "../../../lib/root-stack-reset";
18import { workspaceRoutesOf } from "../workspace-overlay-routes";
19import type { WorkspaceSelections } from "../../../persistence/mobile-preferences";
20import { appAtomRegistry } from "../../../state/atom-registry";
21import { environmentPresentations } from "../../../state/presentation";
22import {
23 environmentThreadShells,
24 startOpeningEnvironmentThread,
25 threadEnvironment,
26} from "../../../state/threads";
27import {
28 rememberedSettingsPage,
29 workspaceSelectionsIn,
30 type WorkspaceSelectionCheck,
31} from "./workspace-tab-navigation";
32
33// Only iOS columns reopen a remembered page; Android always opens a tab on its list.
34const REMEMBERS = Platform.OS === "ios";
35
36// Read synchronously when a tab opens, so it lives in memory and is saved when the app leaves
37// the foreground rather than on every chat switch.
38let selections: WorkspaceSelections = {};
39let saved = selections;
40let hydration: Promise<void> | null = null;
41
42function save() {
43 const writing = selections;
44 if (saved === writing) return;
45 // Marked saved only once written, so a failed write is retried on the next background.
46 void savePreferencesPatch({ workspaceSelections: writing })
47 .then(() => {
48 saved = writing;
49 })
50 .catch(() => undefined);
51}
52
53// Route keys only name routes of the session that wrote them; a saved one would make a tab rebuild
54// its page under a stale key, which UIKit animates as a pop and a push.
55function withoutRouteKeys(saved: WorkspaceSelections | undefined): WorkspaceSelections {
56 const omitKey = <Selection extends { readonly key?: string }>({ key: _, ...rest }: Selection) =>
57 rest;
58 const { threads, pullRequests, settingsKey: _, threadsDraft: _draft, ...rest } = saved ?? {};
59 return {
60 ...rest,
61 ...(threads ? { threads: omitKey(threads) } : {}),
62 ...(pullRequests ? { pullRequests: omitKey(pullRequests) } : {}),
63 };
64}
65
66/** Loads the saved selections once; pages visited before it resolves win over saved ones. */
67export function hydrateWorkspaceSelections() {
68 if (!REMEMBERS) return Promise.resolve();
69 hydration ??= loadPreferences()
70 .then((preferences) => {
71 selections = { ...withoutRouteKeys(preferences.workspaceSelections), ...selections };
72 saved = selections;
73 AppState.addEventListener("change", (status) => {
74 if (status !== "active") save();
75 });
76 })
77 .catch(() => undefined);
78 return hydration;
79}
80
81export function currentWorkspaceSelections() {
82 return selections;
83}
84
85/**
86 * The navigation container's `onReady` and `onStateChange`: remembers the page each tab is
87 * showing. The initial state, such as a cold start's reopened chat, reaches only `onReady`.
88 */
89export function recordWorkspaceSelections(state: NavigationState | undefined) {
90 if (!REMEMBERS || !state) return;
91 selections = workspaceSelectionsIn(state.routes.slice(0, state.index + 1), selections);
92}
93
94function environmentRemoved(environmentId: string) {
95 const catalog = appAtomRegistry.get(environmentCatalog.catalogValueAtom);
96 return (
97 catalog.isReady &&
98 !appAtomRegistry
99 .get(environmentPresentations.presentationsAtom)
100 .has(environmentId as EnvironmentId)
101 );
102}
103
104/**
105 * A remembered chat is gone once its environment was removed, or once that environment's
106 * snapshot has loaded without it. Before it loads there is nothing to contradict it.
107 */
108export const workspaceSelectionCheck: WorkspaceSelectionCheck = {
109 thread: (selection) => {
110 if (environmentRemoved(selection.environmentId)) return false;
111 const environmentId = selection.environmentId as EnvironmentId;
112 if (!appAtomRegistry.get(threadEnvironment.snapshotAtom(environmentId))) return true;
113 return appAtomRegistry
114 .get(environmentThreadShells.environmentThreadIndexAtom(environmentId))
115 .has(selection.threadId as ThreadId);
116 },
117 pullRequest: (selection) => !environmentRemoved(selection.environmentId),
118};
119
120/** Whether the window lays the workspace out in columns, by the rule the layout itself uses. */
121export function workspaceHasColumns() {
122 const metrics = currentNativeLayoutMetrics();
123 const { width, height } = metrics ?? Dimensions.get("window");
124 return deriveLayout({
125 width,
126 height,
127 nativeMetrics: metrics,
128 nativeWorkspace: nativeWorkspaceColumnsSupportedFor(metrics),
129 }).usesSplitView;
130}
131
132/** The Settings page a bare `settings` link opens beside the list when there are columns. */
133export function settingsLinkPage() {
134 return REMEMBERS && workspaceHasColumns() ? rememberedSettingsPage(selections) : undefined;
135}
136
137/**
138 * Where a cold start without a link opens. A window that starts with columns reopens the last
139 * chat beside the list instead of an empty detail column; a compact window opens on the list.
140 * It waits for the first layout metrics as long as the workspace itself does.
141 */
142export async function launchWorkspaceURL(): Promise<string | null> {
143 if (!REMEMBERS) return null;
144 const metrics = firstNativeLayoutMetrics(NATIVE_METRICS_TIMEOUT_MS);
145 await hydrateWorkspaceSelections();
146 const thread = selections.threads;
147 // Read the chat's cached copy while the layout metrics are still on their way.
148 if (thread) {
149 startOpeningEnvironmentThread(
150 thread.environmentId as EnvironmentId,
151 thread.threadId as ThreadId,
152 );
153 }
154 await metrics;
155 if (!thread || !workspaceHasColumns() || !workspaceSelectionCheck.thread(thread)) return null;
156 return Linking.createURL(
157 `threads/${encodeURIComponent(thread.environmentId)}/${encodeURIComponent(thread.threadId)}`,
158 );
159}
160
161/** Whether the workspace shows its first detail page, directly above its list. */
162export function firstWorkspaceDetailOpen(navigation: RootStackNavigation) {
163 const state = rootStackState(navigation);
164 if (!state) return false;
165 const { workspace, overlays } = workspaceRoutesOf(state.routes.slice(0, state.index + 1));
166 const list = (name: string | undefined) => name === "Home" || name === "Settings";
167 return overlays.length === 0 && !list(workspace.at(-1)?.name) && list(workspace.at(-2)?.name);
168}