@opengeni/react 0.11.0 → 0.13.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 (85) hide show
  1. package/README.md +1 -1
  2. package/dist/{chunk-5C7RGAWA.js → chunk-NFYVQWIB.js} +170 -64
  3. package/dist/chunk-NFYVQWIB.js.map +1 -0
  4. package/dist/index.d.ts +791 -145
  5. package/dist/index.js +5447 -2213
  6. package/dist/index.js.map +1 -1
  7. package/dist/{machines-BeZ3bD3t.d.ts → machines-CnlMb7E-.d.ts} +5 -5
  8. package/dist/machines.d.ts +1 -1
  9. package/dist/machines.js +1 -1
  10. package/package.json +17 -12
  11. package/src/approvals.ts +16 -4
  12. package/src/client.ts +32 -5
  13. package/src/commands/index.ts +1 -7
  14. package/src/commands/registry.ts +46 -12
  15. package/src/components/chat-composer.tsx +135 -39
  16. package/src/components/code-editor.tsx +28 -36
  17. package/src/components/command-palette.tsx +26 -5
  18. package/src/components/desktop-viewer.tsx +29 -16
  19. package/src/components/diff-view.tsx +27 -189
  20. package/src/components/enrollment-consent.tsx +28 -10
  21. package/src/components/enrollment-device-flow.tsx +8 -5
  22. package/src/components/file-browser.tsx +190 -56
  23. package/src/components/fleet-tile.tsx +13 -4
  24. package/src/components/machine-card.tsx +16 -4
  25. package/src/components/machine-dock-bar.tsx +20 -8
  26. package/src/components/machine-metrics.tsx +31 -8
  27. package/src/components/machine-status-pill.tsx +26 -5
  28. package/src/components/machines-dashboard.tsx +8 -2
  29. package/src/components/markdown.tsx +39 -9
  30. package/src/components/message-timeline.tsx +633 -109
  31. package/src/components/pierre-diff.tsx +83 -15
  32. package/src/components/pierre-file.tsx +10 -9
  33. package/src/components/sandbox-files.tsx +110 -227
  34. package/src/components/sandbox-terminal.tsx +256 -88
  35. package/src/components/sandbox-workspace.tsx +823 -0
  36. package/src/components/session-status.tsx +28 -2
  37. package/src/components/workbench-changes.tsx +541 -0
  38. package/src/components/workspace-dock.tsx +85 -22
  39. package/src/hooks/internal.ts +5 -2
  40. package/src/hooks/use-available-models.ts +7 -2
  41. package/src/hooks/use-billing-usage.ts +4 -1
  42. package/src/hooks/use-codex-accounts.ts +74 -44
  43. package/src/hooks/use-composer.ts +57 -36
  44. package/src/hooks/use-environments.ts +92 -47
  45. package/src/hooks/use-file-attachments.ts +101 -55
  46. package/src/hooks/use-goal.ts +52 -16
  47. package/src/hooks/use-machine-chip.ts +134 -0
  48. package/src/hooks/use-machines.ts +34 -16
  49. package/src/hooks/use-packs.ts +24 -19
  50. package/src/hooks/use-relay-frame-stream.ts +5 -2
  51. package/src/hooks/use-rigs.ts +335 -0
  52. package/src/hooks/use-sandbox-files.ts +213 -39
  53. package/src/hooks/use-sandbox-git.ts +188 -11
  54. package/src/hooks/use-sandbox-terminal.ts +18 -14
  55. package/src/hooks/use-scheduled-tasks.ts +10 -2
  56. package/src/hooks/use-session-capabilities.ts +77 -20
  57. package/src/hooks/use-session-control.ts +49 -21
  58. package/src/hooks/use-session-events.ts +48 -20
  59. package/src/hooks/use-session-lineage.ts +119 -0
  60. package/src/hooks/use-session.ts +43 -28
  61. package/src/hooks/use-slash-commands.ts +6 -4
  62. package/src/hooks/use-turn-queue.ts +74 -147
  63. package/src/hooks/use-windowed-sections.ts +204 -0
  64. package/src/hooks/use-workspace-capture.ts +233 -0
  65. package/src/hooks/use-workspace-edit.ts +231 -0
  66. package/src/hooks/use-workspace-sessions.ts +48 -5
  67. package/src/hooks/use-workspaces.ts +18 -15
  68. package/src/index.ts +128 -23
  69. package/src/lib/format.ts +44 -6
  70. package/src/lib/xterm-renderer.ts +65 -0
  71. package/src/lib/xterm-theme.ts +224 -18
  72. package/src/machines.ts +13 -3
  73. package/src/provider.tsx +3 -1
  74. package/src/timeline/activity-rail.tsx +213 -54
  75. package/src/timeline/disclosure-context.tsx +12 -2
  76. package/src/timeline/index.ts +21 -2
  77. package/src/timeline/parsers.ts +44 -10
  78. package/src/timeline/projection.ts +435 -84
  79. package/src/timeline/shared.tsx +111 -24
  80. package/src/timeline/tool-diff.tsx +24 -20
  81. package/src/timeline/tool-renderers.tsx +98 -21
  82. package/src/timeline/turn-summary.tsx +76 -23
  83. package/src/timeline/types.ts +96 -12
  84. package/styles/tokens.css +2 -2
  85. package/dist/chunk-5C7RGAWA.js.map +0 -1
@@ -1,118 +1,163 @@
1
1
  import type {
2
- CreateWorkspaceEnvironmentRequest,
3
- UpdateWorkspaceEnvironmentRequest,
4
- WorkspaceEnvironment,
5
- WorkspaceEnvironmentVariableMetadata,
2
+ CreateVariableSetRequest,
3
+ UpdateVariableSetRequest,
4
+ VariableSet,
5
+ VariableSetVariableMetadata,
6
6
  } from "@opengeni/sdk";
7
- import { useCallback } from "react";
7
+ import { useCallback, useMemo } from "react";
8
8
  import { useOpenGeni, type ClientOverride } from "../provider";
9
9
  import { useMutationRunner, usePolledValue } from "./internal";
10
10
 
11
- export type UseEnvironmentsOptions = ClientOverride & {
11
+ export type UseVariableSetsOptions = ClientOverride & {
12
12
  pollIntervalMs?: number | undefined;
13
13
  enabled?: boolean | undefined;
14
14
  };
15
15
 
16
- export type UseEnvironmentsResult = {
17
- environments: WorkspaceEnvironment[];
16
+ export type UseVariableSetsResult = {
17
+ variableSets: VariableSet[];
18
18
  loading: boolean;
19
19
  error: Error | null;
20
20
  refresh: () => Promise<void>;
21
- create: (request: CreateWorkspaceEnvironmentRequest) => Promise<WorkspaceEnvironment | null>;
22
- update: (environmentId: string, request: UpdateWorkspaceEnvironmentRequest) => Promise<WorkspaceEnvironment | null>;
23
- remove: (environmentId: string) => Promise<boolean>;
21
+ create: (request: CreateVariableSetRequest) => Promise<VariableSet | null>;
22
+ update: (variableSetId: string, request: UpdateVariableSetRequest) => Promise<VariableSet | null>;
23
+ remove: (variableSetId: string) => Promise<boolean>;
24
24
  /** Set/rotate a variable. Values are write-only — reads expose metadata only. */
25
- setVariable: (environmentId: string, name: string, value: string) => Promise<WorkspaceEnvironmentVariableMetadata | null>;
26
- deleteVariable: (environmentId: string, name: string) => Promise<boolean>;
25
+ setVariable: (
26
+ variableSetId: string,
27
+ name: string,
28
+ value: string,
29
+ ) => Promise<VariableSetVariableMetadata | null>;
30
+ deleteVariable: (variableSetId: string, name: string) => Promise<boolean>;
27
31
  mutating: boolean;
28
32
  mutationError: Error | null;
29
33
  clearMutationError: () => void;
30
34
  };
31
35
 
32
36
  /**
33
- * Workspace environments (named, encrypted variable sets attached to sessions
37
+ * Variable sets (named, encrypted variable sets attached to sessions
34
38
  * and scheduled tasks). Variable values are write-only end to end: this hook
35
39
  * never sees a value after it is sent.
36
40
  */
37
- export function useEnvironments(options: UseEnvironmentsOptions = {}): UseEnvironmentsResult {
41
+ export function useVariableSets(options: UseVariableSetsOptions = {}): UseVariableSetsResult {
38
42
  const { client, workspaceId } = useOpenGeni(options);
39
- const load = useCallback(async () => await client.listEnvironments(workspaceId), [client, workspaceId]);
40
- const state = usePolledValue(load, { pollIntervalMs: options.pollIntervalMs, enabled: options.enabled });
41
- const mutation = useMutationRunner();
43
+ const load = useCallback(
44
+ async () => await client.listVariableSets(workspaceId),
45
+ [client, workspaceId],
46
+ );
47
+ const { data, loading, error, refresh } = usePolledValue(load, {
48
+ pollIntervalMs: options.pollIntervalMs,
49
+ enabled: options.enabled,
50
+ });
51
+ const { run, mutating, mutationError, clearMutationError } = useMutationRunner();
42
52
 
43
53
  const create = useCallback(
44
- async (request: CreateWorkspaceEnvironmentRequest): Promise<WorkspaceEnvironment | null> => {
45
- const result = await mutation.run(() => client.createEnvironment(workspaceId, request));
54
+ async (request: CreateVariableSetRequest): Promise<VariableSet | null> => {
55
+ const result = await run(() => client.createVariableSet(workspaceId, request));
46
56
  if (result) {
47
- await state.refresh();
57
+ await refresh();
48
58
  }
49
59
  return result;
50
60
  },
51
- [client, workspaceId, mutation.run, state.refresh],
61
+ [client, workspaceId, run, refresh],
52
62
  );
53
63
 
54
64
  const update = useCallback(
55
- async (environmentId: string, request: UpdateWorkspaceEnvironmentRequest): Promise<WorkspaceEnvironment | null> => {
56
- const result = await mutation.run(() => client.updateEnvironment(workspaceId, environmentId, request));
65
+ async (
66
+ variableSetId: string,
67
+ request: UpdateVariableSetRequest,
68
+ ): Promise<VariableSet | null> => {
69
+ const result = await run(() => client.updateVariableSet(workspaceId, variableSetId, request));
57
70
  if (result) {
58
- await state.refresh();
71
+ await refresh();
59
72
  }
60
73
  return result;
61
74
  },
62
- [client, workspaceId, mutation.run, state.refresh],
75
+ [client, workspaceId, run, refresh],
63
76
  );
64
77
 
65
78
  const remove = useCallback(
66
- async (environmentId: string): Promise<boolean> => {
67
- const result = await mutation.run(async () => {
68
- await client.deleteEnvironment(workspaceId, environmentId);
79
+ async (variableSetId: string): Promise<boolean> => {
80
+ const result = await run(async () => {
81
+ await client.deleteVariableSet(workspaceId, variableSetId);
69
82
  return true;
70
83
  });
71
84
  if (result) {
72
- await state.refresh();
85
+ await refresh();
73
86
  }
74
87
  return result === true;
75
88
  },
76
- [client, workspaceId, mutation.run, state.refresh],
89
+ [client, workspaceId, run, refresh],
77
90
  );
78
91
 
79
92
  const setVariable = useCallback(
80
- async (environmentId: string, name: string, value: string): Promise<WorkspaceEnvironmentVariableMetadata | null> => {
81
- const result = await mutation.run(() => client.setEnvironmentVariable(workspaceId, environmentId, name, value));
93
+ async (
94
+ variableSetId: string,
95
+ name: string,
96
+ value: string,
97
+ ): Promise<VariableSetVariableMetadata | null> => {
98
+ const result = await run(() =>
99
+ client.setVariableSetVariable(workspaceId, variableSetId, name, value),
100
+ );
82
101
  if (result) {
83
- await state.refresh();
102
+ await refresh();
84
103
  }
85
104
  return result;
86
105
  },
87
- [client, workspaceId, mutation.run, state.refresh],
106
+ [client, workspaceId, run, refresh],
88
107
  );
89
108
 
90
109
  const deleteVariable = useCallback(
91
- async (environmentId: string, name: string): Promise<boolean> => {
92
- const result = await mutation.run(async () => {
93
- await client.deleteEnvironmentVariable(workspaceId, environmentId, name);
110
+ async (variableSetId: string, name: string): Promise<boolean> => {
111
+ const result = await run(async () => {
112
+ await client.deleteVariableSetVariable(workspaceId, variableSetId, name);
94
113
  return true;
95
114
  });
96
115
  if (result) {
97
- await state.refresh();
116
+ await refresh();
98
117
  }
99
118
  return result === true;
100
119
  },
101
- [client, workspaceId, mutation.run, state.refresh],
120
+ [client, workspaceId, run, refresh],
102
121
  );
103
122
 
104
123
  return {
105
- environments: state.data ?? [],
106
- loading: state.loading,
107
- error: state.error,
108
- refresh: state.refresh,
124
+ variableSets: data ?? [],
125
+ loading,
126
+ error,
127
+ refresh,
109
128
  create,
110
129
  update,
111
130
  remove,
112
131
  setVariable,
113
132
  deleteVariable,
114
- mutating: mutation.mutating,
115
- mutationError: mutation.mutationError,
116
- clearMutationError: mutation.clearMutationError,
133
+ mutating,
134
+ mutationError,
135
+ clearMutationError,
117
136
  };
118
137
  }
138
+
139
+ /** @deprecated use UseVariableSetsOptions */
140
+ export type UseEnvironmentsOptions = UseVariableSetsOptions;
141
+ /** @deprecated use UseVariableSetsResult */
142
+ export type UseEnvironmentsResult = UseVariableSetsResult & { environments: VariableSet[] };
143
+ /** @deprecated use useVariableSets */
144
+ export function useEnvironments(options: UseEnvironmentsOptions = {}): UseEnvironmentsResult {
145
+ const legacyClient = useMemo(() => {
146
+ if (!options.client) {
147
+ return undefined;
148
+ }
149
+ return {
150
+ ...options.client,
151
+ listVariableSets: options.client.listEnvironments ?? options.client.listVariableSets,
152
+ createVariableSet: options.client.createEnvironment ?? options.client.createVariableSet,
153
+ updateVariableSet: options.client.updateEnvironment ?? options.client.updateVariableSet,
154
+ deleteVariableSet: options.client.deleteEnvironment ?? options.client.deleteVariableSet,
155
+ setVariableSetVariable:
156
+ options.client.setEnvironmentVariable ?? options.client.setVariableSetVariable,
157
+ deleteVariableSetVariable:
158
+ options.client.deleteEnvironmentVariable ?? options.client.deleteVariableSetVariable,
159
+ };
160
+ }, [options.client]);
161
+ const result = useVariableSets({ ...options, ...(legacyClient ? { client: legacyClient } : {}) });
162
+ return { ...result, environments: result.variableSets };
163
+ }
@@ -61,7 +61,9 @@ const isImage = (file: File): boolean => file.type.startsWith("image/");
61
61
  * `resources`. Workspace-scoped, so it resolves both client and workspace from
62
62
  * the {@link OpenGeniProvider} (or a per-call `{ client, workspaceId }`).
63
63
  */
64
- export function useFileAttachments(options: UseFileAttachmentsOptions = {}): UseFileAttachmentsResult {
64
+ export function useFileAttachments(
65
+ options: UseFileAttachmentsOptions = {},
66
+ ): UseFileAttachmentsResult {
65
67
  const { client, workspaceId } = useOpenGeni(options);
66
68
  const pasteFilter = options.pasteFilter ?? isImage;
67
69
  const [attachments, setAttachments] = useState<FileAttachment[]>([]);
@@ -72,60 +74,102 @@ export function useFileAttachments(options: UseFileAttachmentsOptions = {}): Use
72
74
  // Run (or re-run) the upload for one already-tracked attachment id. Sets it
73
75
  // back to `uploading`, then resolves to `ready` (with the asset) or `failed`
74
76
  // (with the error message).
75
- const startUpload = useCallback((id: string, file: File) => {
76
- void client.uploadFile(workspaceId, {
77
- filename: file.name || "file",
78
- contentType: file.type || "application/octet-stream",
79
- data: file,
80
- }).then((asset) => {
81
- setAttachments((current) => current.map((attachment) => attachment.id === id
82
- ? { ...attachment, status: "ready", file: asset, name: asset.filename, contentType: asset.contentType, sizeBytes: asset.sizeBytes, error: undefined }
83
- : attachment));
84
- }).catch((error: unknown) => {
85
- setAttachments((current) => current.map((attachment) => attachment.id === id
86
- ? { ...attachment, status: "failed", error: error instanceof Error ? error.message : String(error) }
87
- : attachment));
88
- });
89
- }, [client, workspaceId]);
77
+ const startUpload = useCallback(
78
+ (id: string, file: File) => {
79
+ void client
80
+ .uploadFile(workspaceId, {
81
+ filename: file.name || "file",
82
+ contentType: file.type || "application/octet-stream",
83
+ data: file,
84
+ })
85
+ .then((asset) => {
86
+ setAttachments((current) =>
87
+ current.map((attachment) =>
88
+ attachment.id === id
89
+ ? {
90
+ ...attachment,
91
+ status: "ready",
92
+ file: asset,
93
+ name: asset.filename,
94
+ contentType: asset.contentType,
95
+ sizeBytes: asset.sizeBytes,
96
+ error: undefined,
97
+ }
98
+ : attachment,
99
+ ),
100
+ );
101
+ })
102
+ .catch((error: unknown) => {
103
+ setAttachments((current) =>
104
+ current.map((attachment) =>
105
+ attachment.id === id
106
+ ? {
107
+ ...attachment,
108
+ status: "failed",
109
+ error: error instanceof Error ? error.message : String(error),
110
+ }
111
+ : attachment,
112
+ ),
113
+ );
114
+ });
115
+ },
116
+ [client, workspaceId],
117
+ );
90
118
 
91
- const addFiles = useCallback((files: Iterable<File>) => {
92
- for (const file of files) {
93
- const id = crypto.randomUUID();
94
- sources.current.set(id, file);
95
- const previewUrl = isImage(file) ? URL.createObjectURL(file) : undefined;
96
- setAttachments((current) => [...current, {
97
- id,
98
- name: file.name || "image",
99
- contentType: file.type || "application/octet-stream",
100
- sizeBytes: file.size,
101
- status: "uploading",
102
- ...(previewUrl ? { previewUrl } : {}),
103
- }]);
104
- startUpload(id, file);
105
- }
106
- }, [startUpload]);
119
+ const addFiles = useCallback(
120
+ (files: Iterable<File>) => {
121
+ for (const file of files) {
122
+ const id = crypto.randomUUID();
123
+ sources.current.set(id, file);
124
+ const previewUrl = isImage(file) ? URL.createObjectURL(file) : undefined;
125
+ setAttachments((current) => [
126
+ ...current,
127
+ {
128
+ id,
129
+ name: file.name || "image",
130
+ contentType: file.type || "application/octet-stream",
131
+ sizeBytes: file.size,
132
+ status: "uploading",
133
+ ...(previewUrl ? { previewUrl } : {}),
134
+ },
135
+ ]);
136
+ startUpload(id, file);
137
+ }
138
+ },
139
+ [startUpload],
140
+ );
107
141
 
108
- const retry = useCallback((id: string) => {
109
- const file = sources.current.get(id);
110
- if (!file) {
111
- return;
112
- }
113
- setAttachments((current) => current.map((attachment) => attachment.id === id
114
- ? { ...attachment, status: "uploading", error: undefined }
115
- : attachment));
116
- startUpload(id, file);
117
- }, [startUpload]);
142
+ const retry = useCallback(
143
+ (id: string) => {
144
+ const file = sources.current.get(id);
145
+ if (!file) {
146
+ return;
147
+ }
148
+ setAttachments((current) =>
149
+ current.map((attachment) =>
150
+ attachment.id === id
151
+ ? { ...attachment, status: "uploading", error: undefined }
152
+ : attachment,
153
+ ),
154
+ );
155
+ startUpload(id, file);
156
+ },
157
+ [startUpload],
158
+ );
118
159
 
119
- const addFromPaste = useCallback((event: { clipboardData: DataTransfer | null }) => {
120
- const clipboardFiles = event.clipboardData?.files;
121
- if (!clipboardFiles) {
122
- return;
123
- }
124
- const files = [...clipboardFiles].filter(pasteFilter);
125
- if (files.length > 0) {
126
- addFiles(files);
127
- }
128
- }, [addFiles, pasteFilter]);
160
+ const addFromPaste = useCallback(
161
+ (event: { clipboardData: DataTransfer | null }) => {
162
+ const clipboardFiles = event.clipboardData?.files;
163
+ if (!clipboardFiles) {
164
+ return;
165
+ }
166
+ const files = [...clipboardFiles].filter(pasteFilter);
167
+ if (files.length > 0) {
168
+ addFiles(files);
169
+ }
170
+ },
171
+ [addFiles, pasteFilter],
172
+ );
129
173
 
130
174
  const remove = useCallback((id: string) => {
131
175
  sources.current.delete(id);
@@ -152,9 +196,11 @@ export function useFileAttachments(options: UseFileAttachmentsOptions = {}): Use
152
196
 
153
197
  return {
154
198
  attachments,
155
- readyResources: attachments.flatMap((attachment): FileResourceRef[] => attachment.status === "ready" && attachment.file
156
- ? [{ kind: "file", fileId: attachment.file.id }]
157
- : []),
199
+ readyResources: attachments.flatMap((attachment): FileResourceRef[] =>
200
+ attachment.status === "ready" && attachment.file
201
+ ? [{ kind: "file", fileId: attachment.file.id }]
202
+ : [],
203
+ ),
158
204
  uploading: attachments.some((attachment) => attachment.status === "uploading"),
159
205
  addFiles,
160
206
  addFromPaste,
@@ -1,17 +1,23 @@
1
1
  import { OpenGeniApiError, type SessionEvent, type SessionGoal } from "@opengeni/sdk";
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
3
  import { useOpenGeni, type ClientOverride } from "../provider";
4
- import { useDebouncedCallback, useMutationRunner, useSessionEventTrigger, type SessionEventFeedOptions } from "./internal";
4
+ import {
5
+ useDebouncedCallback,
6
+ useMutationRunner,
7
+ useSessionEventTrigger,
8
+ type SessionEventFeedOptions,
9
+ } from "./internal";
5
10
 
6
11
  /** Event types that change the session goal (set/updated/completed/paused/...). */
7
12
  export function isGoalEvent(event: Pick<SessionEvent, "type">): boolean {
8
13
  return event.type.startsWith("goal.");
9
14
  }
10
15
 
11
- export type UseGoalOptions = ClientOverride & SessionEventFeedOptions & {
12
- /** Optional safety-net polling (ms). Off by default — goal.* events drive updates. */
13
- pollIntervalMs?: number | undefined;
14
- };
16
+ export type UseGoalOptions = ClientOverride &
17
+ SessionEventFeedOptions & {
18
+ /** Optional safety-net polling (ms). Off by default — goal.* events drive updates. */
19
+ pollIntervalMs?: number | undefined;
20
+ };
15
21
 
16
22
  export type UseGoalResult = {
17
23
  /** The session goal, or null when the session has none. */
@@ -27,7 +33,11 @@ export type UseGoalResult = {
27
33
  pause: (rationale?: string) => Promise<SessionGoal | null>;
28
34
  /** Resume a paused goal: resets counters and re-arms continuations. */
29
35
  resume: () => Promise<SessionGoal | null>;
30
- /** True while a pause/resume is in flight. */
36
+ /** Clear the session goal; goal-less sessions remain a successful no-op. */
37
+ clearGoal: () => Promise<void>;
38
+ /** Alias for `clearGoal`. */
39
+ deleteGoal: () => Promise<void>;
40
+ /** True while a pause/resume/clear is in flight. */
31
41
  updating: boolean;
32
42
  mutationError: Error | null;
33
43
  clearMutationError: () => void;
@@ -39,7 +49,10 @@ export type UseGoalResult = {
39
49
  * `goal: null` (the 404 is absorbed). Live-updates on `goal.*` events —
40
50
  * pass `options.events` from `useSessionEvents` to reuse its stream.
41
51
  */
42
- export function useGoal(sessionId: string | null | undefined, options: UseGoalOptions = {}): UseGoalResult {
52
+ export function useGoal(
53
+ sessionId: string | null | undefined,
54
+ options: UseGoalOptions = {},
55
+ ): UseGoalResult {
43
56
  const { client, workspaceId } = useOpenGeni(options);
44
57
  const enabled = (options.enabled ?? true) && Boolean(sessionId);
45
58
  const sharedEvents = options.events;
@@ -47,7 +60,7 @@ export function useGoal(sessionId: string | null | undefined, options: UseGoalOp
47
60
  const [goal, setGoal] = useState<SessionGoal | null>(null);
48
61
  const [loading, setLoading] = useState(enabled);
49
62
  const [error, setError] = useState<Error | null>(null);
50
- const mutation = useMutationRunner();
63
+ const { run, mutating, mutationError, clearMutationError } = useMutationRunner();
51
64
  const generation = useRef(0);
52
65
  const targetKeyRef = useRef<string | null>(null);
53
66
 
@@ -121,29 +134,50 @@ export function useGoal(sessionId: string | null | undefined, options: UseGoalOp
121
134
  if (!sessionId) {
122
135
  return null;
123
136
  }
124
- const result = await mutation.run(() =>
137
+ const result = await run(() =>
125
138
  client.updateGoal(workspaceId, sessionId, {
126
139
  status: "paused",
127
140
  ...(rationale !== undefined ? { rationale } : {}),
128
- }));
141
+ }),
142
+ );
129
143
  if (result) {
130
144
  setGoal(result);
131
145
  }
132
146
  return result;
133
147
  },
134
- [client, workspaceId, sessionId, mutation.run],
148
+ [client, workspaceId, sessionId, run],
135
149
  );
136
150
 
137
151
  const resume = useCallback(async (): Promise<SessionGoal | null> => {
138
152
  if (!sessionId) {
139
153
  return null;
140
154
  }
141
- const result = await mutation.run(() => client.updateGoal(workspaceId, sessionId, { status: "active" }));
155
+ const result = await run(() => client.updateGoal(workspaceId, sessionId, { status: "active" }));
142
156
  if (result) {
143
157
  setGoal(result);
144
158
  }
145
159
  return result;
146
- }, [client, workspaceId, sessionId, mutation.run]);
160
+ }, [client, workspaceId, sessionId, run]);
161
+
162
+ const clearGoal = useCallback(async (): Promise<void> => {
163
+ if (!sessionId) {
164
+ return;
165
+ }
166
+ // deleteGoal resolves void, so distinguish success (a truthy sentinel) from
167
+ // mutation.run's null-on-failure. Only a SUCCESSFUL delete hides the goal:
168
+ // a failed one leaves the pill up so its mutationError renders. And invalidate
169
+ // any in-flight load first so a slower getGoal started before the delete can't
170
+ // commit and repopulate the just-cleared goal.
171
+ const ok = await run(async () => {
172
+ await client.deleteGoal(workspaceId, sessionId);
173
+ return true as const;
174
+ });
175
+ if (ok) {
176
+ generation.current += 1;
177
+ setGoal(null);
178
+ setError(null);
179
+ }
180
+ }, [client, workspaceId, sessionId, run]);
147
181
 
148
182
  return {
149
183
  goal,
@@ -155,8 +189,10 @@ export function useGoal(sessionId: string | null | undefined, options: UseGoalOp
155
189
  refresh: load,
156
190
  pause,
157
191
  resume,
158
- updating: mutation.mutating,
159
- mutationError: mutation.mutationError,
160
- clearMutationError: mutation.clearMutationError,
192
+ clearGoal,
193
+ deleteGoal: clearGoal,
194
+ updating: mutating,
195
+ mutationError,
196
+ clearMutationError,
161
197
  };
162
198
  }
@@ -0,0 +1,134 @@
1
+ import { useMemo } from "react";
2
+ import type { MachineState } from "@opengeni/sdk";
3
+ import type { SessionCapabilitiesState } from "./use-session-capabilities";
4
+
5
+ /** The one truthful machine indicator (dossier §3 #10). Honest staleness beats
6
+ * fake liveness: a cold/asleep box reads "offline — as of <time>", never "live". */
7
+ export type MachineChipState = "live" | "waking" | "offline";
8
+
9
+ export type MachineChip = {
10
+ state: MachineChipState;
11
+ /** A ready-to-render label ("Live" / "Waking…" / "Offline — as of 3m ago"). */
12
+ label: string;
13
+ /** The capture time backing an offline/stale label (ISO), or null. M4 may
14
+ * reformat this; `label` already embeds a relative form. */
15
+ asOf: string | null;
16
+ };
17
+
18
+ export type DeriveMachineChipInput = {
19
+ /** `capabilities.liveness` — "warm" | "draining" | "cold" (or null pre-negotiation). */
20
+ liveness?: string | null | undefined;
21
+ /** `useSessionCapabilities().state` — drives the "waking" (negotiating) read. */
22
+ capabilitiesState?: SessionCapabilitiesState | null | undefined;
23
+ /** The ACTIVE machine's connection state (from `useMachines`), when known. A
24
+ * self-hosted machine that is `offline` cannot be remotely woken. */
25
+ activeMachineState?: MachineState | null | undefined;
26
+ /** Whether the active sandbox is a user-owned self-hosted machine (no remote wake). */
27
+ activeIsSelfhosted?: boolean | undefined;
28
+ /** An interaction (wake-on-edit, terminal focus) is actively warming the box. */
29
+ wantsWarm?: boolean | undefined;
30
+ /** The latest capture's `capturedAt` (ISO) — the "as of <time>" backing. */
31
+ capturedAt?: string | null | undefined;
32
+ /** Injectable clock for deterministic relative labels (defaults to Date.now). */
33
+ now?: number | undefined;
34
+ };
35
+
36
+ /** Compact relative-time for the "as of" label. Pure, no Intl dependency. */
37
+ export function formatAsOf(capturedAt: string, now: number): string {
38
+ const then = Date.parse(capturedAt);
39
+ if (Number.isNaN(then)) return "recently";
40
+ const seconds = Math.max(0, Math.round((now - then) / 1000));
41
+ if (seconds < 45) return "just now";
42
+ const minutes = Math.round(seconds / 60);
43
+ if (minutes < 60) return `${minutes}m ago`;
44
+ const hours = Math.round(minutes / 60);
45
+ if (hours < 24) return `${hours}h ago`;
46
+ const days = Math.round(hours / 24);
47
+ return `${days}d ago`;
48
+ }
49
+
50
+ /**
51
+ * Derive the machine-state chip from the live capability/liveness surface, the
52
+ * active machine's connection state, and the latest capture time. PURE (M4 renders
53
+ * it; the whole point is a deterministic, testable projection).
54
+ *
55
+ * Precedence:
56
+ * 1. warm/draining lease → **live** (a warm box always wins).
57
+ * 2. actively warming (negotiating, wake-on-edit, or a reconnecting/enrolling
58
+ * machine) → **waking**.
59
+ * 3. everything else (a cold/asleep box, a self-hosted machine that's offline,
60
+ * an error) → **offline**, labelled "as of <capture time>".
61
+ */
62
+ export function deriveMachineChip(input: DeriveMachineChipInput): MachineChip {
63
+ const now = input.now ?? Date.now();
64
+ const asOf = input.capturedAt ?? null;
65
+ const offlineLabel = asOf ? `Offline — as of ${formatAsOf(asOf, now)}` : "Offline";
66
+
67
+ // 1. A warm (or draining) lease is live regardless of anything else.
68
+ if (input.liveness === "warm" || input.liveness === "draining") {
69
+ return { state: "live", label: "Live", asOf: null };
70
+ }
71
+
72
+ // A self-hosted machine that reports offline cannot be woken remotely — honest
73
+ // "offline", never "waking" (dossier §3 #5 / #10).
74
+ const selfhostedOffline =
75
+ input.activeIsSelfhosted === true && input.activeMachineState === "offline";
76
+
77
+ // 2. Actively coming up: negotiation in flight, an edit/terminal is warming, or
78
+ // the active machine is (re)connecting. Never when self-hosted is hard-offline.
79
+ const machineWarming =
80
+ input.activeMachineState === "reconnecting" || input.activeMachineState === "enrolling";
81
+ if (
82
+ !selfhostedOffline &&
83
+ (input.capabilitiesState === "negotiating" || input.wantsWarm === true || machineWarming)
84
+ ) {
85
+ return { state: "waking", label: "Waking…", asOf };
86
+ }
87
+
88
+ // 3. Cold / asleep / offline / error → honest stale label.
89
+ return { state: "offline", label: offlineLabel, asOf };
90
+ }
91
+
92
+ export type UseMachineChipOptions = DeriveMachineChipInput;
93
+
94
+ /**
95
+ * Thin memoized wrapper over `deriveMachineChip` for the dock header (M4). The
96
+ * dock already runs `useSessionCapabilities` + `useMachines` + `useWorkspace
97
+ * capture`; it feeds their `liveness` / `state` / active-machine state /
98
+ * `capturedAt` here. Pass a periodically-updated `now` if you want the relative
99
+ * "as of" label to tick.
100
+ */
101
+ export function useMachineChip(input: UseMachineChipOptions): MachineChip {
102
+ const {
103
+ liveness,
104
+ capabilitiesState,
105
+ activeMachineState,
106
+ activeIsSelfhosted,
107
+ wantsWarm,
108
+ capturedAt,
109
+ now,
110
+ } = input;
111
+ return useMemo(
112
+ () =>
113
+ deriveMachineChip({
114
+ liveness,
115
+ capabilitiesState,
116
+ activeMachineState,
117
+ activeIsSelfhosted,
118
+ wantsWarm,
119
+ capturedAt,
120
+ now,
121
+ }),
122
+ // Depend on the primitive fields (not the object identity) so a caller passing
123
+ // a fresh object each render doesn't recompute needlessly.
124
+ [
125
+ liveness,
126
+ capabilitiesState,
127
+ activeMachineState,
128
+ activeIsSelfhosted,
129
+ wantsWarm,
130
+ capturedAt,
131
+ now,
132
+ ],
133
+ );
134
+ }