Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app-prefixable/src/components/telegram-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ export function TelegramSettings(props: Props) {
<p>Config: {report().config.status === "ok" ? "loaded" : "invalid"}</p>
<p>Telegram API: {report().dependencies.telegramApi.status} - {report().dependencies.telegramApi.message}</p>
<p>OpenCode API: {report().dependencies.openCodeApi.status} - {report().dependencies.openCodeApi.message}</p>
<Show when={(report().dependencies.openCodeSources || []).length > 0}>
<Show when={(report().dependencies.openCodeSources || []).length > 1}>
<div class="space-y-1 pt-1">
{(report().dependencies.openCodeSources || []).map((item) => (
<p>Source {item.sourceId}: {item.status} - {item.message}</p>
Expand Down
83 changes: 73 additions & 10 deletions app-prefixable/src/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,70 @@ function sortParts(parts: Part[]): Part[] {
return [...withId, ...withoutId]
}

function toolEnd(part: Extract<Part, { type: "tool" }>): 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<Part, { type: "tool" }>): 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<Part, { type: "tool" }>): 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."
Expand Down Expand Up @@ -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))
Expand Down
64 changes: 23 additions & 41 deletions app-prefixable/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -560,29 +560,13 @@ export function Session() {
})(),
);
const [notifyDenied, setNotifyDenied] = createSignal(false);
const [notifySourceId, setNotifySourceId] = createSignal<string | undefined>();
const [notifySourceReady, setNotifySourceReady] = createSignal(false);
const notifyVersion = { value: 0 };
const deniedTimer = { id: null as ReturnType<typeof setTimeout> | 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) {
Expand All @@ -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))
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveTelegramSourceId(url, dir) can return undefined (e.g. multi-source enabled but no matching source for this directory). In that case this code calls getSessionAlarm without a sourceId, which will query the default source and can display the wrong alarm state for the current directory. Handle the undefined case explicitly (skip the request and keep local state, or surface a warning/error) rather than falling back to default implicitly.

Suggested change
.then((sourceId) => getSessionAlarm(url, id, sourceId))
.then((sourceId) => {
if (sourceId === undefined) return undefined;
return getSessionAlarm(url, id, sourceId);
})

Copilot uses AI. Check for mistakes.
.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))
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If resolveTelegramSourceId(url, dir) resolves to undefined, this still calls setSessionAlarm without a sourceId, which will update the default source. In multi-source setups this can write the alarm state to the wrong source/directory. Only call setSessionAlarm once a sourceId is resolved; otherwise log and treat it as a failed sync.

Suggested change
.then((sourceId) => setSessionAlarm(url, id, enabled, sourceId, dir))
.then((sourceId) => {
if (sourceId === undefined) {
console.warn("[session] telegram session alarm sync failed: unable to resolve telegram source", {
sessionId: id,
enabled,
directory: dir,
})
return false
}
return setSessionAlarm(url, id, enabled, sourceId, dir)
})

Copilot uses AI. Check for mistakes.
.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() {
Expand Down
3 changes: 1 addition & 2 deletions app-prefixable/src/pages/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2458,7 +2457,7 @@ Add your project-specific instructions here.
<button
type="button"
onClick={() => void toggleTelegramAlarmChannel()}
disabled={telegramAlarmSaving() || (!telegramAlarmReady() && !alarmChannels().telegram)}
disabled={telegramAlarmSaving()}
class="relative w-10 h-5 rounded-full transition-colors disabled:opacity-50"
style={{
background: alarmChannels().telegram ? "var(--interactive-base)" : "var(--surface-inset)",
Expand Down
13 changes: 11 additions & 2 deletions app-prefixable/src/utils/extended-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,12 +314,21 @@ export async function getSessionAlarm(serverUrl: string, sessionId: string, sour
}

/** Update session alarm state on the server (Telegram bridge). Returns true on success. */
export async function setSessionAlarm(serverUrl: string, sessionId: string, enabled: boolean, sourceId?: string): Promise<boolean> {
export async function setSessionAlarm(
serverUrl: string,
sessionId: string,
enabled: boolean,
sourceId?: string,
directory?: string,
): Promise<boolean> {
const source = sourceId?.trim()
const dir = directory?.trim()
const base = source ? { sessionId, sourceId: source, enabled } : { sessionId, enabled }
const body = dir ? { ...base, directory: dir } : base
const res = await fetch(`${serverUrl}/api/ext/telegram/session-alarm`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(source ? { sessionId, sourceId: source, enabled } : { sessionId, enabled }),
body: JSON.stringify(body),
}).catch(() => null)
if (!res) {
console.warn("[extended-api] setSessionAlarm: network error for session", sessionId)
Expand Down
4 changes: 2 additions & 2 deletions app-prefixable/tests/telegram-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@ describe("telegram bridge config and cache", () => {
expect(report.status).toBe("healthy");
expect(report.dependencies.telegramApi.status).toBe("ok");
expect(report.dependencies.openCodeApi.status).toBe("ok");
expect(report.dependencies.openCodeApi.message).toBe("OpenCode API is reachable for sources: default");
expect(report.dependencies.openCodeApi.message).toBe("OpenCode API is reachable");
} finally {
globalThis.fetch = originalFetch;
}
Expand Down Expand Up @@ -558,7 +558,7 @@ describe("telegram bridge config and cache", () => {
expect(report.status).toBe("degraded");
expect(report.dependencies.telegramApi.status).toBe("error");
expect(report.dependencies.openCodeApi.status).toBe("ok");
expect(report.dependencies.openCodeApi.message).toBe("OpenCode API is reachable for sources: default");
expect(report.dependencies.openCodeApi.message).toBe("OpenCode API is reachable");
} finally {
globalThis.fetch = originalFetch;
}
Expand Down
Loading