@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 +3 -9
- package/app/chat/page.tsx +77 -6
- package/lib/auth/idp-login.ts +5 -0
- package/lib/auth/login-status.ts +1 -1
- package/lib/connectors/test-runner.ts +18 -3
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,14 +1,8 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.67
|
|
2
2
|
|
|
3
3
|
Released: 2026-06-10
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
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
|
|
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
|
))}
|
package/lib/auth/idp-login.ts
CHANGED
|
@@ -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[];
|
package/lib/auth/login-status.ts
CHANGED
|
@@ -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
|
|
307
|
-
try {
|
|
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
|
-
:
|
|
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