2026-07-01 09:55:41 +03:00
|
|
|
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';
|
2026-07-23 15:45:53 +03:00
|
|
|
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
|
2026-07-01 09:55:41 +03:00
|
|
|
import { useNotificationStore } from '@/sync/notification-store';
|
2026-07-23 15:45:53 +03:00
|
|
|
import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering';
|
2026-07-21 20:52:20 +03:00
|
|
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
2026-07-01 09:55:41 +03:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 {
|
2026-07-21 20:52:20 +03:00
|
|
|
/** Runtime instance that owns all session IDs and paths in this snapshot. */
|
|
|
|
|
runtimeKey: string;
|
2026-07-01 09:55:41 +03:00
|
|
|
/** Count of sessions needing attention — same signal that drives the app-icon badge. */
|
|
|
|
|
attentionCount: number;
|
2026-07-23 15:45:53 +03:00
|
|
|
/** Top-level sessions in the app's shared lifecycle order (capped for the medium widget). */
|
2026-07-01 09:55:41 +03:00
|
|
|
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 => {
|
2026-07-01 09:55:41 +03:00
|
|
|
const sessions = useGlobalSessionsStore.getState().activeSessions;
|
|
|
|
|
const unseenBySession = useNotificationStore.getState().index.session.unseenCount;
|
|
|
|
|
const notifyOnSubtasks = useUIStore.getState().notifyOnSubtasks;
|
|
|
|
|
const projects = useProjectsStore.getState().projects;
|
2026-07-23 15:45:53 +03:00
|
|
|
const pinnedSessionIds = useSessionPinnedStore.getState().ids;
|
|
|
|
|
const sessionOrderRanks = useSessionOrderingStore.getState().rankById;
|
2026-07-01 09:55:41 +03:00
|
|
|
|
|
|
|
|
let attentionCount = 0;
|
2026-07-23 15:45:53 +03:00
|
|
|
const topLevel: Array<{ session: Session; unread: boolean; project: string }> = [];
|
2026-07-01 09:55:41 +03:00
|
|
|
|
|
|
|
|
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({
|
2026-07-23 15:45:53 +03:00
|
|
|
session,
|
2026-07-01 09:55:41 +03:00
|
|
|
unread: needsAttention,
|
|
|
|
|
project: projectLabelForDirectory(resolveGlobalSessionDirectory(session), projects),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 15:45:53 +03:00
|
|
|
topLevel.sort((a, b) => compareSessionsByLifecycleOrder(a.session, b.session, pinnedSessionIds, sessionOrderRanks));
|
2026-07-01 09:55:41 +03:00
|
|
|
const recentSessions = topLevel
|
|
|
|
|
.slice(0, RECENT_LIMIT)
|
2026-07-23 15:45:53 +03:00
|
|
|
.map(({ session, unread, project }) => ({ id: session.id, title: session.title ?? '', unread, project }));
|
2026-07-01 09:55:41 +03:00
|
|
|
|
2026-07-21 20:52:20 +03:00
|
|
|
return { runtimeKey: getRuntimeKey(), attentionCount, recentSessions };
|
2026-07-01 09:55:41 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
};
|