@openchambery/web 1.19.11 → 1.19.13-beta.1
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/dist/assets/{AgentAvatar-CX0a1rKE.js → AgentAvatar-CiQIIM0d.js} +1 -1
- package/dist/assets/AssistantView-YmeyxehG.js +2 -0
- package/dist/assets/{DiagramView-BQO1cBLs.js → DiagramView-BpZb-G9P.js} +1 -1
- package/dist/assets/{FadeInOnReveal-CTlNE4Hg.js → FadeInOnReveal-nf6-ZCrs.js} +1 -1
- package/dist/assets/{Icon-BYM02QyU.js → Icon-CeKHYyKZ.js} +1 -1
- package/dist/assets/MarkdownRenderer-wpeGdciL.js +2 -0
- package/dist/assets/{MarkdownRendererImpl--takeMXN.js → MarkdownRendererImpl-Peu0GVSQ.js} +1 -1
- package/dist/assets/{MarkstreamRendererImpl-DGSqWcPN.js → MarkstreamRendererImpl-B-odd2Gz.js} +1 -1
- package/dist/assets/MobileDetailNavigation-CtYke_1C.js +1 -0
- package/dist/assets/{MobileShareBridge-D-BUjACe.js → MobileShareBridge-DIfOl8EZ.js} +3 -3
- package/dist/assets/MobileSurface-_KoX75Ff.js +2 -0
- package/dist/assets/{MultiRunWindow-BNR9ZWmD.js → MultiRunWindow-BT5Dwgzu.js} +1 -1
- package/dist/assets/{OnboardingScreen-DS2nugLX.js → OnboardingScreen-BPusUgFG.js} +1 -1
- package/dist/assets/{OverlayScrollbar-KUpvYHdv.js → OverlayScrollbar-DUghqFGg.js} +1 -1
- package/dist/assets/{SettingsView-DGerg-C2.js → SettingsView-BwQpbIZl.js} +11 -11
- package/dist/assets/{SettingsWindow-CSpd5JbJ.js → SettingsWindow-DfEGfyr8.js} +1 -1
- package/dist/assets/{TerminalView-DS-oUxJr.js → TerminalView-8402vpcO.js} +1 -1
- package/dist/assets/{ToolOutputDialog-Q1k9-IaY.js → ToolOutputDialog-B5RdkhsG.js} +1 -1
- package/dist/assets/{appThemeRegistry-CG7D-3-q.js → appThemeRegistry-CIZ-shMD.js} +1 -1
- package/dist/assets/{button-C0cth9tl.js → button-DFPOdZ0g.js} +1 -1
- package/dist/assets/{clipboard-DJ4nQEuY.js → clipboard-C05anBwU.js} +1 -1
- package/dist/assets/{collapsible-DFT9cE80.js → collapsible-oGbEvbe1.js} +1 -1
- package/dist/assets/{context-menu-CAp74NfV.js → context-menu-DGz4_2hs.js} +1 -1
- package/dist/assets/{desktopHosts-Dc2PS66M.js → desktopHosts-BVJXQq1S.js} +1 -1
- package/dist/assets/{desktopRelayRestore-T9y0orqa.js → desktopRelayRestore-RDfuj1M7.js} +1 -1
- package/dist/assets/{diagram-BryzMuta.js → diagram-Csk5GpDC.js} +1 -1
- package/dist/assets/{dialog-CgbV5ljg.js → dialog-rrqB-H6q.js} +1 -1
- package/dist/assets/{es-BHqnSjoZ.js → es-FE0sJ9EC.js} +1 -1
- package/dist/assets/{fileQueries-BY-9OWzp.js → fileQueries-BcdWOPlD.js} +1 -1
- package/dist/assets/{fr-BfrlJY9i.js → fr-DNDCSfGt.js} +1 -1
- package/dist/assets/{gitApi-Cr-joUEw.js → gitApi-_P0FsoXe.js} +1 -1
- package/dist/assets/{input-DFLpqjGR.js → input-BO3r20op.js} +1 -1
- package/dist/assets/{insertionBoundaries-X0xbikAA.js → insertionBoundaries-CWoioTdE.js} +1 -1
- package/dist/assets/{ja-DP-7qt-x.js → ja-BDwrk0cM.js} +1 -1
- package/dist/assets/{ko-E2B5vGQP.js → ko-BeeU2o4I.js} +1 -1
- package/dist/assets/{main-D0L7fIg3.js → main-CLj_WS1o.js} +2 -2
- package/dist/assets/{main-BI7fNQoi.js → main-CnSZ89pD.js} +4 -4
- package/dist/assets/{markdown-worker-CTsrtpPh.js → markdown-worker-CZWE1F1M.js} +1 -1
- package/dist/assets/{markdownHeightCache-CYDlisxr.js → markdownHeightCache-Cdd3hkMl.js} +1 -1
- package/dist/assets/{markdownTheme-DyJ4Gfcj.js → markdownTheme-DIB8WVmz.js} +1 -1
- package/dist/assets/miniChat-B9c3y2pD.js +2 -0
- package/dist/assets/{mobile-OcCu_0mG.js → mobile-DvbXUfNS.js} +2 -2
- package/dist/assets/{multirun-B0G_BEIN.js → multirun-CxhTsmRq.js} +1 -1
- package/dist/assets/{persistence-DdUac14I.js → persistence-CVTWFaqe.js} +2 -2
- package/dist/assets/{pl-r8X5gRM5.js → pl-BYuzpcI5.js} +1 -1
- package/dist/assets/{platform-B4KKLnZ5.js → platform-BY_Gtsqr.js} +1 -1
- package/dist/assets/{projectMeta-BNOzn1QS.js → projectMeta-Dl5iPCB6.js} +1 -1
- package/dist/assets/{pt-BR-DcpdXCf5.js → pt-BR-Bn7cwEzv.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-BREc3uhX.js → renderElectronMiniChatApp-CkJ51qCh.js} +2 -2
- package/dist/assets/renderMobileApp-DcjQPhWw.js +28 -0
- package/dist/assets/{runtimeConfig-P8nVMYzE.js → runtimeConfig-Bsk2kWsW.js} +1 -1
- package/dist/assets/runtimeEndpointReset-BBLFgl85.js +147 -0
- package/dist/assets/{sessionChangeSummary-hVby3sJk.js → sessionChangeSummary-DDVT7O5J.js} +1 -1
- package/dist/assets/{sessionLookup-C_9-9ggp.js → sessionLookup-C8VZn0gJ.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-C8_9kHqu.js → sortable-tabs-strip-s7UUxsa6.js} +1 -1
- package/dist/assets/src-BpHDMgud.css +2 -0
- package/dist/assets/{store-BDFdnk6l.js → store-n94dSb3M.js} +2 -2
- package/dist/assets/{terminalApi-947l7Ra3.js → terminalApi-C54_rKEK.js} +1 -1
- package/dist/assets/{textarea-Cgo_nWUU.js → textarea-BjX-KhMN.js} +1 -1
- package/dist/assets/{tooltip-BifLJ8o0.js → tooltip-CQGqGAG_.js} +1 -1
- package/dist/assets/{uk-Di0vl9V-.js → uk-CwpbHEa3.js} +1 -1
- package/dist/assets/{url-Pk6tQ1EZ.js → url-BMjqccCo.js} +1 -1
- package/dist/assets/{useAppFontEffects-CEmOsrr6.js → useAppFontEffects-BIFw36SA.js} +5 -5
- package/dist/assets/{useAssistantUIStore-me6s63b2.js → useAssistantUIStore-Cg9bsT4b.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-CxaK-zXh.js → useDesktopWindowControlsLayout-DoygEcN4.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-BIwv5r5l.js → useEffectiveDirectory-DJSYYkVp.js} +1 -1
- package/dist/assets/{useFontPreferences-BCOBlaAd.js → useFontPreferences-B5_apbBC.js} +1 -1
- package/dist/assets/{useMobileNavigationStore-DHm5kZPx.js → useMobileNavigationStore-DuTu0Mzh.js} +5 -5
- package/dist/assets/{useSessionAutoCleanup-CPLP9KWH.js → useSessionAutoCleanup-C-L9teva.js} +1 -1
- package/dist/assets/{useUIStore-CuaA8-Hp.js → useUIStore-DwpQTjLO.js} +1 -1
- package/dist/assets/{useWorkerHighlightedLines-CZ4ZTWEH.js → useWorkerHighlightedLines-DxTfgdEi.js} +1 -1
- package/dist/assets/{utils-BiK3tBGM.js → utils-81nUI_xp.js} +1 -1
- package/dist/assets/{zh-CN-coFCfeQE.js → zh-CN-BpzFZ-LU.js} +1 -1
- package/dist/assets/{zh-TW-oDRZ1-yb.js → zh-TW-Bopgc5Eo.js} +1 -1
- package/dist/index.html +9 -9
- package/dist/mini-chat.html +9 -9
- package/dist/mobile.html +9 -9
- package/package.json +1 -1
- package/server/index.js +19 -0
- package/server/lib/assistants/DOCUMENTATION.md +21 -6
- package/server/lib/assistants/assign.js +455 -30
- package/server/lib/assistants/assign.test.js +351 -0
- package/server/lib/assistants/contact-active-turn.http.test.js +134 -0
- package/server/lib/assistants/contact-store.js +64 -0
- package/server/lib/assistants/contact-store.test.js +12 -0
- package/server/lib/assistants/contact-tools.js +216 -25
- package/server/lib/assistants/contact-tools.test.js +167 -0
- package/server/lib/assistants/contracts.js +2 -2
- package/server/lib/assistants/harness.js +20 -3
- package/server/lib/assistants/harness.test.js +120 -1
- package/server/lib/assistants/pi-tools.js +50 -3
- package/server/lib/assistants/pi-tools.test.js +25 -0
- package/server/lib/assistants/service.js +320 -27
- package/server/lib/assistants/service.test.js +581 -9
- package/server/lib/llm/DOCUMENTATION.md +31 -18
- package/server/lib/llm/catalog.js +91 -16
- package/server/lib/llm/catalog.test.js +152 -11
- package/server/lib/llm/generate.js +43 -7
- package/server/lib/llm/generate.test.js +98 -1
- package/server/lib/llm/temp-directory.js +34 -13
- package/server/lib/llm/temp-directory.test.js +57 -0
- package/server/lib/notifications/APNS.md +20 -4
- package/server/lib/notifications/DOCUMENTATION.md +5 -0
- package/server/lib/notifications/apns-runtime.js +136 -18
- package/server/lib/notifications/apns-runtime.test.js +128 -0
- package/server/lib/notifications/apns-titles.js +21 -1
- package/server/lib/notifications/live-activity-refresh-runtime.js +185 -0
- package/server/lib/notifications/live-activity-refresh-runtime.test.js +242 -0
- package/server/lib/notifications/runtime.js +93 -2
- package/server/lib/notifications/runtime.test.js +167 -4
- package/server/lib/opencode/settings-helpers.js +12 -0
- package/server/lib/opencode/settings-runtime.js +16 -0
- package/server/lib/opencode/settings-runtime.test.js +21 -0
- package/server/lib/scheduled-tasks/runtime.js +21 -0
- package/server/lib/scheduled-tasks/runtime.test.js +24 -0
- package/dist/assets/AssistantView-BwrcqJzu.js +0 -2
- package/dist/assets/MarkdownRenderer-C49bfcHx.js +0 -2
- package/dist/assets/MobileDetailNavigation-LZU2vFAs.js +0 -1
- package/dist/assets/MobileSurface-OAJjAQCZ.js +0 -2
- package/dist/assets/miniChat-BJ9AclZX.js +0 -2
- package/dist/assets/renderMobileApp-C4igZ42L.js +0 -28
- package/dist/assets/runtimeEndpointReset-6lVRspis.js +0 -147
- package/dist/assets/src-B-P5qIBc.css +0 -2
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Periodic Live Activity snapshot refresh for iOS devices that registered a
|
|
2
|
+
// Live Activity push token. While the app is foreground/connected the UI keeps
|
|
3
|
+
// its own ActivityKit state fresh; once iOS suspends or kills the app, only
|
|
4
|
+
// APNs can move the on-screen activity. Completion/error/title already push
|
|
5
|
+
// event-driven (runtime.js → sendLiveActivityEnd); this runtime covers the
|
|
6
|
+
// busy phase: every interval it recomputes each persisted token's snapshot
|
|
7
|
+
// from the authoritative session-status snapshot and pushes an update only
|
|
8
|
+
// when something actually changed (APNs budgets Live Activity updates per
|
|
9
|
+
// hour, so unchanged snapshots never send).
|
|
10
|
+
//
|
|
11
|
+
// Interval: default 30s, override with OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS
|
|
12
|
+
// (milliseconds; `0` disables the timer). Snapshots use unix seconds, matching
|
|
13
|
+
// the persisted token store (apns-runtime.js parseLiveActivitySnapshot).
|
|
14
|
+
|
|
15
|
+
export const LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS = 30_000;
|
|
16
|
+
export const LIVE_ACTIVITY_REFRESH_ITEM_LIMIT = 4; // mirrors the UI catalog cap
|
|
17
|
+
export const LIVE_ACTIVITY_REFRESH_TITLE_MAX = 80;
|
|
18
|
+
|
|
19
|
+
export const resolveLiveActivityRefreshIntervalMs = (env = process.env) => {
|
|
20
|
+
const raw = env?.OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS;
|
|
21
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
|
22
|
+
return LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS;
|
|
23
|
+
}
|
|
24
|
+
const parsed = Number(raw);
|
|
25
|
+
if (!Number.isFinite(parsed) || parsed < 0) return LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS;
|
|
26
|
+
return Math.floor(parsed);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const liveActivityItemIsWorking = (status) => status !== 'complete' && status !== 'error';
|
|
30
|
+
|
|
31
|
+
// Session-side sub-states the server has no authoritative source for. The UI
|
|
32
|
+
// derives them from live permission/question/message-delta channels; once it is
|
|
33
|
+
// gone we keep the last row state instead of flickering back to `working`.
|
|
34
|
+
const LIVE_ACTIVITY_PRESERVED_ROW_STATUSES = new Set(['permission', 'input', 'tool']);
|
|
35
|
+
|
|
36
|
+
const mapSessionStatusToRowStatus = (sessionStatus) => {
|
|
37
|
+
if (sessionStatus === 'retry') return 'retry';
|
|
38
|
+
if (sessionStatus === 'busy') return 'working';
|
|
39
|
+
return null; // idle / unknown → terminal
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const nextRowStatus = (currentRowStatus, sessionStatus) => {
|
|
43
|
+
if (sessionStatus === 'busy') {
|
|
44
|
+
if (LIVE_ACTIVITY_PRESERVED_ROW_STATUSES.has(currentRowStatus)) return currentRowStatus;
|
|
45
|
+
return 'working'; // including stale → working (server connection is authoritative here)
|
|
46
|
+
}
|
|
47
|
+
if (sessionStatus === 'retry') return 'retry';
|
|
48
|
+
return 'complete';
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const capLiveActivityItems = (items) => {
|
|
52
|
+
if (items.length <= LIVE_ACTIVITY_REFRESH_ITEM_LIMIT) return items;
|
|
53
|
+
const working = items.filter((item) => liveActivityItemIsWorking(item.status));
|
|
54
|
+
const settled = items
|
|
55
|
+
.filter((item) => !liveActivityItemIsWorking(item.status))
|
|
56
|
+
.sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0));
|
|
57
|
+
const next = [...working];
|
|
58
|
+
for (const item of settled) {
|
|
59
|
+
if (next.length >= LIVE_ACTIVITY_REFRESH_ITEM_LIMIT) break;
|
|
60
|
+
next.push(item);
|
|
61
|
+
}
|
|
62
|
+
return next.slice(0, LIVE_ACTIVITY_REFRESH_ITEM_LIMIT);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const liveActivityItemsSignature = (items) => items
|
|
66
|
+
.map((item) => `${item.sessionId}\0${item.title}\0${item.status}\0${item.endedAt ?? ''}`)
|
|
67
|
+
.join('\n');
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Recompute one entry's snapshot from the authoritative session-state snapshot.
|
|
71
|
+
* Returns `{ items }` (unix seconds) when something changed, `null` to skip.
|
|
72
|
+
* Rows for sessions with no live server state are preserved as-is (never guess
|
|
73
|
+
* terminal from missing data); new busy/retry sessions join only when the
|
|
74
|
+
* injected resolver confirms sidebar visibility.
|
|
75
|
+
*/
|
|
76
|
+
export const computeLiveActivityRefreshSnapshot = async (input) => {
|
|
77
|
+
const { entry, sessionStates, resolveSession, nowMs } = input;
|
|
78
|
+
const nowSeconds = Math.floor(nowMs / 1000);
|
|
79
|
+
const previous = Array.isArray(entry.snapshot) ? entry.snapshot : [];
|
|
80
|
+
const items = [];
|
|
81
|
+
const seen = new Set();
|
|
82
|
+
|
|
83
|
+
for (const item of previous) {
|
|
84
|
+
if (!item || typeof item.sessionId !== 'string') continue;
|
|
85
|
+
seen.add(item.sessionId);
|
|
86
|
+
const state = sessionStates[item.sessionId];
|
|
87
|
+
if (!state) {
|
|
88
|
+
items.push(item); // no authoritative state → keep the last known row
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const rowStatus = nextRowStatus(item.status, state.status);
|
|
92
|
+
if (rowStatus === item.status) {
|
|
93
|
+
items.push(item);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (rowStatus === 'complete') {
|
|
97
|
+
items.push({
|
|
98
|
+
...item,
|
|
99
|
+
status: 'complete',
|
|
100
|
+
endedAt: item.endedAt ?? nowSeconds,
|
|
101
|
+
});
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
items.push({ ...item, status: rowStatus });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const additions = [];
|
|
108
|
+
for (const [sessionId, state] of Object.entries(sessionStates || {})) {
|
|
109
|
+
if (seen.has(sessionId)) continue;
|
|
110
|
+
const mapped = mapSessionStatusToRowStatus(state?.status);
|
|
111
|
+
if (mapped === null || mapped === 'complete') continue;
|
|
112
|
+
let candidate = null;
|
|
113
|
+
try {
|
|
114
|
+
candidate = await resolveSession(sessionId);
|
|
115
|
+
} catch {
|
|
116
|
+
candidate = null;
|
|
117
|
+
}
|
|
118
|
+
if (!candidate || candidate.visible !== true) continue;
|
|
119
|
+
additions.push({
|
|
120
|
+
sessionId,
|
|
121
|
+
title: typeof candidate.title === 'string' ? candidate.title.slice(0, LIVE_ACTIVITY_REFRESH_TITLE_MAX) : '',
|
|
122
|
+
status: mapped,
|
|
123
|
+
startedAt: nowSeconds,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const merged = capLiveActivityItems([...items, ...additions]);
|
|
128
|
+
if (liveActivityItemsSignature(merged) === liveActivityItemsSignature(previous)) return null;
|
|
129
|
+
return { items: merged };
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Owns the refresh interval. One tick snapshots the authoritative session
|
|
134
|
+
* states once, then hands every persisted Live Activity entry to
|
|
135
|
+
* `refreshLiveActivityTokens` (apns-runtime.js), which skips entries whose
|
|
136
|
+
* computed snapshot is unchanged. Overlapping ticks are dropped, not queued.
|
|
137
|
+
*/
|
|
138
|
+
export const createLiveActivityRefreshRuntime = (deps) => {
|
|
139
|
+
const {
|
|
140
|
+
intervalMs,
|
|
141
|
+
refreshLiveActivityTokens,
|
|
142
|
+
getSessionStateSnapshot,
|
|
143
|
+
resolveSession,
|
|
144
|
+
} = deps;
|
|
145
|
+
let timer = null;
|
|
146
|
+
let ticking = false;
|
|
147
|
+
|
|
148
|
+
const tick = async () => {
|
|
149
|
+
if (ticking) return;
|
|
150
|
+
ticking = true;
|
|
151
|
+
try {
|
|
152
|
+
const sessionStates = getSessionStateSnapshot();
|
|
153
|
+
const nowMs = Date.now();
|
|
154
|
+
await refreshLiveActivityTokens({
|
|
155
|
+
computeSnapshot: (entry) => computeLiveActivityRefreshSnapshot({
|
|
156
|
+
entry,
|
|
157
|
+
sessionStates,
|
|
158
|
+
resolveSession,
|
|
159
|
+
nowMs,
|
|
160
|
+
}),
|
|
161
|
+
});
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.warn('[Live Activity refresh] tick failed:', error?.message ?? error);
|
|
164
|
+
} finally {
|
|
165
|
+
ticking = false;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const start = () => {
|
|
170
|
+
if (timer || !(intervalMs > 0)) return;
|
|
171
|
+
timer = setInterval(() => {
|
|
172
|
+
void tick();
|
|
173
|
+
}, intervalMs);
|
|
174
|
+
timer.unref?.();
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const dispose = () => {
|
|
178
|
+
if (timer) {
|
|
179
|
+
clearInterval(timer);
|
|
180
|
+
timer = null;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
return { start, dispose, tick };
|
|
185
|
+
};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS,
|
|
5
|
+
computeLiveActivityRefreshSnapshot,
|
|
6
|
+
createLiveActivityRefreshRuntime,
|
|
7
|
+
resolveLiveActivityRefreshIntervalMs,
|
|
8
|
+
} from './live-activity-refresh-runtime.js';
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.useRealTimers();
|
|
12
|
+
vi.restoreAllMocks();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('resolveLiveActivityRefreshIntervalMs', () => {
|
|
16
|
+
it('defaults to 30s', () => {
|
|
17
|
+
expect(resolveLiveActivityRefreshIntervalMs({})).toBe(LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS);
|
|
18
|
+
expect(resolveLiveActivityRefreshIntervalMs({
|
|
19
|
+
OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS: ' ',
|
|
20
|
+
})).toBe(LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('honours explicit overrides including disable', () => {
|
|
24
|
+
expect(resolveLiveActivityRefreshIntervalMs({
|
|
25
|
+
OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS: '45000',
|
|
26
|
+
})).toBe(45_000);
|
|
27
|
+
expect(resolveLiveActivityRefreshIntervalMs({
|
|
28
|
+
OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS: '0',
|
|
29
|
+
})).toBe(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('falls back to the default for invalid values', () => {
|
|
33
|
+
expect(resolveLiveActivityRefreshIntervalMs({
|
|
34
|
+
OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS: 'abc',
|
|
35
|
+
})).toBe(LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS);
|
|
36
|
+
expect(resolveLiveActivityRefreshIntervalMs({
|
|
37
|
+
OPENCHAMBER_LIVE_ACTIVITY_REFRESH_INTERVAL_MS: '-5',
|
|
38
|
+
})).toBe(LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('computeLiveActivityRefreshSnapshot', () => {
|
|
43
|
+
const baseInput = (overrides = {}) => ({
|
|
44
|
+
entry: { token: 't', sessionId: 'live', snapshot: [] },
|
|
45
|
+
sessionStates: {},
|
|
46
|
+
resolveSession: async () => null,
|
|
47
|
+
nowMs: 1_700_000_000_000,
|
|
48
|
+
...overrides,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('maps idle to complete with endedAt and marks the snapshot changed', async () => {
|
|
52
|
+
const result = await computeLiveActivityRefreshSnapshot(baseInput({
|
|
53
|
+
entry: {
|
|
54
|
+
token: 't',
|
|
55
|
+
sessionId: 'live',
|
|
56
|
+
snapshot: [{ sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 }],
|
|
57
|
+
},
|
|
58
|
+
sessionStates: { ses_1: { status: 'idle' } },
|
|
59
|
+
}));
|
|
60
|
+
expect(result).toEqual({
|
|
61
|
+
items: [{
|
|
62
|
+
sessionId: 'ses_1',
|
|
63
|
+
title: 'One',
|
|
64
|
+
status: 'complete',
|
|
65
|
+
startedAt: 1,
|
|
66
|
+
endedAt: Math.floor(1_700_000_000_000 / 1000),
|
|
67
|
+
}],
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('keeps server-side-only sub-states while the session stays busy', async () => {
|
|
72
|
+
const snapshot = [{ sessionId: 'ses_1', title: 'One', status: 'permission', startedAt: 1 }];
|
|
73
|
+
const result = await computeLiveActivityRefreshSnapshot(baseInput({
|
|
74
|
+
entry: { token: 't', sessionId: 'live', snapshot },
|
|
75
|
+
sessionStates: { ses_1: { status: 'busy' } },
|
|
76
|
+
}));
|
|
77
|
+
expect(result).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('recovers stale rows to working while busy', async () => {
|
|
81
|
+
const result = await computeLiveActivityRefreshSnapshot(baseInput({
|
|
82
|
+
entry: {
|
|
83
|
+
token: 't',
|
|
84
|
+
sessionId: 'live',
|
|
85
|
+
snapshot: [{ sessionId: 'ses_1', title: 'One', status: 'stale', startedAt: 1 }],
|
|
86
|
+
},
|
|
87
|
+
sessionStates: { ses_1: { status: 'busy' } },
|
|
88
|
+
}));
|
|
89
|
+
expect(result?.items[0].status).toBe('working');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('maps retry sessions to the retry row status', async () => {
|
|
93
|
+
const result = await computeLiveActivityRefreshSnapshot(baseInput({
|
|
94
|
+
entry: {
|
|
95
|
+
token: 't',
|
|
96
|
+
sessionId: 'live',
|
|
97
|
+
snapshot: [{ sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 }],
|
|
98
|
+
},
|
|
99
|
+
sessionStates: { ses_1: { status: 'retry' } },
|
|
100
|
+
}));
|
|
101
|
+
expect(result?.items[0].status).toBe('retry');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('preserves rows whose session has no authoritative server state', async () => {
|
|
105
|
+
const snapshot = [{ sessionId: 'ses_gone', title: 'Gone', status: 'working', startedAt: 1 }];
|
|
106
|
+
const result = await computeLiveActivityRefreshSnapshot(baseInput({
|
|
107
|
+
entry: { token: 't', sessionId: 'live', snapshot },
|
|
108
|
+
sessionStates: {},
|
|
109
|
+
}));
|
|
110
|
+
expect(result).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('adds new busy sessions only when the resolver marks them visible', async () => {
|
|
114
|
+
const resolveSession = vi.fn(async (sessionId) => (
|
|
115
|
+
sessionId === 'ses_child'
|
|
116
|
+
? { title: 'Child', visible: false }
|
|
117
|
+
: { title: 'New Task', visible: true }
|
|
118
|
+
));
|
|
119
|
+
const result = await computeLiveActivityRefreshSnapshot(baseInput({
|
|
120
|
+
entry: { token: 't', sessionId: 'live', snapshot: [] },
|
|
121
|
+
sessionStates: {
|
|
122
|
+
ses_new: { status: 'busy' },
|
|
123
|
+
ses_child: { status: 'busy' },
|
|
124
|
+
ses_idle: { status: 'idle' },
|
|
125
|
+
},
|
|
126
|
+
resolveSession,
|
|
127
|
+
}));
|
|
128
|
+
expect(result?.items).toHaveLength(1);
|
|
129
|
+
expect(result?.items[0]).toMatchObject({ sessionId: 'ses_new', title: 'New Task', status: 'working' });
|
|
130
|
+
expect(resolveSession).not.toHaveBeenCalledWith('ses_idle');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('skips new sessions when the resolver throws or resolves empty', async () => {
|
|
134
|
+
const result = await computeLiveActivityRefreshSnapshot(baseInput({
|
|
135
|
+
entry: { token: 't', sessionId: 'live', snapshot: [] },
|
|
136
|
+
sessionStates: { ses_new: { status: 'busy' } },
|
|
137
|
+
resolveSession: async () => {
|
|
138
|
+
throw new Error('boom');
|
|
139
|
+
},
|
|
140
|
+
}));
|
|
141
|
+
expect(result).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('caps merged rows at the catalog limit, working rows first', async () => {
|
|
145
|
+
const snapshot = [1, 2, 3, 4, 5].map((n) => ({
|
|
146
|
+
sessionId: `ses_${n}`,
|
|
147
|
+
title: `S${n}`,
|
|
148
|
+
status: 'complete',
|
|
149
|
+
startedAt: n,
|
|
150
|
+
endedAt: n,
|
|
151
|
+
}));
|
|
152
|
+
const result = await computeLiveActivityRefreshSnapshot(baseInput({
|
|
153
|
+
entry: { token: 't', sessionId: 'live', snapshot },
|
|
154
|
+
sessionStates: { ses_new: { status: 'busy' } },
|
|
155
|
+
resolveSession: async () => ({ title: 'New', visible: true }),
|
|
156
|
+
}));
|
|
157
|
+
expect(result?.items).toHaveLength(4);
|
|
158
|
+
expect(result?.items.some((item) => item.sessionId === 'ses_new')).toBe(true);
|
|
159
|
+
// Most recently settled rows survive the cap.
|
|
160
|
+
expect(result?.items.some((item) => item.sessionId === 'ses_5')).toBe(true);
|
|
161
|
+
expect(result?.items.some((item) => item.sessionId === 'ses_1')).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('returns null when nothing changed', async () => {
|
|
165
|
+
const snapshot = [
|
|
166
|
+
{ sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 },
|
|
167
|
+
{ sessionId: 'ses_2', title: 'Two', status: 'complete', startedAt: 2, endedAt: 3 },
|
|
168
|
+
];
|
|
169
|
+
const result = await computeLiveActivityRefreshSnapshot(baseInput({
|
|
170
|
+
entry: { token: 't', sessionId: 'live', snapshot },
|
|
171
|
+
sessionStates: { ses_1: { status: 'busy' } },
|
|
172
|
+
}));
|
|
173
|
+
expect(result).toBeNull();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('createLiveActivityRefreshRuntime', () => {
|
|
178
|
+
it('ticks the refresh with one session-state snapshot for all entries', async () => {
|
|
179
|
+
const refreshLiveActivityTokens = vi.fn(async () => {});
|
|
180
|
+
const runtime = createLiveActivityRefreshRuntime({
|
|
181
|
+
intervalMs: LIVE_ACTIVITY_REFRESH_DEFAULT_INTERVAL_MS,
|
|
182
|
+
refreshLiveActivityTokens,
|
|
183
|
+
getSessionStateSnapshot: () => ({ ses_1: { status: 'busy' } }),
|
|
184
|
+
resolveSession: async () => null,
|
|
185
|
+
});
|
|
186
|
+
await runtime.tick();
|
|
187
|
+
|
|
188
|
+
expect(refreshLiveActivityTokens).toHaveBeenCalledTimes(1);
|
|
189
|
+
const arg = refreshLiveActivityTokens.mock.calls[0][0];
|
|
190
|
+
expect(typeof arg.computeSnapshot).toBe('function');
|
|
191
|
+
const result = await arg.computeSnapshot({
|
|
192
|
+
token: 't',
|
|
193
|
+
sessionId: 'live',
|
|
194
|
+
snapshot: [{ sessionId: 'ses_1', title: 'One', status: 'working', startedAt: 1 }],
|
|
195
|
+
});
|
|
196
|
+
expect(result).toBeNull();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('drops overlapping ticks instead of queueing them', async () => {
|
|
200
|
+
let release;
|
|
201
|
+
const gate = new Promise((resolve) => { release = resolve; });
|
|
202
|
+
const refreshLiveActivityTokens = vi.fn(async () => { await gate; });
|
|
203
|
+
const runtime = createLiveActivityRefreshRuntime({
|
|
204
|
+
intervalMs: 1000,
|
|
205
|
+
refreshLiveActivityTokens,
|
|
206
|
+
getSessionStateSnapshot: () => ({}),
|
|
207
|
+
resolveSession: async () => null,
|
|
208
|
+
});
|
|
209
|
+
const first = runtime.tick();
|
|
210
|
+
await runtime.tick(); // overlapping → dropped
|
|
211
|
+
release();
|
|
212
|
+
await first;
|
|
213
|
+
expect(refreshLiveActivityTokens).toHaveBeenCalledTimes(1);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('never schedules a timer when disabled (interval 0) and disposes cleanly', async () => {
|
|
217
|
+
vi.useFakeTimers();
|
|
218
|
+
const refreshLiveActivityTokens = vi.fn(async () => {});
|
|
219
|
+
const runtime = createLiveActivityRefreshRuntime({
|
|
220
|
+
intervalMs: 0,
|
|
221
|
+
refreshLiveActivityTokens,
|
|
222
|
+
getSessionStateSnapshot: () => ({}),
|
|
223
|
+
resolveSession: async () => null,
|
|
224
|
+
});
|
|
225
|
+
runtime.start();
|
|
226
|
+
await vi.advanceTimersByTimeAsync(120_000);
|
|
227
|
+
expect(refreshLiveActivityTokens).not.toHaveBeenCalled();
|
|
228
|
+
|
|
229
|
+
const enabled = createLiveActivityRefreshRuntime({
|
|
230
|
+
intervalMs: 30_000,
|
|
231
|
+
refreshLiveActivityTokens,
|
|
232
|
+
getSessionStateSnapshot: () => ({}),
|
|
233
|
+
resolveSession: async () => null,
|
|
234
|
+
});
|
|
235
|
+
enabled.start();
|
|
236
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
237
|
+
expect(refreshLiveActivityTokens).toHaveBeenCalledTimes(1);
|
|
238
|
+
enabled.dispose();
|
|
239
|
+
await vi.advanceTimersByTimeAsync(120_000);
|
|
240
|
+
expect(refreshLiveActivityTokens).toHaveBeenCalledTimes(1);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -316,6 +316,34 @@ export const createNotificationTriggerRuntime = (deps) => {
|
|
|
316
316
|
return isHiddenFromNavSession(meta);
|
|
317
317
|
};
|
|
318
318
|
|
|
319
|
+
// Live Activity refresh ticks carry no directory, so the cache lookup must
|
|
320
|
+
// match the session under ANY cached directory key before falling back to the
|
|
321
|
+
// directory-less OpenCode session endpoint.
|
|
322
|
+
const lookupSessionMetaAnyDirectory = (sessionId) => {
|
|
323
|
+
const direct = getCachedSessionMeta(sessionId, undefined);
|
|
324
|
+
if (direct) return direct;
|
|
325
|
+
const suffix = `\0${sessionId}`;
|
|
326
|
+
const now = Date.now();
|
|
327
|
+
for (const [key, entry] of sessionMetaCache) {
|
|
328
|
+
if (!key.endsWith(suffix)) continue;
|
|
329
|
+
if (now - entry.at <= SESSION_META_CACHE_TTL_MS) return entry;
|
|
330
|
+
}
|
|
331
|
+
return undefined;
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// Sidebar candidate for a new Live Activity snapshot row: title + visibility
|
|
335
|
+
// under the same suppression rules as ordinary push. Cache-first; a missing
|
|
336
|
+
// meta resolves as not-visible (hidden sessions must never leak into a push).
|
|
337
|
+
const resolveLiveActivitySessionCandidate = async (sessionId) => {
|
|
338
|
+
const cached = lookupSessionMetaAnyDirectory(sessionId);
|
|
339
|
+
if (cached?.complete) {
|
|
340
|
+
return { title: cached.title ?? '', visible: !isHiddenFromNavSession(cached) };
|
|
341
|
+
}
|
|
342
|
+
const meta = await fetchSessionMeta(sessionId, undefined);
|
|
343
|
+
if (!meta) return null;
|
|
344
|
+
return { title: meta.title ?? '', visible: !isHiddenFromNavSession(meta) };
|
|
345
|
+
};
|
|
346
|
+
|
|
319
347
|
const extractSessionIdFromPayload = (payload) => {
|
|
320
348
|
if (!payload || typeof payload !== 'object') return null;
|
|
321
349
|
const props = payload.properties;
|
|
@@ -650,7 +678,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
|
|
650
678
|
|
|
651
679
|
const settings = await readSettingsFromDisk();
|
|
652
680
|
|
|
653
|
-
if (settings.
|
|
681
|
+
if (settings.notifyOnPermission === false) {
|
|
654
682
|
return;
|
|
655
683
|
}
|
|
656
684
|
|
|
@@ -726,6 +754,8 @@ export const createNotificationTriggerRuntime = (deps) => {
|
|
|
726
754
|
// full text; APNs with the generic per-type title and the session name as
|
|
727
755
|
// body, so the relay never sees content).
|
|
728
756
|
const sendGoalSettlePush = async ({ sessionId, directory, status, title, body }) => {
|
|
757
|
+
const settings = await readSettingsFromDisk();
|
|
758
|
+
if (settings.notifyOnGoals === false) return;
|
|
729
759
|
let sessionName = '';
|
|
730
760
|
try {
|
|
731
761
|
const base = buildOpenCodeUrl(`/session/${encodeURIComponent(sessionId)}`, '');
|
|
@@ -759,6 +789,65 @@ export const createNotificationTriggerRuntime = (deps) => {
|
|
|
759
789
|
);
|
|
760
790
|
};
|
|
761
791
|
|
|
792
|
+
// Scheduled task run notification: fired by the scheduled-tasks runtime when
|
|
793
|
+
// a scheduled (non-manual) run settles. Desktop/UI SSE carry the task name
|
|
794
|
+
// and, on failure, the error text; push channels follow the same generic
|
|
795
|
+
// contract as other triggers (APNs title is the localized scenario, body is
|
|
796
|
+
// the task name). Manual runs skip — the user just clicked "run now".
|
|
797
|
+
const sendScheduledTaskRunNotification = async ({
|
|
798
|
+
projectID,
|
|
799
|
+
taskID,
|
|
800
|
+
taskName,
|
|
801
|
+
status,
|
|
802
|
+
sessionId,
|
|
803
|
+
reason,
|
|
804
|
+
errorMessage,
|
|
805
|
+
} = {}) => {
|
|
806
|
+
if (typeof projectID !== 'string' || !projectID) return;
|
|
807
|
+
if (typeof taskID !== 'string' || !taskID) return;
|
|
808
|
+
if (reason === 'manual') return;
|
|
809
|
+
if (status !== 'success' && status !== 'error') return;
|
|
810
|
+
const settings = await readSettingsFromDisk();
|
|
811
|
+
if (settings.notifyOnScheduledTasks === false) return;
|
|
812
|
+
|
|
813
|
+
const name = typeof taskName === 'string' && taskName.trim() ? taskName.trim() : 'Schedule';
|
|
814
|
+
const isError = status === 'error';
|
|
815
|
+
const title = isError ? 'Scheduled task failed' : 'Scheduled task completed';
|
|
816
|
+
const trimmedError = typeof errorMessage === 'string' && errorMessage.trim() ? errorMessage.trim() : '';
|
|
817
|
+
const body = isError && trimmedError ? `${name}: ${trimmedError}` : name;
|
|
818
|
+
const tag = `scheduled-${projectID}-${taskID}`;
|
|
819
|
+
|
|
820
|
+
if (settings.nativeNotificationsEnabled) {
|
|
821
|
+
const notificationPayload = {
|
|
822
|
+
kind: isError ? 'task-error' : 'task-complete',
|
|
823
|
+
title,
|
|
824
|
+
body,
|
|
825
|
+
tag,
|
|
826
|
+
...(sessionId ? { sessionId } : {}),
|
|
827
|
+
requireHidden: false,
|
|
828
|
+
};
|
|
829
|
+
const desktopNotificationDelivered = emitDesktopNotification(notificationPayload);
|
|
830
|
+
broadcastUiNotification(notificationPayload, { desktopNotificationDelivered });
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
await fanoutPush(
|
|
834
|
+
{
|
|
835
|
+
title,
|
|
836
|
+
body,
|
|
837
|
+
tag,
|
|
838
|
+
data: {
|
|
839
|
+
url: typeof sessionId === 'string' && sessionId
|
|
840
|
+
? buildSessionDeepLinkUrl(sessionId)
|
|
841
|
+
: '/',
|
|
842
|
+
...(typeof sessionId === 'string' && sessionId ? { sessionId } : {}),
|
|
843
|
+
sessionName: name,
|
|
844
|
+
type: isError ? 'task_error' : 'task_complete',
|
|
845
|
+
},
|
|
846
|
+
},
|
|
847
|
+
{ requireNoSse: true },
|
|
848
|
+
);
|
|
849
|
+
};
|
|
850
|
+
|
|
762
851
|
const contactNotificationTitle = (name) => {
|
|
763
852
|
if (typeof name !== 'string' || !name.trim()) return 'Assistant';
|
|
764
853
|
const stripped = name.replace(/^(?:\p{Extended_Pictographic}|\p{Regional_Indicator}|[\uFE0F\u200D])+\s*/u, '').trim();
|
|
@@ -768,7 +857,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
|
|
768
857
|
const sendContactTurnNotification = async ({ assistantID, name, body, status } = {}) => {
|
|
769
858
|
if (typeof assistantID !== 'string' || !assistantID.trim()) return;
|
|
770
859
|
const settings = await readSettingsFromDisk();
|
|
771
|
-
if (settings.
|
|
860
|
+
if (settings.notifyOnAssistants === false) return;
|
|
772
861
|
|
|
773
862
|
const title = contactNotificationTitle(name);
|
|
774
863
|
const rawBody = typeof body === 'string' ? body : '';
|
|
@@ -818,5 +907,7 @@ export const createNotificationTriggerRuntime = (deps) => {
|
|
|
818
907
|
clearPendingPushBadge,
|
|
819
908
|
sendGoalSettlePush,
|
|
820
909
|
sendContactTurnNotification,
|
|
910
|
+
sendScheduledTaskRunNotification,
|
|
911
|
+
resolveLiveActivitySessionCandidate,
|
|
821
912
|
};
|
|
822
913
|
};
|