@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.
- package/README.md +129 -23
- package/dist/chunk-6BXBGJ3B.js +1956 -0
- package/dist/chunk-6BXBGJ3B.js.map +1 -0
- package/dist/chunk-6UT5OC3P.js +2133 -0
- package/dist/chunk-6UT5OC3P.js.map +1 -0
- package/dist/chunk-EBTT3MYN.js +92 -0
- package/dist/chunk-EBTT3MYN.js.map +1 -0
- package/dist/chunk-HHFA4AFX.js +425 -0
- package/dist/chunk-HHFA4AFX.js.map +1 -0
- package/dist/chunk-LAZ2A743.js +2393 -0
- package/dist/chunk-LAZ2A743.js.map +1 -0
- package/dist/chunk-MRSECXRP.js +563 -0
- package/dist/chunk-MRSECXRP.js.map +1 -0
- package/dist/chunk-TK7G6XLT.js +18 -0
- package/dist/chunk-TK7G6XLT.js.map +1 -0
- package/dist/composer-DvUaa5ki.d.ts +585 -0
- package/dist/composer.d.ts +6 -0
- package/dist/composer.js +56 -0
- package/dist/composer.js.map +1 -0
- package/dist/index.d.ts +642 -1080
- package/dist/index.js +7672 -8005
- package/dist/index.js.map +1 -1
- package/dist/machines.d.ts +412 -3
- package/dist/machines.js +25 -1
- package/dist/noto-sans-arabic-loader-IA2T4X2A.js +21 -0
- package/dist/noto-sans-arabic-loader-IA2T4X2A.js.map +1 -0
- package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js +292 -0
- package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js.map +1 -0
- package/dist/queue-surface-implementation-NQMV2ML3.js +890 -0
- package/dist/queue-surface-implementation-NQMV2ML3.js.map +1 -0
- package/dist/session-Gd4iVPYw.d.ts +425 -0
- package/dist/session-context-DIFFlXKc.d.ts +52 -0
- package/dist/session.d.ts +4 -0
- package/dist/session.js +50 -0
- package/dist/session.js.map +1 -0
- package/dist/use-composer-BCfm4mzX.d.ts +112 -0
- package/package.json +23 -4
- package/src/approvals.ts +5 -3
- package/src/client.ts +50 -1
- package/src/components/approval-surface.tsx +198 -0
- package/src/components/chat-composer.tsx +104 -751
- package/src/components/code-editor.tsx +104 -26
- package/src/components/command-palette.tsx +8 -2
- package/src/components/composer-transcription-control.tsx +211 -0
- package/src/components/composer.tsx +1536 -0
- package/src/components/desktop-viewer.tsx +3 -3
- package/src/components/enrollment-consent.tsx +2 -2
- package/src/components/file-browser.tsx +367 -29
- package/src/components/human-input-form.tsx +417 -0
- package/src/components/machine-card.tsx +82 -16
- package/src/components/machine-health-pill.tsx +68 -0
- package/src/components/machine-metrics.tsx +10 -24
- package/src/components/machines/health.ts +146 -0
- package/src/components/machines/machine-detail.tsx +220 -0
- package/src/components/machines/metric-history-chart.tsx +298 -0
- package/src/components/machines/metric-sparkline.tsx +76 -0
- package/src/components/machines/series.ts +113 -0
- package/src/components/machines-dashboard.tsx +16 -4
- package/src/components/message-timeline.tsx +15 -7
- package/src/components/model-picker.tsx +12 -3
- package/src/components/pierre-diff.tsx +32 -6
- package/src/components/queue-surface-implementation.tsx +1041 -0
- package/src/components/queue-surface-state.tsx +94 -0
- package/src/components/queue-surface.tsx +60 -0
- package/src/components/sandbox-files.tsx +230 -21
- package/src/components/sandbox-terminal.tsx +8 -2
- package/src/components/sandbox-workspace.tsx +489 -135
- package/src/components/session-status.tsx +0 -6
- package/src/components/workbench-changes.tsx +145 -50
- package/src/components/workspace-dock.tsx +329 -68
- package/src/composer.ts +60 -0
- package/src/hooks/internal.ts +115 -34
- package/src/hooks/use-composer.ts +635 -74
- package/src/hooks/use-human-input.ts +131 -0
- package/src/hooks/use-machine-chip.ts +2 -2
- package/src/hooks/use-machines.ts +27 -11
- package/src/hooks/use-sandbox-files.ts +310 -62
- package/src/hooks/use-sandbox-git.ts +154 -40
- package/src/hooks/use-sandbox-terminal.ts +28 -6
- package/src/hooks/use-session-capabilities.ts +38 -9
- package/src/hooks/use-session-control.ts +43 -15
- package/src/hooks/use-session-events.ts +523 -48
- package/src/hooks/use-session-lineage.ts +15 -6
- package/src/hooks/use-session.ts +10 -2
- package/src/hooks/use-slash-commands.ts +38 -38
- package/src/hooks/use-transcription.ts +757 -0
- package/src/hooks/use-turn-queue.ts +273 -77
- package/src/hooks/use-windowed-sections.ts +2 -2
- package/src/hooks/use-workspace-capture.ts +146 -40
- package/src/hooks/use-workspace-edit.ts +50 -14
- package/src/hooks/use-workspace-sessions.ts +3 -0
- package/src/human-input.ts +72 -0
- package/src/index.ts +88 -12
- package/src/lib/noto-sans-arabic-loader.ts +21 -0
- package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
- package/src/lib/use-portal-token-style.ts +51 -0
- package/src/lib/use-unicode-fonts.ts +57 -0
- package/src/machines.ts +16 -0
- package/src/provider.tsx +163 -41
- package/src/session-context.ts +135 -0
- package/src/session.ts +79 -0
- package/src/timeline/parsers.ts +78 -6
- package/src/timeline/projection.ts +36 -6
- package/src/timeline/tool-renderers.tsx +41 -0
- package/src/timeline/turn-summary.tsx +2 -2
- package/src/timeline/types.ts +16 -8
- package/styles/index.css +78 -0
- package/styles/index.d.ts +2 -0
- package/styles/tokens.css +11 -6
- package/styles/tokens.d.ts +2 -0
- package/dist/chunk-NFYVQWIB.js +0 -1377
- package/dist/chunk-NFYVQWIB.js.map +0 -1
- 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 {
|
|
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
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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({
|
|
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
|
-
<
|
|
130
|
+
<MachineHealthPill
|
|
95
131
|
state={machine.state}
|
|
96
|
-
|
|
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
|
-
{
|
|
117
|
-
<>
|
|
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={() =>
|
|
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-
|
|
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
|
+
}
|