@opengeni/react 3.5.1-canary.0 → 3.7.0-canary.1

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 (87) hide show
  1. package/dist/artifacts.d.ts +1 -1
  2. package/dist/artifacts.js +248 -6
  3. package/dist/artifacts.js.map +1 -1
  4. package/dist/{browser-viewer-E5DDEFQ4.js → browser-viewer-NTD6UIPR.js} +3 -3
  5. package/dist/{chunk-W2RGYBCV.js → chunk-24M4YBCL.js} +4 -4
  6. package/dist/{chunk-WY3MELVJ.js → chunk-5IXTJTK3.js} +1438 -397
  7. package/dist/chunk-5IXTJTK3.js.map +1 -0
  8. package/dist/chunk-5PQJOCX5.js +902 -0
  9. package/dist/chunk-5PQJOCX5.js.map +1 -0
  10. package/dist/{chunk-UNCXXIQR.js → chunk-INGB2N5R.js} +4310 -3982
  11. package/dist/chunk-INGB2N5R.js.map +1 -0
  12. package/dist/{chunk-TJFMMDQU.js → chunk-MOWYEBCQ.js} +1839 -17
  13. package/dist/chunk-MOWYEBCQ.js.map +1 -0
  14. package/dist/chunk-PAJU5KKL.js +331 -0
  15. package/dist/chunk-PAJU5KKL.js.map +1 -0
  16. package/dist/{chunk-VTOREEXF.js → chunk-PLYRL5ER.js} +24 -2
  17. package/dist/{chunk-VTOREEXF.js.map → chunk-PLYRL5ER.js.map} +1 -1
  18. package/dist/{chunk-YVCHAQZY.js → chunk-ZFOFY5ST.js} +27 -349
  19. package/dist/chunk-ZFOFY5ST.js.map +1 -0
  20. package/dist/client.d.ts +1 -1
  21. package/dist/components/artifacts/index.d.ts +1 -1
  22. package/dist/components/artifacts/published-html-artifact-frame.d.ts +46 -3
  23. package/dist/components/composer.d.ts +2 -0
  24. package/dist/components/session-chrome.d.ts +38 -5
  25. package/dist/components/session-commands-panel.d.ts +6 -0
  26. package/dist/components/session-conversation.d.ts +14 -0
  27. package/dist/components/timeline-anchor.d.ts +21 -0
  28. package/dist/composer.js +2 -3
  29. package/dist/conversation-timeline.d.ts +5 -0
  30. package/dist/{fleet-decision-row-GGCAT4E4.js → fleet-decision-row-YZ3ZKWMM.js} +2 -1
  31. package/dist/fleet-decision-row-YZ3ZKWMM.js.map +1 -0
  32. package/dist/hooks/use-file-attachments.d.ts +6 -0
  33. package/dist/hooks/use-sandbox-files.d.ts +4 -1
  34. package/dist/hooks/use-workspace-edit.d.ts +4 -0
  35. package/dist/index.d.ts +4 -1
  36. package/dist/index.js +823 -965
  37. package/dist/index.js.map +1 -1
  38. package/dist/interaction.js +2 -2
  39. package/dist/lib/format.d.ts +5 -0
  40. package/dist/machines.js +3 -2
  41. package/dist/session-ui.d.ts +3 -0
  42. package/dist/session-ui.js +14 -4
  43. package/dist/session.js +19 -19
  44. package/dist/timeline/registry.d.ts +2 -0
  45. package/dist/timeline/types.d.ts +4 -2
  46. package/package.json +2 -2
  47. package/src/artifacts.ts +3 -0
  48. package/src/client.ts +2 -1
  49. package/src/components/artifacts/index.ts +3 -0
  50. package/src/components/artifacts/published-html-artifact-frame.tsx +307 -4
  51. package/src/components/composer.tsx +89 -25
  52. package/src/components/machine-input-display.ts +6 -0
  53. package/src/components/message-timeline.tsx +351 -204
  54. package/src/components/sandbox-workspace.tsx +11 -1
  55. package/src/components/session-chrome.tsx +501 -128
  56. package/src/components/session-commands-panel.tsx +113 -0
  57. package/src/components/session-conversation.tsx +141 -0
  58. package/src/components/timeline-anchor.tsx +91 -0
  59. package/src/conversation-timeline.ts +83 -0
  60. package/src/hooks/use-file-attachments.ts +34 -0
  61. package/src/hooks/use-sandbox-files.ts +68 -20
  62. package/src/hooks/use-session-background-commands.ts +21 -10
  63. package/src/hooks/use-session-events.ts +56 -39
  64. package/src/hooks/use-session.ts +23 -2
  65. package/src/hooks/use-workspace-edit.ts +23 -5
  66. package/src/index.ts +4 -0
  67. package/src/lib/format.ts +32 -0
  68. package/src/session-ui.ts +3 -0
  69. package/src/timeline/fleet-decision-projection.ts +2 -1
  70. package/src/timeline/fleet-decision-row.tsx +1 -0
  71. package/src/timeline/projection.ts +157 -19
  72. package/src/timeline/registry.ts +15 -3
  73. package/src/timeline/tool-renderers.tsx +117 -1
  74. package/src/timeline/types.ts +12 -1
  75. package/styles/compiled.css +495 -411
  76. package/styles/index.css +23 -0
  77. package/dist/chunk-7EAFGICR.js +0 -302
  78. package/dist/chunk-7EAFGICR.js.map +0 -1
  79. package/dist/chunk-IB27C53Y.js +0 -2582
  80. package/dist/chunk-IB27C53Y.js.map +0 -1
  81. package/dist/chunk-TJFMMDQU.js.map +0 -1
  82. package/dist/chunk-UNCXXIQR.js.map +0 -1
  83. package/dist/chunk-WY3MELVJ.js.map +0 -1
  84. package/dist/chunk-YVCHAQZY.js.map +0 -1
  85. package/dist/fleet-decision-row-GGCAT4E4.js.map +0 -1
  86. /package/dist/{browser-viewer-E5DDEFQ4.js.map → browser-viewer-NTD6UIPR.js.map} +0 -0
  87. /package/dist/{chunk-W2RGYBCV.js.map → chunk-24M4YBCL.js.map} +0 -0
@@ -0,0 +1,113 @@
1
+ import { ChevronRightIcon, Loader2Icon, SquareIcon } from "lucide-react";
2
+ import { useState } from "react";
3
+ import type { UseSessionBackgroundCommandsResult } from "../hooks/use-session-background-commands";
4
+ import { formatClockTime } from "../lib/format";
5
+
6
+ /** The current session's live commands. Settled commands belong to the timeline. */
7
+ export function SessionCommandsPanel({
8
+ commands: state,
9
+ readOnly = false,
10
+ }: {
11
+ commands: UseSessionBackgroundCommandsResult;
12
+ readOnly?: boolean;
13
+ }) {
14
+ const [pending, setPending] = useState<string | null>(null);
15
+ const [error, setError] = useState<string | null>(null);
16
+ const active = state.commands.filter(
17
+ (command) => command.state === "running" || command.state === "stopping",
18
+ );
19
+ const stop = async (id: string) => {
20
+ setPending(id);
21
+ setError(null);
22
+ try {
23
+ await state.cancel(id);
24
+ } catch (cause) {
25
+ setError(cause instanceof Error ? cause.message : "Stop was not confirmed. Try again.");
26
+ } finally {
27
+ setPending(null);
28
+ }
29
+ };
30
+ return (
31
+ <div className="space-y-2 text-og-xs" data-og-session-commands="">
32
+ {state.loading && active.length === 0 ? (
33
+ <p role="status" className="text-og-fg-muted">
34
+ Loading commands…
35
+ </p>
36
+ ) : null}
37
+ {state.error ? (
38
+ <div role="alert" className="flex items-center justify-between gap-2 text-og-danger">
39
+ <span>Commands unavailable.</span>
40
+ <button
41
+ type="button"
42
+ className="underline outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/40"
43
+ onClick={() => void state.refresh()}
44
+ >
45
+ Retry
46
+ </button>
47
+ </div>
48
+ ) : null}
49
+ {!state.loading && !state.error && active.length === 0 ? (
50
+ <p role="status" className="text-og-fg-muted">
51
+ No background commands running.
52
+ </p>
53
+ ) : null}
54
+ {active.length > 0 ? (
55
+ <ul className="max-h-64 overflow-y-auto overscroll-contain divide-y divide-og-border/40">
56
+ {active.map((command) => (
57
+ <li key={command.id} className="flex items-start gap-3 py-2 first:pt-0 last:pb-0">
58
+ <div className="min-w-0 flex-1">
59
+ <details className="group/command">
60
+ <summary
61
+ className="flex cursor-pointer list-none items-center gap-1.5 rounded-og-sm font-mono text-og-fg outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/40"
62
+ title="Expand command"
63
+ >
64
+ <ChevronRightIcon className="size-3 shrink-0 text-og-fg-subtle transition-transform group-open/command:rotate-90" />
65
+ <span className="truncate">
66
+ {command.commandPreview || "Background command"}
67
+ </span>
68
+ </summary>
69
+ <div className="mt-2 space-y-1">
70
+ <p className="whitespace-pre-wrap break-all font-mono text-og-fg-muted">
71
+ {command.commandPreview || "Background command"}
72
+ </p>
73
+ <time
74
+ className="block text-og-fg-subtle"
75
+ dateTime={command.startedAt}
76
+ title={new Date(command.startedAt).toLocaleString()}
77
+ >
78
+ Started {formatClockTime(command.startedAt)}
79
+ </time>
80
+ </div>
81
+ </details>
82
+ <p className="mt-1 text-og-fg-subtle">
83
+ {command.state === "stopping" ? "Stopping…" : "Running"}
84
+ </p>
85
+ </div>
86
+ {!readOnly ? (
87
+ <button
88
+ type="button"
89
+ aria-label={`Stop ${command.commandPreview || "background command"}`}
90
+ disabled={pending !== null || command.state === "stopping"}
91
+ onClick={() => void stop(command.id)}
92
+ className="inline-flex min-h-7 shrink-0 items-center gap-1 rounded-og-sm px-2 text-og-fg-muted outline-hidden hover:bg-og-surface-3/70 hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent/40 disabled:opacity-50 pointer-coarse:min-h-11"
93
+ >
94
+ {pending === command.id || command.state === "stopping" ? (
95
+ <Loader2Icon className="size-3 animate-og-spin motion-reduce:animate-none" />
96
+ ) : (
97
+ <SquareIcon className="size-3" />
98
+ )}
99
+ Stop
100
+ </button>
101
+ ) : null}
102
+ </li>
103
+ ))}
104
+ </ul>
105
+ ) : null}
106
+ {error ? (
107
+ <p role="alert" className="text-og-danger">
108
+ {error}
109
+ </p>
110
+ ) : null}
111
+ </div>
112
+ );
113
+ }
@@ -0,0 +1,141 @@
1
+ import { useRef, type CSSProperties } from "react";
2
+ import { useOpenGeni, type ClientOverride } from "../session-context";
3
+ import { useWorkspaceModelCatalog } from "../hooks/use-available-models";
4
+ import { ModelPolicyPicker } from "./model-policy-picker";
5
+ import { useSessionEvents } from "../hooks/use-session-events";
6
+ import { useSession } from "../hooks/use-session";
7
+ import { useTurnQueue } from "../hooks/use-turn-queue";
8
+ import { useComposer } from "../hooks/use-composer";
9
+ import { useHumanInputRequests } from "../hooks/use-human-input";
10
+ import { ChatComposer, type ChatComposerProps } from "./chat-composer";
11
+ import { QueueSurface } from "./queue-surface";
12
+ import { HumanInputSurface } from "./human-input-surface";
13
+ import { MessageTimeline } from "./message-timeline";
14
+ import { conversationTimeline } from "../conversation-timeline";
15
+ import { cn } from "../lib/cn";
16
+
17
+ export type SessionConversationProps = ClientOverride & {
18
+ sessionId: string;
19
+ className?: string;
20
+ /** Defaults to filling the host. The host owns available height. */
21
+ height?: CSSProperties["height"];
22
+ /** Presentation/custom controls only; queue and delivery wiring stay owned here. */
23
+ composerProps?: Omit<ChatComposerProps, "composer" | "effectiveControl" | "queuedAheadCount">;
24
+ };
25
+
26
+ /** Complete existing-session conversation. Uses the provider's normal SDK client
27
+ * (including Site clients), one shared event feed, and authoritative queue state. */
28
+ export function SessionConversation(props: SessionConversationProps) {
29
+ return <Conversation key={`${props.workspaceId ?? ""}:${props.sessionId}`} {...props} />;
30
+ }
31
+
32
+ function Conversation({
33
+ sessionId,
34
+ client,
35
+ workspaceId,
36
+ className,
37
+ height = "100%",
38
+ composerProps,
39
+ }: SessionConversationProps) {
40
+ const scope = { client, workspaceId };
41
+ const context = useOpenGeni(scope);
42
+ const catalog = useWorkspaceModelCatalog({
43
+ client: context.client,
44
+ workspaceId: context.workspaceId,
45
+ });
46
+ const feed = useSessionEvents(sessionId, scope);
47
+ const options = { ...scope, events: feed.events };
48
+ const detail = useSession(sessionId, options);
49
+ const queue = useTurnQueue(sessionId, options);
50
+ const human = useHumanInputRequests(sessionId, options);
51
+ const status = feed.sessionStatus ?? detail.session?.status;
52
+ const terminal = status === "cancelled";
53
+ const composer = useComposer(sessionId, {
54
+ ...options,
55
+ effectiveControl: queue.effectiveControl ?? detail.session?.effectiveControl,
56
+ sendDestination: () => (queue.queue.length > 0 || status === "running" ? "queue" : "chat"),
57
+ });
58
+ const region = useRef<HTMLDivElement>(null);
59
+ const error = detail.error ?? feed.error ?? human.error;
60
+ return (
61
+ <div
62
+ className={cn("og-root flex min-h-0 min-w-0 flex-col gap-2 overflow-hidden", className)}
63
+ ref={region}
64
+ style={{ height }}
65
+ data-og-conversation=""
66
+ >
67
+ {error && <p role="alert">{error.message}</p>}
68
+ <MessageTimeline
69
+ className="min-h-0 flex-1"
70
+ items={conversationTimeline(feed.timeline, queue, composer)}
71
+ status={status}
72
+ hasOlder={feed.hasOlder}
73
+ loadingOlder={feed.loadingOlder}
74
+ onLoadOlder={feed.loadOlder}
75
+ hasNewer={feed.hasNewer}
76
+ loadingNewer={feed.loadingNewer}
77
+ onLoadNewer={() => {
78
+ void feed.loadNewer();
79
+ }}
80
+ onJumpToStart={async () => {
81
+ await feed.loadOldest();
82
+ }}
83
+ loadingOldest={feed.loadingOldest}
84
+ onJumpToLatest={feed.jumpToLatest}
85
+ onAnnotate={composer.addAnnotation}
86
+ />
87
+ <div className="min-h-0 max-h-[40%] shrink-0 overflow-y-auto" data-og-conversation-inputs="">
88
+ <HumanInputSurface
89
+ requests={human.requests}
90
+ onSubmit={async (id, response) => {
91
+ await human.respond(id, response);
92
+ }}
93
+ respondingRequestId={human.respondingRequestId}
94
+ error={human.mutationError?.message}
95
+ autoFocus={false}
96
+ />
97
+ {terminal ? (
98
+ <QueueSurface queue={queue} readOnly />
99
+ ) : (
100
+ <QueueSurface
101
+ queue={queue}
102
+ composer={composer}
103
+ onRequestComposerFocus={() =>
104
+ region.current?.querySelector<HTMLTextAreaElement>("textarea")?.focus()
105
+ }
106
+ />
107
+ )}
108
+ </div>
109
+ <div className="shrink-0" data-og-conversation-composer="">
110
+ <ChatComposer
111
+ {...composerProps}
112
+ composer={composer}
113
+ disabled={terminal || composerProps?.disabled}
114
+ controlsStart={
115
+ composerProps?.controlsStart ??
116
+ (composer.policy && (
117
+ <ModelPolicyPicker
118
+ rows={catalog.rows}
119
+ model={composer.policy.model}
120
+ effort={composer.policy.reasoningEffort}
121
+ latencyMode={composer.policy.latencyMode}
122
+ loading={catalog.loading}
123
+ error={catalog.error?.message}
124
+ disabled={terminal}
125
+ sessionKey={sessionId}
126
+ onModelChange={(model) => composer.setModel?.(model)}
127
+ onEffortChange={(effort) => composer.setReasoningEffort?.(effort)}
128
+ onLatencyModeChange={(mode) => composer.setLatencyMode?.(mode)}
129
+ />
130
+ ))
131
+ }
132
+ responsiveBasis={composerProps?.responsiveBasis ?? "container"}
133
+ effectiveControl={
134
+ composer.effectiveControl ?? queue.effectiveControl ?? detail.session?.effectiveControl
135
+ }
136
+ queuedAheadCount={queue.queue.length}
137
+ />
138
+ </div>
139
+ </div>
140
+ );
141
+ }
@@ -0,0 +1,91 @@
1
+ import { Component, type ReactNode } from "react";
2
+
3
+ type Anchor = { element: HTMLElement; key: string | null; text: string | null; top: number };
4
+ export type TimelineAnchor = Anchor[];
5
+
6
+ /** Read the old DOM immediately before React changes it, not when a fetch starts. */
7
+ export class TimelineBeforeLayout extends Component<{
8
+ capture: () => void;
9
+ children: ReactNode;
10
+ }> {
11
+ getSnapshotBeforeUpdate() {
12
+ this.props.capture();
13
+ return null;
14
+ }
15
+ componentDidUpdate() {}
16
+ render() {
17
+ return this.props.children;
18
+ }
19
+ }
20
+
21
+ export function captureTimelineAnchor(scroller: HTMLElement): TimelineAnchor | null {
22
+ const viewport = scroller.getBoundingClientRect();
23
+ if (viewport.height <= 0) return null;
24
+ const groups = Array.from(scroller.querySelectorAll<HTMLElement>("[data-og-group-key]")).filter(
25
+ (group) => group.getBoundingClientRect().height > 0,
26
+ );
27
+ const anchors: TimelineAnchor = [];
28
+ // A disclosure is the reader's explicit point of interaction. In particular,
29
+ // anchoring a paragraph below an expanding disclosure would move its button.
30
+ const focused = scroller.ownerDocument.activeElement;
31
+ if (
32
+ focused instanceof HTMLElement &&
33
+ scroller.contains(focused) &&
34
+ focused.matches("button[aria-expanded]")
35
+ ) {
36
+ const box = focused.getBoundingClientRect();
37
+ if (box.bottom > viewport.top && box.top < viewport.bottom) {
38
+ anchors.push({ element: focused, key: null, text: null, top: box.top });
39
+ }
40
+ }
41
+ // A paragraph survives even when earlier deltas reconstruct its containing message.
42
+ for (const group of groups) {
43
+ const rect = group.getBoundingClientRect();
44
+ if (rect.bottom <= viewport.top || rect.top >= viewport.bottom) continue;
45
+ for (const element of group.querySelectorAll<HTMLElement>("p, li, pre, h1, h2, h3, h4")) {
46
+ const box = element.getBoundingClientRect();
47
+ const text = element.textContent;
48
+ if (box.bottom > viewport.top && box.top < viewport.bottom && text && text.length >= 12) {
49
+ anchors.push({ element, key: null, text, top: box.top });
50
+ }
51
+ }
52
+ }
53
+ // Prefer a retained visible row, then a following row. A following row also
54
+ // anchors the unchanged suffix of a partially loaded message above it.
55
+ const rows = groups.map((element) => ({
56
+ element,
57
+ key: element.getAttribute("data-og-group-key"),
58
+ text: null,
59
+ top: element.getBoundingClientRect().top,
60
+ }));
61
+ anchors.push(...rows.filter((row) => row.top >= viewport.top));
62
+ anchors.push(...rows.filter((row) => row.top < viewport.top).reverse());
63
+ return anchors;
64
+ }
65
+
66
+ /** Return only the correction native browser anchoring has not already made. */
67
+ export function timelineAnchorCorrection(
68
+ scroller: HTMLElement,
69
+ anchors: TimelineAnchor,
70
+ ): number | null {
71
+ let blocks: HTMLElement[] | undefined;
72
+ const groups = Array.from(scroller.querySelectorAll<HTMLElement>("[data-og-group-key]"));
73
+ for (const anchor of anchors) {
74
+ let element: HTMLElement | undefined;
75
+ if (
76
+ scroller.contains(anchor.element) &&
77
+ (!anchor.text || anchor.element.textContent === anchor.text)
78
+ ) {
79
+ element = anchor.element;
80
+ } else if (anchor.key) {
81
+ element = groups.find((group) => group.getAttribute("data-og-group-key") === anchor.key);
82
+ } else if (anchor.text) {
83
+ blocks ??= Array.from(scroller.querySelectorAll<HTMLElement>("p, li, pre, h1, h2, h3, h4"));
84
+ const matches = blocks.filter((block) => block.textContent === anchor.text);
85
+ // Repeated boilerplate is not sufficient evidence of retained content.
86
+ if (matches.length === 1) element = matches[0];
87
+ }
88
+ if (element) return element.getBoundingClientRect().top - anchor.top;
89
+ }
90
+ return null;
91
+ }
@@ -0,0 +1,83 @@
1
+ import type { ComposerState } from "./hooks/use-composer";
2
+ import type { UseTurnQueueResult } from "./hooks/use-turn-queue";
3
+ import type { TimelineItem, UserMessageItem } from "./timeline/types";
4
+
5
+ /** Keep pending prompts in the queue, not duplicated in the conversation. */
6
+ export function conversationTimeline(
7
+ items: TimelineItem[],
8
+ queue: Pick<UseTurnQueueResult, "queue" | "snapshot" | "acceptedSteers">,
9
+ composer: Pick<
10
+ ComposerState,
11
+ "optimisticMessages" | "retryOptimisticMessage" | "removeOptimisticMessage"
12
+ >,
13
+ ): TimelineItem[] {
14
+ const queued = new Set(
15
+ queue.queue
16
+ .filter((turn) => turn.metadata.delivery !== "steer")
17
+ .map((turn) => turn.triggerEventId),
18
+ );
19
+ const pending = composer.optimisticMessages ?? [];
20
+ const pendingQueue = new Set(
21
+ pending
22
+ .filter(
23
+ (message) =>
24
+ message.destination === "queue" &&
25
+ !(
26
+ message.turnId &&
27
+ message.appliedQueueVersion != null &&
28
+ queue.snapshot &&
29
+ queue.snapshot.version >= message.appliedQueueVersion &&
30
+ !queue.queue.some((turn) => turn.id === message.turnId)
31
+ ),
32
+ )
33
+ .map((message) => `user-message:${message.clientEventId}`),
34
+ );
35
+ const visible = items.filter(
36
+ (item) =>
37
+ item.kind !== "user-message" ||
38
+ (!queued.has(item.id) && !pendingQueue.has(item.reconciliationKey ?? "")),
39
+ );
40
+ const keys = new Set(
41
+ visible.flatMap((item) => (item.kind === "user-message" ? [item.reconciliationKey] : [])),
42
+ );
43
+ const optimistic: UserMessageItem[] = pending
44
+ .filter(
45
+ (message) =>
46
+ !keys.has(`user-message:${message.clientEventId}`) &&
47
+ !queue.queue.some((turn) => turn.id === message.turnId),
48
+ )
49
+ .map((message) => ({
50
+ kind: "user-message",
51
+ id: `optimistic:${message.clientEventId}`,
52
+ reconciliationKey: `user-message:${message.clientEventId}`,
53
+ text: message.text,
54
+ annotations: message.annotations.map((annotation, ordinal) => ({ ...annotation, ordinal })),
55
+ resources: message.resources,
56
+ tools: [],
57
+ occurredAt: message.occurredAt,
58
+ delivery: {
59
+ state: message.state,
60
+ ...(message.error ? { error: message.error } : {}),
61
+ ...(message.state === "failed"
62
+ ? {
63
+ onRetry: () => composer.retryOptimisticMessage?.(message.clientEventId),
64
+ onRemove: () => composer.removeOptimisticMessage?.(message.clientEventId),
65
+ }
66
+ : {}),
67
+ },
68
+ }));
69
+ const ids = new Set(visible.map((item) => item.id));
70
+ const steers: UserMessageItem[] = (queue.acceptedSteers ?? [])
71
+ .filter((steer) => !ids.has(steer.triggerEventId))
72
+ .map((steer) => ({
73
+ kind: "user-message",
74
+ id: steer.triggerEventId,
75
+ text: steer.text,
76
+ annotations: steer.annotations,
77
+ resources: steer.resources,
78
+ tools: steer.tools,
79
+ occurredAt: steer.occurredAt,
80
+ delivery: { state: steer.state },
81
+ }));
82
+ return [...visible, ...optimistic, ...steers];
83
+ }
@@ -52,6 +52,12 @@ export type UseFileAttachmentsResult = {
52
52
  addFromPaste: (event: { clipboardData: DataTransfer | null }) => void;
53
53
  /** Restore already-ready server assets without recreating browser-local bytes. */
54
54
  restoreReadyFiles: (files: Iterable<FileAsset>) => void;
55
+ /**
56
+ * Resolve a ready image's short-lived server preview URL on demand. Optional
57
+ * for upload-only embedded clients; local object-URL previews remain usable
58
+ * without it.
59
+ */
60
+ loadPreview?: ((id: string, signal?: AbortSignal) => Promise<string | undefined>) | undefined;
55
61
  /**
56
62
  * Re-run the upload for a `failed` attachment, in place (same id, same
57
63
  * source file). No-op for an id that isn't a known failed upload.
@@ -114,6 +120,8 @@ export function useFileAttachments(
114
120
  const { client, workspaceId } = useEmbeddedFileAttachments(options);
115
121
  const pasteFilter = options.pasteFilter ?? isImage;
116
122
  const [attachments, setAttachments] = useState<FileAttachment[]>([]);
123
+ const attachmentsRef = useRef(attachments);
124
+ attachmentsRef.current = attachments;
117
125
  // Keep the source File per attachment id so a failed upload can be retried
118
126
  // in place. Cleared on remove/clear so it never outlives its attachment.
119
127
  const sources = useRef<Map<string, File>>(new Map());
@@ -353,6 +361,31 @@ export function useFileAttachments(
353
361
  [workspaceId],
354
362
  );
355
363
 
364
+ const loadPreview = useCallback(
365
+ async (id: string, signal?: AbortSignal): Promise<string | undefined> => {
366
+ const generation = scopeGeneration.current;
367
+ const attachment = attachmentsRef.current.find((candidate) => candidate.id === id);
368
+ const createDownloadUrl = client.createFileDownloadUrl;
369
+ if (
370
+ !attachment ||
371
+ attachment.status !== "ready" ||
372
+ !attachment.file ||
373
+ !attachment.contentType.startsWith("image/") ||
374
+ typeof createDownloadUrl !== "function" ||
375
+ signal?.aborted
376
+ ) {
377
+ return undefined;
378
+ }
379
+ const fileId = attachment.file.id;
380
+ const signed = await createDownloadUrl.call(client, workspaceId, fileId, { signal });
381
+ if (scopeGeneration.current !== generation || signal?.aborted) return undefined;
382
+ const current = attachmentsRef.current.find((candidate) => candidate.id === id);
383
+ if (current?.status !== "ready" || current.file?.id !== fileId) return undefined;
384
+ return signed.url;
385
+ },
386
+ [client, workspaceId],
387
+ );
388
+
356
389
  const remove = useCallback(
357
390
  (id: string) => {
358
391
  sources.current.delete(id);
@@ -394,6 +427,7 @@ export function useFileAttachments(
394
427
  addFiles,
395
428
  addFromPaste,
396
429
  restoreReadyFiles,
430
+ ...(typeof client.createFileDownloadUrl === "function" ? { loadPreview } : {}),
397
431
  retry,
398
432
  retainPreview,
399
433
  remove,