Open History to see the last commit
1import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
2import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell";
3import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
4import { Atom } from "effect/reactivity";
5
6import { scopedProjectKey } from "../../lib/scopedEntities";
7import { appAtomRegistry } from "../../state/atom-registry";
8import { isNewTaskDraftKey } from "../../state/new-task-draft-key";
9import {
10 draftRouteIdentity,
11 isDraftThreadRoute,
12 type NewTaskDraftRouteParams,
13} from "./thread-route-state";
14
15/** The flow a provider serves while no draft route is on the stack. */
16export const INACTIVE_FLOW_KEY = "inactive";
17
18/** The flow key for a draft Thread route: its key plus the draft it opened. */
19export function newTaskFlowKey(route: {
20 readonly key: string;
21 readonly params?: object;
22}): string | undefined {
23 const draft = (route.params as { draft?: NewTaskDraftRouteParams } | undefined)?.draft;
24 return draft ? `${route.key}:${draftRouteIdentity(draft)}` : undefined;
25}
26
27export interface NewTaskFlowState {
28 readonly environmentId: EnvironmentId | null;
29 readonly projectKey: string | null;
30 readonly draftKey: string | null;
31 readonly submitting: boolean;
32 readonly expandedProvider: string | null;
33}
34
35export const INITIAL_NEW_TASK_FLOW_STATE: NewTaskFlowState = {
36 environmentId: null,
37 projectKey: null,
38 draftKey: null,
39 submitting: false,
40 expandedProvider: null,
41};
42
43// Every draft route on the stack keeps its flow, not just the one the provider
44// currently serves: a draft Thread pushed above draft A must not drop A's
45// selection. Entries live exactly as long as their route (see prune below).
46const flowStatesAtom = Atom.make<Readonly<Record<string, NewTaskFlowState>>>({}).pipe(
47 Atom.keepAlive,
48 Atom.withLabel("mobile:new-task-flows"),
49);
50
51export const newTaskFlowStateAtom = Atom.family((flowKey: string) =>
52 Atom.make((get) => get(flowStatesAtom)[flowKey] ?? INITIAL_NEW_TASK_FLOW_STATE).pipe(
53 Atom.withLabel("mobile:new-task-flow"),
54 ),
55);
56
57export function readNewTaskFlowState(flowKey: string): NewTaskFlowState {
58 return appAtomRegistry.get(flowStatesAtom)[flowKey] ?? INITIAL_NEW_TASK_FLOW_STATE;
59}
60
61export function updateNewTaskFlowState(flowKey: string, patch: Partial<NewTaskFlowState>): void {
62 const flows = appAtomRegistry.get(flowStatesAtom);
63 appAtomRegistry.set(flowStatesAtom, {
64 ...flows,
65 [flowKey]: { ...(flows[flowKey] ?? INITIAL_NEW_TASK_FLOW_STATE), ...patch },
66 });
67}
68
69/**
70 * What a draft route's `draftId` means for a flow that has not bound a draft
71 * yet. Drafts hydrate from disk and projects arrive with the shell snapshot,
72 * so on a cold launch or deep link the answer can be "pending" for a while.
73 */
74export type RouteDraftResolution =
75 | { readonly kind: "none" }
76 | { readonly kind: "pending" }
77 | { readonly kind: "missing" }
78 /** The draft survives but its project is gone: pick another for it. */
79 | { readonly kind: "pickProject" }
80 | { readonly kind: "open"; readonly flow: NewTaskFlowState };
81
82export function resolveRouteDraft(input: {
83 readonly flow: NewTaskFlowState;
84 readonly routeDraftId: string | undefined;
85 readonly draftsLoaded: boolean;
86 readonly stamp:
87 | { readonly environmentId: EnvironmentId; readonly projectId: ProjectId }
88 | undefined;
89 readonly projects: ReadonlyArray<{
90 readonly environmentId: EnvironmentId;
91 readonly id: ProjectId;
92 }>;
93 /** See `stampedEnvironmentProjectsLoading`. */
94 readonly stampedEnvironmentLoading: boolean;
95}): RouteDraftResolution {
96 const { routeDraftId, stamp } = input;
97 if (routeDraftId === undefined || input.flow.draftKey !== null) return { kind: "none" };
98 if (!input.draftsLoaded) return { kind: "pending" };
99 if (!isNewTaskDraftKey(routeDraftId) || stamp === undefined) return { kind: "missing" };
100 // The stamped project must be loaded: the composer falls back to the
101 // environment's first project otherwise, and the draft would be sent
102 // somewhere the user never chose.
103 const loaded = input.projects.some(
104 (project) => project.environmentId === stamp.environmentId && project.id === stamp.projectId,
105 );
106 if (!loaded)
107 return input.stampedEnvironmentLoading ? { kind: "pending" } : { kind: "pickProject" };
108 return {
109 kind: "open",
110 flow: {
111 ...input.flow,
112 environmentId: stamp.environmentId,
113 projectKey: scopedProjectKey(stamp.environmentId, stamp.projectId),
114 draftKey: routeDraftId,
115 },
116 };
117}
118
119/**
120 * Whether the stamped environment may still deliver its project list. An
121 * unknown, offline, or failed environment is settled, so a project it lacks
122 * sends the route back to the picker. Mirrors `threadRouteIsHydrating`.
123 */
124export function stampedEnvironmentProjectsLoading(input: {
125 readonly connectionsReady: boolean;
126 readonly connectionState: EnvironmentConnectionPhase | null;
127 readonly shellCacheRead: boolean;
128 readonly shellStatus: EnvironmentShellStatus;
129 readonly shellHasError: boolean;
130}): boolean {
131 if (input.shellHasError) return false;
132 if (!input.connectionsReady) return true;
133 if (input.connectionState === null) return false;
134 if (!input.shellCacheRead) return true;
135 // Only a first attempt is worth waiting out; a reconnecting environment's
136 // retries leave its shell synchronizing.
137 if (input.connectionState === "connecting") return true;
138 if (input.connectionState !== "connected") return false;
139 return input.shellStatus === "synchronizing" || input.shellStatus === "empty";
140}
141
142interface NavigationRoutes {
143 readonly routes: ReadonlyArray<{
144 readonly key: string;
145 readonly name: string;
146 readonly params?: object;
147 }>;
148}
149
150/** Flow keys of the draft routes on the stack. */
151export function liveNewTaskFlowKeys(state: NavigationRoutes): ReadonlySet<string> {
152 return new Set(
153 state.routes.flatMap((route) => {
154 const flowKey = isDraftThreadRoute(route) ? newTaskFlowKey(route) : undefined;
155 return flowKey === undefined ? [] : [flowKey];
156 }),
157 );
158}
159
160/**
161 * Drops the flow of every draft route that left the stack: popped (discarded)
162 * or promoted to a live thread, whose params no longer carry the draft.
163 */
164export function pruneNewTaskFlowStates(state: NavigationRoutes): void {
165 const flows = appAtomRegistry.get(flowStatesAtom);
166 const live = liveNewTaskFlowKeys(state);
167 const kept = Object.entries(flows).filter(([flowKey]) => live.has(flowKey));
168 if (kept.length === Object.keys(flows).length) return;
169 appAtomRegistry.set(flowStatesAtom, Object.fromEntries(kept));
170}