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 +1 -1
- package/README.md +1 -1
- package/client.js +233 -51
- package/package.json +1 -1
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
|
-
|
|
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
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: '暂无会话变化',
|
|
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',
|
|
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
|
|
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
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
62
|
-
const
|
|
63
|
-
for (const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
}, [
|
|
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(
|
|
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') : '') },
|
|
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('
|
|
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-
|
|
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)
|
|
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