personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

settings: 3-state retention UX in storage section

also: update test_retention_config_cli default expectation
to match the new keep/null defaults landed in e49bbb22.

+85 -56
+84 -55
apps/settings/workspace.html
··· 1700 1700 color: var(--accent, #E8923A); 1701 1701 } 1702 1702 1703 + .retention-days-row.is-disabled { 1704 + opacity: 0.5; 1705 + cursor: not-allowed; 1706 + } 1707 + 1703 1708 .stream-override-row { 1704 1709 display: flex; 1705 1710 align-items: center; ··· 2693 2698 2694 2699 <!-- Storage Section --> 2695 2700 <section class="settings-section" id="section-storage" role="tabpanel" aria-labelledby="tab-storage"> 2696 - <h2>storage</h2> 2701 + <h2>your observed media (raw audio and screencast files)</h2> 2697 2702 <p class="settings-section-desc">Manage raw media retention and view storage usage.</p> 2698 2703 <div id="storageLoadState" class="settings-load-state" aria-live="polite"></div> 2699 2704 <div id="storageWarnings"></div> ··· 2720 2725 2721 2726 <div class="settings-field" id="retentionModeField"> 2722 2727 <label>retention mode</label> 2723 - <small>Controls how long raw media files (audio, video, screen diffs) are kept after processing.</small> 2724 - <div id="retentionModeSelector" style="display: flex; gap: 0.5em; margin-top: 0.5em;"> 2725 - <button type="button" class="settings-mode-btn" data-mode="keep">Keep Forever</button> 2726 - <button type="button" class="settings-mode-btn" data-mode="days">Keep N Days</button> 2727 - <button type="button" class="settings-mode-btn" data-mode="processed">Delete After Processing</button> 2728 - </div> 2729 - </div> 2730 - 2731 - <div class="settings-field" id="retentionDaysField" style="display: none;"> 2732 - <label for="retentionDaysInput">days to keep</label> 2733 - <small>Raw media older than this will be eligible for cleanup.</small> 2734 - <div style="display: flex; gap: 0.5em; align-items: center; margin-top: 0.5em;"> 2728 + <small>Controls how long raw media files (audio, video, screen diffs) are kept after processing. <a href="https://solpbc.org/privacy#recording-laws" target="_blank" rel="noopener">recording-consent laws</a> may apply in your jurisdiction.</small> 2729 + <div id="retentionDaysField" class="retention-days-row" style="margin-top: 0.5em;"> 2735 2730 <button type="button" class="settings-preset-btn" data-days="7">7</button> 2736 2731 <button type="button" class="settings-preset-btn" data-days="30">30</button> 2737 2732 <button type="button" class="settings-preset-btn" data-days="90">90</button> 2738 - <input type="number" id="retentionDaysInput" min="1" style="width: 5em; padding: 0.4em 0.6em; border: 1px solid #ccc; border-radius: 4px;" placeholder="Custom"> 2733 + <input type="number" id="retentionDaysInput" min="1" max="365" style="width: 5em; padding: 0.4em 0.6em; border: 1px solid #ccc; border-radius: 4px;" placeholder="Custom"> 2739 2734 <span style="color: #666; font-size: 0.9em;">days</span> 2740 2735 </div> 2736 + <label for="retentionDontRetain" style="display: flex; align-items: center; gap: 0.5em; margin-top: 0.75em; color: #666; font-size: 0.9em;"> 2737 + <input type="checkbox" id="retentionDontRetain"> 2738 + <span>don't retain observed media (delete as soon as it's processed)</span> 2739 + </label> 2740 + <div id="retentionStatus" style="margin-top: 0.75em; color: #666; font-size: 0.9em;">currently: always retain — no expiration</div> 2741 2741 </div> 2742 2742 2743 2743 <div class="context-overrides" id="streamOverridesContainer" style="display: none;"> ··· 4779 4779 } 4780 4780 4781 4781 function setStorageControlsDisabled(disabled) { 4782 - document.querySelectorAll('#retentionModeSelector .settings-mode-btn, #retentionDaysField .settings-preset-btn').forEach((el) => { 4782 + document.querySelectorAll('#retentionDaysField .settings-preset-btn').forEach((el) => { 4783 4783 el.disabled = disabled; 4784 4784 }); 4785 4785 const daysInput = document.getElementById('retentionDaysInput'); 4786 4786 if (daysInput) { 4787 4787 daysInput.disabled = disabled; 4788 + } 4789 + const dontRetain = document.getElementById('retentionDontRetain'); 4790 + if (dontRetain) { 4791 + dontRetain.disabled = disabled; 4788 4792 } 4789 4793 } 4790 4794 ··· 6113 6117 // ========== STORAGE ========== 6114 6118 let storageData = null; 6115 6119 6120 + function deriveRetention(daysValue, dontRetain) { 6121 + if (dontRetain) return { mode: 'processed', days: null, statusText: 'currently: delete observed media after processing' }; 6122 + const n = parseInt(daysValue, 10); 6123 + if (Number.isFinite(n) && n >= 1) return { mode: 'days', days: n, statusText: `currently: keep ${n} days` }; 6124 + return { mode: 'keep', days: null, statusText: 'currently: always retain — no expiration' }; 6125 + } 6126 + 6116 6127 async function loadStorage() { 6117 6128 try { 6118 6129 storageData = await window.apiJson('api/storage'); ··· 6153 6164 document.getElementById('storageSummaryDetail').textContent = 6154 6165 s.segments_with_raw + ' with raw media · ' + s.segments_purged + ' purged'; 6155 6166 6156 - // Set retention mode 6157 6167 const mode = data.retention.raw_media || 'keep'; 6158 - setRetentionMode(mode, false); 6159 - 6160 - // Set days 6161 6168 const days = data.retention.raw_media_days; 6162 - document.getElementById('retentionDaysInput').value = days || ''; 6163 - document.querySelectorAll('.settings-preset-btn').forEach(btn => { 6164 - btn.classList.toggle('active', !!days && parseInt(btn.dataset.days) === days); 6165 - }); 6169 + const daysInput = document.getElementById('retentionDaysInput'); 6170 + const dontRetainCheckbox = document.getElementById('retentionDontRetain'); 6171 + if (mode === 'days' && Number.isFinite(days) && days >= 1) { 6172 + daysInput.value = days; 6173 + dontRetainCheckbox.checked = false; 6174 + } else if (mode === 'processed') { 6175 + daysInput.value = ''; 6176 + dontRetainCheckbox.checked = true; 6177 + } else { 6178 + daysInput.value = ''; 6179 + dontRetainCheckbox.checked = false; 6180 + } 6181 + updateRetentionUI(); 6166 6182 6167 6183 // Populate per-stream overrides 6168 6184 populateStreamOverrides(data.streams, data.retention.per_stream || {}); 6169 6185 } 6170 6186 6171 - function setRetentionMode(mode, save = true) { 6172 - document.querySelectorAll('#retentionModeSelector .settings-mode-btn').forEach(btn => { 6173 - btn.classList.toggle('active', btn.dataset.mode === mode); 6174 - }); 6175 - 6187 + function updateRetentionUI() { 6176 6188 const daysField = document.getElementById('retentionDaysField'); 6177 - daysField.style.display = mode === 'days' ? '' : 'none'; 6189 + const daysInput = document.getElementById('retentionDaysInput'); 6190 + const dontRetainCheckbox = document.getElementById('retentionDontRetain'); 6191 + const presetButtons = document.querySelectorAll('#retentionDaysField .settings-preset-btn'); 6192 + const statusEl = document.getElementById('retentionStatus'); 6193 + const disabled = dontRetainCheckbox.checked; 6178 6194 6179 - if (save) { 6180 - const payload = { raw_media: mode }; 6181 - if (mode !== 'days') { 6182 - payload.raw_media_days = null; 6183 - } 6184 - saveRetentionConfig(payload); 6195 + if (disabled) { 6196 + daysInput.value = ''; 6185 6197 } 6198 + 6199 + const { statusText } = deriveRetention(daysInput.value, disabled); 6200 + statusEl.textContent = statusText; 6201 + daysField.classList.toggle('is-disabled', disabled); 6202 + daysInput.disabled = disabled; 6203 + daysInput.setAttribute('aria-disabled', disabled ? 'true' : 'false'); 6204 + 6205 + const activeDays = disabled ? null : parseInt(daysInput.value, 10); 6206 + presetButtons.forEach(btn => { 6207 + const isActive = Number.isFinite(activeDays) && parseInt(btn.dataset.days, 10) === activeDays; 6208 + btn.classList.toggle('active', isActive); 6209 + btn.disabled = disabled; 6210 + btn.setAttribute('aria-disabled', disabled ? 'true' : 'false'); 6211 + }); 6186 6212 } 6187 6213 6188 6214 function populateStreamOverrides(streams, perStream) { ··· 6261 6287 async function saveRetentionConfig(data) { 6262 6288 const el = document.getElementById('retentionModeField'); 6263 6289 const errorHost = prepareFieldErrorHost(el); 6290 + const daysInput = document.getElementById('retentionDaysInput'); 6291 + const dontRetainCheckbox = document.getElementById('retentionDontRetain'); 6292 + const { mode, days } = deriveRetention(daysInput.value, dontRetainCheckbox.checked); 6293 + const payload = { 6294 + raw_media: mode, 6295 + raw_media_days: days, 6296 + per_stream: data.per_stream || storageData?.retention?.per_stream || {}, 6297 + }; 6264 6298 try { 6265 6299 await window.saveControl({ 6266 6300 el, ··· 6270 6304 fetchArgs: ['api/storage', { 6271 6305 method: 'PUT', 6272 6306 headers: { 'Content-Type': 'application/json' }, 6273 - body: JSON.stringify(data), 6307 + body: JSON.stringify(payload), 6274 6308 }], 6275 6309 onSuccess: () => { 6310 + if (storageData) { 6311 + storageData.retention = payload; 6312 + } 6276 6313 if (el) showFieldStatus(el, 'saved'); 6277 6314 }, 6278 6315 }); ··· 6281 6318 } 6282 6319 6283 6320 function setupStorageListeners() { 6284 - // Mode selector 6285 - document.querySelectorAll('#retentionModeSelector .settings-mode-btn').forEach(btn => { 6286 - btn.addEventListener('click', () => setRetentionMode(btn.dataset.mode)); 6287 - }); 6288 - 6289 - // Day presets 6290 - document.querySelectorAll('.settings-preset-btn').forEach(btn => { 6321 + document.querySelectorAll('#retentionDaysField .settings-preset-btn').forEach(btn => { 6291 6322 btn.addEventListener('click', () => { 6292 - const days = parseInt(btn.dataset.days); 6293 - document.getElementById('retentionDaysInput').value = days; 6294 - document.querySelectorAll('.settings-preset-btn').forEach(b => 6295 - b.classList.toggle('active', b === btn)); 6296 - saveRetentionConfig({ raw_media_days: days }); 6323 + document.getElementById('retentionDaysInput').value = btn.dataset.days; 6324 + updateRetentionUI(); 6325 + saveRetentionConfig({}); 6297 6326 }); 6298 6327 }); 6299 6328 6300 - // Custom days input 6301 - document.getElementById('retentionDaysInput').addEventListener('change', (e) => { 6302 - const days = parseInt(e.target.value); 6303 - if (days > 0) { 6304 - document.querySelectorAll('.settings-preset-btn').forEach(b => b.classList.remove('active')); 6305 - saveRetentionConfig({ raw_media_days: days }); 6306 - } 6329 + document.getElementById('retentionDaysInput').addEventListener('input', () => { 6330 + updateRetentionUI(); 6331 + saveRetentionConfig({}); 6332 + }); 6333 + document.getElementById('retentionDontRetain').addEventListener('change', () => { 6334 + updateRetentionUI(); 6335 + saveRetentionConfig({}); 6307 6336 }); 6308 6337 6309 6338 // Stream overrides toggle
+1 -1
tests/test_retention_config_cli.py
··· 35 35 36 36 assert result.exit_code == 0 37 37 payload = json.loads(result.output) 38 - assert payload == {"default": {"mode": "days", "days": 7}, "per_stream": {}} 38 + assert payload == {"default": {"mode": "keep", "days": None}, "per_stream": {}} 39 39 40 40 41 41 def test_show_custom(journal_env):