Files
openchamber/packages/ui/src/apps/deepLinkNavigation.ts
T
Bohdan Triapitsyn 86ef96302d feat(mobile): mobile app navigation rework and beta-feedback closeout (#2561)
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.
2026-08-01 21:16:36 +03:00

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