@aion0/forge 0.10.45 → 0.10.47

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,14 +1,12 @@
1
- # Forge v0.10.45
1
+ # Forge v0.10.47
2
2
 
3
3
  Released: 2026-06-08
4
4
 
5
- ## Changes since v0.10.44
6
-
7
- ### Bug Fixes
8
- - fix: validate enterprise key against GitHub before persisting
5
+ ## Changes since v0.10.46
9
6
 
10
7
  ### Other
11
- - fix(enterprise): strip whitespace + zero-width chars from pasted keys
8
+ - ui(header): selected tab uses accent tint, not bg-secondary shade
9
+ - ui(header): bell for Alerts, stacked dept/name pill, +1px nav font
12
10
 
13
11
 
14
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.44...v0.10.45
12
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.46...v0.10.47
@@ -0,0 +1,98 @@
1
+ /**
2
+ * GitLab SSH 2FA verify API.
3
+ *
4
+ * GET /api/auth/gitlab-2fa
5
+ * → { host, verifiedAt: string | null, seconds_left: number }
6
+ *
7
+ * POST /api/auth/gitlab-2fa
8
+ * body: { otp: "123456" }
9
+ * → { ok: true, verifiedAt, seconds_left, host } |
10
+ * { ok: false, error, transcript? }
11
+ *
12
+ * verifiedAt is persisted in settings.gitlab2fa so dropdown survives a
13
+ * page refresh.
14
+ */
15
+
16
+ import { NextResponse } from 'next/server';
17
+ import { loadSettings, saveSettings } from '@/lib/settings';
18
+ import {
19
+ verifyGitlab2FA,
20
+ verifyGitlab2FAViaKeystroke,
21
+ getGitlabSshHost,
22
+ secondsLeft,
23
+ GITLAB_2FA_VALID_MS,
24
+ } from '@/lib/auth/gitlab-2fa';
25
+
26
+ export async function GET() {
27
+ const settings = loadSettings();
28
+ const host = getGitlabSshHost();
29
+ const recorded = settings.gitlab2fa;
30
+ // Drop the stamp if host changed (user pointed gitlab elsewhere)
31
+ const valid = recorded && (!host || recorded.host === host);
32
+ const verifiedAt = valid ? recorded?.verifiedAt ?? null : null;
33
+ return NextResponse.json({
34
+ host,
35
+ verifiedAt,
36
+ seconds_left: secondsLeft(verifiedAt ?? undefined),
37
+ validity_seconds: Math.floor(GITLAB_2FA_VALID_MS / 1000),
38
+ });
39
+ }
40
+
41
+ export async function POST(req: Request) {
42
+ let body: any = {};
43
+ try { body = await req.json(); } catch {}
44
+
45
+ // Manual mode: caller already ran `ssh git@host 2fa_verify` in their
46
+ // own terminal (necessary when corp VPN like FortiClient does per-
47
+ // process tunnel filtering — node-spawned ssh can't reach the host).
48
+ // Forge just stamps verifiedAt so the 55-minute clock starts.
49
+ if (body?.manual === true) {
50
+ const host = getGitlabSshHost();
51
+ if (!host) {
52
+ return NextResponse.json({ ok: false, error: 'GitLab connector not configured' }, { status: 400 });
53
+ }
54
+ const verifiedAt = new Date().toISOString();
55
+ const settings = loadSettings();
56
+ settings.gitlab2fa = { verifiedAt, host };
57
+ saveSettings(settings);
58
+ return NextResponse.json({
59
+ ok: true,
60
+ verifiedAt,
61
+ host,
62
+ seconds_left: Math.floor(GITLAB_2FA_VALID_MS / 1000),
63
+ mode: 'manual',
64
+ });
65
+ }
66
+
67
+ const otp = String(body?.otp || '').trim();
68
+ if (!otp) {
69
+ return NextResponse.json({ ok: false, error: 'otp is required' }, { status: 400 });
70
+ }
71
+
72
+ // Mode selector: 'keystroke' uses osascript+System Events to type
73
+ // into the user's frontmost Terminal — required in environments
74
+ // where the corp VPN blocks Forge-spawned ssh (FortiClient).
75
+ // Default is the direct node-pty path (works for non-filtered envs).
76
+ const mode = String(body?.mode || 'direct');
77
+ const r = mode === 'keystroke'
78
+ ? await verifyGitlab2FAViaKeystroke(otp)
79
+ : await verifyGitlab2FA(otp);
80
+ if (!r.ok) {
81
+ return NextResponse.json(
82
+ { ok: false, error: r.error || 'verify failed', transcript: r.transcript },
83
+ { status: 400 },
84
+ );
85
+ }
86
+
87
+ // Persist for the dropdown's "X min left" rendering.
88
+ const settings = loadSettings();
89
+ settings.gitlab2fa = { verifiedAt: r.verifiedAt!, host: r.host! };
90
+ saveSettings(settings);
91
+
92
+ return NextResponse.json({
93
+ ok: true,
94
+ verifiedAt: r.verifiedAt,
95
+ host: r.host,
96
+ seconds_left: Math.floor(GITLAB_2FA_VALID_MS / 1000),
97
+ });
98
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * GET /api/auth/idp-credentials → { username, password } (decrypted)
3
+ * POST /api/auth/idp-credentials body: { username, password }
4
+ * DELETE /api/auth/idp-credentials
5
+ *
6
+ * Read/write the EnterpriseBadge's saved unified-login credentials.
7
+ * Password lives in SECRET_FIELDS so it's AES-256-GCM encrypted at rest;
8
+ * this endpoint hands it back in plaintext for the local UI to prefill
9
+ * (Forge is single-user, runs on localhost — equivalent threat surface
10
+ * to the user's own keychain). OTP is never persisted — it's TOTP.
11
+ */
12
+
13
+ import { NextResponse } from 'next/server';
14
+ import { loadSettings, saveSettings } from '@/lib/settings';
15
+
16
+ export async function GET() {
17
+ const s = loadSettings() as unknown as Record<string, unknown>;
18
+ return NextResponse.json({
19
+ username: (s.idpSavedUsername as string) || '',
20
+ password: (s.idpSavedPassword as string) || '',
21
+ });
22
+ }
23
+
24
+ export async function POST(req: Request) {
25
+ let body: { username?: string; password?: string } = {};
26
+ try { body = await req.json(); } catch { /* empty body */ }
27
+ const username = String(body.username || '').trim();
28
+ const password = String(body.password || '');
29
+ if (!username || !password) {
30
+ return NextResponse.json({ ok: false, error: 'username and password are required' }, { status: 400 });
31
+ }
32
+ const current = loadSettings() as unknown as Record<string, unknown>;
33
+ saveSettings({ ...current, idpSavedUsername: username, idpSavedPassword: password } as never);
34
+ return NextResponse.json({ ok: true });
35
+ }
36
+
37
+ export async function DELETE() {
38
+ const current = loadSettings() as unknown as Record<string, unknown>;
39
+ saveSettings({ ...current, idpSavedUsername: '', idpSavedPassword: '' } as never);
40
+ return NextResponse.json({ ok: true });
41
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * POST /api/auth/idp-login
3
+ * body: { username, password, otp }
4
+ * → { ok, idp_logins: [{idp_host, ok, steps_completed?, final_url?, error?, post_login_terminal?}], error? }
5
+ *
6
+ * Drives one or more SAML IdP logins (template `_idp` may be array) via
7
+ * the extension. Secrets stay in this request — never persisted.
8
+ */
9
+
10
+ import { NextResponse } from 'next/server';
11
+ import { performIdpLogin } from '@/lib/auth/idp-login';
12
+
13
+ export async function POST(req: Request) {
14
+ let body: any = {};
15
+ try { body = await req.json(); } catch {}
16
+ const r = await performIdpLogin({
17
+ username: String(body?.username || '').trim(),
18
+ password: String(body?.password || ''),
19
+ otp: String(body?.otp || '').trim(),
20
+ });
21
+ return NextResponse.json(r, { status: r.ok ? 200 : 400 });
22
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * GET /api/scratch/<path...>
3
+ * ?download=1 → force Content-Disposition: attachment
4
+ *
5
+ * Serves files from `<dataDir>/scratch/` so chat-emitted paths like
6
+ * `scratch/report-2026-06-08.md` can be clicked open from the chat UI.
7
+ * Path-traversal protected: any segment containing `..` or any resolved
8
+ * path escaping the scratch root is rejected.
9
+ *
10
+ * Auth-gated by Forge's proxy middleware just like every other /api/*.
11
+ */
12
+
13
+ import { NextResponse } from 'next/server';
14
+ import { existsSync, readFileSync, statSync } from 'node:fs';
15
+ import { join, resolve, extname } from 'node:path';
16
+ import { getDataDir } from '@/lib/dirs';
17
+
18
+ const MIME: Record<string, string> = {
19
+ '.md': 'text/markdown; charset=utf-8',
20
+ '.txt': 'text/plain; charset=utf-8',
21
+ '.json': 'application/json; charset=utf-8',
22
+ '.yaml': 'application/yaml; charset=utf-8',
23
+ '.yml': 'application/yaml; charset=utf-8',
24
+ '.csv': 'text/csv; charset=utf-8',
25
+ '.log': 'text/plain; charset=utf-8',
26
+ '.html': 'text/html; charset=utf-8',
27
+ '.pdf': 'application/pdf',
28
+ '.png': 'image/png',
29
+ '.jpg': 'image/jpeg',
30
+ '.jpeg': 'image/jpeg',
31
+ '.gif': 'image/gif',
32
+ '.svg': 'image/svg+xml',
33
+ };
34
+
35
+ export async function GET(
36
+ req: Request,
37
+ ctx: { params: Promise<{ path: string[] }> },
38
+ ) {
39
+ const { path: parts } = await ctx.params;
40
+ if (!parts || parts.length === 0) {
41
+ return NextResponse.json({ ok: false, error: 'missing path' }, { status: 400 });
42
+ }
43
+ // Reject explicit traversal attempts before path construction.
44
+ for (const seg of parts) {
45
+ if (seg === '..' || seg.startsWith('..')) {
46
+ return NextResponse.json({ ok: false, error: 'path traversal rejected' }, { status: 400 });
47
+ }
48
+ }
49
+ const scratchRoot = resolve(join(getDataDir(), 'scratch'));
50
+ const target = resolve(join(scratchRoot, ...parts));
51
+ // Verify the resolved path stays under scratchRoot — handles symlink
52
+ // tricks the segment check above can't catch.
53
+ if (!target.startsWith(scratchRoot + '/') && target !== scratchRoot) {
54
+ return NextResponse.json({ ok: false, error: 'path traversal rejected' }, { status: 400 });
55
+ }
56
+ if (!existsSync(target)) {
57
+ return NextResponse.json({ ok: false, error: 'not found' }, { status: 404 });
58
+ }
59
+ const st = statSync(target);
60
+ if (!st.isFile()) {
61
+ return NextResponse.json({ ok: false, error: 'not a file' }, { status: 400 });
62
+ }
63
+ const url = new URL(req.url);
64
+ const download = url.searchParams.get('download') === '1';
65
+ const ext = extname(target).toLowerCase();
66
+ const mime = MIME[ext] || 'application/octet-stream';
67
+ const data = readFileSync(target);
68
+ const filename = parts[parts.length - 1];
69
+ return new NextResponse(new Uint8Array(data), {
70
+ status: 200,
71
+ headers: {
72
+ 'Content-Type': mime,
73
+ 'Content-Length': String(st.size),
74
+ 'Content-Disposition': `${download ? 'attachment' : 'inline'}; filename="${filename.replace(/"/g, '\\"')}"`,
75
+ 'Cache-Control': 'private, max-age=0, must-revalidate',
76
+ },
77
+ });
78
+ }
@@ -656,7 +656,16 @@ function startBackground() {
656
656
  const child = spawn(nextBin, ['start', '-p', String(webPort)], {
657
657
  cwd: ROOT,
658
658
  stdio: ['ignore', logFd, logFd],
659
- env: { ...process.env, FORGE_EXTERNAL_SERVICES: '1' },
659
+ env: {
660
+ ...process.env,
661
+ FORGE_EXTERNAL_SERVICES: '1',
662
+ // --use-system-ca lets Node 22+ trust the OS keychain in addition
663
+ // to its baked-in CA bundle. Corp networks (FortiClient SSL
664
+ // inspection, Zscaler, etc.) install a custom root via MDM; Node
665
+ // doesn't see it without this flag, so marketplace sync fails with
666
+ // 'self-signed certificate in certificate chain' on api.github.com.
667
+ NODE_OPTIONS: [process.env.NODE_OPTIONS, '--use-system-ca'].filter(Boolean).join(' '),
668
+ },
660
669
  detached: true,
661
670
  });
662
671
 
@@ -738,7 +747,16 @@ if (isDev) {
738
747
  const child = spawn('npx', ['next', 'dev', '--turbopack', '-p', String(webPort)], {
739
748
  cwd: ROOT,
740
749
  stdio: 'inherit',
741
- env: { ...process.env, FORGE_EXTERNAL_SERVICES: '1' },
750
+ env: {
751
+ ...process.env,
752
+ FORGE_EXTERNAL_SERVICES: '1',
753
+ // --use-system-ca lets Node 22+ trust the OS keychain in addition
754
+ // to its baked-in CA bundle. Corp networks (FortiClient SSL
755
+ // inspection, Zscaler, etc.) install a custom root via MDM; Node
756
+ // doesn't see it without this flag, so marketplace sync fails with
757
+ // 'self-signed certificate in certificate chain' on api.github.com.
758
+ NODE_OPTIONS: [process.env.NODE_OPTIONS, '--use-system-ca'].filter(Boolean).join(' '),
759
+ },
742
760
  });
743
761
  child.on('exit', (code) => { stopServices(); process.exit(code || 0); });
744
762
  } else {
@@ -748,7 +766,16 @@ if (isDev) {
748
766
  const child = spawn('npx', ['next', 'start', '-p', String(webPort)], {
749
767
  cwd: ROOT,
750
768
  stdio: 'inherit',
751
- env: { ...process.env, FORGE_EXTERNAL_SERVICES: '1' },
769
+ env: {
770
+ ...process.env,
771
+ FORGE_EXTERNAL_SERVICES: '1',
772
+ // --use-system-ca lets Node 22+ trust the OS keychain in addition
773
+ // to its baked-in CA bundle. Corp networks (FortiClient SSL
774
+ // inspection, Zscaler, etc.) install a custom root via MDM; Node
775
+ // doesn't see it without this flag, so marketplace sync fails with
776
+ // 'self-signed certificate in certificate chain' on api.github.com.
777
+ NODE_OPTIONS: [process.env.NODE_OPTIONS, '--use-system-ca'].filter(Boolean).join(' '),
778
+ },
752
779
  });
753
780
  child.on('exit', (code) => { stopServices(); process.exit(code || 0); });
754
781
  }
@@ -422,9 +422,9 @@ export default function Dashboard({ user }: { user: any }) {
422
422
  <button
423
423
  key={mode}
424
424
  onClick={() => setViewMode(mode)}
425
- className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
425
+ className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
426
426
  viewMode === mode
427
- ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
427
+ ? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
428
428
  : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
429
429
  }`}
430
430
  >
@@ -435,9 +435,9 @@ export default function Dashboard({ user }: { user: any }) {
435
435
  {/* Docs */}
436
436
  <button
437
437
  onClick={() => setViewMode('docs')}
438
- className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
438
+ className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
439
439
  viewMode === 'docs'
440
- ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
440
+ ? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
441
441
  : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
442
442
  }`}
443
443
  >
@@ -451,9 +451,9 @@ export default function Dashboard({ user }: { user: any }) {
451
451
  onClick={() => {
452
452
  if (!['tasks', 'pipelines', 'schedules'].includes(viewMode)) setViewMode('schedules');
453
453
  }}
454
- className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
454
+ className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
455
455
  ['tasks', 'pipelines', 'schedules'].includes(viewMode)
456
- ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
456
+ ? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
457
457
  : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
458
458
  }`}
459
459
  >
@@ -468,9 +468,9 @@ export default function Dashboard({ user }: { user: any }) {
468
468
  {/* Marketplace */}
469
469
  <button
470
470
  onClick={() => setViewMode('skills')}
471
- className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
471
+ className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
472
472
  viewMode === 'skills'
473
- ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
473
+ ? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
474
474
  : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
475
475
  }`}
476
476
  >
@@ -481,56 +481,7 @@ export default function Dashboard({ user }: { user: any }) {
481
481
  </div>
482
482
  <div className="flex items-center gap-2.5">
483
483
  {/* (Automation context actions moved to the secondary toolbar row below) */}
484
- {/* Help */}
485
- <button
486
- onClick={() => setShowHelp(v => !v)}
487
- className={`text-[10px] px-2 py-0.5 border rounded transition-colors ${
488
- showHelp
489
- ? 'border-[var(--accent)] text-[var(--accent)]'
490
- : 'border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)]'
491
- }`}
492
- >?</button>
493
- <div className="relative">
494
- <button
495
- onClick={() => setShowBrowserMenu(v => !v)}
496
- className={`text-[10px] px-2 py-0.5 border rounded transition-colors ${
497
- browserMode !== 'none'
498
- ? 'border-blue-500 text-blue-400'
499
- : 'border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)]'
500
- }`}
501
- >
502
- Browser
503
- </button>
504
- {showBrowserMenu && (
505
- <>
506
- <div className="fixed inset-0 z-40" onClick={() => setShowBrowserMenu(false)} />
507
- <div className="absolute top-full right-0 mt-1 z-50 bg-[var(--bg-secondary)] border border-[var(--border)] rounded shadow-lg py-1 min-w-[140px]">
508
- {browserMode !== 'none' && (
509
- <button onClick={() => { setBrowserMode('none'); setShowBrowserMenu(false); }} className="w-full text-left px-3 py-1.5 text-[10px] text-red-400 hover:bg-[var(--bg-tertiary)]">
510
- Close Browser
511
- </button>
512
- )}
513
- <button onClick={() => { setBrowserMode('float'); setShowBrowserMenu(false); }} className={`w-full text-left px-3 py-1.5 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'float' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
514
- Floating Window
515
- </button>
516
- <button onClick={() => { setBrowserMode('right'); setShowBrowserMenu(false); }} className={`w-full text-left px-3 py-1.5 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'right' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
517
- Right Side
518
- </button>
519
- <button onClick={() => { setBrowserMode('left'); setShowBrowserMenu(false); }} className={`w-full text-left px-3 py-1.5 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'left' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
520
- Left Side
521
- </button>
522
- <button onClick={() => {
523
- const url = localStorage.getItem('forge-browser-url');
524
- if (url) window.open(url, '_blank');
525
- else { const u = prompt('Enter URL to open:'); if (u) window.open(u.trim(), '_blank'); }
526
- setShowBrowserMenu(false);
527
- }} className="w-full text-left px-3 py-1.5 text-[10px] text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]">
528
- New Tab
529
- </button>
530
- </div>
531
- </>
532
- )}
533
- </div>
484
+ {/* Help / Browser moved into the user dropdown — see below. */}
534
485
  <Suspense fallback={null}><TunnelToggle /></Suspense>
535
486
  {onlineCount.total > 0 && (
536
487
  <span className="text-[10px] text-[var(--text-secondary)] flex items-center gap-1" title={`${onlineCount.total} online${onlineCount.remote > 0 ? `, ${onlineCount.remote} remote` : ''}`}>
@@ -539,13 +490,15 @@ export default function Dashboard({ user }: { user: any }) {
539
490
  </span>
540
491
  )}
541
492
  <span className="w-[2px] h-4 bg-[var(--text-secondary)]/30" />
542
- {/* Alerts */}
493
+ {/* Alerts — bell glyph keeps the same role; unread pill rides the bell. */}
543
494
  <div className="relative">
544
495
  <button
545
496
  onClick={() => { setShowNotifications(v => !v); setShowUserMenu(false); }}
546
- className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] relative px-1"
497
+ className="text-[14px] leading-none text-[var(--text-secondary)] hover:text-[var(--text-primary)] relative px-1"
498
+ title="Alerts"
499
+ aria-label="Alerts"
547
500
  >
548
- Alerts
501
+ <span aria-hidden>🔔</span>
549
502
  {unreadCount > 0 && (
550
503
  <span className="absolute -top-1.5 -right-1.5 min-w-[14px] h-[14px] rounded-full bg-[var(--red)] text-[8px] text-white flex items-center justify-center px-1 font-bold">
551
504
  {unreadCount > 99 ? '99+' : unreadCount}
@@ -669,14 +622,17 @@ export default function Dashboard({ user }: { user: any }) {
669
622
  <div className="relative">
670
623
  <button
671
624
  onClick={() => { setShowUserMenu(v => !v); setShowNotifications(false); }}
672
- className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] flex items-center gap-1 px-1"
625
+ className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] flex items-center gap-1 px-1"
673
626
  >
674
- {profileDept && (
675
- <span className="text-[9px] px-1 py-[1px] rounded bg-emerald-500/15 text-emerald-500 border border-emerald-500/30 mr-1" title="Active department">
676
- {profileDept}
677
- </span>
678
- )}
679
- {displayName} <span className="text-[8px]">▾</span>
627
+ <span className="flex flex-col items-end leading-tight">
628
+ {profileDept && (
629
+ <span className="text-[8px] px-1 rounded bg-emerald-500/15 text-emerald-500 border border-emerald-500/30" title="Active department">
630
+ {profileDept}
631
+ </span>
632
+ )}
633
+ <span className="text-[9px]">{displayName}</span>
634
+ </span>
635
+ <span className="text-[8px]">▾</span>
680
636
  </button>
681
637
  {showUserMenu && (
682
638
  <>
@@ -742,7 +698,49 @@ export default function Dashboard({ user }: { user: any }) {
742
698
  <span className="flex-1">Mobile View</span>
743
699
  <span className="text-[9px] text-[var(--text-secondary)]">↗</span>
744
700
  </a>
701
+ {/* Browser — toggling reveals the layout options inline so the
702
+ sub-menu lives inside the user dropdown (no nested popup). */}
703
+ <button
704
+ onClick={() => setShowBrowserMenu(v => !v)}
705
+ 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"
706
+ >
707
+ <span className="w-3 text-center">🌐</span>
708
+ <span className="flex-1">Browser{browserMode !== 'none' ? ` · ${browserMode}` : ''}</span>
709
+ <span className="text-[9px] text-[var(--text-secondary)]">{showBrowserMenu ? '▾' : '▸'}</span>
710
+ </button>
711
+ {showBrowserMenu && (
712
+ <div className="pl-6 pr-1 py-0.5 border-l border-[var(--border)] ml-3 mb-1">
713
+ <button onClick={() => { setBrowserMode('float'); setShowBrowserMenu(false); setShowUserMenu(false); }} className={`w-full text-left px-2 py-1 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'float' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
714
+ Floating Window
715
+ </button>
716
+ <button onClick={() => { setBrowserMode('right'); setShowBrowserMenu(false); setShowUserMenu(false); }} className={`w-full text-left px-2 py-1 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'right' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
717
+ Right Side
718
+ </button>
719
+ <button onClick={() => { setBrowserMode('left'); setShowBrowserMenu(false); setShowUserMenu(false); }} className={`w-full text-left px-2 py-1 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'left' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
720
+ Left Side
721
+ </button>
722
+ <button onClick={() => {
723
+ const url = localStorage.getItem('forge-browser-url');
724
+ if (url) window.open(url, '_blank');
725
+ else { const u = prompt('Enter URL to open:'); if (u) window.open(u.trim(), '_blank'); }
726
+ setShowBrowserMenu(false); setShowUserMenu(false);
727
+ }} className="w-full text-left px-2 py-1 text-[10px] text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]">
728
+ New Tab
729
+ </button>
730
+ {browserMode !== 'none' && (
731
+ <button onClick={() => { setBrowserMode('none'); setShowBrowserMenu(false); setShowUserMenu(false); }} className="w-full text-left px-2 py-1 text-[10px] text-red-400 hover:bg-[var(--bg-tertiary)]">
732
+ Close Browser
733
+ </button>
734
+ )}
735
+ </div>
736
+ )}
745
737
  <div className="border-t border-[var(--border)] my-1" />
738
+ <button
739
+ onClick={() => { setShowHelp(v => !v); setShowUserMenu(false); }}
740
+ 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"
741
+ >
742
+ <span className="w-3 text-center">?</span><span>Help</span>
743
+ </button>
746
744
  <button
747
745
  onClick={() => signOut({ callbackUrl: '/login' })}
748
746
  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"
@@ -765,9 +763,9 @@ export default function Dashboard({ user }: { user: any }) {
765
763
  <button
766
764
  key={m}
767
765
  onClick={() => setViewMode(m)}
768
- className={`text-[11px] px-2.5 py-0.5 rounded transition-colors ${
766
+ className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
769
767
  viewMode === m
770
- ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm'
768
+ ? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
771
769
  : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
772
770
  }`}
773
771
  >