@aion0/forge 0.10.76 → 0.10.78
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.
- package/RELEASE_NOTES.md +8 -6
- package/app/chat/page.tsx +53 -1
- package/components/ConnectorsPanel.tsx +3 -3
- package/components/EnterpriseBadge.tsx +5 -5
- package/components/LoginStatusPanel.tsx +2 -1
- package/components/MobileChat.tsx +225 -0
- package/components/MobileView.tsx +22 -2
- package/components/OnboardingWizard.tsx +4 -4
- package/lib/chat/telegram-bridge.ts +15 -0
- package/lib/help-docs/00-overview.md +2 -0
- package/lib/help-docs/02-telegram.md +3 -0
- package/lib/projects.ts +15 -5
- package/lib/telegram-bot.ts +74 -26
- package/lib/ui/openPortal.ts +60 -0
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.78
|
|
2
2
|
|
|
3
|
-
Released: 2026-06-
|
|
3
|
+
Released: 2026-06-13
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.77
|
|
6
6
|
|
|
7
7
|
### Other
|
|
8
|
-
-
|
|
9
|
-
-
|
|
8
|
+
- fix(projects): prefer ANY local checkout over clone when project is empty
|
|
9
|
+
- feat(chat): live pipeline + task runtime chips in web chat
|
|
10
|
+
- feat(mobile): default to Forge chat agent, toggle to terminal sessions
|
|
11
|
+
- feat(telegram): full chat mode — /chat picks session, all messages route to agent, /endchat exits
|
|
10
12
|
|
|
11
13
|
|
|
12
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.
|
|
14
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.77...v0.10.78
|
package/app/chat/page.tsx
CHANGED
|
@@ -65,6 +65,13 @@ export default function ChatPage() {
|
|
|
65
65
|
// Background-watch progress chips (watch_id → {text, ts}). Ambient,
|
|
66
66
|
// updated in place; pruned when stale. Not part of the message thread.
|
|
67
67
|
const [watchChips, setWatchChips] = useState<Record<string, { text: string; ts: number }>>({});
|
|
68
|
+
// Live pipeline + task runtime (polled from /api/activity/summary). Shows
|
|
69
|
+
// what the chat-triggered trigger_pipeline / dispatch_task — or anything
|
|
70
|
+
// else — has in flight, without leaving the chat.
|
|
71
|
+
const [running, setRunning] = useState<{
|
|
72
|
+
pipelines: Array<{ id: string; workflowName: string; currentNode: string | null; progress: { done: number; total: number } }>;
|
|
73
|
+
tasks: Array<{ id: string; project: string; prompt_preview: string; status: string }>;
|
|
74
|
+
}>({ pipelines: [], tasks: [] });
|
|
68
75
|
const [input, setInput] = useState('');
|
|
69
76
|
const [streaming, setStreaming] = useState(false);
|
|
70
77
|
// True from Stop-click → next iteration boundary on the backend. Drives
|
|
@@ -258,6 +265,31 @@ export default function ChatPage() {
|
|
|
258
265
|
return () => clearInterval(t);
|
|
259
266
|
}, []);
|
|
260
267
|
|
|
268
|
+
// ─── Poll live pipeline + task runtime ────────────────────
|
|
269
|
+
useEffect(() => {
|
|
270
|
+
let alive = true;
|
|
271
|
+
const tick = async () => {
|
|
272
|
+
try {
|
|
273
|
+
const r = await fetch('/api/activity/summary');
|
|
274
|
+
if (!r.ok) return;
|
|
275
|
+
const j = await r.json();
|
|
276
|
+
if (!alive) return;
|
|
277
|
+
setRunning({
|
|
278
|
+
pipelines: (j.running || []).map((p: any) => ({
|
|
279
|
+
id: p.id, workflowName: p.workflowName, currentNode: p.currentNode,
|
|
280
|
+
progress: p.progress || { done: 0, total: 0 },
|
|
281
|
+
})),
|
|
282
|
+
tasks: (j.running_tasks || []).map((t: any) => ({
|
|
283
|
+
id: t.id, project: t.project, prompt_preview: t.prompt_preview, status: t.status,
|
|
284
|
+
})),
|
|
285
|
+
});
|
|
286
|
+
} catch { /* keep last */ }
|
|
287
|
+
};
|
|
288
|
+
tick();
|
|
289
|
+
const iv = setInterval(tick, 5000);
|
|
290
|
+
return () => { alive = false; clearInterval(iv); };
|
|
291
|
+
}, []);
|
|
292
|
+
|
|
261
293
|
// ─── Auto-resize composer ─────────────────────────────────
|
|
262
294
|
useEffect(() => {
|
|
263
295
|
const el = composerRef.current;
|
|
@@ -740,7 +772,7 @@ export default function ChatPage() {
|
|
|
740
772
|
)}
|
|
741
773
|
</div>
|
|
742
774
|
|
|
743
|
-
{Object.keys(watchChips).length > 0 && (
|
|
775
|
+
{(Object.keys(watchChips).length > 0 || running.pipelines.length > 0 || running.tasks.length > 0) && (
|
|
744
776
|
<div className="px-6 pt-2">
|
|
745
777
|
<div className="max-w-3xl mx-auto flex flex-wrap gap-2">
|
|
746
778
|
{Object.entries(watchChips).map(([id, w]) => (
|
|
@@ -753,6 +785,26 @@ export default function ChatPage() {
|
|
|
753
785
|
{w.text}
|
|
754
786
|
</span>
|
|
755
787
|
))}
|
|
788
|
+
{running.pipelines.map((p) => (
|
|
789
|
+
<span
|
|
790
|
+
key={p.id}
|
|
791
|
+
title={`pipeline ${p.id}${p.currentNode ? ` — ${p.currentNode}` : ''}`}
|
|
792
|
+
className="inline-flex items-center gap-1.5 text-xs rounded-full border border-[var(--border)] bg-[var(--bg-secondary)] px-3 py-1 text-[var(--text-secondary)]"
|
|
793
|
+
>
|
|
794
|
+
<span className="inline-block h-1.5 w-1.5 rounded-full bg-sky-400 animate-pulse" />
|
|
795
|
+
⛓ {p.workflowName} {p.progress.total > 0 ? `${p.progress.done}/${p.progress.total}` : ''}{p.currentNode ? ` · ${p.currentNode}` : ''}
|
|
796
|
+
</span>
|
|
797
|
+
))}
|
|
798
|
+
{running.tasks.map((t) => (
|
|
799
|
+
<span
|
|
800
|
+
key={t.id}
|
|
801
|
+
title={`task ${t.id} (${t.project})`}
|
|
802
|
+
className="inline-flex items-center gap-1.5 text-xs rounded-full border border-[var(--border)] bg-[var(--bg-secondary)] px-3 py-1 text-[var(--text-secondary)]"
|
|
803
|
+
>
|
|
804
|
+
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
|
805
|
+
⚙ {t.project}: {t.prompt_preview.slice(0, 30)}
|
|
806
|
+
</span>
|
|
807
|
+
))}
|
|
756
808
|
</div>
|
|
757
809
|
</div>
|
|
758
810
|
)}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
20
|
+
import { openPortal } from '@/lib/ui/openPortal';
|
|
20
21
|
|
|
21
22
|
interface MarketEntry {
|
|
22
23
|
id: string;
|
|
@@ -929,9 +930,8 @@ function TemplateImportModal({
|
|
|
929
930
|
{p.url && (
|
|
930
931
|
<a
|
|
931
932
|
href={p.url}
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
className="ml-auto text-[10px] text-[var(--accent)] hover:underline"
|
|
933
|
+
onClick={(e) => { e.preventDefault(); void openPortal(p.url!); }}
|
|
934
|
+
className="ml-auto text-[10px] text-[var(--accent)] hover:underline cursor-pointer"
|
|
935
935
|
title={p.url}
|
|
936
936
|
>
|
|
937
937
|
↗ {p.url_label || 'Get token'}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { useEffect, useRef, useState } from 'react';
|
|
19
|
+
import { openPortal } from '@/lib/ui/openPortal';
|
|
19
20
|
|
|
20
21
|
interface SourceView {
|
|
21
22
|
tenant_id: string;
|
|
@@ -171,7 +172,7 @@ export default function EnterpriseBadge({ onOpenSettings: _onOpenSettings }: { o
|
|
|
171
172
|
r.source.refresh.url,
|
|
172
173
|
);
|
|
173
174
|
failed.forEach((r, i) => setTimeout(() => {
|
|
174
|
-
|
|
175
|
+
void openPortal(r.source.refresh.url!);
|
|
175
176
|
}, i * 250));
|
|
176
177
|
}
|
|
177
178
|
} catch (e) {
|
|
@@ -183,7 +184,7 @@ export default function EnterpriseBadge({ onOpenSettings: _onOpenSettings }: { o
|
|
|
183
184
|
|
|
184
185
|
const handleOpenWeb = (row: { refresh: { kind: string; url?: string } }) => {
|
|
185
186
|
if (row.refresh.kind === 'open-url' && row.refresh.url) {
|
|
186
|
-
|
|
187
|
+
void openPortal(row.refresh.url);
|
|
187
188
|
}
|
|
188
189
|
};
|
|
189
190
|
|
|
@@ -533,10 +534,9 @@ export default function EnterpriseBadge({ onOpenSettings: _onOpenSettings }: { o
|
|
|
533
534
|
<div key={i} className="flex items-center gap-1">
|
|
534
535
|
<a
|
|
535
536
|
href={u.url}
|
|
536
|
-
|
|
537
|
-
rel="noopener"
|
|
537
|
+
onClick={(e) => { e.preventDefault(); void openPortal(u.url); }}
|
|
538
538
|
title={u.error || u.host}
|
|
539
|
-
className="flex-1 bg-black/30 px-1.5 py-0.5 rounded font-mono text-[10px] break-all hover:bg-black/40 underline"
|
|
539
|
+
className="flex-1 bg-black/30 px-1.5 py-0.5 rounded font-mono text-[10px] break-all hover:bg-black/40 underline cursor-pointer"
|
|
540
540
|
>
|
|
541
541
|
{u.url}
|
|
542
542
|
</a>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useCallback } from 'react';
|
|
4
|
+
import { openPortal } from '@/lib/ui/openPortal';
|
|
4
5
|
|
|
5
6
|
type LoginCategory = 'browser' | 'token' | 'external';
|
|
6
7
|
|
|
@@ -104,7 +105,7 @@ export default function LoginStatusPanel({ onClose }: { onClose: () => void }) {
|
|
|
104
105
|
const refresh = (source: LoginSource) => {
|
|
105
106
|
const r = source.refresh;
|
|
106
107
|
if (r.kind === 'open-url') {
|
|
107
|
-
|
|
108
|
+
void openPortal(r.url);
|
|
108
109
|
} else if (r.kind === 'show-command') {
|
|
109
110
|
setShowCmd({ command: r.command, description: r.description });
|
|
110
111
|
} else if (r.kind === 'open-settings') {
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mobile chat view — talks to chat-standalone via /api/chat-proxy, the same
|
|
5
|
+
* backend as the web /chat and the Telegram chat bridge. Self-contained:
|
|
6
|
+
* session picker (+ new), message history, streaming SSE. Rendered by
|
|
7
|
+
* MobileView when viewMode === 'chat' (the default).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
11
|
+
|
|
12
|
+
const PROXY = '/api/chat-proxy';
|
|
13
|
+
|
|
14
|
+
interface ChatSession { id: string; title?: string; updated_at?: string }
|
|
15
|
+
interface Block { type: string; text?: string; name?: string }
|
|
16
|
+
interface ChatMsg { id?: string; role: string; blocks?: Block[]; content?: string; error?: string }
|
|
17
|
+
|
|
18
|
+
function msgText(m: ChatMsg): string {
|
|
19
|
+
if (typeof m.content === 'string' && m.content) return m.content;
|
|
20
|
+
if (Array.isArray(m.blocks)) {
|
|
21
|
+
return m.blocks
|
|
22
|
+
.map((b) => (b.type === 'text' ? (b.text || '') : b.type === 'tool_use' ? `⚙ ${b.name || 'tool'}` : ''))
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
.join('\n');
|
|
25
|
+
}
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default function MobileChat() {
|
|
30
|
+
const [sessions, setSessions] = useState<ChatSession[]>([]);
|
|
31
|
+
const [activeId, setActiveId] = useState<string>('');
|
|
32
|
+
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
|
33
|
+
const [input, setInput] = useState('');
|
|
34
|
+
const [streaming, setStreaming] = useState(false);
|
|
35
|
+
const [partial, setPartial] = useState('');
|
|
36
|
+
const [showPicker, setShowPicker] = useState(false);
|
|
37
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
38
|
+
const srcRef = useRef<EventSource | null>(null);
|
|
39
|
+
|
|
40
|
+
const refreshSessions = useCallback(async () => {
|
|
41
|
+
try {
|
|
42
|
+
const r = await fetch(`${PROXY}/sessions?limit=50`);
|
|
43
|
+
const j = await r.json();
|
|
44
|
+
setSessions(Array.isArray(j?.sessions) ? j.sessions : []);
|
|
45
|
+
} catch { /* keep */ }
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
const loadMessages = useCallback(async (id: string) => {
|
|
49
|
+
try {
|
|
50
|
+
const r = await fetch(`${PROXY}/sessions/${encodeURIComponent(id)}?limit=500`);
|
|
51
|
+
const j = await r.json();
|
|
52
|
+
setMessages(Array.isArray(j?.messages) ? j.messages : []);
|
|
53
|
+
} catch { /* keep */ }
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
// First load: pick the main session (or the most recent / a fresh one).
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
(async () => {
|
|
59
|
+
await refreshSessions();
|
|
60
|
+
try {
|
|
61
|
+
const r = await fetch(`${PROXY}/sessions/main`);
|
|
62
|
+
if (r.ok) {
|
|
63
|
+
const j = await r.json();
|
|
64
|
+
const id = j?.session?.id || j?.id;
|
|
65
|
+
if (id) { setActiveId(id); return; }
|
|
66
|
+
}
|
|
67
|
+
} catch { /* fall through */ }
|
|
68
|
+
try {
|
|
69
|
+
const r = await fetch(`${PROXY}/sessions?limit=1`);
|
|
70
|
+
const j = await r.json();
|
|
71
|
+
const id = j?.sessions?.[0]?.id;
|
|
72
|
+
if (id) setActiveId(id);
|
|
73
|
+
} catch { /* none */ }
|
|
74
|
+
})();
|
|
75
|
+
}, [refreshSessions]);
|
|
76
|
+
|
|
77
|
+
useEffect(() => { if (activeId) loadMessages(activeId); }, [activeId, loadMessages]);
|
|
78
|
+
|
|
79
|
+
// Auto-scroll
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
82
|
+
}, [messages, partial]);
|
|
83
|
+
|
|
84
|
+
// SSE subscription per active session.
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!activeId) return;
|
|
87
|
+
srcRef.current?.close();
|
|
88
|
+
const src = new EventSource(`${PROXY}/sessions/${encodeURIComponent(activeId)}/events`);
|
|
89
|
+
srcRef.current = src;
|
|
90
|
+
src.onmessage = (ev) => {
|
|
91
|
+
let p: { type?: string; data?: any };
|
|
92
|
+
try { p = JSON.parse(ev.data); } catch { return; }
|
|
93
|
+
const data = p.data || {};
|
|
94
|
+
if (p.type === 'text_delta') {
|
|
95
|
+
setPartial((s) => s + (data.delta || ''));
|
|
96
|
+
} else if (p.type === 'message_saved') {
|
|
97
|
+
loadMessages(activeId);
|
|
98
|
+
setPartial('');
|
|
99
|
+
} else if (p.type === 'turn_done') {
|
|
100
|
+
setStreaming(false);
|
|
101
|
+
setPartial('');
|
|
102
|
+
loadMessages(activeId);
|
|
103
|
+
refreshSessions();
|
|
104
|
+
} else if (p.type === 'error') {
|
|
105
|
+
setStreaming(false);
|
|
106
|
+
setPartial('');
|
|
107
|
+
setMessages((m) => [...m, { role: 'system', content: `Error: ${data.error || 'unknown'}` }]);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
return () => { src.close(); };
|
|
111
|
+
}, [activeId, loadMessages, refreshSessions]);
|
|
112
|
+
|
|
113
|
+
const send = async () => {
|
|
114
|
+
const text = input.trim();
|
|
115
|
+
if (!text || !activeId || streaming) return;
|
|
116
|
+
setInput('');
|
|
117
|
+
setStreaming(true);
|
|
118
|
+
// Optimistic user bubble; turn_done reloads the real thread from DB.
|
|
119
|
+
setMessages((m) => [...m, { role: 'user', content: text }]);
|
|
120
|
+
try {
|
|
121
|
+
await fetch(`${PROXY}/sessions/${encodeURIComponent(activeId)}/messages`, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { 'content-type': 'application/json' },
|
|
124
|
+
body: JSON.stringify({ text }),
|
|
125
|
+
});
|
|
126
|
+
} catch (e) {
|
|
127
|
+
setStreaming(false);
|
|
128
|
+
setMessages((m) => [...m, { role: 'system', content: `Send failed: ${(e as Error).message}` }]);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const newSession = async () => {
|
|
133
|
+
try {
|
|
134
|
+
const r = await fetch(`${PROXY}/sessions`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: { 'content-type': 'application/json' },
|
|
137
|
+
body: JSON.stringify({ title: 'Mobile chat' }),
|
|
138
|
+
});
|
|
139
|
+
const j = await r.json();
|
|
140
|
+
const id = j?.session?.id || j?.id;
|
|
141
|
+
if (id) { setActiveId(id); setMessages([]); setShowPicker(false); await refreshSessions(); }
|
|
142
|
+
} catch { /* ignore */ }
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const pickSession = (id: string) => { setActiveId(id); setMessages([]); setShowPicker(false); };
|
|
146
|
+
|
|
147
|
+
const activeTitle = sessions.find((s) => s.id === activeId)?.title || (activeId ? activeId.slice(0, 8) : 'No session');
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div className="flex-1 flex flex-col min-h-0">
|
|
151
|
+
{/* Session bar */}
|
|
152
|
+
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 bg-[#161b22] border-b border-[#30363d]">
|
|
153
|
+
<button
|
|
154
|
+
onClick={() => { setShowPicker((v) => !v); if (!showPicker) refreshSessions(); }}
|
|
155
|
+
className="flex-1 text-left text-xs text-[#e6edf3] truncate active:opacity-70"
|
|
156
|
+
>💬 {activeTitle} ▾</button>
|
|
157
|
+
<button onClick={newSession} className="text-xs px-2 py-1 border border-[#30363d] rounded text-[#8b949e] active:bg-[#30363d]">+ New</button>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{showPicker && (
|
|
161
|
+
<div className="shrink-0 max-h-[40vh] overflow-y-auto bg-[#161b22] border-b border-[#30363d]">
|
|
162
|
+
{sessions.length === 0 ? (
|
|
163
|
+
<div className="px-3 py-4 text-xs text-[#8b949e] text-center">No sessions</div>
|
|
164
|
+
) : sessions.map((s) => (
|
|
165
|
+
<button
|
|
166
|
+
key={s.id}
|
|
167
|
+
onClick={() => pickSession(s.id)}
|
|
168
|
+
className={`w-full text-left px-3 py-2 border-b border-[#30363d]/50 text-xs active:bg-[#1c2128] ${s.id === activeId ? 'text-[#7c5bf0]' : 'text-[#e6edf3]'}`}
|
|
169
|
+
>
|
|
170
|
+
<span className="truncate">{s.title || s.id.slice(0, 12)}</span>
|
|
171
|
+
</button>
|
|
172
|
+
))}
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
|
|
176
|
+
{/* Messages */}
|
|
177
|
+
<div ref={scrollRef} className="flex-1 overflow-y-auto px-3 py-2 min-h-0 space-y-3">
|
|
178
|
+
{messages.length === 0 && !partial ? (
|
|
179
|
+
<div className="h-full flex items-center justify-center text-sm text-[#8b949e]">Send a message to start.</div>
|
|
180
|
+
) : messages.map((m, i) => {
|
|
181
|
+
const t = msgText(m);
|
|
182
|
+
if (!t) return null;
|
|
183
|
+
return (
|
|
184
|
+
<div key={m.id || i} className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
|
185
|
+
<div className={`max-w-[85%] rounded-2xl px-3 py-2 text-sm whitespace-pre-wrap break-words ${
|
|
186
|
+
m.role === 'user' ? 'bg-[#7c5bf0] text-white rounded-br-sm'
|
|
187
|
+
: m.role === 'system' ? 'bg-red-900/30 text-red-300 rounded-bl-sm'
|
|
188
|
+
: 'bg-[#1c2128] text-[#e6edf3] rounded-bl-sm'
|
|
189
|
+
}`}>{t}</div>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
})}
|
|
193
|
+
{partial && (
|
|
194
|
+
<div className="flex justify-start">
|
|
195
|
+
<div className="max-w-[85%] rounded-2xl rounded-bl-sm px-3 py-2 text-sm whitespace-pre-wrap break-words bg-[#1c2128] text-[#e6edf3]">{partial}</div>
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
{streaming && !partial && (
|
|
199
|
+
<div className="flex justify-start">
|
|
200
|
+
<div className="bg-[#1c2128] rounded-2xl rounded-bl-sm px-3 py-2 text-sm text-[#8b949e]">Thinking…</div>
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
{/* Input */}
|
|
206
|
+
<div className="shrink-0 flex items-center gap-2 px-3 py-2 bg-[#161b22] border-t border-[#30363d]">
|
|
207
|
+
<input
|
|
208
|
+
type="text"
|
|
209
|
+
value={input}
|
|
210
|
+
onChange={(e) => setInput(e.target.value)}
|
|
211
|
+
onKeyDown={(e) => { if (e.key === 'Enter' && !streaming) send(); }}
|
|
212
|
+
placeholder={activeId ? 'Type a message…' : 'No session'}
|
|
213
|
+
disabled={!activeId}
|
|
214
|
+
className="flex-1 bg-[#0d1117] border border-[#30363d] rounded-lg px-3 py-2 text-sm text-[#e6edf3] focus:outline-none focus:border-[#7c5bf0] disabled:opacity-50 min-w-0"
|
|
215
|
+
autoComplete="off" autoCorrect="off"
|
|
216
|
+
/>
|
|
217
|
+
<button
|
|
218
|
+
onClick={send}
|
|
219
|
+
disabled={!activeId || !input.trim() || streaming}
|
|
220
|
+
className="px-4 py-2 bg-[#7c5bf0] text-white rounded-lg text-sm font-medium disabled:opacity-50 shrink-0"
|
|
221
|
+
>Send</button>
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
4
|
+
import MobileChat from './MobileChat';
|
|
4
5
|
|
|
5
6
|
interface Project { name: string; path: string }
|
|
6
7
|
interface SessionInfo { sessionId: string; summary?: string; firstPrompt?: string; modified?: string }
|
|
@@ -24,6 +25,9 @@ export default function MobileView() {
|
|
|
24
25
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
25
26
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
26
27
|
const abortRef = useRef<AbortController | null>(null);
|
|
28
|
+
// Default to the Forge chat agent (same backend as web /chat + Telegram);
|
|
29
|
+
// Terminal tab keeps the original claude-session browse/continue view.
|
|
30
|
+
const [viewMode, setViewMode] = useState<'chat' | 'terminal'>('chat');
|
|
27
31
|
|
|
28
32
|
// Fetch projects
|
|
29
33
|
useEffect(() => {
|
|
@@ -225,9 +229,24 @@ export default function MobileView() {
|
|
|
225
229
|
|
|
226
230
|
return (
|
|
227
231
|
<div className="h-[100dvh] flex flex-col bg-[#0d1117] text-[#e6edf3]">
|
|
232
|
+
{/* Mode toggle — Chat (Forge agent) vs Terminal (claude sessions) */}
|
|
233
|
+
<div className="shrink-0 flex items-center gap-1.5 px-2 py-1.5 bg-[#161b22] border-b border-[#30363d]">
|
|
234
|
+
<span className="text-xs font-bold text-[#7c5bf0] mr-1">Forge</span>
|
|
235
|
+
<button
|
|
236
|
+
onClick={() => setViewMode('chat')}
|
|
237
|
+
className={`text-xs px-2.5 py-1 rounded ${viewMode === 'chat' ? 'bg-[#7c5bf0] text-white' : 'border border-[#30363d] text-[#8b949e] active:bg-[#30363d]'}`}
|
|
238
|
+
>💬 Chat</button>
|
|
239
|
+
<button
|
|
240
|
+
onClick={() => setViewMode('terminal')}
|
|
241
|
+
className={`text-xs px-2.5 py-1 rounded ${viewMode === 'terminal' ? 'bg-[#7c5bf0] text-white' : 'border border-[#30363d] text-[#8b949e] active:bg-[#30363d]'}`}
|
|
242
|
+
>⌨ Terminal</button>
|
|
243
|
+
<a href="/?force=desktop" className="ml-auto text-[9px] px-1.5 py-1 border border-[#30363d] rounded text-[#8b949e] active:bg-[#30363d]" title="Switch to desktop view">PC</a>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
{viewMode === 'chat' ? <MobileChat /> : (
|
|
247
|
+
<>
|
|
228
248
|
{/* Header */}
|
|
229
249
|
<header className="shrink-0 flex items-center gap-1.5 px-2 py-2 bg-[#161b22] border-b border-[#30363d]">
|
|
230
|
-
<span className="text-xs font-bold text-[#7c5bf0]">Forge</span>
|
|
231
250
|
<select
|
|
232
251
|
value={selectedProject?.path || ''}
|
|
233
252
|
onChange={e => {
|
|
@@ -273,7 +292,6 @@ export default function MobileView() {
|
|
|
273
292
|
{tunnelUrl && (
|
|
274
293
|
<button onClick={closeTunnel} className="text-xs px-1.5 py-1 border border-green-700 rounded text-green-400" title={tunnelUrl}>●</button>
|
|
275
294
|
)}
|
|
276
|
-
<a href="/?force=desktop" className="text-[9px] px-1.5 py-1 border border-[#30363d] rounded text-[#8b949e] active:bg-[#30363d]" title="Switch to desktop view">PC</a>
|
|
277
295
|
</header>
|
|
278
296
|
|
|
279
297
|
{/* Session list */}
|
|
@@ -380,6 +398,8 @@ export default function MobileView() {
|
|
|
380
398
|
</div>
|
|
381
399
|
)}
|
|
382
400
|
</div>
|
|
401
|
+
</>
|
|
402
|
+
)}
|
|
383
403
|
</div>
|
|
384
404
|
);
|
|
385
405
|
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { useEffect, useRef, useState } from 'react';
|
|
17
|
+
import { openPortal } from '@/lib/ui/openPortal';
|
|
17
18
|
|
|
18
19
|
// ─── Types echoing the API surface ───────────────────────────
|
|
19
20
|
|
|
@@ -746,9 +747,8 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
|
|
|
746
747
|
{!ok && ref?.kind === 'open-url' && ref.url && (
|
|
747
748
|
<a
|
|
748
749
|
href={ref.url}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
className="text-[var(--accent)] hover:underline ml-auto"
|
|
750
|
+
onClick={(e) => { e.preventDefault(); void openPortal(ref.url!); }}
|
|
751
|
+
className="text-[var(--accent)] hover:underline ml-auto cursor-pointer"
|
|
752
752
|
title={ref.description || ref.url}
|
|
753
753
|
>
|
|
754
754
|
↗ login
|
|
@@ -1242,7 +1242,7 @@ export function OnboardingDrawer({ onClose, onComplete, initialSourceId }: { onC
|
|
|
1242
1242
|
{!p.required && !isSet && <span className="text-[9px] text-[var(--text-secondary)]">(optional)</span>}
|
|
1243
1243
|
{isSet && <span className="text-[9px] text-emerald-500">● currently set</span>}
|
|
1244
1244
|
{p.url && (
|
|
1245
|
-
<a href={p.url}
|
|
1245
|
+
<a href={p.url} onClick={(e) => { e.preventDefault(); void openPortal(p.url!); }} className="ml-auto text-[10px] text-[var(--accent)] hover:underline cursor-pointer" title={p.url}>
|
|
1246
1246
|
↗ {p.url_label || 'Get token'}
|
|
1247
1247
|
</a>
|
|
1248
1248
|
)}
|
|
@@ -104,6 +104,21 @@ async function ensureSession(telegramChatId: number): Promise<string> {
|
|
|
104
104
|
return id;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/** List recent chat sessions (for the /chat picker). Best-effort: [] on error. */
|
|
108
|
+
export async function listChatSessions(limit = 20): Promise<Array<{ id: string; title: string; updated_at?: string }>> {
|
|
109
|
+
try {
|
|
110
|
+
const r = await fetch(`${BASE}/api/sessions?limit=${limit}`);
|
|
111
|
+
if (!r.ok) return [];
|
|
112
|
+
const j = await r.json();
|
|
113
|
+
return Array.isArray(j?.sessions) ? j.sessions : [];
|
|
114
|
+
} catch { return []; }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Create a fresh chat session, returns its id (null on failure). */
|
|
118
|
+
export async function newChatSession(title: string): Promise<string | null> {
|
|
119
|
+
try { return await createSession(title); } catch { return null; }
|
|
120
|
+
}
|
|
121
|
+
|
|
107
122
|
async function openSse(sessionId: string): Promise<ReadableStreamDefaultReader<Uint8Array>> {
|
|
108
123
|
const res = await fetch(`${BASE}/api/sessions/${encodeURIComponent(sessionId)}/events`, {
|
|
109
124
|
headers: { accept: 'text/event-stream' },
|
|
@@ -83,3 +83,5 @@ Next to the **Automation** tab in the left-side nav sits a small **Activity** su
|
|
|
83
83
|
- **User menu (▾)** — `⚙ Settings` + `💬 Chat (web) ↗` at the top (Chat opens in a new tab so the dashboard isn't replaced); then a divider, then the periodic-check screens `📊 Monitor` (background watches, processes, queues), `🔐 Login Status` (connector creds), `💰 Usage` (token/cost analytics), `📜 Logs`, `📱 Mobile View ↗`; then `⏻ Logout`.
|
|
84
84
|
|
|
85
85
|
Periodic-check screens (Monitor / Login Status / Usage) live inside the user menu so the top bar only shows things worth glancing at.
|
|
86
|
+
|
|
87
|
+
**Mobile View** (`/mobile`, opened from the user menu or auto on phones) defaults to a **💬 Chat** tab — the Forge chat agent, same backend as the web `/chat` and the Telegram chat. Pick or start a session and talk; replies stream live. Toggle to the **⌨ Terminal** tab to browse/continue Claude Code sessions per project (the original mobile view). `PC` switches to the desktop layout.
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
| `/peek <project>` | Preview running session |
|
|
25
25
|
| `/cancel <id>` | Cancel a task |
|
|
26
26
|
| `/retry <id>` | Retry a failed task |
|
|
27
|
+
| `/chat` | Enter chat mode — pick an existing chat session or start a new one, then every message goes to the Forge chat agent (same as the web `/chat`) |
|
|
28
|
+
| `/endchat` | Leave chat mode |
|
|
27
29
|
| `/tunnel_start <password>` | Start Cloudflare Tunnel (returns URL + code) |
|
|
28
30
|
| `/tunnel_stop` | Stop tunnel |
|
|
29
31
|
| `/tunnel_code <password>` | Get session code for remote login |
|
|
@@ -33,6 +35,7 @@
|
|
|
33
35
|
- Reply to a task message to interact with it
|
|
34
36
|
- Send `"project: instructions"` to quick-create a task
|
|
35
37
|
- Numbered lists — reply with a number to select
|
|
38
|
+
- **Chat mode**: `/chat` → reply with a number to pick a session (`0` = new) → talk normally; the agent streams replies just like the web `/chat`. `/endchat` exits. Other slash-commands still work while in chat mode.
|
|
36
39
|
|
|
37
40
|
## Troubleshooting
|
|
38
41
|
|
package/lib/projects.ts
CHANGED
|
@@ -400,11 +400,21 @@ export function resolveOrCloneProject(name: string | undefined): ResolveResult {
|
|
|
400
400
|
const projects = scanProjects();
|
|
401
401
|
const want = normalizeRepoPath(targetPath) || targetPath.toLowerCase().replace(/^\/+|\/+$/g, '');
|
|
402
402
|
const wantBase = want.split('/').pop()!;
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
403
|
+
// Prefer ANY local checkout — pipelines run in a worktree, so picking
|
|
404
|
+
// either of several same-repo checkouts (e.g. FortiNAC vs FortiNAC-v3,
|
|
405
|
+
// both origin fortinac/fortinac) is safe and never touches the user's
|
|
406
|
+
// branch. Order: exact origin path → origin basename → project name.
|
|
407
|
+
// Taking the first match (not requiring a unique one) is what keeps a
|
|
408
|
+
// clone — which fails when the GitLab host is behind a VPN the server
|
|
409
|
+
// can't reach — from being attempted at all when a local copy exists.
|
|
410
|
+
const localHit =
|
|
411
|
+
projects.find((p) => p.repo && p.repo === want) ||
|
|
412
|
+
projects.find((p) => p.repo && p.repo.split('/').pop() === wantBase) ||
|
|
413
|
+
projects.find((p) => p.name.toLowerCase() === wantBase.toLowerCase());
|
|
414
|
+
if (localHit) return { project: localHit, source: 'existing' };
|
|
415
|
+
|
|
416
|
+
// No local checkout at all — clone (may fail if host unreachable, then
|
|
417
|
+
// scratch below).
|
|
408
418
|
const cloned = tryGitlabClone(targetPath);
|
|
409
419
|
if (cloned) return { project: cloned, source: 'gitlab-cloned', clone_url: `${gl.base_url}/${targetPath.replace(/^\/+|\/+$/g, '')}.git` };
|
|
410
420
|
}
|
package/lib/telegram-bot.ts
CHANGED
|
@@ -32,7 +32,11 @@ const chatNumberedTasks = new Map<number, Map<number, string>>();
|
|
|
32
32
|
const chatNumberedSessions = new Map<number, Map<number, { projectName: string; sessionId: string }>>();
|
|
33
33
|
const chatNumberedProjects = new Map<number, Map<number, string>>();
|
|
34
34
|
// Track what the last numbered list was for
|
|
35
|
-
const chatListMode = new Map<number, 'tasks' | 'projects' | 'sessions' | 'task-create' | 'peek' | 'inject-pick' | 'inject-typing'>();
|
|
35
|
+
const chatListMode = new Map<number, 'tasks' | 'projects' | 'sessions' | 'task-create' | 'peek' | 'inject-pick' | 'inject-typing' | 'chat-pick'>();
|
|
36
|
+
// Chat mode: once entered (/chat → pick session), every non-command message
|
|
37
|
+
// goes to the Forge chat agent — same as the local /chat — until /endchat.
|
|
38
|
+
const chatActiveMode = new Set<number>(); // chatIds currently in chat mode
|
|
39
|
+
const chatNumberedChatSessions = new Map<number, Map<number, string>>(); // num → chat session id (0 = new)
|
|
36
40
|
// Inject mode state — picked tmux session per chat
|
|
37
41
|
const chatNumberedTmux = new Map<number, Map<number, string>>(); // num → tmux session name
|
|
38
42
|
const chatInjectTarget = new Map<number, string>(); // chatId → tmux session name (currently selected)
|
|
@@ -147,11 +151,27 @@ async function handleMessage(msg: any) {
|
|
|
147
151
|
return;
|
|
148
152
|
}
|
|
149
153
|
|
|
154
|
+
// Chat mode: every non-command message (including bare numbers) goes to the
|
|
155
|
+
// chat agent until /endchat. Must run BEFORE numbered selection so digits are
|
|
156
|
+
// treated as chat content, not list picks. Commands fall through.
|
|
157
|
+
if (chatActiveMode.has(chatId) && !text.startsWith('/')) {
|
|
158
|
+
await handleChat(chatId, text);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
150
162
|
// Quick number selection (1-10) → context-dependent
|
|
151
163
|
if (/^\d{1,2}$/.test(text)) {
|
|
152
164
|
const num = parseInt(text);
|
|
153
165
|
const mode = chatListMode.get(chatId);
|
|
154
166
|
|
|
167
|
+
if (mode === 'chat-pick') {
|
|
168
|
+
const sessMap = chatNumberedChatSessions.get(chatId);
|
|
169
|
+
if (num === 0 || sessMap?.has(num)) {
|
|
170
|
+
await pickChatSession(chatId, num);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
155
175
|
if (mode === 'task-create') {
|
|
156
176
|
const projMap = chatNumberedProjects.get(chatId);
|
|
157
177
|
if (projMap?.has(num)) {
|
|
@@ -298,17 +318,10 @@ async function handleMessage(msg: any) {
|
|
|
298
318
|
}
|
|
299
319
|
case '/chat':
|
|
300
320
|
case '/c':
|
|
301
|
-
|
|
302
|
-
await send(chatId, 'Usage: /chat <message>\n/chat_new to start a fresh session\n/chat_session to show the active session id');
|
|
303
|
-
} else {
|
|
304
|
-
await handleChat(chatId, args.join(' '));
|
|
305
|
-
}
|
|
306
|
-
break;
|
|
307
|
-
case '/chat_new':
|
|
308
|
-
await handleChatReset(chatId);
|
|
321
|
+
await enterChatMode(chatId);
|
|
309
322
|
break;
|
|
310
|
-
case '/
|
|
311
|
-
await
|
|
323
|
+
case '/endchat':
|
|
324
|
+
await exitChatMode(chatId);
|
|
312
325
|
break;
|
|
313
326
|
case '/tunnel':
|
|
314
327
|
await handleTunnelStatus(chatId);
|
|
@@ -368,9 +381,8 @@ async function sendHelp(chatId: number) {
|
|
|
368
381
|
`🔧 /cancel <id> /retry <id>\n` +
|
|
369
382
|
`/projects — list projects\n` +
|
|
370
383
|
`🤖 /agents — list available agents\n\n` +
|
|
371
|
-
`💬 /chat
|
|
372
|
-
`/
|
|
373
|
-
`/chat_session — show active session id\n\n` +
|
|
384
|
+
`💬 /chat — enter chat mode (pick/new session)\n` +
|
|
385
|
+
`/endchat — leave chat mode\n\n` +
|
|
374
386
|
`🌐 /tunnel — status\n` +
|
|
375
387
|
`/tunnel_start / /tunnel_stop\n` +
|
|
376
388
|
`/tunnel_code <admin_pw> — get session code\n\n` +
|
|
@@ -396,20 +408,56 @@ async function handleChat(chatId: number, userText: string) {
|
|
|
396
408
|
}
|
|
397
409
|
}
|
|
398
410
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
411
|
+
// Enter chat mode: show a numbered session picker (0 = new). Once the user
|
|
412
|
+
// picks, every plain message goes to the chat agent until /endchat.
|
|
413
|
+
async function enterChatMode(chatId: number) {
|
|
414
|
+
// Leave any active chat first so digits in the picker are read as choices,
|
|
415
|
+
// not chat content.
|
|
416
|
+
chatActiveMode.delete(chatId);
|
|
417
|
+
try {
|
|
418
|
+
const { listChatSessions } = require('./chat/telegram-bridge') as typeof import('./chat/telegram-bridge');
|
|
419
|
+
const sessions = await listChatSessions(15);
|
|
420
|
+
const numMap = new Map<number, string>();
|
|
421
|
+
const lines = ['💬 Chat mode — pick a session:', '', '0. ➕ New session'];
|
|
422
|
+
sessions.forEach((s, i) => {
|
|
423
|
+
const n = i + 1;
|
|
424
|
+
numMap.set(n, s.id);
|
|
425
|
+
const title = (s.title || s.id.slice(0, 8)).slice(0, 40);
|
|
426
|
+
lines.push(`${n}. ${title}`);
|
|
427
|
+
});
|
|
428
|
+
chatNumberedChatSessions.set(chatId, numMap);
|
|
429
|
+
chatListMode.set(chatId, 'chat-pick');
|
|
430
|
+
lines.push('', 'Reply with a number. /endchat to cancel.');
|
|
431
|
+
await send(chatId, lines.join('\n'));
|
|
432
|
+
} catch (err) {
|
|
433
|
+
await send(chatId, `✗ chat error: ${(err as Error).message}`);
|
|
434
|
+
}
|
|
403
435
|
}
|
|
404
436
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
437
|
+
// Pick a session (0 = new) → bind it and switch to chat-active mode.
|
|
438
|
+
async function pickChatSession(chatId: number, num: number) {
|
|
439
|
+
const { setTelegramSession, newChatSession } = require('./chat/telegram-bridge') as typeof import('./chat/telegram-bridge');
|
|
440
|
+
let sessionId: string | null;
|
|
441
|
+
if (num === 0) {
|
|
442
|
+
sessionId = await newChatSession(`Telegram chat ${chatId}`);
|
|
443
|
+
if (!sessionId) { await send(chatId, '✗ could not create a new session'); return; }
|
|
444
|
+
} else {
|
|
445
|
+
sessionId = chatNumberedChatSessions.get(chatId)?.get(num) || null;
|
|
446
|
+
if (!sessionId) { await send(chatId, 'Invalid choice. /chat to list again.'); return; }
|
|
447
|
+
}
|
|
448
|
+
setTelegramSession(chatId, sessionId);
|
|
449
|
+
chatListMode.delete(chatId);
|
|
450
|
+
chatNumberedChatSessions.delete(chatId);
|
|
451
|
+
chatActiveMode.add(chatId);
|
|
452
|
+
await send(chatId, `✅ Chat started${num === 0 ? ' (new session)' : ''}. Send messages normally — every message goes to the Forge agent.\n\n/endchat to leave.`);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Leave chat mode. Keeps the session binding so /chat can resume it later.
|
|
456
|
+
async function exitChatMode(chatId: number) {
|
|
457
|
+
const wasActive = chatActiveMode.delete(chatId);
|
|
458
|
+
chatListMode.delete(chatId);
|
|
459
|
+
chatNumberedChatSessions.delete(chatId);
|
|
460
|
+
await send(chatId, wasActive ? '👋 Left chat mode.' : 'Not in chat mode.');
|
|
413
461
|
}
|
|
414
462
|
|
|
415
463
|
async function sendAgentList(chatId: number) {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* openPortal — open a vendor login / portal URL in the RIGHT browser.
|
|
3
|
+
*
|
|
4
|
+
* Mac-native Forge: the UI runs in the user's own browser, which holds
|
|
5
|
+
* their SSO cookies → window.open is correct.
|
|
6
|
+
*
|
|
7
|
+
* Container deploy: the UI renders in the user's browser but the agent
|
|
8
|
+
* drives a Chromium INSIDE the container, where all SSO cookies live.
|
|
9
|
+
* window.open would land in the host browser (no corp session, useless).
|
|
10
|
+
* So when an extension is connected — the same signal the Login-status
|
|
11
|
+
* probe already relies on — route the open through the bridge so the
|
|
12
|
+
* extension runs chrome.tabs.create in the container Chromium.
|
|
13
|
+
*
|
|
14
|
+
* Pure client helper (fetch + DOM). Reference/help links should keep
|
|
15
|
+
* using a plain window.open / <a target="_blank"> — they genuinely
|
|
16
|
+
* belong in the user's own browser.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
function portalToast(msg: string): void {
|
|
20
|
+
if (typeof document === 'undefined') return;
|
|
21
|
+
const el = document.createElement('div');
|
|
22
|
+
el.textContent = msg;
|
|
23
|
+
el.style.cssText =
|
|
24
|
+
'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:99999;' +
|
|
25
|
+
'background:#1e293b;color:#fff;padding:8px 16px;border-radius:8px;font-size:13px;' +
|
|
26
|
+
'box-shadow:0 4px 12px rgba(0,0,0,.3);opacity:0;transition:opacity .2s';
|
|
27
|
+
document.body.appendChild(el);
|
|
28
|
+
requestAnimationFrame(() => { el.style.opacity = '1'; });
|
|
29
|
+
setTimeout(() => { el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }, 3000);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function openPortal(url: string): Promise<void> {
|
|
33
|
+
if (!url) return;
|
|
34
|
+
try {
|
|
35
|
+
const status = await fetch('/api/browser-bridge?action=status')
|
|
36
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
37
|
+
.catch(() => null);
|
|
38
|
+
const connected = Number(status?.connected_extensions || 0) > 0;
|
|
39
|
+
if (connected) {
|
|
40
|
+
const r = await fetch('/api/browser-bridge', {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'content-type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
action: 'rpc',
|
|
45
|
+
method: 'browser.open_tab',
|
|
46
|
+
params: { url, active: true },
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
// proxy returns the bridge rpc envelope: {ok, value} | {ok:false, error}
|
|
50
|
+
const j = await r.json().catch(() => null);
|
|
51
|
+
if (r.ok && j && j.ok !== false) {
|
|
52
|
+
portalToast('Opened in workspace browser (visible via stream)');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
/* fall through to host-browser open */
|
|
58
|
+
}
|
|
59
|
+
window.open(url, '_blank', 'noopener');
|
|
60
|
+
}
|
package/package.json
CHANGED