1/**
2 * Multi-environment usage state.
3 *
4 * Every connected environment answers the same typed query; the client merges
5 * the results. Raw transcripts never leave the machine that produced them.
6 *
7 * Mirror of `apps/web/src/state/usage.ts` over mobile's atom wiring; the merge
8 * rules themselves live in `@t3tools/shared/usageMerge`.
9 *
10 * @module state/usage
11 */
12import { useAtomValue } from "@effect/atom-react";
13import {
14 AuthDiagnosticsReadScope,
15 USAGE_CONTRACT_VERSION,
16 type EnvironmentId,
17 type UsageSummary,
18 type UsageSummaryInput,
19} from "@t3tools/contracts";
20import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage";
21import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access";
22import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge";
23import * as Option from "effect/Option";
24import { AsyncResult, Atom } from "effect/reactivity";
25import { useCallback, useMemo } from "react";
26
27import { appAtomRegistry } from "./atom-registry";
28import { environmentPresentations } from "./presentation";
29import { serverEnvironment } from "./server";
30import { environmentSession, readEnvironmentScope } from "./session";
31
32export interface EnvironmentUsageStatus {
33 readonly environmentId: EnvironmentId;
34 readonly label: string;
35 readonly isPending: boolean;
36 readonly isConnected: boolean;
37 readonly canReadDiagnostics: boolean;
38 readonly error: string | null;
39 readonly summary: UsageSummary | null;
40 readonly needsCursorKeychainAccess: boolean;
41}
42
43/**
44 * Reads every environment's summary for one window.
45 *
46 * Keyed by the serialised window so switching ranges does not thrash the atom
47 * cache, and so each environment's query is shared with any other reader of the
48 * same window.
49 */
50const usageByWindowAtom = Atom.family((windowKey: string) =>
51 Atom.make((get): readonly EnvironmentUsageStatus[] => {
52 const input = JSON.parse(windowKey) as UsageSummaryInput;
53 const presentations = get(environmentPresentations.presentationsAtom);
54
55 const statuses: EnvironmentUsageStatus[] = [];
56 for (const [environmentId, presentation] of presentations) {
57 const sessionResult = get(environmentSession.sessionStateAtom(environmentId));
58 const access = resolveUsageAccess({
59 connectionPhase: presentation.connection.phase,
60 session: Option.getOrNull(AsyncResult.value(sessionResult)),
61 hasSessionError: sessionResult._tag === "Failure",
62 });
63 if (!access.canReadDiagnostics) {
64 statuses.push({
65 environmentId,
66 label: presentation.entry.target.label,
67 isConnected: presentation.connection.phase === "connected",
68 ...access,
69 summary: null,
70 needsCursorKeychainAccess: false,
71 });
72 continue;
73 }
74 const result = get(serverEnvironment.usageSummary({ environmentId, input }));
75 const summary = Option.getOrNull(AsyncResult.value(result));
76 statuses.push({
77 environmentId,
78 label: presentation.entry.target.label,
79 isPending: result.waiting,
80 isConnected: presentation.connection.phase === "connected",
81 canReadDiagnostics: true,
82 error: result._tag === "Failure" ? "This environment could not report usage." : null,
83 summary,
84 needsCursorKeychainAccess: needsCursorKeychainAccess(
85 summary,
86 get(serverEnvironment.providersValueAtom(environmentId)),
87 ),
88 });
89 }
90 return statuses;
91 }).pipe(Atom.withLabel(`mobile-usage:window:${windowKey}`)),
92);
93
94export interface UsageView {
95 readonly merged: MergedUsage;
96 readonly environments: readonly EnvironmentUsageStatus[];
97 readonly selectedEnvironments: readonly EnvironmentUsageStatus[];
98 /** True until at least one environment has answered. */
99 readonly isPending: boolean;
100 /**
101 * True while environments that have not failed are still answering. Failed
102 * environments are reported in the environment menu: totals will not
103 * improve by waiting on them, so they must not read as "still reporting".
104 */
105 readonly isPartial: boolean;
106 readonly refresh: (input?: UsageSummaryInput) => Promise<void>;
107}
108
109export function useUsage(
110 input: UsageSummaryInput,
111 selectedEnvironmentIds: ReadonlySet<EnvironmentId> | null = null,
112): UsageView {
113 const windowKey = useMemo(
114 () =>
115 JSON.stringify({
116 sinceDay: input.sinceDay,
117 untilDay: input.untilDay,
118 timeZone: input.timeZone,
119 resolution: input.resolution,
120 sinceTime: input.sinceTime,
121 untilTime: input.untilTime,
122 }),
123 [
124 input.sinceDay,
125 input.untilDay,
126 input.timeZone,
127 input.resolution,
128 input.sinceTime,
129 input.untilTime,
130 ],
131 );
132 const atom = usageByWindowAtom(windowKey);
133 const environments = useAtomValue(atom);
134 const selectedEnvironments = useMemo(
135 () =>
136 selectedEnvironmentIds === null
137 ? environments
138 : environments.filter(({ environmentId }) => selectedEnvironmentIds.has(environmentId)),
139 [environments, selectedEnvironmentIds],
140 );
141
142 const refresh = useCallback(
143 (nextInput?: UsageSummaryInput) =>
144 refreshUsage({
145 registry: appAtomRegistry,
146 server: serverEnvironment,
147 presentations: environmentPresentations,
148 // Only environments this connection may read; the others report a
149 // permission error instead of a stale or failed rescan.
150 environmentIds: selectedEnvironments
151 .filter(
152 (environment) =>
153 environment.canReadDiagnostics &&
154 readEnvironmentScope(environment.environmentId, AuthDiagnosticsReadScope),
155 )
156 .map(({ environmentId }) => environmentId),
157 input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput),
158 }),
159 [selectedEnvironments, windowKey],
160 );
161
162 const merged = useMemo(() => {
163 const answered: EnvironmentUsage[] = selectedEnvironments.flatMap((environment) =>
164 environment.summary === null
165 ? []
166 : [
167 {
168 environmentId: environment.environmentId,
169 label: environment.label,
170 summary: environment.summary,
171 },
172 ],
173 );
174 return mergeUsage(answered, USAGE_CONTRACT_VERSION);
175 }, [selectedEnvironments]);
176
177 const answeredCount = selectedEnvironments.filter(
178 (environment) => environment.summary !== null,
179 ).length;
180 const stillReporting = selectedEnvironments.filter(
181 (environment) => environment.summary === null && environment.error === null,
182 ).length;
183
184 return {
185 merged,
186 environments,
187 selectedEnvironments,
188 isPending: answeredCount === 0 && stillReporting > 0,
189 isPartial: answeredCount > 0 && stillReporting > 0,
190 refresh,
191 };
192}