From ff36b950141758740e4da0745a6a591e83fe679a Mon Sep 17 00:00:00 2001 From: Daniel Diaz <39510674+IslandRhythms@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:28:17 -0400 Subject: [PATCH 1/2] local history --- frontend/src/index.js | 194 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 192 insertions(+), 2 deletions(-) diff --git a/frontend/src/index.js b/frontend/src/index.js index 7ce28ea7..b82ff118 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -17,6 +17,104 @@ const { useToast } = require('vue-toastification'); const appendCSS = require('./appendCSS'); appendCSS(require('vue-toastification/dist/index.css')); +const RECENT_PAGES_STORAGE_KEY = 'studio:recent-pages-history'; +const MAX_RECENT_PAGES = 10; + +function formatHistoryLabel(route) { + if (!route || typeof route.path !== 'string') { + return 'Unknown page'; + } + if (route.name === 'root') { + return 'Home'; + } + if (route.name === 'model') { + return route.params?.model ? `Model: ${route.params.model}` : 'Model'; + } + if (route.name === 'document') { + const model = route.params?.model ? `${route.params.model} ` : ''; + const documentId = route.params?.documentId ? String(route.params.documentId) : ''; + const shortDocumentId = documentId ? documentId.slice(0, 8) : ''; + return `Document: ${model}${shortDocumentId}`.trim(); + } + if (route.name === 'dashboard') { + return route.params?.dashboardId ? `Dashboard: ${route.params.dashboardId}` : 'Dashboard'; + } + if (route.name === 'dashboards') { + return 'Dashboards'; + } + if (route.name === 'tasks') { + return 'Tasks'; + } + if (route.name === 'taskByName') { + return route.params?.name ? `Task: ${route.params.name}` : 'Task'; + } + if (route.name === 'taskSingle') { + const taskName = route.params?.name ? `${route.params.name} ` : ''; + const taskId = route.params?.id ? String(route.params.id) : ''; + return `Task: ${taskName}${taskId}`.trim(); + } + if (route.name === 'team') { + return 'Team'; + } + if (route.name === 'chat' || route.name === 'chat index') { + return route.params?.threadId ? `Chat: ${route.params.threadId}` : 'Chat'; + } + const normalizedPath = route.path.replace(/^\//, ''); + return normalizedPath || 'Home'; +} + +function safeReadRecentPages() { + if (typeof window === 'undefined' || !window.localStorage) { + return []; + } + try { + const raw = window.localStorage.getItem(RECENT_PAGES_STORAGE_KEY); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + return parsed.filter(entry => + entry && + typeof entry.path === 'string' && + typeof entry.label === 'string' && + typeof entry.visitedAt === 'number' + ).slice(0, MAX_RECENT_PAGES); + } catch (err) { + return []; + } +} + +function saveRecentPages(entries) { + if (typeof window === 'undefined' || !window.localStorage) { + return; + } + window.localStorage.setItem( + RECENT_PAGES_STORAGE_KEY, + JSON.stringify(entries.slice(0, MAX_RECENT_PAGES)) + ); +} + +function trackRecentPage(route) { + if (!route || typeof route.path !== 'string') { + return; + } + // Ignore auth callback state because it is transient and not useful history. + if (typeof route.path === 'string' && route.path.includes('code=')) { + return; + } + const path = route.fullPath || route.path; + const label = formatHistoryLabel(route); + const visitedAt = Date.now(); + + const existing = safeReadRecentPages(); + const deduped = existing.filter(entry => entry.path !== path); + const next = [{ path, label, visitedAt }, ...deduped].slice(0, MAX_RECENT_PAGES); + saveRecentPages(next); +} + const app = Vue.createApp({ template: '' }); @@ -65,6 +163,56 @@ app.component('app-component', {
+
+ + +
`, @@ -161,6 +309,33 @@ app.component('app-component', { this.status = 'loaded'; }, + watch: { + '$route.fullPath': function() { + this.recentPages = safeReadRecentPages(); + } + }, + methods: { + toggleHistoryDrawer() { + this.showRecentPagesDrawer = !this.showRecentPagesDrawer; + if (this.showRecentPagesDrawer) { + this.recentPages = safeReadRecentPages(); + } + }, + clearRecentPages() { + this.recentPages = []; + saveRecentPages([]); + }, + goToRecentPage(entry) { + if (!entry || !entry.path) { + return; + } + this.showRecentPagesDrawer = false; + if (entry.path === this.$route.fullPath) { + return; + } + this.$router.push(entry.path); + } + }, setup() { const user = Vue.ref(null); const roles = Vue.ref(null); @@ -168,8 +343,19 @@ app.component('app-component', { const nodeEnv = Vue.ref(null); const authError = Vue.ref(null); const modelSchemaPaths = Vue.ref(null); - - const state = Vue.reactive({ user, roles, status, nodeEnv, authError, modelSchemaPaths }); + const recentPages = Vue.ref(safeReadRecentPages()); + const showRecentPagesDrawer = Vue.ref(false); + + const state = Vue.reactive({ + user, + roles, + status, + nodeEnv, + authError, + modelSchemaPaths, + recentPages, + showRecentPagesDrawer + }); Vue.provide('state', state); return state; @@ -228,6 +414,10 @@ router.beforeEach((to, from, next) => { } }); +router.afterEach((to) => { + trackRecentPage(to); +}); + app.config.globalProperties = { format, arrayUtils, $toast: toast }; app.use(router); From 23b43d38b61a8e6a1f2304112d64dda28bd185ef Mon Sep 17 00:00:00 2001 From: Daniel Diaz <39510674+IslandRhythms@users.noreply.github.com> Date: Fri, 8 May 2026 17:43:33 -0400 Subject: [PATCH 2/2] finishing touches --- frontend/src/_util/recent-pages-history.js | 116 ++++++++++++++ frontend/src/index.js | 167 +++------------------ frontend/src/modal/modal.css | 22 ++- frontend/src/modal/modal.html | 2 +- frontend/src/modal/modal.js | 2 +- frontend/src/navbar/navbar.html | 74 ++++++++- frontend/src/navbar/navbar.js | 64 +++++++- 7 files changed, 296 insertions(+), 151 deletions(-) create mode 100644 frontend/src/_util/recent-pages-history.js diff --git a/frontend/src/_util/recent-pages-history.js b/frontend/src/_util/recent-pages-history.js new file mode 100644 index 00000000..2aba7887 --- /dev/null +++ b/frontend/src/_util/recent-pages-history.js @@ -0,0 +1,116 @@ +'use strict'; + +const RECENT_PAGES_STORAGE_KEY = 'studio:recent-pages-history'; +const RECENT_PAGES_CHANGED_EVENT = 'studio-recent-pages-changed'; +const RECENT_PAGES_LOCK_NAME = `studio-lock:${RECENT_PAGES_STORAGE_KEY}`; +const MAX_RECENT_PAGES = 10; + +/** Collapses hash-style prefixes (e.g. `/#/`) to a normal path; keeps query string. */ +function normalizeTrackedPath(fullPath) { + if (!fullPath || typeof fullPath !== 'string') { + return '/'; + } + const qIndex = fullPath.indexOf('?'); + const rawMain = qIndex === -1 ? fullPath : fullPath.slice(0, qIndex); + const qs = qIndex === -1 ? '' : fullPath.slice(qIndex); + + let pathOnly = rawMain.trim().replace(/#/g, ''); + pathOnly = pathOnly.replace(/^\/+/u, '/'); + if (pathOnly !== '/' && /\/$/u.test(pathOnly)) { + pathOnly = pathOnly.replace(/\/+$/u, ''); + } + if (pathOnly === '' || pathOnly === '/') { + pathOnly = '/'; + } else if (!pathOnly.startsWith('/')) { + pathOnly = `/${pathOnly}`; + } + + const out = qs ? pathOnly + qs : pathOnly; + return out; +} + +function safeReadRecentPages() { + if (typeof window === 'undefined' || !window.localStorage) { + return []; + } + try { + const raw = window.localStorage.getItem(RECENT_PAGES_STORAGE_KEY); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + return parsed + .map(entry => { + if (!entry || typeof entry.path !== 'string') { + return null; + } + return { ...entry, path: normalizeTrackedPath(entry.path) }; + }) + .filter( + entry => + entry && + typeof entry.label === 'string' && + typeof entry.visitedAt === 'number' && + entry.path !== '' + ) + .slice(0, MAX_RECENT_PAGES); + } catch { + return []; + } +} + +function notifyRecentPagesChangedFromThisTab() { + if (typeof window === 'undefined') { + return; + } + try { + window.dispatchEvent(new CustomEvent(RECENT_PAGES_CHANGED_EVENT)); + } catch { + /* ignore */ + } +} + +function saveRecentPages(entries) { + if (typeof window === 'undefined' || !window.localStorage) { + return; + } + window.localStorage.setItem( + RECENT_PAGES_STORAGE_KEY, + JSON.stringify(entries.slice(0, MAX_RECENT_PAGES)) + ); + notifyRecentPagesChangedFromThisTab(); +} + +function withRecentPagesStorageLock(syncFn) { + if (typeof window === 'undefined' || !window.localStorage) { + syncFn(); + return Promise.resolve(); + } + + try { + const locksApi = + typeof navigator !== 'undefined' && navigator.locks !== undefined ? navigator.locks : null; + + if (locksApi !== null && typeof locksApi.request === 'function') { + return locksApi.request(RECENT_PAGES_LOCK_NAME, syncFn); + } + } catch { + /* fall through */ + } + + syncFn(); + return Promise.resolve(); +} + +module.exports = { + RECENT_PAGES_STORAGE_KEY, + RECENT_PAGES_CHANGED_EVENT, + MAX_RECENT_PAGES, + normalizeTrackedPath, + safeReadRecentPages, + saveRecentPages, + withRecentPagesStorageLock +}; diff --git a/frontend/src/index.js b/frontend/src/index.js index b82ff118..ae2fb34c 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -15,18 +15,21 @@ const { routes, hasAccess } = require('./routes'); const Toast = require('vue-toastification').default; const { useToast } = require('vue-toastification'); const appendCSS = require('./appendCSS'); +const { + safeReadRecentPages, + saveRecentPages, + MAX_RECENT_PAGES, + normalizeTrackedPath, + withRecentPagesStorageLock +} = require('./_util/recent-pages-history'); appendCSS(require('vue-toastification/dist/index.css')); -const RECENT_PAGES_STORAGE_KEY = 'studio:recent-pages-history'; -const MAX_RECENT_PAGES = 10; +const TRACKED_RECENT_PAGE_ROUTE_NAMES = new Set(['model', 'document', 'dashboard', 'chat']); function formatHistoryLabel(route) { if (!route || typeof route.path !== 'string') { return 'Unknown page'; } - if (route.name === 'root') { - return 'Home'; - } if (route.name === 'model') { return route.params?.model ? `Model: ${route.params.model}` : 'Model'; } @@ -39,80 +42,35 @@ function formatHistoryLabel(route) { if (route.name === 'dashboard') { return route.params?.dashboardId ? `Dashboard: ${route.params.dashboardId}` : 'Dashboard'; } - if (route.name === 'dashboards') { - return 'Dashboards'; - } - if (route.name === 'tasks') { - return 'Tasks'; - } - if (route.name === 'taskByName') { - return route.params?.name ? `Task: ${route.params.name}` : 'Task'; - } - if (route.name === 'taskSingle') { - const taskName = route.params?.name ? `${route.params.name} ` : ''; - const taskId = route.params?.id ? String(route.params.id) : ''; - return `Task: ${taskName}${taskId}`.trim(); - } - if (route.name === 'team') { - return 'Team'; - } - if (route.name === 'chat' || route.name === 'chat index') { + if (route.name === 'chat') { return route.params?.threadId ? `Chat: ${route.params.threadId}` : 'Chat'; } const normalizedPath = route.path.replace(/^\//, ''); return normalizedPath || 'Home'; } -function safeReadRecentPages() { - if (typeof window === 'undefined' || !window.localStorage) { - return []; - } - try { - const raw = window.localStorage.getItem(RECENT_PAGES_STORAGE_KEY); - if (!raw) { - return []; - } - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) { - return []; - } - return parsed.filter(entry => - entry && - typeof entry.path === 'string' && - typeof entry.label === 'string' && - typeof entry.visitedAt === 'number' - ).slice(0, MAX_RECENT_PAGES); - } catch (err) { - return []; - } -} - -function saveRecentPages(entries) { - if (typeof window === 'undefined' || !window.localStorage) { - return; - } - window.localStorage.setItem( - RECENT_PAGES_STORAGE_KEY, - JSON.stringify(entries.slice(0, MAX_RECENT_PAGES)) - ); -} - function trackRecentPage(route) { if (!route || typeof route.path !== 'string') { return; } - // Ignore auth callback state because it is transient and not useful history. + if (!TRACKED_RECENT_PAGE_ROUTE_NAMES.has(route.name)) { + return; + } if (typeof route.path === 'string' && route.path.includes('code=')) { return; } - const path = route.fullPath || route.path; + const path = normalizeTrackedPath(route.fullPath || route.path || '/'); const label = formatHistoryLabel(route); const visitedAt = Date.now(); - const existing = safeReadRecentPages(); - const deduped = existing.filter(entry => entry.path !== path); - const next = [{ path, label, visitedAt }, ...deduped].slice(0, MAX_RECENT_PAGES); - saveRecentPages(next); + void withRecentPagesStorageLock(() => { + const existing = safeReadRecentPages(); + const deduped = existing.filter(entry => normalizeTrackedPath(entry.path) !== path); + const next = [...deduped, { path, label, visitedAt }] + .sort((a, b) => b.visitedAt - a.visitedAt) + .slice(0, MAX_RECENT_PAGES); + saveRecentPages(next); + }); } const app = Vue.createApp({ @@ -163,56 +121,6 @@ app.component('app-component', {
-
- - -
`, @@ -309,33 +217,6 @@ app.component('app-component', { this.status = 'loaded'; }, - watch: { - '$route.fullPath': function() { - this.recentPages = safeReadRecentPages(); - } - }, - methods: { - toggleHistoryDrawer() { - this.showRecentPagesDrawer = !this.showRecentPagesDrawer; - if (this.showRecentPagesDrawer) { - this.recentPages = safeReadRecentPages(); - } - }, - clearRecentPages() { - this.recentPages = []; - saveRecentPages([]); - }, - goToRecentPage(entry) { - if (!entry || !entry.path) { - return; - } - this.showRecentPagesDrawer = false; - if (entry.path === this.$route.fullPath) { - return; - } - this.$router.push(entry.path); - } - }, setup() { const user = Vue.ref(null); const roles = Vue.ref(null); @@ -343,8 +224,6 @@ app.component('app-component', { const nodeEnv = Vue.ref(null); const authError = Vue.ref(null); const modelSchemaPaths = Vue.ref(null); - const recentPages = Vue.ref(safeReadRecentPages()); - const showRecentPagesDrawer = Vue.ref(false); const state = Vue.reactive({ user, @@ -352,9 +231,7 @@ app.component('app-component', { status, nodeEnv, authError, - modelSchemaPaths, - recentPages, - showRecentPagesDrawer + modelSchemaPaths }); Vue.provide('state', state); diff --git a/frontend/src/modal/modal.css b/frontend/src/modal/modal.css index 6f6d7897..9aa042c4 100644 --- a/frontend/src/modal/modal.css +++ b/frontend/src/modal/modal.css @@ -2,7 +2,7 @@ .modal-mask { position: fixed; - z-index: 9998; + z-index: 10050; top: 0; left: 0; width: 100%; @@ -50,6 +50,25 @@ overflow: auto; } +/* Recent pages: one scroll region — inner .modal-recent-pages-scroll only (avoids double scrollbars with .modal-body overflow). */ +.modal-body.modal-recent-pages-body { + box-sizing: border-box; + display: flex; + flex-direction: column; + margin: 0; + height: min(48vh, 18rem); + max-height: min(48vh, 18rem); + overflow: hidden; +} + +.modal-body.modal-recent-pages-body .modal-recent-pages-scroll { + flex: 1 1 0; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + .modal__button--default { float: right; } @@ -79,6 +98,7 @@ .modal-container .modal-exit { position: absolute; + z-index: 2; right: 0.25em; top: 0.25em; cursor: pointer; diff --git a/frontend/src/modal/modal.html b/frontend/src/modal/modal.html index bfb1c34d..95e0adcb 100644 --- a/frontend/src/modal/modal.html +++ b/frontend/src/modal/modal.html @@ -2,7 +2,7 @@