@opengeni/react 0.13.0 → 0.20.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 (113) hide show
  1. package/README.md +129 -23
  2. package/dist/chunk-6BXBGJ3B.js +1956 -0
  3. package/dist/chunk-6BXBGJ3B.js.map +1 -0
  4. package/dist/chunk-6UT5OC3P.js +2133 -0
  5. package/dist/chunk-6UT5OC3P.js.map +1 -0
  6. package/dist/chunk-EBTT3MYN.js +92 -0
  7. package/dist/chunk-EBTT3MYN.js.map +1 -0
  8. package/dist/chunk-HHFA4AFX.js +425 -0
  9. package/dist/chunk-HHFA4AFX.js.map +1 -0
  10. package/dist/chunk-LAZ2A743.js +2393 -0
  11. package/dist/chunk-LAZ2A743.js.map +1 -0
  12. package/dist/chunk-MRSECXRP.js +563 -0
  13. package/dist/chunk-MRSECXRP.js.map +1 -0
  14. package/dist/chunk-TK7G6XLT.js +18 -0
  15. package/dist/chunk-TK7G6XLT.js.map +1 -0
  16. package/dist/composer-DvUaa5ki.d.ts +585 -0
  17. package/dist/composer.d.ts +6 -0
  18. package/dist/composer.js +56 -0
  19. package/dist/composer.js.map +1 -0
  20. package/dist/index.d.ts +642 -1080
  21. package/dist/index.js +7672 -8005
  22. package/dist/index.js.map +1 -1
  23. package/dist/machines.d.ts +412 -3
  24. package/dist/machines.js +25 -1
  25. package/dist/noto-sans-arabic-loader-IA2T4X2A.js +21 -0
  26. package/dist/noto-sans-arabic-loader-IA2T4X2A.js.map +1 -0
  27. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js +292 -0
  28. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js.map +1 -0
  29. package/dist/queue-surface-implementation-NQMV2ML3.js +890 -0
  30. package/dist/queue-surface-implementation-NQMV2ML3.js.map +1 -0
  31. package/dist/session-Gd4iVPYw.d.ts +425 -0
  32. package/dist/session-context-DIFFlXKc.d.ts +52 -0
  33. package/dist/session.d.ts +4 -0
  34. package/dist/session.js +50 -0
  35. package/dist/session.js.map +1 -0
  36. package/dist/use-composer-BCfm4mzX.d.ts +112 -0
  37. package/package.json +23 -4
  38. package/src/approvals.ts +5 -3
  39. package/src/client.ts +50 -1
  40. package/src/components/approval-surface.tsx +198 -0
  41. package/src/components/chat-composer.tsx +104 -751
  42. package/src/components/code-editor.tsx +104 -26
  43. package/src/components/command-palette.tsx +8 -2
  44. package/src/components/composer-transcription-control.tsx +211 -0
  45. package/src/components/composer.tsx +1536 -0
  46. package/src/components/desktop-viewer.tsx +3 -3
  47. package/src/components/enrollment-consent.tsx +2 -2
  48. package/src/components/file-browser.tsx +367 -29
  49. package/src/components/human-input-form.tsx +417 -0
  50. package/src/components/machine-card.tsx +82 -16
  51. package/src/components/machine-health-pill.tsx +68 -0
  52. package/src/components/machine-metrics.tsx +10 -24
  53. package/src/components/machines/health.ts +146 -0
  54. package/src/components/machines/machine-detail.tsx +220 -0
  55. package/src/components/machines/metric-history-chart.tsx +298 -0
  56. package/src/components/machines/metric-sparkline.tsx +76 -0
  57. package/src/components/machines/series.ts +113 -0
  58. package/src/components/machines-dashboard.tsx +16 -4
  59. package/src/components/message-timeline.tsx +15 -7
  60. package/src/components/model-picker.tsx +12 -3
  61. package/src/components/pierre-diff.tsx +32 -6
  62. package/src/components/queue-surface-implementation.tsx +1041 -0
  63. package/src/components/queue-surface-state.tsx +94 -0
  64. package/src/components/queue-surface.tsx +60 -0
  65. package/src/components/sandbox-files.tsx +230 -21
  66. package/src/components/sandbox-terminal.tsx +8 -2
  67. package/src/components/sandbox-workspace.tsx +489 -135
  68. package/src/components/session-status.tsx +0 -6
  69. package/src/components/workbench-changes.tsx +145 -50
  70. package/src/components/workspace-dock.tsx +329 -68
  71. package/src/composer.ts +60 -0
  72. package/src/hooks/internal.ts +115 -34
  73. package/src/hooks/use-composer.ts +635 -74
  74. package/src/hooks/use-human-input.ts +131 -0
  75. package/src/hooks/use-machine-chip.ts +2 -2
  76. package/src/hooks/use-machines.ts +27 -11
  77. package/src/hooks/use-sandbox-files.ts +310 -62
  78. package/src/hooks/use-sandbox-git.ts +154 -40
  79. package/src/hooks/use-sandbox-terminal.ts +28 -6
  80. package/src/hooks/use-session-capabilities.ts +38 -9
  81. package/src/hooks/use-session-control.ts +43 -15
  82. package/src/hooks/use-session-events.ts +523 -48
  83. package/src/hooks/use-session-lineage.ts +15 -6
  84. package/src/hooks/use-session.ts +10 -2
  85. package/src/hooks/use-slash-commands.ts +38 -38
  86. package/src/hooks/use-transcription.ts +757 -0
  87. package/src/hooks/use-turn-queue.ts +273 -77
  88. package/src/hooks/use-windowed-sections.ts +2 -2
  89. package/src/hooks/use-workspace-capture.ts +146 -40
  90. package/src/hooks/use-workspace-edit.ts +50 -14
  91. package/src/hooks/use-workspace-sessions.ts +3 -0
  92. package/src/human-input.ts +72 -0
  93. package/src/index.ts +88 -12
  94. package/src/lib/noto-sans-arabic-loader.ts +21 -0
  95. package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
  96. package/src/lib/use-portal-token-style.ts +51 -0
  97. package/src/lib/use-unicode-fonts.ts +57 -0
  98. package/src/machines.ts +16 -0
  99. package/src/provider.tsx +163 -41
  100. package/src/session-context.ts +135 -0
  101. package/src/session.ts +79 -0
  102. package/src/timeline/parsers.ts +78 -6
  103. package/src/timeline/projection.ts +36 -6
  104. package/src/timeline/tool-renderers.tsx +41 -0
  105. package/src/timeline/turn-summary.tsx +2 -2
  106. package/src/timeline/types.ts +16 -8
  107. package/styles/index.css +78 -0
  108. package/styles/index.d.ts +2 -0
  109. package/styles/tokens.css +11 -6
  110. package/styles/tokens.d.ts +2 -0
  111. package/dist/chunk-NFYVQWIB.js +0 -1377
  112. package/dist/chunk-NFYVQWIB.js.map +0 -1
  113. package/dist/machines-CnlMb7E-.d.ts +0 -329
@@ -0,0 +1,417 @@
1
+ import type {
2
+ HumanInputAnswer,
3
+ HumanInputQuestion,
4
+ SessionHumanInputRequest,
5
+ SubmitHumanInputResponseRequest,
6
+ } from "@opengeni/sdk";
7
+ import { useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from "react";
8
+ import { cn } from "../lib/cn";
9
+
10
+ export type HumanInputAnswerDraft = {
11
+ values: string[];
12
+ other: string;
13
+ otherSelected: boolean;
14
+ };
15
+
16
+ export type HumanInputFormMessages = {
17
+ title: string;
18
+ description: string;
19
+ submit: string;
20
+ skip: string;
21
+ submitting: string;
22
+ other: string;
23
+ deadlineLabel: string;
24
+ formatDeadline: (value: string) => string;
25
+ required: string;
26
+ minLength: (count: number) => string;
27
+ maxLength: (count: number) => string;
28
+ otherRequired: string;
29
+ minSelections: (count: number) => string;
30
+ maxSelections: (count: number) => string;
31
+ };
32
+
33
+ export const defaultHumanInputFormMessages: HumanInputFormMessages = {
34
+ title: "Your input is needed",
35
+ description: "The agent is paused until you answer these questions.",
36
+ submit: "Continue",
37
+ skip: "Skip",
38
+ submitting: "Submitting…",
39
+ other: "Other",
40
+ deadlineLabel: "Respond before",
41
+ formatDeadline,
42
+ required: "This question is required.",
43
+ minLength: (count) => `Enter at least ${count} characters.`,
44
+ maxLength: (count) => `Enter no more than ${count} characters.`,
45
+ otherRequired: "Enter a value for Other.",
46
+ minSelections: (count) => `Choose at least ${count} option${count === 1 ? "" : "s"}.`,
47
+ maxSelections: (count) => `Choose no more than ${count} option${count === 1 ? "" : "s"}.`,
48
+ };
49
+
50
+ export type HumanInputFormProps = {
51
+ request: Pick<SessionHumanInputRequest, "id" | "questions" | "allowSkip" | "expiresAt">;
52
+ onSubmit: (response: SubmitHumanInputResponseRequest) => void | Promise<void>;
53
+ submitting?: boolean | undefined;
54
+ error?: string | null | undefined;
55
+ title?: ReactNode;
56
+ description?: ReactNode;
57
+ submitLabel?: string | undefined;
58
+ skipLabel?: string | undefined;
59
+ messages?: Partial<HumanInputFormMessages> | undefined;
60
+ autoFocus?: boolean | undefined;
61
+ className?: string | undefined;
62
+ };
63
+
64
+ /**
65
+ * Styled but host-neutral renderer for one structured request. Hosts can use
66
+ * the headless `useHumanInputRequests` hook instead, or replace the title and
67
+ * description while retaining accessible field semantics and validation.
68
+ */
69
+ export function HumanInputForm({
70
+ request,
71
+ onSubmit,
72
+ submitting = false,
73
+ error,
74
+ title,
75
+ description,
76
+ submitLabel,
77
+ skipLabel,
78
+ messages: messageOverrides,
79
+ autoFocus = true,
80
+ className,
81
+ }: HumanInputFormProps) {
82
+ const messages = { ...defaultHumanInputFormMessages, ...messageOverrides };
83
+ const resolvedTitle = title === undefined ? messages.title : title;
84
+ const resolvedDescription = description === undefined ? messages.description : description;
85
+ const resolvedSubmitLabel = submitLabel ?? messages.submit;
86
+ const resolvedSkipLabel = skipLabel ?? messages.skip;
87
+ const formId = useId();
88
+ const [drafts, setDrafts] = useState<Record<string, HumanInputAnswerDraft>>(() =>
89
+ initialDrafts(request.questions),
90
+ );
91
+ const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
92
+ const [submissionError, setSubmissionError] = useState<string | null>(null);
93
+ const [submittingInternally, setSubmittingInternally] = useState(false);
94
+ const submissionInFlight = useRef(false);
95
+ const submissionGeneration = useRef(0);
96
+ const busy = submitting || submittingInternally;
97
+
98
+ useEffect(() => {
99
+ submissionGeneration.current += 1;
100
+ submissionInFlight.current = false;
101
+ setDrafts(initialDrafts(request.questions));
102
+ setValidationErrors({});
103
+ setSubmissionError(null);
104
+ setSubmittingInternally(false);
105
+ }, [request.id, request.questions]);
106
+
107
+ const update = (
108
+ questionId: string,
109
+ apply: (draft: HumanInputAnswerDraft) => HumanInputAnswerDraft,
110
+ ): void => {
111
+ setDrafts((current) => ({
112
+ ...current,
113
+ [questionId]: apply(current[questionId] ?? emptyDraft()),
114
+ }));
115
+ setValidationErrors((current) => {
116
+ if (!(questionId in current)) return current;
117
+ const next = { ...current };
118
+ delete next[questionId];
119
+ return next;
120
+ });
121
+ };
122
+
123
+ const submitResponse = async (response: SubmitHumanInputResponseRequest): Promise<void> => {
124
+ if (busy || submissionInFlight.current) return;
125
+ const generation = submissionGeneration.current;
126
+ submissionInFlight.current = true;
127
+ setSubmissionError(null);
128
+ setSubmittingInternally(true);
129
+ try {
130
+ await onSubmit(response);
131
+ } catch (cause) {
132
+ if (generation === submissionGeneration.current) {
133
+ setSubmissionError(cause instanceof Error ? cause.message : String(cause));
134
+ }
135
+ } finally {
136
+ if (generation === submissionGeneration.current) {
137
+ submissionInFlight.current = false;
138
+ setSubmittingInternally(false);
139
+ }
140
+ }
141
+ };
142
+
143
+ const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
144
+ event.preventDefault();
145
+ const result = answersFromDrafts(request.questions, drafts, messages);
146
+ if (Object.keys(result.errors).length > 0) {
147
+ setValidationErrors(result.errors);
148
+ return;
149
+ }
150
+ await submitResponse({ outcome: "answered", answers: result.answers });
151
+ };
152
+
153
+ return (
154
+ <form
155
+ data-human-input-request={request.id}
156
+ onSubmit={(event) => void submit(event)}
157
+ className={cn(
158
+ "og-root flex w-full flex-col gap-5 rounded-og-lg border border-og-border bg-og-surface-1 p-5 shadow-og-sm",
159
+ className,
160
+ )}
161
+ >
162
+ <header>
163
+ <h2 className="text-og-md font-semibold text-og-fg">{resolvedTitle}</h2>
164
+ {resolvedDescription ? (
165
+ <div className="mt-1 text-og-sm text-og-fg-muted">{resolvedDescription}</div>
166
+ ) : null}
167
+ {request.expiresAt ? (
168
+ <p className="mt-1 text-og-xs text-og-fg-subtle">
169
+ {messages.deadlineLabel}{" "}
170
+ <time dateTime={request.expiresAt}>{messages.formatDeadline(request.expiresAt)}</time>
171
+ </p>
172
+ ) : null}
173
+ </header>
174
+
175
+ <fieldset disabled={busy} className="contents">
176
+ {request.questions.map((question, index) => {
177
+ const draft = drafts[question.id] ?? emptyDraft();
178
+ const fieldId = `${formId}-${index}`;
179
+ const errorId = `${fieldId}-error`;
180
+ const helpId = `${fieldId}-help`;
181
+ const describedBy =
182
+ [question.helpText ? helpId : null, validationErrors[question.id] ? errorId : null]
183
+ .filter(Boolean)
184
+ .join(" ") || undefined;
185
+ return (
186
+ <div key={question.id} className="flex flex-col gap-2">
187
+ <label
188
+ htmlFor={question.kind === "text" ? fieldId : undefined}
189
+ className="text-og-base font-medium text-og-fg"
190
+ >
191
+ {question.label ?? question.prompt}
192
+ {question.required ? (
193
+ <span aria-hidden className="ml-1 text-og-status-failed">
194
+ *
195
+ </span>
196
+ ) : null}
197
+ </label>
198
+ {question.label ? (
199
+ <p className="text-og-sm text-og-fg-muted">{question.prompt}</p>
200
+ ) : null}
201
+ {question.helpText ? (
202
+ <p id={helpId} className="text-og-xs text-og-fg-subtle">
203
+ {question.helpText}
204
+ </p>
205
+ ) : null}
206
+ {question.kind === "text" ? (
207
+ <textarea
208
+ id={fieldId}
209
+ value={draft.values[0] ?? ""}
210
+ onChange={(event) =>
211
+ update(question.id, (current) => ({
212
+ ...current,
213
+ values: event.target.value ? [event.target.value] : [],
214
+ }))
215
+ }
216
+ aria-invalid={Boolean(validationErrors[question.id])}
217
+ aria-describedby={describedBy}
218
+ autoFocus={autoFocus && index === 0}
219
+ rows={3}
220
+ className="min-h-20 w-full resize-y rounded-og-md border border-og-border bg-og-surface-2 px-3 py-2 text-og-base text-og-fg outline-none placeholder:text-og-fg-subtle focus:border-og-accent"
221
+ />
222
+ ) : (
223
+ <div
224
+ role={question.kind === "single_select" ? "radiogroup" : "group"}
225
+ aria-describedby={describedBy}
226
+ className="flex flex-col gap-2"
227
+ >
228
+ {question.options.map((option, optionIndex) => {
229
+ const checked = draft.values.includes(option.id);
230
+ return (
231
+ <label
232
+ key={option.id}
233
+ className="flex cursor-pointer items-start gap-2 rounded-og-md border border-og-border px-3 py-2 hover:bg-og-surface-2"
234
+ >
235
+ <input
236
+ type={question.kind === "single_select" ? "radio" : "checkbox"}
237
+ name={question.kind === "single_select" ? fieldId : undefined}
238
+ autoFocus={autoFocus && index === 0 && optionIndex === 0}
239
+ checked={checked}
240
+ onChange={(event) =>
241
+ update(question.id, (current) => ({
242
+ ...current,
243
+ values:
244
+ question.kind === "single_select"
245
+ ? event.target.checked
246
+ ? [option.id]
247
+ : []
248
+ : event.target.checked
249
+ ? [...current.values, option.id]
250
+ : current.values.filter((value) => value !== option.id),
251
+ ...(question.kind === "single_select" && event.target.checked
252
+ ? { otherSelected: false }
253
+ : {}),
254
+ }))
255
+ }
256
+ className="mt-0.5 accent-og-accent"
257
+ />
258
+ <span className="min-w-0">
259
+ <span className="block text-og-sm font-medium text-og-fg">
260
+ {option.label}
261
+ </span>
262
+ {option.description ? (
263
+ <span className="block text-og-xs text-og-fg-muted">
264
+ {option.description}
265
+ </span>
266
+ ) : null}
267
+ </span>
268
+ </label>
269
+ );
270
+ })}
271
+ {question.allowOther ? (
272
+ <label className="flex items-start gap-2 rounded-og-md border border-og-border px-3 py-2">
273
+ <input
274
+ type={question.kind === "single_select" ? "radio" : "checkbox"}
275
+ name={question.kind === "single_select" ? fieldId : undefined}
276
+ checked={draft.otherSelected}
277
+ autoFocus={autoFocus && index === 0 && question.options.length === 0}
278
+ onChange={(event) =>
279
+ update(question.id, (current) => ({
280
+ ...current,
281
+ otherSelected: event.target.checked,
282
+ ...(question.kind === "single_select" && event.target.checked
283
+ ? { values: [] }
284
+ : {}),
285
+ }))
286
+ }
287
+ className="mt-2 accent-og-accent"
288
+ />
289
+ <span className="min-w-0 flex-1">
290
+ <span className="block text-og-sm font-medium text-og-fg">
291
+ {messages.other}
292
+ </span>
293
+ <input
294
+ type="text"
295
+ value={draft.other}
296
+ disabled={!draft.otherSelected || busy}
297
+ onChange={(event) =>
298
+ update(question.id, (current) => ({
299
+ ...current,
300
+ other: event.target.value,
301
+ }))
302
+ }
303
+ className="mt-1 w-full rounded-og-sm border border-og-border bg-og-surface-2 px-2 py-1.5 text-og-sm text-og-fg outline-none focus:border-og-accent disabled:opacity-50"
304
+ />
305
+ </span>
306
+ </label>
307
+ ) : null}
308
+ </div>
309
+ )}
310
+ {validationErrors[question.id] ? (
311
+ <p id={errorId} role="alert" className="text-og-xs text-og-status-failed">
312
+ {validationErrors[question.id]}
313
+ </p>
314
+ ) : null}
315
+ </div>
316
+ );
317
+ })}
318
+ </fieldset>
319
+
320
+ {(error ?? submissionError) ? (
321
+ <p role="alert" className="text-og-sm text-og-status-failed">
322
+ {error ?? submissionError}
323
+ </p>
324
+ ) : null}
325
+ <footer className="flex items-center justify-end gap-2">
326
+ {request.allowSkip ? (
327
+ <button
328
+ type="button"
329
+ disabled={busy}
330
+ onClick={() => void submitResponse({ outcome: "skipped" })}
331
+ className="rounded-og-md border border-og-border px-3 py-2 text-og-sm font-medium text-og-fg-muted hover:bg-og-surface-2 disabled:opacity-50"
332
+ >
333
+ {resolvedSkipLabel}
334
+ </button>
335
+ ) : null}
336
+ <button
337
+ type="submit"
338
+ disabled={busy}
339
+ className="rounded-og-md bg-og-accent px-3 py-2 text-og-sm font-medium text-og-accent-fg hover:bg-og-accent-strong disabled:opacity-50"
340
+ >
341
+ {busy ? messages.submitting : resolvedSubmitLabel}
342
+ </button>
343
+ </footer>
344
+ </form>
345
+ );
346
+ }
347
+
348
+ export function answersFromDrafts(
349
+ questions: HumanInputQuestion[],
350
+ drafts: Record<string, HumanInputAnswerDraft>,
351
+ messageOverrides: Partial<HumanInputFormMessages> = {},
352
+ ): { answers: HumanInputAnswer[]; errors: Record<string, string> } {
353
+ const messages = { ...defaultHumanInputFormMessages, ...messageOverrides };
354
+ const answers: HumanInputAnswer[] = [];
355
+ const errors: Record<string, string> = {};
356
+ for (const question of questions) {
357
+ const draft = drafts[question.id] ?? emptyDraft();
358
+ const values = question.kind === "text" ? draft.values.filter(Boolean) : draft.values;
359
+ const other = draft.otherSelected ? draft.other.trim() : "";
360
+ const supplied = values.length + (other ? 1 : 0);
361
+ if (question.required && supplied === 0) {
362
+ errors[question.id] = messages.required;
363
+ continue;
364
+ }
365
+ if (question.kind === "text") {
366
+ const value = values[0] ?? "";
367
+ if (
368
+ value &&
369
+ question.validation?.minLength != null &&
370
+ value.length < question.validation.minLength
371
+ ) {
372
+ errors[question.id] = messages.minLength(question.validation.minLength);
373
+ continue;
374
+ }
375
+ if (question.validation?.maxLength != null && value.length > question.validation.maxLength) {
376
+ errors[question.id] = messages.maxLength(question.validation.maxLength);
377
+ continue;
378
+ }
379
+ } else {
380
+ if (draft.otherSelected && !other) {
381
+ errors[question.id] = messages.otherRequired;
382
+ continue;
383
+ }
384
+ const min = question.validation?.minSelections;
385
+ const max = question.kind === "single_select" ? 1 : question.validation?.maxSelections;
386
+ if (min != null && supplied < min) {
387
+ errors[question.id] = messages.minSelections(min);
388
+ continue;
389
+ }
390
+ if (max != null && supplied > max) {
391
+ errors[question.id] = messages.maxSelections(max);
392
+ continue;
393
+ }
394
+ }
395
+ if (supplied > 0) {
396
+ answers.push({
397
+ questionId: question.id,
398
+ values,
399
+ ...(other ? { other } : {}),
400
+ });
401
+ }
402
+ }
403
+ return { answers, errors };
404
+ }
405
+
406
+ function initialDrafts(questions: HumanInputQuestion[]): Record<string, HumanInputAnswerDraft> {
407
+ return Object.fromEntries(questions.map((question) => [question.id, emptyDraft()]));
408
+ }
409
+
410
+ function emptyDraft(): HumanInputAnswerDraft {
411
+ return { values: [], other: "", otherSelected: false };
412
+ }
413
+
414
+ function formatDeadline(value: string): string {
415
+ const date = new Date(value);
416
+ return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
417
+ }
@@ -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,9 +189,12 @@ 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
- "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",
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-11",
132
198
  "hover:border-og-border-strong hover:text-og-fg disabled:cursor-not-allowed disabled:opacity-50",
133
199
  )}
134
200
  >
@@ -0,0 +1,68 @@
1
+ // The card's primary status: the fused HEALTH verdict (dot + label), which
2
+ // subsumes plain connection state (offline/reconnecting fall out of it) and adds
3
+ // resource-pressure + staleness. Capability limitations (consent / no display /
4
+ // enrolling) are orthogonal to reachability, so they ride alongside as their own
5
+ // quiet chip — reusing the existing badge meta so the two surfaces never drift.
6
+ import { cn } from "../lib/cn";
7
+ import type { MachineState, MetricSample } from "../types/machines";
8
+ import { deriveHealth, HEALTH_TOKEN, healthPulses } from "./machines/health";
9
+ import { MACHINE_STATE_BADGE_META } from "./machine-status-pill";
10
+
11
+ export type MachineHealthPillProps = {
12
+ state: MachineState;
13
+ metrics: MetricSample | null;
14
+ now?: number | undefined;
15
+ size?: "sm" | "md" | undefined;
16
+ className?: string | undefined;
17
+ };
18
+
19
+ export function MachineHealthPill({
20
+ state,
21
+ metrics,
22
+ now,
23
+ size = "sm",
24
+ className,
25
+ }: MachineHealthPillProps) {
26
+ const health = deriveHealth(state, metrics, now ?? Date.now());
27
+ const tone = HEALTH_TOKEN[health.level];
28
+ const badge = MACHINE_STATE_BADGE_META[state];
29
+ const pad = size === "md" ? "px-2.5 py-1 text-og-sm" : "px-2 py-0.5 text-og-xs";
30
+
31
+ return (
32
+ <div className={cn("flex shrink-0 items-center gap-1.5", className)}>
33
+ {badge ? (
34
+ <span
35
+ className={cn(
36
+ "rounded-full border px-2 py-0.5 text-og-xs font-medium",
37
+ badge.badgeClassName,
38
+ )}
39
+ >
40
+ {badge.label}
41
+ </span>
42
+ ) : null}
43
+ <span
44
+ className={cn(
45
+ "inline-flex items-center gap-1.5 rounded-full border font-medium",
46
+ pad,
47
+ tone.soft,
48
+ tone.text,
49
+ )}
50
+ data-health={health.level}
51
+ title={health.reason}
52
+ >
53
+ <span className="relative flex size-2">
54
+ {healthPulses(health.level) && (
55
+ <span
56
+ className={cn(
57
+ "absolute inline-flex size-full animate-og-pulse rounded-full opacity-60",
58
+ tone.dot,
59
+ )}
60
+ />
61
+ )}
62
+ <span className={cn("relative inline-flex size-2 rounded-full", tone.dot)} />
63
+ </span>
64
+ {health.label}
65
+ </span>
66
+ </div>
67
+ );
68
+ }