Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ jobs:
echo "VERSION=$version" >> "$GITHUB_ENV"
# Prereleases ship only as GitHub release tarballs.
# Stable versions also publish to npm.
case "$version" in
# Only a hyphen before build metadata (+) marks a prerelease.
version_without_build="${version%%+*}"
case "$version_without_build" in
*-*) echo "PRERELEASE=true" >> "$GITHUB_ENV" ;;
*) echo "PRERELEASE=false" >> "$GITHUB_ENV" ;;
esac
Expand Down Expand Up @@ -129,6 +131,9 @@ jobs:
release_args=()
if [ "$PRERELEASE" = "true" ]; then
release_args=(--prerelease --latest=false)
tarball_name="$(basename "$tarball")"
printf '\n## Install\n\n```sh\ncd /data && npm i https://github.com/%s/releases/download/%s/%s\n```\n\nInstall in your Node-RED user directory (`/data` for Docker or `~/.node-red` locally), then restart Node-RED.\n' \
"$GITHUB_REPOSITORY" "$TAG" "$tarball_name" >> release-notes.md
fi
if gh release view "$TAG" >/dev/null 2>&1; then
gh release edit "$TAG" --title "$TAG" --notes-file release-notes.md "${release_args[@]}"
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- Recover dashboard editor jumps when the last-focused editor tab has closed or
stopped responding. Track editor presence, clear owned focus and presence on
pagehide, and avoid reloading a live editor when acknowledgement times out.
Remove transient reveal claims after handling each request. Prevent duplicate
cards when a late owner races a surviving tab, and refresh presence on
receipt.
- Isolate dashboard reveals to the last-focused editor tab (#128).
- Skip scrolling to the top when opening proposal deep-links (#128).
- Flush the first OAuth client `lastUsedAt` update immediately so Last used
survives a restart (#128).
- Restore the landed-after-withdraw badge style and expose `landedAfterWithdraw`
in proposal list and detail API responses (#128).
- Keep dashboard h2 focus-visible styling subtle (#128).
- Recognize stable versions with `+build` metadata correctly when detecting
prereleases in `release.yml` (#128).
- Include a generic install URL and install command in prerelease notes (#128).

## [0.2.0-beta.13] - 2026-11-10

- Label the "Opened from dashboard" card action Go to flow for flow, tab and
Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,14 @@ Configuration).

Stable releases use npm as shown above. From 0.2.0-beta.12 onward,
pre-releases are available only as GitHub Release assets, not on npm. To
install this beta, use its tarball URL in your user directory (`/data` is
typical for Docker; otherwise use `~/.node-red`):
install a pre-release, pick the one you want from
[GitHub Releases](https://github.com/com55/node-red-ai-access/releases) and
use its exact install command from the release notes. For example, replace
`X.Y.Z` below with the full version, including its pre-release suffix
(`/data` is typical for Docker; otherwise use `~/.node-red`):

```bash
cd /data && npm i https://github.com/com55/node-red-ai-access/releases/download/v0.2.0-beta.13/com55-node-red-ai-access-0.2.0-beta.13.tgz
cd /data && npm i https://github.com/com55/node-red-ai-access/releases/download/vX.Y.Z/com55-node-red-ai-access-X.Y.Z.tgz
```

2. Restart Node-RED.
Expand Down
12 changes: 9 additions & 3 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@ production build and tests. It creates or updates the GitHub release for that
tag, with the CHANGELOG section as notes and the `npm pack` tarball attached.
Stable versions (`PRERELEASE=false`) also publish to npm. From 0.2.0-beta.12
onward, pre-releases (`PRERELEASE=true`) ship only as GitHub pre-release assets
and never publish to npm.
and never publish to npm. Only a `-` before any `+` build metadata marks a
prerelease, so `0.2.0+build` is stable while `0.2.0-beta.13+build` is a
prerelease.

Install stable releases with `npm install @com55/node-red-ai-access` in the
Node-RED user directory. For betas, install the GitHub Release tarball instead:
Node-RED user directory. For pre-releases, pick the one you want from
[GitHub Releases](https://github.com/com55/node-red-ai-access/releases) and use
the exact install command in its release notes. The workflow appends this
command using that tag and its attached tarball. In this template, replace
`X.Y.Z` with the full version, including its pre-release suffix:

```sh
cd /data && npm i https://github.com/com55/node-red-ai-access/releases/download/v0.2.0-beta.13/com55-node-red-ai-access-0.2.0-beta.13.tgz
cd /data && npm i https://github.com/com55/node-red-ai-access/releases/download/vX.Y.Z/com55-node-red-ai-access-X.Y.Z.tgz
```

`/data` is the typical Docker userDir; use `~/.node-red` for a typical local
Expand Down
107 changes: 95 additions & 12 deletions src/plugin/editor-sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
/** Derived from package.json via build-time `__AI_ACCESS_VERSION__` (not hand-typed). */
export const EDITOR_SIDEBAR_ASSET_VERSION = packageVersion();

function editorSidebarStyles(): string {

Check warning on line 14 in src/plugin/editor-sidebar.ts

View workflow job for this annotation

GitHub Actions / Code Quality

Function 'editorSidebarStyles' has too many lines (159). Maximum allowed is 150
return `#red-ui-sidebar-ai-access {
position: relative;
height: 100%;
Expand Down Expand Up @@ -171,7 +171,7 @@
}`;
}

function editorSidebarScriptPartA(): string {

Check warning on line 174 in src/plugin/editor-sidebar.ts

View workflow job for this annotation

GitHub Actions / Code Quality

Function 'editorSidebarScriptPartA' has too many lines (301). Maximum allowed is 150
return `
/**
* Editor right-hand sidebar tab: AI Access.
Expand Down Expand Up @@ -591,7 +591,7 @@
* flow/subflow tab (RED.workspaces.show) and acks so the dashboard does not
* open another editor tab. Ids are looked up in RED.nodes; nothing else runs.
*/
export function editorRevealListenerScript(): string {

Check warning on line 594 in src/plugin/editor-sidebar.ts

View workflow job for this annotation

GitHub Actions / Code Quality

Function 'editorRevealListenerScript' has too many lines (187). Maximum allowed is 150
return `(function () {
if (typeof RED === 'undefined') return;
if (window.__com55AiAccessRevealListener) return;
Expand All @@ -600,6 +600,85 @@
var scope = window.location ? window.location.pathname.replace(/\\/$/, '') + '/' : '/';
var key = 'node-red-ai-access:reveal:' + scope;
var ch;
var tabId = Date.now() + '-' + Math.random().toString(36).slice(2);
var focusKey = key + ':focus';
var presenceKey = key + ':presence:' + tabId;
var presenceTimer;
function recordPresence() {
try { localStorage.setItem(presenceKey, String(Date.now())); } catch (e) {}
}
function startPresence() {
recordPresence();
if (!presenceTimer && typeof window.setInterval === 'function') presenceTimer = window.setInterval(recordPresence, 5000);
}
function leave() {
if (typeof window.clearInterval === 'function') window.clearInterval(presenceTimer);
presenceTimer = null;
try {
localStorage.removeItem(presenceKey);
var focus = JSON.parse(localStorage.getItem(focusKey) || 'null');
if (focus && focus.tabId === tabId) localStorage.removeItem(focusKey);
} catch (e) {}
}
startPresence();
if (typeof window.addEventListener === 'function') {
window.addEventListener('pagehide', leave);
window.addEventListener('unload', leave);
window.addEventListener('pageshow', function () {
startPresence();
try {
if (typeof document !== 'undefined' && document.visibilityState === 'visible' &&
typeof document.hasFocus === 'function' && document.hasFocus()) recordFocus();
} catch (e) {}
});
}
function recordFocus() {
recordPresence();
try { localStorage.setItem(focusKey, JSON.stringify({ tabId: tabId, at: Date.now() })); } catch (e) {}
}
if (typeof window.addEventListener === 'function') window.addEventListener('focus', recordFocus);
if (typeof document !== 'undefined') {
if (typeof document.addEventListener === 'function') document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') recordFocus();
});
try { if (typeof document.hasFocus === 'function' && document.hasFocus()) recordFocus(); } catch (e) {}
}
function claim(m, work) {
var claimKey = key + ':claim:' + m.reqId;
function elect(focused) {
try {
// Completed requests stay deduplicated after their transient claim is removed.
var ack = JSON.parse(localStorage.getItem(key + ':ack') || 'null');
if (ack && ack.reqId === m.reqId) return;
var owner = JSON.parse(localStorage.getItem(claimKey) || 'null');
if (owner && Date.now() - owner.at <= 35000) return;
localStorage.setItem(claimKey, JSON.stringify({ tabId: tabId, at: Date.now() }));
function finish() {
try {
var ack = JSON.parse(localStorage.getItem(key + ':ack') || 'null');
if (ack && ack.reqId === m.reqId) return;
var winner = JSON.parse(localStorage.getItem(claimKey) || 'null');
if (!winner || winner.tabId !== tabId) return;
try { work(); } finally { localStorage.removeItem(claimKey); }
} catch (e) {}
}
if (!focused && typeof window.setTimeout === 'function') window.setTimeout(finish, 50);
else finish();
} catch (e) {
// Without shared storage, only an editor that currently has focus may act.
try { if (typeof document !== 'undefined' && typeof document.hasFocus === 'function' && document.hasFocus()) work(); } catch (ignored) {}
}
}
try {
var focus = JSON.parse(localStorage.getItem(focusKey) || 'null');
if (!focus || typeof focus.tabId !== 'string' || typeof focus.at !== 'number' || !isFinite(focus.at)) focus = null;
// Give the last-focused editor a head start, but never let a closed or
// unresponsive owner prevent surviving editors from acknowledging.
if (focus && focus.tabId !== tabId && typeof window.setTimeout === 'function') {
window.setTimeout(function () { elect(false); }, 100);
} else elect(!!focus);
} catch (e) { elect(false); }
}
var seen = Object.create(null);
var latestTime = 0;
var latestRequest;
Expand Down Expand Up @@ -654,18 +733,22 @@
}
function receive(m, fromStartup) {
if (!valid(m) || seen[m.reqId] || m.sentAt < latestTime) return;
Object.keys(seen).forEach(function (id) { if (Date.now() - seen[id] > 30000) delete seen[id]; });
seen[m.reqId] = Date.now();
latestTime = m.sentAt;
latestRequest = m.reqId;
// Acknowledge liveness before any editor API or card work can block or fail.
var ack = { type: 'reveal-ack', reqId: m.reqId, alive: true };
if (ch) { try { ch.postMessage(ack); } catch (e) {} }
try { localStorage.setItem(key + ':ack', JSON.stringify(ack)); } catch (e) {}
// Preserve live reveal behavior. On startup Node-RED handles the URL hash itself.
if (!fromStartup) reveal(m);
showCard(m);
try { if (RED.sidebar && typeof RED.sidebar.show === 'function') RED.sidebar.show('ai-access'); } catch (e) { /* sidebar may be unavailable */ }
recordPresence();
claim(m, function () {
if (!valid(m) || seen[m.reqId] || m.sentAt < latestTime) return;
Object.keys(seen).forEach(function (id) { if (Date.now() - seen[id] > 30000) delete seen[id]; });
seen[m.reqId] = Date.now();
latestTime = m.sentAt;
latestRequest = m.reqId;
// Acknowledge liveness before any editor API or card work can block or fail.
var ack = { type: 'reveal-ack', reqId: m.reqId, alive: true };
if (ch) { try { ch.postMessage(ack); } catch (e) {} }
try { localStorage.setItem(key + ':ack', JSON.stringify(ack)); } catch (e) {}
// Preserve live reveal behavior. On startup Node-RED handles the URL hash itself.
if (!fromStartup) reveal(m);
showCard(m);
try { if (RED.sidebar && typeof RED.sidebar.show === 'function') RED.sidebar.show('ai-access'); } catch (e) { /* sidebar may be unavailable */ }
});
}
try {
if (typeof BroadcastChannel !== 'undefined') {
Expand Down
11 changes: 8 additions & 3 deletions src/plugin/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,10 +227,14 @@ export class OAuthStore implements OAuthRegisteredClientsStore {
}
}
private touchTimer: ReturnType<typeof setTimeout> | undefined;
/** Coalesces lastUsedAt-only changes into one write per five minutes; token changes still persist immediately. */
touch(): void {
/** Persists first use immediately; later lastUsedAt changes coalesce over five minutes. */
touch(immediate = false): void {
if (this.disposed) return;
this.dirty = true;
if (immediate) {
this.flush();
return;
}
if (this.touchTimer) return;
this.touchTimer = setTimeout(() => {
this.touchTimer = undefined;
Expand Down Expand Up @@ -626,8 +630,9 @@ export class LocalOAuthProvider implements OAuthServerProvider {
throw new InvalidTokenError('Invalid access token');
const client = this.clientsStore.data.clients.find(c => c.client_id === item.clientId);
if (!client) throw new InvalidTokenError('Invalid client');
const firstUse = client.lastUsedAt === undefined;
client.lastUsedAt = Date.now();
this.clientsStore.touch();
this.clientsStore.touch(firstUse);
return {
token,
clientId: item.clientId,
Expand Down
23 changes: 21 additions & 2 deletions src/plugin/page-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,8 @@ function pageClientScriptPartATabs(): string {
}
}
selectedLocationHash = String(location.hash || '');
// The deep-link focus owns scrolling until it consumes the proposal hash.
if (target === 'proposals' && readProposalHashIdFrom(location.hash)) return;
window.scrollTo(0, 0);
// Run after history traversal's scroll restoration and fragment scrolling.
if (window.requestAnimationFrame) {
Expand Down Expand Up @@ -2673,7 +2675,7 @@ function pageClientScriptPartB2CardsList(): string {
return `
function renderLandedAfterWithdraw(p) {
return p.status === 'withdrawn' && p.landedAfterWithdraw
? '<span class="status-pill withdrawn">Landed after withdraw</span> ' : '';
? '<span class="status-pill landed-after-withdraw">Landed after withdraw</span> ' : '';
}
function renderCompactProposal(p) {
var title = titleForProposalCard(p);
Expand Down Expand Up @@ -3878,7 +3880,7 @@ export function pageClientEditorJumpScript(): string {
* Jump links to the editor (#flow/<id>, #node/<id>). A click first asks an
* already-open editor tab (BroadcastChannel 'node-red-ai-access', answered by
* the editor sidebar script) to reveal the item; without an ack within 1000 ms
* it opens the editor in a named window so later clicks reuse that tab.
* it opens a named window only when no live editor presence remains.
*/
var EDITOR_WINDOW_NAME = 'node-red-editor';
var REVEAL_CHANNEL = 'node-red-ai-access';
Expand Down Expand Up @@ -3937,6 +3939,23 @@ export function pageClientEditorJumpScript(): string {
if (opened) return;
timer = setTimeout(cleanup, 30000);
opened = true;
// Heartbeats expire after crashes. Storage failures are inconclusive: avoid
// navigating a named window that could contain undeployed editor changes.
var liveEditor = false;
try {
var prefix = key + ':presence:';
for (var i = 0; i < localStorage.length; i++) {
var presenceKey = localStorage.key(i);
if (!presenceKey || presenceKey.indexOf(prefix) !== 0) continue;
var at = Number(localStorage.getItem(presenceKey));
if (isFinite(at) && at > 0 && Date.now() - at <= 120000) liveEditor = true;
else { localStorage.removeItem(presenceKey); i--; }
}
} catch (e) { liveEditor = true; }
if (liveEditor) {
if (typeof showToast === 'function') showToast('Switch to the Node-RED editor tab to open this item', true, { durationMs: 4000 });
return;
}
var hashAt = href.indexOf('#');
var base = hashAt < 0 ? href : href.slice(0, hashAt);
var hash = hashAt < 0 ? '' : href.slice(hashAt);
Expand Down
3 changes: 3 additions & 0 deletions src/plugin/page-styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ section.panel > h2 {
margin: 0 0 0.75rem;
font-size: 1.65rem;
}
section.panel > h2:focus { outline: none; }
section.panel > h2:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.warning {
background: var(--warn-bg);
border: 1px solid var(--warn-border);
Expand Down Expand Up @@ -1193,6 +1195,7 @@ dialog h3 { margin: 0 0 0.4rem; }
.status-pill.rejected { background: #f4efe6; color: #6a4a14; }
.status-pill.failed { background: #fdeceb; color: var(--danger); }
.status-pill.withdrawn { background: #eef0f3; color: #4a5564; }
.status-pill.landed-after-withdraw { background: #fff3cd; color: #7a4b00; }
.status-pill.expired { background: #f3ebe0; color: #8a5a00; }
.status-pill.superseded { background: #ebe4f5; color: #5b3d8a; }
.debug-toggle {
Expand Down
2 changes: 2 additions & 0 deletions src/plugin/proposal-present.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export interface PresentedProposalApi {
deployPending?: Proposal['deployPending'];
deploymentAlert?: string;
rejectedWhileDeployUnknown?: boolean;
landedAfterWithdraw?: boolean;
/** Soft validation notes (missing config, unwired, …) — not blocking. */
warnings?: Proposal['warnings'];
baseRevChanged?: boolean;
Expand Down Expand Up @@ -774,6 +775,7 @@ export function presentProposalForApi(
}
if (full.deploymentAlert) lean.deploymentAlert = full.deploymentAlert;
if (full.rejectedWhileDeployUnknown) lean.rejectedWhileDeployUnknown = true;
if (full.landedAfterWithdraw === true) lean.landedAfterWithdraw = true;
if (full.deployPending) lean.deployPending = full.deployPending;
if (full.aiConflictOverrides) lean.aiConflictOverrides = full.aiConflictOverrides;
if (full.conflictChoice) lean.conflictChoice = full.conflictChoice;
Expand Down
Loading
Loading