shraga 0.1.85 → 0.1.86

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,7 +13,7 @@
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-yDX1hxfO.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-BSvY0squ.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index-J2NH6FvE.css">
18
18
  </head>
19
19
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.85",
3
+ "version": "0.1.86",
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",
@@ -23,6 +23,7 @@ const rehypeBidi: Plugin<[], Root> = () => (tree) => {
23
23
  import 'highlight.js/styles/github.css';
24
24
  import { ChevronRight, Wrench, User, Bot, Copy, Check, RotateCcw, Pencil, X, SendHorizontal, ShieldQuestion, CheckCircle2, XCircle, Eye, EyeOff, Info, Loader2, BrainCircuit, GitFork, Minimize2 } from 'lucide-react';
25
25
  import { cn } from '@/lib/utils';
26
+ import { annotateLimitReset } from '@/lib/limit-reset';
26
27
  import type { ChatMessage, MessageBlock, Attachment } from '@/hooks/useConversation';
27
28
  import type { AskQuestion, QuestionAnswers } from '@/lib/ws';
28
29
  import { SmartChart, tryParseChartData } from './SmartChart';
@@ -320,7 +321,7 @@ function MessageRow({
320
321
  const result = block.type === 'tool_use'
321
322
  ? message.blocks.find((b): b is MessageBlock & { type: 'tool_result' } => b.type === 'tool_result' && b.toolUseId === block.toolUseId)
322
323
  : undefined;
323
- return <BlockRenderer key={i} block={block} isUser={isUser} onPermissionRespond={onPermissionRespond} onQuestionRespond={onQuestionRespond} pairedResult={result} onImageClick={onImageClick} busy={busy} screenMap={screenMap} />;
324
+ return <BlockRenderer key={i} block={block} isUser={isUser} ts={message.ts} onPermissionRespond={onPermissionRespond} onQuestionRespond={onQuestionRespond} pairedResult={result} onImageClick={onImageClick} busy={busy} screenMap={screenMap} />;
324
325
  })}
325
326
  {isUser && userText && (
326
327
  <div className={cn('flex gap-1 items-center justify-end transition-opacity', actionsVisible ? 'opacity-100' : 'opacity-0 group-hover/row:opacity-100')}>
@@ -385,7 +386,7 @@ function CompactMarkerDivider({ summary, compactedCount }: { summary: string; co
385
386
  );
386
387
  }
387
388
 
388
- function BlockRenderer({ block, isUser, onPermissionRespond, onQuestionRespond, pairedResult, onImageClick, busy, screenMap }: { block: MessageBlock; isUser: boolean; onPermissionRespond?: (id: string, allow: boolean, allowAll?: boolean) => void; onQuestionRespond?: (id: string, answers: QuestionAnswers) => void; pairedResult?: MessageBlock & { type: 'tool_result' }; onImageClick?: (src: string) => void; busy?: boolean; screenMap?: Map<string, string[]> }) {
389
+ function BlockRenderer({ block, isUser, ts, onPermissionRespond, onQuestionRespond, pairedResult, onImageClick, busy, screenMap }: { block: MessageBlock; isUser: boolean; ts?: number; onPermissionRespond?: (id: string, allow: boolean, allowAll?: boolean) => void; onQuestionRespond?: (id: string, answers: QuestionAnswers) => void; pairedResult?: MessageBlock & { type: 'tool_result' }; onImageClick?: (src: string) => void; busy?: boolean; screenMap?: Map<string, string[]> }) {
389
390
  const slots = useSlots();
390
391
  if (block.type === 'image') {
391
392
  return (
@@ -410,7 +411,7 @@ function BlockRenderer({ block, isUser, onPermissionRespond, onQuestionRespond,
410
411
  }
411
412
 
412
413
  if (block.type === 'error') {
413
- return <ErrorBlock text={block.text} />;
414
+ return <ErrorBlock text={block.text} ts={ts} />;
414
415
  }
415
416
 
416
417
  if (block.type === 'text') {
@@ -452,11 +453,12 @@ function BlockRenderer({ block, isUser, onPermissionRespond, onQuestionRespond,
452
453
  }
453
454
 
454
455
  /** A run that failed at the engine/adapter level — deliberately loud, never mistakable for a reply. */
455
- function ErrorBlock({ text }: { text: string }) {
456
+ function ErrorBlock({ text, ts }: { text: string; ts?: number }) {
457
+ const shown = annotateLimitReset(text, ts ?? Date.now());
456
458
  return (
457
459
  <div dir="auto" className="border border-destructive/40 rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive break-words">
458
460
  <span className="font-medium">⚠️ Run failed</span>
459
- <div className="mt-1 text-xs opacity-90 whitespace-pre-wrap font-mono">{text}</div>
461
+ <div className="mt-1 text-xs opacity-90 whitespace-pre-wrap font-mono">{shown}</div>
460
462
  </div>
461
463
  );
462
464
  }
@@ -1,6 +1,9 @@
1
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
+ // Shared with the chat transcript's rate-limit error annotation — one duration formatter, one home.
5
+ import { untilLabel } from '@/lib/limit-reset';
6
+ export { untilLabel };
4
7
  import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
5
8
  import { DISK_WARN_PCT, DISK_CRIT_PCT, formatBytes } from '../../shared/disk';
6
9
 
@@ -293,19 +296,6 @@ export function agoLabel(iso: string | undefined): string | null {
293
296
  return `${Math.floor(h / 24)}d`;
294
297
  }
295
298
 
296
- export function untilLabel(iso: string | null): string | null {
297
- if (!iso) return null;
298
- const ms = new Date(iso).getTime() - Date.now();
299
- if (!Number.isFinite(ms) || ms <= 0) return null;
300
- const min = Math.round(ms / 60_000);
301
- if (min < 1) return '1m';
302
- if (min < 60) return `${min}m`;
303
- const h = Math.floor(min / 60), m = min % 60;
304
- if (h < 48) return m ? `${h}h ${m}m` : `${h}h`;
305
- const d = Math.floor(h / 24), rh = h % 24;
306
- return rh ? `${d}d ${rh}h` : `${d}d`;
307
- }
308
-
309
299
  /** Only severities we have actually SEEN mean something here. The vendor's vocabulary is not
310
300
  * documented and not fully observed, so an unrecognised value falls through to the percentage
311
301
  * thresholds — treating "anything that isn't normal" as elevated would paint the widget a
@@ -0,0 +1,42 @@
1
+ /** Human "time until reset", derived from a resets_at timestamp only — never from a window's kind
2
+ * name. Rounded to whole minutes FIRST so a value handed in as exactly 4h does not render "3h 59m"
3
+ * because a few milliseconds elapsed between building it and reading the clock.
4
+ * Compact "time until" an ISO timestamp. null when absent, unparseable, or already past. */
5
+ export function untilLabel(iso: string | null, now = Date.now()): string | null {
6
+ if (!iso) return null;
7
+ const ms = new Date(iso).getTime() - now;
8
+ if (!Number.isFinite(ms) || ms <= 0) return null;
9
+ const min = Math.round(ms / 60_000);
10
+ if (min < 1) return '1m';
11
+ if (min < 60) return `${min}m`;
12
+ const h = Math.floor(min / 60), m = min % 60;
13
+ if (h < 48) return m ? `${h}h ${m}m` : `${h}h`;
14
+ const d = Math.floor(h / 24), rh = h % 24;
15
+ return rh ? `${d}d ${rh}h` : `${d}d`;
16
+ }
17
+
18
+ // The vendor renders the rate-limit reset as a bare wall-clock time in UTC ("resets 1pm (UTC)"),
19
+ // which is unreadable at a glance from any other timezone and says nothing about how long is left.
20
+ const RESETS_RE = /resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*\(UTC\)/i;
21
+
22
+ /** Rewrite a UTC reset stamp in an error message into the viewer's local time plus a countdown.
23
+ * `anchorMs` (the message's own timestamp) picks the DAY — so a week-old failure doesn't roll its
24
+ * reset forward to tomorrow — while `now` drives the countdown, which is dropped once it's past. */
25
+ export function annotateLimitReset(text: string, anchorMs = Date.now(), now = Date.now()): string {
26
+ const m = text.match(RESETS_RE);
27
+ if (!m) return text;
28
+ let h = Number(m[1]);
29
+ const min = m[2] ? Number(m[2]) : 0;
30
+ const ap = m[3]?.toLowerCase();
31
+ if (ap === 'pm' && h < 12) h += 12;
32
+ if (ap === 'am' && h === 12) h = 0;
33
+ if (h > 23 || min > 59) return text;
34
+
35
+ const a = new Date(anchorMs);
36
+ const reset = new Date(Date.UTC(a.getUTCFullYear(), a.getUTCMonth(), a.getUTCDate(), h, min));
37
+ if (reset.getTime() <= anchorMs) reset.setUTCDate(reset.getUTCDate() + 1);
38
+
39
+ const local = reset.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
40
+ const left = untilLabel(reset.toISOString(), now);
41
+ return text.replace(m[0], `resets ${local}${left ? ` (in ${left})` : ''}`);
42
+ }