paseo-smart-session 2.1.0 → 2.2.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
|
@@ -1,12 +1,29 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { Text, View } from "react-native";
|
|
3
3
|
import { useRpc, type PluginTimelineItemProps } from "@getpaseo/plugin/client";
|
|
4
|
-
import { Icon } from "@getpaseo/plugin/client/
|
|
4
|
+
import { Icon } from "@getpaseo/plugin/client/ui";
|
|
5
5
|
import { timelinePresentation, type TimelineData } from "../../shared/guard/timeline.ts";
|
|
6
6
|
import { dismissPauseRpc, resumeNowRpc, statusRpc } from "../../shared/guard/contracts.ts";
|
|
7
|
-
import type
|
|
7
|
+
import { timestamp, type Pause } from "../../shared/guard/policy.ts";
|
|
8
|
+
import { formatClock, formatDuration } from "../../shared/format.ts";
|
|
9
|
+
import { formatMoment } from "../../shared/forecast.ts";
|
|
10
|
+
import { Button, TextLink, text, useNow } from "../kit.tsx";
|
|
11
|
+
import { Spinner } from "../motion.tsx";
|
|
12
|
+
import { raised, tabular, withAlpha } from "../theme.ts";
|
|
8
13
|
|
|
9
|
-
|
|
14
|
+
const providerName = (id: string) => (id === "claude" ? "Claude" : id === "codex" ? "Codex" : id);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A Session Guard pause in the chat. While the pause waits, it is a card that leads
|
|
18
|
+
* with the clock the window resets at, a live countdown, and Resume now / Dismiss.
|
|
19
|
+
* Once the pause settles it resolves itself into one quiet line, so the chat keeps
|
|
20
|
+
* the history without a stale card. Settled events (resumed, released) are lines
|
|
21
|
+
* from the start.
|
|
22
|
+
*/
|
|
23
|
+
export function GuardTimelineItem({ theme, agentId, item, layout, openOverview }: PluginTimelineItemProps<TimelineData> & { openOverview(): void }) {
|
|
24
|
+
const c = theme.colors;
|
|
25
|
+
const t = text(c);
|
|
26
|
+
const compact = layout.compact;
|
|
10
27
|
const readStatus = useRpc(statusRpc);
|
|
11
28
|
const resumeNow = useRpc(resumeNowRpc);
|
|
12
29
|
const dismissPause = useRpc(dismissPauseRpc);
|
|
@@ -16,8 +33,10 @@ export function GuardTimelineItem({ theme, agentId, item, openOverview }: Plugin
|
|
|
16
33
|
const busy = action !== null;
|
|
17
34
|
const [error, setError] = useState<string | null>(null);
|
|
18
35
|
const [statusError, setStatusError] = useState<string | null>(null);
|
|
36
|
+
const live = ["paused", "recovery"].includes(item.data.event);
|
|
37
|
+
const now = useNow(live && pause?.phase === "waiting" ? 15_000 : 60_000);
|
|
19
38
|
useEffect(() => {
|
|
20
|
-
if (!
|
|
39
|
+
if (!live) return;
|
|
21
40
|
setPause(null); setLoaded(false); setError(null); setStatusError(null);
|
|
22
41
|
let stopped = false, pending = false;
|
|
23
42
|
const refresh = async () => {
|
|
@@ -25,8 +44,8 @@ export function GuardTimelineItem({ theme, agentId, item, openOverview }: Plugin
|
|
|
25
44
|
pending = true;
|
|
26
45
|
try {
|
|
27
46
|
const status = await readStatus({});
|
|
28
|
-
if (!stopped) { setPause(status.pauses.find(p => p.id === item.data.pauseId && p.agentId === agentId) ?? null); setLoaded(true); setStatusError(null); }
|
|
29
|
-
} catch { if (!stopped) setStatusError("
|
|
47
|
+
if (!stopped) { setPause(status.pauses.find((p) => p.id === item.data.pauseId && p.agentId === agentId) ?? null); setLoaded(true); setStatusError(null); }
|
|
48
|
+
} catch { if (!stopped) setStatusError("Couldn't check this pause. Retrying."); }
|
|
30
49
|
finally { pending = false; }
|
|
31
50
|
};
|
|
32
51
|
void refresh(); const timer = setInterval(() => void refresh(), 5000);
|
|
@@ -38,57 +57,98 @@ export function GuardTimelineItem({ theme, agentId, item, openOverview }: Plugin
|
|
|
38
57
|
try {
|
|
39
58
|
await (kind === "resume" ? resumeNow : dismissPause)({ agentId, pauseId: item.data.pauseId });
|
|
40
59
|
const status = await readStatus({});
|
|
41
|
-
setPause(status.pauses.find(p => p.id === item.data.pauseId && p.agentId === agentId) ?? null);
|
|
42
|
-
} catch {
|
|
43
|
-
|
|
44
|
-
|
|
60
|
+
setPause(status.pauses.find((p) => p.id === item.data.pauseId && p.agentId === agentId) ?? null);
|
|
61
|
+
} catch {
|
|
62
|
+
setError(kind === "resume" ? "Resume could not be confirmed. Check the session before trying again."
|
|
63
|
+
: "Dismissal could not be confirmed. Check the pause; retrying dismissal sends no message.");
|
|
64
|
+
} finally { setAction(null); }
|
|
45
65
|
};
|
|
46
|
-
|
|
47
|
-
<Icon name="ShieldOff" size={16} color={theme.colors.foregroundMuted} />
|
|
48
|
-
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 18 }}>Pause dismissed · no message sent · protection off</Text>
|
|
49
|
-
</View>;
|
|
66
|
+
|
|
50
67
|
const presentation = timelinePresentation(item.data);
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
<
|
|
68
|
+
const at = formatClock(item.data.at);
|
|
69
|
+
|
|
70
|
+
/** One quiet line: the settled form of every card. */
|
|
71
|
+
const line = (icon: string, color: string, words: string, extra?: string) => (
|
|
72
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingVertical: 4 }}>
|
|
73
|
+
<View style={{ width: 22, alignItems: "center" }}><Icon name={icon} size={13} color={color} /></View>
|
|
74
|
+
<Text style={{ flexShrink: 1, color: c.foregroundMuted, fontSize: 13, lineHeight: 18, ...tabular }}>
|
|
75
|
+
<Text style={{ color: c.foreground }}>{words}</Text>{extra ? ` · ${extra}` : ""}
|
|
76
|
+
</Text>
|
|
57
77
|
</View>
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
</
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
if (!live) {
|
|
81
|
+
if (item.data.event === "resumed") return line("Play", c.foregroundMuted, presentation.title, item.data.manualResume ? `${at} · by you · protection off for this session` : `${at} · ${item.data.native ? "workers continued in place" : "continuation sent"}`);
|
|
82
|
+
if (item.data.event === "released") return line("ShieldOff", c.foregroundMuted, presentation.title, item.data.dismissed ? `${at} · no message sent · protection off` : at);
|
|
83
|
+
// attention
|
|
84
|
+
return <View style={[{ borderRadius: 12, backgroundColor: c.surface1, padding: 14, gap: 6, borderWidth: 1, borderColor: withAlpha(c.statusDanger, 0.6) }]}>
|
|
85
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
86
|
+
<Icon name="TriangleAlert" size={15} color={c.statusDanger} />
|
|
87
|
+
<Text style={{ ...t.label, color: c.statusDanger, flex: 1 }}>{presentation.title}</Text>
|
|
88
|
+
<Text style={t.meta}>{at}</Text>
|
|
89
|
+
</View>
|
|
90
|
+
<Text style={t.body}>{presentation.detail}</Text>
|
|
91
|
+
</View>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A live pause: settled ones fold to a line.
|
|
95
|
+
if (loaded && (pause === null || pause.phase === "cancelled" || pause.phase === "resumed")) {
|
|
96
|
+
if (pause?.phase === "cancelled" && pause.dismissed) return line("ShieldOff", c.foregroundMuted, "Pause dismissed", "no message sent · protection off");
|
|
97
|
+
if (pause?.phase === "resumed") return line("Pause", c.foregroundMuted, `Paused at ${at}`, pause.manualResume ? `resumed by you ${formatClock(pause.settledAt)}` : `resumed ${formatClock(pause.settledAt)}`);
|
|
98
|
+
// The event that ended it has its own line; this one only says when.
|
|
99
|
+
return line("Pause", c.foregroundMuted, `Paused at ${at}`, pause?.settledAt ? `ended ${formatClock(pause.settledAt)}` : "no longer active");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const reset = timestamp(item.data.resetAt);
|
|
103
|
+
const auto = item.data.autoResume;
|
|
104
|
+
const waiting = pause?.phase === "waiting";
|
|
105
|
+
const stuck = pause?.phase === "attention";
|
|
106
|
+
// Lead with the moment, the way every family card does: the reset clock, then the countdown.
|
|
107
|
+
const big = item.data.demo ? "Test pause" : reset === null ? "Reset time unknown" : formatMoment(item.data.resetAt!, now);
|
|
108
|
+
const countdown = item.data.demo ? "waits for you" : reset === null ? "waiting for the provider to report it" : reset <= now ? "reset passed · waiting for a fresh reading" : `${auto ? "resumes" : "resets"} in ${formatDuration(reset - now)}`;
|
|
109
|
+
const facts = [item.data.demo ? "Test pause" : item.data.event === "recovery" ? "Saved through a Paseo restart" : "Paused at the session limit", at, providerName(item.data.provider),
|
|
110
|
+
item.data.usedPct === null ? null : `${Math.round(item.data.usedPct)}% used`,
|
|
111
|
+
item.data.native ? "native subagents frozen" : "turn interrupted",
|
|
112
|
+
auto ? null : "continue by hand"].filter(Boolean).join(" · ");
|
|
113
|
+
const accent = stuck ? c.statusDanger : c.statusWarning;
|
|
114
|
+
|
|
115
|
+
return <View style={[{ borderRadius: 12, backgroundColor: c.surface1, paddingHorizontal: 14, paddingVertical: 12, gap: 8 }, stuck ? { borderWidth: 1, borderColor: withAlpha(c.statusDanger, 0.6) } : raised(c)]}>
|
|
116
|
+
{stuck ? <>
|
|
117
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
118
|
+
<Icon name="TriangleAlert" size={14} color={accent} />
|
|
119
|
+
<Text style={{ ...t.label, flex: 1, color: c.statusDanger }}>Pause needs attention</Text>
|
|
120
|
+
<Text style={t.meta}>{at}</Text>
|
|
121
|
+
</View>
|
|
122
|
+
<Text style={t.body}>{pause?.note ?? (pause?.dismissed ? "Worker release needs attention. Automatic continuation is cancelled." : "Check the session before continuing by hand.")}</Text>
|
|
123
|
+
</> : <>
|
|
124
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap", rowGap: 0 }}>
|
|
125
|
+
<Icon name="Pause" size={compact ? 15 : 16} color={accent} />
|
|
126
|
+
<Text style={{ color: c.foreground, fontSize: compact ? 18 : 20, lineHeight: compact ? 24 : 26, fontWeight: "600", ...tabular }}>{big}</Text>
|
|
127
|
+
<Text style={{ ...t.meta, fontSize: 13, flexShrink: 1 }}>{countdown}</Text>
|
|
128
|
+
</View>
|
|
129
|
+
<Text style={t.meta}>{facts}</Text>
|
|
130
|
+
{item.data.event === "recovery" ? <Text style={t.meta}>The original workers ended; the conversation and workspace stay in place.</Text> : null}
|
|
131
|
+
</>}
|
|
132
|
+
{!loaded ? <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}><Spinner color={c.foregroundMuted} size={11} /><Text style={t.meta}>Checking the pause…</Text></View> : null}
|
|
133
|
+
{pause?.phase === "resuming" ? <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}><Spinner color={c.foregroundMuted} size={11} /><Text style={t.meta}>Resuming…</Text></View> : null}
|
|
134
|
+
{waiting ? <View style={{ gap: 6 }}>
|
|
135
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 6 }}>
|
|
136
|
+
<Button theme={theme} small compact={compact} icon="Play" title={action === "resume" ? "Resuming…" : "Resume now"} loading={action === "resume"} disabled={busy}
|
|
137
|
+
accessibilityHint={item.data.demo ? "Releases this test session and turns its protection off." : "Continues before the reset and turns protection off for this session. The provider may still refuse work until the reset."} onPress={() => void act("resume")} />
|
|
138
|
+
<Button theme={theme} small compact={compact} kind="ghost" title={action === "dismiss" ? "Dismissing…" : "Dismiss pause"} disabled={busy}
|
|
139
|
+
accessibilityLabel="Dismiss pause without sending a message" onPress={() => void act("dismiss")} />
|
|
140
|
+
<View style={{ flex: 1, minWidth: 8 }} />
|
|
141
|
+
<TextLink theme={theme} compact={compact} label="All paused" onPress={openOverview} />
|
|
142
|
+
</View>
|
|
143
|
+
<Text style={t.small}>{item.data.demo ? "Resume now releases this test session and turns its protection off." : "Resume now turns protection off for this session · Dismiss sends nothing"}</Text>
|
|
144
|
+
</View> : null}
|
|
145
|
+
{stuck && pause?.dismissed ? <View style={{ flexDirection: "row" }}>
|
|
146
|
+
<Button theme={theme} small compact={compact} title={action === "dismiss" ? "Dismissing…" : "Retry dismissal"} disabled={busy}
|
|
147
|
+
accessibilityLabel="Retry dismissing pause without sending a message" onPress={() => void act("dismiss")} />
|
|
148
|
+
</View> : null}
|
|
149
|
+
{error ? <Text accessibilityRole="alert" style={{ ...t.meta, color: c.statusDanger }}>{error}</Text> : null}
|
|
150
|
+
{statusError ? <Text accessibilityRole="alert" style={{ ...t.meta, color: c.statusDanger }}>{statusError}</Text> : null}
|
|
151
|
+
{stuck ? <TextLink theme={theme} compact={compact} label="All paused sessions" onPress={openOverview} /> : null}
|
|
93
152
|
</View>;
|
|
94
153
|
}
|
|
154
|
+
|
package/client/kit.tsx
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
2
|
+
import { Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
|
|
3
|
+
import { Icon } from "@getpaseo/plugin/client/ui";
|
|
4
|
+
import { PressScale, Spinner } from "./motion";
|
|
5
|
+
import { raised, tabular, tint, withAlpha, type Colors, type Theme } from "./theme";
|
|
6
|
+
|
|
7
|
+
export type { Theme } from "./theme";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The plugin family's kit (Follow-up and Defer use the same pieces): one button
|
|
11
|
+
* vocabulary, raised cards, tinted chips, an accessible switch and the type scale.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Re-render on an interval so relative times keep moving between polls. */
|
|
15
|
+
export function useNow(intervalMs = 30_000): number {
|
|
16
|
+
const [now, setNow] = useState(() => Date.now());
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
setNow(Date.now());
|
|
19
|
+
const id = setInterval(() => setNow(Date.now()), intervalMs);
|
|
20
|
+
return () => clearInterval(id);
|
|
21
|
+
}, [intervalMs]);
|
|
22
|
+
return now;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function errorText(error: unknown): string {
|
|
26
|
+
return error instanceof Error ? error.message : String(error);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The family type scale: tabular numerals wherever a time or count can appear. */
|
|
30
|
+
export const text = (colors: Colors) => ({
|
|
31
|
+
title: { color: colors.foreground, fontSize: 15, lineHeight: 21, fontWeight: "600" as const },
|
|
32
|
+
body: { color: colors.foreground, fontSize: 13, lineHeight: 19 },
|
|
33
|
+
meta: { color: colors.foregroundMuted, fontSize: 12, lineHeight: 17, ...tabular },
|
|
34
|
+
small: { color: colors.foregroundMuted, fontSize: 11, lineHeight: 15, ...tabular },
|
|
35
|
+
label: { color: colors.foreground, fontSize: 13, lineHeight: 18, fontWeight: "600" as const },
|
|
36
|
+
group: { color: colors.foregroundMuted, fontSize: 12, lineHeight: 16, fontWeight: "600" as const, ...tabular },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
type ButtonKind = "primary" | "secondary" | "ghost" | "danger";
|
|
40
|
+
/** 28–32px with a hit slop on desktop, 44px on touch layouts. */
|
|
41
|
+
export function Button({ title, onPress, theme, kind = "secondary", disabled = false, loading = false, icon, iconColor, compact = false, small = false, accessibilityLabel, accessibilityHint }: {
|
|
42
|
+
title: string; onPress: () => void; theme: Theme; kind?: ButtonKind; disabled?: boolean; loading?: boolean; icon?: string; iconColor?: string; compact?: boolean; small?: boolean; accessibilityLabel?: string; accessibilityHint?: string;
|
|
43
|
+
}) {
|
|
44
|
+
const c = theme.colors;
|
|
45
|
+
const fg = kind === "primary" ? c.accentForeground : kind === "danger" ? c.statusDanger : c.foreground;
|
|
46
|
+
const inactive = disabled || loading;
|
|
47
|
+
const height = compact ? 44 : small ? 28 : 32;
|
|
48
|
+
return <PressScale accessibilityRole="button" accessibilityLabel={accessibilityLabel ?? title} accessibilityHint={accessibilityHint} accessibilityState={{ disabled: inactive, busy: loading }}
|
|
49
|
+
disabled={inactive} onPress={onPress} hitSlop={compact ? 0 : 4} outerStyle={{ alignSelf: "flex-start" }}
|
|
50
|
+
style={({ pressed, hovered }) => ({
|
|
51
|
+
minHeight: height, paddingHorizontal: compact ? 14 : small ? 9 : 11, borderRadius: compact ? 8 : 6, flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 6,
|
|
52
|
+
opacity: disabled ? 0.45 : 1,
|
|
53
|
+
...(kind === "primary" ? { backgroundColor: pressed ? withAlpha(c.accent, 0.85) : hovered ? withAlpha(c.accent, 0.92) : c.accent }
|
|
54
|
+
: kind === "ghost" || kind === "danger" ? { backgroundColor: pressed ? c.surface2 : hovered ? c.surface1 : "transparent" }
|
|
55
|
+
: { ...raised(c), backgroundColor: pressed ? c.surface2 : hovered ? c.surface2 : c.surface1 }),
|
|
56
|
+
})}>
|
|
57
|
+
{loading ? <Spinner color={fg} size={12} /> : icon ? <Icon name={icon} size={compact ? 16 : 14} color={iconColor ?? fg} /> : null}
|
|
58
|
+
<Text numberOfLines={1} style={{ color: fg, fontSize: compact ? 14 : small ? 12 : 13, lineHeight: compact ? 19 : 16, fontWeight: kind === "primary" ? "600" : "500", ...tabular }}>{title}</Text>
|
|
59
|
+
</PressScale>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A square icon-only control with an accessible name. */
|
|
63
|
+
export function IconButton({ icon, label, onPress, theme, compact = false, disabled = false, color }: { icon: string; label: string; onPress: () => void; theme: Theme; compact?: boolean; disabled?: boolean; color?: string }) {
|
|
64
|
+
const c = theme.colors;
|
|
65
|
+
const size = compact ? 44 : 28;
|
|
66
|
+
return <PressScale accessibilityRole="button" accessibilityLabel={label} disabled={disabled} onPress={onPress} hitSlop={compact ? 0 : 6}
|
|
67
|
+
style={({ pressed, hovered }) => ({ width: size, height: size, borderRadius: 6, alignItems: "center", justifyContent: "center", backgroundColor: pressed ? c.surface2 : hovered ? c.surface1 : "transparent", opacity: disabled ? 0.45 : 1 })}>
|
|
68
|
+
<Icon name={icon} size={compact ? 18 : 15} color={color ?? c.foregroundMuted} />
|
|
69
|
+
</PressScale>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A raised container. Groups of rows sit inside one; rows divide with hairlines. */
|
|
73
|
+
export function Card({ theme, children, style }: { theme: Theme; children: ReactNode; style?: StyleProp<ViewStyle> }) {
|
|
74
|
+
const c = theme.colors;
|
|
75
|
+
return <View style={[{ borderRadius: 10, overflow: "hidden", backgroundColor: c.surface0 }, raised(c), style]}>{children}</View>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A row inside a card: hairline on top, hover tint when pressable. */
|
|
79
|
+
export function CardRow({ theme, onPress, accessibilityLabel, children, style, first = false }: { theme: Theme; onPress?: () => void; accessibilityLabel?: string; children: ReactNode; style?: StyleProp<ViewStyle>; first?: boolean }) {
|
|
80
|
+
const c = theme.colors;
|
|
81
|
+
const base: ViewStyle = { paddingHorizontal: 14, paddingVertical: 12, borderTopWidth: first ? 0 : 1, borderTopColor: c.border };
|
|
82
|
+
if (!onPress) return <View style={[base, style]}>{children}</View>;
|
|
83
|
+
return <Pressable accessibilityRole="button" accessibilityLabel={accessibilityLabel} onPress={onPress}
|
|
84
|
+
style={(state) => [base, { backgroundColor: state.pressed ? c.surface2 : (state as { hovered?: boolean }).hovered ? c.surface1 : "transparent" }, style]}>{children}</Pressable>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A count or status tag in its own hue at 12–16%. */
|
|
88
|
+
export function Chip({ theme, label, color, icon }: { theme: Theme; label: string; color?: string; icon?: string }) {
|
|
89
|
+
const c = theme.colors;
|
|
90
|
+
const hue = color ?? c.foregroundMuted;
|
|
91
|
+
return <View style={{ flexDirection: "row", alignItems: "center", gap: 4, paddingHorizontal: 7, paddingVertical: 1, borderRadius: 999, backgroundColor: tint(hue, c), alignSelf: "center" }}>
|
|
92
|
+
{icon ? <Icon name={icon} size={11} color={hue} /> : null}
|
|
93
|
+
<Text numberOfLines={1} style={{ color: hue, fontSize: 11, lineHeight: 16, fontWeight: "600", ...tabular }}>{label}</Text>
|
|
94
|
+
</View>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A section heading with an optional count and a right-hand slot. */
|
|
98
|
+
export function SectionHead({ theme, title, count, trailing }: { theme: Theme; title: string; count?: number; trailing?: ReactNode }) {
|
|
99
|
+
const c = theme.colors;
|
|
100
|
+
return <View style={{ flexDirection: "row", alignItems: "center", gap: 7, minHeight: 28 }}>
|
|
101
|
+
<Text accessibilityRole="header" style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16, fontWeight: "600" }}>{title}</Text>
|
|
102
|
+
{count === undefined ? null : <Chip theme={theme} label={String(count)} />}
|
|
103
|
+
<View style={{ flex: 1 }} />
|
|
104
|
+
{trailing}
|
|
105
|
+
</View>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A quiet text link with a trailing arrow, for places a view points to rather than controls. */
|
|
109
|
+
export function TextLink({ theme, label, onPress, icon = "ArrowUpRight", compact = false }: { theme: Theme; label: string; onPress: () => void; icon?: string; compact?: boolean }) {
|
|
110
|
+
const c = theme.colors;
|
|
111
|
+
return <Pressable accessibilityRole="link" accessibilityLabel={label} onPress={onPress} hitSlop={compact ? 0 : 6}
|
|
112
|
+
style={(state) => ({ minHeight: compact ? 44 : 24, flexDirection: "row", alignItems: "center", gap: 3, opacity: state.pressed ? 0.6 : 1 })}>
|
|
113
|
+
{(state) => <>
|
|
114
|
+
<Text style={{ color: (state as { hovered?: boolean }).hovered ? c.foreground : c.foregroundMuted, fontSize: 12, lineHeight: 17, textDecorationLine: (state as { hovered?: boolean }).hovered ? "underline" : "none" }}>{label}</Text>
|
|
115
|
+
<Icon name={icon} size={12} color={c.foregroundMuted} />
|
|
116
|
+
</>}
|
|
117
|
+
</Pressable>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** An accessible switch: accent only for "on", the one place this plugin uses it besides primary buttons. */
|
|
121
|
+
export function Switch({ theme, label, value, onChange, disabled = false, hideLabel = false, compact = false }: { theme: Theme; label: string; value: boolean | null; onChange: (next: boolean) => void; disabled?: boolean; hideLabel?: boolean; compact?: boolean }) {
|
|
122
|
+
const c = theme.colors;
|
|
123
|
+
const on = value === true;
|
|
124
|
+
const inactive = disabled || value === null;
|
|
125
|
+
return <Pressable accessibilityRole="switch" aria-checked={value === null ? "mixed" : on} accessibilityLabel={label}
|
|
126
|
+
accessibilityState={{ checked: value === null ? "mixed" : on, disabled: inactive }} accessibilityValue={{ text: value === null ? "Unknown" : on ? "On" : "Off" }}
|
|
127
|
+
disabled={inactive} onPress={() => onChange(!on)} hitSlop={compact ? 0 : 6}
|
|
128
|
+
style={({ pressed }) => ({ minHeight: compact ? 44 : 28, flexDirection: "row", alignItems: "center", gap: 8, opacity: inactive ? 0.5 : pressed ? 0.8 : 1 })}>
|
|
129
|
+
{hideLabel ? null : <Text numberOfLines={1} style={{ color: c.foregroundMuted, fontSize: 12, lineHeight: 16 }}>{label}</Text>}
|
|
130
|
+
<View style={{ width: 34, height: 20, borderRadius: 10, backgroundColor: on ? c.accent : c.surface2, borderWidth: 1, borderColor: on ? c.accent : c.border, justifyContent: "center" }}>
|
|
131
|
+
<View style={{ width: 14, height: 14, borderRadius: 7, marginLeft: value === null ? 9 : on ? 16 : 2, backgroundColor: on ? c.accentForeground : value === null ? c.border : c.foregroundMuted }} />
|
|
132
|
+
</View>
|
|
133
|
+
</Pressable>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A small status dot, or a spinner while the agent works. */
|
|
137
|
+
export function StatusGlyph({ theme, status }: { theme: Theme; status: string }) {
|
|
138
|
+
const c = theme.colors;
|
|
139
|
+
if (status === "running" || status === "initializing") return <Spinner color={c.foregroundMuted} size={11} label="Working" />;
|
|
140
|
+
const color = status === "error" ? c.statusDanger : status === "idle" ? c.foregroundMuted : c.border;
|
|
141
|
+
return <View accessibilityLabel={status} style={{ width: 11, height: 11, alignItems: "center", justifyContent: "center" }}>
|
|
142
|
+
<View style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: color }} />
|
|
143
|
+
</View>;
|
|
144
|
+
}
|
package/client/live.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import type { z } from "zod";
|
|
3
|
+
|
|
4
|
+
import type { budgetStatus } from "../shared/smart-session";
|
|
5
|
+
|
|
6
|
+
type Budget = z.infer<(typeof budgetStatus)["output"]>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What the sidebar row shows, fed by the polls that already run: the budget from
|
|
10
|
+
* the overview and the client's own slow poll, the paused count from the Session
|
|
11
|
+
* Guard pills' status poll. One per plugin installation.
|
|
12
|
+
*/
|
|
13
|
+
export interface Live {
|
|
14
|
+
subscribe(listener: () => void): () => void;
|
|
15
|
+
version(): number;
|
|
16
|
+
budget(): Budget | null;
|
|
17
|
+
paused(): { waiting: number; attention: number };
|
|
18
|
+
setBudget(budget: Budget): void;
|
|
19
|
+
setPaused(counts: { waiting: number; attention: number }): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createLive(): Live {
|
|
23
|
+
let budget: Budget | null = null;
|
|
24
|
+
let paused = { waiting: 0, attention: 0 };
|
|
25
|
+
let version = 0;
|
|
26
|
+
const listeners = new Set<() => void>();
|
|
27
|
+
const notify = () => {
|
|
28
|
+
version += 1;
|
|
29
|
+
for (const listener of [...listeners]) listener();
|
|
30
|
+
};
|
|
31
|
+
return {
|
|
32
|
+
subscribe(listener) {
|
|
33
|
+
listeners.add(listener);
|
|
34
|
+
return () => { listeners.delete(listener); };
|
|
35
|
+
},
|
|
36
|
+
version: () => version,
|
|
37
|
+
budget: () => budget,
|
|
38
|
+
paused: () => paused,
|
|
39
|
+
setBudget(next) { budget = next; notify(); },
|
|
40
|
+
setPaused(next) {
|
|
41
|
+
if (next.waiting === paused.waiting && next.attention === paused.attention) return;
|
|
42
|
+
paused = next; notify();
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function useLive(live: Live): number {
|
|
48
|
+
return useSyncExternalStore(live.subscribe, live.version, live.version);
|
|
49
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import { AccessibilityInfo, ActivityIndicator, Animated, Easing, Platform, Pressable, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
|
|
3
|
+
|
|
4
|
+
// The native driver is unavailable on web, where react-native-web animates in JS.
|
|
5
|
+
const native = () => Platform.OS !== "web";
|
|
6
|
+
// Built on first use, so importing this module needs nothing from react-native.
|
|
7
|
+
const out = () => Easing.bezier(0.16, 1, 0.3, 1);
|
|
8
|
+
|
|
9
|
+
export function useReducedMotion(): boolean {
|
|
10
|
+
const [reduced, setReduced] = useState(false);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
let live = true;
|
|
13
|
+
void AccessibilityInfo.isReduceMotionEnabled().then(value => { if (live) setReduced(value); }).catch(() => undefined);
|
|
14
|
+
const subscription = AccessibilityInfo.addEventListener("reduceMotionChanged", setReduced);
|
|
15
|
+
return () => { live = false; subscription.remove(); };
|
|
16
|
+
}, []);
|
|
17
|
+
return reduced;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type PressState = { pressed: boolean; hovered: boolean };
|
|
21
|
+
/** A pressable that eases to 97% while held. Hover and press tint come from the caller's style function. */
|
|
22
|
+
export function PressScale({ style, outerStyle, children, ...props }: Omit<PressableProps, "style" | "children"> & {
|
|
23
|
+
style?: StyleProp<ViewStyle> | ((state: PressState) => StyleProp<ViewStyle>);
|
|
24
|
+
outerStyle?: StyleProp<ViewStyle>;
|
|
25
|
+
children: ReactNode | ((state: PressState) => ReactNode);
|
|
26
|
+
}) {
|
|
27
|
+
const reduced = useReducedMotion();
|
|
28
|
+
const scale = useRef(new Animated.Value(1)).current;
|
|
29
|
+
const [pressed, setPressed] = useState(false);
|
|
30
|
+
const [hovered, setHovered] = useState(false);
|
|
31
|
+
const to = (value: number) => { if (!reduced) Animated.timing(scale, { toValue: value, duration: 140, easing: out(), useNativeDriver: native() }).start(); };
|
|
32
|
+
const state = { pressed, hovered };
|
|
33
|
+
return <Pressable {...props} style={outerStyle}
|
|
34
|
+
onHoverIn={event => { setHovered(true); props.onHoverIn?.(event); }}
|
|
35
|
+
onHoverOut={event => { setHovered(false); props.onHoverOut?.(event); }}
|
|
36
|
+
onPressIn={event => { setPressed(true); to(0.97); props.onPressIn?.(event); }}
|
|
37
|
+
onPressOut={event => { setPressed(false); to(1); props.onPressOut?.(event); }}>
|
|
38
|
+
<Animated.View style={[typeof style === "function" ? style(state) : style, { transform: [{ scale }] }]}>
|
|
39
|
+
{typeof children === "function" ? children(state) : children}
|
|
40
|
+
</Animated.View>
|
|
41
|
+
</Pressable>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Cross-fades an icon when its key changes: the new one grows in from 40%. Nothing moves on first render. */
|
|
45
|
+
export function IconSwap({ swapKey, size, children }: { swapKey: string; size: number; children: ReactNode }) {
|
|
46
|
+
const reduced = useReducedMotion();
|
|
47
|
+
const enter = useRef(new Animated.Value(1)).current;
|
|
48
|
+
const first = useRef(true);
|
|
49
|
+
useLayoutEffect(() => {
|
|
50
|
+
if (first.current) { first.current = false; return; }
|
|
51
|
+
if (reduced) return;
|
|
52
|
+
enter.setValue(0);
|
|
53
|
+
const animation = Animated.timing(enter, { toValue: 1, duration: 260, easing: out(), useNativeDriver: native() });
|
|
54
|
+
animation.start();
|
|
55
|
+
return () => animation.stop();
|
|
56
|
+
}, [swapKey]);
|
|
57
|
+
return <Animated.View style={{ width: size, height: size, alignItems: "center", justifyContent: "center", opacity: enter, transform: [{ scale: enter.interpolate({ inputRange: [0, 1], outputRange: [0.4, 1] }) }] }}>{children}</Animated.View>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Content that arrives after load rises 6px into place. Reduced motion shows it at once. */
|
|
61
|
+
export function Reveal({ children, delay = 0 }: { children: ReactNode; delay?: number }) {
|
|
62
|
+
const reduced = useReducedMotion();
|
|
63
|
+
const value = useRef(new Animated.Value(reduced ? 1 : 0)).current;
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (reduced) { value.setValue(1); return; }
|
|
66
|
+
const animation = Animated.timing(value, { toValue: 1, duration: 280, delay, easing: out(), useNativeDriver: native() });
|
|
67
|
+
animation.start();
|
|
68
|
+
return () => animation.stop();
|
|
69
|
+
}, [reduced]);
|
|
70
|
+
return <Animated.View style={{ opacity: value, transform: [{ translateY: value.interpolate({ inputRange: [0, 1], outputRange: [6, 0] }) }] }}>{children}</Animated.View>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The system spinner, sized to sit beside a line of text. */
|
|
74
|
+
export function Spinner({ color, size = 12, label = "Working" }: { color: string; size?: number; label?: string }) {
|
|
75
|
+
return <View accessibilityLabel={label} style={{ width: size, height: size, alignItems: "center", justifyContent: "center" }}>
|
|
76
|
+
{Platform.OS === "ios" ? <ActivityIndicator size="small" color={color} style={{ transform: [{ scale: size / 20 }] }} /> : <ActivityIndicator size={size} color={color} />}
|
|
77
|
+
</View>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A soft breathing placeholder bar for loading states. Static under reduced motion. */
|
|
81
|
+
export function Skeleton({ width, height = 10, color, radius = 4 }: { width: number | `${number}%`; height?: number; color: string; radius?: number }) {
|
|
82
|
+
const reduced = useReducedMotion();
|
|
83
|
+
const value = useRef(new Animated.Value(0.55)).current;
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (reduced) return;
|
|
86
|
+
const loop = Animated.loop(Animated.sequence([
|
|
87
|
+
Animated.timing(value, { toValue: 1, duration: 800, easing: Easing.inOut(Easing.ease), useNativeDriver: native() }),
|
|
88
|
+
Animated.timing(value, { toValue: 0.55, duration: 800, easing: Easing.inOut(Easing.ease), useNativeDriver: native() }),
|
|
89
|
+
]));
|
|
90
|
+
loop.start();
|
|
91
|
+
return () => loop.stop();
|
|
92
|
+
}, [reduced]);
|
|
93
|
+
return <Animated.View style={{ width, height, borderRadius: radius, backgroundColor: color, opacity: value }} />;
|
|
94
|
+
}
|