@workerdeck/ui 3.2.0 → 3.4.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.
@@ -1,4 +1,4 @@
1
- import { r as SessionPanelProps } from "./SessionPanel-BNNc8grK.mjs";
1
+ import { r as SessionPanelProps } from "./SessionPanel-DIi4sEgh.mjs";
2
2
  import { CSSProperties } from "react";
3
3
  import { ShellInfo } from "@workerdeck/protocol";
4
4
  import { OpenFile, UseHostFileSearchResult, UseHostFileTreeResult } from "@workerdeck/react";
@@ -1,4 +1,4 @@
1
- import { $t as Spinner, En as Input, N as ShellTerminal, Zt as Splitter, i as SessionPanel, in as TooltipProvider, jn as cn, kn as Button, n as usePathLinks, nn as Tip, q as EntryIcon, t as resolveAgainstCwd } from "./use-path-links-C6Fh5zdx.mjs";
1
+ import { $t as Splitter, N as ShellTerminal, Nn as cn, On as Input, Y as EntryIcon, i as SessionPanel, in as Tip, jn as Button, n as usePathLinks, on as TooltipProvider, t as resolveAgainstCwd, tn as Spinner } from "./use-path-links-WCsysQPy.mjs";
2
2
  import { m as formatBytes } from "./status-kmsBhuFG.mjs";
3
3
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
4
4
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workerdeck/ui",
3
- "version": "3.2.0",
3
+ "version": "3.4.0",
4
4
  "type": "module",
5
5
  "description": "Styled agent-control component library for WorkerDeck: session panel, transcript with streaming, tool-call cards, permission prompts, session list, composer. Tailwind v4 + Base UI + cva. Pairs with the headless @workerdeck/react layer.",
6
6
  "license": "MIT",
@@ -39,9 +39,9 @@
39
39
  "streamdown": "^2.6.0",
40
40
  "tailwind-merge": "^3.7.0",
41
41
  "use-stick-to-bottom": "^1.1.6",
42
- "@workerdeck/client": "3.2.0",
43
- "@workerdeck/protocol": "3.2.0",
44
- "@workerdeck/react": "3.2.0"
42
+ "@workerdeck/client": "3.4.0",
43
+ "@workerdeck/react": "3.4.0",
44
+ "@workerdeck/protocol": "3.4.0"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "monaco-editor": "^0.56.0",
@@ -39,6 +39,8 @@ export type ComposerHandle = {
39
39
 
40
40
  export interface ComposerProps {
41
41
  onSend: (text: string, attachmentIds: string[]) => void
42
+ // Present while a foreground command the engine can background is running: ⌥↵ sends and moves it to the background.
43
+ onSendNow?: (text: string, attachmentIds: string[]) => void
42
44
  onInterrupt: () => void
43
45
  busy: boolean
44
46
  disabled?: boolean
@@ -70,6 +72,7 @@ export interface ComposerProps {
70
72
 
71
73
  export function Composer({
72
74
  onSend,
75
+ onSendNow,
73
76
  onInterrupt,
74
77
  busy,
75
78
  disabled,
@@ -184,7 +187,7 @@ export function Composer({
184
187
  rankPeerSessions(query, peers).map((peer) => ({
185
188
  value: peer.slug,
186
189
  label: peer.label,
187
- description: [peer.engine, peer.project, peer.status].filter(Boolean).join(' · '),
190
+ description: [peer.gateway, peer.engine, peer.project, peer.status].filter(Boolean).join(' · '),
188
191
  icon: <Users className="size-3.5 text-fg-3" />,
189
192
  })),
190
193
  onSelect: (suggestion) => suggestion.value,
@@ -214,7 +217,7 @@ export function Composer({
214
217
  const staged = attachments?.items ?? []
215
218
  const canSend = !disabled && (!isEmpty || staged.length > 0) && !attachments?.uploading && !attachments?.hasFailure
216
219
 
217
- const submit = () => {
220
+ const submit = (now = false) => {
218
221
  if (!canSend) {
219
222
  return
220
223
  }
@@ -230,7 +233,8 @@ export function Composer({
230
233
  focus()
231
234
  return
232
235
  }
233
- onSend(plainText.trim(), attachments?.readyIds ?? [])
236
+ const deliver = now && onSendNow ? onSendNow : onSend
237
+ deliver(plainText.trim(), attachments?.readyIds ?? [])
234
238
  attachments?.clear()
235
239
  draft?.clear()
236
240
  clear()
@@ -275,6 +279,11 @@ export function Composer({
275
279
  setHelpOpen(true)
276
280
  return
277
281
  }
282
+ if (e.key === 'Enter' && e.altKey && onSendNow && canSend) {
283
+ e.preventDefault()
284
+ submit(true)
285
+ return
286
+ }
278
287
  if (helpOpen) {
279
288
  setHelpOpen(false)
280
289
  }
@@ -351,9 +360,16 @@ export function Composer({
351
360
  </span>
352
361
  )
353
362
 
363
+ const sendNow =
364
+ onSendNow && canSend && !shellMode ? (
365
+ <button type="button" className="term-press term-link text-label" onClick={() => submit(true)}>
366
+ ⌥↵ send now - moves the running command to the background
367
+ </button>
368
+ ) : null
369
+
354
370
  const interrupting = busy && !canSend
355
371
  const submitButton = terminal ? (
356
- <GlyphButton label="Send" disabled={!canSend} onClick={submit} tone={canSend ? 'blue' : undefined}>
372
+ <GlyphButton label="Send" disabled={!canSend} onClick={() => submit()} tone={canSend ? 'blue' : undefined}>
357
373
  ↵
358
374
  </GlyphButton>
359
375
  ) : interrupting ? (
@@ -361,7 +377,7 @@ export function Composer({
361
377
  <Square className="size-3" />
362
378
  </Button>
363
379
  ) : (
364
- <Button size="icon-sm" aria-label="Send" className="rounded-full" disabled={!canSend} onClick={submit}>
380
+ <Button size="icon-sm" aria-label="Send" className="rounded-full" disabled={!canSend} onClick={() => submit()}>
365
381
  <ArrowUp className="size-4" />
366
382
  </Button>
367
383
  )
@@ -402,7 +418,7 @@ export function Composer({
402
418
  triggers={triggers}
403
419
  markdown={false}
404
420
  normalizeBullets={false}
405
- onSubmit={submit}
421
+ onSubmit={() => submit()}
406
422
  disabled={disabled}
407
423
  placeholder={disabled ? 'Session ended' : shellMode ? SHELL_PLACEHOLDER : placeholder}
408
424
  minHeight={minHeight}
@@ -428,6 +444,12 @@ export function Composer({
428
444
  className={cn('term-composer', shellMode && 'term-composer-shell', disabled && 'opacity-60')}
429
445
  >
430
446
  <div className="term-composer-body">
447
+ {sendNow ? (
448
+ <div className="term-row">
449
+ <span aria-hidden className="term-gutter" />
450
+ {sendNow}
451
+ </div>
452
+ ) : null}
431
453
  {stagedStrip}
432
454
  <div className="term-row">
433
455
  {canAttach && !shellMode ? fileField : null}
@@ -464,6 +486,7 @@ export function Composer({
464
486
  return (
465
487
  <div data-slot="composer" className={cn('px-[var(--wd-composer-padding)] pb-[var(--wd-composer-padding)]', className)}>
466
488
  {helpRow}
489
+ {sendNow ? <div className="mx-auto mb-1 w-full max-w-[var(--wd-transcript-max-width)] text-text-muted">{sendNow}</div> : null}
467
490
  <div
468
491
  {...dropHandlers}
469
492
  className={cn(
@@ -0,0 +1,64 @@
1
+ import type { EngineCapabilities, ModelOption } from '@workerdeck/protocol'
2
+ import { Brain } from 'lucide-react'
3
+ import { Select, SelectContent, SelectItem, SelectItemText, SelectTrigger, SelectValue } from '../ui/Select.tsx'
4
+ import { cn } from '../../lib/utils.ts'
5
+ import { matchModel } from './ModelSelect.tsx'
6
+
7
+ const DEFAULT_VALUE = 'default'
8
+
9
+ export function effortChoices(
10
+ models: readonly ModelOption[],
11
+ model: string | undefined,
12
+ capabilities: Pick<EngineCapabilities, 'reasoningEfforts'>,
13
+ ): readonly string[] {
14
+ return matchModel(models, model)?.reasoningEfforts ?? capabilities.reasoningEfforts ?? []
15
+ }
16
+
17
+ export interface EffortSelectProps {
18
+ efforts: readonly string[]
19
+ effort?: string | null
20
+ onEffortChange: (effort?: string) => void
21
+ variant?: 'toolbar' | 'form'
22
+ disabled?: boolean
23
+ className?: string
24
+ }
25
+
26
+ export function EffortSelect({ efforts, effort, onEffortChange, variant = 'toolbar', disabled, className }: EffortSelectProps) {
27
+ const items = [{ value: DEFAULT_VALUE, label: 'Default' }, ...efforts.map((value) => ({ value, label: value }))]
28
+ return (
29
+ <Select
30
+ items={items}
31
+ value={effort ?? null}
32
+ onValueChange={(value) => {
33
+ if (typeof value !== 'string' || value === effort) {
34
+ return
35
+ }
36
+ onEffortChange(value === DEFAULT_VALUE ? undefined : value)
37
+ }}
38
+ disabled={disabled}
39
+ >
40
+ <SelectTrigger
41
+ aria-label="Reasoning effort"
42
+ className={cn(
43
+ variant === 'toolbar' && 'h-6 max-w-32 border-transparent bg-transparent text-fg-3 hover:bg-surface-hover',
44
+ className,
45
+ )}
46
+ >
47
+ <span className={cn('flex items-center gap-1 truncate', variant === 'toolbar' && 'font-mono text-label')}>
48
+ <Brain className="size-3 shrink-0" />
49
+ <SelectValue placeholder="effort" />
50
+ </span>
51
+ </SelectTrigger>
52
+ <SelectContent className="min-w-40">
53
+ {items.map((item) => (
54
+ <SelectItem key={item.value} value={item.value}>
55
+ <SelectItemText>
56
+ <span className="font-medium">{item.label}</span>
57
+ </SelectItemText>
58
+ {item.value === DEFAULT_VALUE ? <span className="text-label text-fg-4">The model's configured default</span> : null}
59
+ </SelectItem>
60
+ ))}
61
+ </SelectContent>
62
+ </Select>
63
+ )
64
+ }
@@ -39,7 +39,7 @@ function optionMatches(option: ModelOption, model: string): boolean {
39
39
  return token !== '' && token === family(dropVariant(option.value))
40
40
  }
41
41
 
42
- function matchModel(models: ModelOption[], model?: string): ModelOption | undefined {
42
+ export function matchModel(models: readonly ModelOption[], model?: string): ModelOption | undefined {
43
43
  if (!model) {
44
44
  return undefined
45
45
  }
@@ -1,14 +1,15 @@
1
1
  import { useMemo, useState } from 'react'
2
- import type { ReactNode } from 'react'
3
- import { Eraser, FolderOpen, Layers, Pencil, Search, SearchX, Trash2, X } from 'lucide-react'
4
- import { clearFilters, filterRows, groupRows, hasFacetFilter, scopeActive, subsetSummary } from '@workerdeck/protocol'
5
- import type { SessionRow, SessionTask, StepDisplay, SubagentDisplay, ViewConfig, WorkspaceScope } from '@workerdeck/protocol'
2
+ import type { HTMLAttributes, ReactNode } from 'react'
3
+ import { BedDouble, Eraser, FolderOpen, Layers, Pencil, Plus, Search, SearchX, Trash2, X } from 'lucide-react'
4
+ import { clearFilters, filterRows, groupRows, hasFacetFilter, scopeActive, sessionKey, subsetSummary } from '@workerdeck/protocol'
5
+ import type { SessionGroup, SessionRow, SessionTask, StepDisplay, SubagentDisplay, ViewConfig, WorkspaceScope } from '@workerdeck/protocol'
6
6
  import { Button } from '../ui/Button.tsx'
7
7
  import { Empty } from '../ui/Empty.tsx'
8
8
  import { Input } from '../ui/Input.tsx'
9
9
  import { ProjectIcon } from './ProjectIcon.tsx'
10
10
  import { SessionFilters } from './SessionFilters.tsx'
11
11
  import { SessionItem, type SelectModifiers } from './SessionItem.tsx'
12
+ import { CustomGroupHeader, GroupHeading, HeadingAction, NewGroupButton, useGroupDrag } from './SessionGroups.tsx'
12
13
  import { cn } from '../../lib/utils.ts'
13
14
 
14
15
  export { SessionStatusIcon } from './SessionStatusIcon.tsx'
@@ -33,12 +34,15 @@ export interface SessionBrowserProps {
33
34
  onDelete?: (row: SessionRow) => void
34
35
  onRename?: (row: SessionRow, title: string) => void
35
36
  onClearContext?: (row: SessionRow) => void
37
+ onSleep?: (row: SessionRow) => void
36
38
  onSelectSubagent?: (row: SessionRow, toolUseId: string) => void
37
39
  onSelectTask?: (row: SessionRow, task: SessionTask) => void
38
40
  onStopTask?: (row: SessionRow, toolUseId: string) => void
39
41
  onSelectShell?: (row: SessionRow, shellId: string) => void
40
42
  onKillShell?: (row: SessionRow, shellId: string) => void
41
43
  onShellAgentWrite?: (row: SessionRow, shellId: string, enabled: boolean) => void
44
+ // Draws a `+` on each project heading: start a session on that gateway, in that project's root.
45
+ onCreateInGroup?: (target: GroupTarget) => void
42
46
  emptyState?: ReactNode
43
47
  // The facet controls, drawn inline. A host with a header puts `SessionFiltersButton` there instead.
44
48
  showControls?: boolean
@@ -48,6 +52,8 @@ export interface SessionBrowserProps {
48
52
  className?: string
49
53
  }
50
54
 
55
+ export type GroupTarget = { hostId: string; cwd?: string }
56
+
51
57
  export function rowShapeClass(active: boolean): string {
52
58
  return cn('px-2 py-1.5 hover:bg-row-hover', active ? 'mr-1 ml-0 rounded-r-md border-l-4 border-l-accent' : 'mx-1 rounded-md')
53
59
  }
@@ -69,12 +75,14 @@ export function SessionBrowser({
69
75
  onDelete,
70
76
  onRename,
71
77
  onClearContext,
78
+ onSleep,
72
79
  onSelectSubagent,
73
80
  onSelectTask,
74
81
  onStopTask,
75
82
  onSelectShell,
76
83
  onKillShell,
77
84
  onShellAgentWrite,
85
+ onCreateInGroup,
78
86
  emptyState,
79
87
  showControls = true,
80
88
  showSearch = showControls,
@@ -82,12 +90,65 @@ export function SessionBrowser({
82
90
  projectIcons,
83
91
  className,
84
92
  }: SessionBrowserProps) {
93
+ const rowGateways = useMemo(() => new Set(rows.map((row) => row.hostId)).size, [rows])
94
+ const gateways = gatewayCount ?? rowGateways
85
95
  const visible = useMemo(() => filterRows(rows, config, scope), [rows, config, scope])
86
- const groups = useMemo(() => groupRows(visible, config), [visible, config])
96
+ const groups = useMemo(() => groupRows(visible, config, { gatewayCount: gateways }), [visible, config, gateways])
87
97
  const subset = subsetSummary(config, scope, visible.length, rows.length)
88
- const rowGateways = useMemo(() => new Set(rows.map((row) => row.hostId)).size, [rows])
98
+ const custom = config.groupBy === 'custom'
99
+ const [editingGroup, setEditingGroup] = useState<string>()
89
100
 
90
101
  const set = (patch: Partial<ViewConfig>) => onConfigChange({ ...config, ...patch })
102
+ const drag = useGroupDrag(config.customGroups ?? [], (customGroups) => set({ customGroups }))
103
+
104
+ const heading = (group: SessionGroup) => {
105
+ if (custom) {
106
+ return (
107
+ <CustomGroupHeader
108
+ group={group}
109
+ editing={group.custom !== undefined && editingGroup === group.custom}
110
+ onEditingChange={(editing) => setEditingGroup(editing ? group.custom : undefined)}
111
+ onRename={(name) => group.custom && drag.rename(group.custom, name)}
112
+ onRemove={() => group.custom && drag.remove(group.custom)}
113
+ dragProps={drag.header(group)}
114
+ />
115
+ )
116
+ }
117
+ if (config.groupBy === 'none' || !group.label) {
118
+ return null
119
+ }
120
+ const project = config.groupBy === 'project'
121
+ const target = project && group.hostId ? { hostId: group.hostId, cwd: group.cwd } : undefined
122
+ return (
123
+ <GroupHeading
124
+ label={
125
+ group.gateway && group.project ? (
126
+ <>
127
+ {group.gateway}
128
+ <span aria-hidden> · </span>
129
+ <span className="text-fg-1">{group.project}</span>
130
+ </>
131
+ ) : (
132
+ group.label
133
+ )
134
+ }
135
+ count={group.rows.length}
136
+ title={project && group.cwd ? group.cwd : undefined}
137
+ leading={
138
+ project ? (
139
+ <ProjectIcon icon={group.rows[0]?.info.project?.icon} src={iconSrcOf(group.rows[0], projectIcons)} name={group.label} />
140
+ ) : undefined
141
+ }
142
+ actions={
143
+ target && onCreateInGroup ? (
144
+ <HeadingAction label={`New session in ${group.label}`} onClick={() => onCreateInGroup(target)}>
145
+ <Plus className="size-3" />
146
+ </HeadingAction>
147
+ ) : undefined
148
+ }
149
+ />
150
+ )
151
+ }
91
152
 
92
153
  return (
93
154
  <div data-slot="session-browser" className={cn('flex flex-col gap-3', className)}>
@@ -137,26 +198,29 @@ export function SessionBrowser({
137
198
  ) : (
138
199
  <div className="flex flex-col gap-4 px-1">
139
200
  {groups.map((group) => (
140
- <div key={group.key} className="flex flex-col gap-1">
141
- {config.groupBy !== 'none' && group.label ? (
142
- <div className="flex items-center gap-2 px-2 text-label font-medium text-fg-4">
143
- {config.groupBy === 'project' ? (
144
- <ProjectIcon icon={group.rows[0]?.info.project?.icon} src={iconSrcOf(group.rows[0], projectIcons)} name={group.label} />
145
- ) : null}
146
- <span className="uppercase tracking-wide">{group.label}</span>
147
- <span className="text-fg-4/70">{group.rows.length}</span>
201
+ <div
202
+ key={group.key}
203
+ {...(custom ? drag.container(group) : {})}
204
+ className={cn('flex flex-col gap-1 rounded-md', custom && drag.isOver(`group:${group.key}`) && 'bg-row-hover/50')}
205
+ >
206
+ {heading(group)}
207
+ {custom && group.custom !== undefined && group.rows.length === 0 ? (
208
+ <div className="mx-1 rounded-md border border-dashed border-border px-2 py-2 text-center text-label text-fg-4">
209
+ Drag sessions here
148
210
  </div>
149
211
  ) : null}
150
212
  {group.rows.map((row) => (
151
213
  <SessionRowItem
152
- key={`${row.hostId}:${row.info.id}`}
214
+ key={sessionKey(row)}
215
+ dragProps={custom ? drag.session(sessionKey(row), group) : undefined}
216
+ dropTarget={custom && drag.isOver(`row:${sessionKey(row)}`)}
153
217
  row={row}
154
218
  active={isActive(row)}
155
219
  activeSubagentId={activeSubagentId}
156
220
  activeShellId={activeShellId}
157
221
  actions={rowActions?.(row)}
158
222
  now={now}
159
- showGateway={(gatewayCount ?? rowGateways) > 1 && config.groupBy !== 'gateway'}
223
+ showGateway={gateways > 1 && config.groupBy !== 'project'}
160
224
  showProject={config.groupBy !== 'project'}
161
225
  subagents={config.subagents}
162
226
  shells={config.shells}
@@ -166,6 +230,7 @@ export function SessionBrowser({
166
230
  onDelete={onDelete}
167
231
  onRename={onRename}
168
232
  onClearContext={onClearContext}
233
+ onSleep={onSleep}
169
234
  onSelectSubagent={onSelectSubagent}
170
235
  onSelectTask={onSelectTask}
171
236
  onStopTask={onStopTask}
@@ -176,6 +241,7 @@ export function SessionBrowser({
176
241
  ))}
177
242
  </div>
178
243
  ))}
244
+ {custom ? <NewGroupButton onClick={() => setEditingGroup(drag.create())} /> : null}
179
245
  </div>
180
246
  )}
181
247
  </div>
@@ -189,6 +255,8 @@ function iconSrcOf(row: SessionRow | undefined, icons: Record<string, string> |
189
255
 
190
256
  interface SessionRowItemProps {
191
257
  row: SessionRow
258
+ dragProps?: HTMLAttributes<HTMLDivElement>
259
+ dropTarget?: boolean
192
260
  active?: boolean
193
261
  actions?: ReactNode
194
262
  activeSubagentId?: string
@@ -204,6 +272,7 @@ interface SessionRowItemProps {
204
272
  onDelete?: (row: SessionRow) => void
205
273
  onRename?: (row: SessionRow, title: string) => void
206
274
  onClearContext?: (row: SessionRow) => void
275
+ onSleep?: (row: SessionRow) => void
207
276
  onSelectSubagent?: (row: SessionRow, toolUseId: string) => void
208
277
  onSelectTask?: (row: SessionRow, task: SessionTask) => void
209
278
  onStopTask?: (row: SessionRow, toolUseId: string) => void
@@ -214,6 +283,8 @@ interface SessionRowItemProps {
214
283
 
215
284
  function SessionRowItem({
216
285
  row,
286
+ dragProps,
287
+ dropTarget,
217
288
  active,
218
289
  actions,
219
290
  activeSubagentId,
@@ -229,6 +300,7 @@ function SessionRowItem({
229
300
  onDelete,
230
301
  onRename,
231
302
  onClearContext,
303
+ onSleep,
232
304
  onSelectSubagent,
233
305
  onSelectTask,
234
306
  onStopTask,
@@ -239,7 +311,7 @@ function SessionRowItem({
239
311
  const { info } = row
240
312
  const [editing, setEditing] = useState(false)
241
313
 
242
- return (
314
+ const item = (
243
315
  <SessionItem
244
316
  row={row}
245
317
  active={active === true}
@@ -279,6 +351,15 @@ function SessionRowItem({
279
351
  <Eraser className="size-3 text-fg-3" />
280
352
  </RowAction>
281
353
  ) : null}
354
+ {onSleep && info.capabilities?.engineSleep && info.status === 'idle' && !info.engineAsleep ? (
355
+ <RowAction
356
+ label="Sleep session"
357
+ title="Stop the engine process to free its memory - the session stays here and wakes on your next message"
358
+ onClick={() => onSleep(row)}
359
+ >
360
+ <BedDouble className="size-3 text-fg-3" />
361
+ </RowAction>
362
+ ) : null}
282
363
  {onDelete ? (
283
364
  <RowAction label="Close session" onClick={() => onDelete(row)}>
284
365
  <Trash2 className="size-3 text-fg-3" />
@@ -289,6 +370,14 @@ function SessionRowItem({
289
370
  }
290
371
  />
291
372
  )
373
+ if (!dragProps) {
374
+ return item
375
+ }
376
+ return (
377
+ <div {...dragProps} className={cn('rounded-md', dropTarget && 'shadow-[inset_0_2px_0_0_var(--color-accent)]')}>
378
+ {item}
379
+ </div>
380
+ )
292
381
  }
293
382
 
294
383
  function RowAction({ label, title, onClick, children }: { label: string; title?: string; onClick: () => void; children: ReactNode }) {
@@ -106,8 +106,8 @@ export function SessionFilters({ config, onConfigChange, rows, scope, gateways:
106
106
  { value: 'none', label: 'No grouping' },
107
107
  { value: 'state', label: 'By state' },
108
108
  { value: 'adapter', label: 'By engine' },
109
- ...(projects.length > 1 ? [{ value: 'project' as const, label: 'By project' }] : []),
110
- ...(gateways.length > 1 ? [{ value: 'gateway' as const, label: 'By gateway' }] : []),
109
+ { value: 'project', label: gateways.length > 1 ? 'By gateway & project' : 'By project' },
110
+ { value: 'custom', label: 'Custom' },
111
111
  ]}
112
112
  onChange={(groupBy) => set({ groupBy: groupBy as GroupBy })}
113
113
  />