fix electron desktop ai runtime fallback detection

This commit is contained in:
dirtydishes 2026-05-20 20:11:37 -04:00
parent a54e847c8e
commit d15f96d7be
4 changed files with 390 additions and 47 deletions

View file

@ -19,6 +19,7 @@
{"_type":"issue","id":"islandflow-ayo","title":"Drop stale backlog events from live fanout","description":"Follow-up to live freshness rollout: /ws/live was still fanning out stale backlog events for freshness-gated channels, which kept tape panes in Live feed behind despite active synthetic ingest. Gate fanout and cache ingest by freshness for options/nbbo/equities/flow.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:26:39Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:26:44Z","started_at":"2026-04-28T21:26:44Z","closed_at":"2026-04-28T21:26:44Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-0v6","title":"Fix tape freshness, NBBO coverage, pause controls, and filter popup","description":"Implement the tape fixes requested for synthetic options notional sizing, strict live freshness, live-mode pause/resume behavior, stronger NBBO snapshot coverage, and moving flow filters behind a popup. Includes server-side live cache changes, web terminal state/UI changes, and tests for synthetic pricing, live snapshot freshness/NBBO retention, and live pause/filter interactions.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:02:52Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:13:38Z","started_at":"2026-04-28T21:02:57Z","closed_at":"2026-04-28T21:13:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-e4r","title":"Implement smart-money flow filtering and synthetic firehose modes","description":"Implement the approved multi-surface plan for named synthetic market profiles, options raw-vs-signal filtering, live/API filter contracts, Tape page client-side flow filters, firehose-readiness improvements, tests, and README updates.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:10:49Z","created_by":"dirtydishes","updated_at":"2026-04-28T20:29:29Z","started_at":"2026-04-28T20:10:53Z","closed_at":"2026-04-28T20:29:29Z","close_reason":"Implemented synthetic market profiles, options signal-path filtering, signal-aware API/replay contracts, Tape page filters, tests, and README updates. Follow-up tracked in islandflow-biq.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-y7b","title":"Fix false browser fallback in Electron renderer","description":"Why this issue exists and what needs to be done:\\nElectron sessions can briefly or permanently render browser-only fallback copy when runtime detection depends on async desktop AI state loading.\\n\\nImplement a runtime snapshot that is resolved synchronously on the client (shell marker + bridge presence) and kept independent from bridge.ai state fetch/subscribe behavior. Add bounded runtime resync/retry and lifecycle-triggered resync on focus/pageshow so late bridge exposure flips to desktop mode.\\n\\nUpdate desktop-ai tests to cover: runtime marker present before AI state resolves, bridge present with pending/rejected getState, and late runtime availability. Keep preload/IPC contract unchanged unless a verified failure requires it.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-21T00:06:52Z","created_by":"dirtydishes","updated_at":"2026-05-21T00:11:21Z","started_at":"2026-05-21T00:06:55Z","closed_at":"2026-05-21T00:11:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-xtg","title":"implement ai alert copilot ux refinements","description":"Implement the AI alert Copilot UX plan: markdown result rendering, reusable task result states, in-session result caching with regenerate, task cancellation through the desktop bridge, tests, and required turn documentation.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-20T23:30:50Z","created_by":"dirtydishes","updated_at":"2026-05-20T23:37:58Z","started_at":"2026-05-20T23:30:58Z","closed_at":"2026-05-20T23:37:58Z","close_reason":"Implemented markdown Copilot rendering, session result caching, regenerate controls, task cancellation plumbing, tests, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-dy2","title":"Clarify desktop AI settings when bridge is unavailable","description":"The /settings desktop AI panel currently renders disabled ChatGPT login buttons and empty-feeling model controls when the native bridge is unavailable. Users read this as broken UI because the controls do not clearly explain that the desktop shell is missing its bridge session and therefore cannot load login or model options. Update the settings surface to explain the unavailable state, provide direct recovery guidance, and make disabled controls self-explanatory.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-20T22:56:03Z","created_by":"dirtydishes","updated_at":"2026-05-20T23:01:33Z","started_at":"2026-05-20T22:56:26Z","closed_at":"2026-05-20T23:01:33Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-c8f","title":"fix packages/types ts-extension imports for next build","description":"## Why\\nThe web production build fails during type-checking because packages/types/src/desktop-ai.ts imports sibling files with explicit .ts extensions, which Next's TypeScript config rejects without allowImportingTsExtensions.\\n\\n## What\\nNormalize the packages/types import specifiers so Next can type-check the shared package during app builds, or adjust the shared tsconfig/build strategy in a deliberate way.\\n\\n## Acceptance Criteria\\n- bun --cwd=apps/web run build no longer fails on .ts-extension import paths from packages/types\\n- The chosen import-specifier strategy is consistent across packages/types","status":"open","priority":2,"issue_type":"bug","owner":"dishes@dpdrm.com","created_at":"2026-05-20T22:35:30Z","created_by":"dirtydishes","updated_at":"2026-05-20T22:35:30Z","dependency_count":0,"dependent_count":0,"comment_count":0}

View file

@ -19,6 +19,20 @@ import {
} from "./desktop-ai-panels";
describe("desktop ai runtime detection", () => {
const createBridge = (getState: () => Promise<ReturnType<typeof createUnavailableState>>) => ({
ai: {
getState,
loginWithBrowser: async () => {},
loginWithDeviceCode: async () => {},
cancelLogin: async () => {},
logout: async () => {},
updatePreferences: async () => {},
runTask: async () => ({ taskId: "task-1" }),
cancelTask: async () => {},
subscribe: () => () => {},
},
});
it("recognizes the explicit desktop preload marker before the bridge is available", () => {
const runtime = resolveDesktopAiRuntime({
islandflowDesktopRuntime: {
@ -73,6 +87,88 @@ describe("desktop ai runtime detection", () => {
expect(runtime.bridgeAvailable).toBe(true);
expect(runtime.bridge).not.toBeNull();
});
it("keeps desktop runtime when the bridge exists before ai state resolves", () => {
let resolveState: ((value: ReturnType<typeof createUnavailableState>) => void) | null = null;
const pending = new Promise<ReturnType<typeof createUnavailableState>>((resolve) => {
resolveState = resolve;
});
const runtime = resolveDesktopAiRuntime({
islandflowDesktopRuntime: {
app: "islandflow",
shell: "electron",
},
islandflowDesktop: createBridge(() => pending),
navigator: { userAgent: "Mozilla/5.0 Safari/537.36" },
});
expect(runtime.shellAvailable).toBe(true);
expect(runtime.bridgeAvailable).toBe(true);
expect(runtime.bridge).not.toBeNull();
expect(
requireDesktopActionCopy(
runtime.shellAvailable,
runtime.bridgeAvailable,
false,
),
).toContain("Connect a ChatGPT or Codex account");
resolveState?.(
createUnavailableState({
shellAvailable: true,
bridgeAvailable: true,
}),
);
});
it("keeps bridge-specific recovery copy when bridge state loading fails", async () => {
const runtime = resolveDesktopAiRuntime({
islandflowDesktopRuntime: {
app: "islandflow",
shell: "electron",
},
islandflowDesktop: createBridge(async () => {
throw new Error("state failed");
}),
navigator: { userAgent: "Mozilla/5.0 Safari/537.36" },
});
expect(runtime.shellAvailable).toBe(true);
expect(runtime.bridgeAvailable).toBe(true);
expect(createUnavailableState(runtime).transportError).toContain(
"initial state could not be read",
);
expect(
requireDesktopActionCopy(
runtime.shellAvailable,
runtime.bridgeAvailable,
false,
),
).not.toContain("Open Islandflow Desktop");
});
it("flips from browser runtime to desktop runtime when the marker appears later", () => {
const host: {
islandflowDesktopRuntime?: { app?: string | null; shell?: string | null };
navigator: { userAgent: string };
} = {
navigator: { userAgent: "Mozilla/5.0 Safari/537.36" },
};
const initialRuntime = resolveDesktopAiRuntime(host);
expect(initialRuntime.shellAvailable).toBe(false);
expect(initialRuntime.bridgeAvailable).toBe(false);
host.islandflowDesktopRuntime = {
app: "islandflow",
shell: "electron",
};
const nextRuntime = resolveDesktopAiRuntime(host);
expect(nextRuntime.shellAvailable).toBe(true);
expect(nextRuntime.bridgeAvailable).toBe(false);
});
});
describe("desktop ai unavailable state", () => {

View file

@ -36,6 +36,12 @@ type DesktopAiRuntime = {
bridge: DesktopAiBridge | null;
};
const BROWSER_RUNTIME: DesktopAiRuntime = {
shellAvailable: false,
bridgeAvailable: false,
bridge: null
};
declare global {
interface Window {
islandflowDesktopRuntime?: {
@ -105,6 +111,17 @@ export const resolveDesktopAiRuntime = (
};
};
export const resolveCurrentDesktopAiRuntime = (): DesktopAiRuntime =>
typeof window === "undefined" ? BROWSER_RUNTIME : resolveDesktopAiRuntime(window);
const isSameDesktopAiRuntime = (
left: DesktopAiRuntime,
right: DesktopAiRuntime
): boolean =>
left.shellAvailable === right.shellAvailable &&
left.bridgeAvailable === right.bridgeAvailable &&
left.bridge === right.bridge;
export const createUnavailableState = (runtime?: Partial<DesktopAiRuntime>): IslandflowAiState => {
const shellAvailable = Boolean(runtime?.shellAvailable || runtime?.bridgeAvailable);
const bridgeAvailable = Boolean(runtime?.bridgeAvailable);
@ -193,9 +210,11 @@ const rejectDesktopOnly = async (): Promise<never> => {
};
export function DesktopAiProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<IslandflowAiState>(() => createUnavailableState());
const [bridge, setBridge] = useState<DesktopAiBridge | null>(null);
const [shellAvailable, setShellAvailable] = useState(false);
const initialRuntime = resolveCurrentDesktopAiRuntime();
const [runtime, setRuntime] = useState<DesktopAiRuntime>(initialRuntime);
const [state, setState] = useState<IslandflowAiState>(() =>
createUnavailableState(initialRuntime)
);
useEffect(() => {
if (typeof window === "undefined") {
@ -203,52 +222,69 @@ export function DesktopAiProvider({ children }: { children: ReactNode }) {
}
let disposed = false;
let activeBridge: DesktopAiBridge | null = null;
let unsubscribe = () => {};
let pollTimer: number | null = null;
let attempts = 0;
const clearPendingPoll = () => {
if (pollTimer !== null) {
window.clearTimeout(pollTimer);
pollTimer = null;
}
};
const connectBridge = (runtime: DesktopAiRuntime): boolean => {
if (!runtime.bridge) {
return false;
const replaceRuntime = (nextRuntime: DesktopAiRuntime) => {
setRuntime((currentRuntime) =>
isSameDesktopAiRuntime(currentRuntime, nextRuntime) ? currentRuntime : nextRuntime
);
};
const attachBridge = (nextBridge: DesktopAiBridge | null, runtimeSnapshot: DesktopAiRuntime) => {
if (activeBridge === nextBridge) {
return;
}
setShellAvailable(runtime.shellAvailable);
setBridge(runtime.bridge);
void runtime.bridge.ai.getState().then(
unsubscribe();
unsubscribe = () => {};
activeBridge = nextBridge;
if (!nextBridge) {
return;
}
const bridgeAtAttach = nextBridge;
void bridgeAtAttach.ai.getState().then(
(nextState) => {
if (!disposed) {
if (!disposed && activeBridge === bridgeAtAttach) {
setState(nextState);
}
},
() => {
if (!disposed) {
setState(createUnavailableState(runtime));
if (!disposed && activeBridge === bridgeAtAttach) {
setState(createUnavailableState(runtimeSnapshot));
}
}
);
unsubscribe = runtime.bridge.ai.subscribe((nextState) => {
if (!disposed) {
unsubscribe = bridgeAtAttach.ai.subscribe((nextState) => {
if (!disposed && activeBridge === bridgeAtAttach) {
setState(nextState);
}
});
return true;
};
const syncRuntime = (): boolean => {
const runtime = resolveDesktopAiRuntime(window);
setShellAvailable(runtime.shellAvailable);
if (connectBridge(runtime)) {
const nextRuntime = resolveDesktopAiRuntime(window);
replaceRuntime(nextRuntime);
if (nextRuntime.bridge) {
attachBridge(nextRuntime.bridge, nextRuntime);
return true;
}
setBridge(null);
setState(createUnavailableState(runtime));
attachBridge(null, nextRuntime);
setState(createUnavailableState(nextRuntime));
return false;
};
if (!syncRuntime()) {
const pollForBridge = () => {
if (disposed) {
return;
@ -256,38 +292,54 @@ export function DesktopAiProvider({ children }: { children: ReactNode }) {
attempts += 1;
if (syncRuntime() || attempts >= BRIDGE_POLL_MAX_ATTEMPTS) {
clearPendingPoll();
return;
}
pollTimer = window.setTimeout(pollForBridge, BRIDGE_POLL_INTERVAL_MS);
};
const startRetryWindow = () => {
attempts = 0;
clearPendingPoll();
pollTimer = window.setTimeout(pollForBridge, BRIDGE_POLL_INTERVAL_MS);
};
const onWindowLifecycle = () => {
if (!syncRuntime()) {
startRetryWindow();
}
};
window.addEventListener("focus", onWindowLifecycle);
window.addEventListener("pageshow", onWindowLifecycle);
if (!syncRuntime()) {
startRetryWindow();
}
return () => {
disposed = true;
if (pollTimer !== null) {
window.clearTimeout(pollTimer);
}
clearPendingPoll();
window.removeEventListener("focus", onWindowLifecycle);
window.removeEventListener("pageshow", onWindowLifecycle);
unsubscribe();
};
}, []);
const value = useMemo<DesktopAiContextValue>(
() => ({
bridgeAvailable: Boolean(bridge?.ai),
shellAvailable,
bridgeAvailable: runtime.bridgeAvailable,
shellAvailable: runtime.shellAvailable,
state,
loginWithBrowser: bridge?.ai.loginWithBrowser ?? rejectDesktopOnly,
loginWithDeviceCode: bridge?.ai.loginWithDeviceCode ?? rejectDesktopOnly,
cancelLogin: bridge?.ai.cancelLogin ?? rejectDesktopOnly,
logout: bridge?.ai.logout ?? rejectDesktopOnly,
updatePreferences: bridge?.ai.updatePreferences ?? rejectDesktopOnly,
runTask: bridge?.ai.runTask ?? rejectDesktopOnly,
cancelTask: bridge?.ai.cancelTask ?? rejectDesktopOnly
loginWithBrowser: runtime.bridge?.ai.loginWithBrowser ?? rejectDesktopOnly,
loginWithDeviceCode: runtime.bridge?.ai.loginWithDeviceCode ?? rejectDesktopOnly,
cancelLogin: runtime.bridge?.ai.cancelLogin ?? rejectDesktopOnly,
logout: runtime.bridge?.ai.logout ?? rejectDesktopOnly,
updatePreferences: runtime.bridge?.ai.updatePreferences ?? rejectDesktopOnly,
runTask: runtime.bridge?.ai.runTask ?? rejectDesktopOnly,
cancelTask: runtime.bridge?.ai.cancelTask ?? rejectDesktopOnly
}),
[bridge, shellAvailable, state]
[runtime, state]
);
return <DesktopAiContext.Provider value={value}>{children}</DesktopAiContext.Provider>;

View file

@ -0,0 +1,194 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Turn Doc - Fix False Browser Fallback In Electron</title>
<style>
:root {
color-scheme: light;
--bg: #f5f7fb;
--panel: #ffffff;
--text: #142032;
--muted: #4d607a;
--accent: #2359d1;
--border: #d9e2f0;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
}
main {
max-width: 980px;
margin: 0 auto;
padding: 2rem 1.25rem 3rem;
}
h1, h2 { line-height: 1.25; }
h1 {
margin: 0 0 0.4rem;
font-size: 1.9rem;
}
.lede {
margin: 0 0 1.5rem;
color: var(--muted);
}
section {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1rem 1.1rem;
margin: 0 0 0.9rem;
}
h2 { margin: 0 0 0.55rem; font-size: 1.1rem; }
p, li { font-size: 0.98rem; }
ul { margin: 0.4rem 0 0 1.25rem; padding: 0; }
code, pre {
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
}
pre {
background: #0f1725;
color: #d5e3ff;
border-radius: 10px;
padding: 0.85rem;
overflow: auto;
margin: 0.55rem 0 0;
}
.meta {
display: inline-block;
color: var(--muted);
font-size: 0.9rem;
margin-bottom: 0.75rem;
}
a { color: var(--accent); }
</style>
</head>
<body>
<main>
<h1>Fix False Browser Fallback In Electron</h1>
<p class="meta">Date: 2026-05-20 · Issue: islandflow-y7b</p>
<p class="lede">
Updated the desktop AI runtime handling in the web renderer so Electron runtime detection is treated as an immediate signal,
independent from async bridge state loading, which prevents false browser-only fallback UI in desktop windows.
</p>
<section>
<h2>Summary</h2>
<p>
The renderer now initializes desktop shell and bridge availability from a synchronous runtime snapshot, then handles
AI state loading separately. If state loading hangs or fails, the UI remains in desktop mode and shows bridge/login guidance
instead of browser-only "open desktop app" fallback messaging.
</p>
</section>
<section>
<h2>Changes Made</h2>
<ul>
<li>Refactored <code>DesktopAiProvider</code> to initialize from <code>resolveCurrentDesktopAiRuntime()</code> instead of browser-first defaults.</li>
<li>Separated runtime synchronization from <code>bridge.ai.getState()</code> and subscription state flow.</li>
<li>Added guarded bridge attachment logic to avoid duplicate subscriptions across runtime resync events.</li>
<li>Added bounded runtime retry window plus lifecycle resync hooks on <code>focus</code> and <code>pageshow</code>.</li>
<li>Extended runtime regression coverage in <code>apps/web/app/desktop-ai.test.ts</code> for pending/rejecting bridge state and late marker availability.</li>
</ul>
</section>
<section>
<h2>Context</h2>
<p>
In some Electron sessions, the settings UI could render browser-only fallback copy before or during bridge startup.
The root issue was coupling runtime identity and async bridge state fetch outcomes. This patch keeps runtime identity
authoritative even when bridge state is delayed or unavailable.
</p>
</section>
<section>
<h2>Important Implementation Details</h2>
<ul>
<li><code>BROWSER_RUNTIME</code> provides explicit non-desktop defaults only when no client window exists.</li>
<li><code>isSameDesktopAiRuntime()</code> avoids unnecessary runtime state churn on repeated sync checks.</li>
<li><code>attachBridge()</code> tracks active bridge identity and ignores stale async callbacks from superseded bridges.</li>
<li>When runtime is desktop but bridge is missing, unavailable state remains desktop-scoped and never browser-scoped.</li>
</ul>
</section>
<section>
<h2>Relevant Diff Snippets</h2>
<p>Rendered in standard unified diff format compatible with <a href="https://diffs.com/docs">diffs.com</a>.</p>
<pre><code class="language-diff">diff --git a/apps/web/app/desktop-ai.tsx b/apps/web/app/desktop-ai.tsx
@@
+export const resolveCurrentDesktopAiRuntime = (): DesktopAiRuntime =&gt;
+ typeof window === "undefined" ? BROWSER_RUNTIME : resolveDesktopAiRuntime(window);
@@
- const [state, setState] = useState&lt;IslandflowAiState&gt;(() =&gt; createUnavailableState());
- const [bridge, setBridge] = useState&lt;DesktopAiBridge | null&gt;(null);
- const [shellAvailable, setShellAvailable] = useState(false);
+ const initialRuntime = resolveCurrentDesktopAiRuntime();
+ const [runtime, setRuntime] = useState&lt;DesktopAiRuntime&gt;(initialRuntime);
+ const [state, setState] = useState&lt;IslandflowAiState&gt;(() =&gt;
+ createUnavailableState(initialRuntime)
+ );
@@
+ window.addEventListener("focus", onWindowLifecycle);
+ window.addEventListener("pageshow", onWindowLifecycle);
</code></pre>
<pre><code class="language-diff">diff --git a/apps/web/app/desktop-ai.test.ts b/apps/web/app/desktop-ai.test.ts
@@
+ it("keeps desktop runtime when the bridge exists before ai state resolves", () =&gt; {
+ ...
+ expect(runtime.shellAvailable).toBe(true);
+ expect(runtime.bridgeAvailable).toBe(true);
+ });
+
+ it("keeps bridge-specific recovery copy when bridge state loading fails", async () =&gt; {
+ ...
+ expect(requireDesktopActionCopy(...)).not.toContain("Open Islandflow Desktop");
+ });
+
+ it("flips from browser runtime to desktop runtime when the marker appears later", () =&gt; {
+ ...
+ expect(nextRuntime.shellAvailable).toBe(true);
+ });
</code></pre>
</section>
<section>
<h2>Expected Impact for End-Users</h2>
<ul>
<li>Electron users should no longer see false browser-only "Desktop app required" fallback while already inside desktop shell.</li>
<li>When bridge state is delayed or fails, users see bridge/login recovery messaging that matches desktop context.</li>
<li>Pure browser sessions still correctly show desktop-required fallback copy.</li>
</ul>
</section>
<section>
<h2>Validation</h2>
<ul>
<li><code>bun test apps/web/app/desktop-ai.test.ts</code> (pass)</li>
<li><code>bun test apps/desktop</code> (pass)</li>
<li><code>bun --cwd=apps/web run build</code> (fails on pre-existing typecheck issue in <code>packages/types/src/desktop-ai.ts</code> using <code>.ts</code> import-path extensions)</li>
</ul>
</section>
<section>
<h2>Issues, Limitations, and Mitigations</h2>
<ul>
<li>Manual Electron UI validation at <code>127.0.0.1:3000/settings</code> was not executed in this CLI pass.</li>
<li>Web production build remains blocked by an unrelated repository typecheck issue outside the touched files.</li>
<li>Mitigation: runtime behavior is covered with focused regression tests and desktop-side test suite remains green.</li>
</ul>
</section>
<section>
<h2>Follow-up Work</h2>
<ul>
<li>Perform manual Electron verification of settings copy and fallback pane behavior once local desktop app is running.</li>
<li>Resolve existing <code>packages/types</code> TypeScript extension import issue to restore green web production builds.</li>
<li>No additional follow-up issue created in this pass; current bug tracked as <code>islandflow-y7b</code>.</li>
</ul>
</section>
</main>
</body>
</html>