@opengeni/react 0.23.0 → 0.25.5

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 (62) hide show
  1. package/dist/{chunk-UQS7OQDE.js → chunk-F4CFWKVI.js} +334 -64
  2. package/dist/chunk-F4CFWKVI.js.map +1 -0
  3. package/dist/chunk-GQN2QIR2.js +3767 -0
  4. package/dist/chunk-GQN2QIR2.js.map +1 -0
  5. package/dist/{chunk-I3BJZIG5.js → chunk-HIWPQYWI.js} +2 -120
  6. package/dist/chunk-HIWPQYWI.js.map +1 -0
  7. package/dist/chunk-M7X4JZOD.js +121 -0
  8. package/dist/chunk-M7X4JZOD.js.map +1 -0
  9. package/dist/{chunk-UAHPKLB6.js → chunk-MP6237JB.js} +571 -975
  10. package/dist/chunk-MP6237JB.js.map +1 -0
  11. package/dist/chunk-OZDLELJQ.js +937 -0
  12. package/dist/chunk-OZDLELJQ.js.map +1 -0
  13. package/dist/{chunk-CATATY77.js → chunk-TMH6HZWF.js} +8 -7
  14. package/dist/chunk-TMH6HZWF.js.map +1 -0
  15. package/dist/{chunk-6XUS5VFM.js → chunk-YU5PGUK7.js} +6 -4
  16. package/dist/{chunk-6XUS5VFM.js.map → chunk-YU5PGUK7.js.map} +1 -1
  17. package/dist/{composer-CVm5uoUI.d.ts → composer-DoC4veX1.d.ts} +6 -63
  18. package/dist/composer.d.ts +3 -2
  19. package/dist/composer.js +4 -3
  20. package/dist/fleet-decision-projection-SLNZMS42.js +241 -0
  21. package/dist/fleet-decision-projection-SLNZMS42.js.map +1 -0
  22. package/dist/fleet-decision-row-GGCAT4E4.js +351 -0
  23. package/dist/fleet-decision-row-GGCAT4E4.js.map +1 -0
  24. package/dist/index.d.ts +17 -259
  25. package/dist/index.js +1042 -5058
  26. package/dist/index.js.map +1 -1
  27. package/dist/machines.js +3 -2
  28. package/dist/session-Du_FsrZ1.d.ts +264 -0
  29. package/dist/session-ui-BqQDH7YV.d.ts +183 -0
  30. package/dist/session-ui.d.ts +7 -0
  31. package/dist/session-ui.js +17 -0
  32. package/dist/session-ui.js.map +1 -0
  33. package/dist/session.d.ts +4 -2
  34. package/dist/session.js +26 -9
  35. package/dist/{use-composer-By1wn3MM.d.ts → use-composer-BnEzheSk.d.ts} +8 -2
  36. package/dist/use-file-attachments-C0kpHrs9.d.ts +76 -0
  37. package/dist/use-turn-queue-3rkJwjFv.d.ts +290 -0
  38. package/package.json +6 -2
  39. package/src/components/composer.tsx +7 -6
  40. package/src/components/message-timeline.tsx +94 -54
  41. package/src/hooks/use-composer.ts +470 -73
  42. package/src/hooks/use-file-attachments.ts +96 -3
  43. package/src/hooks/use-goal.ts +23 -5
  44. package/src/hooks/use-session-events.ts +5 -0
  45. package/src/hooks/use-session-lineage.ts +1 -1
  46. package/src/hooks/use-session.ts +9 -6
  47. package/src/hooks/use-workspace-sessions.ts +30 -5
  48. package/src/session-ui.ts +13 -0
  49. package/src/session.ts +15 -0
  50. package/src/timeline/activity-rail.tsx +18 -2
  51. package/src/timeline/fleet-decision-projection.ts +366 -0
  52. package/src/timeline/fleet-decision-row.tsx +412 -0
  53. package/src/timeline/index.ts +2 -0
  54. package/src/timeline/projection.ts +19 -8
  55. package/src/timeline/tool-display-name.ts +16 -0
  56. package/src/timeline/tool-renderers.tsx +1 -1
  57. package/src/timeline/types.ts +68 -1
  58. package/dist/chunk-CATATY77.js.map +0 -1
  59. package/dist/chunk-I3BJZIG5.js.map +0 -1
  60. package/dist/chunk-UAHPKLB6.js.map +0 -1
  61. package/dist/chunk-UQS7OQDE.js.map +0 -1
  62. package/dist/session-Db00TRwl.d.ts +0 -446
@@ -0,0 +1,76 @@
1
+ import { FileAsset, FileResourceRef } from '@opengeni/sdk';
2
+ import { C as ClientOverride } from './session-context-rgrfElMl.js';
3
+
4
+ type UseFileAttachmentsOptions = ClientOverride & {
5
+ /**
6
+ * Only files matching this predicate are accepted by {@link
7
+ * UseFileAttachmentsResult.addFromPaste} (the clipboard path). Defaults to
8
+ * `image/*` — the console's historical paste filter. {@link
9
+ * UseFileAttachmentsResult.addFiles} (the explicit picker / drop path)
10
+ * bypasses it.
11
+ */
12
+ pasteFilter?: ((file: File) => boolean) | undefined;
13
+ };
14
+ type FileAttachment = {
15
+ id: string;
16
+ name: string;
17
+ contentType: string;
18
+ sizeBytes: number;
19
+ status: "uploading" | "ready" | "failed";
20
+ /** The SDK `FileAsset` once the upload finishes. */
21
+ file?: FileAsset | undefined;
22
+ /** Object-URL for an inline preview; minted for `image/*` files only. */
23
+ previewUrl?: string | undefined;
24
+ error?: string | undefined;
25
+ };
26
+ type UseFileAttachmentsResult = {
27
+ attachments: FileAttachment[];
28
+ /**
29
+ * `FileResourceRef[]` for every attachment that finished uploading — feed
30
+ * straight into `useComposer`'s `sendExtras.resources`.
31
+ */
32
+ readyResources: FileResourceRef[];
33
+ /** True while any attachment is still uploading (drives progress UI). */
34
+ uploading: boolean;
35
+ /**
36
+ * True while any attachment still needs an explicit outcome: wait for an
37
+ * upload, retry a failure, or remove it. This is the loss-prevention send
38
+ * gate; failed cards must never be silently omitted from a message.
39
+ */
40
+ hasUnresolved: boolean;
41
+ /** Explicit picker / drop path — uploads every file, no filter. */
42
+ addFiles: (files: Iterable<File>) => void;
43
+ /** Clipboard path — applies `pasteFilter` (default `image/*`) then uploads. */
44
+ addFromPaste: (event: {
45
+ clipboardData: DataTransfer | null;
46
+ }) => void;
47
+ /** Restore already-ready server assets without recreating browser-local bytes. */
48
+ restoreReadyFiles: (files: Iterable<FileAsset>) => void;
49
+ /**
50
+ * Re-run the upload for a `failed` attachment, in place (same id, same
51
+ * source file). No-op for an id that isn't a known failed upload.
52
+ */
53
+ retry: (id: string) => void;
54
+ /** Remove one attachment; revokes its object-URL. */
55
+ remove: (id: string) => void;
56
+ /**
57
+ * Remove only finalized files whose durable ids were accepted by a send.
58
+ * Attachments added while that request was in flight remain queued for the
59
+ * next message.
60
+ */
61
+ removeReadyFiles: (fileIds: Iterable<string>) => void;
62
+ /** Remove all attachments and revoke every object-URL. */
63
+ clear: () => void;
64
+ };
65
+ /**
66
+ * Upload-and-track state for files attached to the next message. Owns the
67
+ * full client-side upload layer: a per-file `uploading | ready | failed`
68
+ * status machine driven by the SDK's `client.uploadFile`, object-URL image
69
+ * previews with create/revoke lifecycle, the `image/*` clipboard paste filter,
70
+ * and a `FileResourceRef[]` projection that drops straight into a message's
71
+ * `resources`. Workspace-scoped, so it resolves both client and workspace from
72
+ * the {@link OpenGeniProvider} (or a per-call `{ client, workspaceId }`).
73
+ */
74
+ declare function useFileAttachments(options?: UseFileAttachmentsOptions): UseFileAttachmentsResult;
75
+
76
+ export { type FileAttachment as F, type UseFileAttachmentsResult as U, type UseFileAttachmentsOptions as a, useFileAttachments as u };
@@ -0,0 +1,290 @@
1
+ import { ToolAuthNeededPayload, ResourceRef, ToolRef, SessionStatus, SessionQueueSnapshot, SessionTurn, EffectiveSessionControl, ComposerDraft, SessionEvent } from '@opengeni/sdk';
2
+ import { E as EmbeddedSessionClientOverride } from './session-context-rgrfElMl.js';
3
+ import { S as SessionEventFeedOptions } from './use-composer-BnEzheSk.js';
4
+
5
+ type UserMessageItem = {
6
+ kind: "user-message";
7
+ id: string;
8
+ text: string;
9
+ /** Resources attached to this message (file uploads, repositories). */
10
+ resources: ResourceRef[];
11
+ /** Tools requested for the turn this message starts. */
12
+ tools: ToolRef[];
13
+ occurredAt: string;
14
+ };
15
+ type AgentMessageItem = {
16
+ kind: "agent-message";
17
+ id: string;
18
+ turnId: string | null;
19
+ text: string;
20
+ /** Still receiving deltas (no completed/turn-end seen yet). */
21
+ streaming: boolean;
22
+ occurredAt: string;
23
+ };
24
+ type ReasoningItem = {
25
+ kind: "reasoning";
26
+ id: string;
27
+ turnId: string | null;
28
+ text: string;
29
+ streaming: boolean;
30
+ occurredAt: string;
31
+ };
32
+ type ToolCallItem = {
33
+ kind: "tool-call";
34
+ id: string;
35
+ turnId: string | null;
36
+ callId: string | null;
37
+ name: string;
38
+ arguments: unknown;
39
+ output: unknown;
40
+ /**
41
+ * The provider-native tool item (`agent.toolCall.created.payload.raw`). Carries
42
+ * `type` (e.g. `apply_patch_call`, `computer_call`, `hosted_tool_call`) and the
43
+ * tool-specific fields the per-tool renderers read (`operation`, `action`,
44
+ * `providerData`, …). `undefined` for first-party MCP tools, which carry their
45
+ * payload in `arguments`/`output` instead.
46
+ */
47
+ raw: unknown;
48
+ status: "running" | "complete" | "failed" | "cancelled";
49
+ occurredAt: string;
50
+ };
51
+ /**
52
+ * An orchestration call against another session — the manager spawning or
53
+ * messaging a worker. Rendered as a first-class "worker" row, not a generic
54
+ * tool call.
55
+ */
56
+ type WorkerItem = {
57
+ kind: "worker";
58
+ id: string;
59
+ turnId: string | null;
60
+ callId: string | null;
61
+ action: "spawn" | "message";
62
+ /** The worker's initial message / the message sent to it, when parseable. */
63
+ prompt: string | null;
64
+ /** The target/spawned worker session id, when parseable from args/output. */
65
+ workerSessionId: string | null;
66
+ status: "running" | "complete" | "failed" | "cancelled";
67
+ occurredAt: string;
68
+ };
69
+ type WorkerCompletionItem = {
70
+ kind: "worker-completion";
71
+ id: string;
72
+ turnId: string | null;
73
+ occurredAt: string;
74
+ childSessionId: string;
75
+ childStatus: string;
76
+ goalStatus: string | null;
77
+ goalText: string | null;
78
+ evidence: string | null;
79
+ pausedReason: string | null;
80
+ text: string;
81
+ };
82
+ type SandboxItem = {
83
+ kind: "sandbox";
84
+ id: string;
85
+ turnId: string | null;
86
+ name: string;
87
+ command: string | null;
88
+ output: string;
89
+ origin?: "created" | "restored" | "resumed" | null;
90
+ status: "running" | "complete" | "failed" | "cancelled";
91
+ occurredAt: string;
92
+ };
93
+ /**
94
+ * A first-party workspace-memory write the agent made mid-turn — a `memory.saved`
95
+ * (it committed a new preference / fact / procedure / decision / history) or a
96
+ * `memory.corrected` (it updated or archived an existing one). A settled save is
97
+ * ordinary progress, not an exceptional state, so it renders as a calm NEUTRAL
98
+ * step on the rail (never accent/color). When the host app supplies an
99
+ * `onMemoryClick` handler the row also deep-links to the record in its memory
100
+ * pane; without one it is non-interactive rich content.
101
+ */
102
+ type MemoryItem = {
103
+ kind: "memory";
104
+ id: string;
105
+ turnId: string | null;
106
+ variant: "saved" | "corrected";
107
+ /** The memory's kind enum (`"preference" | "semantic" | …`); mapped to a human label at render. */
108
+ memoryKind: string;
109
+ /** The memory text, ellipsized to ≤120 chars server-side. For a supersede this is the OLD text. */
110
+ preview: string;
111
+ /** The save collapsed into an existing memory (no new row was written). Saved variant only. */
112
+ deduped?: boolean;
113
+ /** The NEW text when a correction superseded the memory with a replacement; absent = updated-in-place or archived. */
114
+ replacementPreview?: string;
115
+ /**
116
+ * What a `memory.corrected` did: `"superseded"` (replaced by a new record, see
117
+ * `replacementPreview`), `"updated"` (edited in place — the record lives on), or
118
+ * `"archived"` (retired). Distinguishes updated-in-place from archived when there
119
+ * is no replacement. Corrected variant only; read defensively (may be absent).
120
+ */
121
+ action?: string;
122
+ /** The saved / corrected memory's id — the deep-link target for a save. */
123
+ memoryId: string;
124
+ /** The replacement memory's id when a correction produced one — the LIVE record the deep-link targets. */
125
+ replacementMemoryId?: string;
126
+ occurredAt: string;
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
+ };
163
+ type SessionStatusItem = {
164
+ kind: "session-status";
165
+ id: string;
166
+ status: SessionStatus;
167
+ occurredAt: string;
168
+ };
169
+ type GoalItem = {
170
+ kind: "goal";
171
+ id: string;
172
+ action: "set" | "updated" | "completed" | "paused" | "resumed" | "cleared" | "continuation";
173
+ text: string | null;
174
+ occurredAt: string;
175
+ };
176
+ type NoticeItem = {
177
+ kind: "notice";
178
+ id: string;
179
+ tone: "waiting" | "cancelled" | "failed";
180
+ text: string;
181
+ /** Optional evidence kept inspectable without overwhelming the main rail. */
182
+ details?: {
183
+ label: string;
184
+ value: unknown;
185
+ };
186
+ action?: {
187
+ label: string;
188
+ url: string;
189
+ };
190
+ occurredAt: string;
191
+ };
192
+ /**
193
+ * A tool call hit a missing or lapsed connection. The broker reports that
194
+ * condition as a tool error and the turn continues; reconnecting never resumes
195
+ * or replays the original call. Carries the structured `tool.auth_needed`
196
+ * payload so the renderer can draw a clean inline recovery affordance (provider
197
+ * logo + one human line + a Connect/Reconnect button) and the app can start the
198
+ * right flow (OAuth reconnect for the surviving connection, or credential
199
+ * re-entry for an api-key one). The `reason` shapes the human copy but is never
200
+ * shown raw.
201
+ */
202
+ type AuthNeededItem = {
203
+ kind: "auth-needed";
204
+ id: string;
205
+ turnId: string | null;
206
+ /** The connection's registrable domain, e.g. "linear.app". */
207
+ providerDomain: string;
208
+ /** The lapsed connection to reconnect, when the row survived. */
209
+ connectionId: string | null;
210
+ reason: ToolAuthNeededPayload["reason"] | null;
211
+ /** Scopes the provider now needs; may inform the copy, never shown as a raw label. */
212
+ scopes: string[];
213
+ /** The OAuth `resource` (RFC 8707) the reconnect should target, when supplied. */
214
+ resource: string | null;
215
+ /** The tool whose call triggered the reauth, for context. */
216
+ toolName: string | null;
217
+ /** A pre-minted authorization URL, when the broker already produced one. */
218
+ authorizationUrl: string | null;
219
+ occurredAt: string;
220
+ };
221
+ type TurnOutcome = "complete" | "failed" | "cancelled";
222
+ type TurnEndItem = {
223
+ kind: "turn-end";
224
+ id: string;
225
+ turnId: string | null;
226
+ outcome: TurnOutcome;
227
+ failureText: string | null;
228
+ occurredAt: string;
229
+ };
230
+ type TimelineItem = UserMessageItem | AgentMessageItem | ReasoningItem | ToolCallItem | WorkerItem | WorkerCompletionItem | SandboxItem | SessionStatusItem | GoalItem | NoticeItem | AuthNeededItem | MemoryItem | FleetDecisionItem | TurnEndItem;
231
+ /** Activity items cluster between chat messages (reasoning, tools, workers, sandbox, memory). */
232
+ type ActivityItem = ReasoningItem | ToolCallItem | WorkerItem | SandboxItem | MemoryItem | FleetDecisionItem;
233
+ type TimelineGroup = {
234
+ kind: "item";
235
+ item: TimelineItem;
236
+ } | {
237
+ kind: "activity";
238
+ id: string;
239
+ items: ActivityItem[];
240
+ outcome?: TurnOutcome;
241
+ failureText?: string;
242
+ } | {
243
+ kind: "turn";
244
+ id: string;
245
+ outcome: TurnOutcome;
246
+ failureText?: string;
247
+ startedAt: string;
248
+ endedAt: string;
249
+ groups: TimelineGroup[];
250
+ };
251
+
252
+ /** Events that can change the authoritative prompt queue or effective control. */
253
+ declare function isTurnQueueEvent(event: Pick<SessionEvent, "type">): boolean;
254
+ type QueueMutationKind = "move" | "edit" | "steer" | "delete";
255
+ type UseTurnQueueOptions = EmbeddedSessionClientOverride & SessionEventFeedOptions & {
256
+ pollIntervalMs?: number | undefined;
257
+ };
258
+ type UseTurnQueueResult = {
259
+ snapshot: SessionQueueSnapshot | null;
260
+ /** Human/API prompts exactly in server execution order. Never client-sorted. */
261
+ queue: SessionTurn[];
262
+ effectiveControl: EffectiveSessionControl | null;
263
+ /** The latest interrupted attempt has not yet durably proved physical quiescence. */
264
+ stoppingPreviousAttempt: boolean;
265
+ loading: boolean;
266
+ error: Error | null;
267
+ refresh: () => Promise<void>;
268
+ moveTurn: (turnId: string, beforeTurnId: string | null) => Promise<boolean>;
269
+ /** Atomically withdraw a waiting prompt into the private durable composer draft. */
270
+ editTurn: (turnId: string, options: {
271
+ expectedDraftRevision: number;
272
+ replaceDraft: boolean;
273
+ }) => Promise<ComposerDraft | null>;
274
+ /** Advance the same durable waiting prompt; no duplicate prompt is created. */
275
+ steerTurn: (turnId: string) => Promise<boolean>;
276
+ removeTurn: (turnId: string) => Promise<boolean>;
277
+ pendingByTurn: Readonly<Record<string, QueueMutationKind>>;
278
+ mutationFor: (turnId: string) => QueueMutationKind | null;
279
+ mutating: boolean;
280
+ mutationError: Error | null;
281
+ clearMutationError: () => void;
282
+ };
283
+ /**
284
+ * The one authoritative human prompt queue. Every mutation carries the exact
285
+ * server versions the operator saw and accepts only monotonic snapshots. A
286
+ * conflict immediately reloads server truth; the client never invents order.
287
+ */
288
+ declare function useTurnQueue(sessionId: string | null | undefined, options?: UseTurnQueueOptions): UseTurnQueueResult;
289
+
290
+ export { type ActivityItem as A, type GoalItem as G, type MemoryItem as M, type NoticeItem as N, type QueueMutationKind as Q, type ReasoningItem as R, type SandboxItem as S, type TurnOutcome as T, type UseTurnQueueOptions as U, type WorkerItem as W, type AgentMessageItem as a, type AuthNeededItem as b, type SessionStatusItem as c, type TimelineGroup as d, type TimelineItem as e, type ToolCallItem as f, type TurnEndItem as g, type UseTurnQueueResult as h, type UserMessageItem as i, isTurnQueueEvent as j, type WorkerCompletionItem as k, useTurnQueue as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/react",
3
- "version": "0.23.0",
3
+ "version": "0.25.5",
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": {
@@ -31,6 +31,10 @@
31
31
  "types": "./dist/session.d.ts",
32
32
  "import": "./dist/session.js"
33
33
  },
34
+ "./session-ui": {
35
+ "types": "./dist/session-ui.d.ts",
36
+ "import": "./dist/session-ui.js"
37
+ },
34
38
  "./machines": {
35
39
  "types": "./dist/machines.d.ts",
36
40
  "import": "./dist/machines.js"
@@ -62,7 +66,7 @@
62
66
  "@dnd-kit/utilities": "3.2.2",
63
67
  "@fontsource-variable/noto-sans-arabic": "5.2.10",
64
68
  "@fontsource-variable/noto-sans-jp": "5.2.10",
65
- "@opengeni/sdk": "^0.23.0",
69
+ "@opengeni/sdk": "^0.25.5",
66
70
  "clsx": "^2.1.1",
67
71
  "lucide-react": "^1.8.0",
68
72
  "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,
@@ -150,18 +150,23 @@ export function MessageTimeline({
150
150
  // structurally impossible — the reader only ever sees it already at the
151
151
  // bottom. An empty timeline reveals immediately (there is nothing to anchor).
152
152
  const [revealed, setRevealed] = useState(false);
153
- // Our own scrollTop assignments echo back as scroll events; those must never
154
- // UNPIN the reader (they are not reader intent). Marked around every
155
- // programmatic assignment and consumed by onScroll. When an assignment is a
156
- // NO-OP (already at the target) no scroll event will fire, so the mark must
157
- // self-clear a stale mark would eat the reader's next real scroll-up.
158
- const programmaticScrollRef = useRef(false);
153
+ // Our own scrollTop assignments echo back as delayed scroll events. Keep the
154
+ // ordered, clamped targets rather than only the latest one: successive
155
+ // ResizeObserver corrections can queue several echoes before the browser
156
+ // dispatches them. If reader input lands at a different position, its value
157
+ // wins and clears the pending echoes. A matching echo must not recapture the
158
+ // visual anchor while progressive prepend is still mounting older rows.
159
+ const programmaticScrollTargetsRef = useRef<number[]>([]);
159
160
  const assignScrollTop = useCallback((node: HTMLElement, value: number) => {
160
161
  const previous = node.scrollTop;
161
- programmaticScrollRef.current = true;
162
162
  node.scrollTop = value;
163
163
  if (node.scrollTop === previous) {
164
- programmaticScrollRef.current = false;
164
+ return;
165
+ }
166
+ const pending = programmaticScrollTargetsRef.current;
167
+ pending.push(node.scrollTop);
168
+ if (pending.length > 32) {
169
+ pending.splice(0, pending.length - 32);
165
170
  }
166
171
  }, []);
167
172
  // Mirror `pinned` into a ref so the ResizeObserver callback (a stable closure)
@@ -187,9 +192,12 @@ export function MessageTimeline({
187
192
  allGroups.length > 0 && (bulkActive || firstKeyChangedForBulk || mountingOlderGroups);
188
193
 
189
194
  // Snapshot the topmost visible element and where it sits in the viewport, so a
190
- // later reflow can restore it to the same spot. Transient chrome (the backfill
191
- // sentinel and shimmer) is skipped anchoring to a row that unmounts when the
192
- // older window lands would drop the correction mid-prepend.
195
+ // later reflow can restore it to the same spot. Prefer durable row wrappers
196
+ // over their containing group: an older activity item can merge into the
197
+ // existing group, leaving that outer group in place while shifting every row
198
+ // after the insertion. Transient chrome (the backfill sentinel and shimmer) is
199
+ // skipped — anchoring to a row that unmounts when the older window lands would
200
+ // drop the correction mid-prepend.
193
201
  const captureAnchor = useCallback(() => {
194
202
  const node = scrollRef.current;
195
203
  const inner = node?.firstElementChild;
@@ -198,31 +206,63 @@ export function MessageTimeline({
198
206
  return;
199
207
  }
200
208
  const containerTop = node.getBoundingClientRect().top;
201
- for (const child of Array.from(inner.children)) {
202
- if (child instanceof HTMLElement && child.dataset.ogTimelineChrome !== undefined) {
203
- continue;
204
- }
205
- const rect = child.getBoundingClientRect();
206
- if (rect.bottom > containerTop + 1) {
207
- anchorRef.current = { el: child, top: rect.top - containerTop };
208
- return;
209
+ const findVisible = (selector: string): Element | null => {
210
+ for (const candidate of inner.querySelectorAll(selector)) {
211
+ if (candidate instanceof HTMLElement && candidate.dataset.ogTimelineChrome !== undefined) {
212
+ continue;
213
+ }
214
+ const rect = candidate.getBoundingClientRect();
215
+ if (rect.bottom > containerTop + 1 && rect.height > 0) {
216
+ return candidate;
217
+ }
209
218
  }
219
+ return null;
220
+ };
221
+ const anchor =
222
+ findVisible("[data-og-timeline-row-anchor]") ??
223
+ findVisible("[data-og-timeline-group-anchor]");
224
+ if (anchor) {
225
+ const rect = anchor.getBoundingClientRect();
226
+ anchorRef.current = { el: anchor, top: rect.top - containerTop };
227
+ return;
210
228
  }
211
229
  anchorRef.current = null;
212
230
  }, []);
213
231
 
214
- // Follow the stream while pinned to the bottom; never fight the reader.
232
+ // Restore the user-owned anchor synchronously after React commits a frame.
233
+ // ResizeObserver remains a late-measurement fallback, but progressive history
234
+ // frames must be corrected before the browser can paint an intermediate jump.
235
+ const restoreAnchor = useCallback(
236
+ (node: HTMLElement) => {
237
+ if (autoFollow && pinnedRef.current) {
238
+ assignScrollTop(node, node.scrollHeight);
239
+ return;
240
+ }
241
+ const anchor = anchorRef.current;
242
+ if (!anchor || !anchor.el.isConnected) {
243
+ return;
244
+ }
245
+ const containerTop = node.getBoundingClientRect().top;
246
+ const now = anchor.el.getBoundingClientRect().top - containerTop;
247
+ const diff = now - anchor.top;
248
+ if (diff !== 0) {
249
+ assignScrollTop(node, node.scrollTop + diff);
250
+ }
251
+ },
252
+ [assignScrollTop, autoFollow],
253
+ );
254
+
215
255
  // A LAYOUT effect so the very first paint of a freshly loaded session is
216
256
  // already anchored at the bottom — no visible traversal down the history.
217
257
  useLayoutEffect(() => {
218
258
  const node = scrollRef.current;
219
- if (node && autoFollow && pinned) {
220
- assignScrollTop(node, node.scrollHeight);
259
+ if (node) {
260
+ restoreAnchor(node);
221
261
  }
222
262
  if (!revealed && groups.length > 0) {
223
263
  setRevealed(true);
224
264
  }
225
- }, [resolvedItems, working, autoFollow, pinned, revealed, groups.length, assignScrollTop]);
265
+ }, [resolvedItems, working, revealed, groups.length, restoreAnchor]);
226
266
 
227
267
  // A cleared timeline (stream identity change) re-arms the reveal so the next
228
268
  // session also first paints at its bottom.
@@ -297,32 +337,26 @@ export function MessageTimeline({
297
337
  if (!current) {
298
338
  return;
299
339
  }
300
- if (autoFollow && pinnedRef.current) {
301
- assignScrollTop(current, current.scrollHeight);
302
- } else {
303
- const anchor = anchorRef.current;
304
- if (anchor && anchor.el.isConnected) {
305
- const containerTop = current.getBoundingClientRect().top;
306
- const now = anchor.el.getBoundingClientRect().top - containerTop;
307
- const diff = now - anchor.top;
308
- if (diff !== 0) {
309
- assignScrollTop(current, current.scrollTop + diff);
310
- }
311
- }
312
- }
313
- captureAnchor();
340
+ restoreAnchor(current);
314
341
  });
315
342
  observer.observe(inner);
316
343
  return () => observer.disconnect();
317
- }, [autoFollow, captureAnchor, assignScrollTop]);
344
+ }, [restoreAnchor]);
318
345
 
319
346
  const onScroll = () => {
320
347
  const node = scrollRef.current;
321
348
  if (!node) {
322
349
  return;
323
350
  }
324
- const programmatic = programmaticScrollRef.current;
325
- programmaticScrollRef.current = false;
351
+ const pending = programmaticScrollTargetsRef.current;
352
+ const programmaticIndex = pending.findIndex((target) => Math.abs(node.scrollTop - target) <= 1);
353
+ const programmatic = programmaticIndex >= 0;
354
+ if (programmatic) {
355
+ pending.splice(0, programmaticIndex + 1);
356
+ } else {
357
+ // A real reader scroll supersedes every delayed programmatic echo.
358
+ pending.length = 0;
359
+ }
326
360
  const nextPinned = node.scrollHeight - node.scrollTop - node.clientHeight < 48;
327
361
  // Echoes of our own assignments may PIN but never UNPIN — only the reader
328
362
  // scrolling away releases the bottom-follow.
@@ -330,7 +364,12 @@ export function MessageTimeline({
330
364
  pinnedRef.current = nextPinned;
331
365
  setPinned(nextPinned);
332
366
  }
333
- captureAnchor();
367
+ // The assignment site already captured the corrected anchor. Recapturing
368
+ // it from a delayed echo can observe the next prepended row before that
369
+ // row's ResizeObserver correction and make the viewport walk up the page.
370
+ if (!programmatic) {
371
+ captureAnchor();
372
+ }
334
373
  };
335
374
 
336
375
  return (
@@ -366,17 +405,18 @@ export function MessageTimeline({
366
405
  </div>
367
406
  ) : null}
368
407
  {groups.map(({ group, key }, index) => (
369
- <TimelineGroupView
370
- key={key}
371
- group={group}
372
- renderMessageText={renderMessageText}
373
- onOpenSession={onOpenSession}
374
- onMemoryClick={onMemoryClick}
375
- onReconnect={onReconnect}
376
- resolveProviderLogo={resolveProviderLogo}
377
- toolRegistry={toolRegistry}
378
- foldLiveCluster={isAgentProgress(groups[index + 1]?.group)}
379
- />
408
+ <div key={key} data-og-timeline-group-anchor="">
409
+ <TimelineGroupView
410
+ group={group}
411
+ renderMessageText={renderMessageText}
412
+ onOpenSession={onOpenSession}
413
+ onMemoryClick={onMemoryClick}
414
+ onReconnect={onReconnect}
415
+ resolveProviderLogo={resolveProviderLogo}
416
+ toolRegistry={toolRegistry}
417
+ foldLiveCluster={isAgentProgress(groups[index + 1]?.group)}
418
+ />
419
+ </div>
380
420
  ))}
381
421
  {working ? (
382
422
  <div className="flex items-center gap-2 text-sm">
@@ -731,8 +771,8 @@ function clusterIsSettled(group: Extract<TimelineGroup, { kind: "activity" }>):
731
771
  if (item.kind === "reasoning") {
732
772
  return !item.streaming;
733
773
  }
734
- // A memory write is a discrete, already-settled event — it has no running state.
735
- if (item.kind === "memory") {
774
+ // Memory writes and fleet observations are discrete, already-settled events.
775
+ if (item.kind === "memory" || item.kind === "fleet-decision") {
736
776
  return true;
737
777
  }
738
778
  return item.status !== "running";