@opengeni/react 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/react",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "React hooks and styled components for OpenGeni: live session streaming, chat composer, message timeline, session status, and fleet views — token-themed (CSS variables), dark-first, built on Tailwind v4 + Radix + Motion.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -40,7 +40,7 @@
40
40
  "demo:build": "vite build demo"
41
41
  },
42
42
  "dependencies": {
43
- "@opengeni/sdk": "^0.4.0",
43
+ "@opengeni/sdk": "^0.5.0",
44
44
  "clsx": "^2.1.1",
45
45
  "lucide-react": "^1.8.0",
46
46
  "motion": "^12.0.0",
package/src/client.ts CHANGED
@@ -11,6 +11,7 @@ export type SessionClientLike = Pick<
11
11
  | "getClientConfig"
12
12
  // Sessions, events, composer
13
13
  | "getSession"
14
+ | "updateSession"
14
15
  | "listSessions"
15
16
  | "sendMessage"
16
17
  | "steerMessage"
@@ -15,6 +15,11 @@ export type FleetTileProps = {
15
15
 
16
16
  /** Best-effort display title for a session. */
17
17
  export function sessionDisplayTitle(session: Session): string {
18
+ // A set title (agent-generated or user-renamed) wins over metadata + the
19
+ // initial-message fallback.
20
+ if (typeof session.title === "string" && session.title.trim().length > 0) {
21
+ return session.title;
22
+ }
18
23
  for (const key of ["title", "name"] as const) {
19
24
  const value = session.metadata[key];
20
25
  if (typeof value === "string" && value.trim().length > 0) {
@@ -1,33 +1,101 @@
1
- import type { Session } from "@opengeni/sdk";
2
- import { useCallback } from "react";
1
+ import type { Session, SessionEvent } from "@opengeni/sdk";
2
+ import { useCallback, useState } from "react";
3
3
  import { useOpenGeni, type ClientOverride } from "../provider";
4
- import { usePolledValue } from "./internal";
4
+ import { useMutationRunner, usePolledValue, useSessionEventTrigger, type SessionEventFeedOptions } from "./internal";
5
5
 
6
- export type UseSessionOptions = ClientOverride & {
7
- /** Re-fetch on an interval (ms). Off by default — pair with `useSessionEvents` for live status. */
8
- pollIntervalMs?: number | undefined;
9
- enabled?: boolean | undefined;
10
- };
6
+ export type UseSessionOptions = ClientOverride &
7
+ SessionEventFeedOptions & {
8
+ /** Re-fetch on an interval (ms). Off by default — pair with `useSessionEvents` for live status. */
9
+ pollIntervalMs?: number | undefined;
10
+ };
11
11
 
12
12
  export type UseSessionResult = {
13
13
  session: Session | null;
14
14
  loading: boolean;
15
15
  error: Error | null;
16
16
  refresh: () => Promise<void>;
17
+ /** Manually rename the session (PATCH, source='user'). Returns the updated session, or null on failure. */
18
+ updateTitle: (title: string) => Promise<Session | null>;
19
+ /** True while a rename is in flight. */
20
+ updating: boolean;
21
+ mutationError: Error | null;
22
+ clearMutationError: () => void;
17
23
  };
18
24
 
19
- /** Fetch one session (with optional polling). */
25
+ /** Event types that change the session title (auto + cross-client renames). */
26
+ export function isTitleEvent(event: Pick<SessionEvent, "type">): boolean {
27
+ return event.type === "session.title_set";
28
+ }
29
+
30
+ /** Fetch one session (with optional polling), live-patching its title on `session.title_set`. */
20
31
  export function useSession(sessionId: string | null | undefined, options: UseSessionOptions = {}): UseSessionResult {
21
32
  const { client, workspaceId } = useOpenGeni(options);
33
+ const enabled = (options.enabled ?? true) && Boolean(sessionId);
34
+ const [override, setOverride] = useState<Session | null>(null);
35
+ const mutation = useMutationRunner();
22
36
  const load = useCallback(async () => {
23
37
  if (!sessionId) {
24
38
  return null;
25
39
  }
26
- return await client.getSession(workspaceId, sessionId);
40
+ const fetched = await client.getSession(workspaceId, sessionId);
41
+ // A fresh server read supersedes any optimistic/event-driven override.
42
+ setOverride(null);
43
+ return fetched;
27
44
  }, [client, workspaceId, sessionId]);
28
45
  const state = usePolledValue(load, {
29
46
  pollIntervalMs: options.pollIntervalMs,
30
- enabled: (options.enabled ?? true) && Boolean(sessionId),
47
+ enabled,
31
48
  });
32
- return { session: state.data ?? null, loading: state.loading, error: state.error, refresh: state.refresh };
49
+
50
+ const base = state.data ?? null;
51
+ // The override only ever carries title/titleSource patches; it is reset on
52
+ // every fresh load so it can never go stale against the server snapshot.
53
+ const session = base && override && override.id === base.id ? { ...base, title: override.title, titleSource: override.titleSource } : base;
54
+
55
+ // Live-patch the title on auto (agent) + cross-client (user/agent) renames so
56
+ // the UI reflects the new title without polling or a full re-fetch.
57
+ const onTitleEvent = useCallback((event: SessionEvent) => {
58
+ const payload = (event.payload ?? {}) as { title?: unknown; source?: unknown };
59
+ const title = payload.title;
60
+ if (typeof title !== "string") {
61
+ return;
62
+ }
63
+ const source: "user" | "agent" | null = payload.source === "user" || payload.source === "agent" ? payload.source : null;
64
+ setOverride((current): Session | null => {
65
+ const next = current ?? base;
66
+ if (!next) {
67
+ return current;
68
+ }
69
+ return { ...next, title, titleSource: source };
70
+ });
71
+ }, [base]);
72
+ useSessionEventTrigger(client, workspaceId, sessionId, isTitleEvent, onTitleEvent, {
73
+ enabled,
74
+ ...(options.events !== undefined ? { events: options.events } : {}),
75
+ });
76
+
77
+ const updateTitle = useCallback(
78
+ async (title: string): Promise<Session | null> => {
79
+ if (!sessionId) {
80
+ return null;
81
+ }
82
+ const result = await mutation.run(() => client.updateSession(workspaceId, sessionId, { title }));
83
+ if (result) {
84
+ setOverride(result);
85
+ }
86
+ return result;
87
+ },
88
+ [client, workspaceId, sessionId, mutation.run],
89
+ );
90
+
91
+ return {
92
+ session,
93
+ loading: state.loading,
94
+ error: state.error,
95
+ refresh: state.refresh,
96
+ updateTitle,
97
+ updating: mutation.mutating,
98
+ mutationError: mutation.mutationError,
99
+ clearMutationError: mutation.clearMutationError,
100
+ };
33
101
  }
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@ export { OpenGeniProvider, useOpenGeni, useOpenGeniClient } from "./provider";
9
9
  export type { ClientOverride, OpenGeniContextValue, OpenGeniProviderProps } from "./provider";
10
10
 
11
11
  // Hooks
12
- export { useSession } from "./hooks/use-session";
12
+ export { useSession, isTitleEvent } from "./hooks/use-session";
13
13
  export type { UseSessionOptions, UseSessionResult } from "./hooks/use-session";
14
14
  export { useSessionEvents } from "./hooks/use-session-events";
15
15
  export type { SessionEventsConnectionState, UseSessionEventsOptions, UseSessionEventsResult } from "./hooks/use-session-events";
@@ -22,10 +22,16 @@ export function gitFileDiffToPatch(file: GitFileDiff): string {
22
22
  lines.push(`+++ b/${newPath}`);
23
23
  }
24
24
  for (const hunk of file.hunks) {
25
- const header =
26
- hunk.header && hunk.header.startsWith("@@")
27
- ? hunk.header
28
- : `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
25
+ // Only trust a pre-parsed header if it carries the full unified range form
26
+ // `@@ -<o>[,<n>] +<o>[,<n>] @@`. A synthesized create_file hunk (parsers.ts)
27
+ // can carry a degenerate `@@ +1 @@` with no `-`/`+` ranges; a generic patch
28
+ // parser (Pierre) renders zero lines from it, so the expanded diff comes up
29
+ // empty while the collapsed chip still shows the (correct) addition count.
30
+ // In that case regenerate a valid header from the hunk's range fields.
31
+ const headerIsValid = /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/.test(hunk.header ?? "");
32
+ const header = headerIsValid
33
+ ? hunk.header
34
+ : `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`;
29
35
  lines.push(header);
30
36
  for (const line of hunk.lines) {
31
37
  if (line.type === "meta") continue;
@@ -131,7 +131,12 @@ export function v4aToGitFileDiff(op: ApplyPatchOperation): GitFileDiff {
131
131
  hunks.push(cur);
132
132
  } else if (cur || op.type === "create_file") {
133
133
  if (!cur) {
134
- cur = { oldStart: 0, oldLines: 0, newStart: 1, newLines: 0, header: "@@ +1 @@", lines: [] };
134
+ // No `@@` anchor on a create_file body: synthesize an add-only hunk.
135
+ // Leave `header` empty so `gitFileDiffToPatch` regenerates a valid
136
+ // `@@ -0,0 +1,N @@` from the range fields once newLines is counted — a
137
+ // pre-baked partial header (e.g. `@@ +1 @@`) renders zero lines in a
138
+ // generic unified-diff parser.
139
+ cur = { oldStart: 0, oldLines: 0, newStart: 1, newLines: 0, header: "", lines: [] };
135
140
  hunks.push(cur);
136
141
  oldNo = 0;
137
142
  newNo = 1;