@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.
@@ -1,8 +1,24 @@
1
+ import type { PluginTheme } from "@getpaseo/plugin";
1
2
  import { type PluginSurfaceProps, useRpc } from "@getpaseo/plugin/client";
2
- import { useQuery } from "@tanstack/react-query";
3
- import { type ReactNode, useMemo, useState } from "react";
4
- import { ActivityIndicator, Pressable, Text, View } from "react-native";
5
- import { getContextModeAnalyticsDashboard } from "../shared/analytics";
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
- function warningText(warning: unknown): string {
44
- if (typeof warning === "string") return warning;
45
- if (!warning || typeof warning !== "object") return String(warning);
46
- const record = warning as Record<string, unknown>;
47
- const message = typeof record.message === "string" ? record.message : null;
48
- const code = typeof record.code === "string" ? record.code : null;
49
- if (code && message) return `${code}: ${message}`;
50
- if (message) return message;
51
- return JSON.stringify(warning) ?? String(warning);
52
- }
53
-
54
- function optionalFallback(value: unknown): string | null {
55
- if (!value || typeof value !== "object") return null;
56
- const record = value as Record<string, unknown>;
57
- const fallback =
58
- record.raw ?? record.fallback ?? record.rawReport ?? record.fallbackReport ?? null;
59
- if (typeof fallback === "string") return fallback.trim() || null;
60
- if (fallback === null || fallback === undefined) return null;
61
- try {
62
- return JSON.stringify(fallback, null, 2) ?? String(fallback);
63
- } catch {
64
- return String(fallback);
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 = useMemo(
93
- () => ({
94
- root: { gap: layout.compact ? 12 : 16 },
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
- {showHero ? health : null}
251
- <View
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.error || !dashboard.data) {
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
- {showHero ? health : null}
272
- <View style={styles.card}>
273
- <Text accessibilityRole="alert" style={styles.danger}>
274
- {message}
275
- </Text>
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
- onPress={() => dashboard.refetch()}
281
- style={styles.button}
282
- >
283
- <Text style={styles.buttonText}>
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
- <View
322
- accessible
323
- accessibilityLabel={`${formatInteger(data.totals.savedTokens)} lifetime tokens saved, ${percentFormatter.format(data.totals.savingsPercent)} percent reduction`}
324
- style={styles.hero}
325
- >
326
- <View style={styles.row}>
327
- <Text style={styles.eyebrow}>Lifetime impact</Text>
328
- <View style={styles.grow} />
329
- {data.completeness === "complete" ? null : (
330
- <View style={styles.badge}>
331
- <Text style={[styles.badgeText, completenessStyle]}>{completenessLabel}</Text>
332
- </View>
333
- )}
334
- </View>
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.sectionTitle}>tokens saved</Text>
236
+ <Text style={styles.emphasis}>tokens saved</Text>
337
237
  <Text style={styles.subtitle}>
338
- {percentFormatter.format(data.totals.savingsPercent)}% less context returned across{" "}
339
- {formatInteger(data.totals.sessions)} sessions
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
- {health}
344
-
345
- {data.completeness !== "complete" || data.warnings.length > 0 ? (
346
- <View accessibilityRole="alert" style={styles.warningCard}>
347
- <View style={styles.row}>
348
- <Text style={[styles.rowTitle, completenessStyle]}>
349
- {data.completeness === "unsupported"
350
- ? "Analytics unavailable"
351
- : data.completeness === "partial"
352
- ? "Some analytics are partial"
353
- : "Analytics notice"}
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
- {warningsExpanded
373
- ? data.warnings.map((warning) => {
374
- const text = warningText(warning);
375
- return (
376
- <Text
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
- ) : null}
387
- </>
388
- ) : null}
555
+ </Divided>
556
+ ))}
557
+ </Card>
558
+ </SettingsSection>
559
+ );
560
+ }
389
561
 
390
- {showDetails ? (
391
- isEmpty ? (
392
- <View style={styles.card}>
393
- <Text style={styles.sectionTitle}>
394
- {data.completeness === "unsupported" ? "Analytics unavailable" : "No activity yet"}
395
- </Text>
396
- <Text style={styles.subtitle}>
397
- {data.completeness === "unsupported"
398
- ? "Update Context Mode to a supported analytics schema to populate this dashboard."
399
- : "Lifetime metrics will appear after Context Mode records tool activity or indexes a source."}
400
- </Text>
401
- </View>
402
- ) : (
403
- <>
404
- <View style={styles.section}>
405
- <Text style={styles.sectionTitle}>At a glance</Text>
406
- <View style={styles.metricGrid}>
407
- <Metric
408
- label="Providers"
409
- value={formatInteger(data.totals.providers)}
410
- styles={styles}
411
- />
412
- <Metric
413
- label="Sessions"
414
- value={formatInteger(data.totals.sessions)}
415
- styles={styles}
416
- />
417
- <Metric
418
- label="Tool calls"
419
- value={formatInteger(data.totals.toolCalls)}
420
- styles={styles}
421
- />
422
- <Metric
423
- label="Indexed sources"
424
- value={formatInteger(data.totals.indexedSources)}
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
- <Pressable
429
- accessibilityLabel={
430
- detailsExpanded ? "Hide analytics details" : "Show analytics details"
431
- }
432
- accessibilityRole="button"
433
- accessibilityState={{ expanded: detailsExpanded }}
434
- onPress={() => setDetailsExpanded((expanded) => !expanded)}
435
- style={styles.button}
436
- >
437
- <Text style={styles.buttonText}>
438
- {detailsExpanded ? "Hide detail" : "More detail"}
439
- </Text>
440
- </Pressable>
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
- <View style={styles.section}>
493
- <Text style={styles.sectionTitle}>Savings over time</Text>
494
- <View style={styles.card}>
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
- <View style={styles.section}>
579
- <Text style={styles.sectionTitle}>Where savings come from</Text>
580
- <View style={styles.card}>
581
- {data.byCategory.length === 0 ? (
582
- <Text style={styles.subtitle}>No category breakdown is available.</Text>
583
- ) : (
584
- data.byCategory.map((category, index) => (
585
- <View key={category.category}>
586
- {index > 0 ? <View style={styles.divider} /> : null}
587
- <View
588
- accessible
589
- accessibilityLabel={`${category.category}: ${formatInteger(category.count)} events, ${formatInteger(category.savedTokens)} tokens saved`}
590
- style={styles.listRow}
591
- >
592
- <Text style={[styles.rowTitle, styles.grow]}>{category.category}</Text>
593
- <Text style={styles.label}>{formatInteger(category.count)} events</Text>
594
- <Text style={styles.rowValue}>
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
- <View style={styles.section}>
605
- <Text style={styles.sectionTitle}>Knowledge sources</Text>
606
- <View style={styles.card}>
607
- {data.sources.length === 0 ? (
608
- <Text style={styles.subtitle}>No indexed sources were found.</Text>
609
- ) : (
610
- visibleSources.map((source, index) => {
611
- const stateStyle =
612
- source.state === "ready"
613
- ? styles.success
614
- : source.state === "empty"
615
- ? styles.warning
616
- : styles.danger;
617
- return (
618
- <View key={`${source.provider}:${source.source}`}>
619
- {index > 0 ? <View style={styles.divider} /> : null}
620
- <View
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
- ) : null}
662
+ </Divided>
663
+ ))}
664
+ </Card>
665
+ </SettingsSection>
666
+ );
667
+ }
675
668
 
676
- {showDetails && rawFallback ? (
677
- <View style={styles.section}>
678
- <Pressable
679
- accessibilityLabel={`${rawExpanded ? "Hide" : "Show"} raw analytics fallback`}
680
- accessibilityRole="button"
681
- accessibilityState={{ expanded: rawExpanded }}
682
- onPress={() => setRawExpanded((expanded) => !expanded)}
683
- style={styles.button}
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
- <Text style={styles.buttonText}>
686
- {rawExpanded ? "Hide raw fallback" : "Show raw fallback"}
687
- </Text>
688
- </Pressable>
689
- {rawExpanded ? (
690
- <View style={styles.card}>
691
- <Text selectable style={styles.raw}>
692
- {rawFallback}
693
- </Text>
694
- </View>
695
- ) : null}
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
- function Metric({
703
- label,
704
- value,
705
- styles,
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
- metric: object;
711
- metricValue: object;
712
- label: object;
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>