diff --git a/app-prefixable/src/components/telegram-settings.tsx b/app-prefixable/src/components/telegram-settings.tsx
index 3f89c92c..b2e18e3f 100644
--- a/app-prefixable/src/components/telegram-settings.tsx
+++ b/app-prefixable/src/components/telegram-settings.tsx
@@ -274,7 +274,7 @@ export function TelegramSettings(props: Props) {
Config: {report().config.status === "ok" ? "loaded" : "invalid"}
Telegram API: {report().dependencies.telegramApi.status} - {report().dependencies.telegramApi.message}
OpenCode API: {report().dependencies.openCodeApi.status} - {report().dependencies.openCodeApi.message}
- 0}>
+ 1}>
{(report().dependencies.openCodeSources || []).map((item) => (
Source {item.sourceId}: {item.status} - {item.message}
diff --git a/app-prefixable/src/context/sync.tsx b/app-prefixable/src/context/sync.tsx
index 0b303b00..b84641fc 100644
--- a/app-prefixable/src/context/sync.tsx
+++ b/app-prefixable/src/context/sync.tsx
@@ -58,6 +58,70 @@ function sortParts(parts: Part[]): Part[] {
return [...withId, ...withoutId]
}
+function toolEnd(part: Extract
): number {
+ const state = part.state
+ if (state.status === "completed") return state.time.end
+ if (state.status === "error") return state.time.end
+ return 0
+}
+
+function toolStart(part: Extract): number {
+ const state = part.state
+ if (state.status === "running") return state.time.start
+ if (state.status === "completed") return state.time.start
+ if (state.status === "error") return state.time.start
+ return 0
+}
+
+function toolRank(part: Extract): number {
+ const state = part.state
+ if (state.status === "pending") return 1
+ if (state.status === "running") return 2
+ return 3
+}
+
+function mergePart(existing: Part, synced: Part): Part {
+ if (existing.type !== "tool") return synced
+ if (synced.type !== "tool") return synced
+
+ const existingEnd = toolEnd(existing)
+ const syncedEnd = toolEnd(synced)
+ if (existingEnd > syncedEnd) return existing
+ if (syncedEnd > existingEnd) return synced
+
+ const existingStart = toolStart(existing)
+ const syncedStart = toolStart(synced)
+ if (existingStart > syncedStart) return existing
+ if (syncedStart > existingStart) return synced
+
+ const existingRank = toolRank(existing)
+ const syncedRank = toolRank(synced)
+ if (existingRank > syncedRank) return existing
+ if (syncedRank > existingRank) return synced
+
+ return synced
+}
+
+function mergeMessage(existing: MessageWithParts, synced: MessageWithParts): MessageWithParts {
+ const map = new Map(existing.parts.map((part) => [part.id, part]))
+ const merged = synced.parts.map((part) => {
+ const current = map.get(part.id)
+ if (!current) return part
+ return mergePart(current, part)
+ })
+ const ids = new Set(merged.map((part) => part.id))
+
+ for (const part of existing.parts) {
+ if (ids.has(part.id)) continue
+ merged.push(part)
+ }
+
+ return {
+ info: synced.info,
+ parts: sortParts(merged),
+ }
+}
+
function errorText(err: unknown) {
if (err instanceof Error && err.message.trim()) return err.message
return "Failed to bootstrap app state from API."
@@ -405,19 +469,18 @@ export function SyncProvider(props: ParentProps) {
setStore("message", sessionID, (existing: MessageWithParts[]) => {
if (!existing || existing.length === 0) return synced
- // Merge: use existing message if it has more recent parts
- const merged = synced.map((s) => {
- const e = existing.find((m) => m.info.id === s.info.id)
- if (!e) return s
- // Keep existing if it has more parts (SSE updates arrived)
- return e.parts.length >= s.parts.length ? e : s
+ const map = new Map(existing.map((msg) => [msg.info.id, msg]))
+ const merged = synced.map((msg) => {
+ const current = map.get(msg.info.id)
+ if (!current) return msg
+ return mergeMessage(current, msg)
})
+ const ids = new Set(merged.map((msg) => msg.info.id))
// Add any messages from existing that aren't in synced (new SSE messages)
- for (const e of existing) {
- if (!merged.find((m) => m.info.id === e.info.id)) {
- merged.push(e)
- }
+ for (const msg of existing) {
+ if (ids.has(msg.info.id)) continue
+ merged.push(msg)
}
return merged.sort((a, b) => cmp(a.info.id, b.info.id))
diff --git a/app-prefixable/src/pages/session.tsx b/app-prefixable/src/pages/session.tsx
index f6c5eca7..b1611a45 100644
--- a/app-prefixable/src/pages/session.tsx
+++ b/app-prefixable/src/pages/session.tsx
@@ -560,29 +560,13 @@ export function Session() {
})(),
);
const [notifyDenied, setNotifyDenied] = createSignal(false);
- const [notifySourceId, setNotifySourceId] = createSignal();
- const [notifySourceReady, setNotifySourceReady] = createSignal(false);
const notifyVersion = { value: 0 };
const deniedTimer = { id: null as ReturnType | null };
onCleanup(() => { if (deniedTimer.id !== null) clearTimeout(deniedTimer.id) });
- createEffect(() => {
- const dir = directory || base64Decode(params.dir);
- setNotifySourceReady(false);
- let cancelled = false;
- onCleanup(() => { cancelled = true });
- resolveTelegramSourceId(url, dir).then((sourceId) => {
- if (cancelled) return;
- setNotifySourceId(sourceId);
- setNotifySourceReady(true);
- });
- });
-
// Re-read notification state when session changes, and seed from server alarm state
createEffect(() => {
const id = params.id;
- const sourceReady = notifySourceReady();
- const sourceId = notifySourceId();
const dir = directory || base64Decode(params.dir);
setNotifyDenied(false);
if (!id) {
@@ -595,40 +579,38 @@ export function Session() {
if (migrated) writeNotifyMap(map);
const local = notifyEnabledForSession(map, id, dir);
setNotifyEnabled(local);
- if (!sourceReady) return;
// Cancellation flag for stale responses
let cancelled = false;
const version = notifyVersion.value;
onCleanup(() => { cancelled = true });
// Then fetch server-side alarm state asynchronously
- getSessionAlarm(url, id, sourceId).then((state) => {
- // Skip if effect was cleaned up, session changed, or local state was updated
- if (cancelled || !state || params.id !== id) return;
- if (notifyVersion.value !== version) return;
- setNotifyEnabled(state.enabled);
- // Sync localStorage to match server truth
- const map = readNotifyMap();
- writeNotifySessionEnabled(map, id, state.enabled, dir);
- writeNotifyMap(map);
- });
+ resolveTelegramSourceId(url, dir)
+ .then((sourceId) => getSessionAlarm(url, id, sourceId))
+ .then((state) => {
+ // Skip if effect was cleaned up, session changed, or local state was updated
+ if (cancelled || !state || params.id !== id) return;
+ if (notifyVersion.value !== version) return;
+ setNotifyEnabled(state.enabled);
+ // Sync localStorage to match server truth
+ const map = readNotifyMap();
+ writeNotifySessionEnabled(map, id, state.enabled, dir);
+ writeNotifyMap(map);
+ });
});
/** Mirror bell toggle to server-side alarm state (fire-and-forget). */
function syncAlarmToServer(id: string, enabled: boolean) {
- const sourceId = notifySourceId();
- if (sourceId) {
- setSessionAlarm(url, id, enabled, sourceId);
- return;
- }
- const dir = directory || base64Decode(params.dir);
- resolveTelegramSourceId(url, dir).then((resolved) => {
- if (resolved) {
- setNotifySourceId(resolved);
- setSessionAlarm(url, id, enabled, resolved);
- return;
- }
- console.warn("[session] skip setSessionAlarm: telegram source id unresolved", { id, dir });
- });
+ const dir = directory || base64Decode(params.dir)
+ console.log("[session] syncing telegram session alarm", { sessionId: id, enabled, directory: dir })
+ resolveTelegramSourceId(url, dir)
+ .then((sourceId) => setSessionAlarm(url, id, enabled, sourceId, dir))
+ .then((ok) => {
+ if (ok) {
+ console.log("[session] telegram session alarm synced", { sessionId: id, enabled, directory: dir })
+ return
+ }
+ console.warn("[session] telegram session alarm sync failed", { sessionId: id, enabled, directory: dir })
+ })
}
function toggleNotify() {
diff --git a/app-prefixable/src/pages/settings.tsx b/app-prefixable/src/pages/settings.tsx
index 89552493..dc6f79ea 100644
--- a/app-prefixable/src/pages/settings.tsx
+++ b/app-prefixable/src/pages/settings.tsx
@@ -138,7 +138,6 @@ export function Settings() {
async function toggleTelegramAlarmChannel() {
if (telegramAlarmSaving()) return
const current = alarmChannels().telegram
- if (!telegramAlarmReady() && !current) return
setTelegramAlarmSaving(true)
const target = !current
@@ -2458,7 +2457,7 @@ Add your project-specific instructions here.