@guuey/chat 0.8.0 → 0.9.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.
@@ -71,6 +71,7 @@ import type { ThemeMode } from "./theme-css.js";
71
71
  import { Transcript, type TranscriptWindowing } from "./transcript.js";
72
72
  import type { TranscriptComponents, TranscriptItemContext } from "./components.js";
73
73
  import { useTranscript, useTranscriptInputs } from "./use-transcript.js";
74
+ import { oauthPromptAction, useOAuthReturn } from "./oauth-return.js";
74
75
 
75
76
  /**
76
77
  * The imperative seam (guuey#210): programmatic send/prefill/focus for
@@ -177,6 +178,20 @@ export interface GuueyChatProps {
177
178
  * transport). Fired after the transcript record moves.
178
179
  */
179
180
  onHitlAnswer?: (answer: AgHitlAnswer, ask: AgPausedAsk) => void;
181
+ /**
182
+ * The OAuth "authorize this server" arm (guuey#178): where the broker
183
+ * sends the user back after the provider dance. Defaults to this page's
184
+ * own location (stale return params stripped). Pass a value when the
185
+ * chat lives at a URL that is not where the user should land.
186
+ */
187
+ oauthReturnTo?: string;
188
+ /**
189
+ * How the authorize link is opened. Defaults to `openOAuthAuthorize`
190
+ * (in-place navigation; a new tab when framed). The composite ALSO shows
191
+ * the return notice itself (`useOAuthReturn`) — a host that overrides
192
+ * `open` and lands elsewhere renders its own.
193
+ */
194
+ onOAuthAuthorize?: (href: string, ask: AgPausedAsk) => void;
180
195
  /** R11 action slot (sign-in / retry affordances). */
181
196
  onErrorAction?: (item: ErrorItem) => void;
182
197
  /**
@@ -226,6 +241,8 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
226
241
  viewProps,
227
242
  onPromptAction,
228
243
  onHitlAnswer,
244
+ oauthReturnTo,
245
+ onOAuthAuthorize,
229
246
  onErrorAction,
230
247
  onReady,
231
248
  onThread,
@@ -388,9 +405,27 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
388
405
  [invoke],
389
406
  );
390
407
 
408
+ // guuey#178: the broker's return notice (`?connected=` / `?error=`), read
409
+ // + stripped off the address bar once on mount.
410
+ const oauthReturn = useOAuthReturn();
411
+
391
412
  const handlePromptAction = useCallback(
392
413
  (item: PromptItem, action: "accept" | "decline" | "dismiss" | { grantModeId: string }) => {
393
414
  if (item.promptKind === "hitl") {
415
+ // The OAuth arm: the answer is the redirect (no pod door, no
416
+ // `onHitlAnswer`); the ledger records the pick and the link opens.
417
+ if (
418
+ oauthPromptAction({
419
+ item,
420
+ action,
421
+ answerHitlPrompt,
422
+ ...(oauthReturnTo !== undefined ? { returnTo: oauthReturnTo } : {}),
423
+ ...(onOAuthAuthorize !== undefined ? { open: onOAuthAuthorize } : {}),
424
+ })
425
+ ) {
426
+ onPromptAction?.(item, action);
427
+ return;
428
+ }
394
429
  const answer = answerHitlPrompt(
395
430
  item.ask,
396
431
  typeof action === "object" ? action : action === "accept" ? "accept" : action,
@@ -404,7 +439,7 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
404
439
  if (typeof action !== "object") resolvePrompt(item.promptId, PROMPT_STATE[action]);
405
440
  onPromptAction?.(item, action);
406
441
  },
407
- [resolvePrompt, answerHitlPrompt, onHitlAnswer, onPromptAction],
442
+ [resolvePrompt, answerHitlPrompt, onHitlAnswer, onPromptAction, oauthReturnTo, onOAuthAuthorize],
408
443
  );
409
444
 
410
445
  const strings = policy.strings;
@@ -430,6 +465,19 @@ export const GuueyChat = forwardRef<GuueyChatHandle, GuueyChatProps>(function Gu
430
465
  onViewDiagnosis={onViewDiagnosis}
431
466
  {...(viewProps !== undefined ? { viewProps } : {})}
432
467
  />
468
+ {oauthReturn.notice !== null && (
469
+ <p
470
+ role="status"
471
+ className={`guuey-chat-oauth-notice guuey-chat-oauth-${oauthReturn.notice.kind}`}
472
+ >
473
+ {oauthReturn.notice.kind === "connected"
474
+ ? strings.oauthConnected(oauthReturn.notice.serverName)
475
+ : strings.oauthFailed(oauthReturn.notice.reason)}
476
+ <button type="button" className="guuey-chat-oauth-dismiss" onClick={oauthReturn.dismiss}>
477
+ {strings.promptDismissed}
478
+ </button>
479
+ </p>
480
+ )}
433
481
  <form
434
482
  className="guuey-chat-composer"
435
483
  onSubmit={(e) => {
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The web half of the OAuth "authorize this server" arm (guuey#178 Slice 4)
3
+ * — the DOM-touching pieces the pure `../oauth.js` helpers deliberately
4
+ * leave out. Every web surface (the `<GuueyChat>` composite, the widget, the
5
+ * studio agent page) shares these three so the redirect + return behave the
6
+ * same everywhere:
7
+ *
8
+ * - {@link oauthReturnToHere} — the surface's own location, with a stale
9
+ * `?connected=`/`?error=` from a PREVIOUS dance stripped, ready to be
10
+ * the next `returnTo`.
11
+ * - {@link openOAuthAuthorize} — how the link is opened: a top-level page
12
+ * navigates in place (the broker 302s straight back to `returnTo`); a
13
+ * FRAMED page (the widget inside a customer origin) opens a new tab,
14
+ * because identity providers refuse to render inside a third-party
15
+ * frame (`X-Frame-Options` / `frame-ancestors`).
16
+ * - {@link useOAuthReturn} — on mount, read the broker's return params off
17
+ * the address bar, REPLACE the URL without them (a reload never re-shows
18
+ * the notice), and hand the surface a one-shot notice to render.
19
+ */
20
+ import { useCallback, useEffect, useState } from "react";
21
+ import type { AgHitlAnswer, AgPausedAsk } from "@silverprotocol/core";
22
+ import type { HitlPromptAction } from "../hitl.js";
23
+ import { oauthAuthorizeHref, parseOAuthReturn, stripOAuthReturn, type OAuthReturn } from "../oauth.js";
24
+ import type { HitlPromptItem } from "../types.js";
25
+
26
+ /** The surface's own location as the next `returnTo` (stale return params stripped). */
27
+ export function oauthReturnToHere(): string {
28
+ return stripOAuthReturn(window.location.href);
29
+ }
30
+
31
+ /** The slice of `window` the opener touches (injectable — jsdom's `location.assign` cannot be spied). */
32
+ export interface OAuthWindow {
33
+ self: object;
34
+ top: object | null;
35
+ open: (url: string, target: string, features: string) => unknown;
36
+ location: { assign: (url: string) => void };
37
+ }
38
+
39
+ /** Whether this document is rendered inside another origin's frame. */
40
+ function isFramed(win: OAuthWindow): boolean {
41
+ try {
42
+ return win.top !== win.self;
43
+ } catch {
44
+ // A cross-origin `window.top` read throws in some browsers — that IS framed.
45
+ return true;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Open the authorize link. In place when top-level (the dance ends back on
51
+ * this page); a new tab when framed (the IdP will not render in a frame —
52
+ * `returnTo` then lands the SAME page top-level in that tab, where
53
+ * {@link useOAuthReturn} shows the notice; the embedded chat picks the
54
+ * connection up on its next turn).
55
+ */
56
+ export function openOAuthAuthorize(href: string, win: OAuthWindow = window): void {
57
+ if (isFramed(win)) {
58
+ win.open(href, "_blank", "noopener,noreferrer");
59
+ return;
60
+ }
61
+ win.location.assign(href);
62
+ }
63
+
64
+ export interface UseOAuthReturnResult {
65
+ /** The broker's answer found on the address bar at mount, until dismissed. */
66
+ notice: OAuthReturn | null;
67
+ dismiss: () => void;
68
+ }
69
+
70
+ /**
71
+ * Read `?connected=<serverName>` / `?error=<reason>` off the current
72
+ * location ONCE (on mount), strip them from the address bar via
73
+ * `history.replaceState`, and expose the result as a dismissible notice.
74
+ * SSR-safe: nothing runs until the effect.
75
+ */
76
+ export function useOAuthReturn(): UseOAuthReturnResult {
77
+ const [notice, setNotice] = useState<OAuthReturn | null>(null);
78
+ useEffect(() => {
79
+ if (typeof window === "undefined") return;
80
+ const href = window.location.href;
81
+ const found = parseOAuthReturn(href);
82
+ if (found === null) return;
83
+ const clean = stripOAuthReturn(href);
84
+ if (clean !== href) window.history.replaceState(window.history.state, "", clean);
85
+ setNotice(found);
86
+ }, []);
87
+ const dismiss = useCallback(() => setNotice(null), []);
88
+ return { notice, dismiss };
89
+ }
90
+
91
+ export interface OAuthPromptActionArgs {
92
+ item: HitlPromptItem;
93
+ action: HitlPromptAction;
94
+ /** The kit's ledger (`useTranscriptInputs().answerHitlPrompt`) — records the pick locally; nothing is delivered. */
95
+ answerHitlPrompt: (ask: AgPausedAsk, action: HitlPromptAction) => AgHitlAnswer;
96
+ /** Where the broker should send the user back; defaults to {@link oauthReturnToHere}. */
97
+ returnTo?: string;
98
+ /** How to open the link; defaults to {@link openOAuthAuthorize}. */
99
+ open?: (href: string, ask: AgPausedAsk) => void;
100
+ }
101
+
102
+ /**
103
+ * The OAuth arm of a prompt action, shared by every web surface. Returns
104
+ * `false` (nothing done) when the item is not an OAuth ask, so a host's
105
+ * #207 hitl branch (build the answer, POST it to the pod door) runs as
106
+ * before. For an OAuth ask:
107
+ *
108
+ * - a mode pick (or a plain accept on a mode-less ask) records the pick in
109
+ * the ledger — the card shows "Connecting — <mode>" — and OPENS
110
+ * `authorizationUrl&mode=<id>&returnTo=<here>`; NOTHING is posted to the
111
+ * pod (there is no answer door — the answer is the redirect);
112
+ * - "Not now" / decline / dismiss all record `cancelled` (still pending,
113
+ * re-askable): nothing is written anywhere and the pod asks again next
114
+ * turn.
115
+ */
116
+ export function oauthPromptAction(args: OAuthPromptActionArgs): boolean {
117
+ const { item, action, answerHitlPrompt } = args;
118
+ if (item.oauth === null) return false;
119
+ if (typeof action === "object" || action === "accept") {
120
+ const grantModeId = typeof action === "object" ? action.grantModeId : null;
121
+ answerHitlPrompt(item.ask, action);
122
+ const href = oauthAuthorizeHref(item.ask, grantModeId, args.returnTo ?? oauthReturnToHere());
123
+ if (args.open !== undefined) args.open(href, item.ask);
124
+ else openOAuthAuthorize(href);
125
+ return true;
126
+ }
127
+ answerHitlPrompt(item.ask, "dismiss");
128
+ return true;
129
+ }
package/src/react.tsx CHANGED
@@ -49,5 +49,14 @@ export {
49
49
  type UseTranscriptInputsResult,
50
50
  } from "./react/use-transcript.js";
51
51
  export { GuueyChat, type GuueyChatProps, type GuueyChatHandle } from "./react/guuey-chat.js";
52
+ export {
53
+ oauthPromptAction,
54
+ oauthReturnToHere,
55
+ openOAuthAuthorize,
56
+ useOAuthReturn,
57
+ type OAuthPromptActionArgs,
58
+ type OAuthWindow,
59
+ type UseOAuthReturnResult,
60
+ } from "./react/oauth-return.js";
52
61
  export { Markdown } from "./react/markdown.js";
53
62
  export { themeCssVars, type ThemeMode } from "./react/theme-css.js";
package/src/strings.ts CHANGED
@@ -96,6 +96,16 @@ export interface ChatStrings {
96
96
  /** The answered record line, e.g. `Allowed — Always`. */
97
97
  promptAnsweredWith: (modeLabel: string) => string;
98
98
  promptDeclinedRecord: string;
99
+ /**
100
+ * The OAuth arm (guuey#178): the dismiss action ("Not now" — nothing is
101
+ * written, the ask re-emits next turn), the answered record (the user was
102
+ * sent to the provider), and the return notices the surface shows after
103
+ * the broker 302s back with `?connected=<serverName>` / `?error=<reason>`.
104
+ */
105
+ promptNotNow: string;
106
+ promptOAuthSent: (modeLabel: string) => string;
107
+ oauthConnected: (serverName: string) => string;
108
+ oauthFailed: (reason: string) => string;
99
109
 
100
110
  /** R16 — the notice row's label (provenance shows only under debug). */
101
111
  noticeLabel: string;
@@ -171,6 +181,10 @@ export const defaultChatStrings: ChatStrings = {
171
181
  promptDismissed: "Dismissed",
172
182
  promptAnsweredWith: (modeLabel) => `Allowed — ${modeLabel}`,
173
183
  promptDeclinedRecord: "Not allowed",
184
+ promptNotNow: "Not now",
185
+ promptOAuthSent: (modeLabel) => `Connecting — ${modeLabel}`,
186
+ oauthConnected: (serverName) => `Connected ${serverName}. The agent can use it from your next message.`,
187
+ oauthFailed: (reason) => `Couldn't connect: ${reason}`,
174
188
 
175
189
  noticeLabel: "Note",
176
190
 
package/src/types.ts CHANGED
@@ -355,6 +355,14 @@ export interface HitlPromptItem extends BaseItem {
355
355
  askKind: AgPausedAsk["kind"];
356
356
  /** Declared accept variants; empty = a plain accept/decline ask. */
357
357
  grantModes: readonly AgGrantMode[];
358
+ /**
359
+ * The OAuth arm (guuey#178): set when the ask is `kind:"auth"` with
360
+ * `authConfig.scheme:"oauth2"` + an `authorizationUrl`. There is no
361
+ * answer door for this ask — a mode pick OPENS `authorizationUrl` with
362
+ * `&mode=` + `&returnTo=` appended (`oauthAuthorizeHref`); "Not now" is a
363
+ * plain dismissal. `null` for every other ask.
364
+ */
365
+ oauth: { authorizationUrl: string; scopes: readonly string[] } | null;
358
366
  state: "pending" | "resolved" | "declined" | "cancelled";
359
367
  /** Echo of the chosen mode id (identity, never displayed as meaning). */
360
368
  chosenModeId: string | null;
package/styles.css CHANGED
@@ -349,6 +349,32 @@
349
349
  margin: 0;
350
350
  }
351
351
 
352
+ /* The OAuth "authorize this server" return notice (guuey#178) — shown by
353
+ <GuueyChat> after the broker sends the user back with ?connected= / ?error=. */
354
+ .guuey-chat-oauth-notice {
355
+ display: flex;
356
+ align-items: center;
357
+ justify-content: space-between;
358
+ gap: 8px;
359
+ margin: 0;
360
+ padding: 8px 12px;
361
+ font-size: 0.85em;
362
+ color: var(--guuey-chat-ink);
363
+ background: color-mix(in srgb, var(--guuey-chat-accent) 10%, var(--guuey-chat-surface));
364
+ border-top: 1px solid var(--guuey-chat-canvas-muted);
365
+ }
366
+ .guuey-chat-oauth-error {
367
+ background: color-mix(in srgb, var(--guuey-chat-error) 7%, var(--guuey-chat-surface));
368
+ }
369
+ .guuey-chat-oauth-dismiss {
370
+ font: inherit;
371
+ cursor: pointer;
372
+ border: 0;
373
+ background: transparent;
374
+ color: var(--guuey-chat-ink-muted);
375
+ text-decoration: underline;
376
+ }
377
+
352
378
  /* ── R11 errors ── */
353
379
  .guuey-chat-error {
354
380
  border: 1px solid color-mix(in srgb, var(--guuey-chat-error) 45%, transparent);