momolulu 1.1.1 → 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,15 +12,15 @@ 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: '刚结束 · 待查看', 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: '距离下次休息',
20
20
  remindOff: '提醒已关闭,专注计时不受影响', remindToday: '今天已暂停提醒', remindReady: '开始专注后计时',
21
21
  break: '喝口水、活动肩颈,让眼睛看看远处吧。', finish: '专注完成!休息一下,再继续也不迟。',
22
22
  got: '收到', snooze: '10 分钟后提醒', today: '今天暂停', background: '背景', intensity: '背景浓度',
23
- motion: '照片动态', activity: '会话动态', idle: '空闲', processing: '正在处理', newSession: '新会话',
23
+ drift: '缓慢巡游', activity: '会话动态', idle: '空闲', processing: '正在处理', newSession: '新会话',
24
24
  connecting: '正在连接会话…', empty: '暂无会话,开始创作吧。', more: '更多会话', view: '展开专注面板',
25
25
  weather: '天气', nanjing: '南京', local: '当前位置', locate: '使用当前位置', locating: '定位中…', locationError: '定位不可用,已切回南京', weatherLoading: '天气加载中', weatherError: '天气暂不可用', stale: '上次更新', refresh: '刷新', brightness: '背景亮度', backgroundAdjust: '背景调节', privacy: '点击定位后,坐标仅用于 Open-Meteo 天气查询,不保存到本地。', clear: '晴', cloudy: '多云', fog: '雾', rain: '雨', snow: '雪', storm: '雷雨', unknownWeather: '天气',
26
26
  restAfterFocus: '本轮结束后休息', enterFullscreen: '进入全屏', appearance: '外观设置', reminderSettings: '提醒设置', reminderEvery: '每 20 分钟提醒喝水 / 站立', noRunning: '当前没有运行中的任务', runningTasks: '运行中的任务', workMode: '工作模式', scenicMode: '陪伴模式', readingMode: '阅读保护',
@@ -28,15 +28,15 @@ 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: 'Ended · unread', radarEmpty: 'No running or unread sessions', 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',
36
36
  remindOff: 'Reminders off · focus timer continues', remindToday: 'Reminders paused today', remindReady: 'Starts with your focus timer',
37
37
  break: 'Have some water, stretch your shoulders and rest your eyes.', finish: 'Well done! Take a break before your next session.',
38
38
  got: 'Got it', snooze: 'Remind in 10 min', today: 'Pause today', background: 'Background', intensity: 'Background visibility',
39
- motion: 'Photo motion', activity: 'Session activity', idle: 'Idle', processing: 'Processing', newSession: 'New session',
39
+ drift: 'Slow drift', activity: 'Session activity', idle: 'Idle', processing: 'Processing', newSession: 'New session',
40
40
  connecting: 'Connecting to sessions…', empty: 'No sessions yet. Start creating.', more: 'More sessions', view: 'Open focus panel',
41
41
  weather: 'Weather', nanjing: 'Nanjing', local: 'Current location', locate: 'Use my location', locating: 'Locating…', locationError: 'Location unavailable; using Nanjing', weatherLoading: 'Loading weather', weatherError: 'Weather unavailable', stale: 'Last update', refresh: 'Refresh', brightness: 'Background brightness', backgroundAdjust: 'Background settings', privacy: 'Location is sent only to Open-Meteo for weather after you click; coordinates are not stored locally.', clear: 'Clear', cloudy: 'Cloudy', fog: 'Fog', rain: 'Rain', snow: 'Snow', storm: 'Thunderstorm', unknownWeather: 'Weather',
42
42
  restAfterFocus: 'Rest after this session', enterFullscreen: 'Enter fullscreen', appearance: 'Appearance', reminderSettings: 'Reminder settings', reminderEvery: 'Water / stand reminder every 20 minutes', noRunning: 'No tasks running', runningTasks: 'Running tasks', workMode: 'Work mode', scenicMode: 'Scenic mode', readingMode: 'Reading protection',
@@ -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. */ } },
@@ -95,22 +140,24 @@ window.__ModuleLoader__.load({
95
140
  reminderRemaining: Number.isFinite(saved?.reminderRemaining) ? Math.max(0, saved.reminderRemaining) : 20 * 60000,
96
141
  sound: saved?.sound === true,
97
142
  reminders: saved?.reminders !== false, nextReminder: Number.isFinite(saved?.nextReminder) ? saved.nextReminder : 0,
98
- pausedDate: typeof saved?.pausedDate === 'string' ? saved.pausedDate : '', notice: '', noticeAt: 0,
143
+ pausedDate: typeof saved?.pausedDate === 'string' ? saved.pausedDate : '', notice: '', noticeAt: 0, previewAt: 0, previewUntil: 0,
99
144
  scene: [0, 1, 2].includes(saved?.scene) ? saved.scene : 0,
100
145
  minimized: saved?.minimized === true,
101
146
  readingProtection: saved?.readingProtection !== false, fullscreen: false,
102
147
  brightness: Math.min(180, Math.max(70, Number(saved?.brightness) || 122)),
103
148
  weather: null, weatherState: 'loading', locationKey: 'nanjing', locationError: false, locating: false,
104
- bgOn: saved?.bgOn !== false, intensity: Math.min(75, Math.max(15, Number(saved?.intensity) || 45)), motion: saved?.motion !== false,
149
+ bgOn: saved?.bgOn !== false, intensity: Math.min(75, Math.max(15, Number(saved?.intensity) || 45)), drift: saved?.drift !== false,
105
150
  };
106
151
  }
107
152
  let state;
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,42 +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
  }
128
- function playChime() {
129
- const audio = ensureAudio();
130
- if (!audio || audio.state !== 'running') return;
131
- const start = audio.currentTime;
180
+ function buildChime(audio, base) {
181
+ const nodes = [];
132
182
  [660, 880].forEach((frequency, index) => {
133
183
  const oscillator = audio.createOscillator();
134
184
  const gain = audio.createGain();
135
- const at = start + index * 0.18;
185
+ const at = base + index * 0.18;
136
186
  oscillator.type = 'sine';
137
187
  oscillator.frequency.value = frequency;
138
- gain.gain.setValueAtTime(0, at);
139
- gain.gain.linearRampToValueAtTime(0.12, at + 0.02);
140
- gain.gain.exponentialRampToValueAtTime(0.0001, at + 0.35);
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);
141
191
  oscillator.connect(gain).connect(audio.destination);
142
192
  oscillator.start(at);
143
- oscillator.stop(at + 0.4);
193
+ oscillator.stop(at + 0.7);
194
+ nodes.push(oscillator);
144
195
  });
196
+ return nodes;
197
+ }
198
+ function playChime() {
199
+ const audio = ensureAudio();
200
+ if (!audio) return;
201
+ const emit = () => { buildChime(audio, audio.currentTime); };
202
+ // The first chime often runs while the context is still suspended; wait for it.
203
+ if (audio.state === 'running') emit();
204
+ else Promise.resolve(audio.resume()).then(emit).catch(() => {});
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);
145
251
  }
146
252
  function tick(now = Date.now()) {
147
253
  const patch = { now };
148
254
  let persist = false;
149
255
  if (state.status === 'running' && remainingMs(state, now) === 0) {
150
- Object.assign(patch, { status: 'done', remaining: 0 });
151
- if (state.reminders && state.pausedDate !== new Date(now).toDateString()) {
152
- Object.assign(patch, { notice: 'finish', noticeAt: now });
153
- if (state.sound) playChime();
154
- }
256
+ Object.assign(patch, { status: 'done', remaining: 0, notice: 'finish', noticeAt: now });
155
257
  persist = true;
156
258
  } else if (state.status === 'running' && state.reminders && state.pausedDate !== new Date(now).toDateString() && state.nextReminder && now >= state.nextReminder) {
157
259
  Object.assign(patch, { notice: 'break', noticeAt: now, nextReminder: now + 20 * 60000 });
158
- if (state.sound) playChime();
159
260
  persist = true;
160
261
  }
161
262
  update(patch, persist);
@@ -166,7 +267,7 @@ window.__ModuleLoader__.load({
166
267
  update({ now, status: 'running', remaining, deadline: now + remaining, nextReminder: now + (state.status === 'paused' ? (state.reminderRemaining ?? 20 * 60000) : 20 * 60000), notice: '' });
167
268
  }
168
269
  function pause() { const now = Date.now(); update({ now, remaining: remainingMs(state, now), reminderRemaining: Math.max(0, state.nextReminder - now), status: 'paused' }); }
169
- function reset(minutes = state.minutes) { update({ minutes, status: 'ready', remaining: minutes * 60000, deadline: 0, nextReminder: 0, notice: '' }); }
270
+ function reset(minutes = state.minutes) { update({ minutes, status: 'ready', remaining: minutes * 60000, deadline: 0, nextReminder: 0, notice: '', previewAt: 0, previewUntil: 0 }); }
170
271
  function setReminders(enabled) {
171
272
  const now = Date.now();
172
273
  update({ reminders: enabled, pausedDate: '', nextReminder: now + 20 * 60000, reminderRemaining: 20 * 60000, notice: '' });
@@ -283,8 +384,8 @@ window.__ModuleLoader__.load({
283
384
  .llm-primary{flex:1;font-weight:700;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 17%,var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-brand-primary)}
284
385
  .llm-photo{position:relative;display:block;width:196px;height:238px;overflow:hidden;border:2px solid var(--dsw-alias-border-l2);border-radius:26px;padding:0;cursor:pointer;pointer-events:auto;background:var(--dsw-alias-bg-layer-1);box-shadow:0 12px 40px color-mix(in srgb,var(--dsw-alias-label-primary) 20%,transparent)}
285
386
  .llm-photo img{width:100%;height:100%;object-fit:cover;object-position:73% 80%;filter:brightness(1.12);transition:transform .6s}
286
- .llm-photo:hover img{transform:scale(1.06)}.llm-motion{animation:llm-bob 6s ease-in-out infinite}.llm-motion img{animation:llm-breathe 14s ease-in-out infinite alternate}
287
- .llm-photo-caption{position:absolute;inset:12px 10px auto;padding:0;border:0;background:transparent;color:#fff;text-shadow:0 1px 4px #000,0 0 12px #000;text-align:left;pointer-events:none}.llm-photo-caption .llm-sub{color:inherit}.llm-status-line{display:flex;justify-content:center;align-items:center;gap:12px;flex-wrap:wrap}.llm-next-inline{font-size:11px;font-variant-numeric:tabular-nums}.llm-photo-caption .llm-status-line{display:grid;grid-template-columns:1fr;gap:2px;margin-top:4px;text-align:left}.llm-photo-caption .llm-sub,.llm-photo-caption .llm-next-inline,.llm-weather-mini{max-width:100%;text-align:left;font:500 11px/1.45 system-ui,-apple-system,sans-serif;font-variant-numeric:tabular-nums;letter-spacing:0}.llm-weather-mini{margin-top:4px}.llm-photo-name{font-size:12px}.llm-background-adjust{margin-top:14px;border-top:1px solid var(--dsw-alias-border-l1);padding-top:10px}.llm-background-adjust summary{cursor:pointer;font-weight:650}.llm-background-adjust label{display:grid;grid-template-columns:100px 1fr 45px;align-items:center;gap:8px;margin-top:12px}.llm-background-adjust input{width:100%;accent-color:var(--dsw-alias-brand-primary)}.llm-weather-panel{margin:12px 0;padding:10px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px}.llm-weather-panel a{color:var(--dsw-alias-label-secondary)}
387
+ .llm-photo:hover img{transform:scale(1.06)}.llm-stack{position:relative;display:flex;flex-direction:column;align-items:flex-end;gap:7px}.llm-drift{animation:llm-drift 58s linear infinite alternate;will-change:transform}.llm-drift:hover,.llm-drift:has(:focus-visible),.llm-drift:has(.llm-card){animation-play-state:paused}
388
+ .llm-photo-caption{position:absolute;inset:12px 10px auto;padding:0;border:0;background:transparent;color:#fff;text-shadow:0 1px 4px #000,0 0 12px #000;text-align:left;pointer-events:none}.llm-photo-caption .llm-sub{color:inherit}.llm-status-line{display:flex;justify-content:center;align-items:center;gap:12px;flex-wrap:wrap}.llm-next-inline{font-size:11px;font-variant-numeric:tabular-nums}.llm-photo-caption .llm-status-line{display:grid;grid-template-columns:1fr;gap:2px;margin-top:4px;text-align:left}.llm-photo-caption .llm-sub,.llm-photo-caption .llm-next-inline,.llm-weather-mini{max-width:100%;text-align:left;font:500 11px/1.45 system-ui,-apple-system,sans-serif;font-variant-numeric:tabular-nums;letter-spacing:0}.llm-weather-mini{margin-top:4px}.llm-photo-name{font-size:12px}.llm-sliders{margin-top:12px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l1)}.llm-sliders label{display:grid;grid-template-columns:96px 1fr 42px;align-items:center;gap:8px;margin-top:10px;font-size:12px;color:var(--dsw-alias-label-secondary)}.llm-sliders input{width:100%;accent-color:var(--dsw-alias-brand-primary)}.llm-sliders output{text-align:right;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-primary)}.llm-weather-panel{margin:12px 0;padding:10px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px}.llm-weather-panel a{color:var(--dsw-alias-label-secondary)}
288
389
  .llm-photo-name{display:flex;justify-content:space-between;align-items:center;font-weight:750}.llm-mini-clock{font-size:26px;font-weight:800;font-variant-numeric:tabular-nums;line-height:1.2;margin-top:4px}
289
390
  .llm-focus{padding:18px 14px;border:1px solid var(--dsw-alias-border-l1);border-radius:20px;background:var(--dsw-alias-bg-layer-2);text-align:center}
290
391
  .llm-label{color:var(--dsw-alias-label-secondary);font-size:12px}.llm-clock{font-size:64px;font-weight:800;letter-spacing:-3px;line-height:1.18;font-variant-numeric:tabular-nums;margin:7px 0;color:var(--dsw-alias-brand-primary)}
@@ -294,12 +395,47 @@ window.__ModuleLoader__.load({
294
395
  .llm-section{margin:18px 0 8px;font-weight:700}.llm-row{padding:8px 0;border-top:1px solid var(--dsw-alias-border-l1)}.llm-row-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:280px}.llm-dot{height:8px;width:8px;flex:none;border-radius:50%;background:var(--dsw-alias-state-idle-primary)}.llm-live{background:var(--dsw-alias-state-success-primary)}
295
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}
296
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)}
297
- .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-photo-stage:hover,.llm-photo-stage:focus-within{animation-play-state:paused}
298
- .llm-floating{display:flex;flex-direction:column;align-items:flex-end;gap:7px}.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;bottom:calc(100% + 10px);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}.llm-floating:has(>.llm-card) .llm-radar-panel{bottom:auto;top:0;right:calc(100% + 10px)}@media(max-width:760px){.llm-floating:has(>.llm-card) .llm-radar-panel{position:fixed;top:64px;right:12px;bottom:auto;z-index:5;max-height:calc(100dvh - 100px)}}
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}
299
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}}
300
- .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-kind{display:block;color:var(--dsw-alias-state-warn-primary);font-size:11px;font-weight:600;margin-top:2px}
301
- @keyframes llm-bob{0%,100%{transform:translateY(0) rotate(-.5deg)}50%{transform:translateY(-7px) rotate(.5deg)}}@keyframes llm-breathe{from{transform:scale(1)}to{transform:scale(1.07)}}
302
- @media(prefers-reduced-motion:reduce){.llm-motion,.llm-motion img{animation:none}.llm-photo img{transition:none}}
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}
437
+ @keyframes llm-drift{from{transform:translateY(0)}to{transform:translateY(var(--llm-drift-px, 0px))}}
438
+ @media(prefers-reduced-motion:reduce){.llm-drift{animation:none}.llm-photo img{transition:none}}
303
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}}
304
440
  `;
305
441
  function Companion({ useSessions, useSessionStatus, panelMode = false }) {
@@ -309,7 +445,11 @@ window.__ModuleLoader__.load({
309
445
  const [radarOpen, setRadarOpen] = React.useState(false);
310
446
  const [showAll, setShowAll] = React.useState(false);
311
447
  const [navigationError, setNavigationError] = React.useState('');
448
+ const [radarPlacement, setRadarPlacement] = React.useState(null);
449
+ const [expandedParents, setExpandedParents] = React.useState({});
312
450
  const radarTrigger = React.useRef(null);
451
+ const stackRef = React.useRef(null);
452
+ const [driftPx, setDriftPx] = React.useState(0);
313
453
  const radarPanel = React.useRef(null);
314
454
  const radarId = React.useId();
315
455
  React.useEffect(() => {
@@ -321,14 +461,14 @@ window.__ModuleLoader__.load({
321
461
  const [radarAttentionAt, setRadarAttentionAt] = React.useState(0);
322
462
  React.useEffect(() => {
323
463
  if (catalog.phase !== 'ready') { previousAttention.current = null; return; }
324
- const current = attentionSnapshot(catalog, statuses);
464
+ const current = attentionTokens(groups);
325
465
  const now = Date.now();
326
466
  if (!panelMode && newAttention(previousAttention.current, current) && now - lastNudge.current >= 8000) {
327
467
  lastNudge.current = now;
328
468
  setRadarAttentionAt(now);
329
469
  }
330
470
  previousAttention.current = current;
331
- }, [catalog, statuses, panelMode]);
471
+ }, [groups, catalog.phase, panelMode]);
332
472
  const radarHighlight = radarAttentionAt > 0 && model.now - radarAttentionAt < 12000;
333
473
  const radarShake = radarHighlight && model.now - radarAttentionAt < 1800;
334
474
  const [open, setOpen] = React.useState(false);
@@ -347,29 +487,98 @@ window.__ModuleLoader__.load({
347
487
  const statusKey = model.status === 'running' ? 'running' : model.status === 'paused' ? 'paused' : model.status === 'done' ? 'done' : 'ready';
348
488
  const button = (label, onClick, extra = {}) => h('button', { type: 'button', className: 'llm-btn', onClick, ...extra }, label);
349
489
  const radarCount = groups.running.length + groups.unread.length + groups.waiting.length;
350
- const radarSummary = `${groups.waiting.length} ${t('radarWaiting')} · ${groups.running.length} ${t('radarRunning')} · ${groups.unread.length} ${t('radarUnread')}`;
490
+ const radarSummary = [
491
+ groups.waiting.length && `${groups.waiting.length} ${t('radarWaiting')}`,
492
+ groups.running.length && `${groups.running.length} ${t('radarRunning')}`,
493
+ groups.unread.length && `${groups.unread.length} ${t('radarUnread')}`,
494
+ ].filter(Boolean).join(' · ') || t('radarQuiet');
351
495
  const closeRadar = () => { setRadarOpen(false); radarTrigger.current?.focus(); };
352
496
  const navigate = (row) => {
353
- 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(''); }
354
498
  catch { setNavigationError(t('openFailed')); }
355
499
  };
356
- const renderGroup = (label, rows) => h('div', { className: 'llm-radar-group' },
357
- h('div', { className: 'llm-radar-label' }, `${label} · ${rows.length}`),
358
- rows.slice(0, showAll ? rows.length : 3).map((row) => button(
359
- 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 }, '↗')),
360
- () => navigate(row), { key: row.id, className: 'llm-radar-row', title: `${row.displayTitle || row.id}${row.cwd ? '\n' + row.cwd : ''}` })));
361
- return h('div', { className: `llm-root ${panelMode ? 'llm-panel' : 'llm-floating'}`, onPointerDownCapture: () => { if (model.sound) ensureAudio(); } },
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
+ };
532
+ const renderGroup = (label, rows) => rows.length === 0 ? null : h('div', { className: 'llm-radar-group' },
533
+ h('div', { className: 'llm-radar-label' }, label, h('span', { className: 'llm-radar-count' }, rows.length)),
534
+ rows.slice(0, showAll ? rows.length : 3).map(renderEntry));
535
+ const drifting = model.drift && !panelMode;
536
+ React.useEffect(() => {
537
+ if (!drifting) { setDriftPx(0); return undefined; }
538
+ const measure = () => {
539
+ if (expanded) return;
540
+ const height = stackRef.current?.getBoundingClientRect().height ?? 0;
541
+ const travel = Math.round(height + 136 - window.innerHeight);
542
+ setDriftPx(Math.min(0, travel));
543
+ };
544
+ measure();
545
+ window.addEventListener('resize', measure);
546
+ return () => window.removeEventListener('resize', measure);
547
+ }, [drifting, expanded, model.minimized]);
548
+ return h('div', { className: `llm-root ${panelMode ? 'llm-panel' : 'llm-floating'}`, onPointerDownCapture: () => { if (model.sound) { ensureAudio(); syncScheduledChimes(); } } },
362
549
  h('style', null, css),
363
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)` } }),
364
- !panelMode && h('button', { type: 'button', ref: radarTrigger, className: 'llm-radar-trigger' + (radarHighlight ? ' llm-radar-attention' : '') + (radarShake ? ' llm-alert-shake' : '') + (model.minimized && !expanded ? ' llm-radar-badge' : ''), onClick: () => { setRadarOpen(!radarOpen); setRadarAttentionAt(0); }, 'aria-expanded': radarOpen, 'aria-controls': radarId, 'aria-label': `${t('radar')} · ${radarSummary}`, title: radarSummary + (radarHighlight ? ' · ' + t('radarSoftHint') : '') }, model.minimized && !expanded ? (radarCount > 99 ? '99+' : radarCount || '☷') : `☷ ${radarSummary}`),
365
- !panelMode && radarOpen && h('section', { ref: radarPanel, id: radarId, className: 'llm-radar-panel', '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); } },
366
- h('header', { className: 'llm-head' }, h('strong', null, t('radar')), button(t('close'), closeRadar, { className: 'llm-btn llm-actions' })),
551
+
552
+ h('div', { ref: stackRef, className: 'llm-stack' + (drifting ? ' llm-drift' : ''), style: drifting ? { '--llm-drift-px': `${driftPx}px` } : undefined },
553
+ !panelMode && h('button', { type: 'button', ref: radarTrigger, className: 'llm-radar-trigger' + (radarHighlight ? ' llm-radar-attention' : '') + (radarShake ? ' llm-alert-shake' : '') + (model.minimized && !expanded ? ' llm-radar-badge' : ''), onClick: () => {
554
+ const next = !radarOpen;
555
+ if (next && radarTrigger.current) {
556
+ const rect = radarTrigger.current.getBoundingClientRect();
557
+ const spaceAbove = rect.top - 24;
558
+ const spaceBelow = window.innerHeight - rect.bottom - 24;
559
+ if (spaceAbove >= 240 || spaceAbove >= spaceBelow) setRadarPlacement({ side: 'above', maxHeight: Math.max(180, Math.min(440, spaceAbove)) });
560
+ else setRadarPlacement({ side: 'below', maxHeight: Math.max(180, Math.min(440, spaceBelow)) });
561
+ }
562
+ setRadarOpen(next);
563
+ setRadarAttentionAt(0);
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))),
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); } },
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')),
367
575
  groups.waiting.length > 0 && renderGroup(t('radarWaiting'), groups.waiting),
368
576
  groups.running.length > 0 && renderGroup(t('radarRunning'), groups.running),
369
577
  groups.unread.length > 0 && renderGroup(t('radarUnread'), groups.unread),
370
- !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')),
371
579
  (groups.running.length > 3 || groups.unread.length > 3 || groups.waiting.length > 3) && button(t(showAll ? 'showLess' : 'showAll'), () => setShowAll(!showAll)),
372
- 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)),
581
+
373
582
  !expanded ? model.minimized ? h('button', {
374
583
  type: 'button', className: 'llm-bubble' + (alertShake || radarShake ? ' llm-alert-shake' : ''), key: `bubble-${Math.max(attentionAt, radarAttentionAt)}`, onClick: () => update({ minimized: false }),
375
584
  'aria-label': `${t('restore')} · ${formatTime(remaining)} · ${model.notice ? t(model.notice) : t(statusKey)}`,
@@ -378,7 +587,7 @@ window.__ModuleLoader__.load({
378
587
  h('span', { className: 'llm-bubble-time' }, formatTime(remaining)),
379
588
  alertActive && h('span', { className: 'llm-bubble-notice', 'aria-hidden': true }, '!'))
380
589
  : h('div', { className: 'llm-float-widget' + (alertShake || radarShake ? ' llm-alert-shake' : ''), key: `widget-${Math.max(attentionAt, radarAttentionAt)}` },
381
- h('div', { className: 'llm-photo-stage' + (model.motion ? ' llm-motion' : '') },
590
+ h('div', { className: 'llm-photo-stage' },
382
591
  h('button', { type: 'button', className: 'llm-photo llm-photo-clean', onClick: () => setOpen(true), 'aria-label': t('view'), 'aria-expanded': false },
383
592
  h('img', { src: asset(currentScene.name), alt: t('title') }),
384
593
  h('span', { className: 'llm-photo-brand' }, t('title'))),
@@ -401,29 +610,36 @@ window.__ModuleLoader__.load({
401
610
  h('div', { className: 'llm-sub', style: { marginTop: 10 } }, `${t('elapsed')} · ${formatTime(total - remaining)}`)),
402
611
  h('details', { className: 'llm-disclosure' }, h('summary', null, `${t('reminderSettings')} · ${t(model.reminders && !todayPaused ? 'on' : 'off')}`),
403
612
  h('div', { className: 'llm-settings' }, button(t(model.reminders && !todayPaused ? 'on' : 'off'), () => setReminders(!model.reminders || todayPaused), { role: 'switch', 'aria-checked': model.reminders && !todayPaused })),
404
- h('div', { className: 'llm-settings' }, button(t(model.sound ? 'soundOn' : 'soundOff'), () => { update({ sound: !model.sound }); if (!model.sound) ensureAudio(); }, { role: 'switch', 'aria-checked': model.sound })),
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 })),
405
614
  h('div', { className: 'llm-summary' }, t('reminderEvery')),
406
- button(t('testReminder'), () => { const now = Date.now(); update({ now, previewAt: now, previewUntil: now + 8000 }, false); setOpen(false); if (panelMode) layout.selectPanel(null); }),
615
+ button(t('testBackground'), () => { startAudioKeepAlive(); scheduleChimeIn(10000); }),
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); }),
407
617
  h('div', { className: 'llm-summary' }, reminderLabel(model))),
408
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() })))),
409
619
  h('details', { className: 'llm-disclosure' }, h('summary', null, `${t('radar')} · ${radarSummary}`),
410
- renderGroup(t('radarWaiting'), groups.waiting), renderGroup(t('radarRunning'), groups.running), renderGroup(t('radarUnread'), groups.unread),
620
+ groups.waiting.length > 0 || groups.running.length > 0 || groups.unread.length > 0
621
+ ? h(React.Fragment, null, renderGroup(t('radarWaiting'), groups.waiting), renderGroup(t('radarRunning'), groups.running), renderGroup(t('radarUnread'), groups.unread))
622
+ : h('div', { className: 'llm-sub' }, t(catalog.phase === 'ready' ? 'radarEmpty' : 'connecting')),
411
623
  !radarCount && h('div', { className: 'llm-sub' }, t('radarEmpty')),
412
624
  (groups.running.length > 3 || groups.unread.length > 3 || groups.waiting.length > 3) && button(t(showAll ? 'showLess' : 'showAll'), () => setShowAll(!showAll)),
413
625
  navigationError && h('div', { role: 'status' }, navigationError)),
414
- h('details', { className: 'llm-disclosure' }, h('summary', null, t('appearance')),
415
- h('div', { className: 'llm-settings' }, scenes.map((scene, index) => button(t(scene.key), () => update({ scene: index }), { key: scene.key, 'aria-pressed': model.scene === index }))),
416
- h('div', { className: 'llm-settings' }, button(`${t('background')} · ${t(model.bgOn ? 'on' : 'off')}`, () => update({ bgOn: !model.bgOn }), { role: 'switch', 'aria-checked': model.bgOn }), button(`${t('motion')} · ${t(model.motion ? 'on' : 'off')}`, () => update({ motion: !model.motion }), { role: 'switch', 'aria-checked': model.motion })),
417
- h('details', { className: 'llm-background-adjust' }, h('summary', null, t('backgroundAdjust')),
418
- h('label', null, t('brightness'), h('input', { type: 'range', min: 70, max: 180, value: model.brightness, onChange: (event) => update({ brightness: Number(event.target.value) }) }), h('output', null, `${model.brightness}%`)),
419
- h('label', null, t('intensity'), h('input', { type: 'range', min: 15, max: 75, value: model.intensity, onChange: (event) => update({ intensity: Number(event.target.value) }) }), h('output', null, `${model.intensity}%`))),
420
- h('div', { className: 'llm-settings' }, button(t(model.readingProtection ? 'workMode' : 'scenicMode'), () => update({ readingProtection: !model.readingProtection }), { role: 'switch', 'aria-checked': model.readingProtection, 'aria-label': t('readingMode') }))),
626
+ h('details', { className: 'llm-disclosure' },
627
+ h('summary', null, t('appearance')),
628
+ h('div', { className: 'llm-settings' }, scenes.map((scene, index) => button(t(scene.key), () => update({ scene: index }), { key: scene.key, 'aria-pressed': model.scene === index }))),
629
+ h('div', { className: 'llm-settings' }, button(`${t('background')} · ${t(model.bgOn ? 'on' : 'off')}`, () => update({ bgOn: !model.bgOn }), { role: 'switch', 'aria-checked': model.bgOn })),
630
+ model.bgOn && h('div', { className: 'llm-sliders' },
631
+ h('div', { className: 'llm-sub' }, t('backgroundAdjust')),
632
+ h('label', null, t('brightness'), h('input', { type: 'range', min: 70, max: 180, value: model.brightness, onChange: (event) => update({ brightness: Number(event.target.value) }) }), h('output', null, `${model.brightness}%`)),
633
+ h('label', null, t('intensity'), h('input', { type: 'range', min: 15, max: 75, value: model.intensity, onChange: (event) => update({ intensity: Number(event.target.value) }) }), h('output', null, `${model.intensity}%`))),
634
+ h('div', { className: 'llm-settings' }, button(t(model.readingProtection ? 'workMode' : 'scenicMode'), () => update({ readingProtection: !model.readingProtection }), { role: 'switch', 'aria-checked': model.readingProtection, 'aria-label': t('readingMode') })),
635
+ h('div', { className: 'llm-settings' }, button(`${t('drift')} · ${t(model.drift ? 'on' : 'off')}`, () => update({ drift: !model.drift }), { role: 'switch', 'aria-checked': model.drift })),
636
+ ),
421
637
  h('details', { className: 'llm-disclosure' }, h('summary', null, weatherText(model)),
422
638
  h('div', { className: 'llm-summary' }, `${t('stale')}: ${model.weather?.time?.replace('T', ' ') || '—'}`),
423
639
  h('div', { className: 'llm-controls' }, button(t('nanjing'), () => useNanjing()), button(t(model.locating ? 'locating' : 'locate'), locateWeather, { disabled: model.locating }), button(t('refresh'), () => void loadWeather())),
424
640
  model.locationError && h('div', { className: 'llm-sub', role: 'status' }, t('locationError')),
425
641
  h('div', { className: 'llm-sub' }, t('privacy')),
426
- h('a', { href: 'https://open-meteo.com/', target: '_blank', rel: 'noopener noreferrer', className: 'llm-sub' }, 'Weather: Open-Meteo · CC BY 4.0'))));
642
+ h('a', { href: 'https://open-meteo.com/', target: '_blank', rel: 'noopener noreferrer', className: 'llm-sub' }, 'Weather: Open-Meteo · CC BY 4.0')))));
427
643
  }
428
644
  function Icon({ size = 24 }) { return h('img', { src: asset(scenes[0].name), alt: '', width: size, height: size, style: { display: 'block', borderRadius: '50%', objectFit: 'cover', objectPosition: '73% 80%' } }); }
429
645
  return {
@@ -433,6 +649,8 @@ window.__ModuleLoader__.load({
433
649
  workspace = ctx.uiWorkspace;
434
650
  translate = ctx.locale.bind(ID);
435
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 */ }
436
654
  ctx.effect(() => ctx.locale.register(ID, 'zh', zh), 'lumululu-mei Chinese');
437
655
  ctx.effect(() => ctx.locale.register(ID, 'en', en), 'lumululu-mei English');
438
656
  ctx.effect(() => {
@@ -442,6 +660,11 @@ window.__ModuleLoader__.load({
442
660
  return () => { weatherActive = false; locationGeneration++; weatherController?.abort(); stop(); };
443
661
  }, 'lumululu-mei weather');
444
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');
445
668
  ctx.effect(() => {
446
669
  const control = createFullscreenControl({
447
670
  active: () => document.fullscreenElement === document.documentElement,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "momolulu",
3
- "version": "1.1.1",
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": {