@opengeni/react 0.23.0 → 0.25.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 (38) hide show
  1. package/dist/{chunk-UQS7OQDE.js → chunk-AVPU5PMC.js} +6 -4
  2. package/dist/chunk-AVPU5PMC.js.map +1 -0
  3. package/dist/{chunk-CATATY77.js → chunk-RDPDU4TA.js} +7 -6
  4. package/dist/chunk-RDPDU4TA.js.map +1 -0
  5. package/dist/{chunk-UAHPKLB6.js → chunk-SHOFILHJ.js} +18 -8
  6. package/dist/chunk-SHOFILHJ.js.map +1 -0
  7. package/dist/{composer-CVm5uoUI.d.ts → composer-BXb0Q1HF.d.ts} +18 -4
  8. package/dist/composer.d.ts +2 -2
  9. package/dist/composer.js +2 -2
  10. package/dist/fleet-decision-projection-SLNZMS42.js +241 -0
  11. package/dist/fleet-decision-projection-SLNZMS42.js.map +1 -0
  12. package/dist/fleet-decision-row-GGCAT4E4.js +351 -0
  13. package/dist/fleet-decision-row-GGCAT4E4.js.map +1 -0
  14. package/dist/index.d.ts +12 -9
  15. package/dist/index.js +332 -224
  16. package/dist/index.js.map +1 -1
  17. package/dist/{session-Db00TRwl.d.ts → session-BEvtFWhe.d.ts} +40 -14
  18. package/dist/session.d.ts +2 -2
  19. package/dist/session.js +2 -2
  20. package/dist/{use-composer-By1wn3MM.d.ts → use-composer-BnEzheSk.d.ts} +8 -2
  21. package/package.json +2 -2
  22. package/src/components/composer.tsx +7 -6
  23. package/src/components/message-timeline.tsx +2 -2
  24. package/src/hooks/use-composer.ts +15 -5
  25. package/src/hooks/use-file-attachments.ts +95 -2
  26. package/src/hooks/use-goal.ts +22 -4
  27. package/src/hooks/use-workspace-sessions.ts +30 -5
  28. package/src/timeline/activity-rail.tsx +17 -1
  29. package/src/timeline/fleet-decision-projection.ts +366 -0
  30. package/src/timeline/fleet-decision-row.tsx +412 -0
  31. package/src/timeline/index.ts +2 -0
  32. package/src/timeline/projection.ts +19 -8
  33. package/src/timeline/tool-display-name.ts +16 -0
  34. package/src/timeline/tool-renderers.tsx +1 -1
  35. package/src/timeline/types.ts +68 -1
  36. package/dist/chunk-CATATY77.js.map +0 -1
  37. package/dist/chunk-UAHPKLB6.js.map +0 -1
  38. package/dist/chunk-UQS7OQDE.js.map +0 -1
@@ -1,6 +1,6 @@
1
1
  import { E as EmbeddedSessionClientOverride, e as EmbeddedSessionMcpApprovalPolicyClientOverride, f as EmbeddedHumanInputClientOverride } from './session-context-rgrfElMl.js';
2
2
  import { ToolAuthNeededPayload, ResourceRef, ToolRef, SessionStatus, SessionEvent, StreamConnectionState, SessionQueueSnapshot, SessionTurn, EffectiveSessionControl, ComposerDraft, SessionControlResponse, SessionMcpServerMetadata, SessionMcpApprovalPolicy, UpdateSessionMcpApprovalPolicyResponse, HumanInputQuestion, SessionHumanInputRequest, SubmitHumanInputResponseRequest } from '@opengeni/sdk';
3
- import { S as SessionEventFeedOptions } from './use-composer-By1wn3MM.js';
3
+ import { S as SessionEventFeedOptions } from './use-composer-BnEzheSk.js';
4
4
 
5
5
  type UserMessageItem = {
6
6
  kind: "user-message";
@@ -125,6 +125,41 @@ type MemoryItem = {
125
125
  replacementMemoryId?: string;
126
126
  occurredAt: string;
127
127
  };
128
+ type FleetDecisionScoreItem = {
129
+ candidateKey: string;
130
+ eligible: boolean;
131
+ rejectionReason: "allocator_disabled" | "unavailable" | "cooling" | "quota_ceiling" | "overlay_isolation" | null;
132
+ total: number;
133
+ confidence: "unknown" | "low" | "medium" | "high";
134
+ };
135
+ /**
136
+ * One production-vs-shadow placement explanation. Candidate keys are
137
+ * event-local aliases only; no credential/account identity reaches this item.
138
+ */
139
+ type FleetDecisionItem = {
140
+ kind: "fleet-decision";
141
+ id: string;
142
+ turnId: string | null;
143
+ policyVersion: "adaptive-shadow-v1";
144
+ actualOutcome: "selected" | "waiting" | "none";
145
+ actualCandidateKey: string | null;
146
+ actualReason: "lease_reused" | "pin" | "rotation" | "active" | "all_capped" | "none";
147
+ shadowOutcome: "selected" | "paced" | "none";
148
+ shadowCandidateKey: string | null;
149
+ shadowReason: "fenced_in_flight" | "fenced_candidate_missing" | "admission_paced" | "no_eligible_candidate" | "overlay_isolated_empty" | "best_score" | "affinity_best" | "hysteresis_hold";
150
+ comparison: "match" | "different_candidate" | "different_outcome" | "not_comparable_truncated";
151
+ confidence: "unknown" | "low" | "medium" | "high";
152
+ admissionOutcome: "admit" | "pace";
153
+ admissionReason: "fenced_in_flight" | "pacing_disabled" | "capacity_unknown" | "capacity_available" | "work_conserving_borrow" | "manager_priority" | "standard_starvation_bound" | "capacity_saturated" | "emergency_fuse";
154
+ borrowedIdleCapacity: boolean;
155
+ borrowedOverlayCapacity: boolean;
156
+ strandedEligibleCount: number;
157
+ candidateCount: number;
158
+ truncatedCandidateCount: number;
159
+ scoreRowsTruncatedCount: number;
160
+ scores: FleetDecisionScoreItem[];
161
+ occurredAt: string;
162
+ };
128
163
  type SessionStatusItem = {
129
164
  kind: "session-status";
130
165
  id: string;
@@ -192,9 +227,9 @@ type TurnEndItem = {
192
227
  failureText: string | null;
193
228
  occurredAt: string;
194
229
  };
195
- type TimelineItem = UserMessageItem | AgentMessageItem | ReasoningItem | ToolCallItem | WorkerItem | WorkerCompletionItem | SandboxItem | SessionStatusItem | GoalItem | NoticeItem | AuthNeededItem | MemoryItem | TurnEndItem;
230
+ type TimelineItem = UserMessageItem | AgentMessageItem | ReasoningItem | ToolCallItem | WorkerItem | WorkerCompletionItem | SandboxItem | SessionStatusItem | GoalItem | NoticeItem | AuthNeededItem | MemoryItem | FleetDecisionItem | TurnEndItem;
196
231
  /** Activity items cluster between chat messages (reasoning, tools, workers, sandbox, memory). */
197
- type ActivityItem = ReasoningItem | ToolCallItem | WorkerItem | SandboxItem | MemoryItem;
232
+ type ActivityItem = ReasoningItem | ToolCallItem | WorkerItem | SandboxItem | MemoryItem | FleetDecisionItem;
198
233
  type TimelineGroup = {
199
234
  kind: "item";
200
235
  item: TimelineItem;
@@ -414,6 +449,8 @@ type UseHumanInputRequestsResult = {
414
449
  */
415
450
  declare function useHumanInputRequests(sessionId: string | null | undefined, options?: UseHumanInputRequestsOptions): UseHumanInputRequestsResult;
416
451
 
452
+ /** Readable label for a tool call, without leaking an MCP server prefix. */
453
+ declare function toolDisplayName(name: string): string;
417
454
  declare function buildTimeline(events: SessionEvent[]): TimelineItem[];
418
455
  /**
419
456
  * Whether the session's most recent turn ended in credit exhaustion — the
@@ -431,16 +468,5 @@ declare function groupTimeline(items: TimelineItem[]): TimelineGroup[];
431
468
  * text: "{...}" }], structuredContent? }`).
432
469
  */
433
470
  declare function extractSessionRef(value: unknown, depth?: number): string | null;
434
- /**
435
- * Readable label for a tool call ("session_create" -> "session create").
436
- *
437
- * MCP tools are namespaced `<serverId>__<toolName>` (see prefixedMcpToolName),
438
- * and for catalog-imported servers that serverId is an opaque slug+hash
439
- * ("mcp-integrations-sh-supabase-com-34ed9dcf1390-0i6tcf8"). De-slugging the
440
- * whole thing leaked that id into the timeline; strip the server prefix and show
441
- * just the tool ("list organizations"). Names without the `__` boundary (plain
442
- * built-ins like "session_create") are unaffected.
443
- */
444
- declare function toolDisplayName(name: string): string;
445
471
 
446
472
  export { useTurnQueue as $, type ActivityItem as A, type BrowserSessionEventWindow as B, buildTimeline as C, creditExhaustedFromEvents as D, extractSessionRef as E, groupTimeline as F, type GoalItem as G, humanInputRequestFromEvent as H, isHumanInputEvent as I, isSessionMcpApprovalPolicyEvent as J, isTurnQueueEvent as K, projectPendingApprovals as L, projectPendingHumanInputRequests as M, type NoticeItem as N, sessionStatusFromEvents as O, type PendingApproval as P, type QueueMutationKind as Q, type ReasoningItem as R, SESSION_EVENT_BROWSER_MAX_BYTES as S, type ToolCallItem as T, type UseTurnQueueResult as U, toolDisplayName as V, type WorkerItem as W, useHumanInputRequests as X, useSessionControl as Y, useSessionEvents as Z, useSessionMcpApprovalPolicy as _, type TurnOutcome as a, type MemoryItem as a0, type WorkerCompletionItem as a1, type TimelineItem as b, type AgentMessageItem as c, type UserMessageItem as d, type AuthNeededItem as e, type PendingHumanInputRequest as f, SESSION_EVENT_BROWSER_MAX_COUNT as g, SESSION_EVENT_BROWSER_PENDING_MAX_BYTES as h, SESSION_EVENT_BROWSER_PENDING_MAX_COUNT as i, SESSION_EVENT_BROWSER_SINGLE_EVENT_MAX_BYTES as j, type SandboxItem as k, type SessionEventsConnectionState as l, type SessionStatusItem as m, type TimelineGroup as n, type TurnEndItem as o, type UseHumanInputRequestsOptions as p, type UseHumanInputRequestsResult as q, type UseSessionControlOptions as r, type UseSessionControlResult as s, type UseSessionEventsOptions as t, type UseSessionEventsResult as u, type UseSessionMcpApprovalPolicyOptions as v, type UseSessionMcpApprovalPolicyResult as w, type UseTurnQueueOptions as x, approvalsFromRequiresAction as y, boundBrowserSessionEventWindow as z };
package/dist/session.d.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  export { E as ClientOverride, f as HumanInputClientOverride, a as HumanInputSessionClientLike, b as SessionClientLike, c as SessionMcpApprovalPolicyClientLike, e as SessionMcpApprovalPolicyClientOverride } from './session-context-rgrfElMl.js';
2
- export { A as ActivityItem, c as AgentMessageItem, e as AuthNeededItem, G as GoalItem, a0 as MemoryItem, N as NoticeItem, P as PendingApproval, f as PendingHumanInputRequest, Q as QueueMutationKind, R as ReasoningItem, k as SandboxItem, l as SessionEventsConnectionState, m as SessionStatusItem, n as TimelineGroup, b as TimelineItem, T as ToolCallItem, o as TurnEndItem, a as TurnOutcome, p as UseHumanInputRequestsOptions, q as UseHumanInputRequestsResult, r as UseSessionControlOptions, s as UseSessionControlResult, t as UseSessionEventsOptions, u as UseSessionEventsResult, v as UseSessionMcpApprovalPolicyOptions, w as UseSessionMcpApprovalPolicyResult, x as UseTurnQueueOptions, U as UseTurnQueueResult, d as UserMessageItem, a1 as WorkerCompletionItem, W as WorkerItem, y as approvalsFromRequiresAction, C as buildTimeline, D as creditExhaustedFromEvents, E as extractSessionRef, F as groupTimeline, H as humanInputRequestFromEvent, I as isHumanInputEvent, J as isSessionMcpApprovalPolicyEvent, K as isTurnQueueEvent, L as projectPendingApprovals, M as projectPendingHumanInputRequests, O as sessionStatusFromEvents, V as toolDisplayName, X as useHumanInputRequests, Y as useSessionControl, Z as useSessionEvents, _ as useSessionMcpApprovalPolicy, $ as useTurnQueue } from './session-Db00TRwl.js';
3
- export { a as ComposerSendExtras, C as ComposerState, F as FILE_ONLY_MESSAGE_TEXT, U as UseComposerOptions, c as composeSendInput, s as shouldSteerOnKey, b as shouldSubmitOnKey, u as useComposer } from './use-composer-By1wn3MM.js';
2
+ export { A as ActivityItem, c as AgentMessageItem, e as AuthNeededItem, G as GoalItem, a0 as MemoryItem, N as NoticeItem, P as PendingApproval, f as PendingHumanInputRequest, Q as QueueMutationKind, R as ReasoningItem, k as SandboxItem, l as SessionEventsConnectionState, m as SessionStatusItem, n as TimelineGroup, b as TimelineItem, T as ToolCallItem, o as TurnEndItem, a as TurnOutcome, p as UseHumanInputRequestsOptions, q as UseHumanInputRequestsResult, r as UseSessionControlOptions, s as UseSessionControlResult, t as UseSessionEventsOptions, u as UseSessionEventsResult, v as UseSessionMcpApprovalPolicyOptions, w as UseSessionMcpApprovalPolicyResult, x as UseTurnQueueOptions, U as UseTurnQueueResult, d as UserMessageItem, a1 as WorkerCompletionItem, W as WorkerItem, y as approvalsFromRequiresAction, C as buildTimeline, D as creditExhaustedFromEvents, E as extractSessionRef, F as groupTimeline, H as humanInputRequestFromEvent, I as isHumanInputEvent, J as isSessionMcpApprovalPolicyEvent, K as isTurnQueueEvent, L as projectPendingApprovals, M as projectPendingHumanInputRequests, O as sessionStatusFromEvents, V as toolDisplayName, X as useHumanInputRequests, Y as useSessionControl, Z as useSessionEvents, _ as useSessionMcpApprovalPolicy, $ as useTurnQueue } from './session-BEvtFWhe.js';
3
+ export { a as ComposerSendExtras, C as ComposerState, F as FILE_ONLY_MESSAGE_TEXT, U as UseComposerOptions, c as composeSendInput, s as shouldSteerOnKey, b as shouldSubmitOnKey, u as useComposer } from './use-composer-BnEzheSk.js';
4
4
  import '@opengeni/sdk';
package/dist/session.js CHANGED
@@ -17,14 +17,14 @@ import {
17
17
  useSessionEvents,
18
18
  useSessionMcpApprovalPolicy,
19
19
  useTurnQueue
20
- } from "./chunk-UAHPKLB6.js";
20
+ } from "./chunk-SHOFILHJ.js";
21
21
  import {
22
22
  FILE_ONLY_MESSAGE_TEXT,
23
23
  composeSendInput,
24
24
  shouldSteerOnKey,
25
25
  shouldSubmitOnKey,
26
26
  useComposer
27
- } from "./chunk-UQS7OQDE.js";
27
+ } from "./chunk-AVPU5PMC.js";
28
28
  import "./chunk-I3BJZIG5.js";
29
29
  export {
30
30
  FILE_ONLY_MESSAGE_TEXT,
@@ -13,14 +13,20 @@ type SessionEventFeedOptions = {
13
13
 
14
14
  type ComposerSendExtras = Omit<SendMessageInput, "text" | "clientEventId">;
15
15
  type UseComposerOptions = EmbeddedSessionClientOverride & SessionEventFeedOptions & {
16
- /** Called with the accepted text after a successful send. */
17
- onSent?: ((text: string) => void) | undefined;
16
+ /** Called with the exact accepted wire input after a successful send. */
17
+ onSent?: ((text: string, input: SendMessageInput) => void) | undefined;
18
18
  /**
19
19
  * Extra message fields (resources, tools, model, reasoningEffort) merged
20
20
  * into every send. A function is evaluated at send time so it can read the
21
21
  * surrounding UI state (attachment pickers, model selectors, ...).
22
22
  */
23
23
  sendExtras?: ComposerSendExtras | (() => ComposerSendExtras) | undefined;
24
+ /**
25
+ * Fail-closed delivery guard evaluated at send time. Attachment hosts use
26
+ * this to preserve unresolved upload cards until the operator waits,
27
+ * retries, or removes them; direct hook callers cannot bypass the UI gate.
28
+ */
29
+ sendBlocked?: (() => boolean) | undefined;
24
30
  /** Latest server-derived workstream control; bound into Send/Steer OCC. */
25
31
  effectiveControl?: EffectiveSessionControl | null | undefined;
26
32
  /** Apply durable model/tool/reasoning settings in the host's controlled UI. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/react",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "React hooks and styled components for OpenGeni: live session streaming, chat composer, message timeline, session status, and fleet views — token-themed (CSS variables), dark-first, built on Tailwind v4 + Radix + Motion.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -62,7 +62,7 @@
62
62
  "@dnd-kit/utilities": "3.2.2",
63
63
  "@fontsource-variable/noto-sans-arabic": "5.2.10",
64
64
  "@fontsource-variable/noto-sans-jp": "5.2.10",
65
- "@opengeni/sdk": "^0.23.0",
65
+ "@opengeni/sdk": "^0.25.0",
66
66
  "clsx": "^2.1.1",
67
67
  "lucide-react": "^1.8.0",
68
68
  "motion": "^12.0.0",
@@ -199,7 +199,7 @@ export const defaultChatComposerMessages: ChatComposerMessages = {
199
199
  export type ComposerSubmitMode = "queue" | "steer";
200
200
  export type ComposerSubmitBlocker =
201
201
  | "disabled"
202
- | "uploading"
202
+ | "attachment"
203
203
  | "sending"
204
204
  | "command"
205
205
  | "empty"
@@ -292,7 +292,7 @@ export function useChatComposerController({
292
292
  return () => document.removeEventListener(OPEN_WORKSTREAM_CONTROL_EVENT, openControl);
293
293
  }, [id, paused]);
294
294
 
295
- const blockedByUpload = attachments?.uploading === true;
295
+ const blockedByAttachment = attachments?.hasUnresolved === true;
296
296
  const hasReadyAttachment = (attachments?.readyResources.length ?? 0) > 0;
297
297
 
298
298
  const [dragging, setDragging] = useState(false);
@@ -386,8 +386,8 @@ export function useChatComposerController({
386
386
 
387
387
  const submitBlocker: ComposerSubmitBlocker = disabled
388
388
  ? "disabled"
389
- : blockedByUpload
390
- ? "uploading"
389
+ : blockedByAttachment
390
+ ? "attachment"
391
391
  : delivery.sending || submitting
392
392
  ? "sending"
393
393
  : commandDraftBlocked
@@ -404,7 +404,8 @@ export function useChatComposerController({
404
404
  delivery.clearError();
405
405
  return false;
406
406
  }
407
- if (disabled || blockedByUpload || delivery.sending || submittingRef.current) return false;
407
+ if (disabled || blockedByAttachment || delivery.sending || submittingRef.current)
408
+ return false;
408
409
  if (!delivery.canSend && !hasReadyAttachment) return false;
409
410
  submittingRef.current = true;
410
411
  setSubmitting(true);
@@ -416,7 +417,7 @@ export function useChatComposerController({
416
417
  }
417
418
  },
418
419
  [
419
- blockedByUpload,
420
+ blockedByAttachment,
420
421
  commandDraftBlocked,
421
422
  delivery,
422
423
  disabled,
@@ -731,8 +731,8 @@ function clusterIsSettled(group: Extract<TimelineGroup, { kind: "activity" }>):
731
731
  if (item.kind === "reasoning") {
732
732
  return !item.streaming;
733
733
  }
734
- // A memory write is a discrete, already-settled event — it has no running state.
735
- if (item.kind === "memory") {
734
+ // Memory writes and fleet observations are discrete, already-settled events.
735
+ if (item.kind === "memory" || item.kind === "fleet-decision") {
736
736
  return true;
737
737
  }
738
738
  return item.status !== "running";
@@ -15,14 +15,20 @@ export type ComposerSendExtras = Omit<SendMessageInput, "text" | "clientEventId"
15
15
 
16
16
  export type UseComposerOptions = EmbeddedSessionClientOverride &
17
17
  SessionEventFeedOptions & {
18
- /** Called with the accepted text after a successful send. */
19
- onSent?: ((text: string) => void) | undefined;
18
+ /** Called with the exact accepted wire input after a successful send. */
19
+ onSent?: ((text: string, input: SendMessageInput) => void) | undefined;
20
20
  /**
21
21
  * Extra message fields (resources, tools, model, reasoningEffort) merged
22
22
  * into every send. A function is evaluated at send time so it can read the
23
23
  * surrounding UI state (attachment pickers, model selectors, ...).
24
24
  */
25
25
  sendExtras?: ComposerSendExtras | (() => ComposerSendExtras) | undefined;
26
+ /**
27
+ * Fail-closed delivery guard evaluated at send time. Attachment hosts use
28
+ * this to preserve unresolved upload cards until the operator waits,
29
+ * retries, or removes them; direct hook callers cannot bypass the UI gate.
30
+ */
31
+ sendBlocked?: (() => boolean) | undefined;
26
32
  /** Latest server-derived workstream control; bound into Send/Steer OCC. */
27
33
  effectiveControl?: EffectiveSessionControl | null | undefined;
28
34
  /** Apply durable model/tool/reasoning settings in the host's controlled UI. */
@@ -106,6 +112,8 @@ export function useComposer(
106
112
  // callers passing inline functions) does not invalidate `send`.
107
113
  const sendExtrasRef = useRef(options.sendExtras);
108
114
  sendExtrasRef.current = options.sendExtras;
115
+ const sendBlockedRef = useRef(options.sendBlocked);
116
+ sendBlockedRef.current = options.sendBlocked;
109
117
  const liveExtrasVersion = JSON.stringify(resolveSendExtras(options.sendExtras));
110
118
 
111
119
  // A composer is bound to one session: switching targets must not leak the
@@ -378,6 +386,7 @@ export function useComposer(
378
386
  (!hasText && !hasResources) ||
379
387
  !sessionId ||
380
388
  sending ||
389
+ sendBlockedRef.current?.() === true ||
381
390
  targetKeyRef.current !== ownedTargetKey
382
391
  ) {
383
392
  return false;
@@ -457,7 +466,7 @@ export function useComposer(
457
466
  setValue("");
458
467
  }
459
468
  }
460
- onSent?.(sendText);
469
+ onSent?.(sendText, input);
461
470
  return true;
462
471
  } catch (cause) {
463
472
  if (
@@ -498,8 +507,8 @@ export function useComposer(
498
507
  // A send is possible with non-empty text OR with ≥1 attached resource (a
499
508
  // file-only message). Resources ride in `sendExtras`, so we resolve them here
500
509
  // — keeping useComposer attachment-agnostic while still lighting up the send
501
- // affordance the moment a file is ready. ChatComposer additionally gates this
502
- // on its `attachments.uploading` flag so a message never departs mid-upload.
510
+ // affordance the moment a file is ready. Attachment hosts bind `sendBlocked`
511
+ // to unresolved uploads so direct send()/steer() calls fail closed too.
503
512
  const hasReadyResources =
504
513
  restoredResources.length > 0 ||
505
514
  (resolveSendExtras(sendExtrasRef.current).resources?.length ?? 0) > 0;
@@ -717,6 +726,7 @@ export function useComposer(
717
726
  identityMatches &&
718
727
  Boolean(sessionId) &&
719
728
  !sending &&
729
+ sendBlockedRef.current?.() !== true &&
720
730
  (value.trim().length > 0 || hasReadyResources),
721
731
  pause,
722
732
  pausing: identityMatches ? pausing : false,
@@ -33,12 +33,20 @@ export type UseFileAttachmentsResult = {
33
33
  * straight into `useComposer`'s `sendExtras.resources`.
34
34
  */
35
35
  readyResources: FileResourceRef[];
36
- /** True while any attachment is still uploading (drives the send-gate). */
36
+ /** True while any attachment is still uploading (drives progress UI). */
37
37
  uploading: boolean;
38
+ /**
39
+ * True while any attachment still needs an explicit outcome: wait for an
40
+ * upload, retry a failure, or remove it. This is the loss-prevention send
41
+ * gate; failed cards must never be silently omitted from a message.
42
+ */
43
+ hasUnresolved: boolean;
38
44
  /** Explicit picker / drop path — uploads every file, no filter. */
39
45
  addFiles: (files: Iterable<File>) => void;
40
46
  /** Clipboard path — applies `pasteFilter` (default `image/*`) then uploads. */
41
47
  addFromPaste: (event: { clipboardData: DataTransfer | null }) => void;
48
+ /** Restore already-ready server assets without recreating browser-local bytes. */
49
+ restoreReadyFiles: (files: Iterable<FileAsset>) => void;
42
50
  /**
43
51
  * Re-run the upload for a `failed` attachment, in place (same id, same
44
52
  * source file). No-op for an id that isn't a known failed upload.
@@ -46,7 +54,13 @@ export type UseFileAttachmentsResult = {
46
54
  retry: (id: string) => void;
47
55
  /** Remove one attachment; revokes its object-URL. */
48
56
  remove: (id: string) => void;
49
- /** Remove all; revokes every object-URL. Call from `useComposer`'s `onSent`. */
57
+ /**
58
+ * Remove only finalized files whose durable ids were accepted by a send.
59
+ * Attachments added while that request was in flight remain queued for the
60
+ * next message.
61
+ */
62
+ removeReadyFiles: (fileIds: Iterable<string>) => void;
63
+ /** Remove all attachments and revoke every object-URL. */
50
64
  clear: () => void;
51
65
  };
52
66
 
@@ -83,6 +97,10 @@ export function useFileAttachments(
83
97
  data: file,
84
98
  })
85
99
  .then((asset) => {
100
+ // Retry bytes are useful only until durable finalization succeeds.
101
+ // Drop the source File immediately; restored/ready attachments must
102
+ // never retain browser-local byte authority.
103
+ sources.current.delete(id);
86
104
  setAttachments((current) =>
87
105
  current.map((attachment) =>
88
106
  attachment.id === id
@@ -171,6 +189,60 @@ export function useFileAttachments(
171
189
  [addFiles, pasteFilter],
172
190
  );
173
191
 
192
+ const restoreReadyFiles = useCallback(
193
+ (files: Iterable<FileAsset>) => {
194
+ const incoming = new Map<string, FileAsset>();
195
+ for (const file of files) {
196
+ if (file.status === "ready" && file.workspaceId === workspaceId) {
197
+ incoming.set(file.id, file);
198
+ }
199
+ }
200
+ setAttachments((current) => {
201
+ const unresolved = current.filter((attachment) => attachment.status !== "ready");
202
+ const existingReady = new Map(
203
+ current.flatMap((attachment) =>
204
+ attachment.status === "ready" && attachment.file
205
+ ? ([[attachment.file.id, attachment]] as const)
206
+ : [],
207
+ ),
208
+ );
209
+ const restored = [...incoming.values()].map((file): FileAttachment => {
210
+ const existing = existingReady.get(file.id);
211
+ return existing
212
+ ? {
213
+ ...existing,
214
+ name: file.filename,
215
+ contentType: file.contentType,
216
+ sizeBytes: file.sizeBytes,
217
+ status: "ready",
218
+ file,
219
+ error: undefined,
220
+ }
221
+ : {
222
+ id: `restored:${file.id}`,
223
+ name: file.filename,
224
+ contentType: file.contentType,
225
+ sizeBytes: file.sizeBytes,
226
+ status: "ready",
227
+ file,
228
+ // No source File and no object URL: server metadata is the
229
+ // only authority restored across page/device boundaries.
230
+ };
231
+ });
232
+ for (const [fileId, attachment] of existingReady) {
233
+ if (!incoming.has(fileId) && attachment.previewUrl) {
234
+ URL.revokeObjectURL(attachment.previewUrl);
235
+ }
236
+ }
237
+ // A server restoration is authoritative for finalized assets, but an
238
+ // upload that has not finalized still belongs to the local actor. Keep
239
+ // those unresolved entries while replacing the ready set exactly.
240
+ return [...unresolved, ...restored];
241
+ });
242
+ },
243
+ [workspaceId],
244
+ );
245
+
174
246
  const remove = useCallback((id: string) => {
175
247
  sources.current.delete(id);
176
248
  setAttachments((current) => {
@@ -182,6 +254,24 @@ export function useFileAttachments(
182
254
  });
183
255
  }, []);
184
256
 
257
+ const removeReadyFiles = useCallback((fileIds: Iterable<string>) => {
258
+ const accepted = new Set(fileIds);
259
+ if (accepted.size === 0) return;
260
+ setAttachments((current) =>
261
+ current.filter((attachment) => {
262
+ const removeAccepted =
263
+ attachment.status === "ready" &&
264
+ attachment.file !== undefined &&
265
+ accepted.has(attachment.file.id);
266
+ if (removeAccepted) {
267
+ sources.current.delete(attachment.id);
268
+ if (attachment.previewUrl) URL.revokeObjectURL(attachment.previewUrl);
269
+ }
270
+ return !removeAccepted;
271
+ }),
272
+ );
273
+ }, []);
274
+
185
275
  const clear = useCallback(() => {
186
276
  sources.current.clear();
187
277
  setAttachments((current) => {
@@ -202,10 +292,13 @@ export function useFileAttachments(
202
292
  : [],
203
293
  ),
204
294
  uploading: attachments.some((attachment) => attachment.status === "uploading"),
295
+ hasUnresolved: attachments.some((attachment) => attachment.status !== "ready"),
205
296
  addFiles,
206
297
  addFromPaste,
298
+ restoreReadyFiles,
207
299
  retry,
208
300
  remove,
301
+ removeReadyFiles,
209
302
  clear,
210
303
  };
211
304
  }
@@ -13,9 +13,26 @@ export function isGoalEvent(event: Pick<SessionEvent, "type">): boolean {
13
13
  return event.type.startsWith("goal.");
14
14
  }
15
15
 
16
+ /**
17
+ * Events that can change the server-authoritative continuation projection.
18
+ * The projection is derived from durable turn, session, control, and system
19
+ * update state, so those events refresh the goal without model polling.
20
+ */
21
+ export function isGoalRefreshEvent(event: Pick<SessionEvent, "type">): boolean {
22
+ const type = event.type;
23
+ return (
24
+ isGoalEvent(event) ||
25
+ type.startsWith("turn.") ||
26
+ type.startsWith("session.") ||
27
+ type.startsWith("system.update.") ||
28
+ type.startsWith("workspace.inference.") ||
29
+ type.startsWith("user.")
30
+ );
31
+ }
32
+
16
33
  export type UseGoalOptions = ClientOverride &
17
34
  SessionEventFeedOptions & {
18
- /** Optional safety-net polling (ms). Off by default — goal.* events drive updates. */
35
+ /** Optional safety-net polling (ms). Off by default — event refreshes drive updates. */
19
36
  pollIntervalMs?: number | undefined;
20
37
  };
21
38
 
@@ -46,8 +63,9 @@ export type UseGoalResult = {
46
63
  /**
47
64
  * The session's goal: state, the autonomy counters (`autoContinuations`,
48
65
  * `noProgressStreak`), and pause/resume control. A goal-less session yields
49
- * `goal: null` (the 404 is absorbed). Live-updates on `goal.*` events
50
- * pass `options.events` from `useSessionEvents` to reuse its stream.
66
+ * `goal: null` (the 404 is absorbed). Live-updates on goal, turn, session,
67
+ * control, and system-update events — pass `options.events` from
68
+ * `useSessionEvents` to reuse its stream.
51
69
  */
52
70
  export function useGoal(
53
71
  sessionId: string | null | undefined,
@@ -124,7 +142,7 @@ export function useGoal(
124
142
  }, [load, enabled, workspaceId, sessionId, options.pollIntervalMs, sharedFeed]);
125
143
 
126
144
  const scheduleRefresh = useDebouncedCallback(() => void load());
127
- useSessionEventTrigger(client, workspaceId, sessionId, isGoalEvent, scheduleRefresh, {
145
+ useSessionEventTrigger(client, workspaceId, sessionId, isGoalRefreshEvent, scheduleRefresh, {
128
146
  enabled,
129
147
  ...(sharedEvents !== undefined ? { events: sharedEvents } : {}),
130
148
  });
@@ -1,5 +1,5 @@
1
1
  import type { Session } from "@opengeni/sdk";
2
- import { useCallback } from "react";
2
+ import { useCallback, useEffect, useRef } from "react";
3
3
  import { useOpenGeni, type ClientOverride } from "../provider";
4
4
  import { usePolledValue } from "./internal";
5
5
 
@@ -8,6 +8,8 @@ export type UseWorkspaceSessionsOptions = ClientOverride & {
8
8
  parentSessionId?: string | null | undefined;
9
9
  cursor?: string | undefined;
10
10
  search?: string | undefined;
11
+ /** Return only the complete personal pinned projection. */
12
+ pinsOnly?: boolean | undefined;
11
13
  /** Refresh interval (ms) for fleet/manager views. Off by default. */
12
14
  pollIntervalMs?: number | undefined;
13
15
  enabled?: boolean | undefined;
@@ -38,7 +40,21 @@ export function useWorkspaceSessions(
38
40
  const parentSessionId = options.parentSessionId;
39
41
  const cursor = options.cursor;
40
42
  const search = options.search;
41
- const queryKey = JSON.stringify({ workspaceId, limit, parentSessionId, cursor, search });
43
+ const pinsOnly = options.pinsOnly;
44
+ const enabled = options.enabled ?? true;
45
+ const queryKey = [
46
+ workspaceId,
47
+ limit ?? "",
48
+ parentSessionId === null ? "null" : (parentSessionId ?? ""),
49
+ cursor ?? "",
50
+ search ?? "",
51
+ pinsOnly ? "1" : "",
52
+ ].join("\u0000");
53
+ const previousQueryKey = useRef(queryKey);
54
+ const queryKeyTransition = previousQueryKey.current !== queryKey;
55
+ useEffect(() => {
56
+ previousQueryKey.current = queryKey;
57
+ }, [queryKey]);
42
58
  const load = useCallback(
43
59
  async () => ({
44
60
  queryKey,
@@ -47,13 +63,14 @@ export function useWorkspaceSessions(
47
63
  ...(parentSessionId !== undefined ? { parentSessionId } : {}),
48
64
  ...(cursor !== undefined ? { cursor } : {}),
49
65
  ...(search !== undefined ? { search } : {}),
66
+ ...(pinsOnly ? { pinsOnly: true } : {}),
50
67
  }),
51
68
  }),
52
- [client, workspaceId, limit, parentSessionId, cursor, search, queryKey],
69
+ [client, workspaceId, limit, parentSessionId, cursor, search, pinsOnly, queryKey],
53
70
  );
54
71
  const state = usePolledValue(load, {
55
72
  pollIntervalMs: options.pollIntervalMs,
56
- enabled: options.enabled,
73
+ enabled,
57
74
  });
58
75
  // usePolledValue drops stale async completions, while the explicit query key
59
76
  // also prevents the previous query's cached value from painting for the one
@@ -69,7 +86,15 @@ export function useWorkspaceSessions(
69
86
  pinned,
70
87
  pinnedTruncated: page?.pinnedTruncated ?? false,
71
88
  nextCursor: page?.nextCursor ?? null,
72
- loading: state.loading,
89
+ // `usePolledValue` clears the old data and starts the new request in an
90
+ // effect. During that query-key transition render, its old loading flag
91
+ // can still be false; expose loading immediately so consumers do not
92
+ // announce a transient false zero-match result.
93
+ loading:
94
+ enabled &&
95
+ (state.loading ||
96
+ queryKeyTransition ||
97
+ (state.data !== null && state.data.queryKey !== queryKey)),
73
98
  error: state.error,
74
99
  refresh: state.refresh,
75
100
  };
@@ -5,15 +5,19 @@ import {
5
5
  BrainIcon,
6
6
  SquareTerminalIcon,
7
7
  } from "lucide-react";
8
+ import { lazy, Suspense } from "react";
9
+ import { jsx as rowJsx, jsxs as rowJsxs } from "react/jsx-runtime";
8
10
  import { cn } from "../lib/cn";
9
11
  import { truncate } from "../lib/format";
10
12
  import { defaultToolRegistry } from "./tool-renderers";
11
13
  import { useEntranceAnimation } from "./entrance";
12
14
  import type { ToolRegistry } from "./registry";
13
15
  import { BodyNote, PayloadBlock, ActivityDisclosure } from "./shared";
14
- import { toolDisplayName } from "./projection";
16
+ import { toolDisplayName } from "./tool-display-name";
15
17
  import type { ActivityItem, MemoryItem, ReasoningItem, SandboxItem, WorkerItem } from "./types";
16
18
 
19
+ const LazyFleetDecisionRow = lazy(() => import("./fleet-decision-row"));
20
+
17
21
  /* ----------------------------------------------------------------------------
18
22
  Activity rail
19
23
 
@@ -112,6 +116,18 @@ function renderActivity(
112
116
  return <SandboxRow item={item} />;
113
117
  case "memory":
114
118
  return <MemoryRow item={item} onMemoryClick={onMemoryClick} />;
119
+ case "fleet-decision":
120
+ return (
121
+ <Suspense fallback={null}>
122
+ <LazyFleetDecisionRow
123
+ item={item}
124
+ d={ActivityDisclosure}
125
+ b={BodyNote}
126
+ j={rowJsx}
127
+ s={rowJsxs}
128
+ />
129
+ </Suspense>
130
+ );
115
131
  default:
116
132
  return assertNever(item);
117
133
  }