Files
openchamber/packages/ui/src/apps/deepLinkNavigation.ts
T
Serhii DziupinandSerhii Dziupin 86e6a2ae76 Remove verified dead declarations (#2714)
* chore: remove verified dead declarations

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* chore: narrow unused internal exports

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* chore: remove newly exposed dead helpers

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* chore: remove unused deep-link serializer

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: drop two tests that assert on copies of the code

mainLayoutMobileSidebarMount read MainLayout.tsx and SessionSidebar.tsx as
strings and asserted on source substrings down to exact indentation, so it
failed on formatting rather than behaviour. useProjectSessionSelection.test
reimplemented the hook's visitNodes logic inside the test file and asserted
against that copy, so it could not observe the hook at all.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: repair sync suites that had rotted while unrunnable

No runner executed packages/ui, so these drifted from the source unnoticed:
two imported helpers that are no longer exported, one directory-store stub
predated the session field routeMessage reads, and the WebSocket fake missed
the mandatory url-token mint plus the close event the socket wrapper reads.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: stop the web suite failing on timeouts and a hand-copied mock

The Git suites drive a real git binary, so the 5s default made a valid suite
fail differently per run. The gitApiHttp mock listed ~70 export names by hand
and fell behind the source; it now derives every stub from the real module,
which the added shared-UI aliases make resolvable.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: run every suite from one command and in CI

packages/ui (232 files) and packages/vscode (22) had no test script at all, CI
ran neither, and 9 vscode files could never run because Node cannot resolve
their extensionless TypeScript imports. Three electron files sat outside every
script list, one of them importing vitest, which that package does not depend
on. A runner gives each file its own process, since these suites keep
module-level singletons and fail by load order when sharing one.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* chore: delete a superseded repro harness and a completed plan

The issue-2638 harness needed lsof, overrode process.platform and spawned real
servers, and nothing referenced it; event-stream/rebind.test.js now covers the
same hub-pinned-to-the-old-port behaviour. The pairing v2 plan described relay
and the pairing UI as out of scope, both of which shipped.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* docs: point at the theme tools and record the github barrel invariant

convert-vscode-theme and harmonize-theme were referenced nowhere, so the
theme-authoring reference now names them. The github barrel is loaded through
await import('./index.js') and destructured per route, which no static report
can see; documenting that is what stops the next cleanup from deleting it.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: repair merge drift in bridge and route-registry mocks

upstream/main gained upsertProviderConfig on bridge-system-runtime and a
PATCH scheduled-task route after this branch forked. Their test doubles
were never updated to match:
- bridge-system-runtime.test.js: add upsertProviderConfig to the
  opencodeConfig mock so the import resolves.
- sse-routes.test.js: add app.patch to the route registry stub.

---------

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
2026-08-13 15:30:54 +03:00

195 lines
6.8 KiB
TypeScript

import React from 'react';
import { isCapacitorApp } from '@/lib/platform';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { 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. */
const applyDeepLinkIntent = (intent: DeepLinkIntent): void => {
pending = intent;
flush();
};
/** Convenience: parse a raw `openchamber://…` URL and apply it. No-op for unrecognised URLs. */
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());
};
}, []);
};