diff --git a/__tests__/durable-write.test.mjs b/__tests__/durable-write.test.mjs index 5d32778..1cc7712 100644 --- a/__tests__/durable-write.test.mjs +++ b/__tests__/durable-write.test.mjs @@ -29,7 +29,7 @@ function resolveEsbuild() { function buildModule() { const out = mkdtempSync(join(tmpdir(), 'news-test-')) const shim = join(out, 'react-shim.js') - writeFileSync(shim, 'export const jsx=()=>null; export const jsxs=()=>null; export const Fragment=null; export default {}; export const useState=()=>[]; export const useEffect=()=>{}; export const useCallback=(f)=>f; export const useMemo=()=>undefined; export const useRef=()=>({current:null});') + writeFileSync(shim, 'export const jsx=()=>null; export const jsxs=()=>null; export const Fragment=null; export const createPortal=(node)=>node; export default {}; export const useState=()=>[]; export const useEffect=()=>{}; export const useCallback=(f)=>f; export const useId=()=>"test-id"; export const useMemo=()=>undefined; export const useRef=()=>({current:null});') const bundle = join(out, 'news.mjs') execFileSync(resolveEsbuild(), [ join(repo, 'index.jsx'), diff --git a/__tests__/news-regressions.test.mjs b/__tests__/news-regressions.test.mjs index b3eefdb..7805471 100644 --- a/__tests__/news-regressions.test.mjs +++ b/__tests__/news-regressions.test.mjs @@ -13,6 +13,7 @@ import { } from '../domain.js' import { isErrorReport } from '../report-schema.mjs' import { EFFORT_LEVELS, defaultEffort } from '../constants.js' +import { canReorderAgentSlots, reorderAgentSlots } from '../ui/backgroundAgentOrder.js' const HERE = dirname(fileURLToPath(import.meta.url)) const repo = join(HERE, '..') @@ -443,6 +444,61 @@ test('settings writes explicit Background-agent modes and preserves legacy overr assert.equal(manifest.storage_seeds['agent.json'].secondary_agent_mode, 'system') }) +test('background agent slots use the Settings-style picker with an inherited default row', () => { + const settings = readRepoFile(join('ui', 'SettingsTab.jsx')) + const picker = readRepoFile(join('ui', 'ModelPicker.jsx')) + const priorityList = readRepoFile(join('ui', 'BackgroundAgentList.jsx')) + const theme = readRepoFile('theme.js') + assert.ok(settings.includes("useSettingsDefault={primaryAgentMode === 'system'}")) + assert.ok(settings.includes("useSettingsDefault={secondaryAgentMode === 'system'}")) + assert.ok(settings.includes('onChange={saveAgent}')) + assert.ok(settings.includes("onSettingsDefault={() => savePrimaryMode('system')}")) + assert.ok(settings.includes('onChange={saveFallbackAgent}')) + assert.ok(settings.includes('onSettingsDefault={() => toggleFallback(false)}')) + assert.ok(!settings.includes('aria-label="News primary agent mode"')) + assert.ok(!settings.includes('aria-label="News secondary agent mode"')) + assert.ok(picker.includes('Default from settings')) + assert.ok(picker.includes('onSettingsDefault')) + assert.ok(picker.includes('aria-label={triggerLabel}')) + assert.ok(picker.includes('aria-pressed={useSettingsDefault}')) + assert.ok(picker.includes('aria-pressed={selected}')) + assert.ok(picker.includes('`${title}: ${modelName}${effortLabel ? `, ${effortLabel} effort`')) + assert.match(picker, /\{open && createPortal\([\s\S]*document\.body,\s*\)\}/) + assert.match(picker, /event\.target === event\.currentTarget\) closeSheet\(\)/) + assert.match(picker, /mobius-model-sheet__close" onClick=\{closeSheet\}/) + assert.match(picker, /closeRef\.current\?\.focus\?\.\(\)/) + assert.match(picker, /triggerRef\.current\?\.focus\?\.\(\)/) + assert.match(theme, /\.mobius-model-sheet__backdrop\s*\{[\s\S]*z-index:\s*1000/) + assert.ok(settings.includes(' { + const primary = { mode: 'app', provider: 'claude', model: 'claude-opus', effort: 'high' } + const fallback = { mode: 'app', provider: 'codex', model: 'gpt-codex', effort: 'medium' } + const before = [primary, fallback] + const after = reorderAgentSlots(before, 1, 0) + assert.equal(canReorderAgentSlots(before), true) + assert.deepEqual(after, [fallback, primary]) + assert.notEqual(after[0], fallback) + + const inherited = [{ mode: 'system' }, fallback] + assert.equal(canReorderAgentSlots(inherited), false) + assert.equal(reorderAgentSlots(inherited, 0, 1), inherited) +}) + test('fetch.sh resolves and retries a configured fallback agent', () => { const sh = readRepoFile('fetch.sh') assert.ok(sh.includes('from app.background_agents import resolve_background_agents')) @@ -467,6 +523,7 @@ test('mechanical manifest and token fixes stay in place', () => { assert.equal(manifest.version, pkg.version) assert.equal(manifest.embeds_agent, true) assert.ok(manifest.source_files.includes('ui/EffortStepper.jsx')) + assert.ok(manifest.source_files.includes('ui/BackgroundAgentList.jsx')) assert.deepEqual(manifest.offline, { reads: true, writes: 'queued', execution: 'none' }) assert.ok(!/color:\s*#fff/.test(theme)) assert.ok(!/color:\s*var\(--bg\)/.test(theme)) diff --git a/mobius.json b/mobius.json index 745267a..1aff43d 100644 --- a/mobius.json +++ b/mobius.json @@ -1,7 +1,7 @@ { "id": "news", "name": "News", - "version": "1.14.10", + "version": "1.14.11", "description": "A daily AI-curated news digest you can tune by topic, schedule, and editorial brief.", "offline_capable": true, "embeds_agent": true, @@ -24,6 +24,8 @@ "ui/ReportsTab.jsx", "ui/ModelPicker.jsx", "ui/EffortStepper.jsx", + "ui/BackgroundAgentList.jsx", + "ui/backgroundAgentOrder.js", "ui/SettingsTab.jsx" ], "theme_color": "#0c0f14", diff --git a/package.json b/package.json index 421a96a..247d791 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mobius-os/app-news", - "version": "1.14.10", + "version": "1.14.11", "private": true, "type": "module", "scripts": { diff --git a/theme.js b/theme.js index 4a2b9bd..93851ba 100644 --- a/theme.js +++ b/theme.js @@ -534,32 +534,46 @@ export const CSS = ` color: var(--muted); white-space: nowrap; } .nw-effort.is-disabled .nw-effort-label { opacity: 0.55; } -.nw-agent-stack { display: grid; gap: 12px; } -.nw-agent-slot { - display: grid; gap: 8px; min-width: 0; padding: 10px; - border: 1px solid var(--border); border-radius: 10px; - background: color-mix(in srgb, var(--surface) 72%, var(--bg)); -} -.nw-agent-slot-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; } -.nw-agent-slot-title { color: var(--text); font-size: 12.5px; font-weight: 700; } -.nw-agent-mode { - display: inline-flex; gap: 2px; padding: 2px; - border: 1px solid var(--border); border-radius: 8px; background: var(--bg); -} -.nw-agent-mode-btn { - min-height: 44px; padding: 0 10px; border: none; border-radius: 6px; - background: transparent; color: var(--muted); font: inherit; - font-size: 12px; font-weight: 700; cursor: pointer; - touch-action: manipulation; user-select: none; -} -.nw-agent-mode-btn.is-active { background: var(--accent-hover, var(--accent)); color: var(--accent-fg); } -.nw-agent-inherit { - min-height: 42px; display: flex; align-items: center; padding: 9px 10px; - border: 1px dashed var(--border); border-radius: 8px; - background: var(--bg); color: var(--muted); font-size: 12.5px; line-height: 1.4; -} -@media (hover: hover) { .nw-agent-mode-btn:not(.is-active):hover { color: var(--text); } } - +.mobius-agent-priority-list { display:flex; flex-direction:column; gap:6px; position:relative; } +.mobius-agent-priority-row { + position:relative; display:grid; grid-template-columns:44px minmax(0,1fr); + align-items:center; min-height:54px; padding:0; border:0; border-radius:9px; + background:transparent; user-select:none; -webkit-user-select:none; + -webkit-touch-callout:none; will-change:transform; + transition:transform .18s cubic-bezier(.22,1,.36,1), background .15s ease, + border-color .15s ease, box-shadow .15s ease, opacity .15s ease; +} +.mobius-agent-priority-list.is-committing .mobius-agent-priority-row { transition:none; } +.mobius-agent-priority-row.is-dragging { opacity:.96; } +.mobius-agent-priority-row.is-dragging .mobius-model-trigger, +.mobius-agent-priority-row.is-drop-target .mobius-model-trigger { + border-color:color-mix(in srgb,var(--accent) 62%,var(--border)); + background:color-mix(in srgb,var(--accent) 7%,var(--surface)); + box-shadow:0 4px 8px rgb(0 0 0 / 18%); +} +.mobius-agent-priority-handle { + align-self:stretch; min-width:44px; min-height:44px; display:grid; place-items:center; + border:0; border-radius:7px; padding:0; color:var(--muted); background:transparent; + font:inherit; cursor:grab; touch-action:none; -webkit-tap-highlight-color:transparent; +} +.mobius-agent-priority-handle:active, +.mobius-agent-priority-row.is-dragging .mobius-agent-priority-handle { + cursor:grabbing; color:var(--accent); + background:color-mix(in srgb,var(--accent) 10%,transparent); +} +.mobius-agent-priority-handle:focus-visible { outline:2px solid var(--accent); outline-offset:1px; } +.mobius-agent-priority-handle svg { display:block; } +.mobius-agent-priority-handle:disabled { cursor:not-allowed; opacity:.45; } +.mobius-agent-priority-body { min-width:0; } +.mobius-agent-priority-help { margin:0 0 2px; color:var(--muted); font-size:12px; line-height:1.4; } +.mobius-agent-priority-status { + position:absolute; width:1px; height:1px; padding:0; margin:-1px; + overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; +} +@media (hover:hover) and (pointer:fine) { + .mobius-agent-priority-handle:not(:disabled):hover { color:var(--text); background:var(--surface2); } +} +@media (prefers-reduced-motion:reduce) { .mobius-agent-priority-row { transition:none; } } /* Production Settings model trigger + responsive picker vocabulary. */ .mobius-model-trigger { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; @@ -586,7 +600,20 @@ export const CSS = ` background: color-mix(in srgb, var(--accent) 12%, var(--surface)); border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border)); } -.mobius-model-trigger__caret { color: var(--muted); font-size: 11px; flex-shrink: 0; } +.mobius-model-trigger__effort-visual { + position: relative; flex-shrink: 0; display: inline-flex; align-items: center; + justify-content: space-between; gap: 5px; min-width: 68px; padding: 7px 3px; +} +.mobius-model-trigger__effort-visual::before { + content: ''; position: absolute; left: 6px; right: 6px; top: 50%; height: 1px; + background: var(--border); transform: translateY(-50%); +} +.mobius-model-trigger__effort-dot { + position: relative; z-index: 1; width: 6px; height: 6px; border-radius: 50%; + border: 1px solid var(--border); background: var(--surface); +} +.mobius-model-trigger__effort-dot.is-filled { border-color: var(--accent); background: var(--accent); } +.mobius-model-trigger__effort-dot.is-active { transform: scale(1.35); box-shadow: 0 0 0 2px var(--accent-dim); } .mobius-model-sheet__backdrop { position: absolute; inset: 0; z-index: 1000; display: flex; align-items: flex-end; justify-content: center; box-sizing: border-box; diff --git a/ui/BackgroundAgentList.jsx b/ui/BackgroundAgentList.jsx new file mode 100644 index 0000000..934aae6 --- /dev/null +++ b/ui/BackgroundAgentList.jsx @@ -0,0 +1,216 @@ +import React, { useCallback, useEffect, useId, useRef, useState } from 'react' + +function GripVertical() { + return ( + + ) +} + +export function BackgroundAgentList({ + children, + onMove, + itemLabels = [], + reorderDisabled = false, + reorderDisabledReason = '', +}) { + const items = React.Children.toArray(children) + const rowRefs = useRef([]) + const dragRef = useRef(null) + const pointerYRef = useRef(0) + const commitRafRef = useRef(null) + const mountedRef = useRef(true) + const disabledReasonId = useId() + const [drag, setDrag] = useState(null) + const [committing, setCommitting] = useState(false) + const [announcement, setAnnouncement] = useState('') + + useEffect(() => { dragRef.current = drag }, [drag]) + useEffect(() => { rowRefs.current.length = items.length }, [items.length]) + + const indexFromY = useCallback((clientY, slots) => { + if (!slots.length) return 0 + for (let index = 0; index < slots.length - 1; index += 1) { + if (clientY < (slots[index].center + slots[index + 1].center) / 2) return index + } + return slots.length - 1 + }, []) + + const beginCommit = useCallback(() => { + if (commitRafRef.current) cancelAnimationFrame(commitRafRef.current) + setCommitting(true) + commitRafRef.current = requestAnimationFrame(() => { + commitRafRef.current = requestAnimationFrame(() => { + commitRafRef.current = null + setCommitting(false) + }) + }) + }, []) + + const commitMove = useCallback((fromIndex, toIndex) => { + if (reorderDisabled || fromIndex === toIndex) return + const label = itemLabels[fromIndex] || `Background agent ${fromIndex + 1}` + beginCommit() + Promise.resolve() + .then(() => onMove(fromIndex, toIndex)) + .then((applied) => { + if (mountedRef.current && applied !== false) { + setAnnouncement(`${label} moved to priority ${toIndex + 1}.`) + } + }) + .catch(() => {}) + }, [beginCommit, itemLabels, onMove, reorderDisabled]) + + const startReorder = useCallback((index, event) => { + if (reorderDisabled) return + const node = rowRefs.current[index] + if (!node) return + const rect = node.getBoundingClientRect() + const slots = rowRefs.current.map((rowNode) => { + const rowRect = rowNode.getBoundingClientRect() + return { top: rowRect.top, height: rowRect.height, center: rowRect.top + rowRect.height / 2 } + }) + try { event.currentTarget.setPointerCapture?.(event.pointerId) } catch { /* best effort */ } + pointerYRef.current = event.clientY + setDrag({ + fromIndex: index, + toIndex: index, + grabOffsetY: event.clientY - rect.top, + rowHeight: rect.height, + slots, + }) + }, [reorderDisabled]) + + const activeFromIndex = drag?.fromIndex ?? null + useEffect(() => { + if (activeFromIndex === null) return undefined + const start = dragRef.current + if (!start) return undefined + const { fromIndex, grabOffsetY, rowHeight, slots } = start + const originTop = slots[fromIndex]?.top ?? 0 + const minOffset = slots[0]?.top - originTop || 0 + const maxOffset = slots[slots.length - 1]?.top - originTop || 0 + const followOffset = (clientY) => Math.max( + minOffset, + Math.min(maxOffset, clientY - grabOffsetY - originTop), + ) + + const onPointerMove = (event) => { + event.preventDefault() + if (!dragRef.current) return + pointerYRef.current = event.clientY + const node = rowRefs.current[fromIndex] + if (node) node.style.transform = `translateY(${followOffset(event.clientY)}px) scale(1.02)` + const center = event.clientY - grabOffsetY + rowHeight / 2 + const toIndex = indexFromY(center, slots) + setDrag((current) => current && current.toIndex !== toIndex ? { ...current, toIndex } : current) + } + + const finish = (event) => { + event.preventDefault() + const current = dragRef.current + if (!current) return + const center = event.clientY - grabOffsetY + rowHeight / 2 + const toIndex = indexFromY(center, slots) + setDrag(null) + if (toIndex !== fromIndex) { + commitMove(fromIndex, toIndex) + } + } + + const cancel = () => setDrag(null) + window.addEventListener('pointermove', onPointerMove, { passive: false }) + window.addEventListener('pointerup', finish, { passive: false }) + window.addEventListener('pointercancel', cancel) + return () => { + window.removeEventListener('pointermove', onPointerMove) + window.removeEventListener('pointerup', finish) + window.removeEventListener('pointercancel', cancel) + } + }, [activeFromIndex, commitMove, indexFromY]) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + if (commitRafRef.current) cancelAnimationFrame(commitRafRef.current) + } + }, []) + + const styleForIndex = (index) => { + if (!drag) return undefined + const { fromIndex, toIndex, grabOffsetY, slots } = drag + if (index === fromIndex) { + const originTop = slots[fromIndex]?.top ?? 0 + const minOffset = slots[0]?.top - originTop || 0 + const maxOffset = slots[slots.length - 1]?.top - originTop || 0 + const offset = Math.max(minOffset, Math.min(maxOffset, pointerYRef.current - grabOffsetY - originTop)) + return { transform: `translateY(${offset}px) scale(1.02)`, zIndex: 3, transition: 'none' } + } + if (toIndex > fromIndex && index > fromIndex && index <= toIndex) { + return { transform: `translateY(${(slots[index - 1]?.top || 0) - (slots[index]?.top || 0)}px)` } + } + if (toIndex < fromIndex && index >= toIndex && index < fromIndex) { + return { transform: `translateY(${(slots[index + 1]?.top || 0) - (slots[index]?.top || 0)}px)` } + } + return undefined + } + + const moveBy = (index, delta) => { + if (reorderDisabled || dragRef.current) return + const toIndex = index + delta + if (toIndex < 0 || toIndex >= items.length) return + commitMove(index, toIndex) + } + + return ( +
+ {reorderDisabled && reorderDisabledReason && ( +

{reorderDisabledReason}

+ )} + {items.map((child, index) => { + const label = itemLabels[index] || `Background agent ${index + 1}` + return ( +
{ rowRefs.current[index] = node }} + className={`mobius-agent-priority-row${drag?.fromIndex === index ? ' is-dragging' : ''}${drag?.toIndex === index && drag?.fromIndex !== index ? ' is-drop-target' : ''}`} + style={styleForIndex(index)} + aria-label={`${label}, background agent priority ${index + 1}`} + > + +
{child}
+
+ ) + })} + {announcement} +
+ ) +} diff --git a/ui/ModelPicker.jsx b/ui/ModelPicker.jsx index e063605..5a17bf8 100644 --- a/ui/ModelPicker.jsx +++ b/ui/ModelPicker.jsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' function ClaudeLogo() { return ( @@ -29,8 +30,12 @@ export function ModelPicker({ title = 'Model', navKey = 'model-picker', allowProviderDefault = false, + useSettingsDefault = false, + onSettingsDefault, effortControl = null, effortLabel = '', + efforts = [], + effort = '', }) { const [open, setOpen] = useState(false) const sheetRef = useRef(null) @@ -39,7 +44,13 @@ export function ModelPicker({ const navRef = useRef(null) const activeGroup = groups?.find((group) => group.key === provider) const activeModel = activeGroup?.models?.find((item) => item.id === model) - const modelName = activeModel?.name || (model ? model : activeGroup ? `${activeGroup.label} default` : 'Choose model') + const modelName = useSettingsDefault + ? 'Default from settings' + : (activeModel?.name || (model ? model : activeGroup ? `${activeGroup.label} default` : 'Choose model')) + const effortIndex = Math.max(0, efforts.findIndex((item) => item.value === effort)) + const triggerLabel = useSettingsDefault + ? `${title}: Default from settings` + : `${title}: ${modelName}${effortLabel ? `, ${effortLabel} effort` : ''}` const closeSheet = useCallback(() => { const handle = navRef.current @@ -113,18 +124,33 @@ export function ModelPicker({ className="mobius-model-trigger" onClick={openSheet} aria-haspopup="dialog" + aria-label={triggerLabel} > {modelName} - {model || 'Provider default'} + {!useSettingsDefault && ( + {model || 'Provider default'} + )} - {effortLabel && {effortLabel}} - + {!useSettingsDefault && effortLabel && efforts.length > 0 && ( +