@stigmer/react 1.0.2 → 1.0.4

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 (57) hide show
  1. package/agent/__tests__/useAgent.test.d.ts +2 -0
  2. package/agent/__tests__/useAgent.test.d.ts.map +1 -0
  3. package/agent/__tests__/useAgent.test.js +86 -0
  4. package/agent/__tests__/useAgent.test.js.map +1 -0
  5. package/agent/__tests__/useDefaultAgent.test.js +106 -223
  6. package/agent/__tests__/useDefaultAgent.test.js.map +1 -1
  7. package/agent/useDefaultAgent.d.ts +9 -0
  8. package/agent/useDefaultAgent.d.ts.map +1 -1
  9. package/agent/useDefaultAgent.js +35 -5
  10. package/agent/useDefaultAgent.js.map +1 -1
  11. package/composer/ComposerToolbar.d.ts +18 -16
  12. package/composer/ComposerToolbar.d.ts.map +1 -1
  13. package/composer/ComposerToolbar.js +10 -11
  14. package/composer/ComposerToolbar.js.map +1 -1
  15. package/composer/ConfigureMenu.d.ts.map +1 -1
  16. package/composer/ConfigureMenu.js +1 -1
  17. package/composer/ConfigureMenu.js.map +1 -1
  18. package/composer/ContextPopover.d.ts +1 -3
  19. package/composer/ContextPopover.d.ts.map +1 -1
  20. package/composer/ContextPopover.js +2 -2
  21. package/composer/ContextPopover.js.map +1 -1
  22. package/composer/SessionComposer.js +5 -5
  23. package/composer/SessionComposer.js.map +1 -1
  24. package/composer/icons.js +3 -3
  25. package/internal/withTimeout.d.ts +8 -0
  26. package/internal/withTimeout.d.ts.map +1 -0
  27. package/internal/withTimeout.js +19 -0
  28. package/internal/withTimeout.js.map +1 -0
  29. package/package.json +4 -4
  30. package/session/__tests__/useCreateSession.test.js +99 -191
  31. package/session/__tests__/useCreateSession.test.js.map +1 -1
  32. package/session/__tests__/useNewSessionFlow.test.js +71 -0
  33. package/session/__tests__/useNewSessionFlow.test.js.map +1 -1
  34. package/session/__tests__/useSession.test.js +71 -108
  35. package/session/__tests__/useSession.test.js.map +1 -1
  36. package/session/__tests__/useSessionList.test.d.ts +2 -0
  37. package/session/__tests__/useSessionList.test.d.ts.map +1 -0
  38. package/session/__tests__/useSessionList.test.js +63 -0
  39. package/session/__tests__/useSessionList.test.js.map +1 -0
  40. package/session/useNewSessionFlow.d.ts.map +1 -1
  41. package/session/useNewSessionFlow.js +13 -7
  42. package/session/useNewSessionFlow.js.map +1 -1
  43. package/src/agent/__tests__/useAgent.test.tsx +116 -0
  44. package/src/agent/__tests__/useDefaultAgent.test.tsx +115 -240
  45. package/src/agent/useDefaultAgent.ts +53 -2
  46. package/src/composer/ComposerToolbar.tsx +76 -96
  47. package/src/composer/ConfigureMenu.tsx +16 -14
  48. package/src/composer/ContextPopover.tsx +11 -11
  49. package/src/composer/SessionComposer.tsx +6 -6
  50. package/src/composer/icons.tsx +6 -6
  51. package/src/internal/withTimeout.ts +25 -0
  52. package/src/session/__tests__/useCreateSession.test.tsx +114 -235
  53. package/src/session/__tests__/useNewSessionFlow.test.tsx +96 -1
  54. package/src/session/__tests__/useSession.test.tsx +82 -141
  55. package/src/session/__tests__/useSessionList.test.tsx +86 -0
  56. package/src/session/useNewSessionFlow.ts +18 -9
  57. package/styles.css +1 -1
@@ -20,21 +20,31 @@ export interface ComposerToolbarProps {
20
20
  readonly canSend: boolean;
21
21
  readonly onSend: () => void;
22
22
 
23
- // -- Tier 1: Workspace ----------------------------------------------------
23
+ // -- Left group: Primary state --------------------------------------------
24
+
25
+ readonly showHarnessSelector: boolean;
26
+ readonly harness?: HarnessOption;
27
+ readonly onHarnessChange: (harness: HarnessOption) => void;
28
+
29
+ readonly showInteractionModePicker: boolean;
30
+ readonly interactionMode?: InteractionModeOption;
31
+ readonly onInteractionModeChange: (mode: InteractionModeOption) => void;
32
+
33
+ readonly showModelSelector: boolean;
34
+ readonly modelId?: string;
35
+ readonly onModelChange: (id: string) => void;
36
+
37
+ // -- Right group: Secondary actions (icon-only) ---------------------------
24
38
 
25
39
  readonly showWorkspace: boolean;
26
40
  readonly workspaceCount: number;
27
41
  /** Pre-built workspace editor content for the popover. */
28
42
  readonly workspaceContent: React.ReactNode;
29
43
 
30
- // -- Tier 1: Attach -------------------------------------------------------
31
-
32
44
  readonly showAttach: boolean;
33
45
  readonly attachmentCount: number;
34
46
  readonly onAttachClick: () => void;
35
47
 
36
- // -- Tier 2: Configure menu -----------------------------------------------
37
-
38
48
  readonly configureItems: readonly ConfigureMenuItem[];
39
49
  readonly configOpen: boolean;
40
50
  readonly onConfigOpenChange: (open: boolean) => void;
@@ -42,39 +52,23 @@ export interface ComposerToolbarProps {
42
52
  readonly onConfigActivePanelChange: (panel: string | null) => void;
43
53
  /** Render the picker content for a given configure panel id. */
44
54
  readonly renderConfigPanel: (itemId: string) => React.ReactNode;
45
-
46
- // -- Harness selector -----------------------------------------------------
47
-
48
- readonly showHarnessSelector: boolean;
49
- readonly harness?: HarnessOption;
50
- readonly onHarnessChange: (harness: HarnessOption) => void;
51
-
52
- // -- Interaction mode picker ------------------------------------------------
53
-
54
- readonly showInteractionModePicker: boolean;
55
- readonly interactionMode?: InteractionModeOption;
56
- readonly onInteractionModeChange: (mode: InteractionModeOption) => void;
57
-
58
- // -- Model selector -------------------------------------------------------
59
-
60
- readonly showModelSelector: boolean;
61
- readonly modelId?: string;
62
- readonly onModelChange: (id: string) => void;
63
55
  }
64
56
 
65
57
  /**
66
58
  * Composer toolbar — Zone 3 of the SessionComposer.
67
59
  *
68
- * Renders a two-tier toolbar following the frequency-of-interaction principle:
60
+ * Layout follows a two-group pattern inspired by Cursor's compact approach:
69
61
  *
70
- * **Tier 1 (always visible):** Workspace, Attach
71
- * **Tier 2 (behind Configure menu):** Agent, MCP, Skills, Secrets
72
- * **Right edge:** Runner Picker, Model Selector, Send
62
+ * **Left group (primary state):** Interaction Mode, Model Selector
63
+ * **Right group (secondary actions, icon-only):** Workspace, Attach, Configure, Send
73
64
  *
74
- * Workspace precedes Attach because it is the higher-signal context setter
75
- * (defines the codebase scope for the session). Attach is supplementary.
65
+ * Primary state indicators retain text labels (users glance at mode and model
66
+ * frequently). Secondary actions use icon-only buttons with tooltips and
67
+ * aria-labels — they are actions triggered occasionally, not state to monitor.
76
68
  *
77
- * Separators are placed between conceptual groups using Gestalt proximity.
69
+ * This separation follows Fitts's Law (related actions clustered near Send),
70
+ * Gestalt proximity (left = "what," right = "do"), and Nielsen H8 (minimal
71
+ * visual weight for secondary controls).
78
72
  */
79
73
  export function ComposerToolbar({
80
74
  disabled,
@@ -103,23 +97,50 @@ export function ComposerToolbar({
103
97
  modelId,
104
98
  onModelChange,
105
99
  }: ComposerToolbarProps) {
106
- const hasTier1 = showAttach || showWorkspace;
107
- const hasTier2 = configureItems.length > 0;
108
100
  const showHarnessSeparate = showHarnessSelector && !showModelSelector;
109
- const hasExecParams = showHarnessSeparate || showInteractionModePicker || showModelSelector;
110
101
 
111
102
  return (
112
103
  <div className="flex items-center justify-between gap-2 border-t border-border-muted px-3 py-2">
113
- <div className="flex items-center gap-1.5">
114
- {/* ---- Tier 1: Input context (Workspace first, then Attach) ---- */}
104
+ {/* ---- Left group: Primary state (Mode + Model) ---- */}
105
+
106
+ <div className="flex min-w-0 items-center gap-1.5">
107
+ {showInteractionModePicker && (
108
+ <InteractionModePicker
109
+ value={interactionMode ?? "agent"}
110
+ onValueChange={onInteractionModeChange}
111
+ disabled={disabled}
112
+ />
113
+ )}
114
+
115
+ {showHarnessSeparate && (
116
+ <HarnessSelector
117
+ value={harness ?? "native"}
118
+ onValueChange={onHarnessChange}
119
+ disabled={disabled}
120
+ />
121
+ )}
122
+
123
+ {showModelSelector && (
124
+ <ModelSelector
125
+ value={modelId}
126
+ onValueChange={onModelChange}
127
+ harness={showHarnessSelector ? undefined : harness}
128
+ initialHarness={showHarnessSelector ? harness : undefined}
129
+ onHarnessChange={showHarnessSelector ? onHarnessChange : undefined}
130
+ disabled={disabled}
131
+ />
132
+ )}
133
+ </div>
134
+
135
+ {/* ---- Right group: Secondary actions (icon-only) + Send ---- */}
115
136
 
137
+ <div className="flex shrink-0 items-center gap-1">
116
138
  {showWorkspace && (
117
139
  <ContextPopover
118
140
  icon={<WorkspaceIcon />}
119
141
  label="Workspace"
120
142
  count={workspaceCount}
121
143
  disabled={disabled}
122
- hideLabel
123
144
  >
124
145
  {workspaceContent}
125
146
  </ContextPopover>
@@ -130,31 +151,25 @@ export function ComposerToolbar({
130
151
  type="button"
131
152
  disabled={disabled}
132
153
  onClick={onAttachClick}
154
+ title="Attach files"
133
155
  className={cn(
134
- "inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-xs transition-colors",
156
+ "inline-flex h-8 w-8 items-center justify-center rounded-md text-xs transition-colors",
135
157
  "text-muted-foreground hover:text-foreground hover:bg-accent-hover",
136
158
  "disabled:pointer-events-none disabled:opacity-50",
137
159
  )}
138
160
  aria-label="Attach files"
139
161
  >
140
- <PaperclipIcon />
141
- <span className="max-sm:hidden">Attach</span>
142
- {attachmentCount > 0 && (
143
- <span className="rounded-full bg-primary-subtle px-1.5 text-[0.6rem] font-medium text-primary">
144
- {attachmentCount}
145
- </span>
146
- )}
162
+ <span className="relative">
163
+ <PaperclipIcon />
164
+ {attachmentCount > 0 && (
165
+ <span className="absolute -right-1.5 -top-1.5 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-0.5 text-[0.5rem] font-medium leading-none text-primary-foreground">
166
+ {attachmentCount}
167
+ </span>
168
+ )}
169
+ </span>
147
170
  </button>
148
171
  )}
149
172
 
150
- {/* ---- Separator between Tier 1 and Tier 2 ---- */}
151
-
152
- {hasTier1 && hasTier2 && (
153
- <div className="mx-0.5 h-4 w-px bg-border/50" aria-hidden="true" />
154
- )}
155
-
156
- {/* ---- Tier 2: Agent configuration (behind Configure menu) ---- */}
157
-
158
173
  <ConfigureMenu
159
174
  open={configOpen}
160
175
  onOpenChange={onConfigOpenChange}
@@ -165,51 +180,16 @@ export function ComposerToolbar({
165
180
  disabled={disabled}
166
181
  />
167
182
 
168
- {/* ---- Separator before execution parameters ---- */}
169
-
170
- {(hasTier1 || hasTier2) && hasExecParams && (
171
- <div className="mx-0.5 h-4 w-px bg-border/50" aria-hidden="true" />
172
- )}
173
-
174
- {showHarnessSeparate && (
175
- <HarnessSelector
176
- value={harness ?? "native"}
177
- onValueChange={onHarnessChange}
178
- disabled={disabled}
179
- />
180
- )}
181
-
182
- {showInteractionModePicker && (
183
- <InteractionModePicker
184
- value={interactionMode ?? "agent"}
185
- onValueChange={onInteractionModeChange}
186
- disabled={disabled}
187
- />
188
- )}
189
-
190
- {showModelSelector && (
191
- <ModelSelector
192
- value={modelId}
193
- onValueChange={onModelChange}
194
- harness={showHarnessSelector ? undefined : harness}
195
- initialHarness={showHarnessSelector ? harness : undefined}
196
- onHarnessChange={showHarnessSelector ? onHarnessChange : undefined}
197
- disabled={disabled}
198
- />
199
- )}
183
+ <button
184
+ type="button"
185
+ disabled={!canSend}
186
+ onClick={onSend}
187
+ className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground transition-colors hover:bg-primary-hover disabled:pointer-events-none disabled:opacity-40"
188
+ aria-label="Send message"
189
+ >
190
+ {isSubmitting ? <SpinnerIcon /> : <ArrowUpIcon />}
191
+ </button>
200
192
  </div>
201
-
202
- {/* ---- Send button ---- */}
203
-
204
- <button
205
- type="button"
206
- disabled={!canSend}
207
- onClick={onSend}
208
- className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground transition-colors hover:bg-primary-hover disabled:pointer-events-none disabled:opacity-40"
209
- aria-label="Send message"
210
- >
211
- {isSubmitting ? <SpinnerIcon /> : <ArrowUpIcon />}
212
- </button>
213
193
  </div>
214
194
  );
215
195
  }
@@ -73,26 +73,28 @@ export function ConfigureMenu({
73
73
  <Popover.Root open={open} onOpenChange={handleOpenChange}>
74
74
  <Popover.Trigger
75
75
  disabled={disabled}
76
+ title="Configure"
76
77
  className={cn(
77
- "inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-xs transition-colors",
78
+ "inline-flex h-8 w-8 items-center justify-center rounded-md text-xs transition-colors",
78
79
  "text-muted-foreground hover:text-foreground hover:bg-accent-hover",
79
80
  "disabled:pointer-events-none disabled:opacity-50",
80
81
  )}
81
82
  aria-label="Configure agent, tools, and skills"
82
83
  >
83
- <ConfigureIcon />
84
- <span className="max-sm:hidden">Configure</span>
85
- {totalCount > 0 && (
86
- <span className="rounded-full bg-primary-subtle px-1.5 text-[0.6rem] font-medium text-primary">
87
- {totalCount}
88
- </span>
89
- )}
90
- {hasWarning && totalCount === 0 && (
91
- <span
92
- className="inline-block h-1.5 w-1.5 shrink-0 rounded-full bg-warning"
93
- aria-label="Configuration needed"
94
- />
95
- )}
84
+ <span className="relative">
85
+ <ConfigureIcon />
86
+ {totalCount > 0 && (
87
+ <span className="absolute -right-1.5 -top-1.5 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-0.5 text-[0.5rem] font-medium leading-none text-primary-foreground">
88
+ {totalCount}
89
+ </span>
90
+ )}
91
+ {hasWarning && totalCount === 0 && (
92
+ <span
93
+ className="absolute -right-0.5 -top-0.5 inline-block h-2 w-2 rounded-full bg-warning"
94
+ aria-label="Configuration needed"
95
+ />
96
+ )}
97
+ </span>
96
98
  </Popover.Trigger>
97
99
  <Popover.Portal container={portalContainer}>
98
100
  <Popover.Positioner sideOffset={8} align="start">
@@ -10,7 +10,6 @@ export function ContextPopover({
10
10
  disabled,
11
11
  open,
12
12
  onOpenChange,
13
- hideLabel,
14
13
  }: {
15
14
  icon: React.ReactNode;
16
15
  label: string;
@@ -19,8 +18,6 @@ export function ContextPopover({
19
18
  disabled?: boolean;
20
19
  open?: boolean;
21
20
  onOpenChange?: (open: boolean) => void;
22
- /** When true, hides the text label on small viewports (icon-only). */
23
- hideLabel?: boolean;
24
21
  }) {
25
22
  const portalContainer = useStigmerPortalContainer();
26
23
 
@@ -28,19 +25,22 @@ export function ContextPopover({
28
25
  <Popover.Root open={open} onOpenChange={onOpenChange}>
29
26
  <Popover.Trigger
30
27
  disabled={disabled}
28
+ title={label}
29
+ aria-label={label}
31
30
  className={cn(
32
- "inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-xs transition-colors",
31
+ "inline-flex h-8 w-8 items-center justify-center rounded-md text-xs transition-colors",
33
32
  "text-muted-foreground hover:text-foreground hover:bg-accent-hover",
34
33
  "disabled:pointer-events-none disabled:opacity-50",
35
34
  )}
36
35
  >
37
- {icon}
38
- <span className={cn(hideLabel && "max-sm:hidden")}>{label}</span>
39
- {count > 0 && (
40
- <span className="rounded-full bg-primary-subtle px-1.5 text-[0.6rem] font-medium text-primary">
41
- {count}
42
- </span>
43
- )}
36
+ <span className="relative">
37
+ {icon}
38
+ {count > 0 && (
39
+ <span className="absolute -right-1.5 -top-1.5 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-0.5 text-[0.5rem] font-medium leading-none text-primary-foreground">
40
+ {count}
41
+ </span>
42
+ )}
43
+ </span>
44
44
  </Popover.Trigger>
45
45
  <Popover.Portal container={portalContainer}>
46
46
  <Popover.Positioner sideOffset={8} align="start">
@@ -381,9 +381,9 @@ export interface SessionComposerProps {
381
381
  * Combines a self-resizing textarea, model selector, and context pickers
382
382
  * (agent, workspace, MCP servers, skills) into a single input card.
383
383
  *
384
- * The toolbar uses a two-tier layout:
385
- * - **Tier 1** (always visible): Workspace, Attach, Model Selector
386
- * - **Tier 2** (behind Configure menu): Agent, MCP, Skills, Secrets
384
+ * The toolbar uses a two-group layout:
385
+ * - **Left (primary state):** Interaction Mode, Model Selector
386
+ * - **Right (secondary actions, icon-only):** Workspace, Attach, Configure (Agent, MCP, Skills, Secrets), Send
387
387
  *
388
388
  * Selected items render as removable chips between the textarea and toolbar.
389
389
  *
@@ -1510,9 +1510,6 @@ const SessionComposerInner = forwardRef<SessionComposerHandle, SessionComposerPr
1510
1510
  isSubmitting={isSubmitting}
1511
1511
  canSend={canSend}
1512
1512
  onSend={composer.submit}
1513
- showAttach={showAttach}
1514
- attachmentCount={attachments.entries.length}
1515
- onAttachClick={() => fileInputRef.current?.click()}
1516
1513
  showWorkspace={showWorkspace}
1517
1514
  workspaceCount={workspaceCount}
1518
1515
  workspaceContent={
@@ -1540,6 +1537,9 @@ const SessionComposerInner = forwardRef<SessionComposerHandle, SessionComposerPr
1540
1537
  </div>
1541
1538
  : null
1542
1539
  }
1540
+ showAttach={showAttach}
1541
+ attachmentCount={attachments.entries.length}
1542
+ onAttachClick={() => fileInputRef.current?.click()}
1543
1543
  configureItems={configureItems}
1544
1544
  configOpen={configOpen}
1545
1545
  onConfigOpenChange={handleConfigOpenChange}
@@ -93,8 +93,8 @@ export function XIcon() {
93
93
  export function PaperclipIcon() {
94
94
  return (
95
95
  <svg
96
- width="14"
97
- height="14"
96
+ width="16"
97
+ height="16"
98
98
  viewBox="0 0 16 16"
99
99
  fill="none"
100
100
  stroke="currentColor"
@@ -133,8 +133,8 @@ export function AgentIcon() {
133
133
  export function WorkspaceIcon() {
134
134
  return (
135
135
  <svg
136
- width="14"
137
- height="14"
136
+ width="16"
137
+ height="16"
138
138
  viewBox="0 0 14 14"
139
139
  fill="none"
140
140
  stroke="currentColor"
@@ -238,8 +238,8 @@ export function RunnerIcon() {
238
238
  export function ConfigureIcon() {
239
239
  return (
240
240
  <svg
241
- width="14"
242
- height="14"
241
+ width="16"
242
+ height="16"
243
243
  viewBox="0 0 16 16"
244
244
  fill="none"
245
245
  stroke="currentColor"
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Races a promise against a timer. Rejects with `message` if the timer
3
+ * fires before the promise settles.
4
+ *
5
+ * @internal Not part of the public `@stigmer/react` API.
6
+ */
7
+ export function withTimeout<T>(
8
+ promise: Promise<T>,
9
+ ms: number,
10
+ message: string,
11
+ ): Promise<T> {
12
+ return new Promise<T>((resolve, reject) => {
13
+ const timer = setTimeout(() => reject(new Error(message)), ms);
14
+ promise.then(
15
+ (value) => {
16
+ clearTimeout(timer);
17
+ resolve(value);
18
+ },
19
+ (reason) => {
20
+ clearTimeout(timer);
21
+ reject(reason);
22
+ },
23
+ );
24
+ });
25
+ }