@swarmclawai/swarmclaw 1.9.34 → 1.9.37

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.
@@ -144,6 +144,164 @@
144
144
  --command-header: rgba(0,0,0,0.3);
145
145
  }
146
146
 
147
+ .light {
148
+ --background: color-mix(in srgb, var(--neutral-tint) 10%, #fff);
149
+ --foreground: #1f2937;
150
+ --card: color-mix(in srgb, var(--neutral-tint) 8%, #fff);
151
+ --card-foreground: #1f2937;
152
+ --popover: color-mix(in srgb, var(--neutral-tint) 8%, #fff);
153
+ --popover-foreground: #1f2937;
154
+ --primary: #4f46e5;
155
+ --primary-foreground: #ffffff;
156
+ --secondary: color-mix(in srgb, var(--neutral-tint) 12%, #fff);
157
+ --secondary-foreground: #253047;
158
+ --muted: color-mix(in srgb, var(--neutral-tint) 10%, #fff);
159
+ --muted-foreground: #5f6b85;
160
+ --accent: #4f46e5;
161
+ --accent-foreground: #ffffff;
162
+ --destructive: #dc2626;
163
+ --border: rgba(31,41,55,0.12);
164
+ --input: rgba(31,41,55,0.10);
165
+ --ring: rgba(79,70,229,0.38);
166
+ --sidebar: color-mix(in srgb, var(--neutral-tint) 9%, #fff);
167
+ --sidebar-foreground: #1f2937;
168
+ --sidebar-primary: #4f46e5;
169
+ --sidebar-primary-foreground: #ffffff;
170
+ --sidebar-accent: rgba(79,70,229,0.09);
171
+ --sidebar-accent-foreground: #253047;
172
+ --sidebar-border: rgba(31,41,55,0.11);
173
+ --sidebar-ring: rgba(79,70,229,0.35);
174
+
175
+ --color-bg: color-mix(in srgb, var(--neutral-tint) 10%, #fff);
176
+ --color-raised: color-mix(in srgb, var(--neutral-tint) 8%, #fff);
177
+ --color-surface: color-mix(in srgb, var(--neutral-tint) 14%, #fff);
178
+ --color-surface-2: color-mix(in srgb, var(--neutral-tint) 20%, #fff);
179
+ --color-surface-3: color-mix(in srgb, var(--neutral-tint) 24%, #fff);
180
+ --color-border-hi: rgba(31,41,55,0.12);
181
+ --color-border-focus: rgba(79,70,229,0.45);
182
+ --color-text: #1f2937;
183
+ --color-text-2: #4b5568;
184
+ --color-text-3: #6b7280;
185
+ --color-accent-soft: rgba(79,70,229,0.09);
186
+ --color-accent-glow: rgba(79,70,229,0.16);
187
+ --color-accent-bright: #4f46e5;
188
+ --color-user-text: #fff;
189
+ --color-success: #059669;
190
+ --color-success-soft: rgba(5,150,105,0.10);
191
+ --color-danger: #dc2626;
192
+ --color-danger-soft: rgba(220,38,38,0.10);
193
+ --color-shereen: #db2777;
194
+ --color-user-bubble: #4f46e5;
195
+ --color-user-bubble-2: #6366f1;
196
+ --color-ai-bubble: #f4f6fb;
197
+ --color-glass: rgba(255,255,255,0.82);
198
+ --color-glass-border: rgba(31,41,55,0.10);
199
+
200
+ --status-idle-bg: rgba(31,41,55,0.05);
201
+ --status-idle-border: rgba(31,41,55,0.10);
202
+ --status-idle-fg: #5f6b85;
203
+ --status-running-bg: rgba(5,150,105,0.10);
204
+ --status-running-border: rgba(5,150,105,0.18);
205
+ --status-running-fg: #047857;
206
+ --status-error-bg: rgba(220,38,38,0.10);
207
+ --status-error-border: rgba(220,38,38,0.18);
208
+ --status-error-fg: #dc2626;
209
+ --status-connecting-bg: rgba(217,119,6,0.10);
210
+ --status-connecting-border: rgba(217,119,6,0.18);
211
+ --status-connecting-fg: #b45309;
212
+ --status-connected-bg: rgba(2,132,199,0.10);
213
+ --status-connected-border: rgba(2,132,199,0.18);
214
+ --status-connected-fg: #0369a1;
215
+ --status-approval-bg: rgba(234,88,12,0.10);
216
+ --status-approval-border: rgba(234,88,12,0.18);
217
+ --status-approval-fg: #c2410c;
218
+
219
+ --command-bg: #ffffff;
220
+ --command-border: rgba(31,41,55,0.12);
221
+ --command-header: rgba(31,41,55,0.04);
222
+ }
223
+
224
+ .light .bg-white\/\[0\.01\] { background-color: rgba(31,41,55,0.01) !important; }
225
+ .light .bg-white\/\[0\.02\] { background-color: rgba(31,41,55,0.025) !important; }
226
+ .light .bg-white\/\[0\.025\] { background-color: rgba(31,41,55,0.03) !important; }
227
+ .light .bg-white\/\[0\.03\] { background-color: rgba(31,41,55,0.035) !important; }
228
+ .light .bg-white\/\[0\.035\] { background-color: rgba(31,41,55,0.04) !important; }
229
+ .light .bg-white\/\[0\.04\] { background-color: rgba(31,41,55,0.045) !important; }
230
+ .light .bg-white\/\[0\.05\] { background-color: rgba(31,41,55,0.055) !important; }
231
+ .light .bg-white\/\[0\.06\] { background-color: rgba(31,41,55,0.065) !important; }
232
+ .light .bg-white\/\[0\.07\] { background-color: rgba(31,41,55,0.075) !important; }
233
+ .light .bg-white\/\[0\.08\] { background-color: rgba(31,41,55,0.085) !important; }
234
+ .light .bg-white\/\[0\.10\],
235
+ .light .bg-white\/\[0\.1\] { background-color: rgba(31,41,55,0.10) !important; }
236
+ .light .bg-white\/\[0\.12\] { background-color: rgba(31,41,55,0.12) !important; }
237
+ .light .bg-white\/\[0\.15\] { background-color: rgba(31,41,55,0.15) !important; }
238
+ .light .bg-white\/\[0\.16\] { background-color: rgba(31,41,55,0.16) !important; }
239
+ .light .bg-white\/\[0\.18\] { background-color: rgba(31,41,55,0.18) !important; }
240
+ .light .bg-white\/\[0\.2\],
241
+ .light .bg-white\/\[0\.20\] { background-color: rgba(31,41,55,0.20) !important; }
242
+ .light .bg-white\/\[0\.22\] { background-color: rgba(31,41,55,0.22) !important; }
243
+ .light .bg-white\/\[0\.45\] { background-color: rgba(31,41,55,0.45) !important; }
244
+
245
+ .light .border-white\/\[0\.03\] { border-color: rgba(31,41,55,0.07) !important; }
246
+ .light .border-white\/\[0\.04\] { border-color: rgba(31,41,55,0.08) !important; }
247
+ .light .border-white\/\[0\.05\] { border-color: rgba(31,41,55,0.09) !important; }
248
+ .light .border-white\/\[0\.06\] { border-color: rgba(31,41,55,0.10) !important; }
249
+ .light .border-white\/\[0\.07\] { border-color: rgba(31,41,55,0.11) !important; }
250
+ .light .border-white\/\[0\.08\] { border-color: rgba(31,41,55,0.12) !important; }
251
+ .light .border-white\/\[0\.10\],
252
+ .light .border-white\/\[0\.1\] { border-color: rgba(31,41,55,0.14) !important; }
253
+ .light .border-white\/\[0\.12\] { border-color: rgba(31,41,55,0.16) !important; }
254
+ .light .border-white\/\[0\.14\] { border-color: rgba(31,41,55,0.18) !important; }
255
+ .light .border-white\/\[0\.15\] { border-color: rgba(31,41,55,0.19) !important; }
256
+ .light .border-white\/\[0\.16\] { border-color: rgba(31,41,55,0.20) !important; }
257
+ .light .border-white\/\[0\.2\],
258
+ .light .border-white\/\[0\.20\] { border-color: rgba(31,41,55,0.24) !important; }
259
+ .light .border-white\/\[0\.25\] { border-color: rgba(31,41,55,0.28) !important; }
260
+ .light .border-white\/\[0\.4\],
261
+ .light .border-white\/\[0\.40\] { border-color: rgba(31,41,55,0.42) !important; }
262
+ .light .divide-white\/\[0\.04\] > :not(:last-child) { border-color: rgba(31,41,55,0.08) !important; }
263
+ .light .divide-white\/\[0\.05\] > :not(:last-child) { border-color: rgba(31,41,55,0.09) !important; }
264
+ .light .ring-white\/\[0\.08\] { --tw-ring-color: rgba(31,41,55,0.14) !important; }
265
+
266
+ html.light,
267
+ html.light body {
268
+ color-scheme: light;
269
+ }
270
+
271
+ html.light body {
272
+ background: color-mix(in srgb, var(--neutral-tint) 8%, #fff);
273
+ color: #1f2937;
274
+ }
275
+
276
+ html.light .bg-bg { background-color: color-mix(in srgb, var(--neutral-tint) 8%, #fff) !important; }
277
+ html.light .bg-raised { background-color: color-mix(in srgb, var(--neutral-tint) 10%, #fff) !important; }
278
+ html.light .bg-surface { background-color: color-mix(in srgb, var(--neutral-tint) 14%, #fff) !important; }
279
+ html.light .bg-surface-2 { background-color: color-mix(in srgb, var(--neutral-tint) 18%, #fff) !important; }
280
+ html.light .bg-glass { background-color: rgba(255,255,255,0.88) !important; }
281
+ html.light .text-text { color: #1f2937 !important; }
282
+ html.light .text-text-2 { color: #4b5568 !important; }
283
+ html.light .text-text-3 { color: #6b7280 !important; }
284
+ html.light .text-text-3\/40 { color: rgba(107,114,128,0.40) !important; }
285
+ html.light .text-text-3\/45 { color: rgba(107,114,128,0.45) !important; }
286
+ html.light .text-text-3\/50 { color: rgba(107,114,128,0.50) !important; }
287
+ html.light .text-text-3\/60 { color: rgba(107,114,128,0.60) !important; }
288
+ html.light .text-text-3\/70 { color: rgba(107,114,128,0.70) !important; }
289
+ html.light .text-text-3\/75 { color: rgba(107,114,128,0.75) !important; }
290
+ html.light .text-accent-bright { color: #4f46e5 !important; }
291
+ html.light .bg-accent-soft { background-color: rgba(79,70,229,0.09) !important; }
292
+ html.light .placeholder\:text-text-3\/40::placeholder { color: rgba(107,114,128,0.40) !important; }
293
+
294
+ html.light .bg-\[\#0a0a14\],
295
+ html.light .bg-\[\#0c0c13\],
296
+ html.light .bg-\[\#12121e\],
297
+ html.light .bg-\[\#13131e\],
298
+ html.light .bg-\[\#16162a\],
299
+ html.light .bg-\[\#171a2b\],
300
+ html.light .bg-\[\#1a1a2e\],
301
+ html.light .bg-\[\#1e1e38\] {
302
+ background-color: color-mix(in srgb, var(--neutral-tint) 14%, #fff) !important;
303
+ }
304
+
147
305
  @layer base {
148
306
  *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
149
307
  }
@@ -3,6 +3,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"
3
3
  import { Toaster } from "@/components/ui/sonner"
4
4
  import { DashboardShell } from "@/components/layout/dashboard-shell"
5
5
  import { AppQueryProvider } from "@/components/providers/app-query-provider"
6
+ import { ThemeProvider } from "@/components/providers/theme-provider"
6
7
  import "./globals.css"
7
8
 
8
9
  export const metadata: Metadata = {
@@ -27,16 +28,18 @@ export default function RootLayout({
27
28
  children: React.ReactNode
28
29
  }>) {
29
30
  return (
30
- <html lang="en" className="dark">
31
+ <html lang="en" suppressHydrationWarning>
31
32
  <body className="antialiased" cz-shortcut-listen="true">
32
- <AppQueryProvider>
33
- <TooltipProvider>
34
- <DashboardShell>
35
- {children}
36
- </DashboardShell>
37
- <Toaster />
38
- </TooltipProvider>
39
- </AppQueryProvider>
33
+ <ThemeProvider>
34
+ <AppQueryProvider>
35
+ <TooltipProvider>
36
+ <DashboardShell>
37
+ {children}
38
+ </DashboardShell>
39
+ <Toaster />
40
+ </TooltipProvider>
41
+ </AppQueryProvider>
42
+ </ThemeProvider>
40
43
  </body>
41
44
  </html>
42
45
  )
@@ -43,7 +43,7 @@ export default function ProtocolBuilderPage() {
43
43
 
44
44
  if (isLoading) {
45
45
  return (
46
- <div className="flex h-screen items-center justify-center">
46
+ <div className="flex h-full min-h-0 min-w-0 flex-1 items-center justify-center">
47
47
  <div className="text-sm text-muted-foreground">Loading builder...</div>
48
48
  </div>
49
49
  )
@@ -52,7 +52,7 @@ export default function ProtocolBuilderPage() {
52
52
  const template = templates?.find((t) => t.id === templateId)
53
53
  if (!template) {
54
54
  return (
55
- <div className="flex h-screen flex-col items-center justify-center gap-3">
55
+ <div className="flex h-full min-h-0 min-w-0 flex-1 flex-col items-center justify-center gap-3">
56
56
  <div className="text-sm text-muted-foreground">Template not found</div>
57
57
  <button
58
58
  onClick={() => router.push('/protocols')}
@@ -65,9 +65,9 @@ export default function ProtocolBuilderPage() {
65
65
  }
66
66
 
67
67
  return (
68
- <div className="flex h-full flex-col">
68
+ <div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
69
69
  {/* Header */}
70
- <div className="flex items-center justify-between border-b px-4 py-2">
70
+ <div className="flex shrink-0 items-center justify-between border-b px-4 py-2">
71
71
  <div className="flex items-center gap-3">
72
72
  <button
73
73
  onClick={() => router.push('/protocols')}
@@ -85,7 +85,7 @@ export default function ProtocolBuilderPage() {
85
85
  </div>
86
86
 
87
87
  {/* Canvas */}
88
- <div className="flex-1 p-3">
88
+ <div className="min-h-0 flex-1 p-3">
89
89
  <ProtocolBuilderCanvas />
90
90
  </div>
91
91
  </div>
@@ -199,10 +199,13 @@ test('binary -v alias output matches package version', () => {
199
199
  assert.equal(result.stdout.trim(), `${PACKAGE_JSON.name} ${PACKAGE_JSON.version}`)
200
200
  })
201
201
 
202
- test('package ships dagre type declarations required by installed builds', () => {
202
+ test('package ships type declarations required by installed builds', () => {
203
203
  assert.equal(PACKAGE_JSON.dependencies.dagre, '^0.8.5')
204
204
  assert.equal(PACKAGE_JSON.dependencies['@types/dagre'], '^0.7.54')
205
205
  assert.equal(PACKAGE_JSON.devDependencies?.['@types/dagre'], undefined)
206
+ assert.equal(PACKAGE_JSON.dependencies['mime-types'], '^3.0.2')
207
+ assert.equal(PACKAGE_JSON.dependencies['@types/mime-types'], '^2.1.4')
208
+ assert.equal(PACKAGE_JSON.devDependencies?.['@types/mime-types'], undefined)
206
209
  })
207
210
 
208
211
  test('legacy TS launcher falls back to tsx import when strip-types is unavailable', () => {
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useEffect, useRef, useState, useCallback } from 'react'
4
4
  import { useRouter, usePathname } from 'next/navigation'
5
+ import { useTheme } from 'next-themes'
5
6
  import { initAudioContext } from '@/lib/tts'
6
7
  import { clearStoredAccessKey } from '@/lib/app/api-client'
7
8
  import { safeStorageGet, safeStorageRemove, safeStorageSet } from '@/lib/app/safe-storage'
@@ -14,6 +15,7 @@ import { useWs } from '@/hooks/use-ws'
14
15
  import { api } from '@/lib/app/api-client'
15
16
  import { pathToView, useNavigate } from '@/lib/app/navigation'
16
17
  import { shouldAutoOpenPanelSidebar } from '@/lib/app/view-constants'
18
+ import { normalizeThemeMode } from '@/lib/theme-mode'
17
19
 
18
20
  import { FullScreenLoader } from '@/components/ui/full-screen-loader'
19
21
  import { SidebarRail } from '@/components/layout/sidebar-rail'
@@ -32,6 +34,7 @@ export function DashboardShell({ children }: { children: React.ReactNode }) {
32
34
  const router = useRouter()
33
35
  const pathname = usePathname()
34
36
  const navigateTo = useNavigate()
37
+ const { setTheme } = useTheme()
35
38
 
36
39
  const {
37
40
  hydrated,
@@ -152,6 +155,12 @@ export function DashboardShell({ children }: { children: React.ReactNode }) {
152
155
  }
153
156
  }, [appSettings.themeHue])
154
157
 
158
+ // Theme mode
159
+ useEffect(() => {
160
+ if (!appSettings.themeMode) return
161
+ setTheme(normalizeThemeMode(appSettings.themeMode))
162
+ }, [appSettings.themeMode, setTheme])
163
+
155
164
  // View validity check
156
165
  const isViewEnabled = useCallback((view: AppView) => {
157
166
  if (view === 'webhooks') return extensions['http']?.enabled !== false
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useCallback, useMemo, type DragEvent } from 'react'
3
+ import { useCallback, useEffect, useMemo, useRef, type DragEvent } from 'react'
4
4
  import {
5
5
  ReactFlow,
6
6
  Background,
@@ -12,16 +12,19 @@ import {
12
12
  type NodeChange,
13
13
  type EdgeChange,
14
14
  type Node,
15
+ type Edge,
16
+ type ReactFlowInstance,
15
17
  } from '@xyflow/react'
16
18
  import '@xyflow/react/dist/style.css'
17
- import { useProtocolBuilderStore, type BuilderNodeData } from '@/features/protocols/builder/protocol-builder-store'
19
+ import { useProtocolBuilderStore, type BuilderEdgeData, type BuilderNodeData } from '@/features/protocols/builder/protocol-builder-store'
18
20
  import { getNodeTypeForKind } from '@/features/protocols/builder/utils/template-to-nodes'
21
+ import { isBuilderTemplateReadOnly } from '@/features/protocols/builder/utils/builder-template-access'
19
22
  import { PhaseNode, BranchNode, LoopNode, ParallelNode, JoinNode, ForEachNode, SubflowNode, SwarmNode, CompleteNode } from './node-types'
20
23
  import { DefaultEdge, BranchEdge, LoopEdge } from './edge-types'
21
24
  import { NodePalette } from './node-palette'
22
25
  import { NodeInspector } from './node-inspector'
23
26
  import { ValidationPanel } from './validation-panel'
24
- import type { ProtocolStepKind } from '@/types'
27
+ import type { ProtocolStepKind, ProtocolTemplate } from '@/types'
25
28
 
26
29
  const nodeTypes = {
27
30
  phase: PhaseNode,
@@ -41,6 +44,54 @@ const edgeTypes = {
41
44
  loop: LoopEdge,
42
45
  }
43
46
 
47
+ function BuiltInTemplatePanel({
48
+ template,
49
+ onSelectStep,
50
+ }: {
51
+ template: ProtocolTemplate | null
52
+ onSelectStep: (stepId: string) => void
53
+ }) {
54
+ const steps = template?.steps && template.steps.length > 0
55
+ ? template.steps
56
+ : template?.defaultPhases ?? []
57
+
58
+ return (
59
+ <div className="flex w-52 shrink-0 flex-col overflow-y-auto rounded-lg border bg-card p-3 shadow-sm">
60
+ <div className="mb-3">
61
+ <div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
62
+ Built-in template
63
+ </div>
64
+ <div className="mt-1 text-sm font-semibold text-foreground">{template?.name || 'Template'}</div>
65
+ {template?.description && (
66
+ <div className="mt-1 text-xs leading-relaxed text-muted-foreground">
67
+ {template.description}
68
+ </div>
69
+ )}
70
+ </div>
71
+
72
+ <div className="space-y-1">
73
+ {steps.map((step, index) => (
74
+ <button
75
+ key={step.id}
76
+ type="button"
77
+ onClick={() => onSelectStep(step.id)}
78
+ className="w-full rounded-md border bg-background px-3 py-2 text-left"
79
+ title={step.kind.replace(/_/g, ' ')}
80
+ >
81
+ <div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
82
+ Step {index + 1}
83
+ </div>
84
+ <div className="mt-1 text-sm font-medium text-foreground">{step.label}</div>
85
+ <div className="mt-1 text-xs capitalize text-muted-foreground">
86
+ {step.kind.replace(/_/g, ' ')}
87
+ </div>
88
+ </button>
89
+ ))}
90
+ </div>
91
+ </div>
92
+ )
93
+ }
94
+
44
95
  export function ProtocolBuilderCanvas() {
45
96
  const nodes = useProtocolBuilderStore((s) => s.nodes)
46
97
  const edges = useProtocolBuilderStore((s) => s.edges)
@@ -54,23 +105,44 @@ export function ProtocolBuilderCanvas() {
54
105
  const isDirty = useProtocolBuilderStore((s) => s.isDirty)
55
106
  const undo = useProtocolBuilderStore((s) => s.undo)
56
107
  const redo = useProtocolBuilderStore((s) => s.redo)
108
+ const currentTemplate = useProtocolBuilderStore((s) => s.currentTemplate)
109
+ const flowRef = useRef<ReactFlowInstance<Node<BuilderNodeData>, Edge<BuilderEdgeData>> | null>(null)
110
+
111
+ const readOnly = isBuilderTemplateReadOnly(currentTemplate)
112
+
113
+ useEffect(() => {
114
+ if (nodes.length === 0) return
115
+ const frame = window.requestAnimationFrame(() => {
116
+ void flowRef.current?.fitView({ padding: 0.24, duration: 160 })
117
+ })
118
+ return () => window.cancelAnimationFrame(frame)
119
+ }, [nodes.length, edges.length])
57
120
 
58
121
  const onNodesChange = useCallback(
59
122
  (changes: NodeChange<Node<BuilderNodeData>>[]) => {
60
- setNodes(applyNodeChanges(changes, nodes))
123
+ const allowedChanges = readOnly
124
+ ? changes.filter((change) => change.type === 'select')
125
+ : changes
126
+ if (allowedChanges.length === 0) return
127
+ setNodes(applyNodeChanges(allowedChanges, nodes), { markDirty: !readOnly })
61
128
  },
62
- [nodes, setNodes],
129
+ [nodes, readOnly, setNodes],
63
130
  )
64
131
 
65
132
  const onEdgesChange = useCallback(
66
133
  (changes: EdgeChange[]) => {
67
- setEdges(applyEdgeChanges(changes, edges) as typeof edges)
134
+ const allowedChanges = readOnly
135
+ ? changes.filter((change) => change.type === 'select')
136
+ : changes
137
+ if (allowedChanges.length === 0) return
138
+ setEdges(applyEdgeChanges(allowedChanges, edges) as typeof edges, { markDirty: !readOnly })
68
139
  },
69
- [edges, setEdges],
140
+ [edges, readOnly, setEdges],
70
141
  )
71
142
 
72
143
  const onConnect = useCallback(
73
144
  (connection: Connection) => {
145
+ if (readOnly) return
74
146
  pushUndo()
75
147
  addEdge({
76
148
  id: `${connection.source}--${connection.target}--${Date.now()}`,
@@ -82,7 +154,7 @@ export function ProtocolBuilderCanvas() {
82
154
  data: { edgeType: 'default' },
83
155
  })
84
156
  },
85
- [addEdge, pushUndo],
157
+ [addEdge, pushUndo, readOnly],
86
158
  )
87
159
 
88
160
  const onNodeClick = useCallback(
@@ -112,6 +184,7 @@ export function ProtocolBuilderCanvas() {
112
184
  const onDrop = useCallback(
113
185
  (e: DragEvent) => {
114
186
  e.preventDefault()
187
+ if (readOnly) return
115
188
  const kind = e.dataTransfer.getData('application/x-protocol-node-kind') as ProtocolStepKind
116
189
  const label = e.dataTransfer.getData('application/x-protocol-node-label')
117
190
  if (!kind) return
@@ -127,32 +200,43 @@ export function ProtocolBuilderCanvas() {
127
200
  }
128
201
  addNode(newNode)
129
202
  },
130
- [addNode, pushUndo],
203
+ [addNode, pushUndo, readOnly],
131
204
  )
132
205
 
133
206
  const onKeyDown = useCallback(
134
207
  (e: React.KeyboardEvent) => {
208
+ if (readOnly) return
135
209
  if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
136
210
  e.preventDefault()
137
211
  if (e.shiftKey) redo()
138
212
  else undo()
139
213
  }
140
214
  },
141
- [undo, redo],
215
+ [readOnly, undo, redo],
142
216
  )
143
217
 
144
218
  const memoizedNodeTypes = useMemo(() => nodeTypes, [])
145
219
  const memoizedEdgeTypes = useMemo(() => edgeTypes, [])
146
220
 
147
221
  return (
148
- <div className="flex h-full w-full gap-3" onKeyDown={onKeyDown} tabIndex={0}>
149
- <NodePalette />
150
- <div className="relative flex-1 overflow-hidden rounded-lg border">
222
+ <div className="flex h-full min-h-0 w-full min-w-0 gap-3" onKeyDown={onKeyDown} tabIndex={0}>
223
+ {readOnly ? <BuiltInTemplatePanel template={currentTemplate} onSelectStep={selectNode} /> : <NodePalette />}
224
+ <div className="relative min-h-0 min-w-0 flex-1 overflow-hidden rounded-lg border">
151
225
  {isDirty && (
152
226
  <div className="absolute left-1/2 top-2 z-10 -translate-x-1/2 rounded bg-amber-500/10 px-2 py-1 text-xs text-amber-500">
153
227
  Unsaved changes
154
228
  </div>
155
229
  )}
230
+ {nodes.length === 0 && (
231
+ <div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center p-6">
232
+ <div className="pointer-events-auto max-w-md rounded-lg border bg-card/95 p-4 text-center shadow-sm">
233
+ <div className="text-sm font-semibold text-foreground">No visual steps</div>
234
+ <div className="mt-1 text-sm text-muted-foreground">
235
+ This template does not expose a protocol graph yet.
236
+ </div>
237
+ </div>
238
+ </div>
239
+ )}
156
240
  <ReactFlow
157
241
  nodes={nodes}
158
242
  edges={edges}
@@ -166,8 +250,15 @@ export function ProtocolBuilderCanvas() {
166
250
  onPaneClick={onPaneClick}
167
251
  onDragOver={onDragOver}
168
252
  onDrop={onDrop}
253
+ onInit={(instance) => {
254
+ flowRef.current = instance
255
+ }}
169
256
  fitView
170
- deleteKeyCode="Delete"
257
+ fitViewOptions={{ padding: 0.24 }}
258
+ nodesDraggable={!readOnly}
259
+ nodesConnectable={!readOnly}
260
+ edgesReconnectable={!readOnly}
261
+ deleteKeyCode={readOnly ? null : 'Delete'}
171
262
  defaultEdgeOptions={{ type: 'default', data: { edgeType: 'default' } }}
172
263
  >
173
264
  <Background />
@@ -175,7 +266,7 @@ export function ProtocolBuilderCanvas() {
175
266
  <MiniMap />
176
267
  </ReactFlow>
177
268
  </div>
178
- <div className="flex w-72 flex-col gap-3">
269
+ <div className="flex w-72 shrink-0 flex-col gap-3">
179
270
  <NodeInspector />
180
271
  <ValidationPanel />
181
272
  </div>
@@ -0,0 +1,16 @@
1
+ 'use client'
2
+
3
+ import { ThemeProvider as NextThemeProvider } from 'next-themes'
4
+
5
+ export function ThemeProvider({ children }: { children: React.ReactNode }) {
6
+ return (
7
+ <NextThemeProvider
8
+ attribute="class"
9
+ defaultTheme="dark"
10
+ enableSystem
11
+ disableTransitionOnChange
12
+ >
13
+ {children}
14
+ </NextThemeProvider>
15
+ )
16
+ }
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
2
2
  import { useProtocolBuilderStore } from '../protocol-builder-store'
3
3
  import { useUpsertProtocolTemplateMutation, type ProtocolTemplatePayload } from '@/features/protocols/queries'
4
4
  import { nodesToTemplate } from '../utils/nodes-to-template'
5
+ import { isBuilderTemplateReadOnly } from '../utils/builder-template-access'
5
6
 
6
7
  export function useTemplateSync(autoSaveDelayMs = 2000) {
7
8
  const nodes = useProtocolBuilderStore((s) => s.nodes)
@@ -15,6 +16,10 @@ export function useTemplateSync(autoSaveDelayMs = 2000) {
15
16
 
16
17
  useEffect(() => {
17
18
  if (!isDirty || !currentTemplate || validationErrors.length > 0) return
19
+ if (isBuilderTemplateReadOnly(currentTemplate)) {
20
+ if (debounceRef.current) clearTimeout(debounceRef.current)
21
+ return
22
+ }
18
23
 
19
24
  if (debounceRef.current) clearTimeout(debounceRef.current)
20
25
 
@@ -67,8 +67,8 @@ export interface ProtocolBuilderState {
67
67
  redoStack: UndoSnapshot[]
68
68
  activeRunId: string | null
69
69
 
70
- setNodes: (nodes: BuilderNode[]) => void
71
- setEdges: (edges: BuilderEdge[]) => void
70
+ setNodes: (nodes: BuilderNode[], options?: { markDirty?: boolean }) => void
71
+ setEdges: (edges: BuilderEdge[], options?: { markDirty?: boolean }) => void
72
72
  selectNode: (nodeId: string | null) => void
73
73
  selectEdge: (edgeId: string | null) => void
74
74
  updateNodeData: (nodeId: string, data: Partial<BuilderNodeData>) => void
@@ -110,8 +110,8 @@ export const useProtocolBuilderStore = create<ProtocolBuilderState>()(
110
110
  (set, get) => ({
111
111
  ...initialState,
112
112
 
113
- setNodes: (nodes) => set({ nodes, isDirty: true }),
114
- setEdges: (edges) => set({ edges, isDirty: true }),
113
+ setNodes: (nodes, options) => set({ nodes, isDirty: options?.markDirty ?? true }),
114
+ setEdges: (edges, options) => set({ edges, isDirty: options?.markDirty ?? true }),
115
115
 
116
116
  selectNode: (nodeId) =>
117
117
  set({ selectedNodeId: nodeId, selectedEdgeId: null }),
@@ -0,0 +1,30 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import type { ProtocolTemplate } from '@/types'
4
+ import { isBuilderTemplateReadOnly } from './builder-template-access'
5
+
6
+ function makeTemplate(overrides: Partial<ProtocolTemplate> = {}): ProtocolTemplate {
7
+ return {
8
+ id: 'template-1',
9
+ name: 'Template One',
10
+ description: 'A template',
11
+ builtIn: false,
12
+ defaultPhases: [],
13
+ ...overrides,
14
+ }
15
+ }
16
+
17
+ describe('isBuilderTemplateReadOnly', () => {
18
+ it('marks built-in templates as read-only', () => {
19
+ assert.equal(isBuilderTemplateReadOnly(makeTemplate({ builtIn: true })), true)
20
+ })
21
+
22
+ it('keeps custom templates editable', () => {
23
+ assert.equal(isBuilderTemplateReadOnly(makeTemplate({ builtIn: false })), false)
24
+ })
25
+
26
+ it('treats an unloaded template as non-editable only after one is present', () => {
27
+ assert.equal(isBuilderTemplateReadOnly(null), false)
28
+ assert.equal(isBuilderTemplateReadOnly(undefined), false)
29
+ })
30
+ })
@@ -0,0 +1,5 @@
1
+ import type { ProtocolTemplate } from '@/types'
2
+
3
+ export function isBuilderTemplateReadOnly(template: ProtocolTemplate | null | undefined): boolean {
4
+ return Boolean(template?.builtIn)
5
+ }