@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.
- package/README.md +19 -13
- package/dist/chunk-TOJR776I.js +2280 -0
- package/dist/chunk-TOJR776I.js.map +1 -0
- package/dist/index.d.ts +314 -255
- package/dist/index.js +5862 -4404
- package/dist/index.js.map +1 -1
- package/dist/{machines-CnlMb7E-.d.ts → machines-BpdwuQcD.d.ts} +130 -10
- package/dist/machines.d.ts +1 -1
- package/dist/machines.js +23 -1
- package/package.json +5 -2
- package/src/client.ts +10 -1
- package/src/components/chat-composer.tsx +309 -57
- package/src/components/machine-card.tsx +81 -15
- 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 +13 -1
- package/src/components/queue-surface.tsx +578 -0
- package/src/components/sandbox-files.tsx +94 -9
- package/src/components/sandbox-workspace.tsx +186 -52
- package/src/components/session-status.tsx +0 -6
- package/src/components/workbench-changes.tsx +64 -20
- package/src/components/workspace-dock.tsx +146 -55
- package/src/hooks/use-composer.ts +369 -39
- package/src/hooks/use-session-control.ts +6 -7
- package/src/hooks/use-session-events.ts +3 -2
- package/src/hooks/use-session-lineage.ts +15 -6
- package/src/hooks/use-session.ts +10 -2
- package/src/hooks/use-turn-queue.ts +175 -47
- package/src/index.ts +13 -7
- package/src/machines.ts +16 -0
- package/src/provider.tsx +192 -5
- package/src/timeline/parsers.ts +43 -6
- package/src/timeline/projection.ts +24 -2
- package/styles/index.css +22 -0
- package/dist/chunk-NFYVQWIB.js +0 -1377
- package/dist/chunk-NFYVQWIB.js.map +0 -1
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import type { ClientModel,
|
|
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 {
|
|
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
|
-
/**
|
|
47
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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={
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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=
|
|
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 {
|
|
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,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={() =>
|
|
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",
|