@opengeni/react 0.34.2 → 0.35.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/{chunk-GZ4KR67U.js → chunk-ALA3UGWB.js} +2 -2
  2. package/dist/chunk-ATSTR5P3.js +138 -0
  3. package/dist/chunk-ATSTR5P3.js.map +1 -0
  4. package/dist/{chunk-L7R5RK6A.js → chunk-AXQQ2V2I.js} +1312 -926
  5. package/dist/chunk-AXQQ2V2I.js.map +1 -0
  6. package/dist/{chunk-KZRUYCL4.js → chunk-FT2TXNGZ.js} +5 -2
  7. package/dist/chunk-FT2TXNGZ.js.map +1 -0
  8. package/dist/{chunk-TCCE5ZU5.js → chunk-J2RVJ6JX.js} +39 -9
  9. package/dist/chunk-J2RVJ6JX.js.map +1 -0
  10. package/dist/{chunk-4QOQ2DSR.js → chunk-LAKLF4PA.js} +14 -1
  11. package/dist/chunk-LAKLF4PA.js.map +1 -0
  12. package/dist/{chunk-A2WITZTI.js → chunk-OKKMZDQF.js} +3 -2
  13. package/dist/chunk-OKKMZDQF.js.map +1 -0
  14. package/dist/{chunk-MS3FBZ6A.js → chunk-PVW56EVR.js} +105 -62
  15. package/dist/chunk-PVW56EVR.js.map +1 -0
  16. package/dist/components/chat-composer.d.ts +2 -1
  17. package/dist/components/composer.d.ts +8 -2
  18. package/dist/components/copy-button.d.ts +4 -2
  19. package/dist/components/tip-follow.d.ts +3 -0
  20. package/dist/composer.d.ts +2 -1
  21. package/dist/composer.js +4 -3
  22. package/dist/hooks/use-turn-queue.d.ts +3 -1
  23. package/dist/index.d.ts +2 -2
  24. package/dist/index.js +105 -101
  25. package/dist/index.js.map +1 -1
  26. package/dist/lib/format.d.ts +2 -0
  27. package/dist/machines.js +2 -2
  28. package/dist/session-ui.js +4 -3
  29. package/dist/session.js +4 -4
  30. package/dist/timeline/index.d.ts +1 -1
  31. package/dist/timeline/parsers.d.ts +5 -0
  32. package/dist/timeline/projection.d.ts +1 -12
  33. package/dist/timeline/tool-display-name.d.ts +17 -6
  34. package/dist/workstream-control-event.d.ts +6 -0
  35. package/package.json +2 -2
  36. package/src/components/chat-composer.tsx +1 -1
  37. package/src/components/composer-transcription-control.tsx +70 -57
  38. package/src/components/composer.tsx +197 -126
  39. package/src/components/copy-button.tsx +27 -8
  40. package/src/components/message-timeline.tsx +385 -71
  41. package/src/components/tip-follow.ts +15 -0
  42. package/src/composer.ts +1 -1
  43. package/src/hooks/use-composer.ts +7 -1
  44. package/src/hooks/use-turn-queue.ts +4 -0
  45. package/src/index.ts +2 -1
  46. package/src/lib/format.ts +14 -0
  47. package/src/timeline/index.ts +2 -0
  48. package/src/timeline/parsers.ts +18 -1
  49. package/src/timeline/projection.ts +51 -20
  50. package/src/timeline/registry.ts +15 -2
  51. package/src/timeline/shared.tsx +30 -28
  52. package/src/timeline/tool-display-name.ts +30 -11
  53. package/src/timeline/tool-renderers.tsx +347 -16
  54. package/src/timeline/turn-summary.tsx +5 -2
  55. package/src/workstream-control-event.ts +6 -0
  56. package/dist/chunk-4QOQ2DSR.js.map +0 -1
  57. package/dist/chunk-A2WITZTI.js.map +0 -1
  58. package/dist/chunk-KZRUYCL4.js.map +0 -1
  59. package/dist/chunk-L7R5RK6A.js.map +0 -1
  60. package/dist/chunk-MS3FBZ6A.js.map +0 -1
  61. package/dist/chunk-TCCE5ZU5.js.map +0 -1
  62. /package/dist/{chunk-GZ4KR67U.js.map → chunk-ALA3UGWB.js.map} +0 -0
@@ -81,6 +81,21 @@ export const TIP_FOLLOW_VELOCITY_ARM_DEBT_PX = 72;
81
81
  export const TIP_FOLLOW_VELOCITY_DECAY_MS = _t(180);
82
82
  /** Reader-up pixels above clamp budget that count as leaving the tip. */
83
83
  export const TIP_FOLLOW_READER_UP_EPS_PX = 2;
84
+
85
+ /** Test-only override for scrollend feature detection (`null` = probe DOM). */
86
+ let scrollEndSupportOverride: boolean | null = null;
87
+
88
+ /** @internal */ export function setScrollEndSupportForTests(value: boolean | null): void {
89
+ scrollEndSupportOverride = value;
90
+ }
91
+
92
+ /** True when the engine exposes element `scrollend` (prefer over rAF leave). */
93
+ export function supportsScrollEndEvent(): boolean {
94
+ if (scrollEndSupportOverride !== null) {
95
+ return scrollEndSupportOverride;
96
+ }
97
+ return typeof HTMLElement !== "undefined" && "onscrollend" in HTMLElement.prototype;
98
+ }
84
99
  /** @deprecated Shrink lock removed; kept so old imports do not break. */
85
100
  export const TIP_FOLLOW_SHRINK_LOCK_MS = 0;
86
101
  /** @deprecated Absorb thresholds removed — growth track is continuous. */
package/src/composer.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * `import * as Composer from "@opengeni/react/composer"`.
5
5
  */
6
+ export { OPEN_WORKSTREAM_CONTROL_EVENT } from "./workstream-control-event";
6
7
  export {
7
8
  Actions,
8
9
  AttachButton,
@@ -16,7 +17,6 @@ export {
16
17
  Hint,
17
18
  Input,
18
19
  ModelPicker,
19
- OPEN_WORKSTREAM_CONTROL_EVENT,
20
20
  PauseButton,
21
21
  PausedState,
22
22
  RestoredResources,
@@ -420,7 +420,13 @@ export function useComposer(
420
420
  localSignatureAtStart === null
421
421
  ? localAtStart !== 0
422
422
  : localSignatureAtStart !== lastSavedSignature.current;
423
- setDraftLoading(true);
423
+ // Only blank the picker on first hydrate / hard reload. Reconcile and
424
+ // event-triggered soft reloads (loadOlder SSE reconnect) must not flicker
425
+ // draftLoading — stale-while-revalidate keeps the settled UI mounted.
426
+ const showLoading = replaceLocal || draftRef.current === null;
427
+ if (showLoading) {
428
+ setDraftLoading(true);
429
+ }
424
430
  try {
425
431
  const fetched = await client.getComposerDraft(workspaceId, sessionId);
426
432
  if (
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  ComposerDraft,
3
3
  EffectiveSessionControl,
4
+ McpPersonalConnectionSummary,
4
5
  SessionEvent,
5
6
  SessionQueueMutationResponse,
6
7
  SessionQueueSnapshot,
@@ -53,6 +54,8 @@ export type UseTurnQueueResult = {
53
54
  pendingInputs: SessionPendingInputPreview[];
54
55
  /** Exact pending members projected to join an already-waiting prompt. */
55
56
  pendingInputAttachment: SessionQueueSnapshot["pendingInputAttachment"];
57
+ /** Secret-safe personal MCP summaries frozen on the exact active turn. */
58
+ activePersonalConnections: McpPersonalConnectionSummary[];
56
59
  effectiveControl: EffectiveSessionControl | null;
57
60
  /** The latest interrupted attempt has not yet durably proved physical quiescence. */
58
61
  stoppingPreviousAttempt: boolean;
@@ -332,6 +335,7 @@ export function useTurnQueue(
332
335
  queue: visibleSnapshot?.items ?? [],
333
336
  pendingInputs: visibleSnapshot?.pendingInputs ?? [],
334
337
  pendingInputAttachment: visibleSnapshot?.pendingInputAttachment ?? null,
338
+ activePersonalConnections: visibleSnapshot?.activePersonalConnections ?? [],
335
339
  effectiveControl: visibleSnapshot?.effectiveControl ?? null,
336
340
  stoppingPreviousAttempt: visibleSnapshot?.stoppingPreviousAttempt ?? false,
337
341
  loading: identityMatches ? loading : enabled,
package/src/index.ts CHANGED
@@ -100,7 +100,7 @@ export type {
100
100
  SessionChromeSignalTone,
101
101
  } from "./components/session-chrome";
102
102
  export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./components/tooltip";
103
- export { OPEN_WORKSTREAM_CONTROL_EVENT } from "./components/chat-composer";
103
+ export { OPEN_WORKSTREAM_CONTROL_EVENT } from "./workstream-control-event";
104
104
  export { useGoal, isGoalEvent } from "./hooks/use-goal";
105
105
  export type { UseGoalOptions, UseGoalResult } from "./hooks/use-goal";
106
106
  export { useSessionControl } from "./hooks/use-session-control";
@@ -486,6 +486,7 @@ export { cn } from "./lib/cn";
486
486
  export {
487
487
  CREDIT_EXHAUSTION_MESSAGE,
488
488
  formatBytes,
489
+ formatClockTime,
489
490
  formatRelativeTime,
490
491
  humanizeFailureReason,
491
492
  isCreditExhaustion,
package/src/lib/format.ts CHANGED
@@ -1,5 +1,19 @@
1
1
  import { OpenGeniApiError } from "@opengeni/sdk";
2
2
 
3
+ /** Local finished-at stamp for message footers: "Aug 2, 3:42 PM" / locale-equivalent. */
4
+ export function formatClockTime(iso: string): string {
5
+ const then = new Date(iso);
6
+ if (Number.isNaN(then.getTime())) {
7
+ return "";
8
+ }
9
+ return then.toLocaleString(undefined, {
10
+ month: "short",
11
+ day: "numeric",
12
+ hour: "numeric",
13
+ minute: "2-digit",
14
+ });
15
+ }
16
+
3
17
  /** Compact relative time: "now", "42s", "7m", "3h", "2d", then a date. */
4
18
  export function formatRelativeTime(iso: string, now: Date = new Date()): string {
5
19
  const then = new Date(iso).getTime();
@@ -22,7 +22,9 @@ export {
22
22
  extractSessionRef,
23
23
  groupTimeline,
24
24
  sessionStatusFromEvents,
25
+ mcpToolLeaf,
25
26
  toolDisplayName,
27
+ toolMatchesLeaf,
26
28
  } from "./projection";
27
29
 
28
30
  // item types
@@ -227,6 +227,19 @@ export function applyPatchOps(raw: unknown): ApplyPatchOperation[] {
227
227
  return r.operation ? [r.operation] : [];
228
228
  }
229
229
 
230
+ /** Ops from provider `raw`, or from function-tool arguments when raw is empty. */
231
+ export function applyPatchOpsFromToolItem(item: {
232
+ raw: unknown;
233
+ arguments: unknown;
234
+ }): ApplyPatchOperation[] {
235
+ const fromRaw = applyPatchOps(item.raw);
236
+ if (fromRaw.length > 0) {
237
+ return fromRaw;
238
+ }
239
+ const args = parseToolArgs(item.arguments);
240
+ return applyPatchOps(args);
241
+ }
242
+
230
243
  /**
231
244
  * True when a tool item is an `apply_patch_call` — by its provider-native
232
245
  * `raw.type` (the live-wire source of truth) or by tool `name` (first-party
@@ -235,7 +248,11 @@ export function applyPatchOps(raw: unknown): ApplyPatchOperation[] {
235
248
  export function isApplyPatch(item: { name: string; raw: unknown }): boolean {
236
249
  const type =
237
250
  item.raw && typeof item.raw === "object" ? (item.raw as { type?: unknown }).type : undefined;
238
- return type === "apply_patch_call" || item.name === "apply_patch_call";
251
+ if (type === "apply_patch_call") {
252
+ return true;
253
+ }
254
+ const name = item.name;
255
+ return name === "apply_patch_call" || name === "apply_patch" || name.endsWith("__apply_patch");
239
256
  }
240
257
 
241
258
  /* --- secret redaction ------------------------------------------------------- */
@@ -6,6 +6,7 @@ import {
6
6
  isCreditExhaustion,
7
7
  tryParseJson,
8
8
  } from "../lib/format";
9
+ import { mcpToolLeaf, toolMatchesLeaf } from "./tool-display-name";
9
10
  import type {
10
11
  AgentMessageItem,
11
12
  ActivityItem,
@@ -22,12 +23,8 @@ import type {
22
23
  ToolCallItem,
23
24
  WorkerItem,
24
25
  } from "./types";
25
- /** Readable label for a tool call, without leaking an MCP server prefix. */
26
- export function toolDisplayName(name: string): string {
27
- const boundary = name.indexOf("__");
28
- const toolPart = boundary >= 0 ? name.slice(boundary + 2) : name;
29
- return toolPart.replace(/[_-]+/g, " ").trim();
30
- }
26
+
27
+ export { toolDisplayName, mcpToolLeaf, toolMatchesLeaf } from "./tool-display-name";
31
28
 
32
29
  /* ----------------------------------------------------------------------------
33
30
  Timeline projection
@@ -43,10 +40,23 @@ export function toolDisplayName(name: string): string {
43
40
  memoized, unit-tested, and re-run incrementally as new events stream in.
44
41
  -------------------------------------------------------------------------- */
45
42
 
46
- /** Tool names on the first-party OpenGeni MCP server that operate on sessions. */
43
+ /** Tool leaves on the first-party OpenGeni MCP server that operate on sessions. */
47
44
  const WORKER_SPAWN_TOOL = "session_create";
48
45
  const WORKER_MESSAGE_TOOL = "session_send_message";
49
46
 
47
+ /**
48
+ * Tools whose durable side-effect events already own the timeline (MemoryRow).
49
+ * Emitting a generic tool-call too is double chrome — skip the call.
50
+ *
51
+ * Goal tools are intentionally NOT landmark-only: an agent `goal_set` /
52
+ * `goal_update` / `goal_complete` / `goal_pause` stays an in-cluster tool row,
53
+ * and the matching `goal.*` session event is suppressed below when `actor` is
54
+ * `"agent"`. That keeps mid-turn goal tools from splitting the step rail with
55
+ * a breakaway GoalRow pill. Non-agent goal events (API, create-session,
56
+ * system auto-pause, continuations) still render as landmarks.
57
+ */
58
+ const LANDMARK_ONLY_TOOL_LEAVES = new Set(["memory_save", "memory_correct"]);
59
+
50
60
  export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
51
61
  const items: TimelineItem[] = [];
52
62
  const prescan = prescanTurnAnchors(events);
@@ -186,6 +196,9 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
186
196
  open.text = text || open.text;
187
197
  }
188
198
  open.streaming = false;
199
+ // Completion time is what the footer shows ("finished at"); keep the
200
+ // first-delta stamp only until this event arrives.
201
+ open.occurredAt = event.occurredAt;
189
202
  // The SDK can emit a hosted-tool item only after its provider-native
190
203
  // operation has completed, even though answer deltas were already
191
204
  // streamed. The completed message event is the durable ordering
@@ -237,13 +250,16 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
237
250
  const callId = typeof payload.id === "string" ? payload.id : null;
238
251
  const args = payload.arguments ?? null;
239
252
  closeStreamingTail();
240
- if (name === WORKER_SPAWN_TOOL || name === WORKER_MESSAGE_TOOL) {
253
+ if (
254
+ toolMatchesLeaf(name, WORKER_SPAWN_TOOL) ||
255
+ toolMatchesLeaf(name, WORKER_MESSAGE_TOOL)
256
+ ) {
241
257
  items.push({
242
258
  kind: "worker",
243
259
  id: event.id,
244
260
  turnId,
245
261
  callId,
246
- action: name === WORKER_SPAWN_TOOL ? "spawn" : "message",
262
+ action: toolMatchesLeaf(name, WORKER_SPAWN_TOOL) ? "spawn" : "message",
247
263
  prompt: workerPrompt(args),
248
264
  workerSessionId: extractSessionRef(args),
249
265
  status: "running",
@@ -251,6 +267,10 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
251
267
  });
252
268
  break;
253
269
  }
270
+ if (LANDMARK_ONLY_TOOL_LEAVES.has(mcpToolLeaf(name))) {
271
+ // Goal/memory landmarks arrive as goal.* / memory.* events.
272
+ break;
273
+ }
254
274
  // Live Responses `web_search_call` events and the later SDK
255
275
  // `RunToolCallItem` share the same item id. Merge so mid-stream cards
256
276
  // do not duplicate when the step finally materializes.
@@ -639,6 +659,11 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
639
659
  case "goal.resumed":
640
660
  case "goal.cleared":
641
661
  case "goal.continuation": {
662
+ // Agent tool mutations already appear as tool-call rows in the activity
663
+ // cluster. Re-emitting them as GoalRow landmarks splits "N steps" mid-turn.
664
+ if (shouldSuppressAgentGoalLandmark(event.type, payload)) {
665
+ break;
666
+ }
642
667
  items.push({
643
668
  kind: "goal",
644
669
  id: event.id,
@@ -1423,6 +1448,22 @@ function goalText(payload: Record<string, unknown>): string | null {
1423
1448
  return null;
1424
1449
  }
1425
1450
 
1451
+ /**
1452
+ * Agent-owned goal mutations already have an in-cluster tool row. Suppress the
1453
+ * breakaway landmark for those only. `goal.completed` has no actor field today
1454
+ * and is only emitted by the agent tool, so it is always suppressed. API /
1455
+ * system / create-session / continuation landmarks stay visible.
1456
+ */
1457
+ function shouldSuppressAgentGoalLandmark(type: string, payload: Record<string, unknown>): boolean {
1458
+ if (type === "goal.completed") {
1459
+ return true;
1460
+ }
1461
+ if (type === "goal.set" || type === "goal.updated" || type === "goal.paused") {
1462
+ return payload.actor === "agent";
1463
+ }
1464
+ return false;
1465
+ }
1466
+
1426
1467
  /**
1427
1468
  * Fold a `memory.saved` / `memory.corrected` event into a {@link MemoryItem}.
1428
1469
  * Reads DEFENSIVELY (the payload is untyped `unknown`, no Zod schema): a missing
@@ -1466,6 +1507,7 @@ const AUTH_NEEDED_REASONS: ReadonlySet<string> = new Set([
1466
1507
  "expired",
1467
1508
  "insufficient_scope",
1468
1509
  "refresh_failed",
1510
+ "personal_authority_unavailable",
1469
1511
  "unsupported_auth",
1470
1512
  "resource_scope_unavailable",
1471
1513
  ]);
@@ -1563,14 +1605,3 @@ export function extractSessionRef(value: unknown, depth = 0): string | null {
1563
1605
  function looksLikeId(value: string): boolean {
1564
1606
  return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
1565
1607
  }
1566
-
1567
- /**
1568
- * Readable label for a tool call ("session_create" -> "session create").
1569
- *
1570
- * MCP tools are namespaced `<serverId>__<toolName>` (see prefixedMcpToolName),
1571
- * and for catalog-imported servers that serverId is an opaque slug+hash
1572
- * ("mcp-integrations-sh-supabase-com-34ed9dcf1390-0i6tcf8"). De-slugging the
1573
- * whole thing leaked that id into the timeline; strip the server prefix and show
1574
- * just the tool ("list organizations"). Names without the `__` boundary (plain
1575
- * built-ins like "session_create") are unaffected.
1576
- */
@@ -1,4 +1,5 @@
1
1
  import type { ComponentType } from "react";
2
+ import { mcpToolLeaf } from "./tool-display-name";
2
3
  import type { ToolCallItem } from "./types";
3
4
 
4
5
  /* ----------------------------------------------------------------------------
@@ -11,7 +12,8 @@ import type { ToolCallItem } from "./types";
11
12
  Resolution order (most → least specific):
12
13
  1. exact match on `raw.type` (e.g. "apply_patch_call", "computer_call")
13
14
  2. exact match on the tool `name` (e.g. "exec_command", "web_search_call")
14
- 3. the registry's generic fallback
15
+ 3. leaf match after MCP `__` prefix (e.g. opengeni__environment_set_variable)
16
+ 4. the registry's generic fallback
15
17
 
16
18
  A consumer extends the defaults without forking by passing overrides to
17
19
  `createToolRegistry` — e.g. a custom renderer for their own MCP tool, or a
@@ -89,7 +91,18 @@ export function createToolRegistry(
89
91
  return byType;
90
92
  }
91
93
  }
92
- return byName.get(item.name) ?? fallback;
94
+ const exact = byName.get(item.name);
95
+ if (exact) {
96
+ return exact;
97
+ }
98
+ const leaf = mcpToolLeaf(item.name);
99
+ if (leaf !== item.name) {
100
+ const byLeaf = byName.get(leaf);
101
+ if (byLeaf) {
102
+ return byLeaf;
103
+ }
104
+ }
105
+ return fallback;
93
106
  };
94
107
 
95
108
  return { resolve, fallback };
@@ -1,6 +1,5 @@
1
1
  import { CameraIcon, CameraOffIcon, ChevronRightIcon } from "lucide-react";
2
2
  import { useState, type ReactNode } from "react";
3
- import { Collapsible } from "radix-ui";
4
3
  import { cn } from "../lib/cn";
5
4
  import { stringifyPayload } from "../lib/format";
6
5
  import { useForcedDefaultOpen } from "./disclosure-context";
@@ -207,35 +206,38 @@ export function ActivityDisclosure({
207
206
  );
208
207
  }
209
208
 
209
+ // Native disclosure (not radix-ui Collapsible): importing `radix-ui` from the
210
+ // timeline registry pulls Popper into the session share-graph and crashes
211
+ // lazy workspace settings (`createPopperScope is not a function`).
210
212
  return (
211
- <Collapsible.Root open={open} onOpenChange={setOpen}>
212
- <Collapsible.Trigger asChild>
213
- <div
214
- role="button"
215
- tabIndex={0}
216
- // Space/Enter activate a native button; Radix doesn't add this for a
217
- // non-button asChild child, so we toggle here. preventDefault on Space
218
- // stops the page from scrolling.
219
- onKeyDown={(event) => {
220
- if (event.key === "Enter" || event.key === " ") {
221
- event.preventDefault();
222
- setOpen((prev) => !prev);
223
- }
224
- }}
225
- data-status={dataStatus}
226
- className={cn(
227
- rowClass,
228
- "cursor-pointer outline-none hover:bg-og-surface-1 hover:text-og-fg",
229
- "focus-visible:ring-2 focus-visible:ring-og-accent focus-visible:ring-offset-0",
230
- )}
231
- >
232
- {inner}
213
+ <div>
214
+ <div
215
+ role="button"
216
+ tabIndex={0}
217
+ aria-expanded={open}
218
+ data-state={open ? "open" : "closed"}
219
+ onClick={() => setOpen((prev) => !prev)}
220
+ onKeyDown={(event) => {
221
+ if (event.key === "Enter" || event.key === " ") {
222
+ event.preventDefault();
223
+ setOpen((prev) => !prev);
224
+ }
225
+ }}
226
+ data-status={dataStatus}
227
+ className={cn(
228
+ rowClass,
229
+ "cursor-pointer outline-none hover:bg-og-surface-1 hover:text-og-fg",
230
+ "focus-visible:ring-2 focus-visible:ring-og-accent focus-visible:ring-offset-0",
231
+ )}
232
+ >
233
+ {inner}
234
+ </div>
235
+ {open ? (
236
+ <div className="mb-2 ml-7 mt-1.5 flex flex-col gap-2 overflow-hidden animate-og-expand">
237
+ {children}
233
238
  </div>
234
- </Collapsible.Trigger>
235
- <Collapsible.Content className="overflow-hidden data-[state=closed]:animate-og-collapse data-[state=open]:animate-og-expand">
236
- <div className="mb-2 ml-7 mt-1.5 flex flex-col gap-2">{children}</div>
237
- </Collapsible.Content>
238
- </Collapsible.Root>
239
+ ) : null}
240
+ </div>
239
241
  );
240
242
  }
241
243
 
@@ -1,16 +1,35 @@
1
1
  /**
2
- * Readable label for a tool call ("session_create" -> "session create").
2
+ * MCP / first-party tool naming helpers.
3
3
  *
4
- * MCP tools are namespaced `<serverId>__<toolName>` (see prefixedMcpToolName),
5
- * and for catalog-imported servers that serverId is an opaque slug+hash.
6
- * De-slugging the whole thing leaked that id into the timeline; strip the
7
- * server prefix and show just the tool. Names without the `__` boundary (plain
8
- * built-ins like "session_create") are unaffected.
4
+ * Wire names are often `<serverId>__<toolName>` (see prefixedMcpToolName).
5
+ * Matching and titles must use the leaf tool name so `opengeni__session_create`
6
+ * and bare `session_create` resolve the same UI without inventing previews
7
+ * from argument JSON.
9
8
  */
10
- export function toolDisplayName(name: string): string {
11
- // The prefix is a single LEFT boundary (`registryId__toolName`), so split on
12
- // the FIRST `__` — the tool name itself may contain `__` and must survive whole.
9
+
10
+ /** Leaf tool name after the first `__` server boundary (or the whole name). */
11
+ export function mcpToolLeaf(name: string): string {
13
12
  const boundary = name.indexOf("__");
14
- const toolPart = boundary >= 0 ? name.slice(boundary + 2) : name;
15
- return toolPart.replace(/[_-]+/g, " ").trim();
13
+ return boundary >= 0 ? name.slice(boundary + 2) : name;
14
+ }
15
+
16
+ /**
17
+ * True when `wireName` is exactly `leaf` or ends with `__${leaf}` (MCP prefix).
18
+ * Does not treat arbitrary suffixes as matches — the leaf must be the full
19
+ * right-hand side after `__`.
20
+ */
21
+ export function toolMatchesLeaf(wireName: string, leaf: string): boolean {
22
+ return wireName === leaf || wireName.endsWith(`__${leaf}`);
23
+ }
24
+
25
+ /**
26
+ * Readable label for a tool call ("session_create" / "opengeni__session_create"
27
+ * → "Session create"). Title-cases the first character of the leaf phrase.
28
+ */
29
+ export function toolDisplayName(name: string): string {
30
+ const phrase = mcpToolLeaf(name).replace(/[_-]+/g, " ").trim();
31
+ if (!phrase) {
32
+ return name;
33
+ }
34
+ return phrase.charAt(0).toUpperCase() + phrase.slice(1);
16
35
  }