shraga 0.1.59 → 0.1.60

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-NHccVXR_.js"></script>
17
+ <link rel="stylesheet" crossorigin href="/assets/index-LCOnn8yS.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.60",
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-hover-card": "^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
1
  import { useEffect, useState } from 'react';
2
2
  import type { AgentSocket, ServerEvent } from '@/lib/ws';
3
3
  import { cn } from '@/lib/utils';
4
+ import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card';
4
5
 
5
6
  type Sample = Extract<ServerEvent, { type: 'stats' }>['sample'];
6
7
 
@@ -98,15 +99,66 @@ export function UsageMetric({ usage }: { usage: Usage | null }) {
98
99
  const top = usage && binding(usage.limits);
99
100
  if (!usage || !top) return null;
100
101
 
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 ';
106
-
107
102
  // No `series`: usage has no server-side history to seed from, so it renders a GAUGE (full from the
108
103
  // 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} />;
104
+ // No `title` either — the breakdown lives in the hover card below; a native tooltip on the same
105
+ // element would race it, appear a second late, and repeat the card word for word.
106
+ return (
107
+ <HoverCard openDelay={120} closeDelay={80}>
108
+ <HoverCardTrigger asChild>
109
+ <span className="cursor-default">
110
+ <Metric label="usage" value={top.percent} severity={top.severity} />
111
+ </span>
112
+ </HoverCardTrigger>
113
+ <HoverCardContent>
114
+ <UsageCard usage={usage} />
115
+ </HoverCardContent>
116
+ </HoverCard>
117
+ );
118
+ }
119
+
120
+ /** The hover breakdown: one row per reported window. Exported bare so the rows can be asserted
121
+ * without driving a real hover (Radix only mounts the content once open). */
122
+ export function UsageCard({ usage }: { usage: Usage }) {
123
+ const top = binding(usage.limits);
124
+ return (
125
+ <div className="text-[11px] leading-tight">
126
+ <div className="flex items-baseline justify-between gap-2 pb-2 text-muted-foreground">
127
+ <span className="uppercase tracking-wide">claude usage</span>
128
+ {usage.subscriptionType && <span className="tabular-nums">{usage.subscriptionType} plan</span>}
129
+ </div>
130
+ <div className="flex flex-col gap-3">
131
+ {usage.limits.map((l, i) => {
132
+ const until = untilLabel(l.resetsAt);
133
+ const isTop = l === top;
134
+ return (
135
+ <div key={`${l.kind}-${i}`} data-headline={isTop || undefined} className={cn('flex flex-col gap-1', !isTop && 'opacity-60')}>
136
+ <div className="flex items-baseline justify-between gap-2">
137
+ <span className={cn('truncate', isTop && 'font-medium')}>
138
+ {isTop && <span className="mr-1 text-muted-foreground" aria-hidden>▸</span>}
139
+ {windowLabel(l)}
140
+ </span>
141
+ <span className={cn('tabular-nums shrink-0', level(l.percent, l.severity))}>{l.percent}%</span>
142
+ </div>
143
+ <Gauge value={l.percent} className={cn('w-full', level(l.percent, l.severity))} />
144
+ <span className="text-[10px] text-muted-foreground">{until ? `resets in ${until}` : 'no reset reported'}</span>
145
+ </div>
146
+ );
147
+ })}
148
+ </div>
149
+ <div className="mt-3 border-t pt-2 text-[10px] text-muted-foreground">▸ is the window the strip is showing</div>
150
+ </div>
151
+ );
152
+ }
153
+
154
+ /** Human name for a window, derived ONLY from what the payload actually states. `weekly_*` is never
155
+ * printed as "weekly"/"7 days": that window rolls on a ~72h cadence, so the kind name is a lie and
156
+ * only resets_at (rendered separately) tells the truth about its length. */
157
+ export function windowLabel(l: UsageLimit): string {
158
+ if (l.scopeLabel) return `${l.scopeLabel} window`;
159
+ if (l.kind === 'session') return 'current session';
160
+ if (l.kind === 'weekly_all') return 'all models';
161
+ return l.kind.replace(/_/g, ' ');
110
162
  }
111
163
 
112
164
  /** The window that gates you first is simply the FULLEST one, whichever kind it is.
@@ -118,15 +170,20 @@ export function binding(limits: UsageLimit[]): UsageLimit | null {
118
170
  return limits.reduce<UsageLimit | null>((best, l) => (!best || l.percent > best.percent ? l : best), null);
119
171
  }
120
172
 
121
- /** Human "time until reset", derived from resets_at only — never from the limit's kind name. */
173
+ /** Human "time until reset", derived from resets_at only — never from the limit's kind name.
174
+ * Rounded to whole minutes FIRST so a value handed in as exactly 4h does not render "3h 59m"
175
+ * because a few milliseconds elapsed between building it and reading the clock. */
122
176
  export function untilLabel(iso: string | null): string | null {
123
177
  if (!iso) return null;
124
178
  const ms = new Date(iso).getTime() - Date.now();
125
179
  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`;
180
+ const min = Math.round(ms / 60_000);
181
+ if (min < 1) return '1m';
182
+ if (min < 60) return `${min}m`;
183
+ const h = Math.floor(min / 60), m = min % 60;
184
+ if (h < 48) return m ? `${h}h ${m}m` : `${h}h`;
185
+ const d = Math.floor(h / 24), rh = h % 24;
186
+ return rh ? `${d}d ${rh}h` : `${d}d`;
130
187
  }
131
188
 
132
189
  /** Only severities we have actually SEEN mean something here. The vendor's vocabulary is not
@@ -143,8 +200,11 @@ function level(v: number, severity?: string) {
143
200
 
144
201
  function Metric({ label, value, series, title, severity }: { label: string; value: number; series?: number[]; title?: string; severity?: string }) {
145
202
  const tone = level(value, severity);
203
+ // Plain-text fallback only where nothing richer exists (cpu/mem). The usage metric passes no title:
204
+ // it owns a hover card, and a native tooltip on the same element would double up on it.
205
+ const tip = title ?? (series ? `${label} ${value}% — last ${series.length} samples` : undefined);
146
206
  return (
147
- <span className="flex items-center gap-1" title={title ?? `${label} ${value}%${series ? ` — last ${series.length} samples` : ''}`}>
207
+ <span className="flex items-center gap-1" title={tip}>
148
208
  <span className="uppercase tracking-wide">{label}</span>
149
209
  {series ? <Sparkline series={series} className={tone} /> : <Gauge value={value} className={tone} />}
150
210
  <span className={cn('tabular-nums', tone)}>{value}%</span>
@@ -0,0 +1,33 @@
1
+ import * as React from 'react';
2
+ import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
3
+ import { cn } from '@/lib/utils';
4
+
5
+ const HoverCard = HoverCardPrimitive.Root;
6
+ const HoverCardTrigger = HoverCardPrimitive.Trigger;
7
+
8
+ /** Portalled + collision-aware by default: the only consumer today sits in the bottom-left corner of
9
+ * a fixed 256px sidebar, so the card MUST be free to flip upward and slide rightward into the main
10
+ * pane instead of being clipped by the sidebar box or the viewport edge. */
11
+ const HoverCardContent = React.forwardRef<
12
+ React.ElementRef<typeof HoverCardPrimitive.Content>,
13
+ React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
14
+ >(({ className, align = 'start', side = 'top', sideOffset = 8, collisionPadding = 8, ...props }, ref) => (
15
+ <HoverCardPrimitive.Portal>
16
+ <HoverCardPrimitive.Content
17
+ ref={ref}
18
+ align={align}
19
+ side={side}
20
+ sideOffset={sideOffset}
21
+ collisionPadding={collisionPadding}
22
+ className={cn(
23
+ 'z-50 w-64 rounded-md border bg-card p-3 text-card-foreground shadow-md outline-none',
24
+ 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
25
+ className
26
+ )}
27
+ {...props}
28
+ />
29
+ </HoverCardPrimitive.Portal>
30
+ ));
31
+ HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
32
+
33
+ export { HoverCard, HoverCardTrigger, HoverCardContent };