mcpgraph-ux 0.1.1 → 0.1.3

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,20 +1,9 @@
1
1
  import { NextResponse } from 'next/server';
2
- import { McpGraphApi } from 'mcpgraph';
2
+ import { type ExecutionOptions, type ExecutionHooks, type ExecutionResult } from 'mcpgraph';
3
+ import { getApi } from '@/lib/mcpGraphApi';
4
+ import { sendExecutionEvent, closeExecutionStream } from '@/lib/executionStreamServer';
5
+ import { registerController, unregisterController, getController } from '@/lib/executionController';
3
6
 
4
- let apiInstance: McpGraphApi | null = null;
5
-
6
- function getApi(): McpGraphApi {
7
- const configPath = process.env.MCPGRAPH_CONFIG_PATH;
8
- if (!configPath) {
9
- throw new Error('MCPGRAPH_CONFIG_PATH environment variable is not set');
10
- }
11
-
12
- if (!apiInstance) {
13
- apiInstance = new McpGraphApi(configPath);
14
- }
15
-
16
- return apiInstance;
17
- }
18
7
 
19
8
  export async function GET(
20
9
  request: Request,
@@ -45,16 +34,278 @@ export async function POST(
45
34
  request: Request,
46
35
  { params }: { params: { toolName: string } }
47
36
  ) {
37
+ let executionId: string | undefined;
48
38
  try {
49
39
  const api = getApi();
50
40
  const body = await request.json();
51
41
  const args = body.args || {};
42
+ executionId = body.executionId as string | undefined;
43
+ const executionOptions = body.options as ExecutionOptions | undefined;
44
+
45
+ // Log breakpoints received
46
+ const breakpointsReceived = executionOptions?.breakpoints || [];
47
+ console.log(`[API] Received breakpoints: ${breakpointsReceived.length > 0 ? breakpointsReceived.join(', ') : 'none'}`);
52
48
 
53
- const result = await api.executeTool(params.toolName, args);
49
+ // If executionId is provided, set up hooks to stream events via SSE
50
+ // Store breakpoints for use in hooks (controller may not be available yet)
51
+ const breakpointsList = executionOptions?.breakpoints || [];
52
+ let hooks: ExecutionHooks | undefined;
53
+ if (executionId) {
54
+ const execId = executionId; // Capture in const for closure
55
+ console.log(`[API] Setting up hooks for executionId: ${execId}, breakpoints: ${breakpointsList.join(', ')}`);
56
+ hooks = {
57
+ onNodeStart: async (executionIndex, nodeId, node, context) => {
58
+ console.log(`[API] onNodeStart hook called for node: ${nodeId}, executionIndex: ${executionIndex}`);
59
+
60
+ // Check if this node should have a breakpoint
61
+ const controller = getController(execId);
62
+ if (controller && breakpointsList.includes(nodeId)) {
63
+ const controllerBreakpoints = controller.getBreakpoints();
64
+ const state = controller.getState();
65
+ console.log(`[API] WARNING: onNodeStart called for node ${nodeId} which has a breakpoint!`);
66
+ console.log(`[API] Controller breakpoints: ${controllerBreakpoints.length > 0 ? controllerBreakpoints.join(', ') : 'none'}`);
67
+ console.log(`[API] Controller status: ${state.status}, currentNodeId: ${state.currentNodeId}`);
68
+ }
69
+
70
+ sendExecutionEvent(execId, 'nodeStart', {
71
+ nodeId,
72
+ nodeType: node.type,
73
+ executionIndex,
74
+ context, // Send context so client can determine input
75
+ timestamp: Date.now(),
76
+ });
77
+
78
+ // Note: mcpGraph should check breakpoints internally before executing nodes
79
+ // If we reach here, the node is starting. The controller's breakpoint checking
80
+ // should have paused execution before this hook is called.
81
+
82
+ return true; // Continue execution
83
+ },
84
+ onNodeComplete: async (executionIndex, nodeId, node, input, output, duration) => {
85
+ sendExecutionEvent(execId, 'nodeComplete', {
86
+ nodeId,
87
+ nodeType: node.type,
88
+ executionIndex,
89
+ input,
90
+ output,
91
+ duration,
92
+ timestamp: Date.now(),
93
+ });
94
+ },
95
+ onNodeError: async (executionIndex, nodeId, node, error, context) => {
96
+ sendExecutionEvent(execId, 'nodeError', {
97
+ nodeId,
98
+ nodeType: node.type,
99
+ executionIndex,
100
+ input: context, // Include context as input (mcpGraph 0.1.19+ provides actual context)
101
+ error: {
102
+ message: error.message,
103
+ stack: error.stack,
104
+ },
105
+ timestamp: Date.now(),
106
+ });
107
+ },
108
+ onPause: async (executionIndex, nodeId, context) => {
109
+ console.log(`[API] onPause hook called for node: ${nodeId}, executionIndex: ${executionIndex}`);
110
+
111
+ // Look up node type from config
112
+ const api = getApi();
113
+ const config = api.getConfig();
114
+ const node = config.nodes.find(n => n.id === nodeId);
115
+ const nodeType = node?.type || 'unknown';
116
+
117
+ sendExecutionEvent(execId, 'pause', {
118
+ nodeId,
119
+ nodeType,
120
+ executionIndex,
121
+ context, // Include context so client can show input for pending node
122
+ timestamp: Date.now(),
123
+ });
124
+ // Send stateUpdate to ensure UI status is updated
125
+ sendExecutionEvent(execId, 'stateUpdate', {
126
+ status: 'paused',
127
+ currentNodeId: nodeId,
128
+ timestamp: Date.now(),
129
+ });
130
+ },
131
+ onResume: async () => {
132
+ sendExecutionEvent(execId, 'resume', {
133
+ timestamp: Date.now(),
134
+ });
135
+ },
136
+ };
137
+ }
138
+
139
+ // Merge with provided hooks if any
140
+ const finalOptions: ExecutionOptions = {
141
+ ...executionOptions,
142
+ hooks: executionOptions?.hooks
143
+ ? {
144
+ ...hooks,
145
+ ...executionOptions.hooks,
146
+ // Merge hook functions - call both
147
+ onNodeStart: async (executionIndex, nodeId, node, context) => {
148
+ const hook1 = hooks?.onNodeStart;
149
+ const hook2 = executionOptions.hooks?.onNodeStart;
150
+ const result1 = hook1 ? await hook1(executionIndex, nodeId, node, context) : true;
151
+ const result2 = hook2 ? await hook2(executionIndex, nodeId, node, context) : true;
152
+ return result1 && result2;
153
+ },
154
+ onNodeComplete: async (executionIndex, nodeId, node, input, output, duration) => {
155
+ await hooks?.onNodeComplete?.(executionIndex, nodeId, node, input, output, duration);
156
+ await executionOptions.hooks?.onNodeComplete?.(executionIndex, nodeId, node, input, output, duration);
157
+ },
158
+ onNodeError: async (executionIndex, nodeId, node, error, context) => {
159
+ await hooks?.onNodeError?.(executionIndex, nodeId, node, error, context);
160
+ await executionOptions.hooks?.onNodeError?.(executionIndex, nodeId, node, error, context);
161
+ },
162
+ onPause: async (executionIndex, nodeId, context) => {
163
+ await hooks?.onPause?.(executionIndex, nodeId, context);
164
+ await executionOptions.hooks?.onPause?.(executionIndex, nodeId, context);
165
+ },
166
+ onResume: async () => {
167
+ await hooks?.onResume?.();
168
+ await executionOptions.hooks?.onResume?.();
169
+ },
170
+ }
171
+ : hooks,
172
+ breakpoints: executionOptions?.breakpoints,
173
+ enableTelemetry: executionOptions?.enableTelemetry ?? true, // Enable by default for UX
174
+ };
175
+
176
+ console.log(`[API] Executing tool ${params.toolName} with executionId: ${executionId || 'none'}`);
177
+ const finalBreakpoints = finalOptions.breakpoints || [];
178
+ console.log(`[API] Final options breakpoints: ${finalBreakpoints.length > 0 ? finalBreakpoints.join(', ') : 'none'}`);
179
+ console.log(`[API] Final options object:`, JSON.stringify({
180
+ breakpoints: finalBreakpoints,
181
+ enableTelemetry: finalOptions.enableTelemetry,
182
+ hasHooks: !!finalOptions.hooks
183
+ }, null, 2));
54
184
 
55
- return NextResponse.json({ result });
185
+ // Start execution and get controller directly (mcpGraph 0.1.11+ returns both)
186
+ const { promise: executionPromise, controller } = api.executeTool(params.toolName, args, finalOptions);
187
+
188
+ console.log(`[API] executeTool returned controller: ${controller ? 'present' : 'null'}, executionId: ${executionId || 'none'}`);
189
+
190
+ // Register controller immediately if we have executionId and controller
191
+ if (executionId && controller) {
192
+ const execId = executionId; // Capture in const for closure
193
+ registerController(execId, controller);
194
+ console.log(`[API] Registered controller for executionId: ${execId}`);
195
+
196
+ // Log breakpoints on controller
197
+ const controllerBreakpoints = controller.getBreakpoints();
198
+ console.log(`[API] Controller breakpoints: ${controllerBreakpoints.length > 0 ? controllerBreakpoints.join(', ') : 'none'}`);
199
+ } else {
200
+ if (!executionId) {
201
+ console.log(`[API] WARNING: No executionId provided, controller not registered`);
202
+ }
203
+ if (!controller) {
204
+ console.log(`[API] WARNING: Controller is null, cannot register. Hooks: ${!!finalOptions.hooks}, Breakpoints: ${finalBreakpoints.length > 0 ? finalBreakpoints.join(', ') : 'none'}`);
205
+ }
206
+ }
207
+
208
+ const result = await executionPromise;
209
+ console.log(`[API] Tool execution completed, result:`, result.result ? 'present' : 'missing');
210
+
211
+ // Send completion event and close stream
212
+ if (executionId) {
213
+ const execId = executionId; // Capture in const for closure
214
+ console.log(`[API] Sending executionComplete event for executionId: ${execId}`);
215
+
216
+ // Fetch input contexts for all execution records BEFORE unregistering controller
217
+ // Use the controller's context directly since api.getContextForExecution() requires active controller
218
+ const controller = getController(execId);
219
+ console.log(`[API] Fetching input contexts for ${result.executionHistory?.length || 0} records`);
220
+ const executionHistoryWithInput = (result.executionHistory || []).map((record) => {
221
+ console.log(`[API] Processing record: nodeId=${record.nodeId}, executionIndex=${record.executionIndex}`);
222
+ try {
223
+ let context: Record<string, unknown> | null = null;
224
+ if (controller) {
225
+ const state = controller.getState();
226
+ context = state.context.getContextForExecution(record.executionIndex);
227
+ } else {
228
+ console.warn(`[API] Controller not found for ${execId}, trying API method`);
229
+ context = api.getContextForExecution(record.executionIndex);
230
+ }
231
+ console.log(`[API] Got context for ${record.nodeId}:`, context ? 'present' : 'null', context);
232
+ return {
233
+ ...record,
234
+ input: context || undefined,
235
+ };
236
+ } catch (error) {
237
+ console.error(`[API] Error getting context for executionIndex ${record.executionIndex}:`, error);
238
+ return record;
239
+ }
240
+ });
241
+ console.log(`[API] Final execution history with input:`, JSON.stringify(executionHistoryWithInput, null, 2));
242
+
243
+ sendExecutionEvent(execId, 'executionComplete', {
244
+ result: result.result,
245
+ executionHistory: executionHistoryWithInput,
246
+ telemetry: result.telemetry
247
+ ? {
248
+ ...result.telemetry,
249
+ nodeDurations: Object.fromEntries(result.telemetry.nodeDurations),
250
+ nodeCounts: Object.fromEntries(result.telemetry.nodeCounts),
251
+ }
252
+ : undefined,
253
+ timestamp: Date.now(),
254
+ });
255
+ // Close stream immediately - enqueue is synchronous, event is already sent
256
+ console.log(`[API] Closing stream for executionId: ${execId}`);
257
+ closeExecutionStream(execId);
258
+ unregisterController(execId);
259
+ }
260
+
261
+ // Serialize telemetry Maps for JSON response
262
+ const responseResult = {
263
+ ...result,
264
+ telemetry: result.telemetry
265
+ ? {
266
+ ...result.telemetry,
267
+ nodeDurations: Object.fromEntries(result.telemetry.nodeDurations),
268
+ nodeCounts: Object.fromEntries(result.telemetry.nodeCounts),
269
+ }
270
+ : undefined,
271
+ };
272
+
273
+ return NextResponse.json({ result: responseResult });
56
274
  } catch (error) {
57
275
  console.error('Error executing tool:', error);
276
+
277
+ // Check if execution was stopped (not a real error)
278
+ const isStopped = error instanceof Error && error.message === 'Execution was stopped';
279
+
280
+ if (executionId) {
281
+ const execId = executionId; // Capture in const for closure
282
+
283
+ if (isStopped) {
284
+ // Execution was stopped by user - send stopped event, not error
285
+ sendExecutionEvent(execId, 'executionStopped', {
286
+ timestamp: Date.now(),
287
+ });
288
+ } else {
289
+ // Real error occurred
290
+ sendExecutionEvent(execId, 'executionError', {
291
+ error: error instanceof Error ? error.message : 'Unknown error',
292
+ timestamp: Date.now(),
293
+ });
294
+ }
295
+
296
+ // Close stream immediately - enqueue is synchronous, event is already sent
297
+ closeExecutionStream(execId);
298
+ unregisterController(execId);
299
+ }
300
+
301
+ // If stopped, return success (stopping is intentional)
302
+ if (isStopped) {
303
+ return NextResponse.json({
304
+ result: null,
305
+ stopped: true
306
+ });
307
+ }
308
+
58
309
  return NextResponse.json(
59
310
  { error: error instanceof Error ? error.message : 'Unknown error' },
60
311
  { status: 500 }
@@ -1,20 +1,8 @@
1
1
  import { NextResponse } from 'next/server';
2
- import { McpGraphApi } from 'mcpgraph';
2
+ import { getApi } from '@/lib/mcpGraphApi';
3
3
 
4
- let apiInstance: McpGraphApi | null = null;
5
-
6
- function getApi(): McpGraphApi {
7
- const configPath = process.env.MCPGRAPH_CONFIG_PATH;
8
- if (!configPath) {
9
- throw new Error('MCPGRAPH_CONFIG_PATH environment variable is not set');
10
- }
11
-
12
- if (!apiInstance) {
13
- apiInstance = new McpGraphApi(configPath);
14
- }
15
-
16
- return apiInstance;
17
- }
4
+ // Force dynamic rendering - this route requires runtime config
5
+ export const dynamic = 'force-dynamic';
18
6
 
19
7
  export async function GET() {
20
8
  try {
@@ -33,7 +33,9 @@
33
33
  width: 300px;
34
34
  background-color: #fff;
35
35
  border-right: 1px solid #e0e0e0;
36
- overflow-y: auto;
36
+ display: flex;
37
+ flex-direction: column;
38
+ overflow: hidden;
37
39
  }
38
40
 
39
41
  .content {
@@ -43,35 +45,79 @@
43
45
  overflow: hidden;
44
46
  }
45
47
 
46
- .graphSection {
47
- flex: 1;
48
- display: flex;
49
- flex-direction: column;
48
+ .testerSection {
49
+ background-color: #fff;
50
+ padding: 0.75rem 1.5rem;
50
51
  border-bottom: 1px solid #e0e0e0;
51
- background-color: #fafafa;
52
+ flex-shrink: 0;
52
53
  }
53
54
 
54
- .graphSection h2 {
55
- padding: 1rem 1.5rem;
55
+ .testerSection h2 {
56
56
  font-size: 1.1rem;
57
57
  font-weight: 600;
58
+ margin-bottom: 0.5rem;
58
59
  color: #333;
59
- border-bottom: 1px solid #e0e0e0;
60
+ }
61
+
62
+ .bottomSection {
63
+ flex: 1;
64
+ display: flex;
65
+ flex-direction: column;
66
+ overflow: hidden;
67
+ min-height: 0;
68
+ border: 1px solid #e0e0e0;
69
+ border-radius: 8px;
60
70
  background-color: #fff;
61
71
  }
62
72
 
63
- .testerSection {
73
+ .debugControlsHeader {
74
+ flex-shrink: 0;
75
+ border-bottom: 1px solid #e0e0e0;
76
+ background-color: #fafafa;
77
+ border-radius: 8px 8px 0 0;
78
+ }
79
+
80
+ .graphSection {
81
+ flex: 1;
82
+ display: flex;
83
+ flex-direction: column;
84
+ border-right: 1px solid #e0e0e0;
85
+ background-color: #fafafa;
86
+ overflow: hidden;
87
+ min-width: 0;
88
+ min-height: 0;
89
+ }
90
+
91
+ .graphHistoryContainer {
92
+ flex: 1;
93
+ display: flex;
94
+ overflow: hidden;
95
+ min-height: 0;
96
+ }
97
+
98
+
99
+ .historySection {
100
+ flex: 1;
101
+ display: flex;
102
+ flex-direction: column;
64
103
  background-color: #fff;
65
- padding: 1.5rem;
66
- overflow-y: auto;
67
- max-height: 400px;
104
+ overflow: hidden;
105
+ min-width: 0;
68
106
  }
69
107
 
70
- .testerSection h2 {
71
- font-size: 1.1rem;
72
- font-weight: 600;
73
- margin-bottom: 1rem;
74
- color: #333;
108
+
109
+ .emptyHistory {
110
+ padding: 2rem;
111
+ text-align: center;
112
+ color: #666;
113
+ font-style: italic;
114
+ }
115
+
116
+ .historySection > :global(.container) {
117
+ flex: 1;
118
+ display: flex;
119
+ flex-direction: column;
120
+ min-height: 0;
75
121
  }
76
122
 
77
123
  .loading,
package/app/page.tsx CHANGED
@@ -1,9 +1,10 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useState } from 'react';
4
- import GraphVisualization from '@/components/GraphVisualization';
3
+ import { useEffect, useState, useRef } from 'react';
5
4
  import ToolList from '@/components/ToolList';
6
5
  import ToolTester from '@/components/ToolTester';
6
+ import InputForm, { type InputFormHandle } from '@/components/InputForm';
7
+ import { ServerConfig, McpServers } from '@/components/ServerDetails';
7
8
  import styles from './page.module.css';
8
9
 
9
10
  interface Tool {
@@ -19,9 +20,12 @@ export default function Home() {
19
20
  const [graphData, setGraphData] = useState<{ nodes: any[]; edges: any[] } | null>(null);
20
21
  const [loading, setLoading] = useState(true);
21
22
  const [error, setError] = useState<string | null>(null);
23
+ const [serverDetails, setServerDetails] = useState<any>(null);
24
+ const inputFormRef = useRef<InputFormHandle>(null);
25
+ const toolTesterFormSubmitRef = useRef<((formData: Record<string, any>, startPaused: boolean) => void) | null>(null);
22
26
 
23
27
  useEffect(() => {
24
- // Load tools and graph data
28
+ // Load tools and graph data (graph includes config)
25
29
  Promise.all([
26
30
  fetch('/api/tools').then(res => res.json()),
27
31
  fetch('/api/graph').then(res => res.json()),
@@ -37,6 +41,9 @@ export default function Home() {
37
41
  }
38
42
  setTools(toolsRes.tools);
39
43
  setGraphData({ nodes: graphRes.nodes, edges: graphRes.edges });
44
+ if (graphRes.config) {
45
+ setServerDetails(graphRes.config);
46
+ }
40
47
  if (toolsRes.tools.length > 0) {
41
48
  setSelectedTool(toolsRes.tools[0].name);
42
49
  }
@@ -77,31 +84,47 @@ export default function Home() {
77
84
 
78
85
  <div className={styles.main}>
79
86
  <div className={styles.sidebar}>
87
+ {serverDetails && <ServerConfig config={serverDetails} />}
80
88
  <ToolList
81
89
  tools={tools}
82
90
  selectedTool={selectedTool}
83
91
  onSelectTool={setSelectedTool}
84
92
  />
93
+ {serverDetails && <McpServers config={serverDetails} />}
85
94
  </div>
86
95
 
87
96
  <div className={styles.content}>
88
- <div className={styles.graphSection}>
89
- <h2>Graph Visualization</h2>
90
- {graphData && (
91
- <GraphVisualization
92
- nodes={graphData.nodes}
93
- edges={graphData.edges}
94
- selectedTool={selectedTool}
95
- />
96
- )}
97
- </div>
98
-
97
+ {/* Top section: Input form */}
99
98
  {selectedTool && (
100
99
  <div className={styles.testerSection}>
101
100
  <h2>Test Tool: {selectedTool}</h2>
102
- <ToolTester toolName={selectedTool} />
101
+ <InputForm
102
+ ref={inputFormRef}
103
+ toolName={selectedTool}
104
+ onSubmit={(data, startPaused) => {
105
+ // Form validated and collected data - pass to ToolTester for execution
106
+ if (toolTesterFormSubmitRef.current) {
107
+ toolTesterFormSubmitRef.current(data, startPaused);
108
+ } else {
109
+ console.warn('[page.tsx] toolTesterFormSubmitRef.current is null');
110
+ }
111
+ }}
112
+ />
103
113
  </div>
104
114
  )}
115
+
116
+ {/* Bottom section: Execution/testing area (includes debug controls, graph, and history) */}
117
+ {selectedTool && graphData && (
118
+ <ToolTester
119
+ toolName={selectedTool}
120
+ graphData={graphData}
121
+ inputFormRef={inputFormRef}
122
+ onFormSubmit={(handler) => {
123
+ // Store the handler that InputForm will call
124
+ toolTesterFormSubmitRef.current = handler;
125
+ }}
126
+ />
127
+ )}
105
128
  </div>
106
129
  </div>
107
130
  </div>
@@ -0,0 +1,124 @@
1
+ .container {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 12px;
5
+ padding: 12px;
6
+ background: #f5f5f5;
7
+ border: none;
8
+ border-radius: 0;
9
+ margin-bottom: 0;
10
+ }
11
+
12
+ .controlsRow {
13
+ display: flex;
14
+ align-items: center;
15
+ justify-content: space-between;
16
+ gap: 16px;
17
+ }
18
+
19
+ .statusInfo {
20
+ display: flex;
21
+ align-items: center;
22
+ gap: 8px;
23
+ font-size: 13px;
24
+ margin-left: auto;
25
+ }
26
+
27
+ .statusLabel {
28
+ font-weight: 600;
29
+ color: #666;
30
+ }
31
+
32
+ .statusBadge {
33
+ padding: 4px 8px;
34
+ border-radius: 4px;
35
+ font-size: 11px;
36
+ font-weight: 600;
37
+ text-transform: uppercase;
38
+ letter-spacing: 0.5px;
39
+ }
40
+
41
+ .statusBadge.not_started {
42
+ background: #e0e0e0;
43
+ color: #666;
44
+ }
45
+
46
+ .statusBadge.running {
47
+ background: #e3f2fd;
48
+ color: #1976d2;
49
+ animation: pulse 2s ease-in-out infinite;
50
+ }
51
+
52
+ .statusBadge.paused {
53
+ background: #fff3e0;
54
+ color: #f57c00;
55
+ }
56
+
57
+ .statusBadge.finished {
58
+ background: #e8f5e9;
59
+ color: #2e7d32;
60
+ }
61
+
62
+ .statusBadge.error {
63
+ background: #ffebee;
64
+ color: #c62828;
65
+ }
66
+
67
+ @keyframes pulse {
68
+ 0%, 100% {
69
+ opacity: 1;
70
+ }
71
+ 50% {
72
+ opacity: 0.7;
73
+ }
74
+ }
75
+
76
+ .separator {
77
+ color: #999;
78
+ }
79
+
80
+ .currentNode {
81
+ color: #666;
82
+ font-size: 12px;
83
+ }
84
+
85
+ .currentNode code {
86
+ background: #fff;
87
+ padding: 2px 6px;
88
+ border-radius: 3px;
89
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
90
+ font-size: 11px;
91
+ color: #333;
92
+ border: 1px solid #ddd;
93
+ }
94
+
95
+ .controls {
96
+ display: flex;
97
+ gap: 8px;
98
+ }
99
+
100
+ .button {
101
+ padding: 8px 16px;
102
+ background: #2196f3;
103
+ color: white;
104
+ border: none;
105
+ border-radius: 4px;
106
+ font-size: 13px;
107
+ font-weight: 500;
108
+ cursor: pointer;
109
+ transition: background-color 0.2s;
110
+ display: flex;
111
+ align-items: center;
112
+ gap: 6px;
113
+ }
114
+
115
+ .button:hover:not(:disabled) {
116
+ background: #1976d2;
117
+ }
118
+
119
+ .button:disabled {
120
+ background: #ccc;
121
+ cursor: not-allowed;
122
+ opacity: 0.6;
123
+ }
124
+