@opengeni/react 3.7.0-canary.5 → 3.7.0-canary.7

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 (61) hide show
  1. package/README.md +43 -3
  2. package/dist/artifacts.js +7 -7
  3. package/dist/{browser-viewer-NTD6UIPR.js → browser-viewer-SS46LUAC.js} +3 -3
  4. package/dist/chat.d.ts +37 -0
  5. package/dist/chat.js +315 -0
  6. package/dist/chat.js.map +1 -0
  7. package/dist/{chunk-LUYB4SRW.js → chunk-3HIF5IFC.js} +1048 -2439
  8. package/dist/chunk-3HIF5IFC.js.map +1 -0
  9. package/dist/{chunk-3IY6UZI6.js → chunk-BQGIXRKV.js} +288 -396
  10. package/dist/chunk-BQGIXRKV.js.map +1 -0
  11. package/dist/{chunk-MOWYEBCQ.js → chunk-BXJOQFCD.js} +5 -4
  12. package/dist/chunk-BXJOQFCD.js.map +1 -0
  13. package/dist/{chunk-BV5A4OTE.js → chunk-ELIYWBZR.js} +4 -4
  14. package/dist/chunk-F2CFHN3Y.js +1415 -0
  15. package/dist/chunk-F2CFHN3Y.js.map +1 -0
  16. package/dist/chunk-HQV2I5HV.js +124 -0
  17. package/dist/chunk-HQV2I5HV.js.map +1 -0
  18. package/dist/{chunk-JT33KZZN.js → chunk-R4KRSFGA.js} +11 -20
  19. package/dist/chunk-R4KRSFGA.js.map +1 -0
  20. package/dist/{chunk-KWEZ4MPF.js → chunk-UMECRJRV.js} +63 -60
  21. package/dist/chunk-UMECRJRV.js.map +1 -0
  22. package/dist/{chunk-24M4YBCL.js → chunk-VEPNNDNG.js} +4 -4
  23. package/dist/{chunk-WMGF4Z7X.js → chunk-VRBL3F5E.js} +23 -3
  24. package/dist/{chunk-WMGF4Z7X.js.map → chunk-VRBL3F5E.js.map} +1 -1
  25. package/dist/components/model-policy-picker.d.ts +4 -0
  26. package/dist/components/sandbox-files.d.ts +3 -1
  27. package/dist/composer.js +6 -5
  28. package/dist/{computer-viewer-7TEZC6Y2.js → computer-viewer-FWEZISW4.js} +3 -3
  29. package/dist/hooks/use-session-events.d.ts +2 -0
  30. package/dist/index.d.ts +1 -1
  31. package/dist/index.js +92 -36
  32. package/dist/index.js.map +1 -1
  33. package/dist/interaction.js +8 -8
  34. package/dist/{model-policy-picker-menu-R6WXNWJL.js → model-policy-picker-menu-3R336DP2.js} +4 -4
  35. package/dist/model-policy.d.ts +2 -0
  36. package/dist/model-policy.js +3 -1
  37. package/dist/realtime.js +5 -5
  38. package/dist/session-ui.js +11 -8
  39. package/dist/session.js +1 -1
  40. package/package.json +6 -2
  41. package/src/chat.tsx +393 -0
  42. package/src/components/model-policy-picker-menu.tsx +92 -71
  43. package/src/components/model-policy-picker.tsx +21 -18
  44. package/src/components/sandbox-files.tsx +90 -14
  45. package/src/components/sandbox-workspace.tsx +16 -1
  46. package/src/hooks/use-session-events.ts +3 -0
  47. package/src/hooks/use-session.ts +10 -3
  48. package/src/index.ts +1 -0
  49. package/src/model-policy.ts +26 -2
  50. package/styles/compiled.css +40 -8
  51. package/styles/tokens.css +2 -2
  52. package/dist/chunk-3IY6UZI6.js.map +0 -1
  53. package/dist/chunk-JT33KZZN.js.map +0 -1
  54. package/dist/chunk-KWEZ4MPF.js.map +0 -1
  55. package/dist/chunk-LUYB4SRW.js.map +0 -1
  56. package/dist/chunk-MOWYEBCQ.js.map +0 -1
  57. /package/dist/{browser-viewer-NTD6UIPR.js.map → browser-viewer-SS46LUAC.js.map} +0 -0
  58. /package/dist/{chunk-BV5A4OTE.js.map → chunk-ELIYWBZR.js.map} +0 -0
  59. /package/dist/{chunk-24M4YBCL.js.map → chunk-VEPNNDNG.js.map} +0 -0
  60. /package/dist/{computer-viewer-7TEZC6Y2.js.map → computer-viewer-FWEZISW4.js.map} +0 -0
  61. /package/dist/{model-policy-picker-menu-R6WXNWJL.js.map → model-policy-picker-menu-3R336DP2.js.map} +0 -0
@@ -1,7 +1,7 @@
1
1
  import type { ClientModel, ReasoningEffort } from "@opengeni/sdk";
2
2
  import { CheckIcon, SearchIcon, ZapIcon } from "lucide-react";
3
3
  import { useRef, useState, type RefObject, type CSSProperties } from "react";
4
- import { Popover } from "radix-ui";
4
+ import { Popover, RadioGroup } from "radix-ui";
5
5
  import { cn } from "../lib/cn";
6
6
  import {
7
7
  coerceReasoningEffortForModel,
@@ -34,12 +34,7 @@ export function ModelPolicyPickerMenu(props: ModelPolicyPickerProps) {
34
34
  `${row.label} ${row.id} ${row.providerLabel} ${row.billingClassLabel} ${payerSummaryForModel(row.catalog)}`.toLowerCase();
35
35
  return words.every((word) => text.includes(word));
36
36
  });
37
- const groups = groupPickerRowsByBillingClass(filtered)
38
- .map((group) => ({
39
- ...group,
40
- rows: group.rows.filter((row) => words.length > 0 || row.id !== selected?.id),
41
- }))
42
- .filter((group) => group.rows.length > 0);
37
+ const groups = groupPickerRowsByBillingClass(filtered);
43
38
  const choose = (row: ClientPickerModelRow) => {
44
39
  if (!row.selectable || props.disabled) return;
45
40
  if (row.id !== props.model) props.onModelChange(row.id);
@@ -58,14 +53,23 @@ export function ModelPolicyPickerMenu(props: ModelPolicyPickerProps) {
58
53
  <PickerNavRow
59
54
  key={row.id}
60
55
  label={row.label}
61
- hint={`${props.messages?.billingHints?.[row.billingClass] ?? payerSummaryForModel(row.catalog)}${row.unavailableReason ? ` · ${row.unavailableReason}` : ""}`}
56
+ hint={row.unavailableReason ?? undefined}
62
57
  disabled={props.disabled || !row.selectable}
63
- title={row.unavailableReason ?? undefined}
58
+ title={[row.label, row.unavailableReason].filter(Boolean).join(" · ")}
64
59
  active={row.id === props.model}
65
60
  showChevron={false}
66
61
  trailing={
67
- row.id === props.model ? (
68
- <CheckIcon className="size-3.5" aria-label={messages.selected} />
62
+ row.catalog.cost === "free" || row.id === props.model ? (
63
+ <span className="flex items-center gap-2">
64
+ {row.catalog.cost === "free" ? (
65
+ <span className="rounded-og-sm bg-og-surface-2 px-1.5 py-0.5 text-og-control text-og-fg-muted">
66
+ {messages.free}
67
+ </span>
68
+ ) : null}
69
+ {row.id === props.model ? (
70
+ <CheckIcon className="size-3.5" aria-label={messages.selected} />
71
+ ) : null}
72
+ </span>
69
73
  ) : null
70
74
  }
71
75
  testId={`model-picker-choice-${row.id}`}
@@ -99,25 +103,27 @@ export function ModelPolicyPickerMenu(props: ModelPolicyPickerProps) {
99
103
  buttons[next]?.focus();
100
104
  }}
101
105
  >
102
- <div className="flex items-center gap-2 border-b border-og-border px-2 py-2">
103
- <SearchIcon className="size-4 shrink-0 text-og-fg-subtle" aria-hidden />
104
- <input
105
- aria-label={messages.searchLabel}
106
- placeholder={messages.searchPlaceholder}
107
- value={query}
108
- onChange={(event) => setQuery(event.target.value)}
109
- onKeyDown={(event) => {
110
- if (event.key !== "Escape" && event.key !== "Tab") event.stopPropagation();
111
- if (event.key === "ArrowDown") {
112
- event.preventDefault();
113
- event.currentTarget
114
- .closest('[data-testid="model-picker-menu"]')
115
- ?.querySelector<HTMLButtonElement>("button:not(:disabled)")
116
- ?.focus();
117
- }
118
- }}
119
- className="h-8 min-w-0 flex-1 bg-transparent text-og-menu text-og-fg outline-hidden placeholder:text-og-fg-subtle"
120
- />
106
+ <div className="border-b border-og-border py-1">
107
+ <label className="og-model-policy-search flex items-center gap-2 rounded-og-sm px-2 py-1 focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-og-accent/40">
108
+ <SearchIcon className="size-4 shrink-0 text-og-fg-subtle" aria-hidden />
109
+ <input
110
+ aria-label={messages.searchLabel}
111
+ placeholder={messages.searchPlaceholder}
112
+ value={query}
113
+ onChange={(event) => setQuery(event.target.value)}
114
+ onKeyDown={(event) => {
115
+ if (event.key !== "Escape" && event.key !== "Tab") event.stopPropagation();
116
+ if (event.key === "ArrowDown") {
117
+ event.preventDefault();
118
+ event.currentTarget
119
+ .closest('[data-testid="model-picker-menu"]')
120
+ ?.querySelector<HTMLButtonElement>("button:not(:disabled)")
121
+ ?.focus();
122
+ }
123
+ }}
124
+ className="og-model-policy-search-input h-8 min-w-0 flex-1 bg-transparent text-og-menu text-og-fg outline-hidden placeholder:text-og-fg-subtle"
125
+ />
126
+ </label>
121
127
  </div>
122
128
  {props.error ? (
123
129
  <p className="px-2 py-2 text-og-control text-og-status-failed" role="alert">
@@ -132,20 +138,17 @@ export function ModelPolicyPickerMenu(props: ModelPolicyPickerProps) {
132
138
  <p className="px-2 py-3 text-og-control text-og-fg-subtle">{messages.loading}</p>
133
139
  ) : (
134
140
  <>
135
- {selected && words.length === 0 ? (
136
- <div className="border-b border-og-border/60 pb-1 mb-1">
137
- <p className="px-2.5 py-1.5 text-og-control text-og-fg-subtle">
138
- {messages.currentModel}
139
- </p>
140
- {modelRow(selected)}
141
- </div>
142
- ) : null}
143
141
  {groups.map((group) => (
144
142
  <section key={group.billingClass} aria-label={group.label} className="py-1">
145
143
  <div className="flex items-center gap-2 px-2.5 py-1.5 text-og-control font-medium text-og-fg-subtle">
146
144
  <BillingClassMark billingClass={group.billingClass} aria-label="" />
147
145
  {group.label}
148
146
  </div>
147
+ {group.billingClass !== "opengeni_credits" ? (
148
+ <p className="px-2.5 pb-2 text-og-control text-og-fg-subtle">
149
+ {messages.billingHints[group.billingClass]}
150
+ </p>
151
+ ) : null}
149
152
  {group.rows.map(modelRow)}
150
153
  </section>
151
154
  ))}
@@ -157,9 +160,16 @@ export function ModelPolicyPickerMenu(props: ModelPolicyPickerProps) {
157
160
  </>
158
161
  )}
159
162
  </div>
160
- {selected ? (
161
- <div className="border-t border-og-border px-2 py-2">
162
- {selected.catalog.capabilities?.inputModalities.includes("image") === false ? (
163
+ {selected &&
164
+ ((props.hasImageAttachments &&
165
+ selected.catalog.capabilities?.inputModalities.includes("image") === false) ||
166
+ (effortOptionsForModel(selected.catalog).length > 1 &&
167
+ selected.catalog.capabilities?.reasoning.runnable !== false) ||
168
+ (props.allowLatencyMode !== false &&
169
+ runnableLatencyModesForModel(selected.catalog).includes("fast"))) ? (
170
+ <div className="border-t border-og-border px-2.5 py-2.5">
171
+ {props.hasImageAttachments &&
172
+ selected.catalog.capabilities?.inputModalities.includes("image") === false ? (
163
173
  <p className="pb-1.5 text-og-control leading-relaxed text-og-fg-subtle">
164
174
  {messages.unsupportedAttachments}
165
175
  </p>
@@ -177,42 +187,53 @@ function ModelThinkingControls(props: ModelPolicyPickerProps) {
177
187
  const efforts = effortOptionsForModel(selected.catalog);
178
188
  const messages = { ...defaultModelPolicyPickerMessages, ...props.messages };
179
189
  const supportsFast = runnableLatencyModesForModel(selected.catalog).includes("fast");
190
+ const showThinking =
191
+ efforts.length > 1 && selected.catalog.capabilities?.reasoning.runnable !== false;
192
+ const showFast = supportsFast && props.allowLatencyMode !== false;
193
+ if (!showThinking && !showFast) return null;
180
194
  return (
181
- <div
182
- className="flex items-center gap-2 text-og-control text-og-fg-muted"
183
- data-testid="model-picker-reasoning"
184
- >
185
- <label className="flex min-h-9 items-center gap-2">
186
- {messages.thinking}
187
- <select
195
+ <div className="space-y-2 text-og-control" data-testid="model-picker-reasoning">
196
+ <div className="flex min-h-7 items-center justify-between gap-3">
197
+ {showThinking ? <span className="text-og-fg-subtle">{messages.thinking}</span> : <span />}
198
+ {showFast ? (
199
+ <button
200
+ type="button"
201
+ data-testid="model-picker-fast"
202
+ disabled={props.disabled || !selected.selectable}
203
+ aria-pressed={props.latencyMode === "fast"}
204
+ title={messages.fast + " · " + messages.fastRateHint}
205
+ onClick={() =>
206
+ props.onLatencyModeChange(props.latencyMode === "fast" ? "standard" : "fast")
207
+ }
208
+ className="flex min-h-8 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-og-sm px-2 text-og-fg-muted hover:bg-og-surface-2 focus-visible:ring-2 focus-visible:ring-og-accent/40 disabled:opacity-50"
209
+ >
210
+ <ZapIcon className={cn("size-3.5", props.latencyMode === "fast" && "fill-current")} />
211
+ {messages.fast}
212
+ <span className="text-og-fg-subtle">{messages.fastRateHint}</span>
213
+ </button>
214
+ ) : null}
215
+ </div>
216
+ {showThinking ? (
217
+ <RadioGroup.Root
188
218
  aria-label={messages.thinkingEffort}
189
219
  value={coerceReasoningEffortForModel(selected.catalog, props.effort)}
190
- disabled={props.disabled || !selected.selectable || efforts.length < 2}
191
- onChange={(event) => props.onEffortChange(event.target.value as ReasoningEffort)}
192
- className="rounded-og-sm bg-og-surface-2 px-2 py-1.5 text-og-fg outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/40 disabled:opacity-50"
220
+ disabled={props.disabled || !selected.selectable}
221
+ onValueChange={(value) => props.onEffortChange(value as ReasoningEffort)}
222
+ orientation="horizontal"
223
+ className="flex flex-wrap gap-0.5 rounded-og-md bg-og-surface-2 p-0.5"
193
224
  >
194
225
  {efforts.map((effort) => (
195
- <option key={effort} value={effort}>
196
- {labelReasoningEffort(effort)}
197
- </option>
226
+ <RadioGroup.Item
227
+ key={effort}
228
+ value={effort}
229
+ aria-label={labelReasoningEffort(effort)}
230
+ title={labelReasoningEffort(effort)}
231
+ className="min-h-8 min-w-10 flex-1 cursor-pointer whitespace-nowrap rounded-og-sm px-1.5 text-og-control text-og-fg-muted outline-hidden transition-colors hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent/40 data-[state=checked]:bg-og-surface-1 data-[state=checked]:text-og-fg data-[state=checked]:shadow-sm disabled:cursor-not-allowed disabled:opacity-50"
232
+ >
233
+ {effort === "xhigh" ? "X-high" : labelReasoningEffort(effort)}
234
+ </RadioGroup.Item>
198
235
  ))}
199
- </select>
200
- </label>
201
- {supportsFast && props.allowLatencyMode !== false ? (
202
- <button
203
- type="button"
204
- data-testid="model-picker-fast"
205
- disabled={props.disabled || !selected.selectable}
206
- aria-pressed={props.latencyMode === "fast"}
207
- title={`${messages.fast} · ${messages.fastRateHint}`}
208
- onClick={() =>
209
- props.onLatencyModeChange(props.latencyMode === "fast" ? "standard" : "fast")
210
- }
211
- className="ml-auto flex min-h-9 items-center gap-1.5 rounded-og-sm px-2 hover:bg-og-surface-2 focus-visible:ring-2 focus-visible:ring-og-accent/40 disabled:opacity-50"
212
- >
213
- <ZapIcon className={cn("size-3.5", props.latencyMode === "fast" && "fill-current")} />
214
- {messages.fast} <span className="text-og-fg-subtle">{messages.fastRateHint}</span>
215
- </button>
236
+ </RadioGroup.Root>
216
237
  ) : null}
217
238
  </div>
218
239
  );
@@ -21,6 +21,7 @@ import {
21
21
  import { cn } from "../lib/cn";
22
22
  import { usePortalTokenSource, usePortalTokenStyle } from "../lib/use-portal-token-style";
23
23
  import {
24
+ effortOptionsForModel,
24
25
  findPickerRow,
25
26
  labelReasoningEffort,
26
27
  projectClientModelRows,
@@ -51,6 +52,7 @@ export type ModelPolicyPickerMessages = {
51
52
  unsupportedAttachments?: string;
52
53
  thinkingEffort?: string;
53
54
  selected?: string;
55
+ free?: string;
54
56
 
55
57
  billingHints: Record<PickerBillingClass, string>;
56
58
  };
@@ -67,13 +69,13 @@ export const defaultModelPolicyPickerMessages: ModelPolicyPickerMessages = {
67
69
  searchPlaceholder: "Search models or providers…",
68
70
  currentModel: "Current model",
69
71
  noMatches: "No matching models. Try a model or provider name.",
70
- unsupportedAttachments:
71
- "Unsupported attachments stay in the session but are hidden from this model.",
72
+ unsupportedAttachments: "This model cannot view the attached images.",
72
73
  thinkingEffort: "Thinking effort",
73
74
  selected: "Selected",
75
+ free: "Free",
74
76
 
75
77
  billingHints: {
76
- opengeni_credits: "Will use credits",
78
+ opengeni_credits: "Provided by OpenGeni",
77
79
  external: "Provider terms and limits apply",
78
80
  codex_subscription: "ChatGPT / Codex plan",
79
81
  supergrok_subscription: "SuperGrok / xAI plan",
@@ -85,6 +87,8 @@ export const defaultModelPolicyPickerMessages: ModelPolicyPickerMessages = {
85
87
  export type ModelPolicyPickerProps = {
86
88
  /** Lightweight deployment models. Catalog rows take precedence when supplied. */
87
89
  models?: ClientModel[] | undefined;
90
+ /** Warn only when the draft actually contains images this model cannot view. */
91
+ hasImageAttachments?: boolean | undefined;
88
92
  /** Catalog-backed rows with availability and billing-class truth. */
89
93
  rows?: PickerModelRow[] | undefined;
90
94
  model: string;
@@ -119,17 +123,10 @@ export type ModelPolicyPickerProps = {
119
123
 
120
124
  const SLIDE_EASE = [0.22, 1, 0.36, 1] as const;
121
125
 
126
+ // Solid facets keep the brand legible at the picker’s 14px icon size.
122
127
  function OpenGeniMark(props: SVGProps<SVGSVGElement>) {
123
128
  return (
124
- <svg
125
- viewBox="0 0 140 133"
126
- fill="none"
127
- stroke="currentColor"
128
- strokeWidth={1.2}
129
- strokeLinejoin="round"
130
- aria-hidden="true"
131
- {...props}
132
- >
129
+ <svg viewBox="0 0 140 133" fill="currentColor" aria-hidden="true" {...props}>
133
130
  <g transform="translate(-17.5,-20.999893188476562) scale(1.75)">
134
131
  <g transform="translate(0,-952.36218)">
135
132
  <path d="m 60.7828,964.36215 27.1809,0.8834 -27.1809,25.9958 z m -1.9745,1.4513 0,26.7845 -25.2681,0 c 8.6166,-8.7334 16.8796,-17.8103 25.2681,-26.7845 z m 27.7053,3.628 3.4864,1.1989 -12.5877,7.4768 z m -68.1835,2.9656 5.5226,0 12.8654,14.0705 -5.9854,6.1204 -12.4026,0 c 9e-4,-6.7347 0,-13.4597 0,-20.1909 z m -1.9746,1.2304 0,5.8364 -6.3555,0 z m 3.363,20.9796 38.627,0 -10.7675,29.43465 z m 39.0898,4.54286 0,41.20229 -12.5878,-6.8775 c 4.1972,-11.443 8.3886,-22.879 12.5878,-34.32479 z" />
@@ -249,6 +246,7 @@ export function PickerNavRow(props: {
249
246
  showChevron?: boolean | undefined;
250
247
  disabled?: boolean | undefined;
251
248
  title?: string | undefined;
249
+ description?: string | undefined;
252
250
  active?: boolean | undefined;
253
251
  testId?: string | undefined;
254
252
  onClick: () => void;
@@ -258,6 +256,7 @@ export function PickerNavRow(props: {
258
256
  type="button"
259
257
  disabled={props.disabled}
260
258
  title={props.title}
259
+ aria-description={props.description}
261
260
  onClick={props.onClick}
262
261
  data-testid={props.testId}
263
262
  className={cn(
@@ -387,12 +386,16 @@ export function ModelPolicyPicker(props: ModelPolicyPickerProps) {
387
386
  <span className="og-model-policy-label-short min-w-0 truncate font-medium text-og-fg sm:hidden @max-[20rem]/model-controls:block">
388
387
  {selected?.shortLabel ?? selected?.label ?? props.model}
389
388
  </span>
390
- <span
391
- className="og-model-policy-effort min-w-0 shrink-[9999] truncate"
392
- title={labelReasoningEffort(props.effort)}
393
- >
394
- {labelReasoningEffort(props.effort)}
395
- </span>
389
+ {selected &&
390
+ effortOptionsForModel(selected.catalog).length > 1 &&
391
+ selected.catalog.capabilities?.reasoning.runnable !== false ? (
392
+ <span
393
+ className="og-model-policy-effort min-w-0 shrink-[9999] truncate"
394
+ title={labelReasoningEffort(props.effort)}
395
+ >
396
+ {labelReasoningEffort(props.effort)}
397
+ </span>
398
+ ) : null}
396
399
  {props.latencyMode === "fast" ? (
397
400
  <ZapIcon
398
401
  className="size-3.5 shrink-0 fill-current stroke-current text-og-fg"
@@ -57,6 +57,8 @@ export type SandboxFilesProps = {
57
57
  workspaceWaking?: boolean | undefined;
58
58
  /** Whether live reads are currently authoritative. */
59
59
  liveWorkspaceReady?: boolean | undefined;
60
+ /** Failed live negotiation; never present a failure as an ongoing wake. */
61
+ workspaceError?: Error | null | undefined;
60
62
  /** Deliberately wake the machine to read content absent from the capture. */
61
63
  onWakeWorkspace?: (() => void) | undefined;
62
64
  themeType?: "dark" | "light" | undefined;
@@ -87,6 +89,7 @@ export function SandboxFiles({
87
89
  workspaceResting = false,
88
90
  workspaceWaking = false,
89
91
  liveWorkspaceReady = true,
92
+ workspaceError = null,
90
93
  onWakeWorkspace,
91
94
  themeType,
92
95
  className,
@@ -254,10 +257,22 @@ export function SandboxFiles({
254
257
  const captureFileUnavailable =
255
258
  fileView.error instanceof CapturedFileUnavailableError ? fileView.error : null;
256
259
  const waitingForSelectedFile =
260
+ !workspaceError &&
257
261
  liveRequestedPath === viewPath &&
258
262
  (!liveWorkspaceReady || files.loading) &&
259
263
  captureFileUnavailable !== null;
260
264
 
265
+ if (workspaceError && !liveWorkspaceReady && files.source !== "capture") {
266
+ return (
267
+ <Notice className={className} title="Could not open live workspace" announce="alert">
268
+ <p>{workspaceError.message}</p>
269
+ {onWakeWorkspace ? (
270
+ <WakeButton onClick={onWakeWorkspace}>Retry live workspace</WakeButton>
271
+ ) : null}
272
+ </Notice>
273
+ );
274
+ }
275
+
261
276
  if (workspaceResting || workspaceWaking) {
262
277
  return (
263
278
  <div className={cn("h-full", className)} data-opengeni-workspace-resting>
@@ -415,6 +430,15 @@ export function SandboxFiles({
415
430
  {...(onEditIntent ? { onEditIntent } : {})}
416
431
  className="h-full"
417
432
  />
433
+ ) : workspaceError &&
434
+ (liveRequestedPath === viewPath || requestedPath === viewPath) &&
435
+ !liveWorkspaceReady ? (
436
+ <Notice title="Could not open live file" announce="alert">
437
+ <p>{workspaceError.message}</p>
438
+ {onWakeWorkspace ? (
439
+ <WakeButton onClick={onWakeWorkspace}>Retry live file</WakeButton>
440
+ ) : null}
441
+ </Notice>
418
442
  ) : waitingForSelectedFile ? (
419
443
  <Notice
420
444
  icon={
@@ -455,6 +479,12 @@ export function SandboxFiles({
455
479
  </Notice>
456
480
  ) : fileView.loading ? (
457
481
  <Notice announce="status">Loading {viewPath}…</Notice>
482
+ ) : fileView.imageUrl ? (
483
+ <RasterFilePreview
484
+ key={fileView.imageUrl}
485
+ src={fileView.imageUrl}
486
+ path={viewPath}
487
+ />
458
488
  ) : fileView.isBinary ? (
459
489
  <Notice>
460
490
  {viewPath} is a binary file ({fileView.sizeBytes ?? 0} bytes).
@@ -497,18 +527,27 @@ export function SandboxFiles({
497
527
  )
498
528
  ) : // Nothing selected — the tree shows the whole workspace; pick a file.
499
529
  requestedPath && !requestedPathReady ? (
500
- <Notice
501
- icon={
502
- <LoaderCircleIcon
503
- className="size-5 animate-spin motion-reduce:animate-none"
504
- aria-hidden
505
- />
506
- }
507
- title="Waking sandbox"
508
- announce="status"
509
- >
510
- Opening {requestedPath} when the live workspace is ready…
511
- </Notice>
530
+ workspaceError ? (
531
+ <Notice title="Could not open live file" announce="alert">
532
+ <p>{workspaceError.message}</p>
533
+ {onWakeWorkspace ? (
534
+ <WakeButton onClick={onWakeWorkspace}>Retry live file</WakeButton>
535
+ ) : null}
536
+ </Notice>
537
+ ) : (
538
+ <Notice
539
+ icon={
540
+ <LoaderCircleIcon
541
+ className="size-5 animate-spin motion-reduce:animate-none"
542
+ aria-hidden
543
+ />
544
+ }
545
+ title="Waking sandbox"
546
+ announce="status"
547
+ >
548
+ Opening {requestedPath} when the live workspace is ready…
549
+ </Notice>
550
+ )
512
551
  ) : (
513
552
  <Notice icon={<FileCode2Icon className="size-5" aria-hidden />} title="Choose a file">
514
553
  Select a file in the tree to preview it.
@@ -628,8 +667,10 @@ function GitHeader({ git, loading }: { git: SandboxFilesGitSummary; loading: boo
628
667
  </span>
629
668
  {(git.ahead > 0 || git.behind > 0) && (
630
669
  <span className="flex shrink-0 items-center gap-1.5 text-og-xs text-og-fg-subtle">
631
- {git.ahead > 0 && <span>↑{git.ahead}</span>}
632
- {git.behind > 0 && <span>↓{git.behind}</span>}
670
+ {/* Small arrow glyphs can make axe's pixel overlap heuristic inconclusive.
671
+ Keep both counters in the same computed-contrast audit as the dirty count. */}
672
+ {git.ahead > 0 && <span data-contrast-audited>↑{git.ahead}</span>}
673
+ {git.behind > 0 && <span data-contrast-audited>↓{git.behind}</span>}
633
674
  </span>
634
675
  )}
635
676
  {dirty && (
@@ -677,6 +718,7 @@ function Segmented({
677
718
 
678
719
  type FileViewState = {
679
720
  content: string | null;
721
+ imageUrl?: string | null;
680
722
  isBinary: boolean;
681
723
  /** The backend truncated the read (size cap hit) — content is a PREFIX only.
682
724
  * Editing+saving such a file would write the prefix back and corrupt it, so
@@ -749,6 +791,8 @@ function useFileView(
749
791
  : res.content;
750
792
  setState({
751
793
  content,
794
+ imageUrl:
795
+ !res.truncated && res.encoding === "base64" ? rasterImageUrl(path, res.content) : null,
752
796
  isBinary: res.isBinary,
753
797
  truncated: res.truncated,
754
798
  sizeBytes: res.sizeBytes,
@@ -780,6 +824,38 @@ function useFileView(
780
824
  return state;
781
825
  }
782
826
 
827
+ /** Only raster formats belong in this preview; never interpret SVG/HTML as a document. */
828
+ function rasterImageUrl(path: string, content: string): string | null {
829
+ const extension = path.split(".").pop()?.toLowerCase();
830
+ const mime =
831
+ extension === "png"
832
+ ? "image/png"
833
+ : extension === "jpg" || extension === "jpeg"
834
+ ? "image/jpeg"
835
+ : extension === "gif"
836
+ ? "image/gif"
837
+ : extension === "webp"
838
+ ? "image/webp"
839
+ : null;
840
+ return mime ? `data:${mime};base64,${content}` : null;
841
+ }
842
+
843
+ function RasterFilePreview({ src, path }: { src: string; path: string }) {
844
+ const [failed, setFailed] = useState(false);
845
+ return failed ? (
846
+ <Notice title="Image preview unavailable" announce="alert">
847
+ Could not decode {path} as an image.
848
+ </Notice>
849
+ ) : (
850
+ <img
851
+ src={src}
852
+ alt={path}
853
+ onError={() => setFailed(true)}
854
+ className="max-w-full object-contain"
855
+ />
856
+ );
857
+ }
858
+
783
859
  /** Decode a base64 payload to a UTF-8 string (browser `atob` + TextDecoder). */
784
860
  function decodeBase64Utf8(b64: string): string {
785
861
  try {
@@ -781,7 +781,20 @@ export function useSandboxWorkspaceTabs(
781
781
  }
782
782
  workspaceWaking={!liveWorkspaceExpected && !captureAvailable && workspaceWaking}
783
783
  liveWorkspaceReady={liveWorkspaceExpected}
784
- onWakeWorkspace={() => requestWarmIntent("warmFiles")}
784
+ workspaceError={
785
+ caps.error ??
786
+ (caps.viewerCapReached
787
+ ? new Error(
788
+ "The live workspace viewer limit has been reached. Close another viewer and retry.",
789
+ )
790
+ : filesWarmRequested && caps.state === "on-demand" && !fileSystemOn
791
+ ? new Error("This machine does not currently expose a live file system.")
792
+ : null)
793
+ }
794
+ onWakeWorkspace={() => {
795
+ if (filesWarmRequested) caps.renegotiate();
796
+ else requestWarmIntent("warmFiles");
797
+ }}
785
798
  {...(requestedFilePath
786
799
  ? {
787
800
  requestedPath: requestedFilePath,
@@ -943,6 +956,8 @@ export function useSandboxWorkspaceTabs(
943
956
  caps.state,
944
957
  caps.error,
945
958
  caps.viewerCapReached,
959
+ caps.renegotiate,
960
+ filesWarmRequested,
946
961
  requestWarmIntent,
947
962
  createLinkedComputer,
948
963
  ]);
@@ -30,6 +30,8 @@ export type UseSessionEventsResult = {
30
30
  timeline: TimelineItem[];
31
31
  /** Latest session status observed in the event log, if any. */
32
32
  sessionStatus: SessionStatus | null;
33
+ /** Sequence of the retained status projection, including events evicted from the window. */
34
+ sessionStatusSequence?: number;
33
35
  connectionState: SessionEventsConnectionState;
34
36
  /** Highest sequence seen so far (0 before the first event). */
35
37
  lastSequence: number;
@@ -875,6 +877,7 @@ export function useSessionEvents(
875
877
  events: visibleEvents,
876
878
  timeline,
877
879
  sessionStatus: identityMatches ? sessionStatusProjection : null,
880
+ sessionStatusSequence: identityMatches ? sessionStatusRef.current.sequence : 0,
878
881
  connectionState: identityMatches ? connectionState : "idle",
879
882
  lastSequence: identityMatches ? lastSequenceRef.current : after,
880
883
  windowBytes: identityMatches ? eventWindow.bytes : 2,
@@ -39,7 +39,13 @@ export function isTitleEvent(event: Pick<SessionEvent, "type">): boolean {
39
39
  }
40
40
 
41
41
  function isSessionDetailEvent(event: Pick<SessionEvent, "type">): boolean {
42
- return isTitleEvent(event) || event.type.startsWith("session.command.");
42
+ return (
43
+ isTitleEvent(event) ||
44
+ event.type.startsWith("session.command.") ||
45
+ event.type.startsWith("session.wait.") ||
46
+ event.type === "session.status.changed" ||
47
+ event.type === "system.update.pending"
48
+ );
43
49
  }
44
50
 
45
51
  /** Fetch one session (with optional polling), live-patching its title on `session.title_set`. */
@@ -115,12 +121,13 @@ export function useSession(
115
121
  sharedEvents?.some(
116
122
  (item) =>
117
123
  item.sessionId === sessionId &&
118
- item.type.startsWith("session.command.") &&
124
+ !isTitleEvent(item) &&
125
+ isSessionDetailEvent(item) &&
119
126
  item.sequence > (base?.lastSequence ?? -1),
120
127
  )
121
128
  ) {
122
129
  void refresh();
123
- } else if (event.type.startsWith("session.command.")) {
130
+ } else if (!isTitleEvent(event)) {
124
131
  if (!base || event.sequence > base.lastSequence) void refresh();
125
132
  return;
126
133
  }
package/src/index.ts CHANGED
@@ -500,6 +500,7 @@ export {
500
500
  labelLatencyMode,
501
501
  labelReasoningEffort,
502
502
  payerSummaryForModel,
503
+ modelUsesCredits,
503
504
  projectClientModelRows,
504
505
  projectPickerRows,
505
506
  runnableLatencyModesForModel,
@@ -116,6 +116,12 @@ export function labelReasoningEffort(effort: ReasoningEffort): string {
116
116
  return effort.slice(0, 1).toUpperCase() + effort.slice(1);
117
117
  }
118
118
 
119
+ /** Payment prompts must use cost policy, never the presentation group. */
120
+ export function modelUsesCredits(model: ClientModel | undefined): boolean {
121
+ if (model?.cost !== undefined) return model.cost === "credits";
122
+ return model?.billing?.metering === "opengeni_credits";
123
+ }
124
+
119
125
  export function payerSummaryForModel(model: ClientModel): string {
120
126
  if (model.cost === "free") {
121
127
  return "Free in this deployment";
@@ -131,6 +137,9 @@ export function payerSummaryForModel(model: ClientModel): string {
131
137
  if (model.cost === "workspace") {
132
138
  return workspaceProviderPayerSummary(model);
133
139
  }
140
+ if (model.cost === "organization") {
141
+ return organizationProviderPayerSummary(model);
142
+ }
134
143
 
135
144
  // Older client-config payloads do not carry `cost`; preserve their existing
136
145
  // settlement-derived label until every deployment has rolled forward.
@@ -149,14 +158,19 @@ export function payerSummaryForModel(model: ClientModel): string {
149
158
  if (billing.upstreamPayer === "workspace") {
150
159
  return workspaceProviderPayerSummary(model);
151
160
  }
152
- return "External provider · no OpenGeni credits";
161
+ if (billing.upstreamPayer === "organization") {
162
+ return organizationProviderPayerSummary(model);
163
+ }
164
+ return billing.upstreamPayer === "deployment"
165
+ ? "OpenGeni · no model credits"
166
+ : "External provider · no OpenGeni credits";
153
167
  }
154
168
 
155
169
  export function advancedSourceSummary(model: ClientModel): string | null {
156
170
  const source = model.credentialSource;
157
171
  if (!source) {
158
172
  return model.billing?.metering === "external" && model.billing.upstreamPayer === "deployment"
159
- ? "Deployment route · no authentication"
173
+ ? "Deployment-provided connection"
160
174
  : null;
161
175
  }
162
176
  if (source.kind === "connected_subscription") {
@@ -191,6 +205,16 @@ function workspaceProviderPayerSummary(model: ClientModel): string {
191
205
  return "Billed to the workspace provider account";
192
206
  }
193
207
 
208
+ function organizationProviderPayerSummary(model: ClientModel): string {
209
+ if (model.provider === "organization-openrouter") {
210
+ return "Billed to the organization OpenRouter account";
211
+ }
212
+ if (model.provider === "organization-gateway") {
213
+ return "Billed to the organization Vercel account";
214
+ }
215
+ return "Billed to the organization provider account";
216
+ }
217
+
194
218
  export function projectPickerRows(models: WorkspaceModelCatalogModel[]): PickerModelRow[] {
195
219
  return models
196
220
  .filter((catalog) => {