@omercnet/paseo-context-mode 3.1.3 → 3.1.4-next.434.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/OVERVIEW.md +3 -3
- package/README.md +13 -15
- package/client/analytics-dashboard.tsx +745 -613
- package/client/controls.tsx +295 -0
- package/client/diagnostics-section.tsx +178 -0
- package/client/{context-mode-footer.tsx → footer.tsx} +22 -20
- package/client/knowledge-section.tsx +725 -697
- package/client/{context-mode-pill.tsx → pill.tsx} +5 -5
- package/client/scoped-knowledge-section.tsx +14 -20
- package/client/screen-scope.ts +12 -6
- package/client/settings-screen.tsx +94 -97
- package/client/surface.tsx +377 -0
- package/index.client.tsx +3 -3
- package/index.server.ts +18 -27
- package/npm-shrinkwrap.json +8 -1747
- package/package.json +7 -7
- package/server/actions.ts +91 -102
- package/server/analytics.ts +257 -308
- package/server/binary.ts +119 -94
- package/server/database.ts +463 -0
- package/server/handlers.ts +40 -95
- package/server/integration.ts +230 -361
- package/server/knowledge.ts +65 -137
- package/server/mcp-process.ts +135 -86
- package/server/storage-discovery.ts +46 -199
- package/shared/actions.ts +2 -27
- package/shared/analytics.ts +60 -33
- package/shared/contracts.ts +16 -47
- package/shared/knowledge.ts +62 -65
- package/shared/providers.ts +62 -0
- package/shared/settings.ts +33 -18
- package/client/actions-section.tsx +0 -621
- package/client/context-mode-surface.tsx +0 -444
- package/server/context-mode-db.ts +0 -481
- package/shared/index.ts +0 -5
|
@@ -1,8 +1,24 @@
|
|
|
1
|
+
import type { PluginTheme } from "@getpaseo/plugin";
|
|
1
2
|
import { type PluginSurfaceProps, useRpc } from "@getpaseo/plugin/client";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { SettingsSection } from "@getpaseo/plugin/client/ui";
|
|
4
|
+
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
5
|
+
import { type ReactNode, useState } from "react";
|
|
6
|
+
import { Text, View } from "react-native";
|
|
7
|
+
import {
|
|
8
|
+
type AnalyticsDashboard as AnalyticsDashboardData,
|
|
9
|
+
type AnalyticsWarning,
|
|
10
|
+
type DailyAnalytics,
|
|
11
|
+
getContextModeAnalyticsDashboard,
|
|
12
|
+
type IndexedSourceAnalytics,
|
|
13
|
+
type SessionAnalytics,
|
|
14
|
+
} from "../shared/analytics";
|
|
15
|
+
import { type ResumeSessionInput, resumeContextModeSession } from "../shared/knowledge";
|
|
16
|
+
import { ActionButton, Card, ErrorText, Pending, StatusPill, type StatusTone } from "./controls";
|
|
17
|
+
import {
|
|
18
|
+
ForgetSessionButton,
|
|
19
|
+
ForgetSourceButton,
|
|
20
|
+
presentKnowledgeOutput,
|
|
21
|
+
} from "./knowledge-section";
|
|
6
22
|
|
|
7
23
|
const integerFormatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 });
|
|
8
24
|
const compactFormatter = new Intl.NumberFormat(undefined, {
|
|
@@ -19,6 +35,9 @@ const timestampFormatter = new Intl.DateTimeFormat(undefined, {
|
|
|
19
35
|
timeStyle: "short",
|
|
20
36
|
});
|
|
21
37
|
|
|
38
|
+
const DEFAULT_DAY_LIMIT = 14;
|
|
39
|
+
const DEFAULT_SOURCE_LIMIT = 20;
|
|
40
|
+
|
|
22
41
|
function formatInteger(value: number): string {
|
|
23
42
|
return integerFormatter.format(value);
|
|
24
43
|
}
|
|
@@ -40,678 +59,791 @@ function formatDate(value: string, includeTime = false): string {
|
|
|
40
59
|
return includeTime ? timestampFormatter.format(date) : dateFormatter.format(date);
|
|
41
60
|
}
|
|
42
61
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
type Completeness = AnalyticsDashboardData["completeness"];
|
|
63
|
+
|
|
64
|
+
interface CompletenessPresentation {
|
|
65
|
+
label: string;
|
|
66
|
+
tone: StatusTone;
|
|
67
|
+
notice: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const COMPLETENESS: Record<Completeness, CompletenessPresentation> = {
|
|
71
|
+
complete: { label: "Complete", tone: "success", notice: "Analytics notice" },
|
|
72
|
+
partial: { label: "Partial", tone: "warning", notice: "Some analytics are partial" },
|
|
73
|
+
unsupported: { label: "Unsupported", tone: "danger", notice: "Analytics unavailable" },
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function createStyles(theme: PluginTheme, compact: boolean) {
|
|
77
|
+
return {
|
|
78
|
+
root: { gap: 16 },
|
|
79
|
+
grow: { flex: 1 },
|
|
80
|
+
row: { flexDirection: "row" as const, alignItems: "center" as const, gap: 8 },
|
|
81
|
+
headline: {
|
|
82
|
+
color: theme.colors.foreground,
|
|
83
|
+
fontSize: compact ? 28 : 36,
|
|
84
|
+
fontWeight: "500" as const,
|
|
85
|
+
lineHeight: compact ? 32 : 40,
|
|
86
|
+
},
|
|
87
|
+
caption: { color: theme.colors.foregroundMuted, fontSize: 12, fontWeight: "500" as const },
|
|
88
|
+
emphasis: { color: theme.colors.foreground, fontSize: 14, fontWeight: "500" as const },
|
|
89
|
+
subtitle: { color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 18 },
|
|
90
|
+
metricGrid: { flexDirection: "row" as const, flexWrap: "wrap" as const, gap: 8 },
|
|
91
|
+
metric: {
|
|
92
|
+
minWidth: compact ? 112 : 136,
|
|
93
|
+
flexBasis: compact ? 112 : 136,
|
|
94
|
+
flexGrow: 1,
|
|
95
|
+
gap: 4,
|
|
96
|
+
borderWidth: 1,
|
|
97
|
+
borderColor: theme.colors.border,
|
|
98
|
+
borderRadius: 8,
|
|
99
|
+
paddingHorizontal: 12,
|
|
100
|
+
paddingVertical: 8,
|
|
101
|
+
backgroundColor: theme.colors.surface1,
|
|
102
|
+
},
|
|
103
|
+
metricValue: { color: theme.colors.foreground, fontSize: 18, fontWeight: "500" as const },
|
|
104
|
+
label: { color: theme.colors.foregroundMuted, fontSize: 12 },
|
|
105
|
+
listRow: {
|
|
106
|
+
flexDirection: "row" as const,
|
|
107
|
+
alignItems: "center" as const,
|
|
108
|
+
gap: 8,
|
|
109
|
+
minHeight: 32,
|
|
110
|
+
},
|
|
111
|
+
rowTitle: { color: theme.colors.foreground, fontSize: 13 },
|
|
112
|
+
rowValue: { color: theme.colors.foreground, fontSize: 12 },
|
|
113
|
+
divider: { height: 1, backgroundColor: theme.colors.border },
|
|
114
|
+
timelineDate: { color: theme.colors.foregroundMuted, fontSize: 12, width: 48 },
|
|
115
|
+
timelineTrack: {
|
|
116
|
+
flex: 1,
|
|
117
|
+
height: 8,
|
|
118
|
+
borderRadius: 999,
|
|
119
|
+
overflow: "hidden" as const,
|
|
120
|
+
backgroundColor: theme.colors.surface2,
|
|
121
|
+
},
|
|
122
|
+
timelineFill: {
|
|
123
|
+
height: "100%" as const,
|
|
124
|
+
minWidth: 2,
|
|
125
|
+
borderRadius: 999,
|
|
126
|
+
backgroundColor: theme.colors.accent,
|
|
127
|
+
},
|
|
128
|
+
timelineValue: {
|
|
129
|
+
color: theme.colors.foreground,
|
|
130
|
+
fontSize: 12,
|
|
131
|
+
width: compact ? 56 : 76,
|
|
132
|
+
textAlign: "right" as const,
|
|
133
|
+
},
|
|
134
|
+
stack: { gap: 8 },
|
|
135
|
+
meta: { color: theme.colors.foregroundMuted, fontSize: 12, textAlign: "right" as const },
|
|
136
|
+
raw: {
|
|
137
|
+
color: theme.colors.foreground,
|
|
138
|
+
fontFamily: "monospace",
|
|
139
|
+
fontSize: 12,
|
|
140
|
+
lineHeight: 16,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
66
143
|
}
|
|
144
|
+
|
|
145
|
+
type DashboardStyles = ReturnType<typeof createStyles>;
|
|
146
|
+
|
|
67
147
|
interface AnalyticsDashboardProps extends Pick<PluginSurfaceProps, "theme" | "host" | "layout"> {
|
|
68
|
-
fallback?: string | null;
|
|
69
148
|
health?: ReactNode;
|
|
70
149
|
display?: "full" | "hero" | "details";
|
|
71
150
|
}
|
|
151
|
+
|
|
72
152
|
export function AnalyticsDashboard({
|
|
73
153
|
theme,
|
|
74
154
|
host,
|
|
75
155
|
layout,
|
|
76
|
-
fallback,
|
|
77
156
|
health,
|
|
78
157
|
display = "full",
|
|
79
158
|
}: AnalyticsDashboardProps) {
|
|
80
159
|
const showHero = display !== "details";
|
|
81
160
|
const showDetails = display !== "hero";
|
|
82
161
|
const loadDashboard = useRpc(getContextModeAnalyticsDashboard);
|
|
83
|
-
const [rawExpanded, setRawExpanded] = useState(false);
|
|
84
|
-
const [sourceLimit, setSourceLimit] = useState(20);
|
|
85
|
-
const [detailsExpanded, setDetailsExpanded] = useState(false);
|
|
86
|
-
const [warningsExpanded, setWarningsExpanded] = useState(false);
|
|
87
|
-
const [dayLimit, setDayLimit] = useState(14);
|
|
88
162
|
const dashboard = useQuery({
|
|
89
163
|
queryKey: ["context-mode", host.id, "analytics-dashboard"],
|
|
90
164
|
queryFn: () => loadDashboard({}),
|
|
91
165
|
});
|
|
92
|
-
const styles =
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
centered: {
|
|
96
|
-
minHeight: 180,
|
|
97
|
-
alignItems: "center" as const,
|
|
98
|
-
justifyContent: "center" as const,
|
|
99
|
-
gap: 10,
|
|
100
|
-
},
|
|
101
|
-
section: { gap: 8 },
|
|
102
|
-
sectionTitle: {
|
|
103
|
-
color: theme.colors.foreground,
|
|
104
|
-
fontSize: 15,
|
|
105
|
-
fontWeight: "700" as const,
|
|
106
|
-
},
|
|
107
|
-
grow: { flex: 1 },
|
|
108
|
-
card: {
|
|
109
|
-
gap: 10,
|
|
110
|
-
borderWidth: 1,
|
|
111
|
-
borderColor: theme.colors.border,
|
|
112
|
-
borderRadius: 12,
|
|
113
|
-
padding: layout.compact ? 12 : 16,
|
|
114
|
-
backgroundColor: theme.colors.surface1,
|
|
115
|
-
},
|
|
116
|
-
hero: {
|
|
117
|
-
gap: 5,
|
|
118
|
-
borderWidth: 1,
|
|
119
|
-
borderColor: theme.colors.border,
|
|
120
|
-
borderRadius: 14,
|
|
121
|
-
padding: layout.compact ? 14 : 20,
|
|
122
|
-
backgroundColor: theme.colors.surface1,
|
|
123
|
-
},
|
|
124
|
-
eyebrow: {
|
|
125
|
-
color: theme.colors.foregroundMuted,
|
|
126
|
-
fontSize: 11,
|
|
127
|
-
fontWeight: "700" as const,
|
|
128
|
-
letterSpacing: 0.7,
|
|
129
|
-
textTransform: "uppercase" as const,
|
|
130
|
-
},
|
|
131
|
-
headline: {
|
|
132
|
-
color: theme.colors.foreground,
|
|
133
|
-
fontSize: layout.compact ? 28 : 36,
|
|
134
|
-
fontWeight: "800" as const,
|
|
135
|
-
lineHeight: layout.compact ? 32 : 40,
|
|
136
|
-
},
|
|
137
|
-
subtitle: { color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 18 },
|
|
138
|
-
metricGrid: {
|
|
139
|
-
flexDirection: "row" as const,
|
|
140
|
-
flexWrap: "wrap" as const,
|
|
141
|
-
gap: 8,
|
|
142
|
-
},
|
|
143
|
-
metric: {
|
|
144
|
-
minWidth: layout.compact ? 110 : 138,
|
|
145
|
-
flexBasis: layout.compact ? 110 : 138,
|
|
146
|
-
flexGrow: 1,
|
|
147
|
-
gap: 2,
|
|
148
|
-
borderWidth: 1,
|
|
149
|
-
borderColor: theme.colors.border,
|
|
150
|
-
borderRadius: 10,
|
|
151
|
-
paddingHorizontal: 11,
|
|
152
|
-
paddingVertical: 9,
|
|
153
|
-
backgroundColor: theme.colors.surface1,
|
|
154
|
-
},
|
|
155
|
-
metricValue: {
|
|
156
|
-
color: theme.colors.foreground,
|
|
157
|
-
fontSize: 18,
|
|
158
|
-
fontWeight: "700" as const,
|
|
159
|
-
},
|
|
160
|
-
label: { color: theme.colors.foregroundMuted, fontSize: 11 },
|
|
161
|
-
row: {
|
|
162
|
-
flexDirection: "row" as const,
|
|
163
|
-
alignItems: "center" as const,
|
|
164
|
-
gap: 8,
|
|
165
|
-
},
|
|
166
|
-
listRow: {
|
|
167
|
-
flexDirection: "row" as const,
|
|
168
|
-
alignItems: "center" as const,
|
|
169
|
-
gap: 8,
|
|
170
|
-
minHeight: 30,
|
|
171
|
-
},
|
|
172
|
-
rowTitle: {
|
|
173
|
-
color: theme.colors.foreground,
|
|
174
|
-
fontSize: 13,
|
|
175
|
-
fontWeight: "600" as const,
|
|
176
|
-
},
|
|
177
|
-
rowValue: { color: theme.colors.foreground, fontSize: 12 },
|
|
178
|
-
divider: { height: 1, backgroundColor: theme.colors.border },
|
|
179
|
-
badge: {
|
|
180
|
-
borderRadius: 999,
|
|
181
|
-
paddingHorizontal: 8,
|
|
182
|
-
paddingVertical: 3,
|
|
183
|
-
backgroundColor: theme.colors.surface2,
|
|
184
|
-
},
|
|
185
|
-
badgeText: { fontSize: 11, fontWeight: "700" as const },
|
|
186
|
-
success: { color: theme.colors.statusSuccess },
|
|
187
|
-
warning: { color: theme.colors.statusWarning },
|
|
188
|
-
danger: { color: theme.colors.statusDanger },
|
|
189
|
-
warningCard: {
|
|
190
|
-
gap: 6,
|
|
191
|
-
borderWidth: 1,
|
|
192
|
-
borderColor: theme.colors.statusWarning,
|
|
193
|
-
borderRadius: 10,
|
|
194
|
-
padding: 11,
|
|
195
|
-
backgroundColor: theme.colors.surface1,
|
|
196
|
-
},
|
|
197
|
-
timelineRow: {
|
|
198
|
-
flexDirection: "row" as const,
|
|
199
|
-
alignItems: "center" as const,
|
|
200
|
-
gap: 9,
|
|
201
|
-
minHeight: 30,
|
|
202
|
-
},
|
|
203
|
-
timelineDate: { color: theme.colors.foregroundMuted, fontSize: 11, width: 48 },
|
|
204
|
-
timelineTrack: {
|
|
205
|
-
flex: 1,
|
|
206
|
-
height: 7,
|
|
207
|
-
borderRadius: 999,
|
|
208
|
-
overflow: "hidden" as const,
|
|
209
|
-
backgroundColor: theme.colors.surface2,
|
|
210
|
-
},
|
|
211
|
-
timelineFill: {
|
|
212
|
-
height: "100%" as const,
|
|
213
|
-
minWidth: 2,
|
|
214
|
-
borderRadius: 999,
|
|
215
|
-
backgroundColor: theme.colors.accent,
|
|
216
|
-
},
|
|
217
|
-
timelineValue: {
|
|
218
|
-
color: theme.colors.foreground,
|
|
219
|
-
fontSize: 11,
|
|
220
|
-
width: layout.compact ? 58 : 76,
|
|
221
|
-
textAlign: "right" as const,
|
|
222
|
-
},
|
|
223
|
-
sourceMeta: {
|
|
224
|
-
color: theme.colors.foregroundMuted,
|
|
225
|
-
fontSize: 11,
|
|
226
|
-
textAlign: "right" as const,
|
|
227
|
-
},
|
|
228
|
-
button: {
|
|
229
|
-
minHeight: 36,
|
|
230
|
-
alignItems: "center" as const,
|
|
231
|
-
justifyContent: "center" as const,
|
|
232
|
-
borderRadius: 8,
|
|
233
|
-
paddingHorizontal: 12,
|
|
234
|
-
paddingVertical: 8,
|
|
235
|
-
backgroundColor: theme.colors.surface2,
|
|
236
|
-
},
|
|
237
|
-
buttonText: { color: theme.colors.foreground, fontWeight: "600" as const },
|
|
238
|
-
raw: {
|
|
239
|
-
color: theme.colors.foreground,
|
|
240
|
-
fontFamily: "monospace",
|
|
241
|
-
fontSize: layout.compact ? 10 : 11,
|
|
242
|
-
lineHeight: layout.compact ? 15 : 17,
|
|
243
|
-
},
|
|
244
|
-
}),
|
|
245
|
-
[layout.compact, theme],
|
|
246
|
-
);
|
|
166
|
+
const styles = createStyles(theme, layout.compact);
|
|
167
|
+
const heroHealth = showHero ? health : null;
|
|
168
|
+
|
|
247
169
|
if (dashboard.isPending) {
|
|
248
170
|
return (
|
|
249
171
|
<View style={styles.root}>
|
|
250
|
-
{
|
|
251
|
-
<
|
|
252
|
-
accessible
|
|
253
|
-
accessibilityLabel="Loading lifetime Context Mode analytics"
|
|
254
|
-
style={styles.centered}
|
|
255
|
-
>
|
|
256
|
-
<ActivityIndicator color={theme.colors.accent} />
|
|
257
|
-
<Text style={styles.subtitle}>Loading lifetime impact…</Text>
|
|
258
|
-
</View>
|
|
172
|
+
{heroHealth}
|
|
173
|
+
<Pending theme={theme} label="Loading lifetime impact..." />
|
|
259
174
|
</View>
|
|
260
175
|
);
|
|
261
176
|
}
|
|
262
177
|
|
|
263
|
-
if (dashboard.
|
|
264
|
-
const message = dashboard.error
|
|
265
|
-
? dashboard.error instanceof Error
|
|
266
|
-
? dashboard.error.message
|
|
267
|
-
: String(dashboard.error)
|
|
268
|
-
: "Context Mode returned no analytics data.";
|
|
178
|
+
if (dashboard.isError) {
|
|
269
179
|
return (
|
|
270
180
|
<View style={styles.root}>
|
|
271
|
-
{
|
|
272
|
-
<
|
|
273
|
-
<
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
<Pressable
|
|
181
|
+
{heroHealth}
|
|
182
|
+
<Card theme={theme}>
|
|
183
|
+
<ErrorText theme={theme} error={dashboard.error} />
|
|
184
|
+
<ActionButton
|
|
185
|
+
theme={theme}
|
|
277
186
|
accessibilityLabel="Retry loading Context Mode analytics"
|
|
278
|
-
accessibilityRole="button"
|
|
279
187
|
disabled={dashboard.isFetching}
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
{dashboard.isFetching ? "Retrying…" : "Try again"}
|
|
285
|
-
</Text>
|
|
286
|
-
</Pressable>
|
|
287
|
-
</View>
|
|
188
|
+
label={dashboard.isFetching ? "Retrying..." : "Try again"}
|
|
189
|
+
onPress={() => void dashboard.refetch()}
|
|
190
|
+
/>
|
|
191
|
+
</Card>
|
|
288
192
|
</View>
|
|
289
193
|
);
|
|
290
194
|
}
|
|
291
195
|
|
|
292
196
|
const data = dashboard.data;
|
|
293
|
-
const visibleDays = data.byDay.slice(-dayLimit).reverse();
|
|
294
|
-
const maxDailySavings = Math.max(1, ...visibleDays.map((day) => day.savedTokens));
|
|
295
|
-
const rawFallback = fallback?.trim() || optionalFallback(data);
|
|
296
|
-
const isEmpty =
|
|
297
|
-
data.totals.sessions === 0 &&
|
|
298
|
-
data.totals.toolCalls === 0 &&
|
|
299
|
-
data.byProvider.length === 0 &&
|
|
300
|
-
data.byCategory.length === 0 &&
|
|
301
|
-
data.byDay.length === 0 &&
|
|
302
|
-
data.sources.length === 0;
|
|
303
|
-
const visibleSources = data.sources.slice(0, sourceLimit);
|
|
304
|
-
const completenessLabel =
|
|
305
|
-
data.completeness === "complete"
|
|
306
|
-
? "Complete"
|
|
307
|
-
: data.completeness === "partial"
|
|
308
|
-
? "Partial"
|
|
309
|
-
: "Unsupported";
|
|
310
|
-
const completenessStyle =
|
|
311
|
-
data.completeness === "complete"
|
|
312
|
-
? styles.success
|
|
313
|
-
: data.completeness === "partial"
|
|
314
|
-
? styles.warning
|
|
315
|
-
: styles.danger;
|
|
316
|
-
|
|
317
197
|
return (
|
|
318
198
|
<View style={styles.root}>
|
|
319
|
-
{showHero ?
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
199
|
+
{showHero ? <Hero data={data} health={health} styles={styles} theme={theme} /> : null}
|
|
200
|
+
{showDetails ? (
|
|
201
|
+
<DashboardDetails data={data} host={host} styles={styles} theme={theme} />
|
|
202
|
+
) : null}
|
|
203
|
+
</View>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
interface DashboardPartProps {
|
|
208
|
+
data: AnalyticsDashboardData;
|
|
209
|
+
styles: DashboardStyles;
|
|
210
|
+
theme: PluginTheme;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
interface HeroProps extends DashboardPartProps {
|
|
214
|
+
health: ReactNode;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function Hero({ data, health, styles, theme }: HeroProps) {
|
|
218
|
+
const completeness = COMPLETENESS[data.completeness];
|
|
219
|
+
const savedPercent = percentFormatter.format(data.totals.savingsPercent);
|
|
220
|
+
const showNotice = data.completeness !== "complete" || data.warnings.length > 0;
|
|
221
|
+
return (
|
|
222
|
+
<>
|
|
223
|
+
<View
|
|
224
|
+
accessible
|
|
225
|
+
accessibilityLabel={`${formatInteger(data.totals.savedTokens)} lifetime tokens saved, ${savedPercent} percent reduction`}
|
|
226
|
+
>
|
|
227
|
+
<Card theme={theme}>
|
|
228
|
+
<View style={styles.row}>
|
|
229
|
+
<Text style={[styles.caption, styles.grow]}>Lifetime impact</Text>
|
|
230
|
+
{data.completeness === "complete" ? null : (
|
|
231
|
+
<StatusPill theme={theme} label={completeness.label} tone={completeness.tone} />
|
|
232
|
+
)}
|
|
233
|
+
</View>
|
|
234
|
+
<View style={{ gap: 4 }}>
|
|
335
235
|
<Text style={styles.headline}>{formatCompact(data.totals.savedTokens)}</Text>
|
|
336
|
-
<Text style={styles.
|
|
236
|
+
<Text style={styles.emphasis}>tokens saved</Text>
|
|
337
237
|
<Text style={styles.subtitle}>
|
|
338
|
-
{
|
|
339
|
-
|
|
238
|
+
{savedPercent}% less context returned across {formatInteger(data.totals.sessions)}{" "}
|
|
239
|
+
sessions
|
|
340
240
|
</Text>
|
|
341
241
|
</View>
|
|
242
|
+
</Card>
|
|
243
|
+
</View>
|
|
244
|
+
{health}
|
|
245
|
+
{showNotice ? <AnalyticsNotice data={data} styles={styles} theme={theme} /> : null}
|
|
246
|
+
</>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function AnalyticsNotice({ data, styles, theme }: DashboardPartProps) {
|
|
251
|
+
const [expanded, setExpanded] = useState(false);
|
|
252
|
+
const completeness = COMPLETENESS[data.completeness];
|
|
253
|
+
return (
|
|
254
|
+
<View
|
|
255
|
+
accessibilityRole="alert"
|
|
256
|
+
style={{
|
|
257
|
+
gap: 8,
|
|
258
|
+
borderWidth: 1,
|
|
259
|
+
borderColor: theme.colors.statusWarning,
|
|
260
|
+
borderRadius: 8,
|
|
261
|
+
padding: 12,
|
|
262
|
+
backgroundColor: theme.colors.surface1,
|
|
263
|
+
}}
|
|
264
|
+
>
|
|
265
|
+
<View style={styles.row}>
|
|
266
|
+
<Text style={[styles.rowTitle, styles.grow]}>{completeness.notice}</Text>
|
|
267
|
+
{data.warnings.length > 0 ? (
|
|
268
|
+
<ActionButton
|
|
269
|
+
theme={theme}
|
|
270
|
+
variant="outline"
|
|
271
|
+
expanded={expanded}
|
|
272
|
+
accessibilityLabel={expanded ? "Hide analytics warnings" : "Show analytics warnings"}
|
|
273
|
+
label={expanded ? "Hide" : `${data.warnings.length} details`}
|
|
274
|
+
onPress={() => setExpanded((value) => !value)}
|
|
275
|
+
/>
|
|
276
|
+
) : null}
|
|
277
|
+
</View>
|
|
278
|
+
{expanded
|
|
279
|
+
? data.warnings.map((warning) => (
|
|
280
|
+
<WarningLine
|
|
281
|
+
key={`${warning.code}:${warning.project ?? ""}:${warning.message}`}
|
|
282
|
+
warning={warning}
|
|
283
|
+
styles={styles}
|
|
284
|
+
/>
|
|
285
|
+
))
|
|
286
|
+
: null}
|
|
287
|
+
</View>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
interface WarningLineProps {
|
|
292
|
+
warning: AnalyticsWarning;
|
|
293
|
+
styles: DashboardStyles;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function WarningLine({ warning, styles }: WarningLineProps) {
|
|
297
|
+
return <Text style={styles.subtitle}>{`${warning.code}: ${warning.message}`}</Text>;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
interface DashboardDetailsProps extends DashboardPartProps {
|
|
301
|
+
host: PluginSurfaceProps["host"];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function isEmptyDashboard(data: AnalyticsDashboardData): boolean {
|
|
305
|
+
const noTotals = data.totals.sessions === 0 && data.totals.toolCalls === 0;
|
|
306
|
+
const noBreakdowns =
|
|
307
|
+
data.byAgent.length === 0 &&
|
|
308
|
+
data.byProject.length === 0 &&
|
|
309
|
+
data.byCategory.length === 0 &&
|
|
310
|
+
data.byDay.length === 0;
|
|
311
|
+
const noLists = data.sources.length === 0 && data.recentSessions.length === 0;
|
|
312
|
+
return noTotals && noBreakdowns && noLists;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function DashboardDetails({ data, host, styles, theme }: DashboardDetailsProps) {
|
|
316
|
+
if (isEmptyDashboard(data)) {
|
|
317
|
+
const unsupported = data.completeness === "unsupported";
|
|
318
|
+
const title = unsupported ? "Analytics unavailable" : "No activity yet";
|
|
319
|
+
const hint = unsupported
|
|
320
|
+
? "Update Context Mode to a supported analytics schema to populate this dashboard."
|
|
321
|
+
: "Lifetime metrics will appear after Context Mode records tool activity or indexes a source.";
|
|
322
|
+
return (
|
|
323
|
+
<Card theme={theme}>
|
|
324
|
+
<Text style={styles.emphasis}>{title}</Text>
|
|
325
|
+
<Text style={styles.subtitle}>{hint}</Text>
|
|
326
|
+
</Card>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
const part = { data, styles, theme };
|
|
330
|
+
return (
|
|
331
|
+
<>
|
|
332
|
+
<AtAGlance {...part} />
|
|
333
|
+
<SavingsOverTime {...part} />
|
|
334
|
+
<AgentBreakdown {...part} />
|
|
335
|
+
<ProjectBreakdown {...part} />
|
|
336
|
+
{data.recentSessions.length > 0 ? <RecentSessions {...part} host={host} /> : null}
|
|
337
|
+
<CategoryBreakdown {...part} />
|
|
338
|
+
<KnowledgeSources {...part} host={host} />
|
|
339
|
+
</>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function AtAGlance({ data, styles, theme }: DashboardPartProps) {
|
|
344
|
+
const [expanded, setExpanded] = useState(false);
|
|
345
|
+
const { totals } = data;
|
|
346
|
+
return (
|
|
347
|
+
<SettingsSection title="At a glance">
|
|
348
|
+
<View style={styles.stack}>
|
|
349
|
+
<View style={styles.metricGrid}>
|
|
350
|
+
<Metric label="Agents" value={formatInteger(totals.agents)} styles={styles} />
|
|
351
|
+
<Metric label="Sessions" value={formatInteger(totals.sessions)} styles={styles} />
|
|
352
|
+
<Metric label="Tool calls" value={formatInteger(totals.toolCalls)} styles={styles} />
|
|
353
|
+
<Metric
|
|
354
|
+
label="Indexed sources"
|
|
355
|
+
value={formatInteger(totals.indexedSources)}
|
|
356
|
+
styles={styles}
|
|
357
|
+
/>
|
|
358
|
+
</View>
|
|
359
|
+
<ActionButton
|
|
360
|
+
theme={theme}
|
|
361
|
+
variant="outline"
|
|
362
|
+
expanded={expanded}
|
|
363
|
+
accessibilityLabel={expanded ? "Hide analytics details" : "Show analytics details"}
|
|
364
|
+
label={expanded ? "Hide detail" : "More detail"}
|
|
365
|
+
onPress={() => setExpanded((value) => !value)}
|
|
366
|
+
/>
|
|
367
|
+
{expanded ? <MoreDetail data={data} styles={styles} theme={theme} /> : null}
|
|
368
|
+
</View>
|
|
369
|
+
</SettingsSection>
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function MoreDetail({ data, styles, theme }: DashboardPartProps) {
|
|
374
|
+
const { totals } = data;
|
|
375
|
+
const bytes = totals.byteAccounting;
|
|
376
|
+
const lastActivity = totals.lastActivityAt ? formatDate(totals.lastActivityAt, true) : "None";
|
|
377
|
+
const version = data.contextModeVersion ? ` · Context Mode ${data.contextModeVersion}` : "";
|
|
378
|
+
return (
|
|
379
|
+
<>
|
|
380
|
+
<View style={styles.metricGrid}>
|
|
381
|
+
<Metric label="Last activity" value={lastActivity} styles={styles} />
|
|
382
|
+
<Metric label="Projects" value={formatInteger(totals.projects)} styles={styles} />
|
|
383
|
+
<Metric label="Events" value={formatInteger(totals.events)} styles={styles} />
|
|
384
|
+
<Metric label="Input tokens" value={formatCompact(totals.inputTokens)} styles={styles} />
|
|
385
|
+
<Metric label="Output tokens" value={formatCompact(totals.outputTokens)} styles={styles} />
|
|
386
|
+
<Metric
|
|
387
|
+
label="Indexed chunks"
|
|
388
|
+
value={formatInteger(totals.indexedChunks)}
|
|
389
|
+
styles={styles}
|
|
390
|
+
/>
|
|
391
|
+
</View>
|
|
392
|
+
<Card theme={theme}>
|
|
393
|
+
<Text style={styles.rowTitle}>Byte accounting</Text>
|
|
394
|
+
<Text style={styles.subtitle}>
|
|
395
|
+
{formatBytes(bytes.savedBytes)} saved · {formatBytes(bytes.returnedBytes)} returned ·{" "}
|
|
396
|
+
{formatBytes(bytes.producedBytes)} produced
|
|
397
|
+
</Text>
|
|
398
|
+
<Text style={styles.label}>
|
|
399
|
+
{formatBytes(bytes.indexedBytes)} indexed{version}
|
|
400
|
+
</Text>
|
|
401
|
+
</Card>
|
|
402
|
+
</>
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
interface ShowMoreToggleProps {
|
|
407
|
+
theme: PluginTheme;
|
|
408
|
+
total: number;
|
|
409
|
+
limit: number;
|
|
410
|
+
initialLimit: number;
|
|
411
|
+
step: number;
|
|
412
|
+
onLimitChange(limit: number): void;
|
|
413
|
+
moreLabel(next: number): string;
|
|
414
|
+
moreAccessibilityLabel(remaining: number): string;
|
|
415
|
+
lessLabel: string;
|
|
416
|
+
lessAccessibilityLabel: string;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Grows a capped list by `step`, then offers to collapse it back once it is fully shown. */
|
|
420
|
+
function ShowMoreToggle({
|
|
421
|
+
theme,
|
|
422
|
+
total,
|
|
423
|
+
limit,
|
|
424
|
+
initialLimit,
|
|
425
|
+
step,
|
|
426
|
+
onLimitChange,
|
|
427
|
+
moreLabel,
|
|
428
|
+
moreAccessibilityLabel,
|
|
429
|
+
lessLabel,
|
|
430
|
+
lessAccessibilityLabel,
|
|
431
|
+
}: ShowMoreToggleProps) {
|
|
432
|
+
const remaining = total - limit;
|
|
433
|
+
if (remaining > 0) {
|
|
434
|
+
return (
|
|
435
|
+
<ActionButton
|
|
436
|
+
theme={theme}
|
|
437
|
+
variant="outline"
|
|
438
|
+
accessibilityLabel={moreAccessibilityLabel(remaining)}
|
|
439
|
+
label={moreLabel(Math.min(step, remaining))}
|
|
440
|
+
onPress={() => onLimitChange(Math.min(total, limit + step))}
|
|
441
|
+
/>
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
if (limit > initialLimit && total > initialLimit) {
|
|
445
|
+
return (
|
|
446
|
+
<ActionButton
|
|
447
|
+
theme={theme}
|
|
448
|
+
variant="outline"
|
|
449
|
+
accessibilityLabel={lessAccessibilityLabel}
|
|
450
|
+
label={lessLabel}
|
|
451
|
+
onPress={() => onLimitChange(initialLimit)}
|
|
452
|
+
/>
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function SavingsOverTime({ data, styles, theme }: DashboardPartProps) {
|
|
459
|
+
const [dayLimit, setDayLimit] = useState(DEFAULT_DAY_LIMIT);
|
|
460
|
+
const visibleDays = data.byDay.slice(-dayLimit).reverse();
|
|
461
|
+
const dailySavings = visibleDays.map((day) => day.savedTokens);
|
|
462
|
+
const maxDailySavings = Math.max(1, ...dailySavings);
|
|
463
|
+
return (
|
|
464
|
+
<SettingsSection title="Savings over time">
|
|
465
|
+
<Card theme={theme}>
|
|
466
|
+
{data.byDay.length === 0 ? <Text style={styles.subtitle}>No dated activity</Text> : null}
|
|
467
|
+
{visibleDays.map((day) => (
|
|
468
|
+
<DayRow key={day.date} day={day} max={maxDailySavings} styles={styles} />
|
|
469
|
+
))}
|
|
470
|
+
<ShowMoreToggle
|
|
471
|
+
theme={theme}
|
|
472
|
+
total={data.byDay.length}
|
|
473
|
+
limit={Math.min(dayLimit, data.byDay.length)}
|
|
474
|
+
initialLimit={DEFAULT_DAY_LIMIT}
|
|
475
|
+
step={30}
|
|
476
|
+
onLimitChange={setDayLimit}
|
|
477
|
+
moreLabel={() => "Show more history"}
|
|
478
|
+
moreAccessibilityLabel={(remaining) =>
|
|
479
|
+
`Show more savings history, ${remaining} days remaining`
|
|
480
|
+
}
|
|
481
|
+
lessLabel={`Show recent ${DEFAULT_DAY_LIMIT} days`}
|
|
482
|
+
lessAccessibilityLabel="Show recent savings history only"
|
|
483
|
+
/>
|
|
484
|
+
</Card>
|
|
485
|
+
</SettingsSection>
|
|
486
|
+
);
|
|
487
|
+
}
|
|
342
488
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
489
|
+
interface DayRowProps {
|
|
490
|
+
day: DailyAnalytics;
|
|
491
|
+
max: number;
|
|
492
|
+
styles: DashboardStyles;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function DayRow({ day, max, styles }: DayRowProps) {
|
|
496
|
+
const formattedDate = formatDate(day.date);
|
|
497
|
+
const percent = Math.max(2, Math.round((day.savedTokens / max) * 100));
|
|
498
|
+
const width: `${number}%` = `${percent}%`;
|
|
499
|
+
return (
|
|
500
|
+
<View
|
|
501
|
+
accessible
|
|
502
|
+
accessibilityLabel={`${formattedDate}: ${formatInteger(day.calls)} calls, ${formatInteger(day.savedTokens)} tokens saved`}
|
|
503
|
+
style={styles.listRow}
|
|
504
|
+
>
|
|
505
|
+
<Text style={styles.timelineDate}>{formattedDate}</Text>
|
|
506
|
+
<View style={styles.timelineTrack}>
|
|
507
|
+
<View style={[styles.timelineFill, { width }]} />
|
|
508
|
+
</View>
|
|
509
|
+
<Text style={styles.timelineValue}>{formatCompact(day.savedTokens)}</Text>
|
|
510
|
+
</View>
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
interface DividedProps {
|
|
515
|
+
index: number;
|
|
516
|
+
styles: DashboardStyles;
|
|
517
|
+
children: ReactNode;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** A card row; rows after the first carry the divider. */
|
|
521
|
+
function Divided({ index, styles, children }: DividedProps) {
|
|
522
|
+
return (
|
|
523
|
+
<View style={styles.stack}>
|
|
524
|
+
{index > 0 ? <View style={styles.divider} /> : null}
|
|
525
|
+
{children}
|
|
526
|
+
</View>
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function AgentBreakdown({ data, styles, theme }: DashboardPartProps) {
|
|
531
|
+
return (
|
|
532
|
+
<SettingsSection title="Agents">
|
|
533
|
+
<Card theme={theme}>
|
|
534
|
+
{data.byAgent.length === 0 ? <Text style={styles.subtitle}>No agent activity</Text> : null}
|
|
535
|
+
{data.byAgent.map((agent, index) => (
|
|
536
|
+
<Divided key={agent.agent} index={index} styles={styles}>
|
|
537
|
+
<View
|
|
538
|
+
accessible
|
|
539
|
+
accessibilityLabel={`${agent.agent}: ${formatInteger(agent.projects)} projects, ${formatInteger(agent.sessions)} sessions, ${formatInteger(agent.toolCalls)} tool calls, ${formatInteger(agent.savedTokens)} tokens saved, ${percentFormatter.format(agent.savingsPercent)} percent reduction`}
|
|
540
|
+
style={styles.listRow}
|
|
541
|
+
>
|
|
542
|
+
<View style={styles.grow}>
|
|
543
|
+
<Text style={styles.rowTitle}>{agent.agent}</Text>
|
|
544
|
+
<Text style={styles.label}>
|
|
545
|
+
{formatInteger(agent.projects)} projects · {formatInteger(agent.sessions)}{" "}
|
|
546
|
+
sessions · {formatInteger(agent.toolCalls)} calls
|
|
354
547
|
</Text>
|
|
355
|
-
<View style={styles.grow} />
|
|
356
|
-
{data.warnings.length > 0 ? (
|
|
357
|
-
<Pressable
|
|
358
|
-
accessibilityLabel={
|
|
359
|
-
warningsExpanded ? "Hide analytics warnings" : "Show analytics warnings"
|
|
360
|
-
}
|
|
361
|
-
accessibilityRole="button"
|
|
362
|
-
accessibilityState={{ expanded: warningsExpanded }}
|
|
363
|
-
onPress={() => setWarningsExpanded((expanded) => !expanded)}
|
|
364
|
-
style={styles.button}
|
|
365
|
-
>
|
|
366
|
-
<Text style={styles.buttonText}>
|
|
367
|
-
{warningsExpanded ? "Hide" : `${data.warnings.length} details`}
|
|
368
|
-
</Text>
|
|
369
|
-
</Pressable>
|
|
370
|
-
) : null}
|
|
371
548
|
</View>
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
key={`${warning.code}:${warning.provider ?? ""}:${warning.database ?? ""}:${warning.message}`}
|
|
378
|
-
style={styles.subtitle}
|
|
379
|
-
>
|
|
380
|
-
{text}
|
|
381
|
-
</Text>
|
|
382
|
-
);
|
|
383
|
-
})
|
|
384
|
-
: null}
|
|
549
|
+
<SavedValue
|
|
550
|
+
value={`${formatCompact(agent.savedTokens)} saved`}
|
|
551
|
+
percent={agent.savingsPercent}
|
|
552
|
+
styles={styles}
|
|
553
|
+
/>
|
|
385
554
|
</View>
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
555
|
+
</Divided>
|
|
556
|
+
))}
|
|
557
|
+
</Card>
|
|
558
|
+
</SettingsSection>
|
|
559
|
+
);
|
|
560
|
+
}
|
|
389
561
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
562
|
+
interface SavedValueProps {
|
|
563
|
+
value: string;
|
|
564
|
+
percent: number;
|
|
565
|
+
styles: DashboardStyles;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function SavedValue({ value, percent, styles }: SavedValueProps) {
|
|
569
|
+
return (
|
|
570
|
+
<View>
|
|
571
|
+
<Text style={styles.rowValue}>{value}</Text>
|
|
572
|
+
<Text style={styles.meta}>{percentFormatter.format(percent)}% reduction</Text>
|
|
573
|
+
</View>
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function ProjectBreakdown({ data, styles, theme }: DashboardPartProps) {
|
|
578
|
+
const truncated = data.totals.projects > data.byProject.length;
|
|
579
|
+
return (
|
|
580
|
+
<SettingsSection title="Projects">
|
|
581
|
+
<Card theme={theme}>
|
|
582
|
+
{data.byProject.length === 0 ? (
|
|
583
|
+
<Text style={styles.subtitle}>No project activity</Text>
|
|
584
|
+
) : null}
|
|
585
|
+
{data.byProject.map((project, index) => {
|
|
586
|
+
const worktrees =
|
|
587
|
+
project.worktrees > 1 ? ` · ${formatInteger(project.worktrees)} worktrees` : "";
|
|
588
|
+
return (
|
|
589
|
+
<Divided key={project.path} index={index} styles={styles}>
|
|
590
|
+
<View
|
|
591
|
+
accessible
|
|
592
|
+
accessibilityLabel={`${project.path}: ${formatInteger(project.sessions)} sessions, ${formatInteger(project.toolCalls)} tool calls, ${formatInteger(project.indexedSources)} indexed sources, ${formatInteger(project.savedTokens)} tokens saved`}
|
|
593
|
+
style={styles.listRow}
|
|
594
|
+
>
|
|
595
|
+
<View style={styles.grow}>
|
|
596
|
+
<Text style={styles.rowTitle}>{project.path}</Text>
|
|
597
|
+
<Text style={styles.label}>
|
|
598
|
+
{formatInteger(project.sessions)} sessions · {formatInteger(project.toolCalls)}{" "}
|
|
599
|
+
calls · {formatInteger(project.indexedSources)} sources{worktrees}
|
|
600
|
+
</Text>
|
|
601
|
+
</View>
|
|
602
|
+
<SavedValue
|
|
603
|
+
value={`${formatBytes(project.byteAccounting.savedBytes)} saved`}
|
|
604
|
+
percent={project.savingsPercent}
|
|
425
605
|
styles={styles}
|
|
426
606
|
/>
|
|
427
607
|
</View>
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
{detailsExpanded ? (
|
|
442
|
-
<>
|
|
443
|
-
<View style={styles.metricGrid}>
|
|
444
|
-
<Metric
|
|
445
|
-
label="Databases"
|
|
446
|
-
value={formatInteger(data.totals.databases)}
|
|
447
|
-
styles={styles}
|
|
448
|
-
/>
|
|
449
|
-
<Metric
|
|
450
|
-
label="Projects"
|
|
451
|
-
value={formatInteger(data.totals.projects)}
|
|
452
|
-
styles={styles}
|
|
453
|
-
/>
|
|
454
|
-
<Metric
|
|
455
|
-
label="Events"
|
|
456
|
-
value={formatInteger(data.totals.events)}
|
|
457
|
-
styles={styles}
|
|
458
|
-
/>
|
|
459
|
-
<Metric
|
|
460
|
-
label="Input tokens"
|
|
461
|
-
value={formatCompact(data.totals.inputTokens)}
|
|
462
|
-
styles={styles}
|
|
463
|
-
/>
|
|
464
|
-
<Metric
|
|
465
|
-
label="Output tokens"
|
|
466
|
-
value={formatCompact(data.totals.outputTokens)}
|
|
467
|
-
styles={styles}
|
|
468
|
-
/>
|
|
469
|
-
<Metric
|
|
470
|
-
label="Indexed chunks"
|
|
471
|
-
value={formatInteger(data.totals.indexedChunks)}
|
|
472
|
-
styles={styles}
|
|
473
|
-
/>
|
|
474
|
-
</View>
|
|
475
|
-
<View style={styles.card}>
|
|
476
|
-
<Text style={styles.rowTitle}>Byte accounting</Text>
|
|
477
|
-
<Text style={styles.subtitle}>
|
|
478
|
-
{formatBytes(data.totals.byteAccounting.savedBytes)} saved ·{" "}
|
|
479
|
-
{formatBytes(data.totals.byteAccounting.returnedBytes)} returned ·{" "}
|
|
480
|
-
{formatBytes(data.totals.byteAccounting.avoidedBytes)} avoided
|
|
481
|
-
</Text>
|
|
482
|
-
<Text style={styles.label}>
|
|
483
|
-
{formatBytes(data.totals.byteAccounting.eventDataBytes)} event data ·{" "}
|
|
484
|
-
{formatBytes(data.totals.byteAccounting.snapshotBytes)} snapshots ·{" "}
|
|
485
|
-
{formatBytes(data.totals.byteAccounting.indexedBytes)} indexed
|
|
486
|
-
</Text>
|
|
487
|
-
</View>
|
|
488
|
-
</>
|
|
489
|
-
) : null}
|
|
490
|
-
</View>
|
|
608
|
+
</Divided>
|
|
609
|
+
);
|
|
610
|
+
})}
|
|
611
|
+
{truncated ? (
|
|
612
|
+
<Text style={styles.label}>
|
|
613
|
+
Top {formatInteger(data.byProject.length)} of {formatInteger(data.totals.projects)}{" "}
|
|
614
|
+
projects by savings
|
|
615
|
+
</Text>
|
|
616
|
+
) : null}
|
|
617
|
+
</Card>
|
|
618
|
+
</SettingsSection>
|
|
619
|
+
);
|
|
620
|
+
}
|
|
491
621
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
{data.byDay.length === 0 ? (
|
|
496
|
-
<Text style={styles.subtitle}>No dated activity is available.</Text>
|
|
497
|
-
) : (
|
|
498
|
-
visibleDays.map((day) => {
|
|
499
|
-
const formattedDate = formatDate(day.date);
|
|
500
|
-
const width =
|
|
501
|
-
`${Math.max(2, Math.round((day.savedTokens / maxDailySavings) * 100))}%` as `${number}%`;
|
|
502
|
-
return (
|
|
503
|
-
<View
|
|
504
|
-
accessible
|
|
505
|
-
accessibilityLabel={`${formattedDate}: ${formatInteger(day.calls)} calls, ${formatInteger(day.savedTokens)} tokens saved`}
|
|
506
|
-
key={day.date}
|
|
507
|
-
style={styles.timelineRow}
|
|
508
|
-
>
|
|
509
|
-
<Text style={styles.timelineDate}>{formattedDate}</Text>
|
|
510
|
-
<View style={styles.timelineTrack}>
|
|
511
|
-
<View style={[styles.timelineFill, { width }]} />
|
|
512
|
-
</View>
|
|
513
|
-
<Text style={styles.timelineValue}>{formatCompact(day.savedTokens)}</Text>
|
|
514
|
-
</View>
|
|
515
|
-
);
|
|
516
|
-
})
|
|
517
|
-
)}
|
|
518
|
-
{data.byDay.length > visibleDays.length ? (
|
|
519
|
-
<Pressable
|
|
520
|
-
accessibilityLabel={`Show more savings history, ${data.byDay.length - visibleDays.length} days remaining`}
|
|
521
|
-
accessibilityRole="button"
|
|
522
|
-
onPress={() =>
|
|
523
|
-
setDayLimit((current) => Math.min(data.byDay.length, current + 30))
|
|
524
|
-
}
|
|
525
|
-
style={styles.button}
|
|
526
|
-
>
|
|
527
|
-
<Text style={styles.buttonText}>Show more history</Text>
|
|
528
|
-
</Pressable>
|
|
529
|
-
) : dayLimit > 14 && data.byDay.length > 14 ? (
|
|
530
|
-
<Pressable
|
|
531
|
-
accessibilityLabel="Show recent savings history only"
|
|
532
|
-
accessibilityRole="button"
|
|
533
|
-
onPress={() => setDayLimit(14)}
|
|
534
|
-
style={styles.button}
|
|
535
|
-
>
|
|
536
|
-
<Text style={styles.buttonText}>Show recent 14 days</Text>
|
|
537
|
-
</Pressable>
|
|
538
|
-
) : null}
|
|
539
|
-
</View>
|
|
540
|
-
</View>
|
|
541
|
-
<View style={styles.section}>
|
|
542
|
-
<Text style={styles.sectionTitle}>Providers</Text>
|
|
543
|
-
<View style={styles.card}>
|
|
544
|
-
{data.byProvider.length === 0 ? (
|
|
545
|
-
<Text style={styles.subtitle}>No provider-attributed activity is available.</Text>
|
|
546
|
-
) : (
|
|
547
|
-
data.byProvider.map((provider, index) => (
|
|
548
|
-
<View key={provider.provider}>
|
|
549
|
-
{index > 0 ? <View style={styles.divider} /> : null}
|
|
550
|
-
<View
|
|
551
|
-
accessible
|
|
552
|
-
accessibilityLabel={`${provider.provider}: ${formatInteger(provider.projects)} projects, ${formatInteger(provider.sessions)} sessions, ${formatInteger(provider.toolCalls)} tool calls, ${formatInteger(provider.savedTokens)} tokens saved, ${percentFormatter.format(provider.savingsPercent)} percent reduction`}
|
|
553
|
-
style={styles.listRow}
|
|
554
|
-
>
|
|
555
|
-
<View style={styles.grow}>
|
|
556
|
-
<Text style={styles.rowTitle}>{provider.provider}</Text>
|
|
557
|
-
<Text style={styles.label}>
|
|
558
|
-
{formatInteger(provider.projects)} projects ·{" "}
|
|
559
|
-
{formatInteger(provider.sessions)} sessions ·{" "}
|
|
560
|
-
{formatInteger(provider.toolCalls)} calls
|
|
561
|
-
</Text>
|
|
562
|
-
</View>
|
|
563
|
-
<View>
|
|
564
|
-
<Text style={styles.rowValue}>
|
|
565
|
-
{formatCompact(provider.savedTokens)} saved
|
|
566
|
-
</Text>
|
|
567
|
-
<Text style={styles.sourceMeta}>
|
|
568
|
-
{percentFormatter.format(provider.savingsPercent)}% reduction
|
|
569
|
-
</Text>
|
|
570
|
-
</View>
|
|
571
|
-
</View>
|
|
572
|
-
</View>
|
|
573
|
-
))
|
|
574
|
-
)}
|
|
575
|
-
</View>
|
|
576
|
-
</View>
|
|
622
|
+
interface HostPartProps extends DashboardPartProps {
|
|
623
|
+
host: PluginSurfaceProps["host"];
|
|
624
|
+
}
|
|
577
625
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
{formatCompact(category.savedTokens)} saved
|
|
596
|
-
</Text>
|
|
597
|
-
</View>
|
|
598
|
-
</View>
|
|
599
|
-
))
|
|
600
|
-
)}
|
|
601
|
-
</View>
|
|
602
|
-
</View>
|
|
626
|
+
function RecentSessions({ data, host, styles, theme }: HostPartProps) {
|
|
627
|
+
return (
|
|
628
|
+
<SettingsSection title="Recent sessions">
|
|
629
|
+
<Card theme={theme}>
|
|
630
|
+
{data.recentSessions.map((session, index) => (
|
|
631
|
+
<Divided
|
|
632
|
+
key={`${session.agent}:${session.projectPath}:${session.sessionId}`}
|
|
633
|
+
index={index}
|
|
634
|
+
styles={styles}
|
|
635
|
+
>
|
|
636
|
+
<RecentSessionRow host={host} session={session} styles={styles} theme={theme} />
|
|
637
|
+
</Divided>
|
|
638
|
+
))}
|
|
639
|
+
</Card>
|
|
640
|
+
</SettingsSection>
|
|
641
|
+
);
|
|
642
|
+
}
|
|
603
643
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
accessible
|
|
622
|
-
accessibilityLabel={`${source.source} from ${source.provider}: ${formatInteger(source.chunks)} chunks, ${formatInteger(source.codeChunks)} code chunks, ${source.state}`}
|
|
623
|
-
style={styles.listRow}
|
|
624
|
-
>
|
|
625
|
-
<View style={styles.grow}>
|
|
626
|
-
<Text style={styles.rowTitle}>{source.source}</Text>
|
|
627
|
-
<Text style={styles.label}>
|
|
628
|
-
{source.provider} · {formatBytes(source.indexedBytes)}
|
|
629
|
-
{source.indexedAt ? ` · ${formatDate(source.indexedAt, true)}` : ""}
|
|
630
|
-
</Text>
|
|
631
|
-
{source.detail ? (
|
|
632
|
-
<Text style={styles.label}>{source.detail}</Text>
|
|
633
|
-
) : null}
|
|
634
|
-
</View>
|
|
635
|
-
<View>
|
|
636
|
-
<Text style={[styles.badgeText, stateStyle]}>{source.state}</Text>
|
|
637
|
-
<Text style={styles.sourceMeta}>
|
|
638
|
-
{formatInteger(source.chunks)} chunks ·{" "}
|
|
639
|
-
{formatInteger(source.codeChunks)} code
|
|
640
|
-
</Text>
|
|
641
|
-
</View>
|
|
642
|
-
</View>
|
|
643
|
-
</View>
|
|
644
|
-
);
|
|
645
|
-
})
|
|
646
|
-
)}
|
|
647
|
-
{data.sources.length > visibleSources.length ? (
|
|
648
|
-
<Pressable
|
|
649
|
-
accessibilityLabel={`Show more indexed sources, ${data.sources.length - visibleSources.length} remaining`}
|
|
650
|
-
accessibilityRole="button"
|
|
651
|
-
onPress={() =>
|
|
652
|
-
setSourceLimit((current) => Math.min(data.sources.length, current + 100))
|
|
653
|
-
}
|
|
654
|
-
style={styles.button}
|
|
655
|
-
>
|
|
656
|
-
<Text style={styles.buttonText}>
|
|
657
|
-
Show next {Math.min(100, data.sources.length - visibleSources.length)} sources
|
|
658
|
-
</Text>
|
|
659
|
-
</Pressable>
|
|
660
|
-
) : sourceLimit > 20 && data.sources.length > 20 ? (
|
|
661
|
-
<Pressable
|
|
662
|
-
accessibilityLabel="Collapse indexed sources"
|
|
663
|
-
accessibilityRole="button"
|
|
664
|
-
onPress={() => setSourceLimit(20)}
|
|
665
|
-
style={styles.button}
|
|
666
|
-
>
|
|
667
|
-
<Text style={styles.buttonText}>Show first 20 sources</Text>
|
|
668
|
-
</Pressable>
|
|
669
|
-
) : null}
|
|
670
|
-
</View>
|
|
644
|
+
function CategoryBreakdown({ data, styles, theme }: DashboardPartProps) {
|
|
645
|
+
return (
|
|
646
|
+
<SettingsSection title="Where savings come from">
|
|
647
|
+
<Card theme={theme}>
|
|
648
|
+
{data.byCategory.length === 0 ? (
|
|
649
|
+
<Text style={styles.subtitle}>No category breakdown</Text>
|
|
650
|
+
) : null}
|
|
651
|
+
{data.byCategory.map((category, index) => (
|
|
652
|
+
<Divided key={category.category} index={index} styles={styles}>
|
|
653
|
+
<View
|
|
654
|
+
accessible
|
|
655
|
+
accessibilityLabel={`${category.category}: ${formatInteger(category.count)} events, ${formatInteger(category.savedTokens)} tokens saved`}
|
|
656
|
+
style={styles.listRow}
|
|
657
|
+
>
|
|
658
|
+
<Text style={[styles.rowTitle, styles.grow]}>{category.category}</Text>
|
|
659
|
+
<Text style={styles.label}>{formatInteger(category.count)} calls</Text>
|
|
660
|
+
<Text style={styles.rowValue}>{formatCompact(category.savedTokens)} saved</Text>
|
|
671
661
|
</View>
|
|
672
|
-
|
|
673
|
-
)
|
|
674
|
-
|
|
662
|
+
</Divided>
|
|
663
|
+
))}
|
|
664
|
+
</Card>
|
|
665
|
+
</SettingsSection>
|
|
666
|
+
);
|
|
667
|
+
}
|
|
675
668
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
669
|
+
function KnowledgeSources({ data, host, styles, theme }: HostPartProps) {
|
|
670
|
+
const [sourceLimit, setSourceLimit] = useState(DEFAULT_SOURCE_LIMIT);
|
|
671
|
+
const visibleSources = data.sources.slice(0, sourceLimit);
|
|
672
|
+
return (
|
|
673
|
+
<SettingsSection title="Knowledge sources">
|
|
674
|
+
<Card theme={theme}>
|
|
675
|
+
{data.sources.length === 0 ? <Text style={styles.subtitle}>No indexed sources</Text> : null}
|
|
676
|
+
{visibleSources.map((source, index) => (
|
|
677
|
+
<Divided
|
|
678
|
+
key={`${source.agent}:${source.projectPath}:${source.source}:${source.indexedAt ?? ""}`}
|
|
679
|
+
index={index}
|
|
680
|
+
styles={styles}
|
|
684
681
|
>
|
|
685
|
-
<
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
{
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
)
|
|
682
|
+
<SourceRow host={host} source={source} styles={styles} theme={theme} />
|
|
683
|
+
</Divided>
|
|
684
|
+
))}
|
|
685
|
+
<ShowMoreToggle
|
|
686
|
+
theme={theme}
|
|
687
|
+
total={data.sources.length}
|
|
688
|
+
limit={visibleSources.length}
|
|
689
|
+
initialLimit={DEFAULT_SOURCE_LIMIT}
|
|
690
|
+
step={100}
|
|
691
|
+
onLimitChange={setSourceLimit}
|
|
692
|
+
moreLabel={(next) => `Show next ${next} sources`}
|
|
693
|
+
moreAccessibilityLabel={(remaining) =>
|
|
694
|
+
`Show more indexed sources, ${remaining} remaining`
|
|
695
|
+
}
|
|
696
|
+
lessLabel={`Show first ${DEFAULT_SOURCE_LIMIT} sources`}
|
|
697
|
+
lessAccessibilityLabel="Collapse indexed sources"
|
|
698
|
+
/>
|
|
699
|
+
</Card>
|
|
700
|
+
</SettingsSection>
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
interface SourceRowProps extends Pick<PluginSurfaceProps, "theme" | "host"> {
|
|
705
|
+
source: IndexedSourceAnalytics;
|
|
706
|
+
styles: DashboardStyles;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function SourceRow({ host, source, styles, theme }: SourceRowProps) {
|
|
710
|
+
const indexedAt = source.indexedAt ? ` · ${formatDate(source.indexedAt, true)}` : "";
|
|
711
|
+
return (
|
|
712
|
+
<View style={styles.stack}>
|
|
713
|
+
<View
|
|
714
|
+
accessible
|
|
715
|
+
accessibilityLabel={`${source.source} from ${source.agent} in ${source.projectPath}: ${formatInteger(source.chunks)} chunks, ${source.kind}`}
|
|
716
|
+
style={styles.listRow}
|
|
717
|
+
>
|
|
718
|
+
<View style={styles.grow}>
|
|
719
|
+
<Text numberOfLines={2} style={styles.rowTitle}>
|
|
720
|
+
{source.source}
|
|
721
|
+
</Text>
|
|
722
|
+
<Text style={styles.label}>
|
|
723
|
+
{source.agent} · {formatBytes(source.indexedBytes)}
|
|
724
|
+
{indexedAt}
|
|
725
|
+
</Text>
|
|
726
|
+
<Text style={styles.label}>{source.projectPath}</Text>
|
|
727
|
+
</View>
|
|
728
|
+
<View>
|
|
729
|
+
<Text style={[styles.label, styles.caption]}>{source.kind}</Text>
|
|
730
|
+
<Text style={styles.meta}>{formatInteger(source.chunks)} chunks</Text>
|
|
696
731
|
</View>
|
|
732
|
+
</View>
|
|
733
|
+
{source.provider ? (
|
|
734
|
+
<ForgetSourceButton
|
|
735
|
+
theme={theme}
|
|
736
|
+
host={host}
|
|
737
|
+
provider={source.provider}
|
|
738
|
+
projectPath={source.projectPath}
|
|
739
|
+
source={source.source}
|
|
740
|
+
/>
|
|
697
741
|
) : null}
|
|
698
742
|
</View>
|
|
699
743
|
);
|
|
700
744
|
}
|
|
701
745
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
746
|
+
interface RecentSessionRowProps extends Pick<PluginSurfaceProps, "theme" | "host"> {
|
|
747
|
+
session: SessionAnalytics;
|
|
748
|
+
styles: DashboardStyles;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function RecentSessionRow({ theme, host, session, styles }: RecentSessionRowProps) {
|
|
752
|
+
const lastActivity = session.lastActivityAt
|
|
753
|
+
? formatDate(session.lastActivityAt, true)
|
|
754
|
+
: "No recorded activity";
|
|
755
|
+
return (
|
|
756
|
+
<View style={styles.stack}>
|
|
757
|
+
<View
|
|
758
|
+
accessible
|
|
759
|
+
accessibilityLabel={`Session ${session.sessionId} from ${session.agent} in ${session.projectPath}: ${formatInteger(session.events)} events, last activity ${lastActivity}`}
|
|
760
|
+
style={styles.listRow}
|
|
761
|
+
>
|
|
762
|
+
<View style={styles.grow}>
|
|
763
|
+
<Text numberOfLines={2} style={styles.rowTitle}>
|
|
764
|
+
{session.projectPath}
|
|
765
|
+
</Text>
|
|
766
|
+
<Text style={styles.label}>
|
|
767
|
+
{session.agent} · {lastActivity}
|
|
768
|
+
</Text>
|
|
769
|
+
<Text selectable style={styles.label}>
|
|
770
|
+
Session {session.sessionId}
|
|
771
|
+
</Text>
|
|
772
|
+
</View>
|
|
773
|
+
<View>
|
|
774
|
+
<Text style={styles.rowValue}>{formatInteger(session.events)}</Text>
|
|
775
|
+
<Text style={styles.meta}>events</Text>
|
|
776
|
+
</View>
|
|
777
|
+
</View>
|
|
778
|
+
{session.provider ? (
|
|
779
|
+
<>
|
|
780
|
+
<ResumeState
|
|
781
|
+
target={{
|
|
782
|
+
provider: session.provider,
|
|
783
|
+
projectPath: session.projectPath,
|
|
784
|
+
sessionId: session.sessionId,
|
|
785
|
+
}}
|
|
786
|
+
styles={styles}
|
|
787
|
+
theme={theme}
|
|
788
|
+
/>
|
|
789
|
+
<ForgetSessionButton
|
|
790
|
+
host={host}
|
|
791
|
+
projectPath={session.projectPath}
|
|
792
|
+
provider={session.provider}
|
|
793
|
+
sessionId={session.sessionId}
|
|
794
|
+
theme={theme}
|
|
795
|
+
/>
|
|
796
|
+
</>
|
|
797
|
+
) : null}
|
|
798
|
+
</View>
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
interface ResumeStateProps {
|
|
803
|
+
target: ResumeSessionInput;
|
|
804
|
+
styles: DashboardStyles;
|
|
805
|
+
theme: PluginTheme;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** The `ctx_resume` block an agent would get for this session, loaded on demand. */
|
|
809
|
+
function ResumeState({ target, styles, theme }: ResumeStateProps) {
|
|
810
|
+
const resumeSession = useRpc(resumeContextModeSession);
|
|
811
|
+
const [expanded, setExpanded] = useState(false);
|
|
812
|
+
const resume = useMutation({ mutationFn: (input: ResumeSessionInput) => resumeSession(input) });
|
|
813
|
+
|
|
814
|
+
function toggle() {
|
|
815
|
+
if (!expanded) resume.mutate(target);
|
|
816
|
+
setExpanded(!expanded);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
return (
|
|
820
|
+
<>
|
|
821
|
+
<ActionButton
|
|
822
|
+
theme={theme}
|
|
823
|
+
variant="outline"
|
|
824
|
+
expanded={expanded}
|
|
825
|
+
accessibilityLabel={`${expanded ? "Hide" : "Show"} resume state for session ${target.sessionId}`}
|
|
826
|
+
label={expanded ? "Hide" : "Resume state"}
|
|
827
|
+
onPress={toggle}
|
|
828
|
+
/>
|
|
829
|
+
{expanded && resume.isPending ? <Pending theme={theme} label="Loading..." /> : null}
|
|
830
|
+
{expanded ? <ErrorText theme={theme} error={resume.error} /> : null}
|
|
831
|
+
{expanded && resume.data ? (
|
|
832
|
+
<Text selectable style={styles.raw}>
|
|
833
|
+
{presentKnowledgeOutput(resume.data.output) || "No resume state for this session"}
|
|
834
|
+
</Text>
|
|
835
|
+
) : null}
|
|
836
|
+
</>
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
interface MetricProps {
|
|
707
841
|
label: string;
|
|
708
842
|
value: string;
|
|
709
|
-
styles:
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
};
|
|
714
|
-
}) {
|
|
843
|
+
styles: DashboardStyles;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function Metric({ label, value, styles }: MetricProps) {
|
|
715
847
|
return (
|
|
716
848
|
<View accessible accessibilityLabel={`${label}: ${value}`} style={styles.metric}>
|
|
717
849
|
<Text style={styles.metricValue}>{value}</Text>
|