@opengeni/react 3.7.0-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.js +24 -5
- 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-DOK4KZF2.js → chunk-5IXTJTK3.js} +1322 -392
- 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-R55HFTT3.js → chunk-INGB2N5R.js} +4305 -4019
- package/dist/chunk-INGB2N5R.js.map +1 -0
- package/dist/{chunk-FJBOU2TH.js → chunk-MOWYEBCQ.js} +1728 -12
- 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/components/artifacts/published-html-artifact-frame.d.ts +3 -2
- package/dist/components/session-chrome.d.ts +37 -4
- 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/index.d.ts +4 -1
- package/dist/index.js +716 -914
- 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/types.d.ts +3 -1
- package/package.json +2 -2
- package/src/components/artifacts/published-html-artifact-frame.tsx +27 -4
- package/src/components/machine-input-display.ts +6 -0
- package/src/components/message-timeline.tsx +320 -197
- package/src/components/session-chrome.tsx +498 -125
- 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-session-background-commands.ts +21 -10
- package/src/hooks/use-session-events.ts +36 -14
- package/src/hooks/use-session.ts +23 -2
- package/src/index.ts +4 -0
- package/src/lib/format.ts +32 -0
- package/src/session-ui.ts +3 -0
- package/src/timeline/projection.ts +6 -12
- package/src/timeline/types.ts +4 -0
- 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-C6WDLHBA.js +0 -2590
- package/dist/chunk-C6WDLHBA.js.map +0 -1
- package/dist/chunk-DOK4KZF2.js.map +0 -1
- package/dist/chunk-FJBOU2TH.js.map +0 -1
- package/dist/chunk-R55HFTT3.js.map +0 -1
- package/dist/chunk-YVCHAQZY.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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { SessionBackgroundCommand } from "@opengeni/sdk";
|
|
2
|
-
import { useCallback } from "react";
|
|
2
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
3
3
|
|
|
4
4
|
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
5
5
|
import { usePolledValue } from "./internal";
|
|
@@ -23,28 +23,39 @@ export function useSessionBackgroundCommands(
|
|
|
23
23
|
): UseSessionBackgroundCommandsResult {
|
|
24
24
|
const { client, workspaceId } = useOpenGeni(options);
|
|
25
25
|
const enabled = (options.enabled ?? true) && Boolean(sessionId);
|
|
26
|
-
const load = useCallback(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
const load = useCallback(
|
|
27
|
+
async (signal?: AbortSignal) => {
|
|
28
|
+
if (!sessionId) return { commands: [] };
|
|
29
|
+
if (!client.listSessionBackgroundCommands) {
|
|
30
|
+
throw new Error("The configured OpenGeni client does not support background commands");
|
|
31
|
+
}
|
|
32
|
+
return await client.listSessionBackgroundCommands(workspaceId, sessionId, { signal });
|
|
33
|
+
},
|
|
34
|
+
[client, workspaceId, sessionId],
|
|
35
|
+
);
|
|
33
36
|
const state = usePolledValue(load, {
|
|
34
37
|
enabled,
|
|
35
38
|
pollIntervalMs: options.pollIntervalMs,
|
|
36
39
|
});
|
|
37
40
|
const refresh = state.refresh;
|
|
41
|
+
const generation = useRef(0);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
generation.current += 1;
|
|
44
|
+
return () => {
|
|
45
|
+
generation.current += 1;
|
|
46
|
+
};
|
|
47
|
+
}, [client, workspaceId, sessionId, enabled]);
|
|
38
48
|
const cancel = useCallback(
|
|
39
49
|
async (commandId: string): Promise<void> => {
|
|
40
50
|
if (!sessionId) return;
|
|
41
51
|
if (!client.cancelSessionBackgroundCommand) {
|
|
42
52
|
throw new Error("The configured OpenGeni client does not support background commands");
|
|
43
53
|
}
|
|
54
|
+
const ticket = generation.current;
|
|
44
55
|
await client.cancelSessionBackgroundCommand(workspaceId, sessionId, commandId);
|
|
45
|
-
await refresh();
|
|
56
|
+
if (enabled && ticket === generation.current) await refresh();
|
|
46
57
|
},
|
|
47
|
-
[client, workspaceId, sessionId, refresh],
|
|
58
|
+
[client, workspaceId, sessionId, refresh, enabled],
|
|
48
59
|
);
|
|
49
60
|
return {
|
|
50
61
|
commands: state.data?.commands ?? [],
|
|
@@ -77,7 +77,7 @@ export type UseSessionEventsResult = {
|
|
|
77
77
|
// Keep every browser history read inside one database batch, including the
|
|
78
78
|
// server's one-row continuation lookahead. A large total session must never
|
|
79
79
|
// turn one lazy page into dozens of sequential database round trips.
|
|
80
|
-
const SESSION_HISTORY_PAGE_SIZE =
|
|
80
|
+
const SESSION_HISTORY_PAGE_SIZE = 1000;
|
|
81
81
|
const INITIAL_FETCH_CAP = 1;
|
|
82
82
|
const OLDER_GROUP_TARGET = 32;
|
|
83
83
|
const OLDER_FETCH_CAP = 2;
|
|
@@ -193,6 +193,7 @@ export function useSessionEvents(
|
|
|
193
193
|
const streamAbortRef = useRef<AbortController | null>(null);
|
|
194
194
|
const streamKeyRef = useRef<string | null>(null);
|
|
195
195
|
const generationRef = useRef(0);
|
|
196
|
+
const navigationGenerationRef = useRef(0);
|
|
196
197
|
const eventWindowRef = useRef<BrowserSessionEventWindow>(EMPTY_EVENT_WINDOW);
|
|
197
198
|
const sessionStatusRef = useRef<{
|
|
198
199
|
sequence: number;
|
|
@@ -205,6 +206,21 @@ export function useSessionEvents(
|
|
|
205
206
|
// new stream identity cannot expose the previous session's event log.
|
|
206
207
|
const [stateStreamKey, setStateStreamKey] = useState(streamKey);
|
|
207
208
|
|
|
209
|
+
// Reopening SSE after a prepend must not cancel the next history page.
|
|
210
|
+
// Navigation belongs to the session/client lifetime, not the transport.
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
navigationGenerationRef.current += 1;
|
|
213
|
+
loadingOlderRef.current = false;
|
|
214
|
+
loadingNewerRef.current = false;
|
|
215
|
+
loadingOldestRef.current = false;
|
|
216
|
+
setLoadingOlder(false);
|
|
217
|
+
setLoadingNewer(false);
|
|
218
|
+
setLoadingOldest(false);
|
|
219
|
+
return () => {
|
|
220
|
+
navigationGenerationRef.current += 1;
|
|
221
|
+
};
|
|
222
|
+
}, [client, workspaceId, sessionId, after, enabled, fullReplay]);
|
|
223
|
+
|
|
208
224
|
useEffect(() => {
|
|
209
225
|
// Reset the accumulated log only when the stream identity changes —
|
|
210
226
|
// pausing via `enabled: false` keeps the timeline visible.
|
|
@@ -243,10 +259,6 @@ export function useSessionEvents(
|
|
|
243
259
|
// only the newest dependency generation can mutate refs or React state.
|
|
244
260
|
const generation = generationRef.current + 1;
|
|
245
261
|
generationRef.current = generation;
|
|
246
|
-
if (loadingOlderRef.current) {
|
|
247
|
-
loadingOlderRef.current = false;
|
|
248
|
-
setLoadingOlder(false);
|
|
249
|
-
}
|
|
250
262
|
if (!sessionId || !streamEnabled) {
|
|
251
263
|
// A page that stayed hidden beyond the live-activity grace deliberately
|
|
252
264
|
// closed its SSE connection. Replaying from the old cursor on return can
|
|
@@ -415,6 +427,16 @@ export function useSessionEvents(
|
|
|
415
427
|
}
|
|
416
428
|
const status = observeSessionStatus(window.events, sessionStatusRef);
|
|
417
429
|
const retained = boundBrowserSessionEventWindow(window.events);
|
|
430
|
+
// A replacement tail retires requests against the discarded window.
|
|
431
|
+
// Ordinary SSE reconnects preserve navigation, but splicing an old
|
|
432
|
+
// page into this new tail could leave an inaccessible history gap.
|
|
433
|
+
navigationGenerationRef.current += 1;
|
|
434
|
+
loadingOlderRef.current = false;
|
|
435
|
+
loadingNewerRef.current = false;
|
|
436
|
+
loadingOldestRef.current = false;
|
|
437
|
+
setLoadingOlder(false);
|
|
438
|
+
setLoadingNewer(false);
|
|
439
|
+
setLoadingOldest(false);
|
|
418
440
|
eventWindowRef.current = retained;
|
|
419
441
|
oldestSequenceRef.current = retained.events[0]?.sequence ?? window.oldestSequence;
|
|
420
442
|
newestSequenceRef.current =
|
|
@@ -543,7 +565,7 @@ export function useSessionEvents(
|
|
|
543
565
|
setHasOlder(false);
|
|
544
566
|
return false;
|
|
545
567
|
}
|
|
546
|
-
const generation =
|
|
568
|
+
const generation = navigationGenerationRef.current;
|
|
547
569
|
loadingOlderRef.current = true;
|
|
548
570
|
setLoadingOlder(true);
|
|
549
571
|
let published = false;
|
|
@@ -554,7 +576,7 @@ export function useSessionEvents(
|
|
|
554
576
|
targetGroups: OLDER_GROUP_TARGET,
|
|
555
577
|
maxFetches: OLDER_FETCH_CAP,
|
|
556
578
|
});
|
|
557
|
-
if (
|
|
579
|
+
if (navigationGenerationRef.current !== generation) {
|
|
558
580
|
return false;
|
|
559
581
|
}
|
|
560
582
|
if (window.events.length === 0) {
|
|
@@ -635,7 +657,7 @@ export function useSessionEvents(
|
|
|
635
657
|
published = true;
|
|
636
658
|
return olderStillAvailable;
|
|
637
659
|
} finally {
|
|
638
|
-
if (!published) {
|
|
660
|
+
if (!published && navigationGenerationRef.current === generation) {
|
|
639
661
|
loadingOlderRef.current = false;
|
|
640
662
|
setLoadingOlder(false);
|
|
641
663
|
}
|
|
@@ -648,7 +670,7 @@ export function useSessionEvents(
|
|
|
648
670
|
if (!sessionId || navigationBusy() || !hasOlderRef.current) {
|
|
649
671
|
return false;
|
|
650
672
|
}
|
|
651
|
-
const generation =
|
|
673
|
+
const generation = navigationGenerationRef.current;
|
|
652
674
|
loadingOldestRef.current = true;
|
|
653
675
|
setLoadingOldest(true);
|
|
654
676
|
let published = false;
|
|
@@ -659,7 +681,7 @@ export function useSessionEvents(
|
|
|
659
681
|
targetGroups: OLDEST_GROUP_TARGET,
|
|
660
682
|
maxFetches: OLDEST_FETCH_CAP,
|
|
661
683
|
});
|
|
662
|
-
if (
|
|
684
|
+
if (navigationGenerationRef.current !== generation) {
|
|
663
685
|
return false;
|
|
664
686
|
}
|
|
665
687
|
if (window.events.length === 0) {
|
|
@@ -700,7 +722,7 @@ export function useSessionEvents(
|
|
|
700
722
|
published = true;
|
|
701
723
|
return newer;
|
|
702
724
|
} finally {
|
|
703
|
-
if (!published) {
|
|
725
|
+
if (!published && navigationGenerationRef.current === generation) {
|
|
704
726
|
loadingOldestRef.current = false;
|
|
705
727
|
setLoadingOldest(false);
|
|
706
728
|
}
|
|
@@ -717,7 +739,7 @@ export function useSessionEvents(
|
|
|
717
739
|
setHasNewer(false);
|
|
718
740
|
return false;
|
|
719
741
|
}
|
|
720
|
-
const generation =
|
|
742
|
+
const generation = navigationGenerationRef.current;
|
|
721
743
|
loadingNewerRef.current = true;
|
|
722
744
|
setLoadingNewer(true);
|
|
723
745
|
let published = false;
|
|
@@ -728,7 +750,7 @@ export function useSessionEvents(
|
|
|
728
750
|
targetGroups: NEWER_GROUP_TARGET,
|
|
729
751
|
maxFetches: NEWER_FETCH_CAP,
|
|
730
752
|
});
|
|
731
|
-
if (
|
|
753
|
+
if (navigationGenerationRef.current !== generation) {
|
|
732
754
|
return false;
|
|
733
755
|
}
|
|
734
756
|
if (window.events.length === 0) {
|
|
@@ -800,7 +822,7 @@ export function useSessionEvents(
|
|
|
800
822
|
published = true;
|
|
801
823
|
return newer;
|
|
802
824
|
} finally {
|
|
803
|
-
if (!published) {
|
|
825
|
+
if (!published && navigationGenerationRef.current === generation) {
|
|
804
826
|
loadingNewerRef.current = false;
|
|
805
827
|
setLoadingNewer(false);
|
|
806
828
|
}
|
package/src/hooks/use-session.ts
CHANGED
|
@@ -38,6 +38,10 @@ export function isTitleEvent(event: Pick<SessionEvent, "type">): boolean {
|
|
|
38
38
|
return event.type === "session.title_set";
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function isSessionDetailEvent(event: Pick<SessionEvent, "type">): boolean {
|
|
42
|
+
return isTitleEvent(event) || event.type.startsWith("session.command.");
|
|
43
|
+
}
|
|
44
|
+
|
|
41
45
|
/** Fetch one session (with optional polling), live-patching its title on `session.title_set`. */
|
|
42
46
|
export function useSession(
|
|
43
47
|
sessionId: string | null | undefined,
|
|
@@ -99,10 +103,27 @@ export function useSession(
|
|
|
99
103
|
[base, override],
|
|
100
104
|
);
|
|
101
105
|
|
|
106
|
+
const sharedEvents = options.events;
|
|
107
|
+
|
|
102
108
|
// Live-patch the title on auto (agent) + cross-client (user/agent) renames so
|
|
103
109
|
// the UI reflects the new title without polling or a full re-fetch.
|
|
104
110
|
const onTitleEvent = useCallback(
|
|
105
111
|
(event: SessionEvent) => {
|
|
112
|
+
// Reconciliation emits only the last matching member of a shared batch.
|
|
113
|
+
// A later title must not hide an earlier command activity change.
|
|
114
|
+
if (
|
|
115
|
+
sharedEvents?.some(
|
|
116
|
+
(item) =>
|
|
117
|
+
item.sessionId === sessionId &&
|
|
118
|
+
item.type.startsWith("session.command.") &&
|
|
119
|
+
item.sequence > (base?.lastSequence ?? -1),
|
|
120
|
+
)
|
|
121
|
+
) {
|
|
122
|
+
void refresh();
|
|
123
|
+
} else if (event.type.startsWith("session.command.")) {
|
|
124
|
+
if (!base || event.sequence > base.lastSequence) void refresh();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
106
127
|
// The fetched session row is the authoritative title projection through
|
|
107
128
|
// lastSequence. Shared feeds may replay that historical tail after the
|
|
108
129
|
// fetch; applying it would undo a row-only migration quarantine. Only an
|
|
@@ -122,9 +143,9 @@ export function useSession(
|
|
|
122
143
|
return { ...next, title, titleSource: source };
|
|
123
144
|
});
|
|
124
145
|
},
|
|
125
|
-
[base],
|
|
146
|
+
[base, refresh, sharedEvents, sessionId],
|
|
126
147
|
);
|
|
127
|
-
useSessionEventTrigger(client, workspaceId, sessionId,
|
|
148
|
+
useSessionEventTrigger(client, workspaceId, sessionId, isSessionDetailEvent, onTitleEvent, {
|
|
128
149
|
enabled,
|
|
129
150
|
...(options.events !== undefined ? { events: options.events } : {}),
|
|
130
151
|
});
|