Files
openchamber/packages/ui/src/apps/mobileWidgetSnapshot.ts
T
Bohdan Triapitsyn 89f7c37d60 fix: make session ordering follow activity lifecycle
Session lists now promote a conversation when it starts working and again when it settles, instead of reacting to every streaming timestamp update. This keeps ordering responsive without bringing back the sidebar churn removed by the recent performance work.

Apply the same user-visible order across Recent, project and worktree groups, session switchers, mobile navigation, widgets, the command palette, and the desktop tray. Preserve pinned priority, freeze timestamp fallback ordering, and keep child-session activity scoped to siblings under the same parent so it never moves the root conversation.

Seed reconnect snapshots without synthetic jumps, clear ephemeral ranks on deletion and runtime changes, and cover lifecycle transitions, mixed root/child trees, metadata-only updates, and project-group ordering with regression tests.
2026-07-23 15:45:53 +03:00

128 lines
5.1 KiB
TypeScript

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);
};
export 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;
}
};
};