diff --git a/lib/client.js b/lib/client.js index 6a5b970..0fd9df8 100644 --- a/lib/client.js +++ b/lib/client.js @@ -697,7 +697,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba return '' } - function JobList({ t, jobs, runs, selection, expanded, viewer, onSelectJob, onSelectRun, onNew, onRun, onToggle, onRemove, onToggleGroup }) { + function JobList({ t, jobs, runs, selection, expanded, viewer, error, onSelectJob, onSelectRun, onNew, onRun, onToggle, onRemove, onToggleGroup }) { const skin = workspaceSkin() const [query, setQuery] = useState('') const [searchOn, setSearchOn] = useState(false) @@ -897,8 +897,9 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba addProjectIcon() || '+'), ), ), + error ? h('p', { className: 'dsh-ct-error' }, error) : null, h('div', { className: skin.list }, - jobs.length === 0 ? h('p', { className: skin.empty }, t('emptyJobs')) + jobs.length === 0 ? h('p', { className: skin.empty }, error || t('emptyJobs')) : visibleJobs.length === 0 ? h('p', { className: skin.empty }, t('searchEmpty')) : (canViewAll ? renderGrouped() : visibleJobs.map((job) => renderJobGroup(job))), ), @@ -1237,16 +1238,34 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba }, []) useEffect(() => { + const readDomMode = () => { + const root = document.querySelector('[data-dsh-ct-mode]') + return root?.getAttribute('data-dsh-ct-mode') === 'on' + } + // Catch events missed before mount / after sidebar remount. + setCronMode(readDomMode()) load() const id = setInterval(load, cronMode ? 1500 : 4000) const onMode = (event) => setCronMode(!!event.detail) const onVis = () => { if (document.visibilityState === 'visible') load() } window.addEventListener('dsh-ct-mode', onMode) document.addEventListener('visibilitychange', onVis) + const modeObs = new MutationObserver(() => { + const next = readDomMode() + setCronMode((prev) => (prev === next ? prev : next)) + }) + if (document.body) { + modeObs.observe(document.body, { + subtree: true, + attributes: true, + attributeFilter: ['data-dsh-ct-mode'], + }) + } return () => { clearInterval(id) window.removeEventListener('dsh-ct-mode', onMode) document.removeEventListener('visibilitychange', onVis) + modeObs.disconnect() } }, [load, cronMode]) @@ -1421,7 +1440,7 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba return h(React.Fragment, null, h(JobList, { - t, jobs, runs, selection, viewer, expanded, + t, jobs, runs, selection, viewer, expanded, error, onSelectJob: selectJob, onSelectRun: selectRun, onNew: selectNew, @@ -1650,7 +1669,8 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba entry.addEventListener('click', (event) => { event.preventDefault() cronOn = !cronOn - setCronMode(findSidebarRoot(found), cronOn, entry, t) + const live = findNewSessionButton() || found + setCronMode(findSidebarRoot(live), cronOn, entry, t) }) const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg') icon.setAttribute('viewBox', '0 0 16 16') @@ -1701,6 +1721,12 @@ body>.dsh-ct-main{position:fixed;top:0;right:0;bottom:0;left:var(--dsh-ct-sideba hideNativeCronRows(sidebar) if (sidebar) { + // Keep DOM mode attribute in sync after remounts so React overlay can follow. + const want = cronOn ? 'on' : 'off' + if (sidebar.getAttribute('data-dsh-ct-mode') !== want) { + sidebar.setAttribute('data-dsh-ct-mode', want) + window.dispatchEvent(new CustomEvent('dsh-ct-mode', { detail: cronOn })) + } const width = sidebar.getBoundingClientRect().width const next = `${Math.round(width)}px` if (document.documentElement.style.getPropertyValue('--dsh-ct-sidebar') !== next) { diff --git a/lib/ownership.js b/lib/ownership.js index 8334d46..b4267ef 100644 --- a/lib/ownership.js +++ b/lib/ownership.js @@ -13,11 +13,24 @@ export function canViewAllJobs(identity) { return !!identity?.permissions?.canViewAllSessions } +function empNoFromUserWorkspacePath(cwd) { + const norm = String(cwd || '').replace(/\\/g, '/') + const match = norm.match(/\/user-workspaces\/([^/]+)(?:\/|$)/) + return match ? decodeURIComponent(match[1]) : null +} + export function jobVisibleToIdentity(job, identity) { if (!identity?.empNo) return false if (canViewAllJobs(identity)) return true const owner = normalizeOwnerEmpNo(job?.ownerEmpNo) - if (owner === UNASSIGNED_OWNER) return false + // Legacy / tool-created rows often lack ownerEmpNo. Keep them visible so the + // sidebar is not empty, then claimUnassignedForViewer can adopt them — unless + // the cwd clearly belongs to another user. + if (owner === UNASSIGNED_OWNER) { + const pathOwner = empNoFromUserWorkspacePath(job?.cwd) + if (pathOwner && pathOwner !== String(identity.empNo)) return false + return true + } return owner === String(identity.empNo) } @@ -127,14 +140,23 @@ export function claimUnassignedForViewer(state, identity, deps = {}) { ? job.origin.sessionId.trim() : '' let mine = false - // Prefer explicit session ownership / user-workspaces path — do not trust a loose isUserPath. + // Prefer session ownership / user-workspaces cwd. Adopt web/legacy orphans when + // no other owner is known so chat-created jobs reappear in the sidebar. if (sessionId && typeof deps.getSessionOwner === 'function') { - mine = deps.getSessionOwner(sessionId) === identity.empNo + const sessionOwner = deps.getSessionOwner(sessionId) + if (sessionOwner && sessionOwner !== identity.empNo) return job + mine = !sessionOwner || sessionOwner === identity.empNo } if (!mine && job.cwd) { const norm = String(job.cwd).replace(/\\/g, '/') const match = norm.match(/\/user-workspaces\/([^/]+)(?:\/|$)/) - mine = !!(match && decodeURIComponent(match[1]) === identity.empNo) + if (match) { + if (decodeURIComponent(match[1]) === identity.empNo) mine = true + else return job + } + } + if (!mine && job.origin?.kind !== 'im') { + mine = true } if (!mine) return job changed = true diff --git a/test/ownership.test.js b/test/ownership.test.js index 13da883..4285544 100644 --- a/test/ownership.test.js +++ b/test/ownership.test.js @@ -18,7 +18,8 @@ test('jobVisibleToIdentity respects canViewAll and owner', () => { const admin = { empNo: 'a1', permissions: { canViewAllSessions: true } } assert.equal(jobVisibleToIdentity({ ownerEmpNo: 'u1' }, user), true) assert.equal(jobVisibleToIdentity({ ownerEmpNo: 'u2' }, user), false) - assert.equal(jobVisibleToIdentity({ ownerEmpNo: UNASSIGNED_OWNER }, user), false) + assert.equal(jobVisibleToIdentity({ ownerEmpNo: UNASSIGNED_OWNER }, user), true) + assert.equal(jobVisibleToIdentity({ ownerEmpNo: UNASSIGNED_OWNER, cwd: '/data/user-workspaces/u2/x' }, user), false) assert.equal(jobVisibleToIdentity({ ownerEmpNo: UNASSIGNED_OWNER }, admin), true) assert.equal(canViewAllJobs(admin), true) }) @@ -82,14 +83,23 @@ test('claimUnassignedForViewer claims by session owner or user-workspaces cwd', { id: 'b', ownerEmpNo: UNASSIGNED_OWNER, cwd: '/data/user-workspaces/u1/proj' }, { id: 'c', ownerEmpNo: UNASSIGNED_OWNER, cwd: '/data/user-workspaces/u2/proj' }, { id: 'd', ownerEmpNo: 'u2', cwd: '/data/user-workspaces/u1/x' }, + { id: 'e', ownerEmpNo: UNASSIGNED_OWNER, origin: { kind: 'im', peer: { botId: 'b' } } }, + { id: 'f', ownerEmpNo: UNASSIGNED_OWNER, origin: { kind: 'web', sessionId: 'foreign' } }, ], } const { changed, state: next } = claimUnassignedForViewer(state, user, { - getSessionOwner: (id) => (id === 's1' ? 'u1' : null), + getSessionOwner: (id) => { + if (id === 's1') return 'u1' + if (id === 'foreign') return 'u2' + return null + }, }) assert.equal(changed, true) assert.equal(next.jobs[0].ownerEmpNo, 'u1') assert.equal(next.jobs[1].ownerEmpNo, 'u1') + // Foreign user-workspaces path must not be stolen. assert.equal(next.jobs[2].ownerEmpNo, UNASSIGNED_OWNER) assert.equal(next.jobs[3].ownerEmpNo, 'u2') + assert.equal(next.jobs[4].ownerEmpNo, UNASSIGNED_OWNER) + assert.equal(next.jobs[5].ownerEmpNo, UNASSIGNED_OWNER) })