paseo-smart-session 2.1.0 → 3.0.0
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/README.md +40 -11
- package/client/agents.ts +26 -4
- package/client/charts.tsx +277 -152
- package/client/chat.tsx +122 -0
- package/client/display.tsx +55 -33
- package/client/guard/index.tsx +14 -16
- package/client/guard/paused.tsx +125 -38
- package/client/guard/pill-icons.tsx +1 -1
- package/client/guard/pills.ts +16 -6
- package/client/guard/surfaces.ts +3 -2
- package/client/guard/timeline.tsx +116 -56
- package/client/kit.tsx +144 -0
- package/client/live.ts +49 -0
- package/client/motion.tsx +94 -0
- package/client/overview.tsx +373 -0
- package/client/pill-store.ts +77 -0
- package/client/pill.tsx +83 -260
- package/client/popover.tsx +113 -0
- package/client/settings.tsx +105 -0
- package/client/theme.ts +56 -0
- package/docs/media/smart-session-chat.png +0 -0
- package/docs/media/smart-session-flow.gif +0 -0
- package/docs/media/smart-session-guard.png +0 -0
- package/docs/media/smart-session-hero-light.png +0 -0
- package/docs/media/smart-session-hero.png +0 -0
- package/docs/media/smart-session-mobile.png +0 -0
- package/docs/media/smart-session-pill.png +0 -0
- package/docs/navigation-verification.md +6 -6
- package/docs/screenshots/display-settings-desktop.png +0 -0
- package/docs/screenshots/display-settings-mobile.png +0 -0
- package/docs/session-guard.md +3 -3
- package/index.client.tsx +47 -35
- package/index.server.ts +14 -5
- package/mcp.mjs +1 -1
- package/package.json +1 -1
- package/paseo-plugin.json +10 -0
- package/server/settings.ts +10 -0
- package/shared/blocks.ts +15 -0
- package/shared/chat.ts +174 -0
- package/shared/forecast.ts +233 -0
- package/shared/format.ts +47 -1
- package/shared/governor.ts +3 -0
- package/shared/smart-session.ts +16 -0
- package/client/navigation.tsx +0 -14
- package/client/surface.tsx +0 -659
- package/docs/screenshots/smart-compact-handoff.png +0 -0
package/client/charts.tsx
CHANGED
|
@@ -1,185 +1,310 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
|
+
import { Platform, Text, View } from "react-native";
|
|
3
|
+
import { Icon } from "@getpaseo/plugin/client/ui";
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
type Theme
|
|
5
|
+
import { formatClock, formatCompactTokens, formatDay, rampStep } from "../shared/format";
|
|
6
|
+
import { contextRing, formatMoment, type Tone, type WindowForecast } from "../shared/forecast";
|
|
7
|
+
import { isLight, tabular, withAlpha, type Colors, type Theme } from "./theme";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
10
|
+
* Every chart and meter on the overview, drawn with Views (plugins get no SVG
|
|
11
|
+
* library; the host `Icon` draws square inline SVG only, used for the ring).
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* and
|
|
15
|
-
* same construction as a lightness ramp for a single hue.
|
|
13
|
+
* One rule for colour, from the dataviz method: magnitude is neutral ink, one hue at
|
|
14
|
+
* stepped opacity, so a chart reads the same in light and dark. Amber and red are
|
|
15
|
+
* status and appear only where a forecast says a limit will bite or did.
|
|
16
16
|
*/
|
|
17
|
-
const STEPS = [0.12, 0.3, 0.5, 0.72, 1] as const;
|
|
18
17
|
|
|
19
|
-
const
|
|
18
|
+
const HOUR = 3_600_000;
|
|
19
|
+
|
|
20
|
+
/** Ink: the foreground at a given strength, a touch stronger on light grounds. */
|
|
21
|
+
export function ink(colors: Colors, strength: number): string {
|
|
22
|
+
return withAlpha(colors.foreground, Math.min(1, strength * (isLight(colors) ? 1.1 : 1)));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The fill for a value in a given state. */
|
|
26
|
+
export function toneFill(colors: Colors, tone: Tone): string {
|
|
27
|
+
return tone === "danger" ? colors.statusDanger : tone === "warning" ? colors.statusWarning : ink(colors, 0.82);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const hoverable = Platform.OS === "web";
|
|
31
|
+
|
|
32
|
+
/** Pointer handlers for a hover readout; inert on touch platforms. */
|
|
33
|
+
function hover(set: (value: number | null) => void, index: number) {
|
|
34
|
+
return hoverable ? { onPointerEnter: () => set(index), onPointerLeave: () => set(null) } : {};
|
|
35
|
+
}
|
|
20
36
|
|
|
21
37
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* the cell is the unit and the ramp carries the magnitude. Hour labels are shown
|
|
26
|
-
* every six hours: a label on all 24 would be unreadable at this width.
|
|
38
|
+
* A meter: the used share, an optional faint projection of where it lands at the
|
|
39
|
+
* reset, an optional muted pace tick (where an even spend would be) and an optional
|
|
40
|
+
* mark (the compact line on a context bar).
|
|
27
41
|
*/
|
|
28
|
-
export function
|
|
29
|
-
theme
|
|
30
|
-
cells,
|
|
31
|
-
compact,
|
|
32
|
-
}: {
|
|
33
|
-
theme: Theme;
|
|
34
|
-
cells: readonly { weekday: number; hour: number; tokens: number }[];
|
|
35
|
-
compact: boolean;
|
|
42
|
+
export function Meter({ theme, pct, tone = "default", projected, pace, mark, height = 8, label }: {
|
|
43
|
+
theme: Theme; pct: number; tone?: Tone; projected?: number | null; pace?: number | null; mark?: number | null; height?: number; label: string;
|
|
36
44
|
}) {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
const c = theme.colors;
|
|
46
|
+
const used = Math.max(0, Math.min(100, pct));
|
|
47
|
+
const fill = toneFill(c, tone);
|
|
48
|
+
const ahead = projected === null || projected === undefined ? 0 : Math.max(0, Math.min(100, projected) - used);
|
|
49
|
+
const tick = (at: number, color: string, key: string) => (
|
|
50
|
+
<View key={key} style={{ position: "absolute", left: `${Math.max(0, Math.min(100, at))}%`, top: -3, bottom: -3, width: 2, marginLeft: -1, borderRadius: 1, backgroundColor: color }} />
|
|
51
|
+
);
|
|
52
|
+
return <View accessibilityRole="progressbar" accessibilityLabel={label} accessibilityValue={{ min: 0, max: 100, now: Math.round(used) }}
|
|
53
|
+
style={{ height, justifyContent: "center", marginVertical: 3 }}>
|
|
54
|
+
<View style={{ height, borderRadius: height / 2, backgroundColor: ink(c, 0.1), overflow: "hidden", flexDirection: "row" }}>
|
|
55
|
+
<View style={{ width: `${used}%`, backgroundColor: fill, borderTopRightRadius: height / 2, borderBottomRightRadius: height / 2 }} />
|
|
56
|
+
{ahead > 0 ? <View style={{ width: `${ahead}%`, backgroundColor: withAlpha(tone === "default" ? c.foreground : fill, tone === "default" ? 0.16 : 0.24), marginLeft: -height / 2, paddingLeft: height / 2 }} /> : null}
|
|
57
|
+
</View>
|
|
58
|
+
{pace !== null && pace !== undefined ? tick(pace, c.foregroundMuted, "pace") : null}
|
|
59
|
+
{mark !== null && mark !== undefined ? tick(mark, c.foreground, "mark") : null}
|
|
60
|
+
</View>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The context ring glyph, the same object as the composer pill's icon. */
|
|
64
|
+
export function Ring({ size, color, fraction, mark }: { size: number; color: string; fraction: number; mark: number | null }) {
|
|
65
|
+
// A stronger track at pill size, so the ring reads as a gauge and not as the host's spinner.
|
|
66
|
+
return <Icon icon={{ svg: contextRing(fraction, mark, size <= 18 ? 0.5 : 0.28) }} size={size} color={color} />;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The percentage held at `t`: readings only climb within a window, so the last one before it stands. */
|
|
70
|
+
function heldAt(history: readonly { t: string; pct: number }[], t: number): number | null {
|
|
71
|
+
let value: number | null = null;
|
|
72
|
+
for (const point of history) {
|
|
73
|
+
if (Date.parse(point.t) > t) break;
|
|
74
|
+
value = point.pct;
|
|
42
75
|
}
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
43
78
|
|
|
44
|
-
|
|
45
|
-
|
|
79
|
+
/** A small axis caption. */
|
|
80
|
+
function Axis({ theme, children, align = "left" }: { theme: Theme; children: ReactNode; align?: "left" | "right" | "center" }) {
|
|
81
|
+
return <Text numberOfLines={1} style={{ color: theme.colors.foregroundMuted, fontSize: 10, lineHeight: 14, textAlign: align, ...tabular }}>{children}</Text>;
|
|
82
|
+
}
|
|
46
83
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
84
|
+
/**
|
|
85
|
+
* The open session window, start to reset: what was recorded (ink), where the
|
|
86
|
+
* current pace takes it (faint), and the stretch with nothing left (amber) when it
|
|
87
|
+
* runs out first. A hairline at 100%, Session Guard's threshold when it sits lower,
|
|
88
|
+
* and a "now" rule.
|
|
89
|
+
*/
|
|
90
|
+
export function WindowChart({ theme, forecast, history, now, compact, guardPct, hour12 }: {
|
|
91
|
+
theme: Theme; forecast: WindowForecast; history: readonly { t: string; pct: number }[]; now: number; compact: boolean; guardPct?: number | null; hour12?: boolean;
|
|
92
|
+
}) {
|
|
93
|
+
const c = theme.colors;
|
|
94
|
+
const [hovered, setHovered] = useState<number | null>(null);
|
|
95
|
+
if (forecast.startAt === null || forecast.resetsAt === null) return null;
|
|
96
|
+
const start = Date.parse(forecast.startAt);
|
|
97
|
+
const end = Date.parse(forecast.resetsAt);
|
|
98
|
+
const columns = compact ? 40 : 60;
|
|
99
|
+
const slot = (end - start) / columns;
|
|
100
|
+
const height = compact ? 60 : 76;
|
|
101
|
+
const runsOut = forecast.runsOutAt === null ? null : Date.parse(forecast.runsOutAt);
|
|
102
|
+
const bars = Array.from({ length: columns }, (unused, i) => {
|
|
103
|
+
const from = start + i * slot;
|
|
104
|
+
const t = from + slot;
|
|
105
|
+
if (from <= now) {
|
|
106
|
+
const value = heldAt(history, Math.min(t, now)) ?? 0;
|
|
107
|
+
const current = t >= now;
|
|
108
|
+
return { t: Math.min(t, now), value, kind: current ? ("now" as const) : ("past" as const) };
|
|
109
|
+
}
|
|
110
|
+
const rate = forecast.ratePerHour ?? 0;
|
|
111
|
+
const value = Math.min(100, forecast.pct + (rate * (from + slot / 2 - now)) / HOUR);
|
|
112
|
+
const out = runsOut !== null && from + slot / 2 >= runsOut;
|
|
113
|
+
return { t: from + slot / 2, value: out ? 100 : value, kind: out ? ("out" as const) : ("ahead" as const) };
|
|
114
|
+
});
|
|
115
|
+
const elapsed = Math.max(0, Math.min(1, (now - start) / (end - start)));
|
|
116
|
+
const colorOf = (kind: (typeof bars)[number]["kind"]) =>
|
|
117
|
+
kind === "now" ? toneFill(c, forecast.tone === "danger" ? "danger" : "default")
|
|
118
|
+
: kind === "past" ? ink(c, 0.42)
|
|
119
|
+
: kind === "out" ? withAlpha(c.statusWarning, 0.42)
|
|
120
|
+
: ink(c, 0.13);
|
|
121
|
+
const readout = hovered === null ? null : bars[hovered]!;
|
|
122
|
+
const caption = readout === null
|
|
123
|
+
? null
|
|
124
|
+
: `${formatClock(new Date(readout.t).toISOString(), hour12)} · ${readout.kind === "past" || readout.kind === "now" ? `${Math.round(readout.value)}%` : readout.kind === "out" ? "nothing left" : `~${Math.round(readout.value)}% at this pace`}`;
|
|
125
|
+
const showGuard = guardPct !== null && guardPct !== undefined && guardPct < 100;
|
|
126
|
+
const summary = `Session window from ${formatClock(forecast.startAt, hour12)} to ${formatClock(forecast.resetsAt, hour12)}: ${Math.round(forecast.pct)}% used now${forecast.runsOutAt ? `, runs out around ${formatClock(forecast.runsOutAt, hour12)} at this pace` : ""}.`;
|
|
127
|
+
return <View accessible accessibilityLabel={summary} style={{ gap: 4 }}>
|
|
128
|
+
<View style={{ flexDirection: "row", alignItems: "center", minHeight: 14 }}>
|
|
129
|
+
<View style={{ flex: 1 }}>{caption ? <Text style={{ color: c.foreground, fontSize: 11, lineHeight: 14, ...tabular }}>{caption}</Text> : <Axis theme={theme}>This window</Axis>}</View>
|
|
130
|
+
<Axis theme={theme} align="right">100%</Axis>
|
|
131
|
+
</View>
|
|
132
|
+
<View style={{ height, position: "relative" }}>
|
|
133
|
+
<View style={{ position: "absolute", left: 0, right: 0, top: 0, height: 1, backgroundColor: c.border }} />
|
|
134
|
+
<View style={{ position: "absolute", left: 0, right: 0, top: height / 2, height: 1, backgroundColor: withAlpha(c.border, 0.6) }} />
|
|
135
|
+
{showGuard ? <View style={{ position: "absolute", left: 0, right: 0, top: height * (1 - guardPct! / 100), height: 0, borderTopWidth: 1, borderStyle: "dashed", borderColor: c.foregroundMuted }} /> : null}
|
|
136
|
+
<View style={{ position: "absolute", left: 0, right: 0, bottom: 0, top: 0, flexDirection: "row", alignItems: "flex-end", gap: compact ? 1 : 2 }}>
|
|
137
|
+
{bars.map((bar, i) => (
|
|
138
|
+
<View key={i} {...hover(setHovered, i)} style={{ flex: 1, height: "100%", justifyContent: "flex-end" }}>
|
|
139
|
+
<View style={{ height: Math.max(bar.value > 0 ? 2 : 1, (bar.value / 100) * height), backgroundColor: bar.value > 0 ? colorOf(bar.kind) : withAlpha(c.border, 0.8), borderTopLeftRadius: 2, borderTopRightRadius: 2, opacity: hovered === null || hovered === i ? 1 : 0.7 }} />
|
|
80
140
|
</View>
|
|
81
141
|
))}
|
|
82
142
|
</View>
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
</Text>
|
|
143
|
+
<View style={{ position: "absolute", top: -2, bottom: 0, left: `${elapsed * 100}%`, width: 1, backgroundColor: c.foreground }} />
|
|
144
|
+
<View style={{ position: "absolute", left: 0, right: 0, bottom: 0, height: 1, backgroundColor: c.border }} />
|
|
86
145
|
</View>
|
|
87
|
-
|
|
146
|
+
<View style={{ height: 14, position: "relative" }}>
|
|
147
|
+
<View style={{ position: "absolute", left: 0 }}><Axis theme={theme}>{formatClock(forecast.startAt, hour12)}</Axis></View>
|
|
148
|
+
{elapsed > 0.14 && elapsed < 0.86 ? <View style={{ position: "absolute", left: `${elapsed * 100}%`, width: 60, marginLeft: -30 }}><Axis theme={theme} align="center">now</Axis></View> : null}
|
|
149
|
+
<View style={{ position: "absolute", right: 0 }}><Axis theme={theme} align="right">{`resets ${formatClock(forecast.resetsAt, hour12)}`}</Axis></View>
|
|
150
|
+
</View>
|
|
151
|
+
</View>;
|
|
88
152
|
}
|
|
89
153
|
|
|
90
154
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* rather than a number over every bar.
|
|
155
|
+
* Finished session windows over the last 14 days, placed on a calendar: each is
|
|
156
|
+
* a column at its start time, as tall as it got. Red where it hit the limit,
|
|
157
|
+
* amber past 90%.
|
|
95
158
|
*/
|
|
96
|
-
export function
|
|
97
|
-
theme
|
|
98
|
-
days,
|
|
99
|
-
compact,
|
|
100
|
-
}: {
|
|
101
|
-
theme: Theme;
|
|
102
|
-
days: readonly { day: string; tokens: number }[];
|
|
103
|
-
compact: boolean;
|
|
159
|
+
export function PastWindows({ theme, past, now, compact, hour12 }: {
|
|
160
|
+
theme: Theme; past: readonly { startAt: string | null; firstSeenAt: string; peakPct: number }[]; now: number; compact: boolean; hour12?: boolean;
|
|
104
161
|
}) {
|
|
162
|
+
const c = theme.colors;
|
|
163
|
+
const [hovered, setHovered] = useState<number | null>(null);
|
|
164
|
+
const span = 14 * 24 * HOUR;
|
|
165
|
+
const from = now - span;
|
|
166
|
+
const items = past
|
|
167
|
+
.map((w) => ({ at: Date.parse(w.startAt ?? w.firstSeenAt), peak: w.peakPct }))
|
|
168
|
+
.filter((w) => Number.isFinite(w.at) && w.at >= from);
|
|
169
|
+
if (items.length === 0) return null;
|
|
170
|
+
const height = compact ? 36 : 44;
|
|
171
|
+
const width = compact ? 4 : 6;
|
|
172
|
+
const hits = items.filter((w) => w.peak >= 99.5).length;
|
|
173
|
+
// Red only for a window that reached the limit, matching the caption; the rest is ink.
|
|
174
|
+
const color = (peak: number) => (peak >= 99.5 ? c.statusDanger : ink(c, 0.42));
|
|
175
|
+
const readout = hovered === null ? null : items[hovered]!;
|
|
176
|
+
const caption = readout === null
|
|
177
|
+
? `${items.length} windows · ${hits === 0 ? "none reached the limit" : `${hits} reached the limit`}`
|
|
178
|
+
: `${formatMoment(new Date(readout.at).toISOString(), now, hour12)}${new Date(readout.at).toDateString() === new Date(now).toDateString() ? "" : `, ${formatDay(new Date(readout.at).toISOString())}`} · peaked at ${Math.round(readout.peak)}%`;
|
|
179
|
+
const days = Array.from({ length: 15 }, (unused, i) => {
|
|
180
|
+
const day = new Date(from + i * 24 * HOUR); day.setHours(0, 0, 0, 0);
|
|
181
|
+
return day.getTime();
|
|
182
|
+
}).filter((t) => t > from);
|
|
183
|
+
return <View accessible accessibilityLabel={`Session windows in the last 14 days: ${items.length}, ${hits} reached the limit.`} style={{ gap: 4 }}>
|
|
184
|
+
<View style={{ flexDirection: "row", alignItems: "center", minHeight: 14, gap: 8 }}>
|
|
185
|
+
<Text style={{ flex: 1, color: readout ? c.foreground : c.foregroundMuted, fontSize: 11, lineHeight: 14, ...tabular }}>{caption}</Text>
|
|
186
|
+
<Axis theme={theme} align="right">100%</Axis>
|
|
187
|
+
</View>
|
|
188
|
+
<View style={{ height, position: "relative" }}>
|
|
189
|
+
<View style={{ position: "absolute", left: 0, right: 0, top: 0, height: 1, backgroundColor: c.border }} />
|
|
190
|
+
{days.map((t) => <View key={t} style={{ position: "absolute", top: 0, bottom: 0, left: `${((t - from) / span) * 100}%`, width: 1, backgroundColor: withAlpha(c.border, 0.5) }} />)}
|
|
191
|
+
{items.map((w, i) => (
|
|
192
|
+
<View key={i} {...hover(setHovered, i)} style={{ position: "absolute", bottom: 0, left: `${((w.at - from) / span) * 100}%`, width: width + 6, marginLeft: -3, height, justifyContent: "flex-end", alignItems: "center" }}>
|
|
193
|
+
<View style={{ width, height: Math.max(2, (w.peak / 100) * height), backgroundColor: color(w.peak), borderTopLeftRadius: 2, borderTopRightRadius: 2, opacity: hovered === null || hovered === i ? 1 : 0.6 }} />
|
|
194
|
+
</View>
|
|
195
|
+
))}
|
|
196
|
+
<View style={{ position: "absolute", left: 0, right: 0, bottom: 0, height: 1, backgroundColor: c.border }} />
|
|
197
|
+
</View>
|
|
198
|
+
<View style={{ flexDirection: "row", justifyContent: "space-between" }}>
|
|
199
|
+
<Axis theme={theme}>{formatDay(new Date(from).toISOString())}</Axis>
|
|
200
|
+
<Axis theme={theme} align="right">today</Axis>
|
|
201
|
+
</View>
|
|
202
|
+
</View>;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Tokens per day: one ink series, the peak day labelled, everything else on hover. */
|
|
206
|
+
export function DailyBars({ theme, days, compact }: { theme: Theme; days: readonly { day: string; tokens: number }[]; compact: boolean }) {
|
|
207
|
+
const c = theme.colors;
|
|
208
|
+
const [hovered, setHovered] = useState<number | null>(null);
|
|
105
209
|
if (days.length === 0) return null;
|
|
106
210
|
const max = days.reduce((peak, point) => Math.max(peak, point.tokens), 0);
|
|
107
|
-
const height = compact ?
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}}
|
|
126
|
-
/>
|
|
211
|
+
const height = compact ? 56 : 72;
|
|
212
|
+
const peakIndex = days.findIndex((point) => point.tokens === max);
|
|
213
|
+
const readout = hovered === null ? null : days[hovered]!;
|
|
214
|
+
const dayOf = (day: string) => formatDay(`${day}T12:00:00`);
|
|
215
|
+
return <View accessible accessibilityLabel={`Tokens per day over ${days.length} days; the busiest was ${dayOf(days[peakIndex]!.day)} with ${formatCompactTokens(max)}.`} style={{ gap: 4 }}>
|
|
216
|
+
<View style={{ flexDirection: "row", alignItems: "center", minHeight: 14 }}>
|
|
217
|
+
<Text style={{ flex: 1, color: readout ? c.foreground : c.foregroundMuted, fontSize: 11, lineHeight: 14, ...tabular }}>
|
|
218
|
+
{readout ? `${dayOf(readout.day)} · ${formatCompactTokens(readout.tokens)} tokens` : `Busiest: ${dayOf(days[peakIndex]!.day)} · ${formatCompactTokens(max)}`}
|
|
219
|
+
</Text>
|
|
220
|
+
<Axis theme={theme} align="right">{formatCompactTokens(max)}</Axis>
|
|
221
|
+
</View>
|
|
222
|
+
<View style={{ height, position: "relative" }}>
|
|
223
|
+
<View style={{ position: "absolute", left: 0, right: 0, top: 0, height: 1, backgroundColor: withAlpha(c.border, 0.6) }} />
|
|
224
|
+
<View style={{ position: "absolute", left: 0, right: 0, top: 0, bottom: 0, flexDirection: "row", alignItems: "flex-end", gap: 2 }}>
|
|
225
|
+
{days.map((point, i) => (
|
|
226
|
+
<View key={point.day} {...hover(setHovered, i)} style={{ flex: 1, height: "100%", justifyContent: "flex-end", alignItems: "center" }}>
|
|
227
|
+
<View style={{ width: "100%", maxWidth: 24, height: max === 0 ? 1 : Math.max(point.tokens > 0 ? 2 : 1, (point.tokens / max) * height), backgroundColor: point.tokens === 0 ? withAlpha(c.border, 0.8) : ink(c, i === peakIndex || i === hovered ? 0.82 : 0.42), borderTopLeftRadius: 3, borderTopRightRadius: 3 }} />
|
|
228
|
+
</View>
|
|
127
229
|
))}
|
|
128
230
|
</View>
|
|
129
|
-
<View style={{
|
|
130
|
-
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 10 }}>{days[0]!.day.slice(5)}</Text>
|
|
131
|
-
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 10 }}>
|
|
132
|
-
peak {peakDay.day.slice(5)} · {formatTokens(peakDay.tokens)}
|
|
133
|
-
</Text>
|
|
134
|
-
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 10 }}>
|
|
135
|
-
{days[days.length - 1]!.day.slice(5)}
|
|
136
|
-
</Text>
|
|
137
|
-
</View>
|
|
231
|
+
<View style={{ position: "absolute", left: 0, right: 0, bottom: 0, height: 1, backgroundColor: c.border }} />
|
|
138
232
|
</View>
|
|
139
|
-
|
|
233
|
+
<View style={{ flexDirection: "row", justifyContent: "space-between" }}>
|
|
234
|
+
<Axis theme={theme}>{dayOf(days[0]!.day)}</Axis>
|
|
235
|
+
<Axis theme={theme} align="right">today</Axis>
|
|
236
|
+
</View>
|
|
237
|
+
</View>;
|
|
140
238
|
}
|
|
141
239
|
|
|
142
|
-
/** A
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
>
|
|
179
|
-
{formatTokens(row.tokens)}
|
|
180
|
-
</Text>
|
|
240
|
+
/** A sequential ramp in ink: one hue, stepped opacity. Empty hours are the surface. */
|
|
241
|
+
const STEPS = [0.12, 0.26, 0.44, 0.64, 0.88] as const;
|
|
242
|
+
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
243
|
+
|
|
244
|
+
function hourLabel(hour: number, hour12?: boolean): string {
|
|
245
|
+
const d = new Date(2020, 0, 1, hour, 0, 0);
|
|
246
|
+
return hour12 === false ? `${String(hour).padStart(2, "0")}` : formatClock(d.toISOString(), hour12).replace(":00", "");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Tokens by weekday and hour of day, in local time, with a less–more key. */
|
|
250
|
+
export function HourHeatmap({ theme, cells, compact, hour12 }: { theme: Theme; cells: readonly { weekday: number; hour: number; tokens: number }[]; compact: boolean; hour12?: boolean }) {
|
|
251
|
+
const c = theme.colors;
|
|
252
|
+
const [hovered, setHovered] = useState<number | null>(null);
|
|
253
|
+
const grid = new Map<string, number>();
|
|
254
|
+
let max = 0;
|
|
255
|
+
for (const cell of cells) {
|
|
256
|
+
grid.set(`${cell.weekday}|${cell.hour}`, cell.tokens);
|
|
257
|
+
if (cell.tokens > max) max = cell.tokens;
|
|
258
|
+
}
|
|
259
|
+
const gap = 2;
|
|
260
|
+
const cellFill = (tokens: number) => {
|
|
261
|
+
const step = rampStep(tokens, max, STEPS.length);
|
|
262
|
+
return step === -1 ? withAlpha(c.border, 0.55) : ink(c, STEPS[step]!);
|
|
263
|
+
};
|
|
264
|
+
const readout = hovered === null ? null : { weekday: Math.floor(hovered / 24), hour: hovered % 24 };
|
|
265
|
+
return <View style={{ gap: 6 }}>
|
|
266
|
+
<Text style={{ color: readout ? c.foreground : c.foregroundMuted, fontSize: 11, lineHeight: 14, ...tabular }}>
|
|
267
|
+
{readout ? `${WEEKDAYS[readout.weekday]} ${hourLabel(readout.hour, hour12)} · ${formatCompactTokens(grid.get(`${readout.weekday}|${readout.hour}`) ?? 0)} tokens` : "Local time, last 30 days"}
|
|
268
|
+
</Text>
|
|
269
|
+
<View style={{ gap }}>
|
|
270
|
+
{WEEKDAYS.map((label, weekday) => (
|
|
271
|
+
<View key={label} style={{ flexDirection: "row", alignItems: "center", gap }}>
|
|
272
|
+
<Text style={{ color: c.foregroundMuted, fontSize: 10, width: 28 }}>{label}</Text>
|
|
273
|
+
{Array.from({ length: 24 }, (unused, hour) => (
|
|
274
|
+
<View key={hour} {...hover(setHovered, weekday * 24 + hour)} style={{ flex: 1, maxWidth: compact ? 12 : 18, aspectRatio: 1, borderRadius: 2, backgroundColor: cellFill(grid.get(`${weekday}|${hour}`) ?? 0), borderWidth: hovered === weekday * 24 + hour ? 1 : 0, borderColor: c.foreground }} />
|
|
275
|
+
))}
|
|
181
276
|
</View>
|
|
182
277
|
))}
|
|
183
278
|
</View>
|
|
184
|
-
|
|
279
|
+
<View style={{ flexDirection: "row", marginLeft: 28 + gap, marginTop: 2, maxWidth: 24 * ((compact ? 12 : 18) + gap) }}>
|
|
280
|
+
{[0, 6, 12, 18].map((hour) => (
|
|
281
|
+
<View key={hour} style={{ flex: 1 }}><Text numberOfLines={1} style={{ color: c.foregroundMuted, fontSize: 10, lineHeight: 13, ...tabular }}>{hourLabel(hour, hour12)}</Text></View>
|
|
282
|
+
))}
|
|
283
|
+
</View>
|
|
284
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 4, marginTop: 10 }}>
|
|
285
|
+
<Axis theme={theme}>Less</Axis>
|
|
286
|
+
{STEPS.map((step) => <View key={step} style={{ width: 10, height: 10, borderRadius: 2, backgroundColor: ink(c, step) }} />)}
|
|
287
|
+
<Axis theme={theme}>{`More · busiest hour ${formatCompactTokens(max)}`}</Axis>
|
|
288
|
+
</View>
|
|
289
|
+
</View>;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** A ranked list with a thin share bar: a bar chart of eight rows would say the same with more ink. */
|
|
293
|
+
export function RankedTotals({ theme, rows, total, compact }: { theme: Theme; rows: readonly { name: string; tokens: number }[]; total: number; compact: boolean }) {
|
|
294
|
+
const c = theme.colors;
|
|
295
|
+
const top = rows.reduce((peak, row) => Math.max(peak, row.tokens), 0);
|
|
296
|
+
return <View style={{ gap: 9 }}>
|
|
297
|
+
{rows.slice(0, compact ? 5 : 6).map((row) => (
|
|
298
|
+
<View key={row.name} style={{ gap: 4 }}>
|
|
299
|
+
<View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
|
|
300
|
+
<Text numberOfLines={1} style={{ flex: 1, color: c.foreground, fontSize: 12, lineHeight: 16 }}>{row.name}</Text>
|
|
301
|
+
<Text style={{ color: c.foreground, fontSize: 12, lineHeight: 16, ...tabular }}>{formatCompactTokens(row.tokens)}</Text>
|
|
302
|
+
<Text style={{ width: 34, textAlign: "right", color: c.foregroundMuted, fontSize: 11, lineHeight: 16, ...tabular }}>{total === 0 ? "" : `${Math.round((row.tokens / total) * 100)}%`}</Text>
|
|
303
|
+
</View>
|
|
304
|
+
<View style={{ height: 4, borderRadius: 2, backgroundColor: ink(c, 0.08), overflow: "hidden" }}>
|
|
305
|
+
<View style={{ width: `${top === 0 ? 0 : (row.tokens / top) * 100}%`, height: 4, borderRadius: 2, backgroundColor: ink(c, 0.5) }} />
|
|
306
|
+
</View>
|
|
307
|
+
</View>
|
|
308
|
+
))}
|
|
309
|
+
</View>;
|
|
185
310
|
}
|
package/client/chat.tsx
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Platform, Pressable, Text, View } from "react-native";
|
|
3
|
+
import type { PluginClientContext, PluginTimelineItemProps } from "@getpaseo/plugin/client";
|
|
4
|
+
import { Icon, ScrollView } from "@getpaseo/plugin/client/ui";
|
|
5
|
+
|
|
6
|
+
import { PROMPT_CARD_KIND, PromptCardSchema, TOOL_ROW_KIND, ToolRowSchema, fileName, promptCard, toolRow, type PromptCard, type ToolRow } from "../shared/chat";
|
|
7
|
+
import { raised } from "./theme";
|
|
8
|
+
|
|
9
|
+
const ICONS: Record<ToolRow["tool"], string> = {
|
|
10
|
+
context_status: "FoldVertical",
|
|
11
|
+
budget_status: "Gauge",
|
|
12
|
+
checkpoint: "Save",
|
|
13
|
+
request_compaction: "FoldVertical",
|
|
14
|
+
defer_compaction: "Clock",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const mono = () => (Platform.OS === "ios" ? "Menlo" : Platform.OS === "android" ? "monospace" : "ui-monospace, SFMono-Regular, Menlo, monospace");
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Smart Session's agent tools as one quiet line, sized like Paseo's own tool rows,
|
|
21
|
+
* instead of a raw `mcp__smart-session__request_compaction` call. Press to read
|
|
22
|
+
* what the agent saved or asked for.
|
|
23
|
+
*/
|
|
24
|
+
function ToolRowView({ item, theme }: PluginTimelineItemProps<ToolRow>) {
|
|
25
|
+
const c = theme.colors;
|
|
26
|
+
const row = item.data;
|
|
27
|
+
const [expanded, setExpanded] = useState(false);
|
|
28
|
+
const [hovered, setHovered] = useState(false);
|
|
29
|
+
const openable = row.detail !== "";
|
|
30
|
+
const open = expanded && openable;
|
|
31
|
+
const active = openable && (hovered || expanded);
|
|
32
|
+
const alert = row.state === "failed" ? c.statusDanger : row.state === "refused" ? c.statusWarning : null;
|
|
33
|
+
return <View style={{ marginHorizontal: -13 }}>
|
|
34
|
+
<Pressable disabled={!openable} onPress={() => setExpanded((value) => !value)} onHoverIn={() => setHovered(true)} onHoverOut={() => setHovered(false)}
|
|
35
|
+
accessibilityRole={openable ? "button" : undefined} aria-expanded={openable ? expanded : undefined} aria-busy={row.state === "running"}
|
|
36
|
+
style={{ flexDirection: "row", alignItems: "center", borderRadius: 8, borderWidth: 1, borderColor: open ? c.border : "transparent", borderBottomLeftRadius: open ? 0 : 8, borderBottomRightRadius: open ? 0 : 8, backgroundColor: open ? c.surface1 : "transparent", paddingHorizontal: 8, paddingVertical: 4 }}>
|
|
37
|
+
<View style={{ width: 22, height: 22, marginRight: 4, alignItems: "center", justifyContent: "center" }}>
|
|
38
|
+
{active
|
|
39
|
+
? <View style={{ transform: [{ rotate: expanded ? "90deg" : "0deg" }] }}><Icon name="ChevronRight" size={14} color={c.foreground} /></View>
|
|
40
|
+
: <Icon name={alert ? "TriangleAlert" : ICONS[row.tool]} size={12} color={alert ?? c.foregroundMuted} />}
|
|
41
|
+
</View>
|
|
42
|
+
<Text numberOfLines={1} style={{ flexShrink: 1, fontSize: 14, fontVariant: ["tabular-nums"], color: alert ?? (active || row.state === "running" ? c.foreground : c.foregroundMuted), opacity: row.state === "running" ? 0.72 : 1 }}>{row.line}</Text>
|
|
43
|
+
</Pressable>
|
|
44
|
+
{open ? <View style={{ borderWidth: 1, borderTopWidth: 0, borderColor: c.border, borderBottomLeftRadius: 8, borderBottomRightRadius: 8, overflow: "hidden" }}>
|
|
45
|
+
<ScrollView style={[{ maxHeight: 240 }, Platform.OS === "web" ? ({ overscrollBehavior: "contain" } as object) : null]} contentContainerStyle={{ paddingHorizontal: 12, paddingVertical: 8 }}>
|
|
46
|
+
<Text selectable style={{ color: alert ?? c.foreground, fontSize: 13, lineHeight: 19, fontVariant: ["tabular-nums"] }}>{row.detail}</Text>
|
|
47
|
+
</ScrollView>
|
|
48
|
+
</View> : null}
|
|
49
|
+
</View>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const CARD: Record<PromptCard["kind"], { icon: string; title: string }> = {
|
|
53
|
+
compact: { icon: "FoldVertical", title: "Compaction the agent asked for" },
|
|
54
|
+
continue: { icon: "CornerDownRight", title: "Continue after compaction" },
|
|
55
|
+
"guard-continue": { icon: "ShieldCheck", title: "Continue after the reset" },
|
|
56
|
+
"guard-resume": { icon: "Play", title: "Resume now" },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function cardBody(card: PromptCard): string {
|
|
60
|
+
switch (card.kind) {
|
|
61
|
+
case "compact": return card.reason ? card.reason : "No reason given.";
|
|
62
|
+
case "continue": return card.statePath ? `Re-read ${fileName(card.statePath)} and carry on from its current step.` : "Pick the task up from the summary and carry on.";
|
|
63
|
+
case "guard-continue": return "The session window reset. Pick up the unfinished task where it stopped.";
|
|
64
|
+
case "guard-resume": return "You resumed this session before the reset. The provider may still refuse work until then.";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A message Smart Session sent, as a card instead of a wall of instructions. */
|
|
69
|
+
function PromptCardView({ item, theme, layout }: PluginTimelineItemProps<PromptCard>) {
|
|
70
|
+
const c = theme.colors;
|
|
71
|
+
const card = item.data;
|
|
72
|
+
const [expanded, setExpanded] = useState(false);
|
|
73
|
+
const look = CARD[card.kind];
|
|
74
|
+
const sender = card.kind.startsWith("guard") ? "Session Guard" : "Smart Compact";
|
|
75
|
+
return <View style={{ alignSelf: layout.compact ? "stretch" : "flex-end", width: "100%", maxWidth: 560, marginVertical: 4 }}>
|
|
76
|
+
<View style={[{ borderRadius: 12, backgroundColor: c.surface1, overflow: "hidden" }, raised(c)]}>
|
|
77
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 14, paddingTop: 11, paddingBottom: 4 }}>
|
|
78
|
+
<Icon name={look.icon} size={14} color={c.foregroundMuted} />
|
|
79
|
+
<Text numberOfLines={1} style={{ flex: 1, color: c.foreground, fontSize: 13, lineHeight: 18, fontWeight: "600" }}>{look.title}</Text>
|
|
80
|
+
<Pressable accessibilityRole="button" aria-expanded={expanded} accessibilityLabel={expanded ? "Hide the full message" : "Show the full message"} onPress={() => setExpanded((v) => !v)} hitSlop={6}
|
|
81
|
+
style={(state) => ({ paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, backgroundColor: state.pressed ? c.surface2 : "transparent" })}>
|
|
82
|
+
<Text style={{ color: c.foregroundMuted, fontSize: 12 }}>{expanded ? "Hide" : "Full message"}</Text>
|
|
83
|
+
</Pressable>
|
|
84
|
+
</View>
|
|
85
|
+
<View style={{ paddingHorizontal: 14, paddingBottom: 11, gap: 4 }}>
|
|
86
|
+
<Text selectable style={{ color: c.foreground, fontSize: 13, lineHeight: 19 }}>{cardBody(card)}</Text>
|
|
87
|
+
<Text style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 17 }}>
|
|
88
|
+
{card.kind === "compact" ? `Sent by ${sender} once the agent was idle${card.statePath ? ` · keeps the goal, current step, decisions and dead ends · state in ${fileName(card.statePath)}` : ""}` : `Sent by ${sender}`}
|
|
89
|
+
</Text>
|
|
90
|
+
</View>
|
|
91
|
+
{expanded ? <View style={{ borderTopWidth: 1, borderTopColor: c.border }}>
|
|
92
|
+
<ScrollView style={[{ maxHeight: 300 }, Platform.OS === "web" ? ({ overscrollBehavior: "contain" } as object) : null]} contentContainerStyle={{ padding: 14 }}>
|
|
93
|
+
<Text selectable style={{ color: c.foregroundMuted, fontFamily: mono(), fontSize: 12, lineHeight: 18 }}>{card.text}</Text>
|
|
94
|
+
</ScrollView>
|
|
95
|
+
</View> : null}
|
|
96
|
+
</View>
|
|
97
|
+
</View>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function contributeChat(client: PluginClientContext) {
|
|
101
|
+
const removers = [
|
|
102
|
+
client.addTimelineTransformer({
|
|
103
|
+
id: "smart-session-tool-calls",
|
|
104
|
+
query: { itemType: "tool_call" },
|
|
105
|
+
transform({ item }) {
|
|
106
|
+
const row = toolRow(item.name, item.status, item.detail, item.error);
|
|
107
|
+
return row === null ? undefined : { items: [{ type: "plugin" as const, kind: TOOL_ROW_KIND, version: 1, data: row }] };
|
|
108
|
+
},
|
|
109
|
+
}),
|
|
110
|
+
client.addTimelineTransformer({
|
|
111
|
+
id: "smart-session-prompts",
|
|
112
|
+
query: { itemType: "user_message" },
|
|
113
|
+
transform({ item }) {
|
|
114
|
+
const card = promptCard(item.text);
|
|
115
|
+
return card === null ? undefined : { items: [{ type: "plugin" as const, kind: PROMPT_CARD_KIND, version: 1, data: card }] };
|
|
116
|
+
},
|
|
117
|
+
}),
|
|
118
|
+
client.addTimelineRenderer({ kind: TOOL_ROW_KIND, version: 1, schema: ToolRowSchema, Component: ToolRowView }),
|
|
119
|
+
client.addTimelineRenderer({ kind: PROMPT_CARD_KIND, version: 1, schema: PromptCardSchema, Component: PromptCardView }),
|
|
120
|
+
];
|
|
121
|
+
return () => { for (const remove of removers) if (typeof remove === "function") void remove(); };
|
|
122
|
+
}
|