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 +1 -1
- package/README.md +1 -1
- package/client.js +297 -74
- 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,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: '
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
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
|
|
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. */ } },
|
|
@@ -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)),
|
|
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
|
|
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
|
|
129
|
-
const
|
|
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 =
|
|
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.
|
|
140
|
-
gain.gain.exponentialRampToValueAtTime(0.
|
|
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.
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
301
|
-
|
|
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}
|
|
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 =
|
|
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
|
-
}, [
|
|
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 =
|
|
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
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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-
|
|
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)
|
|
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'
|
|
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:
|
|
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('
|
|
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
|
-
|
|
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' },
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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