@stigmer/react 3.4.0 → 3.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.
Files changed (208) hide show
  1. package/agent/AgentDetailView.d.ts +14 -4
  2. package/agent/AgentDetailView.d.ts.map +1 -1
  3. package/agent/AgentDetailView.js +73 -11
  4. package/agent/AgentDetailView.js.map +1 -1
  5. package/agent/internal/agentToInput.d.ts +3 -0
  6. package/agent/internal/agentToInput.d.ts.map +1 -1
  7. package/agent/internal/agentToInput.js +21 -1
  8. package/agent/internal/agentToInput.js.map +1 -1
  9. package/agent/useAgentCount.d.ts +2 -0
  10. package/agent/useAgentCount.d.ts.map +1 -1
  11. package/agent/useAgentCount.js.map +1 -1
  12. package/api-key/ApiKeyListPanel.d.ts +8 -1
  13. package/api-key/ApiKeyListPanel.d.ts.map +1 -1
  14. package/api-key/ApiKeyListPanel.js +9 -7
  15. package/api-key/ApiKeyListPanel.js.map +1 -1
  16. package/api-key/CreateApiKeyForm.d.ts +7 -1
  17. package/api-key/CreateApiKeyForm.d.ts.map +1 -1
  18. package/api-key/CreateApiKeyForm.js +2 -2
  19. package/api-key/CreateApiKeyForm.js.map +1 -1
  20. package/datastore/useDatastoreCount.d.ts +2 -0
  21. package/datastore/useDatastoreCount.d.ts.map +1 -1
  22. package/datastore/useDatastoreCount.js.map +1 -1
  23. package/dependency-graph/DependencyTreeNode.d.ts.map +1 -1
  24. package/dependency-graph/DependencyTreeNode.js +8 -0
  25. package/dependency-graph/DependencyTreeNode.js.map +1 -1
  26. package/dependency-graph/types.d.ts +20 -8
  27. package/dependency-graph/types.d.ts.map +1 -1
  28. package/dependency-graph/useDependencyGraph.d.ts.map +1 -1
  29. package/dependency-graph/useDependencyGraph.js +21 -1
  30. package/dependency-graph/useDependencyGraph.js.map +1 -1
  31. package/index.d.ts +6 -4
  32. package/index.d.ts.map +1 -1
  33. package/index.js +6 -2
  34. package/index.js.map +1 -1
  35. package/internal/scroll-area.d.ts +4 -0
  36. package/internal/scroll-area.d.ts.map +1 -0
  37. package/internal/scroll-area.js +15 -0
  38. package/internal/scroll-area.js.map +1 -0
  39. package/internal/tooltip.d.ts +7 -0
  40. package/internal/tooltip.d.ts.map +1 -0
  41. package/internal/tooltip.js +31 -0
  42. package/internal/tooltip.js.map +1 -0
  43. package/manifest/ApplyManifestDialog.d.ts +10 -5
  44. package/manifest/ApplyManifestDialog.d.ts.map +1 -1
  45. package/manifest/ApplyManifestDialog.js +30 -13
  46. package/manifest/ApplyManifestDialog.js.map +1 -1
  47. package/manifest/useApplyManifest.d.ts +6 -2
  48. package/manifest/useApplyManifest.d.ts.map +1 -1
  49. package/manifest/useApplyManifest.js +5 -5
  50. package/manifest/useApplyManifest.js.map +1 -1
  51. package/mcp-server/McpServerDetailView.d.ts +38 -2
  52. package/mcp-server/McpServerDetailView.d.ts.map +1 -1
  53. package/mcp-server/McpServerDetailView.js +21 -5
  54. package/mcp-server/McpServerDetailView.js.map +1 -1
  55. package/mcp-server/StdioSandboxNotice.d.ts +12 -11
  56. package/mcp-server/StdioSandboxNotice.d.ts.map +1 -1
  57. package/mcp-server/StdioSandboxNotice.js +13 -12
  58. package/mcp-server/StdioSandboxNotice.js.map +1 -1
  59. package/mcp-server/index.d.ts +7 -0
  60. package/mcp-server/index.d.ts.map +1 -1
  61. package/mcp-server/index.js +9 -0
  62. package/mcp-server/index.js.map +1 -1
  63. package/mcp-server/steps/EnvironmentAuthStep.d.ts +4 -0
  64. package/mcp-server/steps/EnvironmentAuthStep.d.ts.map +1 -1
  65. package/mcp-server/steps/EnvironmentAuthStep.js +4 -0
  66. package/mcp-server/steps/EnvironmentAuthStep.js.map +1 -1
  67. package/mcp-server/steps/IdentityTransportStep.d.ts +6 -0
  68. package/mcp-server/steps/IdentityTransportStep.d.ts.map +1 -1
  69. package/mcp-server/steps/IdentityTransportStep.js +7 -1
  70. package/mcp-server/steps/IdentityTransportStep.js.map +1 -1
  71. package/mcp-server/steps/ReviewStep.d.ts +5 -0
  72. package/mcp-server/steps/ReviewStep.d.ts.map +1 -1
  73. package/mcp-server/steps/ReviewStep.js +16 -2
  74. package/mcp-server/steps/ReviewStep.js.map +1 -1
  75. package/mcp-server/useMcpServerCount.d.ts +2 -0
  76. package/mcp-server/useMcpServerCount.d.ts.map +1 -1
  77. package/mcp-server/useMcpServerCount.js.map +1 -1
  78. package/package.json +4 -4
  79. package/resource-creation/CreationPicker.d.ts.map +1 -1
  80. package/resource-creation/CreationPicker.js +3 -3
  81. package/resource-creation/CreationPicker.js.map +1 -1
  82. package/resource-creation/WizardNav.d.ts.map +1 -1
  83. package/resource-creation/WizardNav.js +1 -1
  84. package/resource-creation/WizardNav.js.map +1 -1
  85. package/resource-creation/templates/mcp-server-templates.d.ts.map +1 -1
  86. package/resource-creation/templates/mcp-server-templates.js +17 -14
  87. package/resource-creation/templates/mcp-server-templates.js.map +1 -1
  88. package/resource-detail/Section.d.ts +8 -1
  89. package/resource-detail/Section.d.ts.map +1 -1
  90. package/resource-detail/Section.js +2 -2
  91. package/resource-detail/Section.js.map +1 -1
  92. package/resource-workbench/components/ResourceWorkbench.d.ts +9 -1
  93. package/resource-workbench/components/ResourceWorkbench.d.ts.map +1 -1
  94. package/resource-workbench/components/ResourceWorkbench.js +2 -1
  95. package/resource-workbench/components/ResourceWorkbench.js.map +1 -1
  96. package/resource-workbench/hooks/useResourceCollection.d.ts +9 -0
  97. package/resource-workbench/hooks/useResourceCollection.d.ts.map +1 -1
  98. package/resource-workbench/hooks/useResourceCollection.js +2 -2
  99. package/resource-workbench/hooks/useResourceCollection.js.map +1 -1
  100. package/search/useResourceCount.d.ts +7 -0
  101. package/search/useResourceCount.d.ts.map +1 -1
  102. package/search/useResourceCount.js +2 -1
  103. package/search/useResourceCount.js.map +1 -1
  104. package/session/NewSessionViewer.d.ts.map +1 -1
  105. package/session/NewSessionViewer.js +17 -3
  106. package/session/NewSessionViewer.js.map +1 -1
  107. package/session/SessionViewer.d.ts +2 -1
  108. package/session/SessionViewer.d.ts.map +1 -1
  109. package/session/SessionViewer.js +19 -11
  110. package/session/SessionViewer.js.map +1 -1
  111. package/session/SessionViewerLayout.d.ts +90 -0
  112. package/session/SessionViewerLayout.d.ts.map +1 -0
  113. package/session/SessionViewerLayout.js +32 -0
  114. package/session/SessionViewerLayout.js.map +1 -0
  115. package/session/index.d.ts +4 -0
  116. package/session/index.d.ts.map +1 -1
  117. package/session/index.js +10 -0
  118. package/session/index.js.map +1 -1
  119. package/session/useSessionRailViews.d.ts +2 -2
  120. package/sidebar/SettingsSidebar.d.ts +62 -0
  121. package/sidebar/SettingsSidebar.d.ts.map +1 -0
  122. package/sidebar/SettingsSidebar.js +60 -0
  123. package/sidebar/SettingsSidebar.js.map +1 -0
  124. package/sidebar/WorkspaceSidebar.d.ts +87 -0
  125. package/sidebar/WorkspaceSidebar.d.ts.map +1 -0
  126. package/sidebar/WorkspaceSidebar.js +95 -0
  127. package/sidebar/WorkspaceSidebar.js.map +1 -0
  128. package/sidebar/chrome.d.ts +48 -0
  129. package/sidebar/chrome.d.ts.map +1 -0
  130. package/sidebar/chrome.js +32 -0
  131. package/sidebar/chrome.js.map +1 -0
  132. package/sidebar/index.d.ts +6 -0
  133. package/sidebar/index.d.ts.map +1 -0
  134. package/sidebar/index.js +9 -0
  135. package/sidebar/index.js.map +1 -0
  136. package/sidebar/types.d.ts +54 -0
  137. package/sidebar/types.d.ts.map +1 -0
  138. package/sidebar/types.js +2 -0
  139. package/sidebar/types.js.map +1 -0
  140. package/skill/useSkillCount.d.ts +2 -0
  141. package/skill/useSkillCount.d.ts.map +1 -1
  142. package/skill/useSkillCount.js.map +1 -1
  143. package/src/agent/AgentDetailView.tsx +198 -11
  144. package/src/agent/__tests__/AgentDetailView.datastores.test.tsx +166 -0
  145. package/src/agent/internal/__tests__/agentToInput.test.ts +154 -0
  146. package/src/agent/internal/agentToInput.ts +25 -7
  147. package/src/agent/useAgentCount.ts +2 -0
  148. package/src/api-key/ApiKeyListPanel.tsx +17 -4
  149. package/src/api-key/CreateApiKeyForm.tsx +8 -1
  150. package/src/api-key/__tests__/CreateApiKeyForm.test.tsx +58 -0
  151. package/src/datastore/useDatastoreCount.ts +2 -0
  152. package/src/dependency-graph/DependencyTreeNode.tsx +8 -0
  153. package/src/dependency-graph/__tests__/useDependencyGraph.test.ts +108 -0
  154. package/src/dependency-graph/types.ts +20 -8
  155. package/src/dependency-graph/useDependencyGraph.ts +23 -1
  156. package/src/index.ts +24 -0
  157. package/src/internal/scroll-area.tsx +37 -0
  158. package/src/internal/tooltip.tsx +66 -0
  159. package/src/manifest/ApplyManifestDialog.tsx +40 -16
  160. package/src/manifest/__tests__/ApplyManifestDialog.test.tsx +147 -0
  161. package/src/manifest/__tests__/useApplyManifest.test.tsx +6 -6
  162. package/src/manifest/useApplyManifest.ts +12 -8
  163. package/src/mcp-server/McpServerDetailView.tsx +52 -4
  164. package/src/mcp-server/StdioSandboxNotice.tsx +16 -14
  165. package/src/mcp-server/__tests__/McpServerDetailView.test.tsx +345 -0
  166. package/src/mcp-server/__tests__/StdioSandboxNotice.test.tsx +3 -2
  167. package/src/mcp-server/index.ts +13 -0
  168. package/src/mcp-server/steps/EnvironmentAuthStep.tsx +4 -0
  169. package/src/mcp-server/steps/IdentityTransportStep.tsx +16 -1
  170. package/src/mcp-server/steps/ReviewStep.tsx +18 -1
  171. package/src/mcp-server/useMcpServerCount.ts +2 -0
  172. package/src/resource-creation/CreationPicker.tsx +7 -0
  173. package/src/resource-creation/WizardNav.tsx +1 -0
  174. package/src/resource-creation/templates/mcp-server-templates.ts +17 -14
  175. package/src/resource-detail/Section.tsx +9 -1
  176. package/src/resource-workbench/components/ResourceWorkbench.tsx +13 -0
  177. package/src/resource-workbench/hooks/useResourceCollection.ts +11 -1
  178. package/src/search/useResourceCount.ts +9 -1
  179. package/src/session/NewSessionViewer.tsx +48 -61
  180. package/src/session/SessionViewer.tsx +71 -86
  181. package/src/session/SessionViewerLayout.tsx +131 -0
  182. package/src/session/__tests__/SessionViewerLayout.test.tsx +140 -0
  183. package/src/session/index.ts +14 -0
  184. package/src/session/useSessionRailViews.tsx +2 -2
  185. package/src/sidebar/SettingsSidebar.tsx +173 -0
  186. package/src/sidebar/WorkspaceSidebar.tsx +386 -0
  187. package/src/sidebar/__tests__/SettingsSidebar.test.tsx +107 -0
  188. package/src/sidebar/__tests__/WorkspaceSidebar.test.tsx +236 -0
  189. package/src/sidebar/chrome.tsx +129 -0
  190. package/src/sidebar/index.ts +16 -0
  191. package/src/sidebar/types.ts +55 -0
  192. package/src/skill/useSkillCount.ts +2 -0
  193. package/src/test/__tests__/samples.test.ts +161 -2
  194. package/src/test/index.ts +1 -1
  195. package/src/test/samples.ts +157 -14
  196. package/src/workflow/useWorkflowCount.ts +2 -0
  197. package/styles.css +1 -1
  198. package/test/index.d.ts +1 -1
  199. package/test/index.d.ts.map +1 -1
  200. package/test/index.js +1 -1
  201. package/test/index.js.map +1 -1
  202. package/test/samples.d.ts +100 -7
  203. package/test/samples.d.ts.map +1 -1
  204. package/test/samples.js +133 -14
  205. package/test/samples.js.map +1 -1
  206. package/workflow/useWorkflowCount.d.ts +2 -0
  207. package/workflow/useWorkflowCount.d.ts.map +1 -1
  208. package/workflow/useWorkflowCount.js.map +1 -1
@@ -57,6 +57,15 @@ export interface UseResourceCollectionOptions<TData = SearchResult> {
57
57
  readonly columns?: readonly WorkbenchColumnDef<TData>[];
58
58
  /** Enable row selection state tracking. @default false */
59
59
  readonly enableSelection?: boolean;
60
+ /**
61
+ * Opaque token that forces a background refetch whenever its value
62
+ * changes. Use it to re-read the list after an out-of-band mutation
63
+ * (e.g. applying a manifest or deleting a row) without remounting:
64
+ * bump a counter and pass it here. The current page stays rendered
65
+ * with an `isRefetching` shimmer — no full-list flash. A change to
66
+ * this token alone never resets pagination or sort.
67
+ */
68
+ readonly refetchToken?: unknown;
60
69
  }
61
70
 
62
71
  // ---------------------------------------------------------------------------
@@ -148,6 +157,7 @@ export function useResourceCollection<TData = SearchResult>(
148
157
  onSortChange,
149
158
  columns = [],
150
159
  enableSelection = false,
160
+ refetchToken,
151
161
  } = options;
152
162
 
153
163
  // --- Data fetching via useFetch -----------------------------------------
@@ -169,7 +179,7 @@ export function useResourceCollection<TData = SearchResult>(
169
179
  };
170
180
  }
171
181
  : null,
172
- [listFn, org, query, scope, page, pageSize],
182
+ [listFn, org, query, scope, page, pageSize, refetchToken],
173
183
  INITIAL_DATA,
174
184
  );
175
185
 
@@ -16,6 +16,13 @@ export interface UseResourceCountOptions {
16
16
  * @default "org"
17
17
  */
18
18
  readonly scope?: ResourceListScope;
19
+ /**
20
+ * Opaque token that forces a recount whenever its value changes. Use
21
+ * it to refresh the count after an out-of-band mutation (e.g. applying
22
+ * a manifest) without remounting. Mirrors the `refetchToken` on
23
+ * {@link useResourceCollection}.
24
+ */
25
+ readonly refetchToken?: unknown;
19
26
  }
20
27
 
21
28
  export interface UseResourceCountReturn {
@@ -52,6 +59,7 @@ export function useResourceCount(
52
59
  ): UseResourceCountReturn {
53
60
  const query = options?.query;
54
61
  const scope = options?.scope ?? "org";
62
+ const refetchToken = options?.refetchToken;
55
63
 
56
64
  const { data: count, isLoading, isRefetching, error, refetch } = useFetch<number | undefined>(
57
65
  org
@@ -66,7 +74,7 @@ export function useResourceCount(
66
74
  return result.totalCount;
67
75
  }
68
76
  : null,
69
- [listFn, org, query, scope],
77
+ [listFn, org, query, scope, refetchToken],
70
78
  undefined,
71
79
  );
72
80
 
@@ -1,7 +1,6 @@
1
1
  "use client";
2
2
 
3
3
  import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
4
- import { cn } from "@stigmer/theme";
5
4
  import type { ResourceRef } from "@stigmer/sdk";
6
5
  import type { UseGitHubConnectionReturn } from "../github/useGitHubConnection.js";
7
6
  import type { WorkspaceFileLister } from "../workspace/WorkspaceFileLister.js";
@@ -10,7 +9,7 @@ import type { WorkspaceContentSearcher } from "../workspace/WorkspaceContentSear
10
9
  import type { InteractionModeOption } from "../composer/index.js";
11
10
  import { SessionComposer } from "../composer/index.js";
12
11
  import type { HarnessOption } from "../models/harness.js";
13
- import { ResizableSplit } from "../internal/ResizableSplit.js";
12
+ import { SessionViewerLayout } from "./SessionViewerLayout.js";
14
13
  import { useWorkspaceEditors } from "../internal/store/index.js";
15
14
  import { WorkspaceSurface } from "../workspace/WorkspaceSurface.js";
16
15
  import type { SetupTabProps } from "./facets/SetupTab.js";
@@ -383,68 +382,56 @@ export function NewSessionViewer({
383
382
  );
384
383
 
385
384
  return (
386
- <div className={cn("relative flex h-full w-full flex-col", className)}>
387
- {/* Persistent chrome: the chip is always mounted, matching SessionViewer
388
- (DD-016) rather than the launcher's earlier progressively-revealed
389
- inspector. A toggle that appears/disappears with attached context
390
- reads as instability and, worse, unmounts the open panel's only
391
- collapse control when the last context item is removed. Always-on is
392
- the predictable, discoverable shape; opening homes on the Config
393
- facet, which carries useful defaults (harness/model) pre-session.
394
- Guests are the exception: the panel exposes configuration a visitor
395
- has no business with, so the chip (the panel's only toggle) is
396
- simply absent and the panel can never open. */}
397
- {!isGuest && (
398
- <div className="absolute top-2 right-6 z-10">
385
+ // The launcher renders the same SessionViewerLayout as SessionViewer
386
+ // (DD-016) the shared frame is what makes "the two viewers behave
387
+ // identically" structural rather than hand-synchronized.
388
+ <SessionViewerLayout
389
+ className={className}
390
+ resizeAriaLabel="Resize composer panel"
391
+ splitStorageKey="stgm-new-session-chat-width"
392
+ // Persistent chrome: the chip is always mounted, matching SessionViewer
393
+ // (DD-016) rather than the launcher's earlier progressively-revealed
394
+ // inspector. A toggle that appears/disappears with attached context
395
+ // reads as instability and, worse, unmounts the open panel's only
396
+ // collapse control when the last context item is removed. Always-on is
397
+ // the predictable, discoverable shape; opening homes on the Config
398
+ // facet, which carries useful defaults (harness/model) pre-session.
399
+ // Guests are the exception: the panel exposes configuration a visitor
400
+ // has no business with, so the chip (the panel's only toggle) is
401
+ // simply absent and the panel can never open.
402
+ chip={
403
+ !isGuest ? (
399
404
  <SessionPanelChip
400
405
  isOpen={panel.isOpen}
401
406
  onToggle={panel.isOpen ? panel.closePanel : panel.openPanel}
402
407
  />
403
- </div>
404
- )}
405
-
406
- {/* Same unified-panel layout as SessionViewer (DD-016): collapsed by
407
- default (composer fills the row); opening makes the composer the
408
- fixed narrow pane and hands the flexible region to the surface.
409
- Collapse goes through the split's `collapsedPane` (CSS, not
410
- conditional structure), so the composer never remounts across an
411
- open/close toggle. */}
412
- <ResizableSplit
413
- resizablePane="primary"
414
- collapsedPane={panel.isOpen ? "none" : "secondary"}
415
- defaultSize={420}
416
- minSize={320}
417
- maxSize={640}
418
- storageKey="stgm-new-session-chat-width"
419
- responsiveCollapse={panel.isOpen ? "primary" : "none"}
420
- ariaLabel="Resize composer panel"
421
- className="min-h-0 flex-1"
422
- primary={composerNode}
423
- secondary={
424
- panel.isOpen ? (
425
- <WorkspaceSurface
426
- entries={flow.workspace.entries}
427
- lister={workspaceFileLister}
428
- reader={workspaceFileReader}
429
- searcher={workspaceContentSearcher}
430
- view={panel.view}
431
- onViewChange={panel.setView}
432
- extraViews={railViews}
433
- onRemoveEntry={flow.workspace.remove}
434
- onAddLocalFolder={canAddLocalFolder ? handleAddLocalFolder : undefined}
435
- editors={editors}
436
- selectedFile={activeFile}
437
- onOpenFile={panel.openFile}
438
- onActivateEditor={panel.activateEditor}
439
- onPinEditor={panel.pinEditor}
440
- onCloseEditor={panel.closeEditor}
441
- onCollapse={panel.closePanel}
442
- reveal={activeReveal}
443
- className="h-full"
444
- />
445
- ) : null
446
- }
447
- />
448
- </div>
408
+ ) : undefined
409
+ }
410
+ conversation={composerNode}
411
+ panel={
412
+ panel.isOpen ? (
413
+ <WorkspaceSurface
414
+ entries={flow.workspace.entries}
415
+ lister={workspaceFileLister}
416
+ reader={workspaceFileReader}
417
+ searcher={workspaceContentSearcher}
418
+ view={panel.view}
419
+ onViewChange={panel.setView}
420
+ extraViews={railViews}
421
+ onRemoveEntry={flow.workspace.remove}
422
+ onAddLocalFolder={canAddLocalFolder ? handleAddLocalFolder : undefined}
423
+ editors={editors}
424
+ selectedFile={activeFile}
425
+ onOpenFile={panel.openFile}
426
+ onActivateEditor={panel.activateEditor}
427
+ onPinEditor={panel.pinEditor}
428
+ onCloseEditor={panel.closeEditor}
429
+ onCollapse={panel.closePanel}
430
+ reveal={activeReveal}
431
+ className="h-full"
432
+ />
433
+ ) : null
434
+ }
435
+ />
449
436
  );
450
437
  }
@@ -19,7 +19,7 @@ import {
19
19
  } from "../workspace/WorkspaceSurface.js";
20
20
  import type { InteractionModeOption, SessionComposerHandle, SessionComposerSubmitContext } from "../composer/index.js";
21
21
  import type { ApplyResourceResult } from "../library/useApplyResource.js";
22
- import { ResizableSplit } from "../internal/ResizableSplit.js";
22
+ import { SessionViewerLayout } from "./SessionViewerLayout.js";
23
23
  import { useWorkspaceEditors, isVirtualEntryId } from "../internal/store/index.js";
24
24
  import { isTerminalPhase } from "../execution/execution-phases.js";
25
25
  import { MessageThread, type MessageThreadSlots } from "../execution/MessageThread.js";
@@ -267,7 +267,8 @@ export interface SessionViewerProps {
267
267
  * to a top-right chip; opening a file (tree, search, or a transcript path)
268
268
  * expands it.
269
269
  *
270
- * Connected via `ResizableSplit` with persisted chat width.
270
+ * Both regions render inside {@link SessionViewerLayout} — the frame shared
271
+ * with {@link NewSessionViewer} — with the chat width persisted per viewer.
271
272
  *
272
273
  * Framework-agnostic — no Next.js, no Tauri, no routing deps. Host
273
274
  * apps inject platform-specific values via props (DD-004/DD-016).
@@ -552,101 +553,85 @@ export function SessionViewer({
552
553
  }
553
554
 
554
555
  return (
555
- <div className={cn("relative flex h-full w-full flex-col", className)}>
556
- {/* Top-right controls: host actions + the panel chip. The chip is the
557
- panel's always-mounted toggle; while collapsed it carries only the
558
- pending-item count. Execution status is never surfaced as header
559
- chrome the thread itself communicates run state. Guests get no
560
- chip: the panel exposes session configuration a visitor has no
561
- business with, so its only toggle is simply absent. */}
562
- <div className="absolute top-2 right-6 z-10 flex items-center gap-2">
563
- {headerActions}
564
- {!isGuest && (
556
+ <SessionViewerLayout
557
+ className={className}
558
+ // Top-right controls: host actions + the panel chip. The chip is the
559
+ // panel's always-mounted toggle; while collapsed it carries only the
560
+ // pending-item count. Execution status is never surfaced as header
561
+ // chrome — the thread itself communicates run state. Guests get no
562
+ // chip: the panel exposes session configuration a visitor has no
563
+ // business with, so its only toggle is simply absent.
564
+ headerActions={headerActions}
565
+ chip={
566
+ !isGuest ? (
565
567
  <SessionPanelChip
566
568
  isOpen={panel.isOpen}
567
569
  onToggle={panel.isOpen ? panel.closePanel : panel.openPanel}
568
570
  badgeCount={writeBackCount + artifactCount}
569
571
  />
570
- )}
571
- </div>
572
-
573
- {/* One layout, collapsed by default: chat fills the row until the panel
574
- opens, then becomes the fixed narrow pane on the left while the
575
- panel takes the flexible region. Collapsing goes through the
576
- split's `collapsedPane` (CSS, not conditional structure), so the
577
- conversation is always the same first child and an open/close never
578
- remounts it (invariant 1). */}
579
- <ResizableSplit
580
- resizablePane="primary"
581
- collapsedPane={panel.isOpen ? "none" : "secondary"}
582
- defaultSize={420}
583
- minSize={320}
584
- maxSize={640}
585
- storageKey="stgm-session-chat-width"
586
- responsiveCollapse={panel.isOpen ? "primary" : "none"}
587
- ariaLabel="Resize chat panel"
588
- className="min-h-0 flex-1"
589
- primary={
590
- <ConversationColumn
572
+ ) : undefined
573
+ }
574
+ splitStorageKey="stgm-session-chat-width"
575
+ conversation={
576
+ <ConversationColumn
577
+ flow={flow}
578
+ modelId={modelId}
579
+ setModelId={setModelId}
580
+ interactionMode={interactionMode}
581
+ setInteractionMode={setInteractionMode}
582
+ composerRef={composerRef}
583
+ org={org}
584
+ gitHubConnection={gitHubConnection}
585
+ enableGitHub={enableGitHub}
586
+ enableLocal={enableLocal}
587
+ onBrowseLocalFolder={onBrowseLocalFolder}
588
+ onBuildFromPlan={handleBuildFromPlan}
589
+ onOpenPlan={handleOpenPlan}
590
+ isBuildingFromPlan={isBuildingFromPlan}
591
+ planAttachFailed={planAttachFailed}
592
+ onDismissPlanAttachFailed={() => setPlanAttachFailed(false)}
593
+ onFilePathClick={handleTranscriptFilePathClick}
594
+ isCurated={isCurated}
595
+ isGuest={isGuest}
596
+ isObserver={isObserver}
597
+ threadSlots={threadSlots}
598
+ />
599
+ }
600
+ panel={
601
+ panel.isOpen && !isGuest ? (
602
+ <SessionPanelRegion
591
603
  flow={flow}
592
- modelId={modelId}
593
- setModelId={setModelId}
594
- interactionMode={interactionMode}
595
- setInteractionMode={setInteractionMode}
596
- composerRef={composerRef}
597
604
  org={org}
598
- gitHubConnection={gitHubConnection}
599
- enableGitHub={enableGitHub}
605
+ panel={panel}
606
+ // Observers cannot manage access (no can_grant_access /
607
+ // can_view_access on a channel session) — the control is
608
+ // withheld rather than left to fail server-side.
609
+ accessSlot={isObserver ? undefined : accessSlot}
610
+ onApplied={onApplied}
611
+ // Implementing a plan submits an execution — a write an
612
+ // observer cannot make (can_create_execution_in is
613
+ // owner/viewer-only).
614
+ onImplementPlan={isObserver ? undefined : handleBuildFromPlan}
615
+ implementPlanDisabled={
616
+ isObserver || !conv.canSendFollowUp || isBuildingFromPlan
617
+ }
618
+ sessionPlan={sessionPlan}
619
+ streamingPlan={streamingPlan}
620
+ planDraft={planDraft}
621
+ openPlanExecutionId={openPlanExecutionId}
622
+ onOpenPlan={handleOpenPlan}
623
+ onOpenArtifact={handleOpenArtifact}
624
+ onActivateArtifact={handleActivateArtifact}
600
625
  enableLocal={enableLocal}
601
626
  onBrowseLocalFolder={onBrowseLocalFolder}
602
- onBuildFromPlan={handleBuildFromPlan}
603
- onOpenPlan={handleOpenPlan}
604
- isBuildingFromPlan={isBuildingFromPlan}
605
- planAttachFailed={planAttachFailed}
606
- onDismissPlanAttachFailed={() => setPlanAttachFailed(false)}
607
- onFilePathClick={handleTranscriptFilePathClick}
627
+ workspaceFileLister={workspaceFileLister}
628
+ workspaceFileReader={workspaceFileReader}
629
+ workspaceContentSearcher={workspaceContentSearcher}
608
630
  isCurated={isCurated}
609
- isGuest={isGuest}
610
- isObserver={isObserver}
611
- threadSlots={threadSlots}
612
631
  />
613
- }
614
- secondary={
615
- panel.isOpen && !isGuest ? (
616
- <SessionPanelRegion
617
- flow={flow}
618
- org={org}
619
- panel={panel}
620
- // Observers cannot manage access (no can_grant_access /
621
- // can_view_access on a channel session) — the control is
622
- // withheld rather than left to fail server-side.
623
- accessSlot={isObserver ? undefined : accessSlot}
624
- onApplied={onApplied}
625
- // Implementing a plan submits an execution — a write an
626
- // observer cannot make (can_create_execution_in is
627
- // owner/viewer-only).
628
- onImplementPlan={isObserver ? undefined : handleBuildFromPlan}
629
- implementPlanDisabled={
630
- isObserver || !conv.canSendFollowUp || isBuildingFromPlan
631
- }
632
- sessionPlan={sessionPlan}
633
- streamingPlan={streamingPlan}
634
- planDraft={planDraft}
635
- openPlanExecutionId={openPlanExecutionId}
636
- onOpenPlan={handleOpenPlan}
637
- onOpenArtifact={handleOpenArtifact}
638
- onActivateArtifact={handleActivateArtifact}
639
- enableLocal={enableLocal}
640
- onBrowseLocalFolder={onBrowseLocalFolder}
641
- workspaceFileLister={workspaceFileLister}
642
- workspaceFileReader={workspaceFileReader}
643
- workspaceContentSearcher={workspaceContentSearcher}
644
- isCurated={isCurated}
645
- />
646
- ) : null
647
- }
648
- />
649
- </div>
632
+ ) : null
633
+ }
634
+ />
650
635
  );
651
636
  }
652
637
 
@@ -0,0 +1,131 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import { cn } from "@stigmer/theme";
5
+ import { ResizableSplit } from "../internal/ResizableSplit.js";
6
+
7
+ /** Props for {@link SessionViewerLayout}. */
8
+ export interface SessionViewerLayoutProps {
9
+ /**
10
+ * The conversation column — the layout's primary (left) pane. Stays
11
+ * mounted at a stable tree position across every panel open/close, so
12
+ * streaming and scroll state survive the toggle.
13
+ */
14
+ readonly conversation: ReactNode;
15
+ /**
16
+ * The session panel content, or `null` while the panel is collapsed.
17
+ * This is the single source of truth for the collapsed state: passing
18
+ * `null` collapses the split (the conversation fills the row) and hides
19
+ * the drag separator; passing content expands the panel into the
20
+ * flexible region. Hosts derive it from their panel controller, e.g.
21
+ * `panel.isOpen ? <WorkspaceSurface … /> : null`.
22
+ */
23
+ readonly panel: ReactNode | null;
24
+ /**
25
+ * The panel's toggle chip, rendered in the layout's top-right corner
26
+ * (typically the session panel chip, {@link PanelChip}). Omit it to
27
+ * render no toggle — how hosts express audiences that must never open
28
+ * the panel (the guest case in {@link SessionViewer} and
29
+ * {@link NewSessionViewer}).
30
+ */
31
+ readonly chip?: ReactNode;
32
+ /**
33
+ * Host-injected header actions, rendered beside the chip in the
34
+ * top-right corner (slot injection, as {@link SessionViewerProps.headerActions}).
35
+ */
36
+ readonly headerActions?: ReactNode;
37
+ /**
38
+ * `localStorage` key persisting the chat pane's drag-resized width
39
+ * across sessions. Each host passes its own key so the two shipped
40
+ * viewers keep distinct widths. **Omit it in deterministic hosts**
41
+ * (documentation embeds, video export): with no key the width always
42
+ * starts at the default, so every render of the same input is
43
+ * pixel-identical instead of depending on previously persisted state.
44
+ */
45
+ readonly splitStorageKey?: string;
46
+ /**
47
+ * Whether the conversation pane collapses below the `lg` breakpoint
48
+ * while the panel is open, leaving the panel full-width — the shipped
49
+ * console behavior on narrow windows.
50
+ *
51
+ * Pass `false` in fixed-canvas hosts, where the media query is
52
+ * meaningless or actively wrong: a documentation embed renders in an
53
+ * iframe whose viewport is the docs column (below `lg` on most pages),
54
+ * so the default would hide the conversation on exactly the beats that
55
+ * open the panel.
56
+ *
57
+ * @default true
58
+ */
59
+ readonly responsive?: boolean;
60
+ /**
61
+ * Accessible label for the split's drag separator. Name the pane being
62
+ * resized in the host's vocabulary (the session viewer's chat pane vs
63
+ * the launcher's composer pane).
64
+ *
65
+ * @default "Resize chat panel"
66
+ */
67
+ readonly resizeAriaLabel?: string;
68
+ /** Additional CSS classes for the root container. */
69
+ readonly className?: string;
70
+ }
71
+
72
+ /**
73
+ * The one conversation-plus-panel frame every session viewer shares —
74
+ * {@link SessionViewer} and {@link NewSessionViewer} both render it, so the
75
+ * two surfaces cannot drift apart (their behave-identically contract), and
76
+ * platform builders composing a custom session surface from
77
+ * {@link WorkspaceSurface}, {@link PanelChip}, and the session facets get
78
+ * the same geometry the shipped console uses.
79
+ *
80
+ * The layout owns exactly three things:
81
+ *
82
+ * - **The top-right control corner** — the host's `headerActions` beside
83
+ * the panel toggle `chip`, absolutely positioned over the conversation.
84
+ * - **The split geometry** — chat fills the row while the panel is
85
+ * collapsed; opening makes the chat the fixed, drag-resizable pane
86
+ * (420px default, 320–640px) and hands the flexible region to the panel.
87
+ * - **The collapse mechanics** — collapse goes through the split's
88
+ * `collapsedPane` (CSS, not conditional structure), so the conversation
89
+ * is always the same first child and an open/close toggle never
90
+ * remounts it.
91
+ *
92
+ * Everything rendered inside the panes belongs to the host: the layout
93
+ * takes opaque `ReactNode`s and never inspects them.
94
+ */
95
+ export function SessionViewerLayout({
96
+ conversation,
97
+ panel,
98
+ chip,
99
+ headerActions,
100
+ splitStorageKey,
101
+ responsive = true,
102
+ resizeAriaLabel = "Resize chat panel",
103
+ className,
104
+ }: SessionViewerLayoutProps) {
105
+ const isPanelOpen = panel != null;
106
+
107
+ return (
108
+ <div className={cn("relative flex h-full w-full flex-col", className)}>
109
+ {(headerActions != null || chip != null) && (
110
+ <div className="absolute top-2 right-6 z-10 flex items-center gap-2">
111
+ {headerActions}
112
+ {chip}
113
+ </div>
114
+ )}
115
+
116
+ <ResizableSplit
117
+ resizablePane="primary"
118
+ collapsedPane={isPanelOpen ? "none" : "secondary"}
119
+ defaultSize={420}
120
+ minSize={320}
121
+ maxSize={640}
122
+ storageKey={splitStorageKey}
123
+ responsiveCollapse={responsive && isPanelOpen ? "primary" : "none"}
124
+ ariaLabel={resizeAriaLabel}
125
+ className="min-h-0 flex-1"
126
+ primary={conversation}
127
+ secondary={panel}
128
+ />
129
+ </div>
130
+ );
131
+ }
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect, vi, afterEach } from "vitest";
2
+ import { render, screen, cleanup } from "@testing-library/react";
3
+ import { SessionViewerLayout } from "../SessionViewerLayout";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // The one conversation-plus-panel frame both shipped viewers render
7
+ // (SessionViewer + NewSessionViewer), so the two surfaces cannot drift.
8
+ // The contract under test:
9
+ // - `panel` is the single source of truth for collapsed state
10
+ // - the conversation never remounts across an open/close toggle
11
+ // - `responsive={false}` disables the below-`lg` conversation collapse
12
+ // (fixed-canvas hosts: docs embeds, video export)
13
+ // - no `splitStorageKey` means no localStorage read (deterministic hosts)
14
+ // ---------------------------------------------------------------------------
15
+
16
+ afterEach(() => {
17
+ cleanup();
18
+ vi.restoreAllMocks();
19
+ localStorage.clear();
20
+ });
21
+
22
+ function conversation() {
23
+ return <div data-testid="conversation">chat</div>;
24
+ }
25
+
26
+ function panel() {
27
+ return <div data-testid="panel">panel</div>;
28
+ }
29
+
30
+ describe("SessionViewerLayout", () => {
31
+ it("collapsed (panel null): conversation renders, panel absent, no separator exposed", () => {
32
+ render(<SessionViewerLayout conversation={conversation()} panel={null} />);
33
+ expect(screen.getByTestId("conversation").textContent).toBe("chat");
34
+ expect(screen.queryByTestId("panel")).toBeNull();
35
+ // The drag separator is CSS-hidden and aria-hidden while collapsed.
36
+ expect(screen.queryByRole("separator")).toBeNull();
37
+ });
38
+
39
+ it("open (panel provided): panel renders and the separator is operable", () => {
40
+ render(<SessionViewerLayout conversation={conversation()} panel={panel()} />);
41
+ expect(screen.getByTestId("panel").textContent).toBe("panel");
42
+ const separator = screen.getByRole("separator", { name: "Resize chat panel" });
43
+ expect(separator.getAttribute("tabindex")).toBe("0");
44
+ });
45
+
46
+ it("resizeAriaLabel names the separator in the host's vocabulary", () => {
47
+ render(
48
+ <SessionViewerLayout
49
+ conversation={conversation()}
50
+ panel={panel()}
51
+ resizeAriaLabel="Resize composer panel"
52
+ />,
53
+ );
54
+ expect(
55
+ screen.queryByRole("separator", { name: "Resize composer panel" }),
56
+ ).not.toBeNull();
57
+ });
58
+
59
+ it("never remounts the conversation across an open/close toggle", () => {
60
+ const { rerender } = render(
61
+ <SessionViewerLayout conversation={conversation()} panel={null} />,
62
+ );
63
+ const node = screen.getByTestId("conversation");
64
+
65
+ rerender(<SessionViewerLayout conversation={conversation()} panel={panel()} />);
66
+ expect(screen.getByTestId("conversation")).toBe(node);
67
+
68
+ rerender(<SessionViewerLayout conversation={conversation()} panel={null} />);
69
+ expect(screen.getByTestId("conversation")).toBe(node);
70
+ });
71
+
72
+ it("open panel sizes the conversation pane at the shared default width", () => {
73
+ render(<SessionViewerLayout conversation={conversation()} panel={panel()} />);
74
+ const pane = screen.getByTestId("conversation").parentElement!;
75
+ expect(pane.style.width).toBe("420px");
76
+ });
77
+
78
+ it("responsive (default): the open-panel conversation pane collapses below lg", () => {
79
+ const { container } = render(
80
+ <SessionViewerLayout conversation={conversation()} panel={panel()} />,
81
+ );
82
+ const pane = screen.getByTestId("conversation").parentElement!;
83
+ expect(pane.className).toContain("max-lg:hidden");
84
+ // The separator hides with it, so the panel is the full row below lg.
85
+ expect(container.querySelectorAll('[class*="max-lg:hidden"]').length).toBe(2);
86
+ });
87
+
88
+ it("responsive={false}: no below-lg collapse anywhere (fixed-canvas hosts)", () => {
89
+ const { container } = render(
90
+ <SessionViewerLayout
91
+ conversation={conversation()}
92
+ panel={panel()}
93
+ responsive={false}
94
+ />,
95
+ );
96
+ expect(container.querySelector('[class*="max-lg:hidden"]')).toBeNull();
97
+ });
98
+
99
+ it("reads no localStorage without a splitStorageKey (deterministic hosts)", () => {
100
+ const getItem = vi.spyOn(Storage.prototype, "getItem");
101
+ render(<SessionViewerLayout conversation={conversation()} panel={panel()} />);
102
+ expect(getItem).not.toHaveBeenCalled();
103
+ });
104
+
105
+ it("seeds the pane width from the host's splitStorageKey when given", () => {
106
+ localStorage.setItem("stgm-test-chat-width", "512");
107
+ render(
108
+ <SessionViewerLayout
109
+ conversation={conversation()}
110
+ panel={panel()}
111
+ splitStorageKey="stgm-test-chat-width"
112
+ />,
113
+ );
114
+ const pane = screen.getByTestId("conversation").parentElement!;
115
+ expect(pane.style.width).toBe("512px");
116
+ });
117
+
118
+ it("renders headerActions beside the chip in the top-right corner", () => {
119
+ render(
120
+ <SessionViewerLayout
121
+ conversation={conversation()}
122
+ panel={null}
123
+ headerActions={<button type="button">Share</button>}
124
+ chip={<button type="button">Toggle panel</button>}
125
+ />,
126
+ );
127
+ const actions = screen.getByRole("button", { name: "Share" });
128
+ const chip = screen.getByRole("button", { name: "Toggle panel" });
129
+ expect(actions.parentElement).toBe(chip.parentElement);
130
+ });
131
+
132
+ it("renders no control corner when both chip and headerActions are omitted", () => {
133
+ const { container } = render(
134
+ <SessionViewerLayout conversation={conversation()} panel={null} />,
135
+ );
136
+ // The corner is the only `top-2`-positioned element in the layout (the
137
+ // separator's inner hit target is also `absolute`, so match the corner).
138
+ expect(container.querySelector('[class*="top-2"]')).toBeNull();
139
+ });
140
+ });