Files
openchamber/packages/ui/src/apps/mobileWidgetSnapshot.ts
T

128 lines
5.1 KiB
TypeScript
Raw Normal View History

import type { Session } from '@opencode-ai/sdk/v2';
import type { ProjectEntry } from '@/lib/api/types';
import { useUIStore } from '@/stores/useUIStore';
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
import { useNotificationStore } from '@/sync/notification-store';
import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering';
import { getRuntimeKey } from '@/lib/runtime-switch';
/**
* Builds the lightweight session overview the native iOS widgets render (home medium,
* lock-screen, Control Center). The widget process can't see the WebView, so the native
* shell pulls this snapshot via `window.__OPENCHAMBER_WIDGET_SNAPSHOT__()` on
* background/activate, writes it to the shared App Group, and reloads the widget timelines
* (see SceneDelegate.writeWidgetSnapshot). Mirrors the sidebar's attention logic so the
* widget's "needs attention" mark matches the in-app unread dot exactly:
* needsAttention = unseenCount > 0 && (!isSubtask || notifyOnSubtasks)
*/
export interface MobileWidgetSession {
id: string;
title: string;
/** True when the session needs attention (unread + honouring the subtask setting). */
unread: boolean;
/** Project label for the session's directory (matched project name, else folder name). */
project: string;
}
export interface MobileWidgetSnapshot {
/** Runtime instance that owns all session IDs and paths in this snapshot. */
runtimeKey: string;
/** Count of sessions needing attention — same signal that drives the app-icon badge. */
attentionCount: number;
/** Top-level sessions in the app's shared lifecycle order (capped for the medium widget). */
recentSessions: MobileWidgetSession[];
}
const RECENT_LIMIT = 6;
const parentIdOf = (session: Session): string | null =>
(session as Session & { parentID?: string | null }).parentID ?? null;
const basename = (path: string): string => {
const trimmed = path.replace(/\/+$/, '');
return trimmed.slice(trimmed.lastIndexOf('/') + 1) || trimmed;
};
const normalizeProjectPath = (path: string): string =>
path.replace(/\\/g, '/').replace(/\/+$/, '');
/** Project label for a session directory: longest matching project's name, else the folder name. */
const projectLabelForDirectory = (directory: string | null, projects: ProjectEntry[]): string => {
if (!directory) return '';
let best: ProjectEntry | null = null;
let bestLen = -1;
for (const project of projects) {
const projectPath = normalizeProjectPath(project.path);
if (directory === projectPath || directory.startsWith(`${projectPath}/`)) {
if (projectPath.length > bestLen) {
best = project;
bestLen = projectPath.length;
}
}
}
if (best) {
return best.label?.trim() || basename(best.path);
}
return basename(directory);
};
2026-08-13 15:30:54 +03:00
const buildMobileWidgetSnapshot = (): MobileWidgetSnapshot => {
const sessions = useGlobalSessionsStore.getState().activeSessions;
const unseenBySession = useNotificationStore.getState().index.session.unseenCount;
const notifyOnSubtasks = useUIStore.getState().notifyOnSubtasks;
const projects = useProjectsStore.getState().projects;
const pinnedSessionIds = useSessionPinnedStore.getState().ids;
const sessionOrderRanks = useSessionOrderingStore.getState().rankById;
let attentionCount = 0;
const topLevel: Array<{ session: Session; unread: boolean; project: string }> = [];
for (const session of sessions) {
const isSubtask = parentIdOf(session) !== null;
const unseenCount = unseenBySession[session.id] ?? 0;
const needsAttention = unseenCount > 0 && (!isSubtask || notifyOnSubtasks);
if (needsAttention) {
attentionCount += 1;
}
if (!isSubtask) {
topLevel.push({
session,
unread: needsAttention,
project: projectLabelForDirectory(resolveGlobalSessionDirectory(session), projects),
});
}
}
topLevel.sort((a, b) => compareSessionsByLifecycleOrder(a.session, b.session, pinnedSessionIds, sessionOrderRanks));
const recentSessions = topLevel
.slice(0, RECENT_LIMIT)
.map(({ session, unread, project }) => ({ id: session.id, title: session.title ?? '', unread, project }));
return { runtimeKey: getRuntimeKey(), attentionCount, recentSessions };
};
const SNAPSHOT_GLOBAL_KEY = '__OPENCHAMBER_WIDGET_SNAPSHOT__';
/**
* Exposes the snapshot builder on `window` so the native shell can read it synchronously via
* `evaluateJavaScript`. Returns a JSON string (the bridge wants a primitive result) or `null`
* if building fails, so the native side can skip writing on error rather than clobber a good
* snapshot. Safe to call in any runtime; only the native iOS shell ever invokes it.
*/
export const installMobileWidgetSnapshotBridge = (): void => {
if (typeof window === 'undefined') {
return;
}
(window as typeof window & { [SNAPSHOT_GLOBAL_KEY]?: () => string | null })[SNAPSHOT_GLOBAL_KEY] = () => {
try {
return JSON.stringify(buildMobileWidgetSnapshot());
} catch {
return null;
}
};
};