@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.
- package/dist/artifacts.d.ts +1 -1
- package/dist/artifacts.js +248 -6
- package/dist/artifacts.js.map +1 -1
- package/dist/{browser-viewer-E5DDEFQ4.js → browser-viewer-NTD6UIPR.js} +3 -3
- package/dist/{chunk-W2RGYBCV.js → chunk-24M4YBCL.js} +4 -4
- package/dist/{chunk-WY3MELVJ.js → chunk-5IXTJTK3.js} +1438 -397
- package/dist/chunk-5IXTJTK3.js.map +1 -0
- package/dist/chunk-5PQJOCX5.js +902 -0
- package/dist/chunk-5PQJOCX5.js.map +1 -0
- package/dist/{chunk-UNCXXIQR.js → chunk-INGB2N5R.js} +4310 -3982
- package/dist/chunk-INGB2N5R.js.map +1 -0
- package/dist/{chunk-TJFMMDQU.js → chunk-MOWYEBCQ.js} +1839 -17
- package/dist/chunk-MOWYEBCQ.js.map +1 -0
- package/dist/chunk-PAJU5KKL.js +331 -0
- package/dist/chunk-PAJU5KKL.js.map +1 -0
- package/dist/{chunk-VTOREEXF.js → chunk-PLYRL5ER.js} +24 -2
- package/dist/{chunk-VTOREEXF.js.map → chunk-PLYRL5ER.js.map} +1 -1
- package/dist/{chunk-YVCHAQZY.js → chunk-ZFOFY5ST.js} +27 -349
- package/dist/chunk-ZFOFY5ST.js.map +1 -0
- package/dist/client.d.ts +1 -1
- package/dist/components/artifacts/index.d.ts +1 -1
- package/dist/components/artifacts/published-html-artifact-frame.d.ts +46 -3
- package/dist/components/composer.d.ts +2 -0
- package/dist/components/session-chrome.d.ts +38 -5
- package/dist/components/session-commands-panel.d.ts +6 -0
- package/dist/components/session-conversation.d.ts +14 -0
- package/dist/components/timeline-anchor.d.ts +21 -0
- package/dist/composer.js +2 -3
- package/dist/conversation-timeline.d.ts +5 -0
- package/dist/{fleet-decision-row-GGCAT4E4.js → fleet-decision-row-YZ3ZKWMM.js} +2 -1
- package/dist/fleet-decision-row-YZ3ZKWMM.js.map +1 -0
- package/dist/hooks/use-file-attachments.d.ts +6 -0
- package/dist/hooks/use-sandbox-files.d.ts +4 -1
- package/dist/hooks/use-workspace-edit.d.ts +4 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +823 -965
- package/dist/index.js.map +1 -1
- package/dist/interaction.js +2 -2
- package/dist/lib/format.d.ts +5 -0
- package/dist/machines.js +3 -2
- package/dist/session-ui.d.ts +3 -0
- package/dist/session-ui.js +14 -4
- package/dist/session.js +19 -19
- package/dist/timeline/registry.d.ts +2 -0
- package/dist/timeline/types.d.ts +4 -2
- package/package.json +2 -2
- package/src/artifacts.ts +3 -0
- package/src/client.ts +2 -1
- package/src/components/artifacts/index.ts +3 -0
- package/src/components/artifacts/published-html-artifact-frame.tsx +307 -4
- package/src/components/composer.tsx +89 -25
- package/src/components/machine-input-display.ts +6 -0
- package/src/components/message-timeline.tsx +351 -204
- package/src/components/sandbox-workspace.tsx +11 -1
- package/src/components/session-chrome.tsx +501 -128
- package/src/components/session-commands-panel.tsx +113 -0
- package/src/components/session-conversation.tsx +141 -0
- package/src/components/timeline-anchor.tsx +91 -0
- package/src/conversation-timeline.ts +83 -0
- package/src/hooks/use-file-attachments.ts +34 -0
- package/src/hooks/use-sandbox-files.ts +68 -20
- package/src/hooks/use-session-background-commands.ts +21 -10
- package/src/hooks/use-session-events.ts +56 -39
- package/src/hooks/use-session.ts +23 -2
- package/src/hooks/use-workspace-edit.ts +23 -5
- package/src/index.ts +4 -0
- package/src/lib/format.ts +32 -0
- package/src/session-ui.ts +3 -0
- package/src/timeline/fleet-decision-projection.ts +2 -1
- package/src/timeline/fleet-decision-row.tsx +1 -0
- package/src/timeline/projection.ts +157 -19
- package/src/timeline/registry.ts +15 -3
- package/src/timeline/tool-renderers.tsx +117 -1
- package/src/timeline/types.ts +12 -1
- package/styles/compiled.css +495 -411
- package/styles/index.css +23 -0
- package/dist/chunk-7EAFGICR.js +0 -302
- package/dist/chunk-7EAFGICR.js.map +0 -1
- package/dist/chunk-IB27C53Y.js +0 -2582
- package/dist/chunk-IB27C53Y.js.map +0 -1
- package/dist/chunk-TJFMMDQU.js.map +0 -1
- package/dist/chunk-UNCXXIQR.js.map +0 -1
- package/dist/chunk-WY3MELVJ.js.map +0 -1
- package/dist/chunk-YVCHAQZY.js.map +0 -1
- package/dist/fleet-decision-row-GGCAT4E4.js.map +0 -1
- /package/dist/{browser-viewer-E5DDEFQ4.js.map → browser-viewer-NTD6UIPR.js.map} +0 -0
- /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,
|