momolulu 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -42,7 +42,7 @@ Preferences and timer state stay in browser `localStorage` and are never uploade
42
42
 
43
43
  ## Known limitations
44
44
 
45
- Ordinary sessions only (no child agents); no cross-window timer sync; timers are not OS alarms and stop when DSH is closed; no built-in updater.
45
+ Sub-agent conversations are listed and tagged; no cross-window timer sync; timers are not OS alarms and stop when DSH is closed; no built-in updater.
46
46
 
47
47
  ## Development
48
48
 
package/README.md CHANGED
@@ -42,7 +42,7 @@ dsh plugin --profile web add momolulu
42
42
 
43
43
  ## 已知限制
44
44
 
45
- 只支持普通会话(不含子代理);多窗口不同步计时;计时不是系统闹钟,关掉 DSH 就不会准时触发;未内置自动更新。
45
+ 子代理会话以「子代理」标注列出;多窗口不同步计时;计时不是系统闹钟,关掉 DSH 就不会准时触发;未内置自动更新。
46
46
 
47
47
  ## 开发
48
48
 
package/client.js CHANGED
@@ -12,8 +12,8 @@ window.__ModuleLoader__.load({
12
12
  ];
13
13
  const zh = {
14
14
  title: 'momolulu', subtitle: '慢慢来,我陪你一起。', meadow: '草甸', hike: '徒步', lake: '湖畔',
15
- radarWaiting: '等待处理', radarApproval: '请求权限', radarQuestion: '等待输入', radarSoftHint: '有其他会话可查看,无需确认或立即处理。', radar: '会话雷达', radarRunning: '运行中', radarUnread: '待查看', radarQuiet: '暂无变化', radarEmpty: '暂无会话变化', radarHint: '结束不代表成功;待查看状态仅在当前页面保留。', openFailed: '无法打开此会话,请从侧栏重试。', showAll: '展开全部', showLess: '收起列表', reminderPaused: '提醒随专注暂停',
16
- testReminder: '测试提醒(8 秒)', sound: '提示音', soundOn: '提示音 · 开', soundOff: '提示音 · 关', focusCommand: '专注', focusCommandHint: '开始或暂停 momolulu 的专注计时', minimize: '缩成小头像', restore: '恢复照片', details: '详情',
15
+ radarWaiting: '等待处理', radarApproval: '请求权限', radarQuestion: '等待输入', radarSoftHint: '有其他会话可查看,无需确认或立即处理。', radar: '会话雷达', radarRunning: '运行中', radarUnread: '待查看', radarQuiet: '暂无变化', radarEmpty: '暂无会话变化', radarChild: '子代理', openFailed: '无法打开此会话,请从侧栏重试。', showAll: '展开全部', showLess: '收起列表', reminderPaused: '提醒随专注暂停',
16
+ testReminder: '测试提醒(8 秒)', sound: '提示音', soundOn: '提示音 · 开', testBackground: '10 秒后响(可切屏)', soundOff: '提示音 · 关', focusCommand: '专注', focusCommandHint: '开始或暂停 momolulu 的专注计时', minimize: '缩成小头像', restore: '恢复照片', details: '详情',
17
17
  close: '收起', focus: '专注倒计时', start: '开始专注', pause: '暂停', resume: '继续', reset: '重置',
18
18
  ready: '准备好,开始一小段专注', running: '专注中', paused: '已暂停', done: '这一轮完成啦',
19
19
  minutes: '分钟', elapsed: '已专注', reminders: '休息提醒', on: '开启', off: '关闭', next: '距离下次休息',
@@ -28,8 +28,8 @@ window.__ModuleLoader__.load({
28
28
  };
29
29
  const en = {
30
30
  title: 'momolulu', subtitle: 'One step at a time, together.', meadow: 'Meadow', hike: 'Hike', lake: 'Lake',
31
- radarWaiting: 'Needs input', radarApproval: 'Permission requested', radarQuestion: 'Waiting for input', radarSoftHint: 'Other sessions have updates. No acknowledgement or immediate action required.', radar: 'Session radar', radarRunning: 'Running', radarUnread: 'Unread', radarQuiet: 'No updates', radarEmpty: 'No session changes', radarHint: 'Ended does not mean successful. Unread status is local to this page.', openFailed: 'Unable to open this session. Try the sidebar.', showAll: 'Show all', showLess: 'Show less', reminderPaused: 'Reminders paused with focus',
32
- testReminder: 'Test reminder (8 sec)', sound: 'Chime', soundOn: 'Chime · On', soundOff: 'Chime · Off', focusCommand: 'Focus', focusCommandHint: 'Start or pause the momolulu focus timer', minimize: 'Minimize to avatar', restore: 'Restore photo', details: 'Details',
31
+ radarWaiting: 'Needs input', radarApproval: 'Permission requested', radarQuestion: 'Waiting for input', radarSoftHint: 'Other sessions have updates. No acknowledgement or immediate action required.', radar: 'Session radar', radarRunning: 'Running', radarUnread: 'Unread', radarQuiet: 'No updates', radarEmpty: 'No session changes', radarChild: 'Subagent', openFailed: 'Unable to open this session. Try the sidebar.', showAll: 'Show all', showLess: 'Show less', reminderPaused: 'Reminders paused with focus',
32
+ testReminder: 'Test reminder (8 sec)', sound: 'Chime', soundOn: 'Chime · On', testBackground: 'Ring in 10s (switch screen)', soundOff: 'Chime · Off', focusCommand: 'Focus', focusCommandHint: 'Start or pause the momolulu focus timer', minimize: 'Minimize to avatar', restore: 'Restore photo', details: 'Details',
33
33
  close: 'Close', focus: 'Focus countdown', start: 'Start focus', pause: 'Pause', resume: 'Resume', reset: 'Reset',
34
34
  ready: 'Ready for a little focus time', running: 'Focusing', paused: 'Paused', done: 'Session complete',
35
35
  minutes: 'min', elapsed: 'Focused', reminders: 'Break reminders', on: 'On', off: 'Off', next: 'Next break in',
@@ -45,34 +45,79 @@ window.__ModuleLoader__.load({
45
45
  let layout, translate, workspace;
46
46
  const selectStatus = (snapshot) => snapshot;
47
47
  function radarGroups(catalog, statuses) {
48
+ const rows = Object.values(catalog.byId);
49
+ const childrenByParent = new Map();
50
+ for (const row of rows) {
51
+ if (row.origin !== 'subagent' || !row.parentId) continue;
52
+ const list = childrenByParent.get(row.parentId) ?? [];
53
+ list.push(row);
54
+ childrenByParent.set(row.parentId, list);
55
+ }
56
+ const describe = (row) => {
57
+ const status = statuses.get(row.id);
58
+ const runningNow = status?.running ?? row.running ?? false;
59
+ const pending = status?.pendingInteraction;
60
+ return {
61
+ ...row,
62
+ runningNow,
63
+ unreadNow: status?.completionUnread === true && !runningNow,
64
+ attentionKind: pending ? (pending.kind === 'approval' ? 'radarApproval' : 'radarQuestion') : undefined,
65
+ requestKey: pending?.key,
66
+ };
67
+ };
68
+ const parentRow = (row) => {
69
+ const self = describe(row);
70
+ const children = (childrenByParent.get(row.id) ?? []).map((child) => ({ ...describe(child), child: true }));
71
+ return { ...self, children };
72
+ };
48
73
  const running = [], unread = [], waiting = [];
49
- for (const row of Object.values(catalog.byId)) {
50
- // Child-agent addressing is separate; first version lists ordinary conversations only.
74
+ for (const row of rows) {
51
75
  if (row.origin === 'subagent') continue;
52
- const status = statuses.get(row.id);
53
- const isRunning = status?.running ?? row.running;
54
- if (status?.pendingInteraction) waiting.push({ ...row, attentionKind: status.pendingInteraction.kind === 'approval' ? 'radarApproval' : 'radarQuestion', requestKey: status.pendingInteraction.key });
55
- else if (isRunning) running.push(row);
56
- else if (status?.completionUnread) unread.push(row);
76
+ const entry = parentRow(row);
77
+ const childWaiting = entry.children.find((child) => child.attentionKind);
78
+ const childBusy = entry.children.some((child) => child.runningNow);
79
+ const childUnread = entry.children.some((child) => child.unreadNow);
80
+ if (entry.attentionKind) waiting.push(entry);
81
+ else if (childWaiting) waiting.push({ ...entry, attentionKind: childWaiting.attentionKind, requestKey: childWaiting.requestKey, childAttention: true });
82
+ else if (entry.runningNow || childBusy) running.push(entry);
83
+ else if (entry.unreadNow || childUnread) unread.push(entry);
57
84
  }
58
85
  const recent = (a, b) => b.updatedAt - a.updatedAt;
59
86
  return { running: running.sort(recent), unread: unread.sort(recent), waiting: waiting.sort(recent) };
60
87
  }
61
- function attentionSnapshot(catalog, statuses) {
62
- const result = new Map();
63
- for (const row of Object.values(catalog.byId)) {
64
- if (row.origin === 'subagent' || (row.retainedBy?.mainView ?? 0) > 0) continue;
65
- const status = statuses.get(row.id);
66
- if (status?.pendingInteraction) result.set(row.id, `pending:${status.pendingInteraction.key}:${status.pendingInteraction.kind}`);
67
- else if (status?.completionUnread && !(status.running ?? row.running)) result.set(row.id, 'ended');
88
+ function attentionTokens(groups) {
89
+ const tokens = new Map();
90
+ for (const rows of Object.values(groups)) {
91
+ for (const row of rows) {
92
+ const childPending = (row.children ?? []).find((child) => child.attentionKind);
93
+ const pending = row.attentionKind ?? childPending?.attentionKind;
94
+ const requestKey = row.requestKey ?? childPending?.requestKey ?? '';
95
+ const unread = row.unreadNow || (row.children ?? []).some((child) => child.unreadNow);
96
+ // Merely starting to run is not an attention event.
97
+ if (pending === undefined && !unread) continue;
98
+ tokens.set(row.id, `${pending ?? ''}:${requestKey}|${unread ? 'u' : ''}`);
99
+ }
68
100
  }
69
- return result;
101
+ return tokens;
70
102
  }
71
103
  function newAttention(previous, current) {
72
104
  if (!previous) return false; // Establish baseline quietly after page load.
73
105
  return [...current].some(([id, token]) => previous.get(id) !== token);
74
106
  }
75
107
  const t = (key) => translate(key);
108
+ const relativeFormat = typeof Intl !== 'undefined' && Intl.RelativeTimeFormat
109
+ ? new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
110
+ : null;
111
+ function relativeTime(timestamp, now) {
112
+ if (!relativeFormat || !Number.isFinite(timestamp) || timestamp <= 0) return '';
113
+ const seconds = Math.round((timestamp - now) / 1000);
114
+ if (Math.abs(seconds) < 60) return relativeFormat.format(seconds, 'second');
115
+ const minutes = Math.round(seconds / 60);
116
+ if (Math.abs(minutes) < 60) return relativeFormat.format(minutes, 'minute');
117
+ const hours = Math.round(minutes / 60);
118
+ if (Math.abs(hours) < 24) return relativeFormat.format(hours, 'hour');
119
+ return relativeFormat.format(Math.round(hours / 24), 'day');
120
+ }
76
121
  const storage = {
77
122
  read() { try { return JSON.parse(localStorage.getItem('llm.companion.v2') || 'null'); } catch { return null; } },
78
123
  save(value) { try { localStorage.setItem('llm.companion.v2', JSON.stringify(value)); } catch { /* In-memory mode is still usable. */ } },
@@ -108,9 +153,11 @@ window.__ModuleLoader__.load({
108
153
  const listeners = new Set();
109
154
  const getSnapshot = () => state;
110
155
  const subscribe = (fn) => { listeners.add(fn); return () => listeners.delete(fn); };
156
+ const CHIME_KEYS = ['status', 'sound', 'reminders', 'nextReminder', 'deadline', 'pausedDate'];
111
157
  function update(patch, persist = true) {
112
158
  state = { ...state, ...patch };
113
159
  if (persist) storage.save(state);
160
+ if (CHIME_KEYS.some((key) => key in patch)) syncScheduledChimes();
114
161
  for (const fn of listeners) fn();
115
162
  }
116
163
  // Optional chime. The AudioContext is created only after a user gesture, so
@@ -120,44 +167,96 @@ window.__ModuleLoader__.load({
120
167
  try {
121
168
  const Ctor = window.AudioContext || window.webkitAudioContext;
122
169
  if (!Ctor) return undefined;
123
- audioContext ??= new Ctor();
170
+ if (audioContext === undefined) {
171
+ audioContext = new Ctor();
172
+ audioContext.addEventListener?.('statechange', () => {
173
+ if (audioContext?.state === 'running') syncScheduledChimes();
174
+ });
175
+ }
124
176
  if (audioContext.state === 'suspended') void audioContext.resume().catch(() => {});
125
177
  } catch { audioContext = undefined; }
126
178
  return audioContext;
127
179
  }
180
+ function buildChime(audio, base) {
181
+ const nodes = [];
182
+ [660, 880].forEach((frequency, index) => {
183
+ const oscillator = audio.createOscillator();
184
+ const gain = audio.createGain();
185
+ const at = base + index * 0.18;
186
+ oscillator.type = 'sine';
187
+ oscillator.frequency.value = frequency;
188
+ gain.gain.setValueAtTime(0.0001, at);
189
+ gain.gain.linearRampToValueAtTime(0.32, at + 0.02);
190
+ gain.gain.exponentialRampToValueAtTime(0.0002, at + 0.6);
191
+ oscillator.connect(gain).connect(audio.destination);
192
+ oscillator.start(at);
193
+ oscillator.stop(at + 0.7);
194
+ nodes.push(oscillator);
195
+ });
196
+ return nodes;
197
+ }
128
198
  function playChime() {
129
199
  const audio = ensureAudio();
130
200
  if (!audio) return;
131
- const emit = () => {
132
- const start = audio.currentTime;
133
- [660, 880].forEach((frequency, index) => {
134
- const oscillator = audio.createOscillator();
135
- const gain = audio.createGain();
136
- const at = start + index * 0.18;
137
- oscillator.type = 'sine';
138
- oscillator.frequency.value = frequency;
139
- gain.gain.setValueAtTime(0, at);
140
- gain.gain.linearRampToValueAtTime(0.12, at + 0.02);
141
- gain.gain.exponentialRampToValueAtTime(0.0001, at + 0.35);
142
- oscillator.connect(gain).connect(audio.destination);
143
- oscillator.start(at);
144
- oscillator.stop(at + 0.4);
145
- });
146
- };
201
+ const emit = () => { buildChime(audio, audio.currentTime); };
147
202
  // The first chime often runs while the context is still suspended; wait for it.
148
203
  if (audio.state === 'running') emit();
149
204
  else Promise.resolve(audio.resume()).then(emit).catch(() => {});
150
205
  }
206
+ // A near-silent oscillator holds the audio device open; without it macOS/Chromium can
207
+ // suspend the context while the window is hidden, and scheduled chimes never fire.
208
+ let keepAliveOscillator;
209
+ function startAudioKeepAlive() {
210
+ const audio = ensureAudio();
211
+ if (!audio || keepAliveOscillator) return;
212
+ try {
213
+ const oscillator = audio.createOscillator();
214
+ const gain = audio.createGain();
215
+ oscillator.frequency.value = 30;
216
+ gain.gain.value = 0.00005;
217
+ oscillator.connect(gain).connect(audio.destination);
218
+ oscillator.start();
219
+ keepAliveOscillator = oscillator;
220
+ } catch { keepAliveOscillator = undefined; }
221
+ }
222
+ function stopAudioKeepAlive() {
223
+ try { keepAliveOscillator?.stop(); } catch { /* already stopped */ }
224
+ keepAliveOscillator = undefined;
225
+ }
226
+ // A scheduled chime rides the audio clock, so a hidden or throttled window still rings on time.
227
+ let scheduledChimes = [];
228
+ function cancelScheduledChimes() {
229
+ for (const node of scheduledChimes) { try { node.stop(); } catch { /* already stopped */ } }
230
+ scheduledChimes = [];
231
+ }
232
+ function scheduleChimeIn(delayMs) {
233
+ const audio = ensureAudio();
234
+ if (!audio) return;
235
+ const arm = () => {
236
+ const nodes = buildChime(audio, audio.currentTime + Math.max(0, delayMs) / 1000);
237
+ scheduledChimes.push(...nodes);
238
+ };
239
+ if (audio.state === 'running') arm();
240
+ else Promise.resolve(audio.resume()).then(arm).catch(() => {});
241
+ }
242
+ function syncScheduledChimes() {
243
+ cancelScheduledChimes();
244
+ if (!state.sound) { stopAudioKeepAlive(); return; }
245
+ startAudioKeepAlive();
246
+ if (state.status !== 'running') return;
247
+ const now = Date.now();
248
+ const today = new Date(now).toDateString();
249
+ if (state.reminders && state.pausedDate !== today && state.nextReminder > now) scheduleChimeIn(state.nextReminder - now);
250
+ if (state.deadline > now) scheduleChimeIn(state.deadline - now);
251
+ }
151
252
  function tick(now = Date.now()) {
152
253
  const patch = { now };
153
254
  let persist = false;
154
255
  if (state.status === 'running' && remainingMs(state, now) === 0) {
155
256
  Object.assign(patch, { status: 'done', remaining: 0, notice: 'finish', noticeAt: now });
156
- if (state.sound) playChime();
157
257
  persist = true;
158
258
  } else if (state.status === 'running' && state.reminders && state.pausedDate !== new Date(now).toDateString() && state.nextReminder && now >= state.nextReminder) {
159
259
  Object.assign(patch, { notice: 'break', noticeAt: now, nextReminder: now + 20 * 60000 });
160
- if (state.sound) playChime();
161
260
  persist = true;
162
261
  }
163
262
  update(patch, persist);
@@ -297,9 +396,44 @@ window.__ModuleLoader__.load({
297
396
  .llm-notice{margin-top:12px;padding:12px;border-radius:14px;background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 18%,var(--dsw-alias-bg-layer-2))}.llm-controls{margin-top:10px;flex-wrap:wrap}
298
397
  .llm-settings{flex-wrap:wrap;margin-top:12px}.llm-settings label{display:flex;align-items:center;gap:8px}.llm-settings input[type=range]{width:110px;accent-color:var(--dsw-alias-brand-primary)}
299
398
  .llm-float-widget{width:184px;pointer-events:auto}.llm-photo-stage{position:relative}.llm-photo-clean{width:184px;height:166px;border-radius:22px;border-width:1px}.llm-photo-clean img{object-position:73% 90%}.llm-photo-brand{position:absolute;top:10px;left:12px;font-size:12px;font-weight:700;color:#fff;text-shadow:0 1px 5px #000}.llm-minimize{position:absolute;right:7px;top:7px;width:30px;height:30px;padding:0;border:1px solid var(--dsw-alias-border-l1);border-radius:50%;background:color-mix(in srgb,var(--dsw-alias-bg-overlay) 70%,transparent);color:var(--dsw-alias-label-primary);cursor:pointer;font-size:18px!important}.llm-float-info{padding:8px 2px 0;text-align:left;background:transparent;text-shadow:0 1px 3px var(--dsw-alias-bg-base),0 0 8px var(--dsw-alias-bg-base)}.llm-float-clock-row{display:flex;align-items:center;justify-content:space-between;gap:8px}.llm-float-clock{font-size:28px;font-weight:750;font-variant-numeric:tabular-nums;line-height:1.1;letter-spacing:-.7px}.llm-quick{width:32px;height:32px;border-radius:50%;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);cursor:pointer}.llm-float-status,.llm-float-reminder,.llm-float-weather{font-size:11px;font-weight:500;line-height:1.5;font-variant-numeric:tabular-nums;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.llm-float-weather{color:var(--dsw-alias-label-secondary)}.llm-float-notice{display:block;margin-top:5px;padding:5px 0;background:transparent;border:0;color:var(--dsw-alias-state-warn-primary);font-size:11px;cursor:pointer;text-align:left}.llm-bubble{position:relative;width:68px;height:68px;border-radius:50%;border:0;padding:4px;background:conic-gradient(var(--dsw-alias-brand-primary) var(--llm-progress),var(--dsw-alias-border-l2) 0);cursor:pointer;pointer-events:auto}.llm-bubble img{width:60px;height:60px;object-fit:cover;object-position:73% 85%;border-radius:50%;display:block}.llm-bubble-time{position:absolute;bottom:-7px;left:50%;transform:translateX(-50%);border-radius:10px;padding:1px 6px;background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary);font-size:11px;font-variant-numeric:tabular-nums;border:1px solid var(--dsw-alias-border-l1)}.llm-bubble-notice{position:absolute;right:0;top:0;border-radius:50%;padding:0 6px;background:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-bg-base)}.llm-root button:focus-visible{outline:3px solid var(--dsw-alias-brand-primary);outline-offset:3px}.llm-bubble:hover,.llm-bubble:focus-visible{animation-play-state:paused}
300
- .llm-radar-trigger{pointer-events:auto;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:5px 9px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary);cursor:pointer;font-size:11px!important}.llm-radar-badge{position:absolute;right:-5px;top:-9px;z-index:2;min-width:27px;max-width:48px;min-height:27px;border-radius:999px;font-weight:700!important}.llm-radar-panel{position:absolute;right:0;z-index:30;max-height:min(64vh,520px);width:min(320px,calc(100vw - 32px));max-height:min(440px,calc(100dvh - 130px));overflow:auto;padding:14px;border:1px solid var(--dsw-alias-border-l2);border-radius:18px;background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary);pointer-events:auto;box-shadow:0 12px 40px color-mix(in srgb,var(--dsw-alias-label-primary) 15%,transparent)}.llm-radar-group{margin:10px 0}.llm-radar-label{font-size:12px;font-weight:650;margin-bottom:5px}.llm-radar-row{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;padding:9px 7px;text-align:left;background:transparent;border:0;border-bottom:1px solid var(--dsw-alias-border-l1);border-radius:6px;color:var(--dsw-alias-label-primary);cursor:pointer}.llm-radar-row:hover{background:var(--dsw-alias-bg-layer-2)}.llm-radar-row-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
301
399
  .llm-reminder-button{display:block;width:100%;padding:0;background:transparent;border:0;color:inherit;cursor:pointer;text-align:left}.llm-alert-highlight{color:var(--dsw-alias-label-primary)!important;background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 24%,var(--dsw-alias-bg-layer-1));border-left:3px solid var(--dsw-alias-state-warn-primary);border-radius:6px;padding:6px 8px!important;font-weight:700!important;white-space:normal!important;text-shadow:none}.llm-alert-actions{display:flex;gap:4px;margin-top:5px;flex-wrap:wrap}.llm-alert-actions .llm-btn{font-size:10px;padding:4px 6px}.llm-alert-shake{animation:llm-nudge .75s ease-in-out 2;transform-origin:center bottom}@keyframes llm-nudge{0%,100%{transform:translateX(0) rotate(0)}20%{transform:translateX(-4px) rotate(-2deg)}40%{transform:translateX(4px) rotate(2deg)}60%{transform:translateX(-3px) rotate(-1deg)}80%{transform:translateX(2px) rotate(1deg)}}@media(prefers-reduced-motion:reduce){.llm-alert-shake{animation:none}}
302
- .llm-radar-panel[data-placement='above']{bottom:calc(100% + 8px)}.llm-radar-panel[data-placement='below']{top:calc(100% + 8px)}.llm-radar-attention{border-color:var(--dsw-alias-state-warn-primary)!important;background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 22%,var(--dsw-alias-bg-overlay))!important;box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-state-warn-primary) 18%,transparent)}.llm-radar-count{margin-left:6px;font-weight:600;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary)}.llm-radar-kind{display:block;color:var(--dsw-alias-state-warn-primary);font-size:11px;font-weight:600;margin-top:2px}
400
+ .llm-radar-trigger{pointer-events:auto;display:inline-flex;align-items:center;gap:6px;max-width:240px;padding:4px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;background:color-mix(in srgb,var(--dsw-alias-bg-overlay) 88%,transparent);color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:11px!important;line-height:1.4;transition:border-color .15s,color .15s}
401
+ .llm-radar-trigger:hover{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary)}
402
+ .llm-radar-trigger .llm-radar-sum{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
403
+ .llm-radar-live{width:6px;height:6px;border-radius:50%;flex:none;background:var(--dsw-alias-state-idle-primary)}
404
+ .llm-radar-live.on{background:var(--dsw-alias-state-success-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-state-success-primary) 22%,transparent)}
405
+
406
+ .llm-radar-badge{position:absolute;right:-5px;top:-9px;z-index:2;min-width:26px;max-width:48px;min-height:26px;border-radius:999px;font-weight:700!important;justify-content:center;padding:0 6px}
407
+ .llm-radar-attention{border-color:var(--dsw-alias-state-warn-primary)!important;color:var(--dsw-alias-label-primary)!important;box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-state-warn-primary) 16%,transparent)}
408
+ .llm-radar-panel{position:absolute;right:0;z-index:30;width:min(340px,calc(100vw - 32px));max-height:min(64vh,520px);overflow:auto;padding:10px;border:1px solid var(--dsw-alias-border-l2);border-radius:16px;background:var(--dsw-alias-bg-overlay);color:var(--dsw-alias-label-primary);pointer-events:auto;box-shadow:0 16px 44px color-mix(in srgb,var(--dsw-alias-label-primary) 18%,transparent)}
409
+ .llm-radar-head{display:flex;align-items:center;gap:8px;padding:2px 4px 8px}
410
+ .llm-radar-title{font-size:12.5px;font-weight:700}
411
+ .llm-radar-sum{font-size:11px;color:var(--dsw-alias-label-secondary)}
412
+ .llm-radar-close{margin-left:auto;width:24px;height:24px;padding:0;border:0;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:15px!important;line-height:1}
413
+ .llm-radar-close:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary)}
414
+ .llm-radar-group{margin-top:6px}
415
+ .llm-radar-label{display:flex;align-items:center;justify-content:space-between;padding:6px 4px 4px;font-size:10.5px;font-weight:600;letter-spacing:.03em;color:var(--dsw-alias-label-secondary)}
416
+ .llm-radar-count{min-width:16px;padding:0 5px;border-radius:999px;background:var(--dsw-alias-bg-layer-2);text-align:center;font-variant-numeric:tabular-nums}
417
+ .llm-radar-row{display:grid;grid-template-columns:8px minmax(0,1fr) auto;align-items:center;gap:9px;width:100%;padding:8px;border:0;border-radius:10px;background:transparent;color:inherit;text-align:left;cursor:pointer}
418
+ .llm-radar-row:hover{background:var(--dsw-alias-bg-layer-2)}
419
+ .llm-radar-row:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
420
+ .llm-radar-dot{width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-state-idle-primary)}
421
+ .llm-radar-dot.run{background:var(--dsw-alias-state-success-primary)}
422
+ .llm-radar-dot.wait{background:var(--dsw-alias-state-warn-primary)}
423
+ .llm-radar-body{min-width:0}
424
+ .llm-radar-name{display:block;font-size:12.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
425
+ .llm-radar-meta{display:block;margin-top:1px;font-size:11px;color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
426
+ .llm-radar-toggle{display:flex;align-items:center;gap:4px;margin:2px 0 4px 17px;padding:2px 4px;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);font-size:10.5px!important;cursor:pointer}
427
+ .llm-radar-toggle:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}
428
+ .llm-radar-caret{font-size:9px;opacity:.8}
429
+ .llm-radar-children{margin:0 0 4px 16px;padding-left:6px;border-left:1px solid var(--dsw-alias-border-l1)}
430
+ .llm-radar-row.is-child{padding:6px}
431
+ .llm-radar-row.is-child .llm-radar-name{font-weight:500;font-size:12px}
432
+ .llm-radar-go{color:var(--dsw-alias-label-secondary);font-size:15px;opacity:.6}
433
+ .llm-radar-empty{padding:18px 8px;text-align:center;font-size:11.5px;color:var(--dsw-alias-label-secondary)}
434
+
435
+ .llm-radar-panel[data-placement='above']{bottom:calc(100% + 8px)}
436
+ .llm-radar-panel[data-placement='below']{top:calc(100% + 8px)}.llm-radar-attention{border-color:var(--dsw-alias-state-warn-primary)!important;background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 22%,var(--dsw-alias-bg-overlay))!important;box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-state-warn-primary) 18%,transparent)}.llm-radar-count{margin-left:6px;font-weight:600;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary)}.llm-radar-kind{display:block;color:var(--dsw-alias-state-warn-primary);font-size:11px;font-weight:600;margin-top:2px}
303
437
  @keyframes llm-drift{from{transform:translateY(0)}to{transform:translateY(var(--llm-drift-px, 0px))}}
304
438
  @media(prefers-reduced-motion:reduce){.llm-drift{animation:none}.llm-photo img{transition:none}}
305
439
  @media(max-width:600px){.llm-floating{right:12px;bottom:12px}.llm-photo{width:168px;height:212px}.llm-float-widget{width:168px}.llm-photo-clean{width:168px;height:150px}.llm-card{width:min(370px,calc(100vw - 24px));max-height:calc(100dvh - 48px)}.llm-panel{padding:64px 12px 12px}.llm-clock{font-size:54px}}
@@ -312,6 +446,7 @@ window.__ModuleLoader__.load({
312
446
  const [showAll, setShowAll] = React.useState(false);
313
447
  const [navigationError, setNavigationError] = React.useState('');
314
448
  const [radarPlacement, setRadarPlacement] = React.useState(null);
449
+ const [expandedParents, setExpandedParents] = React.useState({});
315
450
  const radarTrigger = React.useRef(null);
316
451
  const stackRef = React.useRef(null);
317
452
  const [driftPx, setDriftPx] = React.useState(0);
@@ -326,14 +461,14 @@ window.__ModuleLoader__.load({
326
461
  const [radarAttentionAt, setRadarAttentionAt] = React.useState(0);
327
462
  React.useEffect(() => {
328
463
  if (catalog.phase !== 'ready') { previousAttention.current = null; return; }
329
- const current = attentionSnapshot(catalog, statuses);
464
+ const current = attentionTokens(groups);
330
465
  const now = Date.now();
331
466
  if (!panelMode && newAttention(previousAttention.current, current) && now - lastNudge.current >= 8000) {
332
467
  lastNudge.current = now;
333
468
  setRadarAttentionAt(now);
334
469
  }
335
470
  previousAttention.current = current;
336
- }, [catalog, statuses, panelMode]);
471
+ }, [groups, catalog.phase, panelMode]);
337
472
  const radarHighlight = radarAttentionAt > 0 && model.now - radarAttentionAt < 12000;
338
473
  const radarShake = radarHighlight && model.now - radarAttentionAt < 1800;
339
474
  const [open, setOpen] = React.useState(false);
@@ -359,18 +494,49 @@ window.__ModuleLoader__.load({
359
494
  ].filter(Boolean).join(' · ') || t('radarQuiet');
360
495
  const closeRadar = () => { setRadarOpen(false); radarTrigger.current?.focus(); };
361
496
  const navigate = (row) => {
362
- try { workspace.openSession(row.id); setRadarOpen(false); setOpen(false); setNavigationError(''); }
497
+ try { workspace.openSession(row.child && row.parentId ? row.parentId : row.id); setRadarOpen(false); setOpen(false); setNavigationError(''); }
363
498
  catch { setNavigationError(t('openFailed')); }
364
499
  };
500
+ const rowMeta = (row) => {
501
+ const running = row.runningNow || (row.children ?? []).some((child) => child.runningNow);
502
+ const state = row.attentionKind ? t(row.attentionKind) : t(running ? 'radarRunning' : 'radarUnread');
503
+ const when = relativeTime(row.updatedAt, model.now);
504
+ return [state, row.child ? t('radarChild') : '', when].filter(Boolean).join(' · ');
505
+ };
506
+ const rowTone = (row) => row.attentionKind ? 'wait' : row.runningNow ? 'run' : '';
507
+ const renderRow = (row, { nested = false } = {}) => h('button', {
508
+ key: (nested ? 'child-' : '') + row.id,
509
+ type: 'button',
510
+ className: 'llm-radar-row' + (nested ? ' is-child' : ''),
511
+ title: row.cwd || row.id,
512
+ onClick: () => navigate(row),
513
+ },
514
+ h('span', { className: 'llm-radar-dot' + (rowTone(row) ? ' ' + rowTone(row) : ''), 'aria-hidden': true }),
515
+ h('span', { className: 'llm-radar-body' },
516
+ h('span', { className: 'llm-radar-name' }, row.displayTitle || row.id),
517
+ h('span', { className: 'llm-radar-meta' }, rowMeta(row))),
518
+ h('span', { className: 'llm-radar-go', 'aria-hidden': true }, '\u203a'));
519
+ const renderEntry = (row) => {
520
+ const children = row.children ?? [];
521
+ const open = expandedParents[row.id] === true;
522
+ return h(React.Fragment, { key: row.id },
523
+ renderRow(row),
524
+ children.length > 0 && h('button', {
525
+ type: 'button',
526
+ className: 'llm-radar-toggle',
527
+ 'aria-expanded': open,
528
+ onClick: () => setExpandedParents((previous) => ({ ...previous, [row.id]: !open })),
529
+ }, h('span', { className: 'llm-radar-caret', 'aria-hidden': true }, open ? '\u25be' : '\u25b8'), `${children.length} ${t('radarChild')}`),
530
+ open && h('div', { className: 'llm-radar-children' }, children.map((child) => renderRow(child, { nested: true }))));
531
+ };
365
532
  const renderGroup = (label, rows) => rows.length === 0 ? null : h('div', { className: 'llm-radar-group' },
366
533
  h('div', { className: 'llm-radar-label' }, label, h('span', { className: 'llm-radar-count' }, rows.length)),
367
- rows.slice(0, showAll ? rows.length : 3).map((row) => button(
368
- h(React.Fragment, null, h('span', { className: 'llm-radar-row-title' }, row.displayTitle || row.id, row.attentionKind && h('small', { className: 'llm-radar-kind' }, t(row.attentionKind))), h('span', { 'aria-hidden': true }, '↗')),
369
- () => navigate(row), { key: row.id, className: 'llm-radar-row', title: `${row.displayTitle || row.id}${row.cwd ? '\n' + row.cwd : ''}` })));
534
+ rows.slice(0, showAll ? rows.length : 3).map(renderEntry));
370
535
  const drifting = model.drift && !panelMode;
371
536
  React.useEffect(() => {
372
537
  if (!drifting) { setDriftPx(0); return undefined; }
373
538
  const measure = () => {
539
+ if (expanded) return;
374
540
  const height = stackRef.current?.getBoundingClientRect().height ?? 0;
375
541
  const travel = Math.round(height + 136 - window.innerHeight);
376
542
  setDriftPx(Math.min(0, travel));
@@ -379,7 +545,7 @@ window.__ModuleLoader__.load({
379
545
  window.addEventListener('resize', measure);
380
546
  return () => window.removeEventListener('resize', measure);
381
547
  }, [drifting, expanded, model.minimized]);
382
- return h('div', { className: `llm-root ${panelMode ? 'llm-panel' : 'llm-floating'}`, onPointerDownCapture: () => { if (model.sound) ensureAudio(); } },
548
+ return h('div', { className: `llm-root ${panelMode ? 'llm-panel' : 'llm-floating'}`, onPointerDownCapture: () => { if (model.sound) { ensureAudio(); syncScheduledChimes(); } } },
383
549
  h('style', null, css),
384
550
  model.bgOn && h('div', { className: 'llm-bg' + (model.readingProtection && !panelMode ? ' llm-bg-protected' : ''), style: { backgroundImage: `url("${asset(currentScene.name)}")`, opacity: model.intensity / 100, filter: `brightness(${model.brightness / 100}) saturate(1.12)` } }),
385
551
 
@@ -395,15 +561,23 @@ window.__ModuleLoader__.load({
395
561
  }
396
562
  setRadarOpen(next);
397
563
  setRadarAttentionAt(0);
398
- }, 'aria-expanded': radarOpen, 'aria-controls': radarId, 'aria-label': `${t('radar')} · ${radarSummary}`, title: radarSummary + (radarHighlight ? ' · ' + t('radarSoftHint') : '') }, model.minimized && !expanded ? (radarCount > 99 ? '99+' : radarCount || '☷') : `☷ ${radarSummary}`),
564
+ }, 'aria-expanded': radarOpen, 'aria-controls': radarId, 'aria-label': `${t('radar')} · ${radarSummary}`, title: radarSummary + (radarHighlight ? ' · ' + t('radarSoftHint') : '') },
565
+ model.minimized && !expanded
566
+ ? (radarCount > 99 ? '99+' : radarCount || '')
567
+ : h(React.Fragment, null,
568
+ h('span', { className: 'llm-radar-live' + (groups.running.length ? ' on' : ''), 'aria-hidden': true }),
569
+ h('span', { className: 'llm-radar-sum' }, radarSummary))),
399
570
  !panelMode && radarOpen && h('section', { ref: radarPanel, id: radarId, className: 'llm-radar-panel', 'data-placement': radarPlacement?.side || 'above', style: radarPlacement ? { maxHeight: radarPlacement.maxHeight } : undefined, 'aria-label': t('radar'), onKeyDown: (event) => { if (event.key === 'Escape') { event.stopPropagation(); closeRadar(); } }, onBlur: (event) => { if (!event.currentTarget.contains(event.relatedTarget) && event.relatedTarget !== radarTrigger.current) setRadarOpen(false); } },
400
- h('header', { className: 'llm-head' }, h('strong', null, t('radar')), button(t('close'), closeRadar, { className: 'llm-btn llm-actions' })),
571
+ h('div', { className: 'llm-radar-head' },
572
+ h('strong', { className: 'llm-radar-title' }, t('radar')),
573
+ h('span', { className: 'llm-radar-sum' }, radarSummary),
574
+ h('button', { type: 'button', className: 'llm-radar-close', onClick: closeRadar, 'aria-label': t('close') }, '\u00d7')),
401
575
  groups.waiting.length > 0 && renderGroup(t('radarWaiting'), groups.waiting),
402
576
  groups.running.length > 0 && renderGroup(t('radarRunning'), groups.running),
403
577
  groups.unread.length > 0 && renderGroup(t('radarUnread'), groups.unread),
404
- !radarCount && h('div', { className: 'llm-sub' }, t(catalog.phase === 'ready' ? 'radarEmpty' : 'connecting')),
578
+ !radarCount && h('div', { className: 'llm-radar-empty' }, t(catalog.phase === 'ready' ? 'radarEmpty' : 'connecting')),
405
579
  (groups.running.length > 3 || groups.unread.length > 3 || groups.waiting.length > 3) && button(t(showAll ? 'showLess' : 'showAll'), () => setShowAll(!showAll)),
406
- navigationError && h('div', { role: 'status', className: 'llm-sub' }, navigationError), h('div', { className: 'llm-summary' }, t('radarHint'))),
580
+ navigationError && h('div', { role: 'status', className: 'llm-sub' }, navigationError)),
407
581
 
408
582
  !expanded ? model.minimized ? h('button', {
409
583
  type: 'button', className: 'llm-bubble' + (alertShake || radarShake ? ' llm-alert-shake' : ''), key: `bubble-${Math.max(attentionAt, radarAttentionAt)}`, onClick: () => update({ minimized: false }),
@@ -438,6 +612,7 @@ window.__ModuleLoader__.load({
438
612
  h('div', { className: 'llm-settings' }, button(t(model.reminders && !todayPaused ? 'on' : 'off'), () => setReminders(!model.reminders || todayPaused), { role: 'switch', 'aria-checked': model.reminders && !todayPaused })),
439
613
  h('div', { className: 'llm-settings' }, button(t(model.sound ? 'soundOn' : 'soundOff'), () => { const next = !model.sound; update({ sound: next }); if (next) playChime(); }, { role: 'switch', 'aria-checked': model.sound })),
440
614
  h('div', { className: 'llm-summary' }, t('reminderEvery')),
615
+ button(t('testBackground'), () => { startAudioKeepAlive(); scheduleChimeIn(10000); }),
441
616
  button(t('testReminder'), () => { const now = Date.now(); update({ now, previewAt: now, previewUntil: now + 8000 }, false); if (state.sound) playChime(); setOpen(false); if (panelMode) layout.selectPanel(null); }),
442
617
  h('div', { className: 'llm-summary' }, reminderLabel(model))),
443
618
  model.notice && h('div', { className: 'llm-notice', role: 'status' }, t(model.notice), h('div', { className: 'llm-controls' }, button(t('got'), () => update({ notice: '' })), model.status === 'running' && button(t('snooze'), () => update({ notice: '', nextReminder: Date.now() + 10 * 60000 })), button(t('today'), () => update({ notice: '', pausedDate: new Date().toDateString() })))),
@@ -474,6 +649,8 @@ window.__ModuleLoader__.load({
474
649
  workspace = ctx.uiWorkspace;
475
650
  translate = ctx.locale.bind(ID);
476
651
  state = initialState(storage.read());
652
+ // Arm the audio schedule for a restored running session (the first gesture resumes it).
653
+ try { syncScheduledChimes(); } catch { /* audio may still be locked until a gesture */ }
477
654
  ctx.effect(() => ctx.locale.register(ID, 'zh', zh), 'lumululu-mei Chinese');
478
655
  ctx.effect(() => ctx.locale.register(ID, 'en', en), 'lumululu-mei English');
479
656
  ctx.effect(() => {
@@ -483,6 +660,11 @@ window.__ModuleLoader__.load({
483
660
  return () => { weatherActive = false; locationGeneration++; weatherController?.abort(); stop(); };
484
661
  }, 'lumululu-mei weather');
485
662
  ctx.effect(() => { tick(); return ctx.timer.interval(() => tick(), 1000); }, 'lumululu-mei focus clock');
663
+ ctx.effect(() => {
664
+ const onVisible = () => { if (document.visibilityState === 'visible') syncScheduledChimes(); };
665
+ document.addEventListener('visibilitychange', onVisible);
666
+ return () => document.removeEventListener('visibilitychange', onVisible);
667
+ }, 'momolulu chime resync');
486
668
  ctx.effect(() => {
487
669
  const control = createFullscreenControl({
488
670
  active: () => document.fullscreenElement === document.documentElement,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "momolulu",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "type": "module",
5
5
  "description": "momolulu, a gentle coding companion with scenic backgrounds, live session activity, focus timing and wellbeing nudges.",
6
6
  "exports": {