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.
@@ -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-DNHg3blr.js"></script>
17
- <link rel="stylesheet" crossorigin href="/assets/index-DXJfr5b2.css">
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.59",
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
- if (!usage || !top) return null;
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
- // Every window, each labelled from its OWN resets_at — the `weekly_*` kinds do NOT reset weekly.
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
- return <Metric label="usage" value={top.percent} title={`${plan}${top.percent}% of the binding limit\n${detail}`} severity={top.severity} />;
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 h = ms / 3_600_000;
127
- if (h < 1) return `${Math.max(1, Math.round(ms / 60_000))}m`;
128
- if (h < 48) return `${Math.round(h)}h`;
129
- return `${Math.round(h / 24)}d`;
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={title ?? `${label} ${value}%${series ? ` — last ${series.length} samples` : ''}`}>
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 };