@opengeni/react 0.13.0 → 0.15.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.
Files changed (41) hide show
  1. package/README.md +19 -13
  2. package/dist/chunk-TOJR776I.js +2280 -0
  3. package/dist/chunk-TOJR776I.js.map +1 -0
  4. package/dist/index.d.ts +314 -255
  5. package/dist/index.js +5862 -4404
  6. package/dist/index.js.map +1 -1
  7. package/dist/{machines-CnlMb7E-.d.ts → machines-BpdwuQcD.d.ts} +130 -10
  8. package/dist/machines.d.ts +1 -1
  9. package/dist/machines.js +23 -1
  10. package/package.json +5 -2
  11. package/src/client.ts +10 -1
  12. package/src/components/chat-composer.tsx +309 -57
  13. package/src/components/machine-card.tsx +81 -15
  14. package/src/components/machine-health-pill.tsx +68 -0
  15. package/src/components/machine-metrics.tsx +10 -24
  16. package/src/components/machines/health.ts +146 -0
  17. package/src/components/machines/machine-detail.tsx +220 -0
  18. package/src/components/machines/metric-history-chart.tsx +298 -0
  19. package/src/components/machines/metric-sparkline.tsx +76 -0
  20. package/src/components/machines/series.ts +113 -0
  21. package/src/components/machines-dashboard.tsx +13 -1
  22. package/src/components/queue-surface.tsx +578 -0
  23. package/src/components/sandbox-files.tsx +94 -9
  24. package/src/components/sandbox-workspace.tsx +186 -52
  25. package/src/components/session-status.tsx +0 -6
  26. package/src/components/workbench-changes.tsx +64 -20
  27. package/src/components/workspace-dock.tsx +146 -55
  28. package/src/hooks/use-composer.ts +369 -39
  29. package/src/hooks/use-session-control.ts +6 -7
  30. package/src/hooks/use-session-events.ts +3 -2
  31. package/src/hooks/use-session-lineage.ts +15 -6
  32. package/src/hooks/use-session.ts +10 -2
  33. package/src/hooks/use-turn-queue.ts +175 -47
  34. package/src/index.ts +13 -7
  35. package/src/machines.ts +16 -0
  36. package/src/provider.tsx +192 -5
  37. package/src/timeline/parsers.ts +43 -6
  38. package/src/timeline/projection.ts +24 -2
  39. package/styles/index.css +22 -0
  40. package/dist/chunk-NFYVQWIB.js +0 -1377
  41. package/dist/chunk-NFYVQWIB.js.map +0 -1
@@ -1,13 +1,14 @@
1
- import type { ClientModel, SessionStatus } from "@opengeni/sdk";
1
+ import type { ClientModel, EffectiveSessionControl } from "@opengeni/sdk";
2
2
  import {
3
3
  ArrowUpIcon,
4
+ ChevronDownIcon,
4
5
  FileIcon,
5
6
  ImageIcon,
6
7
  LoaderCircleIcon,
7
8
  PaperclipIcon,
9
+ PauseIcon,
8
10
  PlayIcon,
9
11
  RotateCwIcon,
10
- SquareIcon,
11
12
  XIcon,
12
13
  } from "lucide-react";
13
14
  import { AnimatePresence, motion } from "motion/react";
@@ -28,7 +29,7 @@ import {
28
29
  import { argHint } from "../commands/registry";
29
30
  import type { Notice, SlashCommand } from "../commands/types";
30
31
  import type { ComposerState } from "../hooks/use-composer";
31
- import { composerModeForKey, shouldSubmitOnKey } from "../hooks/use-composer";
32
+ import { shouldSteerOnKey, shouldSubmitOnKey } from "../hooks/use-composer";
32
33
  import type { UseFileAttachmentsResult } from "../hooks/use-file-attachments";
33
34
  import { defaultCommands } from "../commands/registry";
34
35
  import {
@@ -37,14 +38,25 @@ import {
37
38
  type SlashCommandContext,
38
39
  } from "../hooks/use-slash-commands";
39
40
  import { cn } from "../lib/cn";
40
- import { formatBytes } from "../lib/format";
41
+ import { formatBytes, formatRelativeTime } from "../lib/format";
41
42
  import { CommandPalette } from "./command-palette";
42
43
  import { ModelPicker } from "./model-picker";
43
44
 
44
45
  export type ChatComposerProps = {
45
46
  composer: ComposerState;
46
- /** Current session status; shows the stop control while a turn runs. */
47
- status?: SessionStatus | null | undefined;
47
+ /** Canonical workstream control, separate from lifecycle status. */
48
+ effectiveControl?: EffectiveSessionControl | null | undefined;
49
+ /** Waiting prompts already ahead of a normal Send. */
50
+ queuedAheadCount?: number | undefined;
51
+ /** Whether broader Workspace Resume is authorized for this viewer. */
52
+ canControlWorkspace?: boolean | undefined;
53
+ /** Optional host routes used to navigate from effective Pause blockers. */
54
+ controlLinks?:
55
+ | {
56
+ workspaceHref?: string | undefined;
57
+ sessionHref?: ((sessionId: string) => string) | undefined;
58
+ }
59
+ | undefined;
48
60
  placeholder?: string | undefined;
49
61
  disabled?: boolean | undefined;
50
62
  autoFocus?: boolean | undefined;
@@ -95,13 +107,7 @@ export type ChatComposerProps = {
95
107
  onClearView?: (() => void) | undefined;
96
108
  };
97
109
 
98
- const ACTIVE_STATUSES: ReadonlySet<SessionStatus> = new Set([
99
- "queued",
100
- "running",
101
- "requires_action",
102
- "recovering",
103
- "waiting_capacity",
104
- ]);
110
+ export const OPEN_WORKSTREAM_CONTROL_EVENT = "opengeni:open-workstream-control";
105
111
 
106
112
  /**
107
113
  * The chat composer — the only human-to-agent input surface. Plain chat in,
@@ -116,7 +122,10 @@ const ACTIVE_STATUSES: ReadonlySet<SessionStatus> = new Set([
116
122
  */
117
123
  export function ChatComposer({
118
124
  composer,
119
- status,
125
+ effectiveControl,
126
+ queuedAheadCount = 0,
127
+ canControlWorkspace = false,
128
+ controlLinks,
120
129
  placeholder,
121
130
  disabled,
122
131
  autoFocus,
@@ -135,7 +144,24 @@ export function ChatComposer({
135
144
  }: ChatComposerProps) {
136
145
  const textareaRef = useRef<HTMLTextAreaElement | null>(null);
137
146
  const fileInputRef = useRef<HTMLInputElement | null>(null);
138
- const active = status != null && ACTIVE_STATUSES.has(status);
147
+ const paused = effectiveControl?.state === "paused";
148
+ const [controlDetailsOpen, setControlDetailsOpen] = useState(false);
149
+ useEffect(() => {
150
+ const openControl = () => {
151
+ textareaRef.current?.scrollIntoView({ block: "end", behavior: "smooth" });
152
+ if (paused) {
153
+ setControlDetailsOpen(true);
154
+ return;
155
+ }
156
+ window.requestAnimationFrame(() => {
157
+ document
158
+ .querySelector<HTMLButtonElement>('button[aria-label="Pause this workstream"]')
159
+ ?.focus();
160
+ });
161
+ };
162
+ document.addEventListener(OPEN_WORKSTREAM_CONTROL_EVENT, openControl);
163
+ return () => document.removeEventListener(OPEN_WORKSTREAM_CONTROL_EVENT, openControl);
164
+ }, [paused]);
139
165
 
140
166
  // Block sends while attachments are still uploading so a message never
141
167
  // departs without the files the user attached to it. This gates BOTH the
@@ -300,7 +326,7 @@ export function ChatComposer({
300
326
  // depart without them. The send button is disabled in the same state.
301
327
  return;
302
328
  }
303
- void composer.send(undefined, composerModeForKey(event));
329
+ void (shouldSteerOnKey(event) ? composer.steer() : composer.send());
304
330
  }
305
331
  };
306
332
 
@@ -342,8 +368,9 @@ export function ChatComposer({
342
368
  (composer.error
343
369
  ? {
344
370
  tone: "error" as const,
345
- message:
346
- composer.error.message || "Sending failed your draft is still here. Try again.",
371
+ message: /control changed|paused while/i.test(composer.error.message)
372
+ ? "This workstream was paused while you were sending. Nothing was sent, and your draft is still here."
373
+ : composer.error.message || "Sending failed — your draft is still here. Try again.",
347
374
  }
348
375
  : null);
349
376
 
@@ -403,6 +430,26 @@ export function ChatComposer({
403
430
  </span>
404
431
  </div>
405
432
  ) : null}
433
+ {paused && effectiveControl ? (
434
+ <WorkstreamPausedStrip
435
+ control={effectiveControl}
436
+ queuedAheadCount={queuedAheadCount}
437
+ open={controlDetailsOpen}
438
+ busy={composer.resuming}
439
+ sending={composer.sending}
440
+ canControlWorkspace={canControlWorkspace}
441
+ controlLinks={controlLinks}
442
+ onOpenChange={setControlDetailsOpen}
443
+ onResume={() => void composer.resume()}
444
+ onResumeOption={(option) => void composer.resumeScope(option)}
445
+ />
446
+ ) : null}
447
+ {composer.restoredResources.length > 0 ? (
448
+ <RestoredResourceChips
449
+ resources={composer.restoredResources}
450
+ onRemove={composer.removeRestoredResource}
451
+ />
452
+ ) : null}
406
453
  {attachments && attachments.attachments.length > 0 ? (
407
454
  <AttachmentChips
408
455
  attachments={attachments.attachments}
@@ -418,7 +465,11 @@ export function ChatComposer({
418
465
  onChange={(event) => composer.setValue(event.target.value)}
419
466
  onKeyDown={onKeyDown}
420
467
  onPaste={handlePaste}
421
- placeholder={placeholder ?? "Message the agent…"}
468
+ placeholder={
469
+ paused && disabled !== true
470
+ ? "Sending will resume this workstream…"
471
+ : (placeholder ?? "Message the agent…")
472
+ }
422
473
  disabled={disabled}
423
474
  autoFocus={autoFocus}
424
475
  aria-label="Message the agent"
@@ -505,42 +556,27 @@ export function ChatComposer({
505
556
  </span>
506
557
  )}
507
558
  <span className="ml-auto flex shrink-0 items-center gap-1.5">
508
- <AnimatePresence initial={false}>
509
- {active || status === "paused" ? (
510
- <motion.button
511
- key="stop"
512
- type="button"
513
- initial={{ opacity: 0, scale: 0.8 }}
514
- animate={{ opacity: 1, scale: 1 }}
515
- exit={{ opacity: 0, scale: 0.8 }}
516
- transition={{ duration: 0.15, ease: "easeOut" }}
517
- onClick={() =>
518
- void (status === "paused" ? composer.resume() : composer.pause())
519
- }
520
- disabled={composer.pausing || composer.resuming}
521
- aria-label={status === "paused" ? "Resume the session" : "Pause the session"}
522
- title={
523
- status === "paused"
524
- ? "Resume the current inference before queued prompts"
525
- : "Pause the session; queued prompts are preserved"
526
- }
527
- className={cn(
528
- "inline-flex size-8 items-center justify-center rounded-og-md border border-og-border pointer-coarse:size-11",
529
- "bg-og-surface-2 text-og-fg-muted transition-colors duration-150",
530
- "hover:border-og-status-failed/50 hover:text-og-status-failed",
531
- "disabled:opacity-50",
532
- )}
533
- >
534
- {composer.pausing || composer.resuming ? (
535
- <LoaderCircleIcon className="size-3.5 animate-og-spin" />
536
- ) : status === "paused" ? (
537
- <PlayIcon className="size-3.5 fill-current" />
538
- ) : (
539
- <SquareIcon className="size-3 fill-current" />
540
- )}
541
- </motion.button>
542
- ) : null}
543
- </AnimatePresence>
559
+ {effectiveControl && !paused ? (
560
+ <button
561
+ type="button"
562
+ onClick={() => void composer.pause()}
563
+ disabled={composer.pausing || composer.resuming}
564
+ aria-label="Pause this workstream"
565
+ title="Pause this workstream; queued prompts and approvals are preserved"
566
+ className={cn(
567
+ "inline-flex size-8 items-center justify-center rounded-og-md border border-og-border pointer-coarse:size-11",
568
+ "bg-og-surface-2 text-og-fg-muted transition-colors duration-150",
569
+ "hover:border-og-status-waiting/50 hover:text-og-status-waiting",
570
+ "disabled:opacity-50",
571
+ )}
572
+ >
573
+ {composer.pausing || composer.resuming ? (
574
+ <LoaderCircleIcon className="size-3.5 animate-og-spin" />
575
+ ) : (
576
+ <PauseIcon className="size-3.5 fill-current" />
577
+ )}
578
+ </button>
579
+ ) : null}
544
580
  <button
545
581
  type="button"
546
582
  onClick={() => {
@@ -550,8 +586,12 @@ export function ChatComposer({
550
586
  void composer.send();
551
587
  }}
552
588
  disabled={!canSend || disabled === true || commandDraftBlocked}
553
- aria-label="Send message"
554
- title="Queue message (Enter); steer with Cmd/Ctrl+Enter"
589
+ aria-label={paused ? "Send and resume" : "Send message"}
590
+ title={
591
+ paused
592
+ ? "Send & resume (Enter); Steer & resume with Cmd/Ctrl+Enter"
593
+ : "Queue message (Enter); steer with Cmd/Ctrl+Enter"
594
+ }
555
595
  className={cn(
556
596
  "inline-flex size-8 items-center justify-center rounded-og-md pointer-coarse:size-11",
557
597
  "bg-og-accent text-og-accent-fg shadow-og-sm",
@@ -599,6 +639,218 @@ export function ChatComposer({
599
639
  </motion.p>
600
640
  ) : null}
601
641
  </AnimatePresence>
642
+ {composer.draftConflict ? (
643
+ <div
644
+ className="mt-1.5 flex flex-wrap items-center gap-2 px-1 text-og-xs text-og-status-failed"
645
+ role="alert"
646
+ >
647
+ <span className="min-w-0 flex-1">
648
+ This draft changed in another tab. Your local draft is still here.
649
+ </span>
650
+ <button
651
+ type="button"
652
+ className="underline underline-offset-2"
653
+ onClick={() => void composer.resolveDraftConflict("use_remote")}
654
+ >
655
+ Use other draft
656
+ </button>
657
+ <button
658
+ type="button"
659
+ className="font-medium underline underline-offset-2"
660
+ onClick={() => void composer.resolveDraftConflict("keep_mine")}
661
+ >
662
+ Keep mine
663
+ </button>
664
+ </div>
665
+ ) : composer.draftSaving ? (
666
+ <p className="px-1 pt-1 text-og-xs text-og-fg-subtle" role="status">
667
+ Saving draft…
668
+ </p>
669
+ ) : null}
670
+ </div>
671
+ );
672
+ }
673
+
674
+ function WorkstreamPausedStrip({
675
+ control,
676
+ queuedAheadCount,
677
+ open,
678
+ busy,
679
+ sending,
680
+ canControlWorkspace,
681
+ controlLinks,
682
+ onOpenChange,
683
+ onResume,
684
+ onResumeOption,
685
+ }: {
686
+ control: EffectiveSessionControl;
687
+ queuedAheadCount: number;
688
+ open: boolean;
689
+ busy: boolean;
690
+ sending: boolean;
691
+ canControlWorkspace: boolean;
692
+ controlLinks: ChatComposerProps["controlLinks"];
693
+ onOpenChange: (open: boolean) => void;
694
+ onResume: () => void;
695
+ onResumeOption: (option: EffectiveSessionControl["resumeOptions"][number]) => void;
696
+ }) {
697
+ const blocker = control.primaryBlocker;
698
+ const cause =
699
+ blocker?.kind === "workspace"
700
+ ? "Workspace paused"
701
+ : control.directState === "paused"
702
+ ? "Paused here"
703
+ : `Paused by ${blocker?.displayName ?? "parent"}`;
704
+ const primary = control.resumeOptions.find((option) => option.scope === "selected");
705
+ const broaderOptions = control.resumeOptions.filter(
706
+ (option) =>
707
+ option !== primary &&
708
+ option.scope !== "selected" &&
709
+ (option.scope !== "workspace" || canControlWorkspace),
710
+ );
711
+ return (
712
+ <div className="border-b border-og-status-waiting/25 bg-og-status-waiting/[0.07]">
713
+ <div className="flex min-w-0 items-center gap-2 px-3 py-2">
714
+ <PauseIcon className="size-3.5 shrink-0 text-og-status-waiting" />
715
+ <button
716
+ type="button"
717
+ className="min-w-0 flex-1 text-left"
718
+ onClick={() => onOpenChange(!open)}
719
+ aria-expanded={open}
720
+ >
721
+ <span className="block truncate text-og-xs font-medium text-og-fg">{cause}</span>
722
+ <span className="block truncate text-[11px] text-og-fg-muted">
723
+ {queuedAheadCount > 0
724
+ ? `Send & resume queues behind ${queuedAheadCount} waiting prompt${queuedAheadCount === 1 ? "" : "s"}.`
725
+ : sending
726
+ ? "Resuming and sending…"
727
+ : "Your next message resumes this workstream automatically."}
728
+ </span>
729
+ </button>
730
+ <button
731
+ type="button"
732
+ aria-label="Resume this workstream"
733
+ className="inline-flex min-h-8 shrink-0 items-center gap-1.5 rounded-og-md border border-og-status-waiting/35 bg-og-surface-1 px-2.5 text-og-xs font-medium text-og-fg hover:bg-og-surface-2 pointer-coarse:min-h-11"
734
+ disabled={busy}
735
+ onClick={onResume}
736
+ >
737
+ {busy ? (
738
+ <LoaderCircleIcon className="size-3.5 animate-og-spin" />
739
+ ) : (
740
+ <PlayIcon className="size-3.5 fill-current" />
741
+ )}
742
+ <span className="hidden min-[400px]:inline">Resume this workstream</span>
743
+ <span className="min-[400px]:hidden">Resume</span>
744
+ </button>
745
+ <button
746
+ type="button"
747
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-og-md text-og-fg-muted hover:bg-og-surface-2 pointer-coarse:size-11"
748
+ onClick={() => onOpenChange(!open)}
749
+ aria-label={open ? "Hide pause details" : "Show pause details"}
750
+ >
751
+ <ChevronDownIcon className={cn("size-3.5 transition-transform", open && "rotate-180")} />
752
+ </button>
753
+ </div>
754
+ {open ? (
755
+ <div className="border-t border-og-status-waiting/20 px-3 pb-3 pt-2">
756
+ <ul className="grid gap-2" aria-label="Reasons this workstream is paused">
757
+ {control.blockers.map((entry, index) => (
758
+ <li
759
+ key={`${entry.kind}-${entry.sessionId ?? "workspace"}-${entry.revision}`}
760
+ className="text-og-xs"
761
+ >
762
+ <span className="font-medium text-og-fg">
763
+ {index === 0 ? "Paused by " : "Also paused by "}
764
+ </span>
765
+ {blockerHref(entry, controlLinks) ? (
766
+ <a
767
+ href={blockerHref(entry, controlLinks)}
768
+ className="font-medium text-og-fg underline decoration-og-border-strong underline-offset-2 hover:text-og-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-ring/40"
769
+ >
770
+ {entry.displayName}
771
+ </a>
772
+ ) : (
773
+ <span className="font-medium text-og-fg">{entry.displayName}</span>
774
+ )}
775
+ {entry.reason ? <span className="text-og-fg-muted"> · {entry.reason}</span> : null}
776
+ {entry.actor ? <span className="text-og-fg-subtle"> · {entry.actor}</span> : null}
777
+ {entry.changedAt ? (
778
+ <span className="text-og-fg-subtle">
779
+ {" "}
780
+ · {formatRelativeTime(entry.changedAt)}
781
+ </span>
782
+ ) : null}
783
+ </li>
784
+ ))}
785
+ </ul>
786
+ {broaderOptions.length > 0 ? (
787
+ <div className="mt-2 flex flex-wrap gap-1.5">
788
+ {broaderOptions.map((option) => (
789
+ <button
790
+ key={`${option.scope}-${option.targetId ?? "selected"}`}
791
+ type="button"
792
+ disabled={busy}
793
+ title={option.impactCopy}
794
+ onClick={() => onResumeOption(option)}
795
+ className="rounded-og-md border border-og-border bg-og-surface-1 px-2 py-1 text-og-xs text-og-fg-muted hover:bg-og-surface-2 hover:text-og-fg pointer-coarse:min-h-10"
796
+ >
797
+ <span className="block font-medium">
798
+ {option.scope === "workspace" ? "Resume workspace" : "Resume from this session"}
799
+ </span>
800
+ <span className="block text-[10px] text-og-fg-subtle">
801
+ {option.selectedStateAfter === "active"
802
+ ? "This session will be able to run"
803
+ : `Still paused by ${option.remainingPrimaryBlocker?.displayName ?? "a narrower pause"}`}
804
+ </span>
805
+ </button>
806
+ ))}
807
+ </div>
808
+ ) : null}
809
+ </div>
810
+ ) : null}
811
+ </div>
812
+ );
813
+ }
814
+
815
+ function blockerHref(
816
+ blocker: EffectiveSessionControl["blockers"][number],
817
+ links: ChatComposerProps["controlLinks"],
818
+ ): string | undefined {
819
+ if (blocker.kind === "workspace") return links?.workspaceHref;
820
+ return blocker.sessionId ? links?.sessionHref?.(blocker.sessionId) : undefined;
821
+ }
822
+
823
+ function RestoredResourceChips({
824
+ resources,
825
+ onRemove,
826
+ }: {
827
+ resources: ComposerState["restoredResources"];
828
+ onRemove: (index: number) => void;
829
+ }) {
830
+ return (
831
+ <div
832
+ className="flex flex-wrap gap-1.5 border-b border-og-border px-3 py-2"
833
+ aria-label="Restored prompt resources"
834
+ >
835
+ {resources.map((resource, index) => (
836
+ <span
837
+ key={`${resource.kind}-${resource.kind === "file" ? resource.fileId : resource.uri}`}
838
+ className="inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-og-md border border-og-border bg-og-surface-2 px-2 py-1 text-og-xs text-og-fg-muted"
839
+ >
840
+ <FileIcon className="size-3 shrink-0" />
841
+ <span className="truncate">
842
+ {resource.kind === "file" ? `File ${resource.fileId.slice(0, 8)}` : resource.uri}
843
+ </span>
844
+ <button
845
+ type="button"
846
+ className="shrink-0 hover:text-og-fg"
847
+ onClick={() => onRemove(index)}
848
+ aria-label={`Remove restored resource ${index + 1}`}
849
+ >
850
+ <XIcon className="size-3" />
851
+ </button>
852
+ </span>
853
+ ))}
602
854
  </div>
603
855
  );
604
856
  }
@@ -1,4 +1,5 @@
1
1
  import {
2
+ ArrowRightIcon,
2
3
  CpuIcon,
3
4
  LaptopIcon,
4
5
  MonitorIcon,
@@ -8,9 +9,10 @@ import {
8
9
  } from "lucide-react";
9
10
  import { cn } from "../lib/cn";
10
11
  import { formatRelativeTime } from "../lib/format";
11
- import type { MachineView } from "../types/machines";
12
+ import type { MachineView, MetricSample } from "../types/machines";
13
+ import { MachineHealthPill } from "./machine-health-pill";
12
14
  import { MachineMetrics } from "./machine-metrics";
13
- import { MachineStatusPill } from "./machine-status-pill";
15
+ import { MetricSparkline } from "./machines/metric-sparkline";
14
16
 
15
17
  export type MachineCardProps = {
16
18
  machine: MachineView;
@@ -18,6 +20,11 @@ export type MachineCardProps = {
18
20
  onAttach?: ((machine: MachineView) => void) | undefined;
19
21
  /** Whether an attach/swap is currently in flight (disables the affordance). */
20
22
  attaching?: boolean | undefined;
23
+ /** Short recent history (e.g. 15m) — drives the card's CPU trend + preview. */
24
+ series?: MetricSample[] | undefined;
25
+ /** Open the full per-machine telemetry detail. Makes the whole card actionable. */
26
+ onOpenDetail?: ((machine: MachineView) => void) | undefined;
27
+ now?: number | undefined;
21
28
  className?: string | undefined;
22
29
  };
23
30
 
@@ -29,23 +36,52 @@ function KindIcon({ machine }: { machine: MachineView }) {
29
36
  }
30
37
 
31
38
  /**
32
- * One machine in the Machines dashboard: name + kind + OS/arch, the composite
33
- * connection/state/shared status, latest resource meters, last-seen recency, and
34
- * an attach/swap affordance. The session's currently-active sandbox carries an
35
- * accent edge + an "Active" marker (never an attach button — it's already active).
39
+ * One machine in the Machines dashboard: name + kind + OS/arch, the fused HEALTH
40
+ * verdict, latest resource meters, a CPU trend that previews the history, live
41
+ * freshness, and attach/swap. The whole card opens the telemetry detail when
42
+ * `onOpenDetail` is wired. The session's active sandbox carries an accent edge.
36
43
  */
37
- export function MachineCard({ machine, onAttach, attaching, className }: MachineCardProps) {
44
+ export function MachineCard({
45
+ machine,
46
+ onAttach,
47
+ attaching,
48
+ series,
49
+ onOpenDetail,
50
+ now,
51
+ className,
52
+ }: MachineCardProps) {
38
53
  const offline = machine.state === "offline";
39
54
  const attachable = !machine.active && !offline && Boolean(onAttach);
40
55
  const shared = machine.sharedSessionCount > 1;
56
+ const clockNow = now ?? Date.now();
57
+ const sampledAgo = machine.metrics
58
+ ? formatRelativeTime(machine.metrics.sampledAt, new Date(clockNow))
59
+ : null;
60
+ const cpuPts = (series ?? []).map((s) => ({ t: new Date(s.sampledAt).getTime(), v: s.cpuPct }));
61
+ const openable = Boolean(onOpenDetail);
41
62
 
42
63
  return (
43
64
  <div
44
65
  data-machine-card={machine.sandboxId}
45
66
  data-active={machine.active ? "true" : "false"}
67
+ role={openable ? "button" : undefined}
68
+ tabIndex={openable ? 0 : undefined}
69
+ onClick={openable ? () => onOpenDetail?.(machine) : undefined}
70
+ onKeyDown={
71
+ openable
72
+ ? (e) => {
73
+ if (e.key === "Enter" || e.key === " ") {
74
+ e.preventDefault();
75
+ onOpenDetail?.(machine);
76
+ }
77
+ }
78
+ : undefined
79
+ }
46
80
  className={cn(
47
- "og-root relative flex flex-col gap-3 overflow-hidden rounded-og-lg border border-og-border",
48
- "bg-og-surface-1 p-4 shadow-og-sm",
81
+ "og-root group relative flex flex-col gap-3 overflow-hidden rounded-og-lg border border-og-border",
82
+ "bg-og-surface-1 p-4 shadow-og-sm transition-colors",
83
+ openable &&
84
+ "cursor-pointer hover:border-og-border-strong focus-visible:border-og-accent/60 focus-visible:outline-none",
49
85
  machine.active && "border-og-accent/40",
50
86
  className,
51
87
  )}
@@ -91,9 +127,10 @@ export function MachineCard({ machine, onAttach, attaching, className }: Machine
91
127
  </div>
92
128
  </div>
93
129
  </div>
94
- <MachineStatusPill
130
+ <MachineHealthPill
95
131
  state={machine.state}
96
- sharedSessionCount={machine.sharedSessionCount}
132
+ metrics={machine.metrics}
133
+ now={clockNow}
97
134
  size="sm"
98
135
  className="shrink-0"
99
136
  />
@@ -111,10 +148,36 @@ export function MachineCard({ machine, onAttach, attaching, className }: Machine
111
148
 
112
149
  <MachineMetrics metrics={machine.metrics} />
113
150
 
151
+ {/* CPU trend — previews the history and invites opening the detail. */}
152
+ {cpuPts.length > 1 ? (
153
+ <div className="flex flex-col gap-1 rounded-og-md bg-og-surface-2/40 p-2.5">
154
+ <div className="flex items-center justify-between text-[10px] font-medium uppercase tracking-wide text-og-fg-subtle">
155
+ <span>CPU · last 15m</span>
156
+ {openable ? (
157
+ <span className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
158
+ Telemetry <ArrowRightIcon className="size-2.5" aria-hidden />
159
+ </span>
160
+ ) : null}
161
+ </div>
162
+ <MetricSparkline points={cpuPts} color="var(--og-color-accent)" yMax={100} height={26} />
163
+ </div>
164
+ ) : null}
165
+
114
166
  <div className="mt-1 flex items-center justify-between gap-3 text-og-xs text-og-fg-subtle">
115
- <span>
116
- {machine.lastSeenAt ? (
117
- <>Last seen {formatRelativeTime(machine.lastSeenAt)}</>
167
+ <span className="inline-flex items-center gap-1.5">
168
+ {sampledAgo ? (
169
+ <>
170
+ <span
171
+ className={cn(
172
+ "size-1.5 rounded-full",
173
+ sampledAgo === "now" ? "bg-og-status-idle" : "bg-og-fg-subtle",
174
+ )}
175
+ aria-hidden
176
+ />
177
+ {sampledAgo === "now" ? "Live" : `Updated ${sampledAgo} ago`}
178
+ </>
179
+ ) : machine.lastSeenAt ? (
180
+ <>Last seen {formatRelativeTime(machine.lastSeenAt, new Date(clockNow))}</>
118
181
  ) : (
119
182
  "Never connected"
120
183
  )}
@@ -126,7 +189,10 @@ export function MachineCard({ machine, onAttach, attaching, className }: Machine
126
189
  type="button"
127
190
  data-attach
128
191
  disabled={attaching}
129
- onClick={() => onAttach?.(machine)}
192
+ onClick={(e) => {
193
+ e.stopPropagation();
194
+ onAttach?.(machine);
195
+ }}
130
196
  className={cn(
131
197
  "rounded-og-sm border border-og-border px-2.5 py-1 text-og-xs font-medium text-og-fg-muted transition-colors pointer-coarse:min-h-10",
132
198
  "hover:border-og-border-strong hover:text-og-fg disabled:cursor-not-allowed disabled:opacity-50",