@stigmer/react 0.2.2 → 0.3.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.
Files changed (178) hide show
  1. package/composer/ComposerToolbar.d.ts +5 -1
  2. package/composer/ComposerToolbar.d.ts.map +1 -1
  3. package/composer/ComposerToolbar.js +6 -3
  4. package/composer/ComposerToolbar.js.map +1 -1
  5. package/composer/SessionComposer.d.ts +17 -1
  6. package/composer/SessionComposer.d.ts.map +1 -1
  7. package/composer/SessionComposer.js +32 -35
  8. package/composer/SessionComposer.js.map +1 -1
  9. package/execution/MessageEntry.d.ts +3 -1
  10. package/execution/MessageEntry.d.ts.map +1 -1
  11. package/execution/MessageEntry.js +30 -1
  12. package/execution/MessageEntry.js.map +1 -1
  13. package/github/index.d.ts +1 -1
  14. package/github/index.d.ts.map +1 -1
  15. package/github/index.js.map +1 -1
  16. package/github/useGitHubConnection.d.ts +70 -1
  17. package/github/useGitHubConnection.d.ts.map +1 -1
  18. package/github/useGitHubConnection.js +99 -20
  19. package/github/useGitHubConnection.js.map +1 -1
  20. package/identity-provider/IdentityProviderWizard.d.ts.map +1 -1
  21. package/identity-provider/IdentityProviderWizard.js +19 -3
  22. package/identity-provider/IdentityProviderWizard.js.map +1 -1
  23. package/index.d.ts +4 -4
  24. package/index.d.ts.map +1 -1
  25. package/index.js +2 -2
  26. package/index.js.map +1 -1
  27. package/models/HarnessSelector.d.ts +41 -0
  28. package/models/HarnessSelector.d.ts.map +1 -0
  29. package/models/HarnessSelector.js +74 -0
  30. package/models/HarnessSelector.js.map +1 -0
  31. package/models/ModelSelector.d.ts +26 -16
  32. package/models/ModelSelector.d.ts.map +1 -1
  33. package/models/ModelSelector.js +128 -48
  34. package/models/ModelSelector.js.map +1 -1
  35. package/models/__tests__/HarnessSelector.test.d.ts +2 -0
  36. package/models/__tests__/HarnessSelector.test.d.ts.map +1 -0
  37. package/models/__tests__/HarnessSelector.test.js +160 -0
  38. package/models/__tests__/HarnessSelector.test.js.map +1 -0
  39. package/models/__tests__/harness.test.d.ts +2 -0
  40. package/models/__tests__/harness.test.d.ts.map +1 -0
  41. package/models/__tests__/harness.test.js +50 -0
  42. package/models/__tests__/harness.test.js.map +1 -0
  43. package/models/__tests__/useModelRegistry.test.d.ts +2 -0
  44. package/models/__tests__/useModelRegistry.test.d.ts.map +1 -0
  45. package/models/__tests__/useModelRegistry.test.js +148 -0
  46. package/models/__tests__/useModelRegistry.test.js.map +1 -0
  47. package/models/harness.d.ts +21 -0
  48. package/models/harness.d.ts.map +1 -0
  49. package/models/harness.js +34 -0
  50. package/models/harness.js.map +1 -0
  51. package/models/index.d.ts +7 -2
  52. package/models/index.d.ts.map +1 -1
  53. package/models/index.js +3 -1
  54. package/models/index.js.map +1 -1
  55. package/models/registry.d.ts +53 -13
  56. package/models/registry.d.ts.map +1 -1
  57. package/models/registry.js +51 -40
  58. package/models/registry.js.map +1 -1
  59. package/models/useModelRegistry.d.ts +39 -19
  60. package/models/useModelRegistry.d.ts.map +1 -1
  61. package/models/useModelRegistry.js +45 -23
  62. package/models/useModelRegistry.js.map +1 -1
  63. package/organization/OrgProfilePanel.d.ts.map +1 -1
  64. package/organization/OrgProfilePanel.js +23 -2
  65. package/organization/OrgProfilePanel.js.map +1 -1
  66. package/package.json +4 -4
  67. package/runner/RunnerFileBrowser.d.ts +11 -1
  68. package/runner/RunnerFileBrowser.d.ts.map +1 -1
  69. package/runner/RunnerFileBrowser.js +70 -7
  70. package/runner/RunnerFileBrowser.js.map +1 -1
  71. package/runner/RunnerListPanel.js +2 -1
  72. package/runner/RunnerListPanel.js.map +1 -1
  73. package/runner/WorkspaceRunnerSelector.d.ts +36 -0
  74. package/runner/WorkspaceRunnerSelector.d.ts.map +1 -0
  75. package/runner/WorkspaceRunnerSelector.js +63 -0
  76. package/runner/WorkspaceRunnerSelector.js.map +1 -0
  77. package/runner/__tests__/phase.test.js +6 -2
  78. package/runner/__tests__/phase.test.js.map +1 -1
  79. package/runner/index.d.ts +2 -0
  80. package/runner/index.d.ts.map +1 -1
  81. package/runner/index.js +1 -0
  82. package/runner/index.js.map +1 -1
  83. package/runner/phase.d.ts +9 -7
  84. package/runner/phase.d.ts.map +1 -1
  85. package/runner/phase.js +18 -12
  86. package/runner/phase.js.map +1 -1
  87. package/runner/useRunnerFileBrowser.d.ts.map +1 -1
  88. package/runner/useRunnerFileBrowser.js +26 -2
  89. package/runner/useRunnerFileBrowser.js.map +1 -1
  90. package/session/__tests__/useCreateSession.test.d.ts +2 -0
  91. package/session/__tests__/useCreateSession.test.d.ts.map +1 -0
  92. package/session/__tests__/useCreateSession.test.js +232 -0
  93. package/session/__tests__/useCreateSession.test.js.map +1 -0
  94. package/session/__tests__/useNewSessionFlow.test.d.ts +2 -0
  95. package/session/__tests__/useNewSessionFlow.test.d.ts.map +1 -0
  96. package/session/__tests__/useNewSessionFlow.test.js +199 -0
  97. package/session/__tests__/useNewSessionFlow.test.js.map +1 -0
  98. package/session/__tests__/useSessionConversation.test.js +37 -0
  99. package/session/__tests__/useSessionConversation.test.js.map +1 -1
  100. package/session/index.d.ts +1 -1
  101. package/session/index.d.ts.map +1 -1
  102. package/session/useCreateSession.d.ts +8 -0
  103. package/session/useCreateSession.d.ts.map +1 -1
  104. package/session/useCreateSession.js +2 -0
  105. package/session/useCreateSession.js.map +1 -1
  106. package/session/useNewSessionFlow.d.ts +6 -1
  107. package/session/useNewSessionFlow.d.ts.map +1 -1
  108. package/session/useNewSessionFlow.js +34 -8
  109. package/session/useNewSessionFlow.js.map +1 -1
  110. package/session/usePersistedModel.d.ts +16 -1
  111. package/session/usePersistedModel.d.ts.map +1 -1
  112. package/session/usePersistedModel.js +15 -6
  113. package/session/usePersistedModel.js.map +1 -1
  114. package/session/useSessionConversation.d.ts.map +1 -1
  115. package/session/useSessionConversation.js +6 -1
  116. package/session/useSessionConversation.js.map +1 -1
  117. package/session/useSessionPageFlow.d.ts +11 -0
  118. package/session/useSessionPageFlow.d.ts.map +1 -1
  119. package/session/useSessionPageFlow.js +11 -2
  120. package/session/useSessionPageFlow.js.map +1 -1
  121. package/settings/MembersSection.d.ts.map +1 -1
  122. package/settings/MembersSection.js +7 -2
  123. package/settings/MembersSection.js.map +1 -1
  124. package/src/composer/ComposerToolbar.tsx +24 -1
  125. package/src/composer/SessionComposer.tsx +81 -44
  126. package/src/execution/MessageEntry.tsx +134 -1
  127. package/src/github/index.ts +1 -0
  128. package/src/github/useGitHubConnection.ts +162 -22
  129. package/src/identity-provider/IdentityProviderWizard.tsx +112 -3
  130. package/src/index.ts +16 -1
  131. package/src/models/HarnessSelector.tsx +130 -0
  132. package/src/models/ModelSelector.tsx +285 -81
  133. package/src/models/__tests__/HarnessSelector.test.tsx +190 -0
  134. package/src/models/__tests__/harness.test.ts +66 -0
  135. package/src/models/__tests__/useModelRegistry.test.tsx +209 -0
  136. package/src/models/harness.ts +45 -0
  137. package/src/models/index.ts +7 -2
  138. package/src/models/registry.ts +122 -50
  139. package/src/models/useModelRegistry.ts +74 -24
  140. package/src/organization/OrgProfilePanel.tsx +98 -0
  141. package/src/runner/RunnerFileBrowser.tsx +227 -8
  142. package/src/runner/RunnerListPanel.tsx +13 -5
  143. package/src/runner/WorkspaceRunnerSelector.tsx +180 -0
  144. package/src/runner/__tests__/phase.test.ts +6 -2
  145. package/src/runner/index.ts +3 -0
  146. package/src/runner/phase.ts +18 -12
  147. package/src/runner/useRunnerFileBrowser.ts +39 -3
  148. package/src/session/__tests__/useCreateSession.test.tsx +296 -0
  149. package/src/session/__tests__/useNewSessionFlow.test.tsx +258 -0
  150. package/src/session/__tests__/useSessionConversation.test.tsx +53 -0
  151. package/src/session/index.ts +1 -1
  152. package/src/session/useCreateSession.ts +9 -0
  153. package/src/session/useNewSessionFlow.ts +46 -9
  154. package/src/session/usePersistedModel.ts +30 -6
  155. package/src/session/useSessionConversation.ts +6 -1
  156. package/src/session/useSessionPageFlow.ts +26 -2
  157. package/src/settings/MembersSection.tsx +23 -1
  158. package/src/workspace/WorkspaceEditor.tsx +176 -126
  159. package/src/workspace/index.ts +5 -0
  160. package/src/workspace/useRecentWorkspaces.ts +162 -0
  161. package/src/workspace/useWorkspaceEntries.ts +13 -0
  162. package/styles.css +1 -1
  163. package/workspace/WorkspaceEditor.d.ts +25 -22
  164. package/workspace/WorkspaceEditor.d.ts.map +1 -1
  165. package/workspace/WorkspaceEditor.js +64 -43
  166. package/workspace/WorkspaceEditor.js.map +1 -1
  167. package/workspace/index.d.ts +2 -0
  168. package/workspace/index.d.ts.map +1 -1
  169. package/workspace/index.js +1 -0
  170. package/workspace/index.js.map +1 -1
  171. package/workspace/useRecentWorkspaces.d.ts +31 -0
  172. package/workspace/useRecentWorkspaces.d.ts.map +1 -0
  173. package/workspace/useRecentWorkspaces.js +117 -0
  174. package/workspace/useRecentWorkspaces.js.map +1 -0
  175. package/workspace/useWorkspaceEntries.d.ts +8 -0
  176. package/workspace/useWorkspaceEntries.d.ts.map +1 -1
  177. package/workspace/useWorkspaceEntries.js +4 -0
  178. package/workspace/useWorkspaceEntries.js.map +1 -1
@@ -6,6 +6,8 @@ import { getUserMessage, type AttachmentInput, type EnvVarInput, type McpServerU
6
6
  import { useComposer } from "./useComposer";
7
7
  import { ComposerToolbar } from "./ComposerToolbar";
8
8
  import { type ConfigureMenuItem } from "./ConfigureMenu";
9
+ import type { HarnessOption } from "../models/harness";
10
+ import { parseModelKey } from "../models/registry";
9
11
  import { ContextChip, type ChipItem } from "./ContextChip";
10
12
  import { WorkspaceEditor } from "../workspace/WorkspaceEditor";
11
13
  import { AgentPicker } from "../agent/AgentPicker";
@@ -21,6 +23,7 @@ import type { UseSessionVariablesReturn } from "../execution/useSessionVariables
21
23
  import type { UseWorkspaceEntriesReturn } from "../workspace/useWorkspaceEntries";
22
24
  import type { UseGitHubConnectionReturn } from "../github/useGitHubConnection";
23
25
  import { useRunnerList } from "../runner/useRunnerList";
26
+ import { WorkspaceRunnerSelector } from "../runner/WorkspaceRunnerSelector";
24
27
  import { useAttachments } from "../attachment/useAttachments";
25
28
  import { AttachmentChipList } from "../attachment/AttachmentChipList";
26
29
  import { useSessionEnvPool } from "../environment/useSessionEnvPool";
@@ -100,6 +103,22 @@ export interface SessionComposerProps {
100
103
  /** Disables the entire composer (e.g., while an execution streams). */
101
104
  readonly disabled?: boolean;
102
105
 
106
+ /**
107
+ * Currently selected execution harness.
108
+ *
109
+ * Controls which models appear in the model selector and flows
110
+ * through to session creation. When omitted, defaults to `"native"`.
111
+ */
112
+ readonly harness?: HarnessOption;
113
+ /**
114
+ * Called when the user switches the harness.
115
+ *
116
+ * Providing this callback enables the harness selector in the toolbar.
117
+ */
118
+ readonly onHarnessChange?: (harness: HarnessOption) => void;
119
+ /** Show the harness selector in the toolbar. @default false */
120
+ readonly showHarnessSelector?: boolean;
121
+
103
122
  /** Initial model ID for the model selector. */
104
123
  readonly defaultModelId?: string;
105
124
  /** Called when the user changes the selected model. */
@@ -356,6 +375,9 @@ export function SessionComposer({
356
375
  onSubmit,
357
376
  isSubmitting = false,
358
377
  disabled = false,
378
+ harness,
379
+ onHarnessChange,
380
+ showHarnessSelector = false,
359
381
  defaultModelId,
360
382
  onModelChange,
361
383
  showModelSelector = true,
@@ -411,20 +433,31 @@ export function SessionComposer({
411
433
  needsRunnerList ? (org ?? null) : null,
412
434
  );
413
435
 
414
- const selectedRunnerName = useMemo(() => {
436
+ const selectedRunner = useMemo(() => {
415
437
  if (!runnerId) return undefined;
416
- const runner = runnerListForBrowse.find((r) => r.metadata?.id === runnerId);
417
- return runner?.metadata?.name;
438
+ return runnerListForBrowse.find((r) => r.metadata?.id === runnerId);
418
439
  }, [runnerId, runnerListForBrowse]);
419
440
 
420
- const browseRunnerId = useMemo(() => {
421
- if (runnerId) return runnerId;
422
- if (!enableLocal) return null;
423
- const active = runnerListForBrowse.find((r) =>
424
- isActivePhase(r.status?.phase ?? RunnerPhase.UNSPECIFIED),
425
- );
426
- return active?.metadata?.id ?? null;
427
- }, [runnerId, enableLocal, runnerListForBrowse]);
441
+ const selectedRunnerName = selectedRunner?.metadata?.name;
442
+ const selectedRunnerHostname = selectedRunner?.status?.connectionInfo?.hostname;
443
+
444
+ const browseRunnerId = runnerId ?? null;
445
+
446
+ // ---------------------------------------------------------------------------
447
+ // Runner-switch safety — clear local workspace entries when runner changes
448
+ // ---------------------------------------------------------------------------
449
+
450
+ const prevRunnerIdRef = useRef(runnerId);
451
+
452
+ useEffect(() => {
453
+ if (prevRunnerIdRef.current !== runnerId) {
454
+ const hadLocal = workspace?.entries.some((e) => e.type === "local");
455
+ prevRunnerIdRef.current = runnerId;
456
+ if (hadLocal) {
457
+ workspace?.clearLocal();
458
+ }
459
+ }
460
+ }, [runnerId, workspace]);
428
461
 
429
462
  // ---------------------------------------------------------------------------
430
463
  // Configure menu state — drives the Tier 2 drill-down popover
@@ -600,7 +633,10 @@ export function SessionComposer({
600
633
  }
601
634
  : undefined;
602
635
 
603
- onSubmit(message, modelId, context);
636
+ const resolvedModelId = modelId
637
+ ? (parseModelKey(modelId)?.modelId ?? modelId)
638
+ : undefined;
639
+ onSubmit(message, resolvedModelId, context);
604
640
 
605
641
  if (enableAttachments) {
606
642
  attachments.clear();
@@ -622,6 +658,13 @@ export function SessionComposer({
622
658
  [onModelChange],
623
659
  );
624
660
 
661
+ const handleHarnessChange = useCallback(
662
+ (h: HarnessOption) => {
663
+ onHarnessChange?.(h);
664
+ },
665
+ [onHarnessChange],
666
+ );
667
+
625
668
  const handleDisplayNameResolved = useCallback(
626
669
  (key: string, name: string) => {
627
670
  setDisplayNames((prev) => {
@@ -883,17 +926,6 @@ export function SessionComposer({
883
926
  });
884
927
  }
885
928
 
886
- if (workspace) {
887
- for (const entry of workspace.entries) {
888
- items.push({
889
- key: `ws:${entry.id}`,
890
- label: entry.name,
891
- type: "workspace",
892
- onRemove: () => workspace.remove(entry.id),
893
- });
894
- }
895
- }
896
-
897
929
  if (showMcp) {
898
930
  for (const [key, entry] of Object.entries(mcpSetup.entries)) {
899
931
  const slug = key.slice(key.indexOf("/") + 1);
@@ -946,15 +978,6 @@ export function SessionComposer({
946
978
  }
947
979
  }
948
980
 
949
- if (showRunner && runnerId) {
950
- items.push({
951
- key: `runner:${runnerId}`,
952
- label: selectedRunnerName ?? "Runner",
953
- type: "runner",
954
- onRemove: () => onRunnerIdChange?.(null),
955
- });
956
- }
957
-
958
981
  if (sessionVariables) {
959
982
  for (const entry of sessionVariables.entries) {
960
983
  const k = entry.key.trim();
@@ -1057,7 +1080,7 @@ export function SessionComposer({
1057
1080
  count: skillCount,
1058
1081
  });
1059
1082
  }
1060
- if (showRunner) {
1083
+ if (showRunner && !showWorkspace) {
1061
1084
  items.push({
1062
1085
  id: "runner",
1063
1086
  icon: <RunnerIcon />,
@@ -1074,7 +1097,7 @@ export function SessionComposer({
1074
1097
  });
1075
1098
  }
1076
1099
  return items;
1077
- }, [showAgent, agentRef, agentSetup.state, showMcp, mcpCount, mcpSetup.needsSetupCount, showSkills, skillCount, showRunner, runnerId, showSessionVars, sessionVarCount]);
1100
+ }, [showAgent, agentRef, agentSetup.state, showMcp, mcpCount, mcpSetup.needsSetupCount, showSkills, skillCount, showRunner, showWorkspace, runnerId, showSessionVars, sessionVarCount]);
1078
1101
 
1079
1102
  const renderConfigPanel = useCallback(
1080
1103
  (panelId: string): React.ReactNode => {
@@ -1356,16 +1379,27 @@ export function SessionComposer({
1356
1379
  workspaceCount={workspaceCount}
1357
1380
  workspaceContent={
1358
1381
  workspace
1359
- ? <WorkspaceEditor
1360
- workspace={workspace}
1361
- disabled={isDisabled}
1362
- gitHubConnection={gitHubConnection}
1363
- enableGitHub={enableGitHub}
1364
- enableLocal={enableLocal}
1365
- runnerId={browseRunnerId}
1366
- onBrowseLocalFolder={onBrowseLocalFolder}
1367
- runnerName={selectedRunnerName}
1368
- />
1382
+ ? <div className="space-y-3">
1383
+ {showRunner && org && (
1384
+ <WorkspaceRunnerSelector
1385
+ org={org}
1386
+ value={runnerId ?? null}
1387
+ onChange={(id) => onRunnerIdChange?.(id)}
1388
+ disabled={isDisabled}
1389
+ />
1390
+ )}
1391
+ <WorkspaceEditor
1392
+ workspace={workspace}
1393
+ disabled={isDisabled}
1394
+ gitHubConnection={gitHubConnection}
1395
+ enableGitHub={enableGitHub}
1396
+ enableLocal={enableLocal}
1397
+ runnerId={browseRunnerId}
1398
+ onBrowseLocalFolder={runnerId ? onBrowseLocalFolder : undefined}
1399
+ runnerName={selectedRunnerName}
1400
+ runnerHostname={selectedRunnerHostname}
1401
+ />
1402
+ </div>
1369
1403
  : null
1370
1404
  }
1371
1405
  configureItems={configureItems}
@@ -1374,6 +1408,9 @@ export function SessionComposer({
1374
1408
  configActivePanel={configActivePanel}
1375
1409
  onConfigActivePanelChange={handleConfigActivePanelChange}
1376
1410
  renderConfigPanel={renderConfigPanel}
1411
+ showHarnessSelector={showHarnessSelector}
1412
+ harness={harness}
1413
+ onHarnessChange={handleHarnessChange}
1377
1414
  showModelSelector={showModelSelector}
1378
1415
  modelId={modelId}
1379
1416
  onModelChange={handleModelChange}
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { useState } from "react";
3
4
  import Markdown from "react-markdown";
4
5
  import type { AgentMessage } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
5
6
  import { MessageType } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
@@ -20,9 +21,11 @@ export interface MessageEntryProps {
20
21
  * - `MESSAGE_HUMAN` — plain text with muted background
21
22
  * - `MESSAGE_AI` — markdown-rendered via `react-markdown` + `remark-gfm`,
22
23
  * with a blinking cursor while streaming
24
+ * - `MESSAGE_THINKING` — collapsible thinking block with subdued styling,
25
+ * collapsed by default showing a brief summary
23
26
  * - `MESSAGE_SYSTEM` — small muted text
24
27
  * - `MESSAGE_TOOL` / `UNSPECIFIED` — renders nothing (tool results are
25
- * consumed by {@link ToolCallGroup} in SP4)
28
+ * consumed by {@link ToolCallGroup})
26
29
  *
27
30
  * Purely presentational — no data fetching, no state.
28
31
  * All visual properties flow through `--stgm-*` tokens.
@@ -44,6 +47,14 @@ export function MessageEntry({ message, className }: MessageEntryProps) {
44
47
  className={className}
45
48
  />
46
49
  );
50
+ case MessageType.MESSAGE_THINKING:
51
+ return (
52
+ <ThinkingMessage
53
+ content={message.content}
54
+ isStreaming={message.isStreaming}
55
+ className={className}
56
+ />
57
+ );
47
58
  case MessageType.MESSAGE_SYSTEM:
48
59
  return <SystemMessage content={message.content} className={className} />;
49
60
  default:
@@ -103,6 +114,69 @@ function AiMessage({
103
114
  );
104
115
  }
105
116
 
117
+ const THINKING_PREVIEW_LENGTH = 80;
118
+
119
+ function ThinkingMessage({
120
+ content,
121
+ isStreaming,
122
+ className,
123
+ }: {
124
+ content: string;
125
+ isStreaming: boolean;
126
+ className?: string;
127
+ }) {
128
+ const [expanded, setExpanded] = useState(false);
129
+ const hasContent = content.trim().length > 0;
130
+
131
+ if (!hasContent && !isStreaming) return null;
132
+
133
+ const preview = content.length > THINKING_PREVIEW_LENGTH
134
+ ? content.slice(0, THINKING_PREVIEW_LENGTH).trimEnd() + "..."
135
+ : content;
136
+
137
+ return (
138
+ <div
139
+ role="article"
140
+ aria-label="Model thinking"
141
+ className={cn("px-4 py-1.5", className)}
142
+ >
143
+ <button
144
+ type="button"
145
+ aria-expanded={expanded}
146
+ onClick={() => setExpanded((v) => !v)}
147
+ className={cn(
148
+ "flex items-center gap-1.5 text-xs text-muted-foreground transition-colors",
149
+ "hover:text-foreground cursor-pointer",
150
+ )}
151
+ >
152
+ <ThinkingIcon isStreaming={isStreaming} />
153
+ <span className="min-w-0 truncate">
154
+ {isStreaming && !hasContent
155
+ ? "Thinking..."
156
+ : expanded
157
+ ? "Thinking"
158
+ : preview}
159
+ </span>
160
+ {hasContent && <ChevronIcon expanded={expanded} />}
161
+ </button>
162
+
163
+ {expanded && hasContent && (
164
+ <div className="mt-1.5 border-l-2 border-muted-foreground/20 pl-3">
165
+ <p className="text-xs text-muted-foreground whitespace-pre-wrap leading-relaxed">
166
+ {content}
167
+ {isStreaming && (
168
+ <span
169
+ className="inline-block w-[2px] h-[0.8em] bg-muted-foreground align-text-bottom animate-pulse ml-0.5"
170
+ aria-hidden="true"
171
+ />
172
+ )}
173
+ </p>
174
+ </div>
175
+ )}
176
+ </div>
177
+ );
178
+ }
179
+
106
180
  function SystemMessage({
107
181
  content,
108
182
  className,
@@ -121,3 +195,62 @@ function SystemMessage({
121
195
  );
122
196
  }
123
197
 
198
+ function ThinkingIcon({ isStreaming }: { isStreaming: boolean }) {
199
+ if (isStreaming) {
200
+ return (
201
+ <svg
202
+ width="12"
203
+ height="12"
204
+ viewBox="0 0 12 12"
205
+ fill="none"
206
+ stroke="currentColor"
207
+ strokeWidth="1.5"
208
+ className="shrink-0 animate-spin"
209
+ aria-hidden="true"
210
+ >
211
+ <path d="M6 1.5A4.5 4.5 0 1 1 1.5 6" strokeLinecap="round" />
212
+ </svg>
213
+ );
214
+ }
215
+ return (
216
+ <svg
217
+ width="12"
218
+ height="12"
219
+ viewBox="0 0 12 12"
220
+ fill="none"
221
+ stroke="currentColor"
222
+ strokeWidth="1.5"
223
+ strokeLinecap="round"
224
+ strokeLinejoin="round"
225
+ className="shrink-0"
226
+ aria-hidden="true"
227
+ >
228
+ <circle cx="6" cy="5" r="3.5" />
229
+ <path d="M4.5 9.5C4.5 8.5 5 8 6 8s1.5.5 1.5 1.5" />
230
+ <circle cx="5" cy="4.5" r="0.5" fill="currentColor" />
231
+ <circle cx="7" cy="4.5" r="0.5" fill="currentColor" />
232
+ </svg>
233
+ );
234
+ }
235
+
236
+ function ChevronIcon({ expanded }: { expanded: boolean }) {
237
+ return (
238
+ <svg
239
+ width="10"
240
+ height="10"
241
+ viewBox="0 0 10 10"
242
+ fill="none"
243
+ stroke="currentColor"
244
+ strokeWidth="1.5"
245
+ strokeLinecap="round"
246
+ strokeLinejoin="round"
247
+ className={cn(
248
+ "shrink-0 transition-transform duration-150",
249
+ expanded && "rotate-90",
250
+ )}
251
+ aria-hidden="true"
252
+ >
253
+ <path d="M3.5 2L6.5 5L3.5 8" />
254
+ </svg>
255
+ );
256
+ }
@@ -3,6 +3,7 @@ export {
3
3
  GITHUB_CALLBACK_MESSAGE_TYPE,
4
4
  type GitHubUser,
5
5
  type GitHubConnectOptions,
6
+ type UseGitHubConnectionConfig,
6
7
  type UseGitHubConnectionReturn,
7
8
  } from "./useGitHubConnection";
8
9
 
@@ -42,6 +42,52 @@ export interface GitHubConnectOptions {
42
42
  readonly popup?: boolean;
43
43
  }
44
44
 
45
+ /**
46
+ * Optional configuration for {@link useGitHubConnection}.
47
+ *
48
+ * Enables desktop and non-browser environments to participate in the
49
+ * GitHub OAuth flow without relying on `window.open()` popups.
50
+ */
51
+ export interface UseGitHubConnectionConfig {
52
+ /**
53
+ * Custom function to open the authorization URL in a browser.
54
+ *
55
+ * When provided, the hook calls this instead of `window.open()` during
56
+ * popup-mode `connect()` flows. This enables desktop environments
57
+ * (e.g. Tauri, Electron) where webview popups are blocked but the
58
+ * system browser is available.
59
+ *
60
+ * When used with {@link callbackUrl}, the callback page processes the
61
+ * token exchange and the consumer calls {@link UseGitHubConnectionReturn.reconcile}
62
+ * to pick up the token from the personal environment.
63
+ *
64
+ * When used without {@link callbackUrl} (e.g. localhost callback server),
65
+ * the consumer calls {@link UseGitHubConnectionReturn.handleCallback}
66
+ * with the code, state, and redirect URI to complete the exchange.
67
+ */
68
+ readonly openUrl?: (url: string) => void | Promise<void>;
69
+
70
+ /**
71
+ * Override the OAuth callback URL.
72
+ *
73
+ * When provided, this replaces the `redirectUri` parameter passed to
74
+ * `connect()` by UI components like `WorkspaceEditor`. Use this to
75
+ * route the GitHub callback to a specific URL — for example, the
76
+ * Stigmer web console's callback page for desktop flows, or a
77
+ * localhost server for local development.
78
+ *
79
+ * When the callback page handles the token exchange itself (cloud
80
+ * desktop flows), the consumer triggers re-reconciliation via
81
+ * {@link UseGitHubConnectionReturn.reconcile} after the callback
82
+ * completes externally.
83
+ *
84
+ * When the consumer handles the exchange (localhost flows), the same
85
+ * URL must be passed to `handleCallback()` — GitHub requires an
86
+ * exact match between the authorize and exchange redirect URIs.
87
+ */
88
+ readonly callbackUrl?: string;
89
+ }
90
+
45
91
  /** Return value of {@link useGitHubConnection}. */
46
92
  export interface UseGitHubConnectionReturn {
47
93
  /** Whether a valid GitHub token exists. */
@@ -72,6 +118,16 @@ export interface UseGitHubConnectionReturn {
72
118
  state: string,
73
119
  redirectUri: string,
74
120
  ) => Promise<void>;
121
+ /**
122
+ * Trigger re-reconciliation from the personal environment.
123
+ *
124
+ * Call this when the token exchange was handled externally (e.g. by
125
+ * the Stigmer web callback page during a desktop OAuth flow) and the
126
+ * token is already stored server-side. The hook will refetch the
127
+ * personal environment, reveal the token, and update
128
+ * {@link isConnected}.
129
+ */
130
+ readonly reconcile: () => void;
75
131
  /** Clear the stored token and user info. */
76
132
  readonly disconnect: () => void;
77
133
  }
@@ -93,6 +149,39 @@ async function fetchGitHubUser(token: string): Promise<GitHubUser | null> {
93
149
  }
94
150
  }
95
151
 
152
+ /**
153
+ * Reads the OAuth state from the opener window's sessionStorage when
154
+ * running inside a popup (same-origin). Falls back to the current
155
+ * window's sessionStorage for redirect-based flows or when the
156
+ * opener is unavailable.
157
+ */
158
+ function getSavedOAuthState(): string | null {
159
+ try {
160
+ if (window.opener && !window.opener.closed) {
161
+ const openerState = window.opener.sessionStorage.getItem(
162
+ STORAGE_KEY_STATE,
163
+ );
164
+ if (openerState) return openerState;
165
+ }
166
+ } catch {
167
+ // Cross-origin or closed opener — fall through to local storage.
168
+ }
169
+ return sessionStorage.getItem(STORAGE_KEY_STATE);
170
+ }
171
+
172
+ /**
173
+ * Removes the OAuth state key from both the current window's and the
174
+ * opener's sessionStorage (best-effort).
175
+ */
176
+ function clearOAuthState(): void {
177
+ sessionStorage.removeItem(STORAGE_KEY_STATE);
178
+ try {
179
+ window.opener?.sessionStorage?.removeItem(STORAGE_KEY_STATE);
180
+ } catch {
181
+ // Cross-origin or closed opener — ignore.
182
+ }
183
+ }
184
+
96
185
  /**
97
186
  * Checks whether the personal environment's redacted data contains a given key.
98
187
  * The key is present even when the value is redacted (`***REDACTED***`).
@@ -127,6 +216,8 @@ function personalEnvHasKey(
127
216
  *
128
217
  * @param org - The active organization slug. Required for server-side
129
218
  * token storage. Pass `null` to skip all server operations.
219
+ * @param config - Optional configuration for desktop / non-browser
220
+ * environments. See {@link UseGitHubConnectionConfig}.
130
221
  *
131
222
  * @example
132
223
  * ```tsx
@@ -155,9 +246,23 @@ function personalEnvHasKey(
155
246
  * );
156
247
  * }
157
248
  * ```
249
+ *
250
+ * @example Desktop (Tauri) — open in system browser, callback via deep link
251
+ * ```tsx
252
+ * function DesktopGitHubConnect({ org }: { org: string }) {
253
+ * const gh = useGitHubConnection(org, {
254
+ * openUrl: (url) => invoke("open_auth_in_browser", { authUrl: url }),
255
+ * callbackUrl: "https://app.stigmer.ai/auth/github/callback?source=desktop",
256
+ * });
257
+ * // The web callback page processes the exchange, then redirects to
258
+ * // stigmer://github/callback-done. The desktop deep link handler
259
+ * // calls gh.reconcile() to pick up the token.
260
+ * }
261
+ * ```
158
262
  */
159
263
  export function useGitHubConnection(
160
264
  org: string | null,
265
+ config?: UseGitHubConnectionConfig,
161
266
  ): UseGitHubConnectionReturn {
162
267
  const stigmer = useStigmer();
163
268
  const [token, setToken] = useState<string | null>(null);
@@ -287,8 +392,12 @@ export function useGitHubConnection(
287
392
 
288
393
  const connect = useCallback(
289
394
  async (redirectUri: string, options?: GitHubConnectOptions) => {
395
+ const effectiveRedirectUri = config?.callbackUrl ?? redirectUri;
396
+
290
397
  const { authorizeUrl, state } =
291
- await stigmer.github.getOAuthAuthorizeUrl({ redirectUri });
398
+ await stigmer.github.getOAuthAuthorizeUrl({
399
+ redirectUri: effectiveRedirectUri,
400
+ });
292
401
 
293
402
  sessionStorage.setItem(STORAGE_KEY_STATE, state);
294
403
 
@@ -297,7 +406,18 @@ export function useGitHubConnection(
297
406
  return;
298
407
  }
299
408
 
300
- // If a popup is already open, bring it to focus.
409
+ // ── External opener (desktop / non-browser) ─────────────────────
410
+ // When `config.openUrl` is provided, delegate to the consumer
411
+ // instead of using `window.open()`. The consumer is responsible
412
+ // for delivering the callback data via `handleCallback()` or
413
+ // triggering re-reconciliation via `reconcile()`.
414
+ if (config?.openUrl) {
415
+ setIsConnecting(true);
416
+ await config.openUrl(authorizeUrl);
417
+ return;
418
+ }
419
+
420
+ // ── Browser popup ───────────────────────────────────────────────
301
421
  if (popupRef.current && !popupRef.current.closed) {
302
422
  popupRef.current.focus();
303
423
  return;
@@ -327,43 +447,62 @@ export function useGitHubConnection(
327
447
  popupPollRef.current = null;
328
448
  popupRef.current = null;
329
449
  setIsConnecting(false);
450
+
451
+ // The callback page may have exchanged the code and stored
452
+ // the token server-side (e.g. cross-origin popup flow for
453
+ // platform builders). Re-reconcile from the personal
454
+ // environment to pick up the token.
455
+ setIsLoading(true);
456
+ reconciled.current = false;
457
+ personalEnvRef.current.refetch();
330
458
  }
331
459
  }, POPUP_CLOSE_POLL_MS);
332
460
  popupPollRef.current = pollId;
333
461
  },
334
- [stigmer],
462
+ [stigmer, config?.openUrl, config?.callbackUrl],
335
463
  );
336
464
 
337
465
  const handleCallback = useCallback(
338
466
  async (code: string, state: string, redirectUri: string) => {
339
- const savedState = sessionStorage.getItem(STORAGE_KEY_STATE);
467
+ const savedState = getSavedOAuthState();
340
468
  if (savedState && savedState !== state) {
341
469
  throw new Error("OAuth state mismatch — possible CSRF attack");
342
470
  }
343
- sessionStorage.removeItem(STORAGE_KEY_STATE);
344
-
345
- const { accessToken } = await stigmer.github.exchangeOAuthCode({
346
- code,
347
- state,
348
- redirectUri,
349
- });
350
-
351
- const tokenVar = {
352
- [GITHUB_TOKEN_KEY]: { value: accessToken, isSecret: true },
353
- };
354
- const env = await personalEnvRef.current.getOrCreate(tokenVar);
355
- if (!personalEnvHasKey(env, GITHUB_TOKEN_KEY)) {
356
- await personalEnvRef.current.addVariables(tokenVar);
357
- }
471
+ clearOAuthState();
358
472
 
359
- setToken(accessToken);
473
+ try {
474
+ const { accessToken } = await stigmer.github.exchangeOAuthCode({
475
+ code,
476
+ state,
477
+ redirectUri,
478
+ });
479
+
480
+ const tokenVar = {
481
+ [GITHUB_TOKEN_KEY]: { value: accessToken, isSecret: true },
482
+ };
483
+ const env = await personalEnvRef.current.getOrCreate(tokenVar);
484
+ if (!personalEnvHasKey(env, GITHUB_TOKEN_KEY)) {
485
+ await personalEnvRef.current.addVariables(tokenVar);
486
+ }
360
487
 
361
- const u = await fetchGitHubUser(accessToken);
362
- setUser(u);
488
+ setToken(accessToken);
489
+
490
+ const u = await fetchGitHubUser(accessToken);
491
+ setUser(u);
492
+ } finally {
493
+ setIsConnecting(false);
494
+ }
363
495
  },
364
496
  [stigmer],
365
497
  );
366
498
 
499
+ const reconcile = useCallback(() => {
500
+ setIsConnecting(false);
501
+ setIsLoading(true);
502
+ reconciled.current = false;
503
+ personalEnvRef.current.refetch();
504
+ }, []);
505
+
367
506
  const disconnect = useCallback(() => {
368
507
  sessionStorage.removeItem(STORAGE_KEY_STATE);
369
508
  setToken(null);
@@ -396,6 +535,7 @@ export function useGitHubConnection(
396
535
  token,
397
536
  connect,
398
537
  handleCallback,
538
+ reconcile,
399
539
  disconnect,
400
540
  };
401
541
  }