@aion0/forge 0.10.32 → 0.10.34

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 CHANGED
@@ -1,11 +1,14 @@
1
- # Forge v0.10.32
1
+ # Forge v0.10.34
2
2
 
3
- Released: 2026-06-02
3
+ Released: 2026-06-03
4
4
 
5
- ## Changes since v0.10.31
5
+ ## Changes since v0.10.33
6
6
 
7
7
  ### Other
8
- - fix(watch): builtin pollers return JSON + add get_task_status
8
+ - fix(watch): heuristic terminal detection for all connector pollers
9
+ - ui(activity): segmented pill — running/upcoming/failed each their own color
10
+ - fix(watch): honor poll result's terminal: true regardless of done_match
11
+ - fix(marketplace): scrollbar on long project list in install dropdown
9
12
 
10
13
 
11
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.31...v0.10.32
14
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.33...v0.10.34
@@ -0,0 +1,135 @@
1
+ /**
2
+ * GET /api/activity/summary
3
+ *
4
+ * Small snapshot for the Activity pill / dashboard. Three sections:
5
+ * - running: pipelines currently in flight (status queued|running)
6
+ * - upcoming: enabled schedules sorted by next_run_at ASC
7
+ * - recent: last 8 finished pipelines (succeeded|failed|cancelled)
8
+ *
9
+ * Aim <5KB response, <30ms. No expensive joins, no per-pipeline detail.
10
+ * Front-end refetches every 5s when ActivityPanel is open.
11
+ */
12
+
13
+ import { NextResponse } from 'next/server';
14
+ import { listPipelinesSummary } from '@/lib/pipeline';
15
+ import { listSchedules } from '@/lib/schedules/store';
16
+
17
+ interface RunningRow {
18
+ id: string;
19
+ workflowName: string;
20
+ status: string;
21
+ currentNode: string | null;
22
+ progress: { done: number; total: number };
23
+ createdAt: string;
24
+ }
25
+
26
+ interface UpcomingRow {
27
+ id: string;
28
+ name: string;
29
+ body_kind: string;
30
+ body_ref: string;
31
+ next_run_at: string | null;
32
+ schedule_kind: string;
33
+ schedule_summary: string;
34
+ }
35
+
36
+ interface RecentRow {
37
+ id: string;
38
+ workflowName: string;
39
+ status: string;
40
+ completedAt: string | null;
41
+ durationMs: number | null;
42
+ }
43
+
44
+ interface Summary {
45
+ running: RunningRow[];
46
+ upcoming: UpcomingRow[];
47
+ recent: RecentRow[];
48
+ generated_at: string;
49
+ }
50
+
51
+ function scheduleSummary(s: {
52
+ schedule_kind: string;
53
+ schedule_interval_minutes: number;
54
+ schedule_at: string | null;
55
+ schedule_cron: string | null;
56
+ }): string {
57
+ switch (s.schedule_kind) {
58
+ case 'period':
59
+ return s.schedule_interval_minutes >= 60
60
+ ? `every ${Math.round(s.schedule_interval_minutes / 60)}h`
61
+ : `every ${s.schedule_interval_minutes}m`;
62
+ case 'cron':
63
+ return `cron: ${s.schedule_cron || '?'}`;
64
+ case 'once':
65
+ return s.schedule_at ? `at ${s.schedule_at}` : 'one-shot';
66
+ case 'manual':
67
+ return 'manual only';
68
+ default:
69
+ return s.schedule_kind;
70
+ }
71
+ }
72
+
73
+ export async function GET() {
74
+ // Single listPipelinesSummary call — uses the parse cache, sorted desc.
75
+ // 200 runs cap is plenty; we'll filter into running + recent and discard
76
+ // the rest.
77
+ const all = listPipelinesSummary({ limit: 200 });
78
+
79
+ const running: RunningRow[] = [];
80
+ const recent: RecentRow[] = [];
81
+ for (const p of all) {
82
+ const isActive = p.status === 'running';
83
+ if (isActive && running.length < 30) {
84
+ // Compute progress + current node from the in-memory nodes map.
85
+ let done = 0;
86
+ let current: string | null = null;
87
+ for (const name of p.nodeOrder) {
88
+ const st = p.nodes[name]?.status;
89
+ if (st === 'done' || st === 'skipped') done++;
90
+ else if (!current && st === 'running') current = name;
91
+ }
92
+ running.push({
93
+ id: p.id,
94
+ workflowName: p.workflowName,
95
+ status: p.status,
96
+ currentNode: current,
97
+ progress: { done, total: p.nodeOrder.length },
98
+ createdAt: p.createdAt,
99
+ });
100
+ } else if (!isActive && recent.length < 8) {
101
+ const startedMs = Date.parse(p.createdAt);
102
+ const endedMs = p.completedAt ? Date.parse(p.completedAt) : NaN;
103
+ recent.push({
104
+ id: p.id,
105
+ workflowName: p.workflowName,
106
+ status: p.status,
107
+ completedAt: p.completedAt || null,
108
+ durationMs: isFinite(startedMs) && isFinite(endedMs) ? endedMs - startedMs : null,
109
+ });
110
+ }
111
+ }
112
+
113
+ // Upcoming = enabled schedules sorted by next_run_at ASC, top 10.
114
+ const schedules = listSchedules()
115
+ .filter((s) => s.enabled && s.next_run_at)
116
+ .sort((a, b) => String(a.next_run_at).localeCompare(String(b.next_run_at)))
117
+ .slice(0, 10);
118
+ const upcoming: UpcomingRow[] = schedules.map((s) => ({
119
+ id: s.id,
120
+ name: s.name,
121
+ body_kind: s.body_kind,
122
+ body_ref: s.body_ref,
123
+ next_run_at: s.next_run_at,
124
+ schedule_kind: s.schedule_kind,
125
+ schedule_summary: scheduleSummary(s),
126
+ }));
127
+
128
+ const summary: Summary = {
129
+ running,
130
+ upcoming,
131
+ recent,
132
+ generated_at: new Date().toISOString(),
133
+ };
134
+ return NextResponse.json(summary);
135
+ }
@@ -0,0 +1,288 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+
5
+ interface RunningRow {
6
+ id: string;
7
+ workflowName: string;
8
+ status: string;
9
+ currentNode: string | null;
10
+ progress: { done: number; total: number };
11
+ createdAt: string;
12
+ }
13
+
14
+ interface UpcomingRow {
15
+ id: string;
16
+ name: string;
17
+ body_kind: string;
18
+ body_ref: string;
19
+ next_run_at: string | null;
20
+ schedule_kind: string;
21
+ schedule_summary: string;
22
+ }
23
+
24
+ interface RecentRow {
25
+ id: string;
26
+ workflowName: string;
27
+ status: string;
28
+ completedAt: string | null;
29
+ durationMs: number | null;
30
+ }
31
+
32
+ interface Summary {
33
+ running: RunningRow[];
34
+ upcoming: UpcomingRow[];
35
+ recent: RecentRow[];
36
+ generated_at: string;
37
+ }
38
+
39
+ const POLL_OPEN_MS = 5000; // active polling while open
40
+ const POLL_BADGE_MS = 30000; // background polling while closed (just for counts)
41
+
42
+ function timeAgo(iso: string | null): string {
43
+ if (!iso) return '';
44
+ const s = Math.round((Date.now() - Date.parse(iso)) / 1000);
45
+ if (s < 60) return `${s}s ago`;
46
+ if (s < 3600) return `${Math.round(s / 60)}m ago`;
47
+ if (s < 86400) return `${Math.round(s / 3600)}h ago`;
48
+ return `${Math.round(s / 86400)}d ago`;
49
+ }
50
+
51
+ function timeUntil(iso: string | null): string {
52
+ if (!iso) return '';
53
+ const s = Math.round((Date.parse(iso) - Date.now()) / 1000);
54
+ if (s < 0) return 'overdue';
55
+ if (s < 60) return `in ${s}s`;
56
+ if (s < 3600) return `in ${Math.round(s / 60)}m`;
57
+ if (s < 86400) return `in ${Math.round(s / 3600)}h`;
58
+ return `in ${Math.round(s / 86400)}d`;
59
+ }
60
+
61
+ function fmtDuration(ms: number | null): string {
62
+ if (ms == null) return '';
63
+ if (ms < 1000) return `${ms}ms`;
64
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
65
+ return `${Math.round(ms / 60_000)}m`;
66
+ }
67
+
68
+ function statusGlyph(s: string): string {
69
+ if (s === 'done') return '✓';
70
+ if (s === 'failed') return '✗';
71
+ if (s === 'cancelled') return '⊘';
72
+ if (s === 'running') return '▶';
73
+ return s;
74
+ }
75
+
76
+ function statusColor(s: string): string {
77
+ if (s === 'done') return 'text-green-500';
78
+ if (s === 'failed') return 'text-red-400';
79
+ if (s === 'cancelled') return 'text-gray-400';
80
+ if (s === 'running') return 'text-blue-400';
81
+ return 'text-[var(--text-secondary)]';
82
+ }
83
+
84
+ /**
85
+ * Activity pill — top-right glanceable status for running pipelines +
86
+ * upcoming schedules. Click to expand a 3-section dropdown. Polls every
87
+ * 5s while open, 30s in background for badge counts.
88
+ */
89
+ export default function ActivityPanel() {
90
+ const [summary, setSummary] = useState<Summary | null>(null);
91
+ const [open, setOpen] = useState(false);
92
+ const panelRef = useRef<HTMLDivElement | null>(null);
93
+
94
+ const refetch = useCallback(async () => {
95
+ try {
96
+ const r = await fetch('/api/activity/summary');
97
+ if (!r.ok) return;
98
+ const j: Summary = await r.json();
99
+ setSummary(j);
100
+ } catch { /* network blip — silent */ }
101
+ }, []);
102
+
103
+ // Initial fetch + adaptive polling cadence
104
+ useEffect(() => {
105
+ refetch();
106
+ const ms = open ? POLL_OPEN_MS : POLL_BADGE_MS;
107
+ const id = setInterval(refetch, ms);
108
+ return () => clearInterval(id);
109
+ }, [open, refetch]);
110
+
111
+ // Click-outside dismisses
112
+ useEffect(() => {
113
+ if (!open) return;
114
+ const onClick = (e: MouseEvent) => {
115
+ if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
116
+ setOpen(false);
117
+ }
118
+ };
119
+ window.addEventListener('mousedown', onClick);
120
+ return () => window.removeEventListener('mousedown', onClick);
121
+ }, [open]);
122
+
123
+ const runningCount = summary?.running.length ?? 0;
124
+ const upcomingCount = summary?.upcoming.length ?? 0;
125
+ const recentFailed = (summary?.recent ?? []).filter((r) => r.status === 'failed').length;
126
+ const hasAny = runningCount + upcomingCount + recentFailed > 0;
127
+
128
+ // Pill border tint picks the most urgent state:
129
+ // failed (red) > running (blue) > else dim.
130
+ const borderTint = recentFailed > 0
131
+ ? 'border-red-500/50'
132
+ : runningCount > 0
133
+ ? 'border-blue-500/50'
134
+ : 'border-[var(--border)]';
135
+
136
+ return (
137
+ <div className="relative" ref={panelRef}>
138
+ <button
139
+ onClick={() => setOpen((o) => !o)}
140
+ className={`text-[10px] px-2 py-0.5 rounded border ${borderTint} flex items-center gap-2.5
141
+ text-[var(--text-secondary)] hover:text-[var(--text-primary)]`}
142
+ title="Activity — running pipelines · upcoming schedules · recent failures"
143
+ >
144
+ {!hasAny ? (
145
+ <span className="text-[var(--text-secondary)]">✓</span>
146
+ ) : (
147
+ <>
148
+ {runningCount > 0 && (
149
+ <span className="inline-flex items-baseline text-blue-400" title={`${runningCount} running`}>
150
+ <span className="text-[7px] mr-0.5">●</span>
151
+ <span className="font-semibold tabular-nums">{runningCount}</span>
152
+ </span>
153
+ )}
154
+ {upcomingCount > 0 && (
155
+ <span className="inline-flex items-baseline text-[var(--text-secondary)]" title={`${upcomingCount} upcoming`}>
156
+ <span className="text-[8px] mr-0.5">◷</span>
157
+ <span className="font-semibold tabular-nums">{upcomingCount}</span>
158
+ </span>
159
+ )}
160
+ {recentFailed > 0 && (
161
+ <span className="inline-flex items-baseline text-red-400" title={`${recentFailed} recently failed`}>
162
+ <span className="text-[8px] mr-0.5">✕</span>
163
+ <span className="font-semibold tabular-nums">{recentFailed}</span>
164
+ </span>
165
+ )}
166
+ </>
167
+ )}
168
+ </button>
169
+
170
+ {open && (
171
+ <div
172
+ className="absolute right-0 mt-1 w-[440px] max-h-[70vh] overflow-y-auto bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl z-50"
173
+ onClick={(e) => e.stopPropagation()}
174
+ >
175
+ {summary === null ? (
176
+ <div className="p-6 text-center text-xs text-[var(--text-secondary)]">Loading…</div>
177
+ ) : (
178
+ <div className="p-3 space-y-4">
179
+ {/* Running */}
180
+ <Section title="Running" count={runningCount}>
181
+ {runningCount === 0 ? (
182
+ <Empty>Nothing running.</Empty>
183
+ ) : (
184
+ summary.running.map((r) => (
185
+ <div key={r.id} className="flex items-start gap-2 text-xs py-1">
186
+ <span className={`${statusColor(r.status)} mt-0.5`}>{statusGlyph(r.status)}</span>
187
+ <div className="flex-1 min-w-0">
188
+ <div className="flex items-center gap-2">
189
+ <span className="text-[var(--text-primary)] font-medium truncate">{r.workflowName}</span>
190
+ <span className="text-[10px] text-[var(--text-secondary)]">
191
+ {r.progress.done}/{r.progress.total}
192
+ </span>
193
+ <span className="text-[10px] text-gray-500">{timeAgo(r.createdAt)}</span>
194
+ </div>
195
+ {r.currentNode && (
196
+ <div className="text-[10px] text-[var(--text-secondary)] mt-0.5">
197
+ current: {r.currentNode}
198
+ </div>
199
+ )}
200
+ </div>
201
+ <button
202
+ onClick={() => {
203
+ setOpen(false);
204
+ window.dispatchEvent(new CustomEvent('forge:navigate', { detail: { view: 'pipelines', pipelineId: r.id } }));
205
+ }}
206
+ className="text-[10px] px-1.5 py-0.5 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
207
+ >
208
+ view
209
+ </button>
210
+ </div>
211
+ ))
212
+ )}
213
+ </Section>
214
+
215
+ {/* Upcoming */}
216
+ <Section title="Next up" count={upcomingCount}>
217
+ {upcomingCount === 0 ? (
218
+ <Empty>No enabled schedules.</Empty>
219
+ ) : (
220
+ summary.upcoming.map((u) => (
221
+ <div key={u.id} className="flex items-start gap-2 text-xs py-1">
222
+ <span className="text-[var(--text-secondary)] mt-0.5">⏰</span>
223
+ <div className="flex-1 min-w-0">
224
+ <div className="flex items-center gap-2">
225
+ <span className="text-[var(--text-primary)] font-medium truncate">{u.name}</span>
226
+ <span className="text-[10px] text-blue-400">{timeUntil(u.next_run_at)}</span>
227
+ </div>
228
+ <div className="text-[10px] text-[var(--text-secondary)] mt-0.5 truncate">
229
+ {u.body_kind}: {u.body_ref} · {u.schedule_summary}
230
+ </div>
231
+ </div>
232
+ </div>
233
+ ))
234
+ )}
235
+ </Section>
236
+
237
+ {/* Recent */}
238
+ <Section title="Recent" count={summary.recent.length}>
239
+ {summary.recent.length === 0 ? (
240
+ <Empty>No recent runs.</Empty>
241
+ ) : (
242
+ summary.recent.map((r) => (
243
+ <div key={r.id} className="flex items-start gap-2 text-xs py-1">
244
+ <span className={`${statusColor(r.status)} mt-0.5`}>{statusGlyph(r.status)}</span>
245
+ <div className="flex-1 min-w-0">
246
+ <div className="flex items-center gap-2">
247
+ <span className="text-[var(--text-primary)] truncate">{r.workflowName}</span>
248
+ <span className="text-[10px] text-[var(--text-secondary)]">
249
+ {fmtDuration(r.durationMs)} · {timeAgo(r.completedAt)}
250
+ </span>
251
+ </div>
252
+ </div>
253
+ <button
254
+ onClick={() => {
255
+ setOpen(false);
256
+ window.dispatchEvent(new CustomEvent('forge:navigate', { detail: { view: 'pipelines', pipelineId: r.id } }));
257
+ }}
258
+ className="text-[10px] px-1.5 py-0.5 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
259
+ >
260
+ view
261
+ </button>
262
+ </div>
263
+ ))
264
+ )}
265
+ </Section>
266
+ </div>
267
+ )}
268
+ </div>
269
+ )}
270
+ </div>
271
+ );
272
+ }
273
+
274
+ function Section({ title, count, children }: { title: string; count: number; children: React.ReactNode }) {
275
+ return (
276
+ <div>
277
+ <h3 className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase mb-1 flex items-center gap-2">
278
+ {title}
279
+ <span className="text-gray-500 font-normal">({count})</span>
280
+ </h3>
281
+ <div className="space-y-0.5">{children}</div>
282
+ </div>
283
+ );
284
+ }
285
+
286
+ function Empty({ children }: { children: React.ReactNode }) {
287
+ return <div className="text-[10px] text-gray-500 italic py-0.5">{children}</div>;
288
+ }
@@ -25,6 +25,7 @@ const NewTaskModal = lazy(() => import('./NewTaskModal'));
25
25
  const SettingsModal = lazy(() => import('./SettingsModal'));
26
26
  const MonitorPanel = lazy(() => import('./MonitorPanel'));
27
27
  const LoginStatusPanel = lazy(() => import('./LoginStatusPanel'));
28
+ const ActivityPanel = lazy(() => import('./ActivityPanel'));
28
29
  const WorkspaceView = lazy(() => import('./WorkspaceView'));
29
30
  // WorkspaceTree moved into ProjectDetail — no longer needed at Dashboard level
30
31
 
@@ -398,6 +399,11 @@ export default function Dashboard({ user }: { user: any }) {
398
399
  >
399
400
  Automation
400
401
  </button>
402
+ {/* Activity sub-pill — sits next to Automation since its content
403
+ (running pipelines + upcoming schedules + recent runs) is the
404
+ live read-side of Automation. Click anywhere → dropdown with
405
+ 3 sections + a "view" jump to the run. */}
406
+ <Suspense fallback={null}><ActivityPanel /></Suspense>
401
407
  <span className="w-[2px] h-4 bg-[var(--text-secondary)]/30 mx-1.5" />
402
408
  {/* Marketplace */}
403
409
  <button
@@ -465,14 +471,6 @@ export default function Dashboard({ user }: { user: any }) {
465
471
  </>
466
472
  )}
467
473
  </div>
468
- <button
469
- onClick={() => setViewMode('usage')}
470
- className={`text-[10px] px-2 py-0.5 border rounded transition-colors ${
471
- viewMode === 'usage'
472
- ? 'border-[var(--accent)] text-[var(--accent)]'
473
- : 'border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)]'
474
- }`}
475
- >Usage</button>
476
474
  <Suspense fallback={null}><TunnelToggle /></Suspense>
477
475
  {onlineCount.total > 0 && (
478
476
  <span className="text-[10px] text-[var(--text-secondary)] flex items-center gap-1" title={`${onlineCount.total} online${onlineCount.remote > 0 ? `, ${onlineCount.remote} remote` : ''}`}>
@@ -618,52 +616,73 @@ export default function Dashboard({ user }: { user: any }) {
618
616
  {showUserMenu && (
619
617
  <>
620
618
  <div className="fixed inset-0 z-40" onClick={() => setShowUserMenu(false)} />
621
- <div className="absolute right-0 top-8 w-[140px] bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl z-50 py-1">
619
+ <div className="absolute right-0 top-8 w-[170px] bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg shadow-xl z-50 py-1">
620
+ {/* Settings promoted to the top — most-reached item in this
621
+ menu. Each row leads with a glyph so it's scannable. */}
622
+ <button
623
+ onClick={() => { setShowSettings(true); setShowUserMenu(false); }}
624
+ className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
625
+ >
626
+ <span className="w-3 text-center">⚙</span><span>Settings</span>
627
+ </button>
628
+ {/* Chat (web) is a separate route — open in a new tab so
629
+ the dashboard isn't replaced. Promoted up here so it's
630
+ one of the prominent items, not buried below Logs. */}
631
+ <a
632
+ href="/chat"
633
+ target="_blank"
634
+ rel="noopener noreferrer"
635
+ className="block w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
636
+ >
637
+ <span className="w-3 text-center">💬</span>
638
+ <span className="flex-1">Chat (web)</span>
639
+ <span className="text-[9px] text-[var(--text-secondary)]">↗</span>
640
+ </a>
641
+ <div className="border-t border-[var(--border)] my-1" />
622
642
  <button
623
643
  onClick={() => { setShowMonitor(true); setShowUserMenu(false); }}
624
- className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
644
+ className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
625
645
  >
626
- Monitor
646
+ <span className="w-3 text-center">📊</span><span>Monitor</span>
627
647
  </button>
628
648
  <button
629
649
  onClick={() => { setShowLoginStatus(true); setShowUserMenu(false); }}
630
- className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center justify-between"
650
+ className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
631
651
  >
632
- <span>Login Status</span>
652
+ <span className="w-3 text-center">🔐</span>
653
+ <span className="flex-1">Login Status</span>
633
654
  {loginBadge && loginBadge.broken > 0 && (
634
- <span className="text-[9px] text-red-400">🔴 {loginBadge.broken}/{loginBadge.total}</span>
655
+ <span className="text-[9px] text-red-400">{loginBadge.broken}/{loginBadge.total}</span>
635
656
  )}
636
657
  </button>
637
658
  <button
638
- onClick={() => { setShowSettings(true); setShowUserMenu(false); }}
639
- className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
659
+ onClick={() => { setViewMode('usage'); setShowUserMenu(false); }}
660
+ className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
640
661
  >
641
- Settings
662
+ <span className="w-3 text-center">💰</span><span>Usage</span>
642
663
  </button>
643
664
  <button
644
665
  onClick={() => { setViewMode('logs'); setShowUserMenu(false); }}
645
- className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
666
+ className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
646
667
  >
647
- Logs
668
+ <span className="w-3 text-center">📜</span><span>Logs</span>
648
669
  </button>
649
- <a
650
- href="/chat"
651
- className="block w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
652
- >
653
- Chat (web)
654
- </a>
655
670
  <a
656
671
  href="/mobile"
657
- className="block w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]"
672
+ target="_blank"
673
+ rel="noopener noreferrer"
674
+ className="block w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
658
675
  >
659
- Mobile View
676
+ <span className="w-3 text-center">📱</span>
677
+ <span className="flex-1">Mobile View</span>
678
+ <span className="text-[9px] text-[var(--text-secondary)]">↗</span>
660
679
  </a>
661
680
  <div className="border-t border-[var(--border)] my-1" />
662
681
  <button
663
682
  onClick={() => signOut({ callbackUrl: '/login' })}
664
- className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--red)] hover:bg-[var(--bg-tertiary)]"
683
+ className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--red)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
665
684
  >
666
- Logout
685
+ <span className="w-3 text-center">⏻</span><span>Logout</span>
667
686
  </button>
668
687
  </div>
669
688
  </>