@aion0/forge 0.1.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 (80) hide show
  1. package/CLAUDE.md +4 -0
  2. package/README.md +264 -0
  3. package/app/api/auth/[...nextauth]/route.ts +3 -0
  4. package/app/api/claude/[id]/route.ts +31 -0
  5. package/app/api/claude/[id]/stream/route.ts +63 -0
  6. package/app/api/claude/route.ts +28 -0
  7. package/app/api/claude-sessions/[projectName]/live/route.ts +72 -0
  8. package/app/api/claude-sessions/[projectName]/route.ts +37 -0
  9. package/app/api/claude-sessions/sync/route.ts +17 -0
  10. package/app/api/flows/route.ts +6 -0
  11. package/app/api/flows/run/route.ts +19 -0
  12. package/app/api/notify/test/route.ts +33 -0
  13. package/app/api/projects/route.ts +7 -0
  14. package/app/api/sessions/[id]/chat/route.ts +64 -0
  15. package/app/api/sessions/[id]/messages/route.ts +9 -0
  16. package/app/api/sessions/[id]/route.ts +17 -0
  17. package/app/api/sessions/route.ts +20 -0
  18. package/app/api/settings/route.ts +15 -0
  19. package/app/api/status/route.ts +12 -0
  20. package/app/api/tasks/[id]/route.ts +36 -0
  21. package/app/api/tasks/[id]/stream/route.ts +77 -0
  22. package/app/api/tasks/link/route.ts +37 -0
  23. package/app/api/tasks/route.ts +43 -0
  24. package/app/api/tasks/session/route.ts +14 -0
  25. package/app/api/templates/route.ts +6 -0
  26. package/app/api/tunnel/route.ts +20 -0
  27. package/app/api/watchers/route.ts +33 -0
  28. package/app/globals.css +26 -0
  29. package/app/icon.svg +26 -0
  30. package/app/layout.tsx +17 -0
  31. package/app/login/page.tsx +61 -0
  32. package/app/page.tsx +9 -0
  33. package/cli/mw.ts +377 -0
  34. package/components/ChatPanel.tsx +191 -0
  35. package/components/ClaudeTerminal.tsx +267 -0
  36. package/components/Dashboard.tsx +270 -0
  37. package/components/MarkdownContent.tsx +57 -0
  38. package/components/NewSessionModal.tsx +93 -0
  39. package/components/NewTaskModal.tsx +456 -0
  40. package/components/ProjectList.tsx +108 -0
  41. package/components/SessionList.tsx +74 -0
  42. package/components/SessionView.tsx +655 -0
  43. package/components/SettingsModal.tsx +366 -0
  44. package/components/StatusBar.tsx +99 -0
  45. package/components/TaskBoard.tsx +110 -0
  46. package/components/TaskDetail.tsx +351 -0
  47. package/components/TunnelToggle.tsx +163 -0
  48. package/components/WebTerminal.tsx +1069 -0
  49. package/docs/LOCAL-DEPLOY.md +144 -0
  50. package/docs/roadmap-multi-agent-workflow.md +330 -0
  51. package/instrumentation.ts +14 -0
  52. package/lib/auth.ts +47 -0
  53. package/lib/claude-process.ts +352 -0
  54. package/lib/claude-sessions.ts +267 -0
  55. package/lib/cloudflared.ts +218 -0
  56. package/lib/flows.ts +86 -0
  57. package/lib/init.ts +82 -0
  58. package/lib/notify.ts +75 -0
  59. package/lib/password.ts +77 -0
  60. package/lib/projects.ts +86 -0
  61. package/lib/session-manager.ts +156 -0
  62. package/lib/session-watcher.ts +345 -0
  63. package/lib/settings.ts +44 -0
  64. package/lib/task-manager.ts +668 -0
  65. package/lib/telegram-bot.ts +912 -0
  66. package/lib/terminal-server.ts +70 -0
  67. package/lib/terminal-standalone.ts +363 -0
  68. package/middleware.ts +33 -0
  69. package/next-env.d.ts +6 -0
  70. package/next.config.ts +16 -0
  71. package/package.json +66 -0
  72. package/postcss.config.mjs +7 -0
  73. package/src/config/index.ts +119 -0
  74. package/src/core/db/database.ts +133 -0
  75. package/src/core/memory/strategy.ts +32 -0
  76. package/src/core/providers/chat.ts +65 -0
  77. package/src/core/providers/registry.ts +60 -0
  78. package/src/core/session/manager.ts +190 -0
  79. package/src/types/index.ts +128 -0
  80. package/tsconfig.json +41 -0
@@ -0,0 +1,351 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef } from 'react';
4
+ import MarkdownContent from './MarkdownContent';
5
+ import type { Task, TaskLogEntry } from '@/src/types';
6
+
7
+ export default function TaskDetail({
8
+ task,
9
+ onRefresh,
10
+ onFollowUp,
11
+ }: {
12
+ task: Task;
13
+ onRefresh: () => void;
14
+ onFollowUp?: (data: { projectName: string; prompt: string; conversationId: string }) => void;
15
+ }) {
16
+ const [liveLog, setLiveLog] = useState<TaskLogEntry[]>(task.log);
17
+ const [liveStatus, setLiveStatus] = useState(task.status);
18
+ const [tab, setTab] = useState<'log' | 'diff' | 'result'>('log');
19
+ const [expandedTools, setExpandedTools] = useState<Set<number>>(new Set());
20
+ const [followUpText, setFollowUpText] = useState('');
21
+ const logEndRef = useRef<HTMLDivElement>(null);
22
+
23
+ // SSE stream for running tasks
24
+ useEffect(() => {
25
+ if (task.status !== 'running' && task.status !== 'queued') {
26
+ setLiveLog(task.log);
27
+ setLiveStatus(task.status);
28
+ return;
29
+ }
30
+
31
+ setLiveLog([]);
32
+ const es = new EventSource(`/api/tasks/${task.id}/stream`);
33
+
34
+ es.onmessage = (event) => {
35
+ try {
36
+ const data = JSON.parse(event.data);
37
+ if (data.type === 'log') {
38
+ setLiveLog(prev => [...prev, data.entry]);
39
+ } else if (data.type === 'status') {
40
+ setLiveStatus(data.status);
41
+ if (data.status === 'done' || data.status === 'failed' || data.status === 'cancelled') {
42
+ onRefresh();
43
+ }
44
+ } else if (data.type === 'complete' && data.task) {
45
+ setLiveLog(data.task.log);
46
+ setLiveStatus(data.task.status);
47
+ }
48
+ } catch {}
49
+ };
50
+
51
+ es.onerror = () => {
52
+ es.close();
53
+ onRefresh();
54
+ };
55
+
56
+ return () => es.close();
57
+ }, [task.id, task.status, onRefresh]);
58
+
59
+ useEffect(() => {
60
+ logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
61
+ }, [liveLog]);
62
+
63
+ const handleAction = async (action: string) => {
64
+ await fetch(`/api/tasks/${task.id}`, {
65
+ method: action === 'delete' ? 'DELETE' : 'POST',
66
+ headers: { 'Content-Type': 'application/json' },
67
+ body: action !== 'delete' ? JSON.stringify({ action }) : undefined,
68
+ });
69
+ onRefresh();
70
+ };
71
+
72
+ const toggleTool = (i: number) => {
73
+ setExpandedTools(prev => {
74
+ const next = new Set(prev);
75
+ next.has(i) ? next.delete(i) : next.add(i);
76
+ return next;
77
+ });
78
+ };
79
+
80
+ const displayLog = liveLog.length > 0 ? liveLog : task.log;
81
+
82
+ return (
83
+ <div className="flex flex-col h-full">
84
+ {/* Header */}
85
+ <div className="border-b border-[var(--border)] px-4 py-2 shrink-0">
86
+ <div className="flex items-center justify-between mb-1">
87
+ <div className="flex items-center gap-2">
88
+ <StatusBadge status={liveStatus} />
89
+ <span className="text-sm font-semibold">{task.projectName}</span>
90
+ <span className="text-[10px] text-[var(--text-secondary)] font-mono">{task.id}</span>
91
+ </div>
92
+ <div className="flex items-center gap-2">
93
+ {(liveStatus === 'running' || liveStatus === 'queued') && (
94
+ <button onClick={() => handleAction('cancel')} className="text-[10px] px-2 py-0.5 text-[var(--red)] border border-[var(--red)]/30 rounded hover:bg-[var(--red)] hover:text-white">
95
+ Cancel
96
+ </button>
97
+ )}
98
+ {(liveStatus === 'failed' || liveStatus === 'cancelled') && (
99
+ <button onClick={() => handleAction('retry')} className="text-[10px] px-2 py-0.5 text-[var(--accent)] border border-[var(--accent)]/30 rounded hover:bg-[var(--accent)] hover:text-white">
100
+ Retry
101
+ </button>
102
+ )}
103
+ <button onClick={() => handleAction('delete')} className="text-[10px] px-2 py-0.5 text-[var(--text-secondary)] hover:text-[var(--red)]">
104
+ Delete
105
+ </button>
106
+ </div>
107
+ </div>
108
+ <p className="text-xs text-[var(--text-secondary)] mb-2">{task.prompt}</p>
109
+ <div className="flex items-center gap-3 text-[10px] text-[var(--text-secondary)]">
110
+ <span>Created: {new Date(task.createdAt).toLocaleString()}</span>
111
+ {task.startedAt && <span>Started: {new Date(task.startedAt).toLocaleString()}</span>}
112
+ {task.completedAt && <span>Completed: {new Date(task.completedAt).toLocaleString()}</span>}
113
+ {task.costUSD != null && <span>Cost: ${task.costUSD.toFixed(4)}</span>}
114
+ </div>
115
+ </div>
116
+
117
+ {/* Tabs */}
118
+ <div className="border-b border-[var(--border)] px-4 flex gap-0.5 shrink-0">
119
+ {(['log', 'result', 'diff'] as const).map(t => (
120
+ <button
121
+ key={t}
122
+ onClick={() => setTab(t)}
123
+ className={`text-[11px] px-3 py-1.5 border-b-2 transition-colors ${
124
+ tab === t ? 'border-[var(--accent)] text-[var(--accent)]' : 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
125
+ }`}
126
+ >
127
+ {t === 'log' ? `Log (${displayLog.length})` : t === 'diff' ? 'Git Diff' : 'Result'}
128
+ </button>
129
+ ))}
130
+ </div>
131
+
132
+ {/* Content */}
133
+ <div className="flex-1 overflow-y-auto p-4 text-sm">
134
+ {tab === 'log' && (
135
+ <div className="space-y-2">
136
+ {displayLog.map((entry, i) => (
137
+ <LogEntry key={i} entry={entry} index={i} expanded={expandedTools.has(i)} onToggle={() => toggleTool(i)} />
138
+ ))}
139
+ {liveStatus === 'running' && (
140
+ <div className="text-[var(--accent)] animate-pulse py-1 text-xs">working...</div>
141
+ )}
142
+ <div ref={logEndRef} />
143
+ </div>
144
+ )}
145
+
146
+ {tab === 'result' && (
147
+ <div className="prose-container">
148
+ {task.resultSummary ? (
149
+ <MarkdownContent content={task.resultSummary} />
150
+ ) : task.error ? (
151
+ <div className="p-3 bg-red-900/10 border border-red-800/20 rounded">
152
+ <pre className="whitespace-pre-wrap break-words text-[var(--red)] text-xs font-mono">{task.error}</pre>
153
+ </div>
154
+ ) : (
155
+ <p className="text-[var(--text-secondary)] text-xs">
156
+ {liveStatus === 'running' || liveStatus === 'queued' ? 'Task is still running...' : 'No result'}
157
+ </p>
158
+ )}
159
+ </div>
160
+ )}
161
+
162
+ {tab === 'diff' && (
163
+ <div>
164
+ {task.gitDiff ? (
165
+ <div className="bg-[var(--bg-tertiary)] rounded border border-[var(--border)] overflow-hidden">
166
+ <pre className="p-3 text-xs font-mono overflow-x-auto">
167
+ {task.gitDiff.split('\n').map((line, i) => (
168
+ <div key={i} className={`px-2 ${
169
+ line.startsWith('+++') || line.startsWith('---') ? 'text-[var(--text-secondary)] font-semibold' :
170
+ line.startsWith('+') ? 'text-[var(--green)] bg-green-500/5' :
171
+ line.startsWith('-') ? 'text-[var(--red)] bg-red-500/5' :
172
+ line.startsWith('@@') ? 'text-[var(--accent)] bg-[var(--accent)]/5 font-semibold' :
173
+ line.startsWith('diff ') ? 'text-[var(--text-primary)] font-bold border-t border-[var(--border)] pt-2 mt-2' :
174
+ 'text-[var(--text-secondary)]'
175
+ }`}>
176
+ {line}
177
+ </div>
178
+ ))}
179
+ </pre>
180
+ </div>
181
+ ) : (
182
+ <p className="text-[var(--text-secondary)] text-xs">
183
+ {liveStatus === 'running' ? 'Diff will be captured after completion' : 'No changes detected'}
184
+ </p>
185
+ )}
186
+ </div>
187
+ )}
188
+ </div>
189
+
190
+ {/* Follow-up input for completed tasks */}
191
+ {liveStatus === 'done' && task.conversationId && onFollowUp && (
192
+ <div className="border-t border-[var(--border)] px-4 py-2 shrink-0">
193
+ <form
194
+ onSubmit={(e) => {
195
+ e.preventDefault();
196
+ if (!followUpText.trim()) return;
197
+ onFollowUp({
198
+ projectName: task.projectName,
199
+ prompt: followUpText.trim(),
200
+ conversationId: task.conversationId!,
201
+ });
202
+ setFollowUpText('');
203
+ }}
204
+ className="flex gap-2"
205
+ >
206
+ <input
207
+ type="text"
208
+ value={followUpText}
209
+ onChange={e => setFollowUpText(e.target.value)}
210
+ placeholder="Send follow-up message (continues this session)..."
211
+ className="flex-1 px-3 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
212
+ />
213
+ <button
214
+ type="submit"
215
+ disabled={!followUpText.trim()}
216
+ className="text-xs px-3 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
217
+ >
218
+ Send
219
+ </button>
220
+ </form>
221
+ <p className="text-[9px] text-[var(--text-secondary)] mt-1">
222
+ Session <span className="font-mono">{task.conversationId.slice(0, 12)}...</span> — creates a new task continuing this conversation
223
+ </p>
224
+ </div>
225
+ )}
226
+ </div>
227
+ );
228
+ }
229
+
230
+ function StatusBadge({ status }: { status: string }) {
231
+ const colors: Record<string, string> = {
232
+ queued: 'bg-yellow-500/20 text-yellow-500',
233
+ running: 'bg-green-500/20 text-[var(--green)]',
234
+ done: 'bg-blue-500/20 text-blue-400',
235
+ failed: 'bg-red-500/20 text-[var(--red)]',
236
+ cancelled: 'bg-gray-500/20 text-[var(--text-secondary)]',
237
+ };
238
+ return (
239
+ <span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${colors[status] || ''}`}>
240
+ {status}
241
+ </span>
242
+ );
243
+ }
244
+
245
+ function LogEntry({ entry, index, expanded, onToggle }: {
246
+ entry: TaskLogEntry;
247
+ index: number;
248
+ expanded: boolean;
249
+ onToggle: () => void;
250
+ }) {
251
+ // System init
252
+ if (entry.type === 'system' && entry.subtype === 'init') {
253
+ return (
254
+ <div className="text-[10px] text-[var(--text-secondary)] py-0.5 flex items-center gap-1">
255
+ <span className="opacity-50">⚙</span> {entry.content}
256
+ </div>
257
+ );
258
+ }
259
+
260
+ // Error
261
+ if (entry.subtype === 'error') {
262
+ return (
263
+ <div className="p-2 bg-red-900/10 border border-red-800/20 rounded text-xs">
264
+ <pre className="whitespace-pre-wrap break-words text-[var(--red)] font-mono">{entry.content}</pre>
265
+ </div>
266
+ );
267
+ }
268
+
269
+ // Tool use — collapsible
270
+ if (entry.subtype === 'tool_use') {
271
+ const toolContent = formatToolContent(entry.content);
272
+ const isLong = toolContent.length > 80;
273
+
274
+ return (
275
+ <div className="border border-[var(--border)] rounded overflow-hidden">
276
+ <button
277
+ onClick={onToggle}
278
+ className="w-full flex items-center gap-2 px-2 py-1.5 bg-[var(--bg-tertiary)] hover:bg-[var(--border)]/30 transition-colors text-left"
279
+ >
280
+ <span className="text-[10px] px-1.5 py-0.5 bg-[var(--accent)]/15 text-[var(--accent)] rounded font-medium font-mono">
281
+ {entry.tool || 'tool'}
282
+ </span>
283
+ <span className="text-[11px] text-[var(--text-secondary)] truncate flex-1 font-mono">
284
+ {isLong && !expanded ? toolContent.slice(0, 80) + '...' : (!isLong ? toolContent : '')}
285
+ </span>
286
+ {isLong && (
287
+ <span className="text-[9px] text-[var(--text-secondary)] shrink-0">{expanded ? '▲' : '▼'}</span>
288
+ )}
289
+ </button>
290
+ {(expanded || !isLong) && isLong && (
291
+ <pre className="px-3 py-2 text-[11px] text-[var(--text-secondary)] font-mono whitespace-pre-wrap break-words max-h-60 overflow-y-auto border-t border-[var(--border)]">
292
+ {toolContent}
293
+ </pre>
294
+ )}
295
+ </div>
296
+ );
297
+ }
298
+
299
+ // Tool result — collapsible with border accent
300
+ if (entry.subtype === 'tool_result') {
301
+ const content = formatToolContent(entry.content);
302
+ const isLong = content.length > 150;
303
+
304
+ return (
305
+ <div className="ml-4 border-l-2 border-[var(--accent)]/30 pl-3">
306
+ <pre className={`text-[11px] text-[var(--text-secondary)] font-mono whitespace-pre-wrap break-words ${isLong && !expanded ? 'max-h-16 overflow-hidden' : 'max-h-80 overflow-y-auto'}`}>
307
+ {content}
308
+ </pre>
309
+ {isLong && !expanded && (
310
+ <button onClick={onToggle} className="text-[9px] text-[var(--accent)] hover:underline mt-0.5">
311
+ show more
312
+ </button>
313
+ )}
314
+ </div>
315
+ );
316
+ }
317
+
318
+ // Final result
319
+ if (entry.type === 'result') {
320
+ return (
321
+ <div className="p-3 bg-green-900/5 border border-green-800/15 rounded">
322
+ <MarkdownContent content={entry.content} />
323
+ </div>
324
+ );
325
+ }
326
+
327
+ // Assistant text — render as markdown
328
+ return (
329
+ <div className="py-1">
330
+ <MarkdownContent content={entry.content} />
331
+ </div>
332
+ );
333
+ }
334
+
335
+ // MarkdownContent is now imported from ./MarkdownContent
336
+
337
+ function formatToolContent(content: string): string {
338
+ try {
339
+ const parsed = JSON.parse(content);
340
+ if (typeof parsed === 'object') {
341
+ // For common tool patterns, show a cleaner format
342
+ if (parsed.command) return `$ ${parsed.command}`;
343
+ if (parsed.file_path) return parsed.file_path;
344
+ if (parsed.pattern) return `/${parsed.pattern}/`;
345
+ return JSON.stringify(parsed, null, 2);
346
+ }
347
+ return content;
348
+ } catch {
349
+ return content;
350
+ }
351
+ }
@@ -0,0 +1,163 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+
5
+ interface TunnelStatus {
6
+ status: 'stopped' | 'starting' | 'running' | 'error';
7
+ url: string | null;
8
+ error: string | null;
9
+ }
10
+
11
+ export default function TunnelToggle() {
12
+ const [tunnel, setTunnel] = useState<TunnelStatus>({ status: 'stopped', url: null, error: null });
13
+ const [loading, setLoading] = useState(false);
14
+ const [copied, setCopied] = useState(false);
15
+ const [isRemote, setIsRemote] = useState(false);
16
+ const [confirmStop, setConfirmStop] = useState(false);
17
+
18
+ useEffect(() => {
19
+ setIsRemote(!['localhost', '127.0.0.1'].includes(window.location.hostname));
20
+ }, []);
21
+
22
+ const refresh = useCallback(() => {
23
+ fetch('/api/tunnel').then(r => r.json()).then(setTunnel).catch(() => {});
24
+ }, []);
25
+
26
+ useEffect(() => {
27
+ refresh();
28
+ const id = setInterval(refresh, 5000);
29
+ return () => clearInterval(id);
30
+ }, [refresh]);
31
+
32
+ useEffect(() => {
33
+ if (tunnel.status !== 'starting') return;
34
+ const id = setInterval(refresh, 2000);
35
+ return () => clearInterval(id);
36
+ }, [tunnel.status, refresh]);
37
+
38
+ const doStop = async () => {
39
+ setLoading(true);
40
+ try {
41
+ const res = await fetch('/api/tunnel', {
42
+ method: 'POST',
43
+ headers: { 'Content-Type': 'application/json' },
44
+ body: JSON.stringify({ action: 'stop' }),
45
+ });
46
+ const data = await res.json();
47
+ setTunnel(data);
48
+ } catch {}
49
+ setLoading(false);
50
+ setConfirmStop(false);
51
+ };
52
+
53
+ const doStart = async () => {
54
+ setLoading(true);
55
+ try {
56
+ const res = await fetch('/api/tunnel', {
57
+ method: 'POST',
58
+ headers: { 'Content-Type': 'application/json' },
59
+ body: JSON.stringify({ action: 'start' }),
60
+ });
61
+ const data = await res.json();
62
+ setTunnel(data);
63
+ } catch {}
64
+ setLoading(false);
65
+ };
66
+
67
+ const copyUrl = () => {
68
+ if (tunnel.url) {
69
+ navigator.clipboard.writeText(tunnel.url);
70
+ setCopied(true);
71
+ setTimeout(() => setCopied(false), 2000);
72
+ }
73
+ };
74
+
75
+ // Hide tunnel controls when accessing remotely
76
+ if (isRemote) {
77
+ return null;
78
+ }
79
+
80
+ // Stop confirmation dialog
81
+ if (confirmStop) {
82
+ return (
83
+ <div className="flex items-center gap-1.5">
84
+ <span className="text-[10px] text-[var(--text-secondary)]">Stop tunnel?</span>
85
+ <button
86
+ onClick={doStop}
87
+ disabled={loading}
88
+ className="text-[10px] px-2 py-0.5 bg-[var(--red)] text-white rounded hover:opacity-90"
89
+ >
90
+ Confirm
91
+ </button>
92
+ <button
93
+ onClick={() => setConfirmStop(false)}
94
+ className="text-[10px] px-2 py-0.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
95
+ >
96
+ Cancel
97
+ </button>
98
+ </div>
99
+ );
100
+ }
101
+
102
+ if (tunnel.status === 'stopped' && !tunnel.error) {
103
+ return (
104
+ <button
105
+ onClick={doStart}
106
+ disabled={loading}
107
+ className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)] transition-colors disabled:opacity-50"
108
+ title="Start Cloudflare Tunnel for remote access"
109
+ >
110
+ {loading ? 'Starting...' : 'Tunnel'}
111
+ </button>
112
+ );
113
+ }
114
+
115
+ if (tunnel.status === 'starting') {
116
+ return (
117
+ <span className="text-[10px] px-2 py-0.5 border border-[var(--yellow)] rounded text-[var(--yellow)]">
118
+ Tunnel starting...
119
+ </span>
120
+ );
121
+ }
122
+
123
+ if (tunnel.status === 'running' && tunnel.url) {
124
+ return (
125
+ <div className="flex items-center gap-1.5">
126
+ <button
127
+ onClick={copyUrl}
128
+ className="text-[10px] px-2 py-0.5 border border-[var(--green)] rounded text-[var(--green)] hover:bg-[var(--green)] hover:text-black transition-colors truncate max-w-[200px]"
129
+ title={`Click to copy: ${tunnel.url}`}
130
+ >
131
+ {copied ? 'Copied!' : tunnel.url.replace('https://', '')}
132
+ </button>
133
+ <button
134
+ onClick={() => setConfirmStop(true)}
135
+ disabled={loading}
136
+ className="text-[10px] px-1.5 py-0.5 text-[var(--red)] hover:bg-[var(--red)] hover:text-white rounded transition-colors"
137
+ title="Stop tunnel"
138
+ >
139
+ Stop
140
+ </button>
141
+ </div>
142
+ );
143
+ }
144
+
145
+ if (tunnel.status === 'error') {
146
+ return (
147
+ <div className="flex items-center gap-1.5">
148
+ <span className="text-[10px] text-[var(--red)] truncate max-w-[200px]" title={tunnel.error || ''}>
149
+ Tunnel error
150
+ </span>
151
+ <button
152
+ onClick={doStart}
153
+ disabled={loading}
154
+ className="text-[10px] px-2 py-0.5 border border-[var(--border)] rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
155
+ >
156
+ Retry
157
+ </button>
158
+ </div>
159
+ );
160
+ }
161
+
162
+ return null;
163
+ }