@aion0/forge 0.10.65 → 0.10.67

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,8 @@
1
- # Forge v0.10.65
1
+ # Forge v0.10.67
2
2
 
3
3
  Released: 2026-06-10
4
4
 
5
- ## Changes since v0.10.64
5
+ ## Changes since v0.10.66
6
6
 
7
- ### Other
8
- - fix(connectors): cache-bust github_api manifest fetch
9
- - feat(chat): connector _files download channel + extract_archive + create_schedule prompt mode
10
- - feat(settings): ↻ refresh-models button — pull registry past the 24h cache
11
- - fix(publish): preflight gh with real API call + actionable 401 hint
12
7
 
13
-
14
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.64...v0.10.65
8
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.66...v0.10.67
package/app/chat/page.tsx CHANGED
@@ -206,12 +206,42 @@ export default function ChatPage() {
206
206
  };
207
207
  }, [activeId, loadMessages, refreshSessions]);
208
208
 
209
- // ─── Auto-scroll on new content ───────────────────────────
209
+ // ─── Auto-scroll on new content — only when already at the bottom ──
210
+ // stickRef tracks whether the user is parked near the bottom. If they've
211
+ // scrolled up to read earlier turns, a refresh / new streamed chunk must
212
+ // NOT yank them back down. Updated on every scroll (see onScroll below).
213
+ const stickRef = useRef(true);
210
214
  useEffect(() => {
211
215
  const el = scrollRef.current;
212
- if (el) el.scrollTop = el.scrollHeight;
216
+ if (el && stickRef.current) el.scrollTop = el.scrollHeight;
213
217
  }, [messages, partial]);
214
218
 
219
+ // ─── Jump markers: one tick per user turn along the scroll track ──
220
+ const [markers, setMarkers] = useState<{ mid: string; pct: number; offset: number; label: string }[]>([]);
221
+ const recomputeMarkers = useCallback(() => {
222
+ const el = scrollRef.current;
223
+ if (!el) return;
224
+ const h = el.scrollHeight || 1;
225
+ const nodes = el.querySelectorAll<HTMLElement>('[data-role="user"][data-mid]');
226
+ setMarkers([...nodes].map((n) => ({
227
+ mid: n.dataset.mid || '',
228
+ offset: n.offsetTop,
229
+ pct: (n.offsetTop / h) * 100,
230
+ label: (n.dataset.label || '').slice(0, 80),
231
+ })));
232
+ }, []);
233
+ useEffect(() => {
234
+ const id = requestAnimationFrame(recomputeMarkers);
235
+ return () => cancelAnimationFrame(id);
236
+ }, [messages, partial, recomputeMarkers]);
237
+ useEffect(() => {
238
+ const el = scrollRef.current;
239
+ if (!el || typeof ResizeObserver === 'undefined') return;
240
+ const ro = new ResizeObserver(() => recomputeMarkers());
241
+ ro.observe(el);
242
+ return () => ro.disconnect();
243
+ }, [recomputeMarkers]);
244
+
215
245
  // ─── Prune stale watch chips (no update in >150s = done/gone) ──
216
246
  useEffect(() => {
217
247
  const t = setInterval(() => {
@@ -643,7 +673,15 @@ export default function ChatPage() {
643
673
  </button>
644
674
  </header>
645
675
 
646
- <div ref={scrollRef} className="flex-1 overflow-y-auto">
676
+ <div className="flex-1 relative min-h-0">
677
+ <div
678
+ ref={scrollRef}
679
+ className="absolute inset-0 overflow-y-auto"
680
+ onScroll={(e) => {
681
+ const el = e.currentTarget;
682
+ stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
683
+ }}
684
+ >
647
685
  <div className="max-w-3xl mx-auto px-6 py-6 space-y-6">
648
686
  {messages.length === 0 && !partial && !streaming && (
649
687
  <div className="text-center text-sm text-[var(--text-secondary)] mt-12">
@@ -672,6 +710,35 @@ export default function ChatPage() {
672
710
  )}
673
711
  </div>
674
712
  </div>
713
+ {/* Jump rail — one tick per user turn, mapped to its position in the
714
+ scroll content. Click to jump. Hidden until there are ≥2 turns. */}
715
+ {markers.length >= 2 && (
716
+ <div className="absolute right-0.5 top-2 bottom-2 w-3 z-10 pointer-events-none">
717
+ {markers.map((mk) => (
718
+ <div
719
+ key={mk.mid}
720
+ className="group pointer-events-auto absolute right-0 -translate-y-1/2"
721
+ style={{ top: `${Math.min(99, Math.max(1, mk.pct))}%` }}
722
+ >
723
+ <button
724
+ onClick={() => {
725
+ const el = scrollRef.current;
726
+ if (el) el.scrollTo({ top: Math.max(0, mk.offset - 12), behavior: 'smooth' });
727
+ }}
728
+ className="block h-1.5 w-1.5 rounded-full bg-[var(--text-secondary)] opacity-40 group-hover:opacity-100 group-hover:w-2.5 group-hover:bg-[var(--accent)] transition-all"
729
+ />
730
+ {/* Instant hover preview (native title lags ~1s). Sits to the
731
+ left of the rail; truncates to one ellipsised line. */}
732
+ {mk.label && (
733
+ <div className="pointer-events-none absolute right-4 top-1/2 -translate-y-1/2 hidden group-hover:block z-20 max-w-[280px] truncate rounded-md border border-[var(--border)] bg-[var(--bg-secondary)] px-2 py-1 text-xs text-[var(--text-primary)] shadow-lg">
734
+ {mk.label}
735
+ </div>
736
+ )}
737
+ </div>
738
+ ))}
739
+ </div>
740
+ )}
741
+ </div>
675
742
 
676
743
  {Object.keys(watchChips).length > 0 && (
677
744
  <div className="px-6 pt-2">
@@ -775,7 +842,7 @@ function fmtTs(ts?: number): string {
775
842
  return sameDay ? time : `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${time}`;
776
843
  }
777
844
 
778
- function RoleBlock({ role, ts, pending, children }: { role: 'user' | 'assistant'; ts?: number; pending?: boolean; children: React.ReactNode }) {
845
+ function RoleBlock({ role, ts, pending, mid, label, children }: { role: 'user' | 'assistant'; ts?: number; pending?: boolean; mid?: string; label?: string; children: React.ReactNode }) {
779
846
  const isUser = role === 'user';
780
847
  // User → right-aligned with bubble; assistant → left-aligned (avatar +
781
848
  // expanded content for markdown / tool cards). Pending optimistic notes
@@ -783,7 +850,7 @@ function RoleBlock({ role, ts, pending, children }: { role: 'user' | 'assistant'
783
850
  // replaces them on the loop's next iteration.
784
851
  if (isUser) {
785
852
  return (
786
- <div className="flex justify-end">
853
+ <div className="flex justify-end" data-role="user" data-mid={mid} data-label={label}>
787
854
  <div className="max-w-[80%]">
788
855
  <div className="flex items-baseline gap-2 mb-1 justify-end">
789
856
  {pending ? (
@@ -834,8 +901,12 @@ function RoleBlock({ role, ts, pending, children }: { role: 'user' | 'assistant'
834
901
  // chat became visibly laggy.
835
902
  const MessageView = memo(function MessageView({ m }: { m: Message }) {
836
903
  const pending = typeof m.id === 'string' && m.id.startsWith('optimistic-note-');
904
+ // Label for the jump rail = first text block of a user turn, trimmed.
905
+ const label = m.role === 'user'
906
+ ? (m.blocks.find((b) => b.type === 'text')?.text || '').replace(/\s+/g, ' ').trim().slice(0, 80)
907
+ : undefined;
837
908
  return (
838
- <RoleBlock role={m.role} ts={m.ts} pending={pending}>
909
+ <RoleBlock role={m.role} ts={m.ts} pending={pending} mid={String(m.id)} label={label}>
839
910
  {m.blocks.map((b, i) => (
840
911
  <BlockView key={i} b={b} role={m.role} />
841
912
  ))}
@@ -56,6 +56,11 @@ export interface IdpTemplateBlock {
56
56
  * `https://<host>/`. Override when the IdP's logged-in landing page
57
57
  * is at a specific path (e.g. `/saml-idp/portal/` for FAC). */
58
58
  probe_url?: string;
59
+ /** DOM selector present ONLY on the login page (e.g. 'input[type=password]').
60
+ * Required for IdPs that serve their login form on the SAME host as the
61
+ * logged-in page (sso.frval.fortinet-emea.com) — without it the host-only
62
+ * probe falsely reports "signed in" when a login screen is showing. */
63
+ auth_required_selector?: string;
59
64
  /** Optional alt hostnames the IdP also uses (e.g. regional SAML servers). */
60
65
  alt_hosts?: string[];
61
66
  saml_sps?: string[];
@@ -235,7 +235,7 @@ async function checkIdp(
235
235
  const block = readIdpBlocks().find((b) => b.host === host);
236
236
  const probe_url = block ? idpProbeUrl(block) : `https://${host}/`;
237
237
  try {
238
- const r = await runBrowserUrlProbe({ id: `idp:${host}`, host, probe_url });
238
+ const r = await runBrowserUrlProbe({ id: `idp:${host}`, host, probe_url, auth_required_selector: block?.auth_required_selector });
239
239
  return {
240
240
  ok: !!r.ok,
241
241
  message: r.ok ? (r.message || 'ok') : (r.error || `HTTP ${r.status || '?'}`),
@@ -283,6 +283,12 @@ export async function runBrowserUrlProbe(opts: {
283
283
  id: string; // identifier for telemetry, e.g. 'idp:fac.corp.fortinet.com'
284
284
  host: string; // expected host (e.g. 'fac.corp.fortinet.com')
285
285
  probe_url: string; // URL to navigate to (e.g. 'https://fac.corp.fortinet.com/saml-idp/portal/')
286
+ // DOM selector present ONLY on the login page (e.g. 'input[type=password]').
287
+ // Required for IdPs that serve their login form on the SAME host as the
288
+ // logged-in page (sso.frval.fortinet-emea.com), where the host-only check
289
+ // can't tell logged-in from a login screen. Extension v0.2.15+ runs the
290
+ // querySelector and returns ok:false on a match.
291
+ auth_required_selector?: string;
286
292
  timeout_ms?: number;
287
293
  }): Promise<TestResult> {
288
294
  const t0 = Date.now();
@@ -291,6 +297,7 @@ export async function runBrowserUrlProbe(opts: {
291
297
  value = await bridgeRpc('connector.probe', {
292
298
  pluginId: opts.id,
293
299
  host_match: opts.probe_url, // extension navigates to this URL directly
300
+ auth_required_selector: opts.auth_required_selector || undefined,
294
301
  runner: 'main',
295
302
  timeout_ms: opts.timeout_ms || 15_000,
296
303
  });
@@ -303,8 +310,14 @@ export async function runBrowserUrlProbe(opts: {
303
310
  // Pass if landed host matches expected. A redirect to a different host
304
311
  // (e.g. fac.corp.fortinet.com → ms-login.fortinet.com when SSO expired)
305
312
  // means the user needs to re-authenticate.
306
- let onExpected = false;
307
- try { onExpected = !failedNetwork && new URL(landed).hostname.toLowerCase() === opts.host.toLowerCase(); } catch { /* bad url */ }
313
+ let hostnameMatch = false;
314
+ try { hostnameMatch = !failedNetwork && new URL(landed).hostname.toLowerCase() === opts.host.toLowerCase(); } catch { /* bad url */ }
315
+ // When auth_required_selector is set, the extension flips ok:false if the
316
+ // login form is on the page — so a same-host login screen no longer passes.
317
+ // Older extensions / no selector → r.ok is undefined and we fall back to
318
+ // the host-only check.
319
+ const loginFormShown = opts.auth_required_selector ? r.ok === false : false;
320
+ const onExpected = hostnameMatch && !loginFormShown;
308
321
  return {
309
322
  ok: onExpected,
310
323
  message: onExpected ? `Session active · ${landed}` : undefined,
@@ -312,7 +325,9 @@ export async function runBrowserUrlProbe(opts: {
312
325
  ? undefined
313
326
  : (failedNetwork
314
327
  ? `Network unreachable — ${landed || '(no url)'}. VPN / hostname / firewall?`
315
- : `Not signed in — redirected to ${landed}`),
328
+ : loginFormShown
329
+ ? `Not signed in — login page shown at ${landed}`
330
+ : `Not signed in — redirected to ${landed}`),
316
331
  url: landed,
317
332
  duration_ms: Date.now() - t0,
318
333
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.65",
3
+ "version": "0.10.67",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {