shraga 0.1.59 → 0.1.61
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/dist/client/assets/index-BeVHw9x2.css +10 -0
- package/dist/client/assets/index-QguraA7L.js +1946 -0
- package/dist/client/index.html +2 -2
- package/package.json +2 -1
- package/src/client/components/MachineStats.tsx +135 -14
- package/src/client/components/ui/popover.tsx +39 -0
- package/dist/client/assets/index-DNHg3blr.js +0 -1948
- package/dist/client/assets/index-DXJfr5b2.css +0 -10
package/dist/client/index.html
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
14
14
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
15
15
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
|
16
|
-
<script type="module" crossorigin src="/assets/index-
|
|
17
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
16
|
+
<script type="module" crossorigin src="/assets/index-QguraA7L.js"></script>
|
|
17
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BeVHw9x2.css">
|
|
18
18
|
</head>
|
|
19
19
|
<body>
|
|
20
20
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.61",
|
|
4
4
|
"description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
"@livx.cc/mcp-firebase": "^0.1.14",
|
|
60
60
|
"@radix-ui/react-accordion": "^1.2.2",
|
|
61
61
|
"@radix-ui/react-dialog": "^1.1.4",
|
|
62
|
+
"@radix-ui/react-popover": "^1.1.23",
|
|
62
63
|
"@radix-ui/react-scroll-area": "^1.2.2",
|
|
63
64
|
"@radix-ui/react-slot": "^1.1.1",
|
|
64
65
|
"@tailwindcss/typography": "^0.5.19",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import type { AgentSocket, ServerEvent } from '@/lib/ws';
|
|
3
3
|
import { cn } from '@/lib/utils';
|
|
4
|
+
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
|
4
5
|
|
|
5
6
|
type Sample = Extract<ServerEvent, { type: 'stats' }>['sample'];
|
|
6
7
|
|
|
@@ -96,17 +97,129 @@ function ClaudeUsageMetric({ getToken }: { getToken: () => Promise<string | null
|
|
|
96
97
|
* the gate and the labelling are testable without a network or a DOM. */
|
|
97
98
|
export function UsageMetric({ usage }: { usage: Usage | null }) {
|
|
98
99
|
const top = usage && binding(usage.limits);
|
|
99
|
-
|
|
100
|
+
const [state, setState] = useState<CardState>(CLOSED);
|
|
101
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
102
|
+
|
|
103
|
+
const cancel = () => { if (timer.current) { clearTimeout(timer.current); timer.current = null; } };
|
|
104
|
+
const send = useCallback((ev: CardEvent, delayMs = 0) => {
|
|
105
|
+
cancel();
|
|
106
|
+
if (!delayMs) { setState(prev => nextCardState(prev, ev)); return; }
|
|
107
|
+
timer.current = setTimeout(() => { timer.current = null; setState(prev => nextCardState(prev, ev)); }, delayMs);
|
|
108
|
+
}, []);
|
|
109
|
+
useEffect(() => cancel, []);
|
|
100
110
|
|
|
101
|
-
|
|
102
|
-
const detail = usage.limits
|
|
103
|
-
.map(l => `${l.scopeLabel ?? l.kind} ${l.percent}%${untilLabel(l.resetsAt) ? ` (resets in ${untilLabel(l.resetsAt)})` : ''}`)
|
|
104
|
-
.join('\n');
|
|
105
|
-
const plan = usage.subscriptionType ? `claude ${usage.subscriptionType} — ` : 'claude ';
|
|
111
|
+
if (!usage || !top) return null;
|
|
106
112
|
|
|
107
113
|
// No `series`: usage has no server-side history to seed from, so it renders a GAUGE (full from the
|
|
108
114
|
// first paint) instead of a sparkline that would plot tab-uptime and be empty for the first minutes.
|
|
109
|
-
|
|
115
|
+
// No `title` either — the breakdown lives in the card below; a native tooltip on the same element
|
|
116
|
+
// would race it, appear a second late, and repeat the card word for word.
|
|
117
|
+
//
|
|
118
|
+
// The trigger is a real <button>: it must be tappable (Radix HoverCard was pointer-only, so the
|
|
119
|
+
// breakdown was flat-out unreachable on a phone) AND focusable, so Enter/Space opens it too.
|
|
120
|
+
// Hover is layered on top of that so a desktop mouse still just points at it.
|
|
121
|
+
return (
|
|
122
|
+
<Popover open={state.open} onOpenChange={o => send(o ? { type: 'activate' } : { type: 'dismiss' })}>
|
|
123
|
+
<PopoverTrigger asChild>
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
aria-label="claude usage breakdown"
|
|
127
|
+
className="cursor-default rounded-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
128
|
+
onPointerEnter={e => send({ type: 'pointerenter', pointerType: e.pointerType }, HOVER_OPEN_MS)}
|
|
129
|
+
onPointerLeave={e => send({ type: 'pointerleave', pointerType: e.pointerType }, HOVER_CLOSE_MS)}
|
|
130
|
+
>
|
|
131
|
+
<Metric label="usage" value={top.percent} severity={top.severity} />
|
|
132
|
+
</button>
|
|
133
|
+
</PopoverTrigger>
|
|
134
|
+
<PopoverContent
|
|
135
|
+
// A hover-opened card must not steal focus — that would yank the caret out of the composer
|
|
136
|
+
// just because the mouse crossed the strip. Escape still closes it: the dismissable layer
|
|
137
|
+
// listens on the document, not on the content's own focus.
|
|
138
|
+
onOpenAutoFocus={e => e.preventDefault()}
|
|
139
|
+
onPointerEnter={() => cancel()}
|
|
140
|
+
onPointerLeave={e => send({ type: 'pointerleave', pointerType: e.pointerType }, HOVER_CLOSE_MS)}
|
|
141
|
+
>
|
|
142
|
+
<UsageCard usage={usage} />
|
|
143
|
+
</PopoverContent>
|
|
144
|
+
</Popover>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const HOVER_OPEN_MS = 120;
|
|
149
|
+
const HOVER_CLOSE_MS = 80;
|
|
150
|
+
|
|
151
|
+
export interface CardState { open: boolean; byHover: boolean }
|
|
152
|
+
export type CardEvent =
|
|
153
|
+
| { type: 'pointerenter' | 'pointerleave'; pointerType: string }
|
|
154
|
+
| { type: 'activate' } // tap, click or Enter/Space on the trigger (Radix reports it as open)
|
|
155
|
+
| { type: 'dismiss' }; // Escape, outside press, or a second activation
|
|
156
|
+
|
|
157
|
+
export const CLOSED: CardState = { open: false, byHover: false };
|
|
158
|
+
|
|
159
|
+
/** Who may open and close the card, as a pure function so both interaction modes are testable
|
|
160
|
+
* without a DOM. Two rules earn their keep:
|
|
161
|
+
* - hover is MOUSE-ONLY. A touch tap also emits pointerenter/pointerleave (pointerType 'touch'),
|
|
162
|
+
* and honouring those would open the card on touch-down and immediately close it again on lift.
|
|
163
|
+
* - a card opened deliberately (tap / click / keyboard) does NOT close when the pointer wanders
|
|
164
|
+
* off; only Escape, an outside press, or another activation dismisses it. */
|
|
165
|
+
export function nextCardState(state: CardState, ev: CardEvent): CardState {
|
|
166
|
+
switch (ev.type) {
|
|
167
|
+
case 'pointerenter':
|
|
168
|
+
return isHoverPointer(ev.pointerType) ? { open: true, byHover: true } : state;
|
|
169
|
+
case 'pointerleave':
|
|
170
|
+
return isHoverPointer(ev.pointerType) && state.byHover ? CLOSED : state;
|
|
171
|
+
case 'activate':
|
|
172
|
+
return { open: true, byHover: false };
|
|
173
|
+
case 'dismiss':
|
|
174
|
+
return CLOSED;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// '' covers synthetic/legacy events that carry no pointerType; a real touch always says 'touch'.
|
|
179
|
+
const isHoverPointer = (t: string) => t === 'mouse' || t === '';
|
|
180
|
+
|
|
181
|
+
/** The breakdown card: one row per reported window. Exported bare so the rows can be asserted
|
|
182
|
+
* without driving a real open (Radix only mounts the content once open). */
|
|
183
|
+
export function UsageCard({ usage }: { usage: Usage }) {
|
|
184
|
+
const top = binding(usage.limits);
|
|
185
|
+
return (
|
|
186
|
+
<div className="text-[11px] leading-tight">
|
|
187
|
+
<div className="flex items-baseline justify-between gap-2 pb-2 text-muted-foreground">
|
|
188
|
+
<span className="uppercase tracking-wide">claude usage</span>
|
|
189
|
+
{usage.subscriptionType && <span className="tabular-nums">{usage.subscriptionType} plan</span>}
|
|
190
|
+
</div>
|
|
191
|
+
<div className="flex flex-col gap-3">
|
|
192
|
+
{usage.limits.map((l, i) => {
|
|
193
|
+
const until = untilLabel(l.resetsAt);
|
|
194
|
+
const isTop = l === top;
|
|
195
|
+
return (
|
|
196
|
+
<div key={`${l.kind}-${i}`} data-headline={isTop || undefined} className={cn('flex flex-col gap-1', !isTop && 'opacity-60')}>
|
|
197
|
+
<div className="flex items-baseline justify-between gap-2">
|
|
198
|
+
<span className={cn('truncate', isTop && 'font-medium')}>
|
|
199
|
+
{isTop && <span className="mr-1 text-muted-foreground" aria-hidden>▸</span>}
|
|
200
|
+
{windowLabel(l)}
|
|
201
|
+
</span>
|
|
202
|
+
<span className={cn('tabular-nums shrink-0', level(l.percent, l.severity))}>{l.percent}%</span>
|
|
203
|
+
</div>
|
|
204
|
+
<Gauge value={l.percent} className={cn('w-full', level(l.percent, l.severity))} />
|
|
205
|
+
<span className="text-[10px] text-muted-foreground">{until ? `resets in ${until}` : 'no reset reported'}</span>
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
})}
|
|
209
|
+
</div>
|
|
210
|
+
<div className="mt-3 border-t pt-2 text-[10px] text-muted-foreground">▸ is the window the strip is showing</div>
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Human name for a window, derived ONLY from what the payload actually states. `weekly_*` is never
|
|
216
|
+
* printed as "weekly"/"7 days": that window rolls on a ~72h cadence, so the kind name is a lie and
|
|
217
|
+
* only resets_at (rendered separately) tells the truth about its length. */
|
|
218
|
+
export function windowLabel(l: UsageLimit): string {
|
|
219
|
+
if (l.scopeLabel) return `${l.scopeLabel} window`;
|
|
220
|
+
if (l.kind === 'session') return 'current session';
|
|
221
|
+
if (l.kind === 'weekly_all') return 'all models';
|
|
222
|
+
return l.kind.replace(/_/g, ' ');
|
|
110
223
|
}
|
|
111
224
|
|
|
112
225
|
/** The window that gates you first is simply the FULLEST one, whichever kind it is.
|
|
@@ -118,15 +231,20 @@ export function binding(limits: UsageLimit[]): UsageLimit | null {
|
|
|
118
231
|
return limits.reduce<UsageLimit | null>((best, l) => (!best || l.percent > best.percent ? l : best), null);
|
|
119
232
|
}
|
|
120
233
|
|
|
121
|
-
/** Human "time until reset", derived from resets_at only — never from the limit's kind name.
|
|
234
|
+
/** Human "time until reset", derived from resets_at only — never from the limit's kind name.
|
|
235
|
+
* Rounded to whole minutes FIRST so a value handed in as exactly 4h does not render "3h 59m"
|
|
236
|
+
* because a few milliseconds elapsed between building it and reading the clock. */
|
|
122
237
|
export function untilLabel(iso: string | null): string | null {
|
|
123
238
|
if (!iso) return null;
|
|
124
239
|
const ms = new Date(iso).getTime() - Date.now();
|
|
125
240
|
if (!Number.isFinite(ms) || ms <= 0) return null;
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
128
|
-
if (
|
|
129
|
-
|
|
241
|
+
const min = Math.round(ms / 60_000);
|
|
242
|
+
if (min < 1) return '1m';
|
|
243
|
+
if (min < 60) return `${min}m`;
|
|
244
|
+
const h = Math.floor(min / 60), m = min % 60;
|
|
245
|
+
if (h < 48) return m ? `${h}h ${m}m` : `${h}h`;
|
|
246
|
+
const d = Math.floor(h / 24), rh = h % 24;
|
|
247
|
+
return rh ? `${d}d ${rh}h` : `${d}d`;
|
|
130
248
|
}
|
|
131
249
|
|
|
132
250
|
/** Only severities we have actually SEEN mean something here. The vendor's vocabulary is not
|
|
@@ -143,8 +261,11 @@ function level(v: number, severity?: string) {
|
|
|
143
261
|
|
|
144
262
|
function Metric({ label, value, series, title, severity }: { label: string; value: number; series?: number[]; title?: string; severity?: string }) {
|
|
145
263
|
const tone = level(value, severity);
|
|
264
|
+
// Plain-text fallback only where nothing richer exists (cpu/mem). The usage metric passes no title:
|
|
265
|
+
// it owns a popover card, and a native tooltip on the same element would double up on it.
|
|
266
|
+
const tip = title ?? (series ? `${label} ${value}% — last ${series.length} samples` : undefined);
|
|
146
267
|
return (
|
|
147
|
-
<span className="flex items-center gap-1" title={
|
|
268
|
+
<span className="flex items-center gap-1" title={tip}>
|
|
148
269
|
<span className="uppercase tracking-wide">{label}</span>
|
|
149
270
|
{series ? <Sparkline series={series} className={tone} /> : <Gauge value={value} className={tone} />}
|
|
150
271
|
<span className={cn('tabular-nums', tone)}>{value}%</span>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
|
3
|
+
import { cn } from '@/lib/utils';
|
|
4
|
+
|
|
5
|
+
const Popover = PopoverPrimitive.Root;
|
|
6
|
+
const PopoverTrigger = PopoverPrimitive.Trigger;
|
|
7
|
+
const PopoverAnchor = PopoverPrimitive.Anchor;
|
|
8
|
+
|
|
9
|
+
/** Popover, not HoverCard: HoverCard is pointer-only by design (it listens for pointerenter/leave and
|
|
10
|
+
* nothing else), so on a touch device its content is unreachable — no hover, no card. Popover opens
|
|
11
|
+
* on activation, which a tap, a click AND Enter/Space all produce, and brings Escape + outside-press
|
|
12
|
+
* dismissal with it. Hover is layered on top by the consumer, so desktop still feels like a hover.
|
|
13
|
+
*
|
|
14
|
+
* Portalled + collision-aware by default: the only consumer today sits in the bottom-left corner of
|
|
15
|
+
* a fixed 256px sidebar, so the card MUST be free to flip upward and slide rightward into the main
|
|
16
|
+
* pane instead of being clipped by the sidebar box or the viewport edge. */
|
|
17
|
+
const PopoverContent = React.forwardRef<
|
|
18
|
+
React.ElementRef<typeof PopoverPrimitive.Content>,
|
|
19
|
+
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
|
20
|
+
>(({ className, align = 'start', side = 'top', sideOffset = 8, collisionPadding = 8, ...props }, ref) => (
|
|
21
|
+
<PopoverPrimitive.Portal>
|
|
22
|
+
<PopoverPrimitive.Content
|
|
23
|
+
ref={ref}
|
|
24
|
+
align={align}
|
|
25
|
+
side={side}
|
|
26
|
+
sideOffset={sideOffset}
|
|
27
|
+
collisionPadding={collisionPadding}
|
|
28
|
+
className={cn(
|
|
29
|
+
'z-50 w-64 max-w-[calc(100vw-1rem)] rounded-md border bg-card p-3 text-card-foreground shadow-md outline-none',
|
|
30
|
+
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
31
|
+
className
|
|
32
|
+
)}
|
|
33
|
+
{...props}
|
|
34
|
+
/>
|
|
35
|
+
</PopoverPrimitive.Portal>
|
|
36
|
+
));
|
|
37
|
+
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
|
38
|
+
|
|
39
|
+
export { Popover, PopoverTrigger, PopoverAnchor, PopoverContent };
|