@detergent-software/conductor 1.0.0-dev.6 → 1.0.0-dev.8
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/CHANGELOG.md +22 -0
- package/README.md +53 -7
- package/build/cli.js +65 -0
- package/build/cli.js.map +1 -1
- package/build/config.js +68 -1
- package/build/config.js.map +1 -1
- package/build/credential.js +52 -0
- package/build/credential.js.map +1 -0
- package/build/daemon.js +14 -0
- package/build/daemon.js.map +1 -1
- package/build/inbox.js +2 -8
- package/build/inbox.js.map +1 -1
- package/build/notify.js +401 -0
- package/build/notify.js.map +1 -0
- package/build/pushover.js +67 -0
- package/build/pushover.js.map +1 -0
- package/build/server.js +48 -0
- package/build/server.js.map +1 -1
- package/build/store.js +84 -0
- package/build/store.js.map +1 -1
- package/build/watchers/linear-key.js +6 -45
- package/build/watchers/linear-key.js.map +1 -1
- package/mod/README.md +1 -1
- package/mod/hooks/model.ts +119 -6
- package/mod/hooks/register.tsx +92 -8
- package/mod/types/index.d.ts +12 -0
- package/package.json +1 -1
package/mod/hooks/model.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// the status line, band and pane show, and what each event asks the mod to do. register.tsx does
|
|
3
3
|
// the engine's part (only its top-level functions may take `$`); vitest tests this file in CI
|
|
4
4
|
// (apps/conductor/mod/tests/model.spec.ts).
|
|
5
|
-
import type { ConductorInboxItem, ConductorSnapshot, ConductorWorker } from '../types';
|
|
5
|
+
import type { ConductorInboxItem, ConductorNotify, ConductorSnapshot, ConductorWorker } from '../types';
|
|
6
6
|
|
|
7
7
|
// A daemon API answer: its status and parsed JSON body.
|
|
8
8
|
export type ApiAnswer = { body: unknown; status: number };
|
|
@@ -19,6 +19,33 @@ export type Reaction =
|
|
|
19
19
|
| { kind: 'refresh'; toast: string | null }
|
|
20
20
|
| { kind: 'send'; text: string; to: string };
|
|
21
21
|
|
|
22
|
+
// What a Remote Control push came to: reached the phone (ok); not, as Remote Control is off
|
|
23
|
+
// (inactive) or pushes are off in Claude Code's config (disabled); not sent to the phone on
|
|
24
|
+
// purpose (skipped: Jason is at the terminal, or the answer says nothing about the phone); or the
|
|
25
|
+
// call failed (failed: it threw, was denied, or answered an error).
|
|
26
|
+
export type PushOutcome = { detail: string; state: 'disabled' | 'failed' | 'inactive' | 'ok' | 'skipped' };
|
|
27
|
+
|
|
28
|
+
// Said once in the console when pushes stop reaching the phone (ATK-255), by why.
|
|
29
|
+
export function pushWarning(outcome: PushOutcome): null | string {
|
|
30
|
+
switch (outcome.state) {
|
|
31
|
+
case 'disabled':
|
|
32
|
+
return "conductor: mobile pushes are off in Claude Code's config, so pushes reach this terminal only. Turn them on, or /notify away for Pushover.";
|
|
33
|
+
case 'failed':
|
|
34
|
+
return `conductor: a Remote Control push failed (${outcome.detail}). /notify away sends by Pushover instead.`;
|
|
35
|
+
case 'inactive':
|
|
36
|
+
return 'conductor: Remote Control is off, so pushes reach this terminal only. Connect Remote Control, or /notify away for Pushover.';
|
|
37
|
+
case 'ok':
|
|
38
|
+
case 'skipped':
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The state the daemon records for a push's outcome (POST /notify/report).
|
|
44
|
+
export function reportedState(outcome: PushOutcome): 'failing' | 'inactive' | 'ok' | 'skipped' {
|
|
45
|
+
if (outcome.state === 'disabled') return 'inactive';
|
|
46
|
+
return outcome.state === 'failed' ? 'failing' : outcome.state;
|
|
47
|
+
}
|
|
48
|
+
|
|
22
49
|
// A band entry and the act its press does.
|
|
23
50
|
export type BandEntry = { act: 'approve' | 'copy' | 'pane'; hotkey: string; id: number; label: string; text: string };
|
|
24
51
|
|
|
@@ -121,6 +148,15 @@ export function age(iso: string, now: number): string {
|
|
|
121
148
|
return `${Math.floor(minutes / (24 * 60))}d`;
|
|
122
149
|
}
|
|
123
150
|
|
|
151
|
+
// The time left until `iso`: "12m", "3h", "2d"; "<1m" when it is (almost) up.
|
|
152
|
+
export function remaining(iso: string, now: number): string {
|
|
153
|
+
const minutes = Math.floor((Date.parse(iso) - now) / 60_000);
|
|
154
|
+
if (!(minutes >= 1)) return '<1m';
|
|
155
|
+
if (minutes < 60) return `${minutes}m`;
|
|
156
|
+
if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h`;
|
|
157
|
+
return `${Math.floor(minutes / (24 * 60))}d`;
|
|
158
|
+
}
|
|
159
|
+
|
|
124
160
|
// 950, 42k, 6.1M.
|
|
125
161
|
export function tokens(n: number): string {
|
|
126
162
|
if (n < 1000) return String(n);
|
|
@@ -169,6 +205,11 @@ export function reaction(line: StreamLine): Reaction {
|
|
|
169
205
|
const ask = text('text');
|
|
170
206
|
return { kind: 'refresh', toast: ask === null ? null : `conductor: ${line.subject ? `${line.subject} · ` : ''}${ask}` };
|
|
171
207
|
}
|
|
208
|
+
case 'notify.failed':
|
|
209
|
+
case 'notify.routed':
|
|
210
|
+
case 'notify.sent':
|
|
211
|
+
// Nothing the mod draws: only notify.profile and notify.channel change the pane.
|
|
212
|
+
return { kind: 'ignore' };
|
|
172
213
|
case 'notify.requested': {
|
|
173
214
|
const message = text('message');
|
|
174
215
|
return line.data.channel === 'remote-control' && message !== null ? { kind: 'push', message } : { kind: 'ignore' };
|
|
@@ -264,13 +305,85 @@ export function endpointOf(portText: string, tokenText: string): { port: number;
|
|
|
264
305
|
return Number.isInteger(port) && port > 0 && /^[0-9a-f]{64}$/.test(token) ? { port, token } : null;
|
|
265
306
|
}
|
|
266
307
|
|
|
267
|
-
// GET /fleet and GET /
|
|
268
|
-
|
|
308
|
+
// GET /fleet, GET /inbox and GET /notify as one snapshot; `error` set when the daemon didn't answer
|
|
309
|
+
// the first two. A daemon older than the notifier answers /notify 404, which leaves `notify` null.
|
|
310
|
+
export function snapshotOf(fleet: ApiAnswer, inbox: ApiAnswer, readAt: number, notify?: ApiAnswer): ConductorSnapshot {
|
|
269
311
|
const workers = fleet.status === 200 ? parseFleet(fleet.body) : null;
|
|
270
|
-
if (workers === null) return { error: errorOf(fleet), inbox: [], readAt, workers: [] };
|
|
312
|
+
if (workers === null) return { error: errorOf(fleet), inbox: [], notify: null, readAt, workers: [] };
|
|
271
313
|
const items = inbox.status === 200 ? parseInbox(inbox.body) : null;
|
|
272
|
-
if (items === null) return { error: errorOf(inbox), inbox: [], readAt, workers: [] };
|
|
273
|
-
return { error: null, inbox: items, readAt, workers };
|
|
314
|
+
if (items === null) return { error: errorOf(inbox), inbox: [], notify: null, readAt, workers: [] };
|
|
315
|
+
return { error: null, inbox: items, notify: notify?.status === 200 ? parseNotify(notify.body) : null, readAt, workers };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// GET /notify's body, kept to what the mod draws; null when it isn't one.
|
|
319
|
+
export function parseNotify(body: unknown): ConductorNotify | null {
|
|
320
|
+
if (!isRecord(body) || typeof body.profile !== 'string') return null;
|
|
321
|
+
const channels = isRecord(body.channels) ? body.channels : {};
|
|
322
|
+
const remote = isRecord(channels['remote-control']) ? channels['remote-control'] : {};
|
|
323
|
+
return {
|
|
324
|
+
held: typeof body.held === 'number' ? body.held : 0,
|
|
325
|
+
profile: body.profile,
|
|
326
|
+
profiles: strings(body.profiles),
|
|
327
|
+
profileUntil: typeof body.profileUntil === 'string' ? body.profileUntil : null,
|
|
328
|
+
quietNow: body.quietNow === true,
|
|
329
|
+
remoteControl: typeof remote.state === 'string' ? remote.state : 'unknown',
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// What `$.tool.call` of PushNotification answered: `{ result: { pushSent, disabledReason }, text }`.
|
|
334
|
+
// Only a push that reached the phone is ok; Remote Control off (no_transport, or the text saying
|
|
335
|
+
// so) is inactive; pushes turned off in the config are disabled; the rest say nothing of the phone.
|
|
336
|
+
export function pushOutcome(answer: unknown): PushOutcome {
|
|
337
|
+
const call = isRecord(answer) ? answer : {};
|
|
338
|
+
if (typeof call.deny === 'string') return { detail: `refused: ${call.deny}`, state: 'failed' };
|
|
339
|
+
const result = isRecord(call.result) ? call.result : {};
|
|
340
|
+
const detail = typeof call.text === 'string' && call.text.trim() !== '' ? call.text.trim() : typeof result.message === 'string' ? result.message : '';
|
|
341
|
+
if (call.isError === true) return { detail: detail || 'PushNotification answered an error', state: 'failed' };
|
|
342
|
+
if (result.pushSent === true) return { detail, state: 'ok' };
|
|
343
|
+
if (result.disabledReason === 'config_off') return { detail: "Mobile push not sent (off in Claude Code's config)", state: 'disabled' };
|
|
344
|
+
if (result.disabledReason === 'no_transport' || /remote control inactive/i.test(detail)) {
|
|
345
|
+
return { detail: detail || 'Mobile push not sent (Remote Control inactive)', state: 'inactive' };
|
|
346
|
+
}
|
|
347
|
+
if (result.disabledReason === 'user_present') return { detail: 'Mobile push not sent: you were at the terminal (user_present)', state: 'skipped' };
|
|
348
|
+
return { detail: detail || 'PushNotification said nothing about the phone', state: 'skipped' };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// `/notify <profile> [--until <when>]`: --until is a local time (the next one), a date (local
|
|
352
|
+
// midnight) or a date and time, as `conduct notify profile` reads it.
|
|
353
|
+
export function notifyArgs(args: string, now: number): { error: string } | { name: string; until: null | string } {
|
|
354
|
+
const typed = /^(\S+)(?:\s+--until\s+(\S+))?$/.exec(args.trim());
|
|
355
|
+
if (!typed?.[1]) return { error: 'usage: /notify [<profile> [--until <when>]]' };
|
|
356
|
+
const when = typed[2];
|
|
357
|
+
if (when === undefined) return { name: typed[1], until: null };
|
|
358
|
+
const clock = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(when);
|
|
359
|
+
let at: Date;
|
|
360
|
+
if (clock) {
|
|
361
|
+
at = new Date(now);
|
|
362
|
+
at.setHours(Number(clock[1]), Number(clock[2]), 0, 0);
|
|
363
|
+
if (at.getTime() <= now) at.setDate(at.getDate() + 1);
|
|
364
|
+
} else {
|
|
365
|
+
at = new Date(/^\d{4}-\d{2}-\d{2}$/.test(when) ? `${when}T00:00` : when);
|
|
366
|
+
}
|
|
367
|
+
if (Number.isNaN(at.getTime())) return { error: `--until "${when}" is not a time: a local time such as 17:00, or a date and time such as 2026-10-12T09:00` };
|
|
368
|
+
return { name: typed[1], until: at.toISOString() };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// `/notify` with no profile: the profile, quiet hours, Remote Control.
|
|
372
|
+
export function notifyText(notify: ConductorNotify | null, now: number): string {
|
|
373
|
+
if (notify === null) return 'the running conductor has no notifier: restart it on this build (conduct upgrade)';
|
|
374
|
+
const until = notify.profileUntil === null ? '' : ` for ${remaining(notify.profileUntil, now)} more`;
|
|
375
|
+
return [
|
|
376
|
+
`notify profile: ${notify.profile}${until} (profiles: ${notify.profiles.join(', ')}; /notify <profile> switches)`,
|
|
377
|
+
`Remote Control: ${notify.remoteControl}${notify.quietNow ? ` · quiet hours now, ${notify.held} held for the summary` : ''}`,
|
|
378
|
+
].join('\n');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// POST /notify/profile's answer as the line to show.
|
|
382
|
+
export function profileText(name: string, answer: ApiAnswer, now: number): string {
|
|
383
|
+
if (answer.status !== 200) return `notify profile not switched: ${errorOf(answer)}`;
|
|
384
|
+
const notify = parseNotify(answer.body);
|
|
385
|
+
const until = notify?.profileUntil ? ` for ${remaining(notify.profileUntil, now)}` : '';
|
|
386
|
+
return `notify profile: ${notify?.profile ?? name}${until}`;
|
|
274
387
|
}
|
|
275
388
|
|
|
276
389
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
package/mod/hooks/register.tsx
CHANGED
|
@@ -15,9 +15,16 @@ import {
|
|
|
15
15
|
glyph,
|
|
16
16
|
homeOf,
|
|
17
17
|
inboxText,
|
|
18
|
+
notifyArgs,
|
|
19
|
+
notifyText,
|
|
18
20
|
parseLine,
|
|
21
|
+
profileText,
|
|
19
22
|
projectGroups,
|
|
23
|
+
type PushOutcome,
|
|
24
|
+
pushOutcome,
|
|
25
|
+
pushWarning,
|
|
20
26
|
reaction,
|
|
27
|
+
reportedState,
|
|
21
28
|
snapshotOf,
|
|
22
29
|
spendToday,
|
|
23
30
|
statusText,
|
|
@@ -47,6 +54,8 @@ let isConsole = false;
|
|
|
47
54
|
let isRefreshing = false;
|
|
48
55
|
// The newest event id the stream showed, so a restarted `conduct events` picks up after it.
|
|
49
56
|
let lastEventId: null | number = null;
|
|
57
|
+
// Set once the console has said that pushes no longer reach the phone; a push that does clears it.
|
|
58
|
+
let warnedInactive = false;
|
|
50
59
|
|
|
51
60
|
type Endpoint = { port: number; token: string };
|
|
52
61
|
|
|
@@ -85,16 +94,20 @@ async function callApi($: EngineInterface, method: 'GET' | 'POST', path: string,
|
|
|
85
94
|
return { body: parsed, status: answer.status };
|
|
86
95
|
}
|
|
87
96
|
|
|
88
|
-
// Reads /fleet and /
|
|
97
|
+
// Reads /fleet, /inbox and /notify into the state the drawings read, and the status line.
|
|
89
98
|
async function refresh($: EngineInterface): Promise<ConductorSnapshot> {
|
|
90
99
|
const readAt = await $.clock.now();
|
|
91
100
|
let next: ConductorSnapshot;
|
|
92
101
|
try {
|
|
93
102
|
const at = await endpoint($);
|
|
94
|
-
const [fleet, inbox] = await Promise.all([
|
|
95
|
-
|
|
103
|
+
const [fleet, inbox, notify] = await Promise.all([
|
|
104
|
+
callApi($, 'GET', '/fleet', undefined, at),
|
|
105
|
+
callApi($, 'GET', '/inbox', undefined, at),
|
|
106
|
+
callApi($, 'GET', '/notify', undefined, at),
|
|
107
|
+
]);
|
|
108
|
+
next = snapshotOf(fleet, inbox, readAt, notify);
|
|
96
109
|
} catch (error) {
|
|
97
|
-
next = { error: error instanceof Error ? error.message : String(error), inbox: [], readAt, workers: [] };
|
|
110
|
+
next = { error: error instanceof Error ? error.message : String(error), inbox: [], notify: null, readAt, workers: [] };
|
|
98
111
|
}
|
|
99
112
|
await update($, snapshot, () => next);
|
|
100
113
|
$.ui.status(statusText(next));
|
|
@@ -121,7 +134,7 @@ async function onLine($: EngineInterface, text: string): Promise<void> {
|
|
|
121
134
|
switch (act.kind) {
|
|
122
135
|
case 'disconnected': {
|
|
123
136
|
const readAt = await $.clock.now();
|
|
124
|
-
await update($, snapshot, () => ({ error: NOT_RUNNING, inbox: [], readAt, workers: [] }));
|
|
137
|
+
await update($, snapshot, () => ({ error: NOT_RUNNING, inbox: [], notify: null, readAt, workers: [] }));
|
|
125
138
|
$.ui.status(undefined);
|
|
126
139
|
return;
|
|
127
140
|
}
|
|
@@ -132,7 +145,7 @@ async function onLine($: EngineInterface, text: string): Promise<void> {
|
|
|
132
145
|
case 'ignore':
|
|
133
146
|
return;
|
|
134
147
|
case 'push':
|
|
135
|
-
await
|
|
148
|
+
await push($, act.message);
|
|
136
149
|
return;
|
|
137
150
|
case 'refresh':
|
|
138
151
|
if (act.toast !== null) $.ui.toast(act.toast);
|
|
@@ -147,6 +160,40 @@ async function onLine($: EngineInterface, text: string): Promise<void> {
|
|
|
147
160
|
}
|
|
148
161
|
}
|
|
149
162
|
|
|
163
|
+
// Pushes to the phone through PushNotification, tells the daemon what came of it whatever it was
|
|
164
|
+
// (`conduct notify` shows it), and says once in the console when pushes stop reaching the phone
|
|
165
|
+
// (ATK-255): Remote Control off, pushes off in the config, or the call failing.
|
|
166
|
+
async function push($: EngineInterface, message: string): Promise<void> {
|
|
167
|
+
let outcome: PushOutcome;
|
|
168
|
+
try {
|
|
169
|
+
outcome = pushOutcome(await $.tool.call({ message, status: 'proactive', tool: 'PushNotification' }));
|
|
170
|
+
} catch (error) {
|
|
171
|
+
outcome = { detail: error instanceof Error ? error.message : String(error), state: 'failed' };
|
|
172
|
+
}
|
|
173
|
+
const warning = pushWarning(outcome);
|
|
174
|
+
if (outcome.state === 'ok') warnedInactive = false;
|
|
175
|
+
if (warning !== null && !warnedInactive) {
|
|
176
|
+
warnedInactive = true;
|
|
177
|
+
$.ui.toast(warning);
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
await callApi($, 'POST', '/notify/report', { channel: 'remote-control', result: outcome.detail, state: reportedState(outcome) });
|
|
181
|
+
} catch (error) {
|
|
182
|
+
$.ui.log(`conductor: notify report failed: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// `/notify <profile> [--until <when>]` and the pane's picker: switch the notification profile.
|
|
187
|
+
async function switchProfile($: EngineInterface, name: string, until: null | string = null): Promise<string> {
|
|
188
|
+
try {
|
|
189
|
+
const result = await callApi($, 'POST', '/notify/profile', until === null ? { name } : { name, until });
|
|
190
|
+
refreshSoon($);
|
|
191
|
+
return profileText(name, result, await $.clock.now());
|
|
192
|
+
} catch (error) {
|
|
193
|
+
return error instanceof Error ? error.message : String(error);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
150
197
|
// `conduct events --follow` from this package's own build, so the mod and the CLI are one version
|
|
151
198
|
// and no .cmd shim is in the way. It reconnects by itself; a child that ends is started again after
|
|
152
199
|
// the last event it showed, so nothing in between is lost. One that keeps exiting at once (no
|
|
@@ -222,6 +269,7 @@ async function registerCommands($: EngineInterface): Promise<void> {
|
|
|
222
269
|
await $.command.register({ argumentHint: '<item | #id>', description: "Approve an item's plan (posts approve as you)", name: 'approve' });
|
|
223
270
|
await $.command.register({ argumentHint: '<item | #id> <what to change>', description: 'Ask for a revised plan (posts revise: … as you)', name: 'revise' });
|
|
224
271
|
await $.command.register({ argumentHint: '<item | worker>', description: 'Wake a parked worker and open it in a tab', name: 'attach' });
|
|
272
|
+
await $.command.register({ argumentHint: '[<profile> [--until <when>]]', description: 'Show or switch the notification profile (default, away, focus …)', name: 'notify' });
|
|
225
273
|
}
|
|
226
274
|
|
|
227
275
|
export const register: Register = (on) => {
|
|
@@ -279,6 +327,15 @@ export const register: Register = (on) => {
|
|
|
279
327
|
return { text: worker.found ? await attach($, worker.found.id, worker.found.name) : worker.error };
|
|
280
328
|
});
|
|
281
329
|
|
|
330
|
+
on('command.run', { command: 'notify' }, async ($, e) => {
|
|
331
|
+
if (e.args.trim() !== '') {
|
|
332
|
+
const typed = notifyArgs(e.args, await $.clock.now());
|
|
333
|
+
return { text: 'error' in typed ? typed.error : await switchProfile($, typed.name, typed.until) };
|
|
334
|
+
}
|
|
335
|
+
const snap = await refresh($);
|
|
336
|
+
return { text: snap.error ?? notifyText(snap.notify, await $.clock.now()) };
|
|
337
|
+
});
|
|
338
|
+
|
|
282
339
|
// The band above the prompt, in the console, while the inbox has items. A bare digit typed into
|
|
283
340
|
// an empty prompt presses a band Button, so a plan's approve takes a second press (`y`).
|
|
284
341
|
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
@@ -331,9 +388,10 @@ export const register: Register = (on) => {
|
|
|
331
388
|
);
|
|
332
389
|
});
|
|
333
390
|
|
|
334
|
-
// The pane: the
|
|
391
|
+
// The pane: the notification profile, the inbox, then the fleet by project, then today's spend.
|
|
335
392
|
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
|
|
336
|
-
const
|
|
393
|
+
const ui = $.ui.resolve(e);
|
|
394
|
+
const { Box, Button, Link, Text } = ui;
|
|
337
395
|
const snap = await read($, snapshot);
|
|
338
396
|
const open = await read($, expanded);
|
|
339
397
|
const now = await $.clock.now();
|
|
@@ -352,6 +410,32 @@ export const register: Register = (on) => {
|
|
|
352
410
|
<Text bold>
|
|
353
411
|
{TITLE} <Text dimColor>{statusText(snap)}</Text>
|
|
354
412
|
</Text>
|
|
413
|
+
{snap.notify !== null && (
|
|
414
|
+
<Box columnGap={2} flexWrap="wrap">
|
|
415
|
+
{e.surface !== 'mobile' && 'Select' in ui ? (
|
|
416
|
+
<ui.Select
|
|
417
|
+
key="notify-profile"
|
|
418
|
+
label="notify "
|
|
419
|
+
onSelect={async (name) => $.ui.toast(await switchProfile($, name))}
|
|
420
|
+
options={snap.notify.profiles.map((p) => ({ value: p }))}
|
|
421
|
+
value={snap.notify.profile}
|
|
422
|
+
/>
|
|
423
|
+
) : (
|
|
424
|
+
snap.notify.profiles.map((p) => (
|
|
425
|
+
<Button
|
|
426
|
+
key={`profile-${p}`}
|
|
427
|
+
label={p === snap.notify?.profile ? `notify: ${p}` : p}
|
|
428
|
+
onPress={async () => $.ui.toast(await switchProfile($, p))}
|
|
429
|
+
variant={p === snap.notify?.profile ? 'primary' : undefined}
|
|
430
|
+
/>
|
|
431
|
+
))
|
|
432
|
+
)}
|
|
433
|
+
<Text dimColor>
|
|
434
|
+
Remote Control: {snap.notify.remoteControl}
|
|
435
|
+
{snap.notify.quietNow ? ` · quiet hours, ${snap.notify.held} held` : ''}
|
|
436
|
+
</Text>
|
|
437
|
+
</Box>
|
|
438
|
+
)}
|
|
355
439
|
<Text dimColor wrap="truncate">
|
|
356
440
|
{rule(`Inbox (${snap.inbox.length})`)}
|
|
357
441
|
</Text>
|
package/mod/types/index.d.ts
CHANGED
|
@@ -24,10 +24,22 @@ export type ConductorInboxItem = {
|
|
|
24
24
|
text: string;
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
+
// GET /notify as the mod draws it: the profile, the others to switch to, Remote Control's state.
|
|
28
|
+
export type ConductorNotify = {
|
|
29
|
+
held: number;
|
|
30
|
+
profile: string;
|
|
31
|
+
profiles: string[];
|
|
32
|
+
profileUntil: string | null;
|
|
33
|
+
quietNow: boolean;
|
|
34
|
+
remoteControl: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
27
37
|
// What the mod last read from the daemon; `error` set (and the lists empty) when none answered.
|
|
38
|
+
// `notify` is null from a daemon older than the notifier.
|
|
28
39
|
export type ConductorSnapshot = {
|
|
29
40
|
error: string | null;
|
|
30
41
|
inbox: ConductorInboxItem[];
|
|
42
|
+
notify: ConductorNotify | null;
|
|
31
43
|
readAt: number;
|
|
32
44
|
workers: ConductorWorker[];
|
|
33
45
|
};
|
package/package.json
CHANGED