Navigation model rebuilt around two full-width drawers and a minimal header (sessions / title-switcher / usage ring / workspace): - Left sessions drawer: cross-project tree with live status indicators, swipe actions on sessions (rename/archive/delete) and on group headers (project edit / two-step close, worktree delete), reorder-only edit mode with collapsible project cards and draggable worktrees, app-level footer (connected instance, settings, pending web update). - Right workspace drawer: Changes / Files / Terminal / Notes / MCP as pill tabs (inactive tabs icon-only); panes stay mounted once visited. The full desktop file editor serves the Files tab; read/skill tool taps in chat open the file there at the requested line. - Header session switcher on title tap: 10 cross-project recents with live busy/attention indicators and project · branch metadata; the usage ring opens a metadata overlay with an explicit loading state. - The overflow menu is gone on phones (its destinations moved into the drawers); iPad keeps it until its dedicated layout pass. Correctness and continuity: - /auth/session answers bearer-first, so a stale WebView cookie can no longer mask a revoked device token; cold launches classify failures fast and land on an explicit connect screen. - Authoritative session snapshots raise frozen ordering baselines and stale live ranks — recents stay truthful after the app slept. - Cold launches reopen the last active session per instance (persisted pointer, confirmed against a sessions snapshot; a user-opened draft clears it), with a logo hold instead of a draft flash. Also: collapsed pill composer gains the stop control; chat tool rows share one 36px rhythm; Task subtool rows truncate; larger bottom safe area so the composer clears big-screen corner radii; Capacitor build hides About/Update (store updates apply there); widgets link to the sessions drawer with a list icon; MobileApp split into focused modules; five mobile-surface detectors unified; translucent borders normalized to 70%; all new strings translated across the 10 locales. iPad and foldable layouts are intentionally untouched - separate next version PR.
199 lines
7.0 KiB
TypeScript
199 lines
7.0 KiB
TypeScript
import React from 'react';
|
|
|
|
import { isCapacitorApp } from '@/lib/platform';
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
|
|
|
import { buildDeepLink, parseDeepLink, type DeepLinkIntent, type SessionsFilter, type ViewTarget } from './deepLinks';
|
|
|
|
/**
|
|
* Navigation layer for {@link DeepLinkIntent}s — the only place that knows how to *apply* a
|
|
* deep link. Producers (notification taps, widget `widgetURL`, Live Activities) feed intents
|
|
* in via {@link useDeepLinkSource}; the surfaces that can satisfy them register imperative
|
|
* handlers via {@link useDeepLinkHandlers}. Session/new-session navigation goes straight to
|
|
* the session store (always available), so those resolve even before the shell has mounted.
|
|
*
|
|
* Intents that arrive before the app is ready (cold launch from a tap/widget) or before their
|
|
* handler is registered are stashed in a module-level holder that survives the connect flow
|
|
* and SyncProvider remount, then applied as soon as the app becomes ready / the handler
|
|
* appears. Only the most recent intent is kept (newest wins) — a burst of taps shouldn't queue.
|
|
*/
|
|
|
|
export interface DeepLinkHandlers {
|
|
/** Open the sessions sheet, optionally pre-filtered (filter support is best-effort for now). */
|
|
openSessions?: (filter?: SessionsFilter) => void;
|
|
/** Open a non-session surface (files / mcp / instances / update). */
|
|
openView?: (target: ViewTarget) => void;
|
|
/** Open the Changes surface, optionally jumping straight to a file diff. */
|
|
openChanges?: (options?: { path?: string; staged?: boolean }) => void;
|
|
/** Open Settings, optionally at a specific section. */
|
|
openSettings?: (section?: string) => void;
|
|
}
|
|
|
|
let handlers: DeepLinkHandlers = {};
|
|
let ready = false;
|
|
let pending: DeepLinkIntent | null = null;
|
|
|
|
const execute = (intent: DeepLinkIntent): boolean => {
|
|
switch (intent.type) {
|
|
case 'session':
|
|
void useSessionUIStore.getState().setCurrentSession(intent.sessionId, intent.directory ?? null);
|
|
return true;
|
|
|
|
case 'new-session': {
|
|
const store = useSessionUIStore.getState();
|
|
store.openNewSessionDraft();
|
|
if (intent.directory || intent.projectId) {
|
|
store.setNewSessionDraftTarget({
|
|
directoryOverride: intent.directory ?? null,
|
|
projectId: intent.projectId ?? null,
|
|
selectedProjectId: intent.projectId ?? null,
|
|
});
|
|
}
|
|
return true;
|
|
}
|
|
|
|
case 'sessions':
|
|
if (!handlers.openSessions) return false;
|
|
handlers.openSessions(intent.filter);
|
|
return true;
|
|
|
|
case 'status':
|
|
// The old input-bar status panel is gone — recent sessions with statuses
|
|
// now live in the sessions drawer, so route status links there.
|
|
if (!handlers.openSessions) return false;
|
|
handlers.openSessions();
|
|
return true;
|
|
|
|
case 'view':
|
|
if (!handlers.openView) return false;
|
|
handlers.openView(intent.target);
|
|
return true;
|
|
|
|
case 'changes':
|
|
if (!handlers.openChanges) return false;
|
|
handlers.openChanges({ path: intent.path, staged: intent.staged });
|
|
return true;
|
|
|
|
case 'settings':
|
|
if (!handlers.openSettings) return false;
|
|
handlers.openSettings(intent.section);
|
|
return true;
|
|
}
|
|
};
|
|
|
|
const flush = (): void => {
|
|
if (!ready || !pending) return;
|
|
const intent = pending;
|
|
// Drop the stash before executing; if the handler isn't registered yet, execute() returns
|
|
// false and we re-stash so a later registerDeepLinkHandlers() flush can retry it.
|
|
pending = null;
|
|
if (!execute(intent)) {
|
|
pending = intent;
|
|
}
|
|
};
|
|
|
|
/** Apply an intent now if possible, otherwise stash it until the app is ready / a handler appears. */
|
|
export const applyDeepLinkIntent = (intent: DeepLinkIntent): void => {
|
|
pending = intent;
|
|
flush();
|
|
};
|
|
|
|
/** Convenience: parse a raw `openchamber://…` URL and apply it. No-op for unrecognised URLs. */
|
|
export const applyDeepLinkUrl = (raw: string | null | undefined): void => {
|
|
const intent = parseDeepLink(raw);
|
|
if (intent) {
|
|
applyDeepLinkIntent(intent);
|
|
}
|
|
};
|
|
|
|
const setReady = (value: boolean): void => {
|
|
ready = value;
|
|
flush();
|
|
};
|
|
|
|
/**
|
|
* Register the surfaces that can satisfy shell-scoped intents (sessions/settings/views/changes).
|
|
* Call from the component that owns those panels; the handlers are torn down on unmount.
|
|
* Registering also flushes any pending intent that was waiting for these handlers.
|
|
*/
|
|
export const useDeepLinkHandlers = (next: DeepLinkHandlers): void => {
|
|
React.useEffect(() => {
|
|
handlers = next;
|
|
flush();
|
|
return () => {
|
|
if (handlers === next) {
|
|
handlers = {};
|
|
}
|
|
};
|
|
}, [next]);
|
|
};
|
|
|
|
/**
|
|
* Single native entry point for deep links. Subscribes to both the custom URL scheme
|
|
* (`App.appUrlOpen` — widgets, Live Activities, external links) and notification taps
|
|
* (`pushNotificationActionPerformed`), normalising each into a {@link DeepLinkIntent}.
|
|
* Both listeners are registered UNCONDITIONALLY so a cold-launch tap/open isn't lost while
|
|
* the app is still connecting; intents stash until `ready` (connected + initialized).
|
|
*/
|
|
export const useDeepLinkSource = (options: { ready: boolean }): void => {
|
|
const { ready: isReady } = options;
|
|
|
|
React.useEffect(() => {
|
|
setReady(isReady);
|
|
}, [isReady]);
|
|
|
|
React.useEffect(() => {
|
|
if (!isCapacitorApp()) return;
|
|
let disposed = false;
|
|
const cleanup: Array<() => void> = [];
|
|
|
|
void import('@capacitor/app')
|
|
.then(async ({ App }) => {
|
|
if (disposed) return;
|
|
const handle = await App.addListener('appUrlOpen', (event) => {
|
|
applyDeepLinkUrl(event?.url);
|
|
});
|
|
if (disposed) {
|
|
void handle.remove();
|
|
return;
|
|
}
|
|
cleanup.push(() => void handle.remove());
|
|
})
|
|
.catch(() => undefined);
|
|
|
|
void import('@capacitor/push-notifications')
|
|
.then(async ({ PushNotifications }) => {
|
|
if (disposed) return;
|
|
const handle = await PushNotifications.addListener('pushNotificationActionPerformed', (action) => {
|
|
const data = action?.notification?.data as Record<string, unknown> | undefined;
|
|
// Prefer an explicit deep link in the payload (richest); fall back to a bare
|
|
// sessionId for backwards compatibility with existing push senders.
|
|
const url = typeof data?.url === 'string' ? data.url : typeof data?.deeplink === 'string' ? data.deeplink : undefined;
|
|
if (url) {
|
|
applyDeepLinkUrl(url);
|
|
return;
|
|
}
|
|
const sessionId = typeof data?.sessionId === 'string' ? data.sessionId : undefined;
|
|
if (sessionId) {
|
|
applyDeepLinkIntent({ type: 'session', sessionId });
|
|
}
|
|
});
|
|
if (disposed) {
|
|
void handle.remove();
|
|
return;
|
|
}
|
|
cleanup.push(() => void handle.remove());
|
|
})
|
|
.catch(() => undefined);
|
|
|
|
return () => {
|
|
disposed = true;
|
|
cleanup.forEach((remove) => remove());
|
|
};
|
|
}, []);
|
|
};
|
|
|
|
// Re-export so producers (notifications, future widgets) have one import for the whole vocabulary.
|
|
export { buildDeepLink, parseDeepLink };
|
|
export type { DeepLinkIntent, SessionsFilter, ViewTarget };
|